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
12 changed files with 552 additions and 29 deletions
Showing only changes of commit 85e3fb60f6 - Show all commits

View 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 }));
}
}

View 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 [];
}
}
}

View File

@ -11,6 +11,9 @@ import { PostgresGameHistoryRepository } from "./history/GameHistoryRepository";
import { createValkeyClient } from "./live/valkey";
import { ValkeyLiveGameStore } from "./live/LiveGameStore";
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 VALKEY_URL = process.env.VALKEY_URL || "redis://localhost:6379";
@ -31,19 +34,25 @@ async function main() {
await waitForReady(valkey, VALKEY_STARTUP_WAIT_MS);
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 gameStore = new GameStoreImpl();
const matchMaker = new MatchMakerImpl({ rtmt });
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.
await gameManager.restoreLiveGames();
recorder.start();
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 });
rtmt.initWebSocket(server.server);
}

View File

@ -8,5 +8,6 @@ export interface RTMT {
listenMessage: (channel: string, callback: OnMessage) => any;
isClientOnline(clientID: string): boolean;
getUser(clientID: string): AuthUser | undefined;
// Adds a listener; every listener is called.
listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void;
}

View File

@ -36,7 +36,7 @@ export class RTMTWS implements RTMT {
private readonly authTimeoutMs: number;
onUserConnectionChange?: OnClientConnectionChange;
private readonly connectionChangeListeners: OnClientConnectionChange[] = [];
constructor(params: { tokenVerifier: TokenVerifier, legacyUserKeyAuth: boolean, authTimeoutMs?: number }) {
this.tokenVerifier = params.tokenVerifier;
@ -127,7 +127,7 @@ export class RTMTWS implements RTMT {
}
userSockets.add(ws);
// 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) {
@ -138,7 +138,7 @@ export class RTMTWS implements RTMT {
if (userSockets.size === 0) {
this.sockets.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) {
this.onUserConnectionChange = onUserConnectionChange;
this.connectionChangeListeners.push(onUserConnectionChange);
}
}

View File

@ -1,48 +1,110 @@
import cors from "cors";
import express from "express";
import { Request, Response } from "express";
import { NextFunction, Request, Response } from "express";
import morgan from "morgan";
import fs from "fs";
import { FinishedGameResponse } from "@mancala/core";
import { generateKey } from "../util/key_factory";
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 {
app: Express.Application;
gameStore: GameStore;
legacyUserKeyAuth: boolean;
constructor(params: { gameStore: GameStore, legacyUserKeyAuth: boolean }) {
this.gameStore = params.gameStore;
this.legacyUserKeyAuth = params.legacyUserKeyAuth;
private readonly params: {
gameStore: GameStore,
gameManager: GameManager,
liveGames: LiveGames,
history: GameHistoryService,
tokenVerifier: TokenVerifier,
legacyUserKeyAuth: boolean,
accessLog?: boolean,
};
constructor(params: ExpressApp["params"]) {
this.params = params;
this.app = this.createExpressApp();
}
private createAccessLogMiddleware() {
return morgan("common", {
stream: fs.createWriteStream("./access.log", { flags: "a" }),
});
}
public createExpressApp(): Express.Application {
const { gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth } = this.params;
const app = express();
app.use(cors());
app.use(this.createAccessLogMiddleware());
app.use(morgan("dev"));
if (this.params.accessLog !== false) {
app.use(this.createAccessLogMiddleware());
app.use(morgan("dev"));
}
app.get("/", (req: Request, res: Response) => {
res.send("Server up and running! ci test1");
});
// Issues userKeys for legacy clients; token clients get their identity from Keycloak.
if (this.legacyUserKeyAuth) {
if (legacyUserKeyAuth) {
app.get("/register/", (req: Request, res: Response) => {
res.send(generateKey());
});
}
app.get("/game/:id", (req: Request, res: Response) => {
const gameId = req.params.id;
const game = this.gameStore.get(gameId);
if(game) {
res.send(game)
} else {
res.sendStatus(404)
// A live game as players receive it, or the saved record of a finished game.
app.get("/game/:id", handleAsync(async (req, res) => {
const game = gameStore.get(req.params.id);
if (game) {
res.send(gameManager.toClientGame(game));
return;
}
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;

View 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);
}
};
}

View File

@ -8,16 +8,16 @@ export class FakeRtmt implements RTMT {
readonly sent: SentMessage[] = [];
private readonly listeners = new Map<string, OnMessage>();
private readonly online = new Map<string, AuthUser>();
private onConnectionChange?: OnClientConnectionChange;
private readonly connectionListeners: OnClientConnectionChange[] = [];
connect(user: AuthUser) {
this.online.set(user.id, user);
this.onConnectionChange?.(user.id, true);
this.connectionListeners.forEach((listener) => listener(user.id, true));
}
disconnect(userId: string) {
this.online.delete(userId);
this.onConnectionChange?.(userId, false);
this.connectionListeners.forEach((listener) => listener(userId, false));
}
receive(userId: string, channel: string, message: Object = {}) {
@ -48,6 +48,6 @@ export class FakeRtmt implements RTMT {
}
listenOnClientConnectionChange(onUserConnectionChange: OnClientConnectionChange): void {
this.onConnectionChange = onUserConnectionChange;
this.connectionListeners.push(onUserConnectionChange);
}
}

View 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([]);
});
});

View File

@ -1,6 +1,7 @@
import { FinishedGameSummary, GameRecord } from "@mancala/core";
import { GameHistoryRepository } from "../../src/history/GameHistoryRepository";
import { LiveGameStore } from "../../src/live/LiveGameStore";
import { Cache } from "../../src/cache/Cache";
export class InMemoryLiveGameStore implements LiveGameStore {
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 {
readonly records = new Map<string, GameRecord>();
failing = false;
@ -53,13 +57,26 @@ export class InMemoryGameHistoryRepository implements GameHistoryRepository {
async getRecentForUser(userId: string, limit: number): Promise<FinishedGameSummary[]> {
return [...this.records.values()]
.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[]> {
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.
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));
}
}

View 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);
});
});

View File

@ -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),
timeout watcher, `offlineSince`, rejecting a new game while playing, saving with retry, restore on startup;
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.
- [ ] 8. **Board size**: `--board-unit` in `BoardView`, `PitView`, `StoreView`, `StoneView`; `width` prop.
- [ ] 9. **Home page**: `ActiveGameBanner`, `LiveGamesSection` with live updates, `RecentGamesSection`; lobby