Files
OpenFrontIO/src/core/execution/utils/AiAttackBehavior.ts
T
FloPinguin 6afaf932a5 Make easy and medium nations less aggressive 📊 (#2671)
## Description:

1. Players complained that they have problems allying with nations in
the earlygame. So I added an `isEarlygame()` check to
`AllianceBehavior`. This should make the easier difficulties much easier
:)

2. The attack order of nations now depends on the difficulty. Easy and
medium nations got dumbed down, they now take nuked territory before
retaliating against attacks again.

3. The attack rate now depends on the difficulty. Easy nations are
reacting slower than impossible nations (to make sure the number of sent
alliance requests stays the same I removed the difficulty check in
`maybeSendAllianceRequests()`).

4. On easy and medium difficulty nations will sometimes just skip an
attack if the enemy is a human (`shouldAttack()`). But this did not
apply for the nuking logic. Now it does, which makes the easier
difficulties a bit easier.

5. I tuned the `getBotAttackMaxParallelism()` method a bit. The nations
are doing a bit less parallel bot attacks now, which makes the easier
difficulties a bit easier.

6. The settings in MIRVBehavior now depend on the difficulty. On easy
difficulty, nations will only send MIRVs very rarely.

7. Unrelated MIRVBehavior Cleanup: There was a 2 second cooldown and
cache logic. But it was completely useless because `considerMIRV()` is
only called every 4-8 seconds by NationExecution. So I removed it.

8. Unrelated little cleanup: I made a couple of methods `private`

## 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
2025-12-24 03:10:39 +00:00

555 lines
16 KiB
TypeScript

import {
Difficulty,
Game,
Player,
PlayerType,
Relation,
TerraNullius,
} from "../../game/Game";
import { PseudoRandom } from "../../PseudoRandom";
import {
assertNever,
boundingBoxCenter,
calculateBoundingBoxCenter,
} from "../../Util";
import { AttackExecution } from "../AttackExecution";
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";
export class AiAttackBehavior {
private botAttackTroopsSent: number = 0;
constructor(
private random: PseudoRandom,
private game: Game,
private player: Player,
private triggerRatio: number,
private reserveRatio: number,
private expandRatio: number,
private allianceBehavior?: NationAllianceBehavior,
private emojiBehavior?: NationEmojiBehavior,
) {}
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.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_RELATION_TOO_LOW);
continue;
}
for (const target of ally.targets()) {
if (target === this.player) {
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ME);
continue;
}
if (this.player.isFriendly(target)) {
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ALLY);
continue;
}
// All checks passed, assist them
this.player.updateRelation(ally, -20);
this.sendAttack(target);
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_ACCEPT);
return;
}
}
}
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;
// Get attack strategies in priority order based on difficulty
const strategies = this.getAttackStrategies(
borderingFriends,
borderingEnemies,
);
for (const strategy of strategies) {
if (strategy()) return;
}
}
private getAttackStrategies(
borderingFriends: Player[],
borderingEnemies: Player[],
): Array<() => boolean> {
const { difficulty } = this.game.config().gameConfig();
// Define all strategies as functions that return true if they attacked
const retaliate = (): boolean => {
const attacker = this.findIncomingAttackPlayer();
if (attacker) {
this.sendAttack(attacker, true);
return true;
}
return false;
};
const bots = (): boolean => this.attackBots();
const betray = (): boolean => this.maybeBetrayAndAttack(borderingFriends);
const nuked = (): boolean => {
if (this.isBorderingNukedTerritory()) {
this.sendAttack(this.game.terraNullius());
return true;
}
return false;
};
const hated = (): boolean => {
const mostHated = this.player.allRelationsSorted()[0];
if (
mostHated !== undefined &&
mostHated.relation === Relation.Hostile &&
this.player.isFriendly(mostHated.player) === false
) {
this.sendAttack(mostHated.player);
return true;
}
return false;
};
const weakest = (): boolean => {
if (borderingEnemies.length > 0) {
this.sendAttack(borderingEnemies[0]);
return true;
}
return false;
};
const island = (): boolean => {
if (borderingEnemies.length === 0) {
const enemy = this.findNearestIslandEnemy();
if (enemy) {
this.sendAttack(enemy);
return true;
}
}
return false;
};
// Return strategies in order based on difficulty
// Easy nations get the dumbest order, impossible nations get the smartest order
switch (difficulty) {
case Difficulty.Easy:
return [nuked, bots, retaliate, betray, hated, weakest];
case Difficulty.Medium:
return [bots, nuked, retaliate, betray, hated, weakest, island];
case Difficulty.Hard:
return [bots, retaliate, betray, nuked, hated, weakest, island];
case Difficulty.Impossible:
return [retaliate, bots, betray, nuked, hated, weakest, island];
default:
assertNever(difficulty);
}
}
// 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
private attackBots(): boolean {
const bots = this.player
.neighbors()
.filter(
(n): n is Player =>
n.isPlayer() &&
this.player.isFriendly(n) === false &&
n.type() === PlayerType.Bot,
);
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;
}
private getBotAttackMaxParallelism(): number {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return 1;
case Difficulty.Medium:
return this.random.chance(2) ? 1 : 2;
case Difficulty.Hard:
return 3;
// On impossible difficulty, attack as much bots as possible in parallel
case Difficulty.Impossible: {
return 100;
}
default:
assertNever(difficulty);
}
}
private maybeBetrayAndAttack(borderingFriends: Player[]): boolean {
if (this.allianceBehavior === undefined) throw new Error("not initialized");
if (borderingFriends.length > 0) {
for (const friend of borderingFriends) {
if (this.allianceBehavior.maybeBetray(friend)) {
this.sendAttack(friend, true);
return true;
}
}
}
return false;
}
private 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;
}
private findNearestIslandEnemy(): Player | null {
const myBorder = this.player.borderTiles();
if (myBorder.size === 0) return null;
const filteredPlayers = this.game.players().filter((p) => {
if (p === this.player) return false;
if (!p.isAlive()) return false;
if (p.borderTiles().size === 0) return false;
if (this.player.isFriendly(p)) return false;
// Don't spam boats into players more than 2x our troops
return p.troops() <= this.player.troops() * 2;
});
if (filteredPlayers.length > 0) {
const playerCenter = this.getPlayerCenter(this.player);
const sortedPlayers = filteredPlayers
.map((filteredPlayer) => {
const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer);
const playerCenterTile = this.game.ref(
playerCenter.x,
playerCenter.y,
);
const filteredPlayerCenterTile = this.game.ref(
filteredPlayerCenter.x,
filteredPlayerCenter.y,
);
const distance = this.game.manhattanDist(
playerCenterTile,
filteredPlayerCenterTile,
);
return { player: filteredPlayer, distance };
})
.sort((a, b) => a.distance - b.distance); // Sort by distance (ascending)
// Select the nearest or second-nearest enemy (So our boat doesn't always run into the same warship, if there is one)
let selectedEnemy: Player | null;
if (sortedPlayers.length > 1 && this.random.chance(2)) {
selectedEnemy = sortedPlayers[1].player;
} else {
selectedEnemy = sortedPlayers[0].player;
}
if (selectedEnemy !== null) {
return selectedEnemy;
}
}
return null;
}
private 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;
// 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.Nation ||
neighbor.type() === PlayerType.Human
) {
if (this.random.chance(2) || difficulty === Difficulty.Easy) {
continue;
}
}
this.sendAttack(neighbor);
return;
}
}
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) {
this.game.addExecution(
new AttackExecution(
this.player.troops() / 2,
this.player,
target.isPlayer() ? target.id() : this.game.terraNullius().id(),
),
);
}
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);
}
}
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;
}
// Prevent attacking of humans on lower difficulties
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;
}
private sendLandAttack(target: Player | TerraNullius) {
const maxTroops = this.game.config().maxTroops(this.player);
const reserveRatio = target.isPlayer()
? this.reserveRatio
: this.expandRatio;
const targetTroops = maxTroops * reserveRatio;
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,
this.player,
target.isPlayer() ? target.id() : this.game.terraNullius().id(),
),
);
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
if (this.emojiBehavior === undefined) throw new Error("not initialized");
this.emojiBehavior.maybeSendHeckleEmoji(target);
}
}
private sendBoatAttack(target: Player) {
const closest = closestTwoTiles(
this.game,
Array.from(this.player.borderTiles()).filter((t) =>
this.game.isOceanShore(t),
),
Array.from(target.borderTiles()).filter((t) => this.game.isOceanShore(t)),
);
if (closest === null) {
return;
}
let troops;
if (target.type() === PlayerType.Bot) {
troops = this.calculateBotAttackTroops(target, this.player.troops() / 5);
} else {
troops = this.player.troops() / 5;
}
if (troops < 1) {
return;
}
this.game.addExecution(
new TransportShipExecution(
this.player,
target.id(),
closest.y,
troops,
null,
),
);
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
if (this.emojiBehavior === undefined) throw new Error("not initialized");
this.emojiBehavior.maybeSendHeckleEmoji(target);
}
}
private 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;
}
}