☢️ Nations send much better nukes now (Part 1) ☢️ (#2756)

This is a very important PR for HumansVsNations (But also for
singleplayer).
Humans will throw lots of nukes onto nations, but nations didn't do
that. Until now :)

## Refactor

- Moved all the nuking logic to the new file `NationNukeBehavior.ts`
- Moved `randTerritoryTileArray()` and `randTerritoryTile()` to the new
file `NationUtils.ts` because we need that method in multiple places now
- Because we already have an `NationUtils.ts` (It contains the method
`createNationsForGame` for HumansVsNations) I renamed the old one to
`NationCreation.ts` to avoid confusion

## Bug fixed

- `allRelationsSorted()` in `PlayerImpl` returned dead players all the
time... Which caused nations to not attack / send nukes in some cases...

## Nuke-sending features / improvements

- On hard and impossible difficulty, nations no longer make sure that
nukes will only hit inside of their targets border. This logic very
often stopped nations from throwing nukes. Now their nukes are allowed
to hit TerraNullius (=> ocean!). And in team games, it's even allowed
that their nukes hit other non-friendly players as well! This is very
important for HumansVsNations.
- The basic check for SAMs now gets skipped if we are on easy difficulty
(easy nations are not smart enough to do that)
- I improved the basic check for SAMs (medium difficulty) a bit (nations
send less nukes into SAMs)
- On hard and impossible difficulty, we now use the new method
`isTrajectoryInterceptableBySam()` to avoid SAMs completely. It's
mirroring `NukeTrajectoryPreviewLayer.ts` logic a bit.
- I added "perceived cost" to simulate nations saving up for a MIRV
(Otherwise most hard/impossible nations will spend all their gold on
nukes). But if we are in a team game (MIRVs are not relevant) or if we
already saved up for a MIRV, the "perceived cost" gets ignored.
- Updated the "most hated player" selection in `findBestNukeTarget()` to
ignore very weak players. We don't need to throw nukes at players which
we can easily steamroll by land.
- Added `findFFACrownTarget()` to nuke the crown (based on difficulty).
- Added `findStrongestTeamTarget()` to nuke the strongest team.
- Updated `randTerritoryTile()` so that it has a higher chance of
returning the tiles of a
"leftover-nuked-to-death-player-with-some-tiles-left": `if
(p.numTilesOwned() <= 100) {return
random.randElement(Array.from(p.tiles()));}`.
- Changed `const range = nukeType === UnitType.HydrogenBomb ? 60 : 15`
to `config().nukeMagnitudes(nukeType).inner`. Should make more sense.
- Adjusted `nukeTileScore()` to search for units in
`this.mg.config().nukeMagnitudes(nukeType).inner` instead of fixed 25
- Adjusted `nukeTileScore()` to account for unit levels (levels got
ignored previously). Also increased score for ports from 10_000 to
15_000.
- I made sure that nations can nuke EVERY SINGLE TILE from an enemy,
even if the enemy has no structures ("Prefer tiles that are closer to a
silo" can no longer make the `nukeTileScore()` drop too much,
`bestValue` in `maybeSendNuke()` starts at -1 now)
- In the entire nuking logic, factories were missing. Now they are
added.

## Media

Nation team vs. nation team: They are nuking the very last pixels of
red, just like humans would do it 😀

<img width="915" height="683" alt="image"
src="https://github.com/user-attachments/assets/109c7921-b959-4aa9-a971-0d7742971686"
/>

Hard difficulty FFA game: Nations throwing much more nukes. And they are
nuking the crown.


https://github.com/user-attachments/assets/a6e43924-a6ca-4b1a-a578-4e4f8252e383

Lots of nukes flying:


https://github.com/user-attachments/assets/8fc4edad-a6e6-4476-8a86-08cdef58169e

## 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

---------

Co-authored-by: iamlewis <lewismmmm@gmail.com>
This commit is contained in:
FloPinguin
2026-01-01 22:29:46 +01:00
committed by GitHub
parent 96622779d1
commit 23e4bf6725
8 changed files with 551 additions and 210 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ import {
GameUpdateType,
GameUpdateViewData,
} from "./game/GameUpdates";
import { createNationsForGame } from "./game/NationUtils";
import { createNationsForGame } from "./game/NationCreation";
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
import { PseudoRandom } from "./PseudoRandom";
import { ClientID, GameStartInfo, Turn } from "./Schemas";
+19 -189
View File
@@ -1,5 +1,4 @@
import {
Cell,
Difficulty,
Execution,
Game,
@@ -10,30 +9,23 @@ import {
PlayerType,
Relation,
TerrainType,
Tick,
Unit,
UnitType,
} from "../game/Game";
import { TileRef, euclDistFN } from "../game/GameMap";
import { TileRef } from "../game/GameMap";
import { canBuildTransportShip } from "../game/TransportShipUtils";
import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
import {
assertNever,
boundingBoxTiles,
calculateBoundingBox,
simpleHash,
} from "../Util";
import { assertNever, simpleHash } from "../Util";
import { ConstructionExecution } from "./ConstructionExecution";
import { NationAllianceBehavior } from "./nation/NationAllianceBehavior";
import { EMOJI_NUKE, NationEmojiBehavior } from "./nation/NationEmojiBehavior";
import { NationEmojiBehavior } from "./nation/NationEmojiBehavior";
import { NationMIRVBehavior } from "./nation/NationMIRVBehavior";
import { NationNukeBehavior } from "./nation/NationNukeBehavior";
import { randTerritoryTileArray } from "./nation/NationUtils";
import { NationWarshipBehavior } from "./nation/NationWarshipBehavior";
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
import { NukeExecution } from "./NukeExecution";
import { SpawnExecution } from "./SpawnExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { closestTwoTiles } from "./Util";
import { AiAttackBehavior } from "./utils/AiAttackBehavior";
export class NationExecution implements Execution {
@@ -44,6 +36,7 @@ export class NationExecution implements Execution {
private attackBehavior: AiAttackBehavior | null = null;
private allianceBehavior: NationAllianceBehavior | null = null;
private warshipBehavior: NationWarshipBehavior | null = null;
private nukeBehavior: NationNukeBehavior | null = null;
private mg: Game;
private player: Player | null = null;
@@ -53,7 +46,6 @@ export class NationExecution implements Execution {
private reserveRatio: number;
private expandRatio: number;
private readonly lastNukeSent: [Tick, TileRef][] = [];
private readonly embargoMalusApplied = new Set<PlayerID>();
constructor(
@@ -148,7 +140,8 @@ export class NationExecution implements Execution {
this.mirvBehavior === null ||
this.attackBehavior === null ||
this.allianceBehavior === null ||
this.warshipBehavior === null
this.warshipBehavior === null ||
this.nukeBehavior === null
) {
// Player is unavailable during init()
this.emojiBehavior = new NationEmojiBehavior(
@@ -184,6 +177,13 @@ export class NationExecution implements Execution {
this.allianceBehavior,
this.emojiBehavior,
);
this.nukeBehavior = new NationNukeBehavior(
this.random,
this.mg,
this.player,
this.attackBehavior,
this.emojiBehavior,
);
// Send an attack on the first tick
this.attackBehavior.forceSendAttack(this.mg.terraNullius());
@@ -293,7 +293,7 @@ export class NationExecution implements Execution {
const tiles =
type === UnitType.Port
? this.randCoastalTileArray(25)
: this.randTerritoryTileArray(25);
: randTerritoryTileArray(this.random, this.mg, this.player, 25);
if (tiles.length === 0) return null;
const valueFunction = structureSpawnTileValue(this.mg, this.player, type);
let bestTile: TileRef | null = null;
@@ -411,7 +411,8 @@ export class NationExecution implements Execution {
if (
this.player === null ||
this.attackBehavior === null ||
this.allianceBehavior === null
this.allianceBehavior === null ||
this.nukeBehavior === null
) {
throw new Error("not initialized");
}
@@ -459,9 +460,7 @@ export class NationExecution implements Execution {
}
this.attackBehavior.attackBestTarget(borderingFriends, borderingEnemies);
this.maybeSendNuke(
this.attackBehavior.findBestNukeTarget(borderingEnemies),
);
this.nukeBehavior.maybeSendNuke(this.attackBehavior.findBestNukeTarget());
}
private sendBoatRandomly(borderingEnemies: Player[] = []) {
@@ -558,175 +557,6 @@ export class NationExecution implements Execution {
return null;
}
private maybeSendNuke(other: Player | null) {
if (this.player === null || this.attackBehavior === 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) ||
this.attackBehavior.shouldAttack(other) === false
) {
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 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 sendNuke(
tile: TileRef,
nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb,
targetPlayer: Player,
) {
if (
this.player === null ||
this.attackBehavior === null ||
this.emojiBehavior === 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.emojiBehavior.maybeSendEmoji(targetPlayer, EMOJI_NUKE);
}
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 cost(type: UnitType): Gold {
if (this.player === null) throw new Error("not initialized");
return this.mg.unitInfo(type).cost(this.mg, this.player);
@@ -0,0 +1,332 @@
import {
Difficulty,
Game,
GameMode,
Gold,
Player,
PlayerType,
Tick,
Unit,
UnitType,
} from "../../game/Game";
import { TileRef, euclDistFN } from "../../game/GameMap";
import { ParabolaPathFinder } from "../../pathfinding/PathFinding";
import { PseudoRandom } from "../../PseudoRandom";
import { boundingBoxTiles } from "../../Util";
import { NukeExecution } from "../NukeExecution";
import { closestTwoTiles } from "../Util";
import { AiAttackBehavior } from "../utils/AiAttackBehavior";
import { EMOJI_NUKE, NationEmojiBehavior } from "./NationEmojiBehavior";
import { randTerritoryTileArray } from "./NationUtils";
export class NationNukeBehavior {
private readonly lastNukeSent: [Tick, TileRef][] = [];
private atomBombsLaunched = 0;
private atomBombPerceivedCost = this.cost(UnitType.AtomBomb);
private hydrogenBombsLaunched = 0;
private hydrogenBombPerceivedCost = this.cost(UnitType.HydrogenBomb);
constructor(
private random: PseudoRandom,
private mg: Game,
private player: Player,
private attackBehavior: AiAttackBehavior,
private emojiBehavior: NationEmojiBehavior,
) {}
maybeSendNuke(other: Player | null) {
const silos = this.player.units(UnitType.MissileSilo);
if (
silos.length === 0 ||
other === null ||
other.type() === PlayerType.Bot || // Don't nuke bots (as opposed to nations and humans)
this.player.isOnSameTeam(other) ||
this.attackBehavior.shouldAttack(other) === false
) {
return;
}
const hydroCost = this.getPerceivedNukeCost(UnitType.HydrogenBomb);
const atomCost = this.getPerceivedNukeCost(UnitType.AtomBomb);
let nukeType: UnitType;
if (this.player.gold() >= hydroCost) {
nukeType = UnitType.HydrogenBomb;
} else if (this.player.gold() >= atomCost) {
nukeType = UnitType.AtomBomb;
} else {
return;
}
const range = this.mg.config().nukeMagnitudes(nukeType).inner;
const structures = other.units(
UnitType.City,
UnitType.DefensePost,
UnitType.MissileSilo,
UnitType.Port,
UnitType.SAMLauncher,
UnitType.Factory,
);
const structureTiles = structures.map((u) => u.tile());
const randomTiles = randTerritoryTileArray(this.random, this.mg, other, 10);
const allTiles = randomTiles.concat(structureTiles);
let bestTile: TileRef | null = null;
let bestValue = -1; // -1 is important, so that we can also nuke land without structures
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) {
if (!this.isValidNukeTile(t, other)) {
continue outer;
}
}
const spawnTile = this.player.canBuild(nukeType, tile);
if (spawnTile === false) continue;
// On Hard & Impossible, avoid trajectories that can be intercepted by enemy SAMs
const difficulty = this.mg.config().gameConfig().difficulty;
if (
(difficulty === Difficulty.Hard ||
difficulty === Difficulty.Impossible) &&
this.isTrajectoryInterceptableBySam(spawnTile, tile)
) {
continue;
}
const value = this.nukeTileScore(tile, silos, structures, nukeType);
if (value > bestValue) {
bestTile = tile;
bestValue = value;
}
}
if (bestTile !== null) {
this.sendNuke(bestTile, nukeType, other);
}
}
// Simulate saving up for a MIRV
private getPerceivedNukeCost(type: UnitType): Gold {
// Return the actual cost in team games (saving up for a MIRV is not relevant, the game will be finished before that)
// or if we already have enough gold to buy both a MIRV and a hydro
if (
this.mg.config().gameConfig().gameMode === GameMode.Team ||
this.player.gold() >
this.cost(UnitType.MIRV) + this.cost(UnitType.HydrogenBomb)
) {
return this.cost(type);
}
if (type === UnitType.AtomBomb) {
return this.atomBombPerceivedCost;
} else {
return this.hydrogenBombPerceivedCost;
}
}
// mirroring NukeTrajectoryPreviewLayer.ts logic a bit
private isTrajectoryInterceptableBySam(
spawnTile: TileRef,
targetTile: TileRef,
): boolean {
const pathFinder = new ParabolaPathFinder(this.mg);
const speed = this.mg.config().defaultNukeSpeed();
const distanceBasedHeight = true; // Atom/Hydrogen bombs use distance-based height
const rocketDirectionUp = true; // AI nukes always go "up" for now
pathFinder.computeControlPoints(
spawnTile,
targetTile,
speed,
distanceBasedHeight,
rocketDirectionUp,
);
const trajectory = pathFinder.allTiles();
if (trajectory.length === 0) {
return false;
}
const targetRangeSquared =
this.mg.config().defaultNukeTargetableRange() ** 2;
let untargetableStart = -1;
let untargetableEnd = -1;
for (let i = 0; i < trajectory.length; i++) {
const tile = trajectory[i];
if (untargetableStart === -1) {
if (
this.mg.euclideanDistSquared(tile, spawnTile) > targetRangeSquared
) {
if (
this.mg.euclideanDistSquared(tile, targetTile) < targetRangeSquared
) {
// Overlapping spawn & target range no untargetable segment.
break;
} else {
untargetableStart = i;
}
}
} else if (
this.mg.euclideanDistSquared(tile, targetTile) < targetRangeSquared
) {
untargetableEnd = i;
break;
}
}
for (let i = 0; i < trajectory.length; i++) {
// Skip the mid-air untargetable portion
if (
untargetableStart !== -1 &&
untargetableEnd !== -1 &&
i === untargetableStart
) {
i = untargetableEnd - 1;
continue;
}
const tile = trajectory[i];
const nearbySams = this.mg.nearbyUnits(
tile,
this.mg.config().maxSamRange(),
UnitType.SAMLauncher,
);
for (const sam of nearbySams) {
const owner = sam.unit.owner();
if (owner === this.player || this.player.isFriendly(owner)) {
continue;
}
const rangeSquared = this.mg.config().samRange(sam.unit.level()) ** 2;
if (sam.distSquared <= rangeSquared) {
return true;
}
}
}
return false;
}
private isValidNukeTile(t: TileRef, other: Player | null): boolean {
const difficulty = this.mg.config().gameConfig().difficulty;
const owner = this.mg.owner(t);
if (owner === other) return true;
// On Hard & Impossible, allow TerraNullius (hit small islands) and in team games other non-friendly players
if (
(difficulty === Difficulty.Hard ||
difficulty === Difficulty.Impossible) &&
(!owner.isPlayer() ||
(this.mg.config().gameConfig().gameMode === GameMode.Team &&
owner.isPlayer() &&
!this.player.isFriendly(owner)))
) {
return true;
}
// On Easy & Medium, only allow tiles owned by the target player (=> nuke away from the border) to reduce nuke usage
return false;
}
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 nukeTileScore(
tile: TileRef,
silos: Unit[],
targets: Unit[],
nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb,
): number {
const magnitude = this.mg.config().nukeMagnitudes(nukeType);
const dist = euclDistFN(tile, magnitude.inner, false);
let tileValue = targets
.filter((unit) => dist(this.mg, unit.tile()))
.map((unit): number => {
const level = unit.level();
switch (unit.type()) {
case UnitType.City:
return 25_000 * level;
case UnitType.DefensePost:
return 5_000 * level;
case UnitType.MissileSilo:
return 50_000 * level;
case UnitType.Port:
return 15_000 * level;
case UnitType.Factory:
return 15_000 * level;
default:
return 0;
}
})
.reduce((prev, cur) => prev + cur, 0);
const difficulty = this.mg.config().gameConfig().difficulty;
// On Easy, ignore SAMs entirely.
// On Medium, apply a simple local SAM penalty.
// On Hard & Impossible we rely on trajectory-based interception checks instead. See maybeSendNuke().
if (difficulty === Difficulty.Medium) {
const dist50 = euclDistFN(tile, 50, false);
const hasSam = targets.some(
(unit) =>
unit.type() === UnitType.SAMLauncher && dist50(this.mg, unit.tile()),
);
if (hasSam) return -1;
}
// Prefer tiles that are closer to a silo (but preserve structure value)
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);
const distancePenalty = distanceToClosestSilo * 30;
const baseTileValue = tileValue;
tileValue = Math.max(baseTileValue * 0.2, tileValue - distancePenalty); // Keep at least 20% of structure value
// Don't target near recent targets
const dist25 = euclDistFN(tile, 25, false);
tileValue -= this.lastNukeSent
.filter(([_tick, tile]) => dist25(this.mg, tile))
.map((_) => 1_000_000)
.reduce((prev, cur) => prev + cur, 0);
return tileValue;
}
private sendNuke(
tile: TileRef,
nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb,
targetPlayer: Player,
) {
const tick = this.mg.ticks();
this.lastNukeSent.push([tick, tile]);
if (nukeType === UnitType.AtomBomb) {
this.atomBombsLaunched++;
// Increase perceived cost by 25% each time to simulate saving up for a MIRV (higher than hydro to make atom bombs less attractive for the lategame)
this.atomBombPerceivedCost = (this.atomBombPerceivedCost * 125n) / 100n;
} else if (nukeType === UnitType.HydrogenBomb) {
this.hydrogenBombsLaunched++;
// Increase perceived cost by 15% each time to simulate saving up for a MIRV
this.hydrogenBombPerceivedCost =
(this.hydrogenBombPerceivedCost * 115n) / 100n;
}
this.mg.addExecution(new NukeExecution(nukeType, this.player, tile));
this.emojiBehavior.maybeSendEmoji(targetPlayer, EMOJI_NUKE);
}
private cost(type: UnitType): Gold {
return this.mg.unitInfo(type).cost(this.mg, this.player);
}
}
+49
View File
@@ -0,0 +1,49 @@
import { Cell, Game, Player } from "../../game/Game";
import { TileRef } from "../../game/GameMap";
import { PseudoRandom } from "../../PseudoRandom";
import { calculateBoundingBox } from "../../Util";
export function randTerritoryTileArray(
random: PseudoRandom,
mg: Game,
player: Player,
numTiles: number,
): TileRef[] {
const boundingBox = calculateBoundingBox(mg, player.borderTiles());
const tiles: TileRef[] = [];
for (let i = 0; i < numTiles; i++) {
const tile = randTerritoryTile(random, mg, player, boundingBox);
if (tile !== null) {
tiles.push(tile);
}
}
return tiles;
}
function randTerritoryTile(
random: PseudoRandom,
mg: Game,
p: Player,
boundingBox: { min: Cell; max: Cell } | null = null,
): TileRef | null {
// Prefer sampling inside the bounding box first (fast, usually good enough)
boundingBox ??= calculateBoundingBox(mg, p.borderTiles());
for (let i = 0; i < 100; i++) {
const randX = random.nextInt(boundingBox.min.x, boundingBox.max.x);
const randY = random.nextInt(boundingBox.min.y, boundingBox.max.y);
if (!mg.isOnMap(new Cell(randX, randY))) {
// Sanity check should never happen
continue;
}
const randTile = mg.ref(randX, randY);
if (mg.owner(randTile) === p) {
return randTile;
}
}
if (p.numTilesOwned() <= 100) {
return random.randElement(Array.from(p.tiles()));
}
return null;
}
+145 -16
View File
@@ -1,6 +1,7 @@
import {
Difficulty,
Game,
GameMode,
Player,
PlayerType,
Relation,
@@ -118,13 +119,11 @@ export class AiAttackBehavior {
};
const hated = (): boolean => {
const mostHated = this.player.allRelationsSorted()[0];
if (
mostHated !== undefined &&
mostHated.relation === Relation.Hostile &&
this.player.isFriendly(mostHated.player) === false
) {
this.sendAttack(mostHated.player);
for (const relation of this.player.allRelationsSorted()) {
if (relation.relation !== Relation.Hostile) continue;
const other = relation.player;
if (this.player.isFriendly(other)) continue;
this.sendAttack(other);
return true;
}
return false;
@@ -201,7 +200,7 @@ export class AiAttackBehavior {
}
}
findBestNukeTarget(borderingEnemies: Player[]): Player | null {
findBestNukeTarget(): Player | null {
// Retaliate against incoming attacks (Most important!)
const incomingAttackPlayer = this.findIncomingAttackPlayer();
if (incomingAttackPlayer) {
@@ -221,19 +220,149 @@ export class AiAttackBehavior {
}
}
// Find the most hated player with hostile relation
const mostHated = this.player.allRelationsSorted()[0];
if (
mostHated !== undefined &&
mostHated.relation === Relation.Hostile &&
this.player.isFriendly(mostHated.player) === false
) {
return mostHated.player;
// Find the most hated player
// Ignore much weaker players (we don't need nukes to deal with them)
const myMaxTroops = this.game.config().maxTroops(this.player);
for (const relation of this.player.allRelationsSorted()) {
if (relation.relation !== Relation.Hostile) continue;
const other = relation.player;
if (this.player.isFriendly(other)) continue;
const otherMaxTroops = this.game.config().maxTroops(other);
if (myMaxTroops >= otherMaxTroops * 2) continue;
return other;
}
// In FFAs, nuke the crown if they're far enough ahead
const crownTarget = this.findFFACrownTarget();
if (crownTarget) {
return crownTarget;
}
// In Teams, nuke the strongest team
const teamTarget = this.findStrongestTeamTarget();
if (teamTarget) {
return teamTarget;
}
return null;
}
private findFFACrownTarget(): Player | null {
const { difficulty, gameMode } = this.game.config().gameConfig();
if (gameMode !== GameMode.FFA) {
return null;
}
if (this.game.players().length <= 1) {
return null;
}
const sortedByTiles = this.game
.players()
.slice()
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
const firstPlace = sortedByTiles[0];
// Don't target ourselves or allies
if (firstPlace === this.player || this.player.isFriendly(firstPlace)) {
return null;
}
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
if (numTilesWithoutFallout <= 0) {
return null;
}
const firstPlaceShare = firstPlace.numTilesOwned() / numTilesWithoutFallout;
const myShare = this.player.numTilesOwned() / numTilesWithoutFallout;
let threshold: number;
switch (difficulty) {
case Difficulty.Easy:
threshold = 0.4; // 40%
break;
case Difficulty.Medium:
threshold = 0.3; // 30%
break;
case Difficulty.Hard:
threshold = 0.2; // 20%
break;
case Difficulty.Impossible:
threshold = 0.1; // 10%
break;
default:
assertNever(difficulty);
}
// Check if first place has threshold% more tile-percentage of the map than us
if (firstPlaceShare - myShare > threshold) {
return firstPlace;
}
return null;
}
private findStrongestTeamTarget(): Player | null {
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
return null;
}
if (this.game.players().length <= 1) {
return null;
}
const teamTiles = new Map<string, number>();
const teamPlayers = new Map<string, Player[]>();
for (const p of this.game.players()) {
const team = p.team();
if (team === null) continue;
teamTiles.set(team, (teamTiles.get(team) ?? 0) + p.numTilesOwned());
let players = teamPlayers.get(team);
if (!players) {
players = [];
teamPlayers.set(team, players);
}
players.push(p);
}
const sortedTeams = Array.from(teamTiles.entries()).sort(
(a, b) => b[1] - a[1],
);
if (sortedTeams.length === 0) {
return null;
}
let strongestTeam = sortedTeams[0][0];
if (strongestTeam === this.player.team()) {
if (sortedTeams.length > 1) {
strongestTeam = sortedTeams[1][0];
} else {
return null;
}
}
const targetTeamPlayers = teamPlayers.get(strongestTeam)!;
if (this.random.chance(2)) {
// Strongest player
return targetTeamPlayers.reduce((prev, current) =>
this.game.config().maxTroops(prev) >
this.game.config().maxTroops(current)
? prev
: current,
);
} else {
// Random player
return this.random.randElement(targetTeamPlayers);
}
}
private hasReserveRatioTroops(): boolean {
const maxTroops = this.game.config().maxTroops(this.player);
const ratio = this.player.troops() / maxTroops;
+1
View File
@@ -494,6 +494,7 @@ export class PlayerImpl implements Player {
allRelationsSorted(): { player: Player; relation: Relation }[] {
return Array.from(this.relations, ([k, v]) => ({ player: k, relation: v }))
.filter((r) => r.player.isAlive())
.sort((a, b) => a.relation - b.relation)
.map((r) => ({
player: r.player,
+4 -4
View File
@@ -257,7 +257,7 @@ describe("Nation MIRV Retaliation", () => {
testExecution.init(game);
for (let tick = 0; tick < 200; tick++) {
testExecution.tick(game.ticks());
testExecution.tick(tick);
// Allow the game to process executions
if (tick % 10 === 0) {
game.executeNextTick();
@@ -410,7 +410,7 @@ describe("Nation MIRV Retaliation", () => {
testExecution.init(game);
for (let tick = 0; tick < 200; tick++) {
testExecution.tick(game.ticks());
testExecution.tick(tick);
// Allow the game to process executions
if (tick % 10 === 0) {
game.executeNextTick();
@@ -561,7 +561,7 @@ describe("Nation MIRV Retaliation", () => {
testExecution.init(game);
for (let tick = 0; tick < 200; tick++) {
testExecution.tick(game.ticks());
testExecution.tick(tick);
game.executeNextTick();
}
@@ -695,7 +695,7 @@ describe("Nation MIRV Retaliation", () => {
testExecution.init(game);
for (let tick = 0; tick < 200; tick++) {
testExecution.tick(game.ticks());
testExecution.tick(tick);
// Allow the game to process executions
if (tick % 10 === 0) {
game.executeNextTick();