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`]. The dispatch system then runs an optimal
5//! bipartite assignment (Kuhn–Munkres / Hungarian algorithm) so coordination
6//! — one car per hall call — is a library invariant, not a per-strategy
7//! responsibility. Cars left unassigned are handed to
8//! [`DispatchStrategy::fallback`] for per-car policy (idle, park, etc.).
9//!
10//! # Example: custom dispatch strategy
11//!
12//! ```rust
13//! use elevator_core::prelude::*;
14//! use elevator_core::dispatch::{
15//!     DispatchDecision, DispatchManifest, ElevatorGroup,
16//! };
17//!
18//! struct AlwaysFirstStop;
19//!
20//! impl DispatchStrategy for AlwaysFirstStop {
21//!     fn rank(
22//!         &mut self,
23//!         _car: EntityId,
24//!         car_position: f64,
25//!         stop: EntityId,
26//!         stop_position: f64,
27//!         group: &ElevatorGroup,
28//!         _manifest: &DispatchManifest,
29//!         _world: &elevator_core::world::World,
30//!     ) -> Option<f64> {
31//!         // Prefer the group's first stop; everything else is unavailable.
32//!         if Some(&stop) == group.stop_entities().first() {
33//!             Some((car_position - stop_position).abs())
34//!         } else {
35//!             None
36//!         }
37//!     }
38//! }
39//!
40//! let sim = SimulationBuilder::demo()
41//!     .dispatch(AlwaysFirstStop)
42//!     .build()
43//!     .unwrap();
44//! ```
45
46/// Hall-call destination dispatch algorithm.
47pub mod destination;
48/// Estimated Time to Destination dispatch algorithm.
49pub mod etd;
50/// LOOK dispatch algorithm.
51pub mod look;
52/// Nearest-car dispatch algorithm.
53pub mod nearest_car;
54/// Built-in repositioning strategies.
55pub mod reposition;
56/// SCAN dispatch algorithm.
57pub mod scan;
58/// Shared sweep-direction logic used by SCAN and LOOK.
59pub(crate) mod sweep;
60
61pub use destination::{AssignedCar, DestinationDispatch};
62pub use etd::EtdDispatch;
63pub use look::LookDispatch;
64pub use nearest_car::NearestCarDispatch;
65pub use scan::ScanDispatch;
66
67use serde::{Deserialize, Serialize};
68
69use crate::components::{CallDirection, CarCall, HallCall};
70use crate::entity::EntityId;
71use crate::ids::GroupId;
72use crate::world::World;
73use std::collections::BTreeMap;
74
75/// Metadata about a single rider, available to dispatch strategies.
76#[derive(Debug, Clone)]
77#[non_exhaustive]
78pub struct RiderInfo {
79    /// Rider entity ID.
80    pub id: EntityId,
81    /// Rider's destination stop entity (from route).
82    pub destination: Option<EntityId>,
83    /// Rider weight.
84    pub weight: f64,
85    /// Ticks this rider has been waiting (0 if riding).
86    pub wait_ticks: u64,
87}
88
89/// Full demand picture for dispatch decisions.
90///
91/// Contains per-rider metadata grouped by stop, enabling entity-aware
92/// dispatch strategies (priority, weight-aware, VIP-first, etc.).
93///
94/// Uses `BTreeMap` for deterministic iteration order.
95#[derive(Debug, Clone, Default)]
96pub struct DispatchManifest {
97    /// Riders waiting at each stop, with full per-rider metadata.
98    pub waiting_at_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
99    /// Riders currently aboard elevators, grouped by their destination stop.
100    pub riding_to_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
101    /// Number of residents at each stop (read-only hint for dispatch strategies).
102    pub resident_count_at_stop: BTreeMap<EntityId, usize>,
103    /// Pending hall calls at each stop — at most two entries per stop
104    /// (one per [`CallDirection`]). Populated only for stops served by
105    /// the group being dispatched. Strategies read this to rank based on
106    /// call age, pending-rider count, pin flags, or DCS destinations.
107    pub hall_calls_at_stop: BTreeMap<EntityId, Vec<HallCall>>,
108    /// Floor buttons pressed inside each car in the group. Keyed by car
109    /// entity. Strategies read this to plan intermediate stops without
110    /// poking into `World` directly.
111    pub car_calls_by_car: BTreeMap<EntityId, Vec<CarCall>>,
112}
113
114impl DispatchManifest {
115    /// Number of riders waiting at a stop.
116    #[must_use]
117    pub fn waiting_count_at(&self, stop: EntityId) -> usize {
118        self.waiting_at_stop.get(&stop).map_or(0, Vec::len)
119    }
120
121    /// Total weight of riders waiting at a stop.
122    #[must_use]
123    pub fn total_weight_at(&self, stop: EntityId) -> f64 {
124        self.waiting_at_stop
125            .get(&stop)
126            .map_or(0.0, |riders| riders.iter().map(|r| r.weight).sum())
127    }
128
129    /// Number of riders heading to a stop (aboard elevators).
130    #[must_use]
131    pub fn riding_count_to(&self, stop: EntityId) -> usize {
132        self.riding_to_stop.get(&stop).map_or(0, Vec::len)
133    }
134
135    /// Whether a stop has any demand (waiting riders or riders heading there).
136    #[must_use]
137    pub fn has_demand(&self, stop: EntityId) -> bool {
138        self.waiting_count_at(stop) > 0 || self.riding_count_to(stop) > 0
139    }
140
141    /// Number of residents at a stop (read-only hint, not active demand).
142    #[must_use]
143    pub fn resident_count_at(&self, stop: EntityId) -> usize {
144        self.resident_count_at_stop.get(&stop).copied().unwrap_or(0)
145    }
146
147    /// The hall call at `(stop, direction)`, if pressed.
148    #[must_use]
149    pub fn hall_call_at(&self, stop: EntityId, direction: CallDirection) -> Option<&HallCall> {
150        self.hall_calls_at_stop
151            .get(&stop)?
152            .iter()
153            .find(|c| c.direction == direction)
154    }
155
156    /// All hall calls across every stop in the group (flattened iterator).
157    ///
158    /// No `#[must_use]` needed: `impl Iterator` already carries that
159    /// annotation, and adding our own triggers clippy's
160    /// `double_must_use` lint.
161    pub fn iter_hall_calls(&self) -> impl Iterator<Item = &HallCall> {
162        self.hall_calls_at_stop.values().flatten()
163    }
164
165    /// Floor buttons currently pressed inside `car`. Empty slice if the
166    /// car has no aboard riders or no outstanding presses.
167    #[must_use]
168    pub fn car_calls_for(&self, car: EntityId) -> &[CarCall] {
169        self.car_calls_by_car.get(&car).map_or(&[], Vec::as_slice)
170    }
171}
172
173/// Serializable identifier for built-in dispatch strategies.
174///
175/// Used in snapshots and config files to restore the correct strategy
176/// without requiring the game to manually re-wire dispatch. Custom strategies
177/// are represented by the `Custom(String)` variant.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[non_exhaustive]
180pub enum BuiltinStrategy {
181    /// SCAN (elevator) algorithm — sweeps end-to-end.
182    Scan,
183    /// LOOK algorithm — reverses at last request.
184    Look,
185    /// Nearest-car — assigns closest idle elevator.
186    NearestCar,
187    /// Estimated Time to Destination — minimizes total cost.
188    Etd,
189    /// Hall-call destination dispatch — sticky per-rider assignment.
190    Destination,
191    /// Custom strategy identified by name. The game must provide a factory.
192    Custom(String),
193}
194
195impl std::fmt::Display for BuiltinStrategy {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            Self::Scan => write!(f, "Scan"),
199            Self::Look => write!(f, "Look"),
200            Self::NearestCar => write!(f, "NearestCar"),
201            Self::Etd => write!(f, "Etd"),
202            Self::Destination => write!(f, "Destination"),
203            Self::Custom(name) => write!(f, "Custom({name})"),
204        }
205    }
206}
207
208impl BuiltinStrategy {
209    /// Instantiate the dispatch strategy for this variant.
210    ///
211    /// Returns `None` for `Custom` — the game must provide those via
212    /// a factory function.
213    #[must_use]
214    pub fn instantiate(&self) -> Option<Box<dyn DispatchStrategy>> {
215        match self {
216            Self::Scan => Some(Box::new(scan::ScanDispatch::new())),
217            Self::Look => Some(Box::new(look::LookDispatch::new())),
218            Self::NearestCar => Some(Box::new(nearest_car::NearestCarDispatch::new())),
219            Self::Etd => Some(Box::new(etd::EtdDispatch::new())),
220            Self::Destination => Some(Box::new(destination::DestinationDispatch::new())),
221            Self::Custom(_) => None,
222        }
223    }
224}
225
226/// Decision returned by a dispatch strategy.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[non_exhaustive]
229pub enum DispatchDecision {
230    /// Go to the specified stop entity.
231    GoToStop(EntityId),
232    /// Remain idle.
233    Idle,
234}
235
236/// Per-line relationship data within an [`ElevatorGroup`].
237///
238/// This is a denormalized cache maintained by [`Simulation`](crate::sim::Simulation).
239/// The source of truth for intrinsic line properties is the
240/// [`Line`](crate::components::Line) component in World.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct LineInfo {
243    /// Line entity ID.
244    entity: EntityId,
245    /// Elevator entities on this line.
246    elevators: Vec<EntityId>,
247    /// Stop entities served by this line.
248    serves: Vec<EntityId>,
249}
250
251impl LineInfo {
252    /// Create a new `LineInfo`.
253    #[must_use]
254    pub const fn new(entity: EntityId, elevators: Vec<EntityId>, serves: Vec<EntityId>) -> Self {
255        Self {
256            entity,
257            elevators,
258            serves,
259        }
260    }
261
262    /// Line entity ID.
263    #[must_use]
264    pub const fn entity(&self) -> EntityId {
265        self.entity
266    }
267
268    /// Elevator entities on this line.
269    #[must_use]
270    pub fn elevators(&self) -> &[EntityId] {
271        &self.elevators
272    }
273
274    /// Stop entities served by this line.
275    #[must_use]
276    pub fn serves(&self) -> &[EntityId] {
277        &self.serves
278    }
279
280    /// Set the line entity ID (used during snapshot restore).
281    pub(crate) const fn set_entity(&mut self, entity: EntityId) {
282        self.entity = entity;
283    }
284
285    /// Mutable access to elevator entities on this line.
286    pub(crate) const fn elevators_mut(&mut self) -> &mut Vec<EntityId> {
287        &mut self.elevators
288    }
289
290    /// Mutable access to stop entities served by this line.
291    pub(crate) const fn serves_mut(&mut self) -> &mut Vec<EntityId> {
292        &mut self.serves
293    }
294}
295
296/// How hall calls expose rider destinations to dispatch.
297///
298/// Different building eras and controller designs reveal destinations
299/// at different moments. Groups pick a mode so the sim can model both
300/// traditional up/down collective-control elevators and modern
301/// destination-dispatch lobby kiosks within the same simulation.
302///
303/// Stops are expected to belong to exactly one group. When a stop
304/// overlaps multiple groups, the hall-call press consults the first
305/// group containing it (iteration order over
306/// [`Simulation::groups`](crate::sim::Simulation::groups)), which in
307/// turn determines the `HallCallMode` and ack latency applied to that
308/// call. Overlapping topologies are not validated at construction
309/// time; games that need them should be aware of this first-match
310/// rule.
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
312#[non_exhaustive]
313pub enum HallCallMode {
314    /// Traditional collective-control ("classic" Otis/Westinghouse).
315    ///
316    /// Riders press an up or down button in the hall; the destination
317    /// is revealed only *after* boarding, via a
318    /// [`CarCall`](crate::components::CarCall). Dispatch sees a direction
319    /// per call but does not know individual rider destinations until
320    /// they're aboard.
321    #[default]
322    Classic,
323    /// Modern destination dispatch ("DCS" — Otis `CompassPlus`, KONE
324    /// Polaris, Schindler PORT).
325    ///
326    /// Riders enter their destination at a hall kiosk, so each
327    /// [`HallCall`](crate::components::HallCall) carries a destination
328    /// stop from the moment it's pressed. Required by
329    /// [`DestinationDispatch`].
330    Destination,
331}
332
333/// Runtime elevator group: a set of lines sharing a dispatch strategy.
334///
335/// A group is the logical dispatch unit. It contains one or more
336/// [`LineInfo`] entries, each representing a physical path with its
337/// elevators and served stops.
338///
339/// The flat `elevator_entities` and `stop_entities` fields are derived
340/// caches (union of all lines' elevators/stops), rebuilt automatically
341/// via [`rebuild_caches()`](Self::rebuild_caches).
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct ElevatorGroup {
344    /// Unique group identifier.
345    id: GroupId,
346    /// Human-readable group name.
347    name: String,
348    /// Lines belonging to this group.
349    lines: Vec<LineInfo>,
350    /// How hall calls reveal destinations to dispatch (Classic vs DCS).
351    hall_call_mode: HallCallMode,
352    /// Ticks between a button press and dispatch first seeing the call.
353    /// `0` = immediate (current behavior). Realistic values: 5–30 ticks
354    /// at 60 Hz, modeling controller processing latency.
355    ack_latency_ticks: u32,
356    /// Derived flat cache — rebuilt by `rebuild_caches()`.
357    elevator_entities: Vec<EntityId>,
358    /// Derived flat cache — rebuilt by `rebuild_caches()`.
359    stop_entities: Vec<EntityId>,
360}
361
362impl ElevatorGroup {
363    /// Create a new group with the given lines. Caches are built automatically.
364    /// Defaults: [`HallCallMode::Classic`], `ack_latency_ticks = 0`.
365    #[must_use]
366    pub fn new(id: GroupId, name: String, lines: Vec<LineInfo>) -> Self {
367        let mut group = Self {
368            id,
369            name,
370            lines,
371            hall_call_mode: HallCallMode::default(),
372            ack_latency_ticks: 0,
373            elevator_entities: Vec::new(),
374            stop_entities: Vec::new(),
375        };
376        group.rebuild_caches();
377        group
378    }
379
380    /// Override the hall call mode for this group.
381    #[must_use]
382    pub const fn with_hall_call_mode(mut self, mode: HallCallMode) -> Self {
383        self.hall_call_mode = mode;
384        self
385    }
386
387    /// Override the ack latency for this group.
388    #[must_use]
389    pub const fn with_ack_latency_ticks(mut self, ticks: u32) -> Self {
390        self.ack_latency_ticks = ticks;
391        self
392    }
393
394    /// Set the hall call mode in-place (for mutation via
395    /// [`Simulation::groups_mut`](crate::sim::Simulation::groups_mut)).
396    pub const fn set_hall_call_mode(&mut self, mode: HallCallMode) {
397        self.hall_call_mode = mode;
398    }
399
400    /// Set the ack latency in-place.
401    pub const fn set_ack_latency_ticks(&mut self, ticks: u32) {
402        self.ack_latency_ticks = ticks;
403    }
404
405    /// Hall call mode for this group.
406    #[must_use]
407    pub const fn hall_call_mode(&self) -> HallCallMode {
408        self.hall_call_mode
409    }
410
411    /// Controller ack latency for this group.
412    #[must_use]
413    pub const fn ack_latency_ticks(&self) -> u32 {
414        self.ack_latency_ticks
415    }
416
417    /// Unique group identifier.
418    #[must_use]
419    pub const fn id(&self) -> GroupId {
420        self.id
421    }
422
423    /// Human-readable group name.
424    #[must_use]
425    pub fn name(&self) -> &str {
426        &self.name
427    }
428
429    /// Lines belonging to this group.
430    #[must_use]
431    pub fn lines(&self) -> &[LineInfo] {
432        &self.lines
433    }
434
435    /// Mutable access to lines (call [`rebuild_caches()`](Self::rebuild_caches) after mutating).
436    pub const fn lines_mut(&mut self) -> &mut Vec<LineInfo> {
437        &mut self.lines
438    }
439
440    /// Elevator entities belonging to this group (derived from lines).
441    #[must_use]
442    pub fn elevator_entities(&self) -> &[EntityId] {
443        &self.elevator_entities
444    }
445
446    /// Stop entities served by this group (derived from lines, deduplicated).
447    #[must_use]
448    pub fn stop_entities(&self) -> &[EntityId] {
449        &self.stop_entities
450    }
451
452    /// Push a stop entity directly into the group's stop cache.
453    ///
454    /// Use when a stop belongs to the group for dispatch purposes but is
455    /// not (yet) assigned to any line. Call `add_stop_to_line` later to
456    /// wire it into the topology graph.
457    pub(crate) fn push_stop(&mut self, stop: EntityId) {
458        if !self.stop_entities.contains(&stop) {
459            self.stop_entities.push(stop);
460        }
461    }
462
463    /// Push an elevator entity directly into the group's elevator cache
464    /// (in addition to the line it belongs to).
465    pub(crate) fn push_elevator(&mut self, elevator: EntityId) {
466        if !self.elevator_entities.contains(&elevator) {
467            self.elevator_entities.push(elevator);
468        }
469    }
470
471    /// Rebuild derived caches from lines. Call after mutating lines.
472    pub fn rebuild_caches(&mut self) {
473        self.elevator_entities = self
474            .lines
475            .iter()
476            .flat_map(|li| li.elevators.iter().copied())
477            .collect();
478        let mut stops: Vec<EntityId> = self
479            .lines
480            .iter()
481            .flat_map(|li| li.serves.iter().copied())
482            .collect();
483        stops.sort_unstable();
484        stops.dedup();
485        self.stop_entities = stops;
486    }
487}
488
489/// Pluggable dispatch algorithm.
490///
491/// Strategies implement [`rank`](Self::rank) to score each `(car, stop)`
492/// pair; the dispatch system then performs an optimal assignment across
493/// the whole group, guaranteeing that no two cars are sent to the same
494/// hall call.
495///
496/// Returning `None` from `rank` excludes a pair from assignment — useful
497/// for capacity limits, direction preferences, restricted stops, or
498/// sticky commitments.
499///
500/// Cars that receive no stop fall through to [`fallback`](Self::fallback),
501/// which returns the policy for that car (idle, park, etc.).
502pub trait DispatchStrategy: Send + Sync {
503    /// Optional hook called once per group before the assignment pass.
504    ///
505    /// Strategies that need to mutate [`World`] extension storage (e.g.
506    /// [`DestinationDispatch`] writing sticky rider → car assignments)
507    /// or pre-populate [`crate::components::DestinationQueue`] entries
508    /// override this. Default: no-op.
509    fn pre_dispatch(
510        &mut self,
511        _group: &ElevatorGroup,
512        _manifest: &DispatchManifest,
513        _world: &mut World,
514    ) {
515    }
516
517    /// Optional hook called once per candidate car, before any
518    /// [`rank`](Self::rank) calls for that car in the current pass.
519    ///
520    /// Strategies whose ranking depends on stable per-car state (e.g. the
521    /// sweep direction used by SCAN/LOOK) set that state here so later
522    /// `rank` calls see a consistent view regardless of iteration order.
523    /// The default is a no-op.
524    fn prepare_car(
525        &mut self,
526        _car: EntityId,
527        _car_position: f64,
528        _group: &ElevatorGroup,
529        _manifest: &DispatchManifest,
530        _world: &World,
531    ) {
532    }
533
534    /// Score the cost of sending `car` to `stop`. Lower is better.
535    ///
536    /// Returning `None` marks this `(car, stop)` pair as unavailable;
537    /// the assignment algorithm will never pair them. Use this for
538    /// capacity limits, wrong-direction stops, stops outside the line's
539    /// topology, or pairs already committed via a sticky assignment.
540    ///
541    /// Must return a finite, non-negative value if `Some` — infinities
542    /// and NaN can destabilize the underlying Hungarian solver.
543    ///
544    /// Implementations must not mutate per-car state inside `rank`: the
545    /// dispatch system calls `rank(car, stop_0..stop_m)` in a loop, so
546    /// mutating `self` on one call affects subsequent calls for the same
547    /// car within the same pass and produces an asymmetric cost matrix
548    /// whose results depend on iteration order. Use
549    /// [`prepare_car`](Self::prepare_car) to compute and store any
550    /// per-car state before `rank` is called.
551    #[allow(clippy::too_many_arguments)]
552    fn rank(
553        &mut self,
554        car: EntityId,
555        car_position: f64,
556        stop: EntityId,
557        stop_position: f64,
558        group: &ElevatorGroup,
559        manifest: &DispatchManifest,
560        world: &World,
561    ) -> Option<f64>;
562
563    /// Decide what an idle car should do when no stop was assigned to it.
564    ///
565    /// Called for each car the assignment phase could not pair with a
566    /// stop (because there were no stops, or all candidate stops had
567    /// rank `None` for this car). Default: [`DispatchDecision::Idle`].
568    fn fallback(
569        &mut self,
570        _car: EntityId,
571        _car_position: f64,
572        _group: &ElevatorGroup,
573        _manifest: &DispatchManifest,
574        _world: &World,
575    ) -> DispatchDecision {
576        DispatchDecision::Idle
577    }
578
579    /// Notify the strategy that an elevator has been removed.
580    ///
581    /// Implementations with per-elevator state (e.g. direction tracking)
582    /// should clean up here to prevent unbounded memory growth.
583    fn notify_removed(&mut self, _elevator: EntityId) {}
584}
585
586/// Resolution of a single dispatch assignment pass for one group.
587///
588/// Produced by [`assign`] and consumed by
589/// [`crate::systems::dispatch::run`] to apply decisions to the world.
590#[derive(Debug, Clone)]
591pub struct AssignmentResult {
592    /// `(car, decision)` pairs for every idle car in the group.
593    pub decisions: Vec<(EntityId, DispatchDecision)>,
594}
595
596/// Sentinel weight used to pad unavailable `(car, stop)` pairs when
597/// building the cost matrix for the Hungarian solver. Chosen so that
598/// `n · SENTINEL` can't overflow `i64`: the Kuhn–Munkres implementation
599/// sums weights and potentials across each row/column internally, so
600/// headroom of ~2¹⁵ above the sentinel lets groups scale past 30 000
601/// cars or stops before any arithmetic risk appears.
602const ASSIGNMENT_SENTINEL: i64 = 1 << 48;
603/// Fixed-point scale for converting `f64` costs to the `i64` values the
604/// Hungarian solver requires. One unit ≈ one micro-tick / millimeter.
605const ASSIGNMENT_SCALE: f64 = 1_000_000.0;
606
607/// Convert a `f64` rank cost into the fixed-point `i64` the Hungarian
608/// solver consumes. Non-finite, negative, or overflow-prone inputs map
609/// to the unavailable sentinel.
610fn scale_cost(cost: f64) -> i64 {
611    if !cost.is_finite() || cost < 0.0 {
612        return ASSIGNMENT_SENTINEL;
613    }
614    // Cap at just below sentinel so any real rank always beats unavailable.
615    (cost * ASSIGNMENT_SCALE)
616        .round()
617        .clamp(0.0, (ASSIGNMENT_SENTINEL - 1) as f64) as i64
618}
619
620/// Run one group's assignment pass: build the cost matrix, solve the
621/// optimal bipartite matching, then resolve unassigned cars via
622/// [`DispatchStrategy::fallback`].
623///
624/// Visible to the `systems` module; not part of the public API.
625pub(crate) fn assign(
626    strategy: &mut dyn DispatchStrategy,
627    idle_cars: &[(EntityId, f64)],
628    group: &ElevatorGroup,
629    manifest: &DispatchManifest,
630    world: &World,
631) -> AssignmentResult {
632    // Collect stops with active demand and known positions.
633    let pending_stops: Vec<(EntityId, f64)> = group
634        .stop_entities()
635        .iter()
636        .filter(|s| manifest.has_demand(**s))
637        .filter_map(|s| world.stop_position(*s).map(|p| (*s, p)))
638        .collect();
639
640    let n = idle_cars.len();
641    let m = pending_stops.len();
642
643    if n == 0 {
644        return AssignmentResult {
645            decisions: Vec::new(),
646        };
647    }
648
649    let mut decisions: Vec<(EntityId, DispatchDecision)> = Vec::with_capacity(n);
650
651    if m == 0 {
652        for &(eid, pos) in idle_cars {
653            let d = strategy.fallback(eid, pos, group, manifest, world);
654            decisions.push((eid, d));
655        }
656        return AssignmentResult { decisions };
657    }
658
659    // Build cost matrix. Hungarian requires rows <= cols.
660    let cols = n.max(m);
661    let mut data: Vec<i64> = vec![ASSIGNMENT_SENTINEL; n * cols];
662    for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
663        strategy.prepare_car(car_eid, car_pos, group, manifest, world);
664        for (j, &(stop_eid, stop_pos)) in pending_stops.iter().enumerate() {
665            let scaled = strategy
666                .rank(car_eid, car_pos, stop_eid, stop_pos, group, manifest, world)
667                .map_or(ASSIGNMENT_SENTINEL, scale_cost);
668            data[i * cols + j] = scaled;
669        }
670    }
671    // `from_vec` only fails if `n * cols != data.len()` — both derived
672    // from `n` and `cols` above, so the construction is infallible. Fall
673    // back to an empty-result shape in the unlikely event the invariant
674    // is violated in future refactors.
675    let Ok(matrix) = pathfinding::matrix::Matrix::from_vec(n, cols, data) else {
676        for &(car_eid, car_pos) in idle_cars {
677            let d = strategy.fallback(car_eid, car_pos, group, manifest, world);
678            decisions.push((car_eid, d));
679        }
680        return AssignmentResult { decisions };
681    };
682    let (_, assignments) = pathfinding::kuhn_munkres::kuhn_munkres_min(&matrix);
683
684    for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
685        let col = assignments[i];
686        // A real assignment is: col points to a real stop (col < m) AND
687        // the cost isn't sentinel-padded (meaning rank() returned Some).
688        if col < m && matrix[(i, col)] < ASSIGNMENT_SENTINEL {
689            let (stop_eid, _) = pending_stops[col];
690            decisions.push((car_eid, DispatchDecision::GoToStop(stop_eid)));
691        } else {
692            let d = strategy.fallback(car_eid, car_pos, group, manifest, world);
693            decisions.push((car_eid, d));
694        }
695    }
696
697    AssignmentResult { decisions }
698}
699
700/// Pluggable strategy for repositioning idle elevators.
701///
702/// After the dispatch phase, elevators that remain idle (no pending
703/// assignments) are candidates for repositioning. The strategy decides
704/// where each idle elevator should move to improve coverage and reduce
705/// expected response times.
706///
707/// Implementations receive the set of idle elevator positions and the
708/// group's stop positions, then return a target stop for each elevator
709/// (or `None` to leave it in place).
710pub trait RepositionStrategy: Send + Sync {
711    /// Decide where to reposition idle elevators.
712    ///
713    /// Returns a vec of `(elevator_entity, target_stop_entity)` pairs.
714    /// Elevators not in the returned vec remain idle.
715    fn reposition(
716        &mut self,
717        idle_elevators: &[(EntityId, f64)],
718        stop_positions: &[(EntityId, f64)],
719        group: &ElevatorGroup,
720        world: &World,
721    ) -> Vec<(EntityId, EntityId)>;
722}
723
724/// Serializable identifier for built-in repositioning strategies.
725///
726/// Used in config and snapshots to restore the correct strategy.
727#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
728#[non_exhaustive]
729pub enum BuiltinReposition {
730    /// Distribute idle elevators evenly across stops.
731    SpreadEvenly,
732    /// Return idle elevators to a configured home stop.
733    ReturnToLobby,
734    /// Position near stops with historically high demand.
735    DemandWeighted,
736    /// Keep idle elevators where they are (no-op).
737    NearestIdle,
738    /// Custom strategy identified by name.
739    Custom(String),
740}
741
742impl std::fmt::Display for BuiltinReposition {
743    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
744        match self {
745            Self::SpreadEvenly => write!(f, "SpreadEvenly"),
746            Self::ReturnToLobby => write!(f, "ReturnToLobby"),
747            Self::DemandWeighted => write!(f, "DemandWeighted"),
748            Self::NearestIdle => write!(f, "NearestIdle"),
749            Self::Custom(name) => write!(f, "Custom({name})"),
750        }
751    }
752}
753
754impl BuiltinReposition {
755    /// Instantiate the reposition strategy for this variant.
756    ///
757    /// Returns `None` for `Custom` — the game must provide those via
758    /// a factory function. `ReturnToLobby` uses stop index 0 as default.
759    #[must_use]
760    pub fn instantiate(&self) -> Option<Box<dyn RepositionStrategy>> {
761        match self {
762            Self::SpreadEvenly => Some(Box::new(reposition::SpreadEvenly)),
763            Self::ReturnToLobby => Some(Box::new(reposition::ReturnToLobby::new())),
764            Self::DemandWeighted => Some(Box::new(reposition::DemandWeighted)),
765            Self::NearestIdle => Some(Box::new(reposition::NearestIdle)),
766            Self::Custom(_) => None,
767        }
768    }
769}