From 23e4bf6725d39180d4379274c5c8a5955f6b0e62 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 1 Jan 2026 22:29:46 +0100 Subject: [PATCH] =?UTF-8?q?=E2=98=A2=EF=B8=8F=20Nations=20send=20much=20be?= =?UTF-8?q?tter=20nukes=20now=20(Part=201)=20=E2=98=A2=EF=B8=8F=20(#2756)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a very important PR for HumansVsNations (But also for singleplayer). Humans will throw lots of nukes onto nations, but nations didn't do that. Until now :) ## Refactor - Moved all the nuking logic to the new file `NationNukeBehavior.ts` - Moved `randTerritoryTileArray()` and `randTerritoryTile()` to the new file `NationUtils.ts` because we need that method in multiple places now - Because we already have an `NationUtils.ts` (It contains the method `createNationsForGame` for HumansVsNations) I renamed the old one to `NationCreation.ts` to avoid confusion ## Bug fixed - `allRelationsSorted()` in `PlayerImpl` returned dead players all the time... Which caused nations to not attack / send nukes in some cases... ## Nuke-sending features / improvements - On hard and impossible difficulty, nations no longer make sure that nukes will only hit inside of their targets border. This logic very often stopped nations from throwing nukes. Now their nukes are allowed to hit TerraNullius (=> ocean!). And in team games, it's even allowed that their nukes hit other non-friendly players as well! This is very important for HumansVsNations. - The basic check for SAMs now gets skipped if we are on easy difficulty (easy nations are not smart enough to do that) - I improved the basic check for SAMs (medium difficulty) a bit (nations send less nukes into SAMs) - On hard and impossible difficulty, we now use the new method `isTrajectoryInterceptableBySam()` to avoid SAMs completely. It's mirroring `NukeTrajectoryPreviewLayer.ts` logic a bit. - I added "perceived cost" to simulate nations saving up for a MIRV (Otherwise most hard/impossible nations will spend all their gold on nukes). But if we are in a team game (MIRVs are not relevant) or if we already saved up for a MIRV, the "perceived cost" gets ignored. - Updated the "most hated player" selection in `findBestNukeTarget()` to ignore very weak players. We don't need to throw nukes at players which we can easily steamroll by land. - Added `findFFACrownTarget()` to nuke the crown (based on difficulty). - Added `findStrongestTeamTarget()` to nuke the strongest team. - Updated `randTerritoryTile()` so that it has a higher chance of returning the tiles of a "leftover-nuked-to-death-player-with-some-tiles-left": `if (p.numTilesOwned() <= 100) {return random.randElement(Array.from(p.tiles()));}`. - Changed `const range = nukeType === UnitType.HydrogenBomb ? 60 : 15` to `config().nukeMagnitudes(nukeType).inner`. Should make more sense. - Adjusted `nukeTileScore()` to search for units in `this.mg.config().nukeMagnitudes(nukeType).inner` instead of fixed 25 - Adjusted `nukeTileScore()` to account for unit levels (levels got ignored previously). Also increased score for ports from 10_000 to 15_000. - I made sure that nations can nuke EVERY SINGLE TILE from an enemy, even if the enemy has no structures ("Prefer tiles that are closer to a silo" can no longer make the `nukeTileScore()` drop too much, `bestValue` in `maybeSendNuke()` starts at -1 now) - In the entire nuking logic, factories were missing. Now they are added. ## Media Nation team vs. nation team: They are nuking the very last pixels of red, just like humans would do it 😀 image Hard difficulty FFA game: Nations throwing much more nukes. And they are nuking the crown. https://github.com/user-attachments/assets/a6e43924-a6ca-4b1a-a578-4e4f8252e383 Lots of nukes flying: https://github.com/user-attachments/assets/8fc4edad-a6e6-4476-8a86-08cdef58169e ## 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/GameRunner.ts | 2 +- src/core/execution/NationExecution.ts | 208 +---------- .../execution/nation/NationNukeBehavior.ts | 332 ++++++++++++++++++ src/core/execution/nation/NationUtils.ts | 49 +++ src/core/execution/utils/AiAttackBehavior.ts | 161 ++++++++- .../{NationUtils.ts => NationCreation.ts} | 0 src/core/game/PlayerImpl.ts | 1 + tests/NationMIRV.test.ts | 8 +- 8 files changed, 551 insertions(+), 210 deletions(-) create mode 100644 src/core/execution/nation/NationNukeBehavior.ts create mode 100644 src/core/execution/nation/NationUtils.ts rename src/core/game/{NationUtils.ts => NationCreation.ts} (100%) diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index d434ba49e..5e45612ea 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -25,7 +25,7 @@ import { GameUpdateType, GameUpdateViewData, } from "./game/GameUpdates"; -import { createNationsForGame } from "./game/NationUtils"; +import { createNationsForGame } from "./game/NationCreation"; import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader"; import { PseudoRandom } from "./PseudoRandom"; import { ClientID, GameStartInfo, Turn } from "./Schemas"; diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index 015f8a46b..76c879fee 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -1,5 +1,4 @@ import { - Cell, Difficulty, Execution, Game, @@ -10,30 +9,23 @@ import { PlayerType, Relation, TerrainType, - Tick, - Unit, UnitType, } from "../game/Game"; -import { TileRef, euclDistFN } from "../game/GameMap"; +import { TileRef } from "../game/GameMap"; import { canBuildTransportShip } from "../game/TransportShipUtils"; import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; -import { - assertNever, - boundingBoxTiles, - calculateBoundingBox, - simpleHash, -} from "../Util"; +import { assertNever, simpleHash } from "../Util"; import { ConstructionExecution } from "./ConstructionExecution"; import { NationAllianceBehavior } from "./nation/NationAllianceBehavior"; -import { EMOJI_NUKE, NationEmojiBehavior } from "./nation/NationEmojiBehavior"; +import { NationEmojiBehavior } from "./nation/NationEmojiBehavior"; import { NationMIRVBehavior } from "./nation/NationMIRVBehavior"; +import { NationNukeBehavior } from "./nation/NationNukeBehavior"; +import { randTerritoryTileArray } from "./nation/NationUtils"; import { NationWarshipBehavior } from "./nation/NationWarshipBehavior"; import { structureSpawnTileValue } from "./nation/structureSpawnTileValue"; -import { NukeExecution } from "./NukeExecution"; import { SpawnExecution } from "./SpawnExecution"; import { TransportShipExecution } from "./TransportShipExecution"; -import { closestTwoTiles } from "./Util"; import { AiAttackBehavior } from "./utils/AiAttackBehavior"; export class NationExecution implements Execution { @@ -44,6 +36,7 @@ export class NationExecution implements Execution { private attackBehavior: AiAttackBehavior | null = null; private allianceBehavior: NationAllianceBehavior | null = null; private warshipBehavior: NationWarshipBehavior | null = null; + private nukeBehavior: NationNukeBehavior | null = null; private mg: Game; private player: Player | null = null; @@ -53,7 +46,6 @@ export class NationExecution implements Execution { private reserveRatio: number; private expandRatio: number; - private readonly lastNukeSent: [Tick, TileRef][] = []; private readonly embargoMalusApplied = new Set(); constructor( @@ -148,7 +140,8 @@ export class NationExecution implements Execution { this.mirvBehavior === null || this.attackBehavior === null || this.allianceBehavior === null || - this.warshipBehavior === null + this.warshipBehavior === null || + this.nukeBehavior === null ) { // Player is unavailable during init() this.emojiBehavior = new NationEmojiBehavior( @@ -184,6 +177,13 @@ export class NationExecution implements Execution { this.allianceBehavior, this.emojiBehavior, ); + this.nukeBehavior = new NationNukeBehavior( + this.random, + this.mg, + this.player, + this.attackBehavior, + this.emojiBehavior, + ); // Send an attack on the first tick this.attackBehavior.forceSendAttack(this.mg.terraNullius()); @@ -293,7 +293,7 @@ export class NationExecution implements Execution { const tiles = type === UnitType.Port ? this.randCoastalTileArray(25) - : this.randTerritoryTileArray(25); + : randTerritoryTileArray(this.random, this.mg, this.player, 25); if (tiles.length === 0) return null; const valueFunction = structureSpawnTileValue(this.mg, this.player, type); let bestTile: TileRef | null = null; @@ -411,7 +411,8 @@ export class NationExecution implements Execution { if ( this.player === null || this.attackBehavior === null || - this.allianceBehavior === null + this.allianceBehavior === null || + this.nukeBehavior === null ) { throw new Error("not initialized"); } @@ -459,9 +460,7 @@ export class NationExecution implements Execution { } this.attackBehavior.attackBestTarget(borderingFriends, borderingEnemies); - this.maybeSendNuke( - this.attackBehavior.findBestNukeTarget(borderingEnemies), - ); + this.nukeBehavior.maybeSendNuke(this.attackBehavior.findBestNukeTarget()); } private sendBoatRandomly(borderingEnemies: Player[] = []) { @@ -558,175 +557,6 @@ export class NationExecution implements Execution { return null; } - private maybeSendNuke(other: Player | null) { - 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.attackBehavior.shouldAttack(other) === false - ) { - return; - } - - const nukeType = - this.player.gold() > this.cost(UnitType.HydrogenBomb) - ? UnitType.HydrogenBomb - : UnitType.AtomBomb; - const range = nukeType === UnitType.HydrogenBomb ? 60 : 15; - - const structures = other.units( - UnitType.City, - UnitType.DefensePost, - UnitType.MissileSilo, - UnitType.Port, - UnitType.SAMLauncher, - ); - const structureTiles = structures.map((u) => u.tile()); - const randomTiles = this.randTerritoryTileArray(10); - const allTiles = randomTiles.concat(structureTiles); - - let bestTile: TileRef | null = null; - let bestValue = 0; - this.removeOldNukeEvents(); - outer: for (const tile of new Set(allTiles)) { - if (tile === null) continue; - const boundingBox = boundingBoxTiles(this.mg, tile, range) - // Add radius / 2 in case there is a piece of unwanted territory inside the outer radius that we miss. - .concat(boundingBoxTiles(this.mg, tile, Math.floor(range / 2))); - for (const t of boundingBox) { - // Make sure we nuke away from the border - if (this.mg.owner(t) !== other) { - continue outer; - } - } - if (!this.player.canBuild(nukeType, tile)) continue; - const value = this.nukeTileScore(tile, silos, structures); - if (value > bestValue) { - bestTile = tile; - bestValue = value; - } - } - if (bestTile !== null) { - this.sendNuke(bestTile, nukeType, other); - } - } - - private removeOldNukeEvents() { - const maxAge = 500; - const tick = this.mg.ticks(); - while ( - this.lastNukeSent.length > 0 && - this.lastNukeSent[0][0] + maxAge < tick - ) { - this.lastNukeSent.shift(); - } - } - - private nukeTileScore(tile: TileRef, silos: Unit[], targets: Unit[]): number { - // Potential damage in a 25-tile radius - const dist = euclDistFN(tile, 25, false); - let tileValue = targets - .filter((unit) => dist(this.mg, unit.tile())) - .map((unit): number => { - switch (unit.type()) { - case UnitType.City: - return 25_000; - case UnitType.DefensePost: - return 5_000; - case UnitType.MissileSilo: - return 50_000; - case UnitType.Port: - return 10_000; - default: - return 0; - } - }) - .reduce((prev, cur) => prev + cur, 0); - - // Avoid areas defended by SAM launchers - const dist50 = euclDistFN(tile, 50, false); - tileValue -= - 50_000 * - targets.filter( - (unit) => - unit.type() === UnitType.SAMLauncher && dist50(this.mg, unit.tile()), - ).length; - - // Prefer tiles that are closer to a silo - const siloTiles = silos.map((u) => u.tile()); - const result = closestTwoTiles(this.mg, siloTiles, [tile]); - if (result === null) throw new Error("Missing result"); - const { x: closestSilo } = result; - const distanceSquared = this.mg.euclideanDistSquared(tile, closestSilo); - const distanceToClosestSilo = Math.sqrt(distanceSquared); - tileValue -= distanceToClosestSilo * 30; - - // Don't target near recent targets - tileValue -= this.lastNukeSent - .filter(([_tick, tile]) => dist(this.mg, tile)) - .map((_) => 1_000_000) - .reduce((prev, cur) => prev + cur, 0); - - return tileValue; - } - - private sendNuke( - tile: TileRef, - nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb, - targetPlayer: Player, - ) { - if ( - this.player === null || - this.attackBehavior === null || - this.emojiBehavior === null - ) - throw new Error("not initialized"); - const tick = this.mg.ticks(); - this.lastNukeSent.push([tick, tile]); - this.mg.addExecution(new NukeExecution(nukeType, this.player, tile)); - this.emojiBehavior.maybeSendEmoji(targetPlayer, EMOJI_NUKE); - } - - private randTerritoryTileArray(numTiles: number): TileRef[] { - const boundingBox = calculateBoundingBox( - this.mg, - this.player!.borderTiles(), - ); - const tiles: TileRef[] = []; - for (let i = 0; i < numTiles; i++) { - const tile = this.randTerritoryTile(this.player!, boundingBox); - if (tile !== null) { - tiles.push(tile); - } - } - return tiles; - } - - private randTerritoryTile( - p: Player, - boundingBox: { min: Cell; max: Cell } | null = null, - ): TileRef | null { - boundingBox ??= calculateBoundingBox(this.mg, p.borderTiles()); - for (let i = 0; i < 100; i++) { - const randX = this.random.nextInt(boundingBox.min.x, boundingBox.max.x); - const randY = this.random.nextInt(boundingBox.min.y, boundingBox.max.y); - if (!this.mg.isOnMap(new Cell(randX, randY))) { - // Sanity check should never happen - continue; - } - const randTile = this.mg.ref(randX, randY); - if (this.mg.owner(randTile) === p) { - return randTile; - } - } - return null; - } - private cost(type: UnitType): Gold { if (this.player === null) throw new Error("not initialized"); return this.mg.unitInfo(type).cost(this.mg, this.player); diff --git a/src/core/execution/nation/NationNukeBehavior.ts b/src/core/execution/nation/NationNukeBehavior.ts new file mode 100644 index 000000000..6d82c8913 --- /dev/null +++ b/src/core/execution/nation/NationNukeBehavior.ts @@ -0,0 +1,332 @@ +import { + Difficulty, + Game, + GameMode, + Gold, + Player, + PlayerType, + Tick, + Unit, + UnitType, +} from "../../game/Game"; +import { TileRef, euclDistFN } from "../../game/GameMap"; +import { ParabolaPathFinder } from "../../pathfinding/PathFinding"; +import { PseudoRandom } from "../../PseudoRandom"; +import { boundingBoxTiles } from "../../Util"; +import { NukeExecution } from "../NukeExecution"; +import { closestTwoTiles } from "../Util"; +import { AiAttackBehavior } from "../utils/AiAttackBehavior"; +import { EMOJI_NUKE, NationEmojiBehavior } from "./NationEmojiBehavior"; +import { randTerritoryTileArray } from "./NationUtils"; + +export class NationNukeBehavior { + private readonly lastNukeSent: [Tick, TileRef][] = []; + private atomBombsLaunched = 0; + private atomBombPerceivedCost = this.cost(UnitType.AtomBomb); + private hydrogenBombsLaunched = 0; + private hydrogenBombPerceivedCost = this.cost(UnitType.HydrogenBomb); + + constructor( + private random: PseudoRandom, + private mg: Game, + private player: Player, + private attackBehavior: AiAttackBehavior, + private emojiBehavior: NationEmojiBehavior, + ) {} + + maybeSendNuke(other: Player | null) { + const silos = this.player.units(UnitType.MissileSilo); + if ( + silos.length === 0 || + other === null || + other.type() === PlayerType.Bot || // Don't nuke bots (as opposed to nations and humans) + this.player.isOnSameTeam(other) || + this.attackBehavior.shouldAttack(other) === false + ) { + return; + } + + const hydroCost = this.getPerceivedNukeCost(UnitType.HydrogenBomb); + const atomCost = this.getPerceivedNukeCost(UnitType.AtomBomb); + let nukeType: UnitType; + if (this.player.gold() >= hydroCost) { + nukeType = UnitType.HydrogenBomb; + } else if (this.player.gold() >= atomCost) { + nukeType = UnitType.AtomBomb; + } else { + return; + } + const range = this.mg.config().nukeMagnitudes(nukeType).inner; + + const structures = other.units( + UnitType.City, + UnitType.DefensePost, + UnitType.MissileSilo, + UnitType.Port, + UnitType.SAMLauncher, + UnitType.Factory, + ); + const structureTiles = structures.map((u) => u.tile()); + const randomTiles = randTerritoryTileArray(this.random, this.mg, other, 10); + const allTiles = randomTiles.concat(structureTiles); + + let bestTile: TileRef | null = null; + let bestValue = -1; // -1 is important, so that we can also nuke land without structures + this.removeOldNukeEvents(); + + outer: for (const tile of new Set(allTiles)) { + if (tile === null) continue; + const boundingBox = boundingBoxTiles(this.mg, tile, range) + // Add radius / 2 in case there is a piece of unwanted territory inside the outer radius that we miss. + .concat(boundingBoxTiles(this.mg, tile, Math.floor(range / 2))); + for (const t of boundingBox) { + if (!this.isValidNukeTile(t, other)) { + continue outer; + } + } + const spawnTile = this.player.canBuild(nukeType, tile); + if (spawnTile === false) continue; + + // On Hard & Impossible, avoid trajectories that can be intercepted by enemy SAMs + const difficulty = this.mg.config().gameConfig().difficulty; + if ( + (difficulty === Difficulty.Hard || + difficulty === Difficulty.Impossible) && + this.isTrajectoryInterceptableBySam(spawnTile, tile) + ) { + continue; + } + + const value = this.nukeTileScore(tile, silos, structures, nukeType); + if (value > bestValue) { + bestTile = tile; + bestValue = value; + } + } + if (bestTile !== null) { + this.sendNuke(bestTile, nukeType, other); + } + } + + // Simulate saving up for a MIRV + private getPerceivedNukeCost(type: UnitType): Gold { + // Return the actual cost in team games (saving up for a MIRV is not relevant, the game will be finished before that) + // or if we already have enough gold to buy both a MIRV and a hydro + if ( + this.mg.config().gameConfig().gameMode === GameMode.Team || + this.player.gold() > + this.cost(UnitType.MIRV) + this.cost(UnitType.HydrogenBomb) + ) { + return this.cost(type); + } + + if (type === UnitType.AtomBomb) { + return this.atomBombPerceivedCost; + } else { + return this.hydrogenBombPerceivedCost; + } + } + + // mirroring NukeTrajectoryPreviewLayer.ts logic a bit + private isTrajectoryInterceptableBySam( + spawnTile: TileRef, + targetTile: TileRef, + ): boolean { + const pathFinder = new ParabolaPathFinder(this.mg); + const speed = this.mg.config().defaultNukeSpeed(); + const distanceBasedHeight = true; // Atom/Hydrogen bombs use distance-based height + const rocketDirectionUp = true; // AI nukes always go "up" for now + + pathFinder.computeControlPoints( + spawnTile, + targetTile, + speed, + distanceBasedHeight, + rocketDirectionUp, + ); + + const trajectory = pathFinder.allTiles(); + if (trajectory.length === 0) { + return false; + } + + const targetRangeSquared = + this.mg.config().defaultNukeTargetableRange() ** 2; + + let untargetableStart = -1; + let untargetableEnd = -1; + for (let i = 0; i < trajectory.length; i++) { + const tile = trajectory[i]; + if (untargetableStart === -1) { + if ( + this.mg.euclideanDistSquared(tile, spawnTile) > targetRangeSquared + ) { + if ( + this.mg.euclideanDistSquared(tile, targetTile) < targetRangeSquared + ) { + // Overlapping spawn & target range – no untargetable segment. + break; + } else { + untargetableStart = i; + } + } + } else if ( + this.mg.euclideanDistSquared(tile, targetTile) < targetRangeSquared + ) { + untargetableEnd = i; + break; + } + } + + for (let i = 0; i < trajectory.length; i++) { + // Skip the mid-air untargetable portion + if ( + untargetableStart !== -1 && + untargetableEnd !== -1 && + i === untargetableStart + ) { + i = untargetableEnd - 1; + continue; + } + + const tile = trajectory[i]; + const nearbySams = this.mg.nearbyUnits( + tile, + this.mg.config().maxSamRange(), + UnitType.SAMLauncher, + ); + + for (const sam of nearbySams) { + const owner = sam.unit.owner(); + if (owner === this.player || this.player.isFriendly(owner)) { + continue; + } + const rangeSquared = this.mg.config().samRange(sam.unit.level()) ** 2; + if (sam.distSquared <= rangeSquared) { + return true; + } + } + } + + return false; + } + + private isValidNukeTile(t: TileRef, other: Player | null): boolean { + const difficulty = this.mg.config().gameConfig().difficulty; + + const owner = this.mg.owner(t); + if (owner === other) return true; + // On Hard & Impossible, allow TerraNullius (hit small islands) and in team games other non-friendly players + if ( + (difficulty === Difficulty.Hard || + difficulty === Difficulty.Impossible) && + (!owner.isPlayer() || + (this.mg.config().gameConfig().gameMode === GameMode.Team && + owner.isPlayer() && + !this.player.isFriendly(owner))) + ) { + return true; + } + // On Easy & Medium, only allow tiles owned by the target player (=> nuke away from the border) to reduce nuke usage + return false; + } + + private removeOldNukeEvents() { + const maxAge = 500; + const tick = this.mg.ticks(); + while ( + this.lastNukeSent.length > 0 && + this.lastNukeSent[0][0] + maxAge < tick + ) { + this.lastNukeSent.shift(); + } + } + + private nukeTileScore( + tile: TileRef, + silos: Unit[], + targets: Unit[], + nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb, + ): number { + const magnitude = this.mg.config().nukeMagnitudes(nukeType); + const dist = euclDistFN(tile, magnitude.inner, false); + let tileValue = targets + .filter((unit) => dist(this.mg, unit.tile())) + .map((unit): number => { + const level = unit.level(); + switch (unit.type()) { + case UnitType.City: + return 25_000 * level; + case UnitType.DefensePost: + return 5_000 * level; + case UnitType.MissileSilo: + return 50_000 * level; + case UnitType.Port: + return 15_000 * level; + case UnitType.Factory: + return 15_000 * level; + default: + return 0; + } + }) + .reduce((prev, cur) => prev + cur, 0); + + const difficulty = this.mg.config().gameConfig().difficulty; + // On Easy, ignore SAMs entirely. + // On Medium, apply a simple local SAM penalty. + // On Hard & Impossible we rely on trajectory-based interception checks instead. See maybeSendNuke(). + if (difficulty === Difficulty.Medium) { + const dist50 = euclDistFN(tile, 50, false); + const hasSam = targets.some( + (unit) => + unit.type() === UnitType.SAMLauncher && dist50(this.mg, unit.tile()), + ); + if (hasSam) return -1; + } + + // Prefer tiles that are closer to a silo (but preserve structure value) + const siloTiles = silos.map((u) => u.tile()); + const result = closestTwoTiles(this.mg, siloTiles, [tile]); + if (result === null) throw new Error("Missing result"); + const { x: closestSilo } = result; + const distanceSquared = this.mg.euclideanDistSquared(tile, closestSilo); + const distanceToClosestSilo = Math.sqrt(distanceSquared); + const distancePenalty = distanceToClosestSilo * 30; + const baseTileValue = tileValue; + tileValue = Math.max(baseTileValue * 0.2, tileValue - distancePenalty); // Keep at least 20% of structure value + + // Don't target near recent targets + const dist25 = euclDistFN(tile, 25, false); + tileValue -= this.lastNukeSent + .filter(([_tick, tile]) => dist25(this.mg, tile)) + .map((_) => 1_000_000) + .reduce((prev, cur) => prev + cur, 0); + + return tileValue; + } + + private sendNuke( + tile: TileRef, + nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb, + targetPlayer: Player, + ) { + const tick = this.mg.ticks(); + this.lastNukeSent.push([tick, tile]); + if (nukeType === UnitType.AtomBomb) { + this.atomBombsLaunched++; + // Increase perceived cost by 25% 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 * 125n) / 100n; + } else if (nukeType === UnitType.HydrogenBomb) { + this.hydrogenBombsLaunched++; + // Increase perceived cost by 15% each time to simulate saving up for a MIRV + this.hydrogenBombPerceivedCost = + (this.hydrogenBombPerceivedCost * 115n) / 100n; + } + this.mg.addExecution(new NukeExecution(nukeType, this.player, tile)); + this.emojiBehavior.maybeSendEmoji(targetPlayer, EMOJI_NUKE); + } + + private cost(type: UnitType): Gold { + return this.mg.unitInfo(type).cost(this.mg, this.player); + } +} diff --git a/src/core/execution/nation/NationUtils.ts b/src/core/execution/nation/NationUtils.ts new file mode 100644 index 000000000..a63c959f8 --- /dev/null +++ b/src/core/execution/nation/NationUtils.ts @@ -0,0 +1,49 @@ +import { Cell, Game, Player } from "../../game/Game"; +import { TileRef } from "../../game/GameMap"; +import { PseudoRandom } from "../../PseudoRandom"; +import { calculateBoundingBox } from "../../Util"; + +export function randTerritoryTileArray( + random: PseudoRandom, + mg: Game, + player: Player, + numTiles: number, +): TileRef[] { + const boundingBox = calculateBoundingBox(mg, player.borderTiles()); + const tiles: TileRef[] = []; + for (let i = 0; i < numTiles; i++) { + const tile = randTerritoryTile(random, mg, player, boundingBox); + if (tile !== null) { + tiles.push(tile); + } + } + return tiles; +} + +function randTerritoryTile( + random: PseudoRandom, + mg: Game, + p: Player, + boundingBox: { min: Cell; max: Cell } | null = null, +): TileRef | null { + // Prefer sampling inside the bounding box first (fast, usually good enough) + boundingBox ??= calculateBoundingBox(mg, p.borderTiles()); + for (let i = 0; i < 100; i++) { + const randX = random.nextInt(boundingBox.min.x, boundingBox.max.x); + const randY = random.nextInt(boundingBox.min.y, boundingBox.max.y); + if (!mg.isOnMap(new Cell(randX, randY))) { + // Sanity check should never happen + continue; + } + const randTile = mg.ref(randX, randY); + if (mg.owner(randTile) === p) { + return randTile; + } + } + + if (p.numTilesOwned() <= 100) { + return random.randElement(Array.from(p.tiles())); + } + + return null; +} diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index f6c005440..dfd55f4e4 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -1,6 +1,7 @@ import { Difficulty, Game, + GameMode, Player, PlayerType, Relation, @@ -118,13 +119,11 @@ export class AiAttackBehavior { }; 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); + for (const relation of this.player.allRelationsSorted()) { + if (relation.relation !== Relation.Hostile) continue; + const other = relation.player; + if (this.player.isFriendly(other)) continue; + this.sendAttack(other); return true; } return false; @@ -201,7 +200,7 @@ export class AiAttackBehavior { } } - findBestNukeTarget(borderingEnemies: Player[]): Player | null { + findBestNukeTarget(): Player | null { // Retaliate against incoming attacks (Most important!) const incomingAttackPlayer = this.findIncomingAttackPlayer(); if (incomingAttackPlayer) { @@ -221,19 +220,149 @@ export class AiAttackBehavior { } } - // Find 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 - ) { - return mostHated.player; + // Find the most hated player + // Ignore much weaker players (we don't need nukes to deal with them) + const myMaxTroops = this.game.config().maxTroops(this.player); + for (const relation of this.player.allRelationsSorted()) { + if (relation.relation !== Relation.Hostile) continue; + const other = relation.player; + if (this.player.isFriendly(other)) continue; + + const otherMaxTroops = this.game.config().maxTroops(other); + if (myMaxTroops >= otherMaxTroops * 2) continue; + + return other; + } + + // In FFAs, nuke the crown if they're far enough ahead + const crownTarget = this.findFFACrownTarget(); + if (crownTarget) { + return crownTarget; + } + + // In Teams, nuke the strongest team + const teamTarget = this.findStrongestTeamTarget(); + if (teamTarget) { + return teamTarget; } return null; } + private findFFACrownTarget(): Player | null { + const { difficulty, gameMode } = this.game.config().gameConfig(); + if (gameMode !== GameMode.FFA) { + return null; + } + + if (this.game.players().length <= 1) { + return null; + } + + const sortedByTiles = this.game + .players() + .slice() + .sort((a, b) => b.numTilesOwned() - a.numTilesOwned()); + const firstPlace = sortedByTiles[0]; + + // Don't target ourselves or allies + if (firstPlace === this.player || this.player.isFriendly(firstPlace)) { + return null; + } + + const numTilesWithoutFallout = + this.game.numLandTiles() - this.game.numTilesWithFallout(); + if (numTilesWithoutFallout <= 0) { + return null; + } + + const firstPlaceShare = firstPlace.numTilesOwned() / numTilesWithoutFallout; + const myShare = this.player.numTilesOwned() / numTilesWithoutFallout; + + let threshold: number; + switch (difficulty) { + case Difficulty.Easy: + threshold = 0.4; // 40% + break; + case Difficulty.Medium: + threshold = 0.3; // 30% + break; + case Difficulty.Hard: + threshold = 0.2; // 20% + break; + case Difficulty.Impossible: + threshold = 0.1; // 10% + break; + default: + assertNever(difficulty); + } + + // Check if first place has threshold% more tile-percentage of the map than us + if (firstPlaceShare - myShare > threshold) { + return firstPlace; + } + + return null; + } + + private findStrongestTeamTarget(): Player | null { + if (this.game.config().gameConfig().gameMode !== GameMode.Team) { + return null; + } + + if (this.game.players().length <= 1) { + return null; + } + + const teamTiles = new Map(); + const teamPlayers = new Map(); + + for (const p of this.game.players()) { + const team = p.team(); + if (team === null) continue; + + teamTiles.set(team, (teamTiles.get(team) ?? 0) + p.numTilesOwned()); + let players = teamPlayers.get(team); + if (!players) { + players = []; + teamPlayers.set(team, players); + } + players.push(p); + } + + const sortedTeams = Array.from(teamTiles.entries()).sort( + (a, b) => b[1] - a[1], + ); + + if (sortedTeams.length === 0) { + return null; + } + + let strongestTeam = sortedTeams[0][0]; + if (strongestTeam === this.player.team()) { + if (sortedTeams.length > 1) { + strongestTeam = sortedTeams[1][0]; + } else { + return null; + } + } + + const targetTeamPlayers = teamPlayers.get(strongestTeam)!; + + if (this.random.chance(2)) { + // Strongest player + return targetTeamPlayers.reduce((prev, current) => + this.game.config().maxTroops(prev) > + this.game.config().maxTroops(current) + ? prev + : current, + ); + } else { + // Random player + return this.random.randElement(targetTeamPlayers); + } + } + private hasReserveRatioTroops(): boolean { const maxTroops = this.game.config().maxTroops(this.player); const ratio = this.player.troops() / maxTroops; diff --git a/src/core/game/NationUtils.ts b/src/core/game/NationCreation.ts similarity index 100% rename from src/core/game/NationUtils.ts rename to src/core/game/NationCreation.ts diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 4ac72fd74..7b1a8210c 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -494,6 +494,7 @@ export class PlayerImpl implements Player { allRelationsSorted(): { player: Player; relation: Relation }[] { return Array.from(this.relations, ([k, v]) => ({ player: k, relation: v })) + .filter((r) => r.player.isAlive()) .sort((a, b) => a.relation - b.relation) .map((r) => ({ player: r.player, diff --git a/tests/NationMIRV.test.ts b/tests/NationMIRV.test.ts index 6f29b235e..14b493ddc 100644 --- a/tests/NationMIRV.test.ts +++ b/tests/NationMIRV.test.ts @@ -257,7 +257,7 @@ describe("Nation MIRV Retaliation", () => { testExecution.init(game); for (let tick = 0; tick < 200; tick++) { - testExecution.tick(game.ticks()); + testExecution.tick(tick); // Allow the game to process executions if (tick % 10 === 0) { game.executeNextTick(); @@ -410,7 +410,7 @@ describe("Nation MIRV Retaliation", () => { testExecution.init(game); for (let tick = 0; tick < 200; tick++) { - testExecution.tick(game.ticks()); + testExecution.tick(tick); // Allow the game to process executions if (tick % 10 === 0) { game.executeNextTick(); @@ -561,7 +561,7 @@ describe("Nation MIRV Retaliation", () => { testExecution.init(game); for (let tick = 0; tick < 200; tick++) { - testExecution.tick(game.ticks()); + testExecution.tick(tick); game.executeNextTick(); } @@ -695,7 +695,7 @@ describe("Nation MIRV Retaliation", () => { testExecution.init(game); for (let tick = 0; tick < 200; tick++) { - testExecution.tick(game.ticks()); + testExecution.tick(tick); // Allow the game to process executions if (tick % 10 === 0) { game.executeNextTick();