sokoban-elements 0.2.1

Parsing and writing Sokoban, Hexoban, Trioban and Octoban file formats.
Documentation
use std::io::Write;

use itertools::Itertools;

use super::super::file_format::FileFormatWriter;
use super::super::{IoWriteError, WriterSettings};
use super::helpers::{
    AUTHOR, BOXORDER, COLLECTION, DATE_CREATED, DATE_OF_LAST_CHANGE, GOALORDER, RAW_FILE_NOTES,
    SOLVER, VARIANT,
};
use crate::rle::Rle;
use crate::utilities::is_blank;
use crate::{Collection, Puzzle, Tessellation};

const SOKOBAN_VARIANT: &str = "Sokoban";
const HEXOBAN_VARIANT: &str = "Hexoban";
const TRIOBAN_VARIANT: &str = "Trioban";
const OCTOBAN_VARIANT: &str = "Octoban";

// Exhaustive, so a new `Tessellation` variant has to be named here. Add it to
// `SokReader::parse_tessellation_str()` in the same commit: that one matches on text and will keep
// compiling without the new name, leaving the writer able to emit what the reader rejects.
const fn variant_name(tessellation: Tessellation) -> &'static str {
    match tessellation {
        Tessellation::Sokoban => SOKOBAN_VARIANT,
        Tessellation::Trioban => TRIOBAN_VARIANT,
        Tessellation::Hexoban => HEXOBAN_VARIANT,
        Tessellation::Octoban => OCTOBAN_VARIANT,
    }
}

pub struct SokWriter {
    restrict_tessellation: Option<Tessellation>,
    suppress_extra:        bool,
    settings:              WriterSettings,
}

impl FileFormatWriter for SokWriter {
    fn write_puzzle<W>(
        &self, puzzle: &Puzzle, puzzle_idx: usize, dest: &mut W,
    ) -> Result<(), IoWriteError>
    where
        W: Write,
    {
        self.puzzle_writer(puzzle, dest, true, puzzle_idx)
    }

    fn write_collection<W>(&self, collection: &Collection, dest: &mut W) -> Result<(), IoWriteError>
    where
        W: Write,
    {
        self.write_collection_header(collection, dest)?;
        for (idx, puzzle) in collection.puzzles.iter().enumerate() {
            self.puzzle_writer(
                puzzle,
                dest,
                !is_blank(&puzzle.author) && puzzle.author != collection.author,
                idx,
            )?;
        }

        Ok(())
    }
}

impl SokWriter {
    pub fn new(
        restrict_tessellation: Option<Tessellation>, suppress_extra: bool, settings: WriterSettings,
    ) -> Self {
        Self {
            restrict_tessellation,
            suppress_extra,
            settings,
        }
    }

    fn write_saved_game<W>(
        &self, dest: &mut W, puzzle: &Puzzle, idx: usize,
    ) -> Result<(), IoWriteError>
    where
        W: Write,
    {
        let saved_game = &puzzle.saved_games[idx];

        if saved_game.is_empty() {
            return Ok(());
        }

        if is_blank(&saved_game.title) {
            // The title needs only these two counts, both of which are facts about the step
            // sequence - so no board is involved and nothing has to be replayed.
            let pushes_count = saved_game.pushes_count();
            writeln!(
                dest,
                "Save game {}/{}",
                saved_game.moves_count() + pushes_count,
                pushes_count
            )?;
        } else {
            writeln!(dest, "{}", saved_game.title)?;
        }

        let steps_str = saved_game.printed_steps();

        let moves = if self.settings.rle_encode {
            Rle::encode(&steps_str).map_err(|e| IoWriteError::new_rle_encoding_error(e, idx))?
        } else {
            steps_str
                .chars()
                .chunks(usize::from(self.settings.wrap_savegame_lines_at))
                .into_iter()
                .map(std::iter::Iterator::collect::<String>)
                .join("\n")
        };

        writeln!(dest, "{moves}")?;
        writeln!(dest)?;

        let mut written = false;

        if let Some(s) = Self::construct_tagged(SOLVER, &saved_game.solved_by) {
            writeln!(dest, "{s}")?;
            written = true;
        }

        if !is_blank(&saved_game.notes) {
            writeln!(dest, "{}", saved_game.notes.trim_end())?;
            written = true;
        }

        if written {
            writeln!(dest)?;
        }

        Ok(())
    }

    fn puzzle_writer<W>(
        &self, puzzle: &Puzzle, dest: &mut W, write_author: bool, puzzle_idx: usize,
    ) -> Result<(), IoWriteError>
    where
        W: Write,
    {
        if let Some(restricted) = self.restrict_tessellation
            && restricted != puzzle.board.tessellation()
        {
            return Err(IoWriteError::new_variant_not_allowed_error(
                puzzle_idx,
                puzzle.board.tessellation(),
            ));
        }

        if puzzle.board.is_blank() {
            return Ok(());
        }

        if is_blank(&puzzle.title) {
            writeln!(dest, "Level {}", puzzle_idx + 1)?;
        } else {
            writeln!(dest, "{}", puzzle.title)?;
        }
        writeln!(dest)?;

        writeln!(
            dest,
            "{}",
            puzzle
                .board
                .printed(self.settings.print_visible_floor)
                .trim_end()
        )?;
        writeln!(dest)?;

        if !self.suppress_extra {
            let mut written = false;

            if puzzle.board.tessellation() != Tessellation::Sokoban
                && let Some(s) =
                    Self::construct_tagged(VARIANT, variant_name(puzzle.board.tessellation()))
            {
                writeln!(dest, "{s}")?;
                written = true;
            }

            if let Some(sokoban_plus) = &puzzle.sokoban_plus
                && !sokoban_plus.is_empty()
            {
                if let Some(s) = Self::construct_tagged(BOXORDER, &sokoban_plus.boxorder()) {
                    writeln!(dest, "{s}")?;
                }
                if let Some(s) = Self::construct_tagged(GOALORDER, &sokoban_plus.goalorder()) {
                    writeln!(dest, "{s}")?;
                }
                written = true;
            }

            if write_author {
                if let Some(s) = Self::construct_tagged(AUTHOR, &puzzle.author) {
                    writeln!(dest, "{s}")?;
                }
                written = true;
            }

            if !is_blank(&puzzle.notes) {
                writeln!(dest, "{}", puzzle.notes.trim_end())?;
                written = true;
            }

            if written {
                writeln!(dest)?;
            }

            for (idx, _) in puzzle.saved_games.iter().enumerate() {
                self.write_saved_game(dest, puzzle, idx)?;
            }
        }

        Ok(())
    }

    fn construct_tagged(tag: &str, data: &str) -> Option<String> {
        (!is_blank(tag) && !is_blank(data)).then(|| format!("{}: {}", tag.trim(), data.trim()))
    }

    fn write_collection_header<W>(
        &self, collection: &Collection, dest: &mut W,
    ) -> Result<(), IoWriteError>
    where
        W: Write,
    {
        if self.suppress_extra {
            return Ok(());
        }

        for line in SOK_HEADER.lines() {
            writeln!(dest, "{line}")?;
        }

        let mut some_written = false;

        if let Some(s) = Self::construct_tagged(DATE_CREATED, &collection.created_at) {
            writeln!(dest, "{s}")?;
            some_written = true;
        }

        if let Some(s) = Self::construct_tagged(DATE_OF_LAST_CHANGE, &collection.updated_at) {
            writeln!(dest, "{s}")?;
            some_written = true;
        }

        if some_written {
            writeln!(dest, "{}", RAW_FILE_NOTES.repeat(30))?;
        }

        some_written = false;

        if let Some(s) = Self::construct_tagged(COLLECTION, &collection.title) {
            writeln!(dest, "{s}")?;
            some_written = true;
        }

        if let Some(s) = Self::construct_tagged(AUTHOR, &collection.author) {
            writeln!(dest, "{s}")?;
            some_written = true;
        }

        if !is_blank(&collection.notes) {
            writeln!(dest, "{}", collection.notes.trim())?;
            some_written = true;
        }

        if some_written {
            writeln!(dest)?;
        }

        Ok(())
    }
}

pub const SOK_HEADER: &str = r#####"::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::         Sokoban (c) by Falcon Co., Ltd., Japan         ::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::                    File Format 0.17                    ::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::                                                        ::
:: File Notes                                  Optional   ::
:: Puzzle 1                                    Required   ::
::    Title                                    Optional*  ::
::    Board                                    See legend ::
::    Puzzle Notes                             Optional   ::
::    Saved Game or Solution 1                 Optional   ::
::      Title                                  Optional*  ::
::      Moves                                  See legend ::
::      Notes                                  Optional   ::
::    Saved Game or Solution 2                 Optional   ::
::    ... (more saved games and solutions)                ::
:: Puzzle 2                                    Optional   ::
:: ... (more puzzles)                                     ::
::                                                        ::
:: Remarks:                                               ::
::                                                        ::
:: File Notes                                             ::
::   File notes consist of unstructured text and          ::
::   key/value properties, such as "Author: Name". Lines  ::
::   beginning with "::" are comments meant to be read    ::
::   only by a person examining the file in a text        ::
::   editor, and should not be displayed by the Sokoban   ::
::   program.                                             ::
::                                                        ::
::   The optional but recommended property                ::
::   "Collection: Name" assigns a name to the puzzle      ::
::   collection. When a collection is copied from the     ::
::   internet, for example, and pasted into a Sokoban     ::
::   program, this information allows the collection to   ::
::   be saved with the proper name.                       ::
::                                                        ::
:: Titles                                                 ::
::   A title line is the last non-blank text line before  ::
::   a board, a saved game, or a solution, provided the   ::
::   line is preceded  by a blank line or it is the only  ::
::   text line at this position in the file.              ::
::                                                        ::
::   Title lines are optional unless a single or a last   ::
::   text line from a preceding puzzle, saved game,       ::
::   solution, or file header can be mistaken for a title ::
::   line.                                                ::
::                                                        ::
:: Puzzle Notes                                           ::
::   Two special key/value pairs are supported in puzzle  ::
::   notes: "Title" and "Author", hence, titles can       ::
::   either come from a title line or from a key/value    ::
::   pair.                                                ::
::                                                        ::
::::::::::::::::::::::::::: Board ::::::::::::::::::::::::::
:: Legend.................:      :.................Legend ::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
:: Wall...................: #  # :...................Wall ::
:: Pusher.................: p  @ :.................Pusher ::
:: Pusher on goal square..: P  + :..Pusher on goal square ::
:: Box....................: b  $ :....................Box ::
:: Box on goal square.....: B  * :.....Box on goal square ::
:: Goal square............: .  . :............Goal square ::
:: Floor..................:      :..................Floor ::
:: Floor..................: -  _ :..................Floor ::
::                                                        ::
:: Remarks:                                               ::
::                                                        ::
::   The first and the last non-empty square in each row  ::
::   must be a wall or a box on a goal. An empty interior ::
::   row is written with at least one "-" or "_".         ::
::                                                        ::
::   Boards may be run-length encoded (RLE), e.g.,        ::
::   "###----p.#" may be encoded as "3#4-p.#", and        ::
::   "#-#-#-##-#-#-#" may be encoded as "2(3(#-)#)".      ::
::   A row cannot be split over multiple lines.           ::
::                                                        ::
::   Rows may be combined on a single line by using "|"   ::
::   as a row separator, e.g., "--3#|3#-#|#pb.#|5#".      ::
::   A "|" at the end of a line is optional and may be    ::
::   omitted.                                             ::
::                                                        ::
::::::::::::::::::::::::::: Moves ::::::::::::::::::::::::::
:: Legend.................:      :.................Legend ::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
:: Move pusher up.........: u  U :.......Push/pull box up ::
:: Move pusher down.......: d  D :.....Push/pull box down ::
:: Move pusher left.......: l  L :.....Push/pull box left ::
:: Move pusher right......: r  R :....Push/pull box right ::
:: Begin jump.............: [  ] :...............End jump ::
:: Begin pusher change....: {  } :......End pusher change ::
:: Current position.......: *  * :.......Current position ::
::                                                        ::
:: Remarks:                                               ::
::                                                        ::
::   Moves may be run-length encoded, e.g., "3r4U" means  ::
::   "rrrUUUU", and "2(3(dr)R)" means "drdrdrRdrdrdrR".   ::
::   Each line must, however, have at least one proper    ::
::   non-digit character. Spaces between moves are        ::
::   allowed.                                             ::
::                                                        ::
::   Jumps and pulls: Only in reverse mode saved games    ::
::   and solutions.                                       ::
::                                                        ::
::   Reverse mode saved games and solutions must begin    ::
::   with a jump, even if it is empty. An example:        ::
::   "[]U[rr]d".                                          ::
::                                                        ::
::   Pusher changes: Only in puzzles with multiple        ::
::   pushers, e.g., Multiban. Moves inside the braces     ::
::   depict the relative movement to get from the         ::
::   currently active pusher to the next active pusher.   ::
::   At game start, a "{...}" sequence activates the      ::
::   pusher relative to the top-left pusher. An example:  ::
::   "{rddd}Urr{uul}uLU". If the top-left pusher is the   ::
::   first active pusher, then the empty "{}" can be      ::
::   omitted.                                             ::
::                                                        ::
::   The current position is optional and defaults to the ::
::   position after the last move.                        ::
::                                                        ::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::"#####;