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