import { MancalaGame, Pit } from 'mancala.js'; import * as React from 'react'; import { FunctionComponent, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; import { v4 } from 'uuid'; import { PitAnimator } from '@mancala/core'; import BoardView from '../components/board/BoardView'; import LoadingComponent from '../components/LoadingComponent'; import PageContainer from '../components/PageContainer'; import AppHeader from '../components/headerbar/AppHeader'; import { LeaveIcon, PlusIcon } from '../components/Icons'; import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove, getHintsEnabled, setHintsEnabled, getMoveOutcome, explainMove } from '@mancala/core'; import { channel_on_game_start, channel_rematch, channel_decline_rematch, channel_on_rematch_request, channel_on_rematch_ended, RematchRequest, RematchEnded, RematchEndReason, isSeriesOver } from '@mancala/core'; import { Context } from '../context/context'; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core'; import { BoardViewModel } from "@mancala/core"; import notyf from '../util/Notyf'; import swal from 'sweetalert'; import Util from '../util/Util'; import { useTranslation } from 'react-i18next'; import MoveList from '../components/history/MoveList'; import ReviewBanner from '../components/history/ReviewBanner'; import useMoveKeys from '../hooks/useMoveKeys'; import { createBoardViewModel } from '../util/BoardViewModels'; import { getResultText } from '../util/GameResultText'; import ReplayView from '../components/history/ReplayView'; import GameClockView from '../components/GameClockView'; import useGameClock from '../hooks/useGameClock'; import PlayerCard from '../components/game/PlayerCard'; import GameOverCard from '../components/game/GameOverCard'; import RulesPanel from '../components/rules/RulesPanel'; import '../styles/game.css'; // Where a rematch of the ended game stands (docs/specs/rematch): none before the end, available to ask, waiting for // the opponent, requested by the opponent, or over (a player left, said no or looked for another game). type RematchStatus = "none" | "available" | "waiting" | "requested" | "over"; interface RematchState { status: RematchStatus; // Translation key of what happened to the last request; worded with the opponent's name. note?: string; } // What the player is told when a request ends; nothing when they ended it themselves. function getRematchNoteKey(status: RematchStatus, reason: RematchEndReason): string | undefined { switch (reason) { case "declined": return status === "waiting" ? "RematchDeclined" : undefined; case "withdrawn": return status === "requested" ? "RematchWithdrawn" : undefined; case "expired": return status === "waiting" ? "RematchNoAnswer" : status === "requested" ? "RematchRequestExpired" : undefined; case "offline": case "left": return status === "over" ? undefined : "RematchOpponentGone"; case "closed": return status === "over" ? undefined : "RematchClosed"; } } const GamePage: FunctionComponent<{ context: Context, userKey?: string, theme: Theme, }> = ({ context, userKey, theme }) => { let params = useParams<{ gameId: string }>(); const [game, setGame] = useState(undefined); const [userKeyWhoLeave, setUserKeyWhoLeave] = useState(undefined); const [boardViewModel, setBoardViewModel] = useState(undefined); const [boardId, setBoardId] = useState("-1"); const [pitAnimator, setPitAnimator] = useState(undefined); // It is a flag for ongoing action such as send game move. // We have to block future actions if there is an ongoing action. const [hasOngoingAction, setHasOngoingAction] = useState(false); const [gameUsersConnectionInfo, setGameUsersConnectionInfo] = useState(); // Set while an earlier position is shown instead of the live board. const [viewedMove, setViewedMove] = useState(undefined); // Set when the game has ended and was loaded from history; the page then shows the replay. const [replayRecord, setReplayRecord] = useState(undefined); // Set when the server says the game ended, for whatever reason. const [gameEnd, setGameEnd] = useState(undefined); // The rules over the game; the game goes on behind them. const [rulesOpen, setRulesOpen] = useState(false); // Hints in games against the bot (docs/specs/tutorial, R3); remembered on the device. const [hintsEnabled, setHintsEnabledState] = useState(() => getHintsEnabled(context.storage)); // Set when the player closes the game over card to look at the board. const [gameOverClosed, setGameOverClosed] = useState(false); const [rematch, setRematch] = useState({ status: "none" }); const navigate = useNavigate(); const { t } = useTranslation(); const [gameLoadingState, setLoadingStateGame] = useState>(LoadingState.Unset()); const checkIsSpectator = (game: Game) => userKey !== game.mancalaGame.player1Id && userKey !== game.mancalaGame.player2Id; const mancalaGame: MancalaGame | undefined = game?.mancalaGame; const isSpectator = game ? checkIsSpectator(game) : undefined; const isPlayer2 = !isSpectator && userKey === mancalaGame?.player2Id; const onGameUpdate = (pitAnimator: PitAnimator, newGame: Game) => { setGame(newGame); pitAnimator.setUpdatedGame(newGame); setHasOngoingAction(false); setGameUsersConnectionInfo(newGame.gameUsersConnectionInfo); } const isUserOnline = (userId: string) => { if (!gameUsersConnectionInfo) return false; const user1ConnectionInfo = gameUsersConnectionInfo.user1ConnectionInfo; const user2ConnectionInfo = gameUsersConnectionInfo.user2ConnectionInfo; if (user1ConnectionInfo.userId === userId) return user1ConnectionInfo.isOnline; if (user2ConnectionInfo.userId === userId) return user2ConnectionInfo.isOnline; return false; } const getPlayerName = (userId: string) => { const connectionInfos = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo]; return getPlayerDisplayName(connectionInfos.find((info) => info?.userId === userId), t); } const onGameUpdateEvent = (pitAnimator: PitAnimator, message: Object) => { const newGame: Game = message as Game; newGame.mancalaGame = MancalaGame.createFromMancalaGame(newGame.mancalaGame); onGameUpdate(pitAnimator, newGame); } const onGameCrashed = (message: any) => { const newCrashMessage = message as string; notyf.error(t("InternalErrorOccurred")); console.error("on_game_crash"); console.error(newCrashMessage); } const onGameUserLeave = (message: any) => { const userKeyWhoLeave = message; setUserKeyWhoLeave(userKeyWhoLeave); setHasOngoingAction(false); }; const onGameEnd = (message: GameEndMessage) => { setGameEnd(message); setHasOngoingAction(false); setRematch({ status: message.rematch === "available" ? "available" : "over" }); }; const onUserConnectionChange = (message: any) => { const gameUsersConnectionInfo = message as GameUsersConnectionInfo; setGameUsersConnectionInfo(gameUsersConnectionInfo); }; const listenMessages = (game: Game, pitAnimator: PitAnimator): () => void => { const _onGameUpdate = (message: object) => onGameUpdateEvent(pitAnimator, message); // The opponent asked for a rematch; the card comes back if it was closed. const onRematchRequest = (message: RematchRequest) => { if (message.gameId !== game.id) return; setRematch({ status: "requested" }); setGameOverClosed(false); }; const onRematchEnded = (message: RematchEnded) => { if (message.gameId !== game.id) return; setRematch((rematch) => ({ status: isSeriesOver(message.reason) ? "over" : "available", note: getRematchNoteKey(rematch.status, message.reason), })); }; // The server sends a player's new game only to them: here, the rematch both have asked for. const onGameStart = (message: Game) => { if (message.id !== game.id) navigate(`/game/${message.id}`); }; context.rtmt.addMessageListener(channel_on_game_update, _onGameUpdate); context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed); context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave); context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange); context.rtmt.addMessageListener(channel_on_game_end, onGameEnd); context.rtmt.addMessageListener(channel_on_rematch_request, onRematchRequest); context.rtmt.addMessageListener(channel_on_rematch_ended, onRematchEnded); context.rtmt.addMessageListener(channel_on_game_start, onGameStart); return () => { context.rtmt.removeMessageListener(channel_on_rematch_request, onRematchRequest); context.rtmt.removeMessageListener(channel_on_rematch_ended, onRematchEnded); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); context.rtmt.removeMessageListener(channel_on_game_end, onGameEnd); context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate); context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed); context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave); context.rtmt.removeMessageListener(channel_on_user_connection_change, onUserConnectionChange); } }; // Spectators tell the server they are watching once their identity is known and the connection is open, // and again after every reconnect, since the server forgets spectators who go offline. React.useEffect(() => { if (!game || !userKey || !checkIsSpectator(game)) return; const listen = () => context.rtmt.sendMessage(channel_listen_game_events, game.id); if (context.rtmt.connectionState === "connected") listen(); context.rtmt.on("open", listen); return () => { context.rtmt.off("open", listen); if (context.rtmt.connectionState === "connected") context.rtmt.sendMessage(channel_unlisten_game_events, game.id); }; }, [game?.id, userKey]); const updateBoardViewModel = (boardViewModel: BoardViewModel) => { boardViewModel.id = v4(); setBoardId(boardViewModel.id); setBoardViewModel(boardViewModel); }; const getBoardIndex = (index: number) => { if (!game || !mancalaGame) return -1; const pitsLenght = mancalaGame.board.pits.length; if (userKey === mancalaGame.player2Id) return index + pitsLenght / 2; return index; }; const getOpponentId = () => mancalaGame?.player1Id === userKey ? mancalaGame?.player2Id : mancalaGame?.player1Id; const checkHasAnOngoingAction = () => hasOngoingAction; const onLeaveGameClick = () => { if (Util.checkConnectionAndMaybeAlert(context, t)) return; swal({ title: t("AreYouSureToLeaveGame"), icon: "warning", buttons: [t("Cancel"), t("Yes")], dangerMode: true, }) .then((confirm) => { if (confirm) { context.rtmt.sendMessage(channel_leave_game, {}); navigate("/"); } }); }; const onNewGameClick = () => { if (Util.checkConnectionAndMaybeAlert(context, t)) return; navigate("/loby") }; // In a game against the bot, the game over card starts another one at the same level. const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId()); // Once a rematch of a bot game is no longer possible, "Play again" starts a new game at the same level. const onPlayAgainClick = () => { if (Util.checkConnectionAndMaybeAlert(context, t)) return; navigate(`/loby?bot=${opponentBotLevel}`) }; // Asks for a rematch, or accepts the opponent's request; the bot accepts at once. const onRematchClick = () => { if (!game || Util.checkConnectionAndMaybeAlert(context, t)) return; context.rtmt.sendMessage(channel_rematch, { gameId: game.id }); setRematch({ status: "waiting" }); }; // Withdraws the player's request, or says no to the opponent's, which ends the series. const onDeclineRematchClick = () => { if (!game || Util.checkConnectionAndMaybeAlert(context, t)) return; context.rtmt.sendMessage(channel_decline_rematch, { gameId: game.id }); setRematch({ status: rematch.status === "waiting" ? "available" : "over" }); }; const onPitSelect = (index: number, pit: Pit) => { if (!game || isSpectator || !userKey) { return; } if(userKeyWhoLeave || gameEnd) { notyf.error(t("GameEnded")); return; } if (viewedMove !== undefined) { notyf.error(t("ViewingMove", { number: viewedMove })); return; } if(game.mancalaGame.state === "ended") { notyf.error(t("GameEnded")); return; } if (Util.checkConnectionAndMaybeAlert(context, t)) return; if (game.mancalaGame.getPlayerIdByIndex(index) !== userKey) { notyf.error(t("UCanOnlyPlayYourOwnPits")); return; } const pitIndexForUser = index % (game.mancalaGame.board.totalPitCount() / 2); if (!game.mancalaGame.canPlayerMove(userKey, pitIndexForUser)) { notyf.error(t("OpponentTurn")); return; } if (checkHasAnOngoingAction()) { notyf.error(t("UMustWaitUntilCurrentMoveComplete")); return; } if (!boardViewModel) return; //TODO: this check should be in mancala.js if (pit.stoneCount === 0) { notyf.error(t("UCanNotPlayEmptyPit")); return; } setHasOngoingAction(true); const selectedPit = boardViewModel.pits[getBoardIndex(pitIndexForUser)]; selectedPit.pitColor = context.themeManager.theme.pitSelectedColor; selectedPit.highlight = "selected"; updateBoardViewModel(boardViewModel); const gameMove: GameMove = { index: pitIndexForUser }; context.rtmt.sendMessage(channel_game_move, gameMove); }; React.useEffect(() => { let pitAnimator: PitAnimator | undefined; let unlistenMessages: () => void; setLoadingStateGame(LoadingState.Loading()) context.gameStore.get(params.gameId!!).then((loaded) => { if (loaded?.kind === "live") { const game = loaded.game; pitAnimator = new PitAnimator(context.themeManager, updateBoardViewModel); setPitAnimator(pitAnimator); onGameUpdate(pitAnimator, game); unlistenMessages = listenMessages(game, pitAnimator); setLoadingStateGame(LoadingState.Loaded({ value: game })) } else if (loaded?.kind === "finished") { setReplayRecord(loaded.record); } else { setLoadingStateGame(LoadingState.Error({ errorMessage: t("GameNotFound") })) } }).catch((error) => { console.error(error); setLoadingStateGame(LoadingState.Error({ errorMessage: t("ErrorWhenRetrievingInformation") })) }) return () => { unlistenMessages?.(); pitAnimator?.dispose(); }; }, []); const renderNewGameBtn = isSpectator || (userKeyWhoLeave || gameEnd || !game || (game && game.mancalaGame.state == "ended")); const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[]; const isReviewing = viewedMove !== undefined && viewedMove < moves.length; // Hints are only for games against the bot, never between people (R3.5). const hintsOn = !!opponentBotLevel && hintsEnabled; const toggleHints = () => { setHintsEnabled(context.storage, !hintsEnabled); setHintsEnabledState(!hintsEnabled); }; const getHints = (): Record | undefined => { if (!hintsOn || !mancalaGame || isReviewing || hasOngoingAction) return undefined; if (mancalaGame.turnPlayerId !== userKey || mancalaGame.state === "ended") return undefined; const stones = mancalaGame.board.getStoneArray(); const first = isPlayer2 ? 7 : 0; const hints: Record = {}; for (let index = first; index < first + 6; index++) { if (!stones[index]) continue; const outcome = getMoveOutcome(stones, index); if (outcome.capture) hints[index] = t("HintCaptures", { count: outcome.captured }); else if (outcome.extraTurn) hints[index] = t("HintExtraTurn"); } return hints; }; // What the last move did and why, under the board (R3.3). const getLastMoveExplanation = (): string | undefined => { if (!hintsOn || moves.length === 0 || isReviewing) return undefined; const last = moves[moves.length - 1]; const outcome = getMoveOutcome(getBoardAfterMove(moves, moves.length - 1), last.moveIndex); return explainMove(outcome, last.playerId === userKey ? "you" : "bot", t); }; const shownBoardViewModel = isReviewing && mancalaGame ? createBoardViewModel(getBoardAfterMove(moves, viewedMove!), context.themeManager.theme) : boardViewModel; const isGameOver = !!(userKeyWhoLeave || gameEnd || mancalaGame?.state === "ended"); const isYourTurn = !isSpectator && !isGameOver && !!userKey && mancalaGame?.turnPlayerId === userKey; const selectMove = (moveNumber: number) => setViewedMove(moveNumber >= moves.length ? undefined : Math.max(0, moveNumber)); useMoveKeys({ previous: () => !replayRecord && selectMove((isReviewing ? viewedMove! : moves.length) - 1), next: () => !replayRecord && isReviewing && selectMove(viewedMove! + 1), }); const playerInfo = (userId: string) => { const info = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo] .find((connectionInfo) => connectionInfo?.userId === userId); return { userId, name: info?.name, isGuest: info?.isGuest ?? true }; }; // How the game ended, as a saved game would say it; from the server's message when it sent one. const getEndSummary = (): FinishedGameSummary | undefined => { if (!mancalaGame || !isGameOver) return undefined; const player1 = playerInfo(mancalaGame.player1Id), player2 = playerInfo(mancalaGame.player2Id); const seatOf = (playerId: string) => playerId === mancalaGame.player1Id ? "player1" : "player2"; if (gameEnd) return { result: gameEnd.result, endReason: gameEnd.endReason, player1, player2 } as FinishedGameSummary; if (userKeyWhoLeave) { const winner = userKeyWhoLeave === mancalaGame.player1Id ? "player2" : "player1"; return { result: winner, endReason: "left", leftUserId: userKeyWhoLeave, player1, player2 } as FinishedGameSummary; } const winnerId = mancalaGame.getWonPlayerId(); return { result: winnerId ? seatOf(winnerId) : "draw", endReason: "completed", player1, player2 } as FinishedGameSummary; }; const endSummary = getEndSummary(); // "You won · Opponent left" becomes the title "You won" and the reason "Opponent left". const [resultTitle, ...resultReasons] = endSummary ? getResultText(endSummary, isSpectator ? undefined : userKey, t).split(" · ") : []; const leftUserId = userKeyWhoLeave ?? gameEnd?.leftUserId; const resultReason = resultReasons.join(" · ") || (isSpectator && leftUserId ? `${getPlayerName(leftUserId) ?? t("Anonymous")} ${t("UserLeftTheGame")}` : undefined); const clockTimes = useGameClock(game, gameEnd); const renderClock = (playerId: string) => { const seat = mancalaGame && getSeat(mancalaGame, playerId); if (!clockTimes || !seat) return undefined; const timeMs = seat === "player1" ? clockTimes.player1Ms : clockTimes.player2Ms; return ; }; const showBoardView = game && boardViewModel && userKey && true; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; const topLocatedUser = { id: topLocatedUserId, name: getPlayerName(topLocatedUserId) ?? t("Anonymous"), isGuest: playerInfo(topLocatedUserId).isGuest, isOnline: isUserOnline(topLocatedUserId), }; const bottomLocatedUser = { id: bottomLocatedUserId, name: getPlayerName(bottomLocatedUserId) ?? t("Anonymous"), isGuest: playerInfo(bottomLocatedUserId).isGuest, isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected", }; if (replayRecord) { return ; } const winnerSeat = endSummary?.result === "player1" || endSummary?.result === "player2" ? endSummary.result : undefined; const getRematchStatusText = (): string | undefined => { if (isSpectator) return undefined; const name = topLocatedUser.name; if (rematch.status === "waiting" && !opponentBotLevel) return t("RematchWaiting", { name }); if (rematch.status === "requested") return t("RematchRequested", { name }); return rematch.note && t(rematch.note, { name }); }; const renderGameOverButtons = () => { const newGame = (primary: boolean) => ( ); const home = ; if (isSpectator) return [newGame(true), home]; if (opponentBotLevel) { const canRematch = rematch.status === "available" || rematch.status === "waiting"; return [ , home, ]; } switch (rematch.status) { case "available": return [, newGame(false), home]; case "waiting": return [, home]; case "requested": return [ , , ]; default: return [newGame(true), home]; } }; const getTurnText = () => { if (isGameOver) return resultTitle ? `${t("GameEnded")} · ${resultTitle}` : t("GameEnded"); if (isSpectator) return `${getPlayerName(mancalaGame!.turnPlayerId) ?? t("Anonymous")} ${t("Playing")}`; return isYourTurn ? t("YourTurn") : t("OpponentTurn"); }; const renderGame = () => { const board = mancalaGame!.board; const lastMoveExplanation = getLastMoveExplanation(); const [topStore, bottomStore] = isPlayer2 ? [board.player1BankIndex(), board.player2BankIndex()] : [board.player2BankIndex(), board.player1BankIndex()]; const shownStones = (index: number) => shownBoardViewModel!.pits[index].stoneCount; const playerCard = (user: typeof topLocatedUser, storeIndex: number, role: string, alignRight: boolean) => ( ); return (
{playerCard(topLocatedUser, topStore, isSpectator ? "" : t("Opponent"), false)}
{!isGameOver && } {getTurnText()}
{opponentBotLevel && ( )}
{playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}
{isReviewing && ( setViewedMove(undefined)} /> )} {lastMoveExplanation &&

{lastMoveExplanation}

} {isGameOver && !gameOverClosed && ( setGameOverClosed(true)}> {renderGameOverButtons()} )}
getPlayerName(playerId) ?? t("Anonymous")} accentPlayerId={bottomLocatedUserId} newestFirst />
); }; return ( {renderHeaderBar()}
{showBoardView && renderGame()}
{rulesOpen && setRulesOpen(false)} />}
); function renderHeaderBar() { return setRulesOpen(true)}> {renderNewGameBtn ? : } ; } } // A page of its own for every game, so following a rematch to the new game's id starts the page afresh. const GameRoute: FunctionComponent> = (props) => { const { gameId } = useParams<{ gameId: string }>(); return ; }; export default GameRoute;