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;
78pub(crate) mod strategy_set;
79mod substep;
80mod tagging;
81mod topology;
82
83pub(crate) use strategy_set::{DispatcherSet, RepositionerSet};
84#[allow(clippy::redundant_pub_crate)]
85pub(crate) mod transition;
86
87pub use rider::RiderBuilder;
88
89use crate::components::{Accel, Orientation, SpatialPosition, Speed, Weight};
90use crate::dispatch::ElevatorGroup;
91use crate::entity::EntityId;
92use crate::events::{Event, EventBus};
93use crate::hooks::PhaseHooks;
94use crate::ids::GroupId;
95use crate::metrics::Metrics;
96use crate::rider_index::RiderIndex;
97use crate::stop::StopId;
98use crate::time::TimeAdapter;
99use crate::topology::TopologyGraph;
100use crate::world::World;
101use std::collections::{HashMap, HashSet};
102use std::fmt;
103use std::sync::Mutex;
104
105/// Parameters for creating a new elevator at runtime.
106#[derive(Debug, Clone)]
107pub struct ElevatorParams {
108 /// Maximum travel speed (distance/tick).
109 pub max_speed: Speed,
110 /// Acceleration rate (distance/tick^2).
111 pub acceleration: Accel,
112 /// Deceleration rate (distance/tick^2).
113 pub deceleration: Accel,
114 /// Maximum weight the car can carry.
115 pub weight_capacity: Weight,
116 /// Ticks for a door open/close transition.
117 pub door_transition_ticks: u32,
118 /// Ticks the door stays fully open.
119 pub door_open_ticks: u32,
120 /// Stop entity IDs this elevator cannot serve (access restriction).
121 pub restricted_stops: HashSet<EntityId>,
122 /// Speed multiplier for Inspection mode (0.0..1.0).
123 pub inspection_speed_factor: f64,
124 /// Full-load bypass threshold for upward pickups (see
125 /// [`Elevator::bypass_load_up_pct`](crate::components::Elevator::bypass_load_up_pct)).
126 pub bypass_load_up_pct: Option<f64>,
127 /// Full-load bypass threshold for downward pickups.
128 pub bypass_load_down_pct: Option<f64>,
129}
130
131impl Default for ElevatorParams {
132 fn default() -> Self {
133 Self {
134 max_speed: Speed::from(2.0),
135 acceleration: Accel::from(1.5),
136 deceleration: Accel::from(2.0),
137 weight_capacity: Weight::from(800.0),
138 door_transition_ticks: 5,
139 door_open_ticks: 10,
140 restricted_stops: HashSet::new(),
141 inspection_speed_factor: 0.25,
142 bypass_load_up_pct: None,
143 bypass_load_down_pct: None,
144 }
145 }
146}
147
148/// Parameters for creating a new line at runtime.
149#[derive(Debug, Clone)]
150pub struct LineParams {
151 /// Human-readable name.
152 pub name: String,
153 /// Dispatch group to add this line to.
154 pub group: GroupId,
155 /// Physical orientation.
156 pub orientation: Orientation,
157 /// Lowest reachable position on the line axis.
158 ///
159 /// Used only when [`Self::kind`] is `None`; otherwise the kind's
160 /// own bounds win. Kept for backward-compat with callers that
161 /// haven't migrated to constructing
162 /// [`LineKind`](crate::components::LineKind) directly.
163 pub min_position: f64,
164 /// Highest reachable position on the line axis. See
165 /// [`Self::min_position`] for the kind interaction.
166 pub max_position: f64,
167 /// Optional floor-plan position. On a Loop line this is the
168 /// loop center.
169 pub position: Option<SpatialPosition>,
170 /// Maximum cars on this line (None = unlimited).
171 pub max_cars: Option<usize>,
172 /// Topology kind. When `Some`, takes precedence over the flat
173 /// `min_position`/`max_position` fields. When `None`, falls back
174 /// to [`LineKind::Linear`](crate::components::LineKind::Linear)
175 /// built from the flat fields — matches the pre-`LineKind` behavior.
176 pub kind: Option<crate::components::LineKind>,
177}
178
179impl LineParams {
180 /// Create line parameters with the given name and group, defaulting
181 /// everything else.
182 pub fn new(name: impl Into<String>, group: GroupId) -> Self {
183 Self {
184 name: name.into(),
185 group,
186 orientation: Orientation::default(),
187 min_position: 0.0,
188 max_position: 0.0,
189 position: None,
190 max_cars: None,
191 kind: None,
192 }
193 }
194}
195
196/// The core simulation state, advanced by calling `step()`.
197pub struct Simulation {
198 /// The ECS world containing all entity data.
199 world: World,
200 /// Internal event bus — only holds events from the current tick.
201 events: EventBus,
202 /// Events from completed ticks, available to consumers via `drain_events()`.
203 pending_output: Vec<Event>,
204 /// Current simulation tick.
205 tick: u64,
206 /// Time delta per tick (seconds).
207 dt: f64,
208 /// Elevator groups in this simulation.
209 groups: Vec<ElevatorGroup>,
210 /// Config `StopId` to `EntityId` mapping for spawn helpers.
211 stop_lookup: HashMap<StopId, EntityId>,
212 /// Config-time `ElevatorConfig.id` → runtime `EntityId` mapping.
213 /// Populated only for elevators spawned from the initial config —
214 /// runtime `add_elevator` takes `ElevatorParams` (no config id) and
215 /// returns the new `EntityId` directly, so it does not populate
216 /// this map. Mirror of [`Self::stop_lookup`] semantics.
217 elevator_lookup: HashMap<crate::config::ElevatorConfigId, EntityId>,
218 /// Config-time `LineConfig.id` → runtime `EntityId` mapping.
219 /// Populated only for lines from an explicit `building.lines` block
220 /// in the config; legacy (flat-elevator-list) configs build a single
221 /// default line with no config id, so this stays empty for them.
222 /// Runtime `add_line` takes `LineParams` (no config id) and returns
223 /// the entity directly. Mirror of [`Self::stop_lookup`] semantics.
224 line_lookup: HashMap<crate::config::LineConfigId, EntityId>,
225 /// Dispatch strategies + their snapshot identities, keyed by group.
226 /// Owns both halves so insert/remove stay atomic — see
227 /// [`DispatcherSet`].
228 dispatcher_set: DispatcherSet,
229 /// Reposition strategies + their snapshot identities, keyed by group.
230 /// Empty when no group opts into the reposition phase.
231 repositioner_set: RepositionerSet,
232 /// Aggregated metrics.
233 metrics: Metrics,
234 /// Time conversion utility.
235 time: TimeAdapter,
236 /// Lifecycle hooks (before/after each phase).
237 hooks: PhaseHooks,
238 /// Reusable buffer for elevator IDs (avoids per-tick allocation).
239 elevator_ids_buf: Vec<EntityId>,
240 /// Reusable buffer for reposition decisions (avoids per-tick allocation).
241 reposition_buf: Vec<(EntityId, EntityId)>,
242 /// Scratch buffers owned by the dispatch phase — the cost matrix,
243 /// pending-stops list, servicing slice, pinned / committed /
244 /// idle-elevator filters. Holding them on the sim means each
245 /// dispatch pass reuses capacity instead of re-allocating.
246 ///
247 /// Stays `pub(crate)` rather than method-encapsulated because the
248 /// dispatch phase needs simultaneous disjoint mutable borrows of
249 /// `world`, `events`, and the scratch — a getter returning
250 /// `&mut DispatchScratch` would borrow all of `self`, conflicting
251 /// with `&mut self.world` in the same call.
252 pub(crate) dispatch_scratch: crate::dispatch::DispatchScratch,
253 /// Lazy-rebuilt connectivity graph for cross-line topology queries.
254 topo_graph: Mutex<TopologyGraph>,
255 /// Phase-partitioned reverse index for O(1) population queries.
256 rider_index: RiderIndex,
257 /// True between the first per-phase `run_*` call and the matching
258 /// `advance_tick()`. Used by [`try_snapshot`](Self::try_snapshot) to
259 /// reject mid-tick captures that would lose in-progress event-bus
260 /// state. Always false outside the substep API path because
261 /// [`step()`](Self::step) takes `&mut self` and snapshots take
262 /// `&self`. (#297) Read via [`tick_in_progress`](Self::tick_in_progress);
263 /// the substep loop owns the mutation through
264 /// [`set_tick_in_progress`](Self::set_tick_in_progress).
265 tick_in_progress: bool,
266 /// Opt-in phase-order check for the substep API. `Disabled` (the
267 /// default) skips all checks for backwards compatibility and zero
268 /// runtime cost. Hosts driving phases manually can flip this on
269 /// via [`set_strict_phase_order`](Self::set_strict_phase_order) to
270 /// fail fast on out-of-order `run_*` calls instead of seeing the
271 /// diffuse symptoms (riders boarding through closed doors,
272 /// movement before dispatch, transient states bleeding across
273 /// tick boundaries).
274 pub(crate) phase_check: PhaseCheck,
275}
276
277/// State of the substep phase-order guard.
278///
279/// `Disabled` (the default) is a no-op: every `run_*` method
280/// short-circuits the check, so there is no runtime cost for hosts
281/// that don't opt in. `Expecting(phase)` enforces that the next
282/// `run_*` call matches `phase`; `AwaitingTick` requires
283/// [`Simulation::advance_tick`] before the next cycle begins.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub(crate) enum PhaseCheck {
286 /// Guard is off; substep calls are not validated.
287 Disabled,
288 /// Guard is on and the next allowed `run_*` is this phase.
289 Expecting(crate::hooks::Phase),
290 /// Metrics has run; `advance_tick()` must come before the next cycle.
291 AwaitingTick,
292}
293
294impl Simulation {
295 /// Whether the sim is between [`run_advance_transient`] and
296 /// [`advance_tick`] — i.e. mid-tick. Snapshot capture needs this
297 /// to reject mid-tick saves that would lose in-progress
298 /// event-bus state.
299 ///
300 /// [`run_advance_transient`]: Self::run_advance_transient
301 /// [`advance_tick`]: Self::advance_tick
302 #[must_use]
303 pub(crate) const fn tick_in_progress(&self) -> bool {
304 self.tick_in_progress
305 }
306
307 /// Set the mid-tick guard. Owned by the substep runner — only
308 /// `run_advance_transient` flips it on and `advance_tick` flips
309 /// it off.
310 pub(crate) const fn set_tick_in_progress(&mut self, in_progress: bool) {
311 self.tick_in_progress = in_progress;
312 }
313}
314
315impl fmt::Debug for Simulation {
316 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317 f.debug_struct("Simulation")
318 .field("tick", &self.tick)
319 .field("dt", &self.dt)
320 .field("groups", &self.groups.len())
321 .field("entities", &self.world.entity_count())
322 .finish_non_exhaustive()
323 }
324}