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/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 un-ticked boards (creature sub-tile state is not representable,
59    /// by design, since levels describe starting states).
60    pub fn to_text(&self) -> String {
61        use std::fmt::Write;
62        let board = &self.board;
63        let mut out = String::new();
64        let _ = writeln!(out, "name: {}", self.name);
65        let _ = writeln!(out, "posts: {}", self.posts);
66        // Always written, both values: what the author chose is not always
67        // what the board looks like, and a file that leaves it out is read
68        // by guessing at the castles.
69        let _ = writeln!(out, "kind: {}", self.kind.token());
70        let _ = writeln!(out, "seed: {}", board.seed());
71        // Puzzle levels (no explicit rule) reconstruct their rule from
72        // `posts:` on parse; writing one would freeze the wrong default.
73        if self.explicit_rule {
74            let (cap, policy) = board.signpost_rule();
75            let _ = writeln!(out, "rule: {} {cap}", policy.token());
76        }
77        for (player, &score) in board.scores().iter().enumerate() {
78            if score > 0 {
79                let _ = writeln!(out, "score: {player} {score}");
80            }
81        }
82        for crab in board.crabs() {
83            let (x, y) = board.coords_u8(crab.tile);
84            let _ = writeln!(
85                out,
86                "crab: {x},{y} {} {} {}",
87                crab.dir.letter(),
88                crab.handed.token(),
89                crab.kind.token()
90            );
91        }
92        for (x, y, kind) in board.tiles() {
93            if let TileKind::Spawner(s) = kind {
94                let _ = writeln!(out, "spawner: {x},{y} {} {}", s.dir.letter(), s.period);
95            }
96        }
97        for gull in board.gulls() {
98            let (x, y) = board.coords_u8(gull.tile);
99            let _ = writeln!(out, "gull: {x},{y} {}", gull.dir.letter());
100        }
101        if board.gull_period() > 0 {
102            let _ = writeln!(out, "gull_period: {}", board.gull_period());
103        }
104        if let Some(round) = board.round_length() {
105            let _ = writeln!(out, "round: {round}");
106        }
107        if board.wrap() {
108            let _ = writeln!(out, "wrap: on");
109        }
110        // Tide events are game state, not authoring flavour: dropping the
111        // flag made replays of versus rounds silently diverge.
112        if board.events_enabled() {
113            let _ = writeln!(out, "events: on");
114        }
115        match self.goal {
116            Goal::AllCrabs => {}
117            Goal::Bank(n) => {
118                let _ = writeln!(out, "goal: bank {n}");
119            }
120            Goal::Survive => {
121                let _ = writeln!(out, "goal: survive");
122            }
123            Goal::Golden => {
124                let _ = writeln!(out, "goal: golden");
125            }
126        }
127        if !self.solution.is_empty() {
128            let parts: Vec<String> = self
129                .solution
130                .iter()
131                .map(|&(x, y, dir)| format!("{x},{y} {}", dir.letter()))
132                .collect();
133            let _ = writeln!(out, "solution: {}", parts.join("; "));
134        }
135        let _ = writeln!(out, "map:");
136
137        // Lattice: (2h+1) rows × (2w+1) columns.
138        let (w, h) = (board.width() as usize, board.height() as usize);
139        let mut lattice = vec![vec![' '; 2 * w + 1]; 2 * h + 1];
140        for row in lattice.iter_mut().step_by(2) {
141            for cell in row.iter_mut().step_by(2) {
142                *cell = '+';
143            }
144        }
145        for (x, y, kind) in board.tiles() {
146            let (lx, ly) = (2 * x as usize + 1, 2 * y as usize + 1);
147            lattice[ly][lx] = tile_glyph(kind);
148            if board.wall_at(x, y, Direction::Up) {
149                lattice[ly - 1][lx] = '-';
150            }
151            if board.wall_at(x, y, Direction::Left) {
152                lattice[ly][lx - 1] = '|';
153            }
154            if board.wall_at(x, y, Direction::Down) {
155                lattice[ly + 1][lx] = '-';
156            }
157            if board.wall_at(x, y, Direction::Right) {
158                lattice[ly][lx + 1] = '|';
159            }
160        }
161        for row in lattice {
162            let line: String = row.into_iter().collect();
163            let _ = writeln!(out, "{}", line.trim_end());
164        }
165        out
166    }
167}
168
169/// What a level with nothing to say about itself must be: castles for two
170/// seats or more is a beach nobody plays alone. Only reached for text
171/// written before the format carried a `kind:` line, and for boards handed
172/// straight to [`Level::from_board`] (a recorded versus round, which is an
173/// arena, and an editor snapshot, whose kind the editor then states).
174fn inferred_kind(level: &Level) -> LevelKind {
175    match level.seats() >= 2 {
176        true => LevelKind::Arena,
177        false => LevelKind::Puzzle,
178    }
179}
180
181fn parse_xy(s: &str) -> Result<(u8, u8, &str), String> {
182    let (xy, rest) = match s.split_once(char::is_whitespace) {
183        Some((xy, rest)) => (xy, rest),
184        None => (s, ""),
185    };
186    let (x, y) = xy
187        .split_once(',')
188        .ok_or_else(|| format!("expected x,y in {s:?}"))?;
189    let x = x.trim().parse::<u8>().map_err(|e| format!("x: {e}"))?;
190    let y = y.trim().parse::<u8>().map_err(|e| format!("y: {e}"))?;
191    Ok((x, y, rest))
192}
193
194/// Everything the `key: value` header section declares, gathered before
195/// the map lattice is read.
196struct Header {
197    name: Option<String>,
198    posts: Option<u8>,
199    solution: Vec<Placement>,
200    crabs: Vec<(u8, u8, Direction, Handedness, CrabKind)>,
201    spawners: Vec<(u8, u8, Direction, u32)>,
202    gulls: Vec<(u8, u8, Direction)>,
203    gull_period: u32,
204    events: bool,
205    round: Option<u32>,
206    seed: u64,
207    rule: Option<(u8, CapPolicy)>,
208    scores: Vec<(u8, u32)>,
209    goal: Goal,
210    wrap: bool,
211    /// `None` when the file predates the editor's puzzle/arena toggle.
212    kind: Option<LevelKind>,
213}
214
215impl Default for Header {
216    fn default() -> Header {
217        Header {
218            name: None,
219            posts: None,
220            solution: Vec::new(),
221            crabs: Vec::new(),
222            spawners: Vec::new(),
223            gulls: Vec::new(),
224            gull_period: 0,
225            events: false,
226            round: None,
227            // An arbitrary but fixed default, so a level file without a
228            // `seed:` still replays identically everywhere.
229            seed: 0x7ead_0001,
230            rule: None,
231            scores: Vec::new(),
232            goal: Goal::AllCrabs,
233            wrap: false,
234            kind: None,
235        }
236    }
237}
238
239/// What one header line did: carried a value, or ended the header.
240enum HeaderLine {
241    Read,
242    /// `map:`, after which the lattice starts on the next line.
243    MapFollows,
244}
245
246impl Header {
247    /// Fold one `key: value` line in. Each key parses its own value and
248    /// says so in its own error message, which is the sort of thing a
249    /// hand-edited level file needs to hear.
250    fn read_line(&mut self, key: &str, value: &str) -> Result<HeaderLine, String> {
251        match key {
252            "name" => self.name = Some(value.to_string()),
253            "posts" => {
254                self.posts = Some(value.parse::<u8>().map_err(|e| format!("posts: {e}"))?);
255            }
256            "crab" => {
257                let (x, y, rest) = parse_xy(value)?;
258                let mut parts = rest.split_whitespace();
259                let dir = parse_dir(parts.next().ok_or("crab: missing direction")?)?;
260                let handed = parts.next().ok_or("crab: missing handedness")?;
261                let handed = Handedness::from_token(handed)
262                    .ok_or_else(|| format!("crab: bad handedness {handed:?}"))?;
263                let kind = parts.next().ok_or("crab: missing kind")?;
264                let kind =
265                    CrabKind::from_token(kind).ok_or_else(|| format!("crab: bad kind {kind:?}"))?;
266                self.crabs.push((x, y, dir, handed, kind));
267            }
268            "spawner" => {
269                let (x, y, rest) = parse_xy(value)?;
270                let mut parts = rest.split_whitespace();
271                let dir = parse_dir(parts.next().ok_or("spawner: missing direction")?)?;
272                let period = parts
273                    .next()
274                    .ok_or("spawner: missing period")?
275                    .parse::<u32>()
276                    .map_err(|e| format!("spawner period: {e}"))?;
277                self.spawners.push((x, y, dir, period));
278            }
279            "gull" => {
280                let (x, y, rest) = parse_xy(value)?;
281                let dir = parse_dir(rest.trim())?;
282                self.gulls.push((x, y, dir));
283            }
284            "gull_period" => {
285                self.gull_period = value
286                    .parse::<u32>()
287                    .map_err(|e| format!("gull_period: {e}"))?;
288            }
289            "round" => {
290                self.round = Some(value.parse::<u32>().map_err(|e| format!("round: {e}"))?);
291            }
292            "seed" => {
293                self.seed = value.parse::<u64>().map_err(|e| format!("seed: {e}"))?;
294            }
295            "goal" => {
296                self.goal = match value.split_once(' ') {
297                    Some(("bank", n)) => {
298                        Goal::Bank(n.trim().parse().map_err(|e| format!("goal bank: {e}"))?)
299                    }
300                    None if value == "survive" => Goal::Survive,
301                    None if value == "golden" => Goal::Golden,
302                    None if value == "all" => Goal::AllCrabs,
303                    _ => return Err(format!("goal: bad value {value:?}")),
304                };
305            }
306            "kind" => {
307                self.kind = Some(
308                    LevelKind::from_token(value)
309                        .ok_or_else(|| format!("kind: bad value {value:?}"))?,
310                );
311            }
312            "wrap" => self.wrap = value == "on",
313            "events" => self.events = value == "on",
314            "rule" => {
315                let (policy, cap) = value
316                    .split_once(' ')
317                    .ok_or("rule: expected `<evict|reject> <cap>`")?;
318                let cap = cap
319                    .trim()
320                    .parse::<u8>()
321                    .map_err(|e| format!("rule cap: {e}"))?;
322                let policy = policy.trim();
323                let policy = CapPolicy::from_token(policy)
324                    .ok_or_else(|| format!("rule: bad policy {policy:?}"))?;
325                self.rule = Some((cap, policy));
326            }
327            "score" => {
328                let (player, score) = value
329                    .split_once(' ')
330                    .ok_or("score: expected `<player> <score>`")?;
331                let player = player
332                    .trim()
333                    .parse::<u8>()
334                    .map_err(|e| format!("score: {e}"))?;
335                let score = score
336                    .trim()
337                    .parse::<u32>()
338                    .map_err(|e| format!("score: {e}"))?;
339                if crate::sim::board::seat(player).is_none() {
340                    return Err(format!("score: player {player} out of range"));
341                }
342                self.scores.push((player, score));
343            }
344            "solution" => {
345                for placement in value.split(';') {
346                    let (x, y, rest) = parse_xy(placement.trim())?;
347                    let dir = parse_dir(rest.trim())?;
348                    self.solution.push((x, y, dir));
349                }
350            }
351            "map" => return Ok(HeaderLine::MapFollows),
352            other => return Err(format!("unknown key {other:?}")),
353        }
354        Ok(HeaderLine::Read)
355    }
356}
357
358/// Stage 1: the `key: value` lines up to (and consuming) `map:`.
359fn parse_header(lines: &mut std::str::Lines) -> Result<Header, String> {
360    let mut header = Header::default();
361    for line in lines.by_ref() {
362        let line = line.trim();
363        // Comments are only possible before the map (map rows use '#').
364        if line.is_empty() || line.starts_with('#') {
365            continue;
366        }
367        let Some((key, value)) = line.split_once(':') else {
368            return Err(format!("expected `key: value` before map, got {line:?}"));
369        };
370        if let HeaderLine::MapFollows = header.read_line(key.trim(), value.trim())? {
371            break;
372        }
373    }
374    Ok(header)
375}
376
377/// Stage 2: the odd-sized wall lattice into a board of tiles and walls.
378fn parse_lattice<'a>(lines: impl Iterator<Item = &'a str>, seed: u64) -> Result<Board, String> {
379    let lattice: Vec<&str> = lines.filter(|l: &&str| !l.trim().is_empty()).collect();
380    if lattice.is_empty() {
381        return Err("missing map section".into());
382    }
383    let lat_h = lattice.len();
384    let lat_w = lattice[0].chars().count();
385    if lat_h.is_multiple_of(2) || lat_w.is_multiple_of(2) {
386        return Err(format!(
387            "map lattice must be odd-sized, got {lat_w}×{lat_h}"
388        ));
389    }
390    // A lattice spends `2n + 1` lines on `n` tiles, so three is the
391    // narrowest that holds one and `2·255 + 1` the widest a `u8` dimension
392    // can name. Odd-sized alone let both ends through: a lone border line
393    // made a zero-sized board, which `Board::new` refuses with a panic, and
394    // an over-wide one wrapped the cast: a 300-tile beach loading silently
395    // as 44, which is worse than any rejection.
396    if lat_w < 3 || lat_h < 3 {
397        return Err(format!(
398            "map lattice must hold at least one tile, got {lat_w}×{lat_h}"
399        ));
400    }
401    let (tiles_w, tiles_h) = (lat_w / 2, lat_h / 2);
402    let (Ok(width), Ok(height)) = (u8::try_from(tiles_w), u8::try_from(tiles_h)) else {
403        return Err(format!(
404            "map is {tiles_w}×{tiles_h} tiles, past the {} a side the format can name",
405            u8::MAX
406        ));
407    };
408    // Short rows pad with spaces rather than erroring: text editors trim
409    // trailing whitespace, and wrap-map rows legitimately end in spaces
410    // (open edges) - to_text itself emits them trimmed.
411    let grid: Vec<Vec<char>> = lattice
412        .iter()
413        .map(|row| {
414            let mut chars: Vec<char> = row.chars().collect();
415            chars.resize(lat_w, ' ');
416            chars
417        })
418        .collect();
419
420    // The seed matters only where the PRNG is consumed (spawner and gull
421    // handedness, takeoff timers); serialized levels carry it so replays
422    // rebuild bit-identical boards.
423    // Both were just checked against the lattice; `Board::new` panics on a
424    // zero, which is how a lone border line once took the game down.
425    debug_assert!(width > 0 && height > 0, "a {width}x{height} board");
426    let mut board = Board::new(width, height, seed);
427    for y in 0..height {
428        for x in 0..width {
429            let c = grid[y as usize * 2 + 1][x as usize * 2 + 1];
430            match tile_from_glyph(c) {
431                Some(TileKind::Empty) => {}
432                Some(tile) => board.set_tile(x, y, tile),
433                None => return Err(format!("bad tile char {c:?} at ({x},{y})")),
434            }
435            // Up edge of (x, y) is lattice row 2y, column 2x+1.
436            if grid[y as usize * 2][x as usize * 2 + 1] == '-' {
437                board.set_wall(x, y, Direction::Up, true);
438            }
439            // Left edge is lattice row 2y+1, column 2x.
440            if grid[y as usize * 2 + 1][x as usize * 2] == '|' {
441                board.set_wall(x, y, Direction::Left, true);
442            }
443        }
444    }
445    // Bottom and right borders of the lattice.
446    for x in 0..width {
447        if grid[height as usize * 2][x as usize * 2 + 1] == '-' {
448            board.set_wall(x, height - 1, Direction::Down, true);
449        }
450    }
451    for y in 0..height {
452        if grid[y as usize * 2 + 1][width as usize * 2] == '|' {
453            board.set_wall(width - 1, y, Direction::Right, true);
454        }
455    }
456    Ok(board)
457}
458
459/// Stage 3: header-declared spawners, creatures, and board switches, with
460/// bounds validation against the lattice-derived size.
461fn place_entities(board: &mut Board, header: &Header) -> Result<(), String> {
462    let (width, height) = (board.width(), board.height());
463    let check = |what: &str, x: u8, y: u8| -> Result<(), String> {
464        if x >= width || y >= height {
465            return Err(format!(
466                "{what} at ({x},{y}) is off the {width}x{height} board"
467            ));
468        }
469        Ok(())
470    };
471    for &(x, y, dir, period) in &header.spawners {
472        check("spawner", x, y)?;
473        if period == 0 {
474            return Err(format!("spawner at ({x},{y}): period must be at least 1"));
475        }
476        board.set_tile(x, y, TileKind::Spawner(Spawner { dir, period }));
477    }
478    for &(x, y, dir, handed, kind) in &header.crabs {
479        check("crab", x, y)?;
480        if board.tile_at(x, y) == TileKind::Rock {
481            return Err(format!("crab at ({x},{y}) is standing on a rock"));
482        }
483        board.spawn_crab(x, y, dir, handed, kind);
484    }
485    for &(x, y, dir) in &header.gulls {
486        check("gull", x, y)?;
487        if board.tile_at(x, y) == TileKind::Rock {
488            return Err(format!("gull at ({x},{y}) is standing on a rock"));
489        }
490        board.spawn_gull(x, y, dir);
491    }
492    board.set_gull_period(header.gull_period);
493    board.set_round_length(header.round);
494    if header.wrap {
495        board.set_wrap(true);
496    }
497    if header.events {
498        board.set_events_enabled(true);
499    }
500    if let Some((cap, policy)) = header.rule {
501        board.set_signpost_rule(cap, policy);
502    }
503    for &(player, score) in &header.scores {
504        board.set_score(player, score);
505    }
506    Ok(())
507}
508
509/// The map glyph for a tile; `tile_from_glyph` is its inverse (spawners
510/// serialize in the header, so they read back as sand here).
511fn tile_glyph(tile: TileKind) -> char {
512    match tile {
513        TileKind::Empty | TileKind::Spawner(_) => '.',
514        TileKind::Rock => '#',
515        TileKind::Castle(owner) => (b'0' + owner) as char,
516        // Upper case is a log about to deflect right, lower case one about to
517        // deflect left. The pivot has to survive: generated arenas mirror
518        // their logs (a reflection swaps left and right), and a replay stores
519        // its starting board *as* a level.
520        TileKind::Turnstile { next_right: true } => 'T',
521        TileKind::Turnstile { next_right: false } => 't',
522        TileKind::Kelp => 'K',
523        TileKind::Pool => '~',
524    }
525}
526
527fn tile_from_glyph(c: char) -> Option<TileKind> {
528    match c {
529        '.' | ' ' => Some(TileKind::Empty),
530        '#' => Some(TileKind::Rock),
531        'T' => Some(TileKind::Turnstile { next_right: true }),
532        't' => Some(TileKind::Turnstile { next_right: false }),
533        'K' => Some(TileKind::Kelp),
534        '~' => Some(TileKind::Pool),
535        // As many seats as the sim has, not the four it had when the glyphs
536        // were chosen: a six-castle board writes '4' and '5', and a reader
537        // that rejects them turns every six-player replay into a parse error.
538        '0'..='9' if crate::sim::board::seat(c as u8 - b'0').is_some() => {
539            Some(TileKind::Castle((c as u8 - b'0') as PlayerId))
540        }
541        _ => None,
542    }
543}
544
545fn parse_dir(s: &str) -> Result<Direction, String> {
546    Direction::from_letter(s).ok_or_else(|| format!("bad direction {s:?}"))
547}