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