Improve nations 🤖 (#2817)

## Description:

### Refactor

- Moved `maybeSpawnWarship()` from `NationExecution` to
`NationWarshipBehavior`
- Moved `maybeAttack()` (and sub-methods) from `NationExecution` to
`AiAttackBehavior`

### Betrayal

- Added nice betrayal logic in `maybeBetray()`. Previously that method
was basically just a placeholder for a future implementation.

### Attacking

- Added `veryWeak()` attack strategy for hard and impossible difficulty
nations attack orders to target MIRVed players with higher priority
- Optimized the `weakest()` attack strategy so that nations don't attack
stronger players. This should make nation-attacks feel less random
(humans complained in discord)
- `findNearestIslandEnemy()` and `randomBoatTarget()` also no longer
returns stronger players
- `afk()` and `hated()` attack strategies no longer return MUCH stronger
players
- Several tiny refactorings, fixes and balance optimizations in
`AiAttackBehavior`

### Emojis

- Added some `canSendEmoji()` because I saw some "cannot send emoji"
warnings in the console

## 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:
FloPinguin
2026-01-09 04:27:47 +01:00
committed by GitHub
parent 9c7709ccb5
commit 96aa39a415
5 changed files with 329 additions and 282 deletions
+4 -209
View File
@@ -6,13 +6,11 @@ import {
Nation,
Player,
PlayerID,
PlayerType,
Relation,
TerrainType,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { canBuildTransportShip } from "../game/TransportShipUtils";
import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
import { assertNever, simpleHash } from "../Util";
@@ -25,7 +23,6 @@ import { randTerritoryTileArray } from "./nation/NationUtils";
import { NationWarshipBehavior } from "./nation/NationWarshipBehavior";
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
import { SpawnExecution } from "./SpawnExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { AiAttackBehavior } from "./utils/AiAttackBehavior";
export class NationExecution implements Execution {
@@ -143,7 +140,6 @@ export class NationExecution implements Execution {
this.warshipBehavior === null ||
this.nukeBehavior === null
) {
// Player is unavailable during init()
this.emojiBehavior = new NationEmojiBehavior(
this.random,
this.mg,
@@ -197,8 +193,9 @@ export class NationExecution implements Execution {
this.mirvBehavior.considerMIRV();
this.handleUnits();
this.handleEmbargoesToHostileNations();
this.maybeAttack();
this.attackBehavior.maybeAttack();
this.warshipBehavior.counterWarshipInfestation();
this.nukeBehavior.maybeSendNuke();
}
private randomSpawnLand(): TileRef | null {
@@ -252,10 +249,11 @@ export class NationExecution implements Execution {
}
private handleUnits() {
if (this.warshipBehavior === null) throw new Error("not initialized");
return (
this.maybeSpawnStructure(UnitType.City, (num) => num) ||
this.maybeSpawnStructure(UnitType.Port, (num) => num) ||
this.maybeSpawnWarship() ||
this.warshipBehavior.maybeSpawnWarship() ||
this.maybeSpawnStructure(UnitType.Factory, (num) => num) ||
this.maybeSpawnStructure(UnitType.DefensePost, (num) => (num + 2) ** 2) ||
this.maybeSpawnStructure(UnitType.SAMLauncher, (num) => num ** 2) ||
@@ -331,59 +329,6 @@ export class NationExecution implements Execution {
}
}
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) {
return false;
}
this.mg.addExecution(
new ConstructionExecution(this.player, UnitType.Warship, targetTile),
);
return true;
}
return false;
}
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 handleEmbargoesToHostileNations() {
const player = this.player;
if (player === null) return;
@@ -406,156 +351,6 @@ export class NationExecution implements Execution {
});
}
private maybeAttack() {
if (
this.player === null ||
this.attackBehavior === null ||
this.allianceBehavior === null ||
this.nukeBehavior === 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.attackBestTarget(borderingFriends, borderingEnemies);
this.nukeBehavior.maybeSendNuke();
}
private 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;
}
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);
const unreachablePlayers = new Set<PlayerID>();
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;
}
// Skip players we already know are unreachable (Performance optimization)
if (owner.isPlayer() && unreachablePlayers.has(owner.id())) {
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;
}
let matchesCriteria = false;
if (highInterestOnly) {
// High-interest targeting: prioritize unowned tiles or tiles owned by bots
matchesCriteria = !owner.isPlayer() || owner.type() === PlayerType.Bot;
} else {
// Normal targeting: return unowned tiles or tiles owned by non-friendly players
matchesCriteria = !owner.isPlayer() || !owner.isFriendly(this.player);
}
if (!matchesCriteria) {
continue;
}
// Validate that we can actually build a transport ship to this target
if (canBuildTransportShip(this.mg, this.player, randTile) === false) {
if (owner.isPlayer()) {
unreachablePlayers.add(owner.id());
}
continue;
}
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);
@@ -336,20 +336,57 @@ export class NationAllianceBehavior {
}
}
// Betray friends if we have 10 times more troops than them
// TODO: Implement better and deeper strategies, for example:
// Check impact on relations with other players
// Check value of targets territory
// Check if target is distracted
// Check the targets territory size
maybeBetray(otherPlayer: Player): boolean {
maybeBetray(otherPlayer: Player, borderingPlayerCount: number): boolean {
if (!this.player.isAlliedWith(otherPlayer)) return false;
const { difficulty } = this.game.config().gameConfig();
// Betray very weak players (For example MIRVed ones)
if (difficulty !== Difficulty.Easy && difficulty !== Difficulty.Medium) {
const otherPlayerMaxTroops = this.game.config().maxTroops(otherPlayer);
const otherPlayerOutgoingTroops = otherPlayer
.outgoingAttacks()
.reduce((sum, attack) => sum + attack.troops(), 0);
if (
otherPlayer.troops() + otherPlayerOutgoingTroops <
otherPlayerMaxTroops * 0.2 &&
otherPlayer.troops() < this.player.troops()
) {
this.betray(otherPlayer);
return true;
}
}
// Betray very weak players (similar check as above but for the easier difficulties)
// This doesn't check for maxTroops and isn't really smart. It opens the nations up for attacks, but that's intended.
if (
this.player.isAlliedWith(otherPlayer) &&
(difficulty === Difficulty.Easy || difficulty === Difficulty.Medium) &&
this.player.troops() >= otherPlayer.troops() * 10
) {
this.betray(otherPlayer);
return true;
}
// Betray traitors who aren't significantly stronger than us
if (
difficulty !== Difficulty.Easy &&
otherPlayer.isTraitor() &&
otherPlayer.troops() < this.player.troops() * 1.2
) {
this.betray(otherPlayer);
return true;
}
// Betray our only bordering player if we are much stronger than them
if (
difficulty !== Difficulty.Easy &&
borderingPlayerCount === 1 &&
otherPlayer.troops() * 3 < this.player.troops()
) {
this.betray(otherPlayer);
return true;
}
return false;
}
@@ -263,6 +263,7 @@ export class NationEmojiBehavior {
sendEmoji(otherPlayer: Player | typeof AllPlayers, emojisList: number[]) {
if (!this.shouldSendEmoji(otherPlayer, false)) return;
if (!this.player.canSendEmoji(otherPlayer)) return;
this.game.addExecution(
new EmojiExecution(
@@ -301,6 +302,7 @@ export function respondToEmoji(
if (recipient === AllPlayers || recipient.type() !== PlayerType.Nation) {
return;
}
if (!recipient.canSendEmoji(sender)) return;
if (emojiString === "🖕") {
recipient.updateRelation(sender, -100);
@@ -346,6 +348,7 @@ export function respondToMIRV(
mirvTarget: Player,
) {
if (!random.chance(8)) return;
if (!mirvTarget.canSendEmoji(AllPlayers)) return;
game.addExecution(
new EmojiExecution(
@@ -29,6 +29,59 @@ export class NationWarshipBehavior {
private emojiBehavior: NationEmojiBehavior,
) {}
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) {
return false;
}
this.game.addExecution(
new ConstructionExecution(this.player, UnitType.Warship, targetTile),
);
return true;
}
return false;
}
private warshipSpawnTile(portTile: TileRef): TileRef | null {
const radius = 250;
for (let attempts = 0; attempts < 50; attempts++) {
const randX = this.random.nextInt(
this.game.x(portTile) - radius,
this.game.x(portTile) + radius,
);
const randY = this.random.nextInt(
this.game.y(portTile) - radius,
this.game.y(portTile) + radius,
);
if (!this.game.isValidCoord(randX, randY)) {
continue;
}
const tile = this.game.ref(randX, randY);
// Sanity check
if (!this.game.isOcean(tile)) {
continue;
}
return tile;
}
return null;
}
trackShipsAndRetaliate(): void {
this.trackTransportShipsAndRetaliate();
this.trackTradeShipsAndRetaliate();
+224 -65
View File
@@ -2,10 +2,13 @@ import {
Difficulty,
Game,
Player,
PlayerID,
PlayerType,
Relation,
TerraNullius,
} from "../../game/Game";
import { TileRef } from "../../game/GameMap";
import { canBuildTransportShip } from "../../game/TransportShipUtils";
import { PseudoRandom } from "../../PseudoRandom";
import {
assertNever,
@@ -38,8 +41,156 @@ export class AiAttackBehavior {
private emojiBehavior?: NationEmojiBehavior,
) {}
maybeAttack() {
if (this.player === null || this.allianceBehavior === undefined) {
throw new Error("not initialized");
}
const border = Array.from(this.player.borderTiles())
.flatMap((t) => this.game.neighbors(t))
.filter(
(t) =>
this.game.isLand(t) &&
this.game.ownerID(t) !== this.player?.smallID(),
);
const borderingPlayers = [
...new Set(
border
.map((t) => this.game.playerBySmallID(this.game.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.game.hasOwner(t) && !this.game.hasFallout(t),
);
if (hasNonNukedTerraNullius) {
this.sendAttack(this.game.terraNullius());
return;
}
if (borderingEnemies.length === 0) {
if (this.random.chance(5)) {
this.attackWithRandomBoat();
}
} else {
if (this.random.chance(10)) {
this.attackWithRandomBoat(borderingEnemies);
return;
}
this.allianceBehavior.maybeSendAllianceRequests(borderingEnemies);
}
this.attackBestTarget(borderingFriends, borderingEnemies);
}
private attackWithRandomBoat(borderingEnemies: Player[] = []) {
if (this.player === null) throw new Error("not initialized");
const oceanShore = Array.from(this.player.borderTiles()).filter((t) =>
this.game.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.findRandomBoatTarget(src, borderingEnemies, true);
if (dst === null) {
// None found? Then look for players
dst = this.findRandomBoatTarget(src, borderingEnemies, false);
if (dst === null) {
return;
}
}
this.game.addExecution(
new TransportShipExecution(
this.player,
this.game.owner(dst).id(),
dst,
this.player.troops() / 5,
null,
),
);
return;
}
private findRandomBoatTarget(
tile: TileRef,
borderingEnemies: Player[],
highInterestOnly: boolean = false,
): TileRef | null {
if (this.player === null) throw new Error("not initialized");
const x = this.game.x(tile);
const y = this.game.y(tile);
const unreachablePlayers = new Set<PlayerID>();
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.game.isValidCoord(randX, randY)) {
continue;
}
const randTile = this.game.ref(randX, randY);
if (!this.game.isLand(randTile)) {
continue;
}
const owner = this.game.owner(randTile);
if (owner === this.player) {
continue;
}
// Skip players we already know are unreachable (Performance optimization)
if (owner.isPlayer() && unreachablePlayers.has(owner.id())) {
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 which are stronger than us
if (owner.isPlayer() && owner.troops() > this.player.troops()) {
continue;
}
let matchesCriteria = false;
if (highInterestOnly) {
// High-interest targeting: prioritize unowned tiles or tiles owned by bots
matchesCriteria = !owner.isPlayer() || owner.type() === PlayerType.Bot;
} else {
// Normal targeting: return unowned tiles or tiles owned by non-friendly players
matchesCriteria = !owner.isPlayer() || !owner.isFriendly(this.player);
}
if (!matchesCriteria) {
continue;
}
// Validate that we can actually build a transport ship to this target
if (canBuildTransportShip(this.game, this.player, randTile) === false) {
if (owner.isPlayer()) {
unreachablePlayers.add(owner.id());
}
continue;
}
return randTile;
}
return null;
}
// attackBestTarget is called with borderingFriends and borderingEnemies sorted by troops (ascending)
attackBestTarget(borderingFriends: Player[], borderingEnemies: Player[]) {
private attackBestTarget(
borderingFriends: Player[],
borderingEnemies: Player[],
) {
// Save up troops until we reach the reserve ratio
if (!this.hasReserveRatioTroops()) return;
@@ -78,27 +229,29 @@ export class AiAttackBehavior {
const assist = (): boolean => this.assistAllies();
const traitor = (): boolean => {
const weakestTraitor = this.findWeakestTraitor(borderingEnemies);
if (weakestTraitor) {
this.sendAttack(weakestTraitor);
const traitor = this.findTraitor(borderingEnemies);
if (traitor) {
this.sendAttack(traitor);
return true;
}
return false;
};
const afk = (): boolean => {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
const weakestAfk = borderingEnemies.find((enemy) =>
enemy.isDisconnected(),
// borderingEnemies is already sorted by troops (ascending), so first match is weakest afk enemy
const afk = borderingEnemies.find(
(enemy) =>
enemy.isDisconnected() && enemy.troops() < this.player.troops() * 3,
);
if (weakestAfk) {
this.sendAttack(weakestAfk);
if (afk) {
this.sendAttack(afk);
return true;
}
return false;
};
const betray = (): boolean => this.maybeBetrayAndAttack(borderingFriends);
const betray = (): boolean =>
this.maybeBetrayAndAttack(borderingFriends, borderingEnemies);
const nuked = (): boolean => {
if (this.isBorderingNukedTerritory()) {
@@ -109,9 +262,9 @@ export class AiAttackBehavior {
};
const victim = (): boolean => {
const weakestVictim = this.findWeakestVictim(borderingEnemies);
if (weakestVictim) {
this.sendAttack(weakestVictim);
const victim = this.findVictim(borderingEnemies);
if (victim) {
this.sendAttack(victim);
return true;
}
return false;
@@ -122,17 +275,31 @@ export class AiAttackBehavior {
if (relation.relation !== Relation.Hostile) continue;
const other = relation.player;
if (this.player.isFriendly(other)) continue;
if (other.troops() > this.player.troops() * 3) continue;
this.sendAttack(other);
return true;
}
return false;
};
const veryWeak = (): boolean => {
const veryWeak = this.findVeryWeakEnemy(borderingEnemies);
if (veryWeak) {
this.sendAttack(veryWeak);
return true;
}
return false;
};
const weakest = (): boolean => {
if (borderingEnemies.length > 0) {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
this.sendAttack(borderingEnemies[0]);
return true;
const weakest = borderingEnemies[0];
// Don't attack if they have more troops than us
if (weakest.troops() < this.player.troops()) {
this.sendAttack(weakest);
return true;
}
}
return false;
};
@@ -152,48 +319,17 @@ export class AiAttackBehavior {
// Easy nations get the dumbest order, impossible nations get the smartest order
switch (difficulty) {
case Difficulty.Easy:
// prettier-ignore
return [nuked, bots, retaliate, assist, betray, hated, weakest];
case Difficulty.Medium:
return [
bots,
nuked,
retaliate,
assist,
betray,
hated,
afk,
traitor,
weakest,
island,
];
// prettier-ignore
return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island];
case Difficulty.Hard:
return [
bots,
retaliate,
assist,
betray,
nuked,
traitor,
afk,
hated,
victim,
weakest,
island,
];
// prettier-ignore
return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island];
case Difficulty.Impossible:
return [
retaliate,
bots,
assist,
traitor,
afk,
betray,
nuked,
victim,
hated,
weakest,
island,
];
// prettier-ignore
return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island];
default:
assertNever(difficulty);
}
@@ -309,23 +445,31 @@ export class AiAttackBehavior {
return false;
}
// Find a traitor who isn't much stronger than us (max 20% more troops)
private findWeakestTraitor(borderingEnemies: Player[]): Player | null {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
// Find a traitor who isn't significantly stronger than us
private findTraitor(borderingEnemies: Player[]): Player | null {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest traitor
return (
borderingEnemies.find(
(enemy) =>
enemy.isTraitor() && enemy.troops() * 1.2 < this.player.troops(),
enemy.isTraitor() && enemy.troops() < this.player.troops() * 1.2,
) ?? null
);
}
private maybeBetrayAndAttack(borderingFriends: Player[]): boolean {
private maybeBetrayAndAttack(
borderingFriends: Player[],
borderingEnemies: Player[],
): boolean {
if (this.allianceBehavior === undefined) throw new Error("not initialized");
if (borderingFriends.length > 0) {
for (const friend of borderingFriends) {
if (this.allianceBehavior.maybeBetray(friend)) {
if (
this.allianceBehavior.maybeBetray(
friend,
borderingFriends.length + borderingEnemies.length,
)
) {
this.sendAttack(friend, true);
return true;
}
@@ -349,12 +493,12 @@ export class AiAttackBehavior {
return false;
}
// Find someone who is weaker than us and is under big attack from others (50%+ of their troops incoming)
private findWeakestVictim(borderingEnemies: Player[]): Player | null {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
// Find someone who isn't significantly stronger than us and is under big attack from others (50%+ of their troops incoming)
private findVictim(borderingEnemies: Player[]): Player | null {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest victim
return (
borderingEnemies.find((enemy) => {
if (enemy.troops() >= this.player.troops()) return false;
if (enemy.troops() > this.player.troops() * 1.2) return false;
const totalIncomingTroops = enemy
.incomingAttacks()
@@ -365,6 +509,21 @@ export class AiAttackBehavior {
);
}
// Find very weak (less than 15% of their maxTroops) enemies
// which also don't have significantly more troops than us (to target MIRVed players)
private findVeryWeakEnemy(borderingEnemies: Player[]): Player | null {
const veryWeakEnemies = borderingEnemies.filter((enemy) => {
const enemyMaxTroops = this.game.config().maxTroops(enemy);
return (
enemy.troops() < enemyMaxTroops * 0.15 &&
enemy.troops() < this.player.troops() * 1.2
);
});
// borderingEnemies is already sorted by troops (ascending), so first match is weakest very weak enemy
return veryWeakEnemies.length > 0 ? veryWeakEnemies[0] : null;
}
private findNearestIslandEnemy(): Player | null {
const myBorder = this.player.borderTiles();
if (myBorder.size === 0) return null;
@@ -374,8 +533,8 @@ export class AiAttackBehavior {
if (!p.isAlive()) return false;
if (p.borderTiles().size === 0) return false;
if (this.player.isFriendly(p)) return false;
// Don't spam boats into players more than 2x our troops
return p.troops() <= this.player.troops() * 2;
// Don't spam boats into players with more troops
return p.troops() < this.player.troops();
});
if (filteredPlayers.length > 0) {