elevator_core/dispatch/nearest_car.rs
1//! Nearest-car dispatch — assigns each call to the closest idle elevator.
2
3use crate::components::Route;
4
5use super::{DispatchStrategy, RankContext, pair_can_do_work};
6
7/// Scores `(car, stop)` by absolute distance between the car and the stop.
8///
9/// Paired with the Hungarian assignment in the dispatch system, this
10/// yields the globally minimum-total-distance matching across the group
11/// — no two cars can be sent to the same hall call.
12///
13/// Two guards are applied on top of the raw distance:
14///
15/// 1. The `(car, stop)` pair must be serviceable — at least one aboard
16/// rider can exit, or at least one waiting rider can fit. A full car
17/// at a pickup stop it cannot serve otherwise self-assigns at zero
18/// cost (doors cycle open → reject → close forever).
19/// 2. A car carrying riders refuses pickups that would pull it backward
20/// (off the path to every aboard rider's destination). Without this,
21/// a stream of closer-destination boarders can indefinitely preempt
22/// a farther aboard rider's delivery — the reported "never reaches
23/// the passenger's desired stop" loop.
24pub struct NearestCarDispatch;
25
26impl NearestCarDispatch {
27 /// Create a new `NearestCarDispatch`.
28 #[must_use]
29 pub const fn new() -> Self {
30 Self
31 }
32}
33
34impl Default for NearestCarDispatch {
35 fn default() -> Self {
36 Self::new()
37 }
38}
39
40impl DispatchStrategy for NearestCarDispatch {
41 fn rank(&mut self, ctx: &RankContext<'_>) -> Option<f64> {
42 if !pair_is_useful(ctx) {
43 return None;
44 }
45 Some((ctx.car_position - ctx.stop_position).abs())
46 }
47
48 fn builtin_id(&self) -> Option<super::BuiltinStrategy> {
49 Some(super::BuiltinStrategy::NearestCar)
50 }
51}
52
53/// Decide whether assigning `ctx.car` to `ctx.stop` is on the path to
54/// any aboard rider's destination. Combined with
55/// [`pair_can_do_work`](super::pair_can_do_work), this keeps a car
56/// carrying riders from being pulled backward by closer pickups.
57fn pair_is_useful(ctx: &RankContext<'_>) -> bool {
58 if !pair_can_do_work(ctx) {
59 return false;
60 }
61
62 let Some(car) = ctx.world.elevator(ctx.car) else {
63 return false;
64 };
65 // Exiting an aboard rider is always on-the-way for that rider.
66 let can_exit_here = car
67 .riders()
68 .iter()
69 .any(|&rid| ctx.world.route(rid).and_then(Route::current_destination) == Some(ctx.stop));
70 if can_exit_here || car.riders().is_empty() {
71 return true;
72 }
73
74 // Route-less aboard riders (game-managed manual riders) don't
75 // publish a destination, so there's no committed path to protect.
76 // Any pickup is trivially on-the-way — fall back to the raw
77 // servability check. Otherwise we'd refuse every pickup the moment
78 // the car carried its first manually-managed passenger.
79 let has_routed_rider = car.riders().iter().any(|&rid| {
80 ctx.world
81 .route(rid)
82 .and_then(Route::current_destination)
83 .is_some()
84 });
85 if !has_routed_rider {
86 return true;
87 }
88
89 // Pickups allowed only on the path to an aboard rider's destination.
90 // Candidate at the car's position (to_cand = 0) trivially qualifies —
91 // useful for same-floor boards.
92 let to_cand = ctx.stop_position - ctx.car_position;
93 car.riders().iter().any(|&rid| {
94 let Some(dest) = ctx.world.route(rid).and_then(Route::current_destination) else {
95 return false;
96 };
97 let Some(dest_pos) = ctx.world.stop_position(dest) else {
98 return false;
99 };
100 let to_dest = dest_pos - ctx.car_position;
101 to_dest * to_cand >= 0.0 && to_cand.abs() <= to_dest.abs()
102 })
103}