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.grid.width, self.grid.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.rules.cap_policy.token(),
39 self.rules.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.rules.gull_period);
53 if let Some(len) = self.rules.round_length {
56 let _ = writeln!(out, "round: {len}");
57 }
58 if self.wrap {
59 let _ = writeln!(out, "wrap: on");
60 }
61 if !self.rules.castle_raids {
62 let _ = writeln!(out, "raids: off");
63 }
64 if self.events_enabled {
65 let _ = writeln!(out, "events: on");
66 }
67 if let Some((owner, ticks)) = self.lure {
68 let _ = writeln!(out, "lure: {owner} {ticks}");
69 }
70 if self.lure_cooldown > 0 {
71 let _ = writeln!(out, "cooldown: {}", self.lure_cooldown);
72 }
73 if self.event_cooldown > 0 {
74 let _ = writeln!(out, "event_cooldown: {}", self.event_cooldown);
75 }
76 if let Some((mania, ticks)) = self.mania {
77 let name = match mania {
78 Mania::Crab => "crab",
79 Mania::Gull => "gull",
80 };
81 let _ = writeln!(out, "mania: {name} {ticks}");
82 }
83 if let Some((tempo, ticks)) = self.tempo {
84 let name = match tempo {
85 Tempo::Fast => "fast",
86 Tempo::Slow => "slow",
87 };
88 let _ = writeln!(out, "tempo: {name} {ticks}");
89 }
90 if let Some((event, at)) = self.last_event {
91 let _ = writeln!(out, "last_event: {} {at}", event.index());
92 }
93 let _ = writeln!(out, "hwalls: {}", bits_to_hex(&self.grid.h_walls));
94 let _ = writeln!(out, "vwalls: {}", bits_to_hex(&self.grid.v_walls));
95 let tiles: Vec<String> = self
96 .grid
97 .tiles
98 .iter()
99 .map(|&kind| tile_token(kind))
100 .collect();
101 let _ = writeln!(out, "tiles: {}", tiles.join(" "));
102 for (tile, slot) in self.signposts.iter().enumerate() {
103 if let Some(post) = slot {
104 let health = match post.health {
105 SignpostHealth::Full => "full",
106 SignpostHealth::Worn => "worn",
107 };
108 let _ = writeln!(
109 out,
110 "post: {tile} {} {} {health} {} {}",
111 post.dir.letter(),
112 post.owner,
113 post.seq,
114 post.placed
115 );
116 }
117 }
118 for crab in &self.crabs {
119 let _ = writeln!(
120 out,
121 "crab: {} {} {} {} {} {} {} {} {}",
122 crab.id,
123 crab.tile,
124 crab.dir.letter(),
125 crab.progress,
126 crab.prev.tile,
127 crab.prev.progress,
128 crab.prev.dir.letter(),
129 crab.handed.token(),
130 crab.kind.token()
131 );
132 }
133 for gull in &self.gulls {
134 let (state, remaining) = match gull.state {
137 GullState::Walking => ("walk", 0),
138 GullState::Flying { remaining } => ("fly", remaining),
139 };
140 let _ = writeln!(
141 out,
142 "gull: {} {} {} {} {} {} {} {} {state} {remaining} {}",
143 gull.id,
144 gull.tile,
145 gull.dir.letter(),
146 gull.progress,
147 gull.prev.tile,
148 gull.prev.progress,
149 gull.prev.dir.letter(),
150 gull.handed.token(),
151 gull.takeoff_in
152 );
153 }
154 out
155 }
156
157 pub fn parse_snapshot(text: &str) -> Result<Board, String> {
163 let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
164 match lines.next() {
165 Some(HEADER) => {}
166 Some(other) => return Err(format!("not a snapshot: {other:?}")),
167 None => return Err("empty snapshot".to_string()),
168 }
169 let mut fields = Fields::default();
170 for line in lines {
171 let (key, value) = line
172 .split_once(':')
173 .ok_or_else(|| format!("no key in {line:?}"))?;
174 fields.read(key.trim(), value.trim())?;
175 }
176 fields.build()
177 }
178}
179
180#[derive(Default)]
187struct Fields {
188 size: Option<(u8, u8)>,
189 seed: Option<u64>,
190 rng: Option<(u64, u64)>,
191 tick: Option<u64>,
192 rule: Option<(CapPolicy, u8)>,
193 counters: Option<(u64, u32, u32, u32, u32)>,
194 scores: Option<[u32; MAX_PLAYERS]>,
195 gull_period: Option<u32>,
196 round_length: Option<u32>,
197 wrap: bool,
198 no_castle_raids: bool,
205 events_enabled: bool,
206 lure: Option<(PlayerId, u32)>,
207 lure_cooldown: u32,
208 event_cooldown: u32,
209 mania: Option<(Mania, u32)>,
210 tempo: Option<(Tempo, u32)>,
211 last_event: Option<(TideEvent, u64)>,
212 h_walls: Option<Vec<bool>>,
213 v_walls: Option<Vec<bool>>,
214 tiles: Option<Vec<TileKind>>,
215 posts: Vec<(usize, Signpost)>,
216 crabs: Vec<Crab>,
217 gulls: Vec<Gull>,
218}
219
220impl Fields {
221 fn read(&mut self, key: &str, value: &str) -> Result<(), String> {
223 let mut words = value.split_whitespace();
224 match key {
225 "size" => {
226 let w = next_num::<u8>(&mut words, "size width")?;
227 let h = next_num::<u8>(&mut words, "size height")?;
228 if w == 0 || h == 0 {
229 return Err("a board is at least 1x1".to_string());
230 }
231 self.size = Some((w, h));
232 }
233 "seed" => self.seed = Some(next_num(&mut words, "seed")?),
234 "rng" => {
235 let state = next_num(&mut words, "rng state")?;
236 self.rng = Some((state, next_num(&mut words, "rng inc")?));
237 }
238 "tick" => self.tick = Some(next_num(&mut words, "tick")?),
239 "rule" => {
240 let token = words.next().ok_or("rule: missing policy")?;
241 let policy = CapPolicy::from_token(token)
242 .ok_or_else(|| format!("rule: bad policy {token:?}"))?;
243 let cap = next_num(&mut words, "rule cap")?;
244 if cap == 0 && policy == CapPolicy::Evict {
245 return Err("rule: evict needs a cap of at least 1".to_string());
246 }
247 self.rule = Some((policy, cap));
248 }
249 "counters" => {
250 self.counters = Some((
251 next_num(&mut words, "signpost_seq")?,
252 next_num(&mut words, "next_crab_id")?,
253 next_num(&mut words, "next_gull_id")?,
254 next_num(&mut words, "crabs_banked")?,
255 next_num(&mut words, "golden_banked")?,
256 ));
257 }
258 "scores" => {
259 let mut seats = [0u32; MAX_PLAYERS];
260 for (seat, slot) in seats.iter_mut().enumerate() {
261 *slot = next_num(&mut words, "score")
262 .map_err(|e| format!("{e} for seat {seat}"))?;
263 }
264 self.scores = Some(seats);
265 }
266 "gull_period" => self.gull_period = Some(next_num(&mut words, "gull_period")?),
267 "round" => self.round_length = Some(next_num(&mut words, "round")?),
268 "wrap" => self.wrap = value == "on",
269 "raids" => self.no_castle_raids = value == "off",
270 "events" => self.events_enabled = value == "on",
271 "lure" => {
272 let owner = next_num::<PlayerId>(&mut words, "lure owner")?;
273 self.lure = Some((owner, ticks_left(&mut words, "lure")?));
274 }
275 "cooldown" => self.lure_cooldown = next_num(&mut words, "cooldown")?,
276 "event_cooldown" => {
277 self.event_cooldown = next_num(&mut words, "event_cooldown")?;
278 }
279 "mania" => {
280 let which = words.next().ok_or("mania: missing kind")?;
281 let kind = match which {
282 "crab" => Mania::Crab,
283 "gull" => Mania::Gull,
284 other => return Err(format!("mania: bad kind {other:?}")),
285 };
286 self.mania = Some((kind, ticks_left(&mut words, "mania")?));
287 }
288 "tempo" => {
289 let which = words.next().ok_or("tempo: missing speed")?;
290 let shift = match which {
291 "fast" => Tempo::Fast,
292 "slow" => Tempo::Slow,
293 other => return Err(format!("tempo: bad speed {other:?}")),
294 };
295 self.tempo = Some((shift, ticks_left(&mut words, "tempo")?));
296 }
297 "last_event" => {
298 let index = next_num::<usize>(&mut words, "last_event index")?;
299 let event = *TideEvent::ALL
300 .get(index)
301 .ok_or_else(|| format!("last_event: no event {index}"))?;
302 self.last_event = Some((event, next_num(&mut words, "last_event tick")?));
303 }
304 "hwalls" => self.h_walls = Some(hex_to_bits(value)?),
305 "vwalls" => self.v_walls = Some(hex_to_bits(value)?),
306 "tiles" => {
307 self.tiles = Some(
308 value
309 .split_whitespace()
310 .map(tile_from_token)
311 .collect::<Result<_, _>>()?,
312 );
313 }
314 "post" => self.posts.push(parse_post(&mut words)?),
315 "crab" => self.crabs.push(parse_crab(&mut words)?),
316 "gull" => self.gulls.push(parse_gull(&mut words)?),
317 other => return Err(format!("unknown key {other:?}")),
318 }
319 Ok(())
320 }
321
322 fn build(self) -> Result<Board, String> {
324 let (width, height) = self.size.ok_or("snapshot has no size")?;
325 let (rng_state, rng_inc) = self.rng.ok_or("snapshot has no rng")?;
326 let (cap_policy, signpost_cap) = self.rule.ok_or("snapshot has no rule")?;
327 let (signpost_seq, next_crab_id, next_gull_id, crabs_banked, golden_banked) =
328 self.counters.ok_or("snapshot has no counters")?;
329 let seed = self.seed.ok_or("snapshot has no seed")?;
330 let tick = self.tick.ok_or("snapshot has no tick")?;
331 let scores = self.scores.ok_or("snapshot has no scores")?;
332 let gull_period = self.gull_period.ok_or("snapshot has no gull_period")?;
333 let (w, h) = (width as usize, height as usize);
334 let tiles = self.tiles.ok_or("snapshot has no tiles")?;
335 if tiles.len() != w * h {
336 return Err(format!("{} tiles for a {w}x{h} board", tiles.len()));
337 }
338 let h_walls = sized(
339 self.h_walls.ok_or("snapshot has no hwalls")?,
340 (h + 1) * w,
341 "hwalls",
342 )?;
343 let v_walls = sized(
344 self.v_walls.ok_or("snapshot has no vwalls")?,
345 h * (w + 1),
346 "vwalls",
347 )?;
348 let mut signposts = vec![None; w * h];
349 for (tile, post) in self.posts {
350 let slot = signposts
351 .get_mut(tile)
352 .ok_or_else(|| format!("post: tile {tile} is off a {w}x{h} board"))?;
353 *slot = Some(post);
354 }
355 for crab in &self.crabs {
356 if usize::from(crab.tile) >= w * h {
357 return Err(format!("crab on tile {}, off the board", crab.tile));
358 }
359 }
360 for gull in &self.gulls {
361 if usize::from(gull.tile) >= w * h {
362 return Err(format!("gull on tile {}, off the board", gull.tile));
363 }
364 }
365
366 Ok(Board {
369 grid: Grid {
370 width,
371 height,
372 h_walls,
373 v_walls,
374 tiles,
375 },
376 seed,
377 signposts,
378 rules: Rules {
379 signpost_cap,
380 cap_policy,
381 gull_period,
382 round_length: self.round_length,
383 castle_raids: !self.no_castle_raids,
384 },
385 crabs: self.crabs,
386 scores,
387 rng: Pcg32::from_state(rng_state, rng_inc),
388 tick,
389 signpost_seq,
390 next_crab_id,
391 gulls: self.gulls,
392 next_gull_id,
393 lure: self.lure,
394 lure_cooldown: self.lure_cooldown,
395 event_cooldown: self.event_cooldown,
396 crabs_banked,
397 golden_banked,
398 events_enabled: self.events_enabled,
399 mania: self.mania,
400 tempo: self.tempo,
401 last_event: self.last_event,
402 wrap: self.wrap,
403 event_queue: Vec::new(),
406 swept_home: Vec::new(),
409 })
410 }
411}
412
413fn sized(bits: Vec<bool>, want: usize, what: &str) -> Result<Vec<bool>, String> {
414 if bits.len() < want || bits.len() >= want + 4 {
419 return Err(format!("{what}: {} bits, wanted {want}", bits.len()));
420 }
421 let mut bits = bits;
422 bits.truncate(want);
423 Ok(bits)
424}
425
426fn next_num<T: std::str::FromStr>(
427 words: &mut std::str::SplitWhitespace,
428 what: &str,
429) -> Result<T, String> {
430 let word = words.next().ok_or_else(|| format!("{what}: missing"))?;
431 word.parse()
432 .map_err(|_| format!("{what}: bad number {word:?}"))
433}
434
435fn ticks_left(words: &mut std::str::SplitWhitespace, what: &str) -> Result<u32, String> {
440 match next_num(words, &format!("{what} ticks"))? {
441 0 => Err(format!("{what}: a running one has at least a tick left")),
442 ticks => Ok(ticks),
443 }
444}
445
446fn next_dir(words: &mut std::str::SplitWhitespace, what: &str) -> Result<Direction, String> {
447 let word = words.next().ok_or_else(|| format!("{what}: missing"))?;
448 Direction::from_letter(word).ok_or_else(|| format!("{what}: bad direction {word:?}"))
449}
450
451fn parse_post(words: &mut std::str::SplitWhitespace) -> Result<(usize, Signpost), String> {
452 let tile = next_num::<usize>(words, "post tile")?;
453 let dir = next_dir(words, "post direction")?;
454 let owner = next_num::<PlayerId>(words, "post owner")?;
455 if seat(owner).is_none() {
456 return Err(format!("post owner: no seat {owner}"));
457 }
458 let word = words.next().ok_or("post health: missing")?;
459 let health = match word {
460 "full" => SignpostHealth::Full,
461 "worn" => SignpostHealth::Worn,
462 other => return Err(format!("post: bad health {other:?}")),
463 };
464 let seq = next_num(words, "post seq")?;
465 let placed = next_num(words, "post placed")?;
466 Ok((
467 tile,
468 Signpost {
469 dir,
470 owner,
471 health,
472 seq,
473 placed,
474 },
475 ))
476}
477
478fn parse_pose(words: &mut std::str::SplitWhitespace, who: &str) -> Result<Pose, String> {
481 let tile = next_num(words, &format!("{who} prev_tile"))?;
482 let progress = next_num(words, &format!("{who} prev_progress"))?;
483 let dir = next_dir(words, &format!("{who} prev_dir"))?;
484 Ok(Pose {
485 tile,
486 dir,
487 progress,
488 })
489}
490
491fn parse_crab(words: &mut std::str::SplitWhitespace) -> Result<Crab, String> {
492 let id = next_num(words, "crab id")?;
493 let tile = next_num(words, "crab tile")?;
494 let dir = next_dir(words, "crab direction")?;
495 let progress = next_num(words, "crab progress")?;
496 let prev = parse_pose(words, "crab")?;
497 let word = words.next().ok_or("crab handedness: missing")?;
498 let handed =
499 Handedness::from_token(word).ok_or_else(|| format!("crab: bad handedness {word:?}"))?;
500 let word = words.next().ok_or("crab kind: missing")?;
501 let kind = CrabKind::from_token(word).ok_or_else(|| format!("crab: bad kind {word:?}"))?;
502 Ok(Crab {
503 id,
504 tile,
505 dir,
506 progress,
507 prev,
508 handed,
509 kind,
510 })
511}
512
513fn parse_gull(words: &mut std::str::SplitWhitespace) -> Result<Gull, String> {
514 let id = next_num(words, "gull id")?;
515 let tile = next_num(words, "gull tile")?;
516 let dir = next_dir(words, "gull direction")?;
517 let progress = next_num(words, "gull progress")?;
518 let prev = parse_pose(words, "gull")?;
519 let word = words.next().ok_or("gull handedness: missing")?;
520 let handed =
521 Handedness::from_token(word).ok_or_else(|| format!("gull: bad handedness {word:?}"))?;
522 let word = words.next().ok_or("gull state: missing")?;
523 let remaining = next_num(words, "gull flight remaining")?;
524 let state = match word {
525 "walk" => GullState::Walking,
526 "fly" => GullState::Flying { remaining },
527 other => return Err(format!("gull: bad state {other:?}")),
528 };
529 let takeoff_in = next_num(words, "gull takeoff_in")?;
530 Ok(Gull {
531 id,
532 tile,
533 dir,
534 progress,
535 prev,
536 handed,
537 state,
538 takeoff_in,
539 })
540}
541
542fn bits_to_hex(bits: &[bool]) -> String {
544 bits.chunks(4)
545 .map(|chunk| {
546 let nibble = chunk
547 .iter()
548 .enumerate()
549 .fold(0u32, |acc, (i, &bit)| acc | (u32::from(bit) << i));
550 char::from_digit(nibble, 16).expect("a nibble is one hex digit")
551 })
552 .collect()
553}
554
555fn hex_to_bits(text: &str) -> Result<Vec<bool>, String> {
556 let mut bits = Vec::with_capacity(text.len() * 4);
557 for ch in text.chars() {
558 let nibble = ch
559 .to_digit(16)
560 .ok_or_else(|| format!("walls: {ch:?} is not hex"))?;
561 bits.extend((0..4).map(|i| nibble & (1 << i) != 0));
562 }
563 Ok(bits)
564}
565
566fn tile_token(kind: TileKind) -> String {
567 match kind {
568 TileKind::Empty => ".".to_string(),
569 TileKind::Rock => "#".to_string(),
570 TileKind::Castle(owner) => format!("c{owner}"),
571 TileKind::Spawner(s) => format!("s{}{}", s.dir.letter(), s.period),
572 TileKind::Turnstile { next_right: true } => "T".to_string(),
573 TileKind::Turnstile { next_right: false } => "t".to_string(),
574 TileKind::Kelp => "K".to_string(),
575 TileKind::Pool => "~".to_string(),
576 }
577}
578
579fn tile_from_token(token: &str) -> Result<TileKind, String> {
580 match token {
581 "." => Ok(TileKind::Empty),
582 "#" => Ok(TileKind::Rock),
583 "T" => Ok(TileKind::Turnstile { next_right: true }),
584 "t" => Ok(TileKind::Turnstile { next_right: false }),
585 "K" => Ok(TileKind::Kelp),
586 "~" => Ok(TileKind::Pool),
587 _ => {
588 let (tag, rest) = token.split_at_checked(1).ok_or("empty tile token")?;
589 match tag {
590 "c" => {
591 let owner: PlayerId = rest
592 .parse()
593 .map_err(|_| format!("tile: bad castle owner in {token:?}"))?;
594 if seat(owner).is_none() {
595 return Err(format!("tile: no seat {owner}"));
596 }
597 Ok(TileKind::Castle(owner))
598 }
599 "s" => {
600 let (letter, period) = rest
601 .split_at_checked(1)
602 .ok_or_else(|| format!("tile: bad spawner {token:?}"))?;
603 let dir = Direction::from_letter(letter)
604 .ok_or_else(|| format!("tile: bad spawner direction in {token:?}"))?;
605 let period: u32 = period
606 .parse()
607 .map_err(|_| format!("tile: bad spawner period in {token:?}"))?;
608 if period == 0 {
609 return Err("tile: a spawner period is at least 1 tick".to_string());
610 }
611 Ok(TileKind::Spawner(Spawner { dir, period }))
612 }
613 _ => Err(format!("tile: unknown token {token:?}")),
614 }
615 }
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use crate::sim::crab::{CrabKind, Handedness};
623
624 fn awkward_board() -> Board {
633 let mut board = Board::new(5, 4, 0xABCD);
634 board.set_tile(0, 0, TileKind::Castle(0));
635 board.set_tile(4, 3, TileKind::Castle(1));
636 board.set_tile(2, 1, TileKind::Rock);
637 board.set_tile(1, 2, TileKind::Kelp);
638 board.set_tile(3, 2, TileKind::Pool);
639 board.set_tile(2, 2, TileKind::Turnstile { next_right: false });
640 board.set_tile(
641 0,
642 2,
643 TileKind::Spawner(Spawner {
644 dir: Direction::Right,
645 period: 37,
646 }),
647 );
648 board.set_wrap(true);
649 board.set_wall(1, 1, Direction::Up, true);
650 board.set_castle_raids(false);
651 board.set_events_enabled(true);
652 board.set_round_length(Some(1234));
653 board.set_gull_period(97);
654 board.set_signpost_rule(2, CapPolicy::Reject);
655 board.set_score(0, 17);
656 board.set_score(3, 4);
657
658 board.rng = Pcg32::from_state(0x1234_5678_9ABC_DEF0, 0x0FED_CBA9_8765_4321);
659 board.tick = 4321;
660 board.signpost_seq = 99;
661 board.next_crab_id = 7;
662 board.next_gull_id = 3;
663 board.crabs_banked = 12;
664 board.golden_banked = 2;
665 board.lure = Some((1, 145));
666 board.lure_cooldown = 60;
667 board.event_cooldown = 210;
668 board.mania = Some((Mania::Gull, 88));
669 board.tempo = Some((Tempo::Slow, 44));
670 board.last_event = Some((TideEvent::FreshSand, 3000));
671 board.signposts[6] = Some(Signpost {
672 dir: Direction::Left,
673 owner: 1,
674 health: SignpostHealth::Worn,
675 seq: 5,
676 placed: 300,
677 });
678 board.crabs.push(Crab {
679 id: 4,
680 tile: 8,
681 dir: Direction::Down,
682 progress: 133,
683 prev: Pose {
684 tile: 3,
685 dir: Direction::Right,
686 progress: 200,
687 },
688 handed: Handedness::Right,
689 kind: CrabKind::Golden,
690 });
691 board.gulls.push(Gull {
692 id: 2,
693 tile: 9,
694 dir: Direction::Up,
695 progress: 77,
696 prev: Pose {
697 tile: 14,
698 dir: Direction::Left,
699 progress: 12,
700 },
701 handed: Handedness::Left,
702 state: GullState::Flying { remaining: 3 },
703 takeoff_in: 222,
704 });
705 board
706 }
707
708 #[test]
709 fn a_board_with_everything_set_survives_a_snapshot() {
710 let board = awkward_board();
711 let text = board.to_snapshot();
712 let back = Board::parse_snapshot(&text).expect("its own output");
713 assert_eq!(
714 back.state_hash(),
715 board.state_hash(),
716 "the snapshot came back a different board:\n{text}"
717 );
718 for expected in [
721 "lure: 1 145",
722 "cooldown: 60",
723 "mania: gull 88",
724 "tempo: slow 44",
725 "last_event: 6 3000",
726 "wrap: on",
727 "raids: off",
728 "events: on",
729 "rule: reject 2",
730 ] {
731 assert!(text.contains(expected), "missing {expected:?} in\n{text}");
732 }
733 let mut raiding = awkward_board();
736 raiding.set_castle_raids(true);
737 let text = raiding.to_snapshot();
738 assert!(
739 !text.contains("raids:"),
740 "a default is not written:\n{text}"
741 );
742 assert!(
743 Board::parse_snapshot(&text)
744 .expect("its own output")
745 .castle_raids(),
746 "an absent raids line must read as raids on:\n{text}"
747 );
748 assert!(text.contains(" worn "), "the worn signpost:\n{text}");
749 assert!(text.contains(" fly 3 "), "the gull mid-flight:\n{text}");
750 }
751
752 #[test]
755 fn what_is_not_a_snapshot_is_refused() {
756 let good = awkward_board().to_snapshot();
757 assert!(Board::parse_snapshot("").is_err(), "empty");
758 assert!(Board::parse_snapshot("hello").is_err(), "not a snapshot");
759 assert!(
760 Board::parse_snapshot(&good.replace(HEADER, "snapshot-v2")).is_err(),
761 "another version"
762 );
763 assert!(
764 Board::parse_snapshot(&good.replace("tiles:", "tyles:")).is_err(),
765 "a key this build does not know"
766 );
767 assert!(
768 Board::parse_snapshot(&good.replace("size: 5 4", "size: 6 4")).is_err(),
769 "a tile count that does not fit the size"
770 );
771 assert!(good.contains("rule: reject 2"), "{good}");
775 assert!(
776 Board::parse_snapshot(&good.replace("rule: reject 2", "rule: evict 0")).is_err(),
777 "evict with nothing to evict"
778 );
779
780 let posted = good
784 .lines()
785 .find(|l| l.starts_with("post:"))
786 .expect("a post");
787 let orphan = posted.replace(" 1 ", " 9 ");
788 assert_ne!(posted, orphan, "{posted}");
789 assert!(Board::parse_snapshot(&good.replace(posted, &orphan)).is_err());
790 let walls = good
791 .lines()
792 .find(|l| l.starts_with("hwalls:"))
793 .expect("walls");
794 assert!(Board::parse_snapshot(&good.replace(walls, &format!("{walls}f"))).is_err());
795 for line in ["lure: 1 0", "mania: gull 0", "tempo: slow 0"] {
796 let key = line.split(':').next().unwrap();
797 let zeroed: String = good
798 .lines()
799 .map(|l| if l.starts_with(key) { line } else { l })
800 .collect::<Vec<_>>()
801 .join("\n");
802 assert!(zeroed.contains(line), "{key} is set on the awkward board");
803 assert!(
804 Board::parse_snapshot(&zeroed).is_err(),
805 "{line} should be refused"
806 );
807 }
808 let lines: Vec<&str> = good.lines().collect();
810 for drop in 1..lines.len() {
811 let mut kept = lines.clone();
812 let line = kept.remove(drop);
813 let optional = [
816 "round:",
817 "wrap:",
818 "raids:",
819 "events:",
820 "lure:",
821 "cooldown:",
822 "event_cooldown:",
823 "mania:",
824 "tempo:",
825 "last_event:",
826 "post:",
827 "crab:",
828 "gull:",
829 ];
830 if optional
831 .iter()
832 .any(|key| line.trim_start().starts_with(key))
833 {
834 continue;
835 }
836 assert!(
837 Board::parse_snapshot(&kept.join("\n")).is_err(),
838 "dropping {line:?} should not still parse"
839 );
840 }
841 }
842}