From 6afaf932a5bd49bfc6acff71dc4cb4e131697d3e Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Wed, 24 Dec 2025 04:10:39 +0100 Subject: [PATCH] =?UTF-8?q?Make=20easy=20and=20medium=20nations=20less=20a?= =?UTF-8?q?ggressive=20=F0=9F=93=8A=20(#2671)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: 1. Players complained that they have problems allying with nations in the earlygame. So I added an `isEarlygame()` check to `AllianceBehavior`. This should make the easier difficulties much easier :) 2. The attack order of nations now depends on the difficulty. Easy and medium nations got dumbed down, they now take nuked territory before retaliating against attacks again. 3. The attack rate now depends on the difficulty. Easy nations are reacting slower than impossible nations (to make sure the number of sent alliance requests stays the same I removed the difficulty check in `maybeSendAllianceRequests()`). 4. On easy and medium difficulty nations will sometimes just skip an attack if the enemy is a human (`shouldAttack()`). But this did not apply for the nuking logic. Now it does, which makes the easier difficulties a bit easier. 5. I tuned the `getBotAttackMaxParallelism()` method a bit. The nations are doing a bit less parallel bot attacks now, which makes the easier difficulties a bit easier. 6. The settings in MIRVBehavior now depend on the difficulty. On easy difficulty, nations will only send MIRVs very rarely. 7. Unrelated MIRVBehavior Cleanup: There was a 2 second cooldown and cache logic. But it was completely useless because `considerMIRV()` is only called every 4-8 seconds by NationExecution. So I removed it. 8. Unrelated little cleanup: I made a couple of methods `private` ## 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/NationExecution.ts | 38 +++- .../nation/NationAllianceBehavior.ts | 59 ++++-- .../execution/nation/NationMIRVBehavior.ts | 169 +++++++++--------- src/core/execution/utils/AiAttackBehavior.ts | 145 +++++++++------ 4 files changed, 250 insertions(+), 161 deletions(-) diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index a3de3834c..53a39ffb9 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -18,7 +18,12 @@ import { TileRef, euclDistFN } from "../game/GameMap"; import { canBuildTransportShip } from "../game/TransportShipUtils"; import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; -import { boundingBoxTiles, calculateBoundingBox, simpleHash } from "../Util"; +import { + assertNever, + boundingBoxTiles, + calculateBoundingBox, + simpleHash, +} from "../Util"; import { ConstructionExecution } from "./ConstructionExecution"; import { NationAllianceBehavior } from "./nation/NationAllianceBehavior"; import { NationEmojiBehavior } from "./nation/NationEmojiBehavior"; @@ -61,8 +66,6 @@ export class NationExecution implements Execution { this.random = new PseudoRandom( simpleHash(nation.playerInfo.id) + simpleHash(gameID), ); - this.attackRate = this.random.nextInt(40, 80); - this.attackTick = this.random.nextInt(0, this.attackRate); this.triggerRatio = this.random.nextInt(50, 60) / 100; this.reserveRatio = this.random.nextInt(30, 40) / 100; this.expandRatio = this.random.nextInt(10, 20) / 100; @@ -70,9 +73,8 @@ export class NationExecution implements Execution { init(mg: Game) { this.mg = mg; - if (this.random.chance(10)) { - // this.isTraitor = true - } + this.attackRate = this.getAttackRate(); + this.attackTick = this.random.nextInt(0, this.attackRate); if (!this.mg.hasPlayer(this.nation.playerInfo.id)) { this.player = this.mg.addPlayer(this.nation.playerInfo); @@ -81,6 +83,22 @@ export class NationExecution implements Execution { } } + private getAttackRate(): number { + const { difficulty } = this.mg.config().gameConfig(); + switch (difficulty) { + case Difficulty.Easy: + return this.random.nextInt(65, 80); // Slower reactions + case Difficulty.Medium: + return this.random.nextInt(55, 70); + case Difficulty.Hard: + return this.random.nextInt(45, 60); + case Difficulty.Impossible: + return this.random.nextInt(30, 50); // Faster reactions + default: + assertNever(difficulty); + } + } + tick(ticks: number) { // Ship tracking if ( @@ -495,7 +513,7 @@ export class NationExecution implements Execution { ); } - sendBoatRandomly(borderingEnemies: Player[] = []) { + private sendBoatRandomly(borderingEnemies: Player[] = []) { if (this.player === null) throw new Error("not initialized"); const oceanShore = Array.from(this.player.borderTiles()).filter((t) => this.mg.isOceanShore(t), @@ -590,14 +608,16 @@ export class NationExecution implements Execution { } private maybeSendNuke(other: Player | null) { - if (this.player === null) throw new Error("not initialized"); + if (this.player === null || this.attackBehavior === null) + throw new Error("not initialized"); const silos = this.player.units(UnitType.MissileSilo); if ( silos.length === 0 || this.player.gold() < this.cost(UnitType.AtomBomb) || other === null || other.type() === PlayerType.Bot || // Don't nuke bots (as opposed to nations and humans) - this.player.isOnSameTeam(other) + this.player.isOnSameTeam(other) || + this.attackBehavior.shouldAttack(other) === false ) { return; } diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts index 99cc6d76b..0408eed3f 100644 --- a/src/core/execution/nation/NationAllianceBehavior.ts +++ b/src/core/execution/nation/NationAllianceBehavior.ts @@ -43,31 +43,15 @@ export class NationAllianceBehavior { } 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); - case Difficulty.Impossible: - return this.random.chance(20); - default: - assertNever(difficulty); - } - }; - // 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 && + this.game.config().gameConfig().difficulty === Difficulty.Easy) || p.type() !== PlayerType.Bot; for (const enemy of borderingEnemies) { if ( - shouldSendAllianceRequest() && + this.random.chance(20) && isAcceptablePlayerType(enemy) && this.player.canSendAllianceRequest(enemy) && this.getAllianceRequestDecision(enemy) @@ -106,6 +90,10 @@ export class NationAllianceBehavior { if (this.checkAlreadyEnoughAlliances(otherPlayer)) { return false; } + // Maybe accept if we are in the earlygame + if (this.isEarlygame()) { + return true; + } // Accept if we are similarly strong return this.isAlliancePartnerSimilarlyStrong(otherPlayer); } @@ -126,6 +114,39 @@ export class NationAllianceBehavior { } } + private isEarlygame(): boolean { + const spawnTicks = this.game.config().numSpawnPhaseTurns(); + const { difficulty } = this.game.config().gameConfig(); + switch (difficulty) { + case Difficulty.Easy: + // On easy, accept 90% in the first 5 minutes + return ( + this.game.ticks() < 3000 + spawnTicks && + this.random.nextInt(0, 100) >= 10 + ); + case Difficulty.Medium: + // On medium, accept 70% in the first 3 minutes + return ( + this.game.ticks() < 1800 + spawnTicks && + this.random.nextInt(0, 100) >= 30 + ); + case Difficulty.Hard: + // On hard, accept 50% in the first 3 minutes + return ( + this.game.ticks() < 1800 + spawnTicks && + this.random.nextInt(0, 100) >= 50 + ); + case Difficulty.Impossible: + // On impossible, accept 30% in the first minute + return ( + this.game.ticks() < 600 + spawnTicks && + this.random.nextInt(0, 100) >= 70 + ); + default: + assertNever(difficulty); + } + } + private isAlliancePartnerThreat(otherPlayer: Player): boolean { const { difficulty } = this.game.config().gameConfig(); switch (difficulty) { diff --git a/src/core/execution/nation/NationMIRVBehavior.ts b/src/core/execution/nation/NationMIRVBehavior.ts index 147a46066..a760b89b2 100644 --- a/src/core/execution/nation/NationMIRVBehavior.ts +++ b/src/core/execution/nation/NationMIRVBehavior.ts @@ -1,38 +1,19 @@ import { + Difficulty, Game, Gold, Player, PlayerType, - Tick, UnitType, } from "../../game/Game"; import { TileRef } from "../../game/GameMap"; import { PseudoRandom } from "../../PseudoRandom"; +import { assertNever } from "../../Util"; import { MirvExecution } from "../MIRVExecution"; import { calculateTerritoryCenter } from "../Util"; import { NationEmojiBehavior } from "./NationEmojiBehavior"; export class NationMIRVBehavior { - private readonly lastMIRVSent: [Tick, TileRef][] = []; - - /** Ticks until MIRV can be attempted again */ - private static readonly MIRV_COOLDOWN_TICKS = 20; - - /** Odds of aborting a MIRV attempt */ - private static readonly MIRV_HESITATION_ODDS = 7; - - /** Threshold for team victory denial */ - private static readonly VICTORY_DENIAL_TEAM_THRESHOLD = 0.8; - - /** Threshold for individual victory denial */ - private static readonly VICTORY_DENIAL_INDIVIDUAL_THRESHOLD = 0.65; - - /** Multiplier for steamroll city gap threshold */ - private static readonly STEAMROLL_CITY_GAP_MULTIPLIER = 1.3; - - /** Minimum city count for leader to trigger steam roll detection */ - private static readonly STEAMROLL_MIN_LEADER_CITIES = 10; - constructor( private random: PseudoRandom, private game: Game, @@ -40,6 +21,85 @@ export class NationMIRVBehavior { private emojiBehavior: NationEmojiBehavior, ) {} + private get hesitationOdds(): number { + const { difficulty } = this.game.config().gameConfig(); + switch (difficulty) { + case Difficulty.Easy: + return 2; // More likely to hesitate + case Difficulty.Medium: + return 4; + case Difficulty.Hard: + return 8; + case Difficulty.Impossible: + return 16; // Rarely hesitates + default: + assertNever(difficulty); + } + } + + private get victoryDenialTeamThreshold(): number { + const { difficulty } = this.game.config().gameConfig(); + switch (difficulty) { + case Difficulty.Easy: + return 0.9; // Only react right before the game ends (95%) + case Difficulty.Medium: + return 0.8; + case Difficulty.Hard: + return 0.7; + case Difficulty.Impossible: + return 0.6; // Reacts early + default: + assertNever(difficulty); + } + } + + private get victoryDenialIndividualThreshold(): number { + const { difficulty } = this.game.config().gameConfig(); + switch (difficulty) { + case Difficulty.Easy: + return 0.75; // Only react right before the game ends (80%) + case Difficulty.Medium: + return 0.65; + case Difficulty.Hard: + return 0.55; + case Difficulty.Impossible: + return 0.4; // Reacts early + default: + assertNever(difficulty); + } + } + + private get steamrollCityGapMultiplier(): number { + const { difficulty } = this.game.config().gameConfig(); + switch (difficulty) { + case Difficulty.Easy: + return 1.5; // Needs larger gap to trigger + case Difficulty.Medium: + return 1.3; + case Difficulty.Hard: + return 1.2; + case Difficulty.Impossible: + return 1.15; // Reacts to smaller gaps + default: + assertNever(difficulty); + } + } + + private get steamrollMinLeaderCities(): number { + const { difficulty } = this.game.config().gameConfig(); + switch (difficulty) { + case Difficulty.Easy: + return 15; // Needs more cities to trigger + case Difficulty.Medium: + case Difficulty.Hard: + return 10; + case Difficulty.Impossible: + return 8; // Reacts early + default: + assertNever(difficulty); + } + } + considerMIRV(): boolean { if (this.player === null) throw new Error("not initialized"); if (this.player.units(UnitType.MissileSilo).length === 0) { @@ -49,13 +109,7 @@ export class NationMIRVBehavior { return false; } - this.removeOldMIRVEvents(); - if (this.lastMIRVSent.length > 0) { - return false; - } - - if (this.random.chance(NationMIRVBehavior.MIRV_HESITATION_ODDS)) { - this.triggerMIRVCooldown(); + if (this.random.chance(this.hesitationOdds)) { return false; } @@ -107,7 +161,7 @@ export class NationMIRVBehavior { .map((x) => x.numTilesOwned()) .reduce((a, b) => a + b, 0); const teamShare = teamTerritory / totalLand; - if (teamShare >= NationMIRVBehavior.VICTORY_DENIAL_TEAM_THRESHOLD) { + if (teamShare >= this.victoryDenialTeamThreshold) { // Only consider the largest team member as the target when team exceeds threshold let largestMember: Player | null = null; let largestTiles = -1; @@ -126,8 +180,7 @@ export class NationMIRVBehavior { } } else { const share = p.numTilesOwned() / totalLand; - if (share >= NationMIRVBehavior.VICTORY_DENIAL_INDIVIDUAL_THRESHOLD) - severity = share; + if (share >= this.victoryDenialIndividualThreshold) severity = share; } if (severity > 0) { if (best === null || severity > best.severity) best = { p, severity }; @@ -152,13 +205,11 @@ export class NationMIRVBehavior { const topPlayer = allPlayers[0]; - if (topPlayer.cityCount <= NationMIRVBehavior.STEAMROLL_MIN_LEADER_CITIES) - return null; + if (topPlayer.cityCount <= this.steamrollMinLeaderCities) return null; const secondHighest = allPlayers[1].cityCount; - const threshold = - secondHighest * NationMIRVBehavior.STEAMROLL_CITY_GAP_MULTIPLIER; + const threshold = secondHighest * this.steamrollCityGapMultiplier; if (topPlayer.cityCount >= threshold) { return validTargets.some((p) => p === topPlayer.p) ? topPlayer.p : null; @@ -168,23 +219,10 @@ export class NationMIRVBehavior { } // MIRV Helper Methods - private mirvTargetsCache: { - tick: number; - players: Player[]; - } | null = null; - private getValidMirvTargetPlayers(): Player[] { - const MIRV_TARGETS_CACHE_TICKS = 2 * 10; // 2 seconds if (this.player === null) throw new Error("not initialized"); - if ( - this.mirvTargetsCache && - this.game.ticks() - this.mirvTargetsCache.tick < MIRV_TARGETS_CACHE_TICKS - ) { - return this.mirvTargetsCache.players; - } - - const players = this.game.players().filter((p) => { + return this.game.players().filter((p) => { return ( p !== this.player && p.isPlayer() && @@ -192,9 +230,6 @@ export class NationMIRVBehavior { !this.player!.isOnSameTeam(p) ); }); - - this.mirvTargetsCache = { tick: this.game.ticks(), players }; - return players; } private isInboundMIRVFrom(attacker: Player): boolean { @@ -220,35 +255,7 @@ export class NationMIRVBehavior { const centerTile = this.calculateTerritoryCenter(enemy); if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) { - this.sendMIRV(centerTile); - return; - } - } - - private sendMIRV(tile: TileRef): void { - if (this.player === null) throw new Error("not initialized"); - this.triggerMIRVCooldown(tile); - this.game.addExecution(new MirvExecution(this.player, tile)); - } - - private triggerMIRVCooldown(tile?: TileRef): void { - if (this.player === null) throw new Error("not initialized"); - this.removeOldMIRVEvents(); - const tick = this.game.ticks(); - // Use provided tile or any tile from player's territory for cooldown tracking - const cooldownTile = - tile ?? Array.from(this.player.tiles())[0] ?? this.game.ref(0, 0); - this.lastMIRVSent.push([tick, cooldownTile]); - } - - private removeOldMIRVEvents() { - const maxAge = NationMIRVBehavior.MIRV_COOLDOWN_TICKS; - const tick = this.game.ticks(); - while ( - this.lastMIRVSent.length > 0 && - this.lastMIRVSent[0][0] + maxAge <= tick - ) { - this.lastMIRVSent.shift(); + this.game.addExecution(new MirvExecution(this.player, centerTile)); } } diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index 1785b1e94..c65b60fa5 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -72,49 +72,90 @@ export class AiAttackBehavior { // Maybe save up troops until we reach the trigger ratio if (!this.hasTriggerRatioTroops() && !this.random.chance(10)) return; - // Retaliate against incoming attacks (Most important!) - const incomingAttackPlayer = this.findIncomingAttackPlayer(); - if (incomingAttackPlayer) { - this.sendAttack(incomingAttackPlayer, true); - return; + // Get attack strategies in priority order based on difficulty + const strategies = this.getAttackStrategies( + borderingFriends, + borderingEnemies, + ); + + for (const strategy of strategies) { + if (strategy()) return; } + } - // Attack bots - if (this.attackBots()) return; + private getAttackStrategies( + borderingFriends: Player[], + borderingEnemies: Player[], + ): Array<() => boolean> { + const { difficulty } = this.game.config().gameConfig(); - // Maybe betray and attack - if (this.maybeBetrayAndAttack(borderingFriends)) return; - - // Attack nuked territory - if (this.isBorderingNukedTerritory()) { - this.sendAttack(this.game.terraNullius()); - return; - } - - // Attack the most hated player with hostile relation - const mostHated = this.player.allRelationsSorted()[0]; - if ( - mostHated !== undefined && - mostHated.relation === Relation.Hostile && - this.player.isFriendly(mostHated.player) === false - ) { - this.sendAttack(mostHated.player); - return; - } - - // Attack the weakest player - if (borderingEnemies.length > 0) { - this.sendAttack(borderingEnemies[0]); - return; - } - - // If we don't have bordering enemies, attack someone on an island next to us - if (borderingEnemies.length === 0) { - const nearestIslandEnemy = this.findNearestIslandEnemy(); - if (nearestIslandEnemy) { - this.sendAttack(nearestIslandEnemy); - return; + // Define all strategies as functions that return true if they attacked + const retaliate = (): boolean => { + const attacker = this.findIncomingAttackPlayer(); + if (attacker) { + this.sendAttack(attacker, true); + return true; } + return false; + }; + + const bots = (): boolean => this.attackBots(); + + const betray = (): boolean => this.maybeBetrayAndAttack(borderingFriends); + + const nuked = (): boolean => { + if (this.isBorderingNukedTerritory()) { + this.sendAttack(this.game.terraNullius()); + return true; + } + return false; + }; + + const hated = (): boolean => { + const mostHated = this.player.allRelationsSorted()[0]; + if ( + mostHated !== undefined && + mostHated.relation === Relation.Hostile && + this.player.isFriendly(mostHated.player) === false + ) { + this.sendAttack(mostHated.player); + return true; + } + return false; + }; + + const weakest = (): boolean => { + if (borderingEnemies.length > 0) { + this.sendAttack(borderingEnemies[0]); + return true; + } + return false; + }; + + const island = (): boolean => { + if (borderingEnemies.length === 0) { + const enemy = this.findNearestIslandEnemy(); + if (enemy) { + this.sendAttack(enemy); + return true; + } + } + return false; + }; + + // Return strategies in order based on difficulty + // Easy nations get the dumbest order, impossible nations get the smartest order + switch (difficulty) { + case Difficulty.Easy: + return [nuked, bots, retaliate, betray, hated, weakest]; + case Difficulty.Medium: + return [bots, nuked, retaliate, betray, hated, weakest, island]; + case Difficulty.Hard: + return [bots, retaliate, betray, nuked, hated, weakest, island]; + case Difficulty.Impossible: + return [retaliate, bots, betray, nuked, hated, weakest, island]; + default: + assertNever(difficulty); } } @@ -187,7 +228,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 - attackBots(): boolean { + private attackBots(): boolean { const bots = this.player .neighbors() .filter( @@ -216,15 +257,15 @@ export class AiAttackBehavior { return this.botAttackTroopsSent > 0; } - getBotAttackMaxParallelism(): number { + private getBotAttackMaxParallelism(): number { const { difficulty } = this.game.config().gameConfig(); switch (difficulty) { case Difficulty.Easy: return 1; case Difficulty.Medium: - return 2; + return this.random.chance(2) ? 1 : 2; case Difficulty.Hard: - return 4; + return 3; // On impossible difficulty, attack as much bots as possible in parallel case Difficulty.Impossible: { return 100; @@ -234,7 +275,7 @@ export class AiAttackBehavior { } } - maybeBetrayAndAttack(borderingFriends: Player[]): boolean { + private maybeBetrayAndAttack(borderingFriends: Player[]): boolean { if (this.allianceBehavior === undefined) throw new Error("not initialized"); if (borderingFriends.length > 0) { @@ -248,7 +289,7 @@ export class AiAttackBehavior { return false; } - isBorderingNukedTerritory(): boolean { + private isBorderingNukedTerritory(): boolean { for (const tile of this.player.borderTiles()) { for (const neighbor of this.game.neighbors(tile)) { if ( @@ -263,7 +304,7 @@ export class AiAttackBehavior { return false; } - findNearestIslandEnemy(): Player | null { + private findNearestIslandEnemy(): Player | null { const myBorder = this.player.borderTiles(); if (myBorder.size === 0) return null; @@ -315,7 +356,7 @@ export class AiAttackBehavior { return null; } - getPlayerCenter(player: Player) { + private getPlayerCenter(player: Player) { if (player.largestClusterBoundingBox) { return boundingBoxCenter(player.largestClusterBoundingBox); } @@ -391,8 +432,7 @@ export class AiAttackBehavior { } } - // Prevent attacking of humans on lower difficulties - private shouldAttack(other: Player | TerraNullius): boolean { + shouldAttack(other: Player | TerraNullius): boolean { // Always attack Terra Nullius, non-humans and traitors if ( other.isPlayer() === false || @@ -402,6 +442,7 @@ export class AiAttackBehavior { return true; } + // Prevent attacking of humans on lower difficulties const { difficulty } = this.game.config().gameConfig(); if (difficulty === Difficulty.Easy && this.random.chance(2)) { return false; @@ -412,7 +453,7 @@ export class AiAttackBehavior { return true; } - sendLandAttack(target: Player | TerraNullius) { + private sendLandAttack(target: Player | TerraNullius) { const maxTroops = this.game.config().maxTroops(this.player); const reserveRatio = target.isPlayer() ? this.reserveRatio @@ -451,7 +492,7 @@ export class AiAttackBehavior { } } - sendBoatAttack(target: Player) { + private sendBoatAttack(target: Player) { const closest = closestTwoTiles( this.game, Array.from(this.player.borderTiles()).filter((t) => @@ -490,7 +531,7 @@ export class AiAttackBehavior { } } - calculateBotAttackTroops(target: Player, maxTroops: number): number { + private calculateBotAttackTroops(target: Player, maxTroops: number): number { const { difficulty } = this.game.config().gameConfig(); if (difficulty === Difficulty.Easy) { this.botAttackTroopsSent += maxTroops;