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