diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 110f395329..b53d798662 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -1,6 +1,7 @@ import { Execution, Game, + isUnit, MessageType, Player, Structures, @@ -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); diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 8e987db533..f36b134527 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -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 && @@ -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(); + 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, @@ -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); @@ -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; } @@ -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, { @@ -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); } } @@ -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( diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts index a0c978c9ea..a74432a77e 100644 --- a/src/core/execution/SAMMissileExecution.ts +++ b/src/core/execution/SAMMissileExecution.ts @@ -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; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index bfc6ba4720..4bcb73d75d 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -250,7 +250,9 @@ export interface UnitParamsMap { [UnitType.Shell]: Record; - [UnitType.SAMMissile]: Record; + [UnitType.SAMMissile]: { + targetUnit: Unit; + }; [UnitType.Port]: Record; diff --git a/src/core/utilities/Line.ts b/src/core/utilities/Line.ts index 67024e9c62..2d80a1c0f6 100644 --- a/src/core/utilities/Line.ts +++ b/src/core/utilities/Line.ts @@ -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; diff --git a/tests/core/executions/SAMLauncherExecution.test.ts b/tests/core/executions/SAMLauncherExecution.test.ts index 4273159a30..5751849b62 100644 --- a/tests/core/executions/SAMLauncherExecution.test.ts +++ b/tests/core/executions/SAMLauncherExecution.test.ts @@ -7,6 +7,7 @@ import { Player, PlayerInfo, PlayerType, + Unit, UnitType, } from "../../../src/core/game/Game"; import { GameID } from "../../../src/core/Schemas"; @@ -305,4 +306,142 @@ 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.reachedTarget()).toBeFalsy(); + expect(dangerousNuke.wasDestroyedByEnemy()).toBeTruthy(); + }); + + 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.reachedTarget()).toBeFalsy(); + expect(nuke1.wasDestroyedByEnemy()).toBeTruthy(); + expect(nuke2.reachedTarget()).toBeFalsy(); + expect(nuke2.wasDestroyedByEnemy()).toBeTruthy(); + }); + + 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.reachedTarget()).toBeFalsy(); + expect(w.wasDestroyedByEnemy()).toBeTruthy(); + } + }); + + test("SAM launcher should intercept nuke in-flight before reaching detonation tile at SAM range edge", async () => { + const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + game.addExecution(new SAMLauncherExecution(defender, game.ref(1, 1), sam)); + // Nuke whose target is inside SAM range but near its edge + const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(149, 1), { + targetTile: game.ref(17, 1), + trajectory: [ + { tile: game.ref(149, 1), targetable: true }, + { tile: game.ref(137, 1), targetable: true }, + { tile: game.ref(125, 1), targetable: true }, + { tile: game.ref(113, 1), targetable: true }, + { tile: game.ref(101, 1), targetable: true }, + { tile: game.ref(89, 1), targetable: true }, + { tile: game.ref(77, 1), targetable: true }, + { tile: game.ref(65, 1), targetable: true }, + { tile: game.ref(53, 1), targetable: true }, + { tile: game.ref(41, 1), targetable: true }, + { tile: game.ref(29, 1), targetable: true }, + { tile: game.ref(17, 1), targetable: true }, + ], + }); + + executeTicks(game, 11); + // Nuke should be intercepted in-flight before detonating on destination tile + expect(nuke.reachedTarget()).toBeFalsy(); + expect(nuke.wasDestroyedByEnemy()).toBeTruthy(); + }); }); diff --git a/tests/core/pathfinding/UniversalPathFinding.Parabola.test.ts b/tests/core/pathfinding/UniversalPathFinding.Parabola.test.ts index b00215da3e..24b02eaeea 100644 --- a/tests/core/pathfinding/UniversalPathFinding.Parabola.test.ts +++ b/tests/core/pathfinding/UniversalPathFinding.Parabola.test.ts @@ -22,7 +22,7 @@ describe("UniversalPathFinding.Parabola", () => { const path = finder.findPath(from, to); expect(path).not.toBeNull(); - expect(path!.length).toBe(39); + expect(path!.length).toBe(40); expect(path![0]).toBe(from); expect(path![path!.length - 1]).toBe(to); }); @@ -61,7 +61,7 @@ describe("UniversalPathFinding.Parabola", () => { const path = finder.findPath(from, to); expect(path).not.toBeNull(); - expect(path!.length).toBe(43); + expect(path!.length).toBe(45); expect(path![0]).toBe(from); expect(path![path!.length - 1]).toBe(to); }); @@ -240,7 +240,7 @@ describe("UniversalPathFinding.Parabola", () => { const path = finder.findPath(from, to); expect(path).not.toBeNull(); - expect(path!.length).toBe(28); + expect(path!.length).toBe(29); expect(path![0]).toBe(from); expect(path![path!.length - 1]).toBe(to); });