mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 04:03:49 +00:00
Merge branch 'main' into bomb-confirmation
This commit is contained in:
@@ -194,9 +194,9 @@ export class NationExecution implements Execution {
|
||||
this.updateRelationsFromEmbargos();
|
||||
this.allianceBehavior.handleAllianceRequests();
|
||||
this.allianceBehavior.handleAllianceExtensionRequests();
|
||||
this.mirvBehavior.considerMIRV();
|
||||
this.handleUnits();
|
||||
this.handleEmbargoesToHostileNations();
|
||||
this.mirvBehavior.considerMIRV();
|
||||
this.maybeAttack();
|
||||
this.warshipBehavior.counterWarshipInfestation();
|
||||
}
|
||||
@@ -350,7 +350,6 @@ export class NationExecution implements Execution {
|
||||
}
|
||||
const canBuild = this.player.canBuild(UnitType.Warship, targetTile);
|
||||
if (canBuild === false) {
|
||||
console.warn("cannot spawn destroyer");
|
||||
return false;
|
||||
}
|
||||
this.mg.addExecution(
|
||||
@@ -460,7 +459,7 @@ export class NationExecution implements Execution {
|
||||
}
|
||||
|
||||
this.attackBehavior.attackBestTarget(borderingFriends, borderingEnemies);
|
||||
this.nukeBehavior.maybeSendNuke(this.attackBehavior.findBestNukeTarget());
|
||||
this.nukeBehavior.maybeSendNuke();
|
||||
}
|
||||
|
||||
private sendBoatRandomly(borderingEnemies: Player[] = []) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Gold,
|
||||
Player,
|
||||
PlayerType,
|
||||
Relation,
|
||||
Tick,
|
||||
Unit,
|
||||
UnitType,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
import { TileRef, euclDistFN } from "../../game/GameMap";
|
||||
import { ParabolaPathFinder } from "../../pathfinding/PathFinding";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { boundingBoxTiles } from "../../Util";
|
||||
import { assertNever, boundingBoxTiles } from "../../Util";
|
||||
import { NukeExecution } from "../NukeExecution";
|
||||
import { closestTwoTiles } from "../Util";
|
||||
import { AiAttackBehavior } from "../utils/AiAttackBehavior";
|
||||
@@ -20,28 +21,38 @@ import { EMOJI_NUKE, NationEmojiBehavior } from "./NationEmojiBehavior";
|
||||
import { randTerritoryTileArray } from "./NationUtils";
|
||||
|
||||
export class NationNukeBehavior {
|
||||
private readonly lastNukeSent: [Tick, TileRef][] = [];
|
||||
private readonly recentlySentNukes: [
|
||||
Tick,
|
||||
TileRef,
|
||||
UnitType.AtomBomb | UnitType.HydrogenBomb,
|
||||
][] = [];
|
||||
private atomBombsLaunched = 0;
|
||||
private atomBombPerceivedCost = this.cost(UnitType.AtomBomb);
|
||||
private hydrogenBombsLaunched = 0;
|
||||
private hydrogenBombPerceivedCost = this.cost(UnitType.HydrogenBomb);
|
||||
// Make 1/3 of nations "hydro-nations" that only throw hydrogen bombs (to reduce atom bomb spam)
|
||||
private readonly isHydroNation: boolean = this.random.chance(3);
|
||||
|
||||
constructor(
|
||||
private random: PseudoRandom,
|
||||
private mg: Game,
|
||||
private game: Game,
|
||||
private player: Player,
|
||||
private attackBehavior: AiAttackBehavior,
|
||||
private emojiBehavior: NationEmojiBehavior,
|
||||
) {}
|
||||
|
||||
maybeSendNuke(other: Player | null) {
|
||||
maybeSendNuke() {
|
||||
const nukeTarget = this.findBestNukeTarget();
|
||||
if (nukeTarget === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
nukeTarget.type() === PlayerType.Bot || // Don't nuke bots (as opposed to nations and humans)
|
||||
this.player.isOnSameTeam(nukeTarget) ||
|
||||
this.attackBehavior.shouldAttack(nukeTarget) === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -51,14 +62,14 @@ export class NationNukeBehavior {
|
||||
let nukeType: UnitType;
|
||||
if (this.player.gold() >= hydroCost) {
|
||||
nukeType = UnitType.HydrogenBomb;
|
||||
} else if (this.player.gold() >= atomCost) {
|
||||
} else if (!this.isHydroNation && this.player.gold() >= atomCost) {
|
||||
nukeType = UnitType.AtomBomb;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const range = this.mg.config().nukeMagnitudes(nukeType).inner;
|
||||
const range = this.game.config().nukeMagnitudes(nukeType).outer;
|
||||
|
||||
const structures = other.units(
|
||||
const structures = nukeTarget.units(
|
||||
UnitType.City,
|
||||
UnitType.DefensePost,
|
||||
UnitType.MissileSilo,
|
||||
@@ -67,7 +78,15 @@ export class NationNukeBehavior {
|
||||
UnitType.Factory,
|
||||
);
|
||||
const structureTiles = structures.map((u) => u.tile());
|
||||
const randomTiles = randTerritoryTileArray(this.random, this.mg, other, 10);
|
||||
const difficulty = this.game.config().gameConfig().difficulty;
|
||||
// Use more random tiles on Impossible difficulty to improve chances of finding a perfect SAM outranging spot
|
||||
const numRandomTiles = difficulty === Difficulty.Impossible ? 30 : 10;
|
||||
const randomTiles = randTerritoryTileArray(
|
||||
this.random,
|
||||
this.game,
|
||||
nukeTarget,
|
||||
numRandomTiles,
|
||||
);
|
||||
const allTiles = randomTiles.concat(structureTiles);
|
||||
|
||||
let bestTile: TileRef | null = null;
|
||||
@@ -76,19 +95,27 @@ export class NationNukeBehavior {
|
||||
|
||||
outer: for (const tile of new Set(allTiles)) {
|
||||
if (tile === null) continue;
|
||||
const boundingBox = boundingBoxTiles(this.mg, tile, range)
|
||||
const boundingBox = boundingBoxTiles(this.game, 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)));
|
||||
.concat(boundingBoxTiles(this.game, tile, Math.floor(range / 2)));
|
||||
for (const t of boundingBox) {
|
||||
if (!this.isValidNukeTile(t, other)) {
|
||||
if (!this.isValidNukeTile(t, nukeTarget)) {
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
const spawnTile = this.player.canBuild(nukeType, tile);
|
||||
if (spawnTile === false) continue;
|
||||
|
||||
// In team games, avoid nuking the same position as a teammate
|
||||
if (
|
||||
this.game.config().gameConfig().gameMode === GameMode.Team &&
|
||||
difficulty !== Difficulty.Easy &&
|
||||
this.isTeammateAlreadyNukingThisSpot(tile, nukeType)
|
||||
) {
|
||||
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) &&
|
||||
@@ -104,7 +131,212 @@ export class NationNukeBehavior {
|
||||
}
|
||||
}
|
||||
if (bestTile !== null) {
|
||||
this.sendNuke(bestTile, nukeType, other);
|
||||
this.sendNuke(bestTile, nukeType, nukeTarget);
|
||||
}
|
||||
}
|
||||
|
||||
findBestNukeTarget(): Player | null {
|
||||
// Retaliate against incoming attacks (Most important!)
|
||||
const incomingAttackPlayer = this.attackBehavior.findIncomingAttackPlayer();
|
||||
if (incomingAttackPlayer) {
|
||||
return incomingAttackPlayer;
|
||||
}
|
||||
|
||||
// On impossible difficulty, prioritize nuking the crown if they have more than 50% of the map
|
||||
const { difficulty, gameMode } = this.game.config().gameConfig();
|
||||
if (difficulty === Difficulty.Impossible && gameMode === GameMode.FFA) {
|
||||
const numTilesWithoutFallout =
|
||||
this.game.numLandTiles() - this.game.numTilesWithFallout();
|
||||
if (numTilesWithoutFallout > 0) {
|
||||
const sortedByTiles = this.game
|
||||
.players()
|
||||
.slice()
|
||||
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
|
||||
const crown = sortedByTiles[0];
|
||||
|
||||
if (crown && crown !== this.player && !this.player.isFriendly(crown)) {
|
||||
const crownShare = crown.numTilesOwned() / numTilesWithoutFallout;
|
||||
if (crownShare > 0.5) {
|
||||
return crown;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assist allies, check their targets (this is basically the same as in assistAllies, but without sending emojis)
|
||||
for (const ally of this.player.allies()) {
|
||||
if (ally.targets().length === 0) continue;
|
||||
if (this.player.relation(ally) < Relation.Friendly) continue;
|
||||
|
||||
for (const target of ally.targets()) {
|
||||
if (target === this.player) continue;
|
||||
if (this.player.isFriendly(target)) continue;
|
||||
// Found a valid ally target to nuke
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
// 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];
|
||||
|
||||
// If we're the crown on Impossible difficulty, target 2nd place
|
||||
if (
|
||||
difficulty === Difficulty.Impossible &&
|
||||
firstPlace === this.player &&
|
||||
sortedByTiles.length >= 2
|
||||
) {
|
||||
const secondPlace = sortedByTiles[1];
|
||||
if (!this.player.isFriendly(secondPlace)) {
|
||||
return secondPlace;
|
||||
}
|
||||
}
|
||||
|
||||
// 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<string, number>();
|
||||
const teamPlayers = new Map<string, Player[]>();
|
||||
|
||||
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)!;
|
||||
|
||||
// Filter out friendly players
|
||||
const validTargets = targetTeamPlayers.filter(
|
||||
(p) => !this.player.isFriendly(p),
|
||||
);
|
||||
|
||||
if (validTargets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.random.chance(2)) {
|
||||
// Strongest player
|
||||
return validTargets.reduce((prev, current) =>
|
||||
this.game.config().maxTroops(prev) >
|
||||
this.game.config().maxTroops(current)
|
||||
? prev
|
||||
: current,
|
||||
);
|
||||
} else {
|
||||
// Random player
|
||||
return this.random.randElement(validTargets);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,13 +345,24 @@ export class NationNukeBehavior {
|
||||
// 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.game.config().gameConfig().gameMode === GameMode.Team ||
|
||||
this.player.gold() >
|
||||
this.cost(UnitType.MIRV) + this.cost(UnitType.HydrogenBomb)
|
||||
) {
|
||||
return this.cost(type);
|
||||
}
|
||||
|
||||
// On Hard & Impossible, ignore perceived cost when under heavy attack (2x troops)
|
||||
// The nation is probably going to get destroyed soon, so go all-in on nukes
|
||||
const difficulty = this.game.config().gameConfig().difficulty;
|
||||
if (
|
||||
(difficulty === Difficulty.Hard ||
|
||||
difficulty === Difficulty.Impossible) &&
|
||||
this.isUnderHeavyAttack()
|
||||
) {
|
||||
return this.cost(type);
|
||||
}
|
||||
|
||||
if (type === UnitType.AtomBomb) {
|
||||
return this.atomBombPerceivedCost;
|
||||
} else {
|
||||
@@ -127,13 +370,83 @@ export class NationNukeBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
private isUnderHeavyAttack(): boolean {
|
||||
// Get the total incoming attack troops
|
||||
const incomingAttacks = this.player.incomingAttacks();
|
||||
let totalIncomingTroops = 0;
|
||||
for (const attack of incomingAttacks) {
|
||||
totalIncomingTroops += attack.troops();
|
||||
}
|
||||
|
||||
const myTroops = this.player.troops();
|
||||
|
||||
// Consider it a heavy attack if total incoming attacks have 2x our troops
|
||||
return totalIncomingTroops >= myTroops * 2;
|
||||
}
|
||||
|
||||
private removeOldNukeEvents() {
|
||||
const maxAge = 600; // 600 ticks = 1 minute
|
||||
const tick = this.game.ticks();
|
||||
while (
|
||||
this.recentlySentNukes.length > 0 &&
|
||||
this.recentlySentNukes[0][0] + maxAge < tick
|
||||
) {
|
||||
this.recentlySentNukes.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private isTeammateAlreadyNukingThisSpot(
|
||||
tile: TileRef,
|
||||
nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb,
|
||||
): boolean {
|
||||
// Get the inner radius for our nuke type
|
||||
const ourInnerRadius = this.game.config().nukeMagnitudes(nukeType).inner;
|
||||
|
||||
// Get all active nukes in the game
|
||||
const activeNukes = this.game.units(
|
||||
UnitType.AtomBomb,
|
||||
UnitType.HydrogenBomb,
|
||||
);
|
||||
|
||||
// Check if any teammate's nuke blast radius overlaps with ours
|
||||
for (const nuke of activeNukes) {
|
||||
const nukeOwner = nuke.owner();
|
||||
|
||||
// Skip our own nukes and non-teammate nukes
|
||||
if (nukeOwner === this.player || !this.player.isFriendly(nukeOwner)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the target tile of the teammate's nuke
|
||||
const targetTile = nuke.targetTile();
|
||||
if (!targetTile) continue;
|
||||
|
||||
// Get the blast radius of the teammate's nuke
|
||||
const teammateInnerRadius = this.game
|
||||
.config()
|
||||
.nukeMagnitudes(nuke.type()).inner;
|
||||
|
||||
// Check if the blast zones overlap
|
||||
// They overlap if distance between targets < sum of the two radii
|
||||
const distSquared = this.game.euclideanDistSquared(tile, targetTile);
|
||||
const sumRadius = ourInnerRadius + teammateInnerRadius;
|
||||
const sumRadiusSquared = sumRadius * sumRadius;
|
||||
|
||||
if (distSquared <= sumRadiusSquared) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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 pathFinder = new ParabolaPathFinder(this.game);
|
||||
const speed = this.game.config().defaultNukeSpeed();
|
||||
const distanceBasedHeight = true; // Atom/Hydrogen bombs use distance-based height
|
||||
const rocketDirectionUp = true; // AI nukes always go "up" for now
|
||||
|
||||
@@ -151,7 +464,7 @@ export class NationNukeBehavior {
|
||||
}
|
||||
|
||||
const targetRangeSquared =
|
||||
this.mg.config().defaultNukeTargetableRange() ** 2;
|
||||
this.game.config().defaultNukeTargetableRange() ** 2;
|
||||
|
||||
let untargetableStart = -1;
|
||||
let untargetableEnd = -1;
|
||||
@@ -159,10 +472,11 @@ export class NationNukeBehavior {
|
||||
const tile = trajectory[i];
|
||||
if (untargetableStart === -1) {
|
||||
if (
|
||||
this.mg.euclideanDistSquared(tile, spawnTile) > targetRangeSquared
|
||||
this.game.euclideanDistSquared(tile, spawnTile) > targetRangeSquared
|
||||
) {
|
||||
if (
|
||||
this.mg.euclideanDistSquared(tile, targetTile) < targetRangeSquared
|
||||
this.game.euclideanDistSquared(tile, targetTile) <
|
||||
targetRangeSquared
|
||||
) {
|
||||
// Overlapping spawn & target range – no untargetable segment.
|
||||
break;
|
||||
@@ -171,7 +485,7 @@ export class NationNukeBehavior {
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
this.mg.euclideanDistSquared(tile, targetTile) < targetRangeSquared
|
||||
this.game.euclideanDistSquared(tile, targetTile) < targetRangeSquared
|
||||
) {
|
||||
untargetableEnd = i;
|
||||
break;
|
||||
@@ -190,9 +504,9 @@ export class NationNukeBehavior {
|
||||
}
|
||||
|
||||
const tile = trajectory[i];
|
||||
const nearbySams = this.mg.nearbyUnits(
|
||||
const nearbySams = this.game.nearbyUnits(
|
||||
tile,
|
||||
this.mg.config().maxSamRange(),
|
||||
this.game.config().maxSamRange(),
|
||||
UnitType.SAMLauncher,
|
||||
);
|
||||
|
||||
@@ -201,7 +515,7 @@ export class NationNukeBehavior {
|
||||
if (owner === this.player || this.player.isFriendly(owner)) {
|
||||
continue;
|
||||
}
|
||||
const rangeSquared = this.mg.config().samRange(sam.unit.level()) ** 2;
|
||||
const rangeSquared = this.game.config().samRange(sam.unit.level()) ** 2;
|
||||
if (sam.distSquared <= rangeSquared) {
|
||||
return true;
|
||||
}
|
||||
@@ -211,17 +525,17 @@ export class NationNukeBehavior {
|
||||
return false;
|
||||
}
|
||||
|
||||
private isValidNukeTile(t: TileRef, other: Player | null): boolean {
|
||||
const difficulty = this.mg.config().gameConfig().difficulty;
|
||||
private isValidNukeTile(t: TileRef, nukeTarget: Player | null): boolean {
|
||||
const difficulty = this.game.config().gameConfig().difficulty;
|
||||
|
||||
const owner = this.mg.owner(t);
|
||||
if (owner === other) return true;
|
||||
const owner = this.game.owner(t);
|
||||
if (owner === nukeTarget) 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 &&
|
||||
(this.game.config().gameConfig().gameMode === GameMode.Team &&
|
||||
owner.isPlayer() &&
|
||||
!this.player.isFriendly(owner)))
|
||||
) {
|
||||
@@ -231,27 +545,16 @@ export class NationNukeBehavior {
|
||||
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);
|
||||
const magnitude = this.game.config().nukeMagnitudes(nukeType);
|
||||
const dist = euclDistFN(tile, magnitude.outer, false);
|
||||
let tileValue = targets
|
||||
.filter((unit) => dist(this.mg, unit.tile()))
|
||||
.filter((unit) => dist(this.game, unit.tile()))
|
||||
.map((unit): number => {
|
||||
const level = unit.level();
|
||||
switch (unit.type()) {
|
||||
@@ -271,7 +574,7 @@ export class NationNukeBehavior {
|
||||
})
|
||||
.reduce((prev, cur) => prev + cur, 0);
|
||||
|
||||
const difficulty = this.mg.config().gameConfig().difficulty;
|
||||
const difficulty = this.game.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().
|
||||
@@ -279,26 +582,63 @@ export class NationNukeBehavior {
|
||||
const dist50 = euclDistFN(tile, 50, false);
|
||||
const hasSam = targets.some(
|
||||
(unit) =>
|
||||
unit.type() === UnitType.SAMLauncher && dist50(this.mg, unit.tile()),
|
||||
unit.type() === UnitType.SAMLauncher &&
|
||||
dist50(this.game, unit.tile()),
|
||||
);
|
||||
if (hasSam) return -1;
|
||||
}
|
||||
|
||||
// On Impossible difficulty and a hydrogen bomb, add value for SAMs that can be outranged
|
||||
if (
|
||||
difficulty === Difficulty.Impossible &&
|
||||
nukeType === UnitType.HydrogenBomb
|
||||
) {
|
||||
const hydroMagnitude = this.game
|
||||
.config()
|
||||
.nukeMagnitudes(UnitType.HydrogenBomb);
|
||||
const nearbySams = this.game.nearbyUnits(
|
||||
tile,
|
||||
hydroMagnitude.outer,
|
||||
UnitType.SAMLauncher,
|
||||
);
|
||||
|
||||
for (const sam of nearbySams) {
|
||||
const samLevel = sam.unit.level();
|
||||
if (samLevel >= 5) continue; // Can't outrange level 5+ SAMs
|
||||
|
||||
const samRange = this.game.config().samRange(samLevel);
|
||||
const distToSam = Math.sqrt(
|
||||
this.game.euclideanDistSquared(tile, sam.unit.tile()),
|
||||
);
|
||||
|
||||
// Check if we can outrange this SAM
|
||||
if (distToSam > samRange) {
|
||||
// Add significant value for destroying a SAM that we can outrange
|
||||
tileValue += 100_000 * samLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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]);
|
||||
const result = closestTwoTiles(this.game, siloTiles, [tile]);
|
||||
if (result === null) throw new Error("Missing result");
|
||||
const { x: closestSilo } = result;
|
||||
const distanceSquared = this.mg.euclideanDistSquared(tile, closestSilo);
|
||||
const distanceSquared = this.game.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))
|
||||
tileValue -= this.recentlySentNukes
|
||||
.filter(([_tick, recentTile, recentNukeType]) => {
|
||||
const recentInnerRadius = this.game
|
||||
.config()
|
||||
.nukeMagnitudes(recentNukeType).inner;
|
||||
const distSquared = this.game.euclideanDistSquared(tile, recentTile);
|
||||
return distSquared <= recentInnerRadius * recentInnerRadius;
|
||||
})
|
||||
.map((_) => 1_000_000)
|
||||
.reduce((prev, cur) => prev + cur, 0);
|
||||
|
||||
@@ -310,8 +650,8 @@ export class NationNukeBehavior {
|
||||
nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb,
|
||||
targetPlayer: Player,
|
||||
) {
|
||||
const tick = this.mg.ticks();
|
||||
this.lastNukeSent.push([tick, tile]);
|
||||
const tick = this.game.ticks();
|
||||
this.recentlySentNukes.push([tick, tile, nukeType]);
|
||||
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)
|
||||
@@ -322,11 +662,11 @@ export class NationNukeBehavior {
|
||||
this.hydrogenBombPerceivedCost =
|
||||
(this.hydrogenBombPerceivedCost * 115n) / 100n;
|
||||
}
|
||||
this.mg.addExecution(new NukeExecution(nukeType, this.player, tile));
|
||||
this.game.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);
|
||||
return this.game.unitInfo(type).cost(this.game, this.player);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
GameMode,
|
||||
Player,
|
||||
PlayerType,
|
||||
Relation,
|
||||
@@ -200,169 +199,6 @@ export class AiAttackBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
findBestNukeTarget(): Player | null {
|
||||
// Retaliate against incoming attacks (Most important!)
|
||||
const incomingAttackPlayer = this.findIncomingAttackPlayer();
|
||||
if (incomingAttackPlayer) {
|
||||
return incomingAttackPlayer;
|
||||
}
|
||||
|
||||
// Assist allies, check their targets (this is basically the same as in assistAllies, but without sending emojis)
|
||||
for (const ally of this.player.allies()) {
|
||||
if (ally.targets().length === 0) continue;
|
||||
if (this.player.relation(ally) < Relation.Friendly) continue;
|
||||
|
||||
for (const target of ally.targets()) {
|
||||
if (target === this.player) continue;
|
||||
if (this.player.isFriendly(target)) continue;
|
||||
// Found a valid ally target to nuke
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
// 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<string, number>();
|
||||
const teamPlayers = new Map<string, Player[]>();
|
||||
|
||||
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;
|
||||
@@ -375,7 +211,7 @@ export class AiAttackBehavior {
|
||||
return ratio >= this.triggerRatio;
|
||||
}
|
||||
|
||||
private findIncomingAttackPlayer(): Player | null {
|
||||
findIncomingAttackPlayer(): Player | null {
|
||||
// Ignore bot attacks if we are not a bot.
|
||||
let incomingAttacks = this.player.incomingAttacks();
|
||||
if (this.player.type() !== PlayerType.Bot) {
|
||||
|
||||
Reference in New Issue
Block a user