mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 02:47:45 +00:00
5d52f73278
## Description: Previously, nations just spammed these two rather toxic emojis: 🤡😡 They now send fewer emojis while attacking, and the clown emoji is reserved for special cases. They got the ability to send emojis in much more cases: - Human didn't donate enough for relation update - Human did donate an ok amount - Human did donate a lot - Responding to emojis that they get sent from a human - Nuke sent - MIRV sent - Retaliation warship sent - Traitor tries to ally - Threat asks for / accepts an alliance request - Disliked human tries to ally - Friendly human tries to ally - They are getting attacked by very much troops - They are getting attacked by very little troops - Congratulating the winner - Bragging with their crown - Charming their allies - Clown-Emoting traitors - Easteregg: Sending a rat emoji to very small humans ## 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 --------- Co-authored-by: iamlewis <lewismmmm@gmail.com>
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 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)) {
|
|
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 (or if we are a bot)
|
|
if (
|
|
other.isPlayer() === false ||
|
|
other.type() !== PlayerType.Human ||
|
|
other.isTraitor() ||
|
|
this.player.type() === PlayerType.Bot
|
|
) {
|
|
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;
|
|
}
|
|
|
|
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
|
|
if (this.emojiBehavior === undefined) throw new Error("not initialized");
|
|
this.emojiBehavior.maybeSendAttackEmoji(target);
|
|
}
|
|
|
|
this.game.addExecution(
|
|
new AttackExecution(
|
|
troops,
|
|
this.player,
|
|
target.isPlayer() ? target.id() : this.game.terraNullius().id(),
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
|
|
if (this.emojiBehavior === undefined) throw new Error("not initialized");
|
|
this.emojiBehavior.maybeSendAttackEmoji(target);
|
|
}
|
|
|
|
this.game.addExecution(
|
|
new TransportShipExecution(
|
|
this.player,
|
|
target.id(),
|
|
closest.y,
|
|
troops,
|
|
null,
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|