mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:40:46 +00:00
6afaf932a5
## 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
555 lines
16 KiB
TypeScript
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;
|
|
}
|
|
}
|