Fix long-distance nuke interception (#1900)

## Description:

Fix a bug introduced by an optimization that caused some incoming nukes
to be ignored. As a result, long-distance nukes could evade in-range
SAM.

Changes:
Replaced the ignored nukes list with a list of precomputed nukes.
For incoming long-distance nukes, the interception tile is now computed
in advance.
When the nuke enters range, the precomputed interception tile is used
for interception.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

IngloriousTom

---------

Co-authored-by: evanpelle <evanpelle@gmail.com>
This commit is contained in:
DevelopingTom
2025-08-25 07:28:36 +02:00
committed by GitHub
parent 713db43f42
commit 3f1efc624f
+63 -25
View File
@@ -16,31 +16,38 @@ type Target = {
tile: TileRef;
};
type InterceptionTile = {
tile: TileRef;
tick: number;
};
/**
* Smart SAM targeting system preshoting nukes so its range is strictly enforced
*/
class SAMTargetingSystem {
// Store unreachable nukes so the SAM won't compute an interception point for them every frame
private nukesToIgnore: Set<number> = new Set();
// Interception tiles are computed a single time, but it may not be reachable yet.
// Store the result so it can be intercepted at the proper time, rather than recomputing each ticks
// Null interception tile means there are no interception tiles in range. Store it to
private readonly precomputedNukes: Map<number, InterceptionTile | null> =
new Map();
private readonly missileSpeed: number;
constructor(
private mg: Game,
private sam: Unit,
) {}
private readonly mg: Game,
private readonly sam: Unit,
) {
this.missileSpeed = this.mg.config().defaultSamMissileSpeed();
}
updateUnreachableNukes(nearbyUnits: { unit: Unit; distSquared: number }[]) {
const nearbyUnitSet = new Set(nearbyUnits.map((u) => u.unit.id()));
for (const nukeId of this.nukesToIgnore) {
for (const nukeId of this.precomputedNukes.keys()) {
if (!nearbyUnitSet.has(nukeId)) {
this.nukesToIgnore.delete(nukeId);
this.precomputedNukes.delete(nukeId);
}
}
}
private storeUnreachableNukes(nukeId: number) {
this.nukesToIgnore.add(nukeId);
}
private isInRange(tile: TileRef) {
const samTile = this.sam.tile();
const rangeSquared = this.mg.config().defaultSamRange() ** 2;
@@ -48,32 +55,33 @@ class SAMTargetingSystem {
}
private tickToReach(currentTile: TileRef, tile: TileRef): number {
const missileSpeed = this.mg.config().defaultSamMissileSpeed();
return Math.ceil(this.mg.manhattanDist(currentTile, tile) / missileSpeed);
return Math.ceil(
this.mg.manhattanDist(currentTile, tile) / this.missileSpeed,
);
}
private computeInterceptionTile(unit: Unit): TileRef | undefined {
private computeInterceptionTile(unit: Unit): InterceptionTile | undefined {
const trajectory = unit.trajectory();
const samTile = this.sam.tile();
const currentIndex = unit.trajectoryIndex();
const explosionTick: number = trajectory.length - currentIndex;
for (let i = unit.trajectoryIndex(); i < trajectory.length; i++) {
for (let i = currentIndex; i < trajectory.length; i++) {
const trajectoryTile = trajectory[i];
if (trajectoryTile.targetable && this.isInRange(trajectoryTile.tile)) {
const nukeTickToReach = i - currentIndex;
const samTickToReach = this.tickToReach(samTile, trajectoryTile.tile);
const reachableOnTime = Math.abs(nukeTickToReach - samTickToReach) <= 1;
if (reachableOnTime && samTickToReach < explosionTick) {
return trajectoryTile.tile;
const tickBeforeShooting = nukeTickToReach - samTickToReach;
if (samTickToReach < explosionTick && tickBeforeShooting >= 0) {
return { tick: tickBeforeShooting, tile: trajectoryTile.tile };
}
}
}
return undefined;
}
public getSingleTarget(): Target | null {
public getSingleTarget(ticks: number): Target | null {
// Look beyond the SAM range so it can preshot nukes
const detectionRange = this.mg.config().defaultSamRange() * 1.5;
const detectionRange = this.mg.config().defaultSamRange() * 2;
const nukes = this.mg.nearbyUnits(
this.sam.tile(),
detectionRange,
@@ -92,15 +100,42 @@ class SAMTargetingSystem {
const targets: Array<Target> = [];
for (const nuke of nukes) {
if (this.nukesToIgnore.has(nuke.unit.id())) {
continue;
const nukeId = nuke.unit.id();
const cached = this.precomputedNukes.get(nukeId);
if (cached !== undefined) {
if (cached === null) {
// Known unreachable, skip.
continue;
}
if (cached.tick === ticks) {
// Time to shoot!
targets.push({ tile: cached.tile, unit: nuke.unit });
this.precomputedNukes.delete(nukeId);
continue;
}
if (cached.tick > ticks) {
// Not due yet, skip for now.
continue;
}
// Missed the planned tick (e.g was on cooldown), recompute a new interception tile if possible
this.precomputedNukes.delete(nukeId);
}
const interceptionTile = this.computeInterceptionTile(nuke.unit);
if (interceptionTile !== undefined) {
targets.push({ unit: nuke.unit, tile: interceptionTile });
if (interceptionTile.tick <= 1) {
// Shoot instantly
targets.push({ unit: nuke.unit, tile: interceptionTile.tile });
} else {
// Nuke will be reachable but not yet. Store the result.
this.precomputedNukes.set(nukeId, {
tick: interceptionTile.tick + ticks,
tile: interceptionTile.tile,
});
}
} else {
// Store unreachable nukes in order to prevent useless interception computation
this.storeUnreachableNukes(nuke.unit.id());
this.precomputedNukes.set(nukeId, null);
}
}
@@ -225,7 +260,10 @@ export class SAMLauncherExecution implements Execution {
let target: Target | null = null;
if (mirvWarheadTargets.length === 0) {
target = this.targetingSystem.getSingleTarget();
target = this.targetingSystem.getSingleTarget(ticks);
if (target !== null) {
console.log("Target acquired");
}
}
const isSingleTarget = target && !target.unit.targetedBySAM();