elevator_core/sim.rs
1//! Top-level simulation runner and tick loop.
2//!
3//! # Essential API
4//!
5//! `Simulation` exposes a large surface, but most users only need the
6//! ~15 methods below, grouped by the order they appear in a typical
7//! game loop.
8//!
9//! ### Construction
10//!
11//! - [`SimulationBuilder::demo()`](crate::builder::SimulationBuilder::demo)
12//! or [`SimulationBuilder::from_config()`](crate::builder::SimulationBuilder::from_config)
13//! — fluent entry point; call [`.build()`](crate::builder::SimulationBuilder::build)
14//! to get a `Simulation`.
15//! - [`Simulation::new()`](crate::sim::Simulation::new) — direct construction from
16//! `&SimConfig` + a dispatch strategy.
17//!
18//! ### Per-tick driving
19//!
20//! - [`Simulation::step()`](crate::sim::Simulation::step) — run all 8 phases.
21//! - [`Simulation::current_tick()`](crate::sim::Simulation::current_tick) — the
22//! current tick counter.
23//!
24//! ### Spawning and rerouting riders
25//!
26//! - [`Simulation::spawn_rider()`](crate::sim::Simulation::spawn_rider)
27//! — simple origin/destination/weight spawn (accepts `EntityId` or `StopId`).
28//! - [`Simulation::build_rider()`](crate::sim::Simulation::build_rider)
29//! — fluent [`RiderBuilder`](crate::sim::RiderBuilder) for patience, preferences, access
30//! control, explicit groups, multi-leg routes (accepts `EntityId` or `StopId`).
31//! - [`Simulation::reroute()`](crate::sim::Simulation::reroute) — change a waiting
32//! rider's destination mid-trip.
33//! - [`Simulation::settle_rider()`](crate::sim::Simulation::settle_rider) /
34//! [`Simulation::despawn_rider()`](crate::sim::Simulation::despawn_rider) —
35//! terminal-state cleanup for `Arrived`/`Abandoned` riders.
36//!
37//! ### Observability
38//!
39//! - [`Simulation::drain_events()`](crate::sim::Simulation::drain_events) — consume
40//! the event stream emitted by the last tick.
41//! - [`Simulation::metrics()`](crate::sim::Simulation::metrics) — aggregate
42//! wait/ride/throughput stats.
43//! - [`Simulation::waiting_at()`](crate::sim::Simulation::waiting_at) /
44//! [`Simulation::residents_at()`](crate::sim::Simulation::residents_at) — O(1)
45//! population queries by stop.
46//!
47//! ### Imperative control
48//!
49//! - [`Simulation::push_destination()`](crate::sim::Simulation::push_destination) /
50//! [`Simulation::push_destination_front()`](crate::sim::Simulation::push_destination_front) /
51//! [`Simulation::clear_destinations()`](crate::sim::Simulation::clear_destinations)
52//! — override dispatch by pushing/clearing stops on an elevator's
53//! [`DestinationQueue`](crate::components::DestinationQueue).
54//! - [`Simulation::abort_movement()`](crate::sim::Simulation::abort_movement)
55//! — hard-abort an in-flight trip, braking the car to the nearest
56//! reachable stop without opening doors (riders stay aboard).
57//!
58//! ### Persistence
59//!
60//! - [`Simulation::snapshot()`](crate::sim::Simulation::snapshot) — capture full
61//! state as a serializable [`WorldSnapshot`](crate::snapshot::WorldSnapshot).
62//! - [`WorldSnapshot::restore()`](crate::snapshot::WorldSnapshot::restore)
63//! — rebuild a `Simulation` from a snapshot.
64//!
65//! Everything else (phase-runners, world-level accessors, energy, tag
66//! metrics, topology queries) is available for advanced use but is not
67//! required for the common case.
68
69mod accessors;
70mod calls;
71mod construction;
72mod destinations;
73mod eta;
74mod lifecycle;
75mod manual;
76mod rider;
77mod runtime;
78mod substep;
79mod tagging;
80mod topology;
81
82use crate::components::{
83 Accel, AccessControl, Orientation, Patience, Preferences, Route, SpatialPosition, Speed, Weight,
84};
85use crate::dispatch::{BuiltinReposition, DispatchStrategy, ElevatorGroup, RepositionStrategy};
86use crate::entity::{EntityId, RiderId};
87use crate::error::SimError;
88use crate::events::{Event, EventBus};
89use crate::hooks::PhaseHooks;
90use crate::ids::GroupId;
91use crate::metrics::Metrics;
92use crate::rider_index::RiderIndex;
93use crate::stop::StopId;
94use crate::time::TimeAdapter;
95use crate::topology::TopologyGraph;
96use crate::world::World;
97use std::collections::{BTreeMap, HashMap, HashSet};
98use std::fmt;
99use std::sync::Mutex;
100
101/// Parameters for creating a new elevator at runtime.
102#[derive(Debug, Clone)]
103pub struct ElevatorParams {
104 /// Maximum travel speed (distance/tick).
105 pub max_speed: Speed,
106 /// Acceleration rate (distance/tick^2).
107 pub acceleration: Accel,
108 /// Deceleration rate (distance/tick^2).
109 pub deceleration: Accel,
110 /// Maximum weight the car can carry.
111 pub weight_capacity: Weight,
112 /// Ticks for a door open/close transition.
113 pub door_transition_ticks: u32,
114 /// Ticks the door stays fully open.
115 pub door_open_ticks: u32,
116 /// Stop entity IDs this elevator cannot serve (access restriction).
117 pub restricted_stops: HashSet<EntityId>,
118 /// Speed multiplier for Inspection mode (0.0..1.0).
119 pub inspection_speed_factor: f64,
120 /// Full-load bypass threshold for upward pickups (see
121 /// [`Elevator::bypass_load_up_pct`](crate::components::Elevator::bypass_load_up_pct)).
122 pub bypass_load_up_pct: Option<f64>,
123 /// Full-load bypass threshold for downward pickups.
124 pub bypass_load_down_pct: Option<f64>,
125}
126
127impl Default for ElevatorParams {
128 fn default() -> Self {
129 Self {
130 max_speed: Speed::from(2.0),
131 acceleration: Accel::from(1.5),
132 deceleration: Accel::from(2.0),
133 weight_capacity: Weight::from(800.0),
134 door_transition_ticks: 5,
135 door_open_ticks: 10,
136 restricted_stops: HashSet::new(),
137 inspection_speed_factor: 0.25,
138 bypass_load_up_pct: None,
139 bypass_load_down_pct: None,
140 }
141 }
142}
143
144/// Parameters for creating a new line at runtime.
145#[derive(Debug, Clone)]
146pub struct LineParams {
147 /// Human-readable name.
148 pub name: String,
149 /// Dispatch group to add this line to.
150 pub group: GroupId,
151 /// Physical orientation.
152 pub orientation: Orientation,
153 /// Lowest reachable position on the line axis.
154 pub min_position: f64,
155 /// Highest reachable position on the line axis.
156 pub max_position: f64,
157 /// Optional floor-plan position.
158 pub position: Option<SpatialPosition>,
159 /// Maximum cars on this line (None = unlimited).
160 pub max_cars: Option<usize>,
161}
162
163impl LineParams {
164 /// Create line parameters with the given name and group, defaulting
165 /// everything else.
166 pub fn new(name: impl Into<String>, group: GroupId) -> Self {
167 Self {
168 name: name.into(),
169 group,
170 orientation: Orientation::default(),
171 min_position: 0.0,
172 max_position: 0.0,
173 position: None,
174 max_cars: None,
175 }
176 }
177}
178
179/// Fluent builder for spawning riders with optional configuration.
180///
181/// Created via [`Simulation::build_rider`].
182///
183/// ```
184/// use elevator_core::prelude::*;
185///
186/// let mut sim = SimulationBuilder::demo().build().unwrap();
187/// let rider = sim.build_rider(StopId(0), StopId(1))
188/// .unwrap()
189/// .weight(80.0)
190/// .spawn()
191/// .unwrap();
192/// ```
193pub struct RiderBuilder<'a> {
194 /// Mutable reference to the simulation (consumed on spawn).
195 sim: &'a mut Simulation,
196 /// Origin stop entity.
197 origin: EntityId,
198 /// Destination stop entity.
199 destination: EntityId,
200 /// Rider weight (default: 75.0).
201 weight: Weight,
202 /// Explicit dispatch group (skips auto-detection).
203 group: Option<GroupId>,
204 /// Explicit multi-leg route.
205 route: Option<Route>,
206 /// Maximum wait ticks before abandoning.
207 patience: Option<u64>,
208 /// Boarding preferences.
209 preferences: Option<Preferences>,
210 /// Per-rider access control.
211 access_control: Option<AccessControl>,
212}
213
214impl RiderBuilder<'_> {
215 /// Set the rider's weight (default: 75.0).
216 #[must_use]
217 pub fn weight(mut self, weight: impl Into<Weight>) -> Self {
218 self.weight = weight.into();
219 self
220 }
221
222 /// Set the dispatch group explicitly, skipping auto-detection.
223 #[must_use]
224 pub const fn group(mut self, group: GroupId) -> Self {
225 self.group = Some(group);
226 self
227 }
228
229 /// Provide an explicit multi-leg route.
230 #[must_use]
231 pub fn route(mut self, route: Route) -> Self {
232 self.route = Some(route);
233 self
234 }
235
236 /// Set maximum wait ticks before the rider abandons.
237 #[must_use]
238 pub const fn patience(mut self, max_wait_ticks: u64) -> Self {
239 self.patience = Some(max_wait_ticks);
240 self
241 }
242
243 /// Set boarding preferences.
244 #[must_use]
245 pub const fn preferences(mut self, prefs: Preferences) -> Self {
246 self.preferences = Some(prefs);
247 self
248 }
249
250 /// Set per-rider access control (allowed stops).
251 #[must_use]
252 pub fn access_control(mut self, ac: AccessControl) -> Self {
253 self.access_control = Some(ac);
254 self
255 }
256
257 /// Spawn the rider with the configured options.
258 ///
259 /// # Errors
260 ///
261 /// Returns [`SimError::NoRoute`] if no group serves both stops (when auto-detecting).
262 /// Returns [`SimError::AmbiguousRoute`] if multiple groups serve both stops (when auto-detecting).
263 /// Returns [`SimError::GroupNotFound`] if an explicit group does not exist.
264 /// Returns [`SimError::RouteOriginMismatch`] if an explicit route's first leg
265 /// does not start at `origin`.
266 pub fn spawn(self) -> Result<RiderId, SimError> {
267 let route = if let Some(route) = self.route {
268 // Validate route origin matches the spawn origin.
269 if let Some(leg) = route.current()
270 && leg.from != self.origin
271 {
272 return Err(SimError::RouteOriginMismatch {
273 expected_origin: self.origin,
274 route_origin: leg.from,
275 });
276 }
277 route
278 } else {
279 // No explicit route: must build one from origin → destination.
280 // Same origin/destination produces a Route::direct that no hall
281 // call can summon a car for — rider deadlocks Waiting (#273).
282 // Trust users that supply their own route.
283 if self.origin == self.destination {
284 return Err(SimError::InvalidConfig {
285 field: "destination",
286 reason: "origin and destination must differ; same-stop \
287 spawns deadlock with no hall call to summon a car"
288 .into(),
289 });
290 }
291 if let Some(group) = self.group {
292 if !self.sim.groups.iter().any(|g| g.id() == group) {
293 return Err(SimError::GroupNotFound(group));
294 }
295 Route::direct(self.origin, self.destination, group)
296 } else {
297 let group = self.sim.auto_detect_group(self.origin, self.destination)?;
298 Route::direct(self.origin, self.destination, group)
299 }
300 };
301
302 let eid = self
303 .sim
304 .spawn_rider_inner(self.origin, self.destination, self.weight, route);
305
306 // Apply optional components.
307 if let Some(max_wait) = self.patience {
308 self.sim.world.set_patience(
309 eid,
310 Patience {
311 max_wait_ticks: max_wait,
312 waited_ticks: 0,
313 },
314 );
315 }
316 if let Some(prefs) = self.preferences {
317 self.sim.world.set_preferences(eid, prefs);
318 }
319 if let Some(ac) = self.access_control {
320 self.sim.world.set_access_control(eid, ac);
321 }
322
323 Ok(RiderId::from(eid))
324 }
325}
326
327/// The core simulation state, advanced by calling `step()`.
328pub struct Simulation {
329 /// The ECS world containing all entity data.
330 world: World,
331 /// Internal event bus — only holds events from the current tick.
332 events: EventBus,
333 /// Events from completed ticks, available to consumers via `drain_events()`.
334 pending_output: Vec<Event>,
335 /// Current simulation tick.
336 tick: u64,
337 /// Time delta per tick (seconds).
338 dt: f64,
339 /// Elevator groups in this simulation.
340 groups: Vec<ElevatorGroup>,
341 /// Config `StopId` to `EntityId` mapping for spawn helpers.
342 stop_lookup: HashMap<StopId, EntityId>,
343 /// Dispatch strategies keyed by group.
344 dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
345 /// Serializable strategy identifiers (for snapshot).
346 strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
347 /// Reposition strategies keyed by group (optional per group).
348 repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>>,
349 /// Serializable reposition strategy identifiers (for snapshot).
350 reposition_ids: BTreeMap<GroupId, BuiltinReposition>,
351 /// Aggregated metrics.
352 metrics: Metrics,
353 /// Time conversion utility.
354 time: TimeAdapter,
355 /// Lifecycle hooks (before/after each phase).
356 hooks: PhaseHooks,
357 /// Reusable buffer for elevator IDs (avoids per-tick allocation).
358 elevator_ids_buf: Vec<EntityId>,
359 /// Reusable buffer for reposition decisions (avoids per-tick allocation).
360 reposition_buf: Vec<(EntityId, EntityId)>,
361 /// Lazy-rebuilt connectivity graph for cross-line topology queries.
362 topo_graph: Mutex<TopologyGraph>,
363 /// Phase-partitioned reverse index for O(1) population queries.
364 rider_index: RiderIndex,
365 /// True between the first per-phase `run_*` call and the matching
366 /// `advance_tick()`. Used by [`try_snapshot`](Self::try_snapshot) to
367 /// reject mid-tick captures that would lose in-progress event-bus
368 /// state. Always false outside the substep API path because
369 /// [`step()`](Self::step) takes `&mut self` and snapshots take
370 /// `&self`. (#297)
371 pub(crate) tick_in_progress: bool,
372}
373
374impl fmt::Debug for Simulation {
375 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
376 f.debug_struct("Simulation")
377 .field("tick", &self.tick)
378 .field("dt", &self.dt)
379 .field("groups", &self.groups.len())
380 .field("entities", &self.world.entity_count())
381 .finish_non_exhaustive()
382 }
383}