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    /// On a [`LineKind::Loop`](crate::components::LineKind::Loop) car
144    /// negative targets are rejected: closed loops are one-way by
145    /// construction, and accepting a reverse command would either
146    /// silently drive backward through the seam (breaking the no-overtake
147    /// invariant) or be silently clamped to zero (a foot-gun for game
148    /// authors). Errors loudly so the host can surface "reverse on a
149    /// loop" as a UX warning instead.
150    ///
151    /// # Errors
152    /// - [`SimError::NotAnElevator`] if the entity is not an elevator.
153    /// - [`SimError::ElevatorDisabled`] if the elevator is disabled.
154    /// - [`SimError::WrongServiceMode`] if the elevator is not in [`ServiceMode::Manual`].
155    /// - [`SimError::InvalidConfig`] if `velocity` is not finite (NaN or
156    ///   infinite), or if `velocity < 0.0` on a `Loop` line.
157    ///
158    /// [`ServiceMode::Manual`]: crate::components::ServiceMode::Manual
159    pub fn set_target_velocity(
160        &mut self,
161        elevator: ElevatorId,
162        velocity: f64,
163    ) -> Result<(), SimError> {
164        let elevator = elevator.entity();
165        self.require_enabled_elevator(elevator)?;
166        self.require_manual_mode(elevator)?;
167        if !velocity.is_finite() {
168            return Err(SimError::InvalidConfig {
169                field: "target_velocity",
170                reason: format!("must be finite, got {velocity}"),
171            });
172        }
173        #[cfg(feature = "loop_lines")]
174        if velocity < 0.0 {
175            let line = self
176                .world
177                .elevator(elevator)
178                .map(|c| c.line)
179                .unwrap_or_default();
180            if self
181                .world
182                .line(line)
183                .is_some_and(crate::components::Line::is_loop)
184            {
185                return Err(SimError::InvalidConfig {
186                    field: "target_velocity",
187                    reason: format!(
188                        "cannot command negative velocity on a Loop line \
189                         (one-way topology); got {velocity}",
190                    ),
191                });
192            }
193        }
194        let max = self
195            .world
196            .elevator(elevator)
197            .map_or(f64::INFINITY, |c| c.max_speed.value());
198        let clamped = velocity.clamp(-max, max);
199        if let Some(car) = self.world.elevator_mut(elevator) {
200            car.manual_target_velocity = Some(clamped);
201        }
202        self.events.emit(Event::ManualVelocityCommanded {
203            elevator,
204            target_velocity: Some(ordered_float::OrderedFloat(clamped)),
205            tick: self.tick,
206        });
207        Ok(())
208    }
209
210    /// Command an immediate stop on a manual-mode elevator.
211    ///
212    /// Sets the target velocity to zero; the car decelerates at its
213    /// configured `deceleration` rate. Equivalent to
214    /// `set_target_velocity(elevator, 0.0)` but emits a distinct
215    /// [`Event::ManualVelocityCommanded`] with `None` payload so games can
216    /// distinguish an emergency stop from a deliberate hold.
217    ///
218    /// # Errors
219    /// Same as [`set_target_velocity`](Self::set_target_velocity), minus
220    /// the finite-velocity check.
221    pub fn emergency_stop(&mut self, elevator: ElevatorId) -> Result<(), SimError> {
222        let elevator = elevator.entity();
223        self.require_enabled_elevator(elevator)?;
224        self.require_manual_mode(elevator)?;
225        if let Some(car) = self.world.elevator_mut(elevator) {
226            car.manual_target_velocity = Some(0.0);
227        }
228        self.events.emit(Event::ManualVelocityCommanded {
229            elevator,
230            target_velocity: None,
231            tick: self.tick,
232        });
233        Ok(())
234    }
235
236    /// Internal: require an elevator be in `ServiceMode::Manual`.
237    fn require_manual_mode(&self, elevator: EntityId) -> Result<(), SimError> {
238        let actual = self
239            .world
240            .service_mode(elevator)
241            .copied()
242            .unwrap_or_default();
243        if actual != crate::components::ServiceMode::Manual {
244            return Err(SimError::WrongServiceMode {
245                entity: elevator,
246                expected: crate::components::ServiceMode::Manual,
247                actual,
248            });
249        }
250        Ok(())
251    }
252
253    /// Internal: push a command onto the queue, collapsing adjacent
254    /// duplicates, capping length, and emitting `DoorCommandQueued`.
255    fn enqueue_door_command(&mut self, elevator: EntityId, command: crate::door::DoorCommand) {
256        if let Some(car) = self.world.elevator_mut(elevator) {
257            let q = &mut car.door_command_queue;
258            // Collapse adjacent duplicates for idempotent commands
259            // (Open/Close/CancelHold) — repeating them adds nothing.
260            // HoldOpen is explicitly cumulative, so never collapsed.
261            let collapse = matches!(
262                command,
263                crate::door::DoorCommand::Open
264                    | crate::door::DoorCommand::Close
265                    | crate::door::DoorCommand::CancelHold
266            ) && q.last().copied() == Some(command);
267            if !collapse {
268                q.push(command);
269                if q.len() > crate::components::DOOR_COMMAND_QUEUE_CAP {
270                    q.remove(0);
271                }
272            }
273        }
274        self.events.emit(Event::DoorCommandQueued {
275            elevator,
276            command,
277            tick: self.tick,
278        });
279    }
280
281    /// Internal: resolve an elevator entity that is not disabled.
282    fn require_enabled_elevator(&self, elevator: EntityId) -> Result<(), SimError> {
283        if self.world.elevator(elevator).is_none() {
284            return Err(SimError::NotAnElevator(elevator));
285        }
286        if self.world.is_disabled(elevator) {
287            return Err(SimError::ElevatorDisabled(elevator));
288        }
289        Ok(())
290    }
291
292    /// Internal: resolve an elevator entity that is alive and not
293    /// disabled. Returns `NotAnElevator` if the entity is missing,
294    /// `ElevatorDisabled` if it has been disabled. Used by runtime
295    /// upgrade setters in `runtime.rs` so they can't silently mutate
296    /// an out-of-service car (#265).
297    pub(super) fn require_elevator(
298        &self,
299        elevator: EntityId,
300    ) -> Result<&crate::components::Elevator, SimError> {
301        let car = self
302            .world
303            .elevator(elevator)
304            .ok_or(SimError::NotAnElevator(elevator))?;
305        if self.world.is_disabled(elevator) {
306            return Err(SimError::ElevatorDisabled(elevator));
307        }
308        Ok(car)
309    }
310
311    /// Internal: positive-finite validator matching the construction-time
312    /// error shape in `sim/construction.rs::validate_elevator_config`.
313    pub(super) fn validate_positive_finite_f64(
314        value: f64,
315        field: &'static str,
316    ) -> Result<(), SimError> {
317        if !value.is_finite() {
318            return Err(SimError::InvalidConfig {
319                field,
320                reason: format!("must be finite, got {value}"),
321            });
322        }
323        if value <= 0.0 {
324            return Err(SimError::InvalidConfig {
325                field,
326                reason: format!("must be positive, got {value}"),
327            });
328        }
329        Ok(())
330    }
331
332    /// Internal: reject zero-tick timings.
333    pub(super) fn validate_nonzero_u32(value: u32, field: &'static str) -> Result<(), SimError> {
334        if value == 0 {
335            return Err(SimError::InvalidConfig {
336                field,
337                reason: "must be > 0".into(),
338            });
339        }
340        Ok(())
341    }
342
343    /// Internal: emit a single `ElevatorUpgraded` event for the current tick.
344    pub(super) fn emit_upgrade(
345        &mut self,
346        elevator: EntityId,
347        field: crate::events::UpgradeField,
348        old: crate::events::UpgradeValue,
349        new: crate::events::UpgradeValue,
350    ) {
351        self.events.emit(Event::ElevatorUpgraded {
352            elevator,
353            field,
354            old,
355            new,
356            tick: self.tick,
357        });
358    }
359
360    // Dispatch & reposition management live in `sim/construction.rs`.
361}