From b2c8e16acd09b299fe6db08953d3de66cd4e811d Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 24 Jun 2026 23:55:39 -0400 Subject: [PATCH] =?UTF-8?q?fix(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=90=9B=20tactical=20specs=20tolerate=20float-encoded=20in?= =?UTF-8?q?ts=20across=20the=20GDScript=20JSON=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Godot's JSON.parse_string decodes every JSON number as a float, so a tactical catalog that round-trips through GDScript (build_*_catalog → JSON → decide_actions / set_ai_*_catalog_json) presents tier/cost/yields as `1.0`, and Rust's u32/i32 deserialize rejected the whole catalog ("invalid type: floating point 1.0, expected u32"). Add lenient_u32 / lenient_i32 deserializers and apply them to TacticalUnitSpec.tier and TacticalBuildingSpec {tier, cost, yield_*×9, great_work_slots}. This was latent in the building delegation and would have bitten as soon as buildings loaded; the unit-catalog delegation surfaced it. Unit-tested (float + int forms). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../crates/mc-core/src/tactical_types.rs | 80 ++++++++++++++++--- 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/src/simulator/crates/mc-core/src/tactical_types.rs b/src/simulator/crates/mc-core/src/tactical_types.rs index bb06eaaf..26b479aa 100644 --- a/src/simulator/crates/mc-core/src/tactical_types.rs +++ b/src/simulator/crates/mc-core/src/tactical_types.rs @@ -166,7 +166,7 @@ pub struct TacticalUnitSpec { /// 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")] + #[serde(default = "default_tier", deserialize_with = "lenient_u32")] pub tier: u32, /// Tech gate — unit buildable when the player has researched this id. pub tech_required: Option, @@ -198,13 +198,13 @@ pub struct TacticalBuildingSpec { /// Building id (e.g. `"forge"`, `"library"`, `"the_great_forge"`). pub id: String, /// Authoring tier (`1..N`). Higher tiers scored higher when buildable. - #[serde(default = "default_tier")] + #[serde(default = "default_tier", deserialize_with = "lenient_u32")] pub tier: u32, /// Coarse role from JSON `category`. #[serde(default)] pub category: String, /// Production cost from JSON `cost`. - #[serde(default)] + #[serde(default, deserialize_with = "lenient_u32")] pub cost: u32, /// Tech gate — buildable only when player has researched this id. #[serde(default)] @@ -222,31 +222,31 @@ pub struct TacticalBuildingSpec { #[serde(default)] pub requires_existing: Option, /// Per-turn food yield. - #[serde(default)] + #[serde(default, deserialize_with = "lenient_i32")] pub yield_food: i32, /// Per-turn production yield. - #[serde(default)] + #[serde(default, deserialize_with = "lenient_i32")] pub yield_production: i32, /// Per-turn gold/trade yield. - #[serde(default)] + #[serde(default, deserialize_with = "lenient_i32")] pub yield_gold: i32, /// Per-turn science yield. - #[serde(default)] + #[serde(default, deserialize_with = "lenient_i32")] pub yield_science: i32, /// Per-turn culture yield. - #[serde(default)] + #[serde(default, deserialize_with = "lenient_i32")] pub yield_culture: i32, /// Sum of authored defense effects. - #[serde(default)] + #[serde(default, deserialize_with = "lenient_i32")] pub yield_defense: i32, /// Sum of GPP effects across all channels. - #[serde(default)] + #[serde(default, deserialize_with = "lenient_i32")] pub yield_gpp: i32, /// Sum of great_work_slot capacities across all categories. - #[serde(default)] + #[serde(default, deserialize_with = "lenient_i32")] pub great_work_slots: i32, /// Happiness contribution from JSON effects. - #[serde(default)] + #[serde(default, deserialize_with = "lenient_i32")] pub yield_happiness: i32, } @@ -294,10 +294,66 @@ fn default_tier() -> u32 { 1 } +/// Deserialize an integer field that may arrive float-encoded. +/// +/// The tactical catalogs cross the GDScript bridge, and Godot's +/// `JSON.parse_string` decodes EVERY JSON number as a float — so a catalog that +/// round-trips through GDScript (build_unit_catalog / build_building_catalog → +/// JSON → decide_actions / set_ai_*_catalog_json) presents `tier`/`cost`/yields +/// as `1.0` rather than `1`. These accept the float form and truncate, so the +/// single Rust transform tolerates the lossy boundary instead of every caller +/// re-`int()`-ing fields in GDScript (Rail 1). +fn lenient_u32<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let v = serde_json::Value::deserialize(deserializer)?; + Ok(v.as_u64() + .or_else(|| v.as_f64().map(|f| f.max(0.0) as u64)) + .unwrap_or(0) as u32) +} + +/// i32 twin of [`lenient_u32`] — see its docs. +fn lenient_i32<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let v = serde_json::Value::deserialize(deserializer)?; + Ok(v.as_i64() + .or_else(|| v.as_f64().map(|f| f as i64)) + .unwrap_or(0) as i32) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn specs_accept_float_encoded_ints_from_the_gdscript_boundary() { + // Godot's JSON.parse_string decodes numbers as floats, so a catalog that + // round-trips through GDScript arrives with `tier`/`cost`/yields as `N.0`. + // The lenient deserializers must accept that and truncate, or + // decide_actions / set_ai_*_catalog_json reject the whole catalog. + let unit: TacticalUnitSpec = + serde_json::from_str(r#"{"id":"u","unit_type":"melee","tier":2.0}"#) + .expect("float tier accepted on unit spec"); + assert_eq!(unit.tier, 2); + + let bld: TacticalBuildingSpec = serde_json::from_str( + r#"{"id":"b","tier":3.0,"cost":60.0,"yield_production":2.0,"yield_food":-1.0}"#, + ) + .expect("float tier/cost/yields accepted on building spec"); + assert_eq!(bld.tier, 3); + assert_eq!(bld.cost, 60); + assert_eq!(bld.yield_production, 2); + assert_eq!(bld.yield_food, -1); + + // Plain integers still work unchanged. + let int_unit: TacticalUnitSpec = + serde_json::from_str(r#"{"id":"u2","unit_type":"melee","tier":1}"#).unwrap(); + assert_eq!(int_unit.tier, 1); + } + #[test] fn tactical_memory_acquire_hold_press_on() { let army = (8, 9);