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