From 8a5fb9e8f3e13e90fd6928c8c9b3995dea77df5f Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 24 Jun 2026 22:34:17 -0400 Subject: [PATCH] =?UTF-8?q?refactor(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=99=BB=EF=B8=8F=20canonical=20Rust=20building-catalog=20tran?= =?UTF-8?q?sform=20(single=20source=20of=20truth)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The building effects→yield aggregation existed only in GDScript (ai_turn_bridge_state.gd::build_building_catalog), and the mc-player-api bench kept a third hand-written copy of the resulting TacticalBuildingSpec literals (fixed costs/yields/gates) that drifted from public/resources/buildings/*.json. Add `mc_ai::tactical::parse_building_catalog` as the ONE Rust implementation of the transform: parses authored building docs (object or array shape), aggregating each `effects[]` entry into the scalar yield fields with the exact same effect-type→field mapping the GDScript builder uses (food/production/gold|trade/ science|research/culture/defense|city_hp|wall_hp/happiness, gpp_*/great_work_slots_* prefixes). Empty gate strings → None; missing tier → 1. Unit-tested. The bench `build_building_catalog` now loads granary/forge/library/walls straight from the canonical JSON through this transform — no hand-maintained specs, can't drift. (granary is correctly tech-gated by husbandry now.) The engine bridge can adopt the same fn to retire the GDScript copy — follow-up. mc-ai + mc-player-api green: 547/0, incl. 3 new parser tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mc-ai/src/tactical/building_catalog.rs | 194 ++++++++++++++++++ .../crates/mc-ai/src/tactical/mod.rs | 2 + .../crates/mc-player-api/tests/common/mod.rs | 55 +++-- 3 files changed, 223 insertions(+), 28 deletions(-) create mode 100644 src/simulator/crates/mc-ai/src/tactical/building_catalog.rs diff --git a/src/simulator/crates/mc-ai/src/tactical/building_catalog.rs b/src/simulator/crates/mc-ai/src/tactical/building_catalog.rs new file mode 100644 index 00000000..36637656 --- /dev/null +++ b/src/simulator/crates/mc-ai/src/tactical/building_catalog.rs @@ -0,0 +1,194 @@ +//! Canonical building-catalog transform: raw `buildings/.json` → tactical +//! [`TacticalBuildingSpec`]. +//! +//! Single source of truth. The authored building documents in +//! `public/resources/buildings/*.json` store yields as a typed `effects[]` +//! array; the tactical AI needs them flattened into the scalar `yield_*` fields +//! it scores against. That aggregation previously lived ONLY in GDScript +//! (`ai_turn_bridge_state.gd::build_building_catalog`), with the test harness +//! keeping a third, hand-written copy of the resulting specs that silently +//! drifted from the data. This module is the one Rust implementation of the +//! transform, consumed by tests today and available to the engine bridge so the +//! aggregation stops being duplicated across languages. +//! +//! The effect-type → yield-field mapping mirrors the GDScript `match` exactly: +//! `food`→food, `production`→production, `gold`/`trade`→gold, +//! `science`/`research`→science, `culture`→culture, +//! `defense`/`city_hp`/`wall_hp`→defense, `happiness`→happiness, any +//! `gpp_*`→gpp, any `great_work_slots_*`→great_work_slots, everything else +//! ignored. + +use serde::Deserialize; + +use crate::tactical::state::TacticalBuildingSpec; + +/// One authored effect entry from a building document's `effects[]` array. +#[derive(Debug, Clone, Deserialize)] +struct BuildingEffect { + #[serde(rename = "type", default)] + effect_type: String, + #[serde(default)] + value: f64, +} + +/// Subset of a `buildings/.json` document needed to derive a tactical spec. +/// Unknown JSON keys (sprite, adjacency, encyclopedia, …) are ignored. +#[derive(Debug, Clone, Deserialize)] +struct BuildingDoc { + id: String, + #[serde(default = "default_tier")] + tier: u32, + #[serde(default)] + cost: u32, + #[serde(default)] + category: String, + #[serde(default)] + tech_required: Option, + #[serde(default)] + race_required: Option, + #[serde(default)] + wonder_type: Option, + #[serde(default)] + requires_resource: Option, + #[serde(default)] + requires_existing: Option, + #[serde(default)] + effects: Vec, +} + +fn default_tier() -> u32 { + 1 +} + +/// Treat an empty/whitespace string field as "absent" (the GDScript builder +/// maps `""` → `null` for the optional gate fields). +fn non_empty(s: Option) -> Option { + s.filter(|v| !v.trim().is_empty()) +} + +impl From for TacticalBuildingSpec { + fn from(doc: BuildingDoc) -> Self { + let mut spec = TacticalBuildingSpec { + id: doc.id, + tier: doc.tier, + category: doc.category, + cost: doc.cost, + tech_required: non_empty(doc.tech_required), + race_required: non_empty(doc.race_required), + wonder_type: non_empty(doc.wonder_type), + requires_resource: non_empty(doc.requires_resource), + requires_existing: non_empty(doc.requires_existing), + yield_food: 0, + yield_production: 0, + yield_gold: 0, + yield_science: 0, + yield_culture: 0, + yield_defense: 0, + yield_gpp: 0, + great_work_slots: 0, + yield_happiness: 0, + }; + for eff in doc.effects { + let v = eff.value as i32; + match eff.effect_type.as_str() { + "food" => spec.yield_food += v, + "production" => spec.yield_production += v, + "gold" | "trade" => spec.yield_gold += v, + "science" | "research" => spec.yield_science += v, + "culture" => spec.yield_culture += v, + "defense" | "city_hp" | "wall_hp" => spec.yield_defense += v, + "happiness" => spec.yield_happiness += v, + other if other.starts_with("gpp_") => spec.yield_gpp += v, + other if other.starts_with("great_work_slots_") => { + spec.great_work_slots += v + } + _ => {} + } + } + spec + } +} + +/// Parse a building-catalog JSON document into tactical specs. +/// +/// Accepts either a single building object or a JSON array of them (authored +/// `buildings/*.json` files are arrays, often of length 1), aggregating each +/// building's `effects[]` into the scalar yield fields. This is the canonical +/// transform — the same one `ai_turn_bridge_state.gd::build_building_catalog` +/// applies — so callers never hand-maintain a parallel set of specs. +/// +/// # Errors +/// +/// Returns the underlying [`serde_json::Error`] when `json` is neither a +/// building object nor an array of them. +pub fn parse_building_catalog(json: &str) -> Result, serde_json::Error> { + // Tolerate both shapes: a bare object and an array of objects. + let value: serde_json::Value = serde_json::from_str(json)?; + let docs: Vec = match value { + serde_json::Value::Array(_) => serde_json::from_value(value)?, + _ => vec![serde_json::from_value(value)?], + }; + Ok(docs.into_iter().map(TacticalBuildingSpec::from).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aggregates_effects_into_yield_fields() { + // Array shape (the authored buildings/*.json form), single building. + let json = r#"[{ + "id": "forge", + "category": "production", + "cost": 60, + "tier": 1, + "tech_required": null, + "effects": [{ "type": "production", "value": 2 }] + }]"#; + let specs = parse_building_catalog(json).expect("forge parses"); + assert_eq!(specs.len(), 1); + let forge = &specs[0]; + assert_eq!(forge.id, "forge"); + assert_eq!(forge.cost, 60); + assert_eq!(forge.yield_production, 2); + assert_eq!(forge.yield_food, 0); + assert_eq!(forge.tech_required, None); + } + + #[test] + fn maps_aliased_and_prefixed_effect_types() { + let json = r#"{ + "id": "grand_archive", + "category": "research", + "cost": 120, + "tier": 3, + "effects": [ + { "type": "research", "value": 3 }, + { "type": "trade", "value": 1 }, + { "type": "wall_hp", "value": 5 }, + { "type": "gpp_scientist", "value": 2 }, + { "type": "great_work_slots_writing", "value": 1 }, + { "type": "unknown_channel", "value": 99 } + ] + }"#; + let specs = parse_building_catalog(json).expect("parses single object"); + let b = &specs[0]; + assert_eq!(b.yield_science, 3, "research aliases to science"); + assert_eq!(b.yield_gold, 1, "trade aliases to gold"); + assert_eq!(b.yield_defense, 5, "wall_hp aliases to defense"); + assert_eq!(b.yield_gpp, 2, "gpp_* prefix sums into gpp"); + assert_eq!(b.great_work_slots, 1, "great_work_slots_* prefix sums"); + // unknown_channel is ignored — no scalar field moved by it. + assert_eq!(b.yield_food + b.yield_production + b.yield_culture, 0); + } + + #[test] + fn empty_gate_strings_become_none() { + let json = r#"[{ "id": "hut", "tech_required": "", "race_required": " " }]"#; + let specs = parse_building_catalog(json).expect("parses"); + assert_eq!(specs[0].tech_required, None); + assert_eq!(specs[0].race_required, None); + assert_eq!(specs[0].tier, 1, "missing tier defaults to 1"); + } +} diff --git a/src/simulator/crates/mc-ai/src/tactical/mod.rs b/src/simulator/crates/mc-ai/src/tactical/mod.rs index ceef4a6b..533be06c 100644 --- a/src/simulator/crates/mc-ai/src/tactical/mod.rs +++ b/src/simulator/crates/mc-ai/src/tactical/mod.rs @@ -32,6 +32,7 @@ //! `JSON.parse_string`. pub mod apply; +pub mod building_catalog; pub(crate) mod citizen; pub mod combat_predict; pub(crate) mod diplomacy; @@ -48,6 +49,7 @@ pub mod thresholds; pub mod tree_state; pub use apply::apply_tactical_action; +pub use building_catalog::parse_building_catalog; pub use memory::TacticalMemory; pub use scoring::score_for_player; pub use tree_state::TacticalTreeState; 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 acad9fde..693251af 100644 --- a/src/simulator/crates/mc-player-api/tests/common/mod.rs +++ b/src/simulator/crates/mc-player-api/tests/common/mod.rs @@ -173,35 +173,34 @@ pub fn build_unit_catalog() -> Vec { /// Tactical-AI building catalog literal. One entry per load-bearing /// yield category so `pick_building_from_catalog` has variety. +/// Bench building set — one per load-bearing yield category, sourced from the +/// canonical store. `granary` is tech-gated (husbandry); `forge`/`library`/ +/// `walls` are ungated tier-1. +const BENCH_BUILDING_IDS: &[&str] = &["granary", "forge", "library", "walls"]; + +/// Read a canonical building document from `public/resources/buildings/`. +fn canonical_building_json(id: &str) -> String { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../../public/resources/buildings") + .join(format!("{id}.json")); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read canonical building {id} ({}): {e}", path.display())) +} + +/// Tactical-AI building catalog (`ai_building_catalog`). Each spec is produced by +/// the canonical [`mc_ai::tactical::parse_building_catalog`] transform from the +/// authored `public/resources/buildings/.json` document — the SAME +/// effects→yield aggregation `ai_turn_bridge_state.gd::build_building_catalog` +/// runs. No hand-written yields/costs/gates: the bench scores buildings exactly +/// as the shipped game does and cannot drift from the data. pub fn build_building_catalog() -> Vec { - fn b(id: &str, cat: &str, food: i32, prod: i32, sci: i32, def: i32) -> TacticalBuildingSpec { - TacticalBuildingSpec { - id: id.into(), - tier: 1, - category: cat.into(), - cost: 60, - tech_required: None, - race_required: None, - wonder_type: None, - requires_resource: None, - requires_existing: None, - yield_food: food, - yield_production: prod, - yield_gold: 0, - yield_science: sci, - yield_culture: 0, - yield_defense: def, - yield_gpp: 0, - great_work_slots: 0, - yield_happiness: 0, - } - } - vec![ - b("granary", "food", 2, 0, 0, 0), - b("forge", "production", 0, 2, 0, 0), - b("library", "research", 0, 0, 2, 0), - b("walls", "defense", 0, 0, 0, 4), - ] + BENCH_BUILDING_IDS + .iter() + .flat_map(|id| { + mc_ai::tactical::parse_building_catalog(&canonical_building_json(id)) + .unwrap_or_else(|e| panic!("parse {id} building catalog: {e}")) + }) + .collect() } /// Runtime `UnitsCatalog` — id → `UnitStats`. Deserialized from the same