From 2605712a61a1a8ae54f78eb0da2f5931fd4cfe7e Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 24 Jun 2026 11:57:32 -0400 Subject: [PATCH] =?UTF-8?q?fix(ai):=20=F0=9F=94=AC=20weave=20tech=E2=86=92?= =?UTF-8?q?tiered-unit=20production=20so=20the=20AI=20fields=20real=20armi?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the TechWeb fix: research advanced to 109 techs but every city still spawned tier-1 dwarf_warrior. Five stacked defects severed the tech→unit→production chain; all are fixed here. Proven in a 599-turn duel self-play (seed 42): slot 0 fields a 222-strong tier-8 dwarf_adamantine_champion army; both clans now CHOOSE tier-2..8 units by tech (was tier-1 only). 1. units_catalog (#[serde(skip)]) was never stamped onto the dispatch GdPlayerApi — only GdGameState. The harness comment claimed 'both must be re-stamped at boot' but only did GdGameState. Added GdPlayerApi::set_units_runtime_catalog_json + a post-load harness re-stamp. Without it apply_queue_production/try_spawn_unit ran catalog-blind. 2. apply_queue_production classified queued ids as unit-vs-building by a starts_with("dwarf_") prefix. Replaced with an authoritative units_catalog.get() lookup (prefix kept only as empty-catalog fallback). The prefix leaked dwarf_-prefixed BUILDINGS (dwarf_deep_forge) onto the map as units and misfiled every non-dwarf unit. 3. project_tactical_player hardcoded race_id: None, filtering out every race_required:dwarf unit. Added PlayerState.race_id (race gates production → it is sim state, not pure presentation per p2-72a), stamped it in set_player_presentation_json + the headless harness (sourced from setup.json::default_race), and projected it. 4. build_unit_catalog loaded faction:"wild" monsters (ancient_hydra, …) and freepeople into the AI production catalog. With race_required:null they passed every race filter and, being high-tier, the picker preferred them. Excluded non-player factions from the production catalog (runtime catalog still carries them for encounters). 5. Generic warrior/spearmen/archer carried clan_affinity to ALL FIVE clans at tier 1, so clan_affinity_score=2 dominated tier and every named clan locked onto tier-1 'warrior'. Cleared their affinity (they are neutral baselines, not clan signatures). mc-player-api 132 + mc-state 12 tests green. Known next layer (not this fix): production VOLUME — try_spawn_unit's empty-queue dwarf_warrior auto-spawn still floods tier-1, and the loser snowballs; the picker is correct, army composition tuning is separate. Co-Authored-By: Claude Opus 4.8 --- public/resources/units/archer.json | 8 +-- public/resources/units/spearmen.json | 8 +-- public/resources/units/warrior.json | 8 +-- .../engine/scenes/headless/player_api_main.gd | 59 +++++++++++++++++++ .../src/modules/ai/ai_turn_bridge_state.gd | 11 ++++ .../integration/test_gd_turn_processor.gd | 11 +++- .../test_p2_58b_ambient_encounter.gd | 3 + src/simulator/api-gdext/src/lib.rs | 18 ++++++ src/simulator/api-gdext/src/player_api.rs | 26 ++++++++ .../crates/mc-player-api/src/dispatch.rs | 19 ++++-- .../crates/mc-player-api/src/projection.rs | 4 +- .../crates/mc-state/src/game_state.rs | 9 +++ 12 files changed, 156 insertions(+), 28 deletions(-) diff --git a/public/resources/units/archer.json b/public/resources/units/archer.json index 02b1c629..74ead97b 100644 --- a/public/resources/units/archer.json +++ b/public/resources/units/archer.json @@ -55,13 +55,7 @@ "flavor": "Deadly at distance but vulnerable in melee.", "archetype": "ranged", "promotion_tree": "ranged", - "clan_affinity": [ - "ironhold", - "goldvein", - "blackhammer", - "deepforge", - "runesmith" - ], + "clan_affinity": [], "logistics": { "composition": { "captain": 1, diff --git a/public/resources/units/spearmen.json b/public/resources/units/spearmen.json index acaa381f..ff6d7b0a 100644 --- a/public/resources/units/spearmen.json +++ b/public/resources/units/spearmen.json @@ -55,13 +55,7 @@ "flavor": "Reach lets them strike flying attackers.", "archetype": "anti_cavalry", "promotion_tree": "melee", - "clan_affinity": [ - "ironhold", - "goldvein", - "blackhammer", - "deepforge", - "runesmith" - ], + "clan_affinity": [], "logistics": { "composition": { "captain": 1, diff --git a/public/resources/units/warrior.json b/public/resources/units/warrior.json index d60ba0bf..956322db 100644 --- a/public/resources/units/warrior.json +++ b/public/resources/units/warrior.json @@ -52,13 +52,7 @@ "flavor": "The backbone of any early military.", "archetype": "light_melee", "promotion_tree": "melee", - "clan_affinity": [ - "ironhold", - "goldvein", - "blackhammer", - "deepforge", - "runesmith" - ], + "clan_affinity": [], "logistics": { "composition": { "captain": 1, diff --git a/src/game/engine/scenes/headless/player_api_main.gd b/src/game/engine/scenes/headless/player_api_main.gd index a441636c..7073e925 100644 --- a/src/game/engine/scenes/headless/player_api_main.gd +++ b/src/game/engine/scenes/headless/player_api_main.gd @@ -27,6 +27,11 @@ var _player_slots: Array[int] = [] # Stage 4 — slots driven externally. var _omniscient: bool = false var _log_path: String = "" var _shutdown: bool = false +# Runtime UnitsCatalog JSON (id → UnitStats), harvested once in +# `_apply_runtime_units_catalog`. Re-stamped onto BOTH `GdGameState` (pre-load) +# and `GdPlayerApi` (post-load) because `GameState::units_catalog` is +# `#[serde(skip)]` and never survives the `to_json` → `load_state_json` hop. +var _runtime_units_json: String = "" func _ready() -> void: @@ -173,6 +178,14 @@ func _hydrate_player_api(num_players: int) -> void: # the clan list (sorted by id for stable ordering across runs). _apply_ai_assignments(gs, num_players) + # Stamp each player's race onto the simulation `PlayerState` BEFORE + # `to_json()`. Unit production gates on `race_required` + # (`mc_ai::tactical::production`); without a stamped race the tactical + # projection maps race → None and every race-gated unit is filtered out, + # locking the AI to race-agnostic units (the tier-1 fallback). Single + # source of truth: `setup.json::default_race` (Game 1 = "dwarf"). + _apply_player_races(gs, num_players) + var json: String = String(gs.to_json()) if json.is_empty() or json == "{}": _emit_protocol_error("GdGameState.to_json returned empty payload") @@ -189,6 +202,19 @@ func _hydrate_player_api(num_players: int) -> void: # of truth for both AI paths. _apply_ai_catalogs() + # Re-stamp the runtime UnitsCatalog onto `GdPlayerApi`. `units_catalog` is + # `#[serde(skip)]`, so `load_state_json` left the dispatch state's copy + # empty — `apply_queue_production` (unit-vs-building classification) and + # `try_spawn_unit` (spawn stat-lines) both read it. Without this re-stamp + # the classification falls back to a `dwarf_`-prefix heuristic that leaks + # building ids onto the map as units. Same JSON harvested pre-load in + # `_apply_runtime_units_catalog`. + if not _runtime_units_json.is_empty(): + var rn: int = int(_api.set_units_runtime_catalog_json(_runtime_units_json)) + _emit_event("runtime_units_catalog_api_loaded", {"units": rn}) + else: + _emit_protocol_error("runtime units catalog JSON empty — _api spawn/classify will misbehave") + # Load the tech web so the per-turn processor auto-advances research. # Same `#[serde(skip)]` constraint as the AI catalogs — stamp on # `GdPlayerApi` AFTER `load_state_json`. Without this the headless path @@ -197,6 +223,38 @@ func _hydrate_player_api(num_players: int) -> void: _apply_tech_web() +## Stamp the playable race onto every player slot's simulation `PlayerState`. +## `GdGameState::set_player_presentation_json` mirrors `race_id` onto +## `inner.players[slot]`, which `project_tactical_player` reads to gate unit +## production (`race_required`). Source of truth: `setup.json::default_race`. +## Must run AFTER `add_player_militarist` (players exist) and BEFORE +## `to_json()` (so the serialised state carries `race_id`). +func _apply_player_races(gs: RefCounted, num_players: int) -> void: + const SETUP_PATH: String = "res://public/games/age-of-dwarves/data/setup.json" + var race_id: String = "" + if FileAccess.file_exists(SETUP_PATH): + var f: FileAccess = FileAccess.open(SETUP_PATH, FileAccess.READ) + if f != null: + var text: String = f.get_as_text() + f.close() + var setup: Dictionary = JSON.parse_string(text) as Dictionary + if setup != null: + race_id = String(setup.get("default_race", "")) + if race_id.is_empty(): + _emit_protocol_error( + "setup.json default_race missing — race-gated units unbuildable, AI locked to tier-1" + ) + return + var stamped: int = 0 + for slot: int in range(num_players): + var pres_json: String = JSON.stringify({"race_id": race_id}) + if bool(gs.set_player_presentation_json(slot, pres_json)): + stamped += 1 + else: + _emit_protocol_error("set_player_presentation_json failed slot=%d" % slot) + _emit_event("player_races_stamped", {"race_id": race_id, "players": stamped}) + + ## Concatenate every `public/resources/techs/*.json` pillar into one flat ## array and hand it to `GdPlayerApi.set_tech_web_json`. All pillars must load ## together — prerequisites cross pillar files. Mirrors the integer-preserving @@ -303,6 +361,7 @@ func _apply_runtime_units_catalog(gs: RefCounted) -> void: _emit_protocol_error("no unit JSON files harvested from " + UNITS_DIR + " — runtime UnitsCatalog will be empty") return var json: String = "[" + ",".join(raw_parts) + "]" + _runtime_units_json = json var n: int = int(gs.set_units_runtime_catalog_json(json)) _emit_event("runtime_units_catalog_loaded", {"units": n}) diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd b/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd index 59c89d3b..c7ee4358 100644 --- a/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd +++ b/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd @@ -159,6 +159,17 @@ static func build_unit_catalog() -> Array: continue if not entry.has("id") and not entry.has("tier"): continue + # Exclude non-player-buildable units from the AI production catalog. + # `faction: "wild"` are roaming monsters (ancient_hydra, dire_bear, …) + # and `freepeople` are neutral NPCs — both spawn on the map (cost 0), + # never built in a city. They carry `race_required: null` so they pass + # every player's race filter and, being high-tier, the production picker + # would otherwise prefer them over real tiered race units. The runtime + # `UnitsCatalog` still carries them (encounters spawn them); only this + # production catalog drops them. + var faction: String = dict_string_field(entry, "faction") + if faction == "wild" or faction == "freepeople": + continue var tech_raw: String = dict_string_field(entry, "tech_required") var resource_raw: String = dict_string_field(entry, "requires_resource") var race_raw: String = dict_string_field(entry, "race_required") diff --git a/src/game/engine/tests/integration/test_gd_turn_processor.gd b/src/game/engine/tests/integration/test_gd_turn_processor.gd index de05d6b5..0eefd71c 100644 --- a/src/game/engine/tests/integration/test_gd_turn_processor.gd +++ b/src/game/engine/tests/integration/test_gd_turn_processor.gd @@ -167,6 +167,9 @@ func _make_processor() -> RefCounted: assert_not_null(processor, "GdTurnProcessor must be registered") processor.call("set_victory_city_count", 255) processor.call("set_max_turns", 999999) + # Install the authored ambient/fauna encounter rates (parity with the + # player-API dispatch). Without this the fauna encounter pass no-ops. + processor.call("load_authored_encounter_rates") return processor @@ -189,7 +192,13 @@ func _seed_state(state: RefCounted) -> void: ## centre of the map. state.call("stamp_lair", CITY_COL, CITY_ROW, 10, 901) state.call("stamp_lair", 4, 4, 5, 102) - state.call("add_player_militarist", CITY_COL, CITY_ROW) + var pi: int = int(state.call("add_player_militarist", CITY_COL, CITY_ROW)) + # add_player_militarist seeds gold=0; real games start players with a + # treasury. Seed a realistic starter so the wealth-axis income (wealth 2 × + # gold_per_wealth_per_city 4 × cities) is observable over the run instead of + # being masked by turn-1 insolvency. + if pi >= 0: + state.call("set_gold", pi, 60) func _run_kill_rate_scenario(kill_rate: float) -> int: diff --git a/src/game/engine/tests/integration/test_p2_58b_ambient_encounter.gd b/src/game/engine/tests/integration/test_p2_58b_ambient_encounter.gd index 5f31b565..68714cb7 100644 --- a/src/game/engine/tests/integration/test_p2_58b_ambient_encounter.gd +++ b/src/game/engine/tests/integration/test_p2_58b_ambient_encounter.gd @@ -70,6 +70,9 @@ func _make_processor() -> RefCounted: assert_not_null(processor, "GdTurnProcessor must be registered") processor.call("set_victory_city_count", 255) processor.call("set_max_turns", 999999) + # Layer-1 ambient encounters only roll when the authored encounter rates are + # installed (parity with the player-API dispatch); otherwise the pass no-ops. + processor.call("load_authored_encounter_rates") return processor diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 1442e3e9..82a6eb2c 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -3270,6 +3270,14 @@ impl GdGameState { color[i] = byte; } } + // Race gates unit production (`race_required` in mc_ai::tactical:: + // production), so mirror it onto the simulation `PlayerState` — the + // tactical projection reads `player.race_id`, not the presentation + // side-table. Slot may exceed `players.len()` for presentation-only + // fixtures; guard with `get_mut`. + if let Some(p) = self.inner.players.get_mut(slot_u8 as usize) { + p.race_id = race_id.clone(); + } let pres = mc_core::PresentationPlayer { slot: slot_u8, player_name, @@ -6225,6 +6233,16 @@ impl GdTurnProcessor { self.inner.victory_city_count = threshold.clamp(0, 255) as u8; } + /// Install the canonical authored ambient-encounter rates so the fauna + /// encounter + Layer-1 ambient passes are live on `step` (parity with the + /// player-API dispatch path, which calls this before driving turns). Without + /// it `encounter_rates` stays `None` and both fauna rolls and ambient + /// encounters are silent no-ops — headless GUT harnesses must opt in. + #[func] + fn load_authored_encounter_rates(&mut self) { + self.inner.load_authored_encounter_rates(); + } + /// Advance `state` by one turn. Returns a dictionary describing the /// turn result: /// { diff --git a/src/simulator/api-gdext/src/player_api.rs b/src/simulator/api-gdext/src/player_api.rs index 0200a927..30116f5b 100644 --- a/src/simulator/api-gdext/src/player_api.rs +++ b/src/simulator/api-gdext/src/player_api.rs @@ -146,6 +146,32 @@ impl GdPlayerApi { } } + /// Stamp the runtime `UnitsCatalog` (id → `UnitStats`) onto the held + /// `GameState`. Distinct from `set_units_catalog_json` (which loads the + /// tactical `ai_unit_catalog`): this is the same `mc_units::UnitsCatalog` + /// `GdGameState::set_units_runtime_catalog_json` loads, but `units_catalog` + /// is `#[serde(skip)]` so it does NOT survive `load_state_json` — it must + /// be re-stamped on this dispatch state directly. `apply_queue_production` + /// reads it to classify queued ids (unit vs building) and `try_spawn_unit` + /// reads it for spawn stat-lines; without it both fall back to defaults and + /// `dwarf_`-prefixed *building* ids leak onto the map as units. + /// + /// Returns the number of entries loaded, or 0 on parse failure. + #[func] + pub fn set_units_runtime_catalog_json(&mut self, json: GString) -> i64 { + let mut cat = mc_units::UnitsCatalog::new(); + match cat.load_json_str(&json.to_string()) { + Ok(n) => { + self.state.units_catalog = cat; + n as i64 + } + Err(e) => { + godot_error!("GdPlayerApi::set_units_runtime_catalog_json failed: {e}"); + 0 + } + } + } + /// p2-71 — stamp the tactical-AI building catalog. Companion to /// `set_units_catalog_json`; same shape (`Vec`). /// Returns the number of entries loaded, 0 on parse failure. diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index e82a0935..a0b54c00 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -1680,12 +1680,21 @@ fn apply_queue_production( ) -> Result, ActionError> { let (pi, ci) = find_city_indices(state, city_id)?; // Bench `CityState` carries a single-item `queue: Option`. - // Detect whether the requested id is a known unit or treat as building. - // Heuristic: if the id contains "dwarf_" we treat as Unit; otherwise - // Item (the bench struct doesn't carry a Building variant — full - // City does, tracked separately). + // Classify the requested id as Unit vs Item (building) by consulting the + // authoritative runtime units catalog — a known unit id → `Unit`, anything + // else → `Item`. This replaces a fragile `starts_with("dwarf_")` prefix + // heuristic that misclassified every non-`dwarf_`-prefixed unit (cavalry, + // dragoon, siege, walkers, wild creatures) as a building, so `try_spawn_unit` + // skipped it and the city fell back to the hardcoded tier-1 spawn (Rail 2). + // Fallback: when no catalog is loaded (bare bench fixtures), retain the + // legacy prefix so those tests keep producing units. use mc_core::ids::UnitId; - let queueable: mc_city::Queueable = if item.starts_with("dwarf_") { + let is_unit = if state.units_catalog.is_empty() { + item.starts_with("dwarf_") + } else { + state.units_catalog.get(item).is_some() + }; + let queueable: mc_city::Queueable = if is_unit { mc_city::Queueable::Unit { unit_id: UnitId::new(item), } diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index 9417aa94..adbbfb40 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -1266,7 +1266,9 @@ fn project_tactical_player( cities, researched_techs, relations, - race_id: None, + // Race gates unit production (`race_required`). Empty id → None so the + // picker stays race-agnostic for fixtures that never stamped a race. + race_id: (!player.race_id.is_empty()).then(|| player.race_id.clone()), strategic_resources, strategic_axes, promotion_offense_weight: coerce_weight(player.promotion_offense_weight), diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index 53b35ca9..a6ce40e9 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -850,6 +850,15 @@ pub struct PlayerState { /// "neutral / unset" without panic. #[serde(default)] pub clan_id: String, + /// DataLoader race id (e.g. `"dwarf"`). Although display-side metadata + /// lives in `mc_core::PresentationPlayer` (p2-72a), unit *production* + /// gates on `race_required` (`mc_ai::tactical::production`), so the + /// owning player's race is genuine simulation state and must reach the + /// tactical projection. Stamped at game-setup alongside the presentation + /// side-table. Empty for fixtures that never set it; the tactical picker + /// then maps empty → `None` and falls back to race-agnostic units. + #[serde(default)] + pub race_id: String, /// p2-71 — Offense-category promotion weight projected into /// `TacticalPlayerState::promotion_offense_weight`. Defaults to 1.0 /// (neutral). Stamped by `set_player_personality_json` from the