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}