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