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