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