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