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