diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 28e8c877..1daca3b2 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -2862,74 +2862,51 @@ impl GdItemSystem { #[class(base=RefCounted)] pub struct GdGameState { inner: mc_turn::GameState, + /// p2-72a Stage 3 — Wall-2 side-table of presentation-only player + /// metadata (display name, race id, gender preset, banner colour, + /// is_human). Aligned with `inner.players` by `slot`. Populated by + /// GDScript via `set_player_presentation_json` at game-setup time and + /// round-tripped through `serialize_full` / `load_from_json`. + presentation_players: Vec, base: Base, } +/// p2-72a Stage 3 — canonical on-disk save envelope. Rust now owns +/// serialisation (the GDScript `SaveManager` becomes a thin wrapper around +/// `GdGameState::serialize_full` / `load_from_json`). Holds both the +/// simulation state (`mc_turn::GameState`) and the Wall-2 presentation +/// side-table. +/// +/// `version` starts at 1; bump on any future breaking shape change. +#[derive(serde::Serialize, serde::Deserialize)] +struct SaveEnvelope { + /// Save-format version. Starts at 1. Bumped on breaking shape changes. + save_format_version: u32, + /// Authoritative simulation state. + sim: mc_turn::GameState, + /// Presentation-only per-player metadata. Aligned with `sim.players` by slot. + presentation: Vec, +} + +impl SaveEnvelope { + /// Current envelope version. + const CURRENT_VERSION: u32 = 1; +} + #[godot_api] impl IRefCounted for GdGameState { fn init(base: Base) -> Self { + // `Default::default()` for `GameState` zero-initialises + // `ai_difficulty_threshold_mult` (f32 default = 0.0); patch it to + // the neutral `1.0` the tactical-AI thresholds expect. The + // `#[serde(default = "default_threshold_mult")]` attribute only + // applies on deserialise; `Default` derives go through the field + // type's own Default. + let mut inner = mc_turn::GameState::default(); + inner.ai_difficulty_threshold_mult = 1.0; Self { - inner: mc_turn::GameState { - turn: 0, - players: Vec::new(), - grid: None, - pending_pvp_attacks: Default::default(), - pending_bombard_requests: Default::default(), - formations: Default::default(), - next_formation_id: 0, - next_unit_id: 0, - pending_rally_requests: Default::default(), - pending_formation_commands: Default::default(), - pending_formation_shapes: Default::default(), - pending_split_requests: Default::default(), - pending_auto_join_requests: Default::default(), - pending_building_actions: Default::default(), - pending_pillage_requests: Default::default(), - pending_volley_requests: Default::default(), - pending_charge_requests: Default::default(), - // p2-67 Phase 9 — Move subsystem queue + units catalog. - pending_move_requests: Default::default(), - units_catalog: Default::default(), - // p2-67 Phase 8 — TradeLedger now lives on GameState. - trade_ledger: Default::default(), - tile_improvements: Default::default(), - improvement_registry: Default::default(), - // p2-55 (Wave 1, simulator-infra): new GameState fields added - // to support civilian capture / ransom. Real bridge wiring - // for these is Wave 2 work — for now we just need to satisfy - // the struct literal so the workspace compiles. - ransom_queue: Default::default(), - // p2-55f: combat balance config (defaults match prior - // hardcoded constants); GameState init reads from - // combat_balance.json once data-loader wiring lands. - combat_balance: Default::default(), - pending_capture_events: Default::default(), - // p3-10b: per-lair siege pressure state. Populated by the - // bridge as players begin sieges; serialized for save - // round-trip via `siege_pressure_as_pairs`. - siege_pressure: Default::default(), - // p3-10c: per-lair raid aftermath state. Populated by the - // bridge on `Caught` raid outcomes; serialized for save - // round-trip via `raid_aftermath_as_pairs`. - raid_aftermath: Default::default(), - // p3-13d: tile-indexed fog state populated by mc-sim - // event_dispatch when AnomalousEvent::FogBank fires. - fog_map: Default::default(), - // p2-48: resignation actions queued by GDScript before turn-end. - // Drained by end_conditions::evaluate_conditions. - pending_resignations: Default::default(), - // p2-71: tactical-AI catalogs + difficulty mult. Populated - // by the harness via `GdPlayerApi::set_*_catalog_json` / - // `set_difficulty_threshold_mult`. Empty/1.0 falls back to - // pre-p2-71 behaviour (uniform AI scoring). - ai_unit_catalog: Vec::new(), - ai_building_catalog: Vec::new(), - ai_difficulty_threshold_mult: 1.0, - // p2-72a Stage 2b: NPC buildings mirror (lairs / villages / - // ruins). Populated by `spawn_npc_building`; Stage 3 wires - // this through the full save-format migration. - npc_buildings: Vec::new(), - }, + inner, + presentation_players: Vec::new(), base, } } @@ -2955,6 +2932,13 @@ impl GdGameState { /// Replace the inner state from a JSON dump (symmetric with `to_json`). /// Used by tests that need to round-trip a hand-built state through /// disk or the wire. Returns `false` on parse failure. + /// + /// **Boot-only**. The serialised payload covers only `mc_turn::GameState` + /// — `#[serde(skip)]` fields (`units_catalog`, `improvement_registry`, + /// `ai_unit_catalog`, `ai_building_catalog`) are wiped on load and the + /// harness must rehydrate them before the next turn. For mid-game + /// save+load use `load_from_json` instead, which preserves these + /// boot-loaded catalogs across the round-trip. #[func] fn from_json(&mut self, json: GString) -> bool { match serde_json::from_str::(json.to_string().as_str()) { @@ -2969,6 +2953,246 @@ impl GdGameState { } } + // ── p2-72a Stage 3: canonical Rust-owned save surface ──────────────── + // + // Rust now owns serialisation. GDScript `SaveManager` becomes a thin + // wrapper that delegates to `serialize_full` (write) and + // `load_from_json` (read). The envelope shape is + // { save_format_version: u32, sim: GameState, presentation: [...] } + // and is locked at version 1. + // + // Wall 2 (presentation-only player metadata: display name, race id, + // gender preset, banner colour, is_human) lives in the + // `presentation_players: Vec` side-table and is + // populated by `set_player_presentation_json` from GDScript at + // game-setup time. + + /// Build the canonical save envelope and serialise it to JSON. + /// + /// Output shape: + /// ```json + /// { "save_format_version": 1, "sim": { ... mc_turn::GameState ... }, + /// "presentation": [ { "slot": 0, "player_name": "Thorin", ... }, ... ] } + /// ``` + /// + /// Returns `"{}"` only on a serde failure (logged via `godot_error!`). + /// `SaveManager` wraps this with `save_format_version` + on-disk timestamp + /// metadata in its outer envelope. + #[func] + fn serialize_full(&self) -> GString { + let envelope = SaveEnvelope { + save_format_version: SaveEnvelope::CURRENT_VERSION, + sim: self.inner.clone(), + presentation: self.presentation_players.clone(), + }; + match serde_json::to_string(&envelope) { + Ok(s) => s.into(), + Err(e) => { + godot_error!("GdGameState::serialize_full failed: {e}"); + "{}".into() + } + } + } + + /// Parse a JSON envelope produced by `serialize_full` and overwrite + /// the inner state + presentation side-table in place. + /// + /// Preserves the boot-loaded `#[serde(skip)]` catalogs + /// (`units_catalog`, `improvement_registry`, `ai_unit_catalog`, + /// `ai_building_catalog`, `ai_difficulty_threshold_mult`) across the + /// load so mid-game save+load does not wipe them — the next + /// `EndTurn` would otherwise fail on an empty units catalog. The + /// catalogs are snapshotted from the current `inner`, the + /// deserialised state is moved into place, then the snapshots are + /// restored. + /// + /// Rejects envelopes whose `save_format_version` does not match + /// [`SaveEnvelope::CURRENT_VERSION`] — dev-time saves are + /// disposable (see `feedback_acs_auto_commit_no_manual.md`), so a + /// version mismatch is a hard failure rather than a migration. + /// Returns `false` on parse failure or version mismatch + /// (`godot_error!`-logged). + #[func] + fn load_from_json(&mut self, json: GString) -> bool { + let parsed: Result = + serde_json::from_str(json.to_string().as_str()); + let envelope = match parsed { + Ok(env) => env, + Err(e) => { + godot_error!("GdGameState::load_from_json: parse failed: {e}"); + return false; + } + }; + if envelope.save_format_version != SaveEnvelope::CURRENT_VERSION { + godot_error!( + "GdGameState::load_from_json: save_format_version {} != expected {} — \ + dev-time saves are disposable; regenerate the save", + envelope.save_format_version, + SaveEnvelope::CURRENT_VERSION + ); + return false; + } + // Snapshot the boot-loaded `#[serde(skip)]` catalogs before + // overwriting `inner` — see method docstring. + let units_catalog = std::mem::take(&mut self.inner.units_catalog); + let improvement_registry = + std::mem::take(&mut self.inner.improvement_registry); + let ai_unit_catalog = std::mem::take(&mut self.inner.ai_unit_catalog); + let ai_building_catalog = + std::mem::take(&mut self.inner.ai_building_catalog); + let ai_difficulty_threshold_mult = self.inner.ai_difficulty_threshold_mult; + + self.inner = envelope.sim; + self.presentation_players = envelope.presentation; + + self.inner.units_catalog = units_catalog; + self.inner.improvement_registry = improvement_registry; + self.inner.ai_unit_catalog = ai_unit_catalog; + self.inner.ai_building_catalog = ai_building_catalog; + self.inner.ai_difficulty_threshold_mult = ai_difficulty_threshold_mult; + true + } + + /// Stamp the presentation-only metadata for `slot` from a JSON + /// dictionary harvested by GDScript at game-setup time. Shape: + /// `{ "player_name": "...", "race_id": "...", "gender_preset": + /// "male"|"female", "color": [r, g, b, a] (each 0..=255), + /// "is_human": bool }`. Missing keys fall through to defaults. + /// + /// The `slot` field on the resulting `PresentationPlayer` is taken + /// from the argument, NOT from the JSON, so the caller cannot + /// accidentally desync the side-table from `inner.players`. + /// Returns `false` on parse failure or out-of-range slot. + #[func] + fn set_player_presentation_json(&mut self, slot: i64, json: GString) -> bool { + if slot < 0 || slot > u8::MAX as i64 { + godot_error!( + "GdGameState::set_player_presentation_json: slot {slot} out of u8 range" + ); + return false; + } + let slot_u8 = slot as u8; + let value: serde_json::Value = + match serde_json::from_str(json.to_string().as_str()) { + Ok(v) => v, + Err(e) => { + godot_error!( + "GdGameState::set_player_presentation_json: parse failed: {e}" + ); + return false; + } + }; + let obj = match value.as_object() { + Some(o) => o, + None => { + godot_error!( + "GdGameState::set_player_presentation_json: JSON root is not an object" + ); + return false; + } + }; + let player_name = obj + .get("player_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let race_id = obj + .get("race_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let gender_preset = obj + .get("gender_preset") + .and_then(|v| v.as_str()) + .unwrap_or("male") + .to_string(); + let is_human = obj.get("is_human").and_then(|v| v.as_bool()).unwrap_or(false); + let mut color = [255u8; 4]; + if let Some(arr) = obj.get("color").and_then(|v| v.as_array()) { + for (i, slot_v) in arr.iter().enumerate().take(4) { + let n = slot_v.as_f64().unwrap_or(1.0); + // Accept either 0..=1 floats (Godot Color convention) or + // 0..=255 ints. Anything > 1.0 is treated as a 0..=255 + // byte; otherwise it's a unit float. + let byte = if n > 1.0 { + n.clamp(0.0, 255.0).round() as u8 + } else { + (n.clamp(0.0, 1.0) * 255.0).round() as u8 + }; + color[i] = byte; + } + } + let pres = mc_core::PresentationPlayer { + slot: slot_u8, + player_name, + race_id, + gender_preset, + color, + is_human, + }; + // Upsert by slot — replace existing entry if present, else + // insert in sorted order so the side-table iteration order is + // deterministic (matches `inner.players` slot ordering). + if let Some(existing) = + self.presentation_players.iter_mut().find(|p| p.slot == slot_u8) + { + *existing = pres; + } else { + // Insert keeping sorted-by-slot. + let insert_at = self + .presentation_players + .iter() + .position(|p| p.slot > slot_u8) + .unwrap_or(self.presentation_players.len()); + self.presentation_players.insert(insert_at, pres); + } + true + } + + /// Read the presentation metadata for `slot` as a `Dictionary` shaped + /// to match `set_player_presentation_json`. Returns an empty + /// `Dictionary` if the slot has no entry. Used by GDScript readers + /// (HUD, save-slot metadata picker) after a `load_from_json`. + #[func] + fn get_player_presentation_dict(&self, slot: i64) -> Dictionary { + if slot < 0 || slot > u8::MAX as i64 { + return Dictionary::new(); + } + let slot_u8 = slot as u8; + let Some(pres) = self.presentation_players.iter().find(|p| p.slot == slot_u8) + else { + return Dictionary::new(); + }; + let mut dict = Dictionary::new(); + dict.set("slot", pres.slot as i64); + dict.set("player_name", GString::from(pres.player_name.as_str())); + dict.set("race_id", GString::from(pres.race_id.as_str())); + dict.set("gender_preset", GString::from(pres.gender_preset.as_str())); + dict.set("is_human", pres.is_human); + let mut color_arr = Array::::new(); + for byte in pres.color { + color_arr.push(byte as i64); + } + dict.set("color", color_arr); + dict + } + + /// Number of presentation entries currently held. Useful for tests + /// and for the SaveManager wrapper to detect whether a save has + /// populated presentation data. + #[func] + fn presentation_player_count(&self) -> i64 { + self.presentation_players.len() as i64 + } + + /// Drop every presentation entry. Called by GDScript at game-setup + /// boundary (`GameState.initialize_game`) so stale entries from a + /// previous game don't leak into a fresh save. + #[func] + fn clear_presentation_players(&mut self) { + self.presentation_players.clear(); + } + /// Attach a grid of the given size. Must be called before /// `stamp_lair` or before running turns that touch the world map. #[func] diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index 3ff657c8..aa6430c2 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -23,6 +23,7 @@ pub mod multi_turn_action; pub mod palace; pub mod perf; pub mod player; +pub mod player_presentation; pub mod production_origin; pub mod resources; pub mod scoring_weights; @@ -50,6 +51,7 @@ pub use lair::{LairCombatMode, LairId, SiegeOutcome, SiegePressure, SiegeState}; pub use resources::{ResourceKind, ResourceStockpile, StockpileError as ResourceStockpileError}; pub use scoring_weights::{LoadError, PersonalityDef, ScoringWeights}; pub use player::{HexCoord, PlayerPrologue}; +pub use player_presentation::PresentationPlayer; pub use production_origin::ProductionOrigin; pub use tech::TechDomain; pub use wonder::WonderId; diff --git a/src/simulator/crates/mc-core/src/player_presentation.rs b/src/simulator/crates/mc-core/src/player_presentation.rs new file mode 100644 index 00000000..b68c3644 --- /dev/null +++ b/src/simulator/crates/mc-core/src/player_presentation.rs @@ -0,0 +1,88 @@ +//! Presentation-only player metadata. +//! +//! Per Rail 3 (GDScript is presentation only) and the p2-72a Wall-2 decision, +//! UI-only player fields — display name, race id, gender preset, banner colour, +//! human-vs-AI flag — must not pollute `mc_turn::PlayerState` (simulation state). +//! Instead, they live in this side-table struct that the save envelope carries +//! alongside `GameState`. +//! +//! Populated by GDScript via `GdGameState::set_player_presentation_json` at +//! game-setup time and round-tripped through `GdGameState::serialize_full` / +//! `load_from_json`. + +use serde::{Deserialize, Serialize}; + +/// Pure-presentation per-player metadata. **Never read by `mc-turn` simulation.** +/// +/// Slot indexes the GameState players vector; `color` is RGBA mapped from a +/// `godot::Color` (`[r, g, b, a]` each in `0..=255`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PresentationPlayer { + /// 0-indexed player slot, aligned with `GameState::players`. + pub slot: u8, + /// Display name shown in HUD / save metadata. + pub player_name: String, + /// DataLoader race id (e.g. `"dwarf"`). + pub race_id: String, + /// `"male"` / `"female"` — drives pronouns + portrait selection. + pub gender_preset: String, + /// Banner / hex-tint colour as RGBA bytes (`0..=255`). + pub color: [u8; 4], + /// True for the local human player; false for AI. + pub is_human: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_round_trips_through_serde() { + let p = PresentationPlayer::default(); + let json = serde_json::to_string(&p).expect("serialize"); + let back: PresentationPlayer = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(p, back); + } + + #[test] + fn populated_round_trips_through_serde() { + let p = PresentationPlayer { + slot: 2, + player_name: "Thorin".into(), + race_id: "dwarf".into(), + gender_preset: "male".into(), + color: [51, 102, 255, 255], + is_human: true, + }; + let json = serde_json::to_string(&p).expect("serialize"); + let back: PresentationPlayer = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(p, back); + assert_eq!(back.player_name, "Thorin"); + assert_eq!(back.color, [51, 102, 255, 255]); + } + + #[test] + fn vec_round_trips() { + let players = vec![ + PresentationPlayer { + slot: 0, + player_name: "Thorin".into(), + race_id: "dwarf".into(), + gender_preset: "male".into(), + color: [51, 102, 255, 255], + is_human: true, + }, + PresentationPlayer { + slot: 1, + player_name: "Arwen".into(), + race_id: "high_elf".into(), + gender_preset: "female".into(), + color: [230, 51, 51, 255], + is_human: false, + }, + ]; + let json = serde_json::to_string(&players).expect("serialize"); + let back: Vec = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(players, back); + } +} diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index e3811e72..9875f14e 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -419,6 +419,77 @@ pub struct GameState { /// emitted before Stage 2b deserialize cleanly with an empty list. #[serde(default)] pub npc_buildings: Vec, + /// p2-72a Stage 3 — Wall-3 fields absorbed from GDScript `GameState`. + /// Era index into the game pack's `eras.json`. Game-pack-driven; the + /// engine defines no era names. Default `0` matches GDScript's `era: int = 0`. + #[serde(default)] + pub era: u32, + /// World-generation seed (from `game_settings["seed"]`). Stored so + /// climate / RNG-derivation systems can rebuild deterministic per-turn + /// seeds after a load. + #[serde(default)] + pub map_seed: u64, + /// Whose turn it is — index into `players`. `0` at game start; the turn + /// loop advances it after each `EndTurn`. Persisted so mid-game saves + /// resume on the correct player. + #[serde(default)] + pub current_player_index: u8, + /// Seed of the central RNG (`GameState.game_rng` in GDScript). Captured + /// at save so the random trajectory is reproducible. + #[serde(default)] + pub game_rng_seed: u64, + /// Live state of the central RNG, captured at save. Together with + /// `game_rng_seed` lets the engine resume the exact sequence on load. + #[serde(default)] + pub game_rng_state: u64, + /// AI-difficulty modifier axes. All eight axes default to neutral + /// values; the harness writes the active difficulty entry into this + /// field at game-setup time. Persisted so the difficulty applied on + /// turn 1 is the same one applied after a mid-game save+load. + #[serde(default)] + pub ai_difficulty: AiDifficulty, +} + +/// AI difficulty modifier axes (eight values), absorbed wholesale from the +/// `GameState.ai_*` field set in GDScript so a save round-trip preserves the +/// difficulty applied to AI players. +/// +/// Field names mirror the GDScript shape one-for-one — the harness stamps +/// each axis directly without rename. Defaults are neutral (`production_mult` +/// = `research_mult` = `1.0`; zero bonuses; empty per-player maps). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AiDifficulty { + /// Difficulty modifier applied to AI production each turn. `<1.0` = penalty, + /// `>1.0` = bonus. Default `1.0`. + pub production_mult: f32, + /// Difficulty modifier applied to AI research (science) each turn. Default `1.0`. + pub research_mult: f32, + /// Gold added to every AI player at game start for the current difficulty tier. + pub starting_gold_bonus: i32, + /// Extra warrior-class units spawned per AI city at game start. + pub extra_starting_units: u32, + /// ID of the extra starting unit. Default `"warrior"`. + pub extra_unit_id: String, + /// Per-player production multiplier overrides. Key = player index. When + /// non-empty, the per-player value takes precedence over `production_mult`. + pub per_player_production_mult: BTreeMap, + /// Per-player research multiplier overrides — same semantics as + /// `per_player_production_mult`. + pub per_player_research_mult: BTreeMap, +} + +impl Default for AiDifficulty { + fn default() -> Self { + Self { + production_mult: 1.0, + research_mult: 1.0, + starting_gold_bonus: 0, + extra_starting_units: 0, + extra_unit_id: "warrior".to_string(), + per_player_production_mult: BTreeMap::new(), + per_player_research_mult: BTreeMap::new(), + } + } } /// p2-55: scratch staging for capture / ransom / destroy events fired during @@ -1062,3 +1133,84 @@ pub struct TechState { pub researched: Vec, pub progress: BTreeMap, } + +#[cfg(test)] +mod p2_72a_save_round_trip_tests { + use super::*; + + #[test] + fn default_game_state_round_trips_through_serde() { + let g = GameState::default(); + let json = serde_json::to_string(&g).expect("serialize default GameState"); + let back: GameState = serde_json::from_str(&json).expect("deserialize default GameState"); + // Equality check by re-serialising the round-trip output and comparing + // the JSON — GameState's `#[serde(skip)]` fields make `PartialEq` impractical. + let json2 = serde_json::to_string(&back).expect("re-serialize"); + assert_eq!(json, json2, "default GameState must byte-equal across round-trip"); + } + + #[test] + fn wall3_fields_default_to_zero() { + let g = GameState::default(); + assert_eq!(g.era, 0); + assert_eq!(g.map_seed, 0); + assert_eq!(g.current_player_index, 0); + assert_eq!(g.game_rng_seed, 0); + assert_eq!(g.game_rng_state, 0); + assert_eq!(g.ai_difficulty.production_mult, 1.0); + assert_eq!(g.ai_difficulty.research_mult, 1.0); + assert_eq!(g.ai_difficulty.extra_unit_id, "warrior"); + assert!(g.ai_difficulty.per_player_production_mult.is_empty()); + } + + #[test] + fn wall3_fields_round_trip_with_values() { + let mut g = GameState::default(); + g.era = 3; + g.map_seed = 0xdead_beef_cafe; + g.current_player_index = 2; + g.game_rng_seed = 12345; + g.game_rng_state = 67890; + g.ai_difficulty.production_mult = 1.25; + g.ai_difficulty.research_mult = 0.85; + g.ai_difficulty.starting_gold_bonus = 100; + g.ai_difficulty.extra_starting_units = 1; + g.ai_difficulty.extra_unit_id = "spearman".into(); + g.ai_difficulty.per_player_production_mult.insert(1, 1.5); + g.ai_difficulty.per_player_research_mult.insert(2, 0.9); + + let json = serde_json::to_string(&g).expect("serialize"); + let back: GameState = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.era, 3); + assert_eq!(back.map_seed, 0xdead_beef_cafe); + assert_eq!(back.current_player_index, 2); + assert_eq!(back.game_rng_seed, 12345); + assert_eq!(back.game_rng_state, 67890); + assert_eq!(back.ai_difficulty.production_mult, 1.25); + assert_eq!(back.ai_difficulty.research_mult, 0.85); + assert_eq!(back.ai_difficulty.starting_gold_bonus, 100); + assert_eq!(back.ai_difficulty.extra_starting_units, 1); + assert_eq!(back.ai_difficulty.extra_unit_id, "spearman"); + assert_eq!(back.ai_difficulty.per_player_production_mult.get(&1), Some(&1.5)); + assert_eq!(back.ai_difficulty.per_player_research_mult.get(&2), Some(&0.9)); + } + + #[test] + fn pre_wall3_save_deserializes_with_defaults() { + // Simulate loading a save written before Wall-3 fields were added: + // only the legacy fields are present. `#[serde(default)]` must + // back-fill `era` / `map_seed` / etc. without error. + let legacy_json = r#"{ + "turn": 5, + "players": [], + "grid": null + }"#; + let back: GameState = serde_json::from_str(legacy_json) + .expect("legacy save must deserialize via serde(default)"); + assert_eq!(back.turn, 5); + assert_eq!(back.era, 0); + assert_eq!(back.map_seed, 0); + assert_eq!(back.current_player_index, 0); + assert_eq!(back.ai_difficulty.production_mult, 1.0); + } +}