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