Skip to main content

pinch_points/sim/board/
snapshot.rs

1//! A complete board, written out and read back exactly.
2//!
3//! The level format describes a *starting* board: authoring data, tile
4//! aligned, no creature mid-stride and no PRNG position. That is the right
5//! shape for a level and the wrong shape for saving a round in progress,
6//! which needs every field [`Board::state_hash`] covers or the board it
7//! reloads is a different board from the next tick onward.
8//!
9//! So this is the other half: not pretty, not hand-authored, and complete.
10//! `parse(to_snapshot(board))` has the same state hash as `board` for any
11//! board, however far into a round it is.
12//!
13//! The completeness is held by the compiler, not by vigilance:
14//! [`Board::parse_snapshot`] builds its result with a struct literal naming
15//! every field, so a field added to `Board` fails to build here until
16//! somebody decides how it travels.
17
18use super::*;
19
20/// Format marker. Bump it if the layout changes, so an old snapshot is
21/// refused rather than half-read.
22const HEADER: &str = "snapshot-v1";
23
24impl Board {
25    /// The whole board as text.
26    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        // Everything below is omitted at its default, as a board between
54        // rounds mostly is.
55        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            // `remaining` is only meaningful mid-flight; a walking gull
124            // writes a placeholder zero so every line has the same shape.
125            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    /// Read a snapshot back, or say why it is not one.
147    ///
148    /// Strict where the level format is lenient: a snapshot is written by
149    /// this build for this build, so a line it cannot read is a corrupt save
150    /// rather than a hand edit to shrug at.
151    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/// A snapshot's lines, gathered before any of them is trusted.
170///
171/// The lines `to_snapshot` always writes are all `Option` here and all
172/// unwrapped in [`Fields::build`]. Defaulting one instead would turn a
173/// truncated save into a board that parses and plays differently, which is
174/// the whole failure this format exists to avoid.
175#[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    /// Take one `key: value` line.
203    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    /// Everything gathered, checked against everything else, as a board.
296    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        // Named in full on purpose: a new `Board` field stops compiling here
340        // until it is decided how, or whether, it survives a save.
341        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            // Drained within the tick that fills it, so a snapshot taken
371            // between ticks never has one to carry.
372            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
483/// Walls as hex nibbles, least-significant bit first.
484fn 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    /// A board with every field pushed off its default, including the three
566    /// the fuzz loop never reaches, because a mania, a tempo shift and a
567    /// recorded last event all need a sparkling crab banked.
568    ///
569    /// Reaches in and sets the private fields directly rather than playing
570    /// toward them: the point is coverage of the *format*, and a test that
571    /// had to engineer a tide event to check one line would test the
572    /// roulette instead.
573    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        // The lines that only this test reaches are really being written,
654        // rather than the hash matching because both sides dropped them.
655        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    /// A snapshot from another build, or no snapshot at all, is refused
672    /// rather than half-read into a board that plays differently.
673    #[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        // A truncated save: every line but the header removed in turn.
691        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            // The optional round-state lines are absent at their defaults,
696            // so dropping one is a legal (different) board, not a bad file.
697            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}