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