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::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<String>,
remaining_tiles: HashMap<String, Vec<Letter>>,
},
}
pub struct Game{
pub tile_pool: Vec<Letter>,
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<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();
@ -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<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 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<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;
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 {
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 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(){
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);
}

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::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<E: Serialize> {
response_type: ResponseType,
value: E,
game_state: Option<GameState>,
}
#[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<bool> = 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<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> {
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,
})?)
}
}

View file

@ -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<boolean>(false);
const [confirmedScorePoints, setConfirmedScorePoints] = useState<number>(-1);
function movePlayableLetters(playerLetters: PlayableLetterData[], update: TileDispatchAction) {
@ -89,13 +91,16 @@ export function Game(props: {
function exchangeFunction(selectedArray: Array<boolean>) {
const result: MyResult<TurnAction | string> = props.wasm.exchange_tiles(selectedArray);
console.log({result});
if(result.response_type === "ERR") {
logDispatch(<div><em>{(result.value as string)}</em></div>);
} 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(<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 [logInfo, logDispatch] = useReducer(addLogInfo, []);
const [turnCount, setTurnCount] = useState<number>(1);
const playerAndScores: PlayerAndScore[] = useMemo(() => {
return props.wasm.get_scores();
}, [turnCount]);
}, [turnCount, isGameOver]);
const [boardLetters, setBoardLetters] = useState<HighlightableLetterData[]>(() => {
const newLetterData = [] as HighlightableLetterData[];
@ -156,18 +148,6 @@ export function Game(props: {
return props.wasm.get_current_player();
}, [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 [isTileExchangeOpen, setIsTileExchangeOpen] = useState<boolean>(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<number | String>;
@ -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(<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 <>
<TileExchangeModal
@ -236,7 +320,9 @@ export function Game(props: {
<div>
{props.settings.aiName} has {remainingAITiles} tiles
</div>
<button onClick={() => {
<button
disabled={remainingTiles == 0}
onClick={() => {
trayDispatch({action: TileDispatchActionType.RETURN}); // want all tiles back on tray for tile exchange
setIsTileExchangeOpen(true);
}}>Open Tile Exchange</button>
@ -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</button>
<button className="pass" onClick={() => {
props.wasm.skip_turn();
const result = props.wasm.skip_turn() as MyResult<string>;
handlePlayerAction({type: "Pass"}, props.settings.playerName);
setTurnCount(turnCount + 1);
if(result.game_state.type === "Ended") {
endGame(result.game_state);
}
}}>Pass</button>
</div>
</div>

View file

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

View file

@ -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;
@ -18,6 +13,11 @@
margin: 10px;
}
.tray-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
width: @board-length*@tile-width;
.player-controls {
display: grid;
grid-template-areas: "check return"