From 12003a068b7bb2738aa778e332588048f3f2ce3c Mon Sep 17 00:00:00 2001 From: JB940 Date: Fri, 31 Jul 2026 00:13:42 +0200 Subject: [PATCH 1/3] fix SAM recheck cached incoming nukes --- src/core/execution/SAMLauncherExecution.ts | 6 +++ src/core/game/Game.ts | 4 ++ src/core/game/GameUpdates.ts | 1 + src/core/game/UnitImpl.ts | 22 +++++++++++ .../executions/SAMLauncherExecution.test.ts | 39 +++++++++++++++++++ 5 files changed, 72 insertions(+) diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index f36b134527..b6c709b1e4 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -167,6 +167,12 @@ class SAMTargetingSystem { } public getValidTargets(ticks: number): Target[] { + // Clear the entire cache, we might be able to shoot other nukes further away too. + if (this.sam.needsSamRangeRecheck()) { + this.precomputedNukes.clear(); + this.sam.setSamRangeRecheck(false); + } + const samTile = this.sam.tile(); const range = this.mg.config().samRange(this.sam.level()); const rangeSquared = range * range; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 4bcb73d75d..6673d23521 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -518,6 +518,10 @@ export interface Unit { isInCooldown(): boolean; missileTimerQueue(): number[]; + // SAMs + needsSamRangeRecheck(): boolean | undefined; + setSamRangeRecheck(samRangeRecheck: boolean): void; + // Trade Ships setSafeFromPirates(): void; // Only for trade ships isSafeFromPirates(): boolean; // Only for trade ships diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 48bb56ce85..94737915cb 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -188,6 +188,7 @@ export interface UnitUpdate { health?: number; underConstruction?: boolean; missileTimerQueue: number[]; + recheckSAMRange: boolean; level: number; hasTrainStation: boolean; trainType?: TrainType; // Only for trains diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 1daabb9a3b..13176f964b 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -36,6 +36,8 @@ export class UnitImpl implements Unit { private _troops: number; // Number of missiles in cooldown, if empty all missiles are ready. private _missileTimerQueue: number[] = []; + //Needs to recheck range on upgrade + private _recheckSAMRange: boolean = false; private _hasTrainStation: boolean = false; private _level: number = 1; private _targetable: boolean = true; @@ -150,6 +152,7 @@ export class UnitImpl implements Unit { targetUnitId: this._targetUnit?.id() ?? undefined, targetTile: this.targetTile() ?? undefined, missileTimerQueue: this._missileTimerQueue, + recheckSAMRange: this._recheckSAMRange, level: this.level(), hasTrainStation: this._hasTrainStation, trainType: this._trainType, @@ -626,6 +629,10 @@ export class UnitImpl implements Unit { this._level++; if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) { this._missileTimerQueue.push(this.mg.ticks()); + //inner if to reduce if checks + if (this.type() === UnitType.SAMLauncher) { + this._recheckSAMRange = true; + } } this.mg.addUpdate(this.toUpdate()); } @@ -634,6 +641,10 @@ export class UnitImpl implements Unit { this._level--; if ([UnitType.MissileSilo, UnitType.SAMLauncher].includes(this.type())) { this._missileTimerQueue.pop(); + //inner if to reduce if checks + if (this.type() === UnitType.SAMLauncher) { + this._recheckSAMRange = true; + } } if (this._level <= 0) { this.delete(true, destroyer); @@ -646,6 +657,17 @@ export class UnitImpl implements Unit { return this._trainType; } + needsSamRangeRecheck(): boolean | undefined { + return this._recheckSAMRange; + } + + setSamRangeRecheck(samRangeRecheck: boolean): void { + if (this._recheckSAMRange !== samRangeRecheck) { + this._recheckSAMRange = samRangeRecheck; + this.mg.addUpdate(this.toUpdate()); + } + } + isLoaded(): boolean | undefined { return this._loaded; } diff --git a/tests/core/executions/SAMLauncherExecution.test.ts b/tests/core/executions/SAMLauncherExecution.test.ts index 5751849b62..3e4fe93ae5 100644 --- a/tests/core/executions/SAMLauncherExecution.test.ts +++ b/tests/core/executions/SAMLauncherExecution.test.ts @@ -444,4 +444,43 @@ describe("SAM", () => { expect(nuke.reachedTarget()).toBeFalsy(); expect(nuke.wasDestroyedByEnemy()).toBeTruthy(); }); + + test("leveling up a SAM launcher should recheck range and intercept a nuke previously out of range", async () => { + const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + const execution = new SAMLauncherExecution(defender, game.ref(1, 1), sam); + game.addExecution(execution); + + // Level 1 SAM range squared is 15^2 = 225. + // Nuke trajectory is at distance ~25 from SAM (out of level 1 range). + const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(50, 1), { + targetTile: game.ref(25, 1), + trajectory: [ + { tile: game.ref(50, 1), targetable: true }, + { tile: game.ref(45, 1), targetable: true }, + { tile: game.ref(40, 1), targetable: true }, + { tile: game.ref(35, 1), targetable: true }, + { tile: game.ref(30, 1), targetable: true }, + { tile: game.ref(25, 1), targetable: true }, + ], + }); + + // Run ticks at level 1: nuke should be marked as unreachable / out of range. + executeTicks(game, 2); + expect(nuke.isActive()).toBeTruthy(); + expect(nuke.wasDestroyedByEnemy()).toBeFalsy(); + + // Level up SAM launcher: and sets recheck flag. + sam.increaseLevel(); + sam.reloadMissile(); + expect(sam.needsSamRangeRecheck()).toBeTruthy(); + + //Custom test config range always returns 20 no matter SAM level, mock 26 for range increase. + vi.spyOn(game.config(), "samRange").mockReturnValue(26); + + // Run next ticks: cache should be cleared and nuke intercepted under new range. + executeTicks(game, 6); + expect(nuke.reachedTarget()).toBeFalsy(); + expect(nuke.wasDestroyedByEnemy()).toBeTruthy(); + }); }); + From e4b7a7b6bd397e0b3d978b08b4a578d951f57106 Mon Sep 17 00:00:00 2001 From: JB940 Date: Fri, 31 Jul 2026 00:26:22 +0200 Subject: [PATCH 2/3] run pretty (before PR this time) --- src/core/game/Game.ts | 2 +- src/core/game/GameUpdates.ts | 2 +- src/core/game/UnitImpl.ts | 4 ++-- tests/core/executions/SAMLauncherExecution.test.ts | 5 ++--- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 6673d23521..dc35a77ca0 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -519,7 +519,7 @@ export interface Unit { missileTimerQueue(): number[]; // SAMs - needsSamRangeRecheck(): boolean | undefined; + needsSamRangeRecheck(): boolean | undefined; setSamRangeRecheck(samRangeRecheck: boolean): void; // Trade Ships diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 94737915cb..232a99bc0a 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -188,7 +188,7 @@ export interface UnitUpdate { health?: number; underConstruction?: boolean; missileTimerQueue: number[]; - recheckSAMRange: boolean; + recheckSAMRange: boolean; level: number; hasTrainStation: boolean; trainType?: TrainType; // Only for trains diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 13176f964b..d083c1a999 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -631,7 +631,7 @@ export class UnitImpl implements Unit { this._missileTimerQueue.push(this.mg.ticks()); //inner if to reduce if checks if (this.type() === UnitType.SAMLauncher) { - this._recheckSAMRange = true; + this._recheckSAMRange = true; } } this.mg.addUpdate(this.toUpdate()); @@ -643,7 +643,7 @@ export class UnitImpl implements Unit { this._missileTimerQueue.pop(); //inner if to reduce if checks if (this.type() === UnitType.SAMLauncher) { - this._recheckSAMRange = true; + this._recheckSAMRange = true; } } if (this._level <= 0) { diff --git a/tests/core/executions/SAMLauncherExecution.test.ts b/tests/core/executions/SAMLauncherExecution.test.ts index 3e4fe93ae5..9f70962ebb 100644 --- a/tests/core/executions/SAMLauncherExecution.test.ts +++ b/tests/core/executions/SAMLauncherExecution.test.ts @@ -473,14 +473,13 @@ describe("SAM", () => { sam.increaseLevel(); sam.reloadMissile(); expect(sam.needsSamRangeRecheck()).toBeTruthy(); - + //Custom test config range always returns 20 no matter SAM level, mock 26 for range increase. vi.spyOn(game.config(), "samRange").mockReturnValue(26); - + // Run next ticks: cache should be cleared and nuke intercepted under new range. executeTicks(game, 6); expect(nuke.reachedTarget()).toBeFalsy(); expect(nuke.wasDestroyedByEnemy()).toBeTruthy(); }); }); - From f455b160b49ddffeb734d03eaa830a41faeda36f Mon Sep 17 00:00:00 2001 From: JB940 Date: Fri, 31 Jul 2026 02:39:43 +0200 Subject: [PATCH 3/3] fix failing build. Update test per Rabbit request. Remove useless checks in test (no NukeExecution - nukes don't physicially move in the game) --- .../executions/SAMLauncherExecution.test.ts | 62 +++++++++++++++---- tests/util/viewStubs.ts | 1 + 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/tests/core/executions/SAMLauncherExecution.test.ts b/tests/core/executions/SAMLauncherExecution.test.ts index 9f70962ebb..783e9e043f 100644 --- a/tests/core/executions/SAMLauncherExecution.test.ts +++ b/tests/core/executions/SAMLauncherExecution.test.ts @@ -441,20 +441,27 @@ describe("SAM", () => { executeTicks(game, 11); // Nuke should be intercepted in-flight before detonating on destination tile - expect(nuke.reachedTarget()).toBeFalsy(); expect(nuke.wasDestroyedByEnemy()).toBeTruthy(); }); - test("leveling up a SAM launcher should recheck range and intercept a nuke previously out of range", async () => { + test("Level changes in a SAM should cause it to target or untarget nukes depending on range", async () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); const execution = new SAMLauncherExecution(defender, game.ref(1, 1), sam); game.addExecution(execution); - // Level 1 SAM range squared is 15^2 = 225. - // Nuke trajectory is at distance ~25 from SAM (out of level 1 range). - const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(50, 1), { + //Custom test config range always returns 20, mock test values + vi.spyOn(game.config(), "samRange").mockImplementation( + (level) => 18 + level * 4, + ); + + // Nuke trajectory is at distance ~25 from SAM (out of range). + const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(70, 1), { targetTile: game.ref(25, 1), trajectory: [ + { tile: game.ref(70, 1), targetable: true }, + { tile: game.ref(65, 1), targetable: true }, + { tile: game.ref(60, 1), targetable: true }, + { tile: game.ref(55, 1), targetable: true }, { tile: game.ref(50, 1), targetable: true }, { tile: game.ref(45, 1), targetable: true }, { tile: game.ref(40, 1), targetable: true }, @@ -465,21 +472,52 @@ describe("SAM", () => { }); // Run ticks at level 1: nuke should be marked as unreachable / out of range. - executeTicks(game, 2); - expect(nuke.isActive()).toBeTruthy(); + executeTicks(game, 10); + expect(nuke.targetedBySAM()).toBeFalsy(); expect(nuke.wasDestroyedByEnemy()).toBeFalsy(); // Level up SAM launcher: and sets recheck flag. sam.increaseLevel(); sam.reloadMissile(); - expect(sam.needsSamRangeRecheck()).toBeTruthy(); - //Custom test config range always returns 20 no matter SAM level, mock 26 for range increase. - vi.spyOn(game.config(), "samRange").mockReturnValue(26); + expect(sam.needsSamRangeRecheck()).toBeTruthy(); // Run next ticks: cache should be cleared and nuke intercepted under new range. - executeTicks(game, 6); - expect(nuke.reachedTarget()).toBeFalsy(); + executeTicks(game, 10); expect(nuke.wasDestroyedByEnemy()).toBeTruthy(); + + // Nuke trajectory is at distance ~25 from SAM (out of range). + const nuke2 = attacker.buildUnit(UnitType.AtomBomb, game.ref(70, 1), { + targetTile: game.ref(25, 1), + trajectory: [ + { tile: game.ref(70, 1), targetable: true }, + { tile: game.ref(65, 1), targetable: true }, + { tile: game.ref(60, 1), targetable: true }, + { tile: game.ref(55, 1), targetable: true }, + { tile: game.ref(50, 1), targetable: true }, + { tile: game.ref(45, 1), targetable: true }, + { tile: game.ref(40, 1), targetable: true }, + { tile: game.ref(35, 1), targetable: true }, + { tile: game.ref(30, 1), targetable: true }, + { tile: game.ref(25, 1), targetable: true }, + ], + }); + + sam.reloadMissile(); + sam.reloadMissile(); + + // Run ticks at level 2: Should be in range. Have to check precomputed. + executeTicks(game, 3); + const precomputed = (execution).targetingSystem + .precomputedNukes as Map; + expect(precomputed.get(nuke2.id())).toBeTruthy(); + + // Level down a SAM launcher and sets recheck flag. + sam.decreaseLevel(); + expect(sam.needsSamRangeRecheck()).toBeTruthy(); + + // Run next ticks: nuke should not be intercepted + executeTicks(game, 40); + expect(nuke2.wasDestroyedByEnemy()).toBeFalsy(); }); }); diff --git a/tests/util/viewStubs.ts b/tests/util/viewStubs.ts index 483e326395..b54575039e 100644 --- a/tests/util/viewStubs.ts +++ b/tests/util/viewStubs.ts @@ -160,6 +160,7 @@ export function makeUnitUpdate( targetable: true, markedForDeletion: false, missileTimerQueue: [], + recheckSAMRange: false, level: 1, hasTrainStation: false, ...overrides,