use crate::sim::board::{Board, TileKind};
use crate::sim::direction::Direction;
use crate::sim::level::{Level, PUZZLE_TICK_LIMIT, PuzzleOutcome};
pub type Placement = (u8, u8, Direction);
pub const DEFAULT_NODE_BUDGET: u32 = 1_000_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Effort {
Budget(u32),
Exhaustive,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SolveOutcome {
Found(Vec<Placement>),
Unsolvable,
GaveUp,
}
pub fn solve(level: &Level) -> SolveOutcome {
solve_with(level, Effort::Budget(DEFAULT_NODE_BUDGET))
}
pub fn solve_with(level: &Level, effort: Effort) -> SolveOutcome {
let mut search = Search::new(level, effort);
for depth in 0..=level.posts {
let mut board = level.board();
if let Some(mut placements) = search.search_at(&mut board, depth) {
placements.reverse();
return SolveOutcome::Found(placements);
}
if search.gave_up {
return SolveOutcome::GaveUp;
}
}
SolveOutcome::Unsolvable
}
struct Search<'a> {
level: &'a Level,
fuel: Option<u32>,
gave_up: bool,
seen: std::collections::HashSet<Vec<(u8, u8, u8)>>,
placed: Vec<Placement>,
}
impl<'a> Search<'a> {
fn new(level: &'a Level, effort: Effort) -> Self {
Search {
level,
fuel: match effort {
Effort::Budget(nodes) => Some(nodes),
Effort::Exhaustive => None,
},
gave_up: false,
seen: std::collections::HashSet::new(),
placed: Vec::new(),
}
}
fn charge(&mut self) -> bool {
if self.gave_up {
return false;
}
if let Some(fuel) = self.fuel.as_mut() {
match fuel.checked_sub(1) {
Some(left) => *fuel = left,
None => {
self.gave_up = true;
return false;
}
}
}
true
}
fn wins(&mut self, board: &Board) -> bool {
if !self.charge() {
return false;
}
let mut sim = board.clone();
loop {
sim.tick_idle();
match self.level.outcome(&sim) {
PuzzleOutcome::Running => {}
PuzzleOutcome::Won => return true,
PuzzleOutcome::Lost => return false,
}
}
}
fn visited_placeable_tiles(&mut self, board: &Board) -> Vec<(u8, u8)> {
if !self.charge() {
return Vec::new();
}
let mut sim = board.clone();
let mut seen = vec![false; sim.width() as usize * sim.height() as usize];
for _ in 0..PUZZLE_TICK_LIMIT {
sim.tick_idle();
for crab in sim.crabs() {
seen[crab.tile as usize] = true;
}
for gull in sim.gulls() {
seen[gull.tile as usize] = true;
}
if sim.crabs().is_empty() {
break;
}
}
let mut tiles = Vec::new();
for (x, y, kind) in board.tiles() {
if seen[usize::from(board.index_of(x, y))]
&& kind == TileKind::Empty
&& board.signpost_at(x, y).is_none()
{
tiles.push((x, y));
}
}
tiles
}
fn search_at(&mut self, board: &mut Board, depth: u8) -> Option<Vec<Placement>> {
self.seen.clear();
self.placed.clear();
self.run(board, depth)
}
fn run(&mut self, board: &mut Board, depth: u8) -> Option<Vec<Placement>> {
if depth == 0 {
return self.wins(board).then(Vec::new);
}
let mut key: Vec<(u8, u8, u8)> = self
.placed
.iter()
.map(|(x, y, dir)| (*x, *y, dir.id()))
.collect();
key.sort_unstable();
if !self.seen.insert(key) {
return None;
}
for (x, y) in self.visited_placeable_tiles(board) {
for dir in Direction::ALL {
if self.gave_up {
return None;
}
if !board.place_signpost(0, x, y, dir) {
continue;
}
self.placed.push((x, y, dir));
if let Some(mut placements) = self.run(board, depth - 1) {
placements.push((x, y, dir));
return Some(placements);
}
self.placed.pop();
board.remove_signpost(0, x, y);
}
}
None
}
}
pub fn validate(level: &Level) -> Result<Vec<Placement>, String> {
match solve_with(level, Effort::Exhaustive) {
SolveOutcome::Found(placements) => Ok(placements),
SolveOutcome::Unsolvable => Err(format!("no solution within {} signposts", level.posts)),
SolveOutcome::GaveUp => Err("search gave up".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_reports_solvability_either_way() {
let solvable = crate::sim::campaign_levels().remove(1);
assert!(validate(&solvable).is_ok());
let text = "name: No\nposts: 1\ncrab: 0,0 R L common\nmap:\n\
+-+-+-+\n|. . .|\n+ +-+ +\n|.|0|.|\n+ +-+ +\n|. . .|\n+-+-+-+\n";
let level = crate::sim::Level::parse(text).expect("parses");
let err = validate(&level).unwrap_err();
assert!(err.contains("no solution"), "{err}");
}
use crate::sim::campaign_levels;
use crate::sim::level::PuzzleOutcome;
#[test]
fn solver_cracks_an_early_campaign_level() {
let levels = campaign_levels();
let level = &levels[1];
let SolveOutcome::Found(solution) = solve(level) else {
panic!("level 2 is solvable");
};
assert!(solution.len() <= level.posts as usize);
let mut board = level.board();
for &(x, y, dir) in &solution {
assert!(board.place_signpost(0, x, y, dir));
}
let won = loop {
board.tick_idle();
match level.outcome(&board) {
PuzzleOutcome::Running => {}
done @ (PuzzleOutcome::Won | PuzzleOutcome::Lost) => {
break done == PuzzleOutcome::Won;
}
}
};
assert!(won, "solver's answer must actually win");
}
#[test]
fn unsolvable_level_returns_none() {
let text = "\
name: Hopeless
posts: 0
crab: 0,0 R L common
map:
+-+-+-+-+-+
|. . . . .|
+ +-+-+-+ +
|. .|0|. .|
+ +-+-+-+ +
|. . . . .|
+-+-+-+-+-+
";
let level = Level::parse(text).expect("parses");
assert_eq!(solve(&level), SolveOutcome::Unsolvable);
}
#[test]
fn a_spent_budget_gives_up_rather_than_claiming_unsolvable() {
let text = "\
name: Hopeless
posts: 2
crab: 0,0 R L common
map:
+-+-+-+-+-+
|. . . . .|
+ +-+-+-+ +
|. .|0|. .|
+ +-+-+-+ +
|. . . . .|
+-+-+-+-+-+
";
let level = Level::parse(text).expect("parses");
assert_eq!(
solve_with(&level, Effort::Exhaustive),
SolveOutcome::Unsolvable
);
assert_eq!(solve_with(&level, Effort::Budget(4)), SolveOutcome::GaveUp);
}
#[test]
fn a_budget_large_enough_still_finds_the_answer() {
let level = &campaign_levels()[1];
assert_eq!(
solve_with(level, Effort::Budget(DEFAULT_NODE_BUDGET)),
solve_with(level, Effort::Exhaustive),
);
}
}