mod format;
use crate::sim::board::{Board, CapPolicy};
use crate::sim::solve::Placement;
pub const PUZZLE_TICK_LIMIT: u64 = 60 * crate::sim::TICKS_PER_SECOND as u64;
#[derive(Clone, Debug)]
pub struct Level {
pub name: String,
pub posts: u8,
pub solution: Vec<Placement>,
pub goal: Goal,
pub kind: LevelKind,
board: Board,
crab_count: u32,
explicit_rule: bool,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum LevelKind {
#[default]
Puzzle,
Arena,
}
impl LevelKind {
pub fn token(self) -> &'static str {
match self {
LevelKind::Puzzle => "puzzle",
LevelKind::Arena => "arena",
}
}
pub fn from_token(token: &str) -> Option<LevelKind> {
match token {
"puzzle" => Some(LevelKind::Puzzle),
"arena" => Some(LevelKind::Arena),
_ => None,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Goal {
AllCrabs,
Bank(u32),
Survive,
Golden,
}
impl Goal {
pub fn saves_every_crab(self) -> bool {
match self {
Goal::AllCrabs | Goal::Survive => true,
Goal::Bank(_) | Goal::Golden => false,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PuzzleOutcome {
Running,
Won,
Lost,
}
impl Level {
pub fn board(&self) -> Board {
let mut board = self.board.clone();
if !self.explicit_rule && self.kind == LevelKind::Puzzle {
board.set_signpost_rule(self.posts, CapPolicy::Reject);
board.set_castle_raids(false);
}
board
}
pub fn crab_count(&self) -> u32 {
self.crab_count
}
pub fn with_kind(mut self, kind: LevelKind) -> Level {
self.kind = kind;
self
}
pub fn seats(&self) -> u8 {
self.board.castle_seats()
}
pub fn play_out(&self, board: &mut Board) -> (PuzzleOutcome, u64) {
loop {
board.tick_idle();
match self.outcome(board) {
PuzzleOutcome::Running => {}
done @ (PuzzleOutcome::Won | PuzzleOutcome::Lost) => return (done, board.ticks()),
}
}
}
pub fn place_solution(&self, board: &mut Board) -> Result<(), Placement> {
for &(x, y, dir) in &self.solution {
if !board.place_signpost(0, x, y, dir) {
return Err((x, y, dir));
}
}
Ok(())
}
pub fn solved_board(&self) -> Result<Board, Placement> {
let mut board = self.board();
self.place_solution(&mut board)?;
Ok(board)
}
pub fn deadline(board: &Board) -> u64 {
board.round_length().map_or(PUZZLE_TICK_LIMIT, u64::from)
}
pub fn outcome(&self, board: &Board) -> PuzzleOutcome {
let alive = board.crabs().len() as u32;
let eaten = board.crabs_banked() + alive < board.crabs_spawned();
let timed_out = board.ticks() >= Self::deadline(board);
let (lost, won) = match self.goal {
Goal::AllCrabs => (
eaten,
alive == 0 && board.crabs_banked() == board.crabs_spawned(),
),
Goal::Bank(n) => (false, board.crabs_banked() >= n),
Goal::Survive => (eaten, timed_out),
Goal::Golden => (false, board.golden_banked() >= 1),
};
if lost {
PuzzleOutcome::Lost
} else if won {
PuzzleOutcome::Won
} else if timed_out {
PuzzleOutcome::Lost
} else {
PuzzleOutcome::Running
}
}
}
#[cfg(test)]
mod tests {
use crate::sim::direction::Direction;
#[test]
fn parse_rejects_bad_tile_chars() {
let text = "name: Bad\nposts: 1\nmap:\n+-+\n|X|\n+-+\n";
let err = Level::parse(text).unwrap_err();
assert!(err.contains("bad tile char"), "{err}");
}
#[test]
fn parse_rejects_unknown_keys() {
let text = "name: Bad\nwibble: 3\nmap:\n+-+\n|.|\n+-+\n";
let err = Level::parse(text).unwrap_err();
assert!(err.contains("unknown key"), "{err}");
}
#[test]
fn parse_rejects_malformed_solutions_and_directions() {
let text = "name: Bad\nposts: 1\nsolution: 0,0 Q\nmap:\n+-+\n|.|\n+-+\n";
assert!(Level::parse(text).is_err());
let text = "name: Bad\nposts: 1\nsolution: zero,0 U\nmap:\n+-+\n|.|\n+-+\n";
assert!(Level::parse(text).is_err());
}
#[test]
fn parse_rejects_a_map_with_no_tiles() {
for text in [
"name: Bad\nposts: 1\nmap:\n+-+-+\n", "name: Bad\nposts: 1\nmap:\n+\n|\n+\n", "name: Bad\nposts: 1\nmap:\n+\n",
] {
let err = Level::parse(text).unwrap_err();
assert!(err.contains("at least one tile"), "{text:?} gave {err}");
}
}
#[test]
fn parse_rejects_a_map_too_wide_to_name() {
let border: String = "+-".repeat(300) + "+";
let row: String = "|.".repeat(300) + "|";
let text = format!("name: Vast\nposts: 1\nmap:\n{border}\n{row}\n{border}\n");
let err = Level::parse(&text).unwrap_err();
assert!(err.contains("300"), "{err}");
let border: String = "+-".repeat(255) + "+";
let row: String = "|.".repeat(255) + "|";
let text = format!("name: Wide\nposts: 1\nmap:\n{border}\n{row}\n{border}\n");
let level = Level::parse(&text).expect("255 tiles is nameable");
assert_eq!(level.board.width(), 255);
}
#[test]
fn parse_pads_short_map_rows() {
let text = "name: Trimmed\nposts: 1\nmap:\n+-+-+\n|.\n+-+-+\n";
let level = Level::parse(text).expect("pads, never panics");
assert_eq!(level.board.width(), 2);
}
use super::*;
use crate::sim::board::TileKind;
#[test]
fn parse_refuses_a_map_row_wider_than_its_border() {
let text = "name: T\nposts: 1\nmap:\n+-+-+\n|.|.|.|\n+-+-+\n";
let err = Level::parse(text).unwrap_err();
assert!(err.contains("row 1 is 7 wide"), "{err}");
}
#[test]
fn castle_raids_survive_the_format() {
let map = "map:\n+-+-+\n|. 0|\n+ + +\n|. .|\n+-+-+\n";
let head = "name: T\nposts: 1\nrule: reject 1\ncrab: 0,0 R R common\n";
let kept = Level::parse(&format!("{head}{map}")).expect("level");
assert!(kept.board().castle_raids(), "stated rule, raids kept");
assert!(
!kept.to_text().contains("raids:"),
"the default is not written"
);
let off = Level::parse(&format!("{head}raids: off\n{map}")).expect("level");
assert!(!off.board().castle_raids());
let text = off.to_text();
assert!(text.contains("raids: off"), "{text}");
let back = Level::parse(&text).expect("round trip");
assert!(!back.board().castle_raids());
}
#[test]
fn the_gull_cap_survives_the_format() {
let map = "map:\n+-+-+\n|. 0|\n+ + +\n|. .|\n+-+-+\n";
let head = "name: T\nposts: 1\ncrab: 0,0 R R common\n";
let plain = Level::parse(&format!("{head}{map}")).expect("level");
assert_eq!(usize::from(plain.board().gull_cap()), crate::sim::GULL_CAP);
assert!(
!plain.to_text().contains("gull_cap"),
"the default is not written"
);
let big = Level::parse(&format!("{head}gull_cap: 14\n{map}")).expect("level");
assert_eq!(big.board().gull_cap(), 14);
let back = Level::parse(&big.to_text()).expect("round trip");
assert_eq!(back.board().gull_cap(), 14);
assert!(
!plain.board().gull_turnover(),
"off unless the file says so"
);
assert!(!plain.to_text().contains("gull_turnover"));
let turning = Level::parse(&format!("{head}gull_turnover: on\n{map}")).expect("level");
let back = Level::parse(&turning.to_text()).expect("round trip");
assert!(back.board().gull_turnover());
assert!(!plain.board().timed_lock(), "off unless the file says so");
assert!(!plain.to_text().contains("timed_lock"));
let timed = Level::parse(&format!("{head}timed_lock: on\n{map}")).expect("level");
let back = Level::parse(&timed.to_text()).expect("round trip");
assert!(back.board().timed_lock());
assert!(!plain.board().sweep_flight(), "off unless the file says so");
assert!(!plain.to_text().contains("sweep_flight"));
let flying = Level::parse(&format!("{head}sweep_flight: on\n{map}")).expect("level");
let back = Level::parse(&flying.to_text()).expect("round trip");
assert!(back.board().sweep_flight());
}
#[test]
fn a_stated_round_outlasts_the_tick_limit() {
let text = "name: T\nposts: 0\ngoal: survive\nround: 2700\nmap:\n\
+-+-+\n|. .|\n+ + +\n|. .|\n+-+-+\n";
let level = Level::parse(text).expect("level");
let mut board = level.board();
assert_eq!(Level::deadline(&board), 2700);
for _ in 0..PUZZLE_TICK_LIMIT {
board.tick_idle();
}
assert_eq!(
level.outcome(&board),
PuzzleOutcome::Running,
"the limit is not the round"
);
let (outcome, at) = level.play_out(&mut board);
assert_eq!((outcome, at), (PuzzleOutcome::Won, 2700));
let untimed = Level::parse(&text.replace("round: 2700\n", "")).expect("level");
assert_eq!(Level::deadline(&untimed.board()), PUZZLE_TICK_LIMIT);
}
#[test]
fn the_deadline_loses_every_goal_but_survive() {
let map = "map:\n+-+-+\n|. .|\n+ + +\n|. .|\n+-+-+\n";
let timed = Level::parse(&format!(
"name: T\nposts: 0\nround: 300\ncrab: 0,0 R R common\n{map}"
))
.expect("level");
let mut board = timed.board();
assert_eq!(timed.play_out(&mut board), (PuzzleOutcome::Lost, 300));
let untimed = Level::parse(&format!("name: T\nposts: 0\ncrab: 0,0 R R common\n{map}"))
.expect("level");
let mut board = untimed.board();
assert_eq!(
untimed.play_out(&mut board),
(PuzzleOutcome::Lost, PUZZLE_TICK_LIMIT)
);
}
#[test]
fn raids_on_is_the_default_said_out_loud() {
let text = "name: T\nposts: 1\nrule: reject 1\nraids: on\ncrab: 0,0 R R common\nmap:\n\
+-+-+\n|. 0|\n+ + +\n|. .|\n+-+-+\n";
let level = Level::parse(text).expect("level");
assert!(level.board().castle_raids());
assert!(!level.to_text().contains("raids:"), "not worth writing");
}
#[test]
fn a_turnstiles_pivot_survives_the_format() {
for next_right in [true, false] {
let mut board = Board::new(4, 3, 1);
board.set_tile(1, 1, TileKind::Turnstile { next_right });
let text = Level::from_board("Pivot", 1, board).to_text();
let parsed = Level::parse(&text).expect("own output").board();
assert_eq!(
parsed.tile_at(1, 1),
TileKind::Turnstile { next_right },
"next_right {next_right} was not preserved:\n{text}"
);
}
}
const TINY: &str = "\
name: Tiny
posts: 1
crab: 0,0 R L common
solution: 2,0 D
map:
+-+-+-+
|. . .|
+ +-+ +
|. . 0|
+-+-+-+
";
#[test]
fn terrain_tiles_round_trip() {
let mut board = Board::new(4, 3, 7);
board.set_tile(1, 1, TileKind::Turnstile { next_right: true });
board.set_tile(2, 1, TileKind::Kelp);
board.set_tile(3, 1, TileKind::Pool);
let text = Level::from_board("Terrain", 2, board).to_text();
let level = Level::parse(&text).expect("parses");
assert_eq!(
level.board.tile_at(1, 1),
TileKind::Turnstile { next_right: true }
);
assert_eq!(level.board.tile_at(2, 1), TileKind::Kelp);
assert_eq!(level.board.tile_at(3, 1), TileKind::Pool);
assert_eq!(level.to_text(), text, "text form is stable");
}
#[test]
fn parses_dimensions_walls_and_tiles() {
let level = Level::parse(TINY).expect("parses");
assert_eq!(level.name, "Tiny");
assert_eq!(level.posts, 1);
assert_eq!(level.crab_count(), 1);
let board = level.board();
assert_eq!((board.width(), board.height()), (3, 2));
assert_eq!(board.tile_at(2, 1), TileKind::Castle(0));
assert!(board.wall_at(1, 0, Direction::Down));
assert!(!board.wall_at(0, 0, Direction::Down));
assert_eq!(board.crabs().len(), 1);
}
#[test]
fn solution_wins_the_puzzle() {
let level = Level::parse(TINY).expect("parses");
let mut board = level.solved_board().expect("every placement lands");
assert_eq!(level.play_out(&mut board).0, PuzzleOutcome::Won);
}
#[test]
fn serialization_round_trips() {
let level = Level::parse(TINY).expect("parses");
let text = level.to_text();
let again = Level::parse(&text).unwrap_or_else(|e| panic!("reparse: {e}\n{text}"));
assert_eq!(level.name, again.name);
assert_eq!(level.posts, again.posts);
assert_eq!(level.solution, again.solution);
assert_eq!(
level.board().state_hash(),
again.board().state_hash(),
"round-tripped board must be bit-identical"
);
}
#[test]
fn timed_level_loses_at_the_wave() {
let text = TINY.replace("posts: 1", "posts: 1\nround: 10");
let level = Level::parse(&text).expect("parses");
let mut board = level.board();
for _ in 0..12 {
board.tick_idle();
}
assert!(board.round_over());
assert_eq!(level.outcome(&board), PuzzleOutcome::Lost);
}
#[test]
fn goal_wrap_and_new_kinds_round_trip() {
let text = "\
name: Fancy
posts: 2
rule: evict 3
round: 900
wrap: on
goal: bank 30
crab: 0,0 R L golden
crab: 2,1 L R sparkling
map:
+-+-+-+-+
|. . . .|
+ + + + +
|. 0 . .|
+-+-+-+-+
";
let level = Level::parse(text).expect("parses");
assert_eq!(level.goal, Goal::Bank(30));
assert!(level.board().wrap());
let again = Level::parse(&level.to_text()).expect("reparses");
assert_eq!(again.goal, level.goal);
assert_eq!(
again.board().state_hash(),
level.board().state_hash(),
"wrap/goal/kind round-trip must be bit-identical"
);
}
#[test]
fn goal_outcomes_judge_correctly() {
let bank = Level::parse(
"name: B\nposts: 0\nrule: evict 3\nround: 900\ngoal: bank 1\n\
crab: 0,1 R L common\nmap:\n+-+-+-+\n|. . .|\n+ + + +\n|. . 0|\n+-+-+-+\n",
)
.expect("parses");
let mut board = bank.board();
assert_eq!(bank.outcome(&board), PuzzleOutcome::Running);
for _ in 0..200 {
board.tick_idle();
}
assert_eq!(bank.outcome(&board), PuzzleOutcome::Won);
let survive = Level::parse(
"name: S\nposts: 0\nrule: evict 3\nround: 900\ngoal: survive\n\
crab: 1,0 R L common\ngull: 1,0 R\nmap:\n+-+-+-+-+-+-+-+-+-+\n\
|. . . . . . . . .|\n+-+-+-+-+-+-+-+-+-+\n",
)
.expect("parses");
let mut board = survive.board();
board.tick_idle();
assert_eq!(
survive.outcome(&board),
PuzzleOutcome::Lost,
"the gull got someone"
);
}
#[test]
fn the_kind_round_trips_and_outranks_the_castles() {
let level = Level::parse(TINY).expect("parses");
assert_eq!(level.kind, LevelKind::Puzzle, "one castle, one player");
for kind in [LevelKind::Puzzle, LevelKind::Arena] {
let text = level.clone().with_kind(kind).to_text();
assert_eq!(Level::parse(&text).expect("reparses").kind, kind, "{text}");
}
let four = "\
name: Four
posts: 1
kind: puzzle
crab: 0,0 R L common
map:
+-+-+-+-+
|0 1 2 3|
+-+-+-+-+
";
let level = Level::parse(four).expect("parses");
assert_eq!(level.seats(), 4);
assert_eq!(level.kind, LevelKind::Puzzle, "the line beats the board");
assert!(Level::parse(&four.replace("kind: puzzle", "kind: mud")).is_err());
}
#[test]
fn a_file_with_no_kind_is_read_off_its_castles() {
assert_eq!(Level::parse(TINY).expect("parses").kind, LevelKind::Puzzle);
let two = "\
name: Old Arena
posts: 3
crab: 0,0 R L common
map:
+-+-+-+
|0 . 1|
+-+-+-+
";
let level = Level::parse(two).expect("parses");
assert_eq!(level.seats(), 2);
assert_eq!(level.kind, LevelKind::Arena);
assert!(
level.to_text().contains("kind: arena"),
"{}",
level.to_text()
);
}
#[test]
fn inventory_is_enforced() {
let level = Level::parse(TINY).expect("parses");
let mut board = level.board();
assert!(board.place_signpost(0, 0, 1, Direction::Up));
assert!(!board.place_signpost(0, 1, 1, Direction::Up));
assert_eq!(board.signpost_count(0), 1);
}
}