Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 */
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user