Files
OpenFrontIO/src/core/execution/nation/NationAllianceBehavior.ts
T
Mattia Migliorini a9c89e4f15 Fix: Nations reject alliance requests during spawn phase (#3312)
## Description:

This PR fixes an exploit that allows the player to request alliances to
Nations, mostly in impossible mode, during spawn phase, with high
chances for it to be accepted due to troop count parity.

Nations now reject alliance requests during the spawn phase.

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

deshack_82603
2026-03-01 11:20:19 +00:00

423 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 {
// Reject alliance requests during the spawn phase
if (this.game.inSpawnPhase()) {
return false;
}
// 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);
}
}