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