Files
OpenFrontIO/src/core/execution/nation/NationMIRVBehavior.ts
T
FloPinguin 4e62114ea0 Improve nations 🤖 (#3206)
## Description:

- `AiAttackBehavior`: Because bots delete stolen structures now, nations
prioritize attacking bots with structures
- `NationMIRVBehavior`: Nations no longer MIRV enemies who already got
MIRVed in the last 30 seconds. Some humans complained about getting
double-MIRVed by nations. And in games with very high starting gold, ALL
nations MIRVed the same player (stop steamroll logic).
- `NationAllianceBehavior`: Fixes a comparison logic bug (Thanks to
Deshack)
- `NationNukeBehavior.ts`: Little atom bomb perceived cost balance
change
- `MIRVExecution`: To make sure the MIRVing nations are attacking the
MIRVed nations (even if they don't share a border), the relation gets
updated in both directions now.
- `SinglePlayerModal` & `HostLobbyModal`: Update the default difficulty
to "Medium" (to synchronize the defaults with the public game default)

## Please complete the following:

- [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

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FloPinguin
2026-02-16 11:13:07 -08:00

304 lines
9.1 KiB
TypeScript

import {
AllPlayers,
Difficulty,
Game,
Gold,
Player,
PlayerID,
PlayerType,
Tick,
UnitType,
} from "../../game/Game";
import { TileRef } from "../../game/GameMap";
import { PseudoRandom } from "../../PseudoRandom";
import { assertNever } from "../../Util";
import { MirvExecution } from "../MIRVExecution";
import { calculateTerritoryCenter } from "../Util";
import {
EMOJI_NUKE,
NationEmojiBehavior,
respondToMIRV,
} from "./NationEmojiBehavior";
// 30 seconds at 10 ticks/second
const MIRV_COOLDOWN_TICKS = 300;
export class NationMIRVBehavior {
// Shared across all NationMIRVBehavior instances.
// Tracks the last tick a MIRV was sent at each player, so multiple nations don't pile-on the same target.
// Especially important for games with very high starting gold settings.
private static recentMirvTargets = new Map<PlayerID, Tick>();
constructor(
private random: PseudoRandom,
private game: Game,
private player: Player,
private emojiBehavior: NationEmojiBehavior,
) {}
private get hesitationOdds(): number {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return 2; // More likely to hesitate
case Difficulty.Medium:
return 4;
case Difficulty.Hard:
return 8;
case Difficulty.Impossible:
return 16; // Rarely hesitates
default:
assertNever(difficulty);
}
}
private get victoryDenialTeamThreshold(): number {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return 0.9; // Only react right before the game ends (95%)
case Difficulty.Medium:
return 0.8;
case Difficulty.Hard:
return 0.7;
case Difficulty.Impossible:
return 0.6; // Reacts early
default:
assertNever(difficulty);
}
}
private get victoryDenialIndividualThreshold(): number {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return 0.75; // Only react right before the game ends (80%)
case Difficulty.Medium:
return 0.65;
case Difficulty.Hard:
return 0.55;
case Difficulty.Impossible:
return 0.4; // Reacts early
default:
assertNever(difficulty);
}
}
private get steamrollCityGapMultiplier(): number {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return 1.5; // Needs larger gap to trigger
case Difficulty.Medium:
return 1.3;
case Difficulty.Hard:
return 1.2;
case Difficulty.Impossible:
return 1.15; // Reacts to smaller gaps
default:
assertNever(difficulty);
}
}
private get steamrollMinLeaderCities(): number {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return 15; // Needs more cities to trigger
case Difficulty.Medium:
case Difficulty.Hard:
return 10;
case Difficulty.Impossible:
return 8; // Reacts early
default:
assertNever(difficulty);
}
}
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;
}
if (this.random.chance(this.hesitationOdds)) {
return false;
}
const inboundMIRVSender = this.selectCounterMirvTarget();
if (inboundMIRVSender && !this.wasRecentlyMirved(inboundMIRVSender)) {
this.maybeSendMIRV(inboundMIRVSender);
return true;
}
const victoryDenialTarget = this.selectVictoryDenialTarget();
if (victoryDenialTarget && !this.wasRecentlyMirved(victoryDenialTarget)) {
this.maybeSendMIRV(victoryDenialTarget);
return true;
}
const steamrollStopTarget = this.selectSteamrollStopTarget();
if (steamrollStopTarget && !this.wasRecentlyMirved(steamrollStopTarget)) {
this.maybeSendMIRV(steamrollStopTarget);
return true;
}
return false;
}
// MIRV Strategy Methods
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.game.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.game
.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 >= this.victoryDenialTeamThreshold) {
// 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 >= this.victoryDenialIndividualThreshold) 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.game
.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 <= this.steamrollMinLeaderCities) return null;
const secondHighest = allPlayers[1].cityCount;
const threshold = secondHighest * this.steamrollCityGapMultiplier;
if (topPlayer.cityCount >= threshold) {
return validTargets.some((p) => p === topPlayer.p) ? topPlayer.p : null;
}
return null;
}
// MIRV Cooldown Methods
private wasRecentlyMirved(target: Player): boolean {
const lastTick = NationMIRVBehavior.recentMirvTargets.get(target.id());
if (lastTick === undefined) return false;
return this.game.ticks() - lastTick < MIRV_COOLDOWN_TICKS;
}
private recordMirvHit(target: Player): void {
NationMIRVBehavior.recentMirvTargets.set(target.id(), this.game.ticks());
}
// MIRV Helper Methods
private getValidMirvTargetPlayers(): Player[] {
if (this.player === null) throw new Error("not initialized");
return this.game.players().filter((p) => {
return (
p !== this.player &&
p.isPlayer() &&
p.type() !== PlayerType.Bot &&
!this.player!.isOnSameTeam(p)
);
});
}
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.game.hasOwner(dst)) continue;
const owner = this.game.owner(dst);
if (owner === this.player) {
return true;
}
}
return false;
}
// MIRV Execution Methods
private maybeSendMIRV(enemy: Player): void {
if (this.player === null) throw new Error("not initialized");
this.emojiBehavior.maybeSendAttackEmoji(enemy);
const centerTile = this.calculateTerritoryCenter(enemy);
if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) {
this.game.addExecution(new MirvExecution(this.player, centerTile));
this.recordMirvHit(enemy);
this.emojiBehavior.sendEmoji(AllPlayers, EMOJI_NUKE);
respondToMIRV(this.game, this.random, enemy);
}
}
private countCities(p: Player): number {
return p.unitCount(UnitType.City);
}
private calculateTerritoryCenter(target: Player): TileRef | null {
return calculateTerritoryCenter(this.game, target);
}
private cost(type: UnitType): Gold {
if (this.player === null) throw new Error("not initialized");
return this.game.unitInfo(type).cost(this.game, this.player);
}
}