Skip to main content

elevator_core/
sim.rs

1//! Top-level simulation runner and tick loop.
2
3use crate::components::{
4    AccessControl, Elevator, ElevatorPhase, FloorPosition, Line, Orientation, Patience, Position,
5    Preferences, Rider, RiderPhase, Route, Stop, Velocity,
6};
7use crate::config::SimConfig;
8use crate::dispatch::{
9    BuiltinReposition, BuiltinStrategy, DispatchStrategy, ElevatorGroup, LineInfo,
10    RepositionStrategy,
11};
12use crate::door::DoorState;
13use crate::entity::EntityId;
14use crate::error::SimError;
15use crate::events::{Event, EventBus};
16use crate::hooks::{Phase, PhaseHooks};
17use crate::ids::GroupId;
18use crate::metrics::Metrics;
19use crate::rider_index::RiderIndex;
20use crate::stop::StopId;
21use crate::systems::PhaseContext;
22use crate::time::TimeAdapter;
23use crate::topology::TopologyGraph;
24use crate::world::World;
25use std::collections::{BTreeMap, HashMap, HashSet};
26use std::fmt;
27use std::sync::Mutex;
28
29/// Parameters for creating a new elevator at runtime.
30#[derive(Debug, Clone)]
31pub struct ElevatorParams {
32    /// Maximum travel speed (distance/tick).
33    pub max_speed: f64,
34    /// Acceleration rate (distance/tick^2).
35    pub acceleration: f64,
36    /// Deceleration rate (distance/tick^2).
37    pub deceleration: f64,
38    /// Maximum weight the car can carry.
39    pub weight_capacity: f64,
40    /// Ticks for a door open/close transition.
41    pub door_transition_ticks: u32,
42    /// Ticks the door stays fully open.
43    pub door_open_ticks: u32,
44    /// Stop entity IDs this elevator cannot serve (access restriction).
45    pub restricted_stops: HashSet<EntityId>,
46    /// Speed multiplier for Inspection mode (0.0..1.0).
47    pub inspection_speed_factor: f64,
48}
49
50impl Default for ElevatorParams {
51    fn default() -> Self {
52        Self {
53            max_speed: 2.0,
54            acceleration: 1.5,
55            deceleration: 2.0,
56            weight_capacity: 800.0,
57            door_transition_ticks: 5,
58            door_open_ticks: 10,
59            restricted_stops: HashSet::new(),
60            inspection_speed_factor: 0.25,
61        }
62    }
63}
64
65/// Parameters for creating a new line at runtime.
66#[derive(Debug, Clone)]
67pub struct LineParams {
68    /// Human-readable name.
69    pub name: String,
70    /// Dispatch group to add this line to.
71    pub group: GroupId,
72    /// Physical orientation.
73    pub orientation: Orientation,
74    /// Lowest reachable position on the line axis.
75    pub min_position: f64,
76    /// Highest reachable position on the line axis.
77    pub max_position: f64,
78    /// Optional floor-plan position.
79    pub position: Option<FloorPosition>,
80    /// Maximum cars on this line (None = unlimited).
81    pub max_cars: Option<usize>,
82}
83
84impl LineParams {
85    /// Create line parameters with the given name and group, defaulting
86    /// everything else.
87    pub fn new(name: impl Into<String>, group: GroupId) -> Self {
88        Self {
89            name: name.into(),
90            group,
91            orientation: Orientation::default(),
92            min_position: 0.0,
93            max_position: 0.0,
94            position: None,
95            max_cars: None,
96        }
97    }
98}
99
100/// Fluent builder for spawning riders with optional configuration.
101///
102/// Created via [`Simulation::build_rider`] or [`Simulation::build_rider_by_stop_id`].
103///
104/// ```
105/// use elevator_core::prelude::*;
106///
107/// let mut sim = SimulationBuilder::new().build().unwrap();
108/// let rider = sim.build_rider_by_stop_id(StopId(0), StopId(1))
109///     .unwrap()
110///     .weight(80.0)
111///     .spawn()
112///     .unwrap();
113/// ```
114pub struct RiderBuilder<'a> {
115    /// Mutable reference to the simulation (consumed on spawn).
116    sim: &'a mut Simulation,
117    /// Origin stop entity.
118    origin: EntityId,
119    /// Destination stop entity.
120    destination: EntityId,
121    /// Rider weight (default: 75.0).
122    weight: f64,
123    /// Explicit dispatch group (skips auto-detection).
124    group: Option<GroupId>,
125    /// Explicit multi-leg route.
126    route: Option<Route>,
127    /// Maximum wait ticks before abandoning.
128    patience: Option<u64>,
129    /// Boarding preferences.
130    preferences: Option<Preferences>,
131    /// Per-rider access control.
132    access_control: Option<AccessControl>,
133}
134
135impl RiderBuilder<'_> {
136    /// Set the rider's weight (default: 75.0).
137    #[must_use]
138    pub const fn weight(mut self, weight: f64) -> Self {
139        self.weight = weight;
140        self
141    }
142
143    /// Set the dispatch group explicitly, skipping auto-detection.
144    #[must_use]
145    pub const fn group(mut self, group: GroupId) -> Self {
146        self.group = Some(group);
147        self
148    }
149
150    /// Provide an explicit multi-leg route.
151    #[must_use]
152    pub fn route(mut self, route: Route) -> Self {
153        self.route = Some(route);
154        self
155    }
156
157    /// Set maximum wait ticks before the rider abandons.
158    #[must_use]
159    pub const fn patience(mut self, max_wait_ticks: u64) -> Self {
160        self.patience = Some(max_wait_ticks);
161        self
162    }
163
164    /// Set boarding preferences.
165    #[must_use]
166    pub const fn preferences(mut self, prefs: Preferences) -> Self {
167        self.preferences = Some(prefs);
168        self
169    }
170
171    /// Set per-rider access control (allowed stops).
172    #[must_use]
173    pub fn access_control(mut self, ac: AccessControl) -> Self {
174        self.access_control = Some(ac);
175        self
176    }
177
178    /// Spawn the rider with the configured options.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`SimError::NoRoute`] if no group serves both stops (when auto-detecting).
183    /// Returns [`SimError::AmbiguousRoute`] if multiple groups serve both stops (when auto-detecting).
184    /// Returns [`SimError::GroupNotFound`] if an explicit group does not exist.
185    pub fn spawn(self) -> Result<EntityId, SimError> {
186        let route = if let Some(route) = self.route {
187            route
188        } else if let Some(group) = self.group {
189            if !self.sim.groups.iter().any(|g| g.id() == group) {
190                return Err(SimError::GroupNotFound(group));
191            }
192            Route::direct(self.origin, self.destination, group)
193        } else {
194            // Auto-detect group (same logic as spawn_rider).
195            let matching: Vec<GroupId> = self
196                .sim
197                .groups
198                .iter()
199                .filter(|g| {
200                    g.stop_entities().contains(&self.origin)
201                        && g.stop_entities().contains(&self.destination)
202                })
203                .map(ElevatorGroup::id)
204                .collect();
205
206            match matching.len() {
207                0 => {
208                    let origin_groups: Vec<GroupId> = self
209                        .sim
210                        .groups
211                        .iter()
212                        .filter(|g| g.stop_entities().contains(&self.origin))
213                        .map(ElevatorGroup::id)
214                        .collect();
215                    let destination_groups: Vec<GroupId> = self
216                        .sim
217                        .groups
218                        .iter()
219                        .filter(|g| g.stop_entities().contains(&self.destination))
220                        .map(ElevatorGroup::id)
221                        .collect();
222                    return Err(SimError::NoRoute {
223                        origin: self.origin,
224                        destination: self.destination,
225                        origin_groups,
226                        destination_groups,
227                    });
228                }
229                1 => Route::direct(self.origin, self.destination, matching[0]),
230                _ => {
231                    return Err(SimError::AmbiguousRoute {
232                        origin: self.origin,
233                        destination: self.destination,
234                        groups: matching,
235                    });
236                }
237            }
238        };
239
240        let eid = self
241            .sim
242            .spawn_rider_inner(self.origin, self.destination, self.weight, route);
243
244        // Apply optional components.
245        if let Some(max_wait) = self.patience {
246            self.sim.world.set_patience(
247                eid,
248                Patience {
249                    max_wait_ticks: max_wait,
250                    waited_ticks: 0,
251                },
252            );
253        }
254        if let Some(prefs) = self.preferences {
255            self.sim.world.set_preferences(eid, prefs);
256        }
257        if let Some(ac) = self.access_control {
258            self.sim.world.set_access_control(eid, ac);
259        }
260
261        Ok(eid)
262    }
263}
264
265/// Bundled topology result: groups, dispatchers, and strategy IDs.
266type TopologyResult = (
267    Vec<ElevatorGroup>,
268    BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
269    BTreeMap<GroupId, BuiltinStrategy>,
270);
271
272/// The core simulation state, advanced by calling `step()`.
273pub struct Simulation {
274    /// The ECS world containing all entity data.
275    world: World,
276    /// Internal event bus — only holds events from the current tick.
277    events: EventBus,
278    /// Events from completed ticks, available to consumers via `drain_events()`.
279    pending_output: Vec<Event>,
280    /// Current simulation tick.
281    tick: u64,
282    /// Time delta per tick (seconds).
283    dt: f64,
284    /// Elevator groups in this simulation.
285    groups: Vec<ElevatorGroup>,
286    /// Config `StopId` to `EntityId` mapping for spawn helpers.
287    stop_lookup: HashMap<StopId, EntityId>,
288    /// Dispatch strategies keyed by group.
289    dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
290    /// Serializable strategy identifiers (for snapshot).
291    strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
292    /// Reposition strategies keyed by group (optional per group).
293    repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>>,
294    /// Serializable reposition strategy identifiers (for snapshot).
295    reposition_ids: BTreeMap<GroupId, BuiltinReposition>,
296    /// Aggregated metrics.
297    metrics: Metrics,
298    /// Time conversion utility.
299    time: TimeAdapter,
300    /// Lifecycle hooks (before/after each phase).
301    hooks: PhaseHooks,
302    /// Reusable buffer for elevator IDs (avoids per-tick allocation).
303    elevator_ids_buf: Vec<EntityId>,
304    /// Lazy-rebuilt connectivity graph for cross-line topology queries.
305    topo_graph: Mutex<TopologyGraph>,
306    /// Phase-partitioned reverse index for O(1) population queries.
307    rider_index: RiderIndex,
308}
309
310impl Simulation {
311    /// Create a new simulation from config and a dispatch strategy.
312    ///
313    /// Returns `Err` if the config is invalid (zero stops, duplicate IDs,
314    /// negative speeds, etc.).
315    ///
316    /// # Errors
317    ///
318    /// Returns [`SimError::InvalidConfig`] if the configuration has zero stops,
319    /// duplicate stop IDs, zero elevators, non-positive physics parameters,
320    /// invalid starting stops, or non-positive tick rate.
321    pub fn new(
322        config: &SimConfig,
323        dispatch: impl DispatchStrategy + 'static,
324    ) -> Result<Self, SimError> {
325        let mut dispatchers = BTreeMap::new();
326        dispatchers.insert(GroupId(0), Box::new(dispatch) as Box<dyn DispatchStrategy>);
327        Self::new_with_hooks(config, dispatchers, PhaseHooks::default())
328    }
329
330    /// Create a simulation with pre-configured lifecycle hooks.
331    ///
332    /// Used by [`SimulationBuilder`](crate::builder::SimulationBuilder).
333    #[allow(clippy::too_many_lines)]
334    pub(crate) fn new_with_hooks(
335        config: &SimConfig,
336        builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
337        hooks: PhaseHooks,
338    ) -> Result<Self, SimError> {
339        Self::validate_config(config)?;
340
341        let mut world = World::new();
342
343        // Create stop entities.
344        let mut stop_lookup: HashMap<StopId, EntityId> = HashMap::new();
345        for sc in &config.building.stops {
346            let eid = world.spawn();
347            world.set_stop(
348                eid,
349                Stop {
350                    name: sc.name.clone(),
351                    position: sc.position,
352                },
353            );
354            world.set_position(eid, Position { value: sc.position });
355            stop_lookup.insert(sc.id, eid);
356        }
357
358        // Build sorted-stops index for O(log n) PassingFloor detection.
359        let mut sorted: Vec<(f64, EntityId)> = world
360            .iter_stops()
361            .map(|(eid, stop)| (stop.position, eid))
362            .collect();
363        sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
364        world.insert_resource(crate::world::SortedStops(sorted));
365
366        let (groups, dispatchers, strategy_ids) = if let Some(line_configs) = &config.building.lines
367        {
368            Self::build_explicit_topology(
369                &mut world,
370                config,
371                line_configs,
372                &stop_lookup,
373                builder_dispatchers,
374            )
375        } else {
376            Self::build_legacy_topology(&mut world, config, &stop_lookup, builder_dispatchers)
377        };
378
379        let dt = 1.0 / config.simulation.ticks_per_second;
380
381        world.insert_resource(crate::tagged_metrics::MetricTags::default());
382
383        // Collect line tag info (entity + name + elevator entities) before
384        // borrowing world mutably for MetricTags.
385        let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
386            .iter()
387            .flat_map(|group| {
388                group.lines().iter().filter_map(|li| {
389                    let line_comp = world.line(li.entity())?;
390                    Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
391                })
392            })
393            .collect();
394
395        // Tag line entities and their elevators with "line:{name}".
396        if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
397            for (line_eid, name, elevators) in &line_tag_info {
398                let tag = format!("line:{name}");
399                tags.tag(*line_eid, tag.clone());
400                for elev_eid in elevators {
401                    tags.tag(*elev_eid, tag.clone());
402                }
403            }
404        }
405
406        // Wire reposition strategies from group configs.
407        let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
408        let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
409        if let Some(group_configs) = &config.building.groups {
410            for gc in group_configs {
411                if let Some(ref repo_id) = gc.reposition {
412                    if let Some(strategy) = repo_id.instantiate() {
413                        let gid = GroupId(gc.id);
414                        repositioners.insert(gid, strategy);
415                        reposition_ids.insert(gid, repo_id.clone());
416                    }
417                }
418            }
419        }
420
421        Ok(Self {
422            world,
423            events: EventBus::default(),
424            pending_output: Vec::new(),
425            tick: 0,
426            dt,
427            groups,
428            stop_lookup,
429            dispatchers,
430            strategy_ids,
431            repositioners,
432            reposition_ids,
433            metrics: Metrics::new(),
434            time: TimeAdapter::new(config.simulation.ticks_per_second),
435            hooks,
436            elevator_ids_buf: Vec::new(),
437            topo_graph: Mutex::new(TopologyGraph::new()),
438            rider_index: RiderIndex::default(),
439        })
440    }
441
442    /// Build topology from the legacy flat elevator list (single default line + group).
443    fn build_legacy_topology(
444        world: &mut World,
445        config: &SimConfig,
446        stop_lookup: &HashMap<StopId, EntityId>,
447        builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
448    ) -> TopologyResult {
449        let all_stop_entities: Vec<EntityId> = stop_lookup.values().copied().collect();
450        let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
451        let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
452        let max_pos = stop_positions
453            .iter()
454            .copied()
455            .fold(f64::NEG_INFINITY, f64::max);
456
457        let default_line_eid = world.spawn();
458        world.set_line(
459            default_line_eid,
460            Line {
461                name: "Default".into(),
462                group: GroupId(0),
463                orientation: Orientation::Vertical,
464                position: None,
465                min_position: min_pos,
466                max_position: max_pos,
467                max_cars: None,
468            },
469        );
470
471        let mut elevator_entities = Vec::new();
472        for ec in &config.elevators {
473            let eid = world.spawn();
474            let start_pos = config
475                .building
476                .stops
477                .iter()
478                .find(|s| s.id == ec.starting_stop)
479                .map_or(0.0, |s| s.position);
480            world.set_position(eid, Position { value: start_pos });
481            world.set_velocity(eid, Velocity { value: 0.0 });
482            let restricted: HashSet<EntityId> = ec
483                .restricted_stops
484                .iter()
485                .filter_map(|sid| stop_lookup.get(sid).copied())
486                .collect();
487            world.set_elevator(
488                eid,
489                Elevator {
490                    phase: ElevatorPhase::Idle,
491                    door: DoorState::Closed,
492                    max_speed: ec.max_speed,
493                    acceleration: ec.acceleration,
494                    deceleration: ec.deceleration,
495                    weight_capacity: ec.weight_capacity,
496                    current_load: 0.0,
497                    riders: Vec::new(),
498                    target_stop: None,
499                    door_transition_ticks: ec.door_transition_ticks,
500                    door_open_ticks: ec.door_open_ticks,
501                    line: default_line_eid,
502                    repositioning: false,
503                    restricted_stops: restricted,
504                    inspection_speed_factor: ec.inspection_speed_factor,
505                    going_up: true,
506                    going_down: true,
507                    move_count: 0,
508                },
509            );
510            #[cfg(feature = "energy")]
511            if let Some(ref profile) = ec.energy_profile {
512                world.set_energy_profile(eid, profile.clone());
513                world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
514            }
515            if let Some(mode) = ec.service_mode {
516                world.set_service_mode(eid, mode);
517            }
518            elevator_entities.push(eid);
519        }
520
521        let default_line_info =
522            LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
523
524        let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
525
526        // Use builder-provided dispatcher or default Scan.
527        let mut dispatchers = BTreeMap::new();
528        let dispatch = builder_dispatchers.into_iter().next().map_or_else(
529            || Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
530            |(_, d)| d,
531        );
532        dispatchers.insert(GroupId(0), dispatch);
533
534        let mut strategy_ids = BTreeMap::new();
535        strategy_ids.insert(GroupId(0), BuiltinStrategy::Scan);
536
537        (vec![group], dispatchers, strategy_ids)
538    }
539
540    /// Build topology from explicit `LineConfig`/`GroupConfig` definitions.
541    #[allow(clippy::too_many_lines)]
542    fn build_explicit_topology(
543        world: &mut World,
544        config: &SimConfig,
545        line_configs: &[crate::config::LineConfig],
546        stop_lookup: &HashMap<StopId, EntityId>,
547        builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
548    ) -> TopologyResult {
549        // Map line config id → (line EntityId, LineInfo).
550        let mut line_map: HashMap<u32, (EntityId, LineInfo)> = HashMap::new();
551
552        for lc in line_configs {
553            // Resolve served stop entities.
554            let served_entities: Vec<EntityId> = lc
555                .serves
556                .iter()
557                .filter_map(|sid| stop_lookup.get(sid).copied())
558                .collect();
559
560            // Compute min/max from stops if not explicitly set.
561            let stop_positions: Vec<f64> = lc
562                .serves
563                .iter()
564                .filter_map(|sid| {
565                    config
566                        .building
567                        .stops
568                        .iter()
569                        .find(|s| s.id == *sid)
570                        .map(|s| s.position)
571                })
572                .collect();
573            let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
574            let auto_max = stop_positions
575                .iter()
576                .copied()
577                .fold(f64::NEG_INFINITY, f64::max);
578
579            let min_pos = lc.min_position.unwrap_or(auto_min);
580            let max_pos = lc.max_position.unwrap_or(auto_max);
581
582            let line_eid = world.spawn();
583            // The group assignment will be set when we process GroupConfigs.
584            // Default to GroupId(0) initially.
585            world.set_line(
586                line_eid,
587                Line {
588                    name: lc.name.clone(),
589                    group: GroupId(0),
590                    orientation: lc.orientation,
591                    position: lc.position,
592                    min_position: min_pos,
593                    max_position: max_pos,
594                    max_cars: lc.max_cars,
595                },
596            );
597
598            // Spawn elevators for this line.
599            let mut elevator_entities = Vec::new();
600            for ec in &lc.elevators {
601                let eid = world.spawn();
602                let start_pos = config
603                    .building
604                    .stops
605                    .iter()
606                    .find(|s| s.id == ec.starting_stop)
607                    .map_or(0.0, |s| s.position);
608                world.set_position(eid, Position { value: start_pos });
609                world.set_velocity(eid, Velocity { value: 0.0 });
610                let restricted: HashSet<EntityId> = ec
611                    .restricted_stops
612                    .iter()
613                    .filter_map(|sid| stop_lookup.get(sid).copied())
614                    .collect();
615                world.set_elevator(
616                    eid,
617                    Elevator {
618                        phase: ElevatorPhase::Idle,
619                        door: DoorState::Closed,
620                        max_speed: ec.max_speed,
621                        acceleration: ec.acceleration,
622                        deceleration: ec.deceleration,
623                        weight_capacity: ec.weight_capacity,
624                        current_load: 0.0,
625                        riders: Vec::new(),
626                        target_stop: None,
627                        door_transition_ticks: ec.door_transition_ticks,
628                        door_open_ticks: ec.door_open_ticks,
629                        line: line_eid,
630                        repositioning: false,
631                        restricted_stops: restricted,
632                        inspection_speed_factor: ec.inspection_speed_factor,
633                        going_up: true,
634                        going_down: true,
635                        move_count: 0,
636                    },
637                );
638                #[cfg(feature = "energy")]
639                if let Some(ref profile) = ec.energy_profile {
640                    world.set_energy_profile(eid, profile.clone());
641                    world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
642                }
643                if let Some(mode) = ec.service_mode {
644                    world.set_service_mode(eid, mode);
645                }
646                elevator_entities.push(eid);
647            }
648
649            let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
650            line_map.insert(lc.id, (line_eid, line_info));
651        }
652
653        // Build groups from GroupConfigs, or auto-infer a single group.
654        let group_configs = config.building.groups.as_deref();
655        let mut groups = Vec::new();
656        let mut dispatchers = BTreeMap::new();
657        let mut strategy_ids = BTreeMap::new();
658
659        if let Some(gcs) = group_configs {
660            for gc in gcs {
661                let group_id = GroupId(gc.id);
662
663                let mut group_lines = Vec::new();
664
665                for &lid in &gc.lines {
666                    if let Some((line_eid, li)) = line_map.get(&lid) {
667                        // Update the line's group assignment.
668                        if let Some(line_comp) = world.line_mut(*line_eid) {
669                            line_comp.group = group_id;
670                        }
671                        group_lines.push(li.clone());
672                    }
673                }
674
675                let group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
676                groups.push(group);
677
678                // GroupConfig strategy; builder overrides applied after this loop.
679                let dispatch: Box<dyn DispatchStrategy> = gc
680                    .dispatch
681                    .instantiate()
682                    .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
683                dispatchers.insert(group_id, dispatch);
684                strategy_ids.insert(group_id, gc.dispatch.clone());
685            }
686        } else {
687            // No explicit groups — create a single default group with all lines.
688            let group_id = GroupId(0);
689            let mut group_lines = Vec::new();
690
691            for (line_eid, li) in line_map.values() {
692                if let Some(line_comp) = world.line_mut(*line_eid) {
693                    line_comp.group = group_id;
694                }
695                group_lines.push(li.clone());
696            }
697
698            let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
699            groups.push(group);
700
701            let dispatch: Box<dyn DispatchStrategy> =
702                Box::new(crate::dispatch::scan::ScanDispatch::new());
703            dispatchers.insert(group_id, dispatch);
704            strategy_ids.insert(group_id, BuiltinStrategy::Scan);
705        }
706
707        // Override with builder-provided dispatchers (they take precedence).
708        for (gid, d) in builder_dispatchers {
709            dispatchers.insert(gid, d);
710        }
711
712        (groups, dispatchers, strategy_ids)
713    }
714
715    /// Restore a simulation from pre-built parts (used by snapshot restore).
716    #[allow(clippy::too_many_arguments)]
717    pub(crate) fn from_parts(
718        world: World,
719        tick: u64,
720        dt: f64,
721        groups: Vec<ElevatorGroup>,
722        stop_lookup: HashMap<StopId, EntityId>,
723        dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
724        strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
725        metrics: Metrics,
726        ticks_per_second: f64,
727    ) -> Self {
728        let mut rider_index = RiderIndex::default();
729        rider_index.rebuild(&world);
730        Self {
731            world,
732            events: EventBus::default(),
733            pending_output: Vec::new(),
734            tick,
735            dt,
736            groups,
737            stop_lookup,
738            dispatchers,
739            strategy_ids,
740            repositioners: BTreeMap::new(),
741            reposition_ids: BTreeMap::new(),
742            metrics,
743            time: TimeAdapter::new(ticks_per_second),
744            hooks: PhaseHooks::default(),
745            elevator_ids_buf: Vec::new(),
746            topo_graph: Mutex::new(TopologyGraph::new()),
747            rider_index,
748        }
749    }
750
751    /// Validate configuration before constructing the simulation.
752    pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
753        if config.building.stops.is_empty() {
754            return Err(SimError::InvalidConfig {
755                field: "building.stops",
756                reason: "at least one stop is required".into(),
757            });
758        }
759
760        // Check for duplicate stop IDs.
761        let mut seen_ids = HashSet::new();
762        for stop in &config.building.stops {
763            if !seen_ids.insert(stop.id) {
764                return Err(SimError::InvalidConfig {
765                    field: "building.stops",
766                    reason: format!("duplicate {}", stop.id),
767                });
768            }
769        }
770
771        let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
772
773        if let Some(line_configs) = &config.building.lines {
774            // ── Explicit topology validation ──
775            Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
776        } else {
777            // ── Legacy flat elevator list validation ──
778            Self::validate_legacy_elevators(&config.elevators, &config.building)?;
779        }
780
781        if config.simulation.ticks_per_second <= 0.0 {
782            return Err(SimError::InvalidConfig {
783                field: "simulation.ticks_per_second",
784                reason: format!(
785                    "must be positive, got {}",
786                    config.simulation.ticks_per_second
787                ),
788            });
789        }
790
791        Ok(())
792    }
793
794    /// Validate the legacy flat elevator list.
795    fn validate_legacy_elevators(
796        elevators: &[crate::config::ElevatorConfig],
797        building: &crate::config::BuildingConfig,
798    ) -> Result<(), SimError> {
799        if elevators.is_empty() {
800            return Err(SimError::InvalidConfig {
801                field: "elevators",
802                reason: "at least one elevator is required".into(),
803            });
804        }
805
806        for elev in elevators {
807            Self::validate_elevator_config(elev, building)?;
808        }
809
810        Ok(())
811    }
812
813    /// Validate a single elevator config's physics and starting stop.
814    fn validate_elevator_config(
815        elev: &crate::config::ElevatorConfig,
816        building: &crate::config::BuildingConfig,
817    ) -> Result<(), SimError> {
818        if elev.max_speed <= 0.0 {
819            return Err(SimError::InvalidConfig {
820                field: "elevators.max_speed",
821                reason: format!("must be positive, got {}", elev.max_speed),
822            });
823        }
824        if elev.acceleration <= 0.0 {
825            return Err(SimError::InvalidConfig {
826                field: "elevators.acceleration",
827                reason: format!("must be positive, got {}", elev.acceleration),
828            });
829        }
830        if elev.deceleration <= 0.0 {
831            return Err(SimError::InvalidConfig {
832                field: "elevators.deceleration",
833                reason: format!("must be positive, got {}", elev.deceleration),
834            });
835        }
836        if elev.weight_capacity <= 0.0 {
837            return Err(SimError::InvalidConfig {
838                field: "elevators.weight_capacity",
839                reason: format!("must be positive, got {}", elev.weight_capacity),
840            });
841        }
842        if elev.inspection_speed_factor <= 0.0 {
843            return Err(SimError::InvalidConfig {
844                field: "elevators.inspection_speed_factor",
845                reason: format!("must be positive, got {}", elev.inspection_speed_factor),
846            });
847        }
848        if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
849            return Err(SimError::InvalidConfig {
850                field: "elevators.starting_stop",
851                reason: format!("references non-existent {}", elev.starting_stop),
852            });
853        }
854        Ok(())
855    }
856
857    /// Validate explicit line/group topology.
858    fn validate_explicit_topology(
859        line_configs: &[crate::config::LineConfig],
860        stop_ids: &HashSet<StopId>,
861        building: &crate::config::BuildingConfig,
862    ) -> Result<(), SimError> {
863        // No duplicate line IDs.
864        let mut seen_line_ids = HashSet::new();
865        for lc in line_configs {
866            if !seen_line_ids.insert(lc.id) {
867                return Err(SimError::InvalidConfig {
868                    field: "building.lines",
869                    reason: format!("duplicate line id {}", lc.id),
870                });
871            }
872        }
873
874        // Every line's serves must reference existing stops.
875        for lc in line_configs {
876            for sid in &lc.serves {
877                if !stop_ids.contains(sid) {
878                    return Err(SimError::InvalidConfig {
879                        field: "building.lines.serves",
880                        reason: format!("line {} references non-existent {}", lc.id, sid),
881                    });
882                }
883            }
884            // Validate elevators within each line.
885            for ec in &lc.elevators {
886                Self::validate_elevator_config(ec, building)?;
887            }
888
889            // Validate max_cars is not exceeded.
890            if let Some(max) = lc.max_cars {
891                if lc.elevators.len() > max {
892                    return Err(SimError::InvalidConfig {
893                        field: "building.lines.max_cars",
894                        reason: format!(
895                            "line {} has {} elevators but max_cars is {max}",
896                            lc.id,
897                            lc.elevators.len()
898                        ),
899                    });
900                }
901            }
902        }
903
904        // At least one line with at least one elevator.
905        let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
906        if !has_elevator {
907            return Err(SimError::InvalidConfig {
908                field: "building.lines",
909                reason: "at least one line must have at least one elevator".into(),
910            });
911        }
912
913        // No orphaned stops: every stop must be served by at least one line.
914        let served: HashSet<StopId> = line_configs
915            .iter()
916            .flat_map(|lc| lc.serves.iter().copied())
917            .collect();
918        for sid in stop_ids {
919            if !served.contains(sid) {
920                return Err(SimError::InvalidConfig {
921                    field: "building.lines",
922                    reason: format!("orphaned stop {sid} not served by any line"),
923                });
924            }
925        }
926
927        // Validate groups if present.
928        if let Some(group_configs) = &building.groups {
929            let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
930
931            let mut seen_group_ids = HashSet::new();
932            for gc in group_configs {
933                if !seen_group_ids.insert(gc.id) {
934                    return Err(SimError::InvalidConfig {
935                        field: "building.groups",
936                        reason: format!("duplicate group id {}", gc.id),
937                    });
938                }
939                for &lid in &gc.lines {
940                    if !line_id_set.contains(&lid) {
941                        return Err(SimError::InvalidConfig {
942                            field: "building.groups.lines",
943                            reason: format!(
944                                "group {} references non-existent line id {}",
945                                gc.id, lid
946                            ),
947                        });
948                    }
949                }
950            }
951
952            // Check for orphaned lines (not referenced by any group).
953            let referenced_line_ids: HashSet<u32> = group_configs
954                .iter()
955                .flat_map(|g| g.lines.iter().copied())
956                .collect();
957            for lc in line_configs {
958                if !referenced_line_ids.contains(&lc.id) {
959                    return Err(SimError::InvalidConfig {
960                        field: "building.lines",
961                        reason: format!("line {} is not assigned to any group", lc.id),
962                    });
963                }
964            }
965        }
966
967        Ok(())
968    }
969
970    // ── Accessors ────────────────────────────────────────────────────
971
972    /// Get a shared reference to the world.
973    #[must_use]
974    pub const fn world(&self) -> &World {
975        &self.world
976    }
977
978    /// Get a mutable reference to the world.
979    ///
980    /// Exposed for advanced use cases (manual rider management, custom
981    /// component attachment). Prefer `spawn_rider` / `spawn_rider_by_stop_id`
982    /// for standard operations.
983    pub const fn world_mut(&mut self) -> &mut World {
984        &mut self.world
985    }
986
987    /// Current simulation tick.
988    #[must_use]
989    pub const fn current_tick(&self) -> u64 {
990        self.tick
991    }
992
993    /// Time delta per tick (seconds).
994    #[must_use]
995    pub const fn dt(&self) -> f64 {
996        self.dt
997    }
998
999    /// Get current simulation metrics.
1000    #[must_use]
1001    pub const fn metrics(&self) -> &Metrics {
1002        &self.metrics
1003    }
1004
1005    /// The time adapter for tick↔wall-clock conversion.
1006    #[must_use]
1007    pub const fn time(&self) -> &TimeAdapter {
1008        &self.time
1009    }
1010
1011    /// Get the elevator groups.
1012    #[must_use]
1013    pub fn groups(&self) -> &[ElevatorGroup] {
1014        &self.groups
1015    }
1016
1017    /// Resolve a config `StopId` to its runtime `EntityId`.
1018    #[must_use]
1019    pub fn stop_entity(&self, id: StopId) -> Option<EntityId> {
1020        self.stop_lookup.get(&id).copied()
1021    }
1022
1023    /// Get the strategy identifier for a group.
1024    #[must_use]
1025    pub fn strategy_id(&self, group: GroupId) -> Option<&crate::dispatch::BuiltinStrategy> {
1026        self.strategy_ids.get(&group)
1027    }
1028
1029    /// Iterate over the stop ID → entity ID mapping.
1030    pub fn stop_lookup_iter(&self) -> impl Iterator<Item = (&StopId, &EntityId)> {
1031        self.stop_lookup.iter()
1032    }
1033
1034    /// Peek at events pending for consumer retrieval.
1035    #[must_use]
1036    pub fn pending_events(&self) -> &[Event] {
1037        &self.pending_output
1038    }
1039
1040    // ── Dispatch management ──────────────────────────────────────────
1041
1042    /// Replace the dispatch strategy for a group.
1043    ///
1044    /// The `id` parameter identifies the strategy for snapshot serialization.
1045    /// Use `BuiltinStrategy::Custom("name")` for custom strategies.
1046    pub fn set_dispatch(
1047        &mut self,
1048        group: GroupId,
1049        strategy: Box<dyn DispatchStrategy>,
1050        id: crate::dispatch::BuiltinStrategy,
1051    ) {
1052        self.dispatchers.insert(group, strategy);
1053        self.strategy_ids.insert(group, id);
1054    }
1055
1056    // ── Reposition management ─────────────────────────────────────────
1057
1058    /// Set the reposition strategy for a group.
1059    ///
1060    /// Enables the reposition phase for this group. Idle elevators will
1061    /// be repositioned according to the strategy after each dispatch phase.
1062    pub fn set_reposition(
1063        &mut self,
1064        group: GroupId,
1065        strategy: Box<dyn RepositionStrategy>,
1066        id: BuiltinReposition,
1067    ) {
1068        self.repositioners.insert(group, strategy);
1069        self.reposition_ids.insert(group, id);
1070    }
1071
1072    /// Remove the reposition strategy for a group, disabling repositioning.
1073    pub fn remove_reposition(&mut self, group: GroupId) {
1074        self.repositioners.remove(&group);
1075        self.reposition_ids.remove(&group);
1076    }
1077
1078    /// Get the reposition strategy identifier for a group.
1079    #[must_use]
1080    pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
1081        self.reposition_ids.get(&group)
1082    }
1083
1084    // ── Tagging ──────────────────────────────────────────────────────
1085
1086    /// Attach a metric tag to an entity (rider, stop, elevator, etc.).
1087    ///
1088    /// Tags enable per-tag metric breakdowns. An entity can have multiple tags.
1089    /// Riders automatically inherit tags from their origin stop when spawned.
1090    pub fn tag_entity(&mut self, id: EntityId, tag: impl Into<String>) {
1091        if let Some(tags) = self
1092            .world
1093            .resource_mut::<crate::tagged_metrics::MetricTags>()
1094        {
1095            tags.tag(id, tag);
1096        }
1097    }
1098
1099    /// Remove a metric tag from an entity.
1100    pub fn untag_entity(&mut self, id: EntityId, tag: &str) {
1101        if let Some(tags) = self
1102            .world
1103            .resource_mut::<crate::tagged_metrics::MetricTags>()
1104        {
1105            tags.untag(id, tag);
1106        }
1107    }
1108
1109    /// Query the metric accumulator for a specific tag.
1110    #[must_use]
1111    pub fn metrics_for_tag(&self, tag: &str) -> Option<&crate::tagged_metrics::TaggedMetric> {
1112        self.world
1113            .resource::<crate::tagged_metrics::MetricTags>()
1114            .and_then(|tags| tags.metric(tag))
1115    }
1116
1117    /// List all registered metric tags.
1118    pub fn all_tags(&self) -> Vec<&str> {
1119        self.world
1120            .resource::<crate::tagged_metrics::MetricTags>()
1121            .map_or_else(Vec::new, |tags| tags.all_tags().collect())
1122    }
1123
1124    // ── Rider spawning ───────────────────────────────────────────────
1125
1126    /// Create a rider builder for fluent rider spawning.
1127    ///
1128    /// ```
1129    /// use elevator_core::prelude::*;
1130    ///
1131    /// let mut sim = SimulationBuilder::new().build().unwrap();
1132    /// let s0 = sim.stop_entity(StopId(0)).unwrap();
1133    /// let s1 = sim.stop_entity(StopId(1)).unwrap();
1134    /// let rider = sim.build_rider(s0, s1)
1135    ///     .weight(80.0)
1136    ///     .spawn()
1137    ///     .unwrap();
1138    /// ```
1139    pub const fn build_rider(
1140        &mut self,
1141        origin: EntityId,
1142        destination: EntityId,
1143    ) -> RiderBuilder<'_> {
1144        RiderBuilder {
1145            sim: self,
1146            origin,
1147            destination,
1148            weight: 75.0,
1149            group: None,
1150            route: None,
1151            patience: None,
1152            preferences: None,
1153            access_control: None,
1154        }
1155    }
1156
1157    /// Create a rider builder using config `StopId`s.
1158    ///
1159    /// # Errors
1160    ///
1161    /// Returns [`SimError::StopNotFound`] if either stop ID is unknown.
1162    ///
1163    /// ```
1164    /// use elevator_core::prelude::*;
1165    ///
1166    /// let mut sim = SimulationBuilder::new().build().unwrap();
1167    /// let rider = sim.build_rider_by_stop_id(StopId(0), StopId(1))
1168    ///     .unwrap()
1169    ///     .weight(80.0)
1170    ///     .spawn()
1171    ///     .unwrap();
1172    /// ```
1173    pub fn build_rider_by_stop_id(
1174        &mut self,
1175        origin: StopId,
1176        destination: StopId,
1177    ) -> Result<RiderBuilder<'_>, SimError> {
1178        let origin_eid = self
1179            .stop_lookup
1180            .get(&origin)
1181            .copied()
1182            .ok_or(SimError::StopNotFound(origin))?;
1183        let dest_eid = self
1184            .stop_lookup
1185            .get(&destination)
1186            .copied()
1187            .ok_or(SimError::StopNotFound(destination))?;
1188        Ok(RiderBuilder {
1189            sim: self,
1190            origin: origin_eid,
1191            destination: dest_eid,
1192            weight: 75.0,
1193            group: None,
1194            route: None,
1195            patience: None,
1196            preferences: None,
1197            access_control: None,
1198        })
1199    }
1200
1201    /// Spawn a rider at the given origin stop entity, headed to destination stop entity.
1202    ///
1203    /// Auto-detects the elevator group by finding groups that serve both origin
1204    /// and destination stops.
1205    ///
1206    /// # Errors
1207    ///
1208    /// Returns [`SimError::NoRoute`] if no group serves both stops.
1209    /// Returns [`SimError::AmbiguousRoute`] if multiple groups serve both stops.
1210    pub fn spawn_rider(
1211        &mut self,
1212        origin: EntityId,
1213        destination: EntityId,
1214        weight: f64,
1215    ) -> Result<EntityId, SimError> {
1216        let matching: Vec<GroupId> = self
1217            .groups
1218            .iter()
1219            .filter(|g| {
1220                g.stop_entities().contains(&origin) && g.stop_entities().contains(&destination)
1221            })
1222            .map(ElevatorGroup::id)
1223            .collect();
1224
1225        let group = match matching.len() {
1226            0 => {
1227                let origin_groups: Vec<GroupId> = self
1228                    .groups
1229                    .iter()
1230                    .filter(|g| g.stop_entities().contains(&origin))
1231                    .map(ElevatorGroup::id)
1232                    .collect();
1233                let destination_groups: Vec<GroupId> = self
1234                    .groups
1235                    .iter()
1236                    .filter(|g| g.stop_entities().contains(&destination))
1237                    .map(ElevatorGroup::id)
1238                    .collect();
1239                return Err(SimError::NoRoute {
1240                    origin,
1241                    destination,
1242                    origin_groups,
1243                    destination_groups,
1244                });
1245            }
1246            1 => matching[0],
1247            _ => {
1248                return Err(SimError::AmbiguousRoute {
1249                    origin,
1250                    destination,
1251                    groups: matching,
1252                });
1253            }
1254        };
1255
1256        let route = Route::direct(origin, destination, group);
1257        Ok(self.spawn_rider_inner(origin, destination, weight, route))
1258    }
1259
1260    /// Spawn a rider with an explicit route.
1261    ///
1262    /// Same as [`spawn_rider`](Self::spawn_rider) but uses the provided route
1263    /// instead of auto-detecting the group.
1264    ///
1265    /// # Errors
1266    ///
1267    /// Returns [`SimError::EntityNotFound`] if origin does not exist.
1268    /// Returns [`SimError::InvalidState`] if origin doesn't match the route's
1269    /// first leg `from`.
1270    pub fn spawn_rider_with_route(
1271        &mut self,
1272        origin: EntityId,
1273        destination: EntityId,
1274        weight: f64,
1275        route: Route,
1276    ) -> Result<EntityId, SimError> {
1277        if self.world.stop(origin).is_none() {
1278            return Err(SimError::EntityNotFound(origin));
1279        }
1280        if let Some(leg) = route.current() {
1281            if leg.from != origin {
1282                return Err(SimError::InvalidState {
1283                    entity: origin,
1284                    reason: format!(
1285                        "origin {origin:?} does not match route first leg from {:?}",
1286                        leg.from
1287                    ),
1288                });
1289            }
1290        }
1291        Ok(self.spawn_rider_inner(origin, destination, weight, route))
1292    }
1293
1294    /// Internal helper: spawn a rider entity with the given route.
1295    fn spawn_rider_inner(
1296        &mut self,
1297        origin: EntityId,
1298        destination: EntityId,
1299        weight: f64,
1300        route: Route,
1301    ) -> EntityId {
1302        let eid = self.world.spawn();
1303        self.world.set_rider(
1304            eid,
1305            Rider {
1306                weight,
1307                phase: RiderPhase::Waiting,
1308                current_stop: Some(origin),
1309                spawn_tick: self.tick,
1310                board_tick: None,
1311            },
1312        );
1313        self.world.set_route(eid, route);
1314        self.rider_index.insert_waiting(origin, eid);
1315        self.events.emit(Event::RiderSpawned {
1316            rider: eid,
1317            origin,
1318            destination,
1319            tick: self.tick,
1320        });
1321
1322        // Auto-tag the rider with "stop:{name}" for per-stop wait time tracking.
1323        let stop_tag = self
1324            .world
1325            .stop(origin)
1326            .map(|s| format!("stop:{}", s.name()));
1327
1328        // Inherit metric tags from the origin stop.
1329        if let Some(tags_res) = self
1330            .world
1331            .resource_mut::<crate::tagged_metrics::MetricTags>()
1332        {
1333            let origin_tags: Vec<String> = tags_res.tags_for(origin).to_vec();
1334            for tag in origin_tags {
1335                tags_res.tag(eid, tag);
1336            }
1337            // Apply the origin stop tag.
1338            if let Some(tag) = stop_tag {
1339                tags_res.tag(eid, tag);
1340            }
1341        }
1342
1343        eid
1344    }
1345
1346    /// Convenience: spawn a rider by config `StopId`.
1347    ///
1348    /// Returns `Err` if either stop ID is not found.
1349    ///
1350    /// # Errors
1351    ///
1352    /// Returns [`SimError::StopNotFound`] if the origin or destination stop ID
1353    /// is not in the building configuration.
1354    ///
1355    /// ```
1356    /// use elevator_core::prelude::*;
1357    ///
1358    /// // Default builder has StopId(0) and StopId(1).
1359    /// let mut sim = SimulationBuilder::new().build().unwrap();
1360    ///
1361    /// let rider = sim.spawn_rider_by_stop_id(StopId(0), StopId(1), 80.0).unwrap();
1362    /// sim.step(); // metrics are updated during the tick
1363    /// assert_eq!(sim.metrics().total_spawned(), 1);
1364    /// ```
1365    pub fn spawn_rider_by_stop_id(
1366        &mut self,
1367        origin: StopId,
1368        destination: StopId,
1369        weight: f64,
1370    ) -> Result<EntityId, SimError> {
1371        let origin_eid = self
1372            .stop_lookup
1373            .get(&origin)
1374            .copied()
1375            .ok_or(SimError::StopNotFound(origin))?;
1376        let dest_eid = self
1377            .stop_lookup
1378            .get(&destination)
1379            .copied()
1380            .ok_or(SimError::StopNotFound(destination))?;
1381        self.spawn_rider(origin_eid, dest_eid, weight)
1382    }
1383
1384    /// Spawn a rider using a specific group for routing.
1385    ///
1386    /// Like [`spawn_rider`](Self::spawn_rider) but skips auto-detection —
1387    /// uses the given group directly. Useful when the caller already knows
1388    /// the group, or to resolve an [`AmbiguousRoute`](crate::error::SimError::AmbiguousRoute).
1389    ///
1390    /// # Errors
1391    ///
1392    /// Returns [`SimError::GroupNotFound`] if the group does not exist.
1393    pub fn spawn_rider_in_group(
1394        &mut self,
1395        origin: EntityId,
1396        destination: EntityId,
1397        weight: f64,
1398        group: GroupId,
1399    ) -> Result<EntityId, SimError> {
1400        if !self.groups.iter().any(|g| g.id() == group) {
1401            return Err(SimError::GroupNotFound(group));
1402        }
1403        let route = Route::direct(origin, destination, group);
1404        Ok(self.spawn_rider_inner(origin, destination, weight, route))
1405    }
1406
1407    /// Convenience: spawn a rider by config `StopId` in a specific group.
1408    ///
1409    /// # Errors
1410    ///
1411    /// Returns [`SimError::StopNotFound`] if a stop ID is unknown, or
1412    /// [`SimError::GroupNotFound`] if the group does not exist.
1413    pub fn spawn_rider_in_group_by_stop_id(
1414        &mut self,
1415        origin: StopId,
1416        destination: StopId,
1417        weight: f64,
1418        group: GroupId,
1419    ) -> Result<EntityId, SimError> {
1420        let origin_eid = self
1421            .stop_lookup
1422            .get(&origin)
1423            .copied()
1424            .ok_or(SimError::StopNotFound(origin))?;
1425        let dest_eid = self
1426            .stop_lookup
1427            .get(&destination)
1428            .copied()
1429            .ok_or(SimError::StopNotFound(destination))?;
1430        self.spawn_rider_in_group(origin_eid, dest_eid, weight, group)
1431    }
1432
1433    /// Drain all pending events from completed ticks.
1434    ///
1435    /// Events emitted during `step()` (or per-phase methods) are buffered
1436    /// and made available here after `advance_tick()` is called.
1437    /// Events emitted outside the tick loop (e.g., `spawn_rider`, `disable`)
1438    /// are also included.
1439    ///
1440    /// ```
1441    /// use elevator_core::prelude::*;
1442    ///
1443    /// let mut sim = SimulationBuilder::new().build().unwrap();
1444    ///
1445    /// sim.spawn_rider_by_stop_id(StopId(0), StopId(1), 70.0).unwrap();
1446    /// sim.step();
1447    ///
1448    /// let events = sim.drain_events();
1449    /// assert!(!events.is_empty());
1450    /// ```
1451    pub fn drain_events(&mut self) -> Vec<Event> {
1452        // Flush any events still in the bus (from spawn_rider, disable, etc.)
1453        self.pending_output.extend(self.events.drain());
1454        std::mem::take(&mut self.pending_output)
1455    }
1456
1457    /// Drain only events matching a predicate.
1458    ///
1459    /// Events that don't match the predicate remain in the buffer
1460    /// and will be returned by future `drain_events` or
1461    /// `drain_events_where` calls.
1462    ///
1463    /// ```
1464    /// use elevator_core::prelude::*;
1465    ///
1466    /// let mut sim = SimulationBuilder::new().build().unwrap();
1467    /// sim.spawn_rider_by_stop_id(StopId(0), StopId(1), 70.0).unwrap();
1468    /// sim.step();
1469    ///
1470    /// let spawns: Vec<Event> = sim.drain_events_where(|e| {
1471    ///     matches!(e, Event::RiderSpawned { .. })
1472    /// });
1473    /// ```
1474    pub fn drain_events_where(&mut self, predicate: impl Fn(&Event) -> bool) -> Vec<Event> {
1475        // Flush bus into pending_output first.
1476        self.pending_output.extend(self.events.drain());
1477
1478        let mut matched = Vec::new();
1479        let mut remaining = Vec::new();
1480        for event in std::mem::take(&mut self.pending_output) {
1481            if predicate(&event) {
1482                matched.push(event);
1483            } else {
1484                remaining.push(event);
1485            }
1486        }
1487        self.pending_output = remaining;
1488        matched
1489    }
1490
1491    // ── Dynamic topology ────────────────────────────────────────────
1492
1493    /// Find the (`group_index`, `line_index`) for a line entity.
1494    fn find_line(&self, line: EntityId) -> Result<(usize, usize), SimError> {
1495        self.groups
1496            .iter()
1497            .enumerate()
1498            .find_map(|(gi, g)| {
1499                g.lines()
1500                    .iter()
1501                    .position(|li| li.entity() == line)
1502                    .map(|li_idx| (gi, li_idx))
1503            })
1504            .ok_or(SimError::LineNotFound(line))
1505    }
1506
1507    /// Add a new stop to a group at runtime. Returns its `EntityId`.
1508    ///
1509    /// Runtime-added stops have no `StopId` — they are identified purely
1510    /// by `EntityId`. The `stop_lookup` (config `StopId` → `EntityId`)
1511    /// is not updated.
1512    ///
1513    /// # Errors
1514    ///
1515    /// Returns [`SimError::LineNotFound`] if the line entity does not exist.
1516    pub fn add_stop(
1517        &mut self,
1518        name: String,
1519        position: f64,
1520        line: EntityId,
1521    ) -> Result<EntityId, SimError> {
1522        let group_id = self
1523            .world
1524            .line(line)
1525            .map(|l| l.group)
1526            .ok_or(SimError::LineNotFound(line))?;
1527
1528        let (group_idx, line_idx) = self.find_line(line)?;
1529
1530        let eid = self.world.spawn();
1531        self.world.set_stop(eid, Stop { name, position });
1532        self.world.set_position(eid, Position { value: position });
1533
1534        // Add to the line's serves list.
1535        self.groups[group_idx].lines_mut()[line_idx]
1536            .serves_mut()
1537            .push(eid);
1538
1539        // Add to the group's flat cache.
1540        self.groups[group_idx].push_stop(eid);
1541
1542        // Maintain sorted-stops index for O(log n) PassingFloor detection.
1543        if let Some(sorted) = self.world.resource_mut::<crate::world::SortedStops>() {
1544            let idx = sorted.0.partition_point(|&(p, _)| p < position);
1545            sorted.0.insert(idx, (position, eid));
1546        }
1547
1548        if let Ok(mut g) = self.topo_graph.lock() {
1549            g.mark_dirty();
1550        }
1551        self.events.emit(Event::StopAdded {
1552            stop: eid,
1553            line,
1554            group: group_id,
1555            tick: self.tick,
1556        });
1557        Ok(eid)
1558    }
1559
1560    /// Add a new elevator to a line at runtime. Returns its `EntityId`.
1561    ///
1562    /// # Errors
1563    ///
1564    /// Returns [`SimError::LineNotFound`] if the line entity does not exist.
1565    pub fn add_elevator(
1566        &mut self,
1567        params: &ElevatorParams,
1568        line: EntityId,
1569        starting_position: f64,
1570    ) -> Result<EntityId, SimError> {
1571        let group_id = self
1572            .world
1573            .line(line)
1574            .map(|l| l.group)
1575            .ok_or(SimError::LineNotFound(line))?;
1576
1577        let (group_idx, line_idx) = self.find_line(line)?;
1578
1579        // Enforce max_cars limit.
1580        if let Some(max) = self.world.line(line).and_then(Line::max_cars) {
1581            let current_count = self.groups[group_idx].lines()[line_idx].elevators().len();
1582            if current_count >= max {
1583                return Err(SimError::InvalidConfig {
1584                    field: "line.max_cars",
1585                    reason: format!("line already has {current_count} cars (max {max})"),
1586                });
1587            }
1588        }
1589
1590        let eid = self.world.spawn();
1591        self.world.set_position(
1592            eid,
1593            Position {
1594                value: starting_position,
1595            },
1596        );
1597        self.world.set_velocity(eid, Velocity { value: 0.0 });
1598        self.world.set_elevator(
1599            eid,
1600            Elevator {
1601                phase: ElevatorPhase::Idle,
1602                door: DoorState::Closed,
1603                max_speed: params.max_speed,
1604                acceleration: params.acceleration,
1605                deceleration: params.deceleration,
1606                weight_capacity: params.weight_capacity,
1607                current_load: 0.0,
1608                riders: Vec::new(),
1609                target_stop: None,
1610                door_transition_ticks: params.door_transition_ticks,
1611                door_open_ticks: params.door_open_ticks,
1612                line,
1613                repositioning: false,
1614                restricted_stops: params.restricted_stops.clone(),
1615                inspection_speed_factor: params.inspection_speed_factor,
1616                going_up: true,
1617                going_down: true,
1618                move_count: 0,
1619            },
1620        );
1621        self.groups[group_idx].lines_mut()[line_idx]
1622            .elevators_mut()
1623            .push(eid);
1624        self.groups[group_idx].push_elevator(eid);
1625
1626        // Tag the elevator with its line's "line:{name}" tag.
1627        let line_name = self.world.line(line).map(|l| l.name.clone());
1628        if let Some(name) = line_name {
1629            if let Some(tags) = self
1630                .world
1631                .resource_mut::<crate::tagged_metrics::MetricTags>()
1632            {
1633                tags.tag(eid, format!("line:{name}"));
1634            }
1635        }
1636
1637        if let Ok(mut g) = self.topo_graph.lock() {
1638            g.mark_dirty();
1639        }
1640        self.events.emit(Event::ElevatorAdded {
1641            elevator: eid,
1642            line,
1643            group: group_id,
1644            tick: self.tick,
1645        });
1646        Ok(eid)
1647    }
1648
1649    // ── Line / group topology ───────────────────────────────────────
1650
1651    /// Add a new line to a group. Returns the line entity.
1652    ///
1653    /// # Errors
1654    ///
1655    /// Returns [`SimError::GroupNotFound`] if the specified group does not exist.
1656    pub fn add_line(&mut self, params: &LineParams) -> Result<EntityId, SimError> {
1657        let group_id = params.group;
1658        let group = self
1659            .groups
1660            .iter_mut()
1661            .find(|g| g.id() == group_id)
1662            .ok_or(SimError::GroupNotFound(group_id))?;
1663
1664        let line_tag = format!("line:{}", params.name);
1665
1666        let eid = self.world.spawn();
1667        self.world.set_line(
1668            eid,
1669            Line {
1670                name: params.name.clone(),
1671                group: group_id,
1672                orientation: params.orientation,
1673                position: params.position,
1674                min_position: params.min_position,
1675                max_position: params.max_position,
1676                max_cars: params.max_cars,
1677            },
1678        );
1679
1680        group
1681            .lines_mut()
1682            .push(LineInfo::new(eid, Vec::new(), Vec::new()));
1683
1684        // Tag the line entity with "line:{name}" for per-line metrics.
1685        if let Some(tags) = self
1686            .world
1687            .resource_mut::<crate::tagged_metrics::MetricTags>()
1688        {
1689            tags.tag(eid, line_tag);
1690        }
1691
1692        if let Ok(mut g) = self.topo_graph.lock() {
1693            g.mark_dirty();
1694        }
1695        self.events.emit(Event::LineAdded {
1696            line: eid,
1697            group: group_id,
1698            tick: self.tick,
1699        });
1700        Ok(eid)
1701    }
1702
1703    /// Remove a line and all its elevators from the simulation.
1704    ///
1705    /// Elevators on the line are disabled (not despawned) so riders are
1706    /// properly ejected to the nearest stop.
1707    ///
1708    /// # Errors
1709    ///
1710    /// Returns [`SimError::LineNotFound`] if the line entity is not found
1711    /// in any group.
1712    pub fn remove_line(&mut self, line: EntityId) -> Result<(), SimError> {
1713        let (group_idx, line_idx) = self.find_line(line)?;
1714
1715        let group_id = self.groups[group_idx].id();
1716
1717        // Collect elevator entities to disable.
1718        let elevator_ids: Vec<EntityId> = self.groups[group_idx].lines()[line_idx]
1719            .elevators()
1720            .to_vec();
1721
1722        // Disable each elevator (ejects riders properly).
1723        for eid in &elevator_ids {
1724            // Ignore errors from already-disabled elevators.
1725            let _ = self.disable(*eid);
1726        }
1727
1728        // Remove the LineInfo from the group.
1729        self.groups[group_idx].lines_mut().remove(line_idx);
1730
1731        // Rebuild flat caches.
1732        self.groups[group_idx].rebuild_caches();
1733
1734        // Remove Line component from world.
1735        self.world.remove_line(line);
1736
1737        if let Ok(mut g) = self.topo_graph.lock() {
1738            g.mark_dirty();
1739        }
1740        self.events.emit(Event::LineRemoved {
1741            line,
1742            group: group_id,
1743            tick: self.tick,
1744        });
1745        Ok(())
1746    }
1747
1748    /// Remove an elevator from the simulation.
1749    ///
1750    /// The elevator is disabled first (ejecting any riders), then removed
1751    /// from its line and despawned from the world.
1752    ///
1753    /// # Errors
1754    ///
1755    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
1756    pub fn remove_elevator(&mut self, elevator: EntityId) -> Result<(), SimError> {
1757        let line = self
1758            .world
1759            .elevator(elevator)
1760            .ok_or(SimError::EntityNotFound(elevator))?
1761            .line();
1762
1763        // Disable first to eject riders and reset state.
1764        let _ = self.disable(elevator);
1765
1766        // Find and remove from group/line topology.
1767        let mut group_id = GroupId(0);
1768        if let Ok((group_idx, line_idx)) = self.find_line(line) {
1769            self.groups[group_idx].lines_mut()[line_idx]
1770                .elevators_mut()
1771                .retain(|&e| e != elevator);
1772            self.groups[group_idx].rebuild_caches();
1773
1774            // Notify dispatch strategy.
1775            group_id = self.groups[group_idx].id();
1776            if let Some(dispatcher) = self.dispatchers.get_mut(&group_id) {
1777                dispatcher.notify_removed(elevator);
1778            }
1779        }
1780
1781        self.events.emit(Event::ElevatorRemoved {
1782            elevator,
1783            line,
1784            group: group_id,
1785            tick: self.tick,
1786        });
1787
1788        // Despawn from world.
1789        self.world.despawn(elevator);
1790
1791        if let Ok(mut g) = self.topo_graph.lock() {
1792            g.mark_dirty();
1793        }
1794        Ok(())
1795    }
1796
1797    /// Remove a stop from the simulation.
1798    ///
1799    /// The stop is disabled first (invalidating routes that reference it),
1800    /// then removed from all lines and despawned from the world.
1801    ///
1802    /// # Errors
1803    ///
1804    /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
1805    pub fn remove_stop(&mut self, stop: EntityId) -> Result<(), SimError> {
1806        if self.world.stop(stop).is_none() {
1807            return Err(SimError::EntityNotFound(stop));
1808        }
1809
1810        // Disable first to invalidate routes referencing this stop.
1811        let _ = self.disable(stop);
1812
1813        // Remove from all lines and groups.
1814        for group in &mut self.groups {
1815            for line_info in group.lines_mut() {
1816                line_info.serves_mut().retain(|&s| s != stop);
1817            }
1818            group.rebuild_caches();
1819        }
1820
1821        // Remove from SortedStops resource.
1822        if let Some(sorted) = self.world.resource_mut::<crate::world::SortedStops>() {
1823            sorted.0.retain(|&(_, s)| s != stop);
1824        }
1825
1826        // Remove from stop_lookup.
1827        self.stop_lookup.retain(|_, &mut eid| eid != stop);
1828
1829        self.events.emit(Event::StopRemoved {
1830            stop,
1831            tick: self.tick,
1832        });
1833
1834        // Despawn from world.
1835        self.world.despawn(stop);
1836
1837        if let Ok(mut g) = self.topo_graph.lock() {
1838            g.mark_dirty();
1839        }
1840        Ok(())
1841    }
1842
1843    /// Create a new dispatch group. Returns the group ID.
1844    pub fn add_group(
1845        &mut self,
1846        name: impl Into<String>,
1847        dispatch: impl DispatchStrategy + 'static,
1848    ) -> GroupId {
1849        let next_id = self
1850            .groups
1851            .iter()
1852            .map(|g| g.id().0)
1853            .max()
1854            .map_or(0, |m| m + 1);
1855        let group_id = GroupId(next_id);
1856
1857        self.groups
1858            .push(ElevatorGroup::new(group_id, name.into(), Vec::new()));
1859
1860        self.dispatchers.insert(group_id, Box::new(dispatch));
1861        self.strategy_ids.insert(group_id, BuiltinStrategy::Scan);
1862        if let Ok(mut g) = self.topo_graph.lock() {
1863            g.mark_dirty();
1864        }
1865        group_id
1866    }
1867
1868    /// Reassign a line to a different group. Returns the old `GroupId`.
1869    ///
1870    /// # Errors
1871    ///
1872    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
1873    /// Returns [`SimError::GroupNotFound`] if `new_group` does not exist.
1874    pub fn assign_line_to_group(
1875        &mut self,
1876        line: EntityId,
1877        new_group: GroupId,
1878    ) -> Result<GroupId, SimError> {
1879        let (old_group_idx, line_idx) = self.find_line(line)?;
1880
1881        // Verify new group exists.
1882        if !self.groups.iter().any(|g| g.id() == new_group) {
1883            return Err(SimError::GroupNotFound(new_group));
1884        }
1885
1886        let old_group_id = self.groups[old_group_idx].id();
1887
1888        // Remove LineInfo from old group.
1889        let line_info = self.groups[old_group_idx].lines_mut().remove(line_idx);
1890        self.groups[old_group_idx].rebuild_caches();
1891
1892        // Add LineInfo to new group.
1893        // Re-lookup new_group_idx since removal may have shifted indices
1894        // (only possible if old and new are different groups; if same group
1895        // the line_info was already removed above).
1896        let new_group_idx = self
1897            .groups
1898            .iter()
1899            .position(|g| g.id() == new_group)
1900            .ok_or(SimError::GroupNotFound(new_group))?;
1901        self.groups[new_group_idx].lines_mut().push(line_info);
1902        self.groups[new_group_idx].rebuild_caches();
1903
1904        // Update Line component's group field.
1905        if let Some(line_comp) = self.world.line_mut(line) {
1906            line_comp.group = new_group;
1907        }
1908
1909        if let Ok(mut g) = self.topo_graph.lock() {
1910            g.mark_dirty();
1911        }
1912        self.events.emit(Event::LineReassigned {
1913            line,
1914            old_group: old_group_id,
1915            new_group,
1916            tick: self.tick,
1917        });
1918
1919        Ok(old_group_id)
1920    }
1921
1922    /// Reassign an elevator to a different line (swing-car pattern).
1923    ///
1924    /// The elevator is moved from its current line to the target line.
1925    /// Both lines must be in the same group, or you must reassign the
1926    /// line first via [`assign_line_to_group`](Self::assign_line_to_group).
1927    ///
1928    /// # Errors
1929    ///
1930    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
1931    /// Returns [`SimError::LineNotFound`] if the target line is not found in any group.
1932    pub fn reassign_elevator_to_line(
1933        &mut self,
1934        elevator: EntityId,
1935        new_line: EntityId,
1936    ) -> Result<(), SimError> {
1937        let old_line = self
1938            .world
1939            .elevator(elevator)
1940            .ok_or(SimError::EntityNotFound(elevator))?
1941            .line();
1942
1943        if old_line == new_line {
1944            return Ok(());
1945        }
1946
1947        // Validate both lines exist BEFORE mutating anything.
1948        let (old_group_idx, old_line_idx) = self.find_line(old_line)?;
1949        let (new_group_idx, new_line_idx) = self.find_line(new_line)?;
1950
1951        // Enforce max_cars on target line.
1952        if let Some(max) = self.world.line(new_line).and_then(Line::max_cars) {
1953            let current_count = self.groups[new_group_idx].lines()[new_line_idx]
1954                .elevators()
1955                .len();
1956            if current_count >= max {
1957                return Err(SimError::InvalidConfig {
1958                    field: "line.max_cars",
1959                    reason: format!("target line already has {current_count} cars (max {max})"),
1960                });
1961            }
1962        }
1963
1964        self.groups[old_group_idx].lines_mut()[old_line_idx]
1965            .elevators_mut()
1966            .retain(|&e| e != elevator);
1967        self.groups[new_group_idx].lines_mut()[new_line_idx]
1968            .elevators_mut()
1969            .push(elevator);
1970
1971        if let Some(car) = self.world.elevator_mut(elevator) {
1972            car.line = new_line;
1973        }
1974
1975        self.groups[old_group_idx].rebuild_caches();
1976        if new_group_idx != old_group_idx {
1977            self.groups[new_group_idx].rebuild_caches();
1978        }
1979
1980        if let Ok(mut g) = self.topo_graph.lock() {
1981            g.mark_dirty();
1982        }
1983
1984        self.events.emit(Event::ElevatorReassigned {
1985            elevator,
1986            old_line,
1987            new_line,
1988            tick: self.tick,
1989        });
1990
1991        Ok(())
1992    }
1993
1994    /// Add a stop to a line's served stops.
1995    ///
1996    /// # Errors
1997    ///
1998    /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
1999    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
2000    pub fn add_stop_to_line(&mut self, stop: EntityId, line: EntityId) -> Result<(), SimError> {
2001        // Verify stop exists.
2002        if self.world.stop(stop).is_none() {
2003            return Err(SimError::EntityNotFound(stop));
2004        }
2005
2006        let (group_idx, line_idx) = self.find_line(line)?;
2007
2008        let li = &mut self.groups[group_idx].lines_mut()[line_idx];
2009        if !li.serves().contains(&stop) {
2010            li.serves_mut().push(stop);
2011        }
2012
2013        self.groups[group_idx].push_stop(stop);
2014
2015        if let Ok(mut g) = self.topo_graph.lock() {
2016            g.mark_dirty();
2017        }
2018        Ok(())
2019    }
2020
2021    /// Remove a stop from a line's served stops.
2022    ///
2023    /// # Errors
2024    ///
2025    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
2026    pub fn remove_stop_from_line(
2027        &mut self,
2028        stop: EntityId,
2029        line: EntityId,
2030    ) -> Result<(), SimError> {
2031        let (group_idx, line_idx) = self.find_line(line)?;
2032
2033        self.groups[group_idx].lines_mut()[line_idx]
2034            .serves_mut()
2035            .retain(|&s| s != stop);
2036
2037        // Rebuild group's stop_entities from all lines.
2038        self.groups[group_idx].rebuild_caches();
2039
2040        if let Ok(mut g) = self.topo_graph.lock() {
2041            g.mark_dirty();
2042        }
2043        Ok(())
2044    }
2045
2046    // ── Line / group queries ────────────────────────────────────────
2047
2048    /// Get all line entities across all groups.
2049    #[must_use]
2050    pub fn all_lines(&self) -> Vec<EntityId> {
2051        self.groups
2052            .iter()
2053            .flat_map(|g| g.lines().iter().map(LineInfo::entity))
2054            .collect()
2055    }
2056
2057    /// Number of lines in the simulation.
2058    #[must_use]
2059    pub fn line_count(&self) -> usize {
2060        self.groups.iter().map(|g| g.lines().len()).sum()
2061    }
2062
2063    /// Get all line entities in a group.
2064    #[must_use]
2065    pub fn lines_in_group(&self, group: GroupId) -> Vec<EntityId> {
2066        self.groups
2067            .iter()
2068            .find(|g| g.id() == group)
2069            .map_or_else(Vec::new, |g| {
2070                g.lines().iter().map(LineInfo::entity).collect()
2071            })
2072    }
2073
2074    /// Get elevator entities on a specific line.
2075    #[must_use]
2076    pub fn elevators_on_line(&self, line: EntityId) -> Vec<EntityId> {
2077        self.groups
2078            .iter()
2079            .flat_map(ElevatorGroup::lines)
2080            .find(|li| li.entity() == line)
2081            .map_or_else(Vec::new, |li| li.elevators().to_vec())
2082    }
2083
2084    /// Get stop entities served by a specific line.
2085    #[must_use]
2086    pub fn stops_served_by_line(&self, line: EntityId) -> Vec<EntityId> {
2087        self.groups
2088            .iter()
2089            .flat_map(ElevatorGroup::lines)
2090            .find(|li| li.entity() == line)
2091            .map_or_else(Vec::new, |li| li.serves().to_vec())
2092    }
2093
2094    /// Get the line entity for an elevator.
2095    #[must_use]
2096    pub fn line_for_elevator(&self, elevator: EntityId) -> Option<EntityId> {
2097        self.groups
2098            .iter()
2099            .flat_map(ElevatorGroup::lines)
2100            .find(|li| li.elevators().contains(&elevator))
2101            .map(LineInfo::entity)
2102    }
2103
2104    /// Iterate over elevators currently repositioning.
2105    pub fn iter_repositioning_elevators(&self) -> impl Iterator<Item = EntityId> + '_ {
2106        self.world
2107            .iter_elevators()
2108            .filter_map(|(id, _pos, car)| if car.repositioning() { Some(id) } else { None })
2109    }
2110
2111    /// Get all line entities that serve a given stop.
2112    #[must_use]
2113    pub fn lines_serving_stop(&self, stop: EntityId) -> Vec<EntityId> {
2114        self.groups
2115            .iter()
2116            .flat_map(ElevatorGroup::lines)
2117            .filter(|li| li.serves().contains(&stop))
2118            .map(LineInfo::entity)
2119            .collect()
2120    }
2121
2122    /// Get all group IDs that serve a given stop.
2123    #[must_use]
2124    pub fn groups_serving_stop(&self, stop: EntityId) -> Vec<GroupId> {
2125        self.groups
2126            .iter()
2127            .filter(|g| g.stop_entities().contains(&stop))
2128            .map(ElevatorGroup::id)
2129            .collect()
2130    }
2131
2132    // ── Topology queries ─────────────────────────────────────────────
2133
2134    /// Rebuild the topology graph if any mutation has invalidated it.
2135    fn ensure_graph_built(&self) {
2136        if let Ok(mut graph) = self.topo_graph.lock() {
2137            if graph.is_dirty() {
2138                graph.rebuild(&self.groups);
2139            }
2140        }
2141    }
2142
2143    /// All stops reachable from a given stop through the line/group topology.
2144    pub fn reachable_stops_from(&self, stop: EntityId) -> Vec<EntityId> {
2145        self.ensure_graph_built();
2146        self.topo_graph
2147            .lock()
2148            .map_or_else(|_| Vec::new(), |g| g.reachable_stops_from(stop))
2149    }
2150
2151    /// Stops that serve as transfer points between groups.
2152    pub fn transfer_points(&self) -> Vec<EntityId> {
2153        self.ensure_graph_built();
2154        TopologyGraph::transfer_points(&self.groups)
2155    }
2156
2157    /// Find the shortest route between two stops, possibly spanning multiple groups.
2158    pub fn shortest_route(&self, from: EntityId, to: EntityId) -> Option<Route> {
2159        self.ensure_graph_built();
2160        self.topo_graph
2161            .lock()
2162            .ok()
2163            .and_then(|g| g.shortest_route(from, to))
2164    }
2165
2166    // ── Extension restore ────────────────────────────────────────────
2167
2168    /// Deserialize extension components from a snapshot.
2169    ///
2170    /// Call this after restoring from a snapshot and registering all
2171    /// extension types via `world.register_ext::<T>(name)`.
2172    ///
2173    /// ```ignore
2174    /// let mut sim = snapshot.restore(None);
2175    /// sim.world_mut().register_ext::<VipTag>("vip_tag");
2176    /// sim.load_extensions();
2177    /// ```
2178    pub fn load_extensions(&mut self) {
2179        if let Some(pending) = self
2180            .world
2181            .remove_resource::<crate::snapshot::PendingExtensions>()
2182        {
2183            self.world.deserialize_extensions(&pending.0);
2184        }
2185    }
2186
2187    // ── Helpers ──────────────────────────────────────────────────────
2188
2189    /// Extract the `GroupId` from the current leg of a route.
2190    ///
2191    /// For Walk legs, looks ahead to the next leg to find the group.
2192    /// Falls back to `GroupId(0)` when no route exists or no group leg is found.
2193    fn group_from_route(&self, route: Option<&Route>) -> GroupId {
2194        if let Some(route) = route {
2195            // Scan forward from current_leg looking for a Group or Line transport mode.
2196            for leg in route.legs.iter().skip(route.current_leg) {
2197                match leg.via {
2198                    crate::components::TransportMode::Group(g) => return g,
2199                    crate::components::TransportMode::Line(l) => {
2200                        if let Some(line) = self.world.line(l) {
2201                            return line.group();
2202                        }
2203                    }
2204                    crate::components::TransportMode::Walk => {}
2205                }
2206            }
2207        }
2208        GroupId(0)
2209    }
2210
2211    // ── Re-routing ───────────────────────────────────────────────────
2212
2213    /// Change a rider's destination mid-route.
2214    ///
2215    /// Replaces remaining route legs with a single direct leg to `new_destination`,
2216    /// keeping the rider's current stop as origin.
2217    ///
2218    /// Returns `Err` if the rider does not exist or is not in `Waiting` phase
2219    /// (riding/boarding riders cannot be rerouted until they exit).
2220    ///
2221    /// # Errors
2222    ///
2223    /// Returns [`SimError::EntityNotFound`] if `rider` does not exist.
2224    /// Returns [`SimError::InvalidState`] if the rider is not in
2225    /// [`RiderPhase::Waiting`] or has no current stop.
2226    pub fn reroute(&mut self, rider: EntityId, new_destination: EntityId) -> Result<(), SimError> {
2227        let r = self
2228            .world
2229            .rider(rider)
2230            .ok_or(SimError::EntityNotFound(rider))?;
2231
2232        if r.phase != RiderPhase::Waiting {
2233            return Err(SimError::InvalidState {
2234                entity: rider,
2235                reason: "can only reroute riders in Waiting phase".into(),
2236            });
2237        }
2238
2239        let origin = r.current_stop.ok_or_else(|| SimError::InvalidState {
2240            entity: rider,
2241            reason: "rider has no current stop for reroute".into(),
2242        })?;
2243
2244        let group = self.group_from_route(self.world.route(rider));
2245        self.world
2246            .set_route(rider, Route::direct(origin, new_destination, group));
2247
2248        self.events.emit(Event::RiderRerouted {
2249            rider,
2250            new_destination,
2251            tick: self.tick,
2252        });
2253
2254        Ok(())
2255    }
2256
2257    /// Replace a rider's entire remaining route.
2258    ///
2259    /// # Errors
2260    ///
2261    /// Returns [`SimError::EntityNotFound`] if `rider` does not exist.
2262    pub fn set_rider_route(&mut self, rider: EntityId, route: Route) -> Result<(), SimError> {
2263        if self.world.rider(rider).is_none() {
2264            return Err(SimError::EntityNotFound(rider));
2265        }
2266        self.world.set_route(rider, route);
2267        Ok(())
2268    }
2269
2270    // ── Rider settlement & population ─────────────────────────────
2271
2272    /// Transition an `Arrived` or `Abandoned` rider to `Resident` at their
2273    /// current stop.
2274    ///
2275    /// Resident riders are parked — invisible to dispatch and loading, but
2276    /// queryable via [`residents_at()`](Self::residents_at). They can later
2277    /// be given a new route via [`reroute_rider()`](Self::reroute_rider).
2278    ///
2279    /// # Errors
2280    ///
2281    /// Returns [`SimError::EntityNotFound`] if `id` does not exist.
2282    /// Returns [`SimError::InvalidState`] if the rider is not in
2283    /// `Arrived` or `Abandoned` phase, or has no current stop.
2284    pub fn settle_rider(&mut self, id: EntityId) -> Result<(), SimError> {
2285        let rider = self.world.rider(id).ok_or(SimError::EntityNotFound(id))?;
2286
2287        let old_phase = rider.phase;
2288        match old_phase {
2289            RiderPhase::Arrived | RiderPhase::Abandoned => {}
2290            _ => {
2291                return Err(SimError::InvalidState {
2292                    entity: id,
2293                    reason: format!(
2294                        "cannot settle rider in {old_phase} phase, expected Arrived or Abandoned"
2295                    ),
2296                });
2297            }
2298        }
2299
2300        let stop = rider.current_stop.ok_or_else(|| SimError::InvalidState {
2301            entity: id,
2302            reason: "rider has no current_stop".into(),
2303        })?;
2304
2305        // Update index: remove from old partition (only Abandoned is indexed).
2306        if old_phase == RiderPhase::Abandoned {
2307            self.rider_index.remove_abandoned(stop, id);
2308        }
2309        self.rider_index.insert_resident(stop, id);
2310
2311        if let Some(r) = self.world.rider_mut(id) {
2312            r.phase = RiderPhase::Resident;
2313        }
2314
2315        self.metrics.record_settle();
2316        self.events.emit(Event::RiderSettled {
2317            rider: id,
2318            stop,
2319            tick: self.tick,
2320        });
2321        Ok(())
2322    }
2323
2324    /// Give a `Resident` rider a new route, transitioning them to `Waiting`.
2325    ///
2326    /// The rider begins waiting at their current stop for an elevator
2327    /// matching the route's transport mode. If the rider has a [`Patience`]
2328    /// component, its `waited_ticks` is reset to zero.
2329    ///
2330    /// # Errors
2331    ///
2332    /// Returns [`SimError::EntityNotFound`] if `id` does not exist.
2333    /// Returns [`SimError::InvalidState`] if the rider is not in `Resident` phase,
2334    /// the route has no legs, or the route's first leg origin does not match the
2335    /// rider's current stop.
2336    pub fn reroute_rider(&mut self, id: EntityId, route: Route) -> Result<(), SimError> {
2337        let rider = self.world.rider(id).ok_or(SimError::EntityNotFound(id))?;
2338
2339        if rider.phase != RiderPhase::Resident {
2340            return Err(SimError::InvalidState {
2341                entity: id,
2342                reason: format!(
2343                    "cannot reroute rider in {} phase, expected Resident",
2344                    rider.phase
2345                ),
2346            });
2347        }
2348
2349        let stop = rider.current_stop.ok_or_else(|| SimError::InvalidState {
2350            entity: id,
2351            reason: "resident rider has no current_stop".into(),
2352        })?;
2353
2354        let new_destination = route
2355            .final_destination()
2356            .ok_or_else(|| SimError::InvalidState {
2357                entity: id,
2358                reason: "route has no legs".into(),
2359            })?;
2360
2361        // Validate that the route departs from the rider's current stop.
2362        if let Some(leg) = route.current() {
2363            if leg.from != stop {
2364                return Err(SimError::InvalidState {
2365                    entity: id,
2366                    reason: format!(
2367                        "route origin {:?} does not match rider current_stop {:?}",
2368                        leg.from, stop
2369                    ),
2370                });
2371            }
2372        }
2373
2374        self.rider_index.remove_resident(stop, id);
2375        self.rider_index.insert_waiting(stop, id);
2376
2377        if let Some(r) = self.world.rider_mut(id) {
2378            r.phase = RiderPhase::Waiting;
2379        }
2380        self.world.set_route(id, route);
2381
2382        // Reset patience if present.
2383        if let Some(p) = self.world.patience_mut(id) {
2384            p.waited_ticks = 0;
2385        }
2386
2387        self.metrics.record_reroute();
2388        self.events.emit(Event::RiderRerouted {
2389            rider: id,
2390            new_destination,
2391            tick: self.tick,
2392        });
2393        Ok(())
2394    }
2395
2396    /// Remove a rider from the simulation entirely.
2397    ///
2398    /// Cleans up the population index, metric tags, and elevator cross-references
2399    /// (if the rider is currently aboard). Emits [`Event::RiderDespawned`].
2400    ///
2401    /// All rider removal should go through this method rather than calling
2402    /// `world.despawn()` directly, to keep the population index consistent.
2403    ///
2404    /// # Errors
2405    ///
2406    /// Returns [`SimError::EntityNotFound`] if `id` does not exist or is
2407    /// not a rider.
2408    pub fn despawn_rider(&mut self, id: EntityId) -> Result<(), SimError> {
2409        let rider = self.world.rider(id).ok_or(SimError::EntityNotFound(id))?;
2410
2411        // Targeted index removal based on current phase (O(1) vs O(n) scan).
2412        if let Some(stop) = rider.current_stop {
2413            match rider.phase {
2414                RiderPhase::Waiting => self.rider_index.remove_waiting(stop, id),
2415                RiderPhase::Resident => self.rider_index.remove_resident(stop, id),
2416                RiderPhase::Abandoned => self.rider_index.remove_abandoned(stop, id),
2417                _ => {} // Boarding/Riding/Exiting/Walking/Arrived — not indexed
2418            }
2419        }
2420
2421        if let Some(tags) = self
2422            .world
2423            .resource_mut::<crate::tagged_metrics::MetricTags>()
2424        {
2425            tags.remove_entity(id);
2426        }
2427
2428        self.world.despawn(id);
2429
2430        self.events.emit(Event::RiderDespawned {
2431            rider: id,
2432            tick: self.tick,
2433        });
2434        Ok(())
2435    }
2436
2437    // ── Access control ──────────────────────────────────────────────
2438
2439    /// Set the allowed stops for a rider.
2440    ///
2441    /// When set, the rider will only be allowed to board elevators that
2442    /// can take them to a stop in the allowed set. See
2443    /// [`AccessControl`](crate::components::AccessControl) for details.
2444    ///
2445    /// # Errors
2446    ///
2447    /// Returns [`SimError::EntityNotFound`] if the rider does not exist.
2448    pub fn set_rider_access(
2449        &mut self,
2450        rider: EntityId,
2451        allowed_stops: HashSet<EntityId>,
2452    ) -> Result<(), SimError> {
2453        if self.world.rider(rider).is_none() {
2454            return Err(SimError::EntityNotFound(rider));
2455        }
2456        self.world
2457            .set_access_control(rider, crate::components::AccessControl::new(allowed_stops));
2458        Ok(())
2459    }
2460
2461    /// Set the restricted stops for an elevator.
2462    ///
2463    /// Riders whose current destination is in this set will be rejected
2464    /// with [`RejectionReason::AccessDenied`](crate::error::RejectionReason::AccessDenied)
2465    /// during the loading phase.
2466    ///
2467    /// # Errors
2468    ///
2469    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
2470    pub fn set_elevator_restricted_stops(
2471        &mut self,
2472        elevator: EntityId,
2473        restricted_stops: HashSet<EntityId>,
2474    ) -> Result<(), SimError> {
2475        let car = self
2476            .world
2477            .elevator_mut(elevator)
2478            .ok_or(SimError::EntityNotFound(elevator))?;
2479        car.restricted_stops = restricted_stops;
2480        Ok(())
2481    }
2482
2483    // ── Population queries ──────────────────────────────────────────
2484
2485    /// Iterate over resident rider IDs at a stop (O(1) lookup).
2486    pub fn residents_at(&self, stop: EntityId) -> impl Iterator<Item = EntityId> + '_ {
2487        self.rider_index.residents_at(stop).iter().copied()
2488    }
2489
2490    /// Count of residents at a stop (O(1)).
2491    #[must_use]
2492    pub fn resident_count_at(&self, stop: EntityId) -> usize {
2493        self.rider_index.resident_count_at(stop)
2494    }
2495
2496    /// Iterate over waiting rider IDs at a stop (O(1) lookup).
2497    pub fn waiting_at(&self, stop: EntityId) -> impl Iterator<Item = EntityId> + '_ {
2498        self.rider_index.waiting_at(stop).iter().copied()
2499    }
2500
2501    /// Count of waiting riders at a stop (O(1)).
2502    #[must_use]
2503    pub fn waiting_count_at(&self, stop: EntityId) -> usize {
2504        self.rider_index.waiting_count_at(stop)
2505    }
2506
2507    /// Iterate over abandoned rider IDs at a stop (O(1) lookup).
2508    pub fn abandoned_at(&self, stop: EntityId) -> impl Iterator<Item = EntityId> + '_ {
2509        self.rider_index.abandoned_at(stop).iter().copied()
2510    }
2511
2512    /// Count of abandoned riders at a stop (O(1)).
2513    #[must_use]
2514    pub fn abandoned_count_at(&self, stop: EntityId) -> usize {
2515        self.rider_index.abandoned_count_at(stop)
2516    }
2517
2518    /// Get the rider entities currently aboard an elevator.
2519    ///
2520    /// Returns an empty slice if the elevator does not exist.
2521    #[must_use]
2522    pub fn riders_on(&self, elevator: EntityId) -> &[EntityId] {
2523        self.world
2524            .elevator(elevator)
2525            .map_or(&[], |car| car.riders())
2526    }
2527
2528    /// Get the number of riders aboard an elevator.
2529    ///
2530    /// Returns 0 if the elevator does not exist.
2531    #[must_use]
2532    pub fn occupancy(&self, elevator: EntityId) -> usize {
2533        self.world
2534            .elevator(elevator)
2535            .map_or(0, |car| car.riders().len())
2536    }
2537
2538    // ── Entity lifecycle ────────────────────────────────────────────
2539
2540    /// Disable an entity. Disabled entities are skipped by all systems.
2541    ///
2542    /// If the entity is an elevator in motion, it is reset to `Idle` with
2543    /// zero velocity to prevent stale target references on re-enable.
2544    ///
2545    /// **Note on residents:** disabling a stop does not automatically handle
2546    /// `Resident` riders parked there. Callers should listen for
2547    /// [`Event::EntityDisabled`] and manually reroute or despawn any
2548    /// residents at the affected stop.
2549    ///
2550    /// Emits `EntityDisabled`. Returns `Err` if the entity does not exist.
2551    ///
2552    /// # Errors
2553    ///
2554    /// Returns [`SimError::EntityNotFound`] if `id` does not refer to a
2555    /// living entity.
2556    pub fn disable(&mut self, id: EntityId) -> Result<(), SimError> {
2557        if !self.world.is_alive(id) {
2558            return Err(SimError::EntityNotFound(id));
2559        }
2560        // If this is an elevator, eject all riders and reset state.
2561        if let Some(car) = self.world.elevator(id) {
2562            let rider_ids = car.riders.clone();
2563            let pos = self.world.position(id).map_or(0.0, |p| p.value);
2564            let nearest_stop = self.world.find_nearest_stop(pos);
2565
2566            for rid in &rider_ids {
2567                if let Some(r) = self.world.rider_mut(*rid) {
2568                    r.phase = RiderPhase::Waiting;
2569                    r.current_stop = nearest_stop;
2570                    r.board_tick = None;
2571                }
2572                if let Some(stop) = nearest_stop {
2573                    self.rider_index.insert_waiting(stop, *rid);
2574                    self.events.emit(Event::RiderEjected {
2575                        rider: *rid,
2576                        elevator: id,
2577                        stop,
2578                        tick: self.tick,
2579                    });
2580                }
2581            }
2582
2583            let had_load = self
2584                .world
2585                .elevator(id)
2586                .is_some_and(|c| c.current_load > 0.0);
2587            let capacity = self.world.elevator(id).map(|c| c.weight_capacity);
2588            if let Some(car) = self.world.elevator_mut(id) {
2589                car.riders.clear();
2590                car.current_load = 0.0;
2591                car.phase = ElevatorPhase::Idle;
2592                car.target_stop = None;
2593            }
2594            if had_load {
2595                if let Some(cap) = capacity {
2596                    self.events.emit(Event::CapacityChanged {
2597                        elevator: id,
2598                        current_load: ordered_float::OrderedFloat(0.0),
2599                        capacity: ordered_float::OrderedFloat(cap),
2600                        tick: self.tick,
2601                    });
2602                }
2603            }
2604        }
2605        if let Some(vel) = self.world.velocity_mut(id) {
2606            vel.value = 0.0;
2607        }
2608
2609        // If this is a stop, invalidate routes that reference it.
2610        if self.world.stop(id).is_some() {
2611            self.invalidate_routes_for_stop(id);
2612        }
2613
2614        self.world.disable(id);
2615        self.events.emit(Event::EntityDisabled {
2616            entity: id,
2617            tick: self.tick,
2618        });
2619        Ok(())
2620    }
2621
2622    /// Re-enable a disabled entity.
2623    ///
2624    /// Emits `EntityEnabled`. Returns `Err` if the entity does not exist.
2625    ///
2626    /// # Errors
2627    ///
2628    /// Returns [`SimError::EntityNotFound`] if `id` does not refer to a
2629    /// living entity.
2630    pub fn enable(&mut self, id: EntityId) -> Result<(), SimError> {
2631        if !self.world.is_alive(id) {
2632            return Err(SimError::EntityNotFound(id));
2633        }
2634        self.world.enable(id);
2635        self.events.emit(Event::EntityEnabled {
2636            entity: id,
2637            tick: self.tick,
2638        });
2639        Ok(())
2640    }
2641
2642    /// Invalidate routes for all riders referencing a disabled stop.
2643    ///
2644    /// Attempts to reroute riders to the nearest enabled alternative stop.
2645    /// If no alternative exists, emits `RouteInvalidated` with `NoAlternative`.
2646    fn invalidate_routes_for_stop(&mut self, disabled_stop: EntityId) {
2647        use crate::events::RouteInvalidReason;
2648
2649        // Find the group this stop belongs to.
2650        let group_stops: Vec<EntityId> = self
2651            .groups
2652            .iter()
2653            .filter(|g| g.stop_entities().contains(&disabled_stop))
2654            .flat_map(|g| g.stop_entities().iter().copied())
2655            .filter(|&s| s != disabled_stop && !self.world.is_disabled(s))
2656            .collect();
2657
2658        // Find all Waiting riders whose route references this stop.
2659        // Riding riders are skipped — they'll be rerouted when they exit.
2660        let rider_ids: Vec<EntityId> = self.world.rider_ids();
2661        for rid in rider_ids {
2662            let is_waiting = self
2663                .world
2664                .rider(rid)
2665                .is_some_and(|r| r.phase == RiderPhase::Waiting);
2666
2667            if !is_waiting {
2668                continue;
2669            }
2670
2671            let references_stop = self.world.route(rid).is_some_and(|route| {
2672                route
2673                    .legs
2674                    .iter()
2675                    .skip(route.current_leg)
2676                    .any(|leg| leg.to == disabled_stop || leg.from == disabled_stop)
2677            });
2678
2679            if !references_stop {
2680                continue;
2681            }
2682
2683            // Try to find nearest alternative (excluding rider's current stop).
2684            let rider_current_stop = self.world.rider(rid).and_then(|r| r.current_stop);
2685
2686            let disabled_stop_pos = self.world.stop(disabled_stop).map_or(0.0, |s| s.position);
2687
2688            let alternative = group_stops
2689                .iter()
2690                .filter(|&&s| Some(s) != rider_current_stop)
2691                .filter_map(|&s| {
2692                    self.world
2693                        .stop(s)
2694                        .map(|stop| (s, (stop.position - disabled_stop_pos).abs()))
2695                })
2696                .min_by(|a, b| a.1.total_cmp(&b.1))
2697                .map(|(s, _)| s);
2698
2699            if let Some(alt_stop) = alternative {
2700                // Reroute to nearest alternative.
2701                let origin = rider_current_stop.unwrap_or(alt_stop);
2702                let group = self.group_from_route(self.world.route(rid));
2703                self.world
2704                    .set_route(rid, Route::direct(origin, alt_stop, group));
2705                self.events.emit(Event::RouteInvalidated {
2706                    rider: rid,
2707                    affected_stop: disabled_stop,
2708                    reason: RouteInvalidReason::StopDisabled,
2709                    tick: self.tick,
2710                });
2711            } else {
2712                // No alternative — rider abandons immediately.
2713                let abandon_stop = rider_current_stop.unwrap_or(disabled_stop);
2714                self.events.emit(Event::RouteInvalidated {
2715                    rider: rid,
2716                    affected_stop: disabled_stop,
2717                    reason: RouteInvalidReason::NoAlternative,
2718                    tick: self.tick,
2719                });
2720                if let Some(r) = self.world.rider_mut(rid) {
2721                    r.phase = RiderPhase::Abandoned;
2722                }
2723                if let Some(stop) = rider_current_stop {
2724                    self.rider_index.remove_waiting(stop, rid);
2725                    self.rider_index.insert_abandoned(stop, rid);
2726                }
2727                self.events.emit(Event::RiderAbandoned {
2728                    rider: rid,
2729                    stop: abandon_stop,
2730                    tick: self.tick,
2731                });
2732            }
2733        }
2734    }
2735
2736    /// Check if an entity is disabled.
2737    #[must_use]
2738    pub fn is_disabled(&self, id: EntityId) -> bool {
2739        self.world.is_disabled(id)
2740    }
2741
2742    // ── Entity type queries ─────────────────────────────────────────
2743
2744    /// Check if an entity is an elevator.
2745    ///
2746    /// ```
2747    /// use elevator_core::prelude::*;
2748    ///
2749    /// let sim = SimulationBuilder::new().build().unwrap();
2750    /// let stop = sim.stop_entity(StopId(0)).unwrap();
2751    /// assert!(!sim.is_elevator(stop));
2752    /// assert!(sim.is_stop(stop));
2753    /// ```
2754    #[must_use]
2755    pub fn is_elevator(&self, id: EntityId) -> bool {
2756        self.world.elevator(id).is_some()
2757    }
2758
2759    /// Check if an entity is a rider.
2760    #[must_use]
2761    pub fn is_rider(&self, id: EntityId) -> bool {
2762        self.world.rider(id).is_some()
2763    }
2764
2765    /// Check if an entity is a stop.
2766    #[must_use]
2767    pub fn is_stop(&self, id: EntityId) -> bool {
2768        self.world.stop(id).is_some()
2769    }
2770
2771    // ── Aggregate queries ───────────────────────────────────────────
2772
2773    /// Count of elevators currently in the [`Idle`](ElevatorPhase::Idle) phase.
2774    ///
2775    /// Excludes disabled elevators (whose phase is reset to `Idle` on disable).
2776    ///
2777    /// ```
2778    /// use elevator_core::prelude::*;
2779    ///
2780    /// let sim = SimulationBuilder::new().build().unwrap();
2781    /// assert_eq!(sim.idle_elevator_count(), 1);
2782    /// ```
2783    #[must_use]
2784    pub fn idle_elevator_count(&self) -> usize {
2785        self.world.iter_idle_elevators().count()
2786    }
2787
2788    /// Current total weight aboard an elevator, or `None` if the entity is
2789    /// not an elevator.
2790    ///
2791    /// ```
2792    /// use elevator_core::prelude::*;
2793    ///
2794    /// let sim = SimulationBuilder::new().build().unwrap();
2795    /// let stop = sim.stop_entity(StopId(0)).unwrap();
2796    /// assert_eq!(sim.elevator_load(stop), None); // not an elevator
2797    /// ```
2798    #[must_use]
2799    pub fn elevator_load(&self, id: EntityId) -> Option<f64> {
2800        self.world.elevator(id).map(|e| e.current_load)
2801    }
2802
2803    /// Whether the elevator's up-direction indicator lamp is lit.
2804    ///
2805    /// Returns `None` if the entity is not an elevator. See
2806    /// [`Elevator::going_up`] for semantics.
2807    #[must_use]
2808    pub fn elevator_going_up(&self, id: EntityId) -> Option<bool> {
2809        self.world.elevator(id).map(Elevator::going_up)
2810    }
2811
2812    /// Whether the elevator's down-direction indicator lamp is lit.
2813    ///
2814    /// Returns `None` if the entity is not an elevator. See
2815    /// [`Elevator::going_down`] for semantics.
2816    #[must_use]
2817    pub fn elevator_going_down(&self, id: EntityId) -> Option<bool> {
2818        self.world.elevator(id).map(Elevator::going_down)
2819    }
2820
2821    /// Count of rounded-floor transitions for an elevator (passing-floor
2822    /// crossings plus arrivals). Returns `None` if the entity is not an
2823    /// elevator. Analogous to elevator-saga's `moveCount`.
2824    #[must_use]
2825    pub fn elevator_move_count(&self, id: EntityId) -> Option<u64> {
2826        self.world.elevator(id).map(Elevator::move_count)
2827    }
2828
2829    /// Count of elevators currently in the given phase.
2830    ///
2831    /// Excludes disabled elevators (whose phase is reset to `Idle` on disable).
2832    ///
2833    /// ```
2834    /// use elevator_core::prelude::*;
2835    ///
2836    /// let sim = SimulationBuilder::new().build().unwrap();
2837    /// assert_eq!(sim.elevators_in_phase(ElevatorPhase::Idle), 1);
2838    /// assert_eq!(sim.elevators_in_phase(ElevatorPhase::Loading), 0);
2839    /// ```
2840    #[must_use]
2841    pub fn elevators_in_phase(&self, phase: ElevatorPhase) -> usize {
2842        self.world
2843            .iter_elevators()
2844            .filter(|(id, _, e)| e.phase() == phase && !self.world.is_disabled(*id))
2845            .count()
2846    }
2847
2848    // ── Service mode ────────────────────────────────────────────────
2849
2850    /// Set the service mode for an elevator.
2851    ///
2852    /// Emits [`Event::ServiceModeChanged`] if the mode actually changes.
2853    ///
2854    /// # Errors
2855    ///
2856    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
2857    pub fn set_service_mode(
2858        &mut self,
2859        elevator: EntityId,
2860        mode: crate::components::ServiceMode,
2861    ) -> Result<(), SimError> {
2862        if self.world.elevator(elevator).is_none() {
2863            return Err(SimError::EntityNotFound(elevator));
2864        }
2865        let old = self
2866            .world
2867            .service_mode(elevator)
2868            .copied()
2869            .unwrap_or_default();
2870        if old == mode {
2871            return Ok(());
2872        }
2873        self.world.set_service_mode(elevator, mode);
2874        self.events.emit(Event::ServiceModeChanged {
2875            elevator,
2876            from: old,
2877            to: mode,
2878            tick: self.tick,
2879        });
2880        Ok(())
2881    }
2882
2883    /// Get the current service mode for an elevator.
2884    #[must_use]
2885    pub fn service_mode(&self, elevator: EntityId) -> crate::components::ServiceMode {
2886        self.world
2887            .service_mode(elevator)
2888            .copied()
2889            .unwrap_or_default()
2890    }
2891
2892    // ── Sub-stepping ────────────────────────────────────────────────
2893
2894    /// Get the dispatch strategies map (for advanced sub-stepping).
2895    #[must_use]
2896    pub fn dispatchers(&self) -> &BTreeMap<GroupId, Box<dyn DispatchStrategy>> {
2897        &self.dispatchers
2898    }
2899
2900    /// Get the dispatch strategies map mutably (for advanced sub-stepping).
2901    pub fn dispatchers_mut(&mut self) -> &mut BTreeMap<GroupId, Box<dyn DispatchStrategy>> {
2902        &mut self.dispatchers
2903    }
2904
2905    /// Get a mutable reference to the event bus.
2906    pub const fn events_mut(&mut self) -> &mut EventBus {
2907        &mut self.events
2908    }
2909
2910    /// Get a mutable reference to the metrics.
2911    pub const fn metrics_mut(&mut self) -> &mut Metrics {
2912        &mut self.metrics
2913    }
2914
2915    /// Build the `PhaseContext` for the current tick.
2916    #[must_use]
2917    pub const fn phase_context(&self) -> PhaseContext {
2918        PhaseContext {
2919            tick: self.tick,
2920            dt: self.dt,
2921        }
2922    }
2923
2924    /// Run only the `advance_transient` phase (with hooks).
2925    pub fn run_advance_transient(&mut self) {
2926        self.hooks
2927            .run_before(Phase::AdvanceTransient, &mut self.world);
2928        for group in &self.groups {
2929            self.hooks
2930                .run_before_group(Phase::AdvanceTransient, group.id(), &mut self.world);
2931        }
2932        let ctx = self.phase_context();
2933        crate::systems::advance_transient::run(
2934            &mut self.world,
2935            &mut self.events,
2936            &ctx,
2937            &mut self.rider_index,
2938        );
2939        for group in &self.groups {
2940            self.hooks
2941                .run_after_group(Phase::AdvanceTransient, group.id(), &mut self.world);
2942        }
2943        self.hooks
2944            .run_after(Phase::AdvanceTransient, &mut self.world);
2945    }
2946
2947    /// Run only the dispatch phase (with hooks).
2948    pub fn run_dispatch(&mut self) {
2949        self.hooks.run_before(Phase::Dispatch, &mut self.world);
2950        for group in &self.groups {
2951            self.hooks
2952                .run_before_group(Phase::Dispatch, group.id(), &mut self.world);
2953        }
2954        let ctx = self.phase_context();
2955        crate::systems::dispatch::run(
2956            &mut self.world,
2957            &mut self.events,
2958            &ctx,
2959            &self.groups,
2960            &mut self.dispatchers,
2961            &self.rider_index,
2962        );
2963        for group in &self.groups {
2964            self.hooks
2965                .run_after_group(Phase::Dispatch, group.id(), &mut self.world);
2966        }
2967        self.hooks.run_after(Phase::Dispatch, &mut self.world);
2968    }
2969
2970    /// Run only the movement phase (with hooks).
2971    pub fn run_movement(&mut self) {
2972        self.hooks.run_before(Phase::Movement, &mut self.world);
2973        for group in &self.groups {
2974            self.hooks
2975                .run_before_group(Phase::Movement, group.id(), &mut self.world);
2976        }
2977        let ctx = self.phase_context();
2978        self.world.elevator_ids_into(&mut self.elevator_ids_buf);
2979        crate::systems::movement::run(
2980            &mut self.world,
2981            &mut self.events,
2982            &ctx,
2983            &self.elevator_ids_buf,
2984            &mut self.metrics,
2985        );
2986        for group in &self.groups {
2987            self.hooks
2988                .run_after_group(Phase::Movement, group.id(), &mut self.world);
2989        }
2990        self.hooks.run_after(Phase::Movement, &mut self.world);
2991    }
2992
2993    /// Run only the doors phase (with hooks).
2994    pub fn run_doors(&mut self) {
2995        self.hooks.run_before(Phase::Doors, &mut self.world);
2996        for group in &self.groups {
2997            self.hooks
2998                .run_before_group(Phase::Doors, group.id(), &mut self.world);
2999        }
3000        let ctx = self.phase_context();
3001        self.world.elevator_ids_into(&mut self.elevator_ids_buf);
3002        crate::systems::doors::run(
3003            &mut self.world,
3004            &mut self.events,
3005            &ctx,
3006            &self.elevator_ids_buf,
3007        );
3008        for group in &self.groups {
3009            self.hooks
3010                .run_after_group(Phase::Doors, group.id(), &mut self.world);
3011        }
3012        self.hooks.run_after(Phase::Doors, &mut self.world);
3013    }
3014
3015    /// Run only the loading phase (with hooks).
3016    pub fn run_loading(&mut self) {
3017        self.hooks.run_before(Phase::Loading, &mut self.world);
3018        for group in &self.groups {
3019            self.hooks
3020                .run_before_group(Phase::Loading, group.id(), &mut self.world);
3021        }
3022        let ctx = self.phase_context();
3023        self.world.elevator_ids_into(&mut self.elevator_ids_buf);
3024        crate::systems::loading::run(
3025            &mut self.world,
3026            &mut self.events,
3027            &ctx,
3028            &self.elevator_ids_buf,
3029            &mut self.rider_index,
3030        );
3031        for group in &self.groups {
3032            self.hooks
3033                .run_after_group(Phase::Loading, group.id(), &mut self.world);
3034        }
3035        self.hooks.run_after(Phase::Loading, &mut self.world);
3036    }
3037
3038    /// Run only the reposition phase (with hooks).
3039    ///
3040    /// Only runs if at least one group has a [`RepositionStrategy`] configured.
3041    /// Idle elevators with no pending dispatch assignment are repositioned
3042    /// according to their group's strategy.
3043    pub fn run_reposition(&mut self) {
3044        if self.repositioners.is_empty() {
3045            return;
3046        }
3047        self.hooks.run_before(Phase::Reposition, &mut self.world);
3048        // Only run per-group hooks for groups that have a repositioner.
3049        for group in &self.groups {
3050            if self.repositioners.contains_key(&group.id()) {
3051                self.hooks
3052                    .run_before_group(Phase::Reposition, group.id(), &mut self.world);
3053            }
3054        }
3055        let ctx = self.phase_context();
3056        crate::systems::reposition::run(
3057            &mut self.world,
3058            &mut self.events,
3059            &ctx,
3060            &self.groups,
3061            &mut self.repositioners,
3062        );
3063        for group in &self.groups {
3064            if self.repositioners.contains_key(&group.id()) {
3065                self.hooks
3066                    .run_after_group(Phase::Reposition, group.id(), &mut self.world);
3067            }
3068        }
3069        self.hooks.run_after(Phase::Reposition, &mut self.world);
3070    }
3071
3072    /// Run the energy system (no hooks — inline phase).
3073    #[cfg(feature = "energy")]
3074    fn run_energy(&mut self) {
3075        let ctx = self.phase_context();
3076        self.world.elevator_ids_into(&mut self.elevator_ids_buf);
3077        crate::systems::energy::run(
3078            &mut self.world,
3079            &mut self.events,
3080            &ctx,
3081            &self.elevator_ids_buf,
3082        );
3083    }
3084
3085    /// Run only the metrics phase (with hooks).
3086    pub fn run_metrics(&mut self) {
3087        self.hooks.run_before(Phase::Metrics, &mut self.world);
3088        for group in &self.groups {
3089            self.hooks
3090                .run_before_group(Phase::Metrics, group.id(), &mut self.world);
3091        }
3092        let ctx = self.phase_context();
3093        crate::systems::metrics::run(
3094            &mut self.world,
3095            &self.events,
3096            &mut self.metrics,
3097            &ctx,
3098            &self.groups,
3099        );
3100        for group in &self.groups {
3101            self.hooks
3102                .run_after_group(Phase::Metrics, group.id(), &mut self.world);
3103        }
3104        self.hooks.run_after(Phase::Metrics, &mut self.world);
3105    }
3106
3107    /// Register a hook to run before a simulation phase.
3108    ///
3109    /// Hooks are called in registration order. The hook receives mutable
3110    /// access to the world, allowing entity inspection or modification.
3111    pub fn add_before_hook(
3112        &mut self,
3113        phase: Phase,
3114        hook: impl Fn(&mut World) + Send + Sync + 'static,
3115    ) {
3116        self.hooks.add_before(phase, Box::new(hook));
3117    }
3118
3119    /// Register a hook to run after a simulation phase.
3120    ///
3121    /// Hooks are called in registration order. The hook receives mutable
3122    /// access to the world, allowing entity inspection or modification.
3123    pub fn add_after_hook(
3124        &mut self,
3125        phase: Phase,
3126        hook: impl Fn(&mut World) + Send + Sync + 'static,
3127    ) {
3128        self.hooks.add_after(phase, Box::new(hook));
3129    }
3130
3131    /// Register a hook to run before a phase for a specific group.
3132    pub fn add_before_group_hook(
3133        &mut self,
3134        phase: Phase,
3135        group: GroupId,
3136        hook: impl Fn(&mut World) + Send + Sync + 'static,
3137    ) {
3138        self.hooks.add_before_group(phase, group, Box::new(hook));
3139    }
3140
3141    /// Register a hook to run after a phase for a specific group.
3142    pub fn add_after_group_hook(
3143        &mut self,
3144        phase: Phase,
3145        group: GroupId,
3146        hook: impl Fn(&mut World) + Send + Sync + 'static,
3147    ) {
3148        self.hooks.add_after_group(phase, group, Box::new(hook));
3149    }
3150
3151    /// Increment the tick counter and flush events to the output buffer.
3152    ///
3153    /// Call after running all desired phases. Events emitted during this tick
3154    /// are moved to the output buffer and available via `drain_events()`.
3155    pub fn advance_tick(&mut self) {
3156        self.pending_output.extend(self.events.drain());
3157        self.tick += 1;
3158    }
3159
3160    /// Advance the simulation by one tick.
3161    ///
3162    /// Events from this tick are buffered internally and available via
3163    /// `drain_events()`. The metrics system only processes events from
3164    /// the current tick, regardless of whether the consumer drains them.
3165    ///
3166    /// ```
3167    /// use elevator_core::prelude::*;
3168    ///
3169    /// let mut sim = SimulationBuilder::new().build().unwrap();
3170    /// sim.step();
3171    /// assert_eq!(sim.current_tick(), 1);
3172    /// ```
3173    pub fn step(&mut self) {
3174        self.run_advance_transient();
3175        self.run_dispatch();
3176        self.run_reposition();
3177        self.run_movement();
3178        self.run_doors();
3179        self.run_loading();
3180        #[cfg(feature = "energy")]
3181        self.run_energy();
3182        self.run_metrics();
3183        self.advance_tick();
3184    }
3185}
3186
3187impl fmt::Debug for Simulation {
3188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3189        f.debug_struct("Simulation")
3190            .field("tick", &self.tick)
3191            .field("dt", &self.dt)
3192            .field("groups", &self.groups.len())
3193            .field("entities", &self.world.entity_count())
3194            .finish_non_exhaustive()
3195    }
3196}