Nations now gang up on players and prioritize traitors/AFK players 🤖 (+ other improvements) (#2730)

## Description:

- **Nations now specifically target AFK players, traitors, and players
who are already being attacked (they gang up on them).** Depends on the
difficulty.
- Added ally assistance directly to the attack order (instead of calling
it separately beforehand).
- Added ally assistance to `findBestNukeTarget`.
- Removed some checks from `findBestNukeTarget` (not necessary; better
checks will follow in a dedicated nuking PR).
- Relation updates on attack now depend on difficulty (makes Easy &
Medium nations a bit easier).
- On betrayal, every nation in the game previously received a –40
relation penalty toward the betrayer. That was too extreme, so now this
only applies only to neighbors.
- Nations send fewer alliance requests now; it felt like too many
before.
- In team games, nations may now reject alliance requests more often
(depending on difficulty).
- To ensure there are enough non-friendly players to stop the crown with
nukes, nations may now reject alliance requests if the other player has
too many alliances (on Hard and Impossible difficulty).
- Rebalanced nation emoji usage a bit.
- Nations may now send an emoji when they get MIRVed.

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

---------

Co-authored-by: iamlewis <lewismmmm@gmail.com>
This commit is contained in:
FloPinguin
2025-12-29 19:16:26 +01:00
committed by GitHub
parent f1561df470
commit d321c08d92
7 changed files with 248 additions and 63 deletions
+21 -1
View File
@@ -1,6 +1,7 @@
import { renderTroops } from "../../client/Utils";
import {
Attack,
Difficulty,
Execution,
Game,
MessageType,
@@ -12,6 +13,7 @@ import {
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { assertNever } from "../Util";
import { FlatBinaryHeap } from "./utils/FlatBinaryHeap"; // adjust path if needed
const malusForRetreat = 25;
@@ -152,7 +154,25 @@ export class AttackExecution implements Execution {
}
if (this.target.isPlayer()) {
this.target.updateRelation(this._owner, -80);
const difficulty = this.mg.config().gameConfig().difficulty;
let relationChange: number;
switch (difficulty) {
case Difficulty.Easy:
relationChange = -60;
break;
case Difficulty.Medium:
relationChange = -70;
break;
case Difficulty.Hard:
relationChange = -80;
break;
case Difficulty.Impossible:
relationChange = -100;
break;
default:
assertNever(difficulty);
}
this.target.updateRelation(this._owner, relationChange);
}
}
-1
View File
@@ -458,7 +458,6 @@ export class NationExecution implements Execution {
this.allianceBehavior.maybeSendAllianceRequests(borderingEnemies);
}
this.attackBehavior.assistAllies();
this.attackBehavior.attackBestTarget(borderingFriends, borderingEnemies);
this.maybeSendNuke(
this.attackBehavior.findBestNukeTarget(borderingEnemies),
@@ -35,11 +35,16 @@ export class BreakAllianceExecution implements Execution {
console.warn("cant break alliance, not allied");
} else {
this.requestor.breakAlliance(alliance);
this.recipient.updateRelation(this.requestor, -200);
for (const player of this.mg.players()) {
if (player !== this.requestor && !player.isOnSameTeam(this.requestor)) {
player.updateRelation(this.requestor, -40);
}
this.recipient.updateRelation(this.requestor, -100);
const neighbors = this.requestor
.neighbors()
.filter(
(n): n is Player => n.isPlayer() && !n.isOnSameTeam(this.recipient!),
);
for (const neighbor of neighbors) {
neighbor.updateRelation(this.requestor, -40);
}
}
this.active = false;
@@ -1,6 +1,7 @@
import {
Difficulty,
Game,
GameMode,
Player,
PlayerType,
Relation,
@@ -59,7 +60,7 @@ export class NationAllianceBehavior {
for (const enemy of borderingEnemies) {
if (
this.random.chance(20) &&
this.random.chance(30) &&
isAcceptablePlayerType(enemy) &&
this.player.canSendAllianceRequest(enemy) &&
this.getAllianceDecision(enemy, false)
@@ -86,18 +87,27 @@ export class NationAllianceBehavior {
}
return false;
}
// Reject if otherPlayer has allied with 50% or more of all 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(3)) {
if (!isResponse && this.random.chance(6)) {
this.emojiBehavior.sendEmoji(otherPlayer, EMOJI_SCARED_OF_THREAT);
}
if (isResponse && this.random.chance(3)) {
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)) {
@@ -124,6 +134,21 @@ export class NationAllianceBehavior {
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().length;
const otherPlayerAlliances = otherPlayer.alliances().length;
return otherPlayerAlliances >= totalPlayers * 0.5;
}
private isConfused(): boolean {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
@@ -207,6 +232,26 @@ export class NationAllianceBehavior {
}
}
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 false; // 0% chance to reject on easy
case Difficulty.Medium:
return this.random.nextInt(0, 100) < 20; // 20% chance to reject on medium
case Difficulty.Hard:
return this.random.nextInt(0, 100) < 40; // 40% chance to reject on hard
case Difficulty.Impossible:
return this.random.nextInt(0, 100) < 60; // 60% chance to reject on impossible
default:
assertNever(difficulty);
}
}
private checkAlreadyEnoughAlliances(otherPlayer: Player): boolean {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
@@ -15,9 +15,7 @@ import { EmojiExecution } from "../EmojiExecution";
const emojiId = (e: (typeof flattenedEmojiTable)[number]) =>
flattenedEmojiTable.indexOf(e);
export const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(
emojiId,
);
export const EMOJI_ASSIST_ACCEPT = (["👍", "🤝", "🎯"] as const).map(emojiId);
export const EMOJI_ASSIST_RELATION_TOO_LOW = (["🥱", "🤦‍♂️"] as const).map(
emojiId,
);
@@ -34,7 +32,7 @@ export const EMOJI_LOVE = (["❤️", "😊", "🥰"] as const).map(emojiId);
export const EMOJI_CONFUSED = (["❓", "🤡"] as const).map(emojiId);
export const EMOJI_BRAG = (["👑", "🥇", "💪"] as const).map(emojiId);
export const EMOJI_CHARM_ALLIES = (["🤝", "😇", "💪"] as const).map(emojiId);
export const EMOJI_CLOWN = (["🤡"] as const).map(emojiId);
export const EMOJI_CLOWN = (["🤡", "🤦‍♂️"] as const).map(emojiId);
export const EMOJI_RAT = (["🐀"] as const).map(emojiId);
export const EMOJI_OVERWHELMED = (
["💀", "🆘", "😱", "🥺", "😭", "😞", "🫡", "👋"] as const
@@ -67,7 +65,7 @@ export class NationEmojiBehavior {
}
private checkOverwhelmedByAttacks(): void {
if (!this.random.chance(4)) return;
if (!this.random.chance(16)) return;
const incomingAttacks = this.player.incomingAttacks();
if (incomingAttacks.length === 0) return;
@@ -85,7 +83,7 @@ export class NationEmojiBehavior {
}
private checkVerySmallAttack(): void {
if (!this.random.chance(4)) return;
if (!this.random.chance(8)) return;
const incomingAttacks = this.player.incomingAttacks();
if (incomingAttacks.length === 0) return;
@@ -175,7 +173,7 @@ export class NationEmojiBehavior {
// Brag with our crown
private brag(): void {
if (!this.random.chance(100)) return;
if (!this.random.chance(300)) return;
const sorted = this.game
.players()
@@ -218,6 +216,7 @@ export class NationEmojiBehavior {
}
private findRat(): void {
if (this.game.ticks() < 6000) return; // Ignore first 10 minutes (everybody is small in the early game)
if (!this.random.chance(10000)) return;
const totalLand = this.game.numLandTiles();
@@ -340,3 +339,19 @@ export function respondToEmoji(
);
}
}
export function respondToMIRV(
game: Game,
random: PseudoRandom,
mirvTarget: Player,
) {
if (!random.chance(8)) return;
game.addExecution(
new EmojiExecution(
mirvTarget,
AllPlayers,
random.randElement(EMOJI_OVERWHELMED),
),
);
}
@@ -12,7 +12,11 @@ import { PseudoRandom } from "../../PseudoRandom";
import { assertNever } from "../../Util";
import { MirvExecution } from "../MIRVExecution";
import { calculateTerritoryCenter } from "../Util";
import { EMOJI_NUKE, NationEmojiBehavior } from "./NationEmojiBehavior";
import {
EMOJI_NUKE,
NationEmojiBehavior,
respondToMIRV,
} from "./NationEmojiBehavior";
export class NationMIRVBehavior {
constructor(
@@ -258,6 +262,7 @@ export class NationMIRVBehavior {
if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) {
this.game.addExecution(new MirvExecution(this.player, centerTile));
this.emojiBehavior.sendEmoji(AllPlayers, EMOJI_NUKE);
respondToMIRV(this.game, this.random, enemy);
}
}
+141 -45
View File
@@ -38,33 +38,7 @@ export class AiAttackBehavior {
private emojiBehavior?: NationEmojiBehavior,
) {}
assistAllies() {
if (this.emojiBehavior === undefined) throw new Error("not initialized");
for (const ally of this.player.allies()) {
if (ally.targets().length === 0) continue;
if (this.player.relation(ally) < Relation.Friendly) {
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_RELATION_TOO_LOW);
continue;
}
for (const target of ally.targets()) {
if (target === this.player) {
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ME);
continue;
}
if (this.player.isFriendly(target)) {
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ALLY);
continue;
}
// All checks passed, assist them
this.player.updateRelation(ally, -20);
this.sendAttack(target);
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_ACCEPT);
return;
}
}
}
// attackBestTarget is called with borderingFriends and borderingEnemies sorted by troops (ascending)
attackBestTarget(borderingFriends: Player[], borderingEnemies: Player[]) {
// Save up troops until we reach the reserve ratio
if (!this.hasReserveRatioTroops()) return;
@@ -101,6 +75,29 @@ export class AiAttackBehavior {
const bots = (): boolean => this.attackBots();
const assist = (): boolean => this.assistAllies();
const traitor = (): boolean => {
const weakestTraitor = this.findWeakestTraitor(borderingEnemies);
if (weakestTraitor) {
this.sendAttack(weakestTraitor);
return true;
}
return false;
};
const afk = (): boolean => {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
const weakestAfk = borderingEnemies.find((enemy) =>
enemy.isDisconnected(),
);
if (weakestAfk) {
this.sendAttack(weakestAfk);
return true;
}
return false;
};
const betray = (): boolean => this.maybeBetrayAndAttack(borderingFriends);
const nuked = (): boolean => {
@@ -111,6 +108,15 @@ export class AiAttackBehavior {
return false;
};
const victim = (): boolean => {
const weakestVictim = this.findWeakestVictim(borderingEnemies);
if (weakestVictim) {
this.sendAttack(weakestVictim);
return true;
}
return false;
};
const hated = (): boolean => {
const mostHated = this.player.allRelationsSorted()[0];
if (
@@ -126,6 +132,7 @@ export class AiAttackBehavior {
const weakest = (): boolean => {
if (borderingEnemies.length > 0) {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
this.sendAttack(borderingEnemies[0]);
return true;
}
@@ -147,19 +154,53 @@ export class AiAttackBehavior {
// Easy nations get the dumbest order, impossible nations get the smartest order
switch (difficulty) {
case Difficulty.Easy:
return [nuked, bots, retaliate, betray, hated, weakest];
return [nuked, bots, retaliate, assist, betray, hated, weakest];
case Difficulty.Medium:
return [bots, nuked, retaliate, betray, hated, weakest, island];
return [
bots,
nuked,
retaliate,
assist,
betray,
hated,
afk,
traitor,
weakest,
island,
];
case Difficulty.Hard:
return [bots, retaliate, betray, nuked, hated, weakest, island];
return [
bots,
retaliate,
assist,
betray,
nuked,
traitor,
afk,
hated,
victim,
weakest,
island,
];
case Difficulty.Impossible:
return [retaliate, bots, betray, nuked, hated, weakest, island];
return [
retaliate,
bots,
assist,
traitor,
afk,
betray,
nuked,
victim,
hated,
weakest,
island,
];
default:
assertNever(difficulty);
}
}
// TODO: Nuke the crown if it's far enough ahead of everybody else (based on difficulty)
findBestNukeTarget(borderingEnemies: Player[]): Player | null {
// Retaliate against incoming attacks (Most important!)
const incomingAttackPlayer = this.findIncomingAttackPlayer();
@@ -167,6 +208,19 @@ export class AiAttackBehavior {
return incomingAttackPlayer;
}
// Assist allies, check their targets (this is basically the same as in assistAllies, but without sending emojis)
for (const ally of this.player.allies()) {
if (ally.targets().length === 0) continue;
if (this.player.relation(ally) < Relation.Friendly) continue;
for (const target of ally.targets()) {
if (target === this.player) continue;
if (this.player.isFriendly(target)) continue;
// Found a valid ally target to nuke
return target;
}
}
// Find the most hated player with hostile relation
const mostHated = this.player.allRelationsSorted()[0];
if (
@@ -177,19 +231,6 @@ export class AiAttackBehavior {
return mostHated.player;
}
// Find the weakest player
if (borderingEnemies.length > 0) {
return borderingEnemies[0];
}
// If we don't have bordering enemies, find someone on an island next to us
if (borderingEnemies.length === 0) {
const nearestIslandEnemy = this.findNearestIslandEnemy();
if (nearestIslandEnemy) {
return nearestIslandEnemy;
}
}
return null;
}
@@ -275,6 +316,45 @@ export class AiAttackBehavior {
}
}
private assistAllies(): boolean {
if (this.emojiBehavior === undefined) throw new Error("not initialized");
for (const ally of this.player.allies()) {
if (ally.targets().length === 0) continue;
if (this.player.relation(ally) < Relation.Friendly) {
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_RELATION_TOO_LOW);
continue;
}
for (const target of ally.targets()) {
if (target === this.player) {
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ME);
continue;
}
if (this.player.isFriendly(target)) {
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ALLY);
continue;
}
// All checks passed, assist them
this.player.updateRelation(ally, -20);
this.sendAttack(target);
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_ACCEPT);
return true;
}
}
return false;
}
// Find a traitor who isn't much stronger than us (max 20% more troops)
private findWeakestTraitor(borderingEnemies: Player[]): Player | null {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
return (
borderingEnemies.find(
(enemy) =>
enemy.isTraitor() && enemy.troops() * 1.2 < this.player.troops(),
) ?? null
);
}
private maybeBetrayAndAttack(borderingFriends: Player[]): boolean {
if (this.allianceBehavior === undefined) throw new Error("not initialized");
@@ -304,6 +384,22 @@ export class AiAttackBehavior {
return false;
}
// Find someone who is weaker than us and is under big attack from others (50%+ of their troops incoming)
private findWeakestVictim(borderingEnemies: Player[]): Player | null {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
return (
borderingEnemies.find((enemy) => {
if (enemy.troops() >= this.player.troops()) return false;
const totalIncomingTroops = enemy
.incomingAttacks()
.reduce((sum, attack) => sum + attack.troops(), 0);
return totalIncomingTroops > enemy.troops() * 0.5;
}) ?? null
);
}
private findNearestIslandEnemy(): Player | null {
const myBorder = this.player.borderTiles();
if (myBorder.size === 0) return null;