Files
OpenFrontIO/src/core/execution/NationExecution.ts
T
FloPinguin 339ace0bd6 v30 nuke wars preparation: Disable boats & Team spawn zones (#3263)
## Description:

Preparation for nuke wars, for v30.
Next PR will be adding the nuke wars modifier for public games, but
Wonders https://github.com/openfrontio/OpenFrontIO/pull/3224 needs to be
merged first to avoid merge conflicts.

### 1. Disable boats setting

It's possible to disable `UnitType.TransportShip` now. Because they are
not needed in nuke wars and can even be annoying.

<img width="720" height="320" alt="image"
src="https://github.com/user-attachments/assets/661bc10d-b204-4b4f-b876-ee7c9b92de8c"
/>

### 2. Team spawn zones for random spawn

Maps can have `teamGameSpawnAreas` in their json file now.
Spawn areas are currently active if 
- a supported map is chosen (Baikal Nuke Wars or Four Islands)
- a supported team size is chosen (2 teams on Baikal Nuke Wars or 2/4
teams on Four Islands)
- random spawn is enabled

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FloPinguin
2026-02-23 16:12:24 -06:00

330 lines
10 KiB
TypeScript

import {
Difficulty,
Execution,
Game,
Nation,
Player,
PlayerID,
Relation,
TerrainType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
import { assertNever, simpleHash } from "../Util";
import { NationAllianceBehavior } from "./nation/NationAllianceBehavior";
import { NationEmojiBehavior } from "./nation/NationEmojiBehavior";
import { NationMIRVBehavior } from "./nation/NationMIRVBehavior";
import { NationNukeBehavior } from "./nation/NationNukeBehavior";
import { NationStructureBehavior } from "./nation/NationStructureBehavior";
import { NationWarshipBehavior } from "./nation/NationWarshipBehavior";
import { SpawnExecution } from "./SpawnExecution";
import { AiAttackBehavior } from "./utils/AiAttackBehavior";
export class NationExecution implements Execution {
private active = true;
private random: PseudoRandom;
private behaviorsInitialized = false;
private emojiBehavior!: NationEmojiBehavior;
private mirvBehavior!: NationMIRVBehavior;
private attackBehavior!: AiAttackBehavior;
private allianceBehavior!: NationAllianceBehavior;
private warshipBehavior!: NationWarshipBehavior;
private nukeBehavior!: NationNukeBehavior;
private structureBehavior!: NationStructureBehavior;
private mg: Game;
private player: Player | null = null;
private attackRate: number;
private attackTick: number;
private triggerRatio: number;
private reserveRatio: number;
private expandRatio: number;
private readonly embargoMalusApplied = new Set<PlayerID>();
constructor(
private gameID: GameID,
private nation: Nation, // Nation contains PlayerInfo with PlayerType.Nation
) {
this.random = new PseudoRandom(
simpleHash(nation.playerInfo.id) + simpleHash(gameID),
);
this.triggerRatio = this.random.nextInt(50, 60) / 100;
this.reserveRatio = this.random.nextInt(30, 40) / 100;
this.expandRatio = this.random.nextInt(10, 20) / 100;
}
init(mg: Game) {
this.mg = mg;
this.attackRate = this.getAttackRate();
this.attackTick = this.random.nextInt(0, this.attackRate);
if (!this.mg.hasPlayer(this.nation.playerInfo.id)) {
this.player = this.mg.addPlayer(this.nation.playerInfo);
} else {
this.player = this.mg.player(this.nation.playerInfo.id);
}
}
private getAttackRate(): number {
const { difficulty } = this.mg.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return this.random.nextInt(65, 80); // Slower reactions
case Difficulty.Medium:
return this.random.nextInt(55, 70);
case Difficulty.Hard:
return this.random.nextInt(45, 60);
case Difficulty.Impossible:
return this.random.nextInt(30, 50); // Faster reactions
default:
assertNever(difficulty);
}
}
tick(ticks: number) {
// Ship tracking
if (
this.behaviorsInitialized &&
this.player !== null &&
this.player.isAlive() &&
this.mg.config().gameConfig().difficulty !== Difficulty.Easy
) {
this.warshipBehavior.trackShipsAndRetaliate();
}
if (this.player === null) {
return;
}
if (this.mg.inSpawnPhase()) {
if (ticks % this.attackRate !== this.attackTick) {
return;
}
// Place nations without a spawn cell (Dynamically created for HumansVsNations) randomly by SpawnExecution
if (this.nation.spawnCell === undefined) {
this.mg.addExecution(
new SpawnExecution(this.gameID, this.nation.playerInfo),
);
return;
}
// If team spawn areas are configured and the nation's spawn cell
// is outside its team's area, spawn randomly within the area instead.
const team = this.player.team();
if (team !== null) {
const area = this.mg.teamSpawnArea(team);
if (area !== undefined) {
const cell = this.nation.spawnCell;
const inArea =
cell.x >= area.x &&
cell.x < area.x + area.width &&
cell.y >= area.y &&
cell.y < area.y + area.height;
if (!inArea) {
this.mg.addExecution(
new SpawnExecution(this.gameID, this.nation.playerInfo),
);
return;
}
}
}
// Select a tile near the position defined in the map manifest
const rl = this.randomSpawnLand();
if (rl === null) {
console.warn(`cannot spawn ${this.nation.playerInfo.name}`);
return;
}
this.mg.addExecution(
new SpawnExecution(this.gameID, this.nation.playerInfo, rl),
);
return;
}
if (!this.player.isAlive()) {
//removeOnDeath is called from nation's PlayerExecution
this.active = false;
return;
}
if (!this.behaviorsInitialized) {
this.initializeBehaviors();
this.attackBehavior.forceSendAttack(this.mg.terraNullius());
return;
}
if (ticks % this.attackRate !== this.attackTick) {
// Call handleStructures twice between regular attack ticks (at 1/3 and 2/3 of the interval)
// Otherwise it is possible that we earn more gold than we can spend
// The alternative is placing multiple structures in handleStructures, but that causes problems
if (this.player.isAlive()) {
const offset = ticks % this.attackRate;
const oneThird =
(this.attackTick + Math.floor(this.attackRate / 3)) % this.attackRate;
const twoThirds =
(this.attackTick + Math.floor((this.attackRate * 2) / 3)) %
this.attackRate;
if (offset === oneThird || offset === twoThirds) {
this.structureBehavior.handleStructures();
}
}
return;
}
this.emojiBehavior.maybeSendCasualEmoji();
this.updateRelationsFromEmbargos();
this.allianceBehavior.handleAllianceRequests();
this.allianceBehavior.handleAllianceExtensionRequests();
this.mirvBehavior.considerMIRV();
this.structureBehavior.handleStructures();
this.warshipBehavior.maybeSpawnWarship();
this.handleEmbargoesToHostileNations();
this.attackBehavior.maybeAttack();
this.warshipBehavior.counterWarshipInfestation();
this.nukeBehavior.maybeSendNuke();
}
private initializeBehaviors(): void {
if (this.player === null) throw new Error("Player not initialized");
this.emojiBehavior = new NationEmojiBehavior(
this.random,
this.mg,
this.player,
);
this.mirvBehavior = new NationMIRVBehavior(
this.random,
this.mg,
this.player,
this.emojiBehavior,
);
this.allianceBehavior = new NationAllianceBehavior(
this.random,
this.mg,
this.player,
this.emojiBehavior,
);
this.warshipBehavior = new NationWarshipBehavior(
this.random,
this.mg,
this.player,
this.emojiBehavior,
);
this.attackBehavior = new AiAttackBehavior(
this.random,
this.mg,
this.player,
this.triggerRatio,
this.reserveRatio,
this.expandRatio,
this.allianceBehavior,
this.emojiBehavior,
);
this.nukeBehavior = new NationNukeBehavior(
this.random,
this.mg,
this.player,
this.attackBehavior,
this.emojiBehavior,
);
this.structureBehavior = new NationStructureBehavior(
this.random,
this.mg,
this.player,
);
this.behaviorsInitialized = true;
}
private randomSpawnLand(): TileRef | null {
if (this.nation.spawnCell === undefined) throw new Error("not initialized");
const delta = 25;
let tries = 0;
while (tries < 50) {
tries++;
const cell = this.nation.spawnCell;
const x = this.random.nextInt(cell.x - delta, cell.x + delta);
const y = this.random.nextInt(cell.y - delta, cell.y + delta);
if (!this.mg.isValidCoord(x, y)) {
continue;
}
const tile = this.mg.ref(x, y);
if (this.mg.isLand(tile) && !this.mg.hasOwner(tile)) {
if (
this.mg.terrainType(tile) === TerrainType.Mountain &&
this.random.chance(2)
) {
continue;
}
return tile;
}
}
return null;
}
private updateRelationsFromEmbargos() {
const player = this.player;
if (player === null) return;
const others = this.mg.players().filter((p) => p.id() !== player.id());
others.forEach((other: Player) => {
const embargoMalus = -20;
if (
other.hasEmbargoAgainst(player) &&
!this.embargoMalusApplied.has(other.id())
) {
player.updateRelation(other, embargoMalus);
this.embargoMalusApplied.add(other.id());
} else if (
!other.hasEmbargoAgainst(player) &&
this.embargoMalusApplied.has(other.id())
) {
player.updateRelation(other, -embargoMalus);
this.embargoMalusApplied.delete(other.id());
}
});
}
private handleEmbargoesToHostileNations() {
const player = this.player;
if (player === null) return;
const others = this.mg.players().filter((p) => p.id() !== player.id());
others.forEach((other: Player) => {
/* When player is hostile starts embargo. Do not stop until neutral again */
if (
player.relation(other) <= Relation.Hostile &&
!player.hasEmbargoAgainst(other) &&
!player.isOnSameTeam(other)
) {
player.addEmbargo(other, false);
} else if (
player.relation(other) >= Relation.Neutral &&
player.hasEmbargoAgainst(other) &&
this.mg.config().gameConfig().difficulty !== Difficulty.Hard &&
this.mg.config().gameConfig().difficulty !== Difficulty.Impossible
) {
player.stopEmbargo(other);
} else if (
player.relation(other) >= Relation.Friendly &&
player.hasEmbargoAgainst(other) &&
this.mg.config().gameConfig().difficulty !== Difficulty.Impossible
) {
player.stopEmbargo(other);
}
});
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return true;
}
}