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.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        // Everything below is omitted at its default, as a board between
54        // rounds mostly is.
55        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            // `remaining` is only meaningful mid-flight; a walking gull
135            // writes a placeholder zero so every line has the same shape.
136            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    /// Read a snapshot back, or say why it is not one.
158    ///
159    /// Strict where the level format is lenient: a snapshot is written by
160    /// this build for this build, so a line it cannot read is a corrupt save
161    /// rather than a hand edit to shrug at.
162    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/// A snapshot's lines, gathered before any of them is trusted.
181///
182/// The lines `to_snapshot` always writes are all `Option` here and all
183/// unwrapped in [`Fields::build`]. Defaulting one instead would turn a
184/// truncated save into a board that parses and plays differently, which is
185/// the whole failure this format exists to avoid.
186#[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    /// Stored the way the wire stores it - the exception, not the rule -
199    /// because `Default` here has to mean "the line was absent". Castle
200    /// raids are the one board switch that is *on* by default, so a
201    /// `castle_raids: bool` deriving `false` would quietly turn them off
202    /// in every snapshot that omitted the line, which is every versus
203    /// round there is.
204    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    /// Take one `key: value` line.
222    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    /// Everything gathered, checked against everything else, as a board.
323    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        // Named in full on purpose: a new `Board` field stops compiling here
367        // until it is decided how, or whether, it survives a save.
368        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            // Drained within the tick that fills it, so a snapshot taken
404            // between ticks never has one to carry.
405            event_queue: Vec::new(),
406            // News for the frame it happened on, and this is not that
407            // frame: a round picked back up has no sweep to report.
408            swept_home: Vec::new(),
409        })
410    }
411}
412
413fn sized(bits: Vec<bool>, want: usize, what: &str) -> Result<Vec<bool>, String> {
414    // The count the size implies, give or take the nibble the hex rounds
415    // up to: fewer is a truncated save, a nibble or more over is a wall
416    // map for another board, and reading the first `want` of that would
417    // be walls for a beach this is not.
418    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
435/// The ticks left on a running lure, mania or tempo. At least one: the
436/// sim clears each of these the tick it reaches zero and never writes a
437/// zero, and `tick` counts down before it looks, so a zero read back here
438/// would count past the bottom on the first tick.
439fn 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
478/// The three `prev` words of a creature line, in the order they are
479/// written: tile, progress, direction.
480fn 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
542/// Walls as hex nibbles, least-significant bit first.
543fn 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    /// A board with every field pushed off its default, including the three
625    /// the fuzz loop never reaches, because a mania, a tempo shift and a
626    /// recorded last event all need a sparkling crab banked.
627    ///
628    /// Reaches in and sets the private fields directly rather than playing
629    /// toward them: the point is coverage of the *format*, and a test that
630    /// had to engineer a tide event to check one line would test the
631    /// roulette instead.
632    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        // The lines that only this test reaches are really being written,
719        // rather than the hash matching because both sides dropped them.
720        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        // And the default direction, which is the one a missing line has to
734        // mean: raids on writes nothing and must come back on.
735        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    /// A snapshot from another build, or no snapshot at all, is refused
753    /// rather than half-read into a board that plays differently.
754    #[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        // Values the sim never writes and could not run: an evict cap of
772        // zero has nothing to evict on the first placement, and a timer
773        // at zero counts past the bottom on the first tick.
774        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        // A post owned by a seat that does not exist would index the
781        // score table past its end; a wall map longer than the board's
782        // is a wall map for another board.
783        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        // A truncated save: every line but the header removed in turn.
809        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            // The optional round-state lines are absent at their defaults,
814            // so dropping one is a legal (different) board, not a bad file.
815            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}