use super::*;
const HEADER: &str = "snapshot-v1";
const MAX_TICK: u64 = u32::MAX as u64;
const MAX_COUNT: u32 = u32::MAX / 2;
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 self.rules.whole_flock {
let _ = writeln!(out, "whole_flock: on");
}
if self.rules.sweep_keeps_worth {
let _ = writeln!(out, "sweep_keeps_worth: on");
}
if self.rules.fair_spawns {
let _ = writeln!(out, "fair_spawns: 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 let Some([left, right]) = self.rules.claw_hands {
let _ = writeln!(out, "claw_hands: {left} {right}");
}
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());
}
for (seat, hand) in self.hands.iter().enumerate() {
if let Some((tile, at)) = hand {
let _ = writeln!(out, "hand: {seat} {tile} {at}");
}
}
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_fields(crab));
}
for flight in &self.sweeps.in_air {
let called = if flight.claw_call { " claws" } else { "" };
let _ = writeln!(
out,
"sweeping: {} {} {} {}{called}",
flight.owner,
flight.launch,
flight.lands,
crab_fields(&flight.crab),
);
}
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
);
}
for gull in &self.incoming {
let _ = writeln!(
out,
"incoming: {} {} {}",
gull.at,
gull.tile,
gull.dir.letter()
);
}
let on_the_sand = |gull: &u32| self.gulls.iter().any(|g| g.id == *gull);
for (gull, by) in self
.steering
.by_gull
.iter()
.filter(|(gull, _)| on_the_sand(gull))
{
let _ = writeln!(out, "steered: {gull} {by}");
}
for (by, row) in self.steering.taken.iter().enumerate() {
for (victim, &points) in row.iter().enumerate() {
if points > 0 {
let _ = writeln!(out, "stolen: {by} {victim} {points}");
}
}
}
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,
whole_flock: bool,
sweep_keeps_worth: bool,
fair_spawns: bool,
sweeping: Vec<Sweeping>,
golden_call: Option<(u64, u8, u8)>,
round_length: Option<u32>,
wrap: bool,
no_castle_raids: bool,
claw_hands: Option<[u8; 2]>,
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)>,
hands: [Option<(u16, u64)>; MAX_PLAYERS],
h_walls: Option<Vec<bool>>,
v_walls: Option<Vec<bool>>,
tiles: Option<Vec<TileKind>>,
posts: Vec<(usize, Signpost)>,
crabs: Vec<Crab>,
gulls: Vec<Gull>,
incoming: Vec<IncomingGull>,
steering: steering::Steering,
}
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 = golden_every(next_num(&mut words, "golden_every")?)?;
}
"timed_lock" => self.timed_lock = value == "on",
"sweep_flight" => self.sweep_flight = value == "on",
"whole_flock" => self.whole_flock = value == "on",
"sweep_keeps_worth" => self.sweep_keeps_worth = value == "on",
"fair_spawns" => self.fair_spawns = 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",
"claw_hands" => {
self.claw_hands = Some([
next_num(&mut words, "claw_hands left")?,
next_num(&mut words, "claw_hands right")?,
]);
}
"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")?));
}
"hand" => {
let seat = next_num::<usize>(&mut words, "hand seat")?;
let tile = next_num(&mut words, "hand tile")?;
let at = next_num(&mut words, "hand tick")?;
*self
.hands
.get_mut(seat)
.ok_or_else(|| format!("hand: no seat {seat}"))? = Some((tile, at));
}
"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)?;
let claw_call = match words.next() {
None => false,
Some("claws") => true,
Some(other) => return Err(format!("sweeping: bad call {other:?}")),
};
self.sweeping.push(Sweeping {
crab,
owner,
launch,
lands,
claw_call,
});
}
"gull" => self.gulls.push(parse_gull(&mut words)?),
"incoming" => self.incoming.push(IncomingGull {
at: next_num(&mut words, "incoming tick")?,
tile: next_num(&mut words, "incoming tile")?,
dir: next_dir(&mut words, "incoming direction")?,
}),
"steered" => {
let gull = next_num(&mut words, "steered gull")?;
let by: PlayerId = next_num(&mut words, "steered seat")?;
if seat(by).is_none() {
return Err(format!("steered: no seat {by}"));
}
if self.steering.steerer(gull).is_some() {
return Err(format!("steered: gull {gull} twice"));
}
if self.steering.by_gull.len() >= MAX_CREATURES {
return Err(format!("more than {MAX_CREATURES} steered gulls"));
}
self.steering.steer(gull, by);
}
"stolen" => {
let by: PlayerId = next_num(&mut words, "stolen seat")?;
let victim: PlayerId = next_num(&mut words, "stolen victim")?;
let (Some(by), Some(victim)) = (seat(by), seat(victim)) else {
return Err(format!("stolen: no seats {by} and {victim}"));
};
self.steering.taken[by][victim] = next_num(&mut words, "stolen points")?;
}
other => return Err(format!("unknown key {other:?}")),
}
if self.crabs.len() + self.gulls.len() + self.sweeping.len() + self.incoming.len()
> MAX_CREATURES
{
return Err(format!("more than {MAX_CREATURES} crabs and gulls"));
}
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 tick > MAX_TICK
|| signpost_seq > u64::from(MAX_COUNT)
|| next_crab_id > MAX_COUNT
|| next_gull_id > MAX_COUNT
|| crabs_banked > MAX_COUNT
{
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 mut last_at = tick;
for gull in &self.incoming {
let tile = usize::from(gull.tile);
if tile >= w * h || matches!(grid.tiles[tile], TileKind::Rock | TileKind::Kelp) {
return Err(format!("incoming: tile {tile} cannot take a gull"));
}
if gull.at < last_at || gull.at > tick + u64::from(GULL_LEAD) {
return Err(format!(
"incoming: tick {} is out of turn (now {tick})",
gull.at
));
}
last_at = gull.at;
}
for (tile, at) in self.hands.iter().flatten() {
if usize::from(*tile) >= w * h {
return Err(format!("hand: tile {tile} is off a {w}x{h} board"));
}
if *at > tick {
return Err(format!("hand: tick {at} is still to come ({tick})"));
}
}
for (gull, _) in &self.steering.by_gull {
if !self.gulls.iter().any(|g| g.id == *gull) {
return Err(format!("steered: no gull {gull}"));
}
}
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"));
}
if self.golden_every == 0 {
return Err("golden_call: this board calls no golden crabs".to_string());
}
if at < tick {
return Err(format!("golden_call: tick {at} is already past ({tick})"));
}
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,
whole_flock: self.whole_flock,
sweep_keeps_worth: self.sweep_keeps_worth,
fair_spawns: self.fair_spawns,
round_length: self.round_length,
castle_raids: !self.no_castle_raids,
claw_hands: self.claw_hands,
},
crabs: self.crabs,
scores,
rng: Pcg32::from_state(rng_state, rng_inc),
tick,
signpost_seq,
next_crab_id,
gulls: self.gulls,
next_gull_id,
incoming: self.incoming,
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(),
},
sweeps: Sweeps {
in_air: self.sweeping,
landed: Vec::new(),
},
refused_lures: Vec::new(),
walked_home: Vec::new(),
hands: self.hands,
steering: self.steering,
})
}
}
pub(crate) fn golden_every(ticks: u32) -> Result<u32, String> {
match ticks {
1..=2 => Err(format!(
"golden_every: {ticks} is too short, 0 (off) or at least 3"
)),
_ => Ok(ticks),
}
}
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 crab_fields(crab: &Crab) -> String {
format!(
"{} {} {} {} {} {} {} {} {}",
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()
)
}
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,
})
}
pub(crate) 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)
}
pub(crate) 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.rules_mut().castle_raids = false;
board.rules_mut().gull_cap = 9;
board.rules_mut().gull_turnover = true;
board.rules_mut().timed_lock = true;
board.rules_mut().sweep_flight = true;
board.rules_mut().whole_flock = true;
board.rules_mut().sweep_keeps_worth = true;
board.rules_mut().fair_spawns = true;
board.rules_mut().claw_hands = Some([2, 1]);
board.set_events_enabled(true);
board.rules_mut().round_length = Some(1234);
board.rules_mut().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.hands[1] = Some((6, 300));
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.sweeps.launch(Sweeping {
crab: Crab {
id: 5,
tile: 6,
dir: Direction::Left,
progress: 40,
prev: Pose {
tile: 6,
dir: Direction::Left,
progress: 20,
},
handed: Handedness::Left,
kind: CrabKind::Common,
},
owner: 1,
launch: 4330,
lands: 4360,
claw_call: true,
});
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",
"whole_flock: on",
"sweep_keeps_worth: on",
"fair_spawns: on",
" common claws",
] {
assert!(text.contains(expected), "missing {expected:?} in\n{text}");
}
let mut raiding = awkward_board();
raiding.rules_mut().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")
.rules()
.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:",
"whole_flock:",
"sweep_keeps_worth:",
"fair_spawns:",
"sweeping:",
"golden_call:",
"round:",
"wrap:",
"raids:",
"claw_hands:",
"events:",
"lure:",
"cooldown:",
"event_cooldown:",
"mania:",
"tempo:",
"last_event:",
"hand:",
"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 clock with no room for a call booked ahead",
bend(&good, "tick", &(MAX_TICK + 1).to_string()),
),
(
"a crab id with no room for a flood",
bend(&good, "counters", &format!("99 {} 3 12 2", MAX_COUNT + 1)),
),
(
"a bank count with no room for a flood",
bend(&good, "counters", &format!("99 7 3 {} 2", MAX_COUNT + 1)),
),
("a lure for nobody", format!("{good}\nlure: 200 5")),
("a hand from the future", bend(&good, "hand", "1 6 5000")),
("a hand off the board", bend(&good, "hand", "1 20 300")),
("a hand for nobody", bend(&good, "hand", "6 6 300")),
(
"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 a_snapshot_at_the_counters_bound_loads_and_plays() {
let good = awkward_board().to_snapshot();
let bent = bend(&good, "tick", &MAX_TICK.to_string());
let bent = bend(
&bent,
"counters",
&format!("99 {MAX_COUNT} {MAX_COUNT} {MAX_COUNT} 2"),
);
let mut board = Board::parse_snapshot(&bent).expect("at the bound parses");
for _ in 0..30 {
board.tick_idle();
}
}
#[test]
fn a_snapshot_with_a_crowd_past_the_cap_is_refused() {
let good = awkward_board().to_snapshot();
let crab = "crab: 4 8 D 133 3 200";
let line = good
.lines()
.find(|l| l.starts_with(crab))
.expect("the fixture's crab");
let have = good
.lines()
.filter(|l| {
["crab:", "gull:", "sweeping:"]
.iter()
.any(|k| l.starts_with(k))
})
.count();
let crowd = |n: usize| format!("{good}\n{}", format!("{line}\n").repeat(n));
let err = Board::parse_snapshot(&crowd(MAX_CREATURES + 1 - have)).expect_err("one over");
assert!(err.contains(&MAX_CREATURES.to_string()), "{err}");
}
#[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_golden_call_that_can_never_come_out_is_refused() {
let mut board = Board::new(9, 7, 11);
board.set_tile(1, 1, TileKind::Castle(0));
board.set_tile(
0,
3,
TileKind::Spawner(Spawner {
dir: Direction::Right,
period: 9,
}),
);
board.rules_mut().golden_every = 600;
for _ in 0..10 {
board.tick_idle();
}
let good = board.to_snapshot();
assert!(Board::parse_snapshot(&good).is_ok(), "the fixture parses");
assert!(good.contains("golden_call: "), "a call to bend:\n{good}");
let cases = [
(bend(&good, "golden_call", "9 0 3"), "already past"),
(
good.lines()
.filter(|line| !line.starts_with("golden_every:"))
.collect::<Vec<_>>()
.join("\n"),
"calls no golden crabs",
),
(bend(&good, "golden_every", "2"), "too short"),
(bend(&good, "golden_every", "1"), "too short"),
];
for (broken, why) in cases {
let err = Board::parse_snapshot(&broken).expect_err(why);
assert!(err.contains(why), "{why}: {err}");
}
let next = bend(&good, "golden_call", "10 0 3");
assert!(Board::parse_snapshot(&next).is_ok(), "{next}");
assert!(Board::parse_snapshot(&bend(&good, "golden_every", "3")).is_ok());
}
#[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}");
}
}
}