Files
OpenFrontIO/src/core/execution/nation/NationAllianceBehavior.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

418 lines
14 KiB
TypeScript

import {
Difficulty,
Game,
GameMode,
Player,
PlayerType,
Relation,
} from "../../game/Game";
import { PseudoRandom } from "../../PseudoRandom";
import { assertNever } from "../../Util";
import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecution";
import { AllianceRequestExecution } from "../alliance/AllianceRequestExecution";
import {
EMOJI_CONFUSED,
EMOJI_HANDSHAKE,
EMOJI_LOVE,
EMOJI_SCARED_OF_THREAT,
NationEmojiBehavior,
} from "./NationEmojiBehavior";
export class NationAllianceBehavior {
constructor(
private random: PseudoRandom,
private game: Game,
private player: Player,
private emojiBehavior: NationEmojiBehavior,
) {}
handleAllianceRequests() {
for (const req of this.player.incomingAllianceRequests()) {
if (this.getAllianceDecision(req.requestor(), true)) {
req.accept();
} else {
req.reject();
}
}
}
handleAllianceExtensionRequests() {
for (const alliance of this.player.alliances()) {
// Alliance expiration tracked by Events Panel, only human ally can click Request to Renew
// Skip if no expiration yet/ ally didn't request extension yet / nation already agreed to extend
if (!alliance.onlyOneAgreedToExtend()) continue;
const human = alliance.other(this.player);
if (!this.getAllianceDecision(human, true)) continue;
this.game.addExecution(
new AllianceExtensionExecution(this.player, human.id()),
);
}
}
maybeSendAllianceRequests(borderingEnemies: Player[]) {
// Only easy nations are allowed to send alliance requests to bots
const isAcceptablePlayerType = (p: Player) =>
(p.type() === PlayerType.Bot &&
this.game.config().gameConfig().difficulty === Difficulty.Easy) ||
p.type() !== PlayerType.Bot;
for (const enemy of borderingEnemies) {
if (
this.random.chance(30) &&
isAcceptablePlayerType(enemy) &&
this.player.canSendAllianceRequest(enemy) &&
this.getAllianceDecision(enemy, false)
) {
this.game.addExecution(
new AllianceRequestExecution(this.player, enemy.id()),
);
}
}
}
private getAllianceDecision(
otherPlayer: Player,
isResponse: boolean,
): boolean {
// Easy (dumb) nations sometimes get confused and accept/reject randomly (Just like dumb humans do)
if (this.isConfused()) {
return this.random.chance(2);
}
// Nearly always reject traitors
if (otherPlayer.isTraitor() && this.random.nextInt(0, 100) >= 10) {
if (isResponse && this.random.chance(3)) {
this.emojiBehavior.sendEmoji(otherPlayer, EMOJI_CONFUSED);
}
return false;
}
// Reject if otherPlayer has allied with a lot of players (Hard and Impossible only)
// To make sure there are enough non-friendly players in the game to stop the crown with nukes
if (this.hasTooManyAlliances(otherPlayer)) {
return false;
}
// Before caring about the relation, first check if the otherPlayer is a threat
// Easy (dumb) nations are blinded by hatred, they don't care about threats, they care about the relation
// Impossible (smart) nations on the other hand are analyzing the facts
if (this.isAlliancePartnerThreat(otherPlayer)) {
if (!isResponse && this.random.chance(6)) {
this.emojiBehavior.sendEmoji(otherPlayer, EMOJI_SCARED_OF_THREAT);
}
if (isResponse && this.random.chance(6)) {
this.emojiBehavior.sendEmoji(otherPlayer, EMOJI_LOVE);
}
return true;
}
// Maybe reject if we are in a team game (allying makes less sense there)
if (this.shouldRejectInTeamGame()) {
return false;
}
// Reject if relation is bad
if (this.player.relation(otherPlayer) < Relation.Neutral) {
if (isResponse && this.random.chance(3)) {
this.emojiBehavior.sendEmoji(otherPlayer, EMOJI_CONFUSED);
}
return false;
}
// Maybe accept if relation is friendly
if (this.isAlliancePartnerFriendly(otherPlayer)) {
if (this.random.chance(3)) {
this.emojiBehavior.sendEmoji(otherPlayer, EMOJI_HANDSHAKE);
}
return true;
}
// Reject if we already have some alliances, we don't want to ally with the entire map
if (this.checkAlreadyEnoughAlliances(otherPlayer)) {
return false;
}
// Maybe accept if we are in the earlygame
if (this.isEarlygame()) {
return true;
}
// Accept if we are similarly strong
return this.isAlliancePartnerSimilarlyStrong(otherPlayer);
}
private hasTooManyAlliances(otherPlayer: Player): boolean {
const { difficulty } = this.game.config().gameConfig();
if (
difficulty !== Difficulty.Hard &&
difficulty !== Difficulty.Impossible
) {
return false;
}
const totalPlayers = this.game
.players()
.filter((p) => p.type() !== PlayerType.Bot).length;
const otherPlayerAlliances = otherPlayer.alliances().length;
if (difficulty === Difficulty.Hard) {
return otherPlayerAlliances >= totalPlayers * 0.5;
} else {
return otherPlayerAlliances >= totalPlayers * 0.25;
}
}
private isConfused(): boolean {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return this.random.chance(10); // 10% chance to be confused on easy
case Difficulty.Medium:
return this.random.chance(20); // 5% chance to be confused on medium
case Difficulty.Hard:
return this.random.chance(40); // 2.5% chance to be confused on hard
case Difficulty.Impossible:
return false; // No confusion on impossible
default:
assertNever(difficulty);
}
}
private isEarlygame(): boolean {
const spawnTicks = this.game.config().numSpawnPhaseTurns();
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
// On easy, accept 90% in the first 5 minutes
return (
this.game.ticks() < 3000 + spawnTicks &&
this.random.nextInt(0, 100) >= 10
);
case Difficulty.Medium:
// On medium, accept 70% in the first 3 minutes
return (
this.game.ticks() < 1800 + spawnTicks &&
this.random.nextInt(0, 100) >= 30
);
case Difficulty.Hard:
// On hard, accept 50% in the first 3 minutes
return (
this.game.ticks() < 1800 + spawnTicks &&
this.random.nextInt(0, 100) >= 50
);
case Difficulty.Impossible:
// On impossible, accept 30% in the first minute
return (
this.game.ticks() < 600 + spawnTicks &&
this.random.nextInt(0, 100) >= 70
);
default:
assertNever(difficulty);
}
}
private isAlliancePartnerThreat(otherPlayer: Player): boolean {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
// On easy we are very dumb, we don't see anybody as a threat
return false;
case Difficulty.Medium:
// On medium we just see players with much more troops as a threat
return otherPlayer.troops() > this.player.troops() * 2.5;
case Difficulty.Hard:
// On hard we are smarter, we check for maxTroops to see the actual strength
return (
otherPlayer.troops() > this.player.troops() &&
this.game.config().maxTroops(otherPlayer) >
this.game.config().maxTroops(this.player) * 2
);
case Difficulty.Impossible: {
// On impossible we check for multiple factors and try to not mess with stronger players (we want to steamroll over weaklings)
const otherHasMoreTroops =
otherPlayer.troops() > this.player.troops() * 1.5;
const otherHasMoreMaxTroops =
otherPlayer.troops() > this.player.troops() &&
this.game.config().maxTroops(otherPlayer) >
this.game.config().maxTroops(this.player) * 1.5;
const otherHasMoreTiles =
otherPlayer.troops() > this.player.troops() &&
otherPlayer.numTilesOwned() > this.player.numTilesOwned() * 1.5;
return otherHasMoreTroops || otherHasMoreMaxTroops || otherHasMoreTiles;
}
default:
assertNever(difficulty);
}
}
private shouldRejectInTeamGame(): boolean {
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
return false;
}
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return this.random.nextInt(0, 100) < 25; // 25% chance to reject on easy
case Difficulty.Medium:
return this.random.nextInt(0, 100) < 50; // 50% chance to reject on medium
case Difficulty.Hard:
return this.random.nextInt(0, 100) < 75; // 75% chance to reject on hard
case Difficulty.Impossible:
return true; // 100% chance to reject on impossible
default:
assertNever(difficulty);
}
}
private checkAlreadyEnoughAlliances(otherPlayer: Player): boolean {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return false; // On easy we never think we have enough alliances
case Difficulty.Medium:
return this.player.alliances().length >= this.random.nextInt(4, 6);
case Difficulty.Hard:
case Difficulty.Impossible: {
// On hard and impossible we try to not ally with all our neighbors (If we have 2+ neighbors)
const borderingPlayers = this.player
.neighbors()
.filter(
(n): n is Player => n.isPlayer() && n.type() !== PlayerType.Bot,
);
const borderingFriends = borderingPlayers.filter(
(o) => this.player?.isFriendly(o) === true,
);
if (
borderingPlayers.length >= 2 &&
borderingPlayers.includes(otherPlayer)
) {
return borderingPlayers.length <= borderingFriends.length + 1;
}
if (difficulty === Difficulty.Hard) {
return this.player.alliances().length >= this.random.nextInt(3, 5);
}
return this.player.alliances().length >= this.random.nextInt(2, 4);
}
default:
assertNever(difficulty);
}
}
private isAlliancePartnerFriendly(otherPlayer: Player): boolean {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
case Difficulty.Medium:
return this.player.relation(otherPlayer) === Relation.Friendly;
case Difficulty.Hard:
return (
this.player.relation(otherPlayer) === Relation.Friendly &&
this.random.nextInt(0, 100) >= 17
);
case Difficulty.Impossible:
return (
this.player.relation(otherPlayer) === Relation.Friendly &&
this.random.nextInt(0, 100) >= 33
);
default:
assertNever(difficulty);
}
}
// It would make a lot of sense to use nextFloat here, but "there's a chance floats can cause desyncs"
private isAlliancePartnerSimilarlyStrong(otherPlayer: Player): boolean {
const { difficulty } = this.game.config().gameConfig();
const troopPercentRangeByDifficulty = {
[Difficulty.Easy]: [60, 70],
[Difficulty.Medium]: [70, 80],
[Difficulty.Hard]: [75, 85],
[Difficulty.Impossible]: [80, 90],
} as const;
const tilePercentRangeByDifficulty = {
[Difficulty.Easy]: [70, 80],
[Difficulty.Medium]: [80, 90],
[Difficulty.Hard]: [85, 95],
[Difficulty.Impossible]: [90, 100],
} as const;
const troopRange = troopPercentRangeByDifficulty[difficulty];
const tileRange = tilePercentRangeByDifficulty[difficulty];
const playerOutgoingTroops = this.player
.outgoingAttacks()
.reduce((sum, attack) => sum + attack.troops(), 0);
const otherOutgoingTroops = otherPlayer
.outgoingAttacks()
.reduce((sum, attack) => sum + attack.troops(), 0);
const playerTotalTroops = this.player.troops() + playerOutgoingTroops;
const otherTotalTroops = otherPlayer.troops() + otherOutgoingTroops;
const troopThreshold =
playerTotalTroops *
(this.random.nextInt(troopRange[0], troopRange[1]) / 100);
const tileThreshold =
this.player.numTilesOwned() *
(this.random.nextInt(tileRange[0], tileRange[1]) / 100);
const hasComparableTroops = otherTotalTroops > troopThreshold;
const hasComparableTiles =
otherPlayer.numTilesOwned() > tileThreshold &&
otherTotalTroops > playerTotalTroops * 0.5;
return hasComparableTroops || hasComparableTiles;
}
maybeBetray(otherPlayer: Player, borderingPlayerCount: number): boolean {
if (!this.player.isAlliedWith(otherPlayer)) return false;
const { difficulty } = this.game.config().gameConfig();
// Betray very weak players (For example MIRVed ones)
if (difficulty !== Difficulty.Easy && difficulty !== Difficulty.Medium) {
const otherPlayerMaxTroops = this.game.config().maxTroops(otherPlayer);
const otherPlayerOutgoingTroops = otherPlayer
.outgoingAttacks()
.reduce((sum, attack) => sum + attack.troops(), 0);
if (
otherPlayer.troops() + otherPlayerOutgoingTroops <
otherPlayerMaxTroops * 0.2 &&
otherPlayer.troops() < this.player.troops()
) {
this.betray(otherPlayer);
return true;
}
}
// Betray very weak players (similar check as above but for the easier difficulties)
// This doesn't check for maxTroops and isn't really smart. It opens the nations up for attacks, but that's intended.
if (
(difficulty === Difficulty.Easy || difficulty === Difficulty.Medium) &&
this.player.troops() >= otherPlayer.troops() * 10
) {
this.betray(otherPlayer);
return true;
}
// Betray traitors who aren't significantly stronger than us
if (
difficulty !== Difficulty.Easy &&
otherPlayer.isTraitor() &&
otherPlayer.troops() < this.player.troops() * 1.2
) {
this.betray(otherPlayer);
return true;
}
// Betray our only bordering player if we are much stronger than them
if (
difficulty !== Difficulty.Easy &&
borderingPlayerCount === 1 &&
otherPlayer.troops() * 3 < this.player.troops()
) {
this.betray(otherPlayer);
return true;
}
return false;
}
private betray(target: Player): void {
const alliance = this.player.allianceWith(target);
if (!alliance) return;
this.player.breakAlliance(alliance);
}
}