mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-04 06:00:39 +00:00
a9c89e4f15
## 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
423 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|