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