mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-09 10:15:57 +00:00
Cleanup nations (Part 2) 🧹 (#2647)
## Description: 1. Moved the currently very small betrayal logic from `AiAttackBehavior` to `NationAllianceBehavior` because it makes more sense to have it there. 3. Very small bugfix in `AiAttackBehavior::shouldAttack()`: the numbers in the two `random.chance` calls were the wrong way round. 4. `NationExecution` was quite big and a lot of it was about MIRVs. So I moved all the MIRV logic to the new `NationMIRVBehavior`. 5. `emoji()` and `maybeSendEmoji()` did not really fit in `AiAttackBehavior`. So I moved it to the new `NationEmojiBehavior` (and did some renaming for clarity). I'm planning to extend that class in a future PR. 2. Reordered methods in `AiAttackBehavior` to easily find related methods. 6. Reordered methods in `NationExecution` to easily find related methods. ## 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:
File diff suppressed because it is too large
Load Diff
@@ -243,4 +243,27 @@ export class NationAllianceBehavior {
|
||||
assertNever(difficulty);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
maybeBetray(otherPlayer: Player): boolean {
|
||||
if (
|
||||
this.player.isAlliedWith(otherPlayer) &&
|
||||
this.player.troops() >= otherPlayer.troops() * 10
|
||||
) {
|
||||
this.betray(otherPlayer);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private betray(target: Player): void {
|
||||
const alliance = this.player.allianceWith(target);
|
||||
if (!alliance) return;
|
||||
this.player.breakAlliance(alliance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Game, Player, PlayerType, Tick } from "../../game/Game";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { flattenedEmojiTable } from "../../Util";
|
||||
import { EmojiExecution } from "../EmojiExecution";
|
||||
|
||||
const emojiId = (e: (typeof flattenedEmojiTable)[number]) =>
|
||||
flattenedEmojiTable.indexOf(e);
|
||||
export const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(
|
||||
emojiId,
|
||||
);
|
||||
export const EMOJI_ASSIST_RELATION_TOO_LOW = (["🥱", "🤦♂️"] as const).map(
|
||||
emojiId,
|
||||
);
|
||||
export const EMOJI_ASSIST_TARGET_ME = (["🥺", "💀"] as const).map(emojiId);
|
||||
export const EMOJI_ASSIST_TARGET_ALLY = (["🕊️", "👎"] as const).map(emojiId);
|
||||
export const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId);
|
||||
|
||||
export class NationEmojiBehavior {
|
||||
private readonly lastEmojiSent = new Map<Player, Tick>();
|
||||
|
||||
constructor(
|
||||
private random: PseudoRandom,
|
||||
private game: Game,
|
||||
private player: Player,
|
||||
) {}
|
||||
|
||||
sendEmoji(player: Player, emojisList: number[]) {
|
||||
if (player.type() !== PlayerType.Human) return;
|
||||
this.game.addExecution(
|
||||
new EmojiExecution(
|
||||
this.player,
|
||||
player.id(),
|
||||
this.random.randElement(emojisList),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
maybeSendHeckleEmoji(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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
Game,
|
||||
Gold,
|
||||
Player,
|
||||
PlayerType,
|
||||
Tick,
|
||||
UnitType,
|
||||
} from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { MirvExecution } from "../MIRVExecution";
|
||||
import { calculateTerritoryCenter } from "../Util";
|
||||
import { NationEmojiBehavior } from "./NationEmojiBehavior";
|
||||
|
||||
export class NationMIRVBehavior {
|
||||
private readonly lastMIRVSent: [Tick, TileRef][] = [];
|
||||
|
||||
/** Ticks until MIRV can be attempted again */
|
||||
private static readonly MIRV_COOLDOWN_TICKS = 20;
|
||||
|
||||
/** Odds of aborting a MIRV attempt */
|
||||
private static readonly MIRV_HESITATION_ODDS = 7;
|
||||
|
||||
/** Threshold for team victory denial */
|
||||
private static readonly VICTORY_DENIAL_TEAM_THRESHOLD = 0.8;
|
||||
|
||||
/** Threshold for individual victory denial */
|
||||
private static readonly VICTORY_DENIAL_INDIVIDUAL_THRESHOLD = 0.65;
|
||||
|
||||
/** Multiplier for steamroll city gap threshold */
|
||||
private static readonly STEAMROLL_CITY_GAP_MULTIPLIER = 1.3;
|
||||
|
||||
/** Minimum city count for leader to trigger steam roll detection */
|
||||
private static readonly STEAMROLL_MIN_LEADER_CITIES = 10;
|
||||
|
||||
constructor(
|
||||
private random: PseudoRandom,
|
||||
private game: Game,
|
||||
private player: Player,
|
||||
private emojiBehavior: NationEmojiBehavior,
|
||||
) {}
|
||||
|
||||
considerMIRV(): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
if (this.player.units(UnitType.MissileSilo).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (this.player.gold() < this.cost(UnitType.MIRV)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeOldMIRVEvents();
|
||||
if (this.lastMIRVSent.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.random.chance(NationMIRVBehavior.MIRV_HESITATION_ODDS)) {
|
||||
this.triggerMIRVCooldown();
|
||||
return false;
|
||||
}
|
||||
|
||||
const inboundMIRVSender = this.selectCounterMirvTarget();
|
||||
if (inboundMIRVSender) {
|
||||
this.maybeSendMIRV(inboundMIRVSender);
|
||||
return true;
|
||||
}
|
||||
|
||||
const victoryDenialTarget = this.selectVictoryDenialTarget();
|
||||
if (victoryDenialTarget) {
|
||||
this.maybeSendMIRV(victoryDenialTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
const steamrollStopTarget = this.selectSteamrollStopTarget();
|
||||
if (steamrollStopTarget) {
|
||||
this.maybeSendMIRV(steamrollStopTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// MIRV Strategy Methods
|
||||
private selectCounterMirvTarget(): Player | null {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const attackers = this.getValidMirvTargetPlayers().filter((p) =>
|
||||
this.isInboundMIRVFrom(p),
|
||||
);
|
||||
if (attackers.length === 0) return null;
|
||||
attackers.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
|
||||
return attackers[0];
|
||||
}
|
||||
|
||||
private selectVictoryDenialTarget(): Player | null {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const totalLand = this.game.numLandTiles();
|
||||
if (totalLand === 0) return null;
|
||||
let best: { p: Player; severity: number } | null = null;
|
||||
for (const p of this.getValidMirvTargetPlayers()) {
|
||||
let severity = 0;
|
||||
const team = p.team();
|
||||
if (team !== null) {
|
||||
const teamMembers = this.game
|
||||
.players()
|
||||
.filter((x) => x.team() === team && x.isPlayer());
|
||||
const teamTerritory = teamMembers
|
||||
.map((x) => x.numTilesOwned())
|
||||
.reduce((a, b) => a + b, 0);
|
||||
const teamShare = teamTerritory / totalLand;
|
||||
if (teamShare >= NationMIRVBehavior.VICTORY_DENIAL_TEAM_THRESHOLD) {
|
||||
// Only consider the largest team member as the target when team exceeds threshold
|
||||
let largestMember: Player | null = null;
|
||||
let largestTiles = -1;
|
||||
for (const member of teamMembers) {
|
||||
const tiles = member.numTilesOwned();
|
||||
if (tiles > largestTiles) {
|
||||
largestTiles = tiles;
|
||||
largestMember = member;
|
||||
}
|
||||
}
|
||||
if (largestMember === p) {
|
||||
severity = teamShare;
|
||||
} else {
|
||||
severity = 0; // Skip non-largest members
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const share = p.numTilesOwned() / totalLand;
|
||||
if (share >= NationMIRVBehavior.VICTORY_DENIAL_INDIVIDUAL_THRESHOLD)
|
||||
severity = share;
|
||||
}
|
||||
if (severity > 0) {
|
||||
if (best === null || severity > best.severity) best = { p, severity };
|
||||
}
|
||||
}
|
||||
return best ? best.p : null;
|
||||
}
|
||||
|
||||
private selectSteamrollStopTarget(): Player | null {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const validTargets = this.getValidMirvTargetPlayers();
|
||||
|
||||
if (validTargets.length === 0) return null;
|
||||
|
||||
const allPlayers = this.game
|
||||
.players()
|
||||
.filter((p) => p.isPlayer())
|
||||
.map((p) => ({ p, cityCount: this.countCities(p) }))
|
||||
.sort((a, b) => b.cityCount - a.cityCount);
|
||||
|
||||
if (allPlayers.length < 2) return null;
|
||||
|
||||
const topPlayer = allPlayers[0];
|
||||
|
||||
if (topPlayer.cityCount <= NationMIRVBehavior.STEAMROLL_MIN_LEADER_CITIES)
|
||||
return null;
|
||||
|
||||
const secondHighest = allPlayers[1].cityCount;
|
||||
|
||||
const threshold =
|
||||
secondHighest * NationMIRVBehavior.STEAMROLL_CITY_GAP_MULTIPLIER;
|
||||
|
||||
if (topPlayer.cityCount >= threshold) {
|
||||
return validTargets.some((p) => p === topPlayer.p) ? topPlayer.p : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// MIRV Helper Methods
|
||||
private mirvTargetsCache: {
|
||||
tick: number;
|
||||
players: Player[];
|
||||
} | null = null;
|
||||
|
||||
private getValidMirvTargetPlayers(): Player[] {
|
||||
const MIRV_TARGETS_CACHE_TICKS = 2 * 10; // 2 seconds
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
|
||||
if (
|
||||
this.mirvTargetsCache &&
|
||||
this.game.ticks() - this.mirvTargetsCache.tick < MIRV_TARGETS_CACHE_TICKS
|
||||
) {
|
||||
return this.mirvTargetsCache.players;
|
||||
}
|
||||
|
||||
const players = this.game.players().filter((p) => {
|
||||
return (
|
||||
p !== this.player &&
|
||||
p.isPlayer() &&
|
||||
p.type() !== PlayerType.Bot &&
|
||||
!this.player!.isOnSameTeam(p)
|
||||
);
|
||||
});
|
||||
|
||||
this.mirvTargetsCache = { tick: this.game.ticks(), players };
|
||||
return players;
|
||||
}
|
||||
|
||||
private isInboundMIRVFrom(attacker: Player): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const enemyMirvs = attacker.units(UnitType.MIRV);
|
||||
for (const mirv of enemyMirvs) {
|
||||
const dst = mirv.targetTile();
|
||||
if (!dst) continue;
|
||||
if (!this.game.hasOwner(dst)) continue;
|
||||
const owner = this.game.owner(dst);
|
||||
if (owner === this.player) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// MIRV Execution Methods
|
||||
private maybeSendMIRV(enemy: Player): void {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
|
||||
this.emojiBehavior.maybeSendHeckleEmoji(enemy);
|
||||
|
||||
const centerTile = this.calculateTerritoryCenter(enemy);
|
||||
if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) {
|
||||
this.sendMIRV(centerTile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private sendMIRV(tile: TileRef): void {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
this.triggerMIRVCooldown(tile);
|
||||
this.game.addExecution(new MirvExecution(this.player, tile));
|
||||
}
|
||||
|
||||
private triggerMIRVCooldown(tile?: TileRef): void {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
this.removeOldMIRVEvents();
|
||||
const tick = this.game.ticks();
|
||||
// Use provided tile or any tile from player's territory for cooldown tracking
|
||||
const cooldownTile =
|
||||
tile ?? Array.from(this.player.tiles())[0] ?? this.game.ref(0, 0);
|
||||
this.lastMIRVSent.push([tick, cooldownTile]);
|
||||
}
|
||||
|
||||
private removeOldMIRVEvents() {
|
||||
const maxAge = NationMIRVBehavior.MIRV_COOLDOWN_TICKS;
|
||||
const tick = this.game.ticks();
|
||||
while (
|
||||
this.lastMIRVSent.length > 0 &&
|
||||
this.lastMIRVSent[0][0] + maxAge <= tick
|
||||
) {
|
||||
this.lastMIRVSent.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private countCities(p: Player): number {
|
||||
return p.unitCount(UnitType.City);
|
||||
}
|
||||
|
||||
private calculateTerritoryCenter(target: Player): TileRef | null {
|
||||
return calculateTerritoryCenter(this.game, target);
|
||||
}
|
||||
|
||||
private cost(type: UnitType): Gold {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
return this.game.unitInfo(type).cost(this.game, this.player);
|
||||
}
|
||||
}
|
||||
@@ -5,31 +5,27 @@ import {
|
||||
PlayerType,
|
||||
Relation,
|
||||
TerraNullius,
|
||||
Tick,
|
||||
} from "../../game/Game";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import {
|
||||
assertNever,
|
||||
boundingBoxCenter,
|
||||
calculateBoundingBoxCenter,
|
||||
flattenedEmojiTable,
|
||||
} from "../../Util";
|
||||
import { AttackExecution } from "../AttackExecution";
|
||||
import { EmojiExecution } from "../EmojiExecution";
|
||||
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";
|
||||
|
||||
const emojiId = (e: (typeof flattenedEmojiTable)[number]) =>
|
||||
flattenedEmojiTable.indexOf(e);
|
||||
const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(emojiId);
|
||||
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);
|
||||
const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId);
|
||||
|
||||
export class AiAttackBehavior {
|
||||
private botAttackTroopsSent: number = 0;
|
||||
private readonly lastEmojiSent = new Map<Player, Tick>();
|
||||
|
||||
constructor(
|
||||
private random: PseudoRandom,
|
||||
@@ -38,103 +34,32 @@ export class AiAttackBehavior {
|
||||
private triggerRatio: number,
|
||||
private reserveRatio: number,
|
||||
private expandRatio: number,
|
||||
private allianceBehavior?: NationAllianceBehavior,
|
||||
private emojiBehavior?: NationEmojiBehavior,
|
||||
) {}
|
||||
|
||||
private emoji(player: Player, emoji: number) {
|
||||
if (player.type() !== PlayerType.Human) return;
|
||||
this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji));
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
if (difficulty === Difficulty.Easy && this.random.chance(4)) {
|
||||
return false;
|
||||
}
|
||||
if (difficulty === Difficulty.Medium && this.random.chance(2)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private betray(target: Player): void {
|
||||
const alliance = this.player.allianceWith(target);
|
||||
if (!alliance) return;
|
||||
this.player.breakAlliance(alliance);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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.emoji(ally, this.random.randElement(EMOJI_RELATION_TOO_LOW));
|
||||
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_RELATION_TOO_LOW);
|
||||
continue;
|
||||
}
|
||||
for (const target of ally.targets()) {
|
||||
if (target === this.player) {
|
||||
this.emoji(ally, this.random.randElement(EMOJI_TARGET_ME));
|
||||
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ME);
|
||||
continue;
|
||||
}
|
||||
if (this.player.isFriendly(target)) {
|
||||
this.emoji(ally, this.random.randElement(EMOJI_TARGET_ALLY));
|
||||
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ALLY);
|
||||
continue;
|
||||
}
|
||||
// All checks passed, assist them
|
||||
this.player.updateRelation(ally, -20);
|
||||
this.sendAttack(target);
|
||||
this.emoji(ally, this.random.randElement(EMOJI_ASSIST_ACCEPT));
|
||||
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_ACCEPT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -193,6 +118,73 @@ export class AiAttackBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
attackBots(): boolean {
|
||||
@@ -242,20 +234,12 @@ export class AiAttackBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (this.allianceBehavior === undefined) throw new Error("not initialized");
|
||||
|
||||
if (borderingFriends.length > 0) {
|
||||
for (const friend of borderingFriends) {
|
||||
if (
|
||||
this.player.isAlliedWith(friend) &&
|
||||
this.player.troops() >= friend.troops() * 10
|
||||
) {
|
||||
this.betray(friend);
|
||||
if (this.allianceBehavior.maybeBetray(friend)) {
|
||||
this.sendAttack(friend, true);
|
||||
return true;
|
||||
}
|
||||
@@ -264,45 +248,19 @@ export class AiAttackBehavior {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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;
|
||||
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 null;
|
||||
}
|
||||
|
||||
getPlayerCenter(player: Player) {
|
||||
if (player.largestClusterBoundingBox) {
|
||||
return boundingBoxCenter(player.largestClusterBoundingBox);
|
||||
}
|
||||
return calculateBoundingBoxCenter(this.game, player.borderTiles());
|
||||
return false;
|
||||
}
|
||||
|
||||
findNearestIslandEnemy(): Player | null {
|
||||
@@ -357,6 +315,13 @@ export class AiAttackBehavior {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -396,19 +361,14 @@ export class AiAttackBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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) {
|
||||
@@ -431,6 +391,27 @@ export class AiAttackBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
sendLandAttack(target: Player | TerraNullius) {
|
||||
const maxTroops = this.game.config().maxTroops(this.player);
|
||||
const reserveRatio = target.isPlayer()
|
||||
@@ -464,26 +445,27 @@ export class AiAttackBehavior {
|
||||
),
|
||||
);
|
||||
|
||||
if (target.isPlayer()) {
|
||||
this.maybeSendEmoji(target);
|
||||
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
|
||||
if (this.emojiBehavior === undefined) throw new Error("not initialized");
|
||||
this.emojiBehavior.maybeSendHeckleEmoji(target);
|
||||
}
|
||||
}
|
||||
|
||||
sendBoatAttack(other: Player) {
|
||||
sendBoatAttack(target: 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)),
|
||||
Array.from(target.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);
|
||||
if (target.type() === PlayerType.Bot) {
|
||||
troops = this.calculateBotAttackTroops(target, this.player.troops() / 5);
|
||||
} else {
|
||||
troops = this.player.troops() / 5;
|
||||
}
|
||||
@@ -495,14 +477,17 @@ export class AiAttackBehavior {
|
||||
this.game.addExecution(
|
||||
new TransportShipExecution(
|
||||
this.player,
|
||||
other.id(),
|
||||
target.id(),
|
||||
closest.y,
|
||||
troops,
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
this.maybeSendEmoji(other);
|
||||
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
|
||||
if (this.emojiBehavior === undefined) throw new Error("not initialized");
|
||||
this.emojiBehavior.maybeSendHeckleEmoji(target);
|
||||
}
|
||||
}
|
||||
|
||||
calculateBotAttackTroops(target: Player, maxTroops: number): number {
|
||||
@@ -525,19 +510,4 @@ export class AiAttackBehavior {
|
||||
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