Skip to main content

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_car = Some(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    #[must_use]
138    pub fn assigned_car(&self, stop: EntityId, direction: CallDirection) -> Option<EntityId> {
139        self.world
140            .hall_call(stop, direction)
141            .and_then(|c| c.assigned_car)
142    }
143
144    /// Estimated ticks remaining before the assigned car reaches the
145    /// call at `(stop, direction)`.
146    ///
147    /// # Errors
148    ///
149    /// - [`EtaError::NotAStop`] if no hall call exists at `(stop, direction)`.
150    /// - [`EtaError::StopNotQueued`] if no car is assigned to the call.
151    /// - [`EtaError::NotAnElevator`] if the assigned car has no positional
152    ///   data or is not a valid elevator.
153    pub fn eta_for_call(&self, stop: EntityId, direction: CallDirection) -> Result<u64, EtaError> {
154        let call = self
155            .world
156            .hall_call(stop, direction)
157            .ok_or(EtaError::NotAStop(stop))?;
158        let car = call.assigned_car.ok_or(EtaError::NoCarAssigned(stop))?;
159        let car_pos = self
160            .world
161            .position(car)
162            .ok_or(EtaError::NotAnElevator(car))?
163            .value;
164        let stop_pos = self
165            .world
166            .stop_position(stop)
167            .ok_or(EtaError::StopVanished(stop))?;
168        let max_speed = self
169            .world
170            .elevator(car)
171            .ok_or(EtaError::NotAnElevator(car))?
172            .max_speed()
173            .value();
174        if max_speed <= 0.0 {
175            return Err(EtaError::NotAnElevator(car));
176        }
177        let distance = (car_pos - stop_pos).abs();
178        // Simple kinematic estimate. The `eta` module has a richer
179        // trapezoidal model; the one-liner suits most hall-display use.
180        Ok((distance / max_speed).ceil() as u64)
181    }
182
183    /// Create or aggregate into the hall call at `(stop, direction)`.
184    /// Emits [`Event::HallButtonPressed`] only on the *first* press.
185    pub(super) fn ensure_hall_call(
186        &mut self,
187        stop: EntityId,
188        direction: CallDirection,
189        rider: Option<EntityId>,
190        destination: Option<EntityId>,
191    ) {
192        let mut fresh_press = false;
193        if self.world.hall_call(stop, direction).is_none() {
194            let mut call = HallCall::new(stop, direction, self.tick);
195            call.destination = destination;
196            call.ack_latency_ticks = self.ack_latency_for_stop(stop);
197            if call.ack_latency_ticks == 0 {
198                // Controller has zero-tick latency — mark acknowledged
199                // immediately so dispatch sees the call this same tick.
200                call.acknowledged_at = Some(self.tick);
201            }
202            if let Some(rid) = rider {
203                call.pending_riders.push(rid);
204            }
205            self.world.set_hall_call(call);
206            fresh_press = true;
207        } else if let Some(existing) = self.world.hall_call_mut(stop, direction) {
208            if let Some(rid) = rider
209                && !existing.pending_riders.contains(&rid)
210            {
211                existing.pending_riders.push(rid);
212            }
213            // Prefer a populated destination over None; don't overwrite
214            // an existing destination even if a later press omits it.
215            if existing.destination.is_none() {
216                existing.destination = destination;
217            }
218        }
219        if fresh_press {
220            self.events.emit(Event::HallButtonPressed {
221                stop,
222                direction,
223                tick: self.tick,
224            });
225            // Zero-latency controllers acknowledge on the press tick.
226            if let Some(call) = self.world.hall_call(stop, direction)
227                && call.acknowledged_at == Some(self.tick)
228            {
229                self.events.emit(Event::HallCallAcknowledged {
230                    stop,
231                    direction,
232                    tick: self.tick,
233                });
234            }
235        }
236    }
237
238    /// Ack latency for the group whose `members` slice contains `entity`.
239    /// Defaults to 0 if no group matches (unreachable in normal builds).
240    fn ack_latency_for(
241        &self,
242        entity: EntityId,
243        members: impl Fn(&ElevatorGroup) -> &[EntityId],
244    ) -> u32 {
245        self.groups
246            .iter()
247            .find(|g| members(g).contains(&entity))
248            .map_or(0, ElevatorGroup::ack_latency_ticks)
249    }
250
251    /// Ack latency for the group that owns `stop` (0 if no group).
252    fn ack_latency_for_stop(&self, stop: EntityId) -> u32 {
253        self.ack_latency_for(stop, ElevatorGroup::stop_entities)
254    }
255
256    /// Ack latency for the group that owns `car` (0 if no group).
257    fn ack_latency_for_car(&self, car: EntityId) -> u32 {
258        self.ack_latency_for(car, ElevatorGroup::elevator_entities)
259    }
260
261    /// Create or aggregate into a car call for `(car, floor)`.
262    /// Emits [`Event::CarButtonPressed`] on first press; repeat presses
263    /// by other riders append to `pending_riders` without re-emitting.
264    fn ensure_car_call(&mut self, car: EntityId, floor: EntityId, rider: Option<EntityId>) {
265        let press_tick = self.tick;
266        let ack_latency = self.ack_latency_for_car(car);
267        let Some(queue) = self.world.car_calls_mut(car) else {
268            debug_assert!(
269                false,
270                "ensure_car_call: car {car:?} has no car_calls component"
271            );
272            return;
273        };
274        let existing_idx = queue.iter().position(|c| c.floor == floor);
275        let fresh = existing_idx.is_none();
276        if let Some(idx) = existing_idx {
277            if let Some(rid) = rider
278                && !queue[idx].pending_riders.contains(&rid)
279            {
280                queue[idx].pending_riders.push(rid);
281            }
282        } else {
283            let mut call = CarCall::new(car, floor, press_tick);
284            call.ack_latency_ticks = ack_latency;
285            if ack_latency == 0 {
286                call.acknowledged_at = Some(press_tick);
287            }
288            if let Some(rid) = rider {
289                call.pending_riders.push(rid);
290            }
291            queue.push(call);
292        }
293        if fresh {
294            self.events.emit(Event::CarButtonPressed {
295                car,
296                floor,
297                rider,
298                tick: press_tick,
299            });
300        }
301    }
302}