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