From 2f48101a01c4394dca5babf6b16331e4e76668d2 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 18 Apr 2026 07:45:58 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20gd=5Fcompat=20serde=20helpers=20for=20u8/u32?= =?UTF-8?q?=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/experiments/p0-26-p1-inert.md | 27 ++++ src/simulator/crates/mc-core/src/gd_compat.rs | 146 ++++++++++++++++++ src/simulator/crates/mc-core/src/lib.rs | 1 + .../crates/mc-turn/src/game_state.rs | 9 +- 4 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 src/simulator/crates/mc-core/src/gd_compat.rs diff --git a/.project/experiments/p0-26-p1-inert.md b/.project/experiments/p0-26-p1-inert.md index 921fe2b4..87871bfc 100644 --- a/.project/experiments/p0-26-p1-inert.md +++ b/.project/experiments/p0-26-p1-inert.md @@ -117,6 +117,33 @@ Plus removed the `push_error` DBG instrumentation from `ai_turn_bridge.gd` so th **If prediction holds**: p0-26 acceptance bullet #7 closes. Campaign chain unblocks. **If prediction fails**: there's a third hidden path (combat_predict, production, or citizen also emits conflicting actions for the settler). Would need another instrumented round to localize. +**RESULT (batch b7e60e5db)**: **Round 6 SUCCEEDED for the symptom I was chasing.** Games now run full length — turn counts 164/180/188/224/301 across seeds, both players active. But a DIFFERENT error surfaced in the E2E gate: + +``` +GdMcTreeController::choose_action_with_stats parse error: + invalid type: floating point `3.0`, expected u8 at line 1 column 1399 +``` + +Same CLASS of bug as Round 2 (WeatherEvent i32 fields) but for u8 fields in mc-turn's `PlayerState` (player_index + strategic_axes BTreeMap). GDScript's JSON.stringify emits all Dictionary numbers as floats. + +--- + +## Round 7 — extend gd_compat to u8/u32 + BTreeMap + +**Insight**: The gd_compat serde-helper approach generalizes. Move the helper from `mc-climate::gd_compat` to `mc-core::gd_compat` so every higher crate (mc-turn, mc-ai, mc-climate, mc-combat) can share. Add: +- `de_u8_flexible` +- `de_u32_flexible` +- `de_btreemap_string_u8_flexible` (for `PlayerState::strategic_axes`) + +**Fix applied**: +- New `src/simulator/crates/mc-core/src/gd_compat.rs` with 4 helpers + 5 unit tests. +- `mc_turn::game_state::PlayerState::player_index` annotated with `de_u8_flexible`. +- `mc_turn::game_state::PlayerState::strategic_axes` annotated with `de_btreemap_string_u8_flexible`. + +**Batch**: `bdncm5x7y` (in flight). + +**Predicted outcome**: E2E gate passes. Games run full length with both players active. Other u8 fields (PlayerSnap.wealth/expansion_axis/production_axis, McSnapshot.victory_city_count, combat_event structs) may still need annotation — but `choose_action_with_stats` only parses `GameState` directly, so the PlayerState fix should be sufficient for that specific call path. + --- ## Key meta-lessons from this debug diff --git a/src/simulator/crates/mc-core/src/gd_compat.rs b/src/simulator/crates/mc-core/src/gd_compat.rs new file mode 100644 index 00000000..50889d4c --- /dev/null +++ b/src/simulator/crates/mc-core/src/gd_compat.rs @@ -0,0 +1,146 @@ +//! Serde helpers for the GDScript↔Rust JSON wire contract. +//! +//! GDScript's `JSON.stringify(dict)` emits ALL numeric Dictionary values as +//! JSON floats (e.g. `3.0` rather than `3`). serde_json's default integer +//! deserialization rejects floats with "invalid type: floating point '3.0', +//! expected u8/i32/i64". Every helper in this module accepts both JSON +//! integers AND JSON floats (as long as the float has no fractional part +//! and fits the target range). +//! +//! Apply via `#[serde(deserialize_with = "mc_core::gd_compat::de__flexible")]` +//! to any integer field on a struct that may be deserialized from +//! GDScript-produced JSON — primarily the Rust-side Gd* shims in +//! api-gdext, mc-turn's PlayerState, and mc-climate's WeatherEvent. +//! +//! Living in `mc-core` because every higher-level crate (mc-turn, mc-ai, +//! mc-climate, mc-combat) already has mc-core as a dep and this module is +//! trivially pure — just serde helpers with zero other deps. + +use serde::{Deserialize, Deserializer}; +use serde_json::Value; + +fn to_i64(v: &Value, target: &'static str) -> Option { + v.as_i64().or_else(|| { + v.as_f64().and_then(|f| { + if f.fract() == 0.0 && f.is_finite() { + Some(f as i64) + } else { + None + } + }) + }).or_else(|| { + let _ = target; + None + }) +} + +/// Deserialize u8 from JSON int OR JSON float (rejects fractional / out-of-range). +pub fn de_u8_flexible<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let v = Value::deserialize(deserializer)?; + let n = to_i64(&v, "u8") + .ok_or_else(|| serde::de::Error::custom(format!("expected int-coercible number, got {v}")))?; + u8::try_from(n).map_err(|_| serde::de::Error::custom(format!("{n} out of u8 range"))) +} + +/// Deserialize u32 from JSON int OR JSON float. +pub fn de_u32_flexible<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let v = Value::deserialize(deserializer)?; + let n = to_i64(&v, "u32") + .ok_or_else(|| serde::de::Error::custom(format!("expected int-coercible number, got {v}")))?; + u32::try_from(n).map_err(|_| serde::de::Error::custom(format!("{n} out of u32 range"))) +} + +/// Deserialize i32 from JSON int OR JSON float. +pub fn de_i32_flexible<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let v = Value::deserialize(deserializer)?; + let n = to_i64(&v, "i32") + .ok_or_else(|| serde::de::Error::custom(format!("expected int-coercible number, got {v}")))?; + i32::try_from(n).map_err(|_| serde::de::Error::custom(format!("{n} out of i32 range"))) +} + +/// Deserialize i64 from JSON int OR JSON float. +pub fn de_i64_flexible<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let v = Value::deserialize(deserializer)?; + to_i64(&v, "i64") + .ok_or_else(|| serde::de::Error::custom(format!("expected int-coercible number, got {v}"))) +} + +/// Deserialize `BTreeMap` accepting float values (GDScript +/// Dictionaries-to-JSON produce float-valued maps for `strategic_axes`). +pub fn de_btreemap_string_u8_flexible<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use std::collections::BTreeMap; + let raw: BTreeMap = BTreeMap::deserialize(deserializer)?; + let mut out = BTreeMap::new(); + for (k, v) in raw { + let n = to_i64(&v, "u8") + .ok_or_else(|| serde::de::Error::custom(format!("map[{k}]: expected number, got {v}")))?; + let u = u8::try_from(n).map_err(|_| { + serde::de::Error::custom(format!("map[{k}]: {n} out of u8 range")) + })?; + out.insert(k, u); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Deserialize)] + struct TU8 { + #[serde(deserialize_with = "de_u8_flexible")] + x: u8, + } + + #[test] + fn u8_accepts_int() { + assert_eq!(serde_json::from_str::(r#"{"x": 5}"#).unwrap().x, 5); + } + + #[test] + fn u8_accepts_float() { + assert_eq!(serde_json::from_str::(r#"{"x": 5.0}"#).unwrap().x, 5); + } + + #[test] + fn u8_rejects_fractional() { + assert!(serde_json::from_str::(r#"{"x": 5.5}"#).is_err()); + } + + #[test] + fn u8_rejects_overflow() { + assert!(serde_json::from_str::(r#"{"x": 300}"#).is_err()); + } + + #[derive(Deserialize)] + struct TMap { + #[serde(deserialize_with = "de_btreemap_string_u8_flexible")] + axes: std::collections::BTreeMap, + } + + #[test] + fn map_accepts_mixed_int_and_float() { + let s = r#"{"axes": {"expansion": 5, "production": 3.0, "wealth": 7.0}}"#; + let t: TMap = serde_json::from_str(s).unwrap(); + assert_eq!(t.axes["expansion"], 5); + assert_eq!(t.axes["production"], 3); + assert_eq!(t.axes["wealth"], 7); + } +} diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index 89a1a582..a6922c80 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod algorithms; pub mod collectibles; +pub mod gd_compat; pub mod grid; pub mod perf; pub mod player; diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index 45e20028..7f833a6c 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -74,7 +74,11 @@ pub struct GameState { /// add boilerplate without safety benefit. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlayerState { - /// 0-indexed player slot. + /// 0-indexed player slot. `deserialize_with` accepts GDScript's JSON + /// floats (e.g. `1.0` for u8 `1`) — the engine stringifies all numbers + /// as floats, and strict u8 decoding rejects them. See + /// `mc_core::gd_compat::de_u8_flexible` docstring. + #[serde(deserialize_with = "mc_core::gd_compat::de_u8_flexible")] pub player_index: u8, /// Treasury. pub gold: i32, @@ -86,6 +90,9 @@ pub struct PlayerState { /// Strategic axis weights, e.g. `{"expansion": 5, "production": 3, ...}`. /// Driven by the strategy profile loaded from JSON or defined inline. /// `BTreeMap` for deterministic save serialization (byte-equal round-trip). + /// GDScript's `Dictionary.stringify` emits u8 values as JSON floats; the + /// flexible helper accepts both int and float forms. + #[serde(deserialize_with = "mc_core::gd_compat::de_btreemap_string_u8_flexible")] pub strategic_axes: BTreeMap, /// AI scoring weights used by the mc-ai evaluator leaf-value function. #[serde(default)]