Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
22 changes: 20 additions & 2 deletions src/core/execution/NukeExecution.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Execution,
Game,
isUnit,
MessageType,
Player,
Structures,
Expand Down Expand Up @@ -261,9 +262,26 @@ export class NukeExecution implements Execution {

// Move to next tile
const result = this.pathFinder.next(this.src!, this.dst, this.speed);

if (result.status === PathStatus.COMPLETE) {
this.detonate();
return;
// move it afterward for visual effect
this.nuke.move(result.node);

// Check for very close SAM missiles that are targeting this.
// The SAM logic should be the main source of truth, since missiles can skip pixels
// and be affected by execution order
const shouldBeDestroyed =
this.mg.nearbyUnits(
this.dst,
this.mg.config().defaultSamMissileSpeed(),
UnitType.SAMMissile,
({ unit }) => {
if (!isUnit(unit) || unit.owner() === this.nuke?.owner())
return false;
return unit.targetUnit()?.id() === this.nuke?.id();
},
).length >= 1;
if (!shouldBeDestroyed) this.detonate();
} else if (result.status === PathStatus.NEXT) {
this.updateNukeTargetable();
this.nuke.move(result.node);
Expand Down
121 changes: 93 additions & 28 deletions src/core/execution/SAMLauncherExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,10 @@ class SAMTargetingSystem {
): InterceptionTile | undefined {
const trajectory = unit.trajectory();
const currentIndex = unit.trajectoryIndex();
const explosionTick: number = trajectory.length - currentIndex;
for (let i = currentIndex; i < trajectory.length; i++) {

// NukeExecution happens before SAMMissileExecution. It cannot intercept the final tick.
const maxInterceptionIndex = trajectory.length - 2;
for (let i = currentIndex; i <= maxInterceptionIndex; i++) {
const trajectoryTile = trajectory[i];
if (
trajectoryTile.targetable &&
Expand All @@ -88,21 +90,91 @@ class SAMTargetingSystem {
const nukeTickToReach = i - currentIndex;
const samTickToReach = this.tickToReach(samTile, trajectoryTile.tile);
const tickBeforeShooting = nukeTickToReach - samTickToReach;
if (samTickToReach < explosionTick && tickBeforeShooting >= 0) {
if (tickBeforeShooting >= 0) {
return { tick: tickBeforeShooting, tile: trajectoryTile.tile };
}
}
}

// No interception found; check if detonation tile inside SAM range
const finalExplosionTile = trajectory[trajectory.length - 1];
if (
finalExplosionTile &&
finalExplosionTile.targetable &&
this.mg.euclideanDistSquared(samTile, finalExplosionTile.tile) <=
rangeSquared
) {
const targetInFlightTile = trajectory[maxInterceptionIndex];
if (targetInFlightTile && targetInFlightTile.targetable) {
const nukeTickToReach = maxInterceptionIndex - currentIndex;
const samTickToReach = this.tickToReach(
samTile,
targetInFlightTile.tile,
);
const tickBeforeShooting = nukeTickToReach - samTickToReach;
// can we shoot the nuke the tick before it explodes?
if (tickBeforeShooting >= 0) {
return { tick: tickBeforeShooting, tile: targetInFlightTile.tile };
}
}
}

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);

// Hydro unit type bonus
// 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 +200,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 @@ -137,16 +209,9 @@ class SAMTargetingSystem {
// Already computed as unreachable, skip
continue;
}
if (cached.tick === ticks) {
if (cached.tick === ticks || cached.tick === ticks + 1) {
// 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 +230,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 +247,9 @@ class SAMTargetingSystem {
}
}

return best;
// This function can easily find further use later to prioritize nukes
// So we can start checking for nukes across multiple ticks
return this.sortTargets(targets);
}
}

Expand Down Expand Up @@ -260,8 +322,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
9 changes: 4 additions & 5 deletions src/core/execution/SAMMissileExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,13 @@ export class SAMMissileExecution implements Execution {
this.pathFinder = PathFinding.Air(mg);
this.mg = mg;
this.speed = this.mg.config().defaultSamMissileSpeed();
this.tick(ticks);
}

tick(ticks: number): void {
this.SAMMissile ??= this._owner.buildUnit(
UnitType.SAMMissile,
this.spawn,
{},
);
this.SAMMissile ??= this._owner.buildUnit(UnitType.SAMMissile, this.spawn, {
targetUnit: this.target,
});
if (!this.SAMMissile.isActive()) {
this.active = false;
return;
Expand Down
4 changes: 3 additions & 1 deletion src/core/game/Game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,9 @@ export interface UnitParamsMap {

[UnitType.Shell]: Record<string, never>;

[UnitType.SAMMissile]: Record<string, never>;
[UnitType.SAMMissile]: {
targetUnit: Unit;
};

[UnitType.Port]: Record<string, never>;

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;
}

prevPoint = currentPoint;
Expand Down
Loading
Loading