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 21:30:25 +01:00
committed by GitHub
parent 0e3b9cca29
commit 35b7213c5c
8 changed files with 204 additions and 37 deletions
@@ -274,7 +274,7 @@ export class StructureIconsLayer implements Layer {
const allies = myPlayer.allies();
if (allies.length > 0) {
targetingAlly = wouldNukeBreakAlliance({
gm: this.game,
game: this.game,
targetTile: tileRef,
magnitude: this.game.config().nukeMagnitudes(nukeType),
allySmallIds: new Set(allies.map((a) => a.smallID())),
+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;
}
+10
View File
@@ -254,6 +254,8 @@ const _structureTypes: ReadonlySet<UnitType> = new Set([
UnitType.Factory,
]);
export const StructureTypes: readonly UnitType[] = [..._structureTypes];
export function isStructureType(type: UnitType): boolean {
return _structureTypes.has(type);
}
@@ -762,6 +764,14 @@ export interface Game extends GameMap {
playerId?: PlayerID,
includeUnderConstruction?: boolean,
): boolean;
anyUnitNearby(
tile: TileRef,
searchRange: number,
types: readonly UnitType[],
predicate: (unit: Unit) => boolean,
playerId?: PlayerID,
includeUnderConstruction?: boolean,
): boolean;
nearbyUnits(
tile: TileRef,
searchRange: number,
+18
View File
@@ -838,6 +838,24 @@ export class GameImpl implements Game {
);
}
anyUnitNearby(
tile: TileRef,
searchRange: number,
types: readonly UnitType[],
predicate: (unit: Unit) => boolean,
playerId?: PlayerID,
includeUnderConstruction?: boolean,
): boolean {
return this.unitGrid.anyUnitNearby(
tile,
searchRange,
types,
predicate,
playerId,
includeUnderConstruction,
);
}
nearbyUnits(
tile: TileRef,
searchRange: number,
+26 -1
View File
@@ -717,8 +717,33 @@ export class GameView implements GameMap {
searchRange: number,
type: UnitType,
playerId?: PlayerID,
includeUnderConstruction?: boolean,
) {
return this.unitGrid.hasUnitNearby(tile, searchRange, type, playerId);
return this.unitGrid.hasUnitNearby(
tile,
searchRange,
type,
playerId,
includeUnderConstruction,
);
}
anyUnitNearby(
tile: TileRef,
searchRange: number,
types: readonly UnitType[],
predicate: (unit: UnitView) => boolean,
playerId?: PlayerID,
includeUnderConstruction?: boolean,
): boolean {
return this.unitGrid.anyUnitNearby(
tile,
searchRange,
types,
predicate,
playerId,
includeUnderConstruction,
);
}
myClientID(): ClientID {
+41
View File
@@ -225,4 +225,45 @@ export class UnitGrid {
}
return false;
}
// Return true if any unit of the given types matches the predicate
anyUnitNearby(
tile: TileRef,
searchRange: number,
types: readonly UnitType[],
predicate: (unit: Unit | UnitView) => boolean,
playerId?: PlayerID,
includeUnderConstruction: boolean = false,
): boolean {
const { startGridX, endGridX, startGridY, endGridY } = this.getCellsInRange(
tile,
searchRange,
);
const rangeSquared = searchRange * searchRange;
for (let cy = startGridY; cy <= endGridY; cy++) {
for (let cx = startGridX; cx <= endGridX; cx++) {
for (const type of types) {
const unitSet = this.grid[cy][cx].get(type);
if (unitSet === undefined) continue;
for (const unit of unitSet) {
if (
!this.unitIsInRange(
unit,
tile,
rangeSquared,
playerId,
includeUnderConstruction,
)
) {
continue;
}
if (predicate(unit)) {
return true;
}
}
}
}
}
return false;
}
}
@@ -125,4 +125,36 @@ describe("NukeExecution", () => {
expect(player.isTraitor()).toBe(true);
expect(player.isAlliedWith(otherPlayer)).toBe(false);
});
test("nuke should break alliance when destroying ally's building even with few tiles", async () => {
const req = player.createAllianceRequest(otherPlayer);
req!.accept();
expect(player.isAlliedWith(otherPlayer)).toBe(true);
player.conquer(game.ref(1, 1));
player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {});
// Give the other player just a few tiles (below the threshold of 5)
// and build a port on one of them
otherPlayer.conquer(game.ref(50, 50));
otherPlayer.conquer(game.ref(51, 50));
otherPlayer.conquer(game.ref(50, 51));
otherPlayer.buildUnit(UnitType.Port, game.ref(50, 50), {});
expect(otherPlayer.units(UnitType.Port)).toHaveLength(1);
// Nuke targeting the ally's port - this should break alliance
// even though the tile count is below threshold
game.addExecution(
new NukeExecution(UnitType.AtomBomb, player, game.ref(50, 50), null),
);
game.executeNextTick(); // init
game.executeNextTick(); // exec
// Alliance should be broken because we're destroying ally's building
expect(player.isTraitor()).toBe(true);
expect(player.isAlliedWith(otherPlayer)).toBe(false);
});
});