test(@projects/@magic-civilization): 🧪 full-content round-trip guard + parity fixes it surfaced

Add a regression guard that loads the ENTIRE authored content store through the
Rust source-of-truth types — every public/resources/units/*.json into UnitStats
AND TacticalUnitSpec, every buildings/*.json through parse_building_catalog — not
just the 7-file bench subset. The `game data JSON schemas` step validates against
schemas; this validates against the structs the simulator actually runs on, so a
file can no longer satisfy a schema yet break the sim. Runs under
`cargo test --workspace`, so verify auto-enforces it; a drifting file fails the
gate with its filename.

The guard immediately caught two parser-parity bugs the bench never exercised:
- Building `effects[]` may carry a boolean value (`{"type":"enables_naval",
  "value":true}`); `BuildingEffect.value: f64` rejected it, dropping the whole
  building (harbor, stable, deep_*, …) from the catalog. Add a lenient_number
  deserializer that coerces non-numbers to 0.0 — parity with the GDScript
  `value is int or is float` guard. (NB: the dylib from 7e2baa25d had the strict
  parser; rebuilt so the live game re-includes these buildings.)
- TacticalUnitSpec.tier had no serde default while the GDScript builder defaults
  it to 1; a unit JSON omitting tier (founder.json) failed to deserialize. Add
  #[serde(default = "default_tier")] for path parity.

Test excludes the *.schema.json / *_categories.json sidecars that live in the
content dirs. Validated: mc-core+mc-ai+mc-player-api 822/0; rebuilt aarch64 dylib;
headless GUT 728/0/13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-24 23:24:46 -04:00
parent 7e2baa25d4
commit 1d536aeaa8
3 changed files with 153 additions and 2 deletions

View file

@ -27,10 +27,29 @@ use crate::tactical::state::TacticalBuildingSpec;
struct BuildingEffect {
#[serde(rename = "type", default)]
effect_type: String,
#[serde(default)]
/// Authored `value` is usually a number, but some effects carry a boolean
/// flag instead (e.g. `{"type":"enables_naval","value":true}`). The GDScript
/// builder reads `value` ONLY when it is int/float and treats anything else
/// as 0, so a non-numeric value never rejects the whole building. Mirror
/// that here — without it a single boolean-valued effect would drop the
/// entire building from the catalog.
#[serde(default, deserialize_with = "lenient_number")]
value: f64,
}
/// Deserialize any JSON scalar as `f64`, coercing non-numbers (bool, string,
/// null) to `0.0` — parity with the GDScript `eff["value"] is int or is float`
/// guard.
fn lenient_number<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
Ok(serde_json::Value::deserialize(deserializer)?
.as_f64()
.unwrap_or(0.0))
}
/// 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)]

View file

@ -163,7 +163,10 @@ pub struct BuildingPriors {
pub struct TacticalUnitSpec {
/// Unit id (e.g. `"warrior"`, `"pikeman"`).
pub id: String,
/// Tier on the 1..N content ladder.
/// Tier on the 1..N content ladder. Missing `tier` defaults to 1, matching
/// the GDScript `build_unit_catalog` builder (`tier_val = 1` unless authored)
/// so a unit file that omits it loads identically on both paths.
#[serde(default = "default_tier")]
pub tier: u32,
/// Tech gate — unit buildable when the player has researched this id.
pub tech_required: Option<String>,

View file

@ -0,0 +1,129 @@
//! Regression guard — the ENTIRE authored content store round-trips through the
//! canonical Rust structs / transforms, not just the handful the bench harness
//! loads.
//!
//! Why this exists: the `game data JSON schemas` verify step validates the
//! authored files against JSON *schemas*, but a file can satisfy a schema and
//! still fail to deserialize into the Rust types the simulator actually runs on
//! (a renamed field, a type the schema leaves loose, a `#[serde(default)]` that
//! silently drops a real value). The bench fixtures only exercise
//! `dwarf_warrior` / `dwarf_founder` / `dwarf_berserker` + four buildings, so
//! the other ~180 units / ~200 buildings were never checked against the source
//! of truth. The session that added this guard had three separate drift bugs
//! (`unit_type`, `build_cost`, building `effects`) hide precisely because no
//! test loaded the real data through the real Rust path.
//!
//! These tests assert that EVERY `public/resources/{units,buildings}/*.json`
//! file deserializes into the same `UnitStats` / `TacticalUnitSpec` the engine
//! consumes and parses through the one canonical `parse_building_catalog`
//! transform. A single drifting file fails the whole `cargo test --workspace`
//! gate with the offending filename, so content and code can never silently
//! diverge again.
use std::fs;
use std::path::{Path, PathBuf};
use mc_ai::tactical::parse_building_catalog;
use mc_ai::tactical::state::TacticalUnitSpec;
use mc_units::UnitStats;
/// `public/resources/<category>/` resolved from this crate's manifest dir
/// (`.../src/simulator/crates/mc-player-api`).
fn resources_dir(category: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../../public/resources")
.join(category)
}
/// Sorted list of `*.json` files in a content category (deterministic order so
/// failures are reproducible).
fn json_files(category: &str) -> Vec<PathBuf> {
let dir = resources_dir(category);
let mut out: Vec<PathBuf> = fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("read_dir {} failed: {e}", dir.display()))
.filter_map(|entry| entry.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "json"))
// Skip non-content files that live alongside the data: JSON schemas and
// the `*_categories.json` taxonomy/metadata sidecars.
.filter(|p| {
let n = p.file_name().unwrap().to_string_lossy();
!n.ends_with(".schema.json") && !n.ends_with("_categories.json")
})
.collect();
out.sort();
out
}
/// A unit document is either a single object or a legacy array of objects;
/// normalise to the list of unit objects either way.
fn unit_objects(raw: &str, file: &str) -> Vec<serde_json::Value> {
match serde_json::from_str::<serde_json::Value>(raw) {
Ok(serde_json::Value::Array(a)) => a,
Ok(other) => vec![other],
Err(e) => panic!("{file}: not valid JSON: {e}"),
}
}
fn name(path: &Path) -> String {
path.file_name().unwrap().to_string_lossy().into_owned()
}
#[test]
fn every_unit_json_deserializes_into_unit_stats_and_tactical_spec() {
let files = json_files("units");
assert!(
files.len() > 50,
"expected the full unit roster under public/resources/units, got {}",
files.len()
);
let mut failures: Vec<String> = Vec::new();
for path in &files {
let raw = fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", name(path)));
for (i, obj) in unit_objects(&raw, &name(path)).into_iter().enumerate() {
let tag = format!("{}[{i}]", name(path));
// The runtime stat-line the sim spawns from (cost/movement/combat).
if let Err(e) = serde_json::from_value::<UnitStats>(obj.clone()) {
failures.push(format!("{tag} -> UnitStats: {e}"));
}
// The tactical-AI buildable spec (tier/gates/archetype).
if let Err(e) = serde_json::from_value::<TacticalUnitSpec>(obj) {
failures.push(format!("{tag} -> TacticalUnitSpec: {e}"));
}
}
}
assert!(
failures.is_empty(),
"{} unit document(s) drifted from the Rust source-of-truth structs:\n {}",
failures.len(),
failures.join("\n ")
);
}
#[test]
fn every_building_json_parses_through_the_canonical_transform() {
let files = json_files("buildings");
assert!(
files.len() > 50,
"expected the full building roster under public/resources/buildings, got {}",
files.len()
);
let mut failures: Vec<String> = Vec::new();
for path in &files {
let raw = fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", name(path)));
match parse_building_catalog(&raw) {
Ok(specs) if !specs.is_empty() => {}
Ok(_) => failures.push(format!("{}: produced 0 specs", name(path))),
Err(e) => failures.push(format!("{}: {e}", name(path))),
}
}
assert!(
failures.is_empty(),
"{} building document(s) drifted from parse_building_catalog:\n {}",
failures.len(),
failures.join("\n ")
);
}