Improved the nation alliance request logic 🤝 Massive upgrade to singleplayer fun (#2606)

## Response to alliance requests

Previously the way nations responded to alliance requests was quite
simple / boring / exploitable. Basically you couldn't ally them if you
had a bad relation with them, or if you had too many alliances.
Otherwise they would just take it.

Now there is a **complete decision tree which is based on the
difficulty**. The nations should also feel more human now.

For example, just like humans, nations will now consider to take an
alliance even if you have a bad relation with them (If you are a
threat).

Also, nations no longer check if YOU have too many alliances. Now they
do what humans do: Check if THEY have too many alliances (they want to
be able to attack somebody).

Another big change is the default case: Previously it was just `return
true`. Now it's `return isAlliancePartnerSimilarlyStrong`. So they do
what humans do: Take a quick look at their troop count before allying
them.

## Sending alliance requests

Previously alliance requests were sent randomly. Quite boring.

Now we use the same decision tree as for responding.

## Alliance extension requests

They also use the same decision tree.

## Tests

Tested it a lot in singleplayer.
I have planned to add unit tests for all the nation/bot stuff in the
upcoming cleanup 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:

FloPinguin
This commit is contained in:
FloPinguin
2025-12-15 00:18:07 +01:00
committed by GitHub
parent 66ae8cd9bb
commit dfe33a05e9
6 changed files with 448 additions and 296 deletions
+21 -2
View File
@@ -1,6 +1,7 @@
import { Execution, Game, Player } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
import { BotBehavior } from "./utils/BotBehavior";
export class BotExecution implements Execution {
@@ -56,11 +57,29 @@ export class BotExecution implements Execution {
return;
}
this.behavior.handleAllianceRequests();
this.behavior.handleAllianceExtensionRequests();
this.acceptAllAllianceRequests();
this.maybeAttack();
}
private acceptAllAllianceRequests() {
// Accept all alliance requests
for (const req of this.bot.incomingAllianceRequests()) {
req.accept();
}
// Accept all alliance extension requests
for (const alliance of this.bot.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 / bot already agreed to extend
if (!alliance.onlyOneAgreedToExtend()) continue;
const human = alliance.other(this.bot);
this.mg.addExecution(
new AllianceExtensionExecution(this.bot, human.id()),
);
}
}
private maybeAttack() {
if (this.behavior === null) {
throw new Error("not initialized");
+23 -18
View File
@@ -18,7 +18,6 @@ import { TileRef, euclDistFN } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
import { boundingBoxTiles, calculateBoundingBox, simpleHash } from "../Util";
import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
import { ConstructionExecution } from "./ConstructionExecution";
import { MirvExecution } from "./MIRVExecution";
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
@@ -26,12 +25,14 @@ import { NukeExecution } from "./NukeExecution";
import { SpawnExecution } from "./SpawnExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { calculateTerritoryCenter, closestTwoTiles } from "./Util";
import { AllianceBehavior } from "./utils/AllianceBehavior";
import { BotBehavior } from "./utils/BotBehavior";
export class FakeHumanExecution implements Execution {
private active = true;
private random: PseudoRandom;
private behavior: BotBehavior | null = null; // Shared behavior logic for both bots and fakehumans
private allianceBehavior: AllianceBehavior | null = null;
private mg: Game;
private player: Player | null = null;
@@ -175,7 +176,7 @@ export class FakeHumanExecution implements Execution {
return;
}
if (this.behavior === null) {
if (this.behavior === null || this.allianceBehavior === null) {
// Player is unavailable during init()
this.behavior = new BotBehavior(
this.random,
@@ -185,6 +186,11 @@ export class FakeHumanExecution implements Execution {
this.reserveRatio,
this.expandRatio,
);
this.allianceBehavior = new AllianceBehavior(
this.random,
this.mg,
this.player,
);
// Send an attack on the first tick
this.behavior.forceSendAttack(this.mg.terraNullius());
@@ -192,8 +198,8 @@ export class FakeHumanExecution implements Execution {
}
this.updateRelationsFromEmbargos();
this.behavior.handleAllianceRequests();
this.behavior.handleAllianceExtensionRequests();
this.allianceBehavior.handleAllianceRequests();
this.allianceBehavior.handleAllianceExtensionRequests();
this.handleUnits();
this.handleEmbargoesToHostileNations();
this.considerMIRV();
@@ -201,7 +207,11 @@ export class FakeHumanExecution implements Execution {
}
private maybeAttack() {
if (this.player === null || this.behavior === null) {
if (
this.player === null ||
this.behavior === null ||
this.allianceBehavior === null
) {
throw new Error("not initialized");
}
@@ -211,10 +221,13 @@ export class FakeHumanExecution implements Execution {
(t) =>
this.mg.isLand(t) && this.mg.ownerID(t) !== this.player?.smallID(),
);
const borderingPlayers = border
.map((t) => this.mg.playerBySmallID(this.mg.ownerID(t)))
.filter((o) => o.isPlayer())
.sort((a, b) => a.troops() - b.troops());
const borderingPlayers = [
...new Set(
border
.map((t) => this.mg.playerBySmallID(this.mg.ownerID(t)))
.filter((o): o is Player => o.isPlayer()),
),
].sort((a, b) => a.troops() - b.troops());
const borderingFriends = borderingPlayers.filter(
(o) => this.player?.isFriendly(o) === true,
);
@@ -241,15 +254,7 @@ export class FakeHumanExecution implements Execution {
return;
}
// 5% chance to send a random alliance request
if (this.random.chance(20)) {
const toAlly = this.random.randElement(borderingEnemies);
if (this.player.canSendAllianceRequest(toAlly)) {
this.mg.addExecution(
new AllianceRequestExecution(this.player, toAlly.id()),
);
}
}
this.allianceBehavior.maybeSendAllianceRequests(borderingEnemies);
}
this.behavior.assistAllies();
@@ -0,0 +1,232 @@
import {
Difficulty,
Game,
Player,
PlayerType,
Relation,
} from "../../game/Game";
import { PseudoRandom } from "../../PseudoRandom";
import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecution";
import { AllianceRequestExecution } from "../alliance/AllianceRequestExecution";
export class AllianceBehavior {
constructor(
private random: PseudoRandom,
private game: Game,
private player: Player,
) {}
handleAllianceRequests() {
for (const req of this.player.incomingAllianceRequests()) {
if (this.getAllianceRequestDecision(req.requestor())) {
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.getAllianceRequestDecision(human)) continue;
this.game.addExecution(
new AllianceExtensionExecution(this.player, human.id()),
);
}
}
maybeSendAllianceRequests(borderingEnemies: Player[]) {
// Impossible / smart nations know the strategic value of alliances and thus send more requests
const { difficulty } = this.game.config().gameConfig();
const shouldSendAllianceRequest = () => {
switch (difficulty) {
case Difficulty.Easy:
return this.random.chance(35);
case Difficulty.Medium:
return this.random.chance(30);
case Difficulty.Hard:
return this.random.chance(25);
default:
return this.random.chance(20);
}
};
// Only easy nations are allowed to send alliance requests to bots
const isAcceptablePlayerType = (p: Player) =>
(p.type() === PlayerType.Bot && difficulty === Difficulty.Easy) ||
p.type() !== PlayerType.Bot;
for (const enemy of borderingEnemies) {
if (
shouldSendAllianceRequest() &&
isAcceptablePlayerType(enemy) &&
this.player.canSendAllianceRequest(enemy) &&
this.getAllianceRequestDecision(enemy)
) {
this.game.addExecution(
new AllianceRequestExecution(this.player, enemy.id()),
);
}
}
}
private getAllianceRequestDecision(otherPlayer: Player): 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) {
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)) {
return true;
}
// Reject if relation is bad
if (this.player.relation(otherPlayer) < Relation.Neutral) {
return false;
}
// Maybe accept if relation is friendly
if (this.isAlliancePartnerFriendly(otherPlayer)) {
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;
}
// Accept if we are similarly strong
return this.isAlliancePartnerSimilarlyStrong(otherPlayer);
}
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
default:
return false; // No confusion on impossible
}
}
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
);
default: {
// 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;
}
}
}
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(5, 8);
default: {
// On hard and impossible we try to not ally with all our neighbors (If we have 3+ 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 >= 3 &&
borderingPlayers.includes(otherPlayer)
) {
return borderingPlayers.length <= borderingFriends.length + 1;
}
if (difficulty === Difficulty.Hard) {
return this.player.alliances().length >= this.random.nextInt(3, 6);
}
return this.player.alliances().length >= this.random.nextInt(2, 5);
}
}
}
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
);
default:
return (
this.player.relation(otherPlayer) === Relation.Friendly &&
this.random.nextInt(0, 100) >= 33
);
}
}
// 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();
switch (difficulty) {
case Difficulty.Easy:
return (
otherPlayer.troops() >
this.player.troops() * (this.random.nextInt(60, 70) / 100)
);
case Difficulty.Medium:
return (
otherPlayer.troops() >
this.player.troops() * (this.random.nextInt(70, 80) / 100)
);
case Difficulty.Hard:
return (
otherPlayer.troops() >
this.player.troops() * (this.random.nextInt(75, 85) / 100)
);
default:
return (
otherPlayer.troops() >
this.player.troops() * (this.random.nextInt(80, 90) / 100)
);
}
}
}
-50
View File
@@ -1,5 +1,4 @@
import {
AllianceRequest,
Difficulty,
Game,
Player,
@@ -14,7 +13,6 @@ import {
calculateBoundingBoxCenter,
flattenedEmojiTable,
} from "../../Util";
import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecution";
import { AttackExecution } from "../AttackExecution";
import { EmojiExecution } from "../EmojiExecution";
import { TransportShipExecution } from "../TransportShipExecution";
@@ -41,38 +39,6 @@ export class BotBehavior {
private expandRatio: number,
) {}
handleAllianceRequests() {
for (const req of this.player.incomingAllianceRequests()) {
if (shouldAcceptAllianceRequest(this.player, req)) {
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/ bot already agreed to extend
if (!alliance.onlyOneAgreedToExtend()) continue;
// Nation is either Friendly or Neutral as an ally. Bot has no attitude
// If Friendly or Bot, always agree to extend. If Neutral, have random chance decide
const human = alliance.other(this.player);
if (
this.player.type() === PlayerType.FakeHuman &&
this.player.relation(human) === Relation.Neutral
) {
if (!this.random.chance(1.5)) continue;
}
this.game.addExecution(
new AllianceExtensionExecution(this.player, human.id()),
);
}
}
private emoji(player: Player, emoji: number) {
if (player.type() !== PlayerType.Human) return;
this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji));
@@ -571,19 +537,3 @@ export class BotBehavior {
);
}
}
function shouldAcceptAllianceRequest(player: Player, request: AllianceRequest) {
if (player.relation(request.requestor()) < Relation.Neutral) {
return false; // Reject if hasMalice
}
if (request.requestor().isTraitor()) {
return false; // Reject if isTraitor
}
if (request.requestor().numTilesOwned() > player.numTilesOwned() * 3) {
return true; // Accept if requestorIsMuchLarger
}
if (request.requestor().alliances().length >= 3) {
return false; // Reject if tooManyAlliances
}
return true; // Accept otherwise
}