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