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