use crate::sim::board::{Board, PlayerId, TileKind};
use crate::sim::crab::Handedness;
use crate::sim::direction::Direction;
use crate::sim::level::{Level, PuzzleOutcome};
pub type Placement = (u8, u8, Direction, PlayerId);
fn placing_seats(board: &Board) -> &'static [PlayerId] {
match board.rules().claw_hands {
Some(_) => &[0, 1],
None => &[0],
}
}
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 {
solve_from(level, effort, 0)
}
pub fn solve_from(level: &Level, effort: Effort, fewest: u8) -> SolveOutcome {
let mut search = Search::new(level, effort);
let room = placing_seats(&search.sim)
.iter()
.map(|&seat| search.sim.signpost_cap_of(seat))
.fold(0u8, u8::saturating_add);
for depth in fewest..=level.posts.min(room) {
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, u8)>>,
placed: Vec<Placement>,
sim: Board,
visited: Vec<u8>,
tile_pool: Vec<Vec<(u8, u8, PlayerId)>>,
key: Vec<(u8, u8, u8, u8)>,
}
const LEFT_CLAW: u8 = 1;
const RIGHT_CLAW: u8 = 2;
const GULL: u8 = 4;
fn turned_by(board: &Board, seat: PlayerId) -> u8 {
match (board.rules().claw_hands, crate::sim::board::claw_of(seat)) {
(Some(_), Some(Handedness::Left)) => LEFT_CLAW | GULL,
(Some(_), Some(Handedness::Right)) => RIGHT_CLAW | GULL,
(Some(_), None) => 0,
(None, _) => LEFT_CLAW | RIGHT_CLAW | GULL,
}
}
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(),
sim: level.board(),
visited: Vec::new(),
tile_pool: Vec::new(),
key: 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;
}
self.sim.copy_from(board);
self.level.play_out(&mut self.sim).0 == PuzzleOutcome::Won
}
fn visited_placeable_tiles(&mut self, board: &Board, tiles: &mut Vec<(u8, u8, PlayerId)>) {
tiles.clear();
if !self.charge() {
return;
}
self.sim.copy_from(board);
self.visited.clear();
self.visited
.resize(self.sim.width() as usize * self.sim.height() as usize, 0);
for _ in 0..Level::deadline(&self.sim) {
self.sim.tick_idle();
for crab in self.sim.crabs() {
self.visited[crab.tile as usize] |= match crab.handed {
Handedness::Left => LEFT_CLAW,
Handedness::Right => RIGHT_CLAW,
};
}
for gull in self.sim.gulls() {
self.visited[gull.tile as usize] |= GULL;
}
if self.level.outcome(&self.sim) != PuzzleOutcome::Running {
break;
}
}
for (x, y, kind) in board.tiles() {
let arrived = self.visited[usize::from(board.index_of(x, y))];
if arrived == 0 || kind != TileKind::Empty || board.signpost_at(x, y).is_some() {
continue;
}
for &seat in placing_seats(board) {
if arrived & turned_by(board, seat) != 0 && board.signpost_cap_of(seat) > 0 {
tiles.push((x, y, seat));
}
}
}
}
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>> {
self.key.clear();
self.key.extend(
self.placed
.iter()
.map(|&(x, y, dir, seat)| (x, y, dir.id(), seat)),
);
self.key.sort_unstable();
if self.seen.contains(&self.key) {
return None;
}
self.seen.insert(self.key.clone());
if depth == 0 {
return self.wins(board).then(Vec::new);
}
let mut tiles = self.tile_pool.pop().unwrap_or_default();
self.visited_placeable_tiles(board, &mut tiles);
let mut found = None;
'candidates: for &(x, y, seat) in &tiles {
for dir in Direction::ALL {
if self.gave_up {
break 'candidates;
}
if !board.place_signpost(seat, x, y, dir) {
continue;
}
self.placed.push((x, y, dir, seat));
if let Some(mut placements) = self.run(board, depth - 1) {
placements.push((x, y, dir, seat));
found = Some(placements);
break 'candidates;
}
self.placed.pop();
board.remove_signpost(seat, x, y);
}
}
tiles.clear();
self.tile_pool.push(tiles);
found
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Deal {
Short(PlayerId),
Alone(PlayerId),
Ordinary,
}
impl Deal {
pub fn all(hands: [u8; 2]) -> Vec<Deal> {
let mut deals: Vec<Deal> = (0..2)
.filter(|&seat| hands[usize::from(seat)] > 0)
.map(Deal::Short)
.collect();
deals.extend([Deal::Alone(0), Deal::Alone(1), Deal::Ordinary]);
deals
}
pub fn level(self, level: &Level) -> Level {
let hands = level.hands().unwrap_or([level.posts, 0]);
let total = hands[0].saturating_add(hands[1]);
match self {
Deal::Short(seat) => {
let mut short = hands;
short[usize::from(seat)] = short[usize::from(seat)].saturating_sub(1);
level.with_hands(Some(short), 0)
}
Deal::Alone(0) => level.with_hands(Some([total, 0]), 0),
Deal::Alone(_) => level.with_hands(Some([0, total]), 0),
Deal::Ordinary => level.with_hands(None, total),
}
}
}
pub fn needs_both(level: &Level, effort: Effort) -> SolveOutcome {
let mut gave_up = false;
for seat in [0, 1] {
match solve_with(&Deal::Alone(seat).level(level), effort) {
found @ SolveOutcome::Found(_) => return found,
SolveOutcome::Unsolvable => {}
SolveOutcome::GaveUp => gave_up = true,
}
}
match gave_up {
true => SolveOutcome::GaveUp,
false => SolveOutcome::Unsolvable,
}
}
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 = 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 = 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 a_search_from_a_count_skips_the_counts_below_it() {
let level = campaign_levels()
.into_iter()
.find(|level| level.posts >= 2)
.expect("a campaign stage of two signposts or more");
let budget = DEFAULT_NODE_BUDGET * 4;
let spent = |fewest: u8| {
let mut search = Search::new(&level, Effort::Budget(budget));
let mut found = None;
for depth in fewest..=level.posts {
let mut board = level.board();
if let Some(placements) = search.search_at(&mut board, depth) {
found = Some(placements.len());
break;
}
}
(found, budget - search.fuel.unwrap_or(0))
};
let (all, all_cost) = spent(0);
let (past, past_cost) = spent(level.posts);
assert_eq!(all, Some(usize::from(level.posts)), "{}", level.name);
assert_eq!(past, all);
assert!(past_cost < all_cost, "{past_cost} vs {all_cost}");
let SolveOutcome::Found(placements) =
solve_from(&level, Effort::Budget(budget), level.posts)
else {
panic!("{} wins at its grant", level.name);
};
assert_eq!(placements.len(), usize::from(level.posts));
}
#[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, seat) in &solution {
assert!(board.place_signpost(seat, x, y, dir));
}
let (outcome, _) = level.play_out(&mut board);
assert_eq!(
outcome,
PuzzleOutcome::Won,
"solver's answer must actually win"
);
}
#[test]
fn an_inventory_past_an_evicting_cap_is_searched_only_to_the_cap() {
let text = "\
name: Evicting
posts: 3
rule: evict 1
crab: 0,3 R L common
map:
+-+-+-+-+-+
|. . . . .|
+ + + + + +
|. . 0 . .|
+ + + + + +
|. . . . .|
+ + + + + +
|. . . . .|
+-+-+-+-+-+
";
let level = Level::parse(text).expect("parses");
let SolveOutcome::Found(solution) = solve_with(&level, Effort::Exhaustive) else {
panic!("one post turns the crab home");
};
assert_eq!(solution.len(), 1, "{solution:?}");
let mut board = level.board();
for &(x, y, dir, seat) in &solution {
assert!(board.place_signpost(seat, x, y, dir));
}
assert_eq!(level.play_out(&mut board).0, PuzzleOutcome::Won);
let sealed = text.replace("|. . 0 . .|", "|. .|0|. .|").replace(
"+ + + + + +\n|. .|0|. .|\n+ + + + + +",
"+ + +-+ + +\n|. .|0|. .|\n+ + +-+ + +",
);
let level = Level::parse(&sealed).expect("parses");
assert_eq!(
solve_with(&level, Effort::Exhaustive),
SolveOutcome::Unsolvable
);
}
#[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 two_hands_past_a_u8_do_not_overflow_the_room() {
let text = "\
name: Big Hands
posts: 1
crab: 0,0 R L common
map:
+-+-+-+
|. . 0|
+-+-+-+
";
let level = Level::parse(text)
.expect("parses")
.with_hands(Some([250, 9]), 0);
assert!(matches!(
solve_with(&level, Effort::Budget(64)),
SolveOutcome::Found(_)
));
}
#[test]
fn a_quiet_spell_does_not_end_the_scan_on_a_stage_that_plays_on() {
let text = "\
name: Second Wave
posts: 1
rule: evict 3
round: 600
goal: bank 5
spawner: 0,1 R 150
map:
+-+-+-+
|. 0 .|
+ + + +
|. t .|
+ + + +
|. . .|
+-+-+-+
";
let level = Level::parse(text).expect("parses");
let mut search = Search::new(&level, Effort::Exhaustive);
let mut tiles = Vec::new();
search.visited_placeable_tiles(&level.board(), &mut tiles);
assert!(
tiles.contains(&(1, 2, 0)),
"the second crab's tile: {tiles:?}"
);
}
#[test]
fn the_scan_stops_once_the_goal_is_decided() {
let text = "\
name: First Wave
posts: 1
rule: evict 3
round: 600
goal: bank 1
spawner: 0,1 R 150
map:
+-+-+-+
|. 0 .|
+ + + +
|. t .|
+ + + +
|. . .|
+-+-+-+
";
let level = Level::parse(text).expect("parses");
let mut search = Search::new(&level, Effort::Exhaustive);
let mut tiles = Vec::new();
search.visited_placeable_tiles(&level.board(), &mut tiles);
assert!(!tiles.contains(&(1, 2, 0)), "past the decision: {tiles:?}");
assert!(search.sim.ticks() < Level::deadline(&search.sim));
}
#[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),
);
}
}