elevator_core/dispatch/mod.rs
1//! Pluggable dispatch strategies for assigning elevators to stops.
2//!
3//! Strategies express preferences as scores on `(car, stop)` pairs via
4//! [`DispatchStrategy::rank`](crate::dispatch::DispatchStrategy::rank). The
5//! dispatch system then runs an optimal bipartite assignment (Kuhn–Munkres /
6//! Hungarian algorithm) so coordination — one car per hall call — is a library
7//! invariant, not a per-strategy responsibility. Cars left unassigned are
8//! handed to [`DispatchStrategy::fallback`](crate::dispatch::DispatchStrategy::fallback)
9//! for per-car policy (idle, park, etc.).
10//!
11//! # Example: custom dispatch strategy
12//!
13//! ```rust
14//! use elevator_core::prelude::*;
15//!
16//! struct AlwaysFirstStop;
17//!
18//! impl DispatchStrategy for AlwaysFirstStop {
19//! fn rank(&mut self, ctx: &RankContext<'_>) -> Option<f64> {
20//! // Prefer the group's first stop; everything else is unavailable.
21//! if Some(&ctx.stop) == ctx.group.stop_entities().first() {
22//! Some((ctx.car_position - ctx.stop_position).abs())
23//! } else {
24//! None
25//! }
26//! }
27//! }
28//!
29//! let sim = SimulationBuilder::demo()
30//! .dispatch(AlwaysFirstStop)
31//! .build()
32//! .unwrap();
33//! ```
34
35/// Hall-call destination dispatch algorithm.
36pub mod destination;
37/// Estimated Time to Destination dispatch algorithm.
38pub mod etd;
39/// LOOK dispatch algorithm.
40pub mod look;
41/// Nearest-car dispatch algorithm.
42pub mod nearest_car;
43/// Built-in repositioning strategies.
44pub mod reposition;
45/// SCAN dispatch algorithm.
46pub mod scan;
47/// Shared sweep-direction logic used by SCAN and LOOK.
48pub(crate) mod sweep;
49
50pub use destination::{AssignedCar, DestinationDispatch};
51pub use etd::EtdDispatch;
52pub use look::LookDispatch;
53pub use nearest_car::NearestCarDispatch;
54pub use scan::ScanDispatch;
55
56use serde::{Deserialize, Serialize};
57
58use crate::components::{CallDirection, CarCall, HallCall, Route, Weight};
59use crate::entity::EntityId;
60use crate::ids::GroupId;
61use crate::world::World;
62use std::collections::BTreeMap;
63
64/// Whether assigning `ctx.car` to `ctx.stop` can perform useful work.
65///
66/// "Useful" here means one of: exit an aboard rider, board a waiting
67/// rider that fits, or answer a rider-less hall call with at least some
68/// spare capacity. A pair that can do none of those is a no-op move —
69/// and worse, a zero-cost one when the car is already parked at the
70/// stop — which dispatch strategies must exclude to avoid door-cycle
71/// stalls against unservable demand.
72///
73/// Built-in strategies use this as a universal floor; delivery-safety
74/// guarantees are only as strong as this guard. Custom strategies
75/// should call it at the top of their `rank` implementations when
76/// capacity-based stalls are a concern.
77#[must_use]
78pub fn pair_can_do_work(ctx: &RankContext<'_>) -> bool {
79 let Some(car) = ctx.world.elevator(ctx.car) else {
80 return false;
81 };
82 let can_exit_here = car
83 .riders()
84 .iter()
85 .any(|&rid| ctx.world.route(rid).and_then(Route::current_destination) == Some(ctx.stop));
86 if can_exit_here {
87 return true;
88 }
89
90 // Direction-dependent full-load bypass (Otis Elevonic 411 model,
91 // patent US5490580A). A car loaded above its configured threshold
92 // in the current travel direction ignores hall calls in that same
93 // direction. Aboard riders still get delivered — the `can_exit_here`
94 // short-circuit above guarantees their destinations remain rank-able.
95 if bypass_in_current_direction(car, ctx) {
96 return false;
97 }
98
99 let remaining_capacity = car.weight_capacity.value() - car.current_load.value();
100 if remaining_capacity <= 0.0 {
101 return false;
102 }
103 let waiting = ctx.manifest.waiting_riders_at(ctx.stop);
104 waiting.is_empty()
105 || waiting
106 .iter()
107 .any(|r| rider_can_board(r, car, ctx, remaining_capacity))
108}
109
110/// Whether a waiting rider could actually board this car, matching the
111/// same filters the loading phase applies. Prevents `pair_can_do_work`
112/// from approving a pickup whose only demand is direction-filtered or
113/// over-capacity — the loading phase would reject the rider, doors
114/// would cycle, and dispatch would re-pick the zero-cost self-pair.
115fn rider_can_board(
116 rider: &RiderInfo,
117 car: &crate::components::Elevator,
118 ctx: &RankContext<'_>,
119 remaining_capacity: f64,
120) -> bool {
121 if rider.weight.value() > remaining_capacity {
122 return false;
123 }
124 // Match `systems::loading`'s direction filter: a rider whose trip
125 // goes the opposite way of the car's committed direction will not
126 // be boarded. An unknown destination (no route yet) is treated as
127 // unconstrained — let the rider through and let the loading phase
128 // make the final call.
129 let Some(dest) = rider.destination else {
130 return true;
131 };
132 let Some(dest_pos) = ctx.world.stop_position(dest) else {
133 return true;
134 };
135 if dest_pos > ctx.stop_position && !car.going_up() {
136 return false;
137 }
138 if dest_pos < ctx.stop_position && !car.going_down() {
139 return false;
140 }
141 true
142}
143
144/// True when a full-load bypass applies: the car has a configured
145/// threshold for its current travel direction, is above that threshold,
146/// and the candidate stop lies in that same direction.
147fn bypass_in_current_direction(car: &crate::components::Elevator, ctx: &RankContext<'_>) -> bool {
148 // Derive travel direction from the car's current target, if any.
149 // An Idle or Stopped car has no committed direction → no bypass.
150 let Some(target) = car.phase().moving_target() else {
151 return false;
152 };
153 let Some(target_pos) = ctx.world.stop_position(target) else {
154 return false;
155 };
156 let going_up = target_pos > ctx.car_position;
157 let going_down = target_pos < ctx.car_position;
158 if !going_up && !going_down {
159 return false;
160 }
161 let threshold = if going_up {
162 car.bypass_load_up_pct()
163 } else {
164 car.bypass_load_down_pct()
165 };
166 let Some(pct) = threshold else {
167 return false;
168 };
169 let capacity = car.weight_capacity().value();
170 if capacity <= 0.0 {
171 return false;
172 }
173 let load_ratio = car.current_load().value() / capacity;
174 if load_ratio < pct {
175 return false;
176 }
177 // Only same-direction pickups get bypassed.
178 let stop_above = ctx.stop_position > ctx.car_position;
179 let stop_below = ctx.stop_position < ctx.car_position;
180 (going_up && stop_above) || (going_down && stop_below)
181}
182
183/// Metadata about a single rider, available to dispatch strategies.
184#[derive(Debug, Clone)]
185#[non_exhaustive]
186pub struct RiderInfo {
187 /// Rider entity ID.
188 pub id: EntityId,
189 /// Rider's destination stop entity (from route).
190 pub destination: Option<EntityId>,
191 /// Rider weight.
192 pub weight: Weight,
193 /// Ticks this rider has been waiting (0 if riding).
194 pub wait_ticks: u64,
195}
196
197/// Full demand picture for dispatch decisions.
198///
199/// Contains per-rider metadata grouped by stop, enabling entity-aware
200/// dispatch strategies (priority, weight-aware, VIP-first, etc.).
201///
202/// Uses `BTreeMap` for deterministic iteration order.
203#[derive(Debug, Clone, Default)]
204pub struct DispatchManifest {
205 /// Riders waiting at each stop, with full per-rider metadata.
206 pub(crate) waiting_at_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
207 /// Riders currently aboard elevators, grouped by their destination stop.
208 pub(crate) riding_to_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
209 /// Number of residents at each stop (read-only hint for dispatch strategies).
210 pub(crate) resident_count_at_stop: BTreeMap<EntityId, usize>,
211 /// Pending hall calls at each stop — at most two entries per stop
212 /// (one per [`CallDirection`]). Populated only for stops served by
213 /// the group being dispatched. Strategies read this to rank based on
214 /// call age, pending-rider count, pin flags, or DCS destinations.
215 pub(crate) hall_calls_at_stop: BTreeMap<EntityId, Vec<HallCall>>,
216 /// Floor buttons pressed inside each car in the group. Keyed by car
217 /// entity. Strategies read this to plan intermediate stops without
218 /// poking into `World` directly.
219 pub(crate) car_calls_by_car: BTreeMap<EntityId, Vec<CarCall>>,
220 /// Recent arrivals per stop, counted over
221 /// [`DispatchManifest::arrival_window_ticks`] ticks. Populated from
222 /// the [`crate::arrival_log::ArrivalLog`] world resource each pass
223 /// so strategies can read a traffic-rate signal without touching
224 /// world state directly.
225 pub(crate) arrivals_at_stop: BTreeMap<EntityId, u64>,
226 /// Window the `arrivals_at_stop` counts cover, in ticks. Exposed so
227 /// strategies interpreting the raw counts can convert them to a
228 /// rate (per tick or per second).
229 pub(crate) arrival_window_ticks: u64,
230}
231
232impl DispatchManifest {
233 /// Number of riders waiting at a stop.
234 #[must_use]
235 pub fn waiting_count_at(&self, stop: EntityId) -> usize {
236 self.waiting_at_stop.get(&stop).map_or(0, Vec::len)
237 }
238
239 /// Total weight of riders waiting at a stop.
240 #[must_use]
241 pub fn total_weight_at(&self, stop: EntityId) -> f64 {
242 self.waiting_at_stop
243 .get(&stop)
244 .map_or(0.0, |riders| riders.iter().map(|r| r.weight.value()).sum())
245 }
246
247 /// Number of riders heading to a stop (aboard elevators).
248 #[must_use]
249 pub fn riding_count_to(&self, stop: EntityId) -> usize {
250 self.riding_to_stop.get(&stop).map_or(0, Vec::len)
251 }
252
253 /// Whether a stop has any demand for this group: waiting riders,
254 /// riders heading there, or a *rider-less* hall call (one that
255 /// `press_hall_button` placed without a backing rider). Pre-fix
256 /// the rider-less case was invisible to every built-in dispatcher,
257 /// so explicit button presses with no associated rider went
258 /// unanswered indefinitely (#255).
259 ///
260 /// Hall calls *with* `pending_riders` are not double-counted —
261 /// those riders already appear in `waiting_count_at` for the
262 /// groups whose dispatch surface they belong to. Adding the call
263 /// to `has_demand` for *every* group that serves the stop would
264 /// pull cars from groups the rider doesn't even want, causing
265 /// open/close oscillation regression that the multi-group test
266 /// `dispatch_ignores_waiting_rider_targeting_another_group` pins.
267 #[must_use]
268 pub fn has_demand(&self, stop: EntityId) -> bool {
269 self.waiting_count_at(stop) > 0
270 || self.riding_count_to(stop) > 0
271 || self
272 .hall_calls_at_stop
273 .get(&stop)
274 .is_some_and(|calls| calls.iter().any(|c| c.pending_riders.is_empty()))
275 }
276
277 /// Number of residents at a stop (read-only hint, not active demand).
278 #[must_use]
279 pub fn resident_count_at(&self, stop: EntityId) -> usize {
280 self.resident_count_at_stop.get(&stop).copied().unwrap_or(0)
281 }
282
283 /// Rider arrivals at `stop` within the last
284 /// [`arrival_window_ticks`](Self::arrival_window_ticks) ticks. The
285 /// signal is the rolling-window per-stop arrival rate that
286 /// commercial controllers use to pick a traffic mode and that
287 /// [`crate::dispatch::reposition::PredictiveParking`] uses to
288 /// forecast demand. Unvisited stops return 0.
289 #[must_use]
290 pub fn arrivals_at(&self, stop: EntityId) -> u64 {
291 self.arrivals_at_stop.get(&stop).copied().unwrap_or(0)
292 }
293
294 /// Window size (in ticks) over which [`arrivals_at`](Self::arrivals_at)
295 /// counts events. Strategies convert counts to rates by dividing
296 /// by this.
297 #[must_use]
298 pub const fn arrival_window_ticks(&self) -> u64 {
299 self.arrival_window_ticks
300 }
301
302 /// The hall call at `(stop, direction)`, if pressed.
303 #[must_use]
304 pub fn hall_call_at(&self, stop: EntityId, direction: CallDirection) -> Option<&HallCall> {
305 self.hall_calls_at_stop
306 .get(&stop)?
307 .iter()
308 .find(|c| c.direction == direction)
309 }
310
311 /// All hall calls across every stop in the group (flattened iterator).
312 ///
313 /// No `#[must_use]` needed: `impl Iterator` already carries that
314 /// annotation, and adding our own triggers clippy's
315 /// `double_must_use` lint.
316 pub fn iter_hall_calls(&self) -> impl Iterator<Item = &HallCall> {
317 self.hall_calls_at_stop.values().flatten()
318 }
319
320 /// Floor buttons currently pressed inside `car`. Empty slice if the
321 /// car has no aboard riders or no outstanding presses.
322 #[must_use]
323 pub fn car_calls_for(&self, car: EntityId) -> &[CarCall] {
324 self.car_calls_by_car.get(&car).map_or(&[], Vec::as_slice)
325 }
326
327 /// Riders waiting at a specific stop.
328 #[must_use]
329 pub fn waiting_riders_at(&self, stop: EntityId) -> &[RiderInfo] {
330 self.waiting_at_stop.get(&stop).map_or(&[], Vec::as_slice)
331 }
332
333 /// Iterate over all `(stop, riders)` pairs with waiting demand.
334 pub fn iter_waiting_stops(&self) -> impl Iterator<Item = (&EntityId, &[RiderInfo])> {
335 self.waiting_at_stop
336 .iter()
337 .map(|(stop, riders)| (stop, riders.as_slice()))
338 }
339
340 /// Riders currently riding toward a specific stop.
341 #[must_use]
342 pub fn riding_riders_to(&self, stop: EntityId) -> &[RiderInfo] {
343 self.riding_to_stop.get(&stop).map_or(&[], Vec::as_slice)
344 }
345
346 /// Iterate over all `(stop, riders)` pairs with in-transit demand.
347 pub fn iter_riding_stops(&self) -> impl Iterator<Item = (&EntityId, &[RiderInfo])> {
348 self.riding_to_stop
349 .iter()
350 .map(|(stop, riders)| (stop, riders.as_slice()))
351 }
352
353 /// Iterate over all `(stop, hall_calls)` pairs with active calls.
354 pub fn iter_hall_call_stops(&self) -> impl Iterator<Item = (&EntityId, &[HallCall])> {
355 self.hall_calls_at_stop
356 .iter()
357 .map(|(stop, calls)| (stop, calls.as_slice()))
358 }
359}
360
361/// Serializable identifier for built-in dispatch strategies.
362///
363/// Used in snapshots and config files to restore the correct strategy
364/// without requiring the game to manually re-wire dispatch. Custom strategies
365/// are represented by the `Custom(String)` variant.
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367#[non_exhaustive]
368pub enum BuiltinStrategy {
369 /// SCAN (elevator) algorithm — sweeps end-to-end.
370 Scan,
371 /// LOOK algorithm — reverses at last request.
372 Look,
373 /// Nearest-car — assigns closest idle elevator.
374 NearestCar,
375 /// Estimated Time to Destination — minimizes total cost.
376 Etd,
377 /// Hall-call destination dispatch — sticky per-rider assignment.
378 Destination,
379 /// Custom strategy identified by name. The game must provide a factory.
380 Custom(String),
381}
382
383impl std::fmt::Display for BuiltinStrategy {
384 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385 match self {
386 Self::Scan => write!(f, "Scan"),
387 Self::Look => write!(f, "Look"),
388 Self::NearestCar => write!(f, "NearestCar"),
389 Self::Etd => write!(f, "Etd"),
390 Self::Destination => write!(f, "Destination"),
391 Self::Custom(name) => write!(f, "Custom({name})"),
392 }
393 }
394}
395
396impl BuiltinStrategy {
397 /// Instantiate the dispatch strategy for this variant.
398 ///
399 /// Returns `None` for `Custom` — the game must provide those via
400 /// a factory function.
401 #[must_use]
402 pub fn instantiate(&self) -> Option<Box<dyn DispatchStrategy>> {
403 match self {
404 Self::Scan => Some(Box::new(scan::ScanDispatch::new())),
405 Self::Look => Some(Box::new(look::LookDispatch::new())),
406 Self::NearestCar => Some(Box::new(nearest_car::NearestCarDispatch::new())),
407 Self::Etd => Some(Box::new(etd::EtdDispatch::new())),
408 Self::Destination => Some(Box::new(destination::DestinationDispatch::new())),
409 Self::Custom(_) => None,
410 }
411 }
412}
413
414/// Decision returned by a dispatch strategy.
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
416#[non_exhaustive]
417pub enum DispatchDecision {
418 /// Go to the specified stop entity.
419 GoToStop(EntityId),
420 /// Remain idle.
421 Idle,
422}
423
424/// Per-line relationship data within an [`ElevatorGroup`].
425///
426/// This is a denormalized cache maintained by [`Simulation`](crate::sim::Simulation).
427/// The source of truth for intrinsic line properties is the
428/// [`Line`](crate::components::Line) component in World.
429#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct LineInfo {
431 /// Line entity ID.
432 entity: EntityId,
433 /// Elevator entities on this line.
434 elevators: Vec<EntityId>,
435 /// Stop entities served by this line.
436 serves: Vec<EntityId>,
437}
438
439impl LineInfo {
440 /// Create a new `LineInfo`.
441 #[must_use]
442 pub const fn new(entity: EntityId, elevators: Vec<EntityId>, serves: Vec<EntityId>) -> Self {
443 Self {
444 entity,
445 elevators,
446 serves,
447 }
448 }
449
450 /// Line entity ID.
451 #[must_use]
452 pub const fn entity(&self) -> EntityId {
453 self.entity
454 }
455
456 /// Elevator entities on this line.
457 #[must_use]
458 pub fn elevators(&self) -> &[EntityId] {
459 &self.elevators
460 }
461
462 /// Stop entities served by this line.
463 #[must_use]
464 pub fn serves(&self) -> &[EntityId] {
465 &self.serves
466 }
467
468 /// Set the line entity ID (used during snapshot restore).
469 pub(crate) const fn set_entity(&mut self, entity: EntityId) {
470 self.entity = entity;
471 }
472
473 /// Mutable access to elevator entities on this line.
474 pub(crate) const fn elevators_mut(&mut self) -> &mut Vec<EntityId> {
475 &mut self.elevators
476 }
477
478 /// Mutable access to stop entities served by this line.
479 pub(crate) const fn serves_mut(&mut self) -> &mut Vec<EntityId> {
480 &mut self.serves
481 }
482}
483
484/// How hall calls expose rider destinations to dispatch.
485///
486/// Different building eras and controller designs reveal destinations
487/// at different moments. Groups pick a mode so the sim can model both
488/// traditional up/down collective-control elevators and modern
489/// destination-dispatch lobby kiosks within the same simulation.
490///
491/// Stops are expected to belong to exactly one group. When a stop
492/// overlaps multiple groups, the hall-call press consults the first
493/// group containing it (iteration order over
494/// [`Simulation::groups`](crate::sim::Simulation::groups)), which in
495/// turn determines the `HallCallMode` and ack latency applied to that
496/// call. Overlapping topologies are not validated at construction
497/// time; games that need them should be aware of this first-match
498/// rule.
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
500#[non_exhaustive]
501pub enum HallCallMode {
502 /// Traditional collective-control ("classic" Otis/Westinghouse).
503 ///
504 /// Riders press an up or down button in the hall; the destination
505 /// is revealed only *after* boarding, via a
506 /// [`CarCall`]. Dispatch sees a direction
507 /// per call but does not know individual rider destinations until
508 /// they're aboard.
509 #[default]
510 Classic,
511 /// Modern destination dispatch ("DCS" — Otis `CompassPlus`, KONE
512 /// Polaris, Schindler PORT).
513 ///
514 /// Riders enter their destination at a hall kiosk, so each
515 /// [`HallCall`] carries a destination
516 /// stop from the moment it's pressed. Required by
517 /// [`DestinationDispatch`].
518 Destination,
519}
520
521/// Runtime elevator group: a set of lines sharing a dispatch strategy.
522///
523/// A group is the logical dispatch unit. It contains one or more
524/// [`LineInfo`] entries, each representing a physical path with its
525/// elevators and served stops.
526///
527/// The flat `elevator_entities` and `stop_entities` fields are derived
528/// caches (union of all lines' elevators/stops), rebuilt automatically
529/// via [`rebuild_caches()`](Self::rebuild_caches).
530#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct ElevatorGroup {
532 /// Unique group identifier.
533 id: GroupId,
534 /// Human-readable group name.
535 name: String,
536 /// Lines belonging to this group.
537 lines: Vec<LineInfo>,
538 /// How hall calls reveal destinations to dispatch (Classic vs DCS).
539 hall_call_mode: HallCallMode,
540 /// Ticks between a button press and dispatch first seeing the call.
541 /// `0` = immediate (current behavior). Realistic values: 5–30 ticks
542 /// at 60 Hz, modeling controller processing latency.
543 ack_latency_ticks: u32,
544 /// Derived flat cache — rebuilt by `rebuild_caches()`.
545 elevator_entities: Vec<EntityId>,
546 /// Derived flat cache — rebuilt by `rebuild_caches()`.
547 stop_entities: Vec<EntityId>,
548}
549
550impl ElevatorGroup {
551 /// Create a new group with the given lines. Caches are built automatically.
552 /// Defaults: [`HallCallMode::Classic`], `ack_latency_ticks = 0`.
553 #[must_use]
554 pub fn new(id: GroupId, name: String, lines: Vec<LineInfo>) -> Self {
555 let mut group = Self {
556 id,
557 name,
558 lines,
559 hall_call_mode: HallCallMode::default(),
560 ack_latency_ticks: 0,
561 elevator_entities: Vec::new(),
562 stop_entities: Vec::new(),
563 };
564 group.rebuild_caches();
565 group
566 }
567
568 /// Override the hall call mode for this group.
569 #[must_use]
570 pub const fn with_hall_call_mode(mut self, mode: HallCallMode) -> Self {
571 self.hall_call_mode = mode;
572 self
573 }
574
575 /// Override the ack latency for this group.
576 #[must_use]
577 pub const fn with_ack_latency_ticks(mut self, ticks: u32) -> Self {
578 self.ack_latency_ticks = ticks;
579 self
580 }
581
582 /// Set the hall call mode in-place (for mutation via
583 /// [`Simulation::groups_mut`](crate::sim::Simulation::groups_mut)).
584 pub const fn set_hall_call_mode(&mut self, mode: HallCallMode) {
585 self.hall_call_mode = mode;
586 }
587
588 /// Set the ack latency in-place.
589 pub const fn set_ack_latency_ticks(&mut self, ticks: u32) {
590 self.ack_latency_ticks = ticks;
591 }
592
593 /// Hall call mode for this group.
594 #[must_use]
595 pub const fn hall_call_mode(&self) -> HallCallMode {
596 self.hall_call_mode
597 }
598
599 /// Controller ack latency for this group.
600 #[must_use]
601 pub const fn ack_latency_ticks(&self) -> u32 {
602 self.ack_latency_ticks
603 }
604
605 /// Unique group identifier.
606 #[must_use]
607 pub const fn id(&self) -> GroupId {
608 self.id
609 }
610
611 /// Human-readable group name.
612 #[must_use]
613 pub fn name(&self) -> &str {
614 &self.name
615 }
616
617 /// Lines belonging to this group.
618 #[must_use]
619 pub fn lines(&self) -> &[LineInfo] {
620 &self.lines
621 }
622
623 /// Mutable access to lines (call [`rebuild_caches()`](Self::rebuild_caches) after mutating).
624 pub const fn lines_mut(&mut self) -> &mut Vec<LineInfo> {
625 &mut self.lines
626 }
627
628 /// Elevator entities belonging to this group (derived from lines).
629 #[must_use]
630 pub fn elevator_entities(&self) -> &[EntityId] {
631 &self.elevator_entities
632 }
633
634 /// Stop entities served by this group (derived from lines, deduplicated).
635 #[must_use]
636 pub fn stop_entities(&self) -> &[EntityId] {
637 &self.stop_entities
638 }
639
640 /// Push a stop entity directly into the group's stop cache.
641 ///
642 /// Use when a stop belongs to the group for dispatch purposes but is
643 /// not (yet) assigned to any line. Call `add_stop_to_line` later to
644 /// wire it into the topology graph.
645 pub(crate) fn push_stop(&mut self, stop: EntityId) {
646 if !self.stop_entities.contains(&stop) {
647 self.stop_entities.push(stop);
648 }
649 }
650
651 /// Push an elevator entity directly into the group's elevator cache
652 /// (in addition to the line it belongs to).
653 pub(crate) fn push_elevator(&mut self, elevator: EntityId) {
654 if !self.elevator_entities.contains(&elevator) {
655 self.elevator_entities.push(elevator);
656 }
657 }
658
659 /// Rebuild derived caches from lines. Call after mutating lines.
660 pub fn rebuild_caches(&mut self) {
661 self.elevator_entities = self
662 .lines
663 .iter()
664 .flat_map(|li| li.elevators.iter().copied())
665 .collect();
666 let mut stops: Vec<EntityId> = self
667 .lines
668 .iter()
669 .flat_map(|li| li.serves.iter().copied())
670 .collect();
671 stops.sort_unstable();
672 stops.dedup();
673 self.stop_entities = stops;
674 }
675}
676
677/// Context passed to [`DispatchStrategy::rank`].
678///
679/// Bundles the per-call arguments into a single struct so future context
680/// fields can be added without breaking existing trait implementations.
681#[non_exhaustive]
682pub struct RankContext<'a> {
683 /// The elevator being evaluated.
684 pub car: EntityId,
685 /// Current position of the car along the shaft axis.
686 pub car_position: f64,
687 /// The stop being evaluated as a candidate destination.
688 pub stop: EntityId,
689 /// Position of the candidate stop along the shaft axis.
690 pub stop_position: f64,
691 /// The dispatch group this assignment belongs to.
692 pub group: &'a ElevatorGroup,
693 /// Demand snapshot for the current dispatch pass.
694 pub manifest: &'a DispatchManifest,
695 /// Read-only world state.
696 pub world: &'a World,
697}
698
699impl std::fmt::Debug for RankContext<'_> {
700 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
701 f.debug_struct("RankContext")
702 .field("car", &self.car)
703 .field("car_position", &self.car_position)
704 .field("stop", &self.stop)
705 .field("stop_position", &self.stop_position)
706 .field("group", &self.group)
707 .field("manifest", &self.manifest)
708 .field("world", &"World { .. }")
709 .finish()
710 }
711}
712
713/// Pluggable dispatch algorithm.
714///
715/// Strategies implement [`rank`](Self::rank) to score each `(car, stop)`
716/// pair; the dispatch system then performs an optimal assignment across
717/// the whole group, guaranteeing that no two cars are sent to the same
718/// hall call.
719///
720/// Returning `None` from `rank` excludes a pair from assignment — useful
721/// for capacity limits, direction preferences, restricted stops, or
722/// sticky commitments.
723///
724/// Cars that receive no stop fall through to [`fallback`](Self::fallback),
725/// which returns the policy for that car (idle, park, etc.).
726pub trait DispatchStrategy: Send + Sync {
727 /// Optional hook called once per group before the assignment pass.
728 ///
729 /// Strategies that need to mutate [`World`] extension storage (e.g.
730 /// [`DestinationDispatch`] writing sticky rider → car assignments)
731 /// or pre-populate [`crate::components::DestinationQueue`] entries
732 /// override this. Default: no-op.
733 fn pre_dispatch(
734 &mut self,
735 _group: &ElevatorGroup,
736 _manifest: &DispatchManifest,
737 _world: &mut World,
738 ) {
739 }
740
741 /// Optional hook called once per candidate car, before any
742 /// [`rank`](Self::rank) calls for that car in the current pass.
743 ///
744 /// Strategies whose ranking depends on stable per-car state (e.g. the
745 /// sweep direction used by SCAN/LOOK) set that state here so later
746 /// `rank` calls see a consistent view regardless of iteration order.
747 /// The default is a no-op.
748 fn prepare_car(
749 &mut self,
750 _car: EntityId,
751 _car_position: f64,
752 _group: &ElevatorGroup,
753 _manifest: &DispatchManifest,
754 _world: &World,
755 ) {
756 }
757
758 /// Score the cost of sending `car` to `stop`. Lower is better.
759 ///
760 /// Returning `None` marks this `(car, stop)` pair as unavailable;
761 /// the assignment algorithm will never pair them. Use this for
762 /// capacity limits, wrong-direction stops, stops outside the line's
763 /// topology, or pairs already committed via a sticky assignment.
764 ///
765 /// Must return a finite, non-negative value if `Some` — infinities
766 /// and NaN can destabilize the underlying Hungarian solver.
767 ///
768 /// Implementations must not mutate per-car state inside `rank`: the
769 /// dispatch system calls `rank(car, stop_0..stop_m)` in a loop, so
770 /// mutating `self` on one call affects subsequent calls for the same
771 /// car within the same pass and produces an asymmetric cost matrix
772 /// whose results depend on iteration order. Use
773 /// [`prepare_car`](Self::prepare_car) to compute and store any
774 /// per-car state before `rank` is called.
775 fn rank(&mut self, ctx: &RankContext<'_>) -> Option<f64>;
776
777 /// Decide what an idle car should do when no stop was assigned to it.
778 ///
779 /// Called for each car the assignment phase could not pair with a
780 /// stop (because there were no stops, or all candidate stops had
781 /// rank `None` for this car). Default: [`DispatchDecision::Idle`].
782 fn fallback(
783 &mut self,
784 _car: EntityId,
785 _car_position: f64,
786 _group: &ElevatorGroup,
787 _manifest: &DispatchManifest,
788 _world: &World,
789 ) -> DispatchDecision {
790 DispatchDecision::Idle
791 }
792
793 /// Notify the strategy that an elevator has been removed.
794 ///
795 /// Implementations with per-elevator state (e.g. direction tracking)
796 /// should clean up here to prevent unbounded memory growth.
797 fn notify_removed(&mut self, _elevator: EntityId) {}
798}
799
800/// Resolution of a single dispatch assignment pass for one group.
801///
802/// Produced by `assign` and consumed by
803/// `crate::systems::dispatch::run` to apply decisions to the world.
804#[derive(Debug, Clone)]
805pub struct AssignmentResult {
806 /// `(car, decision)` pairs for every idle car in the group.
807 pub decisions: Vec<(EntityId, DispatchDecision)>,
808}
809
810/// Sentinel weight used to pad unavailable `(car, stop)` pairs when
811/// building the cost matrix for the Hungarian solver. Chosen so that
812/// `n · SENTINEL` can't overflow `i64`: the Kuhn–Munkres implementation
813/// sums weights and potentials across each row/column internally, so
814/// headroom of ~2¹⁵ above the sentinel lets groups scale past 30 000
815/// cars or stops before any arithmetic risk appears.
816const ASSIGNMENT_SENTINEL: i64 = 1 << 48;
817/// Fixed-point scale for converting `f64` costs to the `i64` values the
818/// Hungarian solver requires. One unit ≈ one micro-tick / millimeter.
819const ASSIGNMENT_SCALE: f64 = 1_000_000.0;
820
821/// Convert a `f64` rank cost into the fixed-point `i64` the Hungarian
822/// solver consumes. Non-finite, negative, or overflow-prone inputs map
823/// to the unavailable sentinel.
824fn scale_cost(cost: f64) -> i64 {
825 if !cost.is_finite() || cost < 0.0 {
826 debug_assert!(
827 cost.is_finite() && cost >= 0.0,
828 "DispatchStrategy::rank() returned invalid cost {cost}; must be finite and non-negative"
829 );
830 return ASSIGNMENT_SENTINEL;
831 }
832 // Cap at just below sentinel so any real rank always beats unavailable.
833 (cost * ASSIGNMENT_SCALE)
834 .round()
835 .clamp(0.0, (ASSIGNMENT_SENTINEL - 1) as f64) as i64
836}
837
838/// Run one group's assignment pass: build the cost matrix, solve the
839/// optimal bipartite matching, then resolve unassigned cars via
840/// [`DispatchStrategy::fallback`].
841///
842/// Visible to the `systems` module; not part of the public API.
843pub(crate) fn assign(
844 strategy: &mut dyn DispatchStrategy,
845 idle_cars: &[(EntityId, f64)],
846 group: &ElevatorGroup,
847 manifest: &DispatchManifest,
848 world: &World,
849) -> AssignmentResult {
850 // Collect stops with active demand and known positions.
851 let pending_stops: Vec<(EntityId, f64)> = group
852 .stop_entities()
853 .iter()
854 .filter(|s| manifest.has_demand(**s))
855 .filter_map(|s| world.stop_position(*s).map(|p| (*s, p)))
856 .collect();
857
858 let n = idle_cars.len();
859 let m = pending_stops.len();
860
861 if n == 0 {
862 return AssignmentResult {
863 decisions: Vec::new(),
864 };
865 }
866
867 let mut decisions: Vec<(EntityId, DispatchDecision)> = Vec::with_capacity(n);
868
869 if m == 0 {
870 for &(eid, pos) in idle_cars {
871 let d = strategy.fallback(eid, pos, group, manifest, world);
872 decisions.push((eid, d));
873 }
874 return AssignmentResult { decisions };
875 }
876
877 // Build cost matrix. Hungarian requires rows <= cols.
878 let cols = n.max(m);
879 let mut data: Vec<i64> = vec![ASSIGNMENT_SENTINEL; n * cols];
880 for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
881 strategy.prepare_car(car_eid, car_pos, group, manifest, world);
882 // Cache the car's restricted-stops set for this row so each
883 // (car, stop) pair can short-circuit before calling rank().
884 // Pre-fix only DCS consulted restricted_stops; SCAN/LOOK/NC/ETD
885 // happily ranked restricted pairs and `commit_go_to_stop` later
886 // silently dropped the assignment, starving the call. (#256)
887 let restricted = world
888 .elevator(car_eid)
889 .map(|c| c.restricted_stops().clone())
890 .unwrap_or_default();
891 for (j, &(stop_eid, stop_pos)) in pending_stops.iter().enumerate() {
892 if restricted.contains(&stop_eid) {
893 continue; // leave SENTINEL — this pair is unavailable
894 }
895 let ctx = RankContext {
896 car: car_eid,
897 car_position: car_pos,
898 stop: stop_eid,
899 stop_position: stop_pos,
900 group,
901 manifest,
902 world,
903 };
904 let scaled = strategy.rank(&ctx).map_or(ASSIGNMENT_SENTINEL, scale_cost);
905 data[i * cols + j] = scaled;
906 }
907 }
908 // `from_vec` only fails if `n * cols != data.len()` — both derived
909 // from `n` and `cols` above, so the construction is infallible. Fall
910 // back to an empty-result shape in the unlikely event the invariant
911 // is violated in future refactors.
912 let Ok(matrix) = pathfinding::matrix::Matrix::from_vec(n, cols, data) else {
913 for &(car_eid, car_pos) in idle_cars {
914 let d = strategy.fallback(car_eid, car_pos, group, manifest, world);
915 decisions.push((car_eid, d));
916 }
917 return AssignmentResult { decisions };
918 };
919 let (_, assignments) = pathfinding::kuhn_munkres::kuhn_munkres_min(&matrix);
920
921 for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
922 let col = assignments[i];
923 // A real assignment is: col points to a real stop (col < m) AND
924 // the cost isn't sentinel-padded (meaning rank() returned Some).
925 if col < m && matrix[(i, col)] < ASSIGNMENT_SENTINEL {
926 let (stop_eid, _) = pending_stops[col];
927 decisions.push((car_eid, DispatchDecision::GoToStop(stop_eid)));
928 } else {
929 let d = strategy.fallback(car_eid, car_pos, group, manifest, world);
930 decisions.push((car_eid, d));
931 }
932 }
933
934 AssignmentResult { decisions }
935}
936
937/// Pluggable strategy for repositioning idle elevators.
938///
939/// After the dispatch phase, elevators that remain idle (no pending
940/// assignments) are candidates for repositioning. The strategy decides
941/// where each idle elevator should move to improve coverage and reduce
942/// expected response times.
943///
944/// Implementations receive the set of idle elevator positions and the
945/// group's stop positions, then return a target stop for each elevator
946/// (or `None` to leave it in place).
947pub trait RepositionStrategy: Send + Sync {
948 /// Decide where to reposition idle elevators.
949 ///
950 /// Push `(elevator_entity, target_stop_entity)` pairs into `out`.
951 /// The buffer is cleared before each call — implementations should
952 /// only push, never read prior contents. Elevators not pushed remain idle.
953 fn reposition(
954 &mut self,
955 idle_elevators: &[(EntityId, f64)],
956 stop_positions: &[(EntityId, f64)],
957 group: &ElevatorGroup,
958 world: &World,
959 out: &mut Vec<(EntityId, EntityId)>,
960 );
961}
962
963/// Serializable identifier for built-in repositioning strategies.
964///
965/// Used in config and snapshots to restore the correct strategy.
966#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
967#[non_exhaustive]
968pub enum BuiltinReposition {
969 /// Distribute idle elevators evenly across stops.
970 SpreadEvenly,
971 /// Return idle elevators to a configured home stop.
972 ReturnToLobby,
973 /// Position near stops with historically high demand.
974 DemandWeighted,
975 /// Keep idle elevators where they are (no-op).
976 NearestIdle,
977 /// Pre-position cars near stops with the highest recent arrival rate.
978 PredictiveParking,
979 /// Custom strategy identified by name.
980 Custom(String),
981}
982
983impl std::fmt::Display for BuiltinReposition {
984 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
985 match self {
986 Self::SpreadEvenly => write!(f, "SpreadEvenly"),
987 Self::ReturnToLobby => write!(f, "ReturnToLobby"),
988 Self::DemandWeighted => write!(f, "DemandWeighted"),
989 Self::NearestIdle => write!(f, "NearestIdle"),
990 Self::PredictiveParking => write!(f, "PredictiveParking"),
991 Self::Custom(name) => write!(f, "Custom({name})"),
992 }
993 }
994}
995
996impl BuiltinReposition {
997 /// Instantiate the reposition strategy for this variant.
998 ///
999 /// Returns `None` for `Custom` — the game must provide those via
1000 /// a factory function. `ReturnToLobby` uses stop index 0 as default.
1001 #[must_use]
1002 pub fn instantiate(&self) -> Option<Box<dyn RepositionStrategy>> {
1003 match self {
1004 Self::SpreadEvenly => Some(Box::new(reposition::SpreadEvenly)),
1005 Self::ReturnToLobby => Some(Box::new(reposition::ReturnToLobby::new())),
1006 Self::DemandWeighted => Some(Box::new(reposition::DemandWeighted)),
1007 Self::NearestIdle => Some(Box::new(reposition::NearestIdle)),
1008 Self::PredictiveParking => Some(Box::new(reposition::PredictiveParking::new())),
1009 Self::Custom(_) => None,
1010 }
1011 }
1012}