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 playerNeighbors = this.player.nearby(); const borderingPlayerSet = new Set( 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; } } 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(); 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 (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.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.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; } private sendLandAttack(target: Player | TerraNullius): boolean { 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 false; } 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(), ), ); 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; } 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 false; } 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), ); 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; } }