use super::*;
const HEADER: &str = "snapshot-v1";
impl Board {
pub fn to_snapshot(&self) -> String {
use std::fmt::Write;
let mut out = String::new();
let (rng_state, rng_inc) = self.rng.hash_state();
let _ = writeln!(out, "{HEADER}");
let _ = writeln!(out, "size: {} {}", self.grid.width, self.grid.height);
let _ = writeln!(out, "seed: {}", self.seed);
let _ = writeln!(out, "rng: {rng_state} {rng_inc}");
let _ = writeln!(out, "tick: {}", self.tick);
let _ = writeln!(
out,
"rule: {} {}",
self.rules.cap_policy.token(),
self.rules.signpost_cap
);
let _ = writeln!(
out,
"counters: {} {} {} {} {}",
self.signpost_seq,
self.next_crab_id,
self.next_gull_id,
self.crabs_banked,
self.golden_banked
);
let scores: Vec<String> = self.scores.iter().map(u32::to_string).collect();
let _ = writeln!(out, "scores: {}", scores.join(" "));
let _ = writeln!(out, "gull_period: {}", self.rules.gull_period);
if usize::from(self.rules.gull_cap) != GULL_CAP {
let _ = writeln!(out, "gull_cap: {}", self.rules.gull_cap);
}
if self.rules.gull_turnover {
let _ = writeln!(out, "gull_turnover: on");
}
if self.rules.golden_every > 0 {
let _ = writeln!(out, "golden_every: {}", self.rules.golden_every);
}
if self.rules.timed_lock {
let _ = writeln!(out, "timed_lock: on");
}
if self.rules.sweep_flight {
let _ = writeln!(out, "sweep_flight: on");
}
if let Some((at, tile)) = self.golden_call {
let (x, y) = self.coords_u8(tile);
let _ = writeln!(out, "golden_call: {at} {x} {y}");
}
if let Some(len) = self.rules.round_length {
let _ = writeln!(out, "round: {len}");
}
if self.wrap {
let _ = writeln!(out, "wrap: on");
}
if !self.rules.castle_raids {
let _ = writeln!(out, "raids: off");
}
if self.tide.enabled {
let _ = writeln!(out, "events: on");
}
if let Some((owner, ticks)) = self.lure {
let _ = writeln!(out, "lure: {owner} {ticks}");
}
if self.lure_cooldown > 0 {
let _ = writeln!(out, "cooldown: {}", self.lure_cooldown);
}
if self.tide.cooldown > 0 {
let _ = writeln!(out, "event_cooldown: {}", self.tide.cooldown);
}
if let Some((mania, ticks)) = self.tide.mania {
let name = match mania {
Mania::Crab => "crab",
Mania::Gull => "gull",
};
let _ = writeln!(out, "mania: {name} {ticks}");
}
if self.tide.claw_call > 0 {
let _ = writeln!(out, "claw_call: {}", self.tide.claw_call);
}
if let Some((tempo, ticks)) = self.tide.tempo {
let name = match tempo {
Tempo::Fast => "fast",
Tempo::Slow => "slow",
};
let _ = writeln!(out, "tempo: {name} {ticks}");
}
if let Some((event, at)) = self.tide.last {
let _ = writeln!(out, "last_event: {} {at}", event.index());
}
let _ = writeln!(out, "hwalls: {}", bits_to_hex(&self.grid.h_walls));
let _ = writeln!(out, "vwalls: {}", bits_to_hex(&self.grid.v_walls));
let tiles: Vec<String> = self
.grid
.tiles
.iter()
.map(|&kind| tile_token(kind))
.collect();
let _ = writeln!(out, "tiles: {}", tiles.join(" "));
for (tile, slot) in self.signposts.iter().enumerate() {
if let Some(post) = slot {
let health = match post.health {
SignpostHealth::Full => "full",
SignpostHealth::Worn => "worn",
};
let _ = writeln!(
out,
"post: {tile} {} {} {health} {} {}",
post.dir.letter(),
post.owner,
post.seq,
post.placed
);
}
}
for crab in &self.crabs {
let _ = writeln!(
out,
"crab: {} {} {} {} {} {} {} {} {}",
crab.id,
crab.tile,
crab.dir.letter(),
crab.progress,
crab.prev.tile,
crab.prev.progress,
crab.prev.dir.letter(),
crab.handed.token(),
crab.kind.token()
);
}
for flight in &self.sweeping {
let crab = &flight.crab;
let _ = writeln!(
out,
"sweeping: {} {} {} {} {} {} {} {} {} {} {} {}",
flight.owner,
flight.launch,
flight.lands,
crab.id,
crab.tile,
crab.dir.letter(),
crab.progress,
crab.prev.tile,
crab.prev.progress,
crab.prev.dir.letter(),
crab.handed.token(),
crab.kind.token()
);
}
for gull in &self.gulls {
let (state, remaining) = match gull.state {
GullState::Walking => ("walk", 0),
GullState::Flying { remaining } => ("fly", remaining),
};
let _ = writeln!(
out,
"gull: {} {} {} {} {} {} {} {} {state} {remaining} {}",
gull.id,
gull.tile,
gull.dir.letter(),
gull.progress,
gull.prev.tile,
gull.prev.progress,
gull.prev.dir.letter(),
gull.handed.token(),
gull.takeoff_in
);
}
out
}
pub fn parse_snapshot(text: &str) -> Result<Board, String> {
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
match lines.next() {
Some(HEADER) => {}
Some(other) => return Err(format!("not a snapshot: {other:?}")),
None => return Err("empty snapshot".to_string()),
}
let mut fields = Fields::default();
for line in lines {
let (key, value) = line
.split_once(':')
.ok_or_else(|| format!("no key in {line:?}"))?;
fields.read(key.trim(), value.trim())?;
}
fields.build()
}
}
#[derive(Default)]
struct Fields {
size: Option<(u8, u8)>,
seed: Option<u64>,
rng: Option<(u64, u64)>,
tick: Option<u64>,
rule: Option<(CapPolicy, u8)>,
counters: Option<(u64, u32, u32, u32, u32)>,
scores: Option<[u32; MAX_PLAYERS]>,
gull_period: Option<u32>,
gull_cap: Option<u8>,
gull_turnover: bool,
golden_every: u32,
timed_lock: bool,
sweep_flight: bool,
sweeping: Vec<Sweeping>,
golden_call: Option<(u64, u8, u8)>,
round_length: Option<u32>,
wrap: bool,
no_castle_raids: bool,
events_enabled: bool,
lure: Option<(PlayerId, u32)>,
lure_cooldown: u32,
event_cooldown: u32,
mania: Option<(Mania, u32)>,
tempo: Option<(Tempo, u32)>,
claw_call: u32,
last_event: Option<(TideEvent, u64)>,
h_walls: Option<Vec<bool>>,
v_walls: Option<Vec<bool>>,
tiles: Option<Vec<TileKind>>,
posts: Vec<(usize, Signpost)>,
crabs: Vec<Crab>,
gulls: Vec<Gull>,
}
impl Fields {
fn read(&mut self, key: &str, value: &str) -> Result<(), String> {
let mut words = value.split_whitespace();
match key {
"size" => {
let w = next_num::<u8>(&mut words, "size width")?;
let h = next_num::<u8>(&mut words, "size height")?;
if w == 0 || h == 0 {
return Err("a board is at least 1x1".to_string());
}
self.size = Some((w, h));
}
"seed" => self.seed = Some(next_num(&mut words, "seed")?),
"rng" => {
let state = next_num(&mut words, "rng state")?;
self.rng = Some((state, next_num(&mut words, "rng inc")?));
}
"tick" => self.tick = Some(next_num(&mut words, "tick")?),
"rule" => {
let token = words.next().ok_or("rule: missing policy")?;
let policy = CapPolicy::from_token(token)
.ok_or_else(|| format!("rule: bad policy {token:?}"))?;
let cap = next_num(&mut words, "rule cap")?;
if cap == 0 && policy == CapPolicy::Evict {
return Err("rule: evict needs a cap of at least 1".to_string());
}
self.rule = Some((policy, cap));
}
"counters" => {
self.counters = Some((
next_num(&mut words, "signpost_seq")?,
next_num(&mut words, "next_crab_id")?,
next_num(&mut words, "next_gull_id")?,
next_num(&mut words, "crabs_banked")?,
next_num(&mut words, "golden_banked")?,
));
}
"scores" => {
let mut seats = [0u32; MAX_PLAYERS];
for (seat, slot) in seats.iter_mut().enumerate() {
*slot = next_num(&mut words, "score")
.map_err(|e| format!("{e} for seat {seat}"))?;
}
self.scores = Some(seats);
}
"gull_period" => self.gull_period = Some(next_num(&mut words, "gull_period")?),
"gull_cap" => self.gull_cap = Some(next_num(&mut words, "gull_cap")?),
"gull_turnover" => self.gull_turnover = value == "on",
"golden_every" => self.golden_every = next_num(&mut words, "golden_every")?,
"timed_lock" => self.timed_lock = value == "on",
"sweep_flight" => self.sweep_flight = value == "on",
"golden_call" => {
self.golden_call = Some((
next_num(&mut words, "golden_call tick")?,
next_num(&mut words, "golden_call x")?,
next_num(&mut words, "golden_call y")?,
));
}
"round" => self.round_length = Some(next_num(&mut words, "round")?),
"wrap" => self.wrap = value == "on",
"raids" => self.no_castle_raids = value == "off",
"events" => self.events_enabled = value == "on",
"lure" => {
let owner = next_num::<PlayerId>(&mut words, "lure owner")?;
if seat(owner).is_none() {
return Err(format!("lure owner: no seat {owner}"));
}
self.lure = Some((owner, ticks_left(&mut words, "lure")?));
}
"cooldown" => self.lure_cooldown = next_num(&mut words, "cooldown")?,
"event_cooldown" => {
self.event_cooldown = next_num(&mut words, "event_cooldown")?;
}
"mania" => {
let which = words.next().ok_or("mania: missing kind")?;
let kind = match which {
"crab" => Mania::Crab,
"gull" => Mania::Gull,
other => return Err(format!("mania: bad kind {other:?}")),
};
self.mania = Some((kind, ticks_left(&mut words, "mania")?));
}
"tempo" => {
let which = words.next().ok_or("tempo: missing speed")?;
let shift = match which {
"fast" => Tempo::Fast,
"slow" => Tempo::Slow,
other => return Err(format!("tempo: bad speed {other:?}")),
};
self.tempo = Some((shift, ticks_left(&mut words, "tempo")?));
}
"claw_call" => self.claw_call = next_num::<u32>(&mut words, "claw_call")?,
"last_event" => {
let index = next_num::<usize>(&mut words, "last_event index")?;
let event = *TideEvent::ALL
.get(index)
.ok_or_else(|| format!("last_event: no event {index}"))?;
self.last_event = Some((event, next_num(&mut words, "last_event tick")?));
}
"hwalls" => self.h_walls = Some(hex_to_bits(value)?),
"vwalls" => self.v_walls = Some(hex_to_bits(value)?),
"tiles" => {
self.tiles = Some(
value
.split_whitespace()
.map(tile_from_token)
.collect::<Result<_, _>>()?,
);
}
"post" => self.posts.push(parse_post(&mut words)?),
"crab" => self.crabs.push(parse_crab(&mut words)?),
"sweeping" => {
let owner: PlayerId = next_num(&mut words, "sweeping owner")?;
if usize::from(owner) >= MAX_PLAYERS {
return Err(format!("sweeping: no seat {owner}"));
}
let launch = next_num(&mut words, "sweeping launch")?;
let lands = next_num(&mut words, "sweeping lands")?;
if lands < launch {
return Err(format!(
"sweeping: lands at {lands}, before it leaves at {launch}"
));
}
let crab = parse_crab(&mut words)?;
self.sweeping.push(Sweeping {
crab,
owner,
launch,
lands,
});
}
"gull" => self.gulls.push(parse_gull(&mut words)?),
other => return Err(format!("unknown key {other:?}")),
}
Ok(())
}
fn build(self) -> Result<Board, String> {
let (width, height) = self.size.ok_or("snapshot has no size")?;
let (rng_state, rng_inc) = self.rng.ok_or("snapshot has no rng")?;
let (cap_policy, signpost_cap) = self.rule.ok_or("snapshot has no rule")?;
let (signpost_seq, next_crab_id, next_gull_id, crabs_banked, golden_banked) =
self.counters.ok_or("snapshot has no counters")?;
let seed = self.seed.ok_or("snapshot has no seed")?;
let tick = self.tick.ok_or("snapshot has no tick")?;
let scores = self.scores.ok_or("snapshot has no scores")?;
let gull_period = self.gull_period.ok_or("snapshot has no gull_period")?;
let (w, h) = (width as usize, height as usize);
let tiles = self.tiles.ok_or("snapshot has no tiles")?;
if tiles.len() != w * h {
return Err(format!("{} tiles for a {w}x{h} board", tiles.len()));
}
let h_walls = sized(
self.h_walls.ok_or("snapshot has no hwalls")?,
(h + 1) * w,
"hwalls",
)?;
let v_walls = sized(
self.v_walls.ok_or("snapshot has no vwalls")?,
h * (w + 1),
"vwalls",
)?;
let grid = Grid {
width,
height,
h_walls,
v_walls,
tiles,
};
if self.wrap && grid.rim_walled() {
return Err("a wall on the rim of an open beach".to_string());
}
if golden_banked > crabs_banked {
return Err(format!(
"{golden_banked} golden crabs banked of {crabs_banked} crabs"
));
}
if signpost_seq == u64::MAX
|| next_crab_id == u32::MAX
|| next_gull_id == u32::MAX
|| crabs_banked == u32::MAX
|| tick == u64::MAX
{
return Err("counters: no room left to count on".to_string());
}
let mut signposts = vec![None; w * h];
let mut held = [0usize; MAX_PLAYERS];
for (tile, post) in self.posts {
let slot = signposts
.get_mut(tile)
.ok_or_else(|| format!("post: tile {tile} is off a {w}x{h} board"))?;
if grid.tiles[tile] != TileKind::Empty {
return Err(format!(
"post: tile {tile} is a {:?}, not sand",
grid.tiles[tile]
));
}
if post.seq >= signpost_seq {
return Err(format!(
"post: seq {} is not behind the counter at {signpost_seq}",
post.seq
));
}
held[usize::from(post.owner)] += 1;
if held[usize::from(post.owner)] > usize::from(signpost_cap) {
return Err(format!(
"post: seat {} holds more than the cap of {signpost_cap}",
post.owner
));
}
*slot = Some(post);
}
let poses = |who: &str, tile: u16, progress: u16, prev: &Pose| {
for (what, tile, progress) in [
("tile", tile, progress),
("prev_tile", prev.tile, prev.progress),
] {
if usize::from(tile) >= w * h {
return Err(format!("{who} {what} {tile}, off the board"));
}
if progress >= SUBUNITS_PER_TILE {
return Err(format!("{who} progress {progress}, past a whole tile"));
}
}
Ok(())
};
for crab in self
.crabs
.iter()
.chain(self.sweeping.iter().map(|f| &f.crab))
{
poses("crab", crab.tile, crab.progress, &crab.prev)?;
}
for gull in &self.gulls {
poses("gull", gull.tile, gull.progress, &gull.prev)?;
}
let golden_call = match self.golden_call {
None => None,
Some((at, x, y)) => {
if x >= width || y >= height {
return Err(format!("golden_call: ({x},{y}) is off the beach"));
}
let tile = usize::from(y) * w + usize::from(x);
if !matches!(grid.tiles[tile], TileKind::Spawner(_)) {
return Err(format!("golden_call: ({x},{y}) is not a spawner"));
}
Some((at, tile as u16))
}
};
Ok(Board {
grid,
seed,
signposts,
rules: Rules {
signpost_cap,
cap_policy,
gull_period,
gull_cap: self.gull_cap.unwrap_or(GULL_CAP as u8),
gull_turnover: self.gull_turnover,
golden_every: self.golden_every,
timed_lock: self.timed_lock,
sweep_flight: self.sweep_flight,
round_length: self.round_length,
castle_raids: !self.no_castle_raids,
},
crabs: self.crabs,
scores,
rng: Pcg32::from_state(rng_state, rng_inc),
tick,
signpost_seq,
next_crab_id,
gulls: self.gulls,
next_gull_id,
lure: self.lure,
lure_cooldown: self.lure_cooldown,
crabs_banked,
golden_banked,
golden_call,
wrap: self.wrap,
tide: events::Tide {
enabled: self.events_enabled,
cooldown: self.event_cooldown,
mania: self.mania,
tempo: self.tempo,
claw_call: self.claw_call,
last: self.last_event,
spun_by: None,
queue: Vec::new(),
},
swept_home: Vec::new(),
sweeping: self.sweeping,
})
}
}
fn sized(bits: Vec<bool>, want: usize, what: &str) -> Result<Vec<bool>, String> {
if bits.len() < want || bits.len() >= want + 4 {
return Err(format!("{what}: {} bits, wanted {want}", bits.len()));
}
let mut bits = bits;
bits.truncate(want);
Ok(bits)
}
fn next_num<T: std::str::FromStr>(
words: &mut std::str::SplitWhitespace,
what: &str,
) -> Result<T, String> {
let word = words.next().ok_or_else(|| format!("{what}: missing"))?;
word.parse()
.map_err(|_| format!("{what}: bad number {word:?}"))
}
fn ticks_left(words: &mut std::str::SplitWhitespace, what: &str) -> Result<u32, String> {
match next_num(words, &format!("{what} ticks"))? {
0 => Err(format!("{what}: a running one has at least a tick left")),
ticks => Ok(ticks),
}
}
fn next_dir(words: &mut std::str::SplitWhitespace, what: &str) -> Result<Direction, String> {
let word = words.next().ok_or_else(|| format!("{what}: missing"))?;
Direction::from_letter(word).ok_or_else(|| format!("{what}: bad direction {word:?}"))
}
fn parse_post(words: &mut std::str::SplitWhitespace) -> Result<(usize, Signpost), String> {
let tile = next_num::<usize>(words, "post tile")?;
let dir = next_dir(words, "post direction")?;
let owner = next_num::<PlayerId>(words, "post owner")?;
if seat(owner).is_none() {
return Err(format!("post owner: no seat {owner}"));
}
let word = words.next().ok_or("post health: missing")?;
let health = match word {
"full" => SignpostHealth::Full,
"worn" => SignpostHealth::Worn,
other => return Err(format!("post: bad health {other:?}")),
};
let seq = next_num(words, "post seq")?;
let placed = next_num(words, "post placed")?;
Ok((
tile,
Signpost {
dir,
owner,
health,
seq,
placed,
},
))
}
fn parse_pose(words: &mut std::str::SplitWhitespace, who: &str) -> Result<Pose, String> {
let tile = next_num(words, &format!("{who} prev_tile"))?;
let progress = next_num(words, &format!("{who} prev_progress"))?;
let dir = next_dir(words, &format!("{who} prev_dir"))?;
Ok(Pose {
tile,
dir,
progress,
})
}
fn parse_crab(words: &mut std::str::SplitWhitespace) -> Result<Crab, String> {
let id = next_num(words, "crab id")?;
let tile = next_num(words, "crab tile")?;
let dir = next_dir(words, "crab direction")?;
let progress = next_num(words, "crab progress")?;
let prev = parse_pose(words, "crab")?;
let word = words.next().ok_or("crab handedness: missing")?;
let handed =
Handedness::from_token(word).ok_or_else(|| format!("crab: bad handedness {word:?}"))?;
let word = words.next().ok_or("crab kind: missing")?;
let kind = CrabKind::from_token(word).ok_or_else(|| format!("crab: bad kind {word:?}"))?;
Ok(Crab {
id,
tile,
dir,
progress,
prev,
handed,
kind,
})
}
fn parse_gull(words: &mut std::str::SplitWhitespace) -> Result<Gull, String> {
let id = next_num(words, "gull id")?;
let tile = next_num(words, "gull tile")?;
let dir = next_dir(words, "gull direction")?;
let progress = next_num(words, "gull progress")?;
let prev = parse_pose(words, "gull")?;
let word = words.next().ok_or("gull handedness: missing")?;
let handed =
Handedness::from_token(word).ok_or_else(|| format!("gull: bad handedness {word:?}"))?;
let word = words.next().ok_or("gull state: missing")?;
let remaining = next_num(words, "gull flight remaining")?;
let state = match word {
"walk" => GullState::Walking,
"fly" => GullState::Flying { remaining },
other => return Err(format!("gull: bad state {other:?}")),
};
let takeoff_in = next_num(words, "gull takeoff_in")?;
Ok(Gull {
id,
tile,
dir,
progress,
prev,
handed,
state,
takeoff_in,
})
}
fn bits_to_hex(bits: &[bool]) -> String {
bits.chunks(4)
.map(|chunk| {
let nibble = chunk
.iter()
.enumerate()
.fold(0u32, |acc, (i, &bit)| acc | (u32::from(bit) << i));
char::from_digit(nibble, 16).expect("a nibble is one hex digit")
})
.collect()
}
fn hex_to_bits(text: &str) -> Result<Vec<bool>, String> {
let mut bits = Vec::with_capacity(text.len() * 4);
for ch in text.chars() {
let nibble = ch
.to_digit(16)
.ok_or_else(|| format!("walls: {ch:?} is not hex"))?;
bits.extend((0..4).map(|i| nibble & (1 << i) != 0));
}
Ok(bits)
}
fn tile_token(kind: TileKind) -> String {
match kind {
TileKind::Empty => ".".to_string(),
TileKind::Rock => "#".to_string(),
TileKind::Castle(owner) => format!("c{owner}"),
TileKind::Spawner(s) => format!("s{}{}", s.dir.letter(), s.period),
TileKind::Turnstile { next_right: true } => "T".to_string(),
TileKind::Turnstile { next_right: false } => "t".to_string(),
TileKind::Kelp => "K".to_string(),
TileKind::Pool => "~".to_string(),
}
}
fn tile_from_token(token: &str) -> Result<TileKind, String> {
match token {
"." => Ok(TileKind::Empty),
"#" => Ok(TileKind::Rock),
"T" => Ok(TileKind::Turnstile { next_right: true }),
"t" => Ok(TileKind::Turnstile { next_right: false }),
"K" => Ok(TileKind::Kelp),
"~" => Ok(TileKind::Pool),
_ => {
let (tag, rest) = token.split_at_checked(1).ok_or("empty tile token")?;
match tag {
"c" => {
let owner: PlayerId = rest
.parse()
.map_err(|_| format!("tile: bad castle owner in {token:?}"))?;
if seat(owner).is_none() {
return Err(format!("tile: no seat {owner}"));
}
Ok(TileKind::Castle(owner))
}
"s" => {
let (letter, period) = rest
.split_at_checked(1)
.ok_or_else(|| format!("tile: bad spawner {token:?}"))?;
let dir = Direction::from_letter(letter)
.ok_or_else(|| format!("tile: bad spawner direction in {token:?}"))?;
let period: u32 = period
.parse()
.map_err(|_| format!("tile: bad spawner period in {token:?}"))?;
if period == 0 {
return Err("tile: a spawner period is at least 1 tick".to_string());
}
Ok(TileKind::Spawner(Spawner { dir, period }))
}
_ => Err(format!("tile: unknown token {token:?}")),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::crab::{CrabKind, Handedness};
fn awkward_board() -> Board {
let mut board = Board::new(5, 4, 0xABCD);
board.set_tile(0, 0, TileKind::Castle(0));
board.set_tile(4, 3, TileKind::Castle(1));
board.set_tile(2, 1, TileKind::Rock);
board.set_tile(1, 2, TileKind::Kelp);
board.set_tile(3, 2, TileKind::Pool);
board.set_tile(2, 2, TileKind::Turnstile { next_right: false });
board.set_tile(
0,
2,
TileKind::Spawner(Spawner {
dir: Direction::Right,
period: 37,
}),
);
board.set_wrap(true);
board.set_wall(1, 1, Direction::Up, true);
board.set_castle_raids(false);
board.set_gull_cap(9);
board.set_gull_turnover(true);
board.set_timed_lock(true);
board.set_sweep_flight(true);
board.set_events_enabled(true);
board.set_round_length(Some(1234));
board.set_gull_period(97);
board.set_signpost_rule(2, CapPolicy::Reject);
board.set_score(0, 17);
board.set_score(3, 4);
board.rng = Pcg32::from_state(0x1234_5678_9ABC_DEF0, 0x0FED_CBA9_8765_4321);
board.tick = 4321;
board.signpost_seq = 99;
board.next_crab_id = 7;
board.next_gull_id = 3;
board.crabs_banked = 12;
board.golden_banked = 2;
board.lure = Some((1, 145));
board.lure_cooldown = 60;
board.tide.cooldown = 210;
board.tide.mania = Some((Mania::Gull, 88));
board.tide.tempo = Some((Tempo::Slow, 44));
board.tide.last = Some((TideEvent::FreshSand, 3000));
board.signposts[6] = Some(Signpost {
dir: Direction::Left,
owner: 1,
health: SignpostHealth::Worn,
seq: 5,
placed: 300,
});
board.crabs.push(Crab {
id: 4,
tile: 8,
dir: Direction::Down,
progress: 133,
prev: Pose {
tile: 3,
dir: Direction::Right,
progress: 200,
},
handed: Handedness::Right,
kind: CrabKind::Golden,
});
board.gulls.push(Gull {
id: 2,
tile: 9,
dir: Direction::Up,
progress: 77,
prev: Pose {
tile: 14,
dir: Direction::Left,
progress: 12,
},
handed: Handedness::Left,
state: GullState::Flying { remaining: 3 },
takeoff_in: 222,
});
board
}
#[test]
fn a_board_with_everything_set_survives_a_snapshot() {
let board = awkward_board();
let text = board.to_snapshot();
let back = Board::parse_snapshot(&text).expect("its own output");
assert_eq!(
back.state_hash(),
board.state_hash(),
"the snapshot came back a different board:\n{text}"
);
for expected in [
"lure: 1 145",
"cooldown: 60",
"mania: gull 88",
"tempo: slow 44",
"last_event: 6 3000",
"wrap: on",
"raids: off",
"events: on",
"rule: reject 2",
"gull_cap: 9",
"gull_turnover: on",
"timed_lock: on",
"sweep_flight: on",
] {
assert!(text.contains(expected), "missing {expected:?} in\n{text}");
}
let mut raiding = awkward_board();
raiding.set_castle_raids(true);
let text = raiding.to_snapshot();
assert!(
!text.contains("raids:"),
"a default is not written:\n{text}"
);
assert!(
Board::parse_snapshot(&text)
.expect("its own output")
.castle_raids(),
"an absent raids line must read as raids on:\n{text}"
);
assert!(text.contains(" worn "), "the worn signpost:\n{text}");
assert!(text.contains(" fly 3 "), "the gull mid-flight:\n{text}");
}
#[test]
fn what_is_not_a_snapshot_is_refused() {
let good = awkward_board().to_snapshot();
assert!(Board::parse_snapshot("").is_err(), "empty");
assert!(Board::parse_snapshot("hello").is_err(), "not a snapshot");
assert!(
Board::parse_snapshot(&good.replace(HEADER, "snapshot-v2")).is_err(),
"another version"
);
assert!(
Board::parse_snapshot(&good.replace("tiles:", "tyles:")).is_err(),
"a key this build does not know"
);
assert!(
Board::parse_snapshot(&good.replace("size: 5 4", "size: 6 4")).is_err(),
"a tile count that does not fit the size"
);
assert!(good.contains("rule: reject 2"), "{good}");
assert!(
Board::parse_snapshot(&good.replace("rule: reject 2", "rule: evict 0")).is_err(),
"evict with nothing to evict"
);
let posted = good
.lines()
.find(|l| l.starts_with("post:"))
.expect("a post");
let orphan = posted.replace(" 1 ", " 9 ");
assert_ne!(posted, orphan, "{posted}");
assert!(Board::parse_snapshot(&good.replace(posted, &orphan)).is_err());
let walls = good
.lines()
.find(|l| l.starts_with("hwalls:"))
.expect("walls");
assert!(Board::parse_snapshot(&good.replace(walls, &format!("{walls}f"))).is_err());
for line in ["lure: 1 0", "mania: gull 0", "tempo: slow 0"] {
let key = line.split(':').next().unwrap();
let zeroed: String = good
.lines()
.map(|l| if l.starts_with(key) { line } else { l })
.collect::<Vec<_>>()
.join("\n");
assert!(zeroed.contains(line), "{key} is set on the awkward board");
assert!(
Board::parse_snapshot(&zeroed).is_err(),
"{line} should be refused"
);
}
let lines: Vec<&str> = good.lines().collect();
for drop in 1..lines.len() {
let mut kept = lines.clone();
let line = kept.remove(drop);
let optional = [
"gull_cap:",
"gull_turnover:",
"golden_every:",
"timed_lock:",
"sweep_flight:",
"sweeping:",
"golden_call:",
"round:",
"wrap:",
"raids:",
"events:",
"lure:",
"cooldown:",
"event_cooldown:",
"mania:",
"tempo:",
"last_event:",
"post:",
"crab:",
"gull:",
];
if optional
.iter()
.any(|key| line.trim_start().starts_with(key))
{
continue;
}
assert!(
Board::parse_snapshot(&kept.join("\n")).is_err(),
"dropping {line:?} should not still parse"
);
}
}
fn bend(text: &str, key: &str, value: &str) -> String {
text.lines()
.map(|line| match line.starts_with(&format!("{key}:")) {
true => format!("{key}: {value}"),
false => line.to_string(),
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn a_creature_standing_off_the_board_is_refused() {
let good = awkward_board().to_snapshot();
for (what, from, to) in [
("crab", "crab: 4 8 ", "crab: 4 20 "),
("gull", "gull: 2 9 ", "gull: 2 20 "),
] {
assert!(
good.contains(from),
"{what} is not where the fixture puts it:\n{good}"
);
let refused = Board::parse_snapshot(&good.replace(from, to))
.expect_err("took a creature off the board");
assert!(
refused.contains("off the board"),
"{what}: complained about something else: {refused}"
);
}
}
#[test]
fn a_snapshot_no_tick_could_leave_is_refused() {
let good = awkward_board().to_snapshot();
assert!(Board::parse_snapshot(&good).is_ok(), "the fixture parses");
let post = "post: 6 L 1 worn 5 300";
assert!(good.contains(post), "the fixture's post moved:\n{good}");
let crab = "crab: 4 8 D 133 3 200";
assert!(good.contains(crab), "the fixture's crab moved:\n{good}");
let cases = [
(
"a post on a rock",
good.replace(post, "post: 7 L 1 worn 5 300"),
),
(
"a post from the future",
good.replace(post, "post: 6 L 1 worn 99 300"),
),
(
"posts over the cap",
good.replace(
post,
&format!("{post}\npost: 1 U 1 full 6 300\npost: 2 U 1 full 7 300"),
),
),
(
"more gold than crabs",
bend(&good, "counters", "99 7 3 1 2"),
),
(
"a crab id with no room",
bend(&good, "counters", "99 4294967295 3 12 2"),
),
(
"a gull id with no room",
bend(&good, "counters", "99 7 4294967295 12 2"),
),
(
"a post count with no room",
bend(&good, "counters", "18446744073709551615 7 3 12 2"),
),
(
"a bank count with no room",
bend(&good, "counters", "99 7 3 4294967295 2"),
),
(
"a clock with no room",
bend(&good, "tick", "18446744073709551615"),
),
("a lure for nobody", format!("{good}\nlure: 200 5")),
(
"a crab past its tile",
good.replace(crab, "crab: 4 8 D 256 3 200"),
),
(
"a crab past its last tile",
good.replace(crab, "crab: 4 8 D 133 3 65535"),
),
(
"a crab from off the board",
good.replace(crab, "crab: 4 8 D 133 20 200"),
),
];
for (what, bent) in cases {
assert_ne!(bent, good, "{what}: nothing was bent");
assert!(
Board::parse_snapshot(&bent).is_err(),
"{what}: parsed anyway"
);
}
}
#[test]
fn an_open_beach_has_an_open_rim_whichever_way_it_is_built() {
let mut board = Board::new(5, 4, 1);
board.set_wrap(true);
board.set_wall(0, 0, Direction::Up, true);
board.set_wall(4, 2, Direction::Right, true);
assert!(!board.wall_at(0, 0, Direction::Up));
assert!(!board.wall_at(4, 2, Direction::Right));
assert!(board.on_open_rim(0, 0, Direction::Up));
assert!(!board.on_open_rim(1, 1, Direction::Up), "inside is a wall");
board.set_wall(1, 1, Direction::Up, true);
let back = Board::parse_snapshot(&board.to_snapshot()).expect("parses");
assert!(back.wall_at(1, 1, Direction::Up));
let level = crate::sim::Level::from_board("Open", 3, board.clone());
let read = crate::sim::Level::parse(&level.to_text())
.expect("parses")
.board();
assert!(
read.wrap() && read.wall_at(1, 1, Direction::Up),
"the level agrees"
);
assert!(!read.wall_at(0, 0, Direction::Up));
let mut walled = board.clone();
walled.grid.h_walls[0] = true;
assert!(Board::parse_snapshot(&walled.to_snapshot()).is_err());
board.set_wrap(false);
assert!(board.wall_at(0, 0, Direction::Up));
assert!(!board.on_open_rim(0, 0, Direction::Up));
}
#[test]
fn a_number_that_is_not_one_is_refused_rather_than_defaulted() {
let good = awkward_board().to_snapshot();
for (key, junk) in [
("seed", "soon"),
("tick", "-1"),
("gull_period", "lots"),
("size", "5 wide"),
("rng", "1"),
("counters", "99 7 3 12"),
("scores", "17 0 0 x 0 0"),
] {
let bent = bend(&good, key, junk);
assert_ne!(bent, good, "{key}: no such line to bend");
assert!(
Board::parse_snapshot(&bent).is_err(),
"{key}: took {junk:?} for a number"
);
}
}
#[test]
fn a_wall_lattice_survives_the_trip_through_hex() {
for len in [0usize, 1, 3, 4, 5, 8, 17, 64] {
let bits: Vec<bool> = (0..len).map(|i| i % 3 == 0).collect();
let hex = bits_to_hex(&bits);
let back = hex_to_bits(&hex).expect("its own hex reads back");
assert!(back.len() >= len, "{len} bits came back as {}", back.len());
assert_eq!(back[..len], bits[..], "{len} bits came back changed");
assert!(
back[len..].iter().all(|bit| !bit),
"{len} bits padded with something other than sand: {hex}"
);
}
assert!(hex_to_bits("nothex").is_err(), "took letters for digits");
}
#[test]
fn every_tile_kind_survives_its_own_token() {
let mut kinds = vec![
TileKind::Empty,
TileKind::Rock,
TileKind::Kelp,
TileKind::Pool,
TileKind::Turnstile { next_right: true },
TileKind::Turnstile { next_right: false },
];
for owner in 0..MAX_PLAYERS as u8 {
kinds.push(TileKind::Castle(owner));
}
let mut seen: Vec<String> = Vec::new();
for kind in kinds {
let token = tile_token(kind);
assert_eq!(
tile_from_token(&token).expect("its own token reads back"),
kind,
"{kind:?} came back as something else"
);
assert!(
!seen.contains(&token),
"{kind:?} shares the token {token:?} with something else"
);
seen.push(token);
}
assert!(
tile_from_token("~~~").is_err(),
"took a token nobody writes"
);
assert!(tile_from_token("").is_err(), "took an empty token");
}
fn stirred() -> Board {
use crate::sim::{
BotLevel, MAX_PLAYERS, PlayerAction, TideEvent, bot_action, generate_arena,
};
let mut board = generate_arena(3, 2, 8, 6);
for _ in 0..200 {
let actions: [PlayerAction; MAX_PLAYERS] =
std::array::from_fn(|seat| bot_action(&board, seat as u8, BotLevel::Hard));
board.tick(&actions);
}
board.force_tide_event(TideEvent::CrabMania, 0);
board.force_tide_event(TideEvent::RightClaws, 0);
board.force_tide_event(TideEvent::SpeedUp, 0);
board
}
#[test]
fn live_tide_events_survive_a_snapshot() {
let board = stirred();
let text = board.to_snapshot();
for line in ["mania: crab", "claw_call:", "tempo: fast"] {
assert!(text.contains(line), "{line} is written: {text}");
}
let back = Board::parse_snapshot(&text).expect("reads back");
assert_eq!(back.state_hash(), board.state_hash());
assert_eq!(back.to_snapshot(), text);
}
#[test]
fn a_field_that_cannot_mean_anything_is_refused_by_name() {
let text = stirred().to_snapshot();
let first_post = text
.lines()
.find(|line| line.starts_with("post: "))
.expect("posts on the beach")
.to_string();
let cases: [(String, &str); 7] = [
(text.replacen("size: 9 7", "size: 0 7", 1), "at least 1x1"),
(text.replacen("mania: crab", "mania: sand", 1), "bad kind"),
(text.replacen("tempo: fast", "tempo: warp", 1), "bad speed"),
(
text.lines()
.filter(|line| !line.starts_with("vwalls:"))
.collect::<Vec<_>>()
.join("\n"),
"no vwalls",
),
(
text.replacen(&first_post, &first_post.replacen(" full ", " torn ", 1), 1),
"bad health",
),
(text.replacen(" c0 ", " c9 ", 1), "no seat"),
(text.replacen("sD94", "sD0", 1), "at least"),
];
for (broken, why) in cases {
assert_ne!(broken, text, "the case changed something ({why})");
let err = Board::parse_snapshot(&broken).expect_err(why);
assert!(err.contains(why), "{why}: {err}");
}
}
}