Skip to main content

pinch_points/sim/level/
format.rs

1//! The level text format: parsing a level file into a [`Board`], and
2//! writing one back out.
3//!
4//! Kept apart from the level *model* next door: the format is the only
5//! thing that cares about glyphs, lattices and `key: value` headers, and it
6//! is the half with a round-trip contract to keep (see `tests/it/format.rs`).
7
8use super::{Goal, Level, LevelKind};
9use crate::sim::board::{Board, CapPolicy, TileKind};
10use crate::sim::crab::{CrabKind, Handedness};
11use crate::sim::direction::Direction;
12use crate::sim::solve::Placement;
13use crate::sim::{PlayerId, Spawner};
14
15impl Level {
16    pub fn parse(text: &str) -> Result<Level, String> {
17        let mut lines = text.lines();
18        let header = parse_header(&mut lines)?;
19        let mut board = parse_lattice(lines, header.seed)?;
20        place_entities(&mut board, &header)?;
21        let level = Level {
22            name: header.name.ok_or("missing name:")?,
23            posts: header.posts.ok_or("missing posts:")?,
24            solution: header.solution,
25            goal: header.goal,
26            // Filled in below: a file written before the editor had a toggle
27            // has no `kind:` line and has to be read off the board.
28            kind: LevelKind::Puzzle,
29            crab_count: header.crabs.len() as u32,
30            explicit_rule: header.rule.is_some(),
31            board,
32        };
33        let kind = header.kind.unwrap_or_else(|| inferred_kind(&level));
34        Ok(level.with_kind(kind))
35    }
36
37    /// Wrap an editor-built board as a level. The board is snapshotted as-is
38    /// (crabs, gulls, walls, timers included), and the kind is read off it -
39    /// call [`Level::with_kind`] to say otherwise, which is what the editor's
40    /// toggle does.
41    pub fn from_board(name: impl Into<String>, posts: u8, board: Board) -> Level {
42        let level = Level {
43            name: name.into(),
44            posts,
45            solution: Vec::new(),
46            goal: Goal::AllCrabs,
47            kind: LevelKind::Puzzle,
48            crab_count: board.crabs().len() as u32,
49            // The board's live rule is authoritative and serialized as-is.
50            explicit_rule: true,
51            board,
52        };
53        let kind = inferred_kind(&level);
54        level.with_kind(kind)
55    }
56
57    /// Serialize back to the text format `parse` reads. Round-trips exactly
58    /// for an un-ticked board that `parse` built (creature sub-tile state
59    /// is not representable, by design, since levels describe starting
60    /// states). A board edited by hand is a little different: placing a
61    /// gull draws twice from the board's PRNG, and the text carries the
62    /// seed, not the stream's position, so a board that placed and erased
63    /// gulls reads back with the survivors rolled afresh. That is why the
64    /// editor certifies the text it will save rather than the board it
65    /// edited (`level_here`).
66    pub fn to_text(&self) -> String {
67        use std::fmt::Write;
68        let board = &self.board;
69        let mut out = String::new();
70        let _ = writeln!(out, "name: {}", self.name);
71        let _ = writeln!(out, "posts: {}", self.posts);
72        // Always written, both values: what the author chose is not always
73        // what the board looks like, and a file that leaves it out is read
74        // by guessing at the castles.
75        let _ = writeln!(out, "kind: {}", self.kind.token());
76        let _ = writeln!(out, "seed: {}", board.seed());
77        // Puzzle levels (no explicit rule) reconstruct their rule from
78        // `posts:` on parse; writing one would freeze the wrong default.
79        if self.explicit_rule {
80            let (cap, policy) = board.signpost_rule();
81            let _ = writeln!(out, "rule: {} {cap}", policy.token());
82        }
83        for (player, &score) in board.scores().iter().enumerate() {
84            if score > 0 {
85                let _ = writeln!(out, "score: {player} {score}");
86            }
87        }
88        for crab in board.crabs() {
89            let (x, y) = board.coords_u8(crab.tile);
90            let _ = writeln!(
91                out,
92                "crab: {x},{y} {} {} {}",
93                crab.dir.letter(),
94                crab.handed.token(),
95                crab.kind.token()
96            );
97        }
98        for (x, y, kind) in board.tiles() {
99            if let TileKind::Spawner(s) = kind {
100                let _ = writeln!(out, "spawner: {x},{y} {} {}", s.dir.letter(), s.period);
101            }
102        }
103        for gull in board.gulls() {
104            let (x, y) = board.coords_u8(gull.tile);
105            let _ = writeln!(out, "gull: {x},{y} {}", gull.dir.letter());
106        }
107        if board.gull_period() > 0 {
108            let _ = writeln!(out, "gull_period: {}", board.gull_period());
109        }
110        if let Some(round) = board.round_length() {
111            let _ = writeln!(out, "round: {round}");
112        }
113        if board.wrap() {
114            let _ = writeln!(out, "wrap: on");
115        }
116        // Written only when they are off, being on by default. An
117        // editor-made puzzle states its rule and so keeps whatever raids
118        // the board had: without this line it played, was checked and was
119        // hinted with raids on, while every campaign puzzle plays without.
120        if !board.castle_raids() {
121            let _ = writeln!(out, "raids: off");
122        }
123        // Tide events are game state, not authoring flavour: dropping the
124        // flag made replays of versus rounds silently diverge.
125        if board.events_enabled() {
126            let _ = writeln!(out, "events: on");
127        }
128        match self.goal {
129            Goal::AllCrabs => {}
130            Goal::Bank(n) => {
131                let _ = writeln!(out, "goal: bank {n}");
132            }
133            Goal::Survive => {
134                let _ = writeln!(out, "goal: survive");
135            }
136            Goal::Golden => {
137                let _ = writeln!(out, "goal: golden");
138            }
139        }
140        if !self.solution.is_empty() {
141            let parts: Vec<String> = self
142                .solution
143                .iter()
144                .map(|&(x, y, dir)| format!("{x},{y} {}", dir.letter()))
145                .collect();
146            let _ = writeln!(out, "solution: {}", parts.join("; "));
147        }
148        let _ = writeln!(out, "map:");
149
150        // Lattice: (2h+1) rows × (2w+1) columns.
151        let (w, h) = (board.width() as usize, board.height() as usize);
152        let mut lattice = vec![vec![' '; 2 * w + 1]; 2 * h + 1];
153        for row in lattice.iter_mut().step_by(2) {
154            for cell in row.iter_mut().step_by(2) {
155                *cell = '+';
156            }
157        }
158        for (x, y, kind) in board.tiles() {
159            let (lx, ly) = (2 * x as usize + 1, 2 * y as usize + 1);
160            lattice[ly][lx] = tile_glyph(kind);
161            if board.wall_at(x, y, Direction::Up) {
162                lattice[ly - 1][lx] = '-';
163            }
164            if board.wall_at(x, y, Direction::Left) {
165                lattice[ly][lx - 1] = '|';
166            }
167            if board.wall_at(x, y, Direction::Down) {
168                lattice[ly + 1][lx] = '-';
169            }
170            if board.wall_at(x, y, Direction::Right) {
171                lattice[ly][lx + 1] = '|';
172            }
173        }
174        for row in lattice {
175            let line: String = row.into_iter().collect();
176            let _ = writeln!(out, "{}", line.trim_end());
177        }
178        out
179    }
180}
181
182/// What a level with nothing to say about itself must be: castles for two
183/// seats or more is a beach nobody plays alone. Only reached for text
184/// written before the format carried a `kind:` line, and for boards handed
185/// straight to [`Level::from_board`] (a recorded versus round, which is an
186/// arena, and an editor snapshot, whose kind the editor then states).
187fn inferred_kind(level: &Level) -> LevelKind {
188    match level.seats() >= 2 {
189        true => LevelKind::Arena,
190        false => LevelKind::Puzzle,
191    }
192}
193
194fn parse_xy(s: &str) -> Result<(u8, u8, &str), String> {
195    let (xy, rest) = match s.split_once(char::is_whitespace) {
196        Some((xy, rest)) => (xy, rest),
197        None => (s, ""),
198    };
199    let (x, y) = xy
200        .split_once(',')
201        .ok_or_else(|| format!("expected x,y in {s:?}"))?;
202    let x = x.trim().parse::<u8>().map_err(|e| format!("x: {e}"))?;
203    let y = y.trim().parse::<u8>().map_err(|e| format!("y: {e}"))?;
204    Ok((x, y, rest))
205}
206
207/// Everything the `key: value` header section declares, gathered before
208/// the map lattice is read.
209struct Header {
210    name: Option<String>,
211    posts: Option<u8>,
212    solution: Vec<Placement>,
213    crabs: Vec<(u8, u8, Direction, Handedness, CrabKind)>,
214    spawners: Vec<(u8, u8, Direction, u32)>,
215    gulls: Vec<(u8, u8, Direction)>,
216    gull_period: u32,
217    events: bool,
218    round: Option<u32>,
219    seed: u64,
220    rule: Option<(u8, CapPolicy)>,
221    scores: Vec<(u8, u32)>,
222    goal: Goal,
223    wrap: bool,
224    /// `None` when the file predates the editor's puzzle/arena toggle.
225    kind: Option<LevelKind>,
226    /// Inverted like the snapshot's, because raids are the one board
227    /// switch that is on by default: a file that says nothing keeps them,
228    /// and only `raids: off` turns them away. A puzzle with no `rule:` of
229    /// its own turns them off regardless, in [`Level::board`].
230    no_castle_raids: bool,
231}
232
233impl Default for Header {
234    fn default() -> Header {
235        Header {
236            name: None,
237            posts: None,
238            solution: Vec::new(),
239            crabs: Vec::new(),
240            spawners: Vec::new(),
241            gulls: Vec::new(),
242            gull_period: 0,
243            events: false,
244            round: None,
245            // An arbitrary but fixed default, so a level file without a
246            // `seed:` still replays identically everywhere.
247            seed: 0x7ead_0001,
248            rule: None,
249            scores: Vec::new(),
250            goal: Goal::AllCrabs,
251            wrap: false,
252            kind: None,
253            no_castle_raids: false,
254        }
255    }
256}
257
258/// What one header line did: carried a value, or ended the header.
259enum HeaderLine {
260    Read,
261    /// `map:`, after which the lattice starts on the next line.
262    MapFollows,
263}
264
265impl Header {
266    /// Fold one `key: value` line in. Each key parses its own value and
267    /// says so in its own error message, which is the sort of thing a
268    /// hand-edited level file needs to hear.
269    fn read_line(&mut self, key: &str, value: &str) -> Result<HeaderLine, String> {
270        match key {
271            "name" => self.name = Some(value.to_string()),
272            "posts" => {
273                self.posts = Some(value.parse::<u8>().map_err(|e| format!("posts: {e}"))?);
274            }
275            "crab" => {
276                let (x, y, rest) = parse_xy(value)?;
277                let mut parts = rest.split_whitespace();
278                let dir = parse_dir(parts.next().ok_or("crab: missing direction")?)?;
279                let handed = parts.next().ok_or("crab: missing handedness")?;
280                let handed = Handedness::from_token(handed)
281                    .ok_or_else(|| format!("crab: bad handedness {handed:?}"))?;
282                let kind = parts.next().ok_or("crab: missing kind")?;
283                let kind =
284                    CrabKind::from_token(kind).ok_or_else(|| format!("crab: bad kind {kind:?}"))?;
285                self.crabs.push((x, y, dir, handed, kind));
286            }
287            "spawner" => {
288                let (x, y, rest) = parse_xy(value)?;
289                let mut parts = rest.split_whitespace();
290                let dir = parse_dir(parts.next().ok_or("spawner: missing direction")?)?;
291                let period = parts
292                    .next()
293                    .ok_or("spawner: missing period")?
294                    .parse::<u32>()
295                    .map_err(|e| format!("spawner period: {e}"))?;
296                self.spawners.push((x, y, dir, period));
297            }
298            "gull" => {
299                let (x, y, rest) = parse_xy(value)?;
300                let dir = parse_dir(rest.trim())?;
301                self.gulls.push((x, y, dir));
302            }
303            "gull_period" => {
304                self.gull_period = value
305                    .parse::<u32>()
306                    .map_err(|e| format!("gull_period: {e}"))?;
307            }
308            "round" => {
309                self.round = Some(value.parse::<u32>().map_err(|e| format!("round: {e}"))?);
310            }
311            "seed" => {
312                self.seed = value.parse::<u64>().map_err(|e| format!("seed: {e}"))?;
313            }
314            "goal" => {
315                self.goal = match value.split_once(' ') {
316                    Some(("bank", n)) => {
317                        Goal::Bank(n.trim().parse().map_err(|e| format!("goal bank: {e}"))?)
318                    }
319                    None if value == "survive" => Goal::Survive,
320                    None if value == "golden" => Goal::Golden,
321                    None if value == "all" => Goal::AllCrabs,
322                    _ => return Err(format!("goal: bad value {value:?}")),
323                };
324            }
325            "raids" => self.no_castle_raids = value == "off",
326            "kind" => {
327                self.kind = Some(
328                    LevelKind::from_token(value)
329                        .ok_or_else(|| format!("kind: bad value {value:?}"))?,
330                );
331            }
332            "wrap" => self.wrap = value == "on",
333            "events" => self.events = value == "on",
334            "rule" => {
335                let (policy, cap) = value
336                    .split_once(' ')
337                    .ok_or("rule: expected `<evict|reject> <cap>`")?;
338                let cap = cap
339                    .trim()
340                    .parse::<u8>()
341                    .map_err(|e| format!("rule cap: {e}"))?;
342                let policy = policy.trim();
343                let policy = CapPolicy::from_token(policy)
344                    .ok_or_else(|| format!("rule: bad policy {policy:?}"))?;
345                // Evicting needs something to evict: at a cap of zero the
346                // first placement would look for an oldest post that is
347                // not there. Reject at zero is a puzzle with no posts.
348                if cap == 0 && policy == CapPolicy::Evict {
349                    return Err("rule: evict needs a cap of at least 1".to_string());
350                }
351                self.rule = Some((cap, policy));
352            }
353            "score" => {
354                let (player, score) = value
355                    .split_once(' ')
356                    .ok_or("score: expected `<player> <score>`")?;
357                let player = player
358                    .trim()
359                    .parse::<u8>()
360                    .map_err(|e| format!("score: {e}"))?;
361                let score = score
362                    .trim()
363                    .parse::<u32>()
364                    .map_err(|e| format!("score: {e}"))?;
365                if crate::sim::board::seat(player).is_none() {
366                    return Err(format!("score: player {player} out of range"));
367                }
368                self.scores.push((player, score));
369            }
370            "solution" => {
371                for placement in value.split(';') {
372                    let (x, y, rest) = parse_xy(placement.trim())?;
373                    let dir = parse_dir(rest.trim())?;
374                    self.solution.push((x, y, dir));
375                }
376            }
377            "map" => return Ok(HeaderLine::MapFollows),
378            other => return Err(format!("unknown key {other:?}")),
379        }
380        Ok(HeaderLine::Read)
381    }
382}
383
384/// Stage 1: the `key: value` lines up to (and consuming) `map:`.
385fn parse_header(lines: &mut std::str::Lines) -> Result<Header, String> {
386    let mut header = Header::default();
387    for line in lines.by_ref() {
388        let line = line.trim();
389        // Comments are only possible before the map (map rows use '#').
390        if line.is_empty() || line.starts_with('#') {
391            continue;
392        }
393        let Some((key, value)) = line.split_once(':') else {
394            return Err(format!("expected `key: value` before map, got {line:?}"));
395        };
396        if let HeaderLine::MapFollows = header.read_line(key.trim(), value.trim())? {
397            break;
398        }
399    }
400    Ok(header)
401}
402
403/// Stage 2: the odd-sized wall lattice into a board of tiles and walls.
404fn parse_lattice<'a>(lines: impl Iterator<Item = &'a str>, seed: u64) -> Result<Board, String> {
405    let lattice: Vec<&str> = lines.filter(|l: &&str| !l.trim().is_empty()).collect();
406    if lattice.is_empty() {
407        return Err("missing map section".into());
408    }
409    let lat_h = lattice.len();
410    let lat_w = lattice[0].chars().count();
411    if lat_h.is_multiple_of(2) || lat_w.is_multiple_of(2) {
412        return Err(format!(
413            "map lattice must be odd-sized, got {lat_w}×{lat_h}"
414        ));
415    }
416    // A lattice spends `2n + 1` lines on `n` tiles, so three is the
417    // narrowest that holds one and `2·255 + 1` the widest a `u8` dimension
418    // can name. Odd-sized alone let both ends through: a lone border line
419    // made a zero-sized board, which `Board::new` refuses with a panic, and
420    // an over-wide one wrapped the cast: a 300-tile beach loading silently
421    // as 44, which is worse than any rejection.
422    if lat_w < 3 || lat_h < 3 {
423        return Err(format!(
424            "map lattice must hold at least one tile, got {lat_w}×{lat_h}"
425        ));
426    }
427    let (tiles_w, tiles_h) = (lat_w / 2, lat_h / 2);
428    let (Ok(width), Ok(height)) = (u8::try_from(tiles_w), u8::try_from(tiles_h)) else {
429        return Err(format!(
430            "map is {tiles_w}×{tiles_h} tiles, past the {} a side the format can name",
431            u8::MAX
432        ));
433    };
434    // A row wider than the top border is a different thing: nothing trims
435    // a line longer, so it is a map drawn wrong, and reading it to the
436    // border's width would drop its last tiles and walls without a word.
437    if let Some((row, width)) = lattice
438        .iter()
439        .map(|row| row.chars().count())
440        .enumerate()
441        .find(|(_, width)| *width > lat_w)
442    {
443        return Err(format!(
444            "map row {row} is {width} wide, past the top border's {lat_w}"
445        ));
446    }
447    // Short rows pad with spaces rather than erroring: text editors trim
448    // trailing whitespace, and wrap-map rows legitimately end in spaces
449    // (open edges) - to_text itself emits them trimmed.
450    let grid: Vec<Vec<char>> = lattice
451        .iter()
452        .map(|row| {
453            let mut chars: Vec<char> = row.chars().collect();
454            chars.resize(lat_w, ' ');
455            chars
456        })
457        .collect();
458
459    // The seed matters only where the PRNG is consumed (spawner and gull
460    // handedness, takeoff timers); serialized levels carry it so replays
461    // rebuild bit-identical boards.
462    // Both were just checked against the lattice; `Board::new` panics on a
463    // zero, which is how a lone border line once took the game down.
464    debug_assert!(width > 0 && height > 0, "a {width}x{height} board");
465    let mut board = Board::new(width, height, seed);
466    for y in 0..height {
467        for x in 0..width {
468            let c = grid[y as usize * 2 + 1][x as usize * 2 + 1];
469            match tile_from_glyph(c) {
470                Some(TileKind::Empty) => {}
471                Some(tile) => board.set_tile(x, y, tile),
472                None => return Err(format!("bad tile char {c:?} at ({x},{y})")),
473            }
474            // Up edge of (x, y) is lattice row 2y, column 2x+1.
475            if grid[y as usize * 2][x as usize * 2 + 1] == '-' {
476                board.set_wall(x, y, Direction::Up, true);
477            }
478            // Left edge is lattice row 2y+1, column 2x.
479            if grid[y as usize * 2 + 1][x as usize * 2] == '|' {
480                board.set_wall(x, y, Direction::Left, true);
481            }
482        }
483    }
484    // Bottom and right borders of the lattice.
485    for x in 0..width {
486        if grid[height as usize * 2][x as usize * 2 + 1] == '-' {
487            board.set_wall(x, height - 1, Direction::Down, true);
488        }
489    }
490    for y in 0..height {
491        if grid[y as usize * 2 + 1][width as usize * 2] == '|' {
492            board.set_wall(width - 1, y, Direction::Right, true);
493        }
494    }
495    Ok(board)
496}
497
498/// Stage 3: header-declared spawners, creatures, and board switches, with
499/// bounds validation against the lattice-derived size.
500fn place_entities(board: &mut Board, header: &Header) -> Result<(), String> {
501    let (width, height) = (board.width(), board.height());
502    let check = |what: &str, x: u8, y: u8| -> Result<(), String> {
503        if x >= width || y >= height {
504            return Err(format!(
505                "{what} at ({x},{y}) is off the {width}x{height} board"
506            ));
507        }
508        Ok(())
509    };
510    for &(x, y, dir, period) in &header.spawners {
511        check("spawner", x, y)?;
512        if period == 0 {
513            return Err(format!("spawner at ({x},{y}): period must be at least 1"));
514        }
515        board.set_tile(x, y, TileKind::Spawner(Spawner { dir, period }));
516    }
517    for &(x, y, dir, handed, kind) in &header.crabs {
518        check("crab", x, y)?;
519        if board.tile_at(x, y) == TileKind::Rock {
520            return Err(format!("crab at ({x},{y}) is standing on a rock"));
521        }
522        board.spawn_crab(x, y, dir, handed, kind);
523    }
524    for &(x, y, dir) in &header.gulls {
525        check("gull", x, y)?;
526        if !board.gull_may_stand(x, y) {
527            return Err(format!(
528                "gull at ({x},{y}) is standing on a rock or in kelp"
529            ));
530        }
531        board.spawn_gull(x, y, dir);
532    }
533    board.set_gull_period(header.gull_period);
534    board.set_round_length(header.round);
535    if header.no_castle_raids {
536        board.set_castle_raids(false);
537    }
538    if header.wrap {
539        board.set_wrap(true);
540    }
541    if header.events {
542        board.set_events_enabled(true);
543    }
544    if let Some((cap, policy)) = header.rule {
545        board.set_signpost_rule(cap, policy);
546    }
547    for &(player, score) in &header.scores {
548        board.set_score(player, score);
549    }
550    Ok(())
551}
552
553/// The map glyph for a tile; `tile_from_glyph` is its inverse (spawners
554/// serialize in the header, so they read back as sand here).
555fn tile_glyph(tile: TileKind) -> char {
556    match tile {
557        TileKind::Empty | TileKind::Spawner(_) => '.',
558        TileKind::Rock => '#',
559        TileKind::Castle(owner) => (b'0' + owner) as char,
560        // Upper case is a log about to deflect right, lower case one about to
561        // deflect left. The pivot has to survive: generated arenas mirror
562        // their logs (a reflection swaps left and right), and a replay stores
563        // its starting board *as* a level.
564        TileKind::Turnstile { next_right: true } => 'T',
565        TileKind::Turnstile { next_right: false } => 't',
566        TileKind::Kelp => 'K',
567        TileKind::Pool => '~',
568    }
569}
570
571fn tile_from_glyph(c: char) -> Option<TileKind> {
572    match c {
573        '.' | ' ' => Some(TileKind::Empty),
574        '#' => Some(TileKind::Rock),
575        'T' => Some(TileKind::Turnstile { next_right: true }),
576        't' => Some(TileKind::Turnstile { next_right: false }),
577        'K' => Some(TileKind::Kelp),
578        '~' => Some(TileKind::Pool),
579        // As many seats as the sim has, not the four it had when the glyphs
580        // were chosen: a six-castle board writes '4' and '5', and a reader
581        // that rejects them turns every six-player replay into a parse error.
582        '0'..='9' if crate::sim::board::seat(c as u8 - b'0').is_some() => {
583            Some(TileKind::Castle((c as u8 - b'0') as PlayerId))
584        }
585        _ => None,
586    }
587}
588
589fn parse_dir(s: &str) -> Result<Direction, String> {
590    Direction::from_letter(s).ok_or_else(|| format!("bad direction {s:?}"))
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    /// A 2x2 board, sand except for a rock at (1,1), with the given header
598    /// lines in front of it.
599    fn level_with(header: &str) -> Result<Level, String> {
600        let text =
601            format!("name: T\nposts: 1\n{header}\nmap:\n+-+-+\n|.|.|\n+-+-+\n|.|#|\n+-+-+\n");
602        Level::parse(&text)
603    }
604
605    /// Placement is the last thing `parse` checks, and each refusal has to
606    /// name its reason: these files are hand-edited and pasted as codes,
607    /// and "invalid level" tells the author nothing. A spawner with period
608    /// 0 would divide the sim's tick by zero; a crab on a rock is standing
609    /// where nothing walks; an entity off the board would index past the
610    /// tile vector, which `spawn_crab` meets with a panic.
611    #[test]
612    fn placing_an_entity_where_it_cannot_go_is_refused_with_the_reason() {
613        let err = level_with("spawner: 0,0 R 0").unwrap_err();
614        assert!(err.contains("period must be at least 1"), "{err}");
615        let err = level_with("rule: evict 0").unwrap_err();
616        assert!(err.contains("at least 1"), "{err}");
617        assert!(
618            level_with("rule: reject 0").is_ok(),
619            "no posts to give is a legal puzzle"
620        );
621        let err = level_with("crab: 1,1 R L common").unwrap_err();
622        assert!(err.contains("standing on a rock"), "{err}");
623        let err = level_with("gull: 1,1 R").unwrap_err();
624        assert!(err.contains("standing on a rock"), "{err}");
625        // Kelp is the other tile a gull cannot stand in, and the sim
626        // asserts on it just the same: a refusal here, not a panic.
627        let kelp = "name: T\nposts: 1\ngull: 1,1 R\nmap:\n+-+-+\n|.|.|\n+-+-+\n|.|K|\n+-+-+\n";
628        let err = Level::parse(kelp).unwrap_err();
629        assert!(err.contains("in kelp"), "{err}");
630        for line in ["spawner: 2,0 R 4", "crab: 0,2 R L common", "gull: 5,5 D"] {
631            let err = level_with(line).unwrap_err();
632            assert!(err.contains("off the 2x2 board"), "{line}: {err}");
633        }
634        // And the same lines with their coordinates fixed are placed.
635        let level = level_with("spawner: 0,0 R 4\ncrab: 1,0 R L common\ngull: 0,1 R")
636            .expect("a valid placement");
637        assert_eq!(level.board.crabs().len(), 1);
638        assert_eq!(level.board.gulls().len(), 1);
639        assert!(matches!(level.board.tile_at(0, 0), TileKind::Spawner(_)));
640    }
641}