elevator_core/sim/lifecycle.rs
1//! Rider lifecycle, population queries, and entity state control.
2//!
3//! Covers reroute/settle/despawn/disable/enable, population queries,
4//! per-entity metrics, service mode, and route invalidation. Split out
5//! from `sim.rs` to keep each concern readable.
6
7use std::collections::HashSet;
8
9use crate::components::{Elevator, ElevatorPhase, RiderPhase, Route};
10use crate::entity::EntityId;
11use crate::error::SimError;
12use crate::events::Event;
13use crate::ids::GroupId;
14
15use super::Simulation;
16
17impl Simulation {
18 // ── Extension restore ────────────────────────────────────────────
19
20 /// Deserialize extension components from a snapshot.
21 ///
22 /// Call this after restoring from a snapshot and registering all
23 /// extension types via `world.register_ext::<T>(name)`.
24 ///
25 /// ```ignore
26 /// let mut sim = snapshot.restore(None);
27 /// sim.world_mut().register_ext::<VipTag>("vip_tag");
28 /// sim.load_extensions();
29 /// ```
30 pub fn load_extensions(&mut self) {
31 if let Some(pending) = self
32 .world
33 .remove_resource::<crate::snapshot::PendingExtensions>()
34 {
35 self.world.deserialize_extensions(&pending.0);
36 }
37 }
38
39 // ── Helpers ──────────────────────────────────────────────────────
40
41 /// Extract the `GroupId` from the current leg of a route.
42 ///
43 /// For Walk legs, looks ahead to the next leg to find the group.
44 /// Falls back to `GroupId(0)` when no route exists or no group leg is found.
45 pub(super) fn group_from_route(&self, route: Option<&Route>) -> GroupId {
46 if let Some(route) = route {
47 // Scan forward from current_leg looking for a Group or Line transport mode.
48 for leg in route.legs.iter().skip(route.current_leg) {
49 match leg.via {
50 crate::components::TransportMode::Group(g) => return g,
51 crate::components::TransportMode::Line(l) => {
52 if let Some(line) = self.world.line(l) {
53 return line.group();
54 }
55 }
56 crate::components::TransportMode::Walk => {}
57 }
58 }
59 }
60 GroupId(0)
61 }
62
63 // ── Re-routing ───────────────────────────────────────────────────
64
65 /// Change a rider's destination mid-route.
66 ///
67 /// Replaces remaining route legs with a single direct leg to `new_destination`,
68 /// keeping the rider's current stop as origin.
69 ///
70 /// Returns `Err` if the rider does not exist or is not in `Waiting` phase
71 /// (riding/boarding riders cannot be rerouted until they exit).
72 ///
73 /// # Errors
74 ///
75 /// Returns [`SimError::EntityNotFound`] if `rider` does not exist.
76 /// Returns [`SimError::InvalidState`] if the rider is not in
77 /// [`RiderPhase::Waiting`] or has no current stop.
78 pub fn reroute(&mut self, rider: EntityId, new_destination: EntityId) -> Result<(), SimError> {
79 let r = self
80 .world
81 .rider(rider)
82 .ok_or(SimError::EntityNotFound(rider))?;
83
84 if r.phase != RiderPhase::Waiting {
85 return Err(SimError::InvalidState {
86 entity: rider,
87 reason: "can only reroute riders in Waiting phase".into(),
88 });
89 }
90
91 let origin = r.current_stop.ok_or_else(|| SimError::InvalidState {
92 entity: rider,
93 reason: "rider has no current stop for reroute".into(),
94 })?;
95
96 let group = self.group_from_route(self.world.route(rider));
97 self.world
98 .set_route(rider, Route::direct(origin, new_destination, group));
99
100 self.events.emit(Event::RiderRerouted {
101 rider,
102 new_destination,
103 tick: self.tick,
104 });
105
106 Ok(())
107 }
108
109 /// Replace a rider's entire remaining route.
110 ///
111 /// # Errors
112 ///
113 /// Returns [`SimError::EntityNotFound`] if `rider` does not exist.
114 pub fn set_rider_route(&mut self, rider: EntityId, route: Route) -> Result<(), SimError> {
115 if self.world.rider(rider).is_none() {
116 return Err(SimError::EntityNotFound(rider));
117 }
118 self.world.set_route(rider, route);
119 Ok(())
120 }
121
122 // ── Rider settlement & population ─────────────────────────────
123
124 /// Transition an `Arrived` or `Abandoned` rider to `Resident` at their
125 /// current stop.
126 ///
127 /// Resident riders are parked — invisible to dispatch and loading, but
128 /// queryable via [`residents_at()`](Self::residents_at). They can later
129 /// be given a new route via [`reroute_rider()`](Self::reroute_rider).
130 ///
131 /// # Errors
132 ///
133 /// Returns [`SimError::EntityNotFound`] if `id` does not exist.
134 /// Returns [`SimError::InvalidState`] if the rider is not in
135 /// `Arrived` or `Abandoned` phase, or has no current stop.
136 pub fn settle_rider(&mut self, id: EntityId) -> Result<(), SimError> {
137 let rider = self.world.rider(id).ok_or(SimError::EntityNotFound(id))?;
138
139 let old_phase = rider.phase;
140 match old_phase {
141 RiderPhase::Arrived | RiderPhase::Abandoned => {}
142 _ => {
143 return Err(SimError::InvalidState {
144 entity: id,
145 reason: format!(
146 "cannot settle rider in {old_phase} phase, expected Arrived or Abandoned"
147 ),
148 });
149 }
150 }
151
152 let stop = rider.current_stop.ok_or_else(|| SimError::InvalidState {
153 entity: id,
154 reason: "rider has no current_stop".into(),
155 })?;
156
157 // Update index: remove from old partition (only Abandoned is indexed).
158 if old_phase == RiderPhase::Abandoned {
159 self.rider_index.remove_abandoned(stop, id);
160 }
161 self.rider_index.insert_resident(stop, id);
162
163 if let Some(r) = self.world.rider_mut(id) {
164 r.phase = RiderPhase::Resident;
165 }
166
167 self.metrics.record_settle();
168 self.events.emit(Event::RiderSettled {
169 rider: id,
170 stop,
171 tick: self.tick,
172 });
173 Ok(())
174 }
175
176 /// Give a `Resident` rider a new route, transitioning them to `Waiting`.
177 ///
178 /// The rider begins waiting at their current stop for an elevator
179 /// matching the route's transport mode. If the rider has a
180 /// [`Patience`](crate::components::Patience) component, its
181 /// `waited_ticks` is reset to zero.
182 ///
183 /// # Errors
184 ///
185 /// Returns [`SimError::EntityNotFound`] if `id` does not exist.
186 /// Returns [`SimError::InvalidState`] if the rider is not in `Resident` phase,
187 /// the route has no legs, or the route's first leg origin does not match the
188 /// rider's current stop.
189 pub fn reroute_rider(&mut self, id: EntityId, route: Route) -> Result<(), SimError> {
190 let rider = self.world.rider(id).ok_or(SimError::EntityNotFound(id))?;
191
192 if rider.phase != RiderPhase::Resident {
193 return Err(SimError::InvalidState {
194 entity: id,
195 reason: format!(
196 "cannot reroute rider in {} phase, expected Resident",
197 rider.phase
198 ),
199 });
200 }
201
202 let stop = rider.current_stop.ok_or_else(|| SimError::InvalidState {
203 entity: id,
204 reason: "resident rider has no current_stop".into(),
205 })?;
206
207 let new_destination = route
208 .final_destination()
209 .ok_or_else(|| SimError::InvalidState {
210 entity: id,
211 reason: "route has no legs".into(),
212 })?;
213
214 // Validate that the route departs from the rider's current stop.
215 if let Some(leg) = route.current()
216 && leg.from != stop
217 {
218 return Err(SimError::InvalidState {
219 entity: id,
220 reason: format!(
221 "route origin {:?} does not match rider current_stop {:?}",
222 leg.from, stop
223 ),
224 });
225 }
226
227 self.rider_index.remove_resident(stop, id);
228 self.rider_index.insert_waiting(stop, id);
229
230 if let Some(r) = self.world.rider_mut(id) {
231 r.phase = RiderPhase::Waiting;
232 }
233 self.world.set_route(id, route);
234
235 // Reset patience if present.
236 if let Some(p) = self.world.patience_mut(id) {
237 p.waited_ticks = 0;
238 }
239
240 self.metrics.record_reroute();
241 self.events.emit(Event::RiderRerouted {
242 rider: id,
243 new_destination,
244 tick: self.tick,
245 });
246 Ok(())
247 }
248
249 /// Remove a rider from the simulation entirely.
250 ///
251 /// Cleans up the population index, metric tags, and elevator cross-references
252 /// (if the rider is currently aboard). Emits [`Event::RiderDespawned`].
253 ///
254 /// All rider removal should go through this method rather than calling
255 /// `world.despawn()` directly, to keep the population index consistent.
256 ///
257 /// # Errors
258 ///
259 /// Returns [`SimError::EntityNotFound`] if `id` does not exist or is
260 /// not a rider.
261 pub fn despawn_rider(&mut self, id: EntityId) -> Result<(), SimError> {
262 let rider = self.world.rider(id).ok_or(SimError::EntityNotFound(id))?;
263
264 // Targeted index removal based on current phase (O(1) vs O(n) scan).
265 if let Some(stop) = rider.current_stop {
266 match rider.phase {
267 RiderPhase::Waiting => self.rider_index.remove_waiting(stop, id),
268 RiderPhase::Resident => self.rider_index.remove_resident(stop, id),
269 RiderPhase::Abandoned => self.rider_index.remove_abandoned(stop, id),
270 _ => {} // Boarding/Riding/Exiting/Walking/Arrived — not indexed
271 }
272 }
273
274 if let Some(tags) = self
275 .world
276 .resource_mut::<crate::tagged_metrics::MetricTags>()
277 {
278 tags.remove_entity(id);
279 }
280
281 self.world.despawn(id);
282
283 self.events.emit(Event::RiderDespawned {
284 rider: id,
285 tick: self.tick,
286 });
287 Ok(())
288 }
289
290 // ── Access control ──────────────────────────────────────────────
291
292 /// Set the allowed stops for a rider.
293 ///
294 /// When set, the rider will only be allowed to board elevators that
295 /// can take them to a stop in the allowed set. See
296 /// [`AccessControl`](crate::components::AccessControl) for details.
297 ///
298 /// # Errors
299 ///
300 /// Returns [`SimError::EntityNotFound`] if the rider does not exist.
301 pub fn set_rider_access(
302 &mut self,
303 rider: EntityId,
304 allowed_stops: HashSet<EntityId>,
305 ) -> Result<(), SimError> {
306 if self.world.rider(rider).is_none() {
307 return Err(SimError::EntityNotFound(rider));
308 }
309 self.world
310 .set_access_control(rider, crate::components::AccessControl::new(allowed_stops));
311 Ok(())
312 }
313
314 /// Set the restricted stops for an elevator.
315 ///
316 /// Riders whose current destination is in this set will be rejected
317 /// with [`RejectionReason::AccessDenied`](crate::error::RejectionReason::AccessDenied)
318 /// during the loading phase.
319 ///
320 /// # Errors
321 ///
322 /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
323 pub fn set_elevator_restricted_stops(
324 &mut self,
325 elevator: EntityId,
326 restricted_stops: HashSet<EntityId>,
327 ) -> Result<(), SimError> {
328 let car = self
329 .world
330 .elevator_mut(elevator)
331 .ok_or(SimError::EntityNotFound(elevator))?;
332 car.restricted_stops = restricted_stops;
333 Ok(())
334 }
335
336 // ── Population queries ──────────────────────────────────────────
337
338 /// Iterate over resident rider IDs at a stop (O(1) lookup).
339 pub fn residents_at(&self, stop: EntityId) -> impl Iterator<Item = EntityId> + '_ {
340 self.rider_index.residents_at(stop).iter().copied()
341 }
342
343 /// Count of residents at a stop (O(1)).
344 #[must_use]
345 pub fn resident_count_at(&self, stop: EntityId) -> usize {
346 self.rider_index.resident_count_at(stop)
347 }
348
349 /// Iterate over waiting rider IDs at a stop (O(1) lookup).
350 pub fn waiting_at(&self, stop: EntityId) -> impl Iterator<Item = EntityId> + '_ {
351 self.rider_index.waiting_at(stop).iter().copied()
352 }
353
354 /// Count of waiting riders at a stop (O(1)).
355 #[must_use]
356 pub fn waiting_count_at(&self, stop: EntityId) -> usize {
357 self.rider_index.waiting_count_at(stop)
358 }
359
360 /// Iterate over abandoned rider IDs at a stop (O(1) lookup).
361 pub fn abandoned_at(&self, stop: EntityId) -> impl Iterator<Item = EntityId> + '_ {
362 self.rider_index.abandoned_at(stop).iter().copied()
363 }
364
365 /// Count of abandoned riders at a stop (O(1)).
366 #[must_use]
367 pub fn abandoned_count_at(&self, stop: EntityId) -> usize {
368 self.rider_index.abandoned_count_at(stop)
369 }
370
371 /// Get the rider entities currently aboard an elevator.
372 ///
373 /// Returns an empty slice if the elevator does not exist.
374 #[must_use]
375 pub fn riders_on(&self, elevator: EntityId) -> &[EntityId] {
376 self.world
377 .elevator(elevator)
378 .map_or(&[], |car| car.riders())
379 }
380
381 /// Get the number of riders aboard an elevator.
382 ///
383 /// Returns 0 if the elevator does not exist.
384 #[must_use]
385 pub fn occupancy(&self, elevator: EntityId) -> usize {
386 self.world
387 .elevator(elevator)
388 .map_or(0, |car| car.riders().len())
389 }
390
391 // ── Entity lifecycle ────────────────────────────────────────────
392
393 /// Disable an entity. Disabled entities are skipped by all systems.
394 ///
395 /// If the entity is an elevator in motion, it is reset to `Idle` with
396 /// zero velocity to prevent stale target references on re-enable.
397 ///
398 /// **Note on residents:** disabling a stop does not automatically handle
399 /// `Resident` riders parked there. Callers should listen for
400 /// [`Event::EntityDisabled`] and manually reroute or despawn any
401 /// residents at the affected stop.
402 ///
403 /// Emits `EntityDisabled`. Returns `Err` if the entity does not exist.
404 ///
405 /// # Errors
406 ///
407 /// Returns [`SimError::EntityNotFound`] if `id` does not refer to a
408 /// living entity.
409 pub fn disable(&mut self, id: EntityId) -> Result<(), SimError> {
410 if !self.world.is_alive(id) {
411 return Err(SimError::EntityNotFound(id));
412 }
413 // If this is an elevator, eject all riders and reset state.
414 if let Some(car) = self.world.elevator(id) {
415 let rider_ids = car.riders.clone();
416 let pos = self.world.position(id).map_or(0.0, |p| p.value);
417 let nearest_stop = self.world.find_nearest_stop(pos);
418
419 for rid in &rider_ids {
420 if let Some(r) = self.world.rider_mut(*rid) {
421 r.phase = RiderPhase::Waiting;
422 r.current_stop = nearest_stop;
423 r.board_tick = None;
424 }
425 if let Some(stop) = nearest_stop {
426 self.rider_index.insert_waiting(stop, *rid);
427 self.events.emit(Event::RiderEjected {
428 rider: *rid,
429 elevator: id,
430 stop,
431 tick: self.tick,
432 });
433 }
434 }
435
436 let had_load = self
437 .world
438 .elevator(id)
439 .is_some_and(|c| c.current_load > 0.0);
440 let capacity = self.world.elevator(id).map(|c| c.weight_capacity);
441 if let Some(car) = self.world.elevator_mut(id) {
442 car.riders.clear();
443 car.current_load = 0.0;
444 car.phase = ElevatorPhase::Idle;
445 car.target_stop = None;
446 }
447 if had_load && let Some(cap) = capacity {
448 self.events.emit(Event::CapacityChanged {
449 elevator: id,
450 current_load: ordered_float::OrderedFloat(0.0),
451 capacity: ordered_float::OrderedFloat(cap),
452 tick: self.tick,
453 });
454 }
455 }
456 if let Some(vel) = self.world.velocity_mut(id) {
457 vel.value = 0.0;
458 }
459
460 // If this is a stop, invalidate routes that reference it.
461 if self.world.stop(id).is_some() {
462 self.invalidate_routes_for_stop(id);
463 }
464
465 self.world.disable(id);
466 self.events.emit(Event::EntityDisabled {
467 entity: id,
468 tick: self.tick,
469 });
470 Ok(())
471 }
472
473 /// Re-enable a disabled entity.
474 ///
475 /// Emits `EntityEnabled`. Returns `Err` if the entity does not exist.
476 ///
477 /// # Errors
478 ///
479 /// Returns [`SimError::EntityNotFound`] if `id` does not refer to a
480 /// living entity.
481 pub fn enable(&mut self, id: EntityId) -> Result<(), SimError> {
482 if !self.world.is_alive(id) {
483 return Err(SimError::EntityNotFound(id));
484 }
485 self.world.enable(id);
486 self.events.emit(Event::EntityEnabled {
487 entity: id,
488 tick: self.tick,
489 });
490 Ok(())
491 }
492
493 /// Invalidate routes for all riders referencing a disabled stop.
494 ///
495 /// Attempts to reroute riders to the nearest enabled alternative stop.
496 /// If no alternative exists, emits `RouteInvalidated` with `NoAlternative`.
497 fn invalidate_routes_for_stop(&mut self, disabled_stop: EntityId) {
498 use crate::events::RouteInvalidReason;
499
500 // Find the group this stop belongs to.
501 let group_stops: Vec<EntityId> = self
502 .groups
503 .iter()
504 .filter(|g| g.stop_entities().contains(&disabled_stop))
505 .flat_map(|g| g.stop_entities().iter().copied())
506 .filter(|&s| s != disabled_stop && !self.world.is_disabled(s))
507 .collect();
508
509 // Find all Waiting riders whose route references this stop.
510 // Riding riders are skipped — they'll be rerouted when they exit.
511 let rider_ids: Vec<EntityId> = self.world.rider_ids();
512 for rid in rider_ids {
513 let is_waiting = self
514 .world
515 .rider(rid)
516 .is_some_and(|r| r.phase == RiderPhase::Waiting);
517
518 if !is_waiting {
519 continue;
520 }
521
522 let references_stop = self.world.route(rid).is_some_and(|route| {
523 route
524 .legs
525 .iter()
526 .skip(route.current_leg)
527 .any(|leg| leg.to == disabled_stop || leg.from == disabled_stop)
528 });
529
530 if !references_stop {
531 continue;
532 }
533
534 // Try to find nearest alternative (excluding rider's current stop).
535 let rider_current_stop = self.world.rider(rid).and_then(|r| r.current_stop);
536
537 let disabled_stop_pos = self.world.stop(disabled_stop).map_or(0.0, |s| s.position);
538
539 let alternative = group_stops
540 .iter()
541 .filter(|&&s| Some(s) != rider_current_stop)
542 .filter_map(|&s| {
543 self.world
544 .stop(s)
545 .map(|stop| (s, (stop.position - disabled_stop_pos).abs()))
546 })
547 .min_by(|a, b| a.1.total_cmp(&b.1))
548 .map(|(s, _)| s);
549
550 if let Some(alt_stop) = alternative {
551 // Reroute to nearest alternative.
552 let origin = rider_current_stop.unwrap_or(alt_stop);
553 let group = self.group_from_route(self.world.route(rid));
554 self.world
555 .set_route(rid, Route::direct(origin, alt_stop, group));
556 self.events.emit(Event::RouteInvalidated {
557 rider: rid,
558 affected_stop: disabled_stop,
559 reason: RouteInvalidReason::StopDisabled,
560 tick: self.tick,
561 });
562 } else {
563 // No alternative — rider abandons immediately.
564 let abandon_stop = rider_current_stop.unwrap_or(disabled_stop);
565 self.events.emit(Event::RouteInvalidated {
566 rider: rid,
567 affected_stop: disabled_stop,
568 reason: RouteInvalidReason::NoAlternative,
569 tick: self.tick,
570 });
571 if let Some(r) = self.world.rider_mut(rid) {
572 r.phase = RiderPhase::Abandoned;
573 }
574 if let Some(stop) = rider_current_stop {
575 self.rider_index.remove_waiting(stop, rid);
576 self.rider_index.insert_abandoned(stop, rid);
577 }
578 self.events.emit(Event::RiderAbandoned {
579 rider: rid,
580 stop: abandon_stop,
581 tick: self.tick,
582 });
583 }
584 }
585 }
586
587 /// Check if an entity is disabled.
588 #[must_use]
589 pub fn is_disabled(&self, id: EntityId) -> bool {
590 self.world.is_disabled(id)
591 }
592
593 // ── Entity type queries ─────────────────────────────────────────
594
595 /// Check if an entity is an elevator.
596 ///
597 /// ```
598 /// use elevator_core::prelude::*;
599 ///
600 /// let sim = SimulationBuilder::demo().build().unwrap();
601 /// let stop = sim.stop_entity(StopId(0)).unwrap();
602 /// assert!(!sim.is_elevator(stop));
603 /// assert!(sim.is_stop(stop));
604 /// ```
605 #[must_use]
606 pub fn is_elevator(&self, id: EntityId) -> bool {
607 self.world.elevator(id).is_some()
608 }
609
610 /// Check if an entity is a rider.
611 #[must_use]
612 pub fn is_rider(&self, id: EntityId) -> bool {
613 self.world.rider(id).is_some()
614 }
615
616 /// Check if an entity is a stop.
617 #[must_use]
618 pub fn is_stop(&self, id: EntityId) -> bool {
619 self.world.stop(id).is_some()
620 }
621
622 // ── Aggregate queries ───────────────────────────────────────────
623
624 /// Count of elevators currently in the [`Idle`](ElevatorPhase::Idle) phase.
625 ///
626 /// Excludes disabled elevators (whose phase is reset to `Idle` on disable).
627 ///
628 /// ```
629 /// use elevator_core::prelude::*;
630 ///
631 /// let sim = SimulationBuilder::demo().build().unwrap();
632 /// assert_eq!(sim.idle_elevator_count(), 1);
633 /// ```
634 #[must_use]
635 pub fn idle_elevator_count(&self) -> usize {
636 self.world.iter_idle_elevators().count()
637 }
638
639 /// Current total weight aboard an elevator, or `None` if the entity is
640 /// not an elevator.
641 ///
642 /// ```
643 /// use elevator_core::prelude::*;
644 ///
645 /// let sim = SimulationBuilder::demo().build().unwrap();
646 /// let stop = sim.stop_entity(StopId(0)).unwrap();
647 /// assert_eq!(sim.elevator_load(stop), None); // not an elevator
648 /// ```
649 #[must_use]
650 pub fn elevator_load(&self, id: EntityId) -> Option<f64> {
651 self.world.elevator(id).map(|e| e.current_load)
652 }
653
654 /// Whether the elevator's up-direction indicator lamp is lit.
655 ///
656 /// Returns `None` if the entity is not an elevator. See
657 /// [`Elevator::going_up`] for semantics.
658 #[must_use]
659 pub fn elevator_going_up(&self, id: EntityId) -> Option<bool> {
660 self.world.elevator(id).map(Elevator::going_up)
661 }
662
663 /// Whether the elevator's down-direction indicator lamp is lit.
664 ///
665 /// Returns `None` if the entity is not an elevator. See
666 /// [`Elevator::going_down`] for semantics.
667 #[must_use]
668 pub fn elevator_going_down(&self, id: EntityId) -> Option<bool> {
669 self.world.elevator(id).map(Elevator::going_down)
670 }
671
672 /// Direction the elevator is currently signalling, derived from the
673 /// indicator-lamp pair. Returns `None` if the entity is not an elevator.
674 #[must_use]
675 pub fn elevator_direction(&self, id: EntityId) -> Option<crate::components::Direction> {
676 self.world.elevator(id).map(Elevator::direction)
677 }
678
679 /// Count of rounded-floor transitions for an elevator (passing-floor
680 /// crossings plus arrivals). Returns `None` if the entity is not an
681 /// elevator.
682 #[must_use]
683 pub fn elevator_move_count(&self, id: EntityId) -> Option<u64> {
684 self.world.elevator(id).map(Elevator::move_count)
685 }
686
687 /// Distance the elevator would travel while braking to a stop from its
688 /// current velocity, at its configured deceleration rate.
689 ///
690 /// Uses the standard `v² / (2·a)` kinematic formula. A stationary
691 /// elevator returns `Some(0.0)`. Returns `None` if the entity is not
692 /// an elevator or lacks a velocity component.
693 ///
694 /// Useful for writing opportunistic dispatch strategies (e.g. "stop at
695 /// this floor if we can brake in time") without duplicating the physics
696 /// computation.
697 #[must_use]
698 pub fn braking_distance(&self, id: EntityId) -> Option<f64> {
699 let car = self.world.elevator(id)?;
700 let vel = self.world.velocity(id)?.value;
701 Some(crate::movement::braking_distance(vel, car.deceleration))
702 }
703
704 /// The position where the elevator would come to rest if it began braking
705 /// this instant. Current position plus a signed braking distance in the
706 /// direction of travel.
707 ///
708 /// Returns `None` if the entity is not an elevator or lacks the required
709 /// components.
710 #[must_use]
711 pub fn future_stop_position(&self, id: EntityId) -> Option<f64> {
712 let pos = self.world.position(id)?.value;
713 let vel = self.world.velocity(id)?.value;
714 let car = self.world.elevator(id)?;
715 let dist = crate::movement::braking_distance(vel, car.deceleration);
716 Some(vel.signum().mul_add(dist, pos))
717 }
718
719 /// Count of elevators currently in the given phase.
720 ///
721 /// Excludes disabled elevators (whose phase is reset to `Idle` on disable).
722 ///
723 /// ```
724 /// use elevator_core::prelude::*;
725 ///
726 /// let sim = SimulationBuilder::demo().build().unwrap();
727 /// assert_eq!(sim.elevators_in_phase(ElevatorPhase::Idle), 1);
728 /// assert_eq!(sim.elevators_in_phase(ElevatorPhase::Loading), 0);
729 /// ```
730 #[must_use]
731 pub fn elevators_in_phase(&self, phase: ElevatorPhase) -> usize {
732 self.world
733 .iter_elevators()
734 .filter(|(id, _, e)| e.phase() == phase && !self.world.is_disabled(*id))
735 .count()
736 }
737
738 // ── Service mode ────────────────────────────────────────────────
739
740 /// Set the service mode for an elevator.
741 ///
742 /// Emits [`Event::ServiceModeChanged`] if the mode actually changes.
743 ///
744 /// # Errors
745 ///
746 /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
747 pub fn set_service_mode(
748 &mut self,
749 elevator: EntityId,
750 mode: crate::components::ServiceMode,
751 ) -> Result<(), SimError> {
752 if self.world.elevator(elevator).is_none() {
753 return Err(SimError::EntityNotFound(elevator));
754 }
755 let old = self
756 .world
757 .service_mode(elevator)
758 .copied()
759 .unwrap_or_default();
760 if old == mode {
761 return Ok(());
762 }
763 self.world.set_service_mode(elevator, mode);
764 self.events.emit(Event::ServiceModeChanged {
765 elevator,
766 from: old,
767 to: mode,
768 tick: self.tick,
769 });
770 Ok(())
771 }
772
773 /// Get the current service mode for an elevator.
774 #[must_use]
775 pub fn service_mode(&self, elevator: EntityId) -> crate::components::ServiceMode {
776 self.world
777 .service_mode(elevator)
778 .copied()
779 .unwrap_or_default()
780 }
781}