diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 14afbf708..725eb1be6 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -545,7 +545,7 @@ export class UnitLayer implements Layer { const targetable = unit.targetable(); if (!targetable) { this.context.save(); - this.context.globalAlpha = 0.4; + this.context.globalAlpha = 0.5; } this.context.drawImage( sprite, diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index c26fd4f31..16d6e9584 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -156,6 +156,7 @@ export interface Config { nukeAllianceBreakThreshold(): number; defaultNukeSpeed(): number; defaultNukeTargetableRange(): number; + defaultSamMissileSpeed(): number; defaultSamRange(): number; nukeDeathFactor( nukeType: NukeType, diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index e4d4404e2..3d8f43628 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -802,11 +802,15 @@ export class DefaultConfig implements Config { } defaultNukeTargetableRange(): number { - return 120; + return 150; } defaultSamRange(): number { - return 80; + return 70; + } + + defaultSamMissileSpeed(): number { + return 12; } // Humans can be soldiers, soldiers attacking, soldiers in boat etc. diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 864992a57..b7ac4a32f 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -5,6 +5,7 @@ import { MessageType, Player, TerraNullius, + TrajectoryTile, Unit, UnitType, } from "../game/Game"; @@ -20,8 +21,6 @@ export class NukeExecution implements Execution { private mg: Game; private nuke: Unit | null = null; private tilesToDestroyCache: Set | undefined; - - private random: PseudoRandom; private pathFinder: ParabolaPathFinder; constructor( @@ -35,7 +34,6 @@ export class NukeExecution implements Execution { init(mg: Game, ticks: number): void { this.mg = mg; - this.random = new PseudoRandom(ticks); if (this.speed === -1) { this.speed = this.mg.config().defaultNukeSpeed(); } @@ -107,10 +105,12 @@ export class NukeExecution implements Execution { this.pathFinder.computeControlPoints( spawn, this.dst, + this.speed, this.nukeType !== UnitType.MIRVWarhead, ); this.nuke = this.player.buildUnit(this.nukeType, spawn, { targetTile: this.dst, + trajectory: this.getTrajectory(this.dst), }); this.maybeBreakAlliances(this.tilesToDestroy()); if (this.mg.hasOwner(this.dst)) { @@ -169,6 +169,8 @@ export class NukeExecution implements Execution { } else { this.updateNukeTargetable(); this.nuke.move(nextTile); + // Update index so SAM can interpolate future position + this.nuke.setTrajectoryIndex(this.pathFinder.currentIndex()); } } @@ -176,21 +178,43 @@ export class NukeExecution implements Execution { return this.nuke; } + private getTrajectory(target: TileRef): TrajectoryTile[] { + const trajectoryTiles: TrajectoryTile[] = []; + const targetRangeSquared = + this.mg.config().defaultNukeTargetableRange() ** 2; + const allTiles: TileRef[] = this.pathFinder.allTiles(); + for (const tile of allTiles) { + trajectoryTiles.push({ + tile, + targetable: this.isTargetable(target, tile, targetRangeSquared), + }); + } + + return trajectoryTiles; + } + + private isTargetable( + targetTile: TileRef, + nukeTile: TileRef, + targetRangeSquared: number, + ): boolean { + return ( + this.mg.euclideanDistSquared(nukeTile, targetTile) < targetRangeSquared || + (this.src !== undefined && + this.src !== null && + this.mg.euclideanDistSquared(this.src, nukeTile) < targetRangeSquared) + ); + } + private updateNukeTargetable() { if (this.nuke === null || this.nuke.targetTile() === undefined) { return; } const targetRangeSquared = - this.mg.config().defaultNukeTargetableRange() * - this.mg.config().defaultNukeTargetableRange(); + this.mg.config().defaultNukeTargetableRange() ** 2; const targetTile = this.nuke.targetTile(); this.nuke.setTargetable( - this.mg.euclideanDistSquared(this.nuke.tile(), targetTile!) < - targetRangeSquared || - (this.src !== undefined && - this.src !== null && - this.mg.euclideanDistSquared(this.src, this.nuke.tile()) < - targetRangeSquared), + this.isTargetable(targetTile!, this.nuke.tile(), targetRangeSquared), ); } diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index a897711fa..484028aec 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -10,6 +10,118 @@ import { TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { SAMMissileExecution } from "./SAMMissileExecution"; +type Target = { + unit: Unit; + tile: TileRef; +}; + +/** + * 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 = new Set(); + + constructor( + private mg: Game, + private player: Player, + private sam: Unit, + ) {} + + updateUnreachableNukes(nearbyUnits: { unit: Unit; distSquared: number }[]) { + const nearbyUnitSet = new Set(nearbyUnits.map((u) => u.unit.id())); + for (const nukeId of this.nukesToIgnore) { + if (!nearbyUnitSet.has(nukeId)) { + this.nukesToIgnore.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; + return this.mg.euclideanDistSquared(samTile, tile) <= rangeSquared; + } + + private tickToReach(currentTile: TileRef, tile: TileRef): number { + const missileSpeed = this.mg.config().defaultSamMissileSpeed(); + return Math.ceil(this.mg.manhattanDist(currentTile, tile) / missileSpeed); + } + + private computeInterceptionTile(unit: Unit): TileRef | 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++) { + 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; + } + } + } + return undefined; + } + + public getSingleTarget(): Target | null { + // Look beyond the SAM range so it can preshot nukes + const detectionRange = this.mg.config().defaultSamRange() * 1.5; + const nukes = this.mg.nearbyUnits( + this.sam.tile(), + detectionRange, + [UnitType.AtomBomb, UnitType.HydrogenBomb], + ({ unit }) => { + return ( + unit.owner() !== this.player && !this.player.isFriendly(unit.owner()) + ); + }, + ); + + // Clear unreachable nukes that went out of range + this.updateUnreachableNukes(nukes); + + const targets: Array = []; + for (const nuke of nukes) { + if (this.nukesToIgnore.has(nuke.unit.id())) { + continue; + } + const interceptionTile = this.computeInterceptionTile(nuke.unit); + if (interceptionTile !== undefined) { + targets.push({ unit: nuke.unit, tile: interceptionTile }); + } else { + // Store unreachable nukes in order to prevent useless interception computation + this.storeUnreachableNukes(nuke.unit.id()); + } + } + + return ( + targets.sort((a: Target, b: Target) => { + // Prioritize Hydrogen Bombs + if ( + a.unit.type() === UnitType.HydrogenBomb && + b.unit.type() !== UnitType.HydrogenBomb + ) + return -1; + if ( + a.unit.type() !== UnitType.HydrogenBomb && + b.unit.type() === UnitType.HydrogenBomb + ) + return 1; + + return 0; + })[0] ?? null + ); + } +} + export class SAMLauncherExecution implements Execution { private mg: Game; private active: boolean = true; @@ -18,6 +130,7 @@ export class SAMLauncherExecution implements Execution { // shoot the one targeting very close (MIRVWarheadProtectionRadius) private MIRVWarheadSearchRadius = 400; private MIRVWarheadProtectionRadius = 50; + private targetingSystem: SAMTargetingSystem; private pseudoRandom: PseudoRandom | undefined; @@ -35,41 +148,6 @@ export class SAMLauncherExecution implements Execution { this.mg = mg; } - private getSingleTarget(): Unit | null { - if (this.sam === null) return null; - const nukes = this.mg.nearbyUnits( - this.sam.tile(), - this.mg.config().defaultSamRange(), - [UnitType.AtomBomb, UnitType.HydrogenBomb], - ({ unit }) => - unit.owner() !== this.player && - !this.player.isFriendly(unit.owner()) && - unit.isTargetable(), - ); - - return ( - nukes.sort((a, b) => { - const { unit: unitA, distSquared: distA } = a; - const { unit: unitB, distSquared: distB } = b; - - // Prioritize Hydrogen Bombs - if ( - unitA.type() === UnitType.HydrogenBomb && - unitB.type() !== UnitType.HydrogenBomb - ) - return -1; - if ( - unitA.type() !== UnitType.HydrogenBomb && - unitB.type() === UnitType.HydrogenBomb - ) - return 1; - - // If both are the same type, sort by distance (lower `distSquared` means closer) - return distA - distB; - })[0]?.unit ?? null - ); - } - private isHit(type: UnitType, random: number): boolean { if (type === UnitType.AtomBomb) { return true; @@ -98,6 +176,26 @@ export class SAMLauncherExecution implements Execution { } this.sam = this.player.buildUnit(UnitType.SAMLauncher, spawnTile, {}); } + this.targetingSystem ??= new SAMTargetingSystem( + this.mg, + this.player, + this.sam, + ); + + if (this.sam.isInCooldown()) { + const frontTime = this.sam.missileTimerQueue()[0]; + if (frontTime === undefined) { + return; + } + const cooldown = + this.mg.config().SAMCooldown() - (this.mg.ticks() - frontTime); + + if (cooldown <= 0) { + this.sam.reloadMissile(); + } + return; + } + if (!this.sam.isActive()) { this.active = false; return; @@ -126,19 +224,18 @@ export class SAMLauncherExecution implements Execution { }, ); - let target: Unit | null = null; + let target: Target | null = null; if (mirvWarheadTargets.length === 0) { - target = this.getSingleTarget(); + target = this.targetingSystem.getSingleTarget(); } - const isSingleTarget = target && !target.targetedBySAM(); - if ( - (isSingleTarget || mirvWarheadTargets.length > 0) && - !this.sam.isInCooldown() - ) { + const isSingleTarget = target && !target.unit.targetedBySAM(); + if (isSingleTarget || mirvWarheadTargets.length > 0) { this.sam.launch(); const type = - mirvWarheadTargets.length > 0 ? UnitType.MIRVWarhead : target?.type(); + mirvWarheadTargets.length > 0 + ? UnitType.MIRVWarhead + : target?.unit.type(); if (type === undefined) throw new Error("Unknown unit type"); const random = this.pseudoRandom.next(); const hit = this.isHit(type, random); @@ -172,31 +269,20 @@ export class SAMLauncherExecution implements Execution { mirvWarheadTargets.length, ); } else if (target !== null) { - target.setTargetedBySAM(true); + target.unit.setTargetedBySAM(true); this.mg.addExecution( new SAMMissileExecution( this.sam.tile(), this.sam.owner(), this.sam, - target, + target.unit, + target.tile, ), ); } else { throw new Error("target is null"); } } - - const frontTime = this.sam.missileTimerQueue()[0]; - if (frontTime === undefined) { - return; - } - - const cooldown = - this.mg.config().SAMCooldown() - (this.mg.ticks() - frontTime); - - if (cooldown <= 0) { - this.sam.reloadMissile(); - } } isActive(): boolean { diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts index 4cf680805..e313acd8c 100644 --- a/src/core/execution/SAMMissileExecution.ts +++ b/src/core/execution/SAMMissileExecution.ts @@ -16,18 +16,20 @@ export class SAMMissileExecution implements Execution { private pathFinder: AirPathFinder; private SAMMissile: Unit | undefined; private mg: Game; + private speed: number = 0; constructor( private spawn: TileRef, private _owner: Player, private ownerUnit: Unit, private target: Unit, - private speed: number = 12, + private targetTile: TileRef, ) {} init(mg: Game, ticks: number): void { this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks())); this.mg = mg; + this.speed = this.mg.config().defaultSamMissileSpeed(); } tick(ticks: number): void { @@ -55,7 +57,7 @@ export class SAMMissileExecution implements Execution { for (let i = 0; i < this.speed; i++) { const result = this.pathFinder.nextTile( this.SAMMissile.tile(), - this.target.tile(), + this.targetTile, ); if (result === true) { this.mg.displayMessage( diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 894b894ae..2a4991afd 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -189,6 +189,10 @@ export interface OwnerComp { owner: Player; } +export type TrajectoryTile = { + tile: TileRef; + targetable: boolean; +}; export interface UnitParamsMap { [UnitType.TransportShip]: { troops?: number; @@ -207,10 +211,12 @@ export interface UnitParamsMap { [UnitType.AtomBomb]: { targetTile?: number; + trajectory: TrajectoryTile[]; }; [UnitType.HydrogenBomb]: { targetTile?: number; + trajectory: TrajectoryTile[]; }; [UnitType.TradeShip]: { @@ -420,6 +426,9 @@ export interface Unit { // Targeting setTargetTile(cell: TileRef | undefined): void; targetTile(): TileRef | undefined; + setTrajectoryIndex(i: number): void; + trajectoryIndex(): number; + trajectory(): TrajectoryTile[]; setTargetUnit(unit: Unit | undefined): void; targetUnit(): Unit | undefined; setTargetedBySAM(targeted: boolean): void; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index ea871e170..899e56d79 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -5,6 +5,7 @@ import { Player, Tick, TrainType, + TrajectoryTile, Unit, UnitInfo, UnitType, @@ -35,6 +36,9 @@ export class UnitImpl implements Unit { private _targetable: boolean = true; private _loaded: boolean | undefined; private _trainType: TrainType | undefined; + // Nuke only + private _trajectoryIndex: number = 0; + private _trajectory: TrajectoryTile[]; constructor( private _type: UnitType, @@ -48,6 +52,7 @@ export class UnitImpl implements Unit { this._health = toInt(this.mg.unitInfo(_type).maxHealth ?? 1); this._targetTile = "targetTile" in params ? (params.targetTile ?? undefined) : undefined; + this._trajectory = "trajectory" in params ? (params.trajectory ?? []) : []; this._troops = "troops" in params ? (params.troops ?? 0) : 0; this._lastSetSafeFromPirates = "lastSetSafeFromPirates" in params @@ -323,6 +328,19 @@ export class UnitImpl implements Unit { return this._targetTile; } + setTrajectoryIndex(i: number): void { + const max = this._trajectory.length - 1; + this._trajectoryIndex = i < 0 ? 0 : i > max ? max : i; + } + + trajectoryIndex(): number { + return this._trajectoryIndex; + } + + trajectory(): TrajectoryTile[] { + return this._trajectory; + } + setTargetUnit(target: Unit | undefined): void { this._targetUnit = target; } diff --git a/src/core/pathfinding/PathFinding.ts b/src/core/pathfinding/PathFinding.ts index 80356f0d2..8d5193ed4 100644 --- a/src/core/pathfinding/PathFinding.ts +++ b/src/core/pathfinding/PathFinding.ts @@ -14,6 +14,7 @@ export class ParabolaPathFinder { computeControlPoints( orig: TileRef, dst: TileRef, + increment: number = 3, distanceBasedHeight = true, ) { const p0 = { x: this.mg.x(orig), y: this.mg.y(orig) }; @@ -34,7 +35,7 @@ export class ParabolaPathFinder { y: Math.max(p0.y + ((p3.y - p0.y) * 3) / 4 - maxHeight, 0), }; - this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3); + this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3, increment); } nextTile(speed: number): TileRef | true { @@ -47,6 +48,22 @@ export class ParabolaPathFinder { } return this.mg.ref(Math.floor(nextPoint.x), Math.floor(nextPoint.y)); } + + currentIndex(): number { + if (!this.curve) { + return 0; + } + return this.curve.getCurrentIndex(); + } + + allTiles(): TileRef[] { + if (!this.curve) { + return []; + } + return this.curve + .getAllPoints() + .map((point) => this.mg.ref(Math.floor(point.x), Math.floor(point.y))); + } } export class AirPathFinder { diff --git a/src/core/utilities/Line.ts b/src/core/utilities/Line.ts index 2e1fea97b..e8c673533 100644 --- a/src/core/utilities/Line.ts +++ b/src/core/utilities/Line.ts @@ -78,76 +78,103 @@ export class CubicBezierCurve { */ export class DistanceBasedBezierCurve extends CubicBezierCurve { private totalDistance: number = 0; - private distanceLUT: Array<{ t: number; distance: number }> = []; - private lastFoundIndex: number = 0; // To keep track of the last found index + private cachedPoints: Point[] = []; + private currentIndex: number = 0; + constructor( + p0: Point, + p1: Point, + p2: Point, + p3: Point, + distanceIncrement: number, + ) { + super(p0, p1, p2, p3); + this.computeAllPoints(distanceIncrement, 0.002); + } + + getAllPoints(): Point[] { + return this.cachedPoints; + } + /** + * Move forward along the curve by the given distance. + * Returns the next cached point, or null if at the end. + */ increment(distance: number): Point | null { this.totalDistance += distance; - const targetDistance = Math.min( - this.totalDistance, - this.distanceLUT[this.distanceLUT.length - 1]?.distance || - this.totalDistance, - ); - const t = this.computeTForDistance(targetDistance); - if (t >= 1) { - return null; // end reached + + // Step forward through cached points until we're at the correct distance + while ( + this.currentIndex < this.cachedPoints.length - 1 && + this.getDistanceUpToIndex(this.currentIndex + 1) < this.totalDistance + ) { + this.currentIndex++; } - return this.getPointAt(t); + + if (this.currentIndex >= this.cachedPoints.length - 1) { + return null; // End of curve + } + + return this.cachedPoints[this.currentIndex]; + } + + getCurrentIndex(): number { + return this.currentIndex; } /** - * Generate @p numSteps segments, starting from the beginning of the curve - * Each segment size is added in the LUT + * Precompute all points spaced @p pixelSpacing apart */ - generateCumulativeDistanceLUT(numSteps: number = 500): void { - this.distanceLUT = []; - let cumulativeDistance = 0; - let prevPoint = this.getPointAt(0); + computeAllPoints(pixelSpacing: number, precision): void { + this.cachedPoints = []; + this.totalDistance = 0; + this.currentIndex = 0; - for (let i = 1; i <= numSteps; i++) { - const t = i / numSteps; + let t = 0; + let prevPoint = this.getPointAt(t); + this.cachedPoints.push(prevPoint); + + let cumulativeDistance = 0; + + while (t < 1) { + t = Math.min(t + precision, 1); const currentPoint = this.getPointAt(t); const dx = currentPoint.x - prevPoint.x; const dy = currentPoint.y - prevPoint.y; const segmentLength = Math.sqrt(dx * dx + dy * dy); - cumulativeDistance += segmentLength; - this.distanceLUT.push({ t, distance: cumulativeDistance }); + + if (cumulativeDistance >= pixelSpacing) { + this.cachedPoints.push(currentPoint); + cumulativeDistance = 0; + } + prevPoint = currentPoint; } + + // Make sure the last point is exactly at t=1 + const finalPoint = this.getPointAt(1); + if ( + this.cachedPoints.length === 0 || + finalPoint.x !== this.cachedPoints[this.cachedPoints.length - 1].x || + finalPoint.y !== this.cachedPoints[this.cachedPoints.length - 1].y + ) { + this.cachedPoints.push(finalPoint); + } } - computeTForDistance(distance: number): number { - if (this.distanceLUT.length === 0) { - this.generateCumulativeDistanceLUT(); + /** + * Optional helper: get distance along the cached points up to a given index + */ + private getDistanceUpToIndex(index: number): number { + let dist = 0; + for (let i = 1; i <= index; i++) { + const p1 = this.cachedPoints[i - 1]; + const p2 = this.cachedPoints[i]; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + dist += Math.sqrt(dx * dx + dy * dy); } - if (distance <= 0) return 0; - if (distance >= this.distanceLUT[this.distanceLUT.length - 1].distance) { - return 1; - } - - let lowerIndex = this.lastFoundIndex; - let upperIndex = this.distanceLUT.length - 1; - // Binary search for the closest range - while (upperIndex - lowerIndex > 1) { - const midIndex = Math.floor((upperIndex + lowerIndex) / 2); - if (this.distanceLUT[midIndex].distance < distance) { - lowerIndex = midIndex; - } else { - upperIndex = midIndex; - } - } - - const lower = this.distanceLUT[lowerIndex]; - const upper = this.distanceLUT[upperIndex]; - this.lastFoundIndex = lowerIndex; - - // Linear interpolation of t based on the distance - const t = - lower.t + - ((distance - lower.distance) * (upper.t - lower.t)) / - (upper.distance - lower.distance); - return t; + return dist; } } diff --git a/tests/core/executions/SAMLauncherExecution.test.ts b/tests/core/executions/SAMLauncherExecution.test.ts index 2802c3b76..0e7fc0d98 100644 --- a/tests/core/executions/SAMLauncherExecution.test.ts +++ b/tests/core/executions/SAMLauncherExecution.test.ts @@ -81,10 +81,16 @@ describe("SAM", () => { test("one sam should take down one nuke", async () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); game.addExecution(new SAMLauncherExecution(defender, null, sam)); - attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { - targetTile: game.ref(2, 1), - }); + // Sam will only target nukes it can destroy before it reaches its target + const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 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 }, + ], + }); executeTicks(game, 3); expect(attacker.units(UnitType.AtomBomb)).toHaveLength(0); @@ -94,10 +100,20 @@ describe("SAM", () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); game.addExecution(new SAMLauncherExecution(defender, null, sam)); attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 1), { - targetTile: 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 }, + ], }); attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), { - targetTile: 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 }, + ], }); expect(attacker.units(UnitType.AtomBomb)).toHaveLength(2); @@ -111,8 +127,13 @@ describe("SAM", () => { game.addExecution(new SAMLauncherExecution(defender, null, sam)); expect(sam.isInCooldown()).toBeFalsy(); - const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), { - targetTile: game.ref(1, 2), + const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { + targetTile: game.ref(1, 3), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(2, 1), targetable: true }, + { tile: game.ref(3, 1), targetable: true }, + ], }); executeTicks(game, 3); @@ -134,8 +155,13 @@ describe("SAM", () => { game.addExecution(new SAMLauncherExecution(defender, null, sam1)); const sam2 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 2), {}); game.addExecution(new SAMLauncherExecution(defender, null, sam2)); - const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 2), { - targetTile: game.ref(2, 2), + const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { + 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); @@ -159,7 +185,7 @@ describe("SAM", () => { game.addExecution(nukeExecution); // Long distance nuke: compute the proper number of ticks const ticksToExecute = Math.ceil( - targetDistance / game.config().defaultNukeSpeed(), + targetDistance / game.config().defaultNukeSpeed() + 1, ); executeTicks(game, ticksToExecute); @@ -194,7 +220,7 @@ describe("SAM", () => { game.addExecution(nukeExecution); // Long distance nuke: compute the proper number of ticks const ticksToExecute = Math.ceil( - targetDistance / game.config().defaultNukeSpeed(), + targetDistance / game.config().defaultNukeSpeed() + 1, ); executeTicks(game, ticksToExecute); expect(nukeExecution.isActive()).toBeFalsy();