Skip to main content

elevator_core/dispatch/
mod.rs

1//! Pluggable dispatch strategies for assigning elevators to stops.
2//!
3//! # Example: custom dispatch strategy
4//!
5//! ```rust
6//! use elevator_core::prelude::*;
7//! use elevator_core::dispatch::{
8//!     DispatchDecision, DispatchManifest, ElevatorGroup,
9//! };
10//!
11//! struct AlwaysFirstStop;
12//!
13//! impl DispatchStrategy for AlwaysFirstStop {
14//!     fn decide(
15//!         &mut self,
16//!         _elevator: EntityId,
17//!         _elevator_position: f64,
18//!         group: &ElevatorGroup,
19//!         _manifest: &DispatchManifest,
20//!         _world: &elevator_core::world::World,
21//!     ) -> DispatchDecision {
22//!         group.stop_entities().first()
23//!             .map(|&stop| DispatchDecision::GoToStop(stop))
24//!             .unwrap_or(DispatchDecision::Idle)
25//!     }
26//! }
27//!
28//! let sim = SimulationBuilder::demo()
29//!     .dispatch(AlwaysFirstStop)
30//!     .build()
31//!     .unwrap();
32//! ```
33
34/// Hall-call destination dispatch algorithm.
35pub mod destination;
36/// Estimated Time to Destination dispatch algorithm.
37pub mod etd;
38/// LOOK dispatch algorithm.
39pub mod look;
40/// Nearest-car dispatch algorithm.
41pub mod nearest_car;
42/// Built-in repositioning strategies.
43pub mod reposition;
44/// SCAN dispatch algorithm.
45pub mod scan;
46
47pub use destination::{AssignedCar, DestinationDispatch};
48pub use etd::EtdDispatch;
49pub use look::LookDispatch;
50pub use nearest_car::NearestCarDispatch;
51pub use scan::ScanDispatch;
52
53use serde::{Deserialize, Serialize};
54
55use crate::entity::EntityId;
56use crate::ids::GroupId;
57use crate::world::World;
58use std::collections::BTreeMap;
59
60/// Metadata about a single rider, available to dispatch strategies.
61#[derive(Debug, Clone)]
62#[non_exhaustive]
63pub struct RiderInfo {
64    /// Rider entity ID.
65    pub id: EntityId,
66    /// Rider's destination stop entity (from route).
67    pub destination: Option<EntityId>,
68    /// Rider weight.
69    pub weight: f64,
70    /// Ticks this rider has been waiting (0 if riding).
71    pub wait_ticks: u64,
72}
73
74/// Full demand picture for dispatch decisions.
75///
76/// Contains per-rider metadata grouped by stop, enabling entity-aware
77/// dispatch strategies (priority, weight-aware, VIP-first, etc.).
78///
79/// Uses `BTreeMap` for deterministic iteration order.
80#[derive(Debug, Clone, Default)]
81pub struct DispatchManifest {
82    /// Riders waiting at each stop, with full per-rider metadata.
83    pub waiting_at_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
84    /// Riders currently aboard elevators, grouped by their destination stop.
85    pub riding_to_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
86    /// Number of residents at each stop (read-only hint for dispatch strategies).
87    pub resident_count_at_stop: BTreeMap<EntityId, usize>,
88}
89
90impl DispatchManifest {
91    /// Number of riders waiting at a stop.
92    #[must_use]
93    pub fn waiting_count_at(&self, stop: EntityId) -> usize {
94        self.waiting_at_stop.get(&stop).map_or(0, Vec::len)
95    }
96
97    /// Total weight of riders waiting at a stop.
98    #[must_use]
99    pub fn total_weight_at(&self, stop: EntityId) -> f64 {
100        self.waiting_at_stop
101            .get(&stop)
102            .map_or(0.0, |riders| riders.iter().map(|r| r.weight).sum())
103    }
104
105    /// Number of riders heading to a stop (aboard elevators).
106    #[must_use]
107    pub fn riding_count_to(&self, stop: EntityId) -> usize {
108        self.riding_to_stop.get(&stop).map_or(0, Vec::len)
109    }
110
111    /// Whether a stop has any demand (waiting riders or riders heading there).
112    #[must_use]
113    pub fn has_demand(&self, stop: EntityId) -> bool {
114        self.waiting_count_at(stop) > 0 || self.riding_count_to(stop) > 0
115    }
116
117    /// Number of residents at a stop (read-only hint, not active demand).
118    #[must_use]
119    pub fn resident_count_at(&self, stop: EntityId) -> usize {
120        self.resident_count_at_stop.get(&stop).copied().unwrap_or(0)
121    }
122}
123
124/// Serializable identifier for built-in dispatch strategies.
125///
126/// Used in snapshots and config files to restore the correct strategy
127/// without requiring the game to manually re-wire dispatch. Custom strategies
128/// are represented by the `Custom(String)` variant.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[non_exhaustive]
131pub enum BuiltinStrategy {
132    /// SCAN (elevator) algorithm — sweeps end-to-end.
133    Scan,
134    /// LOOK algorithm — reverses at last request.
135    Look,
136    /// Nearest-car — assigns closest idle elevator.
137    NearestCar,
138    /// Estimated Time to Destination — minimizes total cost.
139    Etd,
140    /// Hall-call destination dispatch — sticky per-rider assignment.
141    Destination,
142    /// Custom strategy identified by name. The game must provide a factory.
143    Custom(String),
144}
145
146impl std::fmt::Display for BuiltinStrategy {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        match self {
149            Self::Scan => write!(f, "Scan"),
150            Self::Look => write!(f, "Look"),
151            Self::NearestCar => write!(f, "NearestCar"),
152            Self::Etd => write!(f, "Etd"),
153            Self::Destination => write!(f, "Destination"),
154            Self::Custom(name) => write!(f, "Custom({name})"),
155        }
156    }
157}
158
159impl BuiltinStrategy {
160    /// Instantiate the dispatch strategy for this variant.
161    ///
162    /// Returns `None` for `Custom` — the game must provide those via
163    /// a factory function.
164    #[must_use]
165    pub fn instantiate(&self) -> Option<Box<dyn DispatchStrategy>> {
166        match self {
167            Self::Scan => Some(Box::new(scan::ScanDispatch::new())),
168            Self::Look => Some(Box::new(look::LookDispatch::new())),
169            Self::NearestCar => Some(Box::new(nearest_car::NearestCarDispatch::new())),
170            Self::Etd => Some(Box::new(etd::EtdDispatch::new())),
171            Self::Destination => Some(Box::new(destination::DestinationDispatch::new())),
172            Self::Custom(_) => None,
173        }
174    }
175}
176
177/// Decision returned by a dispatch strategy.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[non_exhaustive]
180pub enum DispatchDecision {
181    /// Go to the specified stop entity.
182    GoToStop(EntityId),
183    /// Remain idle.
184    Idle,
185}
186
187/// Per-line relationship data within an [`ElevatorGroup`].
188///
189/// This is a denormalized cache maintained by [`Simulation`](crate::sim::Simulation).
190/// The source of truth for intrinsic line properties is the
191/// [`Line`](crate::components::Line) component in World.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct LineInfo {
194    /// Line entity ID.
195    entity: EntityId,
196    /// Elevator entities on this line.
197    elevators: Vec<EntityId>,
198    /// Stop entities served by this line.
199    serves: Vec<EntityId>,
200}
201
202impl LineInfo {
203    /// Create a new `LineInfo`.
204    #[must_use]
205    pub const fn new(entity: EntityId, elevators: Vec<EntityId>, serves: Vec<EntityId>) -> Self {
206        Self {
207            entity,
208            elevators,
209            serves,
210        }
211    }
212
213    /// Line entity ID.
214    #[must_use]
215    pub const fn entity(&self) -> EntityId {
216        self.entity
217    }
218
219    /// Elevator entities on this line.
220    #[must_use]
221    pub fn elevators(&self) -> &[EntityId] {
222        &self.elevators
223    }
224
225    /// Stop entities served by this line.
226    #[must_use]
227    pub fn serves(&self) -> &[EntityId] {
228        &self.serves
229    }
230
231    /// Set the line entity ID (used during snapshot restore).
232    pub(crate) const fn set_entity(&mut self, entity: EntityId) {
233        self.entity = entity;
234    }
235
236    /// Mutable access to elevator entities on this line.
237    pub(crate) const fn elevators_mut(&mut self) -> &mut Vec<EntityId> {
238        &mut self.elevators
239    }
240
241    /// Mutable access to stop entities served by this line.
242    pub(crate) const fn serves_mut(&mut self) -> &mut Vec<EntityId> {
243        &mut self.serves
244    }
245}
246
247/// Runtime elevator group: a set of lines sharing a dispatch strategy.
248///
249/// A group is the logical dispatch unit. It contains one or more
250/// [`LineInfo`] entries, each representing a physical path with its
251/// elevators and served stops.
252///
253/// The flat `elevator_entities` and `stop_entities` fields are derived
254/// caches (union of all lines' elevators/stops), rebuilt automatically
255/// via [`rebuild_caches()`](Self::rebuild_caches).
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ElevatorGroup {
258    /// Unique group identifier.
259    id: GroupId,
260    /// Human-readable group name.
261    name: String,
262    /// Lines belonging to this group.
263    lines: Vec<LineInfo>,
264    /// Derived flat cache — rebuilt by `rebuild_caches()`.
265    elevator_entities: Vec<EntityId>,
266    /// Derived flat cache — rebuilt by `rebuild_caches()`.
267    stop_entities: Vec<EntityId>,
268}
269
270impl ElevatorGroup {
271    /// Create a new group with the given lines. Caches are built automatically.
272    #[must_use]
273    pub fn new(id: GroupId, name: String, lines: Vec<LineInfo>) -> Self {
274        let mut group = Self {
275            id,
276            name,
277            lines,
278            elevator_entities: Vec::new(),
279            stop_entities: Vec::new(),
280        };
281        group.rebuild_caches();
282        group
283    }
284
285    /// Unique group identifier.
286    #[must_use]
287    pub const fn id(&self) -> GroupId {
288        self.id
289    }
290
291    /// Human-readable group name.
292    #[must_use]
293    pub fn name(&self) -> &str {
294        &self.name
295    }
296
297    /// Lines belonging to this group.
298    #[must_use]
299    pub fn lines(&self) -> &[LineInfo] {
300        &self.lines
301    }
302
303    /// Mutable access to lines (call [`rebuild_caches()`](Self::rebuild_caches) after mutating).
304    pub const fn lines_mut(&mut self) -> &mut Vec<LineInfo> {
305        &mut self.lines
306    }
307
308    /// Elevator entities belonging to this group (derived from lines).
309    #[must_use]
310    pub fn elevator_entities(&self) -> &[EntityId] {
311        &self.elevator_entities
312    }
313
314    /// Stop entities served by this group (derived from lines, deduplicated).
315    #[must_use]
316    pub fn stop_entities(&self) -> &[EntityId] {
317        &self.stop_entities
318    }
319
320    /// Push a stop entity directly into the group's stop cache.
321    ///
322    /// Use when a stop belongs to the group for dispatch purposes but is
323    /// not (yet) assigned to any line. Call `add_stop_to_line` later to
324    /// wire it into the topology graph.
325    pub(crate) fn push_stop(&mut self, stop: EntityId) {
326        if !self.stop_entities.contains(&stop) {
327            self.stop_entities.push(stop);
328        }
329    }
330
331    /// Push an elevator entity directly into the group's elevator cache
332    /// (in addition to the line it belongs to).
333    pub(crate) fn push_elevator(&mut self, elevator: EntityId) {
334        if !self.elevator_entities.contains(&elevator) {
335            self.elevator_entities.push(elevator);
336        }
337    }
338
339    /// Rebuild derived caches from lines. Call after mutating lines.
340    pub fn rebuild_caches(&mut self) {
341        self.elevator_entities = self
342            .lines
343            .iter()
344            .flat_map(|li| li.elevators.iter().copied())
345            .collect();
346        let mut stops: Vec<EntityId> = self
347            .lines
348            .iter()
349            .flat_map(|li| li.serves.iter().copied())
350            .collect();
351        stops.sort_unstable();
352        stops.dedup();
353        self.stop_entities = stops;
354    }
355}
356
357/// Pluggable dispatch algorithm.
358///
359/// Receives a manifest with per-rider metadata grouped by stop.
360/// Convenience methods provide aggregate counts; implementations
361/// can also iterate individual riders for priority/weight-aware dispatch.
362pub trait DispatchStrategy: Send + Sync {
363    /// Optional hook called once before the per-group dispatch pass.
364    ///
365    /// Strategies that need to mutate [`World`] extension storage (e.g.
366    /// [`DestinationDispatch`] writing sticky rider → car assignments) or
367    /// [`crate::components::DestinationQueue`] entries before per-elevator
368    /// decisions override this. The default is a no-op.
369    ///
370    /// Called by [`crate::systems::dispatch::run`] for every group with
371    /// mutable world access. The corresponding [`DispatchManifest`] is
372    /// rebuilt internally so downstream `decide`/`decide_all` calls see
373    /// consistent state.
374    fn pre_dispatch(
375        &mut self,
376        _group: &ElevatorGroup,
377        _manifest: &DispatchManifest,
378        _world: &mut World,
379    ) {
380    }
381
382    /// Decide for a single elevator.
383    fn decide(
384        &mut self,
385        elevator: EntityId,
386        elevator_position: f64,
387        group: &ElevatorGroup,
388        manifest: &DispatchManifest,
389        world: &World,
390    ) -> DispatchDecision;
391
392    /// Decide for all idle elevators in a group.
393    /// Default: calls `decide()` per elevator.
394    fn decide_all(
395        &mut self,
396        elevators: &[(EntityId, f64)], // (entity, position)
397        group: &ElevatorGroup,
398        manifest: &DispatchManifest,
399        world: &World,
400    ) -> Vec<(EntityId, DispatchDecision)> {
401        elevators
402            .iter()
403            .map(|(eid, pos)| (*eid, self.decide(*eid, *pos, group, manifest, world)))
404            .collect()
405    }
406
407    /// Notify the strategy that an elevator has been removed.
408    ///
409    /// Implementations with per-elevator state (e.g., direction tracking)
410    /// should clean up here to prevent unbounded memory growth. Default: no-op.
411    fn notify_removed(&mut self, _elevator: EntityId) {}
412}
413
414/// Pluggable strategy for repositioning idle elevators.
415///
416/// After the dispatch phase, elevators that remain idle (no pending
417/// assignments) are candidates for repositioning. The strategy decides
418/// where each idle elevator should move to improve coverage and reduce
419/// expected response times.
420///
421/// Implementations receive the set of idle elevator positions and the
422/// group's stop positions, then return a target stop for each elevator
423/// (or `None` to leave it in place).
424pub trait RepositionStrategy: Send + Sync {
425    /// Decide where to reposition idle elevators.
426    ///
427    /// Returns a vec of `(elevator_entity, target_stop_entity)` pairs.
428    /// Elevators not in the returned vec remain idle.
429    fn reposition(
430        &mut self,
431        idle_elevators: &[(EntityId, f64)],
432        stop_positions: &[(EntityId, f64)],
433        group: &ElevatorGroup,
434        world: &World,
435    ) -> Vec<(EntityId, EntityId)>;
436}
437
438/// Serializable identifier for built-in repositioning strategies.
439///
440/// Used in config and snapshots to restore the correct strategy.
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
442#[non_exhaustive]
443pub enum BuiltinReposition {
444    /// Distribute idle elevators evenly across stops.
445    SpreadEvenly,
446    /// Return idle elevators to a configured home stop.
447    ReturnToLobby,
448    /// Position near stops with historically high demand.
449    DemandWeighted,
450    /// Keep idle elevators where they are (no-op).
451    NearestIdle,
452    /// Custom strategy identified by name.
453    Custom(String),
454}
455
456impl std::fmt::Display for BuiltinReposition {
457    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
458        match self {
459            Self::SpreadEvenly => write!(f, "SpreadEvenly"),
460            Self::ReturnToLobby => write!(f, "ReturnToLobby"),
461            Self::DemandWeighted => write!(f, "DemandWeighted"),
462            Self::NearestIdle => write!(f, "NearestIdle"),
463            Self::Custom(name) => write!(f, "Custom({name})"),
464        }
465    }
466}
467
468impl BuiltinReposition {
469    /// Instantiate the reposition strategy for this variant.
470    ///
471    /// Returns `None` for `Custom` — the game must provide those via
472    /// a factory function. `ReturnToLobby` uses stop index 0 as default.
473    #[must_use]
474    pub fn instantiate(&self) -> Option<Box<dyn RepositionStrategy>> {
475        match self {
476            Self::SpreadEvenly => Some(Box::new(reposition::SpreadEvenly)),
477            Self::ReturnToLobby => Some(Box::new(reposition::ReturnToLobby::new())),
478            Self::DemandWeighted => Some(Box::new(reposition::DemandWeighted)),
479            Self::NearestIdle => Some(Box::new(reposition::NearestIdle)),
480            Self::Custom(_) => None,
481        }
482    }
483}