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