elevator_core/sim/construction.rs
1//! Simulation construction, validation, and topology assembly.
2//!
3//! Split out from `sim.rs` to keep each concern readable. Holds:
4//!
5//! - [`Simulation::new`] and [`Simulation::new_with_hooks`]
6//! - Config validation ([`Simulation::validate_config`] and helpers)
7//! - Legacy and explicit topology builders
8//! - [`Simulation::from_parts`] for snapshot restore
9//! - Dispatch, reposition, and hook registration helpers
10//!
11//! Since this is a child module of `crate::sim`, it can access `Simulation`'s
12//! private fields directly — no visibility relaxation required.
13
14use std::collections::{BTreeMap, HashMap, HashSet};
15use std::sync::Mutex;
16
17use crate::components::{
18 Elevator, ElevatorPhase, Line, LineKind, Orientation, Position, Stop, Velocity,
19};
20use crate::config::SimConfig;
21use crate::dispatch::{
22 BuiltinReposition, BuiltinStrategy, DispatchStrategy, ElevatorGroup, LineInfo,
23 RepositionStrategy,
24};
25use crate::door::DoorState;
26use crate::entity::EntityId;
27use crate::error::SimError;
28use crate::events::EventBus;
29use crate::hooks::{Phase, PhaseHooks};
30use crate::ids::GroupId;
31use crate::metrics::Metrics;
32use crate::rider_index::RiderIndex;
33use crate::stop::StopId;
34use crate::time::TimeAdapter;
35use crate::topology::TopologyGraph;
36use crate::world::World;
37
38use super::Simulation;
39
40/// Bundled topology result: groups, dispatchers, and strategy IDs.
41type TopologyResult = (
42 Vec<ElevatorGroup>,
43 BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
44 BTreeMap<GroupId, BuiltinStrategy>,
45);
46
47/// Canonical [`HallCallMode`](crate::dispatch::HallCallMode) for a
48/// built-in dispatch strategy.
49///
50/// Returns `None` for [`BuiltinStrategy::Custom`] — custom strategies
51/// don't have a canonical mode and keep whatever the group already
52/// carries. Returns `Some(Destination)` only for the destination
53/// dispatch; every other built-in is `Some(Classic)`.
54///
55/// One source of truth for the construction-time and runtime sync paths
56/// (`sync_hall_call_modes` and [`Simulation::set_dispatch`]). Add the
57/// match arm here when introducing a new built-in dispatch.
58pub(super) const fn canonical_hall_call_mode(
59 strategy: &BuiltinStrategy,
60) -> Option<crate::dispatch::HallCallMode> {
61 match strategy {
62 BuiltinStrategy::Destination => Some(crate::dispatch::HallCallMode::Destination),
63 BuiltinStrategy::Custom(_) => None,
64 BuiltinStrategy::Scan
65 | BuiltinStrategy::Look
66 | BuiltinStrategy::NearestCar
67 | BuiltinStrategy::Etd
68 | BuiltinStrategy::Rsr => Some(crate::dispatch::HallCallMode::Classic),
69 // Loop groups use a one-way patrol model — no concept of an
70 // "Up" vs "Down" hall call, and the boarding phase doesn't gate
71 // on assignment. Classic collective control is the closest fit
72 // (every car serves every waiter regardless of direction lamps).
73 #[cfg(feature = "loop_lines")]
74 BuiltinStrategy::LoopSweep | BuiltinStrategy::LoopSchedule => {
75 Some(crate::dispatch::HallCallMode::Classic)
76 }
77 }
78}
79
80/// Ensure DCS groups have `HallCallMode::Destination` at construction
81/// time. Non-DCS groups are left at whatever the config specified —
82/// forcing Classic here would clobber explicit config overrides (e.g. a
83/// Scan group that the author deliberately set to Destination mode).
84///
85/// Runtime swaps via [`Simulation::set_dispatch`] do a full bidirectional
86/// sync because a strategy change is an explicit user action where
87/// resetting the mode is expected.
88fn sync_hall_call_modes(
89 groups: &mut [ElevatorGroup],
90 strategy_ids: &BTreeMap<GroupId, BuiltinStrategy>,
91) {
92 for group in groups.iter_mut() {
93 if let Some(strategy) = strategy_ids.get(&group.id())
94 && canonical_hall_call_mode(strategy)
95 == Some(crate::dispatch::HallCallMode::Destination)
96 {
97 group.set_hall_call_mode(crate::dispatch::HallCallMode::Destination);
98 }
99 }
100}
101
102/// Validate the physics fields shared by [`crate::config::ElevatorConfig`]
103/// and [`super::ElevatorParams`]. Both construction-time validation and
104/// the runtime `add_elevator` path call this so an invalid set of params
105/// can never reach the world (zeroes blow up movement; zero door ticks
106/// stall the door FSM).
107#[allow(clippy::too_many_arguments)]
108pub(super) fn validate_elevator_physics(
109 max_speed: f64,
110 acceleration: f64,
111 deceleration: f64,
112 weight_capacity: f64,
113 inspection_speed_factor: f64,
114 door_transition_ticks: u32,
115 door_open_ticks: u32,
116 bypass_load_up_pct: Option<f64>,
117 bypass_load_down_pct: Option<f64>,
118) -> Result<(), SimError> {
119 if !max_speed.is_finite() || max_speed <= 0.0 {
120 return Err(SimError::InvalidConfig {
121 field: "elevators.max_speed",
122 reason: format!("must be finite and positive, got {max_speed}"),
123 });
124 }
125 if !acceleration.is_finite() || acceleration <= 0.0 {
126 return Err(SimError::InvalidConfig {
127 field: "elevators.acceleration",
128 reason: format!("must be finite and positive, got {acceleration}"),
129 });
130 }
131 if !deceleration.is_finite() || deceleration <= 0.0 {
132 return Err(SimError::InvalidConfig {
133 field: "elevators.deceleration",
134 reason: format!("must be finite and positive, got {deceleration}"),
135 });
136 }
137 if !weight_capacity.is_finite() || weight_capacity <= 0.0 {
138 return Err(SimError::InvalidConfig {
139 field: "elevators.weight_capacity",
140 reason: format!("must be finite and positive, got {weight_capacity}"),
141 });
142 }
143 if !inspection_speed_factor.is_finite() || inspection_speed_factor <= 0.0 {
144 return Err(SimError::InvalidConfig {
145 field: "elevators.inspection_speed_factor",
146 reason: format!("must be finite and positive, got {inspection_speed_factor}"),
147 });
148 }
149 if door_transition_ticks == 0 {
150 return Err(SimError::InvalidConfig {
151 field: "elevators.door_transition_ticks",
152 reason: "must be > 0".into(),
153 });
154 }
155 if door_open_ticks == 0 {
156 return Err(SimError::InvalidConfig {
157 field: "elevators.door_open_ticks",
158 reason: "must be > 0".into(),
159 });
160 }
161 validate_bypass_pct("elevators.bypass_load_up_pct", bypass_load_up_pct)?;
162 validate_bypass_pct("elevators.bypass_load_down_pct", bypass_load_down_pct)?;
163 Ok(())
164}
165
166/// `bypass_load_{up,down}_pct` must be a finite fraction in `(0.0, 1.0]`
167/// when set. `pct = 0.0` would bypass at an empty car (nonsense); `NaN`
168/// and infinities silently disable the bypass under the dispatch guard,
169/// which is a silent foot-gun. Reject at config time instead.
170fn validate_bypass_pct(field: &'static str, pct: Option<f64>) -> Result<(), SimError> {
171 let Some(pct) = pct else {
172 return Ok(());
173 };
174 if !pct.is_finite() || pct <= 0.0 || pct > 1.0 {
175 return Err(SimError::InvalidConfig {
176 field,
177 reason: format!("must be finite in (0.0, 1.0] when set, got {pct}"),
178 });
179 }
180 Ok(())
181}
182
183impl Simulation {
184 /// Create a new simulation from config and a dispatch strategy.
185 ///
186 /// Returns `Err` if the config is invalid (zero stops, duplicate IDs,
187 /// negative speeds, etc.).
188 ///
189 /// # Errors
190 ///
191 /// Returns [`SimError::InvalidConfig`] if the configuration has zero stops,
192 /// duplicate stop IDs, zero elevators, non-positive physics parameters,
193 /// invalid starting stops, or non-positive tick rate.
194 pub fn new(
195 config: &SimConfig,
196 dispatch: impl DispatchStrategy + 'static,
197 ) -> Result<Self, SimError> {
198 let mut dispatchers = BTreeMap::new();
199 dispatchers.insert(GroupId(0), Box::new(dispatch) as Box<dyn DispatchStrategy>);
200 Self::new_with_hooks(config, dispatchers, PhaseHooks::default())
201 }
202
203 /// Create a simulation with pre-configured lifecycle hooks.
204 ///
205 /// Used by [`SimulationBuilder`](crate::builder::SimulationBuilder).
206 #[allow(clippy::too_many_lines)]
207 pub(crate) fn new_with_hooks(
208 config: &SimConfig,
209 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
210 hooks: PhaseHooks,
211 ) -> Result<Self, SimError> {
212 Self::validate_config(config)?;
213
214 let mut world = World::new();
215
216 // Create stop entities.
217 let mut stop_lookup: HashMap<StopId, EntityId> = HashMap::new();
218 for sc in &config.building.stops {
219 let eid = world.spawn();
220 world.set_stop(
221 eid,
222 Stop {
223 name: sc.name.clone(),
224 position: sc.position,
225 },
226 );
227 world.set_position(eid, Position { value: sc.position });
228 stop_lookup.insert(sc.id, eid);
229 }
230
231 // Build sorted-stops index for O(log n) PassingFloor detection.
232 let mut sorted: Vec<(f64, EntityId)> = world
233 .iter_stops()
234 .map(|(eid, stop)| (stop.position, eid))
235 .collect();
236 sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
237 world.insert_resource(crate::world::SortedStops(sorted));
238
239 // Per-stop arrival signal, appended on rider spawn and queried
240 // by dispatch/reposition strategies to drive traffic-mode
241 // switches and predictive parking. The destination mirror is
242 // what powers down-peak detection — without it the classifier
243 // sees `total_dest = 0` and silently never emits `DownPeak`.
244 // Both resources must exist before the first `RiderSpawned`
245 // event fires (i.e. before any user-driven `spawn_rider` call):
246 // `record_spawn` is fire-and-forget on missing resources, so a
247 // later insert wouldn't replay history.
248 world.insert_resource(crate::arrival_log::ArrivalLog::default());
249 world.insert_resource(crate::arrival_log::DestinationLog::default());
250 world.insert_resource(crate::arrival_log::CurrentTick::default());
251 world.insert_resource(crate::arrival_log::ArrivalLogRetention::default());
252 // Traffic-mode classifier. Auto-refreshed in the metrics phase
253 // from the same rolling window; strategies read the current
254 // mode via `World::resource::<TrafficDetector>()`.
255 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
256 // Per-car reposition cooldown. Populated by the movement
257 // phase when a repositioning car arrives; consulted by the
258 // reposition phase to skip cars that just parked so the
259 // hot-stop ranking can't flip them around again the next
260 // tick.
261 world.insert_resource(crate::dispatch::reposition::RepositionCooldowns::default());
262 // Expose tick rate to strategies that need to unit-convert
263 // tick-denominated elevator fields (door cycle, ack latency)
264 // into the second-denominated terms of their cost functions.
265 // Without this, ETD's door-overhead term was summing ticks
266 // into a seconds expression and getting ~60× over-weighted.
267 world.insert_resource(crate::time::TickRate(config.simulation.ticks_per_second));
268
269 let (mut groups, dispatchers, strategy_ids) =
270 if let Some(line_configs) = &config.building.lines {
271 Self::build_explicit_topology(
272 &mut world,
273 config,
274 line_configs,
275 &stop_lookup,
276 builder_dispatchers,
277 )
278 } else {
279 Self::build_legacy_topology(&mut world, config, &stop_lookup, builder_dispatchers)
280 };
281 sync_hall_call_modes(&mut groups, &strategy_ids);
282
283 let dt = 1.0 / config.simulation.ticks_per_second;
284
285 world.insert_resource(crate::tagged_metrics::MetricTags::default());
286
287 // Auto-register dispatch-internal extension types the sim itself
288 // owns. The same registration runs in `from_parts` (the
289 // snapshot-restore path); doing it here too means snapshot bytes
290 // taken from a fresh sim and from a restored sim agree on the
291 // extensions BTreeMap shape (#534's review surfaced this
292 // asymmetry: pre-fix, fresh sims had no `assigned_car` extension
293 // entry while restored sims did, breaking byte-equality of the
294 // snapshot bytes round-trip and the lockstep checksum).
295 world.register_ext::<crate::dispatch::destination::AssignedCar>(
296 crate::dispatch::destination::ASSIGNED_CAR_KEY,
297 );
298
299 // Collect line tag info (entity + name + elevator entities) before
300 // borrowing world mutably for MetricTags.
301 let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
302 .iter()
303 .flat_map(|group| {
304 group.lines().iter().filter_map(|li| {
305 let line_comp = world.line(li.entity())?;
306 Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
307 })
308 })
309 .collect();
310
311 // Tag line entities and their elevators with "line:{name}".
312 if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
313 for (line_eid, name, elevators) in &line_tag_info {
314 let tag = format!("line:{name}");
315 tags.tag(*line_eid, tag.clone());
316 for elev_eid in elevators {
317 tags.tag(*elev_eid, tag.clone());
318 }
319 }
320 }
321
322 // Wire reposition strategies from group configs.
323 let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
324 let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
325 if let Some(group_configs) = &config.building.groups {
326 for gc in group_configs {
327 if let Some(ref repo_id) = gc.reposition
328 && let Some(strategy) = repo_id.instantiate()
329 {
330 let gid = GroupId(gc.id);
331 repositioners.insert(gid, strategy);
332 reposition_ids.insert(gid, repo_id.clone());
333 }
334 }
335 }
336
337 Ok(Self {
338 world,
339 events: EventBus::default(),
340 pending_output: Vec::new(),
341 tick: 0,
342 dt,
343 groups,
344 stop_lookup,
345 dispatcher_set: super::DispatcherSet::from_parts(dispatchers, strategy_ids),
346 repositioner_set: super::RepositionerSet::from_parts(repositioners, reposition_ids),
347 metrics: Metrics::new(),
348 time: TimeAdapter::new(config.simulation.ticks_per_second),
349 hooks,
350 elevator_ids_buf: Vec::new(),
351 reposition_buf: Vec::new(),
352 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
353 topo_graph: Mutex::new(TopologyGraph::new()),
354 rider_index: RiderIndex::default(),
355 tick_in_progress: false,
356 phase_check: super::PhaseCheck::Disabled,
357 })
358 }
359
360 /// Spawn a single elevator entity from an `ElevatorConfig` onto `line`.
361 ///
362 /// Sets position, velocity, all `Elevator` fields, optional energy profile,
363 /// optional service mode, and an empty `DestinationQueue`.
364 /// Returns the new entity ID.
365 fn spawn_elevator_entity(
366 world: &mut World,
367 ec: &crate::config::ElevatorConfig,
368 line: EntityId,
369 stop_lookup: &HashMap<StopId, EntityId>,
370 start_pos_lookup: &[crate::stop::StopConfig],
371 ) -> EntityId {
372 let eid = world.spawn();
373 let start_pos = start_pos_lookup
374 .iter()
375 .find(|s| s.id == ec.starting_stop)
376 .map_or(0.0, |s| s.position);
377 world.set_position(eid, Position { value: start_pos });
378 world.set_velocity(eid, Velocity { value: 0.0 });
379 let restricted: HashSet<EntityId> = ec
380 .restricted_stops
381 .iter()
382 .filter_map(|sid| stop_lookup.get(sid).copied())
383 .collect();
384 // Loop cars patrol forward indefinitely; Linear cars carry the
385 // legacy two-bit (up/down) lamp pair. Seeding correctly here
386 // means a host inspecting the sim before the first `step()`
387 // already sees the right `Direction` reading on Loop cars
388 // (otherwise they'd report `Either` until the first kickstart).
389 let is_loop = world.line(line).is_some_and(Line::is_loop);
390 world.set_elevator(
391 eid,
392 Elevator {
393 phase: ElevatorPhase::Idle,
394 door: DoorState::Closed,
395 max_speed: ec.max_speed,
396 acceleration: ec.acceleration,
397 deceleration: ec.deceleration,
398 weight_capacity: ec.weight_capacity,
399 current_load: crate::components::Weight::ZERO,
400 riders: Vec::new(),
401 target_stop: None,
402 door_transition_ticks: ec.door_transition_ticks,
403 door_open_ticks: ec.door_open_ticks,
404 line,
405 repositioning: false,
406 restricted_stops: restricted,
407 inspection_speed_factor: ec.inspection_speed_factor,
408 going_up: !is_loop,
409 going_down: !is_loop,
410 going_forward: is_loop,
411 move_count: 0,
412 door_command_queue: Vec::new(),
413 manual_target_velocity: None,
414 bypass_load_up_pct: ec.bypass_load_up_pct,
415 bypass_load_down_pct: ec.bypass_load_down_pct,
416 home_stop: None,
417 },
418 );
419 #[cfg(feature = "energy")]
420 if let Some(ref profile) = ec.energy_profile {
421 world.set_energy_profile(eid, profile.clone());
422 world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
423 }
424 if let Some(mode) = ec.service_mode {
425 world.set_service_mode(eid, mode);
426 }
427 world.set_destination_queue(eid, crate::components::DestinationQueue::new());
428 eid
429 }
430
431 /// Build topology from the legacy flat elevator list (single default line + group).
432 fn build_legacy_topology(
433 world: &mut World,
434 config: &SimConfig,
435 stop_lookup: &HashMap<StopId, EntityId>,
436 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
437 ) -> TopologyResult {
438 // Iterate the config's stop list (deterministic Vec order) and
439 // resolve each through the lookup. Walking `stop_lookup.values()`
440 // would expose `HashMap` iteration order — which varies by
441 // per-process hash seed — into `LineInfo.serves` and from
442 // there into snapshot bytes.
443 let all_stop_entities: Vec<EntityId> = config
444 .building
445 .stops
446 .iter()
447 .filter_map(|s| stop_lookup.get(&s.id).copied())
448 .collect();
449 let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
450 let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
451 let max_pos = stop_positions
452 .iter()
453 .copied()
454 .fold(f64::NEG_INFINITY, f64::max);
455
456 let default_line_eid = world.spawn();
457 world.set_line(
458 default_line_eid,
459 Line {
460 name: "Default".into(),
461 group: GroupId(0),
462 orientation: Orientation::Vertical,
463 position: None,
464 kind: LineKind::Linear {
465 min: min_pos,
466 max: max_pos,
467 },
468 max_cars: None,
469 },
470 );
471
472 let mut elevator_entities = Vec::new();
473 for ec in &config.elevators {
474 let eid = Self::spawn_elevator_entity(
475 world,
476 ec,
477 default_line_eid,
478 stop_lookup,
479 &config.building.stops,
480 );
481 elevator_entities.push(eid);
482 }
483
484 let default_line_info =
485 LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
486
487 let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
488
489 // Legacy topology has exactly one group: GroupId(0). Take a builder
490 // entry keyed on that group; ignore entries keyed on any other group
491 // (they would have nothing to attach to in the legacy schema).
492 let mut dispatchers = BTreeMap::new();
493 let mut strategy_ids = BTreeMap::new();
494 let user_dispatcher = builder_dispatchers
495 .into_iter()
496 .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
497 // Snapshot identity comes from the dispatcher's own `builtin_id`, not
498 // from a hard-coded variant — otherwise a snapshot round-trip would
499 // silently swap a custom strategy back to Scan. Strategies that
500 // return `None` fall back to Scan for snapshot fidelity.
501 let inferred_id = user_dispatcher
502 .as_ref()
503 .and_then(|d| d.builtin_id())
504 .unwrap_or(BuiltinStrategy::Scan);
505 if let Some(d) = user_dispatcher {
506 dispatchers.insert(GroupId(0), d);
507 } else {
508 dispatchers.insert(
509 GroupId(0),
510 Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
511 );
512 }
513 strategy_ids.insert(GroupId(0), inferred_id);
514
515 (vec![group], dispatchers, strategy_ids)
516 }
517
518 /// Build topology from explicit `LineConfig`/`GroupConfig` definitions.
519 #[allow(clippy::too_many_lines)]
520 fn build_explicit_topology(
521 world: &mut World,
522 config: &SimConfig,
523 line_configs: &[crate::config::LineConfig],
524 stop_lookup: &HashMap<StopId, EntityId>,
525 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
526 ) -> TopologyResult {
527 // Map line config id → (line EntityId, LineInfo). `BTreeMap`
528 // (not `HashMap`) so the auto-inferred-groups branch iterates
529 // `.values()` in deterministic key order — otherwise the
530 // resulting `LineInfo` sequence permutes across processes and
531 // leaks into snapshot bytes via `GroupSnapshot::lines`.
532 let mut line_map: BTreeMap<u32, (EntityId, LineInfo)> = BTreeMap::new();
533
534 for lc in line_configs {
535 // Resolve served stop entities.
536 let served_entities: Vec<EntityId> = lc
537 .serves
538 .iter()
539 .filter_map(|sid| stop_lookup.get(sid).copied())
540 .collect();
541
542 // Compute min/max from stops if not explicitly set.
543 let stop_positions: Vec<f64> = lc
544 .serves
545 .iter()
546 .filter_map(|sid| {
547 config
548 .building
549 .stops
550 .iter()
551 .find(|s| s.id == *sid)
552 .map(|s| s.position)
553 })
554 .collect();
555 let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
556 let auto_max = stop_positions
557 .iter()
558 .copied()
559 .fold(f64::NEG_INFINITY, f64::max);
560
561 let min_pos = lc.min_position.unwrap_or(auto_min);
562 let max_pos = lc.max_position.unwrap_or(auto_max);
563
564 let line_eid = world.spawn();
565 // The group assignment will be set when we process GroupConfigs.
566 // Default to GroupId(0) initially. `kind` was validated in
567 // `validate_explicit_topology` before this builder ran.
568 world.set_line(
569 line_eid,
570 Line {
571 name: lc.name.clone(),
572 group: GroupId(0),
573 orientation: lc.orientation,
574 position: lc.position,
575 kind: lc.kind.unwrap_or(LineKind::Linear {
576 min: min_pos,
577 max: max_pos,
578 }),
579 max_cars: lc.max_cars,
580 },
581 );
582
583 // Spawn elevators for this line.
584 let mut elevator_entities = Vec::new();
585 for ec in &lc.elevators {
586 let eid = Self::spawn_elevator_entity(
587 world,
588 ec,
589 line_eid,
590 stop_lookup,
591 &config.building.stops,
592 );
593 elevator_entities.push(eid);
594 }
595
596 let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
597 line_map.insert(lc.id, (line_eid, line_info));
598 }
599
600 // Build groups from GroupConfigs, or auto-infer a single group.
601 let group_configs = config.building.groups.as_deref();
602 let mut groups = Vec::new();
603 let mut dispatchers = BTreeMap::new();
604 let mut strategy_ids = BTreeMap::new();
605
606 if let Some(gcs) = group_configs {
607 for gc in gcs {
608 let group_id = GroupId(gc.id);
609
610 let mut group_lines = Vec::new();
611
612 for &lid in &gc.lines {
613 if let Some((line_eid, li)) = line_map.get(&lid) {
614 // Update the line's group assignment.
615 if let Some(line_comp) = world.line_mut(*line_eid) {
616 line_comp.group = group_id;
617 }
618 group_lines.push(li.clone());
619 }
620 }
621
622 let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
623 if let Some(mode) = gc.hall_call_mode {
624 group.set_hall_call_mode(mode);
625 }
626 if let Some(ticks) = gc.ack_latency_ticks {
627 group.set_ack_latency_ticks(ticks);
628 }
629 groups.push(group);
630
631 // GroupConfig strategy; builder overrides applied after this loop.
632 let dispatch: Box<dyn DispatchStrategy> = gc
633 .dispatch
634 .instantiate()
635 .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
636 dispatchers.insert(group_id, dispatch);
637 strategy_ids.insert(group_id, gc.dispatch.clone());
638 }
639 } else {
640 // No explicit groups — create a single default group with all lines.
641 let group_id = GroupId(0);
642 let mut group_lines = Vec::new();
643
644 for (line_eid, li) in line_map.values() {
645 if let Some(line_comp) = world.line_mut(*line_eid) {
646 line_comp.group = group_id;
647 }
648 group_lines.push(li.clone());
649 }
650
651 let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
652 groups.push(group);
653
654 let dispatch: Box<dyn DispatchStrategy> =
655 Box::new(crate::dispatch::scan::ScanDispatch::new());
656 dispatchers.insert(group_id, dispatch);
657 strategy_ids.insert(group_id, BuiltinStrategy::Scan);
658 }
659
660 // Builder-provided dispatchers override the config. For the matching
661 // `strategy_ids` entry, prefer the dispatcher's own `builtin_id()`
662 // (snapshot fidelity); fall back to the config id only when the
663 // dispatcher is unidentified (custom strategies that don't override).
664 for (gid, d) in builder_dispatchers {
665 let inferred_id = d.builtin_id();
666 dispatchers.insert(gid, d);
667 match inferred_id {
668 Some(id) => {
669 strategy_ids.insert(gid, id);
670 }
671 None => {
672 strategy_ids
673 .entry(gid)
674 .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
675 }
676 }
677 }
678
679 (groups, dispatchers, strategy_ids)
680 }
681
682 /// Restore a simulation from pre-built parts (used by snapshot restore).
683 #[allow(clippy::too_many_arguments)]
684 pub(crate) fn from_parts(
685 world: World,
686 tick: u64,
687 dt: f64,
688 groups: Vec<ElevatorGroup>,
689 stop_lookup: HashMap<StopId, EntityId>,
690 dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
691 strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
692 metrics: Metrics,
693 ticks_per_second: f64,
694 ) -> Self {
695 let mut rider_index = RiderIndex::default();
696 rider_index.rebuild(&world);
697 // Forward-compat: snapshots predating these resources won't carry
698 // them. `TickRate` would otherwise default to 60 Hz and silently
699 // halve ETD's door-cost scale on a 30 Hz sim; the traffic detector
700 // would no-op forever in the metrics phase. `insert_resource` is
701 // last-writer-wins, so snapshots that already carry them are kept.
702 let mut world = world;
703 world.insert_resource(crate::time::TickRate(ticks_per_second));
704 if world
705 .resource::<crate::traffic_detector::TrafficDetector>()
706 .is_none()
707 {
708 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
709 }
710 // Same forward-compat pattern for the destination log. An
711 // older snapshot would leave the detector unable to detect
712 // down-peak post-restore; a fresh empty log lets it resume
713 // classification after a few ticks of observed traffic.
714 if world
715 .resource::<crate::arrival_log::DestinationLog>()
716 .is_none()
717 {
718 world.insert_resource(crate::arrival_log::DestinationLog::default());
719 }
720 // Auto-register dispatch-internal extension types the sim itself
721 // owns, and immediately load their data from the pending
722 // resource. Without this, DCS sticky assignments
723 // (`AssignedCar`) evaporate across snapshot round-trip and
724 // `DestinationDispatch` re-computes every commitment from
725 // scratch — producing different decisions than the original
726 // sim and breaking tick-for-tick determinism.
727 //
728 // `deserialize_extensions` takes a `&` of the pending map and
729 // silently skips types that aren't registered, so the call is
730 // safe to make with user-owned extensions still in the map.
731 // The `PendingExtensions` resource stays in place for a later
732 // `load_extensions_with` call to materialize the caller's own
733 // types.
734 world.register_ext::<crate::dispatch::destination::AssignedCar>(
735 crate::dispatch::destination::ASSIGNED_CAR_KEY,
736 );
737 if let Some(pending) = world.resource::<crate::snapshot::PendingExtensions>() {
738 let data = pending.0.clone();
739 world.deserialize_extensions(&data);
740 }
741 Self {
742 world,
743 events: EventBus::default(),
744 pending_output: Vec::new(),
745 tick,
746 dt,
747 groups,
748 stop_lookup,
749 dispatcher_set: super::DispatcherSet::from_parts(dispatchers, strategy_ids),
750 repositioner_set: super::RepositionerSet::new(),
751 metrics,
752 time: TimeAdapter::new(ticks_per_second),
753 hooks: PhaseHooks::default(),
754 elevator_ids_buf: Vec::new(),
755 reposition_buf: Vec::new(),
756 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
757 topo_graph: Mutex::new(TopologyGraph::new()),
758 rider_index,
759 tick_in_progress: false,
760 phase_check: super::PhaseCheck::Disabled,
761 }
762 }
763
764 /// Validate configuration before constructing the simulation.
765 pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
766 // Schema-version gate: reject forward-incompatible configs (a
767 // future build's RON would silently mis-deserialize fields a
768 // current build doesn't know about) and surface legacy
769 // pre-versioning configs (`schema_version = 0`) as an explicit
770 // upgrade prompt rather than a silent serde-default smear. See
771 // `docs/src/config-versioning.md` for the migration playbook.
772 if config.schema_version > crate::config::CURRENT_CONFIG_SCHEMA_VERSION {
773 return Err(SimError::InvalidConfig {
774 field: "schema_version",
775 reason: format!(
776 "config schema_version={} is newer than this build's CURRENT_CONFIG_SCHEMA_VERSION={}; upgrade elevator-core or downgrade the config",
777 config.schema_version,
778 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
779 ),
780 });
781 }
782 if config.schema_version == 0 {
783 return Err(SimError::InvalidConfig {
784 field: "schema_version",
785 reason: format!(
786 "config schema_version=0 (pre-versioning legacy file) — set schema_version: {} explicitly after auditing field defaults; see docs/src/config-versioning.md",
787 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
788 ),
789 });
790 }
791
792 if config.building.stops.is_empty() {
793 return Err(SimError::InvalidConfig {
794 field: "building.stops",
795 reason: "at least one stop is required".into(),
796 });
797 }
798
799 // Check for duplicate stop IDs and validate positions.
800 let mut seen_ids = HashSet::new();
801 for stop in &config.building.stops {
802 if !seen_ids.insert(stop.id) {
803 return Err(SimError::InvalidConfig {
804 field: "building.stops",
805 reason: format!("duplicate {}", stop.id),
806 });
807 }
808 if !stop.position.is_finite() {
809 return Err(SimError::InvalidConfig {
810 field: "building.stops.position",
811 reason: format!("{} has non-finite position {}", stop.id, stop.position),
812 });
813 }
814 }
815
816 let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
817
818 if let Some(line_configs) = &config.building.lines {
819 // ── Explicit topology validation ──
820 Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
821 } else {
822 // ── Legacy flat elevator list validation ──
823 Self::validate_legacy_elevators(&config.elevators, &config.building)?;
824 }
825
826 if !config.simulation.ticks_per_second.is_finite()
827 || config.simulation.ticks_per_second <= 0.0
828 {
829 return Err(SimError::InvalidConfig {
830 field: "simulation.ticks_per_second",
831 reason: format!(
832 "must be finite and positive, got {}",
833 config.simulation.ticks_per_second
834 ),
835 });
836 }
837
838 Self::validate_passenger_spawning(&config.passenger_spawning)?;
839
840 Ok(())
841 }
842
843 /// Validate `PassengerSpawnConfig`. Without this, bad inputs reach
844 /// `PoissonSource::from_config` and panic later (NaN/negative weights
845 /// crash `random_range`/`Weight::from`; zero `mean_interval_ticks`
846 /// burst-fires every catch-up tick). (#272)
847 fn validate_passenger_spawning(
848 spawn: &crate::config::PassengerSpawnConfig,
849 ) -> Result<(), SimError> {
850 let (lo, hi) = spawn.weight_range;
851 if !lo.is_finite() || !hi.is_finite() {
852 return Err(SimError::InvalidConfig {
853 field: "passenger_spawning.weight_range",
854 reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
855 });
856 }
857 if lo < 0.0 || hi < 0.0 {
858 return Err(SimError::InvalidConfig {
859 field: "passenger_spawning.weight_range",
860 reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
861 });
862 }
863 if lo > hi {
864 return Err(SimError::InvalidConfig {
865 field: "passenger_spawning.weight_range",
866 reason: format!("min must be <= max, got ({lo}, {hi})"),
867 });
868 }
869 if spawn.mean_interval_ticks == 0 {
870 return Err(SimError::InvalidConfig {
871 field: "passenger_spawning.mean_interval_ticks",
872 reason: "must be > 0; mean_interval_ticks=0 burst-fires \
873 every catch-up tick"
874 .into(),
875 });
876 }
877 Ok(())
878 }
879
880 /// Validate the legacy flat elevator list.
881 fn validate_legacy_elevators(
882 elevators: &[crate::config::ElevatorConfig],
883 building: &crate::config::BuildingConfig,
884 ) -> Result<(), SimError> {
885 if elevators.is_empty() {
886 return Err(SimError::InvalidConfig {
887 field: "elevators",
888 reason: "at least one elevator is required".into(),
889 });
890 }
891
892 for elev in elevators {
893 Self::validate_elevator_config(elev, building)?;
894 }
895
896 Ok(())
897 }
898
899 /// Validate a single elevator config's physics and starting stop.
900 fn validate_elevator_config(
901 elev: &crate::config::ElevatorConfig,
902 building: &crate::config::BuildingConfig,
903 ) -> Result<(), SimError> {
904 validate_elevator_physics(
905 elev.max_speed.value(),
906 elev.acceleration.value(),
907 elev.deceleration.value(),
908 elev.weight_capacity.value(),
909 elev.inspection_speed_factor,
910 elev.door_transition_ticks,
911 elev.door_open_ticks,
912 elev.bypass_load_up_pct,
913 elev.bypass_load_down_pct,
914 )?;
915 if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
916 return Err(SimError::InvalidConfig {
917 field: "elevators.starting_stop",
918 reason: format!("references non-existent {}", elev.starting_stop),
919 });
920 }
921 Ok(())
922 }
923
924 /// Validate explicit line/group topology.
925 #[allow(
926 clippy::too_many_lines,
927 reason = "validation reads top-to-bottom; extracting helpers would scatter related rejections across files"
928 )]
929 fn validate_explicit_topology(
930 line_configs: &[crate::config::LineConfig],
931 stop_ids: &HashSet<StopId>,
932 building: &crate::config::BuildingConfig,
933 ) -> Result<(), SimError> {
934 // No duplicate line IDs.
935 let mut seen_line_ids = HashSet::new();
936 for lc in line_configs {
937 if !seen_line_ids.insert(lc.id) {
938 return Err(SimError::InvalidConfig {
939 field: "building.lines",
940 reason: format!("duplicate line id {}", lc.id),
941 });
942 }
943 }
944
945 // Every line's serves must reference existing stops and be non-empty.
946 for lc in line_configs {
947 if lc.serves.is_empty() {
948 return Err(SimError::InvalidConfig {
949 field: "building.lines.serves",
950 reason: format!("line {} has no stops", lc.id),
951 });
952 }
953 for sid in &lc.serves {
954 if !stop_ids.contains(sid) {
955 return Err(SimError::InvalidConfig {
956 field: "building.lines.serves",
957 reason: format!("line {} references non-existent {}", lc.id, sid),
958 });
959 }
960 }
961 // Validate elevators within each line.
962 for ec in &lc.elevators {
963 Self::validate_elevator_config(ec, building)?;
964 }
965
966 // Validate max_cars is not exceeded.
967 if let Some(max) = lc.max_cars
968 && lc.elevators.len() > max
969 {
970 return Err(SimError::InvalidConfig {
971 field: "building.lines.max_cars",
972 reason: format!(
973 "line {} has {} elevators but max_cars is {max}",
974 lc.id,
975 lc.elevators.len()
976 ),
977 });
978 }
979
980 // Validate the explicit topology kind, if any. Linear-only
981 // configs (kind = None) are validated by the auto-derived
982 // bounds check inside `build_explicit_topology` instead.
983 if let Some(kind) = lc.kind
984 && let Err((field, reason)) = kind.validate()
985 {
986 return Err(SimError::InvalidConfig { field, reason });
987 }
988
989 // Loop-specific cross-field invariant: every car must fit
990 // around the loop with at least `min_headway` between
991 // successive cars. Without this guard, the second car
992 // configured on a too-short loop would instantly violate
993 // the no-overtake invariant the headway clamp is designed
994 // to preserve.
995 #[cfg(feature = "loop_lines")]
996 if let Some(crate::components::LineKind::Loop {
997 circumference,
998 min_headway,
999 }) = lc.kind
1000 {
1001 let car_count = lc
1002 .max_cars
1003 .map_or_else(|| lc.elevators.len(), |max| max.max(lc.elevators.len()));
1004 if car_count > 0 {
1005 #[allow(
1006 clippy::cast_precision_loss,
1007 reason = "car_count is bounded by usize and the comparison is against a finite f64"
1008 )]
1009 let required = (car_count as f64) * min_headway;
1010 if required > circumference {
1011 return Err(SimError::InvalidConfig {
1012 field: "building.lines.kind",
1013 reason: format!(
1014 "loop line {}: {car_count} cars × min_headway {min_headway} = {required} \
1015 exceeds circumference {circumference}",
1016 lc.id,
1017 ),
1018 });
1019 }
1020 }
1021 }
1022 }
1023
1024 // At least one line with at least one elevator.
1025 let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
1026 if !has_elevator {
1027 return Err(SimError::InvalidConfig {
1028 field: "building.lines",
1029 reason: "at least one line must have at least one elevator".into(),
1030 });
1031 }
1032
1033 // No orphaned stops: every stop must be served by at least one line.
1034 let served: HashSet<StopId> = line_configs
1035 .iter()
1036 .flat_map(|lc| lc.serves.iter().copied())
1037 .collect();
1038 for sid in stop_ids {
1039 if !served.contains(sid) {
1040 return Err(SimError::InvalidConfig {
1041 field: "building.lines",
1042 reason: format!("orphaned stop {sid} not served by any line"),
1043 });
1044 }
1045 }
1046
1047 // Validate groups if present.
1048 if let Some(group_configs) = &building.groups {
1049 let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
1050
1051 let mut seen_group_ids = HashSet::new();
1052 for gc in group_configs {
1053 if !seen_group_ids.insert(gc.id) {
1054 return Err(SimError::InvalidConfig {
1055 field: "building.groups",
1056 reason: format!("duplicate group id {}", gc.id),
1057 });
1058 }
1059 for &lid in &gc.lines {
1060 if !line_id_set.contains(&lid) {
1061 return Err(SimError::InvalidConfig {
1062 field: "building.groups.lines",
1063 reason: format!(
1064 "group {} references non-existent line id {}",
1065 gc.id, lid
1066 ),
1067 });
1068 }
1069 }
1070 }
1071
1072 // Check for orphaned lines (not referenced by any group).
1073 let referenced_line_ids: HashSet<u32> = group_configs
1074 .iter()
1075 .flat_map(|g| g.lines.iter().copied())
1076 .collect();
1077 for lc in line_configs {
1078 if !referenced_line_ids.contains(&lc.id) {
1079 return Err(SimError::InvalidConfig {
1080 field: "building.lines",
1081 reason: format!("line {} is not assigned to any group", lc.id),
1082 });
1083 }
1084 }
1085
1086 // Loop-specific group-level invariants. Run inside the
1087 // group-validation block so we can iterate `group_configs`
1088 // without recomputing the lookup. Skipped (compiles to a
1089 // no-op block) when `loop_lines` is off because no Loop
1090 // variant could possibly have been constructed.
1091 #[cfg(feature = "loop_lines")]
1092 for gc in group_configs {
1093 let lines: Vec<&crate::config::LineConfig> = gc
1094 .lines
1095 .iter()
1096 .filter_map(|lid| line_configs.iter().find(|lc| lc.id == *lid))
1097 .collect();
1098 let any_loop = lines
1099 .iter()
1100 .any(|lc| matches!(lc.kind, Some(crate::components::LineKind::Loop { .. })));
1101 // Positive match against the expected linear variant —
1102 // including the implicit-Linear case where `kind = None`.
1103 // Using a negative match against `Loop` would silently
1104 // absorb any future non-Loop variant (e.g. a hypothetical
1105 // `LineKind::Shuttle`) into the "linear" bucket and reject
1106 // intentional Loop+Shuttle mixes as "Linear+Loop", which
1107 // we wouldn't want.
1108 let any_linear = lines
1109 .iter()
1110 .any(|lc| lc.kind.as_ref().is_none_or(LineKind::is_linear));
1111 // Homogeneity: a group is either all-Linear or all-Loop.
1112 // Mixing means dispatch and reposition strategies would have
1113 // to handle both topologies in the same group, which the
1114 // strategy authors explicitly opted out of supporting.
1115 if any_loop && any_linear {
1116 return Err(SimError::InvalidConfig {
1117 field: "building.groups",
1118 reason: format!(
1119 "group {} mixes Loop and Linear lines; groups must be homogeneous",
1120 gc.id,
1121 ),
1122 });
1123 }
1124 // Parking-style reposition strategies don't compose with
1125 // continuous-patrol Loop semantics. The reposition system
1126 // already skips Loop cars, but configuring a strategy is
1127 // almost always a misunderstanding so we reject it up front.
1128 if any_loop && gc.reposition.is_some() {
1129 return Err(SimError::InvalidConfig {
1130 field: "building.groups.reposition",
1131 reason: format!(
1132 "group {} contains Loop lines; reposition strategies are unsupported on Loop",
1133 gc.id,
1134 ),
1135 });
1136 }
1137 // Strategy: Loop groups accept `LoopSweep` or
1138 // `LoopSchedule` only. Linear strategies don't apply
1139 // (Loop cars are excluded from the Hungarian idle pool
1140 // by `systems::dispatch::run`), and silently swapping a
1141 // misconfigured Linear strategy for the Loop default
1142 // would hide a bug — every other "wrong strategy" case
1143 // in this file rejects loud, so do the same here.
1144 if any_loop
1145 && !matches!(
1146 gc.dispatch,
1147 BuiltinStrategy::LoopSweep | BuiltinStrategy::LoopSchedule,
1148 )
1149 {
1150 return Err(SimError::InvalidConfig {
1151 field: "building.groups.dispatch",
1152 reason: format!(
1153 "group {} contains Loop lines but uses {} dispatch; \
1154 only LoopSweep or LoopSchedule is supported for Loop groups",
1155 gc.id, gc.dispatch,
1156 ),
1157 });
1158 }
1159 }
1160 }
1161
1162 // Per-line Loop invariants that don't depend on group context:
1163 // duplicate-position stops and initial car spacing.
1164 #[cfg(feature = "loop_lines")]
1165 for lc in line_configs {
1166 let Some(crate::components::LineKind::Loop {
1167 circumference,
1168 min_headway,
1169 }) = lc.kind
1170 else {
1171 continue;
1172 };
1173
1174 // Duplicate-position stops on a Loop are ambiguous in cyclic
1175 // order — `position_a == position_b` mod C means the dispatch
1176 // strategy can't decide which comes "first" deterministically.
1177 // Reject so authors notice the conflict explicitly.
1178 let stop_positions: Vec<f64> = lc
1179 .serves
1180 .iter()
1181 .filter_map(|sid| {
1182 building
1183 .stops
1184 .iter()
1185 .find(|s| s.id == *sid)
1186 .map(|s| s.position)
1187 })
1188 .collect();
1189 for (i, &pi) in stop_positions.iter().enumerate() {
1190 for (j, &pj) in stop_positions.iter().enumerate().skip(i + 1) {
1191 if (pi - pj).abs() < 1e-9 {
1192 return Err(SimError::InvalidConfig {
1193 field: "building.lines.serves",
1194 reason: format!(
1195 "loop line {} has duplicate stop positions at indices {i} and {j} (both at {pi})",
1196 lc.id,
1197 ),
1198 });
1199 }
1200 }
1201 }
1202
1203 // Initial car spacing: cars whose `starting_stop` positions
1204 // are closer than `min_headway` would violate the no-overtake
1205 // invariant on tick 0. Compare every pair in cyclic distance
1206 // (the shortest unsigned arc, since we don't know the cyclic
1207 // order from starting positions alone).
1208 let car_starts: Vec<f64> = lc
1209 .elevators
1210 .iter()
1211 .filter_map(|ec| {
1212 building
1213 .stops
1214 .iter()
1215 .find(|s| s.id == ec.starting_stop)
1216 .map(|s| s.position)
1217 })
1218 .collect();
1219 for (i, &a) in car_starts.iter().enumerate() {
1220 for (j, &b) in car_starts.iter().enumerate().skip(i + 1) {
1221 let d = crate::components::cyclic::cyclic_distance(a, b, circumference);
1222 if d < min_headway - 1e-9 {
1223 return Err(SimError::InvalidConfig {
1224 field: "building.lines.elevators.starting_stop",
1225 reason: format!(
1226 "loop line {}: cars at indices {i} ({a}) and {j} ({b}) are {d} apart \
1227 — below min_headway {min_headway}",
1228 lc.id,
1229 ),
1230 });
1231 }
1232 }
1233 }
1234 }
1235
1236 Ok(())
1237 }
1238
1239 // ── Dispatch management ──────────────────────────────────────────
1240
1241 /// Replace the dispatch strategy for a group.
1242 ///
1243 /// Also synchronises `HallCallMode`: `Destination` for DCS, `Classic`
1244 /// for other built-ins; `Custom` strategies leave the mode untouched.
1245 ///
1246 /// The stored snapshot identity is taken from the strategy's own
1247 /// [`DispatchStrategy::builtin_id`] when it returns `Some(..)`, so
1248 /// built-in strategies always round-trip as themselves even if the
1249 /// `id` argument drifts out of sync with the actual impl. Custom
1250 /// strategies that don't override `builtin_id` fall back to the
1251 /// caller-supplied `id`, preserving the prior API for registered
1252 /// custom factories. Mirrors the pattern applied to
1253 /// [`set_reposition`](Self::set_reposition) in #414.
1254 ///
1255 /// # Example
1256 ///
1257 /// ```
1258 /// use elevator_core::prelude::*;
1259 /// use elevator_core::ids::GroupId;
1260 ///
1261 /// let mut sim = SimulationBuilder::demo().build().unwrap();
1262 /// let strategy = BuiltinStrategy::Look.instantiate().unwrap();
1263 /// sim.set_dispatch(GroupId(0), strategy, BuiltinStrategy::Look);
1264 /// ```
1265 pub fn set_dispatch(
1266 &mut self,
1267 group: GroupId,
1268 strategy: Box<dyn DispatchStrategy>,
1269 id: crate::dispatch::BuiltinStrategy,
1270 ) {
1271 let resolved_id = strategy.builtin_id().unwrap_or(id);
1272 if let Some(mode) = canonical_hall_call_mode(&resolved_id)
1273 && let Some(g) = self.groups.iter_mut().find(|g| g.id() == group)
1274 {
1275 g.set_hall_call_mode(mode);
1276 }
1277 self.dispatcher_set.insert(group, strategy, resolved_id);
1278 }
1279
1280 // ── Reposition management ─────────────────────────────────────────
1281
1282 /// Set the reposition strategy for a group.
1283 ///
1284 /// Enables the reposition phase for this group. Idle elevators will
1285 /// be repositioned according to the strategy after each dispatch phase.
1286 ///
1287 /// The stored snapshot identity is taken from the strategy's own
1288 /// [`RepositionStrategy::builtin_id`] when it returns `Some(..)`,
1289 /// so built-in strategies always round-trip as themselves even if
1290 /// the `id` argument drifts out of sync with the actual impl.
1291 /// Custom strategies that don't override `builtin_id` fall back
1292 /// to the caller-supplied `id`, preserving the prior API for
1293 /// registered custom factories.
1294 ///
1295 /// ## Retention
1296 /// Widens [`ArrivalLogRetention`](crate::arrival_log::ArrivalLogRetention)
1297 /// to the strategy's
1298 /// [`min_arrival_log_window`](crate::dispatch::RepositionStrategy::min_arrival_log_window)
1299 /// when that exceeds current retention, never narrows it. This is
1300 /// monotonic by design — replacing a wide-window strategy with a
1301 /// narrow one (or [`remove_reposition`](Self::remove_reposition))
1302 /// leaves retention at the high-water mark rather than recomputing
1303 /// across the remaining strategies, since shrinking would also
1304 /// clobber any explicit
1305 /// [`set_arrival_log_retention_ticks`](Self::set_arrival_log_retention_ticks)
1306 /// the caller made afterwards. Long-running sims that hot-swap
1307 /// strategies pay a memory cost equal to the largest historic
1308 /// window; if that matters, call `set_arrival_log_retention_ticks`
1309 /// explicitly after the swap.
1310 ///
1311 /// # Example
1312 ///
1313 /// ```
1314 /// use elevator_core::prelude::*;
1315 /// use elevator_core::dispatch::BuiltinReposition;
1316 /// use elevator_core::ids::GroupId;
1317 ///
1318 /// let mut sim = SimulationBuilder::demo().build().unwrap();
1319 /// let strategy = BuiltinReposition::DemandWeighted.instantiate().unwrap();
1320 /// sim.set_reposition(GroupId(0), strategy, BuiltinReposition::DemandWeighted);
1321 /// ```
1322 pub fn set_reposition(
1323 &mut self,
1324 group: GroupId,
1325 strategy: Box<dyn RepositionStrategy>,
1326 id: BuiltinReposition,
1327 ) {
1328 let resolved_id = strategy.builtin_id().unwrap_or(id);
1329 let needed_window = strategy.min_arrival_log_window();
1330 self.repositioner_set.insert(group, strategy, resolved_id);
1331 // Widen the arrival-log retention if the freshly installed
1332 // strategy queries a window the pruner would otherwise truncate
1333 // under it. Without this, `PredictiveParking::with_window_ticks`
1334 // (or any custom strategy advertising a longer window) silently
1335 // sees only the last `DEFAULT_ARRIVAL_WINDOW_TICKS` of arrivals.
1336 if needed_window > 0
1337 && let Some(retention) = self
1338 .world
1339 .resource_mut::<crate::arrival_log::ArrivalLogRetention>()
1340 && needed_window > retention.0
1341 {
1342 retention.0 = needed_window;
1343 }
1344 }
1345
1346 /// Remove the reposition strategy for a group, disabling repositioning.
1347 ///
1348 /// Does not narrow
1349 /// [`ArrivalLogRetention`](crate::arrival_log::ArrivalLogRetention)
1350 /// — see the retention note on
1351 /// [`set_reposition`](Self::set_reposition) for why retention is
1352 /// monotonic across strategy lifecycle changes. Call
1353 /// [`set_arrival_log_retention_ticks`](Self::set_arrival_log_retention_ticks)
1354 /// explicitly to shrink retention after removing a wide-window
1355 /// strategy.
1356 pub fn remove_reposition(&mut self, group: GroupId) {
1357 self.repositioner_set.remove(group);
1358 }
1359
1360 /// Get the reposition strategy identifier for a group.
1361 #[must_use]
1362 pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
1363 self.repositioner_set.id_for(group)
1364 }
1365
1366 // ── Hooks ────────────────────────────────────────────────────────
1367
1368 /// Register a hook to run before a simulation phase.
1369 ///
1370 /// Hooks are called in registration order. The hook receives mutable
1371 /// access to the world, allowing entity inspection or modification.
1372 pub fn add_before_hook(
1373 &mut self,
1374 phase: Phase,
1375 hook: impl Fn(&mut World) + Send + Sync + 'static,
1376 ) {
1377 self.hooks.add_before(phase, Box::new(hook));
1378 }
1379
1380 /// Register a hook to run after a simulation phase.
1381 ///
1382 /// Hooks are called in registration order. The hook receives mutable
1383 /// access to the world, allowing entity inspection or modification.
1384 pub fn add_after_hook(
1385 &mut self,
1386 phase: Phase,
1387 hook: impl Fn(&mut World) + Send + Sync + 'static,
1388 ) {
1389 self.hooks.add_after(phase, Box::new(hook));
1390 }
1391
1392 /// Register a hook to run before a phase for a specific group.
1393 pub fn add_before_group_hook(
1394 &mut self,
1395 phase: Phase,
1396 group: GroupId,
1397 hook: impl Fn(&mut World) + Send + Sync + 'static,
1398 ) {
1399 self.hooks.add_before_group(phase, group, Box::new(hook));
1400 }
1401
1402 /// Register a hook to run after a phase for a specific group.
1403 pub fn add_after_group_hook(
1404 &mut self,
1405 phase: Phase,
1406 group: GroupId,
1407 hook: impl Fn(&mut World) + Send + Sync + 'static,
1408 ) {
1409 self.hooks.add_after_group(phase, group, Box::new(hook));
1410 }
1411}