fix(@projects/@magic-civilization): 🐛 tactical specs tolerate float-encoded ints across the GDScript JSON boundary

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-24 23:55:39 -04:00
parent 1d536aeaa8
commit b2c8e16acd

View file

@ -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<String>,
@ -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<String>,
/// 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<u32, D::Error>
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<i32, D::Error>
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);