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}