Skip to main content

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}