Fix nuke trajectory preview missing SAM interception for would-be-betrayed allies (#4235)

## Summary
Fixes #4226 (Release Blocker, V32 regression).

The WebGL nuke trajectory preview built its SAM threat set by
unconditionally excluding own + allied SAMs
(`BuildPreviewController.updateNukeTrajectoryPreview`). But when the
strike targets allied territory, the alliance breaks at launch —
`NukeExecution.maybeBreakAlliances()` — so the betrayed ally's SAMs
**do** engage the nuke. The preview therefore showed a fully white
trajectory with no intercept X over an allied SAM, even though the bomb
would be shot down (V31 previewed this correctly).

## Fix
- Compute the would-be-betrayed player set with
`listNukeBreakAlliance()` — the exact function the sim uses at launch,
so preview and sim can't drift.
- Keep an allied SAM in the threat set iff its owner is in that set
(extracted as pure `samThreatensNukePreview()`).
- Other (non-betrayed) allies' SAMs remain excluded, matching sim
behavior where only alliances over the blast threshold break.

Both missing artifacts in the issue (red post-intercept segment and X
marker) come from `tSamIntercept` staying at 1.0 because no SAM was
supplied, so this one change restores both.

Cost note: this adds one `circleSearch` per throttled ghost update
(50ms) when the player has allies — same order as the existing
`wouldNukeBreakAlliance` call for the red warning circle.

## Testing
- Unit tests for the new threat-set predicate (4 cases) in
`tests/client/controllers/BuildPreviewController.test.ts`
- `tsc --noEmit`, ESLint, Prettier clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-12 09:29:10 -07:00
committed by GitHub
parent eeb5f7e850
commit 71af72606a
2 changed files with 60 additions and 4 deletions
@@ -8,7 +8,10 @@
*/
import { EventBus } from "../../core/EventBus";
import { wouldNukeBreakAlliance } from "../../core/execution/Util";
import {
listNukeBreakAlliance,
wouldNukeBreakAlliance,
} from "../../core/execution/Util";
import {
BuildableUnit,
PlayerBuildableUnitType,
@@ -38,6 +41,22 @@ export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean {
return unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb;
}
/**
* Whether a SAM belongs in the nuke trajectory preview's threat set.
* Allied SAMs are excluded unless the strike would betray that ally —
* the alliance breaks at launch, so their SAMs will engage the nuke.
* (Own SAMs never threaten; the caller filters those out first.)
*/
export function samThreatensNukePreview(
samOwnerSmallId: number,
allySmallIds: ReadonlySet<number>,
betrayedSmallIds: ReadonlySet<number>,
): boolean {
return (
!allySmallIds.has(samOwnerSmallId) || betrayedSmallIds.has(samOwnerSmallId)
);
}
export class BuildPreviewController implements Controller {
/** Current ghost (null when no build type is active). */
private ghostUnit: { buildableUnit: BuildableUnit } | null = null;
@@ -312,15 +331,29 @@ export class BuildPreviewController implements Controller {
const srcX = this.game.x(bestSilo.tile());
const srcY = this.game.y(bestSilo.tile());
// Non-allied SAMs threaten the trajectory; own + allied SAMs don't.
// Non-allied SAMs threaten the trajectory; own + allied SAMs don't
// except allies this strike would betray: the alliance breaks at launch
// (NukeExecution.maybeBreakAlliances), so their SAMs will intercept.
// listNukeBreakAlliance is the same function the sim uses there.
const allyIds = new Set<number>();
for (const a of myPlayer.allies()) allyIds.add(a.smallID());
const betrayedIds: ReadonlySet<number> =
allyIds.size > 0
? listNukeBreakAlliance({
game: this.game,
targetTile: tileRef,
magnitude: this.game.config().nukeMagnitudes(type),
threshold: this.game.config().nukeAllianceBreakThreshold(),
})
: new Set();
const sams: SAMInfo[] = [];
for (const s of this.game.units(UnitType.SAMLauncher)) {
if (!s.isActive()) continue;
const owner = s.owner();
if (owner === myPlayer) continue;
if (allyIds.has(owner.smallID())) continue;
if (!samThreatensNukePreview(owner.smallID(), allyIds, betrayedIds)) {
continue;
}
const r = this.game.config().samRange(s.level());
sams.push({
x: this.game.x(s.tile()),
@@ -1,5 +1,8 @@
import { describe, expect, test } from "vitest";
import { shouldPreserveGhostAfterBuild } from "../../../src/client/controllers/BuildPreviewController";
import {
samThreatensNukePreview,
shouldPreserveGhostAfterBuild,
} from "../../../src/client/controllers/BuildPreviewController";
import { UnitType } from "../../../src/core/game/Game";
describe("BuildPreviewController ghost preservation (locked nuke / Enter confirm)", () => {
@@ -30,3 +33,23 @@ describe("BuildPreviewController ghost preservation (locked nuke / Enter confirm
});
});
});
describe("samThreatensNukePreview (nuke trajectory threat set, #4226)", () => {
const allies = new Set([2, 3]);
test("non-allied SAM threatens the trajectory", () => {
expect(samThreatensNukePreview(5, allies, new Set())).toBe(true);
});
test("allied SAM does not threaten when the strike breaks no alliance", () => {
expect(samThreatensNukePreview(2, allies, new Set())).toBe(false);
});
test("would-be-betrayed ally's SAM threatens (alliance breaks at launch)", () => {
expect(samThreatensNukePreview(2, allies, new Set([2]))).toBe(true);
});
test("other allies' SAMs still excluded when a different ally is betrayed", () => {
expect(samThreatensNukePreview(3, allies, new Set([2]))).toBe(false);
});
});