feat(backend): add game history API and live game previews
- GET /games/me/active and /games/me (Bearer token), /games/popular (live games, or the most spectated finished ones), and /game/:id now also returns saved records of finished games - Lists are cached in Valkey for 10 seconds, cleared when a game is saved, and empty instead of failing while the database is down - Live game previews over WebSocket: subscribers get the top 5 on subscribe and on change, at most once per second, without counting as spectators; ids are public ids - RTMT supports several connection listeners
This commit is contained in:
parent
7239bbb6ed
commit
85e3fb60f6
89
apps/backend/src/game/LiveGames.ts
Normal file
89
apps/backend/src/game/LiveGames.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
channel_on_live_games,
|
||||||
|
channel_subscribe_live_games,
|
||||||
|
channel_unsubscribe_live_games,
|
||||||
|
LiveGameSummary,
|
||||||
|
} from "@mancala/core";
|
||||||
|
import { RTMT } from "../rtmt/rtmt";
|
||||||
|
import { BackendGame } from "../models/Game";
|
||||||
|
import { GameStore } from "./gamestore/GameStore";
|
||||||
|
import { GameManager } from "./GameManager";
|
||||||
|
import { publicUserId } from "./publicIds";
|
||||||
|
|
||||||
|
export const LIVE_GAMES_PUSH_COUNT = 5;
|
||||||
|
const PUSH_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
|
export function toLiveGameSummary(game: BackendGame): LiveGameSummary {
|
||||||
|
const { mancalaGame } = game;
|
||||||
|
const player = (id: string) => {
|
||||||
|
const info = game.players[id];
|
||||||
|
const summary = { userId: publicUserId(id), isGuest: info?.isGuest ?? true };
|
||||||
|
return info?.name === undefined ? summary : { ...summary, name: info.name };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: game.id,
|
||||||
|
player1: player(mancalaGame.player1Id),
|
||||||
|
player2: player(mancalaGame.player2Id),
|
||||||
|
board: mancalaGame.board.getStoneArray(),
|
||||||
|
turnPlayerId: publicUserId(mancalaGame.turnPlayerId),
|
||||||
|
spectatorCount: game.spectatorIds.length,
|
||||||
|
moveCount: mancalaGame.history.length,
|
||||||
|
startedAt: game.startedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastActivity = (game: BackendGame) => game.moveTimes[game.moveTimes.length - 1] ?? game.startedAt;
|
||||||
|
|
||||||
|
// Live games for the home page: most spectated first, then most recently active. Subscribers get the top games
|
||||||
|
// when they subscribe and again whenever they change, at most once per second. Subscribing does not make anyone a
|
||||||
|
// spectator.
|
||||||
|
export class LiveGames {
|
||||||
|
private readonly subscribers = new Set<string>();
|
||||||
|
private lastPushed = "";
|
||||||
|
private lastPushAt = 0;
|
||||||
|
private pushTimer?: NodeJS.Timeout;
|
||||||
|
|
||||||
|
constructor(private readonly params: { rtmt: RTMT, gameStore: GameStore, gameManager: GameManager }) {
|
||||||
|
const { rtmt, gameManager } = params;
|
||||||
|
rtmt.listenMessage(channel_subscribe_live_games, (userId: string) => this.subscribe(userId));
|
||||||
|
rtmt.listenMessage(channel_unsubscribe_live_games, (userId: string) => this.subscribers.delete(userId));
|
||||||
|
rtmt.listenOnClientConnectionChange((userId: string, isOnline: boolean) => {
|
||||||
|
if (!isOnline) this.subscribers.delete(userId);
|
||||||
|
});
|
||||||
|
gameManager.onLiveGamesChanged(() => this.schedulePush());
|
||||||
|
}
|
||||||
|
|
||||||
|
getTop(limit: number): LiveGameSummary[] {
|
||||||
|
return this.params.gameStore.getAll()
|
||||||
|
.sort((a, b) => b.spectatorIds.length - a.spectatorIds.length || lastActivity(b) - lastActivity(a))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(toLiveGameSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.pushTimer) clearTimeout(this.pushTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private subscribe(userId: string) {
|
||||||
|
this.subscribers.add(userId);
|
||||||
|
this.params.rtmt.sendMessage(userId, channel_on_live_games, { games: this.getTop(LIVE_GAMES_PUSH_COUNT) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedulePush() {
|
||||||
|
if (this.pushTimer) return;
|
||||||
|
const wait = Math.max(0, this.lastPushAt + PUSH_INTERVAL_MS - Date.now());
|
||||||
|
this.pushTimer = setTimeout(() => {
|
||||||
|
this.pushTimer = undefined;
|
||||||
|
this.push();
|
||||||
|
}, wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
private push() {
|
||||||
|
const games = this.getTop(LIVE_GAMES_PUSH_COUNT);
|
||||||
|
const serialized = JSON.stringify(games);
|
||||||
|
if (serialized === this.lastPushed) return;
|
||||||
|
this.lastPushed = serialized;
|
||||||
|
this.lastPushAt = Date.now();
|
||||||
|
this.subscribers.forEach((userId) => this.params.rtmt.sendMessage(userId, channel_on_live_games, { games }));
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/backend/src/history/GameHistoryService.ts
Normal file
46
apps/backend/src/history/GameHistoryService.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { FinishedGameSummary, GameRecord } from "@mancala/core";
|
||||||
|
import { Cache } from "../cache/Cache";
|
||||||
|
import { GameHistoryRepository } from "./GameHistoryRepository";
|
||||||
|
|
||||||
|
export const MAX_LIST_LIMIT = 20;
|
||||||
|
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) { }
|
||||||
|
|
||||||
|
get(id: string): Promise<GameRecord | undefined> {
|
||||||
|
return this.repository.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecentForUser(userId: string, limit: number): Promise<FinishedGameSummary[]> {
|
||||||
|
const games = await this.cached(recentKey(userId), () => this.repository.getRecentForUser(userId, MAX_LIST_LIMIT));
|
||||||
|
return games.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPopular(limit: number): Promise<FinishedGameSummary[]> {
|
||||||
|
const games = await this.cached(POPULAR_KEY, () => this.repository.getPopular(MAX_LIST_LIMIT));
|
||||||
|
return games.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call when a game has been saved, so lists show it right away.
|
||||||
|
onGameSaved(record: GameRecord): Promise<void> {
|
||||||
|
return this.cache.delete(POPULAR_KEY, recentKey(record.player1.userId), recentKey(record.player2.userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cached(key: string, load: () => Promise<FinishedGameSummary[]>): Promise<FinishedGameSummary[]> {
|
||||||
|
const hit = await this.cache.getJson<FinishedGameSummary[]>(key);
|
||||||
|
if (hit) return hit;
|
||||||
|
try {
|
||||||
|
const games = await load();
|
||||||
|
await this.cache.setJson(key, games, CACHE_TTL_SECONDS);
|
||||||
|
return games;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("(History) Could not load games:", (err as Error).message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,9 @@ import { PostgresGameHistoryRepository } from "./history/GameHistoryRepository";
|
|||||||
import { createValkeyClient } from "./live/valkey";
|
import { createValkeyClient } from "./live/valkey";
|
||||||
import { ValkeyLiveGameStore } from "./live/LiveGameStore";
|
import { ValkeyLiveGameStore } from "./live/LiveGameStore";
|
||||||
import { GameRecorder } from "./game/GameRecorder";
|
import { GameRecorder } from "./game/GameRecorder";
|
||||||
|
import { LiveGames } from "./game/LiveGames";
|
||||||
|
import { GameHistoryService } from "./history/GameHistoryService";
|
||||||
|
import { ValkeyCache } from "./cache/Cache";
|
||||||
|
|
||||||
const DATABASE_URL = process.env.DATABASE_URL || "postgres://mancala:mancala@localhost:5433/mancala";
|
const DATABASE_URL = process.env.DATABASE_URL || "postgres://mancala:mancala@localhost:5433/mancala";
|
||||||
const VALKEY_URL = process.env.VALKEY_URL || "redis://localhost:6379";
|
const VALKEY_URL = process.env.VALKEY_URL || "redis://localhost:6379";
|
||||||
@ -31,19 +34,25 @@ async function main() {
|
|||||||
await waitForReady(valkey, VALKEY_STARTUP_WAIT_MS);
|
await waitForReady(valkey, VALKEY_STARTUP_WAIT_MS);
|
||||||
|
|
||||||
const liveGameStore = new ValkeyLiveGameStore(valkey);
|
const liveGameStore = new ValkeyLiveGameStore(valkey);
|
||||||
const recorder = new GameRecorder(new PostgresGameHistoryRepository(pool), liveGameStore);
|
const repository = new PostgresGameHistoryRepository(pool);
|
||||||
|
const recorder = new GameRecorder(repository, liveGameStore);
|
||||||
|
const history = new GameHistoryService(repository, new ValkeyCache(valkey));
|
||||||
|
recorder.onSaved((record) => history.onGameSaved(record));
|
||||||
|
|
||||||
const rtmt = new RTMTWS({ tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth });
|
const rtmt = new RTMTWS({ tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth });
|
||||||
const gameStore = new GameStoreImpl();
|
const gameStore = new GameStoreImpl();
|
||||||
const matchMaker = new MatchMakerImpl({ rtmt });
|
const matchMaker = new MatchMakerImpl({ rtmt });
|
||||||
const gameManager = new GameManager({ rtmt, gameStore, matchMaker, liveGameStore, recorder });
|
const gameManager = new GameManager({ rtmt, gameStore, matchMaker, liveGameStore, recorder });
|
||||||
|
const liveGames = new LiveGames({ rtmt, gameStore, gameManager });
|
||||||
|
|
||||||
// Before accepting connections, so reconnecting players find their games.
|
// Before accepting connections, so reconnecting players find their games.
|
||||||
await gameManager.restoreLiveGames();
|
await gameManager.restoreLiveGames();
|
||||||
recorder.start();
|
recorder.start();
|
||||||
gameManager.startTimers();
|
gameManager.startTimers();
|
||||||
|
|
||||||
const expressApp = new ExpressApp({ gameStore, legacyUserKeyAuth: authConfig.legacyUserKeyAuth });
|
const expressApp = new ExpressApp({
|
||||||
|
gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth,
|
||||||
|
});
|
||||||
const server = new WebServer({ expressApp: expressApp.app });
|
const server = new WebServer({ expressApp: expressApp.app });
|
||||||
rtmt.initWebSocket(server.server);
|
rtmt.initWebSocket(server.server);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,5 +8,6 @@ export interface RTMT {
|
|||||||
listenMessage: (channel: string, callback: OnMessage) => any;
|
listenMessage: (channel: string, callback: OnMessage) => any;
|
||||||
isClientOnline(clientID: string): boolean;
|
isClientOnline(clientID: string): boolean;
|
||||||
getUser(clientID: string): AuthUser | undefined;
|
getUser(clientID: string): AuthUser | undefined;
|
||||||
|
// Adds a listener; every listener is called.
|
||||||
listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void;
|
listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export class RTMTWS implements RTMT {
|
|||||||
|
|
||||||
private readonly authTimeoutMs: number;
|
private readonly authTimeoutMs: number;
|
||||||
|
|
||||||
onUserConnectionChange?: OnClientConnectionChange;
|
private readonly connectionChangeListeners: OnClientConnectionChange[] = [];
|
||||||
|
|
||||||
constructor(params: { tokenVerifier: TokenVerifier, legacyUserKeyAuth: boolean, authTimeoutMs?: number }) {
|
constructor(params: { tokenVerifier: TokenVerifier, legacyUserKeyAuth: boolean, authTimeoutMs?: number }) {
|
||||||
this.tokenVerifier = params.tokenVerifier;
|
this.tokenVerifier = params.tokenVerifier;
|
||||||
@ -127,7 +127,7 @@ export class RTMTWS implements RTMT {
|
|||||||
}
|
}
|
||||||
userSockets.add(ws);
|
userSockets.add(ws);
|
||||||
// Fired for every new socket so each device gets the current game state.
|
// Fired for every new socket so each device gets the current game state.
|
||||||
this.onUserConnectionChange?.(user.id, true);
|
this.connectionChangeListeners.forEach((listener) => listener(user.id, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
private unbindSocket(ws: ClientSocket) {
|
private unbindSocket(ws: ClientSocket) {
|
||||||
@ -138,7 +138,7 @@ export class RTMTWS implements RTMT {
|
|||||||
if (userSockets.size === 0) {
|
if (userSockets.size === 0) {
|
||||||
this.sockets.delete(userId);
|
this.sockets.delete(userId);
|
||||||
this.users.delete(userId);
|
this.users.delete(userId);
|
||||||
this.onUserConnectionChange?.(userId, false);
|
this.connectionChangeListeners.forEach((listener) => listener(userId, false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,6 +234,6 @@ export class RTMTWS implements RTMT {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange) {
|
listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange) {
|
||||||
this.onUserConnectionChange = onUserConnectionChange;
|
this.connectionChangeListeners.push(onUserConnectionChange);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,48 +1,110 @@
|
|||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import morgan from "morgan";
|
import morgan from "morgan";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import { FinishedGameResponse } from "@mancala/core";
|
||||||
import { generateKey } from "../util/key_factory";
|
import { generateKey } from "../util/key_factory";
|
||||||
import { GameStore } from "../game/gamestore/GameStore";
|
import { GameStore } from "../game/gamestore/GameStore";
|
||||||
|
import { GameManager } from "../game/GameManager";
|
||||||
|
import { LiveGames, toLiveGameSummary } from "../game/LiveGames";
|
||||||
|
import { GameHistoryService, MAX_LIST_LIMIT } from "../history/GameHistoryService";
|
||||||
|
import { AuthUser, TokenVerifier } from "../auth";
|
||||||
|
import { requireUser } from "./authMiddleware";
|
||||||
|
|
||||||
|
type AsyncHandler = (req: Request, res: Response) => Promise<void>;
|
||||||
|
|
||||||
|
// Express 4 does not catch rejected promises from handlers.
|
||||||
|
const handleAsync = (handler: AsyncHandler) => (req: Request, res: Response, next: NextFunction) =>
|
||||||
|
handler(req, res).catch(next);
|
||||||
|
|
||||||
|
const parseLimit = (value: unknown, fallback: number): number => {
|
||||||
|
const limit = Number.parseInt(String(value ?? ""), 10);
|
||||||
|
return Number.isNaN(limit) ? fallback : Math.min(Math.max(limit, 1), MAX_LIST_LIMIT);
|
||||||
|
};
|
||||||
|
|
||||||
export class ExpressApp {
|
export class ExpressApp {
|
||||||
app: Express.Application;
|
app: Express.Application;
|
||||||
gameStore: GameStore;
|
private readonly params: {
|
||||||
legacyUserKeyAuth: boolean;
|
gameStore: GameStore,
|
||||||
constructor(params: { gameStore: GameStore, legacyUserKeyAuth: boolean }) {
|
gameManager: GameManager,
|
||||||
this.gameStore = params.gameStore;
|
liveGames: LiveGames,
|
||||||
this.legacyUserKeyAuth = params.legacyUserKeyAuth;
|
history: GameHistoryService,
|
||||||
|
tokenVerifier: TokenVerifier,
|
||||||
|
legacyUserKeyAuth: boolean,
|
||||||
|
accessLog?: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(params: ExpressApp["params"]) {
|
||||||
|
this.params = params;
|
||||||
this.app = this.createExpressApp();
|
this.app = this.createExpressApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
private createAccessLogMiddleware() {
|
private createAccessLogMiddleware() {
|
||||||
return morgan("common", {
|
return morgan("common", {
|
||||||
stream: fs.createWriteStream("./access.log", { flags: "a" }),
|
stream: fs.createWriteStream("./access.log", { flags: "a" }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public createExpressApp(): Express.Application {
|
public createExpressApp(): Express.Application {
|
||||||
|
const { gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth } = this.params;
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(this.createAccessLogMiddleware());
|
if (this.params.accessLog !== false) {
|
||||||
app.use(morgan("dev"));
|
app.use(this.createAccessLogMiddleware());
|
||||||
|
app.use(morgan("dev"));
|
||||||
|
}
|
||||||
app.get("/", (req: Request, res: Response) => {
|
app.get("/", (req: Request, res: Response) => {
|
||||||
res.send("Server up and running! ci test1");
|
res.send("Server up and running! ci test1");
|
||||||
});
|
});
|
||||||
// Issues userKeys for legacy clients; token clients get their identity from Keycloak.
|
// Issues userKeys for legacy clients; token clients get their identity from Keycloak.
|
||||||
if (this.legacyUserKeyAuth) {
|
if (legacyUserKeyAuth) {
|
||||||
app.get("/register/", (req: Request, res: Response) => {
|
app.get("/register/", (req: Request, res: Response) => {
|
||||||
res.send(generateKey());
|
res.send(generateKey());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get("/game/:id", (req: Request, res: Response) => {
|
// A live game as players receive it, or the saved record of a finished game.
|
||||||
const gameId = req.params.id;
|
app.get("/game/:id", handleAsync(async (req, res) => {
|
||||||
const game = this.gameStore.get(gameId);
|
const game = gameStore.get(req.params.id);
|
||||||
if(game) {
|
if (game) {
|
||||||
res.send(game)
|
res.send(gameManager.toClientGame(game));
|
||||||
} else {
|
return;
|
||||||
res.sendStatus(404)
|
|
||||||
}
|
}
|
||||||
|
const record = await history.get(req.params.id);
|
||||||
|
if (record) {
|
||||||
|
const response: FinishedGameResponse = { status: "finished", record };
|
||||||
|
res.send(response);
|
||||||
|
} else {
|
||||||
|
res.sendStatus(404);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Live games if any are being played, otherwise the most spectated finished games.
|
||||||
|
app.get("/games/popular", handleAsync(async (req, res) => {
|
||||||
|
const limit = parseLimit(req.query.limit, 5);
|
||||||
|
const live = liveGames.getTop(limit);
|
||||||
|
if (live.length > 0) {
|
||||||
|
res.send({ kind: "live", games: live });
|
||||||
|
} else {
|
||||||
|
res.send({ kind: "finished", games: await history.getPopular(limit) });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get("/games/me/active", requireUser(tokenVerifier), (req: Request, res: Response) => {
|
||||||
|
const user = res.locals.user as AuthUser;
|
||||||
|
const game = gameStore.getGameByUser(user.id);
|
||||||
|
res.send({ game: game ? toLiveGameSummary(game) : null });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/games/me", requireUser(tokenVerifier), handleAsync(async (req, res) => {
|
||||||
|
const user = res.locals.user as AuthUser;
|
||||||
|
res.send({ games: await history.getRecentForUser(user.id, parseLimit(req.query.limit, 3)) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||||
|
console.error(`(HTTP) ${req.method} ${req.path} failed:`, err.message);
|
||||||
|
res.sendStatus(503);
|
||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
21
apps/backend/src/server/authMiddleware.ts
Normal file
21
apps/backend/src/server/authMiddleware.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import { AuthUser, InvalidTokenError, TokenVerifier } from "../auth";
|
||||||
|
|
||||||
|
// Requires an "Authorization: Bearer <access token>" header with the same Keycloak token the WebSocket uses.
|
||||||
|
// The user is available as res.locals.user.
|
||||||
|
export function requireUser(tokenVerifier: TokenVerifier) {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const match = /^Bearer (.+)$/.exec(req.headers.authorization ?? "");
|
||||||
|
if (!match) {
|
||||||
|
res.sendStatus(401);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.locals.user = await tokenVerifier.verify(match[1]) as AuthUser;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof InvalidTokenError)) console.error("(Auth) Token verification failed", err);
|
||||||
|
res.sendStatus(401);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -8,16 +8,16 @@ export class FakeRtmt implements RTMT {
|
|||||||
readonly sent: SentMessage[] = [];
|
readonly sent: SentMessage[] = [];
|
||||||
private readonly listeners = new Map<string, OnMessage>();
|
private readonly listeners = new Map<string, OnMessage>();
|
||||||
private readonly online = new Map<string, AuthUser>();
|
private readonly online = new Map<string, AuthUser>();
|
||||||
private onConnectionChange?: OnClientConnectionChange;
|
private readonly connectionListeners: OnClientConnectionChange[] = [];
|
||||||
|
|
||||||
connect(user: AuthUser) {
|
connect(user: AuthUser) {
|
||||||
this.online.set(user.id, user);
|
this.online.set(user.id, user);
|
||||||
this.onConnectionChange?.(user.id, true);
|
this.connectionListeners.forEach((listener) => listener(user.id, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(userId: string) {
|
disconnect(userId: string) {
|
||||||
this.online.delete(userId);
|
this.online.delete(userId);
|
||||||
this.onConnectionChange?.(userId, false);
|
this.connectionListeners.forEach((listener) => listener(userId, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
receive(userId: string, channel: string, message: Object = {}) {
|
receive(userId: string, channel: string, message: Object = {}) {
|
||||||
@ -48,6 +48,6 @@ export class FakeRtmt implements RTMT {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void {
|
listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void {
|
||||||
this.onConnectionChange = onUserConnectionChange;
|
this.connectionListeners.push(onUserConnectionChange);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
92
apps/backend/tests/game/LiveGames.test.ts
Normal file
92
apps/backend/tests/game/LiveGames.test.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
channel_game_move,
|
||||||
|
channel_listen_game_events,
|
||||||
|
channel_new_game,
|
||||||
|
channel_on_live_games,
|
||||||
|
channel_subscribe_live_games,
|
||||||
|
channel_unsubscribe_live_games,
|
||||||
|
} from "@mancala/core";
|
||||||
|
import { GameManager } from "../../src/game/GameManager";
|
||||||
|
import { GameRecorder } from "../../src/game/GameRecorder";
|
||||||
|
import { GameStoreImpl } from "../../src/game/gamestore/GameStoreImpl";
|
||||||
|
import { LiveGames } from "../../src/game/LiveGames";
|
||||||
|
import { MatchMakerImpl } from "../../src/matchmaker/MatchMakerImpl";
|
||||||
|
import { FakeRtmt } from "./FakeRtmt";
|
||||||
|
import { InMemoryGameHistoryRepository, InMemoryLiveGameStore } from "./fakes";
|
||||||
|
|
||||||
|
const ALICE = { id: "kc:alice", name: "alice", isGuest: false };
|
||||||
|
const BOB = { id: "kc:bob", name: "bob", isGuest: false };
|
||||||
|
const VIEWER = { id: "kc:viewer", name: "viewer", isGuest: false };
|
||||||
|
const SPECTATOR = { id: "kc:spectator", name: "spectator", isGuest: false };
|
||||||
|
|
||||||
|
describe("LiveGames", () => {
|
||||||
|
let rtmt: FakeRtmt;
|
||||||
|
let gameStore: GameStoreImpl;
|
||||||
|
let liveGames: LiveGames;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers("modern");
|
||||||
|
rtmt = new FakeRtmt();
|
||||||
|
gameStore = new GameStoreImpl();
|
||||||
|
const recorder = new GameRecorder(new InMemoryGameHistoryRepository(), new InMemoryLiveGameStore());
|
||||||
|
const gameManager = new GameManager({
|
||||||
|
rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore: new InMemoryLiveGameStore(), recorder,
|
||||||
|
});
|
||||||
|
liveGames = new LiveGames({ rtmt, gameStore, gameManager });
|
||||||
|
[ALICE, BOB, VIEWER, SPECTATOR].forEach((user) => rtmt.connect(user));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
liveGames.dispose();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushes = () => rtmt.messagesTo(VIEWER.id, channel_on_live_games);
|
||||||
|
|
||||||
|
const startGame = () => {
|
||||||
|
rtmt.receive(ALICE.id, channel_new_game);
|
||||||
|
rtmt.receive(BOB.id, channel_new_game);
|
||||||
|
return gameStore.getGameByUser(ALICE.id)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("sends the current games on subscribe", () => {
|
||||||
|
const game = startGame();
|
||||||
|
rtmt.receive(VIEWER.id, channel_subscribe_live_games);
|
||||||
|
expect(pushes()).toHaveLength(1);
|
||||||
|
expect(pushes()[0].games.map((g: any) => g.id)).toEqual([game.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pushes changes at most once per second", () => {
|
||||||
|
rtmt.receive(VIEWER.id, channel_subscribe_live_games);
|
||||||
|
const game = startGame();
|
||||||
|
jest.advanceTimersByTime(0);
|
||||||
|
expect(pushes()).toHaveLength(2);
|
||||||
|
|
||||||
|
// Two changes right after a push are sent together, a second later.
|
||||||
|
rtmt.receive(game.mancalaGame.turnPlayerId, channel_game_move, { index: 0 });
|
||||||
|
rtmt.receive(SPECTATOR.id, channel_listen_game_events, game.id);
|
||||||
|
jest.advanceTimersByTime(999);
|
||||||
|
expect(pushes()).toHaveLength(2);
|
||||||
|
jest.advanceTimersByTime(1);
|
||||||
|
expect(pushes()).toHaveLength(3);
|
||||||
|
expect(pushes()[2].games[0]).toMatchObject({ moveCount: 1, spectatorCount: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not count subscribers as spectators", () => {
|
||||||
|
const game = startGame();
|
||||||
|
rtmt.receive(VIEWER.id, channel_subscribe_live_games);
|
||||||
|
expect(game.spectatorIds).toEqual([]);
|
||||||
|
expect(pushes()[0].games[0].spectatorCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops pushing to users who unsubscribe or go offline", () => {
|
||||||
|
rtmt.receive(VIEWER.id, channel_subscribe_live_games);
|
||||||
|
rtmt.receive(SPECTATOR.id, channel_subscribe_live_games);
|
||||||
|
rtmt.receive(VIEWER.id, channel_unsubscribe_live_games);
|
||||||
|
rtmt.disconnect(SPECTATOR.id);
|
||||||
|
const before = rtmt.sent.length;
|
||||||
|
startGame();
|
||||||
|
jest.advanceTimersByTime(1000);
|
||||||
|
expect(rtmt.sent.slice(before).filter((m) => m.channel === channel_on_live_games)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { FinishedGameSummary, GameRecord } from "@mancala/core";
|
import { FinishedGameSummary, GameRecord } from "@mancala/core";
|
||||||
import { GameHistoryRepository } from "../../src/history/GameHistoryRepository";
|
import { GameHistoryRepository } from "../../src/history/GameHistoryRepository";
|
||||||
import { LiveGameStore } from "../../src/live/LiveGameStore";
|
import { LiveGameStore } from "../../src/live/LiveGameStore";
|
||||||
|
import { Cache } from "../../src/cache/Cache";
|
||||||
|
|
||||||
export class InMemoryLiveGameStore implements LiveGameStore {
|
export class InMemoryLiveGameStore implements LiveGameStore {
|
||||||
readonly games = new Map<string, unknown>();
|
readonly games = new Map<string, unknown>();
|
||||||
@ -38,6 +39,9 @@ export class InMemoryLiveGameStore implements LiveGameStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lists leave out the moves, like the Postgres repository.
|
||||||
|
const toSummary = ({ moves, totalSpectators, ...summary }: GameRecord): FinishedGameSummary => summary;
|
||||||
|
|
||||||
export class InMemoryGameHistoryRepository implements GameHistoryRepository {
|
export class InMemoryGameHistoryRepository implements GameHistoryRepository {
|
||||||
readonly records = new Map<string, GameRecord>();
|
readonly records = new Map<string, GameRecord>();
|
||||||
failing = false;
|
failing = false;
|
||||||
@ -53,13 +57,26 @@ export class InMemoryGameHistoryRepository implements GameHistoryRepository {
|
|||||||
async getRecentForUser(userId: string, limit: number): Promise<FinishedGameSummary[]> {
|
async getRecentForUser(userId: string, limit: number): Promise<FinishedGameSummary[]> {
|
||||||
return [...this.records.values()]
|
return [...this.records.values()]
|
||||||
.filter((r) => r.player1.userId === userId || r.player2.userId === userId)
|
.filter((r) => r.player1.userId === userId || r.player2.userId === userId)
|
||||||
.sort((a, b) => b.endedAt - a.endedAt).slice(0, limit);
|
.sort((a, b) => b.endedAt - a.endedAt).slice(0, limit).map(toSummary);
|
||||||
}
|
}
|
||||||
async getPopular(limit: number): Promise<FinishedGameSummary[]> {
|
async getPopular(limit: number): Promise<FinishedGameSummary[]> {
|
||||||
return [...this.records.values()]
|
return [...this.records.values()]
|
||||||
.sort((a, b) => b.peakSpectators - a.peakSpectators || b.endedAt - a.endedAt).slice(0, limit);
|
.sort((a, b) => b.peakSpectators - a.peakSpectators || b.endedAt - a.endedAt).slice(0, limit).map(toSummary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lets fire-and-forget promises (saving to Valkey or the database) settle.
|
// Lets fire-and-forget promises (saving to Valkey or the database) settle.
|
||||||
export const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
|
export const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
export class InMemoryCache implements Cache {
|
||||||
|
readonly values = new Map<string, unknown>();
|
||||||
|
async getJson<T>(key: string) {
|
||||||
|
return this.values.get(key) as T | undefined;
|
||||||
|
}
|
||||||
|
async setJson(key: string, value: unknown) {
|
||||||
|
this.values.set(key, JSON.parse(JSON.stringify(value)));
|
||||||
|
}
|
||||||
|
async delete(...keys: string[]) {
|
||||||
|
keys.forEach((key) => this.values.delete(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
186
apps/backend/tests/server/ExpressApp.test.ts
Normal file
186
apps/backend/tests/server/ExpressApp.test.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import * as http from "http";
|
||||||
|
import { AddressInfo } from "net";
|
||||||
|
import {
|
||||||
|
channel_game_move,
|
||||||
|
channel_leave_game,
|
||||||
|
channel_listen_game_events,
|
||||||
|
channel_new_game,
|
||||||
|
GameRecord,
|
||||||
|
} from "@mancala/core";
|
||||||
|
import { AuthUser, InvalidTokenError, TokenVerifier } from "../../src/auth";
|
||||||
|
import { GameManager } from "../../src/game/GameManager";
|
||||||
|
import { GameRecorder } from "../../src/game/GameRecorder";
|
||||||
|
import { GameStoreImpl } from "../../src/game/gamestore/GameStoreImpl";
|
||||||
|
import { LiveGames } from "../../src/game/LiveGames";
|
||||||
|
import { publicUserId } from "../../src/game/publicIds";
|
||||||
|
import { GameHistoryService } from "../../src/history/GameHistoryService";
|
||||||
|
import { MatchMakerImpl } from "../../src/matchmaker/MatchMakerImpl";
|
||||||
|
import { ExpressApp } from "../../src/server/ExpressApp";
|
||||||
|
import { FakeRtmt } from "../game/FakeRtmt";
|
||||||
|
import { flushPromises, InMemoryCache, InMemoryGameHistoryRepository, InMemoryLiveGameStore } from "../game/fakes";
|
||||||
|
|
||||||
|
const ALICE = { id: "kc:alice", name: "alice", isGuest: false };
|
||||||
|
const BOB = { id: "kc:bob", name: "bob", isGuest: false };
|
||||||
|
const CAROL = { id: "kc:carol", name: "carol", isGuest: false };
|
||||||
|
const DAVE = { id: "kc:dave", name: "dave", isGuest: false };
|
||||||
|
const LEGACY = { id: "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11", isGuest: true };
|
||||||
|
|
||||||
|
// Accepts tokens of the form "valid:<user id>".
|
||||||
|
const verifier: TokenVerifier = {
|
||||||
|
async verify(token: string): Promise<AuthUser> {
|
||||||
|
if (!token.startsWith("valid:")) throw new InvalidTokenError("bad token");
|
||||||
|
return { id: token.slice("valid:".length), isGuest: false };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("HTTP API", () => {
|
||||||
|
let rtmt: FakeRtmt;
|
||||||
|
let gameStore: GameStoreImpl;
|
||||||
|
let repository: InMemoryGameHistoryRepository;
|
||||||
|
let gameManager: GameManager;
|
||||||
|
let liveGames: LiveGames;
|
||||||
|
let server: http.Server;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
rtmt = new FakeRtmt();
|
||||||
|
gameStore = new GameStoreImpl();
|
||||||
|
repository = new InMemoryGameHistoryRepository();
|
||||||
|
const liveGameStore = new InMemoryLiveGameStore();
|
||||||
|
const recorder = new GameRecorder(repository, liveGameStore);
|
||||||
|
const history = new GameHistoryService(repository, new InMemoryCache());
|
||||||
|
recorder.onSaved((record) => history.onGameSaved(record));
|
||||||
|
gameManager = new GameManager({ rtmt, gameStore, matchMaker: new MatchMakerImpl({ rtmt }), liveGameStore, recorder });
|
||||||
|
liveGames = new LiveGames({ rtmt, gameStore, gameManager });
|
||||||
|
const app = new ExpressApp({
|
||||||
|
gameStore, gameManager, liveGames, history, tokenVerifier: verifier, legacyUserKeyAuth: true, accessLog: false,
|
||||||
|
}).app as http.RequestListener;
|
||||||
|
server = http.createServer(app);
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||||
|
baseUrl = `http://localhost:${(server.address() as AddressInfo).port}`;
|
||||||
|
[ALICE, BOB, CAROL, DAVE].forEach((user) => rtmt.connect(user));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
liveGames.dispose();
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
});
|
||||||
|
|
||||||
|
const get = async (path: string, userId?: string) => {
|
||||||
|
const response = await fetch(baseUrl + path, userId ? { headers: { Authorization: `Bearer valid:${userId}` } } : {});
|
||||||
|
const body = response.headers.get("content-type")?.includes("json") ? await response.json() : undefined;
|
||||||
|
return { status: response.status, body };
|
||||||
|
};
|
||||||
|
|
||||||
|
const startGame = (first: { id: string } = ALICE, second: { id: string } = BOB) => {
|
||||||
|
rtmt.receive(first.id, channel_new_game);
|
||||||
|
rtmt.receive(second.id, channel_new_game);
|
||||||
|
return gameStore.getGameByUser(first.id)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishGame = async (first: { id: string } = ALICE, second: { id: string } = BOB) => {
|
||||||
|
const game = startGame(first, second);
|
||||||
|
rtmt.receive(first.id, channel_leave_game);
|
||||||
|
await flushPromises();
|
||||||
|
return repository.records.get(game.id)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("GET /game/:id", () => {
|
||||||
|
it("returns a live game as players receive it", async () => {
|
||||||
|
const game = startGame();
|
||||||
|
rtmt.receive(CAROL.id, channel_listen_game_events, game.id);
|
||||||
|
const { status, body } = await get(`/game/${game.id}`);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.id).toBe(game.id);
|
||||||
|
expect(body.mancalaGame.player1Id).toBe(game.mancalaGame.player1Id);
|
||||||
|
expect(body.spectatorCount).toBe(1);
|
||||||
|
expect(body).not.toHaveProperty("spectatorIds");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the saved record of a finished game", async () => {
|
||||||
|
const record = await finishGame();
|
||||||
|
const { status, body } = await get(`/game/${record.id}`);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual({ status: "finished", record });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers 404 for unknown games and 503 when the database is down", async () => {
|
||||||
|
expect((await get("/game/unknown")).status).toBe(404);
|
||||||
|
repository.failing = true;
|
||||||
|
expect((await get("/game/unknown")).status).toBe(503);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /games/popular", () => {
|
||||||
|
it("lists live games, most spectated first, with public ids", async () => {
|
||||||
|
rtmt.connect(LEGACY);
|
||||||
|
const quiet = startGame(ALICE, LEGACY);
|
||||||
|
const watched = startGame(BOB, CAROL);
|
||||||
|
rtmt.receive(DAVE.id, channel_listen_game_events, watched.id);
|
||||||
|
|
||||||
|
const { body } = await get("/games/popular?limit=5");
|
||||||
|
expect(body.kind).toBe("live");
|
||||||
|
expect(body.games.map((g: any) => g.id)).toEqual([watched.id, quiet.id]);
|
||||||
|
expect(body.games[0].spectatorCount).toBe(1);
|
||||||
|
expect(JSON.stringify(body)).not.toContain(LEGACY.id);
|
||||||
|
expect(JSON.stringify(body)).toContain(publicUserId(LEGACY.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists the most spectated finished games when nothing is live", async () => {
|
||||||
|
const watched = startGame();
|
||||||
|
rtmt.receive(CAROL.id, channel_listen_game_events, watched.id);
|
||||||
|
rtmt.receive(ALICE.id, channel_leave_game);
|
||||||
|
const quiet = await finishGame(CAROL, DAVE);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const { body } = await get("/games/popular");
|
||||||
|
expect(body.kind).toBe("finished");
|
||||||
|
expect(body.games.map((g: any) => g.id)).toEqual([watched.id, quiet.id]);
|
||||||
|
expect(body.games[0]).not.toHaveProperty("moves");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /games/me/active", () => {
|
||||||
|
it("requires a valid token", async () => {
|
||||||
|
expect((await get("/games/me/active")).status).toBe(401);
|
||||||
|
const response = await fetch(baseUrl + "/games/me/active", { headers: { Authorization: "Bearer forged" } });
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the user's live game, or null", async () => {
|
||||||
|
expect((await get("/games/me/active", ALICE.id)).body).toEqual({ game: null });
|
||||||
|
const game = startGame();
|
||||||
|
const { body } = await get("/games/me/active", ALICE.id);
|
||||||
|
expect(body.game).toMatchObject({ id: game.id, turnPlayerId: game.mancalaGame.turnPlayerId, moveCount: 0 });
|
||||||
|
expect(body.game.player1.name).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /games/me", () => {
|
||||||
|
it("requires a valid token", async () => {
|
||||||
|
expect((await get("/games/me")).status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the user's latest finished games, and shows a new one right away despite the cache", async () => {
|
||||||
|
const first = await finishGame(ALICE, BOB);
|
||||||
|
await finishGame(CAROL, DAVE);
|
||||||
|
expect((await get("/games/me", ALICE.id)).body.games.map((g: GameRecord) => g.id)).toEqual([first.id]);
|
||||||
|
|
||||||
|
const second = await finishGame(BOB, ALICE);
|
||||||
|
await flushPromises();
|
||||||
|
const { body } = await get("/games/me?limit=1", ALICE.id);
|
||||||
|
expect(body.games.map((g: GameRecord) => g.id)).toEqual([second.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list while the database is down", async () => {
|
||||||
|
repository.failing = true;
|
||||||
|
expect((await get("/games/me", ALICE.id)).body).toEqual({ games: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves keep working with the API in place", async () => {
|
||||||
|
const game = startGame();
|
||||||
|
rtmt.receive(game.mancalaGame.turnPlayerId, channel_game_move, { index: 0 });
|
||||||
|
expect((await get(`/game/${game.id}`)).body.mancalaGame.history).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -15,7 +15,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
|
|||||||
- [x] 6. **Game lifecycle**: start and move times, spectator counters, outcomes (completed, left, timeout, inactive),
|
- [x] 6. **Game lifecycle**: start and move times, spectator counters, outcomes (completed, left, timeout, inactive),
|
||||||
timeout watcher, `offlineSince`, rejecting a new game while playing, saving with retry, restore on startup;
|
timeout watcher, `offlineSince`, rejecting a new game while playing, saving with retry, restore on startup;
|
||||||
unit tests.
|
unit tests.
|
||||||
- [ ] 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.
|
||||||
- [ ] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop.
|
- [ ] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop.
|
||||||
- [ ] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby
|
- [ ] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user