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}