feat(@projects/@magic-civilization): add mcts strategic override logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 03:22:08 -07:00
parent 91b6386f23
commit c02c0767ab
7 changed files with 133 additions and 10 deletions

View file

@ -13,6 +13,8 @@ evidence:
- src/game/engine/src/autoloads/event_bus.gd
- src/game/engine/tests/unit/test_chronicle_coverage.gd
- src/game/engine/tests/unit/test_chronicle_filter_and_click.gd
- src/game/engine/scenes/tests/chronicle_filter_proof.tscn
- ~/Desktop/magic_civ_chronicle_filter.png
---
## Summary

View file

@ -32,6 +32,8 @@ func _ready() -> void:
)
panel._is_processing = false
panel._show_log()
# Prevent the 3-second auto-dismiss timer from hiding the log during capture.
panel._dismiss_timer.stop()
func _seed_players() -> void:

View file

@ -65,7 +65,10 @@ static func _apply_mcts_strategic_override(player: RefCounted) -> void:
ctrl.set_rollout_budget(MCTS_ROLLOUT_COUNT)
ctrl.set_rollout_depth(MCTS_ROLLOUT_DEPTH)
var json: String = _build_game_state_json(player)
var data_dir: String = ProjectSettings.globalize_path(
"res://public/games/age-of-dwarves/data"
)
var json: String = _build_game_state_json(player, ctrl, data_dir)
# Seed from turn + player index for determinism: same (turn, player) pair
# always produces the same MCTS path given the same game state.
var seed: int = GameState.turn_number * 1000 + player.index
@ -81,7 +84,11 @@ static func _apply_mcts_strategic_override(player: RefCounted) -> void:
## Build a minimal Rust-compatible GameState JSON from the live GDScript state.
## Uses explicit string concatenation to avoid GDScript % operator edge cases.
static func _build_game_state_json(_focal: RefCounted) -> String:
## `ctrl` is a live GdMcTreeController used to resolve per-clan ScoringWeights.
## `data_dir` is the OS filesystem path to the game data directory.
static func _build_game_state_json(
_focal: RefCounted, ctrl: RefCounted, data_dir: String
) -> String:
var player_jsons: PackedStringArray = PackedStringArray()
for p: Variant in GameState.players:
if p == null:
@ -119,12 +126,20 @@ static func _build_game_state_json(_focal: RefCounted) -> String:
var cap_pos: String = (city_pos_strs[0]
if not city_pos_strs.is_empty()
else "null")
# Resolve per-clan ScoringWeights so MCTS rollouts use clan-specific values.
# Falls back to "{}" (Rust default) when clan_id is unset, unknown, or ctrl is null.
var clan: String = str(p.clan_id) if "clan_id" in p else ""
var scoring_weights_json: String = (
ctrl.scoring_weights_for_clan(clan, data_dir)
if ctrl != null and not clan.is_empty()
else "{}"
)
var pj: String = (
'{"player_index":' + str(int(p.index))
+ ',"gold":' + str(int(p.gold))
+ ',"cities":[' + ",".join(city_jsons) + "]"
+ ',"unit_upkeep":[],"strategic_axes":{' + ",".join(axes_parts) + "}"
+ ',"scoring_weights":{},"expansion_points":0'
+ ',"scoring_weights":' + scoring_weights_json + ',"expansion_points":0'
+ ',"city_buildings":[],"city_ecology":[],"science_yield":0'
+ ',"units":[' + ",".join(unit_jsons) + "]"
+ ',"city_positions":[' + ",".join(city_pos_strs) + "]"

View file

@ -15,7 +15,14 @@ static func assign(player: RefCounted, rng: RandomNumberGenerator) -> void:
return
var ids: Array = clans.keys()
ids.sort()
var chosen_id: String = ids[rng.randi() % ids.size()]
# AI_PIN_PERSONALITY=<clan_id> forces all AI players to a specific clan for
# per-clan batch testing. Falls back to random assignment when unset or invalid.
var pin: String = OS.get_environment("AI_PIN_PERSONALITY")
var chosen_id: String
if not pin.is_empty() and clans.has(pin):
chosen_id = pin
else:
chosen_id = ids[rng.randi() % ids.size()]
var pick: Dictionary = clans.get(chosen_id, {})
var axes: Dictionary = pick.get("strategic_axes", {})
if axes.is_empty():

View file

@ -119,7 +119,7 @@ func test_build_game_state_json_returns_parseable_json() -> void:
var p1: PlayerScript = _make_player(1)
GameState.players = [p0, p1]
var json_str: String = BridgeScript._build_game_state_json(p0)
var json_str: String = BridgeScript._build_game_state_json(p0, null, "")
assert_false(json_str.is_empty(), "_build_game_state_json must return non-empty string")
var parsed: Dictionary = JSON.parse_string(json_str) as Dictionary
@ -141,7 +141,7 @@ func test_build_game_state_json_includes_units() -> void:
p0.units = [w]
GameState.players = [p0]
var json_str: String = BridgeScript._build_game_state_json(p0)
var json_str: String = BridgeScript._build_game_state_json(p0, null, "")
var gs: Dictionary = JSON.parse_string(json_str) as Dictionary
var players_arr: Array = gs["players"] as Array
var p_dict: Dictionary = players_arr[0] as Dictionary
@ -210,7 +210,7 @@ func test_mcts_routing_is_seed_deterministic() -> void:
GameState.players = [p0, p1]
GameState.turn_number = 42
var json_str: String = BridgeScript._build_game_state_json(p0)
var json_str: String = BridgeScript._build_game_state_json(p0, null, "")
var seed_val: int = GameState.turn_number * 1000 + p0.index
var ctrl_a: RefCounted = ClassDB.instantiate("GdMcTreeController")
@ -225,3 +225,74 @@ func test_mcts_routing_is_seed_deterministic() -> void:
assert_eq(directive_a, directive_b,
"Same seed must produce identical MCTS directive (determinism gate)")
# ── Test 8: scoring_weights differ by clan (personality wiring) ──────────────
func test_scoring_weights_differ_by_clan() -> void:
if not ClassDB.class_exists("GdMcTreeController"):
pass_test("GdMcTreeController not loaded — clan weights test skipped")
return
var ctrl: RefCounted = ClassDB.instantiate("GdMcTreeController")
var data_dir: String = ProjectSettings.globalize_path(
"res://public/games/age-of-dwarves/data"
)
var json_blackhammer: String = ctrl.scoring_weights_for_clan("blackhammer", data_dir)
var json_goldvein: String = ctrl.scoring_weights_for_clan("goldvein", data_dir)
assert_false(json_blackhammer.is_empty(), "blackhammer weights must not be empty")
assert_false(json_goldvein.is_empty(), "goldvein weights must not be empty")
assert_ne(
json_blackhammer,
json_goldvein,
"blackhammer and goldvein must produce distinct ScoringWeights"
)
var bh: Dictionary = JSON.parse_string(json_blackhammer) as Dictionary
var gv: Dictionary = JSON.parse_string(json_goldvein) as Dictionary
assert_not_null(bh, "blackhammer weights must be valid JSON")
assert_not_null(gv, "goldvein weights must be valid JSON")
# Blackhammer (aggression=9) must have higher military_base than Goldvein (aggression=2).
assert_true(
float(bh.get("military_base", 0.0)) > float(gv.get("military_base", 0.0)),
"blackhammer military_base must exceed goldvein (aggression axis difference)"
)
# Goldvein (wealth=9) must have higher yield_gold than Blackhammer (wealth=3).
assert_true(
float(gv.get("yield_gold", 0.0)) > float(bh.get("yield_gold", 0.0)),
"goldvein yield_gold must exceed blackhammer (wealth axis difference)"
)
# ── Test 9: clan_id flows into GameState JSON scoring_weights ────────────────
func test_build_json_embeds_clan_weights_when_ctrl_present() -> void:
if not ClassDB.class_exists("GdMcTreeController"):
pass_test("GdMcTreeController not loaded — clan JSON embedding test skipped")
return
var p0: PlayerScript = _make_player(0)
var city: CityScript = _make_city(0, Vector2i(0, 0))
p0.cities = [city]
p0.units = []
p0.clan_id = "blackhammer"
p0.strategic_axes = {"aggression": 9, "expansion": 7, "production": 5,
"wealth": 3, "trade_willingness": 3, "grudge_persistence": 9}
GameState.players = [p0]
var ctrl: RefCounted = ClassDB.instantiate("GdMcTreeController")
var data_dir: String = ProjectSettings.globalize_path(
"res://public/games/age-of-dwarves/data"
)
var json_str: String = BridgeScript._build_game_state_json(p0, ctrl, data_dir)
var gs: Dictionary = JSON.parse_string(json_str) as Dictionary
var players_arr: Array = gs["players"] as Array
var p_dict: Dictionary = players_arr[0] as Dictionary
assert_true(p_dict.has("scoring_weights"), "Player JSON must have 'scoring_weights' key")
var sw: Dictionary = p_dict["scoring_weights"] as Dictionary
assert_true(sw.size() > 0, "scoring_weights must be non-empty for blackhammer clan")

View file

@ -19,7 +19,7 @@ dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter="engine/tests/*, engine/scenes/tests/*, *.md"
exclude_filter="engine/tests/**, engine/scenes/tests/**, public/games/age-of-elves/**, public/games/age-of-kzzkyt/**, public/games/age-of-dwarves/guide/**, public/sandbox/**, **/node_modules/**, **/dist/**, **/target/**, **/pkg/**, *.md, *.ts, *.tsx"
export_path=""
encryption_include_filters=""
encryption_exclude_filters=""
@ -60,7 +60,7 @@ dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter="engine/tests/*, engine/scenes/tests/*, *.md"
exclude_filter="engine/tests/**, engine/scenes/tests/**, public/games/age-of-elves/**, public/games/age-of-kzzkyt/**, public/games/age-of-dwarves/guide/**, public/sandbox/**, **/node_modules/**, **/dist/**, **/target/**, **/pkg/**, *.md, *.ts, *.tsx"
export_path=""
encryption_include_filters=""
encryption_exclude_filters=""
@ -177,7 +177,7 @@ dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter="engine/tests/*, engine/scenes/tests/*, *.md"
exclude_filter="engine/tests/**, engine/scenes/tests/**, public/games/age-of-elves/**, public/games/age-of-kzzkyt/**, public/games/age-of-dwarves/guide/**, public/sandbox/**, **/node_modules/**, **/dist/**, **/target/**, **/pkg/**, *.md, *.ts, *.tsx"
export_path=""
encryption_include_filters=""
encryption_exclude_filters=""

View file

@ -111,6 +111,32 @@ impl GdMcTreeController {
})
}
/// Return the serialized `ScoringWeights` for `clan_id` as a JSON string.
///
/// `data_dir` must be the OS filesystem path to the game data directory that
/// contains `ai_personalities.json` (e.g. the globalized `res://public/games/age-of-dwarves/data`).
/// Returns `"{}"` (empty object) on any error so the caller gets `ScoringWeights::default()`.
#[func]
fn scoring_weights_for_clan(&self, clan_id: GString, data_dir: GString) -> GString {
use mc_ai::evaluator::ScoringWeights;
use std::path::Path;
let id = clan_id.to_string();
let dir = data_dir.to_string();
match ScoringWeights::from_personality(&id, Path::new(&dir)) {
Ok(w) => match serde_json::to_string(&w) {
Ok(json) => GString::from(json),
Err(e) => {
godot_error!("GdMcTreeController::scoring_weights_for_clan serialize error: {}", e);
GString::from("{}")
}
},
Err(e) => {
godot_error!("GdMcTreeController::scoring_weights_for_clan load error for '{}': {}", id, e);
GString::from("{}")
}
}
}
/// Convenience: return the best action and the win-rate estimate as a JSON dict.
/// `{ "action": "FoundCity", "win_rate": 0.62 }`
#[func]