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:
parent
1d536aeaa8
commit
b2c8e16acd
1 changed files with 68 additions and 12 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue