diff --git a/src/simulator/crates/mc-player-api/tests/common/mod.rs b/src/simulator/crates/mc-player-api/tests/common/mod.rs index d267fde1..acad9fde 100644 --- a/src/simulator/crates/mc-player-api/tests/common/mod.rs +++ b/src/simulator/crates/mc-player-api/tests/common/mod.rs @@ -24,7 +24,31 @@ use mc_ai::evaluator::ScoringWeights; use mc_ai::tactical::state::{TacticalBuildingSpec, TacticalUnitSpec}; use mc_state::game_state::{GameState, MapUnit, PlayerState}; -use mc_units::{CombatStats, UnitStats, UnitsCatalog}; +use mc_units::{UnitStats, UnitsCatalog}; + +/// Unit ids the bench harness sources from the canonical content store. +/// +/// Single source of truth: both the runtime `UnitsCatalog` (`UnitStats`) and the +/// tactical `ai_unit_catalog` (`TacticalUnitSpec`) deserialize from the SAME +/// `public/resources/units/.json` documents the shipped game loads — so the +/// bench economy (cost, tier, race/tech gates, combat stats, archetype) cannot +/// drift from the data. The prior inline fixtures were a second, hand-edited +/// copy that silently diverged (wrong `unit_type`, `build_cost: 0`, +/// `iron_working` where the JSON says `bronze_working`) and masked real +/// AI-production bugs. `dwarf_warrior` = tier-1 melee, `dwarf_founder` = settler, +/// `dwarf_berserker` = the tech-gated tier-2 (iron_working) the legal-actions +/// round-trip exercises. (The generic `pikeman.json` is a legacy multi-unit +/// array file; the dwarf-native berserker is the single-object equivalent.) +const BENCH_UNIT_IDS: &[&str] = &["dwarf_warrior", "dwarf_founder", "dwarf_berserker"]; + +/// Read a canonical unit document from `public/resources/units/`. +fn canonical_unit_json(id: &str) -> String { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../../public/resources/units") + .join(format!("{id}.json")); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read canonical unit {id} ({}): {e}", path.display())) +} /// Mirror of `GdGameState::add_player_militarist` (api-gdext/src/lib.rs). /// @@ -132,50 +156,19 @@ pub fn stamp_personality(state: &mut GameState, pi: usize, clan_id: &str) { state.players[pi].promotion_mobility_weight = 1.0; } -/// Tactical-AI unit catalog literal. Minimum viable set: the two units -/// the harness spawns plus one tier-2 melee. Field shape mirrors -/// `ai_turn_bridge_state.gd::build_unit_catalog`. +/// Tactical-AI unit catalog (`ai_unit_catalog`) — the buildable specs the MCTS +/// picker ranks. Deserialized straight from the canonical +/// `public/resources/units/.json` documents (`TacticalUnitSpec`'s gate +/// fields are all `#[serde(default)]`), so it stays in lockstep with the data +/// the shipped game loads via `ai_turn_bridge_state.gd::build_unit_catalog`. pub fn build_unit_catalog() -> Vec { - vec![ - TacticalUnitSpec { - id: "dwarf_warrior".into(), - tier: 1, - tech_required: None, - // Combat archetype, NOT the "military" ai_tag. The tactical picker - // (pick_best_unit_of_type) filters on unit_type ∈ {melee,ranged,siege}; - // the real units JSON uses "melee" for dwarf_warrior. A bogus - // "military" here matches no preferred type, so the picker falls back - // to the phantom "warrior" id and the AI never spawns a unit. - unit_type: "melee".into(), - requires_resource: None, - race_required: Some("dwarf".into()), - clan_affinity: Vec::new(), - archetype: Some("light_melee".into()), - requires_building: None, - }, - TacticalUnitSpec { - id: "dwarf_founder".into(), - tier: 1, - tech_required: None, - unit_type: "founder".into(), - requires_resource: None, - race_required: Some("dwarf".into()), - clan_affinity: Vec::new(), - archetype: None, - requires_building: None, - }, - TacticalUnitSpec { - id: "pikeman".into(), - tier: 2, - tech_required: Some("iron_working".into()), - unit_type: "melee".into(), - requires_resource: None, - race_required: None, - clan_affinity: Vec::new(), - archetype: Some("anti_cavalry".into()), - requires_building: None, - }, - ] + BENCH_UNIT_IDS + .iter() + .map(|id| { + serde_json::from_str::(&canonical_unit_json(id)) + .unwrap_or_else(|e| panic!("parse {id} as TacticalUnitSpec: {e}")) + }) + .collect() } /// Tactical-AI building catalog literal. One entry per load-bearing @@ -211,35 +204,19 @@ pub fn build_building_catalog() -> Vec { ] } -/// Runtime `UnitsCatalog` literal — id → `UnitStats`. Mirrors what -/// `player_api_main.gd::_apply_runtime_units_catalog` harvests from -/// `public/resources/units/*.json` (the `movement` field maps to -/// `UnitStats::base_moves`). +/// Runtime `UnitsCatalog` — id → `UnitStats`. Deserialized from the same +/// canonical `public/resources/units/.json` documents that +/// `player_api_main.gd::_apply_runtime_units_catalog` harvests at runtime +/// (`UnitStats` flattens the JSON's top-level combat line and renames +/// `movement`→`base_moves`, `cost`→`build_cost`). No hand-maintained stat +/// literals — the bench picks up real costs/stats and never drifts. pub fn build_runtime_units_catalog() -> UnitsCatalog { let mut cat = UnitsCatalog::new(); - cat.insert(UnitStats { - id: "dwarf_warrior".into(), - base_moves: 2, - domain: "land".into(), - action_point_capacity: None, - capturable: false, - ransom_multiplier: 2.0, - build_cost: 0, - logistics: None, - // Mirrors public/resources/units/dwarf_warrior.json combat line. - combat: CombatStats { hp: 60, attack: 12, defense: 1, ..Default::default() }, - }); - cat.insert(UnitStats { - id: "dwarf_founder".into(), - base_moves: 2, - domain: "land".into(), - action_point_capacity: None, - capturable: true, - ransom_multiplier: 2.0, - build_cost: 80, - logistics: None, - combat: CombatStats::default(), - }); + for id in BENCH_UNIT_IDS { + let stats: UnitStats = serde_json::from_str(&canonical_unit_json(id)) + .unwrap_or_else(|e| panic!("parse {id} as UnitStats: {e}")); + cat.insert(stats); + } cat } diff --git a/src/simulator/crates/mc-player-api/tests/legal_actions_round_trip.rs b/src/simulator/crates/mc-player-api/tests/legal_actions_round_trip.rs index 5947985d..4b48ff0f 100644 --- a/src/simulator/crates/mc-player-api/tests/legal_actions_round_trip.rs +++ b/src/simulator/crates/mc-player-api/tests/legal_actions_round_trip.rs @@ -78,10 +78,10 @@ fn per_city_legal_actions_dispatch_without_error() { "own city must have legal_actions populated; queue is empty so QueueProduction \ for catalog members should appear" ); - // 3 unit catalog members + 4 building catalog members → 7 entries when - // none require tech (the harness catalogs leave `dwarf_warrior` / - // `dwarf_founder` / `granary` / `forge` / `library` / `walls` ungated; - // `pikeman` requires `iron_working` which the player hasn't researched). + // 3 unit catalog members + 4 building catalog members → 6 buildable entries: + // `dwarf_warrior` / `dwarf_founder` / `granary` / `forge` / `library` / + // `walls` are ungated; `dwarf_berserker` requires `iron_working` which the + // player hasn't researched, so it is filtered out. let n_queue = city .legal_actions .iter()