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
}
+171
View File
@@ -0,0 +1,171 @@
import { AllianceBehavior } from "../src/core/execution/utils/AllianceBehavior";
import {
AllianceRequest,
Game,
Player,
PlayerInfo,
PlayerType,
Tick,
} from "../src/core/game/Game";
import { PseudoRandom } from "../src/core/PseudoRandom";
import { setup } from "./util/Setup";
let game: Game;
let player: Player;
let requestor: Player;
let allianceBehavior: AllianceBehavior;
describe("AllianceBehavior.handleAllianceRequests", () => {
beforeEach(async () => {
game = await setup("big_plains", {
infiniteGold: true,
instantBuild: true,
});
const playerInfo = new PlayerInfo(
"player_id",
PlayerType.Bot,
null,
"player_id",
);
const requestorInfo = new PlayerInfo(
"requestor_id",
PlayerType.Human,
null,
"requestor_id",
);
game.addPlayer(playerInfo);
game.addPlayer(requestorInfo);
player = game.player("player_id");
requestor = game.player("requestor_id");
// Use a fixed random seed for deterministic behavior
const random = new PseudoRandom(46);
allianceBehavior = new AllianceBehavior(random, game, player);
});
function setupAllianceRequest({
isTraitor = false,
relationDelta = 2,
numTilesPlayer = 10,
numTilesRequestor = 10,
alliancesCount = 0,
} = {}) {
if (isTraitor) requestor.markTraitor();
player.updateRelation(requestor, relationDelta);
requestor.updateRelation(player, relationDelta);
game.map().forEachTile((tile) => {
if (game.map().isLand(tile)) {
if (numTilesPlayer > 0) {
player.conquer(tile);
numTilesPlayer--;
} else if (numTilesRequestor > 0) {
requestor.conquer(tile);
numTilesRequestor--;
}
}
});
jest.spyOn(player, "alliances").mockReturnValue(new Array(alliancesCount));
const mockRequest = {
requestor: () => requestor,
recipient: () => player,
createdAt: () => 0 as unknown as Tick,
accept: jest.fn(),
reject: jest.fn(),
} as unknown as AllianceRequest;
jest
.spyOn(player, "incomingAllianceRequests")
.mockReturnValue([mockRequest]);
return mockRequest;
}
test("should accept alliance when all conditions are met", () => {
const request = setupAllianceRequest({});
allianceBehavior.handleAllianceRequests();
expect(request.accept).toHaveBeenCalled();
expect(request.reject).not.toHaveBeenCalled();
});
test("should reject alliance if requestor is a traitor", () => {
const request = setupAllianceRequest({ isTraitor: true });
allianceBehavior.handleAllianceRequests();
expect(request.accept).not.toHaveBeenCalled();
expect(request.reject).toHaveBeenCalled();
});
test("should reject alliance if relation is hostile", () => {
const request = setupAllianceRequest({ relationDelta: -2 });
allianceBehavior.handleAllianceRequests();
expect(request.accept).not.toHaveBeenCalled();
expect(request.reject).toHaveBeenCalled();
});
test("should accept alliance if requestor is much larger (> 3 times size of recipient)", () => {
const request = setupAllianceRequest({
numTilesRequestor: 40,
});
allianceBehavior.handleAllianceRequests();
expect(request.accept).toHaveBeenCalled();
expect(request.reject).not.toHaveBeenCalled();
});
test("should reject alliance if player has too many alliances", () => {
const request = setupAllianceRequest({ alliancesCount: 10 });
allianceBehavior.handleAllianceRequests();
expect(request.accept).not.toHaveBeenCalled();
expect(request.reject).toHaveBeenCalled();
});
});
describe("AllianceBehavior.handleAllianceExtensionRequests", () => {
let mockGame: any;
let mockPlayer: any;
let mockAlliance: any;
let mockHuman: any;
let mockRandom: any;
let allianceBehavior: AllianceBehavior;
beforeEach(() => {
mockGame = { addExecution: jest.fn() };
mockHuman = { id: jest.fn(() => "human_id") };
mockAlliance = {
onlyOneAgreedToExtend: jest.fn(() => true),
other: jest.fn(() => mockHuman),
};
mockRandom = { chance: jest.fn() };
mockPlayer = {
alliances: jest.fn(() => [mockAlliance]),
relation: jest.fn(),
id: jest.fn(() => "bot_id"),
type: jest.fn(() => PlayerType.FakeHuman),
};
allianceBehavior = new AllianceBehavior(mockRandom, mockGame, mockPlayer);
});
it("should NOT request extension if onlyOneAgreedToExtend is false (no expiration yet or both already agreed)", () => {
mockAlliance.onlyOneAgreedToExtend.mockReturnValue(false);
allianceBehavior.handleAllianceExtensionRequests();
expect(mockGame.addExecution).not.toHaveBeenCalled();
});
});
+1 -226
View File
@@ -1,233 +1,8 @@
import { AllianceExtensionExecution } from "../src/core/execution/alliance/AllianceExtensionExecution";
import { BotBehavior } from "../src/core/execution/utils/BotBehavior";
import {
AllianceRequest,
Game,
Player,
PlayerInfo,
PlayerType,
Relation,
Tick,
} from "../src/core/game/Game";
import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game";
import { PseudoRandom } from "../src/core/PseudoRandom";
import { setup } from "./util/Setup";
let game: Game;
let player: Player;
let requestor: Player;
let botBehavior: BotBehavior;
describe("BotBehavior.handleAllianceRequests", () => {
beforeEach(async () => {
game = await setup("big_plains", {
infiniteGold: true,
instantBuild: true,
});
const playerInfo = new PlayerInfo(
"player_id",
PlayerType.Bot,
null,
"player_id",
);
const requestorInfo = new PlayerInfo(
"requestor_id",
PlayerType.Human,
null,
"requestor_id",
);
game.addPlayer(playerInfo);
game.addPlayer(requestorInfo);
player = game.player("player_id");
requestor = game.player("requestor_id");
const random = new PseudoRandom(42);
botBehavior = new BotBehavior(random, game, player, 0.5, 0.5, 0.2);
});
function setupAllianceRequest({
isTraitor = false,
relationDelta = 2,
numTilesPlayer = 10,
numTilesRequestor = 10,
alliancesCount = 0,
} = {}) {
if (isTraitor) requestor.markTraitor();
player.updateRelation(requestor, relationDelta);
requestor.updateRelation(player, relationDelta);
game.map().forEachTile((tile) => {
if (game.map().isLand(tile)) {
if (numTilesPlayer > 0) {
player.conquer(tile);
numTilesPlayer--;
} else if (numTilesRequestor > 0) {
requestor.conquer(tile);
numTilesRequestor--;
}
}
});
jest
.spyOn(requestor, "alliances")
.mockReturnValue(new Array(alliancesCount));
const mockRequest = {
requestor: () => requestor,
recipient: () => player,
createdAt: () => 0 as unknown as Tick,
accept: jest.fn(),
reject: jest.fn(),
} as unknown as AllianceRequest;
jest
.spyOn(player, "incomingAllianceRequests")
.mockReturnValue([mockRequest]);
return mockRequest;
}
test("should accept alliance when all conditions are met", () => {
const request = setupAllianceRequest({});
botBehavior.handleAllianceRequests();
expect(request.accept).toHaveBeenCalled();
expect(request.reject).not.toHaveBeenCalled();
});
test("should reject alliance if requestor is a traitor", () => {
const request = setupAllianceRequest({ isTraitor: true });
botBehavior.handleAllianceRequests();
expect(request.accept).not.toHaveBeenCalled();
expect(request.reject).toHaveBeenCalled();
});
test("should reject alliance if relation is malicious", () => {
const request = setupAllianceRequest({ relationDelta: -2 });
botBehavior.handleAllianceRequests();
expect(request.accept).not.toHaveBeenCalled();
expect(request.reject).toHaveBeenCalled();
});
test("should accept alliance if requestor is much larger (> 3 times size of recipient) and has too many alliances (>= 3)", () => {
const request = setupAllianceRequest({
numTilesRequestor: 40,
alliancesCount: 4,
});
botBehavior.handleAllianceRequests();
expect(request.accept).toHaveBeenCalled();
expect(request.reject).not.toHaveBeenCalled();
});
test("should accept alliance if requestor is much larger (> 3 times size of recipient) and does not have too many alliances (< 3)", () => {
const request = setupAllianceRequest({
numTilesRequestor: 40,
alliancesCount: 2,
});
botBehavior.handleAllianceRequests();
expect(request.accept).toHaveBeenCalled();
expect(request.reject).not.toHaveBeenCalled();
});
test("should reject alliance if requestor is acceptably small (<= 3 times size of recipient) and has too many alliances (>= 3)", () => {
const request = setupAllianceRequest({ alliancesCount: 3 });
botBehavior.handleAllianceRequests();
expect(request.accept).not.toHaveBeenCalled();
expect(request.reject).toHaveBeenCalled();
});
});
describe("BotBehavior.handleAllianceExtensionRequests", () => {
let mockGame: any;
let mockPlayer: any;
let mockAlliance: any;
let mockHuman: any;
let mockRandom: any;
let botBehavior: BotBehavior;
beforeEach(() => {
mockGame = { addExecution: jest.fn() };
mockHuman = { id: jest.fn(() => "human_id") };
mockAlliance = {
onlyOneAgreedToExtend: jest.fn(() => true),
other: jest.fn(() => mockHuman),
};
mockRandom = { chance: jest.fn() };
mockPlayer = {
alliances: jest.fn(() => [mockAlliance]),
relation: jest.fn(),
id: jest.fn(() => "bot_id"),
type: jest.fn(() => PlayerType.FakeHuman),
};
botBehavior = new BotBehavior(
mockRandom,
mockGame,
mockPlayer,
0.5,
0.5,
0.2,
);
});
it("should NOT request extension if onlyOneAgreedToExtend is false (no expiration yet or both already agreed)", () => {
mockAlliance.onlyOneAgreedToExtend.mockReturnValue(false);
botBehavior.handleAllianceExtensionRequests();
expect(mockGame.addExecution).not.toHaveBeenCalled();
});
it("should always extend if type Bot", () => {
mockPlayer.type.mockReturnValue(PlayerType.Bot);
botBehavior.handleAllianceExtensionRequests();
expect(mockGame.addExecution).toHaveBeenCalledTimes(1);
expect(mockGame.addExecution.mock.calls[0][0]).toBeInstanceOf(
AllianceExtensionExecution,
);
});
it("should always extend if Nation and relation is Friendly", () => {
mockPlayer.relation.mockReturnValue(Relation.Friendly);
botBehavior.handleAllianceExtensionRequests();
expect(mockGame.addExecution).toHaveBeenCalledTimes(1);
expect(mockGame.addExecution.mock.calls[0][0]).toBeInstanceOf(
AllianceExtensionExecution,
);
});
it("should extend if Nation, relation is Neutral and random chance is true", () => {
mockPlayer.relation.mockReturnValue(Relation.Neutral);
mockRandom.chance.mockReturnValue(true);
botBehavior.handleAllianceExtensionRequests();
expect(mockGame.addExecution).toHaveBeenCalledTimes(1);
expect(mockGame.addExecution.mock.calls[0][0]).toBeInstanceOf(
AllianceExtensionExecution,
);
});
it("should NOT extend if Nation, relation is Neutral and random chance is false", () => {
mockPlayer.relation.mockReturnValue(Relation.Neutral);
mockRandom.chance.mockReturnValue(false);
botBehavior.handleAllianceExtensionRequests();
expect(mockGame.addExecution).not.toHaveBeenCalled();
});
});
describe("BotBehavior Attack Behavior", () => {
let game: Game;
let bot: Player;