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