test(@projects/@magic-civilization): ♻️ load bench unit catalogs from canonical JSON (single source of truth)

The mc-player-api bench harness hand-defined unit stats twice — `build_unit_catalog`
(TacticalUnitSpec) and `build_runtime_units_catalog` (UnitStats) — as inline literals
that silently drifted from public/resources/units/*.json: wrong unit_type ("military"
vs "melee"), build_cost 0 vs the real 40, iron_working vs the real bronze_working.
Each drift masked a real AI-production bug and had to be patched field-by-field.

Root fix per Rail 2: both catalogs now deserialize straight from the canonical
`public/resources/units/<id>.json` documents (UnitStats flattens the top-level combat
line + renames cost/movement; TacticalUnitSpec's gate fields are all serde(default)),
so one file feeds both and the bench economy — cost, tier, race/tech gates, combat
stats, archetype — cannot diverge from the shipped game. Tier-2 slot uses the
dwarf-native `dwarf_berserker` (single-object, iron_working-gated) instead of the
generic legacy `pikeman.json` (a multi-unit array). This supersedes the earlier
inline unit_type/build_cost band-aids.

Full mc-player-api suite green (163/0), incl. claude_vs_ai_full_game_transcript with
real costs (warrior 40), so the AI still fields units on the corrected economy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-24 21:45:52 -04:00
parent 9d2c72051f
commit 110082d133
2 changed files with 52 additions and 75 deletions

View file

@ -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/<id>.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/<id>.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<TacticalUnitSpec> {
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::<TacticalUnitSpec>(&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<TacticalBuildingSpec> {
]
}
/// 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/<id>.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
}

View file

@ -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()