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}