elevator_core/sim/calls.rs
1//! Hall-call and car-call API.
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::components::{CallDirection, CarCall, HallCall};
8use crate::dispatch::ElevatorGroup;
9use crate::entity::{ElevatorId, EntityId};
10use crate::error::{EtaError, SimError};
11use crate::events::Event;
12use crate::stop::StopRef;
13
14impl super::Simulation {
15 // ── Hall / car call API ─────────────────────────────────────────
16
17 /// Press an up/down hall button at `stop` without associating it
18 /// with any particular rider. Useful for scripted NPCs, player
19 /// input, or cutscene cues.
20 ///
21 /// If a call in this direction already exists at `stop`, the press
22 /// tick is left untouched (first press wins for latency purposes).
23 ///
24 /// # Errors
25 /// Returns [`SimError::EntityNotFound`] if `stop` is not a valid
26 /// stop entity.
27 pub fn press_hall_button(
28 &mut self,
29 stop: impl Into<StopRef>,
30 direction: CallDirection,
31 ) -> Result<(), SimError> {
32 let stop = self.resolve_stop(stop.into())?;
33 if self.world.stop(stop).is_none() {
34 return Err(SimError::EntityNotFound(stop));
35 }
36 self.ensure_hall_call(stop, direction, None, None);
37 Ok(())
38 }
39
40 /// Press a floor button from inside `car`. No-op if the car already
41 /// has a pending call for `floor`.
42 ///
43 /// # Errors
44 /// Returns [`SimError::EntityNotFound`] if `car` or `floor` is invalid.
45 pub fn press_car_button(
46 &mut self,
47 car: ElevatorId,
48 floor: impl Into<StopRef>,
49 ) -> Result<(), SimError> {
50 let car = car.entity();
51 let floor = self.resolve_stop(floor.into())?;
52 if self.world.elevator(car).is_none() {
53 return Err(SimError::EntityNotFound(car));
54 }
55 if self.world.stop(floor).is_none() {
56 return Err(SimError::EntityNotFound(floor));
57 }
58 self.ensure_car_call(car, floor, None);
59 Ok(())
60 }
61
62 /// Pin the hall call at `(stop, direction)` to `car`. Dispatch is
63 /// forbidden from reassigning the call to a different car until
64 /// [`unpin_assignment`](Self::unpin_assignment) is called or the
65 /// call is cleared.
66 ///
67 /// # Errors
68 /// - [`SimError::EntityNotFound`] — `car` is not a valid elevator.
69 /// - [`SimError::HallCallNotFound`] — no hall call exists at that
70 /// `(stop, direction)` pair yet.
71 /// - [`SimError::LineDoesNotServeStop`] — the car's line does not
72 /// serve `stop`. Without this check a cross-line pin would be
73 /// silently dropped at dispatch time yet leave the call `pinned`,
74 /// blocking every other car.
75 pub fn pin_assignment(
76 &mut self,
77 car: ElevatorId,
78 stop: EntityId,
79 direction: CallDirection,
80 ) -> Result<(), SimError> {
81 let car = car.entity();
82 let Some(elev) = self.world.elevator(car) else {
83 return Err(SimError::EntityNotFound(car));
84 };
85 let car_line = elev.line;
86 // Validate the car's line can reach the stop. If the line has
87 // an entry in any group, we consult its `serves` list. A car
88 // whose line entity doesn't match any line in any group falls
89 // through — older test fixtures create elevators without a
90 // line entity, and we don't want to regress them.
91 let line_serves_stop = self
92 .groups
93 .iter()
94 .flat_map(|g| g.lines().iter())
95 .find(|li| li.entity() == car_line)
96 .map(|li| li.serves().contains(&stop));
97 if line_serves_stop == Some(false) {
98 return Err(SimError::LineDoesNotServeStop {
99 line_or_car: car,
100 stop,
101 });
102 }
103 let Some(call) = self.world.hall_call_mut(stop, direction) else {
104 return Err(SimError::HallCallNotFound { stop, direction });
105 };
106 call.assigned_cars_by_line.insert(car_line, car);
107 call.pinned = true;
108 Ok(())
109 }
110
111 /// Release a previous pin at `(stop, direction)`. No-op if the call
112 /// doesn't exist or wasn't pinned.
113 pub fn unpin_assignment(&mut self, stop: EntityId, direction: CallDirection) {
114 if let Some(call) = self.world.hall_call_mut(stop, direction) {
115 call.pinned = false;
116 }
117 }
118
119 /// Iterate every active hall call across the simulation. Yields a
120 /// reference per live `(stop, direction)` press; games use this to
121 /// render lobby lamp states, pending-rider counts, or per-floor
122 /// button animations.
123 pub fn hall_calls(&self) -> impl Iterator<Item = &HallCall> {
124 self.world.iter_hall_calls()
125 }
126
127 /// Floor buttons currently pressed inside `car`. Returns an empty
128 /// slice when the car has no aboard riders or hasn't been used.
129 #[must_use]
130 pub fn car_calls(&self, car: ElevatorId) -> &[CarCall] {
131 let car = car.entity();
132 self.world.car_calls(car)
133 }
134
135 /// Car currently assigned to serve the call at `(stop, direction)`,
136 /// if dispatch has made an assignment yet.
137 ///
138 /// At stops served by multiple lines a single call can hold one
139 /// assignment per line; this accessor returns the entry with the
140 /// numerically smallest line-entity key (stable across ticks). Use
141 /// [`assigned_cars_by_line`](Self::assigned_cars_by_line) when the
142 /// full per-line list matters.
143 #[must_use]
144 pub fn assigned_car(&self, stop: EntityId, direction: CallDirection) -> Option<EntityId> {
145 self.world
146 .hall_call(stop, direction)
147 .and_then(HallCall::any_assigned_car)
148 }
149
150 /// Per-line cars assigned to the call at `(stop, direction)`.
151 /// Returns an empty slice when dispatch has no assignments yet; one
152 /// entry per line that has a car committed. Iteration order is
153 /// stable by line-entity id (`BTreeMap` ordering).
154 #[must_use]
155 pub fn assigned_cars_by_line(
156 &self,
157 stop: EntityId,
158 direction: CallDirection,
159 ) -> Vec<(EntityId, EntityId)> {
160 self.world
161 .hall_call(stop, direction)
162 .map(|c| {
163 c.assigned_cars_by_line
164 .iter()
165 .map(|(&line, &car)| (line, car))
166 .collect()
167 })
168 .unwrap_or_default()
169 }
170
171 /// Estimated ticks remaining before the assigned car reaches the
172 /// call at `(stop, direction)`.
173 ///
174 /// # Errors
175 ///
176 /// - [`EtaError::NotAStop`] if no hall call exists at `(stop, direction)`.
177 /// - [`EtaError::StopNotQueued`] if no car is assigned to the call.
178 /// - [`EtaError::NotAnElevator`] if the assigned car has no positional
179 /// data or is not a valid elevator.
180 pub fn eta_for_call(&self, stop: EntityId, direction: CallDirection) -> Result<u64, EtaError> {
181 let call = self
182 .world
183 .hall_call(stop, direction)
184 .ok_or(EtaError::NotAStop(stop))?;
185 let car = call
186 .any_assigned_car()
187 .ok_or(EtaError::NoCarAssigned(stop))?;
188 let car_pos = self
189 .world
190 .position(car)
191 .ok_or(EtaError::NotAnElevator(car))?
192 .value;
193 let stop_pos = self
194 .world
195 .stop_position(stop)
196 .ok_or(EtaError::StopVanished(stop))?;
197 let max_speed = self
198 .world
199 .elevator(car)
200 .ok_or(EtaError::NotAnElevator(car))?
201 .max_speed()
202 .value();
203 if max_speed <= 0.0 {
204 return Err(EtaError::NotAnElevator(car));
205 }
206 let distance = (car_pos - stop_pos).abs();
207 // Simple kinematic estimate. The `eta` module has a richer
208 // trapezoidal model; the one-liner suits most hall-display use.
209 Ok((distance / max_speed).ceil() as u64)
210 }
211
212 /// Create or aggregate into the hall call at `(stop, direction)`.
213 /// Emits [`Event::HallButtonPressed`] only on the *first* press.
214 pub(super) fn ensure_hall_call(
215 &mut self,
216 stop: EntityId,
217 direction: CallDirection,
218 rider: Option<EntityId>,
219 destination: Option<EntityId>,
220 ) {
221 let mut fresh_press = false;
222 if self.world.hall_call(stop, direction).is_none() {
223 let mut call = HallCall::new(stop, direction, self.tick);
224 call.destination = destination;
225 call.ack_latency_ticks = self.ack_latency_for_stop(stop);
226 if call.ack_latency_ticks == 0 {
227 // Controller has zero-tick latency — mark acknowledged
228 // immediately so dispatch sees the call this same tick.
229 call.acknowledged_at = Some(self.tick);
230 }
231 if let Some(rid) = rider {
232 call.pending_riders.push(rid);
233 }
234 self.world.set_hall_call(call);
235 fresh_press = true;
236 } else if let Some(existing) = self.world.hall_call_mut(stop, direction) {
237 if let Some(rid) = rider
238 && !existing.pending_riders.contains(&rid)
239 {
240 existing.pending_riders.push(rid);
241 }
242 // Prefer a populated destination over None; don't overwrite
243 // an existing destination even if a later press omits it.
244 if existing.destination.is_none() {
245 existing.destination = destination;
246 }
247 }
248 if fresh_press {
249 self.events.emit(Event::HallButtonPressed {
250 stop,
251 direction,
252 tick: self.tick,
253 });
254 // Zero-latency controllers acknowledge on the press tick.
255 if let Some(call) = self.world.hall_call(stop, direction)
256 && call.acknowledged_at == Some(self.tick)
257 {
258 self.events.emit(Event::HallCallAcknowledged {
259 stop,
260 direction,
261 tick: self.tick,
262 });
263 }
264 }
265 }
266
267 /// Ack latency for the group whose `members` slice contains `entity`.
268 /// Defaults to 0 if no group matches (unreachable in normal builds).
269 fn ack_latency_for(
270 &self,
271 entity: EntityId,
272 members: impl Fn(&ElevatorGroup) -> &[EntityId],
273 ) -> u32 {
274 self.groups
275 .iter()
276 .find(|g| members(g).contains(&entity))
277 .map_or(0, ElevatorGroup::ack_latency_ticks)
278 }
279
280 /// Ack latency for the group that owns `stop` (0 if no group).
281 fn ack_latency_for_stop(&self, stop: EntityId) -> u32 {
282 self.ack_latency_for(stop, ElevatorGroup::stop_entities)
283 }
284
285 /// Ack latency for the group that owns `car` (0 if no group).
286 fn ack_latency_for_car(&self, car: EntityId) -> u32 {
287 self.ack_latency_for(car, ElevatorGroup::elevator_entities)
288 }
289
290 /// Create or aggregate into a car call for `(car, floor)`.
291 /// Emits [`Event::CarButtonPressed`] on first press; repeat presses
292 /// by other riders append to `pending_riders` without re-emitting.
293 fn ensure_car_call(&mut self, car: EntityId, floor: EntityId, rider: Option<EntityId>) {
294 let press_tick = self.tick;
295 let ack_latency = self.ack_latency_for_car(car);
296 let Some(queue) = self.world.car_calls_mut(car) else {
297 debug_assert!(
298 false,
299 "ensure_car_call: car {car:?} has no car_calls component"
300 );
301 return;
302 };
303 let existing_idx = queue.iter().position(|c| c.floor == floor);
304 let fresh = existing_idx.is_none();
305 if let Some(idx) = existing_idx {
306 if let Some(rid) = rider
307 && !queue[idx].pending_riders.contains(&rid)
308 {
309 queue[idx].pending_riders.push(rid);
310 }
311 } else {
312 let mut call = CarCall::new(car, floor, press_tick);
313 call.ack_latency_ticks = ack_latency;
314 if ack_latency == 0 {
315 call.acknowledged_at = Some(press_tick);
316 }
317 if let Some(rid) = rider {
318 call.pending_riders.push(rid);
319 }
320 queue.push(call);
321 }
322 if fresh {
323 let tag = rider.map(|rid| {
324 self.world
325 .rider(rid)
326 .map_or(0, crate::components::Rider::tag)
327 });
328 self.events.emit(Event::CarButtonPressed {
329 car,
330 floor,
331 rider,
332 tag,
333 tick: press_tick,
334 });
335 }
336 }
337}