feat(@projects/@magic-civilization): ✨ add presentation player metadata and save envelope
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
8f57d63d37
commit
dbed870401
4 changed files with 527 additions and 61 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
88
src/simulator/crates/mc-core/src/player_presentation.rs
Normal file
88
src/simulator/crates/mc-core/src/player_presentation.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue