From be7c8242752f24f3bb9f354edc5dff3b9b54df55 Mon Sep 17 00:00:00 2001 From: autocommit Date: Sat, 6 Jun 2026 16:21:16 -0700 Subject: [PATCH] =?UTF-8?q?refactor(ecology):=20=E2=99=BB=EF=B8=8F=20Impro?= =?UTF-8?q?ve=20ecology=20state=20serialization=20to=20enable=20full=20con?= =?UTF-8?q?tinuation=20state=20persistence=20for=20save=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/api-gdext/src/lib.rs | 48 +++++++-------- src/simulator/crates/mc-ecology/src/engine.rs | 59 +++++++++++++++++++ src/simulator/crates/mc-ecology/src/lib.rs | 2 +- src/simulator/crates/mc-worldsim/src/lib.rs | 34 ++++++----- 4 files changed, 100 insertions(+), 43 deletions(-) diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index ff412802..86d096f5 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -799,49 +799,45 @@ impl GdFaunaEcology { out } - /// Serialize the live per-tile fauna population map to a JSON string for + /// Serialize the engine's full **continuation state** to a JSON string for /// save persistence (Increment 2 — closes the `EcologyState.gd` "save not - /// round-tripped" gap). `tile_populations` is a `BTreeMap` keyed by an - /// `(i32, i32)` tuple, so the output is a deterministic JSON array of - /// `[[col, row], [slots…]]` pairs (JSON object keys must be strings). - /// - /// Only the populations are persisted — the species registry is rebuilt - /// from the canonical JSON pack on load (`EcologyState.gd::reset` + - /// `_ensure_species_registered`), and the restored slots reference those - /// species by numeric id. + /// round-tripped" gap properly). This is the complete mutable state that + /// evolves turn-over-turn: `tile_populations`, the `species_registry` + /// (INCLUDING procedurally-emerged species that a JSON-only re-registration + /// on load would not contain), and the `tick_count` emergence throttle. + /// Persisting only the populations would diverge on continue, because + /// emerged-species slots would be skipped and emergence would re-fire on + /// the wrong turns. #[func] - fn tile_populations_to_json(&self) -> GString { - let pairs: Vec<(&(i32, i32), &Vec)> = - self.inner.tile_populations.iter().collect(); - match serde_json::to_string(&pairs) { + fn continuation_state_to_json(&self) -> GString { + match serde_json::to_string(&self.inner.continuation_state()) { Ok(s) => s.into(), Err(e) => { - godot_error!("GdFaunaEcology::tile_populations_to_json: {e}"); + godot_error!("GdFaunaEcology::continuation_state_to_json: {e}"); GString::new() } } } - /// Restore the per-tile fauna population map from a JSON string produced by - /// `tile_populations_to_json`. Replaces the current map. Returns `false` on - /// parse failure (the existing map is left untouched). - /// - /// The caller MUST register the species library first (so the restored - /// slots' `species_id`s resolve during `tick_populations`); the standard - /// load path does this via `EcologyState.gd::_ensure_species_registered`. + /// Restore the engine's full continuation state from a JSON string produced + /// by `continuation_state_to_json`. Overwrites `tile_populations`, merges + /// the saved `species_registry` (so procedural species survive), and + /// restores `tick_count`. Returns `false` on parse failure (state left + /// untouched). The standard load path still re-registers the JSON-pack + /// species first (`EcologyState.gd`); this merges the procedural ones on top. #[func] - fn restore_tile_populations_from_json(&mut self, json: GString) -> bool { + fn restore_continuation_state_from_json(&mut self, json: GString) -> bool { let s = json.to_string(); if s.is_empty() { return false; } - match serde_json::from_str::)>>(&s) { - Ok(pairs) => { - self.inner.tile_populations = pairs.into_iter().collect(); + match serde_json::from_str::(&s) { + Ok(state) => { + self.inner.restore_continuation_state(state); true } Err(e) => { - godot_error!("GdFaunaEcology::restore_tile_populations_from_json: {e}"); + godot_error!("GdFaunaEcology::restore_continuation_state_from_json: {e}"); false } } diff --git a/src/simulator/crates/mc-ecology/src/engine.rs b/src/simulator/crates/mc-ecology/src/engine.rs index cb2396c1..b71cb801 100644 --- a/src/simulator/crates/mc-ecology/src/engine.rs +++ b/src/simulator/crates/mc-ecology/src/engine.rs @@ -1025,6 +1025,65 @@ impl EcologyEngine { .or_default() .push(slot); } + + /// Capture the engine's mutable **continuation state** for save persistence. + /// + /// This is the complete set of fields that evolve turn-over-turn and must + /// be restored for a loaded game to continue identically to one that never + /// saved: + /// - `tile_populations` — the per-tile fauna populations. + /// - `species_registry` — INCLUDES procedural species registered by + /// `run_emergence` mid-game (it takes `&mut species_registry`), which a + /// JSON-only re-registration on load would NOT contain. Without this, + /// restored population slots referencing emerged species would be skipped + /// by `tick_populations` and the run would diverge. + /// - `tick_count` — the emergence-throttle counter; a fresh engine starts + /// at 0 and would fire emergence on different turns. + /// + /// The data-driven configs (`EcologyConfig`, `ClassificationConfig`, lair / + /// behavior / stressor configs, `species_library`, biome multipliers) are + /// NOT continuation state — they are reloaded from the canonical JSON pack + /// on `reset()` and are identical across save/load by construction. + #[must_use] + pub fn continuation_state(&self) -> EcologyContinuationState { + EcologyContinuationState { + tile_populations: self.tile_populations.clone(), + species_registry: self.species_registry.clone(), + tick_count: self.tick_count, + } + } + + /// Restore the mutable continuation state captured by + /// [`continuation_state`](Self::continuation_state). Overwrites + /// `tile_populations`, merges the saved `species_registry` (so procedural + /// species survive), and restores `tick_count`. Call on a freshly-built + /// engine whose configs are already loaded. + pub fn restore_continuation_state(&mut self, state: EcologyContinuationState) { + self.tile_populations = state.tile_populations; + // Merge rather than replace: keep any JSON-pack species already + // registered, and add back the procedural ones captured at save time. + for (id, sp) in state.species_registry { + self.species_registry.insert(id, sp); + } + self.tick_count = state.tick_count; + } +} + +/// Serializable snapshot of an [`EcologyEngine`]'s mutable continuation state. +/// +/// Round-trips via serde (all three fields are serde types: `PopulationSlot`, +/// `Species`, `u64`). `BTreeMap`/`HashMap` of these serialize deterministically +/// for `BTreeMap`; the registry is a `HashMap` but its contents are restored by +/// key so order is irrelevant to correctness. Persisted as the opaque-JSON +/// `worldsim_state` payload in `mc_save::SaveFile`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct EcologyContinuationState { + /// Per-tile fauna populations. + pub tile_populations: BTreeMap<(i32, i32), Vec>, + /// Full species registry, including procedurally-emerged species. + pub species_registry: HashMap, + /// Emergence-throttle tick counter. + pub tick_count: u64, } /// Deterministic hash for seed dispersal RNG — seeded from tick, tile coords, and species ID. diff --git a/src/simulator/crates/mc-ecology/src/lib.rs b/src/simulator/crates/mc-ecology/src/lib.rs index 6de6b6e4..2f102b4d 100644 --- a/src/simulator/crates/mc-ecology/src/lib.rs +++ b/src/simulator/crates/mc-ecology/src/lib.rs @@ -41,7 +41,7 @@ pub use flora_select::{TerrainFloraIndex, FloraSpec, SelectedFlora, FloraLayer, pub use fauna_select::{TerrainFaunaIndex, FaunaSpec, FaunaManifest, SelectedFauna, pick_fauna_for_tile, domain_gate as fauna_domain_gate}; pub use fauna_glyphs::{FaunaGlyphCluster, lineage_to_glyph_cluster}; pub use config::{DispersalConfig, EcologyConfig, FloraFeedbackConfig}; -pub use engine::{EcologyEngine, load_biome_emergence_multipliers_json}; +pub use engine::{EcologyContinuationState, EcologyEngine, load_biome_emergence_multipliers_json}; pub use biological::{advance_bloom_streak, derive_biological_events, BiologicalEvent, BiologicalThresholds}; pub use events::{EventCategory, EventTierData, load_event_categories}; pub use species::load_species_library; diff --git a/src/simulator/crates/mc-worldsim/src/lib.rs b/src/simulator/crates/mc-worldsim/src/lib.rs index 439ab2fe..43f449a6 100644 --- a/src/simulator/crates/mc-worldsim/src/lib.rs +++ b/src/simulator/crates/mc-worldsim/src/lib.rs @@ -143,19 +143,21 @@ impl WorldSim { /// Overwrite the persisted worldsim side-state on a freshly-constructed /// `WorldSim` after a load: the per-tile eco-damage accumulator and the - /// live fauna population map. The species registry must already be - /// populated (e.g. via `EcologyEngine::with_species_library` + - /// re-registration) before calling, so the restored slots' `species_id`s - /// resolve on the next `tick_populations`. This is the in-Rust mirror of - /// the api-gdext `GdWorldSim::restore_eco_map_from_json` + - /// `GdFaunaEcology::restore_tile_populations_from_json` pair. + /// ecology engine's full **continuation state** (`tile_populations` + + /// `species_registry`, including procedurally-emerged species, + the + /// `tick_count` emergence throttle). The fresh engine's configs must + /// already be loaded; the continuation state carries everything that + /// evolves turn-over-turn so the loaded game continues byte-identically. + /// This is the in-Rust mirror of the api-gdext + /// `GdWorldSim::restore_eco_map_from_json` + + /// `GdFaunaEcology::restore_continuation_state_from_json` pair. pub fn restore_state( &mut self, eco_map: BTreeMap<(u16, u16), TileEcoState>, - tile_populations: BTreeMap<(i32, i32), Vec>, + ecology_state: mc_ecology::EcologyContinuationState, ) { self.eco_map = eco_map; - self.ecology.tile_populations = tile_populations; + self.ecology.restore_continuation_state(ecology_state); } /// Advance the whole world by one turn: discrete game turn, then the @@ -449,12 +451,12 @@ mod tests { saved.step(&mut saved_state); } - // Serialize exactly what the game persists: eco_map + tile_populations - // (the opaque-JSON `worldsim_state` payload). BTreeMap → deterministic. + // Serialize exactly what the game persists: eco_map + the ecology + // engine's full continuation state (the opaque-JSON `worldsim_state` + // payload). BTreeMap → deterministic. let eco_json = serde_json::to_string(&saved.eco_map).expect("ser eco_map"); - let pop_pairs: Vec<(&(i32, i32), &Vec)> = - saved.ecology().tile_populations.iter().collect(); - let pop_json = serde_json::to_string(&pop_pairs).expect("ser tile_populations"); + let ecology_json = + serde_json::to_string(&saved.ecology().continuation_state()).expect("ser ecology"); // The grid is part of the game save too (mc-save `grid` field); carry it. let grid_snapshot = saved_state.grid.clone(); let turn_snapshot = saved_state.turn; @@ -463,9 +465,9 @@ mod tests { let mut restored = make_worldsim_registry_only(SEED); let eco_map: BTreeMap<(u16, u16), TileEcoState> = serde_json::from_str(&eco_json).expect("de eco_map"); - let pop_pairs: Vec<((i32, i32), Vec)> = - serde_json::from_str(&pop_json).expect("de tile_populations"); - restored.restore_state(eco_map, pop_pairs.into_iter().collect()); + let ecology_state: mc_ecology::EcologyContinuationState = + serde_json::from_str(&ecology_json).expect("de ecology"); + restored.restore_state(eco_map, ecology_state); let mut restored_state = make_state(); restored_state.grid = grid_snapshot; restored_state.turn = turn_snapshot;