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::dispatch::RankContext;
15//! use elevator_core::prelude::*;
16//!
17//! struct AlwaysFirstStop;
18//!
19//! impl DispatchStrategy for AlwaysFirstStop {
20//!     fn rank(&self, ctx: &RankContext<'_>) -> Option<f64> {
21//!         // Prefer the group's first stop; everything else is unavailable.
22//!         if Some(&ctx.stop) == ctx.group.stop_entities().first() {
23//!             Some((ctx.car_position() - ctx.stop_position()).abs())
24//!         } else {
25//!             None
26//!         }
27//!     }
28//! }
29//!
30//! let sim = SimulationBuilder::demo()
31//!     .dispatch(AlwaysFirstStop)
32//!     .build()
33//!     .unwrap();
34//! ```
35
36/// Hall-call destination dispatch algorithm.
37pub mod destination;
38/// Estimated Time to Destination dispatch algorithm.
39pub mod etd;
40/// LOOK dispatch algorithm.
41pub mod look;
42/// Nearest-car dispatch algorithm.
43pub mod nearest_car;
44/// Built-in repositioning strategies.
45pub mod reposition;
46/// Relative System Response (RSR) dispatch algorithm.
47pub mod rsr;
48/// SCAN dispatch algorithm.
49pub mod scan;
50/// Shared sweep-direction logic used by SCAN and LOOK.
51pub(crate) mod sweep;
52
53pub use destination::{AssignedCar, DestinationDispatch};
54pub use etd::EtdDispatch;
55pub use look::LookDispatch;
56pub use nearest_car::NearestCarDispatch;
57pub use rsr::RsrDispatch;
58pub use scan::ScanDispatch;
59
60use serde::{Deserialize, Serialize};
61
62use crate::components::{
63    CallDirection, CarCall, ElevatorPhase, HallCall, Route, TransportMode, Weight,
64};
65use crate::entity::EntityId;
66use crate::ids::GroupId;
67use crate::world::World;
68use std::collections::{BTreeMap, HashSet};
69
70/// Whether assigning `ctx.car` to `ctx.stop` is worth ranking.
71///
72/// Combines two checks every dispatch strategy needs at the top of its
73/// `rank` implementation:
74///
75/// 1. **Servability** — capacity, full-load bypass, and the loading-phase
76///    boarding filter. A pair that can't exit an aboard rider, board a
77///    waiter, or answer a rider-less hall call is a no-op move (and a
78///    zero-cost one when the car is already parked there) which would
79///    otherwise stall doors against unservable demand.
80/// 2. **Path discipline** (only when `respect_aboard_path` is `true`) —
81///    refuses pickups that would pull a car carrying routed riders off
82///    the direct path to every aboard rider's destination. Without it, a
83///    stream of closer-destination hall calls can indefinitely preempt a
84///    farther aboard rider's delivery (the "never reaches the
85///    passenger's desired stop" loop).
86///
87/// Strategies with their own direction discipline (SCAN, LOOK, ETD,
88/// Destination) pass `respect_aboard_path: false` because their
89/// sweep/direction terms already rule out backtracks. Strategies without
90/// it (`NearestCar`, RSR) pass `respect_aboard_path: true`. Custom
91/// strategies should pass `true` unless they enforce direction
92/// discipline themselves.
93///
94/// Aboard riders without a published route (game-managed manual riders)
95/// don't constrain the path — any pickup is trivially on-the-way for
96/// them, so the path check trivially passes when no aboard rider has a
97/// `Route::current_destination`.
98#[must_use]
99pub fn pair_is_useful(ctx: &RankContext<'_>, respect_aboard_path: bool) -> bool {
100    let Some(car) = ctx.world.elevator(ctx.car) else {
101        return false;
102    };
103    let can_exit_here = car
104        .riders()
105        .iter()
106        .any(|&rid| ctx.world.route(rid).and_then(Route::current_destination) == Some(ctx.stop));
107    if can_exit_here {
108        return true;
109    }
110
111    // Direction-dependent full-load bypass (Otis Elevonic 411 model,
112    // patent US5490580A). A car loaded above its configured threshold
113    // in the current travel direction ignores hall calls in that same
114    // direction. Aboard riders still get delivered — the `can_exit_here`
115    // short-circuit above guarantees their destinations remain rank-able.
116    if bypass_in_current_direction(car, ctx) {
117        return false;
118    }
119
120    let remaining_capacity = car.weight_capacity.value() - car.current_load.value();
121    if remaining_capacity <= 0.0 {
122        return false;
123    }
124    let waiting = ctx.manifest.waiting_riders_at(ctx.stop);
125    let servable = if waiting.is_empty() {
126        // No waiters at the stop, and no aboard rider of ours exits here
127        // (the `can_exit_here` short-circuit ruled that out above).
128        // Demand must therefore come from either another car's
129        // `riding_to_stop` (not work this car can perform) or a
130        // rider-less hall call (someone pressed a button with no rider
131        // attached yet — a press from `press_hall_button` or one whose
132        // riders have since been fulfilled or abandoned). Only the
133        // latter is actionable; without this filter an idle car parked
134        // at the stop collapses to cost 0, the Hungarian picks the
135        // self-pair every tick, and doors cycle open/close indefinitely
136        // while the other car finishes its trip.
137        ctx.manifest
138            .hall_calls_at_stop
139            .get(&ctx.stop)
140            .is_some_and(|calls| calls.iter().any(|c| c.pending_riders.is_empty()))
141    } else {
142        waiting
143            .iter()
144            .any(|r| rider_can_board(r, car, ctx, remaining_capacity))
145    };
146    if !servable {
147        return false;
148    }
149    if !respect_aboard_path || car.riders().is_empty() {
150        return true;
151    }
152
153    // Route-less aboard riders (game-managed manual riders) don't
154    // publish a destination, so there's no committed path to protect.
155    // Any pickup is trivially on-the-way — let it through. Otherwise
156    // we'd refuse every pickup the moment the car carried its first
157    // manually-managed passenger.
158    let has_routed_rider = car.riders().iter().any(|&rid| {
159        ctx.world
160            .route(rid)
161            .and_then(Route::current_destination)
162            .is_some()
163    });
164    if !has_routed_rider {
165        return true;
166    }
167
168    // Pickups allowed only on the path to an aboard rider's destination.
169    // Candidate at the car's position (to_cand = 0) trivially qualifies —
170    // useful for same-floor boards.
171    let to_cand = ctx.stop_position() - ctx.car_position();
172    car.riders().iter().any(|&rid| {
173        let Some(dest) = ctx.world.route(rid).and_then(Route::current_destination) else {
174            return false;
175        };
176        let Some(dest_pos) = ctx.world.stop_position(dest) else {
177            return false;
178        };
179        let to_dest = dest_pos - ctx.car_position();
180        to_dest * to_cand >= 0.0 && to_cand.abs() <= to_dest.abs()
181    })
182}
183
184/// Whether a waiting rider could actually board this car, matching the
185/// same filters the loading phase applies. Prevents `pair_is_useful`
186/// from approving a pickup whose only demand is direction-filtered or
187/// over-capacity — the loading phase would reject the rider, doors
188/// would cycle, and dispatch would re-pick the zero-cost self-pair.
189fn rider_can_board(
190    rider: &RiderInfo,
191    car: &crate::components::Elevator,
192    ctx: &RankContext<'_>,
193    remaining_capacity: f64,
194) -> bool {
195    if rider.weight.value() > remaining_capacity {
196        return false;
197    }
198    // Match `systems::loading`'s direction filter: a rider whose trip
199    // goes the opposite way of the car's committed direction will not
200    // be boarded. An unknown destination (no route yet) is treated as
201    // unconstrained — let the rider through and let the loading phase
202    // make the final call.
203    let Some(dest) = rider.destination else {
204        return true;
205    };
206    let Some(dest_pos) = ctx.world.stop_position(dest) else {
207        return true;
208    };
209    if dest_pos > ctx.stop_position() && !car.going_up() {
210        return false;
211    }
212    if dest_pos < ctx.stop_position() && !car.going_down() {
213        return false;
214    }
215    true
216}
217
218/// True when a full-load bypass applies: the car has a configured
219/// threshold for its current travel direction, is above that threshold,
220/// and the candidate stop lies in that same direction.
221fn bypass_in_current_direction(car: &crate::components::Elevator, ctx: &RankContext<'_>) -> bool {
222    // Derive travel direction from the car's current target, if any.
223    // An Idle or Stopped car has no committed direction → no bypass.
224    let Some(target) = car.phase().moving_target() else {
225        return false;
226    };
227    let Some(target_pos) = ctx.world.stop_position(target) else {
228        return false;
229    };
230    let going_up = target_pos > ctx.car_position();
231    let going_down = target_pos < ctx.car_position();
232    if !going_up && !going_down {
233        return false;
234    }
235    let threshold = if going_up {
236        car.bypass_load_up_pct()
237    } else {
238        car.bypass_load_down_pct()
239    };
240    let Some(pct) = threshold else {
241        return false;
242    };
243    let capacity = car.weight_capacity().value();
244    if capacity <= 0.0 {
245        return false;
246    }
247    let load_ratio = car.current_load().value() / capacity;
248    if load_ratio < pct {
249        return false;
250    }
251    // Only same-direction pickups get bypassed.
252    let stop_above = ctx.stop_position() > ctx.car_position();
253    let stop_below = ctx.stop_position() < ctx.car_position();
254    (going_up && stop_above) || (going_down && stop_below)
255}
256
257/// Metadata about a single rider, available to dispatch strategies.
258#[derive(Debug, Clone)]
259#[non_exhaustive]
260pub struct RiderInfo {
261    /// Rider entity ID.
262    pub id: EntityId,
263    /// Rider's destination stop entity (from route).
264    pub destination: Option<EntityId>,
265    /// Rider weight.
266    pub weight: Weight,
267    /// Ticks this rider has been waiting (0 if riding).
268    pub wait_ticks: u64,
269}
270
271/// Full demand picture for dispatch decisions.
272///
273/// Contains per-rider metadata grouped by stop, enabling entity-aware
274/// dispatch strategies (priority, weight-aware, VIP-first, etc.).
275///
276/// Uses `BTreeMap` for deterministic iteration order.
277#[derive(Debug, Clone, Default)]
278pub struct DispatchManifest {
279    /// Riders waiting at each stop, with full per-rider metadata.
280    pub(crate) waiting_at_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
281    /// Riders currently aboard elevators, grouped by their destination stop.
282    pub(crate) riding_to_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
283    /// Number of residents at each stop (read-only hint for dispatch strategies).
284    pub(crate) resident_count_at_stop: BTreeMap<EntityId, usize>,
285    /// Pending hall calls at each stop — at most two entries per stop
286    /// (one per [`CallDirection`]). Populated only for stops served by
287    /// the group being dispatched. Strategies read this to rank based on
288    /// call age, pending-rider count, pin flags, or DCS destinations.
289    pub(crate) hall_calls_at_stop: BTreeMap<EntityId, Vec<HallCall>>,
290    /// Floor buttons pressed inside each car in the group. Keyed by car
291    /// entity. Strategies read this to plan intermediate stops without
292    /// poking into `World` directly.
293    pub(crate) car_calls_by_car: BTreeMap<EntityId, Vec<CarCall>>,
294    /// Recent arrivals per stop, counted over
295    /// [`DispatchManifest::arrival_window_ticks`] ticks. Populated from
296    /// the [`crate::arrival_log::ArrivalLog`] world resource each pass
297    /// so strategies can read a traffic-rate signal without touching
298    /// world state directly.
299    pub(crate) arrivals_at_stop: BTreeMap<EntityId, u64>,
300    /// Window the `arrivals_at_stop` counts cover, in ticks. Exposed so
301    /// strategies interpreting the raw counts can convert them to a
302    /// rate (per tick or per second).
303    pub(crate) arrival_window_ticks: u64,
304}
305
306impl DispatchManifest {
307    /// Number of riders waiting at a stop.
308    #[must_use]
309    pub fn waiting_count_at(&self, stop: EntityId) -> usize {
310        self.waiting_at_stop.get(&stop).map_or(0, Vec::len)
311    }
312
313    /// Total weight of riders waiting at a stop.
314    #[must_use]
315    pub fn total_weight_at(&self, stop: EntityId) -> f64 {
316        self.waiting_at_stop
317            .get(&stop)
318            .map_or(0.0, |riders| riders.iter().map(|r| r.weight.value()).sum())
319    }
320
321    /// Number of riders heading to a stop (aboard elevators).
322    #[must_use]
323    pub fn riding_count_to(&self, stop: EntityId) -> usize {
324        self.riding_to_stop.get(&stop).map_or(0, Vec::len)
325    }
326
327    /// Whether a stop has any demand for this group: waiting riders,
328    /// riders heading there, or a *rider-less* hall call (one that
329    /// `press_hall_button` placed without a backing rider). Pre-fix
330    /// the rider-less case was invisible to every built-in dispatcher,
331    /// so explicit button presses with no associated rider went
332    /// unanswered indefinitely (#255).
333    ///
334    /// Hall calls *with* `pending_riders` are not double-counted —
335    /// those riders already appear in `waiting_count_at` for the
336    /// groups whose dispatch surface they belong to. Adding the call
337    /// to `has_demand` for *every* group that serves the stop would
338    /// pull cars from groups the rider doesn't even want, causing
339    /// open/close oscillation regression that the multi-group test
340    /// `dispatch_ignores_waiting_rider_targeting_another_group` pins.
341    #[must_use]
342    pub fn has_demand(&self, stop: EntityId) -> bool {
343        self.waiting_count_at(stop) > 0
344            || self.riding_count_to(stop) > 0
345            || self
346                .hall_calls_at_stop
347                .get(&stop)
348                .is_some_and(|calls| calls.iter().any(|c| c.pending_riders.is_empty()))
349    }
350
351    /// Number of residents at a stop (read-only hint, not active demand).
352    #[must_use]
353    pub fn resident_count_at(&self, stop: EntityId) -> usize {
354        self.resident_count_at_stop.get(&stop).copied().unwrap_or(0)
355    }
356
357    /// Rider arrivals at `stop` within the last
358    /// [`arrival_window_ticks`](Self::arrival_window_ticks) ticks. The
359    /// signal is the rolling-window per-stop arrival rate that
360    /// commercial controllers use to pick a traffic mode and that
361    /// [`crate::dispatch::reposition::PredictiveParking`] uses to
362    /// forecast demand. Unvisited stops return 0.
363    #[must_use]
364    pub fn arrivals_at(&self, stop: EntityId) -> u64 {
365        self.arrivals_at_stop.get(&stop).copied().unwrap_or(0)
366    }
367
368    /// Window size (in ticks) over which [`arrivals_at`](Self::arrivals_at)
369    /// counts events. Strategies convert counts to rates by dividing
370    /// by this.
371    #[must_use]
372    pub const fn arrival_window_ticks(&self) -> u64 {
373        self.arrival_window_ticks
374    }
375
376    /// The hall call at `(stop, direction)`, if pressed.
377    #[must_use]
378    pub fn hall_call_at(&self, stop: EntityId, direction: CallDirection) -> Option<&HallCall> {
379        self.hall_calls_at_stop
380            .get(&stop)?
381            .iter()
382            .find(|c| c.direction == direction)
383    }
384
385    /// All hall calls across every stop in the group (flattened iterator).
386    ///
387    /// No `#[must_use]` needed: `impl Iterator` already carries that
388    /// annotation, and adding our own triggers clippy's
389    /// `double_must_use` lint.
390    pub fn iter_hall_calls(&self) -> impl Iterator<Item = &HallCall> {
391        self.hall_calls_at_stop.values().flatten()
392    }
393
394    /// Floor buttons currently pressed inside `car`. Empty slice if the
395    /// car has no aboard riders or no outstanding presses.
396    #[must_use]
397    pub fn car_calls_for(&self, car: EntityId) -> &[CarCall] {
398        self.car_calls_by_car.get(&car).map_or(&[], Vec::as_slice)
399    }
400
401    /// Riders waiting at a specific stop.
402    #[must_use]
403    pub fn waiting_riders_at(&self, stop: EntityId) -> &[RiderInfo] {
404        self.waiting_at_stop.get(&stop).map_or(&[], Vec::as_slice)
405    }
406
407    /// Iterate over all `(stop, riders)` pairs with waiting demand.
408    pub fn iter_waiting_stops(&self) -> impl Iterator<Item = (&EntityId, &[RiderInfo])> {
409        self.waiting_at_stop
410            .iter()
411            .map(|(stop, riders)| (stop, riders.as_slice()))
412    }
413
414    /// Riders currently riding toward a specific stop.
415    #[must_use]
416    pub fn riding_riders_to(&self, stop: EntityId) -> &[RiderInfo] {
417        self.riding_to_stop.get(&stop).map_or(&[], Vec::as_slice)
418    }
419
420    /// Iterate over all `(stop, riders)` pairs with in-transit demand.
421    pub fn iter_riding_stops(&self) -> impl Iterator<Item = (&EntityId, &[RiderInfo])> {
422        self.riding_to_stop
423            .iter()
424            .map(|(stop, riders)| (stop, riders.as_slice()))
425    }
426
427    /// Iterate over all `(stop, hall_calls)` pairs with active calls.
428    pub fn iter_hall_call_stops(&self) -> impl Iterator<Item = (&EntityId, &[HallCall])> {
429        self.hall_calls_at_stop
430            .iter()
431            .map(|(stop, calls)| (stop, calls.as_slice()))
432    }
433}
434
435/// Serializable identifier for built-in dispatch strategies.
436///
437/// Used in snapshots and config files to restore the correct strategy
438/// without requiring the game to manually re-wire dispatch. Custom strategies
439/// are represented by the `Custom(String)` variant.
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441#[non_exhaustive]
442pub enum BuiltinStrategy {
443    /// SCAN (elevator) algorithm — sweeps end-to-end.
444    Scan,
445    /// LOOK algorithm — reverses at last request.
446    Look,
447    /// Nearest-car — assigns closest idle elevator.
448    NearestCar,
449    /// Estimated Time to Destination — minimizes total cost.
450    Etd,
451    /// Hall-call destination dispatch — sticky per-rider assignment.
452    Destination,
453    /// Relative System Response — additive composite of ETA, direction,
454    /// car-call affinity, and load-share terms.
455    Rsr,
456    /// Custom strategy identified by name. The game must provide a factory.
457    Custom(String),
458}
459
460impl std::fmt::Display for BuiltinStrategy {
461    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462        match self {
463            Self::Scan => write!(f, "Scan"),
464            Self::Look => write!(f, "Look"),
465            Self::NearestCar => write!(f, "NearestCar"),
466            Self::Etd => write!(f, "Etd"),
467            Self::Destination => write!(f, "Destination"),
468            Self::Rsr => write!(f, "Rsr"),
469            Self::Custom(name) => write!(f, "Custom({name})"),
470        }
471    }
472}
473
474impl BuiltinStrategy {
475    /// Instantiate the dispatch strategy for this variant.
476    ///
477    /// Returns `None` for `Custom` — the game must provide those via
478    /// a factory function.
479    #[must_use]
480    pub fn instantiate(&self) -> Option<Box<dyn DispatchStrategy>> {
481        match self {
482            Self::Scan => Some(Box::new(scan::ScanDispatch::new())),
483            Self::Look => Some(Box::new(look::LookDispatch::new())),
484            Self::NearestCar => Some(Box::new(nearest_car::NearestCarDispatch::new())),
485            // `Default` ships the tuned stack (age-linear fairness term
486            // active); `new()` is the zero baseline for mutant/unit
487            // tests that isolate single terms. The playground's "ETD"
488            // dropdown entry should map to the strategy with fairness
489            // protection, not the raw version that lets the max-wait
490            // tail drift unbounded.
491            Self::Etd => Some(Box::new(etd::EtdDispatch::default())),
492            Self::Destination => Some(Box::new(destination::DestinationDispatch::new())),
493            // `Default` ships with the tuned penalty stack; `new()` is
494            // the zero baseline for additive-composition tests. The
495            // playground's "RSR" dropdown entry should map to the
496            // actual strategy, not to NearestCar-in-disguise, so use
497            // `Default` here.
498            Self::Rsr => Some(Box::new(rsr::RsrDispatch::default())),
499            Self::Custom(_) => None,
500        }
501    }
502}
503
504/// Decision returned by a dispatch strategy.
505#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
506#[non_exhaustive]
507pub enum DispatchDecision {
508    /// Go to the specified stop entity.
509    GoToStop(EntityId),
510    /// Remain idle.
511    Idle,
512}
513
514/// Per-line relationship data within an [`ElevatorGroup`].
515///
516/// This is a denormalized cache maintained by [`Simulation`](crate::sim::Simulation).
517/// The source of truth for intrinsic line properties is the
518/// [`Line`](crate::components::Line) component in World.
519#[derive(Debug, Clone, Serialize, Deserialize)]
520pub struct LineInfo {
521    /// Line entity ID.
522    entity: EntityId,
523    /// Elevator entities on this line.
524    elevators: Vec<EntityId>,
525    /// Stop entities served by this line.
526    serves: Vec<EntityId>,
527}
528
529impl LineInfo {
530    /// Create a new `LineInfo`.
531    #[must_use]
532    pub const fn new(entity: EntityId, elevators: Vec<EntityId>, serves: Vec<EntityId>) -> Self {
533        Self {
534            entity,
535            elevators,
536            serves,
537        }
538    }
539
540    /// Line entity ID.
541    #[must_use]
542    pub const fn entity(&self) -> EntityId {
543        self.entity
544    }
545
546    /// Elevator entities on this line.
547    #[must_use]
548    pub fn elevators(&self) -> &[EntityId] {
549        &self.elevators
550    }
551
552    /// Stop entities served by this line.
553    #[must_use]
554    pub fn serves(&self) -> &[EntityId] {
555        &self.serves
556    }
557
558    /// Set the line entity ID (used during snapshot restore).
559    pub(crate) const fn set_entity(&mut self, entity: EntityId) {
560        self.entity = entity;
561    }
562
563    /// Add an elevator to this line, deduplicating against existing entries.
564    ///
565    /// Returns `true` if the elevator was inserted, `false` if it was
566    /// already present. Replaces direct `&mut Vec` access so callers
567    /// can't introduce duplicates the dedup invariants in
568    /// [`ElevatorGroup::rebuild_caches`] rely on.
569    pub(crate) fn add_elevator(&mut self, elevator: EntityId) -> bool {
570        if self.elevators.contains(&elevator) {
571            false
572        } else {
573            self.elevators.push(elevator);
574            true
575        }
576    }
577
578    /// Remove an elevator from this line.
579    ///
580    /// Returns `true` if the elevator was present and removed, `false`
581    /// if it was absent.
582    pub(crate) fn remove_elevator(&mut self, elevator: EntityId) -> bool {
583        let len_before = self.elevators.len();
584        self.elevators.retain(|&e| e != elevator);
585        self.elevators.len() != len_before
586    }
587
588    /// Add a stop to this line's served list, deduplicating against
589    /// existing entries.
590    ///
591    /// Returns `true` if the stop was inserted, `false` if it was
592    /// already present.
593    pub(crate) fn add_stop(&mut self, stop: EntityId) -> bool {
594        if self.serves.contains(&stop) {
595            false
596        } else {
597            self.serves.push(stop);
598            true
599        }
600    }
601
602    /// Remove a stop from this line's served list.
603    ///
604    /// Returns `true` if the stop was present and removed, `false`
605    /// if it was absent.
606    pub(crate) fn remove_stop(&mut self, stop: EntityId) -> bool {
607        let len_before = self.serves.len();
608        self.serves.retain(|&s| s != stop);
609        self.serves.len() != len_before
610    }
611}
612
613/// How hall calls expose rider destinations to dispatch.
614///
615/// Different building eras and controller designs reveal destinations
616/// at different moments. Groups pick a mode so the sim can model both
617/// traditional up/down collective-control elevators and modern
618/// destination-dispatch lobby kiosks within the same simulation.
619///
620/// Stops are expected to belong to exactly one group. When a stop
621/// overlaps multiple groups, the hall-call press consults the first
622/// group containing it (iteration order over
623/// [`Simulation::groups`](crate::sim::Simulation::groups)), which in
624/// turn determines the `HallCallMode` and ack latency applied to that
625/// call. Overlapping topologies are not validated at construction
626/// time; games that need them should be aware of this first-match
627/// rule.
628#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
629#[non_exhaustive]
630pub enum HallCallMode {
631    /// Traditional collective-control ("classic" Otis/Westinghouse).
632    ///
633    /// Riders press an up or down button in the hall; the destination
634    /// is revealed only *after* boarding, via a
635    /// [`CarCall`]. Dispatch sees a direction
636    /// per call but does not know individual rider destinations until
637    /// they're aboard.
638    #[default]
639    Classic,
640    /// Modern destination dispatch ("DCS" — Otis `CompassPlus`, KONE
641    /// Polaris, Schindler PORT).
642    ///
643    /// Riders enter their destination at a hall kiosk, so each
644    /// [`HallCall`] carries a destination
645    /// stop from the moment it's pressed. Required by
646    /// [`DestinationDispatch`].
647    Destination,
648}
649
650/// Runtime elevator group: a set of lines sharing a dispatch strategy.
651///
652/// A group is the logical dispatch unit. It contains one or more
653/// [`LineInfo`] entries, each representing a physical path with its
654/// elevators and served stops.
655///
656/// The flat `elevator_entities` and `stop_entities` fields are derived
657/// caches (union of all lines' elevators/stops), rebuilt automatically
658/// via [`rebuild_caches()`](Self::rebuild_caches).
659#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct ElevatorGroup {
661    /// Unique group identifier.
662    id: GroupId,
663    /// Human-readable group name.
664    name: String,
665    /// Lines belonging to this group.
666    lines: Vec<LineInfo>,
667    /// How hall calls reveal destinations to dispatch (Classic vs DCS).
668    hall_call_mode: HallCallMode,
669    /// Ticks between a button press and dispatch first seeing the call.
670    /// `0` = immediate (current behavior). Realistic values: 5–30 ticks
671    /// at 60 Hz, modeling controller processing latency.
672    ack_latency_ticks: u32,
673    /// Derived flat cache — rebuilt by `rebuild_caches()`.
674    elevator_entities: Vec<EntityId>,
675    /// Derived flat cache — rebuilt by `rebuild_caches()`.
676    stop_entities: Vec<EntityId>,
677}
678
679impl ElevatorGroup {
680    /// Create a new group with the given lines. Caches are built automatically.
681    /// Defaults: [`HallCallMode::Classic`], `ack_latency_ticks = 0`.
682    #[must_use]
683    pub fn new(id: GroupId, name: String, lines: Vec<LineInfo>) -> Self {
684        let mut group = Self {
685            id,
686            name,
687            lines,
688            hall_call_mode: HallCallMode::default(),
689            ack_latency_ticks: 0,
690            elevator_entities: Vec::new(),
691            stop_entities: Vec::new(),
692        };
693        group.rebuild_caches();
694        group
695    }
696
697    /// Override the hall call mode for this group.
698    #[must_use]
699    pub const fn with_hall_call_mode(mut self, mode: HallCallMode) -> Self {
700        self.hall_call_mode = mode;
701        self
702    }
703
704    /// Override the ack latency for this group.
705    #[must_use]
706    pub const fn with_ack_latency_ticks(mut self, ticks: u32) -> Self {
707        self.ack_latency_ticks = ticks;
708        self
709    }
710
711    /// Set the hall call mode in-place (for mutation via
712    /// [`Simulation::groups_mut`](crate::sim::Simulation::groups_mut)).
713    pub const fn set_hall_call_mode(&mut self, mode: HallCallMode) {
714        self.hall_call_mode = mode;
715    }
716
717    /// Set the ack latency in-place.
718    pub const fn set_ack_latency_ticks(&mut self, ticks: u32) {
719        self.ack_latency_ticks = ticks;
720    }
721
722    /// Hall call mode for this group.
723    #[must_use]
724    pub const fn hall_call_mode(&self) -> HallCallMode {
725        self.hall_call_mode
726    }
727
728    /// Controller ack latency for this group.
729    #[must_use]
730    pub const fn ack_latency_ticks(&self) -> u32 {
731        self.ack_latency_ticks
732    }
733
734    /// Unique group identifier.
735    #[must_use]
736    pub const fn id(&self) -> GroupId {
737        self.id
738    }
739
740    /// Human-readable group name.
741    #[must_use]
742    pub fn name(&self) -> &str {
743        &self.name
744    }
745
746    /// Lines belonging to this group.
747    #[must_use]
748    pub fn lines(&self) -> &[LineInfo] {
749        &self.lines
750    }
751
752    /// Mutable access to lines (call [`rebuild_caches()`](Self::rebuild_caches) after mutating).
753    pub const fn lines_mut(&mut self) -> &mut Vec<LineInfo> {
754        &mut self.lines
755    }
756
757    /// Elevator entities belonging to this group (derived from lines).
758    #[must_use]
759    pub fn elevator_entities(&self) -> &[EntityId] {
760        &self.elevator_entities
761    }
762
763    /// Stop entities served by this group (derived from lines, deduplicated).
764    #[must_use]
765    pub fn stop_entities(&self) -> &[EntityId] {
766        &self.stop_entities
767    }
768
769    /// Whether this group can serve a rider on `leg`. A `Group(g)` leg
770    /// matches by group id; a `Line(l)` leg matches if `l` belongs to
771    /// this group; `Walk` never rides an elevator.
772    #[must_use]
773    pub fn accepts_leg(&self, leg: &crate::components::RouteLeg) -> bool {
774        match leg.via {
775            crate::components::TransportMode::Group(g) => g == self.id,
776            crate::components::TransportMode::Line(l) => {
777                self.lines.iter().any(|li| li.entity() == l)
778            }
779            crate::components::TransportMode::Walk => false,
780        }
781    }
782
783    /// Push a stop entity directly into the group's stop cache.
784    ///
785    /// Use when a stop belongs to the group for dispatch purposes but is
786    /// not (yet) assigned to any line. Call `add_stop_to_line` later to
787    /// wire it into the topology graph.
788    pub(crate) fn push_stop(&mut self, stop: EntityId) {
789        if !self.stop_entities.contains(&stop) {
790            self.stop_entities.push(stop);
791        }
792    }
793
794    /// Push an elevator entity directly into the group's elevator cache
795    /// (in addition to the line it belongs to).
796    pub(crate) fn push_elevator(&mut self, elevator: EntityId) {
797        if !self.elevator_entities.contains(&elevator) {
798            self.elevator_entities.push(elevator);
799        }
800    }
801
802    /// Rebuild derived caches from lines. Call after mutating lines.
803    pub fn rebuild_caches(&mut self) {
804        self.elevator_entities = self
805            .lines
806            .iter()
807            .flat_map(|li| li.elevators.iter().copied())
808            .collect();
809        let mut stops: Vec<EntityId> = self
810            .lines
811            .iter()
812            .flat_map(|li| li.serves.iter().copied())
813            .collect();
814        stops.sort_unstable();
815        stops.dedup();
816        self.stop_entities = stops;
817    }
818}
819
820/// Look up the `serves` list for an elevator's line.
821///
822/// Walks `groups` to find the [`LineInfo`] whose entity matches the
823/// car's current `line()`. Returns `None` if the car has no line
824/// registered in any group (an inconsistent state — should be
825/// unreachable in a healthy sim).
826///
827/// Helper for callers of
828/// [`World::find_stop_at_position_in`](crate::world::World::find_stop_at_position_in)
829/// that already have group context: `find_stop_at_position(pos)` is
830/// global (any line wins) and ambiguous when two lines share a
831/// position; passing the elevator's serves list scopes the lookup to
832/// *its* line.
833///
834/// Cost: `O(groups × lines_per_group)` per call. For loops over many
835/// elevators per tick, prefer [`build_line_serves_index`] +
836/// [`elevator_line_serves_indexed`] to amortize the line walk.
837#[must_use]
838pub fn elevator_line_serves<'a>(
839    world: &World,
840    groups: &'a [ElevatorGroup],
841    elevator: EntityId,
842) -> Option<&'a [EntityId]> {
843    let line_eid = world.elevator(elevator)?.line();
844    groups
845        .iter()
846        .flat_map(ElevatorGroup::lines)
847        .find(|li| li.entity() == line_eid)
848        .map(LineInfo::serves)
849}
850
851/// Pre-built index mapping each line entity to its `serves` slice.
852/// Built once with [`build_line_serves_index`]; queried with
853/// [`elevator_line_serves_indexed`] for O(1) per-elevator lookup.
854pub type LineServesIndex<'a> = std::collections::HashMap<EntityId, &'a [EntityId]>;
855
856/// Build a [`LineServesIndex`] from the group list. O(groups × lines).
857/// Call once per substep / system and reuse across the elevator loop.
858#[must_use]
859pub fn build_line_serves_index(groups: &[ElevatorGroup]) -> LineServesIndex<'_> {
860    let mut idx: LineServesIndex<'_> = std::collections::HashMap::new();
861    for li in groups.iter().flat_map(ElevatorGroup::lines) {
862        idx.insert(li.entity(), li.serves());
863    }
864    idx
865}
866
867/// Indexed variant of [`elevator_line_serves`]. O(1) per call given
868/// a pre-built [`LineServesIndex`].
869#[must_use]
870pub fn elevator_line_serves_indexed<'a>(
871    world: &World,
872    index: &LineServesIndex<'a>,
873    elevator: EntityId,
874) -> Option<&'a [EntityId]> {
875    let line_eid = world.elevator(elevator)?.line();
876    index.get(&line_eid).copied()
877}
878
879/// Context passed to [`DispatchStrategy::rank`].
880///
881/// Bundles the per-call arguments into a single struct so future context
882/// fields can be added without breaking existing trait implementations.
883#[non_exhaustive]
884pub struct RankContext<'a> {
885    /// The elevator being evaluated.
886    pub car: EntityId,
887    /// The stop being evaluated as a candidate destination.
888    pub stop: EntityId,
889    /// The dispatch group this assignment belongs to.
890    pub group: &'a ElevatorGroup,
891    /// Demand snapshot for the current dispatch pass.
892    pub manifest: &'a DispatchManifest,
893    /// Read-only world state.
894    pub world: &'a World,
895}
896
897impl RankContext<'_> {
898    /// Position of [`car`](Self::car) along the shaft axis.
899    ///
900    /// Returns `0.0` for an entity that has no `Position` component
901    /// (which would never reach this method through normal dispatch
902    /// — `compute_assignments` filters out cars without positions
903    /// upstream — but the defensive default protects custom callers).
904    /// Derived from [`world`](Self::world) on each call: the dispatch
905    /// loop never moves elevators between rank calls, so re-deriving
906    /// is free, and skipping the duplicate field eliminates the
907    /// synchronisation risk of the old shape.
908    #[must_use]
909    pub fn car_position(&self) -> f64 {
910        self.world.position(self.car).map_or(0.0, |p| p.value)
911    }
912
913    /// Position of [`stop`](Self::stop) along the shaft axis.
914    ///
915    /// Returns `0.0` for an entity that has no `Stop` component (same
916    /// rationale as [`car_position`](Self::car_position)).
917    #[must_use]
918    pub fn stop_position(&self) -> f64 {
919        self.world.stop_position(self.stop).unwrap_or(0.0)
920    }
921}
922
923impl std::fmt::Debug for RankContext<'_> {
924    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
925        f.debug_struct("RankContext")
926            .field("car", &self.car)
927            .field("car_position", &self.car_position())
928            .field("stop", &self.stop)
929            .field("stop_position", &self.stop_position())
930            .field("group", &self.group)
931            .field("manifest", &self.manifest)
932            .field("world", &"World { .. }")
933            .finish()
934    }
935}
936
937/// Pluggable dispatch algorithm.
938///
939/// Strategies implement [`rank`](Self::rank) to score each `(car, stop)`
940/// pair; the dispatch system then performs an optimal assignment across
941/// the whole group, guaranteeing that no two cars are sent to the same
942/// hall call.
943///
944/// Returning `None` from `rank` excludes a pair from assignment — useful
945/// for capacity limits, direction preferences, restricted stops, or
946/// sticky commitments.
947///
948/// Cars that receive no stop fall through to [`fallback`](Self::fallback),
949/// which returns the policy for that car (idle, park, etc.).
950pub trait DispatchStrategy: Send + Sync {
951    /// Optional hook called once per group before the assignment pass.
952    ///
953    /// Strategies that need to mutate [`World`] extension storage (e.g.
954    /// [`DestinationDispatch`] writing sticky rider → car assignments)
955    /// or pre-populate [`crate::components::DestinationQueue`] entries
956    /// override this. Default: no-op.
957    fn pre_dispatch(
958        &mut self,
959        _group: &ElevatorGroup,
960        _manifest: &DispatchManifest,
961        _world: &mut World,
962    ) {
963    }
964
965    /// Optional hook called once per candidate car, before any
966    /// [`rank`](Self::rank) calls for that car in the current pass.
967    ///
968    /// Strategies whose ranking depends on stable per-car state (e.g. the
969    /// sweep direction used by SCAN/LOOK) set that state here so later
970    /// `rank` calls see a consistent view regardless of iteration order.
971    /// The default is a no-op.
972    fn prepare_car(
973        &mut self,
974        _car: EntityId,
975        _car_position: f64,
976        _group: &ElevatorGroup,
977        _manifest: &DispatchManifest,
978        _world: &World,
979    ) {
980    }
981
982    /// Score the cost of sending `car` to `stop`. Lower is better.
983    ///
984    /// Returning `None` marks this `(car, stop)` pair as unavailable;
985    /// the assignment algorithm will never pair them. Use this for
986    /// capacity limits, wrong-direction stops, stops outside the line's
987    /// topology, or pairs already committed via a sticky assignment.
988    ///
989    /// Must return a finite, non-negative value if `Some` — infinities
990    /// and NaN can destabilize the underlying Hungarian solver.
991    ///
992    /// Takes `&self` so the assignment loop can score `(car, stop)` pairs
993    /// in any order without producing an asymmetric cost matrix. Compute
994    /// any per-car scratch in [`prepare_car`](Self::prepare_car) (which
995    /// takes `&mut self`) before this method is called.
996    fn rank(&self, ctx: &RankContext<'_>) -> Option<f64>;
997
998    /// Decide what an idle car should do when no stop was assigned to it.
999    ///
1000    /// Called for each car the assignment phase could not pair with a
1001    /// stop (because there were no stops, or all candidate stops had
1002    /// rank `None` for this car). Default: [`DispatchDecision::Idle`].
1003    fn fallback(
1004        &mut self,
1005        _car: EntityId,
1006        _car_position: f64,
1007        _group: &ElevatorGroup,
1008        _manifest: &DispatchManifest,
1009        _world: &World,
1010    ) -> DispatchDecision {
1011        DispatchDecision::Idle
1012    }
1013
1014    /// Notify the strategy that an elevator has been removed.
1015    ///
1016    /// Implementations with per-elevator state (e.g. direction tracking)
1017    /// should clean up here to prevent unbounded memory growth.
1018    fn notify_removed(&mut self, _elevator: EntityId) {}
1019
1020    /// If this strategy is a known built-in variant, return it so
1021    /// [`Simulation::new`](crate::sim::Simulation::new) can stamp the
1022    /// correct [`BuiltinStrategy`] into the group's snapshot identity.
1023    ///
1024    /// Without this, legacy-topology sims constructed via
1025    /// `Simulation::new(config, SomeNonScanStrategy::new())` silently
1026    /// recorded `BuiltinStrategy::Scan` as their identity — so a
1027    /// snapshot round-trip replaced the running strategy with Scan
1028    /// and produced different dispatch decisions post-restore
1029    /// (determinism regression).
1030    ///
1031    /// Default: `None` (unidentified — the constructor falls back to
1032    /// recording [`BuiltinStrategy::Scan`], matching pre-fix behaviour
1033    /// for callers that never cared about round-trip identity). Custom
1034    /// strategies that DO care should override this to return
1035    /// [`BuiltinStrategy::Custom`] with a stable name.
1036    #[must_use]
1037    fn builtin_id(&self) -> Option<BuiltinStrategy> {
1038        None
1039    }
1040
1041    /// Serialize this strategy's tunable configuration to a string
1042    /// that [`restore_config`](Self::restore_config) can apply to a
1043    /// freshly-instantiated instance.
1044    ///
1045    /// Returning `Some(..)` makes the configuration survive snapshot
1046    /// round-trip: without it, [`crate::snapshot::WorldSnapshot::restore`]
1047    /// instantiates each built-in via [`BuiltinStrategy::instantiate`],
1048    /// which calls `::new()` with default weights — silently dropping
1049    /// any tuning applied via `with_*` builder methods (e.g.
1050    /// `EtdDispatch::with_delay_weight(2.5)` degrades to the default
1051    /// `1.0` on the restored sim).
1052    ///
1053    /// Default: `None` (no configuration to save). Built-ins with
1054    /// tunable weights override to return a RON-serialized copy of
1055    /// themselves; strategies with transient per-pass scratch should
1056    /// use `#[serde(skip)]` on those fields so the snapshot stays
1057    /// compact and deterministic.
1058    #[must_use]
1059    fn snapshot_config(&self) -> Option<String> {
1060        None
1061    }
1062
1063    /// Restore tunable configuration from a string previously produced
1064    /// by [`snapshot_config`](Self::snapshot_config) on the same
1065    /// strategy variant. Called by
1066    /// [`crate::snapshot::WorldSnapshot::restore`] immediately after
1067    /// [`BuiltinStrategy::instantiate`] builds the default instance,
1068    /// so the restore writes over the defaults.
1069    ///
1070    /// # Errors
1071    /// Returns the underlying parse error as a `String` when the
1072    /// serialized form doesn't round-trip. Default implementation
1073    /// ignores the argument and returns `Ok(())` — paired with the
1074    /// `None` default of `snapshot_config`, this means strategies that
1075    /// don't override either method skip configuration round-trip,
1076    /// matching pre-fix behaviour.
1077    fn restore_config(&mut self, _serialized: &str) -> Result<(), String> {
1078        Ok(())
1079    }
1080}
1081
1082/// Resolution of a single dispatch assignment pass for one group.
1083///
1084/// Produced by `assign` and consumed by
1085/// `crate::systems::dispatch::run` to apply decisions to the world.
1086#[derive(Debug, Clone)]
1087pub struct AssignmentResult {
1088    /// `(car, decision)` pairs for every idle car in the group.
1089    pub decisions: Vec<(EntityId, DispatchDecision)>,
1090}
1091
1092/// Per-simulation scratch buffers for the dispatch phase.
1093///
1094/// Every field is a `Vec`/`HashSet` whose allocations the hot path
1095/// would otherwise re-take on every tick per group (cost matrix
1096/// backing store, pending-stops list, servicing cars, pinned /
1097/// committed / idle-elevator filters). Owning them on the
1098/// simulation lets each dispatch pass `clear()` them in place and
1099/// reuse the capacity — on a 50-car × 200-stop group the cost matrix
1100/// alone is ~80 KB of heap churn per tick, and at the 500-car
1101/// `scaling_extreme` scale it's ~20 MB.
1102///
1103/// The scratch is always cleared before use; consumers should not
1104/// rely on any carry-over content between groups or ticks.
1105#[derive(Default)]
1106pub(crate) struct DispatchScratch {
1107    /// Reusable `Matrix<i64>` the Hungarian consumes by reference. When
1108    /// the dispatch pass can reuse the stored Matrix (`rows × cols`
1109    /// match), this is `Some` and gets filled in-place via `Matrix::fill`;
1110    /// when shapes change the slot is replaced with `Matrix::new`.
1111    pub cost_matrix_mx: Option<pathfinding::matrix::Matrix<i64>>,
1112    /// `(stop, line, remaining_capacity)` for door-cycling cars, used
1113    /// by `pending_stops_minus_covered` to avoid double-dispatching
1114    /// stops a car is already servicing.
1115    pub servicing: Vec<(EntityId, EntityId, f64)>,
1116    /// Stops with live demand, returned from `pending_stops_minus_covered`.
1117    pub pending_stops: Vec<(EntityId, f64)>,
1118    /// Aboard-rider destinations across idle cars — consulted so a
1119    /// stop that a car aboard wants to reach stays pickup-eligible.
1120    pub idle_rider_destinations: HashSet<EntityId>,
1121    /// Per-stop linestamp buffer reused inside `is_covered`.
1122    pub lines_here: Vec<EntityId>,
1123    /// Pinned hall-call `(car, stop)` pairs for the current group.
1124    pub pinned_pairs: Vec<(EntityId, EntityId)>,
1125    /// Committed `(car, target)` pairs — mid-flight cars whose trip
1126    /// still has demand; held out of the Hungarian idle pool.
1127    pub committed_pairs: Vec<(EntityId, EntityId)>,
1128    /// Idle elevator pool `(car, position)` for this group.
1129    pub idle_elevators: Vec<(EntityId, f64)>,
1130}
1131
1132impl DispatchScratch {
1133    /// Clear every buffer without freeing its backing capacity.
1134    ///
1135    /// `cost_matrix_mx` is re-sized/re-filled lazily in
1136    /// `assign_with_scratch`; leaving it alone here preserves its
1137    /// capacity when the group's (rows, cols) match the last
1138    /// dispatch pass.
1139    pub fn clear_all(&mut self) {
1140        self.servicing.clear();
1141        self.pending_stops.clear();
1142        self.idle_rider_destinations.clear();
1143        self.lines_here.clear();
1144        self.pinned_pairs.clear();
1145        self.committed_pairs.clear();
1146        self.idle_elevators.clear();
1147    }
1148}
1149
1150/// Sentinel weight used to pad unavailable `(car, stop)` pairs when
1151/// building the cost matrix for the Hungarian solver. Chosen so that
1152/// `n · SENTINEL` can't overflow `i64`: the Kuhn–Munkres implementation
1153/// sums weights and potentials across each row/column internally, so
1154/// headroom of ~2¹⁵ above the sentinel lets groups scale past 30 000
1155/// cars or stops before any arithmetic risk appears.
1156const ASSIGNMENT_SENTINEL: i64 = 1 << 48;
1157/// Fixed-point scale for converting `f64` costs to the `i64` values the
1158/// Hungarian solver requires. One unit ≈ one micro-tick / millimeter:
1159/// the smallest meaningful rank delta is sub-tick / sub-millimeter, so
1160/// scaling by 1e6 keeps that delta as a 1-unit `i64` difference and
1161/// preserves the strategy's tie-breaking precision through the cast.
1162const ASSIGNMENT_SCALE: f64 = 1_000_000.0;
1163
1164/// Convert a `f64` rank cost into the fixed-point `i64` the Hungarian
1165/// solver consumes. Non-finite, negative, or overflow-prone inputs map
1166/// to the unavailable sentinel.
1167fn scale_cost(cost: f64) -> i64 {
1168    if !cost.is_finite() || cost < 0.0 {
1169        debug_assert!(
1170            cost.is_finite() && cost >= 0.0,
1171            "DispatchStrategy::rank() returned invalid cost {cost}; must be finite and non-negative"
1172        );
1173        return ASSIGNMENT_SENTINEL;
1174    }
1175    // Cap at just below sentinel so any real rank always beats unavailable.
1176    (cost * ASSIGNMENT_SCALE)
1177        .round()
1178        .clamp(0.0, (ASSIGNMENT_SENTINEL - 1) as f64) as i64
1179}
1180
1181/// Build the pending-demand stop list, subtracting stops whose
1182/// demand is already being absorbed by a car — either currently in
1183/// its door cycle at the stop, or en route via `MovingToStop`.
1184///
1185/// Both phases count as "servicing" because they represent a
1186/// commitment to open doors at the target with remaining capacity
1187/// that waiting riders can (typically) fit into. Without the
1188/// `MovingToStop` case, a new idle car becoming available during
1189/// car A's trip to the lobby gets paired with the same lobby call
1190/// on the next dispatch tick — car B travels empty behind car A
1191/// and the playground shows two cars doing a lobby touch-and-go
1192/// for one rider. Composes with the commitment set in
1193/// [`systems::dispatch`](crate::systems::dispatch), which excludes
1194/// committed cars from the idle pool at the same time.
1195///
1196/// `Stopped` (parked-with-doors-closed) is deliberately *not* in
1197/// the list: that's a legitimately reassignable state.
1198/// `Repositioning` is also excluded — a repositioning car doesn't
1199/// open doors on arrival, so it cannot absorb waiting riders.
1200///
1201/// Line-pinned riders (`TransportMode::Line(L)`) keep a stop
1202/// pending even when a car is present, because a car on Shaft A
1203/// can't absorb a rider pinned to Shaft B. Coverage also fails
1204/// when the waiting riders' combined weight exceeds the servicing
1205/// car's remaining capacity — the leftover spills out when doors
1206/// close and deserves its own dispatch immediately.
1207fn pending_stops_minus_covered(
1208    group: &ElevatorGroup,
1209    manifest: &DispatchManifest,
1210    world: &World,
1211    idle_cars: &[(EntityId, f64)],
1212    scratch: &mut DispatchScratch,
1213) {
1214    // Refill `scratch.servicing` in place — the buffer survives across
1215    // ticks so the hot path doesn't reallocate per group.
1216    scratch.servicing.clear();
1217    for &eid in group.elevator_entities() {
1218        let Some(car) = world.elevator(eid) else {
1219            continue;
1220        };
1221        let Some(target) = car.target_stop() else {
1222            continue;
1223        };
1224        if !matches!(
1225            car.phase(),
1226            ElevatorPhase::MovingToStop(_)
1227                | ElevatorPhase::DoorOpening
1228                | ElevatorPhase::Loading
1229                | ElevatorPhase::DoorClosing
1230        ) {
1231            continue;
1232        }
1233        let remaining = car.weight_capacity().value() - car.current_load().value();
1234        scratch.servicing.push((target, car.line(), remaining));
1235    }
1236
1237    // Aboard-rider destinations — reused buffer, same owned semantics.
1238    scratch.idle_rider_destinations.clear();
1239    for &(car_eid, _) in idle_cars {
1240        if let Some(car) = world.elevator(car_eid) {
1241            for &rid in car.riders() {
1242                if let Some(dest) = world.route(rid).and_then(Route::current_destination) {
1243                    scratch.idle_rider_destinations.insert(dest);
1244                }
1245            }
1246        }
1247    }
1248
1249    // A stop is "covered" iff every waiting rider this group sees can
1250    // board at least one of the door-cycling cars here (line check)
1251    // AND the combined remaining capacity of the cars whose line
1252    // accepts the rider is enough to board them all (capacity check).
1253    //
1254    // Iterates `manifest.waiting_riders_at` rather than `world.iter_riders`
1255    // so `TransportMode::Walk` riders and cross-group-routed riders
1256    // (excluded by `build_manifest`) don't inflate the weight total.
1257    // `lines_here` is the same `scratch.lines_here` buffer each call —
1258    // cleared then refilled — so coverage checks don't churn the
1259    // allocator per stop.
1260    let mut lines_here: Vec<EntityId> = std::mem::take(&mut scratch.lines_here);
1261    let servicing = &scratch.servicing;
1262    let is_covered = |stop_eid: EntityId, lines_here: &mut Vec<EntityId>| -> bool {
1263        lines_here.clear();
1264        let mut capacity_here = 0.0;
1265        for &(stop, line, rem) in servicing {
1266            if stop == stop_eid {
1267                lines_here.push(line);
1268                capacity_here += rem;
1269            }
1270        }
1271        if lines_here.is_empty() {
1272            return false;
1273        }
1274        let mut total_weight = 0.0;
1275        for rider in manifest.waiting_riders_at(stop_eid) {
1276            let required_line = world
1277                .route(rider.id)
1278                .and_then(Route::current)
1279                .and_then(|leg| match leg.via {
1280                    TransportMode::Line(l) => Some(l),
1281                    _ => None,
1282                });
1283            if let Some(required) = required_line
1284                && !lines_here.contains(&required)
1285            {
1286                return false;
1287            }
1288            total_weight += rider.weight.value();
1289        }
1290        total_weight <= capacity_here
1291    };
1292
1293    scratch.pending_stops.clear();
1294    for &stop in group.stop_entities() {
1295        if !manifest.has_demand(stop) {
1296            continue;
1297        }
1298        let keep =
1299            scratch.idle_rider_destinations.contains(&stop) || !is_covered(stop, &mut lines_here);
1300        if !keep {
1301            continue;
1302        }
1303        if let Some(pos) = world.stop_position(stop) {
1304            scratch.pending_stops.push((stop, pos));
1305        }
1306    }
1307    // Return the lines_here buffer to scratch so its capacity survives.
1308    scratch.lines_here = lines_here;
1309}
1310
1311/// Run one group's assignment pass: build the cost matrix, solve the
1312/// optimal bipartite matching, then resolve unassigned cars via
1313/// [`DispatchStrategy::fallback`].
1314///
1315/// Visible to the `systems` module; not part of the public API.
1316/// Back-compat wrapper that allocates a throw-away scratch for
1317/// tests and one-off callers. Production paths (in
1318/// `crate::systems::dispatch::run`) must use
1319/// [`assign_with_scratch`] so the scratch capacity amortises
1320/// across ticks.
1321#[cfg(test)]
1322pub(crate) fn assign(
1323    strategy: &mut dyn DispatchStrategy,
1324    idle_cars: &[(EntityId, f64)],
1325    group: &ElevatorGroup,
1326    manifest: &DispatchManifest,
1327    world: &World,
1328) -> AssignmentResult {
1329    let mut scratch = DispatchScratch::default();
1330    assign_with_scratch(strategy, idle_cars, group, manifest, world, &mut scratch)
1331}
1332
1333/// Run one group's assignment pass: build the cost matrix, solve the
1334/// optimal bipartite matching, then resolve unassigned cars via
1335/// [`DispatchStrategy::fallback`]. Uses `scratch` so the per-tick
1336/// allocations (cost matrix, pending stops, etc.) reuse capacity
1337/// across invocations.
1338pub(crate) fn assign_with_scratch(
1339    strategy: &mut dyn DispatchStrategy,
1340    idle_cars: &[(EntityId, f64)],
1341    group: &ElevatorGroup,
1342    manifest: &DispatchManifest,
1343    world: &World,
1344    scratch: &mut DispatchScratch,
1345) -> AssignmentResult {
1346    // Fill `scratch.pending_stops` in place. The buffer's capacity
1347    // survives across ticks.
1348    pending_stops_minus_covered(group, manifest, world, idle_cars, scratch);
1349
1350    let n = idle_cars.len();
1351    let m = scratch.pending_stops.len();
1352
1353    if n == 0 {
1354        return AssignmentResult {
1355            decisions: Vec::new(),
1356        };
1357    }
1358
1359    let mut decisions: Vec<(EntityId, DispatchDecision)> = Vec::with_capacity(n);
1360
1361    if m == 0 {
1362        for &(eid, pos) in idle_cars {
1363            let d = strategy.fallback(eid, pos, group, manifest, world);
1364            decisions.push((eid, d));
1365        }
1366        return AssignmentResult { decisions };
1367    }
1368
1369    // Hungarian requires rows <= cols. Reuse the scratch `Matrix` when
1370    // the shape matches the previous dispatch pass — on a realistic
1371    // building the (rows, cols) tuple changes only when the car or
1372    // stop count does, so steady-state dispatch avoids any heap
1373    // traffic for the cost matrix at all. When the shape does change,
1374    // a fresh Matrix replaces the stored one and becomes the new
1375    // reusable buffer going forward.
1376    let cols = n.max(m);
1377    match &mut scratch.cost_matrix_mx {
1378        Some(mx) if mx.rows == n && mx.columns == cols => {
1379            mx.fill(ASSIGNMENT_SENTINEL);
1380        }
1381        slot => {
1382            *slot = Some(pathfinding::matrix::Matrix::new(
1383                n,
1384                cols,
1385                ASSIGNMENT_SENTINEL,
1386            ));
1387        }
1388    }
1389    let matrix_ref = scratch
1390        .cost_matrix_mx
1391        .as_mut()
1392        .unwrap_or_else(|| unreachable!("cost_matrix_mx populated by match above"));
1393
1394    {
1395        let pending_stops = &scratch.pending_stops;
1396        for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
1397            strategy.prepare_car(car_eid, car_pos, group, manifest, world);
1398            // Borrow the car's restricted-stops set for this row so each
1399            // (car, stop) pair can short-circuit before calling rank().
1400            // Pre-fix only DCS consulted restricted_stops; SCAN/LOOK/NC/ETD
1401            // happily ranked restricted pairs and `commit_go_to_stop` later
1402            // silently dropped the assignment, starving the call. (#256)
1403            let restricted = world
1404                .elevator(car_eid)
1405                .map(crate::components::Elevator::restricted_stops);
1406
1407            // The car's line's `serves` list is the set of stops it can
1408            // physically reach. In a single-line group every stop is
1409            // served (filter is a no-op); in a multi-line group (e.g.
1410            // sky-lobby + service bank, low/high banks sharing a
1411            // transfer floor) a car on line A must not be assigned to
1412            // a stop only line B serves — it would commit, sit there
1413            // unable to reach, and starve the call. The pre-fix matrix
1414            // happily ranked such cross-line pairs because no other
1415            // gate caught them: `restricted_stops` is for explicit
1416            // access denials, `pending_stops_minus_covered` filters
1417            // stops not cars, and built-in strategies score on
1418            // distance/direction without consulting line topology.
1419            let car_serves: Option<&[EntityId]> = world
1420                .elevator(car_eid)
1421                .map(crate::components::Elevator::line)
1422                .and_then(|line_eid| {
1423                    group
1424                        .lines()
1425                        .iter()
1426                        .find(|li| li.entity() == line_eid)
1427                        .map(LineInfo::serves)
1428                });
1429            // `None` here means the car's line isn't in this group's
1430            // line list — a topology inconsistency that should be
1431            // unreachable. We can't fail the dispatch tick over it (the
1432            // sim still has to make progress), so the filter falls
1433            // open: the car is treated as if it could reach any stop.
1434            // The debug-assert catches it during testing without
1435            // affecting release builds.
1436            debug_assert!(
1437                world.elevator(car_eid).is_none() || car_serves.is_some(),
1438                "car {car_eid:?} on line not present in its group's lines list"
1439            );
1440
1441            for (j, &(stop_eid, _stop_pos)) in pending_stops.iter().enumerate() {
1442                if restricted.is_some_and(|r| r.contains(&stop_eid)) {
1443                    continue; // leave SENTINEL — this pair is unavailable
1444                }
1445                if car_serves.is_some_and(|s| !s.contains(&stop_eid)) {
1446                    continue; // car's line doesn't reach this stop
1447                }
1448                let ctx = RankContext {
1449                    car: car_eid,
1450                    stop: stop_eid,
1451                    group,
1452                    manifest,
1453                    world,
1454                };
1455                let scaled = strategy.rank(&ctx).map_or(ASSIGNMENT_SENTINEL, scale_cost);
1456                matrix_ref[(i, j)] = scaled;
1457            }
1458        }
1459    }
1460    let matrix = &*matrix_ref;
1461    let (_, assignments) = pathfinding::kuhn_munkres::kuhn_munkres_min(matrix);
1462
1463    for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
1464        let col = assignments[i];
1465        // A real assignment is: col points to a real stop (col < m) AND
1466        // the cost isn't sentinel-padded (meaning rank() returned Some).
1467        if col < m && matrix[(i, col)] < ASSIGNMENT_SENTINEL {
1468            let (stop_eid, _) = scratch.pending_stops[col];
1469            decisions.push((car_eid, DispatchDecision::GoToStop(stop_eid)));
1470        } else {
1471            let d = strategy.fallback(car_eid, car_pos, group, manifest, world);
1472            decisions.push((car_eid, d));
1473        }
1474    }
1475
1476    AssignmentResult { decisions }
1477}
1478
1479/// Pluggable strategy for repositioning idle elevators.
1480///
1481/// After the dispatch phase, elevators that remain idle (no pending
1482/// assignments) are candidates for repositioning. The strategy decides
1483/// where each idle elevator should move to improve coverage and reduce
1484/// expected response times.
1485///
1486/// Implementations receive the set of idle elevator positions and the
1487/// group's stop positions, then return a target stop for each elevator
1488/// (or `None` to leave it in place).
1489pub trait RepositionStrategy: Send + Sync {
1490    /// Decide where to reposition idle elevators.
1491    ///
1492    /// Push `(elevator_entity, target_stop_entity)` pairs into `out`.
1493    /// The buffer is cleared before each call — implementations should
1494    /// only push, never read prior contents. Elevators not pushed remain idle.
1495    fn reposition(
1496        &mut self,
1497        idle_elevators: &[(EntityId, f64)],
1498        stop_positions: &[(EntityId, f64)],
1499        group: &ElevatorGroup,
1500        world: &World,
1501        out: &mut Vec<(EntityId, EntityId)>,
1502    );
1503
1504    /// If this strategy is a known built-in variant, return it so
1505    /// [`Simulation::set_reposition`](crate::sim::Simulation::set_reposition)
1506    /// callers don't have to pass a separate [`BuiltinReposition`] id
1507    /// that might drift from the dispatcher's actual type.
1508    ///
1509    /// Mirrors the pattern introduced for [`DispatchStrategy::builtin_id`]
1510    /// in #410: the runtime impl identifies itself so the snapshot
1511    /// identity always matches the executing behaviour, instead of
1512    /// depending on the caller to keep two parameters consistent.
1513    /// Default `None` — custom strategies should override to return
1514    /// [`BuiltinReposition::Custom`] with a stable name for snapshot
1515    /// fidelity.
1516    #[must_use]
1517    fn builtin_id(&self) -> Option<BuiltinReposition> {
1518        None
1519    }
1520
1521    /// Minimum [`ArrivalLog`](crate::arrival_log::ArrivalLog) retention
1522    /// (in ticks) the strategy needs to function. Strategies that read
1523    /// the log directly with a custom rolling window must override this
1524    /// so [`Simulation::set_reposition`](crate::sim::Simulation::set_reposition)
1525    /// can widen
1526    /// [`ArrivalLogRetention`](crate::arrival_log::ArrivalLogRetention)
1527    /// to keep the data alive long enough for the query.
1528    ///
1529    /// Default `0` — strategies that don't read the arrival log (or that
1530    /// only consume it through [`DispatchManifest::arrivals_at`], which
1531    /// already tracks retention) impose no requirement.
1532    #[must_use]
1533    fn min_arrival_log_window(&self) -> u64 {
1534        0
1535    }
1536}
1537
1538/// Serializable identifier for built-in repositioning strategies.
1539///
1540/// Used in config and snapshots to restore the correct strategy.
1541#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1542#[non_exhaustive]
1543pub enum BuiltinReposition {
1544    /// Distribute idle elevators evenly across stops.
1545    SpreadEvenly,
1546    /// Return idle elevators to a configured home stop.
1547    ReturnToLobby,
1548    /// Position near stops with historically high demand.
1549    DemandWeighted,
1550    /// Keep idle elevators where they are (no-op).
1551    NearestIdle,
1552    /// Pre-position cars near stops with the highest recent arrival rate.
1553    PredictiveParking,
1554    /// Mode-gated: picks between `ReturnToLobby` / `PredictiveParking`
1555    /// based on the current `TrafficDetector` mode.
1556    Adaptive,
1557    /// Custom strategy identified by name.
1558    Custom(String),
1559}
1560
1561impl std::fmt::Display for BuiltinReposition {
1562    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1563        match self {
1564            Self::SpreadEvenly => write!(f, "SpreadEvenly"),
1565            Self::ReturnToLobby => write!(f, "ReturnToLobby"),
1566            Self::DemandWeighted => write!(f, "DemandWeighted"),
1567            Self::NearestIdle => write!(f, "NearestIdle"),
1568            Self::PredictiveParking => write!(f, "PredictiveParking"),
1569            Self::Adaptive => write!(f, "Adaptive"),
1570            Self::Custom(name) => write!(f, "Custom({name})"),
1571        }
1572    }
1573}
1574
1575impl BuiltinReposition {
1576    /// Instantiate the reposition strategy for this variant.
1577    ///
1578    /// Returns `None` for `Custom` — the game must provide those via
1579    /// a factory function. `ReturnToLobby` uses stop index 0 as default.
1580    #[must_use]
1581    pub fn instantiate(&self) -> Option<Box<dyn RepositionStrategy>> {
1582        match self {
1583            Self::SpreadEvenly => Some(Box::new(reposition::SpreadEvenly)),
1584            Self::ReturnToLobby => Some(Box::new(reposition::ReturnToLobby::new())),
1585            Self::DemandWeighted => Some(Box::new(reposition::DemandWeighted)),
1586            Self::NearestIdle => Some(Box::new(reposition::NearestIdle)),
1587            Self::PredictiveParking => Some(Box::new(reposition::PredictiveParking::new())),
1588            Self::Adaptive => Some(Box::new(reposition::AdaptiveParking::new())),
1589            Self::Custom(_) => None,
1590        }
1591    }
1592}