Skip to main content

elevator_core/
sim.rs

1//! Top-level simulation runner and tick loop.
2//!
3//! # Essential API
4//!
5//! `Simulation` exposes a large surface, but most users only need the
6//! ~15 methods below, grouped by the order they appear in a typical
7//! game loop.
8//!
9//! ### Construction
10//!
11//! - [`SimulationBuilder::demo()`](crate::builder::SimulationBuilder::demo)
12//!   or [`SimulationBuilder::from_config()`](crate::builder::SimulationBuilder::from_config)
13//!   — fluent entry point; call [`.build()`](crate::builder::SimulationBuilder::build)
14//!   to get a `Simulation`.
15//! - [`Simulation::new()`](crate::sim::Simulation::new) — direct construction from
16//!   `&SimConfig` + a dispatch strategy.
17//!
18//! ### Per-tick driving
19//!
20//! - [`Simulation::step()`](crate::sim::Simulation::step) — run all 8 phases.
21//! - [`Simulation::current_tick()`](crate::sim::Simulation::current_tick) — the
22//!   current tick counter.
23//!
24//! ### Spawning and rerouting riders
25//!
26//! - [`Simulation::spawn_rider_by_stop_id()`](crate::sim::Simulation::spawn_rider_by_stop_id)
27//!   — simple origin/destination/weight spawn.
28//! - [`Simulation::build_rider_by_stop_id()`](crate::sim::Simulation::build_rider_by_stop_id)
29//!   — fluent [`RiderBuilder`](crate::sim::RiderBuilder) for patience, preferences, access
30//!   control, explicit groups, multi-leg routes.
31//! - [`Simulation::reroute()`](crate::sim::Simulation::reroute) — change a waiting
32//!   rider's destination mid-trip.
33//! - [`Simulation::settle_rider()`](crate::sim::Simulation::settle_rider) /
34//!   [`Simulation::despawn_rider()`](crate::sim::Simulation::despawn_rider) —
35//!   terminal-state cleanup for `Arrived`/`Abandoned` riders.
36//!
37//! ### Observability
38//!
39//! - [`Simulation::drain_events()`](crate::sim::Simulation::drain_events) — consume
40//!   the event stream emitted by the last tick.
41//! - [`Simulation::metrics()`](crate::sim::Simulation::metrics) — aggregate
42//!   wait/ride/throughput stats.
43//! - [`Simulation::waiting_at()`](crate::sim::Simulation::waiting_at) /
44//!   [`Simulation::residents_at()`](crate::sim::Simulation::residents_at) — O(1)
45//!   population queries by stop.
46//!
47//! ### Imperative control
48//!
49//! - [`Simulation::push_destination()`](crate::sim::Simulation::push_destination) /
50//!   [`Simulation::push_destination_front()`](crate::sim::Simulation::push_destination_front) /
51//!   [`Simulation::clear_destinations()`](crate::sim::Simulation::clear_destinations)
52//!   — override dispatch by pushing/clearing stops on an elevator's
53//!   [`DestinationQueue`](crate::components::DestinationQueue).
54//!
55//! ### Persistence
56//!
57//! - [`Simulation::snapshot()`](crate::sim::Simulation::snapshot) — capture full
58//!   state as a serializable [`WorldSnapshot`](crate::snapshot::WorldSnapshot).
59//! - [`WorldSnapshot::restore()`](crate::snapshot::WorldSnapshot::restore)
60//!   — rebuild a `Simulation` from a snapshot.
61//!
62//! Everything else (phase-runners, world-level accessors, energy, tag
63//! metrics, topology queries) is available for advanced use but is not
64//! required for the common case.
65
66mod construction;
67mod lifecycle;
68mod topology;
69
70use crate::components::{
71    AccessControl, FloorPosition, Orientation, Patience, Preferences, Rider, RiderPhase, Route,
72    Velocity,
73};
74use crate::dispatch::{BuiltinReposition, DispatchStrategy, ElevatorGroup, RepositionStrategy};
75use crate::entity::EntityId;
76use crate::error::SimError;
77use crate::events::{Event, EventBus};
78use crate::hooks::{Phase, PhaseHooks};
79use crate::ids::GroupId;
80use crate::metrics::Metrics;
81use crate::rider_index::RiderIndex;
82use crate::stop::StopId;
83use crate::systems::PhaseContext;
84use crate::time::TimeAdapter;
85use crate::topology::TopologyGraph;
86use crate::world::World;
87use std::collections::{BTreeMap, HashMap, HashSet};
88use std::fmt;
89use std::sync::Mutex;
90use std::time::Duration;
91
92/// Parameters for creating a new elevator at runtime.
93#[derive(Debug, Clone)]
94pub struct ElevatorParams {
95    /// Maximum travel speed (distance/tick).
96    pub max_speed: f64,
97    /// Acceleration rate (distance/tick^2).
98    pub acceleration: f64,
99    /// Deceleration rate (distance/tick^2).
100    pub deceleration: f64,
101    /// Maximum weight the car can carry.
102    pub weight_capacity: f64,
103    /// Ticks for a door open/close transition.
104    pub door_transition_ticks: u32,
105    /// Ticks the door stays fully open.
106    pub door_open_ticks: u32,
107    /// Stop entity IDs this elevator cannot serve (access restriction).
108    pub restricted_stops: HashSet<EntityId>,
109    /// Speed multiplier for Inspection mode (0.0..1.0).
110    pub inspection_speed_factor: f64,
111}
112
113impl Default for ElevatorParams {
114    fn default() -> Self {
115        Self {
116            max_speed: 2.0,
117            acceleration: 1.5,
118            deceleration: 2.0,
119            weight_capacity: 800.0,
120            door_transition_ticks: 5,
121            door_open_ticks: 10,
122            restricted_stops: HashSet::new(),
123            inspection_speed_factor: 0.25,
124        }
125    }
126}
127
128/// Parameters for creating a new line at runtime.
129#[derive(Debug, Clone)]
130pub struct LineParams {
131    /// Human-readable name.
132    pub name: String,
133    /// Dispatch group to add this line to.
134    pub group: GroupId,
135    /// Physical orientation.
136    pub orientation: Orientation,
137    /// Lowest reachable position on the line axis.
138    pub min_position: f64,
139    /// Highest reachable position on the line axis.
140    pub max_position: f64,
141    /// Optional floor-plan position.
142    pub position: Option<FloorPosition>,
143    /// Maximum cars on this line (None = unlimited).
144    pub max_cars: Option<usize>,
145}
146
147impl LineParams {
148    /// Create line parameters with the given name and group, defaulting
149    /// everything else.
150    pub fn new(name: impl Into<String>, group: GroupId) -> Self {
151        Self {
152            name: name.into(),
153            group,
154            orientation: Orientation::default(),
155            min_position: 0.0,
156            max_position: 0.0,
157            position: None,
158            max_cars: None,
159        }
160    }
161}
162
163/// Fluent builder for spawning riders with optional configuration.
164///
165/// Created via [`Simulation::build_rider`] or [`Simulation::build_rider_by_stop_id`].
166///
167/// ```
168/// use elevator_core::prelude::*;
169///
170/// let mut sim = SimulationBuilder::demo().build().unwrap();
171/// let rider = sim.build_rider_by_stop_id(StopId(0), StopId(1))
172///     .unwrap()
173///     .weight(80.0)
174///     .spawn()
175///     .unwrap();
176/// ```
177pub struct RiderBuilder<'a> {
178    /// Mutable reference to the simulation (consumed on spawn).
179    sim: &'a mut Simulation,
180    /// Origin stop entity.
181    origin: EntityId,
182    /// Destination stop entity.
183    destination: EntityId,
184    /// Rider weight (default: 75.0).
185    weight: f64,
186    /// Explicit dispatch group (skips auto-detection).
187    group: Option<GroupId>,
188    /// Explicit multi-leg route.
189    route: Option<Route>,
190    /// Maximum wait ticks before abandoning.
191    patience: Option<u64>,
192    /// Boarding preferences.
193    preferences: Option<Preferences>,
194    /// Per-rider access control.
195    access_control: Option<AccessControl>,
196}
197
198impl RiderBuilder<'_> {
199    /// Set the rider's weight (default: 75.0).
200    #[must_use]
201    pub const fn weight(mut self, weight: f64) -> Self {
202        self.weight = weight;
203        self
204    }
205
206    /// Set the dispatch group explicitly, skipping auto-detection.
207    #[must_use]
208    pub const fn group(mut self, group: GroupId) -> Self {
209        self.group = Some(group);
210        self
211    }
212
213    /// Provide an explicit multi-leg route.
214    #[must_use]
215    pub fn route(mut self, route: Route) -> Self {
216        self.route = Some(route);
217        self
218    }
219
220    /// Set maximum wait ticks before the rider abandons.
221    #[must_use]
222    pub const fn patience(mut self, max_wait_ticks: u64) -> Self {
223        self.patience = Some(max_wait_ticks);
224        self
225    }
226
227    /// Set boarding preferences.
228    #[must_use]
229    pub const fn preferences(mut self, prefs: Preferences) -> Self {
230        self.preferences = Some(prefs);
231        self
232    }
233
234    /// Set per-rider access control (allowed stops).
235    #[must_use]
236    pub fn access_control(mut self, ac: AccessControl) -> Self {
237        self.access_control = Some(ac);
238        self
239    }
240
241    /// Spawn the rider with the configured options.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`SimError::NoRoute`] if no group serves both stops (when auto-detecting).
246    /// Returns [`SimError::AmbiguousRoute`] if multiple groups serve both stops (when auto-detecting).
247    /// Returns [`SimError::GroupNotFound`] if an explicit group does not exist.
248    pub fn spawn(self) -> Result<EntityId, SimError> {
249        let route = if let Some(route) = self.route {
250            route
251        } else if let Some(group) = self.group {
252            if !self.sim.groups.iter().any(|g| g.id() == group) {
253                return Err(SimError::GroupNotFound(group));
254            }
255            Route::direct(self.origin, self.destination, group)
256        } else {
257            // Auto-detect group (same logic as spawn_rider).
258            let matching: Vec<GroupId> = self
259                .sim
260                .groups
261                .iter()
262                .filter(|g| {
263                    g.stop_entities().contains(&self.origin)
264                        && g.stop_entities().contains(&self.destination)
265                })
266                .map(ElevatorGroup::id)
267                .collect();
268
269            match matching.len() {
270                0 => {
271                    let origin_groups: Vec<GroupId> = self
272                        .sim
273                        .groups
274                        .iter()
275                        .filter(|g| g.stop_entities().contains(&self.origin))
276                        .map(ElevatorGroup::id)
277                        .collect();
278                    let destination_groups: Vec<GroupId> = self
279                        .sim
280                        .groups
281                        .iter()
282                        .filter(|g| g.stop_entities().contains(&self.destination))
283                        .map(ElevatorGroup::id)
284                        .collect();
285                    return Err(SimError::NoRoute {
286                        origin: self.origin,
287                        destination: self.destination,
288                        origin_groups,
289                        destination_groups,
290                    });
291                }
292                1 => Route::direct(self.origin, self.destination, matching[0]),
293                _ => {
294                    return Err(SimError::AmbiguousRoute {
295                        origin: self.origin,
296                        destination: self.destination,
297                        groups: matching,
298                    });
299                }
300            }
301        };
302
303        let eid = self
304            .sim
305            .spawn_rider_inner(self.origin, self.destination, self.weight, route);
306
307        // Apply optional components.
308        if let Some(max_wait) = self.patience {
309            self.sim.world.set_patience(
310                eid,
311                Patience {
312                    max_wait_ticks: max_wait,
313                    waited_ticks: 0,
314                },
315            );
316        }
317        if let Some(prefs) = self.preferences {
318            self.sim.world.set_preferences(eid, prefs);
319        }
320        if let Some(ac) = self.access_control {
321            self.sim.world.set_access_control(eid, ac);
322        }
323
324        Ok(eid)
325    }
326}
327
328/// The core simulation state, advanced by calling `step()`.
329pub struct Simulation {
330    /// The ECS world containing all entity data.
331    world: World,
332    /// Internal event bus — only holds events from the current tick.
333    events: EventBus,
334    /// Events from completed ticks, available to consumers via `drain_events()`.
335    pending_output: Vec<Event>,
336    /// Current simulation tick.
337    tick: u64,
338    /// Time delta per tick (seconds).
339    dt: f64,
340    /// Elevator groups in this simulation.
341    groups: Vec<ElevatorGroup>,
342    /// Config `StopId` to `EntityId` mapping for spawn helpers.
343    stop_lookup: HashMap<StopId, EntityId>,
344    /// Dispatch strategies keyed by group.
345    dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
346    /// Serializable strategy identifiers (for snapshot).
347    strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
348    /// Reposition strategies keyed by group (optional per group).
349    repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>>,
350    /// Serializable reposition strategy identifiers (for snapshot).
351    reposition_ids: BTreeMap<GroupId, BuiltinReposition>,
352    /// Aggregated metrics.
353    metrics: Metrics,
354    /// Time conversion utility.
355    time: TimeAdapter,
356    /// Lifecycle hooks (before/after each phase).
357    hooks: PhaseHooks,
358    /// Reusable buffer for elevator IDs (avoids per-tick allocation).
359    elevator_ids_buf: Vec<EntityId>,
360    /// Lazy-rebuilt connectivity graph for cross-line topology queries.
361    topo_graph: Mutex<TopologyGraph>,
362    /// Phase-partitioned reverse index for O(1) population queries.
363    rider_index: RiderIndex,
364}
365
366impl Simulation {
367    // ── Accessors ────────────────────────────────────────────────────
368
369    /// Get a shared reference to the world.
370    #[must_use]
371    pub const fn world(&self) -> &World {
372        &self.world
373    }
374
375    /// Get a mutable reference to the world.
376    ///
377    /// Exposed for advanced use cases (manual rider management, custom
378    /// component attachment). Prefer `spawn_rider` / `spawn_rider_by_stop_id`
379    /// for standard operations.
380    pub const fn world_mut(&mut self) -> &mut World {
381        &mut self.world
382    }
383
384    /// Current simulation tick.
385    #[must_use]
386    pub const fn current_tick(&self) -> u64 {
387        self.tick
388    }
389
390    /// Time delta per tick (seconds).
391    #[must_use]
392    pub const fn dt(&self) -> f64 {
393        self.dt
394    }
395
396    /// Interpolated position between the previous and current tick.
397    ///
398    /// `alpha` is clamped to `[0.0, 1.0]`, where `0.0` returns the entity's
399    /// position at the start of the last completed tick and `1.0` returns
400    /// the current position. Intended for smooth rendering when a render
401    /// frame falls between simulation ticks.
402    ///
403    /// Returns `None` if the entity has no position component. Returns the
404    /// current position unchanged if no previous snapshot exists (i.e. before
405    /// the first [`step`](Self::step)).
406    ///
407    /// [`step`]: Self::step
408    #[must_use]
409    pub fn position_at(&self, id: EntityId, alpha: f64) -> Option<f64> {
410        let current = self.world.position(id)?.value;
411        let alpha = if alpha.is_nan() {
412            0.0
413        } else {
414            alpha.clamp(0.0, 1.0)
415        };
416        let prev = self.world.prev_position(id).map_or(current, |p| p.value);
417        Some((current - prev).mul_add(alpha, prev))
418    }
419
420    /// Current velocity of an entity along the shaft axis (signed: +up, -down).
421    ///
422    /// Convenience wrapper over [`World::velocity`] that returns the raw
423    /// `f64` value. Returns `None` if the entity has no velocity component.
424    #[must_use]
425    pub fn velocity(&self, id: EntityId) -> Option<f64> {
426        self.world.velocity(id).map(Velocity::value)
427    }
428
429    /// Get current simulation metrics.
430    #[must_use]
431    pub const fn metrics(&self) -> &Metrics {
432        &self.metrics
433    }
434
435    /// The time adapter for tick↔wall-clock conversion.
436    #[must_use]
437    pub const fn time(&self) -> &TimeAdapter {
438        &self.time
439    }
440
441    /// Get the elevator groups.
442    #[must_use]
443    pub fn groups(&self) -> &[ElevatorGroup] {
444        &self.groups
445    }
446
447    /// Resolve a config `StopId` to its runtime `EntityId`.
448    #[must_use]
449    pub fn stop_entity(&self, id: StopId) -> Option<EntityId> {
450        self.stop_lookup.get(&id).copied()
451    }
452
453    /// Get the strategy identifier for a group.
454    #[must_use]
455    pub fn strategy_id(&self, group: GroupId) -> Option<&crate::dispatch::BuiltinStrategy> {
456        self.strategy_ids.get(&group)
457    }
458
459    /// Iterate over the stop ID → entity ID mapping.
460    pub fn stop_lookup_iter(&self) -> impl Iterator<Item = (&StopId, &EntityId)> {
461        self.stop_lookup.iter()
462    }
463
464    /// Peek at events pending for consumer retrieval.
465    #[must_use]
466    pub fn pending_events(&self) -> &[Event] {
467        &self.pending_output
468    }
469
470    // ── Destination queue (imperative dispatch) ────────────────────
471
472    /// Read-only view of an elevator's destination queue (FIFO of target
473    /// stop `EntityId`s).
474    ///
475    /// Returns `None` if `elev` is not an elevator entity. Returns
476    /// `Some(&[])` for elevators with an empty queue.
477    #[must_use]
478    pub fn destination_queue(&self, elev: EntityId) -> Option<&[EntityId]> {
479        self.world
480            .destination_queue(elev)
481            .map(crate::components::DestinationQueue::queue)
482    }
483
484    /// Push a stop onto the back of an elevator's destination queue.
485    ///
486    /// Adjacent duplicates are suppressed: if the last entry already equals
487    /// `stop`, the queue is unchanged and no event is emitted.
488    /// Otherwise emits [`Event::DestinationQueued`].
489    ///
490    /// # Errors
491    ///
492    /// - [`SimError::InvalidState`] if `elev` is not an elevator.
493    /// - [`SimError::InvalidState`] if `stop` is not a stop.
494    pub fn push_destination(&mut self, elev: EntityId, stop: EntityId) -> Result<(), SimError> {
495        self.validate_push_targets(elev, stop)?;
496        let appended = self
497            .world
498            .destination_queue_mut(elev)
499            .is_some_and(|q| q.push_back(stop));
500        if appended {
501            self.events.emit(Event::DestinationQueued {
502                elevator: elev,
503                stop,
504                tick: self.tick,
505            });
506        }
507        Ok(())
508    }
509
510    /// Insert a stop at the front of an elevator's destination queue —
511    /// "go here next, before anything else in the queue".
512    ///
513    /// On the next `AdvanceQueue` phase (between Dispatch and Movement),
514    /// the elevator redirects to this new front if it differs from the
515    /// current target.
516    ///
517    /// Adjacent duplicates are suppressed: if the first entry already equals
518    /// `stop`, the queue is unchanged and no event is emitted.
519    ///
520    /// # Errors
521    ///
522    /// - [`SimError::InvalidState`] if `elev` is not an elevator.
523    /// - [`SimError::InvalidState`] if `stop` is not a stop.
524    pub fn push_destination_front(
525        &mut self,
526        elev: EntityId,
527        stop: EntityId,
528    ) -> Result<(), SimError> {
529        self.validate_push_targets(elev, stop)?;
530        let inserted = self
531            .world
532            .destination_queue_mut(elev)
533            .is_some_and(|q| q.push_front(stop));
534        if inserted {
535            self.events.emit(Event::DestinationQueued {
536                elevator: elev,
537                stop,
538                tick: self.tick,
539            });
540        }
541        Ok(())
542    }
543
544    /// Clear an elevator's destination queue.
545    ///
546    /// TODO: clearing does not currently abort an in-flight movement — the
547    /// elevator will finish its current leg and then go idle (since the
548    /// queue is empty). A future change can add a phase transition to
549    /// cancel mid-flight.
550    ///
551    /// # Errors
552    ///
553    /// Returns [`SimError::InvalidState`] if `elev` is not an elevator.
554    pub fn clear_destinations(&mut self, elev: EntityId) -> Result<(), SimError> {
555        if self.world.elevator(elev).is_none() {
556            return Err(SimError::InvalidState {
557                entity: elev,
558                reason: "not an elevator".into(),
559            });
560        }
561        if let Some(q) = self.world.destination_queue_mut(elev) {
562            q.clear();
563        }
564        Ok(())
565    }
566
567    /// Validate that `elev` is an elevator and `stop` is a stop.
568    fn validate_push_targets(&self, elev: EntityId, stop: EntityId) -> Result<(), SimError> {
569        if self.world.elevator(elev).is_none() {
570            return Err(SimError::InvalidState {
571                entity: elev,
572                reason: "not an elevator".into(),
573            });
574        }
575        if self.world.stop(stop).is_none() {
576            return Err(SimError::InvalidState {
577                entity: stop,
578                reason: "not a stop".into(),
579            });
580        }
581        Ok(())
582    }
583
584    // ── ETA queries ─────────────────────────────────────────────────
585
586    /// Estimated time until `elev` arrives at `stop`, summing closed-form
587    /// trapezoidal travel time for every leg up to (and including) the leg
588    /// that ends at `stop`, plus the door dwell at every *intermediate* stop.
589    ///
590    /// "Arrival" is the moment the door cycle begins at `stop` — door time
591    /// at `stop` itself is **not** added; door time at earlier stops along
592    /// the route **is**.
593    ///
594    /// Returns `None` if:
595    /// - `elev` is not an elevator or `stop` is not a stop,
596    /// - the elevator's [`ServiceMode`](crate::components::ServiceMode) is
597    ///   dispatch-excluded (`Manual` / `Independent`), or
598    /// - `stop` is neither the elevator's current movement target nor anywhere
599    ///   in its [`destination_queue`](Self::destination_queue).
600    ///
601    /// The estimate is best-effort. It assumes the queue is served in order
602    /// with no mid-trip insertions; dispatch decisions, manual door commands,
603    /// and rider boarding/exiting beyond the configured dwell will perturb
604    /// the actual arrival.
605    #[must_use]
606    pub fn eta(&self, elev: EntityId, stop: EntityId) -> Option<Duration> {
607        let elevator = self.world.elevator(elev)?;
608        self.world.stop(stop)?;
609        let svc = self.world.service_mode(elev).copied().unwrap_or_default();
610        if svc.is_dispatch_excluded() {
611            return None;
612        }
613
614        // Build the route in service order: current target first (if any),
615        // then queue entries, with adjacent duplicates collapsed.
616        let mut route: Vec<EntityId> = Vec::new();
617        if let Some(t) = elevator.phase().moving_target() {
618            route.push(t);
619        }
620        if let Some(q) = self.world.destination_queue(elev) {
621            for &s in q.queue() {
622                if route.last() != Some(&s) {
623                    route.push(s);
624                }
625            }
626        }
627        if !route.contains(&stop) {
628            return None;
629        }
630
631        let max_speed = elevator.max_speed();
632        let accel = elevator.acceleration();
633        let decel = elevator.deceleration();
634        let door_cycle_ticks =
635            u64::from(elevator.door_transition_ticks()) * 2 + u64::from(elevator.door_open_ticks());
636        let door_cycle_secs = (door_cycle_ticks as f64) * self.dt;
637
638        // Account for any in-progress door cycle before the first travel leg:
639        // the elevator is parked at its current stop and won't move until the
640        // door FSM returns to Closed.
641        let mut total = match elevator.door() {
642            crate::door::DoorState::Opening {
643                ticks_remaining,
644                open_duration,
645                close_duration,
646            } => f64::from(*ticks_remaining + *open_duration + *close_duration) * self.dt,
647            crate::door::DoorState::Open {
648                ticks_remaining,
649                close_duration,
650            } => f64::from(*ticks_remaining + *close_duration) * self.dt,
651            crate::door::DoorState::Closing { ticks_remaining } => {
652                f64::from(*ticks_remaining) * self.dt
653            }
654            crate::door::DoorState::Closed => 0.0,
655        };
656
657        let in_door_cycle = !matches!(elevator.door(), crate::door::DoorState::Closed);
658        let mut pos = self.world.position(elev)?.value;
659        let vel_signed = self.world.velocity(elev).map_or(0.0, Velocity::value);
660
661        for (idx, &s) in route.iter().enumerate() {
662            let Some(s_pos) = self.world.stop_position(s) else {
663                // A queued entry without a position can only mean the stop
664                // entity was despawned out from under us. Bail rather than
665                // returning a partial accumulation that would silently
666                // understate the ETA.
667                return None;
668            };
669            let dist = (s_pos - pos).abs();
670            // Only the first leg can carry initial velocity, and only if
671            // the car is already moving toward this stop and not stuck in
672            // a door cycle (which forces it to stop first).
673            let v0 = if idx == 0 && !in_door_cycle && vel_signed.abs() > f64::EPSILON {
674                let dir = (s_pos - pos).signum();
675                if dir * vel_signed > 0.0 {
676                    vel_signed.abs()
677                } else {
678                    0.0
679                }
680            } else {
681                0.0
682            };
683            total += crate::eta::travel_time(dist, v0, max_speed, accel, decel);
684            if s == stop {
685                return Some(Duration::from_secs_f64(total.max(0.0)));
686            }
687            total += door_cycle_secs;
688            pos = s_pos;
689        }
690        // `route.contains(&stop)` was true above, so the loop must hit `stop`.
691        // Fall through to `None` as a defensive backstop.
692        None
693    }
694
695    /// Best ETA to `stop` across all dispatch-eligible elevators, optionally
696    /// filtered by indicator-lamp [`Direction`](crate::components::Direction).
697    ///
698    /// Pass [`Direction::Either`](crate::components::Direction::Either) to
699    /// consider every car. Otherwise, only cars whose committed direction is
700    /// `Either` or matches the requested direction are considered — useful
701    /// for hall-call assignment ("which up-going car arrives first?").
702    ///
703    /// Returns the entity ID of the winning elevator and its ETA, or `None`
704    /// if no eligible car has `stop` queued.
705    #[must_use]
706    pub fn best_eta(
707        &self,
708        stop: EntityId,
709        direction: crate::components::Direction,
710    ) -> Option<(EntityId, Duration)> {
711        use crate::components::Direction;
712        self.world
713            .iter_elevators()
714            .filter_map(|(eid, _, elev)| {
715                let car_dir = elev.direction();
716                let direction_ok = match direction {
717                    Direction::Either => true,
718                    requested => car_dir == Direction::Either || car_dir == requested,
719                };
720                if !direction_ok {
721                    return None;
722                }
723                self.eta(eid, stop).map(|d| (eid, d))
724            })
725            .min_by_key(|(_, d)| *d)
726    }
727
728    // ── Runtime elevator upgrades ────────────────────────────────────
729    //
730    // Games that want to mutate elevator parameters at runtime (e.g.
731    // an RPG speed-upgrade purchase, a scripted capacity boost) go
732    // through these setters rather than poking `Elevator` directly via
733    // `world_mut()`. Each setter validates its input, updates the
734    // underlying component, and emits an [`Event::ElevatorUpgraded`]
735    // so game code can react without polling.
736    //
737    // ### Semantics
738    //
739    // - `max_speed`, `acceleration`, `deceleration`: applied on the next
740    //   movement integration step. The car's **current velocity is
741    //   preserved** — there is no instantaneous jerk. If `max_speed`
742    //   is lowered below the current velocity, the movement integrator
743    //   clamps velocity to the new cap on the next tick.
744    // - `weight_capacity`: applied immediately. If the new capacity is
745    //   below `current_load` the car ends up temporarily overweight —
746    //   no riders are ejected, but the next boarding pass will reject
747    //   any rider that would push the load further over the new cap.
748    // - `door_transition_ticks`, `door_open_ticks`: applied on the
749    //   **next** door cycle. An in-progress door transition keeps its
750    //   original timing, so setters never cause visual glitches.
751
752    /// Set the maximum travel speed for an elevator at runtime.
753    ///
754    /// The new value applies on the next movement integration step;
755    /// the car's current velocity is preserved (see the
756    /// [runtime upgrades section](crate#runtime-upgrades) of the crate
757    /// docs). If the new cap is below the current velocity, the movement
758    /// system clamps velocity down on the next tick.
759    ///
760    /// # Errors
761    ///
762    /// - [`SimError::InvalidState`] if `elevator` is not an elevator entity.
763    /// - [`SimError::InvalidConfig`] if `speed` is not a positive finite number.
764    ///
765    /// # Example
766    ///
767    /// ```
768    /// use elevator_core::prelude::*;
769    ///
770    /// let mut sim = SimulationBuilder::demo().build().unwrap();
771    /// let elev = sim.world().iter_elevators().next().unwrap().0;
772    /// sim.set_max_speed(elev, 4.0).unwrap();
773    /// assert_eq!(sim.world().elevator(elev).unwrap().max_speed(), 4.0);
774    /// ```
775    pub fn set_max_speed(&mut self, elevator: EntityId, speed: f64) -> Result<(), SimError> {
776        Self::validate_positive_finite_f64(speed, "elevators.max_speed")?;
777        let old = self.require_elevator(elevator)?.max_speed;
778        if let Some(car) = self.world.elevator_mut(elevator) {
779            car.max_speed = speed;
780        }
781        self.emit_upgrade(
782            elevator,
783            crate::events::UpgradeField::MaxSpeed,
784            crate::events::UpgradeValue::float(old),
785            crate::events::UpgradeValue::float(speed),
786        );
787        Ok(())
788    }
789
790    /// Set the acceleration rate for an elevator at runtime.
791    ///
792    /// See [`set_max_speed`](Self::set_max_speed) for the general
793    /// velocity-preservation rules that apply to kinematic setters.
794    ///
795    /// # Errors
796    ///
797    /// - [`SimError::InvalidState`] if `elevator` is not an elevator entity.
798    /// - [`SimError::InvalidConfig`] if `accel` is not a positive finite number.
799    ///
800    /// # Example
801    ///
802    /// ```
803    /// use elevator_core::prelude::*;
804    ///
805    /// let mut sim = SimulationBuilder::demo().build().unwrap();
806    /// let elev = sim.world().iter_elevators().next().unwrap().0;
807    /// sim.set_acceleration(elev, 3.0).unwrap();
808    /// assert_eq!(sim.world().elevator(elev).unwrap().acceleration(), 3.0);
809    /// ```
810    pub fn set_acceleration(&mut self, elevator: EntityId, accel: f64) -> Result<(), SimError> {
811        Self::validate_positive_finite_f64(accel, "elevators.acceleration")?;
812        let old = self.require_elevator(elevator)?.acceleration;
813        if let Some(car) = self.world.elevator_mut(elevator) {
814            car.acceleration = accel;
815        }
816        self.emit_upgrade(
817            elevator,
818            crate::events::UpgradeField::Acceleration,
819            crate::events::UpgradeValue::float(old),
820            crate::events::UpgradeValue::float(accel),
821        );
822        Ok(())
823    }
824
825    /// Set the deceleration rate for an elevator at runtime.
826    ///
827    /// See [`set_max_speed`](Self::set_max_speed) for the general
828    /// velocity-preservation rules that apply to kinematic setters.
829    ///
830    /// # Errors
831    ///
832    /// - [`SimError::InvalidState`] if `elevator` is not an elevator entity.
833    /// - [`SimError::InvalidConfig`] if `decel` is not a positive finite number.
834    ///
835    /// # Example
836    ///
837    /// ```
838    /// use elevator_core::prelude::*;
839    ///
840    /// let mut sim = SimulationBuilder::demo().build().unwrap();
841    /// let elev = sim.world().iter_elevators().next().unwrap().0;
842    /// sim.set_deceleration(elev, 3.5).unwrap();
843    /// assert_eq!(sim.world().elevator(elev).unwrap().deceleration(), 3.5);
844    /// ```
845    pub fn set_deceleration(&mut self, elevator: EntityId, decel: f64) -> Result<(), SimError> {
846        Self::validate_positive_finite_f64(decel, "elevators.deceleration")?;
847        let old = self.require_elevator(elevator)?.deceleration;
848        if let Some(car) = self.world.elevator_mut(elevator) {
849            car.deceleration = decel;
850        }
851        self.emit_upgrade(
852            elevator,
853            crate::events::UpgradeField::Deceleration,
854            crate::events::UpgradeValue::float(old),
855            crate::events::UpgradeValue::float(decel),
856        );
857        Ok(())
858    }
859
860    /// Set the weight capacity for an elevator at runtime.
861    ///
862    /// Applied immediately. If the new capacity is below the car's
863    /// current load the car is temporarily overweight; no riders are
864    /// ejected, but subsequent boarding attempts that would push load
865    /// further over the cap will be rejected as
866    /// [`RejectionReason::OverCapacity`](crate::error::RejectionReason::OverCapacity).
867    ///
868    /// # Errors
869    ///
870    /// - [`SimError::InvalidState`] if `elevator` is not an elevator entity.
871    /// - [`SimError::InvalidConfig`] if `capacity` is not a positive finite number.
872    ///
873    /// # Example
874    ///
875    /// ```
876    /// use elevator_core::prelude::*;
877    ///
878    /// let mut sim = SimulationBuilder::demo().build().unwrap();
879    /// let elev = sim.world().iter_elevators().next().unwrap().0;
880    /// sim.set_weight_capacity(elev, 1200.0).unwrap();
881    /// assert_eq!(sim.world().elevator(elev).unwrap().weight_capacity(), 1200.0);
882    /// ```
883    pub fn set_weight_capacity(
884        &mut self,
885        elevator: EntityId,
886        capacity: f64,
887    ) -> Result<(), SimError> {
888        Self::validate_positive_finite_f64(capacity, "elevators.weight_capacity")?;
889        let old = self.require_elevator(elevator)?.weight_capacity;
890        if let Some(car) = self.world.elevator_mut(elevator) {
891            car.weight_capacity = capacity;
892        }
893        self.emit_upgrade(
894            elevator,
895            crate::events::UpgradeField::WeightCapacity,
896            crate::events::UpgradeValue::float(old),
897            crate::events::UpgradeValue::float(capacity),
898        );
899        Ok(())
900    }
901
902    /// Set the door open/close transition duration for an elevator.
903    ///
904    /// Applied on the **next** door cycle — an in-progress transition
905    /// keeps its original timing to avoid visual glitches.
906    ///
907    /// # Errors
908    ///
909    /// - [`SimError::InvalidState`] if `elevator` is not an elevator entity.
910    /// - [`SimError::InvalidConfig`] if `ticks` is zero.
911    ///
912    /// # Example
913    ///
914    /// ```
915    /// use elevator_core::prelude::*;
916    ///
917    /// let mut sim = SimulationBuilder::demo().build().unwrap();
918    /// let elev = sim.world().iter_elevators().next().unwrap().0;
919    /// sim.set_door_transition_ticks(elev, 3).unwrap();
920    /// assert_eq!(sim.world().elevator(elev).unwrap().door_transition_ticks(), 3);
921    /// ```
922    pub fn set_door_transition_ticks(
923        &mut self,
924        elevator: EntityId,
925        ticks: u32,
926    ) -> Result<(), SimError> {
927        Self::validate_nonzero_u32(ticks, "elevators.door_transition_ticks")?;
928        let old = self.require_elevator(elevator)?.door_transition_ticks;
929        if let Some(car) = self.world.elevator_mut(elevator) {
930            car.door_transition_ticks = ticks;
931        }
932        self.emit_upgrade(
933            elevator,
934            crate::events::UpgradeField::DoorTransitionTicks,
935            crate::events::UpgradeValue::ticks(old),
936            crate::events::UpgradeValue::ticks(ticks),
937        );
938        Ok(())
939    }
940
941    /// Set how long doors hold fully open for an elevator.
942    ///
943    /// Applied on the **next** door cycle — a door that is currently
944    /// holding open will complete its original dwell before the new
945    /// value takes effect.
946    ///
947    /// # Errors
948    ///
949    /// - [`SimError::InvalidState`] if `elevator` is not an elevator entity.
950    /// - [`SimError::InvalidConfig`] if `ticks` is zero.
951    ///
952    /// # Example
953    ///
954    /// ```
955    /// use elevator_core::prelude::*;
956    ///
957    /// let mut sim = SimulationBuilder::demo().build().unwrap();
958    /// let elev = sim.world().iter_elevators().next().unwrap().0;
959    /// sim.set_door_open_ticks(elev, 20).unwrap();
960    /// assert_eq!(sim.world().elevator(elev).unwrap().door_open_ticks(), 20);
961    /// ```
962    pub fn set_door_open_ticks(&mut self, elevator: EntityId, ticks: u32) -> Result<(), SimError> {
963        Self::validate_nonzero_u32(ticks, "elevators.door_open_ticks")?;
964        let old = self.require_elevator(elevator)?.door_open_ticks;
965        if let Some(car) = self.world.elevator_mut(elevator) {
966            car.door_open_ticks = ticks;
967        }
968        self.emit_upgrade(
969            elevator,
970            crate::events::UpgradeField::DoorOpenTicks,
971            crate::events::UpgradeValue::ticks(old),
972            crate::events::UpgradeValue::ticks(ticks),
973        );
974        Ok(())
975    }
976
977    // ── Manual door control ──────────────────────────────────────────
978    //
979    // These methods let games drive door state directly — e.g. a
980    // cab-panel open/close button in a first-person game, or an RPG
981    // where the player *is* the elevator and decides when to cycle doors.
982    //
983    // Each method either applies the command immediately (if the car is
984    // in a matching door-FSM state) or queues it on the elevator for
985    // application at the next valid moment. This way games can call
986    // these any time without worrying about FSM timing, and get a clean
987    // success/failure split between "bad entity" and "bad moment".
988
989    /// Request the doors to open.
990    ///
991    /// Applied immediately if the car is stopped at a stop with closed
992    /// or closing doors; otherwise queued until the car next arrives.
993    /// A no-op if the doors are already open or opening.
994    ///
995    /// # Errors
996    ///
997    /// - [`SimError::InvalidState`] if `elevator` is not an elevator
998    ///   entity or is disabled.
999    ///
1000    /// # Example
1001    ///
1002    /// ```
1003    /// use elevator_core::prelude::*;
1004    ///
1005    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1006    /// let elev = sim.world().iter_elevators().next().unwrap().0;
1007    /// sim.request_door_open(elev).unwrap();
1008    /// ```
1009    pub fn request_door_open(&mut self, elevator: EntityId) -> Result<(), SimError> {
1010        self.require_enabled_elevator(elevator)?;
1011        self.enqueue_door_command(elevator, crate::door::DoorCommand::Open);
1012        Ok(())
1013    }
1014
1015    /// Request the doors to close now.
1016    ///
1017    /// Applied immediately if the doors are open or loading — forcing an
1018    /// early close — unless a rider is mid-boarding/exiting this car, in
1019    /// which case the close waits for the rider to finish. If doors are
1020    /// currently opening, the close queues and fires once fully open.
1021    ///
1022    /// # Errors
1023    ///
1024    /// - [`SimError::InvalidState`] if `elevator` is not an elevator
1025    ///   entity or is disabled.
1026    ///
1027    /// # Example
1028    ///
1029    /// ```
1030    /// use elevator_core::prelude::*;
1031    ///
1032    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1033    /// let elev = sim.world().iter_elevators().next().unwrap().0;
1034    /// sim.request_door_close(elev).unwrap();
1035    /// ```
1036    pub fn request_door_close(&mut self, elevator: EntityId) -> Result<(), SimError> {
1037        self.require_enabled_elevator(elevator)?;
1038        self.enqueue_door_command(elevator, crate::door::DoorCommand::Close);
1039        Ok(())
1040    }
1041
1042    /// Extend the doors' open dwell by `ticks`.
1043    ///
1044    /// Cumulative — two calls of 30 ticks each extend the dwell by 60
1045    /// ticks in total. If the doors aren't open yet, the hold is queued
1046    /// and applied when they next reach the fully-open state.
1047    ///
1048    /// # Errors
1049    ///
1050    /// - [`SimError::InvalidState`] if `elevator` is not an elevator
1051    ///   entity or is disabled.
1052    /// - [`SimError::InvalidConfig`] if `ticks` is zero.
1053    ///
1054    /// # Example
1055    ///
1056    /// ```
1057    /// use elevator_core::prelude::*;
1058    ///
1059    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1060    /// let elev = sim.world().iter_elevators().next().unwrap().0;
1061    /// sim.hold_door_open(elev, 30).unwrap();
1062    /// ```
1063    pub fn hold_door_open(&mut self, elevator: EntityId, ticks: u32) -> Result<(), SimError> {
1064        Self::validate_nonzero_u32(ticks, "hold_door_open.ticks")?;
1065        self.require_enabled_elevator(elevator)?;
1066        self.enqueue_door_command(elevator, crate::door::DoorCommand::HoldOpen { ticks });
1067        Ok(())
1068    }
1069
1070    /// Cancel any pending hold extension.
1071    ///
1072    /// If the base open timer has already elapsed the doors close on
1073    /// the next doors-phase tick.
1074    ///
1075    /// # Errors
1076    ///
1077    /// - [`SimError::InvalidState`] if `elevator` is not an elevator
1078    ///   entity or is disabled.
1079    ///
1080    /// # Example
1081    ///
1082    /// ```
1083    /// use elevator_core::prelude::*;
1084    ///
1085    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1086    /// let elev = sim.world().iter_elevators().next().unwrap().0;
1087    /// sim.hold_door_open(elev, 100).unwrap();
1088    /// sim.cancel_door_hold(elev).unwrap();
1089    /// ```
1090    pub fn cancel_door_hold(&mut self, elevator: EntityId) -> Result<(), SimError> {
1091        self.require_enabled_elevator(elevator)?;
1092        self.enqueue_door_command(elevator, crate::door::DoorCommand::CancelHold);
1093        Ok(())
1094    }
1095
1096    /// Set the target velocity for a manual-mode elevator.
1097    ///
1098    /// The velocity is clamped to the elevator's `[-max_speed, max_speed]`
1099    /// range after validation. The car ramps toward the target each tick
1100    /// using `acceleration` (speeding up, or starting from rest) or
1101    /// `deceleration` (slowing down, or reversing direction). Positive
1102    /// values command upward travel, negative values command downward travel.
1103    ///
1104    /// # Errors
1105    /// - Entity is not an elevator, or is disabled.
1106    /// - Elevator is not in [`ServiceMode::Manual`].
1107    /// - `velocity` is not finite (NaN or infinite).
1108    ///
1109    /// [`ServiceMode::Manual`]: crate::components::ServiceMode::Manual
1110    pub fn set_target_velocity(
1111        &mut self,
1112        elevator: EntityId,
1113        velocity: f64,
1114    ) -> Result<(), SimError> {
1115        self.require_enabled_elevator(elevator)?;
1116        self.require_manual_mode(elevator)?;
1117        if !velocity.is_finite() {
1118            return Err(SimError::InvalidConfig {
1119                field: "target_velocity",
1120                reason: format!("must be finite, got {velocity}"),
1121            });
1122        }
1123        let max = self
1124            .world
1125            .elevator(elevator)
1126            .map_or(f64::INFINITY, |c| c.max_speed);
1127        let clamped = velocity.clamp(-max, max);
1128        if let Some(car) = self.world.elevator_mut(elevator) {
1129            car.manual_target_velocity = Some(clamped);
1130        }
1131        self.events.emit(Event::ManualVelocityCommanded {
1132            elevator,
1133            target_velocity: Some(ordered_float::OrderedFloat(clamped)),
1134            tick: self.tick,
1135        });
1136        Ok(())
1137    }
1138
1139    /// Command an immediate stop on a manual-mode elevator.
1140    ///
1141    /// Sets the target velocity to zero; the car decelerates at its
1142    /// configured `deceleration` rate. Equivalent to
1143    /// `set_target_velocity(elevator, 0.0)` but emits a distinct
1144    /// [`Event::ManualVelocityCommanded`] with `None` payload so games can
1145    /// distinguish an emergency stop from a deliberate hold.
1146    ///
1147    /// # Errors
1148    /// Same as [`set_target_velocity`](Self::set_target_velocity), minus
1149    /// the finite-velocity check.
1150    pub fn emergency_stop(&mut self, elevator: EntityId) -> Result<(), SimError> {
1151        self.require_enabled_elevator(elevator)?;
1152        self.require_manual_mode(elevator)?;
1153        if let Some(car) = self.world.elevator_mut(elevator) {
1154            car.manual_target_velocity = Some(0.0);
1155        }
1156        self.events.emit(Event::ManualVelocityCommanded {
1157            elevator,
1158            target_velocity: None,
1159            tick: self.tick,
1160        });
1161        Ok(())
1162    }
1163
1164    /// Internal: require an elevator be in `ServiceMode::Manual`.
1165    fn require_manual_mode(&self, elevator: EntityId) -> Result<(), SimError> {
1166        let is_manual = self
1167            .world
1168            .service_mode(elevator)
1169            .is_some_and(|m| *m == crate::components::ServiceMode::Manual);
1170        if !is_manual {
1171            return Err(SimError::InvalidState {
1172                entity: elevator,
1173                reason: "elevator is not in ServiceMode::Manual".into(),
1174            });
1175        }
1176        Ok(())
1177    }
1178
1179    /// Internal: push a command onto the queue, collapsing adjacent
1180    /// duplicates, capping length, and emitting `DoorCommandQueued`.
1181    fn enqueue_door_command(&mut self, elevator: EntityId, command: crate::door::DoorCommand) {
1182        if let Some(car) = self.world.elevator_mut(elevator) {
1183            let q = &mut car.door_command_queue;
1184            // Collapse adjacent duplicates for idempotent commands
1185            // (Open/Close/CancelHold) — repeating them adds nothing.
1186            // HoldOpen is explicitly cumulative, so never collapsed.
1187            let collapse = matches!(
1188                command,
1189                crate::door::DoorCommand::Open
1190                    | crate::door::DoorCommand::Close
1191                    | crate::door::DoorCommand::CancelHold
1192            ) && q.last().copied() == Some(command);
1193            if !collapse {
1194                q.push(command);
1195                if q.len() > crate::components::DOOR_COMMAND_QUEUE_CAP {
1196                    q.remove(0);
1197                }
1198            }
1199        }
1200        self.events.emit(Event::DoorCommandQueued {
1201            elevator,
1202            command,
1203            tick: self.tick,
1204        });
1205    }
1206
1207    /// Internal: resolve an elevator entity that is not disabled.
1208    fn require_enabled_elevator(&self, elevator: EntityId) -> Result<(), SimError> {
1209        if self.world.elevator(elevator).is_none() {
1210            return Err(SimError::InvalidState {
1211                entity: elevator,
1212                reason: "not an elevator".into(),
1213            });
1214        }
1215        if self.world.is_disabled(elevator) {
1216            return Err(SimError::InvalidState {
1217                entity: elevator,
1218                reason: "elevator is disabled".into(),
1219            });
1220        }
1221        Ok(())
1222    }
1223
1224    /// Internal: resolve an elevator entity or return a clear error.
1225    fn require_elevator(
1226        &self,
1227        elevator: EntityId,
1228    ) -> Result<&crate::components::Elevator, SimError> {
1229        self.world
1230            .elevator(elevator)
1231            .ok_or_else(|| SimError::InvalidState {
1232                entity: elevator,
1233                reason: "not an elevator".into(),
1234            })
1235    }
1236
1237    /// Internal: positive-finite validator matching the construction-time
1238    /// error shape in `sim/construction.rs::validate_elevator_config`.
1239    fn validate_positive_finite_f64(value: f64, field: &'static str) -> Result<(), SimError> {
1240        if !value.is_finite() {
1241            return Err(SimError::InvalidConfig {
1242                field,
1243                reason: format!("must be finite, got {value}"),
1244            });
1245        }
1246        if value <= 0.0 {
1247            return Err(SimError::InvalidConfig {
1248                field,
1249                reason: format!("must be positive, got {value}"),
1250            });
1251        }
1252        Ok(())
1253    }
1254
1255    /// Internal: reject zero-tick timings.
1256    fn validate_nonzero_u32(value: u32, field: &'static str) -> Result<(), SimError> {
1257        if value == 0 {
1258            return Err(SimError::InvalidConfig {
1259                field,
1260                reason: "must be > 0".into(),
1261            });
1262        }
1263        Ok(())
1264    }
1265
1266    /// Internal: emit a single `ElevatorUpgraded` event for the current tick.
1267    fn emit_upgrade(
1268        &mut self,
1269        elevator: EntityId,
1270        field: crate::events::UpgradeField,
1271        old: crate::events::UpgradeValue,
1272        new: crate::events::UpgradeValue,
1273    ) {
1274        self.events.emit(Event::ElevatorUpgraded {
1275            elevator,
1276            field,
1277            old,
1278            new,
1279            tick: self.tick,
1280        });
1281    }
1282
1283    // Dispatch & reposition management live in `sim/construction.rs`.
1284
1285    // ── Tagging ──────────────────────────────────────────────────────
1286
1287    /// Attach a metric tag to an entity (rider, stop, elevator, etc.).
1288    ///
1289    /// Tags enable per-tag metric breakdowns. An entity can have multiple tags.
1290    /// Riders automatically inherit tags from their origin stop when spawned.
1291    pub fn tag_entity(&mut self, id: EntityId, tag: impl Into<String>) {
1292        if let Some(tags) = self
1293            .world
1294            .resource_mut::<crate::tagged_metrics::MetricTags>()
1295        {
1296            tags.tag(id, tag);
1297        }
1298    }
1299
1300    /// Remove a metric tag from an entity.
1301    pub fn untag_entity(&mut self, id: EntityId, tag: &str) {
1302        if let Some(tags) = self
1303            .world
1304            .resource_mut::<crate::tagged_metrics::MetricTags>()
1305        {
1306            tags.untag(id, tag);
1307        }
1308    }
1309
1310    /// Query the metric accumulator for a specific tag.
1311    #[must_use]
1312    pub fn metrics_for_tag(&self, tag: &str) -> Option<&crate::tagged_metrics::TaggedMetric> {
1313        self.world
1314            .resource::<crate::tagged_metrics::MetricTags>()
1315            .and_then(|tags| tags.metric(tag))
1316    }
1317
1318    /// List all registered metric tags.
1319    pub fn all_tags(&self) -> Vec<&str> {
1320        self.world
1321            .resource::<crate::tagged_metrics::MetricTags>()
1322            .map_or_else(Vec::new, |tags| tags.all_tags().collect())
1323    }
1324
1325    // ── Rider spawning ───────────────────────────────────────────────
1326
1327    /// Create a rider builder for fluent rider spawning.
1328    ///
1329    /// ```
1330    /// use elevator_core::prelude::*;
1331    ///
1332    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1333    /// let s0 = sim.stop_entity(StopId(0)).unwrap();
1334    /// let s1 = sim.stop_entity(StopId(1)).unwrap();
1335    /// let rider = sim.build_rider(s0, s1)
1336    ///     .weight(80.0)
1337    ///     .spawn()
1338    ///     .unwrap();
1339    /// ```
1340    pub const fn build_rider(
1341        &mut self,
1342        origin: EntityId,
1343        destination: EntityId,
1344    ) -> RiderBuilder<'_> {
1345        RiderBuilder {
1346            sim: self,
1347            origin,
1348            destination,
1349            weight: 75.0,
1350            group: None,
1351            route: None,
1352            patience: None,
1353            preferences: None,
1354            access_control: None,
1355        }
1356    }
1357
1358    /// Create a rider builder using config `StopId`s.
1359    ///
1360    /// # Errors
1361    ///
1362    /// Returns [`SimError::StopNotFound`] if either stop ID is unknown.
1363    ///
1364    /// ```
1365    /// use elevator_core::prelude::*;
1366    ///
1367    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1368    /// let rider = sim.build_rider_by_stop_id(StopId(0), StopId(1))
1369    ///     .unwrap()
1370    ///     .weight(80.0)
1371    ///     .spawn()
1372    ///     .unwrap();
1373    /// ```
1374    pub fn build_rider_by_stop_id(
1375        &mut self,
1376        origin: StopId,
1377        destination: StopId,
1378    ) -> Result<RiderBuilder<'_>, SimError> {
1379        let origin_eid = self
1380            .stop_lookup
1381            .get(&origin)
1382            .copied()
1383            .ok_or(SimError::StopNotFound(origin))?;
1384        let dest_eid = self
1385            .stop_lookup
1386            .get(&destination)
1387            .copied()
1388            .ok_or(SimError::StopNotFound(destination))?;
1389        Ok(RiderBuilder {
1390            sim: self,
1391            origin: origin_eid,
1392            destination: dest_eid,
1393            weight: 75.0,
1394            group: None,
1395            route: None,
1396            patience: None,
1397            preferences: None,
1398            access_control: None,
1399        })
1400    }
1401
1402    /// Spawn a rider at the given origin stop entity, headed to destination stop entity.
1403    ///
1404    /// Auto-detects the elevator group by finding groups that serve both origin
1405    /// and destination stops.
1406    ///
1407    /// # Errors
1408    ///
1409    /// Returns [`SimError::NoRoute`] if no group serves both stops.
1410    /// Returns [`SimError::AmbiguousRoute`] if multiple groups serve both stops.
1411    pub fn spawn_rider(
1412        &mut self,
1413        origin: EntityId,
1414        destination: EntityId,
1415        weight: f64,
1416    ) -> Result<EntityId, SimError> {
1417        let matching: Vec<GroupId> = self
1418            .groups
1419            .iter()
1420            .filter(|g| {
1421                g.stop_entities().contains(&origin) && g.stop_entities().contains(&destination)
1422            })
1423            .map(ElevatorGroup::id)
1424            .collect();
1425
1426        let group = match matching.len() {
1427            0 => {
1428                let origin_groups: Vec<GroupId> = self
1429                    .groups
1430                    .iter()
1431                    .filter(|g| g.stop_entities().contains(&origin))
1432                    .map(ElevatorGroup::id)
1433                    .collect();
1434                let destination_groups: Vec<GroupId> = self
1435                    .groups
1436                    .iter()
1437                    .filter(|g| g.stop_entities().contains(&destination))
1438                    .map(ElevatorGroup::id)
1439                    .collect();
1440                return Err(SimError::NoRoute {
1441                    origin,
1442                    destination,
1443                    origin_groups,
1444                    destination_groups,
1445                });
1446            }
1447            1 => matching[0],
1448            _ => {
1449                return Err(SimError::AmbiguousRoute {
1450                    origin,
1451                    destination,
1452                    groups: matching,
1453                });
1454            }
1455        };
1456
1457        let route = Route::direct(origin, destination, group);
1458        Ok(self.spawn_rider_inner(origin, destination, weight, route))
1459    }
1460
1461    /// Spawn a rider with an explicit route.
1462    ///
1463    /// Same as [`spawn_rider`](Self::spawn_rider) but uses the provided route
1464    /// instead of auto-detecting the group.
1465    ///
1466    /// # Errors
1467    ///
1468    /// Returns [`SimError::EntityNotFound`] if origin does not exist.
1469    /// Returns [`SimError::InvalidState`] if origin doesn't match the route's
1470    /// first leg `from`.
1471    pub fn spawn_rider_with_route(
1472        &mut self,
1473        origin: EntityId,
1474        destination: EntityId,
1475        weight: f64,
1476        route: Route,
1477    ) -> Result<EntityId, SimError> {
1478        if self.world.stop(origin).is_none() {
1479            return Err(SimError::EntityNotFound(origin));
1480        }
1481        if let Some(leg) = route.current()
1482            && leg.from != origin
1483        {
1484            return Err(SimError::InvalidState {
1485                entity: origin,
1486                reason: format!(
1487                    "origin {origin:?} does not match route first leg from {:?}",
1488                    leg.from
1489                ),
1490            });
1491        }
1492        Ok(self.spawn_rider_inner(origin, destination, weight, route))
1493    }
1494
1495    /// Internal helper: spawn a rider entity with the given route.
1496    fn spawn_rider_inner(
1497        &mut self,
1498        origin: EntityId,
1499        destination: EntityId,
1500        weight: f64,
1501        route: Route,
1502    ) -> EntityId {
1503        let eid = self.world.spawn();
1504        self.world.set_rider(
1505            eid,
1506            Rider {
1507                weight,
1508                phase: RiderPhase::Waiting,
1509                current_stop: Some(origin),
1510                spawn_tick: self.tick,
1511                board_tick: None,
1512            },
1513        );
1514        self.world.set_route(eid, route);
1515        self.rider_index.insert_waiting(origin, eid);
1516        self.events.emit(Event::RiderSpawned {
1517            rider: eid,
1518            origin,
1519            destination,
1520            tick: self.tick,
1521        });
1522
1523        // Auto-tag the rider with "stop:{name}" for per-stop wait time tracking.
1524        let stop_tag = self
1525            .world
1526            .stop(origin)
1527            .map(|s| format!("stop:{}", s.name()));
1528
1529        // Inherit metric tags from the origin stop.
1530        if let Some(tags_res) = self
1531            .world
1532            .resource_mut::<crate::tagged_metrics::MetricTags>()
1533        {
1534            let origin_tags: Vec<String> = tags_res.tags_for(origin).to_vec();
1535            for tag in origin_tags {
1536                tags_res.tag(eid, tag);
1537            }
1538            // Apply the origin stop tag.
1539            if let Some(tag) = stop_tag {
1540                tags_res.tag(eid, tag);
1541            }
1542        }
1543
1544        eid
1545    }
1546
1547    /// Convenience: spawn a rider by config `StopId`.
1548    ///
1549    /// Returns `Err` if either stop ID is not found.
1550    ///
1551    /// # Errors
1552    ///
1553    /// Returns [`SimError::StopNotFound`] if the origin or destination stop ID
1554    /// is not in the building configuration.
1555    ///
1556    /// ```
1557    /// use elevator_core::prelude::*;
1558    ///
1559    /// // Default builder has StopId(0) and StopId(1).
1560    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1561    ///
1562    /// let rider = sim.spawn_rider_by_stop_id(StopId(0), StopId(1), 80.0).unwrap();
1563    /// sim.step(); // metrics are updated during the tick
1564    /// assert_eq!(sim.metrics().total_spawned(), 1);
1565    /// ```
1566    pub fn spawn_rider_by_stop_id(
1567        &mut self,
1568        origin: StopId,
1569        destination: StopId,
1570        weight: f64,
1571    ) -> Result<EntityId, SimError> {
1572        let origin_eid = self
1573            .stop_lookup
1574            .get(&origin)
1575            .copied()
1576            .ok_or(SimError::StopNotFound(origin))?;
1577        let dest_eid = self
1578            .stop_lookup
1579            .get(&destination)
1580            .copied()
1581            .ok_or(SimError::StopNotFound(destination))?;
1582        self.spawn_rider(origin_eid, dest_eid, weight)
1583    }
1584
1585    /// Spawn a rider using a specific group for routing.
1586    ///
1587    /// Like [`spawn_rider`](Self::spawn_rider) but skips auto-detection —
1588    /// uses the given group directly. Useful when the caller already knows
1589    /// the group, or to resolve an [`AmbiguousRoute`](crate::error::SimError::AmbiguousRoute).
1590    ///
1591    /// # Errors
1592    ///
1593    /// Returns [`SimError::GroupNotFound`] if the group does not exist.
1594    pub fn spawn_rider_in_group(
1595        &mut self,
1596        origin: EntityId,
1597        destination: EntityId,
1598        weight: f64,
1599        group: GroupId,
1600    ) -> Result<EntityId, SimError> {
1601        if !self.groups.iter().any(|g| g.id() == group) {
1602            return Err(SimError::GroupNotFound(group));
1603        }
1604        let route = Route::direct(origin, destination, group);
1605        Ok(self.spawn_rider_inner(origin, destination, weight, route))
1606    }
1607
1608    /// Convenience: spawn a rider by config `StopId` in a specific group.
1609    ///
1610    /// # Errors
1611    ///
1612    /// Returns [`SimError::StopNotFound`] if a stop ID is unknown, or
1613    /// [`SimError::GroupNotFound`] if the group does not exist.
1614    pub fn spawn_rider_in_group_by_stop_id(
1615        &mut self,
1616        origin: StopId,
1617        destination: StopId,
1618        weight: f64,
1619        group: GroupId,
1620    ) -> Result<EntityId, SimError> {
1621        let origin_eid = self
1622            .stop_lookup
1623            .get(&origin)
1624            .copied()
1625            .ok_or(SimError::StopNotFound(origin))?;
1626        let dest_eid = self
1627            .stop_lookup
1628            .get(&destination)
1629            .copied()
1630            .ok_or(SimError::StopNotFound(destination))?;
1631        self.spawn_rider_in_group(origin_eid, dest_eid, weight, group)
1632    }
1633
1634    /// Drain all pending events from completed ticks.
1635    ///
1636    /// Events emitted during `step()` (or per-phase methods) are buffered
1637    /// and made available here after `advance_tick()` is called.
1638    /// Events emitted outside the tick loop (e.g., `spawn_rider`, `disable`)
1639    /// are also included.
1640    ///
1641    /// ```
1642    /// use elevator_core::prelude::*;
1643    ///
1644    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1645    ///
1646    /// sim.spawn_rider_by_stop_id(StopId(0), StopId(1), 70.0).unwrap();
1647    /// sim.step();
1648    ///
1649    /// let events = sim.drain_events();
1650    /// assert!(!events.is_empty());
1651    /// ```
1652    pub fn drain_events(&mut self) -> Vec<Event> {
1653        // Flush any events still in the bus (from spawn_rider, disable, etc.)
1654        self.pending_output.extend(self.events.drain());
1655        std::mem::take(&mut self.pending_output)
1656    }
1657
1658    /// Drain only events matching a predicate.
1659    ///
1660    /// Events that don't match the predicate remain in the buffer
1661    /// and will be returned by future `drain_events` or
1662    /// `drain_events_where` calls.
1663    ///
1664    /// ```
1665    /// use elevator_core::prelude::*;
1666    ///
1667    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1668    /// sim.spawn_rider_by_stop_id(StopId(0), StopId(1), 70.0).unwrap();
1669    /// sim.step();
1670    ///
1671    /// let spawns: Vec<Event> = sim.drain_events_where(|e| {
1672    ///     matches!(e, Event::RiderSpawned { .. })
1673    /// });
1674    /// ```
1675    pub fn drain_events_where(&mut self, predicate: impl Fn(&Event) -> bool) -> Vec<Event> {
1676        // Flush bus into pending_output first.
1677        self.pending_output.extend(self.events.drain());
1678
1679        let mut matched = Vec::new();
1680        let mut remaining = Vec::new();
1681        for event in std::mem::take(&mut self.pending_output) {
1682            if predicate(&event) {
1683                matched.push(event);
1684            } else {
1685                remaining.push(event);
1686            }
1687        }
1688        self.pending_output = remaining;
1689        matched
1690    }
1691
1692    // ── Sub-stepping ────────────────────────────────────────────────
1693
1694    /// Get the dispatch strategies map (for advanced sub-stepping).
1695    #[must_use]
1696    pub fn dispatchers(&self) -> &BTreeMap<GroupId, Box<dyn DispatchStrategy>> {
1697        &self.dispatchers
1698    }
1699
1700    /// Get the dispatch strategies map mutably (for advanced sub-stepping).
1701    pub fn dispatchers_mut(&mut self) -> &mut BTreeMap<GroupId, Box<dyn DispatchStrategy>> {
1702        &mut self.dispatchers
1703    }
1704
1705    /// Get a mutable reference to the event bus.
1706    pub const fn events_mut(&mut self) -> &mut EventBus {
1707        &mut self.events
1708    }
1709
1710    /// Get a mutable reference to the metrics.
1711    pub const fn metrics_mut(&mut self) -> &mut Metrics {
1712        &mut self.metrics
1713    }
1714
1715    /// Build the `PhaseContext` for the current tick.
1716    #[must_use]
1717    pub const fn phase_context(&self) -> PhaseContext {
1718        PhaseContext {
1719            tick: self.tick,
1720            dt: self.dt,
1721        }
1722    }
1723
1724    /// Run only the `advance_transient` phase (with hooks).
1725    pub fn run_advance_transient(&mut self) {
1726        self.hooks
1727            .run_before(Phase::AdvanceTransient, &mut self.world);
1728        for group in &self.groups {
1729            self.hooks
1730                .run_before_group(Phase::AdvanceTransient, group.id(), &mut self.world);
1731        }
1732        let ctx = self.phase_context();
1733        crate::systems::advance_transient::run(
1734            &mut self.world,
1735            &mut self.events,
1736            &ctx,
1737            &mut self.rider_index,
1738        );
1739        for group in &self.groups {
1740            self.hooks
1741                .run_after_group(Phase::AdvanceTransient, group.id(), &mut self.world);
1742        }
1743        self.hooks
1744            .run_after(Phase::AdvanceTransient, &mut self.world);
1745    }
1746
1747    /// Run only the dispatch phase (with hooks).
1748    pub fn run_dispatch(&mut self) {
1749        self.hooks.run_before(Phase::Dispatch, &mut self.world);
1750        for group in &self.groups {
1751            self.hooks
1752                .run_before_group(Phase::Dispatch, group.id(), &mut self.world);
1753        }
1754        let ctx = self.phase_context();
1755        crate::systems::dispatch::run(
1756            &mut self.world,
1757            &mut self.events,
1758            &ctx,
1759            &self.groups,
1760            &mut self.dispatchers,
1761            &self.rider_index,
1762        );
1763        for group in &self.groups {
1764            self.hooks
1765                .run_after_group(Phase::Dispatch, group.id(), &mut self.world);
1766        }
1767        self.hooks.run_after(Phase::Dispatch, &mut self.world);
1768    }
1769
1770    /// Run only the movement phase (with hooks).
1771    pub fn run_movement(&mut self) {
1772        self.hooks.run_before(Phase::Movement, &mut self.world);
1773        for group in &self.groups {
1774            self.hooks
1775                .run_before_group(Phase::Movement, group.id(), &mut self.world);
1776        }
1777        let ctx = self.phase_context();
1778        self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1779        crate::systems::movement::run(
1780            &mut self.world,
1781            &mut self.events,
1782            &ctx,
1783            &self.elevator_ids_buf,
1784            &mut self.metrics,
1785        );
1786        for group in &self.groups {
1787            self.hooks
1788                .run_after_group(Phase::Movement, group.id(), &mut self.world);
1789        }
1790        self.hooks.run_after(Phase::Movement, &mut self.world);
1791    }
1792
1793    /// Run only the doors phase (with hooks).
1794    pub fn run_doors(&mut self) {
1795        self.hooks.run_before(Phase::Doors, &mut self.world);
1796        for group in &self.groups {
1797            self.hooks
1798                .run_before_group(Phase::Doors, group.id(), &mut self.world);
1799        }
1800        let ctx = self.phase_context();
1801        self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1802        crate::systems::doors::run(
1803            &mut self.world,
1804            &mut self.events,
1805            &ctx,
1806            &self.elevator_ids_buf,
1807        );
1808        for group in &self.groups {
1809            self.hooks
1810                .run_after_group(Phase::Doors, group.id(), &mut self.world);
1811        }
1812        self.hooks.run_after(Phase::Doors, &mut self.world);
1813    }
1814
1815    /// Run only the loading phase (with hooks).
1816    pub fn run_loading(&mut self) {
1817        self.hooks.run_before(Phase::Loading, &mut self.world);
1818        for group in &self.groups {
1819            self.hooks
1820                .run_before_group(Phase::Loading, group.id(), &mut self.world);
1821        }
1822        let ctx = self.phase_context();
1823        self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1824        crate::systems::loading::run(
1825            &mut self.world,
1826            &mut self.events,
1827            &ctx,
1828            &self.elevator_ids_buf,
1829            &mut self.rider_index,
1830        );
1831        for group in &self.groups {
1832            self.hooks
1833                .run_after_group(Phase::Loading, group.id(), &mut self.world);
1834        }
1835        self.hooks.run_after(Phase::Loading, &mut self.world);
1836    }
1837
1838    /// Run only the advance-queue phase (with hooks).
1839    ///
1840    /// Reconciles each elevator's phase/target with the front of its
1841    /// [`DestinationQueue`](crate::components::DestinationQueue). Runs
1842    /// between Reposition and Movement.
1843    pub fn run_advance_queue(&mut self) {
1844        self.hooks.run_before(Phase::AdvanceQueue, &mut self.world);
1845        for group in &self.groups {
1846            self.hooks
1847                .run_before_group(Phase::AdvanceQueue, group.id(), &mut self.world);
1848        }
1849        let ctx = self.phase_context();
1850        self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1851        crate::systems::advance_queue::run(
1852            &mut self.world,
1853            &mut self.events,
1854            &ctx,
1855            &self.elevator_ids_buf,
1856        );
1857        for group in &self.groups {
1858            self.hooks
1859                .run_after_group(Phase::AdvanceQueue, group.id(), &mut self.world);
1860        }
1861        self.hooks.run_after(Phase::AdvanceQueue, &mut self.world);
1862    }
1863
1864    /// Run only the reposition phase (with hooks).
1865    ///
1866    /// Only runs if at least one group has a [`RepositionStrategy`] configured.
1867    /// Idle elevators with no pending dispatch assignment are repositioned
1868    /// according to their group's strategy.
1869    pub fn run_reposition(&mut self) {
1870        if self.repositioners.is_empty() {
1871            return;
1872        }
1873        self.hooks.run_before(Phase::Reposition, &mut self.world);
1874        // Only run per-group hooks for groups that have a repositioner.
1875        for group in &self.groups {
1876            if self.repositioners.contains_key(&group.id()) {
1877                self.hooks
1878                    .run_before_group(Phase::Reposition, group.id(), &mut self.world);
1879            }
1880        }
1881        let ctx = self.phase_context();
1882        crate::systems::reposition::run(
1883            &mut self.world,
1884            &mut self.events,
1885            &ctx,
1886            &self.groups,
1887            &mut self.repositioners,
1888        );
1889        for group in &self.groups {
1890            if self.repositioners.contains_key(&group.id()) {
1891                self.hooks
1892                    .run_after_group(Phase::Reposition, group.id(), &mut self.world);
1893            }
1894        }
1895        self.hooks.run_after(Phase::Reposition, &mut self.world);
1896    }
1897
1898    /// Run the energy system (no hooks — inline phase).
1899    #[cfg(feature = "energy")]
1900    fn run_energy(&mut self) {
1901        let ctx = self.phase_context();
1902        self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1903        crate::systems::energy::run(
1904            &mut self.world,
1905            &mut self.events,
1906            &ctx,
1907            &self.elevator_ids_buf,
1908        );
1909    }
1910
1911    /// Run only the metrics phase (with hooks).
1912    pub fn run_metrics(&mut self) {
1913        self.hooks.run_before(Phase::Metrics, &mut self.world);
1914        for group in &self.groups {
1915            self.hooks
1916                .run_before_group(Phase::Metrics, group.id(), &mut self.world);
1917        }
1918        let ctx = self.phase_context();
1919        crate::systems::metrics::run(
1920            &mut self.world,
1921            &self.events,
1922            &mut self.metrics,
1923            &ctx,
1924            &self.groups,
1925        );
1926        for group in &self.groups {
1927            self.hooks
1928                .run_after_group(Phase::Metrics, group.id(), &mut self.world);
1929        }
1930        self.hooks.run_after(Phase::Metrics, &mut self.world);
1931    }
1932
1933    // Phase-hook registration lives in `sim/construction.rs`.
1934
1935    /// Increment the tick counter and flush events to the output buffer.
1936    ///
1937    /// Call after running all desired phases. Events emitted during this tick
1938    /// are moved to the output buffer and available via `drain_events()`.
1939    pub fn advance_tick(&mut self) {
1940        self.pending_output.extend(self.events.drain());
1941        self.tick += 1;
1942    }
1943
1944    /// Advance the simulation by one tick.
1945    ///
1946    /// Events from this tick are buffered internally and available via
1947    /// `drain_events()`. The metrics system only processes events from
1948    /// the current tick, regardless of whether the consumer drains them.
1949    ///
1950    /// ```
1951    /// use elevator_core::prelude::*;
1952    ///
1953    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1954    /// sim.step();
1955    /// assert_eq!(sim.current_tick(), 1);
1956    /// ```
1957    pub fn step(&mut self) {
1958        self.world.snapshot_prev_positions();
1959        self.run_advance_transient();
1960        self.run_dispatch();
1961        self.run_reposition();
1962        self.run_advance_queue();
1963        self.run_movement();
1964        self.run_doors();
1965        self.run_loading();
1966        #[cfg(feature = "energy")]
1967        self.run_energy();
1968        self.run_metrics();
1969        self.advance_tick();
1970    }
1971}
1972
1973impl fmt::Debug for Simulation {
1974    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1975        f.debug_struct("Simulation")
1976            .field("tick", &self.tick)
1977            .field("dt", &self.dt)
1978            .field("groups", &self.groups.len())
1979            .field("entities", &self.world.entity_count())
1980            .finish_non_exhaustive()
1981    }
1982}