Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 59 additions & 24 deletions src/core/execution/SAMLauncherExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,57 @@ class SAMTargetingSystem {
return undefined;
}

public getSingleTarget(ticks: number): Target | null {
private computeTargetScore(target: Target): number {
const samTile = this.sam.tile();
const unit = target.unit;
const trajectory = unit.trajectory();
const currentIndex = unit.trajectoryIndex();
const timeToExplode = Math.max(1, trajectory.length - currentIndex);

const targetTile =
unit.targetTile() ??
(trajectory.length > 0 ? trajectory[trajectory.length - 1].tile : samTile);

const distToSilo = this.mg.manhattanDist(samTile, targetTile);

// Type bonus: Hydro gets +70,001
// (70,000 offset balances the distance bonus between Hydro at 100 and Atom at 30)
const typeBonus = unit.type() === UnitType.HydrogenBomb ? 70_001 : 0;

// Distance bonus: Closer to silo higher score (-1,000 pts per unit distance)
// due to manhattanDist, distToSilo can exceed 150 diagonally, 200000 starting point.
const distanceBonus = Math.max(0, 200_000 - distToSilo * 1000);

// Time based score: +100 pts per tick earlier
// Since all nukes are already guaranteed to need a SAM response at this tick,
// this is only a very minor tiebreaker.
const urgencyBonus = Math.max(0, 10_000 - timeToExplode * 100);

return typeBonus + distanceBonus + urgencyBonus;
}

private sortTargets(targets: Target[]): Target[] {
if (targets.length <= 1) return targets;

// Create a map for quick look-up time.
const scores = new Map<Target, number>();
for (const target of targets) {
scores.set(target, this.computeTargetScore(target));
}

// Sort by score, js' Timsort guarantees O(n log n)
return targets.sort((a, b) => scores.get(b)! - scores.get(a)!);
}

public getValidTargets(ticks: number): Target[] {
const samTile = this.sam.tile();
const range = this.mg.config().samRange(this.sam.level());
const rangeSquared = range * range;

// Look beyond the SAM range so it can preshot nukes
const detectionRange = this.mg.config().maxSamRange() * 2;
// Look beyond the SAM range so it can preshot nukes.
// Every missile should be spotted in time to allow it to be shot down at maxSamRange
// Times 3 is barely not enough for a MIRV warhead (speed 22 vs SAM 12)
const detectionRange = this.mg.config().maxSamRange() * 4;
const nukes = this.mg.nearbyUnits(
samTile,
detectionRange,
Expand All @@ -128,7 +172,7 @@ class SAMTargetingSystem {
// Clear unreachable nukes that went out of range
this.updateUnreachableNukes(nukes);

let best: Target | null = null;
const targets: Target[] = [];
for (const nuke of nukes) {
const nukeId = nuke.unit.id();
const cached = this.precomputedNukes.get(nukeId);
Expand All @@ -139,14 +183,7 @@ class SAMTargetingSystem {
}
if (cached.tick === ticks) {
// Time to shoot!
const target = { tile: cached.tile, unit: nuke.unit };
if (
best === null ||
(target.unit.type() === UnitType.HydrogenBomb &&
best.unit.type() !== UnitType.HydrogenBomb)
) {
best = target;
}
targets.push({ tile: cached.tile, unit: nuke.unit});
this.precomputedNukes.delete(nukeId);
continue;
}
Expand All @@ -165,15 +202,10 @@ class SAMTargetingSystem {
if (interceptionTile !== undefined) {
if (interceptionTile.tick <= 1) {
// Shoot instantly

const target = { unit: nuke.unit, tile: interceptionTile.tile };
if (
best === null ||
(target.unit.type() === UnitType.HydrogenBomb &&
best.unit.type() !== UnitType.HydrogenBomb)
) {
best = target;
}
targets.push({
unit: nuke.unit,
tile: interceptionTile.tile,
});
} else {
// Nuke will be reachable but not yet. Store the result.
this.precomputedNukes.set(nukeId, {
Expand All @@ -187,7 +219,7 @@ class SAMTargetingSystem {
}
}

return best;
return this.sortTargets(targets);
}
}

Expand Down Expand Up @@ -260,8 +292,11 @@ export class SAMLauncherExecution implements Execution {
this.pseudoRandom ??= new PseudoRandom(this.sam.id());

// target is already filtered to exclude nukes targeted by other SAMs
const target = this.targetingSystem.getSingleTarget(ticks);
if (target !== null) {
const targets = this.targetingSystem.getValidTargets(ticks);
for (const target of targets) {
if (this.sam.isInCooldown()) {
break;
}
this.sam.launch();
target.unit.setTargetedBySAM(true);
this.mg.addExecution(
Expand Down
3 changes: 2 additions & 1 deletion src/core/utilities/Line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ export class DistanceBasedBezierCurve extends CubicBezierCurve {

if (cumulativeDistance >= pixelSpacing) {
this.cachedPoints.push(currentPoint);
cumulativeDistance = 0;
// Reset cumulative distance, don't discard excess
cumulativeDistance -= pixelSpacing;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

prevPoint = currentPoint;
Expand Down
103 changes: 103 additions & 0 deletions tests/core/executions/SAMLauncherExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Player,
PlayerInfo,
PlayerType,
Unit,
UnitType,
} from "../../../src/core/game/Game";
import { GameID } from "../../../src/core/Schemas";
Expand Down Expand Up @@ -305,4 +306,106 @@ describe("SAM", () => {

expect(sam.missileTimerQueue()).toHaveLength(0);
});

test("SAM should prioritize nuke targeting close to SAM launcher over distant nuke", async () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
game.addExecution(new SAMLauncherExecution(defender, null, sam));

// Distant AtomBomb landing far away (at 10, 1)
attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 1), {
targetTile: game.ref(10, 1),
trajectory: [
{ tile: game.ref(2, 1), targetable: true },
{ tile: game.ref(5, 3), targetable: true },
{ tile: game.ref(10, 1), targetable: true },
],
});

// Close AtomBomb targeting right on the SAM (1, 1)
const dangerousNuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), {
targetTile: game.ref(1, 1),
trajectory: [
{ tile: game.ref(1, 1), targetable: true },
{ tile: game.ref(1, 2), targetable: true },
{ tile: game.ref(1, 1), targetable: true },
],
});

executeTicks(game, 3);

// The dangerous nuke aimed directly at the SAM launcher should be intercepted first
expect(dangerousNuke.isActive()).toBeFalsy();
});

test("upgraded SAM launcher should launch multiple missiles in a single tick if multiple targets arrive", async () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
sam.increaseLevel(); // Level 2 allows 2 missile slots
sam.reloadMissile(); // Reload the slot added by increaseLevel()
expect(sam.level()).toBe(2);
expect(sam.isInCooldown()).toBeFalsy();

game.addExecution(new SAMLauncherExecution(defender, null, sam));

const nuke1 = attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 1), {
targetTile: game.ref(3, 1),
trajectory: [
{ tile: game.ref(1, 1), targetable: true },
{ tile: game.ref(2, 1), targetable: true },
{ tile: game.ref(3, 1), targetable: true },
],
});

const nuke2 = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), {
targetTile: game.ref(1, 3),
trajectory: [
{ tile: game.ref(1, 1), targetable: true },
{ tile: game.ref(1, 2), targetable: true },
{ tile: game.ref(1, 3), targetable: true },
],
});

executeTicks(game, 3);

// Both nukes should be intercepted simultaneously by the level-2 SAM launcher
expect(nuke1.isActive()).toBeFalsy();
expect(nuke2.isActive()).toBeFalsy();
});

test("high-level SAM launcher should shoot down multiple MIRV warheads arriving in the same tick", async () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
for (let i = 1; i < 50; i++) {
sam.increaseLevel();
sam.reloadMissile();
}
expect(sam.level()).toBe(50);
expect(sam.isInCooldown()).toBeFalsy();

game.addExecution(new SAMLauncherExecution(defender, null, sam));

const warheads: Unit[] = [];
for (let i = 0; i < 10; i++) {
const warhead = attacker.buildUnit(
UnitType.MIRVWarhead,
game.ref(1, 2 + i),
{
targetTile: game.ref(1, 15 + i),
trajectory: [
{ tile: game.ref(1, 1), targetable: true },
{ tile: game.ref(1, 2 + i), targetable: true },
{ tile: game.ref(1, 15 + i), targetable: true },
],
},
);
warheads.push(warhead);
}

expect(attacker.units(UnitType.MIRVWarhead)).toHaveLength(10);

executeTicks(game, 3);

expect(attacker.units(UnitType.MIRVWarhead)).toHaveLength(0);
for (const w of warheads) {
expect(w.isActive()).toBeFalsy();
}
});
});
Loading