Skip to main content

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}