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