feat(@projects/@magic-civilization): add gd_compat serde helpers for u8/u32 fields

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-18 07:45:58 -07:00
parent 62461e8a7d
commit 2f48101a01
4 changed files with 182 additions and 1 deletions

View file

@ -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<String, u8>). GDScript's JSON.stringify emits all Dictionary numbers as floats.
---
## Round 7 — extend gd_compat to u8/u32 + BTreeMap<String, u8>
**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

View file

@ -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_<type>_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<i64> {
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<u8, D::Error>
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<u32, D::Error>
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<i32, D::Error>
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<i64, D::Error>
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<String, u8>` 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<std::collections::BTreeMap<String, u8>, D::Error>
where
D: Deserializer<'de>,
{
use std::collections::BTreeMap;
let raw: BTreeMap<String, Value> = 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::<TU8>(r#"{"x": 5}"#).unwrap().x, 5);
}
#[test]
fn u8_accepts_float() {
assert_eq!(serde_json::from_str::<TU8>(r#"{"x": 5.0}"#).unwrap().x, 5);
}
#[test]
fn u8_rejects_fractional() {
assert!(serde_json::from_str::<TU8>(r#"{"x": 5.5}"#).is_err());
}
#[test]
fn u8_rejects_overflow() {
assert!(serde_json::from_str::<TU8>(r#"{"x": 300}"#).is_err());
}
#[derive(Deserialize)]
struct TMap {
#[serde(deserialize_with = "de_btreemap_string_u8_flexible")]
axes: std::collections::BTreeMap<String, u8>,
}
#[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);
}
}

View file

@ -1,5 +1,6 @@
pub mod algorithms;
pub mod collectibles;
pub mod gd_compat;
pub mod grid;
pub mod perf;
pub mod player;

View file

@ -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<String, u8>,
/// AI scoring weights used by the mc-ai evaluator leaf-value function.
#[serde(default)]