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
49/// Decide whether assigning `ctx.car` to `ctx.stop` is on the path to
50/// any aboard rider's destination. Combined with
51/// [`pair_can_do_work`](super::pair_can_do_work), this keeps a car
52/// carrying riders from being pulled backward by closer pickups.
53fn pair_is_useful(ctx: &RankContext<'_>) -> bool {
54 if !pair_can_do_work(ctx) {
55 return false;
56 }
57
58 let Some(car) = ctx.world.elevator(ctx.car) else {
59 return false;
60 };
61 // Exiting an aboard rider is always on-the-way for that rider.
62 let can_exit_here = car
63 .riders()
64 .iter()
65 .any(|&rid| ctx.world.route(rid).and_then(Route::current_destination) == Some(ctx.stop));
66 if can_exit_here || car.riders().is_empty() {
67 return true;
68 }
69
70 // Route-less aboard riders (game-managed manual riders) don't
71 // publish a destination, so there's no committed path to protect.
72 // Any pickup is trivially on-the-way — fall back to the raw
73 // servability check. Otherwise we'd refuse every pickup the moment
74 // the car carried its first manually-managed passenger.
75 let has_routed_rider = car.riders().iter().any(|&rid| {
76 ctx.world
77 .route(rid)
78 .and_then(Route::current_destination)
79 .is_some()
80 });
81 if !has_routed_rider {
82 return true;
83 }
84
85 // Pickups allowed only on the path to an aboard rider's destination.
86 // Candidate at the car's position (to_cand = 0) trivially qualifies —
87 // useful for same-floor boards.
88 let to_cand = ctx.stop_position - ctx.car_position;
89 car.riders().iter().any(|&rid| {
90 let Some(dest) = ctx.world.route(rid).and_then(Route::current_destination) else {
91 return false;
92 };
93 let Some(dest_pos) = ctx.world.stop_position(dest) else {
94 return false;
95 };
96 let to_dest = dest_pos - ctx.car_position;
97 to_dest * to_cand >= 0.0 && to_cand.abs() <= to_dest.abs()
98 })
99}