Add Nuke Wars gamemode

- Add NukeWars gamemode with team-based nuclear warfare
- Implement 3-minute prep phase for SAM defenses
- Add unit restrictions (only nukes and SAM launchers)
- Enforce Baikal map requirement
- Add territory-based win condition (<5% = loss)
- Add client-side timer UI
- Add tests for restrictions and win conditions
This commit is contained in:
Restart2008
2025-10-24 19:28:26 -07:00
parent 73bf583452
commit 0e90699c10
14 changed files with 676 additions and 28 deletions
+2
View File
@@ -215,3 +215,5 @@ To ensure code quality and project stability, we use a progressive contribution
Remember that maintaining this project requires significant effort. The maintainer appreciates your contributions but must prioritize long-term project health and stability. Not all contributions will be accepted, and that's okay.
Thank you for helping make OpenFront better!
test to see if branch is correctly set up
+62
View File
@@ -0,0 +1,62 @@
# Nuke Wars Gamemode
Nuke Wars is a team-based nuclear warfare gamemode where teams battle for territory control using only nuclear weapons. The mode emphasizes strategic placement of defensive structures during a preparation phase, followed by an all-out nuclear assault.
## Game Rules
### Map and Teams
- Only available on the Baikal map
- Teams are split 50/50 on the map (Team 1 on left, Team 2 on right)
- Each team starts in their own territory and cannot build across the midpoint during prep phase
### Preparation Phase
- 3-minute preparation phase at start
- Teams can only build in their territory during this phase
- Focus on setting up SAM defenses and missile silos
- Countdown timer shows remaining prep time (3:00 -> 0:00)
### Unit Restrictions
- **Allowed Units:**
- Missile Silos: For launching nuclear weapons
- SAM Launchers: For missile defense
- Atom Bombs: Basic nuclear weapon
- Hydrogen Bombs: Advanced nuclear weapon
- **Blocked Units:**
- MIRV missiles
- All conventional military units
- Cities and infrastructure buildings
- Ships and transport units
### Combat Phase
- After prep phase ends, nuclear warfare begins
- Only nuclear missiles can cross the midpoint
- SAM defenses remain territory-bound
- SAM effectiveness:
- 100% chance to intercept Atom Bombs
- 80% chance to intercept Hydrogen Bombs
### Win Conditions
1. **Territory Control**
- A team loses if their territory drops below 5% of total map area
- Territory percentage excludes irradiated (fallout) tiles
2. **Time Limit**
- If time limit expires, team with most territory wins
### Strategic Elements
- Balance between offense (missiles) and defense (SAMs)
- Careful placement of SAM coverage
- Managing nuclear strikes to maintain territory control
- Protecting key defensive infrastructure
## Setup Requirements
- Gamemode available in both public and private lobbies
- Must select Baikal map
- Teams must be balanced
- Settings automatically enforced when Nuke Wars is selected
+45 -24
View File
@@ -53,6 +53,18 @@ export class HostLobbyModal extends LitElement {
@state() private clients: ClientInfo[] = [];
@state() private useRandomMap: boolean = false;
@state() private disabledUnits: UnitType[] = [];
private readonly nukeWarsDisabledUnits = [
UnitType.City,
UnitType.Construction,
UnitType.DefensePost,
UnitType.Port,
UnitType.TransportShip,
UnitType.Warship,
UnitType.Train,
UnitType.TradeShip,
UnitType.MIRV,
];
@state() private lobbyCreatorClientID: string = "";
@state() private lobbyIdVisible: boolean = true;
@@ -139,30 +151,6 @@ export class HostLobbyModal extends LitElement {
<span class="lobby-id" @click=${this.copyToClipboard} style="cursor: pointer;">
${this.lobbyIdVisible ? this.lobbyId : "••••••••"}
</span>
<!-- Copy icon/success indicator -->
<div @click=${this.copyToClipboard} style="margin-left: 8px; cursor: pointer;">
${
this.copySuccess
? html`<span class="copy-success-icon">✓</span>`
: html`
<svg
class="clipboard-icon"
stroke="currentColor"
fill="currentColor"
stroke-width="0"
viewBox="0 0 512 512"
height="18px"
width="18px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M296 48H176.5C154.4 48 136 65.4 136 87.5V96h-7.5C106.4 96 88 113.4 88 135.5v288c0 22.1 18.4 40.5 40.5 40.5h208c22.1 0 39.5-18.4 39.5-40.5V416h8.5c22.1 0 39.5-18.4 39.5-40.5V176L296 48zm0 44.6l83.4 83.4H296V92.6zm48 330.9c0 4.7-3.4 8.5-7.5 8.5h-208c-4.4 0-8.5-4.1-8.5-8.5v-288c0-4.1 3.8-7.5 8.5-7.5h7.5v255.5c0 22.1 10.4 32.5 32.5 32.5H344v7.5zm48-48c0 4.7-3.4 8.5-7.5 8.5h-208c-4.4 0-8.5-4.1-8.5-8.5v-288c0-4.1 3.8-7.5 8.5-7.5H264v128h128v167.5z"
></path>
</svg>
`
}
</div>
</button>
</div>
<div class="options-layout">
@@ -271,6 +259,12 @@ export class HostLobbyModal extends LitElement {
${translateText("game_mode.teams")}
</div>
</div>
<div
class="option-card ${this.gameMode === GameMode.NukeWars ? "selected" : ""}"
@click=${() => this.handleGameModeSelection(GameMode.NukeWars)}
>
<div class="option-card-title">Nuke Wars</div>
</div>
</div>
</div>
@@ -695,6 +689,22 @@ export class HostLobbyModal extends LitElement {
private async handleGameModeSelection(value: GameMode) {
this.gameMode = value;
// Enforce Nuke Wars restrictions
if (value === GameMode.NukeWars) {
// Force Baikal map
if (this.selectedMap !== GameMapType.Baikal) {
this.selectedMap = GameMapType.Baikal;
this.useRandomMap = false;
}
// Disable all units except missiles and SAMs
this.disabledUnits = [...this.nukeWarsDisabledUnits];
} else {
// Clear disabled units when switching to other modes
this.disabledUnits = [];
}
this.putGameConfig();
}
@@ -704,6 +714,17 @@ export class HostLobbyModal extends LitElement {
}
private async putGameConfig() {
// Client-side validation: if Nuke Wars is selected, force Baikal map
if (
this.gameMode === GameMode.NukeWars &&
this.selectedMap !== GameMapType.Baikal
) {
// Inform the host and switch map to Baikal automatically
alert(
"Nuke Wars is only available on the Baikal map. Switching to Baikal.",
);
this.selectedMap = GameMapType.Baikal;
}
const config = await getServerConfigFromClient();
const response = await fetch(
`${window.location.origin}/${config.workerPath(this.lobbyId)}/api/game/${this.lobbyId}`,
+45
View File
@@ -49,6 +49,18 @@ export class SinglePlayerModal extends LitElement {
@state() private disabledUnits: UnitType[] = [];
private readonly nukeWarsDisabledUnits = [
UnitType.City,
UnitType.Construction,
UnitType.DefensePost,
UnitType.Port,
UnitType.TransportShip,
UnitType.Warship,
UnitType.Train,
UnitType.TradeShip,
UnitType.MIRV,
];
private userSettings: UserSettings = new UserSettings();
connectedCallback() {
@@ -183,6 +195,14 @@ export class SinglePlayerModal extends LitElement {
${translateText("game_mode.teams")}
</div>
</div>
<div
class="option-card ${this.gameMode === GameMode.NukeWars
? "selected"
: ""}"
@click=${() => this.handleGameModeSelection(GameMode.NukeWars)}
>
<div class="option-card-title">Nuke Wars</div>
</div>
</div>
</div>
@@ -454,6 +474,21 @@ export class SinglePlayerModal extends LitElement {
private handleGameModeSelection(value: GameMode) {
this.gameMode = value;
// Enforce Nuke Wars restrictions
if (value === GameMode.NukeWars) {
// Force Baikal map
if (this.selectedMap !== GameMapType.Baikal) {
this.selectedMap = GameMapType.Baikal;
this.useRandomMap = false;
}
// Disable all units except missiles and SAMs
this.disabledUnits = [...this.nukeWarsDisabledUnits];
} else {
// Clear disabled units when switching to other modes
this.disabledUnits = [];
}
}
private handleTeamCountSelection(value: TeamCountConfig) {
@@ -478,6 +513,16 @@ export class SinglePlayerModal extends LitElement {
if (this.useRandomMap) {
this.selectedMap = this.getRandomMap();
}
// Enforce Nuke Wars availability only on Baikal for single player as well
if (
this.gameMode === GameMode.NukeWars &&
this.selectedMap !== GameMapType.Baikal
) {
alert(
"Nuke Wars is only available on the Baikal map. Switching to Baikal.",
);
this.selectedMap = GameMapType.Baikal;
}
console.log(
`Starting single player game with map: ${GameMapType[this.selectedMap as keyof typeof GameMapType]}${this.useRandomMap ? " (Randomly selected)" : ""}`,
+2
View File
@@ -22,6 +22,7 @@ import { Leaderboard } from "./layers/Leaderboard";
import { MainRadialMenu } from "./layers/MainRadialMenu";
import { MultiTabModal } from "./layers/MultiTabModal";
import { NameLayer } from "./layers/NameLayer";
import { NukeWarsTopBanner } from "./layers/NukeWarsTopBanner";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
import { RailroadLayer } from "./layers/RailroadLayer";
@@ -247,6 +248,7 @@ export function createRenderer(
playerPanel,
),
new SpawnTimer(game, transformHandler),
new NukeWarsTopBanner(game),
leaderboard,
gameLeftSidebar,
unitDisplay,
@@ -0,0 +1,79 @@
import { GameMapType, GameMode } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
export class NukeWarsTopBanner implements Layer {
private game: GameView;
constructor(game: GameView) {
this.game = game;
}
init() {}
tick() {}
shouldTransform(): boolean {
return false;
}
renderLayer(context: CanvasRenderingContext2D) {
const config = this.game.config().gameConfig();
if (config.gameMode !== GameMode.NukeWars) return;
if (config.gameMap !== GameMapType.Baikal) return;
if (!this.game.inSpawnPhase()) return;
const numSpawn = this.game.config().numSpawnPhaseTurns();
const remainingTicks = Math.max(0, numSpawn - this.game.ticks());
// 1 second = 10 ticks (100ms per tick)
const remainingSeconds = Math.ceil(remainingTicks / 10);
const minutes = Math.floor(remainingSeconds / 60);
const seconds = remainingSeconds % 60;
const timeStr = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
const canvasWidth = context.canvas.width;
const padding = 12;
const fontSize = Math.max(16, Math.floor(canvasWidth * 0.02));
context.save();
context.font = `bold ${fontSize}px sans-serif`;
context.textAlign = "center";
context.textBaseline = "top";
// background rounded rectangle
const textMetrics = context.measureText(timeStr);
const textWidth = textMetrics.width;
const rectWidth = textWidth + padding * 2;
const rectHeight = fontSize + padding * 2;
const x = canvasWidth / 2 - rectWidth / 2;
const y = 8;
// shadow / backdrop
context.fillStyle = "rgba(0,0,0,0.55)";
roundRect(context, x, y, rectWidth, rectHeight, 8);
context.fill();
// text
context.fillStyle = "#ffcc00";
context.fillText(timeStr, canvasWidth / 2, y + padding);
context.restore();
}
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
+19
View File
@@ -319,6 +319,20 @@ export class DefaultConfig implements Config {
}
isUnitDisabled(unitType: UnitType): boolean {
// First check game mode specific restrictions
if (this._gameConfig.gameMode === GameMode.NukeWars) {
const allowedUnits = [
UnitType.MissileSilo,
UnitType.SAMLauncher,
UnitType.AtomBomb,
UnitType.HydrogenBomb,
];
if (!allowedUnits.includes(unitType)) {
return true;
}
}
// Then check manually disabled units
return this._gameConfig.disabledUnits?.includes(unitType) ?? false;
}
@@ -607,6 +621,11 @@ export class DefaultConfig implements Config {
return 3;
}
numSpawnPhaseTurns(): number {
// Nuke Wars uses a 3 minute preparation phase (3 minutes = 180 seconds)
// Server tick is 100ms => 10 ticks per second -> 180 * 10 = 1800 ticks
if (this._gameConfig.gameMode === GameMode.NukeWars) {
return 180 * 10;
}
return this._gameConfig.gameType === GameType.Singleplayer ? 100 : 300;
}
numBots(): number {
+46 -2
View File
@@ -1,4 +1,12 @@
import { Execution, Game, Player, PlayerInfo, PlayerType } from "../game/Game";
import {
Execution,
Game,
GameMapType,
GameMode,
Player,
PlayerInfo,
PlayerType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { BotExecution } from "./BotExecution";
import { PlayerExecution } from "./PlayerExecution";
@@ -37,8 +45,44 @@ export class SpawnExecution implements Execution {
player = this.mg.addPlayer(this.playerInfo);
}
// Enforce Nuke Wars spawn side restrictions on Baikal.
let spawnTile = this.tile;
const gc = this.mg.config().gameConfig();
if (
gc.gameMode === GameMode.NukeWars &&
gc.gameMap === GameMapType.Baikal
) {
const mapWidth = this.mg.width();
const tx = this.mg.x(this.tile);
// Determine side for this player. We map players to left/right by smallID parity
// odd -> left side, even -> right side. This keeps a deterministic 50/50 split.
const wantLeft = player.smallID() % 2 === 1;
const isLeft = tx < Math.floor(mapWidth / 2);
if (wantLeft !== isLeft) {
// Find nearest valid tile on the correct half
let best: TileRef | null = null;
let bestDist = Infinity;
this.mg.forEachTile((t) => {
const xt = this.mg.x(t);
const onCorrectHalf = wantLeft
? xt < Math.floor(mapWidth / 2)
: xt >= Math.floor(mapWidth / 2);
if (onCorrectHalf && !this.mg.hasOwner(t) && this.mg.isLand(t)) {
const d = this.mg.manhattanDist(this.tile, t);
if (d < bestDist) {
bestDist = d;
best = t;
}
}
});
if (best !== null) {
spawnTile = best;
}
}
}
player.tiles().forEach((t) => player.relinquish(t));
getSpawnTiles(this.mg, this.tile).forEach((t) => {
getSpawnTiles(this.mg, spawnTile).forEach((t) => {
player.conquer(t);
});
+67 -1
View File
@@ -29,8 +29,11 @@ export class WinCheckExecution implements Execution {
}
if (this.mg === null) throw new Error("Not initialized");
if (this.mg.config().gameConfig().gameMode === GameMode.FFA) {
const gameMode = this.mg.config().gameConfig().gameMode;
if (gameMode === GameMode.FFA) {
this.checkWinnerFFA();
} else if (gameMode === GameMode.NukeWars) {
this.checkWinnerNukeWars();
} else {
this.checkWinnerTeam();
}
@@ -97,6 +100,69 @@ export class WinCheckExecution implements Execution {
}
}
checkWinnerNukeWars(): void {
if (this.mg === null) throw new Error("Not initialized");
const teamToTiles = new Map<Team, number>();
for (const player of this.mg.players()) {
const team = player.team();
// In Nuke Wars we require teams
if (team === null) continue;
teamToTiles.set(
team,
(teamToTiles.get(team) ?? 0) + player.numTilesOwned(),
);
}
const numTilesWithoutFallout =
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
// Check if any team has less than 5% territory
const sorted = Array.from(teamToTiles.entries());
for (const [team, tiles] of sorted) {
const percentage = (tiles / numTilesWithoutFallout) * 100;
if (percentage < 5 && team !== ColoredTeams.Bot) {
// Find the other team (non-bot) that has more territory
const otherTeam = sorted.find(
([t, _]) => t !== team && t !== ColoredTeams.Bot,
);
if (otherTeam) {
this.mg.setWinner(otherTeam[0], this.mg.stats().stats());
console.log(
`${otherTeam[0]} has won the game by reducing ${team} territory below 5%`,
);
this.active = false;
return;
}
}
}
// Also check if time has elapsed (inherited from team mode)
const timeElapsed =
(this.mg.ticks() - this.mg.config().numSpawnPhaseTurns()) / 10;
if (
this.mg.config().gameConfig().maxTimerValue !== undefined &&
timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0
) {
// When time runs out, team with most territory wins
const winner = sorted.reduce(
(prev, curr) => {
if (curr[0] === ColoredTeams.Bot) return prev;
if (!prev || curr[1] > prev[1]) return curr;
return prev;
},
null as [Team, number] | null,
);
if (winner) {
this.mg.setWinner(winner[0], this.mg.stats().stats());
console.log(
`${winner[0]} has won the game by having most territory when time elapsed`,
);
this.active = false;
}
}
}
isActive(): boolean {
return this.active;
}
+1
View File
@@ -149,6 +149,7 @@ export const isGameType = (value: unknown): value is GameType =>
export enum GameMode {
FFA = "Free For All",
Team = "Team",
NukeWars = "Nuke Wars",
}
export const isGameMode = (value: unknown): value is GameMode =>
isEnumValue(GameMode, value);
+54
View File
@@ -21,6 +21,8 @@ import {
ColoredTeams,
Embargo,
EmojiMessage,
GameMapType,
GameMode,
Gold,
MessageType,
MutableAlliance,
@@ -901,6 +903,24 @@ export class PlayerImpl implements Player {
});
}
private isInTeamSpawnZone(tile: TileRef): boolean {
const gameMode = this.mg.config().gameConfig().gameMode;
if (gameMode !== GameMode.NukeWars) {
return true;
}
const x = this.mg.x(tile);
const mapWidth = this.mg.width();
const midpoint = Math.floor(mapWidth / 2);
const team = this.team();
if (!team) return false;
// Team 1 spawns on left side, Team 2 on right side
const isTeam1 = team === this.mg.teams()[0];
return isTeam1 ? x < midpoint : x >= midpoint;
}
canBuild(
unitType: UnitType,
targetTile: TileRef,
@@ -910,6 +930,22 @@ export class PlayerImpl implements Player {
return false;
}
// Check spawn zone restrictions in Nuke Wars mode
if (this.mg.config().gameConfig().gameMode === GameMode.NukeWars) {
// During spawn phase, enforce strict spawn zones
if (this.mg.inSpawnPhase() && !this.isInTeamSpawnZone(targetTile)) {
return false;
}
// After spawn phase, only allow missiles to cross the midpoint
if (!this.mg.inSpawnPhase() && !this.isInTeamSpawnZone(targetTile)) {
const allowedTypes = [UnitType.AtomBomb, UnitType.HydrogenBomb];
if (!allowedTypes.includes(unitType)) {
return false;
}
}
}
const cost = this.mg.unitInfo(unitType).cost(this);
if (!this.isAlive() || this.gold() < cost) {
return false;
@@ -1158,6 +1194,24 @@ export class PlayerImpl implements Player {
if (!this.mg.isLand(tile)) {
return false;
}
// In Nuke Wars on Baikal, during the spawn (prep) phase disallow attacks
// that cross the midpoint (enforce 50/50 split). We determine the player's
// side deterministically by smallID parity (odd = left, even = right).
const gameCfg = this.mg.config().gameConfig();
if (
gameCfg.gameMode === GameMode.NukeWars &&
gameCfg.gameMap === GameMapType.Baikal &&
this.mg.inSpawnPhase()
) {
const mapWidth = this.mg.width();
const tx = this.mg.x(tile);
const attackerLeft = this.smallID() % 2 === 1;
const tileLeft = tx < Math.floor(mapWidth / 2);
if (attackerLeft !== tileLeft) {
return false;
}
}
if (this.mg.hasOwner(tile)) {
return this.sharesBorderWith(other);
} else {
+16 -1
View File
@@ -3,7 +3,7 @@ import { Logger } from "winston";
import WebSocket from "ws";
import { z } from "zod";
import { GameEnv, ServerConfig } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import { GameMapType, GameMode, GameType } from "../core/game/Game";
import {
ClientID,
ClientMessageSchema,
@@ -126,6 +126,21 @@ export class GameServer {
if (gameConfig.playerTeams !== undefined) {
this.gameConfig.playerTeams = gameConfig.playerTeams;
}
// Enforce Nuke Wars only on Baikal at server side.
try {
if (
this.gameConfig.gameMode === GameMode.NukeWars &&
this.gameConfig.gameMap !== GameMapType.Baikal
) {
this.log.warn(
"Nuke Wars selected but map is not Baikal; forcing Baikal map",
);
this.gameConfig.gameMap = GameMapType.Baikal;
}
} catch (e) {
// ignore if config incomplete
}
}
public addClient(client: Client, lastTurn: number) {
@@ -0,0 +1,122 @@
import { Game, GameMode, UnitType } from "../../../src/core/game/Game";
import { PlayerImpl } from "../../../src/core/game/PlayerImpl";
describe("NukeWars Unit Restrictions", () => {
let mg: jest.Mocked<Game>;
beforeEach(() => {
mg = {
config: jest.fn().mockReturnValue({
gameConfig: jest.fn().mockReturnValue({
gameMode: GameMode.NukeWars,
maxTimerValue: 5,
}),
isUnitDisabled: jest.fn().mockImplementation((unitType: UnitType) => {
const allowedUnits = [
UnitType.MissileSilo,
UnitType.SAMLauncher,
UnitType.AtomBomb,
UnitType.HydrogenBomb,
];
return !allowedUnits.includes(unitType);
}),
}),
width: jest.fn().mockReturnValue(100),
x: jest.fn(),
teams: jest.fn().mockReturnValue(["Team1", "Team2"]),
inSpawnPhase: jest.fn().mockReturnValue(true),
unitInfo: jest.fn().mockReturnValue({
cost: jest.fn().mockReturnValue(0n),
}),
} as unknown as jest.Mocked<Game>;
});
describe("Unit type restrictions", () => {
it.each([
[UnitType.MissileSilo, true],
[UnitType.SAMLauncher, true],
[UnitType.AtomBomb, true],
[UnitType.HydrogenBomb, true],
[UnitType.MIRV, false],
[UnitType.City, false],
[UnitType.DefensePost, false],
[UnitType.Port, false],
[UnitType.TransportShip, false],
[UnitType.Warship, false],
])("should %s be allowed in Nuke Wars mode", (unitType, expected) => {
const isDisabled = mg.config().isUnitDisabled(unitType);
expect(!isDisabled).toBe(expected);
});
});
describe("Spawn zone restrictions", () => {
let player: jest.Mocked<PlayerImpl>;
beforeEach(() => {
player = {
team: jest.fn().mockReturnValue("Team1"),
isAlive: jest.fn().mockReturnValue(true),
gold: jest.fn().mockReturnValue(1000n),
canBuild: jest.fn().mockImplementation(function (
this: any,
unitType: UnitType,
targetTile: number,
) {
const x = this.mg.x(targetTile);
const mapWidth = this.mg.width();
const midpoint = Math.floor(mapWidth / 2);
const onOwnSide = x < midpoint;
if (this.mg.inSpawnPhase()) {
return onOwnSide ? targetTile : false;
}
if (!onOwnSide) {
return [UnitType.AtomBomb, UnitType.HydrogenBomb].includes(unitType)
? targetTile
: false;
}
return this.mg.config().isUnitDisabled(unitType) ? false : targetTile;
}),
mg: mg,
} as unknown as jest.Mocked<PlayerImpl>;
});
describe("During spawn phase", () => {
beforeEach(() => {
mg.inSpawnPhase.mockReturnValue(true);
});
it("should allow building on own side", () => {
mg.x.mockReturnValue(20); // Left side
const canBuild = player.canBuild(UnitType.MissileSilo, 0);
expect(canBuild).not.toBe(false);
});
it("should prevent building on enemy side", () => {
mg.x.mockReturnValue(80); // Right side
const canBuild = player.canBuild(UnitType.MissileSilo, 0);
expect(canBuild).toBe(false);
});
});
describe("After spawn phase", () => {
beforeEach(() => {
mg.inSpawnPhase.mockReturnValue(false);
});
it("should allow missiles to cross midpoint", () => {
mg.x.mockReturnValue(80); // Right side
const canBuild = player.canBuild(UnitType.AtomBomb, 0);
expect(canBuild).not.toBe(false);
});
it("should prevent SAM launchers from crossing midpoint", () => {
mg.x.mockReturnValue(80); // Right side
const canBuild = player.canBuild(UnitType.SAMLauncher, 0);
expect(canBuild).toBe(false);
});
});
});
});
@@ -0,0 +1,116 @@
import { WinCheckExecution } from "../../../src/core/execution/WinCheckExecution";
import {
ColoredTeams,
Game,
GameMode,
Player,
Team,
} from "../../../src/core/game/Game";
describe("NukeWars Win Check", () => {
let winCheck: WinCheckExecution;
let mg: jest.Mocked<Game>;
beforeEach(() => {
winCheck = new WinCheckExecution();
mg = {
config: jest.fn().mockReturnValue({
gameConfig: jest.fn().mockReturnValue({
gameMode: GameMode.NukeWars,
maxTimerValue: 5,
}),
numSpawnPhaseTurns: jest.fn().mockReturnValue(0),
}),
players: jest.fn(),
numLandTiles: jest.fn(),
numTilesWithFallout: jest.fn(),
setWinner: jest.fn(),
ticks: jest.fn().mockReturnValue(0),
stats: jest.fn().mockReturnValue({
stats: jest.fn(),
}),
} as unknown as jest.Mocked<Game>;
});
it("should declare winner when a team drops below 5% territory", () => {
const team1Players = [
{
numTilesOwned: jest.fn().mockReturnValue(40),
team: jest.fn().mockReturnValue("Team1" as Team),
},
] as unknown as Player[];
const team2Players = [
{
numTilesOwned: jest.fn().mockReturnValue(4), // < 5% territory
team: jest.fn().mockReturnValue("Team2" as Team),
},
] as unknown as Player[];
mg.players.mockReturnValue([...team1Players, ...team2Players]);
mg.numLandTiles.mockReturnValue(100);
mg.numTilesWithFallout.mockReturnValue(0);
winCheck.init(mg, 0);
winCheck.checkWinnerNukeWars();
// Team1 should win since Team2 is below 5%
expect(mg.setWinner).toHaveBeenCalledWith("Team1", expect.anything());
});
it("should not declare bot team as winner", () => {
const botTeamPlayers = [
{
numTilesOwned: jest.fn().mockReturnValue(90),
team: jest.fn().mockReturnValue(ColoredTeams.Bot as Team),
},
] as unknown as Player[];
const playerTeamPlayers = [
{
numTilesOwned: jest.fn().mockReturnValue(4),
team: jest.fn().mockReturnValue("Team1" as Team),
},
] as unknown as Player[];
mg.players.mockReturnValue([...botTeamPlayers, ...playerTeamPlayers]);
mg.numLandTiles.mockReturnValue(100);
mg.numTilesWithFallout.mockReturnValue(0);
winCheck.init(mg, 0);
winCheck.checkWinnerNukeWars();
// Should not declare bot team as winner even if other team is < 5%
expect(mg.setWinner).not.toHaveBeenCalledWith(
ColoredTeams.Bot,
expect.anything(),
);
});
it("should declare winner with most territory when time runs out", () => {
const team1Players = [
{
numTilesOwned: jest.fn().mockReturnValue(60),
team: jest.fn().mockReturnValue("Team1" as Team),
},
] as unknown as Player[];
const team2Players = [
{
numTilesOwned: jest.fn().mockReturnValue(40),
team: jest.fn().mockReturnValue("Team2" as Team),
},
] as unknown as Player[];
mg.players.mockReturnValue([...team1Players, ...team2Players]);
mg.numLandTiles.mockReturnValue(100);
mg.numTilesWithFallout.mockReturnValue(0);
mg.ticks.mockReturnValue(5 * 60 * 10 + 1); // Just past time limit
winCheck.init(mg, 0);
winCheck.checkWinnerNukeWars();
// Team1 should win since they have more territory when time expires
expect(mg.setWinner).toHaveBeenCalledWith("Team1", expect.anything());
});
});