elevator_core/components/elevator.rs
1//! Elevator state and configuration component.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5
6use super::units::{Accel, Speed, Weight};
7use crate::door::{DoorCommand, DoorState};
8use crate::entity::EntityId;
9
10/// Maximum number of manual door commands queued per elevator.
11///
12/// Beyond this cap, the oldest entry is dropped (after adjacent-duplicate
13/// collapsing). Prevents runaway growth if a game submits commands faster
14/// than the sim can apply them.
15pub const DOOR_COMMAND_QUEUE_CAP: usize = 16;
16
17/// Direction an elevator's indicator lamps are signalling.
18///
19/// On a [`LineKind::Linear`](crate::components::LineKind::Linear) shaft this
20/// is derived from the pair of `going_up` / `going_down` flags on
21/// [`Elevator`]. `Either` corresponds to both lamps lit — the car is idle
22/// and will accept riders heading either way; `Up` / `Down` correspond to
23/// an actively committed direction.
24///
25/// On a [`LineKind::Loop`](crate::components::LineKind::Loop) the
26/// `Up`/`Down`/`Either` distinction is meaningless (every car serves every
27/// stop forward through the loop), so Loop cars report
28/// [`Direction::Forward`] instead. HUD/metrics consumers should
29/// pattern-match exhaustively on the enum (it is `#[non_exhaustive]`).
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[non_exhaustive]
32pub enum Direction {
33 /// Car will serve upward trips only.
34 Up,
35 /// Car will serve downward trips only.
36 Down,
37 /// Car will serve either direction (idle).
38 Either,
39 /// Car is travelling forward around a one-way loop. Loop topologies
40 /// have no "up"/"down" axis, so neither lamp is meaningful — this
41 /// variant is the honest replacement for `Either` on Loop cars.
42 /// Linear cars never report this variant.
43 Forward,
44}
45
46impl std::fmt::Display for Direction {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 Self::Up => write!(f, "Up"),
50 Self::Down => write!(f, "Down"),
51 Self::Either => write!(f, "Either"),
52 Self::Forward => write!(f, "Forward"),
53 }
54 }
55}
56
57/// Operational phase of an elevator.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[non_exhaustive]
60pub enum ElevatorPhase {
61 /// Parked with no pending requests.
62 Idle,
63 /// Travelling toward a specific stop in response to a dispatch
64 /// assignment (carrying or about to pick up riders).
65 MovingToStop(EntityId),
66 /// Travelling toward a stop for repositioning — no rider service
67 /// obligation, will transition directly to [`Idle`] on arrival
68 /// without opening doors. Distinct from [`MovingToStop`] so that
69 /// downstream code (dispatch, UI, metrics) can treat opportunistic
70 /// moves differently from scheduled trips.
71 ///
72 /// [`MovingToStop`]: Self::MovingToStop
73 /// [`Idle`]: Self::Idle
74 Repositioning(EntityId),
75 /// Doors are currently opening.
76 DoorOpening,
77 /// Doors open; riders may board or exit.
78 Loading,
79 /// Doors are currently closing.
80 DoorClosing,
81 /// Stopped at a floor (doors closed, awaiting dispatch).
82 Stopped,
83}
84
85impl ElevatorPhase {
86 /// Whether the elevator is currently travelling (in either a dispatched
87 /// or a repositioning move).
88 #[must_use]
89 pub const fn is_moving(&self) -> bool {
90 matches!(self, Self::MovingToStop(_) | Self::Repositioning(_))
91 }
92
93 /// The target stop of a moving elevator, if any.
94 ///
95 /// Returns `Some(stop)` for both [`MovingToStop`] and [`Repositioning`]
96 /// variants; `None` otherwise.
97 ///
98 /// [`MovingToStop`]: Self::MovingToStop
99 /// [`Repositioning`]: Self::Repositioning
100 #[must_use]
101 pub const fn moving_target(&self) -> Option<EntityId> {
102 match self {
103 Self::MovingToStop(s) | Self::Repositioning(s) => Some(*s),
104 _ => None,
105 }
106 }
107}
108
109impl std::fmt::Display for ElevatorPhase {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 match self {
112 Self::Idle => write!(f, "Idle"),
113 Self::MovingToStop(id) => write!(f, "MovingToStop({id:?})"),
114 Self::Repositioning(id) => write!(f, "Repositioning({id:?})"),
115 Self::DoorOpening => write!(f, "DoorOpening"),
116 Self::Loading => write!(f, "Loading"),
117 Self::DoorClosing => write!(f, "DoorClosing"),
118 Self::Stopped => write!(f, "Stopped"),
119 }
120 }
121}
122
123/// Component for an elevator entity.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[allow(
126 clippy::struct_excessive_bools,
127 reason = "the indicator-lamp triple (going_up, going_down, going_forward) plus repositioning are independently observable signals; collapsing them into an enum would lose the ability to express idle (both up and down lamps lit) and forces hosts that read individual lamps to round-trip through enum match arms"
128)]
129pub struct Elevator {
130 /// Current operational phase.
131 pub(crate) phase: ElevatorPhase,
132 /// Door finite-state machine.
133 pub(crate) door: DoorState,
134 /// Maximum travel speed (distance/tick).
135 pub(crate) max_speed: Speed,
136 /// Acceleration rate (distance/tick^2).
137 pub(crate) acceleration: Accel,
138 /// Deceleration rate (distance/tick^2).
139 pub(crate) deceleration: Accel,
140 /// Maximum weight the car can carry.
141 pub(crate) weight_capacity: Weight,
142 /// Total weight of riders currently aboard.
143 pub(crate) current_load: Weight,
144 /// Entity IDs of riders currently aboard.
145 pub(crate) riders: Vec<EntityId>,
146 /// Stop entity the car is heading toward, if any.
147 pub(crate) target_stop: Option<EntityId>,
148 /// Ticks for a door open/close transition.
149 pub(crate) door_transition_ticks: u32,
150 /// Ticks the door stays fully open.
151 pub(crate) door_open_ticks: u32,
152 /// Line entity this car belongs to.
153 #[serde(alias = "group")]
154 pub(crate) line: EntityId,
155 /// Whether this elevator is currently repositioning (not serving a dispatch).
156 #[serde(default)]
157 pub(crate) repositioning: bool,
158 /// Stop entity IDs this elevator cannot serve (access restriction).
159 #[serde(default)]
160 pub(crate) restricted_stops: HashSet<EntityId>,
161 /// Speed multiplier for Inspection mode (0.0..1.0).
162 #[serde(default = "default_inspection_speed_factor")]
163 pub(crate) inspection_speed_factor: f64,
164 /// Up-direction indicator lamp: whether this car will serve upward trips.
165 ///
166 /// Auto-managed by the dispatch phase: set true when heading up (or idle),
167 /// false while actively committed to a downward trip. Affects boarding:
168 /// a rider whose next leg goes up will not board a car with `going_up=false`.
169 ///
170 /// On a [`LineKind::Loop`](crate::components::LineKind::Loop) this lamp
171 /// has no meaning — `going_forward` is consulted instead.
172 #[serde(default = "default_true")]
173 pub(crate) going_up: bool,
174 /// Down-direction indicator lamp: whether this car will serve downward trips.
175 ///
176 /// Auto-managed by the dispatch phase: set true when heading down (or idle),
177 /// false while actively committed to an upward trip. Affects boarding:
178 /// a rider whose next leg goes down will not board a car with `going_down=false`.
179 ///
180 /// On a [`LineKind::Loop`](crate::components::LineKind::Loop) this lamp
181 /// has no meaning — `going_forward` is consulted instead.
182 #[serde(default = "default_true")]
183 pub(crate) going_down: bool,
184 /// Forward-direction indicator lamp for closed-loop topologies.
185 ///
186 /// On a [`LineKind::Loop`](crate::components::LineKind::Loop) line, the
187 /// `Up`/`Down` lamp pair is meaningless; this lamp signals "actively
188 /// patrolling forward around the loop". Linear cars always have
189 /// `going_forward = false`. Loop cars are kickstarted in the dispatch
190 /// phase and have it flipped to `true` once they begin patrol; the
191 /// door-FSM continuation keeps it set across each subsequent stop.
192 /// Defaults to `false` so existing snapshots and Linear cars are
193 /// unaffected.
194 #[serde(default)]
195 pub(crate) going_forward: bool,
196 /// Count of rounded-floor transitions (passing-floors + arrivals).
197 /// Useful as a scoring axis for efficiency — fewer moves per delivery
198 /// means less wasted travel.
199 #[serde(default)]
200 pub(crate) move_count: u64,
201 /// Pending manual door-control commands. Processed at the start of the
202 /// doors phase; commands that aren't yet valid remain queued.
203 #[serde(default)]
204 pub(crate) door_command_queue: Vec<DoorCommand>,
205 /// Target velocity commanded by the game while in
206 /// [`ServiceMode::Manual`](crate::components::ServiceMode::Manual).
207 ///
208 /// `None` means no command is active — the car coasts to a stop using
209 /// `deceleration`. Read the current target via
210 /// [`Elevator::manual_target_velocity`].
211 #[serde(default)]
212 pub(crate) manual_target_velocity: Option<f64>,
213 /// Load-ratio threshold (0..=1) above which this car ignores new
214 /// upward hall calls. Modeled on Otis Elevonic 411's full-load
215 /// bypass (patent US5490580A). `None` disables the bypass — the
216 /// default for backwards compatibility.
217 ///
218 /// Aboard riders still get delivered regardless; the threshold
219 /// affects *pickup* decisions only.
220 #[serde(default)]
221 pub(crate) bypass_load_up_pct: Option<f64>,
222 /// Load-ratio threshold for downward-direction pickups. Typically
223 /// set lower than `bypass_load_up_pct` because down-peak riders
224 /// accumulate more gradually and a half-full car has less incentive
225 /// to skip the next stop. `None` disables the bypass.
226 #[serde(default)]
227 pub(crate) bypass_load_down_pct: Option<f64>,
228 /// Per-elevator home stop. When `Some(stop)`, the reposition phase
229 /// hard-pins this car to that stop: any time the car is idle and
230 /// off-position, it is routed home regardless of the group's
231 /// reposition strategy. Useful for express cars assigned to a
232 /// dedicated lobby or service cars that should park in a
233 /// loading bay between requests. `None` (the default) leaves
234 /// reposition decisions entirely to the group strategy.
235 #[serde(default)]
236 pub(crate) home_stop: Option<EntityId>,
237}
238
239/// Default inspection speed factor (25% of normal speed).
240const fn default_inspection_speed_factor() -> f64 {
241 0.25
242}
243
244/// Default value for direction indicator fields (both lamps on = idle/either direction).
245const fn default_true() -> bool {
246 true
247}
248
249impl Elevator {
250 /// Current operational phase.
251 #[must_use]
252 pub const fn phase(&self) -> ElevatorPhase {
253 self.phase
254 }
255
256 /// Door finite-state machine.
257 #[must_use]
258 pub const fn door(&self) -> &DoorState {
259 &self.door
260 }
261
262 /// Maximum travel speed (distance/tick).
263 #[must_use]
264 pub const fn max_speed(&self) -> Speed {
265 self.max_speed
266 }
267
268 /// Acceleration rate (distance/tick^2).
269 #[must_use]
270 pub const fn acceleration(&self) -> Accel {
271 self.acceleration
272 }
273
274 /// Deceleration rate (distance/tick^2).
275 #[must_use]
276 pub const fn deceleration(&self) -> Accel {
277 self.deceleration
278 }
279
280 /// Maximum weight the car can carry.
281 #[must_use]
282 pub const fn weight_capacity(&self) -> Weight {
283 self.weight_capacity
284 }
285
286 /// Total weight of riders currently aboard.
287 #[must_use]
288 pub const fn current_load(&self) -> Weight {
289 self.current_load
290 }
291
292 /// Entity IDs of riders currently aboard.
293 #[must_use]
294 pub fn riders(&self) -> &[EntityId] {
295 &self.riders
296 }
297
298 /// Stop entity the car is heading toward, if any.
299 #[must_use]
300 pub const fn target_stop(&self) -> Option<EntityId> {
301 self.target_stop
302 }
303
304 /// Ticks for a door open/close transition.
305 #[must_use]
306 pub const fn door_transition_ticks(&self) -> u32 {
307 self.door_transition_ticks
308 }
309
310 /// Ticks the door stays fully open.
311 #[must_use]
312 pub const fn door_open_ticks(&self) -> u32 {
313 self.door_open_ticks
314 }
315
316 /// Line entity this car belongs to.
317 #[must_use]
318 pub const fn line(&self) -> EntityId {
319 self.line
320 }
321
322 /// Whether this elevator is currently repositioning (not serving a dispatch).
323 #[must_use]
324 pub const fn repositioning(&self) -> bool {
325 self.repositioning
326 }
327
328 /// Stop entity IDs this elevator cannot serve (access restriction).
329 #[must_use]
330 pub const fn restricted_stops(&self) -> &HashSet<EntityId> {
331 &self.restricted_stops
332 }
333
334 /// Speed multiplier applied during Inspection mode.
335 #[must_use]
336 pub const fn inspection_speed_factor(&self) -> f64 {
337 self.inspection_speed_factor
338 }
339
340 /// Load-ratio threshold above which upward-direction pickups are
341 /// skipped (full-load bypass). `None` disables the bypass. See the
342 /// field docs on [`Elevator`] for the underlying model.
343 #[must_use]
344 pub const fn bypass_load_up_pct(&self) -> Option<f64> {
345 self.bypass_load_up_pct
346 }
347
348 /// Load-ratio threshold above which downward-direction pickups are
349 /// skipped. `None` disables the bypass.
350 #[must_use]
351 pub const fn bypass_load_down_pct(&self) -> Option<f64> {
352 self.bypass_load_down_pct
353 }
354
355 /// Mutator for the upward full-load bypass threshold.
356 pub const fn set_bypass_load_up_pct(&mut self, pct: Option<f64>) {
357 self.bypass_load_up_pct = pct;
358 }
359
360 /// Mutator for the downward full-load bypass threshold.
361 pub const fn set_bypass_load_down_pct(&mut self, pct: Option<f64>) {
362 self.bypass_load_down_pct = pct;
363 }
364
365 /// Per-elevator hard-pinned home stop. When `Some(stop)`, the
366 /// reposition phase routes this car to `stop` whenever it is
367 /// idle and off-position, regardless of the group's reposition
368 /// strategy. `None` (the default) leaves reposition decisions
369 /// entirely to the group strategy.
370 #[must_use]
371 pub const fn home_stop(&self) -> Option<EntityId> {
372 self.home_stop
373 }
374
375 /// Whether this car's up-direction indicator lamp is lit.
376 ///
377 /// A lit up-lamp signals the car will serve upward-travelling riders.
378 /// Both lamps lit means the car is idle and will accept either direction.
379 #[must_use]
380 pub const fn going_up(&self) -> bool {
381 self.going_up
382 }
383
384 /// Whether this car's down-direction indicator lamp is lit.
385 ///
386 /// A lit down-lamp signals the car will serve downward-travelling riders.
387 /// Both lamps lit means the car is idle and will accept either direction.
388 #[must_use]
389 pub const fn going_down(&self) -> bool {
390 self.going_down
391 }
392
393 /// Whether this car's forward-direction indicator lamp is lit.
394 ///
395 /// Only meaningful on [`LineKind::Loop`](crate::components::LineKind::Loop)
396 /// lines. Set true while a Loop car is patrolling forward, false when
397 /// pulled out of service. Always false for Linear cars.
398 #[must_use]
399 pub const fn going_forward(&self) -> bool {
400 self.going_forward
401 }
402
403 /// Direction this car is currently committed to, derived from the
404 /// indicator-lamp triple.
405 ///
406 /// - [`Direction::Forward`] — `going_forward` is set (Loop cars only)
407 /// - [`Direction::Up`] — only `going_up` is set
408 /// - [`Direction::Down`] — only `going_down` is set
409 /// - [`Direction::Either`] — both up/down lamps lit (car is idle /
410 /// accepting either direction), or neither is set (treated as
411 /// `Either` too, though the dispatch phase normally keeps at least
412 /// one lit)
413 ///
414 /// Forward dominates the up/down pair so a Loop car never reports a
415 /// stale `Up`/`Down`/`Either` value left over from earlier Linear
416 /// behaviour.
417 #[must_use]
418 pub const fn direction(&self) -> Direction {
419 if self.going_forward {
420 return Direction::Forward;
421 }
422 match (self.going_up, self.going_down) {
423 (true, false) => Direction::Up,
424 (false, true) => Direction::Down,
425 _ => Direction::Either,
426 }
427 }
428
429 /// Count of rounded-floor transitions this elevator has made
430 /// (both passing-floor crossings and arrivals).
431 #[must_use]
432 pub const fn move_count(&self) -> u64 {
433 self.move_count
434 }
435
436 /// Pending manual door-control commands for this elevator.
437 ///
438 /// Populated by
439 /// [`Simulation::open_door`](crate::sim::Simulation::open_door)
440 /// and its siblings. Commands are drained at the start of each doors-phase
441 /// tick; any that aren't yet valid remain queued.
442 #[must_use]
443 pub fn door_command_queue(&self) -> &[DoorCommand] {
444 &self.door_command_queue
445 }
446
447 /// Currently commanded target velocity for
448 /// [`ServiceMode::Manual`](crate::components::ServiceMode::Manual).
449 ///
450 /// Returns `None` if no target is set, meaning the car coasts to a
451 /// stop using the configured deceleration. Positive values command
452 /// upward travel, negative values command downward travel.
453 #[must_use]
454 pub const fn manual_target_velocity(&self) -> Option<f64> {
455 self.manual_target_velocity
456 }
457}