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
9 changed files with 88 additions and 40 deletions
Showing only changes of commit 20359b7a8f - Show all commits

View File

@ -0,0 +1,40 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
import { CLOCK_LOW_TIME_MS, formatClock, getColorByBrightness } from "@mancala/core";
import { Context } from '../context/context';
// Readable on every theme; the themes have no warning color of their own.
const LOW_TIME_COLOR = "#b3261e";
const LOW_TIME_TEXT_COLOR = "#ffffff";
// One player's clock: highlighted while it runs, in a warning color when the running clock is low.
const GameClockView: FunctionComponent<{
context: Context,
timeMs: number,
running: boolean,
}> = ({ context, timeMs, running }) => {
const theme = context.themeManager.theme;
const lowTime = running && timeMs < CLOCK_LOW_TIME_MS;
const background = lowTime ? LOW_TIME_COLOR : running ? theme.playerTurnColor : "transparent";
const color = lowTime ? LOW_TIME_TEXT_COLOR : running
? getColorByBrightness(theme.playerTurnColor, theme.textColor, theme.textLightColor)
: getColorByBrightness(theme.background, theme.textColor, theme.textLightColor);
return (
<span className="clock" style={{ background, color }} role="timer">
{formatClock(timeMs)}
<style jsx>{`
.clock {
padding: 2px 10px;
border-radius: 30px;
border: 2px solid ${theme.boardColor};
font-weight: bold;
font-variant-numeric: tabular-nums;
white-space: nowrap;
transition: background-color 0.5s, color 0.5s;
}
`}</style>
</span>
);
}
export default GameClockView;

View File

@ -13,8 +13,10 @@ const UserStatus: FunctionComponent<{
user: User, user: User,
layoutMode: LayoutMode, layoutMode: LayoutMode,
visible?: boolean, visible?: boolean,
style?: React.CSSProperties style?: React.CSSProperties,
}> = ({ context, user, layoutMode, visible, style }) => { // Shown after the online status, e.g. the player's clock.
accessory?: React.ReactNode,
}> = ({ context, user, layoutMode, visible, style, accessory }) => {
if (visible === false) return <></>; if (visible === false) return <></>;
const { t } = useTranslation(); const { t } = useTranslation();
@ -29,6 +31,7 @@ const UserStatus: FunctionComponent<{
<span style={{color: textColorOnBoard, transition: 'color 0.5s'}} className='text'>{user.isAnonymous ? t("Anonymous") : user.name}</span> <span style={{color: textColorOnBoard, transition: 'color 0.5s'}} className='text'>{user.isAnonymous ? t("Anonymous") : user.name}</span>
<Space width='5px' /> <Space width='5px' />
<div className={"circle " + (user.isOnline ? "online" : "offline")}></div> <div className={"circle " + (user.isOnline ? "online" : "offline")}></div>
{accessory && <><Space width='8px' />{accessory}</>}
<style jsx>{` <style jsx>{`
.online { .online {
background-color: ${context.themeManager.theme.boardColor}; background-color: ${context.themeManager.theme.boardColor};

View File

@ -0,0 +1,27 @@
import { useEffect, useRef, useState } from "react";
import { ClockState, ClockSync, Game, GameEndMessage } from "@mancala/core";
const TICK_MS = 250;
// Both players' clocks in a live game, counting down between the server's messages. Undefined when the server sends
// no clock.
export default function useGameClock(game: Game | undefined, gameEnd: GameEndMessage | undefined): ClockState | undefined {
const sync = useRef(new ClockSync()).current;
const lastGame = useRef<Game>();
// Every game message is a new object, so a changed reference means a new message.
if (game && game !== lastGame.current) {
sync.update(game);
lastGame.current = game;
}
if (gameEnd) sync.stop(gameEnd.clock);
const [, setTick] = useState(0);
const isRunning = sync.isRunning;
useEffect(() => {
if (!isRunning) return;
const timer = setInterval(() => setTick((tick) => tick + 1), TICK_MS);
return () => clearInterval(timer);
}, [isRunning]);
return sync.getTimes();
}

View File

@ -17,7 +17,7 @@ import LoadingComponent from '../components/LoadingComponent';
import PageContainer from '../components/PageContainer'; import PageContainer from '../components/PageContainer';
import Row from '../components/Row'; import Row from '../components/Row';
import UserStatus from '../components/UserStatus'; import UserStatus from '../components/UserStatus';
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, PlayedMove, TURN_TIMEOUT_MS } 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 } from '@mancala/core';
import { Context } from '../context/context'; import { Context } from '../context/context';
import useWindowDimensions from '../hooks/useWindowDimensions'; import useWindowDimensions from '../hooks/useWindowDimensions';
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
@ -34,8 +34,9 @@ import ReviewBanner from '../components/history/ReviewBanner';
import useMoveKeys from '../hooks/useMoveKeys'; import useMoveKeys from '../hooks/useMoveKeys';
import { createBoardViewModel } from '../util/BoardViewModels'; import { createBoardViewModel } from '../util/BoardViewModels';
import { getResultText } from '../util/GameResultText'; import { getResultText } from '../util/GameResultText';
import CircularPanel from '../components/CircularPanel';
import ReplayView from '../components/history/ReplayView'; import ReplayView from '../components/history/ReplayView';
import GameClockView from '../components/GameClockView';
import useGameClock from '../hooks/useGameClock';
const GamePage: FunctionComponent<{ const GamePage: FunctionComponent<{
context: Context, context: Context,
@ -69,9 +70,6 @@ const GamePage: FunctionComponent<{
// Set when the server says the game ended, for whatever reason. // Set when the server says the game ended, for whatever reason.
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined); const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
// Ticks every second while a countdown is shown.
const [now, setNow] = useState<number>(Date.now());
const { height, width } = useWindowDimensions(); const { height, width } = useWindowDimensions();
const navigate = useNavigate(); const navigate = useNavigate();
@ -306,21 +304,12 @@ const GamePage: FunctionComponent<{
} as FinishedGameSummary, isSpectator ? undefined : userKey, t)}` } as FinishedGameSummary, isSpectator ? undefined : userKey, t)}`
: undefined; : undefined;
// Countdown for the waiting player while the opponent is offline on their turn. const clockTimes = useGameClock(game, gameEnd);
const opponentInfo = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo] const renderClock = (playerId: string) => {
.find((info) => info && info.userId !== userKey); const seat = mancalaGame && getSeat(mancalaGame, playerId);
const countdownMs = !isSpectator && !isGameOver && opponentInfo && !opponentInfo.isOnline if (!clockTimes || !seat) return undefined;
&& opponentInfo.offlineSince !== undefined && mancalaGame?.turnPlayerId === opponentInfo.userId const timeMs = seat === "player1" ? clockTimes.player1Ms : clockTimes.player2Ms;
? Math.max(0, opponentInfo.offlineSince + TURN_TIMEOUT_MS - now) return <GameClockView context={context} timeMs={timeMs} running={clockTimes.runningSeat === seat} />;
: undefined;
React.useEffect(() => {
if (countdownMs === undefined) return;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [countdownMs === undefined]);
const formatCountdown = (ms: number) => {
const seconds = Math.ceil(ms / 1000);
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}; };
const showBoardView = game && boardViewModel && userKey && true; const showBoardView = game && boardViewModel && userKey && true;
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
@ -355,11 +344,6 @@ const GamePage: FunctionComponent<{
{renderHeaderBar()} {renderHeaderBar()}
{renderMobileBoardToolbar()} {renderMobileBoardToolbar()}
{buildBoardTopToolbar()} {buildBoardTopToolbar()}
{showBoardView && countdownMs !== undefined && (
<CircularPanel color={context.themeManager.theme.playerTurnColor} style={{ marginBottom: "0.5rem" }}>
{t("OpponentOfflineCountdown", { time: formatCountdown(countdownMs) })}
</CircularPanel>
)}
{showBoardView && isReviewing && ( {showBoardView && isReviewing && (
<ReviewBanner <ReviewBanner
context={context} context={context}
@ -423,19 +407,22 @@ const GamePage: FunctionComponent<{
<UserStatus style={{ <UserStatus style={{
marginBottom: "0.5rem", marginLeft: "6%", maxWidth: isMobile ? "40vw" : "30vw", marginBottom: "0.5rem", marginLeft: "6%", maxWidth: isMobile ? "40vw" : "30vw",
width: isMobile ? "40vw" : "30vw" width: isMobile ? "40vw" : "30vw"
}} context={context} layoutMode="left" user={topLocatedUser} visible={showBoardView || false} /> }} context={context} layoutMode="left" user={topLocatedUser} visible={showBoardView || false}
accessory={renderClock(topLocatedUserId)} />
{buildInfoPanel({ visible: !isMobile })} {buildInfoPanel({ visible: !isMobile })}
<UserStatus style={{ <UserStatus style={{
marginBottom: "0.5rem", marginRight: "6%", maxWidth: isMobile ? "40vw" : "30vw", marginBottom: "0.5rem", marginRight: "6%", maxWidth: isMobile ? "40vw" : "30vw",
width: isMobile ? "40vw" : "30vw" width: isMobile ? "40vw" : "30vw"
}} context={context} layoutMode="right" user={bottomLocatedUser} visible={showBoardView || false} /> }} context={context} layoutMode="right" user={bottomLocatedUser} visible={showBoardView || false}
accessory={renderClock(bottomLocatedUserId)} />
</BoardToolbar>; </BoardToolbar>;
} }
function buildInfoPanel(params: { visible: boolean }) { function buildInfoPanel(params: { visible: boolean }) {
return ( return (
<InfoPanel <InfoPanel
style={{ marginTop: "0.5rem", marginBottom: "0.5rem" }} // Long texts such as "Game Ended · You Won · Opponent ran out of time" wrap on phones.
style={{ marginTop: "0.5rem", marginBottom: "0.5rem", ...(isMobile && { whiteSpace: "normal", textAlign: "center" }) }}
context={context} context={context}
game={game} game={game}
currentUser={currentUser} currentUser={currentUser}

View File

@ -12,7 +12,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
`checkTimeouts`, the 10-second timer and the backend's `offlineSince`; unit tests. `checkTimeouts`, the 10-second timer and the backend's `offlineSince`; unit tests.
- [x] 4. **Restore and saving**: snapshot version 2 and restore of versions 1 and 2, `remainingMs` and - [x] 4. **Restore and saving**: snapshot version 2 and restore of versions 1 and 2, `remainingMs` and
`clockInitialMs` in game records, migration `002_clock` and the repository; unit and integration tests. `clockInitialMs` in game records, migration `002_clock` and the repository; unit and integration tests.
- [ ] 5. **Web app, live game**: `useGameClock`, `GameClockView`, clocks in `GamePage` on desktop and mobile - [x] 5. **Web app, live game**: `useGameClock`, `GameClockView`, clocks in `GamePage` on desktop and mobile
layouts; remove the offline countdown, and with it `TURN_TIMEOUT_MS`, `INACTIVE_TIMEOUT_MS`, layouts; remove the offline countdown, and with it `TURN_TIMEOUT_MS`, `INACTIVE_TIMEOUT_MS`,
`UserConnectionInfo.offlineSince` and `OpponentOfflineCountdown` from core (the web app uses them until then). `UserConnectionInfo.offlineSince` and `OpponentOfflineCountdown` from core (the web app uses them until then).
- [ ] 6. **Web app, replay**: clocks in `ReplayView` from `getClockAtMove`. - [ ] 6. **Web app, replay**: clocks in `ReplayView` from `getClockAtMove`.

View File

@ -72,5 +72,4 @@ export const en = {
WatchFromStart: "Watch from start", WatchFromStart: "Watch from start",
Speed: "Speed", Speed: "Speed",
Duration: "Duration", Duration: "Duration",
OpponentOfflineCountdown: "Opponent is offline. You win in {{time}} if they don't come back."
} }

View File

@ -72,5 +72,4 @@ export const tr = {
WatchFromStart: "Baştan izle", WatchFromStart: "Baştan izle",
Speed: "Hız", Speed: "Hız",
Duration: "Süre", Duration: "Süre",
OpponentOfflineCountdown: "Rakip çevrimdışı. Dönmezse {{time}} içinde kazanırsın."
}; };

View File

@ -1,11 +1,6 @@
import { GameStep } from "mancala.js"; import { GameStep } from "mancala.js";
import { GameClock } from "./GameClock"; import { GameClock } from "./GameClock";
// How long the player whose turn it is may stay offline before the opponent wins.
export const TURN_TIMEOUT_MS = 5 * 60 * 1000;
// How long both players may stay offline before the game ends without a winner.
export const INACTIVE_TIMEOUT_MS = 30 * 60 * 1000;
export interface PlayerSummary { export interface PlayerSummary {
userId: string; userId: string;
name?: string; name?: string;

View File

@ -4,6 +4,4 @@ export interface UserConnectionInfo {
// Sent by servers with Keycloak auth; undefined for legacy userKey players. // Sent by servers with Keycloak auth; undefined for legacy userKey players.
name?: string; name?: string;
isGuest?: boolean; isGuest?: boolean;
// Epoch milliseconds since the user has been offline; undefined while online.
offlineSince?: number;
} }