refactor(ecology): ♻️ Improve ecology state serialization to enable full continuation state persistence for save functionality

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-06 16:21:16 -07:00
parent 061b6f0f7b
commit be7c824275
4 changed files with 100 additions and 43 deletions

View file

@ -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<PopulationSlot>)> =
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::<Vec<((i32, i32), Vec<PopulationSlot>)>>(&s) {
Ok(pairs) => {
self.inner.tile_populations = pairs.into_iter().collect();
match serde_json::from_str::<mc_ecology::EcologyContinuationState>(&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
}
}

View file

@ -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<PopulationSlot>>,
/// Full species registry, including procedurally-emerged species.
pub species_registry: HashMap<u32, Species>,
/// Emergence-throttle tick counter.
pub tick_count: u64,
}
/// Deterministic hash for seed dispersal RNG — seeded from tick, tile coords, and species ID.

View file

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

View file

@ -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<mc_ecology::population::PopulationSlot>>,
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<mc_ecology::population::PopulationSlot>)> =
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<mc_ecology::population::PopulationSlot>)> =
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;