Merge branch 'main' into bomb-confirmation

This commit is contained in:
Ryan
2026-01-05 17:44:13 +00:00
committed by GitHub
3 changed files with 400 additions and 225 deletions
+2 -3
View File
@@ -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[] = []) {
+397 -57
View File
@@ -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<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)!;
// 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);
}
}
+1 -165
View File
@@ -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<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;
@@ -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) {