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); + }); +});