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 = ` + + + + + + + + + + + + + + + ${payload.buckets + .slice(0, 40) + .map( + (bucket) => ` + + + + + + + + + + + `, + ) + .join("")} + +
BucketCountIn ProgressCompletedNot StartedAvg Open(s)Join/minFill@Close
${escapeHtml(bucket.bucket)}${bucket.count}${bucket.inProgress}${bucket.completed}${bucket.notStarted}${bucket.avgOpenSec.toFixed(1)}${bucket.avgJoinRatePerMin.toFixed(2)}${(bucket.avgFillAtClose * 100).toFixed(1)}%
+ `; +} + +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 = ` + + + ${poly("opened", "#4fa3ff")} + ${poly("closed", "#ffd166")} + ${poly("started", "#9fff7a")} + opened + closed + started + + `; +} + +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 = ` + + + ${legend} + ${bars} + + `; + + containers.orderTable.innerHTML = ` + + + + + + + + + + + + + + ${rows + .slice() + .reverse() + .map( + (row) => ` + + + + + + + + + + `, + ) + .join("")} + +
GameBucketStatusLobby + GamePeak FillJoin/minOpened
${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()}
+ `; +} + +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 = ` + + + + + + + + + + + + + ${rows + .slice(0, 12) + .map( + (row) => ` + + + + + + + + + `, + ) + .join("")} + +
GameModeMapPeakStart FillOpen
${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)}
+ `; +} + +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, + }, + }, + }, +});