Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
5 changed files with 172 additions and 22 deletions
Showing only changes of commit aa68f04b28 - Show all commits

View File

@ -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 = () => {
<Route index element={<Home context={context} theme={theme} userKey={userKey} authUser={authUser} />} />
<Route path="/" >
<Route path="game" >
<Route path=":gameId" element={<GamePage context={context} theme={theme} userKey={userKey} />} ></Route>
<Route path=":gameId" element={<GameRoute context={context} theme={theme} userKey={userKey} />} ></Route>
</Route>
<Route path="loby" element={<LobyPage context={context} theme={theme} userKey={userKey} />}>
</Route>

View File

@ -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) => (
<div className={"osc" + (score.isWinner ? " win" : "") + (score.isBottom ? " mine" : "")}>
<span className="oname" title={score.name}>{score.name}</span>
<span className="onum">{score.stones}</span>
</div>
);
return (
<div className="over">
<div className="ocard" role="dialog" aria-label={title}>
<button className="btn icon ghost close" aria-label={t("Close")} onClick={onClose}><CloseIcon /></button>
<div className="otitle">{title}</div>
{reason && <div className="oreason">{reason}</div>}
<div className="oscore">{score}</div>
<div className="row">
<button className="btn pri" onClick={onNewGame}>{newGameLabel ?? t("NewGame")}</button>
<button className="btn ghost" onClick={() => navigate("/")}>{t("Home")}</button>
<div className="oscore" aria-label={`${t("Score")}: ${scores.map((s) => `${s.name} ${s.stones}`).join(", ")}`}>
{scoreColumn(scores[0])}
<span className="odash" aria-hidden="true">–</span>
{scoreColumn(scores[1])}
</div>
{seriesScore && <div className="oseries">{seriesScore}</div>}
{status && (
<div className={"ostatus" + (waiting ? " waiting" : "")} role="status">
{waiting && <span className="dot" />}
{status}
</div>
)}
<div className="row">{children}</div>
</div>
</div>
);

View File

@ -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<boolean>(false);
const [rematch, setRematch] = useState<RematchState>({ 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 <ReplayView context={context} theme={theme} record={replayRecord} userKey={userKey} />;
}
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) => (
<button key="new" className={"btn " + (primary ? "pri" : "ghost")} onClick={onNewGameClick}>{t("NewGame")}</button>
);
const home = <button key="home" className="btn ghost" onClick={() => navigate("/")}>{t("Home")}</button>;
if (isSpectator) return [newGame(true), home];
if (opponentBotLevel) {
const canRematch = rematch.status === "available" || rematch.status === "waiting";
return [
<button key="again" className="btn pri" disabled={rematch.status === "waiting"}
onClick={canRematch ? onRematchClick : onPlayAgainClick}>{t("PlayAgain")}</button>,
home,
];
}
switch (rematch.status) {
case "available":
return [<button key="rematch" className="btn pri" onClick={onRematchClick}>{t("Rematch")}</button>, newGame(false), home];
case "waiting":
return [<button key="cancel" className="btn ghost" onClick={onDeclineRematchClick}>{t("Cancel")}</button>, home];
case "requested":
return [
<button key="rematch" className="btn pri" onClick={onRematchClick}>{t("Rematch")}</button>,
<button key="no" className="btn ghost" onClick={onDeclineRematchClick}>{t("No")}</button>,
];
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<{
<GameOverCard
title={resultTitle ?? t("GameEnded")}
reason={resultReason}
score={`${bottomLocatedUser.name} ${board.pits[bottomStore].stoneCount} – ${board.pits[topStore].stoneCount} ${topLocatedUser.name}`}
newGameLabel={opponentBotLevel ? t("PlayAgain") : undefined}
onNewGame={opponentBotLevel ? onPlayAgainClick : onNewGameClick}
onClose={() => 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()}
</GameOverCard>
)}
</div>
<div className="a-moves">
@ -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<React.ComponentProps<typeof GamePage>> = (props) => {
const { gameId } = useParams<{ gameId: string }>();
return <GamePage key={gameId} {...props} />;
};
export default GameRoute;

View File

@ -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 */

View File

@ -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),