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::dispatch::RankContext;
15//! use elevator_core::prelude::*;
16//!
17//! struct AlwaysFirstStop;
18//!
19//! impl DispatchStrategy for AlwaysFirstStop {
20//! fn rank(&self, ctx: &RankContext<'_>) -> Option<f64> {
21//! // Prefer the group's first stop; everything else is unavailable.
22//! if Some(&ctx.stop) == ctx.group.stop_entities().first() {
23//! Some((ctx.car_position() - ctx.stop_position()).abs())
24//! } else {
25//! None
26//! }
27//! }
28//! }
29//!
30//! let sim = SimulationBuilder::demo()
31//! .dispatch(AlwaysFirstStop)
32//! .build()
33//! .unwrap();
34//! ```
35
36/// Hall-call destination dispatch algorithm.
37pub mod destination;
38/// Estimated Time to Destination dispatch algorithm.
39pub mod etd;
40/// LOOK dispatch algorithm.
41pub mod look;
42/// Nearest-car dispatch algorithm.
43pub mod nearest_car;
44/// Built-in repositioning strategies.
45pub mod reposition;
46/// Relative System Response (RSR) dispatch algorithm.
47pub mod rsr;
48/// SCAN dispatch algorithm.
49pub mod scan;
50/// Per-elevator scratch helper for custom strategies.
51pub mod scratch;
52/// Shared sweep-direction logic used by SCAN and LOOK.
53pub(crate) mod sweep;
54
55pub use destination::{AssignedCar, DestinationDispatch};
56pub use etd::EtdDispatch;
57pub use look::LookDispatch;
58pub use nearest_car::NearestCarDispatch;
59pub use rsr::RsrDispatch;
60pub use scan::ScanDispatch;
61pub use scratch::PrepareScratch;
62
63use serde::{Deserialize, Serialize};
64
65use crate::components::{
66 CallDirection, CarCall, ElevatorPhase, HallCall, Route, TransportMode, Weight,
67};
68use crate::entity::EntityId;
69use crate::ids::GroupId;
70use crate::world::World;
71use std::collections::{BTreeMap, HashSet};
72
73/// Whether assigning `ctx.car` to `ctx.stop` is worth ranking.
74///
75/// Combines two checks every dispatch strategy needs at the top of its
76/// `rank` implementation:
77///
78/// 1. **Servability** — capacity, full-load bypass, and the loading-phase
79/// boarding filter. A pair that can't exit an aboard rider, board a
80/// waiter, or answer a rider-less hall call is a no-op move (and a
81/// zero-cost one when the car is already parked there) which would
82/// otherwise stall doors against unservable demand.
83/// 2. **Path discipline** (only when `respect_aboard_path` is `true`) —
84/// refuses pickups that would pull a car carrying routed riders off
85/// the direct path to every aboard rider's destination. Without it, a
86/// stream of closer-destination hall calls can indefinitely preempt a
87/// farther aboard rider's delivery (the "never reaches the
88/// passenger's desired stop" loop).
89///
90/// Strategies with their own direction discipline (SCAN, LOOK, ETD,
91/// Destination) pass `respect_aboard_path: false` because their
92/// sweep/direction terms already rule out backtracks. Strategies without
93/// it (`NearestCar`, RSR) pass `respect_aboard_path: true`. Custom
94/// strategies should pass `true` unless they enforce direction
95/// discipline themselves.
96///
97/// Aboard riders without a published route (game-managed manual riders)
98/// don't constrain the path — any pickup is trivially on-the-way for
99/// them, so the path check trivially passes when no aboard rider has a
100/// `Route::current_destination`.
101#[must_use]
102pub fn pair_is_useful(ctx: &RankContext<'_>, respect_aboard_path: bool) -> bool {
103 let Some(car) = ctx.world.elevator(ctx.car) else {
104 return false;
105 };
106 let can_exit_here = car
107 .riders()
108 .iter()
109 .any(|&rid| ctx.world.route(rid).and_then(Route::current_destination) == Some(ctx.stop));
110 if can_exit_here {
111 return true;
112 }
113
114 // Direction-dependent full-load bypass (Otis Elevonic 411 model,
115 // patent US5490580A). A car loaded above its configured threshold
116 // in the current travel direction ignores hall calls in that same
117 // direction. Aboard riders still get delivered — the `can_exit_here`
118 // short-circuit above guarantees their destinations remain rank-able.
119 if bypass_in_current_direction(car, ctx) {
120 return false;
121 }
122
123 let remaining_capacity = car.weight_capacity.value() - car.current_load.value();
124 if remaining_capacity <= 0.0 {
125 return false;
126 }
127 let waiting = ctx.manifest.waiting_riders_at(ctx.stop);
128 let servable = if waiting.is_empty() {
129 // No waiters at the stop, and no aboard rider of ours exits here
130 // (the `can_exit_here` short-circuit ruled that out above).
131 // Demand must therefore come from either another car's
132 // `riding_to_stop` (not work this car can perform) or a
133 // rider-less hall call (someone pressed a button with no rider
134 // attached yet — a press from `press_hall_button` or one whose
135 // riders have since been fulfilled or abandoned). Only the
136 // latter is actionable; without this filter an idle car parked
137 // at the stop collapses to cost 0, the Hungarian picks the
138 // self-pair every tick, and doors cycle open/close indefinitely
139 // while the other car finishes its trip.
140 ctx.manifest
141 .hall_calls_at_stop
142 .get(&ctx.stop)
143 .is_some_and(|calls| calls.iter().any(|c| c.pending_riders.is_empty()))
144 } else {
145 waiting
146 .iter()
147 .any(|r| rider_can_board(r, car, ctx, remaining_capacity))
148 };
149 if !servable {
150 return false;
151 }
152 if !respect_aboard_path || car.riders().is_empty() {
153 return true;
154 }
155
156 // Route-less aboard riders (game-managed manual riders) don't
157 // publish a destination, so there's no committed path to protect.
158 // Any pickup is trivially on-the-way — let it through. Otherwise
159 // we'd refuse every pickup the moment the car carried its first
160 // manually-managed passenger.
161 let has_routed_rider = car.riders().iter().any(|&rid| {
162 ctx.world
163 .route(rid)
164 .and_then(Route::current_destination)
165 .is_some()
166 });
167 if !has_routed_rider {
168 return true;
169 }
170
171 // Pickups allowed only on the path to an aboard rider's destination.
172 // Candidate at the car's position (to_cand = 0) trivially qualifies —
173 // useful for same-floor boards.
174 let to_cand = ctx.stop_position() - ctx.car_position();
175 car.riders().iter().any(|&rid| {
176 let Some(dest) = ctx.world.route(rid).and_then(Route::current_destination) else {
177 return false;
178 };
179 let Some(dest_pos) = ctx.world.stop_position(dest) else {
180 return false;
181 };
182 let to_dest = dest_pos - ctx.car_position();
183 to_dest * to_cand >= 0.0 && to_cand.abs() <= to_dest.abs()
184 })
185}
186
187/// Sum of `wait_ticks` across `riders`, as `f64`.
188///
189/// Helper used by ETD and RSR fairness terms — both compute the same
190/// `riders.iter().map(|r| r.wait_ticks as f64).sum()` and feed the
191/// result into a fused-multiply-add against a configured weight.
192#[must_use]
193pub(crate) fn wait_ticks_sum(riders: &[RiderInfo]) -> f64 {
194 riders.iter().map(|r| r.wait_ticks as f64).sum()
195}
196
197/// Sum of squared `wait_ticks` across `riders`, as `f64`.
198///
199/// Used by ETD's quadratic-fairness term to escalate cost as old
200/// waiters age. RSR has no quadratic fairness; the linear form lives
201/// in [`wait_ticks_sum`].
202#[must_use]
203pub(crate) fn wait_ticks_squared_sum(riders: &[RiderInfo]) -> f64 {
204 riders
205 .iter()
206 .map(|r| {
207 let w = r.wait_ticks as f64;
208 w * w
209 })
210 .sum()
211}
212
213/// Whether a waiting rider could actually board this car, matching the
214/// same filters the loading phase applies. Prevents `pair_is_useful`
215/// from approving a pickup whose only demand is direction-filtered or
216/// over-capacity — the loading phase would reject the rider, doors
217/// would cycle, and dispatch would re-pick the zero-cost self-pair.
218fn rider_can_board(
219 rider: &RiderInfo,
220 car: &crate::components::Elevator,
221 ctx: &RankContext<'_>,
222 remaining_capacity: f64,
223) -> bool {
224 if rider.weight.value() > remaining_capacity {
225 return false;
226 }
227 // Match `systems::loading`'s direction filter: a rider whose trip
228 // goes the opposite way of the car's committed direction will not
229 // be boarded. An unknown destination (no route yet) is treated as
230 // unconstrained — let the rider through and let the loading phase
231 // make the final call.
232 let Some(dest) = rider.destination else {
233 return true;
234 };
235 let Some(dest_pos) = ctx.world.stop_position(dest) else {
236 return true;
237 };
238 if dest_pos > ctx.stop_position() && !car.going_up() {
239 return false;
240 }
241 if dest_pos < ctx.stop_position() && !car.going_down() {
242 return false;
243 }
244 true
245}
246
247/// True when a full-load bypass applies: the car has a configured
248/// threshold for its current travel direction, is above that threshold,
249/// and the candidate stop lies in that same direction.
250fn bypass_in_current_direction(car: &crate::components::Elevator, ctx: &RankContext<'_>) -> bool {
251 // Derive travel direction from the car's current target, if any.
252 // An Idle or Stopped car has no committed direction → no bypass.
253 let Some(target) = car.phase().moving_target() else {
254 return false;
255 };
256 let Some(target_pos) = ctx.world.stop_position(target) else {
257 return false;
258 };
259 let going_up = target_pos > ctx.car_position();
260 let going_down = target_pos < ctx.car_position();
261 if !going_up && !going_down {
262 return false;
263 }
264 let threshold = if going_up {
265 car.bypass_load_up_pct()
266 } else {
267 car.bypass_load_down_pct()
268 };
269 let Some(pct) = threshold else {
270 return false;
271 };
272 let capacity = car.weight_capacity().value();
273 if capacity <= 0.0 {
274 return false;
275 }
276 let load_ratio = car.current_load().value() / capacity;
277 if load_ratio < pct {
278 return false;
279 }
280 // Only same-direction pickups get bypassed.
281 let stop_above = ctx.stop_position() > ctx.car_position();
282 let stop_below = ctx.stop_position() < ctx.car_position();
283 (going_up && stop_above) || (going_down && stop_below)
284}
285
286/// Metadata about a single rider, available to dispatch strategies.
287#[derive(Debug, Clone)]
288#[non_exhaustive]
289pub struct RiderInfo {
290 /// Rider entity ID.
291 pub id: EntityId,
292 /// Rider's destination stop entity (from route).
293 pub destination: Option<EntityId>,
294 /// Rider weight.
295 pub weight: Weight,
296 /// Ticks this rider has been waiting (0 if riding).
297 pub wait_ticks: u64,
298}
299
300/// Full demand picture for dispatch decisions.
301///
302/// Contains per-rider metadata grouped by stop, enabling entity-aware
303/// dispatch strategies (priority, weight-aware, VIP-first, etc.).
304///
305/// Uses `BTreeMap` for deterministic iteration order.
306#[derive(Debug, Clone, Default)]
307pub struct DispatchManifest {
308 /// Riders waiting at each stop, with full per-rider metadata.
309 pub(crate) waiting_at_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
310 /// Riders currently aboard elevators, grouped by their destination stop.
311 pub(crate) riding_to_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
312 /// Number of residents at each stop (read-only hint for dispatch strategies).
313 pub(crate) resident_count_at_stop: BTreeMap<EntityId, usize>,
314 /// Pending hall calls at each stop — at most two entries per stop
315 /// (one per [`CallDirection`]). Populated only for stops served by
316 /// the group being dispatched. Strategies read this to rank based on
317 /// call age, pending-rider count, pin flags, or DCS destinations.
318 pub(crate) hall_calls_at_stop: BTreeMap<EntityId, Vec<HallCall>>,
319 /// Floor buttons pressed inside each car in the group. Keyed by car
320 /// entity. Strategies read this to plan intermediate stops without
321 /// poking into `World` directly.
322 pub(crate) car_calls_by_car: BTreeMap<EntityId, Vec<CarCall>>,
323 /// Recent arrivals per stop, counted over
324 /// [`DispatchManifest::arrival_window_ticks`] ticks. Populated from
325 /// the [`crate::arrival_log::ArrivalLog`] world resource each pass
326 /// so strategies can read a traffic-rate signal without touching
327 /// world state directly.
328 pub(crate) arrivals_at_stop: BTreeMap<EntityId, u64>,
329 /// Window the `arrivals_at_stop` counts cover, in ticks. Exposed so
330 /// strategies interpreting the raw counts can convert them to a
331 /// rate (per tick or per second).
332 pub(crate) arrival_window_ticks: u64,
333}
334
335impl DispatchManifest {
336 /// Number of riders waiting at a stop.
337 #[must_use]
338 pub fn waiting_count_at(&self, stop: EntityId) -> usize {
339 self.waiting_at_stop.get(&stop).map_or(0, Vec::len)
340 }
341
342 /// Total weight of riders waiting at a stop.
343 #[must_use]
344 pub fn total_weight_at(&self, stop: EntityId) -> f64 {
345 self.waiting_at_stop
346 .get(&stop)
347 .map_or(0.0, |riders| riders.iter().map(|r| r.weight.value()).sum())
348 }
349
350 /// Number of riders heading to a stop (aboard elevators).
351 #[must_use]
352 pub fn riding_count_to(&self, stop: EntityId) -> usize {
353 self.riding_to_stop.get(&stop).map_or(0, Vec::len)
354 }
355
356 /// Whether a stop has any demand for this group: waiting riders,
357 /// riders heading there, or a *rider-less* hall call (one that
358 /// `press_hall_button` placed without a backing rider). Pre-fix
359 /// the rider-less case was invisible to every built-in dispatcher,
360 /// so explicit button presses with no associated rider went
361 /// unanswered indefinitely (#255).
362 ///
363 /// Hall calls *with* `pending_riders` are not double-counted —
364 /// those riders already appear in `waiting_count_at` for the
365 /// groups whose dispatch surface they belong to. Adding the call
366 /// to `has_demand` for *every* group that serves the stop would
367 /// pull cars from groups the rider doesn't even want, causing
368 /// open/close oscillation regression that the multi-group test
369 /// `dispatch_ignores_waiting_rider_targeting_another_group` pins.
370 #[must_use]
371 pub fn has_demand(&self, stop: EntityId) -> bool {
372 self.waiting_count_at(stop) > 0
373 || self.riding_count_to(stop) > 0
374 || self
375 .hall_calls_at_stop
376 .get(&stop)
377 .is_some_and(|calls| calls.iter().any(|c| c.pending_riders.is_empty()))
378 }
379
380 /// Number of residents at a stop (read-only hint, not active demand).
381 #[must_use]
382 pub fn resident_count_at(&self, stop: EntityId) -> usize {
383 self.resident_count_at_stop.get(&stop).copied().unwrap_or(0)
384 }
385
386 /// Rider arrivals at `stop` within the last
387 /// [`arrival_window_ticks`](Self::arrival_window_ticks) ticks. The
388 /// signal is the rolling-window per-stop arrival rate that
389 /// commercial controllers use to pick a traffic mode and that
390 /// [`crate::dispatch::reposition::PredictiveParking`] uses to
391 /// forecast demand. Unvisited stops return 0.
392 #[must_use]
393 pub fn arrivals_at(&self, stop: EntityId) -> u64 {
394 self.arrivals_at_stop.get(&stop).copied().unwrap_or(0)
395 }
396
397 /// Window size (in ticks) over which [`arrivals_at`](Self::arrivals_at)
398 /// counts events. Strategies convert counts to rates by dividing
399 /// by this.
400 #[must_use]
401 pub const fn arrival_window_ticks(&self) -> u64 {
402 self.arrival_window_ticks
403 }
404
405 /// The hall call at `(stop, direction)`, if pressed.
406 #[must_use]
407 pub fn hall_call_at(&self, stop: EntityId, direction: CallDirection) -> Option<&HallCall> {
408 self.hall_calls_at_stop
409 .get(&stop)?
410 .iter()
411 .find(|c| c.direction == direction)
412 }
413
414 /// All hall calls across every stop in the group (flattened iterator).
415 ///
416 /// No `#[must_use]` needed: `impl Iterator` already carries that
417 /// annotation, and adding our own triggers clippy's
418 /// `double_must_use` lint.
419 pub fn iter_hall_calls(&self) -> impl Iterator<Item = &HallCall> {
420 self.hall_calls_at_stop.values().flatten()
421 }
422
423 /// Floor buttons currently pressed inside `car`. Empty slice if the
424 /// car has no aboard riders or no outstanding presses.
425 #[must_use]
426 pub fn car_calls_for(&self, car: EntityId) -> &[CarCall] {
427 self.car_calls_by_car.get(&car).map_or(&[], Vec::as_slice)
428 }
429
430 /// Riders waiting at a specific stop.
431 #[must_use]
432 pub fn waiting_riders_at(&self, stop: EntityId) -> &[RiderInfo] {
433 self.waiting_at_stop.get(&stop).map_or(&[], Vec::as_slice)
434 }
435
436 /// Iterate over all `(stop, riders)` pairs with waiting demand.
437 pub fn iter_waiting_stops(&self) -> impl Iterator<Item = (&EntityId, &[RiderInfo])> {
438 self.waiting_at_stop
439 .iter()
440 .map(|(stop, riders)| (stop, riders.as_slice()))
441 }
442
443 /// Riders currently riding toward a specific stop.
444 #[must_use]
445 pub fn riding_riders_to(&self, stop: EntityId) -> &[RiderInfo] {
446 self.riding_to_stop.get(&stop).map_or(&[], Vec::as_slice)
447 }
448
449 /// Iterate over all `(stop, riders)` pairs with in-transit demand.
450 pub fn iter_riding_stops(&self) -> impl Iterator<Item = (&EntityId, &[RiderInfo])> {
451 self.riding_to_stop
452 .iter()
453 .map(|(stop, riders)| (stop, riders.as_slice()))
454 }
455
456 /// Iterate over all `(stop, hall_calls)` pairs with active calls.
457 pub fn iter_hall_call_stops(&self) -> impl Iterator<Item = (&EntityId, &[HallCall])> {
458 self.hall_calls_at_stop
459 .iter()
460 .map(|(stop, calls)| (stop, calls.as_slice()))
461 }
462}
463
464/// Serializable identifier for built-in dispatch strategies.
465///
466/// Used in snapshots and config files to restore the correct strategy
467/// without requiring the game to manually re-wire dispatch. Custom strategies
468/// are represented by the `Custom(String)` variant.
469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470#[non_exhaustive]
471pub enum BuiltinStrategy {
472 /// SCAN (elevator) algorithm — sweeps end-to-end.
473 Scan,
474 /// LOOK algorithm — reverses at last request.
475 Look,
476 /// Nearest-car — assigns closest idle elevator.
477 NearestCar,
478 /// Estimated Time to Destination — minimizes total cost.
479 Etd,
480 /// Hall-call destination dispatch — sticky per-rider assignment.
481 Destination,
482 /// Relative System Response — additive composite of ETA, direction,
483 /// car-call affinity, and load-share terms.
484 Rsr,
485 /// Custom strategy identified by name. The game must provide a factory.
486 Custom(String),
487}
488
489impl std::fmt::Display for BuiltinStrategy {
490 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
491 match self {
492 Self::Scan => write!(f, "Scan"),
493 Self::Look => write!(f, "Look"),
494 Self::NearestCar => write!(f, "NearestCar"),
495 Self::Etd => write!(f, "Etd"),
496 Self::Destination => write!(f, "Destination"),
497 Self::Rsr => write!(f, "Rsr"),
498 Self::Custom(name) => write!(f, "Custom({name})"),
499 }
500 }
501}
502
503impl BuiltinStrategy {
504 /// Instantiate the dispatch strategy for this variant.
505 ///
506 /// Returns `None` for `Custom` — the game must provide those via
507 /// a factory function.
508 #[must_use]
509 pub fn instantiate(&self) -> Option<Box<dyn DispatchStrategy>> {
510 match self {
511 Self::Scan => Some(Box::new(scan::ScanDispatch::new())),
512 Self::Look => Some(Box::new(look::LookDispatch::new())),
513 Self::NearestCar => Some(Box::new(nearest_car::NearestCarDispatch::new())),
514 // `Default` ships the tuned stack (age-linear fairness term
515 // active); `new()` is the zero baseline for mutant/unit
516 // tests that isolate single terms. The playground's "ETD"
517 // dropdown entry should map to the strategy with fairness
518 // protection, not the raw version that lets the max-wait
519 // tail drift unbounded.
520 Self::Etd => Some(Box::new(etd::EtdDispatch::default())),
521 Self::Destination => Some(Box::new(destination::DestinationDispatch::new())),
522 // `Default` ships with the tuned penalty stack; `new()` is
523 // the zero baseline for additive-composition tests. The
524 // playground's "RSR" dropdown entry should map to the
525 // actual strategy, not to NearestCar-in-disguise, so use
526 // `Default` here.
527 Self::Rsr => Some(Box::new(rsr::RsrDispatch::default())),
528 Self::Custom(_) => None,
529 }
530 }
531}
532
533/// Decision returned by a dispatch strategy.
534#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
535#[non_exhaustive]
536pub enum DispatchDecision {
537 /// Go to the specified stop entity.
538 GoToStop(EntityId),
539 /// Remain idle.
540 Idle,
541}
542
543/// Per-line relationship data within an [`ElevatorGroup`].
544///
545/// This is a denormalized cache maintained by [`Simulation`](crate::sim::Simulation).
546/// The source of truth for intrinsic line properties is the
547/// [`Line`](crate::components::Line) component in World.
548#[derive(Debug, Clone, Serialize, Deserialize)]
549pub struct LineInfo {
550 /// Line entity ID.
551 entity: EntityId,
552 /// Elevator entities on this line.
553 elevators: Vec<EntityId>,
554 /// Stop entities served by this line.
555 serves: Vec<EntityId>,
556}
557
558impl LineInfo {
559 /// Create a new `LineInfo`.
560 #[must_use]
561 pub const fn new(entity: EntityId, elevators: Vec<EntityId>, serves: Vec<EntityId>) -> Self {
562 Self {
563 entity,
564 elevators,
565 serves,
566 }
567 }
568
569 /// Line entity ID.
570 #[must_use]
571 pub const fn entity(&self) -> EntityId {
572 self.entity
573 }
574
575 /// Elevator entities on this line.
576 #[must_use]
577 pub fn elevators(&self) -> &[EntityId] {
578 &self.elevators
579 }
580
581 /// Stop entities served by this line.
582 #[must_use]
583 pub fn serves(&self) -> &[EntityId] {
584 &self.serves
585 }
586
587 /// Set the line entity ID (used during snapshot restore).
588 pub(crate) const fn set_entity(&mut self, entity: EntityId) {
589 self.entity = entity;
590 }
591
592 /// Add an elevator to this line, deduplicating against existing entries.
593 ///
594 /// Returns `true` if the elevator was inserted, `false` if it was
595 /// already present. Replaces direct `&mut Vec` access so callers
596 /// can't introduce duplicates the dedup invariants in
597 /// [`ElevatorGroup::rebuild_caches`] rely on.
598 pub(crate) fn add_elevator(&mut self, elevator: EntityId) -> bool {
599 if self.elevators.contains(&elevator) {
600 false
601 } else {
602 self.elevators.push(elevator);
603 true
604 }
605 }
606
607 /// Remove an elevator from this line.
608 ///
609 /// Returns `true` if the elevator was present and removed, `false`
610 /// if it was absent.
611 pub(crate) fn remove_elevator(&mut self, elevator: EntityId) -> bool {
612 let len_before = self.elevators.len();
613 self.elevators.retain(|&e| e != elevator);
614 self.elevators.len() != len_before
615 }
616
617 /// Add a stop to this line's served list, deduplicating against
618 /// existing entries.
619 ///
620 /// Returns `true` if the stop was inserted, `false` if it was
621 /// already present.
622 pub(crate) fn add_stop(&mut self, stop: EntityId) -> bool {
623 if self.serves.contains(&stop) {
624 false
625 } else {
626 self.serves.push(stop);
627 true
628 }
629 }
630
631 /// Remove a stop from this line's served list.
632 ///
633 /// Returns `true` if the stop was present and removed, `false`
634 /// if it was absent.
635 pub(crate) fn remove_stop(&mut self, stop: EntityId) -> bool {
636 let len_before = self.serves.len();
637 self.serves.retain(|&s| s != stop);
638 self.serves.len() != len_before
639 }
640}
641
642/// How hall calls expose rider destinations to dispatch.
643///
644/// Different building eras and controller designs reveal destinations
645/// at different moments. Groups pick a mode so the sim can model both
646/// traditional up/down collective-control elevators and modern
647/// destination-dispatch lobby kiosks within the same simulation.
648///
649/// Stops are expected to belong to exactly one group. When a stop
650/// overlaps multiple groups, the hall-call press consults the first
651/// group containing it (iteration order over
652/// [`Simulation::groups`](crate::sim::Simulation::groups)), which in
653/// turn determines the `HallCallMode` and ack latency applied to that
654/// call. Overlapping topologies are not validated at construction
655/// time; games that need them should be aware of this first-match
656/// rule.
657#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
658#[non_exhaustive]
659pub enum HallCallMode {
660 /// Traditional collective-control ("classic" Otis/Westinghouse).
661 ///
662 /// Riders press an up or down button in the hall; the destination
663 /// is revealed only *after* boarding, via a
664 /// [`CarCall`]. Dispatch sees a direction
665 /// per call but does not know individual rider destinations until
666 /// they're aboard.
667 #[default]
668 Classic,
669 /// Modern destination dispatch ("DCS" — Otis `CompassPlus`, KONE
670 /// Polaris, Schindler PORT).
671 ///
672 /// Riders enter their destination at a hall kiosk, so each
673 /// [`HallCall`] carries a destination
674 /// stop from the moment it's pressed. Required by
675 /// [`DestinationDispatch`].
676 Destination,
677}
678
679impl std::fmt::Display for HallCallMode {
680 /// ```
681 /// # use elevator_core::dispatch::HallCallMode;
682 /// assert_eq!(format!("{}", HallCallMode::Classic), "classic");
683 /// assert_eq!(format!("{}", HallCallMode::Destination), "destination");
684 /// ```
685 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
686 match self {
687 Self::Classic => f.write_str("classic"),
688 Self::Destination => f.write_str("destination"),
689 }
690 }
691}
692
693/// Runtime elevator group: a set of lines sharing a dispatch strategy.
694///
695/// A group is the logical dispatch unit. It contains one or more
696/// [`LineInfo`] entries, each representing a physical path with its
697/// elevators and served stops.
698///
699/// The flat `elevator_entities` and `stop_entities` fields are derived
700/// caches (union of all lines' elevators/stops), rebuilt automatically
701/// via [`rebuild_caches()`](Self::rebuild_caches).
702#[derive(Debug, Clone, Serialize, Deserialize)]
703pub struct ElevatorGroup {
704 /// Unique group identifier.
705 id: GroupId,
706 /// Human-readable group name.
707 name: String,
708 /// Lines belonging to this group.
709 lines: Vec<LineInfo>,
710 /// How hall calls reveal destinations to dispatch (Classic vs DCS).
711 hall_call_mode: HallCallMode,
712 /// Ticks between a button press and dispatch first seeing the call.
713 /// `0` = immediate (current behavior). Realistic values: 5–30 ticks
714 /// at 60 Hz, modeling controller processing latency.
715 ack_latency_ticks: u32,
716 /// Derived flat cache — rebuilt by `rebuild_caches()`.
717 elevator_entities: Vec<EntityId>,
718 /// Derived flat cache — rebuilt by `rebuild_caches()`.
719 stop_entities: Vec<EntityId>,
720}
721
722impl ElevatorGroup {
723 /// Create a new group with the given lines. Caches are built automatically.
724 /// Defaults: [`HallCallMode::Classic`], `ack_latency_ticks = 0`.
725 #[must_use]
726 pub fn new(id: GroupId, name: String, lines: Vec<LineInfo>) -> Self {
727 let mut group = Self {
728 id,
729 name,
730 lines,
731 hall_call_mode: HallCallMode::default(),
732 ack_latency_ticks: 0,
733 elevator_entities: Vec::new(),
734 stop_entities: Vec::new(),
735 };
736 group.rebuild_caches();
737 group
738 }
739
740 /// Override the hall call mode for this group.
741 #[must_use]
742 pub const fn with_hall_call_mode(mut self, mode: HallCallMode) -> Self {
743 self.hall_call_mode = mode;
744 self
745 }
746
747 /// Override the ack latency for this group.
748 #[must_use]
749 pub const fn with_ack_latency_ticks(mut self, ticks: u32) -> Self {
750 self.ack_latency_ticks = ticks;
751 self
752 }
753
754 /// Set the hall call mode in-place (for mutation via
755 /// [`Simulation::groups_mut`](crate::sim::Simulation::groups_mut)).
756 pub const fn set_hall_call_mode(&mut self, mode: HallCallMode) {
757 self.hall_call_mode = mode;
758 }
759
760 /// Set the ack latency in-place.
761 pub const fn set_ack_latency_ticks(&mut self, ticks: u32) {
762 self.ack_latency_ticks = ticks;
763 }
764
765 /// Hall call mode for this group.
766 #[must_use]
767 pub const fn hall_call_mode(&self) -> HallCallMode {
768 self.hall_call_mode
769 }
770
771 /// Controller ack latency for this group.
772 #[must_use]
773 pub const fn ack_latency_ticks(&self) -> u32 {
774 self.ack_latency_ticks
775 }
776
777 /// Unique group identifier.
778 #[must_use]
779 pub const fn id(&self) -> GroupId {
780 self.id
781 }
782
783 /// Human-readable group name.
784 #[must_use]
785 pub fn name(&self) -> &str {
786 &self.name
787 }
788
789 /// Lines belonging to this group.
790 #[must_use]
791 pub fn lines(&self) -> &[LineInfo] {
792 &self.lines
793 }
794
795 /// Mutable access to lines (call [`rebuild_caches()`](Self::rebuild_caches) after mutating).
796 pub const fn lines_mut(&mut self) -> &mut Vec<LineInfo> {
797 &mut self.lines
798 }
799
800 /// Elevator entities belonging to this group (derived from lines).
801 #[must_use]
802 pub fn elevator_entities(&self) -> &[EntityId] {
803 &self.elevator_entities
804 }
805
806 /// Stop entities served by this group (derived from lines, deduplicated).
807 #[must_use]
808 pub fn stop_entities(&self) -> &[EntityId] {
809 &self.stop_entities
810 }
811
812 /// Whether this group can serve a rider on `leg`. A `Group(g)` leg
813 /// matches by group id; a `Line(l)` leg matches if `l` belongs to
814 /// this group; `Walk` never rides an elevator.
815 #[must_use]
816 pub fn accepts_leg(&self, leg: &crate::components::RouteLeg) -> bool {
817 match leg.via {
818 crate::components::TransportMode::Group(g) => g == self.id,
819 crate::components::TransportMode::Line(l) => {
820 self.lines.iter().any(|li| li.entity() == l)
821 }
822 crate::components::TransportMode::Walk => false,
823 }
824 }
825
826 /// Push a stop entity directly into the group's stop cache.
827 ///
828 /// Use when a stop belongs to the group for dispatch purposes but is
829 /// not (yet) assigned to any line. Call `add_stop_to_line` later to
830 /// wire it into the topology graph.
831 pub(crate) fn push_stop(&mut self, stop: EntityId) {
832 if !self.stop_entities.contains(&stop) {
833 self.stop_entities.push(stop);
834 }
835 }
836
837 /// Push an elevator entity directly into the group's elevator cache
838 /// (in addition to the line it belongs to).
839 pub(crate) fn push_elevator(&mut self, elevator: EntityId) {
840 if !self.elevator_entities.contains(&elevator) {
841 self.elevator_entities.push(elevator);
842 }
843 }
844
845 /// Rebuild derived caches from lines. Call after mutating lines.
846 pub fn rebuild_caches(&mut self) {
847 self.elevator_entities = self
848 .lines
849 .iter()
850 .flat_map(|li| li.elevators.iter().copied())
851 .collect();
852 let mut stops: Vec<EntityId> = self
853 .lines
854 .iter()
855 .flat_map(|li| li.serves.iter().copied())
856 .collect();
857 stops.sort_unstable();
858 stops.dedup();
859 self.stop_entities = stops;
860 }
861}
862
863/// Look up the `serves` list for an elevator's line.
864///
865/// Walks `groups` to find the [`LineInfo`] whose entity matches the
866/// car's current `line()`. Returns `None` if the car has no line
867/// registered in any group (an inconsistent state — should be
868/// unreachable in a healthy sim).
869///
870/// Helper for callers of
871/// [`World::find_stop_at_position_in`](crate::world::World::find_stop_at_position_in)
872/// that already have group context: `find_stop_at_position(pos)` is
873/// global (any line wins) and ambiguous when two lines share a
874/// position; passing the elevator's serves list scopes the lookup to
875/// *its* line.
876///
877/// Cost: `O(groups × lines_per_group)` per call. For loops over many
878/// elevators per tick, prefer [`build_line_serves_index`] +
879/// [`elevator_line_serves_indexed`] to amortize the line walk.
880#[must_use]
881pub fn elevator_line_serves<'a>(
882 world: &World,
883 groups: &'a [ElevatorGroup],
884 elevator: EntityId,
885) -> Option<&'a [EntityId]> {
886 let line_eid = world.elevator(elevator)?.line();
887 groups
888 .iter()
889 .flat_map(ElevatorGroup::lines)
890 .find(|li| li.entity() == line_eid)
891 .map(LineInfo::serves)
892}
893
894/// Pre-built index mapping each line entity to its `serves` slice.
895/// Built once with [`build_line_serves_index`]; queried with
896/// [`elevator_line_serves_indexed`] for O(1) per-elevator lookup.
897pub type LineServesIndex<'a> = std::collections::HashMap<EntityId, &'a [EntityId]>;
898
899/// Build a [`LineServesIndex`] from the group list. O(groups × lines).
900/// Call once per substep / system and reuse across the elevator loop.
901#[must_use]
902pub fn build_line_serves_index(groups: &[ElevatorGroup]) -> LineServesIndex<'_> {
903 let mut idx: LineServesIndex<'_> = std::collections::HashMap::new();
904 for li in groups.iter().flat_map(ElevatorGroup::lines) {
905 idx.insert(li.entity(), li.serves());
906 }
907 idx
908}
909
910/// Indexed variant of [`elevator_line_serves`]. O(1) per call given
911/// a pre-built [`LineServesIndex`].
912#[must_use]
913pub fn elevator_line_serves_indexed<'a>(
914 world: &World,
915 index: &LineServesIndex<'a>,
916 elevator: EntityId,
917) -> Option<&'a [EntityId]> {
918 let line_eid = world.elevator(elevator)?.line();
919 index.get(&line_eid).copied()
920}
921
922/// Context passed to [`DispatchStrategy::rank`].
923///
924/// Bundles the per-call arguments into a single struct so future context
925/// fields can be added without breaking existing trait implementations.
926#[non_exhaustive]
927pub struct RankContext<'a> {
928 /// The elevator being evaluated.
929 pub car: EntityId,
930 /// The stop being evaluated as a candidate destination.
931 pub stop: EntityId,
932 /// The dispatch group this assignment belongs to.
933 pub group: &'a ElevatorGroup,
934 /// Demand snapshot for the current dispatch pass.
935 pub manifest: &'a DispatchManifest,
936 /// Read-only world state.
937 pub world: &'a World,
938}
939
940impl RankContext<'_> {
941 /// Position of [`car`](Self::car) along the shaft axis.
942 ///
943 /// Returns `0.0` for an entity that has no `Position` component
944 /// (which would never reach this method through normal dispatch
945 /// — `compute_assignments` filters out cars without positions
946 /// upstream — but the defensive default protects custom callers).
947 /// Derived from [`world`](Self::world) on each call: the dispatch
948 /// loop never moves elevators between rank calls, so re-deriving
949 /// is free, and skipping the duplicate field eliminates the
950 /// synchronisation risk of the old shape.
951 #[must_use]
952 pub fn car_position(&self) -> f64 {
953 self.world.position(self.car).map_or(0.0, |p| p.value)
954 }
955
956 /// Position of [`stop`](Self::stop) along the shaft axis.
957 ///
958 /// Returns `0.0` for an entity that has no `Stop` component (same
959 /// rationale as [`car_position`](Self::car_position)).
960 #[must_use]
961 pub fn stop_position(&self) -> f64 {
962 self.world.stop_position(self.stop).unwrap_or(0.0)
963 }
964}
965
966impl std::fmt::Debug for RankContext<'_> {
967 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
968 f.debug_struct("RankContext")
969 .field("car", &self.car)
970 .field("car_position", &self.car_position())
971 .field("stop", &self.stop)
972 .field("stop_position", &self.stop_position())
973 .field("group", &self.group)
974 .field("manifest", &self.manifest)
975 .field("world", &"World { .. }")
976 .finish()
977 }
978}
979
980/// Pluggable dispatch algorithm.
981///
982/// Strategies implement [`rank`](Self::rank) to score each `(car, stop)`
983/// pair; the dispatch system then performs an optimal assignment across
984/// the whole group, guaranteeing that no two cars are sent to the same
985/// hall call.
986///
987/// Returning `None` from `rank` excludes a pair from assignment — useful
988/// for capacity limits, direction preferences, restricted stops, or
989/// sticky commitments.
990///
991/// Cars that receive no stop fall through to [`fallback`](Self::fallback),
992/// which returns the policy for that car (idle, park, etc.).
993pub trait DispatchStrategy: Send + Sync {
994 /// Optional hook called once per group before the assignment pass.
995 ///
996 /// Strategies that need to mutate [`World`] extension storage (e.g.
997 /// [`DestinationDispatch`] writing sticky rider → car assignments)
998 /// or pre-populate [`crate::components::DestinationQueue`] entries
999 /// override this. Default: no-op.
1000 fn pre_dispatch(
1001 &mut self,
1002 _group: &ElevatorGroup,
1003 _manifest: &DispatchManifest,
1004 _world: &mut World,
1005 ) {
1006 }
1007
1008 /// Optional hook called once per candidate car, before any
1009 /// [`rank`](Self::rank) calls for that car in the current pass.
1010 ///
1011 /// Strategies whose ranking depends on stable per-car state (e.g. the
1012 /// sweep direction used by SCAN/LOOK) set that state here so later
1013 /// `rank` calls see a consistent view regardless of iteration order.
1014 /// The default is a no-op.
1015 fn prepare_car(
1016 &mut self,
1017 _car: EntityId,
1018 _car_position: f64,
1019 _group: &ElevatorGroup,
1020 _manifest: &DispatchManifest,
1021 _world: &World,
1022 ) {
1023 }
1024
1025 /// Score the cost of sending `car` to `stop`. Lower is better.
1026 ///
1027 /// Returning `None` marks this `(car, stop)` pair as unavailable;
1028 /// the assignment algorithm will never pair them. Use this for
1029 /// capacity limits, wrong-direction stops, stops outside the line's
1030 /// topology, or pairs already committed via a sticky assignment.
1031 ///
1032 /// Must return a finite, non-negative value if `Some` — infinities
1033 /// and NaN can destabilize the underlying Hungarian solver.
1034 ///
1035 /// Takes `&self` so the assignment loop can score `(car, stop)` pairs
1036 /// in any order without producing an asymmetric cost matrix. Compute
1037 /// any per-car scratch in [`prepare_car`](Self::prepare_car) (which
1038 /// takes `&mut self`) before this method is called.
1039 fn rank(&self, ctx: &RankContext<'_>) -> Option<f64>;
1040
1041 /// Decide what an idle car should do when no stop was assigned to it.
1042 ///
1043 /// Called for each car the assignment phase could not pair with a
1044 /// stop (because there were no stops, or all candidate stops had
1045 /// rank `None` for this car). Default: [`DispatchDecision::Idle`].
1046 fn fallback(
1047 &mut self,
1048 _car: EntityId,
1049 _car_position: f64,
1050 _group: &ElevatorGroup,
1051 _manifest: &DispatchManifest,
1052 _world: &World,
1053 ) -> DispatchDecision {
1054 DispatchDecision::Idle
1055 }
1056
1057 /// Notify the strategy that an elevator has been removed.
1058 ///
1059 /// Implementations with per-elevator state (e.g. direction tracking)
1060 /// should clean up here to prevent unbounded memory growth.
1061 fn notify_removed(&mut self, _elevator: EntityId) {}
1062
1063 /// If this strategy is a known built-in variant, return it so
1064 /// [`Simulation::new`](crate::sim::Simulation::new) can stamp the
1065 /// correct [`BuiltinStrategy`] into the group's snapshot identity.
1066 ///
1067 /// Without this, legacy-topology sims constructed via
1068 /// `Simulation::new(config, SomeNonScanStrategy::new())` silently
1069 /// recorded `BuiltinStrategy::Scan` as their identity — so a
1070 /// snapshot round-trip replaced the running strategy with Scan
1071 /// and produced different dispatch decisions post-restore
1072 /// (determinism regression).
1073 ///
1074 /// Default: `None` (unidentified — the constructor falls back to
1075 /// recording [`BuiltinStrategy::Scan`], matching pre-fix behaviour
1076 /// for callers that never cared about round-trip identity). Custom
1077 /// strategies that DO care should override this to return
1078 /// [`BuiltinStrategy::Custom`] with a stable name.
1079 #[must_use]
1080 fn builtin_id(&self) -> Option<BuiltinStrategy> {
1081 None
1082 }
1083
1084 /// Serialize this strategy's tunable configuration to a string
1085 /// that [`restore_config`](Self::restore_config) can apply to a
1086 /// freshly-instantiated instance.
1087 ///
1088 /// Returning `Some(..)` makes the configuration survive snapshot
1089 /// round-trip: without it, [`crate::snapshot::WorldSnapshot::restore`]
1090 /// instantiates each built-in via [`BuiltinStrategy::instantiate`],
1091 /// which calls `::new()` with default weights — silently dropping
1092 /// any tuning applied via `with_*` builder methods (e.g.
1093 /// `EtdDispatch::with_delay_weight(2.5)` degrades to the default
1094 /// `1.0` on the restored sim).
1095 ///
1096 /// Default: `None` (no configuration to save). Built-ins with
1097 /// tunable weights override to return a RON-serialized copy of
1098 /// themselves; strategies with transient per-pass scratch should
1099 /// use `#[serde(skip)]` on those fields so the snapshot stays
1100 /// compact and deterministic.
1101 #[must_use]
1102 fn snapshot_config(&self) -> Option<String> {
1103 None
1104 }
1105
1106 /// Restore tunable configuration from a string previously produced
1107 /// by [`snapshot_config`](Self::snapshot_config) on the same
1108 /// strategy variant. Called by
1109 /// [`crate::snapshot::WorldSnapshot::restore`] immediately after
1110 /// [`BuiltinStrategy::instantiate`] builds the default instance,
1111 /// so the restore writes over the defaults.
1112 ///
1113 /// # Errors
1114 /// Returns the underlying parse error as a `String` when the
1115 /// serialized form doesn't round-trip. Default implementation
1116 /// ignores the argument and returns `Ok(())` — paired with the
1117 /// `None` default of `snapshot_config`, this means strategies that
1118 /// don't override either method skip configuration round-trip,
1119 /// matching pre-fix behaviour.
1120 fn restore_config(&mut self, _serialized: &str) -> Result<(), String> {
1121 Ok(())
1122 }
1123}
1124
1125/// Resolution of a single dispatch assignment pass for one group.
1126///
1127/// Produced by `assign` and consumed by
1128/// `crate::systems::dispatch::run` to apply decisions to the world.
1129#[derive(Debug, Clone)]
1130pub struct AssignmentResult {
1131 /// `(car, decision)` pairs for every idle car in the group.
1132 pub decisions: Vec<(EntityId, DispatchDecision)>,
1133}
1134
1135/// Per-simulation scratch buffers for the dispatch phase.
1136///
1137/// Every field is a `Vec`/`HashSet` whose allocations the hot path
1138/// would otherwise re-take on every tick per group (cost matrix
1139/// backing store, pending-stops list, servicing cars, pinned /
1140/// committed / idle-elevator filters). Owning them on the
1141/// simulation lets each dispatch pass `clear()` them in place and
1142/// reuse the capacity — on a 50-car × 200-stop group the cost matrix
1143/// alone is ~80 KB of heap churn per tick, and at the 500-car
1144/// `scaling_extreme` scale it's ~20 MB.
1145///
1146/// The scratch is always cleared before use; consumers should not
1147/// rely on any carry-over content between groups or ticks.
1148#[derive(Default)]
1149pub(crate) struct DispatchScratch {
1150 /// Reusable `Matrix<i64>` the Hungarian consumes by reference. When
1151 /// the dispatch pass can reuse the stored Matrix (`rows × cols`
1152 /// match), this is `Some` and gets filled in-place via `Matrix::fill`;
1153 /// when shapes change the slot is replaced with `Matrix::new`.
1154 pub cost_matrix_mx: Option<pathfinding::matrix::Matrix<i64>>,
1155 /// `(stop, line, remaining_capacity)` for door-cycling cars, used
1156 /// by `pending_stops_minus_covered` to avoid double-dispatching
1157 /// stops a car is already servicing.
1158 pub servicing: Vec<(EntityId, EntityId, f64)>,
1159 /// Stops with live demand, returned from `pending_stops_minus_covered`.
1160 pub pending_stops: Vec<(EntityId, f64)>,
1161 /// Aboard-rider destinations across idle cars — consulted so a
1162 /// stop that a car aboard wants to reach stays pickup-eligible.
1163 pub idle_rider_destinations: HashSet<EntityId>,
1164 /// Per-stop linestamp buffer reused inside `is_covered`.
1165 pub lines_here: Vec<EntityId>,
1166 /// Pinned hall-call `(car, stop)` pairs for the current group.
1167 pub pinned_pairs: Vec<(EntityId, EntityId)>,
1168 /// Committed `(car, target)` pairs — mid-flight cars whose trip
1169 /// still has demand; held out of the Hungarian idle pool.
1170 pub committed_pairs: Vec<(EntityId, EntityId)>,
1171 /// Idle elevator pool `(car, position)` for this group.
1172 pub idle_elevators: Vec<(EntityId, f64)>,
1173}
1174
1175impl DispatchScratch {
1176 /// Clear every buffer without freeing its backing capacity.
1177 ///
1178 /// `cost_matrix_mx` is re-sized/re-filled lazily in
1179 /// `assign_with_scratch`; leaving it alone here preserves its
1180 /// capacity when the group's (rows, cols) match the last
1181 /// dispatch pass.
1182 pub fn clear_all(&mut self) {
1183 self.servicing.clear();
1184 self.pending_stops.clear();
1185 self.idle_rider_destinations.clear();
1186 self.lines_here.clear();
1187 self.pinned_pairs.clear();
1188 self.committed_pairs.clear();
1189 self.idle_elevators.clear();
1190 }
1191}
1192
1193/// Sentinel weight used to pad unavailable `(car, stop)` pairs when
1194/// building the cost matrix for the Hungarian solver. Chosen so that
1195/// `n · SENTINEL` can't overflow `i64`: the Kuhn–Munkres implementation
1196/// sums weights and potentials across each row/column internally, so
1197/// headroom of ~2¹⁵ above the sentinel lets groups scale past 30 000
1198/// cars or stops before any arithmetic risk appears.
1199const ASSIGNMENT_SENTINEL: i64 = 1 << 48;
1200/// Fixed-point scale for converting `f64` costs to the `i64` values the
1201/// Hungarian solver requires. One unit ≈ one micro-tick / millimeter:
1202/// the smallest meaningful rank delta is sub-tick / sub-millimeter, so
1203/// scaling by 1e6 keeps that delta as a 1-unit `i64` difference and
1204/// preserves the strategy's tie-breaking precision through the cast.
1205const ASSIGNMENT_SCALE: f64 = 1_000_000.0;
1206
1207/// Convert a `f64` rank cost into the fixed-point `i64` the Hungarian
1208/// solver consumes. Non-finite, negative, or overflow-prone inputs map
1209/// to the unavailable sentinel.
1210fn scale_cost(cost: f64) -> i64 {
1211 if !cost.is_finite() || cost < 0.0 {
1212 debug_assert!(
1213 cost.is_finite() && cost >= 0.0,
1214 "DispatchStrategy::rank() returned invalid cost {cost}; must be finite and non-negative"
1215 );
1216 return ASSIGNMENT_SENTINEL;
1217 }
1218 // Cap at just below sentinel so any real rank always beats unavailable.
1219 (cost * ASSIGNMENT_SCALE)
1220 .round()
1221 .clamp(0.0, (ASSIGNMENT_SENTINEL - 1) as f64) as i64
1222}
1223
1224/// Build the pending-demand stop list, subtracting stops whose
1225/// demand is already being absorbed by a car — either currently in
1226/// its door cycle at the stop, or en route via `MovingToStop`.
1227///
1228/// Both phases count as "servicing" because they represent a
1229/// commitment to open doors at the target with remaining capacity
1230/// that waiting riders can (typically) fit into. Without the
1231/// `MovingToStop` case, a new idle car becoming available during
1232/// car A's trip to the lobby gets paired with the same lobby call
1233/// on the next dispatch tick — car B travels empty behind car A
1234/// and the playground shows two cars doing a lobby touch-and-go
1235/// for one rider. Composes with the commitment set in
1236/// [`systems::dispatch`](crate::systems::dispatch), which excludes
1237/// committed cars from the idle pool at the same time.
1238///
1239/// `Stopped` (parked-with-doors-closed) is deliberately *not* in
1240/// the list: that's a legitimately reassignable state.
1241/// `Repositioning` is also excluded — a repositioning car doesn't
1242/// open doors on arrival, so it cannot absorb waiting riders.
1243///
1244/// Line-pinned riders (`TransportMode::Line(L)`) keep a stop
1245/// pending even when a car is present, because a car on Shaft A
1246/// can't absorb a rider pinned to Shaft B. Coverage also fails
1247/// when the waiting riders' combined weight exceeds the servicing
1248/// car's remaining capacity — the leftover spills out when doors
1249/// close and deserves its own dispatch immediately.
1250fn pending_stops_minus_covered(
1251 group: &ElevatorGroup,
1252 manifest: &DispatchManifest,
1253 world: &World,
1254 idle_cars: &[(EntityId, f64)],
1255 scratch: &mut DispatchScratch,
1256) {
1257 // Refill `scratch.servicing` in place — the buffer survives across
1258 // ticks so the hot path doesn't reallocate per group.
1259 scratch.servicing.clear();
1260 for &eid in group.elevator_entities() {
1261 let Some(car) = world.elevator(eid) else {
1262 continue;
1263 };
1264 let Some(target) = car.target_stop() else {
1265 continue;
1266 };
1267 if !matches!(
1268 car.phase(),
1269 ElevatorPhase::MovingToStop(_)
1270 | ElevatorPhase::DoorOpening
1271 | ElevatorPhase::Loading
1272 | ElevatorPhase::DoorClosing
1273 ) {
1274 continue;
1275 }
1276 let remaining = car.weight_capacity().value() - car.current_load().value();
1277 scratch.servicing.push((target, car.line(), remaining));
1278 }
1279
1280 // Aboard-rider destinations — reused buffer, same owned semantics.
1281 scratch.idle_rider_destinations.clear();
1282 for &(car_eid, _) in idle_cars {
1283 if let Some(car) = world.elevator(car_eid) {
1284 for &rid in car.riders() {
1285 if let Some(dest) = world.route(rid).and_then(Route::current_destination) {
1286 scratch.idle_rider_destinations.insert(dest);
1287 }
1288 }
1289 }
1290 }
1291
1292 // A stop is "covered" iff every waiting rider this group sees can
1293 // board at least one of the door-cycling cars here (line check)
1294 // AND the combined remaining capacity of the cars whose line
1295 // accepts the rider is enough to board them all (capacity check).
1296 //
1297 // Iterates `manifest.waiting_riders_at` rather than `world.iter_riders`
1298 // so `TransportMode::Walk` riders and cross-group-routed riders
1299 // (excluded by `build_manifest`) don't inflate the weight total.
1300 // `lines_here` is the same `scratch.lines_here` buffer each call —
1301 // cleared then refilled — so coverage checks don't churn the
1302 // allocator per stop.
1303 let mut lines_here: Vec<EntityId> = std::mem::take(&mut scratch.lines_here);
1304 let servicing = &scratch.servicing;
1305 let is_covered = |stop_eid: EntityId, lines_here: &mut Vec<EntityId>| -> bool {
1306 lines_here.clear();
1307 let mut capacity_here = 0.0;
1308 for &(stop, line, rem) in servicing {
1309 if stop == stop_eid {
1310 lines_here.push(line);
1311 capacity_here += rem;
1312 }
1313 }
1314 if lines_here.is_empty() {
1315 return false;
1316 }
1317 let mut total_weight = 0.0;
1318 for rider in manifest.waiting_riders_at(stop_eid) {
1319 let required_line = world
1320 .route(rider.id)
1321 .and_then(Route::current)
1322 .and_then(|leg| match leg.via {
1323 TransportMode::Line(l) => Some(l),
1324 _ => None,
1325 });
1326 if let Some(required) = required_line
1327 && !lines_here.contains(&required)
1328 {
1329 return false;
1330 }
1331 total_weight += rider.weight.value();
1332 }
1333 total_weight <= capacity_here
1334 };
1335
1336 scratch.pending_stops.clear();
1337 for &stop in group.stop_entities() {
1338 if !manifest.has_demand(stop) {
1339 continue;
1340 }
1341 let keep =
1342 scratch.idle_rider_destinations.contains(&stop) || !is_covered(stop, &mut lines_here);
1343 if !keep {
1344 continue;
1345 }
1346 if let Some(pos) = world.stop_position(stop) {
1347 scratch.pending_stops.push((stop, pos));
1348 }
1349 }
1350 // Return the lines_here buffer to scratch so its capacity survives.
1351 scratch.lines_here = lines_here;
1352}
1353
1354/// Run one group's assignment pass: build the cost matrix, solve the
1355/// optimal bipartite matching, then resolve unassigned cars via
1356/// [`DispatchStrategy::fallback`].
1357///
1358/// Visible to the `systems` module; not part of the public API.
1359/// Back-compat wrapper that allocates a throw-away scratch for
1360/// tests and one-off callers. Production paths (in
1361/// `crate::systems::dispatch::run`) must use
1362/// [`assign_with_scratch`] so the scratch capacity amortises
1363/// across ticks.
1364#[cfg(test)]
1365pub(crate) fn assign(
1366 strategy: &mut dyn DispatchStrategy,
1367 idle_cars: &[(EntityId, f64)],
1368 group: &ElevatorGroup,
1369 manifest: &DispatchManifest,
1370 world: &World,
1371) -> AssignmentResult {
1372 let mut scratch = DispatchScratch::default();
1373 assign_with_scratch(strategy, idle_cars, group, manifest, world, &mut scratch)
1374}
1375
1376/// Run one group's assignment pass: build the cost matrix, solve the
1377/// optimal bipartite matching, then resolve unassigned cars via
1378/// [`DispatchStrategy::fallback`]. Uses `scratch` so the per-tick
1379/// allocations (cost matrix, pending stops, etc.) reuse capacity
1380/// across invocations.
1381pub(crate) fn assign_with_scratch(
1382 strategy: &mut dyn DispatchStrategy,
1383 idle_cars: &[(EntityId, f64)],
1384 group: &ElevatorGroup,
1385 manifest: &DispatchManifest,
1386 world: &World,
1387 scratch: &mut DispatchScratch,
1388) -> AssignmentResult {
1389 // Fill `scratch.pending_stops` in place. The buffer's capacity
1390 // survives across ticks.
1391 pending_stops_minus_covered(group, manifest, world, idle_cars, scratch);
1392
1393 let n = idle_cars.len();
1394 let m = scratch.pending_stops.len();
1395
1396 if n == 0 {
1397 return AssignmentResult {
1398 decisions: Vec::new(),
1399 };
1400 }
1401
1402 let mut decisions: Vec<(EntityId, DispatchDecision)> = Vec::with_capacity(n);
1403
1404 if m == 0 {
1405 for &(eid, pos) in idle_cars {
1406 let d = strategy.fallback(eid, pos, group, manifest, world);
1407 decisions.push((eid, d));
1408 }
1409 return AssignmentResult { decisions };
1410 }
1411
1412 // Hungarian requires rows <= cols. Reuse the scratch `Matrix` when
1413 // the shape matches the previous dispatch pass — on a realistic
1414 // building the (rows, cols) tuple changes only when the car or
1415 // stop count does, so steady-state dispatch avoids any heap
1416 // traffic for the cost matrix at all. When the shape does change,
1417 // a fresh Matrix replaces the stored one and becomes the new
1418 // reusable buffer going forward.
1419 let cols = n.max(m);
1420 match &mut scratch.cost_matrix_mx {
1421 Some(mx) if mx.rows == n && mx.columns == cols => {
1422 mx.fill(ASSIGNMENT_SENTINEL);
1423 }
1424 slot => {
1425 *slot = Some(pathfinding::matrix::Matrix::new(
1426 n,
1427 cols,
1428 ASSIGNMENT_SENTINEL,
1429 ));
1430 }
1431 }
1432 let matrix_ref = scratch
1433 .cost_matrix_mx
1434 .as_mut()
1435 .unwrap_or_else(|| unreachable!("cost_matrix_mx populated by match above"));
1436
1437 {
1438 let pending_stops = &scratch.pending_stops;
1439 for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
1440 strategy.prepare_car(car_eid, car_pos, group, manifest, world);
1441 // Borrow the car's restricted-stops set for this row so each
1442 // (car, stop) pair can short-circuit before calling rank().
1443 // Pre-fix only DCS consulted restricted_stops; SCAN/LOOK/NC/ETD
1444 // happily ranked restricted pairs and `commit_go_to_stop` later
1445 // silently dropped the assignment, starving the call. (#256)
1446 let restricted = world
1447 .elevator(car_eid)
1448 .map(crate::components::Elevator::restricted_stops);
1449
1450 // The car's line's `serves` list is the set of stops it can
1451 // physically reach. In a single-line group every stop is
1452 // served (filter is a no-op); in a multi-line group (e.g.
1453 // sky-lobby + service bank, low/high banks sharing a
1454 // transfer floor) a car on line A must not be assigned to
1455 // a stop only line B serves — it would commit, sit there
1456 // unable to reach, and starve the call. The pre-fix matrix
1457 // happily ranked such cross-line pairs because no other
1458 // gate caught them: `restricted_stops` is for explicit
1459 // access denials, `pending_stops_minus_covered` filters
1460 // stops not cars, and built-in strategies score on
1461 // distance/direction without consulting line topology.
1462 let car_serves: Option<&[EntityId]> = world
1463 .elevator(car_eid)
1464 .map(crate::components::Elevator::line)
1465 .and_then(|line_eid| {
1466 group
1467 .lines()
1468 .iter()
1469 .find(|li| li.entity() == line_eid)
1470 .map(LineInfo::serves)
1471 });
1472 // `None` here means the car's line isn't in this group's
1473 // line list — a topology inconsistency that should be
1474 // unreachable. We can't fail the dispatch tick over it (the
1475 // sim still has to make progress), so the filter falls
1476 // open: the car is treated as if it could reach any stop.
1477 // The debug-assert catches it during testing without
1478 // affecting release builds.
1479 debug_assert!(
1480 world.elevator(car_eid).is_none() || car_serves.is_some(),
1481 "car {car_eid:?} on line not present in its group's lines list"
1482 );
1483
1484 for (j, &(stop_eid, _stop_pos)) in pending_stops.iter().enumerate() {
1485 if restricted.is_some_and(|r| r.contains(&stop_eid)) {
1486 continue; // leave SENTINEL — this pair is unavailable
1487 }
1488 if car_serves.is_some_and(|s| !s.contains(&stop_eid)) {
1489 continue; // car's line doesn't reach this stop
1490 }
1491 let ctx = RankContext {
1492 car: car_eid,
1493 stop: stop_eid,
1494 group,
1495 manifest,
1496 world,
1497 };
1498 let scaled = strategy.rank(&ctx).map_or(ASSIGNMENT_SENTINEL, scale_cost);
1499 matrix_ref[(i, j)] = scaled;
1500 }
1501 }
1502 }
1503 let matrix = &*matrix_ref;
1504 let (_, assignments) = pathfinding::kuhn_munkres::kuhn_munkres_min(matrix);
1505
1506 for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
1507 let col = assignments[i];
1508 // A real assignment is: col points to a real stop (col < m) AND
1509 // the cost isn't sentinel-padded (meaning rank() returned Some).
1510 if col < m && matrix[(i, col)] < ASSIGNMENT_SENTINEL {
1511 let (stop_eid, _) = scratch.pending_stops[col];
1512 decisions.push((car_eid, DispatchDecision::GoToStop(stop_eid)));
1513 } else {
1514 let d = strategy.fallback(car_eid, car_pos, group, manifest, world);
1515 decisions.push((car_eid, d));
1516 }
1517 }
1518
1519 AssignmentResult { decisions }
1520}
1521
1522/// Pluggable strategy for repositioning idle elevators.
1523///
1524/// After the dispatch phase, elevators that remain idle (no pending
1525/// assignments) are candidates for repositioning. The strategy decides
1526/// where each idle elevator should move to improve coverage and reduce
1527/// expected response times.
1528///
1529/// Implementations receive the set of idle elevator positions and the
1530/// group's stop positions, then return a target stop for each elevator
1531/// (or `None` to leave it in place).
1532pub trait RepositionStrategy: Send + Sync {
1533 /// Decide where to reposition idle elevators.
1534 ///
1535 /// Push `(elevator_entity, target_stop_entity)` pairs into `out`.
1536 /// The buffer is cleared before each call — implementations should
1537 /// only push, never read prior contents. Elevators not pushed remain idle.
1538 fn reposition(
1539 &mut self,
1540 idle_elevators: &[(EntityId, f64)],
1541 stop_positions: &[(EntityId, f64)],
1542 group: &ElevatorGroup,
1543 world: &World,
1544 out: &mut Vec<(EntityId, EntityId)>,
1545 );
1546
1547 /// If this strategy is a known built-in variant, return it so
1548 /// [`Simulation::set_reposition`](crate::sim::Simulation::set_reposition)
1549 /// callers don't have to pass a separate [`BuiltinReposition`] id
1550 /// that might drift from the dispatcher's actual type.
1551 ///
1552 /// Mirrors the pattern introduced for [`DispatchStrategy::builtin_id`]
1553 /// in #410: the runtime impl identifies itself so the snapshot
1554 /// identity always matches the executing behaviour, instead of
1555 /// depending on the caller to keep two parameters consistent.
1556 /// Default `None` — custom strategies should override to return
1557 /// [`BuiltinReposition::Custom`] with a stable name for snapshot
1558 /// fidelity.
1559 #[must_use]
1560 fn builtin_id(&self) -> Option<BuiltinReposition> {
1561 None
1562 }
1563
1564 /// Minimum [`ArrivalLog`](crate::arrival_log::ArrivalLog) retention
1565 /// (in ticks) the strategy needs to function. Strategies that read
1566 /// the log directly with a custom rolling window must override this
1567 /// so [`Simulation::set_reposition`](crate::sim::Simulation::set_reposition)
1568 /// can widen
1569 /// [`ArrivalLogRetention`](crate::arrival_log::ArrivalLogRetention)
1570 /// to keep the data alive long enough for the query.
1571 ///
1572 /// Default `0` — strategies that don't read the arrival log (or that
1573 /// only consume it through [`DispatchManifest::arrivals_at`], which
1574 /// already tracks retention) impose no requirement.
1575 #[must_use]
1576 fn min_arrival_log_window(&self) -> u64 {
1577 0
1578 }
1579}
1580
1581/// Serializable identifier for built-in repositioning strategies.
1582///
1583/// Used in config and snapshots to restore the correct strategy.
1584#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1585#[non_exhaustive]
1586pub enum BuiltinReposition {
1587 /// Distribute idle elevators evenly across stops.
1588 SpreadEvenly,
1589 /// Return idle elevators to a configured home stop.
1590 ReturnToLobby,
1591 /// Position near stops with historically high demand.
1592 DemandWeighted,
1593 /// Keep idle elevators where they are (no-op).
1594 NearestIdle,
1595 /// Pre-position cars near stops with the highest recent arrival rate.
1596 PredictiveParking,
1597 /// Mode-gated: picks between `ReturnToLobby` / `PredictiveParking`
1598 /// based on the current `TrafficDetector` mode.
1599 Adaptive,
1600 /// Custom strategy identified by name.
1601 Custom(String),
1602}
1603
1604impl std::fmt::Display for BuiltinReposition {
1605 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1606 match self {
1607 Self::SpreadEvenly => write!(f, "SpreadEvenly"),
1608 Self::ReturnToLobby => write!(f, "ReturnToLobby"),
1609 Self::DemandWeighted => write!(f, "DemandWeighted"),
1610 Self::NearestIdle => write!(f, "NearestIdle"),
1611 Self::PredictiveParking => write!(f, "PredictiveParking"),
1612 Self::Adaptive => write!(f, "Adaptive"),
1613 Self::Custom(name) => write!(f, "Custom({name})"),
1614 }
1615 }
1616}
1617
1618impl BuiltinReposition {
1619 /// Instantiate the reposition strategy for this variant.
1620 ///
1621 /// Returns `None` for `Custom` — the game must provide those via
1622 /// a factory function. `ReturnToLobby` uses stop index 0 as default.
1623 #[must_use]
1624 pub fn instantiate(&self) -> Option<Box<dyn RepositionStrategy>> {
1625 match self {
1626 Self::SpreadEvenly => Some(Box::new(reposition::SpreadEvenly)),
1627 Self::ReturnToLobby => Some(Box::new(reposition::ReturnToLobby::new())),
1628 Self::DemandWeighted => Some(Box::new(reposition::DemandWeighted)),
1629 Self::NearestIdle => Some(Box::new(reposition::NearestIdle)),
1630 Self::PredictiveParking => Some(Box::new(reposition::PredictiveParking::new())),
1631 Self::Adaptive => Some(Box::new(reposition::AdaptiveParking::new())),
1632 Self::Custom(_) => None,
1633 }
1634 }
1635}