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