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