Revamp nation/bot enemy selection 🗡️ (#2550)

## Description:

I closed my previous PR #2533 which was already reviewed by evan (but
not yet merged) because I noticed some issues.
Which led me to changing the enemy selection entirely. 

Nations / Bots previously had a fixed enemy which they kept for 100
ticks (10 seconds). This could make them react too late and feel slow.
Now they are a bit more responsive.

But the main benefit: Without a fixed enemy we can do multiple
sendAttack() on the same tick, which allowed me to give impossible
nations extremely efficient parallel bot attacks:


https://github.com/user-attachments/assets/38f65623-fbf0-4e98-a833-5fcba2ee6eee

Previously nations were so slow in taking out bots that you could even
encircle them on the Archiran map...
Now they are like 200% faster (but only on the impossible difficulty)

## Nuke enemy selection

Previously, the enemy for troop attacks and nukes was identical. Now, as
we no longer have a fixed enemy in BotBehaviour, I added
findBestNukeTarget() to select better nuke-targets. I will probably open
a PR soon which makes nations nuke the crown :)

## Betrayal logic

While revamping the attack logic I had to work on the betrayal logic,
which was quite confusing, with many negations. And the betrayals were
just random.
So I made it easier to understand with maybeBetrayAndAttack().
Now it does betray friends if we have 10 times more troops than them. I
will improve that method in a future PR, but already now it should be
better than just betraying randomly.

## Attack order

Previously, nations attacked in this order:

- TerraNullius (Untaken land and nuked territory)
- Bots
- Retaliate against incoming attacks

Now its in this order:

- TerraNullius (Untaken land)
- Retaliate against incoming attacks
- Bots
- TerraNullius (Nuked territory)

So the changes are these:

- After throwing a nuke onto a nation, they will no longer ignore
incoming attacks. Previously they attacked the nuked territory first.
Very common singleplayer problem.
- Nations now retaliate against incoming attacks before attacking bots.
Previously you could attack a nation but they did not care because there
were still bots left.

I also changed the attack order of bots a bit (retaliate before
attacking randoms), but that isn't even noticeable.

## Big bug fixed

Additionally, I fixed a big bug: selectEnemy() oftentimes returned null
(because of enemySanityCheck) and therefore no attack happened.
This was especially visible in games where nations are surrounded by
friends (Team games and nations vs humans).
This was also the reason why Enzo could play nations vs humans in
singleplayer and NO NATION of the much bigger nation team would try to
attack him.

## 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
2025-12-11 22:57:15 +01:00
committed by GitHub
parent 1f8adb4849
commit 427e462fe5
3 changed files with 361 additions and 236 deletions
+1 -5
View File
@@ -89,11 +89,7 @@ export class BotExecution implements Execution {
this.neighborsTerraNullius = false;
}
this.behavior.forgetOldEnemies();
const enemy = this.behavior.selectRandomEnemy();
if (!enemy) return;
if (!this.bot.sharesBorderWith(enemy)) return;
this.behavior.sendAttack(enemy);
this.behavior.attackRandomTarget();
}
isActive(): boolean {
+31 -68
View File
@@ -20,14 +20,13 @@ import { GameID } from "../Schemas";
import { boundingBoxTiles, calculateBoundingBox, simpleHash } from "../Util";
import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
import { ConstructionExecution } from "./ConstructionExecution";
import { EmojiExecution } from "./EmojiExecution";
import { MirvExecution } from "./MIRVExecution";
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
import { NukeExecution } from "./NukeExecution";
import { SpawnExecution } from "./SpawnExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { calculateTerritoryCenter, closestTwoTiles } from "./Util";
import { BotBehavior, EMOJI_HECKLE } from "./utils/BotBehavior";
import { BotBehavior } from "./utils/BotBehavior";
export class FakeHumanExecution implements Execution {
private active = true;
@@ -42,7 +41,6 @@ export class FakeHumanExecution implements Execution {
private reserveRatio: number;
private expandRatio: number;
private readonly lastEmojiSent = new Map<Player, Tick>();
private readonly lastNukeSent: [Tick, TileRef][] = [];
private readonly lastMIRVSent: [Tick, TileRef][] = [];
private readonly embargoMalusApplied = new Set<PlayerID>();
@@ -207,22 +205,35 @@ export class FakeHumanExecution implements Execution {
throw new Error("not initialized");
}
const enemyborder = Array.from(this.player.borderTiles())
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 borderPlayers = enemyborder.map((t) =>
this.mg.playerBySmallID(this.mg.ownerID(t)),
);
const borderingEnemies = borderPlayers
const borderingPlayers = border
.map((t) => this.mg.playerBySmallID(this.mg.ownerID(t)))
.filter((o) => 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,
);
if (enemyborder.length === 0) {
// Attack TerraNullius but not nuked territory
const hasNonNukedTerraNullius = border.some(
(t) => !this.mg.hasOwner(t) && !this.mg.hasFallout(t),
);
if (hasNonNukedTerraNullius) {
this.behavior.sendAttack(this.mg.terraNullius());
return;
}
if (borderingEnemies.length === 0) {
if (this.random.chance(5)) {
this.sendBoatRandomly(borderingEnemies);
this.sendBoatRandomly();
}
} else {
if (this.random.chance(10)) {
@@ -230,11 +241,6 @@ export class FakeHumanExecution implements Execution {
return;
}
if (borderPlayers.some((o) => !o.isPlayer())) {
this.behavior.sendAttack(this.mg.terraNullius());
return;
}
// 5% chance to send a random alliance request
if (this.random.chance(20)) {
const toAlly = this.random.randElement(borderingEnemies);
@@ -246,41 +252,20 @@ export class FakeHumanExecution implements Execution {
}
}
this.behavior.forgetOldEnemies();
this.behavior.assistAllies();
const enemy = this.behavior.selectEnemy(borderingEnemies);
if (!enemy) return;
this.maybeSendEmoji(enemy);
this.maybeSendNuke(enemy);
if (this.player.sharesBorderWith(enemy)) {
this.behavior.sendAttack(enemy);
} else {
this.maybeSendBoatAttack(enemy);
}
this.behavior.attackBestTarget(borderingFriends, borderingEnemies);
this.maybeSendNuke(this.behavior.findBestNukeTarget(borderingEnemies));
}
private maybeSendEmoji(enemy: Player) {
if (this.player === null) throw new Error("not initialized");
if (enemy.type() !== PlayerType.Human) return;
const lastSent = this.lastEmojiSent.get(enemy) ?? -300;
if (this.mg.ticks() - lastSent <= 300) return;
this.lastEmojiSent.set(enemy, this.mg.ticks());
this.mg.addExecution(
new EmojiExecution(
this.player,
enemy.id(),
this.random.randElement(EMOJI_HECKLE),
),
);
}
private maybeSendNuke(other: Player) {
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 fakehumans and humans)
this.player.isOnSameTeam(other)
) {
@@ -326,7 +311,7 @@ export class FakeHumanExecution implements Execution {
}
}
if (bestTile !== null) {
this.sendNuke(bestTile, nukeType);
this.sendNuke(bestTile, nukeType, other);
}
}
@@ -344,11 +329,13 @@ export class FakeHumanExecution implements Execution {
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.behavior?.maybeSendEmoji(targetPlayer);
}
private nukeTileScore(tile: TileRef, silos: Unit[], targets: Unit[]): number {
@@ -399,30 +386,6 @@ export class FakeHumanExecution implements Execution {
return tileValue;
}
private maybeSendBoatAttack(other: Player) {
if (this.player === null) throw new Error("not initialized");
if (this.player.isFriendly(other)) return;
const closest = closestTwoTiles(
this.mg,
Array.from(this.player.borderTiles()).filter((t) =>
this.mg.isOceanShore(t),
),
Array.from(other.borderTiles()).filter((t) => this.mg.isOceanShore(t)),
);
if (closest === null) {
return;
}
this.mg.addExecution(
new TransportShipExecution(
this.player,
other.id(),
closest.y,
this.player.troops() / 5,
null,
),
);
}
private handleUnits() {
return (
this.maybeSpawnStructure(UnitType.City, (num) => num) ||
@@ -597,7 +560,7 @@ export class FakeHumanExecution implements Execution {
return this.mg.unitInfo(type).cost(this.player);
}
sendBoatRandomly(borderingEnemies: Player[]) {
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),
@@ -884,7 +847,7 @@ export class FakeHumanExecution implements Execution {
private maybeSendMIRV(enemy: Player): void {
if (this.player === null) throw new Error("not initialized");
this.maybeSendEmoji(enemy);
this.behavior?.maybeSendEmoji(enemy);
const centerTile = this.calculateTerritoryCenter(enemy);
if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) {
+329 -163
View File
@@ -17,6 +17,8 @@ import {
import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecution";
import { AttackExecution } from "../AttackExecution";
import { EmojiExecution } from "../EmojiExecution";
import { TransportShipExecution } from "../TransportShipExecution";
import { closestTwoTiles } from "../Util";
const emojiId = (e: (typeof flattenedEmojiTable)[number]) =>
flattenedEmojiTable.indexOf(e);
@@ -24,11 +26,11 @@ const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(emoji
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);
export const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId);
const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId);
export class BotBehavior {
private enemy: Player | null = null;
private enemyUpdated: Tick | undefined;
private botAttackTroopsSent: number = 0;
private readonly lastEmojiSent = new Map<Player, Tick>();
constructor(
private random: PseudoRandom,
@@ -76,71 +78,31 @@ export class BotBehavior {
this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji));
}
private setNewEnemy(newEnemy: Player | null, force = false) {
if (newEnemy !== null && !force && !this.shouldAttack(newEnemy)) return;
this.enemy = newEnemy;
this.enemyUpdated = this.game.ticks();
}
private shouldAttack(other: Player): boolean {
if (this.player === null) throw new Error("not initialized");
if (this.player.isOnSameTeam(other)) {
return false;
}
const shouldAttack = this.attackChance(other);
if (shouldAttack && this.player.isAlliedWith(other)) {
this.betray(other);
// 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;
}
return shouldAttack;
}
private betray(target: Player): void {
if (this.player === null) throw new Error("not initialized");
const alliance = this.player.allianceWith(target);
if (!alliance) return;
this.player.breakAlliance(alliance);
}
private attackChance(other: Player): boolean {
if (this.player === null) throw new Error("not initialized");
if (this.player.isAlliedWith(other)) {
return this.shouldDiscourageAttack(other)
? this.random.chance(200)
: this.random.chance(50);
} else {
return this.shouldDiscourageAttack(other) ? this.random.chance(4) : true;
}
}
private shouldDiscourageAttack(other: Player) {
if (other.isTraitor()) {
return false;
}
const { difficulty } = this.game.config().gameConfig();
if (
difficulty === Difficulty.Hard ||
difficulty === Difficulty.Impossible
) {
if (difficulty === Difficulty.Easy && this.random.chance(4)) {
return false;
}
if (other.type() !== PlayerType.Human) {
if (difficulty === Difficulty.Medium && this.random.chance(2)) {
return false;
}
// Only discourage attacks on Humans who are not traitors on easy or medium difficulty.
return true;
}
private clearEnemy() {
this.enemy = null;
}
forgetOldEnemies() {
// Forget old enemies
if (this.game.ticks() - (this.enemyUpdated ?? 0) > 100) {
this.clearEnemy();
}
private betray(target: Player): void {
const alliance = this.player.allianceWith(target);
if (!alliance) return;
this.player.breakAlliance(alliance);
}
private hasReserveRatioTroops(): boolean {
@@ -155,9 +117,14 @@ export class BotBehavior {
return ratio >= this.triggerRatio;
}
private checkIncomingAttacks() {
// Switch enemies if we're under attack
const incomingAttacks = this.player.incomingAttacks();
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) {
@@ -166,14 +133,18 @@ export class BotBehavior {
largestAttacker = attack.attacker();
}
if (largestAttacker !== undefined) {
this.setNewEnemy(largestAttacker, true);
return largestAttacker;
}
return null;
}
getNeighborTraitorToAttack(): Player | null {
const traitors = this.player
.neighbors()
.filter((n): n is Player => n.isPlayer() && n.isTraitor());
.filter(
(n): n is Player =>
n.isPlayer() && this.player.isFriendly(n) === false && n.isTraitor(),
);
return traitors.length > 0 ? this.random.randElement(traitors) : null;
}
@@ -189,87 +160,172 @@ export class BotBehavior {
this.emoji(ally, this.random.randElement(EMOJI_TARGET_ME));
continue;
}
if (this.player.isAlliedWith(target)) {
if (this.player.isFriendly(target)) {
this.emoji(ally, this.random.randElement(EMOJI_TARGET_ALLY));
continue;
}
// All checks passed, assist them
this.player.updateRelation(ally, -20);
this.setNewEnemy(target);
this.sendAttack(target);
this.emoji(ally, this.random.randElement(EMOJI_ASSIST_ACCEPT));
return;
}
}
}
selectEnemy(borderingEnemies: Player[]): Player | null {
if (this.enemy === null) {
// Save up troops until we reach the reserve ratio
if (!this.hasReserveRatioTroops()) return null;
attackBestTarget(borderingFriends: Player[], borderingEnemies: Player[]) {
// Save up troops until we reach the reserve ratio
if (!this.hasReserveRatioTroops()) return;
// Maybe save up troops until we reach the trigger ratio
if (!this.hasTriggerRatioTroops() && !this.random.chance(10)) return null;
// Maybe save up troops until we reach the trigger ratio
if (!this.hasTriggerRatioTroops() && !this.random.chance(10)) return;
// Prefer neighboring bots
const bots = this.player
.neighbors()
.filter(
(n): n is Player => n.isPlayer() && n.type() === PlayerType.Bot,
);
if (bots.length > 0) {
const density = (p: Player) => p.troops() / p.numTilesOwned();
let lowestDensityBot: Player | undefined;
let lowestDensity = Infinity;
// Retaliate against incoming attacks (Most important!)
const incomingAttackPlayer = this.findIncomingAttackPlayer();
if (incomingAttackPlayer) {
this.sendAttack(incomingAttackPlayer, true);
return;
}
for (const bot of bots) {
const currentDensity = density(bot);
if (currentDensity < lowestDensity) {
lowestDensity = currentDensity;
lowestDensityBot = bot;
}
}
// Attack bots
if (this.attackBots()) return;
if (lowestDensityBot !== undefined) {
this.setNewEnemy(lowestDensityBot);
}
// Maybe betray and attack
if (this.maybeBetrayAndAttack(borderingFriends)) return;
// Attack nuked territory
if (this.isBorderingNukedTerritory()) {
this.sendAttack(this.game.terraNullius());
return;
}
// Attack 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
) {
this.sendAttack(mostHated.player);
return;
}
// Attack the weakest player
if (borderingEnemies.length > 0) {
this.sendAttack(borderingEnemies[0]);
return;
}
// If we don't have bordering enemies, attack someone on an island next to us
if (borderingEnemies.length === 0) {
const nearestIslandEnemy = this.findNearestIslandEnemy();
if (nearestIslandEnemy) {
this.sendAttack(nearestIslandEnemy);
return;
}
}
}
// Retaliate against incoming attacks
if (this.enemy === null) {
// Only after clearing bots
this.checkIncomingAttacks();
}
// 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 {
const bots = this.player
.neighbors()
.filter(
(n): n is Player =>
n.isPlayer() &&
this.player.isFriendly(n) === false &&
n.type() === PlayerType.Bot,
);
// Select the most hated player
if (this.enemy === null && this.random.chance(2)) {
// 50% chance
const mostHated = this.player.allRelationsSorted()[0];
if (bots.length === 0) {
return false;
}
this.botAttackTroopsSent = 0;
const density = (p: Player) => p.troops() / p.numTilesOwned();
const sortedBots = bots.slice().sort((a, b) => density(a) - density(b));
const reducedBots = sortedBots.slice(0, this.getBotAttackMaxParallelism());
for (const bot of reducedBots) {
this.sendAttack(bot);
}
// Only short-circuit the rest of the targeting pipeline if we actually
// allocated some troops to bot attacks.
return this.botAttackTroopsSent > 0;
}
getBotAttackMaxParallelism(): number {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return 1;
case Difficulty.Medium:
return 2;
case Difficulty.Hard:
return 4;
// On impossible difficulty, attack as much bots as possible in parallel
default:
return 100;
}
}
// 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 (borderingFriends.length > 0) {
for (const friend of borderingFriends) {
if (
mostHated !== undefined &&
mostHated.relation === Relation.Hostile
this.player.isAlliedWith(friend) &&
this.player.troops() >= friend.troops() * 10
) {
this.setNewEnemy(mostHated.player);
this.betray(friend);
this.sendAttack(friend, true);
return true;
}
}
}
return false;
}
// Select the weakest player
if (this.enemy === null && borderingEnemies.length > 0) {
this.setNewEnemy(borderingEnemies[0]);
}
// 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;
}
// Select a random player
if (this.enemy === null && borderingEnemies.length > 0) {
this.setNewEnemy(this.random.randElement(borderingEnemies));
}
// 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;
}
// If we don't have bordering enemies, we are on an island. Attack someone on an island next to us
if (this.enemy === null && borderingEnemies.length === 0) {
this.selectNearestIslandEnemy();
// 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;
}
}
// Sanity check, don't attack our allies or teammates
return this.enemySanityCheck();
return null;
}
getPlayerCenter(player: Player) {
@@ -279,9 +335,9 @@ export class BotBehavior {
return calculateBoundingBoxCenter(this.game, player.borderTiles());
}
selectNearestIslandEnemy() {
findNearestIslandEnemy(): Player | null {
const myBorder = this.player.borderTiles();
if (myBorder.size === 0) return;
if (myBorder.size === 0) return null;
const filteredPlayers = this.game.players().filter((p) => {
if (p === this.player) return false;
@@ -325,54 +381,64 @@ export class BotBehavior {
}
if (selectedEnemy !== null) {
this.setNewEnemy(selectedEnemy);
return selectedEnemy;
}
}
return null;
}
attackRandomTarget() {
// Save up troops until we reach the trigger ratio
if (!this.hasTriggerRatioTroops()) return;
// Retaliate against incoming attacks
const incomingAttackPlayer = this.findIncomingAttackPlayer();
if (incomingAttackPlayer) {
this.sendAttack(incomingAttackPlayer, true);
return;
}
// Select a traitor as an enemy
const toAttack = this.getNeighborTraitorToAttack();
if (toAttack !== null) {
if (this.random.chance(3)) {
this.sendAttack(toAttack);
return;
}
}
// Choose a new enemy randomly
const { difficulty } = this.game.config().gameConfig();
const neighbors = this.player.neighbors();
for (const neighbor of this.random.shuffleArray(neighbors)) {
if (!neighbor.isPlayer()) continue;
if (this.player.isFriendly(neighbor)) continue;
if (
neighbor.type() === PlayerType.FakeHuman ||
neighbor.type() === PlayerType.Human
) {
if (this.random.chance(2) || difficulty === Difficulty.Easy) {
continue;
}
}
this.sendAttack(neighbor);
return;
}
}
selectRandomEnemy(): Player | TerraNullius | null {
if (this.enemy === null) {
// Save up troops until we reach the trigger ratio
if (!this.hasTriggerRatioTroops()) return null;
// Choose a new enemy randomly
const neighbors = this.player.neighbors();
for (const neighbor of this.random.shuffleArray(neighbors)) {
if (!neighbor.isPlayer()) continue;
if (this.player.isFriendly(neighbor)) continue;
if (neighbor.type() === PlayerType.FakeHuman) {
if (this.random.chance(2)) {
continue;
}
}
this.setNewEnemy(neighbor);
}
// Retaliate against incoming attacks
if (this.enemy === null) {
this.checkIncomingAttacks();
}
// Select a traitor as an enemy
if (this.enemy === null) {
const toAttack = this.getNeighborTraitorToAttack();
if (toAttack !== null) {
if (!this.player.isFriendly(toAttack) && this.random.chance(3)) {
this.setNewEnemy(toAttack);
}
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;
}
}
}
// Sanity check, don't attack our allies or teammates
return this.enemySanityCheck();
}
private enemySanityCheck(): Player | null {
if (this.enemy && this.player.isFriendly(this.enemy)) {
this.clearEnemy();
}
return this.enemy;
return false;
}
forceSendAttack(target: Player | TerraNullius) {
@@ -385,17 +451,41 @@ export class BotBehavior {
);
}
sendAttack(target: Player | TerraNullius) {
// Skip attacking friendly targets (allies or teammates) - decision to break alliances should be made by caller
if (target.isPlayer() && this.player.isFriendly(target)) return;
sendAttack(target: Player | TerraNullius, force = false) {
if (!force && !this.shouldAttack(target)) return;
if (this.player.sharesBorderWith(target)) {
this.sendLandAttack(target);
} else if (target.isPlayer()) {
this.sendBoatAttack(target);
}
}
sendLandAttack(target: Player | TerraNullius) {
const maxTroops = this.game.config().maxTroops(this.player);
const reserveRatio = target.isPlayer()
? this.reserveRatio
: this.expandRatio;
const targetTroops = maxTroops * reserveRatio;
const troops = this.player.troops() - targetTroops;
if (troops < 1) return;
let troops;
if (
target.isPlayer() &&
target.type() === PlayerType.Bot &&
this.player.type() !== PlayerType.Bot
) {
troops = this.calculateBotAttackTroops(
target,
this.player.troops() - targetTroops - this.botAttackTroopsSent,
);
} else {
troops = this.player.troops() - targetTroops;
}
if (troops < 1) {
return;
}
this.game.addExecution(
new AttackExecution(
troops,
@@ -403,6 +493,82 @@ export class BotBehavior {
target.isPlayer() ? target.id() : this.game.terraNullius().id(),
),
);
if (target.isPlayer()) {
this.maybeSendEmoji(target);
}
}
sendBoatAttack(other: 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)),
);
if (closest === null) {
return;
}
let troops;
if (other.type() === PlayerType.Bot) {
troops = this.calculateBotAttackTroops(other, this.player.troops() / 5);
} else {
troops = this.player.troops() / 5;
}
if (troops < 1) {
return;
}
this.game.addExecution(
new TransportShipExecution(
this.player,
other.id(),
closest.y,
troops,
null,
),
);
this.maybeSendEmoji(other);
}
calculateBotAttackTroops(target: Player, maxTroops: number): number {
const { difficulty } = this.game.config().gameConfig();
if (difficulty === Difficulty.Easy) {
this.botAttackTroopsSent += maxTroops;
return maxTroops;
}
let troops = target.troops() * 4;
// Don't send more troops than maxTroops (Keep reserve)
if (troops > maxTroops) {
// If we haven't enough troops left to do a big enough bot attack, skip it
if (maxTroops < target.troops() * 2) {
troops = 0;
} else {
troops = maxTroops;
}
}
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),
),
);
}
}