From d321c08d928b0a1c9dce131095c6518bd366e15f Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:16:26 +0100 Subject: [PATCH] =?UTF-8?q?Nations=20now=20gang=20up=20on=20players=20and?= =?UTF-8?q?=20prioritize=20traitors/AFK=20players=20=F0=9F=A4=96=20(+=20ot?= =?UTF-8?q?her=20improvements)=20(#2730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- src/core/execution/AttackExecution.ts | 22 ++- src/core/execution/NationExecution.ts | 1 - .../alliance/BreakAllianceExecution.ts | 15 +- .../nation/NationAllianceBehavior.ts | 51 ++++- .../execution/nation/NationEmojiBehavior.ts | 29 ++- .../execution/nation/NationMIRVBehavior.ts | 7 +- src/core/execution/utils/AiAttackBehavior.ts | 186 +++++++++++++----- 7 files changed, 248 insertions(+), 63 deletions(-) diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 5997c1320..d123bd159 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -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); } } diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index 961591378..015f8a46b 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -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), diff --git a/src/core/execution/alliance/BreakAllianceExecution.ts b/src/core/execution/alliance/BreakAllianceExecution.ts index db5c158d9..36d774527 100644 --- a/src/core/execution/alliance/BreakAllianceExecution.ts +++ b/src/core/execution/alliance/BreakAllianceExecution.ts @@ -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; diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts index 036ad9d3e..10e70fffc 100644 --- a/src/core/execution/nation/NationAllianceBehavior.ts +++ b/src/core/execution/nation/NationAllianceBehavior.ts @@ -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) { diff --git a/src/core/execution/nation/NationEmojiBehavior.ts b/src/core/execution/nation/NationEmojiBehavior.ts index 1a23b6b5a..c69ffcc2d 100644 --- a/src/core/execution/nation/NationEmojiBehavior.ts +++ b/src/core/execution/nation/NationEmojiBehavior.ts @@ -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), + ), + ); +} diff --git a/src/core/execution/nation/NationMIRVBehavior.ts b/src/core/execution/nation/NationMIRVBehavior.ts index 1f5dfd374..284d29862 100644 --- a/src/core/execution/nation/NationMIRVBehavior.ts +++ b/src/core/execution/nation/NationMIRVBehavior.ts @@ -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); } } diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index 853bb2855..f6c005440 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -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;