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    /// ```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}