From 117fa4394711b539f266f92f8d993f4a827df1c3 Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 18 Jun 2026 13:37:45 -0700 Subject: [PATCH] Fix nuke preview showing teammate SAMs as threats (#4342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem In the nuke trajectory preview, the SAM-intercept **"X"** marker was drawn over **teammates'** SAMs — implying their SAM would shoot down your missile. It shouldn't: like allies, a teammate's SAM never engages your nuke. The bug only affected teammates; allies already worked. ## Cause The preview built its threat set from `myPlayer.allies()` only — formal alliances — and never considered teammates. That diverged from the sim ([`SAMLauncherExecution.ts`](src/core/execution/SAMLauncherExecution.ts#L118-L134)), which skips any nuke whose owner it's `isFriendly()` with (**same team OR allied**). ## Fix `samThreatensNukePreview` now takes a teammate set and excludes teammates **unconditionally**. The subtlety: allies keep the existing *betrayal* exception — a strike close enough to break the alliance makes that ally's SAM engage at launch (`listNukeBreakAlliance`, the same function the sim uses). Teammates get **no** such exception, because a strike can break an alliance but never a team relationship. So even a player who is both a teammate *and* a betrayed ally is correctly left off the threat set. ## Notes - The sim has an "aftergame fun" exception where teammate SAMs *do* target teammate nukes once there's a winner. The preview only appears while aiming a buildable mid-game (no winner yet), so that case doesn't apply here. ## Tests Updated `samThreatensNukePreview` unit tests for the new signature and added coverage for: teammate excluded, and teammate stays excluded even when listed as betrayed. All 11 tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- .../controllers/BuildPreviewController.ts | 30 +++++++++++++++---- .../BuildPreviewController.test.ts | 29 ++++++++++++++---- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts index 3dfaa22ca..7a523a6cd 100644 --- a/src/client/controllers/BuildPreviewController.ts +++ b/src/client/controllers/BuildPreviewController.ts @@ -43,15 +43,21 @@ export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean { /** * 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. + * Mirrors SAMLauncherExecution: a SAM ignores a nuke whose owner it's + * friendly with (same team OR allied). + * Teammates are excluded unconditionally — a strike can break an alliance + * but never a team relationship, so a teammate's SAM never engages. + * 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, + teammateSmallIds: ReadonlySet, allySmallIds: ReadonlySet, betrayedSmallIds: ReadonlySet, ): boolean { + if (teammateSmallIds.has(samOwnerSmallId)) return false; return ( !allySmallIds.has(samOwnerSmallId) || betrayedSmallIds.has(samOwnerSmallId) ); @@ -331,10 +337,15 @@ 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 — - // except allies this strike would betray: the alliance breaks at launch - // (NukeExecution.maybeBreakAlliances), so their SAMs will intercept. + // Non-friendly SAMs threaten the trajectory; own + teammate + allied SAMs + // don't — except allies this strike would betray: the alliance breaks at + // launch (NukeExecution.maybeBreakAlliances), so their SAMs will intercept. + // Teammates have no such exception (a strike never breaks a team). // listNukeBreakAlliance is the same function the sim uses there. + const teammateIds = new Set(); + for (const p of this.game.players()) { + if (myPlayer.isOnSameTeam(p)) teammateIds.add(p.smallID()); + } const allyIds = new Set(); for (const a of myPlayer.allies()) allyIds.add(a.smallID()); const betrayedIds: ReadonlySet = @@ -351,7 +362,14 @@ export class BuildPreviewController implements Controller { if (!s.isActive()) continue; const owner = s.owner(); if (owner === myPlayer) continue; - if (!samThreatensNukePreview(owner.smallID(), allyIds, betrayedIds)) { + if ( + !samThreatensNukePreview( + owner.smallID(), + teammateIds, + allyIds, + betrayedIds, + ) + ) { continue; } const r = this.game.config().samRange(s.level()); diff --git a/tests/client/controllers/BuildPreviewController.test.ts b/tests/client/controllers/BuildPreviewController.test.ts index b0d475b63..267997cb4 100644 --- a/tests/client/controllers/BuildPreviewController.test.ts +++ b/tests/client/controllers/BuildPreviewController.test.ts @@ -35,21 +35,40 @@ describe("BuildPreviewController ghost preservation (locked nuke / Enter confirm }); describe("samThreatensNukePreview (nuke trajectory threat set, #4226)", () => { + const teammates = new Set([7, 8]); const allies = new Set([2, 3]); - test("non-allied SAM threatens the trajectory", () => { - expect(samThreatensNukePreview(5, allies, new Set())).toBe(true); + test("non-friendly SAM threatens the trajectory", () => { + expect(samThreatensNukePreview(5, teammates, 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); + expect(samThreatensNukePreview(2, teammates, 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); + expect(samThreatensNukePreview(2, teammates, 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); + expect(samThreatensNukePreview(3, teammates, allies, new Set([2]))).toBe( + false, + ); + }); + + test("teammate SAM does not threaten the trajectory", () => { + expect(samThreatensNukePreview(7, teammates, new Set(), new Set())).toBe( + false, + ); + }); + + test("teammate SAM stays excluded even if listed as betrayed (a strike never breaks a team)", () => { + expect( + samThreatensNukePreview(7, teammates, new Set([7]), new Set([7])), + ).toBe(false); }); });