diff --git a/resources/ai-profiles.json b/resources/ai-profiles.json index 318fe5e0..7705c440 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/client/graphics/layers/ConstructionDebugOverlay.ts b/src/client/graphics/layers/ConstructionDebugOverlay.ts index 6b636582..0aaef769 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 54523e19..e5b34f84 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 bd5f7938..00000000 --- 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 1c0588ed..5aa308b9 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 0cce22d3..de91914b 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; @@ -104,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; @@ -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 eb9a2faa..8630dd80 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 4b3bbc7c..71d8fa12 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"; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index bdeceef7..b3854b80 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) {