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()`](crate::sim::Simulation::spawn_rider)
27//!   — simple origin/destination/weight spawn (accepts `EntityId` or `StopId`).
28//! - [`Simulation::build_rider()`](crate::sim::Simulation::build_rider)
29//!   — fluent [`RiderBuilder`](crate::sim::RiderBuilder) for patience, preferences, access
30//!   control, explicit groups, multi-leg routes (accepts `EntityId` or `StopId`).
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, Orientation, Patience, Preferences, Rider, RiderPhase, Route, SpatialPosition,
72    Velocity,
73};
74use crate::dispatch::{BuiltinReposition, DispatchStrategy, ElevatorGroup, RepositionStrategy};
75use crate::entity::EntityId;
76use crate::error::{EtaError, 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, StopRef};
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<SpatialPosition>,
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`].
166///
167/// ```
168/// use elevator_core::prelude::*;
169///
170/// let mut sim = SimulationBuilder::demo().build().unwrap();
171/// let rider = sim.build_rider(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    /// Returns [`SimError::RouteOriginMismatch`] if an explicit route's first leg
249    /// does not start at `origin`.
250    pub fn spawn(self) -> Result<EntityId, SimError> {
251        let route = if let Some(route) = self.route {
252            // Validate route origin matches the spawn origin.
253            if let Some(leg) = route.current()
254                && leg.from != self.origin
255            {
256                return Err(SimError::RouteOriginMismatch {
257                    expected_origin: self.origin,
258                    route_origin: leg.from,
259                });
260            }
261            route
262        } else if let Some(group) = self.group {
263            if !self.sim.groups.iter().any(|g| g.id() == group) {
264                return Err(SimError::GroupNotFound(group));
265            }
266            Route::direct(self.origin, self.destination, group)
267        } else {
268            // Auto-detect group (same logic as spawn_rider).
269            let matching: Vec<GroupId> = self
270                .sim
271                .groups
272                .iter()
273                .filter(|g| {
274                    g.stop_entities().contains(&self.origin)
275                        && g.stop_entities().contains(&self.destination)
276                })
277                .map(ElevatorGroup::id)
278                .collect();
279
280            match matching.len() {
281                0 => {
282                    let origin_groups: Vec<GroupId> = self
283                        .sim
284                        .groups
285                        .iter()
286                        .filter(|g| g.stop_entities().contains(&self.origin))
287                        .map(ElevatorGroup::id)
288                        .collect();
289                    let destination_groups: Vec<GroupId> = self
290                        .sim
291                        .groups
292                        .iter()
293                        .filter(|g| g.stop_entities().contains(&self.destination))
294                        .map(ElevatorGroup::id)
295                        .collect();
296                    return Err(SimError::NoRoute {
297                        origin: self.origin,
298                        destination: self.destination,
299                        origin_groups,
300                        destination_groups,
301                    });
302                }
303                1 => Route::direct(self.origin, self.destination, matching[0]),
304                _ => {
305                    return Err(SimError::AmbiguousRoute {
306                        origin: self.origin,
307                        destination: self.destination,
308                        groups: matching,
309                    });
310                }
311            }
312        };
313
314        let eid = self
315            .sim
316            .spawn_rider_inner(self.origin, self.destination, self.weight, route);
317
318        // Apply optional components.
319        if let Some(max_wait) = self.patience {
320            self.sim.world.set_patience(
321                eid,
322                Patience {
323                    max_wait_ticks: max_wait,
324                    waited_ticks: 0,
325                },
326            );
327        }
328        if let Some(prefs) = self.preferences {
329            self.sim.world.set_preferences(eid, prefs);
330        }
331        if let Some(ac) = self.access_control {
332            self.sim.world.set_access_control(eid, ac);
333        }
334
335        Ok(eid)
336    }
337}
338
339/// The core simulation state, advanced by calling `step()`.
340pub struct Simulation {
341    /// The ECS world containing all entity data.
342    world: World,
343    /// Internal event bus — only holds events from the current tick.
344    events: EventBus,
345    /// Events from completed ticks, available to consumers via `drain_events()`.
346    pending_output: Vec<Event>,
347    /// Current simulation tick.
348    tick: u64,
349    /// Time delta per tick (seconds).
350    dt: f64,
351    /// Elevator groups in this simulation.
352    groups: Vec<ElevatorGroup>,
353    /// Config `StopId` to `EntityId` mapping for spawn helpers.
354    stop_lookup: HashMap<StopId, EntityId>,
355    /// Dispatch strategies keyed by group.
356    dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
357    /// Serializable strategy identifiers (for snapshot).
358    strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
359    /// Reposition strategies keyed by group (optional per group).
360    repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>>,
361    /// Serializable reposition strategy identifiers (for snapshot).
362    reposition_ids: BTreeMap<GroupId, BuiltinReposition>,
363    /// Aggregated metrics.
364    metrics: Metrics,
365    /// Time conversion utility.
366    time: TimeAdapter,
367    /// Lifecycle hooks (before/after each phase).
368    hooks: PhaseHooks,
369    /// Reusable buffer for elevator IDs (avoids per-tick allocation).
370    elevator_ids_buf: Vec<EntityId>,
371    /// Lazy-rebuilt connectivity graph for cross-line topology queries.
372    topo_graph: Mutex<TopologyGraph>,
373    /// Phase-partitioned reverse index for O(1) population queries.
374    rider_index: RiderIndex,
375}
376
377impl Simulation {
378    // ── Accessors ────────────────────────────────────────────────────
379
380    /// Get a shared reference to the world.
381    //
382    // Intentionally non-`const`: a `const` qualifier on a runtime accessor
383    // signals "usable in const context", which these methods are not in
384    // practice (the `World` is heap-allocated and mutated). Marking them
385    // `const` misleads readers without unlocking any call sites.
386    #[must_use]
387    #[allow(clippy::missing_const_for_fn)]
388    pub fn world(&self) -> &World {
389        &self.world
390    }
391
392    /// Get a mutable reference to the world.
393    ///
394    /// Exposed for advanced use cases (manual rider management, custom
395    /// component attachment). Prefer `spawn_rider` / `build_rider`
396    /// for standard operations.
397    #[allow(clippy::missing_const_for_fn)]
398    pub fn world_mut(&mut self) -> &mut World {
399        &mut self.world
400    }
401
402    /// Current simulation tick.
403    #[must_use]
404    pub const fn current_tick(&self) -> u64 {
405        self.tick
406    }
407
408    /// Time delta per tick (seconds).
409    #[must_use]
410    pub const fn dt(&self) -> f64 {
411        self.dt
412    }
413
414    /// Interpolated position between the previous and current tick.
415    ///
416    /// `alpha` is clamped to `[0.0, 1.0]`, where `0.0` returns the entity's
417    /// position at the start of the last completed tick and `1.0` returns
418    /// the current position. Intended for smooth rendering when a render
419    /// frame falls between simulation ticks.
420    ///
421    /// Returns `None` if the entity has no position component. Returns the
422    /// current position unchanged if no previous snapshot exists (i.e. before
423    /// the first [`step`](Self::step)).
424    ///
425    /// [`step`]: Self::step
426    #[must_use]
427    pub fn position_at(&self, id: EntityId, alpha: f64) -> Option<f64> {
428        let current = self.world.position(id)?.value;
429        let alpha = if alpha.is_nan() {
430            0.0
431        } else {
432            alpha.clamp(0.0, 1.0)
433        };
434        let prev = self.world.prev_position(id).map_or(current, |p| p.value);
435        Some((current - prev).mul_add(alpha, prev))
436    }
437
438    /// Current velocity of an entity along the shaft axis (signed: +up, -down).
439    ///
440    /// Convenience wrapper over [`World::velocity`] that returns the raw
441    /// `f64` value. Returns `None` if the entity has no velocity component.
442    #[must_use]
443    pub fn velocity(&self, id: EntityId) -> Option<f64> {
444        self.world.velocity(id).map(Velocity::value)
445    }
446
447    /// Get current simulation metrics.
448    #[must_use]
449    pub const fn metrics(&self) -> &Metrics {
450        &self.metrics
451    }
452
453    /// The time adapter for tick↔wall-clock conversion.
454    #[must_use]
455    pub const fn time(&self) -> &TimeAdapter {
456        &self.time
457    }
458
459    /// Get the elevator groups.
460    #[must_use]
461    pub fn groups(&self) -> &[ElevatorGroup] {
462        &self.groups
463    }
464
465    /// Mutable access to the group collection. Use this to flip a group
466    /// into [`HallCallMode::Destination`](crate::dispatch::HallCallMode)
467    /// or tune its `ack_latency_ticks` after construction. Changing the
468    /// line/elevator structure here is not supported — use the dedicated
469    /// topology mutators for that.
470    pub fn groups_mut(&mut self) -> &mut [ElevatorGroup] {
471        &mut self.groups
472    }
473
474    /// Resolve a config `StopId` to its runtime `EntityId`.
475    #[must_use]
476    pub fn stop_entity(&self, id: StopId) -> Option<EntityId> {
477        self.stop_lookup.get(&id).copied()
478    }
479
480    /// Resolve a [`StopRef`] to its runtime [`EntityId`].
481    fn resolve_stop(&self, stop: StopRef) -> Result<EntityId, SimError> {
482        match stop {
483            StopRef::ByEntity(id) => Ok(id),
484            StopRef::ById(sid) => self.stop_entity(sid).ok_or(SimError::StopNotFound(sid)),
485        }
486    }
487
488    /// Get the strategy identifier for a group.
489    #[must_use]
490    pub fn strategy_id(&self, group: GroupId) -> Option<&crate::dispatch::BuiltinStrategy> {
491        self.strategy_ids.get(&group)
492    }
493
494    /// Iterate over the stop ID → entity ID mapping.
495    pub fn stop_lookup_iter(&self) -> impl Iterator<Item = (&StopId, &EntityId)> {
496        self.stop_lookup.iter()
497    }
498
499    /// Peek at events pending for consumer retrieval.
500    #[must_use]
501    pub fn pending_events(&self) -> &[Event] {
502        &self.pending_output
503    }
504
505    // ── Destination queue (imperative dispatch) ────────────────────
506
507    /// Read-only view of an elevator's destination queue (FIFO of target
508    /// stop `EntityId`s).
509    ///
510    /// Returns `None` if `elev` is not an elevator entity. Returns
511    /// `Some(&[])` for elevators with an empty queue.
512    #[must_use]
513    pub fn destination_queue(&self, elev: EntityId) -> Option<&[EntityId]> {
514        self.world
515            .destination_queue(elev)
516            .map(crate::components::DestinationQueue::queue)
517    }
518
519    /// Push a stop onto the back of an elevator's destination queue.
520    ///
521    /// Adjacent duplicates are suppressed: if the last entry already equals
522    /// `stop`, the queue is unchanged and no event is emitted.
523    /// Otherwise emits [`Event::DestinationQueued`].
524    ///
525    /// # Errors
526    ///
527    /// - [`SimError::NotAnElevator`] if `elev` is not an elevator.
528    /// - [`SimError::NotAStop`] if `stop` is not a stop.
529    pub fn push_destination(
530        &mut self,
531        elev: EntityId,
532        stop: impl Into<StopRef>,
533    ) -> Result<(), SimError> {
534        let stop = self.resolve_stop(stop.into())?;
535        self.validate_push_targets(elev, stop)?;
536        let appended = self
537            .world
538            .destination_queue_mut(elev)
539            .is_some_and(|q| q.push_back(stop));
540        if appended {
541            self.events.emit(Event::DestinationQueued {
542                elevator: elev,
543                stop,
544                tick: self.tick,
545            });
546        }
547        Ok(())
548    }
549
550    /// Insert a stop at the front of an elevator's destination queue —
551    /// "go here next, before anything else in the queue".
552    ///
553    /// On the next `AdvanceQueue` phase (between Dispatch and Movement),
554    /// the elevator redirects to this new front if it differs from the
555    /// current target.
556    ///
557    /// Adjacent duplicates are suppressed: if the first entry already equals
558    /// `stop`, the queue is unchanged and no event is emitted.
559    ///
560    /// # Errors
561    ///
562    /// - [`SimError::NotAnElevator`] if `elev` is not an elevator.
563    /// - [`SimError::NotAStop`] if `stop` is not a stop.
564    pub fn push_destination_front(
565        &mut self,
566        elev: EntityId,
567        stop: impl Into<StopRef>,
568    ) -> Result<(), SimError> {
569        let stop = self.resolve_stop(stop.into())?;
570        self.validate_push_targets(elev, stop)?;
571        let inserted = self
572            .world
573            .destination_queue_mut(elev)
574            .is_some_and(|q| q.push_front(stop));
575        if inserted {
576            self.events.emit(Event::DestinationQueued {
577                elevator: elev,
578                stop,
579                tick: self.tick,
580            });
581        }
582        Ok(())
583    }
584
585    /// Clear an elevator's destination queue.
586    ///
587    /// TODO: clearing does not currently abort an in-flight movement — the
588    /// elevator will finish its current leg and then go idle (since the
589    /// queue is empty). A future change can add a phase transition to
590    /// cancel mid-flight.
591    ///
592    /// # Errors
593    ///
594    /// Returns [`SimError::NotAnElevator`] if `elev` is not an elevator.
595    pub fn clear_destinations(&mut self, elev: EntityId) -> Result<(), SimError> {
596        if self.world.elevator(elev).is_none() {
597            return Err(SimError::NotAnElevator(elev));
598        }
599        if let Some(q) = self.world.destination_queue_mut(elev) {
600            q.clear();
601        }
602        Ok(())
603    }
604
605    /// Validate that `elev` is an elevator and `stop` is a stop.
606    fn validate_push_targets(&self, elev: EntityId, stop: EntityId) -> Result<(), SimError> {
607        if self.world.elevator(elev).is_none() {
608            return Err(SimError::NotAnElevator(elev));
609        }
610        if self.world.stop(stop).is_none() {
611            return Err(SimError::NotAStop(stop));
612        }
613        Ok(())
614    }
615
616    // ── ETA queries ─────────────────────────────────────────────────
617
618    /// Estimated time until `elev` arrives at `stop`, summing closed-form
619    /// trapezoidal travel time for every leg up to (and including) the leg
620    /// that ends at `stop`, plus the door dwell at every *intermediate* stop.
621    ///
622    /// "Arrival" is the moment the door cycle begins at `stop` — door time
623    /// at `stop` itself is **not** added; door time at earlier stops along
624    /// the route **is**.
625    ///
626    /// # Errors
627    ///
628    /// - [`EtaError::NotAnElevator`] if `elev` is not an elevator entity.
629    /// - [`EtaError::NotAStop`] if `stop` is not a stop entity.
630    /// - [`EtaError::ServiceModeExcluded`] if the elevator's
631    ///   [`ServiceMode`](crate::components::ServiceMode) is dispatch-excluded
632    ///   (`Manual` / `Independent`).
633    /// - [`EtaError::StopNotQueued`] if `stop` is neither the elevator's
634    ///   current movement target nor anywhere in its
635    ///   [`destination_queue`](Self::destination_queue).
636    /// - [`EtaError::StopVanished`] if a stop in the route lost its position
637    ///   during calculation.
638    ///
639    /// The estimate is best-effort. It assumes the queue is served in order
640    /// with no mid-trip insertions; dispatch decisions, manual door commands,
641    /// and rider boarding/exiting beyond the configured dwell will perturb
642    /// the actual arrival.
643    pub fn eta(&self, elev: EntityId, stop: EntityId) -> Result<Duration, EtaError> {
644        let elevator = self
645            .world
646            .elevator(elev)
647            .ok_or(EtaError::NotAnElevator(elev))?;
648        self.world.stop(stop).ok_or(EtaError::NotAStop(stop))?;
649        let svc = self.world.service_mode(elev).copied().unwrap_or_default();
650        if svc.is_dispatch_excluded() {
651            return Err(EtaError::ServiceModeExcluded(elev));
652        }
653
654        // Build the route in service order: current target first (if any),
655        // then queue entries, with adjacent duplicates collapsed.
656        let mut route: Vec<EntityId> = Vec::new();
657        if let Some(t) = elevator.phase().moving_target() {
658            route.push(t);
659        }
660        if let Some(q) = self.world.destination_queue(elev) {
661            for &s in q.queue() {
662                if route.last() != Some(&s) {
663                    route.push(s);
664                }
665            }
666        }
667        if !route.contains(&stop) {
668            return Err(EtaError::StopNotQueued {
669                elevator: elev,
670                stop,
671            });
672        }
673
674        let max_speed = elevator.max_speed();
675        let accel = elevator.acceleration();
676        let decel = elevator.deceleration();
677        let door_cycle_ticks =
678            u64::from(elevator.door_transition_ticks()) * 2 + u64::from(elevator.door_open_ticks());
679        let door_cycle_secs = (door_cycle_ticks as f64) * self.dt;
680
681        // Account for any in-progress door cycle before the first travel leg:
682        // the elevator is parked at its current stop and won't move until the
683        // door FSM returns to Closed.
684        let mut total = match elevator.door() {
685            crate::door::DoorState::Opening {
686                ticks_remaining,
687                open_duration,
688                close_duration,
689            } => f64::from(*ticks_remaining + *open_duration + *close_duration) * self.dt,
690            crate::door::DoorState::Open {
691                ticks_remaining,
692                close_duration,
693            } => f64::from(*ticks_remaining + *close_duration) * self.dt,
694            crate::door::DoorState::Closing { ticks_remaining } => {
695                f64::from(*ticks_remaining) * self.dt
696            }
697            crate::door::DoorState::Closed => 0.0,
698        };
699
700        let in_door_cycle = !matches!(elevator.door(), crate::door::DoorState::Closed);
701        let mut pos = self
702            .world
703            .position(elev)
704            .ok_or(EtaError::NotAnElevator(elev))?
705            .value;
706        let vel_signed = self.world.velocity(elev).map_or(0.0, Velocity::value);
707
708        for (idx, &s) in route.iter().enumerate() {
709            let s_pos = self
710                .world
711                .stop_position(s)
712                .ok_or(EtaError::StopVanished(s))?;
713            let dist = (s_pos - pos).abs();
714            // Only the first leg can carry initial velocity, and only if
715            // the car is already moving toward this stop and not stuck in
716            // a door cycle (which forces it to stop first).
717            let v0 = if idx == 0 && !in_door_cycle && vel_signed.abs() > f64::EPSILON {
718                let dir = (s_pos - pos).signum();
719                if dir * vel_signed > 0.0 {
720                    vel_signed.abs()
721                } else {
722                    0.0
723                }
724            } else {
725                0.0
726            };
727            total += crate::eta::travel_time(dist, v0, max_speed, accel, decel);
728            if s == stop {
729                return Ok(Duration::from_secs_f64(total.max(0.0)));
730            }
731            total += door_cycle_secs;
732            pos = s_pos;
733        }
734        // `route.contains(&stop)` was true above, so the loop must hit `stop`.
735        // Fall through as a defensive backstop.
736        Err(EtaError::StopNotQueued {
737            elevator: elev,
738            stop,
739        })
740    }
741
742    /// Best ETA to `stop` across all dispatch-eligible elevators, optionally
743    /// filtered by indicator-lamp [`Direction`](crate::components::Direction).
744    ///
745    /// Pass [`Direction::Either`](crate::components::Direction::Either) to
746    /// consider every car. Otherwise, only cars whose committed direction is
747    /// `Either` or matches the requested direction are considered — useful
748    /// for hall-call assignment ("which up-going car arrives first?").
749    ///
750    /// Returns the entity ID of the winning elevator and its ETA, or `None`
751    /// if no eligible car has `stop` queued.
752    #[must_use]
753    pub fn best_eta(
754        &self,
755        stop: impl Into<StopRef>,
756        direction: crate::components::Direction,
757    ) -> Option<(EntityId, Duration)> {
758        use crate::components::Direction;
759        let stop = self.resolve_stop(stop.into()).ok()?;
760        self.world
761            .iter_elevators()
762            .filter_map(|(eid, _, elev)| {
763                let car_dir = elev.direction();
764                let direction_ok = match direction {
765                    Direction::Either => true,
766                    requested => car_dir == Direction::Either || car_dir == requested,
767                };
768                if !direction_ok {
769                    return None;
770                }
771                self.eta(eid, stop).ok().map(|d| (eid, d))
772            })
773            .min_by_key(|(_, d)| *d)
774    }
775
776    // ── Runtime elevator upgrades ────────────────────────────────────
777    //
778    // Games that want to mutate elevator parameters at runtime (e.g.
779    // an RPG speed-upgrade purchase, a scripted capacity boost) go
780    // through these setters rather than poking `Elevator` directly via
781    // `world_mut()`. Each setter validates its input, updates the
782    // underlying component, and emits an [`Event::ElevatorUpgraded`]
783    // so game code can react without polling.
784    //
785    // ### Semantics
786    //
787    // - `max_speed`, `acceleration`, `deceleration`: applied on the next
788    //   movement integration step. The car's **current velocity is
789    //   preserved** — there is no instantaneous jerk. If `max_speed`
790    //   is lowered below the current velocity, the movement integrator
791    //   clamps velocity to the new cap on the next tick.
792    // - `weight_capacity`: applied immediately. If the new capacity is
793    //   below `current_load` the car ends up temporarily overweight —
794    //   no riders are ejected, but the next boarding pass will reject
795    //   any rider that would push the load further over the new cap.
796    // - `door_transition_ticks`, `door_open_ticks`: applied on the
797    //   **next** door cycle. An in-progress door transition keeps its
798    //   original timing, so setters never cause visual glitches.
799
800    /// Set the maximum travel speed for an elevator at runtime.
801    ///
802    /// The new value applies on the next movement integration step;
803    /// the car's current velocity is preserved (see the
804    /// [runtime upgrades section](crate#runtime-upgrades) of the crate
805    /// docs). If the new cap is below the current velocity, the movement
806    /// system clamps velocity down on the next tick.
807    ///
808    /// # Errors
809    ///
810    /// - [`SimError::NotAnElevator`] if `elevator` is not an elevator entity.
811    /// - [`SimError::InvalidConfig`] if `speed` is not a positive finite number.
812    ///
813    /// # Example
814    ///
815    /// ```
816    /// use elevator_core::prelude::*;
817    ///
818    /// let mut sim = SimulationBuilder::demo().build().unwrap();
819    /// let elev = sim.world().iter_elevators().next().unwrap().0;
820    /// sim.set_max_speed(elev, 4.0).unwrap();
821    /// assert_eq!(sim.world().elevator(elev).unwrap().max_speed(), 4.0);
822    /// ```
823    pub fn set_max_speed(&mut self, elevator: EntityId, speed: f64) -> Result<(), SimError> {
824        Self::validate_positive_finite_f64(speed, "elevators.max_speed")?;
825        let old = self.require_elevator(elevator)?.max_speed;
826        if let Some(car) = self.world.elevator_mut(elevator) {
827            car.max_speed = speed;
828        }
829        self.emit_upgrade(
830            elevator,
831            crate::events::UpgradeField::MaxSpeed,
832            crate::events::UpgradeValue::float(old),
833            crate::events::UpgradeValue::float(speed),
834        );
835        Ok(())
836    }
837
838    /// Set the acceleration rate for an elevator at runtime.
839    ///
840    /// See [`set_max_speed`](Self::set_max_speed) for the general
841    /// velocity-preservation rules that apply to kinematic setters.
842    ///
843    /// # Errors
844    ///
845    /// - [`SimError::NotAnElevator`] if `elevator` is not an elevator entity.
846    /// - [`SimError::InvalidConfig`] if `accel` is not a positive finite number.
847    ///
848    /// # Example
849    ///
850    /// ```
851    /// use elevator_core::prelude::*;
852    ///
853    /// let mut sim = SimulationBuilder::demo().build().unwrap();
854    /// let elev = sim.world().iter_elevators().next().unwrap().0;
855    /// sim.set_acceleration(elev, 3.0).unwrap();
856    /// assert_eq!(sim.world().elevator(elev).unwrap().acceleration(), 3.0);
857    /// ```
858    pub fn set_acceleration(&mut self, elevator: EntityId, accel: f64) -> Result<(), SimError> {
859        Self::validate_positive_finite_f64(accel, "elevators.acceleration")?;
860        let old = self.require_elevator(elevator)?.acceleration;
861        if let Some(car) = self.world.elevator_mut(elevator) {
862            car.acceleration = accel;
863        }
864        self.emit_upgrade(
865            elevator,
866            crate::events::UpgradeField::Acceleration,
867            crate::events::UpgradeValue::float(old),
868            crate::events::UpgradeValue::float(accel),
869        );
870        Ok(())
871    }
872
873    /// Set the deceleration rate for an elevator at runtime.
874    ///
875    /// See [`set_max_speed`](Self::set_max_speed) for the general
876    /// velocity-preservation rules that apply to kinematic setters.
877    ///
878    /// # Errors
879    ///
880    /// - [`SimError::NotAnElevator`] if `elevator` is not an elevator entity.
881    /// - [`SimError::InvalidConfig`] if `decel` is not a positive finite number.
882    ///
883    /// # Example
884    ///
885    /// ```
886    /// use elevator_core::prelude::*;
887    ///
888    /// let mut sim = SimulationBuilder::demo().build().unwrap();
889    /// let elev = sim.world().iter_elevators().next().unwrap().0;
890    /// sim.set_deceleration(elev, 3.5).unwrap();
891    /// assert_eq!(sim.world().elevator(elev).unwrap().deceleration(), 3.5);
892    /// ```
893    pub fn set_deceleration(&mut self, elevator: EntityId, decel: f64) -> Result<(), SimError> {
894        Self::validate_positive_finite_f64(decel, "elevators.deceleration")?;
895        let old = self.require_elevator(elevator)?.deceleration;
896        if let Some(car) = self.world.elevator_mut(elevator) {
897            car.deceleration = decel;
898        }
899        self.emit_upgrade(
900            elevator,
901            crate::events::UpgradeField::Deceleration,
902            crate::events::UpgradeValue::float(old),
903            crate::events::UpgradeValue::float(decel),
904        );
905        Ok(())
906    }
907
908    /// Set the weight capacity for an elevator at runtime.
909    ///
910    /// Applied immediately. If the new capacity is below the car's
911    /// current load the car is temporarily overweight; no riders are
912    /// ejected, but subsequent boarding attempts that would push load
913    /// further over the cap will be rejected as
914    /// [`RejectionReason::OverCapacity`](crate::error::RejectionReason::OverCapacity).
915    ///
916    /// # Errors
917    ///
918    /// - [`SimError::NotAnElevator`] if `elevator` is not an elevator entity.
919    /// - [`SimError::InvalidConfig`] if `capacity` is not a positive finite number.
920    ///
921    /// # Example
922    ///
923    /// ```
924    /// use elevator_core::prelude::*;
925    ///
926    /// let mut sim = SimulationBuilder::demo().build().unwrap();
927    /// let elev = sim.world().iter_elevators().next().unwrap().0;
928    /// sim.set_weight_capacity(elev, 1200.0).unwrap();
929    /// assert_eq!(sim.world().elevator(elev).unwrap().weight_capacity(), 1200.0);
930    /// ```
931    pub fn set_weight_capacity(
932        &mut self,
933        elevator: EntityId,
934        capacity: f64,
935    ) -> Result<(), SimError> {
936        Self::validate_positive_finite_f64(capacity, "elevators.weight_capacity")?;
937        let old = self.require_elevator(elevator)?.weight_capacity;
938        if let Some(car) = self.world.elevator_mut(elevator) {
939            car.weight_capacity = capacity;
940        }
941        self.emit_upgrade(
942            elevator,
943            crate::events::UpgradeField::WeightCapacity,
944            crate::events::UpgradeValue::float(old),
945            crate::events::UpgradeValue::float(capacity),
946        );
947        Ok(())
948    }
949
950    /// Set the door open/close transition duration for an elevator.
951    ///
952    /// Applied on the **next** door cycle — an in-progress transition
953    /// keeps its original timing to avoid visual glitches.
954    ///
955    /// # Errors
956    ///
957    /// - [`SimError::NotAnElevator`] if `elevator` is not an elevator entity.
958    /// - [`SimError::InvalidConfig`] if `ticks` is zero.
959    ///
960    /// # Example
961    ///
962    /// ```
963    /// use elevator_core::prelude::*;
964    ///
965    /// let mut sim = SimulationBuilder::demo().build().unwrap();
966    /// let elev = sim.world().iter_elevators().next().unwrap().0;
967    /// sim.set_door_transition_ticks(elev, 3).unwrap();
968    /// assert_eq!(sim.world().elevator(elev).unwrap().door_transition_ticks(), 3);
969    /// ```
970    pub fn set_door_transition_ticks(
971        &mut self,
972        elevator: EntityId,
973        ticks: u32,
974    ) -> Result<(), SimError> {
975        Self::validate_nonzero_u32(ticks, "elevators.door_transition_ticks")?;
976        let old = self.require_elevator(elevator)?.door_transition_ticks;
977        if let Some(car) = self.world.elevator_mut(elevator) {
978            car.door_transition_ticks = ticks;
979        }
980        self.emit_upgrade(
981            elevator,
982            crate::events::UpgradeField::DoorTransitionTicks,
983            crate::events::UpgradeValue::ticks(old),
984            crate::events::UpgradeValue::ticks(ticks),
985        );
986        Ok(())
987    }
988
989    /// Set how long doors hold fully open for an elevator.
990    ///
991    /// Applied on the **next** door cycle — a door that is currently
992    /// holding open will complete its original dwell before the new
993    /// value takes effect.
994    ///
995    /// # Errors
996    ///
997    /// - [`SimError::NotAnElevator`] if `elevator` is not an elevator entity.
998    /// - [`SimError::InvalidConfig`] if `ticks` is zero.
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.set_door_open_ticks(elev, 20).unwrap();
1008    /// assert_eq!(sim.world().elevator(elev).unwrap().door_open_ticks(), 20);
1009    /// ```
1010    pub fn set_door_open_ticks(&mut self, elevator: EntityId, ticks: u32) -> Result<(), SimError> {
1011        Self::validate_nonzero_u32(ticks, "elevators.door_open_ticks")?;
1012        let old = self.require_elevator(elevator)?.door_open_ticks;
1013        if let Some(car) = self.world.elevator_mut(elevator) {
1014            car.door_open_ticks = ticks;
1015        }
1016        self.emit_upgrade(
1017            elevator,
1018            crate::events::UpgradeField::DoorOpenTicks,
1019            crate::events::UpgradeValue::ticks(old),
1020            crate::events::UpgradeValue::ticks(ticks),
1021        );
1022        Ok(())
1023    }
1024
1025    // ── Manual door control ──────────────────────────────────────────
1026    //
1027    // These methods let games drive door state directly — e.g. a
1028    // cab-panel open/close button in a first-person game, or an RPG
1029    // where the player *is* the elevator and decides when to cycle doors.
1030    //
1031    // Each method either applies the command immediately (if the car is
1032    // in a matching door-FSM state) or queues it on the elevator for
1033    // application at the next valid moment. This way games can call
1034    // these any time without worrying about FSM timing, and get a clean
1035    // success/failure split between "bad entity" and "bad moment".
1036
1037    /// Request the doors to open.
1038    ///
1039    /// Applied immediately if the car is stopped at a stop with closed
1040    /// or closing doors; otherwise queued until the car next arrives.
1041    /// A no-op if the doors are already open or opening.
1042    ///
1043    /// # Errors
1044    ///
1045    /// - [`SimError::NotAnElevator`] if `elevator` is not an elevator entity.
1046    /// - [`SimError::ElevatorDisabled`] if the elevator is disabled.
1047    ///
1048    /// # Example
1049    ///
1050    /// ```
1051    /// use elevator_core::prelude::*;
1052    ///
1053    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1054    /// let elev = sim.world().iter_elevators().next().unwrap().0;
1055    /// sim.open_door(elev).unwrap();
1056    /// ```
1057    pub fn open_door(&mut self, elevator: EntityId) -> Result<(), SimError> {
1058        self.require_enabled_elevator(elevator)?;
1059        self.enqueue_door_command(elevator, crate::door::DoorCommand::Open);
1060        Ok(())
1061    }
1062
1063    /// Request the doors to close now.
1064    ///
1065    /// Applied immediately if the doors are open or loading — forcing an
1066    /// early close — unless a rider is mid-boarding/exiting this car, in
1067    /// which case the close waits for the rider to finish. If doors are
1068    /// currently opening, the close queues and fires once fully open.
1069    ///
1070    /// # Errors
1071    ///
1072    /// - [`SimError::NotAnElevator`] if `elevator` is not an elevator entity.
1073    /// - [`SimError::ElevatorDisabled`] if the elevator is disabled.
1074    ///
1075    /// # Example
1076    ///
1077    /// ```
1078    /// use elevator_core::prelude::*;
1079    ///
1080    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1081    /// let elev = sim.world().iter_elevators().next().unwrap().0;
1082    /// sim.close_door(elev).unwrap();
1083    /// ```
1084    pub fn close_door(&mut self, elevator: EntityId) -> Result<(), SimError> {
1085        self.require_enabled_elevator(elevator)?;
1086        self.enqueue_door_command(elevator, crate::door::DoorCommand::Close);
1087        Ok(())
1088    }
1089
1090    /// Extend the doors' open dwell by `ticks`.
1091    ///
1092    /// Cumulative — two calls of 30 ticks each extend the dwell by 60
1093    /// ticks in total. If the doors aren't open yet, the hold is queued
1094    /// and applied when they next reach the fully-open state.
1095    ///
1096    /// # Errors
1097    ///
1098    /// - [`SimError::NotAnElevator`] if `elevator` is not an elevator entity.
1099    /// - [`SimError::ElevatorDisabled`] if the elevator is disabled.
1100    /// - [`SimError::InvalidConfig`] if `ticks` is zero.
1101    ///
1102    /// # Example
1103    ///
1104    /// ```
1105    /// use elevator_core::prelude::*;
1106    ///
1107    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1108    /// let elev = sim.world().iter_elevators().next().unwrap().0;
1109    /// sim.hold_door(elev, 30).unwrap();
1110    /// ```
1111    pub fn hold_door(&mut self, elevator: EntityId, ticks: u32) -> Result<(), SimError> {
1112        Self::validate_nonzero_u32(ticks, "hold_door.ticks")?;
1113        self.require_enabled_elevator(elevator)?;
1114        self.enqueue_door_command(elevator, crate::door::DoorCommand::HoldOpen { ticks });
1115        Ok(())
1116    }
1117
1118    /// Cancel any pending hold extension.
1119    ///
1120    /// If the base open timer has already elapsed the doors close on
1121    /// the next doors-phase tick.
1122    ///
1123    /// # Errors
1124    ///
1125    /// - [`SimError::NotAnElevator`] if `elevator` is not an elevator entity.
1126    /// - [`SimError::ElevatorDisabled`] if the elevator is disabled.
1127    ///
1128    /// # Example
1129    ///
1130    /// ```
1131    /// use elevator_core::prelude::*;
1132    ///
1133    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1134    /// let elev = sim.world().iter_elevators().next().unwrap().0;
1135    /// sim.hold_door(elev, 100).unwrap();
1136    /// sim.cancel_door_hold(elev).unwrap();
1137    /// ```
1138    pub fn cancel_door_hold(&mut self, elevator: EntityId) -> Result<(), SimError> {
1139        self.require_enabled_elevator(elevator)?;
1140        self.enqueue_door_command(elevator, crate::door::DoorCommand::CancelHold);
1141        Ok(())
1142    }
1143
1144    /// Set the target velocity for a manual-mode elevator.
1145    ///
1146    /// The velocity is clamped to the elevator's `[-max_speed, max_speed]`
1147    /// range after validation. The car ramps toward the target each tick
1148    /// using `acceleration` (speeding up, or starting from rest) or
1149    /// `deceleration` (slowing down, or reversing direction). Positive
1150    /// values command upward travel, negative values command downward travel.
1151    ///
1152    /// # Errors
1153    /// - [`SimError::NotAnElevator`] if the entity is not an elevator.
1154    /// - [`SimError::ElevatorDisabled`] if the elevator is disabled.
1155    /// - [`SimError::WrongServiceMode`] if the elevator is not in [`ServiceMode::Manual`].
1156    /// - [`SimError::InvalidConfig`] if `velocity` is not finite (NaN or infinite).
1157    ///
1158    /// [`ServiceMode::Manual`]: crate::components::ServiceMode::Manual
1159    pub fn set_target_velocity(
1160        &mut self,
1161        elevator: EntityId,
1162        velocity: f64,
1163    ) -> Result<(), SimError> {
1164        self.require_enabled_elevator(elevator)?;
1165        self.require_manual_mode(elevator)?;
1166        if !velocity.is_finite() {
1167            return Err(SimError::InvalidConfig {
1168                field: "target_velocity",
1169                reason: format!("must be finite, got {velocity}"),
1170            });
1171        }
1172        let max = self
1173            .world
1174            .elevator(elevator)
1175            .map_or(f64::INFINITY, |c| c.max_speed);
1176        let clamped = velocity.clamp(-max, max);
1177        if let Some(car) = self.world.elevator_mut(elevator) {
1178            car.manual_target_velocity = Some(clamped);
1179        }
1180        self.events.emit(Event::ManualVelocityCommanded {
1181            elevator,
1182            target_velocity: Some(ordered_float::OrderedFloat(clamped)),
1183            tick: self.tick,
1184        });
1185        Ok(())
1186    }
1187
1188    /// Command an immediate stop on a manual-mode elevator.
1189    ///
1190    /// Sets the target velocity to zero; the car decelerates at its
1191    /// configured `deceleration` rate. Equivalent to
1192    /// `set_target_velocity(elevator, 0.0)` but emits a distinct
1193    /// [`Event::ManualVelocityCommanded`] with `None` payload so games can
1194    /// distinguish an emergency stop from a deliberate hold.
1195    ///
1196    /// # Errors
1197    /// Same as [`set_target_velocity`](Self::set_target_velocity), minus
1198    /// the finite-velocity check.
1199    pub fn emergency_stop(&mut self, elevator: EntityId) -> Result<(), SimError> {
1200        self.require_enabled_elevator(elevator)?;
1201        self.require_manual_mode(elevator)?;
1202        if let Some(car) = self.world.elevator_mut(elevator) {
1203            car.manual_target_velocity = Some(0.0);
1204        }
1205        self.events.emit(Event::ManualVelocityCommanded {
1206            elevator,
1207            target_velocity: None,
1208            tick: self.tick,
1209        });
1210        Ok(())
1211    }
1212
1213    /// Internal: require an elevator be in `ServiceMode::Manual`.
1214    fn require_manual_mode(&self, elevator: EntityId) -> Result<(), SimError> {
1215        let actual = self
1216            .world
1217            .service_mode(elevator)
1218            .copied()
1219            .unwrap_or_default();
1220        if actual != crate::components::ServiceMode::Manual {
1221            return Err(SimError::WrongServiceMode {
1222                entity: elevator,
1223                expected: crate::components::ServiceMode::Manual,
1224                actual,
1225            });
1226        }
1227        Ok(())
1228    }
1229
1230    /// Internal: push a command onto the queue, collapsing adjacent
1231    /// duplicates, capping length, and emitting `DoorCommandQueued`.
1232    fn enqueue_door_command(&mut self, elevator: EntityId, command: crate::door::DoorCommand) {
1233        if let Some(car) = self.world.elevator_mut(elevator) {
1234            let q = &mut car.door_command_queue;
1235            // Collapse adjacent duplicates for idempotent commands
1236            // (Open/Close/CancelHold) — repeating them adds nothing.
1237            // HoldOpen is explicitly cumulative, so never collapsed.
1238            let collapse = matches!(
1239                command,
1240                crate::door::DoorCommand::Open
1241                    | crate::door::DoorCommand::Close
1242                    | crate::door::DoorCommand::CancelHold
1243            ) && q.last().copied() == Some(command);
1244            if !collapse {
1245                q.push(command);
1246                if q.len() > crate::components::DOOR_COMMAND_QUEUE_CAP {
1247                    q.remove(0);
1248                }
1249            }
1250        }
1251        self.events.emit(Event::DoorCommandQueued {
1252            elevator,
1253            command,
1254            tick: self.tick,
1255        });
1256    }
1257
1258    /// Internal: resolve an elevator entity that is not disabled.
1259    fn require_enabled_elevator(&self, elevator: EntityId) -> Result<(), SimError> {
1260        if self.world.elevator(elevator).is_none() {
1261            return Err(SimError::NotAnElevator(elevator));
1262        }
1263        if self.world.is_disabled(elevator) {
1264            return Err(SimError::ElevatorDisabled(elevator));
1265        }
1266        Ok(())
1267    }
1268
1269    /// Internal: resolve an elevator entity or return a clear error.
1270    fn require_elevator(
1271        &self,
1272        elevator: EntityId,
1273    ) -> Result<&crate::components::Elevator, SimError> {
1274        self.world
1275            .elevator(elevator)
1276            .ok_or(SimError::NotAnElevator(elevator))
1277    }
1278
1279    /// Internal: positive-finite validator matching the construction-time
1280    /// error shape in `sim/construction.rs::validate_elevator_config`.
1281    fn validate_positive_finite_f64(value: f64, field: &'static str) -> Result<(), SimError> {
1282        if !value.is_finite() {
1283            return Err(SimError::InvalidConfig {
1284                field,
1285                reason: format!("must be finite, got {value}"),
1286            });
1287        }
1288        if value <= 0.0 {
1289            return Err(SimError::InvalidConfig {
1290                field,
1291                reason: format!("must be positive, got {value}"),
1292            });
1293        }
1294        Ok(())
1295    }
1296
1297    /// Internal: reject zero-tick timings.
1298    fn validate_nonzero_u32(value: u32, field: &'static str) -> Result<(), SimError> {
1299        if value == 0 {
1300            return Err(SimError::InvalidConfig {
1301                field,
1302                reason: "must be > 0".into(),
1303            });
1304        }
1305        Ok(())
1306    }
1307
1308    /// Internal: emit a single `ElevatorUpgraded` event for the current tick.
1309    fn emit_upgrade(
1310        &mut self,
1311        elevator: EntityId,
1312        field: crate::events::UpgradeField,
1313        old: crate::events::UpgradeValue,
1314        new: crate::events::UpgradeValue,
1315    ) {
1316        self.events.emit(Event::ElevatorUpgraded {
1317            elevator,
1318            field,
1319            old,
1320            new,
1321            tick: self.tick,
1322        });
1323    }
1324
1325    // Dispatch & reposition management live in `sim/construction.rs`.
1326
1327    // ── Tagging ──────────────────────────────────────────────────────
1328
1329    /// Attach a metric tag to an entity (rider, stop, elevator, etc.).
1330    ///
1331    /// Tags enable per-tag metric breakdowns. An entity can have multiple tags.
1332    /// Riders automatically inherit tags from their origin stop when spawned.
1333    ///
1334    /// # Errors
1335    ///
1336    /// Returns [`SimError::EntityNotFound`] if the entity does not exist in
1337    /// the world.
1338    pub fn tag_entity(&mut self, id: EntityId, tag: impl Into<String>) -> Result<(), SimError> {
1339        if !self.world.is_alive(id) {
1340            return Err(SimError::EntityNotFound(id));
1341        }
1342        if let Some(tags) = self
1343            .world
1344            .resource_mut::<crate::tagged_metrics::MetricTags>()
1345        {
1346            tags.tag(id, tag);
1347        }
1348        Ok(())
1349    }
1350
1351    /// Remove a metric tag from an entity.
1352    pub fn untag_entity(&mut self, id: EntityId, tag: &str) {
1353        if let Some(tags) = self
1354            .world
1355            .resource_mut::<crate::tagged_metrics::MetricTags>()
1356        {
1357            tags.untag(id, tag);
1358        }
1359    }
1360
1361    /// Query the metric accumulator for a specific tag.
1362    #[must_use]
1363    pub fn metrics_for_tag(&self, tag: &str) -> Option<&crate::tagged_metrics::TaggedMetric> {
1364        self.world
1365            .resource::<crate::tagged_metrics::MetricTags>()
1366            .and_then(|tags| tags.metric(tag))
1367    }
1368
1369    /// List all registered metric tags.
1370    pub fn all_tags(&self) -> Vec<&str> {
1371        self.world
1372            .resource::<crate::tagged_metrics::MetricTags>()
1373            .map_or_else(Vec::new, |tags| tags.all_tags().collect())
1374    }
1375
1376    // ── Rider spawning ───────────────────────────────────────────────
1377
1378    /// Create a rider builder for fluent rider spawning.
1379    ///
1380    /// Accepts [`EntityId`] or [`StopId`] for origin and destination
1381    /// (anything that implements `Into<StopRef>`).
1382    ///
1383    /// # Errors
1384    ///
1385    /// Returns [`SimError::StopNotFound`] if a [`StopId`] does not exist
1386    /// in the building configuration.
1387    ///
1388    /// ```
1389    /// use elevator_core::prelude::*;
1390    ///
1391    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1392    /// let rider = sim.build_rider(StopId(0), StopId(1))
1393    ///     .unwrap()
1394    ///     .weight(80.0)
1395    ///     .spawn()
1396    ///     .unwrap();
1397    /// ```
1398    pub fn build_rider(
1399        &mut self,
1400        origin: impl Into<StopRef>,
1401        destination: impl Into<StopRef>,
1402    ) -> Result<RiderBuilder<'_>, SimError> {
1403        let origin = self.resolve_stop(origin.into())?;
1404        let destination = self.resolve_stop(destination.into())?;
1405        Ok(RiderBuilder {
1406            sim: self,
1407            origin,
1408            destination,
1409            weight: 75.0,
1410            group: None,
1411            route: None,
1412            patience: None,
1413            preferences: None,
1414            access_control: None,
1415        })
1416    }
1417
1418    /// Spawn a rider at the given origin stop entity, headed to destination stop entity.
1419    ///
1420    /// Auto-detects the elevator group by finding groups that serve both origin
1421    /// and destination stops.
1422    ///
1423    /// # Errors
1424    ///
1425    /// Returns [`SimError::NoRoute`] if no group serves both stops.
1426    /// Returns [`SimError::AmbiguousRoute`] if multiple groups serve both stops.
1427    pub fn spawn_rider(
1428        &mut self,
1429        origin: impl Into<StopRef>,
1430        destination: impl Into<StopRef>,
1431        weight: f64,
1432    ) -> Result<EntityId, SimError> {
1433        let origin = self.resolve_stop(origin.into())?;
1434        let destination = self.resolve_stop(destination.into())?;
1435        let matching: Vec<GroupId> = self
1436            .groups
1437            .iter()
1438            .filter(|g| {
1439                g.stop_entities().contains(&origin) && g.stop_entities().contains(&destination)
1440            })
1441            .map(ElevatorGroup::id)
1442            .collect();
1443
1444        let group = match matching.len() {
1445            0 => {
1446                let origin_groups: Vec<GroupId> = self
1447                    .groups
1448                    .iter()
1449                    .filter(|g| g.stop_entities().contains(&origin))
1450                    .map(ElevatorGroup::id)
1451                    .collect();
1452                let destination_groups: Vec<GroupId> = self
1453                    .groups
1454                    .iter()
1455                    .filter(|g| g.stop_entities().contains(&destination))
1456                    .map(ElevatorGroup::id)
1457                    .collect();
1458                return Err(SimError::NoRoute {
1459                    origin,
1460                    destination,
1461                    origin_groups,
1462                    destination_groups,
1463                });
1464            }
1465            1 => matching[0],
1466            _ => {
1467                return Err(SimError::AmbiguousRoute {
1468                    origin,
1469                    destination,
1470                    groups: matching,
1471                });
1472            }
1473        };
1474
1475        let route = Route::direct(origin, destination, group);
1476        Ok(self.spawn_rider_inner(origin, destination, weight, route))
1477    }
1478
1479    /// Internal helper: spawn a rider entity with the given route.
1480    fn spawn_rider_inner(
1481        &mut self,
1482        origin: EntityId,
1483        destination: EntityId,
1484        weight: f64,
1485        route: Route,
1486    ) -> EntityId {
1487        let eid = self.world.spawn();
1488        self.world.set_rider(
1489            eid,
1490            Rider {
1491                weight,
1492                phase: RiderPhase::Waiting,
1493                current_stop: Some(origin),
1494                spawn_tick: self.tick,
1495                board_tick: None,
1496            },
1497        );
1498        self.world.set_route(eid, route);
1499        self.rider_index.insert_waiting(origin, eid);
1500        self.events.emit(Event::RiderSpawned {
1501            rider: eid,
1502            origin,
1503            destination,
1504            tick: self.tick,
1505        });
1506
1507        // Auto-press the hall button for this rider. Direction is the
1508        // sign of `dest_pos - origin_pos`; if the two coincide (walk
1509        // leg, identity trip) no call is registered.
1510        if let (Some(op), Some(dp)) = (
1511            self.world.stop_position(origin),
1512            self.world.stop_position(destination),
1513        ) && let Some(direction) = crate::components::CallDirection::between(op, dp)
1514        {
1515            self.register_hall_call_for_rider(origin, direction, eid, destination);
1516        }
1517
1518        // Auto-tag the rider with "stop:{name}" for per-stop wait time tracking.
1519        let stop_tag = self
1520            .world
1521            .stop(origin)
1522            .map(|s| format!("stop:{}", s.name()));
1523
1524        // Inherit metric tags from the origin stop.
1525        if let Some(tags_res) = self
1526            .world
1527            .resource_mut::<crate::tagged_metrics::MetricTags>()
1528        {
1529            let origin_tags: Vec<String> = tags_res.tags_for(origin).to_vec();
1530            for tag in origin_tags {
1531                tags_res.tag(eid, tag);
1532            }
1533            // Apply the origin stop tag.
1534            if let Some(tag) = stop_tag {
1535                tags_res.tag(eid, tag);
1536            }
1537        }
1538
1539        eid
1540    }
1541
1542    /// Drain all pending events from completed ticks.
1543    ///
1544    /// Events emitted during `step()` (or per-phase methods) are buffered
1545    /// and made available here after `advance_tick()` is called.
1546    /// Events emitted outside the tick loop (e.g., `spawn_rider`, `disable`)
1547    /// are also included.
1548    ///
1549    /// ```
1550    /// use elevator_core::prelude::*;
1551    ///
1552    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1553    ///
1554    /// sim.spawn_rider(StopId(0), StopId(1), 70.0).unwrap();
1555    /// sim.step();
1556    ///
1557    /// let events = sim.drain_events();
1558    /// assert!(!events.is_empty());
1559    /// ```
1560    pub fn drain_events(&mut self) -> Vec<Event> {
1561        // Flush any events still in the bus (from spawn_rider, disable, etc.)
1562        self.pending_output.extend(self.events.drain());
1563        std::mem::take(&mut self.pending_output)
1564    }
1565
1566    /// Drain only events matching a predicate.
1567    ///
1568    /// Events that don't match the predicate remain in the buffer
1569    /// and will be returned by future `drain_events` or
1570    /// `drain_events_where` calls.
1571    ///
1572    /// ```
1573    /// use elevator_core::prelude::*;
1574    ///
1575    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1576    /// sim.spawn_rider(StopId(0), StopId(1), 70.0).unwrap();
1577    /// sim.step();
1578    ///
1579    /// let spawns: Vec<Event> = sim.drain_events_where(|e| {
1580    ///     matches!(e, Event::RiderSpawned { .. })
1581    /// });
1582    /// ```
1583    pub fn drain_events_where(&mut self, predicate: impl Fn(&Event) -> bool) -> Vec<Event> {
1584        // Flush bus into pending_output first.
1585        self.pending_output.extend(self.events.drain());
1586
1587        let mut matched = Vec::new();
1588        let mut remaining = Vec::new();
1589        for event in std::mem::take(&mut self.pending_output) {
1590            if predicate(&event) {
1591                matched.push(event);
1592            } else {
1593                remaining.push(event);
1594            }
1595        }
1596        self.pending_output = remaining;
1597        matched
1598    }
1599
1600    // ── Sub-stepping ────────────────────────────────────────────────
1601
1602    /// Get the dispatch strategies map (for advanced sub-stepping).
1603    #[must_use]
1604    pub fn dispatchers(&self) -> &BTreeMap<GroupId, Box<dyn DispatchStrategy>> {
1605        &self.dispatchers
1606    }
1607
1608    /// Get the dispatch strategies map mutably (for advanced sub-stepping).
1609    pub fn dispatchers_mut(&mut self) -> &mut BTreeMap<GroupId, Box<dyn DispatchStrategy>> {
1610        &mut self.dispatchers
1611    }
1612
1613    /// Get a mutable reference to the event bus.
1614    pub const fn events_mut(&mut self) -> &mut EventBus {
1615        &mut self.events
1616    }
1617
1618    /// Get a mutable reference to the metrics.
1619    pub const fn metrics_mut(&mut self) -> &mut Metrics {
1620        &mut self.metrics
1621    }
1622
1623    /// Build the `PhaseContext` for the current tick.
1624    #[must_use]
1625    pub const fn phase_context(&self) -> PhaseContext {
1626        PhaseContext {
1627            tick: self.tick,
1628            dt: self.dt,
1629        }
1630    }
1631
1632    /// Run only the `advance_transient` phase (with hooks).
1633    pub fn run_advance_transient(&mut self) {
1634        self.hooks
1635            .run_before(Phase::AdvanceTransient, &mut self.world);
1636        for group in &self.groups {
1637            self.hooks
1638                .run_before_group(Phase::AdvanceTransient, group.id(), &mut self.world);
1639        }
1640        let ctx = self.phase_context();
1641        crate::systems::advance_transient::run(
1642            &mut self.world,
1643            &mut self.events,
1644            &ctx,
1645            &mut self.rider_index,
1646        );
1647        for group in &self.groups {
1648            self.hooks
1649                .run_after_group(Phase::AdvanceTransient, group.id(), &mut self.world);
1650        }
1651        self.hooks
1652            .run_after(Phase::AdvanceTransient, &mut self.world);
1653    }
1654
1655    /// Run only the dispatch phase (with hooks).
1656    pub fn run_dispatch(&mut self) {
1657        self.hooks.run_before(Phase::Dispatch, &mut self.world);
1658        for group in &self.groups {
1659            self.hooks
1660                .run_before_group(Phase::Dispatch, group.id(), &mut self.world);
1661        }
1662        let ctx = self.phase_context();
1663        crate::systems::dispatch::run(
1664            &mut self.world,
1665            &mut self.events,
1666            &ctx,
1667            &self.groups,
1668            &mut self.dispatchers,
1669            &self.rider_index,
1670        );
1671        for group in &self.groups {
1672            self.hooks
1673                .run_after_group(Phase::Dispatch, group.id(), &mut self.world);
1674        }
1675        self.hooks.run_after(Phase::Dispatch, &mut self.world);
1676    }
1677
1678    /// Run only the movement phase (with hooks).
1679    pub fn run_movement(&mut self) {
1680        self.hooks.run_before(Phase::Movement, &mut self.world);
1681        for group in &self.groups {
1682            self.hooks
1683                .run_before_group(Phase::Movement, group.id(), &mut self.world);
1684        }
1685        let ctx = self.phase_context();
1686        self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1687        crate::systems::movement::run(
1688            &mut self.world,
1689            &mut self.events,
1690            &ctx,
1691            &self.elevator_ids_buf,
1692            &mut self.metrics,
1693        );
1694        for group in &self.groups {
1695            self.hooks
1696                .run_after_group(Phase::Movement, group.id(), &mut self.world);
1697        }
1698        self.hooks.run_after(Phase::Movement, &mut self.world);
1699    }
1700
1701    /// Run only the doors phase (with hooks).
1702    pub fn run_doors(&mut self) {
1703        self.hooks.run_before(Phase::Doors, &mut self.world);
1704        for group in &self.groups {
1705            self.hooks
1706                .run_before_group(Phase::Doors, group.id(), &mut self.world);
1707        }
1708        let ctx = self.phase_context();
1709        self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1710        crate::systems::doors::run(
1711            &mut self.world,
1712            &mut self.events,
1713            &ctx,
1714            &self.elevator_ids_buf,
1715        );
1716        for group in &self.groups {
1717            self.hooks
1718                .run_after_group(Phase::Doors, group.id(), &mut self.world);
1719        }
1720        self.hooks.run_after(Phase::Doors, &mut self.world);
1721    }
1722
1723    /// Run only the loading phase (with hooks).
1724    pub fn run_loading(&mut self) {
1725        self.hooks.run_before(Phase::Loading, &mut self.world);
1726        for group in &self.groups {
1727            self.hooks
1728                .run_before_group(Phase::Loading, group.id(), &mut self.world);
1729        }
1730        let ctx = self.phase_context();
1731        self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1732        crate::systems::loading::run(
1733            &mut self.world,
1734            &mut self.events,
1735            &ctx,
1736            &self.elevator_ids_buf,
1737            &mut self.rider_index,
1738        );
1739        for group in &self.groups {
1740            self.hooks
1741                .run_after_group(Phase::Loading, group.id(), &mut self.world);
1742        }
1743        self.hooks.run_after(Phase::Loading, &mut self.world);
1744    }
1745
1746    /// Run only the advance-queue phase (with hooks).
1747    ///
1748    /// Reconciles each elevator's phase/target with the front of its
1749    /// [`DestinationQueue`](crate::components::DestinationQueue). Runs
1750    /// between Reposition and Movement.
1751    pub fn run_advance_queue(&mut self) {
1752        self.hooks.run_before(Phase::AdvanceQueue, &mut self.world);
1753        for group in &self.groups {
1754            self.hooks
1755                .run_before_group(Phase::AdvanceQueue, group.id(), &mut self.world);
1756        }
1757        let ctx = self.phase_context();
1758        self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1759        crate::systems::advance_queue::run(
1760            &mut self.world,
1761            &mut self.events,
1762            &ctx,
1763            &self.elevator_ids_buf,
1764        );
1765        for group in &self.groups {
1766            self.hooks
1767                .run_after_group(Phase::AdvanceQueue, group.id(), &mut self.world);
1768        }
1769        self.hooks.run_after(Phase::AdvanceQueue, &mut self.world);
1770    }
1771
1772    /// Run only the reposition phase (with hooks).
1773    ///
1774    /// Only runs if at least one group has a [`RepositionStrategy`] configured.
1775    /// Idle elevators with no pending dispatch assignment are repositioned
1776    /// according to their group's strategy.
1777    pub fn run_reposition(&mut self) {
1778        if self.repositioners.is_empty() {
1779            return;
1780        }
1781        self.hooks.run_before(Phase::Reposition, &mut self.world);
1782        // Only run per-group hooks for groups that have a repositioner.
1783        for group in &self.groups {
1784            if self.repositioners.contains_key(&group.id()) {
1785                self.hooks
1786                    .run_before_group(Phase::Reposition, group.id(), &mut self.world);
1787            }
1788        }
1789        let ctx = self.phase_context();
1790        crate::systems::reposition::run(
1791            &mut self.world,
1792            &mut self.events,
1793            &ctx,
1794            &self.groups,
1795            &mut self.repositioners,
1796        );
1797        for group in &self.groups {
1798            if self.repositioners.contains_key(&group.id()) {
1799                self.hooks
1800                    .run_after_group(Phase::Reposition, group.id(), &mut self.world);
1801            }
1802        }
1803        self.hooks.run_after(Phase::Reposition, &mut self.world);
1804    }
1805
1806    /// Run the energy system (no hooks — inline phase).
1807    #[cfg(feature = "energy")]
1808    fn run_energy(&mut self) {
1809        let ctx = self.phase_context();
1810        self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1811        crate::systems::energy::run(
1812            &mut self.world,
1813            &mut self.events,
1814            &ctx,
1815            &self.elevator_ids_buf,
1816        );
1817    }
1818
1819    /// Run only the metrics phase (with hooks).
1820    pub fn run_metrics(&mut self) {
1821        self.hooks.run_before(Phase::Metrics, &mut self.world);
1822        for group in &self.groups {
1823            self.hooks
1824                .run_before_group(Phase::Metrics, group.id(), &mut self.world);
1825        }
1826        let ctx = self.phase_context();
1827        crate::systems::metrics::run(
1828            &mut self.world,
1829            &self.events,
1830            &mut self.metrics,
1831            &ctx,
1832            &self.groups,
1833        );
1834        for group in &self.groups {
1835            self.hooks
1836                .run_after_group(Phase::Metrics, group.id(), &mut self.world);
1837        }
1838        self.hooks.run_after(Phase::Metrics, &mut self.world);
1839    }
1840
1841    // Phase-hook registration lives in `sim/construction.rs`.
1842
1843    /// Increment the tick counter and flush events to the output buffer.
1844    ///
1845    /// Call after running all desired phases. Events emitted during this tick
1846    /// are moved to the output buffer and available via `drain_events()`.
1847    pub fn advance_tick(&mut self) {
1848        self.pending_output.extend(self.events.drain());
1849        self.tick += 1;
1850    }
1851
1852    /// Advance the simulation by one tick.
1853    ///
1854    /// Events from this tick are buffered internally and available via
1855    /// `drain_events()`. The metrics system only processes events from
1856    /// the current tick, regardless of whether the consumer drains them.
1857    ///
1858    /// ```
1859    /// use elevator_core::prelude::*;
1860    ///
1861    /// let mut sim = SimulationBuilder::demo().build().unwrap();
1862    /// sim.step();
1863    /// assert_eq!(sim.current_tick(), 1);
1864    /// ```
1865    pub fn step(&mut self) {
1866        self.world.snapshot_prev_positions();
1867        self.run_advance_transient();
1868        self.run_dispatch();
1869        self.run_reposition();
1870        self.run_advance_queue();
1871        self.run_movement();
1872        self.run_doors();
1873        self.run_loading();
1874        #[cfg(feature = "energy")]
1875        self.run_energy();
1876        self.run_metrics();
1877        self.advance_tick();
1878    }
1879
1880    // ── Hall / car call API ─────────────────────────────────────────
1881
1882    /// Press an up/down hall button at `stop` without associating it
1883    /// with any particular rider. Useful for scripted NPCs, player
1884    /// input, or cutscene cues.
1885    ///
1886    /// If a call in this direction already exists at `stop`, the press
1887    /// tick is left untouched (first press wins for latency purposes).
1888    ///
1889    /// # Errors
1890    /// Returns [`SimError::EntityNotFound`] if `stop` is not a valid
1891    /// stop entity.
1892    pub fn press_hall_button(
1893        &mut self,
1894        stop: impl Into<StopRef>,
1895        direction: crate::components::CallDirection,
1896    ) -> Result<(), SimError> {
1897        let stop = self.resolve_stop(stop.into())?;
1898        if self.world.stop(stop).is_none() {
1899            return Err(SimError::EntityNotFound(stop));
1900        }
1901        self.ensure_hall_call(stop, direction, None, None);
1902        Ok(())
1903    }
1904
1905    /// Press a floor button from inside `car`. No-op if the car already
1906    /// has a pending call for `floor`.
1907    ///
1908    /// # Errors
1909    /// Returns [`SimError::EntityNotFound`] if `car` or `floor` is invalid.
1910    pub fn press_car_button(
1911        &mut self,
1912        car: EntityId,
1913        floor: impl Into<StopRef>,
1914    ) -> Result<(), SimError> {
1915        let floor = self.resolve_stop(floor.into())?;
1916        if self.world.elevator(car).is_none() {
1917            return Err(SimError::EntityNotFound(car));
1918        }
1919        if self.world.stop(floor).is_none() {
1920            return Err(SimError::EntityNotFound(floor));
1921        }
1922        self.ensure_car_call(car, floor, None);
1923        Ok(())
1924    }
1925
1926    /// Pin the hall call at `(stop, direction)` to `car`. Dispatch is
1927    /// forbidden from reassigning the call to a different car until
1928    /// [`unpin_assignment`](Self::unpin_assignment) is called or the
1929    /// call is cleared.
1930    ///
1931    /// # Errors
1932    /// - [`SimError::EntityNotFound`] — `car` is not a valid elevator.
1933    /// - [`SimError::HallCallNotFound`] — no hall call exists at that
1934    ///   `(stop, direction)` pair yet.
1935    /// - [`SimError::LineDoesNotServeStop`] — the car's line does not
1936    ///   serve `stop`. Without this check a cross-line pin would be
1937    ///   silently dropped at dispatch time yet leave the call `pinned`,
1938    ///   blocking every other car.
1939    pub fn pin_assignment(
1940        &mut self,
1941        car: EntityId,
1942        stop: EntityId,
1943        direction: crate::components::CallDirection,
1944    ) -> Result<(), SimError> {
1945        let Some(elev) = self.world.elevator(car) else {
1946            return Err(SimError::EntityNotFound(car));
1947        };
1948        let car_line = elev.line;
1949        // Validate the car's line can reach the stop. If the line has
1950        // an entry in any group, we consult its `serves` list. A car
1951        // whose line entity doesn't match any line in any group falls
1952        // through — older test fixtures create elevators without a
1953        // line entity, and we don't want to regress them.
1954        let line_serves_stop = self
1955            .groups
1956            .iter()
1957            .flat_map(|g| g.lines().iter())
1958            .find(|li| li.entity() == car_line)
1959            .map(|li| li.serves().contains(&stop));
1960        if line_serves_stop == Some(false) {
1961            return Err(SimError::LineDoesNotServeStop {
1962                line_or_car: car,
1963                stop,
1964            });
1965        }
1966        let Some(call) = self.world.hall_call_mut(stop, direction) else {
1967            return Err(SimError::HallCallNotFound { stop, direction });
1968        };
1969        call.assigned_car = Some(car);
1970        call.pinned = true;
1971        Ok(())
1972    }
1973
1974    /// Release a previous pin at `(stop, direction)`. No-op if the call
1975    /// doesn't exist or wasn't pinned.
1976    pub fn unpin_assignment(
1977        &mut self,
1978        stop: EntityId,
1979        direction: crate::components::CallDirection,
1980    ) {
1981        if let Some(call) = self.world.hall_call_mut(stop, direction) {
1982            call.pinned = false;
1983        }
1984    }
1985
1986    /// Iterate every active hall call across the simulation. Yields a
1987    /// reference per live `(stop, direction)` press; games use this to
1988    /// render lobby lamp states, pending-rider counts, or per-floor
1989    /// button animations.
1990    pub fn hall_calls(&self) -> impl Iterator<Item = &crate::components::HallCall> {
1991        self.world.iter_hall_calls()
1992    }
1993
1994    /// Floor buttons currently pressed inside `car`. Returns an empty
1995    /// slice when the car has no aboard riders or hasn't been used.
1996    #[must_use]
1997    pub fn car_calls(&self, car: EntityId) -> &[crate::components::CarCall] {
1998        self.world.car_calls(car)
1999    }
2000
2001    /// Car currently assigned to serve the call at `(stop, direction)`,
2002    /// if dispatch has made an assignment yet.
2003    #[must_use]
2004    pub fn assigned_car(
2005        &self,
2006        stop: EntityId,
2007        direction: crate::components::CallDirection,
2008    ) -> Option<EntityId> {
2009        self.world
2010            .hall_call(stop, direction)
2011            .and_then(|c| c.assigned_car)
2012    }
2013
2014    /// Estimated ticks remaining before the assigned car reaches the
2015    /// call at `(stop, direction)`.
2016    ///
2017    /// # Errors
2018    ///
2019    /// - [`EtaError::NotAStop`] if no hall call exists at `(stop, direction)`.
2020    /// - [`EtaError::StopNotQueued`] if no car is assigned to the call.
2021    /// - [`EtaError::NotAnElevator`] if the assigned car has no positional
2022    ///   data or is not a valid elevator.
2023    pub fn eta_for_call(
2024        &self,
2025        stop: EntityId,
2026        direction: crate::components::CallDirection,
2027    ) -> Result<u64, EtaError> {
2028        let call = self
2029            .world
2030            .hall_call(stop, direction)
2031            .ok_or(EtaError::NotAStop(stop))?;
2032        let car = call.assigned_car.ok_or(EtaError::NoCarAssigned(stop))?;
2033        let car_pos = self
2034            .world
2035            .position(car)
2036            .ok_or(EtaError::NotAnElevator(car))?
2037            .value;
2038        let stop_pos = self
2039            .world
2040            .stop_position(stop)
2041            .ok_or(EtaError::StopVanished(stop))?;
2042        let max_speed = self
2043            .world
2044            .elevator(car)
2045            .ok_or(EtaError::NotAnElevator(car))?
2046            .max_speed();
2047        if max_speed <= 0.0 {
2048            return Err(EtaError::NotAnElevator(car));
2049        }
2050        let distance = (car_pos - stop_pos).abs();
2051        // Simple kinematic estimate. The `eta` module has a richer
2052        // trapezoidal model; the one-liner suits most hall-display use.
2053        Ok((distance / max_speed).ceil() as u64)
2054    }
2055
2056    // ── Internal helpers ────────────────────────────────────────────
2057
2058    /// Register (or aggregate) a hall call on behalf of a specific
2059    /// rider, including their destination in DCS mode.
2060    fn register_hall_call_for_rider(
2061        &mut self,
2062        stop: EntityId,
2063        direction: crate::components::CallDirection,
2064        rider: EntityId,
2065        destination: EntityId,
2066    ) {
2067        let mode = self
2068            .groups
2069            .iter()
2070            .find(|g| g.stop_entities().contains(&stop))
2071            .map(crate::dispatch::ElevatorGroup::hall_call_mode);
2072        let dest = match mode {
2073            Some(crate::dispatch::HallCallMode::Destination) => Some(destination),
2074            _ => None,
2075        };
2076        self.ensure_hall_call(stop, direction, Some(rider), dest);
2077    }
2078
2079    /// Create or aggregate into the hall call at `(stop, direction)`.
2080    /// Emits [`Event::HallButtonPressed`] only on the *first* press.
2081    fn ensure_hall_call(
2082        &mut self,
2083        stop: EntityId,
2084        direction: crate::components::CallDirection,
2085        rider: Option<EntityId>,
2086        destination: Option<EntityId>,
2087    ) {
2088        let mut fresh_press = false;
2089        if self.world.hall_call(stop, direction).is_none() {
2090            let mut call = crate::components::HallCall::new(stop, direction, self.tick);
2091            call.destination = destination;
2092            call.ack_latency_ticks = self.ack_latency_for_stop(stop);
2093            if call.ack_latency_ticks == 0 {
2094                // Controller has zero-tick latency — mark acknowledged
2095                // immediately so dispatch sees the call this same tick.
2096                call.acknowledged_at = Some(self.tick);
2097            }
2098            if let Some(rid) = rider {
2099                call.pending_riders.push(rid);
2100            }
2101            self.world.set_hall_call(call);
2102            fresh_press = true;
2103        } else if let Some(existing) = self.world.hall_call_mut(stop, direction) {
2104            if let Some(rid) = rider
2105                && !existing.pending_riders.contains(&rid)
2106            {
2107                existing.pending_riders.push(rid);
2108            }
2109            // Prefer a populated destination over None; don't overwrite
2110            // an existing destination even if a later press omits it.
2111            if existing.destination.is_none() {
2112                existing.destination = destination;
2113            }
2114        }
2115        if fresh_press {
2116            self.events.emit(Event::HallButtonPressed {
2117                stop,
2118                direction,
2119                tick: self.tick,
2120            });
2121            // Zero-latency controllers acknowledge on the press tick.
2122            if let Some(call) = self.world.hall_call(stop, direction)
2123                && call.acknowledged_at == Some(self.tick)
2124            {
2125                self.events.emit(Event::HallCallAcknowledged {
2126                    stop,
2127                    direction,
2128                    tick: self.tick,
2129                });
2130            }
2131        }
2132    }
2133
2134    /// Ack latency for the group whose `members` slice contains `entity`.
2135    /// Defaults to 0 if no group matches (unreachable in normal builds).
2136    fn ack_latency_for(
2137        &self,
2138        entity: EntityId,
2139        members: impl Fn(&crate::dispatch::ElevatorGroup) -> &[EntityId],
2140    ) -> u32 {
2141        self.groups
2142            .iter()
2143            .find(|g| members(g).contains(&entity))
2144            .map_or(0, crate::dispatch::ElevatorGroup::ack_latency_ticks)
2145    }
2146
2147    /// Ack latency for the group that owns `stop` (0 if no group).
2148    fn ack_latency_for_stop(&self, stop: EntityId) -> u32 {
2149        self.ack_latency_for(stop, crate::dispatch::ElevatorGroup::stop_entities)
2150    }
2151
2152    /// Ack latency for the group that owns `car` (0 if no group).
2153    fn ack_latency_for_car(&self, car: EntityId) -> u32 {
2154        self.ack_latency_for(car, crate::dispatch::ElevatorGroup::elevator_entities)
2155    }
2156
2157    /// Create or aggregate into a car call for `(car, floor)`.
2158    /// Emits [`Event::CarButtonPressed`] on first press; repeat presses
2159    /// by other riders append to `pending_riders` without re-emitting.
2160    fn ensure_car_call(&mut self, car: EntityId, floor: EntityId, rider: Option<EntityId>) {
2161        let press_tick = self.tick;
2162        let ack_latency = self.ack_latency_for_car(car);
2163        let Some(queue) = self.world.car_calls_mut(car) else {
2164            return;
2165        };
2166        let existing_idx = queue.iter().position(|c| c.floor == floor);
2167        let fresh = existing_idx.is_none();
2168        if let Some(idx) = existing_idx {
2169            if let Some(rid) = rider
2170                && !queue[idx].pending_riders.contains(&rid)
2171            {
2172                queue[idx].pending_riders.push(rid);
2173            }
2174        } else {
2175            let mut call = crate::components::CarCall::new(car, floor, press_tick);
2176            call.ack_latency_ticks = ack_latency;
2177            if ack_latency == 0 {
2178                call.acknowledged_at = Some(press_tick);
2179            }
2180            if let Some(rid) = rider {
2181                call.pending_riders.push(rid);
2182            }
2183            queue.push(call);
2184        }
2185        if fresh {
2186            self.events.emit(Event::CarButtonPressed {
2187                car,
2188                floor,
2189                rider,
2190                tick: press_tick,
2191            });
2192        }
2193    }
2194}
2195
2196impl fmt::Debug for Simulation {
2197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2198        f.debug_struct("Simulation")
2199            .field("tick", &self.tick)
2200            .field("dt", &self.dt)
2201            .field("groups", &self.groups.len())
2202            .field("entities", &self.world.entity_count())
2203            .finish_non_exhaustive()
2204    }
2205}