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