commit 9367f285bab613dee064cfbffaeab5792e72b9ad
Author: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Sun Feb 15 21:47:50 2026 +0100
init
diff --git a/.env.example b/.env.example
new file mode 100644
index 000000000..ed45e8906
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,10 @@
+PORT=3100
+TARGET_BASE_URL=https://openfront.io
+# Optional override:
+# TARGET_WS_URL=wss://openfront.io/lobbies
+ARCHIVE_API_BASE=https://api.openfront.io
+DB_PATH=data/db.json
+NUM_WORKERS=20
+GAME_INFO_POLL_MS=5000
+CLOSURE_PROBE_ATTEMPTS=20
+CLOSURE_PROBE_INTERVAL_MS=3000
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..91c93a088
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+static/
+dist/
+.env
+.env.local
+*.log
+data/db.json
+data/db.backup.json
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..1e4ebf611
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,27 @@
+FROM node:24-slim AS base
+WORKDIR /usr/src/app
+
+FROM base AS build
+ENV HUSKY=0
+COPY package*.json ./
+RUN --mount=type=cache,target=/root/.npm npm ci
+COPY tsconfig.json ./
+COPY vite.config.ts ./
+COPY index.html ./
+COPY src ./src
+RUN npm run build-prod
+
+FROM base AS prod-deps
+ENV HUSKY=0
+COPY package*.json ./
+RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --ignore-scripts
+
+FROM base
+COPY --from=prod-deps /usr/src/app/node_modules ./node_modules
+COPY --from=build /usr/src/app/static ./static
+COPY package*.json ./
+COPY src ./src
+COPY data ./data
+ENV PORT=3100
+EXPOSE 3100
+CMD ["node", "node_modules/tsx/dist/cli.mjs", "src/ingest/server.ts"]
diff --git a/README.md b/README.md
new file mode 100644
index 000000000..779fb4dea
--- /dev/null
+++ b/README.md
@@ -0,0 +1,79 @@
+# Lobby Statistics
+
+Standalone ingest + analytics tool for OpenFront public lobbies.
+
+## What it tracks
+
+- Lobby open and close times from `/lobbies`.
+- Observed join/leave deltas and join rate over time.
+- Peak fill, full-duration moments, and churn proxies.
+- Start detection after lobby disappears (via `/api/game/:id/exists` + `/api/game/:id`).
+- Optional archive enrichment from `${ARCHIVE_API_BASE}/game/:id`.
+- Started games are re-polled every 10 minutes until marked completed.
+- On startup, historical records already marked `started` are immediately reconciled.
+- Replay/archive records backfill `actualStartAt` and `actualEndAt` when available.
+- Bucketed analytics for:
+ - game mode
+ - game mode + team setup
+ - map
+ - map size + mode
+ - modifiers
+
+## Important data caveat
+
+The public APIs do not expose explicit "failed join attempts" (for example, full-lobby rejections).
+This tool therefore tracks:
+
+- observed joins from lobby population deltas,
+- unique observed client IDs from `/api/game/:id` polls,
+- churn and full-lobby pressure proxies.
+
+## Local NoSQL storage
+
+Document file:
+
+- `data/db.json`
+
+The ingest process writes lobby documents and lifecycle metrics continuously.
+
+Production API notes:
+
+- Lobby websocket stream is `wss://openfront.io/lobbies`.
+- Production messages use `type: "lobbies_update"` with `data.lobbies[]`.
+- Worker websocket paths (`/wX/lobbies`) may connect but can be silent.
+
+## Scripts
+
+- `npm run dev`: Vite UI + ingest server in parallel.
+- `npm run start:server`: ingest server only.
+- `npm run build-prod`: typecheck + build frontend into `static/`.
+
+## Environment
+
+Optional env vars:
+
+- `PORT` (default `3100`)
+- `TARGET_BASE_URL` (default `https://openfront.io`)
+- `TARGET_WS_URL` (default `wss://openfront.io/lobbies`)
+- `ARCHIVE_API_BASE` (default `https://api.openfront.io`)
+- `DB_PATH` (default `data/db.json`)
+- `NUM_WORKERS` (default `20`)
+- `GAME_INFO_POLL_MS` (default `5000`)
+- `CLOSURE_PROBE_ATTEMPTS` (default `20`)
+- `CLOSURE_PROBE_INTERVAL_MS` (default `3000`)
+
+## Run
+
+```bash
+npm install
+npm run dev
+```
+
+UI:
+
+- Vite dev UI: `http://localhost:9100`
+- Ingest/API: `http://localhost:3100`
+
+## Deployment note
+
+This project mirrors the existing OpenFront tooling style (TypeScript + Vite + Node/Express + tsx + concurrently/cross-env).
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 000000000..8b1378917
--- /dev/null
+++ b/data/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/index.html b/index.html
new file mode 100644
index 000000000..d25a32f49
--- /dev/null
+++ b/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Lobby Statistics
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 000000000..4ed0c2a46
--- /dev/null
+++ b/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "lobbystatistics",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build-dev": "concurrently \"tsc --noEmit\" \"vite build --mode development\"",
+ "build-prod": "concurrently --kill-others-on-fail \"tsc --noEmit\" \"vite build\"",
+ "start:client": "vite",
+ "start:server": "tsx src/ingest/server.ts",
+ "start:server-dev": "cross-env NODE_ENV=development tsx src/ingest/server.ts",
+ "dev": "cross-env NODE_ENV=development concurrently --restart-tries 999 --restart-after 2000 \"npm run start:client\" \"npm run start:server-dev\"",
+ "test:types": "tsc --noEmit",
+ "probe:prod": "node scripts/probe-production-api.mjs"
+ },
+ "dependencies": {
+ "express": "^4.22.1",
+ "ws": "^8.18.0",
+ "zod": "^4.0.5"
+ },
+ "devDependencies": {
+ "@types/express": "^4.17.23",
+ "@types/node": "^22.10.2",
+ "@types/ws": "^8.5.11",
+ "concurrently": "^8.2.2",
+ "cross-env": "^7.0.3",
+ "tsx": "^4.17.0",
+ "typescript": "^5.7.2",
+ "vite": "^7.3.0"
+ }
+}
diff --git a/scripts/probe-production-api.mjs b/scripts/probe-production-api.mjs
new file mode 100644
index 000000000..5455db253
--- /dev/null
+++ b/scripts/probe-production-api.mjs
@@ -0,0 +1,245 @@
+import WebSocket from "ws";
+
+const BASE_URL = process.env.TARGET_BASE_URL || "https://openfront.io";
+const ARCHIVE_API_BASE = process.env.ARCHIVE_API_BASE || "https://api.openfront.io";
+const NUM_WORKERS = Number(process.env.NUM_WORKERS || "20");
+const WS_WAIT_MS = Number(process.env.WS_WAIT_MS || "6000");
+const CONNECT_TIMEOUT_MS = Number(process.env.CONNECT_TIMEOUT_MS || "5000");
+
+const trim = (v) => v.replace(/\/+$/, "");
+const base = trim(BASE_URL);
+const archiveBase = trim(ARCHIVE_API_BASE);
+
+const workerIndexForGame = (gameID, workers) => {
+ let hash = 0;
+ for (let i = 0; i < gameID.length; i++) {
+ const char = gameID.charCodeAt(i);
+ hash = (hash << 5) - hash + char;
+ hash |= 0;
+ }
+ return Math.abs(hash) % Math.max(1, workers);
+};
+
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+const fetchJson = async (url, timeoutMs = 6000) => {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const response = await fetch(url, {
+ signal: controller.signal,
+ headers: { Accept: "application/json" },
+ });
+ const contentType = response.headers.get("content-type") || "";
+ const text = await response.text();
+ let json = null;
+ if (contentType.includes("application/json")) {
+ try {
+ json = JSON.parse(text);
+ } catch {
+ json = null;
+ }
+ }
+ return {
+ ok: response.ok,
+ status: response.status,
+ contentType,
+ json,
+ textSample: text.slice(0, 240),
+ };
+ } catch (error) {
+ return {
+ ok: false,
+ status: 0,
+ contentType: "",
+ json: null,
+ textSample: "",
+ error: String(error),
+ };
+ } finally {
+ clearTimeout(timeout);
+ }
+};
+
+const wsProbe = async (url, waitMs = WS_WAIT_MS) => {
+ return new Promise((resolve) => {
+ const result = {
+ url,
+ opened: false,
+ openAt: null,
+ closeAt: null,
+ closeCode: null,
+ closeReason: "",
+ error: null,
+ messageCount: 0,
+ firstMessageSample: "",
+ firstMessageRaw: "",
+ firstMessageType: "",
+ firstMessageGames: null,
+ firstMessageLobbyCount: null,
+ parseError: null,
+ };
+
+ let settled = false;
+ let ws = null;
+
+ const finish = () => {
+ if (settled) return;
+ settled = true;
+ resolve(result);
+ };
+
+ const connectTimeout = setTimeout(() => {
+ result.error = "connect-timeout";
+ try {
+ ws?.terminate();
+ } catch {}
+ finish();
+ }, CONNECT_TIMEOUT_MS);
+
+ try {
+ ws = new WebSocket(url);
+ } catch (error) {
+ clearTimeout(connectTimeout);
+ result.error = String(error);
+ finish();
+ return;
+ }
+
+ ws.on("open", () => {
+ clearTimeout(connectTimeout);
+ result.opened = true;
+ result.openAt = Date.now();
+ setTimeout(() => {
+ try {
+ ws.close(1000, "probe-done");
+ } catch {}
+ }, waitMs);
+ });
+
+ ws.on("message", (raw) => {
+ result.messageCount += 1;
+ if (!result.firstMessageSample) {
+ const text = typeof raw === "string" ? raw : raw.toString("utf-8");
+ result.firstMessageRaw = text;
+ result.firstMessageSample = text.slice(0, 280);
+ try {
+ const parsed = JSON.parse(text);
+ result.firstMessageType = typeof parsed?.type === "string" ? parsed.type : "json";
+ if (Array.isArray(parsed?.games)) {
+ result.firstMessageGames = parsed.games.length;
+ }
+ if (Array.isArray(parsed?.data?.lobbies)) {
+ result.firstMessageLobbyCount = parsed.data.lobbies.length;
+ }
+ } catch (error) {
+ result.parseError = String(error);
+ }
+ }
+ });
+
+ ws.on("close", (code, reason) => {
+ result.closeAt = Date.now();
+ result.closeCode = code;
+ result.closeReason = reason ? reason.toString("utf-8") : "";
+ finish();
+ });
+
+ ws.on("error", (error) => {
+ result.error = String(error);
+ });
+ });
+};
+
+const printHeader = (title) => {
+ console.log(`\n=== ${title} ===`);
+};
+
+const wsUrlForPath = (pathPart) =>
+ `${base.replace(/^http/i, "ws")}${pathPart.startsWith("/") ? "" : "/"}${pathPart}`;
+
+async function main() {
+ console.log("Probe config:", {
+ BASE_URL: base,
+ ARCHIVE_API_BASE: archiveBase,
+ NUM_WORKERS,
+ WS_WAIT_MS,
+ });
+
+ printHeader("HTTP env check");
+ const envRes = await fetchJson(`${base}/api/env`);
+ console.log(envRes);
+
+ printHeader("HTTP lobbies path check");
+ const rootLobbiesHttp = await fetchJson(`${base}/lobbies`);
+ const workerLobbiesHttp = await fetchJson(`${base}/w0/lobbies`);
+ console.log({ rootLobbiesHttp, workerLobbiesHttp });
+
+ printHeader("WS probes");
+ const wsTargets = ["/lobbies", ...Array.from({ length: NUM_WORKERS }, (_, i) => `/w${i}/lobbies`)];
+ const wsResults = [];
+ for (const target of wsTargets) {
+ const url = wsUrlForPath(target);
+ const result = await wsProbe(url);
+ wsResults.push(result);
+ console.log({
+ target,
+ opened: result.opened,
+ messageCount: result.messageCount,
+ closeCode: result.closeCode,
+ error: result.error,
+ firstMessageGames: result.firstMessageGames,
+ firstMessageLobbyCount: result.firstMessageLobbyCount,
+ firstMessageSample: result.firstMessageSample,
+ });
+ }
+
+ const withLobbies = wsResults.find(
+ (result) =>
+ result.firstMessageGames !== null || result.firstMessageLobbyCount !== null,
+ );
+ if (!withLobbies) {
+ printHeader("No games payload found on WS");
+ console.log(
+ "No websocket endpoint returned a payload with lobby arrays during probe window.",
+ );
+ return;
+ }
+
+ printHeader("Sample game follow-up");
+ const parsed = JSON.parse(withLobbies.firstMessageRaw || withLobbies.firstMessageSample);
+ const game = parsed.games?.[0] ?? parsed.data?.lobbies?.[0];
+ if (!game?.gameID) {
+ console.log("No sample game in first games payload.");
+ return;
+ }
+
+ const gameID = game.gameID;
+ const workerIndex = workerIndexForGame(gameID, NUM_WORKERS);
+ const workerPath = `w${workerIndex}`;
+ const paths = [
+ `${base}/${workerPath}/api/game/${gameID}`,
+ `${base}/${workerPath}/api/game/${gameID}/exists`,
+ `${base}/api/game/${gameID}`,
+ `${base}/api/game/${gameID}/exists`,
+ `${archiveBase}/game/${gameID}`,
+ ];
+
+ for (const url of paths) {
+ const result = await fetchJson(url);
+ console.log(url, {
+ status: result.status,
+ ok: result.ok,
+ contentType: result.contentType,
+ hasJson: result.json !== null,
+ textSample: result.textSample,
+ });
+ }
+
+ printHeader("Probe complete");
+}
+
+main().catch((error) => {
+ console.error("Probe failed", error);
+ process.exit(1);
+});
diff --git a/src/ingest/analytics.ts b/src/ingest/analytics.ts
new file mode 100644
index 000000000..e570b2280
--- /dev/null
+++ b/src/ingest/analytics.ts
@@ -0,0 +1,195 @@
+import {
+ AnalyticsPayload,
+ BucketMode,
+ BucketStat,
+ LobbyRecord,
+ TimelineBucket,
+ bucketForConfig,
+ joinRatePerMinute,
+ peakFillRatio,
+ safeMaxPlayers,
+} from "../shared/types";
+
+const clampLookback = (hours: number): number => {
+ if (!Number.isFinite(hours)) return 24;
+ return Math.max(1, Math.min(24 * 30, Math.floor(hours)));
+};
+
+export function buildAnalytics(
+ allLobbies: LobbyRecord[],
+ bucketMode: BucketMode,
+ lookbackHoursRaw: number,
+): AnalyticsPayload {
+ const now = Date.now();
+ const lookbackHours = clampLookback(lookbackHoursRaw);
+ const since = now - lookbackHours * 60 * 60 * 1000;
+
+ const lobbies = allLobbies
+ .filter((lobby) => lobby.firstSeenAt >= since)
+ .sort((a, b) => a.firstSeenAt - b.firstSeenAt);
+
+ const started = lobbies.filter((l) => l.status === "started");
+ const completed = lobbies.filter((l) => l.status === "completed");
+ const notStarted = lobbies.filter((l) => l.status === "did_not_start");
+ const unknown = lobbies.filter((l) => l.status === "unknown");
+ const active = lobbies.filter((l) => l.status === "active");
+
+ const avgOpenSec =
+ average(
+ lobbies
+ .map((lobby) => lobby.openDurationMs)
+ .filter((value): value is number => value !== undefined),
+ ) / 1000;
+ const avgJoinRatePerMin = average(lobbies.map((lobby) => joinRatePerMinute(lobby)));
+ const avgPeakFillPct = average(lobbies.map((lobby) => peakFillRatio(lobby))) * 100;
+ const startedOrCompleted = [...started, ...completed];
+ const underfilledStarted = startedOrCompleted.filter((lobby) => {
+ if (lobby.playersAtStart === undefined || !lobby.maxPlayers) return false;
+ return lobby.playersAtStart < lobby.maxPlayers;
+ }).length;
+
+ const bucketMap = new Map();
+ for (const lobby of lobbies) {
+ const bucket = bucketForConfig(lobby.gameConfig, bucketMode);
+ if (!bucketMap.has(bucket)) bucketMap.set(bucket, []);
+ bucketMap.get(bucket)!.push(lobby);
+ }
+ const buckets: BucketStat[] = Array.from(bucketMap.entries())
+ .map(([bucket, entries]) => {
+ const startedCount = entries.filter((entry) => entry.status === "started").length;
+ const completedCount = entries.filter(
+ (entry) => entry.status === "completed",
+ ).length;
+ const notStartedCount = entries.filter(
+ (entry) => entry.status === "did_not_start",
+ ).length;
+ const avgPlayersAtStart = average(
+ entries
+ .map((entry) => entry.playersAtStart)
+ .filter((value): value is number => value !== undefined),
+ );
+ const avgFillAtClose = average(
+ entries.map((entry) => entry.lastObservedClients / safeMaxPlayers(entry)),
+ );
+ const avgOpen = average(
+ entries
+ .map((entry) => entry.openDurationMs)
+ .filter((value): value is number => value !== undefined),
+ );
+ return {
+ bucket,
+ count: entries.length,
+ inProgress: startedCount,
+ completed: completedCount,
+ started: startedCount,
+ notStarted: notStartedCount,
+ avgOpenSec: avgOpen / 1000,
+ avgJoinRatePerMin: average(entries.map((entry) => joinRatePerMinute(entry))),
+ avgFillAtClose,
+ avgPlayersAtStart,
+ };
+ })
+ .sort((a, b) => b.count - a.count);
+
+ const timeline = buildTimeline(lobbies);
+
+ const order = lobbies
+ .map((lobby) => ({
+ gameID: lobby.gameID,
+ bucket: bucketForConfig(lobby.gameConfig, bucketMode),
+ openedAt: lobby.openedAt,
+ closedAt: lobby.closedAt,
+ startDetectedAt: lobby.startDetectedAt,
+ actualStartAt: lobby.actualStartAt,
+ actualEndAt: lobby.actualEndAt,
+ archiveDurationSec: lobby.archiveDurationSec,
+ scheduledStartAt: lobby.scheduledStartAt,
+ peakClients: lobby.peakClients,
+ maxPlayers: lobby.maxPlayers,
+ status: lobby.status,
+ openDurationMs: lobby.openDurationMs,
+ joinRatePerMin: joinRatePerMinute(lobby),
+ }))
+ .sort((a, b) => a.openedAt - b.openedAt);
+
+ const neverStarted = notStarted
+ .slice()
+ .sort((a, b) => (b.openDurationMs ?? 0) - (a.openDurationMs ?? 0))
+ .slice(0, 20);
+ const lowFillStarted = startedOrCompleted
+ .filter((lobby) => {
+ if (lobby.playersAtStart === undefined || !lobby.maxPlayers) return false;
+ return lobby.playersAtStart / lobby.maxPlayers < 0.7;
+ })
+ .sort((a, b) => {
+ const aFill = (a.playersAtStart ?? 0) / Math.max(1, a.maxPlayers ?? 1);
+ const bFill = (b.playersAtStart ?? 0) / Math.max(1, b.maxPlayers ?? 1);
+ return aFill - bFill;
+ })
+ .slice(0, 20);
+ const highChurn = lobbies
+ .filter((lobby) => lobby.observedLeaveEvents > 0)
+ .sort((a, b) => {
+ const aChurn = a.observedJoinEvents + a.observedLeaveEvents;
+ const bChurn = b.observedJoinEvents + b.observedLeaveEvents;
+ return bChurn - aChurn;
+ })
+ .slice(0, 20);
+
+ return {
+ now,
+ summary: {
+ total: lobbies.length,
+ active: active.length,
+ inProgress: started.length,
+ completed: completed.length,
+ started: started.length,
+ notStarted: notStarted.length,
+ unknown: unknown.length,
+ underfilledStarted,
+ avgOpenSec,
+ avgJoinRatePerMin,
+ avgPeakFillPct,
+ },
+ buckets,
+ timeline,
+ order,
+ interesting: {
+ neverStarted,
+ lowFillStarted,
+ highChurn,
+ },
+ };
+}
+
+function buildTimeline(lobbies: LobbyRecord[]): TimelineBucket[] {
+ const byMinute = new Map();
+
+ const push = (when: number, key: "opened" | "closed" | "started"): void => {
+ const minute = Math.floor(when / 60_000) * 60_000;
+ const existing = byMinute.get(minute) ?? {
+ minute,
+ opened: 0,
+ closed: 0,
+ started: 0,
+ };
+ existing[key] += 1;
+ byMinute.set(minute, existing);
+ };
+
+ for (const lobby of lobbies) {
+ push(lobby.openedAt, "opened");
+ if (lobby.closedAt) push(lobby.closedAt, "closed");
+ if (lobby.status === "started" && lobby.startDetectedAt) {
+ push(lobby.startDetectedAt, "started");
+ }
+ }
+
+ return Array.from(byMinute.values()).sort((a, b) => a.minute - b.minute);
+}
+
+function average(values: number[]): number {
+ if (values.length === 0) return 0;
+ const sum = values.reduce((acc, value) => acc + value, 0);
+ return sum / values.length;
+}
diff --git a/src/ingest/config.ts b/src/ingest/config.ts
new file mode 100644
index 000000000..46aa0e1f9
--- /dev/null
+++ b/src/ingest/config.ts
@@ -0,0 +1,53 @@
+export interface IngestConfig {
+ port: number;
+ targetBaseUrl: string;
+ targetWsUrl: string;
+ archiveApiBase: string | null;
+ dbPath: string;
+ reconnectDelayMs: number;
+ numWorkers: number;
+ gameInfoPollMs: number;
+ closureProbeAttempts: number;
+ closureProbeIntervalMs: number;
+}
+
+const envInt = (name: string, fallback: number): number => {
+ const raw = process.env[name];
+ if (!raw) return fallback;
+ const parsed = Number(raw);
+ if (!Number.isFinite(parsed)) return fallback;
+ return Math.trunc(parsed);
+};
+
+const envString = (name: string, fallback: string): string => {
+ const raw = process.env[name];
+ return raw && raw.length > 0 ? raw : fallback;
+};
+
+const trimSlash = (value: string): string => value.replace(/\/+$/, "");
+
+export function loadConfig(): IngestConfig {
+ const numWorkers = Math.max(1, envInt("NUM_WORKERS", 20));
+ const targetBaseUrl = trimSlash(
+ envString("TARGET_BASE_URL", "https://openfront.io"),
+ );
+ const wsBase = targetBaseUrl.replace(/^http/i, "ws");
+ const wsDefault = `${wsBase}/lobbies`;
+ return {
+ port: envInt("PORT", 3100),
+ targetBaseUrl,
+ targetWsUrl: envString("TARGET_WS_URL", wsDefault),
+ archiveApiBase: trimSlash(
+ envString("ARCHIVE_API_BASE", "https://api.openfront.io"),
+ ),
+ dbPath: envString("DB_PATH", "data/db.json"),
+ reconnectDelayMs: envInt("RECONNECT_DELAY_MS", 3000),
+ numWorkers,
+ gameInfoPollMs: Math.max(1000, envInt("GAME_INFO_POLL_MS", 5000)),
+ closureProbeAttempts: Math.max(1, envInt("CLOSURE_PROBE_ATTEMPTS", 20)),
+ closureProbeIntervalMs: Math.max(
+ 1000,
+ envInt("CLOSURE_PROBE_INTERVAL_MS", 3000),
+ ),
+ };
+}
diff --git a/src/ingest/ingestService.ts b/src/ingest/ingestService.ts
new file mode 100644
index 000000000..53e64dd13
--- /dev/null
+++ b/src/ingest/ingestService.ts
@@ -0,0 +1,552 @@
+import WebSocket from "ws";
+import {
+ GameInfoResponse,
+ LobbyRecord,
+ PublicGamesMessage,
+ workerPathForGame,
+} from "../shared/types";
+import { IngestConfig } from "./config";
+import {
+ ArchiveSummarySchema,
+ GameInfoResponseSchema,
+ ProdLobbiesUpdateSchema,
+ PublicGamesMessageSchema,
+} from "./schemas";
+import { JsonStore } from "./store";
+
+interface ExistsResponse {
+ exists?: boolean;
+}
+
+const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+const MAX_LOG_PAYLOAD = 1600;
+const compactPayload = (value: string): string =>
+ value.length <= MAX_LOG_PAYLOAD
+ ? value
+ : `${value.slice(0, MAX_LOG_PAYLOAD)}...[truncated ${value.length - MAX_LOG_PAYLOAD} chars]`;
+
+export class LobbyIngestService {
+ private ws: WebSocket | null = null;
+ private reconnectTimer: ReturnType | null = null;
+ private gameInfoPollTimer: ReturnType | null = null;
+ private archiveBackfillTimer: ReturnType | null = null;
+ private startedPollTimer: ReturnType | null = null;
+ private activeLobbyIds: Set = new Set();
+ private closingProbeJobs: Set = new Set();
+ private archiveAttemptCount = new Map();
+ private isStarted = false;
+
+ constructor(
+ private readonly config: IngestConfig,
+ private readonly store: JsonStore,
+ ) {}
+
+ start(): void {
+ if (this.isStarted) return;
+ this.isStarted = true;
+ this.connect();
+ this.gameInfoPollTimer = setInterval(() => {
+ void this.safeRun("pollActiveGameInfo", () => this.pollActiveGameInfo());
+ }, this.config.gameInfoPollMs);
+ this.archiveBackfillTimer = setInterval(() => {
+ void this.safeRun("backfillArchiveData", () => this.backfillArchiveData());
+ }, 60_000);
+ this.startedPollTimer = setInterval(() => {
+ void this.safeRun("pollStartedGames", () => this.pollStartedGames());
+ }, 60_000);
+
+ void this.safeRun("reconcileHistoricalStartedGames", () =>
+ this.reconcileHistoricalStartedGames(),
+ );
+ }
+
+ stop(): void {
+ this.isStarted = false;
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ if (this.gameInfoPollTimer) {
+ clearInterval(this.gameInfoPollTimer);
+ this.gameInfoPollTimer = null;
+ }
+ if (this.archiveBackfillTimer) {
+ clearInterval(this.archiveBackfillTimer);
+ this.archiveBackfillTimer = null;
+ }
+ if (this.startedPollTimer) {
+ clearInterval(this.startedPollTimer);
+ this.startedPollTimer = null;
+ }
+ if (this.ws) {
+ this.ws.removeAllListeners();
+ this.ws.close();
+ this.ws = null;
+ }
+ }
+
+ private connect(): void {
+ if (!this.isStarted) return;
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
+
+ this.ws = new WebSocket(this.config.targetWsUrl);
+
+ this.ws.on("open", () => {
+ this.store.systemNote(`Connected to ${this.config.targetWsUrl}`);
+ });
+
+ this.ws.on("message", (raw) => {
+ const now = Date.now();
+ this.store.markMessageReceived();
+ const text = typeof raw === "string" ? raw : raw.toString();
+
+ let json: unknown;
+ try {
+ json = JSON.parse(text);
+ } catch (error) {
+ const payload = compactPayload(text);
+ this.store.systemNote(
+ `Invalid /lobbies JSON parse error: ${String(error)} | payload=${payload}`,
+ );
+ // eslint-disable-next-line no-console
+ console.error("[lobbystatistics] invalid websocket payload", {
+ error,
+ payload,
+ });
+ return;
+ }
+
+ if (json && typeof json === "object" && (json as { type?: string }).type === "error") {
+ const payload = compactPayload(text);
+ this.store.systemNote(`WebSocket error reply received: payload=${payload}`);
+ // eslint-disable-next-line no-console
+ console.error("[lobbystatistics] websocket error reply", payload);
+ return;
+ }
+
+ const normalized = this.normalizeLobbiesMessage(json);
+ if (!normalized.ok) {
+ const payload = compactPayload(text);
+ this.store.systemNote(
+ `Invalid /lobbies schema: ${normalized.error
+ .slice(0, 240)} | payload=${payload}`,
+ );
+ // eslint-disable-next-line no-console
+ console.error("[lobbystatistics] websocket schema mismatch", {
+ error: normalized.error,
+ payload,
+ });
+ return;
+ }
+
+ this.ingestLobbyFrame(now, normalized.message);
+ });
+
+ this.ws.on("close", (code, reason) => {
+ const reasonText = reason.length > 0 ? reason.toString("utf-8") : "";
+ this.store.systemNote(
+ `WebSocket closed: code=${code}${reasonText ? ` reason=${compactPayload(reasonText)}` : ""}`,
+ );
+ this.scheduleReconnect();
+ });
+
+ this.ws.on("error", (error) => {
+ this.store.systemNote(`WebSocket error: ${String(error)}`);
+ });
+ }
+
+ private normalizeLobbiesMessage(
+ json: unknown,
+ ): { ok: true; message: PublicGamesMessage } | { ok: false; error: string } {
+ const direct = PublicGamesMessageSchema.safeParse(json);
+ if (direct.success) {
+ return {
+ ok: true,
+ message: direct.data as PublicGamesMessage,
+ };
+ }
+
+ const prod = ProdLobbiesUpdateSchema.safeParse(json);
+ if (prod.success) {
+ const now = Date.now();
+ const serverTime = prod.data.data.serverTime ?? now;
+ const games = prod.data.data.lobbies.map((lobby) => ({
+ gameID: lobby.gameID,
+ numClients: lobby.numClients,
+ startsAt:
+ lobby.startsAt ??
+ (typeof lobby.msUntilStart === "number"
+ ? now + lobby.msUntilStart
+ : now),
+ gameConfig: lobby.gameConfig,
+ }));
+ return {
+ ok: true,
+ message: {
+ serverTime,
+ games,
+ },
+ };
+ }
+
+ return {
+ ok: false,
+ error: direct.error.issues
+ .slice(0, 3)
+ .map((issue) => issue.message)
+ .join("; "),
+ };
+ }
+
+ private scheduleReconnect(): void {
+ if (!this.isStarted) return;
+ if (this.reconnectTimer !== null) return;
+ this.store.markReconnect();
+ this.reconnectTimer = setTimeout(() => {
+ this.reconnectTimer = null;
+ this.connect();
+ }, this.config.reconnectDelayMs);
+ }
+
+ private ingestLobbyFrame(now: number, message: PublicGamesMessage): void {
+ const nextActive = new Set();
+ for (const lobby of message.games) {
+ nextActive.add(lobby.gameID);
+ this.store.upsertFromLobby(now, message.serverTime, lobby);
+ }
+
+ for (const previousId of this.activeLobbyIds) {
+ if (!nextActive.has(previousId)) {
+ const closed = this.store.markClosed(previousId, now);
+ if (closed) {
+ this.store.note(previousId, "Lobby disappeared from /lobbies stream");
+ void this.handleLobbyClosed(closed);
+ }
+ }
+ }
+
+ this.activeLobbyIds = nextActive;
+ }
+
+ private async pollActiveGameInfo(): Promise {
+ const ids = Array.from(this.activeLobbyIds);
+ for (const gameID of ids) {
+ await this.pollSingleGameInfo(gameID);
+ }
+ }
+
+ private async pollSingleGameInfo(gameID: string): Promise {
+ const workerPath = workerPathForGame(gameID, this.config.numWorkers);
+ const primary = `${this.config.targetBaseUrl}/${workerPath}/api/game/${gameID}`;
+ const fallback = `${this.config.targetBaseUrl}/api/game/${gameID}`;
+
+ const responses = [await this.fetchJson(primary, 3500)];
+ if (responses[0].status === 404 || responses[0].status === 502) {
+ responses.push(await this.fetchJson(fallback, 3500));
+ }
+
+ const candidate = responses.find((entry) => entry.status === 200);
+ if (!candidate || candidate.json === null) {
+ this.store.markGameInfoPollError(gameID);
+ return;
+ }
+
+ const parsed = GameInfoResponseSchema.safeParse(candidate.json);
+ if (!parsed.success) {
+ this.store.markGameInfoPollError(gameID);
+ return;
+ }
+
+ const body = parsed.data as GameInfoResponse;
+ this.store.setGameInfoPollResult(gameID, {
+ status: candidate.status,
+ gameConfig: body.gameConfig,
+ clientIds: body.clients?.map((client) => client.clientID),
+ playersInGame: body.clients?.length,
+ });
+ }
+
+ private async handleLobbyClosed(lobby: LobbyRecord): Promise {
+ if (this.closingProbeJobs.has(lobby.gameID)) return;
+ this.closingProbeJobs.add(lobby.gameID);
+
+ try {
+ for (let attempt = 1; attempt <= this.config.closureProbeAttempts; attempt++) {
+ const exists = await this.checkExists(lobby.gameID);
+ const existsValue = exists.json && typeof exists.json === "object"
+ ? (exists.json as ExistsResponse).exists === true
+ : false;
+
+ if (existsValue) {
+ const info = await this.fetchGameInfo(lobby.gameID);
+ const playersAtStart = info?.clients?.length;
+ const maxPlayers = lobby.maxPlayers ?? info?.gameConfig?.maxPlayers;
+ this.store.applyClosureProbe(lobby.gameID, {
+ attempt,
+ existsStatus: exists.status,
+ started: true,
+ playersAtStart,
+ fillRatioAtStart:
+ playersAtStart !== undefined && maxPlayers
+ ? playersAtStart / Math.max(1, maxPlayers)
+ : undefined,
+ startDetectedAt: Date.now(),
+ });
+ await this.tryArchiveLookup(lobby.gameID);
+ return;
+ }
+
+ const now = Date.now();
+ if (
+ lobby.scheduledStartAt > 0 &&
+ now > lobby.scheduledStartAt + 45_000 &&
+ attempt >= Math.floor(this.config.closureProbeAttempts / 2)
+ ) {
+ this.store.applyClosureProbe(lobby.gameID, {
+ attempt,
+ existsStatus: exists.status,
+ started: false,
+ didNotStart: true,
+ });
+ } else {
+ this.store.applyClosureProbe(lobby.gameID, {
+ attempt,
+ existsStatus: exists.status,
+ started: false,
+ });
+ }
+ await delay(this.config.closureProbeIntervalMs);
+ }
+
+ this.store.applyClosureProbe(lobby.gameID, {
+ attempt: this.config.closureProbeAttempts,
+ started: false,
+ didNotStart: true,
+ });
+ await this.tryArchiveLookup(lobby.gameID);
+ } finally {
+ this.closingProbeJobs.delete(lobby.gameID);
+ }
+ }
+
+ private async fetchGameInfo(gameID: string): Promise {
+ const workerPath = workerPathForGame(gameID, this.config.numWorkers);
+ const primary = `${this.config.targetBaseUrl}/${workerPath}/api/game/${gameID}`;
+ const fallback = `${this.config.targetBaseUrl}/api/game/${gameID}`;
+ const first = await this.fetchJson(primary, 3500);
+ const second =
+ first.status === 200 ? first : await this.fetchJson(fallback, 3500);
+ if (second.status !== 200 || second.json === null) return null;
+ const parsed = GameInfoResponseSchema.safeParse(second.json);
+ if (!parsed.success) return null;
+ return parsed.data as GameInfoResponse;
+ }
+
+ private async checkExists(
+ gameID: string,
+ ): Promise<{ status: number; json: unknown | null }> {
+ const workerPath = workerPathForGame(gameID, this.config.numWorkers);
+ const primary = `${this.config.targetBaseUrl}/${workerPath}/api/game/${gameID}/exists`;
+ const fallback = `${this.config.targetBaseUrl}/api/game/${gameID}/exists`;
+ const first = await this.fetchJson(primary, 3500);
+ if (first.status === 200) return first;
+ return this.fetchJson(fallback, 3500);
+ }
+
+ private async backfillArchiveData(): Promise {
+ const target = this.store
+ .values()
+ .filter(
+ (record) =>
+ record.status !== "active" &&
+ !record.archiveFound &&
+ !!record.closedAt &&
+ Date.now() - record.closedAt > 120_000,
+ )
+ .sort((a, b) => a.closedAt! - b.closedAt!)
+ .slice(0, 8);
+
+ for (const record of target) {
+ await this.tryArchiveLookup(record.gameID);
+ }
+ }
+
+ private async reconcileHistoricalStartedGames(): Promise {
+ const now = Date.now();
+ const targets = this.store
+ .values()
+ .filter((record) => record.status === "started")
+ .sort(
+ (a, b) =>
+ (a.startedPollLastAt ?? a.startDetectedAt ?? a.closedAt ?? 0) -
+ (b.startedPollLastAt ?? b.startDetectedAt ?? b.closedAt ?? 0),
+ )
+ .slice(0, 250);
+
+ for (const record of targets) {
+ const exists = await this.checkExists(record.gameID);
+ const existsValue =
+ exists.json && typeof exists.json === "object"
+ ? (exists.json as ExistsResponse).exists === true
+ : false;
+
+ if (!existsValue) {
+ this.store.markCompleted(
+ record.gameID,
+ now,
+ "historical-sweep-exists-false",
+ );
+ await this.tryArchiveLookup(record.gameID);
+ continue;
+ }
+
+ const info = await this.fetchGameInfo(record.gameID);
+ this.store.markStartedHeartbeat(record.gameID, {
+ checkedAt: now,
+ playersInGame: info?.clients?.length,
+ statusCode: exists.status,
+ });
+ }
+ }
+
+ private async pollStartedGames(): Promise {
+ const now = Date.now();
+ const thresholdMs = 10 * 60_000;
+ const targets = this.store
+ .values()
+ .filter((record) => record.status === "started")
+ .filter(
+ (record) =>
+ !record.startedPollLastAt || now - record.startedPollLastAt >= thresholdMs,
+ )
+ .sort(
+ (a, b) => (a.startedPollLastAt ?? a.startDetectedAt ?? 0) - (b.startedPollLastAt ?? b.startDetectedAt ?? 0),
+ )
+ .slice(0, 20);
+
+ for (const record of targets) {
+ const exists = await this.checkExists(record.gameID);
+ const existsValue =
+ exists.json && typeof exists.json === "object"
+ ? (exists.json as ExistsResponse).exists === true
+ : false;
+
+ if (!existsValue) {
+ this.store.markCompleted(
+ record.gameID,
+ now,
+ "exists-endpoint-false",
+ );
+ await this.tryArchiveLookup(record.gameID);
+ continue;
+ }
+
+ const info = await this.fetchGameInfo(record.gameID);
+ this.store.markStartedHeartbeat(record.gameID, {
+ checkedAt: now,
+ playersInGame: info?.clients?.length,
+ statusCode: exists.status,
+ });
+ }
+ }
+
+ private async safeRun(
+ label: string,
+ fn: () => Promise,
+ ): Promise {
+ try {
+ await fn();
+ } catch (error) {
+ this.store.systemNote(`Task ${label} failed: ${String(error)}`);
+ // eslint-disable-next-line no-console
+ console.error(`[lobbystatistics] task ${label} failed`, error);
+ }
+ }
+
+ private async tryArchiveLookup(gameID: string): Promise {
+ if (!this.config.archiveApiBase) return;
+ const attempts = (this.archiveAttemptCount.get(gameID) ?? 0) + 1;
+ this.archiveAttemptCount.set(gameID, attempts);
+ if (attempts > 8) return;
+
+ const url = `${this.config.archiveApiBase}/game/${encodeURIComponent(gameID)}`;
+ const response = await this.fetchJson(url, 4000);
+ if (response.status !== 200 || response.json === null) return;
+ const parsed = ArchiveSummarySchema.safeParse(response.json);
+ if (!parsed.success) return;
+
+ const info = parsed.data.info;
+ const normalizeTimestamp = (timestamp: number | undefined): number | undefined => {
+ if (timestamp === undefined || !Number.isFinite(timestamp)) return undefined;
+ return timestamp < 1e12 ? Math.round(timestamp * 1000) : Math.round(timestamp);
+ };
+ const winnerLabel = (() => {
+ if (!info?.winner) return undefined;
+ if (
+ typeof info.winner === "object" &&
+ !Array.isArray(info.winner) &&
+ info.winner !== null &&
+ "username" in info.winner
+ ) {
+ const value = info.winner.username;
+ return typeof value === "string" && value.length > 0 ? value : undefined;
+ }
+ if (Array.isArray(info.winner)) {
+ const winnerArray = info.winner;
+ if (winnerArray.length === 0) return undefined;
+ const type = winnerArray[0];
+ if (type === "nation" && winnerArray[1]) return winnerArray[1];
+ if (type === "player" && winnerArray[1]) {
+ const id = winnerArray[1];
+ const player = info.players?.find((entry) => entry.clientID === id);
+ return player?.username ?? id;
+ }
+ if (type === "team") {
+ const ids = winnerArray.slice(2);
+ if (ids.length === 0) return undefined;
+ const names = ids
+ .map((id) => info.players?.find((entry) => entry.clientID === id)?.username ?? id)
+ .filter((entry) => !!entry);
+ return names.join(", ");
+ }
+ }
+ return undefined;
+ })();
+
+ this.store.setArchiveSummary(gameID, {
+ found: true,
+ players: info?.players?.length,
+ durationSec:
+ typeof info?.duration === "number" ? Math.round(info.duration) : undefined,
+ winner: winnerLabel,
+ lobbyCreatedAt: normalizeTimestamp(info?.lobbyCreatedAt),
+ startAt: normalizeTimestamp(info?.start),
+ endAt: normalizeTimestamp(info?.end),
+ });
+ }
+
+ private async fetchJson(
+ url: string,
+ timeoutMs: number,
+ ): Promise<{ status: number; json: unknown | null }> {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const response = await fetch(url, {
+ method: "GET",
+ signal: controller.signal,
+ headers: { Accept: "application/json" },
+ });
+ const contentType = response.headers.get("content-type") ?? "";
+ if (!contentType.includes("application/json")) {
+ return { status: response.status, json: null };
+ }
+ const json = await response.json();
+ return { status: response.status, json };
+ } catch {
+ return { status: 0, json: null };
+ } finally {
+ clearTimeout(timeout);
+ }
+ }
+}
diff --git a/src/ingest/schemas.ts b/src/ingest/schemas.ts
new file mode 100644
index 000000000..7fa27fd32
--- /dev/null
+++ b/src/ingest/schemas.ts
@@ -0,0 +1,92 @@
+import { z } from "zod";
+
+export const PublicGameModifiersSchema = z.object({
+ isCompact: z.boolean().optional().default(false),
+ isRandomSpawn: z.boolean().optional().default(false),
+ isCrowded: z.boolean().optional().default(false),
+ startingGold: z.number().int().min(0).optional(),
+});
+
+export const GameConfigSchema = z.object({
+ gameMap: z.string(),
+ gameType: z.string().optional().default("unknown"),
+ gameMode: z.string().optional().default("unknown"),
+ maxPlayers: z.number().int().min(1).optional(),
+ bots: z.number().int().min(0).optional(),
+ difficulty: z.string().optional(),
+ playerTeams: z.union([z.number().int().positive(), z.string()]).optional(),
+ gameMapSize: z.string().optional(),
+ publicGameModifiers: PublicGameModifiersSchema.optional(),
+});
+
+export const PublicGameInfoSchema = z.object({
+ gameID: z.string().min(1),
+ numClients: z.number().int().min(0),
+ startsAt: z.number().int(),
+ gameConfig: GameConfigSchema.optional(),
+});
+
+export const PublicGamesMessageSchema = z.object({
+ serverTime: z.number().int(),
+ games: z.array(PublicGameInfoSchema),
+});
+
+export const ProdLobbyInfoSchema = z.object({
+ gameID: z.string().min(1),
+ numClients: z.number().int().min(0),
+ gameConfig: GameConfigSchema.optional(),
+ msUntilStart: z.number().int().optional(),
+ startsAt: z.number().int().optional(),
+});
+
+export const ProdLobbiesUpdateSchema = z.object({
+ type: z.literal("lobbies_update"),
+ data: z.object({
+ lobbies: z.array(ProdLobbyInfoSchema),
+ serverTime: z.number().int().optional(),
+ }),
+});
+
+export const GameInfoResponseSchema = z.object({
+ gameID: z.string(),
+ clients: z
+ .array(
+ z.object({
+ username: z.string(),
+ clientID: z.string(),
+ }),
+ )
+ .optional(),
+ lobbyCreatorClientID: z.string().optional(),
+ gameConfig: GameConfigSchema.optional(),
+ startsAt: z.number().int().optional(),
+ serverTime: z.number().int(),
+});
+
+export const ArchiveSummarySchema = z.object({
+ info: z
+ .object({
+ players: z
+ .array(
+ z.object({
+ username: z.string().optional(),
+ clientID: z.string().optional(),
+ }),
+ )
+ .optional(),
+ lobbyCreatedAt: z.number().optional(),
+ start: z.number().optional(),
+ end: z.number().optional(),
+ duration: z.number().optional(),
+ winner: z
+ .union([
+ z.array(z.string()),
+ z.object({
+ username: z.string().optional(),
+ }),
+ ])
+ .optional(),
+ })
+ .passthrough()
+ .optional(),
+});
diff --git a/src/ingest/server.ts b/src/ingest/server.ts
new file mode 100644
index 000000000..dd9a5c987
--- /dev/null
+++ b/src/ingest/server.ts
@@ -0,0 +1,128 @@
+import express from "express";
+import path from "path";
+import { fileURLToPath } from "url";
+import { buildAnalytics } from "./analytics";
+import { loadConfig } from "./config";
+import { LobbyIngestService } from "./ingestService";
+import { JsonStore } from "./store";
+import { BucketMode, bucketForConfig } from "../shared/types";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const toBucketMode = (value: unknown): BucketMode => {
+ switch (value) {
+ case "game_mode":
+ case "game_mode_team":
+ case "map":
+ case "map_size":
+ case "modifiers":
+ return value;
+ default:
+ return "game_mode_team";
+ }
+};
+
+async function main() {
+ const config = loadConfig();
+ const store = await JsonStore.open(config);
+ const ingest = new LobbyIngestService(config, store);
+ ingest.start();
+
+ const app = express();
+ app.use(express.json({ limit: "1mb" }));
+
+ const staticDir = path.resolve(__dirname, "../../static");
+ app.use(express.static(staticDir));
+
+ app.get("/api/health", (_req, res) => {
+ const db = store.getDb();
+ res.json({
+ status: "ok",
+ now: Date.now(),
+ messagesReceived: db.messagesReceived,
+ reconnectCount: db.reconnectCount,
+ lobbiesTracked: Object.keys(db.lobbies).length,
+ target: db.environment,
+ lastUpdatedAt: db.lastUpdatedAt,
+ systemNotes: db.systemNotes.slice(-10),
+ });
+ });
+
+ app.get("/api/lobbies", (req, res) => {
+ const status = typeof req.query.status === "string" ? req.query.status : null;
+ const bucketMode = toBucketMode(req.query.bucketMode);
+ const lookbackHours =
+ typeof req.query.lookbackHours === "string"
+ ? Number(req.query.lookbackHours)
+ : 24 * 7;
+ const since = Date.now() - Math.max(1, lookbackHours) * 60 * 60 * 1000;
+
+ const lobbies = store
+ .values()
+ .filter((lobby) => lobby.firstSeenAt >= since)
+ .filter((lobby) => (status ? lobby.status === status : true))
+ .map((lobby) => ({
+ ...lobby,
+ bucket: bucketForConfig(lobby.gameConfig, bucketMode),
+ }))
+ .sort((a, b) => b.firstSeenAt - a.firstSeenAt);
+
+ res.json({ count: lobbies.length, lobbies });
+ });
+
+ app.get("/api/lobbies/:id", (req, res) => {
+ const record = store.getLobby(req.params.id);
+ if (!record) {
+ res.status(404).json({ error: "Lobby not found" });
+ return;
+ }
+ res.json(record);
+ });
+
+ app.get("/api/analytics", (req, res) => {
+ const bucketMode = toBucketMode(req.query.bucketMode);
+ const lookbackHours =
+ typeof req.query.lookbackHours === "string"
+ ? Number(req.query.lookbackHours)
+ : 24 * 7;
+ const payload = buildAnalytics(store.values(), bucketMode, lookbackHours);
+ res.json(payload);
+ });
+
+ app.get("*", (_req, res) => {
+ res.sendFile(path.resolve(staticDir, "index.html"));
+ });
+
+ const server = app.listen(config.port, () => {
+ // eslint-disable-next-line no-console
+ console.log(`[lobbystatistics] ingest server listening on :${config.port}`);
+ });
+
+ process.on("unhandledRejection", (reason) => {
+ // eslint-disable-next-line no-console
+ console.error("[lobbystatistics] unhandledRejection", reason);
+ });
+
+ process.on("uncaughtException", (error) => {
+ // eslint-disable-next-line no-console
+ console.error("[lobbystatistics] uncaughtException", error);
+ });
+
+ const shutdown = async () => {
+ ingest.stop();
+ await store.close();
+ server.close(() => {
+ process.exit(0);
+ });
+ };
+
+ process.on("SIGINT", () => void shutdown());
+ process.on("SIGTERM", () => void shutdown());
+}
+
+void main().catch((error) => {
+ // eslint-disable-next-line no-console
+ console.error("[lobbystatistics] fatal startup error", error);
+ process.exit(1);
+});
diff --git a/src/ingest/store.ts b/src/ingest/store.ts
new file mode 100644
index 000000000..ad7cbf18a
--- /dev/null
+++ b/src/ingest/store.ts
@@ -0,0 +1,413 @@
+import fs from "fs/promises";
+import path from "path";
+import {
+ DbSchema,
+ GameConfig,
+ LobbyRecord,
+ PublicGameInfo,
+ workerPathForGame,
+} from "../shared/types";
+import { IngestConfig } from "./config";
+
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+const DEFAULT_SCHEMA = (config: IngestConfig): DbSchema => ({
+ version: 1,
+ createdAt: Date.now(),
+ lastUpdatedAt: Date.now(),
+ environment: {
+ targetBaseUrl: config.targetBaseUrl,
+ targetWsUrl: config.targetWsUrl,
+ archiveApiBase: config.archiveApiBase,
+ numWorkers: config.numWorkers,
+ },
+ messagesReceived: 0,
+ reconnectCount: 0,
+ systemNotes: [],
+ lobbies: {},
+});
+
+const normalizeDb = (config: IngestConfig, candidate: Partial): DbSchema => {
+ const fallback = DEFAULT_SCHEMA(config);
+ const normalizedLobbies = { ...(candidate.lobbies ?? fallback.lobbies) };
+ for (const value of Object.values(normalizedLobbies)) {
+ if (
+ value &&
+ value.status === "completed" &&
+ typeof value.actualEndAt === "number"
+ ) {
+ value.completedAt = value.actualEndAt;
+ value.completionReason = "archive-end-time";
+ }
+ }
+ return {
+ ...fallback,
+ ...candidate,
+ environment: {
+ targetBaseUrl: config.targetBaseUrl,
+ targetWsUrl: config.targetWsUrl,
+ archiveApiBase: config.archiveApiBase,
+ numWorkers: config.numWorkers,
+ },
+ systemNotes: Array.isArray(candidate.systemNotes)
+ ? candidate.systemNotes
+ : fallback.systemNotes,
+ lobbies: normalizedLobbies,
+ };
+};
+
+export class JsonStore {
+ private db: DbSchema;
+ private dirty = false;
+ private flushTimer: ReturnType | null = null;
+
+ private constructor(
+ private readonly config: IngestConfig,
+ initialDb: DbSchema,
+ ) {
+ this.db = initialDb;
+ }
+
+ static async open(config: IngestConfig): Promise {
+ const dbPath = path.resolve(config.dbPath);
+ await fs.mkdir(path.dirname(dbPath), { recursive: true });
+
+ try {
+ const raw = await fs.readFile(dbPath, "utf-8");
+ const parsed = normalizeDb(config, JSON.parse(raw) as Partial);
+ return new JsonStore(config, parsed);
+ } catch {
+ const fresh = DEFAULT_SCHEMA(config);
+ const store = new JsonStore(config, fresh);
+ await store.flush();
+ return store;
+ }
+ }
+
+ getDb(): DbSchema {
+ return this.db;
+ }
+
+ systemNote(message: string): void {
+ this.db.systemNotes.push(`${new Date().toISOString()}: ${message}`);
+ if (this.db.systemNotes.length > 300) {
+ this.db.systemNotes = this.db.systemNotes.slice(-300);
+ }
+ this.touch();
+ }
+
+ values(): LobbyRecord[] {
+ return Object.values(this.db.lobbies);
+ }
+
+ getLobby(gameID: string): LobbyRecord | undefined {
+ return this.db.lobbies[gameID];
+ }
+
+ markMessageReceived(): void {
+ this.db.messagesReceived += 1;
+ this.touch();
+ }
+
+ markReconnect(): void {
+ this.db.reconnectCount += 1;
+ this.touch();
+ }
+
+ upsertFromLobby(now: number, serverTime: number, lobby: PublicGameInfo): void {
+ const existing = this.db.lobbies[lobby.gameID];
+ const maxPlayers = lobby.gameConfig?.maxPlayers;
+ if (!existing) {
+ this.db.lobbies[lobby.gameID] = {
+ gameID: lobby.gameID,
+ firstSeenAt: now,
+ lastSeenAt: now,
+ openedAt: now,
+ scheduledStartAt: lobby.startsAt,
+ workerPath: workerPathForGame(lobby.gameID, this.config.numWorkers),
+ gameConfig: lobby.gameConfig,
+ status: "active",
+ lastObservedClients: lobby.numClients,
+ peakClients: lobby.numClients,
+ troughClients: lobby.numClients,
+ maxPlayers,
+ observedJoinEvents: 0,
+ observedLeaveEvents: 0,
+ snapshots: [
+ {
+ at: now,
+ serverTime,
+ numClients: lobby.numClients,
+ maxPlayers,
+ },
+ ],
+ fullMoments: 0,
+ fullDurationMs: 0,
+ uniqueClientsObserved: 0,
+ uniqueClientIds: [],
+ gameInfoPolls: 0,
+ gameInfoPollErrors: 0,
+ probeAttempts: 0,
+ archiveFound: false,
+ notes: [],
+ };
+ this.touch();
+ return;
+ }
+
+ if (existing.status !== "active") {
+ existing.status = "active";
+ existing.notes.push(
+ `${new Date(now).toISOString()}: lobby returned to active list`,
+ );
+ }
+
+ const delta = lobby.numClients - existing.lastObservedClients;
+ if (delta > 0) existing.observedJoinEvents += delta;
+ if (delta < 0) existing.observedLeaveEvents += Math.abs(delta);
+
+ if (
+ existing.maxPlayers &&
+ existing.maxPlayers > 0 &&
+ lobby.numClients >= existing.maxPlayers
+ ) {
+ existing.fullMoments += 1;
+ if (existing.fullLastSeenAt) {
+ existing.fullDurationMs += now - existing.fullLastSeenAt;
+ }
+ existing.fullLastSeenAt = now;
+ } else {
+ existing.fullLastSeenAt = undefined;
+ }
+
+ existing.lastSeenAt = now;
+ existing.lastObservedClients = lobby.numClients;
+ existing.peakClients = Math.max(existing.peakClients, lobby.numClients);
+ existing.troughClients = Math.min(existing.troughClients, lobby.numClients);
+ existing.maxPlayers = existing.maxPlayers ?? maxPlayers;
+ existing.gameConfig = lobby.gameConfig ?? existing.gameConfig;
+ existing.scheduledStartAt = lobby.startsAt || existing.scheduledStartAt;
+ existing.snapshots.push({
+ at: now,
+ serverTime,
+ numClients: lobby.numClients,
+ maxPlayers: existing.maxPlayers,
+ });
+ this.touch();
+ }
+
+ markClosed(gameID: string, closedAt: number): LobbyRecord | null {
+ const lobby = this.db.lobbies[gameID];
+ if (!lobby) return null;
+ if (lobby.closedAt) return lobby;
+
+ lobby.closedAt = closedAt;
+ lobby.status = lobby.status === "active" ? "unknown" : lobby.status;
+ lobby.openDurationMs = closedAt - lobby.openedAt;
+ lobby.lastSeenAt = Math.max(lobby.lastSeenAt, closedAt);
+ if (lobby.fullLastSeenAt) {
+ lobby.fullDurationMs += closedAt - lobby.fullLastSeenAt;
+ lobby.fullLastSeenAt = undefined;
+ }
+ this.touch();
+ return lobby;
+ }
+
+ note(gameID: string, message: string): void {
+ const lobby = this.db.lobbies[gameID];
+ if (!lobby) return;
+ lobby.notes.push(`${new Date().toISOString()}: ${message}`);
+ this.touch();
+ }
+
+ setGameInfoPollResult(
+ gameID: string,
+ payload: {
+ status: number;
+ gameConfig?: GameConfig;
+ clientIds?: string[];
+ playersInGame?: number;
+ },
+ ): void {
+ const lobby = this.db.lobbies[gameID];
+ if (!lobby) return;
+ lobby.gameInfoPolls += 1;
+ lobby.probeLastStatus = payload.status;
+ if (payload.gameConfig) lobby.gameConfig = payload.gameConfig;
+ if (payload.playersInGame !== undefined) {
+ lobby.lastObservedClients = payload.playersInGame;
+ lobby.peakClients = Math.max(lobby.peakClients, payload.playersInGame);
+ }
+ if (payload.clientIds) {
+ const seen = new Set(lobby.uniqueClientIds);
+ for (const id of payload.clientIds) seen.add(id);
+ lobby.uniqueClientIds = Array.from(seen);
+ lobby.uniqueClientsObserved = lobby.uniqueClientIds.length;
+ }
+ this.touch();
+ }
+
+ markGameInfoPollError(gameID: string): void {
+ const lobby = this.db.lobbies[gameID];
+ if (!lobby) return;
+ lobby.gameInfoPollErrors += 1;
+ this.touch();
+ }
+
+ applyClosureProbe(
+ gameID: string,
+ payload: {
+ attempt: number;
+ existsStatus?: number;
+ started: boolean;
+ playersAtStart?: number;
+ fillRatioAtStart?: number;
+ startDetectedAt?: number;
+ didNotStart?: boolean;
+ },
+ ): void {
+ const lobby = this.db.lobbies[gameID];
+ if (!lobby) return;
+ lobby.probeAttempts = Math.max(lobby.probeAttempts, payload.attempt);
+ if (payload.existsStatus !== undefined) {
+ lobby.probeLastStatus = payload.existsStatus;
+ }
+ if (payload.started) {
+ lobby.status = "started";
+ lobby.startDetectedAt = payload.startDetectedAt ?? Date.now();
+ lobby.playersAtStart = payload.playersAtStart;
+ lobby.fillRatioAtStart = payload.fillRatioAtStart;
+ lobby.startedPollLastAt = Date.now();
+ lobby.completedAt = undefined;
+ lobby.completionReason = undefined;
+ lobby.probeSuccessAt = Date.now();
+ } else if (payload.didNotStart) {
+ lobby.status = "did_not_start";
+ }
+ this.touch();
+ }
+
+ markStartedHeartbeat(
+ gameID: string,
+ payload: { checkedAt: number; playersInGame?: number; statusCode?: number },
+ ): void {
+ const lobby = this.db.lobbies[gameID];
+ if (!lobby) return;
+ if (lobby.status !== "started") return;
+ lobby.startedPollLastAt = payload.checkedAt;
+ if (payload.playersInGame !== undefined) {
+ lobby.lastObservedClients = payload.playersInGame;
+ lobby.peakClients = Math.max(lobby.peakClients, payload.playersInGame);
+ }
+ if (payload.statusCode !== undefined) {
+ lobby.probeLastStatus = payload.statusCode;
+ }
+ this.touch();
+ }
+
+ markCompleted(gameID: string, completedAt: number, reason: string): void {
+ const lobby = this.db.lobbies[gameID];
+ if (!lobby) return;
+ if (lobby.status === "completed") return;
+ if (lobby.status !== "started") return;
+ lobby.status = "completed";
+ lobby.completedAt = completedAt;
+ lobby.completionReason = reason;
+ lobby.startedPollLastAt = completedAt;
+ lobby.notes.push(
+ `${new Date(completedAt).toISOString()}: completed (${reason})`,
+ );
+ this.touch();
+ }
+
+ setArchiveSummary(
+ gameID: string,
+ payload: {
+ found: boolean;
+ players?: number;
+ durationSec?: number;
+ winner?: string;
+ lobbyCreatedAt?: number;
+ startAt?: number;
+ endAt?: number;
+ },
+ ): void {
+ const lobby = this.db.lobbies[gameID];
+ if (!lobby) return;
+ lobby.archiveFound = payload.found;
+ lobby.archivePlayers = payload.players;
+ lobby.archiveDurationSec = payload.durationSec;
+ lobby.archiveWinner = payload.winner;
+ lobby.actualLobbyCreatedAt = payload.lobbyCreatedAt;
+ lobby.actualStartAt = payload.startAt;
+ lobby.actualEndAt = payload.endAt;
+
+ if (payload.endAt !== undefined && lobby.status !== "active") {
+ // Normalize to archive truth once available.
+ // This avoids drift when completion was first inferred via /exists=false.
+ lobby.status = "completed";
+ lobby.completedAt = payload.endAt;
+ lobby.completionReason = "archive-end-time";
+ }
+
+ this.touch();
+ }
+
+ async close(): Promise {
+ if (this.flushTimer) {
+ clearTimeout(this.flushTimer);
+ this.flushTimer = null;
+ }
+ await this.flush();
+ }
+
+ private touch(): void {
+ this.db.lastUpdatedAt = Date.now();
+ this.dirty = true;
+ if (this.flushTimer !== null) return;
+ this.flushTimer = setTimeout(() => {
+ this.flushTimer = null;
+ void this.flush().catch((error) => {
+ this.dirty = true;
+ // eslint-disable-next-line no-console
+ console.error("[lobbystatistics] db flush failed", error);
+ });
+ }, 500);
+ }
+
+ private async flush(): Promise {
+ if (!this.dirty) return;
+ this.dirty = false;
+ const dbPath = path.resolve(this.config.dbPath);
+ const content = JSON.stringify(this.db, null, 2);
+ const retryDelays = [0, 30, 120, 300];
+ let lastError: unknown = null;
+
+ for (const waitMs of retryDelays) {
+ if (waitMs > 0) {
+ await sleep(waitMs);
+ }
+ try {
+ await fs.writeFile(dbPath, content, "utf-8");
+ return;
+ } catch (error) {
+ lastError = error;
+ const code =
+ typeof error === "object" &&
+ error !== null &&
+ "code" in error &&
+ typeof (error as { code?: unknown }).code === "string"
+ ? (error as { code: string }).code
+ : "";
+ if (code !== "EPERM" && code !== "EACCES") {
+ break;
+ }
+ }
+ }
+
+ this.dirty = true;
+ throw lastError instanceof Error
+ ? lastError
+ : new Error(`db write failed: ${String(lastError)}`);
+ }
+}
diff --git a/src/shared/types.ts b/src/shared/types.ts
new file mode 100644
index 000000000..3fc728388
--- /dev/null
+++ b/src/shared/types.ts
@@ -0,0 +1,245 @@
+export interface PublicGameModifiers {
+ isCompact: boolean;
+ isRandomSpawn: boolean;
+ isCrowded: boolean;
+ startingGold?: number;
+}
+
+export interface GameConfig {
+ gameMap: string;
+ gameType: string;
+ gameMode: string;
+ maxPlayers?: number;
+ bots?: number;
+ difficulty?: string;
+ playerTeams?: number | string;
+ gameMapSize?: string;
+ publicGameModifiers?: PublicGameModifiers;
+}
+
+export interface PublicGameInfo {
+ gameID: string;
+ numClients: number;
+ startsAt: number;
+ gameConfig?: GameConfig;
+}
+
+export interface PublicGamesMessage {
+ serverTime: number;
+ games: PublicGameInfo[];
+}
+
+export interface GameInfoResponse {
+ gameID: string;
+ clients?: Array<{
+ username: string;
+ clientID: string;
+ }>;
+ lobbyCreatorClientID?: string;
+ gameConfig?: GameConfig;
+ startsAt?: number;
+ serverTime: number;
+}
+
+export type LobbyOutcome =
+ | "active"
+ | "started"
+ | "completed"
+ | "did_not_start"
+ | "unknown";
+
+export interface LobbySnapshotPoint {
+ at: number;
+ serverTime: number;
+ numClients: number;
+ maxPlayers?: number;
+}
+
+export interface LobbyRecord {
+ gameID: string;
+ firstSeenAt: number;
+ lastSeenAt: number;
+ openedAt: number;
+ scheduledStartAt: number;
+ workerPath: string;
+ gameConfig?: GameConfig;
+ status: LobbyOutcome;
+ closedAt?: number;
+ startDetectedAt?: number;
+ openDurationMs?: number;
+ lastObservedClients: number;
+ peakClients: number;
+ troughClients: number;
+ maxPlayers?: number;
+ observedJoinEvents: number;
+ observedLeaveEvents: number;
+ snapshots: LobbySnapshotPoint[];
+ fullMoments: number;
+ fullDurationMs: number;
+ fullLastSeenAt?: number;
+ uniqueClientsObserved: number;
+ uniqueClientIds: string[];
+ gameInfoPolls: number;
+ gameInfoPollErrors: number;
+ probeAttempts: number;
+ probeSuccessAt?: number;
+ probeLastStatus?: number;
+ playersAtStart?: number;
+ fillRatioAtStart?: number;
+ startedPollLastAt?: number;
+ completedAt?: number;
+ completionReason?: string;
+ archiveFound: boolean;
+ archivePlayers?: number;
+ archiveDurationSec?: number;
+ archiveWinner?: string;
+ actualLobbyCreatedAt?: number;
+ actualStartAt?: number;
+ actualEndAt?: number;
+ notes: string[];
+}
+
+export interface DbSchema {
+ version: 1;
+ createdAt: number;
+ lastUpdatedAt: number;
+ environment: {
+ targetBaseUrl: string;
+ targetWsUrl: string;
+ archiveApiBase: string | null;
+ numWorkers: number;
+ };
+ messagesReceived: number;
+ reconnectCount: number;
+ systemNotes: string[];
+ lobbies: Record;
+}
+
+export interface BucketStat {
+ bucket: string;
+ count: number;
+ inProgress: number;
+ completed: number;
+ started: number;
+ notStarted: number;
+ avgOpenSec: number;
+ avgJoinRatePerMin: number;
+ avgFillAtClose: number;
+ avgPlayersAtStart: number;
+}
+
+export interface TimelineBucket {
+ minute: number;
+ opened: number;
+ closed: number;
+ started: number;
+}
+
+export interface AnalyticsPayload {
+ now: number;
+ summary: {
+ total: number;
+ active: number;
+ inProgress: number;
+ completed: number;
+ started: number;
+ notStarted: number;
+ unknown: number;
+ underfilledStarted: number;
+ avgOpenSec: number;
+ avgJoinRatePerMin: number;
+ avgPeakFillPct: number;
+ };
+ buckets: BucketStat[];
+ timeline: TimelineBucket[];
+ order: Array<{
+ gameID: string;
+ bucket: string;
+ openedAt: number;
+ closedAt?: number;
+ startDetectedAt?: number;
+ actualStartAt?: number;
+ actualEndAt?: number;
+ archiveDurationSec?: number;
+ scheduledStartAt: number;
+ peakClients: number;
+ maxPlayers?: number;
+ status: LobbyOutcome;
+ openDurationMs?: number;
+ joinRatePerMin: number;
+ }>;
+ interesting: {
+ neverStarted: LobbyRecord[];
+ lowFillStarted: LobbyRecord[];
+ highChurn: LobbyRecord[];
+ };
+}
+
+export type BucketMode =
+ | "game_mode"
+ | "game_mode_team"
+ | "map"
+ | "map_size"
+ | "modifiers";
+
+export function simpleHash(value: string): number {
+ let hash = 0;
+ for (let i = 0; i < value.length; i++) {
+ const code = value.charCodeAt(i);
+ hash = (hash << 5) - hash + code;
+ hash = hash & hash;
+ }
+ return Math.abs(hash);
+}
+
+export function workerPathForGame(gameID: string, numWorkers: number): string {
+ const index = simpleHash(gameID) % Math.max(1, numWorkers);
+ return `w${index}`;
+}
+
+export function safeMaxPlayers(record: Pick): number {
+ return Math.max(1, record.maxPlayers ?? 1);
+}
+
+export function peakFillRatio(record: Pick): number {
+ return record.peakClients / safeMaxPlayers(record as Pick);
+}
+
+export function bucketForConfig(
+ config: GameConfig | undefined,
+ mode: BucketMode,
+): string {
+ if (!config) return "unknown";
+ const modeName = (config.gameMode ?? "unknown").toLowerCase();
+ const team = config.playerTeams ?? "none";
+ const map = (config.gameMap ?? "unknown").toLowerCase();
+ const mapSize = (config.gameMapSize ?? "unknown").toLowerCase();
+ const modifiers = config.publicGameModifiers;
+ const modifierParts = [
+ modifiers?.isCompact ? "compact" : null,
+ modifiers?.isRandomSpawn ? "random-spawn" : null,
+ modifiers?.isCrowded ? "crowded" : null,
+ modifiers?.startingGold ? `start-gold-${modifiers.startingGold}` : null,
+ ].filter((entry): entry is string => entry !== null);
+
+ switch (mode) {
+ case "game_mode":
+ return modeName;
+ case "game_mode_team":
+ return `${modeName}|team:${team}`;
+ case "map":
+ return map;
+ case "map_size":
+ return `${mapSize}|${modeName}`;
+ case "modifiers":
+ return modifierParts.length > 0 ? modifierParts.join("+") : "default";
+ default:
+ return "unknown";
+ }
+}
+
+export function joinRatePerMinute(record: LobbyRecord): number {
+ const closedAt = record.closedAt ?? record.lastSeenAt;
+ const durationMs = Math.max(1, closedAt - record.openedAt);
+ return (record.observedJoinEvents * 60_000) / durationMs;
+}
diff --git a/src/web/main.ts b/src/web/main.ts
new file mode 100644
index 000000000..4784fb973
--- /dev/null
+++ b/src/web/main.ts
@@ -0,0 +1,493 @@
+import {
+ AnalyticsPayload,
+ BucketMode,
+ LobbyRecord,
+ TimelineBucket,
+} from "../shared/types";
+import "./styles.css";
+
+const DEFAULT_BUCKET_MODE: BucketMode = "game_mode_team";
+const DEFAULT_LOOKBACK_HOURS = 24;
+
+const app = document.getElementById("app");
+if (!app) {
+ throw new Error("Missing #app");
+}
+
+app.innerHTML = `
+
+
+
Lobby Statistics
+
Realtime ingest for /lobbies with lifecycle and conversion analytics
+
+
+
+
+
+
+
+
+
+
+
+
+ Bucket Performance
+
+
+
+ Timeline (Open/Close/Start)
+
+
+
+ Lobby Order Analysis
+
+
+
+
+ Games That Did Not Start
+
+
+
+ Low Fill Starts
+
+
+
+`;
+
+const controls = {
+ bucketMode: document.getElementById("bucketMode") as HTMLSelectElement,
+ lookbackHours: document.getElementById("lookbackHours") as HTMLInputElement,
+ refreshBtn: document.getElementById("refreshBtn") as HTMLButtonElement,
+ autoBtn: document.getElementById("autoBtn") as HTMLButtonElement,
+};
+
+const containers = {
+ health: document.getElementById("health") as HTMLDivElement,
+ summary: document.getElementById("summary") as HTMLDivElement,
+ bucketTable: document.getElementById("bucketTable") as HTMLDivElement,
+ timelineChart: document.getElementById("timelineChart") as HTMLDivElement,
+ orderChart: document.getElementById("orderChart") as HTMLDivElement,
+ orderTable: document.getElementById("orderTable") as HTMLDivElement,
+ neverStarted: document.getElementById("neverStarted") as HTMLDivElement,
+ lowFill: document.getElementById("lowFill") as HTMLDivElement,
+};
+
+let autoRefresh = true;
+let refreshTimer: number | null = null;
+
+controls.refreshBtn.onclick = () => {
+ void loadData();
+};
+controls.autoBtn.onclick = () => {
+ autoRefresh = !autoRefresh;
+ controls.autoBtn.textContent = `Auto: ${autoRefresh ? "on" : "off"}`;
+ if (autoRefresh) scheduleRefresh();
+ if (!autoRefresh && refreshTimer !== null) {
+ window.clearTimeout(refreshTimer);
+ refreshTimer = null;
+ }
+};
+
+controls.bucketMode.onchange = () => void loadData();
+controls.lookbackHours.onchange = () => void loadData();
+
+void loadData();
+
+async function loadData(): Promise {
+ const bucketMode = controls.bucketMode.value as BucketMode;
+ const lookbackHours = Number(controls.lookbackHours.value || DEFAULT_LOOKBACK_HOURS);
+ const [health, analytics] = await Promise.all([
+ fetchJson("/api/health"),
+ fetchJson(
+ `/api/analytics?bucketMode=${encodeURIComponent(bucketMode)}&lookbackHours=${encodeURIComponent(
+ String(lookbackHours),
+ )}`,
+ ),
+ ]);
+
+ renderHealth(health);
+ renderAnalytics(analytics as AnalyticsPayload);
+ scheduleRefresh();
+}
+
+function scheduleRefresh(): void {
+ if (!autoRefresh) return;
+ if (refreshTimer !== null) window.clearTimeout(refreshTimer);
+ refreshTimer = window.setTimeout(() => {
+ void loadData();
+ }, 5000);
+}
+
+async function fetchJson(url: string): Promise {
+ const res = await fetch(url, { headers: { Accept: "application/json" } });
+ if (!res.ok) {
+ throw new Error(`Request failed ${res.status} for ${url}`);
+ }
+ return res.json();
+}
+
+function renderHealth(payload: any): void {
+ const notes = Array.isArray(payload.systemNotes)
+ ? payload.systemNotes
+ .slice(-5)
+ .map((note: string) => `${escapeHtml(note)}
`)
+ .join("")
+ : "";
+ containers.health.innerHTML = `
+
+
ingest: ${payload.status}
+
messages ${payload.messagesReceived}
+
reconnects ${payload.reconnectCount}
+
tracked ${payload.lobbiesTracked}
+
last update ${new Date(payload.lastUpdatedAt).toLocaleString()}
+
target ${payload.target.targetWsUrl}
+
${notes}
+
+ `;
+}
+
+function renderAnalytics(payload: AnalyticsPayload): void {
+ renderSummary(payload);
+ renderBucketTable(payload);
+ renderTimeline(payload.timeline);
+ renderOrder(payload);
+ renderInteresting("neverStarted", payload.interesting.neverStarted);
+ renderInteresting("lowFill", payload.interesting.lowFillStarted);
+}
+
+function renderSummary(payload: AnalyticsPayload): void {
+ const cards = [
+ ["Lobbies", payload.summary.total],
+ ["Active", payload.summary.active],
+ ["In Progress", payload.summary.inProgress],
+ ["Completed", payload.summary.completed],
+ ["Did Not Start", payload.summary.notStarted],
+ ["Underfilled Starts", payload.summary.underfilledStarted],
+ ["Avg Open (sec)", payload.summary.avgOpenSec.toFixed(1)],
+ ["Avg Join Rate / min", payload.summary.avgJoinRatePerMin.toFixed(2)],
+ ["Avg Peak Fill %", payload.summary.avgPeakFillPct.toFixed(1)],
+ ];
+ containers.summary.innerHTML = cards
+ .map(
+ ([label, value]) => `
+
+ ${label}
+ ${value}
+
+ `,
+ )
+ .join("");
+}
+
+function renderBucketTable(payload: AnalyticsPayload): void {
+ containers.bucketTable.innerHTML = `
+
+
+
+ | Bucket |
+ Count |
+ In Progress |
+ Completed |
+ Not Started |
+ Avg Open(s) |
+ Join/min |
+ Fill@Close |
+
+
+
+ ${payload.buckets
+ .slice(0, 40)
+ .map(
+ (bucket) => `
+
+ | ${escapeHtml(bucket.bucket)} |
+ ${bucket.count} |
+ ${bucket.inProgress} |
+ ${bucket.completed} |
+ ${bucket.notStarted} |
+ ${bucket.avgOpenSec.toFixed(1)} |
+ ${bucket.avgJoinRatePerMin.toFixed(2)} |
+ ${(bucket.avgFillAtClose * 100).toFixed(1)}% |
+
+ `,
+ )
+ .join("")}
+
+
+ `;
+}
+
+function renderTimeline(timeline: TimelineBucket[]): void {
+ if (timeline.length === 0) {
+ containers.timelineChart.innerHTML = "No data yet.
";
+ return;
+ }
+ const width = 760;
+ const height = 250;
+ const pad = 26;
+ const maxY = Math.max(
+ 1,
+ ...timeline.map((row) => Math.max(row.opened, row.closed, row.started)),
+ );
+ const minX = timeline[0].minute;
+ const maxX = timeline[timeline.length - 1].minute;
+ const x = (v: number) =>
+ pad + ((v - minX) / Math.max(1, maxX - minX)) * (width - pad * 2);
+ const y = (v: number) => height - pad - (v / maxY) * (height - pad * 2);
+
+ const poly = (key: "opened" | "closed" | "started", color: string) => {
+ const points = timeline.map((row) => `${x(row.minute)},${y(row[key])}`).join(" ");
+ return ``;
+ };
+
+ containers.timelineChart.innerHTML = `
+
+ `;
+}
+
+function renderOrder(payload: AnalyticsPayload): void {
+ const rows = payload.order.slice(-40);
+ if (rows.length === 0) {
+ containers.orderChart.innerHTML = "No data yet.
";
+ containers.orderTable.innerHTML = "";
+ return;
+ }
+ const width = 1220;
+ const rowHeight = 18;
+ const height = Math.max(220, rows.length * rowHeight + 30);
+
+ const minAt = Math.min(...rows.map((row) => row.openedAt));
+ const maxAt = Math.max(
+ ...rows.map((row) => row.closedAt ?? row.startDetectedAt ?? row.openedAt),
+ );
+ const pad = 16;
+ const x = (v: number) =>
+ pad + ((v - minAt) / Math.max(1, maxAt - minAt)) * (width - pad * 2);
+
+ const bars = rows
+ .map((row, i) => {
+ const y = 20 + i * rowHeight;
+ const startX = x(row.openedAt);
+ const endAt = row.closedAt ?? row.startDetectedAt ?? row.openedAt;
+ const endX = Math.max(startX + 2, x(endAt));
+ const color = colorForBucket(row.bucket, row.status);
+ const statusStroke =
+ row.status === "started"
+ ? "#9fff7a"
+ : row.status === "completed"
+ ? "#7fd3ff"
+ : row.status === "did_not_start"
+ ? "#ff6b6b"
+ : "#ffd166";
+ const openDurationText = formatDurationMs(row.openDurationMs);
+ const gameDurationText = formatGameDuration(row, payload.now);
+ return `
+
+ ${row.gameID} | ${row.bucket} | status ${row.status} | open ${openDurationText} | game ${gameDurationText}
+
+ `;
+ })
+ .join("");
+
+ const legendBuckets = Array.from(new Set(rows.map((row) => row.bucket))).slice(0, 12);
+ const legend = legendBuckets
+ .map((bucket, index) => {
+ const color = colorForBucket(bucket);
+ const xPos = 14 + (index % 4) * 300;
+ const yPos = 12 + Math.floor(index / 4) * 14;
+ return `
+
+ ${escapeHtml(
+ bucket.length > 36 ? `${bucket.slice(0, 36)}...` : bucket,
+ )}
+ `;
+ })
+ .join("");
+
+ containers.orderChart.innerHTML = `
+
+ `;
+
+ containers.orderTable.innerHTML = `
+
+
+
+ | Game |
+ Bucket |
+ Status |
+ Lobby + Game |
+ Peak Fill |
+ Join/min |
+ Opened |
+
+
+
+ ${rows
+ .slice()
+ .reverse()
+ .map(
+ (row) => `
+
+ | ${row.gameID} |
+
+ ${escapeHtml(row.bucket)}
+ |
+ ${row.status} |
+ ${formatDurationMs(row.openDurationMs)} + ${formatGameDuration(row, payload.now)} |
+ ${row.maxPlayers ? `${row.peakClients}/${row.maxPlayers}` : row.peakClients} |
+ ${row.joinRatePerMin.toFixed(2)} |
+ ${new Date(row.openedAt).toLocaleString()} |
+
+ `,
+ )
+ .join("")}
+
+
+ `;
+}
+
+function renderInteresting(target: "neverStarted" | "lowFill", rows: LobbyRecord[]): void {
+ const element =
+ target === "neverStarted" ? containers.neverStarted : containers.lowFill;
+
+ if (rows.length === 0) {
+ element.innerHTML = "No entries in selected window.
";
+ return;
+ }
+
+ element.innerHTML = `
+
+
+
+ | Game |
+ Mode |
+ Map |
+ Peak |
+ Start Fill |
+ Open |
+
+
+
+ ${rows
+ .slice(0, 12)
+ .map(
+ (row) => `
+
+ | ${row.gameID} |
+ ${row.gameConfig?.gameMode ?? "-"} |
+ ${row.gameConfig?.gameMap ?? "-"} |
+ ${row.maxPlayers ? `${row.peakClients}/${row.maxPlayers}` : row.peakClients} |
+ ${row.fillRatioAtStart !== undefined ? `${(row.fillRatioAtStart * 100).toFixed(1)}%` : "-"} |
+ ${formatDurationMs(row.openDurationMs)} |
+
+ `,
+ )
+ .join("")}
+
+
+ `;
+}
+
+function formatDurationMs(durationMs: number | undefined): string {
+ if (durationMs === undefined) return "-";
+ if (durationMs < 1000) return `${durationMs}ms`;
+ const sec = Math.round(durationMs / 1000);
+ const min = Math.floor(sec / 60);
+ const rem = sec % 60;
+ return `${min}m ${rem}s`;
+}
+
+function formatDurationSec(durationSec: number | undefined): string {
+ if (durationSec === undefined) return "-";
+ return formatDurationMs(durationSec * 1000);
+}
+
+function formatGameDuration(
+ row: {
+ status: string;
+ startDetectedAt?: number;
+ actualStartAt?: number;
+ actualEndAt?: number;
+ archiveDurationSec?: number;
+ },
+ now: number,
+): string {
+ if (
+ row.actualStartAt !== undefined &&
+ row.actualEndAt !== undefined &&
+ row.actualEndAt >= row.actualStartAt
+ ) {
+ return formatDurationMs(row.actualEndAt - row.actualStartAt);
+ }
+
+ if (row.archiveDurationSec !== undefined) {
+ return formatDurationSec(row.archiveDurationSec);
+ }
+
+ if (row.status === "started") {
+ const start = row.actualStartAt ?? row.startDetectedAt;
+ if (start !== undefined && now >= start) {
+ return `${formatDurationMs(now - start)} (running)`;
+ }
+ }
+
+ if (
+ row.status === "completed" &&
+ row.startDetectedAt !== undefined &&
+ row.actualEndAt !== undefined &&
+ row.actualEndAt >= row.startDetectedAt
+ ) {
+ return formatDurationMs(row.actualEndAt - row.startDetectedAt);
+ }
+
+ return "-";
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function hashString(value: string): number {
+ let hash = 0;
+ for (let i = 0; i < value.length; i++) {
+ hash = (hash << 5) - hash + value.charCodeAt(i);
+ hash |= 0;
+ }
+ return Math.abs(hash);
+}
+
+function colorForBucket(bucket: string, status?: string): string {
+ const hue = hashString(bucket) % 360;
+ if (status === "started") {
+ // In-progress games keep bucket hue but are less saturated.
+ return `hsl(${hue} 40% 58%)`;
+ }
+ return `hsl(${hue} 75% 58%)`;
+}
diff --git a/src/web/styles.css b/src/web/styles.css
new file mode 100644
index 000000000..e45300ddc
--- /dev/null
+++ b/src/web/styles.css
@@ -0,0 +1,176 @@
+:root {
+ --bg: #081018;
+ --bg-elev: #101b27;
+ --text: #e6edf3;
+ --muted: #9db1c5;
+ --accent: #4fa3ff;
+ --accent-2: #9fff7a;
+ --danger: #ff6b6b;
+ --warn: #ffd166;
+ --border: #1e3348;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
+ color: var(--text);
+ background:
+ radial-gradient(circle at 20% -20%, #163151 0%, rgba(22, 49, 81, 0) 45%),
+ radial-gradient(circle at 100% 10%, #1a3a2b 0%, rgba(26, 58, 43, 0) 40%),
+ var(--bg);
+}
+
+#app {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+.topbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ align-items: flex-end;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+
+.title {
+ margin: 0;
+ font-size: 30px;
+ letter-spacing: 0.04em;
+}
+
+.subtitle {
+ color: var(--muted);
+ margin: 6px 0 0;
+}
+
+.controls {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+select,
+input,
+button {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ color: var(--text);
+ border-radius: 8px;
+ padding: 8px 10px;
+}
+
+button {
+ cursor: pointer;
+ background: linear-gradient(160deg, #1a3550, #112336);
+}
+
+.grid {
+ display: grid;
+ gap: 12px;
+}
+
+.kpi-grid {
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+}
+
+.card {
+ background: linear-gradient(180deg, rgba(22, 36, 52, 0.95), rgba(14, 24, 36, 0.95));
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 12px;
+}
+
+.kpi-label {
+ color: var(--muted);
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.kpi-value {
+ margin-top: 8px;
+ font-size: 28px;
+ font-weight: 700;
+}
+
+.layout {
+ display: grid;
+ gap: 12px;
+ grid-template-columns: 1.3fr 1fr;
+}
+
+.wide {
+ grid-column: 1 / -1;
+}
+
+.chart {
+ width: 100%;
+ height: 280px;
+ border: 1px dashed #29435d;
+ border-radius: 8px;
+ padding: 8px;
+ overflow: hidden;
+ background: rgba(2, 10, 18, 0.4);
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+
+th,
+td {
+ text-align: left;
+ padding: 8px;
+ border-bottom: 1px solid rgba(49, 77, 106, 0.45);
+}
+
+th {
+ color: var(--muted);
+ text-transform: uppercase;
+ font-size: 11px;
+ letter-spacing: 0.07em;
+}
+
+.status-started {
+ color: var(--accent-2);
+}
+
+.status-completed {
+ color: #7fd3ff;
+}
+
+.status-did_not_start {
+ color: var(--danger);
+}
+
+.status-unknown {
+ color: var(--warn);
+}
+
+.mono {
+ font-family: "Cascadia Mono", "Consolas", monospace;
+}
+
+.pill {
+ display: inline-block;
+ border-radius: 999px;
+ border: 1px solid #315271;
+ padding: 2px 8px;
+ font-size: 12px;
+}
+
+@media (max-width: 1000px) {
+ .layout {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 000000000..00b8129b4
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "rootDir": ".",
+ "baseUrl": ".",
+ "sourceMap": true,
+ "allowSyntheticDefaultImports": true,
+ "alwaysStrict": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "types": ["node"]
+ },
+ "include": ["src/**/*", "vite.config.ts"],
+ "exclude": ["node_modules", "static", "dist"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 000000000..59be98209
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ root: "./",
+ base: "/",
+ build: {
+ outDir: "static",
+ emptyOutDir: true,
+ },
+ server: {
+ port: 9100,
+ proxy: {
+ "/api": {
+ target: "http://localhost:3100",
+ changeOrigin: true,
+ },
+ },
+ },
+});