mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
init
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
static/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
data/db.json
|
||||
data/db.backup.json
|
||||
+27
@@ -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"]
|
||||
@@ -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).
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
+12
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, "&")
|
||||
.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%)`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user