Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -6,7 +6,7 @@ import {
|
|||||||
Route,
|
Route,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import FloatingPanel from './components/FloatingPanel';
|
import FloatingPanel from './components/FloatingPanel';
|
||||||
import GamePage from './routes/GamePage';
|
import GameRoute from './routes/GamePage';
|
||||||
import Home from './routes/Home';
|
import Home from './routes/Home';
|
||||||
|
|
||||||
import { initContext } from './context/context';
|
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 index element={<Home context={context} theme={theme} userKey={userKey} authUser={authUser} />} />
|
||||||
<Route path="/" >
|
<Route path="/" >
|
||||||
<Route path="game" >
|
<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>
|
||||||
<Route path="loby" element={<LobyPage context={context} theme={theme} userKey={userKey} />}>
|
<Route path="loby" element={<LobyPage context={context} theme={theme} userKey={userKey} />}>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@ -1,32 +1,57 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent } from "react";
|
import { FunctionComponent } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { CloseIcon } from "../Icons";
|
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<{
|
const GameOverCard: FunctionComponent<{
|
||||||
title: string,
|
title: string,
|
||||||
reason?: string,
|
reason?: string,
|
||||||
score: string,
|
// The bottom player's first.
|
||||||
// "New Game" unless given, e.g. "Play again" after a game against the bot.
|
scores: [FinalScore, FinalScore],
|
||||||
newGameLabel?: string,
|
seriesScore?: string,
|
||||||
onNewGame: () => void,
|
status?: string,
|
||||||
|
// The status is something being waited for.
|
||||||
|
waiting?: boolean,
|
||||||
|
children: React.ReactNode,
|
||||||
onClose: () => void,
|
onClose: () => void,
|
||||||
}> = ({ title, reason, score, newGameLabel, onNewGame, onClose }) => {
|
}> = ({ title, reason, scores, seriesScore, status, waiting, children, onClose }) => {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div className="over">
|
<div className="over">
|
||||||
<div className="ocard" role="dialog" aria-label={title}>
|
<div className="ocard" role="dialog" aria-label={title}>
|
||||||
<button className="btn icon ghost close" aria-label={t("Close")} onClick={onClose}><CloseIcon /></button>
|
<button className="btn icon ghost close" aria-label={t("Close")} onClick={onClose}><CloseIcon /></button>
|
||||||
<div className="otitle">{title}</div>
|
<div className="otitle">{title}</div>
|
||||||
{reason && <div className="oreason">{reason}</div>}
|
{reason && <div className="oreason">{reason}</div>}
|
||||||
<div className="oscore">{score}</div>
|
<div className="oscore" aria-label={`${t("Score")}: ${scores.map((s) => `${s.name} ${s.stones}`).join(", ")}`}>
|
||||||
<div className="row">
|
{scoreColumn(scores[0])}
|
||||||
<button className="btn pri" onClick={onNewGame}>{newGameLabel ?? t("NewGame")}</button>
|
<span className="odash" aria-hidden="true">–</span>
|
||||||
<button className="btn ghost" onClick={() => navigate("/")}>{t("Home")}</button>
|
{scoreColumn(scores[1])}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import PageContainer from '../components/PageContainer';
|
|||||||
import AppHeader from '../components/headerbar/AppHeader';
|
import AppHeader from '../components/headerbar/AppHeader';
|
||||||
import { LeaveIcon, PlusIcon } from '../components/Icons';
|
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 { 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 { Context } from '../context/context';
|
||||||
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
||||||
import { Theme, getBotLevel, getPlayerDisplayName } 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 RulesPanel from '../components/rules/RulesPanel';
|
||||||
import '../styles/game.css';
|
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<{
|
const GamePage: FunctionComponent<{
|
||||||
context: Context,
|
context: Context,
|
||||||
userKey?: string,
|
userKey?: string,
|
||||||
@ -70,6 +93,8 @@ const GamePage: FunctionComponent<{
|
|||||||
// Set when the player closes the game over card to look at the board.
|
// Set when the player closes the game over card to look at the board.
|
||||||
const [gameOverClosed, setGameOverClosed] = useState<boolean>(false);
|
const [gameOverClosed, setGameOverClosed] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [rematch, setRematch] = useState<RematchState>({ status: "none" });
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -123,6 +148,7 @@ const GamePage: FunctionComponent<{
|
|||||||
const onGameEnd = (message: GameEndMessage) => {
|
const onGameEnd = (message: GameEndMessage) => {
|
||||||
setGameEnd(message);
|
setGameEnd(message);
|
||||||
setHasOngoingAction(false);
|
setHasOngoingAction(false);
|
||||||
|
setRematch({ status: message.rematch === "available" ? "available" : "over" });
|
||||||
};
|
};
|
||||||
const onUserConnectionChange = (message: any) => {
|
const onUserConnectionChange = (message: any) => {
|
||||||
const gameUsersConnectionInfo = message as GameUsersConnectionInfo;
|
const gameUsersConnectionInfo = message as GameUsersConnectionInfo;
|
||||||
@ -131,12 +157,35 @@ const GamePage: FunctionComponent<{
|
|||||||
|
|
||||||
const listenMessages = (game: Game, pitAnimator: PitAnimator): () => void => {
|
const listenMessages = (game: Game, pitAnimator: PitAnimator): () => void => {
|
||||||
const _onGameUpdate = (message: object) => onGameUpdateEvent(pitAnimator, message);
|
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_update, _onGameUpdate);
|
||||||
context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed);
|
context.rtmt.addMessageListener(channel_on_game_crashed, onGameCrashed);
|
||||||
context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
context.rtmt.addMessageListener(channel_on_game_user_leave, onGameUserLeave);
|
||||||
context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange);
|
context.rtmt.addMessageListener(channel_on_user_connection_change, onUserConnectionChange);
|
||||||
context.rtmt.addMessageListener(channel_on_game_end, onGameEnd);
|
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 () => {
|
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_end, onGameEnd);
|
||||||
context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate);
|
context.rtmt.removeMessageListener(channel_on_game_update, _onGameUpdate);
|
||||||
context.rtmt.removeMessageListener(channel_on_game_crashed, onGameCrashed);
|
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.
|
// In a game against the bot, the game over card starts another one at the same level.
|
||||||
const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId());
|
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 = () => {
|
const onPlayAgainClick = () => {
|
||||||
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
|
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
|
||||||
navigate(`/loby?bot=${opponentBotLevel}`)
|
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) => {
|
const onPitSelect = (index: number, pit: Pit) => {
|
||||||
if (!game || isSpectator || !userKey) {
|
if (!game || isSpectator || !userKey) {
|
||||||
return;
|
return;
|
||||||
@ -373,6 +437,45 @@ const GamePage: FunctionComponent<{
|
|||||||
return <ReplayView context={context} theme={theme} record={replayRecord} userKey={userKey} />;
|
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 = () => {
|
const getTurnText = () => {
|
||||||
if (isGameOver) return resultTitle ? `${t("GameEnded")} · ${resultTitle}` : t("GameEnded");
|
if (isGameOver) return resultTitle ? `${t("GameEnded")} · ${resultTitle}` : t("GameEnded");
|
||||||
if (isSpectator) return `${getPlayerName(mancalaGame!.turnPlayerId) ?? t("Anonymous")} ${t("Playing")}`;
|
if (isSpectator) return `${getPlayerName(mancalaGame!.turnPlayerId) ?? t("Anonymous")} ${t("Playing")}`;
|
||||||
@ -434,10 +537,15 @@ const GamePage: FunctionComponent<{
|
|||||||
<GameOverCard
|
<GameOverCard
|
||||||
title={resultTitle ?? t("GameEnded")}
|
title={resultTitle ?? t("GameEnded")}
|
||||||
reason={resultReason}
|
reason={resultReason}
|
||||||
score={`${bottomLocatedUser.name} ${board.pits[bottomStore].stoneCount} – ${board.pits[topStore].stoneCount} ${topLocatedUser.name}`}
|
scores={[
|
||||||
newGameLabel={opponentBotLevel ? t("PlayAgain") : undefined}
|
{ name: bottomLocatedUser.name, stones: board.pits[bottomStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, bottomLocatedUserId), isBottom: true },
|
||||||
onNewGame={opponentBotLevel ? onPlayAgainClick : onNewGameClick}
|
{ name: topLocatedUser.name, stones: board.pits[topStore].stoneCount, isWinner: winnerSeat === getSeat(mancalaGame!, topLocatedUserId), isBottom: false },
|
||||||
onClose={() => setGameOverClosed(true)} />
|
]}
|
||||||
|
status={getRematchStatusText()}
|
||||||
|
waiting={rematch.status === "waiting"}
|
||||||
|
onClose={() => setGameOverClosed(true)}>
|
||||||
|
{renderGameOverButtons()}
|
||||||
|
</GameOverCard>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="a-moves">
|
<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; }
|
.ocard .close { position: absolute; top: 8px; right: 8px; }
|
||||||
.otitle { font-size: 30px; font-weight: 800; letter-spacing: -.02em; }
|
.otitle { font-size: 30px; font-weight: 800; letter-spacing: -.02em; }
|
||||||
.oreason { font-size: 15px; font-weight: 700; color: var(--muted); }
|
.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; }
|
/* The score: each player's name above their stones, the bottom player's on the left. */
|
||||||
.ocard .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
|
.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 */
|
/* 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.
|
`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] 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.
|
- [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.
|
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.
|
- [ ] 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),
|
- [ ] 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