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;