From 4e62114ea0da95ab44c26d4a48950104e8df08a6 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:13:07 +0100 Subject: [PATCH] =?UTF-8?q?Improve=20nations=20=F0=9F=A4=96=20(#3206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: - `AiAttackBehavior`: Because bots delete stolen structures now, nations prioritize attacking bots with structures - `NationMIRVBehavior`: Nations no longer MIRV enemies who already got MIRVed in the last 30 seconds. Some humans complained about getting double-MIRVed by nations. And in games with very high starting gold, ALL nations MIRVed the same player (stop steamroll logic). - `NationAllianceBehavior`: Fixes a comparison logic bug (Thanks to Deshack) - `NationNukeBehavior.ts`: Little atom bomb perceived cost balance change - `MIRVExecution`: To make sure the MIRVing nations are attacking the MIRVed nations (even if they don't share a border), the relation gets updated in both directions now. - `SinglePlayerModal` & `HostLobbyModal`: Update the default difficulty to "Medium" (to synchronize the defaults with the public game default) ## 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 --- src/core/execution/MIRVExecution.ts | 1 + .../nation/NationAllianceBehavior.ts | 4 +- .../execution/nation/NationMIRVBehavior.ts | 28 ++++++++++-- .../execution/nation/NationNukeBehavior.ts | 4 +- src/core/execution/utils/AiAttackBehavior.ts | 43 +++++++++++++++++-- 5 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index ebdba59e5..b840976b9 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -63,6 +63,7 @@ export class MirvExecution implements Execution { } if (this.targetPlayer !== this.player) { this.targetPlayer.updateRelation(this.player, -100); + this.player.updateRelation(this.targetPlayer, -100); } } } diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts index 2cffd925f..186a3b556 100644 --- a/src/core/execution/nation/NationAllianceBehavior.ts +++ b/src/core/execution/nation/NationAllianceBehavior.ts @@ -87,7 +87,7 @@ export class NationAllianceBehavior { } return false; } - // Reject if otherPlayer has allied with 50% or more of all players (Hard and Impossible only) + // Reject if otherPlayer has allied with a lot of 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; @@ -148,7 +148,7 @@ export class NationAllianceBehavior { .filter((p) => p.type() !== PlayerType.Bot).length; const otherPlayerAlliances = otherPlayer.alliances().length; - if (difficulty !== Difficulty.Hard) { + if (difficulty === Difficulty.Hard) { return otherPlayerAlliances >= totalPlayers * 0.5; } else { return otherPlayerAlliances >= totalPlayers * 0.25; diff --git a/src/core/execution/nation/NationMIRVBehavior.ts b/src/core/execution/nation/NationMIRVBehavior.ts index 284d29862..f935665f9 100644 --- a/src/core/execution/nation/NationMIRVBehavior.ts +++ b/src/core/execution/nation/NationMIRVBehavior.ts @@ -4,7 +4,9 @@ import { Game, Gold, Player, + PlayerID, PlayerType, + Tick, UnitType, } from "../../game/Game"; import { TileRef } from "../../game/GameMap"; @@ -18,7 +20,15 @@ import { respondToMIRV, } from "./NationEmojiBehavior"; +// 30 seconds at 10 ticks/second +const MIRV_COOLDOWN_TICKS = 300; + export class NationMIRVBehavior { + // Shared across all NationMIRVBehavior instances. + // Tracks the last tick a MIRV was sent at each player, so multiple nations don't pile-on the same target. + // Especially important for games with very high starting gold settings. + private static recentMirvTargets = new Map(); + constructor( private random: PseudoRandom, private game: Game, @@ -119,19 +129,19 @@ export class NationMIRVBehavior { } const inboundMIRVSender = this.selectCounterMirvTarget(); - if (inboundMIRVSender) { + if (inboundMIRVSender && !this.wasRecentlyMirved(inboundMIRVSender)) { this.maybeSendMIRV(inboundMIRVSender); return true; } const victoryDenialTarget = this.selectVictoryDenialTarget(); - if (victoryDenialTarget) { + if (victoryDenialTarget && !this.wasRecentlyMirved(victoryDenialTarget)) { this.maybeSendMIRV(victoryDenialTarget); return true; } const steamrollStopTarget = this.selectSteamrollStopTarget(); - if (steamrollStopTarget) { + if (steamrollStopTarget && !this.wasRecentlyMirved(steamrollStopTarget)) { this.maybeSendMIRV(steamrollStopTarget); return true; } @@ -223,6 +233,17 @@ export class NationMIRVBehavior { return null; } + // MIRV Cooldown Methods + private wasRecentlyMirved(target: Player): boolean { + const lastTick = NationMIRVBehavior.recentMirvTargets.get(target.id()); + if (lastTick === undefined) return false; + return this.game.ticks() - lastTick < MIRV_COOLDOWN_TICKS; + } + + private recordMirvHit(target: Player): void { + NationMIRVBehavior.recentMirvTargets.set(target.id(), this.game.ticks()); + } + // MIRV Helper Methods private getValidMirvTargetPlayers(): Player[] { if (this.player === null) throw new Error("not initialized"); @@ -261,6 +282,7 @@ export class NationMIRVBehavior { const centerTile = this.calculateTerritoryCenter(enemy); if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) { this.game.addExecution(new MirvExecution(this.player, centerTile)); + this.recordMirvHit(enemy); this.emojiBehavior.sendEmoji(AllPlayers, EMOJI_NUKE); respondToMIRV(this.game, this.random, enemy); } diff --git a/src/core/execution/nation/NationNukeBehavior.ts b/src/core/execution/nation/NationNukeBehavior.ts index cddd8b88e..7ab12df5e 100644 --- a/src/core/execution/nation/NationNukeBehavior.ts +++ b/src/core/execution/nation/NationNukeBehavior.ts @@ -659,8 +659,8 @@ export class NationNukeBehavior { this.recentlySentNukes.push([tick, tile, nukeType]); if (nukeType === UnitType.AtomBomb) { this.atomBombsLaunched++; - // Increase perceived cost by 35% each time to simulate saving up for a MIRV (higher than hydro to make atom bombs less attractive for the lategame) - this.atomBombPerceivedCost = (this.atomBombPerceivedCost * 135n) / 100n; + // Increase perceived cost by 50% each time to simulate saving up for a MIRV (higher than hydro to make atom bombs less attractive for the lategame) + this.atomBombPerceivedCost = (this.atomBombPerceivedCost * 150n) / 100n; } else if (nukeType === UnitType.HydrogenBomb) { this.hydrogenBombsLaunched++; // Increase perceived cost by 25% each time to simulate saving up for a MIRV diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index 5d6cedc08..af39c2028 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -3,6 +3,7 @@ import { Game, GameMode, HumansVsNations, + isStructureType, Player, PlayerID, PlayerType, @@ -199,6 +200,13 @@ export class AiAttackBehavior { borderingFriends: Player[], borderingEnemies: Player[], ) { + // In games with high starting gold, nations will quickly build a lot of cities + // This causes them to expand slowly (cities increase max troops), and bots will steal their structures + // In this case: Attack bots before ratio checks + if (this.hasNeighboringBotWithStructures()) { + if (this.attackBots()) return; + } + // Save up troops until we reach the reserve ratio if (!this.hasReserveRatioTroops()) return; @@ -345,6 +353,18 @@ export class AiAttackBehavior { } } + private hasNeighboringBotWithStructures(): boolean { + return this.player + .neighbors() + .some( + (n) => + n.isPlayer() && + n.type() === PlayerType.Bot && + !this.player.isFriendly(n) && + n.units().some((u) => isStructureType(u.type())), + ); + } + private hasReserveRatioTroops(): boolean { const maxTroops = this.game.config().maxTroops(this.player); const ratio = this.player.troops() / maxTroops; @@ -380,6 +400,7 @@ export class AiAttackBehavior { // Sort neighboring bots by density (troops / tiles) and attempt to attack many of them (Parallel attacks) // sendAttack will do nothing if we don't have enough reserve troops left + // Bots that own structures are prioritized as targets (they might have stolen our structures and they will delete them!) private attackBots(): boolean { const bots = this.player .neighbors() @@ -397,7 +418,16 @@ export class AiAttackBehavior { this.botAttackTroopsSent = 0; const density = (p: Player) => p.troops() / p.numTilesOwned(); - const sortedBots = bots.slice().sort((a, b) => density(a) - density(b)); + const ownsStructures = (p: Player) => + p.units().some((u) => isStructureType(u.type())); + const sortedBots = bots.slice().sort((a, b) => { + const aHasStructures = ownsStructures(a); + const bHasStructures = ownsStructures(b); + if (aHasStructures !== bHasStructures) { + return aHasStructures ? -1 : 1; + } + return density(a) - density(b); + }); const reducedBots = sortedBots.slice(0, this.getBotAttackMaxParallelism()); for (const bot of reducedBots) { @@ -700,9 +730,14 @@ export class AiAttackBehavior { private sendLandAttack(target: Player | TerraNullius) { const maxTroops = this.game.config().maxTroops(this.player); - const reserveRatio = target.isPlayer() - ? this.reserveRatio - : this.expandRatio; + const botWithStructures = + target.isPlayer() && + target.type() === PlayerType.Bot && + target.units().some((u) => isStructureType(u.type())); + // Use the expand ratio when attacking a bot that owns structures — we need to + // recapture those structures ASAP, even before reaching the normal reserve. + const useReserve = target.isPlayer() && !botWithStructures; + const reserveRatio = useReserve ? this.reserveRatio : this.expandRatio; const targetTroops = maxTroops * reserveRatio; let troops;