feat(backend): replace the Node backend with the Quarkus backend

The Quarkus project moves to apps/backend and the Express sources go.
The image is built from the repo root as before, with Temurin 25, and
runs as a non-root user on port 6000. Compose passes the database as a
JDBC URL with the same MANCALA_DB_PASSWORD.
This commit is contained in:
Halit Aksoy 2026-10-16 11:47:40 +03:00
parent 0c112eb716
commit 6aeff37eb8
155 changed files with 33 additions and 8538 deletions

View File

@ -1,6 +1,7 @@
# Images for apps/backend and apps/frontend are built with the repo root as context.
**/node_modules
**/dist
**/target
**/.parcel-cache
.git
apps/mobile

View File

@ -1,9 +0,0 @@
target/
.idea
*.iml
.vscode
.factorypath
# Written at runtime
access*.log
crashes/

View File

@ -1,40 +1,9 @@
lib-cov
*.seed
*.log
*.csv
*.dat
*.out
*.pid
*.gz
*.swp
pids
logs
results
tmp
# Build
public/css/main.css
# Coverage reports
coverage
# API keys and secrets
.env
# Dependency directory
node_modules
bower_components
# Editors
target/
.idea
*.iml
.vscode
.factorypath
# OS metadata
.DS_Store
Thumbs.db
# Ignore built ts files
dist/**/*
crashes
# Written at runtime
access*.log
crashes/

View File

@ -1,25 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${workspaceFolder}/src/index.ts",
"preLaunchTask": "tsc: build - tsconfig.json",
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
},
{
"type": "node",
"request": "launch",
"name": "Jest Current File",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["${relativeFile}"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"windows": {
"program": "${workspaceFolder}/node_modules/jest/bin/jest"
}
}
]
}

View File

@ -1,26 +1,29 @@
# Build from the repo root: docker build -f apps/backend/Dockerfile .
FROM node:lts-buster-slim
FROM eclipse-temurin:25.0.4_7-jdk-noble AS build
# The Maven wrapper checks the .zip it downloads; without unzip it would fetch a .tar.gz that fails the check.
RUN apt-get update && apt-get install -y --no-install-recommends unzip && rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Dependencies first, so they are cached until the pom changes
COPY apps/backend/.mvn .mvn
COPY apps/backend/mvnw apps/backend/pom.xml ./
RUN ./mvnw -B -q quarkus:go-offline
COPY apps/backend/src src
RUN ./mvnw -B -q package -DskipTests
FROM eclipse-temurin:25.0.4_7-jre-noble
ENV PORT=6000
WORKDIR /usr/src/app
# Local packages the backend links to
COPY package.json package.json
COPY packages packages
RUN yarn build:packages
WORKDIR /usr/src/app/apps/backend
COPY apps/backend/package.json package.json
COPY apps/backend/yarn.lock yarn.lock
RUN yarn
COPY apps/backend .
RUN yarn build
# The app writes access.log and crashes/ to its working directory.
RUN useradd --system --create-home mancala && install -d -o mancala /home/mancala/app
WORKDIR /home/mancala/app
COPY --from=build --chown=mancala /build/target/quarkus-app/ ./
USER mancala
EXPOSE ${PORT}
CMD [ "yarn", "start"]
CMD ["java", "-jar", "quarkus-run.jar"]

View File

@ -1,82 +0,0 @@
# Mancala Backend
This is the backend code for the Mancala project, written in Express.js.
## Getting Started
### Install Packages
```bash
yarn install
```
### Start in Development Mode
```bash
yarn dev
```
### Start in Release Mode
```bash
yarn build
yarn start
```
### Development Dependencies
This package links the local packages `@mancala/core` and `mancala.js`. Build them first with `yarn build:packages` in the repo root.
## Authentication
Clients log in with Keycloak (see `apps/keycloak`), either as a guest or with an account, and send the access token
over the WebSocket right after connecting:
```json
{ "channel": "auth", "message": { "token": "<Keycloak access token>" } }
```
The server answers on `on_auth` with the user (`{ id, name, isGuest }`) or on `on_auth_error` and closes the socket
with code 4001. Other messages are ignored until the socket is authenticated, and connections that do not
authenticate within 5 seconds are closed. Send `auth` again with the new token after refreshing it.
User ids of Keycloak users are the token subject prefixed with `kc:`.
| Variable | Default | Purpose |
|---|---|---|
| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/mancala` | Token issuer; must match the `iss` claim exactly |
| `KEYCLOAK_JWKS_URL` | issuer + `/protocol/openid-connect/certs` | Where to fetch signing keys, if the backend reaches Keycloak at a different address |
| `KEYCLOAK_CLIENT_IDS` | `mancala-web` | Comma-separated clients whose tokens are accepted |
| `LEGACY_USERKEY_AUTH` | `true` | Also accept old clients that connect with `?userKey=<uuid>` and the `/register` endpoint. Turn off once no released client uses it, because a userKey is not a secret the server can check |
## Saved games and live games
Finished games are saved in Postgres; live games are copied to Valkey after every change and restored when the
backend starts, so a restart or deploy does not end them. The design is in `docs/specs/game-history`.
| Variable | Default | Purpose |
|---|---|---|
| `DATABASE_URL` | `postgres://mancala:mancala@localhost:5433/mancala` | Saved games; migrations run on startup |
| `VALKEY_URL` | `redis://localhost:6379` | Live game state and short-lived caches |
The backend keeps running when either is down: games are still played, finished games are saved once Postgres is
back, and lists are served without the missing data.
## Game clock
Each player has a clock for the whole game; only the clock of the player whose turn it is runs, also while they are
offline. A move that arrives after the mover's time has run out is refused, and `GameClockScheduler` ends games whose
running clock reaches zero once a second, so the opponent wins on time even if nobody is connected. Time while the
backend is down is not charged. The design is in `docs/specs/game-clock`.
| Variable | Default | Purpose |
|---|---|---|
| `GAME_CLOCK_MS` | `300000` | Time each player has for a game; a short value such as `40000` helps when testing by hand |
| Route | Auth | Response |
|---|---|---|
| `GET /game/:id` | – | A live game, or `{ status: "finished", record }` |
| `GET /games/popular?limit=5` | – | `{ kind: "live" \| "finished", games }` |
| `GET /games/me/active` | Bearer token | `{ game }` or `{ game: null }` |
| `GET /games/me?limit=3` | Bearer token | `{ games }`, newest first |
Tests use Docker (testcontainers) for Postgres and Valkey.

View File

@ -1,4 +0,0 @@
rm -rf node_modules
yarn
yarn build
yarn start

View File

@ -1,6 +0,0 @@
module.exports = {
transform: {'^.+\\.ts?$': 'ts-jest'},
testEnvironment: '<rootDir>/tests/support/NodeEnvironment.js',
testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
};

View File

@ -1,44 +0,0 @@
{
"name": "@mancala/backend",
"version": "0.4.7",
"description": "",
"main": "index.js",
"scripts": {
"start": "node dist/index.js",
"dev": "nodemon src/index.ts",
"build": "tsc",
"test": "jest",
"coverage": "jest --coverage"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@mancala/core": "link:../../packages/core",
"@types/cors": "^2.8.10",
"@types/morgan": "^1.9.3",
"@types/uuid": "^8.3.0",
"@types/ws": "^7.4.5",
"cors": "^2.8.5",
"express": "^4.17.1",
"iovalkey": "0.4.0",
"jose": "5.10.0",
"mancala.js": "link:../../packages/mancala.js",
"morgan": "^1.10.0",
"pg": "8.23.1",
"uuid": "^8.3.2",
"ws": "^7.5.0"
},
"devDependencies": {
"@types/express": "^4.17.12",
"@types/jest": "^26.0.23",
"@types/node": "^15.12.4",
"@types/pg": "8.23.1",
"jest": "^27.0.5",
"nodemon": "^2.0.7",
"testcontainers": "12.2.0",
"ts-jest": "^27.0.3",
"ts-node": "^10.0.0",
"typescript": "^4.7.4"
}
}

View File

@ -1,16 +0,0 @@
export interface AuthConfig {
keycloakIssuer: string;
keycloakJwksUrl?: string;
keycloakClientIds: string[];
// Accept old clients that identify themselves with ?userKey= instead of a token.
legacyUserKeyAuth: boolean;
}
export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig {
return {
keycloakIssuer: env.KEYCLOAK_ISSUER || "http://localhost:8080/realms/mancala",
keycloakJwksUrl: env.KEYCLOAK_JWKS_URL || undefined,
keycloakClientIds: (env.KEYCLOAK_CLIENT_IDS || "mancala-web").split(",").map((id) => id.trim()).filter(Boolean),
legacyUserKeyAuth: (env.LEGACY_USERKEY_AUTH ?? "true") === "true",
};
}

View File

@ -1 +0,0 @@
export type { AuthUser } from "@mancala/core";

View File

@ -1,72 +0,0 @@
import { createRemoteJWKSet, errors, JWTPayload, JWTVerifyGetKey, jwtVerify } from "jose";
import { AuthUser } from "./AuthUser";
import { InvalidTokenError, TokenVerifier } from "./TokenVerifier";
export const KEYCLOAK_USER_ID_PREFIX = "kc:";
export const GUEST_ROLE = "guest";
export interface KeycloakTokenVerifierOptions {
// Realm issuer, e.g. https://auth.playmancala.app/realms/mancala
issuer: string;
// Clients whose access tokens are accepted (checked against the azp claim).
allowedClientIds: string[];
// Where to fetch signing keys from. Defaults to the issuer's certs endpoint.
jwksUrl?: string;
// Overrides key lookup, for tests.
keys?: JWTVerifyGetKey;
}
type KeycloakAccessToken = JWTPayload & {
typ?: string;
azp?: string;
preferred_username?: string;
realm_access?: { roles?: string[] };
};
/**
* Verifies Keycloak access tokens offline against the realm's published signing keys.
* Player ids are the token subject prefixed with "kc:", so they can never collide with
* ids that legacy userKey clients choose for themselves.
*/
export class KeycloakTokenVerifier implements TokenVerifier {
private readonly issuer: string;
private readonly allowedClientIds: string[];
private readonly keys: JWTVerifyGetKey;
constructor(options: KeycloakTokenVerifierOptions) {
this.issuer = options.issuer;
this.allowedClientIds = options.allowedClientIds;
this.keys = options.keys ??
createRemoteJWKSet(new URL(options.jwksUrl ?? `${options.issuer}/protocol/openid-connect/certs`));
}
async verify(token: string): Promise<AuthUser> {
let payload: KeycloakAccessToken;
try {
({ payload } = await jwtVerify(token, this.keys, {
issuer: this.issuer,
algorithms: ["RS256"],
requiredClaims: ["sub", "exp"],
}));
} catch (err) {
if (err instanceof errors.JOSEError) {
throw new InvalidTokenError(err.code);
}
throw err;
}
// Keycloak ID tokens and refresh tokens are signed by the same keys, only accept access tokens.
if (payload.typ !== "Bearer") {
throw new InvalidTokenError("not an access token");
}
if (!payload.azp || !this.allowedClientIds.includes(payload.azp)) {
throw new InvalidTokenError("token issued to an unknown client");
}
return {
id: KEYCLOAK_USER_ID_PREFIX + payload.sub,
name: payload.preferred_username,
isGuest: payload.realm_access?.roles?.includes(GUEST_ROLE) ?? false,
};
}
}

View File

@ -1,13 +0,0 @@
import { AuthUser } from "./AuthUser";
export class InvalidTokenError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidTokenError";
}
}
export interface TokenVerifier {
// Resolves to the token's user, or rejects with InvalidTokenError.
verify(token: string): Promise<AuthUser>;
}

View File

@ -1,4 +0,0 @@
export * from "./AuthUser";
export * from "./AuthConfig";
export * from "./TokenVerifier";
export * from "./KeycloakTokenVerifier";

View File

@ -1,40 +0,0 @@
import { Valkey } from "iovalkey";
const PREFIX = "mancala:cache:";
// Short-lived cache of JSON values. Never fails: errors count as a miss, so callers just compute the value.
export interface Cache {
getJson<T>(key: string): Promise<T | undefined>;
setJson(key: string, value: unknown, ttlSeconds: number): Promise<void>;
delete(...keys: string[]): Promise<void>;
}
export class ValkeyCache implements Cache {
constructor(private readonly client: Valkey) { }
async getJson<T>(key: string): Promise<T | undefined> {
try {
const value = await this.client.get(PREFIX + key);
return value === null ? undefined : JSON.parse(value) as T;
} catch (err) {
return undefined;
}
}
async setJson(key: string, value: unknown, ttlSeconds: number): Promise<void> {
try {
await this.client.set(PREFIX + key, JSON.stringify(value), "EX", ttlSeconds);
} catch (err) {
// A missing cache entry only costs a database query.
}
}
async delete(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
try {
await this.client.del(...keys.map((key) => PREFIX + key));
} catch (err) {
// Entries expire on their own.
}
}
}

View File

@ -1,9 +0,0 @@
export const RTMT_WS_PING_INTERVAL = 2000;
// Time each player has for a whole game (docs/specs/game-clock).
export const GAME_CLOCK_MS = positiveInteger(process.env.GAME_CLOCK_MS) ?? 5 * 60 * 1000;
function positiveInteger(value: string | undefined): number | undefined {
const number = Number(value);
return value && Number.isInteger(number) && number > 0 ? number : undefined;
}

View File

@ -1,29 +0,0 @@
export const sql = `
CREATE TABLE games (
id uuid PRIMARY KEY,
player1_id text NOT NULL,
player2_id text NOT NULL,
result text CHECK (result IN ('player1', 'player2', 'draw')),
end_reason text NOT NULL CHECK (end_reason IN ('completed', 'left', 'timeout', 'inactive')),
left_user_id text,
started_at timestamptz NOT NULL,
ended_at timestamptz NOT NULL,
move_count integer NOT NULL,
final_board jsonb NOT NULL,
moves jsonb NOT NULL,
peak_spectators integer NOT NULL DEFAULT 0,
total_spectators integer NOT NULL DEFAULT 0
);
CREATE TABLE game_players (
game_id uuid NOT NULL REFERENCES games (id) ON DELETE CASCADE,
seat smallint NOT NULL CHECK (seat IN (1, 2)),
user_id text NOT NULL,
name text,
is_guest boolean NOT NULL,
PRIMARY KEY (game_id, seat)
);
CREATE INDEX game_players_user_id ON game_players (user_id);
CREATE INDEX games_popular ON games (peak_spectators DESC, ended_at DESC);
`;

View File

@ -1,4 +0,0 @@
// Time each player started with; NULL for games saved before the game clock. Each move's clock is in `moves`.
export const sql = `
ALTER TABLE games ADD COLUMN clock_initial_ms integer;
`;

View File

@ -1,13 +0,0 @@
import { sql as games001 } from "./001_games";
import { sql as clock002 } from "./002_clock";
export interface Migration {
version: string;
sql: string;
}
// Applied in this order; never change a migration that has been released, add a new one instead.
export const migrations: Migration[] = [
{ version: "001_games", sql: games001 },
{ version: "002_clock", sql: clock002 },
];

View File

@ -1,41 +0,0 @@
import { Pool } from "pg";
import { Migration, migrations as allMigrations } from "./migrations";
const MIGRATION_LOCK_ID = 727274;
export function createPool(databaseUrl: string): Pool {
const pool = new Pool({ connectionString: databaseUrl, max: 5, connectionTimeoutMillis: 5000 });
// Without a listener, an error on an idle connection (e.g. Postgres restarting) would crash the process.
pool.on("error", (err) => console.error("(DB) Idle connection error", err.message));
return pool;
}
// Applies the migrations that have not run yet, each in its own transaction.
export async function runMigrations(pool: Pool, migrations: Migration[] = allMigrations): Promise<void> {
const client = await pool.connect();
try {
await client.query("SELECT pg_advisory_lock($1)", [MIGRATION_LOCK_ID]);
await client.query(`CREATE TABLE IF NOT EXISTS schema_migrations (
version text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
)`);
const { rows } = await client.query<{ version: string }>("SELECT version FROM schema_migrations");
const applied = new Set(rows.map((row) => row.version));
for (const migration of migrations) {
if (applied.has(migration.version)) continue;
try {
await client.query("BEGIN");
await client.query(migration.sql);
await client.query("INSERT INTO schema_migrations (version) VALUES ($1)", [migration.version]);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
}
console.info(`(DB) Applied migration ${migration.version}`);
}
} finally {
await client.query("SELECT pg_advisory_unlock($1)", [MIGRATION_LOCK_ID]).catch(() => undefined);
client.release();
}
}

View File

@ -1,33 +0,0 @@
import { GameClock, getClockTimes, Seat } from "@mancala/core";
// The game clock (docs/specs/game-clock). A clock is stored as each player's time at the start of the current turn
// plus when that turn started, so it only changes on moves; the time left at any moment is computed.
export function startClock(initialMs: number, now: number): GameClock {
return { initialMs, player1Ms: initialMs, player2Ms: initialMs, turnStartedAt: now };
}
export function getRemainingMs(clock: GameClock, seat: Seat, runningSeat: Seat | undefined, now: number): number {
const times = getClockTimes(clock, runningSeat, now);
return seat === "player1" ? times.player1Ms : times.player2Ms;
}
// Deducts the turn's time from the mover and starts the next turn, whether it is the opponent's or an extra turn.
export function chargeMove(clock: GameClock, moverSeat: Seat, now: number): { clock: GameClock, remainingMs: number } {
const remainingMs = getRemainingMs(clock, moverSeat, moverSeat, now);
const charged = { ...clock, turnStartedAt: now };
if (moverSeat === "player1") charged.player1Ms = remainingMs;
else charged.player2Ms = remainingMs;
return { clock: charged, remainingMs };
}
// Deducts the running seat's time, if any, and stops the clock.
export function stopClock(clock: GameClock, runningSeat: Seat | undefined, now: number): GameClock {
const times = getClockTimes(clock, runningSeat, now);
return { initialMs: clock.initialMs, ...times };
}
// Starts the running clock again from the time it had at the start of the turn, e.g. after a restart.
export function resumeClock(clock: GameClock, now: number): GameClock {
return { ...clock, turnStartedAt: now };
}

View File

@ -1,25 +0,0 @@
import { GameManager } from "./GameManager";
export const CLOCK_CHECK_INTERVAL_MS = 1_000;
// The scheduled service that ends games whose running clock has reached zero, also when nobody is connected
// (docs/specs/game-clock, R2.2). A move that arrives late is refused by GameManager itself, so how often this runs
// only decides how soon the end is announced, never who wins.
export class GameClockScheduler {
private timer?: NodeJS.Timeout;
constructor(
private readonly gameManager: Pick<GameManager, "endGamesOutOfTime">,
private readonly intervalMs = CLOCK_CHECK_INTERVAL_MS,
) { }
start() {
if (this.timer) return;
this.timer = setInterval(() => this.gameManager.endGamesOutOfTime(), this.intervalMs);
}
stop() {
if (this.timer) clearInterval(this.timer);
this.timer = undefined;
}
}

View File

@ -1,38 +0,0 @@
import fs from "fs";
import { Game } from "@mancala/core";
import { BackendGame } from "../models/Game";
const crashFolder = "./crashes";
export class GameCrashManager {
constructor() {}
static createCrashFolderIfNotExist(): void {
if (!fs.existsSync(crashFolder)) {
fs.mkdirSync(crashFolder);
}
}
static getTime(): string {
let date_ob = new Date();
let date = ("0" + date_ob.getDate()).slice(-2);
let month = ("0" + (date_ob.getMonth() + 1)).slice(-2);
let year = date_ob.getFullYear();
let hours = date_ob.getHours();
let minutes = date_ob.getMinutes();
let seconds = date_ob.getSeconds();
let miliSeconds = date_ob.getMilliseconds();
return `${year}-${month}-${date}-${hours}:${minutes}:${seconds}:${miliSeconds}`;
}
public static logGameCrash(error: Error, game: BackendGame): string {
this.createCrashFolderIfNotExist();
const crash = {
message: error.message,
stack: error.stack,
game,
};
const crashStr = JSON.stringify(crash);
const crashFileName = `${game.id}::${this.getTime()}`;
fs.writeFile(`${crashFolder}/${crashFileName}`, crashStr, () => {});
return crashFileName;
}
}

View File

@ -1,402 +0,0 @@
import { CommonMancalaGame } from "mancala.js";
import { RTMT } from "../rtmt/rtmt";
import { GameStore } from "./gamestore/GameStore";
import { generateKey } from "../util/key_factory";
import {
channel_cancel_new_game,
channel_game_move,
channel_leave_game,
channel_listen_game_events,
channel_new_game,
channel_on_game_crashed,
channel_on_game_end,
channel_on_game_start,
channel_on_game_update,
channel_on_game_user_leave,
channel_on_new_game_rejected,
channel_on_user_connection_change,
channel_unlisten_game_events,
Game,
GameEndMessage,
GameMove,
GameUsersConnectionInfo,
getSeat,
Seat,
UserConnectionInfo,
} from "@mancala/core";
import { GameCrashManager } from "./GameCrashManager";
import { MatchMaker } from "../matchmaker/MatchMaker";
import { BackendGame, PlayerInfo } from "../models/Game";
import { LiveGameStore } from "../live/LiveGameStore";
import { GameRecorder } from "./GameRecorder";
import { fromSnapshot, GameEnding, getResult, isLiveGameSnapshot, toGameRecord, toSnapshot } from "./GameSerialization";
import { chargeMove, getRemainingMs, startClock, stopClock } from "./GameClock";
import { GAME_CLOCK_MS } from "../consts/config";
// What players and spectators receive; leaves out spectator ids and other server-side state.
export type ClientGame = Game & {
startedAt: number, moveTimes: number[], spectatorCount: number, clock: NonNullable<Game["clock"]>, serverTime: number,
};
export class GameManager {
gameStore: GameStore;
rtmt: RTMT;
matchMaker: MatchMaker;
private readonly liveGameStore: LiveGameStore;
private readonly recorder: GameRecorder;
private readonly clock: () => number;
// Time each player has for a new game.
private readonly gameClockMs: number;
private readonly liveGamesListeners: (() => void)[] = [];
constructor(params: {
gameStore: GameStore,
rtmt: RTMT,
matchMaker: MatchMaker,
liveGameStore: LiveGameStore,
recorder: GameRecorder,
clock?: () => number,
gameClockMs?: number,
}) {
this.gameStore = params.gameStore;
this.rtmt = params.rtmt;
this.matchMaker = params.matchMaker;
this.liveGameStore = params.liveGameStore;
this.recorder = params.recorder;
this.clock = params.clock ?? Date.now;
this.gameClockMs = params.gameClockMs ?? GAME_CLOCK_MS;
this.initialize();
}
private initialize() {
this.listenOnPlayersPaired();
this.listenRtmtMessages();
this.listenUserConnectionChange();
}
// Called whenever a live game starts, changes or ends.
public onLiveGamesChanged(listener: () => void) {
this.liveGamesListeners.push(listener);
}
private listenRtmtMessages() {
this.rtmt.listenMessage(channel_new_game, (userKey: string, message: Object) => {
const activeGame = this.gameStore.getGameByUser(userKey);
if (activeGame) {
this.rtmt.sendMessage(userKey, channel_on_new_game_rejected, { gameId: activeGame.id });
return;
}
this.matchMaker.join(userKey);
});
this.rtmt.listenMessage(channel_cancel_new_game, (userKey: string, message: Object) => {
this.matchMaker.cancel(userKey);
});
this.rtmt.listenMessage(channel_game_move, (userKey: string, message: Object) => {
this.onGameMove(userKey, message as GameMove);
});
this.rtmt.listenMessage(channel_leave_game, (userKey: string, message: Object) => {
this.onPlayerLeave(userKey)
});
this.rtmt.listenMessage(channel_listen_game_events, (userKey: string, message: Object) => {
this.addSpectator(userKey, message as string);
});
this.rtmt.listenMessage(channel_unlisten_game_events, (userKey: string, message: Object) => {
this.removeSpectator(userKey, message as string);
});
}
private onGameMove(userKey: string, gameMove: GameMove) {
const game = this.gameStore.getGameByUser(userKey);
if (game) {
try {
const now = this.clock();
// A move that arrives after the mover's time has run out is not played.
if (userKey === game.mancalaGame.turnPlayerId && this.isOutOfTime(game, now)) {
this.endGameOnTime(game);
return;
}
const movesBefore = game.mancalaGame.history.length;
const moverSeat = getSeat(game.mancalaGame, userKey);
game.mancalaGame.moveByPlayerPit(userKey, gameMove.index);
if (game.mancalaGame.history.length > movesBefore && moverSeat) {
game.moveTimes.push(now);
const charged = chargeMove(game.clock, moverSeat, now);
game.clock = charged.clock;
game.moveRemainingMs.push(charged.remainingMs);
}
game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game);
this.sendMessageToPlayersAndSpectators(game, channel_on_game_update, this.toClientGame(game));
if (game.mancalaGame.state == "ended") {
this.endGame(game, { endReason: "completed", winnerId: game.mancalaGame.getWonPlayerId() });
} else {
this.saveLiveGame(game);
this.notifyLiveGamesChanged();
}
} catch (err: any) {
this.onGameError(game, err)
}
} else {
console.log("Game not found!");
}
}
private onGameError(game: BackendGame, error: any) {
console.error(error);
const crashFileName = GameCrashManager.logGameCrash(error, game);
console.info(`Game crash saved to file : ${crashFileName}`);
this.sendMessageToPlayersAndSpectators(game, channel_on_game_crashed, error);
}
private onPlayerLeave(userKey: string) {
const game = this.gameStore.getGameByUser(userKey);
if (game) {
this.endGame(game, { endReason: "left", winnerId: this.getOpponentId(game, userKey), leftUserId: userKey });
}
}
// Ends a live game for any reason: stops the clock, tells everyone, forgets the game and saves it.
private endGame(game: BackendGame, ending: GameEnding) {
if (this.gameStore.get(game.id) !== game) return;
this.deleteGame(game);
game.clock = stopClock(game.clock, this.getRunningSeat(game), this.clock());
const endMessage: GameEndMessage = {
gameId: game.id, result: getResult(game, ending), endReason: ending.endReason, clock: game.clock,
};
if (ending.winnerId !== undefined) endMessage.winnerId = ending.winnerId;
if (ending.leftUserId !== undefined) {
endMessage.leftUserId = ending.leftUserId;
this.sendMessageToPlayersAndSpectators(game, channel_on_game_user_leave, ending.leftUserId);
}
this.sendMessageToPlayersAndSpectators(game, channel_on_game_end, endMessage);
game.spectatorIds = [];
const record = toGameRecord(game, ending, this.clock());
this.liveGameStore.removeGame(game.id).catch((err) => this.logStoreError(err));
this.recorder.save(record);
this.notifyLiveGamesChanged();
}
// The player whose turn it is ran out of time: the opponent wins.
private endGameOnTime(game: BackendGame) {
const { turnPlayerId } = game.mancalaGame;
this.endGame(game, { endReason: "timeout", winnerId: this.getOpponentId(game, turnPlayerId) });
}
private isOutOfTime(game: BackendGame, now: number): boolean {
const runningSeat = this.getRunningSeat(game);
return runningSeat !== undefined && getRemainingMs(game.clock, runningSeat, runningSeat, now) <= 0;
}
// The seat whose clock runs; undefined once the game has ended.
private getRunningSeat(game: BackendGame): Seat | undefined {
const { mancalaGame } = game;
if (mancalaGame.state === "ended" || game.clock.turnStartedAt === undefined) return undefined;
return getSeat(mancalaGame, mancalaGame.turnPlayerId);
}
// Ends every live game whose running clock has reached zero; run by GameClockScheduler. The clock runs whether or
// not the players are online, so this also ends games that both players have left.
public endGamesOutOfTime(now = this.clock()) {
for (const game of this.gameStore.getAll()) {
try {
if (this.isOutOfTime(game, now)) this.endGameOnTime(game);
} catch (err) {
console.error(`(GameManager) Could not end game ${game.id} on time:`, err);
}
}
}
// Puts the live games saved in Valkey back into memory, e.g. after a restart or deploy.
public async restoreLiveGames(): Promise<number> {
let snapshots: unknown[];
try {
snapshots = await this.liveGameStore.loadGames();
} catch (err) {
console.error("(GameManager) Could not load live games:", (err as Error).message);
return 0;
}
let restored = 0;
for (const snapshot of snapshots) {
if (!isLiveGameSnapshot(snapshot) || this.gameStore.get(snapshot.id)) continue;
// A game that ended but whose snapshot could not be removed must not come back.
if (await this.recorder.isRecorded(snapshot.id).catch(() => false)) {
this.liveGameStore.removeGame(snapshot.id).catch((err) => this.logStoreError(err));
continue;
}
const game = fromSnapshot(snapshot, { now: this.clock(), gameClockMs: this.gameClockMs });
this.gameStore.set(game.id, game);
for (const playerId of [game.mancalaGame.player1Id, game.mancalaGame.player2Id]) {
this.gameStore.setGameByUser(playerId, game);
}
game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game);
restored++;
}
if (restored > 0) {
console.info(`(GameManager) Restored ${restored} live game(s)`);
this.notifyLiveGamesChanged();
}
return restored;
}
private addSpectator(userId: string, gameId: string) {
const game = this.gameStore.get(gameId);
if (game) {
const isUserPlayer = this.checkUserIdisPlayer(game, userId);
const isAlreadySpectator = game.spectatorIds.find((value) => value === userId);
if (!isAlreadySpectator && !isUserPlayer) {
game.spectatorIds.push(userId);
if (!game.allSpectatorIds.includes(userId)) game.allSpectatorIds.push(userId);
game.peakSpectators = Math.max(game.peakSpectators, game.spectatorIds.length);
this.saveLiveGame(game);
this.notifyLiveGamesChanged();
}
}
}
private removeSpectator(userId: string, gameId: string) {
const game = this.gameStore.get(gameId);
if (game) {
const userIdIndex = game.spectatorIds.findIndex((value) => value === userId);
if (userIdIndex >= 0) {
game.spectatorIds.splice(userIdIndex, 1);
this.notifyLiveGamesChanged();
}
}
}
private checkUserIdisPlayer(game: BackendGame, userId: string): boolean {
return game.mancalaGame.player1Id === userId || game.mancalaGame.player2Id === userId;
}
private getOpponentId(game: BackendGame, userId: string): string {
const { player1Id, player2Id } = game.mancalaGame;
return userId === player1Id ? player2Id : player1Id;
}
private sendMessageToPlayersAndSpectators(game: BackendGame, channel: string, message: Object) {
const sendMessage = (userId: string) => this.rtmt.sendMessage(userId, channel, message);
sendMessage(game.mancalaGame.player1Id);
sendMessage(game.mancalaGame.player2Id);
game.spectatorIds.forEach((spectatorId) => sendMessage(spectatorId));
}
public toClientGame(game: BackendGame): ClientGame {
return {
id: game.id,
mancalaGame: game.mancalaGame,
gameUsersConnectionInfo: game.gameUsersConnectionInfo,
startedAt: game.startedAt,
moveTimes: game.moveTimes,
spectatorCount: game.spectatorIds.length,
clock: game.clock,
serverTime: this.clock(),
};
}
private listenOnPlayersPaired() {
this.matchMaker.listenOnPlayersPaired((player1Id: string, player2Id: string) => {
const game = this.createMancalaGame(player1Id, player2Id);
this.startGame(game);
});
}
public createMancalaGame(userKey1: string, userKey2: string): BackendGame {
const mancalaGame = new CommonMancalaGame(generateKey(), userKey1, userKey2);
const random = Math.random();
mancalaGame.turnPlayerId = random > 0.5 ? userKey1 : userKey2;
const now = this.clock();
const players: Record<string, PlayerInfo> = {
[userKey1]: this.getPlayerInfo(userKey1),
[userKey2]: this.getPlayerInfo(userKey2),
};
const game: BackendGame = {
id: mancalaGame.id,
mancalaGame,
gameUsersConnectionInfo: this.getGameUsersConnectionInfoFromPlayers(players[userKey1], players[userKey2]),
spectatorIds: [],
allSpectatorIds: [],
peakSpectators: 0,
players,
startedAt: now,
moveTimes: [],
clock: startClock(this.gameClockMs, now),
moveRemainingMs: [],
};
this.gameStore.set(game.id, game);
this.gameStore.setGameByUser(userKey1, game);
this.gameStore.setGameByUser(userKey2, game);
return game;
}
private getPlayerInfo(userId: string): PlayerInfo {
const user = this.rtmt.getUser(userId);
return { userId, name: user?.name, isGuest: user?.isGuest ?? true };
}
public getGameUsersConnectionInfo(game: BackendGame): GameUsersConnectionInfo {
const { player1Id, player2Id } = game.mancalaGame;
const player = (id: string): PlayerInfo => game.players[id] ?? { userId: id, isGuest: true };
return this.getGameUsersConnectionInfoFromPlayers(player(player1Id), player(player2Id));
}
private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo {
const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo =>
({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) });
return {
user1ConnectionInfo: toConnectionInfo(player1),
user2ConnectionInfo: toConnectionInfo(player2)
};
}
public startGame(game: BackendGame) {
this.sendMessageToPlayersAndSpectators(game, channel_on_game_start, this.toClientGame(game));
this.sendUserConnectionInfo(game);
this.saveLiveGame(game);
this.notifyLiveGamesChanged();
}
public sendUserConnectionInfo(game: BackendGame) {
game.gameUsersConnectionInfo = this.getGameUsersConnectionInfo(game);
this.sendMessageToPlayersAndSpectators(game, channel_on_user_connection_change, game.gameUsersConnectionInfo);
}
public deleteGame(game: BackendGame) {
if (game) {
this.gameStore.removeGameByPlayer(game.mancalaGame.player1Id);
this.gameStore.removeGameByPlayer(game.mancalaGame.player2Id);
this.gameStore.remove(game.id);
}
}
private saveLiveGame(game: BackendGame) {
this.liveGameStore.saveGame(game.id, toSnapshot(game)).catch((err) => this.logStoreError(err));
}
private logStoreError(err: Error) {
console.error("(GameManager) Live game store:", err.message);
}
private notifyLiveGamesChanged() {
this.liveGamesListeners.forEach((listener) => listener());
}
private listenUserConnectionChange() {
this.rtmt.listenOnClientConnectionChange((clientId: string, isOnline: boolean) => {
const game = this.gameStore.getGameByUser(clientId);
if (game) this.sendUserConnectionInfo(game);
if (!isOnline) {
const speactatingGameIds = this.gameStore.getSpeactatingGameIdsByUser(clientId);
speactatingGameIds.forEach((gameId) => this.removeSpectator(clientId, gameId));
} else {
game && this.rtmt.sendMessage(clientId, channel_on_game_update, this.toClientGame(game));
}
});
}
}

View File

@ -1,82 +0,0 @@
import { GameRecord } from "@mancala/core";
import { GameHistoryRepository } from "../history/GameHistoryRepository";
import { LiveGameStore } from "../live/LiveGameStore";
const RETRY_INTERVAL_MS = 30_000;
// Saves finished games. When the database is unreachable the record is kept (in Valkey, or in memory if Valkey is
// down too) and saved later, so a game is never lost because a dependency was briefly unavailable.
export class GameRecorder {
// Being saved right now.
private readonly saving = new Map<string, GameRecord>();
// Failed to save; retried later.
private readonly pending = new Map<string, GameRecord>();
private readonly savedListeners: ((record: GameRecord) => void)[] = [];
private retryTimer?: NodeJS.Timeout;
private retrying = false;
constructor(private readonly repository: GameHistoryRepository, private readonly liveGameStore: LiveGameStore) { }
onSaved(listener: (record: GameRecord) => void) {
this.savedListeners.push(listener);
}
async save(record: GameRecord): Promise<void> {
this.saving.set(record.id, record);
try {
await this.repository.save(record);
this.saving.delete(record.id);
this.notifySaved(record);
} catch (err) {
console.error(`(GameRecorder) Could not save game ${record.id}, will retry:`, (err as Error).message);
this.pending.set(record.id, record);
this.saving.delete(record.id);
await this.liveGameStore.savePendingRecord(record).catch(() => undefined);
}
}
// Finished games that are not in the database yet, so readers can include them.
getUnsaved(): GameRecord[] {
return [...this.saving.values(), ...this.pending.values()];
}
// True if the game is saved or waiting to be saved. Throws if the database cannot be asked.
async isRecorded(gameId: string): Promise<boolean> {
return this.pending.has(gameId) || (await this.repository.get(gameId)) !== undefined;
}
async retryPending(): Promise<void> {
if (this.retrying) return;
this.retrying = true;
try {
const stored = await this.liveGameStore.loadPendingRecords().catch(() => []);
stored.forEach((record) => this.pending.set(record.id, record));
for (const record of [...this.pending.values()]) {
try {
await this.repository.save(record);
} catch (err) {
return; // still unreachable; try again later
}
this.pending.delete(record.id);
await this.liveGameStore.removePendingRecord(record.id).catch(() => undefined);
console.info(`(GameRecorder) Saved game ${record.id} after retrying`);
this.notifySaved(record);
}
} finally {
this.retrying = false;
}
}
start() {
this.retryPending();
this.retryTimer = setInterval(() => this.retryPending(), RETRY_INTERVAL_MS);
}
stop() {
if (this.retryTimer) clearInterval(this.retryTimer);
}
private notifySaved(record: GameRecord) {
this.savedListeners.forEach((listener) => listener(record));
}
}

View File

@ -1,139 +0,0 @@
import { EndReason, GameClock, GameRecord, GameResult, MoveRecord } from "@mancala/core";
import { CommonMancalaGame, GameState, GameStep } from "mancala.js";
import { BackendGame, PlayerInfo } from "../models/Game";
import { publicUserId } from "./publicIds";
import { resumeClock, startClock } from "./GameClock";
type HistoryEntry = { playerId: string, moveIndex: number, boardSnapshot: number[], gameSteps: GameStep[] };
// What is kept in Valkey for a live game; enough to rebuild it after a restart. Version 1 has no clock.
export interface LiveGameSnapshot {
version: 1 | 2;
id: string;
player1Id: string;
player2Id: string;
turnPlayerId: string;
state: GameState;
board: number[];
history: HistoryEntry[];
players: Record<string, PlayerInfo>;
startedAt: number;
moveTimes: number[];
peakSpectators: number;
allSpectatorIds: string[];
clock?: GameClock;
moveRemainingMs?: (number | null)[];
}
export interface GameEnding {
endReason: EndReason;
// Undefined when the game ends without a winner.
winnerId?: string;
leftUserId?: string;
}
export function toSnapshot(game: BackendGame): LiveGameSnapshot {
const { mancalaGame } = game;
return {
version: 2,
id: game.id,
player1Id: mancalaGame.player1Id,
player2Id: mancalaGame.player2Id,
turnPlayerId: mancalaGame.turnPlayerId,
state: mancalaGame.state,
board: mancalaGame.board.getStoneArray(),
history: mancalaGame.history as unknown as HistoryEntry[],
players: game.players,
startedAt: game.startedAt,
moveTimes: game.moveTimes,
peakSpectators: game.peakSpectators,
allSpectatorIds: game.allSpectatorIds,
clock: game.clock,
moveRemainingMs: game.moveRemainingMs,
};
}
export function isLiveGameSnapshot(value: unknown): value is LiveGameSnapshot {
const snapshot = value as LiveGameSnapshot;
if (typeof snapshot !== "object" || snapshot === null) return false;
const valid = typeof snapshot.id === "string" && Array.isArray(snapshot.board) && snapshot.board.length === 14
&& Array.isArray(snapshot.history) && typeof snapshot.players === "object";
if (snapshot.version === 1) return valid;
return snapshot.version === 2 && valid && typeof snapshot.clock?.initialMs === "number"
&& Array.isArray(snapshot.moveRemainingMs);
}
// Rebuilds a live game with real engine objects (rules, pits), so it can be played on. The running clock goes on
// from the start of the turn, so the time the backend was down is not charged; games from before the game clock
// get a new clock.
export function fromSnapshot(snapshot: LiveGameSnapshot, params: { now: number, gameClockMs: number }): BackendGame {
const mancalaGame = new CommonMancalaGame(snapshot.id, snapshot.player1Id, snapshot.player2Id);
snapshot.board.forEach((stoneCount, index) => (mancalaGame.board.pits[index].stoneCount = stoneCount));
mancalaGame.turnPlayerId = snapshot.turnPlayerId;
mancalaGame.state = snapshot.state;
mancalaGame.history.push(...snapshot.history);
const player = (id: string): PlayerInfo => snapshot.players[id] ?? { userId: id, isGuest: true };
return {
id: snapshot.id,
mancalaGame,
gameUsersConnectionInfo: {
user1ConnectionInfo: { ...player(snapshot.player1Id), isOnline: false },
user2ConnectionInfo: { ...player(snapshot.player2Id), isOnline: false },
},
spectatorIds: [],
allSpectatorIds: snapshot.allSpectatorIds,
peakSpectators: snapshot.peakSpectators,
players: snapshot.players,
startedAt: snapshot.startedAt,
moveTimes: snapshot.moveTimes,
clock: snapshot.clock ? resumeClock(snapshot.clock, params.now) : startClock(params.gameClockMs, params.now),
moveRemainingMs: snapshot.moveRemainingMs ?? snapshot.history.map(() => null),
};
}
export function getResult(game: BackendGame, ending: GameEnding): GameResult | null {
const { player1Id, player2Id } = game.mancalaGame;
if (ending.winnerId === player1Id) return "player1";
if (ending.winnerId === player2Id) return "player2";
return ending.endReason === "completed" ? "draw" : null;
}
// The record saved for a finished game; every id in it is a public id.
export function toGameRecord(game: BackendGame, ending: GameEnding, endedAt: number): GameRecord {
const { mancalaGame } = game;
const history = mancalaGame.history as unknown as HistoryEntry[];
const playerSummary = (id: string) => {
const player = game.players[id];
const summary = { userId: publicUserId(id), isGuest: player?.isGuest ?? true };
return player?.name === undefined ? summary : { ...summary, name: player.name };
};
const moves: MoveRecord[] = history.map((entry, i) => {
const move: MoveRecord = {
playerId: publicUserId(entry.playerId),
moveIndex: entry.moveIndex,
boardSnapshot: entry.boardSnapshot,
gameSteps: entry.gameSteps,
at: game.moveTimes[i] ?? endedAt,
};
const remainingMs = game.moveRemainingMs[i];
if (typeof remainingMs === "number") move.remainingMs = remainingMs;
return move;
});
const record: GameRecord = {
id: game.id,
player1: playerSummary(mancalaGame.player1Id),
player2: playerSummary(mancalaGame.player2Id),
board: mancalaGame.board.getStoneArray(),
result: getResult(game, ending),
endReason: ending.endReason,
startedAt: game.startedAt,
endedAt,
moveCount: moves.length,
peakSpectators: game.peakSpectators,
totalSpectators: game.allSpectatorIds.length,
moves,
clockInitialMs: game.clock.initialMs,
};
if (ending.leftUserId !== undefined) record.leftUserId = publicUserId(ending.leftUserId);
return record;
}

View File

@ -1,89 +0,0 @@
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

@ -1,19 +0,0 @@
import { Game } from "@mancala/core";
import { BackendGame } from "../../models/Game";
export interface GameStore {
get(id: string): BackendGame | undefined;
getAll(): BackendGame[];
set(id: string, game: BackendGame): void;
remove(id: string): void;
setGameIdByUser(userId: string, gameId: string): void;
getGameIdByUser(userId: string): string | undefined;
removeGameIdByPlayer(userId: string): void;
setGameByUser(userId: string, game: BackendGame): void;
getGameByUser(userId: string): BackendGame | undefined;
removeGameByPlayer(userId: string): void;
getSpeactatingGameIdsByUser(userId: string): string[]
}

View File

@ -1,50 +0,0 @@
import { GameStore } from "./GameStore";
import { BackendGame } from "../../models/Game";
export class GameStoreImpl implements GameStore {
gameStore: Map<string, BackendGame> = new Map<string, BackendGame>()
userGameMap: Map<string, string> = new Map<string, string>()
get(id: string): BackendGame | undefined {
return this.gameStore.get(id);
}
getAll(): BackendGame[] {
return [...this.gameStore.values()];
}
set(id: string, game: BackendGame): void {
this.gameStore.set(id, game);
}
remove(id: string): void {
this.gameStore.delete(id);
}
setGameIdByUser(userId: string, gameId: string): void {
this.userGameMap.set(userId, gameId)
}
getGameIdByUser(userId: string): string | undefined {
return this.userGameMap.get(userId);
}
removeGameIdByPlayer(userId: string): void {
this.userGameMap.delete(userId);
}
setGameByUser(userId: string, game: BackendGame): void {
this.setGameIdByUser(userId, game.id);
}
getGameByUser(userId: string): BackendGame | undefined {
const gameId = this.getGameIdByUser(userId);
return gameId && this.gameStore.get(gameId) || undefined;
}
removeGameByPlayer(userId: string): void {
this.removeGameIdByPlayer(userId);
}
getSpeactatingGameIdsByUser(userId: string): string[] {
const speactatingGameIds = [];
for (const game of this.gameStore.values()) {
const isSpectator = game.spectatorIds.find((value) => value === userId);
isSpectator && speactatingGameIds.push(game.id);
}
return speactatingGameIds;
}
}

View File

@ -1,11 +0,0 @@
import { createHash } from "crypto";
import { KEYCLOAK_USER_ID_PREFIX } from "../auth";
const LEGACY_PUBLIC_ID_PREFIX = "legacy:";
// Ids of legacy userKey users are the secret they log in with, so anything saved or listed publicly uses a hash
// instead. Keycloak ids are not secret and stay as they are.
export function publicUserId(userId: string): string {
if (userId.startsWith(KEYCLOAK_USER_ID_PREFIX) || userId.startsWith(LEGACY_PUBLIC_ID_PREFIX)) return userId;
return LEGACY_PUBLIC_ID_PREFIX + createHash("sha256").update(userId).digest("hex").slice(0, 20);
}

View File

@ -1,115 +0,0 @@
import { FinishedGameSummary, GameRecord, MoveRecord, PlayerSummary } from "@mancala/core";
import { Pool, PoolClient } from "pg";
// Saved, finished games. Ids in records are public ids (see publicIds.ts).
export interface GameHistoryRepository {
// Saving the same game twice is a no-op, so failed saves can be retried.
save(record: GameRecord): Promise<void>;
get(id: string): Promise<GameRecord | undefined>;
// The user's finished games, most recent first.
getRecentForUser(userId: string, limit: number): Promise<FinishedGameSummary[]>;
// Finished games with the most spectators, most recent first among equals.
getPopular(limit: number): Promise<FinishedGameSummary[]>;
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SUMMARY_COLUMNS = `g.id, g.result, g.end_reason, g.left_user_id, g.started_at, g.ended_at, g.move_count,
g.final_board, g.peak_spectators, g.clock_initial_ms,
p1.user_id AS p1_user_id, p1.name AS p1_name, p1.is_guest AS p1_is_guest,
p2.user_id AS p2_user_id, p2.name AS p2_name, p2.is_guest AS p2_is_guest`;
const FROM_GAMES = `FROM games g
JOIN game_players p1 ON p1.game_id = g.id AND p1.seat = 1
JOIN game_players p2 ON p2.game_id = g.id AND p2.seat = 2`;
type SummaryRow = {
id: string; result: FinishedGameSummary["result"]; end_reason: FinishedGameSummary["endReason"];
left_user_id: string | null; started_at: Date; ended_at: Date; move_count: number; final_board: number[];
peak_spectators: number; clock_initial_ms: number | null;
p1_user_id: string; p1_name: string | null; p1_is_guest: boolean;
p2_user_id: string; p2_name: string | null; p2_is_guest: boolean;
};
const toPlayer = (userId: string, name: string | null, isGuest: boolean): PlayerSummary =>
name === null ? { userId, isGuest } : { userId, name, isGuest };
function toSummary(row: SummaryRow): FinishedGameSummary {
const summary: FinishedGameSummary = {
id: row.id,
player1: toPlayer(row.p1_user_id, row.p1_name, row.p1_is_guest),
player2: toPlayer(row.p2_user_id, row.p2_name, row.p2_is_guest),
board: row.final_board,
result: row.result,
endReason: row.end_reason,
startedAt: row.started_at.getTime(),
endedAt: row.ended_at.getTime(),
moveCount: row.move_count,
peakSpectators: row.peak_spectators,
};
if (row.left_user_id !== null) summary.leftUserId = row.left_user_id;
if (row.clock_initial_ms !== null) summary.clockInitialMs = row.clock_initial_ms;
return summary;
}
export class PostgresGameHistoryRepository implements GameHistoryRepository {
constructor(private readonly pool: Pool) { }
async save(record: GameRecord): Promise<void> {
await this.inTransaction(async (client) => {
const inserted = await client.query(
`INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at,
move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms)
VALUES ($1, $2, $3, $4, $5, $6, to_timestamp($7 / 1000.0), to_timestamp($8 / 1000.0),
$9, $10, $11, $12, $13, $14)
ON CONFLICT (id) DO NOTHING`,
[record.id, record.player1.userId, record.player2.userId, record.result, record.endReason,
record.leftUserId ?? null, record.startedAt, record.endedAt, record.moveCount,
JSON.stringify(record.board), JSON.stringify(record.moves), record.peakSpectators,
record.totalSpectators, record.clockInitialMs ?? null]);
if (inserted.rowCount === 0) return;
for (const [seat, player] of [[1, record.player1], [2, record.player2]] as const) {
await client.query(
"INSERT INTO game_players (game_id, seat, user_id, name, is_guest) VALUES ($1, $2, $3, $4, $5)",
[record.id, seat, player.userId, player.name ?? null, player.isGuest]);
}
});
}
async get(id: string): Promise<GameRecord | undefined> {
if (!UUID_PATTERN.test(id)) return undefined;
const { rows } = await this.pool.query<SummaryRow & { moves: MoveRecord[], total_spectators: number }>(
`SELECT ${SUMMARY_COLUMNS}, g.moves, g.total_spectators ${FROM_GAMES} WHERE g.id = $1`, [id]);
if (rows.length === 0) return undefined;
return { ...toSummary(rows[0]), moves: rows[0].moves, totalSpectators: rows[0].total_spectators };
}
async getRecentForUser(userId: string, limit: number): Promise<FinishedGameSummary[]> {
const { rows } = await this.pool.query<SummaryRow>(
`SELECT ${SUMMARY_COLUMNS} ${FROM_GAMES}
WHERE g.id IN (SELECT game_id FROM game_players WHERE user_id = $1)
ORDER BY g.ended_at DESC LIMIT $2`, [userId, limit]);
return rows.map(toSummary);
}
async getPopular(limit: number): Promise<FinishedGameSummary[]> {
const { rows } = await this.pool.query<SummaryRow>(
`SELECT ${SUMMARY_COLUMNS} ${FROM_GAMES} ORDER BY g.peak_spectators DESC, g.ended_at DESC LIMIT $1`,
[limit]);
return rows.map(toSummary);
}
private async inTransaction(work: (client: PoolClient) => Promise<void>): Promise<void> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
await work(client);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK").catch(() => undefined);
throw err;
} finally {
client.release();
}
}
}

View File

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

View File

@ -1,86 +0,0 @@
import { RTMTWS } from "./rtmt/rtmt_websocket";
import { GameManager } from "./game/GameManager";
import { GameStoreImpl } from "./game/gamestore/GameStoreImpl";
import { MatchMakerImpl } from "./matchmaker/MatchMakerImpl";
import { ExpressApp } from "./server/ExpressApp";
import { WebServer } from "./server/WebServer";
import { KeycloakTokenVerifier, readAuthConfig } from "./auth";
import { createPool, runMigrations } from "./db/postgres";
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";
import { GameClockScheduler } from "./game/GameClockScheduler";
const DATABASE_URL = process.env.DATABASE_URL || "postgres://mancala:mancala@localhost:5433/mancala";
const VALKEY_URL = process.env.VALKEY_URL || "redis://localhost:6379";
const MIGRATION_RETRY_MS = 30_000;
const VALKEY_STARTUP_WAIT_MS = 5_000;
async function main() {
const authConfig = readAuthConfig();
const tokenVerifier = new KeycloakTokenVerifier({
issuer: authConfig.keycloakIssuer,
jwksUrl: authConfig.keycloakJwksUrl,
allowedClientIds: authConfig.keycloakClientIds,
});
const pool = createPool(DATABASE_URL);
migrateWhenReachable(pool);
const valkey = createValkeyClient(VALKEY_URL);
await waitForReady(valkey, VALKEY_STARTUP_WAIT_MS);
const liveGameStore = new ValkeyLiveGameStore(valkey);
const repository = new PostgresGameHistoryRepository(pool);
const recorder = new GameRecorder(repository, liveGameStore);
const history = new GameHistoryService(repository, new ValkeyCache(valkey), () => recorder.getUnsaved());
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();
new GameClockScheduler(gameManager).start();
const expressApp = new ExpressApp({
gameStore, gameManager, liveGames, history, tokenVerifier, legacyUserKeyAuth: authConfig.legacyUserKeyAuth,
});
const server = new WebServer({ expressApp: expressApp.app });
rtmt.initWebSocket(server.server);
}
// The game runs without the database; saved games wait in the recorder until migrations have run.
function migrateWhenReachable(pool: ReturnType<typeof createPool>) {
runMigrations(pool).catch((err) => {
console.error(`(DB) Migrations failed, retrying in ${MIGRATION_RETRY_MS / 1000}s:`, err.message);
setTimeout(() => migrateWhenReachable(pool), MIGRATION_RETRY_MS);
});
}
function waitForReady(client: ReturnType<typeof createValkeyClient>, timeoutMs: number): Promise<void> {
if (client.status === "ready") return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(() => {
console.error("(Valkey) Not reachable yet; starting without restoring live games");
resolve();
}, timeoutMs);
client.once("ready", () => {
clearTimeout(timer);
resolve();
});
});
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -1,61 +0,0 @@
import { GameRecord } from "@mancala/core";
import { Valkey } from "iovalkey";
import { scanKeys } from "./valkey";
const LIVE_PREFIX = "mancala:live:";
const PENDING_SAVE_PREFIX = "mancala:pending-save:";
// Durable copy of state that otherwise only lives in memory: live games, so they survive a restart, and finished
// games that could not be saved to the database yet. Methods reject when Valkey is unreachable.
export interface LiveGameStore {
saveGame(gameId: string, snapshot: unknown): Promise<void>;
removeGame(gameId: string): Promise<void>;
loadGames(): Promise<unknown[]>;
savePendingRecord(record: GameRecord): Promise<void>;
removePendingRecord(gameId: string): Promise<void>;
loadPendingRecords(): Promise<GameRecord[]>;
}
export class ValkeyLiveGameStore implements LiveGameStore {
constructor(private readonly client: Valkey) { }
async saveGame(gameId: string, snapshot: unknown): Promise<void> {
await this.client.set(LIVE_PREFIX + gameId, JSON.stringify(snapshot));
}
async removeGame(gameId: string): Promise<void> {
await this.client.del(LIVE_PREFIX + gameId);
}
loadGames(): Promise<unknown[]> {
return this.loadAll(LIVE_PREFIX);
}
async savePendingRecord(record: GameRecord): Promise<void> {
await this.client.set(PENDING_SAVE_PREFIX + record.id, JSON.stringify(record));
}
async removePendingRecord(gameId: string): Promise<void> {
await this.client.del(PENDING_SAVE_PREFIX + gameId);
}
loadPendingRecords(): Promise<GameRecord[]> {
return this.loadAll(PENDING_SAVE_PREFIX) as Promise<GameRecord[]>;
}
private async loadAll(prefix: string): Promise<unknown[]> {
const keys = await scanKeys(this.client, prefix + "*");
if (keys.length === 0) return [];
const values = await this.client.mget(keys);
const parsed: unknown[] = [];
values.forEach((value, i) => {
if (value === null) return;
try {
parsed.push(JSON.parse(value));
} catch {
console.error(`(Valkey) Ignoring unreadable value in ${keys[i]}`);
}
});
return parsed;
}
}

View File

@ -1,33 +0,0 @@
import { RedisOptions, Valkey } from "iovalkey";
export function createValkeyClient(url: string, options: RedisOptions = {}): Valkey {
const client = new Valkey(url, {
// Fail commands at once while Valkey is down instead of queueing them; callers fall back.
enableOfflineQueue: false,
maxRetriesPerRequest: 1,
...options,
});
// Without a listener, connection errors would be unhandled 'error' events and crash the process.
let lastError = "";
client.on("error", (err: Error) => {
if (err.message !== lastError) console.error("(Valkey) " + err.message);
lastError = err.message;
});
client.on("ready", () => {
lastError = "";
console.info("(Valkey) Connected");
});
return client;
}
// Keys matching the pattern, without blocking Valkey the way KEYS would.
export async function scanKeys(client: Valkey, pattern: string): Promise<string[]> {
const keys: string[] = [];
let cursor = "0";
do {
const [next, batch] = await client.scan(cursor, "MATCH", pattern, "COUNT", 100);
keys.push(...batch);
cursor = next;
} while (cursor !== "0");
return keys;
}

Some files were not shown because too many files have changed in this diff Show More