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..dc35a77ca0 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..232a99bc0a 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..d083c1a999 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..783e9e043f 100644 --- a/tests/core/executions/SAMLauncherExecution.test.ts +++ b/tests/core/executions/SAMLauncherExecution.test.ts @@ -441,7 +441,83 @@ 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("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); + + //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 }, + { 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, 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(); + + // Run next ticks: cache should be cleared and nuke intercepted under new range. + 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,