diff --git a/src/game.rs b/src/game.rs index 88094a6..2f7ae87 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use rand::prelude::SliceRandom; use rand::rngs::SmallRng; use rand::SeedableRng; @@ -123,6 +125,18 @@ impl PlayerStates { } } + +#[derive(Deserialize, Serialize, Tsify, Debug, Clone)] +#[tsify(from_wasm_abi)] +#[serde(tag = "type")] +pub enum GameState { + InProgress, + Ended { + finisher: Option, + remaining_tiles: HashMap>, + }, +} + pub struct Game{ pub tile_pool: Vec, rng: SmallRng, @@ -130,6 +144,8 @@ pub struct Game{ pub player_states: PlayerStates, dictionary: DictionaryImpl, turn_order: usize, + turns_not_played: usize, + state: GameState, } @@ -183,6 +199,8 @@ impl Game { player_states: PlayerStates(player_states), dictionary, turn_order: 0, + turns_not_played: 0, + state: GameState::InProgress, } } @@ -194,7 +212,7 @@ impl Game { self.board = new_board; } - pub fn fill_trays(&mut self){ + fn fill_trays(&mut self){ for state in self.player_states.0.iter_mut() { let tray = &mut state.tray; tray.fill(&mut self.tile_pool); @@ -205,7 +223,15 @@ impl Game { &self.dictionary } - pub fn receive_play(&mut self, tray_tile_locations: Vec>, commit_move: bool) -> Result { + fn verify_game_in_progress(&self) -> Result<(), String> { + if !matches!(self.state, GameState::InProgress) { + return Err("Moves cannot be made after a game has finished".to_string()); + } + Ok(()) + } + + pub fn receive_play(&mut self, tray_tile_locations: Vec>, commit_move: bool) -> Result<(TurnAction, GameState), String> { + self.verify_game_in_progress()?; let player = self.current_player_name(); @@ -244,18 +270,26 @@ impl Game { self.set_board(board_instance); self.fill_trays(); - self.increment_turn(); + self.increment_turn(true); + + if self.get_player_tile_count(&player).unwrap() == 0 { + // game is over + self.end_game(Some(player)); + } + } - Ok(TurnAction::PlayTiles { + Ok((TurnAction::PlayTiles { result: ScoreResult { words, total: total_score, }, - }) + }, self.state.clone())) } - pub fn exchange_tiles(&mut self, tray_tile_locations: Vec) -> Result<(Tray, TurnAction), String> { + pub fn exchange_tiles(&mut self, tray_tile_locations: Vec) -> Result<(Tray, TurnAction, GameState), String> { + self.verify_game_in_progress()?; + let player = self.current_player_name(); let tray = match self.player_states.get_tray_mut(&player) { None => {return Err(format!("Player {} not found", player))} @@ -285,9 +319,9 @@ impl Game { let tray = tray.clone(); - self.increment_turn(); + let state = self.increment_turn(false); - Ok((tray, TurnAction::ExchangeTiles { tiles_exchanged })) + Ok((tray, TurnAction::ExchangeTiles { tiles_exchanged }, state.clone())) } @@ -297,15 +331,66 @@ impl Game { self.dictionary.insert(word, -1.0); } - pub fn increment_turn(&mut self) { + pub fn pass(&mut self) -> Result { + self.verify_game_in_progress()?; + Ok(self.increment_turn(false).clone()) + } + + fn increment_turn(&mut self, played: bool) -> &GameState{ self.turn_order += 1; + if !played { + self.turns_not_played += 1; + + // check if game has ended due to passing + if self.turns_not_played >= 2*self.player_states.0.len() { + self.end_game(None); + } + + } else { + self.turns_not_played = 0; + } + + &self.state + } + + fn end_game(&mut self, finisher: Option) { + + let mut finished_letters_map = HashMap::new(); + let mut points_forfeit = 0; + + for player in self.player_states.0.iter_mut() { + let tray = &mut player.tray; + let mut letters_remaining = Vec::new(); + let mut player_points_lost = 0; + for letter in tray.letters.iter_mut() { + if let Some(letter) = letter { + player_points_lost += letter.points; + letters_remaining.push(letter.clone()); + } + *letter = None; + } + + points_forfeit += player_points_lost; + player.score -= player_points_lost; + finished_letters_map.insert(player.player.get_name().to_string(), letters_remaining); + } + + if let Some(finisher) = &finisher { + let mut state = self.player_states.get_player_state_mut(finisher).unwrap(); + state.score += points_forfeit; + } + + self.state = GameState::Ended { + finisher, + remaining_tiles: finished_letters_map + }; } pub fn current_player_name(&self) -> String { self.player_states.get_player_name_by_turn_id(self.turn_order).to_string() } - pub fn advance_turn(&mut self) -> Result { + pub fn advance_turn(&mut self) -> Result<(TurnAdvanceResult, GameState), String> { let current_player = self.current_player_name(); let state = self.player_states.get_player_state_mut(¤t_player).ok_or("There should be a player available")?; @@ -330,32 +415,32 @@ impl Game { } if self.tile_pool.is_empty(){ - self.increment_turn(); - Ok(TurnAdvanceResult::AIMove { + let game_state = self.increment_turn(false); + Ok((TurnAdvanceResult::AIMove { name: current_player, action: TurnAction::Pass, - }) + }, game_state.clone())) } else { - let (_, action) = self.exchange_tiles(to_exchange)?; - Ok(TurnAdvanceResult::AIMove { + let (_, action, game_state) = self.exchange_tiles(to_exchange)?; + Ok((TurnAdvanceResult::AIMove { name: current_player, action, - }) + }, game_state)) } } Some(best_move) => { let play = best_move.convert_to_play(tray); - let action = self.receive_play(play, true)?; - Ok(TurnAdvanceResult::AIMove { + let (action, game_state) = self.receive_play(play, true)?; + Ok((TurnAdvanceResult::AIMove { name: current_player, action, - }) + }, game_state)) } } } else { - Ok(TurnAdvanceResult::HumanInputRequired{name: self.current_player_name()}) + Ok((TurnAdvanceResult::HumanInputRequired{name: self.current_player_name()}, self.state.clone())) } @@ -372,12 +457,12 @@ impl Game { }; Ok( - tray.letters.iter() - .filter(|l| l.is_some()) - .count() + tray.count() ) } + + } #[derive(Serialize, Deserialize, Tsify, Debug)] @@ -426,13 +511,17 @@ mod tests { println!("Current player is {current_player}"); assert_eq!(current_player, "Player"); - game.increment_turn(); - assert_eq!(game.current_player_name(), "AI 0"); + assert_eq!(0, game.turns_not_played); + game.increment_turn(false); + assert_eq!(1, game.turns_not_played); + + assert_eq!(game.current_player_name(), "AI"); let result = game.advance_turn(); println!("AI move is {result:?}"); assert_eq!(game.current_player_name(), "Player"); + assert_eq!(0, game.turns_not_played); } diff --git a/src/player_interaction.rs b/src/player_interaction.rs index 5f18a9d..0e32b17 100644 --- a/src/player_interaction.rs +++ b/src/player_interaction.rs @@ -36,6 +36,12 @@ impl Tray { } } + pub fn count(&self) -> usize { + self.letters.iter() + .filter(|l| l.is_some()) + .count() + } + } diff --git a/src/wasm.rs b/src/wasm.rs index f03a336..1aa4bd2 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -4,7 +4,7 @@ use tsify::Tsify; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::wasm_bindgen; use crate::board::{CellType, Letter}; -use crate::game::{Game, PlayedTile}; +use crate::game::{Game, GameState, PlayedTile}; use crate::player_interaction::ai::Difficulty; #[wasm_bindgen] @@ -22,10 +22,12 @@ pub enum ResponseType { pub struct MyResult { response_type: ResponseType, value: E, + game_state: Option, } + #[wasm_bindgen] impl GameWasm { @@ -69,16 +71,18 @@ impl GameWasm { let result = self.0.receive_play(tray_tile_locations, commit_move); match result { - Ok(x) => { + Ok((x, game_state)) => { serde_wasm_bindgen::to_value(&MyResult { response_type: ResponseType::OK, - value: x + value: x, + game_state: Some(game_state) }) }, Err(e) => { serde_wasm_bindgen::to_value(&MyResult { response_type: ResponseType::ERR, - value: e + value: e, + game_state: None, }) } } @@ -113,16 +117,18 @@ impl GameWasm { let tray_tile_locations: Vec = serde_wasm_bindgen::from_value(tray_tile_locations)?; match self.0.exchange_tiles(tray_tile_locations) { - Ok((_, turn_action)) => { + Ok((_, turn_action, state)) => { serde_wasm_bindgen::to_value(&MyResult { response_type: ResponseType::OK, - value: turn_action + value: turn_action, + game_state: Some(state), }) }, Err(e) => { serde_wasm_bindgen::to_value(&MyResult { response_type: ResponseType::ERR, - value: e + value: e, + game_state: None, }) } } @@ -134,17 +140,43 @@ impl GameWasm { self.0.add_word(word); } - pub fn skip_turn(&mut self) {self.0.increment_turn()} + pub fn skip_turn(&mut self) -> Result{ + let result = self.0.pass(); + match result { + Ok(game_state) => { + Ok(serde_wasm_bindgen::to_value(&MyResult { + response_type: ResponseType::OK, + value: "Turn passed", + game_state: Some(game_state), + })?) + }, + Err(e) => { + Ok(serde_wasm_bindgen::to_value(&MyResult { + response_type: ResponseType::ERR, + value: e, + game_state: None, + })?) + } + } + } pub fn advance_turn(&mut self) -> Result { let result = self.0.advance_turn(); match result { - Ok(x) => { - Ok(serde_wasm_bindgen::to_value(&x)?) + Ok((turn_advance_result, game_state)) => { + Ok(serde_wasm_bindgen::to_value(&MyResult { + response_type: ResponseType::OK, + value: turn_advance_result, + game_state: Some(game_state), + })?) }, Err(e) => { - Ok(serde_wasm_bindgen::to_value(&e)?) + Ok(serde_wasm_bindgen::to_value(&MyResult { + response_type: ResponseType::ERR, + value: e, + game_state: None, + })?) } } @@ -165,12 +197,14 @@ impl GameWasm { Ok(serde_wasm_bindgen::to_value(&MyResult{ response_type: ResponseType::OK, value: count, + game_state: None, })?) }, Err(msg) => { Ok(serde_wasm_bindgen::to_value(&MyResult{ response_type: ResponseType::OK, value: msg, + game_state: None, })?) } } diff --git a/ui/src/Game.tsx b/ui/src/Game.tsx index 78cd2a7..aa8725b 100644 --- a/ui/src/Game.tsx +++ b/ui/src/Game.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { - GameWasm, + GameState, + GameWasm, Letter, Letter as LetterData, MyResult, PlayedTile, @@ -38,6 +39,7 @@ export function Game(props: { return props.wasm.get_board_cell_types(); }, []); + const [isGameOver, setGameOver] = useState(false); const [confirmedScorePoints, setConfirmedScorePoints] = useState(-1); function movePlayableLetters(playerLetters: PlayableLetterData[], update: TileDispatchAction) { @@ -89,13 +91,16 @@ export function Game(props: { function exchangeFunction(selectedArray: Array) { const result: MyResult = props.wasm.exchange_tiles(selectedArray); - console.log({result}); if(result.response_type === "ERR") { logDispatch(
{(result.value as string)}
); } else { handlePlayerAction(result.value as TurnAction, props.settings.playerName); setTurnCount(turnCount + 1); + + if(result.game_state.type === "Ended") { + endGame(result.game_state); + } } } @@ -105,26 +110,13 @@ export function Game(props: { logDispatch(
{word} was added to dictionary.
); } - function runAI() { - const result: TurnAdvanceResult = props.wasm.advance_turn(); - if(result.type == "AIMove"){ - handlePlayerAction(result.action, props.settings.aiName); - } else { - // this would be quite surprising - console.error({result}); - } - - setTurnCount(turnCount + 1); - } - - const [playerLetters, trayDispatch] = useReducer(movePlayableLetters, []); const [logInfo, logDispatch] = useReducer(addLogInfo, []); const [turnCount, setTurnCount] = useState(1); const playerAndScores: PlayerAndScore[] = useMemo(() => { return props.wasm.get_scores(); - }, [turnCount]); + }, [turnCount, isGameOver]); const [boardLetters, setBoardLetters] = useState(() => { const newLetterData = [] as HighlightableLetterData[]; @@ -156,18 +148,6 @@ export function Game(props: { return props.wasm.get_current_player(); }, [turnCount]); - - useEffect(() => { - logDispatch(

Turn {turnCount}

); - logDispatch(
{playerTurnName}'s turn
) - setConfirmedScorePoints(-1); - trayDispatch({action: TileDispatchActionType.RETRIEVE}); - if(playerTurnName != props.settings.playerName) { - runAI(); - } - - }, [turnCount]); - const logDivRef = useRef(null); const [isTileExchangeOpen, setIsTileExchangeOpen] = useState(false); @@ -181,7 +161,7 @@ export function Game(props: { const remainingTiles = useMemo(() => { return props.wasm.get_remaining_tiles(); - }, [turnCount]); + }, [turnCount, isGameOver]); const remainingAITiles = useMemo(() => { let result = props.wasm.get_player_tile_count(props.settings.aiName) as MyResult; @@ -192,7 +172,7 @@ export function Game(props: { return -1; } - }, [turnCount]); + }, [turnCount, isGameOver]); function handlePlayerAction(action: TurnAction, playerName: string) { @@ -211,6 +191,110 @@ export function Game(props: { } } + function endGame(state: GameState) { + + if(state.type != "InProgress") { + + setGameOver(true); + logDispatch(

Scoring

); + + const scores = props.wasm.get_scores() as PlayerAndScore[]; + let pointsBonus = 0; + for(const playerAndScore of scores) { + const name = playerAndScore.name; + + if(name == state.finisher) { + // we'll do the finisher last + continue + } + + const letters = state.remaining_tiles.get(name); + if(letters.length == 0) { + logDispatch(
{name} has no remaining tiles.
); + } else { + let pointsLost = 0; + let letterListStr = ''; + for(let i=0; i 2) { + if(i == letters.length - 2) { + letterListStr += ', and '; + } else if (i < letters.length - 2) { + letterListStr += ', '; + } + } else if (i == 0 && letters.length == 2){ + // list of 2 + letterListStr += ' and '; + } + + } + logDispatch(
{name} penalized {pointsLost} points for not using {letterListStr}.
); + pointsBonus += pointsLost; + } + } + + if(state.finisher != null) { + logDispatch(
{state.finisher} receives {pointsBonus} bonus for completing first.
); + } + + const highestScore = scores + .map((score) => score.score) + .sort((a, b) => b - a) + .at(0); + + const playersAtHighest = scores.filter((score) => score.score == highestScore); + let endGameMsg: string = ''; + + if(playersAtHighest.length > 1 && state.finisher == null) { + endGameMsg = "Tie game!"; + } else if (playersAtHighest.length > 1 && state.finisher != null) { + // if there's a tie then the finisher gets the win + endGameMsg = `${playersAtHighest[0].name} won by finishing first!`; + } + else { + endGameMsg = `${playersAtHighest[0].name} won!`; + } + logDispatch(

Game over - {endGameMsg}

); + } else { + // what are we doing in this function?! + console.error("endGame was called despite the state being InProgress!"); + } + + } + + function runAI() { + const result: MyResult = props.wasm.advance_turn(); + if(result.response_type === "OK" && result.value.type == "AIMove") { + handlePlayerAction(result.value.action, props.settings.aiName); + if(result.game_state.type === "Ended") { + endGame(result.game_state); + } + + } else { + // this would be quite surprising + console.error({result}); + } + + setTurnCount(turnCount + 1); + } + + useEffect(() => { + logDispatch(

Turn {turnCount}

); + logDispatch(
{playerTurnName}'s turn
) + setConfirmedScorePoints(-1); + trayDispatch({action: TileDispatchActionType.RETRIEVE}); + if(playerTurnName != props.settings.playerName && !isGameOver) { + runAI(); + } + + }, [turnCount]); + return <> {props.settings.aiName} has {remainingAITiles} tiles - @@ -289,6 +375,10 @@ export function Game(props: { setTurnCount(turnCount + 1); } + if (result.game_state.type === "Ended") { + endGame(result.game_state); + } + setConfirmedScorePoints(total_points); } @@ -299,9 +389,13 @@ export function Game(props: { trayDispatch({action: TileDispatchActionType.RETURN}); }}>Return Tiles diff --git a/ui/src/TileExchange.tsx b/ui/src/TileExchange.tsx index 01334b6..e2a9094 100644 --- a/ui/src/TileExchange.tsx +++ b/ui/src/TileExchange.tsx @@ -96,8 +96,10 @@ function TilesExchangedTray(props: { } } - return
- {divContent} + return
+
+ {divContent} +
; } diff --git a/ui/src/style.less b/ui/src/style.less index 080ab22..865561a 100644 --- a/ui/src/style.less +++ b/ui/src/style.less @@ -3,12 +3,7 @@ @board-length: 15; @tile-star-size: 45px; -.tray-row { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - width: @board-length*@tile-width; - - .tray { +.tray { // Don't put under tray-row as this also gets used in tile exchange display: grid; grid-template-columns: repeat(7, @tile-width); grid-gap: 5px; @@ -16,7 +11,12 @@ width: fit-content; background-color: #bbb59d; margin: 10px; - } +} + +.tray-row { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + width: @board-length*@tile-width; .player-controls { display: grid;