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