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