mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-06 18:46:49 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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)" : ""}`,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user