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/// Hungarian-assignment pass + per-pass scratch buffers.
37pub(crate) mod assignment;
38/// Hall-call destination dispatch algorithm.
39pub mod destination;
40/// Estimated Time to Destination dispatch algorithm.
41pub mod etd;
42/// LOOK dispatch algorithm.
43pub mod look;
44/// Call-driven sweep for closed-loop topologies.
45#[cfg(feature = "loop_lines")]
46pub mod loop_sweep;
47/// Per-tick demand picture handed to dispatch strategies.
48pub mod manifest;
49/// Nearest-car dispatch algorithm.
50pub mod nearest_car;
51/// Built-in repositioning strategies.
52pub mod reposition;
53/// Relative System Response (RSR) dispatch algorithm.
54pub mod rsr;
55/// SCAN dispatch algorithm.
56pub mod scan;
57/// Per-elevator scratch helper for custom strategies.
58pub mod scratch;
59/// Shared sweep-direction logic used by SCAN and LOOK.
60pub(crate) mod sweep;
61
62pub use assignment::AssignmentResult;
63#[cfg(test)]
64pub(crate) use assignment::assign;
65pub(crate) use assignment::{DispatchScratch, assign_with_scratch};
66pub use destination::{AssignedCar, DestinationDispatch};
67pub use etd::EtdDispatch;
68pub use look::LookDispatch;
69#[cfg(feature = "loop_lines")]
70pub use loop_sweep::LoopSweepDispatch;
71pub use manifest::{DispatchManifest, RiderInfo};
72pub use nearest_car::NearestCarDispatch;
73pub use rsr::RsrDispatch;
74pub use scan::ScanDispatch;
75pub use scratch::PrepareScratch;
76
77use serde::{Deserialize, Serialize};
78
79use crate::components::Route;
80use crate::entity::EntityId;
81use crate::ids::GroupId;
82use crate::world::World;
83
84/// Whether assigning `ctx.car` to `ctx.stop` is worth ranking.
85///
86/// Combines two checks every dispatch strategy needs at the top of its
87/// `rank` implementation:
88///
89/// 1. **Servability** — capacity, full-load bypass, and the loading-phase
90/// boarding filter. A pair that can't exit an aboard rider, board a
91/// waiter, or answer a rider-less hall call is a no-op move (and a
92/// zero-cost one when the car is already parked there) which would
93/// otherwise stall doors against unservable demand.
94/// 2. **Path discipline** (only when `respect_aboard_path` is `true`) —
95/// refuses pickups that would pull a car carrying routed riders off
96/// the direct path to every aboard rider's destination. Without it, a
97/// stream of closer-destination hall calls can indefinitely preempt a
98/// farther aboard rider's delivery (the "never reaches the
99/// passenger's desired stop" loop).
100///
101/// Strategies with their own direction discipline (SCAN, LOOK, ETD,
102/// Destination) pass `respect_aboard_path: false` because their
103/// sweep/direction terms already rule out backtracks. Strategies without
104/// it (`NearestCar`, RSR) pass `respect_aboard_path: true`. Custom
105/// strategies should pass `true` unless they enforce direction
106/// discipline themselves.
107///
108/// Aboard riders without a published route (game-managed manual riders)
109/// don't constrain the path — any pickup is trivially on-the-way for
110/// them, so the path check trivially passes when no aboard rider has a
111/// `Route::current_destination`.
112#[must_use]
113pub fn pair_is_useful(ctx: &RankContext<'_>, respect_aboard_path: bool) -> bool {
114 let Some(car) = ctx.world.elevator(ctx.car) else {
115 return false;
116 };
117 let can_exit_here = car
118 .riders()
119 .iter()
120 .any(|&rid| ctx.world.route(rid).and_then(Route::current_destination) == Some(ctx.stop));
121 if can_exit_here {
122 return true;
123 }
124
125 // Direction-dependent full-load bypass (Otis Elevonic 411 model,
126 // patent US5490580A). A car loaded above its configured threshold
127 // in the current travel direction ignores hall calls in that same
128 // direction. Aboard riders still get delivered — the `can_exit_here`
129 // short-circuit above guarantees their destinations remain rank-able.
130 if bypass_in_current_direction(car, ctx) {
131 return false;
132 }
133
134 let remaining_capacity = car.weight_capacity.value() - car.current_load.value();
135 if remaining_capacity <= 0.0 {
136 return false;
137 }
138 let waiting = ctx.manifest.waiting_riders_at(ctx.stop);
139 let servable = if waiting.is_empty() {
140 // No waiters at the stop, and no aboard rider of ours exits here
141 // (the `can_exit_here` short-circuit ruled that out above).
142 // Demand must therefore come from either another car's
143 // `riding_to_stop` (not work this car can perform) or a
144 // rider-less hall call (someone pressed a button with no rider
145 // attached yet — a press from `press_hall_button` or one whose
146 // riders have since been fulfilled or abandoned). Only the
147 // latter is actionable; without this filter an idle car parked
148 // at the stop collapses to cost 0, the Hungarian picks the
149 // self-pair every tick, and doors cycle open/close indefinitely
150 // while the other car finishes its trip.
151 ctx.manifest
152 .hall_calls_at_stop
153 .get(&ctx.stop)
154 .is_some_and(|calls| calls.iter().any(|c| c.pending_riders.is_empty()))
155 } else {
156 waiting
157 .iter()
158 .any(|r| rider_can_board(r, car, ctx, remaining_capacity))
159 };
160 if !servable {
161 return false;
162 }
163 if !respect_aboard_path || car.riders().is_empty() {
164 return true;
165 }
166
167 // Route-less aboard riders (game-managed manual riders) don't
168 // publish a destination, so there's no committed path to protect.
169 // Any pickup is trivially on-the-way — let it through. Otherwise
170 // we'd refuse every pickup the moment the car carried its first
171 // manually-managed passenger.
172 let has_routed_rider = car.riders().iter().any(|&rid| {
173 ctx.world
174 .route(rid)
175 .and_then(Route::current_destination)
176 .is_some()
177 });
178 if !has_routed_rider {
179 return true;
180 }
181
182 // Pickups allowed only on the path to an aboard rider's destination.
183 // Candidate at the car's position (to_cand = 0) trivially qualifies —
184 // useful for same-floor boards.
185 let to_cand = ctx.stop_position() - ctx.car_position();
186 car.riders().iter().any(|&rid| {
187 let Some(dest) = ctx.world.route(rid).and_then(Route::current_destination) else {
188 return false;
189 };
190 let Some(dest_pos) = ctx.world.stop_position(dest) else {
191 return false;
192 };
193 let to_dest = dest_pos - ctx.car_position();
194 to_dest * to_cand >= 0.0 && to_cand.abs() <= to_dest.abs()
195 })
196}
197
198/// Sum of `wait_ticks` across `riders`, as `f64`.
199///
200/// Helper used by ETD and RSR fairness terms — both compute the same
201/// `riders.iter().map(|r| r.wait_ticks as f64).sum()` and feed the
202/// result into a fused-multiply-add against a configured weight.
203#[must_use]
204pub(crate) fn wait_ticks_sum(riders: &[RiderInfo]) -> f64 {
205 riders.iter().map(|r| r.wait_ticks as f64).sum()
206}
207
208/// Sum of squared `wait_ticks` across `riders`, as `f64`.
209///
210/// Used by ETD's quadratic-fairness term to escalate cost as old
211/// waiters age. RSR has no quadratic fairness; the linear form lives
212/// in [`wait_ticks_sum`].
213#[must_use]
214pub(crate) fn wait_ticks_squared_sum(riders: &[RiderInfo]) -> f64 {
215 riders
216 .iter()
217 .map(|r| {
218 let w = r.wait_ticks as f64;
219 w * w
220 })
221 .sum()
222}
223
224/// Apply a fairness bonus to a dispatch cost, clamping at zero.
225///
226/// Computes `(cost - weight * term).max(0.0)` via [`fp::fma`] for
227/// tighter rounding than the manual `cost - weight * term` form when
228/// the multiplier and product are both finite.
229///
230/// Both ETD's `age_linear` / `wait_squared` weights and RSR's
231/// `age_linear_weight` use this exact shape — a non-negative
232/// `weight` scaled against a non-negative aggregate (`wait_ticks_sum`
233/// / `wait_ticks_squared_sum`) subtracted from the running cost. The
234/// `.max(0.0)` floor is mandatory because the Hungarian assignment
235/// requires non-negative costs; without it, deeply-aged waits could
236/// underflow the cost into the negative territory, where the assigner's
237/// row-reduction step would produce a smaller-than-zero pseudo-cost
238/// and silently mis-rank.
239///
240/// [`fp::fma`]: crate::fp::fma
241#[must_use]
242pub(crate) fn apply_fairness_bonus(cost: f64, weight: f64, term: f64) -> f64 {
243 crate::fp::fma(weight, -term, cost).max(0.0)
244}
245
246/// Whether a waiting rider could actually board this car, matching the
247/// same filters the loading phase applies. Prevents `pair_is_useful`
248/// from approving a pickup whose only demand is direction-filtered or
249/// over-capacity — the loading phase would reject the rider, doors
250/// would cycle, and dispatch would re-pick the zero-cost self-pair.
251fn rider_can_board(
252 rider: &RiderInfo,
253 car: &crate::components::Elevator,
254 ctx: &RankContext<'_>,
255 remaining_capacity: f64,
256) -> bool {
257 if rider.weight.value() > remaining_capacity {
258 return false;
259 }
260 // Match `systems::loading`'s direction filter: a rider whose trip
261 // goes the opposite way of the car's committed direction will not
262 // be boarded. An unknown destination (no route yet) is treated as
263 // unconstrained — let the rider through and let the loading phase
264 // make the final call.
265 let Some(dest) = rider.destination else {
266 return true;
267 };
268 let Some(dest_pos) = ctx.world.stop_position(dest) else {
269 return true;
270 };
271 if dest_pos > ctx.stop_position() && !car.going_up() {
272 return false;
273 }
274 if dest_pos < ctx.stop_position() && !car.going_down() {
275 return false;
276 }
277 true
278}
279
280/// True when a full-load bypass applies: the car has a configured
281/// threshold for its current travel direction, is above that threshold,
282/// and the candidate stop lies in that same direction.
283fn bypass_in_current_direction(car: &crate::components::Elevator, ctx: &RankContext<'_>) -> bool {
284 // Derive travel direction from the car's current target, if any.
285 // An Idle or Stopped car has no committed direction → no bypass.
286 let Some(target) = car.phase().moving_target() else {
287 return false;
288 };
289 let Some(target_pos) = ctx.world.stop_position(target) else {
290 return false;
291 };
292 let going_up = target_pos > ctx.car_position();
293 let going_down = target_pos < ctx.car_position();
294 if !going_up && !going_down {
295 return false;
296 }
297 let threshold = if going_up {
298 car.bypass_load_up_pct()
299 } else {
300 car.bypass_load_down_pct()
301 };
302 let Some(pct) = threshold else {
303 return false;
304 };
305 let capacity = car.weight_capacity().value();
306 if capacity <= 0.0 {
307 return false;
308 }
309 let load_ratio = car.current_load().value() / capacity;
310 if load_ratio < pct {
311 return false;
312 }
313 // Only same-direction pickups get bypassed.
314 let stop_above = ctx.stop_position() > ctx.car_position();
315 let stop_below = ctx.stop_position() < ctx.car_position();
316 (going_up && stop_above) || (going_down && stop_below)
317}
318
319/// Serializable identifier for built-in dispatch strategies.
320///
321/// Used in snapshots and config files to restore the correct strategy
322/// without requiring the game to manually re-wire dispatch. Custom strategies
323/// are represented by the `Custom(String)` variant.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[non_exhaustive]
326pub enum BuiltinStrategy {
327 /// SCAN (elevator) algorithm — sweeps end-to-end.
328 Scan,
329 /// LOOK algorithm — reverses at last request.
330 Look,
331 /// Nearest-car — assigns closest idle elevator.
332 NearestCar,
333 /// Estimated Time to Destination — minimizes total cost.
334 Etd,
335 /// Hall-call destination dispatch — sticky per-rider assignment.
336 Destination,
337 /// Relative System Response — additive composite of ETA, direction,
338 /// car-call affinity, and load-share terms.
339 Rsr,
340 /// Continuous-patrol sweep for [`LineKind::Loop`](crate::components::LineKind::Loop)
341 /// groups. Loop cars never enter the Hungarian idle pool — the
342 /// kickstart pass and door FSM handle routing — so this variant is
343 /// a typed snapshot/config label rather than a ranking implementation.
344 /// Available only when the `loop_lines` cargo feature is enabled.
345 #[cfg(feature = "loop_lines")]
346 LoopSweep,
347 /// Custom strategy identified by name. The game must provide a factory.
348 Custom(String),
349}
350
351impl std::fmt::Display for BuiltinStrategy {
352 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353 match self {
354 Self::Scan => write!(f, "Scan"),
355 Self::Look => write!(f, "Look"),
356 Self::NearestCar => write!(f, "NearestCar"),
357 Self::Etd => write!(f, "Etd"),
358 Self::Destination => write!(f, "Destination"),
359 Self::Rsr => write!(f, "Rsr"),
360 #[cfg(feature = "loop_lines")]
361 Self::LoopSweep => write!(f, "LoopSweep"),
362 Self::Custom(name) => write!(f, "Custom({name})"),
363 }
364 }
365}
366
367impl BuiltinStrategy {
368 /// Instantiate the dispatch strategy for this variant.
369 ///
370 /// Returns `None` for `Custom` — the game must provide those via
371 /// a factory function.
372 #[must_use]
373 pub fn instantiate(&self) -> Option<Box<dyn DispatchStrategy>> {
374 match self {
375 Self::Scan => Some(Box::new(scan::ScanDispatch::new())),
376 Self::Look => Some(Box::new(look::LookDispatch::new())),
377 Self::NearestCar => Some(Box::new(nearest_car::NearestCarDispatch::new())),
378 // `Default` ships the tuned stack (age-linear fairness term
379 // active); `new()` is the zero baseline for mutant/unit
380 // tests that isolate single terms. The playground's "ETD"
381 // dropdown entry should map to the strategy with fairness
382 // protection, not the raw version that lets the max-wait
383 // tail drift unbounded.
384 Self::Etd => Some(Box::new(etd::EtdDispatch::default())),
385 Self::Destination => Some(Box::new(destination::DestinationDispatch::new())),
386 // `Default` ships with the tuned penalty stack; `new()` is
387 // the zero baseline for additive-composition tests. The
388 // playground's "RSR" dropdown entry should map to the
389 // actual strategy, not to NearestCar-in-disguise, so use
390 // `Default` here.
391 Self::Rsr => Some(Box::new(rsr::RsrDispatch::default())),
392 #[cfg(feature = "loop_lines")]
393 Self::LoopSweep => Some(Box::new(loop_sweep::LoopSweepDispatch::new())),
394 Self::Custom(_) => None,
395 }
396 }
397}
398
399/// Decision returned by a dispatch strategy.
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
401#[non_exhaustive]
402pub enum DispatchDecision {
403 /// Go to the specified stop entity.
404 GoToStop(EntityId),
405 /// Remain idle.
406 Idle,
407}
408
409/// Per-line relationship data within an [`ElevatorGroup`].
410///
411/// This is a denormalized cache maintained by [`Simulation`](crate::sim::Simulation).
412/// The source of truth for intrinsic line properties is the
413/// [`Line`](crate::components::Line) component in World.
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct LineInfo {
416 /// Line entity ID.
417 entity: EntityId,
418 /// Elevator entities on this line.
419 elevators: Vec<EntityId>,
420 /// Stop entities served by this line.
421 serves: Vec<EntityId>,
422}
423
424impl LineInfo {
425 /// Create a new `LineInfo`.
426 #[must_use]
427 pub const fn new(entity: EntityId, elevators: Vec<EntityId>, serves: Vec<EntityId>) -> Self {
428 Self {
429 entity,
430 elevators,
431 serves,
432 }
433 }
434
435 /// Line entity ID.
436 #[must_use]
437 pub const fn entity(&self) -> EntityId {
438 self.entity
439 }
440
441 /// Elevator entities on this line.
442 #[must_use]
443 pub fn elevators(&self) -> &[EntityId] {
444 &self.elevators
445 }
446
447 /// Stop entities served by this line.
448 #[must_use]
449 pub fn serves(&self) -> &[EntityId] {
450 &self.serves
451 }
452
453 /// Set the line entity ID (used during snapshot restore).
454 pub(crate) const fn set_entity(&mut self, entity: EntityId) {
455 self.entity = entity;
456 }
457
458 /// Add an elevator to this line, deduplicating against existing entries.
459 ///
460 /// Returns `true` if the elevator was inserted, `false` if it was
461 /// already present. Replaces direct `&mut Vec` access so callers
462 /// can't introduce duplicates the dedup invariants in
463 /// [`ElevatorGroup::rebuild_caches`] rely on.
464 pub(crate) fn add_elevator(&mut self, elevator: EntityId) -> bool {
465 if self.elevators.contains(&elevator) {
466 false
467 } else {
468 self.elevators.push(elevator);
469 true
470 }
471 }
472
473 /// Remove an elevator from this line.
474 ///
475 /// Returns `true` if the elevator was present and removed, `false`
476 /// if it was absent.
477 pub(crate) fn remove_elevator(&mut self, elevator: EntityId) -> bool {
478 let len_before = self.elevators.len();
479 self.elevators.retain(|&e| e != elevator);
480 self.elevators.len() != len_before
481 }
482
483 /// Add a stop to this line's served list, deduplicating against
484 /// existing entries.
485 ///
486 /// Returns `true` if the stop was inserted, `false` if it was
487 /// already present.
488 pub(crate) fn add_stop(&mut self, stop: EntityId) -> bool {
489 if self.serves.contains(&stop) {
490 false
491 } else {
492 self.serves.push(stop);
493 true
494 }
495 }
496
497 /// Remove a stop from this line's served list.
498 ///
499 /// Returns `true` if the stop was present and removed, `false`
500 /// if it was absent.
501 pub(crate) fn remove_stop(&mut self, stop: EntityId) -> bool {
502 let len_before = self.serves.len();
503 self.serves.retain(|&s| s != stop);
504 self.serves.len() != len_before
505 }
506}
507
508/// How hall calls expose rider destinations to dispatch.
509///
510/// Different building eras and controller designs reveal destinations
511/// at different moments. Groups pick a mode so the sim can model both
512/// traditional up/down collective-control elevators and modern
513/// destination-dispatch lobby kiosks within the same simulation.
514///
515/// Stops are expected to belong to exactly one group. When a stop
516/// overlaps multiple groups, the hall-call press consults the first
517/// group containing it (iteration order over
518/// [`Simulation::groups`](crate::sim::Simulation::groups)), which in
519/// turn determines the `HallCallMode` and ack latency applied to that
520/// call. Overlapping topologies are not validated at construction
521/// time; games that need them should be aware of this first-match
522/// rule.
523#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
524#[non_exhaustive]
525pub enum HallCallMode {
526 /// Traditional collective-control ("classic" Otis/Westinghouse).
527 ///
528 /// Riders press an up or down button in the hall; the destination
529 /// is revealed only *after* boarding, via a
530 /// [`CarCall`](crate::components::CarCall). Dispatch sees a direction
531 /// per call but does not know individual rider destinations until
532 /// they're aboard.
533 #[default]
534 Classic,
535 /// Modern destination dispatch ("DCS" — Otis `CompassPlus`, KONE
536 /// Polaris, Schindler PORT).
537 ///
538 /// Riders enter their destination at a hall kiosk, so each
539 /// [`HallCall`](crate::components::HallCall) carries a destination
540 /// stop from the moment it's pressed. Required by
541 /// [`DestinationDispatch`].
542 Destination,
543}
544
545impl std::fmt::Display for HallCallMode {
546 /// ```
547 /// # use elevator_core::dispatch::HallCallMode;
548 /// assert_eq!(format!("{}", HallCallMode::Classic), "classic");
549 /// assert_eq!(format!("{}", HallCallMode::Destination), "destination");
550 /// ```
551 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552 match self {
553 Self::Classic => f.write_str("classic"),
554 Self::Destination => f.write_str("destination"),
555 }
556 }
557}
558
559/// Runtime elevator group: a set of lines sharing a dispatch strategy.
560///
561/// A group is the logical dispatch unit. It contains one or more
562/// [`LineInfo`] entries, each representing a physical path with its
563/// elevators and served stops.
564///
565/// The flat `elevator_entities` and `stop_entities` fields are derived
566/// caches (union of all lines' elevators/stops), rebuilt automatically
567/// via [`rebuild_caches()`](Self::rebuild_caches).
568#[derive(Debug, Clone, Serialize, Deserialize)]
569pub struct ElevatorGroup {
570 /// Unique group identifier.
571 id: GroupId,
572 /// Human-readable group name.
573 name: String,
574 /// Lines belonging to this group.
575 lines: Vec<LineInfo>,
576 /// How hall calls reveal destinations to dispatch (Classic vs DCS).
577 hall_call_mode: HallCallMode,
578 /// Ticks between a button press and dispatch first seeing the call.
579 /// `0` = immediate (current behavior). Realistic values: 5–30 ticks
580 /// at 60 Hz, modeling controller processing latency.
581 ack_latency_ticks: u32,
582 /// Derived flat cache — rebuilt by `rebuild_caches()`.
583 elevator_entities: Vec<EntityId>,
584 /// Derived flat cache — rebuilt by `rebuild_caches()`.
585 stop_entities: Vec<EntityId>,
586}
587
588impl ElevatorGroup {
589 /// Create a new group with the given lines. Caches are built automatically.
590 /// Defaults: [`HallCallMode::Classic`], `ack_latency_ticks = 0`.
591 #[must_use]
592 pub fn new(id: GroupId, name: String, lines: Vec<LineInfo>) -> Self {
593 let mut group = Self {
594 id,
595 name,
596 lines,
597 hall_call_mode: HallCallMode::default(),
598 ack_latency_ticks: 0,
599 elevator_entities: Vec::new(),
600 stop_entities: Vec::new(),
601 };
602 group.rebuild_caches();
603 group
604 }
605
606 /// Override the hall call mode for this group.
607 #[must_use]
608 pub const fn with_hall_call_mode(mut self, mode: HallCallMode) -> Self {
609 self.hall_call_mode = mode;
610 self
611 }
612
613 /// Override the ack latency for this group.
614 #[must_use]
615 pub const fn with_ack_latency_ticks(mut self, ticks: u32) -> Self {
616 self.ack_latency_ticks = ticks;
617 self
618 }
619
620 /// Set the hall call mode in-place (for mutation via
621 /// [`Simulation::groups_mut`](crate::sim::Simulation::groups_mut)).
622 pub const fn set_hall_call_mode(&mut self, mode: HallCallMode) {
623 self.hall_call_mode = mode;
624 }
625
626 /// Set the ack latency in-place.
627 pub const fn set_ack_latency_ticks(&mut self, ticks: u32) {
628 self.ack_latency_ticks = ticks;
629 }
630
631 /// Hall call mode for this group.
632 #[must_use]
633 pub const fn hall_call_mode(&self) -> HallCallMode {
634 self.hall_call_mode
635 }
636
637 /// Controller ack latency for this group.
638 #[must_use]
639 pub const fn ack_latency_ticks(&self) -> u32 {
640 self.ack_latency_ticks
641 }
642
643 /// Unique group identifier.
644 #[must_use]
645 pub const fn id(&self) -> GroupId {
646 self.id
647 }
648
649 /// Human-readable group name.
650 #[must_use]
651 pub fn name(&self) -> &str {
652 &self.name
653 }
654
655 /// Lines belonging to this group.
656 #[must_use]
657 pub fn lines(&self) -> &[LineInfo] {
658 &self.lines
659 }
660
661 /// Mutable access to lines (call [`rebuild_caches()`](Self::rebuild_caches) after mutating).
662 pub const fn lines_mut(&mut self) -> &mut Vec<LineInfo> {
663 &mut self.lines
664 }
665
666 /// Elevator entities belonging to this group (derived from lines).
667 #[must_use]
668 pub fn elevator_entities(&self) -> &[EntityId] {
669 &self.elevator_entities
670 }
671
672 /// Stop entities served by this group (derived from lines, deduplicated).
673 #[must_use]
674 pub fn stop_entities(&self) -> &[EntityId] {
675 &self.stop_entities
676 }
677
678 /// Whether this group can serve a rider on `leg`. A `Group(g)` leg
679 /// matches by group id; a `Line(l)` leg matches if `l` belongs to
680 /// this group; `Walk` never rides an elevator.
681 #[must_use]
682 pub fn accepts_leg(&self, leg: &crate::components::RouteLeg) -> bool {
683 match leg.via {
684 crate::components::TransportMode::Group(g) => g == self.id,
685 crate::components::TransportMode::Line(l) => {
686 self.lines.iter().any(|li| li.entity() == l)
687 }
688 crate::components::TransportMode::Walk => false,
689 }
690 }
691
692 /// Push a stop entity directly into the group's stop cache.
693 ///
694 /// Use when a stop belongs to the group for dispatch purposes but is
695 /// not (yet) assigned to any line. Call `add_stop_to_line` later to
696 /// wire it into the topology graph.
697 pub(crate) fn push_stop(&mut self, stop: EntityId) {
698 if !self.stop_entities.contains(&stop) {
699 self.stop_entities.push(stop);
700 }
701 }
702
703 /// Push an elevator entity directly into the group's elevator cache
704 /// (in addition to the line it belongs to).
705 pub(crate) fn push_elevator(&mut self, elevator: EntityId) {
706 if !self.elevator_entities.contains(&elevator) {
707 self.elevator_entities.push(elevator);
708 }
709 }
710
711 /// Rebuild derived caches from lines. Call after mutating lines.
712 pub fn rebuild_caches(&mut self) {
713 self.elevator_entities = self
714 .lines
715 .iter()
716 .flat_map(|li| li.elevators.iter().copied())
717 .collect();
718 let mut stops: Vec<EntityId> = self
719 .lines
720 .iter()
721 .flat_map(|li| li.serves.iter().copied())
722 .collect();
723 stops.sort_unstable();
724 stops.dedup();
725 self.stop_entities = stops;
726 }
727}
728
729/// Look up the `serves` list for an elevator's line.
730///
731/// Walks `groups` to find the [`LineInfo`] whose entity matches the
732/// car's current `line()`. Returns `None` if the car has no line
733/// registered in any group (an inconsistent state — should be
734/// unreachable in a healthy sim).
735///
736/// Helper for callers of
737/// [`World::find_stop_at_position_in`](crate::world::World::find_stop_at_position_in)
738/// that already have group context: `find_stop_at_position(pos)` is
739/// global (any line wins) and ambiguous when two lines share a
740/// position; passing the elevator's serves list scopes the lookup to
741/// *its* line.
742///
743/// Cost: `O(groups × lines_per_group)` per call. For loops over many
744/// elevators per tick, prefer [`build_line_serves_index`] +
745/// [`elevator_line_serves_indexed`] to amortize the line walk.
746#[must_use]
747pub fn elevator_line_serves<'a>(
748 world: &World,
749 groups: &'a [ElevatorGroup],
750 elevator: EntityId,
751) -> Option<&'a [EntityId]> {
752 let line_eid = world.elevator(elevator)?.line();
753 groups
754 .iter()
755 .flat_map(ElevatorGroup::lines)
756 .find(|li| li.entity() == line_eid)
757 .map(LineInfo::serves)
758}
759
760/// Pre-built index mapping each line entity to its `serves` slice.
761/// Built once with [`build_line_serves_index`]; queried with
762/// [`elevator_line_serves_indexed`] for O(1) per-elevator lookup.
763pub type LineServesIndex<'a> = std::collections::HashMap<EntityId, &'a [EntityId]>;
764
765/// Build a [`LineServesIndex`] from the group list. O(groups × lines).
766/// Call once per substep / system and reuse across the elevator loop.
767#[must_use]
768pub fn build_line_serves_index(groups: &[ElevatorGroup]) -> LineServesIndex<'_> {
769 let mut idx: LineServesIndex<'_> = std::collections::HashMap::new();
770 for li in groups.iter().flat_map(ElevatorGroup::lines) {
771 idx.insert(li.entity(), li.serves());
772 }
773 idx
774}
775
776/// Indexed variant of [`elevator_line_serves`]. O(1) per call given
777/// a pre-built [`LineServesIndex`].
778#[must_use]
779pub fn elevator_line_serves_indexed<'a>(
780 world: &World,
781 index: &LineServesIndex<'a>,
782 elevator: EntityId,
783) -> Option<&'a [EntityId]> {
784 let line_eid = world.elevator(elevator)?.line();
785 index.get(&line_eid).copied()
786}
787
788/// On a loop, the served stop that comes immediately *after* `position`
789/// in forward cyclic order.
790///
791/// Walks `served_stops`, computes the forward cyclic distance from
792/// `position` to each, and returns the entity with the smallest non-zero
793/// distance. A stop coincident with `position` is treated as a "full lap
794/// ahead" (returns `circumference` for that stop) so callers already at
795/// a stop never get the same stop back as "next" — they get the *next*
796/// distinct stop forward.
797///
798/// Returns `None` if `served_stops` is empty, every stop is missing a
799/// position component, or `position` / `circumference` is non-finite or
800/// non-positive. This is a building block shared by
801/// [`Simulation::loop_next_stop`](crate::sim::Simulation::loop_next_stop)
802/// and the door FSM's loop-continuation path so both pick the same stop
803/// when given the same inputs. Always available regardless of the
804/// `loop_lines` feature flag — `LineKind::Loop` deserialization rejects
805/// when the feature is off, so a Linear-only sim simply never reaches a
806/// caller with a positive `circumference`.
807#[must_use]
808pub(crate) fn loop_next_stop_forward(
809 world: &World,
810 circumference: f64,
811 served_stops: &[EntityId],
812 position: f64,
813) -> Option<EntityId> {
814 if !position.is_finite() || !circumference.is_finite() || circumference <= 0.0 {
815 return None;
816 }
817 if served_stops.is_empty() {
818 return None;
819 }
820
821 let mut best: Option<(f64, EntityId)> = None;
822 for &stop_eid in served_stops {
823 let Some(stop_pos) = world.stop_position(stop_eid) else {
824 continue;
825 };
826 let mut d = crate::components::cyclic::forward_distance(position, stop_pos, circumference);
827 if d <= 1e-9 {
828 d = circumference;
829 }
830 match best {
831 Some((d_best, _)) if d_best <= d => {}
832 _ => best = Some((d, stop_eid)),
833 }
834 }
835 best.map(|(_, eid)| eid)
836}
837
838/// Resolve the forward-next stop for a Loop car.
839///
840/// Returns `None` for Linear cars, missing line components, missing
841/// position components, and any `LineKind::Loop` whose served-stop list
842/// is empty (which construction validation rejects, so this is purely
843/// defensive). Shared between the dispatch kickstart and the door-FSM
844/// continuation path so both pick the same stop given identical world
845/// state.
846#[must_use]
847pub(crate) fn loop_next_stop_for_car(
848 world: &World,
849 groups: &[ElevatorGroup],
850 elevator: EntityId,
851) -> Option<EntityId> {
852 let car = world.elevator(elevator)?;
853 let line_eid = car.line();
854 let circumference = world
855 .line(line_eid)
856 .and_then(crate::components::Line::circumference)?;
857 let serves = elevator_line_serves(world, groups, elevator)?;
858 let position = world.position(elevator)?.value;
859 loop_next_stop_forward(world, circumference, serves, position)
860}
861
862/// Context passed to [`DispatchStrategy::rank`].
863///
864/// Bundles the per-call arguments into a single struct so future context
865/// fields can be added without breaking existing trait implementations.
866#[non_exhaustive]
867pub struct RankContext<'a> {
868 /// The elevator being evaluated.
869 pub car: EntityId,
870 /// The stop being evaluated as a candidate destination.
871 pub stop: EntityId,
872 /// The dispatch group this assignment belongs to.
873 pub group: &'a ElevatorGroup,
874 /// Demand snapshot for the current dispatch pass.
875 pub manifest: &'a DispatchManifest,
876 /// Read-only world state.
877 pub world: &'a World,
878}
879
880impl RankContext<'_> {
881 /// Position of [`car`](Self::car) along the shaft axis.
882 ///
883 /// Returns `0.0` for an entity that has no `Position` component
884 /// (which would never reach this method through normal dispatch
885 /// — `compute_assignments` filters out cars without positions
886 /// upstream — but the defensive default protects custom callers).
887 /// Derived from [`world`](Self::world) on each call: the dispatch
888 /// loop never moves elevators between rank calls, so re-deriving
889 /// is free, and skipping the duplicate field eliminates the
890 /// synchronisation risk of the old shape.
891 #[must_use]
892 pub fn car_position(&self) -> f64 {
893 self.world.position(self.car).map_or(0.0, |p| p.value)
894 }
895
896 /// Position of [`stop`](Self::stop) along the shaft axis.
897 ///
898 /// Returns `0.0` for an entity that has no `Stop` component (same
899 /// rationale as [`car_position`](Self::car_position)).
900 #[must_use]
901 pub fn stop_position(&self) -> f64 {
902 self.world.stop_position(self.stop).unwrap_or(0.0)
903 }
904}
905
906impl std::fmt::Debug for RankContext<'_> {
907 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
908 f.debug_struct("RankContext")
909 .field("car", &self.car)
910 .field("car_position", &self.car_position())
911 .field("stop", &self.stop)
912 .field("stop_position", &self.stop_position())
913 .field("group", &self.group)
914 .field("manifest", &self.manifest)
915 .field("world", &"World { .. }")
916 .finish()
917 }
918}
919
920/// Pluggable dispatch algorithm.
921///
922/// Strategies implement [`rank`](Self::rank) to score each `(car, stop)`
923/// pair; the dispatch system then performs an optimal assignment across
924/// the whole group, guaranteeing that no two cars are sent to the same
925/// hall call.
926///
927/// Returning `None` from `rank` excludes a pair from assignment — useful
928/// for capacity limits, direction preferences, restricted stops, or
929/// sticky commitments.
930///
931/// Cars that receive no stop fall through to [`fallback`](Self::fallback),
932/// which returns the policy for that car (idle, park, etc.).
933pub trait DispatchStrategy: Send + Sync {
934 /// Optional hook called once per group before the assignment pass.
935 ///
936 /// Strategies that need to mutate [`World`] extension storage (e.g.
937 /// [`DestinationDispatch`] writing sticky rider → car assignments)
938 /// or pre-populate [`crate::components::DestinationQueue`] entries
939 /// override this. Default: no-op.
940 fn pre_dispatch(
941 &mut self,
942 _group: &ElevatorGroup,
943 _manifest: &DispatchManifest,
944 _world: &mut World,
945 ) {
946 }
947
948 /// Optional hook called once per candidate car, before any
949 /// [`rank`](Self::rank) calls for that car in the current pass.
950 ///
951 /// Strategies whose ranking depends on stable per-car state (e.g. the
952 /// sweep direction used by SCAN/LOOK) set that state here so later
953 /// `rank` calls see a consistent view regardless of iteration order.
954 /// The default is a no-op.
955 fn prepare_car(
956 &mut self,
957 _car: EntityId,
958 _car_position: f64,
959 _group: &ElevatorGroup,
960 _manifest: &DispatchManifest,
961 _world: &World,
962 ) {
963 }
964
965 /// Score the cost of sending `car` to `stop`. Lower is better.
966 ///
967 /// Returning `None` marks this `(car, stop)` pair as unavailable;
968 /// the assignment algorithm will never pair them. Use this for
969 /// capacity limits, wrong-direction stops, stops outside the line's
970 /// topology, or pairs already committed via a sticky assignment.
971 ///
972 /// Must return a finite, non-negative value if `Some` — infinities
973 /// and NaN can destabilize the underlying Hungarian solver.
974 ///
975 /// Takes `&self` so the assignment loop can score `(car, stop)` pairs
976 /// in any order without producing an asymmetric cost matrix. Compute
977 /// any per-car scratch in [`prepare_car`](Self::prepare_car) (which
978 /// takes `&mut self`) before this method is called.
979 fn rank(&self, ctx: &RankContext<'_>) -> Option<f64>;
980
981 /// Decide what an idle car should do when no stop was assigned to it.
982 ///
983 /// Called for each car the assignment phase could not pair with a
984 /// stop (because there were no stops, or all candidate stops had
985 /// rank `None` for this car). Default: [`DispatchDecision::Idle`].
986 fn fallback(
987 &mut self,
988 _car: EntityId,
989 _car_position: f64,
990 _group: &ElevatorGroup,
991 _manifest: &DispatchManifest,
992 _world: &World,
993 ) -> DispatchDecision {
994 DispatchDecision::Idle
995 }
996
997 /// Notify the strategy that an elevator has been removed.
998 ///
999 /// Implementations with per-elevator state (e.g. direction tracking)
1000 /// should clean up here to prevent unbounded memory growth.
1001 fn notify_removed(&mut self, _elevator: EntityId) {}
1002
1003 /// If this strategy is a known built-in variant, return it so
1004 /// [`Simulation::new`](crate::sim::Simulation::new) can stamp the
1005 /// correct [`BuiltinStrategy`] into the group's snapshot identity.
1006 ///
1007 /// Without this, legacy-topology sims constructed via
1008 /// `Simulation::new(config, SomeNonScanStrategy::new())` silently
1009 /// recorded `BuiltinStrategy::Scan` as their identity — so a
1010 /// snapshot round-trip replaced the running strategy with Scan
1011 /// and produced different dispatch decisions post-restore
1012 /// (determinism regression).
1013 ///
1014 /// Default: `None` (unidentified — the constructor falls back to
1015 /// recording [`BuiltinStrategy::Scan`], matching pre-fix behaviour
1016 /// for callers that never cared about round-trip identity). Custom
1017 /// strategies that DO care should override this to return
1018 /// [`BuiltinStrategy::Custom`] with a stable name.
1019 #[must_use]
1020 fn builtin_id(&self) -> Option<BuiltinStrategy> {
1021 None
1022 }
1023
1024 /// Serialize this strategy's tunable configuration to a string
1025 /// that [`restore_config`](Self::restore_config) can apply to a
1026 /// freshly-instantiated instance.
1027 ///
1028 /// Returning `Some(..)` makes the configuration survive snapshot
1029 /// round-trip: without it, [`crate::snapshot::WorldSnapshot::restore`]
1030 /// instantiates each built-in via [`BuiltinStrategy::instantiate`],
1031 /// which calls `::new()` with default weights — silently dropping
1032 /// any tuning applied via `with_*` builder methods (e.g.
1033 /// `EtdDispatch::with_delay_weight(2.5)` degrades to the default
1034 /// `1.0` on the restored sim).
1035 ///
1036 /// Default: `None` (no configuration to save). Built-ins with
1037 /// tunable weights override to return a RON-serialized copy of
1038 /// themselves; strategies with transient per-pass scratch should
1039 /// use `#[serde(skip)]` on those fields so the snapshot stays
1040 /// compact and deterministic.
1041 #[must_use]
1042 fn snapshot_config(&self) -> Option<String> {
1043 None
1044 }
1045
1046 /// Restore tunable configuration from a string previously produced
1047 /// by [`snapshot_config`](Self::snapshot_config) on the same
1048 /// strategy variant. Called by
1049 /// [`crate::snapshot::WorldSnapshot::restore`] immediately after
1050 /// [`BuiltinStrategy::instantiate`] builds the default instance,
1051 /// so the restore writes over the defaults.
1052 ///
1053 /// # Errors
1054 /// Returns the underlying parse error as a `String` when the
1055 /// serialized form doesn't round-trip. Default implementation
1056 /// ignores the argument and returns `Ok(())` — paired with the
1057 /// `None` default of `snapshot_config`, this means strategies that
1058 /// don't override either method skip configuration round-trip,
1059 /// matching pre-fix behaviour.
1060 fn restore_config(&mut self, _serialized: &str) -> Result<(), String> {
1061 Ok(())
1062 }
1063
1064 /// Maximum candidate stops the assignment phase considers per car.
1065 ///
1066 /// `Some(K)` keeps only the K nearest viable pending stops per
1067 /// idle car when filling the cost matrix; the rest are sentinel-
1068 /// scored so the Hungarian skips them. `None` disables pruning
1069 /// (full matrix). The default is `Some(50)` — generous enough to
1070 /// preserve optimality on real-building loads (≤200 stops, ≤50
1071 /// cars) while cutting per-cell `rank()` calls ~90× at extreme
1072 /// scale (5000 stops × 500 cars). Researchers and tests asserting
1073 /// global-optimal assignments can opt out via the strategy's
1074 /// `with_candidate_limit(None)` builder.
1075 ///
1076 /// "Nearest" here means absolute axial distance
1077 /// (`|car_pos - stop_pos|`) with `(distance, EntityId)` tie-break
1078 /// for snapshot-determinism. Line-restriction and
1079 /// [`Elevator::restricted_stops`](crate::components::Elevator::restricted_stops)
1080 /// filtering happens *before* the top-K cut, so a car always
1081 /// sees up to K *viable* candidates rather than K nominal ones
1082 /// of which most are unreachable.
1083 ///
1084 /// Strategies that don't go through the Hungarian path
1085 /// ([`scan::ScanDispatch`], [`look::LookDispatch`],
1086 /// [`nearest_car::NearestCarDispatch`]) inherit the default but
1087 /// it's a no-op for them — their per-car `rank` is independent
1088 /// of matrix size.
1089 #[must_use]
1090 fn candidate_limit(&self) -> Option<usize> {
1091 Some(DEFAULT_CANDIDATE_LIMIT)
1092 }
1093}
1094
1095/// Default per-car candidate limit applied by the
1096/// [`DispatchStrategy::candidate_limit`] trait default.
1097///
1098/// Strategies that build with a custom limit override the trait method
1099/// to return their stored value; opting out (`None`) disables pruning
1100/// entirely.
1101pub const DEFAULT_CANDIDATE_LIMIT: usize = 50;
1102
1103/// Pluggable strategy for repositioning idle elevators.
1104///
1105/// After the dispatch phase, elevators that remain idle (no pending
1106/// assignments) are candidates for repositioning. The strategy decides
1107/// where each idle elevator should move to improve coverage and reduce
1108/// expected response times.
1109///
1110/// Implementations receive the set of idle elevator positions and the
1111/// group's stop positions, then return a target stop for each elevator
1112/// (or `None` to leave it in place).
1113pub trait RepositionStrategy: Send + Sync {
1114 /// Decide where to reposition idle elevators.
1115 ///
1116 /// Push `(elevator_entity, target_stop_entity)` pairs into `out`.
1117 /// The buffer is cleared before each call — implementations should
1118 /// only push, never read prior contents. Elevators not pushed remain idle.
1119 fn reposition(
1120 &mut self,
1121 idle_elevators: &[(EntityId, f64)],
1122 stop_positions: &[(EntityId, f64)],
1123 group: &ElevatorGroup,
1124 world: &World,
1125 out: &mut Vec<(EntityId, EntityId)>,
1126 );
1127
1128 /// If this strategy is a known built-in variant, return it so
1129 /// [`Simulation::set_reposition`](crate::sim::Simulation::set_reposition)
1130 /// callers don't have to pass a separate [`BuiltinReposition`] id
1131 /// that might drift from the dispatcher's actual type.
1132 ///
1133 /// Mirrors the pattern introduced for [`DispatchStrategy::builtin_id`]
1134 /// in #410: the runtime impl identifies itself so the snapshot
1135 /// identity always matches the executing behaviour, instead of
1136 /// depending on the caller to keep two parameters consistent.
1137 /// Default `None` — custom strategies should override to return
1138 /// [`BuiltinReposition::Custom`] with a stable name for snapshot
1139 /// fidelity.
1140 #[must_use]
1141 fn builtin_id(&self) -> Option<BuiltinReposition> {
1142 None
1143 }
1144
1145 /// Minimum [`ArrivalLog`](crate::arrival_log::ArrivalLog) retention
1146 /// (in ticks) the strategy needs to function. Strategies that read
1147 /// the log directly with a custom rolling window must override this
1148 /// so [`Simulation::set_reposition`](crate::sim::Simulation::set_reposition)
1149 /// can widen
1150 /// [`ArrivalLogRetention`](crate::arrival_log::ArrivalLogRetention)
1151 /// to keep the data alive long enough for the query.
1152 ///
1153 /// Default `0` — strategies that don't read the arrival log (or that
1154 /// only consume it through [`DispatchManifest::arrivals_at`], which
1155 /// already tracks retention) impose no requirement.
1156 #[must_use]
1157 fn min_arrival_log_window(&self) -> u64 {
1158 0
1159 }
1160}
1161
1162/// Serializable identifier for built-in repositioning strategies.
1163///
1164/// Used in config and snapshots to restore the correct strategy.
1165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1166#[non_exhaustive]
1167pub enum BuiltinReposition {
1168 /// Distribute idle elevators evenly across stops.
1169 SpreadEvenly,
1170 /// Return idle elevators to a configured home stop.
1171 ReturnToLobby,
1172 /// Position near stops with historically high demand.
1173 DemandWeighted,
1174 /// Keep idle elevators where they are (no-op).
1175 NearestIdle,
1176 /// Pre-position cars near stops with the highest recent arrival rate.
1177 PredictiveParking,
1178 /// Mode-gated: picks between `ReturnToLobby` / `PredictiveParking`
1179 /// based on the current `TrafficDetector` mode.
1180 Adaptive,
1181 /// Custom strategy identified by name.
1182 Custom(String),
1183}
1184
1185impl std::fmt::Display for BuiltinReposition {
1186 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1187 match self {
1188 Self::SpreadEvenly => write!(f, "SpreadEvenly"),
1189 Self::ReturnToLobby => write!(f, "ReturnToLobby"),
1190 Self::DemandWeighted => write!(f, "DemandWeighted"),
1191 Self::NearestIdle => write!(f, "NearestIdle"),
1192 Self::PredictiveParking => write!(f, "PredictiveParking"),
1193 Self::Adaptive => write!(f, "Adaptive"),
1194 Self::Custom(name) => write!(f, "Custom({name})"),
1195 }
1196 }
1197}
1198
1199impl BuiltinReposition {
1200 /// Instantiate the reposition strategy for this variant.
1201 ///
1202 /// Returns `None` for `Custom` — the game must provide those via
1203 /// a factory function. `ReturnToLobby` uses stop index 0 as default.
1204 #[must_use]
1205 pub fn instantiate(&self) -> Option<Box<dyn RepositionStrategy>> {
1206 match self {
1207 Self::SpreadEvenly => Some(Box::new(reposition::SpreadEvenly)),
1208 Self::ReturnToLobby => Some(Box::new(reposition::ReturnToLobby::new())),
1209 Self::DemandWeighted => Some(Box::new(reposition::DemandWeighted)),
1210 Self::NearestIdle => Some(Box::new(reposition::NearestIdle)),
1211 Self::PredictiveParking => Some(Box::new(reposition::PredictiveParking::new())),
1212 Self::Adaptive => Some(Box::new(reposition::AdaptiveParking::new())),
1213 Self::Custom(_) => None,
1214 }
1215 }
1216}