From 09a1cf885fd2fc05ffc1756aa79de560fa07605d Mon Sep 17 00:00:00 2001
From: Abdallah Bahrawi <140177728+abdallahbahrawi1@users.noreply.github.com>
Date: Tue, 30 Dec 2025 19:55:15 +0200
Subject: [PATCH] Add red warning circle when nuke would break alliance (#2728)
## Description:
When placing a nuke (Atom Bomb or Hydrogen Bomb), the range circle now
turns red to warn players when the attack would break an alliance.
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
## Please put your Discord username so you can be contacted if a bug or
regression is found:
abodcraft1
---------
Co-authored-by: iamlewis
---
.../graphics/layers/StructureDrawingUtils.ts | 13 +++-
.../graphics/layers/StructureIconsLayer.ts | 37 +++++++++-
src/core/execution/NukeExecution.ts | 62 +++++++---------
src/core/execution/Util.ts | 71 +++++++++++++++++++
4 files changed, 142 insertions(+), 41 deletions(-)
diff --git a/src/client/graphics/layers/StructureDrawingUtils.ts b/src/client/graphics/layers/StructureDrawingUtils.ts
index ec125b1c1..9fe6a940b 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 75e807c23..0c896f717 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 0edfb481f..17cf878ea 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 53a69f8c8..32052c137 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),