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