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_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}