From 0c8105a5c6febaf63dbb84bf034e2c0113f0aac3 Mon Sep 17 00:00:00 2001 From: 22314621 Date: Sat, 16 May 2026 20:21:12 +0300 Subject: [PATCH 01/10] fix: catch unhandled promise rejection from async game.end() (SEC-05) game.end() is async but was called without await inside a try/catch. The try/catch only catches synchronous errors. Added .catch() to the returned promise to prevent unhandled promise rejections from crashing the server. --- src/server/GameManager.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 72065a2067..b1095c5d58 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -131,7 +131,9 @@ export class GameManager { if (phase === GamePhase.Finished) { try { - game.end(); + game.end().catch((error: any) => { + this.log.error(`error ending game ${id}: ${error}`); + }); } catch (error) { this.log.error(`error ending game ${id}: ${error}`); } From 7a9fd2bd5aa1d91125e2290450a5b309a3ffbf81 Mon Sep 17 00:00:00 2001 From: 22314621 Date: Sun, 17 May 2026 05:59:27 +0300 Subject: [PATCH 02/10] refactor: remove redundant try/catch around fire-and-forget game.end() (SEC-05) --- src/server/GameManager.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index b1095c5d58..8749838361 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -130,13 +130,9 @@ export class GameManager { } if (phase === GamePhase.Finished) { - try { - game.end().catch((error: any) => { - this.log.error(`error ending game ${id}: ${error}`); - }); - } catch (error) { + game.end().catch((error: any) => { this.log.error(`error ending game ${id}: ${error}`); - } + }); } else { active.set(id, game); } From 18c92c93c1a8d7101905384a1af03982c629d9a6 Mon Sep 17 00:00:00 2001 From: 22314621 Date: Mon, 18 May 2026 01:40:19 +0300 Subject: [PATCH 03/10] fix(api): secure /api/archive_singleplayer_game endpoint with JWT validation --- src/server/Worker.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 340fbe681f..a5af36e833 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -233,6 +233,23 @@ export async function startWorker() { app.post("/api/archive_singleplayer_game", async (req, res) => { try { + let persistentID: string; + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.substring("Bearer ".length); + const tokenResult = await verifyClientToken(token); + if (tokenResult.type === "success") { + persistentID = tokenResult.persistentId; + } else { + log.warn( + `Invalid token for archive_singleplayer_game: ${tokenResult.message}`, + ); + return res.status(401).json({ error: "Invalid token" }); + } + } else { + return res.status(401).json({ error: "Authorization header required" }); + } + const record = req.body; const result = PartialGameRecordSchema.safeParse(record); @@ -260,6 +277,17 @@ export async function startWorker() { return res.status(400).json({ error: "Invalid request" }); } + const player = result.data.info.players[0]; + if (player.persistentID !== persistentID) { + log.warn("Authenticated user does not match record persistentID", { + tokenUser: persistentID, + recordUser: player.persistentID, + }); + return res + .status(403) + .json({ error: "Unauthorized user for this record" }); + } + log.info("archiving singleplayer game", { gameID: gameRecord.info.gameID, }); From e475bdbe66f69e5b65ecae0f7be155ad8abfc88f Mon Sep 17 00:00:00 2001 From: 22314621 Date: Mon, 18 May 2026 01:41:17 +0300 Subject: [PATCH 04/10] fix(game): patch Winner Spoofing and Desync DoS with strict majority consensus --- src/server/GameServer.ts | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 9cd8b6b6ff..3d70c23e12 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -606,6 +606,9 @@ export class GameServer { } this._hasEnded = true; } + } else { + // Check if remaining clients have reached a winner consensus + this.checkWinnerConsensus(); } }); client.ws.on("error", (error: Error) => { @@ -1187,8 +1190,8 @@ export class GameServer { } } - // If half clients out of sync assume all are out of sync. - if (outOfSyncClients.length >= Math.floor(this.activeClients.length / 2)) { + // If strict majority clients out of sync assume all are out of sync. + if (outOfSyncClients.length > Math.floor(this.activeClients.length / 2)) { outOfSyncClients = this.activeClients; } @@ -1227,7 +1230,7 @@ export class GameServer { }, ); - if (potentialWinner.ips.size * 2 < activeUniqueIPs.size) { + if (potentialWinner.ips.size * 2 <= activeUniqueIPs.size) { return; } @@ -1241,4 +1244,26 @@ export class GameServer { ); this.archiveGame(); } + + private checkWinnerConsensus() { + if (this.winner !== null) { + return; + } + + const activeUniqueIPs = new Set(this.activeClients.map((c) => c.ip)); + + for (const [winnerKey, potentialWinner] of this.winnerVotes.entries()) { + if (potentialWinner.ips.size * 2 > activeUniqueIPs.size) { + this.winner = potentialWinner.winner; + this.log.info( + `Winner determined by ${potentialWinner.ips.size}/${activeUniqueIPs.size} active IPs after client disconnect`, + { + winnerKey: winnerKey, + }, + ); + this.archiveGame(); + return; + } + } + } } From 120c1f88df44e53fc5ef0db85c4c98f8c117e2b9 Mon Sep 17 00:00:00 2001 From: 22314621 Date: Tue, 19 May 2026 16:31:29 +0300 Subject: [PATCH 05/10] feat: optimized 30s disconnect grace period for 1v1 --- src/core/execution/WinCheckExecution.ts | 73 ++++++- .../core/executions/WinCheckExecution.test.ts | 185 +++++++++++++++--- 2 files changed, 220 insertions(+), 38 deletions(-) diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 8b86643539..efd25f5702 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -23,6 +23,14 @@ export class WinCheckExecution implements Execution { // maxGameDuration hard kill. 170mins (10 mins before 3hrs) private static readonly HARD_TIME_LIMIT_SECONDS = 170 * 60; + // Grace period (in ticks) before declaring a winner due to disconnect + // in 1v1 ranked. 300 ticks = 30 seconds at 100ms/tick. + private static readonly DISCONNECT_GRACE_TICKS = 300; + + // The tick at which we first detected only one connected human in 1v1. + // null means both players are currently connected (or grace not started). + private disconnectGraceTick: number | null = null; + constructor() {} init(mg: Game, ticks: number) { @@ -52,14 +60,63 @@ export class WinCheckExecution implements Execution { } if (this.mg.config().gameConfig().rankedType === RankedType.OneVOne) { - const humans = sorted.filter( - (p) => p.type() === PlayerType.Human && !p.isDisconnected(), - ); - if (humans.length === 1) { - this.mg.setWinner(humans[0], this.mg.stats().stats()); - console.log(`${humans[0].name()} has won the game`); - this.active = false; - return; + let numHumans = 0; + let numConnected = 0; + let firstHuman: Player | null = null; + let connectedHuman: Player | null = null; + + for (const p of sorted) { + if (p.type() === PlayerType.Human) { + numHumans++; + firstHuman ??= p; + if (!p.isDisconnected()) { + numConnected++; + connectedHuman ??= p; + } + } + } + + if (numHumans === 2) { + if (numConnected === 1) { + // One player is disconnected — start or continue grace period + if (this.disconnectGraceTick === null) { + this.disconnectGraceTick = this.mg.ticks(); + console.log( + `1v1 disconnect grace period started at tick ${this.disconnectGraceTick}`, + ); + } + const elapsed = this.mg.ticks() - this.disconnectGraceTick; + if (elapsed >= WinCheckExecution.DISCONNECT_GRACE_TICKS) { + this.mg.setWinner(connectedHuman!, this.mg.stats().stats()); + console.log( + `${connectedHuman!.name()} has won the game (opponent disconnected for ${elapsed} ticks)`, + ); + this.active = false; + return; + } + return; + } else if (numConnected === 0) { + // Both disconnected — keep/start grace timer + this.disconnectGraceTick ??= this.mg.ticks(); + const elapsed = this.mg.ticks() - this.disconnectGraceTick; + if (elapsed >= WinCheckExecution.DISCONNECT_GRACE_TICKS) { + this.mg.setWinner(firstHuman!, this.mg.stats().stats()); + console.log( + `${firstHuman!.name()} has won the game (both disconnected, most tiles)`, + ); + this.active = false; + return; + } + return; + } else { + // Both connected — reset grace timer + if (this.disconnectGraceTick !== null) { + console.log( + `1v1 disconnect grace period reset (player reconnected)`, + ); + this.disconnectGraceTick = null; + } + } } } diff --git a/tests/core/executions/WinCheckExecution.test.ts b/tests/core/executions/WinCheckExecution.test.ts index 6148f598ce..b885ec6f51 100644 --- a/tests/core/executions/WinCheckExecution.test.ts +++ b/tests/core/executions/WinCheckExecution.test.ts @@ -354,7 +354,7 @@ describe("WinCheckExecution - Nation Winners", () => { }); describe("WinCheckExecution - 1v1 Ranked Mode", () => { - test("should set winner when only one human remains connected", async () => { + test("should NOT set winner immediately when one human disconnects (grace period)", async () => { // Setup game with 1v1 ranked mode and two human players const game = await setup( "big_plains", @@ -373,8 +373,6 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { const human1 = game.player("Player1"); const human2 = game.player("Player2"); - // Skip spawn phase - // Assign some territory to both players let human1Count = 0; let human2Count = 0; @@ -396,18 +394,72 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { const setWinnerSpy = vi.fn(); game.setWinner = setWinnerSpy; - // Initialize and run win check + // Initialize and run win check ÔÇö should NOT declare winner yet const winCheck = new WinCheckExecution(); winCheck.init(game, 0); winCheck.checkWinnerFFA(); - // Verify the remaining connected human is declared winner + // Grace period just started ÔÇö no winner yet + expect(setWinnerSpy).not.toHaveBeenCalled(); + expect(winCheck.isActive()).toBe(true); + }); + + test("should set winner after grace period expires", async () => { + const game = await setup( + "big_plains", + { + infiniteGold: true, + gameMode: GameMode.FFA, + instantBuild: true, + rankedType: RankedType.OneVOne, + }, + [ + playerInfo("Player1", PlayerType.Human), + playerInfo("Player2", PlayerType.Human), + ], + ); + + const human1 = game.player("Player1"); + const human2 = game.player("Player2"); + + let human1Count = 0; + let human2Count = 0; + game.map().forEachTile((tile) => { + if (!game.map().isLand(tile)) return; + if (human1Count < 10) { + human1.conquer(tile); + human1Count++; + } else if (human2Count < 10) { + human2.conquer(tile); + human2Count++; + } + }); + + human2.markDisconnected(true); + + const setWinnerSpy = vi.fn(); + game.setWinner = setWinnerSpy; + + const winCheck = new WinCheckExecution(); + winCheck.init(game, 0); + + // First call starts grace period + winCheck.checkWinnerFFA(); + expect(setWinnerSpy).not.toHaveBeenCalled(); + + // Advance ticks past grace period (300 ticks) + game.endSpawnPhase(); + for (let i = 0; i < 310; i++) { + game.executeNextTick(); + } + + // Now check again ÔÇö grace period should have expired + winCheck.checkWinnerFFA(); expect(setWinnerSpy).toHaveBeenCalledWith(human1, expect.anything()); expect(winCheck.isActive()).toBe(false); }); - test("should not set winner when multiple humans are still connected", async () => { - // Setup game with 1v1 ranked mode and two human players + test("should reset grace period if disconnected player reconnects", async () => { const game = await setup( "big_plains", { @@ -425,9 +477,71 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { const human1 = game.player("Player1"); const human2 = game.player("Player2"); - // Skip spawn phase + let human1Count = 0; + let human2Count = 0; + game.map().forEachTile((tile) => { + if (!game.map().isLand(tile)) return; + if (human1Count < 10) { + human1.conquer(tile); + human1Count++; + } else if (human2Count < 10) { + human2.conquer(tile); + human2Count++; + } + }); + + human2.markDisconnected(true); + + const setWinnerSpy = vi.fn(); + game.setWinner = setWinnerSpy; + + const winCheck = new WinCheckExecution(); + winCheck.init(game, 0); + + // Start grace period + winCheck.checkWinnerFFA(); + expect(setWinnerSpy).not.toHaveBeenCalled(); + + // Advance some ticks (but not past grace period) + game.endSpawnPhase(); + for (let i = 0; i < 100; i++) { + game.executeNextTick(); + } + + // Player reconnects + human2.markDisconnected(false); + winCheck.checkWinnerFFA(); + + // Advance ticks past what would have been the grace period + for (let i = 0; i < 250; i++) { + game.executeNextTick(); + } + + winCheck.checkWinnerFFA(); + + // Should NOT have declared a winner because the player reconnected + expect(setWinnerSpy).not.toHaveBeenCalled(); + expect(winCheck.isActive()).toBe(true); + }); + + test("should not set winner when multiple humans are still connected", async () => { + const game = await setup( + "big_plains", + { + infiniteGold: true, + gameMode: GameMode.FFA, + instantBuild: true, + rankedType: RankedType.OneVOne, + }, + [ + playerInfo("Player1", PlayerType.Human), + playerInfo("Player2", PlayerType.Human), + ], + ); + + const human1 = game.player("Player1"); + const human2 = game.player("Player2"); - // Assign territory to both players let human1Count = 0; let human2Count = 0; game.map().forEachTile((tile) => { @@ -441,26 +555,21 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { } }); - // Both players remain connected expect(human1.isDisconnected()).toBe(false); expect(human2.isDisconnected()).toBe(false); - // Mock setWinner to capture calls const setWinnerSpy = vi.fn(); game.setWinner = setWinnerSpy; - // Initialize and run win check const winCheck = new WinCheckExecution(); winCheck.init(game, 0); winCheck.checkWinnerFFA(); - // Verify no winner declared yet (both players still connected) expect(setWinnerSpy).not.toHaveBeenCalled(); expect(winCheck.isActive()).toBe(true); }); - test("should not set winner when no humans remain connected", async () => { - // Setup game with 1v1 ranked mode and two human players + test("should not set winner immediately when both players disconnect, but should after grace", async () => { const game = await setup( "big_plains", { @@ -478,28 +587,48 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { const human1 = game.player("Player1"); const human2 = game.player("Player2"); - // Skip spawn phase + // Give both players tiles so both are alive (isAlive requires tiles > 0) + // human1 gets more tiles so winner is deterministic + let h1Count = 0; + let h2Count = 0; + game.map().forEachTile((tile) => { + if (!game.map().isLand(tile)) return; + if (h1Count < 10) { + human1.conquer(tile); + h1Count++; + } else if (h2Count < 5) { + human2.conquer(tile); + h2Count++; + } + }); - // Both players disconnect human1.markDisconnected(true); human2.markDisconnected(true); - // Mock setWinner to capture calls const setWinnerSpy = vi.fn(); game.setWinner = setWinnerSpy; - // Initialize and run win check const winCheck = new WinCheckExecution(); winCheck.init(game, 0); winCheck.checkWinnerFFA(); - // Verify no winner declared (no connected humans) + // Grace timer should have started ÔÇö no immediate winner expect(setWinnerSpy).not.toHaveBeenCalled(); expect(winCheck.isActive()).toBe(true); + + // Advance past grace period (300 ticks) + game.endSpawnPhase(); + for (let i = 0; i < 310; i++) { + game.executeNextTick(); + } + + // Now check again ÔÇö grace period expired, winner by tiles + winCheck.checkWinnerFFA(); + expect(setWinnerSpy).toHaveBeenCalledWith(human1, expect.anything()); + expect(winCheck.isActive()).toBe(false); }); - test("should ignore bots and nations in 1v1 ranked mode", async () => { - // Setup game with 1v1 ranked mode, one human, one bot, and one nation + test("should ignore bots and nations in 1v1 ranked mode (only 1 human = no opponent)", async () => { const game = await setup( "big_plains", { @@ -519,9 +648,6 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { const bot = game.player("BotPlayer"); const nation = game.player("NationPlayer"); - // Skip spawn phase - - // Assign territory to all players let humanCount = 0; let botCount = 0; let nationCount = 0; @@ -539,17 +665,16 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { } }); - // Mock setWinner to capture calls const setWinnerSpy = vi.fn(); game.setWinner = setWinnerSpy; - // Initialize and run win check const winCheck = new WinCheckExecution(); winCheck.init(game, 0); winCheck.checkWinnerFFA(); - // Verify human is declared winner (only one human player) - expect(setWinnerSpy).toHaveBeenCalledWith(human, expect.anything()); - expect(winCheck.isActive()).toBe(false); + // Only 1 human total (allHumans.length !== 2), so 1v1 disconnect logic + // does NOT apply. Falls through to normal FFA win check. + // Whether winner is set depends on tile % threshold (won't be met with 10 tiles). + expect(winCheck.isActive()).toBe(true); }); }); From cb72356680177b0df7476976e1c0fd646957460c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berk=20Elmal=C4=B1?= Date: Thu, 21 May 2026 00:40:02 +0300 Subject: [PATCH 06/10] fix: replace broken diacritics (mojibake) with ASCII dashes in comments --- src/core/execution/WinCheckExecution.ts | 6 +++--- tests/core/executions/WinCheckExecution.test.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index efd25f5702..009ca17c73 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -78,7 +78,7 @@ export class WinCheckExecution implements Execution { if (numHumans === 2) { if (numConnected === 1) { - // One player is disconnected — start or continue grace period + // One player is disconnected -- start or continue grace period if (this.disconnectGraceTick === null) { this.disconnectGraceTick = this.mg.ticks(); console.log( @@ -96,7 +96,7 @@ export class WinCheckExecution implements Execution { } return; } else if (numConnected === 0) { - // Both disconnected — keep/start grace timer + // Both disconnected -- keep/start grace timer this.disconnectGraceTick ??= this.mg.ticks(); const elapsed = this.mg.ticks() - this.disconnectGraceTick; if (elapsed >= WinCheckExecution.DISCONNECT_GRACE_TICKS) { @@ -109,7 +109,7 @@ export class WinCheckExecution implements Execution { } return; } else { - // Both connected — reset grace timer + // Both connected -- reset grace timer if (this.disconnectGraceTick !== null) { console.log( `1v1 disconnect grace period reset (player reconnected)`, diff --git a/tests/core/executions/WinCheckExecution.test.ts b/tests/core/executions/WinCheckExecution.test.ts index b885ec6f51..56cac4bd81 100644 --- a/tests/core/executions/WinCheckExecution.test.ts +++ b/tests/core/executions/WinCheckExecution.test.ts @@ -394,12 +394,12 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { const setWinnerSpy = vi.fn(); game.setWinner = setWinnerSpy; - // Initialize and run win check ÔÇö should NOT declare winner yet + // Initialize and run win check -- should NOT declare winner yet const winCheck = new WinCheckExecution(); winCheck.init(game, 0); winCheck.checkWinnerFFA(); - // Grace period just started ÔÇö no winner yet + // Grace period just started -- no winner yet expect(setWinnerSpy).not.toHaveBeenCalled(); expect(winCheck.isActive()).toBe(true); }); @@ -453,7 +453,7 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { game.executeNextTick(); } - // Now check again ÔÇö grace period should have expired + // Now check again -- grace period should have expired winCheck.checkWinnerFFA(); expect(setWinnerSpy).toHaveBeenCalledWith(human1, expect.anything()); expect(winCheck.isActive()).toBe(false); @@ -612,7 +612,7 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { winCheck.init(game, 0); winCheck.checkWinnerFFA(); - // Grace timer should have started ÔÇö no immediate winner + // Grace timer should have started -- no immediate winner expect(setWinnerSpy).not.toHaveBeenCalled(); expect(winCheck.isActive()).toBe(true); @@ -622,7 +622,7 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { game.executeNextTick(); } - // Now check again ÔÇö grace period expired, winner by tiles + // Now check again -- grace period expired, winner by tiles winCheck.checkWinnerFFA(); expect(setWinnerSpy).toHaveBeenCalledWith(human1, expect.anything()); expect(winCheck.isActive()).toBe(false); From 29f4afcd3fe4b1e94648c86da9a1e4a9ca8957ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berk=20Elmal=C4=B1?= Date: Thu, 21 May 2026 00:53:31 +0300 Subject: [PATCH 07/10] fix: add hard time limit guard inside grace period, reduce grace to 150 ticks, add defensive tests --- src/core/execution/WinCheckExecution.ts | 19 ++- .../core/executions/WinCheckExecution.test.ts | 114 ++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 009ca17c73..bb459f39c9 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -24,8 +24,10 @@ export class WinCheckExecution implements Execution { private static readonly HARD_TIME_LIMIT_SECONDS = 170 * 60; // Grace period (in ticks) before declaring a winner due to disconnect - // in 1v1 ranked. 300 ticks = 30 seconds at 100ms/tick. - private static readonly DISCONNECT_GRACE_TICKS = 300; + // in 1v1 ranked. 150 ticks = 15 seconds at 100ms/tick. + // Combined with the server's 30s ping-timeout detection, the total + // effective delay from actual disconnect to winner declaration is ~45s. + private static readonly DISCONNECT_GRACE_TICKS = 150; // The tick at which we first detected only one connected human in 1v1. // null means both players are currently connected (or grace not started). @@ -77,6 +79,19 @@ export class WinCheckExecution implements Execution { } if (numHumans === 2) { + // Hard time limit guard -- even during a grace period the game + // must not exceed HARD_TIME_LIMIT_SECONDS (170 min). + const timeElapsed = this.mg.elapsedGameSeconds(); + if (timeElapsed >= WinCheckExecution.HARD_TIME_LIMIT_SECONDS) { + const winner = connectedHuman ?? firstHuman!; + this.mg.setWinner(winner, this.mg.stats().stats()); + console.log( + `${winner.name()} has won the game (hard time limit during 1v1 grace)`, + ); + this.active = false; + return; + } + if (numConnected === 1) { // One player is disconnected -- start or continue grace period if (this.disconnectGraceTick === null) { diff --git a/tests/core/executions/WinCheckExecution.test.ts b/tests/core/executions/WinCheckExecution.test.ts index 56cac4bd81..43abb8a7ac 100644 --- a/tests/core/executions/WinCheckExecution.test.ts +++ b/tests/core/executions/WinCheckExecution.test.ts @@ -677,4 +677,118 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { // Whether winner is set depends on tile % threshold (won't be met with 10 tiles). expect(winCheck.isActive()).toBe(true); }); + + test("should declare winner immediately when hard time limit is reached during grace period", async () => { + const game = await setup( + "big_plains", + { + infiniteGold: true, + gameMode: GameMode.FFA, + instantBuild: true, + rankedType: RankedType.OneVOne, + // maxTimerValue in minutes -- set very low so we can reach hard limit + maxTimerValue: 5, + }, + [ + playerInfo("Player1", PlayerType.Human), + playerInfo("Player2", PlayerType.Human), + ], + ); + + const human1 = game.player("Player1"); + const human2 = game.player("Player2"); + + let human1Count = 0; + let human2Count = 0; + game.map().forEachTile((tile) => { + if (!game.map().isLand(tile)) return; + if (human1Count < 10) { + human1.conquer(tile); + human1Count++; + } else if (human2Count < 5) { + human2.conquer(tile); + human2Count++; + } + }); + + // Disconnect one player to start grace period + human2.markDisconnected(true); + + const setWinnerSpy = vi.fn(); + game.setWinner = setWinnerSpy; + + const winCheck = new WinCheckExecution(); + winCheck.init(game, 0); + + // First call starts grace period + winCheck.checkWinnerFFA(); + expect(setWinnerSpy).not.toHaveBeenCalled(); + + // Simulate that the game has been running for 170+ minutes + // by advancing past the hard time limit (170 * 60 * 10 ticks) + game.endSpawnPhase(); + const hardLimitTicks = 170 * 60 * 10; + for (let i = 0; i < hardLimitTicks; i++) { + game.executeNextTick(); + } + + // Now check again -- hard time limit should override grace period + winCheck.checkWinnerFFA(); + expect(setWinnerSpy).toHaveBeenCalledWith(human1, expect.anything()); + expect(winCheck.isActive()).toBe(false); + }); + + test("should fall through to normal FFA check when more than 2 humans in 1v1 ranked", async () => { + const game = await setup( + "big_plains", + { + infiniteGold: true, + gameMode: GameMode.FFA, + instantBuild: true, + rankedType: RankedType.OneVOne, + }, + [ + playerInfo("Player1", PlayerType.Human), + playerInfo("Player2", PlayerType.Human), + playerInfo("Player3", PlayerType.Human), + ], + ); + + const human1 = game.player("Player1"); + const human2 = game.player("Player2"); + const human3 = game.player("Player3"); + + let h1 = 0; + let h2 = 0; + let h3 = 0; + game.map().forEachTile((tile) => { + if (!game.map().isLand(tile)) return; + if (h1 < 10) { + human1.conquer(tile); + h1++; + } else if (h2 < 10) { + human2.conquer(tile); + h2++; + } else if (h3 < 10) { + human3.conquer(tile); + h3++; + } + }); + + // Disconnect two players -- if 1v1 logic applied, this would trigger grace + human2.markDisconnected(true); + human3.markDisconnected(true); + + const setWinnerSpy = vi.fn(); + game.setWinner = setWinnerSpy; + + const winCheck = new WinCheckExecution(); + winCheck.init(game, 0); + winCheck.checkWinnerFFA(); + + // numHumans === 3 (!== 2) so 1v1 disconnect logic is skipped. + // Falls through to normal FFA win check (tile % threshold not met). + expect(setWinnerSpy).not.toHaveBeenCalled(); + expect(winCheck.isActive()).toBe(true); + }); }); From b92d85bbbd9824d612600777fd8f4d0739614c36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berk=20Elmal=C4=B1?= Date: Thu, 21 May 2026 01:01:22 +0300 Subject: [PATCH 08/10] test: resolve CodeRabbit review issues by fixing grace period tick count in assertions and mocking elapsedGameSeconds in hard time limit test --- .../core/executions/WinCheckExecution.test.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/core/executions/WinCheckExecution.test.ts b/tests/core/executions/WinCheckExecution.test.ts index 43abb8a7ac..0e5d2526b9 100644 --- a/tests/core/executions/WinCheckExecution.test.ts +++ b/tests/core/executions/WinCheckExecution.test.ts @@ -447,9 +447,9 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { winCheck.checkWinnerFFA(); expect(setWinnerSpy).not.toHaveBeenCalled(); - // Advance ticks past grace period (300 ticks) + // Advance ticks past grace period (150 ticks) game.endSpawnPhase(); - for (let i = 0; i < 310; i++) { + for (let i = 0; i < 160; i++) { game.executeNextTick(); } @@ -616,9 +616,9 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { expect(setWinnerSpy).not.toHaveBeenCalled(); expect(winCheck.isActive()).toBe(true); - // Advance past grace period (300 ticks) + // Advance past grace period (150 ticks) game.endSpawnPhase(); - for (let i = 0; i < 310; i++) { + for (let i = 0; i < 160; i++) { game.executeNextTick(); } @@ -724,16 +724,16 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { winCheck.checkWinnerFFA(); expect(setWinnerSpy).not.toHaveBeenCalled(); - // Simulate that the game has been running for 170+ minutes - // by advancing past the hard time limit (170 * 60 * 10 ticks) - game.endSpawnPhase(); - const hardLimitTicks = 170 * 60 * 10; - for (let i = 0; i < hardLimitTicks; i++) { - game.executeNextTick(); - } + // Mock elapsedGameSeconds to simulate 170+ minutes instantly without running 100k+ ticks + const elapsedSpy = vi + .spyOn(game, "elapsedGameSeconds") + .mockReturnValue(170 * 60 + 1); // Now check again -- hard time limit should override grace period winCheck.checkWinnerFFA(); + + // Ensure spy is cleaned up after assertion + elapsedSpy.mockRestore(); expect(setWinnerSpy).toHaveBeenCalledWith(human1, expect.anything()); expect(winCheck.isActive()).toBe(false); }); From bac11140121fe96c7484a6d07f433ebda16922f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berk=20Elmal=C4=B1?= Date: Thu, 21 May 2026 01:06:31 +0300 Subject: [PATCH 09/10] test: reorder mockRestore to execute after assertions in hard time limit test --- tests/core/executions/WinCheckExecution.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/core/executions/WinCheckExecution.test.ts b/tests/core/executions/WinCheckExecution.test.ts index 0e5d2526b9..57a9f45308 100644 --- a/tests/core/executions/WinCheckExecution.test.ts +++ b/tests/core/executions/WinCheckExecution.test.ts @@ -732,10 +732,12 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { // Now check again -- hard time limit should override grace period winCheck.checkWinnerFFA(); - // Ensure spy is cleaned up after assertion - elapsedSpy.mockRestore(); + // Verify the outcomes first expect(setWinnerSpy).toHaveBeenCalledWith(human1, expect.anything()); expect(winCheck.isActive()).toBe(false); + + // Clean up spy after all logic and assertions are complete + elapsedSpy.mockRestore(); }); test("should fall through to normal FFA check when more than 2 humans in 1v1 ranked", async () => { From 3fdeadf7993e0931a39c81914f57b620a56599b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berk=20Elmal=C4=B1?= Date: Thu, 21 May 2026 01:15:15 +0300 Subject: [PATCH 10/10] fix(server): return early in checkWinnerConsensus when activeUniqueIPs is empty to avoid declaring winners on disconnects --- src/server/GameServer.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 3d70c23e12..368e287661 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -1251,6 +1251,9 @@ export class GameServer { } const activeUniqueIPs = new Set(this.activeClients.map((c) => c.ip)); + if (activeUniqueIPs.size === 0) { + return; + } for (const [winnerKey, potentialWinner] of this.winnerVotes.entries()) { if (potentialWinner.ips.size * 2 > activeUniqueIPs.size) {