shuyi_core/path.rs
1//! Paths over a square grid: where a walker can go on a budget and the
2//! cheapest path there ([`reachable`]), and how far every tile is from a
3//! set of goals ([`flow_field`]). The game says what a tile costs with a
4//! cost function; the rules of who may stand where stay the game's.
5//!
6//! # Rules
7//!
8//! - **Steps.** Only the four grid directions ([`Dir`]) are steps. A step
9//! pays the cost of the tile it enters: the cost function's answer for
10//! that tile, or no step at all when it answers `None`. The start costs
11//! nothing. The cost function is only asked about tiles inside the grid.
12//! - **Budget.** [`reachable`] keeps the tiles whose cheapest cost is at
13//! most the budget.
14//! - **Determinism.** Both searches are one Dijkstra. Its queue is ordered
15//! by `(cost, y, x)`, neighbours are visited in [`Dir::ALL`] order, and a
16//! tile's recorded predecessor only changes for a strictly cheaper cost.
17//! So among equally cheap paths to a tile, the one through the neighbour
18//! expanded first wins, every time. When every step costs at least 1,
19//! tiles are expanded in `(cost, y, x)` order, so that neighbour is the
20//! one with the smallest `(cost, y, x)` among those on a cheapest path.
21//! Steps that cost 0 can reach a tile at the cost being expanded but with
22//! a smaller `(y, x)` than tiles already expanded at that cost, so then
23//! the order is only cheapest first.
24//! - A cost that would pass `u32::MAX` is no step.
25
26use alloc::collections::BinaryHeap;
27use alloc::vec;
28use alloc::vec::Vec;
29use core::cmp::Reverse;
30
31use crate::grid::{Dir, Grid, Pos, TileSet};
32
33/// Where a walker can go from one tile on a budget, with the cheapest path
34/// to each tile. Built by [`reachable`].
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Reach {
37 origin: Pos,
38 budget: u32,
39 cost: Grid<Option<u32>>,
40 prev: Grid<Option<Pos>>,
41}
42
43impl Reach {
44 /// The tile the walk starts on.
45 pub fn origin(&self) -> Pos {
46 self.origin
47 }
48
49 /// The budget the walk had.
50 pub fn budget(&self) -> u32 {
51 self.budget
52 }
53
54 /// The cheapest cost to reach `pos`, or `None` if it is out of reach.
55 pub fn cost(&self, pos: Pos) -> Option<u32> {
56 self.cost.get(pos).copied().flatten()
57 }
58
59 /// Whether `pos` is within reach.
60 pub fn contains(&self, pos: Pos) -> bool {
61 self.cost(pos).is_some()
62 }
63
64 /// Every tile within reach, the origin included (unless it lies outside
65 /// the grid).
66 pub fn tiles(&self) -> TileSet {
67 let mut set = TileSet::new(self.cost.width(), self.cost.height());
68 for (pos, cost) in self.cost.positions().zip(self.cost.cells()) {
69 if cost.is_some() {
70 set.insert(pos);
71 }
72 }
73 set
74 }
75
76 /// The cheapest path from the origin to `dest`, both included, or
77 /// `None` if `dest` is out of reach. Deterministic (see the module
78 /// docs).
79 pub fn path_to(&self, dest: Pos) -> Option<Vec<Pos>> {
80 self.cost(dest)?;
81 let mut path = vec![dest];
82 let mut cur = dest;
83 while let Some(&Some(p)) = self.prev.get(cur) {
84 path.push(p);
85 cur = p;
86 }
87 path.reverse();
88 Some(path)
89 }
90}
91
92/// Where a walker on `start` can go with `budget` to spend on a
93/// `width × height` grid, where entering a tile costs `cost(tile)` (`None`:
94/// it can't be entered). A start outside the grid reaches nothing.
95pub fn reachable(
96 width: u16,
97 height: u16,
98 start: Pos,
99 budget: u32,
100 cost: impl Fn(Pos) -> Option<u32>,
101) -> Reach {
102 let found = search(width, height, [start], budget, |_| Some(0), cost);
103 Reach {
104 origin: start,
105 budget,
106 cost: found.cost,
107 prev: found.prev,
108 }
109}
110
111/// How far every tile of a `width × height` grid is from the nearest of
112/// `goals`: the cost of the cheapest walk from the tile onto a goal, where
113/// entering a tile costs `cost(tile)`, as in [`reachable`], and there is no
114/// budget. A goal is 0 from itself. A tile that can't be entered has no
115/// distance (unless it is a goal), and neither has a tile with no walk to a
116/// goal: a goal that can't be entered is never walked onto. Goals outside
117/// the grid are left out.
118pub fn flow_field(
119 width: u16,
120 height: u16,
121 goals: impl IntoIterator<Item = Pos>,
122 cost: impl Fn(Pos) -> Option<u32>,
123) -> Grid<Option<u32>> {
124 // Searched from the goals outwards, so each step is a walk's step
125 // backwards: from a tile to the neighbour a walker comes from. It pays
126 // for the tile walked onto, and the walker must be able to stand on the
127 // neighbour.
128 let found = search(width, height, goals, u32::MAX, &cost, |pos| {
129 cost(pos).map(|_| 0)
130 });
131 found.cost
132}
133
134/// What [`search`] found: each tile's cheapest cost and the tile it was
135/// reached from.
136struct Search {
137 cost: Grid<Option<u32>>,
138 prev: Grid<Option<Pos>>,
139}
140
141/// The one Dijkstra (see the module docs): from every tile of `sources` at
142/// cost 0, a step from a tile `a` to its neighbour `b` costs
143/// `leave(a) + enter(b)` (no step if either is `None`), and only costs
144/// within `budget` are kept.
145fn search(
146 width: u16,
147 height: u16,
148 sources: impl IntoIterator<Item = Pos>,
149 budget: u32,
150 leave: impl Fn(Pos) -> Option<u32>,
151 enter: impl Fn(Pos) -> Option<u32>,
152) -> Search {
153 let mut cost = Grid::filled(width, height, None);
154 let mut prev = Grid::filled(width, height, None);
155 let mut queue: BinaryHeap<Reverse<(u32, i32, i32)>> = BinaryHeap::new();
156 for source in sources {
157 if let Some(c) = cost.get_mut(source) {
158 *c = Some(0);
159 queue.push(Reverse((0, source.y, source.x)));
160 }
161 }
162 // Tiles already expanded: their cost and predecessor are final.
163 let mut done = TileSet::new(width, height);
164 while let Some(Reverse((c, y, x))) = queue.pop() {
165 let pos = Pos::new(x, y);
166 if !done.insert(pos) {
167 continue; // A stale entry: `pos` was expanded at a lower cost.
168 }
169 let Some(out) = leave(pos) else {
170 continue;
171 };
172 for next in Dir::ALL.map(|dir| pos.step(dir)) {
173 if !cost.in_bounds(next) || done.contains(next) {
174 continue;
175 }
176 let step = enter(next).and_then(|step| step.checked_add(out));
177 let Some(next_cost) = step
178 .and_then(|step| c.checked_add(step))
179 .filter(|&total| total <= budget)
180 else {
181 continue;
182 };
183 let Some(slot) = cost.get_mut(next) else {
184 continue;
185 };
186 if slot.is_none_or(|old| next_cost < old) {
187 *slot = Some(next_cost);
188 if let Some(p) = prev.get_mut(next) {
189 *p = Some(pos);
190 }
191 queue.push(Reverse((next_cost, next.y, next.x)));
192 }
193 }
194 }
195 Search { cost, prev }
196}
197
198#[cfg(test)]
199mod tests;