elevator_core/dispatch/loop_schedule.rs
1//! `LoopSchedule` — fixed-dwell timetable for [`LineKind::Loop`] groups.
2//!
3//! Where [`crate::dispatch::LoopSweepDispatch`] lets each Loop car
4//! carry whatever per-car `door_open_ticks` the config specified
5//! (so dwell tracks the rider load at each stop), `LoopSchedule`
6//! overrides every Loop car in the group to a single
7//! `dwell_ticks` value. The resulting timetable is predictable —
8//! every car spends the same amount of time at every stop on every
9//! lap — which is what people-mover lines, gondolas, and timetabled
10//! shuttle services want.
11//!
12//! ## What this PR ships
13//!
14//! - Fixed-dwell override applied via `pre_dispatch`: every Loop car
15//! in the group has its `door_open_ticks` rewritten to the schedule's
16//! `dwell_ticks` once per pass. Idempotent — the same value is
17//! written unconditionally each tick, so re-applying the strategy
18//! leaves car state unchanged.
19//! - Round-trips through snapshots and config: `builtin_id` returns
20//! [`BuiltinStrategy::LoopSchedule`] and `snapshot_config` /
21//! `restore_config` carry the two tunable fields.
22//! - The construction-time validator (relaxed from the
23//! `LoopSweep`-only check in PR #816) accepts both `LoopSweep` and
24//! `LoopSchedule` on Loop groups.
25//!
26//! ## Deferred to a follow-up
27//!
28//! The `target_headway_ticks` field is parsed and serialized but the
29//! hold-recovery mechanism that would consume it (extending dwell when
30//! a car arrives early relative to the preceding car so the schedule
31//! resynchronises) lands in the next PR in this series. Keeping it on
32//! the struct now is forward-compatible: snapshots taken today survive
33//! the wiring change unchanged.
34//!
35//! Bunching under heavy load is therefore a known v1 limitation. With
36//! fixed dwell alone, a leading car that picks up an unusually large
37//! group can fall behind schedule, and the following car catches up.
38//! Hold-recovery prevents that follower-on-leader bunching.
39//!
40//! [`LineKind::Loop`]: crate::components::LineKind::Loop
41
42use crate::entity::EntityId;
43use crate::world::World;
44
45use super::{BuiltinStrategy, DispatchManifest, DispatchStrategy, ElevatorGroup, RankContext};
46
47/// Dispatch strategy that holds Loop cars to a uniform dwell at every
48/// stop.
49///
50/// See the module-level documentation for the full contract. The two
51/// tunable fields are exposed through accessors so hosts can inspect
52/// the schedule in-flight (HUDs, debuggers). The struct itself is
53/// immutable after construction — replace the active strategy via
54/// [`Simulation::set_dispatch`](crate::sim::Simulation::set_dispatch)
55/// with a freshly-built instance to retune live, or rely on
56/// [`restore_config`](DispatchStrategy::restore_config) on the snapshot
57/// path.
58#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
59pub struct LoopScheduleDispatch {
60 /// Target dwell at each stop, in ticks. Overrides every Loop car's
61 /// per-car `door_open_ticks` whenever this strategy is active on
62 /// the group.
63 dwell_ticks: u32,
64 /// Desired tick gap between consecutive cars arriving at the same
65 /// stop. Held on the struct now for snapshot-stability; the
66 /// hold-recovery path that consumes it ships in a follow-up PR.
67 target_headway_ticks: u32,
68}
69
70impl LoopScheduleDispatch {
71 /// Construct a `LoopScheduleDispatch`.
72 ///
73 /// Both `dwell_ticks` and `target_headway_ticks` are clamped to a
74 /// minimum of `1` — a zero dwell would collapse the door cycle into
75 /// a no-op (the car arrives, immediately departs, never boards),
76 /// and a zero headway is meaningless. Construction-time validation
77 /// in `validate_explicit_topology` rejects pathological values up
78 /// front; this clamp is a defence in depth for hosts wiring the
79 /// strategy at runtime through `set_dispatch`.
80 #[must_use]
81 pub const fn new(dwell_ticks: u32, target_headway_ticks: u32) -> Self {
82 Self {
83 dwell_ticks: if dwell_ticks == 0 { 1 } else { dwell_ticks },
84 target_headway_ticks: if target_headway_ticks == 0 {
85 1
86 } else {
87 target_headway_ticks
88 },
89 }
90 }
91
92 /// Dwell at each stop, in ticks. See [`Self::new`] for the
93 /// invariants this guarantees.
94 #[must_use]
95 pub const fn dwell_ticks(&self) -> u32 {
96 self.dwell_ticks
97 }
98
99 /// Desired tick gap between consecutive arrivals.
100 #[must_use]
101 pub const fn target_headway_ticks(&self) -> u32 {
102 self.target_headway_ticks
103 }
104}
105
106impl Default for LoopScheduleDispatch {
107 /// Sensible defaults for a 60-tick-per-second sim: a 30-tick
108 /// (half-second) dwell and a 300-tick (5-second) headway target.
109 /// Hosts should call [`Self::new`] with values matched to their
110 /// line geometry rather than relying on these.
111 fn default() -> Self {
112 Self::new(30, 300)
113 }
114}
115
116impl DispatchStrategy for LoopScheduleDispatch {
117 fn pre_dispatch(
118 &mut self,
119 group: &ElevatorGroup,
120 _manifest: &DispatchManifest,
121 world: &mut World,
122 ) {
123 // Stamp the schedule's dwell onto every Loop car in the group.
124 // We rewrite unconditionally rather than compare-then-write
125 // because the comparison + branch saves nothing in practice
126 // (the field is a `u32` on a struct already in cache) and the
127 // unconditional write keeps the operation defensively
128 // idempotent against any host that re-set `door_open_ticks`
129 // out-of-band between ticks.
130 //
131 // Lines that the group claims but the world doesn't know about,
132 // and elevators whose entity has been removed since the group
133 // was last rebuilt, are simply skipped — there's no useful work
134 // to do, and silently degrading matches how every other
135 // dispatch strategy handles dangling references.
136 for line in group.lines() {
137 if !world
138 .line(line.entity())
139 .is_some_and(crate::components::Line::is_loop)
140 {
141 continue;
142 }
143 for &eid in line.elevators() {
144 if let Some(car) = world.elevator_mut(eid) {
145 car.door_open_ticks = self.dwell_ticks;
146 }
147 }
148 }
149 }
150
151 fn rank(&self, _ctx: &RankContext<'_>) -> Option<f64> {
152 // Loop cars are excluded from the Hungarian idle pool by
153 // `systems::dispatch::run`, so this method is unreachable in
154 // practice. Returning `None` keeps the contract conservative
155 // (a `Some(finite)` would have to invent a meaningless cost).
156 None
157 }
158
159 fn builtin_id(&self) -> Option<BuiltinStrategy> {
160 Some(BuiltinStrategy::LoopSchedule)
161 }
162
163 fn snapshot_config(&self) -> Option<String> {
164 // RON-serialize the tunable fields so snapshot round-trip
165 // preserves the schedule's identity. Without this, restoring a
166 // snapshot would call `LoopScheduleDispatch::default()` via
167 // `BuiltinStrategy::instantiate` and silently downgrade
168 // whatever the live sim configured.
169 ron::to_string(self).ok()
170 }
171
172 fn restore_config(&mut self, config: &str) -> Result<(), String> {
173 // A garbled config is a snapshot/version drift bug. Surface
174 // the parse error to the caller (the snapshot restore path)
175 // rather than swallow it — `WorldSnapshot::restore` propagates
176 // it back as the restore error so the caller sees a clear
177 // failure instead of a silently-defaulted strategy with
178 // observably different dwell timing.
179 let restored: Self = ron::from_str(config).map_err(|e| e.to_string())?;
180 *self = restored;
181 Ok(())
182 }
183
184 fn notify_removed(&mut self, _elevator: EntityId) {
185 // No per-car state to evict.
186 }
187}