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