elevator_core/sim/substep.rs
1//! Sub-tick stepping, phase hooks, event drainage.
2//!
3//! Part of the [`super::Simulation`] API surface; extracted from the
4//! monolithic `sim.rs` for readability. See the parent module for the
5//! overarching essential-API summary.
6
7use crate::dispatch::DispatchStrategy;
8use crate::events::EventBus;
9use crate::hooks::Phase;
10use crate::ids::GroupId;
11use crate::metrics::Metrics;
12use crate::sim::PhaseCheck;
13use crate::systems::PhaseContext;
14use std::collections::BTreeMap;
15
16impl super::Simulation {
17 // ── Sub-stepping ────────────────────────────────────────────────
18
19 /// Get the dispatch strategies map (for advanced sub-stepping).
20 ///
21 /// Returns the strategy half of the internally-encapsulated
22 /// dispatcher set — the snapshot identity half is only readable
23 /// through [`strategy_id`](Self::strategy_id) so callers can't
24 /// accidentally drift the two halves out of sync via this accessor.
25 #[must_use]
26 pub fn dispatchers(&self) -> &BTreeMap<GroupId, Box<dyn DispatchStrategy>> {
27 self.dispatcher_set.strategies()
28 }
29
30 /// Get the dispatch strategies map mutably (for advanced sub-stepping).
31 ///
32 /// Direct insertion via this map bypasses the internally-enforced
33 /// strategy/identity atomicity, leaving the snapshot identity
34 /// stale. Prefer [`set_dispatch`](Self::set_dispatch) for swaps;
35 /// reach for this only when a system needs to mutate an
36 /// already-installed trait object in place (e.g. `restore_config`).
37 pub fn dispatchers_mut(&mut self) -> &mut BTreeMap<GroupId, Box<dyn DispatchStrategy>> {
38 self.dispatcher_set.strategies_mut()
39 }
40
41 /// Get a mutable reference to the event bus.
42 pub const fn events_mut(&mut self) -> &mut EventBus {
43 &mut self.events
44 }
45
46 /// Get a mutable reference to the metrics.
47 pub const fn metrics_mut(&mut self) -> &mut Metrics {
48 &mut self.metrics
49 }
50
51 /// Build the `PhaseContext` for the current tick.
52 #[must_use]
53 pub const fn phase_context(&self) -> PhaseContext {
54 PhaseContext {
55 tick: self.tick,
56 dt: self.dt,
57 }
58 }
59
60 /// Enable or disable strict substep phase-order validation.
61 ///
62 /// When enabled, each `run_*` substep method panics if called out
63 /// of the canonical 8-phase order, and `advance_tick()` panics if
64 /// called before `run_metrics()` has run. Default off — opt in
65 /// during host development to fail fast on accidental out-of-order
66 /// calls instead of debugging the downstream symptoms (riders
67 /// boarding through closed doors, movement before dispatch,
68 /// transient rider states bleeding across tick boundaries).
69 ///
70 /// `step()` and `step_many()` always satisfy the order, so flipping
71 /// this on in production code that drives the sim via `step()` is
72 /// safe (and zero overhead — a single branch per phase).
73 ///
74 /// # Canonical order
75 ///
76 /// Each tick: `run_advance_transient` → `run_dispatch` →
77 /// `run_reposition` → `run_advance_queue` → `run_movement` →
78 /// `run_doors` → `run_loading` → `run_metrics` → `advance_tick`.
79 ///
80 /// ```
81 /// use elevator_core::prelude::*;
82 ///
83 /// let mut sim = SimulationBuilder::demo().build().unwrap();
84 /// sim.set_strict_phase_order(true);
85 /// sim.step(); // canonical order — passes the check.
86 /// ```
87 pub const fn set_strict_phase_order(&mut self, enabled: bool) {
88 // Idempotent on a redundant enable: if the guard is already
89 // on, preserve the existing mid-cycle / AwaitingTick state so
90 // a `set_strict_phase_order(true)` call between `run_metrics`
91 // and `advance_tick` doesn't silently erase the
92 // advance_tick-required marker (which would let the consumer
93 // skip the tick-counter increment and event flush).
94 self.phase_check = match (enabled, self.phase_check) {
95 (true, PhaseCheck::Disabled) => PhaseCheck::Expecting(Phase::AdvanceTransient),
96 (true, current) => current,
97 (false, _) => PhaseCheck::Disabled,
98 };
99 }
100
101 /// Whether strict substep phase-order validation is currently
102 /// enabled. Useful for hosts that want to surface the setting in
103 /// debug overlays.
104 #[must_use]
105 pub const fn is_strict_phase_order(&self) -> bool {
106 !matches!(self.phase_check, PhaseCheck::Disabled)
107 }
108
109 /// Validate that `current` is the next-expected phase, then advance
110 /// the expectation to `next`. No-op when the guard is disabled.
111 /// `next == None` means "tick complete, await `advance_tick`".
112 //
113 // `clippy::panic` is workspace-denied, but the AwaitingTick arm is
114 // unrepresentable as an `assert_eq!` against `current` — there is no
115 // single "expected phase" to compare against (the only allowed next
116 // action is `advance_tick`, not a phase). A direct `panic!` with a
117 // tailored message is the right shape for the AwaitingTick arm; the
118 // allow is scoped to this helper only.
119 #[allow(clippy::panic)]
120 fn check_and_advance_phase(&mut self, current: Phase, next: Option<Phase>) {
121 match self.phase_check {
122 PhaseCheck::Disabled => {}
123 PhaseCheck::Expecting(expected) => {
124 assert_eq!(
125 expected, current,
126 "substep phase order violated: expected {expected}, called {current}.\n\
127 Canonical order each tick: advance_transient → dispatch → reposition → \
128 advance_queue → movement → doors → loading → metrics, then advance_tick() \
129 before the next cycle. See Simulation::set_strict_phase_order.",
130 );
131 self.phase_check = next.map_or(PhaseCheck::AwaitingTick, PhaseCheck::Expecting);
132 }
133 PhaseCheck::AwaitingTick => {
134 panic!(
135 "substep phase order violated: called {current} but the previous tick's \
136 metrics phase has run — advance_tick() must run before the next cycle. \
137 See Simulation::set_strict_phase_order.",
138 );
139 }
140 }
141 }
142
143 /// Run only the `advance_transient` phase (with hooks).
144 ///
145 /// # Phase ordering
146 ///
147 /// When calling individual phase methods instead of [`step()`](Self::step),
148 /// phases **must** be called in this order each tick:
149 ///
150 /// 1. `run_advance_transient`
151 /// 2. `run_dispatch`
152 /// 3. `run_reposition`
153 /// 4. `run_advance_queue`
154 /// 5. `run_movement`
155 /// 6. `run_doors`
156 /// 7. `run_loading`
157 /// 8. `run_metrics`
158 ///
159 /// Out-of-order execution may cause riders to board with closed doors,
160 /// elevators to move before dispatch, or transient states to persist
161 /// across tick boundaries.
162 pub fn run_advance_transient(&mut self) {
163 self.check_and_advance_phase(Phase::AdvanceTransient, Some(Phase::Dispatch));
164 self.set_tick_in_progress(true);
165 self.sync_world_tick();
166 self.hooks
167 .run_before(Phase::AdvanceTransient, &mut self.world);
168 for group in &self.groups {
169 self.hooks
170 .run_before_group(Phase::AdvanceTransient, group.id(), &mut self.world);
171 }
172 let ctx = self.phase_context();
173 crate::systems::advance_transient::run(
174 &mut self.world,
175 &mut self.events,
176 &ctx,
177 &mut self.rider_index,
178 );
179 for group in &self.groups {
180 self.hooks
181 .run_after_group(Phase::AdvanceTransient, group.id(), &mut self.world);
182 }
183 self.hooks
184 .run_after(Phase::AdvanceTransient, &mut self.world);
185 }
186
187 /// Run only the dispatch phase (with hooks).
188 pub fn run_dispatch(&mut self) {
189 self.check_and_advance_phase(Phase::Dispatch, Some(Phase::Reposition));
190 self.sync_world_tick();
191 self.hooks.run_before(Phase::Dispatch, &mut self.world);
192 for group in &self.groups {
193 self.hooks
194 .run_before_group(Phase::Dispatch, group.id(), &mut self.world);
195 }
196 let ctx = self.phase_context();
197 crate::systems::dispatch::run(
198 &mut self.world,
199 &mut self.events,
200 &ctx,
201 &self.groups,
202 self.dispatcher_set.strategies_mut(),
203 &self.rider_index,
204 &mut self.dispatch_scratch,
205 );
206 for group in &self.groups {
207 self.hooks
208 .run_after_group(Phase::Dispatch, group.id(), &mut self.world);
209 }
210 self.hooks.run_after(Phase::Dispatch, &mut self.world);
211 }
212
213 /// Run only the movement phase (with hooks).
214 pub fn run_movement(&mut self) {
215 self.check_and_advance_phase(Phase::Movement, Some(Phase::Doors));
216 self.hooks.run_before(Phase::Movement, &mut self.world);
217 for group in &self.groups {
218 self.hooks
219 .run_before_group(Phase::Movement, group.id(), &mut self.world);
220 }
221 let ctx = self.phase_context();
222 self.world.elevator_ids_into(&mut self.elevator_ids_buf);
223 crate::systems::movement::run(
224 &mut self.world,
225 &mut self.events,
226 &ctx,
227 &self.elevator_ids_buf,
228 &mut self.metrics,
229 );
230 for group in &self.groups {
231 self.hooks
232 .run_after_group(Phase::Movement, group.id(), &mut self.world);
233 }
234 self.hooks.run_after(Phase::Movement, &mut self.world);
235 }
236
237 /// Run only the doors phase (with hooks).
238 pub fn run_doors(&mut self) {
239 self.check_and_advance_phase(Phase::Doors, Some(Phase::Loading));
240 self.hooks.run_before(Phase::Doors, &mut self.world);
241 for group in &self.groups {
242 self.hooks
243 .run_before_group(Phase::Doors, group.id(), &mut self.world);
244 }
245 let ctx = self.phase_context();
246 self.world.elevator_ids_into(&mut self.elevator_ids_buf);
247 crate::systems::doors::run(
248 &mut self.world,
249 &mut self.events,
250 &ctx,
251 &self.groups,
252 &self.elevator_ids_buf,
253 );
254 for group in &self.groups {
255 self.hooks
256 .run_after_group(Phase::Doors, group.id(), &mut self.world);
257 }
258 self.hooks.run_after(Phase::Doors, &mut self.world);
259 }
260
261 /// Run only the loading phase (with hooks).
262 pub fn run_loading(&mut self) {
263 self.check_and_advance_phase(Phase::Loading, Some(Phase::Metrics));
264 self.hooks.run_before(Phase::Loading, &mut self.world);
265 for group in &self.groups {
266 self.hooks
267 .run_before_group(Phase::Loading, group.id(), &mut self.world);
268 }
269 let ctx = self.phase_context();
270 self.world.elevator_ids_into(&mut self.elevator_ids_buf);
271 crate::systems::loading::run(
272 &mut self.world,
273 &mut self.events,
274 &ctx,
275 &self.groups,
276 &self.elevator_ids_buf,
277 &mut self.rider_index,
278 );
279 for group in &self.groups {
280 self.hooks
281 .run_after_group(Phase::Loading, group.id(), &mut self.world);
282 }
283 self.hooks.run_after(Phase::Loading, &mut self.world);
284 }
285
286 /// Run only the advance-queue phase (with hooks).
287 ///
288 /// Reconciles each elevator's phase/target with the front of its
289 /// [`DestinationQueue`](crate::components::DestinationQueue). Runs
290 /// between Reposition and Movement.
291 pub fn run_advance_queue(&mut self) {
292 self.check_and_advance_phase(Phase::AdvanceQueue, Some(Phase::Movement));
293 self.hooks.run_before(Phase::AdvanceQueue, &mut self.world);
294 for group in &self.groups {
295 self.hooks
296 .run_before_group(Phase::AdvanceQueue, group.id(), &mut self.world);
297 }
298 let ctx = self.phase_context();
299 self.world.elevator_ids_into(&mut self.elevator_ids_buf);
300 crate::systems::advance_queue::run(
301 &mut self.world,
302 &mut self.events,
303 &ctx,
304 &self.groups,
305 &self.elevator_ids_buf,
306 );
307 for group in &self.groups {
308 self.hooks
309 .run_after_group(Phase::AdvanceQueue, group.id(), &mut self.world);
310 }
311 self.hooks.run_after(Phase::AdvanceQueue, &mut self.world);
312 }
313
314 /// Run only the reposition phase (with hooks).
315 ///
316 /// Global before/after hooks always fire even when no
317 /// [`RepositionStrategy`](crate::dispatch::RepositionStrategy) is
318 /// configured. Per-group hooks only fire for groups that have a
319 /// repositioner — this differs from other phases where per-group hooks
320 /// fire unconditionally.
321 pub fn run_reposition(&mut self) {
322 self.check_and_advance_phase(Phase::Reposition, Some(Phase::AdvanceQueue));
323 self.sync_world_tick();
324 self.hooks.run_before(Phase::Reposition, &mut self.world);
325 if !self.repositioner_set.is_empty() {
326 // Only run per-group hooks for groups that have a repositioner.
327 for group in &self.groups {
328 if self.repositioner_set.contains_key(group.id()) {
329 self.hooks
330 .run_before_group(Phase::Reposition, group.id(), &mut self.world);
331 }
332 }
333 let ctx = self.phase_context();
334 crate::systems::reposition::run(
335 &mut self.world,
336 &mut self.events,
337 &ctx,
338 &self.groups,
339 self.repositioner_set.strategies_mut(),
340 &mut self.reposition_buf,
341 );
342 for group in &self.groups {
343 if self.repositioner_set.contains_key(group.id()) {
344 self.hooks
345 .run_after_group(Phase::Reposition, group.id(), &mut self.world);
346 }
347 }
348 }
349 self.hooks.run_after(Phase::Reposition, &mut self.world);
350 }
351
352 /// Run the energy system (no hooks — inline phase).
353 #[cfg(feature = "energy")]
354 fn run_energy(&mut self) {
355 let ctx = self.phase_context();
356 self.world.elevator_ids_into(&mut self.elevator_ids_buf);
357 crate::systems::energy::run(
358 &mut self.world,
359 &mut self.events,
360 &ctx,
361 &self.elevator_ids_buf,
362 );
363 }
364
365 /// Run only the metrics phase (with hooks).
366 pub fn run_metrics(&mut self) {
367 // None → AwaitingTick: advance_tick() must come before the next cycle.
368 self.check_and_advance_phase(Phase::Metrics, None);
369 self.hooks.run_before(Phase::Metrics, &mut self.world);
370 for group in &self.groups {
371 self.hooks
372 .run_before_group(Phase::Metrics, group.id(), &mut self.world);
373 }
374 let ctx = self.phase_context();
375 crate::systems::metrics::run(
376 &mut self.world,
377 &self.events,
378 &mut self.metrics,
379 &ctx,
380 &self.groups,
381 );
382 for group in &self.groups {
383 self.hooks
384 .run_after_group(Phase::Metrics, group.id(), &mut self.world);
385 }
386 self.hooks.run_after(Phase::Metrics, &mut self.world);
387 }
388
389 // Phase-hook registration lives in `sim/construction.rs`.
390
391 /// Increment the tick counter and flush events to the output buffer.
392 ///
393 /// Call after running all desired phases. Events emitted during this tick
394 /// are moved to the output buffer and available via `drain_events()`.
395 //
396 // `clippy::panic` is workspace-denied, but the two `Expecting(...)`
397 // arms below describe distinct guard violations that don't fit a
398 // single `assert_eq!`: the start-of-cycle case has no value to
399 // compare against, and the mid-cycle case panics whenever the
400 // expected phase is anything other than AdvanceTransient — which is
401 // already handled by the other arm. Tailored `panic!` messages
402 // surface the failure context; allow is scoped to this function.
403 #[allow(clippy::panic)]
404 pub fn advance_tick(&mut self) {
405 // Reset the substep guard to the start of the next cycle. With
406 // strict mode on, `advance_tick()` is only valid after
407 // `run_metrics()` (the `AwaitingTick` state). Any `Expecting(...)`
408 // means the host stopped short — either mid-cycle after some
409 // run_*'s, or at the start of a cycle with zero run_*'s. Both
410 // would silently bump the tick counter on a half-stepped (or
411 // empty) cycle, so reject both.
412 match self.phase_check {
413 PhaseCheck::Disabled => {}
414 PhaseCheck::AwaitingTick => {
415 self.phase_check = PhaseCheck::Expecting(Phase::AdvanceTransient);
416 }
417 PhaseCheck::Expecting(Phase::AdvanceTransient) => {
418 // Zero phases ran since the last advance_tick (or since
419 // enabling strict mode). Reject to keep the
420 // documented invariant "advance_tick only fires after
421 // run_metrics" honest.
422 panic!(
423 "advance_tick() called with zero phases run this cycle. \
424 Strict mode requires the full canonical phase sequence \
425 per tick: advance_transient → dispatch → reposition → \
426 advance_queue → movement → doors → loading → metrics, \
427 then advance_tick(). See Simulation::set_strict_phase_order.",
428 );
429 }
430 PhaseCheck::Expecting(phase) => {
431 panic!(
432 "advance_tick() called mid-tick: expected to be entering phase {phase} but the \
433 metrics phase has not run yet. See Simulation::set_strict_phase_order.",
434 );
435 }
436 }
437 self.pending_output.extend(self.events.drain());
438 self.tick += 1;
439 self.set_tick_in_progress(false);
440 // Keep the `CurrentTick` world resource in lockstep after the tick
441 // counter advances; substep consumers driving phases manually
442 // will see the fresh value on their next call.
443 self.sync_world_tick();
444 // Drop arrival-log entries older than the configured retention.
445 // Unbounded growth would turn `arrivals_in_window` into an O(n)
446 // per-stop per-tick scan.
447 let retention = self
448 .world
449 .resource::<crate::arrival_log::ArrivalLogRetention>()
450 .copied()
451 .unwrap_or_default()
452 .0;
453 let cutoff = self.tick.saturating_sub(retention);
454 if let Some(log) = self.world.resource_mut::<crate::arrival_log::ArrivalLog>() {
455 log.prune_before(cutoff);
456 }
457 if let Some(log) = self
458 .world
459 .resource_mut::<crate::arrival_log::DestinationLog>()
460 {
461 log.prune_before(cutoff);
462 }
463 }
464
465 /// Mirror `self.tick` into the `CurrentTick` world resource so
466 /// phases that only have `&World` (reposition strategies, custom
467 /// consumers) can compute rolling-window queries without plumbing
468 /// `PhaseContext`. Called from `step()` and `advance_tick()` so
469 /// manual-phase callers stay in sync too.
470 fn sync_world_tick(&mut self) {
471 if let Some(ct) = self.world.resource_mut::<crate::arrival_log::CurrentTick>() {
472 ct.0 = self.tick;
473 }
474 }
475
476 /// Advance the simulation by one tick.
477 ///
478 /// Events from this tick are buffered internally and available via
479 /// `drain_events()`. The metrics system only processes events from
480 /// the current tick, regardless of whether the consumer drains them.
481 ///
482 /// ```
483 /// use elevator_core::prelude::*;
484 ///
485 /// let mut sim = SimulationBuilder::demo().build().unwrap();
486 /// sim.step();
487 /// assert_eq!(sim.current_tick(), 1);
488 /// ```
489 pub fn step(&mut self) {
490 self.sync_world_tick();
491 self.world.snapshot_prev_positions();
492 self.run_advance_transient();
493 self.run_dispatch();
494 self.run_reposition();
495 self.run_advance_queue();
496 self.run_movement();
497 self.run_doors();
498 self.run_loading();
499 #[cfg(feature = "energy")]
500 self.run_energy();
501 self.run_metrics();
502 self.advance_tick();
503 }
504
505 /// Advance the simulation by `n` ticks.
506 ///
507 /// Equivalent to calling [`step`](Self::step) `n` times. Hosts driving
508 /// the sim across an FFI / wasm boundary should prefer this over a
509 /// per-tick loop on their side: keeping the loop in Rust avoids
510 /// per-tick boundary crossings that add up at scale.
511 ///
512 /// Events from each tick accumulate in the internal queue; consumers
513 /// call [`drain_events`](Self::drain_events) once after the batch to
514 /// read the cumulative stream.
515 ///
516 /// `n == 0` is a no-op.
517 ///
518 /// ```
519 /// use elevator_core::prelude::*;
520 ///
521 /// let mut sim = SimulationBuilder::demo().build().unwrap();
522 /// sim.step_many(60);
523 /// assert_eq!(sim.current_tick(), 60);
524 /// ```
525 pub fn step_many(&mut self, n: u32) {
526 for _ in 0..n {
527 self.step();
528 }
529 }
530
531 /// Step the simulation until every rider reaches a terminal phase
532 /// (`Arrived`, `Abandoned`, or `Resident`), draining events each
533 /// tick so event-driven metrics stay up to date.
534 ///
535 /// Returns the number of ticks actually stepped, or `Err(max_ticks)`
536 /// if the budget was exhausted before the sim drained. The cap is a
537 /// safety net against a stuck dispatch or an unserviceable rider
538 /// holding the tick loop open forever — right-size it for your
539 /// workload and fail fast rather than spinning silently.
540 ///
541 /// A sim with zero riders returns `Ok(0)` immediately.
542 ///
543 /// ```
544 /// use elevator_core::prelude::*;
545 /// use elevator_core::stop::StopId;
546 ///
547 /// let mut sim = SimulationBuilder::demo().build().unwrap();
548 /// sim.spawn_rider(StopId(0), StopId(1), 70.0).unwrap();
549 /// let ticks = sim.run_until_quiet(2_000).expect("sim drained in time");
550 /// assert!(sim.metrics().total_delivered() >= 1);
551 /// assert!(ticks <= 2_000);
552 /// ```
553 ///
554 /// # Errors
555 /// Returns `Err(max_ticks)` when `max_ticks` elapse without every
556 /// rider reaching a terminal phase. Inspect `sim.world()`
557 /// iteration or `sim.metrics()` to diagnose stuck riders; the
558 /// sim is left in its partially-advanced state so you can
559 /// snapshot it for post-mortem.
560 pub fn run_until_quiet(&mut self, max_ticks: u64) -> Result<u64, u64> {
561 use crate::components::RiderPhase;
562
563 fn all_quiet(sim: &super::Simulation) -> bool {
564 sim.world().iter_riders().all(|(_, r)| {
565 matches!(
566 r.phase(),
567 RiderPhase::Arrived | RiderPhase::Abandoned | RiderPhase::Resident
568 )
569 })
570 }
571
572 if all_quiet(self) {
573 return Ok(0);
574 }
575 for tick in 1..=max_ticks {
576 self.step();
577 let _ = self.drain_events();
578 if all_quiet(self) {
579 return Ok(tick);
580 }
581 }
582 Err(max_ticks)
583 }
584}