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:
parent
110082d133
commit
8a5fb9e8f3
3 changed files with 223 additions and 28 deletions
194
src/simulator/crates/mc-ai/src/tactical/building_catalog.rs
Normal file
194
src/simulator/crates/mc-ai/src/tactical/building_catalog.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue