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