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,
}
#[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
}
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 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.round_over() || board.ticks() >= PUZZLE_TICK_LIMIT;
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 = super::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 = super::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!(super::Level::parse(text).is_err());
let text = "name: Bad\nposts: 1\nsolution: zero,0 U\nmap:\n+-+\n|.|\n+-+\n";
assert!(super::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 = super::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 = super::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 = super::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 = super::Level::parse(text).expect("pads, never panics");
assert_eq!(level.board.width(), 2);
}
use super::*;
use crate::sim::board::TileKind;
#[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.board();
for &(x, y, dir) in &level.solution {
assert!(board.place_signpost(0, x, y, dir), "placement ({x},{y})");
}
let outcome = loop {
board.tick_idle();
match level.outcome(&board) {
PuzzleOutcome::Running => {}
done @ (PuzzleOutcome::Won | PuzzleOutcome::Lost) => break done,
}
};
assert_eq!(outcome, 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);
}
}