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