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