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        }
134        board
135    }
136
137    pub fn crab_count(&self) -> u32 {
138        self.crab_count
139    }
140
141    /// Say what the level is for. The editor's toggle is authoritative: a
142    /// level saved as a puzzle stays one however many castles it grew.
143    pub fn with_kind(mut self, kind: LevelKind) -> Level {
144        self.kind = kind;
145        self
146    }
147
148    /// How many seats the beach could hold: one castle each, counted by
149    /// owner. What the map dial checks a handmade beach against, and what
150    /// tells a level with no `kind:` line of its own what it must be.
151    pub fn seats(&self) -> u8 {
152        self.board.castle_seats()
153    }
154
155    /// Win/loss state of a board created by [`Level::board`], judged by the
156    /// level's goal. All goals use banked-count accounting (spec §5.1: a
157    /// crab a gull ate is a loss for AllCrabs/Survive, never a quiet
158    /// disappearance).
159    pub fn outcome(&self, board: &Board) -> PuzzleOutcome {
160        let alive = board.crabs().len() as u32;
161        let eaten = board.crabs_banked() + alive < board.crabs_spawned();
162        let timed_out = board.round_over() || board.ticks() >= PUZZLE_TICK_LIMIT;
163        // What each goal counts as already lost, and as already won. Losing
164        // is checked first: a goal met on the same tick a crab was eaten does
165        // not save an AllCrabs or Survive stage.
166        let (lost, won) = match self.goal {
167            Goal::AllCrabs => (
168                eaten,
169                alive == 0 && board.crabs_banked() == board.crabs_spawned(),
170            ),
171            Goal::Bank(n) => (false, board.crabs_banked() >= n),
172            // Survive is the one goal the clock is *for*: running it out is
173            // the win. The tick limit stands in when a stage has no timer,
174            // which would otherwise never end.
175            Goal::Survive => (eaten, timed_out),
176            Goal::Golden => (false, board.golden_banked() >= 1),
177        };
178        if lost {
179            PuzzleOutcome::Lost
180        } else if won {
181            PuzzleOutcome::Won
182        } else if timed_out {
183            // Every other goal loses when the tide beats it to the finish.
184            PuzzleOutcome::Lost
185        } else {
186            PuzzleOutcome::Running
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use crate::sim::direction::Direction;
194
195    #[test]
196    fn parse_rejects_bad_tile_chars() {
197        let text = "name: Bad\nposts: 1\nmap:\n+-+\n|X|\n+-+\n";
198        let err = super::Level::parse(text).unwrap_err();
199        assert!(err.contains("bad tile char"), "{err}");
200    }
201
202    #[test]
203    fn parse_rejects_unknown_keys() {
204        let text = "name: Bad\nwibble: 3\nmap:\n+-+\n|.|\n+-+\n";
205        let err = super::Level::parse(text).unwrap_err();
206        assert!(err.contains("unknown key"), "{err}");
207    }
208
209    #[test]
210    fn parse_rejects_malformed_solutions_and_directions() {
211        let text = "name: Bad\nposts: 1\nsolution: 0,0 Q\nmap:\n+-+\n|.|\n+-+\n";
212        assert!(super::Level::parse(text).is_err());
213        let text = "name: Bad\nposts: 1\nsolution: zero,0 U\nmap:\n+-+\n|.|\n+-+\n";
214        assert!(super::Level::parse(text).is_err());
215    }
216
217    /// A degenerate lattice is refused, not asserted against. `parse` is
218    /// fallible because it reads text a stranger wrote (a pasted level
219    /// code, a hand-edited custom level) and both callers handle the
220    /// error. Odd-sized alone let a lone border line through as a
221    /// zero-sized board, which `Board::new` met with a panic.
222    #[test]
223    fn parse_rejects_a_map_with_no_tiles() {
224        for text in [
225            "name: Bad\nposts: 1\nmap:\n+-+-+\n",   // one row of border
226            "name: Bad\nposts: 1\nmap:\n+\n|\n+\n", // one column of border
227            "name: Bad\nposts: 1\nmap:\n+\n",
228        ] {
229            let err = super::Level::parse(text).unwrap_err();
230            assert!(err.contains("at least one tile"), "{text:?} gave {err}");
231        }
232    }
233
234    /// A board dimension is a `u8`, and the cast to one used to wrap: a
235    /// 300-tile lattice loaded as a 44-tile beach, silently not the level
236    /// the text described.
237    #[test]
238    fn parse_rejects_a_map_too_wide_to_name() {
239        let border: String = "+-".repeat(300) + "+";
240        let row: String = "|.".repeat(300) + "|";
241        let text = format!("name: Vast\nposts: 1\nmap:\n{border}\n{row}\n{border}\n");
242        let err = super::Level::parse(&text).unwrap_err();
243        assert!(err.contains("300"), "{err}");
244        // And the widest board that *does* fit still parses.
245        let border: String = "+-".repeat(255) + "+";
246        let row: String = "|.".repeat(255) + "|";
247        let text = format!("name: Wide\nposts: 1\nmap:\n{border}\n{row}\n{border}\n");
248        let level = super::Level::parse(&text).expect("255 tiles is nameable");
249        assert_eq!(level.board.width(), 255);
250    }
251
252    /// Short rows pad with spaces by design: editors trim trailing
253    /// whitespace, and wrap maps end rows with spaces (open edges), so a
254    /// strict width check would reject to_text's own trimmed output.
255    #[test]
256    fn parse_pads_short_map_rows() {
257        let text = "name: Trimmed\nposts: 1\nmap:\n+-+-+\n|.\n+-+-+\n";
258        let level = super::Level::parse(text).expect("pads, never panics");
259        assert_eq!(level.board.width(), 2);
260    }
261
262    use super::*;
263    use crate::sim::board::TileKind;
264
265    /// A turnstile's pivot survives the format: a replay stores its starting
266    /// board as a level, and generated arenas mirror their logs, so a dropped
267    /// pivot would flip half of them on every recorded round.
268    #[test]
269    fn a_turnstiles_pivot_survives_the_format() {
270        for next_right in [true, false] {
271            let mut board = Board::new(4, 3, 1);
272            board.set_tile(1, 1, TileKind::Turnstile { next_right });
273            let text = Level::from_board("Pivot", 1, board).to_text();
274            let parsed = Level::parse(&text).expect("own output").board();
275            assert_eq!(
276                parsed.tile_at(1, 1),
277                TileKind::Turnstile { next_right },
278                "next_right {next_right} was not preserved:\n{text}"
279            );
280        }
281    }
282
283    const TINY: &str = "\
284name: Tiny
285posts: 1
286crab: 0,0 R L common
287solution: 2,0 D
288map:
289+-+-+-+
290|. . .|
291+ +-+ +
292|. . 0|
293+-+-+-+
294";
295
296    #[test]
297    fn terrain_tiles_round_trip() {
298        let mut board = Board::new(4, 3, 7);
299        board.set_tile(1, 1, TileKind::Turnstile { next_right: true });
300        board.set_tile(2, 1, TileKind::Kelp);
301        board.set_tile(3, 1, TileKind::Pool);
302        let text = Level::from_board("Terrain", 2, board).to_text();
303        let level = Level::parse(&text).expect("parses");
304        assert_eq!(
305            level.board.tile_at(1, 1),
306            TileKind::Turnstile { next_right: true }
307        );
308        assert_eq!(level.board.tile_at(2, 1), TileKind::Kelp);
309        assert_eq!(level.board.tile_at(3, 1), TileKind::Pool);
310        assert_eq!(level.to_text(), text, "text form is stable");
311    }
312
313    #[test]
314    fn parses_dimensions_walls_and_tiles() {
315        let level = Level::parse(TINY).expect("parses");
316        assert_eq!(level.name, "Tiny");
317        assert_eq!(level.posts, 1);
318        assert_eq!(level.crab_count(), 1);
319        let board = level.board();
320        assert_eq!((board.width(), board.height()), (3, 2));
321        assert_eq!(board.tile_at(2, 1), TileKind::Castle(0));
322        assert!(board.wall_at(1, 0, Direction::Down));
323        assert!(!board.wall_at(0, 0, Direction::Down));
324        assert_eq!(board.crabs().len(), 1);
325    }
326
327    #[test]
328    fn solution_wins_the_puzzle() {
329        let level = Level::parse(TINY).expect("parses");
330        let mut board = level.board();
331        for &(x, y, dir) in &level.solution {
332            assert!(board.place_signpost(0, x, y, dir), "placement ({x},{y})");
333        }
334        let outcome = loop {
335            board.tick_idle();
336            match level.outcome(&board) {
337                PuzzleOutcome::Running => {}
338                done @ (PuzzleOutcome::Won | PuzzleOutcome::Lost) => break done,
339            }
340        };
341        assert_eq!(outcome, PuzzleOutcome::Won);
342    }
343
344    #[test]
345    fn serialization_round_trips() {
346        let level = Level::parse(TINY).expect("parses");
347        let text = level.to_text();
348        let again = Level::parse(&text).unwrap_or_else(|e| panic!("reparse: {e}\n{text}"));
349        assert_eq!(level.name, again.name);
350        assert_eq!(level.posts, again.posts);
351        assert_eq!(level.solution, again.solution);
352        assert_eq!(
353            level.board().state_hash(),
354            again.board().state_hash(),
355            "round-tripped board must be bit-identical"
356        );
357    }
358
359    #[test]
360    fn timed_level_loses_at_the_wave() {
361        // A `round:` shorter than the crab's walk: the tide freezes the sim
362        // and the puzzle must resolve to Lost, not sit Running forever.
363        let text = TINY.replace("posts: 1", "posts: 1\nround: 10");
364        let level = Level::parse(&text).expect("parses");
365        let mut board = level.board();
366        for _ in 0..12 {
367            board.tick_idle();
368        }
369        assert!(board.round_over());
370        assert_eq!(level.outcome(&board), PuzzleOutcome::Lost);
371    }
372
373    #[test]
374    fn goal_wrap_and_new_kinds_round_trip() {
375        let text = "\
376name: Fancy
377posts: 2
378rule: evict 3
379round: 900
380wrap: on
381goal: bank 30
382crab: 0,0 R L golden
383crab: 2,1 L R sparkling
384map:
385+-+-+-+-+
386|. . . .|
387+ + + + +
388|. 0 . .|
389+-+-+-+-+
390";
391        let level = Level::parse(text).expect("parses");
392        assert_eq!(level.goal, Goal::Bank(30));
393        assert!(level.board().wrap());
394        let again = Level::parse(&level.to_text()).expect("reparses");
395        assert_eq!(again.goal, level.goal);
396        assert_eq!(
397            again.board().state_hash(),
398            level.board().state_hash(),
399            "wrap/goal/kind round-trip must be bit-identical"
400        );
401    }
402
403    #[test]
404    fn goal_outcomes_judge_correctly() {
405        // Bank goal: reached mid-round.
406        let bank = Level::parse(
407            "name: B\nposts: 0\nrule: evict 3\nround: 900\ngoal: bank 1\n\
408             crab: 0,1 R L common\nmap:\n+-+-+-+\n|. . .|\n+ + + +\n|. . 0|\n+-+-+-+\n",
409        )
410        .expect("parses");
411        let mut board = bank.board();
412        assert_eq!(bank.outcome(&board), PuzzleOutcome::Running);
413        for _ in 0..200 {
414            board.tick_idle();
415        }
416        assert_eq!(bank.outcome(&board), PuzzleOutcome::Won);
417
418        // Survive goal: an eaten crab is an instant loss. The gull starts on
419        // the crab's tile, so the very first collision pass eats it.
420        let survive = Level::parse(
421            "name: S\nposts: 0\nrule: evict 3\nround: 900\ngoal: survive\n\
422             crab: 1,0 R L common\ngull: 1,0 R\nmap:\n+-+-+-+-+-+-+-+-+-+\n\
423             |. . . . . . . . .|\n+-+-+-+-+-+-+-+-+-+\n",
424        )
425        .expect("parses");
426        let mut board = survive.board();
427        board.tick_idle();
428        assert_eq!(
429            survive.outcome(&board),
430            PuzzleOutcome::Lost,
431            "the gull got someone"
432        );
433    }
434
435    /// The kind survives the format both ways round, and is the author's
436    /// answer rather than the board's: a puzzle with four castles saved as
437    /// a puzzle comes back a puzzle.
438    #[test]
439    fn the_kind_round_trips_and_outranks_the_castles() {
440        let level = Level::parse(TINY).expect("parses");
441        assert_eq!(level.kind, LevelKind::Puzzle, "one castle, one player");
442        for kind in [LevelKind::Puzzle, LevelKind::Arena] {
443            let text = level.clone().with_kind(kind).to_text();
444            assert_eq!(Level::parse(&text).expect("reparses").kind, kind, "{text}");
445        }
446        // Four castles and the author still says puzzle.
447        let four = "\
448name: Four
449posts: 1
450kind: puzzle
451crab: 0,0 R L common
452map:
453+-+-+-+-+
454|0 1 2 3|
455+-+-+-+-+
456";
457        let level = Level::parse(four).expect("parses");
458        assert_eq!(level.seats(), 4);
459        assert_eq!(level.kind, LevelKind::Puzzle, "the line beats the board");
460        assert!(Level::parse(&four.replace("kind: puzzle", "kind: mud")).is_err());
461    }
462
463    /// A file written before the editor had a toggle says nothing about
464    /// what it is, so the castles answer for it: one bank is a stage,
465    /// castles for two players is a beach nobody plays alone.
466    #[test]
467    fn a_file_with_no_kind_is_read_off_its_castles() {
468        assert_eq!(Level::parse(TINY).expect("parses").kind, LevelKind::Puzzle);
469        let two = "\
470name: Old Arena
471posts: 3
472crab: 0,0 R L common
473map:
474+-+-+-+
475|0 . 1|
476+-+-+-+
477";
478        let level = Level::parse(two).expect("parses");
479        assert_eq!(level.seats(), 2);
480        assert_eq!(level.kind, LevelKind::Arena);
481        // And once read, it is written down: the guess happens once.
482        assert!(
483            level.to_text().contains("kind: arena"),
484            "{}",
485            level.to_text()
486        );
487    }
488
489    #[test]
490    fn inventory_is_enforced() {
491        let level = Level::parse(TINY).expect("parses");
492        let mut board = level.board();
493        assert!(board.place_signpost(0, 0, 1, Direction::Up));
494        // posts: 1, so the second placement must be rejected, not evict.
495        assert!(!board.place_signpost(0, 1, 1, Direction::Up));
496        assert_eq!(board.signpost_count(0), 1);
497    }
498}