Skip to main content

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