mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 23:12:03 +00:00
4e62114ea0
## 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
304 lines
9.1 KiB
TypeScript
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);
|
|
}
|
|
}
|