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