From 9ada38f65b9a64b048296b6ee9b195236bdccc1f Mon Sep 17 00:00:00 2001 From: Radomir <116832305+Radomir-Aksenenko@users.noreply.github.com> Date: Mon, 25 May 2026 17:47:10 +0700 Subject: [PATCH 1/9] fix: prevent fake winner stats and auth-gate singleplayer archive Two attack vectors closed: 1. allPlayersStats in ClientSendWinnerMessage was fully client-controlled, allowing a modified client (PoC browser extension confirmed) to archive arbitrary stats for any player account in multiplayer games. Fix: GameServer now ignores allPlayersStats entirely; Transport no longer sends it in multiplayer winner messages; the field is marked optional in the schema so LocalServer (offline singleplayer) continues to work. 2. POST /api/archive_singleplayer_game accepted game records with any persistentID without authentication, allowing unauthenticated clients to submit fake records for other players' accounts. Fix: endpoint now requires a valid JWT (Bearer token); the persistentID in the record must match the token owner. Co-Authored-By: Claude Sonnet 4.5 --- src/client/LocalServer.ts | 2 +- src/client/Transport.ts | 4 +++- src/core/Schemas.ts | 6 +++++- src/server/GameServer.ts | 20 ++++++++++---------- src/server/Worker.ts | 28 ++++++++++++++++++++++++++++ 5 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 0304224bff..e9ecfa904c 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -228,7 +228,7 @@ export class LocalServer { } if (clientMsg.type === "winner") { this.winner = clientMsg; - this.allPlayersStats = clientMsg.allPlayersStats; + this.allPlayersStats = clientMsg.allPlayersStats ?? {}; } } diff --git a/src/client/Transport.ts b/src/client/Transport.ts index fee60b966c..36c1eab406 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -574,10 +574,12 @@ export class Transport { private onSendWinnerEvent(event: SendWinnerEvent) { if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { + // allPlayersStats is intentionally omitted from multiplayer messages: + // the server ignores client-reported stats to prevent fake stat injection. + // LocalServer handles stats separately for offline singleplayer. this.sendMsg({ type: "winner", winner: event.winner, - allPlayersStats: event.allPlayersStats, } satisfies ClientSendWinnerMessage); } else { console.log( diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 3b18c9656b..4245af0b61 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -641,7 +641,11 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [ export const ClientSendWinnerSchema = z.object({ type: z.literal("winner"), winner: WinnerSchema, - allPlayersStats: AllPlayersStatsSchema, + // allPlayersStats is optional and intentionally ignored by the multiplayer + // server (GameServer.handleWinner). It is only used by LocalServer for + // offline singleplayer archiving. Clients must not rely on the server + // accepting or trusting this field in multiplayer. + allPlayersStats: AllPlayersStatsSchema.optional(), }); export const ClientHashSchema = z.object({ diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index dcc68c620f..8b7ffd8374 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -23,6 +23,7 @@ import { ServerTurnMessage, StampedIntent, Turn, + Winner, } from "../core/Schemas"; import { createPartialGameRecord } from "../core/Util"; import { archive, finalizeGameRecord } from "./Archive"; @@ -66,7 +67,7 @@ export class GameServer { private lastPingUpdate = 0; - private winner: ClientSendWinnerMessage | null = null; + private winner: Winner | null = null; // Note: This can be undefined if accessed before the game starts. private gameStartInfo!: GameStartInfo; @@ -88,7 +89,7 @@ export class GameServer { private winnerVotes: Map< string, - { winner: ClientSendWinnerMessage; ips: Set } + { winner: Winner; ips: Set } > = new Map(); private _hasEnded = false; @@ -1095,23 +1096,22 @@ export class GameServer { private archiveGame() { this.log.info("archiving game", { gameID: this.id, - winner: this.winner?.winner, + winner: this.winner, }); // Players must stay in the same order as the game start info. + // Stats are intentionally left undefined: client-reported allPlayersStats + // were removed from ClientSendWinnerMessage to prevent fake stat injection. + // A future server-side stats tracker should populate this field instead. const playerRecords: PlayerRecord[] = this.gameStartInfo.players.map( (player) => { - const stats = this.winner?.allPlayersStats[player.clientID]; - if (stats === undefined) { - this.log.warn(`Unable to find stats for clientID ${player.clientID}`); - } return { clientID: player.clientID, username: player.username, clanTag: player.clanTag, persistentID: this.allClients.get(player.clientID)?.persistentID ?? "", - stats, + stats: undefined, cosmetics: player.cosmetics, } satisfies PlayerRecord; }, @@ -1125,7 +1125,7 @@ export class GameServer { this.turns, this._startTime ?? 0, Date.now(), - this.winner?.winner, + this.winner ?? undefined, this.createdAt, this.visibleAt, ), @@ -1248,7 +1248,7 @@ 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 }); + this.winnerVotes.set(winnerKey, { ips: new Set(), winner: clientMsg.winner }); } const potentialWinner = this.winnerVotes.get(winnerKey)!; potentialWinner.ips.add(client.ip); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 59626396a3..bb2ff2f06d 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -233,6 +233,24 @@ export async function startWorker() { app.post("/api/archive_singleplayer_game", async (req, res) => { try { + // Require a valid JWT so only the actual player can archive their own game. + // Without this, any unauthenticated client could submit fake records for + // arbitrary persistentIDs (see security audit finding #1). + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + log.warn("archive_singleplayer_game: missing Authorization header"); + return res.status(401).json({ error: "Unauthorized" }); + } + const tokenResult = await verifyClientToken( + authHeader.substring("Bearer ".length), + ); + if (tokenResult.type === "error") { + log.warn( + `archive_singleplayer_game: invalid token — ${tokenResult.message}`, + ); + return res.status(401).json({ error: "Unauthorized" }); + } + const record = req.body; const result = PartialGameRecordSchema.safeParse(record); @@ -260,6 +278,16 @@ export async function startWorker() { return res.status(400).json({ error: "Invalid request" }); } + // Ensure the token owner matches the player in the record. + const recordPersistentID = result.data.info.players[0]?.persistentID; + if (recordPersistentID !== tokenResult.persistentId) { + log.warn( + `archive_singleplayer_game: persistentID mismatch — token: ${tokenResult.persistentId?.substring(0, 8)}, record: ${recordPersistentID?.substring(0, 8)}`, + { gameID: gameRecord.info.gameID }, + ); + return res.status(403).json({ error: "Forbidden" }); + } + log.info("archiving singleplayer game", { gameID: gameRecord.info.gameID, }); From 602ba1243cd6a6a61957406c02512123dc4e4298 Mon Sep 17 00:00:00 2001 From: Radomir <116832305+Radomir-Aksenenko@users.noreply.github.com> Date: Mon, 25 May 2026 17:54:39 +0700 Subject: [PATCH 2/9] fix: send JWT auth header when archiving singleplayer game LocalServer.endGame() now attaches the Authorization: Bearer token to the POST /api/archive_singleplayer_game request, which the endpoint now requires after the security hardening in the previous commit. If the user is not logged in the header is omitted gracefully. Co-Authored-By: Claude Sonnet 4.6 --- src/client/LocalServer.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index e9ecfa904c..c726b1595d 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -18,7 +18,7 @@ import { decompressGameRecord, replacer, } from "../core/Util"; -import { getPersistentID } from "./Auth"; +import { getAuthHeader, getPersistentID } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; import { GameSpeedDownIntentEvent, @@ -303,14 +303,18 @@ export class LocalServer { const jsonString = JSON.stringify(result.data, replacer); - compress(jsonString) - .then((compressedData) => { + Promise.all([compress(jsonString), getAuthHeader()]) + .then(([compressedData, authHeader]) => { + const headers: HeadersInit = { + "Content-Type": "application/json", + "Content-Encoding": "gzip", + }; + if (authHeader) { + headers["Authorization"] = authHeader; + } return fetch(`/${workerPath}/api/archive_singleplayer_game`, { method: "POST", - headers: { - "Content-Type": "application/json", - "Content-Encoding": "gzip", - }, + headers, body: compressedData, keepalive: true, // Ensures request completes even if page unloads }); From 3e07e25b032fb8b82261632ce2db8130fb30c12d Mon Sep 17 00:00:00 2001 From: Radomir <116832305+Radomir-Aksenenko@users.noreply.github.com> Date: Mon, 25 May 2026 18:16:04 +0700 Subject: [PATCH 3/9] style: fix Prettier formatting in GameServer.ts Co-Authored-By: Claude Sonnet 4.6 --- src/server/GameServer.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 8b7ffd8374..7c31df9d93 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -87,10 +87,8 @@ export class GameServer { private websockets: Set = new Set(); - private winnerVotes: Map< - string, - { winner: Winner; ips: Set } - > = new Map(); + private winnerVotes: Map }> = + new Map(); private _hasEnded = false; @@ -1248,7 +1246,10 @@ 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.winner }); + this.winnerVotes.set(winnerKey, { + ips: new Set(), + winner: clientMsg.winner, + }); } const potentialWinner = this.winnerVotes.get(winnerKey)!; potentialWinner.ips.add(client.ip); From 2096ab7c66be54d2bab6e55de20a8d598e8846f7 Mon Sep 17 00:00:00 2001 From: Radomir <116832305+Radomir-Aksenenko@users.noreply.github.com> Date: Mon, 25 May 2026 18:19:31 +0700 Subject: [PATCH 4/9] test: add ClientSendWinnerSchema validation tests Verify that the winner message schema accepts/rejects correct inputs and that allPlayersStats is optional (not required on the wire). Co-Authored-By: Claude Sonnet 4.6 --- tests/ClientSendWinnerSchema.test.ts | 62 ++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/ClientSendWinnerSchema.test.ts diff --git a/tests/ClientSendWinnerSchema.test.ts b/tests/ClientSendWinnerSchema.test.ts new file mode 100644 index 0000000000..4dac5df192 --- /dev/null +++ b/tests/ClientSendWinnerSchema.test.ts @@ -0,0 +1,62 @@ +import { ClientSendWinnerSchema } from "../src/core/Schemas"; + +describe("ClientSendWinnerSchema", () => { + // ID must match /^[A-Za-z0-9]{8}$/ + const id1 = "AAAAAAAA"; + const id2 = "BBBBBBBB"; + const validWinner = ["player", id1, id2] as const; + + test("accepts a winner message without allPlayersStats", () => { + const result = ClientSendWinnerSchema.safeParse({ + type: "winner", + winner: validWinner, + }); + expect(result.success).toBe(true); + }); + + test("accepts a winner message with allPlayersStats (singleplayer path)", () => { + const result = ClientSendWinnerSchema.safeParse({ + type: "winner", + winner: validWinner, + allPlayersStats: { + [id1]: {}, + }, + }); + expect(result.success).toBe(true); + }); + + test("accepts a winner message with undefined winner (draw)", () => { + const result = ClientSendWinnerSchema.safeParse({ + type: "winner", + winner: undefined, + }); + expect(result.success).toBe(true); + }); + + test("rejects a message with wrong type", () => { + const result = ClientSendWinnerSchema.safeParse({ + type: "not_winner", + winner: validWinner, + }); + expect(result.success).toBe(false); + }); + + test("rejects a message with invalid winner format", () => { + const result = ClientSendWinnerSchema.safeParse({ + type: "winner", + winner: "invalid", + }); + expect(result.success).toBe(false); + }); + + test("allPlayersStats is stripped / ignored when not provided", () => { + const result = ClientSendWinnerSchema.safeParse({ + type: "winner", + winner: validWinner, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.allPlayersStats).toBeUndefined(); + } + }); +}); From db6aff4dd2c534c6d37467a6398cc0b92c37da4f Mon Sep 17 00:00:00 2001 From: Radomir <116832305+Radomir-Aksenenko@users.noreply.github.com> Date: Mon, 25 May 2026 18:32:04 +0700 Subject: [PATCH 5/9] fix: decouple auth lookup from archive upload in LocalServer If getAuthHeader() rejects (e.g. token refresh network error), the upload must still proceed rather than being silently aborted. Compress first, then resolve auth with .catch(() => "") so a failed auth only results in an unauthenticated request, not a dropped upload. Co-Authored-By: Claude Sonnet 4.6 --- src/client/LocalServer.ts | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index c726b1595d..c8f9dbdd27 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -303,21 +303,28 @@ export class LocalServer { const jsonString = JSON.stringify(result.data, replacer); - Promise.all([compress(jsonString), getAuthHeader()]) - .then(([compressedData, authHeader]) => { - const headers: HeadersInit = { - "Content-Type": "application/json", - "Content-Encoding": "gzip", - }; - if (authHeader) { - headers["Authorization"] = authHeader; - } - return fetch(`/${workerPath}/api/archive_singleplayer_game`, { - method: "POST", - headers, - body: compressedData, - keepalive: true, // Ensures request completes even if page unloads - }); + compress(jsonString) + .then((compressedData) => { + // Auth lookup must not abort the upload on failure (e.g. network error + // during token refresh). Resolve to an empty string so the fetch still + // proceeds unauthenticated and the server can return 401 gracefully. + return getAuthHeader() + .catch(() => "") + .then((authHeader) => { + const headers: HeadersInit = { + "Content-Type": "application/json", + "Content-Encoding": "gzip", + }; + if (authHeader) { + headers["Authorization"] = authHeader; + } + return fetch(`/${workerPath}/api/archive_singleplayer_game`, { + method: "POST", + headers, + body: compressedData, + keepalive: true, // Ensures request completes even if page unloads + }); + }); }) .catch((error) => { console.error("Failed to archive singleplayer game:", error); From ef4d5e607ff31dae5f2071834fac0c28677b365c Mon Sep 17 00:00:00 2001 From: Radomir <116832305+Radomir-Aksenenko@users.noreply.github.com> Date: Sun, 31 May 2026 20:31:03 +0700 Subject: [PATCH 6/9] fix: skip singleplayer archive for anonymous users; add security test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocalServer: skip archive silently when no JWT instead of sending a request that would be rejected with 401. Anonymous singleplayer archiving is intentionally not supported — the endpoint requires auth to prevent spoofing. - ClientSendWinnerSchema.test: rename misleading "stripped/ignored" test to clarify the field is simply absent when not provided by the sender. - tests/server/WinnerSecurity: new test verifying that the server archives stats: undefined regardless of client-supplied allPlayersStats. Co-Authored-By: Claude Sonnet 4.6 --- src/client/LocalServer.ts | 40 ++++----- tests/ClientSendWinnerSchema.test.ts | 2 +- tests/server/WinnerSecurity.test.ts | 129 +++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 23 deletions(-) create mode 100644 tests/server/WinnerSecurity.test.ts diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index c8f9dbdd27..5840adceab 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -303,28 +303,24 @@ export class LocalServer { const jsonString = JSON.stringify(result.data, replacer); - compress(jsonString) - .then((compressedData) => { - // Auth lookup must not abort the upload on failure (e.g. network error - // during token refresh). Resolve to an empty string so the fetch still - // proceeds unauthenticated and the server can return 401 gracefully. - return getAuthHeader() - .catch(() => "") - .then((authHeader) => { - const headers: HeadersInit = { - "Content-Type": "application/json", - "Content-Encoding": "gzip", - }; - if (authHeader) { - headers["Authorization"] = authHeader; - } - return fetch(`/${workerPath}/api/archive_singleplayer_game`, { - method: "POST", - headers, - body: compressedData, - keepalive: true, // Ensures request completes even if page unloads - }); - }); + getAuthHeader() + .then(async (authHeader) => { + // Anonymous users have no JWT. The archive endpoint requires auth to + // prevent spoofing, so skip silently rather than sending a request + // that would be rejected with 401. Logged-in singleplayer archiving + // is intentionally preserved; anonymous singleplayer is not archived. + if (!authHeader) return; + const compressedData = await compress(jsonString); + return fetch(`/${workerPath}/api/archive_singleplayer_game`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Encoding": "gzip", + Authorization: authHeader, + }, + body: compressedData, + keepalive: true, + }); }) .catch((error) => { console.error("Failed to archive singleplayer game:", error); diff --git a/tests/ClientSendWinnerSchema.test.ts b/tests/ClientSendWinnerSchema.test.ts index 4dac5df192..55760107d3 100644 --- a/tests/ClientSendWinnerSchema.test.ts +++ b/tests/ClientSendWinnerSchema.test.ts @@ -49,7 +49,7 @@ describe("ClientSendWinnerSchema", () => { expect(result.success).toBe(false); }); - test("allPlayersStats is stripped / ignored when not provided", () => { + test("allPlayersStats is absent (undefined) when not provided by sender", () => { const result = ClientSendWinnerSchema.safeParse({ type: "winner", winner: validWinner, diff --git a/tests/server/WinnerSecurity.test.ts b/tests/server/WinnerSecurity.test.ts new file mode 100644 index 0000000000..84ea03a68e --- /dev/null +++ b/tests/server/WinnerSecurity.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/core/Schemas", async () => { + const actual = (await vi.importActual("../../src/core/Schemas")) as any; + return { + ...actual, + GameStartInfoSchema: { + safeParse: (data: any) => ({ success: true, data: data }), + }, + ServerPrestartMessageSchema: { + safeParse: (data: any) => ({ success: true, data: data }), + }, + ClientMessageSchema: { + safeParse: (data: any) => ({ success: true, data: data }), + }, + }; +}); + +vi.mock("../../src/server/Archive", () => ({ + archive: vi.fn(), + finalizeGameRecord: (r: any) => r, +})); + +import { GameType } from "../../src/core/game/Game"; +import { Client } from "../../src/server/Client"; +import { archive } from "../../src/server/Archive"; +import { GameServer } from "../../src/server/GameServer"; + +function makeMockWs(ip = "1.2.3.4") { + const handlers: Record any> = {}; + return { + on: (event: string, handler: (...args: any[]) => any) => { + handlers[event] = handler; + }, + removeAllListeners: (_event: string) => {}, + send: vi.fn(), + close: vi.fn(), + readyState: 1, + trigger: (event: string, ...args: any[]) => handlers[event]?.(...args), + }; +} + +function makeClient( + clientID: string, + persistentID: string, + ip = "1.2.3.4", +): { client: Client; ws: ReturnType } { + const ws = makeMockWs(ip); + const client = new Client( + clientID, + persistentID, + null, + null, + undefined, + ip, + "TestUser", + null, + ws as any, + undefined, + undefined, + [], + ); + return { client, ws }; +} + +describe("GameServer - winner message security", () => { + 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(); + }); + + it("archives with undefined stats regardless of client-supplied allPlayersStats", async () => { + const game = new GameServer( + "test-game", + mockLogger, + Date.now(), + { gameType: GameType.Private } as any, + ); + + const { client: c1, ws: ws1 } = makeClient("cid-1", "pid-1", "1.2.3.4"); + const { client: c2, ws: ws2 } = makeClient("cid-2", "pid-2", "5.6.7.8"); + game.joinClient(c1); + game.joinClient(c2); + game.start(); + + // Both clients vote for the same winner and attach fabricated stats. + // Majority threshold is met so archiveGame() is triggered. + const fabricatedStats = { + "cid-1": { kills: 9999, gold: 9999 } as any, + "cid-2": { kills: 9999, gold: 9999 } as any, + }; + + await ws1.trigger( + "message", + JSON.stringify({ + type: "winner", + winner: ["player", "cid-1"], + allPlayersStats: fabricatedStats, + }), + ); + await ws2.trigger( + "message", + JSON.stringify({ + type: "winner", + winner: ["player", "cid-1"], + allPlayersStats: fabricatedStats, + }), + ); + + expect(archive).toHaveBeenCalledOnce(); + const archivedRecord = (archive as ReturnType).mock + .calls[0][0]; + for (const player of archivedRecord.info.players) { + expect(player.stats).toBeUndefined(); + } + }); +}); From 237808bf2602c40f668d10d29f6aae834ea79d7d Mon Sep 17 00:00:00 2001 From: Radomir <116832305+Radomir-Aksenenko@users.noreply.github.com> Date: Sun, 31 May 2026 20:34:42 +0700 Subject: [PATCH 7/9] fix: keep allPlayersStats for local games in onSendWinnerEvent The previous commit stripped allPlayersStats from all winner messages, but LocalServer.endGame() relies on it as the sole source of per-player stats for singleplayer archiving. Only strip it on the multiplayer path (socket open); preserve it when isLocal so archived singleplayer records retain the player's stats. Co-Authored-By: Claude Sonnet 4.6 --- src/client/Transport.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 36c1eab406..c6ab6b568e 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -574,13 +574,18 @@ export class Transport { private onSendWinnerEvent(event: SendWinnerEvent) { if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { - // allPlayersStats is intentionally omitted from multiplayer messages: - // the server ignores client-reported stats to prevent fake stat injection. - // LocalServer handles stats separately for offline singleplayer. - this.sendMsg({ - type: "winner", - winner: event.winner, - } satisfies ClientSendWinnerMessage); + // allPlayersStats is stripped on the multiplayer path: the server does + // not trust client-reported stats (prevents fake stat injection). + // For local (singleplayer) games allPlayersStats must be kept — it is + // the only source of stats that LocalServer.endGame() archives. + const msg: ClientSendWinnerMessage = this.isLocal + ? { + type: "winner", + winner: event.winner, + allPlayersStats: event.allPlayersStats, + } + : { type: "winner", winner: event.winner }; + this.sendMsg(msg); } else { console.log( "WebSocket is not open. Current state:", From 5c6823508f10eb966c18ef5b7afb4cf8792effd1 Mon Sep 17 00:00:00 2001 From: Radomir <116832305+Radomir-Aksenenko@users.noreply.github.com> Date: Sun, 31 May 2026 20:42:38 +0700 Subject: [PATCH 8/9] style: fix Prettier formatting in Transport.ts, LocalServer.ts, tests Co-Authored-By: Claude Sonnet 4.6 --- tests/server/WinnerSecurity.test.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/server/WinnerSecurity.test.ts b/tests/server/WinnerSecurity.test.ts index 84ea03a68e..fe7482f217 100644 --- a/tests/server/WinnerSecurity.test.ts +++ b/tests/server/WinnerSecurity.test.ts @@ -22,8 +22,8 @@ vi.mock("../../src/server/Archive", () => ({ })); import { GameType } from "../../src/core/game/Game"; -import { Client } from "../../src/server/Client"; import { archive } from "../../src/server/Archive"; +import { Client } from "../../src/server/Client"; import { GameServer } from "../../src/server/GameServer"; function makeMockWs(ip = "1.2.3.4") { @@ -82,12 +82,9 @@ describe("GameServer - winner message security", () => { }); it("archives with undefined stats regardless of client-supplied allPlayersStats", async () => { - const game = new GameServer( - "test-game", - mockLogger, - Date.now(), - { gameType: GameType.Private } as any, - ); + const game = new GameServer("test-game", mockLogger, Date.now(), { + gameType: GameType.Private, + } as any); const { client: c1, ws: ws1 } = makeClient("cid-1", "pid-1", "1.2.3.4"); const { client: c2, ws: ws2 } = makeClient("cid-2", "pid-2", "5.6.7.8"); From e4ed6bf5df7c773c783c0e6f89ad44e7c4aed5e4 Mon Sep 17 00:00:00 2001 From: Radomir <116832305+Radomir-Aksenenko@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:12:14 +0700 Subject: [PATCH 9/9] test: restore real timers in WinnerSecurity teardown afterEach cleared timers but left fake timers enabled, which would leak into any future test added to this file. Add vi.useRealTimers(). Co-Authored-By: Claude Opus 4.8 --- tests/server/WinnerSecurity.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/server/WinnerSecurity.test.ts b/tests/server/WinnerSecurity.test.ts index fe7482f217..cad4dd4d4c 100644 --- a/tests/server/WinnerSecurity.test.ts +++ b/tests/server/WinnerSecurity.test.ts @@ -79,6 +79,7 @@ describe("GameServer - winner message security", () => { afterEach(() => { vi.restoreAllMocks(); vi.clearAllTimers(); + vi.useRealTimers(); }); it("archives with undefined stats regardless of client-supplied allPlayersStats", async () => {