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, 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 the board's [`Level::deadline`], which makes this a
25/// ceiling on 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        self.level.play_out(&mut sim).0 == PuzzleOutcome::Won
189    }
190
191    /// Tiles any creature arrives at during a run of the current board: the
192    /// only places a new signpost could matter. Gulls count too: a solution
193    /// may hinge on steering a gull away from the crabs. Restricted to empty,
194    /// signpost-free tiles (the only legal placements).
195    fn visited_placeable_tiles(&mut self, board: &Board) -> Vec<(u8, u8)> {
196        if !self.charge() {
197            return Vec::new();
198        }
199        let mut sim = board.clone();
200        let mut seen = vec![false; sim.width() as usize * sim.height() as usize];
201        for _ in 0..Level::deadline(&sim) {
202            sim.tick_idle();
203            for crab in sim.crabs() {
204                seen[crab.tile as usize] = true;
205            }
206            for gull in sim.gulls() {
207                seen[gull.tile as usize] = true;
208            }
209            if sim.crabs().is_empty() {
210                break;
211            }
212        }
213        let mut tiles = Vec::new();
214        for (x, y, kind) in board.tiles() {
215            if seen[usize::from(board.index_of(x, y))]
216                && kind == TileKind::Empty
217                && board.signpost_at(x, y).is_none()
218            {
219                tiles.push((x, y));
220            }
221        }
222        tiles
223    }
224
225    /// One whole search at a fixed inventory size.
226    ///
227    /// Within a single call, "how many are placed" and "how many are left"
228    /// add to a constant, so the placed set alone identifies a node and the
229    /// memo is exact. Across calls it is not, so the memo starts empty.
230    fn search_at(&mut self, board: &mut Board, depth: u8) -> Option<Vec<Placement>> {
231        self.seen.clear();
232        self.placed.clear();
233        self.run(board, depth)
234    }
235
236    fn run(&mut self, board: &mut Board, depth: u8) -> Option<Vec<Placement>> {
237        // Somewhere we have already been, by another road. Nothing about the
238        // board depends on how it was reached, so neither does the answer.
239        // The leaves are asked too, and they are the ones that matter: a
240        // set of `d` posts has up to `d` parents, and each used to play the
241        // full board out again, which is the one thing the budget counts.
242        let mut key: Vec<(u8, u8, u8)> = self
243            .placed
244            .iter()
245            .map(|(x, y, dir)| (*x, *y, dir.id()))
246            .collect();
247        key.sort_unstable();
248        if !self.seen.insert(key) {
249            return None;
250        }
251        if depth == 0 {
252            return self.wins(board).then(Vec::new);
253        }
254        for (x, y) in self.visited_placeable_tiles(board) {
255            for dir in Direction::ALL {
256                if self.gave_up {
257                    return None;
258                }
259                if !board.place_signpost(0, x, y, dir) {
260                    continue;
261                }
262                self.placed.push((x, y, dir));
263                if let Some(mut placements) = self.run(board, depth - 1) {
264                    placements.push((x, y, dir));
265                    return Some(placements);
266                }
267                self.placed.pop();
268                board.remove_signpost(0, x, y);
269            }
270        }
271        None
272    }
273}
274
275/// Convenience: validate a level exhaustively, returning the solution found
276/// (also checks an authored level's claim that it is solvable).
277pub fn validate(level: &Level) -> Result<Vec<Placement>, String> {
278    match solve_with(level, Effort::Exhaustive) {
279        SolveOutcome::Found(placements) => Ok(placements),
280        SolveOutcome::Unsolvable => Err(format!("no solution within {} signposts", level.posts)),
281        // Unreachable by construction: an exhaustive search has no budget to
282        // run out of. Spelled out rather than waved through, so that adding a
283        // second way to stop early cannot silently become "no solution".
284        SolveOutcome::GaveUp => Err("search gave up".into()),
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn validate_reports_solvability_either_way() {
294        let solvable = crate::sim::campaign_levels().remove(1);
295        assert!(validate(&solvable).is_ok());
296        // A castle sealed on all four sides cannot be solved.
297        let text = "name: No\nposts: 1\ncrab: 0,0 R L common\nmap:\n\
298+-+-+-+\n|. . .|\n+ +-+ +\n|.|0|.|\n+ +-+ +\n|. . .|\n+-+-+-+\n";
299        let level = crate::sim::Level::parse(text).expect("parses");
300        let err = validate(&level).unwrap_err();
301        assert!(err.contains("no solution"), "{err}");
302    }
303    use crate::sim::campaign_levels;
304    use crate::sim::level::PuzzleOutcome;
305
306    #[test]
307    fn solver_cracks_an_early_campaign_level() {
308        // Level 2, "First Turn": one post, one crab.
309        let levels = campaign_levels();
310        let level = &levels[1];
311        let SolveOutcome::Found(solution) = solve(level) else {
312            panic!("level 2 is solvable");
313        };
314        assert!(solution.len() <= level.posts as usize);
315        // Replay the found solution to be sure.
316        let mut board = level.board();
317        for &(x, y, dir) in &solution {
318            assert!(board.place_signpost(0, x, y, dir));
319        }
320        let (outcome, _) = level.play_out(&mut board);
321        assert_eq!(
322            outcome,
323            PuzzleOutcome::Won,
324            "solver's answer must actually win"
325        );
326    }
327
328    #[test]
329    fn unsolvable_level_returns_none() {
330        // A crab orbiting the border with zero posts and an unreachable
331        // interior castle: provably unsolvable.
332        let text = "\
333name: Hopeless
334posts: 0
335crab: 0,0 R L common
336map:
337+-+-+-+-+-+
338|. . . . .|
339+ +-+-+-+ +
340|. .|0|. .|
341+ +-+-+-+ +
342|. . . . .|
343+-+-+-+-+-+
344";
345        let level = Level::parse(text).expect("parses");
346        assert_eq!(solve(&level), SolveOutcome::Unsolvable);
347    }
348
349    /// The distinction the budget exists to make: the same hopeless board
350    /// answers "no solution" when the search is allowed to finish, and "gave
351    /// up" when it is not. Both are honest; only the first is a claim about
352    /// the level.
353    #[test]
354    fn a_spent_budget_gives_up_rather_than_claiming_unsolvable() {
355        let text = "\
356name: Hopeless
357posts: 2
358crab: 0,0 R L common
359map:
360+-+-+-+-+-+
361|. . . . .|
362+ +-+-+-+ +
363|. .|0|. .|
364+ +-+-+-+ +
365|. . . . .|
366+-+-+-+-+-+
367";
368        let level = Level::parse(text).expect("parses");
369        assert_eq!(
370            solve_with(&level, Effort::Exhaustive),
371            SolveOutcome::Unsolvable
372        );
373        assert_eq!(solve_with(&level, Effort::Budget(4)), SolveOutcome::GaveUp);
374    }
375
376    /// A budget must not cost correctness on a board it can afford: one
377    /// simulation is enough to see that a level already won needs nothing.
378    #[test]
379    fn a_budget_large_enough_still_finds_the_answer() {
380        let level = &campaign_levels()[1];
381        assert_eq!(
382            solve_with(level, Effort::Budget(DEFAULT_NODE_BUDGET)),
383            solve_with(level, Effort::Exhaustive),
384        );
385    }
386}