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::{
10    CallDirection, Elevator, ElevatorPhase, RiderPhase, RiderPhaseKind, Route, TransportMode,
11};
12use crate::dispatch::ElevatorGroup;
13use crate::entity::{ElevatorId, EntityId, RiderId};
14use crate::error::SimError;
15use crate::events::Event;
16use crate::ids::GroupId;
17
18use super::Simulation;
19
20impl Simulation {
21    // ── Extension restore ────────────────────────────────────────────
22
23    /// Deserialize extension components from a snapshot.
24    ///
25    /// Call this after restoring from a snapshot and registering all
26    /// extension types via `world.register_ext::<T>(key)`.
27    ///
28    /// Returns the names of any extension types present in the snapshot
29    /// that were not registered. An empty vec means all extensions were
30    /// deserialized successfully.
31    ///
32    /// Prefer [`load_extensions_with`](Self::load_extensions_with) which
33    /// combines registration and loading in one call.
34    #[must_use]
35    pub fn load_extensions(&mut self) -> Vec<String> {
36        let Some(pending) = self
37            .world
38            .remove_resource::<crate::snapshot::PendingExtensions>()
39        else {
40            return Vec::new();
41        };
42        let unregistered = self.world.unregistered_ext_names(pending.0.keys());
43        self.world.deserialize_extensions(&pending.0);
44        unregistered
45    }
46
47    /// Register extension types and load their data from a snapshot
48    /// in one step.
49    ///
50    /// This is the recommended way to restore extensions. It replaces the
51    /// manual 3-step ceremony of `register_ext` → `load_extensions`:
52    ///
53    /// ```no_run
54    /// # use elevator_core::prelude::*;
55    /// # use elevator_core::register_extensions;
56    /// # use elevator_core::snapshot::WorldSnapshot;
57    /// # use serde::{Serialize, Deserialize};
58    /// # #[derive(Clone, Serialize, Deserialize)] struct VipTag;
59    /// # #[derive(Clone, Serialize, Deserialize)] struct TeamId;
60    /// # fn before(snapshot: WorldSnapshot) -> Result<(), SimError> {
61    /// // Before (3-step ceremony):
62    /// let mut sim = snapshot.restore(None)?;
63    /// sim.world_mut().register_ext::<VipTag>(ExtKey::from_type_name());
64    /// sim.world_mut().register_ext::<TeamId>(ExtKey::from_type_name());
65    /// sim.load_extensions();
66    /// # Ok(()) }
67    /// # fn after(snapshot: WorldSnapshot) -> Result<(), SimError> {
68    ///
69    /// // After:
70    /// let mut sim = snapshot.restore(None)?;
71    /// let unregistered = sim.load_extensions_with(|world| {
72    ///     register_extensions!(world, VipTag, TeamId);
73    /// });
74    /// assert!(unregistered.is_empty(), "missing: {unregistered:?}");
75    /// # Ok(()) }
76    /// ```
77    ///
78    /// Returns the names of any extension types in the snapshot that were
79    /// not registered. This catches "forgot to register" bugs at load time.
80    #[must_use]
81    pub fn load_extensions_with<F>(&mut self, register: F) -> Vec<String>
82    where
83        F: FnOnce(&mut crate::world::World),
84    {
85        register(&mut self.world);
86        self.load_extensions()
87    }
88
89    // ── Helpers ──────────────────────────────────────────────────────
90
91    /// Extract the `GroupId` from the current leg of a route.
92    ///
93    /// For Walk legs, looks ahead to the next leg to find the group.
94    /// Falls back to `GroupId(0)` when no route exists or no group leg is found.
95    pub(super) fn group_from_route(&self, route: Option<&Route>) -> GroupId {
96        if let Some(route) = route {
97            // Scan forward from current_leg looking for a Group or Line transport mode.
98            for leg in route.legs.iter().skip(route.current_leg) {
99                match leg.via {
100                    crate::components::TransportMode::Group(g) => return g,
101                    crate::components::TransportMode::Line(l) => {
102                        if let Some(line) = self.world.line(l) {
103                            return line.group();
104                        }
105                    }
106                    crate::components::TransportMode::Walk => {}
107                }
108            }
109        }
110        GroupId(0)
111    }
112
113    // ── Re-routing ───────────────────────────────────────────────────
114
115    /// Change a rider's destination mid-route.
116    ///
117    /// Replaces remaining route legs with a single direct leg to `new_destination`,
118    /// keeping the rider's current stop as origin.
119    ///
120    /// Returns `Err` if the rider does not exist or is not in `Waiting` phase
121    /// (riding/boarding riders cannot be rerouted until they exit).
122    ///
123    /// # Errors
124    ///
125    /// Returns [`SimError::EntityNotFound`] if `rider` does not exist.
126    /// Returns [`SimError::WrongRiderPhase`] if the rider is not in
127    /// [`RiderPhase::Waiting`], or [`SimError::RiderHasNoStop`] if the
128    /// rider has no current stop.
129    pub fn reroute(&mut self, rider: RiderId, new_destination: EntityId) -> Result<(), SimError> {
130        let rider = rider.entity();
131        let r = self
132            .world
133            .rider(rider)
134            .ok_or(SimError::EntityNotFound(rider))?;
135
136        if r.phase != RiderPhase::Waiting {
137            return Err(SimError::WrongRiderPhase {
138                rider,
139                expected: RiderPhaseKind::Waiting,
140                actual: r.phase.kind(),
141            });
142        }
143
144        let origin = r.current_stop.ok_or(SimError::RiderHasNoStop(rider))?;
145
146        let group = self.group_from_route(self.world.route(rider));
147        self.world
148            .set_route(rider, Route::direct(origin, new_destination, group));
149
150        let tag = self
151            .world
152            .rider(rider)
153            .map_or(0, crate::components::Rider::tag);
154        self.events.emit(Event::RiderRerouted {
155            rider,
156            new_destination,
157            tag,
158            tick: self.tick,
159        });
160
161        Ok(())
162    }
163
164    /// Replace a rider's entire remaining route.
165    ///
166    /// # Errors
167    ///
168    /// Returns [`SimError::EntityNotFound`] if `rider` does not exist.
169    pub fn set_rider_route(&mut self, rider: EntityId, route: Route) -> Result<(), SimError> {
170        if self.world.rider(rider).is_none() {
171            return Err(SimError::EntityNotFound(rider));
172        }
173        self.world.set_route(rider, route);
174        Ok(())
175    }
176
177    // ── Rider settlement & population ─────────────────────────────
178
179    /// Transition an `Arrived` or `Abandoned` rider to `Resident` at their
180    /// current stop.
181    ///
182    /// Resident riders are parked — invisible to dispatch and loading, but
183    /// queryable via [`residents_at()`](Self::residents_at). They can later
184    /// be given a new route via [`reroute_rider()`](Self::reroute_rider).
185    ///
186    /// # Errors
187    ///
188    /// Returns [`SimError::EntityNotFound`] if `id` does not exist.
189    /// Returns [`SimError::WrongRiderPhase`] if the rider is not in
190    /// `Arrived` or `Abandoned` phase, or [`SimError::RiderHasNoStop`]
191    /// if the rider has no current stop.
192    pub fn settle_rider(&mut self, id: RiderId) -> Result<(), SimError> {
193        let id = id.entity();
194        let rider = self.world.rider(id).ok_or(SimError::EntityNotFound(id))?;
195
196        let old_phase = rider.phase;
197        match old_phase {
198            RiderPhase::Arrived | RiderPhase::Abandoned => {}
199            _ => {
200                return Err(SimError::WrongRiderPhase {
201                    rider: id,
202                    expected: RiderPhaseKind::Arrived,
203                    actual: old_phase.kind(),
204                });
205            }
206        }
207
208        let stop = rider.current_stop.ok_or(SimError::RiderHasNoStop(id))?;
209
210        // Update index: remove from old partition (only Abandoned is indexed).
211        if old_phase == RiderPhase::Abandoned {
212            self.rider_index.remove_abandoned(stop, id);
213        }
214        self.rider_index.insert_resident(stop, id);
215
216        if let Some(r) = self.world.rider_mut(id) {
217            r.phase = RiderPhase::Resident;
218        }
219
220        self.metrics.record_settle();
221        let tag = self
222            .world
223            .rider(id)
224            .map_or(0, crate::components::Rider::tag);
225        self.events.emit(Event::RiderSettled {
226            rider: id,
227            stop,
228            tag,
229            tick: self.tick,
230        });
231        Ok(())
232    }
233
234    /// Give a `Resident` rider a new route, transitioning them to `Waiting`.
235    ///
236    /// The rider begins waiting at their current stop for an elevator
237    /// matching the route's transport mode. If the rider has a
238    /// [`Patience`](crate::components::Patience) component, its
239    /// `waited_ticks` is reset to zero.
240    ///
241    /// # Errors
242    ///
243    /// Returns [`SimError::EntityNotFound`] if `id` does not exist.
244    /// Returns [`SimError::WrongRiderPhase`] if the rider is not in `Resident`
245    /// phase, [`SimError::EmptyRoute`] if the route has no legs, or
246    /// [`SimError::RouteOriginMismatch`] if the route's first leg origin does
247    /// not match the rider's current stop.
248    pub fn reroute_rider(&mut self, id: EntityId, route: Route) -> Result<(), SimError> {
249        let rider = self.world.rider(id).ok_or(SimError::EntityNotFound(id))?;
250
251        if rider.phase != RiderPhase::Resident {
252            return Err(SimError::WrongRiderPhase {
253                rider: id,
254                expected: RiderPhaseKind::Resident,
255                actual: rider.phase.kind(),
256            });
257        }
258
259        let stop = rider.current_stop.ok_or(SimError::RiderHasNoStop(id))?;
260
261        let new_destination = route.final_destination().ok_or(SimError::EmptyRoute)?;
262
263        // Validate that the route departs from the rider's current stop.
264        if let Some(leg) = route.current()
265            && leg.from != stop
266        {
267            return Err(SimError::RouteOriginMismatch {
268                expected_origin: stop,
269                route_origin: leg.from,
270            });
271        }
272
273        self.rider_index.remove_resident(stop, id);
274        self.rider_index.insert_waiting(stop, id);
275
276        if let Some(r) = self.world.rider_mut(id) {
277            r.phase = RiderPhase::Waiting;
278            // Reset spawn_tick so manifest wait_ticks measures time since
279            // reroute, not time since the original spawn as a Resident.
280            r.spawn_tick = self.tick;
281        }
282        self.world.set_route(id, route);
283
284        // Reset patience if present.
285        if let Some(p) = self.world.patience_mut(id) {
286            p.waited_ticks = 0;
287        }
288
289        // A rerouted resident is indistinguishable from a fresh arrival —
290        // record it so predictive parking and `arrivals_at` see the demand.
291        // Mirror into the destination log so down-peak classification stays
292        // coherent for multi-leg riders.
293        if let Some(log) = self.world.resource_mut::<crate::arrival_log::ArrivalLog>() {
294            log.record(self.tick, stop);
295        }
296        if let Some(log) = self
297            .world
298            .resource_mut::<crate::arrival_log::DestinationLog>()
299        {
300            log.record(self.tick, new_destination);
301        }
302
303        self.metrics.record_reroute();
304        let tag = self
305            .world
306            .rider(id)
307            .map_or(0, crate::components::Rider::tag);
308        self.events.emit(Event::RiderRerouted {
309            rider: id,
310            new_destination,
311            tag,
312            tick: self.tick,
313        });
314        Ok(())
315    }
316
317    /// Remove a rider from the simulation entirely.
318    ///
319    /// Cleans up the population index, metric tags, and elevator cross-references
320    /// (if the rider is currently aboard). Emits [`Event::RiderDespawned`].
321    ///
322    /// All rider removal should go through this method rather than calling
323    /// `world.despawn()` directly, to keep the population index consistent.
324    ///
325    /// # Errors
326    ///
327    /// Returns [`SimError::EntityNotFound`] if `id` does not exist or is
328    /// not a rider.
329    pub fn despawn_rider(&mut self, id: RiderId) -> Result<(), SimError> {
330        let id = id.entity();
331        let rider = self.world.rider(id).ok_or(SimError::EntityNotFound(id))?;
332        let tag = rider.tag();
333
334        // Targeted index removal based on current phase (O(1) vs O(n) scan).
335        if let Some(stop) = rider.current_stop {
336            match rider.phase {
337                RiderPhase::Waiting => self.rider_index.remove_waiting(stop, id),
338                RiderPhase::Resident => self.rider_index.remove_resident(stop, id),
339                RiderPhase::Abandoned => self.rider_index.remove_abandoned(stop, id),
340                _ => {} // Boarding/Riding/Exiting/Walking/Arrived — not indexed
341            }
342        }
343
344        if let Some(tags) = self
345            .world
346            .resource_mut::<crate::tagged_metrics::MetricTags>()
347        {
348            tags.remove_entity(id);
349        }
350
351        // Purge stale `pending_riders` entries before the entity slot
352        // is reused. `world.despawn` cleans ext storage keyed on this
353        // rider (e.g. `AssignedCar`) but not back-references living on
354        // stop/car entities.
355        self.world.scrub_rider_from_pending_calls(id);
356
357        self.world.despawn(id);
358
359        self.events.emit(Event::RiderDespawned {
360            rider: id,
361            tag,
362            tick: self.tick,
363        });
364        Ok(())
365    }
366
367    // ── Access control ──────────────────────────────────────────────
368
369    /// Set the allowed stops for a rider.
370    ///
371    /// When set, the rider will only be allowed to board elevators that
372    /// can take them to a stop in the allowed set. See
373    /// [`AccessControl`](crate::components::AccessControl) for details.
374    ///
375    /// # Errors
376    ///
377    /// Returns [`SimError::EntityNotFound`] if the rider does not exist.
378    pub fn set_rider_access(
379        &mut self,
380        rider: EntityId,
381        allowed_stops: HashSet<EntityId>,
382    ) -> Result<(), SimError> {
383        if self.world.rider(rider).is_none() {
384            return Err(SimError::EntityNotFound(rider));
385        }
386        self.world
387            .set_access_control(rider, crate::components::AccessControl::new(allowed_stops));
388        Ok(())
389    }
390
391    /// Set the restricted stops for an elevator.
392    ///
393    /// Riders whose current destination is in this set will be rejected
394    /// with [`RejectionReason::AccessDenied`](crate::error::RejectionReason::AccessDenied)
395    /// during the loading phase.
396    ///
397    /// # Errors
398    ///
399    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
400    pub fn set_elevator_restricted_stops(
401        &mut self,
402        elevator: EntityId,
403        restricted_stops: HashSet<EntityId>,
404    ) -> Result<(), SimError> {
405        let car = self
406            .world
407            .elevator_mut(elevator)
408            .ok_or(SimError::EntityNotFound(elevator))?;
409        car.restricted_stops = restricted_stops;
410        Ok(())
411    }
412
413    // ── Population queries ──────────────────────────────────────────
414
415    /// Iterate over resident rider IDs at a stop (O(1) lookup).
416    pub fn residents_at(&self, stop: EntityId) -> impl Iterator<Item = EntityId> + '_ {
417        self.rider_index.residents_at(stop).iter().copied()
418    }
419
420    /// Count of residents at a stop (O(1)).
421    #[must_use]
422    pub fn resident_count_at(&self, stop: EntityId) -> usize {
423        self.rider_index.resident_count_at(stop)
424    }
425
426    /// Iterate over waiting rider IDs at a stop (O(1) lookup).
427    pub fn waiting_at(&self, stop: EntityId) -> impl Iterator<Item = EntityId> + '_ {
428        self.rider_index.waiting_at(stop).iter().copied()
429    }
430
431    /// Count of waiting riders at a stop (O(1)).
432    #[must_use]
433    pub fn waiting_count_at(&self, stop: EntityId) -> usize {
434        self.rider_index.waiting_count_at(stop)
435    }
436
437    /// Partition waiting riders at `stop` by their route direction.
438    ///
439    /// Returns `(up, down)` where `up` counts riders whose current route
440    /// destination lies above `stop` (they want to go up) and `down` counts
441    /// riders whose destination lies below. Riders without a [`Route`] or
442    /// whose current leg has no destination are excluded from both counts —
443    /// they have no intrinsic direction. The sum `up + down` may therefore
444    /// be less than [`waiting_count_at`](Self::waiting_count_at).
445    ///
446    /// Runs in `O(waiting riders at stop)`. Designed for per-frame rendering
447    /// code that wants to show up/down queues separately; dispatch strategies
448    /// should read [`HallCall`](crate::components::HallCall)s instead.
449    #[must_use]
450    pub fn waiting_direction_counts_at(&self, stop: EntityId) -> (usize, usize) {
451        let Some(origin_pos) = self.world.stop(stop).map(crate::components::Stop::position) else {
452            return (0, 0);
453        };
454        let mut up = 0usize;
455        let mut down = 0usize;
456        for rider in self.rider_index.waiting_at(stop) {
457            let Some(route) = self.world.route(*rider) else {
458                continue;
459            };
460            let Some(dest_entity) = route.current_destination() else {
461                continue;
462            };
463            let Some(dest_pos) = self
464                .world
465                .stop(dest_entity)
466                .map(crate::components::Stop::position)
467            else {
468                continue;
469            };
470            match CallDirection::between(origin_pos, dest_pos) {
471                Some(CallDirection::Up) => up += 1,
472                Some(CallDirection::Down) => down += 1,
473                None => {}
474            }
475        }
476        (up, down)
477    }
478
479    /// Partition waiting riders at `stop` by the line that will serve
480    /// their current route leg. Each entry is `(line_entity, count)`.
481    ///
482    /// Attribution rules:
483    /// - `TransportMode::Line(l)` riders are attributed to `l` exactly.
484    /// - `TransportMode::Group(g)` riders are attributed to the first
485    ///   line in group `g` whose `serves` list contains `stop`. Groups
486    ///   with a single line (the common case) attribute unambiguously.
487    /// - `TransportMode::Walk` riders and route-less / same-position
488    ///   riders are excluded — they have no intrinsic line to summon.
489    ///
490    /// Runs in `O(waiting riders at stop · lines in their group)`.
491    /// Intended for per-frame rendering code that needs to split the
492    /// waiting queue across multi-line stops (e.g. a sky-lobby shared
493    /// by low-bank, express, and service lines).
494    #[must_use]
495    pub fn waiting_counts_by_line_at(&self, stop: EntityId) -> Vec<(EntityId, u32)> {
496        use std::collections::BTreeMap;
497        let mut by_line: BTreeMap<EntityId, u32> = BTreeMap::new();
498        for &rider in self.rider_index.waiting_at(stop) {
499            let Some(line) = self.resolve_line_for_waiting(rider, stop) else {
500                continue;
501            };
502            *by_line.entry(line).or_insert(0) += 1;
503        }
504        by_line.into_iter().collect()
505    }
506
507    /// Resolve the line entity that should "claim" `rider` for their
508    /// current leg starting at `stop`. Used by
509    /// [`waiting_counts_by_line_at`](Self::waiting_counts_by_line_at).
510    fn resolve_line_for_waiting(&self, rider: EntityId, stop: EntityId) -> Option<EntityId> {
511        let leg = self.world.route(rider).and_then(Route::current)?;
512        match leg.via {
513            TransportMode::Line(l) => Some(l),
514            TransportMode::Group(g) => self.groups.iter().find(|gr| gr.id() == g).and_then(|gr| {
515                gr.lines()
516                    .iter()
517                    .find(|li| li.serves().contains(&stop))
518                    .map(crate::dispatch::LineInfo::entity)
519            }),
520            TransportMode::Walk => None,
521        }
522    }
523
524    /// Iterate over abandoned rider IDs at a stop (O(1) lookup).
525    pub fn abandoned_at(&self, stop: EntityId) -> impl Iterator<Item = EntityId> + '_ {
526        self.rider_index.abandoned_at(stop).iter().copied()
527    }
528
529    /// Count of abandoned riders at a stop (O(1)).
530    #[must_use]
531    pub fn abandoned_count_at(&self, stop: EntityId) -> usize {
532        self.rider_index.abandoned_count_at(stop)
533    }
534
535    /// Get the rider entities currently aboard an elevator.
536    ///
537    /// Returns an empty slice if the elevator does not exist.
538    #[must_use]
539    pub fn riders_on(&self, elevator: EntityId) -> &[EntityId] {
540        self.world
541            .elevator(elevator)
542            .map_or(&[], |car| car.riders())
543    }
544
545    /// Get the number of riders aboard an elevator.
546    ///
547    /// Returns 0 if the elevator does not exist.
548    #[must_use]
549    pub fn occupancy(&self, elevator: EntityId) -> usize {
550        self.world
551            .elevator(elevator)
552            .map_or(0, |car| car.riders().len())
553    }
554
555    // ── Entity lifecycle ────────────────────────────────────────────
556
557    /// Disable an entity. Disabled entities are skipped by all systems.
558    ///
559    /// If the entity is an elevator in motion, it is reset to `Idle` with
560    /// zero velocity to prevent stale target references on re-enable.
561    ///
562    /// If the entity is a stop, any `Resident` riders parked there are
563    /// transitioned to `Abandoned` and appropriate events are emitted.
564    ///
565    /// Emits `EntityDisabled`. Returns `Err` if the entity does not exist.
566    ///
567    /// # Errors
568    ///
569    /// Returns [`SimError::EntityNotFound`] if `id` does not refer to a
570    /// living entity.
571    pub fn disable(&mut self, id: EntityId) -> Result<(), SimError> {
572        if !self.world.is_alive(id) {
573            return Err(SimError::EntityNotFound(id));
574        }
575        // If this is an elevator, eject all riders and reset state.
576        if let Some(car) = self.world.elevator(id) {
577            let rider_ids = car.riders.clone();
578            let pos = self.world.position(id).map_or(0.0, |p| p.value);
579            let nearest_stop = self.world.find_nearest_stop(pos);
580
581            // Drop any sticky DCS assignments pointing at this car so
582            // routed riders are not stranded behind a dead reference.
583            crate::dispatch::destination::clear_assignments_to(&mut self.world, id);
584            // Same for hall-call assignments — pre-fix, a pinned hall
585            // call to the disabled car was permanently stranded because
586            // dispatch kept committing the disabled car as the assignee
587            // and other cars couldn't take the call. (#292) Now that
588            // assignments are per-line, drop only the line entries that
589            // reference the disabled car; other lines at the same stop
590            // keep their cars. The pin is lifted only when *every*
591            // remaining entry has been cleared, since a pin protects the
592            // whole call, not a single line's assignment.
593            for hc in self.world.iter_hall_calls_mut() {
594                hc.assigned_cars_by_line.retain(|_, car| *car != id);
595                if hc.assigned_cars_by_line.is_empty() {
596                    hc.pinned = false;
597                }
598            }
599
600            for rid in &rider_ids {
601                let tag = self
602                    .world
603                    .rider(*rid)
604                    .map_or(0, crate::components::Rider::tag);
605                if let Some(r) = self.world.rider_mut(*rid) {
606                    r.phase = RiderPhase::Waiting;
607                    r.current_stop = nearest_stop;
608                    r.board_tick = None;
609                }
610                if let Some(stop) = nearest_stop {
611                    self.rider_index.insert_waiting(stop, *rid);
612                    self.events.emit(Event::RiderEjected {
613                        rider: *rid,
614                        elevator: id,
615                        stop,
616                        tag,
617                        tick: self.tick,
618                    });
619                }
620            }
621
622            let had_load = self
623                .world
624                .elevator(id)
625                .is_some_and(|c| c.current_load.value() > 0.0);
626            let capacity = self.world.elevator(id).map(|c| c.weight_capacity.value());
627            if let Some(car) = self.world.elevator_mut(id) {
628                car.riders.clear();
629                car.current_load = crate::components::Weight::ZERO;
630                car.phase = ElevatorPhase::Idle;
631                car.target_stop = None;
632            }
633            // Wipe any pressed floor buttons. On re-enable they'd
634            // otherwise resurface as active demand with stale press
635            // ticks, and dispatch would plan against a rider set that
636            // no longer exists.
637            if let Some(calls) = self.world.car_calls_mut(id) {
638                calls.clear();
639            }
640            // Tell the group's dispatcher the car left. SCAN/LOOK
641            // keep per-car direction state across ticks; without this
642            // a disabled-then-enabled car would re-enter service with
643            // whatever sweep direction it had before, potentially
644            // colliding with the new sweep state. Mirrors the
645            // `remove_elevator` / `reassign_elevator_to_line` paths in
646            // `topology.rs`, which already do this.
647            let group_id = self
648                .groups
649                .iter()
650                .find(|g| g.elevator_entities().contains(&id))
651                .map(ElevatorGroup::id);
652            if let Some(gid) = group_id
653                && let Some(dispatcher) = self.dispatchers.get_mut(&gid)
654            {
655                dispatcher.notify_removed(id);
656            }
657            if had_load && let Some(cap) = capacity {
658                self.events.emit(Event::CapacityChanged {
659                    elevator: id,
660                    current_load: ordered_float::OrderedFloat(0.0),
661                    capacity: ordered_float::OrderedFloat(cap),
662                    tick: self.tick,
663                });
664            }
665        }
666        if let Some(vel) = self.world.velocity_mut(id) {
667            vel.value = 0.0;
668        }
669
670        // If this is a stop, scrub it from elevator targets/queues,
671        // abandon resident riders, and invalidate routes.
672        if self.world.stop(id).is_some() {
673            self.disable_stop_inner(id, false);
674        }
675
676        self.world.disable(id);
677        self.events.emit(Event::EntityDisabled {
678            entity: id,
679            tick: self.tick,
680        });
681        Ok(())
682    }
683
684    /// Stop-specific disable work shared by [`Self::disable`] and
685    /// [`Self::remove_stop`]. `removed` flips the route-invalidation
686    /// reason to [`RouteInvalidReason::StopRemoved`](crate::events::RouteInvalidReason::StopRemoved).
687    pub(super) fn disable_stop_inner(&mut self, id: EntityId, removed: bool) {
688        self.scrub_stop_from_elevators(id);
689        let resident_ids: Vec<EntityId> =
690            self.rider_index.residents_at(id).iter().copied().collect();
691        for rid in resident_ids {
692            self.rider_index.remove_resident(id, rid);
693            self.rider_index.insert_abandoned(id, rid);
694            let tag = self
695                .world
696                .rider(rid)
697                .map_or(0, crate::components::Rider::tag);
698            if let Some(r) = self.world.rider_mut(rid) {
699                r.phase = RiderPhase::Abandoned;
700            }
701            self.events.emit(Event::RiderAbandoned {
702                rider: rid,
703                stop: id,
704                tag,
705                tick: self.tick,
706            });
707        }
708        self.invalidate_routes_for_stop(id, removed);
709    }
710
711    /// Re-enable a disabled entity.
712    ///
713    /// Emits `EntityEnabled`. Returns `Err` if the entity does not exist.
714    ///
715    /// # Errors
716    ///
717    /// Returns [`SimError::EntityNotFound`] if `id` does not refer to a
718    /// living entity.
719    pub fn enable(&mut self, id: EntityId) -> Result<(), SimError> {
720        if !self.world.is_alive(id) {
721            return Err(SimError::EntityNotFound(id));
722        }
723        self.world.enable(id);
724        self.events.emit(Event::EntityEnabled {
725            entity: id,
726            tick: self.tick,
727        });
728        Ok(())
729    }
730
731    /// Invalidate routes for all riders referencing a disabled stop.
732    ///
733    /// Reroutes Waiting and in-car riders to the nearest enabled
734    /// alternative stop in the same group. If no alternative exists, a
735    /// Waiting rider is abandoned in place; an in-car rider is ejected at
736    /// the car's nearest enabled stop (mirrors elevator-disable behavior
737    /// at `lifecycle.rs:583-598`).
738    ///
739    /// `removed` distinguishes a permanent removal (`StopRemoved`) from a
740    /// transient disable (`StopDisabled`) for emitted events.
741    fn invalidate_routes_for_stop(&mut self, disabled_stop: EntityId, removed: bool) {
742        use crate::events::RouteInvalidReason;
743
744        let reroute_reason = if removed {
745            RouteInvalidReason::StopRemoved
746        } else {
747            RouteInvalidReason::StopDisabled
748        };
749
750        let group_stops: Vec<EntityId> = self
751            .groups
752            .iter()
753            .filter(|g| g.stop_entities().contains(&disabled_stop))
754            .flat_map(|g| g.stop_entities().iter().copied())
755            .filter(|&s| s != disabled_stop && !self.world.is_disabled(s))
756            .collect();
757
758        for rid in self.world.rider_ids() {
759            self.invalidate_route_for_rider(rid, disabled_stop, &group_stops, reroute_reason);
760        }
761    }
762
763    /// Per-rider invalidation: reroute, eject, or abandon depending on
764    /// the rider's phase and the availability of alternatives.
765    fn invalidate_route_for_rider(
766        &mut self,
767        rid: EntityId,
768        disabled_stop: EntityId,
769        group_stops: &[EntityId],
770        reroute_reason: crate::events::RouteInvalidReason,
771    ) {
772        let Some(phase) = self.world.rider(rid).map(|r| r.phase) else {
773            return;
774        };
775        let is_waiting = phase == RiderPhase::Waiting;
776        let aboard_car = match phase {
777            RiderPhase::Boarding(c) | RiderPhase::Riding(c) | RiderPhase::Exiting(c) => Some(c),
778            _ => None,
779        };
780        if !is_waiting && aboard_car.is_none() {
781            return;
782        }
783
784        let references_stop = self.world.route(rid).is_some_and(|route| {
785            route
786                .legs
787                .iter()
788                .skip(route.current_leg)
789                .any(|leg| leg.to == disabled_stop || leg.from == disabled_stop)
790        });
791        if !references_stop {
792            return;
793        }
794
795        let rider_current_stop = self.world.rider(rid).and_then(|r| r.current_stop);
796        let disabled_stop_pos = self.world.stop(disabled_stop).map_or(0.0, |s| s.position);
797        let alternative = group_stops
798            .iter()
799            .filter(|&&s| Some(s) != rider_current_stop)
800            .filter_map(|&s| {
801                self.world
802                    .stop(s)
803                    .map(|stop| (s, (stop.position - disabled_stop_pos).abs()))
804            })
805            .min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
806            .map(|(s, _)| s);
807
808        if let Some(alt_stop) = alternative {
809            self.reroute_to_alternative(rid, disabled_stop, alt_stop, aboard_car, reroute_reason);
810        } else if let Some(car_eid) = aboard_car {
811            self.eject_or_abandon_in_car_rider(rid, car_eid, disabled_stop, reroute_reason);
812        } else {
813            self.abandon_waiting_rider(rid, disabled_stop, rider_current_stop, reroute_reason);
814        }
815    }
816
817    /// Rewrite the rider's route to point at `alt_stop` and (if aboard a
818    /// car) re-prime the car's `target_stop` so it resumes movement.
819    fn reroute_to_alternative(
820        &mut self,
821        rid: EntityId,
822        disabled_stop: EntityId,
823        alt_stop: EntityId,
824        aboard_car: Option<EntityId>,
825        reroute_reason: crate::events::RouteInvalidReason,
826    ) {
827        let rider_current_stop = self.world.rider(rid).and_then(|r| r.current_stop);
828        let origin = rider_current_stop.unwrap_or(alt_stop);
829        let group = self.group_from_route(self.world.route(rid));
830        self.world
831            .set_route(rid, Route::direct(origin, alt_stop, group));
832        let tag = self
833            .world
834            .rider(rid)
835            .map_or(0, crate::components::Rider::tag);
836        self.events.emit(Event::RouteInvalidated {
837            rider: rid,
838            affected_stop: disabled_stop,
839            reason: reroute_reason,
840            tag,
841            tick: self.tick,
842        });
843        // For in-car riders, the car's target_stop was just nulled by
844        // `scrub_stop_from_elevators`. Re-point it at the new destination
845        // so the car resumes movement on the next tick; dispatch picks
846        // it up via `riding_to_stop` regardless, but setting target_stop
847        // avoids one tick of idle drift. Phase is left untouched — a
848        // car mid-travel keeps `MovingToStop` and decelerates naturally.
849        if let Some(car_eid) = aboard_car
850            && let Some(car) = self.world.elevator_mut(car_eid)
851            && car.target_stop.is_none()
852        {
853            car.target_stop = Some(alt_stop);
854        }
855    }
856
857    /// Handle an in-car rider when no alternative destination exists:
858    /// eject at the car's nearest enabled stop, or abandon if no stops
859    /// remain anywhere. The reroute reason is forwarded so consumers
860    /// can distinguish a permanent removal from a transient disable.
861    fn eject_or_abandon_in_car_rider(
862        &mut self,
863        rid: EntityId,
864        car_eid: EntityId,
865        disabled_stop: EntityId,
866        reroute_reason: crate::events::RouteInvalidReason,
867    ) {
868        let car_pos = self.world.position(car_eid).map_or(0.0, |p| p.value);
869        let eject_stop = self
870            .world
871            .iter_stops()
872            .filter(|(eid, _)| *eid != disabled_stop && !self.world.is_disabled(*eid))
873            .map(|(eid, stop)| (eid, (stop.position - car_pos).abs()))
874            .min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
875            .map(|(eid, _)| eid);
876
877        let tag = self
878            .world
879            .rider(rid)
880            .map_or(0, crate::components::Rider::tag);
881        self.events.emit(Event::RouteInvalidated {
882            rider: rid,
883            affected_stop: disabled_stop,
884            reason: reroute_reason,
885            tag,
886            tick: self.tick,
887        });
888
889        let rider_weight = self
890            .world
891            .rider(rid)
892            .map_or(crate::components::Weight::ZERO, |r| r.weight);
893
894        if let Some(stop) = eject_stop {
895            if let Some(r) = self.world.rider_mut(rid) {
896                r.phase = RiderPhase::Waiting;
897                r.current_stop = Some(stop);
898                r.board_tick = None;
899            }
900            if let Some(car) = self.world.elevator_mut(car_eid) {
901                car.riders.retain(|r| *r != rid);
902                car.current_load -= rider_weight;
903            }
904            // Replace the now-stale Route (still references the removed
905            // stop) with a self-loop at the eject stop. Dispatch sees a
906            // rider whose destination is its current location and
907            // ignores them; consumers observe `RiderEjected` and
908            // decide what to do next (game-side respawn, refund, etc.).
909            let group = self.group_from_route(self.world.route(rid));
910            self.world.set_route(rid, Route::direct(stop, stop, group));
911            self.rider_index.insert_waiting(stop, rid);
912            self.emit_capacity_changed(car_eid);
913            self.events.emit(Event::RiderEjected {
914                rider: rid,
915                elevator: car_eid,
916                stop,
917                tag,
918                tick: self.tick,
919            });
920        } else {
921            if let Some(r) = self.world.rider_mut(rid) {
922                r.phase = RiderPhase::Abandoned;
923            }
924            if let Some(car) = self.world.elevator_mut(car_eid) {
925                car.riders.retain(|r| *r != rid);
926                car.current_load -= rider_weight;
927            }
928            self.world.scrub_rider_from_pending_calls(rid);
929            self.emit_capacity_changed(car_eid);
930            self.events.emit(Event::RiderAbandoned {
931                rider: rid,
932                stop: disabled_stop,
933                tag,
934                tick: self.tick,
935            });
936        }
937    }
938
939    /// Emit a `CapacityChanged` event reflecting the car's current load
940    /// after a passenger removal. Mirrors the pattern at
941    /// `loading.rs:364-371`.
942    fn emit_capacity_changed(&mut self, car_eid: EntityId) {
943        use ordered_float::OrderedFloat;
944        if let Some(car) = self.world.elevator(car_eid) {
945            self.events.emit(Event::CapacityChanged {
946                elevator: car_eid,
947                current_load: OrderedFloat(car.current_load.value()),
948                capacity: OrderedFloat(car.weight_capacity.value()),
949                tick: self.tick,
950            });
951        }
952    }
953
954    /// Abandon a Waiting rider in place when no alternative stop exists
955    /// in their group. The reroute reason is forwarded so consumers can
956    /// distinguish a permanent removal (`StopRemoved`) from a transient
957    /// disable (`StopDisabled`); the supplementary "no alternative was
958    /// found" signal is implicit in the `RiderAbandoned` event that
959    /// fires alongside this one.
960    fn abandon_waiting_rider(
961        &mut self,
962        rid: EntityId,
963        disabled_stop: EntityId,
964        rider_current_stop: Option<EntityId>,
965        reroute_reason: crate::events::RouteInvalidReason,
966    ) {
967        let abandon_stop = rider_current_stop.unwrap_or(disabled_stop);
968        let tag = self
969            .world
970            .rider(rid)
971            .map_or(0, crate::components::Rider::tag);
972        self.events.emit(Event::RouteInvalidated {
973            rider: rid,
974            affected_stop: disabled_stop,
975            reason: reroute_reason,
976            tag,
977            tick: self.tick,
978        });
979        if let Some(r) = self.world.rider_mut(rid) {
980            r.phase = RiderPhase::Abandoned;
981        }
982        // Fourth abandonment site (alongside the two in
983        // `advance_transient`); same stale-ID hazard. Scrub the rider
984        // from every hall/car-call pending list.
985        self.world.scrub_rider_from_pending_calls(rid);
986        if let Some(stop) = rider_current_stop {
987            self.rider_index.remove_waiting(stop, rid);
988            self.rider_index.insert_abandoned(stop, rid);
989        }
990        self.events.emit(Event::RiderAbandoned {
991            rider: rid,
992            stop: abandon_stop,
993            tag,
994            tick: self.tick,
995        });
996    }
997
998    /// Remove a disabled stop from all elevator targets and queues.
999    fn scrub_stop_from_elevators(&mut self, stop: EntityId) {
1000        let elevator_ids: Vec<EntityId> =
1001            self.world.iter_elevators().map(|(eid, _, _)| eid).collect();
1002        for eid in elevator_ids {
1003            if let Some(car) = self.world.elevator_mut(eid)
1004                && car.target_stop == Some(stop)
1005            {
1006                car.target_stop = None;
1007                car.phase = ElevatorPhase::Idle;
1008            }
1009            if let Some(q) = self.world.destination_queue_mut(eid) {
1010                q.retain(|s| s != stop);
1011            }
1012        }
1013    }
1014
1015    /// Check if an entity is disabled.
1016    #[must_use]
1017    pub fn is_disabled(&self, id: EntityId) -> bool {
1018        self.world.is_disabled(id)
1019    }
1020
1021    // ── Entity type queries ─────────────────────────────────────────
1022
1023    /// Check if an entity is an elevator.
1024    ///
1025    /// ```
1026    /// use elevator_core::prelude::*;
1027    ///
1028    /// let sim = SimulationBuilder::demo().build().unwrap();
1029    /// let stop = sim.stop_entity(StopId(0)).unwrap();
1030    /// assert!(!sim.is_elevator(stop));
1031    /// assert!(sim.is_stop(stop));
1032    /// ```
1033    #[must_use]
1034    pub fn is_elevator(&self, id: EntityId) -> bool {
1035        self.world.elevator(id).is_some()
1036    }
1037
1038    /// Check if an entity is a rider.
1039    #[must_use]
1040    pub fn is_rider(&self, id: EntityId) -> bool {
1041        self.world.rider(id).is_some()
1042    }
1043
1044    /// Check if an entity is a stop.
1045    #[must_use]
1046    pub fn is_stop(&self, id: EntityId) -> bool {
1047        self.world.stop(id).is_some()
1048    }
1049
1050    // ── Aggregate queries ───────────────────────────────────────────
1051
1052    /// Count of elevators currently in the [`Idle`](ElevatorPhase::Idle) phase.
1053    ///
1054    /// Excludes disabled elevators (whose phase is reset to `Idle` on disable).
1055    ///
1056    /// ```
1057    /// use elevator_core::prelude::*;
1058    ///
1059    /// let sim = SimulationBuilder::demo().build().unwrap();
1060    /// assert_eq!(sim.idle_elevator_count(), 1);
1061    /// ```
1062    #[must_use]
1063    pub fn idle_elevator_count(&self) -> usize {
1064        self.world.iter_idle_elevators().count()
1065    }
1066
1067    /// Current total weight aboard an elevator, or `None` if the entity is
1068    /// not an elevator.
1069    ///
1070    /// ```
1071    /// use elevator_core::prelude::*;
1072    ///
1073    /// let sim = SimulationBuilder::demo().build().unwrap();
1074    /// let stop = sim.stop_entity(StopId(0)).unwrap();
1075    /// assert_eq!(sim.elevator_load(ElevatorId::from(stop)), None); // not an elevator
1076    /// ```
1077    #[must_use]
1078    pub fn elevator_load(&self, id: ElevatorId) -> Option<f64> {
1079        let id = id.entity();
1080        self.world.elevator(id).map(|e| e.current_load.value())
1081    }
1082
1083    /// Whether the elevator's up-direction indicator lamp is lit.
1084    ///
1085    /// Returns `None` if the entity is not an elevator. See
1086    /// [`Elevator::going_up`] for semantics.
1087    #[must_use]
1088    pub fn elevator_going_up(&self, id: EntityId) -> Option<bool> {
1089        self.world.elevator(id).map(Elevator::going_up)
1090    }
1091
1092    /// Whether the elevator's down-direction indicator lamp is lit.
1093    ///
1094    /// Returns `None` if the entity is not an elevator. See
1095    /// [`Elevator::going_down`] for semantics.
1096    #[must_use]
1097    pub fn elevator_going_down(&self, id: EntityId) -> Option<bool> {
1098        self.world.elevator(id).map(Elevator::going_down)
1099    }
1100
1101    /// Direction the elevator is currently signalling, derived from the
1102    /// indicator-lamp pair. Returns `None` if the entity is not an elevator.
1103    #[must_use]
1104    pub fn elevator_direction(&self, id: EntityId) -> Option<crate::components::Direction> {
1105        self.world.elevator(id).map(Elevator::direction)
1106    }
1107
1108    /// Count of rounded-floor transitions for an elevator (passing-floor
1109    /// crossings plus arrivals). Returns `None` if the entity is not an
1110    /// elevator.
1111    #[must_use]
1112    pub fn elevator_move_count(&self, id: EntityId) -> Option<u64> {
1113        self.world.elevator(id).map(Elevator::move_count)
1114    }
1115
1116    /// Distance the elevator would travel while braking to a stop from its
1117    /// current velocity, at its configured deceleration rate.
1118    ///
1119    /// Uses the standard `v² / (2·a)` kinematic formula. A stationary
1120    /// elevator returns `Some(0.0)`. Returns `None` if the entity is not
1121    /// an elevator or lacks a velocity component.
1122    ///
1123    /// Useful for writing opportunistic dispatch strategies (e.g. "stop at
1124    /// this floor if we can brake in time") without duplicating the physics
1125    /// computation.
1126    #[must_use]
1127    pub fn braking_distance(&self, id: EntityId) -> Option<f64> {
1128        let car = self.world.elevator(id)?;
1129        let vel = self.world.velocity(id)?.value;
1130        Some(crate::movement::braking_distance(
1131            vel,
1132            car.deceleration.value(),
1133        ))
1134    }
1135
1136    /// The position where the elevator would come to rest if it began braking
1137    /// this instant. Current position plus a signed braking distance in the
1138    /// direction of travel.
1139    ///
1140    /// Returns `None` if the entity is not an elevator or lacks the required
1141    /// components.
1142    #[must_use]
1143    pub fn future_stop_position(&self, id: EntityId) -> Option<f64> {
1144        let pos = self.world.position(id)?.value;
1145        let vel = self.world.velocity(id)?.value;
1146        let car = self.world.elevator(id)?;
1147        let dist = crate::movement::braking_distance(vel, car.deceleration.value());
1148        Some(crate::fp::fma(vel.signum(), dist, pos))
1149    }
1150
1151    /// Count of elevators currently in the given phase.
1152    ///
1153    /// Excludes disabled elevators (whose phase is reset to `Idle` on disable).
1154    ///
1155    /// ```
1156    /// use elevator_core::prelude::*;
1157    ///
1158    /// let sim = SimulationBuilder::demo().build().unwrap();
1159    /// assert_eq!(sim.elevators_in_phase(ElevatorPhase::Idle), 1);
1160    /// assert_eq!(sim.elevators_in_phase(ElevatorPhase::Loading), 0);
1161    /// ```
1162    #[must_use]
1163    pub fn elevators_in_phase(&self, phase: ElevatorPhase) -> usize {
1164        self.world
1165            .iter_elevators()
1166            .filter(|(id, _, e)| e.phase() == phase && !self.world.is_disabled(*id))
1167            .count()
1168    }
1169
1170    // ── Service mode ────────────────────────────────────────────────
1171
1172    /// Set the service mode for an elevator.
1173    ///
1174    /// Emits [`Event::ServiceModeChanged`] if the mode actually changes.
1175    ///
1176    /// # Errors
1177    ///
1178    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
1179    pub fn set_service_mode(
1180        &mut self,
1181        elevator: EntityId,
1182        mode: crate::components::ServiceMode,
1183    ) -> Result<(), SimError> {
1184        if self.world.elevator(elevator).is_none() {
1185            return Err(SimError::EntityNotFound(elevator));
1186        }
1187        let old = self
1188            .world
1189            .service_mode(elevator)
1190            .copied()
1191            .unwrap_or_default();
1192        if old == mode {
1193            return Ok(());
1194        }
1195        // Leaving Manual: clear the pending velocity command and zero
1196        // the velocity component. Otherwise a car moving at transition
1197        // time is stranded — the Normal movement system only runs for
1198        // MovingToStop/Repositioning phases, so velocity would linger
1199        // forever without producing any position change.
1200        if old == crate::components::ServiceMode::Manual {
1201            if let Some(car) = self.world.elevator_mut(elevator) {
1202                car.manual_target_velocity = None;
1203                car.door_command_queue.clear();
1204            }
1205            if let Some(v) = self.world.velocity_mut(elevator) {
1206                v.value = 0.0;
1207            }
1208        }
1209        self.world.set_service_mode(elevator, mode);
1210        self.events.emit(Event::ServiceModeChanged {
1211            elevator,
1212            from: old,
1213            to: mode,
1214            tick: self.tick,
1215        });
1216        Ok(())
1217    }
1218
1219    /// Get the current service mode for an elevator.
1220    #[must_use]
1221    pub fn service_mode(&self, elevator: EntityId) -> crate::components::ServiceMode {
1222        self.world
1223            .service_mode(elevator)
1224            .copied()
1225            .unwrap_or_default()
1226    }
1227}