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