mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-04 03:40:39 +00:00
Improve nations 🤖 (#2817)
## Description: ### Refactor - Moved `maybeSpawnWarship()` from `NationExecution` to `NationWarshipBehavior` - Moved `maybeAttack()` (and sub-methods) from `NationExecution` to `AiAttackBehavior` ### Betrayal - Added nice betrayal logic in `maybeBetray()`. Previously that method was basically just a placeholder for a future implementation. ### Attacking - Added `veryWeak()` attack strategy for hard and impossible difficulty nations attack orders to target MIRVed players with higher priority - Optimized the `weakest()` attack strategy so that nations don't attack stronger players. This should make nation-attacks feel less random (humans complained in discord) - `findNearestIslandEnemy()` and `randomBoatTarget()` also no longer returns stronger players - `afk()` and `hated()` attack strategies no longer return MUCH stronger players - Several tiny refactorings, fixes and balance optimizations in `AiAttackBehavior` ### Emojis - Added some `canSendEmoji()` because I saw some "cannot send emoji" warnings in the console ## 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:
@@ -6,13 +6,11 @@ import {
|
||||
Nation,
|
||||
Player,
|
||||
PlayerID,
|
||||
PlayerType,
|
||||
Relation,
|
||||
TerrainType,
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { canBuildTransportShip } from "../game/TransportShipUtils";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { GameID } from "../Schemas";
|
||||
import { assertNever, simpleHash } from "../Util";
|
||||
@@ -25,7 +23,6 @@ import { randTerritoryTileArray } from "./nation/NationUtils";
|
||||
import { NationWarshipBehavior } from "./nation/NationWarshipBehavior";
|
||||
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { TransportShipExecution } from "./TransportShipExecution";
|
||||
import { AiAttackBehavior } from "./utils/AiAttackBehavior";
|
||||
|
||||
export class NationExecution implements Execution {
|
||||
@@ -143,7 +140,6 @@ export class NationExecution implements Execution {
|
||||
this.warshipBehavior === null ||
|
||||
this.nukeBehavior === null
|
||||
) {
|
||||
// Player is unavailable during init()
|
||||
this.emojiBehavior = new NationEmojiBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
@@ -197,8 +193,9 @@ export class NationExecution implements Execution {
|
||||
this.mirvBehavior.considerMIRV();
|
||||
this.handleUnits();
|
||||
this.handleEmbargoesToHostileNations();
|
||||
this.maybeAttack();
|
||||
this.attackBehavior.maybeAttack();
|
||||
this.warshipBehavior.counterWarshipInfestation();
|
||||
this.nukeBehavior.maybeSendNuke();
|
||||
}
|
||||
|
||||
private randomSpawnLand(): TileRef | null {
|
||||
@@ -252,10 +249,11 @@ export class NationExecution implements Execution {
|
||||
}
|
||||
|
||||
private handleUnits() {
|
||||
if (this.warshipBehavior === null) throw new Error("not initialized");
|
||||
return (
|
||||
this.maybeSpawnStructure(UnitType.City, (num) => num) ||
|
||||
this.maybeSpawnStructure(UnitType.Port, (num) => num) ||
|
||||
this.maybeSpawnWarship() ||
|
||||
this.warshipBehavior.maybeSpawnWarship() ||
|
||||
this.maybeSpawnStructure(UnitType.Factory, (num) => num) ||
|
||||
this.maybeSpawnStructure(UnitType.DefensePost, (num) => (num + 2) ** 2) ||
|
||||
this.maybeSpawnStructure(UnitType.SAMLauncher, (num) => num ** 2) ||
|
||||
@@ -331,59 +329,6 @@ export class NationExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
private maybeSpawnWarship(): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
if (!this.random.chance(50)) {
|
||||
return false;
|
||||
}
|
||||
const ports = this.player.units(UnitType.Port);
|
||||
const ships = this.player.units(UnitType.Warship);
|
||||
if (
|
||||
ports.length > 0 &&
|
||||
ships.length === 0 &&
|
||||
this.player.gold() > this.cost(UnitType.Warship)
|
||||
) {
|
||||
const port = this.random.randElement(ports);
|
||||
const targetTile = this.warshipSpawnTile(port.tile());
|
||||
if (targetTile === null) {
|
||||
return false;
|
||||
}
|
||||
const canBuild = this.player.canBuild(UnitType.Warship, targetTile);
|
||||
if (canBuild === false) {
|
||||
return false;
|
||||
}
|
||||
this.mg.addExecution(
|
||||
new ConstructionExecution(this.player, UnitType.Warship, targetTile),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private warshipSpawnTile(portTile: TileRef): TileRef | null {
|
||||
const radius = 250;
|
||||
for (let attempts = 0; attempts < 50; attempts++) {
|
||||
const randX = this.random.nextInt(
|
||||
this.mg.x(portTile) - radius,
|
||||
this.mg.x(portTile) + radius,
|
||||
);
|
||||
const randY = this.random.nextInt(
|
||||
this.mg.y(portTile) - radius,
|
||||
this.mg.y(portTile) + radius,
|
||||
);
|
||||
if (!this.mg.isValidCoord(randX, randY)) {
|
||||
continue;
|
||||
}
|
||||
const tile = this.mg.ref(randX, randY);
|
||||
// Sanity check
|
||||
if (!this.mg.isOcean(tile)) {
|
||||
continue;
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private handleEmbargoesToHostileNations() {
|
||||
const player = this.player;
|
||||
if (player === null) return;
|
||||
@@ -406,156 +351,6 @@ export class NationExecution implements Execution {
|
||||
});
|
||||
}
|
||||
|
||||
private maybeAttack() {
|
||||
if (
|
||||
this.player === null ||
|
||||
this.attackBehavior === null ||
|
||||
this.allianceBehavior === null ||
|
||||
this.nukeBehavior === null
|
||||
) {
|
||||
throw new Error("not initialized");
|
||||
}
|
||||
|
||||
const border = Array.from(this.player.borderTiles())
|
||||
.flatMap((t) => this.mg.neighbors(t))
|
||||
.filter(
|
||||
(t) =>
|
||||
this.mg.isLand(t) && this.mg.ownerID(t) !== this.player?.smallID(),
|
||||
);
|
||||
const borderingPlayers = [
|
||||
...new Set(
|
||||
border
|
||||
.map((t) => this.mg.playerBySmallID(this.mg.ownerID(t)))
|
||||
.filter((o): o is Player => o.isPlayer()),
|
||||
),
|
||||
].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
|
||||
const hasNonNukedTerraNullius = border.some(
|
||||
(t) => !this.mg.hasOwner(t) && !this.mg.hasFallout(t),
|
||||
);
|
||||
if (hasNonNukedTerraNullius) {
|
||||
this.attackBehavior.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
|
||||
if (borderingEnemies.length === 0) {
|
||||
if (this.random.chance(5)) {
|
||||
this.sendBoatRandomly();
|
||||
}
|
||||
} else {
|
||||
if (this.random.chance(10)) {
|
||||
this.sendBoatRandomly(borderingEnemies);
|
||||
return;
|
||||
}
|
||||
|
||||
this.allianceBehavior.maybeSendAllianceRequests(borderingEnemies);
|
||||
}
|
||||
|
||||
this.attackBehavior.attackBestTarget(borderingFriends, borderingEnemies);
|
||||
this.nukeBehavior.maybeSendNuke();
|
||||
}
|
||||
|
||||
private sendBoatRandomly(borderingEnemies: Player[] = []) {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const oceanShore = Array.from(this.player.borderTiles()).filter((t) =>
|
||||
this.mg.isOceanShore(t),
|
||||
);
|
||||
if (oceanShore.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const src = this.random.randElement(oceanShore);
|
||||
|
||||
// First look for high-interest targets (unowned or bot-owned). Mainly relevant for earlygame
|
||||
let dst = this.randomBoatTarget(src, borderingEnemies, true);
|
||||
if (dst === null) {
|
||||
// None found? Then look for players
|
||||
dst = this.randomBoatTarget(src, borderingEnemies, false);
|
||||
if (dst === null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.mg.addExecution(
|
||||
new TransportShipExecution(
|
||||
this.player,
|
||||
this.mg.owner(dst).id(),
|
||||
dst,
|
||||
this.player.troops() / 5,
|
||||
null,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
private randomBoatTarget(
|
||||
tile: TileRef,
|
||||
borderingEnemies: Player[],
|
||||
highInterestOnly: boolean = false,
|
||||
): TileRef | null {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const x = this.mg.x(tile);
|
||||
const y = this.mg.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.mg.isValidCoord(randX, randY)) {
|
||||
continue;
|
||||
}
|
||||
const randTile = this.mg.ref(randX, randY);
|
||||
if (!this.mg.isLand(randTile)) {
|
||||
continue;
|
||||
}
|
||||
const owner = this.mg.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 that are more than twice as large as us
|
||||
if (owner.isPlayer() && owner.troops() > this.player.troops() * 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let matchesCriteria = false;
|
||||
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.mg, this.player, randTile) === false) {
|
||||
if (owner.isPlayer()) {
|
||||
unreachablePlayers.add(owner.id());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
return randTile;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private cost(type: UnitType): Gold {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
return this.mg.unitInfo(type).cost(this.mg, this.player);
|
||||
|
||||
@@ -336,20 +336,57 @@ export class NationAllianceBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
maybeBetray(otherPlayer: Player, borderingPlayerCount: number): boolean {
|
||||
if (!this.player.isAlliedWith(otherPlayer)) return false;
|
||||
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
|
||||
// Betray very weak players (For example MIRVed ones)
|
||||
if (difficulty !== Difficulty.Easy && difficulty !== Difficulty.Medium) {
|
||||
const otherPlayerMaxTroops = this.game.config().maxTroops(otherPlayer);
|
||||
const otherPlayerOutgoingTroops = otherPlayer
|
||||
.outgoingAttacks()
|
||||
.reduce((sum, attack) => sum + attack.troops(), 0);
|
||||
if (
|
||||
otherPlayer.troops() + otherPlayerOutgoingTroops <
|
||||
otherPlayerMaxTroops * 0.2 &&
|
||||
otherPlayer.troops() < this.player.troops()
|
||||
) {
|
||||
this.betray(otherPlayer);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Betray very weak players (similar check as above but for the easier difficulties)
|
||||
// This doesn't check for maxTroops and isn't really smart. It opens the nations up for attacks, but that's intended.
|
||||
if (
|
||||
this.player.isAlliedWith(otherPlayer) &&
|
||||
(difficulty === Difficulty.Easy || difficulty === Difficulty.Medium) &&
|
||||
this.player.troops() >= otherPlayer.troops() * 10
|
||||
) {
|
||||
this.betray(otherPlayer);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Betray traitors who aren't significantly stronger than us
|
||||
if (
|
||||
difficulty !== Difficulty.Easy &&
|
||||
otherPlayer.isTraitor() &&
|
||||
otherPlayer.troops() < this.player.troops() * 1.2
|
||||
) {
|
||||
this.betray(otherPlayer);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Betray our only bordering player if we are much stronger than them
|
||||
if (
|
||||
difficulty !== Difficulty.Easy &&
|
||||
borderingPlayerCount === 1 &&
|
||||
otherPlayer.troops() * 3 < this.player.troops()
|
||||
) {
|
||||
this.betray(otherPlayer);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -263,6 +263,7 @@ export class NationEmojiBehavior {
|
||||
|
||||
sendEmoji(otherPlayer: Player | typeof AllPlayers, emojisList: number[]) {
|
||||
if (!this.shouldSendEmoji(otherPlayer, false)) return;
|
||||
if (!this.player.canSendEmoji(otherPlayer)) return;
|
||||
|
||||
this.game.addExecution(
|
||||
new EmojiExecution(
|
||||
@@ -301,6 +302,7 @@ export function respondToEmoji(
|
||||
if (recipient === AllPlayers || recipient.type() !== PlayerType.Nation) {
|
||||
return;
|
||||
}
|
||||
if (!recipient.canSendEmoji(sender)) return;
|
||||
|
||||
if (emojiString === "🖕") {
|
||||
recipient.updateRelation(sender, -100);
|
||||
@@ -346,6 +348,7 @@ export function respondToMIRV(
|
||||
mirvTarget: Player,
|
||||
) {
|
||||
if (!random.chance(8)) return;
|
||||
if (!mirvTarget.canSendEmoji(AllPlayers)) return;
|
||||
|
||||
game.addExecution(
|
||||
new EmojiExecution(
|
||||
|
||||
@@ -29,6 +29,59 @@ export class NationWarshipBehavior {
|
||||
private emojiBehavior: NationEmojiBehavior,
|
||||
) {}
|
||||
|
||||
maybeSpawnWarship(): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
if (!this.random.chance(50)) {
|
||||
return false;
|
||||
}
|
||||
const ports = this.player.units(UnitType.Port);
|
||||
const ships = this.player.units(UnitType.Warship);
|
||||
if (
|
||||
ports.length > 0 &&
|
||||
ships.length === 0 &&
|
||||
this.player.gold() > this.cost(UnitType.Warship)
|
||||
) {
|
||||
const port = this.random.randElement(ports);
|
||||
const targetTile = this.warshipSpawnTile(port.tile());
|
||||
if (targetTile === null) {
|
||||
return false;
|
||||
}
|
||||
const canBuild = this.player.canBuild(UnitType.Warship, targetTile);
|
||||
if (canBuild === false) {
|
||||
return false;
|
||||
}
|
||||
this.game.addExecution(
|
||||
new ConstructionExecution(this.player, UnitType.Warship, targetTile),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private warshipSpawnTile(portTile: TileRef): TileRef | null {
|
||||
const radius = 250;
|
||||
for (let attempts = 0; attempts < 50; attempts++) {
|
||||
const randX = this.random.nextInt(
|
||||
this.game.x(portTile) - radius,
|
||||
this.game.x(portTile) + radius,
|
||||
);
|
||||
const randY = this.random.nextInt(
|
||||
this.game.y(portTile) - radius,
|
||||
this.game.y(portTile) + radius,
|
||||
);
|
||||
if (!this.game.isValidCoord(randX, randY)) {
|
||||
continue;
|
||||
}
|
||||
const tile = this.game.ref(randX, randY);
|
||||
// Sanity check
|
||||
if (!this.game.isOcean(tile)) {
|
||||
continue;
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
trackShipsAndRetaliate(): void {
|
||||
this.trackTransportShipsAndRetaliate();
|
||||
this.trackTradeShipsAndRetaliate();
|
||||
|
||||
@@ -2,10 +2,13 @@ import {
|
||||
Difficulty,
|
||||
Game,
|
||||
Player,
|
||||
PlayerID,
|
||||
PlayerType,
|
||||
Relation,
|
||||
TerraNullius,
|
||||
} from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { canBuildTransportShip } from "../../game/TransportShipUtils";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import {
|
||||
assertNever,
|
||||
@@ -38,8 +41,156 @@ export class AiAttackBehavior {
|
||||
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.ownerID(t) !== this.player?.smallID(),
|
||||
);
|
||||
const borderingPlayers = [
|
||||
...new Set(
|
||||
border
|
||||
.map((t) => this.game.playerBySmallID(this.game.ownerID(t)))
|
||||
.filter((o): o is Player => o.isPlayer()),
|
||||
),
|
||||
].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
|
||||
const hasNonNukedTerraNullius = border.some(
|
||||
(t) => !this.game.hasOwner(t) && !this.game.hasFallout(t),
|
||||
);
|
||||
if (hasNonNukedTerraNullius) {
|
||||
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");
|
||||
const oceanShore = Array.from(this.player.borderTiles()).filter((t) =>
|
||||
this.game.isOceanShore(t),
|
||||
);
|
||||
if (oceanShore.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const src = this.random.randElement(oceanShore);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
this.game.addExecution(
|
||||
new TransportShipExecution(
|
||||
this.player,
|
||||
this.game.owner(dst).id(),
|
||||
dst,
|
||||
this.player.troops() / 5,
|
||||
null,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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
|
||||
if (owner.isPlayer() && owner.troops() > this.player.troops()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let matchesCriteria = false;
|
||||
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)
|
||||
attackBestTarget(borderingFriends: Player[], borderingEnemies: Player[]) {
|
||||
private attackBestTarget(
|
||||
borderingFriends: Player[],
|
||||
borderingEnemies: Player[],
|
||||
) {
|
||||
// Save up troops until we reach the reserve ratio
|
||||
if (!this.hasReserveRatioTroops()) return;
|
||||
|
||||
@@ -78,27 +229,29 @@ export class AiAttackBehavior {
|
||||
const assist = (): boolean => this.assistAllies();
|
||||
|
||||
const traitor = (): boolean => {
|
||||
const weakestTraitor = this.findWeakestTraitor(borderingEnemies);
|
||||
if (weakestTraitor) {
|
||||
this.sendAttack(weakestTraitor);
|
||||
const traitor = this.findTraitor(borderingEnemies);
|
||||
if (traitor) {
|
||||
this.sendAttack(traitor);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const afk = (): boolean => {
|
||||
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
|
||||
const weakestAfk = borderingEnemies.find((enemy) =>
|
||||
enemy.isDisconnected(),
|
||||
// borderingEnemies is already sorted by troops (ascending), so first match is weakest afk enemy
|
||||
const afk = borderingEnemies.find(
|
||||
(enemy) =>
|
||||
enemy.isDisconnected() && enemy.troops() < this.player.troops() * 3,
|
||||
);
|
||||
if (weakestAfk) {
|
||||
this.sendAttack(weakestAfk);
|
||||
if (afk) {
|
||||
this.sendAttack(afk);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const betray = (): boolean => this.maybeBetrayAndAttack(borderingFriends);
|
||||
const betray = (): boolean =>
|
||||
this.maybeBetrayAndAttack(borderingFriends, borderingEnemies);
|
||||
|
||||
const nuked = (): boolean => {
|
||||
if (this.isBorderingNukedTerritory()) {
|
||||
@@ -109,9 +262,9 @@ export class AiAttackBehavior {
|
||||
};
|
||||
|
||||
const victim = (): boolean => {
|
||||
const weakestVictim = this.findWeakestVictim(borderingEnemies);
|
||||
if (weakestVictim) {
|
||||
this.sendAttack(weakestVictim);
|
||||
const victim = this.findVictim(borderingEnemies);
|
||||
if (victim) {
|
||||
this.sendAttack(victim);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -122,17 +275,31 @@ export class AiAttackBehavior {
|
||||
if (relation.relation !== Relation.Hostile) continue;
|
||||
const other = relation.player;
|
||||
if (this.player.isFriendly(other)) continue;
|
||||
if (other.troops() > this.player.troops() * 3) continue;
|
||||
this.sendAttack(other);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const veryWeak = (): boolean => {
|
||||
const veryWeak = this.findVeryWeakEnemy(borderingEnemies);
|
||||
if (veryWeak) {
|
||||
this.sendAttack(veryWeak);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const weakest = (): boolean => {
|
||||
if (borderingEnemies.length > 0) {
|
||||
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
|
||||
this.sendAttack(borderingEnemies[0]);
|
||||
return true;
|
||||
const weakest = borderingEnemies[0];
|
||||
// Don't attack if they have more troops than us
|
||||
if (weakest.troops() < this.player.troops()) {
|
||||
this.sendAttack(weakest);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -152,48 +319,17 @@ export class AiAttackBehavior {
|
||||
// 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:
|
||||
return [
|
||||
bots,
|
||||
nuked,
|
||||
retaliate,
|
||||
assist,
|
||||
betray,
|
||||
hated,
|
||||
afk,
|
||||
traitor,
|
||||
weakest,
|
||||
island,
|
||||
];
|
||||
// prettier-ignore
|
||||
return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island];
|
||||
case Difficulty.Hard:
|
||||
return [
|
||||
bots,
|
||||
retaliate,
|
||||
assist,
|
||||
betray,
|
||||
nuked,
|
||||
traitor,
|
||||
afk,
|
||||
hated,
|
||||
victim,
|
||||
weakest,
|
||||
island,
|
||||
];
|
||||
// prettier-ignore
|
||||
return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island];
|
||||
case Difficulty.Impossible:
|
||||
return [
|
||||
retaliate,
|
||||
bots,
|
||||
assist,
|
||||
traitor,
|
||||
afk,
|
||||
betray,
|
||||
nuked,
|
||||
victim,
|
||||
hated,
|
||||
weakest,
|
||||
island,
|
||||
];
|
||||
// prettier-ignore
|
||||
return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island];
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
@@ -309,23 +445,31 @@ export class AiAttackBehavior {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find a traitor who isn't much stronger than us (max 20% more troops)
|
||||
private findWeakestTraitor(borderingEnemies: Player[]): Player | null {
|
||||
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
|
||||
// Find a traitor who isn't significantly stronger than us
|
||||
private findTraitor(borderingEnemies: Player[]): Player | null {
|
||||
// borderingEnemies is already sorted by troops (ascending), so first match is weakest traitor
|
||||
return (
|
||||
borderingEnemies.find(
|
||||
(enemy) =>
|
||||
enemy.isTraitor() && enemy.troops() * 1.2 < this.player.troops(),
|
||||
enemy.isTraitor() && enemy.troops() < this.player.troops() * 1.2,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private maybeBetrayAndAttack(borderingFriends: Player[]): boolean {
|
||||
private maybeBetrayAndAttack(
|
||||
borderingFriends: Player[],
|
||||
borderingEnemies: 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)) {
|
||||
if (
|
||||
this.allianceBehavior.maybeBetray(
|
||||
friend,
|
||||
borderingFriends.length + borderingEnemies.length,
|
||||
)
|
||||
) {
|
||||
this.sendAttack(friend, true);
|
||||
return true;
|
||||
}
|
||||
@@ -349,12 +493,12 @@ export class AiAttackBehavior {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find someone who is weaker than us and is under big attack from others (50%+ of their troops incoming)
|
||||
private findWeakestVictim(borderingEnemies: Player[]): Player | null {
|
||||
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
|
||||
// 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 (enemy.troops() >= this.player.troops()) return false;
|
||||
if (enemy.troops() > this.player.troops() * 1.2) return false;
|
||||
|
||||
const totalIncomingTroops = enemy
|
||||
.incomingAttacks()
|
||||
@@ -365,6 +509,21 @@ export class AiAttackBehavior {
|
||||
);
|
||||
}
|
||||
|
||||
// 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 &&
|
||||
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 {
|
||||
const myBorder = this.player.borderTiles();
|
||||
if (myBorder.size === 0) return null;
|
||||
@@ -374,8 +533,8 @@ export class AiAttackBehavior {
|
||||
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;
|
||||
// Don't spam boats into players with more troops
|
||||
return p.troops() < this.player.troops();
|
||||
});
|
||||
|
||||
if (filteredPlayers.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user