From 96aa39a415eb787a9397468539f206094357e3d6 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Fri, 9 Jan 2026 04:27:47 +0100 Subject: [PATCH] =?UTF-8?q?Improve=20nations=20=F0=9F=A4=96=20(#2817)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- src/core/execution/NationExecution.ts | 213 +------------ .../nation/NationAllianceBehavior.ts | 53 +++- .../execution/nation/NationEmojiBehavior.ts | 3 + .../execution/nation/NationWarshipBehavior.ts | 53 ++++ src/core/execution/utils/AiAttackBehavior.ts | 289 ++++++++++++++---- 5 files changed, 329 insertions(+), 282 deletions(-) diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index 987cfffa1..4e9754c88 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -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(); - 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); diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts index 10e70fffc..ce03b7650 100644 --- a/src/core/execution/nation/NationAllianceBehavior.ts +++ b/src/core/execution/nation/NationAllianceBehavior.ts @@ -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; } diff --git a/src/core/execution/nation/NationEmojiBehavior.ts b/src/core/execution/nation/NationEmojiBehavior.ts index c69ffcc2d..ebb2e0f02 100644 --- a/src/core/execution/nation/NationEmojiBehavior.ts +++ b/src/core/execution/nation/NationEmojiBehavior.ts @@ -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( diff --git a/src/core/execution/nation/NationWarshipBehavior.ts b/src/core/execution/nation/NationWarshipBehavior.ts index 98e82912e..5dc7b0344 100644 --- a/src/core/execution/nation/NationWarshipBehavior.ts +++ b/src/core/execution/nation/NationWarshipBehavior.ts @@ -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(); diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index fa18b83a7..4e27b6bb4 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -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(); + 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) {