diff --git a/README.md b/README.md
index db90be6cb..1d09ffd27 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/gamemodes/nuke-wars.md b/docs/gamemodes/nuke-wars.md
new file mode 100644
index 000000000..41d067a19
--- /dev/null
+++ b/docs/gamemodes/nuke-wars.md
@@ -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
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index a7a743ff1..2cc738984 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -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 {
${this.lobbyIdVisible ? this.lobbyId : "••••••••"}
-
-
-
- ${
- this.copySuccess
- ? html`
✓ `
- : html`
-
-
-
- `
- }
-
@@ -271,6 +259,12 @@ export class HostLobbyModal extends LitElement {
${translateText("game_mode.teams")}
+ this.handleGameModeSelection(GameMode.NukeWars)}
+ >
+
Nuke Wars
+
@@ -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}`,
diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts
index 446dde847..3d695133e 100644
--- a/src/client/SinglePlayerModal.ts
+++ b/src/client/SinglePlayerModal.ts
@@ -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")}
+ this.handleGameModeSelection(GameMode.NukeWars)}
+ >
+
Nuke Wars
+
@@ -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)" : ""}`,
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts
index 4491dede9..f697ce382 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -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,
diff --git a/src/client/graphics/layers/NukeWarsTopBanner.ts b/src/client/graphics/layers/NukeWarsTopBanner.ts
new file mode 100644
index 000000000..1b47a398a
--- /dev/null
+++ b/src/client/graphics/layers/NukeWarsTopBanner.ts
@@ -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();
+}
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 2a4388d11..aaea7ec8d 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -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 {
diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts
index 57baff6ee..89365cbf6 100644
--- a/src/core/execution/SpawnExecution.ts
+++ b/src/core/execution/SpawnExecution.ts
@@ -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);
});
diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts
index be9793cb8..cabc32ddd 100644
--- a/src/core/execution/WinCheckExecution.ts
+++ b/src/core/execution/WinCheckExecution.ts
@@ -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();
+ 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;
}
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index db48e0a2f..181cf259a 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -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);
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index d9ba8ba7c..5acde80b6 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -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 {
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index 799c25c88..1b5a972af 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -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) {
diff --git a/tests/core/executions/NukeWarsRestrictions.test.ts b/tests/core/executions/NukeWarsRestrictions.test.ts
new file mode 100644
index 000000000..b1f9867e0
--- /dev/null
+++ b/tests/core/executions/NukeWarsRestrictions.test.ts
@@ -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;
+
+ 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;
+ });
+
+ 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;
+
+ 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;
+ });
+
+ 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);
+ });
+ });
+ });
+});
diff --git a/tests/core/executions/NukeWarsWinCheck.test.ts b/tests/core/executions/NukeWarsWinCheck.test.ts
new file mode 100644
index 000000000..172979111
--- /dev/null
+++ b/tests/core/executions/NukeWarsWinCheck.test.ts
@@ -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;
+
+ 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;
+ });
+
+ 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());
+ });
+});