sokoban-elements 0.2.1

Parsing and writing Sokoban, Hexoban, Trioban and Octoban file formats.
Documentation
/// Shape of single game board tile.
///
/// In different [`Tessellation`](crate::Tessellation)s, board tiles have different shapes.
///
/// Shape of board tile can also depend on position of that tile on game board.
///
/// For example, in [`Trioban`](crate::Tessellation::Trioban), origin of coordinate system is
/// triangle pointing upwards. Shape of all other triangles depends on orientation of origin and may
/// be either triangle pointing upwards or triangle pointing downwards.
///
/// In [`Octoban`](crate::Tessellation::Octoban) tessellation, board is laid on alternating squares
/// and octagons. Again, shape of each tile depends on position of that tile.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum TileShape {
    Square,
    Hexagon,
    TriangleUp,
    TriangleDown,
    Octagon,
}

/// What kind of game board element is one tile of that board.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum TileKind {
    Box,
    BoxOnGoal,
    Pusher,
    PusherOnGoal,
    #[default]
    Floor,
    Goal,
    Wall,
}

impl TileKind {
    /// Tile is a wall.
    pub fn is_wall(&self) -> bool { *self == Self::Wall }

    /// Tile carries a pusher, whether or not it also carries a goal.
    pub fn has_pusher(&self) -> bool { *self == Self::Pusher || *self == Self::PusherOnGoal }

    /// Tile carries a box, whether or not it also carries a goal.
    pub fn has_box(&self) -> bool { *self == Self::Box || *self == Self::BoxOnGoal }

    /// Tile carries a goal, whether or not a box or a pusher stands on it.
    ///
    /// [`BoxOnGoal`](Self::BoxOnGoal) and [`PusherOnGoal`](Self::PusherOnGoal) both match. Because
    /// of that, counting goals and counting boxes are two independent scans of a board and the
    /// n-th box is not the counterpart of the n-th goal.
    pub fn has_goal(&self) -> bool {
        *self == Self::Goal || *self == Self::BoxOnGoal || *self == Self::PusherOnGoal
    }

    /// Tile holds nothing at all: no wall, no piece and no goal.
    ///
    /// This is [`Floor`](Self::Floor), the [`Default`](std::default::Default) variant, and it is
    /// the only tile kind that carries no board content. Its negation is exactly what
    /// [`GameBoard::trim()`](crate::GameBoard::trim()) retains, and a board of nothing but empty
    /// floor is what [`GameBoard::is_blank()`](crate::GameBoard::is_blank()) reports.
    pub fn is_empty_floor(&self) -> bool { *self == Self::Floor }

    /// Tile may be the first non-empty tile of a board row.
    ///
    /// This is a `SokobanYASC` board condition: the first non-empty tile in each row must be a wall
    /// or a box standing on a goal. A plain [`Box`](Self::Box) does **not** qualify, and neither
    /// does a lone goal or a pusher.
    ///
    /// Nothing in this crate enforces the condition - [`GameBoard`](crate::GameBoard) places no
    /// restrictions on itself, and boards are edited long before they are played. The predicate is
    /// here for callers that do want to check it.
    pub fn can_start_a_row(&self) -> bool { self.is_wall() || *self == Self::BoxOnGoal }
}

#[cfg(test)]
mod tests {
    use super::*;

    const ALL_KINDS: [TileKind; 7] = [
        TileKind::Box,
        TileKind::BoxOnGoal,
        TileKind::Pusher,
        TileKind::PusherOnGoal,
        TileKind::Floor,
        TileKind::Goal,
        TileKind::Wall,
    ];

    // Every kind the predicate accepts, so each test pins a whole membership set rather than a
    /// few hand-picked variants.
    fn matching(predicate: impl Fn(&TileKind) -> bool) -> Vec<TileKind> {
        ALL_KINDS
            .into_iter()
            .filter(|kind| predicate(kind))
            .collect()
    }

    #[test]
    fn only_the_wall_is_a_wall() {
        assert_eq!(matching(TileKind::is_wall), vec![TileKind::Wall]);
    }

    #[test]
    fn a_pusher_on_a_goal_still_has_a_pusher() {
        assert_eq!(
            matching(TileKind::has_pusher),
            vec![TileKind::Pusher, TileKind::PusherOnGoal]
        );
    }

    #[test]
    fn a_box_on_a_goal_still_has_a_box() {
        assert_eq!(matching(TileKind::has_box), vec![TileKind::Box, TileKind::BoxOnGoal]);
    }

    #[test]
    fn an_occupied_goal_still_has_a_goal() {
        assert_eq!(
            matching(TileKind::has_goal),
            vec![TileKind::BoxOnGoal, TileKind::PusherOnGoal, TileKind::Goal]
        );
    }

    #[test]
    fn only_the_floor_is_empty_floor() {
        assert_eq!(matching(TileKind::is_empty_floor), vec![TileKind::Floor]);
    }

    #[test]
    fn the_default_tile_is_empty_floor() {
        assert!(TileKind::default().is_empty_floor());
    }

    #[test]
    fn only_walls_and_boxes_on_goals_can_start_a_row() {
        assert_eq!(
            matching(TileKind::can_start_a_row),
            vec![TileKind::BoxOnGoal, TileKind::Wall]
        );
    }

    #[test]
    fn a_plain_box_cannot_start_a_row() {
        assert!(!TileKind::Box.can_start_a_row());
        assert!(TileKind::BoxOnGoal.can_start_a_row());
    }
}