diff --git a/.project/objectives/p1-07-chronicle-coverage.md b/.project/objectives/p1-07-chronicle-coverage.md index b1a6d99e..7f023219 100644 --- a/.project/objectives/p1-07-chronicle-coverage.md +++ b/.project/objectives/p1-07-chronicle-coverage.md @@ -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 diff --git a/src/game/engine/scenes/tests/chronicle_filter_proof.gd b/src/game/engine/scenes/tests/chronicle_filter_proof.gd index 89604001..9f34216a 100644 --- a/src/game/engine/scenes/tests/chronicle_filter_proof.gd +++ b/src/game/engine/scenes/tests/chronicle_filter_proof.gd @@ -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: diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge.gd b/src/game/engine/src/modules/ai/ai_turn_bridge.gd index 04058c7e..ca2124c0 100644 --- a/src/game/engine/src/modules/ai/ai_turn_bridge.gd +++ b/src/game/engine/src/modules/ai/ai_turn_bridge.gd @@ -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) + "]" diff --git a/src/game/engine/src/modules/ai/personality_assigner.gd b/src/game/engine/src/modules/ai/personality_assigner.gd index 7995582b..572b85ce 100644 --- a/src/game/engine/src/modules/ai/personality_assigner.gd +++ b/src/game/engine/src/modules/ai/personality_assigner.gd @@ -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= 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(): diff --git a/src/game/engine/tests/unit/ai/test_ai_turn_bridge_mcts.gd b/src/game/engine/tests/unit/ai/test_ai_turn_bridge_mcts.gd index a2dce610..e05fb51d 100644 --- a/src/game/engine/tests/unit/ai/test_ai_turn_bridge_mcts.gd +++ b/src/game/engine/tests/unit/ai/test_ai_turn_bridge_mcts.gd @@ -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") diff --git a/src/game/export_presets.cfg b/src/game/export_presets.cfg index 4e4895a4..b89e8d6f 100644 --- a/src/game/export_presets.cfg +++ b/src/game/export_presets.cfg @@ -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="" diff --git a/src/simulator/api-gdext/src/ai.rs b/src/simulator/api-gdext/src/ai.rs index 236359c2..0feed78f 100644 --- a/src/simulator/api-gdext/src/ai.rs +++ b/src/simulator/api-gdext/src/ai.rs @@ -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]