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