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