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