mnk-games 0.3.0

K-in-a-row games
Documentation
use crate::{MnkBoard, PlaceError, Player};
use std::error::Error;
use std::fmt;

/// Errors which may occur when playing a move in an *m,n,k*-game.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum PlayError {
    /// An error which may occur when the game is already over.
    GameOver(GameStatus),
    /// An error which may occur when a stone cannot be placed at the indicated position.
    PlaceError(PlaceError),
    /// An error which may occur when a move is against a game's rules.
    RuleError {
        /// An informative message about the violated rule.
        message: String,
    },
}

impl fmt::Display for PlayError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::GameOver(status) => write!(f, "game already over: {status}"),
            Self::PlaceError(place_error) => write!(f, "impossible move: {place_error}"),
            Self::RuleError { message } => write!(f, "illegal move: {message}"),
        }
    }
}

impl Error for PlayError {}

/// The current status of a game.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum GameStatus {
    /// The game is over and is a draw.
    Drawn,
    /// The game is not over.
    Ongoing {
        /// The [`Player`] who will play the next move.
        next: Player,
    },
    /// The game is over and has been won by the indicated [`Player`].
    Won(Player),
}

impl fmt::Display for GameStatus {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Drawn => write!(f, "Draw"),
            Self::Ongoing { next } => write!(f, "Next: {next}"),
            Self::Won(player) => write!(f, "{player} won!"),
        }
    }
}

/// A standard [*m,n,k*-game].
///
/// [`Player::X`] and [`Player::O`] alternate placing stones, in that order, on a board with `R`
/// rows and `C` columns until one wins by having `K` stones in a row, column, or diagonal.
///
/// Methods are zero-indexed. Rows at least `R` and columns at least `C` are considered out of
/// bounds.
///
/// [*m,n,k*-game]: https://en.wikipedia.org/wiki/M,n,k-game
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct MnkGame<const R: usize, const C: usize, const K: usize> {
    board: MnkBoard<R, C, K>,
    status: GameStatus,
}

impl<const R: usize, const C: usize, const K: usize> MnkGame<R, C, K> {
    /// Returns a [`GameStatus::Ongoing`] `MnkGame<R, C, K>` with an empty board and current player
    /// [`Player::X`].
    #[must_use]
    pub const fn new() -> Self {
        Self {
            board: MnkBoard::<R, C, K>::new(),
            status: GameStatus::Ongoing { next: Player::X },
        }
    }

    /// The current state of the game's [`MnkBoard`].
    #[must_use]
    pub const fn board(&self) -> &MnkBoard<R, C, K> {
        &self.board
    }

    /// The current [`GameStatus`] of the game.
    #[must_use]
    pub const fn status(&self) -> GameStatus {
        self.status
    }

    /// Attempts to play at the indicated location.
    ///
    /// If successful, plays a stone at the indicated location, switches the current [`Player`],
    /// and checks whether the [`GameStatus`] has changed. Never plays a stone if it also returns an
    /// error.
    ///
    /// # Errors
    ///
    /// - [`PlayError::GameOver`] if the game is [`GameStatus::Drawn`] or [`GameStatus::Won`].
    /// - [`PlayError::PlaceError`] if the indicated location is not a valid move.
    pub fn play_at(&mut self, row: usize, column: usize) -> Result<(), PlayError> {
        match self.status {
            GameStatus::Drawn | GameStatus::Won(_) => Err(PlayError::GameOver(self.status)),
            GameStatus::Ongoing { next } => self.board.place(next, row, column).map_or_else(
                |err| Err(PlayError::PlaceError(err)),
                |()| {
                    self.status = GameStatus::Ongoing { next: !next };
                    self.update_status();
                    Ok(())
                },
            ),
        }
    }

    /// Changes the `status` field.
    ///
    /// [`GameStatus::Won`] if the game has been won. Otherwise, [`GameStatus::Drawn`] if the board
    /// is full and [`GameStatus::Ongoing`] otherwise.
    fn update_status(&mut self) {
        self.status = self.board.winner().map_or_else(
            || {
                if self.board.full() {
                    GameStatus::Drawn
                } else {
                    self.status // To retain the wrapped Player
                }
            },
            GameStatus::Won,
        );
    }
}

impl<const R: usize, const C: usize, const K: usize> Default for MnkGame<R, C, K> {
    fn default() -> Self {
        Self::new()
    }
}

impl<const R: usize, const C: usize, const K: usize> From<MnkGame<R, C, K>> for MnkBoard<R, C, K> {
    fn from(game: MnkGame<R, C, K>) -> Self {
        game.board
    }
}

impl<const R: usize, const C: usize, const K: usize> fmt::Display for MnkGame<R, C, K> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
        write!(f, "{}\n{}", self.board, self.status)
    }
}

#[cfg(test)]
mod test_play_at {
    use super::*;
    use crate::Space;

    #[test]
    fn rejects_finished_games() {
        let mut drawn = MnkGame::<1, 1, 1>::new();
        drawn.status = GameStatus::Drawn;
        assert_eq!(
            drawn.play_at(0, 0),
            Err(PlayError::GameOver(GameStatus::Drawn))
        );
        assert_eq!(drawn.board, MnkBoard::<1, 1, 1>::new());

        let mut x_won = MnkGame::<1, 1, 1>::new();
        x_won.status = GameStatus::Won(Player::X);
        assert_eq!(
            x_won.play_at(0, 0),
            Err(PlayError::GameOver(GameStatus::Won(Player::X)))
        );
        assert_eq!(x_won.board, MnkBoard::<1, 1, 1>::new());

        let mut o_won = MnkGame::<1, 1, 1>::new();
        o_won.status = GameStatus::Won(Player::O);
        assert_eq!(
            o_won.play_at(0, 0),
            Err(PlayError::GameOver(GameStatus::Won(Player::O)))
        );
        assert_eq!(o_won.board, MnkBoard::<1, 1, 1>::new());
    }

    #[test]
    fn rejects_place_errors() {
        let mut empty = MnkGame::<1, 1, 1>::new();
        assert_eq!(
            empty.play_at(1, 0),
            Err(PlayError::PlaceError(PlaceError::OutOfBounds {
                row: 1,
                column: 0
            }))
        );
    }

    #[test]
    fn depends_on_next_player() {
        let mut x_plays = MnkGame::<1, 1, 1>::new();
        assert_eq!(x_plays.play_at(0, 0), Ok(()));
        assert_eq!(x_plays.board.get(0, 0), Some(&Space::Stone(Player::X)));

        let mut o_plays = MnkGame::<1, 1, 1>::new();
        o_plays.status = GameStatus::Ongoing { next: Player::O };
        assert_eq!(o_plays.play_at(0, 0), Ok(()));
        assert_eq!(o_plays.board.get(0, 0), Some(&Space::Stone(Player::O)));
    }

    #[test]
    fn swaps_next_player() {
        let mut x_plays = MnkGame::<2, 2, 2>::new();
        assert_eq!(x_plays.play_at(0, 0), Ok(()));
        assert_eq!(x_plays.status, GameStatus::Ongoing { next: Player::O });

        let mut o_plays = MnkGame::<2, 2, 2>::new();
        o_plays.status = GameStatus::Ongoing { next: Player::O };
        assert_eq!(o_plays.play_at(0, 0), Ok(()));
        assert_eq!(o_plays.status, GameStatus::Ongoing { next: Player::X });
    }

    #[test]
    fn updates_status() {
        let mut x_wins = MnkGame::<1, 1, 1>::new();
        assert_eq!(x_wins.play_at(0, 0), Ok(()));
        assert_eq!(x_wins.status, GameStatus::Won(Player::X));
    }
}

#[cfg(test)]
mod test_update_status {
    use super::*;
    use crate::Space;

    #[test]
    fn detects_wins() {
        let mut x_wins = MnkGame::<1, 1, 1>::new();
        x_wins.board = MnkBoard::from([[Space::Stone(Player::X)]]);
        x_wins.update_status();
        assert_eq!(x_wins.status, GameStatus::Won(Player::X));

        let mut o_wins = MnkGame::<1, 1, 1>::new();
        o_wins.board = MnkBoard::from([[Space::Stone(Player::O)]]);
        o_wins.update_status();
        assert_eq!(o_wins.status, GameStatus::Won(Player::O));
    }

    #[test]
    fn detects_draws() {
        let mut drawn = MnkGame::<1, 1, 2>::new();
        drawn.board = MnkBoard::from([[Space::Stone(Player::X)]]);
        drawn.update_status();
        assert_eq!(drawn.status, GameStatus::Drawn);
    }

    #[test]
    fn detects_ongoing() {
        let mut ongoing = MnkGame::<1, 1, 1>::new();
        ongoing.update_status();
        assert_eq!(ongoing.status, GameStatus::Ongoing { next: Player::X });
    }
}

#[cfg(test)]
mod test_mnk_game_display {
    use crate::{GameStatus, MnkGame, Player};

    #[test]
    fn draw() {
        let mut draw = MnkGame::<1, 1, 1>::new();
        draw.status = GameStatus::Drawn;
        assert_eq!(
            draw.to_string(),
            "+-+\n\
             | |\n\
             +-+\n\
             Draw"
        );
    }

    #[test]
    fn ongoing() {
        let x_next = MnkGame::<1, 1, 1>::new();
        assert_eq!(
            x_next.to_string(),
            "+-+\n\
             | |\n\
             +-+\n\
             Next: X"
        );

        let mut o_next = MnkGame::<1, 1, 1>::new();
        o_next.status = GameStatus::Ongoing { next: Player::O };
        assert_eq!(
            o_next.to_string(),
            "+-+\n\
             | |\n\
             +-+\n\
             Next: O"
        );
    }

    #[test]
    fn won() {
        let mut x_won = MnkGame::<1, 1, 1>::new();
        x_won.status = GameStatus::Won(Player::X);
        assert_eq!(
            x_won.to_string(),
            "+-+\n\
             | |\n\
             +-+\n\
             X won!"
        );

        let mut o_won = MnkGame::<1, 1, 1>::new();
        o_won.status = GameStatus::Won(Player::O);
        assert_eq!(
            o_won.to_string(),
            "+-+\n\
             | |\n\
             +-+\n\
             O won!"
        );
    }
}