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