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,
11};
12use crate::entity::{ElevatorId, EntityId, RiderId};
13use crate::error::SimError;
14use crate::events::Event;
15use crate::ids::GroupId;
16
17use super::Simulation;
18
19impl Simulation {
20    // ── Extension restore ────────────────────────────────────────────
21
22    /// Deserialize extension components from a snapshot.
23    ///
24    /// Call this after restoring from a snapshot and registering all
25    /// extension types via `world.register_ext::<T>(key)`.
26    ///
27    /// Returns the names of any extension types present in the snapshot
28    /// that were not registered. An empty vec means all extensions were
29    /// deserialized successfully.
30    ///
31    /// Prefer [`load_extensions_with`](Self::load_extensions_with) which
32    /// combines registration and loading in one call.
33    #[must_use]
34    pub fn load_extensions(&mut self) -> Vec<String> {
35        let Some(pending) = self
36            .world
37            .remove_resource::<crate::snapshot::PendingExtensions>()
38        else {
39            return Vec::new();
40        };
41        let unregistered = self.world.unregistered_ext_names(pending.0.keys());
42        self.world.deserialize_extensions(&pending.0);
43        unregistered
44    }
45
46    /// Register extension types and load their data from a snapshot
47    /// in one step.
48    ///
49    /// This is the recommended way to restore extensions. It replaces the
50    /// manual 3-step ceremony of `register_ext` → `load_extensions`:
51    ///
52    /// ```no_run
53    /// # use elevator_core::prelude::*;
54    /// # use elevator_core::register_extensions;
55    /// # use elevator_core::snapshot::WorldSnapshot;
56    /// # use serde::{Serialize, Deserialize};
57    /// # #[derive(Clone, Serialize, Deserialize)] struct VipTag;
58    /// # #[derive(Clone, Serialize, Deserialize)] struct TeamId;
59    /// # fn before(snapshot: WorldSnapshot) -> Result<(), SimError> {
60    /// // Before (3-step ceremony):
61    /// let mut sim = snapshot.restore(None)?;
62    /// sim.world_mut().register_ext::<VipTag>(ExtKey::from_type_name());
63    /// sim.world_mut().register_ext::<TeamId>(ExtKey::from_type_name());
64    /// sim.load_extensions();
65    /// # Ok(()) }
66    /// # fn after(snapshot: WorldSnapshot) -> Result<(), SimError> {
67    ///
68    /// // After:
69    /// let mut sim = snapshot.restore(None)?;
70    /// let unregistered = sim.load_extensions_with(|world| {
71    ///     register_extensions!(world, VipTag, TeamId);
72    /// });
73    /// assert!(unregistered.is_empty(), "missing: {unregistered:?}");
74    /// # Ok(()) }
75    /// ```
76    ///
77    /// Returns the names of any extension types in the snapshot that were
78    /// not registered. This catches "forgot to register" bugs at load time.
79    #[must_use]
80    pub fn load_extensions_with<F>(&mut self, register: F) -> Vec<String>
81    where
82        F: FnOnce(&mut crate::world::World),
83    {
84        register(&mut self.world);
85        self.load_extensions()
86    }
87
88    // ── Helpers ──────────────────────────────────────────────────────
89
90    /// Extract the `GroupId` from the current leg of a route.
91    ///
92    /// For Walk legs, looks ahead to the next leg to find the group.
93    /// Falls back to `GroupId(0)` when no route exists or no group leg is found.
94    pub(super) fn group_from_route(&self, route: Option<&Route>) -> GroupId {
95        if let Some(route) = route {
96            // Scan forward from current_leg looking for a Group or Line transport mode.
97            for leg in route.legs.iter().skip(route.current_leg) {
98                match leg.via {
99                    crate::components::TransportMode::Group(g) => return g,
100                    crate::components::TransportMode::Line(l) => {
101                        if let Some(line) = self.world.line(l) {
102                            return line.group();
103                        }
104                    }
105                    crate::components::TransportMode::Walk => {}
106                }
107            }
108        }
109        GroupId(0)
110    }
111
112    // ── Re-routing ───────────────────────────────────────────────────
113
114    /// Change a rider's destination mid-route.
115    ///
116    /// Replaces remaining route legs with a single direct leg to `new_destination`,
117    /// keeping the rider's current stop as origin.
118    ///
119    /// Returns `Err` if the rider does not exist or is not in `Waiting` phase
120    /// (riding/boarding riders cannot be rerouted until they exit).
121    ///
122    /// # Errors
123    ///
124    /// Returns [`SimError::EntityNotFound`] if `rider` does not exist.
125    /// Returns [`SimError::WrongRiderPhase`] if the rider is not in
126    /// [`RiderPhase::Waiting`], or [`SimError::RiderHasNoStop`] if the
127    /// rider has no current stop.
128    pub fn reroute(&mut self, rider: RiderId, new_destination: EntityId) -> Result<(), SimError> {
129        let rider = rider.entity();
130        let r = self
131            .world
132            .rider(rider)
133            .ok_or(SimError::EntityNotFound(rider))?;
134
135        if r.phase != RiderPhase::Waiting {
136            return Err(SimError::WrongRiderPhase {
137                rider,
138                expected: RiderPhaseKind::Waiting,
139                actual: r.phase.kind(),
140            });
141        }
142
143        let origin = r.current_stop.ok_or(SimError::RiderHasNoStop(rider))?;
144
145        let group = self.group_from_route(self.world.route(rider));
146        self.world
147            .set_route(rider, Route::direct(origin, new_destination, group));
148
149        self.events.emit(Event::RiderRerouted {
150            rider,
151            new_destination,
152            tick: self.tick,
153        });
154
155        Ok(())
156    }
157
158    /// Replace a rider's entire remaining route.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`SimError::EntityNotFound`] if `rider` does not exist.
163    pub fn set_rider_route(&mut self, rider: EntityId, route: Route) -> Result<(), SimError> {
164        if self.world.rider(rider).is_none() {
165            return Err(SimError::EntityNotFound(rider));
166        }
167        self.world.set_route(rider, route);
168        Ok(())
169    }
170
171    // ── Rider settlement & population ─────────────────────────────
172
173    /// Transition an `Arrived` or `Abandoned` rider to `Resident` at their
174    /// current stop.
175    ///
176    /// Resident riders are parked — invisible to dispatch and loading, but
177    /// queryable via [`residents_at()`](Self::residents_at). They can later
178    /// be given a new route via [`reroute_rider()`](Self::reroute_rider).
179    ///
180    /// # Errors
181    ///
182    /// Returns [`SimError::EntityNotFound`] if `id` does not exist.
183    /// Returns [`SimError::WrongRiderPhase`] if the rider is not in
184    /// `Arrived` or `Abandoned` phase, or [`SimError::RiderHasNoStop`]
185    /// if the rider has no current stop.
186    pub fn settle_rider(&mut self, id: RiderId) -> Result<(), SimError> {
187        let id = id.entity();
188        let rider = self.world.rider(id).ok_or(SimError::EntityNotFound(id))?;
189
190        let old_phase = rider.phase;
191        match old_phase {
192            RiderPhase::Arrived | RiderPhase::Abandoned => {}
193            _ => {
194                return Err(SimError::WrongRiderPhase {
195                    rider: id,
196                    expected: RiderPhaseKind::Arrived,
197                    actual: old_phase.kind(),
198                });
199            }
200        }
201
202        let stop = rider.current_stop.ok_or(SimError::RiderHasNoStop(id))?;
203
204        // Update index: remove from old partition (only Abandoned is indexed).
205        if old_phase == RiderPhase::Abandoned {
206            self.rider_index.remove_abandoned(stop, id);
207        }
208        self.rider_index.insert_resident(stop, id);
209
210        if let Some(r) = self.world.rider_mut(id) {
211            r.phase = RiderPhase::Resident;
212        }
213
214        self.metrics.record_settle();
215        self.events.emit(Event::RiderSettled {
216            rider: id,
217            stop,
218            tick: self.tick,
219        });
220        Ok(())
221    }
222
223    /// Give a `Resident` rider a new route, transitioning them to `Waiting`.
224    ///
225    /// The rider begins waiting at their current stop for an elevator
226    /// matching the route's transport mode. If the rider has a
227    /// [`Patience`](crate::components::Patience) component, its
228    /// `waited_ticks` is reset to zero.
229    ///
230    /// # Errors
231    ///
232    /// Returns [`SimError::EntityNotFound`] if `id` does not exist.
233    /// Returns [`SimError::WrongRiderPhase`] if the rider is not in `Resident`
234    /// phase, [`SimError::EmptyRoute`] if the route has no legs, or
235    /// [`SimError::RouteOriginMismatch`] if the route's first leg origin does
236    /// not match the rider's current stop.
237    pub fn reroute_rider(&mut self, id: EntityId, route: Route) -> Result<(), SimError> {
238        let rider = self.world.rider(id).ok_or(SimError::EntityNotFound(id))?;
239
240        if rider.phase != RiderPhase::Resident {
241            return Err(SimError::WrongRiderPhase {
242                rider: id,
243                expected: RiderPhaseKind::Resident,
244                actual: rider.phase.kind(),
245            });
246        }
247
248        let stop = rider.current_stop.ok_or(SimError::RiderHasNoStop(id))?;
249
250        let new_destination = route.final_destination().ok_or(SimError::EmptyRoute)?;
251
252        // Validate that the route departs from the rider's current stop.
253        if let Some(leg) = route.current()
254            && leg.from != stop
255        {
256            return Err(SimError::RouteOriginMismatch {
257                expected_origin: stop,
258                route_origin: leg.from,
259            });
260        }
261
262        self.rider_index.remove_resident(stop, id);
263        self.rider_index.insert_waiting(stop, id);
264
265        if let Some(r) = self.world.rider_mut(id) {
266            r.phase = RiderPhase::Waiting;
267            // Reset spawn_tick so manifest wait_ticks measures time since
268            // reroute, not time since the original spawn as a Resident.
269            r.spawn_tick = self.tick;
270        }
271        self.world.set_route(id, route);
272
273        // Reset patience if present.
274        if let Some(p) = self.world.patience_mut(id) {
275            p.waited_ticks = 0;
276        }
277
278        // A rerouted resident is indistinguishable from a fresh arrival —
279        // record it so predictive parking and `arrivals_at` see the demand.
280        if let Some(log) = self.world.resource_mut::<crate::arrival_log::ArrivalLog>() {
281            log.record(self.tick, stop);
282        }
283
284        self.metrics.record_reroute();
285        self.events.emit(Event::RiderRerouted {
286            rider: id,
287            new_destination,
288            tick: self.tick,
289        });
290        Ok(())
291    }
292
293    /// Remove a rider from the simulation entirely.
294    ///
295    /// Cleans up the population index, metric tags, and elevator cross-references
296    /// (if the rider is currently aboard). Emits [`Event::RiderDespawned`].
297    ///
298    /// All rider removal should go through this method rather than calling
299    /// `world.despawn()` directly, to keep the population index consistent.
300    ///
301    /// # Errors
302    ///
303    /// Returns [`SimError::EntityNotFound`] if `id` does not exist or is
304    /// not a rider.
305    pub fn despawn_rider(&mut self, id: RiderId) -> Result<(), SimError> {
306        let id = id.entity();
307        let rider = self.world.rider(id).ok_or(SimError::EntityNotFound(id))?;
308
309        // Targeted index removal based on current phase (O(1) vs O(n) scan).
310        if let Some(stop) = rider.current_stop {
311            match rider.phase {
312                RiderPhase::Waiting => self.rider_index.remove_waiting(stop, id),
313                RiderPhase::Resident => self.rider_index.remove_resident(stop, id),
314                RiderPhase::Abandoned => self.rider_index.remove_abandoned(stop, id),
315                _ => {} // Boarding/Riding/Exiting/Walking/Arrived — not indexed
316            }
317        }
318
319        if let Some(tags) = self
320            .world
321            .resource_mut::<crate::tagged_metrics::MetricTags>()
322        {
323            tags.remove_entity(id);
324        }
325
326        // Purge stale `pending_riders` entries before the entity slot
327        // is reused. `world.despawn` cleans ext storage keyed on this
328        // rider (e.g. `AssignedCar`) but not back-references living on
329        // stop/car entities.
330        self.world.scrub_rider_from_pending_calls(id);
331
332        self.world.despawn(id);
333
334        self.events.emit(Event::RiderDespawned {
335            rider: id,
336            tick: self.tick,
337        });
338        Ok(())
339    }
340
341    // ── Access control ──────────────────────────────────────────────
342
343    /// Set the allowed stops for a rider.
344    ///
345    /// When set, the rider will only be allowed to board elevators that
346    /// can take them to a stop in the allowed set. See
347    /// [`AccessControl`](crate::components::AccessControl) for details.
348    ///
349    /// # Errors
350    ///
351    /// Returns [`SimError::EntityNotFound`] if the rider does not exist.
352    pub fn set_rider_access(
353        &mut self,
354        rider: EntityId,
355        allowed_stops: HashSet<EntityId>,
356    ) -> Result<(), SimError> {
357        if self.world.rider(rider).is_none() {
358            return Err(SimError::EntityNotFound(rider));
359        }
360        self.world
361            .set_access_control(rider, crate::components::AccessControl::new(allowed_stops));
362        Ok(())
363    }
364
365    /// Set the restricted stops for an elevator.
366    ///
367    /// Riders whose current destination is in this set will be rejected
368    /// with [`RejectionReason::AccessDenied`](crate::error::RejectionReason::AccessDenied)
369    /// during the loading phase.
370    ///
371    /// # Errors
372    ///
373    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
374    pub fn set_elevator_restricted_stops(
375        &mut self,
376        elevator: EntityId,
377        restricted_stops: HashSet<EntityId>,
378    ) -> Result<(), SimError> {
379        let car = self
380            .world
381            .elevator_mut(elevator)
382            .ok_or(SimError::EntityNotFound(elevator))?;
383        car.restricted_stops = restricted_stops;
384        Ok(())
385    }
386
387    // ── Population queries ──────────────────────────────────────────
388
389    /// Iterate over resident rider IDs at a stop (O(1) lookup).
390    pub fn residents_at(&self, stop: EntityId) -> impl Iterator<Item = EntityId> + '_ {
391        self.rider_index.residents_at(stop).iter().copied()
392    }
393
394    /// Count of residents at a stop (O(1)).
395    #[must_use]
396    pub fn resident_count_at(&self, stop: EntityId) -> usize {
397        self.rider_index.resident_count_at(stop)
398    }
399
400    /// Iterate over waiting rider IDs at a stop (O(1) lookup).
401    pub fn waiting_at(&self, stop: EntityId) -> impl Iterator<Item = EntityId> + '_ {
402        self.rider_index.waiting_at(stop).iter().copied()
403    }
404
405    /// Count of waiting riders at a stop (O(1)).
406    #[must_use]
407    pub fn waiting_count_at(&self, stop: EntityId) -> usize {
408        self.rider_index.waiting_count_at(stop)
409    }
410
411    /// Partition waiting riders at `stop` by their route direction.
412    ///
413    /// Returns `(up, down)` where `up` counts riders whose current route
414    /// destination lies above `stop` (they want to go up) and `down` counts
415    /// riders whose destination lies below. Riders without a [`Route`] or
416    /// whose current leg has no destination are excluded from both counts —
417    /// they have no intrinsic direction. The sum `up + down` may therefore
418    /// be less than [`waiting_count_at`](Self::waiting_count_at).
419    ///
420    /// Runs in `O(waiting riders at stop)`. Designed for per-frame rendering
421    /// code that wants to show up/down queues separately; dispatch strategies
422    /// should read [`HallCall`](crate::components::HallCall)s instead.
423    #[must_use]
424    pub fn waiting_direction_counts_at(&self, stop: EntityId) -> (usize, usize) {
425        let Some(origin_pos) = self.world.stop(stop).map(crate::components::Stop::position) else {
426            return (0, 0);
427        };
428        let mut up = 0usize;
429        let mut down = 0usize;
430        for rider in self.rider_index.waiting_at(stop) {
431            let Some(route) = self.world.route(*rider) else {
432                continue;
433            };
434            let Some(dest_entity) = route.current_destination() else {
435                continue;
436            };
437            let Some(dest_pos) = self
438                .world
439                .stop(dest_entity)
440                .map(crate::components::Stop::position)
441            else {
442                continue;
443            };
444            match CallDirection::between(origin_pos, dest_pos) {
445                Some(CallDirection::Up) => up += 1,
446                Some(CallDirection::Down) => down += 1,
447                None => {}
448            }
449        }
450        (up, down)
451    }
452
453    /// Iterate over abandoned rider IDs at a stop (O(1) lookup).
454    pub fn abandoned_at(&self, stop: EntityId) -> impl Iterator<Item = EntityId> + '_ {
455        self.rider_index.abandoned_at(stop).iter().copied()
456    }
457
458    /// Count of abandoned riders at a stop (O(1)).
459    #[must_use]
460    pub fn abandoned_count_at(&self, stop: EntityId) -> usize {
461        self.rider_index.abandoned_count_at(stop)
462    }
463
464    /// Get the rider entities currently aboard an elevator.
465    ///
466    /// Returns an empty slice if the elevator does not exist.
467    #[must_use]
468    pub fn riders_on(&self, elevator: EntityId) -> &[EntityId] {
469        self.world
470            .elevator(elevator)
471            .map_or(&[], |car| car.riders())
472    }
473
474    /// Get the number of riders aboard an elevator.
475    ///
476    /// Returns 0 if the elevator does not exist.
477    #[must_use]
478    pub fn occupancy(&self, elevator: EntityId) -> usize {
479        self.world
480            .elevator(elevator)
481            .map_or(0, |car| car.riders().len())
482    }
483
484    // ── Entity lifecycle ────────────────────────────────────────────
485
486    /// Disable an entity. Disabled entities are skipped by all systems.
487    ///
488    /// If the entity is an elevator in motion, it is reset to `Idle` with
489    /// zero velocity to prevent stale target references on re-enable.
490    ///
491    /// If the entity is a stop, any `Resident` riders parked there are
492    /// transitioned to `Abandoned` and appropriate events are emitted.
493    ///
494    /// Emits `EntityDisabled`. Returns `Err` if the entity does not exist.
495    ///
496    /// # Errors
497    ///
498    /// Returns [`SimError::EntityNotFound`] if `id` does not refer to a
499    /// living entity.
500    pub fn disable(&mut self, id: EntityId) -> Result<(), SimError> {
501        if !self.world.is_alive(id) {
502            return Err(SimError::EntityNotFound(id));
503        }
504        // If this is an elevator, eject all riders and reset state.
505        if let Some(car) = self.world.elevator(id) {
506            let rider_ids = car.riders.clone();
507            let pos = self.world.position(id).map_or(0.0, |p| p.value);
508            let nearest_stop = self.world.find_nearest_stop(pos);
509
510            // Drop any sticky DCS assignments pointing at this car so
511            // routed riders are not stranded behind a dead reference.
512            crate::dispatch::destination::clear_assignments_to(&mut self.world, id);
513            // Same for hall-call assignments — pre-fix, a pinned hall
514            // call to the disabled car was permanently stranded because
515            // dispatch kept committing the disabled car as the assignee
516            // and other cars couldn't take the call. (#292)
517            for hc in self.world.iter_hall_calls_mut() {
518                if hc.assigned_car == Some(id) {
519                    hc.assigned_car = None;
520                    hc.pinned = false;
521                }
522            }
523
524            for rid in &rider_ids {
525                if let Some(r) = self.world.rider_mut(*rid) {
526                    r.phase = RiderPhase::Waiting;
527                    r.current_stop = nearest_stop;
528                    r.board_tick = None;
529                }
530                if let Some(stop) = nearest_stop {
531                    self.rider_index.insert_waiting(stop, *rid);
532                    self.events.emit(Event::RiderEjected {
533                        rider: *rid,
534                        elevator: id,
535                        stop,
536                        tick: self.tick,
537                    });
538                }
539            }
540
541            let had_load = self
542                .world
543                .elevator(id)
544                .is_some_and(|c| c.current_load.value() > 0.0);
545            let capacity = self.world.elevator(id).map(|c| c.weight_capacity.value());
546            if let Some(car) = self.world.elevator_mut(id) {
547                car.riders.clear();
548                car.current_load = crate::components::Weight::ZERO;
549                car.phase = ElevatorPhase::Idle;
550                car.target_stop = None;
551            }
552            if had_load && let Some(cap) = capacity {
553                self.events.emit(Event::CapacityChanged {
554                    elevator: id,
555                    current_load: ordered_float::OrderedFloat(0.0),
556                    capacity: ordered_float::OrderedFloat(cap),
557                    tick: self.tick,
558                });
559            }
560        }
561        if let Some(vel) = self.world.velocity_mut(id) {
562            vel.value = 0.0;
563        }
564
565        // If this is a stop, abandon resident riders and invalidate routes.
566        if self.world.stop(id).is_some() {
567            let resident_ids: Vec<EntityId> =
568                self.rider_index.residents_at(id).iter().copied().collect();
569            for rid in resident_ids {
570                self.rider_index.remove_resident(id, rid);
571                self.rider_index.insert_abandoned(id, rid);
572                if let Some(r) = self.world.rider_mut(rid) {
573                    r.phase = RiderPhase::Abandoned;
574                }
575                self.events.emit(Event::RiderAbandoned {
576                    rider: rid,
577                    stop: id,
578                    tick: self.tick,
579                });
580            }
581            self.invalidate_routes_for_stop(id);
582        }
583
584        self.world.disable(id);
585        self.events.emit(Event::EntityDisabled {
586            entity: id,
587            tick: self.tick,
588        });
589        Ok(())
590    }
591
592    /// Re-enable a disabled entity.
593    ///
594    /// Emits `EntityEnabled`. Returns `Err` if the entity does not exist.
595    ///
596    /// # Errors
597    ///
598    /// Returns [`SimError::EntityNotFound`] if `id` does not refer to a
599    /// living entity.
600    pub fn enable(&mut self, id: EntityId) -> Result<(), SimError> {
601        if !self.world.is_alive(id) {
602            return Err(SimError::EntityNotFound(id));
603        }
604        self.world.enable(id);
605        self.events.emit(Event::EntityEnabled {
606            entity: id,
607            tick: self.tick,
608        });
609        Ok(())
610    }
611
612    /// Invalidate routes for all riders referencing a disabled stop.
613    ///
614    /// Attempts to reroute riders to the nearest enabled alternative stop.
615    /// If no alternative exists, emits `RouteInvalidated` with `NoAlternative`.
616    fn invalidate_routes_for_stop(&mut self, disabled_stop: EntityId) {
617        use crate::events::RouteInvalidReason;
618
619        // Find the group this stop belongs to.
620        let group_stops: Vec<EntityId> = self
621            .groups
622            .iter()
623            .filter(|g| g.stop_entities().contains(&disabled_stop))
624            .flat_map(|g| g.stop_entities().iter().copied())
625            .filter(|&s| s != disabled_stop && !self.world.is_disabled(s))
626            .collect();
627
628        // Find all Waiting riders whose route references this stop.
629        // Riding riders are skipped — they'll be rerouted when they exit.
630        let rider_ids: Vec<EntityId> = self.world.rider_ids();
631        for rid in rider_ids {
632            let is_waiting = self
633                .world
634                .rider(rid)
635                .is_some_and(|r| r.phase == RiderPhase::Waiting);
636
637            if !is_waiting {
638                continue;
639            }
640
641            let references_stop = self.world.route(rid).is_some_and(|route| {
642                route
643                    .legs
644                    .iter()
645                    .skip(route.current_leg)
646                    .any(|leg| leg.to == disabled_stop || leg.from == disabled_stop)
647            });
648
649            if !references_stop {
650                continue;
651            }
652
653            // Try to find nearest alternative (excluding rider's current stop).
654            let rider_current_stop = self.world.rider(rid).and_then(|r| r.current_stop);
655
656            let disabled_stop_pos = self.world.stop(disabled_stop).map_or(0.0, |s| s.position);
657
658            let alternative = group_stops
659                .iter()
660                .filter(|&&s| Some(s) != rider_current_stop)
661                .filter_map(|&s| {
662                    self.world
663                        .stop(s)
664                        .map(|stop| (s, (stop.position - disabled_stop_pos).abs()))
665                })
666                .min_by(|a, b| a.1.total_cmp(&b.1))
667                .map(|(s, _)| s);
668
669            if let Some(alt_stop) = alternative {
670                // Reroute to nearest alternative.
671                let origin = rider_current_stop.unwrap_or(alt_stop);
672                let group = self.group_from_route(self.world.route(rid));
673                self.world
674                    .set_route(rid, Route::direct(origin, alt_stop, group));
675                self.events.emit(Event::RouteInvalidated {
676                    rider: rid,
677                    affected_stop: disabled_stop,
678                    reason: RouteInvalidReason::StopDisabled,
679                    tick: self.tick,
680                });
681            } else {
682                // No alternative — rider abandons immediately.
683                let abandon_stop = rider_current_stop.unwrap_or(disabled_stop);
684                self.events.emit(Event::RouteInvalidated {
685                    rider: rid,
686                    affected_stop: disabled_stop,
687                    reason: RouteInvalidReason::NoAlternative,
688                    tick: self.tick,
689                });
690                if let Some(r) = self.world.rider_mut(rid) {
691                    r.phase = RiderPhase::Abandoned;
692                }
693                // Fourth abandonment site (alongside the two in
694                // `advance_transient`); same stale-ID hazard. Scrub
695                // the rider from every hall/car-call pending list.
696                self.world.scrub_rider_from_pending_calls(rid);
697                if let Some(stop) = rider_current_stop {
698                    self.rider_index.remove_waiting(stop, rid);
699                    self.rider_index.insert_abandoned(stop, rid);
700                }
701                self.events.emit(Event::RiderAbandoned {
702                    rider: rid,
703                    stop: abandon_stop,
704                    tick: self.tick,
705                });
706            }
707        }
708    }
709
710    /// Check if an entity is disabled.
711    #[must_use]
712    pub fn is_disabled(&self, id: EntityId) -> bool {
713        self.world.is_disabled(id)
714    }
715
716    // ── Entity type queries ─────────────────────────────────────────
717
718    /// Check if an entity is an elevator.
719    ///
720    /// ```
721    /// use elevator_core::prelude::*;
722    ///
723    /// let sim = SimulationBuilder::demo().build().unwrap();
724    /// let stop = sim.stop_entity(StopId(0)).unwrap();
725    /// assert!(!sim.is_elevator(stop));
726    /// assert!(sim.is_stop(stop));
727    /// ```
728    #[must_use]
729    pub fn is_elevator(&self, id: EntityId) -> bool {
730        self.world.elevator(id).is_some()
731    }
732
733    /// Check if an entity is a rider.
734    #[must_use]
735    pub fn is_rider(&self, id: EntityId) -> bool {
736        self.world.rider(id).is_some()
737    }
738
739    /// Check if an entity is a stop.
740    #[must_use]
741    pub fn is_stop(&self, id: EntityId) -> bool {
742        self.world.stop(id).is_some()
743    }
744
745    // ── Aggregate queries ───────────────────────────────────────────
746
747    /// Count of elevators currently in the [`Idle`](ElevatorPhase::Idle) phase.
748    ///
749    /// Excludes disabled elevators (whose phase is reset to `Idle` on disable).
750    ///
751    /// ```
752    /// use elevator_core::prelude::*;
753    ///
754    /// let sim = SimulationBuilder::demo().build().unwrap();
755    /// assert_eq!(sim.idle_elevator_count(), 1);
756    /// ```
757    #[must_use]
758    pub fn idle_elevator_count(&self) -> usize {
759        self.world.iter_idle_elevators().count()
760    }
761
762    /// Current total weight aboard an elevator, or `None` if the entity is
763    /// not an elevator.
764    ///
765    /// ```
766    /// use elevator_core::prelude::*;
767    ///
768    /// let sim = SimulationBuilder::demo().build().unwrap();
769    /// let stop = sim.stop_entity(StopId(0)).unwrap();
770    /// assert_eq!(sim.elevator_load(ElevatorId::from(stop)), None); // not an elevator
771    /// ```
772    #[must_use]
773    pub fn elevator_load(&self, id: ElevatorId) -> Option<f64> {
774        let id = id.entity();
775        self.world.elevator(id).map(|e| e.current_load.value())
776    }
777
778    /// Whether the elevator's up-direction indicator lamp is lit.
779    ///
780    /// Returns `None` if the entity is not an elevator. See
781    /// [`Elevator::going_up`] for semantics.
782    #[must_use]
783    pub fn elevator_going_up(&self, id: EntityId) -> Option<bool> {
784        self.world.elevator(id).map(Elevator::going_up)
785    }
786
787    /// Whether the elevator's down-direction indicator lamp is lit.
788    ///
789    /// Returns `None` if the entity is not an elevator. See
790    /// [`Elevator::going_down`] for semantics.
791    #[must_use]
792    pub fn elevator_going_down(&self, id: EntityId) -> Option<bool> {
793        self.world.elevator(id).map(Elevator::going_down)
794    }
795
796    /// Direction the elevator is currently signalling, derived from the
797    /// indicator-lamp pair. Returns `None` if the entity is not an elevator.
798    #[must_use]
799    pub fn elevator_direction(&self, id: EntityId) -> Option<crate::components::Direction> {
800        self.world.elevator(id).map(Elevator::direction)
801    }
802
803    /// Count of rounded-floor transitions for an elevator (passing-floor
804    /// crossings plus arrivals). Returns `None` if the entity is not an
805    /// elevator.
806    #[must_use]
807    pub fn elevator_move_count(&self, id: EntityId) -> Option<u64> {
808        self.world.elevator(id).map(Elevator::move_count)
809    }
810
811    /// Distance the elevator would travel while braking to a stop from its
812    /// current velocity, at its configured deceleration rate.
813    ///
814    /// Uses the standard `v² / (2·a)` kinematic formula. A stationary
815    /// elevator returns `Some(0.0)`. Returns `None` if the entity is not
816    /// an elevator or lacks a velocity component.
817    ///
818    /// Useful for writing opportunistic dispatch strategies (e.g. "stop at
819    /// this floor if we can brake in time") without duplicating the physics
820    /// computation.
821    #[must_use]
822    pub fn braking_distance(&self, id: EntityId) -> Option<f64> {
823        let car = self.world.elevator(id)?;
824        let vel = self.world.velocity(id)?.value;
825        Some(crate::movement::braking_distance(
826            vel,
827            car.deceleration.value(),
828        ))
829    }
830
831    /// The position where the elevator would come to rest if it began braking
832    /// this instant. Current position plus a signed braking distance in the
833    /// direction of travel.
834    ///
835    /// Returns `None` if the entity is not an elevator or lacks the required
836    /// components.
837    #[must_use]
838    pub fn future_stop_position(&self, id: EntityId) -> Option<f64> {
839        let pos = self.world.position(id)?.value;
840        let vel = self.world.velocity(id)?.value;
841        let car = self.world.elevator(id)?;
842        let dist = crate::movement::braking_distance(vel, car.deceleration.value());
843        Some(vel.signum().mul_add(dist, pos))
844    }
845
846    /// Count of elevators currently in the given phase.
847    ///
848    /// Excludes disabled elevators (whose phase is reset to `Idle` on disable).
849    ///
850    /// ```
851    /// use elevator_core::prelude::*;
852    ///
853    /// let sim = SimulationBuilder::demo().build().unwrap();
854    /// assert_eq!(sim.elevators_in_phase(ElevatorPhase::Idle), 1);
855    /// assert_eq!(sim.elevators_in_phase(ElevatorPhase::Loading), 0);
856    /// ```
857    #[must_use]
858    pub fn elevators_in_phase(&self, phase: ElevatorPhase) -> usize {
859        self.world
860            .iter_elevators()
861            .filter(|(id, _, e)| e.phase() == phase && !self.world.is_disabled(*id))
862            .count()
863    }
864
865    // ── Service mode ────────────────────────────────────────────────
866
867    /// Set the service mode for an elevator.
868    ///
869    /// Emits [`Event::ServiceModeChanged`] if the mode actually changes.
870    ///
871    /// # Errors
872    ///
873    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
874    pub fn set_service_mode(
875        &mut self,
876        elevator: EntityId,
877        mode: crate::components::ServiceMode,
878    ) -> Result<(), SimError> {
879        if self.world.elevator(elevator).is_none() {
880            return Err(SimError::EntityNotFound(elevator));
881        }
882        let old = self
883            .world
884            .service_mode(elevator)
885            .copied()
886            .unwrap_or_default();
887        if old == mode {
888            return Ok(());
889        }
890        // Leaving Manual: clear the pending velocity command and zero
891        // the velocity component. Otherwise a car moving at transition
892        // time is stranded — the Normal movement system only runs for
893        // MovingToStop/Repositioning phases, so velocity would linger
894        // forever without producing any position change.
895        if old == crate::components::ServiceMode::Manual {
896            if let Some(car) = self.world.elevator_mut(elevator) {
897                car.manual_target_velocity = None;
898            }
899            if let Some(v) = self.world.velocity_mut(elevator) {
900                v.value = 0.0;
901            }
902        }
903        self.world.set_service_mode(elevator, mode);
904        self.events.emit(Event::ServiceModeChanged {
905            elevator,
906            from: old,
907            to: mode,
908            tick: self.tick,
909        });
910        Ok(())
911    }
912
913    /// Get the current service mode for an elevator.
914    #[must_use]
915    pub fn service_mode(&self, elevator: EntityId) -> crate::components::ServiceMode {
916        self.world
917            .service_mode(elevator)
918            .copied()
919            .unwrap_or_default()
920    }
921}