import * as React from "react"; import {useEffect, useReducer, useRef, useState} from "react"; import { Direction, GRID_LENGTH, GridArrowData, GridArrowDispatchAction, GridArrowDispatchActionType, HighlightableLetterData, LocationType, matchCoordinate, mergeTrays, PlayableLetterData, Settings, TileDispatchAction, TileDispatchActionType } from "./utils"; import {TileExchangeModal} from "./TileExchange"; import {Grid, Scores, TileTray} from "./UI"; import {API, APIState, GameState, TurnAction, Tray, PlayedTile, Letter} from "./api"; function addLogInfo(existingLog: React.JSX.Element[], newItem: React.JSX.Element) { newItem = React.cloneElement(newItem, {key: existingLog.length}) existingLog.push(newItem); return existingLog.slice(); } export function Game(props: { api: API, settings: Settings, end_game_fn: () => void, }) { const [api_state, setAPIState] = useState(undefined); const [confirmedScorePoints, setConfirmedScorePoints] = useState(-1); const currentTurnNumber = useRef(-1); const historyProcessedNumber = useRef(0); let isGameOver = false; if (api_state != null) { isGameOver = api_state.public_information.game_state.type === "Ended"; } function waitForUpdate() { const result = props.api.load(true); result.then( (state) => { setAPIState(state); } ) .catch((error) => { console.log("waitForUpdate() failed") console.log(error); }); } const [boardLetters, setBoardLetters] = useState(() => { const newLetterData = [] as HighlightableLetterData[]; for (let i = 0; i < GRID_LENGTH * GRID_LENGTH; i++) { newLetterData.push(null); } return newLetterData; }); function adjustGridArrow(existing: GridArrowData, update: GridArrowDispatchAction): GridArrowData { if (update.action == GridArrowDispatchActionType.CLEAR) { return null; } else if (update.action == GridArrowDispatchActionType.CYCLE) { // if there's nothing where the user clicked, we create a right arrow. if (existing == null || existing.position != update.position) { return { direction: Direction.RIGHT, position: update.position } // if there's a right arrow, we shift to downwards } else if (existing.direction == Direction.RIGHT) { return { direction: Direction.DOWN, position: existing.position } // if there's a down arrow, we clear it } else if (existing.direction == Direction.DOWN) { return null; } } else if (update.action == GridArrowDispatchActionType.SHIFT) { if (existing == null) { // no arrow to shift return null; } else { let current_x = existing.position % GRID_LENGTH; let current_y = Math.floor(existing.position / GRID_LENGTH); // we loop because we want to skip over letters that are already set while (current_x < GRID_LENGTH && current_y < GRID_LENGTH) { if (existing.direction == Direction.RIGHT) { current_x += 1; } else { current_y += 1; } const new_position = current_x + current_y * GRID_LENGTH; let tray_letter_at_position = false; // need to also check if the player put a letter in the spot for (const letter of update.playerLetters) { if (letter != null && letter.location == LocationType.GRID && letter.index == new_position) { tray_letter_at_position = true; break } } if (current_x < GRID_LENGTH && current_y < GRID_LENGTH && boardLetters[new_position] == null && !tray_letter_at_position) { return { direction: existing.direction, position: new_position, } } } // if we reached this point without returning then we went off the board, remove arrow return null; } } } function exchangeFunction(selectedArray: Array) { const result = props.api.exchange(selectedArray); result .then( (api_state) => { setAPIState(api_state); }) .catch((error) => { console.error({error}); logDispatch(
{error}
); }); } const [gridArrow, gridArrowDispatch] = useReducer(adjustGridArrow, null); const [logInfo, logDispatch] = useReducer(addLogInfo, []); useEffect(() => { props.api.load(false) .then((api_state) => { setAPIState(api_state); }); }, []); function movePlayableLetters(playerLetters: PlayableLetterData[], update: TileDispatchAction) { if (update.action === TileDispatchActionType.RETRIEVE) { let tray: Tray = api_state.tray; if (update.override) { playerLetters = []; } return mergeTrays(playerLetters, tray.letters); } else if (update.action === TileDispatchActionType.MOVE) { let startIndex = matchCoordinate(playerLetters, update.start); let endIndex = matchCoordinate(playerLetters, update.end); if (startIndex != null) { let startLetter = playerLetters[startIndex]; startLetter.location = update.end.location; startLetter.index = update.end.index; } if (endIndex != null) { let endLetter = playerLetters[endIndex]; endLetter.location = update.start.location; endLetter.index = update.start.index; } setConfirmedScorePoints(-1); return playerLetters.slice(); } else if (update.action === TileDispatchActionType.SET_BLANK) { const blankLetter = playerLetters[update.blankIndex]; if (blankLetter.text !== update.newBlankValue) { blankLetter.text = update.newBlankValue; if (blankLetter.location == LocationType.GRID) { setConfirmedScorePoints(-1); } } return playerLetters.slice(); } else if (update.action === TileDispatchActionType.RETURN) { setConfirmedScorePoints(-1); gridArrowDispatch({action: GridArrowDispatchActionType.CLEAR}); return mergeTrays(playerLetters, playerLetters); } else if (update.action === TileDispatchActionType.MOVE_TO_ARROW) { // let's verify that the arrow is defined, otherwise do nothing if (gridArrow != null) { const end_position = { location: LocationType.GRID, index: gridArrow.position, }; gridArrowDispatch({ action: GridArrowDispatchActionType.SHIFT, playerLetters: playerLetters }); return movePlayableLetters(playerLetters, { action: TileDispatchActionType.MOVE, start: update.start, end: end_position, }); } else { return playerLetters; } } else { console.error("Unknown tray update"); console.error({update}); } } const [playerLetters, trayDispatch] = useReducer(movePlayableLetters, []); const logDivRef = useRef(null); const [isTileExchangeOpen, setIsTileExchangeOpen] = useState(false); useEffect(() => { // Effect is to keep the log window scrolled down if (logDivRef.current != null) { logDivRef.current.scrollTo(0, logDivRef.current.scrollHeight); // scroll down } }, [logInfo]); function handlePlayerAction(action: TurnAction, playerName: string) { if (action.type == "PlayTiles") { const result = action.result; result.words.sort((a, b) => b.score - a.score); for (let word of result.words) { logDispatch(
{playerName} received {word.score} points for playing '{word.word}.'
); } logDispatch(
{playerName} received a total of {result.total} points for their turn.
); } else if (action.type == "ExchangeTiles") { logDispatch(
{playerName} exchanged {action.tiles_exchanged} tile{action.tiles_exchanged > 1 ? 's' : ''} for their turn.
); } else if (action.type == "Pass") { logDispatch(
{playerName} passed.
); } else if (action.type == "AddToDictionary") { logDispatch(
{playerName} added {action.word} to the dictionary.
) } else { console.error("Received unknown turn action: ", action); } } function endGame(state: GameState) { if (state.type != "InProgress") { logDispatch(

Scoring

); const scores = api_state.public_information.players; 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[name]; if (letters.length == 0) { logDispatch(
{name} has no remaining tiles.
); } 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(
{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 updateBoardLetters(newLetters: Array) { const newLetterData = newLetters as HighlightableLetterData[]; for (let i = 0; i < newLetterData.length; i++) { const newLetter = newLetterData[i]; if (newLetter != null) { newLetter.highlight = false; } } // loop through the histories backwards until we reach our player for (let j = api_state.public_information.history.length - 1; j >= 0; j--) { const update = api_state.public_information.history[j]; if (update.player == props.settings.playerName) { break } if (update.type.type === "PlayTiles") { for (let i of update.type.locations) { newLetterData[i].highlight = true; } } } setBoardLetters(newLetterData); } useEffect(() => { if (api_state) { console.log("In state: ", api_state.public_information.current_turn_number); console.log("In ref: ", currentTurnNumber.current); console.debug(api_state); if(currentTurnNumber.current < api_state.public_information.current_turn_number){ // We only clear everything if there's a chance the board changed // We may have gotten a dictionary update event which doesn't count gridArrowDispatch({action: GridArrowDispatchActionType.CLEAR}); trayDispatch({action: TileDispatchActionType.RETRIEVE}); setConfirmedScorePoints(-1); updateBoardLetters(api_state.public_information.board); } for (let i = historyProcessedNumber.current; i < api_state.public_information.history.length; i++) { const update = api_state.public_information.history[i]; if (update.turn_number > currentTurnNumber.current) { currentTurnNumber.current = update.turn_number; logDispatch(

Turn {update.turn_number + 1}

); const playerAtTurn = api_state.public_information.players[(update.turn_number) % api_state.public_information.players.length].name; logDispatch(
{playerAtTurn}'s turn
); } handlePlayerAction(update.type, update.player); } historyProcessedNumber.current = api_state.public_information.history.length; if (!isGameOver) { if(api_state.public_information.current_turn_number > currentTurnNumber.current){ logDispatch(

Turn {api_state.public_information.current_turn_number + 1}

); logDispatch(
{api_state.public_information.current_player}'s turn
); currentTurnNumber.current = api_state.public_information.current_turn_number; } } else { endGame(api_state.public_information.game_state); } if(api_state.public_information.current_player != props.settings.playerName) { waitForUpdate(); } } }, [api_state]); if(api_state == null){ return
Still loading
; } const playerAndScores = api_state.public_information.players; const remainingTiles = api_state.public_information.remaining_tiles; const isPlayersTurn = api_state.public_information.current_player.toLowerCase() == props.settings.playerName.toLowerCase(); return <>
{logInfo}
{remainingTiles} letters remaining
; } function AddWordButton(props: { word: string, addWordFn: (x: string) => void }) { const [isClicked, setIsClicked] = useState(false); if (!isClicked) { return
{props.word} is not a valid word.
; } else { return
Adding {props.word} to dictionary.
; } }