sokoban-elements 0.2.0

Parsing and writing Sokoban, Hexoban, Trioban and Octoban file formats.
Documentation
use std::ops::{Index, IndexMut};

use super::GameBoard;
use crate::utilities::{index_x, index_y};
use crate::{Point2D, TileKind};

impl Index<(usize, usize)> for GameBoard {
    type Output = TileKind;

    /// `index` is `(x, y)`
    ///
    /// # Panics
    ///
    /// Panics if `index` is outside of board.
    #[expect(clippy::panic)]
    fn index(&self, index: (usize, usize)) -> &Self::Output {
        self.data
            .get(index.1)
            .and_then(|row| row.get(index.0))
            .unwrap_or_else(|| panic!("Invalid GameBoard index {index:?}!"))
    }
}

impl IndexMut<(usize, usize)> for GameBoard {
    /// `index` is `(x, y)`
    ///
    /// # Panics
    ///
    /// Panics if `index` is outside of board.
    #[expect(clippy::panic)]
    fn index_mut(&mut self, index: (usize, usize)) -> &mut Self::Output {
        self.data
            .get_mut(index.1)
            .and_then(|row| row.get_mut(index.0))
            .unwrap_or_else(|| panic!("Invalid GameBoard index {index:?}!"))
    }
}

impl Index<usize> for GameBoard {
    type Output = TileKind;

    /// `index` is linear board position: `y * width + x`
    ///
    /// # Panics
    ///
    /// Panics if `index` is outside of board.
    #[expect(clippy::panic)]
    fn index(&self, index: usize) -> &Self::Output {
        let width = self.width();
        assert!(width != 0, "Invalid GameBoard index {index}!");

        self.data
            .get(index_y(index, width))
            .and_then(|row| row.get(index_x(index, width)))
            .unwrap_or_else(|| panic!("Invalid GameBoard index {index}!"))
    }
}

impl IndexMut<usize> for GameBoard {
    /// `index` is linear board position: `y * width + x`
    ///
    /// # Panics
    ///
    /// Panics if `index` is outside of board.
    #[expect(clippy::panic)]
    fn index_mut(&mut self, index: usize) -> &mut Self::Output {
        let width = self.width();
        assert!(width != 0, "Invalid GameBoard index {index}!");

        self.data
            .get_mut(index_y(index, width))
            .and_then(|row| row.get_mut(index_x(index, width)))
            .unwrap_or_else(|| panic!("Invalid GameBoard index {index}!"))
    }
}

impl Index<Point2D> for GameBoard {
    type Output = TileKind;

    /// `index` is [`Point2D`](crate::Point2D) of the tile.
    ///
    /// # Panics
    ///
    /// Panics if `index` is outside of board.
    #[expect(clippy::panic)]
    fn index(&self, index: Point2D) -> &Self::Output {
        // Rows are indexed directly instead of through `Point2D::idx()`: that one saturates, so an
        // off board `x` would alias onto an on board tile instead of panicking.
        self.data
            .get(index.y)
            .and_then(|row| row.get(index.x))
            .unwrap_or_else(|| panic!("Invalid GameBoard index {index}!"))
    }
}

impl IndexMut<Point2D> for GameBoard {
    /// `index` is [`Point2D`](crate::Point2D) of the tile.
    ///
    /// # Panics
    ///
    /// Panics if `index` is outside of board.
    #[expect(clippy::panic)]
    fn index_mut(&mut self, index: Point2D) -> &mut Self::Output {
        self.data
            .get_mut(index.y)
            .and_then(|row| row.get_mut(index.x))
            .unwrap_or_else(|| panic!("Invalid GameBoard index {index}!"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{BoardSyntaxError, Tessellation};

    fn board_text() -> String {
        [
            "    #####",
            "    #   #",
            "    #$  #",
            "  ###  $##",
            "  #  $ $ #",
            "### # ## #   ######",
            "#   # ## #####  ..#",
            "# $  $          ..#",
            "##### ### #@##  ..#",
            "    #     #########",
            "    #######",
        ]
        .join("\n")
    }

    fn board() -> Result<GameBoard, BoardSyntaxError> {
        GameBoard::parsed(&board_text(), Tessellation::Sokoban)
    }

    #[test]
    fn it_reads_tiles_by_x_and_y() -> Result<(), BoardSyntaxError> {
        let board = board()?;

        assert_eq!(board[(0, 0)], TileKind::Floor);
        assert_eq!(board[(4, 0)], TileKind::Wall);
        assert_eq!(board[(5, 2)], TileKind::Box);
        assert_eq!(board[(16, 6)], TileKind::Goal);
        assert_eq!(board[(11, 8)], TileKind::Pusher);

        Ok(())
    }

    #[test]
    fn when_indexed_by_position_it_matches_indexing_by_x_and_y() -> Result<(), BoardSyntaxError> {
        let board = board()?;

        for y in 0..board.height() {
            for x in 0..board.width() {
                assert_eq!(board[(x, y)], board[y * board.width() + x]);
            }
        }

        Ok(())
    }

    #[test]
    fn it_writes_tiles_by_x_and_y() -> Result<(), BoardSyntaxError> {
        let mut board = board()?;
        let boxes_count = board.boxes_count();

        assert_eq!(board[(0, 0)], TileKind::Floor);

        board[(0, 0)] = TileKind::Box;

        assert_eq!(board[(0, 0)], TileKind::Box);
        assert_eq!(board.boxes_count(), boxes_count + 1);

        Ok(())
    }

    #[test]
    fn it_writes_tiles_by_linear_position() -> Result<(), BoardSyntaxError> {
        let mut board = board()?;
        let position: usize = 4;

        assert_eq!(board[position], TileKind::Wall);

        board[position] = TileKind::Goal;

        assert_eq!(board[position], TileKind::Goal);
        assert_eq!(board[(4, 0)], TileKind::Goal);

        Ok(())
    }

    #[test]
    fn it_reads_tiles_by_point() -> Result<(), BoardSyntaxError> {
        let board = board()?;

        assert_eq!(board[Point2D::new(0, 0)], TileKind::Floor);
        assert_eq!(board[Point2D::new(4, 0)], TileKind::Wall);
        assert_eq!(board[Point2D::new(5, 2)], TileKind::Box);
        assert_eq!(board[Point2D::new(16, 6)], TileKind::Goal);
        assert_eq!(board[Point2D::new(11, 8)], TileKind::Pusher);

        Ok(())
    }

    #[test]
    fn when_indexed_by_point_it_matches_indexing_by_x_and_y() -> Result<(), BoardSyntaxError> {
        let board = board()?;

        for y in 0..board.height() {
            for x in 0..board.width() {
                assert_eq!(board[Point2D::new(x, y)], board[(x, y)]);
                assert_eq!(board[Point2D::new(x, y)], board[y * board.width() + x]);
            }
        }

        Ok(())
    }

    #[test]
    fn it_writes_tiles_by_point() -> Result<(), BoardSyntaxError> {
        let mut board = board()?;
        let boxes_count = board.boxes_count();
        // Deliberately asymmetric, and holding a different tile than its transposition: writing to
        // `(5, 2)` must not touch `(2, 5)`, which is what catches a swapped `x` and `y`.
        let position = Point2D::new(5, 2);

        assert_eq!(board[position], TileKind::Box);
        assert_eq!(board[(2, 5)], TileKind::Wall);

        board[position] = TileKind::Goal;

        assert_eq!(board[position], TileKind::Goal);
        assert_eq!(board[(5, 2)], TileKind::Goal);
        assert_eq!(board[(2, 5)], TileKind::Wall, "x and y must not be swapped");
        assert_eq!(board.boxes_count(), boxes_count - 1);

        Ok(())
    }

    #[test]
    #[should_panic(expected = "Invalid GameBoard index")]
    fn it_panics_when_x_and_y_are_out_of_bounds() {
        let board = GameBoard::new(3, 3, Tessellation::Sokoban);
        let _ = board[(0, board.height())];
    }

    #[test]
    #[should_panic(expected = "Invalid GameBoard index")]
    fn it_panics_when_position_is_out_of_bounds() {
        let board = GameBoard::new(3, 3, Tessellation::Sokoban);
        let _ = board[board.width() * board.height()];
    }

    #[test]
    #[should_panic(expected = "Invalid GameBoard index")]
    fn when_board_is_empty_indexing_by_position_panics() {
        let board = GameBoard::new(0, 0, Tessellation::Sokoban);
        let position: usize = 0;
        let _ = board[position];
    }

    /// An off board `x` must panic even when `y * width + x` names an on board tile: `(3, 0)` on a
    /// 3 wide board computes the same linear index as `(0, 1)`. Indexing through
    /// [`Point2D::idx()`], which saturates instead of failing, would return that other tile and
    /// not panic at all.
    #[test]
    #[should_panic(expected = "Invalid GameBoard index")]
    fn it_panics_when_point_x_is_out_of_bounds() {
        let board = GameBoard::new(3, 3, Tessellation::Sokoban);
        let _ = board[Point2D::new(board.width(), 0)];
    }

    #[test]
    #[should_panic(expected = "Invalid GameBoard index")]
    fn it_panics_when_point_y_is_out_of_bounds() {
        let board = GameBoard::new(3, 3, Tessellation::Sokoban);
        let _ = board[Point2D::new(0, board.height())];
    }

    #[test]
    #[should_panic(expected = "Invalid GameBoard index")]
    fn when_board_is_empty_indexing_by_point_panics() {
        let board = GameBoard::new(0, 0, Tessellation::Sokoban);
        let _ = board[Point2D::origin()];
    }
}