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, Patience, Position,
13    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    /// Global metrics at snapshot time.
111    pub metrics: Metrics,
112    /// Per-tag metric accumulators and entity-tag associations.
113    pub metric_tags: MetricTags,
114    /// Serialized extension component data: name → (`EntityId` → RON string).
115    /// Both maps are `BTreeMap` for deterministic snapshot bytes (#254).
116    pub extensions: BTreeMap<String, BTreeMap<EntityId, String>>,
117    /// Ticks per second (for `TimeAdapter` reconstruction).
118    pub ticks_per_second: f64,
119    /// All pending hall calls across every stop. Absent in legacy snapshots.
120    #[serde(default)]
121    pub hall_calls: Vec<HallCall>,
122    /// Rolling per-stop arrival log. Empty in legacy snapshots; on
123    /// restore the log's `(tick, stop)` entries have their stop IDs
124    /// remapped through `id_remap` so they line up with the newly
125    /// allocated entity IDs.
126    #[serde(default)]
127    pub arrival_log: crate::arrival_log::ArrivalLog,
128    /// Retention window for the arrival log (ticks). Captured so a
129    /// host-configured value (e.g. via
130    /// `Simulation::set_arrival_log_retention_ticks`) survives
131    /// snapshot round-trip; legacy snapshots default to
132    /// [`DEFAULT_ARRIVAL_WINDOW_TICKS`](crate::arrival_log::DEFAULT_ARRIVAL_WINDOW_TICKS).
133    #[serde(default)]
134    pub arrival_log_retention: crate::arrival_log::ArrivalLogRetention,
135    /// Mirror of `arrival_log` keyed on rider *destination* — what
136    /// powers the `DownPeak` classifier branch. Same remap semantics
137    /// as `arrival_log` on restore. Empty in legacy snapshots; the
138    /// detector silently under-classifies `DownPeak` until the post-
139    /// restore log refills.
140    #[serde(default)]
141    pub destination_log: crate::arrival_log::DestinationLog,
142    /// Traffic-mode classifier state. Carries the current mode,
143    /// thresholds, and last-update tick across snapshot round-trip
144    /// so a restored sim doesn't momentarily reset to `Idle` when
145    /// the metrics phase hasn't run yet.
146    #[serde(default)]
147    pub traffic_detector: crate::traffic_detector::TrafficDetector,
148    /// Per-car reposition cooldown eligibility. Entries map to the
149    /// tick when each car next becomes eligible for reposition. Empty
150    /// in legacy snapshots; on restore the map is remapped through
151    /// `id_remap` to match newly-allocated entity IDs.
152    #[serde(default)]
153    pub reposition_cooldowns: crate::dispatch::reposition::RepositionCooldowns,
154    /// Per-group serialized dispatcher configuration produced by
155    /// [`crate::dispatch::DispatchStrategy::snapshot_config`] and
156    /// replayed via
157    /// [`crate::dispatch::DispatchStrategy::restore_config`] on
158    /// restore. Round-trips the tunable weights configured via
159    /// `with_*` builder methods (e.g. `EtdDispatch::with_delay_weight`)
160    /// that [`BuiltinStrategy::instantiate`](crate::dispatch::BuiltinStrategy::instantiate)
161    /// can't reconstruct because it always calls `::new()`. Absent /
162    /// empty in legacy snapshots — they restore to default weights,
163    /// matching pre-fix behaviour.
164    #[serde(default)]
165    pub dispatch_config: BTreeMap<GroupId, String>,
166}
167
168/// Per-line snapshot info within a group.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct LineSnapshotInfo {
171    /// Index into the `entities` vec for the line entity.
172    pub entity_index: usize,
173    /// Indices into the `entities` vec for elevators on this line.
174    pub elevator_indices: Vec<usize>,
175    /// Indices into the `entities` vec for stops served by this line.
176    pub stop_indices: Vec<usize>,
177}
178
179/// Serializable representation of an elevator group.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct GroupSnapshot {
182    /// Group identifier.
183    pub id: GroupId,
184    /// Group name.
185    pub name: String,
186    /// Indices into the `entities` vec for elevators in this group.
187    pub elevator_indices: Vec<usize>,
188    /// Indices into the `entities` vec for stops in this group.
189    pub stop_indices: Vec<usize>,
190    /// The dispatch strategy used by this group.
191    pub strategy: crate::dispatch::BuiltinStrategy,
192    /// Per-line snapshot data. Empty in legacy snapshots.
193    #[serde(default)]
194    pub lines: Vec<LineSnapshotInfo>,
195    /// Optional repositioning strategy for idle elevators.
196    #[serde(default)]
197    pub reposition: Option<crate::dispatch::BuiltinReposition>,
198    /// Hall call mode for this group. Legacy snapshots default to `Classic`.
199    #[serde(default)]
200    pub hall_call_mode: crate::dispatch::HallCallMode,
201    /// Controller ack latency in ticks. Legacy snapshots default to `0`.
202    #[serde(default)]
203    pub ack_latency_ticks: u32,
204}
205
206/// Pending extension data from a snapshot, awaiting type registration.
207///
208/// Stored as a world resource after `restore()`. Call
209/// `sim.load_extensions()` after registering extension types to
210/// deserialize the data.
211pub(crate) struct PendingExtensions(pub(crate) BTreeMap<String, BTreeMap<EntityId, String>>);
212
213/// Factory function type for instantiating custom dispatch strategies by name.
214type CustomStrategyFactory<'a> =
215    Option<&'a dyn Fn(&str) -> Option<Box<dyn crate::dispatch::DispatchStrategy>>>;
216
217impl WorldSnapshot {
218    /// Restore a simulation from this snapshot.
219    ///
220    /// Built-in strategies (Scan, Look, `NearestCar`, ETD) are auto-restored.
221    /// For `Custom` strategies, provide a factory function that maps strategy
222    /// names to instances. Pass `None` if only using built-in strategies.
223    ///
224    /// # Errors
225    /// Returns [`SimError::UnresolvedCustomStrategy`](crate::error::SimError::UnresolvedCustomStrategy)
226    /// if a snapshot group uses a `Custom` strategy and the factory returns `None`.
227    ///
228    /// To restore extension components, call
229    /// [`Simulation::load_extensions_with`](crate::sim::Simulation::load_extensions_with)
230    /// on the returned simulation.
231    #[allow(clippy::too_many_lines)]
232    pub fn restore(
233        self,
234        custom_strategy_factory: CustomStrategyFactory<'_>,
235    ) -> Result<crate::sim::Simulation, crate::error::SimError> {
236        use crate::world::{SortedStops, World};
237
238        // Reject snapshots from incompatible schema versions. The bytes
239        // envelope path also checks the crate semver string, but the RON/
240        // JSON path was previously unguarded — older snapshots silently
241        // deserialized with `#[serde(default)]` filling new fields, masking
242        // schema mismatches. (#295)
243        if self.version != SNAPSHOT_SCHEMA_VERSION {
244            return Err(crate::error::SimError::SnapshotVersion {
245                saved: format!("schema {}", self.version),
246                current: format!("schema {SNAPSHOT_SCHEMA_VERSION}"),
247            });
248        }
249
250        let mut world = World::new();
251
252        // Phase 1: spawn all entities and build old→new EntityId mapping.
253        let (index_to_id, id_remap) = Self::spawn_entities(&mut world, &self.entities);
254
255        // Phase 2: attach components with remapped EntityIds.
256        Self::attach_components(&mut world, &self.entities, &index_to_id, &id_remap);
257
258        // Phase 2b: re-register hall calls (cross-reference stops/cars/riders).
259        self.attach_hall_calls(&mut world, &id_remap);
260
261        // Rebuild sorted stops index.
262        let mut sorted: Vec<(f64, EntityId)> = world
263            .iter_stops()
264            .map(|(eid, stop)| (stop.position, eid))
265            .collect();
266        sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
267        world.insert_resource(SortedStops(sorted));
268
269        // Rebuild groups, stop lookup, dispatchers, and extensions (borrows self).
270        let (mut groups, stop_lookup, dispatchers, strategy_ids) =
271            self.rebuild_groups_and_dispatchers(&index_to_id, custom_strategy_factory)?;
272
273        // Fix legacy snapshots: synthetic LineInfo entries with EntityId::default()
274        // need real line entities spawned in the world.
275        for group in &mut groups {
276            let group_id = group.id();
277            let lines = group.lines_mut();
278            for line_info in lines.iter_mut() {
279                if line_info.entity() != EntityId::default() {
280                    continue;
281                }
282                // Compute min/max position from the line's served stops.
283                let (min_pos, max_pos) = line_info
284                    .serves()
285                    .iter()
286                    .filter_map(|&sid| world.stop(sid).map(|s| s.position))
287                    .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| {
288                        (lo.min(p), hi.max(p))
289                    });
290                let line_eid = world.spawn();
291                world.set_line(
292                    line_eid,
293                    Line {
294                        name: format!("Legacy-{group_id}"),
295                        group: group_id,
296                        orientation: crate::components::Orientation::Vertical,
297                        position: None,
298                        min_position: if min_pos.is_finite() { min_pos } else { 0.0 },
299                        max_position: if max_pos.is_finite() { max_pos } else { 0.0 },
300                        max_cars: None,
301                    },
302                );
303                // Update all elevators on this line to reference the new entity.
304                for &elev_eid in line_info.elevators() {
305                    if let Some(car) = world.elevator_mut(elev_eid) {
306                        car.line = line_eid;
307                    }
308                }
309                line_info.set_entity(line_eid);
310            }
311        }
312
313        // Remap EntityIds in extension data for later deserialization.
314        let remapped_exts = Self::remap_extensions(&self.extensions, &id_remap);
315        world.insert_resource(PendingExtensions(remapped_exts));
316
317        // Restore MetricTags with remapped entity IDs (moves out of self).
318        let mut tags = self.metric_tags;
319        tags.remap_entity_ids(&id_remap);
320        world.insert_resource(tags);
321
322        // Restore the arrival log (per-stop spawn counts) and the
323        // tick-mirror resource — without these `PredictiveParking` and
324        // `DispatchManifest::arrivals_at` silently no-op post-restore.
325        // Also re-seat `ArrivalLogRetention`: post-restore the first
326        // `advance_tick` prunes the log, and missing this resource
327        // quietly falls back to the default window, clipping any
328        // longer retention the host configured.
329        let mut log = self.arrival_log;
330        log.remap_entity_ids(&id_remap);
331        world.insert_resource(log);
332        world.insert_resource(crate::arrival_log::CurrentTick(self.tick));
333        world.insert_resource(self.arrival_log_retention);
334        // Destination log mirrors the same remap — entries reference
335        // rider destinations, which are stop entities that were just
336        // reallocated. Without the remap every entry would reference
337        // a dead ID and the classifier's down-peak branch would
338        // silently see zero.
339        let mut dest_log = self.destination_log;
340        dest_log.remap_entity_ids(&id_remap);
341        world.insert_resource(dest_log);
342        // The detector carries classified-mode state plus thresholds.
343        // Re-inserting it last-writer-wins means the restore carries
344        // the *classified* state forward — refresh_traffic_detector
345        // will update on the next metrics phase with fresh counts.
346        world.insert_resource(self.traffic_detector);
347        // Reposition cooldowns remap through fresh IDs so a mid-
348        // cooldown car stays grounded across snapshot round-trips.
349        let mut reposition_cooldowns = self.reposition_cooldowns;
350        reposition_cooldowns.remap_entity_ids(&id_remap);
351        world.insert_resource(reposition_cooldowns);
352
353        let mut sim = crate::sim::Simulation::from_parts(
354            world,
355            self.tick,
356            self.dt,
357            groups,
358            stop_lookup,
359            dispatchers,
360            strategy_ids,
361            self.metrics,
362            self.ticks_per_second,
363        );
364
365        // Replay any per-group dispatcher tuning captured in the
366        // snapshot. Each built-in with `with_*` builder methods
367        // overrides `snapshot_config`/`restore_config` to round-trip
368        // its weights; strategies that don't override silently skip
369        // (default `restore_config` is `Ok(())`), preserving pre-fix
370        // behaviour. A deserialization failure (e.g. snapshot from a
371        // future version with new fields) surfaces as an event rather
372        // than a hard error — the restored sim runs with defaults,
373        // same as a legacy snapshot with no dispatch_config at all.
374        for (gid, serialized) in &self.dispatch_config {
375            if let Some(dispatcher) = sim.dispatchers_mut().get_mut(gid)
376                && let Err(err) = dispatcher.restore_config(serialized)
377            {
378                sim.push_event(crate::events::Event::DispatchConfigNotRestored {
379                    group: *gid,
380                    reason: err,
381                });
382            }
383        }
384
385        // Restore reposition strategies from group snapshots.
386        for gs in &self.groups {
387            if let Some(ref repo_id) = gs.reposition {
388                if let Some(strategy) = repo_id.instantiate() {
389                    sim.set_reposition(gs.id, strategy, repo_id.clone());
390                } else {
391                    sim.push_event(crate::events::Event::RepositionStrategyNotRestored {
392                        group: gs.id,
393                    });
394                }
395            }
396        }
397
398        Self::emit_dangling_warnings(
399            &self.entities,
400            &self.hall_calls,
401            &id_remap,
402            self.tick,
403            &mut sim,
404        );
405
406        Ok(sim)
407    }
408
409    /// Spawn entities in the world and build the old→new `EntityId` mapping.
410    fn spawn_entities(
411        world: &mut crate::world::World,
412        entities: &[EntitySnapshot],
413    ) -> (Vec<EntityId>, HashMap<EntityId, EntityId>) {
414        let mut index_to_id: Vec<EntityId> = Vec::with_capacity(entities.len());
415        let mut id_remap: HashMap<EntityId, EntityId> = HashMap::new();
416        for snap in entities {
417            let new_id = world.spawn();
418            index_to_id.push(new_id);
419            id_remap.insert(snap.original_id, new_id);
420        }
421        (index_to_id, id_remap)
422    }
423
424    /// Attach components to spawned entities, remapping cross-references.
425    fn attach_components(
426        world: &mut crate::world::World,
427        entities: &[EntitySnapshot],
428        index_to_id: &[EntityId],
429        id_remap: &HashMap<EntityId, EntityId>,
430    ) {
431        let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
432        let remap_opt = |old: Option<EntityId>| -> Option<EntityId> { old.map(&remap) };
433
434        for (i, snap) in entities.iter().enumerate() {
435            let eid = index_to_id[i];
436
437            if let Some(pos) = snap.position {
438                world.set_position(eid, pos);
439            }
440            if let Some(vel) = snap.velocity {
441                world.set_velocity(eid, vel);
442            }
443            if let Some(ref elev) = snap.elevator {
444                let mut e = elev.clone();
445                e.riders = e.riders.iter().map(|&r| remap(r)).collect();
446                e.target_stop = remap_opt(e.target_stop);
447                e.line = remap(e.line);
448                e.restricted_stops = e.restricted_stops.iter().map(|&s| remap(s)).collect();
449                e.phase = match e.phase {
450                    crate::components::ElevatorPhase::MovingToStop(s) => {
451                        crate::components::ElevatorPhase::MovingToStop(remap(s))
452                    }
453                    crate::components::ElevatorPhase::Repositioning(s) => {
454                        crate::components::ElevatorPhase::Repositioning(remap(s))
455                    }
456                    other => other,
457                };
458                world.set_elevator(eid, e);
459            }
460            if let Some(ref stop) = snap.stop {
461                world.set_stop(eid, stop.clone());
462            }
463            if let Some(ref rider) = snap.rider {
464                use crate::components::RiderPhase;
465                let mut r = rider.clone();
466                r.current_stop = remap_opt(r.current_stop);
467                r.phase = match r.phase {
468                    RiderPhase::Boarding(e) => RiderPhase::Boarding(remap(e)),
469                    RiderPhase::Riding(e) => RiderPhase::Riding(remap(e)),
470                    RiderPhase::Exiting(e) => RiderPhase::Exiting(remap(e)),
471                    other => other,
472                };
473                world.set_rider(eid, r);
474            }
475            if let Some(ref route) = snap.route {
476                let mut rt = route.clone();
477                for leg in &mut rt.legs {
478                    leg.from = remap(leg.from);
479                    leg.to = remap(leg.to);
480                    if let crate::components::TransportMode::Line(ref mut l) = leg.via {
481                        *l = remap(*l);
482                    }
483                }
484                world.set_route(eid, rt);
485            }
486            if let Some(ref line) = snap.line {
487                world.set_line(eid, line.clone());
488            }
489            if let Some(patience) = snap.patience {
490                world.set_patience(eid, patience);
491            }
492            if let Some(prefs) = snap.preferences {
493                world.set_preferences(eid, prefs);
494            }
495            if let Some(ref ac) = snap.access_control {
496                let remapped =
497                    AccessControl::new(ac.allowed_stops().iter().map(|&s| remap(s)).collect());
498                world.set_access_control(eid, remapped);
499            }
500            if snap.disabled {
501                world.disable(eid);
502            }
503            #[cfg(feature = "energy")]
504            if let Some(ref profile) = snap.energy_profile {
505                world.set_energy_profile(eid, profile.clone());
506            }
507            #[cfg(feature = "energy")]
508            if let Some(ref em) = snap.energy_metrics {
509                world.set_energy_metrics(eid, em.clone());
510            }
511            if let Some(mode) = snap.service_mode {
512                world.set_service_mode(eid, mode);
513            }
514            if let Some(ref dq) = snap.destination_queue {
515                use crate::components::DestinationQueue as DQ;
516                let mut new_dq = DQ::new();
517                for &e in dq.queue() {
518                    new_dq.push_back(remap(e));
519                }
520                world.set_destination_queue(eid, new_dq);
521            }
522            Self::attach_car_calls(world, eid, &snap.car_calls, id_remap);
523        }
524    }
525
526    /// Re-register per-car floor button presses after entities are spawned.
527    fn attach_car_calls(
528        world: &mut crate::world::World,
529        car: EntityId,
530        car_calls: &[CarCall],
531        id_remap: &HashMap<EntityId, EntityId>,
532    ) {
533        if car_calls.is_empty() {
534            return;
535        }
536        let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
537        let Some(slot) = world.car_calls_mut(car) else {
538            return;
539        };
540        for cc in car_calls {
541            let mut c = cc.clone();
542            c.car = car;
543            c.floor = remap(c.floor);
544            c.pending_riders = c.pending_riders.iter().map(|&r| remap(r)).collect();
545            slot.push(c);
546        }
547    }
548
549    /// Re-register hall calls in the world after entities are spawned.
550    ///
551    /// `HallCall` cross-references stops, cars, riders, and optional
552    /// destinations — all `EntityId`s must be remapped through `id_remap`.
553    fn attach_hall_calls(
554        &self,
555        world: &mut crate::world::World,
556        id_remap: &HashMap<EntityId, EntityId>,
557    ) {
558        let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
559        let remap_opt = |old: Option<EntityId>| -> Option<EntityId> { old.map(&remap) };
560        for hc in &self.hall_calls {
561            let mut c = hc.clone();
562            c.stop = remap(c.stop);
563            c.destination = remap_opt(c.destination);
564            c.assigned_car = remap_opt(c.assigned_car);
565            c.pending_riders = c.pending_riders.iter().map(|&r| remap(r)).collect();
566            world.set_hall_call(c);
567        }
568    }
569
570    /// Rebuild groups, stop lookup, and dispatchers from snapshot data.
571    #[allow(clippy::type_complexity)]
572    fn rebuild_groups_and_dispatchers(
573        &self,
574        index_to_id: &[EntityId],
575        custom_strategy_factory: CustomStrategyFactory<'_>,
576    ) -> Result<
577        (
578            Vec<crate::dispatch::ElevatorGroup>,
579            HashMap<StopId, EntityId>,
580            std::collections::BTreeMap<GroupId, Box<dyn crate::dispatch::DispatchStrategy>>,
581            std::collections::BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
582        ),
583        crate::error::SimError,
584    > {
585        use crate::dispatch::ElevatorGroup;
586
587        let groups: Vec<ElevatorGroup> = self
588            .groups
589            .iter()
590            .map(|gs| {
591                let elevator_entities: Vec<EntityId> = gs
592                    .elevator_indices
593                    .iter()
594                    .filter_map(|&i| index_to_id.get(i).copied())
595                    .collect();
596                let stop_entities: Vec<EntityId> = gs
597                    .stop_indices
598                    .iter()
599                    .filter_map(|&i| index_to_id.get(i).copied())
600                    .collect();
601
602                let lines = if gs.lines.is_empty() {
603                    // Legacy snapshots have no per-line data; create a single
604                    // synthetic LineInfo containing all elevators and stops.
605                    vec![crate::dispatch::LineInfo::new(
606                        EntityId::default(),
607                        elevator_entities,
608                        stop_entities,
609                    )]
610                } else {
611                    gs.lines
612                        .iter()
613                        .filter_map(|lsi| {
614                            let entity = index_to_id.get(lsi.entity_index).copied()?;
615                            Some(crate::dispatch::LineInfo::new(
616                                entity,
617                                lsi.elevator_indices
618                                    .iter()
619                                    .filter_map(|&i| index_to_id.get(i).copied())
620                                    .collect(),
621                                lsi.stop_indices
622                                    .iter()
623                                    .filter_map(|&i| index_to_id.get(i).copied())
624                                    .collect(),
625                            ))
626                        })
627                        .collect()
628                };
629
630                ElevatorGroup::new(gs.id, gs.name.clone(), lines)
631                    .with_hall_call_mode(gs.hall_call_mode)
632                    .with_ack_latency_ticks(gs.ack_latency_ticks)
633            })
634            .collect();
635
636        let stop_lookup: HashMap<StopId, EntityId> = self
637            .stop_lookup
638            .iter()
639            .filter_map(|(sid, &idx)| index_to_id.get(idx).map(|&eid| (*sid, eid)))
640            .collect();
641
642        let mut dispatchers = std::collections::BTreeMap::new();
643        let mut strategy_ids = std::collections::BTreeMap::new();
644        for (gs, group) in self.groups.iter().zip(groups.iter()) {
645            let strategy: Box<dyn crate::dispatch::DispatchStrategy> =
646                if let Some(builtin) = gs.strategy.instantiate() {
647                    builtin
648                } else if let crate::dispatch::BuiltinStrategy::Custom(ref name) = gs.strategy {
649                    custom_strategy_factory
650                        .and_then(|f| f(name))
651                        .ok_or_else(|| crate::error::SimError::UnresolvedCustomStrategy {
652                            name: name.clone(),
653                            group: group.id(),
654                        })?
655                } else {
656                    Box::new(crate::dispatch::scan::ScanDispatch::new())
657                };
658            dispatchers.insert(group.id(), strategy);
659            strategy_ids.insert(group.id(), gs.strategy.clone());
660        }
661
662        Ok((groups, stop_lookup, dispatchers, strategy_ids))
663    }
664
665    /// Remap `EntityId`s in extension data using the old→new mapping.
666    fn remap_extensions(
667        extensions: &BTreeMap<String, BTreeMap<EntityId, String>>,
668        id_remap: &HashMap<EntityId, EntityId>,
669    ) -> BTreeMap<String, BTreeMap<EntityId, String>> {
670        extensions
671            .iter()
672            .map(|(name, entries)| {
673                let remapped: BTreeMap<EntityId, String> = entries
674                    .iter()
675                    .map(|(old_id, data)| {
676                        let new_id = id_remap.get(old_id).copied().unwrap_or(*old_id);
677                        (new_id, data.clone())
678                    })
679                    .collect();
680                (name.clone(), remapped)
681            })
682            .collect()
683    }
684
685    /// Emit `SnapshotDanglingReference` events for entity IDs not in `id_remap`.
686    fn emit_dangling_warnings(
687        entities: &[EntitySnapshot],
688        hall_calls: &[HallCall],
689        id_remap: &HashMap<EntityId, EntityId>,
690        tick: u64,
691        sim: &mut crate::sim::Simulation,
692    ) {
693        let mut seen = HashSet::new();
694        let mut check = |old: EntityId| {
695            if !id_remap.contains_key(&old) && seen.insert(old) {
696                sim.push_event(crate::events::Event::SnapshotDanglingReference {
697                    stale_id: old,
698                    tick,
699                });
700            }
701        };
702        for snap in entities {
703            Self::collect_referenced_ids(snap, &mut check);
704        }
705        for hc in hall_calls {
706            check(hc.stop);
707            if let Some(car) = hc.assigned_car {
708                check(car);
709            }
710            if let Some(dest) = hc.destination {
711                check(dest);
712            }
713            for &rider in &hc.pending_riders {
714                check(rider);
715            }
716        }
717    }
718
719    /// Visit all cross-referenced `EntityId`s inside an entity snapshot.
720    fn collect_referenced_ids(snap: &EntitySnapshot, mut visit: impl FnMut(EntityId)) {
721        if let Some(ref elev) = snap.elevator {
722            for &r in &elev.riders {
723                visit(r);
724            }
725            if let Some(t) = elev.target_stop {
726                visit(t);
727            }
728            visit(elev.line);
729            match elev.phase {
730                crate::components::ElevatorPhase::MovingToStop(s)
731                | crate::components::ElevatorPhase::Repositioning(s) => visit(s),
732                _ => {}
733            }
734            for &s in &elev.restricted_stops {
735                visit(s);
736            }
737        }
738        if let Some(ref rider) = snap.rider {
739            if let Some(s) = rider.current_stop {
740                visit(s);
741            }
742            match rider.phase {
743                crate::components::RiderPhase::Boarding(e)
744                | crate::components::RiderPhase::Riding(e)
745                | crate::components::RiderPhase::Exiting(e) => visit(e),
746                _ => {}
747            }
748        }
749        if let Some(ref route) = snap.route {
750            for leg in &route.legs {
751                visit(leg.from);
752                visit(leg.to);
753                if let crate::components::TransportMode::Line(l) = leg.via {
754                    visit(l);
755                }
756            }
757        }
758        if let Some(ref ac) = snap.access_control {
759            for &s in ac.allowed_stops() {
760                visit(s);
761            }
762        }
763        if let Some(ref dq) = snap.destination_queue {
764            for &e in dq.queue() {
765                visit(e);
766            }
767        }
768        for cc in &snap.car_calls {
769            visit(cc.floor);
770            for &r in &cc.pending_riders {
771                visit(r);
772            }
773        }
774    }
775}
776
777/// Magic bytes identifying a bincode snapshot blob.
778const SNAPSHOT_MAGIC: [u8; 8] = *b"ELEVSNAP";
779
780/// Schema version for [`WorldSnapshot`]. Bump on incompatible layout
781/// changes so RON/JSON restore can reject older snapshots loudly
782/// instead of silently filling new fields with `#[serde(default)]`.
783const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
784
785/// Byte-level snapshot envelope: magic + crate version + payload.
786///
787/// Serialized via bincode. The magic and version fields are checked on
788/// restore to reject blobs from other tools or from a different
789/// `elevator-core` version.
790#[derive(Debug, Serialize, Deserialize)]
791struct SnapshotEnvelope {
792    /// Magic bytes; must equal [`SNAPSHOT_MAGIC`] or the blob is rejected.
793    magic: [u8; 8],
794    /// `elevator-core` crate version that produced the blob.
795    version: String,
796    /// The captured simulation state.
797    payload: WorldSnapshot,
798}
799
800impl crate::sim::Simulation {
801    /// Create a serializable snapshot of the current simulation state.
802    ///
803    /// The snapshot captures all entities, components, groups, metrics,
804    /// the tick counter, and extension component data (game must
805    /// re-register types via `register_ext` before `restore`).
806    /// Custom resources inserted via `world.insert_resource` are NOT
807    /// captured — games using them must save/restore separately (#296).
808    ///
809    /// **Mid-tick safety:** `snapshot()` returns a snapshot regardless
810    /// of whether you are mid-tick (between phase calls in the substep
811    /// API). For substep callers that care about event-bus state, use
812    /// [`try_snapshot`](Self::try_snapshot) which returns
813    /// [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
814    /// when invoked between `run_*` and `advance_tick`. (#297)
815    #[must_use]
816    #[allow(clippy::too_many_lines)]
817    pub fn snapshot(&self) -> WorldSnapshot {
818        self.snapshot_inner()
819    }
820
821    /// Like [`snapshot`](Self::snapshot) but returns
822    /// [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
823    /// when called between phases of an in-progress tick. (#297)
824    ///
825    /// # Errors
826    ///
827    /// Returns [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
828    /// when invoked between a `run_*` phase call and the matching
829    /// `advance_tick`.
830    pub fn try_snapshot(&self) -> Result<WorldSnapshot, crate::error::SimError> {
831        if self.tick_in_progress {
832            return Err(crate::error::SimError::MidTickSnapshot);
833        }
834        Ok(self.snapshot())
835    }
836
837    /// Internal snapshot builder shared by [`snapshot`](Self::snapshot)
838    /// and [`try_snapshot`](Self::try_snapshot). Holds the line-count
839    /// allow so the public methods remain visible in nursery lints.
840    #[allow(clippy::too_many_lines)]
841    fn snapshot_inner(&self) -> WorldSnapshot {
842        let world = self.world();
843
844        // Build entity index: map EntityId → position in vec.
845        let all_ids: Vec<EntityId> = world.alive.keys().collect();
846        let id_to_index: HashMap<EntityId, usize> = all_ids
847            .iter()
848            .copied()
849            .enumerate()
850            .map(|(i, e)| (e, i))
851            .collect();
852
853        // Snapshot each entity.
854        let entities: Vec<EntitySnapshot> = all_ids
855            .iter()
856            .map(|&eid| EntitySnapshot {
857                original_id: eid,
858                position: world.position(eid).copied(),
859                velocity: world.velocity(eid).copied(),
860                elevator: world.elevator(eid).cloned(),
861                stop: world.stop(eid).cloned(),
862                rider: world.rider(eid).cloned(),
863                route: world.route(eid).cloned(),
864                line: world.line(eid).cloned(),
865                patience: world.patience(eid).copied(),
866                preferences: world.preferences(eid).copied(),
867                access_control: world.access_control(eid).cloned(),
868                disabled: world.is_disabled(eid),
869                #[cfg(feature = "energy")]
870                energy_profile: world.energy_profile(eid).cloned(),
871                #[cfg(feature = "energy")]
872                energy_metrics: world.energy_metrics(eid).cloned(),
873                service_mode: world.service_mode(eid).copied(),
874                destination_queue: world.destination_queue(eid).cloned(),
875                car_calls: world.car_calls(eid).to_vec(),
876            })
877            .collect();
878
879        // Snapshot groups (convert EntityIds to indices).
880        let groups: Vec<GroupSnapshot> = self
881            .groups()
882            .iter()
883            .map(|g| {
884                let lines: Vec<LineSnapshotInfo> = g
885                    .lines()
886                    .iter()
887                    .filter_map(|li| {
888                        let entity_index = id_to_index.get(&li.entity()).copied()?;
889                        Some(LineSnapshotInfo {
890                            entity_index,
891                            elevator_indices: li
892                                .elevators()
893                                .iter()
894                                .filter_map(|eid| id_to_index.get(eid).copied())
895                                .collect(),
896                            stop_indices: li
897                                .serves()
898                                .iter()
899                                .filter_map(|eid| id_to_index.get(eid).copied())
900                                .collect(),
901                        })
902                    })
903                    .collect();
904                GroupSnapshot {
905                    id: g.id(),
906                    name: g.name().to_owned(),
907                    elevator_indices: g
908                        .elevator_entities()
909                        .iter()
910                        .filter_map(|eid| id_to_index.get(eid).copied())
911                        .collect(),
912                    stop_indices: g
913                        .stop_entities()
914                        .iter()
915                        .filter_map(|eid| id_to_index.get(eid).copied())
916                        .collect(),
917                    strategy: self
918                        .strategy_id(g.id())
919                        .cloned()
920                        .unwrap_or(crate::dispatch::BuiltinStrategy::Scan),
921                    lines,
922                    reposition: self.reposition_id(g.id()).cloned(),
923                    hall_call_mode: g.hall_call_mode(),
924                    ack_latency_ticks: g.ack_latency_ticks(),
925                }
926            })
927            .collect();
928
929        // Snapshot stop lookup (convert EntityIds to indices).
930        let stop_lookup: BTreeMap<StopId, usize> = self
931            .stop_lookup_iter()
932            .filter_map(|(sid, eid)| id_to_index.get(eid).map(|&idx| (*sid, idx)))
933            .collect();
934
935        WorldSnapshot {
936            version: SNAPSHOT_SCHEMA_VERSION,
937            tick: self.current_tick(),
938            dt: self.dt(),
939            entities,
940            groups,
941            stop_lookup,
942            metrics: self.metrics().clone(),
943            metric_tags: self
944                .world()
945                .resource::<MetricTags>()
946                .cloned()
947                .unwrap_or_default(),
948            extensions: self.world().serialize_extensions(),
949            ticks_per_second: 1.0 / self.dt(),
950            hall_calls: world.iter_hall_calls().cloned().collect(),
951            arrival_log: world
952                .resource::<crate::arrival_log::ArrivalLog>()
953                .cloned()
954                .unwrap_or_default(),
955            arrival_log_retention: world
956                .resource::<crate::arrival_log::ArrivalLogRetention>()
957                .copied()
958                .unwrap_or_default(),
959            destination_log: world
960                .resource::<crate::arrival_log::DestinationLog>()
961                .cloned()
962                .unwrap_or_default(),
963            traffic_detector: world
964                .resource::<crate::traffic_detector::TrafficDetector>()
965                .cloned()
966                .unwrap_or_default(),
967            reposition_cooldowns: world
968                .resource::<crate::dispatch::reposition::RepositionCooldowns>()
969                .cloned()
970                .unwrap_or_default(),
971            // Per-group dispatcher configuration. Only strategies that
972            // override `snapshot_config` emit non-None here; the rest
973            // default to empty and restore to built-in defaults,
974            // preserving pre-fix behaviour for stateless strategies.
975            dispatch_config: self
976                .dispatchers()
977                .iter()
978                .filter_map(|(gid, d)| d.snapshot_config().map(|s| (*gid, s)))
979                .collect(),
980        }
981    }
982
983    /// Serialize the current state to a self-describing byte blob.
984    ///
985    /// The blob is postcard-encoded and carries a magic prefix plus the
986    /// `elevator-core` crate version. Use [`Self::restore_bytes`]
987    /// on the receiving end. Determinism is bit-exact across builds of
988    /// the same crate version; cross-version restores return
989    /// [`SimError::SnapshotVersion`](crate::error::SimError::SnapshotVersion).
990    ///
991    /// Extension component *data* is serialized (identical to
992    /// [`Self::snapshot`]); after restore, use
993    /// [`Simulation::load_extensions_with`](crate::sim::Simulation::load_extensions_with)
994    /// to register and load them.
995    /// Custom dispatch strategies and arbitrary `World` resources are
996    /// not included.
997    ///
998    /// # Errors
999    /// - [`SimError::SnapshotFormat`](crate::error::SimError::SnapshotFormat)
1000    ///   if postcard encoding fails. Unreachable for well-formed
1001    ///   `WorldSnapshot` values, so callers that don't care can `unwrap`.
1002    /// - [`SimError::MidTickSnapshot`](crate::error::SimError::MidTickSnapshot)
1003    ///   if invoked between phases of an in-progress tick (substep API
1004    ///   path) — the in-flight `EventBus` would otherwise be lost. (#297)
1005    pub fn snapshot_bytes(&self) -> Result<Vec<u8>, crate::error::SimError> {
1006        let envelope = SnapshotEnvelope {
1007            magic: SNAPSHOT_MAGIC,
1008            version: env!("CARGO_PKG_VERSION").to_owned(),
1009            payload: self.try_snapshot()?,
1010        };
1011        postcard::to_allocvec(&envelope)
1012            .map_err(|e| crate::error::SimError::SnapshotFormat(e.to_string()))
1013    }
1014
1015    /// Restore a simulation from bytes produced by [`Self::snapshot_bytes`].
1016    ///
1017    /// Built-in dispatch strategies are auto-restored. For groups using
1018    /// [`BuiltinStrategy::Custom`](crate::dispatch::BuiltinStrategy::Custom),
1019    /// provide a factory; pass `None` otherwise.
1020    ///
1021    /// # Errors
1022    /// - [`SimError::SnapshotFormat`](crate::error::SimError::SnapshotFormat)
1023    ///   if the bytes are not a valid envelope or the magic prefix does
1024    ///   not match.
1025    /// - [`SimError::SnapshotVersion`](crate::error::SimError::SnapshotVersion)
1026    ///   if the blob was produced by a different crate version.
1027    /// - [`SimError::UnresolvedCustomStrategy`](crate::error::SimError::UnresolvedCustomStrategy)
1028    ///   if a group uses a custom strategy that the factory cannot resolve.
1029    pub fn restore_bytes(
1030        bytes: &[u8],
1031        custom_strategy_factory: CustomStrategyFactory<'_>,
1032    ) -> Result<Self, crate::error::SimError> {
1033        let (envelope, tail): (SnapshotEnvelope, &[u8]) = postcard::take_from_bytes(bytes)
1034            .map_err(|e| crate::error::SimError::SnapshotFormat(e.to_string()))?;
1035        if !tail.is_empty() {
1036            return Err(crate::error::SimError::SnapshotFormat(format!(
1037                "trailing bytes: {} unread of {}",
1038                tail.len(),
1039                bytes.len()
1040            )));
1041        }
1042        if envelope.magic != SNAPSHOT_MAGIC {
1043            return Err(crate::error::SimError::SnapshotFormat(
1044                "magic bytes do not match".to_string(),
1045            ));
1046        }
1047        let current = env!("CARGO_PKG_VERSION");
1048        if envelope.version != current {
1049            return Err(crate::error::SimError::SnapshotVersion {
1050                saved: envelope.version,
1051                current: current.to_owned(),
1052            });
1053        }
1054        envelope.payload.restore(custom_strategy_factory)
1055    }
1056}