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}
-
-
{newGameLabel ?? t("NewGame")}
-
navigate("/")}>{t("Home")}
+
`${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) => (
+ {t("NewGame")}
+ );
+ const home = navigate("/")}>{t("Home")} ;
+ if (isSpectator) return [newGame(true), home];
+ if (opponentBotLevel) {
+ const canRematch = rematch.status === "available" || rematch.status === "waiting";
+ return [
+ {t("PlayAgain")} ,
+ home,
+ ];
+ }
+ switch (rematch.status) {
+ case "available":
+ return [{t("Rematch")} , newGame(false), home];
+ case "waiting":
+ return [{t("Cancel")} , home];
+ case "requested":
+ return [
+ {t("Rematch")} ,
+ {t("No")} ,
+ ];
+ 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),