From 2dada6f516425f4a703f42eef0ae95163edd7197 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Thu, 8 Jan 2026 22:51:23 +0100 Subject: [PATCH] Handle Nation win condition (#2824) Resolves #2823 ## Description: When playing in single-player mode, if an NPC reaches 80% land control before the player, the game enters a broken state where: - The game clock stops - Win checking stops permanently - Even if the player later conquers 100% of land, victory is never awarded - The game becomes "stuck" in a zombie state. This PR addresses this allowing Nations to be set as winners in single mode, and in this case showing a "Nation {nation} has won" modal to the user. This WinModal is the same as the "{player} has won", with the only change being the title. Nation wins in FFA, from the human player perspective: image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: deshack_82603 --- resources/lang/en.json | 1 + src/client/graphics/layers/WinModal.ts | 6 + src/core/Schemas.ts | 1 + src/core/game/GameImpl.ts | 4 +- .../core/executions/WinCheckExecution.test.ts | 289 +++++++++++++++++- 5 files changed, 299 insertions(+), 2 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index ab294e099..4ec742af3 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -587,6 +587,7 @@ "other_team": "{team} team has won!", "you_won": "You Won!", "other_won": "{player} has won!", + "nation_won": "Nation {nation} has won!", "exit": "Exit Game", "keep": "Keep Playing", "spectate": "Spectate", diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index cf4c0535c..8f0139f02 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -303,6 +303,12 @@ export class WinModal extends LitElement implements Layer { this.isWin = false; } this.show(); + } else if (wu.winner[0] === "nation") { + this._title = translateText("win_modal.nation_won", { + nation: wu.winner[1], + }); + this.isWin = false; + this.show(); } else { const winner = this.game.playerByClientID(wu.winner[1]); if (!winner?.isPlayer()) return; diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index a86bdb6f8..4a9878786 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -472,6 +472,7 @@ export const WinnerSchema = z .union([ z.tuple([z.literal("player"), ID]).rest(ID), z.tuple([z.literal("team"), SafeString]).rest(ID), + z.tuple([z.literal("nation"), SafeString]).rest(ID), ]) .optional(); export type Winner = z.infer; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 553a5669a..47c0cc46c 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -718,7 +718,9 @@ export class GameImpl implements Game { ]; } else { const clientId = winner.clientID(); - if (clientId === null) return; + if (clientId === null) { + return ["nation", winner.name()]; + } return [ "player", clientId, diff --git a/tests/core/executions/WinCheckExecution.test.ts b/tests/core/executions/WinCheckExecution.test.ts index 5df40658c..17dbdb4e7 100644 --- a/tests/core/executions/WinCheckExecution.test.ts +++ b/tests/core/executions/WinCheckExecution.test.ts @@ -1,5 +1,10 @@ import { WinCheckExecution } from "../../../src/core/execution/WinCheckExecution"; -import { GameMode } from "../../../src/core/game/Game"; +import { + ColoredTeams, + GameMode, + PlayerInfo, + PlayerType, +} from "../../../src/core/game/Game"; import { setup } from "../../util/Setup"; describe("WinCheckExecution", () => { @@ -82,3 +87,285 @@ describe("WinCheckExecution", () => { expect(winCheck.activeDuringSpawnPhase()).toBe(false); }); }); + +describe("WinCheckExecution - Nation Winners", () => { + test("should set Nation as winner when reaching 80% territory", async () => { + // Setup game + const game = await setup("big_plains", { + infiniteGold: true, + gameMode: GameMode.FFA, + instantBuild: true, + }); + + // Create Nation player + const nationInfo = new PlayerInfo( + "TestNation", + PlayerType.Nation, + null, + "nation_id", + ); + game.addPlayer(nationInfo); + const nation = game.player("nation_id"); + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + // Assign 81% of land to Nation + const totalLand = game.numLandTiles(); + const targetTiles = Math.ceil(totalLand * 0.81); + let assigned = 0; + + game.map().forEachTile((tile) => { + if (assigned >= targetTiles) return; + if (!game.map().isLand(tile)) return; + nation.conquer(tile); + assigned++; + }); + + // Verify territory ownership + expect(nation.numTilesOwned()).toBeGreaterThanOrEqual(targetTiles); + + // 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 Nation declared winner + expect(setWinnerSpy).toHaveBeenCalledWith(nation, expect.anything()); + expect(winCheck.isActive()).toBe(false); + }); + + test("should set Nation as winner when timer expires with most territory", async () => { + // Setup game with timer + const game = await setup("big_plains", { + infiniteGold: true, + gameMode: GameMode.FFA, + instantBuild: true, + maxTimerValue: 5, + }); + + // Create human player + const humanInfo = new PlayerInfo( + "HumanPlayer", + PlayerType.Human, + null, + "human_id", + ); + game.addPlayer(humanInfo); + const human = game.player("human_id"); + + // Create Nation player + const nationInfo = new PlayerInfo( + "TestNation", + PlayerType.Nation, + null, + "nation_id", + ); + game.addPlayer(nationInfo); + const nation = game.player("nation_id"); + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + // Give Nation 60% territory (below 80% threshold) + // Give human 30% territory + const totalLand = game.numLandTiles(); + const nationTiles = Math.ceil(totalLand * 0.6); + const humanTiles = Math.ceil(totalLand * 0.3); + let nationAssigned = 0; + let humanAssigned = 0; + + game.map().forEachTile((tile) => { + if (!game.map().isLand(tile)) return; + + if (nationAssigned < nationTiles) { + nation.conquer(tile); + nationAssigned++; + } else if (humanAssigned < humanTiles) { + human.conquer(tile); + humanAssigned++; + } + }); + + // Verify territory distribution + expect(nation.numTilesOwned()).toBeGreaterThan(human.numTilesOwned()); + + // Fast-forward game ticks past timer expiration + const threshold = + game.config().numSpawnPhaseTurns() + + (game.config().gameConfig().maxTimerValue ?? 0) * 600; + while (game.ticks() < threshold) { + game.executeNextTick(); + } + + // Mock setWinner to capture calls + const setWinnerSpy = vi.fn(); + game.setWinner = setWinnerSpy; + + // Initialize and run win check + const winCheck = new WinCheckExecution(); + winCheck.init(game, game.ticks()); + winCheck.checkWinnerFFA(); + + // Verify Nation declared winner (has most territory when timer expires) + expect(setWinnerSpy).toHaveBeenCalledWith(nation, expect.anything()); + expect(winCheck.isActive()).toBe(false); + }); + + test("should set correct Nation as winner among multiple Nations", async () => { + // Setup game + const game = await setup("big_plains", { + infiniteGold: true, + gameMode: GameMode.FFA, + instantBuild: true, + }); + + // Create 3 Nation players + const nation1Info = new PlayerInfo( + "Nation1", + PlayerType.Nation, + null, + "nation1_id", + ); + game.addPlayer(nation1Info); + const nation1 = game.player("nation1_id"); + + const nation2Info = new PlayerInfo( + "Nation2", + PlayerType.Nation, + null, + "nation2_id", + ); + game.addPlayer(nation2Info); + const nation2 = game.player("nation2_id"); + + const nation3Info = new PlayerInfo( + "Nation3", + PlayerType.Nation, + null, + "nation3_id", + ); + game.addPlayer(nation3Info); + const nation3 = game.player("nation3_id"); + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + // Assign territories: Nation1 (85%), Nation2 (10%), Nation3 (5%) + const totalLand = game.numLandTiles(); + const nation1Tiles = Math.ceil(totalLand * 0.85); + const nation2Tiles = Math.ceil(totalLand * 0.1); + let nation1Assigned = 0; + let nation2Assigned = 0; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let nation3Assigned = 0; + + game.map().forEachTile((tile) => { + if (!game.map().isLand(tile)) return; + + if (nation1Assigned < nation1Tiles) { + nation1.conquer(tile); + nation1Assigned++; + } else if (nation2Assigned < nation2Tiles) { + nation2.conquer(tile); + nation2Assigned++; + } else { + nation3.conquer(tile); + nation3Assigned++; + } + }); + + // Verify territory distribution + expect(nation1.numTilesOwned()).toBeGreaterThan(nation2.numTilesOwned()); + expect(nation2.numTilesOwned()).toBeGreaterThan(nation3.numTilesOwned()); + + // 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 Nation1 (highest territory) declared winner + expect(setWinnerSpy).toHaveBeenCalledWith(nation1, expect.anything()); + expect(winCheck.isActive()).toBe(false); + }); + + test("should not set winner for bot team in Team mode", async () => { + // Setup Team mode game + const game = await setup("big_plains", { + infiniteGold: true, + gameMode: GameMode.Team, + instantBuild: true, + playerTeams: 2, + }); + + // Create 2 bot players (auto-assigned to Bot team) + const bot1Info = new PlayerInfo("Bot1", PlayerType.Bot, null, "bot1_id"); + game.addPlayer(bot1Info); + const bot1 = game.player("bot1_id"); + + const bot2Info = new PlayerInfo("Bot2", PlayerType.Bot, null, "bot2_id"); + game.addPlayer(bot2Info); + const bot2 = game.player("bot2_id"); + + // Verify bots are on Bot team + expect(bot1.team()).toBe(ColoredTeams.Bot); + expect(bot2.team()).toBe(ColoredTeams.Bot); + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + // Assign 96% of land to bot team (above 95% Team mode threshold) + const totalLand = game.numLandTiles(); + const botTeamTiles = Math.ceil(totalLand * 0.96); + let bot1Assigned = 0; + let bot2Assigned = 0; + + game.map().forEachTile((tile) => { + if (!game.map().isLand(tile)) return; + const totalAssigned = bot1Assigned + bot2Assigned; + if (totalAssigned >= botTeamTiles) return; + + // Alternate between bots + if (bot1Assigned <= bot2Assigned) { + bot1.conquer(tile); + bot1Assigned++; + } else { + bot2.conquer(tile); + bot2Assigned++; + } + }); + + // Verify territory ownership (bot team has > 95%) + const botTeamTotal = bot1.numTilesOwned() + bot2.numTilesOwned(); + expect(botTeamTotal / totalLand).toBeGreaterThan(0.95); + + // 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.checkWinnerTeam(); + + // Verify no winner declared (bot teams excluded) + expect(setWinnerSpy).not.toHaveBeenCalled(); + expect(winCheck.isActive()).toBe(true); + }); +});