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: EntityId) -> &[EntityId] {
499 self.world
500 .elevator(elevator)
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: EntityId) -> usize {
509 self.world
510 .elevator(elevator)
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 entity is not an elevator. See
1058 /// [`Elevator::going_up`] for semantics.
1059 #[must_use]
1060 pub fn elevator_going_up(&self, id: EntityId) -> Option<bool> {
1061 self.world.elevator(id).map(Elevator::going_up)
1062 }
1063
1064 /// Whether the elevator's down-direction indicator lamp is lit.
1065 ///
1066 /// Returns `None` if the entity is not an elevator. See
1067 /// [`Elevator::going_down`] for semantics.
1068 #[must_use]
1069 pub fn elevator_going_down(&self, id: EntityId) -> Option<bool> {
1070 self.world.elevator(id).map(Elevator::going_down)
1071 }
1072
1073 /// Direction the elevator is currently signalling, derived from the
1074 /// indicator-lamp pair. Returns `None` if the entity is not an elevator.
1075 #[must_use]
1076 pub fn elevator_direction(&self, id: EntityId) -> Option<crate::components::Direction> {
1077 self.world.elevator(id).map(Elevator::direction)
1078 }
1079
1080 /// Count of rounded-floor transitions for an elevator (passing-floor
1081 /// crossings plus arrivals). Returns `None` if the entity is not an
1082 /// elevator.
1083 #[must_use]
1084 pub fn elevator_move_count(&self, id: EntityId) -> Option<u64> {
1085 self.world.elevator(id).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 entity is not
1093 /// an elevator 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: EntityId) -> Option<f64> {
1100 let car = self.world.elevator(id)?;
1101 let vel = self.world.velocity(id)?.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 entity is not an elevator or lacks the required
1113 /// components.
1114 #[must_use]
1115 pub fn future_stop_position(&self, id: EntityId) -> Option<f64> {
1116 let pos = self.world.position(id)?.value;
1117 let vel = self.world.velocity(id)?.value;
1118 let car = self.world.elevator(id)?;
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: EntityId) -> crate::components::ServiceMode {
1194 self.world
1195 .service_mode(elevator)
1196 .copied()
1197 .unwrap_or_default()
1198 }
1199}