Files
OpenFrontIO/src/core/execution/FakeHumanExecution.ts
T
2025-05-06 15:19:36 +02:00

953 lines
27 KiB
TypeScript

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<Player, Tick>();
private lastNukeSent: [Tick, TileRef][] = [];
private embargoMalusApplied = new Set<PlayerID>();
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<Unit>();
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;
}
}
}
}