fix(simulator): 🐛 Replace HashMap with BTreeMap in EcologyEngine’s continuation state to ensure deterministic serialization order for species registry

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-06 16:42:16 -07:00
parent 2de6051c5f
commit b0069856f8

View file

@ -1048,7 +1048,14 @@ impl EcologyEngine {
pub fn continuation_state(&self) -> EcologyContinuationState {
EcologyContinuationState {
tile_populations: self.tile_populations.clone(),
species_registry: self.species_registry.clone(),
// Collect the runtime HashMap into a BTreeMap so serialization order
// is deterministic (byte-stable saves) — HashMap iteration order
// varies across instances and would break round-trip byte-equality.
species_registry: self
.species_registry
.iter()
.map(|(id, sp)| (*id, sp.clone()))
.collect(),
tick_count: self.tick_count,
}
}
@ -1071,21 +1078,51 @@ impl EcologyEngine {
/// 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`.
/// Round-trips via serde, byte-stably: `tile_populations` serializes as an
/// ordered sequence of pairs (serde_json rejects tuple map keys), and
/// `species_registry` is a `BTreeMap` so its JSON key order is deterministic.
/// 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.
/// Per-tile fauna populations. Serialized as an ordered sequence of
/// `((col, row), slots)` pairs rather than a JSON object: serde_json cannot
/// encode a map with tuple keys as object keys ("key must be a string"), and
/// this representation round-trips losslessly while staying deterministic
/// (BTreeMap iteration is sorted).
#[serde(with = "tile_populations_serde")]
pub tile_populations: BTreeMap<(i32, i32), Vec<PopulationSlot>>,
/// Full species registry, including procedurally-emerged species.
pub species_registry: HashMap<u32, Species>,
/// Full species registry, including procedurally-emerged species. A
/// `BTreeMap` (not the engine's runtime `HashMap`) so JSON key order is
/// deterministic and saves are byte-stable.
pub species_registry: BTreeMap<u32, Species>,
/// Emergence-throttle tick counter.
pub tick_count: u64,
}
/// Serde adapter for `EcologyContinuationState::tile_populations`: represents the
/// tuple-keyed `BTreeMap<(i32, i32), Vec<PopulationSlot>>` as an ordered sequence
/// of `((col, row), slots)` pairs so it survives JSON serialization (serde_json
/// rejects non-string map keys). Deterministic: BTreeMap iterates in sorted order.
mod tile_populations_serde {
use super::{BTreeMap, PopulationSlot};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer>(
map: &BTreeMap<(i32, i32), Vec<PopulationSlot>>,
serializer: S,
) -> Result<S::Ok, S::Error> {
let pairs: Vec<(&(i32, i32), &Vec<PopulationSlot>)> = map.iter().collect();
pairs.serialize(serializer)
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<(i32, i32), Vec<PopulationSlot>>, D::Error> {
let pairs: Vec<((i32, i32), Vec<PopulationSlot>)> = Vec::deserialize(deserializer)?;
Ok(pairs.into_iter().collect())
}
}
/// Deterministic hash for seed dispersal RNG — seeded from tick, tile coords, and species ID.
/// Returns a u64 that can be converted to [0, 1) by dividing by u64::MAX.
fn seed_dispersal_hash(tick: u64, col: i32, row: i32, species_id: u32) -> u64 {