From aa68f04b28e5e79edc3e53e3f6f0785cbcbf7de8 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Sat, 17 Oct 2026 02:09:22 +0300 Subject: [PATCH] feat(frontend): offer a rematch on the game over card The card asks for a rematch, shows a request from the opponent with Rematch and No, waits with Cancel and says why a request ended. The game page follows the next game, keyed by its id so it starts afresh. Play again against the bot is a rematch. The score is two columns, each name above its stones, the winner's stones stronger. --- apps/frontend/src/MancalaApp.tsx | 4 +- .../src/components/game/GameOverCard.tsx | 49 +++++-- apps/frontend/src/routes/GamePage.tsx | 124 +++++++++++++++++- apps/frontend/src/styles/game.css | 15 ++- docs/specs/rematch/tasks.md | 2 +- 5 files changed, 172 insertions(+), 22 deletions(-) diff --git a/apps/frontend/src/MancalaApp.tsx b/apps/frontend/src/MancalaApp.tsx index 670b298..4c0be81 100644 --- a/apps/frontend/src/MancalaApp.tsx +++ b/apps/frontend/src/MancalaApp.tsx @@ -6,7 +6,7 @@ import { Route, } from "react-router-dom"; import FloatingPanel from './components/FloatingPanel'; -import GamePage from './routes/GamePage'; +import GameRoute from './routes/GamePage'; import Home from './routes/Home'; import { initContext } from './context/context'; @@ -127,7 +127,7 @@ const MancalaApp: FunctionComponent = () => { } /> - } > + } > }> diff --git a/apps/frontend/src/components/game/GameOverCard.tsx b/apps/frontend/src/components/game/GameOverCard.tsx index 23e3789..8757563 100644 --- a/apps/frontend/src/components/game/GameOverCard.tsx +++ b/apps/frontend/src/components/game/GameOverCard.tsx @@ -1,32 +1,57 @@ import * as React from "react"; import { FunctionComponent } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; import { CloseIcon } from "../Icons"; -// Over the board when a game ends: the result, why it ended, the score, and where to go next. +// A player's stones at the end, for the score columns. +export interface FinalScore { + name: string; + stones: number; + isWinner: boolean; + // The bottom player, whose wins are shown in the accent colour. + isBottom: boolean; +} + +// Over the board when a game ends: the result, why it ended, the score as two columns (name above stones), the +// series score, a line about a rematch, and the page's buttons. const GameOverCard: FunctionComponent<{ title: string, reason?: string, - score: string, - // "New Game" unless given, e.g. "Play again" after a game against the bot. - newGameLabel?: string, - onNewGame: () => void, + // The bottom player's first. + scores: [FinalScore, FinalScore], + seriesScore?: string, + status?: string, + // The status is something being waited for. + waiting?: boolean, + children: React.ReactNode, onClose: () => void, -}> = ({ title, reason, score, newGameLabel, onNewGame, onClose }) => { +}> = ({ title, reason, scores, seriesScore, status, waiting, children, onClose }) => { const { t } = useTranslation(); - const navigate = useNavigate(); + const scoreColumn = (score: FinalScore) => ( +
+ {score.name} + {score.stones} +
+ ); return (
{title}
{reason &&
{reason}
} -
{score}
-
- - +
`${s.name} ${s.stones}`).join(", ")}`}> + {scoreColumn(scores[0])} + + {scoreColumn(scores[1])}
+ {seriesScore &&
{seriesScore}
} + {status && ( +
+ {waiting && } + {status} +
+ )} +
{children}
); diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index fbe538a..332185e 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -10,6 +10,7 @@ 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'; @@ -31,6 +32,28 @@ 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, @@ -70,6 +93,8 @@ const GamePage: FunctionComponent<{ // 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(); @@ -123,6 +148,7 @@ const GamePage: FunctionComponent<{ const onGameEnd = (message: GameEndMessage) => { setGameEnd(message); setHasOngoingAction(false); + setRematch({ status: message.rematch === "available" ? "available" : "over" }); }; const onUserConnectionChange = (message: any) => { const gameUsersConnectionInfo = message as GameUsersConnectionInfo; @@ -131,12 +157,35 @@ const GamePage: FunctionComponent<{ 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); @@ -199,11 +248,26 @@ const GamePage: FunctionComponent<{ // 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; @@ -373,6 +437,45 @@ const GamePage: FunctionComponent<{ 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")}`; @@ -434,10 +537,15 @@ const GamePage: FunctionComponent<{ setGameOverClosed(true)} /> + scores={[ + { name: bottomLocatedUser.name, stones: board.pits[bottomStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, bottomLocatedUserId), isBottom: true }, + { name: topLocatedUser.name, stones: board.pits[topStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, topLocatedUserId), isBottom: false }, + ]} + status={getRematchStatusText()} + waiting={rematch.status === "waiting"} + onClose={() => setGameOverClosed(true)}> + {renderGameOverButtons()} + )}
@@ -477,4 +585,10 @@ const GamePage: FunctionComponent<{ } } -export default GamePage; +// 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; diff --git a/apps/frontend/src/styles/game.css b/apps/frontend/src/styles/game.css index a22c48d..07d46c0 100644 --- a/apps/frontend/src/styles/game.css +++ b/apps/frontend/src/styles/game.css @@ -156,8 +156,19 @@ .ocard .close { position: absolute; top: 8px; right: 8px; } .otitle { font-size: 30px; font-weight: 800; letter-spacing: -.02em; } .oreason { font-size: 15px; font-weight: 700; color: var(--muted); } -.oscore { font-size: 18px; color: var(--muted); font-variant-numeric: tabular-nums; margin-bottom: 8px; } -.ocard .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; } +/* The score: each player's name above their stones, the bottom player's on the left. */ +.oscore { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: end; gap: 0 14px; width: 100%; min-width: 220px; margin: 2px 0 4px; } +.osc { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; } +.oname { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 700; color: var(--muted); } +.onum { font-size: 38px; font-weight: 800; line-height: 1; letter-spacing: -.02em; color: var(--muted); font-variant-numeric: tabular-nums; } +.osc:not(.win) .onum { opacity: .55; } +.osc.win .onum { color: var(--ink); } +.osc.win.mine .onum { color: var(--accent); } +.odash { padding-bottom: 6px; font-size: 22px; font-weight: 700; color: var(--muted); } +.oseries { font-size: 14px; font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; } +.ostatus { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; } +.ostatus.waiting { color: var(--muted); } +.ocard .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-top: 6px; } /* Tags of a move: extra turn, capture, stones to the store */ diff --git a/docs/specs/rematch/tasks.md b/docs/specs/rematch/tasks.md index 57db6f8..25ddbfd 100644 --- a/docs/specs/rematch/tasks.md +++ b/docs/specs/rematch/tasks.md @@ -10,7 +10,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. `new_game`, `new_bot_game` and going offline, the bot accepting at once; unit tests. - [x] 4. **Saving**: `seriesId` in `GameRecord`, migration `004_series`, the repository; integration tests. - [x] 5. **Core**: channel names, types, `getSeriesScore`, texts in English and Turkish; unit tests. -- [ ] 6. **Web app, rematch**: the game page following `on_game_start` (`key={gameId}`), the game over card's rematch +- [x] 6. **Web app, rematch**: the game page following `on_game_start` (`key={gameId}`), the game over card's rematch states, "Play again" as a rematch, the score in two columns. - [ ] 7. **Web app, series strip**: `SeriesStrip`, the series score on the game over card, spectators. - [ ] 8. **End to end and docs**: the Chrome tests from `design.md`; `AGENTS.md` (the specs list, rematch notes),