From af86a9222f1c68a68e843e69753f0acde741d899 Mon Sep 17 00:00:00 2001 From: Sam Bokai Date: Thu, 30 Oct 2025 00:39:31 +0100 Subject: [PATCH] Feature: Enable FakeHumans ("Nation Bots") to Launch MIRVs Strategically (#2225) ## Description: > [!IMPORTANT] > Try here: https://mirv-test.openfront.dev/ > [!NOTE] > Blocks PRs: > - #2244 > - #2263 ### Summary Implements intelligent MIRV usage for fakehuman players, enabling them to make strategic nuclear strikes based on game state analysis. ### Changes #### Core FakeHuman Strategy (`FakeHumanExecution.ts`) - **MIRV Decision System**: Added `considerMIRV()` method that evaluates game state and determines optimal MIRV usage - **Three Strategic Targeting Modes**: 1. **Counter-MIRV**: Retaliatory strikes against players actively launching MIRVs at the fakehuman 2. **Victory Denial**: Preemptive strikes against players approaching win conditions - Team threshold: n% of total land (configurable) - Individual threshold: n% of total land (configurable) 3. **Steamroll Prevention**: Strikes against players with dominant city counts (n% ahead of next competitor) #### FakeHuman Behavior Tuning - **Cooldown System**: n-minute cooldown between MIRV attempts (configurable) - **Failure Rate**: ~n% chance of cooldown trigger without launch (simulates human hesitation/resource management; configurable) - **Territory Targeting**: Centers MIRV strikes on enemy territory center-of-mass for maximum impact #### Technical Improvements - **Type Safety**: Updated `UnitParamsMap` to include `targetTile` parameter for MIRV units - **Execution Flow**: Integrated MIRV consideration into fakehuman tick cycle outside of standard attack logic, due to its holistic strategic nature ### Game Balance Impact - **FakeHuman Threat Level**: Increases late-game fakehuman competitiveness - **Endgame Dynamics**: Prevents runaway victories, extends game tension ### Breaking Changes None - purely additive feature ### Related GitHub Issues: - #2205 ------ ## Other - [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 Discord Username: samsammiliah --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Evan --- src/core/execution/FakeHumanExecution.ts | 256 +++++++- src/core/execution/MIRVExecution.ts | 4 +- src/core/execution/Util.ts | 59 ++ src/core/game/Game.ts | 4 +- tests/FakeHumanMIRV.test.ts | 741 +++++++++++++++++++++++ 5 files changed, 1057 insertions(+), 7 deletions(-) create mode 100644 tests/FakeHumanMIRV.test.ts diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 3b8a7e94d..2d388492e 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -20,17 +20,18 @@ import { boundingBoxTiles, calculateBoundingBox, simpleHash } from "../Util"; import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution"; import { ConstructionExecution } from "./ConstructionExecution"; import { EmojiExecution } from "./EmojiExecution"; +import { MirvExecution } from "./MIRVExecution"; import { structureSpawnTileValue } from "./nation/structureSpawnTileValue"; import { NukeExecution } from "./NukeExecution"; import { SpawnExecution } from "./SpawnExecution"; import { TransportShipExecution } from "./TransportShipExecution"; -import { closestTwoTiles } from "./Util"; +import { calculateTerritoryCenter, closestTwoTiles } from "./Util"; import { BotBehavior, EMOJI_HECKLE } from "./utils/BotBehavior"; export class FakeHumanExecution implements Execution { private active = true; private random: PseudoRandom; - private behavior: BotBehavior | null = null; + private behavior: BotBehavior | null = null; // Shared behavior logic for both bots and fakehumans private mg: Game; private player: Player | null = null; @@ -42,11 +43,32 @@ export class FakeHumanExecution implements Execution { private readonly lastEmojiSent = new Map(); private readonly lastNukeSent: [Tick, TileRef][] = []; + private readonly lastMIRVSent: [Tick, TileRef][] = []; private readonly embargoMalusApplied = new Set(); + /** MIRV Strategy Constants */ + + /** 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( gameID: GameID, - private nation: Nation, + private nation: Nation, // Nation contains PlayerInfo with PlayerType.FakeHuman ) { this.random = new PseudoRandom( simpleHash(nation.playerInfo.id) + simpleHash(gameID), @@ -111,7 +133,9 @@ export class FakeHumanExecution implements Execution { } tick(ticks: number) { - if (ticks % this.attackRate !== this.attackTick) return; + if (ticks % this.attackRate !== this.attackTick) { + return; + } if (this.mg.inSpawnPhase()) { const rl = this.randomSpawnLand(); @@ -158,6 +182,7 @@ export class FakeHumanExecution implements Execution { this.behavior.handleAllianceExtensionRequests(); this.handleUnits(); this.handleEmbargoesToHostileNations(); + this.considerMIRV(); this.maybeAttack(); } @@ -230,6 +255,7 @@ export class FakeHumanExecution implements Execution { this.behavior.forgetOldEnemies(); this.behavior.assistAllies(); + const enemy = this.behavior.selectEnemy(enemies); if (!enemy) return; this.maybeSendEmoji(enemy); @@ -262,7 +288,7 @@ export class FakeHumanExecution implements Execution { if ( silos.length === 0 || this.player.gold() < this.cost(UnitType.AtomBomb) || - other.type() === PlayerType.Bot || + other.type() === PlayerType.Bot || // Don't nuke bots (as opposed to fakehumans and humans) this.player.isOnSameTeam(other) ) { return; @@ -656,6 +682,226 @@ export class FakeHumanExecution implements Execution { return null; } + // MIRV Strategy Methods + private 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(FakeHumanExecution.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; + } + + 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.mg.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.mg + .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 >= FakeHumanExecution.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 >= FakeHumanExecution.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.mg + .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 <= FakeHumanExecution.STEAMROLL_MIN_LEADER_CITIES) + return null; + + const secondHighest = allPlayers[1].cityCount; + + const threshold = + secondHighest * FakeHumanExecution.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.mg.ticks() - this.mirvTargetsCache.tick < MIRV_TARGETS_CACHE_TICKS + ) { + return this.mirvTargetsCache.players; + } + + const players = this.mg.players().filter((p) => { + return ( + p !== this.player && + p.isPlayer() && + p.type() !== PlayerType.Bot && + !this.player!.isOnSameTeam(p) + ); + }); + + this.mirvTargetsCache = { tick: this.mg.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.mg.hasOwner(dst)) continue; + const owner = this.mg.owner(dst); + if (owner === this.player) { + return true; + } + } + return false; + } + + private countCities(p: Player): number { + return p.unitCount(UnitType.City); + } + + private calculateTerritoryCenter(target: Player): TileRef | null { + return calculateTerritoryCenter(this.mg, target); + } + + // MIRV Execution Methods + private maybeSendMIRV(enemy: Player): void { + if (this.player === null) throw new Error("not initialized"); + + this.maybeSendEmoji(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.mg.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.mg.ticks(); + // Use provided tile or any tile from player's territory for cooldown tracking + const cooldownTile = + tile ?? Array.from(this.player.tiles())[0] ?? this.mg.ref(0, 0); + this.lastMIRVSent.push([tick, cooldownTile]); + } + + private removeOldMIRVEvents() { + const maxAge = FakeHumanExecution.MIRV_COOLDOWN_TICKS; + const tick = this.mg.ticks(); + while ( + this.lastMIRVSent.length > 0 && + this.lastMIRVSent[0][0] + maxAge <= tick + ) { + this.lastMIRVSent.shift(); + } + } + isActive(): boolean { return this.active; } diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index 7f5c1687f..b79a4e1ef 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -68,7 +68,9 @@ export class MirvExecution implements Execution { this.active = false; return; } - this.nuke = this.player.buildUnit(UnitType.MIRV, spawn, {}); + this.nuke = this.player.buildUnit(UnitType.MIRV, spawn, { + targetTile: this.dst, + }); const x = Math.floor( (this.mg.x(this.dst) + this.mg.x(this.mg.x(this.nuke.tile()))) / 2, ); diff --git a/src/core/execution/Util.ts b/src/core/execution/Util.ts index 6135c319f..53a69f8c8 100644 --- a/src/core/execution/Util.ts +++ b/src/core/execution/Util.ts @@ -1,3 +1,4 @@ +import { Game, Player } from "../game/Game"; import { euclDistFN, GameMap, TileRef } from "../game/GameMap"; export function getSpawnTiles(gm: GameMap, tile: TileRef): TileRef[] { @@ -71,3 +72,61 @@ export function closestTwoTiles( return result; } + +/** + * Calculates the center of a player's territory using geometric approach. + * Uses the bounding box center and verifies ownership, falling back to nearest border tile if necessary. + * + * @param game - The game instance + * @param target - The player whose territory center to calculate + * @returns The tile reference for the territory center, or null if no valid center found + */ +export function calculateTerritoryCenter( + game: Game, + target: Player, +): TileRef | null { + const borderTiles = target.borderTiles(); + if (borderTiles.size === 0) return null; + + // Calculate bounding box center in a single pass through border tiles + let minX = Infinity, + maxX = -Infinity; + let minY = Infinity, + maxY = -Infinity; + + for (const tile of borderTiles) { + const x = game.x(tile); + const y = game.y(tile); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + + const centerX = Math.floor((minX + maxX) / 2); + const centerY = Math.floor((minY + maxY) / 2); + + const centerTile = game.ref(centerX, centerY); + + // Verify ownership of the center tile + if (game.owner(centerTile) === target) { + return centerTile; + } + + // Fall back to nearest border tile if center is not owned + let closestTile: TileRef | null = null; + let closestDistanceSquared = Infinity; + + for (const tile of borderTiles) { + const dx = game.x(tile) - centerX; + const dy = game.y(tile) - centerY; + const distSquared = dx * dx + dy * dy; + + if (distSquared < closestDistanceSquared) { + closestDistanceSquared = distSquared; + closestTile = tile; + } + } + + return closestTile; +} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 7353ddcc1..36f8f135e 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -270,7 +270,9 @@ export interface UnitParamsMap { [UnitType.City]: Record; - [UnitType.MIRV]: Record; + [UnitType.MIRV]: { + targetTile?: number; + }; [UnitType.MIRVWarhead]: { targetTile?: number; diff --git a/tests/FakeHumanMIRV.test.ts b/tests/FakeHumanMIRV.test.ts new file mode 100644 index 000000000..2d9c46cbb --- /dev/null +++ b/tests/FakeHumanMIRV.test.ts @@ -0,0 +1,741 @@ +import { FakeHumanExecution } from "../src/core/execution/FakeHumanExecution"; +import { MirvExecution } from "../src/core/execution/MIRVExecution"; +import { + Cell, + GameMode, + Nation, + PlayerInfo, + PlayerType, + UnitType, +} from "../src/core/game/Game"; +import { setup } from "./util/Setup"; +import { executeTicks } from "./util/utils"; + +describe("FakeHuman MIRV Retaliation", () => { + test("fakehuman retaliates with MIRV when attacked by MIRV", async () => { + const game = await setup("big_plains", { + infiniteGold: true, + instantBuild: true, + }); + + // Create two players + const attackerInfo = new PlayerInfo( + "attacker", + PlayerType.Human, + null, + "attacker_id", + ); + const fakehumanInfo = new PlayerInfo( + "defender_fakehuman", + PlayerType.FakeHuman, + null, + "fakehuman_id", + ); + + game.addPlayer(attackerInfo); + game.addPlayer(fakehumanInfo); + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + const attacker = game.player("attacker_id"); + const fakehuman = game.player("fakehuman_id"); + + // Give attacker territory and missile silo + for (let x = 5; x < 15; x++) { + for (let y = 5; y < 15; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile)) { + attacker.conquer(tile); + } + } + } + attacker.buildUnit(UnitType.MissileSilo, game.ref(10, 10), {}); + + // Give fakehuman territory and missile silo + for (let x = 25; x < 75; x++) { + for (let y = 25; y < 75; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile)) { + fakehuman.conquer(tile); + } + } + } + fakehuman.buildUnit(UnitType.MissileSilo, game.ref(50, 50), {}); + + // Give both players enough gold for MIRVs + attacker.addGold(100_000_000n); + fakehuman.addGold(100_000_000n); + + // Verify preconditions + expect(attacker.units(UnitType.MissileSilo)).toHaveLength(1); + expect(fakehuman.units(UnitType.MissileSilo)).toHaveLength(1); + expect(attacker.gold()).toBeGreaterThan(35_000_000n); + expect(fakehuman.gold()).toBeGreaterThan(35_000_000n); + + // Track MIRVs before fakehuman retaliates + const mirvCountBefore = fakehuman.units(UnitType.MIRV).length; + + // Initialize fakehuman with FakeHumanExecution to enable retaliation logic + const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info()); + + // Try different game IDs to account for hesitation odds + const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`); + let retaliationAttempted = false; + + for (const gameId of gameIds) { + const testExecution = new FakeHumanExecution(gameId, fakehumanNation); + testExecution.init(game); + + // Launch MIRV from attacker to fakehuman + const targetTile = Array.from(fakehuman.tiles())[0]; + game.addExecution(new MirvExecution(attacker, targetTile)); + + // Execute fakehuman's tick logic + for (let tick = 0; tick < 200; tick++) { + testExecution.tick(tick); + // Allow the game to process executions + if (tick % 10 === 0) { + game.executeNextTick(); + } + + // Check if fakehuman attempted retaliation + if (fakehuman.units(UnitType.MIRV).length > mirvCountBefore) { + retaliationAttempted = true; + break; + } + } + + if (retaliationAttempted) break; + } + + // Assert that retaliation was attempted + expect(retaliationAttempted).toBe(true); + + // Process the retaliation + executeTicks(game, 2); + + // Assert: Fakehuman launched a retaliatory MIRV + const mirvCountAfter = fakehuman.units(UnitType.MIRV).length; + expect(mirvCountAfter).toBeGreaterThan(mirvCountBefore); + + // Verify the retaliatory MIRV targets the attacker's territory + const fakehumanMirvs = fakehuman.units(UnitType.MIRV); + expect(fakehumanMirvs.length).toBeGreaterThan(0); + + const retaliationMirv = fakehumanMirvs[fakehumanMirvs.length - 1]; + const retaliationTarget = retaliationMirv.targetTile(); + expect(retaliationTarget).toBeDefined(); + + if (retaliationTarget) { + const targetOwner = game.owner(retaliationTarget); + expect(targetOwner).toBe(attacker); + } + }); + + test("fakehuman launches MIRV to prevent victory when player approaches win condition", async () => { + // Setup game + const game = await setup("big_plains", { + infiniteGold: true, + instantBuild: true, + }); + + // Create two players + const dominantPlayerInfo = new PlayerInfo( + "dominant_player", + PlayerType.Human, + null, + "dominant_id", + ); + const fakehumanInfo = new PlayerInfo( + "defender_fakehuman", + PlayerType.FakeHuman, + null, + "fakehuman_id", + ); + + game.addPlayer(dominantPlayerInfo); + game.addPlayer(fakehumanInfo); + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + const dominantPlayer = game.player("dominant_id"); + const fakehuman = game.player("fakehuman_id"); + + // First, give fakehuman a small territory and missile silo + let fakehumanTiles = 0; + for (let x = 45; x < 55; x++) { + for (let y = 45; y < 55; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + fakehuman.conquer(tile); + fakehumanTiles++; + } + } + } + + // If we didn't find enough tiles, try a different area + if (fakehumanTiles === 0) { + for (let x = 60; x < 70; x++) { + for (let y = 60; y < 70; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + fakehuman.conquer(tile); + fakehumanTiles++; + if (fakehumanTiles >= 10) break; // Need at least some territory + } + } + if (fakehumanTiles >= 10) break; + } + } + + // Build missile silo on one of the fakehuman's tiles + const fakehumanTile = Array.from(fakehuman.tiles())[0]; + if (fakehumanTile) { + fakehuman.buildUnit(UnitType.MissileSilo, fakehumanTile, {}); + } + + // Then give dominant player a large amount of territory + // This should trigger the victory denial threshold + const totalLandTiles = game.map().numLandTiles(); + const targetTiles = Math.floor(totalLandTiles * 0.66); + + let conqueredTiles = 0; + for ( + let x = 0; + x < game.map().width() && conqueredTiles < targetTiles; + x++ + ) { + for ( + let y = 0; + y < game.map().height() && conqueredTiles < targetTiles; + y++ + ) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + dominantPlayer.conquer(tile); + conqueredTiles++; + } + } + } + + // Give both players enough gold for MIRVs + dominantPlayer.addGold(100_000_000n); + fakehuman.addGold(100_000_000n); + + // Verify preconditions + expect(dominantPlayer.units(UnitType.MissileSilo)).toHaveLength(0); + expect(fakehuman.units(UnitType.MissileSilo)).toHaveLength(1); + expect(fakehuman.units(UnitType.MIRV)).toHaveLength(0); + expect(dominantPlayer.units(UnitType.MIRV)).toHaveLength(0); + expect(dominantPlayer.gold()).toBeGreaterThan(35_000_000n); + expect(fakehuman.gold()).toBeGreaterThan(35_000_000n); + expect(fakehuman.isAlive()).toBe(true); + expect(fakehuman.numTilesOwned()).toBeGreaterThan(0); + + // Verify dominant player has enough territory to trigger victory denial + const dominantTerritoryShare = + dominantPlayer.numTilesOwned() / game.map().numLandTiles(); + expect(dominantTerritoryShare).toBeGreaterThan(0.65); + + // Track MIRVs before fakehuman considers victory denial + const mirvCountBefore = fakehuman.units(UnitType.MIRV).length; + + // Initialize fakehuman with FakeHumanExecution to enable victory denial logic + const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info()); + + // Try different game IDs to account for hesitation odds + const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`); + let victoryDenialSuccessful = false; + + for (const gameId of gameIds) { + const testExecution = new FakeHumanExecution(gameId, fakehumanNation); + testExecution.init(game); + + for (let tick = 0; tick < 200; tick++) { + testExecution.tick(game.ticks()); + // Allow the game to process executions + if (tick % 10 === 0) { + game.executeNextTick(); + } + if (fakehuman.units(UnitType.MIRV).length > mirvCountBefore) { + victoryDenialSuccessful = true; + break; + } + } + + if (victoryDenialSuccessful) break; + } + + // Assert that victory denial was successful + expect(victoryDenialSuccessful).toBe(true); + + // Process the victory denial MIRV + executeTicks(game, 2); + + // Assert: Fakehuman launched a victory denial MIRV + const mirvCountAfter = fakehuman.units(UnitType.MIRV).length; + expect(mirvCountAfter).toBeGreaterThan(mirvCountBefore); + + // Verify the victory denial MIRV targets the dominant player's territory + const fakehumanMirvs = fakehuman.units(UnitType.MIRV); + expect(fakehumanMirvs.length).toBeGreaterThan(0); + + const victoryDenialMirv = fakehumanMirvs[fakehumanMirvs.length - 1]; + const victoryDenialTarget = victoryDenialMirv.targetTile(); + expect(victoryDenialTarget).toBeDefined(); + + if (victoryDenialTarget) { + const targetOwner = game.owner(victoryDenialTarget); + expect(targetOwner).toBe(dominantPlayer); + } + }); + + test("fakehuman launches MIRV to stop steamrolling player with excessive cities", async () => { + // Setup game + const game = await setup("big_plains", { + infiniteGold: true, + instantBuild: true, + }); + + // Create three players + const steamrollerInfo = new PlayerInfo( + "steamroller", + PlayerType.Human, + null, + "steamroller_id", + ); + const secondPlayerInfo = new PlayerInfo( + "second_player", + PlayerType.Human, + null, + "second_id", + ); + const fakehumanInfo = new PlayerInfo( + "defender_fakehuman", + PlayerType.FakeHuman, + null, + "fakehuman_id", + ); + + game.addPlayer(steamrollerInfo); + game.addPlayer(secondPlayerInfo); + game.addPlayer(fakehumanInfo); + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + const steamroller = game.player("steamroller_id"); + const secondPlayer = game.player("second_id"); + const fakehuman = game.player("fakehuman_id"); + + // Give fakehuman a small territory and missile silo + for (let x = 45; x < 55; x++) { + for (let y = 45; y < 55; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + fakehuman.conquer(tile); + } + } + } + const fakehumanTile = Array.from(fakehuman.tiles())[0]; + if (fakehumanTile) { + fakehuman.buildUnit(UnitType.MissileSilo, fakehumanTile, {}); + } + + // Give second player some territory and cities + for (let x = 20; x < 30; x++) { + for (let y = 20; y < 30; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + secondPlayer.conquer(tile); + } + } + } + // Give second player 5 cities + for (let i = 0; i < 5; i++) { + const secondPlayerTile = Array.from(secondPlayer.tiles())[0]; + if (secondPlayerTile) { + secondPlayer.buildUnit(UnitType.City, secondPlayerTile, {}); + } + } + + // Give steamroller territory and many cities + for (let x = 5; x < 25; x++) { + for (let y = 5; y < 25; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + steamroller.conquer(tile); + } + } + } + // Give steamroller cities + const minLeaderCities = 10; + for (let i = 0; i < minLeaderCities + 2; i++) { + const steamrollerTile = Array.from(steamroller.tiles())[0]; + if (steamrollerTile) { + steamroller.buildUnit(UnitType.City, steamrollerTile, {}); + } + } + + // Give all players enough gold for MIRVs + steamroller.addGold(100_000_000n); + secondPlayer.addGold(100_000_000n); + fakehuman.addGold(100_000_000n); + + // Verify preconditions + expect(fakehuman.units(UnitType.MissileSilo)).toHaveLength(1); + expect(steamroller.unitCount(UnitType.City)).toBe(minLeaderCities + 2); + expect(secondPlayer.unitCount(UnitType.City)).toBe(5); + expect(fakehuman.units(UnitType.MIRV)).toHaveLength(0); + + // Track MIRVs before fakehuman considers steamroll stop + const mirvCountBefore = fakehuman.units(UnitType.MIRV).length; + + // Initialize fakehuman with FakeHumanExecution to enable steamroll stop logic + const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info()); + + // Try different game IDs to account for hesitation odds + const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`); + let steamrollStopSuccessful = false; + + for (const gameId of gameIds) { + const testExecution = new FakeHumanExecution(gameId, fakehumanNation); + testExecution.init(game); + + for (let tick = 0; tick < 200; tick++) { + testExecution.tick(game.ticks()); + // Allow the game to process executions + if (tick % 10 === 0) { + game.executeNextTick(); + } + if (fakehuman.units(UnitType.MIRV).length > mirvCountBefore) { + steamrollStopSuccessful = true; + break; + } + } + + if (steamrollStopSuccessful) break; + } + + // Assert that steamroll stop was successful + expect(steamrollStopSuccessful).toBe(true); + + // Process the steamroll stop MIRV + executeTicks(game, 2); + + // Assert: Fakehuman launched a steamroll stop MIRV + const mirvCountAfter = fakehuman.units(UnitType.MIRV).length; + expect(mirvCountAfter).toBeGreaterThan(mirvCountBefore); + + // Verify the steamroll stop MIRV targets the steamroller's territory + const fakehumanMirvs = fakehuman.units(UnitType.MIRV); + expect(fakehumanMirvs.length).toBeGreaterThan(0); + + const steamrollStopMirv = fakehumanMirvs[fakehumanMirvs.length - 1]; + const steamrollStopTarget = steamrollStopMirv.targetTile(); + expect(steamrollStopTarget).toBeDefined(); + + if (steamrollStopTarget) { + const targetOwner = game.owner(steamrollStopTarget); + expect(targetOwner).toBe(steamroller); + } + }); + + test("fakehuman does not launch MIRV for steamroll when leader has <= 10 cities", async () => { + // Setup game + const game = await setup("big_plains", { + infiniteGold: true, + instantBuild: true, + }); + + // Create three players + const steamrollerInfo = new PlayerInfo( + "steamroller", + PlayerType.Human, + null, + "steamroller_id", + ); + const secondPlayerInfo = new PlayerInfo( + "second_player", + PlayerType.Human, + null, + "second_id", + ); + const fakehumanInfo = new PlayerInfo( + "defender_fakehuman", + PlayerType.FakeHuman, + null, + "fakehuman_id", + ); + + game.addPlayer(steamrollerInfo); + game.addPlayer(secondPlayerInfo); + game.addPlayer(fakehumanInfo); + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + const steamroller = game.player("steamroller_id"); + const secondPlayer = game.player("second_id"); + const fakehuman = game.player("fakehuman_id"); + + // Give fakehuman a small territory and missile silo + for (let x = 45; x < 55; x++) { + for (let y = 45; y < 55; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + fakehuman.conquer(tile); + } + } + } + const fakehumanTile = Array.from(fakehuman.tiles())[0]; + if (fakehumanTile) { + fakehuman.buildUnit(UnitType.MissileSilo, fakehumanTile, {}); + } + + // Give second player territory and cities (5 cities) + for (let x = 25; x < 45; x++) { + for (let y = 25; y < 45; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + secondPlayer.conquer(tile); + } + } + } + for (let i = 0; i < 5; i++) { + const secondPlayerTile = Array.from(secondPlayer.tiles())[0]; + if (secondPlayerTile) { + secondPlayer.buildUnit(UnitType.City, secondPlayerTile, {}); + } + } + + // Give steamroller territory and cities + const minLeaderCities = 10; + for (let x = 5; x < 25; x++) { + for (let y = 5; y < 25; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + steamroller.conquer(tile); + } + } + } + for (let i = 0; i < minLeaderCities; i++) { + const steamrollerTile = Array.from(steamroller.tiles())[0]; + if (steamrollerTile) { + steamroller.buildUnit(UnitType.City, steamrollerTile, {}); + } + } + + // Give all players enough gold for MIRVs + steamroller.addGold(100_000_000n); + secondPlayer.addGold(100_000_000n); + fakehuman.addGold(100_000_000n); + + // Verify preconditions + expect(fakehuman.units(UnitType.MissileSilo)).toHaveLength(1); + expect(steamroller.unitCount(UnitType.City)).toBe(minLeaderCities); + expect(secondPlayer.unitCount(UnitType.City)).toBe(5); + expect(fakehuman.units(UnitType.MIRV)).toHaveLength(0); + + // Track MIRVs before fakehuman considers steamroll stop + const mirvCountBefore = fakehuman.units(UnitType.MIRV).length; + + // Initialize fakehuman with FakeHumanExecution to enable steamroll stop logic + const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info()); + + // Try different game IDs to account for hesitation odds + const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`); + let steamrollStopAttempted = false; + + for (const gameId of gameIds) { + const testExecution = new FakeHumanExecution(gameId, fakehumanNation); + testExecution.init(game); + + for (let tick = 0; tick < 200; tick++) { + testExecution.tick(game.ticks()); + game.executeNextTick(); + } + + // Check if any MIRVs were launched for steamroll stop + const fakehumanMirvs = fakehuman.units(UnitType.MIRV); + if (fakehumanMirvs.length > mirvCountBefore) { + steamrollStopAttempted = true; + break; + } + } + + // Assert that steamroll stop was NOT attempted + expect(steamrollStopAttempted).toBe(false); + }); + + test("fakehuman launches MIRV to prevent team victory when team approaches victory denial threshold (targets biggest team member)", async () => { + // Setup game + const teamPlayer1Info = new PlayerInfo( + "[ALPHA]team_player_1", + PlayerType.Human, + null, + "team1_id", + ); + const teamPlayer2Info = new PlayerInfo( + "[ALPHA]team_player_2", + PlayerType.Human, + null, + "team2_id", + ); + const fakehumanInfo = new PlayerInfo( + "defender_fakehuman", + PlayerType.FakeHuman, + null, + "fakehuman_id", + ); + const game = await setup( + "big_plains", + { + infiniteGold: true, + instantBuild: true, + gameMode: GameMode.Team, + playerTeams: 2, + }, + [teamPlayer1Info, teamPlayer2Info, fakehumanInfo], + ); + + // Players already added via setup() with Team mode and shared clan for humans + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + const teamPlayer1 = game.player("team1_id"); + const teamPlayer2 = game.player("team2_id"); + const fakehuman = game.player("fakehuman_id"); + + // Give fakehuman a small territory and missile silo + for (let x = 45; x < 55; x++) { + for (let y = 45; y < 55; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + fakehuman.conquer(tile); + } + } + } + const fakehumanTile = Array.from(fakehuman.tiles())[0]; + if (fakehumanTile) { + fakehuman.buildUnit(UnitType.MissileSilo, fakehumanTile, {}); + } + + // Give team players a large amount of territory to exceed team threshold, + // but skew so teamPlayer1 is clearly the largest member + const totalLandTiles = game.map().numLandTiles(); + const teamTargetTiles = Math.floor(totalLandTiles * 0.82); + + let conqueredTiles = 0; + for ( + let x = 0; + x < game.map().width() && conqueredTiles < teamTargetTiles; + x++ + ) { + for ( + let y = 0; + y < game.map().height() && conqueredTiles < teamTargetTiles; + y++ + ) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + // 3:1 bias towards teamPlayer1 to ensure largest-member targeting is well-defined + const teamPlayer = + conqueredTiles % 4 === 0 ? teamPlayer2 : teamPlayer1; + teamPlayer.conquer(tile); + conqueredTiles++; + } + } + } + + // Give all players enough gold for MIRVs + teamPlayer1.addGold(100_000_000n); + teamPlayer2.addGold(100_000_000n); + fakehuman.addGold(100_000_000n); + + // Verify preconditions + expect(fakehuman.units(UnitType.MissileSilo)).toHaveLength(1); + expect(fakehuman.units(UnitType.MIRV)).toHaveLength(0); + expect(teamPlayer1.gold()).toBeGreaterThan(35_000_000n); + expect(teamPlayer2.gold()).toBeGreaterThan(35_000_000n); + expect(fakehuman.gold()).toBeGreaterThan(35_000_000n); + expect(fakehuman.isAlive()).toBe(true); + expect(fakehuman.numTilesOwned()).toBeGreaterThan(0); + + // Verify team has enough territory to trigger team victory denial + const teamTerritory = + teamPlayer1.numTilesOwned() + teamPlayer2.numTilesOwned(); + const teamShare = teamTerritory / game.map().numLandTiles(); + expect(teamShare).toBeGreaterThan(0.8); // + + // Track MIRVs before fakehuman considers team victory denial + const mirvCountBefore = fakehuman.units(UnitType.MIRV).length; + + // Initialize fakehuman with FakeHumanExecution to enable team victory denial logic + const fakehumanNation = new Nation(new Cell(50, 50), 1, fakehuman.info()); + + // Try different game IDs to account for hesitation odds + const gameIds = Array.from({ length: 20 }, (_, i) => `game_${i}`); + let teamVictoryDenialSuccessful = false; + + for (const gameId of gameIds) { + const testExecution = new FakeHumanExecution(gameId, fakehumanNation); + testExecution.init(game); + + for (let tick = 0; tick < 200; tick++) { + testExecution.tick(game.ticks()); + // Allow the game to process executions + if (tick % 10 === 0) { + game.executeNextTick(); + } + if (fakehuman.units(UnitType.MIRV).length > mirvCountBefore) { + teamVictoryDenialSuccessful = true; + break; + } + } + + if (teamVictoryDenialSuccessful) break; + } + + // Assert that team victory denial was successful + expect(teamVictoryDenialSuccessful).toBe(true); + + // Process the team victory denial MIRV + executeTicks(game, 2); + + // Assert: Fakehuman launched a team victory denial MIRV + const mirvCountAfter = fakehuman.units(UnitType.MIRV).length; + expect(mirvCountAfter).toBeGreaterThan(mirvCountBefore); + + // Verify the team victory denial MIRV targets the largest member of the team + const fakehumanMirvs = fakehuman.units(UnitType.MIRV); + expect(fakehumanMirvs.length).toBeGreaterThan(0); + + const teamVictoryDenialMirv = fakehumanMirvs[fakehumanMirvs.length - 1]; + const teamVictoryDenialTarget = teamVictoryDenialMirv.targetTile(); + expect(teamVictoryDenialTarget).toBeDefined(); + + if (teamVictoryDenialTarget) { + const targetOwner = game.owner(teamVictoryDenialTarget); + // Should target the biggest member of the team + const biggest = + teamPlayer1.numTilesOwned() >= teamPlayer2.numTilesOwned() + ? teamPlayer1 + : teamPlayer2; + expect(targetOwner).toBe(biggest); + } + }); +});