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