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