mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:10:42 +00:00
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:
committed by
GitHub
parent
b090f2f624
commit
2dada6f516
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user