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:
parent
62461e8a7d
commit
2f48101a01
4 changed files with 182 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
146
src/simulator/crates/mc-core/src/gd_compat.rs
Normal file
146
src/simulator/crates/mc-core/src/gd_compat.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod algorithms;
|
||||
pub mod collectibles;
|
||||
pub mod gd_compat;
|
||||
pub mod grid;
|
||||
pub mod perf;
|
||||
pub mod player;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue