From d6615030582a309516c4622aead9faf2eac0385c Mon Sep 17 00:00:00 2001
From: 1brucben <1benjbruce@gmail.com>
Date: Mon, 23 Feb 2026 05:20:51 +0100
Subject: [PATCH 1/2] Refactor nuclear evaluation system: remove
AINukeEvaluator, integrate scoring breakdowns
- Removed AINukeEvaluator and its associated methods, consolidating functionality into AINukeHandler.
- Introduced NukeScoreBreakdown interface for detailed scoring breakdowns of nuclear targets.
- Updated AIConstructionHandler and ConstructionDebugOverlay to utilize new breakdown data.
- Adjusted AIPlayerExecution to reflect changes in nuke handling and scoring.
- Enhanced debug information with detailed breakdowns for best atom and hydrogen targets.
---
.../layers/ConstructionDebugOverlay.ts | 43 ++-
src/core/ai/AIConstructionHandler.ts | 44 ---
src/core/ai/AINukeEvaluator.ts | 295 ----------------
src/core/ai/AINukeHandler.ts | 321 ++++++++++++++++--
src/core/ai/AIPlayerExecution.ts | 109 ++----
src/core/ai/ConstructionDebugData.ts | 6 +
src/core/ai/index.ts | 7 +-
7 files changed, 379 insertions(+), 446 deletions(-)
delete mode 100644 src/core/ai/AINukeEvaluator.ts
diff --git a/src/client/graphics/layers/ConstructionDebugOverlay.ts b/src/client/graphics/layers/ConstructionDebugOverlay.ts
index 6b636582b..0aaef7694 100644
--- a/src/client/graphics/layers/ConstructionDebugOverlay.ts
+++ b/src/client/graphics/layers/ConstructionDebugOverlay.ts
@@ -1,3 +1,4 @@
+import { NukeScoreBreakdown } from "../../../core/ai/AINukeHandler";
import {
ConstructionDebugData,
ConstructionScoreEntry,
@@ -272,8 +273,48 @@ export class ConstructionDebugOverlay implements Layer {
- Adjusted best nuke score (×multiplier×7): ${this.formatScore(n.adjustedBestNukeScore)}
+ Adjusted best nuke score (×multiplier×0.7): ${this.formatScore(n.adjustedBestNukeScore)}
+ ${this.renderNukeBreakdown("Atom", n.atomBreakdown)}
+ ${this.renderNukeBreakdown("Hydrogen", n.hydrogenBreakdown)}
+
+ `;
+ }
+
+ private renderNukeBreakdown(
+ label: string,
+ b: NukeScoreBreakdown | null,
+ ): string {
+ if (!b) return "";
+ const fmt = (v: number) => this.formatScore(v);
+ const fmtK = (v: number) =>
+ v >= 1_000_000
+ ? (v / 1_000_000).toFixed(2) + "M"
+ : v >= 1000
+ ? (v / 1000).toFixed(1) + "k"
+ : v.toFixed(0);
+ return `
+
+
${label} Breakdown
+
+ | Enemy structs | ${b.enemyStructureCount} |
+ | Raw enemy value | ${fmtK(b.rawEnemyValue)} |
+ | Strongest enemy? | ${b.isStrongestEnemy ? "Yes (+1000/struct)" : "No"} |
+ | War score (raw) | ${b.rawWarScore.toFixed(1)} |
+ | War score sigmoid | ${b.warScoreSigmoid.toFixed(4)} |
+ | Friendly structs | ${b.friendlyStructureCount} |
+ | Friendly value | ${fmtK(b.friendlyValue)} |
+ | Numerator | ${fmt(b.numerator)} |
+ | ───── Cost / Discount ───── |
+ | Bomb cost | ${fmtK(b.bombCost)} |
+ | SAM levels | ${b.samLevels} |
+ | Silo cost (amort.) | ${fmtK(b.siloCost)} |
+ | Total cost | ${fmtK(b.totalCost)} |
+ | Gold/min | ${fmtK(b.goldPerMinute)} |
+ | T (minutes) | ${b.T === Infinity ? "∞" : b.T.toFixed(2)} |
+ | (1+r)^T | ${b.discountFactor >= 1e6 ? b.discountFactor.toExponential(2) : b.discountFactor.toFixed(4)} |
+ | Final Score | ${fmt(b.finalScore)} |
+
`;
}
diff --git a/src/core/ai/AIConstructionHandler.ts b/src/core/ai/AIConstructionHandler.ts
index 54523e197..e5b34f84a 100644
--- a/src/core/ai/AIConstructionHandler.ts
+++ b/src/core/ai/AIConstructionHandler.ts
@@ -9,7 +9,6 @@ import {
PlayerType,
Unit,
UnitType,
- UpgradeType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import {
@@ -20,7 +19,6 @@ import {
import { PseudoRandom } from "../PseudoRandom";
import { tradeIncomeModifiers } from "../tech/TechEffects";
import { AIBehaviorParams } from "./AIBehaviorParams";
-import { AINukeEvaluator } from "./AINukeEvaluator";
/**
* Handles structure construction for AI players.
@@ -143,15 +141,11 @@ export class AIConstructionHandler {
/** Optional callback that returns the current naval unit score (max of warship, submarine). */
private _navalScoreProvider: (() => number) | null = null;
- /** Internal multiplier applied to nuke scores in shouldDeferToNukes. */
- private static readonly NUKE_SCORE_CONSTRUCTION_INTERNAL_MULTIPLIER = 1;
-
constructor(
private mg: Game,
private playerId: PlayerID,
private random: PseudoRandom,
private params: AIBehaviorParams,
- private nukeEvaluator: AINukeEvaluator | null = null,
) {
// Stagger periodic actions across AIs using random offset
this.phaseSeed = random.nextInt(0, 0x7fffffff);
@@ -290,11 +284,6 @@ export class AIConstructionHandler {
return;
}
- // If nuke score threshold is set, skip construction when nuke value is higher
- if (this.shouldDeferToNukes(player)) {
- return;
- }
-
// Only attempt placement if we can afford the target structure
if (!this.canAffordTarget(player, this.target)) {
return;
@@ -2255,39 +2244,6 @@ export class AIConstructionHandler {
}
}
- /**
- * Returns true if construction should be deferred because nuke value
- * exceeds the construction target score (scaled by threshold param).
- * Only considers hydrogen bomb score if the player has ThermonuclearStaging.
- */
- private shouldDeferToNukes(player: Player): boolean {
- const threshold = this.params.nukeScoreConstructionThreshold ?? 0;
- if (threshold <= 0 || !this.nukeEvaluator || this.target === null)
- return false;
-
- // Get the best nuke scores
- const atomTarget = this.nukeEvaluator.bestAtomTarget();
- let bestNukeScore = atomTarget?.score ?? 0;
-
- // Only consider hydrogen bomb if player has researched ThermonuclearStaging
- if (player.hasUpgrade(UpgradeType.ThermonuclearStaging)) {
- const hydrogenTarget = this.nukeEvaluator.bestHydrogenTarget();
- if (hydrogenTarget && hydrogenTarget.score > bestNukeScore) {
- bestNukeScore = hydrogenTarget.score;
- }
- }
-
- if (bestNukeScore <= 0) return false;
-
- // Apply internal multiplier
- bestNukeScore *=
- AIConstructionHandler.NUKE_SCORE_CONSTRUCTION_INTERNAL_MULTIPLIER;
-
- const constructionScore = this.scoreTarget(player, this.target);
-
- return constructionScore < threshold * bestNukeScore;
- }
-
/**
* Returns the best construction score across all candidate structure types.
*/
diff --git a/src/core/ai/AINukeEvaluator.ts b/src/core/ai/AINukeEvaluator.ts
deleted file mode 100644
index bd5f79381..000000000
--- a/src/core/ai/AINukeEvaluator.ts
+++ /dev/null
@@ -1,295 +0,0 @@
-import { NukeMagnitude } from "../configuration/Config";
-import { Game, isStructureType, Player, Unit, UnitType } from "../game/Game";
-import { TileRef } from "../game/GameMap";
-import { playerMaxStructureTechLevel } from "../game/Upgradeables";
-import { PseudoRandom } from "../PseudoRandom";
-import { GameID } from "../Schemas";
-
-/**
- * Best nuke target info for a given bomb type.
- */
-export interface NukeBestTarget {
- tile: TileRef;
- score: number;
-}
-
-/**
- * Shared AI handler that evaluates potential nuclear strike targets.
- *
- * Every tick, picks a random tile and calculates two scores (atom bomb and
- * hydrogen bomb) based on the value of all structures within the bomb's inner
- * blast range, minus the bomb cost and SAM interception penalties.
- *
- * Scores are shared across all AI players in the same game.
- * Every 100 ticks, the currently saved best tiles are reevaluated.
- */
-export class AINukeEvaluator {
- // One shared instance per game, keyed by GameID
- private static _instances: Map = new Map();
-
- private static readonly REEVALUATE_INTERVAL = 100;
- private static readonly UPGRADE_MULTIPLIER = 0.8;
-
- private static readonly ALL_STRUCTURE_TYPES: UnitType[] = Object.values(
- UnitType,
- ).filter((t) => isStructureType(t));
-
- // Best atom bomb target
- private _bestAtomScore: number = 0;
- private _bestAtomTile: TileRef | null = null;
-
- // Best hydrogen bomb target
- private _bestHydrogenScore: number = 0;
- private _bestHydrogenTile: TileRef | null = null;
-
- // Tick tracking for reevaluation
- private _lastReevalTick: number = -1;
-
- // Dedup guard: only evaluate once per game tick even if multiple AI players call tick()
- private _lastTickProcessed: number = -1;
-
- // Precomputed max SAM range (level 3) for spatial queries
- private _maxSAMRange: number = 0;
-
- private constructor(private mg: Game) {
- // Precompute worst-case SAM range (max tech level = 3) for spatial queries
- const baseRange = mg.config().defaultSamRange();
- const rangeBonus = mg.config().samRangeUpgradePercent();
- const maxTechLevel = 3; // SAMLauncher max stack count
- this._maxSAMRange = baseRange * Math.pow(1 + rangeBonus, maxTechLevel - 1);
- }
-
- /**
- * Get or create the shared NukeHandler instance for this game.
- */
- static getInstance(gameID: GameID, mg: Game): AINukeEvaluator {
- let instance = AINukeEvaluator._instances.get(gameID);
- if (!instance) {
- instance = new AINukeEvaluator(mg);
- AINukeEvaluator._instances.set(gameID, instance);
- }
- return instance;
- }
-
- /**
- * Remove the shared instance for a game (call on game end).
- */
- static removeInstance(gameID: GameID): void {
- AINukeEvaluator._instances.delete(gameID);
- }
-
- /**
- * Called each tick by any AI player. Only evaluates once per game tick;
- * subsequent calls within the same tick are no-ops.
- */
- tick(random: PseudoRandom, ticks: number): void {
- // Only evaluate once per game tick
- if (ticks === this._lastTickProcessed) return;
- this._lastTickProcessed = ticks;
-
- // Every 100 ticks, reevaluate the saved best tiles
- if (
- this._lastReevalTick < 0 ||
- ticks - this._lastReevalTick >= AINukeEvaluator.REEVALUATE_INTERVAL
- ) {
- this.reevaluateBest();
- this._lastReevalTick = ticks;
- }
-
- // Pick a random tile on the map
- const w = this.mg.width();
- const h = this.mg.height();
- const rx = random.nextInt(0, w);
- const ry = random.nextInt(0, h);
- const tile = this.mg.ref(rx, ry);
-
- // Score for atom bomb
- const atomScore = this.calculateNukeScore(tile, UnitType.AtomBomb);
- if (atomScore > this._bestAtomScore) {
- this._bestAtomScore = atomScore;
- this._bestAtomTile = tile;
- }
-
- // Score for hydrogen bomb
- const hydrogenScore = this.calculateNukeScore(tile, UnitType.HydrogenBomb);
- if (hydrogenScore > this._bestHydrogenScore) {
- this._bestHydrogenScore = hydrogenScore;
- this._bestHydrogenTile = tile;
- }
- }
-
- /**
- * Returns the best atom bomb target found so far (or null if none).
- */
- bestAtomTarget(): NukeBestTarget | null {
- if (this._bestAtomTile === null) return null;
- return { tile: this._bestAtomTile, score: this._bestAtomScore };
- }
-
- /**
- * Returns the best hydrogen bomb target found so far (or null if none).
- */
- bestHydrogenTarget(): NukeBestTarget | null {
- if (this._bestHydrogenTile === null) return null;
- return { tile: this._bestHydrogenTile, score: this._bestHydrogenScore };
- }
-
- // ---------------------------------------------------------------------------
- // Private helpers
- // ---------------------------------------------------------------------------
-
- /**
- * Reevaluate the saved best tiles. If the tile is no longer valuable,
- * reset it so future sampling can find a better one.
- */
- private reevaluateBest(): void {
- if (this._bestAtomTile !== null) {
- const newScore = this.calculateNukeScore(
- this._bestAtomTile,
- UnitType.AtomBomb,
- );
- if (newScore <= 0) {
- this._bestAtomScore = 0;
- this._bestAtomTile = null;
- } else {
- this._bestAtomScore = newScore;
- }
- }
-
- if (this._bestHydrogenTile !== null) {
- const newScore = this.calculateNukeScore(
- this._bestHydrogenTile,
- UnitType.HydrogenBomb,
- );
- if (newScore <= 0) {
- this._bestHydrogenScore = 0;
- this._bestHydrogenTile = null;
- } else {
- this._bestHydrogenScore = newScore;
- }
- }
- }
-
- /**
- * Calculate the nuke score for a given tile and bomb type.
- * Uses spatial grid query (nearbyUnits) instead of iterating all structures.
- *
- * Score = (total value of all structures within inner blast range)
- * / (cost of the bomb + atom bomb cost × SAM levels within SAM range)
- */
- private calculateNukeScore(tile: TileRef, bombType: UnitType): number {
- const magnitude: NukeMagnitude = this.mg.config().nukeMagnitudes(bombType);
- const innerRange = magnitude.inner;
-
- // Spatial query: only checks nearby grid cells, not all structures on the map
- const nearby = this.mg.nearbyUnits(
- tile,
- innerRange,
- AINukeEvaluator.ALL_STRUCTURE_TYPES,
- );
-
- let totalValue = 0;
- for (const { unit: structure } of nearby) {
- totalValue += this.getStructureValue(structure);
- }
-
- // Compute total cost: bomb + SAM interception
- const bombCost = Number(
- this.mg.unitInfo(bombType).cost(this.dummyPlayer()),
- );
- const atomBombCost = Number(
- this.mg.unitInfo(UnitType.AtomBomb).cost(this.dummyPlayer()),
- );
- const samPenalty = this.calculateSAMPenalty(tile) * atomBombCost;
- const totalCost = Math.max(bombCost + samPenalty, 1);
-
- return totalValue / totalCost;
- }
-
- /**
- * Count total SAM levels within SAM range of the tile.
- * Uses spatial query with max possible SAM range, then filters
- * by each SAM's actual effective range based on owner tech level.
- */
- private calculateSAMPenalty(tile: TileRef): number {
- const nearbySAMs = this.mg.nearbyUnits(
- tile,
- this._maxSAMRange,
- UnitType.SAMLauncher,
- );
- let totalSAMLevels = 0;
-
- for (const { unit: sam, distSquared } of nearbySAMs) {
- // Get the SAM's effective range based on its owner's tech level
- const owner = sam.owner();
- const samRange = this.getEffectiveSAMRange(owner);
- const samRangeSquared = samRange * samRange;
-
- // Check if the tile is within this SAM's actual range
- if (distSquared <= samRangeSquared) {
- totalSAMLevels += sam.stackCount();
- }
- }
-
- return totalSAMLevels;
- }
-
- /**
- * Compute the value of a structure: base cost + 80% per upgrade level.
- * Same calculation as AIConstructionHandler.getStructureValue.
- */
- private getStructureValue(structure: Unit): number {
- const unitType = structure.type();
- const owner = structure.owner();
- const baseCost = Number(this.mg.unitInfo(unitType).cost(owner));
- const level = structure.stackCount?.() ?? 1;
-
- if (level <= 1) {
- return baseCost;
- }
-
- let totalValue = baseCost;
- for (let i = 2; i <= level; i++) {
- totalValue += baseCost * AINukeEvaluator.UPGRADE_MULTIPLIER;
- }
- return totalValue;
- }
-
- /**
- * Compute the effective SAM range for a player's tech level.
- */
- private getEffectiveSAMRange(player: Player): number {
- const baseRange = this.mg.config().defaultSamRange();
- const rangeBonus = this.mg.config().samRangeUpgradePercent();
- const techLevel = this.getPlayerSAMTechLevel(player);
- if (techLevel <= 1) return baseRange;
- return baseRange * Math.pow(1 + rangeBonus, techLevel - 1);
- }
-
- /**
- * Get a player's SAM tech level.
- */
- private getPlayerSAMTechLevel(player: Player): number {
- return playerMaxStructureTechLevel(player, UnitType.SAMLauncher);
- }
-
- /**
- * Get a dummy player reference for cost lookups.
- * unitInfo().cost() requires a Player, but for base cost we use the first alive player.
- * Falls back to any player if none alive.
- */
- private _dummyCached: Player | null = null;
- private dummyPlayer(): Player {
- if (this._dummyCached && this._dummyCached.isAlive()) {
- return this._dummyCached;
- }
- const players = this.mg.players();
- this._dummyCached =
- players.find((p) => p.isAlive()) ??
- (players.length > 0 ? players[0] : null);
- if (!this._dummyCached) {
- throw new Error("No players available for cost lookup");
- }
- return this._dummyCached;
- }
-}
diff --git a/src/core/ai/AINukeHandler.ts b/src/core/ai/AINukeHandler.ts
index 1c0588ed7..5aa308b98 100644
--- a/src/core/ai/AINukeHandler.ts
+++ b/src/core/ai/AINukeHandler.ts
@@ -21,6 +21,49 @@ export interface NukeHandlerBestTarget {
score: number;
}
+/**
+ * Detailed breakdown of all inputs to a nuke score calculation.
+ * Used for the construction debug overlay.
+ */
+export interface NukeScoreBreakdown {
+ /** Total raw enemy structure value (before sigmoid). */
+ rawEnemyValue: number;
+ /** Total friendly structure value in blast zone. */
+ friendlyValue: number;
+ /** Number of enemy structures in blast zone. */
+ enemyStructureCount: number;
+ /** Number of friendly structures in blast zone. */
+ friendlyStructureCount: number;
+ /** War score sigmoid applied to target owner. */
+ warScoreSigmoid: number;
+ /** Raw war score value before sigmoid. */
+ rawWarScore: number;
+ /** Strongest enemy bonus per structure (flat). */
+ strongestEnemyBonus: number;
+ /** Whether target owner is the strongest enemy. */
+ isStrongestEnemy: boolean;
+ /** Numerator: enemyValue - friendlyDamageWeight * friendlyValue. */
+ numerator: number;
+ /** Cost of the main bomb. */
+ bombCost: number;
+ /** Total SAM levels in range. */
+ samLevels: number;
+ /** Extra silo cost (amortised). */
+ siloCost: number;
+ /** Total cost used in discount calculation. */
+ totalCost: number;
+ /** Gross gold income per minute. */
+ goldPerMinute: number;
+ /** T = totalCost / goldPerMinute (minutes to afford). */
+ T: number;
+ /** Discount rate used. */
+ discountRate: number;
+ /** Discount factor: (1 + r)^T. */
+ discountFactor: number;
+ /** Final score: numerator / discountFactor. */
+ finalScore: number;
+}
+
/**
* Per-AI-player handler that evaluates potential nuclear strike targets
* against players the AI is currently at war with.
@@ -30,8 +73,8 @@ export interface NukeHandlerBestTarget {
* inner blast range, minus the bomb cost, SAM penalties, and a penalty for
* collateral damage to non-enemy player structures.
*
- * Unlike the shared AINukeEvaluator, each AI player has its own instance
- * so scores reflect that player's specific war relationships.
+ * Each AI player has its own instance so scores reflect that player's
+ * specific war relationships.
*/
export class AINukeHandler {
private static readonly REEVALUATE_INTERVAL = 100;
@@ -46,10 +89,12 @@ export class AINukeHandler {
// Best atom bomb target for this AI player
private _bestAtomScore: number = 0;
private _bestAtomTile: TileRef | null = null;
+ private _bestAtomBreakdown: NukeScoreBreakdown | null = null;
// Best hydrogen bomb target for this AI player
private _bestHydrogenScore: number = 0;
private _bestHydrogenTile: TileRef | null = null;
+ private _bestHydrogenBreakdown: NukeScoreBreakdown | null = null;
// Tick tracking for reevaluation
private _lastReevalTick: number = -1;
@@ -139,15 +184,17 @@ export class AINukeHandler {
if (tile === null) return;
// Score both bomb types in a single pass (one spatial query)
- const { atomScore, hydrogenScore } = this.scoreTileBothBombs(tile);
+ const result = this.scoreTileBothBombs(tile);
- if (atomScore > this._bestAtomScore) {
- this._bestAtomScore = atomScore;
+ if (result.atomScore > this._bestAtomScore) {
+ this._bestAtomScore = result.atomScore;
this._bestAtomTile = tile;
+ this._bestAtomBreakdown = result.atomBreakdown;
}
- if (hydrogenScore > this._bestHydrogenScore) {
- this._bestHydrogenScore = hydrogenScore;
+ if (result.hydrogenScore > this._bestHydrogenScore) {
+ this._bestHydrogenScore = result.hydrogenScore;
this._bestHydrogenTile = tile;
+ this._bestHydrogenBreakdown = result.hydrogenBreakdown;
}
}
@@ -159,6 +206,14 @@ export class AINukeHandler {
return { tile: this._bestAtomTile, score: this._bestAtomScore };
}
+ /**
+ * Returns the stored breakdown for the best atom target (captured at the
+ * moment the score was recorded), or null.
+ */
+ bestAtomBreakdown(): NukeScoreBreakdown | null {
+ return this._bestAtomBreakdown;
+ }
+
/**
* Returns the best hydrogen bomb target found so far (or null if none).
*/
@@ -167,6 +222,14 @@ export class AINukeHandler {
return { tile: this._bestHydrogenTile, score: this._bestHydrogenScore };
}
+ /**
+ * Returns the stored breakdown for the best hydrogen target (captured at
+ * the moment the score was recorded), or null.
+ */
+ bestHydrogenBreakdown(): NukeScoreBreakdown | null {
+ return this._bestHydrogenBreakdown;
+ }
+
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
@@ -242,28 +305,32 @@ export class AINukeHandler {
*/
private reevaluateBest(): void {
if (this._bestAtomTile !== null) {
- const newScore = this.calculateNukeScore(
+ const bd = this.calculateNukeScoreBreakdown(
this._bestAtomTile,
UnitType.AtomBomb,
);
- if (newScore <= 0) {
+ if (bd.finalScore <= 0) {
this._bestAtomScore = 0;
this._bestAtomTile = null;
+ this._bestAtomBreakdown = null;
} else {
- this._bestAtomScore = newScore;
+ this._bestAtomScore = bd.finalScore;
+ this._bestAtomBreakdown = bd;
}
}
if (this._bestHydrogenTile !== null) {
- const newScore = this.calculateNukeScore(
+ const bd = this.calculateNukeScoreBreakdown(
this._bestHydrogenTile,
UnitType.HydrogenBomb,
);
- if (newScore <= 0) {
+ if (bd.finalScore <= 0) {
this._bestHydrogenScore = 0;
this._bestHydrogenTile = null;
+ this._bestHydrogenBreakdown = null;
} else {
- this._bestHydrogenScore = newScore;
+ this._bestHydrogenScore = bd.finalScore;
+ this._bestHydrogenBreakdown = bd;
}
}
}
@@ -279,6 +346,8 @@ export class AINukeHandler {
private scoreTileBothBombs(tile: TileRef): {
atomScore: number;
hydrogenScore: number;
+ atomBreakdown: NukeScoreBreakdown;
+ hydrogenBreakdown: NukeScoreBreakdown;
} {
const atomMagnitude = this.mg.config().nukeMagnitudes(UnitType.AtomBomb);
const hydrogenMagnitude = this.mg
@@ -291,12 +360,30 @@ export class AINukeHandler {
const friendlyDamageWeight = this.params.nukeFriendlyDamageWeight ?? 1.0;
const strongestEnemyId = this._cachedStrongestEnemyId;
+ const strongestEnemyBonus = 1000;
+ // Weighted (post-sigmoid) values
let atomEnemyValue = 0;
let atomFriendlyValue = 0;
let hydrogenEnemyValue = 0;
let hydrogenFriendlyValue = 0;
+ // Raw (pre-sigmoid) values for breakdown
+ let atomRawEnemyValue = 0;
+ let hydrogenRawEnemyValue = 0;
+
+ // Structure counts
+ let atomEnemyCount = 0;
+ let atomFriendlyCount = 0;
+ let hydrogenEnemyCount = 0;
+ let hydrogenFriendlyCount = 0;
+
+ // Track primary target for war-score display
+ let primaryTargetId: PlayerID | null = null;
+ let primarySigmoid = 1;
+ let primaryWarScore = 0;
+ let primaryIsStrongest = false;
+
// Single spatial query using the larger hydrogen radius
const nearby = this.mg.nearbyUnits(
tile,
@@ -315,14 +402,34 @@ export class AINukeHandler {
owner.id() !== this.playerId && this.player!.isAtWarWith(owner);
if (isEnemy) {
- const bonus = owner.id() === strongestEnemyId ? 1000 : 0;
+ const bonus = owner.id() === strongestEnemyId ? strongestEnemyBonus : 0;
const sig = this.getCachedSigmoid(owner.id());
- hydrogenEnemyValue += (value + bonus) * sig;
- if (distSquared <= atomInnerRangeSq)
- atomEnemyValue += (value + bonus) * sig;
+ const raw = value + bonus;
+ hydrogenEnemyValue += raw * sig;
+ hydrogenRawEnemyValue += raw;
+ hydrogenEnemyCount++;
+ if (distSquared <= atomInnerRangeSq) {
+ atomEnemyValue += raw * sig;
+ atomRawEnemyValue += raw;
+ atomEnemyCount++;
+ }
+ // Track first enemy as primary target
+ if (primaryTargetId === null) {
+ primaryTargetId = owner.id();
+ primarySigmoid = sig;
+ primaryIsStrongest = owner.id() === strongestEnemyId;
+ const scale = this.params.nukeWarScoreSigmoidScale ?? 1 / 50;
+ if (scale !== 0 && this._warScoreProvider) {
+ primaryWarScore = this._warScoreProvider(owner.id());
+ }
+ }
} else {
hydrogenFriendlyValue += value;
- if (distSquared <= atomInnerRangeSq) atomFriendlyValue += value;
+ hydrogenFriendlyCount++;
+ if (distSquared <= atomInnerRangeSq) {
+ atomFriendlyValue += value;
+ atomFriendlyCount++;
+ }
}
}
@@ -344,7 +451,8 @@ export class AINukeHandler {
const atomTotalCost = atomBombCost + samLevels * atomBombCost;
const atomT =
grossGoldPerMinute > 0 ? atomTotalCost / grossGoldPerMinute : Infinity;
- const atomScore = atomNumerator / Math.pow(1 + discountRate, atomT);
+ const atomDiscountFactor = Math.pow(1 + discountRate, atomT);
+ const atomScore = atomNumerator / atomDiscountFactor;
// Hydrogen score
const hydrogenNumerator =
@@ -358,10 +466,52 @@ export class AINukeHandler {
grossGoldPerMinute > 0
? hydrogenTotalCost / grossGoldPerMinute
: Infinity;
- const hydrogenScore =
- hydrogenNumerator / Math.pow(1 + discountRate, hydrogenT);
-
- return { atomScore, hydrogenScore };
+ const hydrogenDiscountFactor = Math.pow(1 + discountRate, hydrogenT);
+ const hydrogenScore = hydrogenNumerator / hydrogenDiscountFactor;
+
+ const atomBreakdown: NukeScoreBreakdown = {
+ rawEnemyValue: atomRawEnemyValue,
+ friendlyValue: atomFriendlyValue,
+ enemyStructureCount: atomEnemyCount,
+ friendlyStructureCount: atomFriendlyCount,
+ warScoreSigmoid: primarySigmoid,
+ rawWarScore: primaryWarScore,
+ strongestEnemyBonus,
+ isStrongestEnemy: primaryIsStrongest,
+ numerator: atomNumerator,
+ bombCost: atomBombCost,
+ samLevels,
+ siloCost: 0,
+ totalCost: atomTotalCost,
+ goldPerMinute: grossGoldPerMinute,
+ T: atomT,
+ discountRate,
+ discountFactor: atomDiscountFactor,
+ finalScore: atomScore,
+ };
+
+ const hydrogenBreakdown: NukeScoreBreakdown = {
+ rawEnemyValue: hydrogenRawEnemyValue,
+ friendlyValue: hydrogenFriendlyValue,
+ enemyStructureCount: hydrogenEnemyCount,
+ friendlyStructureCount: hydrogenFriendlyCount,
+ warScoreSigmoid: primarySigmoid,
+ rawWarScore: primaryWarScore,
+ strongestEnemyBonus,
+ isStrongestEnemy: primaryIsStrongest,
+ numerator: hydrogenNumerator,
+ bombCost: hydrogenBombCost,
+ samLevels,
+ siloCost: 0,
+ totalCost: hydrogenTotalCost,
+ goldPerMinute: grossGoldPerMinute,
+ T: hydrogenT,
+ discountRate,
+ discountFactor: hydrogenDiscountFactor,
+ finalScore: hydrogenScore,
+ };
+
+ return { atomScore, hydrogenScore, atomBreakdown, hydrogenBreakdown };
}
/**
@@ -384,7 +534,7 @@ export class AINukeHandler {
const scale = this.params.nukeWarScoreSigmoidScale ?? 1 / 50;
if (scale === 0 || !this._warScoreProvider) return 1;
const ws = this._warScoreProvider(targetId);
- return AINukeHandler.sigmoid(scale * (ws - 4));
+ return AINukeHandler.sigmoid(4 - ws * scale);
}
/**
@@ -634,8 +784,10 @@ export class AINukeHandler {
resetScores(): void {
this._bestAtomScore = 0;
this._bestAtomTile = null;
+ this._bestAtomBreakdown = null;
this._bestHydrogenScore = 0;
this._bestHydrogenTile = null;
+ this._bestHydrogenBreakdown = null;
}
/**
@@ -648,6 +800,127 @@ export class AINukeHandler {
return this.calculateNukeScore(tile, bombType);
}
+ /**
+ * Compute a detailed score breakdown for a tile and bomb type.
+ * Used for the construction debug overlay.
+ */
+ scoreBreakdownForTile(
+ tile: TileRef,
+ bombType: UnitType,
+ ): NukeScoreBreakdown | null {
+ this.player = this.mg.player(this.playerId);
+ if (!this.player || !this.player.isAlive()) return null;
+ return this.calculateNukeScoreBreakdown(tile, bombType);
+ }
+
+ /**
+ * Internal: compute nuke score with full breakdown of all components.
+ */
+ private calculateNukeScoreBreakdown(
+ tile: TileRef,
+ bombType: UnitType,
+ ): NukeScoreBreakdown {
+ const magnitude: NukeMagnitude = this.mg.config().nukeMagnitudes(bombType);
+ const innerRange = magnitude.inner;
+
+ const friendlyDamageWeight = this.params.nukeFriendlyDamageWeight ?? 1.0;
+ const strongestEnemyId = this._cachedStrongestEnemyId;
+
+ let rawEnemyValue = 0;
+ let enemyValue = 0;
+ let friendlyValue = 0;
+ let enemyStructureCount = 0;
+ let friendlyStructureCount = 0;
+ let primaryTargetId: PlayerID | null = null;
+ let primaryTargetSigmoid = 1;
+ let primaryTargetWarScore = 0;
+ let isStrongestEnemy = false;
+ const strongestEnemyBonus = 1000;
+
+ const nearby = this.mg.nearbyUnits(
+ tile,
+ innerRange,
+ AINukeHandler.ALL_STRUCTURE_TYPES,
+ );
+
+ for (const { unit: structure } of nearby) {
+ const owner = structure.owner();
+ if (owner.type() !== PlayerType.Human && owner.type() !== PlayerType.AI) {
+ continue;
+ }
+
+ if (owner.id() === this.playerId) {
+ friendlyValue += this.getStructureValue(structure);
+ friendlyStructureCount++;
+ continue;
+ }
+
+ if (this.player!.isAtWarWith(owner)) {
+ const bonus = owner.id() === strongestEnemyId ? strongestEnemyBonus : 0;
+ const sig = this.getCachedSigmoid(owner.id());
+ const val = this.getStructureValue(structure);
+ rawEnemyValue += val + bonus;
+ enemyValue += (val + bonus) * sig;
+ enemyStructureCount++;
+ // Track the first/dominant enemy target
+ if (primaryTargetId === null) {
+ primaryTargetId = owner.id();
+ primaryTargetSigmoid = sig;
+ isStrongestEnemy = owner.id() === strongestEnemyId;
+ // Recover raw war score
+ const scale = this.params.nukeWarScoreSigmoidScale ?? 1 / 50;
+ if (scale !== 0 && this._warScoreProvider) {
+ primaryTargetWarScore = this._warScoreProvider(owner.id());
+ }
+ }
+ } else {
+ friendlyValue += this.getStructureValue(structure);
+ friendlyStructureCount++;
+ }
+ }
+
+ const numerator = enemyValue - friendlyDamageWeight * friendlyValue;
+
+ const bombCost = this.getCachedUnitCost(bombType, this.player!);
+ const atomBombCost = this.getCachedUnitCost(
+ UnitType.AtomBomb,
+ this.player!,
+ );
+ const samLevels = this.calculateSAMPenalty(tile);
+ const siloCapacity = this._cachedSiloCapacity;
+ const siloCost =
+ this.computeSiloCost(samLevels, siloCapacity) /
+ AINukeHandler.EXPECTED_NUKES_PER_SILO;
+ const totalCost = bombCost + samLevels * atomBombCost + siloCost;
+
+ const goldPerMinute = this.player!.estimatedGoldIncomePerMinute();
+ const discountRate = this.params.discountFactor ?? 0.1;
+ const T = goldPerMinute > 0 ? totalCost / goldPerMinute : Infinity;
+ const discountFactor = Math.pow(1 + discountRate, T);
+ const finalScore = numerator / discountFactor;
+
+ return {
+ rawEnemyValue,
+ friendlyValue,
+ enemyStructureCount,
+ friendlyStructureCount,
+ warScoreSigmoid: primaryTargetSigmoid,
+ rawWarScore: primaryTargetWarScore,
+ strongestEnemyBonus,
+ isStrongestEnemy,
+ numerator,
+ bombCost,
+ samLevels,
+ siloCost,
+ totalCost,
+ goldPerMinute,
+ T,
+ discountRate,
+ discountFactor,
+ finalScore,
+ };
+ }
+
/**
* How many bomb launches are needed for a strike at the given tile:
* 1 (main bomb) + total SAM levels in range.
diff --git a/src/core/ai/AIPlayerExecution.ts b/src/core/ai/AIPlayerExecution.ts
index 0cce22d34..e16815525 100644
--- a/src/core/ai/AIPlayerExecution.ts
+++ b/src/core/ai/AIPlayerExecution.ts
@@ -20,7 +20,6 @@ import { AIBehaviorParams } from "./AIBehaviorParams";
import { AIBotAttackHandler } from "./AIBotAttackHandler";
import { AIConstructionHandler } from "./AIConstructionHandler";
import { AIDiplomacyHandler } from "./AIDiplomacyHandler";
-import { AINukeEvaluator } from "./AINukeEvaluator";
import { AINukeHandler } from "./AINukeHandler";
import { AISpawnHandler } from "./AISpawnHandler";
import { AITerraNulliusHandler } from "./AITerraNulliusHandler";
@@ -91,7 +90,7 @@ export class AIPlayerExecution implements Execution {
private attackHandler: AIAttackHandler | null = null;
private constructionHandler: AIConstructionHandler | null = null;
private diplomacyHandler: AIDiplomacyHandler | null = null;
- private nukeEvaluator: AINukeEvaluator | null = null;
+
private nukeHandler: AINukeHandler | null = null;
private unitHandler: AIUnitHandler | null = null;
private initialInvestmentSet = false;
@@ -171,7 +170,6 @@ export class AIPlayerExecution implements Execution {
this.nation.playerInfo.id,
this.random,
this.params,
- AINukeEvaluator.getInstance(this.gameID, mg),
);
this.diplomacyHandler = new AIDiplomacyHandler(
mg,
@@ -179,7 +177,6 @@ export class AIPlayerExecution implements Execution {
this.random,
this.params,
);
- this.nukeEvaluator = AINukeEvaluator.getInstance(this.gameID, mg);
this.nukeHandler = new AINukeHandler(
mg,
this.nation.playerInfo.id,
@@ -237,11 +234,6 @@ export class AIPlayerExecution implements Execution {
const sliderPeriod = 100;
const constructionRescorePeriod = 100;
- // Update shared nuke target evaluation
- performance.mark("ai-nukeEval");
- this.nukeEvaluator?.tick(this.random, ticks);
- performance.measure("nukeEval", "ai-nukeEval");
-
// Update per-player nuke target evaluation (must run before tickNukeSequence
// so scores are fresh when the nuke sequence reads them)
performance.mark("ai-nukeHandler");
@@ -417,17 +409,18 @@ export class AIPlayerExecution implements Execution {
// Periodically re-evaluate the entire nuke plan: score, SAMs,
// redundancy, construction comparison, and retargeting.
- // Only run during pre-launch phases so we don't detect our own
- // in-flight SAM-suppression nukes once launching has started.
+ // Only run during pre-launch phases so we don't reset SAM progress
+ // or detect our own in-flight SAM-suppression nukes.
const isPreLaunch =
state.phase === "waitForFunds" || state.phase === "buildSilo";
if (
+ isPreLaunch &&
this.shouldRunPeriodic(
ticks,
AIPlayerExecution.NUKE_REDUNDANCY_CHECK_INTERVAL,
)
) {
- if (isPreLaunch && this.isNukeAlreadyInbound(state)) {
+ if (this.isNukeAlreadyInbound(state)) {
this.resetNukeSequence();
return;
}
@@ -443,61 +436,27 @@ export class AIPlayerExecution implements Execution {
// Fully refresh SAM list from scratch: picks up new SAMs, removes
// destroyed ones, and updates stack counts on surviving ones.
const freshSAMs = this.nukeHandler.getSAMsInRange(state.targetTile);
- const oldTotalLevels = state.samTargets.reduce(
- (sum, s) => sum + s.levelsRemaining,
- 0,
- );
state.samTargets = freshSAMs.map((s) => ({
sam: s,
levelsRemaining: s.stackCount(),
}));
- const newTotalLevels = state.samTargets.reduce(
- (sum, s) => sum + s.levelsRemaining,
- 0,
- );
- if (newTotalLevels > 5 || oldTotalLevels > 5) {
- const tileX = this.mg.x(state.targetTile);
- const tileY = this.mg.y(state.targetTile);
- console.warn(
- `[NUKE-DIAG] REFRESH SAMs: player=${this.player.id()} ` +
- `phase=${state.phase} target=(${tileX},${tileY}) ` +
- `oldTotalLevels=${oldTotalLevels} newSAMs=${freshSAMs.length} ` +
- `newTotalLevels=${newTotalLevels} ` +
- `SAM details=[${freshSAMs
- .map((s) => {
- const ox = this.mg.x(s.tile());
- const oy = this.mg.y(s.tile());
- const dist = Math.sqrt(
- this.mg.euclideanDistSquared(state.targetTile, s.tile()),
- );
- const ownerRange = this.nukeHandler!.getEffectiveSAMRange(
- s.owner(),
- );
- return `{id=${s.id()} pos=(${ox},${oy}) owner=${s.owner().id()} stack=${s.stackCount()} dist=${dist.toFixed(1)} ownerRange=${ownerRange.toFixed(1)} isActive=${s.isActive()}}`;
- })
- .join(", ")}]`,
- );
- }
- // During pre-launch phases, perform additional checks
- if (state.phase === "waitForFunds" || state.phase === "buildSilo") {
- // Abort if construction is now more valuable than this nuke
- const profileMultiplier = this.params.nukeScoreMultiplier ?? 1;
- const adjustedScore =
- currentScore *
- profileMultiplier *
- AIPlayerExecution.NUKE_SCORE_INTERNAL_MULTIPLIER;
- const constructionScore =
- this.constructionHandler?.bestConstructionScore() ?? 0;
- const unitScore = this.unitHandler?.bestUnitScore() ?? 0;
- if (adjustedScore <= constructionScore || adjustedScore <= unitScore) {
- this.resetNukeSequence();
- return;
- }
-
- // Check if a better target has appeared
- this.maybeRetargetNukeSequence(state, currentScore);
+ // Abort if construction is now more valuable than this nuke
+ const profileMultiplier = this.params.nukeScoreMultiplier ?? 1;
+ const adjustedScore =
+ currentScore *
+ profileMultiplier *
+ AIPlayerExecution.NUKE_SCORE_INTERNAL_MULTIPLIER;
+ const constructionScore =
+ this.constructionHandler?.bestConstructionScore() ?? 0;
+ const unitScore = this.unitHandler?.bestUnitScore() ?? 0;
+ if (adjustedScore <= constructionScore || adjustedScore <= unitScore) {
+ this.resetNukeSequence();
+ return;
}
+
+ // Check if a better target has appeared
+ this.maybeRetargetNukeSequence(state, currentScore);
}
switch (state.phase) {
@@ -780,6 +739,13 @@ export class AIPlayerExecution implements Execution {
(s) => s.levelsRemaining === s.sam.stackCount(),
);
if (isFirstLaunch) {
+ // Refresh SAM list one final time so we launch with up-to-date data
+ const freshSAMs = this.nukeHandler.getSAMsInRange(state.targetTile);
+ state.samTargets = freshSAMs.map((s) => ({
+ sam: s,
+ levelsRemaining: s.stackCount(),
+ }));
+
// Abort if another player's nuke is already heading to this target
if (this.isNukeAlreadyInbound(state)) {
this.resetNukeSequence();
@@ -859,25 +825,6 @@ export class AIPlayerExecution implements Execution {
}
// Launch atom bomb at this SAM's tile
- const totalLevelsBeforeLaunch = state.samTargets.reduce(
- (sum, s) => sum + s.levelsRemaining,
- 0,
- );
- if (totalLevelsBeforeLaunch > 5) {
- const tileX = this.mg.x(state.targetTile);
- const tileY = this.mg.y(state.targetTile);
- const samX = this.mg.x(nextSam.sam.tile());
- const samY = this.mg.y(nextSam.sam.tile());
- console.warn(
- `[NUKE-DIAG] LAUNCH SAM-bomb: player=${this.player.id()} ` +
- `target=(${tileX},${tileY}) samTarget=(${samX},${samY}) ` +
- `samId=${nextSam.sam.id()} samOwner=${nextSam.sam.owner().id()} ` +
- `samStack=${nextSam.sam.stackCount()} samActive=${nextSam.sam.isActive()} ` +
- `levelsRemaining=${nextSam.levelsRemaining} ` +
- `totalLevelsRemaining=${totalLevelsBeforeLaunch} ` +
- `allSamTargets=[${state.samTargets.map((s) => `{id=${s.sam.id()} stack=${s.sam.stackCount()} remaining=${s.levelsRemaining} active=${s.sam.isActive()}}`).join(", ")}]`,
- );
- }
this.mg.addExecution(
new ConstructionExecution(
this.player,
@@ -1206,6 +1153,8 @@ export class AIPlayerExecution implements Execution {
bestHydrogenScore: hydrogenTarget?.score ?? 0,
bestHydrogenTargetPlayerName: hydrogenTargetPlayer?.displayName() ?? "—",
adjustedBestNukeScore,
+ atomBreakdown: this.nukeHandler.bestAtomBreakdown(),
+ hydrogenBreakdown: this.nukeHandler.bestHydrogenBreakdown(),
};
// Spending winner
diff --git a/src/core/ai/ConstructionDebugData.ts b/src/core/ai/ConstructionDebugData.ts
index eb9a2faa9..8630dd80b 100644
--- a/src/core/ai/ConstructionDebugData.ts
+++ b/src/core/ai/ConstructionDebugData.ts
@@ -30,6 +30,8 @@ export interface NukeSequenceDebugInfo {
currentScore: number;
}
+import { NukeScoreBreakdown } from "./AINukeHandler";
+
/**
* Nuke scoring snapshot (best atom / hydrogen targets).
*/
@@ -40,6 +42,10 @@ export interface NukeScoreDebugInfo {
bestHydrogenTargetPlayerName: string;
/** The adjusted nuke score used for comparison against construction/unit scores. */
adjustedBestNukeScore: number;
+ /** Detailed breakdown for the best atom target (null if none). */
+ atomBreakdown: NukeScoreBreakdown | null;
+ /** Detailed breakdown for the best hydrogen target (null if none). */
+ hydrogenBreakdown: NukeScoreBreakdown | null;
}
/**
diff --git a/src/core/ai/index.ts b/src/core/ai/index.ts
index 4b3bbc7c1..71d8fa12e 100644
--- a/src/core/ai/index.ts
+++ b/src/core/ai/index.ts
@@ -7,8 +7,11 @@ export {
export { AIBotAttackHandler } from "./AIBotAttackHandler";
export { AIConstructionHandler } from "./AIConstructionHandler";
export { AIDiplomacyHandler } from "./AIDiplomacyHandler";
-export { AINukeEvaluator, NukeBestTarget } from "./AINukeEvaluator";
-export { AINukeHandler, NukeHandlerBestTarget } from "./AINukeHandler";
+export {
+ AINukeHandler,
+ NukeHandlerBestTarget,
+ NukeScoreBreakdown,
+} from "./AINukeHandler";
export { AIPlayerExecution } from "./AIPlayerExecution";
export { AISpawnHandler } from "./AISpawnHandler";
export { AITerraNulliusHandler } from "./AITerraNulliusHandler";
From 0b358bde7290d2f9c75d54d2be3d9e074c7ac871 Mon Sep 17 00:00:00 2001
From: 1brucben <1benjbruce@gmail.com>
Date: Mon, 23 Feb 2026 05:55:17 +0100
Subject: [PATCH 2/2] fix: adjust warship coastal threat weight and refine gold
income calculations
---
resources/ai-profiles.json | 2 +-
src/core/ai/AIPlayerExecution.ts | 2 +-
src/core/game/PlayerImpl.ts | 11 ++++++-----
3 files changed, 8 insertions(+), 7 deletions(-)
diff --git a/resources/ai-profiles.json b/resources/ai-profiles.json
index 318fe5e0d..7705c4409 100644
--- a/resources/ai-profiles.json
+++ b/resources/ai-profiles.json
@@ -85,7 +85,7 @@
"discountFactor": 0.1,
"weightWarship": 1,
"warshipTradeIncomeWeight": 0,
- "warshipCoastalThreatWeight": 8e4,
+ "warshipCoastalThreatWeight": 10e4,
"firstPortIncomeShare": 0.45
}
},
diff --git a/src/core/ai/AIPlayerExecution.ts b/src/core/ai/AIPlayerExecution.ts
index e16815525..de91914bd 100644
--- a/src/core/ai/AIPlayerExecution.ts
+++ b/src/core/ai/AIPlayerExecution.ts
@@ -103,7 +103,7 @@ export class AIPlayerExecution implements Execution {
private static readonly NUKE_REDUNDANCY_CHECK_INTERVAL = 10;
/** Internal multiplier applied to nuke scores when comparing against construction scores. */
- private static readonly NUKE_SCORE_INTERNAL_MULTIPLIER = 7e-1;
+ private static readonly NUKE_SCORE_INTERNAL_MULTIPLIER = 6e-1;
// Wall-clock perf logging (shared across all AI instances)
private static readonly PERF_LOG_INTERVAL_MS = 10_000;
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index bdeceef73..b3854b804 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -1662,9 +1662,9 @@ export class PlayerImpl implements Player {
return (
this.troops() +
0.9 * this.attackingTroops() +
- Number(this._gold) / 10 +
- this._militaryAssetValue / 10 +
- this.estimatedGoldIncomePerMinute()
+ Number(this._gold) / 9 +
+ this._militaryAssetValue / 9 +
+ 1.1 * this.estimatedGoldIncomePerMinute()
);
}
@@ -1794,12 +1794,13 @@ export class PlayerImpl implements Player {
return this._estimatedGoldIncomePerMinute;
}
updateProductivity(): void {
- const alpha = 0.00035;
+ const alpha = 0.00015;
const beta = 0.5;
const maxPop = this.mg.config().maxPopulation(this);
const workers = this.workers();
- const rate = (this._investmentRate * workers) / maxPop;
+ const rate =
+ (this._investmentRate * Math.pow(workers, 0.65)) / Math.pow(maxPop, 0.65);
const growth = alpha * Math.pow(rate, beta);
if (!Number.isFinite(growth) || growth < 0) {