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/// Validate the physics fields shared by [`crate::config::ElevatorConfig`]
46/// and [`super::ElevatorParams`]. Both construction-time validation and
47/// the runtime `add_elevator` path call this so an invalid set of params
48/// can never reach the world (zeroes blow up movement; zero door ticks
49/// stall the door FSM).
50#[allow(clippy::too_many_arguments)]
51pub(super) fn validate_elevator_physics(
52    max_speed: f64,
53    acceleration: f64,
54    deceleration: f64,
55    weight_capacity: f64,
56    inspection_speed_factor: f64,
57    door_transition_ticks: u32,
58    door_open_ticks: u32,
59    bypass_load_up_pct: Option<f64>,
60    bypass_load_down_pct: Option<f64>,
61) -> Result<(), SimError> {
62    if !max_speed.is_finite() || max_speed <= 0.0 {
63        return Err(SimError::InvalidConfig {
64            field: "elevators.max_speed",
65            reason: format!("must be finite and positive, got {max_speed}"),
66        });
67    }
68    if !acceleration.is_finite() || acceleration <= 0.0 {
69        return Err(SimError::InvalidConfig {
70            field: "elevators.acceleration",
71            reason: format!("must be finite and positive, got {acceleration}"),
72        });
73    }
74    if !deceleration.is_finite() || deceleration <= 0.0 {
75        return Err(SimError::InvalidConfig {
76            field: "elevators.deceleration",
77            reason: format!("must be finite and positive, got {deceleration}"),
78        });
79    }
80    if !weight_capacity.is_finite() || weight_capacity <= 0.0 {
81        return Err(SimError::InvalidConfig {
82            field: "elevators.weight_capacity",
83            reason: format!("must be finite and positive, got {weight_capacity}"),
84        });
85    }
86    if !inspection_speed_factor.is_finite() || inspection_speed_factor <= 0.0 {
87        return Err(SimError::InvalidConfig {
88            field: "elevators.inspection_speed_factor",
89            reason: format!("must be finite and positive, got {inspection_speed_factor}"),
90        });
91    }
92    if door_transition_ticks == 0 {
93        return Err(SimError::InvalidConfig {
94            field: "elevators.door_transition_ticks",
95            reason: "must be > 0".into(),
96        });
97    }
98    if door_open_ticks == 0 {
99        return Err(SimError::InvalidConfig {
100            field: "elevators.door_open_ticks",
101            reason: "must be > 0".into(),
102        });
103    }
104    validate_bypass_pct("elevators.bypass_load_up_pct", bypass_load_up_pct)?;
105    validate_bypass_pct("elevators.bypass_load_down_pct", bypass_load_down_pct)?;
106    Ok(())
107}
108
109/// `bypass_load_{up,down}_pct` must be a finite fraction in `(0.0, 1.0]`
110/// when set. `pct = 0.0` would bypass at an empty car (nonsense); `NaN`
111/// and infinities silently disable the bypass under the dispatch guard,
112/// which is a silent foot-gun. Reject at config time instead.
113fn validate_bypass_pct(field: &'static str, pct: Option<f64>) -> Result<(), SimError> {
114    let Some(pct) = pct else {
115        return Ok(());
116    };
117    if !pct.is_finite() || pct <= 0.0 || pct > 1.0 {
118        return Err(SimError::InvalidConfig {
119            field,
120            reason: format!("must be finite in (0.0, 1.0] when set, got {pct}"),
121        });
122    }
123    Ok(())
124}
125
126impl Simulation {
127    /// Create a new simulation from config and a dispatch strategy.
128    ///
129    /// Returns `Err` if the config is invalid (zero stops, duplicate IDs,
130    /// negative speeds, etc.).
131    ///
132    /// # Errors
133    ///
134    /// Returns [`SimError::InvalidConfig`] if the configuration has zero stops,
135    /// duplicate stop IDs, zero elevators, non-positive physics parameters,
136    /// invalid starting stops, or non-positive tick rate.
137    pub fn new(
138        config: &SimConfig,
139        dispatch: impl DispatchStrategy + 'static,
140    ) -> Result<Self, SimError> {
141        let mut dispatchers = BTreeMap::new();
142        dispatchers.insert(GroupId(0), Box::new(dispatch) as Box<dyn DispatchStrategy>);
143        Self::new_with_hooks(config, dispatchers, PhaseHooks::default())
144    }
145
146    /// Create a simulation with pre-configured lifecycle hooks.
147    ///
148    /// Used by [`SimulationBuilder`](crate::builder::SimulationBuilder).
149    #[allow(clippy::too_many_lines)]
150    pub(crate) fn new_with_hooks(
151        config: &SimConfig,
152        builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
153        hooks: PhaseHooks,
154    ) -> Result<Self, SimError> {
155        Self::validate_config(config)?;
156
157        let mut world = World::new();
158
159        // Create stop entities.
160        let mut stop_lookup: HashMap<StopId, EntityId> = HashMap::new();
161        for sc in &config.building.stops {
162            let eid = world.spawn();
163            world.set_stop(
164                eid,
165                Stop {
166                    name: sc.name.clone(),
167                    position: sc.position,
168                },
169            );
170            world.set_position(eid, Position { value: sc.position });
171            stop_lookup.insert(sc.id, eid);
172        }
173
174        // Build sorted-stops index for O(log n) PassingFloor detection.
175        let mut sorted: Vec<(f64, EntityId)> = world
176            .iter_stops()
177            .map(|(eid, stop)| (stop.position, eid))
178            .collect();
179        sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
180        world.insert_resource(crate::world::SortedStops(sorted));
181
182        // Per-stop arrival signal, appended on rider spawn and queried
183        // by dispatch/reposition strategies to drive traffic-mode
184        // switches and predictive parking.
185        world.insert_resource(crate::arrival_log::ArrivalLog::default());
186        world.insert_resource(crate::arrival_log::CurrentTick::default());
187        world.insert_resource(crate::arrival_log::ArrivalLogRetention::default());
188        // Traffic-mode classifier. Auto-refreshed in the metrics phase
189        // from the same rolling window; strategies read the current
190        // mode via `World::resource::<TrafficDetector>()`.
191        world.insert_resource(crate::traffic_detector::TrafficDetector::default());
192        // Expose tick rate to strategies that need to unit-convert
193        // tick-denominated elevator fields (door cycle, ack latency)
194        // into the second-denominated terms of their cost functions.
195        // Without this, ETD's door-overhead term was summing ticks
196        // into a seconds expression and getting ~60× over-weighted.
197        world.insert_resource(crate::time::TickRate(config.simulation.ticks_per_second));
198
199        let (groups, dispatchers, strategy_ids) = if let Some(line_configs) = &config.building.lines
200        {
201            Self::build_explicit_topology(
202                &mut world,
203                config,
204                line_configs,
205                &stop_lookup,
206                builder_dispatchers,
207            )
208        } else {
209            Self::build_legacy_topology(&mut world, config, &stop_lookup, builder_dispatchers)
210        };
211
212        let dt = 1.0 / config.simulation.ticks_per_second;
213
214        world.insert_resource(crate::tagged_metrics::MetricTags::default());
215
216        // Collect line tag info (entity + name + elevator entities) before
217        // borrowing world mutably for MetricTags.
218        let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
219            .iter()
220            .flat_map(|group| {
221                group.lines().iter().filter_map(|li| {
222                    let line_comp = world.line(li.entity())?;
223                    Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
224                })
225            })
226            .collect();
227
228        // Tag line entities and their elevators with "line:{name}".
229        if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
230            for (line_eid, name, elevators) in &line_tag_info {
231                let tag = format!("line:{name}");
232                tags.tag(*line_eid, tag.clone());
233                for elev_eid in elevators {
234                    tags.tag(*elev_eid, tag.clone());
235                }
236            }
237        }
238
239        // Wire reposition strategies from group configs.
240        let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
241        let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
242        if let Some(group_configs) = &config.building.groups {
243            for gc in group_configs {
244                if let Some(ref repo_id) = gc.reposition
245                    && let Some(strategy) = repo_id.instantiate()
246                {
247                    let gid = GroupId(gc.id);
248                    repositioners.insert(gid, strategy);
249                    reposition_ids.insert(gid, repo_id.clone());
250                }
251            }
252        }
253
254        Ok(Self {
255            world,
256            events: EventBus::default(),
257            pending_output: Vec::new(),
258            tick: 0,
259            dt,
260            groups,
261            stop_lookup,
262            dispatchers,
263            strategy_ids,
264            repositioners,
265            reposition_ids,
266            metrics: Metrics::new(),
267            time: TimeAdapter::new(config.simulation.ticks_per_second),
268            hooks,
269            elevator_ids_buf: Vec::new(),
270            reposition_buf: Vec::new(),
271            topo_graph: Mutex::new(TopologyGraph::new()),
272            rider_index: RiderIndex::default(),
273            tick_in_progress: false,
274        })
275    }
276
277    /// Spawn a single elevator entity from an `ElevatorConfig` onto `line`.
278    ///
279    /// Sets position, velocity, all `Elevator` fields, optional energy profile,
280    /// optional service mode, and an empty `DestinationQueue`.
281    /// Returns the new entity ID.
282    fn spawn_elevator_entity(
283        world: &mut World,
284        ec: &crate::config::ElevatorConfig,
285        line: EntityId,
286        stop_lookup: &HashMap<StopId, EntityId>,
287        start_pos_lookup: &[crate::stop::StopConfig],
288    ) -> EntityId {
289        let eid = world.spawn();
290        let start_pos = start_pos_lookup
291            .iter()
292            .find(|s| s.id == ec.starting_stop)
293            .map_or(0.0, |s| s.position);
294        world.set_position(eid, Position { value: start_pos });
295        world.set_velocity(eid, Velocity { value: 0.0 });
296        let restricted: HashSet<EntityId> = ec
297            .restricted_stops
298            .iter()
299            .filter_map(|sid| stop_lookup.get(sid).copied())
300            .collect();
301        world.set_elevator(
302            eid,
303            Elevator {
304                phase: ElevatorPhase::Idle,
305                door: DoorState::Closed,
306                max_speed: ec.max_speed,
307                acceleration: ec.acceleration,
308                deceleration: ec.deceleration,
309                weight_capacity: ec.weight_capacity,
310                current_load: crate::components::Weight::ZERO,
311                riders: Vec::new(),
312                target_stop: None,
313                door_transition_ticks: ec.door_transition_ticks,
314                door_open_ticks: ec.door_open_ticks,
315                line,
316                repositioning: false,
317                restricted_stops: restricted,
318                inspection_speed_factor: ec.inspection_speed_factor,
319                going_up: true,
320                going_down: true,
321                move_count: 0,
322                door_command_queue: Vec::new(),
323                manual_target_velocity: None,
324                bypass_load_up_pct: ec.bypass_load_up_pct,
325                bypass_load_down_pct: ec.bypass_load_down_pct,
326            },
327        );
328        #[cfg(feature = "energy")]
329        if let Some(ref profile) = ec.energy_profile {
330            world.set_energy_profile(eid, profile.clone());
331            world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
332        }
333        if let Some(mode) = ec.service_mode {
334            world.set_service_mode(eid, mode);
335        }
336        world.set_destination_queue(eid, crate::components::DestinationQueue::new());
337        eid
338    }
339
340    /// Build topology from the legacy flat elevator list (single default line + group).
341    fn build_legacy_topology(
342        world: &mut World,
343        config: &SimConfig,
344        stop_lookup: &HashMap<StopId, EntityId>,
345        builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
346    ) -> TopologyResult {
347        let all_stop_entities: Vec<EntityId> = stop_lookup.values().copied().collect();
348        let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
349        let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
350        let max_pos = stop_positions
351            .iter()
352            .copied()
353            .fold(f64::NEG_INFINITY, f64::max);
354
355        let default_line_eid = world.spawn();
356        world.set_line(
357            default_line_eid,
358            Line {
359                name: "Default".into(),
360                group: GroupId(0),
361                orientation: Orientation::Vertical,
362                position: None,
363                min_position: min_pos,
364                max_position: max_pos,
365                max_cars: None,
366            },
367        );
368
369        let mut elevator_entities = Vec::new();
370        for ec in &config.elevators {
371            let eid = Self::spawn_elevator_entity(
372                world,
373                ec,
374                default_line_eid,
375                stop_lookup,
376                &config.building.stops,
377            );
378            elevator_entities.push(eid);
379        }
380
381        let default_line_info =
382            LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
383
384        let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
385
386        // Legacy topology has exactly one group: GroupId(0). Honour a
387        // builder-provided dispatcher for that group; ignore any builder
388        // entry keyed on a different GroupId (it would have nothing to
389        // attach to). Pre-fix this used `into_iter().next()` which
390        // discarded the GroupId entirely and could attach a dispatcher
391        // intended for a different group to GroupId(0). (#288)
392        let mut dispatchers = BTreeMap::new();
393        let mut strategy_ids = BTreeMap::new();
394        let user_dispatcher = builder_dispatchers
395            .into_iter()
396            .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
397        if let Some(d) = user_dispatcher {
398            dispatchers.insert(GroupId(0), d);
399        } else {
400            dispatchers.insert(
401                GroupId(0),
402                Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
403            );
404        }
405        // strategy_ids defaults to Scan (the legacy-topology default and
406        // the type passed by every Simulation::new caller in practice).
407        // Builder users who install a non-Scan dispatcher should also
408        // call `.with_strategy_id(...)` if they need snapshot fidelity —
409        // we can't infer the BuiltinStrategy class from `Box<dyn>`.
410        strategy_ids.insert(GroupId(0), BuiltinStrategy::Scan);
411
412        (vec![group], dispatchers, strategy_ids)
413    }
414
415    /// Build topology from explicit `LineConfig`/`GroupConfig` definitions.
416    #[allow(clippy::too_many_lines)]
417    fn build_explicit_topology(
418        world: &mut World,
419        config: &SimConfig,
420        line_configs: &[crate::config::LineConfig],
421        stop_lookup: &HashMap<StopId, EntityId>,
422        builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
423    ) -> TopologyResult {
424        // Map line config id → (line EntityId, LineInfo).
425        let mut line_map: HashMap<u32, (EntityId, LineInfo)> = HashMap::new();
426
427        for lc in line_configs {
428            // Resolve served stop entities.
429            let served_entities: Vec<EntityId> = lc
430                .serves
431                .iter()
432                .filter_map(|sid| stop_lookup.get(sid).copied())
433                .collect();
434
435            // Compute min/max from stops if not explicitly set.
436            let stop_positions: Vec<f64> = lc
437                .serves
438                .iter()
439                .filter_map(|sid| {
440                    config
441                        .building
442                        .stops
443                        .iter()
444                        .find(|s| s.id == *sid)
445                        .map(|s| s.position)
446                })
447                .collect();
448            let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
449            let auto_max = stop_positions
450                .iter()
451                .copied()
452                .fold(f64::NEG_INFINITY, f64::max);
453
454            let min_pos = lc.min_position.unwrap_or(auto_min);
455            let max_pos = lc.max_position.unwrap_or(auto_max);
456
457            let line_eid = world.spawn();
458            // The group assignment will be set when we process GroupConfigs.
459            // Default to GroupId(0) initially.
460            world.set_line(
461                line_eid,
462                Line {
463                    name: lc.name.clone(),
464                    group: GroupId(0),
465                    orientation: lc.orientation,
466                    position: lc.position,
467                    min_position: min_pos,
468                    max_position: max_pos,
469                    max_cars: lc.max_cars,
470                },
471            );
472
473            // Spawn elevators for this line.
474            let mut elevator_entities = Vec::new();
475            for ec in &lc.elevators {
476                let eid = Self::spawn_elevator_entity(
477                    world,
478                    ec,
479                    line_eid,
480                    stop_lookup,
481                    &config.building.stops,
482                );
483                elevator_entities.push(eid);
484            }
485
486            let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
487            line_map.insert(lc.id, (line_eid, line_info));
488        }
489
490        // Build groups from GroupConfigs, or auto-infer a single group.
491        let group_configs = config.building.groups.as_deref();
492        let mut groups = Vec::new();
493        let mut dispatchers = BTreeMap::new();
494        let mut strategy_ids = BTreeMap::new();
495
496        if let Some(gcs) = group_configs {
497            for gc in gcs {
498                let group_id = GroupId(gc.id);
499
500                let mut group_lines = Vec::new();
501
502                for &lid in &gc.lines {
503                    if let Some((line_eid, li)) = line_map.get(&lid) {
504                        // Update the line's group assignment.
505                        if let Some(line_comp) = world.line_mut(*line_eid) {
506                            line_comp.group = group_id;
507                        }
508                        group_lines.push(li.clone());
509                    }
510                }
511
512                let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
513                if let Some(mode) = gc.hall_call_mode {
514                    group.set_hall_call_mode(mode);
515                }
516                if let Some(ticks) = gc.ack_latency_ticks {
517                    group.set_ack_latency_ticks(ticks);
518                }
519                groups.push(group);
520
521                // GroupConfig strategy; builder overrides applied after this loop.
522                let dispatch: Box<dyn DispatchStrategy> = gc
523                    .dispatch
524                    .instantiate()
525                    .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
526                dispatchers.insert(group_id, dispatch);
527                strategy_ids.insert(group_id, gc.dispatch.clone());
528            }
529        } else {
530            // No explicit groups — create a single default group with all lines.
531            let group_id = GroupId(0);
532            let mut group_lines = Vec::new();
533
534            for (line_eid, li) in line_map.values() {
535                if let Some(line_comp) = world.line_mut(*line_eid) {
536                    line_comp.group = group_id;
537                }
538                group_lines.push(li.clone());
539            }
540
541            let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
542            groups.push(group);
543
544            let dispatch: Box<dyn DispatchStrategy> =
545                Box::new(crate::dispatch::scan::ScanDispatch::new());
546            dispatchers.insert(group_id, dispatch);
547            strategy_ids.insert(group_id, BuiltinStrategy::Scan);
548        }
549
550        // Override with builder-provided dispatchers (they take precedence).
551        // Pre-fix this could mismatch `strategy_ids` against `dispatchers`
552        // when both config and builder specified a strategy for the same
553        // group (#287). The new precedence: builder wins for the dispatcher
554        // and we keep the config's strategy_id only when no builder
555        // override touched the group.
556        for (gid, d) in builder_dispatchers {
557            dispatchers.insert(gid, d);
558            // Builder dispatchers don't carry a `BuiltinStrategy` discriminant.
559            // If there's no config strategy_id for this group, leave it absent
560            // (snapshot will fail to instantiate; caller must register a
561            // factory). If there IS one, keep it: in practice, the
562            // `Simulation::new(cfg, X)` direct path always passes an X that
563            // matches the config's declared strategy.
564            strategy_ids
565                .entry(gid)
566                .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
567        }
568
569        (groups, dispatchers, strategy_ids)
570    }
571
572    /// Restore a simulation from pre-built parts (used by snapshot restore).
573    #[allow(clippy::too_many_arguments)]
574    pub(crate) fn from_parts(
575        world: World,
576        tick: u64,
577        dt: f64,
578        groups: Vec<ElevatorGroup>,
579        stop_lookup: HashMap<StopId, EntityId>,
580        dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
581        strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
582        metrics: Metrics,
583        ticks_per_second: f64,
584    ) -> Self {
585        let mut rider_index = RiderIndex::default();
586        rider_index.rebuild(&world);
587        // Ensure the dispatch-visible tick rate matches the simulation
588        // tick rate after a snapshot restore; a snapshot that predates
589        // the `TickRate` resource leaves it absent and dispatch would
590        // otherwise fall back to the 60 Hz default even for a 30 Hz
591        // sim, silently halving ETD's door-cost scale.
592        let mut world = world;
593        world.insert_resource(crate::time::TickRate(ticks_per_second));
594        // Re-insert the traffic detector for the same forward-compat
595        // reason as `TickRate`: a snapshot taken before this resource
596        // existed wouldn't carry it, and `refresh_traffic_detector` in
597        // the metrics phase would silently no-op forever post-restore
598        // (greptile review of #361). `insert_resource` is
599        // last-writer-wins, so snapshots that already carry a
600        // detector keep their stored state.
601        if world
602            .resource::<crate::traffic_detector::TrafficDetector>()
603            .is_none()
604        {
605            world.insert_resource(crate::traffic_detector::TrafficDetector::default());
606        }
607        // Same forward-compat pattern for the destination log. An
608        // older snapshot would leave the detector unable to detect
609        // down-peak post-restore; a fresh empty log lets it resume
610        // classification after a few ticks of observed traffic.
611        if world
612            .resource::<crate::arrival_log::DestinationLog>()
613            .is_none()
614        {
615            world.insert_resource(crate::arrival_log::DestinationLog::default());
616        }
617        Self {
618            world,
619            events: EventBus::default(),
620            pending_output: Vec::new(),
621            tick,
622            dt,
623            groups,
624            stop_lookup,
625            dispatchers,
626            strategy_ids,
627            repositioners: BTreeMap::new(),
628            reposition_ids: BTreeMap::new(),
629            metrics,
630            time: TimeAdapter::new(ticks_per_second),
631            hooks: PhaseHooks::default(),
632            elevator_ids_buf: Vec::new(),
633            reposition_buf: Vec::new(),
634            topo_graph: Mutex::new(TopologyGraph::new()),
635            rider_index,
636            tick_in_progress: false,
637        }
638    }
639
640    /// Validate configuration before constructing the simulation.
641    pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
642        if config.building.stops.is_empty() {
643            return Err(SimError::InvalidConfig {
644                field: "building.stops",
645                reason: "at least one stop is required".into(),
646            });
647        }
648
649        // Check for duplicate stop IDs and validate positions.
650        let mut seen_ids = HashSet::new();
651        for stop in &config.building.stops {
652            if !seen_ids.insert(stop.id) {
653                return Err(SimError::InvalidConfig {
654                    field: "building.stops",
655                    reason: format!("duplicate {}", stop.id),
656                });
657            }
658            if !stop.position.is_finite() {
659                return Err(SimError::InvalidConfig {
660                    field: "building.stops.position",
661                    reason: format!("{} has non-finite position {}", stop.id, stop.position),
662                });
663            }
664        }
665
666        let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
667
668        if let Some(line_configs) = &config.building.lines {
669            // ── Explicit topology validation ──
670            Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
671        } else {
672            // ── Legacy flat elevator list validation ──
673            Self::validate_legacy_elevators(&config.elevators, &config.building)?;
674        }
675
676        if !config.simulation.ticks_per_second.is_finite()
677            || config.simulation.ticks_per_second <= 0.0
678        {
679            return Err(SimError::InvalidConfig {
680                field: "simulation.ticks_per_second",
681                reason: format!(
682                    "must be finite and positive, got {}",
683                    config.simulation.ticks_per_second
684                ),
685            });
686        }
687
688        Self::validate_passenger_spawning(&config.passenger_spawning)?;
689
690        Ok(())
691    }
692
693    /// Validate `PassengerSpawnConfig`. Without this, bad inputs reach
694    /// `PoissonSource::from_config` and panic later (NaN/negative weights
695    /// crash `random_range`/`Weight::from`; zero `mean_interval_ticks`
696    /// burst-fires every catch-up tick). (#272)
697    fn validate_passenger_spawning(
698        spawn: &crate::config::PassengerSpawnConfig,
699    ) -> Result<(), SimError> {
700        let (lo, hi) = spawn.weight_range;
701        if !lo.is_finite() || !hi.is_finite() {
702            return Err(SimError::InvalidConfig {
703                field: "passenger_spawning.weight_range",
704                reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
705            });
706        }
707        if lo < 0.0 || hi < 0.0 {
708            return Err(SimError::InvalidConfig {
709                field: "passenger_spawning.weight_range",
710                reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
711            });
712        }
713        if lo > hi {
714            return Err(SimError::InvalidConfig {
715                field: "passenger_spawning.weight_range",
716                reason: format!("min must be <= max, got ({lo}, {hi})"),
717            });
718        }
719        if spawn.mean_interval_ticks == 0 {
720            return Err(SimError::InvalidConfig {
721                field: "passenger_spawning.mean_interval_ticks",
722                reason: "must be > 0; mean_interval_ticks=0 burst-fires \
723                         every catch-up tick"
724                    .into(),
725            });
726        }
727        Ok(())
728    }
729
730    /// Validate the legacy flat elevator list.
731    fn validate_legacy_elevators(
732        elevators: &[crate::config::ElevatorConfig],
733        building: &crate::config::BuildingConfig,
734    ) -> Result<(), SimError> {
735        if elevators.is_empty() {
736            return Err(SimError::InvalidConfig {
737                field: "elevators",
738                reason: "at least one elevator is required".into(),
739            });
740        }
741
742        for elev in elevators {
743            Self::validate_elevator_config(elev, building)?;
744        }
745
746        Ok(())
747    }
748
749    /// Validate a single elevator config's physics and starting stop.
750    fn validate_elevator_config(
751        elev: &crate::config::ElevatorConfig,
752        building: &crate::config::BuildingConfig,
753    ) -> Result<(), SimError> {
754        validate_elevator_physics(
755            elev.max_speed.value(),
756            elev.acceleration.value(),
757            elev.deceleration.value(),
758            elev.weight_capacity.value(),
759            elev.inspection_speed_factor,
760            elev.door_transition_ticks,
761            elev.door_open_ticks,
762            elev.bypass_load_up_pct,
763            elev.bypass_load_down_pct,
764        )?;
765        if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
766            return Err(SimError::InvalidConfig {
767                field: "elevators.starting_stop",
768                reason: format!("references non-existent {}", elev.starting_stop),
769            });
770        }
771        Ok(())
772    }
773
774    /// Validate explicit line/group topology.
775    fn validate_explicit_topology(
776        line_configs: &[crate::config::LineConfig],
777        stop_ids: &HashSet<StopId>,
778        building: &crate::config::BuildingConfig,
779    ) -> Result<(), SimError> {
780        // No duplicate line IDs.
781        let mut seen_line_ids = HashSet::new();
782        for lc in line_configs {
783            if !seen_line_ids.insert(lc.id) {
784                return Err(SimError::InvalidConfig {
785                    field: "building.lines",
786                    reason: format!("duplicate line id {}", lc.id),
787                });
788            }
789        }
790
791        // Every line's serves must reference existing stops and be non-empty.
792        for lc in line_configs {
793            if lc.serves.is_empty() {
794                return Err(SimError::InvalidConfig {
795                    field: "building.lines.serves",
796                    reason: format!("line {} has no stops", lc.id),
797                });
798            }
799            for sid in &lc.serves {
800                if !stop_ids.contains(sid) {
801                    return Err(SimError::InvalidConfig {
802                        field: "building.lines.serves",
803                        reason: format!("line {} references non-existent {}", lc.id, sid),
804                    });
805                }
806            }
807            // Validate elevators within each line.
808            for ec in &lc.elevators {
809                Self::validate_elevator_config(ec, building)?;
810            }
811
812            // Validate max_cars is not exceeded.
813            if let Some(max) = lc.max_cars
814                && lc.elevators.len() > max
815            {
816                return Err(SimError::InvalidConfig {
817                    field: "building.lines.max_cars",
818                    reason: format!(
819                        "line {} has {} elevators but max_cars is {max}",
820                        lc.id,
821                        lc.elevators.len()
822                    ),
823                });
824            }
825        }
826
827        // At least one line with at least one elevator.
828        let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
829        if !has_elevator {
830            return Err(SimError::InvalidConfig {
831                field: "building.lines",
832                reason: "at least one line must have at least one elevator".into(),
833            });
834        }
835
836        // No orphaned stops: every stop must be served by at least one line.
837        let served: HashSet<StopId> = line_configs
838            .iter()
839            .flat_map(|lc| lc.serves.iter().copied())
840            .collect();
841        for sid in stop_ids {
842            if !served.contains(sid) {
843                return Err(SimError::InvalidConfig {
844                    field: "building.lines",
845                    reason: format!("orphaned stop {sid} not served by any line"),
846                });
847            }
848        }
849
850        // Validate groups if present.
851        if let Some(group_configs) = &building.groups {
852            let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
853
854            let mut seen_group_ids = HashSet::new();
855            for gc in group_configs {
856                if !seen_group_ids.insert(gc.id) {
857                    return Err(SimError::InvalidConfig {
858                        field: "building.groups",
859                        reason: format!("duplicate group id {}", gc.id),
860                    });
861                }
862                for &lid in &gc.lines {
863                    if !line_id_set.contains(&lid) {
864                        return Err(SimError::InvalidConfig {
865                            field: "building.groups.lines",
866                            reason: format!(
867                                "group {} references non-existent line id {}",
868                                gc.id, lid
869                            ),
870                        });
871                    }
872                }
873            }
874
875            // Check for orphaned lines (not referenced by any group).
876            let referenced_line_ids: HashSet<u32> = group_configs
877                .iter()
878                .flat_map(|g| g.lines.iter().copied())
879                .collect();
880            for lc in line_configs {
881                if !referenced_line_ids.contains(&lc.id) {
882                    return Err(SimError::InvalidConfig {
883                        field: "building.lines",
884                        reason: format!("line {} is not assigned to any group", lc.id),
885                    });
886                }
887            }
888        }
889
890        Ok(())
891    }
892
893    // ── Dispatch management ──────────────────────────────────────────
894
895    /// Replace the dispatch strategy for a group.
896    ///
897    /// The `id` parameter identifies the strategy for snapshot serialization.
898    /// Use `BuiltinStrategy::Custom("name")` for custom strategies.
899    pub fn set_dispatch(
900        &mut self,
901        group: GroupId,
902        strategy: Box<dyn DispatchStrategy>,
903        id: crate::dispatch::BuiltinStrategy,
904    ) {
905        self.dispatchers.insert(group, strategy);
906        self.strategy_ids.insert(group, id);
907    }
908
909    // ── Reposition management ─────────────────────────────────────────
910
911    /// Set the reposition strategy for a group.
912    ///
913    /// Enables the reposition phase for this group. Idle elevators will
914    /// be repositioned according to the strategy after each dispatch phase.
915    pub fn set_reposition(
916        &mut self,
917        group: GroupId,
918        strategy: Box<dyn RepositionStrategy>,
919        id: BuiltinReposition,
920    ) {
921        self.repositioners.insert(group, strategy);
922        self.reposition_ids.insert(group, id);
923    }
924
925    /// Remove the reposition strategy for a group, disabling repositioning.
926    pub fn remove_reposition(&mut self, group: GroupId) {
927        self.repositioners.remove(&group);
928        self.reposition_ids.remove(&group);
929    }
930
931    /// Get the reposition strategy identifier for a group.
932    #[must_use]
933    pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
934        self.reposition_ids.get(&group)
935    }
936
937    // ── Hooks ────────────────────────────────────────────────────────
938
939    /// Register a hook to run before a simulation phase.
940    ///
941    /// Hooks are called in registration order. The hook receives mutable
942    /// access to the world, allowing entity inspection or modification.
943    pub fn add_before_hook(
944        &mut self,
945        phase: Phase,
946        hook: impl Fn(&mut World) + Send + Sync + 'static,
947    ) {
948        self.hooks.add_before(phase, Box::new(hook));
949    }
950
951    /// Register a hook to run after a simulation phase.
952    ///
953    /// Hooks are called in registration order. The hook receives mutable
954    /// access to the world, allowing entity inspection or modification.
955    pub fn add_after_hook(
956        &mut self,
957        phase: Phase,
958        hook: impl Fn(&mut World) + Send + Sync + 'static,
959    ) {
960        self.hooks.add_after(phase, Box::new(hook));
961    }
962
963    /// Register a hook to run before a phase for a specific group.
964    pub fn add_before_group_hook(
965        &mut self,
966        phase: Phase,
967        group: GroupId,
968        hook: impl Fn(&mut World) + Send + Sync + 'static,
969    ) {
970        self.hooks.add_before_group(phase, group, Box::new(hook));
971    }
972
973    /// Register a hook to run after a phase for a specific group.
974    pub fn add_after_group_hook(
975        &mut self,
976        phase: Phase,
977        group: GroupId,
978        hook: impl Fn(&mut World) + Send + Sync + 'static,
979    ) {
980        self.hooks.add_after_group(phase, group, Box::new(hook));
981    }
982}