Skip to main content

elevator_core/sim/
manual.rs

1//! Manual door control and `ServiceMode::Manual` commands.
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::entity::{ElevatorId, EntityId};
8use crate::error::SimError;
9use crate::events::Event;
10
11impl super::Simulation {
12    // ── Manual door control ──────────────────────────────────────────
13    //
14    // These methods let games drive door state directly — e.g. a
15    // cab-panel open/close button in a first-person game, or an RPG
16    // where the player *is* the elevator and decides when to cycle doors.
17    //
18    // Each method either applies the command immediately (if the car is
19    // in a matching door-FSM state) or queues it on the elevator for
20    // application at the next valid moment. This way games can call
21    // these any time without worrying about FSM timing, and get a clean
22    // success/failure split between "bad entity" and "bad moment".
23
24    /// Request the doors to open.
25    ///
26    /// Applied immediately if the car is stopped at a stop with closed
27    /// or closing doors; otherwise queued until the car next arrives.
28    /// A no-op if the doors are already open or opening.
29    ///
30    /// # Errors
31    ///
32    /// - [`SimError::NotAnElevator`] if `elevator` is not an elevator entity.
33    /// - [`SimError::ElevatorDisabled`] if the elevator is disabled.
34    ///
35    /// # Example
36    ///
37    /// ```
38    /// use elevator_core::prelude::*;
39    ///
40    /// let mut sim = SimulationBuilder::demo().build().unwrap();
41    /// let elev = ElevatorId::from(sim.world().iter_elevators().next().unwrap().0);
42    /// sim.open_door(elev).unwrap();
43    /// ```
44    pub fn open_door(&mut self, elevator: ElevatorId) -> Result<(), SimError> {
45        let elevator = elevator.entity();
46        self.require_enabled_elevator(elevator)?;
47        self.enqueue_door_command(elevator, crate::door::DoorCommand::Open);
48        Ok(())
49    }
50
51    /// Request the doors to close now.
52    ///
53    /// Applied immediately if the doors are open or loading — forcing an
54    /// early close — unless a rider is mid-boarding/exiting this car, in
55    /// which case the close waits for the rider to finish. If doors are
56    /// currently opening, the close queues and fires once fully open.
57    ///
58    /// # Errors
59    ///
60    /// - [`SimError::NotAnElevator`] if `elevator` is not an elevator entity.
61    /// - [`SimError::ElevatorDisabled`] if the elevator is disabled.
62    ///
63    /// # Example
64    ///
65    /// ```
66    /// use elevator_core::prelude::*;
67    ///
68    /// let mut sim = SimulationBuilder::demo().build().unwrap();
69    /// let elev = ElevatorId::from(sim.world().iter_elevators().next().unwrap().0);
70    /// sim.close_door(elev).unwrap();
71    /// ```
72    pub fn close_door(&mut self, elevator: ElevatorId) -> Result<(), SimError> {
73        let elevator = elevator.entity();
74        self.require_enabled_elevator(elevator)?;
75        self.enqueue_door_command(elevator, crate::door::DoorCommand::Close);
76        Ok(())
77    }
78
79    /// Extend the doors' open dwell by `ticks`.
80    ///
81    /// Cumulative — two calls of 30 ticks each extend the dwell by 60
82    /// ticks in total. If the doors aren't open yet, the hold is queued
83    /// and applied when they next reach the fully-open state.
84    ///
85    /// # Errors
86    ///
87    /// - [`SimError::NotAnElevator`] if `elevator` is not an elevator entity.
88    /// - [`SimError::ElevatorDisabled`] if the elevator is disabled.
89    /// - [`SimError::InvalidConfig`] if `ticks` is zero.
90    ///
91    /// # Example
92    ///
93    /// ```
94    /// use elevator_core::prelude::*;
95    ///
96    /// let mut sim = SimulationBuilder::demo().build().unwrap();
97    /// let elev = ElevatorId::from(sim.world().iter_elevators().next().unwrap().0);
98    /// sim.hold_door(elev, 30).unwrap();
99    /// ```
100    pub fn hold_door(&mut self, elevator: ElevatorId, ticks: u32) -> Result<(), SimError> {
101        let elevator = elevator.entity();
102        Self::validate_nonzero_u32(ticks, "hold_door.ticks")?;
103        self.require_enabled_elevator(elevator)?;
104        self.enqueue_door_command(elevator, crate::door::DoorCommand::HoldOpen { ticks });
105        Ok(())
106    }
107
108    /// Cancel any pending hold extension.
109    ///
110    /// If the base open timer has already elapsed the doors close on
111    /// the next doors-phase tick.
112    ///
113    /// # Errors
114    ///
115    /// - [`SimError::NotAnElevator`] if `elevator` is not an elevator entity.
116    /// - [`SimError::ElevatorDisabled`] if the elevator is disabled.
117    ///
118    /// # Example
119    ///
120    /// ```
121    /// use elevator_core::prelude::*;
122    ///
123    /// let mut sim = SimulationBuilder::demo().build().unwrap();
124    /// let elev = ElevatorId::from(sim.world().iter_elevators().next().unwrap().0);
125    /// sim.hold_door(elev, 100).unwrap();
126    /// sim.cancel_door_hold(elev).unwrap();
127    /// ```
128    pub fn cancel_door_hold(&mut self, elevator: ElevatorId) -> Result<(), SimError> {
129        let elevator = elevator.entity();
130        self.require_enabled_elevator(elevator)?;
131        self.enqueue_door_command(elevator, crate::door::DoorCommand::CancelHold);
132        Ok(())
133    }
134
135    /// Set the target velocity for a manual-mode elevator.
136    ///
137    /// The velocity is clamped to the elevator's `[-max_speed, max_speed]`
138    /// range after validation. The car ramps toward the target each tick
139    /// using `acceleration` (speeding up, or starting from rest) or
140    /// `deceleration` (slowing down, or reversing direction). Positive
141    /// values command upward travel, negative values command downward travel.
142    ///
143    /// # Errors
144    /// - [`SimError::NotAnElevator`] if the entity is not an elevator.
145    /// - [`SimError::ElevatorDisabled`] if the elevator is disabled.
146    /// - [`SimError::WrongServiceMode`] if the elevator is not in [`ServiceMode::Manual`].
147    /// - [`SimError::InvalidConfig`] if `velocity` is not finite (NaN or infinite).
148    ///
149    /// [`ServiceMode::Manual`]: crate::components::ServiceMode::Manual
150    pub fn set_target_velocity(
151        &mut self,
152        elevator: ElevatorId,
153        velocity: f64,
154    ) -> Result<(), SimError> {
155        let elevator = elevator.entity();
156        self.require_enabled_elevator(elevator)?;
157        self.require_manual_mode(elevator)?;
158        if !velocity.is_finite() {
159            return Err(SimError::InvalidConfig {
160                field: "target_velocity",
161                reason: format!("must be finite, got {velocity}"),
162            });
163        }
164        let max = self
165            .world
166            .elevator(elevator)
167            .map_or(f64::INFINITY, |c| c.max_speed.value());
168        let clamped = velocity.clamp(-max, max);
169        if let Some(car) = self.world.elevator_mut(elevator) {
170            car.manual_target_velocity = Some(clamped);
171        }
172        self.events.emit(Event::ManualVelocityCommanded {
173            elevator,
174            target_velocity: Some(ordered_float::OrderedFloat(clamped)),
175            tick: self.tick,
176        });
177        Ok(())
178    }
179
180    /// Command an immediate stop on a manual-mode elevator.
181    ///
182    /// Sets the target velocity to zero; the car decelerates at its
183    /// configured `deceleration` rate. Equivalent to
184    /// `set_target_velocity(elevator, 0.0)` but emits a distinct
185    /// [`Event::ManualVelocityCommanded`] with `None` payload so games can
186    /// distinguish an emergency stop from a deliberate hold.
187    ///
188    /// # Errors
189    /// Same as [`set_target_velocity`](Self::set_target_velocity), minus
190    /// the finite-velocity check.
191    pub fn emergency_stop(&mut self, elevator: ElevatorId) -> Result<(), SimError> {
192        let elevator = elevator.entity();
193        self.require_enabled_elevator(elevator)?;
194        self.require_manual_mode(elevator)?;
195        if let Some(car) = self.world.elevator_mut(elevator) {
196            car.manual_target_velocity = Some(0.0);
197        }
198        self.events.emit(Event::ManualVelocityCommanded {
199            elevator,
200            target_velocity: None,
201            tick: self.tick,
202        });
203        Ok(())
204    }
205
206    /// Internal: require an elevator be in `ServiceMode::Manual`.
207    fn require_manual_mode(&self, elevator: EntityId) -> Result<(), SimError> {
208        let actual = self
209            .world
210            .service_mode(elevator)
211            .copied()
212            .unwrap_or_default();
213        if actual != crate::components::ServiceMode::Manual {
214            return Err(SimError::WrongServiceMode {
215                entity: elevator,
216                expected: crate::components::ServiceMode::Manual,
217                actual,
218            });
219        }
220        Ok(())
221    }
222
223    /// Internal: push a command onto the queue, collapsing adjacent
224    /// duplicates, capping length, and emitting `DoorCommandQueued`.
225    fn enqueue_door_command(&mut self, elevator: EntityId, command: crate::door::DoorCommand) {
226        if let Some(car) = self.world.elevator_mut(elevator) {
227            let q = &mut car.door_command_queue;
228            // Collapse adjacent duplicates for idempotent commands
229            // (Open/Close/CancelHold) — repeating them adds nothing.
230            // HoldOpen is explicitly cumulative, so never collapsed.
231            let collapse = matches!(
232                command,
233                crate::door::DoorCommand::Open
234                    | crate::door::DoorCommand::Close
235                    | crate::door::DoorCommand::CancelHold
236            ) && q.last().copied() == Some(command);
237            if !collapse {
238                q.push(command);
239                if q.len() > crate::components::DOOR_COMMAND_QUEUE_CAP {
240                    q.remove(0);
241                }
242            }
243        }
244        self.events.emit(Event::DoorCommandQueued {
245            elevator,
246            command,
247            tick: self.tick,
248        });
249    }
250
251    /// Internal: resolve an elevator entity that is not disabled.
252    fn require_enabled_elevator(&self, elevator: EntityId) -> Result<(), SimError> {
253        if self.world.elevator(elevator).is_none() {
254            return Err(SimError::NotAnElevator(elevator));
255        }
256        if self.world.is_disabled(elevator) {
257            return Err(SimError::ElevatorDisabled(elevator));
258        }
259        Ok(())
260    }
261
262    /// Internal: resolve an elevator entity or return a clear error.
263    pub(super) fn require_elevator(
264        &self,
265        elevator: EntityId,
266    ) -> Result<&crate::components::Elevator, SimError> {
267        self.world
268            .elevator(elevator)
269            .ok_or(SimError::NotAnElevator(elevator))
270    }
271
272    /// Internal: positive-finite validator matching the construction-time
273    /// error shape in `sim/construction.rs::validate_elevator_config`.
274    pub(super) fn validate_positive_finite_f64(
275        value: f64,
276        field: &'static str,
277    ) -> Result<(), SimError> {
278        if !value.is_finite() {
279            return Err(SimError::InvalidConfig {
280                field,
281                reason: format!("must be finite, got {value}"),
282            });
283        }
284        if value <= 0.0 {
285            return Err(SimError::InvalidConfig {
286                field,
287                reason: format!("must be positive, got {value}"),
288            });
289        }
290        Ok(())
291    }
292
293    /// Internal: reject zero-tick timings.
294    pub(super) fn validate_nonzero_u32(value: u32, field: &'static str) -> Result<(), SimError> {
295        if value == 0 {
296            return Err(SimError::InvalidConfig {
297                field,
298                reason: "must be > 0".into(),
299            });
300        }
301        Ok(())
302    }
303
304    /// Internal: emit a single `ElevatorUpgraded` event for the current tick.
305    pub(super) fn emit_upgrade(
306        &mut self,
307        elevator: EntityId,
308        field: crate::events::UpgradeField,
309        old: crate::events::UpgradeValue,
310        new: crate::events::UpgradeValue,
311    ) {
312        self.events.emit(Event::ElevatorUpgraded {
313            elevator,
314            field,
315            old,
316            new,
317            tick: self.tick,
318        });
319    }
320
321    // Dispatch & reposition management live in `sim/construction.rs`.
322}