Cleanup nations (Part 1) 🧹 (#2637)

## Description:

1. Using the wording `"Nation"`, `"FakeHuman"` and `"NPC"` at the same
time is confusing.
So I renamed every mention of `"FakeHuman"` and `"NPC"` in the entire
project to `"Nation"`. Just like they are called ingame.

2. `BotBehavior.ts` was originally intended for sharing the logic
between nations and bots.
But at the moment, the logic there isn't really shared and it's
basically just about attacking.
So I renamed `BotBehavior.ts` to `AiAttackBehavior.ts`. I use "Ai" to
indicate that this file is used by bots AND nations.

3. Moved `execuction/utils/AllianceBehavior.ts` to
`execuction/nation/NationAllianceBehavior.ts` to make sure everybody
understands that this file is not about alliances in general. It's just
about nations and how they handle alliances.

4. Removed `difficultyModifier` from `DefaultConfig`. It's unused and I
think we usually want to finetune the difficulty instead of using that
method.

5. Added `assertNever` in all `switch (difficulty)` default cases.

## 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-19 01:20:23 +01:00
committed by GitHub
parent f60aef65e1
commit 4d5bb7a835
26 changed files with 290 additions and 278 deletions
@@ -9,6 +9,7 @@ import {
} from "../../game/Game";
import { PseudoRandom } from "../../PseudoRandom";
import {
assertNever,
boundingBoxCenter,
calculateBoundingBoxCenter,
flattenedEmojiTable,
@@ -26,7 +27,7 @@ const EMOJI_TARGET_ME = (["🥺", "💀"] as const).map(emojiId);
const EMOJI_TARGET_ALLY = (["🕊️", "👎"] as const).map(emojiId);
const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId);
export class BotBehavior {
export class AiAttackBehavior {
private botAttackTroopsSent: number = 0;
private readonly lastEmojiSent = new Map<Player, Tick>();
@@ -233,8 +234,11 @@ export class BotBehavior {
case Difficulty.Hard:
return 4;
// On impossible difficulty, attack as much bots as possible in parallel
default:
case Difficulty.Impossible: {
return 100;
}
default:
assertNever(difficulty);
}
}
@@ -380,7 +384,7 @@ export class BotBehavior {
if (!neighbor.isPlayer()) continue;
if (this.player.isFriendly(neighbor)) continue;
if (
neighbor.type() === PlayerType.FakeHuman ||
neighbor.type() === PlayerType.Nation ||
neighbor.type() === PlayerType.Human
) {
if (this.random.chance(2) || difficulty === Difficulty.Easy) {
@@ -1,232 +0,0 @@
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)
);
}
}
}