diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 8b86643539..17fe235d97 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,51 @@ 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; + const allHumans = sorted.filter((p) => p.type() === PlayerType.Human); + const connectedHumans = allHumans.filter((p) => !p.isDisconnected()); + + if (connectedHumans.length === 1 && allHumans.length === 2) { + // 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) { + // Grace period expired — declare the connected player as winner + this.mg.setWinner(connectedHumans[0], this.mg.stats().stats()); + console.log( + `${connectedHumans[0].name()} has won the game (opponent disconnected for ${elapsed} ticks)`, + ); + this.active = false; + return; + } + // Still within grace period — wait for reconnect + return; + } else if (connectedHumans.length === 0 && allHumans.length === 2) { + // Both players disconnected — keep/start grace timer + this.disconnectGraceTick ??= this.mg.ticks(); + const elapsed = this.mg.ticks() - this.disconnectGraceTick; + if (elapsed >= WinCheckExecution.DISCONNECT_GRACE_TICKS) { + // Both disconnected past grace — pick the one with more tiles + const winner = allHumans[0]; // already sorted by tiles desc + this.mg.setWinner(winner, this.mg.stats().stats()); + console.log( + `${winner.name()} has won the game (both disconnected, most tiles)`, + ); + this.active = false; + return; + } return; + } else { + // Both players are 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..2c17517e59 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); }); });