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