diff --git a/board-frontend/Cargo.toml b/board-frontend/Cargo.toml index 9b112be..5b96cf5 100644 --- a/board-frontend/Cargo.toml +++ b/board-frontend/Cargo.toml @@ -11,3 +11,4 @@ categories = ["gui", "wasm", "web-programming"] yew = { version="0.20", features=["csr"] } board-shared = { path = "../board-shared" } gloo-dialogs = "0.1.1" +getrandom = { version = "0.2.8", features = ["js"] } diff --git a/board-frontend/src/app.rs b/board-frontend/src/app.rs index 97fe667..b7af009 100644 --- a/board-frontend/src/app.rs +++ b/board-frontend/src/app.rs @@ -1,11 +1,12 @@ use crate::hand_view::HandView; use crate::tile_view::PlacedTileView; use board_shared::board::Board; +use board_shared::deck::RngDeck; use board_shared::expr::is_valid_guess; use board_shared::game::Game; +use board_shared::position::Grid2d; use board_shared::tile::Tile; use gloo_dialogs::alert; -use board_shared::position::Grid2d; use yew::prelude::*; enum SelectedTile { @@ -78,11 +79,14 @@ pub fn app() -> Html { let current_game = current_game.clone(); Callback::from(move |_| { let diff = game.board.difference(¤t_game.board); + let mut deck = RngDeck::new_complete(); if let Some(true) = Board::is_contiguous(&diff) { if let Ok(true) = is_valid_guess(¤t_game.board, &diff) { alert("Valid move!"); let mut in_hand = current_game.in_hand.clone(); - in_hand.complete(); + if in_hand.complete(&mut deck).is_err() { + alert("No more tiles left in deck!"); + } game.set(Game { board: current_game.board.clone(), in_hand: in_hand.clone(), @@ -103,7 +107,9 @@ pub fn app() -> Html { alert("Invalid move! (not contiguous)"); } let mut in_hand = game.in_hand.clone(); - in_hand.complete(); + if in_hand.complete(&mut deck).is_err() { + alert("No more tiles left in deck!"); + } current_game.set(Game { board: game.board.clone(), in_hand, diff --git a/board-shared/Cargo.toml b/board-shared/Cargo.toml index a7885eb..667928c 100644 --- a/board-shared/Cargo.toml +++ b/board-shared/Cargo.toml @@ -6,3 +6,5 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +enum-map = "2.4.2" +rand = "0.8.5" diff --git a/board-shared/src/deck.rs b/board-shared/src/deck.rs new file mode 100644 index 0000000..f206ab0 --- /dev/null +++ b/board-shared/src/deck.rs @@ -0,0 +1,166 @@ +use crate::tile::Operator; +use enum_map::EnumMap; +use rand::Rng; + +type DeckSize = u16; +type DigitDeck = [DeckSize; 19]; + +/// When a deck is empty, new tiles cannot be retrieved. +pub type EmptyDeckError = (); + +/// A entire deck of tiles. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Deck { + /// The digits and their current count. + digits: DigitDeck, + /// The operators and their current count. + operators: EnumMap, +} + +impl Deck { + pub fn new_complete() -> Self { + let mut deck = Self::default(); + deck.add_operator_times(Operator::Add, 10); + deck.add_operator_times(Operator::Subtract, 10); + deck.add_operator_times(Operator::Multiply, 6); + deck.add_operator_times(Operator::Divide, 4); + for digit in -9..0 { + deck.add_digit_times(digit, 2); + } + for digit in 0..=9 { + deck.add_digit_times(digit, 8); + } + deck + } + + /// Adds a single digit to the deck. + pub fn add_digit(&mut self, digit: i8) { + self.add_digit_times(digit, 1) + } + + /// Adds a digit multiple times to the deck. + pub fn add_digit_times(&mut self, digit: i8, times: DeckSize) { + self.digits[Deck::digit_index(digit)] += times; + } + + /// Adds a single operator to the deck. + pub fn add_operator(&mut self, operator: Operator) { + self.add_operator_times(operator, 1) + } + + /// Adds an operator multiple times to the deck. + pub fn add_operator_times(&mut self, operator: Operator, times: DeckSize) { + self.operators[operator] += times; + } + + /// Gets the index of a digit in the digit deck. + fn digit_index(digit: i8) -> usize { + (digit + 9) as usize + } +} + +/// A deck of tiles that can be chosen at random. +#[derive(Debug, Clone, Default)] +pub struct RngDeck { + deck: Deck, + rng: rand::rngs::ThreadRng, +} + +impl RngDeck { + pub fn new_complete() -> Self { + Self { + deck: Deck::new_complete(), + rng: rand::thread_rng(), + } + } + + /// Gets a random tile from the deck and remove it from the deck. + pub fn rand_digit(&mut self) -> Option { + let sum = self.deck.digits.iter().sum(); + Self::select_rng(&mut self.rng, sum, self.deck.digits.iter_mut().enumerate()) + .map(|n| n as i8 - 9) + } + + /// Gets a random operator from the deck and remove it from the deck. + pub fn rand_operator(&mut self) -> Option { + let sum = self.deck.operators.values().sum(); + Self::select_rng(&mut self.rng, sum, self.deck.operators.iter_mut()) + } + + /// Selects a random item from an iterator of (item, count) pairs. + /// The count is decremented by one if the item is selected. + fn select_rng<'a, T>( + rng: &mut rand::rngs::ThreadRng, + sum: DeckSize, + it: impl Iterator, + ) -> Option { + if sum == 0 { + return None; + } + let mut threshold = rng.gen_range(1..=sum); + for (item, count) in it { + threshold = threshold.saturating_sub(*count); + if threshold == 0 { + *count -= 1; + return Some(item); + } + } + unreachable!() + } +} + +impl PartialEq for RngDeck { + fn eq(&self, other: &Self) -> bool { + self.deck == other.deck + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_empty() { + let mut deck = RngDeck::default(); + assert_eq!(deck.rand_digit(), None); + assert_eq!(deck.rand_operator(), None); + } + + #[test] + fn one_digit() { + let mut deck = RngDeck::default(); + deck.deck.add_digit(1); + assert_eq!(deck.rand_digit(), Some(1)); + assert_eq!(deck.rand_operator(), None); + assert_eq!(deck.rand_digit(), None); + } + + #[test] + fn one_operator() { + let mut deck = RngDeck::default(); + deck.deck.add_operator(Operator::Multiply); + assert_eq!(deck.rand_digit(), None); + assert_eq!(deck.rand_operator(), Some(Operator::Multiply)); + assert_eq!(deck.rand_operator(), None); + } + + #[test] + fn respect_proportion() { + let mut deck = RngDeck::default(); + deck.deck.add_digit_times(-4, 2); + deck.deck.add_digit_times(3, 7); + deck.deck.add_digit_times(7, 3); + + let mut actual = DigitDeck::default(); + while let Some(digit) = deck.rand_digit() { + actual[Deck::digit_index(digit)] += 1; + } + + let mut excepted = DigitDeck::default(); + excepted[Deck::digit_index(-4)] = 2; + excepted[Deck::digit_index(3)] = 7; + excepted[Deck::digit_index(7)] = 3; + + assert_eq!(actual, excepted); + } +} diff --git a/board-shared/src/game.rs b/board-shared/src/game.rs index cb18f54..4098185 100644 --- a/board-shared/src/game.rs +++ b/board-shared/src/game.rs @@ -1,5 +1,6 @@ use crate::board::Board; -use crate::tile::{Digit, Operator, Tile}; +use crate::deck::{EmptyDeckError, RngDeck}; +use crate::tile::{Digit, Tile}; #[derive(Default, Debug, Clone, PartialEq)] pub struct Game { @@ -31,12 +32,15 @@ impl Hand { ) } - pub fn complete(&mut self) { + pub fn complete(&mut self, deck: &mut RngDeck) -> Result<(), EmptyDeckError> { for _ in 0..self.count_missing_operators() { - self.tiles.push(Tile::Operator(Operator::Add)); + self.tiles + .push(Tile::Operator(deck.rand_operator().ok_or(())?)); } - for n in 0..self.count_missing_numbers() { - self.tiles.push(Tile::Digit(Digit::new((n % 10) as u8))); + for _ in 0..self.count_missing_numbers() { + self.tiles + .push(Tile::Digit(Digit::new(deck.rand_digit().ok_or(())?))); } + Ok(()) } } diff --git a/board-shared/src/lib.rs b/board-shared/src/lib.rs index d524a51..4bbf163 100644 --- a/board-shared/src/lib.rs +++ b/board-shared/src/lib.rs @@ -1,4 +1,5 @@ pub mod board; +pub mod deck; pub mod expr; pub mod game; mod lexer; diff --git a/board-shared/src/tile.rs b/board-shared/src/tile.rs index 644393f..ee3d0cf 100644 --- a/board-shared/src/tile.rs +++ b/board-shared/src/tile.rs @@ -1,15 +1,16 @@ +use enum_map::Enum; use std::fmt; /// A single digit that can be wrapped in parentheses. #[derive(Debug, PartialEq, Eq, Copy, Clone)] pub struct Digit { - pub value: u8, + pub value: i8, pub has_left_parenthesis: bool, pub has_right_parenthesis: bool, } impl Digit { - pub fn new(value: u8) -> Self { + pub fn new(value: i8) -> Self { Self { value, has_left_parenthesis: false, @@ -44,9 +45,9 @@ impl TryFrom<&str> for Digit { let c = it.next().ok_or(())?; if c == '(' { res.has_left_parenthesis = true; - res.value = it.next().ok_or(())?.to_digit(10).ok_or(())? as u8; + res.value = it.next().ok_or(())?.to_digit(10).ok_or(())? as i8; } else { - res.value = c.to_digit(10).ok_or(())? as u8; + res.value = c.to_digit(10).ok_or(())? as i8; } if let Some(c) = it.next() { if c != ')' { @@ -59,7 +60,7 @@ impl TryFrom<&str> for Digit { } /// An operator that can be applied between two terms. -#[derive(Debug, PartialEq, Eq, Copy, Clone)] +#[derive(Debug, PartialEq, Eq, Copy, Clone, Enum)] pub enum Operator { Add, Subtract,