Files
OpenFrontIO/src/core/execution/nation/NationMIRVBehavior.ts
T
FloPinguin 5d52f73278 The clown is gone! 🤡 Nations send much better emojis now (#2696)
## Description:

Previously, nations just spammed these two rather toxic emojis: 🤡😡

They now send fewer emojis while attacking, and the clown emoji is
reserved for special cases.

They got the ability to send emojis in much more cases:
- Human didn't donate enough for relation update
- Human did donate an ok amount
- Human did donate a lot
- Responding to emojis that they get sent from a human
- Nuke sent
- MIRV sent
- Retaliation warship sent
- Traitor tries to ally
- Threat asks for / accepts an alliance request
- Disliked human tries to ally
- Friendly human tries to ally
- They are getting attacked by very much troops
- They are getting attacked by very little troops
- Congratulating the winner
- Bragging with their crown
- Charming their allies
- Clown-Emoting traitors
- Easteregg: Sending a rat emoji to very small humans

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

---------

Co-authored-by: iamlewis <lewismmmm@gmail.com>
2025-12-26 13:07:31 -08:00

277 lines
8.1 KiB
TypeScript

import {
AllPlayers,
Difficulty,
Game,
Gold,
Player,
PlayerType,
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 } from "./NationEmojiBehavior";
export class NationMIRVBehavior {
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.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;
}
// 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 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.emojiBehavior.sendEmoji(AllPlayers, EMOJI_NUKE);
}
}
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);
}
}