diff --git a/src/client/graphics/layers/StructureDrawingUtils.ts b/src/client/graphics/layers/StructureDrawingUtils.ts index ec125b1c19..9fe6a940b1 100644 --- a/src/client/graphics/layers/StructureDrawingUtils.ts +++ b/src/client/graphics/layers/StructureDrawingUtils.ts @@ -454,6 +454,7 @@ export class SpriteFactory { stage: PIXI.Container, pos: { x: number; y: number }, level?: number, + targetingAlly: boolean = false, ): PIXI.Container | null { if (stage === undefined) throw new Error("Not initialized"); const parentContainer = new PIXI.Container(); @@ -478,10 +479,18 @@ export class SpriteFactory { default: return null; } + // Add warning colors (red/orange) when targeting an ally to indicate alliance will break + const isNuke = type === UnitType.AtomBomb || type === UnitType.HydrogenBomb; + const fillColor = targetingAlly && isNuke ? 0xff6b35 : 0xffffff; + const fillAlpha = targetingAlly && isNuke ? 0.35 : 0.2; + const strokeColor = targetingAlly && isNuke ? 0xff4444 : 0xffffff; + const strokeAlpha = targetingAlly && isNuke ? 0.8 : 0.5; + const strokeWidth = targetingAlly && isNuke ? 2 : 1; + circle .circle(0, 0, radius) - .fill({ color: 0xffffff, alpha: 0.2 }) - .stroke({ width: 1, color: 0xffffff, alpha: 0.5 }); + .fill({ color: fillColor, alpha: fillAlpha }) + .stroke({ width: strokeWidth, color: strokeColor, alpha: strokeAlpha }); parentContainer.addChild(circle); parentContainer.position.set(pos.x, pos.y); parentContainer.scale.set(this.transformHandler.scale); diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 75e807c232..0c896f7176 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -4,6 +4,7 @@ import { OutlineFilter } from "pixi-filters"; import * as PIXI from "pixi.js"; import { Theme } from "../../../core/configuration/Config"; import { EventBus } from "../../../core/EventBus"; +import { wouldNukeBreakAlliance } from "../../../core/execution/Util"; import { BuildableUnit, Cell, @@ -65,6 +66,7 @@ export class StructureIconsLayer implements Layer { priceBox: { height: number; y: number; paddingX: number; minWidth: number }; range: PIXI.Container | null; rangeLevel?: number; + targetingAlly?: boolean; buildableUnit: BuildableUnit; } | null = null; private pixicanvas: HTMLCanvasElement; @@ -258,6 +260,29 @@ export class StructureIconsLayer implements Layer { tileRef = this.game.ref(tile.x, tile.y); } + // Check if targeting an ally (for nuke warning visual) + // Uses shared logic with NukeExecution.maybeBreakAlliances() + let targetingAlly = false; + const myPlayer = this.game.myPlayer(); + const nukeType = this.ghostUnit.buildableUnit.type; + if ( + tileRef && + myPlayer && + (nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb) + ) { + // Only check if player has allies + const allies = myPlayer.allies(); + if (allies.length > 0) { + targetingAlly = wouldNukeBreakAlliance({ + gm: this.game, + targetTile: tileRef, + magnitude: this.game.config().nukeMagnitudes(nukeType), + allySmallIds: new Set(allies.map((a) => a.smallID())), + threshold: this.game.config().nukeAllianceBreakThreshold(), + }); + } + } + this.game ?.myPlayer() ?.actions(tileRef) @@ -292,7 +317,7 @@ export class StructureIconsLayer implements Layer { this.updateGhostPrice(unit.cost ?? 0, showPrice); const targetLevel = this.resolveGhostRangeLevel(unit); - this.updateGhostRange(targetLevel); + this.updateGhostRange(targetLevel, targetingAlly); if (unit.canUpgrade) { this.potentialUpgrade = this.renders.find( @@ -470,18 +495,23 @@ export class StructureIconsLayer implements Layer { return 1; } - private updateGhostRange(level?: number) { + private updateGhostRange(level?: number, targetingAlly: boolean = false) { if (!this.ghostUnit) { return; } - if (this.ghostUnit.range && this.ghostUnit.rangeLevel === level) { + if ( + this.ghostUnit.range && + this.ghostUnit.rangeLevel === level && + this.ghostUnit.targetingAlly === targetingAlly + ) { return; } this.ghostUnit.range?.destroy(); this.ghostUnit.range = null; this.ghostUnit.rangeLevel = level; + this.ghostUnit.targetingAlly = targetingAlly; const position = this.ghostUnit.container.position; const range = this.factory.createRange( @@ -489,6 +519,7 @@ export class StructureIconsLayer implements Layer { this.ghostStage, { x: position.x, y: position.y }, level, + targetingAlly, ); if (range) { this.ghostUnit.range = range; diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 0edfb481fd..17cf878ea7 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -13,6 +13,7 @@ import { TileRef } from "../game/GameMap"; import { ParabolaPathFinder } from "../pathfinding/PathFinding"; import { PseudoRandom } from "../PseudoRandom"; import { NukeType } from "../StatsSchemas"; +import { computeNukeBlastCounts } from "./Util"; const SPRITE_RADIUS = 16; @@ -45,24 +46,6 @@ export class NukeExecution implements Execution { return this.mg.owner(this.dst); } - private tilesInRange(): Map { - if (this.nuke === null) { - throw new Error("Not initialized"); - } - const tilesInRange = new Map(); - const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type()); - const inner2 = magnitude.inner * magnitude.inner; - this.mg.circleSearch( - this.dst, - magnitude.outer, - (t: TileRef, d2: number) => { - tilesInRange.set(t, d2 <= inner2 ? 1 : 0.5); - return true; - }, - ); - return tilesInRange; - } - private tilesToDestroy(): Set { if (this.tilesToDestroyCache !== undefined) { return this.tilesToDestroyCache; @@ -82,37 +65,44 @@ export class NukeExecution implements Execution { } /** - * Break alliances based on all tiles in range. - * Tiles are weighted roughly based on their chance of being destroyed. + * Break alliances with players significantly affected by the nuke strike. + * Uses weighted tile counting (inner=1, outer=0.5). */ - private maybeBreakAlliances(inRange: Map) { + private maybeBreakAlliances() { if (this.nuke === null) { throw new Error("Not initialized"); } - const attacked = new Map(); - for (const [tile, weight] of inRange.entries()) { - const owner = this.mg.owner(tile); - if (owner.isPlayer()) { - const prev = attacked.get(owner) ?? 0; - attacked.set(owner, prev + weight); - } + if (this.nuke.type() === UnitType.MIRVWarhead) { + // MIRV warheads shouldn't break alliances + return; } + const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type()); const threshold = this.mg.config().nukeAllianceBreakThreshold(); - for (const [attackedPlayer, totalWeight] of attacked) { - if ( - totalWeight > threshold && - this.nuke.type() !== UnitType.MIRVWarhead - ) { + + // Use shared utility to compute weighted tile counts per player + const blastCounts = computeNukeBlastCounts({ + gm: this.mg, + targetTile: this.dst, + magnitude, + }); + + for (const [playerSmallId, totalWeight] of blastCounts) { + if (totalWeight > threshold) { + const attackedPlayer = this.mg.playerBySmallID(playerSmallId); + if (!attackedPlayer.isPlayer()) { + continue; + } + // Resolves exploit of alliance breaking in which a pending alliance request // was accepted in the middle of a missile attack. const allianceRequest = attackedPlayer .incomingAllianceRequests() .find((ar) => ar.requestor() === this.player); if (allianceRequest) { - allianceRequest?.reject(); + allianceRequest.reject(); } - // Mirv warheads shouldn't break alliances + const alliance = this.player.allianceWith(attackedPlayer); if (alliance !== null) { this.player.breakAlliance(alliance); @@ -145,7 +135,7 @@ export class NukeExecution implements Execution { trajectory: this.getTrajectory(this.dst), }); if (this.nuke.type() !== UnitType.MIRVWarhead) { - this.maybeBreakAlliances(this.tilesInRange()); + this.maybeBreakAlliances(); } if (this.mg.hasOwner(this.dst)) { const target = this.mg.owner(this.dst); diff --git a/src/core/execution/Util.ts b/src/core/execution/Util.ts index 53a69f8c84..32052c1374 100644 --- a/src/core/execution/Util.ts +++ b/src/core/execution/Util.ts @@ -1,6 +1,77 @@ +import { NukeMagnitude } from "../configuration/Config"; import { Game, Player } from "../game/Game"; import { euclDistFN, GameMap, TileRef } from "../game/GameMap"; +export interface NukeBlastParams { + gm: GameMap; + targetTile: TileRef; + magnitude: NukeMagnitude; +} + +/** + * Counts how many tiles each player has in the nuke's blast zone. + * + * returns Map of player ID and weighted tile count + */ +export function computeNukeBlastCounts( + params: NukeBlastParams, +): Map { + const { gm, targetTile, magnitude } = params; + + const inner2 = magnitude.inner * magnitude.inner; + const counts = new Map(); + + gm.circleSearch(targetTile, magnitude.outer, (tile: TileRef, d2: number) => { + const ownerSmallId = gm.ownerID(tile); + if (ownerSmallId > 0) { + const weight = d2 <= inner2 ? 1 : 0.5; + const prev = counts.get(ownerSmallId) ?? 0; + counts.set(ownerSmallId, prev + weight); + } + return true; + }); + + return counts; +} + +export interface NukeAllianceCheckParams extends NukeBlastParams { + allySmallIds: Set; + threshold: number; +} + +// Checks if nuking this tile would break an alliance. +export function wouldNukeBreakAlliance( + params: NukeAllianceCheckParams, +): boolean { + const { gm, targetTile, magnitude, allySmallIds, threshold } = params; + + if (allySmallIds.size === 0) { + return false; + } + + const inner2 = magnitude.inner * magnitude.inner; + const allyTileCounts = new Map(); + + let result = false; + + gm.circleSearch(targetTile, magnitude.outer, (tile: TileRef, d2: number) => { + const ownerSmallId = gm.ownerID(tile); + if (ownerSmallId > 0 && allySmallIds.has(ownerSmallId)) { + const weight = d2 <= inner2 ? 1 : 0.5; + const newCount = (allyTileCounts.get(ownerSmallId) ?? 0) + weight; + allyTileCounts.set(ownerSmallId, newCount); + + if (newCount > threshold) { + result = true; + return false; // Found one! Stop searching. + } + } + return true; + }); + + return result; +} + export function getSpawnTiles(gm: GameMap, tile: TileRef): TileRef[] { return Array.from(gm.bfs(tile, euclDistFN(tile, 4, true))).filter( (t) => !gm.hasOwner(t) && gm.isLand(t),