mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 10:12:02 +00:00
181368f962
## Summary The game simulation runs **client-side**, so the server can't directly see what's happening in a running game. This adds a way for the admin bot to observe a live game: clients report a live stats snapshot every ~10s, the server reaches consensus on it (reusing the winner's vote mechanism), and a new admin-bot endpoint serves it. ## How it works 1. **`LiveStatsController`** (client) emits a snapshot every **100 turns** (~10s at 100ms/turn) — only deterministic sim values, with players sorted by clientID, so in-sync clients produce an identical payload. 2. The snapshot is sent as a new **`live_stats`** wire message wrapping a `LiveStats` object (`turn` + per-human-player `tilesOwned`/`troops`/`gold`/`isAlive`/`team`). 3. **`GameServer.handleLiveStats`** tallies a per-turn **IP-weighted majority vote** — the same consensus the winner uses — and keeps the latest agreed snapshot. 4. **`GET /api/adminbot/game/:id/stats`** returns it, enriched with usernames the server already holds. `liveStats` is `null` until the first consensus. The winner's vote tally was extracted into a small reusable **`VoteRound`** (`src/server/VoteTally.ts`) and is now used for both winner and live-stats consensus. Names are deliberately **excluded** from the voted payload (they vary per client under name anonymization, which would break exact-match consensus); the server joins `clientID → username` instead. ## Changes - `src/server/VoteTally.ts` *(new)* — reusable IP-weighted `VoteRound` - `src/core/Schemas.ts` — `PlayerLiveStatsSchema`, `LiveStatsSchema`, `ClientSendLiveStatsSchema` + unions - `src/client/controllers/LiveStatsController.ts` *(new)* — per-100-turn snapshot reporter - `src/client/Transport.ts` — `SendLiveStatsEvent` + sender - `src/client/hud/GameRenderer.ts` — register the controller - `src/server/GameServer.ts` — refactor winner onto `VoteRound`; add live-stats consensus + `liveStats()` accessor - `src/server/AdminBotRoutes.ts` — `GET …/stats` endpoint ## Testing - **Unit:** `tests/server/VoteTally.test.ts` (majority/dedup/ties), `tests/server/LiveStats.test.ts` (consensus, disagreement, per-client dedup, stale-turn rejection, turn advance, out-of-sync exclusion, + endpoint 200/404/400). Full suite green (`npm test`), typecheck + lint clean. - **Manual e2e** against the dev server: created an admin-bot game, joined it in a browser, force-started via `toggle_game_start_timer`, and confirmed `GET …/stats` returned the consensus snapshot with username enrichment and an advancing `turn`. Also verified wrong-worker → 400 and missing-key → 401. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
150 lines
4.7 KiB
TypeScript
150 lines
4.7 KiB
TypeScript
import crypto from "crypto";
|
|
import type {
|
|
Express,
|
|
NextFunction,
|
|
Request,
|
|
RequestHandler,
|
|
Response,
|
|
} from "express";
|
|
import type { Logger } from "winston";
|
|
import { z } from "zod";
|
|
import { GameType } from "../core/game/Game";
|
|
import {
|
|
ADMIN_BOT_CLIENT_ID,
|
|
GameConfigSchema,
|
|
ID,
|
|
IntentSchema,
|
|
} from "../core/Schemas";
|
|
import type { GameManager } from "./GameManager";
|
|
import { ServerEnv } from "./ServerEnv";
|
|
|
|
function timingSafeEqualStr(a: string, b: string): boolean {
|
|
const ab = Buffer.from(a);
|
|
const bb = Buffer.from(b);
|
|
if (ab.length !== bb.length) return false;
|
|
return crypto.timingSafeEqual(ab, bb);
|
|
}
|
|
|
|
// Gate for the admin bot HTTP API. 404 when the feature is disabled (key unset)
|
|
// so the routes aren't advertised; 401 on a missing/incorrect key.
|
|
export const requireAdminBotKey: RequestHandler = (
|
|
req: Request,
|
|
res: Response,
|
|
next: NextFunction,
|
|
) => {
|
|
const expected = ServerEnv.adminBotKey();
|
|
if (expected === undefined) {
|
|
res.status(404).end();
|
|
return;
|
|
}
|
|
const provided = req.headers[ServerEnv.adminBotHeader()];
|
|
if (typeof provided !== "string" || !timingSafeEqualStr(provided, expected)) {
|
|
res.status(401).json({ error: "Unauthorized" });
|
|
return;
|
|
}
|
|
next();
|
|
};
|
|
|
|
export function registerAdminBotRoutes(opts: {
|
|
app: Express;
|
|
gm: GameManager;
|
|
workerId: number;
|
|
log: Logger;
|
|
}) {
|
|
const { app, gm, workerId, log } = opts;
|
|
|
|
// Validate game id format and that this worker owns it. Returns false and
|
|
// sends the error response when the id is bad/misrouted.
|
|
const ownsGame = (id: string, res: Response): boolean => {
|
|
if (!ID.safeParse(id).success) {
|
|
res.status(400).json({ error: "Invalid game ID" });
|
|
return false;
|
|
}
|
|
if (ServerEnv.workerIndex(id) !== workerId) {
|
|
res.status(400).json({ error: "Worker, game id mismatch" });
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
// Create a private game. The worker mints a self-owned id and returns it, so
|
|
// the bot doesn't need to know the sharding. nginx (and the vite dev proxy)
|
|
// randomly route here to spread new games across workers.
|
|
app.post("/api/adminbot/create_game", requireAdminBotKey, (req, res) => {
|
|
const parsed = GameConfigSchema.partial().safeParse(req.body ?? {});
|
|
if (!parsed.success) {
|
|
return res.status(400).json({ error: z.prettifyError(parsed.error) });
|
|
}
|
|
const config = parsed.data;
|
|
// Private only: reject Public and Singleplayer. An omitted gameType defaults
|
|
// to Private in createGame, so it's allowed through.
|
|
if (config.gameType !== undefined && config.gameType !== GameType.Private) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "admin bot can only create private games" });
|
|
}
|
|
|
|
const id = ServerEnv.generateGameIdForWorker(workerId);
|
|
if (id === null) {
|
|
log.warn(`admin bot: failed to mint game id on worker ${workerId}`);
|
|
return res.status(500).json({ error: "Could not allocate game id" });
|
|
}
|
|
|
|
const game = gm.createGame(id, config, undefined);
|
|
if (game === null) {
|
|
return res.status(409).json({ error: "Game ID already exists" });
|
|
}
|
|
log.info(`admin bot created game ${id}`);
|
|
res.json({
|
|
...game.gameInfo(),
|
|
workerIndex: workerId,
|
|
workerPath: ServerEnv.workerPath(id),
|
|
});
|
|
});
|
|
|
|
// Read what's happening in a running game. The sim runs on the clients, so
|
|
// this returns the latest live stats snapshot a majority of them agreed on
|
|
// (liveStats is null until the first consensus is reached).
|
|
app.get("/api/adminbot/game/:id/stats", requireAdminBotKey, (req, res) => {
|
|
const id = req.params.id as string;
|
|
if (!ownsGame(id, res)) return;
|
|
|
|
const game = gm.game(id);
|
|
if (game === null) {
|
|
return res.status(404).json({ error: "Game not found" });
|
|
}
|
|
|
|
res.json({
|
|
gameID: id,
|
|
liveStats: game.liveStats(),
|
|
});
|
|
});
|
|
|
|
// Send an intent. Honors the lobby-management intents; everything else 400.
|
|
app.post("/api/adminbot/game/:id/intent", requireAdminBotKey, (req, res) => {
|
|
const id = req.params.id as string;
|
|
if (!ownsGame(id, res)) return;
|
|
|
|
const parsed = IntentSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
return res.status(400).json({ error: z.prettifyError(parsed.error) });
|
|
}
|
|
const game = gm.game(id);
|
|
if (game === null) {
|
|
return res.status(404).json({ error: "Game not found" });
|
|
}
|
|
|
|
const result = game.handleIntent(parsed.data, {
|
|
clientID: ADMIN_BOT_CLIENT_ID,
|
|
isLobbyCreator: false,
|
|
isAdmin: true,
|
|
isAdminBot: true,
|
|
});
|
|
if (result.status !== 200) {
|
|
return res.status(result.status).json({ error: result.error ?? "error" });
|
|
}
|
|
log.info(`admin bot intent ${parsed.data.type} on game ${id}`);
|
|
res.json(game.gameInfo());
|
|
});
|
|
}
|