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 /// Push an event into the pending output buffer (crate-internal).
1604 pub(crate) fn push_event(&mut self, event: Event) {
1605 self.pending_output.push(event);
1606 }
1607
1608 /// Drain only events matching a predicate.
1609 ///
1610 /// Events that don't match the predicate remain in the buffer
1611 /// and will be returned by future `drain_events` or
1612 /// `drain_events_where` calls.
1613 ///
1614 /// ```
1615 /// use elevator_core::prelude::*;
1616 ///
1617 /// let mut sim = SimulationBuilder::demo().build().unwrap();
1618 /// sim.spawn_rider(StopId(0), StopId(1), 70.0).unwrap();
1619 /// sim.step();
1620 ///
1621 /// let spawns: Vec<Event> = sim.drain_events_where(|e| {
1622 /// matches!(e, Event::RiderSpawned { .. })
1623 /// });
1624 /// ```
1625 pub fn drain_events_where(&mut self, predicate: impl Fn(&Event) -> bool) -> Vec<Event> {
1626 // Flush bus into pending_output first.
1627 self.pending_output.extend(self.events.drain());
1628
1629 let mut matched = Vec::new();
1630 let mut remaining = Vec::new();
1631 for event in std::mem::take(&mut self.pending_output) {
1632 if predicate(&event) {
1633 matched.push(event);
1634 } else {
1635 remaining.push(event);
1636 }
1637 }
1638 self.pending_output = remaining;
1639 matched
1640 }
1641
1642 // ── Sub-stepping ────────────────────────────────────────────────
1643
1644 /// Get the dispatch strategies map (for advanced sub-stepping).
1645 #[must_use]
1646 pub fn dispatchers(&self) -> &BTreeMap<GroupId, Box<dyn DispatchStrategy>> {
1647 &self.dispatchers
1648 }
1649
1650 /// Get the dispatch strategies map mutably (for advanced sub-stepping).
1651 pub fn dispatchers_mut(&mut self) -> &mut BTreeMap<GroupId, Box<dyn DispatchStrategy>> {
1652 &mut self.dispatchers
1653 }
1654
1655 /// Get a mutable reference to the event bus.
1656 pub const fn events_mut(&mut self) -> &mut EventBus {
1657 &mut self.events
1658 }
1659
1660 /// Get a mutable reference to the metrics.
1661 pub const fn metrics_mut(&mut self) -> &mut Metrics {
1662 &mut self.metrics
1663 }
1664
1665 /// Build the `PhaseContext` for the current tick.
1666 #[must_use]
1667 pub const fn phase_context(&self) -> PhaseContext {
1668 PhaseContext {
1669 tick: self.tick,
1670 dt: self.dt,
1671 }
1672 }
1673
1674 /// Run only the `advance_transient` phase (with hooks).
1675 pub fn run_advance_transient(&mut self) {
1676 self.hooks
1677 .run_before(Phase::AdvanceTransient, &mut self.world);
1678 for group in &self.groups {
1679 self.hooks
1680 .run_before_group(Phase::AdvanceTransient, group.id(), &mut self.world);
1681 }
1682 let ctx = self.phase_context();
1683 crate::systems::advance_transient::run(
1684 &mut self.world,
1685 &mut self.events,
1686 &ctx,
1687 &mut self.rider_index,
1688 );
1689 for group in &self.groups {
1690 self.hooks
1691 .run_after_group(Phase::AdvanceTransient, group.id(), &mut self.world);
1692 }
1693 self.hooks
1694 .run_after(Phase::AdvanceTransient, &mut self.world);
1695 }
1696
1697 /// Run only the dispatch phase (with hooks).
1698 pub fn run_dispatch(&mut self) {
1699 self.hooks.run_before(Phase::Dispatch, &mut self.world);
1700 for group in &self.groups {
1701 self.hooks
1702 .run_before_group(Phase::Dispatch, group.id(), &mut self.world);
1703 }
1704 let ctx = self.phase_context();
1705 crate::systems::dispatch::run(
1706 &mut self.world,
1707 &mut self.events,
1708 &ctx,
1709 &self.groups,
1710 &mut self.dispatchers,
1711 &self.rider_index,
1712 );
1713 for group in &self.groups {
1714 self.hooks
1715 .run_after_group(Phase::Dispatch, group.id(), &mut self.world);
1716 }
1717 self.hooks.run_after(Phase::Dispatch, &mut self.world);
1718 }
1719
1720 /// Run only the movement phase (with hooks).
1721 pub fn run_movement(&mut self) {
1722 self.hooks.run_before(Phase::Movement, &mut self.world);
1723 for group in &self.groups {
1724 self.hooks
1725 .run_before_group(Phase::Movement, group.id(), &mut self.world);
1726 }
1727 let ctx = self.phase_context();
1728 self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1729 crate::systems::movement::run(
1730 &mut self.world,
1731 &mut self.events,
1732 &ctx,
1733 &self.elevator_ids_buf,
1734 &mut self.metrics,
1735 );
1736 for group in &self.groups {
1737 self.hooks
1738 .run_after_group(Phase::Movement, group.id(), &mut self.world);
1739 }
1740 self.hooks.run_after(Phase::Movement, &mut self.world);
1741 }
1742
1743 /// Run only the doors phase (with hooks).
1744 pub fn run_doors(&mut self) {
1745 self.hooks.run_before(Phase::Doors, &mut self.world);
1746 for group in &self.groups {
1747 self.hooks
1748 .run_before_group(Phase::Doors, group.id(), &mut self.world);
1749 }
1750 let ctx = self.phase_context();
1751 self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1752 crate::systems::doors::run(
1753 &mut self.world,
1754 &mut self.events,
1755 &ctx,
1756 &self.elevator_ids_buf,
1757 );
1758 for group in &self.groups {
1759 self.hooks
1760 .run_after_group(Phase::Doors, group.id(), &mut self.world);
1761 }
1762 self.hooks.run_after(Phase::Doors, &mut self.world);
1763 }
1764
1765 /// Run only the loading phase (with hooks).
1766 pub fn run_loading(&mut self) {
1767 self.hooks.run_before(Phase::Loading, &mut self.world);
1768 for group in &self.groups {
1769 self.hooks
1770 .run_before_group(Phase::Loading, group.id(), &mut self.world);
1771 }
1772 let ctx = self.phase_context();
1773 self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1774 crate::systems::loading::run(
1775 &mut self.world,
1776 &mut self.events,
1777 &ctx,
1778 &self.elevator_ids_buf,
1779 &mut self.rider_index,
1780 );
1781 for group in &self.groups {
1782 self.hooks
1783 .run_after_group(Phase::Loading, group.id(), &mut self.world);
1784 }
1785 self.hooks.run_after(Phase::Loading, &mut self.world);
1786 }
1787
1788 /// Run only the advance-queue phase (with hooks).
1789 ///
1790 /// Reconciles each elevator's phase/target with the front of its
1791 /// [`DestinationQueue`](crate::components::DestinationQueue). Runs
1792 /// between Reposition and Movement.
1793 pub fn run_advance_queue(&mut self) {
1794 self.hooks.run_before(Phase::AdvanceQueue, &mut self.world);
1795 for group in &self.groups {
1796 self.hooks
1797 .run_before_group(Phase::AdvanceQueue, group.id(), &mut self.world);
1798 }
1799 let ctx = self.phase_context();
1800 self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1801 crate::systems::advance_queue::run(
1802 &mut self.world,
1803 &mut self.events,
1804 &ctx,
1805 &self.elevator_ids_buf,
1806 );
1807 for group in &self.groups {
1808 self.hooks
1809 .run_after_group(Phase::AdvanceQueue, group.id(), &mut self.world);
1810 }
1811 self.hooks.run_after(Phase::AdvanceQueue, &mut self.world);
1812 }
1813
1814 /// Run only the reposition phase (with hooks).
1815 ///
1816 /// Global before/after hooks always fire even when no [`RepositionStrategy`]
1817 /// is configured. Per-group hooks only fire for groups that have a
1818 /// repositioner — this differs from other phases where per-group hooks
1819 /// fire unconditionally.
1820 pub fn run_reposition(&mut self) {
1821 self.hooks.run_before(Phase::Reposition, &mut self.world);
1822 if !self.repositioners.is_empty() {
1823 // Only run per-group hooks for groups that have a repositioner.
1824 for group in &self.groups {
1825 if self.repositioners.contains_key(&group.id()) {
1826 self.hooks
1827 .run_before_group(Phase::Reposition, group.id(), &mut self.world);
1828 }
1829 }
1830 let ctx = self.phase_context();
1831 crate::systems::reposition::run(
1832 &mut self.world,
1833 &mut self.events,
1834 &ctx,
1835 &self.groups,
1836 &mut self.repositioners,
1837 &mut self.reposition_buf,
1838 );
1839 for group in &self.groups {
1840 if self.repositioners.contains_key(&group.id()) {
1841 self.hooks
1842 .run_after_group(Phase::Reposition, group.id(), &mut self.world);
1843 }
1844 }
1845 }
1846 self.hooks.run_after(Phase::Reposition, &mut self.world);
1847 }
1848
1849 /// Run the energy system (no hooks — inline phase).
1850 #[cfg(feature = "energy")]
1851 fn run_energy(&mut self) {
1852 let ctx = self.phase_context();
1853 self.world.elevator_ids_into(&mut self.elevator_ids_buf);
1854 crate::systems::energy::run(
1855 &mut self.world,
1856 &mut self.events,
1857 &ctx,
1858 &self.elevator_ids_buf,
1859 );
1860 }
1861
1862 /// Run only the metrics phase (with hooks).
1863 pub fn run_metrics(&mut self) {
1864 self.hooks.run_before(Phase::Metrics, &mut self.world);
1865 for group in &self.groups {
1866 self.hooks
1867 .run_before_group(Phase::Metrics, group.id(), &mut self.world);
1868 }
1869 let ctx = self.phase_context();
1870 crate::systems::metrics::run(
1871 &mut self.world,
1872 &self.events,
1873 &mut self.metrics,
1874 &ctx,
1875 &self.groups,
1876 );
1877 for group in &self.groups {
1878 self.hooks
1879 .run_after_group(Phase::Metrics, group.id(), &mut self.world);
1880 }
1881 self.hooks.run_after(Phase::Metrics, &mut self.world);
1882 }
1883
1884 // Phase-hook registration lives in `sim/construction.rs`.
1885
1886 /// Increment the tick counter and flush events to the output buffer.
1887 ///
1888 /// Call after running all desired phases. Events emitted during this tick
1889 /// are moved to the output buffer and available via `drain_events()`.
1890 pub fn advance_tick(&mut self) {
1891 self.pending_output.extend(self.events.drain());
1892 self.tick += 1;
1893 }
1894
1895 /// Advance the simulation by one tick.
1896 ///
1897 /// Events from this tick are buffered internally and available via
1898 /// `drain_events()`. The metrics system only processes events from
1899 /// the current tick, regardless of whether the consumer drains them.
1900 ///
1901 /// ```
1902 /// use elevator_core::prelude::*;
1903 ///
1904 /// let mut sim = SimulationBuilder::demo().build().unwrap();
1905 /// sim.step();
1906 /// assert_eq!(sim.current_tick(), 1);
1907 /// ```
1908 pub fn step(&mut self) {
1909 self.world.snapshot_prev_positions();
1910 self.run_advance_transient();
1911 self.run_dispatch();
1912 self.run_reposition();
1913 self.run_advance_queue();
1914 self.run_movement();
1915 self.run_doors();
1916 self.run_loading();
1917 #[cfg(feature = "energy")]
1918 self.run_energy();
1919 self.run_metrics();
1920 self.advance_tick();
1921 }
1922
1923 // ── Hall / car call API ─────────────────────────────────────────
1924
1925 /// Press an up/down hall button at `stop` without associating it
1926 /// with any particular rider. Useful for scripted NPCs, player
1927 /// input, or cutscene cues.
1928 ///
1929 /// If a call in this direction already exists at `stop`, the press
1930 /// tick is left untouched (first press wins for latency purposes).
1931 ///
1932 /// # Errors
1933 /// Returns [`SimError::EntityNotFound`] if `stop` is not a valid
1934 /// stop entity.
1935 pub fn press_hall_button(
1936 &mut self,
1937 stop: impl Into<StopRef>,
1938 direction: crate::components::CallDirection,
1939 ) -> Result<(), SimError> {
1940 let stop = self.resolve_stop(stop.into())?;
1941 if self.world.stop(stop).is_none() {
1942 return Err(SimError::EntityNotFound(stop));
1943 }
1944 self.ensure_hall_call(stop, direction, None, None);
1945 Ok(())
1946 }
1947
1948 /// Press a floor button from inside `car`. No-op if the car already
1949 /// has a pending call for `floor`.
1950 ///
1951 /// # Errors
1952 /// Returns [`SimError::EntityNotFound`] if `car` or `floor` is invalid.
1953 pub fn press_car_button(
1954 &mut self,
1955 car: ElevatorId,
1956 floor: impl Into<StopRef>,
1957 ) -> Result<(), SimError> {
1958 let car = car.entity();
1959 let floor = self.resolve_stop(floor.into())?;
1960 if self.world.elevator(car).is_none() {
1961 return Err(SimError::EntityNotFound(car));
1962 }
1963 if self.world.stop(floor).is_none() {
1964 return Err(SimError::EntityNotFound(floor));
1965 }
1966 self.ensure_car_call(car, floor, None);
1967 Ok(())
1968 }
1969
1970 /// Pin the hall call at `(stop, direction)` to `car`. Dispatch is
1971 /// forbidden from reassigning the call to a different car until
1972 /// [`unpin_assignment`](Self::unpin_assignment) is called or the
1973 /// call is cleared.
1974 ///
1975 /// # Errors
1976 /// - [`SimError::EntityNotFound`] — `car` is not a valid elevator.
1977 /// - [`SimError::HallCallNotFound`] — no hall call exists at that
1978 /// `(stop, direction)` pair yet.
1979 /// - [`SimError::LineDoesNotServeStop`] — the car's line does not
1980 /// serve `stop`. Without this check a cross-line pin would be
1981 /// silently dropped at dispatch time yet leave the call `pinned`,
1982 /// blocking every other car.
1983 pub fn pin_assignment(
1984 &mut self,
1985 car: ElevatorId,
1986 stop: EntityId,
1987 direction: crate::components::CallDirection,
1988 ) -> Result<(), SimError> {
1989 let car = car.entity();
1990 let Some(elev) = self.world.elevator(car) else {
1991 return Err(SimError::EntityNotFound(car));
1992 };
1993 let car_line = elev.line;
1994 // Validate the car's line can reach the stop. If the line has
1995 // an entry in any group, we consult its `serves` list. A car
1996 // whose line entity doesn't match any line in any group falls
1997 // through — older test fixtures create elevators without a
1998 // line entity, and we don't want to regress them.
1999 let line_serves_stop = self
2000 .groups
2001 .iter()
2002 .flat_map(|g| g.lines().iter())
2003 .find(|li| li.entity() == car_line)
2004 .map(|li| li.serves().contains(&stop));
2005 if line_serves_stop == Some(false) {
2006 return Err(SimError::LineDoesNotServeStop {
2007 line_or_car: car,
2008 stop,
2009 });
2010 }
2011 let Some(call) = self.world.hall_call_mut(stop, direction) else {
2012 return Err(SimError::HallCallNotFound { stop, direction });
2013 };
2014 call.assigned_car = Some(car);
2015 call.pinned = true;
2016 Ok(())
2017 }
2018
2019 /// Release a previous pin at `(stop, direction)`. No-op if the call
2020 /// doesn't exist or wasn't pinned.
2021 pub fn unpin_assignment(
2022 &mut self,
2023 stop: EntityId,
2024 direction: crate::components::CallDirection,
2025 ) {
2026 if let Some(call) = self.world.hall_call_mut(stop, direction) {
2027 call.pinned = false;
2028 }
2029 }
2030
2031 /// Iterate every active hall call across the simulation. Yields a
2032 /// reference per live `(stop, direction)` press; games use this to
2033 /// render lobby lamp states, pending-rider counts, or per-floor
2034 /// button animations.
2035 pub fn hall_calls(&self) -> impl Iterator<Item = &crate::components::HallCall> {
2036 self.world.iter_hall_calls()
2037 }
2038
2039 /// Floor buttons currently pressed inside `car`. Returns an empty
2040 /// slice when the car has no aboard riders or hasn't been used.
2041 #[must_use]
2042 pub fn car_calls(&self, car: ElevatorId) -> &[crate::components::CarCall] {
2043 let car = car.entity();
2044 self.world.car_calls(car)
2045 }
2046
2047 /// Car currently assigned to serve the call at `(stop, direction)`,
2048 /// if dispatch has made an assignment yet.
2049 #[must_use]
2050 pub fn assigned_car(
2051 &self,
2052 stop: EntityId,
2053 direction: crate::components::CallDirection,
2054 ) -> Option<EntityId> {
2055 self.world
2056 .hall_call(stop, direction)
2057 .and_then(|c| c.assigned_car)
2058 }
2059
2060 /// Estimated ticks remaining before the assigned car reaches the
2061 /// call at `(stop, direction)`.
2062 ///
2063 /// # Errors
2064 ///
2065 /// - [`EtaError::NotAStop`] if no hall call exists at `(stop, direction)`.
2066 /// - [`EtaError::StopNotQueued`] if no car is assigned to the call.
2067 /// - [`EtaError::NotAnElevator`] if the assigned car has no positional
2068 /// data or is not a valid elevator.
2069 pub fn eta_for_call(
2070 &self,
2071 stop: EntityId,
2072 direction: crate::components::CallDirection,
2073 ) -> Result<u64, EtaError> {
2074 let call = self
2075 .world
2076 .hall_call(stop, direction)
2077 .ok_or(EtaError::NotAStop(stop))?;
2078 let car = call.assigned_car.ok_or(EtaError::NoCarAssigned(stop))?;
2079 let car_pos = self
2080 .world
2081 .position(car)
2082 .ok_or(EtaError::NotAnElevator(car))?
2083 .value;
2084 let stop_pos = self
2085 .world
2086 .stop_position(stop)
2087 .ok_or(EtaError::StopVanished(stop))?;
2088 let max_speed = self
2089 .world
2090 .elevator(car)
2091 .ok_or(EtaError::NotAnElevator(car))?
2092 .max_speed()
2093 .value();
2094 if max_speed <= 0.0 {
2095 return Err(EtaError::NotAnElevator(car));
2096 }
2097 let distance = (car_pos - stop_pos).abs();
2098 // Simple kinematic estimate. The `eta` module has a richer
2099 // trapezoidal model; the one-liner suits most hall-display use.
2100 Ok((distance / max_speed).ceil() as u64)
2101 }
2102
2103 // ── Internal helpers ────────────────────────────────────────────
2104
2105 /// Register (or aggregate) a hall call on behalf of a specific
2106 /// rider, including their destination in DCS mode.
2107 fn register_hall_call_for_rider(
2108 &mut self,
2109 stop: EntityId,
2110 direction: crate::components::CallDirection,
2111 rider: EntityId,
2112 destination: EntityId,
2113 ) {
2114 let mode = self
2115 .groups
2116 .iter()
2117 .find(|g| g.stop_entities().contains(&stop))
2118 .map(crate::dispatch::ElevatorGroup::hall_call_mode);
2119 let dest = match mode {
2120 Some(crate::dispatch::HallCallMode::Destination) => Some(destination),
2121 _ => None,
2122 };
2123 self.ensure_hall_call(stop, direction, Some(rider), dest);
2124 }
2125
2126 /// Create or aggregate into the hall call at `(stop, direction)`.
2127 /// Emits [`Event::HallButtonPressed`] only on the *first* press.
2128 fn ensure_hall_call(
2129 &mut self,
2130 stop: EntityId,
2131 direction: crate::components::CallDirection,
2132 rider: Option<EntityId>,
2133 destination: Option<EntityId>,
2134 ) {
2135 let mut fresh_press = false;
2136 if self.world.hall_call(stop, direction).is_none() {
2137 let mut call = crate::components::HallCall::new(stop, direction, self.tick);
2138 call.destination = destination;
2139 call.ack_latency_ticks = self.ack_latency_for_stop(stop);
2140 if call.ack_latency_ticks == 0 {
2141 // Controller has zero-tick latency — mark acknowledged
2142 // immediately so dispatch sees the call this same tick.
2143 call.acknowledged_at = Some(self.tick);
2144 }
2145 if let Some(rid) = rider {
2146 call.pending_riders.push(rid);
2147 }
2148 self.world.set_hall_call(call);
2149 fresh_press = true;
2150 } else if let Some(existing) = self.world.hall_call_mut(stop, direction) {
2151 if let Some(rid) = rider
2152 && !existing.pending_riders.contains(&rid)
2153 {
2154 existing.pending_riders.push(rid);
2155 }
2156 // Prefer a populated destination over None; don't overwrite
2157 // an existing destination even if a later press omits it.
2158 if existing.destination.is_none() {
2159 existing.destination = destination;
2160 }
2161 }
2162 if fresh_press {
2163 self.events.emit(Event::HallButtonPressed {
2164 stop,
2165 direction,
2166 tick: self.tick,
2167 });
2168 // Zero-latency controllers acknowledge on the press tick.
2169 if let Some(call) = self.world.hall_call(stop, direction)
2170 && call.acknowledged_at == Some(self.tick)
2171 {
2172 self.events.emit(Event::HallCallAcknowledged {
2173 stop,
2174 direction,
2175 tick: self.tick,
2176 });
2177 }
2178 }
2179 }
2180
2181 /// Ack latency for the group whose `members` slice contains `entity`.
2182 /// Defaults to 0 if no group matches (unreachable in normal builds).
2183 fn ack_latency_for(
2184 &self,
2185 entity: EntityId,
2186 members: impl Fn(&crate::dispatch::ElevatorGroup) -> &[EntityId],
2187 ) -> u32 {
2188 self.groups
2189 .iter()
2190 .find(|g| members(g).contains(&entity))
2191 .map_or(0, crate::dispatch::ElevatorGroup::ack_latency_ticks)
2192 }
2193
2194 /// Ack latency for the group that owns `stop` (0 if no group).
2195 fn ack_latency_for_stop(&self, stop: EntityId) -> u32 {
2196 self.ack_latency_for(stop, crate::dispatch::ElevatorGroup::stop_entities)
2197 }
2198
2199 /// Ack latency for the group that owns `car` (0 if no group).
2200 fn ack_latency_for_car(&self, car: EntityId) -> u32 {
2201 self.ack_latency_for(car, crate::dispatch::ElevatorGroup::elevator_entities)
2202 }
2203
2204 /// Create or aggregate into a car call for `(car, floor)`.
2205 /// Emits [`Event::CarButtonPressed`] on first press; repeat presses
2206 /// by other riders append to `pending_riders` without re-emitting.
2207 fn ensure_car_call(&mut self, car: EntityId, floor: EntityId, rider: Option<EntityId>) {
2208 let press_tick = self.tick;
2209 let ack_latency = self.ack_latency_for_car(car);
2210 let Some(queue) = self.world.car_calls_mut(car) else {
2211 debug_assert!(
2212 false,
2213 "ensure_car_call: car {car:?} has no car_calls component"
2214 );
2215 return;
2216 };
2217 let existing_idx = queue.iter().position(|c| c.floor == floor);
2218 let fresh = existing_idx.is_none();
2219 if let Some(idx) = existing_idx {
2220 if let Some(rid) = rider
2221 && !queue[idx].pending_riders.contains(&rid)
2222 {
2223 queue[idx].pending_riders.push(rid);
2224 }
2225 } else {
2226 let mut call = crate::components::CarCall::new(car, floor, press_tick);
2227 call.ack_latency_ticks = ack_latency;
2228 if ack_latency == 0 {
2229 call.acknowledged_at = Some(press_tick);
2230 }
2231 if let Some(rid) = rider {
2232 call.pending_riders.push(rid);
2233 }
2234 queue.push(call);
2235 }
2236 if fresh {
2237 self.events.emit(Event::CarButtonPressed {
2238 car,
2239 floor,
2240 rider,
2241 tick: press_tick,
2242 });
2243 }
2244 }
2245}
2246
2247impl fmt::Debug for Simulation {
2248 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2249 f.debug_struct("Simulation")
2250 .field("tick", &self.tick)
2251 .field("dt", &self.dt)
2252 .field("groups", &self.groups.len())
2253 .field("entities", &self.world.entity_count())
2254 .finish_non_exhaustive()
2255 }
2256}