From 71af72606a2c444ef115c823b4c37113c9557f81 Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 12 Jun 2026 09:29:10 -0700 Subject: [PATCH] Fix nuke trajectory preview missing SAM interception for would-be-betrayed allies (#4235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../controllers/BuildPreviewController.ts | 39 +++++++++++++++++-- .../BuildPreviewController.test.ts | 25 +++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts index 416bc2cdb..19a70ab32 100644 --- a/src/client/controllers/BuildPreviewController.ts +++ b/src/client/controllers/BuildPreviewController.ts @@ -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, + betrayedSmallIds: ReadonlySet, +): 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(); for (const a of myPlayer.allies()) allyIds.add(a.smallID()); + const betrayedIds: ReadonlySet = + 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()), diff --git a/tests/client/controllers/BuildPreviewController.test.ts b/tests/client/controllers/BuildPreviewController.test.ts index 3dafc5469..b0d475b63 100644 --- a/tests/client/controllers/BuildPreviewController.test.ts +++ b/tests/client/controllers/BuildPreviewController.test.ts @@ -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); + }); +});