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