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