diff --git a/jest.config.ts b/jest.config.ts index d1f6e3c7a..52d147124 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -17,7 +17,7 @@ export default { coverageThreshold: { global: { statements: 21.5, - branches: 17.0, + branches: 16.5, lines: 22.0, functions: 20.5, }, diff --git a/src/client/graphics/layers/EmojiTable.ts b/src/client/graphics/layers/EmojiTable.ts index aefec7ade..ca87f009b 100644 --- a/src/client/graphics/layers/EmojiTable.ts +++ b/src/client/graphics/layers/EmojiTable.ts @@ -4,7 +4,7 @@ import { EventBus } from "../../../core/EventBus"; import { AllPlayers } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl"; -import { emojiTable, flattenedEmojiTable } from "../../../core/Util"; +import { Emoji, emojiTable, flattenedEmojiTable } from "../../../core/Util"; import { CloseViewEvent, ShowEmojiMenuEvent } from "../../InputHandler"; import { SendEmojiIntentEvent } from "../../Transport"; import { TransformHandler } from "../TransformHandler"; @@ -42,7 +42,7 @@ export class EmojiTable extends LitElement { eventBus.emit( new SendEmojiIntentEvent( recipient, - flattenedEmojiTable.indexOf(emoji), + flattenedEmojiTable.indexOf(emoji as Emoji), ), ); this.hideTable(); diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 65d4bebf1..d3c03bc48 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -20,7 +20,7 @@ import { } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; -import { flattenedEmojiTable } from "../../../core/Util"; +import { Emoji, flattenedEmojiTable } from "../../../core/Util"; import { actionButton } from "../../components/ui/ActionButton"; import "../../components/ui/Divider"; import Countries from "../../data/countries.json"; @@ -218,12 +218,15 @@ export class PlayerPanel extends LitElement implements Layer { this.eventBus.emit( new SendEmojiIntentEvent( AllPlayers, - flattenedEmojiTable.indexOf(emoji), + flattenedEmojiTable.indexOf(emoji as Emoji), ), ); } else { this.eventBus.emit( - new SendEmojiIntentEvent(other, flattenedEmojiTable.indexOf(emoji)), + new SendEmojiIntentEvent( + other, + flattenedEmojiTable.indexOf(emoji as Emoji), + ), ); } this.emojiTable.hideTable(); diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index df95334d8..9e1fe21ef 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -2,7 +2,7 @@ import { Config } from "../../../core/configuration/Config"; import { AllPlayers, PlayerActions, UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; -import { flattenedEmojiTable } from "../../../core/Util"; +import { Emoji, flattenedEmojiTable } from "../../../core/Util"; import { renderNumber, translateText } from "../../Utils"; import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu"; import { ChatIntegration } from "./ChatIntegration"; @@ -271,7 +271,7 @@ const infoEmojiElement: MenuElement = { : params.selected; params.playerActionHandler.handleEmoji( targetPlayer!, - flattenedEmojiTable.indexOf(emoji), + flattenedEmojiTable.indexOf(emoji as Emoji), ); params.emojiTable.hideTable(); }); diff --git a/src/core/Util.ts b/src/core/Util.ts index 8d30c1a23..d74d27b11 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -257,7 +257,7 @@ export function createRandomName( return randomName; } -export const emojiTable: string[][] = [ +export const emojiTable = [ ["😀", "😊", "🥰", "😇", "😎"], ["😞", "🥺", "😭", "😱", "😡"], ["😈", "🤡", "🖕", "🥱", "🤦‍♂️"], @@ -269,9 +269,11 @@ export const emojiTable: string[][] = [ ["⬅️", "🎯", "➡️", "🥈", "🥉"], ["↙️", "⬇️", "↘️", "❤️", "💔"], ["💰", "⚓", "⛵", "🏡", "🛡️"], -]; +] as const; // 2d to 1d array -export const flattenedEmojiTable: string[] = emojiTable.flat(); +export const flattenedEmojiTable = emojiTable.flat(); + +export type Emoji = (typeof flattenedEmojiTable)[number]; /** * JSON.stringify replacer function that converts bigint values to strings. diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index 8535a9b81..69f1f4f29 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -20,8 +20,8 @@ export class BotExecution implements Execution { this.random = new PseudoRandom(simpleHash(bot.id())); this.attackRate = this.random.nextInt(40, 80); this.attackTick = this.random.nextInt(0, this.attackRate); - this.triggerRatio = this.random.nextInt(60, 90) / 100; - this.reserveRatio = this.random.nextInt(20, 30) / 100; + this.triggerRatio = this.random.nextInt(50, 60) / 100; + this.reserveRatio = this.random.nextInt(30, 40) / 100; this.expandRatio = this.random.nextInt(10, 20) / 100; } diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 7326375f1..aaefa84de 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -1,6 +1,5 @@ import { Cell, - Difficulty, Execution, Game, Gold, @@ -14,17 +13,18 @@ import { Unit, UnitType, } from "../game/Game"; -import { euclDistFN, manhattanDistFN, TileRef } from "../game/GameMap"; +import { TileRef, euclDistFN, manhattanDistFN } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; -import { calculateBoundingBox, flattenedEmojiTable, simpleHash } from "../Util"; +import { calculateBoundingBox, simpleHash } from "../Util"; import { ConstructionExecution } from "./ConstructionExecution"; import { EmojiExecution } from "./EmojiExecution"; +import { structureSpawnTileValue } from "./nation/structureSpawnTileValue"; import { NukeExecution } from "./NukeExecution"; import { SpawnExecution } from "./SpawnExecution"; import { TransportShipExecution } from "./TransportShipExecution"; import { closestTwoTiles } from "./Util"; -import { BotBehavior } from "./utils/BotBehavior"; +import { BotBehavior, EMOJI_HECKLE } from "./utils/BotBehavior"; export class FakeHumanExecution implements Execution { private active = true; @@ -39,10 +39,9 @@ export class FakeHumanExecution implements Execution { private reserveRatio: number; private expandRatio: number; - private lastEmojiSent = new Map(); - private lastNukeSent: [Tick, TileRef][] = []; - private embargoMalusApplied = new Set(); - private heckleEmoji: number[]; + private readonly lastEmojiSent = new Map(); + private readonly lastNukeSent: [Tick, TileRef][] = []; + private readonly embargoMalusApplied = new Set(); constructor( gameID: GameID, @@ -53,10 +52,9 @@ export class FakeHumanExecution implements Execution { ); this.attackRate = this.random.nextInt(40, 80); this.attackTick = this.random.nextInt(0, this.attackRate); - this.triggerRatio = this.random.nextInt(60, 90) / 100; - this.reserveRatio = this.random.nextInt(30, 60) / 100; - this.expandRatio = this.random.nextInt(15, 25) / 100; - this.heckleEmoji = ["🤡", "😡"].map((e) => flattenedEmojiTable.indexOf(e)); + this.triggerRatio = this.random.nextInt(50, 60) / 100; + this.reserveRatio = this.random.nextInt(30, 40) / 100; + this.expandRatio = this.random.nextInt(10, 20) / 100; } init(mg: Game) { @@ -224,23 +222,12 @@ export class FakeHumanExecution implements Execution { const toAlly = this.random.randElement(enemies); if (this.player.canSendAllianceRequest(toAlly)) { this.player.createAllianceRequest(toAlly); - return; } } - // 50-50 attack weakest player vs random player - const toAttack = this.random.chance(2) - ? enemies[0] - : this.random.randElement(enemies); - - if (this.shouldAttack(toAttack)) { - this.behavior.sendAttack(toAttack); - return; - } - this.behavior.forgetOldEnemies(); this.behavior.assistAllies(); - const enemy = this.behavior.selectEnemy(); + const enemy = this.behavior.selectEnemy(enemies); if (!enemy) return; this.maybeSendEmoji(enemy); this.maybeSendNuke(enemy); @@ -251,53 +238,6 @@ export class FakeHumanExecution implements Execution { } } - private shouldAttack(other: Player): boolean { - if (this.player === null) throw new Error("not initialized"); - - if (this.player.isOnSameTeam(other)) { - return false; - } - - const shouldAttack = this.attackChance(other); - - // Consider betrayal for allies - if (shouldAttack && this.player.isAlliedWith(other)) { - return this.maybeConsiderBetrayal(other); - } - - return shouldAttack; - } - - private attackChance(other: Player): boolean { - if (this.player === null) throw new Error("not initialized"); - - if (this.player.isAlliedWith(other)) { - return this.shouldDiscourageAttack(other) - ? this.random.chance(200) - : this.random.chance(50); - } else { - return this.shouldDiscourageAttack(other) ? this.random.chance(4) : true; - } - } - - private shouldDiscourageAttack(other: Player) { - if (other.isTraitor()) { - return false; - } - const difficulty = this.mg.config().gameConfig().difficulty; - if ( - difficulty === Difficulty.Hard || - difficulty === Difficulty.Impossible - ) { - return false; - } - if (other.type() !== PlayerType.Human) { - return false; - } - // Only discourage attacks on Humans who are not traitors on easy or medium difficulty. - return true; - } - private maybeSendEmoji(enemy: Player) { if (this.player === null) throw new Error("not initialized"); if (enemy.type() !== PlayerType.Human) return; @@ -308,7 +248,7 @@ export class FakeHumanExecution implements Execution { new EmojiExecution( this.player, enemy.id(), - this.random.randElement(this.heckleEmoji), + this.random.randElement(EMOJI_HECKLE), ), ); } @@ -460,6 +400,8 @@ export class FakeHumanExecution implements Execution { this.maybeSpawnStructure(UnitType.Port) || this.maybeSpawnWarship() || this.maybeSpawnStructure(UnitType.Factory) || + this.maybeSpawnStructure(UnitType.DefensePost) || + this.maybeSpawnStructure(UnitType.SAMLauncher) || this.maybeSpawnStructure(UnitType.MissileSilo) ); } @@ -486,7 +428,8 @@ export class FakeHumanExecution implements Execution { } private structureSpawnTile(type: UnitType): TileRef | null { - if (this.player === null) throw new Error("not initialized"); + if (this.mg === undefined) throw new Error("Not initialized"); + if (this.player === null) throw new Error("Not initialized"); const tiles = type === UnitType.Port ? Array.from(this.player.borderTiles()).filter((t) => @@ -494,7 +437,7 @@ export class FakeHumanExecution implements Execution { ) : Array.from(this.player.tiles()); if (tiles.length === 0) return null; - const valueFunction = this.structureSpawnTileValue(type); + const valueFunction = structureSpawnTileValue(this.mg, this.player, type); let bestTile: TileRef | null = null; let bestValue = 0; const sampledTiles = this.arraySampler(tiles); @@ -524,69 +467,6 @@ export class FakeHumanExecution implements Execution { } } - private structureSpawnTileValue(type: UnitType): (tile: TileRef) => number { - if (this.player === null) throw new Error("not initialized"); - const borderTiles = this.player.borderTiles(); - const mg = this.mg; - const otherUnits = this.player.units(type); - // Prefer spacing structures out of atom bomb range - const borderSpacing = this.mg - .config() - .nukeMagnitudes(UnitType.AtomBomb).outer; - const structureSpacing = borderSpacing * 2; - switch (type) { - case UnitType.Port: - return (tile) => { - let w = 0; - - // Prefer to be far away from other structures of the same type - const otherTiles: Set = new Set( - otherUnits.map((u) => u.tile()), - ); - otherTiles.delete(tile); - const closestOther = closestTwoTiles(mg, otherTiles, [tile]); - if (closestOther !== null) { - const d = mg.manhattanDist(closestOther.x, tile); - w += Math.min(d, structureSpacing); - } - - return w; - }; - case UnitType.City: - case UnitType.Factory: - case UnitType.MissileSilo: - return (tile) => { - let w = 0; - - // Prefer higher elevations - w += mg.magnitude(tile); - - // Prefer to be away from the border - const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); - if (closestBorder !== null) { - const d = mg.manhattanDist(closestBorder.x, tile); - w += Math.min(d, borderSpacing); - } - - // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set( - otherUnits.map((u) => u.tile()), - ); - otherTiles.delete(tile); - const closestOther = closestTwoTiles(mg, otherTiles, [tile]); - if (closestOther !== null) { - const d = mg.manhattanDist(closestOther.x, tile); - w += Math.min(d, structureSpacing); - } - - // TODO: Cities and factories should consider train range limits - return w; - }; - default: - throw new Error(`Value function not implemented for ${type}`); - } - } - private maybeSpawnWarship(): boolean { if (this.player === null) throw new Error("not initialized"); if (!this.random.chance(50)) { diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index d44522cea..baabf0fb2 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -50,7 +50,8 @@ class SAMTargetingSystem { private isInRange(tile: TileRef) { const samTile = this.sam.tile(); - const rangeSquared = this.mg.config().defaultSamRange() ** 2; + const range = this.mg.config().defaultSamRange(); + const rangeSquared = range * range; return this.mg.euclideanDistSquared(samTile, tile) <= rangeSquared; } diff --git a/src/core/execution/nation/structureSpawnTileValue.ts b/src/core/execution/nation/structureSpawnTileValue.ts new file mode 100644 index 000000000..5c8edae45 --- /dev/null +++ b/src/core/execution/nation/structureSpawnTileValue.ts @@ -0,0 +1,158 @@ +import { Game, Player, Relation, UnitType } from "../../game/Game"; +import { TileRef } from "../../game/GameMap"; +import { closestTwoTiles } from "../Util"; + +export function structureSpawnTileValue( + mg: Game, + player: Player, + type: UnitType, +): (tile: TileRef) => number { + const borderTiles = player.borderTiles(); + const otherUnits = player.units(type); + // Prefer spacing structures out of atom bomb range + const borderSpacing = mg.config().nukeMagnitudes(UnitType.AtomBomb).outer; + const structureSpacing = borderSpacing * 2; + switch (type) { + case UnitType.City: + case UnitType.Factory: + case UnitType.MissileSilo: { + return (tile) => { + let w = 0; + + // Prefer higher elevations + w += mg.magnitude(tile); + + // Prefer to be away from the border + const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); + if (closestBorder !== null) { + const d = mg.manhattanDist(closestBorder.x, tile); + w += Math.min(d, borderSpacing); + } + + // Prefer to be away from other structures of the same type + const otherTiles: Set = new Set( + otherUnits.map((u) => u.tile()), + ); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(mg, otherTiles, [tile]); + if (closestOther !== null) { + const d = mg.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + // TODO: Cities and factories should consider train range limits + return w; + }; + } + case UnitType.Port: { + return (tile) => { + let w = 0; + + // Prefer to be away from other structures of the same type + const otherTiles: Set = new Set( + otherUnits.map((u) => u.tile()), + ); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(mg, otherTiles, [tile]); + if (closestOther !== null) { + const d = mg.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + return w; + }; + } + case UnitType.DefensePost: { + return (tile) => { + let w = 0; + + // Prefer higher elevations + w += mg.magnitude(tile); + + const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); + if (closestBorder !== null) { + // Prefer to be borderSpacing tiles from the border + const d = mg.manhattanDist(closestBorder.x, tile); + w += Math.max(0, borderSpacing - Math.abs(borderSpacing - d)); + + // Prefer adjacent players who are hostile + const neighbors: Set = new Set(); + for (const tile of mg.neighbors(closestBorder.x)) { + if (!mg.isLand(tile)) continue; + const id = mg.ownerID(tile); + if (id === player.smallID()) continue; + const neighbor = mg.playerBySmallID(id); + if (!neighbor.isPlayer()) continue; + neighbors.add(neighbor); + } + for (const neighbor of neighbors) { + w += + borderSpacing * (Relation.Friendly - player.relation(neighbor)); + } + } + + // Prefer to be away from other structures of the same type + const otherTiles: Set = new Set( + otherUnits.map((u) => u.tile()), + ); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(mg, otherTiles, [tile]); + if (closestOther !== null) { + const d = mg.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + return w; + }; + } + case UnitType.SAMLauncher: { + const protectTiles: Set = new Set(); + for (const unit of player.units()) { + switch (unit.type()) { + case UnitType.City: + case UnitType.Factory: + case UnitType.MissileSilo: + case UnitType.Port: + protectTiles.add(unit.tile()); + } + } + const range = mg.config().defaultSamRange(); + const rangeSquared = range * range; + return (tile) => { + let w = 0; + + // Prefer higher elevations + w += mg.magnitude(tile); + + // Prefer to be away from the border + const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); + if (closestBorder !== null) { + const d = mg.manhattanDist(closestBorder.x, tile); + w += Math.min(d, borderSpacing); + } + + // Prefer to be away from other structures of the same type + const otherTiles: Set = new Set( + otherUnits.map((u) => u.tile()), + ); + otherTiles.delete(tile); + const closestOther = closestTwoTiles(mg, otherTiles, [tile]); + if (closestOther !== null) { + const d = mg.manhattanDist(closestOther.x, tile); + w += Math.min(d, structureSpacing); + } + + // Prefer to be in range of other structures + for (const maybeProtected of protectTiles) { + const distanceSquared = mg.euclideanDistSquared(tile, maybeProtected); + if (distanceSquared > rangeSquared) continue; + w += structureSpacing; + } + + return w; + }; + } + default: + throw new Error(`Value function not implemented for ${type}`); + } +} diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index 91c7e03a9..2fd64cc71 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -1,5 +1,6 @@ import { AllianceRequest, + Difficulty, Game, Player, PlayerType, @@ -13,11 +14,17 @@ import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecuti import { AttackExecution } from "../AttackExecution"; import { EmojiExecution } from "../EmojiExecution"; +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); +export const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId); + export class BotBehavior { private enemy: Player | null = null; - private enemyUpdated: Tick; - - private assistAcceptEmoji = flattenedEmojiTable.indexOf("👍"); + private enemyUpdated: Tick | undefined; constructor( private random: PseudoRandom, @@ -65,23 +72,80 @@ export class BotBehavior { this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji)); } - private setNewEnemy(newEnemy: Player | null) { + private setNewEnemy(newEnemy: Player | null, force = false) { + if (newEnemy !== null && !force && !this.shouldAttack(newEnemy)) return; this.enemy = newEnemy; this.enemyUpdated = this.game.ticks(); } + private shouldAttack(other: Player): boolean { + if (this.player === null) throw new Error("not initialized"); + if (this.player.isOnSameTeam(other)) { + return false; + } + const shouldAttack = this.attackChance(other); + if (shouldAttack && this.player.isAlliedWith(other)) { + this.betray(other); + return true; + } + return shouldAttack; + } + + private betray(target: Player): void { + if (this.player === null) throw new Error("not initialized"); + const alliance = this.player.allianceWith(target); + if (!alliance) return; + this.player.breakAlliance(alliance); + } + + private attackChance(other: Player): boolean { + if (this.player === null) throw new Error("not initialized"); + + if (this.player.isAlliedWith(other)) { + return this.shouldDiscourageAttack(other) + ? this.random.chance(200) + : this.random.chance(50); + } else { + return this.shouldDiscourageAttack(other) ? this.random.chance(4) : true; + } + } + + private shouldDiscourageAttack(other: Player) { + if (other.isTraitor()) { + return false; + } + const { difficulty } = this.game.config().gameConfig(); + if ( + difficulty === Difficulty.Hard || + difficulty === Difficulty.Impossible + ) { + return false; + } + if (other.type() !== PlayerType.Human) { + return false; + } + // Only discourage attacks on Humans who are not traitors on easy or medium difficulty. + return true; + } + private clearEnemy() { this.enemy = null; } forgetOldEnemies() { // Forget old enemies - if (this.game.ticks() - this.enemyUpdated > 100) { + if (this.game.ticks() - (this.enemyUpdated ?? 0) > 100) { this.clearEnemy(); } } - private hasSufficientTroops(): boolean { + 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; @@ -98,7 +162,7 @@ export class BotBehavior { largestAttacker = attack.attacker(); } if (largestAttacker !== undefined) { - this.setNewEnemy(largestAttacker); + this.setNewEnemy(largestAttacker, true); } } @@ -110,34 +174,37 @@ export class BotBehavior { } assistAllies() { - outer: for (const ally of this.player.allies()) { + for (const ally of this.player.allies()) { if (ally.targets().length === 0) continue; if (this.player.relation(ally) < Relation.Friendly) { - // this.emoji(ally, "🤦"); + this.emoji(ally, this.random.randElement(EMOJI_RELATION_TOO_LOW)); continue; } for (const target of ally.targets()) { if (target === this.player) { - // this.emoji(ally, "💀"); + this.emoji(ally, this.random.randElement(EMOJI_TARGET_ME)); continue; } if (this.player.isAlliedWith(target)) { - // this.emoji(ally, "👎"); + this.emoji(ally, this.random.randElement(EMOJI_TARGET_ALLY)); continue; } // All checks passed, assist them this.player.updateRelation(ally, -20); this.setNewEnemy(target); - this.emoji(ally, this.assistAcceptEmoji); - break outer; + this.emoji(ally, this.random.randElement(EMOJI_ASSIST_ACCEPT)); + return; } } } - selectEnemy(): Player | null { + selectEnemy(enemies: Player[]): Player | null { if (this.enemy === null) { - // Save up troops until we reach the trigger ratio - if (!this.hasSufficientTroops()) return null; + // Save up troops until we reach the reserve ratio + if (!this.hasReserveRatioTroops()) return null; + + // Maybe save up troops until we reach the trigger ratio + if (!this.hasTriggerRatioTroops() && !this.random.chance(10)) return null; // Prefer neighboring bots const bots = this.player @@ -165,11 +232,13 @@ export class BotBehavior { // Retaliate against incoming attacks if (this.enemy === null) { + // Only after clearing bots this.checkIncomingAttacks(); } // Select the most hated player - if (this.enemy === null) { + if (this.enemy === null && this.random.chance(2)) { + // 50% chance const mostHated = this.player.allRelationsSorted()[0]; if ( mostHated !== undefined && @@ -178,6 +247,16 @@ export class BotBehavior { this.setNewEnemy(mostHated.player); } } + + // Select the weakest player + if (this.enemy === null && enemies.length > 0) { + this.setNewEnemy(enemies[0]); + } + + // Select a random player + if (this.enemy === null && enemies.length > 0) { + this.setNewEnemy(this.random.randElement(enemies)); + } } // Sanity check, don't attack our allies or teammates @@ -187,7 +266,7 @@ export class BotBehavior { selectRandomEnemy(): Player | TerraNullius | null { if (this.enemy === null) { // Save up troops until we reach the trigger ratio - if (!this.hasSufficientTroops()) return null; + if (!this.hasTriggerRatioTroops()) return null; // Choose a new enemy randomly const neighbors = this.player.neighbors();