mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:50:42 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user