diff --git a/board-server/src/leaderboard.rs b/board-server/src/leaderboard.rs index 8a4ddb3..21814e8 100644 --- a/board-server/src/leaderboard.rs +++ b/board-server/src/leaderboard.rs @@ -37,8 +37,9 @@ impl Leaderboard for RedisLeaderboard { async fn get_highscores(&self) -> Result, RedisError> { let mut con = self.client.get_async_connection().await?; let count: isize = con.zcard(LEADERBOARD).await?; - let leaderboard: Vec = - con.zrange_withscores(LEADERBOARD, 0, (count - 1).min(LEADERBOARD_SIZE)).await?; + let leaderboard: Vec = con + .zrange_withscores(LEADERBOARD, 0, (count - 1).min(LEADERBOARD_SIZE)) + .await?; Ok(leaderboard) } diff --git a/board-server/src/room.rs b/board-server/src/room.rs index 6108cb4..c02ad61 100644 --- a/board-server/src/room.rs +++ b/board-server/src/room.rs @@ -210,7 +210,7 @@ impl Room { self.successive_skipped_turns += 1; self.next_player(); } - if !Board::has_alignment(&diff) { + if !Board::has_alignment(&diff) || !self.board.is_unique_chain(&diff) { self.reset_player_moves(); self.send( self.active_player, diff --git a/board-shared/src/board.rs b/board-shared/src/board.rs index 5a124f7..6dd534f 100644 --- a/board-shared/src/board.rs +++ b/board-shared/src/board.rs @@ -1,8 +1,16 @@ -use crate::position::{Alignment, Grid2d, Position2d}; +use crate::position::{Alignment, Direction, Grid2d, Position2d}; use crate::tile::Tile; const DEFAULT_BOARD_SIZE: usize = 19; +/// A board of tiles. +/// +/// This is a fixed-size 2D grid of tiles, where each tile is either empty or +/// contains a single tile. +/// +/// This struct is implement `Default` so that you can use [`Board::default()`] +/// to create a new board with a default size. To create a board with a custom +/// size, use [`Board::new()`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Board { tiles: Vec>, @@ -11,19 +19,47 @@ pub struct Board { } impl Board { + /// Creates a new board with the given size. + pub fn new(width: usize, height: usize) -> Self { + Self { + tiles: vec![None; width * height], + width, + height, + } + } + + /// Gets the tile at the given position, or `None` if there is no tile. + /// + /// # Panics + /// Panics if the position is out of bounds. pub fn get(&self, x: usize, y: usize) -> Option { self.tiles[y * self.width + x] } + /// Sets the tile at the given position. + /// + /// If there was already a tile at the position, it is replaced. + /// + /// # Panics + /// Panics if the position is out of bounds. pub fn set(&mut self, x: usize, y: usize, tile: Tile) { self.tiles[y * self.width + x] = Some(tile); } + /// Removes and returns the tile at the given position. + /// + /// If there was no tile at the position, `None` is returned. + /// + /// # Panics + /// Panics if the position is out of bounds. pub fn take(&mut self, x: usize, y: usize) -> Option { self.tiles[y * self.width + x].take() } /// Gets the difference between this board and another. + /// + /// This returns a vector of positions at which the tiles differ. + /// The order is not guaranteed. pub fn difference(&self, other: &Board) -> Vec { let mut diff = Vec::new(); for y in 0..self.height { @@ -53,6 +89,14 @@ impl Board { chains } + /// Tests whether the given positions form a unique chain. + pub fn is_unique_chain(&self, positions: &Vec) -> bool { + Self::is_aligned(positions, Alignment::Horizontal) + && self.belong_to_same_chain(positions, Direction::Right) + || Self::is_aligned(positions, Alignment::Vertical) + && self.belong_to_same_chain(positions, Direction::Down) + } + /// Finds the starting tile of a chain in the given direction. fn find_starting_tile(&self, pos: Position2d, alignment: Alignment) -> Option { self.get(pos.x, pos.y)?; @@ -97,10 +141,33 @@ impl Board { } } + /// Tests whether the given positions are part of the same chain. + pub fn belong_to_same_chain(&self, positions: &Vec, direction: Direction) -> bool { + let mut it = positions.iter().copied().peekable(); + while let Some(mut pos) = it.next() { + if let Some(&next) = it.peek() { + while !pos.is_contiguous(next) { + if let Some(relative) = pos.relative(direction, self) { + pos = relative; + if self.get(pos.x, pos.y).is_none() { + return false; + } + } else { + return false; + } + } + } + } + true + } + /// Determines whether the given positions are contiguous. /// /// Contiguous means that the positions are adjacent in a straight line, either - /// horizontally or vertically. + /// horizontally or vertically. This does not check the tiles at the positions. + /// + /// You may want to use [`Board::is_unique_chain`] to check if the positions are + /// contiguous based on the current board state. pub fn is_contiguous(positions: &[Position2d]) -> Option { let mut it = positions.iter(); let first = *it.next()?; @@ -180,6 +247,7 @@ impl Default for Board { #[cfg(test)] mod tests { use super::*; + use crate::tile::Digit; fn positions(input: &[(usize, usize)]) -> Vec { input @@ -224,28 +292,28 @@ mod tests { assert!(Board::is_aligned(&[], Alignment::Vertical)); assert!(Board::is_aligned( &positions(&[(0, 0)]), - Alignment::Horizontal + Alignment::Vertical )); assert!(Board::is_aligned( &positions(&[(0, 0)]), - Alignment::Vertical - )); - assert!(Board::is_aligned( - &positions(&[(0, 0), (0, 1)]), Alignment::Horizontal )); assert!(Board::is_aligned( - &positions(&[(0, 0), (1, 0)]), + &positions(&[(0, 0), (0, 1)]), Alignment::Vertical )); - assert!(!Board::is_aligned( + assert!(Board::is_aligned( &positions(&[(0, 0), (1, 0)]), Alignment::Horizontal )); assert!(!Board::is_aligned( - &positions(&[(0, 0), (0, 1)]), + &positions(&[(0, 0), (1, 0)]), Alignment::Vertical )); + assert!(!Board::is_aligned( + &positions(&[(0, 0), (0, 1)]), + Alignment::Horizontal + )); } #[test] @@ -268,4 +336,37 @@ mod tests { assert_eq!(board.find_chains(&[Position2d::new(2, 2)]), expected); assert_eq!(board.find_chains(&[Position2d::new(4, 2)]), expected); } + + #[test] + fn test_is_unique_chain_empty_board() { + let board = Board::default(); + assert_eq!( + board.is_unique_chain(&vec![Position2d::new(0, 0), Position2d::new(0, 1),]), + true + ); + assert_eq!( + board.is_unique_chain(&vec![ + Position2d::new(0, 0), + Position2d::new(0, 1), + Position2d::new(1, 1), + ]), + false + ); + assert_eq!(board.is_unique_chain(&vec![]), true); + } + + #[test] + fn test_is_unique_chain_existing_board() { + let mut board = Board::default(); + board.set(0, 1, Tile::Digit(Digit::new(2))); + board.set(0, 2, Tile::Equals); + board.set(1, 1, Tile::Equals); + assert_eq!(board.is_unique_chain(&positions(&[(0, 0), (0, 3)])), true); + assert_eq!( + board.is_unique_chain(&positions(&[(0, 0), (0, 3), (0, 4)])), + true + ); + assert_eq!(board.is_unique_chain(&positions(&[(1, 1), (2, 1)])), true); + assert_eq!(board.is_unique_chain(&positions(&[(1, 1), (3, 1)])), false); + } } diff --git a/board-shared/src/lib.rs b/board-shared/src/lib.rs index 603693e..7ca103b 100644 --- a/board-shared/src/lib.rs +++ b/board-shared/src/lib.rs @@ -1,3 +1,16 @@ +///! # Scrabble with Numbers +///! +///! This crate provides the core game logic for the Scrabble with Numbers game. +///! It can be used standalone, or as a library for other projects. +///! +///! ## Features +///! - Create and verify game expressions, such as `2*3+4*5`. +///! - Generate valid automatic moves for a given board state, with the `ai` feature. +///! - Check if a player move valid. +///! - Calculate the score of an expression. +///! +///! If you are looking for a server implementation, see the `board-server` crate. + #[cfg(feature = "ai")] pub mod ai; pub mod board; diff --git a/board-shared/src/position.rs b/board-shared/src/position.rs index e686355..241ce9b 100644 --- a/board-shared/src/position.rs +++ b/board-shared/src/position.rs @@ -41,8 +41,8 @@ impl Alignment { /// Test if two positions are aligned in this direction. pub fn is_aligned(self, a: Position2d, b: Position2d) -> bool { match self { - Alignment::Horizontal => a.x == b.x, - Alignment::Vertical => a.y == b.y, + Alignment::Horizontal => a.y == b.y, + Alignment::Vertical => a.x == b.x, } } } @@ -67,6 +67,53 @@ impl Position2d { None } } + + /// Returns an iterator over all positions relative to this position in the given direction. + /// + /// If the positions are not aligned in any direction, the iterator will be empty. + /// If the end position is before the start position, the iterator will be empty. + pub fn iterate_to(self, end: Position2d, offset: (usize, usize)) -> RelativeIterator { + if (self.x != end.x && self.y != end.y) || self.x > end.x || self.y > end.y { + return RelativeIterator { + pos: self, + end: self, + offset: (0, 0), + }; + } + RelativeIterator { + pos: self, + end, + offset, + } + } + + pub fn manhattan_distance(self, other: Position2d) -> usize { + self.x.abs_diff(other.x) + self.y.abs_diff(other.y) + } + + pub fn is_contiguous(self, other: Position2d) -> bool { + self.manhattan_distance(other) == 1 + } +} + +pub struct RelativeIterator { + pos: Position2d, + end: Position2d, + offset: (usize, usize), +} + +impl Iterator for RelativeIterator { + type Item = Position2d; + + fn next(&mut self) -> Option { + if self.pos == self.end { + None + } else { + let pos = self.pos; + self.pos = Position2d::new(self.pos.x + self.offset.0, self.pos.y + self.offset.1); + Some(pos) + } + } } impl From<(usize, usize)> for Position2d {