Cleanup nations (Part 2) 🧹 (#2647)

## Description:

1. Moved the currently very small betrayal logic from `AiAttackBehavior`
to `NationAllianceBehavior` because it makes more sense to have it
there.

3. Very small bugfix in `AiAttackBehavior::shouldAttack()`: the numbers
in the two `random.chance` calls were the wrong way round.

4. `NationExecution` was quite big and a lot of it was about MIRVs. So I
moved all the MIRV logic to the new `NationMIRVBehavior`.

5. `emoji()` and `maybeSendEmoji()` did not really fit in
`AiAttackBehavior`. So I moved it to the new `NationEmojiBehavior` (and
did some renaming for clarity). I'm planning to extend that class in a
future PR.

2. Reordered methods in `AiAttackBehavior` to easily find related
methods.
6. Reordered methods in `NationExecution` to easily find related
methods.

## 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:
FloPinguin
2025-12-20 04:08:37 +01:00
committed by GitHub
parent 4e8aa1f066
commit af1e05961c
5 changed files with 844 additions and 748 deletions
File diff suppressed because it is too large Load Diff
@@ -243,4 +243,27 @@ export class NationAllianceBehavior {
assertNever(difficulty);
}
}
// 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 {
if (
this.player.isAlliedWith(otherPlayer) &&
this.player.troops() >= otherPlayer.troops() * 10
) {
this.betray(otherPlayer);
return true;
}
return false;
}
private betray(target: Player): void {
const alliance = this.player.allianceWith(target);
if (!alliance) return;
this.player.breakAlliance(alliance);
}
}
@@ -0,0 +1,52 @@
import { Game, Player, PlayerType, Tick } from "../../game/Game";
import { PseudoRandom } from "../../PseudoRandom";
import { flattenedEmojiTable } from "../../Util";
import { EmojiExecution } from "../EmojiExecution";
const emojiId = (e: (typeof flattenedEmojiTable)[number]) =>
flattenedEmojiTable.indexOf(e);
export const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(
emojiId,
);
export const EMOJI_ASSIST_RELATION_TOO_LOW = (["🥱", "🤦‍♂️"] as const).map(
emojiId,
);
export const EMOJI_ASSIST_TARGET_ME = (["🥺", "💀"] as const).map(emojiId);
export const EMOJI_ASSIST_TARGET_ALLY = (["🕊️", "👎"] as const).map(emojiId);
export const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId);
export class NationEmojiBehavior {
private readonly lastEmojiSent = new Map<Player, Tick>();
constructor(
private random: PseudoRandom,
private game: Game,
private player: Player,
) {}
sendEmoji(player: Player, emojisList: number[]) {
if (player.type() !== PlayerType.Human) return;
this.game.addExecution(
new EmojiExecution(
this.player,
player.id(),
this.random.randElement(emojisList),
),
);
}
maybeSendHeckleEmoji(enemy: Player) {
if (this.player.type() === PlayerType.Bot) return;
if (enemy.type() !== PlayerType.Human) return;
const lastSent = this.lastEmojiSent.get(enemy) ?? -300;
if (this.game.ticks() - lastSent <= 300) return;
this.lastEmojiSent.set(enemy, this.game.ticks());
this.game.addExecution(
new EmojiExecution(
this.player,
enemy.id(),
this.random.randElement(EMOJI_HECKLE),
),
);
}
}
@@ -0,0 +1,267 @@
import {
Game,
Gold,
Player,
PlayerType,
Tick,
UnitType,
} from "../../game/Game";
import { TileRef } from "../../game/GameMap";
import { PseudoRandom } from "../../PseudoRandom";
import { MirvExecution } from "../MIRVExecution";
import { calculateTerritoryCenter } from "../Util";
import { NationEmojiBehavior } from "./NationEmojiBehavior";
export class NationMIRVBehavior {
private readonly lastMIRVSent: [Tick, TileRef][] = [];
/** Ticks until MIRV can be attempted again */
private static readonly MIRV_COOLDOWN_TICKS = 20;
/** Odds of aborting a MIRV attempt */
private static readonly MIRV_HESITATION_ODDS = 7;
/** Threshold for team victory denial */
private static readonly VICTORY_DENIAL_TEAM_THRESHOLD = 0.8;
/** Threshold for individual victory denial */
private static readonly VICTORY_DENIAL_INDIVIDUAL_THRESHOLD = 0.65;
/** Multiplier for steamroll city gap threshold */
private static readonly STEAMROLL_CITY_GAP_MULTIPLIER = 1.3;
/** Minimum city count for leader to trigger steam roll detection */
private static readonly STEAMROLL_MIN_LEADER_CITIES = 10;
constructor(
private random: PseudoRandom,
private game: Game,
private player: Player,
private emojiBehavior: NationEmojiBehavior,
) {}
considerMIRV(): boolean {
if (this.player === null) throw new Error("not initialized");
if (this.player.units(UnitType.MissileSilo).length === 0) {
return false;
}
if (this.player.gold() < this.cost(UnitType.MIRV)) {
return false;
}
this.removeOldMIRVEvents();
if (this.lastMIRVSent.length > 0) {
return false;
}
if (this.random.chance(NationMIRVBehavior.MIRV_HESITATION_ODDS)) {
this.triggerMIRVCooldown();
return false;
}
const inboundMIRVSender = this.selectCounterMirvTarget();
if (inboundMIRVSender) {
this.maybeSendMIRV(inboundMIRVSender);
return true;
}
const victoryDenialTarget = this.selectVictoryDenialTarget();
if (victoryDenialTarget) {
this.maybeSendMIRV(victoryDenialTarget);
return true;
}
const steamrollStopTarget = this.selectSteamrollStopTarget();
if (steamrollStopTarget) {
this.maybeSendMIRV(steamrollStopTarget);
return true;
}
return false;
}
// MIRV Strategy Methods
private selectCounterMirvTarget(): Player | null {
if (this.player === null) throw new Error("not initialized");
const attackers = this.getValidMirvTargetPlayers().filter((p) =>
this.isInboundMIRVFrom(p),
);
if (attackers.length === 0) return null;
attackers.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
return attackers[0];
}
private selectVictoryDenialTarget(): Player | null {
if (this.player === null) throw new Error("not initialized");
const totalLand = this.game.numLandTiles();
if (totalLand === 0) return null;
let best: { p: Player; severity: number } | null = null;
for (const p of this.getValidMirvTargetPlayers()) {
let severity = 0;
const team = p.team();
if (team !== null) {
const teamMembers = this.game
.players()
.filter((x) => x.team() === team && x.isPlayer());
const teamTerritory = teamMembers
.map((x) => x.numTilesOwned())
.reduce((a, b) => a + b, 0);
const teamShare = teamTerritory / totalLand;
if (teamShare >= NationMIRVBehavior.VICTORY_DENIAL_TEAM_THRESHOLD) {
// Only consider the largest team member as the target when team exceeds threshold
let largestMember: Player | null = null;
let largestTiles = -1;
for (const member of teamMembers) {
const tiles = member.numTilesOwned();
if (tiles > largestTiles) {
largestTiles = tiles;
largestMember = member;
}
}
if (largestMember === p) {
severity = teamShare;
} else {
severity = 0; // Skip non-largest members
}
}
} else {
const share = p.numTilesOwned() / totalLand;
if (share >= NationMIRVBehavior.VICTORY_DENIAL_INDIVIDUAL_THRESHOLD)
severity = share;
}
if (severity > 0) {
if (best === null || severity > best.severity) best = { p, severity };
}
}
return best ? best.p : null;
}
private selectSteamrollStopTarget(): Player | null {
if (this.player === null) throw new Error("not initialized");
const validTargets = this.getValidMirvTargetPlayers();
if (validTargets.length === 0) return null;
const allPlayers = this.game
.players()
.filter((p) => p.isPlayer())
.map((p) => ({ p, cityCount: this.countCities(p) }))
.sort((a, b) => b.cityCount - a.cityCount);
if (allPlayers.length < 2) return null;
const topPlayer = allPlayers[0];
if (topPlayer.cityCount <= NationMIRVBehavior.STEAMROLL_MIN_LEADER_CITIES)
return null;
const secondHighest = allPlayers[1].cityCount;
const threshold =
secondHighest * NationMIRVBehavior.STEAMROLL_CITY_GAP_MULTIPLIER;
if (topPlayer.cityCount >= threshold) {
return validTargets.some((p) => p === topPlayer.p) ? topPlayer.p : null;
}
return null;
}
// MIRV Helper Methods
private mirvTargetsCache: {
tick: number;
players: Player[];
} | null = null;
private getValidMirvTargetPlayers(): Player[] {
const MIRV_TARGETS_CACHE_TICKS = 2 * 10; // 2 seconds
if (this.player === null) throw new Error("not initialized");
if (
this.mirvTargetsCache &&
this.game.ticks() - this.mirvTargetsCache.tick < MIRV_TARGETS_CACHE_TICKS
) {
return this.mirvTargetsCache.players;
}
const players = this.game.players().filter((p) => {
return (
p !== this.player &&
p.isPlayer() &&
p.type() !== PlayerType.Bot &&
!this.player!.isOnSameTeam(p)
);
});
this.mirvTargetsCache = { tick: this.game.ticks(), players };
return players;
}
private isInboundMIRVFrom(attacker: Player): boolean {
if (this.player === null) throw new Error("not initialized");
const enemyMirvs = attacker.units(UnitType.MIRV);
for (const mirv of enemyMirvs) {
const dst = mirv.targetTile();
if (!dst) continue;
if (!this.game.hasOwner(dst)) continue;
const owner = this.game.owner(dst);
if (owner === this.player) {
return true;
}
}
return false;
}
// MIRV Execution Methods
private maybeSendMIRV(enemy: Player): void {
if (this.player === null) throw new Error("not initialized");
this.emojiBehavior.maybeSendHeckleEmoji(enemy);
const centerTile = this.calculateTerritoryCenter(enemy);
if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) {
this.sendMIRV(centerTile);
return;
}
}
private sendMIRV(tile: TileRef): void {
if (this.player === null) throw new Error("not initialized");
this.triggerMIRVCooldown(tile);
this.game.addExecution(new MirvExecution(this.player, tile));
}
private triggerMIRVCooldown(tile?: TileRef): void {
if (this.player === null) throw new Error("not initialized");
this.removeOldMIRVEvents();
const tick = this.game.ticks();
// Use provided tile or any tile from player's territory for cooldown tracking
const cooldownTile =
tile ?? Array.from(this.player.tiles())[0] ?? this.game.ref(0, 0);
this.lastMIRVSent.push([tick, cooldownTile]);
}
private removeOldMIRVEvents() {
const maxAge = NationMIRVBehavior.MIRV_COOLDOWN_TICKS;
const tick = this.game.ticks();
while (
this.lastMIRVSent.length > 0 &&
this.lastMIRVSent[0][0] + maxAge <= tick
) {
this.lastMIRVSent.shift();
}
}
private countCities(p: Player): number {
return p.unitCount(UnitType.City);
}
private calculateTerritoryCenter(target: Player): TileRef | null {
return calculateTerritoryCenter(this.game, target);
}
private cost(type: UnitType): Gold {
if (this.player === null) throw new Error("not initialized");
return this.game.unitInfo(type).cost(this.game, this.player);
}
}
+145 -175
View File
@@ -5,31 +5,27 @@ import {
PlayerType,
Relation,
TerraNullius,
Tick,
} from "../../game/Game";
import { PseudoRandom } from "../../PseudoRandom";
import {
assertNever,
boundingBoxCenter,
calculateBoundingBoxCenter,
flattenedEmojiTable,
} from "../../Util";
import { AttackExecution } from "../AttackExecution";
import { EmojiExecution } from "../EmojiExecution";
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";
const emojiId = (e: (typeof flattenedEmojiTable)[number]) =>
flattenedEmojiTable.indexOf(e);
const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(emojiId);
const EMOJI_RELATION_TOO_LOW = (["🥱", "🤦‍♂️"] as const).map(emojiId);
const EMOJI_TARGET_ME = (["🥺", "💀"] as const).map(emojiId);
const EMOJI_TARGET_ALLY = (["🕊️", "👎"] as const).map(emojiId);
const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId);
export class AiAttackBehavior {
private botAttackTroopsSent: number = 0;
private readonly lastEmojiSent = new Map<Player, Tick>();
constructor(
private random: PseudoRandom,
@@ -38,103 +34,32 @@ export class AiAttackBehavior {
private triggerRatio: number,
private reserveRatio: number,
private expandRatio: number,
private allianceBehavior?: NationAllianceBehavior,
private emojiBehavior?: NationEmojiBehavior,
) {}
private emoji(player: Player, emoji: number) {
if (player.type() !== PlayerType.Human) return;
this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji));
}
// Prevent attacking of humans on lower difficulties
private shouldAttack(other: Player | TerraNullius): boolean {
// Always attack Terra Nullius, non-humans and traitors
if (
other.isPlayer() === false ||
other.type() !== PlayerType.Human ||
other.isTraitor()
) {
return true;
}
const { difficulty } = this.game.config().gameConfig();
if (difficulty === Difficulty.Easy && this.random.chance(4)) {
return false;
}
if (difficulty === Difficulty.Medium && this.random.chance(2)) {
return false;
}
return true;
}
private betray(target: Player): void {
const alliance = this.player.allianceWith(target);
if (!alliance) return;
this.player.breakAlliance(alliance);
}
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;
}
private 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;
}
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;
}
assistAllies() {
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.emoji(ally, this.random.randElement(EMOJI_RELATION_TOO_LOW));
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_RELATION_TOO_LOW);
continue;
}
for (const target of ally.targets()) {
if (target === this.player) {
this.emoji(ally, this.random.randElement(EMOJI_TARGET_ME));
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ME);
continue;
}
if (this.player.isFriendly(target)) {
this.emoji(ally, this.random.randElement(EMOJI_TARGET_ALLY));
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ALLY);
continue;
}
// All checks passed, assist them
this.player.updateRelation(ally, -20);
this.sendAttack(target);
this.emoji(ally, this.random.randElement(EMOJI_ASSIST_ACCEPT));
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_ACCEPT);
return;
}
}
@@ -193,6 +118,73 @@ export class AiAttackBehavior {
}
}
// TODO: Nuke the crown if it's far enough ahead of everybody else (based on difficulty)
findBestNukeTarget(borderingEnemies: Player[]): Player | null {
// Retaliate against incoming attacks (Most important!)
const incomingAttackPlayer = this.findIncomingAttackPlayer();
if (incomingAttackPlayer) {
return incomingAttackPlayer;
}
// Find the most hated player with hostile relation
const mostHated = this.player.allRelationsSorted()[0];
if (
mostHated !== undefined &&
mostHated.relation === Relation.Hostile &&
this.player.isFriendly(mostHated.player) === false
) {
return mostHated.player;
}
// Find the weakest player
if (borderingEnemies.length > 0) {
return borderingEnemies[0];
}
// If we don't have bordering enemies, find someone on an island next to us
if (borderingEnemies.length === 0) {
const nearestIslandEnemy = this.findNearestIslandEnemy();
if (nearestIslandEnemy) {
return nearestIslandEnemy;
}
}
return null;
}
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;
}
private 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
attackBots(): boolean {
@@ -242,20 +234,12 @@ export class AiAttackBehavior {
}
}
// 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
maybeBetrayAndAttack(borderingFriends: Player[]): boolean {
if (this.allianceBehavior === undefined) throw new Error("not initialized");
if (borderingFriends.length > 0) {
for (const friend of borderingFriends) {
if (
this.player.isAlliedWith(friend) &&
this.player.troops() >= friend.troops() * 10
) {
this.betray(friend);
if (this.allianceBehavior.maybeBetray(friend)) {
this.sendAttack(friend, true);
return true;
}
@@ -264,45 +248,19 @@ export class AiAttackBehavior {
return false;
}
// TODO: Nuke the crown if it's far enough ahead of everybody else (based on difficulty)
findBestNukeTarget(borderingEnemies: Player[]): Player | null {
// Retaliate against incoming attacks (Most important!)
const incomingAttackPlayer = this.findIncomingAttackPlayer();
if (incomingAttackPlayer) {
return incomingAttackPlayer;
}
// Find the most hated player with hostile relation
const mostHated = this.player.allRelationsSorted()[0];
if (
mostHated !== undefined &&
mostHated.relation === Relation.Hostile &&
this.player.isFriendly(mostHated.player) === false
) {
return mostHated.player;
}
// Find the weakest player
if (borderingEnemies.length > 0) {
return borderingEnemies[0];
}
// If we don't have bordering enemies, find someone on an island next to us
if (borderingEnemies.length === 0) {
const nearestIslandEnemy = this.findNearestIslandEnemy();
if (nearestIslandEnemy) {
return nearestIslandEnemy;
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 null;
}
getPlayerCenter(player: Player) {
if (player.largestClusterBoundingBox) {
return boundingBoxCenter(player.largestClusterBoundingBox);
}
return calculateBoundingBoxCenter(this.game, player.borderTiles());
return false;
}
findNearestIslandEnemy(): Player | null {
@@ -357,6 +315,13 @@ export class AiAttackBehavior {
return null;
}
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;
@@ -396,19 +361,14 @@ export class AiAttackBehavior {
}
}
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;
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) {
@@ -431,6 +391,27 @@ export class AiAttackBehavior {
}
}
// Prevent attacking of humans on lower difficulties
private shouldAttack(other: Player | TerraNullius): boolean {
// Always attack Terra Nullius, non-humans and traitors
if (
other.isPlayer() === false ||
other.type() !== PlayerType.Human ||
other.isTraitor()
) {
return true;
}
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;
}
sendLandAttack(target: Player | TerraNullius) {
const maxTroops = this.game.config().maxTroops(this.player);
const reserveRatio = target.isPlayer()
@@ -464,26 +445,27 @@ export class AiAttackBehavior {
),
);
if (target.isPlayer()) {
this.maybeSendEmoji(target);
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
if (this.emojiBehavior === undefined) throw new Error("not initialized");
this.emojiBehavior.maybeSendHeckleEmoji(target);
}
}
sendBoatAttack(other: Player) {
sendBoatAttack(target: Player) {
const closest = closestTwoTiles(
this.game,
Array.from(this.player.borderTiles()).filter((t) =>
this.game.isOceanShore(t),
),
Array.from(other.borderTiles()).filter((t) => this.game.isOceanShore(t)),
Array.from(target.borderTiles()).filter((t) => this.game.isOceanShore(t)),
);
if (closest === null) {
return;
}
let troops;
if (other.type() === PlayerType.Bot) {
troops = this.calculateBotAttackTroops(other, this.player.troops() / 5);
if (target.type() === PlayerType.Bot) {
troops = this.calculateBotAttackTroops(target, this.player.troops() / 5);
} else {
troops = this.player.troops() / 5;
}
@@ -495,14 +477,17 @@ export class AiAttackBehavior {
this.game.addExecution(
new TransportShipExecution(
this.player,
other.id(),
target.id(),
closest.y,
troops,
null,
),
);
this.maybeSendEmoji(other);
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
if (this.emojiBehavior === undefined) throw new Error("not initialized");
this.emojiBehavior.maybeSendHeckleEmoji(target);
}
}
calculateBotAttackTroops(target: Player, maxTroops: number): number {
@@ -525,19 +510,4 @@ export class AiAttackBehavior {
this.botAttackTroopsSent += troops;
return troops;
}
maybeSendEmoji(enemy: Player) {
if (this.player.type() === PlayerType.Bot) return;
if (enemy.type() !== PlayerType.Human) return;
const lastSent = this.lastEmojiSent.get(enemy) ?? -300;
if (this.game.ticks() - lastSent <= 300) return;
this.lastEmojiSent.set(enemy, this.game.ticks());
this.game.addExecution(
new EmojiExecution(
this.player,
enemy.id(),
this.random.randElement(EMOJI_HECKLE),
),
);
}
}