Skip to main content

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
87use crate::components::{
88    Accel, AccessControl, Orientation, Patience, Preferences, Route, SpatialPosition, Speed, Weight,
89};
90use crate::dispatch::ElevatorGroup;
91use crate::entity::{EntityId, RiderId};
92use crate::error::SimError;
93use crate::events::{Event, EventBus};
94use crate::hooks::PhaseHooks;
95use crate::ids::GroupId;
96use crate::metrics::Metrics;
97use crate::rider_index::RiderIndex;
98use crate::stop::StopId;
99use crate::time::TimeAdapter;
100use crate::topology::TopologyGraph;
101use crate::world::World;
102use std::collections::{HashMap, HashSet};
103use std::fmt;
104use std::sync::Mutex;
105
106/// Parameters for creating a new elevator at runtime.
107#[derive(Debug, Clone)]
108pub struct ElevatorParams {
109    /// Maximum travel speed (distance/tick).
110    pub max_speed: Speed,
111    /// Acceleration rate (distance/tick^2).
112    pub acceleration: Accel,
113    /// Deceleration rate (distance/tick^2).
114    pub deceleration: Accel,
115    /// Maximum weight the car can carry.
116    pub weight_capacity: Weight,
117    /// Ticks for a door open/close transition.
118    pub door_transition_ticks: u32,
119    /// Ticks the door stays fully open.
120    pub door_open_ticks: u32,
121    /// Stop entity IDs this elevator cannot serve (access restriction).
122    pub restricted_stops: HashSet<EntityId>,
123    /// Speed multiplier for Inspection mode (0.0..1.0).
124    pub inspection_speed_factor: f64,
125    /// Full-load bypass threshold for upward pickups (see
126    /// [`Elevator::bypass_load_up_pct`](crate::components::Elevator::bypass_load_up_pct)).
127    pub bypass_load_up_pct: Option<f64>,
128    /// Full-load bypass threshold for downward pickups.
129    pub bypass_load_down_pct: Option<f64>,
130}
131
132impl Default for ElevatorParams {
133    fn default() -> Self {
134        Self {
135            max_speed: Speed::from(2.0),
136            acceleration: Accel::from(1.5),
137            deceleration: Accel::from(2.0),
138            weight_capacity: Weight::from(800.0),
139            door_transition_ticks: 5,
140            door_open_ticks: 10,
141            restricted_stops: HashSet::new(),
142            inspection_speed_factor: 0.25,
143            bypass_load_up_pct: None,
144            bypass_load_down_pct: None,
145        }
146    }
147}
148
149/// Parameters for creating a new line at runtime.
150#[derive(Debug, Clone)]
151pub struct LineParams {
152    /// Human-readable name.
153    pub name: String,
154    /// Dispatch group to add this line to.
155    pub group: GroupId,
156    /// Physical orientation.
157    pub orientation: Orientation,
158    /// Lowest reachable position on the line axis.
159    pub min_position: f64,
160    /// Highest reachable position on the line axis.
161    pub max_position: f64,
162    /// Optional floor-plan position.
163    pub position: Option<SpatialPosition>,
164    /// Maximum cars on this line (None = unlimited).
165    pub max_cars: Option<usize>,
166}
167
168impl LineParams {
169    /// Create line parameters with the given name and group, defaulting
170    /// everything else.
171    pub fn new(name: impl Into<String>, group: GroupId) -> Self {
172        Self {
173            name: name.into(),
174            group,
175            orientation: Orientation::default(),
176            min_position: 0.0,
177            max_position: 0.0,
178            position: None,
179            max_cars: None,
180        }
181    }
182}
183
184/// Fluent builder for spawning riders with optional configuration.
185///
186/// Created via [`Simulation::build_rider`].
187///
188/// ```
189/// use elevator_core::prelude::*;
190///
191/// let mut sim = SimulationBuilder::demo().build().unwrap();
192/// let rider = sim.build_rider(StopId(0), StopId(1))
193///     .unwrap()
194///     .weight(80.0)
195///     .spawn()
196///     .unwrap();
197/// ```
198pub struct RiderBuilder<'a> {
199    /// Mutable reference to the simulation (consumed on spawn).
200    sim: &'a mut Simulation,
201    /// Origin stop entity.
202    origin: EntityId,
203    /// Destination stop entity.
204    destination: EntityId,
205    /// Rider weight (default: 75.0).
206    weight: Weight,
207    /// Explicit dispatch group (skips auto-detection).
208    group: Option<GroupId>,
209    /// Explicit multi-leg route.
210    route: Option<Route>,
211    /// Maximum wait ticks before abandoning.
212    patience: Option<u64>,
213    /// Boarding preferences.
214    preferences: Option<Preferences>,
215    /// Per-rider access control.
216    access_control: Option<AccessControl>,
217}
218
219impl RiderBuilder<'_> {
220    /// Set the rider's weight (default: 75.0).
221    #[must_use]
222    pub fn weight(mut self, weight: impl Into<Weight>) -> Self {
223        self.weight = weight.into();
224        self
225    }
226
227    /// Set the dispatch group explicitly, skipping auto-detection.
228    #[must_use]
229    pub const fn group(mut self, group: GroupId) -> Self {
230        self.group = Some(group);
231        self
232    }
233
234    /// Provide an explicit multi-leg route.
235    #[must_use]
236    pub fn route(mut self, route: Route) -> Self {
237        self.route = Some(route);
238        self
239    }
240
241    /// Set maximum wait ticks before the rider abandons.
242    #[must_use]
243    pub const fn patience(mut self, max_wait_ticks: u64) -> Self {
244        self.patience = Some(max_wait_ticks);
245        self
246    }
247
248    /// Set boarding preferences.
249    #[must_use]
250    pub const fn preferences(mut self, prefs: Preferences) -> Self {
251        self.preferences = Some(prefs);
252        self
253    }
254
255    /// Set per-rider access control (allowed stops).
256    #[must_use]
257    pub fn access_control(mut self, ac: AccessControl) -> Self {
258        self.access_control = Some(ac);
259        self
260    }
261
262    /// Spawn the rider with the configured options.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`SimError::NoRoute`] if no group serves both stops (when auto-detecting).
267    /// Returns [`SimError::AmbiguousRoute`] if multiple groups serve both stops (when auto-detecting).
268    /// Returns [`SimError::GroupNotFound`] if an explicit group does not exist.
269    /// Returns [`SimError::RouteOriginMismatch`] if an explicit route's first leg
270    /// does not start at `origin`.
271    pub fn spawn(self) -> Result<RiderId, SimError> {
272        let route = if let Some(route) = self.route {
273            // Validate route origin matches the spawn origin.
274            if let Some(leg) = route.current()
275                && leg.from != self.origin
276            {
277                return Err(SimError::RouteOriginMismatch {
278                    expected_origin: self.origin,
279                    route_origin: leg.from,
280                });
281            }
282            route
283        } else {
284            // No explicit route: must build one from origin → destination.
285            // Same origin/destination produces a Route::direct that no hall
286            // call can summon a car for — rider deadlocks Waiting (#273).
287            // The route-supplied path above is exempt from this check: a
288            // caller that constructs their own Route presumably also drives
289            // the corresponding hall-call / dispatch path, so the
290            // same-stop case there is their responsibility, not ours.
291            if self.origin == self.destination {
292                return Err(SimError::InvalidConfig {
293                    field: "destination",
294                    reason: "origin and destination must differ; same-stop \
295                             spawns deadlock with no hall call to summon a car"
296                        .into(),
297                });
298            }
299            if let Some(group) = self.group {
300                if !self.sim.groups.iter().any(|g| g.id() == group) {
301                    return Err(SimError::GroupNotFound(group));
302                }
303                Route::direct(self.origin, self.destination, group)
304            } else {
305                // Auto-detect the single-group case first; on `NoRoute` or
306                // `AmbiguousRoute`, fall back to the multi-leg topology
307                // search so zoned buildings and specialty-overlap floors
308                // work through the plain `spawn_rider` API without callers
309                // having to thread a group pick through transfer points.
310                match self.sim.auto_detect_group(self.origin, self.destination) {
311                    Ok(group) => Route::direct(self.origin, self.destination, group),
312                    Err(
313                        original @ (SimError::NoRoute { .. } | SimError::AmbiguousRoute { .. }),
314                    ) => {
315                        match self.sim.shortest_route(self.origin, self.destination) {
316                            Some(route) => route,
317                            // Preserve the original diagnostic context (which
318                            // groups serve origin / destination) so callers
319                            // still see the misconfiguration, not just a
320                            // bare "no route" from the fallback.
321                            None => return Err(original),
322                        }
323                    }
324                    Err(other) => return Err(other),
325                }
326            }
327        };
328
329        let eid = self
330            .sim
331            .spawn_rider_inner(self.origin, self.destination, self.weight, route);
332
333        // Apply optional components.
334        if let Some(max_wait) = self.patience {
335            self.sim.world.set_patience(
336                eid,
337                Patience {
338                    max_wait_ticks: max_wait,
339                    waited_ticks: 0,
340                },
341            );
342        }
343        if let Some(prefs) = self.preferences {
344            self.sim.world.set_preferences(eid, prefs);
345        }
346        if let Some(ac) = self.access_control {
347            self.sim.world.set_access_control(eid, ac);
348        }
349
350        Ok(RiderId::wrap_unchecked(eid))
351    }
352}
353
354/// The core simulation state, advanced by calling `step()`.
355pub struct Simulation {
356    /// The ECS world containing all entity data.
357    world: World,
358    /// Internal event bus — only holds events from the current tick.
359    events: EventBus,
360    /// Events from completed ticks, available to consumers via `drain_events()`.
361    pending_output: Vec<Event>,
362    /// Current simulation tick.
363    tick: u64,
364    /// Time delta per tick (seconds).
365    dt: f64,
366    /// Elevator groups in this simulation.
367    groups: Vec<ElevatorGroup>,
368    /// Config `StopId` to `EntityId` mapping for spawn helpers.
369    stop_lookup: HashMap<StopId, EntityId>,
370    /// Dispatch strategies + their snapshot identities, keyed by group.
371    /// Owns both halves so insert/remove stay atomic — see
372    /// [`DispatcherSet`].
373    dispatcher_set: DispatcherSet,
374    /// Reposition strategies + their snapshot identities, keyed by group.
375    /// Empty when no group opts into the reposition phase.
376    repositioner_set: RepositionerSet,
377    /// Aggregated metrics.
378    metrics: Metrics,
379    /// Time conversion utility.
380    time: TimeAdapter,
381    /// Lifecycle hooks (before/after each phase).
382    hooks: PhaseHooks,
383    /// Reusable buffer for elevator IDs (avoids per-tick allocation).
384    elevator_ids_buf: Vec<EntityId>,
385    /// Reusable buffer for reposition decisions (avoids per-tick allocation).
386    reposition_buf: Vec<(EntityId, EntityId)>,
387    /// Scratch buffers owned by the dispatch phase — the cost matrix,
388    /// pending-stops list, servicing slice, pinned / committed /
389    /// idle-elevator filters. Holding them on the sim means each
390    /// dispatch pass reuses capacity instead of re-allocating.
391    pub(crate) dispatch_scratch: crate::dispatch::DispatchScratch,
392    /// Lazy-rebuilt connectivity graph for cross-line topology queries.
393    topo_graph: Mutex<TopologyGraph>,
394    /// Phase-partitioned reverse index for O(1) population queries.
395    rider_index: RiderIndex,
396    /// True between the first per-phase `run_*` call and the matching
397    /// `advance_tick()`. Used by [`try_snapshot`](Self::try_snapshot) to
398    /// reject mid-tick captures that would lose in-progress event-bus
399    /// state. Always false outside the substep API path because
400    /// [`step()`](Self::step) takes `&mut self` and snapshots take
401    /// `&self`. (#297)
402    pub(crate) tick_in_progress: bool,
403}
404
405impl fmt::Debug for Simulation {
406    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407        f.debug_struct("Simulation")
408            .field("tick", &self.tick)
409            .field("dt", &self.dt)
410            .field("groups", &self.groups.len())
411            .field("entities", &self.world.entity_count())
412            .finish_non_exhaustive()
413    }
414}