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.

<img width="456" height="333" alt="Screenshot 2025-12-28 211927"
src="https://github.com/user-attachments/assets/dfe6f874-3f8b-4662-8877-0af30aa20139"
/>


## 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 <lewismmmm@gmail.com>
This commit is contained in:
Abdallah Bahrawi
2025-12-30 19:55:15 +02:00
committed by GitHub
parent 4f3d9df46a
commit 09a1cf885f
4 changed files with 142 additions and 41 deletions
+26 -36
View File
@@ -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<TileRef, number> {
if (this.nuke === null) {
throw new Error("Not initialized");
}
const tilesInRange = new Map<TileRef, number>();
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<TileRef> {
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<TileRef, number>) {
private maybeBreakAlliances() {
if (this.nuke === null) {
throw new Error("Not initialized");
}
const attacked = new Map<Player, number>();
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);
+71
View File
@@ -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<number, number> {
const { gm, targetTile, magnitude } = params;
const inner2 = magnitude.inner * magnitude.inner;
const counts = new Map<number, number>();
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<number>;
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<number, number>();
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),