This commit is contained in:
scamiv
2026-02-15 21:47:50 +01:00
commit 9367f285ba
19 changed files with 2798 additions and 0 deletions
+10
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
node_modules/
static/
dist/
.env
.env.local
*.log
data/db.json
data/db.backup.json
+27
View File
@@ -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"]
+79
View File
@@ -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).
+1
View File
@@ -0,0 +1 @@
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lobby Statistics</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/web/main.ts"></script>
</body>
</html>
+31
View File
@@ -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"
}
}
+245
View File
@@ -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);
});
+195
View File
@@ -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<string, LobbyRecord[]>();
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<number, TimelineBucket>();
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;
}
+53
View File
@@ -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),
),
};
}
+552
View File
@@ -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<typeof setTimeout> | null = null;
private gameInfoPollTimer: ReturnType<typeof setInterval> | null = null;
private archiveBackfillTimer: ReturnType<typeof setInterval> | null = null;
private startedPollTimer: ReturnType<typeof setInterval> | null = null;
private activeLobbyIds: Set<string> = new Set();
private closingProbeJobs: Set<string> = new Set();
private archiveAttemptCount = new Map<string, number>();
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<string>();
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<void> {
const ids = Array.from(this.activeLobbyIds);
for (const gameID of ids) {
await this.pollSingleGameInfo(gameID);
}
}
private async pollSingleGameInfo(gameID: string): Promise<void> {
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<void> {
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<GameInfoResponse | null> {
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<void> {
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<void> {
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<void> {
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<void>,
): Promise<void> {
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<void> {
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);
}
}
}
+92
View File
@@ -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(),
});
+128
View File
@@ -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);
});
+413
View File
@@ -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>): 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<typeof setTimeout> | null = null;
private constructor(
private readonly config: IngestConfig,
initialDb: DbSchema,
) {
this.db = initialDb;
}
static async open(config: IngestConfig): Promise<JsonStore> {
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<DbSchema>);
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<void> {
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<void> {
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)}`);
}
}
+245
View File
@@ -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<string, LobbyRecord>;
}
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<LobbyRecord, "maxPlayers">): number {
return Math.max(1, record.maxPlayers ?? 1);
}
export function peakFillRatio(record: Pick<LobbyRecord, "peakClients" | "maxPlayers">): number {
return record.peakClients / safeMaxPlayers(record as Pick<LobbyRecord, "maxPlayers">);
}
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;
}
+493
View File
@@ -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 = `
<section class="topbar">
<div>
<h1 class="title">Lobby Statistics</h1>
<p class="subtitle">Realtime ingest for /lobbies with lifecycle and conversion analytics</p>
</div>
<div class="controls">
<label>Bucket
<select id="bucketMode">
<option value="game_mode">Game mode</option>
<option value="game_mode_team" selected>Game mode + teams</option>
<option value="map">Map</option>
<option value="map_size">Map size + mode</option>
<option value="modifiers">Modifiers</option>
</select>
</label>
<label>Lookback (h)
<input id="lookbackHours" type="number" min="1" max="720" value="${DEFAULT_LOOKBACK_HOURS}" />
</label>
<button id="refreshBtn">Refresh</button>
<button id="autoBtn">Auto: on</button>
</div>
</section>
<section id="health"></section>
<section id="summary" class="grid kpi-grid"></section>
<section class="layout">
<article class="card">
<h3>Bucket Performance</h3>
<div id="bucketTable"></div>
</article>
<article class="card">
<h3>Timeline (Open/Close/Start)</h3>
<div id="timelineChart" class="chart"></div>
</article>
<article class="card wide">
<h3>Lobby Order Analysis</h3>
<div id="orderChart" class="chart"></div>
<div id="orderTable"></div>
</article>
<article class="card">
<h3>Games That Did Not Start</h3>
<div id="neverStarted"></div>
</article>
<article class="card">
<h3>Low Fill Starts</h3>
<div id="lowFill"></div>
</article>
</section>
`;
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<void> {
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<unknown> {
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) => `<div class="mono">${escapeHtml(note)}</div>`)
.join("")
: "";
containers.health.innerHTML = `
<div class="card">
<span class="pill">ingest: ${payload.status}</span>
<span class="pill mono">messages ${payload.messagesReceived}</span>
<span class="pill mono">reconnects ${payload.reconnectCount}</span>
<span class="pill mono">tracked ${payload.lobbiesTracked}</span>
<span class="pill mono">last update ${new Date(payload.lastUpdatedAt).toLocaleString()}</span>
<span class="pill mono">target ${payload.target.targetWsUrl}</span>
<div style="margin-top:10px;color:#9db1c5;font-size:12px;">${notes}</div>
</div>
`;
}
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]) => `
<article class="card">
<div class="kpi-label">${label}</div>
<div class="kpi-value mono">${value}</div>
</article>
`,
)
.join("");
}
function renderBucketTable(payload: AnalyticsPayload): void {
containers.bucketTable.innerHTML = `
<table>
<thead>
<tr>
<th>Bucket</th>
<th>Count</th>
<th>In Progress</th>
<th>Completed</th>
<th>Not Started</th>
<th>Avg Open(s)</th>
<th>Join/min</th>
<th>Fill@Close</th>
</tr>
</thead>
<tbody>
${payload.buckets
.slice(0, 40)
.map(
(bucket) => `
<tr>
<td class="mono">${escapeHtml(bucket.bucket)}</td>
<td>${bucket.count}</td>
<td class="status-started">${bucket.inProgress}</td>
<td class="status-completed">${bucket.completed}</td>
<td class="status-did_not_start">${bucket.notStarted}</td>
<td>${bucket.avgOpenSec.toFixed(1)}</td>
<td>${bucket.avgJoinRatePerMin.toFixed(2)}</td>
<td>${(bucket.avgFillAtClose * 100).toFixed(1)}%</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
`;
}
function renderTimeline(timeline: TimelineBucket[]): void {
if (timeline.length === 0) {
containers.timelineChart.innerHTML = "<p>No data yet.</p>";
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 `<polyline fill="none" stroke="${color}" stroke-width="2" points="${points}" />`;
};
containers.timelineChart.innerHTML = `
<svg viewBox="0 0 ${width} ${height}" width="100%" height="100%">
<rect x="0" y="0" width="${width}" height="${height}" fill="transparent"></rect>
${poly("opened", "#4fa3ff")}
${poly("closed", "#ffd166")}
${poly("started", "#9fff7a")}
<text x="${pad}" y="${pad - 8}" fill="#9db1c5" font-size="11">opened</text>
<text x="${pad + 70}" y="${pad - 8}" fill="#9db1c5" font-size="11">closed</text>
<text x="${pad + 130}" y="${pad - 8}" fill="#9db1c5" font-size="11">started</text>
</svg>
`;
}
function renderOrder(payload: AnalyticsPayload): void {
const rows = payload.order.slice(-40);
if (rows.length === 0) {
containers.orderChart.innerHTML = "<p>No data yet.</p>";
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 `
<rect x="${startX.toFixed(1)}" y="${y}" width="${(endX - startX).toFixed(1)}" height="10" fill="${color}" stroke="${statusStroke}" stroke-width="0.7" opacity="0.9">
<title>${row.gameID} | ${row.bucket} | status ${row.status} | open ${openDurationText} | game ${gameDurationText}</title>
</rect>
`;
})
.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 `
<rect x="${xPos}" y="${yPos}" width="10" height="10" fill="${color}" opacity="0.95"></rect>
<text x="${xPos + 14}" y="${yPos + 9}" fill="#d3e2ef" font-size="10">${escapeHtml(
bucket.length > 36 ? `${bucket.slice(0, 36)}...` : bucket,
)}</text>
`;
})
.join("");
containers.orderChart.innerHTML = `
<svg viewBox="0 0 ${width} ${height}" width="100%" height="100%">
<rect x="0" y="0" width="${width}" height="${height}" fill="transparent"></rect>
${legend}
${bars}
</svg>
`;
containers.orderTable.innerHTML = `
<table>
<thead>
<tr>
<th>Game</th>
<th>Bucket</th>
<th>Status</th>
<th>Lobby + Game</th>
<th>Peak Fill</th>
<th>Join/min</th>
<th>Opened</th>
</tr>
</thead>
<tbody>
${rows
.slice()
.reverse()
.map(
(row) => `
<tr>
<td class="mono">${row.gameID}</td>
<td class="mono">
<span style="display:inline-block;width:9px;height:9px;border-radius:999px;background:${colorForBucket(row.bucket, row.status)};margin-right:6px;vertical-align:middle;"></span>${escapeHtml(row.bucket)}
</td>
<td class="status-${row.status}">${row.status}</td>
<td>${formatDurationMs(row.openDurationMs)} + ${formatGameDuration(row, payload.now)}</td>
<td>${row.maxPlayers ? `${row.peakClients}/${row.maxPlayers}` : row.peakClients}</td>
<td>${row.joinRatePerMin.toFixed(2)}</td>
<td>${new Date(row.openedAt).toLocaleString()}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
`;
}
function renderInteresting(target: "neverStarted" | "lowFill", rows: LobbyRecord[]): void {
const element =
target === "neverStarted" ? containers.neverStarted : containers.lowFill;
if (rows.length === 0) {
element.innerHTML = "<p>No entries in selected window.</p>";
return;
}
element.innerHTML = `
<table>
<thead>
<tr>
<th>Game</th>
<th>Mode</th>
<th>Map</th>
<th>Peak</th>
<th>Start Fill</th>
<th>Open</th>
</tr>
</thead>
<tbody>
${rows
.slice(0, 12)
.map(
(row) => `
<tr>
<td class="mono">${row.gameID}</td>
<td>${row.gameConfig?.gameMode ?? "-"}</td>
<td>${row.gameConfig?.gameMap ?? "-"}</td>
<td>${row.maxPlayers ? `${row.peakClients}/${row.maxPlayers}` : row.peakClients}</td>
<td>${row.fillRatioAtStart !== undefined ? `${(row.fillRatioAtStart * 100).toFixed(1)}%` : "-"}</td>
<td>${formatDurationMs(row.openDurationMs)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
`;
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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%)`;
}
+176
View File
@@ -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;
}
}
+19
View File
@@ -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"]
}
+19
View File
@@ -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,
},
},
},
});