diff --git a/src/client/Transport.ts b/src/client/Transport.ts index e5311e165..a35099c11 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -18,9 +18,11 @@ import { ClientMessage, ClientPingMessage, ClientRejoinMessage, + ClientSendLiveStatsMessage, ClientSendWinnerMessage, GameConfig, Intent, + LiveStats, ServerMessage, ServerMessageSchema, Winner, @@ -152,6 +154,9 @@ export class SendWinnerEvent implements GameEvent { public readonly allPlayersStats: AllPlayersStats, ) {} } +export class SendLiveStatsEvent implements GameEvent { + constructor(public readonly stats: LiveStats) {} +} export class SendHashEvent implements GameEvent { constructor( public readonly tick: Tick, @@ -244,6 +249,7 @@ export class Transport { this.eventBus.on(PauseGameIntentEvent, (e) => this.onPauseGameIntent(e)); this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e)); + this.eventBus.on(SendLiveStatsEvent, (e) => this.onSendLiveStatsEvent(e)); this.eventBus.on(SendHashEvent, (e) => this.onSendHashEvent(e)); this.eventBus.on(CancelAttackIntentEvent, (e) => this.onCancelAttackIntentEvent(e), @@ -592,6 +598,15 @@ export class Transport { } } + private onSendLiveStatsEvent(event: SendLiveStatsEvent) { + if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { + this.sendMsg({ + type: "live_stats", + stats: event.stats, + } satisfies ClientSendLiveStatsMessage); + } + } + private onSendHashEvent(event: SendHashEvent) { if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { this.sendMsg({ diff --git a/src/client/controllers/LiveStatsController.ts b/src/client/controllers/LiveStatsController.ts new file mode 100644 index 000000000..0f1a5b00d --- /dev/null +++ b/src/client/controllers/LiveStatsController.ts @@ -0,0 +1,62 @@ +import { EventBus } from "../../core/EventBus"; +import { LiveStats, PlayerLiveStats } from "../../core/Schemas"; +import { Controller } from "../Controller"; +import { SendLiveStatsEvent } from "../Transport"; +import { GameView } from "../view"; + +// Clients each report a live stats snapshot to the server every ~10s (turns are +// 100ms), which the server reaches consensus on so the admin bot can observe a +// running game. Opt-in per game (GameConfig.liveStatsEnabled) since it adds +// per-client traffic; the admin bot sets it for tournaments. See +// GameServer.handleLiveStats. +const LIVE_STATS_INTERVAL_TURNS = 100; + +export class LiveStatsController implements Controller { + // Only report when the game opted in, and never for replays (which have no + // server to report to). + private readonly enabled: boolean; + + constructor( + private readonly game: GameView, + private readonly eventBus: EventBus, + ) { + this.enabled = + game.config().gameConfig().liveStatsEnabled === true && + !game.config().isReplay(); + } + + // Report a live snapshot of the game so the server can reach consensus and + // serve it to the admin bot. Only deterministic sim values are sent, with + // players sorted by clientID, so in-sync clients produce an identical payload + // that the server can vote on. + tick(): void { + if (!this.enabled) { + return; + } + const turn = this.game.ticks(); + if (turn <= 0 || turn % LIVE_STATS_INTERVAL_TURNS !== 0) { + return; + } + const players: PlayerLiveStats[] = this.game + .players() + .flatMap((p) => { + const clientID = p.clientID(); + if (clientID === null) { + return []; + } + return [ + { + clientID, + tilesOwned: p.numTilesOwned(), + troops: p.troops(), + gold: p.gold().toString(), + isAlive: p.isAlive(), + team: p.team(), + }, + ]; + }) + .sort((a, b) => a.clientID.localeCompare(b.clientID)); + const stats: LiveStats = { turn, players }; + this.eventBus.emit(new SendLiveStatsEvent(stats)); + } +} diff --git a/src/client/hud/GameRenderer.ts b/src/client/hud/GameRenderer.ts index 24a12315e..5fc8a5055 100644 --- a/src/client/hud/GameRenderer.ts +++ b/src/client/hud/GameRenderer.ts @@ -4,6 +4,7 @@ import { Controller } from "../Controller"; import { AttackingTroopsController } from "../controllers/AttackingTroopsController"; import { BuildPreviewController } from "../controllers/BuildPreviewController"; import { HoverHighlightController } from "../controllers/HoverHighlightController"; +import { LiveStatsController } from "../controllers/LiveStatsController"; import { SoundEffectController } from "../controllers/SoundEffectController"; import { StructureHighlightController } from "../controllers/StructureHighlightController"; import { ViewModeController } from "../controllers/ViewModeController"; @@ -293,6 +294,7 @@ export function createRenderer( userSettings, ), new HoverHighlightController(game, eventBus, transformHandler, view), + new LiveStatsController(game, eventBus), new StructureHighlightController(eventBus, view), new ViewModeController(eventBus, view), new AttackingTroopsController(game, eventBus, userSettings, view), diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 4884103df..cfcc6849d 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -94,6 +94,7 @@ export type GameConfig = z.infer; export type ClientMessage = | ClientSendWinnerMessage + | ClientSendLiveStatsMessage | ClientPingMessage | ClientIntentMessage | ClientJoinMessage @@ -122,6 +123,11 @@ export type ServerLobbyInfoMessage = z.infer< typeof ServerLobbyInfoMessageSchema >; export type ClientSendWinnerMessage = z.infer; +export type ClientSendLiveStatsMessage = z.infer< + typeof ClientSendLiveStatsSchema +>; +export type PlayerLiveStats = z.infer; +export type LiveStats = z.infer; export type ClientPingMessage = z.infer; export type ClientIntentMessage = z.infer; export type ClientJoinMessage = z.infer; @@ -279,6 +285,10 @@ export const GameConfigSchema = z.object({ disableNavMesh: z.boolean().optional(), disableAlliances: z.boolean().nullable().optional(), disableClanTags: z.boolean().optional(), + // Opt-in live game stats reporting for the admin bot. Off by default and has + // no UI — the admin bot sets it when creating tournament games, since it adds + // per-client traffic. See LiveStatsController / GameServer.handleLiveStats. + liveStatsEnabled: z.boolean().optional(), anonymizeNames: z.boolean().optional(), // While anonymizeNames is on, clientIDs the host has granted real-name // visibility to (e.g. casters / observers). Everyone else stays anonymized. @@ -693,6 +703,32 @@ export const ClientSendWinnerSchema = z.object({ allPlayersStats: AllPlayersStatsSchema, }); +// A live snapshot of one human player at a given turn. Only deterministic sim +// values are included so in-sync clients produce an identical snapshot that can +// be agreed on by majority vote. gold is a decimal string because it is a +// bigint in the engine. +export const PlayerLiveStatsSchema = z.object({ + clientID: ID, + tilesOwned: z.number().int().nonnegative(), + troops: z.number(), + gold: z.string(), + isAlive: z.boolean(), + team: z.string().nullable(), +}); + +// A full live snapshot of a running game at a given turn. Reported by clients +// (which run the sim) so the server can answer "what's happening" queries for +// the admin bot. +export const LiveStatsSchema = z.object({ + turn: z.number().int().nonnegative(), + players: PlayerLiveStatsSchema.array(), +}); + +export const ClientSendLiveStatsSchema = z.object({ + type: z.literal("live_stats"), + stats: LiveStatsSchema, +}); + export const ClientHashSchema = z.object({ type: z.literal("hash"), hash: z.number(), @@ -737,6 +773,7 @@ export const ClientRejoinMessageSchema = z.object({ export const ClientMessageSchema = z.discriminatedUnion("type", [ ClientSendWinnerSchema, + ClientSendLiveStatsSchema, ClientPingMessageSchema, ClientIntentMessageSchema, ClientJoinMessageSchema, diff --git a/src/server/AdminBotRoutes.ts b/src/server/AdminBotRoutes.ts index 4e0d472a9..0fab1ff7c 100644 --- a/src/server/AdminBotRoutes.ts +++ b/src/server/AdminBotRoutes.ts @@ -102,6 +102,24 @@ export function registerAdminBotRoutes(opts: { }); }); + // 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; diff --git a/src/server/ClientMsgRateLimiter.ts b/src/server/ClientMsgRateLimiter.ts index 99e88beb4..96b408092 100644 --- a/src/server/ClientMsgRateLimiter.ts +++ b/src/server/ClientMsgRateLimiter.ts @@ -4,7 +4,7 @@ import { ClientID } from "../core/Schemas"; const INTENTS_PER_SECOND = 10; const INTENTS_PER_MINUTE = 150; const MAX_INTENT_SIZE = 2000; -const TOTAL_BYTES = 2 * 1024 * 1024; // 2MB per client +const TOTAL_BYTES = 5 * 1024 * 1024; // 5MB per client export type RateLimitResult = "ok" | "limit" | "kick"; interface ClientBucket { diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 8ad31145c..0fa7e9d71 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -8,12 +8,15 @@ import { GameType } from "../core/game/Game"; import { ClientID, ClientMessageSchema, + ClientSendLiveStatsMessage, ClientSendWinnerMessage, GameConfig, GameInfo, GameStartInfo, GameStartInfoSchema, Intent, + LiveStats, + PlayerLiveStats, PlayerRecord, PublicGameType, ServerDesyncSchema, @@ -30,6 +33,7 @@ import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter"; import { ServerEnv } from "./ServerEnv"; +import { VoteRound } from "./VoteTally"; export enum GamePhase { Lobby = "LOBBY", Active = "ACTIVE", @@ -104,10 +108,17 @@ export class GameServer { private websockets: Set = new Set(); - private winnerVotes: Map< - string, - { winner: ClientSendWinnerMessage; ips: Set } + private winnerVotes = new VoteRound(); + + // Per-turn consensus on the live stats snapshot (see handleLiveStats). + // Tallies are keyed by turn number; an entry is removed once consensus is + // reached for that turn (or a later one) so the map stays small. + private liveStatsVotes: Map< + number, + { round: VoteRound; voters: Set } > = new Map(); + private latestLiveStats: LiveStats | null = null; + private static readonly MAX_PENDING_LIVE_STATS_ROUNDS = 20; private _hasEnded = false; @@ -615,6 +626,10 @@ export class GameServer { this.handleWinner(client, clientMsg); break; } + case "live_stats": { + this.handleLiveStats(client, clientMsg); + break; + } default: { this.log.warn(`Unknown message type: ${(clientMsg as any).type}`, { clientID: client.clientID, @@ -1309,34 +1324,114 @@ export class GameServer { // Add client vote const winnerKey = JSON.stringify(clientMsg.winner); - if (!this.winnerVotes.has(winnerKey)) { - this.winnerVotes.set(winnerKey, { ips: new Set(), winner: clientMsg }); - } - const potentialWinner = this.winnerVotes.get(winnerKey)!; - potentialWinner.ips.add(client.ip); + const activeUniqueIPs = new Set(this.activeClients.map((c) => c.ip)).size; + const votes = this.winnerVotes.add(winnerKey, clientMsg, client.ip); - const activeUniqueIPs = new Set(this.activeClients.map((c) => c.ip)); - - const ratio = `${potentialWinner.ips.size}/${activeUniqueIPs.size}`; this.log.info( - `received winner vote ${clientMsg.winner}, ${ratio} votes for this winner`, + `received winner vote ${clientMsg.winner}, ${votes}/${activeUniqueIPs} votes for this winner`, { clientID: client.clientID, }, ); - if (potentialWinner.ips.size * 2 < activeUniqueIPs.size) { + const result = this.winnerVotes.result(activeUniqueIPs); + if (result === null) { return; } // Vote succeeded - this.winner = potentialWinner.winner; + this.winner = result.value; this.log.info( - `Winner determined by ${potentialWinner.ips.size}/${activeUniqueIPs.size} active IPs`, + `Winner determined by ${result.votes}/${activeUniqueIPs} active IPs`, { - winnerKey: winnerKey, + winnerKey, }, ); this.archiveGame(); } + + // Clients each send a live stats snapshot every ~10s tagged with the turn it + // was taken at. In-sync clients produce an identical snapshot for a given + // turn, so we reach majority consensus (same IP-weighted vote as the winner) + // and keep the latest agreed snapshot for the admin bot to read. + private handleLiveStats( + client: Client, + clientMsg: ClientSendLiveStatsMessage, + ) { + if ( + this.outOfSyncClients.has(client.clientID) || + this.isKicked(client.clientID) + ) { + return; + } + const stats = clientMsg.stats; + const turn = stats.turn; + // Ignore turns we've already reached consensus on (or older ones). + if (this.latestLiveStats !== null && turn <= this.latestLiveStats.turn) { + return; + } + + let entry = this.liveStatsVotes.get(turn); + if (entry === undefined) { + entry = { round: new VoteRound(), voters: new Set() }; + this.liveStatsVotes.set(turn, entry); + this.pruneLiveStatsVotes(); + } + // One vote per client per turn. + if (entry.voters.has(client.clientID)) { + return; + } + entry.voters.add(client.clientID); + + const activeUniqueIPs = new Set(this.activeClients.map((c) => c.ip)).size; + entry.round.add(JSON.stringify(stats), stats, client.ip); + const result = entry.round.result(activeUniqueIPs); + if (result === null) { + return; + } + + this.latestLiveStats = result.value; + // This turn (and any older still-pending ones) are now settled. + for (const t of this.liveStatsVotes.keys()) { + if (t <= turn) { + this.liveStatsVotes.delete(t); + } + } + } + + // Bound the pending-vote map in case consensus is never reached for some + // turns (e.g. a persistent desync). Maps iterate in insertion order and turns + // arrive ascending, so this drops the oldest pending rounds. + private pruneLiveStatsVotes() { + while ( + this.liveStatsVotes.size > GameServer.MAX_PENDING_LIVE_STATS_ROUNDS + ) { + const oldest = this.liveStatsVotes.keys().next().value; + if (oldest === undefined) break; + this.liveStatsVotes.delete(oldest); + } + } + + // Latest majority-agreed live stats snapshot, with players enriched with + // server-authoritative info the clients don't vote on: the username and + // current connection status. null until the first consensus. + public liveStats(): { + turn: number; + players: (PlayerLiveStats & { + username: string | null; + connected: boolean; + })[]; + } | null { + if (this.latestLiveStats === null) { + return null; + } + return { + turn: this.latestLiveStats.turn, + players: this.latestLiveStats.players.map((p) => ({ + ...p, + username: this.allClients.get(p.clientID)?.username ?? null, + connected: !this.isClientDisconnected(p.clientID), + })), + }; + } } diff --git a/src/server/VoteTally.ts b/src/server/VoteTally.ts new file mode 100644 index 000000000..8ba1094b2 --- /dev/null +++ b/src/server/VoteTally.ts @@ -0,0 +1,34 @@ +// IP-weighted single-round vote used to reach consensus on a value that the +// authoritative simulation only exists for on the clients (which run the game), +// not the server. Clients each vote for a candidate value; a candidate wins once +// a strict majority of the electorate's unique IPs back it. +// +// Used both for end-of-game winner consensus and for periodic running-stats +// consensus (see GameServer). +export class VoteRound { + private candidates = new Map }>(); + + // Records a vote for `value` (identified by the stable string `key`) from + // `ip`. Repeat votes from the same IP for the same candidate are idempotent. + // Returns the candidate's unique-IP vote count after the vote. + add(key: string, value: T, ip: string): number { + let candidate = this.candidates.get(key); + if (candidate === undefined) { + candidate = { value, ips: new Set() }; + this.candidates.set(key, candidate); + } + candidate.ips.add(ip); + return candidate.ips.size; + } + + // Returns the winning value once some candidate holds a strict majority of + // `totalUniqueIPs` (votes * 2 >= total), else null. + result(totalUniqueIPs: number): { value: T; votes: number } | null { + for (const candidate of this.candidates.values()) { + if (candidate.ips.size * 2 >= totalUniqueIPs) { + return { value: candidate.value, votes: candidate.ips.size }; + } + } + return null; + } +} diff --git a/tests/server/AdminBotCreateGame.test.ts b/tests/server/AdminBotCreateGame.test.ts index f47b0f5c2..029b81c40 100644 --- a/tests/server/AdminBotCreateGame.test.ts +++ b/tests/server/AdminBotCreateGame.test.ts @@ -27,6 +27,7 @@ function captureCreateHandler() { post(path: string, ...handlers: ((req: any, res: any) => void)[]) { routes[path] = handlers[handlers.length - 1]; }, + get() {}, }; const log: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; registerAdminBotRoutes({ app, gm: {} as any, workerId: 0, log }); diff --git a/tests/server/ClientMsgRateLimiter.test.ts b/tests/server/ClientMsgRateLimiter.test.ts index 31c5a6db8..a5f5483d6 100644 --- a/tests/server/ClientMsgRateLimiter.test.ts +++ b/tests/server/ClientMsgRateLimiter.test.ts @@ -57,20 +57,20 @@ describe("ClientMsgRateLimiter", () => { }); describe("total bytes limit", () => { - it("kicks when cumulative bytes reach 2MB", () => { + it("kicks when cumulative bytes reach 5MB", () => { const limiter = new ClientMsgRateLimiter(); - const chunkSize = 512 * 1024; // 512KB - // Send 3 chunks = 1.5MB, should be ok - for (let i = 0; i < 3; i++) { + const chunkSize = 1024 * 1024; // 1MB + // Send 4 chunks = 4MB, should be ok + for (let i = 0; i < 4; i++) { expect(limiter.check(CLIENT_A, "other", chunkSize)).toBe("ok"); } - // 4th chunk pushes to 2MB, should kick + // 5th chunk pushes to 5MB, should kick expect(limiter.check(CLIENT_A, "other", chunkSize)).toBe("kick"); }); it("byte tracking is per client", () => { const limiter = new ClientMsgRateLimiter(); - const almostFull = 2 * 1024 * 1024 - 1; + const almostFull = 5 * 1024 * 1024 - 1; expect(limiter.check(CLIENT_A, "other", almostFull)).toBe("ok"); // CLIENT_B should still be fine expect(limiter.check(CLIENT_B, "other", 100)).toBe("ok"); diff --git a/tests/server/LiveStats.test.ts b/tests/server/LiveStats.test.ts new file mode 100644 index 000000000..5e519ba04 --- /dev/null +++ b/tests/server/LiveStats.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GameType } from "../../src/core/game/Game"; +import { PlayerLiveStats } from "../../src/core/Schemas"; +import { registerAdminBotRoutes } from "../../src/server/AdminBotRoutes"; +import { GameServer } from "../../src/server/GameServer"; +import { ServerEnv } from "../../src/server/ServerEnv"; + +describe("GameServer.handleLiveStats", () => { + let mockLogger: any; + + beforeEach(() => { + vi.useFakeTimers(); + mockLogger = { + child: vi.fn().mockReturnThis(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllTimers(); + }); + + function makeClient(clientID: string, ip: string, username: string) { + return { clientID, ip, persistentID: `pid-${clientID}`, username } as any; + } + + // A GameServer with three distinct-IP active clients wired up. + function gameWithClients() { + const game = new GameServer("test-game", mockLogger, Date.now(), { + gameType: GameType.Private, + } as any); + const clients = [ + makeClient("client01", "1.1.1.1", "Alice"), + makeClient("client02", "2.2.2.2", "Bob"), + makeClient("client03", "3.3.3.3", "Carol"), + ]; + (game as any).activeClients = clients; + const allClients = new Map(); + const disconnected = new Map(); + for (const c of clients) { + allClients.set(c.clientID, c); + disconnected.set(c.clientID, false); // all connected + } + (game as any).allClients = allClients; + (game as any).clientsDisconnectedStatus = disconnected; + return { game, clients }; + } + + const snapshot = (tilesOwned: number): PlayerLiveStats[] => [ + { + clientID: "client01", + tilesOwned, + troops: 5, + gold: "100", + isAlive: true, + team: null, + }, + ]; + + const report = ( + game: GameServer, + client: any, + turn: number, + players: PlayerLiveStats[], + ) => + (game as any).handleLiveStats(client, { + type: "live_stats", + stats: { turn, players }, + }); + + it("reaches consensus at a strict majority and enriches usernames", () => { + const { game, clients } = gameWithClients(); + const players = snapshot(10); + + report(game, clients[0], 100, players); + // 1 of 3 IPs -> not yet. + expect(game.liveStats()).toBeNull(); + + report(game, clients[1], 100, players); + // 2 of 3 IPs -> consensus. + expect(game.liveStats()).toEqual({ + turn: 100, + players: [{ ...players[0], username: "Alice", connected: true }], + }); + }); + + it("reports server-side connection status per player", () => { + const { game, clients } = gameWithClients(); + // client01 (the only player in the snapshot) has dropped. + (game as any).clientsDisconnectedStatus.set("client01", true); + const players = snapshot(10); + report(game, clients[0], 100, players); + report(game, clients[1], 100, players); + expect(game.liveStats()?.players[0].connected).toBe(false); + }); + + it("does not reach consensus when clients disagree", () => { + const { game, clients } = gameWithClients(); + report(game, clients[0], 100, snapshot(10)); + report(game, clients[1], 100, snapshot(20)); + report(game, clients[2], 100, snapshot(30)); + expect(game.liveStats()).toBeNull(); + }); + + it("ignores a second vote from the same client in a turn", () => { + const { game, clients } = gameWithClients(); + report(game, clients[0], 100, snapshot(10)); + // Same client trying to back a different snapshot is ignored, so neither + // candidate can reach a majority from this one client. + report(game, clients[0], 100, snapshot(20)); + report(game, clients[1], 100, snapshot(20)); + expect(game.liveStats()).toBeNull(); + }); + + it("ignores stats for a turn already settled", () => { + const { game, clients } = gameWithClients(); + const players = snapshot(10); + report(game, clients[0], 100, players); + report(game, clients[1], 100, players); + expect(game.liveStats()?.turn).toBe(100); + + // Late/old turns must not overwrite the latest snapshot. + report(game, clients[0], 50, snapshot(99)); + report(game, clients[1], 50, snapshot(99)); + expect(game.liveStats()?.turn).toBe(100); + }); + + it("advances to a newer turn once it reaches consensus", () => { + const { game, clients } = gameWithClients(); + report(game, clients[0], 100, snapshot(10)); + report(game, clients[1], 100, snapshot(10)); + expect(game.liveStats()?.turn).toBe(100); + + report(game, clients[0], 200, snapshot(42)); + report(game, clients[1], 200, snapshot(42)); + expect(game.liveStats()).toEqual({ + turn: 200, + players: [{ ...snapshot(42)[0], username: "Alice", connected: true }], + }); + }); + + it("ignores out-of-sync clients", () => { + const { game, clients } = gameWithClients(); + (game as any).outOfSyncClients = new Set(["client01"]); + report(game, clients[0], 100, snapshot(10)); + report(game, clients[1], 100, snapshot(10)); + // Only client02's vote counted (1 of 3) -> no consensus. + expect(game.liveStats()).toBeNull(); + }); +}); + +function mockRes() { + const res: any = { + statusCode: 200, + body: undefined, + status(code: number) { + this.statusCode = code; + return this; + }, + json(body: unknown) { + this.body = body; + return this; + }, + }; + return res; +} + +// Capture the GET handler registered for the stats route, bypassing the +// requireAdminBotKey middleware (tested separately). +function captureStatsHandler(gm: unknown) { + const routes: Record void> = {}; + const app: any = { + post() {}, + get(path: string, ...handlers: ((req: any, res: any) => void)[]) { + routes[path] = handlers[handlers.length - 1]; + }, + }; + const log: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + registerAdminBotRoutes({ app, gm: gm as any, workerId: 0, log }); + return routes["/api/adminbot/game/:id/stats"]; +} + +describe("admin bot stats endpoint", () => { + beforeEach(() => { + vi.spyOn(ServerEnv, "workerIndex").mockReturnValue(0); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns the game's live stats", () => { + const liveStats = { + turn: 100, + players: [ + { + clientID: "client01", + tilesOwned: 10, + troops: 5, + gold: "100", + isAlive: true, + team: null, + username: "Alice", + connected: true, + }, + ], + }; + const gm = { game: () => ({ liveStats: () => liveStats }) }; + const handler = captureStatsHandler(gm); + const res = mockRes(); + handler({ params: { id: "abcdABCD" } }, res); + expect(res.statusCode).toBe(200); + expect(res.body.gameID).toBe("abcdABCD"); + expect(res.body.liveStats).toEqual(liveStats); + }); + + it("404s when the game is not found", () => { + const gm = { game: () => null }; + const handler = captureStatsHandler(gm); + const res = mockRes(); + handler({ params: { id: "abcdABCD" } }, res); + expect(res.statusCode).toBe(404); + }); + + it("400s on an invalid game id", () => { + const gm = { game: () => null }; + const handler = captureStatsHandler(gm); + const res = mockRes(); + handler({ params: { id: "bad" } }, res); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/server/VoteTally.test.ts b/tests/server/VoteTally.test.ts new file mode 100644 index 000000000..41868762a --- /dev/null +++ b/tests/server/VoteTally.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { VoteRound } from "../../src/server/VoteTally"; + +describe("VoteRound", () => { + it("returns null until a candidate has a strict majority of IPs", () => { + const round = new VoteRound(); + round.add("a", "a", "1.1.1.1"); + // 1 of 3 unique IPs -> no majority yet. + expect(round.result(3)).toBeNull(); + round.add("a", "a", "2.2.2.2"); + // 2 of 3 -> majority (2 * 2 >= 3). + expect(round.result(3)).toEqual({ value: "a", votes: 2 }); + }); + + it("counts each IP once per candidate", () => { + const round = new VoteRound(); + expect(round.add("a", "a", "1.1.1.1")).toBe(1); + expect(round.add("a", "a", "1.1.1.1")).toBe(1); + // Still a single IP, so no majority of a 3-IP electorate. + expect(round.result(3)).toBeNull(); + }); + + it("tallies competing candidates independently", () => { + const round = new VoteRound(); + round.add("a", "a", "1.1.1.1"); + round.add("b", "b", "2.2.2.2"); + round.add("a", "a", "3.3.3.3"); + // 'a' has 2 of 4 IPs (2 * 2 >= 4), 'b' has 1. + expect(round.result(4)).toEqual({ value: "a", votes: 2 }); + }); + + it("accepts with exactly half the electorate (ties pass)", () => { + const round = new VoteRound(); + round.add("a", "a", "1.1.1.1"); + expect(round.result(2)).toEqual({ value: "a", votes: 1 }); + }); +});