Skip to main content

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 *will* have it set to `true`
190    /// while moving and `false` only when out of service or pulled from
191    /// the loop. Defaults to `false` so existing snapshots and Linear
192    /// cars are unaffected.
193    ///
194    /// **PR 3 status:** the field, the [`Direction::Forward`] variant, and
195    /// the precedence in [`Elevator::direction`] are all in place, but
196    /// no code path writes `going_forward = true` yet. The wiring lives
197    /// in PR 4 alongside `tick_movement_cyclic` integration in
198    /// `systems/movement.rs`. Until that lands, a Loop car constructed
199    /// via [`Simulation::new`](crate::sim::Simulation::new) will report
200    /// [`Direction::Either`] from `direction()`.
201    #[serde(default)]
202    pub(crate) going_forward: bool,
203    /// Count of rounded-floor transitions (passing-floors + arrivals).
204    /// Useful as a scoring axis for efficiency — fewer moves per delivery
205    /// means less wasted travel.
206    #[serde(default)]
207    pub(crate) move_count: u64,
208    /// Pending manual door-control commands. Processed at the start of the
209    /// doors phase; commands that aren't yet valid remain queued.
210    #[serde(default)]
211    pub(crate) door_command_queue: Vec<DoorCommand>,
212    /// Target velocity commanded by the game while in
213    /// [`ServiceMode::Manual`](crate::components::ServiceMode::Manual).
214    ///
215    /// `None` means no command is active — the car coasts to a stop using
216    /// `deceleration`. Read the current target via
217    /// [`Elevator::manual_target_velocity`].
218    #[serde(default)]
219    pub(crate) manual_target_velocity: Option<f64>,
220    /// Load-ratio threshold (0..=1) above which this car ignores new
221    /// upward hall calls. Modeled on Otis Elevonic 411's full-load
222    /// bypass (patent US5490580A). `None` disables the bypass — the
223    /// default for backwards compatibility.
224    ///
225    /// Aboard riders still get delivered regardless; the threshold
226    /// affects *pickup* decisions only.
227    #[serde(default)]
228    pub(crate) bypass_load_up_pct: Option<f64>,
229    /// Load-ratio threshold for downward-direction pickups. Typically
230    /// set lower than `bypass_load_up_pct` because down-peak riders
231    /// accumulate more gradually and a half-full car has less incentive
232    /// to skip the next stop. `None` disables the bypass.
233    #[serde(default)]
234    pub(crate) bypass_load_down_pct: Option<f64>,
235    /// Per-elevator home stop. When `Some(stop)`, the reposition phase
236    /// hard-pins this car to that stop: any time the car is idle and
237    /// off-position, it is routed home regardless of the group's
238    /// reposition strategy. Useful for express cars assigned to a
239    /// dedicated lobby or service cars that should park in a
240    /// loading bay between requests. `None` (the default) leaves
241    /// reposition decisions entirely to the group strategy.
242    #[serde(default)]
243    pub(crate) home_stop: Option<EntityId>,
244}
245
246/// Default inspection speed factor (25% of normal speed).
247const fn default_inspection_speed_factor() -> f64 {
248    0.25
249}
250
251/// Default value for direction indicator fields (both lamps on = idle/either direction).
252const fn default_true() -> bool {
253    true
254}
255
256impl Elevator {
257    /// Current operational phase.
258    #[must_use]
259    pub const fn phase(&self) -> ElevatorPhase {
260        self.phase
261    }
262
263    /// Door finite-state machine.
264    #[must_use]
265    pub const fn door(&self) -> &DoorState {
266        &self.door
267    }
268
269    /// Maximum travel speed (distance/tick).
270    #[must_use]
271    pub const fn max_speed(&self) -> Speed {
272        self.max_speed
273    }
274
275    /// Acceleration rate (distance/tick^2).
276    #[must_use]
277    pub const fn acceleration(&self) -> Accel {
278        self.acceleration
279    }
280
281    /// Deceleration rate (distance/tick^2).
282    #[must_use]
283    pub const fn deceleration(&self) -> Accel {
284        self.deceleration
285    }
286
287    /// Maximum weight the car can carry.
288    #[must_use]
289    pub const fn weight_capacity(&self) -> Weight {
290        self.weight_capacity
291    }
292
293    /// Total weight of riders currently aboard.
294    #[must_use]
295    pub const fn current_load(&self) -> Weight {
296        self.current_load
297    }
298
299    /// Entity IDs of riders currently aboard.
300    #[must_use]
301    pub fn riders(&self) -> &[EntityId] {
302        &self.riders
303    }
304
305    /// Stop entity the car is heading toward, if any.
306    #[must_use]
307    pub const fn target_stop(&self) -> Option<EntityId> {
308        self.target_stop
309    }
310
311    /// Ticks for a door open/close transition.
312    #[must_use]
313    pub const fn door_transition_ticks(&self) -> u32 {
314        self.door_transition_ticks
315    }
316
317    /// Ticks the door stays fully open.
318    #[must_use]
319    pub const fn door_open_ticks(&self) -> u32 {
320        self.door_open_ticks
321    }
322
323    /// Line entity this car belongs to.
324    #[must_use]
325    pub const fn line(&self) -> EntityId {
326        self.line
327    }
328
329    /// Whether this elevator is currently repositioning (not serving a dispatch).
330    #[must_use]
331    pub const fn repositioning(&self) -> bool {
332        self.repositioning
333    }
334
335    /// Stop entity IDs this elevator cannot serve (access restriction).
336    #[must_use]
337    pub const fn restricted_stops(&self) -> &HashSet<EntityId> {
338        &self.restricted_stops
339    }
340
341    /// Speed multiplier applied during Inspection mode.
342    #[must_use]
343    pub const fn inspection_speed_factor(&self) -> f64 {
344        self.inspection_speed_factor
345    }
346
347    /// Load-ratio threshold above which upward-direction pickups are
348    /// skipped (full-load bypass). `None` disables the bypass. See the
349    /// field docs on [`Elevator`] for the underlying model.
350    #[must_use]
351    pub const fn bypass_load_up_pct(&self) -> Option<f64> {
352        self.bypass_load_up_pct
353    }
354
355    /// Load-ratio threshold above which downward-direction pickups are
356    /// skipped. `None` disables the bypass.
357    #[must_use]
358    pub const fn bypass_load_down_pct(&self) -> Option<f64> {
359        self.bypass_load_down_pct
360    }
361
362    /// Mutator for the upward full-load bypass threshold.
363    pub const fn set_bypass_load_up_pct(&mut self, pct: Option<f64>) {
364        self.bypass_load_up_pct = pct;
365    }
366
367    /// Mutator for the downward full-load bypass threshold.
368    pub const fn set_bypass_load_down_pct(&mut self, pct: Option<f64>) {
369        self.bypass_load_down_pct = pct;
370    }
371
372    /// Per-elevator hard-pinned home stop. When `Some(stop)`, the
373    /// reposition phase routes this car to `stop` whenever it is
374    /// idle and off-position, regardless of the group's reposition
375    /// strategy. `None` (the default) leaves reposition decisions
376    /// entirely to the group strategy.
377    #[must_use]
378    pub const fn home_stop(&self) -> Option<EntityId> {
379        self.home_stop
380    }
381
382    /// Whether this car's up-direction indicator lamp is lit.
383    ///
384    /// A lit up-lamp signals the car will serve upward-travelling riders.
385    /// Both lamps lit means the car is idle and will accept either direction.
386    #[must_use]
387    pub const fn going_up(&self) -> bool {
388        self.going_up
389    }
390
391    /// Whether this car's down-direction indicator lamp is lit.
392    ///
393    /// A lit down-lamp signals the car will serve downward-travelling riders.
394    /// Both lamps lit means the car is idle and will accept either direction.
395    #[must_use]
396    pub const fn going_down(&self) -> bool {
397        self.going_down
398    }
399
400    /// Whether this car's forward-direction indicator lamp is lit.
401    ///
402    /// Only meaningful on [`LineKind::Loop`](crate::components::LineKind::Loop)
403    /// lines. Set true while a Loop car is patrolling forward, false when
404    /// pulled out of service. Always false for Linear cars.
405    #[must_use]
406    pub const fn going_forward(&self) -> bool {
407        self.going_forward
408    }
409
410    /// Direction this car is currently committed to, derived from the
411    /// indicator-lamp triple.
412    ///
413    /// - [`Direction::Forward`] — `going_forward` is set (Loop cars only)
414    /// - [`Direction::Up`] — only `going_up` is set
415    /// - [`Direction::Down`] — only `going_down` is set
416    /// - [`Direction::Either`] — both up/down lamps lit (car is idle /
417    ///   accepting either direction), or neither is set (treated as
418    ///   `Either` too, though the dispatch phase normally keeps at least
419    ///   one lit)
420    ///
421    /// Forward dominates the up/down pair so a Loop car never reports a
422    /// stale `Up`/`Down`/`Either` value left over from earlier Linear
423    /// behaviour.
424    #[must_use]
425    pub const fn direction(&self) -> Direction {
426        if self.going_forward {
427            return Direction::Forward;
428        }
429        match (self.going_up, self.going_down) {
430            (true, false) => Direction::Up,
431            (false, true) => Direction::Down,
432            _ => Direction::Either,
433        }
434    }
435
436    /// Count of rounded-floor transitions this elevator has made
437    /// (both passing-floor crossings and arrivals).
438    #[must_use]
439    pub const fn move_count(&self) -> u64 {
440        self.move_count
441    }
442
443    /// Pending manual door-control commands for this elevator.
444    ///
445    /// Populated by
446    /// [`Simulation::open_door`](crate::sim::Simulation::open_door)
447    /// and its siblings. Commands are drained at the start of each doors-phase
448    /// tick; any that aren't yet valid remain queued.
449    #[must_use]
450    pub fn door_command_queue(&self) -> &[DoorCommand] {
451        &self.door_command_queue
452    }
453
454    /// Currently commanded target velocity for
455    /// [`ServiceMode::Manual`](crate::components::ServiceMode::Manual).
456    ///
457    /// Returns `None` if no target is set, meaning the car coasts to a
458    /// stop using the configured deceleration. Positive values command
459    /// upward travel, negative values command downward travel.
460    #[must_use]
461    pub const fn manual_target_velocity(&self) -> Option<f64> {
462        self.manual_target_velocity
463    }
464}