From af1e05961c6fd685cc8c14af90b757a4467b2017 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Sat, 20 Dec 2025 04:08:37 +0100 Subject: [PATCH] =?UTF-8?q?Cleanup=20nations=20(Part=202)=20=F0=9F=A7=B9?= =?UTF-8?q?=20(#2647)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: 1. Moved the currently very small betrayal logic from `AiAttackBehavior` to `NationAllianceBehavior` because it makes more sense to have it there. 3. Very small bugfix in `AiAttackBehavior::shouldAttack()`: the numbers in the two `random.chance` calls were the wrong way round. 4. `NationExecution` was quite big and a lot of it was about MIRVs. So I moved all the MIRV logic to the new `NationMIRVBehavior`. 5. `emoji()` and `maybeSendEmoji()` did not really fit in `AiAttackBehavior`. So I moved it to the new `NationEmojiBehavior` (and did some renaming for clarity). I'm planning to extend that class in a future PR. 2. Reordered methods in `AiAttackBehavior` to easily find related methods. 6. Reordered methods in `NationExecution` to easily find related methods. ## 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 | 930 +++++++----------- .../nation/NationAllianceBehavior.ts | 23 + .../execution/nation/NationEmojiBehavior.ts | 52 + .../execution/nation/NationMIRVBehavior.ts | 267 +++++ src/core/execution/utils/AiAttackBehavior.ts | 320 +++--- 5 files changed, 844 insertions(+), 748 deletions(-) create mode 100644 src/core/execution/nation/NationEmojiBehavior.ts create mode 100644 src/core/execution/nation/NationMIRVBehavior.ts diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index 8f149303a..98c0b4faa 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -19,18 +19,21 @@ import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; import { boundingBoxTiles, calculateBoundingBox, simpleHash } from "../Util"; import { ConstructionExecution } from "./ConstructionExecution"; -import { MirvExecution } from "./MIRVExecution"; import { NationAllianceBehavior } from "./nation/NationAllianceBehavior"; +import { NationEmojiBehavior } from "./nation/NationEmojiBehavior"; +import { NationMIRVBehavior } from "./nation/NationMIRVBehavior"; import { structureSpawnTileValue } from "./nation/structureSpawnTileValue"; import { NukeExecution } from "./NukeExecution"; import { SpawnExecution } from "./SpawnExecution"; import { TransportShipExecution } from "./TransportShipExecution"; -import { calculateTerritoryCenter, closestTwoTiles } from "./Util"; +import { closestTwoTiles } from "./Util"; import { AiAttackBehavior } from "./utils/AiAttackBehavior"; export class NationExecution implements Execution { private active = true; private random: PseudoRandom; + private emojiBehavior: NationEmojiBehavior | null = null; + private mirvBehavior: NationMIRVBehavior | null = null; private attackBehavior: AiAttackBehavior | null = null; private allianceBehavior: NationAllianceBehavior | null = null; private mg: Game; @@ -43,7 +46,6 @@ export class NationExecution implements Execution { private expandRatio: number; private readonly lastNukeSent: [Tick, TileRef][] = []; - private readonly lastMIRVSent: [Tick, TileRef][] = []; private readonly embargoMalusApplied = new Set(); // Track our transport ships we currently own @@ -51,26 +53,6 @@ export class NationExecution implements Execution { // Track our trade ships we currently own private trackedTradeShips: Set = new Set(); - /** MIRV Strategy Constants */ - - /** Ticks until MIRV can be attempted again */ - private static readonly MIRV_COOLDOWN_TICKS = 20; - - /** Odds of aborting a MIRV attempt */ - private static readonly MIRV_HESITATION_ODDS = 7; - - /** Threshold for team victory denial */ - private static readonly VICTORY_DENIAL_TEAM_THRESHOLD = 0.8; - - /** Threshold for individual victory denial */ - private static readonly VICTORY_DENIAL_INDIVIDUAL_THRESHOLD = 0.65; - - /** Multiplier for steamroll city gap threshold */ - private static readonly STEAMROLL_CITY_GAP_MULTIPLIER = 1.3; - - /** Minimum city count for leader to trigger steam roll detection */ - private static readonly STEAMROLL_MIN_LEADER_CITIES = 10; - constructor( gameID: GameID, private nation: Nation, // Nation contains PlayerInfo with PlayerType.Nation @@ -92,51 +74,6 @@ export class NationExecution implements Execution { } } - private updateRelationsFromEmbargos() { - const player = this.player; - if (player === null) return; - const others = this.mg.players().filter((p) => p.id() !== player.id()); - - others.forEach((other: Player) => { - const embargoMalus = -20; - if ( - other.hasEmbargoAgainst(player) && - !this.embargoMalusApplied.has(other.id()) - ) { - player.updateRelation(other, embargoMalus); - this.embargoMalusApplied.add(other.id()); - } else if ( - !other.hasEmbargoAgainst(player) && - this.embargoMalusApplied.has(other.id()) - ) { - player.updateRelation(other, -embargoMalus); - this.embargoMalusApplied.delete(other.id()); - } - }); - } - - private handleEmbargoesToHostileNations() { - const player = this.player; - if (player === null) return; - const others = this.mg.players().filter((p) => p.id() !== player.id()); - - others.forEach((other: Player) => { - /* When player is hostile starts embargo. Do not stop until neutral again */ - if ( - player.relation(other) <= Relation.Hostile && - !player.hasEmbargoAgainst(other) && - !player.isOnSameTeam(other) - ) { - player.addEmbargo(other, false); - } else if ( - player.relation(other) >= Relation.Neutral && - player.hasEmbargoAgainst(other) - ) { - player.stopEmbargo(other); - } - }); - } - tick(ticks: number) { // Ship tracking if ( @@ -176,8 +113,28 @@ export class NationExecution implements Execution { return; } - if (this.attackBehavior === null || this.allianceBehavior === null) { + if ( + this.mirvBehavior === null || + this.attackBehavior === null || + this.allianceBehavior === null + ) { // Player is unavailable during init() + this.emojiBehavior = new NationEmojiBehavior( + this.random, + this.mg, + this.player, + ); + this.mirvBehavior = new NationMIRVBehavior( + this.random, + this.mg, + this.player, + this.emojiBehavior, + ); + this.allianceBehavior = new NationAllianceBehavior( + this.random, + this.mg, + this.player, + ); this.attackBehavior = new AiAttackBehavior( this.random, this.mg, @@ -185,11 +142,8 @@ export class NationExecution implements Execution { this.triggerRatio, this.reserveRatio, this.expandRatio, - ); - this.allianceBehavior = new NationAllianceBehavior( - this.random, - this.mg, - this.player, + this.allianceBehavior, + this.emojiBehavior, ); // Send an attack on the first tick @@ -202,195 +156,120 @@ export class NationExecution implements Execution { this.allianceBehavior.handleAllianceExtensionRequests(); this.handleUnits(); this.handleEmbargoesToHostileNations(); - this.considerMIRV(); + this.mirvBehavior.considerMIRV(); this.maybeAttack(); } - private maybeAttack() { - if ( - this.player === null || - this.attackBehavior === null || - this.allianceBehavior === null - ) { - throw new Error("not initialized"); - } + // Send out a warship if our transport ship got captured + private trackTransportShipsAndRetaliate(): void { + if (this.player === null) return; - 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, - ); + // Add any currently owned transport ships to our tracking set + this.player + .units(UnitType.TransportShip) + .forEach((u) => this.trackedTransportShips.add(u)); - // 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(); + // Iterate tracked transport ships; if it got destroyed by an enemy: retaliate + for (const ship of Array.from(this.trackedTransportShips)) { + if (!ship.isActive()) { + // Distinguish between arrival/retreat and enemy destruction + if (ship.wasDestroyedByEnemy()) { + this.maybeRetaliateWithWarship(ship.tile()); + } + this.trackedTransportShips.delete(ship); } - } else { - if (this.random.chance(10)) { - this.sendBoatRandomly(borderingEnemies); + } + } + + // Send out a warship if our trade ship got captured + private trackTradeShipsAndRetaliate(): void { + if (this.player === null) return; + + // Add any currently owned trade ships to our tracking map + this.player + .units(UnitType.TradeShip) + .forEach((u) => this.trackedTradeShips.add(u)); + + // Iterate tracked trade ships; if we no longer own it, it was captured: retaliate + for (const ship of Array.from(this.trackedTradeShips)) { + if (!ship.isActive()) { + this.trackedTradeShips.delete(ship); + continue; + } + if (ship.owner().id() !== this.player.id()) { + // Ship was ours and is now owned by someone else -> captured + this.maybeRetaliateWithWarship(ship.tile()); + this.trackedTradeShips.delete(ship); + } + } + } + + private maybeRetaliateWithWarship(tile: TileRef): void { + if (this.player === null) return; + + const { difficulty } = this.mg.config().gameConfig(); + // In Easy never retaliate. In Medium retaliate with 15% chance. Hard with 50%, Impossible with 80%. + if ( + (difficulty === Difficulty.Medium && this.random.nextInt(0, 100) < 15) || + (difficulty === Difficulty.Hard && this.random.nextInt(0, 100) < 50) || + (difficulty === Difficulty.Impossible && this.random.nextInt(0, 100) < 80) + ) { + const canBuild = this.player.canBuild(UnitType.Warship, tile); + if (canBuild === false) { return; } - - this.allianceBehavior.maybeSendAllianceRequests(borderingEnemies); + this.mg.addExecution( + new ConstructionExecution(this.player, UnitType.Warship, tile), + ); } - - this.attackBehavior.assistAllies(); - - this.attackBehavior.attackBestTarget(borderingFriends, borderingEnemies); - - this.maybeSendNuke( - this.attackBehavior.findBestNukeTarget(borderingEnemies), - ); } - private maybeSendNuke(other: Player | null) { - if (this.player === null) throw new Error("not initialized"); - const silos = this.player.units(UnitType.MissileSilo); - if ( - silos.length === 0 || - this.player.gold() < this.cost(UnitType.AtomBomb) || - other === null || - other.type() === PlayerType.Bot || // Don't nuke bots (as opposed to nations and humans) - this.player.isOnSameTeam(other) - ) { - return; - } - - const nukeType = - this.player.gold() > this.cost(UnitType.HydrogenBomb) - ? UnitType.HydrogenBomb - : UnitType.AtomBomb; - const range = nukeType === UnitType.HydrogenBomb ? 60 : 15; - - const structures = other.units( - UnitType.City, - UnitType.DefensePost, - UnitType.MissileSilo, - UnitType.Port, - UnitType.SAMLauncher, - ); - const structureTiles = structures.map((u) => u.tile()); - const randomTiles = this.randTerritoryTileArray(10); - const allTiles = randomTiles.concat(structureTiles); - - let bestTile: TileRef | null = null; - let bestValue = 0; - this.removeOldNukeEvents(); - outer: for (const tile of new Set(allTiles)) { - if (tile === null) continue; - const boundingBox = boundingBoxTiles(this.mg, tile, range) - // Add radius / 2 in case there is a piece of unwanted territory inside the outer radius that we miss. - .concat(boundingBoxTiles(this.mg, tile, Math.floor(range / 2))); - for (const t of boundingBox) { - // Make sure we nuke away from the border - if (this.mg.owner(t) !== other) { - continue outer; - } + randomSpawnLand(): TileRef | null { + const delta = 25; + let tries = 0; + while (tries < 50) { + tries++; + const cell = this.nation.spawnCell; + const x = this.random.nextInt(cell.x - delta, cell.x + delta); + const y = this.random.nextInt(cell.y - delta, cell.y + delta); + if (!this.mg.isValidCoord(x, y)) { + continue; } - if (!this.player.canBuild(nukeType, tile)) continue; - const value = this.nukeTileScore(tile, silos, structures); - if (value > bestValue) { - bestTile = tile; - bestValue = value; + const tile = this.mg.ref(x, y); + if (this.mg.isLand(tile) && !this.mg.hasOwner(tile)) { + if ( + this.mg.terrainType(tile) === TerrainType.Mountain && + this.random.chance(2) + ) { + continue; + } + return tile; } } - if (bestTile !== null) { - this.sendNuke(bestTile, nukeType, other); - } + return null; } - 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 updateRelationsFromEmbargos() { + const player = this.player; + if (player === null) return; + const others = this.mg.players().filter((p) => p.id() !== player.id()); - private sendNuke( - tile: TileRef, - nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb, - targetPlayer: Player, - ) { - if (this.player === null) throw new Error("not initialized"); - const tick = this.mg.ticks(); - this.lastNukeSent.push([tick, tile]); - this.mg.addExecution(new NukeExecution(nukeType, this.player, tile)); - this.attackBehavior?.maybeSendEmoji(targetPlayer); - } - - private nukeTileScore(tile: TileRef, silos: Unit[], targets: Unit[]): number { - // Potential damage in a 25-tile radius - const dist = euclDistFN(tile, 25, false); - let tileValue = targets - .filter((unit) => dist(this.mg, unit.tile())) - .map((unit): number => { - switch (unit.type()) { - case UnitType.City: - return 25_000; - case UnitType.DefensePost: - return 5_000; - case UnitType.MissileSilo: - return 50_000; - case UnitType.Port: - return 10_000; - default: - return 0; - } - }) - .reduce((prev, cur) => prev + cur, 0); - - // Avoid areas defended by SAM launchers - const dist50 = euclDistFN(tile, 50, false); - tileValue -= - 50_000 * - targets.filter( - (unit) => - unit.type() === UnitType.SAMLauncher && dist50(this.mg, unit.tile()), - ).length; - - // Prefer tiles that are closer to a silo - const siloTiles = silos.map((u) => u.tile()); - const result = closestTwoTiles(this.mg, siloTiles, [tile]); - if (result === null) throw new Error("Missing result"); - const { x: closestSilo } = result; - const distanceSquared = this.mg.euclideanDistSquared(tile, closestSilo); - const distanceToClosestSilo = Math.sqrt(distanceSquared); - tileValue -= distanceToClosestSilo * 30; - - // Don't target near recent targets - tileValue -= this.lastNukeSent - .filter(([_tick, tile]) => dist(this.mg, tile)) - .map((_) => 1_000_000) - .reduce((prev, cur) => prev + cur, 0); - - return tileValue; + others.forEach((other: Player) => { + const embargoMalus = -20; + if ( + other.hasEmbargoAgainst(player) && + !this.embargoMalusApplied.has(other.id()) + ) { + player.updateRelation(other, embargoMalus); + this.embargoMalusApplied.add(other.id()); + } else if ( + !other.hasEmbargoAgainst(player) && + this.embargoMalusApplied.has(other.id()) + ) { + player.updateRelation(other, -embargoMalus); + this.embargoMalusApplied.delete(other.id()); + } + }); } private handleUnits() { @@ -503,41 +382,6 @@ export class NationExecution implements Execution { return false; } - private randTerritoryTileArray(numTiles: number): TileRef[] { - const boundingBox = calculateBoundingBox( - this.mg, - this.player!.borderTiles(), - ); - const tiles: TileRef[] = []; - for (let i = 0; i < numTiles; i++) { - const tile = this.randTerritoryTile(this.player!, boundingBox); - if (tile !== null) { - tiles.push(tile); - } - } - return tiles; - } - - private randTerritoryTile( - p: Player, - boundingBox: { min: Cell; max: Cell } | null = null, - ): TileRef | null { - boundingBox ??= calculateBoundingBox(this.mg, p.borderTiles()); - for (let i = 0; i < 100; i++) { - const randX = this.random.nextInt(boundingBox.min.x, boundingBox.max.x); - const randY = this.random.nextInt(boundingBox.min.y, boundingBox.max.y); - if (!this.mg.isOnMap(new Cell(randX, randY))) { - // Sanity check should never happen - continue; - } - const randTile = this.mg.ref(randX, randY); - if (this.mg.owner(randTile) === p) { - return randTile; - } - } - return null; - } - private warshipSpawnTile(portTile: TileRef): TileRef | null { const radius = 250; for (let attempts = 0; attempts < 50; attempts++) { @@ -562,9 +406,86 @@ export class NationExecution implements Execution { 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); + private handleEmbargoesToHostileNations() { + const player = this.player; + if (player === null) return; + const others = this.mg.players().filter((p) => p.id() !== player.id()); + + others.forEach((other: Player) => { + /* When player is hostile starts embargo. Do not stop until neutral again */ + if ( + player.relation(other) <= Relation.Hostile && + !player.hasEmbargoAgainst(other) && + !player.isOnSameTeam(other) + ) { + player.addEmbargo(other, false); + } else if ( + player.relation(other) >= Relation.Neutral && + player.hasEmbargoAgainst(other) + ) { + player.stopEmbargo(other); + } + }); + } + + private maybeAttack() { + if ( + this.player === null || + this.attackBehavior === null || + this.allianceBehavior === 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.assistAllies(); + + this.attackBehavior.attackBestTarget(borderingFriends, borderingEnemies); + + this.maybeSendNuke( + this.attackBehavior.findBestNukeTarget(borderingEnemies), + ); } sendBoatRandomly(borderingEnemies: Player[] = []) { @@ -600,31 +521,6 @@ export class NationExecution implements Execution { return; } - randomSpawnLand(): TileRef | null { - const delta = 25; - let tries = 0; - while (tries < 50) { - tries++; - const cell = this.nation.spawnCell; - const x = this.random.nextInt(cell.x - delta, cell.x + delta); - const y = this.random.nextInt(cell.y - delta, cell.y + delta); - if (!this.mg.isValidCoord(x, y)) { - continue; - } - const tile = this.mg.ref(x, y); - if (this.mg.isLand(tile) && !this.mg.hasOwner(tile)) { - if ( - this.mg.terrainType(tile) === TerrainType.Mountain && - this.random.chance(2) - ) { - continue; - } - return tile; - } - } - return null; - } - private randomBoatTarget( tile: TileRef, borderingEnemies: Player[], @@ -670,288 +566,176 @@ export class NationExecution implements Execution { return null; } - // MIRV Strategy Methods - private considerMIRV(): boolean { + private maybeSendNuke(other: Player | null) { if (this.player === null) throw new Error("not initialized"); - if (this.player.units(UnitType.MissileSilo).length === 0) { - return false; - } - if (this.player.gold() < this.cost(UnitType.MIRV)) { - return false; + const silos = this.player.units(UnitType.MissileSilo); + if ( + silos.length === 0 || + this.player.gold() < this.cost(UnitType.AtomBomb) || + other === null || + other.type() === PlayerType.Bot || // Don't nuke bots (as opposed to nations and humans) + this.player.isOnSameTeam(other) + ) { + return; } - this.removeOldMIRVEvents(); - if (this.lastMIRVSent.length > 0) { - return false; - } + const nukeType = + this.player.gold() > this.cost(UnitType.HydrogenBomb) + ? UnitType.HydrogenBomb + : UnitType.AtomBomb; + const range = nukeType === UnitType.HydrogenBomb ? 60 : 15; - if (this.random.chance(NationExecution.MIRV_HESITATION_ODDS)) { - this.triggerMIRVCooldown(); - return false; - } - - const inboundMIRVSender = this.selectCounterMirvTarget(); - if (inboundMIRVSender) { - this.maybeSendMIRV(inboundMIRVSender); - return true; - } - - const victoryDenialTarget = this.selectVictoryDenialTarget(); - if (victoryDenialTarget) { - this.maybeSendMIRV(victoryDenialTarget); - return true; - } - - const steamrollStopTarget = this.selectSteamrollStopTarget(); - if (steamrollStopTarget) { - this.maybeSendMIRV(steamrollStopTarget); - return true; - } - - return false; - } - - private selectCounterMirvTarget(): Player | null { - if (this.player === null) throw new Error("not initialized"); - const attackers = this.getValidMirvTargetPlayers().filter((p) => - this.isInboundMIRVFrom(p), + const structures = other.units( + UnitType.City, + UnitType.DefensePost, + UnitType.MissileSilo, + UnitType.Port, + UnitType.SAMLauncher, ); - if (attackers.length === 0) return null; - attackers.sort((a, b) => b.numTilesOwned() - a.numTilesOwned()); - return attackers[0]; - } + const structureTiles = structures.map((u) => u.tile()); + const randomTiles = this.randTerritoryTileArray(10); + const allTiles = randomTiles.concat(structureTiles); - private selectVictoryDenialTarget(): Player | null { - if (this.player === null) throw new Error("not initialized"); - const totalLand = this.mg.numLandTiles(); - if (totalLand === 0) return null; - let best: { p: Player; severity: number } | null = null; - for (const p of this.getValidMirvTargetPlayers()) { - let severity = 0; - const team = p.team(); - if (team !== null) { - const teamMembers = this.mg - .players() - .filter((x) => x.team() === team && x.isPlayer()); - const teamTerritory = teamMembers - .map((x) => x.numTilesOwned()) - .reduce((a, b) => a + b, 0); - const teamShare = teamTerritory / totalLand; - if (teamShare >= NationExecution.VICTORY_DENIAL_TEAM_THRESHOLD) { - // Only consider the largest team member as the target when team exceeds threshold - let largestMember: Player | null = null; - let largestTiles = -1; - for (const member of teamMembers) { - const tiles = member.numTilesOwned(); - if (tiles > largestTiles) { - largestTiles = tiles; - largestMember = member; - } - } - if (largestMember === p) { - severity = teamShare; - } else { - severity = 0; // Skip non-largest members - } + let bestTile: TileRef | null = null; + let bestValue = 0; + this.removeOldNukeEvents(); + outer: for (const tile of new Set(allTiles)) { + if (tile === null) continue; + const boundingBox = boundingBoxTiles(this.mg, tile, range) + // Add radius / 2 in case there is a piece of unwanted territory inside the outer radius that we miss. + .concat(boundingBoxTiles(this.mg, tile, Math.floor(range / 2))); + for (const t of boundingBox) { + // Make sure we nuke away from the border + if (this.mg.owner(t) !== other) { + continue outer; } - } else { - const share = p.numTilesOwned() / totalLand; - if (share >= NationExecution.VICTORY_DENIAL_INDIVIDUAL_THRESHOLD) - severity = share; } - if (severity > 0) { - if (best === null || severity > best.severity) best = { p, severity }; + if (!this.player.canBuild(nukeType, tile)) continue; + const value = this.nukeTileScore(tile, silos, structures); + if (value > bestValue) { + bestTile = tile; + bestValue = value; } } - return best ? best.p : null; + if (bestTile !== null) { + this.sendNuke(bestTile, nukeType, other); + } } - private selectSteamrollStopTarget(): Player | null { - if (this.player === null) throw new Error("not initialized"); - const validTargets = this.getValidMirvTargetPlayers(); - - if (validTargets.length === 0) return null; - - const allPlayers = this.mg - .players() - .filter((p) => p.isPlayer()) - .map((p) => ({ p, cityCount: this.countCities(p) })) - .sort((a, b) => b.cityCount - a.cityCount); - - if (allPlayers.length < 2) return null; - - const topPlayer = allPlayers[0]; - - if (topPlayer.cityCount <= NationExecution.STEAMROLL_MIN_LEADER_CITIES) - return null; - - const secondHighest = allPlayers[1].cityCount; - - const threshold = - secondHighest * NationExecution.STEAMROLL_CITY_GAP_MULTIPLIER; - - if (topPlayer.cityCount >= threshold) { - return validTargets.some((p) => p === topPlayer.p) ? topPlayer.p : null; + private removeOldNukeEvents() { + const maxAge = 500; + const tick = this.mg.ticks(); + while ( + this.lastNukeSent.length > 0 && + this.lastNukeSent[0][0] + maxAge < tick + ) { + this.lastNukeSent.shift(); } + } + private nukeTileScore(tile: TileRef, silos: Unit[], targets: Unit[]): number { + // Potential damage in a 25-tile radius + const dist = euclDistFN(tile, 25, false); + let tileValue = targets + .filter((unit) => dist(this.mg, unit.tile())) + .map((unit): number => { + switch (unit.type()) { + case UnitType.City: + return 25_000; + case UnitType.DefensePost: + return 5_000; + case UnitType.MissileSilo: + return 50_000; + case UnitType.Port: + return 10_000; + default: + return 0; + } + }) + .reduce((prev, cur) => prev + cur, 0); + + // Avoid areas defended by SAM launchers + const dist50 = euclDistFN(tile, 50, false); + tileValue -= + 50_000 * + targets.filter( + (unit) => + unit.type() === UnitType.SAMLauncher && dist50(this.mg, unit.tile()), + ).length; + + // Prefer tiles that are closer to a silo + const siloTiles = silos.map((u) => u.tile()); + const result = closestTwoTiles(this.mg, siloTiles, [tile]); + if (result === null) throw new Error("Missing result"); + const { x: closestSilo } = result; + const distanceSquared = this.mg.euclideanDistSquared(tile, closestSilo); + const distanceToClosestSilo = Math.sqrt(distanceSquared); + tileValue -= distanceToClosestSilo * 30; + + // Don't target near recent targets + tileValue -= this.lastNukeSent + .filter(([_tick, tile]) => dist(this.mg, tile)) + .map((_) => 1_000_000) + .reduce((prev, cur) => prev + cur, 0); + + return tileValue; + } + + private sendNuke( + tile: TileRef, + nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb, + targetPlayer: Player, + ) { + if ( + this.player === null || + this.attackBehavior === null || + this.emojiBehavior === null + ) + throw new Error("not initialized"); + const tick = this.mg.ticks(); + this.lastNukeSent.push([tick, tile]); + this.mg.addExecution(new NukeExecution(nukeType, this.player, tile)); + this.emojiBehavior.maybeSendHeckleEmoji(targetPlayer); + } + + private randTerritoryTileArray(numTiles: number): TileRef[] { + const boundingBox = calculateBoundingBox( + this.mg, + this.player!.borderTiles(), + ); + const tiles: TileRef[] = []; + for (let i = 0; i < numTiles; i++) { + const tile = this.randTerritoryTile(this.player!, boundingBox); + if (tile !== null) { + tiles.push(tile); + } + } + return tiles; + } + + private randTerritoryTile( + p: Player, + boundingBox: { min: Cell; max: Cell } | null = null, + ): TileRef | null { + boundingBox ??= calculateBoundingBox(this.mg, p.borderTiles()); + for (let i = 0; i < 100; i++) { + const randX = this.random.nextInt(boundingBox.min.x, boundingBox.max.x); + const randY = this.random.nextInt(boundingBox.min.y, boundingBox.max.y); + if (!this.mg.isOnMap(new Cell(randX, randY))) { + // Sanity check should never happen + continue; + } + const randTile = this.mg.ref(randX, randY); + if (this.mg.owner(randTile) === p) { + return randTile; + } + } return null; } - // MIRV Helper Methods - private mirvTargetsCache: { - tick: number; - players: Player[]; - } | null = null; - - private getValidMirvTargetPlayers(): Player[] { - const MIRV_TARGETS_CACHE_TICKS = 2 * 10; // 2 seconds + private cost(type: UnitType): Gold { if (this.player === null) throw new Error("not initialized"); - - if ( - this.mirvTargetsCache && - this.mg.ticks() - this.mirvTargetsCache.tick < MIRV_TARGETS_CACHE_TICKS - ) { - return this.mirvTargetsCache.players; - } - - const players = this.mg.players().filter((p) => { - return ( - p !== this.player && - p.isPlayer() && - p.type() !== PlayerType.Bot && - !this.player!.isOnSameTeam(p) - ); - }); - - this.mirvTargetsCache = { tick: this.mg.ticks(), players }; - return players; - } - - private isInboundMIRVFrom(attacker: Player): boolean { - if (this.player === null) throw new Error("not initialized"); - const enemyMirvs = attacker.units(UnitType.MIRV); - for (const mirv of enemyMirvs) { - const dst = mirv.targetTile(); - if (!dst) continue; - if (!this.mg.hasOwner(dst)) continue; - const owner = this.mg.owner(dst); - if (owner === this.player) { - return true; - } - } - return false; - } - - private countCities(p: Player): number { - return p.unitCount(UnitType.City); - } - - private calculateTerritoryCenter(target: Player): TileRef | null { - return calculateTerritoryCenter(this.mg, target); - } - - // MIRV Execution Methods - private maybeSendMIRV(enemy: Player): void { - if (this.player === null) throw new Error("not initialized"); - - this.attackBehavior?.maybeSendEmoji(enemy); - - const centerTile = this.calculateTerritoryCenter(enemy); - if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) { - this.sendMIRV(centerTile); - return; - } - } - - private sendMIRV(tile: TileRef): void { - if (this.player === null) throw new Error("not initialized"); - this.triggerMIRVCooldown(tile); - this.mg.addExecution(new MirvExecution(this.player, tile)); - } - - private triggerMIRVCooldown(tile?: TileRef): void { - if (this.player === null) throw new Error("not initialized"); - this.removeOldMIRVEvents(); - const tick = this.mg.ticks(); - // Use provided tile or any tile from player's territory for cooldown tracking - const cooldownTile = - tile ?? Array.from(this.player.tiles())[0] ?? this.mg.ref(0, 0); - this.lastMIRVSent.push([tick, cooldownTile]); - } - - private removeOldMIRVEvents() { - const maxAge = NationExecution.MIRV_COOLDOWN_TICKS; - const tick = this.mg.ticks(); - while ( - this.lastMIRVSent.length > 0 && - this.lastMIRVSent[0][0] + maxAge <= tick - ) { - this.lastMIRVSent.shift(); - } - } - - // Send out a warship if our transport ship got captured - private trackTransportShipsAndRetaliate(): void { - if (this.player === null) return; - - // Add any currently owned transport ships to our tracking set - this.player - .units(UnitType.TransportShip) - .forEach((u) => this.trackedTransportShips.add(u)); - - // Iterate tracked transport ships; if it got destroyed by an enemy: retaliate - for (const ship of Array.from(this.trackedTransportShips)) { - if (!ship.isActive()) { - // Distinguish between arrival/retreat and enemy destruction - if (ship.wasDestroyedByEnemy()) { - this.maybeRetaliateWithWarship(ship.tile()); - } - this.trackedTransportShips.delete(ship); - } - } - } - - // Send out a warship if our trade ship got captured - private trackTradeShipsAndRetaliate(): void { - if (this.player === null) return; - - // Add any currently owned trade ships to our tracking map - this.player - .units(UnitType.TradeShip) - .forEach((u) => this.trackedTradeShips.add(u)); - - // Iterate tracked trade ships; if we no longer own it, it was captured: retaliate - for (const ship of Array.from(this.trackedTradeShips)) { - if (!ship.isActive()) { - this.trackedTradeShips.delete(ship); - continue; - } - if (ship.owner().id() !== this.player.id()) { - // Ship was ours and is now owned by someone else -> captured - this.maybeRetaliateWithWarship(ship.tile()); - this.trackedTradeShips.delete(ship); - } - } - } - - private maybeRetaliateWithWarship(tile: TileRef): void { - if (this.player === null) return; - - const { difficulty } = this.mg.config().gameConfig(); - // In Easy never retaliate. In Medium retaliate with 15% chance. Hard with 50%, Impossible with 80%. - if ( - (difficulty === Difficulty.Medium && this.random.nextInt(0, 100) < 15) || - (difficulty === Difficulty.Hard && this.random.nextInt(0, 100) < 50) || - (difficulty === Difficulty.Impossible && this.random.nextInt(0, 100) < 80) - ) { - const canBuild = this.player.canBuild(UnitType.Warship, tile); - if (canBuild === false) { - return; - } - this.mg.addExecution( - new ConstructionExecution(this.player, UnitType.Warship, tile), - ); - } + return this.mg.unitInfo(type).cost(this.mg, this.player); } isActive(): boolean { diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts index e83950fd1..99cc6d76b 100644 --- a/src/core/execution/nation/NationAllianceBehavior.ts +++ b/src/core/execution/nation/NationAllianceBehavior.ts @@ -243,4 +243,27 @@ export class NationAllianceBehavior { assertNever(difficulty); } } + + // 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 { + if ( + this.player.isAlliedWith(otherPlayer) && + this.player.troops() >= otherPlayer.troops() * 10 + ) { + this.betray(otherPlayer); + return true; + } + return false; + } + + private betray(target: Player): void { + const alliance = this.player.allianceWith(target); + if (!alliance) return; + this.player.breakAlliance(alliance); + } } diff --git a/src/core/execution/nation/NationEmojiBehavior.ts b/src/core/execution/nation/NationEmojiBehavior.ts new file mode 100644 index 000000000..4aa65e8a2 --- /dev/null +++ b/src/core/execution/nation/NationEmojiBehavior.ts @@ -0,0 +1,52 @@ +import { Game, Player, PlayerType, Tick } from "../../game/Game"; +import { PseudoRandom } from "../../PseudoRandom"; +import { flattenedEmojiTable } from "../../Util"; +import { EmojiExecution } from "../EmojiExecution"; + +const emojiId = (e: (typeof flattenedEmojiTable)[number]) => + flattenedEmojiTable.indexOf(e); +export const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map( + emojiId, +); +export const EMOJI_ASSIST_RELATION_TOO_LOW = (["🥱", "🤦‍♂️"] as const).map( + emojiId, +); +export const EMOJI_ASSIST_TARGET_ME = (["🥺", "💀"] as const).map(emojiId); +export const EMOJI_ASSIST_TARGET_ALLY = (["🕊️", "👎"] as const).map(emojiId); +export const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId); + +export class NationEmojiBehavior { + private readonly lastEmojiSent = new Map(); + + constructor( + private random: PseudoRandom, + private game: Game, + private player: Player, + ) {} + + sendEmoji(player: Player, emojisList: number[]) { + if (player.type() !== PlayerType.Human) return; + this.game.addExecution( + new EmojiExecution( + this.player, + player.id(), + this.random.randElement(emojisList), + ), + ); + } + + maybeSendHeckleEmoji(enemy: Player) { + if (this.player.type() === PlayerType.Bot) return; + if (enemy.type() !== PlayerType.Human) return; + const lastSent = this.lastEmojiSent.get(enemy) ?? -300; + if (this.game.ticks() - lastSent <= 300) return; + this.lastEmojiSent.set(enemy, this.game.ticks()); + this.game.addExecution( + new EmojiExecution( + this.player, + enemy.id(), + this.random.randElement(EMOJI_HECKLE), + ), + ); + } +} diff --git a/src/core/execution/nation/NationMIRVBehavior.ts b/src/core/execution/nation/NationMIRVBehavior.ts new file mode 100644 index 000000000..147a46066 --- /dev/null +++ b/src/core/execution/nation/NationMIRVBehavior.ts @@ -0,0 +1,267 @@ +import { + Game, + Gold, + Player, + PlayerType, + Tick, + UnitType, +} from "../../game/Game"; +import { TileRef } from "../../game/GameMap"; +import { PseudoRandom } from "../../PseudoRandom"; +import { MirvExecution } from "../MIRVExecution"; +import { calculateTerritoryCenter } from "../Util"; +import { NationEmojiBehavior } from "./NationEmojiBehavior"; + +export class NationMIRVBehavior { + private readonly lastMIRVSent: [Tick, TileRef][] = []; + + /** Ticks until MIRV can be attempted again */ + private static readonly MIRV_COOLDOWN_TICKS = 20; + + /** Odds of aborting a MIRV attempt */ + private static readonly MIRV_HESITATION_ODDS = 7; + + /** Threshold for team victory denial */ + private static readonly VICTORY_DENIAL_TEAM_THRESHOLD = 0.8; + + /** Threshold for individual victory denial */ + private static readonly VICTORY_DENIAL_INDIVIDUAL_THRESHOLD = 0.65; + + /** Multiplier for steamroll city gap threshold */ + private static readonly STEAMROLL_CITY_GAP_MULTIPLIER = 1.3; + + /** Minimum city count for leader to trigger steam roll detection */ + private static readonly STEAMROLL_MIN_LEADER_CITIES = 10; + + constructor( + private random: PseudoRandom, + private game: Game, + private player: Player, + private emojiBehavior: NationEmojiBehavior, + ) {} + + considerMIRV(): boolean { + if (this.player === null) throw new Error("not initialized"); + if (this.player.units(UnitType.MissileSilo).length === 0) { + return false; + } + if (this.player.gold() < this.cost(UnitType.MIRV)) { + return false; + } + + this.removeOldMIRVEvents(); + if (this.lastMIRVSent.length > 0) { + return false; + } + + if (this.random.chance(NationMIRVBehavior.MIRV_HESITATION_ODDS)) { + this.triggerMIRVCooldown(); + return false; + } + + const inboundMIRVSender = this.selectCounterMirvTarget(); + if (inboundMIRVSender) { + this.maybeSendMIRV(inboundMIRVSender); + return true; + } + + const victoryDenialTarget = this.selectVictoryDenialTarget(); + if (victoryDenialTarget) { + this.maybeSendMIRV(victoryDenialTarget); + return true; + } + + const steamrollStopTarget = this.selectSteamrollStopTarget(); + if (steamrollStopTarget) { + this.maybeSendMIRV(steamrollStopTarget); + return true; + } + + return false; + } + + // MIRV Strategy Methods + private selectCounterMirvTarget(): Player | null { + if (this.player === null) throw new Error("not initialized"); + const attackers = this.getValidMirvTargetPlayers().filter((p) => + this.isInboundMIRVFrom(p), + ); + if (attackers.length === 0) return null; + attackers.sort((a, b) => b.numTilesOwned() - a.numTilesOwned()); + return attackers[0]; + } + + private selectVictoryDenialTarget(): Player | null { + if (this.player === null) throw new Error("not initialized"); + const totalLand = this.game.numLandTiles(); + if (totalLand === 0) return null; + let best: { p: Player; severity: number } | null = null; + for (const p of this.getValidMirvTargetPlayers()) { + let severity = 0; + const team = p.team(); + if (team !== null) { + const teamMembers = this.game + .players() + .filter((x) => x.team() === team && x.isPlayer()); + const teamTerritory = teamMembers + .map((x) => x.numTilesOwned()) + .reduce((a, b) => a + b, 0); + const teamShare = teamTerritory / totalLand; + if (teamShare >= NationMIRVBehavior.VICTORY_DENIAL_TEAM_THRESHOLD) { + // Only consider the largest team member as the target when team exceeds threshold + let largestMember: Player | null = null; + let largestTiles = -1; + for (const member of teamMembers) { + const tiles = member.numTilesOwned(); + if (tiles > largestTiles) { + largestTiles = tiles; + largestMember = member; + } + } + if (largestMember === p) { + severity = teamShare; + } else { + severity = 0; // Skip non-largest members + } + } + } else { + const share = p.numTilesOwned() / totalLand; + if (share >= NationMIRVBehavior.VICTORY_DENIAL_INDIVIDUAL_THRESHOLD) + severity = share; + } + if (severity > 0) { + if (best === null || severity > best.severity) best = { p, severity }; + } + } + return best ? best.p : null; + } + + private selectSteamrollStopTarget(): Player | null { + if (this.player === null) throw new Error("not initialized"); + const validTargets = this.getValidMirvTargetPlayers(); + + if (validTargets.length === 0) return null; + + const allPlayers = this.game + .players() + .filter((p) => p.isPlayer()) + .map((p) => ({ p, cityCount: this.countCities(p) })) + .sort((a, b) => b.cityCount - a.cityCount); + + if (allPlayers.length < 2) return null; + + const topPlayer = allPlayers[0]; + + if (topPlayer.cityCount <= NationMIRVBehavior.STEAMROLL_MIN_LEADER_CITIES) + return null; + + const secondHighest = allPlayers[1].cityCount; + + const threshold = + secondHighest * NationMIRVBehavior.STEAMROLL_CITY_GAP_MULTIPLIER; + + if (topPlayer.cityCount >= threshold) { + return validTargets.some((p) => p === topPlayer.p) ? topPlayer.p : null; + } + + return null; + } + + // MIRV Helper Methods + private mirvTargetsCache: { + tick: number; + players: Player[]; + } | null = null; + + private getValidMirvTargetPlayers(): Player[] { + const MIRV_TARGETS_CACHE_TICKS = 2 * 10; // 2 seconds + if (this.player === null) throw new Error("not initialized"); + + if ( + this.mirvTargetsCache && + this.game.ticks() - this.mirvTargetsCache.tick < MIRV_TARGETS_CACHE_TICKS + ) { + return this.mirvTargetsCache.players; + } + + const players = this.game.players().filter((p) => { + return ( + p !== this.player && + p.isPlayer() && + p.type() !== PlayerType.Bot && + !this.player!.isOnSameTeam(p) + ); + }); + + this.mirvTargetsCache = { tick: this.game.ticks(), players }; + return players; + } + + private isInboundMIRVFrom(attacker: Player): boolean { + if (this.player === null) throw new Error("not initialized"); + const enemyMirvs = attacker.units(UnitType.MIRV); + for (const mirv of enemyMirvs) { + const dst = mirv.targetTile(); + if (!dst) continue; + if (!this.game.hasOwner(dst)) continue; + const owner = this.game.owner(dst); + if (owner === this.player) { + return true; + } + } + return false; + } + + // MIRV Execution Methods + private maybeSendMIRV(enemy: Player): void { + if (this.player === null) throw new Error("not initialized"); + + this.emojiBehavior.maybeSendHeckleEmoji(enemy); + + const centerTile = this.calculateTerritoryCenter(enemy); + if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) { + this.sendMIRV(centerTile); + return; + } + } + + private sendMIRV(tile: TileRef): void { + if (this.player === null) throw new Error("not initialized"); + this.triggerMIRVCooldown(tile); + this.game.addExecution(new MirvExecution(this.player, tile)); + } + + private triggerMIRVCooldown(tile?: TileRef): void { + if (this.player === null) throw new Error("not initialized"); + this.removeOldMIRVEvents(); + const tick = this.game.ticks(); + // Use provided tile or any tile from player's territory for cooldown tracking + const cooldownTile = + tile ?? Array.from(this.player.tiles())[0] ?? this.game.ref(0, 0); + this.lastMIRVSent.push([tick, cooldownTile]); + } + + private removeOldMIRVEvents() { + const maxAge = NationMIRVBehavior.MIRV_COOLDOWN_TICKS; + const tick = this.game.ticks(); + while ( + this.lastMIRVSent.length > 0 && + this.lastMIRVSent[0][0] + maxAge <= tick + ) { + this.lastMIRVSent.shift(); + } + } + + private countCities(p: Player): number { + return p.unitCount(UnitType.City); + } + + private calculateTerritoryCenter(target: Player): TileRef | null { + return calculateTerritoryCenter(this.game, target); + } + + private cost(type: UnitType): Gold { + if (this.player === null) throw new Error("not initialized"); + 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 1a8bcf063..1785b1e94 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -5,31 +5,27 @@ import { PlayerType, Relation, TerraNullius, - Tick, } from "../../game/Game"; import { PseudoRandom } from "../../PseudoRandom"; import { assertNever, boundingBoxCenter, calculateBoundingBoxCenter, - flattenedEmojiTable, } from "../../Util"; import { AttackExecution } from "../AttackExecution"; -import { EmojiExecution } from "../EmojiExecution"; +import { NationAllianceBehavior } from "../nation/NationAllianceBehavior"; +import { + EMOJI_ASSIST_ACCEPT, + EMOJI_ASSIST_RELATION_TOO_LOW, + EMOJI_ASSIST_TARGET_ALLY, + EMOJI_ASSIST_TARGET_ME, + NationEmojiBehavior, +} from "../nation/NationEmojiBehavior"; import { TransportShipExecution } from "../TransportShipExecution"; import { closestTwoTiles } from "../Util"; -const emojiId = (e: (typeof flattenedEmojiTable)[number]) => - flattenedEmojiTable.indexOf(e); -const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(emojiId); -const EMOJI_RELATION_TOO_LOW = (["🥱", "🤦‍♂️"] as const).map(emojiId); -const EMOJI_TARGET_ME = (["🥺", "💀"] as const).map(emojiId); -const EMOJI_TARGET_ALLY = (["🕊️", "👎"] as const).map(emojiId); -const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId); - export class AiAttackBehavior { private botAttackTroopsSent: number = 0; - private readonly lastEmojiSent = new Map(); constructor( private random: PseudoRandom, @@ -38,103 +34,32 @@ export class AiAttackBehavior { private triggerRatio: number, private reserveRatio: number, private expandRatio: number, + private allianceBehavior?: NationAllianceBehavior, + private emojiBehavior?: NationEmojiBehavior, ) {} - private emoji(player: Player, emoji: number) { - if (player.type() !== PlayerType.Human) return; - this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji)); - } - - // Prevent attacking of humans on lower difficulties - private shouldAttack(other: Player | TerraNullius): boolean { - // Always attack Terra Nullius, non-humans and traitors - if ( - other.isPlayer() === false || - other.type() !== PlayerType.Human || - other.isTraitor() - ) { - return true; - } - - const { difficulty } = this.game.config().gameConfig(); - if (difficulty === Difficulty.Easy && this.random.chance(4)) { - return false; - } - if (difficulty === Difficulty.Medium && this.random.chance(2)) { - return false; - } - return true; - } - - private betray(target: Player): void { - const alliance = this.player.allianceWith(target); - if (!alliance) return; - this.player.breakAlliance(alliance); - } - - private hasReserveRatioTroops(): boolean { - const maxTroops = this.game.config().maxTroops(this.player); - const ratio = this.player.troops() / maxTroops; - return ratio >= this.reserveRatio; - } - - private hasTriggerRatioTroops(): boolean { - const maxTroops = this.game.config().maxTroops(this.player); - const ratio = this.player.troops() / maxTroops; - return ratio >= this.triggerRatio; - } - - private findIncomingAttackPlayer(): Player | null { - // Ignore bot attacks if we are not a bot. - let incomingAttacks = this.player.incomingAttacks(); - if (this.player.type() !== PlayerType.Bot) { - incomingAttacks = incomingAttacks.filter( - (attack) => attack.attacker().type() !== PlayerType.Bot, - ); - } - let largestAttack = 0; - let largestAttacker: Player | undefined; - for (const attack of incomingAttacks) { - if (attack.troops() <= largestAttack) continue; - largestAttack = attack.troops(); - largestAttacker = attack.attacker(); - } - if (largestAttacker !== undefined) { - return largestAttacker; - } - return null; - } - - getNeighborTraitorToAttack(): Player | null { - const traitors = this.player - .neighbors() - .filter( - (n): n is Player => - n.isPlayer() && this.player.isFriendly(n) === false && n.isTraitor(), - ); - return traitors.length > 0 ? this.random.randElement(traitors) : null; - } - assistAllies() { + if (this.emojiBehavior === undefined) throw new Error("not initialized"); + for (const ally of this.player.allies()) { if (ally.targets().length === 0) continue; if (this.player.relation(ally) < Relation.Friendly) { - this.emoji(ally, this.random.randElement(EMOJI_RELATION_TOO_LOW)); + this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_RELATION_TOO_LOW); continue; } for (const target of ally.targets()) { if (target === this.player) { - this.emoji(ally, this.random.randElement(EMOJI_TARGET_ME)); + this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ME); continue; } if (this.player.isFriendly(target)) { - this.emoji(ally, this.random.randElement(EMOJI_TARGET_ALLY)); + this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ALLY); continue; } // All checks passed, assist them this.player.updateRelation(ally, -20); this.sendAttack(target); - this.emoji(ally, this.random.randElement(EMOJI_ASSIST_ACCEPT)); + this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_ACCEPT); return; } } @@ -193,6 +118,73 @@ export class AiAttackBehavior { } } + // TODO: Nuke the crown if it's far enough ahead of everybody else (based on difficulty) + findBestNukeTarget(borderingEnemies: Player[]): Player | null { + // Retaliate against incoming attacks (Most important!) + const incomingAttackPlayer = this.findIncomingAttackPlayer(); + if (incomingAttackPlayer) { + return incomingAttackPlayer; + } + + // Find the most hated player with hostile relation + const mostHated = this.player.allRelationsSorted()[0]; + if ( + mostHated !== undefined && + mostHated.relation === Relation.Hostile && + this.player.isFriendly(mostHated.player) === false + ) { + return mostHated.player; + } + + // Find the weakest player + if (borderingEnemies.length > 0) { + return borderingEnemies[0]; + } + + // If we don't have bordering enemies, find someone on an island next to us + if (borderingEnemies.length === 0) { + const nearestIslandEnemy = this.findNearestIslandEnemy(); + if (nearestIslandEnemy) { + return nearestIslandEnemy; + } + } + + return null; + } + + private hasReserveRatioTroops(): boolean { + const maxTroops = this.game.config().maxTroops(this.player); + const ratio = this.player.troops() / maxTroops; + return ratio >= this.reserveRatio; + } + + private hasTriggerRatioTroops(): boolean { + const maxTroops = this.game.config().maxTroops(this.player); + const ratio = this.player.troops() / maxTroops; + return ratio >= this.triggerRatio; + } + + private findIncomingAttackPlayer(): Player | null { + // Ignore bot attacks if we are not a bot. + let incomingAttacks = this.player.incomingAttacks(); + if (this.player.type() !== PlayerType.Bot) { + incomingAttacks = incomingAttacks.filter( + (attack) => attack.attacker().type() !== PlayerType.Bot, + ); + } + let largestAttack = 0; + let largestAttacker: Player | undefined; + for (const attack of incomingAttacks) { + if (attack.troops() <= largestAttack) continue; + largestAttack = attack.troops(); + largestAttacker = attack.attacker(); + } + if (largestAttacker !== undefined) { + return largestAttacker; + } + return null; + } + // Sort neighboring bots by density (troops / tiles) and attempt to attack many of them (Parallel attacks) // sendAttack will do nothing if we don't have enough reserve troops left attackBots(): boolean { @@ -242,20 +234,12 @@ export class AiAttackBehavior { } } - // 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 maybeBetrayAndAttack(borderingFriends: Player[]): boolean { + if (this.allianceBehavior === undefined) throw new Error("not initialized"); + if (borderingFriends.length > 0) { for (const friend of borderingFriends) { - if ( - this.player.isAlliedWith(friend) && - this.player.troops() >= friend.troops() * 10 - ) { - this.betray(friend); + if (this.allianceBehavior.maybeBetray(friend)) { this.sendAttack(friend, true); return true; } @@ -264,45 +248,19 @@ export class AiAttackBehavior { return false; } - // TODO: Nuke the crown if it's far enough ahead of everybody else (based on difficulty) - findBestNukeTarget(borderingEnemies: Player[]): Player | null { - // Retaliate against incoming attacks (Most important!) - const incomingAttackPlayer = this.findIncomingAttackPlayer(); - if (incomingAttackPlayer) { - return incomingAttackPlayer; - } - - // Find the most hated player with hostile relation - const mostHated = this.player.allRelationsSorted()[0]; - if ( - mostHated !== undefined && - mostHated.relation === Relation.Hostile && - this.player.isFriendly(mostHated.player) === false - ) { - return mostHated.player; - } - - // Find the weakest player - if (borderingEnemies.length > 0) { - return borderingEnemies[0]; - } - - // If we don't have bordering enemies, find someone on an island next to us - if (borderingEnemies.length === 0) { - const nearestIslandEnemy = this.findNearestIslandEnemy(); - if (nearestIslandEnemy) { - return nearestIslandEnemy; + isBorderingNukedTerritory(): boolean { + for (const tile of this.player.borderTiles()) { + for (const neighbor of this.game.neighbors(tile)) { + if ( + this.game.isLand(neighbor) && + !this.game.hasOwner(neighbor) && + this.game.hasFallout(neighbor) + ) { + return true; + } } } - - return null; - } - - getPlayerCenter(player: Player) { - if (player.largestClusterBoundingBox) { - return boundingBoxCenter(player.largestClusterBoundingBox); - } - return calculateBoundingBoxCenter(this.game, player.borderTiles()); + return false; } findNearestIslandEnemy(): Player | null { @@ -357,6 +315,13 @@ export class AiAttackBehavior { return null; } + getPlayerCenter(player: Player) { + if (player.largestClusterBoundingBox) { + return boundingBoxCenter(player.largestClusterBoundingBox); + } + return calculateBoundingBoxCenter(this.game, player.borderTiles()); + } + attackRandomTarget() { // Save up troops until we reach the trigger ratio if (!this.hasTriggerRatioTroops()) return; @@ -396,19 +361,14 @@ export class AiAttackBehavior { } } - isBorderingNukedTerritory(): boolean { - for (const tile of this.player.borderTiles()) { - for (const neighbor of this.game.neighbors(tile)) { - if ( - this.game.isLand(neighbor) && - !this.game.hasOwner(neighbor) && - this.game.hasFallout(neighbor) - ) { - return true; - } - } - } - return false; + getNeighborTraitorToAttack(): Player | null { + const traitors = this.player + .neighbors() + .filter( + (n): n is Player => + n.isPlayer() && this.player.isFriendly(n) === false && n.isTraitor(), + ); + return traitors.length > 0 ? this.random.randElement(traitors) : null; } forceSendAttack(target: Player | TerraNullius) { @@ -431,6 +391,27 @@ export class AiAttackBehavior { } } + // Prevent attacking of humans on lower difficulties + private shouldAttack(other: Player | TerraNullius): boolean { + // Always attack Terra Nullius, non-humans and traitors + if ( + other.isPlayer() === false || + other.type() !== PlayerType.Human || + other.isTraitor() + ) { + return true; + } + + const { difficulty } = this.game.config().gameConfig(); + if (difficulty === Difficulty.Easy && this.random.chance(2)) { + return false; + } + if (difficulty === Difficulty.Medium && this.random.chance(4)) { + return false; + } + return true; + } + sendLandAttack(target: Player | TerraNullius) { const maxTroops = this.game.config().maxTroops(this.player); const reserveRatio = target.isPlayer() @@ -464,26 +445,27 @@ export class AiAttackBehavior { ), ); - if (target.isPlayer()) { - this.maybeSendEmoji(target); + if (target.isPlayer() && this.player.type() === PlayerType.Nation) { + if (this.emojiBehavior === undefined) throw new Error("not initialized"); + this.emojiBehavior.maybeSendHeckleEmoji(target); } } - sendBoatAttack(other: Player) { + sendBoatAttack(target: Player) { const closest = closestTwoTiles( this.game, Array.from(this.player.borderTiles()).filter((t) => this.game.isOceanShore(t), ), - Array.from(other.borderTiles()).filter((t) => this.game.isOceanShore(t)), + Array.from(target.borderTiles()).filter((t) => this.game.isOceanShore(t)), ); if (closest === null) { return; } let troops; - if (other.type() === PlayerType.Bot) { - troops = this.calculateBotAttackTroops(other, this.player.troops() / 5); + if (target.type() === PlayerType.Bot) { + troops = this.calculateBotAttackTroops(target, this.player.troops() / 5); } else { troops = this.player.troops() / 5; } @@ -495,14 +477,17 @@ export class AiAttackBehavior { this.game.addExecution( new TransportShipExecution( this.player, - other.id(), + target.id(), closest.y, troops, null, ), ); - this.maybeSendEmoji(other); + if (target.isPlayer() && this.player.type() === PlayerType.Nation) { + if (this.emojiBehavior === undefined) throw new Error("not initialized"); + this.emojiBehavior.maybeSendHeckleEmoji(target); + } } calculateBotAttackTroops(target: Player, maxTroops: number): number { @@ -525,19 +510,4 @@ export class AiAttackBehavior { this.botAttackTroopsSent += troops; return troops; } - - maybeSendEmoji(enemy: Player) { - if (this.player.type() === PlayerType.Bot) return; - if (enemy.type() !== PlayerType.Human) return; - const lastSent = this.lastEmojiSent.get(enemy) ?? -300; - if (this.game.ticks() - lastSent <= 300) return; - this.lastEmojiSent.set(enemy, this.game.ticks()); - this.game.addExecution( - new EmojiExecution( - this.player, - enemy.id(), - this.random.randElement(EMOJI_HECKLE), - ), - ); - } }