Skip to main content

pinch_points/sim/level/
mod.rs

1//! Text level format and puzzle rules (spec §5.1).
2//!
3//! A level file has key/value header lines, then a `map:` section, e.g.:
4//!
5//! ```text
6//! name: First Steps
7//! posts: 1
8//! crab: 0,1 R L common
9//! solution: 3,1 U
10//! map:
11//! +-+-+-+-+
12//! |. . . 0|
13//! + +-+ + +
14//! |. .|. .|
15//! +-+-+-+-+
16//! ```
17//!
18//! The map is a half-resolution lattice: tile `(x, y)` sits at lattice
19//! `(2x+1, 2y+1)`; the character between two tiles is their shared wall
20//! (`-`/`|` wall, anything else open). Tile characters: `.` sand, `#` rock,
21//! `0`–`3` castle of that player. Crabs and spawners are header lines, not
22//! map characters, because they carry more data than one character holds:
23//! `crab: x,y DIR HAND KIND` and `spawner: x,y DIR period`.
24//!
25//! Puzzles are played with a fixed signpost inventory (`posts:`) under
26//! `CapPolicy::Reject`, and won when every crab has banked. `solution:` lines
27//! are machine-checked by tests, so a level that ships is a level that is
28//! solvable.
29//!
30//! `kind: puzzle|arena` says which of the two a level is (see [`LevelKind`]),
31//! and so which list it joins. Files written before the editor had that
32//! toggle leave it out, and are read off their castles.
33
34mod format;
35
36use crate::sim::board::{Board, CapPolicy};
37use crate::sim::solve::Placement;
38
39/// Sim ticks a puzzle may run before it counts as failed (60 s at 30 Hz).
40/// Generous: it exists to catch crabs orbiting forever, not to add pressure.
41pub const PUZZLE_TICK_LIMIT: u64 = 60 * crate::sim::TICKS_PER_SECOND as u64;
42
43#[derive(Clone, Debug)]
44pub struct Level {
45    pub name: String,
46    /// Signpost inventory for the puzzle.
47    pub posts: u8,
48    /// One known solution; validated by tests.
49    pub solution: Vec<Placement>,
50    pub goal: Goal,
51    /// What the level is for: a stage to solve alone, or a beach to fight
52    /// over. The author chooses it in the editor and it decides which of
53    /// the two lists the level joins.
54    pub kind: LevelKind,
55    board: Board,
56    crab_count: u32,
57    /// True when the level text carried an explicit `rule:` line (versus
58    /// replays); otherwise `board()` applies puzzle rules from `posts`.
59    explicit_rule: bool,
60}
61
62/// What a level was built to be.
63///
64/// The two want opposite things of a beach: a puzzle wants one bank and a
65/// route to it, an arena wants a castle per seat and crabs arriving forever.
66/// A file that does not say which it is has to be guessed at, and guessing
67/// put a versus beach in the middle of the campaign and kept a spawner-fed
68/// arena off the map dial for want of a starting crab.
69#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
70pub enum LevelKind {
71    /// A stage for one player: route every crab home with the signposts the
72    /// level grants. Joins the Tide Pool list.
73    #[default]
74    Puzzle,
75    /// A beach for a match: a castle for each seat. Joins the map dial in
76    /// Turf War and the lobby.
77    Arena,
78}
79
80impl LevelKind {
81    pub fn token(self) -> &'static str {
82        match self {
83            LevelKind::Puzzle => "puzzle",
84            LevelKind::Arena => "arena",
85        }
86    }
87
88    pub fn from_token(token: &str) -> Option<LevelKind> {
89        match token {
90            "puzzle" => Some(LevelKind::Puzzle),
91            "arena" => Some(LevelKind::Arena),
92            _ => None,
93        }
94    }
95}
96
97/// What a stage asks of the player. `AllCrabs` is classic puzzle mode; the
98/// rest are Beach Day challenge goals (the original's Stage Challenge,
99/// re-themed).
100#[derive(Clone, Copy, PartialEq, Eq, Debug)]
101pub enum Goal {
102    /// Every crab that ever exists must bank (spec §5.1).
103    AllCrabs,
104    /// Bank at least this many crabs before the tide.
105    Bank(u32),
106    /// No crab may be eaten before the tide comes in.
107    Survive,
108    /// Bank a golden crab before the tide.
109    Golden,
110}
111
112#[derive(Clone, Copy, PartialEq, Eq, Debug)]
113pub enum PuzzleOutcome {
114    Running,
115    Won,
116    /// Some crab is still loose at the tick limit.
117    Lost,
118}
119
120impl Level {
121    /// A fresh board for this level. Puzzle levels get fixed-inventory rules
122    /// from `posts`; levels carrying an explicit `rule:` (recorded versus
123    /// matches) keep the rule the match was played under.
124    ///
125    /// An arena that states no rule keeps the board's own, which is the
126    /// versus one. Reading `posts:` as a rule there gave a table three
127    /// signposts each and no way to replace them - the file says how many
128    /// the *author* had to hand, not how a match is played on it.
129    pub fn board(&self) -> Board {
130        let mut board = self.board.clone();
131        if !self.explicit_rule && self.kind == LevelKind::Puzzle {
132            board.set_signpost_rule(self.posts, CapPolicy::Reject);
133            // A campaign puzzle's castle is the finish line, not a bank to
134            // be robbed: see `Board::castle_raids`. Beach Day states `rule:`
135            // and keeps its raids - there the goal is a number banked and a
136            // robbery is drama, not a target that moves.
137            board.set_castle_raids(false);
138        }
139        board
140    }
141
142    pub fn crab_count(&self) -> u32 {
143        self.crab_count
144    }
145
146    /// Say what the level is for. The editor's toggle is authoritative: a
147    /// level saved as a puzzle stays one however many castles it grew.
148    pub fn with_kind(mut self, kind: LevelKind) -> Level {
149        self.kind = kind;
150        self
151    }
152
153    /// How many seats the beach could hold: one castle each, counted by
154    /// owner. What the map dial checks a handmade beach against, and what
155    /// tells a level with no `kind:` line of its own what it must be.
156    pub fn seats(&self) -> u8 {
157        self.board.castle_seats()
158    }
159
160    /// Run `board` idle until the goal is decided, and say when it was.
161    ///
162    /// Every caller that wants to know how a board ends wants exactly this
163    /// loop, and ten of them once carried their own copy. [`Level::outcome`]
164    /// is what makes it terminate: every goal loses or wins by the board's
165    /// [`Level::deadline`], so the loop needs no guard of its own, and if
166    /// that ever moves there is one place to put it.
167    pub fn play_out(&self, board: &mut Board) -> (PuzzleOutcome, u64) {
168        loop {
169            board.tick_idle();
170            match self.outcome(board) {
171                PuzzleOutcome::Running => {}
172                done @ (PuzzleOutcome::Won | PuzzleOutcome::Lost) => return (done, board.ticks()),
173            }
174        }
175    }
176
177    /// Place the authored solution on `board` for seat 0. `Err` names the
178    /// first placement the board refused, which on a shipped level is a
179    /// broken level file and never something to shrug off.
180    pub fn place_solution(&self, board: &mut Board) -> Result<(), Placement> {
181        for &(x, y, dir) in &self.solution {
182            if !board.place_signpost(0, x, y, dir) {
183                return Err((x, y, dir));
184            }
185        }
186        Ok(())
187    }
188
189    /// A fresh board with the authored solution standing on it.
190    pub fn solved_board(&self) -> Result<Board, Placement> {
191        let mut board = self.board();
192        self.place_solution(&mut board)?;
193        Ok(board)
194    }
195
196    /// The tick by which every goal on `board` is decided: its own round
197    /// when it carries one, else [`PUZZLE_TICK_LIMIT`], which stands in for
198    /// a stage with no timer, which would otherwise never end. One answer
199    /// for the outcome, the solver's scan and the clock, so a stage that
200    /// states a longer round is not cut off at the limit anyway.
201    pub fn deadline(board: &Board) -> u64 {
202        board.round_length().map_or(PUZZLE_TICK_LIMIT, u64::from)
203    }
204
205    /// Win/loss state of a board created by [`Level::board`], judged by the
206    /// level's goal. All goals use banked-count accounting (spec §5.1: a
207    /// crab a gull ate is a loss for AllCrabs/Survive, never a quiet
208    /// disappearance). [`Level::deadline`] is the clock it is judged on.
209    pub fn outcome(&self, board: &Board) -> PuzzleOutcome {
210        let alive = board.crabs().len() as u32;
211        let eaten = board.crabs_banked() + alive < board.crabs_spawned();
212        let timed_out = board.ticks() >= Self::deadline(board);
213        // What each goal counts as already lost, and as already won. Losing
214        // is checked first: a goal met on the same tick a crab was eaten does
215        // not save an AllCrabs or Survive stage.
216        let (lost, won) = match self.goal {
217            Goal::AllCrabs => (
218                eaten,
219                alive == 0 && board.crabs_banked() == board.crabs_spawned(),
220            ),
221            Goal::Bank(n) => (false, board.crabs_banked() >= n),
222            // Survive is the one goal the clock is *for*: running it out is
223            // the win (see `deadline` for which clock).
224            Goal::Survive => (eaten, timed_out),
225            Goal::Golden => (false, board.golden_banked() >= 1),
226        };
227        if lost {
228            PuzzleOutcome::Lost
229        } else if won {
230            PuzzleOutcome::Won
231        } else if timed_out {
232            // Every other goal loses when the tide beats it to the finish.
233            PuzzleOutcome::Lost
234        } else {
235            PuzzleOutcome::Running
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use crate::sim::direction::Direction;
243
244    #[test]
245    fn parse_rejects_bad_tile_chars() {
246        let text = "name: Bad\nposts: 1\nmap:\n+-+\n|X|\n+-+\n";
247        let err = super::Level::parse(text).unwrap_err();
248        assert!(err.contains("bad tile char"), "{err}");
249    }
250
251    #[test]
252    fn parse_rejects_unknown_keys() {
253        let text = "name: Bad\nwibble: 3\nmap:\n+-+\n|.|\n+-+\n";
254        let err = super::Level::parse(text).unwrap_err();
255        assert!(err.contains("unknown key"), "{err}");
256    }
257
258    #[test]
259    fn parse_rejects_malformed_solutions_and_directions() {
260        let text = "name: Bad\nposts: 1\nsolution: 0,0 Q\nmap:\n+-+\n|.|\n+-+\n";
261        assert!(super::Level::parse(text).is_err());
262        let text = "name: Bad\nposts: 1\nsolution: zero,0 U\nmap:\n+-+\n|.|\n+-+\n";
263        assert!(super::Level::parse(text).is_err());
264    }
265
266    /// A degenerate lattice is refused, not asserted against. `parse` is
267    /// fallible because it reads text a stranger wrote (a pasted level
268    /// code, a hand-edited custom level) and both callers handle the
269    /// error. Odd-sized alone let a lone border line through as a
270    /// zero-sized board, which `Board::new` met with a panic.
271    #[test]
272    fn parse_rejects_a_map_with_no_tiles() {
273        for text in [
274            "name: Bad\nposts: 1\nmap:\n+-+-+\n",   // one row of border
275            "name: Bad\nposts: 1\nmap:\n+\n|\n+\n", // one column of border
276            "name: Bad\nposts: 1\nmap:\n+\n",
277        ] {
278            let err = super::Level::parse(text).unwrap_err();
279            assert!(err.contains("at least one tile"), "{text:?} gave {err}");
280        }
281    }
282
283    /// A board dimension is a `u8`, and the cast to one used to wrap: a
284    /// 300-tile lattice loaded as a 44-tile beach, silently not the level
285    /// the text described.
286    #[test]
287    fn parse_rejects_a_map_too_wide_to_name() {
288        let border: String = "+-".repeat(300) + "+";
289        let row: String = "|.".repeat(300) + "|";
290        let text = format!("name: Vast\nposts: 1\nmap:\n{border}\n{row}\n{border}\n");
291        let err = super::Level::parse(&text).unwrap_err();
292        assert!(err.contains("300"), "{err}");
293        // And the widest board that *does* fit still parses.
294        let border: String = "+-".repeat(255) + "+";
295        let row: String = "|.".repeat(255) + "|";
296        let text = format!("name: Wide\nposts: 1\nmap:\n{border}\n{row}\n{border}\n");
297        let level = super::Level::parse(&text).expect("255 tiles is nameable");
298        assert_eq!(level.board.width(), 255);
299    }
300
301    /// Short rows pad with spaces by design: editors trim trailing
302    /// whitespace, and wrap maps end rows with spaces (open edges), so a
303    /// strict width check would reject to_text's own trimmed output.
304    #[test]
305    fn parse_pads_short_map_rows() {
306        let text = "name: Trimmed\nposts: 1\nmap:\n+-+-+\n|.\n+-+-+\n";
307        let level = super::Level::parse(text).expect("pads, never panics");
308        assert_eq!(level.board.width(), 2);
309    }
310
311    use super::*;
312    use crate::sim::board::TileKind;
313
314    /// A turnstile's pivot survives the format: a replay stores its starting
315    /// board as a level, and generated arenas mirror their logs, so a dropped
316    /// pivot would flip half of them on every recorded round.
317    /// A row wider than the top border is a map drawn wrong, not one to
318    /// read to the border's width: that dropped the row's last tiles and
319    /// walls without a word.
320    #[test]
321    fn parse_refuses_a_map_row_wider_than_its_border() {
322        let text = "name: T\nposts: 1\nmap:\n+-+-+\n|.|.|.|\n+-+-+\n";
323        let err = Level::parse(text).unwrap_err();
324        assert!(err.contains("row 1 is 7 wide"), "{err}");
325    }
326
327    /// Castle raids travel with the level. A puzzle with no `rule:` of its
328    /// own turns them off in `board()` regardless; one that states its
329    /// rule, as every editor-made puzzle does, keeps whatever the file
330    /// says, and the file says `raids: off` when they are.
331    #[test]
332    fn castle_raids_survive_the_format() {
333        let map = "map:\n+-+-+\n|. 0|\n+ + +\n|. .|\n+-+-+\n";
334        let head = "name: T\nposts: 1\nrule: reject 1\ncrab: 0,0 R R common\n";
335        let kept = Level::parse(&format!("{head}{map}")).expect("level");
336        assert!(kept.board().castle_raids(), "stated rule, raids kept");
337        assert!(
338            !kept.to_text().contains("raids:"),
339            "the default is not written"
340        );
341        let off = Level::parse(&format!("{head}raids: off\n{map}")).expect("level");
342        assert!(!off.board().castle_raids());
343        let text = off.to_text();
344        assert!(text.contains("raids: off"), "{text}");
345        let back = Level::parse(&text).expect("round trip");
346        assert!(!back.board().castle_raids());
347    }
348
349    /// A stage that states a round longer than the campaign tick limit is
350    /// judged on its round: the limit stands in only for a stage with no
351    /// timer. A Survive stage with ninety seconds on the clock used to be
352    /// declared won at sixty.
353    #[test]
354    fn a_stated_round_outlasts_the_tick_limit() {
355        let text = "name: T\nposts: 0\ngoal: survive\nround: 2700\nmap:\n\
356                    +-+-+\n|. .|\n+ + +\n|. .|\n+-+-+\n";
357        let level = Level::parse(text).expect("level");
358        let mut board = level.board();
359        assert_eq!(Level::deadline(&board), 2700);
360        for _ in 0..PUZZLE_TICK_LIMIT {
361            board.tick_idle();
362        }
363        assert_eq!(
364            level.outcome(&board),
365            PuzzleOutcome::Running,
366            "the limit is not the round"
367        );
368        let (outcome, at) = level.play_out(&mut board);
369        assert_eq!((outcome, at), (PuzzleOutcome::Won, 2700));
370        // And without a round the limit is the deadline, as before.
371        let untimed = Level::parse(&text.replace("round: 2700\n", "")).expect("level");
372        assert_eq!(Level::deadline(&untimed.board()), PUZZLE_TICK_LIMIT);
373    }
374
375    /// Every goal but Survive loses when the deadline beats it: the
376    /// board's round when it states one, the campaign limit when it does
377    /// not. A crab circling a castle-less beach is never banked and never
378    /// eaten, so only the clock can end it.
379    #[test]
380    fn the_deadline_loses_every_goal_but_survive() {
381        let map = "map:\n+-+-+\n|. .|\n+ + +\n|. .|\n+-+-+\n";
382        let timed = Level::parse(&format!(
383            "name: T\nposts: 0\nround: 300\ncrab: 0,0 R R common\n{map}"
384        ))
385        .expect("level");
386        let mut board = timed.board();
387        assert_eq!(timed.play_out(&mut board), (PuzzleOutcome::Lost, 300));
388        let untimed = Level::parse(&format!("name: T\nposts: 0\ncrab: 0,0 R R common\n{map}"))
389            .expect("level");
390        let mut board = untimed.board();
391        assert_eq!(
392            untimed.play_out(&mut board),
393            (PuzzleOutcome::Lost, PUZZLE_TICK_LIMIT)
394        );
395    }
396
397    /// `raids: on` is the default said out loud, and is read as such: only
398    /// `off` turns them away, and a stated rule keeps whatever the file
399    /// says.
400    #[test]
401    fn raids_on_is_the_default_said_out_loud() {
402        let text = "name: T\nposts: 1\nrule: reject 1\nraids: on\ncrab: 0,0 R R common\nmap:\n\
403                    +-+-+\n|. 0|\n+ + +\n|. .|\n+-+-+\n";
404        let level = Level::parse(text).expect("level");
405        assert!(level.board().castle_raids());
406        assert!(!level.to_text().contains("raids:"), "not worth writing");
407    }
408
409    #[test]
410    fn a_turnstiles_pivot_survives_the_format() {
411        for next_right in [true, false] {
412            let mut board = Board::new(4, 3, 1);
413            board.set_tile(1, 1, TileKind::Turnstile { next_right });
414            let text = Level::from_board("Pivot", 1, board).to_text();
415            let parsed = Level::parse(&text).expect("own output").board();
416            assert_eq!(
417                parsed.tile_at(1, 1),
418                TileKind::Turnstile { next_right },
419                "next_right {next_right} was not preserved:\n{text}"
420            );
421        }
422    }
423
424    const TINY: &str = "\
425name: Tiny
426posts: 1
427crab: 0,0 R L common
428solution: 2,0 D
429map:
430+-+-+-+
431|. . .|
432+ +-+ +
433|. . 0|
434+-+-+-+
435";
436
437    #[test]
438    fn terrain_tiles_round_trip() {
439        let mut board = Board::new(4, 3, 7);
440        board.set_tile(1, 1, TileKind::Turnstile { next_right: true });
441        board.set_tile(2, 1, TileKind::Kelp);
442        board.set_tile(3, 1, TileKind::Pool);
443        let text = Level::from_board("Terrain", 2, board).to_text();
444        let level = Level::parse(&text).expect("parses");
445        assert_eq!(
446            level.board.tile_at(1, 1),
447            TileKind::Turnstile { next_right: true }
448        );
449        assert_eq!(level.board.tile_at(2, 1), TileKind::Kelp);
450        assert_eq!(level.board.tile_at(3, 1), TileKind::Pool);
451        assert_eq!(level.to_text(), text, "text form is stable");
452    }
453
454    #[test]
455    fn parses_dimensions_walls_and_tiles() {
456        let level = Level::parse(TINY).expect("parses");
457        assert_eq!(level.name, "Tiny");
458        assert_eq!(level.posts, 1);
459        assert_eq!(level.crab_count(), 1);
460        let board = level.board();
461        assert_eq!((board.width(), board.height()), (3, 2));
462        assert_eq!(board.tile_at(2, 1), TileKind::Castle(0));
463        assert!(board.wall_at(1, 0, Direction::Down));
464        assert!(!board.wall_at(0, 0, Direction::Down));
465        assert_eq!(board.crabs().len(), 1);
466    }
467
468    #[test]
469    fn solution_wins_the_puzzle() {
470        let level = Level::parse(TINY).expect("parses");
471        let mut board = level.solved_board().expect("every placement lands");
472        assert_eq!(level.play_out(&mut board).0, PuzzleOutcome::Won);
473    }
474
475    #[test]
476    fn serialization_round_trips() {
477        let level = Level::parse(TINY).expect("parses");
478        let text = level.to_text();
479        let again = Level::parse(&text).unwrap_or_else(|e| panic!("reparse: {e}\n{text}"));
480        assert_eq!(level.name, again.name);
481        assert_eq!(level.posts, again.posts);
482        assert_eq!(level.solution, again.solution);
483        assert_eq!(
484            level.board().state_hash(),
485            again.board().state_hash(),
486            "round-tripped board must be bit-identical"
487        );
488    }
489
490    #[test]
491    fn timed_level_loses_at_the_wave() {
492        // A `round:` shorter than the crab's walk: the tide freezes the sim
493        // and the puzzle must resolve to Lost, not sit Running forever.
494        let text = TINY.replace("posts: 1", "posts: 1\nround: 10");
495        let level = Level::parse(&text).expect("parses");
496        let mut board = level.board();
497        for _ in 0..12 {
498            board.tick_idle();
499        }
500        assert!(board.round_over());
501        assert_eq!(level.outcome(&board), PuzzleOutcome::Lost);
502    }
503
504    #[test]
505    fn goal_wrap_and_new_kinds_round_trip() {
506        let text = "\
507name: Fancy
508posts: 2
509rule: evict 3
510round: 900
511wrap: on
512goal: bank 30
513crab: 0,0 R L golden
514crab: 2,1 L R sparkling
515map:
516+-+-+-+-+
517|. . . .|
518+ + + + +
519|. 0 . .|
520+-+-+-+-+
521";
522        let level = Level::parse(text).expect("parses");
523        assert_eq!(level.goal, Goal::Bank(30));
524        assert!(level.board().wrap());
525        let again = Level::parse(&level.to_text()).expect("reparses");
526        assert_eq!(again.goal, level.goal);
527        assert_eq!(
528            again.board().state_hash(),
529            level.board().state_hash(),
530            "wrap/goal/kind round-trip must be bit-identical"
531        );
532    }
533
534    #[test]
535    fn goal_outcomes_judge_correctly() {
536        // Bank goal: reached mid-round.
537        let bank = Level::parse(
538            "name: B\nposts: 0\nrule: evict 3\nround: 900\ngoal: bank 1\n\
539             crab: 0,1 R L common\nmap:\n+-+-+-+\n|. . .|\n+ + + +\n|. . 0|\n+-+-+-+\n",
540        )
541        .expect("parses");
542        let mut board = bank.board();
543        assert_eq!(bank.outcome(&board), PuzzleOutcome::Running);
544        for _ in 0..200 {
545            board.tick_idle();
546        }
547        assert_eq!(bank.outcome(&board), PuzzleOutcome::Won);
548
549        // Survive goal: an eaten crab is an instant loss. The gull starts on
550        // the crab's tile, so the very first collision pass eats it.
551        let survive = Level::parse(
552            "name: S\nposts: 0\nrule: evict 3\nround: 900\ngoal: survive\n\
553             crab: 1,0 R L common\ngull: 1,0 R\nmap:\n+-+-+-+-+-+-+-+-+-+\n\
554             |. . . . . . . . .|\n+-+-+-+-+-+-+-+-+-+\n",
555        )
556        .expect("parses");
557        let mut board = survive.board();
558        board.tick_idle();
559        assert_eq!(
560            survive.outcome(&board),
561            PuzzleOutcome::Lost,
562            "the gull got someone"
563        );
564    }
565
566    /// The kind survives the format both ways round, and is the author's
567    /// answer rather than the board's: a puzzle with four castles saved as
568    /// a puzzle comes back a puzzle.
569    #[test]
570    fn the_kind_round_trips_and_outranks_the_castles() {
571        let level = Level::parse(TINY).expect("parses");
572        assert_eq!(level.kind, LevelKind::Puzzle, "one castle, one player");
573        for kind in [LevelKind::Puzzle, LevelKind::Arena] {
574            let text = level.clone().with_kind(kind).to_text();
575            assert_eq!(Level::parse(&text).expect("reparses").kind, kind, "{text}");
576        }
577        // Four castles and the author still says puzzle.
578        let four = "\
579name: Four
580posts: 1
581kind: puzzle
582crab: 0,0 R L common
583map:
584+-+-+-+-+
585|0 1 2 3|
586+-+-+-+-+
587";
588        let level = Level::parse(four).expect("parses");
589        assert_eq!(level.seats(), 4);
590        assert_eq!(level.kind, LevelKind::Puzzle, "the line beats the board");
591        assert!(Level::parse(&four.replace("kind: puzzle", "kind: mud")).is_err());
592    }
593
594    /// A file written before the editor had a toggle says nothing about
595    /// what it is, so the castles answer for it: one bank is a stage,
596    /// castles for two players is a beach nobody plays alone.
597    #[test]
598    fn a_file_with_no_kind_is_read_off_its_castles() {
599        assert_eq!(Level::parse(TINY).expect("parses").kind, LevelKind::Puzzle);
600        let two = "\
601name: Old Arena
602posts: 3
603crab: 0,0 R L common
604map:
605+-+-+-+
606|0 . 1|
607+-+-+-+
608";
609        let level = Level::parse(two).expect("parses");
610        assert_eq!(level.seats(), 2);
611        assert_eq!(level.kind, LevelKind::Arena);
612        // And once read, it is written down: the guess happens once.
613        assert!(
614            level.to_text().contains("kind: arena"),
615            "{}",
616            level.to_text()
617        );
618    }
619
620    #[test]
621    fn inventory_is_enforced() {
622        let level = Level::parse(TINY).expect("parses");
623        let mut board = level.board();
624        assert!(board.place_signpost(0, 0, 1, Direction::Up));
625        // posts: 1, so the second placement must be rejected, not evict.
626        assert!(!board.place_signpost(0, 1, 1, Direction::Up));
627        assert_eq!(board.signpost_count(0), 1);
628    }
629}