Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
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 { 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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
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);
|
||||
// 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(game)
|
||||
} else {
|
||||
res.sendStatus(404)
|
||||
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;
|
||||
|
||||
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[] = [];
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 { 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));
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user