feat(@projects/@magic-civilization): add presentation player metadata and save envelope

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-12 03:47:08 -07:00
parent 8f57d63d37
commit dbed870401
4 changed files with 527 additions and 61 deletions

View file

@ -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<mc_core::PresentationPlayer>,
base: Base<RefCounted>,
}
/// 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<mc_core::PresentationPlayer>,
}
impl SaveEnvelope {
/// Current envelope version.
const CURRENT_VERSION: u32 = 1;
}
#[godot_api]
impl IRefCounted for GdGameState {
fn init(base: Base<RefCounted>) -> 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::<mc_turn::game_state::GameState>(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<PresentationPlayer>` 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<SaveEnvelope, _> =
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::<i64>::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]

View file

@ -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;

View file

@ -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<PresentationPlayer> = serde_json::from_str(&json).expect("deserialize");
assert_eq!(players, back);
}
}

View file

@ -419,6 +419,77 @@ pub struct GameState {
/// emitted before Stage 2b deserialize cleanly with an empty list.
#[serde(default)]
pub npc_buildings: Vec<mc_core::BuildingEntity>,
/// 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<u8, f32>,
/// Per-player research multiplier overrides — same semantics as
/// `per_player_production_mult`.
pub per_player_research_mult: BTreeMap<u8, f32>,
}
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<String>,
pub progress: BTreeMap<String, u32>,
}
#[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);
}
}