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
19 changed files with 569 additions and 44 deletions
Showing only changes of commit a65662f695 - Show all commits

View File

@ -7,6 +7,9 @@ const RETRY_INTERVAL_MS = 30_000;
// Saves finished games. When the database is unreachable the record is kept (in Valkey, or in memory if Valkey is // Saves finished games. When the database is unreachable the record is kept (in Valkey, or in memory if Valkey is
// down too) and saved later, so a game is never lost because a dependency was briefly unavailable. // down too) and saved later, so a game is never lost because a dependency was briefly unavailable.
export class GameRecorder { export class GameRecorder {
// Being saved right now.
private readonly saving = new Map<string, GameRecord>();
// Failed to save; retried later.
private readonly pending = new Map<string, GameRecord>(); private readonly pending = new Map<string, GameRecord>();
private readonly savedListeners: ((record: GameRecord) => void)[] = []; private readonly savedListeners: ((record: GameRecord) => void)[] = [];
private retryTimer?: NodeJS.Timeout; private retryTimer?: NodeJS.Timeout;
@ -19,16 +22,24 @@ export class GameRecorder {
} }
async save(record: GameRecord): Promise<void> { async save(record: GameRecord): Promise<void> {
this.saving.set(record.id, record);
try { try {
await this.repository.save(record); await this.repository.save(record);
this.saving.delete(record.id);
this.notifySaved(record); this.notifySaved(record);
} catch (err) { } catch (err) {
console.error(`(GameRecorder) Could not save game ${record.id}, will retry:`, (err as Error).message); console.error(`(GameRecorder) Could not save game ${record.id}, will retry:`, (err as Error).message);
this.pending.set(record.id, record); this.pending.set(record.id, record);
this.saving.delete(record.id);
await this.liveGameStore.savePendingRecord(record).catch(() => undefined); await this.liveGameStore.savePendingRecord(record).catch(() => undefined);
} }
} }
// Finished games that are not in the database yet, so readers can include them.
getUnsaved(): GameRecord[] {
return [...this.saving.values(), ...this.pending.values()];
}
// True if the game is saved or waiting to be saved. Throws if the database cannot be asked. // True if the game is saved or waiting to be saved. Throws if the database cannot be asked.
async isRecorded(gameId: string): Promise<boolean> { async isRecorded(gameId: string): Promise<boolean> {
return this.pending.has(gameId) || (await this.repository.get(gameId)) !== undefined; return this.pending.has(gameId) || (await this.repository.get(gameId)) !== undefined;

View File

@ -7,23 +7,37 @@ const CACHE_TTL_SECONDS = 10;
const POPULAR_KEY = "popular-finished"; const POPULAR_KEY = "popular-finished";
const recentKey = (userId: string) => `recent:${userId}`; const recentKey = (userId: string) => `recent:${userId}`;
// Reads saved games for the API. Lists are cached briefly and come back empty while the database is unreachable, // Games that ended but are not in the database yet (being saved, or waiting for a retry).
// so the home page still loads. export type UnsavedGames = () => GameRecord[];
export class GameHistoryService {
constructor(private readonly repository: GameHistoryRepository, private readonly cache: Cache) { }
get(id: string): Promise<GameRecord | undefined> { const toSummary = ({ moves, totalSpectators, ...summary }: GameRecord): FinishedGameSummary => summary;
return this.repository.get(id);
const byNewest = (a: FinishedGameSummary, b: FinishedGameSummary) => b.endedAt - a.endedAt;
const byPopularity = (a: FinishedGameSummary, b: FinishedGameSummary) =>
b.peakSpectators - a.peakSpectators || byNewest(a, b);
// Reads finished games for the API, including ones not saved yet. Lists are cached briefly and come back without the
// saved games while the database is unreachable, so the home page still loads.
export class GameHistoryService {
constructor(
private readonly repository: GameHistoryRepository,
private readonly cache: Cache,
private readonly unsaved: UnsavedGames = () => [],
) { }
async get(id: string): Promise<GameRecord | undefined> {
return this.unsaved().find((record) => record.id === id) ?? this.repository.get(id);
} }
async getRecentForUser(userId: string, limit: number): Promise<FinishedGameSummary[]> { async getRecentForUser(userId: string, limit: number): Promise<FinishedGameSummary[]> {
const games = await this.cached(recentKey(userId), () => this.repository.getRecentForUser(userId, MAX_LIST_LIMIT)); const saved = await this.cached(recentKey(userId), () => this.repository.getRecentForUser(userId, MAX_LIST_LIMIT));
return games.slice(0, limit); const unsaved = this.unsaved().filter((r) => r.player1.userId === userId || r.player2.userId === userId);
return merge(saved, unsaved, byNewest).slice(0, limit);
} }
async getPopular(limit: number): Promise<FinishedGameSummary[]> { async getPopular(limit: number): Promise<FinishedGameSummary[]> {
const games = await this.cached(POPULAR_KEY, () => this.repository.getPopular(MAX_LIST_LIMIT)); const saved = await this.cached(POPULAR_KEY, () => this.repository.getPopular(MAX_LIST_LIMIT));
return games.slice(0, limit); return merge(saved, this.unsaved(), byPopularity).slice(0, limit);
} }
// Call when a game has been saved, so lists show it right away. // Call when a game has been saved, so lists show it right away.
@ -44,3 +58,8 @@ export class GameHistoryService {
} }
} }
} }
function merge(saved: FinishedGameSummary[], unsaved: GameRecord[], order: typeof byNewest): FinishedGameSummary[] {
const savedIds = new Set(saved.map((game) => game.id));
return [...saved, ...unsaved.filter((record) => !savedIds.has(record.id)).map(toSummary)].sort(order);
}

View File

@ -36,7 +36,7 @@ async function main() {
const liveGameStore = new ValkeyLiveGameStore(valkey); const liveGameStore = new ValkeyLiveGameStore(valkey);
const repository = new PostgresGameHistoryRepository(pool); const repository = new PostgresGameHistoryRepository(pool);
const recorder = new GameRecorder(repository, liveGameStore); const recorder = new GameRecorder(repository, liveGameStore);
const history = new GameHistoryService(repository, new ValkeyCache(valkey)); const history = new GameHistoryService(repository, new ValkeyCache(valkey), () => recorder.getUnsaved());
recorder.onSaved((record) => history.onGameSaved(record)); recorder.onSaved((record) => history.onGameSaved(record));
const rtmt = new RTMTWS({ tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth }); const rtmt = new RTMTWS({ tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth });

View File

@ -48,7 +48,7 @@ describe("HTTP API", () => {
repository = new InMemoryGameHistoryRepository(); repository = new InMemoryGameHistoryRepository();
const liveGameStore = new InMemoryLiveGameStore(); const liveGameStore = new InMemoryLiveGameStore();
const recorder = new GameRecorder(repository, liveGameStore); const recorder = new GameRecorder(repository, liveGameStore);
const history = new GameHistoryService(repository, new InMemoryCache()); const history = new GameHistoryService(repository, new InMemoryCache(), () => recorder.getUnsaved());
recorder.onSaved((record) => history.onGameSaved(record)); recorder.onSaved((record) => history.onGameSaved(record));
gameManager = new GameManager({ rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder }); gameManager = new GameManager({ rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder });
liveGames = new LiveGames({ rtmt, gameStore, gameManager }); liveGames = new LiveGames({ rtmt, gameStore, gameManager });
@ -176,6 +176,19 @@ describe("HTTP API", () => {
repository.failing = true; repository.failing = true;
expect((await get("/games/me", ALICE.id)).body).toEqual({ games: [] }); expect((await get("/games/me", ALICE.id)).body).toEqual({ games: [] });
}); });
it("includes a game that ended but is not saved yet", async () => {
repository.failing = true;
const game = startGame();
rtmt.receive(ALICE.id, channel_leave_game);
await flushPromises();
expect((await get("/games/me", ALICE.id)).body.games.map((g: GameRecord) => g.id)).toEqual([game.id]);
expect((await get("/games/popular")).body.games.map((g: GameRecord) => g.id)).toEqual([game.id]);
const { body } = await get(`/game/${game.id}`);
expect(body.status).toBe("finished");
expect(body.record.moves).toEqual([]);
});
}); });
it("moves keep working with the API in place", async () => { it("moves keep working with the API in place", async () => {

View File

@ -0,0 +1,50 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { getColorByBrightness, getPlayerDisplayName, LiveGameSummary } from "@mancala/core";
import { useTranslation } from "react-i18next";
import { Context } from "../../context/context";
import Button from "../Button";
// Shown at the top of the home page while the user has a game in progress.
const ActiveGameBanner: FunctionComponent<{
context: Context,
game: LiveGameSummary,
userId: string,
onContinue: () => void,
}> = ({ context, game, userId, onContinue }) => {
const { t } = useTranslation();
const theme = context.themeManager.theme;
const textColor = getColorByBrightness(theme.boardColor, theme.textColor, theme.textLightColor);
const opponent = game.player1.userId === userId ? game.player2 : game.player1;
const turn = game.turnPlayerId === userId ? t("YourTurn") : t("OpponentTurn");
return (
<div className="banner" style={{ background: theme.boardColor, color: textColor }}>
<div>
<div className="title">{t("GameInProgress")}</div>
<div>{`${t("Versus")} ${getPlayerDisplayName(opponent, t) ?? t("Anonymous")} · ${turn}`}</div>
</div>
<Button context={context} color={theme.pitColor} text={t("Continue")} onClick={onContinue} />
<style jsx>{`
.banner {
width: calc(100% - 32px);
max-width: 868px;
margin: 16px 16px 0;
padding: 12px 16px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
box-sizing: border-box;
transition: background-color 0.5s;
}
.title {
font-weight: bold;
margin-bottom: 4px;
}
`}</style>
</div>
);
};
export default ActiveGameBanner;

View File

@ -0,0 +1,104 @@
import * as React from "react";
import { FunctionComponent, useMemo } from "react";
import {
BoardViewModelFactory,
createGameFromBoard,
getColorByBrightness,
getPlayerDisplayName,
PitViewModelFactory,
PlayerSummary,
} from "@mancala/core";
import { useTranslation } from "react-i18next";
import { Context } from "../../context/context";
import BoardView from "../board/BoardView";
export const PREVIEW_BOARD_WIDTH = 260;
// A game as a card: the players, the real board at preview size, and a few lines of details.
const GamePreviewCard: FunctionComponent<{
context: Context,
id: string,
player1: PlayerSummary,
player2: PlayerSummary,
board: number[],
// Highlighted as the player to move.
turnPlayerId?: string,
// Show player 2's side at the bottom.
revert?: boolean,
lines: string[],
onClick: () => void,
}> = ({ context, id, player1, player2, board, turnPlayerId, revert, lines, onClick }) => {
const { t } = useTranslation();
const theme = context.themeManager.theme;
const textColor = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor);
const game = useMemo(() => createGameFromBoard({
id, player1Id: player1.userId, player2Id: player2.userId, turnPlayerId: turnPlayerId ?? player1.userId, board,
}), [id, player1.userId, player2.userId, turnPlayerId, board.join(",")]);
const boardViewModel = BoardViewModelFactory.create(id, board.map((stoneCount, index) => PitViewModelFactory.create({
id: String(index), stoneCount, stoneColor: theme.stoneColor, pitColor: theme.pitColor,
})));
const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous");
const nameStyle = (player: PlayerSummary): React.CSSProperties =>
({ fontWeight: player.userId === turnPlayerId ? "bold" : "normal" });
const [bottomPlayer, topPlayer] = revert ? [player2, player1] : [player1, player2];
return (
<div className="card" onClick={onClick} role="link" style={{ background: theme.appBarBgColor, color: textColor }}>
<div className="players">
<span style={nameStyle(topPlayer)}>{name(topPlayer)}</span>
<span className="versus">{t("Versus")}</span>
<span style={nameStyle(bottomPlayer)}>{name(bottomPlayer)}</span>
</div>
<div className="board">
<BoardView
game={game}
context={context}
boardId={id}
boardViewModel={boardViewModel}
revert={!!revert}
onPitSelect={() => undefined}
width={PREVIEW_BOARD_WIDTH} />
</div>
{lines.map((line, index) => <div key={index} className="line">{line}</div>)}
<style jsx>{`
.card {
padding: 12px;
border-radius: 16px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: ${PREVIEW_BOARD_WIDTH + 24}px;
box-sizing: border-box;
transition: background-color 0.5s, transform 0.2s;
}
.card:hover {
transform: translateY(-2px);
}
.players {
display: flex;
gap: 6px;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.versus {
opacity: 0.6;
}
.board {
pointer-events: none;
}
.line {
font-size: 0.9em;
opacity: 0.85;
}
`}</style>
</div>
);
};
export default GamePreviewCard;

View File

@ -0,0 +1,45 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { getColorByBrightness } from "@mancala/core";
import { Context } from "../../context/context";
// A titled block of the home page; children are laid out as wrapping cards.
const HomeSection: FunctionComponent<{ context: Context, title: string, emptyText?: string }> = (props) => {
const theme = props.context.themeManager.theme;
const textColor = getColorByBrightness(theme.background, theme.textColor, theme.textLightColor);
const isEmpty = React.Children.count(props.children) === 0;
return (
<section style={{ color: textColor }}>
<h3>{props.title}</h3>
{isEmpty && props.emptyText ? <p className="empty">{props.emptyText}</p> : <div className="cards">{props.children}</div>}
<style jsx>{`
section {
width: 100%;
max-width: 900px;
padding: 0 16px;
box-sizing: border-box;
}
h3 {
margin: 24px 0 12px;
}
.cards {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: flex-start;
}
.empty {
opacity: 0.7;
margin: 0;
}
@media (max-width: 600px) {
.cards {
justify-content: center;
}
}
`}</style>
</section>
);
};
export default HomeSection;

View File

@ -0,0 +1,89 @@
import * as React from "react";
import { FunctionComponent, useEffect, useState } from "react";
import {
channel_on_live_games,
channel_subscribe_live_games,
channel_unsubscribe_live_games,
LiveGameSummary,
} from "@mancala/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Context } from "../../context/context";
import { PopularGames } from "../../store/GameStore";
import { formatDate, getResultText, getScoreText } from "../../util/GameResultText";
import GamePreviewCard from "./GamePreviewCard";
import HomeSection from "./HomeSection";
const GAME_COUNT = 5;
// Live games, updated as they are played; when nothing is live, the most watched finished games.
const LiveGamesSection: FunctionComponent<{ context: Context }> = ({ context }) => {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const [popular, setPopular] = useState<PopularGames | undefined>(undefined);
useEffect(() => {
let disposed = false;
const loadPopular = () => context.gameStore.getPopularGames(GAME_COUNT)
.then((games) => !disposed && setPopular(games))
.catch((error) => console.error("(Home) Could not load popular games", error));
const onLiveGames = (message: { games: LiveGameSummary[] }) => {
if (message.games.length > 0) setPopular({ kind: "live", games: message.games });
else loadPopular();
};
// Subscribe whenever the connection is (re)established; the server forgets subscriptions of offline users.
const subscribe = () => context.rtmt.sendMessage(channel_subscribe_live_games, {});
context.rtmt.addMessageListener(channel_on_live_games, onLiveGames);
context.rtmt.on("open", subscribe);
if (context.rtmt.connectionState === "connected") subscribe();
loadPopular();
return () => {
disposed = true;
context.rtmt.off("open", subscribe);
context.rtmt.removeMessageListener(channel_on_live_games, onLiveGames);
if (context.rtmt.connectionState === "connected") context.rtmt.sendMessage(channel_unsubscribe_live_games, {});
};
}, []);
if (!popular) return <></>;
const openGame = (id: string) => navigate(`/game/${id}`);
if (popular.kind === "live") {
return (
<HomeSection context={context} title={t("LiveGames")}>
{popular.games.map((game) => (
<GamePreviewCard
key={game.id}
context={context}
id={game.id}
player1={game.player1}
player2={game.player2}
board={game.board}
turnPlayerId={game.turnPlayerId}
lines={[t("Watching", { count: game.spectatorCount }) + " · " + t("MoveNumber", { number: game.moveCount })]}
onClick={() => openGame(game.id)} />
))}
</HomeSection>
);
}
return (
<HomeSection context={context} title={t("PopularGames")} emptyText={t("NoGamesYet")}>
{popular.games.map((game) => (
<GamePreviewCard
key={game.id}
context={context}
id={game.id}
player1={game.player1}
player2={game.player2}
board={game.board}
lines={[
`${getResultText(game, undefined, t)} · ${getScoreText(game, undefined)}`,
`${formatDate(game.endedAt, i18n.language)} · ${t("Watching", { count: game.peakSpectators })}`,
]}
onClick={() => openGame(game.id)} />
))}
</HomeSection>
);
};
export default LiveGamesSection;

View File

@ -0,0 +1,50 @@
import * as React from "react";
import { FunctionComponent, useEffect, useState } from "react";
import { FinishedGameSummary } from "@mancala/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Context } from "../../context/context";
import { formatDate, getResultText, getScoreText, getViewerSeat } from "../../util/GameResultText";
import GamePreviewCard from "./GamePreviewCard";
import HomeSection from "./HomeSection";
const GAME_COUNT = 3;
// The user's last finished games, each shown from their side of the board.
const RecentGamesSection: FunctionComponent<{ context: Context, userId: string }> = ({ context, userId }) => {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const [games, setGames] = useState<FinishedGameSummary[] | undefined>(undefined);
useEffect(() => {
let disposed = false;
context.authService.getAccessToken()
.then((token) => context.gameStore.getMyRecentGames(token, GAME_COUNT))
.then((recent) => !disposed && setGames(recent))
.catch((error) => console.error("(Home) Could not load recent games", error));
return () => { disposed = true; };
}, [userId]);
if (!games) return <></>;
return (
<HomeSection context={context} title={t("MyRecentGames")} emptyText={t("NoGamesYet")}>
{games.map((game) => (
<GamePreviewCard
key={game.id}
context={context}
id={game.id}
player1={game.player1}
player2={game.player2}
board={game.board}
revert={getViewerSeat(game, userId) === "player2"}
lines={[
`${getResultText(game, userId, t)} · ${getScoreText(game, userId)}`,
formatDate(game.endedAt, i18n.language),
]}
onClick={() => navigate(`/game/${game.id}`)} />
))}
</HomeSection>
);
};
export default RecentGamesSection;

View File

@ -116,9 +116,7 @@ const GamePage: FunctionComponent<{
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);
checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_listen_game_events, game.id);
return () => { return () => {
checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_unlisten_game_events, game.id);
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);
context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave); context.rtmt.removeMessageListener(channel_on_game_user_leave, onGameUserLeave);
@ -126,6 +124,19 @@ const GamePage: FunctionComponent<{
} }
}; };
// Spectators tell the server they are watching once their identity is known and the connection is open,
// and again after every reconnect, since the server forgets spectators who go offline.
React.useEffect(() => {
if (!game || !userKey || !checkIsSpectator(game)) return;
const listen = () => context.rtmt.sendMessage(channel_listen_game_events, game.id);
if (context.rtmt.connectionState === "connected") listen();
context.rtmt.on("open", listen);
return () => {
context.rtmt.off("open", listen);
if (context.rtmt.connectionState === "connected") context.rtmt.sendMessage(channel_unlisten_game_events, game.id);
};
}, [game?.id, userKey]);
const updateBoardViewModel = (boardViewModel: BoardViewModel) => { const updateBoardViewModel = (boardViewModel: BoardViewModel) => {
boardViewModel.id = v4(); boardViewModel.id = v4();
setBoardId(boardViewModel.id); setBoardId(boardViewModel.id);
@ -207,8 +218,9 @@ const GamePage: FunctionComponent<{
let pitAnimator: PitAnimator | undefined; let pitAnimator: PitAnimator | undefined;
let unlistenMessages: () => void; let unlistenMessages: () => void;
setLoadingStateGame(LoadingState.Loading()) setLoadingStateGame(LoadingState.Loading())
context.gameStore.get(params.gameId!!).then((game) => { context.gameStore.get(params.gameId!!).then((loaded) => {
if (game) { if (loaded?.kind === "live") {
const game = loaded.game;
pitAnimator = new PitAnimator(context.themeManager, updateBoardViewModel); pitAnimator = new PitAnimator(context.themeManager, updateBoardViewModel);
setPitAnimator(pitAnimator); setPitAnimator(pitAnimator);
onGameUpdate(pitAnimator, game); onGameUpdate(pitAnimator, game);
@ -217,6 +229,9 @@ const GamePage: FunctionComponent<{
} else { } else {
setLoadingStateGame(LoadingState.Error({ errorMessage: t("GameNotFound") })) setLoadingStateGame(LoadingState.Error({ errorMessage: t("GameNotFound") }))
} }
}).catch((error) => {
console.error(error);
setLoadingStateGame(LoadingState.Error({ errorMessage: t("ErrorWhenRetrievingInformation") }))
}) })
return () => { return () => {
unlistenMessages?.(); unlistenMessages?.();

View File

@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import { FunctionComponent, useEffect, useState } from "react"; import { FunctionComponent, useEffect, useState } from "react";
import { getColorByBrightness } from "@mancala/core"; import { getColorByBrightness, LiveGameSummary } from "@mancala/core";
import { AuthUser, Theme } from "@mancala/core"; import { AuthUser, Theme } from "@mancala/core";
import HeaderBar from "../components/headerbar/HeaderBar"; import HeaderBar from "../components/headerbar/HeaderBar";
import PageContainer from "../components/PageContainer"; import PageContainer from "../components/PageContainer";
@ -9,6 +9,9 @@ import HeaderbarIcon from "../components/headerbar/HeaderbarIcon";
import HeaderbarTitle from "../components/headerbar/HeaderbarTitle"; import HeaderbarTitle from "../components/headerbar/HeaderbarTitle";
import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu"; import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu";
import AccountMenu from "../components/headerbar/AccountMenu"; import AccountMenu from "../components/headerbar/AccountMenu";
import ActiveGameBanner from "../components/home/ActiveGameBanner";
import LiveGamesSection from "../components/home/LiveGamesSection";
import RecentGamesSection from "../components/home/RecentGamesSection";
import Button from "../components/Button"; import Button from "../components/Button";
import { Context } from "../context/context"; import { Context } from "../context/context";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
@ -24,6 +27,18 @@ const Home: FunctionComponent<{
const navigate = useNavigate(); const navigate = useNavigate();
const [activeGame, setActiveGame] = useState<LiveGameSummary | null>(null);
useEffect(() => {
if (!authUser) return;
let disposed = false;
context.authService.getAccessToken()
.then((token) => context.gameStore.getActiveGame(token))
.then((game) => !disposed && setActiveGame(game))
.catch((error) => console.error("(Home) Could not load the active game", error));
return () => { disposed = true; };
}, [authUser?.id]);
const { t } = useTranslation(); const { t } = useTranslation();
const onNewGameClick = () => { const onNewGameClick = () => {
@ -57,6 +72,15 @@ const Home: FunctionComponent<{
onClick={onNewGameClick} /> onClick={onNewGameClick} />
</Row> </Row>
</HeaderBar> </HeaderBar>
{activeGame && authUser && (
<ActiveGameBanner
context={context}
game={activeGame}
userId={authUser.id}
onContinue={() => navigate(`/game/${activeGame.id}`)} />
)}
<LiveGamesSection context={context} />
{authUser && <RecentGamesSection context={context} userId={authUser.id} />}
</PageContainer> </PageContainer>
); );
}; };

View File

@ -1,6 +1,6 @@
import { CommonMancalaGame, MancalaGame } from 'mancala.js'; import { CommonMancalaGame, MancalaGame } from 'mancala.js';
import * as React from 'react'; import * as React from 'react';
import { FunctionComponent, useEffect } from 'react'; import { FunctionComponent, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import Center from '../components/Center'; import Center from '../components/Center';
import CircularPanel from '../components/CircularPanel'; import CircularPanel from '../components/CircularPanel';
@ -10,7 +10,7 @@ import HeaderbarTitle from '../components/headerbar/HeaderbarTitle';
import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu'; import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu';
import PageContainer from '../components/PageContainer'; import PageContainer from '../components/PageContainer';
import Row from '../components/Row'; import Row from '../components/Row';
import { channel_cancel_new_game, channel_on_game_start } from '@mancala/core'; import { channel_cancel_new_game, channel_on_game_start, channel_on_new_game_rejected } from '@mancala/core';
import { Context } from '../context/context'; import { Context } from '../context/context';
import { Theme } from '@mancala/core'; import { Theme } from '@mancala/core';
import { getColorByBrightness } from "@mancala/core"; import { getColorByBrightness } from "@mancala/core";
@ -27,6 +27,11 @@ const LobyPage: FunctionComponent<{
const { t } = useTranslation(); const { t } = useTranslation();
// Set when the server refuses a new game because the user has one in progress.
const [activeGameId, setActiveGameId] = useState<string | undefined>(undefined);
const onNewGameRejected = (message: { gameId: string }) => setActiveGameId(message.gameId);
const onGameStart = (message: Object) => { const onGameStart = (message: Object) => {
const newGame: CommonMancalaGame = message as CommonMancalaGame; const newGame: CommonMancalaGame = message as CommonMancalaGame;
navigate(`/game/${newGame.id}`) navigate(`/game/${newGame.id}`)
@ -40,9 +45,11 @@ const LobyPage: FunctionComponent<{
} }
useEffect(() => { useEffect(() => {
context.rtmt.addMessageListener(channel_on_game_start, onGameStart); context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
context.rtmt.addMessageListener(channel_on_new_game_rejected, onNewGameRejected);
context.rtmt.sendMessage("new_game", {}); context.rtmt.sendMessage("new_game", {});
return () => { return () => {
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected);
} }
}, []); }, []);
@ -78,8 +85,15 @@ const LobyPage: FunctionComponent<{
alignContent: "center" alignContent: "center"
}}> }}>
<CircularPanel color={context.themeManager.theme.boardColor}> <CircularPanel color={context.themeManager.theme.boardColor}>
<h4 style={{ margin: "0", color: textColorOnBoard }}>{`${t("SearchingOpponent")} ${t("PleaseWait")}...`}</h4> <h4 style={{ margin: "0", color: textColorOnBoard }}>
{activeGameId ? t("GameInProgress") : `${t("SearchingOpponent")} ${t("PleaseWait")}...`}
</h4>
</CircularPanel> </CircularPanel>
{activeGameId && <Button
context={context}
color={context.themeManager.theme.pitColor}
text={t("Continue")}
onClick={() => navigate(`/game/${activeGameId}`)} />}
<Button <Button
context={context} context={context}
color={context.themeManager.theme.boardColor} color={context.themeManager.theme.boardColor}

View File

@ -7,13 +7,12 @@ export class HttpServiceImpl implements HttpService {
this.serverAdress = serverAdress; this.serverAdress = serverAdress;
} }
public async get(route: string): Promise<Response> { // With a token, the request is authenticated as that user.
public async get(route: string, token?: string): Promise<Response> {
const url = this.serverAdress + route; const url = this.serverAdress + route;
const requestOptions = { const headers: Record<string, string> = { 'Content-Type': 'application/json' };
method: 'GET', if (token) headers['Authorization'] = `Bearer ${token}`;
headers: { 'Content-Type': 'application/json' }, const response = await fetch(url, { method: 'GET', headers });
};
const response = await fetch(url, requestOptions);
return response; return response;
} }
} }

View File

@ -1,28 +1,55 @@
import { MancalaGame } from "mancala.js"; import { MancalaGame } from "mancala.js";
import { Game } from "@mancala/core"; import { FinishedGameResponse, FinishedGameSummary, Game, GameRecord, LiveGameSummary } from "@mancala/core";
import { HttpService } from "../service/HttpService"; import { HttpServiceImpl } from "../service/HttpService";
export type LoadedGame = { kind: "live", game: Game } | { kind: "finished", record: GameRecord };
export type PopularGames = { kind: "live", games: LiveGameSummary[] } | { kind: "finished", games: FinishedGameSummary[] };
export interface GameStore { export interface GameStore {
get(id: string): Promise<Game | undefined>; // A live game, or the saved record of a finished one; undefined if there is no such game.
get(id: string): Promise<LoadedGame | undefined>;
getActiveGame(token: string): Promise<LiveGameSummary | null>;
getMyRecentGames(token: string, limit: number): Promise<FinishedGameSummary[]>;
getPopularGames(limit: number): Promise<PopularGames>;
} }
export class GameStoreImpl implements GameStore { export class GameStoreImpl implements GameStore {
httpService: HttpService; httpService: HttpServiceImpl;
constructor(props: { httpService: HttpService }) { constructor(props: { httpService: HttpServiceImpl }) {
this.httpService = props.httpService; this.httpService = props.httpService;
} }
async get(id: string): Promise<Game | undefined> { async get(id: string): Promise<LoadedGame | undefined> {
try { const response = await this.httpService.get(`/game/${id}`);
const response = await this.httpService.get(`/game/${id}`); if (response.status === 404) return undefined;
const json = await response.json(); if (!response.ok) throw new Error(`Loading game ${id} failed: ${response.status}`);
const game: Game = json as Game; const json = await response.json();
game.mancalaGame = MancalaGame.createFromMancalaGame(game.mancalaGame); if ((json as FinishedGameResponse).status === "finished") {
return game; return { kind: "finished", record: (json as FinishedGameResponse).record };
} catch (error) {
// todo check error
Promise.resolve(undefined);
} }
const game = json as Game;
game.mancalaGame = MancalaGame.createFromMancalaGame(game.mancalaGame);
return { kind: "live", game };
} }
} async getActiveGame(token: string): Promise<LiveGameSummary | null> {
const json = await this.getJson("/games/me/active", token);
return json.game;
}
async getMyRecentGames(token: string, limit: number): Promise<FinishedGameSummary[]> {
const json = await this.getJson(`/games/me?limit=${limit}`, token);
return json.games;
}
async getPopularGames(limit: number): Promise<PopularGames> {
return this.getJson(`/games/popular?limit=${limit}`);
}
private async getJson(route: string, token?: string): Promise<any> {
const response = await this.httpService.get(route, token);
if (!response.ok) throw new Error(`GET ${route} failed: ${response.status}`);
return response.json();
}
}

View File

@ -0,0 +1,38 @@
import { FinishedGameSummary, getPlayerDisplayName } from "@mancala/core";
type Translate = (key: string, options?: Record<string, unknown>) => string;
const PLAYER1_STORE = 6;
const PLAYER2_STORE = 13;
// The result as the viewer would say it: "You won · Opponent left" for a player, "alice won" for anyone else.
export function getResultText(game: FinishedGameSummary, viewerId: string | undefined, t: Translate): string {
if (game.result === null) return t("Abandoned");
if (game.result === "draw") return t("Draw");
const winner = game.result === "player1" ? game.player1 : game.player2;
const viewerSeat = getViewerSeat(game, viewerId);
if (!viewerSeat) return t("PlayerWon", { name: getPlayerDisplayName(winner, t) ?? t("Anonymous") });
const viewerWon = winner.userId === viewerId;
const outcome = viewerWon ? t("YouWon") : t("YouLost");
if (game.endReason === "left") return `${outcome} · ${viewerWon ? t("OpponentLeft") : t("YouLeft")}`;
if (game.endReason === "timeout") return `${outcome} · ${viewerWon ? t("OpponentTimedOut") : t("YouTimedOut")}`;
return outcome;
}
// Final stones in the stores, the viewer's first when they played.
export function getScoreText(game: FinishedGameSummary, viewerId: string | undefined): string {
const player1 = game.board[PLAYER1_STORE], player2 = game.board[PLAYER2_STORE];
return getViewerSeat(game, viewerId) === "player2" ? `${player2} - ${player1}` : `${player1} - ${player2}`;
}
export function getViewerSeat(game: FinishedGameSummary, viewerId: string | undefined): "player1" | "player2" | undefined {
if (viewerId === undefined) return undefined;
if (game.player1.userId === viewerId) return "player1";
if (game.player2.userId === viewerId) return "player2";
return undefined;
}
export function formatDate(epochMillis: number, language: string): string {
return new Date(epochMillis).toLocaleDateString(language, { day: "numeric", month: "short", year: "numeric" });
}

View File

@ -18,7 +18,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
- [x] 7. **API**: public ids, Bearer auth middleware, `/games/me/active`, `/games/me`, `/games/popular`, - [x] 7. **API**: public ids, Bearer auth middleware, `/games/me/active`, `/games/me`, `/games/popular`,
`/game/:id` for finished games, live games channel with throttled push; tests. `/game/:id` for finished games, live games channel with throttled push; tests.
- [x] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop. - [x] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop.
- [ ] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby - [x] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby
rejection message. rejection message.
- [ ] 10. **Live game history**: `MoveList`, review mode with `ReviewBanner` and keyboard, timeout countdown. - [ ] 10. **Live game history**: `MoveList`, review mode with `ReviewBanner` and keyboard, timeout countdown.
- [ ] 11. **Replay**: `ReplayView` with controls, speed, timeline, `?move=`, own-side orientation, duration. - [ ] 11. **Replay**: `ReplayView` with controls, speed, timeline, `?move=`, own-side orientation, duration.

View File

@ -85,3 +85,28 @@ export function createGameAtMove(game: Game, moves: PlayedMove[], moveNumber: nu
); );
return { ...game, mancalaGame }; return { ...game, mancalaGame };
} }
// A game showing the given stones, for displaying a board (e.g. a preview) when only the stone counts are known.
export function createGameFromBoard(params: {
id: string, player1Id: string, player2Id: string, turnPlayerId: string, board: number[],
}): Game {
const pits = params.board.map((stoneCount, index) =>
referenceBoard.checkPitTypeIsBankByIndex(index) ? new Bank(index, stoneCount) : new Pit(index, stoneCount));
const mancalaGame = new MancalaGame(
params.id,
new Board(referenceBoard.playerPitCount, referenceBoard.initialStoneCountInPits, pits),
params.player1Id,
params.player2Id,
params.turnPlayerId,
[],
[]
);
return {
id: params.id,
mancalaGame,
gameUsersConnectionInfo: {
user1ConnectionInfo: { userId: params.player1Id, isOnline: false },
user2ConnectionInfo: { userId: params.player2Id, isOnline: false },
},
};
}

View File

@ -66,6 +66,7 @@ export const en = {
GameOver: "Game over", GameOver: "Game over",
ViewingMove: "Viewing move {{number}}", ViewingMove: "Viewing move {{number}}",
MoveOf: "Move {{number}} / {{total}}", MoveOf: "Move {{number}} / {{total}}",
MoveNumber: "Move {{number}}",
Start: "Start", Start: "Start",
BackToLive: "Back to live", BackToLive: "Back to live",
WatchFromStart: "Watch from start", WatchFromStart: "Watch from start",

View File

@ -66,6 +66,7 @@ export const tr = {
GameOver: "Oyun bitti", GameOver: "Oyun bitti",
ViewingMove: "{{number}}. hamleye bakıyorsun", ViewingMove: "{{number}}. hamleye bakıyorsun",
MoveOf: "Hamle {{number}} / {{total}}", MoveOf: "Hamle {{number}} / {{total}}",
MoveNumber: "{{number}}. hamle",
Start: "Başlangıç", Start: "Başlangıç",
BackToLive: "Canlıya dön", BackToLive: "Canlıya dön",
WatchFromStart: "Baştan izle", WatchFromStart: "Baştan izle",