refactor(@projects/@magic-civilization): ♻️ canonical Rust building-catalog transform (single source of truth)

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-24 22:34:17 -04:00
parent 110082d133
commit 8a5fb9e8f3
3 changed files with 223 additions and 28 deletions

View file

@ -0,0 +1,194 @@
//! Canonical building-catalog transform: raw `buildings/<id>.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/<id>.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<String>,
#[serde(default)]
race_required: Option<String>,
#[serde(default)]
wonder_type: Option<String>,
#[serde(default)]
requires_resource: Option<String>,
#[serde(default)]
requires_existing: Option<String>,
#[serde(default)]
effects: Vec<BuildingEffect>,
}
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<String>) -> Option<String> {
s.filter(|v| !v.trim().is_empty())
}
impl From<BuildingDoc> 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<Vec<TacticalBuildingSpec>, 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<BuildingDoc> = 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");
}
}

View file

@ -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;

View file

@ -173,35 +173,34 @@ pub fn build_unit_catalog() -> Vec<TacticalUnitSpec> {
/// 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/<id>.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<TacticalBuildingSpec> {
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