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();