Skip to main content

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}