diff --git a/apps/backend/src/game/GameRecorder.ts b/apps/backend/src/game/GameRecorder.ts index 5bb0a1b..8330ed9 100644 --- a/apps/backend/src/game/GameRecorder.ts +++ b/apps/backend/src/game/GameRecorder.ts @@ -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 // down too) and saved later, so a game is never lost because a dependency was briefly unavailable. export class GameRecorder { + // Being saved right now. + private readonly saving = new Map(); + // Failed to save; retried later. private readonly pending = new Map(); private readonly savedListeners: ((record: GameRecord) => void)[] = []; private retryTimer?: NodeJS.Timeout; @@ -19,16 +22,24 @@ export class GameRecorder { } async save(record: GameRecord): Promise { + this.saving.set(record.id, record); try { await this.repository.save(record); + this.saving.delete(record.id); this.notifySaved(record); } catch (err) { console.error(`(GameRecorder) Could not save game ${record.id}, will retry:`, (err as Error).message); this.pending.set(record.id, record); + this.saving.delete(record.id); 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. async isRecorded(gameId: string): Promise { return this.pending.has(gameId) || (await this.repository.get(gameId)) !== undefined; diff --git a/apps/backend/src/history/GameHistoryService.ts b/apps/backend/src/history/GameHistoryService.ts index ed708a3..3844590 100644 --- a/apps/backend/src/history/GameHistoryService.ts +++ b/apps/backend/src/history/GameHistoryService.ts @@ -7,23 +7,37 @@ const CACHE_TTL_SECONDS = 10; const POPULAR_KEY = "popular-finished"; 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, -// so the home page still loads. -export class GameHistoryService { - constructor(private readonly repository: GameHistoryRepository, private readonly cache: Cache) { } +// Games that ended but are not in the database yet (being saved, or waiting for a retry). +export type UnsavedGames = () => GameRecord[]; - get(id: string): Promise { - return this.repository.get(id); +const toSummary = ({ moves, totalSpectators, ...summary }: GameRecord): FinishedGameSummary => summary; + +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 { + return this.unsaved().find((record) => record.id === id) ?? this.repository.get(id); } async getRecentForUser(userId: string, limit: number): Promise { - const games = await this.cached(recentKey(userId), () => this.repository.getRecentForUser(userId, MAX_LIST_LIMIT)); - return games.slice(0, limit); + const saved = await this.cached(recentKey(userId), () => this.repository.getRecentForUser(userId, MAX_LIST_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 { - const games = await this.cached(POPULAR_KEY, () => this.repository.getPopular(MAX_LIST_LIMIT)); - return games.slice(0, limit); + const saved = await this.cached(POPULAR_KEY, () => this.repository.getPopular(MAX_LIST_LIMIT)); + return merge(saved, this.unsaved(), byPopularity).slice(0, limit); } // 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); +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 36db2f5..b2af499 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -36,7 +36,7 @@ async function main() { const liveGameStore = new ValkeyLiveGameStore(valkey); const repository = new PostgresGameHistoryRepository(pool); 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)); const rtmt = new RTMTWS({ tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth }); diff --git a/apps/backend/tests/server/ExpressApp.test.ts b/apps/backend/tests/server/ExpressApp.test.ts index b9f94a5..1318c75 100644 --- a/apps/backend/tests/server/ExpressApp.test.ts +++ b/apps/backend/tests/server/ExpressApp.test.ts @@ -48,7 +48,7 @@ describe("HTTP API", () => { repository = new InMemoryGameHistoryRepository(); const liveGameStore = new InMemoryLiveGameStore(); 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)); gameManager = new GameManager({ rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder }); liveGames = new LiveGames({ rtmt, gameStore, gameManager }); @@ -176,6 +176,19 @@ describe("HTTP API", () => { repository.failing = true; 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 () => { diff --git a/apps/frontend/src/components/home/ActiveGameBanner.tsx b/apps/frontend/src/components/home/ActiveGameBanner.tsx new file mode 100644 index 0000000..e8d0b78 --- /dev/null +++ b/apps/frontend/src/components/home/ActiveGameBanner.tsx @@ -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 ( +
+
+
{t("GameInProgress")}
+
{`${t("Versus")} ${getPlayerDisplayName(opponent, t) ?? t("Anonymous")} · ${turn}`}
+
+
+ ); +}; + +export default ActiveGameBanner; diff --git a/apps/frontend/src/components/home/GamePreviewCard.tsx b/apps/frontend/src/components/home/GamePreviewCard.tsx new file mode 100644 index 0000000..f0f10ed --- /dev/null +++ b/apps/frontend/src/components/home/GamePreviewCard.tsx @@ -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 ( +
+
+ {name(topPlayer)} + {t("Versus")} + {name(bottomPlayer)} +
+
+ undefined} + width={PREVIEW_BOARD_WIDTH} /> +
+ {lines.map((line, index) =>
{line}
)} + +
+ ); +}; + +export default GamePreviewCard; diff --git a/apps/frontend/src/components/home/HomeSection.tsx b/apps/frontend/src/components/home/HomeSection.tsx new file mode 100644 index 0000000..6f7a7a2 --- /dev/null +++ b/apps/frontend/src/components/home/HomeSection.tsx @@ -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 ( +
+

{props.title}

+ {isEmpty && props.emptyText ?

{props.emptyText}

:
{props.children}
} + +
+ ); +}; + +export default HomeSection; diff --git a/apps/frontend/src/components/home/LiveGamesSection.tsx b/apps/frontend/src/components/home/LiveGamesSection.tsx new file mode 100644 index 0000000..a52ffec --- /dev/null +++ b/apps/frontend/src/components/home/LiveGamesSection.tsx @@ -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(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 ( + + {popular.games.map((game) => ( + openGame(game.id)} /> + ))} + + ); + } + return ( + + {popular.games.map((game) => ( + openGame(game.id)} /> + ))} + + ); +}; + +export default LiveGamesSection; diff --git a/apps/frontend/src/components/home/RecentGamesSection.tsx b/apps/frontend/src/components/home/RecentGamesSection.tsx new file mode 100644 index 0000000..240111a --- /dev/null +++ b/apps/frontend/src/components/home/RecentGamesSection.tsx @@ -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(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 ( + + {games.map((game) => ( + navigate(`/game/${game.id}`)} /> + ))} + + ); +}; + +export default RecentGamesSection; diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index 68e9f20..7889633 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -116,9 +116,7 @@ const GamePage: FunctionComponent<{ 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); - checkIsSpectator(game) && userKey && context.rtmt.sendMessage(channel_listen_game_events, game.id); 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_crashed, onGameCrashed); 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) => { boardViewModel.id = v4(); setBoardId(boardViewModel.id); @@ -207,8 +218,9 @@ const GamePage: FunctionComponent<{ let pitAnimator: PitAnimator | undefined; let unlistenMessages: () => void; setLoadingStateGame(LoadingState.Loading()) - context.gameStore.get(params.gameId!!).then((game) => { - if (game) { + context.gameStore.get(params.gameId!!).then((loaded) => { + if (loaded?.kind === "live") { + const game = loaded.game; pitAnimator = new PitAnimator(context.themeManager, updateBoardViewModel); setPitAnimator(pitAnimator); onGameUpdate(pitAnimator, game); @@ -217,6 +229,9 @@ const GamePage: FunctionComponent<{ } else { setLoadingStateGame(LoadingState.Error({ errorMessage: t("GameNotFound") })) } + }).catch((error) => { + console.error(error); + setLoadingStateGame(LoadingState.Error({ errorMessage: t("ErrorWhenRetrievingInformation") })) }) return () => { unlistenMessages?.(); diff --git a/apps/frontend/src/routes/Home.tsx b/apps/frontend/src/routes/Home.tsx index 6e83ac0..d3959cf 100644 --- a/apps/frontend/src/routes/Home.tsx +++ b/apps/frontend/src/routes/Home.tsx @@ -1,6 +1,6 @@ import * as React 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 HeaderBar from "../components/headerbar/HeaderBar"; import PageContainer from "../components/PageContainer"; @@ -9,6 +9,9 @@ import HeaderbarIcon from "../components/headerbar/HeaderbarIcon"; import HeaderbarTitle from "../components/headerbar/HeaderbarTitle"; import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu"; 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 { Context } from "../context/context"; import { Link, useNavigate } from "react-router-dom"; @@ -24,6 +27,18 @@ const Home: FunctionComponent<{ const navigate = useNavigate(); + const [activeGame, setActiveGame] = useState(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 onNewGameClick = () => { @@ -57,6 +72,15 @@ const Home: FunctionComponent<{ onClick={onNewGameClick} /> + {activeGame && authUser && ( + navigate(`/game/${activeGame.id}`)} /> + )} + + {authUser && } ); }; diff --git a/apps/frontend/src/routes/LobyPage.tsx b/apps/frontend/src/routes/LobyPage.tsx index 94923a8..873992e 100644 --- a/apps/frontend/src/routes/LobyPage.tsx +++ b/apps/frontend/src/routes/LobyPage.tsx @@ -1,6 +1,6 @@ import { CommonMancalaGame, MancalaGame } from 'mancala.js'; import * as React from 'react'; -import { FunctionComponent, useEffect } from 'react'; +import { FunctionComponent, useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import Center from '../components/Center'; import CircularPanel from '../components/CircularPanel'; @@ -10,7 +10,7 @@ import HeaderbarTitle from '../components/headerbar/HeaderbarTitle'; import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu'; import PageContainer from '../components/PageContainer'; 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 { Theme } from '@mancala/core'; import { getColorByBrightness } from "@mancala/core"; @@ -27,6 +27,11 @@ const LobyPage: FunctionComponent<{ const { t } = useTranslation(); + // Set when the server refuses a new game because the user has one in progress. + const [activeGameId, setActiveGameId] = useState(undefined); + + const onNewGameRejected = (message: { gameId: string }) => setActiveGameId(message.gameId); + const onGameStart = (message: Object) => { const newGame: CommonMancalaGame = message as CommonMancalaGame; navigate(`/game/${newGame.id}`) @@ -40,9 +45,11 @@ const LobyPage: FunctionComponent<{ } useEffect(() => { context.rtmt.addMessageListener(channel_on_game_start, onGameStart); + context.rtmt.addMessageListener(channel_on_new_game_rejected, onNewGameRejected); context.rtmt.sendMessage("new_game", {}); return () => { 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" }}> -

{`${t("SearchingOpponent")} ${t("PleaseWait")}...`}

+

+ {activeGameId ? t("GameInProgress") : `${t("SearchingOpponent")} ${t("PleaseWait")}...`} +

+ {activeGameId &&