Fix nuke preview showing teammate SAMs as threats (#4342)

## 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) <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-18 13:37:45 -07:00
committed by evanpelle
parent 86599fe15b
commit 6f8c2a5e91
2 changed files with 48 additions and 11 deletions
@@ -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<number>,
allySmallIds: ReadonlySet<number>,
betrayedSmallIds: ReadonlySet<number>,
): 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<number>();
for (const p of this.game.players()) {
if (myPlayer.isOnSameTeam(p)) teammateIds.add(p.smallID());
}
const allyIds = new Set<number>();
for (const a of myPlayer.allies()) allyIds.add(a.smallID());
const betrayedIds: ReadonlySet<number> =
@@ -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());
@@ -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);
});
});