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:
parent
9d2c72051f
commit
110082d133
2 changed files with 52 additions and 75 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue