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/// Whether a waiting rider could actually board this car, matching the
131/// same filters the loading phase applies. Prevents `pair_can_do_work`
132/// from approving a pickup whose only demand is direction-filtered or
133/// over-capacity — the loading phase would reject the rider, doors
134/// would cycle, and dispatch would re-pick the zero-cost self-pair.
135fn rider_can_board(
136 rider: &RiderInfo,
137 car: &crate::components::Elevator,
138 ctx: &RankContext<'_>,
139 remaining_capacity: f64,
140) -> bool {
141 if rider.weight.value() > remaining_capacity {
142 return false;
143 }
144 // Match `systems::loading`'s direction filter: a rider whose trip
145 // goes the opposite way of the car's committed direction will not
146 // be boarded. An unknown destination (no route yet) is treated as
147 // unconstrained — let the rider through and let the loading phase
148 // make the final call.
149 let Some(dest) = rider.destination else {
150 return true;
151 };
152 let Some(dest_pos) = ctx.world.stop_position(dest) else {
153 return true;
154 };
155 if dest_pos > ctx.stop_position && !car.going_up() {
156 return false;
157 }
158 if dest_pos < ctx.stop_position && !car.going_down() {
159 return false;
160 }
161 true
162}
163
164/// True when a full-load bypass applies: the car has a configured
165/// threshold for its current travel direction, is above that threshold,
166/// and the candidate stop lies in that same direction.
167fn bypass_in_current_direction(car: &crate::components::Elevator, ctx: &RankContext<'_>) -> bool {
168 // Derive travel direction from the car's current target, if any.
169 // An Idle or Stopped car has no committed direction → no bypass.
170 let Some(target) = car.phase().moving_target() else {
171 return false;
172 };
173 let Some(target_pos) = ctx.world.stop_position(target) else {
174 return false;
175 };
176 let going_up = target_pos > ctx.car_position;
177 let going_down = target_pos < ctx.car_position;
178 if !going_up && !going_down {
179 return false;
180 }
181 let threshold = if going_up {
182 car.bypass_load_up_pct()
183 } else {
184 car.bypass_load_down_pct()
185 };
186 let Some(pct) = threshold else {
187 return false;
188 };
189 let capacity = car.weight_capacity().value();
190 if capacity <= 0.0 {
191 return false;
192 }
193 let load_ratio = car.current_load().value() / capacity;
194 if load_ratio < pct {
195 return false;
196 }
197 // Only same-direction pickups get bypassed.
198 let stop_above = ctx.stop_position > ctx.car_position;
199 let stop_below = ctx.stop_position < ctx.car_position;
200 (going_up && stop_above) || (going_down && stop_below)
201}
202
203/// Metadata about a single rider, available to dispatch strategies.
204#[derive(Debug, Clone)]
205#[non_exhaustive]
206pub struct RiderInfo {
207 /// Rider entity ID.
208 pub id: EntityId,
209 /// Rider's destination stop entity (from route).
210 pub destination: Option<EntityId>,
211 /// Rider weight.
212 pub weight: Weight,
213 /// Ticks this rider has been waiting (0 if riding).
214 pub wait_ticks: u64,
215}
216
217/// Full demand picture for dispatch decisions.
218///
219/// Contains per-rider metadata grouped by stop, enabling entity-aware
220/// dispatch strategies (priority, weight-aware, VIP-first, etc.).
221///
222/// Uses `BTreeMap` for deterministic iteration order.
223#[derive(Debug, Clone, Default)]
224pub struct DispatchManifest {
225 /// Riders waiting at each stop, with full per-rider metadata.
226 pub(crate) waiting_at_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
227 /// Riders currently aboard elevators, grouped by their destination stop.
228 pub(crate) riding_to_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
229 /// Number of residents at each stop (read-only hint for dispatch strategies).
230 pub(crate) resident_count_at_stop: BTreeMap<EntityId, usize>,
231 /// Pending hall calls at each stop — at most two entries per stop
232 /// (one per [`CallDirection`]). Populated only for stops served by
233 /// the group being dispatched. Strategies read this to rank based on
234 /// call age, pending-rider count, pin flags, or DCS destinations.
235 pub(crate) hall_calls_at_stop: BTreeMap<EntityId, Vec<HallCall>>,
236 /// Floor buttons pressed inside each car in the group. Keyed by car
237 /// entity. Strategies read this to plan intermediate stops without
238 /// poking into `World` directly.
239 pub(crate) car_calls_by_car: BTreeMap<EntityId, Vec<CarCall>>,
240 /// Recent arrivals per stop, counted over
241 /// [`DispatchManifest::arrival_window_ticks`] ticks. Populated from
242 /// the [`crate::arrival_log::ArrivalLog`] world resource each pass
243 /// so strategies can read a traffic-rate signal without touching
244 /// world state directly.
245 pub(crate) arrivals_at_stop: BTreeMap<EntityId, u64>,
246 /// Window the `arrivals_at_stop` counts cover, in ticks. Exposed so
247 /// strategies interpreting the raw counts can convert them to a
248 /// rate (per tick or per second).
249 pub(crate) arrival_window_ticks: u64,
250}
251
252impl DispatchManifest {
253 /// Number of riders waiting at a stop.
254 #[must_use]
255 pub fn waiting_count_at(&self, stop: EntityId) -> usize {
256 self.waiting_at_stop.get(&stop).map_or(0, Vec::len)
257 }
258
259 /// Total weight of riders waiting at a stop.
260 #[must_use]
261 pub fn total_weight_at(&self, stop: EntityId) -> f64 {
262 self.waiting_at_stop
263 .get(&stop)
264 .map_or(0.0, |riders| riders.iter().map(|r| r.weight.value()).sum())
265 }
266
267 /// Number of riders heading to a stop (aboard elevators).
268 #[must_use]
269 pub fn riding_count_to(&self, stop: EntityId) -> usize {
270 self.riding_to_stop.get(&stop).map_or(0, Vec::len)
271 }
272
273 /// Whether a stop has any demand for this group: waiting riders,
274 /// riders heading there, or a *rider-less* hall call (one that
275 /// `press_hall_button` placed without a backing rider). Pre-fix
276 /// the rider-less case was invisible to every built-in dispatcher,
277 /// so explicit button presses with no associated rider went
278 /// unanswered indefinitely (#255).
279 ///
280 /// Hall calls *with* `pending_riders` are not double-counted —
281 /// those riders already appear in `waiting_count_at` for the
282 /// groups whose dispatch surface they belong to. Adding the call
283 /// to `has_demand` for *every* group that serves the stop would
284 /// pull cars from groups the rider doesn't even want, causing
285 /// open/close oscillation regression that the multi-group test
286 /// `dispatch_ignores_waiting_rider_targeting_another_group` pins.
287 #[must_use]
288 pub fn has_demand(&self, stop: EntityId) -> bool {
289 self.waiting_count_at(stop) > 0
290 || self.riding_count_to(stop) > 0
291 || self
292 .hall_calls_at_stop
293 .get(&stop)
294 .is_some_and(|calls| calls.iter().any(|c| c.pending_riders.is_empty()))
295 }
296
297 /// Number of residents at a stop (read-only hint, not active demand).
298 #[must_use]
299 pub fn resident_count_at(&self, stop: EntityId) -> usize {
300 self.resident_count_at_stop.get(&stop).copied().unwrap_or(0)
301 }
302
303 /// Rider arrivals at `stop` within the last
304 /// [`arrival_window_ticks`](Self::arrival_window_ticks) ticks. The
305 /// signal is the rolling-window per-stop arrival rate that
306 /// commercial controllers use to pick a traffic mode and that
307 /// [`crate::dispatch::reposition::PredictiveParking`] uses to
308 /// forecast demand. Unvisited stops return 0.
309 #[must_use]
310 pub fn arrivals_at(&self, stop: EntityId) -> u64 {
311 self.arrivals_at_stop.get(&stop).copied().unwrap_or(0)
312 }
313
314 /// Window size (in ticks) over which [`arrivals_at`](Self::arrivals_at)
315 /// counts events. Strategies convert counts to rates by dividing
316 /// by this.
317 #[must_use]
318 pub const fn arrival_window_ticks(&self) -> u64 {
319 self.arrival_window_ticks
320 }
321
322 /// The hall call at `(stop, direction)`, if pressed.
323 #[must_use]
324 pub fn hall_call_at(&self, stop: EntityId, direction: CallDirection) -> Option<&HallCall> {
325 self.hall_calls_at_stop
326 .get(&stop)?
327 .iter()
328 .find(|c| c.direction == direction)
329 }
330
331 /// All hall calls across every stop in the group (flattened iterator).
332 ///
333 /// No `#[must_use]` needed: `impl Iterator` already carries that
334 /// annotation, and adding our own triggers clippy's
335 /// `double_must_use` lint.
336 pub fn iter_hall_calls(&self) -> impl Iterator<Item = &HallCall> {
337 self.hall_calls_at_stop.values().flatten()
338 }
339
340 /// Floor buttons currently pressed inside `car`. Empty slice if the
341 /// car has no aboard riders or no outstanding presses.
342 #[must_use]
343 pub fn car_calls_for(&self, car: EntityId) -> &[CarCall] {
344 self.car_calls_by_car.get(&car).map_or(&[], Vec::as_slice)
345 }
346
347 /// Riders waiting at a specific stop.
348 #[must_use]
349 pub fn waiting_riders_at(&self, stop: EntityId) -> &[RiderInfo] {
350 self.waiting_at_stop.get(&stop).map_or(&[], Vec::as_slice)
351 }
352
353 /// Iterate over all `(stop, riders)` pairs with waiting demand.
354 pub fn iter_waiting_stops(&self) -> impl Iterator<Item = (&EntityId, &[RiderInfo])> {
355 self.waiting_at_stop
356 .iter()
357 .map(|(stop, riders)| (stop, riders.as_slice()))
358 }
359
360 /// Riders currently riding toward a specific stop.
361 #[must_use]
362 pub fn riding_riders_to(&self, stop: EntityId) -> &[RiderInfo] {
363 self.riding_to_stop.get(&stop).map_or(&[], Vec::as_slice)
364 }
365
366 /// Iterate over all `(stop, riders)` pairs with in-transit demand.
367 pub fn iter_riding_stops(&self) -> impl Iterator<Item = (&EntityId, &[RiderInfo])> {
368 self.riding_to_stop
369 .iter()
370 .map(|(stop, riders)| (stop, riders.as_slice()))
371 }
372
373 /// Iterate over all `(stop, hall_calls)` pairs with active calls.
374 pub fn iter_hall_call_stops(&self) -> impl Iterator<Item = (&EntityId, &[HallCall])> {
375 self.hall_calls_at_stop
376 .iter()
377 .map(|(stop, calls)| (stop, calls.as_slice()))
378 }
379}
380
381/// Serializable identifier for built-in dispatch strategies.
382///
383/// Used in snapshots and config files to restore the correct strategy
384/// without requiring the game to manually re-wire dispatch. Custom strategies
385/// are represented by the `Custom(String)` variant.
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[non_exhaustive]
388pub enum BuiltinStrategy {
389 /// SCAN (elevator) algorithm — sweeps end-to-end.
390 Scan,
391 /// LOOK algorithm — reverses at last request.
392 Look,
393 /// Nearest-car — assigns closest idle elevator.
394 NearestCar,
395 /// Estimated Time to Destination — minimizes total cost.
396 Etd,
397 /// Hall-call destination dispatch — sticky per-rider assignment.
398 Destination,
399 /// Relative System Response — additive composite of ETA, direction,
400 /// car-call affinity, and load-share terms.
401 Rsr,
402 /// Custom strategy identified by name. The game must provide a factory.
403 Custom(String),
404}
405
406impl std::fmt::Display for BuiltinStrategy {
407 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408 match self {
409 Self::Scan => write!(f, "Scan"),
410 Self::Look => write!(f, "Look"),
411 Self::NearestCar => write!(f, "NearestCar"),
412 Self::Etd => write!(f, "Etd"),
413 Self::Destination => write!(f, "Destination"),
414 Self::Rsr => write!(f, "Rsr"),
415 Self::Custom(name) => write!(f, "Custom({name})"),
416 }
417 }
418}
419
420impl BuiltinStrategy {
421 /// Instantiate the dispatch strategy for this variant.
422 ///
423 /// Returns `None` for `Custom` — the game must provide those via
424 /// a factory function.
425 #[must_use]
426 pub fn instantiate(&self) -> Option<Box<dyn DispatchStrategy>> {
427 match self {
428 Self::Scan => Some(Box::new(scan::ScanDispatch::new())),
429 Self::Look => Some(Box::new(look::LookDispatch::new())),
430 Self::NearestCar => Some(Box::new(nearest_car::NearestCarDispatch::new())),
431 Self::Etd => Some(Box::new(etd::EtdDispatch::new())),
432 Self::Destination => Some(Box::new(destination::DestinationDispatch::new())),
433 Self::Rsr => Some(Box::new(rsr::RsrDispatch::new())),
434 Self::Custom(_) => None,
435 }
436 }
437}
438
439/// Decision returned by a dispatch strategy.
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441#[non_exhaustive]
442pub enum DispatchDecision {
443 /// Go to the specified stop entity.
444 GoToStop(EntityId),
445 /// Remain idle.
446 Idle,
447}
448
449/// Per-line relationship data within an [`ElevatorGroup`].
450///
451/// This is a denormalized cache maintained by [`Simulation`](crate::sim::Simulation).
452/// The source of truth for intrinsic line properties is the
453/// [`Line`](crate::components::Line) component in World.
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct LineInfo {
456 /// Line entity ID.
457 entity: EntityId,
458 /// Elevator entities on this line.
459 elevators: Vec<EntityId>,
460 /// Stop entities served by this line.
461 serves: Vec<EntityId>,
462}
463
464impl LineInfo {
465 /// Create a new `LineInfo`.
466 #[must_use]
467 pub const fn new(entity: EntityId, elevators: Vec<EntityId>, serves: Vec<EntityId>) -> Self {
468 Self {
469 entity,
470 elevators,
471 serves,
472 }
473 }
474
475 /// Line entity ID.
476 #[must_use]
477 pub const fn entity(&self) -> EntityId {
478 self.entity
479 }
480
481 /// Elevator entities on this line.
482 #[must_use]
483 pub fn elevators(&self) -> &[EntityId] {
484 &self.elevators
485 }
486
487 /// Stop entities served by this line.
488 #[must_use]
489 pub fn serves(&self) -> &[EntityId] {
490 &self.serves
491 }
492
493 /// Set the line entity ID (used during snapshot restore).
494 pub(crate) const fn set_entity(&mut self, entity: EntityId) {
495 self.entity = entity;
496 }
497
498 /// Mutable access to elevator entities on this line.
499 pub(crate) const fn elevators_mut(&mut self) -> &mut Vec<EntityId> {
500 &mut self.elevators
501 }
502
503 /// Mutable access to stop entities served by this line.
504 pub(crate) const fn serves_mut(&mut self) -> &mut Vec<EntityId> {
505 &mut self.serves
506 }
507}
508
509/// How hall calls expose rider destinations to dispatch.
510///
511/// Different building eras and controller designs reveal destinations
512/// at different moments. Groups pick a mode so the sim can model both
513/// traditional up/down collective-control elevators and modern
514/// destination-dispatch lobby kiosks within the same simulation.
515///
516/// Stops are expected to belong to exactly one group. When a stop
517/// overlaps multiple groups, the hall-call press consults the first
518/// group containing it (iteration order over
519/// [`Simulation::groups`](crate::sim::Simulation::groups)), which in
520/// turn determines the `HallCallMode` and ack latency applied to that
521/// call. Overlapping topologies are not validated at construction
522/// time; games that need them should be aware of this first-match
523/// rule.
524#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
525#[non_exhaustive]
526pub enum HallCallMode {
527 /// Traditional collective-control ("classic" Otis/Westinghouse).
528 ///
529 /// Riders press an up or down button in the hall; the destination
530 /// is revealed only *after* boarding, via a
531 /// [`CarCall`]. Dispatch sees a direction
532 /// per call but does not know individual rider destinations until
533 /// they're aboard.
534 #[default]
535 Classic,
536 /// Modern destination dispatch ("DCS" — Otis `CompassPlus`, KONE
537 /// Polaris, Schindler PORT).
538 ///
539 /// Riders enter their destination at a hall kiosk, so each
540 /// [`HallCall`] carries a destination
541 /// stop from the moment it's pressed. Required by
542 /// [`DestinationDispatch`].
543 Destination,
544}
545
546/// Runtime elevator group: a set of lines sharing a dispatch strategy.
547///
548/// A group is the logical dispatch unit. It contains one or more
549/// [`LineInfo`] entries, each representing a physical path with its
550/// elevators and served stops.
551///
552/// The flat `elevator_entities` and `stop_entities` fields are derived
553/// caches (union of all lines' elevators/stops), rebuilt automatically
554/// via [`rebuild_caches()`](Self::rebuild_caches).
555#[derive(Debug, Clone, Serialize, Deserialize)]
556pub struct ElevatorGroup {
557 /// Unique group identifier.
558 id: GroupId,
559 /// Human-readable group name.
560 name: String,
561 /// Lines belonging to this group.
562 lines: Vec<LineInfo>,
563 /// How hall calls reveal destinations to dispatch (Classic vs DCS).
564 hall_call_mode: HallCallMode,
565 /// Ticks between a button press and dispatch first seeing the call.
566 /// `0` = immediate (current behavior). Realistic values: 5–30 ticks
567 /// at 60 Hz, modeling controller processing latency.
568 ack_latency_ticks: u32,
569 /// Derived flat cache — rebuilt by `rebuild_caches()`.
570 elevator_entities: Vec<EntityId>,
571 /// Derived flat cache — rebuilt by `rebuild_caches()`.
572 stop_entities: Vec<EntityId>,
573}
574
575impl ElevatorGroup {
576 /// Create a new group with the given lines. Caches are built automatically.
577 /// Defaults: [`HallCallMode::Classic`], `ack_latency_ticks = 0`.
578 #[must_use]
579 pub fn new(id: GroupId, name: String, lines: Vec<LineInfo>) -> Self {
580 let mut group = Self {
581 id,
582 name,
583 lines,
584 hall_call_mode: HallCallMode::default(),
585 ack_latency_ticks: 0,
586 elevator_entities: Vec::new(),
587 stop_entities: Vec::new(),
588 };
589 group.rebuild_caches();
590 group
591 }
592
593 /// Override the hall call mode for this group.
594 #[must_use]
595 pub const fn with_hall_call_mode(mut self, mode: HallCallMode) -> Self {
596 self.hall_call_mode = mode;
597 self
598 }
599
600 /// Override the ack latency for this group.
601 #[must_use]
602 pub const fn with_ack_latency_ticks(mut self, ticks: u32) -> Self {
603 self.ack_latency_ticks = ticks;
604 self
605 }
606
607 /// Set the hall call mode in-place (for mutation via
608 /// [`Simulation::groups_mut`](crate::sim::Simulation::groups_mut)).
609 pub const fn set_hall_call_mode(&mut self, mode: HallCallMode) {
610 self.hall_call_mode = mode;
611 }
612
613 /// Set the ack latency in-place.
614 pub const fn set_ack_latency_ticks(&mut self, ticks: u32) {
615 self.ack_latency_ticks = ticks;
616 }
617
618 /// Hall call mode for this group.
619 #[must_use]
620 pub const fn hall_call_mode(&self) -> HallCallMode {
621 self.hall_call_mode
622 }
623
624 /// Controller ack latency for this group.
625 #[must_use]
626 pub const fn ack_latency_ticks(&self) -> u32 {
627 self.ack_latency_ticks
628 }
629
630 /// Unique group identifier.
631 #[must_use]
632 pub const fn id(&self) -> GroupId {
633 self.id
634 }
635
636 /// Human-readable group name.
637 #[must_use]
638 pub fn name(&self) -> &str {
639 &self.name
640 }
641
642 /// Lines belonging to this group.
643 #[must_use]
644 pub fn lines(&self) -> &[LineInfo] {
645 &self.lines
646 }
647
648 /// Mutable access to lines (call [`rebuild_caches()`](Self::rebuild_caches) after mutating).
649 pub const fn lines_mut(&mut self) -> &mut Vec<LineInfo> {
650 &mut self.lines
651 }
652
653 /// Elevator entities belonging to this group (derived from lines).
654 #[must_use]
655 pub fn elevator_entities(&self) -> &[EntityId] {
656 &self.elevator_entities
657 }
658
659 /// Stop entities served by this group (derived from lines, deduplicated).
660 #[must_use]
661 pub fn stop_entities(&self) -> &[EntityId] {
662 &self.stop_entities
663 }
664
665 /// Whether this group can serve a rider on `leg`. A `Group(g)` leg
666 /// matches by group id; a `Line(l)` leg matches if `l` belongs to
667 /// this group; `Walk` never rides an elevator.
668 #[must_use]
669 pub fn accepts_leg(&self, leg: &crate::components::RouteLeg) -> bool {
670 match leg.via {
671 crate::components::TransportMode::Group(g) => g == self.id,
672 crate::components::TransportMode::Line(l) => {
673 self.lines.iter().any(|li| li.entity() == l)
674 }
675 crate::components::TransportMode::Walk => false,
676 }
677 }
678
679 /// Push a stop entity directly into the group's stop cache.
680 ///
681 /// Use when a stop belongs to the group for dispatch purposes but is
682 /// not (yet) assigned to any line. Call `add_stop_to_line` later to
683 /// wire it into the topology graph.
684 pub(crate) fn push_stop(&mut self, stop: EntityId) {
685 if !self.stop_entities.contains(&stop) {
686 self.stop_entities.push(stop);
687 }
688 }
689
690 /// Push an elevator entity directly into the group's elevator cache
691 /// (in addition to the line it belongs to).
692 pub(crate) fn push_elevator(&mut self, elevator: EntityId) {
693 if !self.elevator_entities.contains(&elevator) {
694 self.elevator_entities.push(elevator);
695 }
696 }
697
698 /// Rebuild derived caches from lines. Call after mutating lines.
699 pub fn rebuild_caches(&mut self) {
700 self.elevator_entities = self
701 .lines
702 .iter()
703 .flat_map(|li| li.elevators.iter().copied())
704 .collect();
705 let mut stops: Vec<EntityId> = self
706 .lines
707 .iter()
708 .flat_map(|li| li.serves.iter().copied())
709 .collect();
710 stops.sort_unstable();
711 stops.dedup();
712 self.stop_entities = stops;
713 }
714}
715
716/// Context passed to [`DispatchStrategy::rank`].
717///
718/// Bundles the per-call arguments into a single struct so future context
719/// fields can be added without breaking existing trait implementations.
720#[non_exhaustive]
721pub struct RankContext<'a> {
722 /// The elevator being evaluated.
723 pub car: EntityId,
724 /// Current position of the car along the shaft axis.
725 pub car_position: f64,
726 /// The stop being evaluated as a candidate destination.
727 pub stop: EntityId,
728 /// Position of the candidate stop along the shaft axis.
729 pub stop_position: f64,
730 /// The dispatch group this assignment belongs to.
731 pub group: &'a ElevatorGroup,
732 /// Demand snapshot for the current dispatch pass.
733 pub manifest: &'a DispatchManifest,
734 /// Read-only world state.
735 pub world: &'a World,
736}
737
738impl std::fmt::Debug for RankContext<'_> {
739 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
740 f.debug_struct("RankContext")
741 .field("car", &self.car)
742 .field("car_position", &self.car_position)
743 .field("stop", &self.stop)
744 .field("stop_position", &self.stop_position)
745 .field("group", &self.group)
746 .field("manifest", &self.manifest)
747 .field("world", &"World { .. }")
748 .finish()
749 }
750}
751
752/// Pluggable dispatch algorithm.
753///
754/// Strategies implement [`rank`](Self::rank) to score each `(car, stop)`
755/// pair; the dispatch system then performs an optimal assignment across
756/// the whole group, guaranteeing that no two cars are sent to the same
757/// hall call.
758///
759/// Returning `None` from `rank` excludes a pair from assignment — useful
760/// for capacity limits, direction preferences, restricted stops, or
761/// sticky commitments.
762///
763/// Cars that receive no stop fall through to [`fallback`](Self::fallback),
764/// which returns the policy for that car (idle, park, etc.).
765pub trait DispatchStrategy: Send + Sync {
766 /// Optional hook called once per group before the assignment pass.
767 ///
768 /// Strategies that need to mutate [`World`] extension storage (e.g.
769 /// [`DestinationDispatch`] writing sticky rider → car assignments)
770 /// or pre-populate [`crate::components::DestinationQueue`] entries
771 /// override this. Default: no-op.
772 fn pre_dispatch(
773 &mut self,
774 _group: &ElevatorGroup,
775 _manifest: &DispatchManifest,
776 _world: &mut World,
777 ) {
778 }
779
780 /// Optional hook called once per candidate car, before any
781 /// [`rank`](Self::rank) calls for that car in the current pass.
782 ///
783 /// Strategies whose ranking depends on stable per-car state (e.g. the
784 /// sweep direction used by SCAN/LOOK) set that state here so later
785 /// `rank` calls see a consistent view regardless of iteration order.
786 /// The default is a no-op.
787 fn prepare_car(
788 &mut self,
789 _car: EntityId,
790 _car_position: f64,
791 _group: &ElevatorGroup,
792 _manifest: &DispatchManifest,
793 _world: &World,
794 ) {
795 }
796
797 /// Score the cost of sending `car` to `stop`. Lower is better.
798 ///
799 /// Returning `None` marks this `(car, stop)` pair as unavailable;
800 /// the assignment algorithm will never pair them. Use this for
801 /// capacity limits, wrong-direction stops, stops outside the line's
802 /// topology, or pairs already committed via a sticky assignment.
803 ///
804 /// Must return a finite, non-negative value if `Some` — infinities
805 /// and NaN can destabilize the underlying Hungarian solver.
806 ///
807 /// Implementations must not mutate per-car state inside `rank`: the
808 /// dispatch system calls `rank(car, stop_0..stop_m)` in a loop, so
809 /// mutating `self` on one call affects subsequent calls for the same
810 /// car within the same pass and produces an asymmetric cost matrix
811 /// whose results depend on iteration order. Use
812 /// [`prepare_car`](Self::prepare_car) to compute and store any
813 /// per-car state before `rank` is called.
814 fn rank(&mut self, ctx: &RankContext<'_>) -> Option<f64>;
815
816 /// Decide what an idle car should do when no stop was assigned to it.
817 ///
818 /// Called for each car the assignment phase could not pair with a
819 /// stop (because there were no stops, or all candidate stops had
820 /// rank `None` for this car). Default: [`DispatchDecision::Idle`].
821 fn fallback(
822 &mut self,
823 _car: EntityId,
824 _car_position: f64,
825 _group: &ElevatorGroup,
826 _manifest: &DispatchManifest,
827 _world: &World,
828 ) -> DispatchDecision {
829 DispatchDecision::Idle
830 }
831
832 /// Notify the strategy that an elevator has been removed.
833 ///
834 /// Implementations with per-elevator state (e.g. direction tracking)
835 /// should clean up here to prevent unbounded memory growth.
836 fn notify_removed(&mut self, _elevator: EntityId) {}
837
838 /// If this strategy is a known built-in variant, return it so
839 /// [`Simulation::new`](crate::sim::Simulation::new) can stamp the
840 /// correct [`BuiltinStrategy`] into the group's snapshot identity.
841 ///
842 /// Without this, legacy-topology sims constructed via
843 /// `Simulation::new(config, SomeNonScanStrategy::new())` silently
844 /// recorded `BuiltinStrategy::Scan` as their identity — so a
845 /// snapshot round-trip replaced the running strategy with Scan
846 /// and produced different dispatch decisions post-restore
847 /// (determinism regression).
848 ///
849 /// Default: `None` (unidentified — the constructor falls back to
850 /// recording [`BuiltinStrategy::Scan`], matching pre-fix behaviour
851 /// for callers that never cared about round-trip identity). Custom
852 /// strategies that DO care should override this to return
853 /// [`BuiltinStrategy::Custom`] with a stable name.
854 #[must_use]
855 fn builtin_id(&self) -> Option<BuiltinStrategy> {
856 None
857 }
858
859 /// Serialize this strategy's tunable configuration to a string
860 /// that [`restore_config`](Self::restore_config) can apply to a
861 /// freshly-instantiated instance.
862 ///
863 /// Returning `Some(..)` makes the configuration survive snapshot
864 /// round-trip: without it, [`crate::snapshot::WorldSnapshot::restore`]
865 /// instantiates each built-in via [`BuiltinStrategy::instantiate`],
866 /// which calls `::new()` with default weights — silently dropping
867 /// any tuning applied via `with_*` builder methods (e.g.
868 /// `EtdDispatch::with_delay_weight(2.5)` degrades to the default
869 /// `1.0` on the restored sim).
870 ///
871 /// Default: `None` (no configuration to save). Built-ins with
872 /// tunable weights override to return a RON-serialized copy of
873 /// themselves; strategies with transient per-pass scratch should
874 /// use `#[serde(skip)]` on those fields so the snapshot stays
875 /// compact and deterministic.
876 #[must_use]
877 fn snapshot_config(&self) -> Option<String> {
878 None
879 }
880
881 /// Restore tunable configuration from a string previously produced
882 /// by [`snapshot_config`](Self::snapshot_config) on the same
883 /// strategy variant. Called by
884 /// [`crate::snapshot::WorldSnapshot::restore`] immediately after
885 /// [`BuiltinStrategy::instantiate`] builds the default instance,
886 /// so the restore writes over the defaults.
887 ///
888 /// # Errors
889 /// Returns the underlying parse error as a `String` when the
890 /// serialized form doesn't round-trip. Default implementation
891 /// ignores the argument and returns `Ok(())` — paired with the
892 /// `None` default of `snapshot_config`, this means strategies that
893 /// don't override either method skip configuration round-trip,
894 /// matching pre-fix behaviour.
895 fn restore_config(&mut self, _serialized: &str) -> Result<(), String> {
896 Ok(())
897 }
898}
899
900/// Resolution of a single dispatch assignment pass for one group.
901///
902/// Produced by `assign` and consumed by
903/// `crate::systems::dispatch::run` to apply decisions to the world.
904#[derive(Debug, Clone)]
905pub struct AssignmentResult {
906 /// `(car, decision)` pairs for every idle car in the group.
907 pub decisions: Vec<(EntityId, DispatchDecision)>,
908}
909
910/// Sentinel weight used to pad unavailable `(car, stop)` pairs when
911/// building the cost matrix for the Hungarian solver. Chosen so that
912/// `n · SENTINEL` can't overflow `i64`: the Kuhn–Munkres implementation
913/// sums weights and potentials across each row/column internally, so
914/// headroom of ~2¹⁵ above the sentinel lets groups scale past 30 000
915/// cars or stops before any arithmetic risk appears.
916const ASSIGNMENT_SENTINEL: i64 = 1 << 48;
917/// Fixed-point scale for converting `f64` costs to the `i64` values the
918/// Hungarian solver requires. One unit ≈ one micro-tick / millimeter.
919const ASSIGNMENT_SCALE: f64 = 1_000_000.0;
920
921/// Convert a `f64` rank cost into the fixed-point `i64` the Hungarian
922/// solver consumes. Non-finite, negative, or overflow-prone inputs map
923/// to the unavailable sentinel.
924fn scale_cost(cost: f64) -> i64 {
925 if !cost.is_finite() || cost < 0.0 {
926 debug_assert!(
927 cost.is_finite() && cost >= 0.0,
928 "DispatchStrategy::rank() returned invalid cost {cost}; must be finite and non-negative"
929 );
930 return ASSIGNMENT_SENTINEL;
931 }
932 // Cap at just below sentinel so any real rank always beats unavailable.
933 (cost * ASSIGNMENT_SCALE)
934 .round()
935 .clamp(0.0, (ASSIGNMENT_SENTINEL - 1) as f64) as i64
936}
937
938/// Build the pending-demand stop list, subtracting stops whose
939/// demand is already being absorbed by a car — either currently in
940/// its door cycle at the stop, or en route via `MovingToStop`.
941///
942/// Both phases count as "servicing" because they represent a
943/// commitment to open doors at the target with remaining capacity
944/// that waiting riders can (typically) fit into. Without the
945/// `MovingToStop` case, a new idle car becoming available during
946/// car A's trip to the lobby gets paired with the same lobby call
947/// on the next dispatch tick — car B travels empty behind car A
948/// and the playground shows two cars doing a lobby touch-and-go
949/// for one rider. Composes with the commitment set in
950/// [`systems::dispatch`](crate::systems::dispatch), which excludes
951/// committed cars from the idle pool at the same time.
952///
953/// `Stopped` (parked-with-doors-closed) is deliberately *not* in
954/// the list: that's a legitimately reassignable state.
955/// `Repositioning` is also excluded — a repositioning car doesn't
956/// open doors on arrival, so it cannot absorb waiting riders.
957///
958/// Line-pinned riders (`TransportMode::Line(L)`) keep a stop
959/// pending even when a car is present, because a car on Shaft A
960/// can't absorb a rider pinned to Shaft B. Coverage also fails
961/// when the waiting riders' combined weight exceeds the servicing
962/// car's remaining capacity — the leftover spills out when doors
963/// close and deserves its own dispatch immediately.
964fn pending_stops_minus_covered(
965 group: &ElevatorGroup,
966 manifest: &DispatchManifest,
967 world: &World,
968 idle_cars: &[(EntityId, f64)],
969) -> Vec<(EntityId, f64)> {
970 // Vec + linear scan is fine: groups have O(few) elevators and
971 // this runs once per dispatch tick.
972 let servicing: Vec<(EntityId, EntityId, f64)> = group
973 .elevator_entities()
974 .iter()
975 .filter_map(|&eid| {
976 let car = world.elevator(eid)?;
977 let target = car.target_stop()?;
978 matches!(
979 car.phase(),
980 ElevatorPhase::MovingToStop(_)
981 | ElevatorPhase::DoorOpening
982 | ElevatorPhase::Loading
983 | ElevatorPhase::DoorClosing
984 )
985 .then(|| {
986 let remaining = car.weight_capacity().value() - car.current_load().value();
987 (target, car.line(), remaining)
988 })
989 })
990 .collect();
991
992 // A stop is "covered" iff every waiting rider this group sees can
993 // board at least one of the door-cycling cars here (line check)
994 // AND the combined remaining capacity of the cars whose line
995 // accepts the rider is enough to board them all (capacity check).
996 //
997 // Iterates `manifest.waiting_riders_at` rather than `world.iter_riders`
998 // so `TransportMode::Walk` riders and cross-group-routed riders
999 // (excluded by `build_manifest`) don't inflate the weight total.
1000 let is_covered = |stop_eid: EntityId| {
1001 // Single fold so readers see the "same cars, both attributes"
1002 // invariant structurally — the two derived values can never
1003 // disagree about which cars contributed.
1004 let (lines_here, capacity_here): (Vec<EntityId>, f64) =
1005 servicing
1006 .iter()
1007 .fold((Vec::new(), 0.0), |(mut lines, cap), &(stop, line, rem)| {
1008 if stop == stop_eid {
1009 lines.push(line);
1010 (lines, cap + rem)
1011 } else {
1012 (lines, cap)
1013 }
1014 });
1015 if lines_here.is_empty() {
1016 return false;
1017 }
1018 let mut total_weight = 0.0;
1019 for rider in manifest.waiting_riders_at(stop_eid) {
1020 let required_line = world
1021 .route(rider.id)
1022 .and_then(Route::current)
1023 .and_then(|leg| match leg.via {
1024 TransportMode::Line(l) => Some(l),
1025 _ => None,
1026 });
1027 if let Some(required) = required_line
1028 && !lines_here.contains(&required)
1029 {
1030 return false;
1031 }
1032 total_weight += rider.weight.value();
1033 }
1034 total_weight <= capacity_here
1035 };
1036
1037 let idle_rider_destinations: HashSet<EntityId> = idle_cars
1038 .iter()
1039 .filter_map(|&(car_eid, _)| world.elevator(car_eid))
1040 .flat_map(|car| car.riders().iter().copied())
1041 .filter_map(|rid| world.route(rid).and_then(Route::current_destination))
1042 .collect();
1043
1044 group
1045 .stop_entities()
1046 .iter()
1047 .filter(|s| {
1048 if !manifest.has_demand(**s) {
1049 return false;
1050 }
1051 if idle_rider_destinations.contains(*s) {
1052 return true;
1053 }
1054 !is_covered(**s)
1055 })
1056 .filter_map(|s| world.stop_position(*s).map(|p| (*s, p)))
1057 .collect()
1058}
1059
1060/// Run one group's assignment pass: build the cost matrix, solve the
1061/// optimal bipartite matching, then resolve unassigned cars via
1062/// [`DispatchStrategy::fallback`].
1063///
1064/// Visible to the `systems` module; not part of the public API.
1065pub(crate) fn assign(
1066 strategy: &mut dyn DispatchStrategy,
1067 idle_cars: &[(EntityId, f64)],
1068 group: &ElevatorGroup,
1069 manifest: &DispatchManifest,
1070 world: &World,
1071) -> AssignmentResult {
1072 // Collect stops with active demand and known positions, excluding
1073 // any whose demand is already being absorbed by a car mid door
1074 // cycle (see `pending_stops_minus_covered` for the why).
1075 let pending_stops = pending_stops_minus_covered(group, manifest, world, idle_cars);
1076
1077 let n = idle_cars.len();
1078 let m = pending_stops.len();
1079
1080 if n == 0 {
1081 return AssignmentResult {
1082 decisions: Vec::new(),
1083 };
1084 }
1085
1086 let mut decisions: Vec<(EntityId, DispatchDecision)> = Vec::with_capacity(n);
1087
1088 if m == 0 {
1089 for &(eid, pos) in idle_cars {
1090 let d = strategy.fallback(eid, pos, group, manifest, world);
1091 decisions.push((eid, d));
1092 }
1093 return AssignmentResult { decisions };
1094 }
1095
1096 // Build cost matrix. Hungarian requires rows <= cols.
1097 let cols = n.max(m);
1098 let mut data: Vec<i64> = vec![ASSIGNMENT_SENTINEL; n * cols];
1099 for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
1100 strategy.prepare_car(car_eid, car_pos, group, manifest, world);
1101 // Cache the car's restricted-stops set for this row so each
1102 // (car, stop) pair can short-circuit before calling rank().
1103 // Pre-fix only DCS consulted restricted_stops; SCAN/LOOK/NC/ETD
1104 // happily ranked restricted pairs and `commit_go_to_stop` later
1105 // silently dropped the assignment, starving the call. (#256)
1106 let restricted = world
1107 .elevator(car_eid)
1108 .map(|c| c.restricted_stops().clone())
1109 .unwrap_or_default();
1110 for (j, &(stop_eid, stop_pos)) in pending_stops.iter().enumerate() {
1111 if restricted.contains(&stop_eid) {
1112 continue; // leave SENTINEL — this pair is unavailable
1113 }
1114 let ctx = RankContext {
1115 car: car_eid,
1116 car_position: car_pos,
1117 stop: stop_eid,
1118 stop_position: stop_pos,
1119 group,
1120 manifest,
1121 world,
1122 };
1123 let scaled = strategy.rank(&ctx).map_or(ASSIGNMENT_SENTINEL, scale_cost);
1124 data[i * cols + j] = scaled;
1125 }
1126 }
1127 // `from_vec` only fails if `n * cols != data.len()` — both derived
1128 // from `n` and `cols` above, so the construction is infallible. Fall
1129 // back to an empty-result shape in the unlikely event the invariant
1130 // is violated in future refactors.
1131 let Ok(matrix) = pathfinding::matrix::Matrix::from_vec(n, cols, data) else {
1132 for &(car_eid, car_pos) in idle_cars {
1133 let d = strategy.fallback(car_eid, car_pos, group, manifest, world);
1134 decisions.push((car_eid, d));
1135 }
1136 return AssignmentResult { decisions };
1137 };
1138 let (_, assignments) = pathfinding::kuhn_munkres::kuhn_munkres_min(&matrix);
1139
1140 for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
1141 let col = assignments[i];
1142 // A real assignment is: col points to a real stop (col < m) AND
1143 // the cost isn't sentinel-padded (meaning rank() returned Some).
1144 if col < m && matrix[(i, col)] < ASSIGNMENT_SENTINEL {
1145 let (stop_eid, _) = pending_stops[col];
1146 decisions.push((car_eid, DispatchDecision::GoToStop(stop_eid)));
1147 } else {
1148 let d = strategy.fallback(car_eid, car_pos, group, manifest, world);
1149 decisions.push((car_eid, d));
1150 }
1151 }
1152
1153 AssignmentResult { decisions }
1154}
1155
1156/// Pluggable strategy for repositioning idle elevators.
1157///
1158/// After the dispatch phase, elevators that remain idle (no pending
1159/// assignments) are candidates for repositioning. The strategy decides
1160/// where each idle elevator should move to improve coverage and reduce
1161/// expected response times.
1162///
1163/// Implementations receive the set of idle elevator positions and the
1164/// group's stop positions, then return a target stop for each elevator
1165/// (or `None` to leave it in place).
1166pub trait RepositionStrategy: Send + Sync {
1167 /// Decide where to reposition idle elevators.
1168 ///
1169 /// Push `(elevator_entity, target_stop_entity)` pairs into `out`.
1170 /// The buffer is cleared before each call — implementations should
1171 /// only push, never read prior contents. Elevators not pushed remain idle.
1172 fn reposition(
1173 &mut self,
1174 idle_elevators: &[(EntityId, f64)],
1175 stop_positions: &[(EntityId, f64)],
1176 group: &ElevatorGroup,
1177 world: &World,
1178 out: &mut Vec<(EntityId, EntityId)>,
1179 );
1180
1181 /// If this strategy is a known built-in variant, return it so
1182 /// [`Simulation::set_reposition`](crate::sim::Simulation::set_reposition)
1183 /// callers don't have to pass a separate [`BuiltinReposition`] id
1184 /// that might drift from the dispatcher's actual type.
1185 ///
1186 /// Mirrors the pattern introduced for [`DispatchStrategy::builtin_id`]
1187 /// in #410: the runtime impl identifies itself so the snapshot
1188 /// identity always matches the executing behaviour, instead of
1189 /// depending on the caller to keep two parameters consistent.
1190 /// Default `None` — custom strategies should override to return
1191 /// [`BuiltinReposition::Custom`] with a stable name for snapshot
1192 /// fidelity.
1193 #[must_use]
1194 fn builtin_id(&self) -> Option<BuiltinReposition> {
1195 None
1196 }
1197}
1198
1199/// Serializable identifier for built-in repositioning strategies.
1200///
1201/// Used in config and snapshots to restore the correct strategy.
1202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1203#[non_exhaustive]
1204pub enum BuiltinReposition {
1205 /// Distribute idle elevators evenly across stops.
1206 SpreadEvenly,
1207 /// Return idle elevators to a configured home stop.
1208 ReturnToLobby,
1209 /// Position near stops with historically high demand.
1210 DemandWeighted,
1211 /// Keep idle elevators where they are (no-op).
1212 NearestIdle,
1213 /// Pre-position cars near stops with the highest recent arrival rate.
1214 PredictiveParking,
1215 /// Mode-gated: picks between `ReturnToLobby` / `PredictiveParking`
1216 /// based on the current `TrafficDetector` mode.
1217 Adaptive,
1218 /// Custom strategy identified by name.
1219 Custom(String),
1220}
1221
1222impl std::fmt::Display for BuiltinReposition {
1223 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1224 match self {
1225 Self::SpreadEvenly => write!(f, "SpreadEvenly"),
1226 Self::ReturnToLobby => write!(f, "ReturnToLobby"),
1227 Self::DemandWeighted => write!(f, "DemandWeighted"),
1228 Self::NearestIdle => write!(f, "NearestIdle"),
1229 Self::PredictiveParking => write!(f, "PredictiveParking"),
1230 Self::Adaptive => write!(f, "Adaptive"),
1231 Self::Custom(name) => write!(f, "Custom({name})"),
1232 }
1233 }
1234}
1235
1236impl BuiltinReposition {
1237 /// Instantiate the reposition strategy for this variant.
1238 ///
1239 /// Returns `None` for `Custom` — the game must provide those via
1240 /// a factory function. `ReturnToLobby` uses stop index 0 as default.
1241 #[must_use]
1242 pub fn instantiate(&self) -> Option<Box<dyn RepositionStrategy>> {
1243 match self {
1244 Self::SpreadEvenly => Some(Box::new(reposition::SpreadEvenly)),
1245 Self::ReturnToLobby => Some(Box::new(reposition::ReturnToLobby::new())),
1246 Self::DemandWeighted => Some(Box::new(reposition::DemandWeighted)),
1247 Self::NearestIdle => Some(Box::new(reposition::NearestIdle)),
1248 Self::PredictiveParking => Some(Box::new(reposition::PredictiveParking::new())),
1249 Self::Adaptive => Some(Box::new(reposition::AdaptiveParking::new())),
1250 Self::Custom(_) => None,
1251 }
1252 }
1253}