Skip to main content

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