pinch_points/sim/solve.rs
1//! Brute-force puzzle validation (spec ยง5.4): search signpost placements
2//! against the headless sim until one meets the level's goal.
3//!
4//! The search is depth-first over placement counts 0..=inventory. Candidate
5//! tiles are pruned to tiles crabs actually cross in a run under the current
6//! placements, since a signpost nobody walks over cannot change the
7//! outcome, and the candidate set is recomputed at each depth so placements
8//! that open new paths are still found.
9
10use crate::sim::board::{Board, TileKind};
11use crate::sim::direction::Direction;
12use crate::sim::level::{Level, PUZZLE_TICK_LIMIT, PuzzleOutcome};
13
14/// A signpost as an instruction: where it goes and which way it points.
15///
16/// The same triple is a solution step, a hint, and a line in a level file,
17/// so it is one name rather than three spellings.
18pub type Placement = (u8, u8, Direction);
19
20/// Boards a budgeted search may simulate before it gives up.
21///
22/// Counted in simulations rather than seconds, so a board costs the same
23/// budget on every machine and in every build profile. Each simulation is
24/// itself bounded by [`PUZZLE_TICK_LIMIT`], which makes this a ceiling on
25/// total work rather than merely on visits.
26///
27/// Sized from both ends, and both ends were measured.
28///
29/// The ceiling is there for the boards nobody vetted: an author's own level,
30/// which can be any size, any shape, and unsolvable in ways that take far
31/// longer to prove than to draw. A 12x9 board with six crabs, sealed so that
32/// no solution exists, ran past ten minutes unbudgeted without an answer. At
33/// this ceiling that same board gives up in 48 seconds of a release build,
34/// measured 2026-08-15. The editor validates on a background thread and says
35/// it is working, so that is a wait rather than a freeze.
36///
37/// Raised from 50,000 the same day, and the reason is the campaign rather
38/// than the editor. `no_campaign_level_grants_a_post_it_does_not_need`
39/// proves a level's inventory minimal under this number, and proving that no
40/// *three*-post answer exists costs roughly the cube of the tiles the crabs
41/// cross. At 50,000 that proof consumed the whole budget on any board big
42/// enough to need four posts, so four-post levels could not be shown minimal
43/// and therefore could not ship: of 140 boards built to need one, 101 gave
44/// up and none came back with four. The old ceiling was not protecting the
45/// editor from slow boards so much as capping how hard a level was allowed
46/// to be.
47///
48/// Raised again to 1,000,000 on 2026-08-16, and again the campaign asked
49/// for it. Gulls started eating the crabs they met, which makes a placement
50/// fail later and deeper, and four gull levels stopped fitting: 400,000 for
51/// The Long Shelf, 600,000 for Quick Feet, a round million for Slow And
52/// Sure and The Far Corner. Six to twelve seconds each on this machine, on
53/// the editor's background thread, which is a wait on a button you press
54/// deliberately.
55pub const DEFAULT_NODE_BUDGET: u32 = 1_000_000;
56
57/// How hard a search may work before it admits defeat.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum Effort {
60 /// Give up after this many boards simulated.
61 Budget(u32),
62 /// Search until the answer is certain, however long that takes. For
63 /// callers that can wait and need the truth: level authoring and CI.
64 Exhaustive,
65}
66
67/// What a search found, and when it found nothing, whether that is a proof.
68///
69/// The distinction is what a budget is for. "No solution" is a claim
70/// about the level; "gave up" is a claim about the search, and an editor that
71/// prints the first when it means the second tells an author their level is
72/// broken when it may be fine.
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub enum SolveOutcome {
75 /// Placements meeting the level's goal, using as few posts as possible.
76 Found(Vec<Placement>),
77 /// Every placement within the inventory was tried, and none wins.
78 Unsolvable,
79 /// The budget ran out first. Says nothing about whether a solution exists.
80 GaveUp,
81}
82
83/// Search for a signpost set (within the level's inventory) that meets the
84/// level's goal, under [`DEFAULT_NODE_BUDGET`]. Prefers fewer posts.
85pub fn solve(level: &Level) -> SolveOutcome {
86 solve_with(level, Effort::Budget(DEFAULT_NODE_BUDGET))
87}
88
89/// [`solve`], with the ceiling named by the caller.
90pub fn solve_with(level: &Level, effort: Effort) -> SolveOutcome {
91 let mut search = Search::new(level, effort);
92 for depth in 0..=level.posts {
93 let mut board = level.board();
94 if let Some(mut placements) = search.search_at(&mut board, depth) {
95 placements.reverse();
96 return SolveOutcome::Found(placements);
97 }
98 if search.gave_up {
99 return SolveOutcome::GaveUp;
100 }
101 }
102 SolveOutcome::Unsolvable
103}
104
105/// One search, and the fuel it has left.
106struct Search<'a> {
107 level: &'a Level,
108 /// Simulations remaining, or `None` when the caller asked for certainty.
109 fuel: Option<u32>,
110 /// Set the moment fuel runs out, so "found nothing" can be told apart
111 /// from "proved there is nothing".
112 gave_up: bool,
113 /// Signpost sets already explored and found wanting, each held in a
114 /// fixed order so that the same set reached by a different route is
115 /// recognised as the same set.
116 ///
117 /// Signposts are a *set*: placing A then B leaves exactly the board
118 /// that placing B then A leaves. The search walks orderings, so without
119 /// this it explores every one of them, and there are `depth!` of those.
120 /// Six wasted subtrees out of seven at three posts, twenty-three out of
121 /// twenty-four at four. That factorial is what kept four-post levels out
122 /// of reach of any budget worth having.
123 ///
124 /// Ordering the candidates instead would be cheaper still and is not
125 /// sound here: the candidate set is recomputed at each depth from the
126 /// board as it now stands, so a tile can become worth trying only
127 /// *because* an earlier signpost sent a crab across it. Refusing to go
128 /// back would lose those. Remembering where we have been loses nothing.
129 ///
130 /// Cleared between depths, and that is not housekeeping. The search
131 /// runs once per inventory size, and a set that failed with one post
132 /// left to spend says nothing about the same set with two. Carrying the
133 /// memo across those runs made `Both Lanes` unsolvable, which is what
134 /// the minimality guard is for.
135 /// Keyed by `(x, y, Direction::id)` so the set can be sorted and
136 /// hashed without asking a sim type to grow orderings it has no other
137 /// use for.
138 seen: std::collections::HashSet<Vec<(u8, u8, u8)>>,
139 /// The signposts standing right now, in placement order.
140 placed: Vec<Placement>,
141}
142
143impl<'a> Search<'a> {
144 fn new(level: &'a Level, effort: Effort) -> Self {
145 Search {
146 level,
147 fuel: match effort {
148 Effort::Budget(nodes) => Some(nodes),
149 Effort::Exhaustive => None,
150 },
151 gave_up: false,
152 seen: std::collections::HashSet::new(),
153 placed: Vec::new(),
154 }
155 }
156
157 /// Charge one simulation to the budget. Once it returns false it returns
158 /// false forever, and every caller unwinds without simulating again: a
159 /// spent search must stop promptly, not merely stop eventually.
160 fn charge(&mut self) -> bool {
161 if self.gave_up {
162 return false;
163 }
164 if let Some(fuel) = self.fuel.as_mut() {
165 match fuel.checked_sub(1) {
166 Some(left) => *fuel = left,
167 None => {
168 self.gave_up = true;
169 return false;
170 }
171 }
172 }
173 true
174 }
175
176 /// Does this exact board win on its own, by the level's own reckoning?
177 ///
178 /// The judge is [`Level::outcome`], the same one the game uses, rather
179 /// than a copy of the all-crabs rule: a Beach Day stage asks for a number
180 /// banked, or for nobody eaten, and a solver that only knows how to bank
181 /// every crab answers a harder question than it was asked, reporting "no
182 /// solution" for stages that are perfectly beatable.
183 fn wins(&mut self, board: &Board) -> bool {
184 if !self.charge() {
185 return false;
186 }
187 let mut sim = board.clone();
188 loop {
189 sim.tick_idle();
190 match self.level.outcome(&sim) {
191 PuzzleOutcome::Running => {}
192 PuzzleOutcome::Won => return true,
193 PuzzleOutcome::Lost => return false,
194 }
195 }
196 }
197
198 /// Tiles any creature arrives at during a run of the current board: the
199 /// only places a new signpost could matter. Gulls count too: a solution
200 /// may hinge on steering a gull away from the crabs. Restricted to empty,
201 /// signpost-free tiles (the only legal placements).
202 fn visited_placeable_tiles(&mut self, board: &Board) -> Vec<(u8, u8)> {
203 if !self.charge() {
204 return Vec::new();
205 }
206 let mut sim = board.clone();
207 let mut seen = vec![false; sim.width() as usize * sim.height() as usize];
208 for _ in 0..PUZZLE_TICK_LIMIT {
209 sim.tick_idle();
210 for crab in sim.crabs() {
211 seen[crab.tile as usize] = true;
212 }
213 for gull in sim.gulls() {
214 seen[gull.tile as usize] = true;
215 }
216 if sim.crabs().is_empty() {
217 break;
218 }
219 }
220 let mut tiles = Vec::new();
221 for (x, y, kind) in board.tiles() {
222 if seen[usize::from(board.index_of(x, y))]
223 && kind == TileKind::Empty
224 && board.signpost_at(x, y).is_none()
225 {
226 tiles.push((x, y));
227 }
228 }
229 tiles
230 }
231
232 /// One whole search at a fixed inventory size.
233 ///
234 /// Within a single call, "how many are placed" and "how many are left"
235 /// add to a constant, so the placed set alone identifies a node and the
236 /// memo is exact. Across calls it is not, so the memo starts empty.
237 fn search_at(&mut self, board: &mut Board, depth: u8) -> Option<Vec<Placement>> {
238 self.seen.clear();
239 self.placed.clear();
240 self.run(board, depth)
241 }
242
243 fn run(&mut self, board: &mut Board, depth: u8) -> Option<Vec<Placement>> {
244 if depth == 0 {
245 return self.wins(board).then(Vec::new);
246 }
247 // Somewhere we have already been, by another road. Nothing about the
248 // board depends on how it was reached, so neither does the answer.
249 let mut key: Vec<(u8, u8, u8)> = self
250 .placed
251 .iter()
252 .map(|(x, y, dir)| (*x, *y, dir.id()))
253 .collect();
254 key.sort_unstable();
255 if !self.seen.insert(key) {
256 return None;
257 }
258 for (x, y) in self.visited_placeable_tiles(board) {
259 for dir in Direction::ALL {
260 if self.gave_up {
261 return None;
262 }
263 if !board.place_signpost(0, x, y, dir) {
264 continue;
265 }
266 self.placed.push((x, y, dir));
267 if let Some(mut placements) = self.run(board, depth - 1) {
268 placements.push((x, y, dir));
269 return Some(placements);
270 }
271 self.placed.pop();
272 board.remove_signpost(0, x, y);
273 }
274 }
275 None
276 }
277}
278
279/// Convenience: validate a level exhaustively, returning the solution found
280/// (also checks an authored level's claim that it is solvable).
281pub fn validate(level: &Level) -> Result<Vec<Placement>, String> {
282 match solve_with(level, Effort::Exhaustive) {
283 SolveOutcome::Found(placements) => Ok(placements),
284 SolveOutcome::Unsolvable => Err(format!("no solution within {} signposts", level.posts)),
285 // Unreachable by construction: an exhaustive search has no budget to
286 // run out of. Spelled out rather than waved through, so that adding a
287 // second way to stop early cannot silently become "no solution".
288 SolveOutcome::GaveUp => Err("search gave up".into()),
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn validate_reports_solvability_either_way() {
298 let solvable = crate::sim::campaign_levels().remove(1);
299 assert!(validate(&solvable).is_ok());
300 // A castle sealed on all four sides cannot be solved.
301 let text = "name: No\nposts: 1\ncrab: 0,0 R L common\nmap:\n\
302+-+-+-+\n|. . .|\n+ +-+ +\n|.|0|.|\n+ +-+ +\n|. . .|\n+-+-+-+\n";
303 let level = crate::sim::Level::parse(text).expect("parses");
304 let err = validate(&level).unwrap_err();
305 assert!(err.contains("no solution"), "{err}");
306 }
307 use crate::sim::campaign_levels;
308 use crate::sim::level::PuzzleOutcome;
309
310 #[test]
311 fn solver_cracks_an_early_campaign_level() {
312 // Level 2, "First Turn": one post, one crab.
313 let levels = campaign_levels();
314 let level = &levels[1];
315 let SolveOutcome::Found(solution) = solve(level) else {
316 panic!("level 2 is solvable");
317 };
318 assert!(solution.len() <= level.posts as usize);
319 // Replay the found solution to be sure.
320 let mut board = level.board();
321 for &(x, y, dir) in &solution {
322 assert!(board.place_signpost(0, x, y, dir));
323 }
324 let won = loop {
325 board.tick_idle();
326 match level.outcome(&board) {
327 PuzzleOutcome::Running => {}
328 done @ (PuzzleOutcome::Won | PuzzleOutcome::Lost) => {
329 break done == PuzzleOutcome::Won;
330 }
331 }
332 };
333 assert!(won, "solver's answer must actually win");
334 }
335
336 #[test]
337 fn unsolvable_level_returns_none() {
338 // A crab orbiting the border with zero posts and an unreachable
339 // interior castle: provably unsolvable.
340 let text = "\
341name: Hopeless
342posts: 0
343crab: 0,0 R L common
344map:
345+-+-+-+-+-+
346|. . . . .|
347+ +-+-+-+ +
348|. .|0|. .|
349+ +-+-+-+ +
350|. . . . .|
351+-+-+-+-+-+
352";
353 let level = Level::parse(text).expect("parses");
354 assert_eq!(solve(&level), SolveOutcome::Unsolvable);
355 }
356
357 /// The distinction the budget exists to make: the same hopeless board
358 /// answers "no solution" when the search is allowed to finish, and "gave
359 /// up" when it is not. Both are honest; only the first is a claim about
360 /// the level.
361 #[test]
362 fn a_spent_budget_gives_up_rather_than_claiming_unsolvable() {
363 let text = "\
364name: Hopeless
365posts: 2
366crab: 0,0 R L common
367map:
368+-+-+-+-+-+
369|. . . . .|
370+ +-+-+-+ +
371|. .|0|. .|
372+ +-+-+-+ +
373|. . . . .|
374+-+-+-+-+-+
375";
376 let level = Level::parse(text).expect("parses");
377 assert_eq!(
378 solve_with(&level, Effort::Exhaustive),
379 SolveOutcome::Unsolvable
380 );
381 assert_eq!(solve_with(&level, Effort::Budget(4)), SolveOutcome::GaveUp);
382 }
383
384 /// A budget must not cost correctness on a board it can afford: one
385 /// simulation is enough to see that a level already won needs nothing.
386 #[test]
387 fn a_budget_large_enough_still_finds_the_answer() {
388 let level = &campaign_levels()[1];
389 assert_eq!(
390 solve_with(level, Effort::Budget(DEFAULT_NODE_BUDGET)),
391 solve_with(level, Effort::Exhaustive),
392 );
393 }
394}