From b0069856f8b6f84446dac133e75c5aa6a84058f9 Mon Sep 17 00:00:00 2001 From: autocommit Date: Sat, 6 Jun 2026 16:42:16 -0700 Subject: [PATCH] =?UTF-8?q?fix(simulator):=20=F0=9F=90=9B=20Replace=20Hash?= =?UTF-8?q?Map=20with=20BTreeMap=20in=20EcologyEngine=E2=80=99s=20continua?= =?UTF-8?q?tion=20state=20to=20ensure=20deterministic=20serialization=20or?= =?UTF-8?q?der=20for=20species=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/crates/mc-ecology/src/engine.rs | 55 ++++++++++++++++--- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/src/simulator/crates/mc-ecology/src/engine.rs b/src/simulator/crates/mc-ecology/src/engine.rs index b71cb801..903e3fe3 100644 --- a/src/simulator/crates/mc-ecology/src/engine.rs +++ b/src/simulator/crates/mc-ecology/src/engine.rs @@ -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>, - /// Full species registry, including procedurally-emerged species. - pub species_registry: HashMap, + /// 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, /// Emergence-throttle tick counter. pub tick_count: u64, } +/// Serde adapter for `EcologyContinuationState::tile_populations`: represents the +/// tuple-keyed `BTreeMap<(i32, i32), Vec>` 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( + map: &BTreeMap<(i32, i32), Vec>, + serializer: S, + ) -> Result { + let pairs: Vec<(&(i32, i32), &Vec)> = map.iter().collect(); + pairs.serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result>, D::Error> { + let pairs: Vec<((i32, i32), Vec)> = 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 {