mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-21 10:06:05 +00:00
Cleanup nations (Part 1) 🧹 (#2637)
## Description: 1. Using the wording `"Nation"`, `"FakeHuman"` and `"NPC"` at the same time is confusing. So I renamed every mention of `"FakeHuman"` and `"NPC"` in the entire project to `"Nation"`. Just like they are called ingame. 2. `BotBehavior.ts` was originally intended for sharing the logic between nations and bots. But at the moment, the logic there isn't really shared and it's basically just about attacking. So I renamed `BotBehavior.ts` to `AiAttackBehavior.ts`. I use "Ai" to indicate that this file is used by bots AND nations. 3. Moved `execuction/utils/AllianceBehavior.ts` to `execuction/nation/NationAllianceBehavior.ts` to make sure everybody understands that this file is not about alliances in general. It's just about nations and how they handle alliances. 4. Removed `difficultyModifier` from `DefaultConfig`. It's unused and I think we usually want to finetune the difficulty instead of using that method. 5. Added `assertNever` in all `switch (difficulty)` default cases. ## 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
This commit is contained in:
@@ -0,0 +1,964 @@
|
||||
import {
|
||||
Cell,
|
||||
Difficulty,
|
||||
Execution,
|
||||
Game,
|
||||
Gold,
|
||||
Nation,
|
||||
Player,
|
||||
PlayerID,
|
||||
PlayerType,
|
||||
Relation,
|
||||
TerrainType,
|
||||
Tick,
|
||||
Unit,
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef, euclDistFN } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { GameID } from "../Schemas";
|
||||
import { boundingBoxTiles, calculateBoundingBox, simpleHash } from "../Util";
|
||||
import { ConstructionExecution } from "./ConstructionExecution";
|
||||
import { MirvExecution } from "./MIRVExecution";
|
||||
import { NationAllianceBehavior } from "./nation/NationAllianceBehavior";
|
||||
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
|
||||
import { NukeExecution } from "./NukeExecution";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { TransportShipExecution } from "./TransportShipExecution";
|
||||
import { calculateTerritoryCenter, closestTwoTiles } from "./Util";
|
||||
import { AiAttackBehavior } from "./utils/AiAttackBehavior";
|
||||
|
||||
export class NationExecution implements Execution {
|
||||
private active = true;
|
||||
private random: PseudoRandom;
|
||||
private attackBehavior: AiAttackBehavior | null = null;
|
||||
private allianceBehavior: NationAllianceBehavior | null = null;
|
||||
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 lastNukeSent: [Tick, TileRef][] = [];
|
||||
private readonly lastMIRVSent: [Tick, TileRef][] = [];
|
||||
private readonly embargoMalusApplied = new Set<PlayerID>();
|
||||
|
||||
// Track our transport ships we currently own
|
||||
private trackedTransportShips: Set<Unit> = new Set();
|
||||
// Track our trade ships we currently own
|
||||
private trackedTradeShips: Set<Unit> = new Set();
|
||||
|
||||
/** MIRV Strategy Constants */
|
||||
|
||||
/** Ticks until MIRV can be attempted again */
|
||||
private static readonly MIRV_COOLDOWN_TICKS = 20;
|
||||
|
||||
/** Odds of aborting a MIRV attempt */
|
||||
private static readonly MIRV_HESITATION_ODDS = 7;
|
||||
|
||||
/** Threshold for team victory denial */
|
||||
private static readonly VICTORY_DENIAL_TEAM_THRESHOLD = 0.8;
|
||||
|
||||
/** Threshold for individual victory denial */
|
||||
private static readonly VICTORY_DENIAL_INDIVIDUAL_THRESHOLD = 0.65;
|
||||
|
||||
/** Multiplier for steamroll city gap threshold */
|
||||
private static readonly STEAMROLL_CITY_GAP_MULTIPLIER = 1.3;
|
||||
|
||||
/** Minimum city count for leader to trigger steam roll detection */
|
||||
private static readonly STEAMROLL_MIN_LEADER_CITIES = 10;
|
||||
|
||||
constructor(
|
||||
gameID: GameID,
|
||||
private nation: Nation, // Nation contains PlayerInfo with PlayerType.Nation
|
||||
) {
|
||||
this.random = new PseudoRandom(
|
||||
simpleHash(nation.playerInfo.id) + simpleHash(gameID),
|
||||
);
|
||||
this.attackRate = this.random.nextInt(40, 80);
|
||||
this.attackTick = this.random.nextInt(0, this.attackRate);
|
||||
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;
|
||||
if (this.random.chance(10)) {
|
||||
// this.isTraitor = true
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
player.stopEmbargo(other);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tick(ticks: number) {
|
||||
// Ship tracking
|
||||
if (
|
||||
this.player !== null &&
|
||||
this.player.isAlive() &&
|
||||
this.mg.config().gameConfig().difficulty !== Difficulty.Easy
|
||||
) {
|
||||
this.trackTransportShipsAndRetaliate();
|
||||
this.trackTradeShipsAndRetaliate();
|
||||
}
|
||||
|
||||
if (ticks % this.attackRate !== this.attackTick) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mg.inSpawnPhase()) {
|
||||
const rl = this.randomSpawnLand();
|
||||
if (rl === null) {
|
||||
console.warn(`cannot spawn ${this.nation.playerInfo.name}`);
|
||||
return;
|
||||
}
|
||||
this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, rl));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.player === null) {
|
||||
this.player =
|
||||
this.mg.players().find((p) => p.id() === this.nation.playerInfo.id) ??
|
||||
null;
|
||||
if (this.player === null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.player.isAlive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.attackBehavior === null || this.allianceBehavior === null) {
|
||||
// Player is unavailable during init()
|
||||
this.attackBehavior = new AiAttackBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.triggerRatio,
|
||||
this.reserveRatio,
|
||||
this.expandRatio,
|
||||
);
|
||||
this.allianceBehavior = new NationAllianceBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
);
|
||||
|
||||
// Send an attack on the first tick
|
||||
this.attackBehavior.forceSendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateRelationsFromEmbargos();
|
||||
this.allianceBehavior.handleAllianceRequests();
|
||||
this.allianceBehavior.handleAllianceExtensionRequests();
|
||||
this.handleUnits();
|
||||
this.handleEmbargoesToHostileNations();
|
||||
this.considerMIRV();
|
||||
this.maybeAttack();
|
||||
}
|
||||
|
||||
private maybeAttack() {
|
||||
if (
|
||||
this.player === null ||
|
||||
this.attackBehavior === null ||
|
||||
this.allianceBehavior === null
|
||||
) {
|
||||
throw new Error("not initialized");
|
||||
}
|
||||
|
||||
const border = Array.from(this.player.borderTiles())
|
||||
.flatMap((t) => this.mg.neighbors(t))
|
||||
.filter(
|
||||
(t) =>
|
||||
this.mg.isLand(t) && this.mg.ownerID(t) !== this.player?.smallID(),
|
||||
);
|
||||
const borderingPlayers = [
|
||||
...new Set(
|
||||
border
|
||||
.map((t) => this.mg.playerBySmallID(this.mg.ownerID(t)))
|
||||
.filter((o): o is Player => o.isPlayer()),
|
||||
),
|
||||
].sort((a, b) => a.troops() - b.troops());
|
||||
const borderingFriends = borderingPlayers.filter(
|
||||
(o) => this.player?.isFriendly(o) === true,
|
||||
);
|
||||
const borderingEnemies = borderingPlayers.filter(
|
||||
(o) => this.player?.isFriendly(o) === false,
|
||||
);
|
||||
|
||||
// Attack TerraNullius but not nuked territory
|
||||
const hasNonNukedTerraNullius = border.some(
|
||||
(t) => !this.mg.hasOwner(t) && !this.mg.hasFallout(t),
|
||||
);
|
||||
if (hasNonNukedTerraNullius) {
|
||||
this.attackBehavior.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
|
||||
if (borderingEnemies.length === 0) {
|
||||
if (this.random.chance(5)) {
|
||||
this.sendBoatRandomly();
|
||||
}
|
||||
} else {
|
||||
if (this.random.chance(10)) {
|
||||
this.sendBoatRandomly(borderingEnemies);
|
||||
return;
|
||||
}
|
||||
|
||||
this.allianceBehavior.maybeSendAllianceRequests(borderingEnemies);
|
||||
}
|
||||
|
||||
this.attackBehavior.assistAllies();
|
||||
|
||||
this.attackBehavior.attackBestTarget(borderingFriends, borderingEnemies);
|
||||
|
||||
this.maybeSendNuke(
|
||||
this.attackBehavior.findBestNukeTarget(borderingEnemies),
|
||||
);
|
||||
}
|
||||
|
||||
private maybeSendNuke(other: Player | null) {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const silos = this.player.units(UnitType.MissileSilo);
|
||||
if (
|
||||
silos.length === 0 ||
|
||||
this.player.gold() < this.cost(UnitType.AtomBomb) ||
|
||||
other === null ||
|
||||
other.type() === PlayerType.Bot || // Don't nuke bots (as opposed to nations and humans)
|
||||
this.player.isOnSameTeam(other)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nukeType =
|
||||
this.player.gold() > this.cost(UnitType.HydrogenBomb)
|
||||
? UnitType.HydrogenBomb
|
||||
: UnitType.AtomBomb;
|
||||
const range = nukeType === UnitType.HydrogenBomb ? 60 : 15;
|
||||
|
||||
const structures = other.units(
|
||||
UnitType.City,
|
||||
UnitType.DefensePost,
|
||||
UnitType.MissileSilo,
|
||||
UnitType.Port,
|
||||
UnitType.SAMLauncher,
|
||||
);
|
||||
const structureTiles = structures.map((u) => u.tile());
|
||||
const randomTiles = this.randTerritoryTileArray(10);
|
||||
const allTiles = randomTiles.concat(structureTiles);
|
||||
|
||||
let bestTile: TileRef | null = null;
|
||||
let bestValue = 0;
|
||||
this.removeOldNukeEvents();
|
||||
outer: for (const tile of new Set(allTiles)) {
|
||||
if (tile === null) continue;
|
||||
const boundingBox = boundingBoxTiles(this.mg, tile, range)
|
||||
// Add radius / 2 in case there is a piece of unwanted territory inside the outer radius that we miss.
|
||||
.concat(boundingBoxTiles(this.mg, tile, Math.floor(range / 2)));
|
||||
for (const t of boundingBox) {
|
||||
// Make sure we nuke away from the border
|
||||
if (this.mg.owner(t) !== other) {
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
if (!this.player.canBuild(nukeType, tile)) continue;
|
||||
const value = this.nukeTileScore(tile, silos, structures);
|
||||
if (value > bestValue) {
|
||||
bestTile = tile;
|
||||
bestValue = value;
|
||||
}
|
||||
}
|
||||
if (bestTile !== null) {
|
||||
this.sendNuke(bestTile, nukeType, other);
|
||||
}
|
||||
}
|
||||
|
||||
private removeOldNukeEvents() {
|
||||
const maxAge = 500;
|
||||
const tick = this.mg.ticks();
|
||||
while (
|
||||
this.lastNukeSent.length > 0 &&
|
||||
this.lastNukeSent[0][0] + maxAge < tick
|
||||
) {
|
||||
this.lastNukeSent.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private sendNuke(
|
||||
tile: TileRef,
|
||||
nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb,
|
||||
targetPlayer: Player,
|
||||
) {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const tick = this.mg.ticks();
|
||||
this.lastNukeSent.push([tick, tile]);
|
||||
this.mg.addExecution(new NukeExecution(nukeType, this.player, tile));
|
||||
this.attackBehavior?.maybeSendEmoji(targetPlayer);
|
||||
}
|
||||
|
||||
private nukeTileScore(tile: TileRef, silos: Unit[], targets: Unit[]): number {
|
||||
// Potential damage in a 25-tile radius
|
||||
const dist = euclDistFN(tile, 25, false);
|
||||
let tileValue = targets
|
||||
.filter((unit) => dist(this.mg, unit.tile()))
|
||||
.map((unit): number => {
|
||||
switch (unit.type()) {
|
||||
case UnitType.City:
|
||||
return 25_000;
|
||||
case UnitType.DefensePost:
|
||||
return 5_000;
|
||||
case UnitType.MissileSilo:
|
||||
return 50_000;
|
||||
case UnitType.Port:
|
||||
return 10_000;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
.reduce((prev, cur) => prev + cur, 0);
|
||||
|
||||
// Avoid areas defended by SAM launchers
|
||||
const dist50 = euclDistFN(tile, 50, false);
|
||||
tileValue -=
|
||||
50_000 *
|
||||
targets.filter(
|
||||
(unit) =>
|
||||
unit.type() === UnitType.SAMLauncher && dist50(this.mg, unit.tile()),
|
||||
).length;
|
||||
|
||||
// Prefer tiles that are closer to a silo
|
||||
const siloTiles = silos.map((u) => u.tile());
|
||||
const result = closestTwoTiles(this.mg, siloTiles, [tile]);
|
||||
if (result === null) throw new Error("Missing result");
|
||||
const { x: closestSilo } = result;
|
||||
const distanceSquared = this.mg.euclideanDistSquared(tile, closestSilo);
|
||||
const distanceToClosestSilo = Math.sqrt(distanceSquared);
|
||||
tileValue -= distanceToClosestSilo * 30;
|
||||
|
||||
// Don't target near recent targets
|
||||
tileValue -= this.lastNukeSent
|
||||
.filter(([_tick, tile]) => dist(this.mg, tile))
|
||||
.map((_) => 1_000_000)
|
||||
.reduce((prev, cur) => prev + cur, 0);
|
||||
|
||||
return tileValue;
|
||||
}
|
||||
|
||||
private handleUnits() {
|
||||
return (
|
||||
this.maybeSpawnStructure(UnitType.City, (num) => num) ||
|
||||
this.maybeSpawnStructure(UnitType.Port, (num) => num) ||
|
||||
this.maybeSpawnWarship() ||
|
||||
this.maybeSpawnStructure(UnitType.Factory, (num) => num) ||
|
||||
this.maybeSpawnStructure(UnitType.DefensePost, (num) => (num + 2) ** 2) ||
|
||||
this.maybeSpawnStructure(UnitType.SAMLauncher, (num) => num ** 2) ||
|
||||
this.maybeSpawnStructure(UnitType.MissileSilo, (num) => num ** 2)
|
||||
);
|
||||
}
|
||||
|
||||
private maybeSpawnStructure(
|
||||
type: UnitType,
|
||||
multiplier: (num: number) => number,
|
||||
) {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const owned = this.player.unitsOwned(type);
|
||||
const perceivedCostMultiplier = multiplier(owned + 1);
|
||||
const realCost = this.cost(type);
|
||||
const perceivedCost = realCost * BigInt(perceivedCostMultiplier);
|
||||
if (this.player.gold() < perceivedCost) {
|
||||
return false;
|
||||
}
|
||||
const tile = this.structureSpawnTile(type);
|
||||
if (tile === null) {
|
||||
return false;
|
||||
}
|
||||
const canBuild = this.player.canBuild(type, tile);
|
||||
if (canBuild === false) {
|
||||
return false;
|
||||
}
|
||||
this.mg.addExecution(new ConstructionExecution(this.player, type, tile));
|
||||
return true;
|
||||
}
|
||||
|
||||
private structureSpawnTile(type: UnitType): TileRef | null {
|
||||
if (this.mg === undefined) throw new Error("Not initialized");
|
||||
if (this.player === null) throw new Error("Not initialized");
|
||||
const tiles =
|
||||
type === UnitType.Port
|
||||
? this.randCoastalTileArray(25)
|
||||
: this.randTerritoryTileArray(25);
|
||||
if (tiles.length === 0) return null;
|
||||
const valueFunction = structureSpawnTileValue(this.mg, this.player, type);
|
||||
let bestTile: TileRef | null = null;
|
||||
let bestValue = 0;
|
||||
for (const t of tiles) {
|
||||
const v = valueFunction(t);
|
||||
if (v <= bestValue && bestTile !== null) continue;
|
||||
if (!this.player.canBuild(type, t)) continue;
|
||||
// Found a better tile
|
||||
bestTile = t;
|
||||
bestValue = v;
|
||||
}
|
||||
return bestTile;
|
||||
}
|
||||
|
||||
private randCoastalTileArray(numTiles: number): TileRef[] {
|
||||
const tiles = Array.from(this.player!.borderTiles()).filter((t) =>
|
||||
this.mg.isOceanShore(t),
|
||||
);
|
||||
return Array.from(this.arraySampler(tiles, numTiles));
|
||||
}
|
||||
|
||||
private *arraySampler<T>(a: T[], sampleSize: number): Generator<T> {
|
||||
if (a.length <= sampleSize) {
|
||||
// Return all elements
|
||||
yield* a;
|
||||
} else {
|
||||
// Sample `sampleSize` elements
|
||||
const remaining = new Set<T>(a);
|
||||
while (sampleSize--) {
|
||||
const t = this.random.randFromSet(remaining);
|
||||
remaining.delete(t);
|
||||
yield t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private maybeSpawnWarship(): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
if (!this.random.chance(50)) {
|
||||
return false;
|
||||
}
|
||||
const ports = this.player.units(UnitType.Port);
|
||||
const ships = this.player.units(UnitType.Warship);
|
||||
if (
|
||||
ports.length > 0 &&
|
||||
ships.length === 0 &&
|
||||
this.player.gold() > this.cost(UnitType.Warship)
|
||||
) {
|
||||
const port = this.random.randElement(ports);
|
||||
const targetTile = this.warshipSpawnTile(port.tile());
|
||||
if (targetTile === null) {
|
||||
return false;
|
||||
}
|
||||
const canBuild = this.player.canBuild(UnitType.Warship, targetTile);
|
||||
if (canBuild === false) {
|
||||
console.warn("cannot spawn destroyer");
|
||||
return false;
|
||||
}
|
||||
this.mg.addExecution(
|
||||
new ConstructionExecution(this.player, UnitType.Warship, targetTile),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private randTerritoryTileArray(numTiles: number): TileRef[] {
|
||||
const boundingBox = calculateBoundingBox(
|
||||
this.mg,
|
||||
this.player!.borderTiles(),
|
||||
);
|
||||
const tiles: TileRef[] = [];
|
||||
for (let i = 0; i < numTiles; i++) {
|
||||
const tile = this.randTerritoryTile(this.player!, boundingBox);
|
||||
if (tile !== null) {
|
||||
tiles.push(tile);
|
||||
}
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private randTerritoryTile(
|
||||
p: Player,
|
||||
boundingBox: { min: Cell; max: Cell } | null = null,
|
||||
): TileRef | null {
|
||||
boundingBox ??= calculateBoundingBox(this.mg, p.borderTiles());
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const randX = this.random.nextInt(boundingBox.min.x, boundingBox.max.x);
|
||||
const randY = this.random.nextInt(boundingBox.min.y, boundingBox.max.y);
|
||||
if (!this.mg.isOnMap(new Cell(randX, randY))) {
|
||||
// Sanity check should never happen
|
||||
continue;
|
||||
}
|
||||
const randTile = this.mg.ref(randX, randY);
|
||||
if (this.mg.owner(randTile) === p) {
|
||||
return randTile;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private warshipSpawnTile(portTile: TileRef): TileRef | null {
|
||||
const radius = 250;
|
||||
for (let attempts = 0; attempts < 50; attempts++) {
|
||||
const randX = this.random.nextInt(
|
||||
this.mg.x(portTile) - radius,
|
||||
this.mg.x(portTile) + radius,
|
||||
);
|
||||
const randY = this.random.nextInt(
|
||||
this.mg.y(portTile) - radius,
|
||||
this.mg.y(portTile) + radius,
|
||||
);
|
||||
if (!this.mg.isValidCoord(randX, randY)) {
|
||||
continue;
|
||||
}
|
||||
const tile = this.mg.ref(randX, randY);
|
||||
// Sanity check
|
||||
if (!this.mg.isOcean(tile)) {
|
||||
continue;
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private cost(type: UnitType): Gold {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
return this.mg.unitInfo(type).cost(this.mg, this.player);
|
||||
}
|
||||
|
||||
sendBoatRandomly(borderingEnemies: Player[] = []) {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const oceanShore = Array.from(this.player.borderTiles()).filter((t) =>
|
||||
this.mg.isOceanShore(t),
|
||||
);
|
||||
if (oceanShore.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const src = this.random.randElement(oceanShore);
|
||||
|
||||
// First look for high-interest targets (unowned or bot-owned). Mainly relevant for earlygame
|
||||
let dst = this.randomBoatTarget(src, borderingEnemies, true);
|
||||
if (dst === null) {
|
||||
// None found? Then look for players
|
||||
dst = this.randomBoatTarget(src, borderingEnemies, false);
|
||||
if (dst === null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.mg.addExecution(
|
||||
new TransportShipExecution(
|
||||
this.player,
|
||||
this.mg.owner(dst).id(),
|
||||
dst,
|
||||
this.player.troops() / 5,
|
||||
null,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
randomSpawnLand(): TileRef | null {
|
||||
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 randomBoatTarget(
|
||||
tile: TileRef,
|
||||
borderingEnemies: Player[],
|
||||
highInterestOnly: boolean = false,
|
||||
): TileRef | null {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const x = this.mg.x(tile);
|
||||
const y = this.mg.y(tile);
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const randX = this.random.nextInt(x - 150, x + 150);
|
||||
const randY = this.random.nextInt(y - 150, y + 150);
|
||||
if (!this.mg.isValidCoord(randX, randY)) {
|
||||
continue;
|
||||
}
|
||||
const randTile = this.mg.ref(randX, randY);
|
||||
if (!this.mg.isLand(randTile)) {
|
||||
continue;
|
||||
}
|
||||
const owner = this.mg.owner(randTile);
|
||||
if (owner === this.player) {
|
||||
continue;
|
||||
}
|
||||
// Don't send boats to players with which we share a border, that usually looks stupid
|
||||
if (owner.isPlayer() && borderingEnemies.includes(owner)) {
|
||||
continue;
|
||||
}
|
||||
// Don't spam boats into players that are more than twice as large as us
|
||||
if (owner.isPlayer() && owner.troops() > this.player.troops() * 2) {
|
||||
continue;
|
||||
}
|
||||
// High-interest targeting: prioritize unowned tiles or tiles owned by bots
|
||||
if (highInterestOnly) {
|
||||
if (!owner.isPlayer() || owner.type() === PlayerType.Bot) {
|
||||
return randTile;
|
||||
}
|
||||
} else {
|
||||
// Normal targeting: return unowned tiles or tiles owned by non-friendly players
|
||||
if (!owner.isPlayer() || !owner.isFriendly(this.player)) {
|
||||
return randTile;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// MIRV Strategy Methods
|
||||
private considerMIRV(): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
if (this.player.units(UnitType.MissileSilo).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (this.player.gold() < this.cost(UnitType.MIRV)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeOldMIRVEvents();
|
||||
if (this.lastMIRVSent.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.random.chance(NationExecution.MIRV_HESITATION_ODDS)) {
|
||||
this.triggerMIRVCooldown();
|
||||
return false;
|
||||
}
|
||||
|
||||
const inboundMIRVSender = this.selectCounterMirvTarget();
|
||||
if (inboundMIRVSender) {
|
||||
this.maybeSendMIRV(inboundMIRVSender);
|
||||
return true;
|
||||
}
|
||||
|
||||
const victoryDenialTarget = this.selectVictoryDenialTarget();
|
||||
if (victoryDenialTarget) {
|
||||
this.maybeSendMIRV(victoryDenialTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
const steamrollStopTarget = this.selectSteamrollStopTarget();
|
||||
if (steamrollStopTarget) {
|
||||
this.maybeSendMIRV(steamrollStopTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private selectCounterMirvTarget(): Player | null {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const attackers = this.getValidMirvTargetPlayers().filter((p) =>
|
||||
this.isInboundMIRVFrom(p),
|
||||
);
|
||||
if (attackers.length === 0) return null;
|
||||
attackers.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
|
||||
return attackers[0];
|
||||
}
|
||||
|
||||
private selectVictoryDenialTarget(): Player | null {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const totalLand = this.mg.numLandTiles();
|
||||
if (totalLand === 0) return null;
|
||||
let best: { p: Player; severity: number } | null = null;
|
||||
for (const p of this.getValidMirvTargetPlayers()) {
|
||||
let severity = 0;
|
||||
const team = p.team();
|
||||
if (team !== null) {
|
||||
const teamMembers = this.mg
|
||||
.players()
|
||||
.filter((x) => x.team() === team && x.isPlayer());
|
||||
const teamTerritory = teamMembers
|
||||
.map((x) => x.numTilesOwned())
|
||||
.reduce((a, b) => a + b, 0);
|
||||
const teamShare = teamTerritory / totalLand;
|
||||
if (teamShare >= NationExecution.VICTORY_DENIAL_TEAM_THRESHOLD) {
|
||||
// Only consider the largest team member as the target when team exceeds threshold
|
||||
let largestMember: Player | null = null;
|
||||
let largestTiles = -1;
|
||||
for (const member of teamMembers) {
|
||||
const tiles = member.numTilesOwned();
|
||||
if (tiles > largestTiles) {
|
||||
largestTiles = tiles;
|
||||
largestMember = member;
|
||||
}
|
||||
}
|
||||
if (largestMember === p) {
|
||||
severity = teamShare;
|
||||
} else {
|
||||
severity = 0; // Skip non-largest members
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const share = p.numTilesOwned() / totalLand;
|
||||
if (share >= NationExecution.VICTORY_DENIAL_INDIVIDUAL_THRESHOLD)
|
||||
severity = share;
|
||||
}
|
||||
if (severity > 0) {
|
||||
if (best === null || severity > best.severity) best = { p, severity };
|
||||
}
|
||||
}
|
||||
return best ? best.p : null;
|
||||
}
|
||||
|
||||
private selectSteamrollStopTarget(): Player | null {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const validTargets = this.getValidMirvTargetPlayers();
|
||||
|
||||
if (validTargets.length === 0) return null;
|
||||
|
||||
const allPlayers = this.mg
|
||||
.players()
|
||||
.filter((p) => p.isPlayer())
|
||||
.map((p) => ({ p, cityCount: this.countCities(p) }))
|
||||
.sort((a, b) => b.cityCount - a.cityCount);
|
||||
|
||||
if (allPlayers.length < 2) return null;
|
||||
|
||||
const topPlayer = allPlayers[0];
|
||||
|
||||
if (topPlayer.cityCount <= NationExecution.STEAMROLL_MIN_LEADER_CITIES)
|
||||
return null;
|
||||
|
||||
const secondHighest = allPlayers[1].cityCount;
|
||||
|
||||
const threshold =
|
||||
secondHighest * NationExecution.STEAMROLL_CITY_GAP_MULTIPLIER;
|
||||
|
||||
if (topPlayer.cityCount >= threshold) {
|
||||
return validTargets.some((p) => p === topPlayer.p) ? topPlayer.p : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// MIRV Helper Methods
|
||||
private mirvTargetsCache: {
|
||||
tick: number;
|
||||
players: Player[];
|
||||
} | null = null;
|
||||
|
||||
private getValidMirvTargetPlayers(): Player[] {
|
||||
const MIRV_TARGETS_CACHE_TICKS = 2 * 10; // 2 seconds
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
|
||||
if (
|
||||
this.mirvTargetsCache &&
|
||||
this.mg.ticks() - this.mirvTargetsCache.tick < MIRV_TARGETS_CACHE_TICKS
|
||||
) {
|
||||
return this.mirvTargetsCache.players;
|
||||
}
|
||||
|
||||
const players = this.mg.players().filter((p) => {
|
||||
return (
|
||||
p !== this.player &&
|
||||
p.isPlayer() &&
|
||||
p.type() !== PlayerType.Bot &&
|
||||
!this.player!.isOnSameTeam(p)
|
||||
);
|
||||
});
|
||||
|
||||
this.mirvTargetsCache = { tick: this.mg.ticks(), players };
|
||||
return players;
|
||||
}
|
||||
|
||||
private isInboundMIRVFrom(attacker: Player): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const enemyMirvs = attacker.units(UnitType.MIRV);
|
||||
for (const mirv of enemyMirvs) {
|
||||
const dst = mirv.targetTile();
|
||||
if (!dst) continue;
|
||||
if (!this.mg.hasOwner(dst)) continue;
|
||||
const owner = this.mg.owner(dst);
|
||||
if (owner === this.player) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private countCities(p: Player): number {
|
||||
return p.unitCount(UnitType.City);
|
||||
}
|
||||
|
||||
private calculateTerritoryCenter(target: Player): TileRef | null {
|
||||
return calculateTerritoryCenter(this.mg, target);
|
||||
}
|
||||
|
||||
// MIRV Execution Methods
|
||||
private maybeSendMIRV(enemy: Player): void {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
|
||||
this.attackBehavior?.maybeSendEmoji(enemy);
|
||||
|
||||
const centerTile = this.calculateTerritoryCenter(enemy);
|
||||
if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) {
|
||||
this.sendMIRV(centerTile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private sendMIRV(tile: TileRef): void {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
this.triggerMIRVCooldown(tile);
|
||||
this.mg.addExecution(new MirvExecution(this.player, tile));
|
||||
}
|
||||
|
||||
private triggerMIRVCooldown(tile?: TileRef): void {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
this.removeOldMIRVEvents();
|
||||
const tick = this.mg.ticks();
|
||||
// Use provided tile or any tile from player's territory for cooldown tracking
|
||||
const cooldownTile =
|
||||
tile ?? Array.from(this.player.tiles())[0] ?? this.mg.ref(0, 0);
|
||||
this.lastMIRVSent.push([tick, cooldownTile]);
|
||||
}
|
||||
|
||||
private removeOldMIRVEvents() {
|
||||
const maxAge = NationExecution.MIRV_COOLDOWN_TICKS;
|
||||
const tick = this.mg.ticks();
|
||||
while (
|
||||
this.lastMIRVSent.length > 0 &&
|
||||
this.lastMIRVSent[0][0] + maxAge <= tick
|
||||
) {
|
||||
this.lastMIRVSent.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Send out a warship if our transport ship got captured
|
||||
private trackTransportShipsAndRetaliate(): void {
|
||||
if (this.player === null) return;
|
||||
|
||||
// Add any currently owned transport ships to our tracking set
|
||||
this.player
|
||||
.units(UnitType.TransportShip)
|
||||
.forEach((u) => this.trackedTransportShips.add(u));
|
||||
|
||||
// Iterate tracked transport ships; if it got destroyed by an enemy: retaliate
|
||||
for (const ship of Array.from(this.trackedTransportShips)) {
|
||||
if (!ship.isActive()) {
|
||||
// Distinguish between arrival/retreat and enemy destruction
|
||||
if (ship.wasDestroyedByEnemy()) {
|
||||
this.maybeRetaliateWithWarship(ship.tile());
|
||||
}
|
||||
this.trackedTransportShips.delete(ship);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send out a warship if our trade ship got captured
|
||||
private trackTradeShipsAndRetaliate(): void {
|
||||
if (this.player === null) return;
|
||||
|
||||
// Add any currently owned trade ships to our tracking map
|
||||
this.player
|
||||
.units(UnitType.TradeShip)
|
||||
.forEach((u) => this.trackedTradeShips.add(u));
|
||||
|
||||
// Iterate tracked trade ships; if we no longer own it, it was captured: retaliate
|
||||
for (const ship of Array.from(this.trackedTradeShips)) {
|
||||
if (!ship.isActive()) {
|
||||
this.trackedTradeShips.delete(ship);
|
||||
continue;
|
||||
}
|
||||
if (ship.owner().id() !== this.player.id()) {
|
||||
// Ship was ours and is now owned by someone else -> captured
|
||||
this.maybeRetaliateWithWarship(ship.tile());
|
||||
this.trackedTradeShips.delete(ship);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private maybeRetaliateWithWarship(tile: TileRef): void {
|
||||
if (this.player === null) return;
|
||||
|
||||
const { difficulty } = this.mg.config().gameConfig();
|
||||
// In Easy never retaliate. In Medium retaliate with 15% chance. Hard with 50%, Impossible with 80%.
|
||||
if (
|
||||
(difficulty === Difficulty.Medium && this.random.nextInt(0, 100) < 15) ||
|
||||
(difficulty === Difficulty.Hard && this.random.nextInt(0, 100) < 50) ||
|
||||
(difficulty === Difficulty.Impossible && this.random.nextInt(0, 100) < 80)
|
||||
) {
|
||||
const canBuild = this.player.canBuild(UnitType.Warship, tile);
|
||||
if (canBuild === false) {
|
||||
return;
|
||||
}
|
||||
this.mg.addExecution(
|
||||
new ConstructionExecution(this.player, UnitType.Warship, tile),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user