From f73953c2400f068ee92cea9fea21bff7ce196111 Mon Sep 17 00:00:00 2001
From: FloPinguin <25036848+FloPinguin@users.noreply.github.com>
Date: Sun, 4 Jan 2026 23:28:33 +0100
Subject: [PATCH] =?UTF-8?q?=E2=98=A2=EF=B8=8F=20Nations=20send=20much=20be?=
=?UTF-8?q?tter=20nukes=20now=20(Part=202)=20=E2=98=A2=EF=B8=8F=20(#2779)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description:
### Refactor
- Moved `findBestNukeTarget()` (and child methods) from
`AiAttackBehavior` to `NationNukeBehavior` because it makes more sense.
- `NationNukeBehavior`: Renamed `mg` to `game` (So its similar to the
other nation .ts files)
- Moved the `removeOldNukeEvents()` method a bit
### New features
- Impossible difficulty nations are now optimized for SAM outranging!
- 1 of 3 nations is now a hydro-nation. They cannot send atom bombs.
They save up for hydros. This is to reduce atom-bomb-spam on the map.
- On impossible difficulty, the crown nukes the second place now (in
FFAs)
- On hard and impossible difficulty, nations now ignore the perceived
cost for nukes if they get heavily attacked. Reasoning: They should stop
saving for MIRV, they should defend with all their gold!
- On medium, hard and impossible difficulty, nations no longer throw
nukes at places where another team member already has a nuke "in the
flying process"
- Optimized `lastNukeSent` a bit (to respect nuke radius)
- Adjusted `maybeSendNuke()` to use 30 instead of 10 randomTiles on
impossible difficulty to improve chances of finding a perfect SAM
outranging spot
- On impossible difficulty, nations now ignore their "most hated enemy"
if the crown has 50%+ of the map (Very big danger). Instead they are
nuking the crown.
- Added `isFriendly` check to `findStrongestTeamTarget()`
### Media
SAM outranging:
https://github.com/user-attachments/assets/d1e88bb6-0060-400b-9c16-24d7399f5949
Team game nukes not hitting the same spot:
## 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
---
src/core/execution/NationExecution.ts | 5 +-
.../execution/nation/NationNukeBehavior.ts | 454 +++++++++++++++---
src/core/execution/utils/AiAttackBehavior.ts | 166 +------
3 files changed, 400 insertions(+), 225 deletions(-)
diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts
index 76c879fee..987cfffa1 100644
--- a/src/core/execution/NationExecution.ts
+++ b/src/core/execution/NationExecution.ts
@@ -194,9 +194,9 @@ export class NationExecution implements Execution {
this.updateRelationsFromEmbargos();
this.allianceBehavior.handleAllianceRequests();
this.allianceBehavior.handleAllianceExtensionRequests();
+ this.mirvBehavior.considerMIRV();
this.handleUnits();
this.handleEmbargoesToHostileNations();
- this.mirvBehavior.considerMIRV();
this.maybeAttack();
this.warshipBehavior.counterWarshipInfestation();
}
@@ -350,7 +350,6 @@ export class NationExecution implements Execution {
}
const canBuild = this.player.canBuild(UnitType.Warship, targetTile);
if (canBuild === false) {
- console.warn("cannot spawn destroyer");
return false;
}
this.mg.addExecution(
@@ -460,7 +459,7 @@ export class NationExecution implements Execution {
}
this.attackBehavior.attackBestTarget(borderingFriends, borderingEnemies);
- this.nukeBehavior.maybeSendNuke(this.attackBehavior.findBestNukeTarget());
+ this.nukeBehavior.maybeSendNuke();
}
private sendBoatRandomly(borderingEnemies: Player[] = []) {
diff --git a/src/core/execution/nation/NationNukeBehavior.ts b/src/core/execution/nation/NationNukeBehavior.ts
index 6d82c8913..7c2166c99 100644
--- a/src/core/execution/nation/NationNukeBehavior.ts
+++ b/src/core/execution/nation/NationNukeBehavior.ts
@@ -5,6 +5,7 @@ import {
Gold,
Player,
PlayerType,
+ Relation,
Tick,
Unit,
UnitType,
@@ -12,7 +13,7 @@ import {
import { TileRef, euclDistFN } from "../../game/GameMap";
import { ParabolaPathFinder } from "../../pathfinding/PathFinding";
import { PseudoRandom } from "../../PseudoRandom";
-import { boundingBoxTiles } from "../../Util";
+import { assertNever, boundingBoxTiles } from "../../Util";
import { NukeExecution } from "../NukeExecution";
import { closestTwoTiles } from "../Util";
import { AiAttackBehavior } from "../utils/AiAttackBehavior";
@@ -20,28 +21,38 @@ import { EMOJI_NUKE, NationEmojiBehavior } from "./NationEmojiBehavior";
import { randTerritoryTileArray } from "./NationUtils";
export class NationNukeBehavior {
- private readonly lastNukeSent: [Tick, TileRef][] = [];
+ private readonly recentlySentNukes: [
+ Tick,
+ TileRef,
+ UnitType.AtomBomb | UnitType.HydrogenBomb,
+ ][] = [];
private atomBombsLaunched = 0;
private atomBombPerceivedCost = this.cost(UnitType.AtomBomb);
private hydrogenBombsLaunched = 0;
private hydrogenBombPerceivedCost = this.cost(UnitType.HydrogenBomb);
+ // Make 1/3 of nations "hydro-nations" that only throw hydrogen bombs (to reduce atom bomb spam)
+ private readonly isHydroNation: boolean = this.random.chance(3);
constructor(
private random: PseudoRandom,
- private mg: Game,
+ private game: Game,
private player: Player,
private attackBehavior: AiAttackBehavior,
private emojiBehavior: NationEmojiBehavior,
) {}
- maybeSendNuke(other: Player | null) {
+ maybeSendNuke() {
+ const nukeTarget = this.findBestNukeTarget();
+ if (nukeTarget === null) {
+ return;
+ }
+
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
+ nukeTarget.type() === PlayerType.Bot || // Don't nuke bots (as opposed to nations and humans)
+ this.player.isOnSameTeam(nukeTarget) ||
+ this.attackBehavior.shouldAttack(nukeTarget) === false
) {
return;
}
@@ -51,14 +62,14 @@ export class NationNukeBehavior {
let nukeType: UnitType;
if (this.player.gold() >= hydroCost) {
nukeType = UnitType.HydrogenBomb;
- } else if (this.player.gold() >= atomCost) {
+ } else if (!this.isHydroNation && this.player.gold() >= atomCost) {
nukeType = UnitType.AtomBomb;
} else {
return;
}
- const range = this.mg.config().nukeMagnitudes(nukeType).inner;
+ const range = this.game.config().nukeMagnitudes(nukeType).outer;
- const structures = other.units(
+ const structures = nukeTarget.units(
UnitType.City,
UnitType.DefensePost,
UnitType.MissileSilo,
@@ -67,7 +78,15 @@ export class NationNukeBehavior {
UnitType.Factory,
);
const structureTiles = structures.map((u) => u.tile());
- const randomTiles = randTerritoryTileArray(this.random, this.mg, other, 10);
+ const difficulty = this.game.config().gameConfig().difficulty;
+ // Use more random tiles on Impossible difficulty to improve chances of finding a perfect SAM outranging spot
+ const numRandomTiles = difficulty === Difficulty.Impossible ? 30 : 10;
+ const randomTiles = randTerritoryTileArray(
+ this.random,
+ this.game,
+ nukeTarget,
+ numRandomTiles,
+ );
const allTiles = randomTiles.concat(structureTiles);
let bestTile: TileRef | null = null;
@@ -76,19 +95,27 @@ export class NationNukeBehavior {
outer: for (const tile of new Set(allTiles)) {
if (tile === null) continue;
- const boundingBox = boundingBoxTiles(this.mg, tile, range)
+ const boundingBox = boundingBoxTiles(this.game, 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)));
+ .concat(boundingBoxTiles(this.game, tile, Math.floor(range / 2)));
for (const t of boundingBox) {
- if (!this.isValidNukeTile(t, other)) {
+ if (!this.isValidNukeTile(t, nukeTarget)) {
continue outer;
}
}
const spawnTile = this.player.canBuild(nukeType, tile);
if (spawnTile === false) continue;
+ // In team games, avoid nuking the same position as a teammate
+ if (
+ this.game.config().gameConfig().gameMode === GameMode.Team &&
+ difficulty !== Difficulty.Easy &&
+ this.isTeammateAlreadyNukingThisSpot(tile, nukeType)
+ ) {
+ 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) &&
@@ -104,7 +131,212 @@ export class NationNukeBehavior {
}
}
if (bestTile !== null) {
- this.sendNuke(bestTile, nukeType, other);
+ this.sendNuke(bestTile, nukeType, nukeTarget);
+ }
+ }
+
+ findBestNukeTarget(): Player | null {
+ // Retaliate against incoming attacks (Most important!)
+ const incomingAttackPlayer = this.attackBehavior.findIncomingAttackPlayer();
+ if (incomingAttackPlayer) {
+ return incomingAttackPlayer;
+ }
+
+ // On impossible difficulty, prioritize nuking the crown if they have more than 50% of the map
+ const { difficulty, gameMode } = this.game.config().gameConfig();
+ if (difficulty === Difficulty.Impossible && gameMode === GameMode.FFA) {
+ const numTilesWithoutFallout =
+ this.game.numLandTiles() - this.game.numTilesWithFallout();
+ if (numTilesWithoutFallout > 0) {
+ const sortedByTiles = this.game
+ .players()
+ .slice()
+ .sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
+ const crown = sortedByTiles[0];
+
+ if (crown && crown !== this.player && !this.player.isFriendly(crown)) {
+ const crownShare = crown.numTilesOwned() / numTilesWithoutFallout;
+ if (crownShare > 0.5) {
+ return crown;
+ }
+ }
+ }
+ }
+
+ // Assist allies, check their targets (this is basically the same as in assistAllies, but without sending emojis)
+ for (const ally of this.player.allies()) {
+ if (ally.targets().length === 0) continue;
+ if (this.player.relation(ally) < Relation.Friendly) continue;
+
+ for (const target of ally.targets()) {
+ if (target === this.player) continue;
+ if (this.player.isFriendly(target)) continue;
+ // Found a valid ally target to nuke
+ return target;
+ }
+ }
+
+ // 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];
+
+ // If we're the crown on Impossible difficulty, target 2nd place
+ if (
+ difficulty === Difficulty.Impossible &&
+ firstPlace === this.player &&
+ sortedByTiles.length >= 2
+ ) {
+ const secondPlace = sortedByTiles[1];
+ if (!this.player.isFriendly(secondPlace)) {
+ return secondPlace;
+ }
+ }
+
+ // 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();
+ const teamPlayers = new Map();
+
+ 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)!;
+
+ // Filter out friendly players
+ const validTargets = targetTeamPlayers.filter(
+ (p) => !this.player.isFriendly(p),
+ );
+
+ if (validTargets.length === 0) {
+ return null;
+ }
+
+ if (this.random.chance(2)) {
+ // Strongest player
+ return validTargets.reduce((prev, current) =>
+ this.game.config().maxTroops(prev) >
+ this.game.config().maxTroops(current)
+ ? prev
+ : current,
+ );
+ } else {
+ // Random player
+ return this.random.randElement(validTargets);
}
}
@@ -113,13 +345,24 @@ export class NationNukeBehavior {
// 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.game.config().gameConfig().gameMode === GameMode.Team ||
this.player.gold() >
this.cost(UnitType.MIRV) + this.cost(UnitType.HydrogenBomb)
) {
return this.cost(type);
}
+ // On Hard & Impossible, ignore perceived cost when under heavy attack (2x troops)
+ // The nation is probably going to get destroyed soon, so go all-in on nukes
+ const difficulty = this.game.config().gameConfig().difficulty;
+ if (
+ (difficulty === Difficulty.Hard ||
+ difficulty === Difficulty.Impossible) &&
+ this.isUnderHeavyAttack()
+ ) {
+ return this.cost(type);
+ }
+
if (type === UnitType.AtomBomb) {
return this.atomBombPerceivedCost;
} else {
@@ -127,13 +370,83 @@ export class NationNukeBehavior {
}
}
+ private isUnderHeavyAttack(): boolean {
+ // Get the total incoming attack troops
+ const incomingAttacks = this.player.incomingAttacks();
+ let totalIncomingTroops = 0;
+ for (const attack of incomingAttacks) {
+ totalIncomingTroops += attack.troops();
+ }
+
+ const myTroops = this.player.troops();
+
+ // Consider it a heavy attack if total incoming attacks have 2x our troops
+ return totalIncomingTroops >= myTroops * 2;
+ }
+
+ private removeOldNukeEvents() {
+ const maxAge = 600; // 600 ticks = 1 minute
+ const tick = this.game.ticks();
+ while (
+ this.recentlySentNukes.length > 0 &&
+ this.recentlySentNukes[0][0] + maxAge < tick
+ ) {
+ this.recentlySentNukes.shift();
+ }
+ }
+
+ private isTeammateAlreadyNukingThisSpot(
+ tile: TileRef,
+ nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb,
+ ): boolean {
+ // Get the inner radius for our nuke type
+ const ourInnerRadius = this.game.config().nukeMagnitudes(nukeType).inner;
+
+ // Get all active nukes in the game
+ const activeNukes = this.game.units(
+ UnitType.AtomBomb,
+ UnitType.HydrogenBomb,
+ );
+
+ // Check if any teammate's nuke blast radius overlaps with ours
+ for (const nuke of activeNukes) {
+ const nukeOwner = nuke.owner();
+
+ // Skip our own nukes and non-teammate nukes
+ if (nukeOwner === this.player || !this.player.isFriendly(nukeOwner)) {
+ continue;
+ }
+
+ // Get the target tile of the teammate's nuke
+ const targetTile = nuke.targetTile();
+ if (!targetTile) continue;
+
+ // Get the blast radius of the teammate's nuke
+ const teammateInnerRadius = this.game
+ .config()
+ .nukeMagnitudes(nuke.type()).inner;
+
+ // Check if the blast zones overlap
+ // They overlap if distance between targets < sum of the two radii
+ const distSquared = this.game.euclideanDistSquared(tile, targetTile);
+ const sumRadius = ourInnerRadius + teammateInnerRadius;
+ const sumRadiusSquared = sumRadius * sumRadius;
+
+ if (distSquared <= sumRadiusSquared) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
// 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 pathFinder = new ParabolaPathFinder(this.game);
+ const speed = this.game.config().defaultNukeSpeed();
const distanceBasedHeight = true; // Atom/Hydrogen bombs use distance-based height
const rocketDirectionUp = true; // AI nukes always go "up" for now
@@ -151,7 +464,7 @@ export class NationNukeBehavior {
}
const targetRangeSquared =
- this.mg.config().defaultNukeTargetableRange() ** 2;
+ this.game.config().defaultNukeTargetableRange() ** 2;
let untargetableStart = -1;
let untargetableEnd = -1;
@@ -159,10 +472,11 @@ export class NationNukeBehavior {
const tile = trajectory[i];
if (untargetableStart === -1) {
if (
- this.mg.euclideanDistSquared(tile, spawnTile) > targetRangeSquared
+ this.game.euclideanDistSquared(tile, spawnTile) > targetRangeSquared
) {
if (
- this.mg.euclideanDistSquared(tile, targetTile) < targetRangeSquared
+ this.game.euclideanDistSquared(tile, targetTile) <
+ targetRangeSquared
) {
// Overlapping spawn & target range – no untargetable segment.
break;
@@ -171,7 +485,7 @@ export class NationNukeBehavior {
}
}
} else if (
- this.mg.euclideanDistSquared(tile, targetTile) < targetRangeSquared
+ this.game.euclideanDistSquared(tile, targetTile) < targetRangeSquared
) {
untargetableEnd = i;
break;
@@ -190,9 +504,9 @@ export class NationNukeBehavior {
}
const tile = trajectory[i];
- const nearbySams = this.mg.nearbyUnits(
+ const nearbySams = this.game.nearbyUnits(
tile,
- this.mg.config().maxSamRange(),
+ this.game.config().maxSamRange(),
UnitType.SAMLauncher,
);
@@ -201,7 +515,7 @@ export class NationNukeBehavior {
if (owner === this.player || this.player.isFriendly(owner)) {
continue;
}
- const rangeSquared = this.mg.config().samRange(sam.unit.level()) ** 2;
+ const rangeSquared = this.game.config().samRange(sam.unit.level()) ** 2;
if (sam.distSquared <= rangeSquared) {
return true;
}
@@ -211,17 +525,17 @@ export class NationNukeBehavior {
return false;
}
- private isValidNukeTile(t: TileRef, other: Player | null): boolean {
- const difficulty = this.mg.config().gameConfig().difficulty;
+ private isValidNukeTile(t: TileRef, nukeTarget: Player | null): boolean {
+ const difficulty = this.game.config().gameConfig().difficulty;
- const owner = this.mg.owner(t);
- if (owner === other) return true;
+ const owner = this.game.owner(t);
+ if (owner === nukeTarget) 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 &&
+ (this.game.config().gameConfig().gameMode === GameMode.Team &&
owner.isPlayer() &&
!this.player.isFriendly(owner)))
) {
@@ -231,27 +545,16 @@ export class NationNukeBehavior {
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);
+ const magnitude = this.game.config().nukeMagnitudes(nukeType);
+ const dist = euclDistFN(tile, magnitude.outer, false);
let tileValue = targets
- .filter((unit) => dist(this.mg, unit.tile()))
+ .filter((unit) => dist(this.game, unit.tile()))
.map((unit): number => {
const level = unit.level();
switch (unit.type()) {
@@ -271,7 +574,7 @@ export class NationNukeBehavior {
})
.reduce((prev, cur) => prev + cur, 0);
- const difficulty = this.mg.config().gameConfig().difficulty;
+ const difficulty = this.game.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().
@@ -279,26 +582,63 @@ export class NationNukeBehavior {
const dist50 = euclDistFN(tile, 50, false);
const hasSam = targets.some(
(unit) =>
- unit.type() === UnitType.SAMLauncher && dist50(this.mg, unit.tile()),
+ unit.type() === UnitType.SAMLauncher &&
+ dist50(this.game, unit.tile()),
);
if (hasSam) return -1;
}
+ // On Impossible difficulty and a hydrogen bomb, add value for SAMs that can be outranged
+ if (
+ difficulty === Difficulty.Impossible &&
+ nukeType === UnitType.HydrogenBomb
+ ) {
+ const hydroMagnitude = this.game
+ .config()
+ .nukeMagnitudes(UnitType.HydrogenBomb);
+ const nearbySams = this.game.nearbyUnits(
+ tile,
+ hydroMagnitude.outer,
+ UnitType.SAMLauncher,
+ );
+
+ for (const sam of nearbySams) {
+ const samLevel = sam.unit.level();
+ if (samLevel >= 5) continue; // Can't outrange level 5+ SAMs
+
+ const samRange = this.game.config().samRange(samLevel);
+ const distToSam = Math.sqrt(
+ this.game.euclideanDistSquared(tile, sam.unit.tile()),
+ );
+
+ // Check if we can outrange this SAM
+ if (distToSam > samRange) {
+ // Add significant value for destroying a SAM that we can outrange
+ tileValue += 100_000 * samLevel;
+ }
+ }
+ }
+
// 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]);
+ const result = closestTwoTiles(this.game, siloTiles, [tile]);
if (result === null) throw new Error("Missing result");
const { x: closestSilo } = result;
- const distanceSquared = this.mg.euclideanDistSquared(tile, closestSilo);
+ const distanceSquared = this.game.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))
+ tileValue -= this.recentlySentNukes
+ .filter(([_tick, recentTile, recentNukeType]) => {
+ const recentInnerRadius = this.game
+ .config()
+ .nukeMagnitudes(recentNukeType).inner;
+ const distSquared = this.game.euclideanDistSquared(tile, recentTile);
+ return distSquared <= recentInnerRadius * recentInnerRadius;
+ })
.map((_) => 1_000_000)
.reduce((prev, cur) => prev + cur, 0);
@@ -310,8 +650,8 @@ export class NationNukeBehavior {
nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb,
targetPlayer: Player,
) {
- const tick = this.mg.ticks();
- this.lastNukeSent.push([tick, tile]);
+ const tick = this.game.ticks();
+ this.recentlySentNukes.push([tick, tile, nukeType]);
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)
@@ -322,11 +662,11 @@ export class NationNukeBehavior {
this.hydrogenBombPerceivedCost =
(this.hydrogenBombPerceivedCost * 115n) / 100n;
}
- this.mg.addExecution(new NukeExecution(nukeType, this.player, tile));
+ this.game.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);
+ return this.game.unitInfo(type).cost(this.game, this.player);
}
}
diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts
index dfd55f4e4..fa18b83a7 100644
--- a/src/core/execution/utils/AiAttackBehavior.ts
+++ b/src/core/execution/utils/AiAttackBehavior.ts
@@ -1,7 +1,6 @@
import {
Difficulty,
Game,
- GameMode,
Player,
PlayerType,
Relation,
@@ -200,169 +199,6 @@ export class AiAttackBehavior {
}
}
- findBestNukeTarget(): Player | null {
- // Retaliate against incoming attacks (Most important!)
- const incomingAttackPlayer = this.findIncomingAttackPlayer();
- if (incomingAttackPlayer) {
- return incomingAttackPlayer;
- }
-
- // Assist allies, check their targets (this is basically the same as in assistAllies, but without sending emojis)
- for (const ally of this.player.allies()) {
- if (ally.targets().length === 0) continue;
- if (this.player.relation(ally) < Relation.Friendly) continue;
-
- for (const target of ally.targets()) {
- if (target === this.player) continue;
- if (this.player.isFriendly(target)) continue;
- // Found a valid ally target to nuke
- return target;
- }
- }
-
- // 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();
- const teamPlayers = new Map();
-
- 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;
@@ -375,7 +211,7 @@ export class AiAttackBehavior {
return ratio >= this.triggerRatio;
}
- private findIncomingAttackPlayer(): Player | null {
+ findIncomingAttackPlayer(): Player | null {
// Ignore bot attacks if we are not a bot.
let incomingAttacks = this.player.incomingAttacks();
if (this.player.type() !== PlayerType.Bot) {