diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 8b86643539..bb459f39c9 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -23,6 +23,16 @@ 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. 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). + private disconnectGraceTick: number | null = null; + constructor() {} init(mg: Game, ticks: number) { @@ -52,14 +62,76 @@ 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) { + // 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) { + 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/src/server/GameManager.ts b/src/server/GameManager.ts index 72065a2067..8749838361 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -130,11 +130,9 @@ export class GameManager { } if (phase === GamePhase.Finished) { - try { - game.end(); - } catch (error) { + game.end().catch((error: any) => { this.log.error(`error ending game ${id}: ${error}`); - } + }); } else { active.set(id, game); } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 9cd8b6b6ff..368e287661 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,29 @@ export class GameServer { ); this.archiveGame(); } + + private checkWinnerConsensus() { + if (this.winner !== null) { + return; + } + + 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) { + 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; + } + } + } } 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, }); diff --git a/tests/core/executions/WinCheckExecution.test.ts b/tests/core/executions/WinCheckExecution.test.ts index 6148f598ce..57a9f45308 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(); + + // 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 (150 ticks) + game.endSpawnPhase(); + for (let i = 0; i < 160; i++) { + game.executeNextTick(); + } - // Verify the remaining connected human is declared winner + // 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 (150 ticks) + game.endSpawnPhase(); + for (let i = 0; i < 160; 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,132 @@ 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()); + // 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); + }); + + 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(); + + // 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(); + + // 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 () => { + 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); }); });