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;
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        Ok(sim)
268    }
269
270    /// Spawn entities in the world and build the old→new `EntityId` mapping.
271    fn spawn_entities(
272        world: &mut crate::world::World,
273        entities: &[EntitySnapshot],
274    ) -> (Vec<EntityId>, HashMap<EntityId, EntityId>) {
275        let mut index_to_id: Vec<EntityId> = Vec::with_capacity(entities.len());
276        let mut id_remap: HashMap<EntityId, EntityId> = HashMap::new();
277        for snap in entities {
278            let new_id = world.spawn();
279            index_to_id.push(new_id);
280            id_remap.insert(snap.original_id, new_id);
281        }
282        (index_to_id, id_remap)
283    }
284
285    /// Attach components to spawned entities, remapping cross-references.
286    fn attach_components(
287        world: &mut crate::world::World,
288        entities: &[EntitySnapshot],
289        index_to_id: &[EntityId],
290        id_remap: &HashMap<EntityId, EntityId>,
291    ) {
292        let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
293        let remap_opt = |old: Option<EntityId>| -> Option<EntityId> { old.map(&remap) };
294
295        for (i, snap) in entities.iter().enumerate() {
296            let eid = index_to_id[i];
297
298            if let Some(pos) = snap.position {
299                world.set_position(eid, pos);
300            }
301            if let Some(vel) = snap.velocity {
302                world.set_velocity(eid, vel);
303            }
304            if let Some(ref elev) = snap.elevator {
305                let mut e = elev.clone();
306                e.riders = e.riders.iter().map(|&r| remap(r)).collect();
307                e.target_stop = remap_opt(e.target_stop);
308                e.line = remap(e.line);
309                e.restricted_stops = e.restricted_stops.iter().map(|&s| remap(s)).collect();
310                e.phase = match e.phase {
311                    crate::components::ElevatorPhase::MovingToStop(s) => {
312                        crate::components::ElevatorPhase::MovingToStop(remap(s))
313                    }
314                    crate::components::ElevatorPhase::Repositioning(s) => {
315                        crate::components::ElevatorPhase::Repositioning(remap(s))
316                    }
317                    other => other,
318                };
319                world.set_elevator(eid, e);
320            }
321            if let Some(ref stop) = snap.stop {
322                world.set_stop(eid, stop.clone());
323            }
324            if let Some(ref rider) = snap.rider {
325                use crate::components::RiderPhase;
326                let mut r = rider.clone();
327                r.current_stop = remap_opt(r.current_stop);
328                r.phase = match r.phase {
329                    RiderPhase::Boarding(e) => RiderPhase::Boarding(remap(e)),
330                    RiderPhase::Riding(e) => RiderPhase::Riding(remap(e)),
331                    RiderPhase::Exiting(e) => RiderPhase::Exiting(remap(e)),
332                    other => other,
333                };
334                world.set_rider(eid, r);
335            }
336            if let Some(ref route) = snap.route {
337                let mut rt = route.clone();
338                for leg in &mut rt.legs {
339                    leg.from = remap(leg.from);
340                    leg.to = remap(leg.to);
341                    if let crate::components::TransportMode::Line(ref mut l) = leg.via {
342                        *l = remap(*l);
343                    }
344                }
345                world.set_route(eid, rt);
346            }
347            if let Some(ref line) = snap.line {
348                world.set_line(eid, line.clone());
349            }
350            if let Some(patience) = snap.patience {
351                world.set_patience(eid, patience);
352            }
353            if let Some(prefs) = snap.preferences {
354                world.set_preferences(eid, prefs);
355            }
356            if let Some(ref ac) = snap.access_control {
357                let remapped =
358                    AccessControl::new(ac.allowed_stops().iter().map(|&s| remap(s)).collect());
359                world.set_access_control(eid, remapped);
360            }
361            if snap.disabled {
362                world.disable(eid);
363            }
364            #[cfg(feature = "energy")]
365            if let Some(ref profile) = snap.energy_profile {
366                world.set_energy_profile(eid, profile.clone());
367            }
368            #[cfg(feature = "energy")]
369            if let Some(ref em) = snap.energy_metrics {
370                world.set_energy_metrics(eid, em.clone());
371            }
372            if let Some(mode) = snap.service_mode {
373                world.set_service_mode(eid, mode);
374            }
375            if let Some(ref dq) = snap.destination_queue {
376                use crate::components::DestinationQueue as DQ;
377                let mut new_dq = DQ::new();
378                for &e in dq.queue() {
379                    new_dq.push_back(remap(e));
380                }
381                world.set_destination_queue(eid, new_dq);
382            }
383            Self::attach_car_calls(world, eid, &snap.car_calls, id_remap);
384        }
385    }
386
387    /// Re-register per-car floor button presses after entities are spawned.
388    fn attach_car_calls(
389        world: &mut crate::world::World,
390        car: EntityId,
391        car_calls: &[CarCall],
392        id_remap: &HashMap<EntityId, EntityId>,
393    ) {
394        if car_calls.is_empty() {
395            return;
396        }
397        let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
398        let Some(slot) = world.car_calls_mut(car) else {
399            return;
400        };
401        for cc in car_calls {
402            let mut c = cc.clone();
403            c.car = car;
404            c.floor = remap(c.floor);
405            c.pending_riders = c.pending_riders.iter().map(|&r| remap(r)).collect();
406            slot.push(c);
407        }
408    }
409
410    /// Re-register hall calls in the world after entities are spawned.
411    ///
412    /// `HallCall` cross-references stops, cars, riders, and optional
413    /// destinations — all `EntityId`s must be remapped through `id_remap`.
414    fn attach_hall_calls(
415        &self,
416        world: &mut crate::world::World,
417        id_remap: &HashMap<EntityId, EntityId>,
418    ) {
419        let remap = |old: EntityId| -> EntityId { id_remap.get(&old).copied().unwrap_or(old) };
420        let remap_opt = |old: Option<EntityId>| -> Option<EntityId> { old.map(&remap) };
421        for hc in &self.hall_calls {
422            let mut c = hc.clone();
423            c.stop = remap(c.stop);
424            c.destination = remap_opt(c.destination);
425            c.assigned_car = remap_opt(c.assigned_car);
426            c.pending_riders = c.pending_riders.iter().map(|&r| remap(r)).collect();
427            world.set_hall_call(c);
428        }
429    }
430
431    /// Rebuild groups, stop lookup, and dispatchers from snapshot data.
432    #[allow(clippy::type_complexity)]
433    fn rebuild_groups_and_dispatchers(
434        &self,
435        index_to_id: &[EntityId],
436        custom_strategy_factory: CustomStrategyFactory<'_>,
437    ) -> Result<
438        (
439            Vec<crate::dispatch::ElevatorGroup>,
440            HashMap<StopId, EntityId>,
441            std::collections::BTreeMap<GroupId, Box<dyn crate::dispatch::DispatchStrategy>>,
442            std::collections::BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
443        ),
444        crate::error::SimError,
445    > {
446        use crate::dispatch::ElevatorGroup;
447
448        let groups: Vec<ElevatorGroup> = self
449            .groups
450            .iter()
451            .map(|gs| {
452                let elevator_entities: Vec<EntityId> = gs
453                    .elevator_indices
454                    .iter()
455                    .filter_map(|&i| index_to_id.get(i).copied())
456                    .collect();
457                let stop_entities: Vec<EntityId> = gs
458                    .stop_indices
459                    .iter()
460                    .filter_map(|&i| index_to_id.get(i).copied())
461                    .collect();
462
463                let lines = if gs.lines.is_empty() {
464                    // Legacy snapshots have no per-line data; create a single
465                    // synthetic LineInfo containing all elevators and stops.
466                    vec![crate::dispatch::LineInfo::new(
467                        EntityId::default(),
468                        elevator_entities,
469                        stop_entities,
470                    )]
471                } else {
472                    gs.lines
473                        .iter()
474                        .filter_map(|lsi| {
475                            let entity = index_to_id.get(lsi.entity_index).copied()?;
476                            Some(crate::dispatch::LineInfo::new(
477                                entity,
478                                lsi.elevator_indices
479                                    .iter()
480                                    .filter_map(|&i| index_to_id.get(i).copied())
481                                    .collect(),
482                                lsi.stop_indices
483                                    .iter()
484                                    .filter_map(|&i| index_to_id.get(i).copied())
485                                    .collect(),
486                            ))
487                        })
488                        .collect()
489                };
490
491                ElevatorGroup::new(gs.id, gs.name.clone(), lines)
492                    .with_hall_call_mode(gs.hall_call_mode)
493                    .with_ack_latency_ticks(gs.ack_latency_ticks)
494            })
495            .collect();
496
497        let stop_lookup: HashMap<StopId, EntityId> = self
498            .stop_lookup
499            .iter()
500            .filter_map(|(sid, &idx)| index_to_id.get(idx).map(|&eid| (*sid, eid)))
501            .collect();
502
503        let mut dispatchers = std::collections::BTreeMap::new();
504        let mut strategy_ids = std::collections::BTreeMap::new();
505        for (gs, group) in self.groups.iter().zip(groups.iter()) {
506            let strategy: Box<dyn crate::dispatch::DispatchStrategy> =
507                if let Some(builtin) = gs.strategy.instantiate() {
508                    builtin
509                } else if let crate::dispatch::BuiltinStrategy::Custom(ref name) = gs.strategy {
510                    custom_strategy_factory
511                        .and_then(|f| f(name))
512                        .ok_or_else(|| crate::error::SimError::UnresolvedCustomStrategy {
513                            name: name.clone(),
514                            group: group.id(),
515                        })?
516                } else {
517                    Box::new(crate::dispatch::scan::ScanDispatch::new())
518                };
519            dispatchers.insert(group.id(), strategy);
520            strategy_ids.insert(group.id(), gs.strategy.clone());
521        }
522
523        Ok((groups, stop_lookup, dispatchers, strategy_ids))
524    }
525
526    /// Remap `EntityId`s in extension data using the old→new mapping.
527    fn remap_extensions(
528        extensions: &HashMap<String, HashMap<EntityId, String>>,
529        id_remap: &HashMap<EntityId, EntityId>,
530    ) -> HashMap<String, HashMap<EntityId, String>> {
531        extensions
532            .iter()
533            .map(|(name, entries)| {
534                let remapped: HashMap<EntityId, String> = entries
535                    .iter()
536                    .map(|(old_id, data)| {
537                        let new_id = id_remap.get(old_id).copied().unwrap_or(*old_id);
538                        (new_id, data.clone())
539                    })
540                    .collect();
541                (name.clone(), remapped)
542            })
543            .collect()
544    }
545}
546
547/// Magic bytes identifying a bincode snapshot blob.
548const SNAPSHOT_MAGIC: [u8; 8] = *b"ELEVSNAP";
549
550/// Byte-level snapshot envelope: magic + crate version + payload.
551///
552/// Serialized via bincode. The magic and version fields are checked on
553/// restore to reject blobs from other tools or from a different
554/// `elevator-core` version.
555#[derive(Debug, Serialize, Deserialize)]
556struct SnapshotEnvelope {
557    /// Magic bytes; must equal [`SNAPSHOT_MAGIC`] or the blob is rejected.
558    magic: [u8; 8],
559    /// `elevator-core` crate version that produced the blob.
560    version: String,
561    /// The captured simulation state.
562    payload: WorldSnapshot,
563}
564
565impl crate::sim::Simulation {
566    /// Create a serializable snapshot of the current simulation state.
567    ///
568    /// The snapshot captures all entities, components, groups, metrics,
569    /// and the tick counter. Extension components and custom resources
570    /// are NOT included — games must serialize those separately.
571    #[must_use]
572    pub fn snapshot(&self) -> WorldSnapshot {
573        let world = self.world();
574
575        // Build entity index: map EntityId → position in vec.
576        let all_ids: Vec<EntityId> = world.alive.keys().collect();
577        let id_to_index: HashMap<EntityId, usize> = all_ids
578            .iter()
579            .copied()
580            .enumerate()
581            .map(|(i, e)| (e, i))
582            .collect();
583
584        // Snapshot each entity.
585        let entities: Vec<EntitySnapshot> = all_ids
586            .iter()
587            .map(|&eid| EntitySnapshot {
588                original_id: eid,
589                position: world.position(eid).copied(),
590                velocity: world.velocity(eid).copied(),
591                elevator: world.elevator(eid).cloned(),
592                stop: world.stop(eid).cloned(),
593                rider: world.rider(eid).cloned(),
594                route: world.route(eid).cloned(),
595                line: world.line(eid).cloned(),
596                patience: world.patience(eid).copied(),
597                preferences: world.preferences(eid).copied(),
598                access_control: world.access_control(eid).cloned(),
599                disabled: world.is_disabled(eid),
600                #[cfg(feature = "energy")]
601                energy_profile: world.energy_profile(eid).cloned(),
602                #[cfg(feature = "energy")]
603                energy_metrics: world.energy_metrics(eid).cloned(),
604                service_mode: world.service_mode(eid).copied(),
605                destination_queue: world.destination_queue(eid).cloned(),
606                car_calls: world.car_calls(eid).to_vec(),
607            })
608            .collect();
609
610        // Snapshot groups (convert EntityIds to indices).
611        let groups: Vec<GroupSnapshot> = self
612            .groups()
613            .iter()
614            .map(|g| {
615                let lines: Vec<LineSnapshotInfo> = g
616                    .lines()
617                    .iter()
618                    .filter_map(|li| {
619                        let entity_index = id_to_index.get(&li.entity()).copied()?;
620                        Some(LineSnapshotInfo {
621                            entity_index,
622                            elevator_indices: li
623                                .elevators()
624                                .iter()
625                                .filter_map(|eid| id_to_index.get(eid).copied())
626                                .collect(),
627                            stop_indices: li
628                                .serves()
629                                .iter()
630                                .filter_map(|eid| id_to_index.get(eid).copied())
631                                .collect(),
632                        })
633                    })
634                    .collect();
635                GroupSnapshot {
636                    id: g.id(),
637                    name: g.name().to_owned(),
638                    elevator_indices: g
639                        .elevator_entities()
640                        .iter()
641                        .filter_map(|eid| id_to_index.get(eid).copied())
642                        .collect(),
643                    stop_indices: g
644                        .stop_entities()
645                        .iter()
646                        .filter_map(|eid| id_to_index.get(eid).copied())
647                        .collect(),
648                    strategy: self
649                        .strategy_id(g.id())
650                        .cloned()
651                        .unwrap_or(crate::dispatch::BuiltinStrategy::Scan),
652                    lines,
653                    reposition: self.reposition_id(g.id()).cloned(),
654                    hall_call_mode: g.hall_call_mode(),
655                    ack_latency_ticks: g.ack_latency_ticks(),
656                }
657            })
658            .collect();
659
660        // Snapshot stop lookup (convert EntityIds to indices).
661        let stop_lookup: HashMap<StopId, usize> = self
662            .stop_lookup_iter()
663            .filter_map(|(sid, eid)| id_to_index.get(eid).map(|&idx| (*sid, idx)))
664            .collect();
665
666        WorldSnapshot {
667            tick: self.current_tick(),
668            dt: self.dt(),
669            entities,
670            groups,
671            stop_lookup,
672            metrics: self.metrics().clone(),
673            metric_tags: self
674                .world()
675                .resource::<MetricTags>()
676                .cloned()
677                .unwrap_or_default(),
678            extensions: self.world().serialize_extensions(),
679            ticks_per_second: 1.0 / self.dt(),
680            hall_calls: world.iter_hall_calls().cloned().collect(),
681        }
682    }
683
684    /// Serialize the current state to a self-describing byte blob.
685    ///
686    /// The blob is postcard-encoded and carries a magic prefix plus the
687    /// `elevator-core` crate version. Use [`Simulation::restore_bytes`]
688    /// on the receiving end. Determinism is bit-exact across builds of
689    /// the same crate version; cross-version restores return
690    /// [`SimError::SnapshotVersion`](crate::error::SimError::SnapshotVersion).
691    ///
692    /// Extension component *data* is serialized (identical to
693    /// [`Simulation::snapshot`]); after restore, use
694    /// [`Simulation::load_extensions_with`] to register and load them.
695    /// Custom dispatch strategies and arbitrary `World` resources are
696    /// not included.
697    ///
698    /// # Errors
699    /// Returns [`SimError::SnapshotFormat`](crate::error::SimError::SnapshotFormat)
700    /// if postcard encoding fails. This is unreachable for well-formed
701    /// `WorldSnapshot` values (all fields derive `Serialize`), so callers
702    /// that don't care can `unwrap`.
703    pub fn snapshot_bytes(&self) -> Result<Vec<u8>, crate::error::SimError> {
704        let envelope = SnapshotEnvelope {
705            magic: SNAPSHOT_MAGIC,
706            version: env!("CARGO_PKG_VERSION").to_owned(),
707            payload: self.snapshot(),
708        };
709        postcard::to_allocvec(&envelope)
710            .map_err(|e| crate::error::SimError::SnapshotFormat(e.to_string()))
711    }
712
713    /// Restore a simulation from bytes produced by [`Simulation::snapshot_bytes`].
714    ///
715    /// Built-in dispatch strategies are auto-restored. For groups using
716    /// [`BuiltinStrategy::Custom`](crate::dispatch::BuiltinStrategy::Custom),
717    /// provide a factory; pass `None` otherwise.
718    ///
719    /// # Errors
720    /// - [`SimError::SnapshotFormat`](crate::error::SimError::SnapshotFormat)
721    ///   if the bytes are not a valid envelope or the magic prefix does
722    ///   not match.
723    /// - [`SimError::SnapshotVersion`](crate::error::SimError::SnapshotVersion)
724    ///   if the blob was produced by a different crate version.
725    /// - [`SimError::UnresolvedCustomStrategy`](crate::error::SimError::UnresolvedCustomStrategy)
726    ///   if a group uses a custom strategy that the factory cannot resolve.
727    pub fn restore_bytes(
728        bytes: &[u8],
729        custom_strategy_factory: CustomStrategyFactory<'_>,
730    ) -> Result<Self, crate::error::SimError> {
731        let (envelope, tail): (SnapshotEnvelope, &[u8]) = postcard::take_from_bytes(bytes)
732            .map_err(|e| crate::error::SimError::SnapshotFormat(e.to_string()))?;
733        if !tail.is_empty() {
734            return Err(crate::error::SimError::SnapshotFormat(format!(
735                "trailing bytes: {} unread of {}",
736                tail.len(),
737                bytes.len()
738            )));
739        }
740        if envelope.magic != SNAPSHOT_MAGIC {
741            return Err(crate::error::SimError::SnapshotFormat(
742                "magic bytes do not match".to_string(),
743            ));
744        }
745        let current = env!("CARGO_PKG_VERSION");
746        if envelope.version != current {
747            return Err(crate::error::SimError::SnapshotVersion {
748                saved: envelope.version,
749                current: current.to_owned(),
750            });
751        }
752        envelope.payload.restore(custom_strategy_factory)
753    }
754}