Enhance nuke alliance breaking logic to account for allied structures in blast radius 💣 (#2887)

## Description:

Doesn't need a description :D


https://github.com/user-attachments/assets/8de576fd-050b-4b35-8526-e4c88d1a9f25


https://github.com/user-attachments/assets/c99147a1-efdf-426b-96d1-e996e01f89aa

## 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:

FloPinguin
This commit is contained in:
FloPinguin
2026-01-13 12:30:25 -08:00
committed by GitHub
parent 0e3b9cca29
commit 35b7213c5c
8 changed files with 204 additions and 37 deletions
+40 -20
View File
@@ -4,6 +4,7 @@ import {
isStructureType,
MessageType,
Player,
StructureTypes,
TerraNullius,
TrajectoryTile,
Unit,
@@ -72,7 +73,7 @@ export class NukeExecution implements Execution {
/**
* Break alliances with players significantly affected by the nuke strike.
* Uses weighted tile counting (inner=1, outer=0.5).
* Uses weighted tile counting (inner=1, outer=0.5) OR if any allied structure would be destroyed.
*/
private maybeBreakAlliances() {
if (this.nuke === null) {
@@ -93,29 +94,48 @@ export class NukeExecution implements Execution {
magnitude,
});
// Collect all players that should have alliance broken:
// either exceeds tile threshold OR has a structure in blast radius
const playersToBreakAllianceWith = new Set<number>();
for (const [playerSmallId, totalWeight] of blastCounts) {
if (totalWeight > threshold) {
const attackedPlayer = this.mg.playerBySmallID(playerSmallId);
if (!attackedPlayer.isPlayer()) {
continue;
}
playersToBreakAllianceWith.add(playerSmallId);
}
}
// 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();
}
// Also check if any allied structures would be destroyed
this.mg
.nearbyUnits(this.dst, magnitude.outer, [...StructureTypes])
.filter(
({ unit }) =>
unit.owner().isPlayer() && this.player.isAlliedWith(unit.owner()),
)
.forEach(({ unit }) =>
playersToBreakAllianceWith.add(unit.owner().smallID()),
);
const alliance = this.player.allianceWith(attackedPlayer);
if (alliance !== null) {
this.player.breakAlliance(alliance);
}
if (attackedPlayer !== this.player) {
attackedPlayer.updateRelation(this.player, -100);
}
for (const playerSmallId of playersToBreakAllianceWith) {
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();
}
const alliance = this.player.allianceWith(attackedPlayer);
if (alliance !== null) {
this.player.breakAlliance(alliance);
}
if (attackedPlayer !== this.player) {
attackedPlayer.updateRelation(this.player, -100);
}
}
}
+36 -15
View File
@@ -1,6 +1,7 @@
import { NukeMagnitude } from "../configuration/Config";
import { Game, Player } from "../game/Game";
import { Game, Player, StructureTypes } from "../game/Game";
import { euclDistFN, GameMap, TileRef } from "../game/GameMap";
import { GameView } from "../game/GameView";
export interface NukeBlastParams {
gm: GameMap;
@@ -34,40 +35,60 @@ export function computeNukeBlastCounts(
return counts;
}
export interface NukeAllianceCheckParams extends NukeBlastParams {
export interface NukeAllianceCheckParams {
game: GameView;
targetTile: TileRef;
magnitude: NukeMagnitude;
allySmallIds: Set<number>;
threshold: number;
}
// Checks if nuking this tile would break an alliance.
// Returns true if either:
// 1. The weighted tile count for any ally exceeds the threshold
// 2. Any allied structure would be destroyed
export function wouldNukeBreakAlliance(
params: NukeAllianceCheckParams,
): boolean {
const { gm, targetTile, magnitude, allySmallIds, threshold } = params;
const { game, targetTile, magnitude, allySmallIds, threshold } = params;
if (allySmallIds.size === 0) {
return false;
}
// Check if any allied structure would be destroyed
const wouldDestroyAlliedStructure = game.anyUnitNearby(
targetTile,
magnitude.outer,
StructureTypes,
(unit) =>
unit.owner().isPlayer() && allySmallIds.has(unit.owner().smallID()),
);
if (wouldDestroyAlliedStructure) return true;
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);
game.circleSearch(
targetTile,
magnitude.outer,
(tile: TileRef, d2: number) => {
const ownerSmallId = game.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.
if (newCount > threshold) {
result = true;
return false; // Found one! Stop searching.
}
}
}
return true;
});
return true;
},
);
return result;
}