Skip to main content

elevator_core/
snapshot.rs

1//! World snapshot for save/load functionality.
2//!
3//! Provides [`WorldSnapshot`](crate::snapshot::WorldSnapshot) which captures the full simulation state
4//! (all entities, components, groups, metrics, tick counter) in a
5//! serializable form. Games choose the serialization format via serde.
6//!
7//! Extension component *data* is included in the snapshot. After restoring,
8//! call [`Simulation::load_extensions_with`](crate::sim::Simulation::load_extensions_with)
9//! to register types and materialize the data.
10
11use crate::components::{
12    AccessControl, CarCall, DestinationQueue, Elevator, HallCall, Line, LineKind, Patience,
13    Position, Preferences, Rider, Route, Stop, Velocity,
14};
15use crate::entity::EntityId;
16use crate::ids::GroupId;
17use crate::metrics::Metrics;
18use crate::stop::StopId;
19use crate::tagged_metrics::MetricTags;
20use serde::{Deserialize, Serialize};
21use std::collections::{BTreeMap, HashMap, HashSet};
22
23/// Serializable snapshot of a single entity's components.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct EntitySnapshot {
26    /// The original `EntityId` (used for remapping cross-references on restore).
27    pub original_id: EntityId,
28    /// Position component (if present).
29    pub position: Option<Position>,
30    /// Velocity component (if present).
31    pub velocity: Option<Velocity>,
32    /// Elevator component (if present).
33    pub elevator: Option<Elevator>,
34    /// Stop component (if present).
35    pub stop: Option<Stop>,
36    /// Rider component (if present).
37    pub rider: Option<Rider>,
38    /// Route component (if present).
39    pub route: Option<Route>,
40    /// Line component (if present).
41    #[serde(default)]
42    pub line: Option<Line>,
43    /// Patience component (if present).
44    pub patience: Option<Patience>,
45    /// Preferences component (if present).
46    pub preferences: Option<Preferences>,
47    /// Access control component (if present).
48    #[serde(default)]
49    pub access_control: Option<AccessControl>,
50    /// Whether this entity is disabled.
51    pub disabled: bool,
52    /// Energy profile (if present, requires `energy` feature).
53    #[cfg(feature = "energy")]
54    #[serde(default)]
55    pub energy_profile: Option<crate::energy::EnergyProfile>,
56    /// Energy metrics (if present, requires `energy` feature).
57    #[cfg(feature = "energy")]
58    #[serde(default)]
59    pub energy_metrics: Option<crate::energy::EnergyMetrics>,
60    /// Service mode (if present).
61    #[serde(default)]
62    pub service_mode: Option<crate::components::ServiceMode>,
63    /// Destination queue (per-elevator; absent in legacy snapshots).
64    #[serde(default)]
65    pub destination_queue: Option<DestinationQueue>,
66    /// Car calls pressed inside this elevator (per-car; absent in legacy snapshots).
67    #[serde(default)]
68    pub car_calls: Vec<CarCall>,
69}
70
71/// Serializable snapshot of the entire simulation state.
72///
73/// Capture via [`Simulation::snapshot()`](crate::sim::Simulation::snapshot)
74/// and restore via [`WorldSnapshot::restore()`]. The game chooses the serde format
75/// (RON, JSON, bincode, etc.).
76///
77/// **Determinism:** the map fields below all use `BTreeMap` instead of
78/// `HashMap` so postcard/RON/JSON serialize entries in a deterministic
79/// (key-sorted) order. With `HashMap`, two snapshots of the same sim
80/// taken in different processes produced different bytes, defeating
81/// content-addressed caching and bit-equality replay (#254).
82///
83/// **Extension components are included** (via `extensions`); games must
84/// register types via `register_ext` before `restore()` to materialize them.
85/// **Custom resources** inserted via `world.insert_resource` are NOT
86/// snapshotted — only the built-in `MetricTags` resource is captured
87/// in `metric_tags`. Games using custom resources must save and restore
88/// them out-of-band (#296).
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct WorldSnapshot {
91    /// Schema version of this snapshot. Bumped on incompatible changes
92    /// to the snapshot layout. Loading a snapshot whose version differs
93    /// from the current crate's expected version returns
94    /// [`SimError::SnapshotVersion`](crate::error::SimError::SnapshotVersion).
95    /// Legacy snapshots default to `0` (#295).
96    #[serde(default)]
97    pub version: u32,
98    /// Current simulation tick.
99    pub tick: u64,
100    /// Time delta per tick.
101    pub dt: f64,
102    /// All entities indexed by position in this vec.
103    /// `EntityId`s are regenerated on restore.
104    pub entities: Vec<EntitySnapshot>,
105    /// Elevator groups (references into entities by index).
106    pub groups: Vec<GroupSnapshot>,
107    /// Stop ID → entity index mapping. `BTreeMap` for deterministic
108    /// snapshot bytes across processes (#254).
109    pub stop_lookup: BTreeMap<StopId, usize>,
110    /// Config-time `ElevatorConfig.id` → entity index mapping. Absent
111    /// in legacy snapshots; on restore an empty map means
112    /// [`crate::sim::Simulation::elevator_entity`] returns `None` for
113    /// all ids (acceptable degradation — config-loaded host queries on
114    /// legacy snapshots fall back to the iteration path).
115    #[serde(default)]
116    pub elevator_lookup: BTreeMap<u32, usize>,
117    /// Config-time `LineConfig.id` → entity index mapping. Absent in
118    /// legacy snapshots (and on legacy-topology sims that never had a
119    /// `building.lines` block); on restore an empty map means
120    /// [`crate::sim::Simulation::line_entity`] returns `None` for all
121    /// ids — same fall-back semantics as `elevator_lookup`.
122    #[serde(default)]
123    pub line_lookup: BTreeMap<u32, usize>,
124    /// Global metrics at snapshot time.
125    pub metrics: Metrics,
126    /// Per-tag metric accumulators and entity-tag associations.
127    pub metric_tags: MetricTags,
128    /// Serialized extension component data: name → (`EntityId` → RON string).
129    /// Both maps are `BTreeMap` for deterministic snapshot bytes (#254).
130    pub extensions: BTreeMap<String, BTreeMap<EntityId, String>>,
131    /// Ticks per second (for `TimeAdapter` reconstruction).
132    pub ticks_per_second: f64,
133    /// All pending hall calls across every stop. Absent in legacy snapshots.
134    #[serde(default)]
135    pub hall_calls: Vec<HallCall>,
136    /// Rolling per-stop arrival log. Empty in legacy snapshots; on
137    /// restore the log's `(tick, stop)` entries have their stop IDs
138    /// remapped through `id_remap` so they line up with the newly
139    /// allocated entity IDs.
140    #[serde(default)]
141    pub arrival_log: crate::arrival_log::ArrivalLog,
142    /// Retention window for the arrival log (ticks). Captured so a
143    /// host-configured value (e.g. via
144    /// `Simulation::set_arrival_log_retention_ticks`) survives
145    /// snapshot round-trip; legacy snapshots default to
146    /// [`DEFAULT_ARRIVAL_WINDOW_TICKS`](crate::arrival_log::DEFAULT_ARRIVAL_WINDOW_TICKS).
147    #[serde(default)]
148    pub arrival_log_retention: crate::arrival_log::ArrivalLogRetention,
149    /// Mirror of `arrival_log` keyed on rider *destination* — what
150    /// powers the `DownPeak` classifier branch. Same remap semantics
151    /// as `arrival_log` on restore. Empty in legacy snapshots; the
152    /// detector silently under-classifies `DownPeak` until the post-
153    /// restore log refills.
154    #[serde(default)]
155    pub destination_log: crate::arrival_log::DestinationLog,
156    /// Traffic-mode classifier state. Carries the current mode,
157    /// thresholds, and last-update tick across snapshot round-trip
158    /// so a restored sim doesn't momentarily reset to `Idle` when
159    /// the metrics phase hasn't run yet.
160    #[serde(default)]
161    pub traffic_detector: crate::traffic_detector::TrafficDetector,
162    /// Per-car reposition cooldown eligibility. Entries map to the
163    /// tick when each car next becomes eligible for reposition. Empty
164    /// in legacy snapshots; on restore the map is remapped through
165    /// `id_remap` to match newly-allocated entity IDs.
166    #[serde(default)]
167    pub reposition_cooldowns: crate::dispatch::reposition::RepositionCooldowns,
168    /// Per-group serialized dispatcher configuration produced by
169    /// [`crate::dispatch::DispatchStrategy::snapshot_config`] and
170    /// replayed via
171    /// [`crate::dispatch::DispatchStrategy::restore_config`] on
172    /// restore. Round-trips the tunable weights configured via
173    /// `with_*` builder methods (e.g. `EtdDispatch::with_delay_weight`)
174    /// that [`BuiltinStrategy::instantiate`](crate::dispatch::BuiltinStrategy::instantiate)
175    /// can't reconstruct because it always calls `::new()`. Absent /
176    /// empty in legacy snapshots — they restore to default weights,
177    /// matching pre-fix behaviour.
178    #[serde(default)]
179    pub dispatch_config: BTreeMap<GroupId, String>,
180}
181
182/// Per-line snapshot info within a group.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct LineSnapshotInfo {
185    /// Index into the `entities` vec for the line entity.
186    pub entity_index: usize,
187    /// Indices into the `entities` vec for elevators on this line.
188    pub elevator_indices: Vec<usize>,
189    /// Indices into the `entities` vec for stops served by this line.
190    pub stop_indices: Vec<usize>,
191}
192
193/// Serializable representation of an elevator group.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct GroupSnapshot {
196    /// Group identifier.
197    pub id: GroupId,
198    /// Group name.
199    pub name: String,
200    /// Indices into the `entities` vec for elevators in this group.
201    pub elevator_indices: Vec<usize>,
202    /// Indices into the `entities` vec for stops in this group.
203    pub stop_indices: Vec<usize>,
204    /// The dispatch strategy used by this group.
205    pub strategy: crate::dispatch::BuiltinStrategy,
206    /// Per-line snapshot data. Empty in legacy snapshots.
207    #[serde(default)]
208    pub lines: Vec<LineSnapshotInfo>,
209    /// Optional repositioning strategy for idle elevators.
210    #[serde(default)]
211    pub reposition: Option<crate::dispatch::BuiltinReposition>,
212    /// Hall call mode for this group. Legacy snapshots default to `Classic`.
213    #[serde(default)]
214    pub hall_call_mode: crate::dispatch::HallCallMode,
215    /// Controller ack latency in ticks. Legacy snapshots default to `0`.
216    #[serde(default)]
217    pub ack_latency_ticks: u32,
218}
219
220/// Pending extension data from a snapshot, awaiting type registration.
221///
222/// Stored as a world resource after `restore()`. Call
223/// `sim.load_extensions()` after registering extension types to
224/// deserialize the data.
225pub(crate) struct PendingExtensions(pub(crate) BTreeMap<String, BTreeMap<EntityId, String>>);
226
227/// Factory function type for instantiating custom dispatch strategies by name.
228///
229/// Wired into [`RestoreOptions::custom_strategy_factory`] and used
230/// internally by [`WorldSnapshot::restore`] /
231/// [`Simulation::restore_bytes`](crate::sim::Simulation::restore_bytes)
232/// to look up dispatch strategies that aren't built-ins. Callers that
233/// need to hold or pass the factory by value can name this alias
234/// directly instead of repeating the trait-object spelling.
235pub type CustomStrategyFactory<'a> =
236    Option<&'a dyn Fn(&str) -> Option<Box<dyn crate::dispatch::DispatchStrategy>>>;
237
238/// Options for [`WorldSnapshot::restore`] and
239/// [`Simulation::restore_bytes`](crate::sim::Simulation::restore_bytes).
240///
241/// Construct via [`Default::default`] when the snapshot only uses
242/// built-in strategies, or via [`with_factory`](Self::with_factory)
243/// when groups in the snapshot reference [`Custom`] dispatch strategies
244/// that need to be re-instantiated by name. The struct is
245/// `#[non_exhaustive]` so future restore knobs can land without an API
246/// break.
247///
248/// [`Custom`]: crate::dispatch::BuiltinStrategy::Custom
249///
250/// ```no_run
251/// # use elevator_core::snapshot::{RestoreOptions, WorldSnapshot};
252/// # fn doit(snap: WorldSnapshot) -> Result<(), elevator_core::error::SimError> {
253/// // Built-ins only:
254/// let _sim = snap.restore(RestoreOptions::default())?;
255/// # Ok(()) }
256/// ```
257#[derive(Clone, Copy, Default)]
258#[non_exhaustive]
259pub struct RestoreOptions<'a> {
260    /// Factory mapping [`Custom`](crate::dispatch::BuiltinStrategy::Custom)
261    /// strategy names to fresh trait-object instances. `None` when the
262    /// snapshot only uses built-in strategies — restore returns
263    /// [`SimError::UnresolvedCustomStrategy`](crate::error::SimError::UnresolvedCustomStrategy)
264    /// if a snapshot group needs a custom strategy and the factory isn't
265    /// supplied (or returns `None`).
266    pub custom_strategy_factory: CustomStrategyFactory<'a>,
267}
268
269impl<'a> RestoreOptions<'a> {
270    /// Build a [`RestoreOptions`] with a custom-strategy factory wired
271    /// up. Use [`Default::default`] when no custom strategies are in
272    /// play.
273    #[must_use]
274    pub fn with_factory(
275        factory: &'a dyn Fn(&str) -> Option<Box<dyn crate::dispatch::DispatchStrategy>>,
276    ) -> Self {
277        Self {
278            custom_strategy_factory: Some(factory),
279        }
280    }
281}
282
283impl WorldSnapshot {
284    /// Schema version this build of `elevator-core` writes and accepts.
285    ///
286    /// Compare against [`WorldSnapshot::version`] to decide whether a
287    /// snapshot needs forward migration before [`WorldSnapshot::restore`]
288    /// will accept it.
289    pub const CURRENT_SCHEMA_VERSION: u32 = SNAPSHOT_SCHEMA_VERSION;
290
291    /// Forward-migrate a snapshot to [`CURRENT_SCHEMA_VERSION`](Self::CURRENT_SCHEMA_VERSION).
292    ///
293    /// `restore` rejects mismatched schema versions hard so silent
294    /// `#[serde(default)]` field-filling can't mask a mismatch. Callers
295    /// that want to load older snapshots route them through this method
296    /// first:
297    ///
298    /// ```no_run
299    /// # use elevator_core::snapshot::{RestoreOptions, WorldSnapshot};
300    /// # fn load(snap: WorldSnapshot) -> Result<(), elevator_core::error::SimError> {
301    /// let sim = snap.migrate()?.restore(RestoreOptions::default())?;
302    /// # let _ = sim;
303    /// # Ok(()) }
304    /// ```
305    ///
306    /// Future schema bumps wire their step-up migrations into the match
307    /// arm below; each step consumes a snapshot at version `n` and
308    /// produces one at `n + 1`. The outer `while` drives the chain to
309    /// [`CURRENT_SCHEMA_VERSION`](Self::CURRENT_SCHEMA_VERSION) with
310    /// O(1) stack depth regardless of how far behind the input is.
311    ///
312    /// # Errors
313    ///
314    /// Returns [`SimError::SnapshotVersion`](crate::error::SimError::SnapshotVersion)
315    /// for snapshots from a version this build doesn't know how to
316    /// migrate (either `version > CURRENT_SCHEMA_VERSION`, or
317    /// `version < CURRENT_SCHEMA_VERSION` with no migration path
318    /// registered).
319    pub fn migrate(self) -> Result<Self, crate::error::SimError> {
320        // Snapshots from a future build can't be down-migrated — the
321        // newer build defines what the new shape *is*.
322        if self.version > Self::CURRENT_SCHEMA_VERSION {
323            return Err(crate::error::SimError::SnapshotVersion {
324                saved: format!("schema {}", self.version),
325                current: format!("schema {}", Self::CURRENT_SCHEMA_VERSION),
326            });
327        }
328        if self.version == Self::CURRENT_SCHEMA_VERSION {
329            return Ok(self);
330        }
331        // Schema is at v1 today, so no step-up arms exist; any
332        // version below current with no migration path errors out.
333        // The intended shape once schema bumps land is an iterative
334        // step_up loop with O(1) stack depth regardless of how many
335        // schema versions an archived snapshot has to traverse:
336        //
337        //     let mut snap = self;
338        //     while snap.version < Self::CURRENT_SCHEMA_VERSION {
339        //         snap = step_up_one(snap)?;
340        //     }
341        //     Ok(snap)
342        //
343        // where `step_up_one` matches on `snap.version` and emits one
344        // arm per `n -> n + 1` upgrade.
345        Err(crate::error::SimError::SnapshotVersion {
346            saved: format!("schema {}", self.version),
347            current: format!("schema {}", Self::CURRENT_SCHEMA_VERSION),
348        })
349    }
350
351    /// Restore a simulation from this snapshot.
352    ///
353    /// Built-in strategies (Scan, Look, `NearestCar`, ETD, RSR,
354    /// Destination) are auto-restored. For [`Custom`] strategies,
355    /// configure [`RestoreOptions`] with a factory; pass
356    /// [`RestoreOptions::default()`] when only built-in strategies are
357    /// in play. The struct is `#[non_exhaustive]` so future restore
358    /// knobs land without an API break.
359    ///
360    /// [`Custom`]: crate::dispatch::BuiltinStrategy::Custom
361    ///
362    /// # Errors
363    /// Returns [`SimError::UnresolvedCustomStrategy`](crate::error::SimError::UnresolvedCustomStrategy)
364    /// if a snapshot group uses a `Custom` strategy and the factory
365    /// returns `None` (or no factory was supplied).
366    ///
367    /// To restore extension components, call
368    /// [`Simulation::load_extensions_with`](crate::sim::Simulation::load_extensions_with)
369    /// on the returned simulation.
370    pub fn restore(
371        self,
372        options: RestoreOptions<'_>,
373    ) -> Result<crate::sim::Simulation, crate::error::SimError> {
374        use crate::world::{SortedStops, World};
375
376        let RestoreOptions {
377            custom_strategy_factory,
378            ..
379        } = options;
380
381        // Reject snapshots from incompatible schema versions. Without this
382        // guard, `#[serde(default)]` on newly-added fields would silently
383        // fill them in and mask the mismatch. The bytes envelope path
384        // separately checks the crate semver string.
385        if self.version != SNAPSHOT_SCHEMA_VERSION {
386            return Err(crate::error::SimError::SnapshotVersion {
387                saved: format!("schema {}", self.version),
388                current: format!("schema {SNAPSHOT_SCHEMA_VERSION}"),
389            });
390        }
391
392        let mut world = World::new();
393
394        // Phase 1: spawn all entities and build old→new EntityId mapping.
395        let (index_to_id, id_remap) = Self::spawn_entities(&mut world, &self.entities);
396
397        // Phase 2: attach components with remapped EntityIds.
398        Self::attach_components(&mut world, &self.entities, &index_to_id, &id_remap);
399
400        // Phase 2b: re-register hall calls (cross-reference stops/cars/riders).
401        self.attach_hall_calls(&mut world, &id_remap);
402
403        // Rebuild sorted stops index.
404        let mut sorted: Vec<(f64, EntityId)> = world
405            .iter_stops()
406            .map(|(eid, stop)| (stop.position, eid))
407            .collect();
408        sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
409        world.insert_resource(SortedStops(sorted));
410
411        // Rebuild groups, stop/elevator/line lookup, dispatchers, and
412        // extensions (borrows self).
413        let (mut groups, stop_lookup, elevator_lookup, line_lookup, dispatchers, strategy_ids) =
414            self.rebuild_groups_and_dispatchers(&index_to_id, custom_strategy_factory)?;
415
416        Self::fix_legacy_line_entities(&mut groups, &mut world);
417
418        // Resource installation moves several fields out of self via partial
419        // moves; remaining fields (dispatch_config, groups, entities, hall_calls,
420        // metrics, etc.) stay accessible for the construction steps below.
421        Self::install_runtime_resources(
422            &mut world,
423            &id_remap,
424            self.tick,
425            &self.extensions,
426            self.metric_tags,
427            self.arrival_log,
428            self.arrival_log_retention,
429            self.destination_log,
430            self.traffic_detector,
431            self.reposition_cooldowns,
432        );
433
434        let mut sim = crate::sim::Simulation::from_parts(
435            world,
436            self.tick,
437            self.dt,
438            groups,
439            stop_lookup,
440            elevator_lookup,
441            line_lookup,
442            dispatchers,
443            strategy_ids,
444            self.metrics,
445            self.ticks_per_second,
446        );
447
448        Self::replay_dispatcher_tuning(&mut sim, &self.dispatch_config);
449        Self::restore_reposition_strategies(&mut sim, &self.groups);
450
451        Self::emit_dangling_warnings(
452            &self.entities,
453            &self.hall_calls,
454            &id_remap,
455            self.tick,
456            &mut sim,
457        );
458
459        Ok(sim)
460    }
461
462    /// Replace synthetic legacy `LineInfo` entries (entity = `EntityId::default()`)
463    /// with real line entities spawned in the world. Pre-line snapshots stored
464    /// only group-level elevator/stop indices; the legacy single-line `LineInfo`
465    /// is materialised here so dispatch and rendering see a real entity.
466    fn fix_legacy_line_entities(
467        groups: &mut [crate::dispatch::ElevatorGroup],
468        world: &mut crate::world::World,
469    ) {
470        for group in groups.iter_mut() {
471            let group_id = group.id();
472            for line_info in group.lines_mut().iter_mut() {
473                if line_info.entity() != EntityId::default() {
474                    continue;
475                }
476                let (min_pos, max_pos) = line_info
477                    .serves()
478                    .iter()
479                    .filter_map(|&sid| world.stop(sid).map(|s| s.position))
480                    .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| {
481                        (lo.min(p), hi.max(p))
482                    });
483                let line_eid = world.spawn();
484                world.set_line(
485                    line_eid,
486                    Line {
487                        name: format!("Legacy-{group_id}"),
488                        group: group_id,
489                        orientation: crate::components::Orientation::Vertical,
490                        position: None,
491                        kind: LineKind::Linear {
492                            min: if min_pos.is_finite() { min_pos } else { 0.0 },
493                            max: if max_pos.is_finite() { max_pos } else { 0.0 },
494                        },
495                        max_cars: None,
496                    },
497                );
498                for &elev_eid in line_info.elevators() {
499                    if let Some(car) = world.elevator_mut(elev_eid) {
500                        car.line = line_eid;
501                    }
502                }
503                line_info.set_entity(line_eid);
504            }
505        }
506    }
507
508    /// Insert all post-entity world resources, remapping `EntityId`s where
509    /// they cross-reference. Without these `PredictiveParking`,
510    /// `DispatchManifest::arrivals_at`, the down-peak classifier branch,
511    /// host-configured retention, and reposition cooldowns silently no-op
512    /// or fall back to defaults post-restore.
513    #[allow(clippy::too_many_arguments)]
514    fn install_runtime_resources(
515        world: &mut crate::world::World,
516        id_remap: &HashMap<EntityId, EntityId>,
517        tick: u64,
518        extensions: &BTreeMap<String, BTreeMap<EntityId, String>>,
519        metric_tags: MetricTags,
520        arrival_log: crate::arrival_log::ArrivalLog,
521        arrival_log_retention: crate::arrival_log::ArrivalLogRetention,
522        destination_log: crate::arrival_log::DestinationLog,
523        traffic_detector: crate::traffic_detector::TrafficDetector,
524        reposition_cooldowns: crate::dispatch::reposition::RepositionCooldowns,
525    ) {
526        let remapped_exts = Self::remap_extensions(extensions, id_remap);
527        world.insert_resource(PendingExtensions(remapped_exts));
528
529        let mut tags = metric_tags;
530        tags.remap_entity_ids(id_remap);
531        world.insert_resource(tags);
532
533        let mut log = arrival_log;
534        log.remap_entity_ids(id_remap);
535        world.insert_resource(log);
536        world.insert_resource(crate::arrival_log::CurrentTick(tick));
537        world.insert_resource(arrival_log_retention);
538
539        let mut dest_log = destination_log;
540        dest_log.remap_entity_ids(id_remap);
541        world.insert_resource(dest_log);
542
543        // Detector is re-inserted last-writer-wins so the *classified* state
544        // carries forward; refresh_traffic_detector updates on the next
545        // metrics phase with fresh counts.
546        world.insert_resource(traffic_detector);
547
548        let mut cooldowns = reposition_cooldowns;
549        cooldowns.remap_entity_ids(id_remap);
550        world.insert_resource(cooldowns);
551    }
552
553    /// Replay per-group dispatcher tuning captured in the snapshot. Built-ins
554    /// with `with_*` builders override `snapshot_config`/`restore_config` to
555    /// round-trip their weights; strategies that don't override silently skip
556    /// (default `restore_config` is `Ok(())`), preserving pre-fix behaviour. A
557    /// deserialization failure surfaces as an event rather than a hard error
558    /// — the restored sim runs with defaults, same as a legacy snapshot.
559    fn replay_dispatcher_tuning(
560        sim: &mut crate::sim::Simulation,
561        dispatch_config: &std::collections::BTreeMap<GroupId, String>,
562    ) {
563        for (gid, serialized) in dispatch_config {
564            if let Some(dispatcher) = sim.dispatchers_mut().get_mut(gid)
565                && let Err(err) = dispatcher.restore_config(serialized)
566            {
567                sim.push_event(crate::events::Event::DispatchConfigNotRestored {
568                    group: *gid,
569                    reason: err,
570                });
571            }
572        }
573    }
574
575    /// Restore reposition strategies from group snapshots, emitting
576    /// [`Event::RepositionStrategyNotRestored`](crate::events::Event::RepositionStrategyNotRestored)
577    /// when an id can't be re-instantiated.
578    fn restore_reposition_strategies(sim: &mut crate::sim::Simulation, groups: &[GroupSnapshot]) {
579        for gs in groups {
580            let Some(ref repo_id) = gs.reposition else {
581                continue;
582            };
583            if let Some(strategy) = repo_id.instantiate() {
584                sim.set_reposition(gs.id, strategy, repo_id.clone());
585            } else {
586                sim.push_event(crate::events::Event::RepositionStrategyNotRestored {
587                    group: gs.id,
588                });
589            }
590        }
591    }
592
593    /// Spawn entities in the world and build the old→new `EntityId` mapping.
594    fn spawn_entities(
595        world: &mut crate::world::World,
596        entities: &[EntitySnapshot],
597    ) -> (Vec<EntityId>, HashMap<EntityId, EntityId>) {
598        let mut index_to_id: Vec<EntityId> = Vec::with_capacity(entities.len());
599        let mut id_remap: HashMap<EntityId, EntityId> = HashMap::new();
600        for snap in entities {
601            let new_id = world.spawn();
602            index_to_id.push(new_id);
603            id_remap.insert(snap.original_id, new_id);
604        }
605        (index_to_id, id_remap)
606    }
607
608    /// Attach components to spawned entities, remapping cross-references.
609    fn attach_components(
610        world: &mut crate::world::World,
611        entities: &[EntitySnapshot],
612        index_to_id: &[EntityId],
613        id_remap: &HashMap<EntityId, EntityId>,
614    ) {
615        let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
616        let remap_opt = |old: Option<EntityId>| -> Option<EntityId> { old.map(&remap) };
617
618        for (i, snap) in entities.iter().enumerate() {
619            let eid = index_to_id[i];
620
621            if let Some(pos) = snap.position {
622                world.set_position(eid, pos);
623            }
624            if let Some(vel) = snap.velocity {
625                world.set_velocity(eid, vel);
626            }
627            if let Some(ref elev) = snap.elevator {
628                let mut e = elev.clone();
629                e.riders = e.riders.iter().map(|&r| remap(r)).collect();
630                e.target_stop = remap_opt(e.target_stop);
631                e.line = remap(e.line);
632                e.restricted_stops = e.restricted_stops.iter().map(|&s| remap(s)).collect();
633                e.phase = match e.phase {
634                    crate::components::ElevatorPhase::MovingToStop(s) => {
635                        crate::components::ElevatorPhase::MovingToStop(remap(s))
636                    }
637                    crate::components::ElevatorPhase::Repositioning(s) => {
638                        crate::components::ElevatorPhase::Repositioning(remap(s))
639                    }
640                    other => other,
641                };
642                world.set_elevator(eid, e);
643            }
644            if let Some(ref stop) = snap.stop {
645                world.set_stop(eid, stop.clone());
646            }
647            if let Some(ref rider) = snap.rider {
648                use crate::components::RiderPhase;
649                let mut r = rider.clone();
650                r.current_stop = remap_opt(r.current_stop);
651                r.phase = match r.phase {
652                    RiderPhase::Boarding(e) => RiderPhase::Boarding(remap(e)),
653                    RiderPhase::Riding(e) => RiderPhase::Riding(remap(e)),
654                    RiderPhase::Exiting(e) => RiderPhase::Exiting(remap(e)),
655                    other => other,
656                };
657                world.set_rider(eid, r);
658            }
659            if let Some(ref route) = snap.route {
660                let mut rt = route.clone();
661                for leg in &mut rt.legs {
662                    leg.from = remap(leg.from);
663                    leg.to = remap(leg.to);
664                    if let crate::components::TransportMode::Line(ref mut l) = leg.via {
665                        *l = remap(*l);
666                    }
667                }
668                world.set_route(eid, rt);
669            }
670            if let Some(ref line) = snap.line {
671                world.set_line(eid, line.clone());
672            }
673            if let Some(patience) = snap.patience {
674                world.set_patience(eid, patience);
675            }
676            if let Some(prefs) = snap.preferences {
677                world.set_preferences(eid, prefs);
678            }
679            if let Some(ref ac) = snap.access_control {
680                let remapped =
681                    AccessControl::new(ac.allowed_stops().iter().map(|&s| remap(s)).collect());
682                world.set_access_control(eid, remapped);
683            }
684            if snap.disabled {
685                world.disable(eid);
686            }
687            #[cfg(feature = "energy")]
688            if let Some(ref profile) = snap.energy_profile {
689                world.set_energy_profile(eid, profile.clone());
690            }
691            #[cfg(feature = "energy")]
692            if let Some(ref em) = snap.energy_metrics {
693                world.set_energy_metrics(eid, em.clone());
694            }
695            if let Some(mode) = snap.service_mode {
696                world.set_service_mode(eid, mode);
697            }
698            if let Some(ref dq) = snap.destination_queue {
699                use crate::components::DestinationQueue as DQ;
700                let mut new_dq = DQ::new();
701                for &e in dq.queue() {
702                    new_dq.push_back(remap(e));
703                }
704                world.set_destination_queue(eid, new_dq);
705            }
706            Self::attach_car_calls(world, eid, &snap.car_calls, id_remap);
707        }
708    }
709
710    /// Re-register per-car floor button presses after entities are spawned.
711    fn attach_car_calls(
712        world: &mut crate::world::World,
713        car: EntityId,
714        car_calls: &[CarCall],
715        id_remap: &HashMap<EntityId, EntityId>,
716    ) {
717        if car_calls.is_empty() {
718            return;
719        }
720        let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
721        let Some(slot) = world.car_calls_mut(car) else {
722            return;
723        };
724        for cc in car_calls {
725            let mut c = cc.clone();
726            c.car = car;
727            c.floor = remap(c.floor);
728            c.pending_riders = c.pending_riders.iter().map(|&r| remap(r)).collect();
729            slot.push(c);
730        }
731    }
732
733    /// Re-register hall calls in the world after entities are spawned.
734    ///
735    /// `HallCall` cross-references stops, cars, riders, and optional
736    /// destinations — all `EntityId`s must be remapped through `id_remap`.
737    /// Pre-15.23 snapshots stored a single `assigned_car` field, silently
738    /// dropped by `#[serde(default)]` on `assigned_cars_by_line`; the
739    /// next dispatch pass repopulates the empty map, so no explicit
740    /// migration is attempted here.
741    fn attach_hall_calls(
742        &self,
743        world: &mut crate::world::World,
744        id_remap: &HashMap<EntityId, EntityId>,
745    ) {
746        let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
747        let remap_opt = |old: Option<EntityId>| -> Option<EntityId> { old.map(&remap) };
748        for hc in &self.hall_calls {
749            let mut c = hc.clone();
750            c.stop = remap(c.stop);
751            c.destination = remap_opt(c.destination);
752            c.assigned_cars_by_line = c
753                .assigned_cars_by_line
754                .iter()
755                .map(|(&line, &car)| (remap(line), remap(car)))
756                .collect();
757            c.pending_riders = c.pending_riders.iter().map(|&r| remap(r)).collect();
758            world.set_hall_call(c);
759        }
760    }
761
762    /// Rebuild groups, stop lookup, and dispatchers from snapshot data.
763    #[allow(clippy::type_complexity)]
764    fn rebuild_groups_and_dispatchers(
765        &self,
766        index_to_id: &[EntityId],
767        custom_strategy_factory: CustomStrategyFactory<'_>,
768    ) -> Result<
769        (
770            Vec<crate::dispatch::ElevatorGroup>,
771            HashMap<StopId, EntityId>,
772            HashMap<crate::config::ElevatorConfigId, EntityId>,
773            HashMap<crate::config::LineConfigId, EntityId>,
774            std::collections::BTreeMap<GroupId, Box<dyn crate::dispatch::DispatchStrategy>>,
775            std::collections::BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
776        ),
777        crate::error::SimError,
778    > {
779        use crate::dispatch::ElevatorGroup;
780
781        let groups: Vec<ElevatorGroup> = self
782            .groups
783            .iter()
784            .map(|gs| {
785                let elevator_entities: Vec<EntityId> = gs
786                    .elevator_indices
787                    .iter()
788                    .filter_map(|&i| index_to_id.get(i).copied())
789                    .collect();
790                let stop_entities: Vec<EntityId> = gs
791                    .stop_indices
792                    .iter()
793                    .filter_map(|&i| index_to_id.get(i).copied())
794                    .collect();
795
796                let lines = if gs.lines.is_empty() {
797                    // Legacy snapshots have no per-line data; create a single
798                    // synthetic LineInfo containing all elevators and stops.
799                    vec![crate::dispatch::LineInfo::new(
800                        EntityId::default(),
801                        elevator_entities,
802                        stop_entities,
803                    )]
804                } else {
805                    gs.lines
806                        .iter()
807                        .filter_map(|lsi| {
808                            let entity = index_to_id.get(lsi.entity_index).copied()?;
809                            Some(crate::dispatch::LineInfo::new(
810                                entity,
811                                lsi.elevator_indices
812                                    .iter()
813                                    .filter_map(|&i| index_to_id.get(i).copied())
814                                    .collect(),
815                                lsi.stop_indices
816                                    .iter()
817                                    .filter_map(|&i| index_to_id.get(i).copied())
818                                    .collect(),
819                            ))
820                        })
821                        .collect()
822                };
823
824                ElevatorGroup::new(gs.id, gs.name.clone(), lines)
825                    .with_hall_call_mode(gs.hall_call_mode)
826                    .with_ack_latency_ticks(gs.ack_latency_ticks)
827            })
828            .collect();
829
830        let stop_lookup: HashMap<StopId, EntityId> = self
831            .stop_lookup
832            .iter()
833            .filter_map(|(sid, &idx)| index_to_id.get(idx).map(|&eid| (*sid, eid)))
834            .collect();
835
836        let elevator_lookup: HashMap<crate::config::ElevatorConfigId, EntityId> = self
837            .elevator_lookup
838            .iter()
839            .filter_map(|(cid, &idx)| {
840                index_to_id
841                    .get(idx)
842                    .map(|&eid| (crate::config::ElevatorConfigId(*cid), eid))
843            })
844            .collect();
845
846        let line_lookup: HashMap<crate::config::LineConfigId, EntityId> = self
847            .line_lookup
848            .iter()
849            .filter_map(|(cid, &idx)| {
850                index_to_id
851                    .get(idx)
852                    .map(|&eid| (crate::config::LineConfigId(*cid), eid))
853            })
854            .collect();
855
856        let mut dispatchers = std::collections::BTreeMap::new();
857        let mut strategy_ids = std::collections::BTreeMap::new();
858        for (gs, group) in self.groups.iter().zip(groups.iter()) {
859            let strategy: Box<dyn crate::dispatch::DispatchStrategy> =
860                if let Some(builtin) = gs.strategy.instantiate() {
861                    builtin
862                } else if let crate::dispatch::BuiltinStrategy::Custom(ref name) = gs.strategy {
863                    custom_strategy_factory
864                        .and_then(|f| f(name))
865                        .ok_or_else(|| crate::error::SimError::UnresolvedCustomStrategy {
866                            name: name.clone(),
867                            group: group.id(),
868                        })?
869                } else {
870                    Box::new(crate::dispatch::scan::ScanDispatch::new())
871                };
872            dispatchers.insert(group.id(), strategy);
873            strategy_ids.insert(group.id(), gs.strategy.clone());
874        }
875
876        Ok((
877            groups,
878            stop_lookup,
879            elevator_lookup,
880            line_lookup,
881            dispatchers,
882            strategy_ids,
883        ))
884    }
885
886    /// Remap `EntityId`s in extension data using the old→new mapping.
887    fn remap_extensions(
888        extensions: &BTreeMap<String, BTreeMap<EntityId, String>>,
889        id_remap: &HashMap<EntityId, EntityId>,
890    ) -> BTreeMap<String, BTreeMap<EntityId, String>> {
891        extensions
892            .iter()
893            .map(|(name, entries)| {
894                let remapped: BTreeMap<EntityId, String> = entries
895                    .iter()
896                    .map(|(old_id, data)| {
897                        let new_id = id_remap.get(old_id).copied().unwrap_or(*old_id);
898                        (new_id, data.clone())
899                    })
900                    .collect();
901                (name.clone(), remapped)
902            })
903            .collect()
904    }
905
906    /// Emit `SnapshotDanglingReference` events for entity IDs not in `id_remap`.
907    fn emit_dangling_warnings(
908        entities: &[EntitySnapshot],
909        hall_calls: &[HallCall],
910        id_remap: &HashMap<EntityId, EntityId>,
911        tick: u64,
912        sim: &mut crate::sim::Simulation,
913    ) {
914        let mut seen = HashSet::new();
915        let mut check = |old: EntityId| {
916            if !id_remap.contains_key(&old) && seen.insert(old) {
917                sim.push_event(crate::events::Event::SnapshotDanglingReference {
918                    stale_id: old,
919                    tick,
920                });
921            }
922        };
923        for snap in entities {
924            Self::collect_referenced_ids(snap, &mut check);
925        }
926        for hc in hall_calls {
927            check(hc.stop);
928            for (&line, &car) in &hc.assigned_cars_by_line {
929                check(line);
930                check(car);
931            }
932            if let Some(dest) = hc.destination {
933                check(dest);
934            }
935            for &rider in &hc.pending_riders {
936                check(rider);
937            }
938        }
939    }
940
941    /// Visit all cross-referenced `EntityId`s inside an entity snapshot.
942    fn collect_referenced_ids(snap: &EntitySnapshot, mut visit: impl FnMut(EntityId)) {
943        if let Some(ref elev) = snap.elevator {
944            for &r in &elev.riders {
945                visit(r);
946            }
947            if let Some(t) = elev.target_stop {
948                visit(t);
949            }
950            visit(elev.line);
951            match elev.phase {
952                crate::components::ElevatorPhase::MovingToStop(s)
953                | crate::components::ElevatorPhase::Repositioning(s) => visit(s),
954                _ => {}
955            }
956            for &s in &elev.restricted_stops {
957                visit(s);
958            }
959        }
960        if let Some(ref rider) = snap.rider {
961            if let Some(s) = rider.current_stop {
962                visit(s);
963            }
964            match rider.phase {
965                crate::components::RiderPhase::Boarding(e)
966                | crate::components::RiderPhase::Riding(e)
967                | crate::components::RiderPhase::Exiting(e) => visit(e),
968                _ => {}
969            }
970        }
971        if let Some(ref route) = snap.route {
972            for leg in &route.legs {
973                visit(leg.from);
974                visit(leg.to);
975                if let crate::components::TransportMode::Line(l) = leg.via {
976                    visit(l);
977                }
978            }
979        }
980        if let Some(ref ac) = snap.access_control {
981            for &s in ac.allowed_stops() {
982                visit(s);
983            }
984        }
985        if let Some(ref dq) = snap.destination_queue {
986            for &e in dq.queue() {
987                visit(e);
988            }
989        }
990        for cc in &snap.car_calls {
991            visit(cc.floor);
992            for &r in &cc.pending_riders {
993                visit(r);
994            }
995        }
996    }
997}
998
999/// Magic bytes identifying a postcard snapshot blob.
1000const SNAPSHOT_MAGIC: [u8; 8] = *b"ELEVSNAP";
1001
1002/// Schema version for [`WorldSnapshot`]. Bump on incompatible layout
1003/// changes so RON/JSON restore can reject older snapshots loudly
1004/// instead of silently filling new fields with `#[serde(default)]`.
1005///
1006/// See `docs/src/snapshot-versioning.md` for the full bump-trigger
1007/// policy, the asymmetry between this `u32` and the crate-version
1008/// string in the bytes envelope, and the migration path.
1009const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
1010
1011/// Byte-level snapshot envelope: magic + crate version + payload.
1012///
1013/// Serialized via postcard. The magic and version fields are checked on
1014/// restore to reject blobs from other tools or from a different
1015/// `elevator-core` version.
1016#[derive(Debug, Serialize, Deserialize)]
1017struct SnapshotEnvelope {
1018    /// Magic bytes; must equal [`SNAPSHOT_MAGIC`] or the blob is rejected.
1019    magic: [u8; 8],
1020    /// `elevator-core` crate version that produced the blob.
1021    version: String,
1022    /// The captured simulation state.
1023    payload: WorldSnapshot,
1024}
1025
1026impl crate::sim::Simulation {
1027    /// Create a serializable snapshot of the current simulation state.
1028    ///
1029    /// The snapshot captures all entities, components, groups, metrics,
1030    /// the tick counter, and extension component data (game must
1031    /// re-register types via `register_ext` before `restore`).
1032    /// Custom resources inserted via `world.insert_resource` are NOT
1033    /// captured — games using them must save/restore separately (#296).
1034    ///
1035    /// **Mid-tick safety:** `snapshot()` returns a snapshot regardless
1036    /// of whether you are mid-tick (between phase calls in the substep
1037    /// API). For substep callers that care about event-bus state, use
1038    /// [`try_snapshot`](Self::try_snapshot) which returns
1039    /// [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
1040    /// when invoked between `run_*` and `advance_tick`. (#297)
1041    #[must_use]
1042    #[allow(clippy::too_many_lines)]
1043    pub fn snapshot(&self) -> WorldSnapshot {
1044        self.snapshot_inner()
1045    }
1046
1047    /// Like [`snapshot`](Self::snapshot) but returns
1048    /// [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
1049    /// when called between phases of an in-progress tick. (#297)
1050    ///
1051    /// # Errors
1052    ///
1053    /// Returns [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
1054    /// when invoked between a `run_*` phase call and the matching
1055    /// `advance_tick`.
1056    pub fn try_snapshot(&self) -> Result<WorldSnapshot, crate::error::SimError> {
1057        if self.tick_in_progress() {
1058            return Err(crate::error::SimError::MidTickSnapshot);
1059        }
1060        Ok(self.snapshot())
1061    }
1062
1063    /// Internal snapshot builder shared by [`snapshot`](Self::snapshot)
1064    /// and [`try_snapshot`](Self::try_snapshot). Holds the line-count
1065    /// allow so the public methods remain visible in nursery lints.
1066    #[allow(clippy::too_many_lines)]
1067    fn snapshot_inner(&self) -> WorldSnapshot {
1068        let world = self.world();
1069
1070        // Build entity index: map EntityId → position in vec.
1071        let all_ids: Vec<EntityId> = world.alive.keys().collect();
1072        let id_to_index: HashMap<EntityId, usize> = all_ids
1073            .iter()
1074            .copied()
1075            .enumerate()
1076            .map(|(i, e)| (e, i))
1077            .collect();
1078
1079        // Snapshot each entity.
1080        let entities: Vec<EntitySnapshot> = all_ids
1081            .iter()
1082            .map(|&eid| EntitySnapshot {
1083                original_id: eid,
1084                position: world.position(eid).copied(),
1085                velocity: world.velocity(eid).copied(),
1086                elevator: world.elevator(eid).cloned(),
1087                stop: world.stop(eid).cloned(),
1088                rider: world.rider(eid).cloned(),
1089                route: world.route(eid).cloned(),
1090                line: world.line(eid).cloned(),
1091                patience: world.patience(eid).copied(),
1092                preferences: world.preferences(eid).copied(),
1093                access_control: world.access_control(eid).cloned(),
1094                disabled: world.is_disabled(eid),
1095                #[cfg(feature = "energy")]
1096                energy_profile: world.energy_profile(eid).cloned(),
1097                #[cfg(feature = "energy")]
1098                energy_metrics: world.energy_metrics(eid).cloned(),
1099                service_mode: world.service_mode(eid).copied(),
1100                destination_queue: world.destination_queue(eid).cloned(),
1101                car_calls: world.car_calls(eid).to_vec(),
1102            })
1103            .collect();
1104
1105        // Snapshot groups (convert EntityIds to indices).
1106        let groups: Vec<GroupSnapshot> = self
1107            .groups()
1108            .iter()
1109            .map(|g| {
1110                let lines: Vec<LineSnapshotInfo> = g
1111                    .lines()
1112                    .iter()
1113                    .filter_map(|li| {
1114                        let entity_index = id_to_index.get(&li.entity()).copied()?;
1115                        Some(LineSnapshotInfo {
1116                            entity_index,
1117                            elevator_indices: li
1118                                .elevators()
1119                                .iter()
1120                                .filter_map(|eid| id_to_index.get(eid).copied())
1121                                .collect(),
1122                            stop_indices: li
1123                                .serves()
1124                                .iter()
1125                                .filter_map(|eid| id_to_index.get(eid).copied())
1126                                .collect(),
1127                        })
1128                    })
1129                    .collect();
1130                GroupSnapshot {
1131                    id: g.id(),
1132                    name: g.name().to_owned(),
1133                    elevator_indices: g
1134                        .elevator_entities()
1135                        .iter()
1136                        .filter_map(|eid| id_to_index.get(eid).copied())
1137                        .collect(),
1138                    stop_indices: g
1139                        .stop_entities()
1140                        .iter()
1141                        .filter_map(|eid| id_to_index.get(eid).copied())
1142                        .collect(),
1143                    strategy: self
1144                        .strategy_id(g.id())
1145                        .cloned()
1146                        .unwrap_or(crate::dispatch::BuiltinStrategy::Scan),
1147                    lines,
1148                    reposition: self.reposition_id(g.id()).cloned(),
1149                    hall_call_mode: g.hall_call_mode(),
1150                    ack_latency_ticks: g.ack_latency_ticks(),
1151                }
1152            })
1153            .collect();
1154
1155        // Snapshot stop + elevator lookups (convert EntityIds to indices).
1156        let stop_lookup: BTreeMap<StopId, usize> = self
1157            .stop_lookup_iter()
1158            .filter_map(|(sid, eid)| id_to_index.get(eid).map(|&idx| (*sid, idx)))
1159            .collect();
1160        let elevator_lookup: BTreeMap<u32, usize> = self
1161            .elevator_lookup_iter()
1162            .filter_map(|(cid, eid)| id_to_index.get(eid).map(|&idx| (cid.0, idx)))
1163            .collect();
1164        let line_lookup: BTreeMap<u32, usize> = self
1165            .line_lookup_iter()
1166            .filter_map(|(cid, eid)| id_to_index.get(eid).map(|&idx| (cid.0, idx)))
1167            .collect();
1168
1169        WorldSnapshot {
1170            version: SNAPSHOT_SCHEMA_VERSION,
1171            tick: self.current_tick(),
1172            dt: self.dt(),
1173            entities,
1174            groups,
1175            stop_lookup,
1176            elevator_lookup,
1177            line_lookup,
1178            metrics: self.metrics().clone(),
1179            metric_tags: self
1180                .world()
1181                .resource::<MetricTags>()
1182                .cloned()
1183                .unwrap_or_default(),
1184            extensions: self.world().serialize_extensions(),
1185            ticks_per_second: 1.0 / self.dt(),
1186            hall_calls: world.iter_hall_calls().cloned().collect(),
1187            arrival_log: world
1188                .resource::<crate::arrival_log::ArrivalLog>()
1189                .cloned()
1190                .unwrap_or_default(),
1191            arrival_log_retention: world
1192                .resource::<crate::arrival_log::ArrivalLogRetention>()
1193                .copied()
1194                .unwrap_or_default(),
1195            destination_log: world
1196                .resource::<crate::arrival_log::DestinationLog>()
1197                .cloned()
1198                .unwrap_or_default(),
1199            traffic_detector: world
1200                .resource::<crate::traffic_detector::TrafficDetector>()
1201                .cloned()
1202                .unwrap_or_default(),
1203            reposition_cooldowns: world
1204                .resource::<crate::dispatch::reposition::RepositionCooldowns>()
1205                .cloned()
1206                .unwrap_or_default(),
1207            // Per-group dispatcher configuration. Only strategies that
1208            // override `snapshot_config` emit non-None here; the rest
1209            // default to empty and restore to built-in defaults,
1210            // preserving pre-fix behaviour for stateless strategies.
1211            dispatch_config: self
1212                .dispatchers()
1213                .iter()
1214                .filter_map(|(gid, d)| d.snapshot_config().map(|s| (*gid, s)))
1215                .collect(),
1216        }
1217    }
1218
1219    /// Serialize the current state to a self-describing byte blob.
1220    ///
1221    /// The blob is postcard-encoded and carries a magic prefix plus the
1222    /// `elevator-core` crate version. Use [`Self::restore_bytes`]
1223    /// on the receiving end. Determinism is bit-exact across builds of
1224    /// the same crate version; cross-version restores return
1225    /// [`SimError::SnapshotVersion`](crate::error::SimError::SnapshotVersion).
1226    ///
1227    /// Extension component *data* is serialized (identical to
1228    /// [`Self::snapshot`]); after restore, use
1229    /// [`Simulation::load_extensions_with`](crate::sim::Simulation::load_extensions_with)
1230    /// to register and load them.
1231    /// Custom dispatch strategies and arbitrary `World` resources are
1232    /// not included.
1233    ///
1234    /// # Errors
1235    /// - [`SimError::SnapshotFormat`](crate::error::SimError::SnapshotFormat)
1236    ///   if postcard encoding fails. Unreachable for well-formed
1237    ///   `WorldSnapshot` values, so callers that don't care can `unwrap`.
1238    /// - [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
1239    ///   if invoked between phases of an in-progress tick (substep API
1240    ///   path) — the in-flight `EventBus` would otherwise be lost. (#297)
1241    pub fn snapshot_bytes(&self) -> Result<Vec<u8>, crate::error::SimError> {
1242        let envelope = SnapshotEnvelope {
1243            magic: SNAPSHOT_MAGIC,
1244            version: env!("CARGO_PKG_VERSION").to_owned(),
1245            payload: self.try_snapshot()?,
1246        };
1247        postcard::to_allocvec(&envelope)
1248            .map_err(|e| crate::error::SimError::SnapshotFormat(e.to_string()))
1249    }
1250
1251    /// Cheap u64 checksum of the simulation's serializable state.
1252    /// FNV-1a over the postcard encoding of [`Self::snapshot`]'s
1253    /// `WorldSnapshot` payload — the envelope (magic + crate version
1254    /// string) is *not* hashed, so the value depends only on the
1255    /// logical sim state, not on the `elevator-core` version that
1256    /// produced it. The numeric value is FNV-1a-specific and not
1257    /// equivalent to other hash functions of the same bytes; consumers
1258    /// computing an independent hash for comparison must use this
1259    /// method (or run FNV-1a themselves with the same constants).
1260    ///
1261    /// Snapshot/restore is byte-symmetric: a fresh sim and a restored
1262    /// sim with the same logical state hash equal. (Earlier code had
1263    /// a first-restore asymmetry from the `AssignedCar` extension
1264    /// type registering on restore but not `new`; that was fixed.)
1265    ///
1266    /// Designed for divergence detection between runtimes that should
1267    /// be in lockstep (browser vs server, multi-client multiplayer)
1268    /// and for golden checksums that need to survive a
1269    /// release-please version bump. Two sims that have produced bit-
1270    /// identical inputs in bit-identical order must hash to the same
1271    /// value, regardless of `CARGO_PKG_VERSION`.
1272    ///
1273    /// # Errors
1274    /// - [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
1275    ///   when invoked between phases of an in-progress tick (substep
1276    ///   API path).
1277    /// - [`SimError::SnapshotFormat`](crate::error::SimError::SnapshotFormat)
1278    ///   if postcard encoding of the payload fails. Unreachable for
1279    ///   well-formed sims; callers that don't care can `unwrap`.
1280    pub fn snapshot_checksum(&self) -> Result<u64, crate::error::SimError> {
1281        // FNV-1a (64-bit). Small, allocation-free over the byte slice,
1282        // well-distributed for arbitrary input. Not cryptographic;
1283        // collision tolerance is fine for divergence detection.
1284        // Constants are the standard 64-bit FNV-1a parameters from
1285        // RFC draft-eastlake-fnv (offset basis and prime), not arbitrary.
1286        const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
1287        const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
1288        let snapshot = self.try_snapshot()?;
1289        let bytes = postcard::to_allocvec(&snapshot)
1290            .map_err(|e| crate::error::SimError::SnapshotFormat(e.to_string()))?;
1291        let mut h: u64 = FNV_OFFSET;
1292        for byte in &bytes {
1293            h ^= u64::from(*byte);
1294            h = h.wrapping_mul(FNV_PRIME);
1295        }
1296        Ok(h)
1297    }
1298
1299    /// Restore a simulation from bytes produced by [`Self::snapshot_bytes`].
1300    ///
1301    /// Built-in dispatch strategies are auto-restored. For groups using
1302    /// [`BuiltinStrategy::Custom`](crate::dispatch::BuiltinStrategy::Custom),
1303    /// configure [`RestoreOptions`] with a factory; pass
1304    /// [`RestoreOptions::default()`] when the snapshot only carries
1305    /// built-in strategies.
1306    ///
1307    /// # Errors
1308    /// - [`SimError::SnapshotFormat`](crate::error::SimError::SnapshotFormat)
1309    ///   if the bytes are not a valid envelope or the magic prefix does
1310    ///   not match.
1311    /// - [`SimError::SnapshotVersion`](crate::error::SimError::SnapshotVersion)
1312    ///   if the blob was produced by a different crate version.
1313    /// - [`SimError::UnresolvedCustomStrategy`](crate::error::SimError::UnresolvedCustomStrategy)
1314    ///   if a group uses a custom strategy that the factory cannot resolve.
1315    pub fn restore_bytes(
1316        bytes: &[u8],
1317        options: RestoreOptions<'_>,
1318    ) -> Result<Self, crate::error::SimError> {
1319        let (envelope, tail): (SnapshotEnvelope, &[u8]) = postcard::take_from_bytes(bytes)
1320            .map_err(|e| crate::error::SimError::SnapshotFormat(e.to_string()))?;
1321        if !tail.is_empty() {
1322            return Err(crate::error::SimError::SnapshotFormat(format!(
1323                "trailing bytes: {} unread of {}",
1324                tail.len(),
1325                bytes.len()
1326            )));
1327        }
1328        if envelope.magic != SNAPSHOT_MAGIC {
1329            return Err(crate::error::SimError::SnapshotFormat(
1330                "magic bytes do not match".to_string(),
1331            ));
1332        }
1333        let current = env!("CARGO_PKG_VERSION");
1334        if envelope.version != current {
1335            return Err(crate::error::SimError::SnapshotVersion {
1336                saved: envelope.version,
1337                current: current.to_owned(),
1338            });
1339        }
1340        envelope.payload.restore(options)
1341    }
1342}