mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:20:47 +00:00
1162 lines
36 KiB
TypeScript
1162 lines
36 KiB
TypeScript
import {
|
|
Difficulty,
|
|
Game,
|
|
GameMode,
|
|
GameType,
|
|
HumansVsNations,
|
|
Player,
|
|
PlayerID,
|
|
PlayerType,
|
|
Relation,
|
|
Structures,
|
|
TerraNullius,
|
|
UnitType,
|
|
} from "../../game/Game";
|
|
import { TileRef } from "../../game/GameMap";
|
|
import { canBuildTransportShip } from "../../game/TransportShipUtils";
|
|
import { PseudoRandom } from "../../PseudoRandom";
|
|
import {
|
|
assertNever,
|
|
boundingBoxCenter,
|
|
calculateBoundingBoxCenter,
|
|
} from "../../Util";
|
|
import { AttackExecution } from "../AttackExecution";
|
|
import { DonateTroopsExecution } from "../DonateTroopExecution";
|
|
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,
|
|
) {}
|
|
|
|
maybeAttack() {
|
|
if (this.player === null || this.allianceBehavior === undefined) {
|
|
throw new Error("not initialized");
|
|
}
|
|
|
|
const border = Array.from(this.player.borderTiles())
|
|
.flatMap((t) => this.game.neighbors(t))
|
|
.filter(
|
|
(t) =>
|
|
this.game.isLand(t) &&
|
|
!this.game.isImpassable(t) &&
|
|
this.game.ownerID(t) !== this.player?.smallID(),
|
|
);
|
|
const playerNeighbors = this.player.nearby();
|
|
const borderingPlayerSet = new Set<Player>(
|
|
border
|
|
.map((t) => this.game.playerBySmallID(this.game.ownerID(t)))
|
|
.filter((o): o is Player => o.isPlayer()),
|
|
);
|
|
for (const n of playerNeighbors) {
|
|
if (n.isPlayer()) borderingPlayerSet.add(n);
|
|
}
|
|
const borderingPlayers = [...borderingPlayerSet].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,
|
|
);
|
|
|
|
// Attack TerraNullius but not nuked territory (direct border or across a river)
|
|
const hasNonNukedTerraNullius =
|
|
border.some((t) => !this.game.hasOwner(t) && !this.game.hasFallout(t)) ||
|
|
playerNeighbors.some((n) => !n.isPlayer());
|
|
if (hasNonNukedTerraNullius) {
|
|
if (this.sendAttack(this.game.terraNullius())) return;
|
|
}
|
|
|
|
if (borderingEnemies.length === 0) {
|
|
if (this.random.chance(5)) {
|
|
this.attackWithRandomBoat();
|
|
}
|
|
} else {
|
|
if (this.random.chance(10)) {
|
|
this.attackWithRandomBoat(borderingEnemies);
|
|
return;
|
|
}
|
|
|
|
this.allianceBehavior.maybeSendAllianceRequests(borderingEnemies);
|
|
}
|
|
|
|
this.attackBestTarget(borderingFriends, borderingEnemies);
|
|
}
|
|
|
|
private attackWithRandomBoat(borderingEnemies: Player[] = []) {
|
|
if (this.player === null) throw new Error("not initialized");
|
|
|
|
if (this.game.config().isUnitDisabled(UnitType.TransportShip)) {
|
|
return;
|
|
}
|
|
|
|
// Check if we've already sent out the maximum number of transport ships
|
|
if (
|
|
this.player.unitCount(UnitType.TransportShip) >=
|
|
this.game.config().boatMaxNumber()
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Check if we have any shore tiles to launch from
|
|
const shore = Array.from(this.player.borderTiles()).filter((t) =>
|
|
this.game.isShore(t),
|
|
);
|
|
if (shore.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const src = this.random.randElement(shore);
|
|
|
|
// First look for high-interest targets (unowned or bot-owned). Mainly relevant for earlygame
|
|
let dst = this.findRandomBoatTarget(src, borderingEnemies, true);
|
|
if (dst === null) {
|
|
// None found? Then look for players
|
|
dst = this.findRandomBoatTarget(src, borderingEnemies, false);
|
|
if (dst === null) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const owner = this.game.owner(dst);
|
|
const cap = owner.isPlayer() ? this.troopSendCap() : Infinity;
|
|
const troops = Math.min(this.player.troops() / 5, cap);
|
|
if (troops < 1) return;
|
|
|
|
// Hard & Impossible: don't attack if we'd send less than 20% of target's troops
|
|
if (owner.isPlayer() && this.isAttackTooWeak(troops, owner)) {
|
|
return;
|
|
}
|
|
|
|
this.game.addExecution(
|
|
new TransportShipExecution(this.player, dst, troops),
|
|
);
|
|
}
|
|
|
|
private findRandomBoatTarget(
|
|
tile: TileRef,
|
|
borderingEnemies: Player[],
|
|
highInterestOnly: boolean = false,
|
|
): TileRef | null {
|
|
if (this.player === null) throw new Error("not initialized");
|
|
const x = this.game.x(tile);
|
|
const y = this.game.y(tile);
|
|
const unreachablePlayers = new Set<PlayerID>();
|
|
for (let i = 0; i < 500; i++) {
|
|
const randX = this.random.nextInt(x - 150, x + 150);
|
|
const randY = this.random.nextInt(y - 150, y + 150);
|
|
if (!this.game.isValidCoord(randX, randY)) {
|
|
continue;
|
|
}
|
|
const randTile = this.game.ref(randX, randY);
|
|
if (!this.game.isLand(randTile)) {
|
|
continue;
|
|
}
|
|
if (this.game.isImpassable(randTile)) {
|
|
continue;
|
|
}
|
|
const owner = this.game.owner(randTile);
|
|
if (owner === this.player) {
|
|
continue;
|
|
}
|
|
// Skip players we already know are unreachable (Performance optimization)
|
|
if (owner.isPlayer() && unreachablePlayers.has(owner.id())) {
|
|
continue;
|
|
}
|
|
// Don't send boats to players with which we share a border, that usually looks stupid
|
|
if (owner.isPlayer() && borderingEnemies.includes(owner)) {
|
|
continue;
|
|
}
|
|
// Don't spam boats into players which are stronger than us (FFA only)
|
|
if (
|
|
this.isFFA() &&
|
|
owner.isPlayer() &&
|
|
owner.troops() > this.player.troops()
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
let matchesCriteria: boolean;
|
|
if (highInterestOnly) {
|
|
// High-interest targeting: prioritize unowned tiles or tiles owned by bots
|
|
matchesCriteria = !owner.isPlayer() || owner.type() === PlayerType.Bot;
|
|
} else {
|
|
// Normal targeting: return unowned tiles or tiles owned by non-friendly players
|
|
matchesCriteria = !owner.isPlayer() || !owner.isFriendly(this.player);
|
|
}
|
|
if (!matchesCriteria) {
|
|
continue;
|
|
}
|
|
|
|
// Validate that we can actually build a transport ship to this target
|
|
if (canBuildTransportShip(this.game, this.player, randTile) === false) {
|
|
if (owner.isPlayer()) {
|
|
unreachablePlayers.add(owner.id());
|
|
}
|
|
continue;
|
|
}
|
|
|
|
return randTile;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// attackBestTarget is called with borderingFriends and borderingEnemies sorted by troops (ascending)
|
|
private attackBestTarget(
|
|
borderingFriends: Player[],
|
|
borderingEnemies: Player[],
|
|
) {
|
|
// In games with high starting gold, nations will quickly build a lot of cities
|
|
// This causes them to expand slowly (cities increase max troops), and bots will steal their structures
|
|
// In this case: Attack bots before ratio checks
|
|
if (this.hasNeighboringBotWithStructures()) {
|
|
if (this.attackBots()) return;
|
|
}
|
|
|
|
// 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) {
|
|
return this.sendAttack(attacker, true);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const bots = (): boolean => this.attackBots();
|
|
|
|
const assist = (): boolean => this.assistAllies();
|
|
|
|
const traitor = (): boolean => {
|
|
const traitor = this.findTraitor(borderingEnemies);
|
|
if (traitor) {
|
|
return this.sendAttack(traitor);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const afk = (): boolean => {
|
|
// borderingEnemies is already sorted by troops (ascending), so first match is weakest afk enemy
|
|
const afk = borderingEnemies.find(
|
|
(enemy) =>
|
|
enemy.isDisconnected() &&
|
|
(!this.isFFA() || enemy.troops() < this.player.troops() * 3),
|
|
);
|
|
if (afk) {
|
|
return this.sendAttack(afk);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const betray = (): boolean =>
|
|
this.maybeBetrayAndAttack(borderingFriends, borderingEnemies);
|
|
|
|
const nuked = (): boolean => {
|
|
if (this.isBorderingNukedTerritory()) {
|
|
return this.sendAttack(this.game.terraNullius());
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const victim = (): boolean => {
|
|
const victim = this.findVictim(borderingEnemies);
|
|
if (victim) {
|
|
return this.sendAttack(victim);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const hated = (): boolean => {
|
|
for (const relation of this.player.allRelationsSorted()) {
|
|
if (relation.relation !== Relation.Hostile) continue;
|
|
const other = relation.player;
|
|
if (this.player.isFriendly(other)) continue;
|
|
if (this.isFFA() && other.troops() > this.player.troops() * 3) continue;
|
|
return this.sendAttack(other);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const veryWeak = (): boolean => {
|
|
const veryWeak = this.findVeryWeakEnemy(borderingEnemies);
|
|
if (veryWeak) {
|
|
return this.sendAttack(veryWeak);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const weakest = (): boolean => {
|
|
if (borderingEnemies.length > 0) {
|
|
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
|
|
const weakest = borderingEnemies[0];
|
|
// In FFA, don't attack if they have more troops than us
|
|
if (!this.isFFA() || weakest.troops() < this.player.troops()) {
|
|
return this.sendAttack(weakest);
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const island = (): boolean => {
|
|
if (borderingEnemies.length === 0) {
|
|
const enemy = this.findNearestIslandEnemy();
|
|
if (enemy) {
|
|
return this.sendAttack(enemy);
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const donate = (): boolean => this.donateTroops();
|
|
|
|
// Return strategies in order based on difficulty
|
|
// Easy nations get the dumbest order, impossible nations get the smartest order
|
|
switch (difficulty) {
|
|
case Difficulty.Easy:
|
|
// prettier-ignore
|
|
return [nuked, bots, retaliate, assist, betray, hated, weakest];
|
|
case Difficulty.Medium:
|
|
// prettier-ignore
|
|
return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island, donate];
|
|
case Difficulty.Hard:
|
|
// prettier-ignore
|
|
return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island, donate];
|
|
case Difficulty.Impossible:
|
|
// prettier-ignore
|
|
return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island, donate];
|
|
default:
|
|
assertNever(difficulty);
|
|
}
|
|
}
|
|
|
|
private hasNeighboringBotWithStructures(): boolean {
|
|
return this.player
|
|
.nearby()
|
|
.some(
|
|
(n) =>
|
|
n.isPlayer() &&
|
|
n.type() === PlayerType.Bot &&
|
|
!this.player.isFriendly(n) &&
|
|
n.units().some((u) => Structures.has(u.type())),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
findIncomingAttackPlayer(): Player | null {
|
|
let incomingAttacks = this.player
|
|
.incomingAttacks()
|
|
.filter((attack) => !this.player.isFriendly(attack.attacker()));
|
|
// Ignore bot attacks if we are not a bot.
|
|
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
|
|
// Bots that own structures are prioritized as targets (they might have stolen our structures and they will delete them!)
|
|
private attackBots(): boolean {
|
|
const bots = this.player
|
|
.nearby()
|
|
.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 ownsStructures = (p: Player) =>
|
|
p.units().some((u) => Structures.has(u.type()));
|
|
const sortedBots = bots.slice().sort((a, b) => {
|
|
const aHasStructures = ownsStructures(a);
|
|
const bHasStructures = ownsStructures(b);
|
|
if (aHasStructures !== bHasStructures) {
|
|
return aHasStructures ? -1 : 1;
|
|
}
|
|
return 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 assistAllies(): boolean {
|
|
if (this.emojiBehavior === undefined) throw new Error("not initialized");
|
|
|
|
if (this.game.config().disableAlliances()) return false;
|
|
|
|
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;
|
|
}
|
|
if (!this.sendAttack(target)) continue;
|
|
this.player.updateRelation(ally, -20);
|
|
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_ACCEPT);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Find a traitor who isn't significantly stronger than us
|
|
private findTraitor(borderingEnemies: Player[]): Player | null {
|
|
if (this.game.config().disableAlliances()) return null;
|
|
|
|
// borderingEnemies is already sorted by troops (ascending), so first match is weakest traitor
|
|
return (
|
|
borderingEnemies.find(
|
|
(enemy) =>
|
|
enemy.isTraitor() &&
|
|
(!this.isFFA() || enemy.troops() < this.player.troops() * 1.2),
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
private maybeBetrayAndAttack(
|
|
borderingFriends: Player[],
|
|
borderingEnemies: Player[],
|
|
): boolean {
|
|
if (this.allianceBehavior === undefined) throw new Error("not initialized");
|
|
|
|
if (this.game.config().disableAlliances()) return false;
|
|
|
|
if (borderingFriends.length > 0) {
|
|
for (const friend of borderingFriends) {
|
|
if (
|
|
this.allianceBehavior.maybeBetray(
|
|
friend,
|
|
borderingFriends.length + borderingEnemies.length,
|
|
)
|
|
) {
|
|
return this.sendAttack(friend, true);
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private isBorderingNukedTerritory(): boolean {
|
|
if (this.game.config().isUnitDisabled(UnitType.MissileSilo)) {
|
|
return false;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Find someone who isn't significantly stronger than us and is under big attack from others (50%+ of their troops incoming)
|
|
private findVictim(borderingEnemies: Player[]): Player | null {
|
|
// borderingEnemies is already sorted by troops (ascending), so first match is weakest victim
|
|
return (
|
|
borderingEnemies.find((enemy) => {
|
|
if (this.isFFA() && enemy.troops() > this.player.troops() * 1.2) {
|
|
return false;
|
|
}
|
|
|
|
const totalIncomingTroops = enemy
|
|
.incomingAttacks()
|
|
.reduce((sum, attack) => sum + attack.troops(), 0);
|
|
|
|
return totalIncomingTroops > enemy.troops() * 0.5;
|
|
}) ?? null
|
|
);
|
|
}
|
|
|
|
// Find very weak (less than 15% of their maxTroops) enemies
|
|
// which also don't have significantly more troops than us (to target MIRVed players)
|
|
private findVeryWeakEnemy(borderingEnemies: Player[]): Player | null {
|
|
const veryWeakEnemies = borderingEnemies.filter((enemy) => {
|
|
const enemyMaxTroops = this.game.config().maxTroops(enemy);
|
|
return (
|
|
enemy.troops() < enemyMaxTroops * 0.15 &&
|
|
(!this.isFFA() || enemy.troops() < this.player.troops() * 1.2)
|
|
);
|
|
});
|
|
|
|
// borderingEnemies is already sorted by troops (ascending), so first match is weakest very weak enemy
|
|
return veryWeakEnemies.length > 0 ? veryWeakEnemies[0] : null;
|
|
}
|
|
|
|
private findNearestIslandEnemy(): Player | null {
|
|
if (this.game.config().isUnitDisabled(UnitType.TransportShip)) {
|
|
return null;
|
|
}
|
|
|
|
// Check if we've already sent out the maximum number of transport ships
|
|
if (
|
|
this.player.unitCount(UnitType.TransportShip) >=
|
|
this.game.config().boatMaxNumber()
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// Check if we have any shore tiles to launch from
|
|
const hasShore = Array.from(this.player.borderTiles()).some((t) =>
|
|
this.game.isShore(t),
|
|
);
|
|
if (!hasShore) return null;
|
|
|
|
const filteredPlayers = this.game.players().filter((p) => {
|
|
if (p === this.player) return false;
|
|
if (this.player.isFriendly(p)) return false;
|
|
// In FFA, don't spam boats into players with more troops
|
|
return !this.isFFA() || p.troops() < this.player.troops();
|
|
});
|
|
|
|
if (filteredPlayers.length === 0) return null;
|
|
|
|
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)
|
|
|
|
// Try players in order of distance until we find reachable candidates
|
|
const reachablePlayers: Player[] = [];
|
|
for (const entry of sortedPlayers) {
|
|
const closest = closestTwoTiles(
|
|
this.game,
|
|
Array.from(this.player.borderTiles()).filter((t) =>
|
|
this.game.isShore(t),
|
|
),
|
|
Array.from(entry.player.borderTiles()).filter((t) =>
|
|
this.game.isShore(t),
|
|
),
|
|
);
|
|
if (closest === null) continue;
|
|
|
|
if (canBuildTransportShip(this.game, this.player, closest.y)) {
|
|
reachablePlayers.push(entry.player);
|
|
// We only need up to 2 reachable candidates
|
|
if (reachablePlayers.length >= 2) break;
|
|
}
|
|
}
|
|
|
|
if (reachablePlayers.length === 0) return null;
|
|
|
|
// 33% chance to pick the second-nearest player if available
|
|
if (reachablePlayers.length >= 2 && this.random.chance(3)) {
|
|
return reachablePlayers[1];
|
|
}
|
|
|
|
return reachablePlayers[0];
|
|
}
|
|
|
|
// In team games, nations should be willing to attack/boat into stronger
|
|
// enemies - they can rely on teammates to donate. In FFA, going after
|
|
// someone significantly stronger is usually a losing proposition.
|
|
private isFFA(): boolean {
|
|
return this.game.config().gameConfig().gameMode === GameMode.FFA;
|
|
}
|
|
|
|
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) {
|
|
if (this.sendAttack(incomingAttackPlayer, true)) return;
|
|
}
|
|
|
|
// Select a traitor as an enemy
|
|
const toAttack = this.getNeighborTraitorToAttack();
|
|
if (toAttack !== null) {
|
|
if (this.random.chance(3)) {
|
|
if (this.sendAttack(toAttack)) return;
|
|
}
|
|
}
|
|
|
|
// Choose a new enemy randomly
|
|
const neighbors = this.player.nearby();
|
|
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;
|
|
}
|
|
}
|
|
if (this.sendAttack(neighbor)) return;
|
|
}
|
|
}
|
|
|
|
getNeighborTraitorToAttack(): Player | null {
|
|
if (this.game.config().disableAlliances()) return null;
|
|
|
|
const traitors = this.player
|
|
.nearby()
|
|
.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): boolean {
|
|
if (!force && !this.shouldAttack(target)) return false;
|
|
|
|
if (target.isPlayer()) {
|
|
if (this.player.sharesBorderWith(target)) {
|
|
return this.sendLandAttack(target);
|
|
} else {
|
|
return this.sendBoatAttack(target);
|
|
}
|
|
} else {
|
|
// sharesBorderWith(TerraNullius) counts water tiles as TN (ownerID 0 = TN smallID),
|
|
// so use a land-only adjacency check to decide land vs boat attack.
|
|
if (this.hasLandBorderWithTerraNullius()) {
|
|
return this.sendLandAttack(target);
|
|
} else {
|
|
return this.sendBoatAttackToNearbyTerraNullius();
|
|
}
|
|
}
|
|
}
|
|
|
|
private hasLandBorderWithTerraNullius(): boolean {
|
|
for (const border of this.player.borderTiles()) {
|
|
for (const neighbor of this.game.neighbors(border)) {
|
|
if (
|
|
this.game.isLand(neighbor) &&
|
|
!this.game.isImpassable(neighbor) &&
|
|
!this.game.hasOwner(neighbor)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Scans shore border tiles (every 10th) for unowned land within 5 water tiles
|
|
// in each cardinal direction, then sends a transport ship to the first match.
|
|
private sendBoatAttackToNearbyTerraNullius(): boolean {
|
|
if (this.game.config().isUnitDisabled(UnitType.TransportShip)) return false;
|
|
if (
|
|
this.player.unitCount(UnitType.TransportShip) >=
|
|
this.game.config().boatMaxNumber()
|
|
)
|
|
return false;
|
|
|
|
const directions: [number, number][] = [
|
|
[0, -1],
|
|
[0, 1],
|
|
[-1, 0],
|
|
[1, 0],
|
|
];
|
|
const shores = Array.from(this.player.borderTiles()).filter((t) =>
|
|
this.game.isShore(t),
|
|
);
|
|
|
|
for (let i = 0; i < shores.length; i += 10) {
|
|
const border = shores[i];
|
|
|
|
const bx = this.game.x(border);
|
|
const by = this.game.y(border);
|
|
|
|
for (const [dx, dy] of directions) {
|
|
const x1 = bx + dx;
|
|
const y1 = by + dy;
|
|
if (!this.game.isValidCoord(x1, y1)) continue;
|
|
if (!this.game.isWater(this.game.ref(x1, y1))) continue;
|
|
|
|
const nx = bx + dx * 5;
|
|
const ny = by + dy * 5;
|
|
if (!this.game.isValidCoord(nx, ny)) continue;
|
|
const tile = this.game.ref(nx, ny);
|
|
if (!this.game.isLand(tile)) continue;
|
|
if (this.game.isImpassable(tile)) continue;
|
|
if (this.game.hasOwner(tile)) continue;
|
|
if (this.game.hasFallout(tile)) continue;
|
|
if (!canBuildTransportShip(this.game, this.player, tile)) continue;
|
|
|
|
const troops = this.player.troops() / 5;
|
|
if (troops < 1) return false;
|
|
|
|
this.game.addExecution(
|
|
new TransportShipExecution(this.player, tile, troops),
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
shouldAttack(other: Player | TerraNullius): boolean {
|
|
if (
|
|
// Always attack Terra Nullius, non-humans and traitors
|
|
other.isPlayer() === false ||
|
|
other.type() !== PlayerType.Human ||
|
|
other.isTraitor() ||
|
|
// Always attack if we are a bot or in an HvN game
|
|
this.player.type() === PlayerType.Bot ||
|
|
this.game.config().gameConfig().playerTeams === HumansVsNations
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// Prevent attacking of humans on lower difficulties
|
|
const { difficulty } = this.game.config().gameConfig();
|
|
if (difficulty === Difficulty.Easy && this.random.nextInt(0, 4) !== 0) {
|
|
return false;
|
|
}
|
|
if (difficulty === Difficulty.Medium && this.random.chance(4)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* For Hard & Impossible nations in FFA: returns true if `troops` is less
|
|
* than 20% of the target's troop count, meaning the attack is too weak to
|
|
* be worthwhile. Bots and team games are exempt.
|
|
*/
|
|
private isAttackTooWeak(troops: number, target: Player): boolean {
|
|
if (this.player.type() === PlayerType.Bot) return false;
|
|
if (this.game.config().gameConfig().gameMode === GameMode.Team)
|
|
return false;
|
|
// Nations under attack may retaliate freely
|
|
if (this.player.incomingAttacks().length > 0) return false;
|
|
const { difficulty } = this.game.config().gameConfig();
|
|
return (
|
|
(difficulty === Difficulty.Hard ||
|
|
difficulty === Difficulty.Impossible) &&
|
|
troops < target.troops() * 0.2
|
|
);
|
|
}
|
|
|
|
/**
|
|
* For Hard & Impossible nations in FFA: computes the max troops this nation
|
|
* can send in an attack without letting its troop count drop below a
|
|
* fraction of its strongest non-allied neighbor's troop count (Hard: 75%,
|
|
* Impossible: 90%). Allied players and bot neighbors are not considered
|
|
* threats. Bots and team games are entirely exempt. Returns Infinity when
|
|
* no cap applies.
|
|
*
|
|
* Nations under attack may retaliate with at least the total incoming
|
|
* attack troops, even if that exceeds the neighbor-based cap.
|
|
*/
|
|
private troopSendCap(): number {
|
|
if (this.player.type() === PlayerType.Bot) return Infinity;
|
|
if (this.game.config().gameConfig().gameMode === GameMode.Team)
|
|
return Infinity;
|
|
|
|
const { difficulty } = this.game.config().gameConfig();
|
|
let retainFraction: number;
|
|
switch (difficulty) {
|
|
case Difficulty.Hard:
|
|
retainFraction = 0.75;
|
|
break;
|
|
case Difficulty.Impossible:
|
|
retainFraction = 0.9;
|
|
break;
|
|
default:
|
|
return Infinity;
|
|
}
|
|
|
|
let maxNeighborTroops = 0;
|
|
for (const n of this.player.nearby()) {
|
|
if (
|
|
n.isPlayer() &&
|
|
!this.player.isFriendly(n) &&
|
|
n.type() !== PlayerType.Bot &&
|
|
n.troops() > maxNeighborTroops
|
|
) {
|
|
maxNeighborTroops = n.troops();
|
|
}
|
|
}
|
|
|
|
let cap: number;
|
|
if (maxNeighborTroops === 0) {
|
|
cap = Infinity;
|
|
} else {
|
|
const minRetained = Math.ceil(maxNeighborTroops * retainFraction);
|
|
cap = Math.max(0, this.player.troops() - minRetained);
|
|
}
|
|
|
|
// Nations under attack may retaliate with at least the incoming troops
|
|
const incoming = this.player.incomingAttacks();
|
|
if (incoming.length > 0) {
|
|
const totalIncoming = incoming.reduce((sum, a) => sum + a.troops(), 0);
|
|
cap = Math.max(cap, totalIncoming);
|
|
}
|
|
|
|
return cap;
|
|
}
|
|
|
|
private calculateAttackTroops(
|
|
target: Player | TerraNullius,
|
|
nonBotTroops: (targetTroops: number) => number,
|
|
): number | null {
|
|
const maxTroops = this.game.config().maxTroops(this.player);
|
|
const botWithStructures =
|
|
target.isPlayer() &&
|
|
target.type() === PlayerType.Bot &&
|
|
target.units().some((u) => Structures.has(u.type()));
|
|
// Use the expand ratio when attacking a bot that owns structures — we need to
|
|
// recapture those structures ASAP, even before reaching the normal reserve.
|
|
const useReserve = target.isPlayer() && !botWithStructures;
|
|
const reserveRatio = useReserve ? 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 = nonBotTroops(targetTroops);
|
|
}
|
|
|
|
// Hard & Impossible: don't drop below neighbor troop threshold (players only)
|
|
if (target.isPlayer()) {
|
|
troops = Math.min(troops, this.troopSendCap());
|
|
}
|
|
|
|
if (troops < 1) {
|
|
return null;
|
|
}
|
|
|
|
// Hard & Impossible: don't attack if we'd send less than 20% of target's troops
|
|
if (target.isPlayer() && this.isAttackTooWeak(troops, target)) {
|
|
return null;
|
|
}
|
|
|
|
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
|
|
if (this.emojiBehavior === undefined) throw new Error("not initialized");
|
|
this.emojiBehavior.maybeSendAttackEmoji(target);
|
|
}
|
|
|
|
return troops;
|
|
}
|
|
|
|
private sendLandAttack(target: Player | TerraNullius): boolean {
|
|
const troops = this.calculateAttackTroops(
|
|
target,
|
|
(targetTroops) => this.player.troops() - targetTroops,
|
|
);
|
|
if (troops === null) {
|
|
return false;
|
|
}
|
|
|
|
this.game.addExecution(
|
|
new AttackExecution(
|
|
troops,
|
|
this.player,
|
|
target.isPlayer() ? target.id() : this.game.terraNullius().id(),
|
|
),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
private sendBoatAttack(target: Player): boolean {
|
|
if (this.game.config().isUnitDisabled(UnitType.TransportShip)) {
|
|
return false;
|
|
}
|
|
|
|
const closest = closestTwoTiles(
|
|
this.game,
|
|
Array.from(this.player.borderTiles()).filter((t) => this.game.isShore(t)),
|
|
Array.from(target.borderTiles()).filter((t) => this.game.isShore(t)),
|
|
);
|
|
if (closest === null) {
|
|
return false;
|
|
}
|
|
|
|
if (!canBuildTransportShip(this.game, this.player, closest.y)) {
|
|
return false;
|
|
}
|
|
|
|
const troops = this.calculateAttackTroops(
|
|
target,
|
|
() => this.player.troops() / 5,
|
|
);
|
|
if (troops === null) {
|
|
return false;
|
|
}
|
|
|
|
this.game.addExecution(
|
|
new TransportShipExecution(this.player, closest.y, troops),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private donateTroops(): boolean {
|
|
// Only donate in team games
|
|
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
|
|
return false;
|
|
}
|
|
|
|
// Don't donate in public games (To balance HvN)
|
|
if (this.game.config().gameConfig().gameType === GameType.Public) {
|
|
return false;
|
|
}
|
|
|
|
// Check if donating troops is allowed
|
|
if (this.game.config().donateTroops() === false) {
|
|
return false;
|
|
}
|
|
|
|
// Don't donate if the game has a winner
|
|
if (this.game.getWinner() !== null) {
|
|
return false;
|
|
}
|
|
|
|
// Skip donating based on difficulty
|
|
const { difficulty } = this.game.config().gameConfig();
|
|
switch (difficulty) {
|
|
case Difficulty.Easy:
|
|
// Easy nations don't donate
|
|
return false;
|
|
case Difficulty.Medium:
|
|
// Medium nations donate 25% of the time
|
|
if (!this.random.chance(4)) {
|
|
return false;
|
|
}
|
|
break;
|
|
case Difficulty.Hard:
|
|
// Hard nations donate 50% of the time
|
|
if (!this.random.chance(2)) {
|
|
return false;
|
|
}
|
|
break;
|
|
case Difficulty.Impossible:
|
|
// Impossible nations always try to donate
|
|
break;
|
|
default:
|
|
assertNever(difficulty);
|
|
}
|
|
|
|
// Find teammates who are currently in combat
|
|
const teammates = this.game
|
|
.players()
|
|
.filter((p) => this.player.isOnSameTeam(p))
|
|
.filter(
|
|
(p) => p.incomingAttacks().length > 0 || p.outgoingAttacks().length > 0,
|
|
);
|
|
|
|
if (teammates.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
// Find teammate with lowest troop percentage (troops / maxTroops)
|
|
const teammatesWithTroopPercentage = teammates
|
|
.map((teammate) => {
|
|
const maxTroops = this.game.config().maxTroops(teammate);
|
|
const troopPercentage = teammate.troops() / Math.max(maxTroops, 1);
|
|
return { teammate, troopPercentage };
|
|
})
|
|
.sort((a, b) => a.troopPercentage - b.troopPercentage);
|
|
|
|
// Try to donate to teammates in order of lowest troop percentage
|
|
let selectedTeammate: Player | null = null;
|
|
for (const entry of teammatesWithTroopPercentage) {
|
|
if (this.player.canDonateTroops(entry.teammate)) {
|
|
selectedTeammate = entry.teammate;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (selectedTeammate === null) {
|
|
return false;
|
|
}
|
|
|
|
// Donate a portion of our troops (keeping reserve)
|
|
const maxTroops = this.game.config().maxTroops(this.player);
|
|
const troopsToKeep = maxTroops * this.reserveRatio;
|
|
const availableTroops = this.player.troops() - troopsToKeep;
|
|
|
|
if (availableTroops < 1) {
|
|
return false;
|
|
}
|
|
|
|
this.game.addExecution(
|
|
new DonateTroopsExecution(
|
|
this.player,
|
|
selectedTeammate.id(),
|
|
availableTroops,
|
|
),
|
|
);
|
|
|
|
return true;
|
|
}
|
|
}
|