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