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}