import { consolex } from "../Consolex"; import { Cell, Difficulty, Execution, Game, Nation, Player, PlayerID, PlayerType, Relation, TerrainType, Tick, Unit, UnitType, } from "../game/Game"; import { euclDistFN, manhattanDistFN, TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; import { calculateBoundingBox, flattenedEmojiTable, simpleHash, within, } from "../Util"; import { ConstructionExecution } from "./ConstructionExecution"; import { EmojiExecution } from "./EmojiExecution"; import { NukeExecution } from "./NukeExecution"; import { SpawnExecution } from "./SpawnExecution"; import { TransportShipExecution } from "./TransportShipExecution"; import { closestTwoTiles } from "./Util"; import { BotBehavior } from "./utils/BotBehavior"; export class FakeHumanExecution implements Execution { private firstMove = true; private active = true; private random: PseudoRandom; private behavior: BotBehavior | null = null; private mg: Game; private player: Player = null; private attackRate: number; private attackTick: number; private triggerRatio: number; private reserveRatio: number; private lastEmojiSent = new Map(); private lastNukeSent: [Tick, TileRef][] = []; private embargoMalusApplied = new Set(); private heckleEmoji: number[]; private dogpileEmoji: number; private portTargetRatio: number = 0.00005; // desired ports per tile private cityTargetRatio: number = 0.0001; // desired cities per tile private defensePostSpacing: number = 60; // minimum distance between defense posts private defensePostTargetRatio: number = 0.004; // desired defense posts per border length private lastDefensePostTick: number = -9999; private builtSAMNearSilo = new Set(); private dogpileTarget: Player | null = null; private dogpileLastChecked: number = -1; private attackedThisTick: boolean = false; constructor( gameID: GameID, private nation: Nation, ) { this.random = new PseudoRandom( simpleHash(nation.playerInfo.id) + simpleHash(gameID), ); 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(40, 60) / 100; this.heckleEmoji = ["🤡", "😡"].map((e) => flattenedEmojiTable.indexOf(e)); this.dogpileEmoji = flattenedEmojiTable.indexOf("🖕"); } init(mg: Game) { this.mg = mg; if (this.random.chance(10)) { // this.isTraitor = true } } private updateRelationsFromEmbargos() { const others = this.mg.players().filter((p) => p.id() != this.player.id()); others.forEach((other: Player) => { const embargoMalus = -20; if ( other.hasEmbargoAgainst(this.player) && !this.embargoMalusApplied.has(other.id()) ) { this.player.updateRelation(other, embargoMalus); this.embargoMalusApplied.add(other.id()); } else if ( !other.hasEmbargoAgainst(this.player) && this.embargoMalusApplied.has(other.id()) ) { this.player.updateRelation(other, -embargoMalus); this.embargoMalusApplied.delete(other.id()); } }); } private handleEmbargoesToHostileNations() { const others = this.mg.players().filter((p) => p.id() != this.player.id()); others.forEach((other: Player) => { /* When player is hostile starts embargo. Do not stop until neutral again */ if ( this.player.relation(other) <= Relation.Hostile && !this.player.hasEmbargoAgainst(other) ) { this.player.addEmbargo(other.id()); } else if ( this.player.relation(other) >= Relation.Neutral && this.player.hasEmbargoAgainst(other) ) { this.player.stopEmbargo(other.id()); } }); } tick(ticks: number) { if (ticks % this.attackRate != this.attackTick) return; this.attackedThisTick = false; // new tick, reset this.updateDogpile(); if (this.mg.inSpawnPhase()) { const rl = this.randomLand(); if (rl == null) { consolex.warn(`cannot spawn ${this.nation.playerInfo.name}`); return; } this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, rl)); return; } if (this.player == null) { this.player = this.mg .players() .find((p) => p.id() == this.nation.playerInfo.id); if (this.player == null) { return; } } if (!this.player.isAlive()) { this.active = false; return; } if (this.behavior === null) { // Player is unavailable during init() this.behavior = new BotBehavior( this.random, this.mg, this.player, this.triggerRatio, this.reserveRatio, ); } if (this.firstMove) { this.firstMove = false; this.behavior.sendAttack(this.mg.terraNullius(), true); return; } if ( this.player.troops() > 100_000 && this.player.targetTroopRatio() > 0.6 ) { this.player.setTargetTroopRatio(0.6); } this.updateRelationsFromEmbargos(); this.behavior.handleAllianceRequests(); this.handleEnemies(); this.handleUnits(); this.handleEmbargoesToHostileNations(); if (this.attackedThisTick) { return; // ⛔ Stop if already attacked this tick } this.maybeAttack(); } private maybeAttack() { const enemyborder = Array.from(this.player.borderTiles()) .flatMap((t) => this.mg.neighbors(t)) .filter( (t) => this.mg.isLand(t) && this.mg.ownerID(t) != this.player.smallID(), ); if (enemyborder.length == 0) { if (this.random.chance(2)) { this.sendBoatRandomly(); } return; } if (this.random.chance(20)) { this.sendBoatRandomly(); return; } const enemiesWithTN = enemyborder.map((t) => this.mg.playerBySmallID(this.mg.ownerID(t)), ); if (enemiesWithTN.filter((o) => !o.isPlayer()).length > 0) { this.behavior.sendAttack(this.mg.terraNullius()); return; } const enemies = enemiesWithTN .filter((o) => o.isPlayer()) .sort((a, b) => a.troops() - b.troops()); // 5% chance to send a random alliance request if (this.random.chance(20)) { 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); } } private shouldAttack(other: Player): boolean { if (this.player.isOnSameTeam(other)) { return false; } if (this.player.isFriendly(other)) { if (this.shouldDiscourageAttack(other)) { return this.random.chance(200); } return this.random.chance(50); } else { if (this.shouldDiscourageAttack(other)) { return this.random.chance(4); } return 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; } handleEnemies() { this.behavior.forgetOldEnemies(); const sharesBorderWithTN = Array.from(this.player.borderTiles()) .flatMap((t) => this.mg.neighbors(t)) .some((t) => this.mg.isLand(t) && !this.mg.hasOwner(t)); if (sharesBorderWithTN) return; this.behavior.checkIncomingAttacks(); this.behavior.assistAllies(); let enemy: Player | null = null; if ( this.dogpileTarget != null && this.dogpileTarget.isAlive() && !this.player.isOnSameTeam(this.dogpileTarget) ) { enemy = this.dogpileTarget; } else { enemy = this.behavior.selectEnemy(); } if (!enemy) return; this.maybeSendEmoji(enemy); this.maybeSendNuke(enemy); if (this.player.sharesBorderWith(enemy)) { this.behavior.sendAttack(enemy); if (this.behavior.sendAttack(enemy)) { this.attackedThisTick = true; } } else { this.maybeSendBoatAttack(enemy); } } private maybeSendEmoji(enemy: Player) { if (enemy.type() != PlayerType.Human) return; // 🛑 Dogpile mode special case if (this.dogpileTarget != null) { // Only send one middle finger emoji once if (!this.lastEmojiSent.has(enemy)) { this.lastEmojiSent.set(enemy, this.mg.ticks()); this.mg.addExecution( new EmojiExecution(this.player.id(), enemy.id(), this.dogpileEmoji), ); } return; } // Normal mode (not dogpile) behavior const lastSent = this.lastEmojiSent.get(enemy) ?? -300; if (this.mg.ticks() - lastSent <= 300) return; this.lastEmojiSent.set(enemy, this.mg.ticks()); this.mg.addExecution( new EmojiExecution( this.player.id(), enemy.id(), this.random.randElement(this.heckleEmoji), ), ); } private maybeSendNuke(other: Player) { const silos = this.player.units(UnitType.MissileSilo); if ( silos.length == 0 || this.player.gold() < this.cost(UnitType.AtomBomb) || other.type() == PlayerType.Bot || this.player.isOnSameTeam(other) ) { return; } const structures = other.units( UnitType.City, UnitType.DefensePost, UnitType.MissileSilo, UnitType.Port, UnitType.SAMLauncher, ); const structureTiles = structures.map((u) => u.tile()); const randomTiles: TileRef[] = new Array(10); for (let i = 0; i < randomTiles.length; i++) { randomTiles[i] = this.randTerritoryTile(other); } const allTiles = randomTiles.concat(structureTiles); let bestTile = null; let bestValue = 0; this.removeOldNukeEvents(); outer: for (const tile of new Set(allTiles)) { if (tile == null) continue; for (const t of this.mg.bfs(tile, manhattanDistFN(tile, 15))) { // Make sure we nuke at least 15 tiles in border if (this.mg.owner(t) != other) { continue outer; } } if (!this.player.canBuild(UnitType.AtomBomb, tile)) continue; const value = this.nukeTileScore(tile, silos, structures); if (value > bestTile) { bestTile = tile; bestValue = value; } } if (bestTile != null) { this.sendNuke(bestTile); } } private removeOldNukeEvents() { const maxAge = 500; const tick = this.mg.ticks(); while ( this.lastNukeSent.length > 0 && this.lastNukeSent[0][0] + maxAge < tick ) { this.lastNukeSent.shift(); } } private sendNuke(tile: TileRef) { const tick = this.mg.ticks(); this.lastNukeSent.push([tick, tile]); this.mg.addExecution( new NukeExecution(UnitType.AtomBomb, this.player.id(), tile), ); } private nukeTileScore(tile: TileRef, silos: Unit[], targets: Unit[]): number { // Potential damage in a 25-tile radius const dist = euclDistFN(tile, 25, false); let tileValue = targets .filter((unit) => dist(this.mg, unit.tile())) .map((unit) => { switch (unit.type()) { case UnitType.City: return 25_000; case UnitType.DefensePost: return 5_000; case UnitType.MissileSilo: return 50_000; case UnitType.Port: return 10_000; case UnitType.SAMLauncher: return -1_000_000; default: return 0; } }) .reduce((prev, cur) => prev + cur, 0); // Prefer tiles that are closer to a silo const siloTiles = silos.map((u) => u.tile()); const { x: closestSilo } = closestTwoTiles(this.mg, siloTiles, [tile]); const distanceSquared = this.mg.euclideanDistSquared(tile, closestSilo); const distanceToClosestSilo = Math.sqrt(distanceSquared); tileValue -= distanceToClosestSilo * 30; // Don't target near recent targets tileValue -= this.lastNukeSent .filter(([_tick, tile]) => dist(this.mg, tile)) .map((_) => 1_000_000) .reduce((prev, cur) => prev + cur, 0); return tileValue; } private maybeSendBoatAttack(other: Player) { if (this.player.isOnSameTeam(other)) return; const closest = closestTwoTiles( this.mg, Array.from(this.player.borderTiles()).filter((t) => this.mg.isOceanShore(t), ), Array.from(other.borderTiles()).filter((t) => this.mg.isOceanShore(t)), ); if (closest == null) { return; } // 🛑 New check 1: In dogpile mode, don't send if too far if (this.dogpileTarget != null) { const dist = Math.sqrt( this.mg.euclideanDistSquared(closest.x, closest.y), ); if (dist > 200) { return; } // 🛑 New check 2: In dogpile mode, limit to 2 active transport ships const activeBoats = this.player.units(UnitType.TransportShip).length; if (activeBoats >= 2) { return; } } const maxPop = this.mg.config().maxPopulation(this.player); const maxTroops = maxPop * this.player.targetTroopRatio(); const targetTroops = maxTroops * this.reserveRatio; const surplusTroops = this.player.troops() - targetTroops; if (surplusTroops <= 0) return; // ❗ Not enough spare troops to send const troopsToSend = within( surplusTroops, 0.1 * this.player.troops(), 0.2 * this.player.troops(), ); if (troopsToSend < 1) return; // ❗ Don't send if too little this.mg.addExecution( new TransportShipExecution( this.player.id(), other.id(), closest.y, troopsToSend, null, ), ); } private handleUnits() { const currentTick = this.mg.ticks(); const portsCount = this.player.units(UnitType.Port).length; const citiesCount = this.player.units(UnitType.City).length; const tilesCount = this.player.numTilesOwned(); const portRatio = portsCount / tilesCount; const cityRatio = citiesCount / tilesCount; const portDeficit = Math.max( (this.portTargetRatio - portRatio) / this.portTargetRatio, portsCount < 1 ? 1 : -1, ); const cityDeficit = Math.max( (this.cityTargetRatio - cityRatio) / this.cityTargetRatio, citiesCount < 2 ? 1 : -1, ); const canAffordPort = this.player.gold() >= this.cost(UnitType.Port); const canAffordCity = this.player.gold() >= this.cost(UnitType.City); const oceanTiles = Array.from(this.player.borderTiles()).filter((t) => this.mg.isOceanShore(t), ); const canBuildPort = canAffordPort && oceanTiles.length > 0; const canBuildCity = canAffordCity && this.randTerritoryTile(this.player) !== null; if (portDeficit > 0 || cityDeficit > 0) { if (cityDeficit >= portDeficit && canBuildCity) { const tile = this.randTerritoryTile(this.player); if (tile) { this.mg.addExecution( new ConstructionExecution(this.player.id(), tile, UnitType.City), ); return; } } if (portDeficit > cityDeficit && canBuildPort) { const tile = this.random.randElement(oceanTiles); this.mg.addExecution( new ConstructionExecution(this.player.id(), tile, UnitType.Port), ); return; } // fallback: if port was preferred but unbuildable, try city if ( portDeficit > cityDeficit && !canBuildPort && canBuildCity && cityDeficit > 0 ) { const tile = this.randTerritoryTile(this.player); if (tile) { this.mg.addExecution( new ConstructionExecution(this.player.id(), tile, UnitType.City), ); return; } } } if (currentTick - this.lastDefensePostTick >= 100) { this.lastDefensePostTick = currentTick; const hasCity = this.player.units(UnitType.City).length >= 1; const defensePostsCount = this.player.units(UnitType.DefensePost).length; const borderTiles = new Set(this.player.borderTiles()); const defensePostRatio = defensePostsCount / borderTiles.size; const defensePostDeficit = this.defensePostTargetRatio - defensePostRatio; const canAffordDefensePost = this.player.gold() >= this.cost(UnitType.DefensePost); if (defensePostDeficit > 0 && canAffordDefensePost && hasCity) { const borderTiles = new Set(this.player.borderTiles()); const existingPosts = this.player .units(UnitType.DefensePost) .map((u) => u.tile()); const radius = 10; const borderTileArray = Array.from(borderTiles); this.random.shuffleArray(borderTileArray); const sampledBorderTiles = borderTileArray.slice(0, 5); // <-- scan 5 border tiles only let builtDefensePost = false; for (const borderTile of sampledBorderTiles) { const x0 = this.mg.x(borderTile); const y0 = this.mg.y(borderTile); for (let dx = -radius; dx <= radius; dx++) { for (let dy = -radius; dy <= radius; dy++) { const x = x0 + dx; const y = y0 + dy; if (!this.mg.isValidCoord(x, y)) continue; const tile = this.mg.ref(x, y); if (this.mg.owner(tile) !== this.player) continue; if (borderTiles.has(tile)) continue; let tooCloseToBorder = false; for (const borderTile of borderTiles) { const distSq = this.mg.euclideanDistSquared(tile, borderTile); if (distSq < 36) { tooCloseToBorder = true; break; } } if (tooCloseToBorder) continue; let nearOtherPost = false; for (const postTile of existingPosts) { const distSq = this.mg.euclideanDistSquared(tile, postTile); if ( distSq < this.defensePostSpacing * this.defensePostSpacing ) { nearOtherPost = true; break; } } if (nearOtherPost) continue; if (this.player.canBuild(UnitType.DefensePost, tile)) { this.mg.addExecution( new ConstructionExecution( this.player.id(), tile, UnitType.DefensePost, ), ); builtDefensePost = true; break; } } if (builtDefensePost) break; } if (builtDefensePost) break; } if (!builtDefensePost) { consolex.log( `[${this.nation.playerInfo.name}] no valid tile found for Defense Post`, ); } } } if (this.maybeSpawnWarship()) { return; } if (!this.mg.config().disableNukes()) { this.maybeSpawnStructure(UnitType.MissileSilo, 1); this.tryBuildSAMNearSilos(); } } private tryBuildSAMNearSilos() { if (this.player.gold() < this.cost(UnitType.SAMLauncher)) { return; } const silos = this.player.units(UnitType.MissileSilo); for (const silo of silos) { if (this.hasLiveSAMNearSilo(silo)) { continue; // Skip if there's already a SAM nearby } const siloTile = silo.tile(); for (const t of this.mg.bfs(siloTile, manhattanDistFN(siloTile, 40))) { if ( this.mg.ownerID(t) === this.player.smallID() && this.player.canBuild(UnitType.SAMLauncher, t) ) { this.mg.addExecution( new ConstructionExecution( this.player.id(), t, UnitType.SAMLauncher, ), ); return; // Build only one per tick } } } } private hasLiveSAMNearSilo(silo: Unit): boolean { const radiusSq = 40 * 40; return this.player.units(UnitType.SAMLauncher).some((sam) => { const distSq = this.mg.euclideanDistSquared(silo.tile(), sam.tile()); return distSq <= radiusSq; }); } private maybeSpawnStructure(type: UnitType, maxNum: number) { const units = this.player.units(type); if (units.length >= maxNum) { return; } if (this.player.gold() < this.cost(type)) { return; } const tile = this.randTerritoryTile(this.player); if (tile == null) { return; } const canBuild = this.player.canBuild(type, tile); if (canBuild == false) { return; } this.mg.addExecution( new ConstructionExecution(this.player.id(), tile, type), ); } private maybeSpawnWarship(): boolean { if (!this.random.chance(50)) { return false; } const ports = this.player.units(UnitType.Port); const ships = this.player.units(UnitType.Warship); if ( ports.length > 0 && ships.length == 0 && this.player.gold() > this.cost(UnitType.Warship) ) { const port = this.random.randElement(ports); const targetTile = this.warshipSpawnTile(port.tile()); if (targetTile == null) { return false; } const canBuild = this.player.canBuild(UnitType.Warship, targetTile); if (canBuild == false) { consolex.warn("cannot spawn destroyer"); return false; } this.mg.addExecution( new ConstructionExecution( this.player.id(), targetTile, UnitType.Warship, ), ); return true; } return false; } private randTerritoryTile(p: Player): TileRef | null { const boundingBox = calculateBoundingBox(this.mg, p.borderTiles()); for (let i = 0; i < 100; i++) { const randX = this.random.nextInt(boundingBox.min.x, boundingBox.max.x); const randY = this.random.nextInt(boundingBox.min.y, boundingBox.max.y); if (!this.mg.isOnMap(new Cell(randX, randY))) { // Sanity check should never happen continue; } const randTile = this.mg.ref(randX, randY); if (this.mg.owner(randTile) == p) { return randTile; } } return null; } private warshipSpawnTile(portTile: TileRef): TileRef | null { const radius = 250; for (let attempts = 0; attempts < 50; attempts++) { const randX = this.random.nextInt( this.mg.x(portTile) - radius, this.mg.x(portTile) + radius, ); const randY = this.random.nextInt( this.mg.y(portTile) - radius, this.mg.y(portTile) + radius, ); if (!this.mg.isValidCoord(randX, randY)) { continue; } const tile = this.mg.ref(randX, randY); // Sanity check if (!this.mg.isOcean(tile)) { continue; } return tile; } return null; } private cost(type: UnitType): number { return this.mg.unitInfo(type).cost(this.player); } sendBoatRandomly() { const oceanShore = Array.from(this.player.borderTiles()).filter((t) => this.mg.isOceanShore(t), ); if (oceanShore.length == 0) { return; } const src = this.random.randElement(oceanShore); const dst = this.randOceanShoreTile(src, 150); if (dst == null) { return; } const maxPop = this.mg.config().maxPopulation(this.player); const maxTroops = maxPop * this.player.targetTroopRatio(); const targetTroops = maxTroops * this.reserveRatio; const surplusTroops = this.player.troops() - targetTroops; if (surplusTroops <= 0) return; // ❗ Not enough troops to send a boat const troopsToSend = within( surplusTroops, 0.1 * this.player.troops(), // a little smaller range for random boats 0.2 * this.player.troops(), ); if (troopsToSend < 1) return; // ❗ Avoid sending tiny attacks this.mg.addExecution( new TransportShipExecution( this.player.id(), this.mg.owner(dst).id(), dst, troopsToSend, null, ), ); } randomLand(): TileRef | null { const delta = 25; let tries = 0; while (tries < 50) { tries++; const cell = this.nation.spawnCell; const x = this.random.nextInt(cell.x - delta, cell.x + delta); const y = this.random.nextInt(cell.y - delta, cell.y + delta); if (!this.mg.isValidCoord(x, y)) { continue; } const tile = this.mg.ref(x, y); if (this.mg.isLand(tile) && !this.mg.hasOwner(tile)) { if ( this.mg.terrainType(tile) == TerrainType.Mountain && this.random.chance(2) ) { continue; } return tile; } } return null; } private randOceanShoreTile(tile: TileRef, dist: number): TileRef | null { const x = this.mg.x(tile); const y = this.mg.y(tile); for (let i = 0; i < 500; i++) { const randX = this.random.nextInt(x - dist, x + dist); const randY = this.random.nextInt(y - dist, y + dist); if (!this.mg.isValidCoord(randX, randY)) { continue; } const randTile = this.mg.ref(randX, randY); if (!this.mg.isOceanShore(randTile)) { continue; } const owner = this.mg.owner(randTile); if (!owner.isPlayer()) { return randTile; } if (!owner.isFriendly(this.player)) { return randTile; } } return null; } owner(): Player { return null; } isActive(): boolean { return this.active; } activeDuringSpawnPhase(): boolean { return true; } private updateDogpile() { if (!this.player) return; if (this.mg.ticks() < 3000) { this.dogpileTarget = null; return; } const isTeamGame = this.mg .players() .some((p) => p !== this.player && this.player.isOnSameTeam(p)); if (isTeamGame) { consolex.log("Dogpile disabled in team game"); this.dogpileTarget = null; return; } const CHECK_INTERVAL = 50; if (this.mg.ticks() - this.dogpileLastChecked < CHECK_INTERVAL) return; this.dogpileLastChecked = this.mg.ticks(); const competitors = this.mg .players() .filter( (p) => p.isAlive() && p.isPlayer() && !this.player?.isOnSameTeam(p), ); const sorted = competitors.sort( (a, b) => b.numTilesOwned() - a.numTilesOwned(), ); const top = sorted[0]; const second = sorted[1]; // ✅ Don't dogpile if we are the top player if (top.id() === this.player.id()) { this.dogpileTarget = null; return; } if (top.numTilesOwned() > second.numTilesOwned() * 2) { if (this.dogpileTarget !== top) { if (this.random.chance(20)) { this.dogpileTarget = top; } } } else { if (this.dogpileTarget !== top) { this.dogpileTarget = null; } } } }