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:
parent
0c112eb716
commit
6aeff37eb8
@ -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
|
||||
|
||||
9
apps/backend-quarkus/.gitignore
vendored
9
apps/backend-quarkus/.gitignore
vendored
@ -1,9 +0,0 @@
|
||||
target/
|
||||
.idea
|
||||
*.iml
|
||||
.vscode
|
||||
.factorypath
|
||||
|
||||
# Written at runtime
|
||||
access*.log
|
||||
crashes/
|
||||
43
apps/backend/.gitignore
vendored
43
apps/backend/.gitignore
vendored
@ -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/
|
||||
|
||||
25
apps/backend/.vscode/launch.json
vendored
25
apps/backend/.vscode/launch.json
vendored
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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"]
|
||||
|
||||
@ -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.
|
||||
@ -1,4 +0,0 @@
|
||||
rm -rf node_modules
|
||||
yarn
|
||||
yarn build
|
||||
yarn start
|
||||
@ -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'],
|
||||
};
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
};
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export type { AuthUser } from "@mancala/core";
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
export * from "./AuthUser";
|
||||
export * from "./AuthConfig";
|
||||
export * from "./TokenVerifier";
|
||||
export * from "./KeycloakTokenVerifier";
|
||||
40
apps/backend/src/cache/Cache.ts
vendored
40
apps/backend/src/cache/Cache.ts
vendored
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
`;
|
||||
@ -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;
|
||||
`;
|
||||
@ -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 },
|
||||
];
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 }));
|
||||
}
|
||||
}
|
||||
@ -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[]
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user