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 <evanpelle@gmail.com>
This commit is contained in:
Sam Bokai
2025-10-29 16:39:31 -07:00
committed by GitHub
co-authored by coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Evan
parent 7fe3b03b83
commit af86a9222f
5 changed files with 1057 additions and 7 deletions
+251 -5
View File
@@ -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<Player, Tick>();
private readonly lastNukeSent: [Tick, TileRef][] = [];
private readonly lastMIRVSent: [Tick, TileRef][] = [];
private readonly embargoMalusApplied = new Set<PlayerID>();
/** 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;
}
+3 -1
View File
@@ -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,
);
+59
View File
@@ -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;
}
+3 -1
View File
@@ -270,7 +270,9 @@ export interface UnitParamsMap {
[UnitType.City]: Record<string, never>;
[UnitType.MIRV]: Record<string, never>;
[UnitType.MIRV]: {
targetTile?: number;
};
[UnitType.MIRVWarhead]: {
targetTile?: number;