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
189        let (groups, dispatchers, strategy_ids) = if let Some(line_configs) = &config.building.lines
190        {
191            Self::build_explicit_topology(
192                &mut world,
193                config,
194                line_configs,
195                &stop_lookup,
196                builder_dispatchers,
197            )
198        } else {
199            Self::build_legacy_topology(&mut world, config, &stop_lookup, builder_dispatchers)
200        };
201
202        let dt = 1.0 / config.simulation.ticks_per_second;
203
204        world.insert_resource(crate::tagged_metrics::MetricTags::default());
205
206        // Collect line tag info (entity + name + elevator entities) before
207        // borrowing world mutably for MetricTags.
208        let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
209            .iter()
210            .flat_map(|group| {
211                group.lines().iter().filter_map(|li| {
212                    let line_comp = world.line(li.entity())?;
213                    Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
214                })
215            })
216            .collect();
217
218        // Tag line entities and their elevators with "line:{name}".
219        if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
220            for (line_eid, name, elevators) in &line_tag_info {
221                let tag = format!("line:{name}");
222                tags.tag(*line_eid, tag.clone());
223                for elev_eid in elevators {
224                    tags.tag(*elev_eid, tag.clone());
225                }
226            }
227        }
228
229        // Wire reposition strategies from group configs.
230        let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
231        let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
232        if let Some(group_configs) = &config.building.groups {
233            for gc in group_configs {
234                if let Some(ref repo_id) = gc.reposition
235                    && let Some(strategy) = repo_id.instantiate()
236                {
237                    let gid = GroupId(gc.id);
238                    repositioners.insert(gid, strategy);
239                    reposition_ids.insert(gid, repo_id.clone());
240                }
241            }
242        }
243
244        Ok(Self {
245            world,
246            events: EventBus::default(),
247            pending_output: Vec::new(),
248            tick: 0,
249            dt,
250            groups,
251            stop_lookup,
252            dispatchers,
253            strategy_ids,
254            repositioners,
255            reposition_ids,
256            metrics: Metrics::new(),
257            time: TimeAdapter::new(config.simulation.ticks_per_second),
258            hooks,
259            elevator_ids_buf: Vec::new(),
260            reposition_buf: Vec::new(),
261            topo_graph: Mutex::new(TopologyGraph::new()),
262            rider_index: RiderIndex::default(),
263            tick_in_progress: false,
264        })
265    }
266
267    /// Spawn a single elevator entity from an `ElevatorConfig` onto `line`.
268    ///
269    /// Sets position, velocity, all `Elevator` fields, optional energy profile,
270    /// optional service mode, and an empty `DestinationQueue`.
271    /// Returns the new entity ID.
272    fn spawn_elevator_entity(
273        world: &mut World,
274        ec: &crate::config::ElevatorConfig,
275        line: EntityId,
276        stop_lookup: &HashMap<StopId, EntityId>,
277        start_pos_lookup: &[crate::stop::StopConfig],
278    ) -> EntityId {
279        let eid = world.spawn();
280        let start_pos = start_pos_lookup
281            .iter()
282            .find(|s| s.id == ec.starting_stop)
283            .map_or(0.0, |s| s.position);
284        world.set_position(eid, Position { value: start_pos });
285        world.set_velocity(eid, Velocity { value: 0.0 });
286        let restricted: HashSet<EntityId> = ec
287            .restricted_stops
288            .iter()
289            .filter_map(|sid| stop_lookup.get(sid).copied())
290            .collect();
291        world.set_elevator(
292            eid,
293            Elevator {
294                phase: ElevatorPhase::Idle,
295                door: DoorState::Closed,
296                max_speed: ec.max_speed,
297                acceleration: ec.acceleration,
298                deceleration: ec.deceleration,
299                weight_capacity: ec.weight_capacity,
300                current_load: crate::components::Weight::ZERO,
301                riders: Vec::new(),
302                target_stop: None,
303                door_transition_ticks: ec.door_transition_ticks,
304                door_open_ticks: ec.door_open_ticks,
305                line,
306                repositioning: false,
307                restricted_stops: restricted,
308                inspection_speed_factor: ec.inspection_speed_factor,
309                going_up: true,
310                going_down: true,
311                move_count: 0,
312                door_command_queue: Vec::new(),
313                manual_target_velocity: None,
314                bypass_load_up_pct: ec.bypass_load_up_pct,
315                bypass_load_down_pct: ec.bypass_load_down_pct,
316            },
317        );
318        #[cfg(feature = "energy")]
319        if let Some(ref profile) = ec.energy_profile {
320            world.set_energy_profile(eid, profile.clone());
321            world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
322        }
323        if let Some(mode) = ec.service_mode {
324            world.set_service_mode(eid, mode);
325        }
326        world.set_destination_queue(eid, crate::components::DestinationQueue::new());
327        eid
328    }
329
330    /// Build topology from the legacy flat elevator list (single default line + group).
331    fn build_legacy_topology(
332        world: &mut World,
333        config: &SimConfig,
334        stop_lookup: &HashMap<StopId, EntityId>,
335        builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
336    ) -> TopologyResult {
337        let all_stop_entities: Vec<EntityId> = stop_lookup.values().copied().collect();
338        let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
339        let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
340        let max_pos = stop_positions
341            .iter()
342            .copied()
343            .fold(f64::NEG_INFINITY, f64::max);
344
345        let default_line_eid = world.spawn();
346        world.set_line(
347            default_line_eid,
348            Line {
349                name: "Default".into(),
350                group: GroupId(0),
351                orientation: Orientation::Vertical,
352                position: None,
353                min_position: min_pos,
354                max_position: max_pos,
355                max_cars: None,
356            },
357        );
358
359        let mut elevator_entities = Vec::new();
360        for ec in &config.elevators {
361            let eid = Self::spawn_elevator_entity(
362                world,
363                ec,
364                default_line_eid,
365                stop_lookup,
366                &config.building.stops,
367            );
368            elevator_entities.push(eid);
369        }
370
371        let default_line_info =
372            LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
373
374        let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
375
376        // Legacy topology has exactly one group: GroupId(0). Honour a
377        // builder-provided dispatcher for that group; ignore any builder
378        // entry keyed on a different GroupId (it would have nothing to
379        // attach to). Pre-fix this used `into_iter().next()` which
380        // discarded the GroupId entirely and could attach a dispatcher
381        // intended for a different group to GroupId(0). (#288)
382        let mut dispatchers = BTreeMap::new();
383        let mut strategy_ids = BTreeMap::new();
384        let user_dispatcher = builder_dispatchers
385            .into_iter()
386            .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
387        if let Some(d) = user_dispatcher {
388            dispatchers.insert(GroupId(0), d);
389        } else {
390            dispatchers.insert(
391                GroupId(0),
392                Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
393            );
394        }
395        // strategy_ids defaults to Scan (the legacy-topology default and
396        // the type passed by every Simulation::new caller in practice).
397        // Builder users who install a non-Scan dispatcher should also
398        // call `.with_strategy_id(...)` if they need snapshot fidelity —
399        // we can't infer the BuiltinStrategy class from `Box<dyn>`.
400        strategy_ids.insert(GroupId(0), BuiltinStrategy::Scan);
401
402        (vec![group], dispatchers, strategy_ids)
403    }
404
405    /// Build topology from explicit `LineConfig`/`GroupConfig` definitions.
406    #[allow(clippy::too_many_lines)]
407    fn build_explicit_topology(
408        world: &mut World,
409        config: &SimConfig,
410        line_configs: &[crate::config::LineConfig],
411        stop_lookup: &HashMap<StopId, EntityId>,
412        builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
413    ) -> TopologyResult {
414        // Map line config id → (line EntityId, LineInfo).
415        let mut line_map: HashMap<u32, (EntityId, LineInfo)> = HashMap::new();
416
417        for lc in line_configs {
418            // Resolve served stop entities.
419            let served_entities: Vec<EntityId> = lc
420                .serves
421                .iter()
422                .filter_map(|sid| stop_lookup.get(sid).copied())
423                .collect();
424
425            // Compute min/max from stops if not explicitly set.
426            let stop_positions: Vec<f64> = lc
427                .serves
428                .iter()
429                .filter_map(|sid| {
430                    config
431                        .building
432                        .stops
433                        .iter()
434                        .find(|s| s.id == *sid)
435                        .map(|s| s.position)
436                })
437                .collect();
438            let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
439            let auto_max = stop_positions
440                .iter()
441                .copied()
442                .fold(f64::NEG_INFINITY, f64::max);
443
444            let min_pos = lc.min_position.unwrap_or(auto_min);
445            let max_pos = lc.max_position.unwrap_or(auto_max);
446
447            let line_eid = world.spawn();
448            // The group assignment will be set when we process GroupConfigs.
449            // Default to GroupId(0) initially.
450            world.set_line(
451                line_eid,
452                Line {
453                    name: lc.name.clone(),
454                    group: GroupId(0),
455                    orientation: lc.orientation,
456                    position: lc.position,
457                    min_position: min_pos,
458                    max_position: max_pos,
459                    max_cars: lc.max_cars,
460                },
461            );
462
463            // Spawn elevators for this line.
464            let mut elevator_entities = Vec::new();
465            for ec in &lc.elevators {
466                let eid = Self::spawn_elevator_entity(
467                    world,
468                    ec,
469                    line_eid,
470                    stop_lookup,
471                    &config.building.stops,
472                );
473                elevator_entities.push(eid);
474            }
475
476            let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
477            line_map.insert(lc.id, (line_eid, line_info));
478        }
479
480        // Build groups from GroupConfigs, or auto-infer a single group.
481        let group_configs = config.building.groups.as_deref();
482        let mut groups = Vec::new();
483        let mut dispatchers = BTreeMap::new();
484        let mut strategy_ids = BTreeMap::new();
485
486        if let Some(gcs) = group_configs {
487            for gc in gcs {
488                let group_id = GroupId(gc.id);
489
490                let mut group_lines = Vec::new();
491
492                for &lid in &gc.lines {
493                    if let Some((line_eid, li)) = line_map.get(&lid) {
494                        // Update the line's group assignment.
495                        if let Some(line_comp) = world.line_mut(*line_eid) {
496                            line_comp.group = group_id;
497                        }
498                        group_lines.push(li.clone());
499                    }
500                }
501
502                let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
503                if let Some(mode) = gc.hall_call_mode {
504                    group.set_hall_call_mode(mode);
505                }
506                if let Some(ticks) = gc.ack_latency_ticks {
507                    group.set_ack_latency_ticks(ticks);
508                }
509                groups.push(group);
510
511                // GroupConfig strategy; builder overrides applied after this loop.
512                let dispatch: Box<dyn DispatchStrategy> = gc
513                    .dispatch
514                    .instantiate()
515                    .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
516                dispatchers.insert(group_id, dispatch);
517                strategy_ids.insert(group_id, gc.dispatch.clone());
518            }
519        } else {
520            // No explicit groups — create a single default group with all lines.
521            let group_id = GroupId(0);
522            let mut group_lines = Vec::new();
523
524            for (line_eid, li) in line_map.values() {
525                if let Some(line_comp) = world.line_mut(*line_eid) {
526                    line_comp.group = group_id;
527                }
528                group_lines.push(li.clone());
529            }
530
531            let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
532            groups.push(group);
533
534            let dispatch: Box<dyn DispatchStrategy> =
535                Box::new(crate::dispatch::scan::ScanDispatch::new());
536            dispatchers.insert(group_id, dispatch);
537            strategy_ids.insert(group_id, BuiltinStrategy::Scan);
538        }
539
540        // Override with builder-provided dispatchers (they take precedence).
541        // Pre-fix this could mismatch `strategy_ids` against `dispatchers`
542        // when both config and builder specified a strategy for the same
543        // group (#287). The new precedence: builder wins for the dispatcher
544        // and we keep the config's strategy_id only when no builder
545        // override touched the group.
546        for (gid, d) in builder_dispatchers {
547            dispatchers.insert(gid, d);
548            // Builder dispatchers don't carry a `BuiltinStrategy` discriminant.
549            // If there's no config strategy_id for this group, leave it absent
550            // (snapshot will fail to instantiate; caller must register a
551            // factory). If there IS one, keep it: in practice, the
552            // `Simulation::new(cfg, X)` direct path always passes an X that
553            // matches the config's declared strategy.
554            strategy_ids
555                .entry(gid)
556                .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
557        }
558
559        (groups, dispatchers, strategy_ids)
560    }
561
562    /// Restore a simulation from pre-built parts (used by snapshot restore).
563    #[allow(clippy::too_many_arguments)]
564    pub(crate) fn from_parts(
565        world: World,
566        tick: u64,
567        dt: f64,
568        groups: Vec<ElevatorGroup>,
569        stop_lookup: HashMap<StopId, EntityId>,
570        dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
571        strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
572        metrics: Metrics,
573        ticks_per_second: f64,
574    ) -> Self {
575        let mut rider_index = RiderIndex::default();
576        rider_index.rebuild(&world);
577        Self {
578            world,
579            events: EventBus::default(),
580            pending_output: Vec::new(),
581            tick,
582            dt,
583            groups,
584            stop_lookup,
585            dispatchers,
586            strategy_ids,
587            repositioners: BTreeMap::new(),
588            reposition_ids: BTreeMap::new(),
589            metrics,
590            time: TimeAdapter::new(ticks_per_second),
591            hooks: PhaseHooks::default(),
592            elevator_ids_buf: Vec::new(),
593            reposition_buf: Vec::new(),
594            topo_graph: Mutex::new(TopologyGraph::new()),
595            rider_index,
596            tick_in_progress: false,
597        }
598    }
599
600    /// Validate configuration before constructing the simulation.
601    pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
602        if config.building.stops.is_empty() {
603            return Err(SimError::InvalidConfig {
604                field: "building.stops",
605                reason: "at least one stop is required".into(),
606            });
607        }
608
609        // Check for duplicate stop IDs and validate positions.
610        let mut seen_ids = HashSet::new();
611        for stop in &config.building.stops {
612            if !seen_ids.insert(stop.id) {
613                return Err(SimError::InvalidConfig {
614                    field: "building.stops",
615                    reason: format!("duplicate {}", stop.id),
616                });
617            }
618            if !stop.position.is_finite() {
619                return Err(SimError::InvalidConfig {
620                    field: "building.stops.position",
621                    reason: format!("{} has non-finite position {}", stop.id, stop.position),
622                });
623            }
624        }
625
626        let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
627
628        if let Some(line_configs) = &config.building.lines {
629            // ── Explicit topology validation ──
630            Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
631        } else {
632            // ── Legacy flat elevator list validation ──
633            Self::validate_legacy_elevators(&config.elevators, &config.building)?;
634        }
635
636        if !config.simulation.ticks_per_second.is_finite()
637            || config.simulation.ticks_per_second <= 0.0
638        {
639            return Err(SimError::InvalidConfig {
640                field: "simulation.ticks_per_second",
641                reason: format!(
642                    "must be finite and positive, got {}",
643                    config.simulation.ticks_per_second
644                ),
645            });
646        }
647
648        Self::validate_passenger_spawning(&config.passenger_spawning)?;
649
650        Ok(())
651    }
652
653    /// Validate `PassengerSpawnConfig`. Without this, bad inputs reach
654    /// `PoissonSource::from_config` and panic later (NaN/negative weights
655    /// crash `random_range`/`Weight::from`; zero `mean_interval_ticks`
656    /// burst-fires every catch-up tick). (#272)
657    fn validate_passenger_spawning(
658        spawn: &crate::config::PassengerSpawnConfig,
659    ) -> Result<(), SimError> {
660        let (lo, hi) = spawn.weight_range;
661        if !lo.is_finite() || !hi.is_finite() {
662            return Err(SimError::InvalidConfig {
663                field: "passenger_spawning.weight_range",
664                reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
665            });
666        }
667        if lo < 0.0 || hi < 0.0 {
668            return Err(SimError::InvalidConfig {
669                field: "passenger_spawning.weight_range",
670                reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
671            });
672        }
673        if lo > hi {
674            return Err(SimError::InvalidConfig {
675                field: "passenger_spawning.weight_range",
676                reason: format!("min must be <= max, got ({lo}, {hi})"),
677            });
678        }
679        if spawn.mean_interval_ticks == 0 {
680            return Err(SimError::InvalidConfig {
681                field: "passenger_spawning.mean_interval_ticks",
682                reason: "must be > 0; mean_interval_ticks=0 burst-fires \
683                         every catch-up tick"
684                    .into(),
685            });
686        }
687        Ok(())
688    }
689
690    /// Validate the legacy flat elevator list.
691    fn validate_legacy_elevators(
692        elevators: &[crate::config::ElevatorConfig],
693        building: &crate::config::BuildingConfig,
694    ) -> Result<(), SimError> {
695        if elevators.is_empty() {
696            return Err(SimError::InvalidConfig {
697                field: "elevators",
698                reason: "at least one elevator is required".into(),
699            });
700        }
701
702        for elev in elevators {
703            Self::validate_elevator_config(elev, building)?;
704        }
705
706        Ok(())
707    }
708
709    /// Validate a single elevator config's physics and starting stop.
710    fn validate_elevator_config(
711        elev: &crate::config::ElevatorConfig,
712        building: &crate::config::BuildingConfig,
713    ) -> Result<(), SimError> {
714        validate_elevator_physics(
715            elev.max_speed.value(),
716            elev.acceleration.value(),
717            elev.deceleration.value(),
718            elev.weight_capacity.value(),
719            elev.inspection_speed_factor,
720            elev.door_transition_ticks,
721            elev.door_open_ticks,
722            elev.bypass_load_up_pct,
723            elev.bypass_load_down_pct,
724        )?;
725        if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
726            return Err(SimError::InvalidConfig {
727                field: "elevators.starting_stop",
728                reason: format!("references non-existent {}", elev.starting_stop),
729            });
730        }
731        Ok(())
732    }
733
734    /// Validate explicit line/group topology.
735    fn validate_explicit_topology(
736        line_configs: &[crate::config::LineConfig],
737        stop_ids: &HashSet<StopId>,
738        building: &crate::config::BuildingConfig,
739    ) -> Result<(), SimError> {
740        // No duplicate line IDs.
741        let mut seen_line_ids = HashSet::new();
742        for lc in line_configs {
743            if !seen_line_ids.insert(lc.id) {
744                return Err(SimError::InvalidConfig {
745                    field: "building.lines",
746                    reason: format!("duplicate line id {}", lc.id),
747                });
748            }
749        }
750
751        // Every line's serves must reference existing stops and be non-empty.
752        for lc in line_configs {
753            if lc.serves.is_empty() {
754                return Err(SimError::InvalidConfig {
755                    field: "building.lines.serves",
756                    reason: format!("line {} has no stops", lc.id),
757                });
758            }
759            for sid in &lc.serves {
760                if !stop_ids.contains(sid) {
761                    return Err(SimError::InvalidConfig {
762                        field: "building.lines.serves",
763                        reason: format!("line {} references non-existent {}", lc.id, sid),
764                    });
765                }
766            }
767            // Validate elevators within each line.
768            for ec in &lc.elevators {
769                Self::validate_elevator_config(ec, building)?;
770            }
771
772            // Validate max_cars is not exceeded.
773            if let Some(max) = lc.max_cars
774                && lc.elevators.len() > max
775            {
776                return Err(SimError::InvalidConfig {
777                    field: "building.lines.max_cars",
778                    reason: format!(
779                        "line {} has {} elevators but max_cars is {max}",
780                        lc.id,
781                        lc.elevators.len()
782                    ),
783                });
784            }
785        }
786
787        // At least one line with at least one elevator.
788        let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
789        if !has_elevator {
790            return Err(SimError::InvalidConfig {
791                field: "building.lines",
792                reason: "at least one line must have at least one elevator".into(),
793            });
794        }
795
796        // No orphaned stops: every stop must be served by at least one line.
797        let served: HashSet<StopId> = line_configs
798            .iter()
799            .flat_map(|lc| lc.serves.iter().copied())
800            .collect();
801        for sid in stop_ids {
802            if !served.contains(sid) {
803                return Err(SimError::InvalidConfig {
804                    field: "building.lines",
805                    reason: format!("orphaned stop {sid} not served by any line"),
806                });
807            }
808        }
809
810        // Validate groups if present.
811        if let Some(group_configs) = &building.groups {
812            let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
813
814            let mut seen_group_ids = HashSet::new();
815            for gc in group_configs {
816                if !seen_group_ids.insert(gc.id) {
817                    return Err(SimError::InvalidConfig {
818                        field: "building.groups",
819                        reason: format!("duplicate group id {}", gc.id),
820                    });
821                }
822                for &lid in &gc.lines {
823                    if !line_id_set.contains(&lid) {
824                        return Err(SimError::InvalidConfig {
825                            field: "building.groups.lines",
826                            reason: format!(
827                                "group {} references non-existent line id {}",
828                                gc.id, lid
829                            ),
830                        });
831                    }
832                }
833            }
834
835            // Check for orphaned lines (not referenced by any group).
836            let referenced_line_ids: HashSet<u32> = group_configs
837                .iter()
838                .flat_map(|g| g.lines.iter().copied())
839                .collect();
840            for lc in line_configs {
841                if !referenced_line_ids.contains(&lc.id) {
842                    return Err(SimError::InvalidConfig {
843                        field: "building.lines",
844                        reason: format!("line {} is not assigned to any group", lc.id),
845                    });
846                }
847            }
848        }
849
850        Ok(())
851    }
852
853    // ── Dispatch management ──────────────────────────────────────────
854
855    /// Replace the dispatch strategy for a group.
856    ///
857    /// The `id` parameter identifies the strategy for snapshot serialization.
858    /// Use `BuiltinStrategy::Custom("name")` for custom strategies.
859    pub fn set_dispatch(
860        &mut self,
861        group: GroupId,
862        strategy: Box<dyn DispatchStrategy>,
863        id: crate::dispatch::BuiltinStrategy,
864    ) {
865        self.dispatchers.insert(group, strategy);
866        self.strategy_ids.insert(group, id);
867    }
868
869    // ── Reposition management ─────────────────────────────────────────
870
871    /// Set the reposition strategy for a group.
872    ///
873    /// Enables the reposition phase for this group. Idle elevators will
874    /// be repositioned according to the strategy after each dispatch phase.
875    pub fn set_reposition(
876        &mut self,
877        group: GroupId,
878        strategy: Box<dyn RepositionStrategy>,
879        id: BuiltinReposition,
880    ) {
881        self.repositioners.insert(group, strategy);
882        self.reposition_ids.insert(group, id);
883    }
884
885    /// Remove the reposition strategy for a group, disabling repositioning.
886    pub fn remove_reposition(&mut self, group: GroupId) {
887        self.repositioners.remove(&group);
888        self.reposition_ids.remove(&group);
889    }
890
891    /// Get the reposition strategy identifier for a group.
892    #[must_use]
893    pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
894        self.reposition_ids.get(&group)
895    }
896
897    // ── Hooks ────────────────────────────────────────────────────────
898
899    /// Register a hook to run before a simulation phase.
900    ///
901    /// Hooks are called in registration order. The hook receives mutable
902    /// access to the world, allowing entity inspection or modification.
903    pub fn add_before_hook(
904        &mut self,
905        phase: Phase,
906        hook: impl Fn(&mut World) + Send + Sync + 'static,
907    ) {
908        self.hooks.add_before(phase, Box::new(hook));
909    }
910
911    /// Register a hook to run after a simulation phase.
912    ///
913    /// Hooks are called in registration order. The hook receives mutable
914    /// access to the world, allowing entity inspection or modification.
915    pub fn add_after_hook(
916        &mut self,
917        phase: Phase,
918        hook: impl Fn(&mut World) + Send + Sync + 'static,
919    ) {
920        self.hooks.add_after(phase, Box::new(hook));
921    }
922
923    /// Register a hook to run before a phase for a specific group.
924    pub fn add_before_group_hook(
925        &mut self,
926        phase: Phase,
927        group: GroupId,
928        hook: impl Fn(&mut World) + Send + Sync + 'static,
929    ) {
930        self.hooks.add_before_group(phase, group, Box::new(hook));
931    }
932
933    /// Register a hook to run after a phase for a specific group.
934    pub fn add_after_group_hook(
935        &mut self,
936        phase: Phase,
937        group: GroupId,
938        hook: impl Fn(&mut World) + Send + Sync + 'static,
939    ) {
940        self.hooks.add_after_group(phase, group, Box::new(hook));
941    }
942}