1use super::*;
19
20const HEADER: &str = "snapshot-v1";
23
24impl Board {
25 pub fn to_snapshot(&self) -> String {
27 use std::fmt::Write;
28 let mut out = String::new();
29 let (rng_state, rng_inc) = self.rng.hash_state();
30 let _ = writeln!(out, "{HEADER}");
31 let _ = writeln!(out, "size: {} {}", self.width, self.height);
32 let _ = writeln!(out, "seed: {}", self.seed);
33 let _ = writeln!(out, "rng: {rng_state} {rng_inc}");
34 let _ = writeln!(out, "tick: {}", self.tick);
35 let _ = writeln!(
36 out,
37 "rule: {} {}",
38 self.cap_policy.token(),
39 self.signpost_cap
40 );
41 let _ = writeln!(
42 out,
43 "counters: {} {} {} {} {}",
44 self.signpost_seq,
45 self.next_crab_id,
46 self.next_gull_id,
47 self.crabs_banked,
48 self.golden_banked
49 );
50 let scores: Vec<String> = self.scores.iter().map(u32::to_string).collect();
51 let _ = writeln!(out, "scores: {}", scores.join(" "));
52 let _ = writeln!(out, "gull_period: {}", self.gull_period);
53 if let Some(len) = self.round_length {
56 let _ = writeln!(out, "round: {len}");
57 }
58 if self.wrap {
59 let _ = writeln!(out, "wrap: on");
60 }
61 if self.events_enabled {
62 let _ = writeln!(out, "events: on");
63 }
64 if let Some((owner, ticks)) = self.lure {
65 let _ = writeln!(out, "lure: {owner} {ticks}");
66 }
67 if self.lure_cooldown > 0 {
68 let _ = writeln!(out, "cooldown: {}", self.lure_cooldown);
69 }
70 if let Some((mania, ticks)) = self.mania {
71 let name = match mania {
72 Mania::Crab => "crab",
73 Mania::Gull => "gull",
74 };
75 let _ = writeln!(out, "mania: {name} {ticks}");
76 }
77 if let Some((tempo, ticks)) = self.tempo {
78 let name = match tempo {
79 Tempo::Fast => "fast",
80 Tempo::Slow => "slow",
81 };
82 let _ = writeln!(out, "tempo: {name} {ticks}");
83 }
84 if let Some((event, at)) = self.last_event {
85 let _ = writeln!(out, "last_event: {} {at}", event.index());
86 }
87 let _ = writeln!(out, "hwalls: {}", bits_to_hex(&self.h_walls));
88 let _ = writeln!(out, "vwalls: {}", bits_to_hex(&self.v_walls));
89 let tiles: Vec<String> = self.tiles.iter().map(|&kind| tile_token(kind)).collect();
90 let _ = writeln!(out, "tiles: {}", tiles.join(" "));
91 for (tile, slot) in self.signposts.iter().enumerate() {
92 if let Some(post) = slot {
93 let health = match post.health {
94 SignpostHealth::Full => "full",
95 SignpostHealth::Worn => "worn",
96 };
97 let _ = writeln!(
98 out,
99 "post: {tile} {} {} {health} {} {}",
100 post.dir.letter(),
101 post.owner,
102 post.seq,
103 post.placed
104 );
105 }
106 }
107 for crab in &self.crabs {
108 let _ = writeln!(
109 out,
110 "crab: {} {} {} {} {} {} {} {} {}",
111 crab.id,
112 crab.tile,
113 crab.dir.letter(),
114 crab.progress,
115 crab.prev_tile,
116 crab.prev_progress,
117 crab.prev_dir.letter(),
118 crab.handed.token(),
119 crab.kind.token()
120 );
121 }
122 for gull in &self.gulls {
123 let (state, remaining) = match gull.state {
126 GullState::Walking => ("walk", 0),
127 GullState::Flying { remaining } => ("fly", remaining),
128 };
129 let _ = writeln!(
130 out,
131 "gull: {} {} {} {} {} {} {} {} {state} {remaining} {}",
132 gull.id,
133 gull.tile,
134 gull.dir.letter(),
135 gull.progress,
136 gull.prev_tile,
137 gull.prev_progress,
138 gull.prev_dir.letter(),
139 gull.handed.token(),
140 gull.takeoff_in
141 );
142 }
143 out
144 }
145
146 pub fn parse_snapshot(text: &str) -> Result<Board, String> {
152 let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
153 match lines.next() {
154 Some(HEADER) => {}
155 Some(other) => return Err(format!("not a snapshot: {other:?}")),
156 None => return Err("empty snapshot".to_string()),
157 }
158 let mut fields = Fields::default();
159 for line in lines {
160 let (key, value) = line
161 .split_once(':')
162 .ok_or_else(|| format!("no key in {line:?}"))?;
163 fields.read(key.trim(), value.trim())?;
164 }
165 fields.build()
166 }
167}
168
169#[derive(Default)]
176struct Fields {
177 size: Option<(u8, u8)>,
178 seed: Option<u64>,
179 rng: Option<(u64, u64)>,
180 tick: Option<u64>,
181 rule: Option<(CapPolicy, u8)>,
182 counters: Option<(u64, u32, u32, u32, u32)>,
183 scores: Option<[u32; MAX_PLAYERS]>,
184 gull_period: Option<u32>,
185 round_length: Option<u32>,
186 wrap: bool,
187 events_enabled: bool,
188 lure: Option<(PlayerId, u32)>,
189 lure_cooldown: u32,
190 mania: Option<(Mania, u32)>,
191 tempo: Option<(Tempo, u32)>,
192 last_event: Option<(TideEvent, u64)>,
193 h_walls: Option<Vec<bool>>,
194 v_walls: Option<Vec<bool>>,
195 tiles: Option<Vec<TileKind>>,
196 posts: Vec<(usize, Signpost)>,
197 crabs: Vec<Crab>,
198 gulls: Vec<Gull>,
199}
200
201impl Fields {
202 fn read(&mut self, key: &str, value: &str) -> Result<(), String> {
204 let mut words = value.split_whitespace();
205 match key {
206 "size" => {
207 let w = next_num::<u8>(&mut words, "size width")?;
208 let h = next_num::<u8>(&mut words, "size height")?;
209 if w == 0 || h == 0 {
210 return Err("a board is at least 1x1".to_string());
211 }
212 self.size = Some((w, h));
213 }
214 "seed" => self.seed = Some(next_num(&mut words, "seed")?),
215 "rng" => {
216 let state = next_num(&mut words, "rng state")?;
217 self.rng = Some((state, next_num(&mut words, "rng inc")?));
218 }
219 "tick" => self.tick = Some(next_num(&mut words, "tick")?),
220 "rule" => {
221 let token = words.next().ok_or("rule: missing policy")?;
222 let policy = CapPolicy::from_token(token)
223 .ok_or_else(|| format!("rule: bad policy {token:?}"))?;
224 self.rule = Some((policy, next_num(&mut words, "rule cap")?));
225 }
226 "counters" => {
227 self.counters = Some((
228 next_num(&mut words, "signpost_seq")?,
229 next_num(&mut words, "next_crab_id")?,
230 next_num(&mut words, "next_gull_id")?,
231 next_num(&mut words, "crabs_banked")?,
232 next_num(&mut words, "golden_banked")?,
233 ));
234 }
235 "scores" => {
236 let mut seats = [0u32; MAX_PLAYERS];
237 for (seat, slot) in seats.iter_mut().enumerate() {
238 *slot = next_num(&mut words, "score")
239 .map_err(|e| format!("{e} for seat {seat}"))?;
240 }
241 self.scores = Some(seats);
242 }
243 "gull_period" => self.gull_period = Some(next_num(&mut words, "gull_period")?),
244 "round" => self.round_length = Some(next_num(&mut words, "round")?),
245 "wrap" => self.wrap = value == "on",
246 "events" => self.events_enabled = value == "on",
247 "lure" => {
248 let owner = next_num::<PlayerId>(&mut words, "lure owner")?;
249 self.lure = Some((owner, next_num(&mut words, "lure ticks")?));
250 }
251 "cooldown" => self.lure_cooldown = next_num(&mut words, "cooldown")?,
252 "mania" => {
253 let which = words.next().ok_or("mania: missing kind")?;
254 let kind = match which {
255 "crab" => Mania::Crab,
256 "gull" => Mania::Gull,
257 other => return Err(format!("mania: bad kind {other:?}")),
258 };
259 self.mania = Some((kind, next_num(&mut words, "mania ticks")?));
260 }
261 "tempo" => {
262 let which = words.next().ok_or("tempo: missing speed")?;
263 let shift = match which {
264 "fast" => Tempo::Fast,
265 "slow" => Tempo::Slow,
266 other => return Err(format!("tempo: bad speed {other:?}")),
267 };
268 self.tempo = Some((shift, next_num(&mut words, "tempo ticks")?));
269 }
270 "last_event" => {
271 let index = next_num::<usize>(&mut words, "last_event index")?;
272 let event = *TideEvent::ALL
273 .get(index)
274 .ok_or_else(|| format!("last_event: no event {index}"))?;
275 self.last_event = Some((event, next_num(&mut words, "last_event tick")?));
276 }
277 "hwalls" => self.h_walls = Some(hex_to_bits(value)?),
278 "vwalls" => self.v_walls = Some(hex_to_bits(value)?),
279 "tiles" => {
280 self.tiles = Some(
281 value
282 .split_whitespace()
283 .map(tile_from_token)
284 .collect::<Result<_, _>>()?,
285 );
286 }
287 "post" => self.posts.push(parse_post(&mut words)?),
288 "crab" => self.crabs.push(parse_crab(&mut words)?),
289 "gull" => self.gulls.push(parse_gull(&mut words)?),
290 other => return Err(format!("unknown key {other:?}")),
291 }
292 Ok(())
293 }
294
295 fn build(self) -> Result<Board, String> {
297 let (width, height) = self.size.ok_or("snapshot has no size")?;
298 let (rng_state, rng_inc) = self.rng.ok_or("snapshot has no rng")?;
299 let (cap_policy, signpost_cap) = self.rule.ok_or("snapshot has no rule")?;
300 let (signpost_seq, next_crab_id, next_gull_id, crabs_banked, golden_banked) =
301 self.counters.ok_or("snapshot has no counters")?;
302 let seed = self.seed.ok_or("snapshot has no seed")?;
303 let tick = self.tick.ok_or("snapshot has no tick")?;
304 let scores = self.scores.ok_or("snapshot has no scores")?;
305 let gull_period = self.gull_period.ok_or("snapshot has no gull_period")?;
306 let (w, h) = (width as usize, height as usize);
307 let tiles = self.tiles.ok_or("snapshot has no tiles")?;
308 if tiles.len() != w * h {
309 return Err(format!("{} tiles for a {w}x{h} board", tiles.len()));
310 }
311 let h_walls = sized(
312 self.h_walls.ok_or("snapshot has no hwalls")?,
313 (h + 1) * w,
314 "hwalls",
315 )?;
316 let v_walls = sized(
317 self.v_walls.ok_or("snapshot has no vwalls")?,
318 h * (w + 1),
319 "vwalls",
320 )?;
321 let mut signposts = vec![None; w * h];
322 for (tile, post) in self.posts {
323 let slot = signposts
324 .get_mut(tile)
325 .ok_or_else(|| format!("post: tile {tile} is off a {w}x{h} board"))?;
326 *slot = Some(post);
327 }
328 for crab in &self.crabs {
329 if usize::from(crab.tile) >= w * h {
330 return Err(format!("crab on tile {}, off the board", crab.tile));
331 }
332 }
333 for gull in &self.gulls {
334 if usize::from(gull.tile) >= w * h {
335 return Err(format!("gull on tile {}, off the board", gull.tile));
336 }
337 }
338
339 Ok(Board {
342 width,
343 height,
344 seed,
345 h_walls,
346 v_walls,
347 tiles,
348 signposts,
349 crabs: self.crabs,
350 scores,
351 rng: Pcg32::from_state(rng_state, rng_inc),
352 tick,
353 signpost_seq,
354 next_crab_id,
355 signpost_cap,
356 cap_policy,
357 gulls: self.gulls,
358 next_gull_id,
359 gull_period,
360 round_length: self.round_length,
361 lure: self.lure,
362 lure_cooldown: self.lure_cooldown,
363 crabs_banked,
364 golden_banked,
365 events_enabled: self.events_enabled,
366 mania: self.mania,
367 tempo: self.tempo,
368 last_event: self.last_event,
369 wrap: self.wrap,
370 event_queue: Vec::new(),
373 })
374 }
375}
376
377fn sized(bits: Vec<bool>, want: usize, what: &str) -> Result<Vec<bool>, String> {
378 if bits.len() < want {
379 return Err(format!("{what}: {} bits, wanted {want}", bits.len()));
380 }
381 let mut bits = bits;
382 bits.truncate(want);
383 Ok(bits)
384}
385
386fn next_num<T: std::str::FromStr>(
387 words: &mut std::str::SplitWhitespace,
388 what: &str,
389) -> Result<T, String> {
390 let word = words.next().ok_or_else(|| format!("{what}: missing"))?;
391 word.parse()
392 .map_err(|_| format!("{what}: bad number {word:?}"))
393}
394
395fn next_dir(words: &mut std::str::SplitWhitespace, what: &str) -> Result<Direction, String> {
396 let word = words.next().ok_or_else(|| format!("{what}: missing"))?;
397 Direction::from_letter(word).ok_or_else(|| format!("{what}: bad direction {word:?}"))
398}
399
400fn parse_post(words: &mut std::str::SplitWhitespace) -> Result<(usize, Signpost), String> {
401 let tile = next_num::<usize>(words, "post tile")?;
402 let dir = next_dir(words, "post direction")?;
403 let owner = next_num::<PlayerId>(words, "post owner")?;
404 let word = words.next().ok_or("post health: missing")?;
405 let health = match word {
406 "full" => SignpostHealth::Full,
407 "worn" => SignpostHealth::Worn,
408 other => return Err(format!("post: bad health {other:?}")),
409 };
410 let seq = next_num(words, "post seq")?;
411 let placed = next_num(words, "post placed")?;
412 Ok((
413 tile,
414 Signpost {
415 dir,
416 owner,
417 health,
418 seq,
419 placed,
420 },
421 ))
422}
423
424fn parse_crab(words: &mut std::str::SplitWhitespace) -> Result<Crab, String> {
425 let id = next_num(words, "crab id")?;
426 let tile = next_num(words, "crab tile")?;
427 let dir = next_dir(words, "crab direction")?;
428 let progress = next_num(words, "crab progress")?;
429 let prev_tile = next_num(words, "crab prev_tile")?;
430 let prev_progress = next_num(words, "crab prev_progress")?;
431 let prev_dir = next_dir(words, "crab prev_dir")?;
432 let word = words.next().ok_or("crab handedness: missing")?;
433 let handed =
434 Handedness::from_token(word).ok_or_else(|| format!("crab: bad handedness {word:?}"))?;
435 let word = words.next().ok_or("crab kind: missing")?;
436 let kind = CrabKind::from_token(word).ok_or_else(|| format!("crab: bad kind {word:?}"))?;
437 Ok(Crab {
438 id,
439 tile,
440 dir,
441 progress,
442 prev_tile,
443 prev_progress,
444 prev_dir,
445 handed,
446 kind,
447 })
448}
449
450fn parse_gull(words: &mut std::str::SplitWhitespace) -> Result<Gull, String> {
451 let id = next_num(words, "gull id")?;
452 let tile = next_num(words, "gull tile")?;
453 let dir = next_dir(words, "gull direction")?;
454 let progress = next_num(words, "gull progress")?;
455 let prev_tile = next_num(words, "gull prev_tile")?;
456 let prev_progress = next_num(words, "gull prev_progress")?;
457 let prev_dir = next_dir(words, "gull prev_dir")?;
458 let word = words.next().ok_or("gull handedness: missing")?;
459 let handed =
460 Handedness::from_token(word).ok_or_else(|| format!("gull: bad handedness {word:?}"))?;
461 let word = words.next().ok_or("gull state: missing")?;
462 let remaining = next_num(words, "gull flight remaining")?;
463 let state = match word {
464 "walk" => GullState::Walking,
465 "fly" => GullState::Flying { remaining },
466 other => return Err(format!("gull: bad state {other:?}")),
467 };
468 let takeoff_in = next_num(words, "gull takeoff_in")?;
469 Ok(Gull {
470 id,
471 tile,
472 dir,
473 progress,
474 prev_tile,
475 prev_progress,
476 prev_dir,
477 handed,
478 state,
479 takeoff_in,
480 })
481}
482
483fn bits_to_hex(bits: &[bool]) -> String {
485 bits.chunks(4)
486 .map(|chunk| {
487 let nibble = chunk
488 .iter()
489 .enumerate()
490 .fold(0u32, |acc, (i, &bit)| acc | (u32::from(bit) << i));
491 char::from_digit(nibble, 16).expect("a nibble is one hex digit")
492 })
493 .collect()
494}
495
496fn hex_to_bits(text: &str) -> Result<Vec<bool>, String> {
497 let mut bits = Vec::with_capacity(text.len() * 4);
498 for ch in text.chars() {
499 let nibble = ch
500 .to_digit(16)
501 .ok_or_else(|| format!("walls: {ch:?} is not hex"))?;
502 bits.extend((0..4).map(|i| nibble & (1 << i) != 0));
503 }
504 Ok(bits)
505}
506
507fn tile_token(kind: TileKind) -> String {
508 match kind {
509 TileKind::Empty => ".".to_string(),
510 TileKind::Rock => "#".to_string(),
511 TileKind::Castle(owner) => format!("c{owner}"),
512 TileKind::Spawner(s) => format!("s{}{}", s.dir.letter(), s.period),
513 TileKind::Turnstile { next_right: true } => "T".to_string(),
514 TileKind::Turnstile { next_right: false } => "t".to_string(),
515 TileKind::Kelp => "K".to_string(),
516 TileKind::Pool => "~".to_string(),
517 }
518}
519
520fn tile_from_token(token: &str) -> Result<TileKind, String> {
521 match token {
522 "." => Ok(TileKind::Empty),
523 "#" => Ok(TileKind::Rock),
524 "T" => Ok(TileKind::Turnstile { next_right: true }),
525 "t" => Ok(TileKind::Turnstile { next_right: false }),
526 "K" => Ok(TileKind::Kelp),
527 "~" => Ok(TileKind::Pool),
528 _ => {
529 let (tag, rest) = token.split_at_checked(1).ok_or("empty tile token")?;
530 match tag {
531 "c" => {
532 let owner: PlayerId = rest
533 .parse()
534 .map_err(|_| format!("tile: bad castle owner in {token:?}"))?;
535 if seat(owner).is_none() {
536 return Err(format!("tile: no seat {owner}"));
537 }
538 Ok(TileKind::Castle(owner))
539 }
540 "s" => {
541 let (letter, period) = rest
542 .split_at_checked(1)
543 .ok_or_else(|| format!("tile: bad spawner {token:?}"))?;
544 let dir = Direction::from_letter(letter)
545 .ok_or_else(|| format!("tile: bad spawner direction in {token:?}"))?;
546 let period: u32 = period
547 .parse()
548 .map_err(|_| format!("tile: bad spawner period in {token:?}"))?;
549 if period == 0 {
550 return Err("tile: a spawner period is at least 1 tick".to_string());
551 }
552 Ok(TileKind::Spawner(Spawner { dir, period }))
553 }
554 _ => Err(format!("tile: unknown token {token:?}")),
555 }
556 }
557 }
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563 use crate::sim::crab::{CrabKind, Handedness};
564
565 fn awkward_board() -> Board {
574 let mut board = Board::new(5, 4, 0xABCD);
575 board.set_tile(0, 0, TileKind::Castle(0));
576 board.set_tile(4, 3, TileKind::Castle(1));
577 board.set_tile(2, 1, TileKind::Rock);
578 board.set_tile(1, 2, TileKind::Kelp);
579 board.set_tile(3, 2, TileKind::Pool);
580 board.set_tile(2, 2, TileKind::Turnstile { next_right: false });
581 board.set_tile(
582 0,
583 2,
584 TileKind::Spawner(Spawner {
585 dir: Direction::Right,
586 period: 37,
587 }),
588 );
589 board.set_wrap(true);
590 board.set_wall(1, 1, Direction::Up, true);
591 board.set_events_enabled(true);
592 board.set_round_length(Some(1234));
593 board.set_gull_period(97);
594 board.set_signpost_rule(2, CapPolicy::Reject);
595 board.set_score(0, 17);
596 board.set_score(3, 4);
597
598 board.rng = Pcg32::from_state(0x1234_5678_9ABC_DEF0, 0x0FED_CBA9_8765_4321);
599 board.tick = 4321;
600 board.signpost_seq = 99;
601 board.next_crab_id = 7;
602 board.next_gull_id = 3;
603 board.crabs_banked = 12;
604 board.golden_banked = 2;
605 board.lure = Some((1, 145));
606 board.lure_cooldown = 60;
607 board.mania = Some((Mania::Gull, 88));
608 board.tempo = Some((Tempo::Slow, 44));
609 board.last_event = Some((TideEvent::FreshSand, 3000));
610 board.signposts[6] = Some(Signpost {
611 dir: Direction::Left,
612 owner: 1,
613 health: SignpostHealth::Worn,
614 seq: 5,
615 placed: 300,
616 });
617 board.crabs.push(Crab {
618 id: 4,
619 tile: 8,
620 dir: Direction::Down,
621 progress: 133,
622 prev_tile: 3,
623 prev_progress: 200,
624 prev_dir: Direction::Right,
625 handed: Handedness::Right,
626 kind: CrabKind::Golden,
627 });
628 board.gulls.push(Gull {
629 id: 2,
630 tile: 9,
631 dir: Direction::Up,
632 progress: 77,
633 prev_tile: 14,
634 prev_progress: 12,
635 prev_dir: Direction::Left,
636 handed: Handedness::Left,
637 state: GullState::Flying { remaining: 3 },
638 takeoff_in: 222,
639 });
640 board
641 }
642
643 #[test]
644 fn a_board_with_everything_set_survives_a_snapshot() {
645 let board = awkward_board();
646 let text = board.to_snapshot();
647 let back = Board::parse_snapshot(&text).expect("its own output");
648 assert_eq!(
649 back.state_hash(),
650 board.state_hash(),
651 "the snapshot came back a different board:\n{text}"
652 );
653 for expected in [
656 "lure: 1 145",
657 "cooldown: 60",
658 "mania: gull 88",
659 "tempo: slow 44",
660 "last_event: 6 3000",
661 "wrap: on",
662 "events: on",
663 "rule: reject 2",
664 ] {
665 assert!(text.contains(expected), "missing {expected:?} in\n{text}");
666 }
667 assert!(text.contains(" worn "), "the worn signpost:\n{text}");
668 assert!(text.contains(" fly 3 "), "the gull mid-flight:\n{text}");
669 }
670
671 #[test]
674 fn what_is_not_a_snapshot_is_refused() {
675 let good = awkward_board().to_snapshot();
676 assert!(Board::parse_snapshot("").is_err(), "empty");
677 assert!(Board::parse_snapshot("hello").is_err(), "not a snapshot");
678 assert!(
679 Board::parse_snapshot(&good.replace(HEADER, "snapshot-v2")).is_err(),
680 "another version"
681 );
682 assert!(
683 Board::parse_snapshot(&good.replace("tiles:", "tyles:")).is_err(),
684 "a key this build does not know"
685 );
686 assert!(
687 Board::parse_snapshot(&good.replace("size: 5 4", "size: 6 4")).is_err(),
688 "a tile count that does not fit the size"
689 );
690 let lines: Vec<&str> = good.lines().collect();
692 for drop in 1..lines.len() {
693 let mut kept = lines.clone();
694 let line = kept.remove(drop);
695 let optional = [
698 "round:",
699 "wrap:",
700 "events:",
701 "lure:",
702 "cooldown:",
703 "mania:",
704 "tempo:",
705 "last_event:",
706 "post:",
707 "crab:",
708 "gull:",
709 ];
710 if optional
711 .iter()
712 .any(|key| line.trim_start().starts_with(key))
713 {
714 continue;
715 }
716 assert!(
717 Board::parse_snapshot(&kept.join("\n")).is_err(),
718 "dropping {line:?} should not still parse"
719 );
720 }
721 }
722}