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()); + }); +});