feat: Nuke trajectory prediction now accounts for alliance breakage. (#2912)

## Description:

Nuke trajectory prediction now will show interception with allied SAMs
if the alliance will break on nuke launch.

Code was also refactored to be shared a bit more. 

In addition, if an incoming alliance would break if accepted, the nuke
launch will break the alliance.

<img width="1199" height="1002" alt="nukepr"
src="https://github.com/user-attachments/assets/c31066d9-66cf-4eaa-be3c-e2fbcfe7965a"
/>

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

bibizu
This commit is contained in:
bibizu
2026-01-18 23:56:43 -05:00
committed by GitHub
parent 969b301aac
commit 3dadfbd23d
3 changed files with 63 additions and 29 deletions
@@ -1,4 +1,5 @@
import { EventBus } from "../../../core/EventBus";
import { listNukeBreakAlliance } from "../../../core/execution/Util";
import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView } from "../../../core/game/GameView";
@@ -258,6 +259,18 @@ export class NukeTrajectoryPreviewLayer implements Layer {
break;
}
}
const playersToBreakAllianceWith = listNukeBreakAlliance({
game: this.game,
targetTile,
magnitude: this.game.config().nukeMagnitudes(ghostStructure),
allySmallIds: new Set(
this.game
.myPlayer()
?.allies()
.map((a) => a.smallID()),
),
threshold: this.game.config().nukeAllianceBreakThreshold(),
});
// Find the point where SAM can intercept
this.targetedIndex = this.trajectoryPoints.length;
// Check trajectory
@@ -270,7 +283,8 @@ export class NukeTrajectoryPreviewLayer implements Layer {
)) {
if (
sam.unit.owner().isMe() ||
this.game.myPlayer()?.isFriendly(sam.unit.owner())
(this.game.myPlayer()?.isFriendly(sam.unit.owner()) &&
!playersToBreakAllianceWith.has(sam.unit.owner().smallID()))
) {
continue;
}
+13 -27
View File
@@ -4,7 +4,6 @@ import {
isStructureType,
MessageType,
Player,
StructureTypes,
TerraNullius,
TrajectoryTile,
Unit,
@@ -16,7 +15,7 @@ import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola"
import { PathStatus } from "../pathfinding/types";
import { PseudoRandom } from "../PseudoRandom";
import { NukeType } from "../StatsSchemas";
import { computeNukeBlastCounts } from "./Util";
import { listNukeBreakAlliance } from "./Util";
const SPRITE_RADIUS = 16;
@@ -85,36 +84,22 @@ export class NukeExecution implements Execution {
}
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
const threshold = this.mg.config().nukeAllianceBreakThreshold();
// Use shared utility to compute weighted tile counts per player
const blastCounts = computeNukeBlastCounts({
gm: this.mg,
const playersToBreakAllianceWith = listNukeBreakAlliance({
game: this.mg,
targetTile: this.dst,
magnitude,
allySmallIds: new Set(this.player.allies().map((a) => a.smallID())),
threshold: this.mg.config().nukeAllianceBreakThreshold(),
});
// 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) {
playersToBreakAllianceWith.add(playerSmallId);
// Automatically reject incoming alliance requests.
for (const incoming of this.player.incomingAllianceRequests()) {
if (playersToBreakAllianceWith.has(incoming.requestor().smallID())) {
incoming.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()),
);
for (const playerSmallId of playersToBreakAllianceWith) {
const attackedPlayer = this.mg.playerBySmallID(playerSmallId);
if (!attackedPlayer.isPlayer()) {
@@ -123,11 +108,12 @@ export class NukeExecution implements Execution {
// Resolves exploit of alliance breaking in which a pending alliance request
// was accepted in the middle of a missile attack.
const allianceRequest = attackedPlayer
const outgoingAllianceRequest = attackedPlayer
.incomingAllianceRequests()
.find((ar) => ar.requestor() === this.player);
if (allianceRequest) {
allianceRequest.reject();
if (outgoingAllianceRequest) {
outgoingAllianceRequest.reject();
continue;
}
const alliance = this.player.allianceWith(attackedPlayer);
+35 -1
View File
@@ -36,7 +36,7 @@ export function computeNukeBlastCounts(
}
export interface NukeAllianceCheckParams {
game: GameView;
game: Game | GameView;
targetTile: TileRef;
magnitude: NukeMagnitude;
allySmallIds: Set<number>;
@@ -93,6 +93,40 @@ export function wouldNukeBreakAlliance(
return result;
}
// Same as wouldNukeBreakAlliance(), but takes time to find every player
// that would be "angered" from this nuke.
// This includes unallied players!
export function listNukeBreakAlliance(
params: NukeAllianceCheckParams,
): Set<number> {
const { game, targetTile, magnitude, threshold } = params;
// Collect all players that should have alliance broken:
// either exceeds tile threshold OR has a structure in blast radius
const playersToBreakAllianceWith = new Set<number>();
// compute tile breakage threshold
const blastCounts = computeNukeBlastCounts({
gm: game,
targetTile,
magnitude,
});
for (const [playerSmallId, totalWeight] of blastCounts) {
if (totalWeight > threshold) {
playersToBreakAllianceWith.add(playerSmallId);
}
}
// Also check if any allied structures would be destroyed
game
.nearbyUnits(targetTile, magnitude.outer, [...StructureTypes])
.forEach(({ unit }) =>
playersToBreakAllianceWith.add(unit.owner().smallID()),
);
return playersToBreakAllianceWith;
}
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),