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:
parent
061b6f0f7b
commit
be7c824275
4 changed files with 100 additions and 43 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue