Skip to main content

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