mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 10:48:10 +00:00
aa59dd23be
## Description: For the next v30 fix version <img width="868" height="364" alt="imaege" src="https://github.com/user-attachments/assets/520a999c-67e7-4c57-8651-895ad9eeb73a" /> HvN balancing for the revamped difficulty steps of v30 sadly doesn't really work out... In medium difficulty games humans nearly always win (boring) In hard difficulty games humans usually lose It was intended differently... So lets get rid of medium difficulty HvN, always use hard difficulty and disable the donation-capability for public game nations. That will tune the human winrate towards a middle ground at about 65% I think. Which should be nice. Easier than in v29 (was frustrating sometimes) but not as easy as it's now. We can only test this in prod lol ## 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
944 lines
28 KiB
TypeScript
944 lines
28 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.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");
|
|
|
|
// 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 ocean shore tiles to launch from
|
|
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, dst, this.player.troops() / 5),
|
|
);
|
|
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)
|
|
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) {
|
|
this.sendAttack(attacker, true);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const bots = (): boolean => this.attackBots();
|
|
|
|
const assist = (): boolean => this.assistAllies();
|
|
|
|
const traitor = (): boolean => {
|
|
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 afk enemy
|
|
const afk = borderingEnemies.find(
|
|
(enemy) =>
|
|
enemy.isDisconnected() && enemy.troops() < this.player.troops() * 3,
|
|
);
|
|
if (afk) {
|
|
this.sendAttack(afk);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const betray = (): boolean =>
|
|
this.maybeBetrayAndAttack(borderingFriends, borderingEnemies);
|
|
|
|
const nuked = (): boolean => {
|
|
if (this.isBorderingNukedTerritory()) {
|
|
this.sendAttack(this.game.terraNullius());
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const victim = (): boolean => {
|
|
const victim = this.findVictim(borderingEnemies);
|
|
if (victim) {
|
|
this.sendAttack(victim);
|
|
return true;
|
|
}
|
|
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 (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
|
|
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;
|
|
};
|
|
|
|
const island = (): boolean => {
|
|
if (borderingEnemies.length === 0) {
|
|
const enemy = this.findNearestIslandEnemy();
|
|
if (enemy) {
|
|
this.sendAttack(enemy);
|
|
return true;
|
|
}
|
|
}
|
|
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
|
|
.neighbors()
|
|
.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 {
|
|
// 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
|
|
// 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
|
|
.neighbors()
|
|
.filter(
|
|
(n): n is Player =>
|
|
n.isPlayer() &&
|
|
this.player.isFriendly(n) === false &&
|
|
n.type() === PlayerType.Bot,
|
|
);
|
|
|
|
if (bots.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
this.botAttackTroopsSent = 0;
|
|
|
|
const density = (p: Player) => p.troops() / p.numTilesOwned();
|
|
const 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");
|
|
|
|
for (const ally of this.player.allies()) {
|
|
if (ally.targets().length === 0) continue;
|
|
if (this.player.relation(ally) < Relation.Friendly) {
|
|
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_RELATION_TOO_LOW);
|
|
continue;
|
|
}
|
|
for (const target of ally.targets()) {
|
|
if (target === this.player) {
|
|
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ME);
|
|
continue;
|
|
}
|
|
if (this.player.isFriendly(target)) {
|
|
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ALLY);
|
|
continue;
|
|
}
|
|
// All checks passed, assist them
|
|
this.player.updateRelation(ally, -20);
|
|
this.sendAttack(target);
|
|
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_ACCEPT);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// 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() < this.player.troops() * 1.2,
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
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,
|
|
borderingFriends.length + borderingEnemies.length,
|
|
)
|
|
) {
|
|
this.sendAttack(friend, true);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private isBorderingNukedTerritory(): boolean {
|
|
for (const tile of this.player.borderTiles()) {
|
|
for (const neighbor of this.game.neighbors(tile)) {
|
|
if (
|
|
this.game.isLand(neighbor) &&
|
|
!this.game.hasOwner(neighbor) &&
|
|
this.game.hasFallout(neighbor)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// 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() * 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 &&
|
|
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 {
|
|
// 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 ocean shore tiles to launch from
|
|
const hasOceanShore = Array.from(this.player.borderTiles()).some((t) =>
|
|
this.game.isOceanShore(t),
|
|
);
|
|
if (!hasOceanShore) return null;
|
|
|
|
const filteredPlayers = this.game.players().filter((p) => {
|
|
if (p === this.player) return false;
|
|
if (this.player.isFriendly(p)) return false;
|
|
// Don't spam boats into players with more troops
|
|
return 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.isOceanShore(t),
|
|
),
|
|
Array.from(entry.player.borderTiles()).filter((t) =>
|
|
this.game.isOceanShore(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];
|
|
}
|
|
|
|
private getPlayerCenter(player: Player) {
|
|
if (player.largestClusterBoundingBox) {
|
|
return boundingBoxCenter(player.largestClusterBoundingBox);
|
|
}
|
|
return calculateBoundingBoxCenter(this.game, player.borderTiles());
|
|
}
|
|
|
|
attackRandomTarget() {
|
|
// Save up troops until we reach the trigger ratio
|
|
if (!this.hasTriggerRatioTroops()) return;
|
|
|
|
// Retaliate against incoming attacks
|
|
const incomingAttackPlayer = this.findIncomingAttackPlayer();
|
|
if (incomingAttackPlayer) {
|
|
this.sendAttack(incomingAttackPlayer, true);
|
|
return;
|
|
}
|
|
|
|
// Select a traitor as an enemy
|
|
const toAttack = this.getNeighborTraitorToAttack();
|
|
if (toAttack !== null) {
|
|
if (this.random.chance(3)) {
|
|
this.sendAttack(toAttack);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Choose a new enemy randomly
|
|
const neighbors = this.player.neighbors();
|
|
for (const neighbor of this.random.shuffleArray(neighbors)) {
|
|
if (!neighbor.isPlayer()) continue;
|
|
if (this.player.isFriendly(neighbor)) continue;
|
|
if (
|
|
neighbor.type() === PlayerType.Nation ||
|
|
neighbor.type() === PlayerType.Human
|
|
) {
|
|
if (this.random.chance(2)) {
|
|
continue;
|
|
}
|
|
}
|
|
this.sendAttack(neighbor);
|
|
return;
|
|
}
|
|
}
|
|
|
|
getNeighborTraitorToAttack(): Player | null {
|
|
const traitors = this.player
|
|
.neighbors()
|
|
.filter(
|
|
(n): n is Player =>
|
|
n.isPlayer() && this.player.isFriendly(n) === false && n.isTraitor(),
|
|
);
|
|
return traitors.length > 0 ? this.random.randElement(traitors) : null;
|
|
}
|
|
|
|
forceSendAttack(target: Player | TerraNullius) {
|
|
this.game.addExecution(
|
|
new AttackExecution(
|
|
this.player.troops() / 2,
|
|
this.player,
|
|
target.isPlayer() ? target.id() : this.game.terraNullius().id(),
|
|
),
|
|
);
|
|
}
|
|
|
|
sendAttack(target: Player | TerraNullius, force = false) {
|
|
if (!force && !this.shouldAttack(target)) return;
|
|
|
|
if (this.player.sharesBorderWith(target)) {
|
|
this.sendLandAttack(target);
|
|
} else if (target.isPlayer()) {
|
|
this.sendBoatAttack(target);
|
|
}
|
|
}
|
|
|
|
shouldAttack(other: Player | TerraNullius): boolean {
|
|
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.chance(2)) {
|
|
return false;
|
|
}
|
|
if (difficulty === Difficulty.Medium && this.random.chance(4)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private sendLandAttack(target: Player | TerraNullius) {
|
|
const maxTroops = this.game.config().maxTroops(this.player);
|
|
const 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 = this.player.troops() - targetTroops;
|
|
}
|
|
|
|
if (troops < 1) {
|
|
return;
|
|
}
|
|
|
|
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
|
|
if (this.emojiBehavior === undefined) throw new Error("not initialized");
|
|
this.emojiBehavior.maybeSendAttackEmoji(target);
|
|
}
|
|
|
|
this.game.addExecution(
|
|
new AttackExecution(
|
|
troops,
|
|
this.player,
|
|
target.isPlayer() ? target.id() : this.game.terraNullius().id(),
|
|
),
|
|
);
|
|
}
|
|
|
|
private sendBoatAttack(target: Player) {
|
|
const closest = closestTwoTiles(
|
|
this.game,
|
|
Array.from(this.player.borderTiles()).filter((t) =>
|
|
this.game.isOceanShore(t),
|
|
),
|
|
Array.from(target.borderTiles()).filter((t) => this.game.isOceanShore(t)),
|
|
);
|
|
if (closest === null) {
|
|
return;
|
|
}
|
|
|
|
if (!canBuildTransportShip(this.game, this.player, closest.y)) {
|
|
return;
|
|
}
|
|
|
|
let troops;
|
|
if (target.type() === PlayerType.Bot) {
|
|
troops = this.calculateBotAttackTroops(target, this.player.troops() / 5);
|
|
} else {
|
|
troops = this.player.troops() / 5;
|
|
}
|
|
|
|
if (troops < 1) {
|
|
return;
|
|
}
|
|
|
|
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
|
|
if (this.emojiBehavior === undefined) throw new Error("not initialized");
|
|
this.emojiBehavior.maybeSendAttackEmoji(target);
|
|
}
|
|
|
|
this.game.addExecution(
|
|
new TransportShipExecution(this.player, closest.y, troops),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|