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:

<img width="1457" height="837" alt="image"
src="https://github.com/user-attachments/assets/1ce569bd-6616-4a23-b4a4-afedad2c64f8"
/>

## 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
This commit is contained in:
Mattia Migliorini
2026-01-08 22:51:23 +01:00
committed by GitHub
parent b090f2f624
commit 2dada6f516
5 changed files with 299 additions and 2 deletions
+1
View File
@@ -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",
+6
View File
@@ -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;
+1
View File
@@ -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<typeof WinnerSchema>;
+3 -1
View File
@@ -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,
+288 -1
View File
@@ -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);
});
});