Merge branch 'main' into local-attack

This commit is contained in:
Aotumuri
2026-01-29 12:45:45 +09:00
committed by GitHub
161 changed files with 9232 additions and 2455 deletions
+5 -1
View File
@@ -401,7 +401,11 @@ export class AttackExecution implements Execution {
} else {
for (const neighbor of this.mg.neighbors(tile)) {
const no = this.mg.owner(neighbor);
if (no.isPlayer() && no !== this.target) {
if (
no.isPlayer() &&
no !== this.target &&
!no.isFriendly(this.target)
) {
this.mg.player(no.id()).conquer(tile);
break;
}
+1 -1
View File
@@ -101,7 +101,7 @@ export class BotExecution implements Execution {
}
if (this.neighborsTerraNullius) {
if (this.bot.sharesBorderWith(this.mg.terraNullius())) {
if (this.bot.neighbors().some((n) => !n.isPlayer())) {
this.attackBehavior.sendAttack(this.mg.terraNullius());
return;
}
+1 -7
View File
@@ -72,13 +72,7 @@ export class Executor {
case "spawn":
return new SpawnExecution(this.gameID, player.info(), intent.tile);
case "boat":
return new TransportShipExecution(
player,
intent.targetID,
intent.dst,
intent.troops,
intent.src,
);
return new TransportShipExecution(player, intent.dst, intent.troops);
case "allianceRequest":
return new AllianceRequestExecution(player, intent.recipient);
case "allianceRequestReply":
+13 -27
View File
@@ -4,7 +4,6 @@ import {
isStructureType,
MessageType,
Player,
StructureTypes,
TerraNullius,
TrajectoryTile,
Unit,
@@ -16,7 +15,7 @@ import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola"
import { PathStatus } from "../pathfinding/types";
import { PseudoRandom } from "../PseudoRandom";
import { NukeType } from "../StatsSchemas";
import { computeNukeBlastCounts } from "./Util";
import { listNukeBreakAlliance } from "./Util";
const SPRITE_RADIUS = 16;
@@ -85,36 +84,22 @@ export class NukeExecution implements Execution {
}
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
const threshold = this.mg.config().nukeAllianceBreakThreshold();
// Use shared utility to compute weighted tile counts per player
const blastCounts = computeNukeBlastCounts({
gm: this.mg,
const playersToBreakAllianceWith = listNukeBreakAlliance({
game: this.mg,
targetTile: this.dst,
magnitude,
allySmallIds: new Set(this.player.allies().map((a) => a.smallID())),
threshold: this.mg.config().nukeAllianceBreakThreshold(),
});
// Collect all players that should have alliance broken:
// either exceeds tile threshold OR has a structure in blast radius
const playersToBreakAllianceWith = new Set<number>();
for (const [playerSmallId, totalWeight] of blastCounts) {
if (totalWeight > threshold) {
playersToBreakAllianceWith.add(playerSmallId);
// Automatically reject incoming alliance requests.
for (const incoming of this.player.incomingAllianceRequests()) {
if (playersToBreakAllianceWith.has(incoming.requestor().smallID())) {
incoming.reject();
}
}
// Also check if any allied structures would be destroyed
this.mg
.nearbyUnits(this.dst, magnitude.outer, [...StructureTypes])
.filter(
({ unit }) =>
unit.owner().isPlayer() && this.player.isAlliedWith(unit.owner()),
)
.forEach(({ unit }) =>
playersToBreakAllianceWith.add(unit.owner().smallID()),
);
for (const playerSmallId of playersToBreakAllianceWith) {
const attackedPlayer = this.mg.playerBySmallID(playerSmallId);
if (!attackedPlayer.isPlayer()) {
@@ -123,11 +108,12 @@ export class NukeExecution implements Execution {
// Resolves exploit of alliance breaking in which a pending alliance request
// was accepted in the middle of a missile attack.
const allianceRequest = attackedPlayer
const outgoingAllianceRequest = attackedPlayer
.incomingAllianceRequests()
.find((ar) => ar.requestor() === this.player);
if (allianceRequest) {
allianceRequest.reject();
if (outgoingAllianceRequest) {
outgoingAllianceRequest.reject();
continue;
}
const alliance = this.player.allianceWith(attackedPlayer);
+5
View File
@@ -42,6 +42,11 @@ export class SpawnExecution implements Execution {
player = this.mg.addPlayer(this.playerInfo);
}
// Security: If random spawn is enabled, prevent players from re-rolling their spawn location
if (this.mg.config().isRandomSpawn() && player.hasSpawned()) {
return;
}
this.tile ??= this.randomSpawnLand();
if (this.tile === undefined) {
+27 -69
View File
@@ -4,7 +4,6 @@ import {
Game,
MessageType,
Player,
PlayerID,
TerraNullius,
Unit,
UnitType,
@@ -16,33 +15,28 @@ import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
import { AttackExecution } from "./AttackExecution";
const malusForRetreat = 25;
export class TransportShipExecution implements Execution {
private lastMove: number;
private active = true;
// TODO: make this configurable
private ticksPerMove = 1;
private active = true;
private lastMove: number;
private mg: Game;
private target: Player | TerraNullius;
// TODO make private
public path: TileRef[];
private dst: TileRef | null;
private boat: Unit;
private pathFinder: SteppingPathFinder<TileRef>;
private dst: TileRef | null;
private src: TileRef | null;
private boat: Unit;
private originalOwner: Player;
constructor(
private attacker: Player,
private targetID: PlayerID | null,
private ref: TileRef,
private startTroops: number,
private src: TileRef | null,
private troops: number,
) {
this.originalOwner = this.attacker;
}
@@ -52,24 +46,15 @@ export class TransportShipExecution implements Execution {
}
init(mg: Game, ticks: number) {
if (this.targetID !== null && !mg.hasPlayer(this.targetID)) {
console.warn(`TransportShipExecution: target ${this.targetID} not found`);
this.active = false;
return;
}
if (!mg.isValidRef(this.ref)) {
console.warn(`TransportShipExecution: ref ${this.ref} not valid`);
this.active = false;
return;
}
if (this.src !== null && !mg.isValidRef(this.src)) {
console.warn(`TransportShipExecution: src ${this.src} not valid`);
this.active = false;
return;
}
this.lastMove = ticks;
this.mg = mg;
this.target = mg.owner(this.ref);
this.pathFinder = PathFinding.Water(mg);
if (
@@ -87,78 +72,51 @@ export class TransportShipExecution implements Execution {
return;
}
if (
this.targetID === null ||
this.targetID === this.mg.terraNullius().id()
) {
this.target = mg.terraNullius();
} else {
this.target = mg.player(this.targetID);
}
if (this.target.isPlayer() && !this.attacker.canAttackPlayer(this.target)) {
this.active = false;
return;
}
this.startTroops ??= this.mg
this.troops ??= this.mg
.config()
.boatAttackAmount(this.attacker, this.target);
this.startTroops = Math.min(this.startTroops, this.attacker.troops());
this.troops = Math.min(this.troops, this.attacker.troops());
this.dst = targetTransportTile(this.mg, this.ref);
if (this.dst === null) {
console.warn(
`${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`,
`${this.attacker} cannot send ship to ${this.target}, cannot find target tile`,
);
this.active = false;
return;
}
const closestTileSrc = this.attacker.canBuild(
UnitType.TransportShip,
this.dst,
);
if (closestTileSrc === false) {
console.warn(`can't build transport ship`);
const src = this.attacker.canBuild(UnitType.TransportShip, this.dst);
if (src === false) {
console.warn(
`${this.attacker} cannot send ship to ${this.target}, cannot find start tile`,
);
this.active = false;
return;
}
if (this.src === null) {
// Only update the src if it's not already set
// because we assume that the src is set to the best spawn tile
this.src = closestTileSrc;
} else {
if (
this.mg.owner(this.src) !== this.attacker ||
!this.mg.isShore(this.src)
) {
console.warn(
`src is not a shore tile or not owned by: ${this.attacker.name()}`,
);
this.src = closestTileSrc;
}
}
this.src = src;
this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, {
troops: this.startTroops,
troops: this.troops,
targetTile: this.dst,
});
if (this.dst !== null) {
this.boat.setTargetTile(this.dst);
} else {
this.boat.setTargetTile(undefined);
}
// Notify the target player about the incoming naval invasion
if (this.targetID && this.targetID !== mg.terraNullius().id()) {
if (this.target.id() !== mg.terraNullius().id()) {
mg.displayIncomingUnit(
this.boat.id(),
// TODO TranslateText
`Naval invasion incoming from ${this.attacker.displayName()}`,
`Naval invasion incoming from ${this.attacker.displayName()} (${renderTroops(this.boat.troops())})`,
MessageType.NAVAL_INVASION_INBOUND,
this.targetID,
this.target.id(),
);
}
@@ -259,7 +217,7 @@ export class TransportShipExecution implements Execution {
new AttackExecution(
this.boat.troops(),
this.attacker,
this.targetID,
this.target.id(),
this.dst,
false,
),
@@ -283,7 +241,7 @@ export class TransportShipExecution implements Execution {
const map = this.mg.map();
const boatTile = this.boat.tile();
console.warn(
`TransportShip path not found: boat@(${map.x(boatTile)},${map.y(boatTile)}) -> dst@(${map.x(this.dst)},${map.y(this.dst)}), attacker=${this.attacker.id()}, target=${this.targetID}`,
`TransportShip path not found: boat@(${map.x(boatTile)},${map.y(boatTile)}) -> dst@(${map.x(this.dst)},${map.y(this.dst)}), attacker=${this.attacker.id()}, target=${this.target.id()}`,
);
this.attacker.addTroops(this.boat.troops());
this.boat.delete(false);
+35 -1
View File
@@ -36,7 +36,7 @@ export function computeNukeBlastCounts(
}
export interface NukeAllianceCheckParams {
game: GameView;
game: Game | GameView;
targetTile: TileRef;
magnitude: NukeMagnitude;
allySmallIds: Set<number>;
@@ -93,6 +93,40 @@ export function wouldNukeBreakAlliance(
return result;
}
// Same as wouldNukeBreakAlliance(), but takes time to find every player
// that would be "angered" from this nuke.
// This includes unallied players!
export function listNukeBreakAlliance(
params: NukeAllianceCheckParams,
): Set<number> {
const { game, targetTile, magnitude, threshold } = params;
// Collect all players that should have alliance broken:
// either exceeds tile threshold OR has a structure in blast radius
const playersToBreakAllianceWith = new Set<number>();
// compute tile breakage threshold
const blastCounts = computeNukeBlastCounts({
gm: game,
targetTile,
magnitude,
});
for (const [playerSmallId, totalWeight] of blastCounts) {
if (totalWeight > threshold) {
playersToBreakAllianceWith.add(playerSmallId);
}
}
// Also check if any allied structures would be destroyed
game
.nearbyUnits(targetTile, magnitude.outer, [...StructureTypes])
.forEach(({ unit }) =>
playersToBreakAllianceWith.add(unit.owner().smallID()),
);
return playersToBreakAllianceWith;
}
export function getSpawnTiles(gm: GameMap, tile: TileRef): TileRef[] {
return Array.from(gm.bfs(tile, euclDistFN(tile, 4, true))).filter(
(t) => !gm.hasOwner(t) && gm.isLand(t),
@@ -1,4 +1,11 @@
import { Execution, Game, Player, PlayerID } from "../../game/Game";
import {
Execution,
Game,
MessageType,
Player,
PlayerID,
UnitType,
} from "../../game/Game";
export class AllianceRequestReplyExecution implements Execution {
private active = true;
@@ -10,6 +17,57 @@ export class AllianceRequestReplyExecution implements Execution {
private accept: boolean,
) {}
private cancelNukesBetweenAlliedPlayers(
mg: Game,
p1: Player,
p2: Player,
): void {
const neutralized = new Map<Player, number>();
const players = [p1, p2];
for (const launcher of players) {
for (const unit of launcher.units(
UnitType.AtomBomb,
UnitType.HydrogenBomb,
)) {
if (!unit.isActive() || unit.reachedTarget()) continue;
const targetTile = unit.targetTile();
if (!targetTile) continue;
const targetOwner = mg.owner(targetTile);
if (!targetOwner.isPlayer()) continue;
const other = launcher === p1 ? p2 : p1;
if (targetOwner !== other) continue;
unit.delete(false);
neutralized.set(launcher, (neutralized.get(launcher) ?? 0) + 1);
}
}
for (const [launcher, count] of neutralized) {
const other = launcher === p1 ? p2 : p1;
mg.displayMessage(
"events_display.alliance_nukes_destroyed_outgoing",
MessageType.ALLIANCE_ACCEPTED,
launcher.id(),
undefined,
{ name: other.displayName(), count },
);
mg.displayMessage(
"events_display.alliance_nukes_destroyed_incoming",
MessageType.ALLIANCE_ACCEPTED,
other.id(),
undefined,
{ name: launcher.displayName(), count },
);
}
}
init(mg: Game, ticks: number): void {
if (!mg.hasPlayer(this.requestorID)) {
console.warn(
@@ -33,6 +91,12 @@ export class AllianceRequestReplyExecution implements Execution {
request.accept();
this.requestor.updateRelation(this.recipient, 100);
this.recipient.updateRelation(this.requestor, 100);
this.cancelNukesBetweenAlliedPlayers(
mg,
this.requestor,
this.recipient,
);
} else {
request.reject();
}
@@ -240,13 +240,13 @@ export class NationAllianceBehavior {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return false; // 0% chance to reject on easy
return this.random.nextInt(0, 100) < 25; // 25% chance to reject on easy
case Difficulty.Medium:
return this.random.nextInt(0, 100) < 20; // 20% chance to reject on medium
return this.random.nextInt(0, 100) < 50; // 50% chance to reject on medium
case Difficulty.Hard:
return this.random.nextInt(0, 100) < 40; // 40% chance to reject on hard
return this.random.nextInt(0, 100) < 75; // 75% chance to reject on hard
case Difficulty.Impossible:
return this.random.nextInt(0, 100) < 60; // 60% chance to reject on impossible
return true; // 100% chance to reject on impossible
default:
assertNever(difficulty);
}
@@ -6,7 +6,6 @@ import {
Player,
PlayerType,
Relation,
Team,
Tick,
} from "../../game/Game";
import { PseudoRandom } from "../../PseudoRandom";
@@ -55,6 +54,8 @@ export class NationEmojiBehavior {
) {}
maybeSendCasualEmoji() {
if (this.gameOver) return;
this.checkOverwhelmedByAttacks();
this.checkVerySmallAttack();
this.congratulateWinner();
@@ -107,60 +108,23 @@ export class NationEmojiBehavior {
// Check if game is over - send congratulations
private congratulateWinner(): void {
if (this.gameOver) return;
const winner = this.game.getWinner();
if (winner === null) return;
this.gameOver = true;
const percentToWin = this.game.config().percentageTilesOwnedToWin();
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
const isTeamGame =
this.game.config().gameConfig().gameMode === GameMode.Team;
if (isTeamGame) {
// Team game: all nations congratulate if another team won
const teamToTiles = new Map<Team, number>();
for (const player of this.game.players()) {
const team = player.team();
if (team === null) continue;
teamToTiles.set(
team,
(teamToTiles.get(team) ?? 0) + player.numTilesOwned(),
);
}
const sorted = Array.from(teamToTiles.entries()).sort(
(a, b) => b[1] - a[1],
);
if (sorted.length === 0) return;
const [winningTeam, winningTiles] = sorted[0];
const winningPercent = (winningTiles / numTilesWithoutFallout) * 100;
if (winningPercent < percentToWin) return;
this.gameOver = true;
// Don't congratulate if it's our own team
if (winningTeam === this.player.team()) return;
if (winner === this.player.team()) return;
this.sendEmoji(AllPlayers, EMOJI_CONGRATULATE);
} else {
// FFA game: The largest nation congratulates if a human player won
const sorted = this.game
.players()
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
if (sorted.length === 0) return;
const firstPlace = sorted[0];
// Check if first place has won (crossed the win threshold)
const firstPlacePercent =
(firstPlace.numTilesOwned() / numTilesWithoutFallout) * 100;
if (firstPlacePercent < percentToWin) return;
this.gameOver = true;
// Only send if first place is a human
if (firstPlace.type() !== PlayerType.Human) return;
if (typeof winner === "string") return; // It's a team, not a player
// Only the largest nation sends the congratulation
const largestNation = this.game
@@ -169,13 +133,12 @@ export class NationEmojiBehavior {
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned())[0];
if (largestNation !== this.player) return;
this.sendEmoji(firstPlace, EMOJI_CONGRATULATE);
this.sendEmoji(winner, EMOJI_CONGRATULATE);
}
}
// Brag with our crown
private brag(): void {
if (this.gameOver) return;
if (!this.random.chance(300)) return;
const sorted = this.game
+170 -52
View File
@@ -1,11 +1,14 @@
import {
Difficulty,
Game,
GameMode,
HumansVsNations,
Player,
PlayerID,
PlayerType,
Relation,
TerraNullius,
UnitType,
} from "../../game/Game";
import { TileRef } from "../../game/GameMap";
import { canBuildTransportShip } from "../../game/TransportShipUtils";
@@ -16,6 +19,7 @@ import {
calculateBoundingBoxCenter,
} from "../../Util";
import { AttackExecution } from "../AttackExecution";
import { DonateTroopsExecution } from "../DonateTroopExecution";
import { NationAllianceBehavior } from "../nation/NationAllianceBehavior";
import {
EMOJI_ASSIST_ACCEPT,
@@ -94,6 +98,16 @@ export class AiAttackBehavior {
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),
);
@@ -114,13 +128,7 @@ export class AiAttackBehavior {
}
this.game.addExecution(
new TransportShipExecution(
this.player,
this.game.owner(dst).id(),
dst,
this.player.troops() / 5,
null,
),
new TransportShipExecution(this.player, dst, this.player.troops() / 5),
);
return;
}
@@ -315,6 +323,8 @@ export class AiAttackBehavior {
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) {
@@ -323,13 +333,13 @@ export class AiAttackBehavior {
return [nuked, bots, retaliate, assist, betray, hated, weakest];
case Difficulty.Medium:
// prettier-ignore
return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island];
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];
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];
return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island, donate];
default:
assertNever(difficulty);
}
@@ -525,54 +535,67 @@ export class AiAttackBehavior {
}
private findNearestIslandEnemy(): Player | null {
const myBorder = this.player.borderTiles();
if (myBorder.size === 0) 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 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 (!p.isAlive()) return false;
if (p.borderTiles().size === 0) 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) {
const playerCenter = this.getPlayerCenter(this.player);
if (filteredPlayers.length === 0) return null;
const sortedPlayers = filteredPlayers
.map((filteredPlayer) => {
const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer);
const playerCenter = this.getPlayerCenter(this.player);
const playerCenterTile = this.game.ref(
playerCenter.x,
playerCenter.y,
);
const filteredPlayerCenterTile = this.game.ref(
filteredPlayerCenter.x,
filteredPlayerCenter.y,
);
const sortedPlayers = filteredPlayers
.map((filteredPlayer) => {
const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer);
const distance = this.game.manhattanDist(
playerCenterTile,
filteredPlayerCenterTile,
);
return { player: filteredPlayer, distance };
})
.sort((a, b) => a.distance - b.distance); // Sort by distance (ascending)
const playerCenterTile = this.game.ref(playerCenter.x, playerCenter.y);
const filteredPlayerCenterTile = this.game.ref(
filteredPlayerCenter.x,
filteredPlayerCenter.y,
);
// Select the nearest or second-nearest enemy (So our boat doesn't always run into the same warship, if there is one)
let selectedEnemy: Player | null;
if (sortedPlayers.length > 1 && this.random.chance(2)) {
selectedEnemy = sortedPlayers[1].player;
} else {
selectedEnemy = sortedPlayers[0].player;
}
const distance = this.game.manhattanDist(
playerCenterTile,
filteredPlayerCenterTile,
);
return { player: filteredPlayer, distance };
})
.sort((a, b) => a.distance - b.distance); // Sort by distance (ascending)
if (selectedEnemy !== null) {
return selectedEnemy;
// Try players in order of distance until we find one reachable by boat
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)) {
return entry.player;
}
}
return null;
}
@@ -652,12 +675,14 @@ export class AiAttackBehavior {
}
shouldAttack(other: Player | TerraNullius): boolean {
// Always attack Terra Nullius, non-humans and traitors (or if we are a bot)
if (
// Always attack Terra Nullius, non-humans and traitors
other.isPlayer() === false ||
other.type() !== PlayerType.Human ||
other.isTraitor() ||
this.player.type() === PlayerType.Bot
// 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;
}
@@ -724,6 +749,10 @@ export class AiAttackBehavior {
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);
@@ -741,13 +770,7 @@ export class AiAttackBehavior {
}
this.game.addExecution(
new TransportShipExecution(
this.player,
target.id(),
closest.y,
troops,
null,
),
new TransportShipExecution(this.player, closest.y, troops),
);
}
@@ -771,4 +794,99 @@ export class AiAttackBehavior {
this.botAttackTroopsSent += troops;
return troops;
}
private donateTroops(): boolean {
// Only donate in team games
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
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;
}
}