mod format;
use crate::sim::board::{ArenaProblem, 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,
pub filing: Option<Filing>,
pub tables: Vec<u8>,
pub roll: u8,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Filing {
pub key: String,
pub legacy: Option<String>,
}
#[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, Default)]
pub enum Goal {
#[default]
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.rules_mut().castle_raids = false;
}
board
}
pub fn board_seeded(&self, seed: u64) -> Board {
match Level::parse_at(&self.to_text(), Some(seed)) {
Ok(level) => level.board(),
Err(e) => {
debug_assert!(false, "{:?} does not read back: {e}", self.name);
self.board()
}
}
}
pub fn table_board(&self, seed: u64, seats: u8) -> Board {
let mut board = self.board_seeded(seed);
board.retire_castles_from(seats);
board
}
pub fn fits(&self, seats: u8) -> bool {
self.table_seats().is_some_and(|castles| castles >= seats)
&& (self.tables.is_empty() || self.tables.contains(&seats))
}
pub fn table_seats(&self) -> Option<u8> {
self.board.table_seats()
}
pub fn arena_problem(&self) -> Option<ArenaProblem> {
self.board.arena_problem()
}
pub fn crab_count(&self) -> u32 {
self.crab_count
}
pub fn progress_key(&self) -> &str {
self.filing
.as_ref()
.map_or(self.name.as_str(), |filing| filing.key.as_str())
}
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, seat) in &self.solution {
if !board.place_signpost(seat, x, y, dir) {
return Err((x, y, dir, seat));
}
}
Ok(())
}
pub fn hands(&self) -> Option<[u8; 2]> {
self.board.rules().claw_hands
}
pub fn is_coop(&self) -> bool {
self.hands().is_some()
}
pub fn with_hands(&self, hands: Option<[u8; 2]>, posts: u8) -> Level {
let mut level = self.clone();
level.board.rules_mut().claw_hands = hands;
level.posts = hands.map_or(posts, |[left, right]| left.saturating_add(right));
if level.explicit_rule {
let policy = level.board.signpost_rule().1;
level.board.set_signpost_rule(level.posts, policy);
}
level.solution.clear();
level
}
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
.rules()
.round_length
.map_or(PUZZLE_TICK_LIMIT, u64::from)
}
pub fn outcome(&self, board: &Board) -> PuzzleOutcome {
let alive = (board.crabs().len() + board.sweeping().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().rules().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().rules().castle_raids);
let text = off.to_text();
assert!(text.contains("raids: off"), "{text}");
let back = Level::parse(&text).expect("round trip");
assert!(!back.board().rules().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().rules().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().rules().gull_cap, 14);
let back = Level::parse(&big.to_text()).expect("round trip");
assert_eq!(back.board().rules().gull_cap, 14);
assert!(
!plain.board().rules().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().rules().gull_turnover);
assert!(
!plain.board().rules().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().rules().timed_lock);
assert!(
!plain.board().rules().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().rules().sweep_flight);
assert!(
!plain.board().rules().whole_flock,
"off unless the file says so"
);
assert!(!plain.to_text().contains("whole_flock"));
let whole = Level::parse(&format!("{head}whole_flock: on\n{map}")).expect("level");
let back = Level::parse(&whole.to_text()).expect("round trip");
assert!(back.board().rules().whole_flock);
assert!(
!plain.board().rules().sweep_keeps_worth,
"off unless the file says so"
);
assert!(!plain.to_text().contains("sweep_keeps_worth"));
let keeping = Level::parse(&format!("{head}sweep_keeps_worth: on\n{map}")).expect("level");
let back = Level::parse(&keeping.to_text()).expect("round trip");
assert!(back.board().rules().sweep_keeps_worth);
for every in [1, 2] {
let err = Level::parse(&format!("{head}golden_every: {every}\n{map}"))
.expect_err("too short");
assert!(err.contains("golden_every"), "{err}");
}
let calling = Level::parse(&format!("{head}golden_every: 3\n{map}")).expect("level");
assert_eq!(calling.board().rules().golden_every, 3);
}
#[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().rules().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|
+-+-+-+
";
const DUO: &str = "\
name: Duo
hands: 1 1
crab: 0,0 R L common
crab: 0,1 R R common
solution: 2,0 D L; 1,1 U R
map:
+-+-+-+
|. . .|
+ + + +
|. . 0|
+-+-+-+
";
#[test]
fn a_coop_stage_survives_the_format() {
let level = Level::parse(DUO).expect("parses");
assert_eq!(level.hands(), Some([1, 1]));
assert_eq!(level.posts, 2);
assert_eq!(level.kind, LevelKind::Puzzle);
assert_eq!(
level.solution,
[(2, 0, Direction::Down, 0), (1, 1, Direction::Up, 1)]
);
assert_eq!(level.board().rules().claw_hands, Some([1, 1]));
let text = level.to_text();
assert!(text.contains("hands: 1 1"), "{text}");
assert!(text.contains("solution: 2,0 D L; 1,1 U R"), "{text}");
let again = Level::parse(&text).expect("own output");
assert_eq!(again.solution, level.solution);
assert_eq!(again.board().state_hash(), level.board().state_hash());
assert_eq!(again.to_text(), text);
}
#[test]
fn a_coop_stage_refuses_what_contradicts_it() {
let posts = DUO.replace("hands: 1 1", "posts: 3\nhands: 1 1");
assert!(Level::parse(&posts).unwrap_err().contains("two hands"));
let solo = DUO.replace("hands: 1 1\n", "posts: 2\n");
assert!(Level::parse(&solo).unwrap_err().contains("names a hand"));
let arena = DUO.replace("hands: 1 1", "hands: 1 1\nkind: arena");
assert!(Level::parse(&arena).unwrap_err().contains("no hands"));
assert!(Level::parse(&DUO.replace("hands: 1 1", "hands: 1")).is_err());
let bare = Level::parse(&DUO.replace("2,0 D L", "2,0 D")).expect("parses");
assert_eq!(bare.solution[0], (2, 0, Direction::Down, 0));
}
#[test]
fn a_coop_stage_deals_its_arrows_again() {
let level = Level::parse(DUO).expect("parses");
let alone = level.with_hands(Some([2, 0]), 0);
assert_eq!((alone.posts, alone.hands()), (2, Some([2, 0])));
let solo = level.with_hands(None, 2);
assert_eq!((solo.posts, solo.hands()), (2, None));
assert_eq!(solo.board().signpost_cap_of(0), 2);
assert!(solo.solution.is_empty(), "the deal's own answer is unknown");
}
#[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 a_crab_in_the_air_is_not_eaten() {
let level = Level::parse(
"name: S\nposts: 0\nrule: evict 3\nround: 900\ngoal: survive\n\
crab: 1,0 R L common\ncrab: 2,0 R L common\nmap:\n+-+-+-+-+-+-+\n\
|. . . . . 0|\n+-+-+-+-+-+-+\n",
)
.expect("parses");
let mut board = level.board();
board.set_events_enabled(true);
board.rules_mut().sweep_flight = true;
board.force_tide_event(crate::sim::TideEvent::Monopoly, 0);
assert!(!board.sweeping().is_empty(), "one is in the air");
assert_eq!(level.outcome(&board), PuzzleOutcome::Running);
}
#[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_table_plays_the_castles_it_has_seats_for() {
let beach = |row: &str| {
Level::parse(&format!(
"name: B\nposts: 1\nkind: arena\nmap:\n+-+-+-+-+\n|{row}|\n+-+-+-+-+\n"
))
.expect("parses")
};
let four = beach("0 1 2 3");
assert_eq!(four.table_seats(), Some(4));
assert!(four.fits(2) && four.fits(4) && !four.fits(5));
let board = four.table_board(9, 2);
assert_eq!(board.castle_owners().collect::<Vec<_>>(), vec![0, 1]);
assert_eq!(board.seats_in_play(), 2);
assert_eq!(board.tile_at(2, 0), TileKind::Empty);
assert_eq!(
board.state_hash(),
four.table_board(9, 2).state_hash(),
"built alike"
);
let gapped = beach("0 . 2 .");
assert_eq!(gapped.seats(), 2, "two owners, counted");
assert_eq!(gapped.table_seats(), None, "but seat 1 has no castle");
assert!(!gapped.fits(2) && !gapped.fits(1));
}
#[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);
}
#[test]
fn a_beach_on_a_fresh_seed_replays_from_what_it_records() {
let level = Level::parse(
"name: B\nposts: 3\nkind: arena\nseed: 1\ngull: 4,0 R\nmap:\n\
+-+-+-+-+-+-+-+-+-+\n|0 . . . . . . . 1|\n+-+-+-+-+-+-+-+-+-+\n",
)
.expect("parses");
let fresh = level.board_seeded(77);
assert_eq!(fresh.seed(), 77);
assert_ne!(
fresh.state_hash(),
level.board_seeded(78).state_hash(),
"another seed, another stream"
);
let recorded = Level::from_board("B", 3, fresh.clone()).to_text();
let rebuilt = Level::parse(&recorded).expect("reads back").board();
assert_eq!(rebuilt.state_hash(), fresh.state_hash());
}
}