Add end game conditions

Also fix some UI bugs
This commit is contained in:
Joel Therrien 2023-09-20 18:59:32 -07:00
parent 0d30ac0b46
commit e0fe22e9ce
6 changed files with 302 additions and 77 deletions

View file

@ -1,3 +1,5 @@
use std::collections::HashMap;
use rand::prelude::SliceRandom; use rand::prelude::SliceRandom;
use rand::rngs::SmallRng; use rand::rngs::SmallRng;
use rand::SeedableRng; 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<String>,
remaining_tiles: HashMap<String, Vec<Letter>>,
},
}
pub struct Game{ pub struct Game{
pub tile_pool: Vec<Letter>, pub tile_pool: Vec<Letter>,
rng: SmallRng, rng: SmallRng,
@ -130,6 +144,8 @@ pub struct Game{
pub player_states: PlayerStates, pub player_states: PlayerStates,
dictionary: DictionaryImpl, dictionary: DictionaryImpl,
turn_order: usize, turn_order: usize,
turns_not_played: usize,
state: GameState,
} }
@ -183,6 +199,8 @@ impl Game {
player_states: PlayerStates(player_states), player_states: PlayerStates(player_states),
dictionary, dictionary,
turn_order: 0, turn_order: 0,
turns_not_played: 0,
state: GameState::InProgress,
} }
} }
@ -194,7 +212,7 @@ impl Game {
self.board = new_board; self.board = new_board;
} }
pub fn fill_trays(&mut self){ fn fill_trays(&mut self){
for state in self.player_states.0.iter_mut() { for state in self.player_states.0.iter_mut() {
let tray = &mut state.tray; let tray = &mut state.tray;
tray.fill(&mut self.tile_pool); tray.fill(&mut self.tile_pool);
@ -205,7 +223,15 @@ impl Game {
&self.dictionary &self.dictionary
} }
pub fn receive_play(&mut self, tray_tile_locations: Vec<Option<PlayedTile>>, commit_move: bool) -> Result<TurnAction, String> { 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<Option<PlayedTile>>, commit_move: bool) -> Result<(TurnAction, GameState), String> {
self.verify_game_in_progress()?;
let player = self.current_player_name(); let player = self.current_player_name();
@ -244,18 +270,26 @@ impl Game {
self.set_board(board_instance); self.set_board(board_instance);
self.fill_trays(); 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 { result: ScoreResult {
words, words,
total: total_score, total: total_score,
}, },
}) }, self.state.clone()))
} }
pub fn exchange_tiles(&mut self, tray_tile_locations: Vec<bool>) -> Result<(Tray, TurnAction), String> { pub fn exchange_tiles(&mut self, tray_tile_locations: Vec<bool>) -> Result<(Tray, TurnAction, GameState), String> {
self.verify_game_in_progress()?;
let player = self.current_player_name(); let player = self.current_player_name();
let tray = match self.player_states.get_tray_mut(&player) { let tray = match self.player_states.get_tray_mut(&player) {
None => {return Err(format!("Player {} not found", player))} None => {return Err(format!("Player {} not found", player))}
@ -285,9 +319,9 @@ impl Game {
let tray = tray.clone(); 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); self.dictionary.insert(word, -1.0);
} }
pub fn increment_turn(&mut self) { pub fn pass(&mut self) -> Result<GameState, String> {
self.verify_game_in_progress()?;
Ok(self.increment_turn(false).clone())
}
fn increment_turn(&mut self, played: bool) -> &GameState{
self.turn_order += 1; 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<String>) {
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 { pub fn current_player_name(&self) -> String {
self.player_states.get_player_name_by_turn_id(self.turn_order).to_string() self.player_states.get_player_name_by_turn_id(self.turn_order).to_string()
} }
pub fn advance_turn(&mut self) -> Result<TurnAdvanceResult, String> { pub fn advance_turn(&mut self) -> Result<(TurnAdvanceResult, GameState), String> {
let current_player = self.current_player_name(); let current_player = self.current_player_name();
let state = self.player_states.get_player_state_mut(&current_player).ok_or("There should be a player available")?; let state = self.player_states.get_player_state_mut(&current_player).ok_or("There should be a player available")?;
@ -330,32 +415,32 @@ impl Game {
} }
if self.tile_pool.is_empty(){ if self.tile_pool.is_empty(){
self.increment_turn(); let game_state = self.increment_turn(false);
Ok(TurnAdvanceResult::AIMove { Ok((TurnAdvanceResult::AIMove {
name: current_player, name: current_player,
action: TurnAction::Pass, action: TurnAction::Pass,
}) }, game_state.clone()))
} else { } else {
let (_, action) = self.exchange_tiles(to_exchange)?; let (_, action, game_state) = self.exchange_tiles(to_exchange)?;
Ok(TurnAdvanceResult::AIMove { Ok((TurnAdvanceResult::AIMove {
name: current_player, name: current_player,
action, action,
}) }, game_state))
} }
} }
Some(best_move) => { Some(best_move) => {
let play = best_move.convert_to_play(tray); let play = best_move.convert_to_play(tray);
let action = self.receive_play(play, true)?; let (action, game_state) = self.receive_play(play, true)?;
Ok(TurnAdvanceResult::AIMove { Ok((TurnAdvanceResult::AIMove {
name: current_player, name: current_player,
action, action,
}) }, game_state))
} }
} }
} else { } 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( Ok(
tray.letters.iter() tray.count()
.filter(|l| l.is_some())
.count()
) )
} }
} }
#[derive(Serialize, Deserialize, Tsify, Debug)] #[derive(Serialize, Deserialize, Tsify, Debug)]
@ -426,13 +511,17 @@ mod tests {
println!("Current player is {current_player}"); println!("Current player is {current_player}");
assert_eq!(current_player, "Player"); assert_eq!(current_player, "Player");
game.increment_turn(); assert_eq!(0, game.turns_not_played);
assert_eq!(game.current_player_name(), "AI 0"); game.increment_turn(false);
assert_eq!(1, game.turns_not_played);
assert_eq!(game.current_player_name(), "AI");
let result = game.advance_turn(); let result = game.advance_turn();
println!("AI move is {result:?}"); println!("AI move is {result:?}");
assert_eq!(game.current_player_name(), "Player"); assert_eq!(game.current_player_name(), "Player");
assert_eq!(0, game.turns_not_played);
} }

View file

@ -36,6 +36,12 @@ impl Tray {
} }
} }
pub fn count(&self) -> usize {
self.letters.iter()
.filter(|l| l.is_some())
.count()
}
} }

View file

@ -4,7 +4,7 @@ use tsify::Tsify;
use wasm_bindgen::JsValue; use wasm_bindgen::JsValue;
use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::prelude::wasm_bindgen;
use crate::board::{CellType, Letter}; use crate::board::{CellType, Letter};
use crate::game::{Game, PlayedTile}; use crate::game::{Game, GameState, PlayedTile};
use crate::player_interaction::ai::Difficulty; use crate::player_interaction::ai::Difficulty;
#[wasm_bindgen] #[wasm_bindgen]
@ -22,10 +22,12 @@ pub enum ResponseType {
pub struct MyResult<E: Serialize> { pub struct MyResult<E: Serialize> {
response_type: ResponseType, response_type: ResponseType,
value: E, value: E,
game_state: Option<GameState>,
} }
#[wasm_bindgen] #[wasm_bindgen]
impl GameWasm { impl GameWasm {
@ -69,16 +71,18 @@ impl GameWasm {
let result = self.0.receive_play(tray_tile_locations, commit_move); let result = self.0.receive_play(tray_tile_locations, commit_move);
match result { match result {
Ok(x) => { Ok((x, game_state)) => {
serde_wasm_bindgen::to_value(&MyResult { serde_wasm_bindgen::to_value(&MyResult {
response_type: ResponseType::OK, response_type: ResponseType::OK,
value: x value: x,
game_state: Some(game_state)
}) })
}, },
Err(e) => { Err(e) => {
serde_wasm_bindgen::to_value(&MyResult { serde_wasm_bindgen::to_value(&MyResult {
response_type: ResponseType::ERR, response_type: ResponseType::ERR,
value: e value: e,
game_state: None,
}) })
} }
} }
@ -113,16 +117,18 @@ impl GameWasm {
let tray_tile_locations: Vec<bool> = serde_wasm_bindgen::from_value(tray_tile_locations)?; let tray_tile_locations: Vec<bool> = serde_wasm_bindgen::from_value(tray_tile_locations)?;
match self.0.exchange_tiles(tray_tile_locations) { match self.0.exchange_tiles(tray_tile_locations) {
Ok((_, turn_action)) => { Ok((_, turn_action, state)) => {
serde_wasm_bindgen::to_value(&MyResult { serde_wasm_bindgen::to_value(&MyResult {
response_type: ResponseType::OK, response_type: ResponseType::OK,
value: turn_action value: turn_action,
game_state: Some(state),
}) })
}, },
Err(e) => { Err(e) => {
serde_wasm_bindgen::to_value(&MyResult { serde_wasm_bindgen::to_value(&MyResult {
response_type: ResponseType::ERR, response_type: ResponseType::ERR,
value: e value: e,
game_state: None,
}) })
} }
} }
@ -134,17 +140,43 @@ impl GameWasm {
self.0.add_word(word); self.0.add_word(word);
} }
pub fn skip_turn(&mut self) {self.0.increment_turn()} pub fn skip_turn(&mut self) -> Result<JsValue, Error>{
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<JsValue, Error> { pub fn advance_turn(&mut self) -> Result<JsValue, Error> {
let result = self.0.advance_turn(); let result = self.0.advance_turn();
match result { match result {
Ok(x) => { Ok((turn_advance_result, game_state)) => {
Ok(serde_wasm_bindgen::to_value(&x)?) Ok(serde_wasm_bindgen::to_value(&MyResult {
response_type: ResponseType::OK,
value: turn_advance_result,
game_state: Some(game_state),
})?)
}, },
Err(e) => { 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{ Ok(serde_wasm_bindgen::to_value(&MyResult{
response_type: ResponseType::OK, response_type: ResponseType::OK,
value: count, value: count,
game_state: None,
})?) })?)
}, },
Err(msg) => { Err(msg) => {
Ok(serde_wasm_bindgen::to_value(&MyResult{ Ok(serde_wasm_bindgen::to_value(&MyResult{
response_type: ResponseType::OK, response_type: ResponseType::OK,
value: msg, value: msg,
game_state: None,
})?) })?)
} }
} }

View file

@ -1,6 +1,7 @@
import * as React from "react"; import * as React from "react";
import { import {
GameWasm, GameState,
GameWasm, Letter,
Letter as LetterData, Letter as LetterData,
MyResult, MyResult,
PlayedTile, PlayedTile,
@ -38,6 +39,7 @@ export function Game(props: {
return props.wasm.get_board_cell_types(); return props.wasm.get_board_cell_types();
}, []); }, []);
const [isGameOver, setGameOver] = useState<boolean>(false);
const [confirmedScorePoints, setConfirmedScorePoints] = useState<number>(-1); const [confirmedScorePoints, setConfirmedScorePoints] = useState<number>(-1);
function movePlayableLetters(playerLetters: PlayableLetterData[], update: TileDispatchAction) { function movePlayableLetters(playerLetters: PlayableLetterData[], update: TileDispatchAction) {
@ -89,13 +91,16 @@ export function Game(props: {
function exchangeFunction(selectedArray: Array<boolean>) { function exchangeFunction(selectedArray: Array<boolean>) {
const result: MyResult<TurnAction | string> = props.wasm.exchange_tiles(selectedArray); const result: MyResult<TurnAction | string> = props.wasm.exchange_tiles(selectedArray);
console.log({result});
if(result.response_type === "ERR") { if(result.response_type === "ERR") {
logDispatch(<div><em>{(result.value as string)}</em></div>); logDispatch(<div><em>{(result.value as string)}</em></div>);
} else { } else {
handlePlayerAction(result.value as TurnAction, props.settings.playerName); handlePlayerAction(result.value as TurnAction, props.settings.playerName);
setTurnCount(turnCount + 1); setTurnCount(turnCount + 1);
if(result.game_state.type === "Ended") {
endGame(result.game_state);
}
} }
} }
@ -105,26 +110,13 @@ export function Game(props: {
logDispatch(<div><em>{word} was added to dictionary.</em></div>); logDispatch(<div><em>{word} was added to dictionary.</em></div>);
} }
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 [playerLetters, trayDispatch] = useReducer(movePlayableLetters, []);
const [logInfo, logDispatch] = useReducer(addLogInfo, []); const [logInfo, logDispatch] = useReducer(addLogInfo, []);
const [turnCount, setTurnCount] = useState<number>(1); const [turnCount, setTurnCount] = useState<number>(1);
const playerAndScores: PlayerAndScore[] = useMemo(() => { const playerAndScores: PlayerAndScore[] = useMemo(() => {
return props.wasm.get_scores(); return props.wasm.get_scores();
}, [turnCount]); }, [turnCount, isGameOver]);
const [boardLetters, setBoardLetters] = useState<HighlightableLetterData[]>(() => { const [boardLetters, setBoardLetters] = useState<HighlightableLetterData[]>(() => {
const newLetterData = [] as HighlightableLetterData[]; const newLetterData = [] as HighlightableLetterData[];
@ -156,18 +148,6 @@ export function Game(props: {
return props.wasm.get_current_player(); return props.wasm.get_current_player();
}, [turnCount]); }, [turnCount]);
useEffect(() => {
logDispatch(<h4>Turn {turnCount}</h4>);
logDispatch(<div>{playerTurnName}'s turn</div>)
setConfirmedScorePoints(-1);
trayDispatch({action: TileDispatchActionType.RETRIEVE});
if(playerTurnName != props.settings.playerName) {
runAI();
}
}, [turnCount]);
const logDivRef = useRef(null); const logDivRef = useRef(null);
const [isTileExchangeOpen, setIsTileExchangeOpen] = useState<boolean>(false); const [isTileExchangeOpen, setIsTileExchangeOpen] = useState<boolean>(false);
@ -181,7 +161,7 @@ export function Game(props: {
const remainingTiles = useMemo(() => { const remainingTiles = useMemo(() => {
return props.wasm.get_remaining_tiles(); return props.wasm.get_remaining_tiles();
}, [turnCount]); }, [turnCount, isGameOver]);
const remainingAITiles = useMemo(() => { const remainingAITiles = useMemo(() => {
let result = props.wasm.get_player_tile_count(props.settings.aiName) as MyResult<number | String>; let result = props.wasm.get_player_tile_count(props.settings.aiName) as MyResult<number | String>;
@ -192,7 +172,7 @@ export function Game(props: {
return -1; return -1;
} }
}, [turnCount]); }, [turnCount, isGameOver]);
function handlePlayerAction(action: TurnAction, playerName: string) { 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(<h4>Scoring</h4>);
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(<div>{name} has no remaining tiles.</div>);
} else {
let pointsLost = 0;
let letterListStr = '';
for(let i=0; i<letters.length; i++) {
const letter = letters[i];
const letterText = letter.is_blank ? 'a blank' : letter.text;
pointsLost += letter.points;
letterListStr += letterText;
// we're doing a list of 3 or more so add commas
if(letters.length > 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(<div>{name} penalized {pointsLost} points for not using {letterListStr}.</div>);
pointsBonus += pointsLost;
}
}
if(state.finisher != null) {
logDispatch(<div>{state.finisher} receives {pointsBonus} bonus for completing first.</div>);
}
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(<h4>Game over - {endGameMsg}</h4>);
} else {
// what are we doing in this function?!
console.error("endGame was called despite the state being InProgress!");
}
}
function runAI() {
const result: MyResult<TurnAdvanceResult> = 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(<h4>Turn {turnCount}</h4>);
logDispatch(<div>{playerTurnName}'s turn</div>)
setConfirmedScorePoints(-1);
trayDispatch({action: TileDispatchActionType.RETRIEVE});
if(playerTurnName != props.settings.playerName && !isGameOver) {
runAI();
}
}, [turnCount]);
return <> return <>
<TileExchangeModal <TileExchangeModal
@ -236,7 +320,9 @@ export function Game(props: {
<div> <div>
{props.settings.aiName} has {remainingAITiles} tiles {props.settings.aiName} has {remainingAITiles} tiles
</div> </div>
<button onClick={() => { <button
disabled={remainingTiles == 0}
onClick={() => {
trayDispatch({action: TileDispatchActionType.RETURN}); // want all tiles back on tray for tile exchange trayDispatch({action: TileDispatchActionType.RETURN}); // want all tiles back on tray for tile exchange
setIsTileExchangeOpen(true); setIsTileExchangeOpen(true);
}}>Open Tile Exchange</button> }}>Open Tile Exchange</button>
@ -289,6 +375,10 @@ export function Game(props: {
setTurnCount(turnCount + 1); setTurnCount(turnCount + 1);
} }
if (result.game_state.type === "Ended") {
endGame(result.game_state);
}
setConfirmedScorePoints(total_points); setConfirmedScorePoints(total_points);
} }
@ -299,9 +389,13 @@ export function Game(props: {
trayDispatch({action: TileDispatchActionType.RETURN}); trayDispatch({action: TileDispatchActionType.RETURN});
}}>Return Tiles</button> }}>Return Tiles</button>
<button className="pass" onClick={() => { <button className="pass" onClick={() => {
props.wasm.skip_turn(); const result = props.wasm.skip_turn() as MyResult<string>;
handlePlayerAction({type: "Pass"}, props.settings.playerName); handlePlayerAction({type: "Pass"}, props.settings.playerName);
setTurnCount(turnCount + 1); setTurnCount(turnCount + 1);
if(result.game_state.type === "Ended") {
endGame(result.game_state);
}
}}>Pass</button> }}>Pass</button>
</div> </div>
</div> </div>

View file

@ -96,8 +96,10 @@ function TilesExchangedTray(props: {
} }
} }
return <div className="tray"> return <div className="tray-container">
{divContent} <div className="tray">
{divContent}
</div>
</div>; </div>;
} }

View file

@ -3,12 +3,7 @@
@board-length: 15; @board-length: 15;
@tile-star-size: 45px; @tile-star-size: 45px;
.tray-row { .tray { // Don't put under tray-row as this also gets used in tile exchange
display: grid;
grid-template-columns: 1fr 1fr 1fr;
width: @board-length*@tile-width;
.tray {
display: grid; display: grid;
grid-template-columns: repeat(7, @tile-width); grid-template-columns: repeat(7, @tile-width);
grid-gap: 5px; grid-gap: 5px;
@ -16,7 +11,12 @@
width: fit-content; width: fit-content;
background-color: #bbb59d; background-color: #bbb59d;
margin: 10px; margin: 10px;
} }
.tray-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
width: @board-length*@tile-width;
.player-controls { .player-controls {
display: grid; display: grid;