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