From 9f01b9d7cca370490df38929cb9a71983ae881ae Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 3 Aug 2026 09:00:02 -0700 Subject: [PATCH] Let nukes fly over impassable terrain Removes the rule (from #4340) that nuke trajectories cannot cross impassable terrain, and with it the silo path-selection / curve-flip workaround from #4815, which this supersedes: - NukeExecution no longer aborts or flips the curve at launch - nukeSpawn picks the plain closest ready silo again (MIRV special case collapsed away) - Nation AI no longer skips silos/targets behind impassable walls - isParabolaBlocked / clearParabolaDirection helpers deleted Impassable terrain itself still can't be nuked: targeting is rejected in nukeSpawn and the blast radius still skips impassable tiles. Client preview: hovering impassable terrain with a nuke keeps the ghost hidden (void treatment) but now shows the trajectory arc with the red X marker pinned at the untargetable destination, reusing the SAM-intercept marker machinery (tSamIntercept clamped to 0.9999). Co-Authored-By: Claude Fable 5 --- .../controllers/BuildPreviewController.ts | 101 ++++---- src/client/render/gl/utils/NukeTrajectory.ts | 78 +----- src/core/execution/NukeExecution.ts | 29 +-- .../execution/nation/NationNukeBehavior.ts | 27 +- src/core/game/PlayerImpl.ts | 17 +- src/core/pathfinding/PathFinder.Parabola.ts | 49 ---- tests/ImpassableTerrain.test.ts | 232 +++--------------- tests/NukeTrajectory.test.ts | 107 +------- 8 files changed, 87 insertions(+), 553 deletions(-) diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts index 4f342565ea..4ebac9c4b3 100644 --- a/src/client/controllers/BuildPreviewController.ts +++ b/src/client/controllers/BuildPreviewController.ts @@ -19,7 +19,6 @@ import { } from "../../core/game/Game"; import { TileRef } from "../../core/game/GameMap"; import { UserSettings } from "../../core/game/UserSettings"; -import { clearParabolaDirection } from "../../core/pathfinding/PathFinder.Parabola"; import { Controller } from "../Controller"; import { ConfirmGhostStructureEvent, @@ -42,6 +41,11 @@ export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean { return unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb; } +// tSamIntercept value used to flag an untargetable (impassable) destination: +// draws the red X marker essentially at the destination while leaving the +// visible line unchanged (1.0 would mean "no marker"). +const T_BLOCKED_DST = 0.9999; + /** * Whether a SAM belongs in the nuke trajectory preview's threat set. * Mirrors SAMLauncherExecution: a SAM ignores a nuke whose owner it's @@ -79,16 +83,14 @@ export class BuildPreviewController implements Controller { private lastGhostData: GhostPreviewData | null = null; // Static inputs for the nuke trajectory preview (source silo + threatening - // SAMs + impassable-terrain blocker). Recomputed in the throttled renderGhost - // path; cursorLoop rebuilds the Bezier each frame with the live cursor - // position as the destination so the arc tracks the cursor smoothly instead - // of snapping tile-to-tile. + // SAMs). Recomputed in the throttled renderGhost path; cursorLoop rebuilds + // the Bezier each frame with the live cursor position as the destination so + // the arc tracks the cursor smoothly instead of snapping tile-to-tile. private nukeTrajectoryStatic: { srcX: number; srcY: number; directionUp: boolean; sams: SAMInfo[]; - isBlocked: (x: number, y: number) => boolean; } | null = null; constructor( @@ -144,18 +146,27 @@ export class BuildPreviewController implements Controller { if (traj !== null) { // Rebuild the arc with the live cursor as the destination (same // tile-center convention as the icon: shader adds +0.5). - this.view.updateNukeTrajectory( - buildNukeTrajectory( - traj.srcX, - traj.srcY, - w.x - 0.5, - w.y - 0.5, - this.game.height(), - traj.directionUp, - traj.sams, - traj.isBlocked, - ), + const data = buildNukeTrajectory( + traj.srcX, + traj.srcY, + w.x - 0.5, + w.y - 0.5, + this.game.height(), + traj.directionUp, + traj.sams, ); + // Impassable terrain can't be targeted (nukeSpawn rejects it) + // even though nukes fly over it — mark the destination with the + // blocked X. Checked per frame so the X tracks the live cursor. + const tx = Math.floor(w.x); + const ty = Math.floor(w.y); + if ( + this.game.isValidCoord(tx, ty) && + this.game.isImpassable(this.game.ref(tx, ty)) + ) { + data.tSamIntercept = Math.min(data.tSamIntercept, T_BLOCKED_DST); + } + this.view.updateNukeTrajectory(data); } } requestAnimationFrame(cursorLoop); @@ -195,14 +206,19 @@ export class BuildPreviewController implements Controller { if (now - this.lastGhostQueryAt < 50) return; this.lastGhostQueryAt = now; let tileRef: TileRef | undefined; + let trajectoryTileRef: TileRef | undefined; const tile = this.transformHandler.screenToWorldCoordinates( this.mousePos.x, this.mousePos.y, ); if (this.game.isValidCoord(tile.x, tile.y)) { tileRef = this.game.ref(tile.x, tile.y); + trajectoryTileRef = tileRef; // Impassable terrain is a void — treat hovering over it the same as - // hovering outside the map (no ghost, no trajectory, no blast circle). + // hovering outside the map (no ghost, no blast circle). The nuke + // trajectory preview is the exception: nukes fly over impassable + // terrain, so the arc still renders (with a blocked X at the + // untargetable destination — see cursorLoop). if (this.game.isImpassable(tileRef)) { tileRef = undefined; } @@ -243,7 +259,7 @@ export class BuildPreviewController implements Controller { .then((buildables) => { if (!this.ghostUnit) { this.pendingConfirm = null; - this.emitGhostPreview(tileRef, targetingAlly); + this.emitGhostPreview(tileRef, targetingAlly, trajectoryTileRef); return; } @@ -256,7 +272,7 @@ export class BuildPreviewController implements Controller { canUpgrade: false, }); this.pendingConfirm = null; - this.emitGhostPreview(tileRef, targetingAlly); + this.emitGhostPreview(tileRef, targetingAlly, trajectoryTileRef); return; } @@ -270,7 +286,7 @@ export class BuildPreviewController implements Controller { } } - this.emitGhostPreview(tileRef, targetingAlly); + this.emitGhostPreview(tileRef, targetingAlly, trajectoryTileRef); }); } @@ -283,6 +299,7 @@ export class BuildPreviewController implements Controller { private emitGhostPreview( tileRef: TileRef | undefined, targetingAlly: boolean, + trajectoryTileRef: TileRef | undefined, ): void { const data = this.buildGhostPreviewData(tileRef, targetingAlly); if (data === null) { @@ -291,14 +308,17 @@ export class BuildPreviewController implements Controller { } else { this.lastGhostData = data; } - this.updateNukeTrajectoryPreview(tileRef); + // The trajectory target is tracked separately from the ghost tile: + // impassable terrain voids the ghost but still gets a trajectory arc. + this.updateNukeTrajectoryPreview(trajectoryTileRef); } /** * For AtomBomb / HydrogenBomb ghosts, push the Bezier trajectory preview * (closest player-owned silo → target, accounting for non-allied SAMs). * Cleared whenever the ghost isn't a nuke, has no target, or the player - * has no silos. + * has no silos. Unlike the ghost icon, the trajectory also renders when + * hovering impassable terrain (cursorLoop adds the blocked X there). */ private updateNukeTrajectoryPreview(tileRef: TileRef | undefined): void { if (!this.ghostUnit || tileRef === undefined) { @@ -318,10 +338,9 @@ export class BuildPreviewController implements Controller { // Mirror PlayerImpl.nukeSpawn (the source NukeExecution actually fires // from): only silos that are active, not reloading, and not under - // construction are eligible, and the nearest (Manhattan distance) whose - // parabola avoids impassable terrain on the up or down curve is chosen. - // Keeping these in sync prevents the preview arc from originating from - // a silo the game wouldn't use. + // construction are eligible, and the nearest (Manhattan distance) is + // chosen. Keeping these in sync prevents the preview arc from + // originating from a silo the game wouldn't use. const silos = myPlayer .units(UnitType.MissileSilo) .filter( @@ -342,25 +361,8 @@ export class BuildPreviewController implements Controller { Math.abs(this.game.y(b.tile()) - dstY)), ); - // NukeExecution flies the requested curve direction if clear, otherwise - // the opposite one — resolve the direction the sim would actually use. - // If every silo is blocked both ways, fall back to the nearest silo and - // the requested direction so the arc renders red with the blocked X. - let bestSilo = silos[0]; - let directionUp = this.uiState.rocketDirectionUp; - for (const s of silos) { - const dir = clearParabolaDirection( - this.game, - s.tile(), - tileRef, - this.uiState.rocketDirectionUp, - ); - if (dir !== null) { - bestSilo = s; - directionUp = dir; - break; - } - } + const bestSilo = silos[0]; + const directionUp = this.uiState.rocketDirectionUp; const srcX = this.game.x(bestSilo.tile()); const srcY = this.game.y(bestSilo.tile()); @@ -409,18 +411,11 @@ export class BuildPreviewController implements Controller { // Stash the static inputs; cursorLoop rebuilds the Bezier each frame with // the live cursor as the destination so the arc tracks smoothly. - // The isBlocked callback tests impassable terrain so the trajectory turns - // red with a red X where it would cross impassable terrain (matching the - // simulation's abort-on-impassable behavior). this.nukeTrajectoryStatic = { srcX, srcY, directionUp, sams, - isBlocked: (x: number, y: number) => { - if (!this.game.isValidCoord(x, y)) return false; - return this.game.isImpassable(this.game.ref(x, y)); - }, }; } diff --git a/src/client/render/gl/utils/NukeTrajectory.ts b/src/client/render/gl/utils/NukeTrajectory.ts index d2016574a4..d020a65429 100644 --- a/src/client/render/gl/utils/NukeTrajectory.ts +++ b/src/client/render/gl/utils/NukeTrajectory.ts @@ -125,19 +125,10 @@ function refineCrossing( /** * Sample the Bezier curve at regular t intervals and find color threshold - * t-values for untargetable zones, SAM intercept, and impassable terrain. + * t-values for untargetable zones and SAM intercept. * * Uses binary search refinement for sub-sample precision so that zone * boundary markers don't jiggle when the cursor moves. - * - * @param isBlocked Optional callback: given a continuous (x, y) point on the - * Bezier, returns true if that point falls on impassable - * terrain. The scan covers the ENTIRE curve (including the - * untargetable mid-air zone), because impassable terrain - * blocks the nuke regardless of targetability. When a - * blocked point is found, its t-value is merged into - * `tSamIntercept` (via min) so the existing red-line + red-X - * machinery renders the trajectory as blocked. */ export function computeTrajectoryThresholds( cp: { @@ -155,7 +146,6 @@ export function computeTrajectoryThresholds( dstX: number, dstY: number, sams: readonly SAMInfo[], - isBlocked?: (x: number, y: number) => boolean, ): { tUntargetableStart: number; tUntargetableEnd: number; @@ -164,7 +154,6 @@ export function computeTrajectoryThresholds( let tUntargetableStart = -1; let tUntargetableEnd = -1; let tSamIntercept = 1.0; - let tBlocked = 1.0; const dt = 1.0 / THRESHOLD_SAMPLES; @@ -243,66 +232,12 @@ export function computeTrajectoryThresholds( } } - // Pass 3: find impassable terrain intercept (scan the ENTIRE curve — - // impassable terrain blocks the nuke regardless of targetability, so - // unlike SAMs we do NOT skip the untargetable mid-air zone). - if (isBlocked) { - for (let i = 1; i <= THRESHOLD_SAMPLES; i++) { - const t = i * dt; - const x = bezier(t, cp.p0x, cp.p1x, cp.p2x, cp.p3x); - const y = bezier(t, cp.p0y, cp.p1y, cp.p2y, cp.p3y); - // Mirror the simulation's tile-sampling: floor to integer tile coords. - if (isBlocked(Math.floor(x), Math.floor(y))) { - tBlocked = refineBlockedCrossing(cp, isBlocked, t - dt, t); - break; - } - } - // Merge: the earlier of SAM intercept and impassable block determines - // where the trajectory turns red + shows the X. - tSamIntercept = Math.min(tSamIntercept, tBlocked); - } - return { tUntargetableStart, tUntargetableEnd, tSamIntercept }; } -/** - * Binary-search for the exact t where the curve first enters a blocked tile. - * Unlike refineCrossing (which uses a radial distance test), this tests - * isBlocked on the floored integer tile at each subdivision point. - */ -function refineBlockedCrossing( - cp: { - p0x: number; - p0y: number; - p1x: number; - p1y: number; - p2x: number; - p2y: number; - p3x: number; - p3y: number; - }, - isBlocked: (x: number, y: number) => boolean, - tLo: number, - tHi: number, -): number { - for (let i = 0; i < 10; i++) { - const tMid = (tLo + tHi) * 0.5; - const x = Math.floor(bezier(tMid, cp.p0x, cp.p1x, cp.p2x, cp.p3x)); - const y = Math.floor(bezier(tMid, cp.p0y, cp.p1y, cp.p2y, cp.p3y)); - if (isBlocked(x, y)) tHi = tMid; - else tLo = tMid; - } - return (tLo + tHi) * 0.5; -} - /** * Build complete NukeTrajectoryData from source/target positions. * Convenience function combining control point + threshold computation. - * - * @param isBlocked Optional callback: returns true if a floored (x, y) point - * on the Bezier is impassable terrain. When provided, the - * trajectory turns red and shows the red X at the first - * impassable tile (merged with any SAM intercept). */ export function buildNukeTrajectory( srcX: number, @@ -312,7 +247,6 @@ export function buildNukeTrajectory( mapH: number, directionUp: boolean, sams: readonly SAMInfo[], - isBlocked?: (x: number, y: number) => boolean, ): NukeTrajectoryData { const cp = computeNukeControlPoints( srcX, @@ -322,14 +256,6 @@ export function buildNukeTrajectory( mapH, directionUp, ); - const th = computeTrajectoryThresholds( - cp, - srcX, - srcY, - dstX, - dstY, - sams, - isBlocked, - ); + const th = computeTrajectoryThresholds(cp, srcX, srcY, dstX, dstY, sams); return { ...cp, ...th }; } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index ad97c03659..4d182e75b3 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -12,10 +12,7 @@ import { } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { UniversalPathFinding } from "../pathfinding/PathFinder"; -import { - clearParabolaDirection, - ParabolaUniversalPathFinder, -} from "../pathfinding/PathFinder.Parabola"; +import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola"; import { PathStatus } from "../pathfinding/types"; import { PseudoRandom } from "../PseudoRandom"; import { NukeType } from "../StatsSchemas"; @@ -195,30 +192,6 @@ export class NukeExecution implements Execution { // The launch tile can be overridden by the caller (e.g. MIRV warheads // launch from the MIRV separation point, not a silo). this.src ??= spawn; - // Nuke trajectories cannot pass over impassable terrain unless they are MIRV warheads, just as they - // cannot exceed the map border. Fly the requested curve direction if - // it is clear, otherwise the opposite one; if both curves cross - // impassable terrain, abort the launch. - if (this.nukeType !== UnitType.MIRVWarhead) { - const direction = clearParabolaDirection( - this.mg, - this.src, - this.dst, - this.rocketDirectionUp, - ); - if (direction === null) { - console.warn(`nuke trajectory crosses impassable terrain`); - this.active = false; - return; - } - if (direction !== this.rocketDirectionUp) { - this.rocketDirectionUp = direction; - this.pathFinder = UniversalPathFinding.Parabola(this.mg, { - increment: this.speed, - directionUp: direction, - }); - } - } this.nuke = this.player.buildUnit(this.nukeType, this.src, { targetTile: this.dst, trajectory: this.getTrajectory(this.dst), diff --git a/src/core/execution/nation/NationNukeBehavior.ts b/src/core/execution/nation/NationNukeBehavior.ts index 643e0c42c4..dca40bb889 100644 --- a/src/core/execution/nation/NationNukeBehavior.ts +++ b/src/core/execution/nation/NationNukeBehavior.ts @@ -13,7 +13,6 @@ import { } from "../../game/Game"; import { TileRef, euclDistFN } from "../../game/GameMap"; import { UniversalPathFinding } from "../../pathfinding/PathFinder"; -import { clearParabolaDirection } from "../../pathfinding/PathFinder.Parabola"; import { PseudoRandom } from "../../PseudoRandom"; import { assertNever, boundingBoxTiles } from "../../Util"; import { NukeExecution } from "../NukeExecution"; @@ -628,21 +627,6 @@ export class NationNukeBehavior { return false; } - /** - * Check if the parabolic nuke trajectory from spawnTile to targetTile - * crosses impassable terrain on BOTH curve directions. Mirrors - * NukeExecution, which flips to the opposite curve when the requested one - * is blocked and aborts only when both are. - */ - private isTrajectoryBlockedByImpassable( - spawnTile: TileRef, - targetTile: TileRef, - ): boolean { - return ( - clearParabolaDirection(this.game, spawnTile, targetTile, true) === null - ); - } - private isValidNukeTile(t: TileRef, nukeTarget: Player | null): boolean { const difficulty = this.game.config().gameConfig().difficulty; @@ -871,10 +855,6 @@ export class NationNukeBehavior { }); const trajectory = pathFinder.findPath(silo.tile(), targetTile) ?? []; if (trajectory.length === 0) continue; - // Skip silos whose trajectory crosses impassable terrain — the - // simulation would abort these launches (see NukeExecution). - if (this.isTrajectoryBlockedByImpassable(silo.tile(), targetTile)) - continue; allAvailableSilos.push({ silo, slots: availableSlots, @@ -1064,8 +1044,7 @@ export class NationNukeBehavior { // First pass: find silos with an unblocked trajectory to the failed // target. Only these contribute slots to the overwhelm plan. - // "Unblocked" means not interceptable by non-covering enemy SAMs AND - // not crossing impassable terrain (the sim aborts those launches). + // "Unblocked" means not interceptable by non-covering enemy SAMs. const unblockedSilos: Unit[] = []; for (const silo of silos) { if ( @@ -1073,10 +1052,6 @@ export class NationNukeBehavior { silo.tile(), failedTarget.targetTile, failedTarget.coveringSamIds, - ) && - !this.isTrajectoryBlockedByImpassable( - silo.tile(), - failedTarget.targetTile, ) ) { unblockedSilos.push(silo); diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index b244d71307..a0a9d94b88 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -8,7 +8,6 @@ import { toInt, within, } from "../Util"; -import { clearParabolaDirection } from "../pathfinding/PathFinder.Parabola"; import { AttackImpl } from "./AttackImpl"; import { Alliance, @@ -1473,21 +1472,7 @@ export class PlayerImpl implements Player { (a, b) => mg.manhattanDist(a.tile(), tile) - mg.manhattanDist(b.tile(), tile), ); - - if (nukeType === UnitType.MIRV) { - // MIRVs fly to a separation point high above the map and their - // warheads are exempt from impassable checks, so any silo works. - return readySilos[0]?.tile() ?? false; - } - - // Closest silo whose trajectory (up or down curve) avoids impassable - // terrain. NukeExecution picks the actual curve direction. - for (const silo of readySilos) { - if (clearParabolaDirection(mg, silo.tile(), tile, true) !== null) { - return silo.tile(); - } - } - return false; + return readySilos[0]?.tile() ?? false; } portSpawn(tile: TileRef, validTiles: TileRef[] | null): TileRef | false { diff --git a/src/core/pathfinding/PathFinder.Parabola.ts b/src/core/pathfinding/PathFinder.Parabola.ts index 2eb2a8e2b0..83a6de28aa 100644 --- a/src/core/pathfinding/PathFinder.Parabola.ts +++ b/src/core/pathfinding/PathFinder.Parabola.ts @@ -11,55 +11,6 @@ export interface ParabolaOptions { const PARABOLA_MIN_HEIGHT = 50; -// Fine sampling increment for impassable checks — independent of nuke speed -// so that a fast nuke cannot "skip over" a thin impassable strip that a slow -// one would hit. -const BLOCKED_CHECK_INCREMENT = 1; - -/** - * True if the parabola from `from` to `to` crosses impassable terrain. - * Uses the same curve construction as nuke flight but samples it finely, - * so the result does not depend on the nuke's speed. - */ -export function isParabolaBlocked( - gameMap: GameMap, - from: TileRef, - to: TileRef, - directionUp: boolean, -): boolean { - const finder = new ParabolaUniversalPathFinder(gameMap, { - increment: BLOCKED_CHECK_INCREMENT, - directionUp, - }); - const path = finder.findPath(from, to) ?? []; - for (const tile of path) { - if (gameMap.isImpassable(tile)) { - return true; - } - } - return false; -} - -/** - * Pick a curve direction whose parabola avoids impassable terrain: - * the preferred direction if clear, otherwise the opposite direction if - * clear, otherwise null (no clear path). - */ -export function clearParabolaDirection( - gameMap: GameMap, - from: TileRef, - to: TileRef, - preferUp: boolean, -): boolean | null { - if (!isParabolaBlocked(gameMap, from, to, preferUp)) { - return preferUp; - } - if (!isParabolaBlocked(gameMap, from, to, !preferUp)) { - return !preferUp; - } - return null; -} - export class ParabolaUniversalPathFinder implements SteppingPathFinder { private curve: DistanceBasedBezierCurve | null = null; private lastTo: TileRef | null = null; diff --git a/tests/ImpassableTerrain.test.ts b/tests/ImpassableTerrain.test.ts index 143ca0b191..f44ad82df8 100644 --- a/tests/ImpassableTerrain.test.ts +++ b/tests/ImpassableTerrain.test.ts @@ -2,7 +2,6 @@ import { encodeTerrainTile } from "../src/client/render/gl/utils/ColorUtils"; import { AttackExecution } from "../src/core/execution/AttackExecution"; import { NationAllianceBehavior } from "../src/core/execution/nation/NationAllianceBehavior"; import { NationEmojiBehavior } from "../src/core/execution/nation/NationEmojiBehavior"; -import { NationNukeBehavior } from "../src/core/execution/nation/NationNukeBehavior"; import { NukeExecution } from "../src/core/execution/NukeExecution"; import { AiAttackBehavior } from "../src/core/execution/utils/AiAttackBehavior"; import { @@ -44,20 +43,13 @@ function buildTerrain( height: number, wallX: number, wallWidth: number, - wallYMin = 0, - wallYMax = height, ): { data: Uint8Array; numLandTiles: number } { const data = new Uint8Array(width * height); let numLandTiles = 0; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = y * width + x; - if ( - x >= wallX && - x < wallX + wallWidth && - y >= wallYMin && - y < wallYMax - ) { + if (x >= wallX && x < wallX + wallWidth) { data[idx] = IMPASSABLE; // Impassable tiles are NOT counted as land tiles. } else { @@ -69,30 +61,11 @@ function buildTerrain( return { data, numLandTiles }; } -async function setupImpassableGame( - humans: PlayerInfo[] = [], - opts?: { wallYMin?: number; wallYMax?: number }, -): Promise { +async function setupImpassableGame(humans: PlayerInfo[] = []): Promise { vi.spyOn(console, "debug").mockImplementation(() => {}); - const wallYMin = opts?.wallYMin ?? 0; - const wallYMax = opts?.wallYMax ?? MAP_H; - const full = buildTerrain( - MAP_W, - MAP_H, - WALL_X, - WALL_WIDTH, - wallYMin, - wallYMax, - ); - const mini = buildTerrain( - MINI_W, - MINI_H, - Math.floor(WALL_X / 2), - 1, - Math.floor(wallYMin / 2), - Math.ceil(wallYMax / 2), - ); + const full = buildTerrain(MAP_W, MAP_H, WALL_X, WALL_WIDTH); + const mini = buildTerrain(MINI_W, MINI_H, Math.floor(WALL_X / 2), 1); const gameMap = await genTerrainFromBin( { width: MAP_W, height: MAP_H, num_land_tiles: full.numLandTiles }, @@ -264,18 +237,18 @@ describe("Impassable Terrain", () => { // ── Nukes: trajectory ───────────────────────────────────────────────── - test("nuke trajectory blocked by impassable terrain", () => { + test("nuke flies over impassable terrain and detonates", () => { player.conquer(game.ref(20, 100)); player.buildUnit(UnitType.MissileSilo, game.ref(20, 100), {}); - // Target is on the right side of the wall — trajectory must cross it. + // Target is on the right side of the wall — trajectory crosses it. const target = game.ref(150, 100); expect(game.isImpassable(target)).toBe(false); const nuke = new NukeExecution(UnitType.AtomBomb, player, target); game.addExecution(nuke); - executeTicks(game, 10); - // Should have been blocked. - expect(nuke.isActive()).toBe(false); + executeTicks(game, 30); + expect(nuke.getNuke()).not.toBeNull(); + expect(nuke.getNuke()!.reachedTarget()).toBe(true); }); test("nuke can launch when trajectory does not cross impassable terrain", () => { @@ -292,7 +265,7 @@ describe("Impassable Terrain", () => { expect(nuke.isActive()).toBe(false); }); - test("MIRV warhead not blocked by impassable terrain", () => { + test("MIRV warhead flies over impassable terrain", () => { player.conquer(game.ref(20, 100)); player.buildUnit(UnitType.MissileSilo, game.ref(20, 100), {}); // Target is on the right side of the wall — trajectory must cross it. @@ -419,184 +392,39 @@ describe("Impassable Terrain", () => { }); }); - // ── Nation AI: nuke trajectory over impassable terrain ─────────────── - - describe("NationNukeBehavior trajectory over impassable terrain", () => { - let nukePlayer: Player; - - beforeEach(() => { - nukePlayer = game.player("player_id"); - (game.config() as TestConfig).infiniteGold = () => true; - (game.config() as TestConfig).instantBuild = () => true; - (game.config() as TestConfig).nukeMagnitudes = vi.fn(() => ({ - inner: 5, - outer: 5, - })); - (game.config() as TestConfig).nukeAllianceBreakThreshold = vi.fn( - () => 999, - ); - (game.config() as TestConfig).setDefaultNukeSpeed(50); - }); - - test("NationNukeBehavior skips nuke targets whose trajectory crosses impassable terrain", () => { - // Build a silo on the left side of the wall. - nukePlayer.conquer(game.ref(20, 100)); - nukePlayer.buildUnit(UnitType.MissileSilo, game.ref(20, 100), {}); - - // Enemy owns tiles on the RIGHT side of the wall — trajectory must - // cross the impassable wall. - const enemy = game.player("other_id"); - enemy.conquer(game.ref(150, 100)); - - // Build a NationNukeBehavior and call maybeSendNuke. - const emojiBehavior = new NationEmojiBehavior( - new PseudoRandom(42), - game, - nukePlayer, - ); - const allianceBehavior = new NationAllianceBehavior( - new PseudoRandom(42), - game, - nukePlayer, - emojiBehavior, - ); - const attackBehavior = new AiAttackBehavior( - new PseudoRandom(42), - game, - nukePlayer, - 0.0, - 0.0, - 0.0, - allianceBehavior, - emojiBehavior, - ); - const nukeBehavior = new NationNukeBehavior( - new PseudoRandom(42), - game, - nukePlayer, - attackBehavior, - emojiBehavior, - ); - - // Set the enemy as a hostile target so the nuke behavior considers them. - nukePlayer.updateRelation(enemy, -100); - - // Run maybeSendNuke — it should NOT launch a nuke because the - // trajectory crosses impassable terrain. - nukeBehavior.maybeSendNuke(); - - // No nukes should have been launched. - const nukes = nukePlayer.units(UnitType.AtomBomb, UnitType.HydrogenBomb); - expect(nukes.length).toBe(0); - }); - }); - - // ── Nukes: silo selection & curve direction ─────────────────────────── - - describe("silo selection and curve direction around impassable terrain", () => { - async function setupSiloGame(opts?: { - wallYMin?: number; - wallYMax?: number; - }): Promise<{ g: Game; p: Player; o: Player }> { - const g = await setupImpassableGame( - [ - new PlayerInfo("player", PlayerType.Human, "c1", "player_id"), - new PlayerInfo("other", PlayerType.Human, "c2", "other_id"), - ], - opts, - ); - (g.config() as TestConfig).nukeMagnitudes = vi.fn(() => ({ - inner: 5, - outer: 5, - })); - (g.config() as TestConfig).nukeAllianceBreakThreshold = vi.fn(() => 999); - (g.config() as TestConfig).setDefaultNukeSpeed(50); - return { g, p: g.player("player_id"), o: g.player("other_id") }; - } + // ── Nukes: silo selection ───────────────────────────────────────────── + describe("silo selection with impassable terrain", () => { function buildSilo(g: Game, p: Player, x: number, y: number) { p.conquer(g.ref(x, y)); p.buildUnit(UnitType.MissileSilo, g.ref(x, y), {}); } - test("closest silo with blocked trajectory is skipped for a farther clear silo", async () => { - const { g, p } = await setupSiloGame(); - // 60 tiles from the target but on the other side of the wall — - // both curves are blocked. - buildSilo(g, p, 90, 100); - // 70 tiles from the target, same side — clear. - buildSilo(g, p, 150, 30); - - const target = g.ref(150, 100); - expect(p.canBuild(UnitType.AtomBomb, target)).toBe(g.ref(150, 30)); + test("closest silo is chosen even when the trajectory crosses impassable terrain", () => { + // 60 tiles from the target but on the other side of the wall. + buildSilo(game, player, 90, 100); + // 70 tiles from the target, same side. + buildSilo(game, player, 150, 30); - const nuke = new NukeExecution(UnitType.AtomBomb, p, target); - g.addExecution(nuke); - executeTicks(g, 30); - expect(nuke.getNuke()).not.toBeNull(); - expect(nuke.getNuke()!.reachedTarget()).toBe(true); - }); + const target = game.ref(150, 100); + expect(player.canBuild(UnitType.AtomBomb, target)).toBe( + game.ref(90, 100), + ); - test("up curve blocked by impassable terrain flips to the down curve", async () => { - // Wall only spans y < 150: the up curve (arcing toward y=0) crosses - // it, the down curve passes underneath. - const { g, p } = await setupSiloGame({ wallYMax: 150 }); - buildSilo(g, p, 50, 150); - const target = g.ref(150, 150); - expect(p.canBuild(UnitType.AtomBomb, target)).toBe(g.ref(50, 150)); - - // Requests the up curve (default). - const nuke = new NukeExecution(UnitType.AtomBomb, p, target); - g.addExecution(nuke); - executeTicks(g, 30); + const nuke = new NukeExecution(UnitType.AtomBomb, player, target); + game.addExecution(nuke); + executeTicks(game, 30); expect(nuke.getNuke()).not.toBeNull(); expect(nuke.getNuke()!.reachedTarget()).toBe(true); }); - test("down curve blocked by impassable terrain flips to the up curve", async () => { - // Wall only spans y >= 50: the down curve (arcing toward y=199) - // crosses it, the up curve passes above. - const { g, p } = await setupSiloGame({ wallYMin: 50 }); - buildSilo(g, p, 50, 30); - const target = g.ref(150, 30); - expect(p.canBuild(UnitType.AtomBomb, target)).toBe(g.ref(50, 30)); - - // Requests the down curve. - const nuke = new NukeExecution( - UnitType.AtomBomb, - p, - target, - null, - -1, - 0, - false, + test("MIRV silo selection ignores impassable terrain", () => { + buildSilo(game, player, 20, 100); + // MIRV targets must be owned. + other.conquer(game.ref(150, 100)); + expect(player.canBuild(UnitType.MIRV, game.ref(150, 100))).toBe( + game.ref(20, 100), ); - g.addExecution(nuke); - executeTicks(g, 30); - expect(nuke.getNuke()).not.toBeNull(); - expect(nuke.getNuke()!.reachedTarget()).toBe(true); - }); - - test("canBuild returns false when both curves from every silo are blocked", async () => { - const { g, p } = await setupSiloGame(); - buildSilo(g, p, 20, 100); - const target = g.ref(150, 100); - expect(p.canBuild(UnitType.AtomBomb, target)).toBe(false); - - const nuke = new NukeExecution(UnitType.AtomBomb, p, target); - g.addExecution(nuke); - executeTicks(g, 5); - expect(nuke.isActive()).toBe(false); - expect(nuke.getNuke()).toBeNull(); - }); - - test("MIRV silo selection ignores impassable trajectories", async () => { - const { g, p, o } = await setupSiloGame(); - buildSilo(g, p, 20, 100); - // MIRV targets must be owned; the warheads are exempt from - // impassable checks, so the blocked silo is still chosen. - o.conquer(g.ref(150, 100)); - expect(p.canBuild(UnitType.MIRV, g.ref(150, 100))).toBe(g.ref(20, 100)); }); }); }); diff --git a/tests/NukeTrajectory.test.ts b/tests/NukeTrajectory.test.ts index 332d136c28..82ce0a5b4a 100644 --- a/tests/NukeTrajectory.test.ts +++ b/tests/NukeTrajectory.test.ts @@ -2,7 +2,6 @@ import { buildNukeTrajectory, computeNukeControlPoints, computeTrajectoryThresholds, - type SAMInfo, } from "../src/client/render/gl/utils/NukeTrajectory"; // A large map height so the parabola arc isn't clamped. @@ -13,119 +12,21 @@ function horizontalCp(srcX: number, dstX: number) { return computeNukeControlPoints(srcX, 500, dstX, 500, MAP_H, true); } -describe("NukeTrajectory impassable terrain blocking", () => { - test("tSamIntercept is 1.0 when no SAMs and no blocked terrain", () => { +describe("NukeTrajectory thresholds", () => { + test("tSamIntercept is 1.0 when no SAMs", () => { const cp = horizontalCp(100, 800); const th = computeTrajectoryThresholds(cp, 100, 500, 800, 500, []); expect(th.tSamIntercept).toBe(1.0); }); - test("tSamIntercept < 1.0 when trajectory crosses impassable terrain", () => { - const cp = horizontalCp(100, 800); - // Block tiles at x=400..500 (midway through the arc). - const isBlocked = (x: number) => x >= 400 && x <= 500; - const th = computeTrajectoryThresholds( - cp, - 100, - 500, - 800, - 500, - [], - isBlocked, - ); - expect(th.tSamIntercept).toBeLessThan(1.0); - // The block is roughly at the midpoint of the curve (t ≈ 0.5). - expect(th.tSamIntercept).toBeGreaterThan(0.3); - expect(th.tSamIntercept).toBeLessThan(0.7); - }); - - test("tSamIntercept is 1.0 when blocked terrain is not on the trajectory", () => { - const cp = horizontalCp(100, 800); - // Block tiles far away from the trajectory. - const isBlocked = (x: number) => x >= 0 && x <= 50; - const th = computeTrajectoryThresholds( - cp, - 100, - 500, - 800, - 500, - [], - isBlocked, - ); - // The source is at x=100, so blocking x=0..50 shouldn't affect the arc. - // (The arc starts at x=100 and goes to x=800, it never touches x<100.) - expect(th.tSamIntercept).toBe(1.0); - }); - - test("blocked terrain takes precedence (min of SAM and blocked)", () => { - const cp = horizontalCp(100, 800); - // SAM at x=600 with range covering a wide area. - const sams: SAMInfo[] = [{ x: 600, y: 500, rangeSq: 200 * 200 }]; - // Block at x=300 (earlier than the SAM at x=600). - const isBlocked = (x: number) => x >= 300 && x <= 350; - const th = computeTrajectoryThresholds( - cp, - 100, - 500, - 800, - 500, - sams, - isBlocked, - ); - // The block at x=300 should be hit first (lower t) than the SAM at x=600. - expect(th.tSamIntercept).toBeLessThan(0.5); - }); - - test("blocked scan covers the untargetable mid-air zone (not skipped like SAMs)", () => { - // With a long trajectory, there's an untargetable zone in the middle. + test("long trajectory has an untargetable mid-air zone", () => { const cp = horizontalCp(100, 800); const th = computeTrajectoryThresholds(cp, 100, 500, 800, 500, []); - // Verify there IS an untargetable zone. expect(th.tUntargetableStart).toBeGreaterThanOrEqual(0); expect(th.tUntargetableEnd).toBeGreaterThan(th.tUntargetableStart); - - // Block a tile in the middle of the untargetable zone. - const blockT = (th.tUntargetableStart + th.tUntargetableEnd) / 2; - // Sample the Bezier at that t to find the x coordinate. - const { p0x, p1x, p2x, p3x } = cp; - const T = 1 - blockT; - const blockX = Math.floor( - T * T * T * p0x + - 3 * T * T * blockT * p1x + - 3 * T * blockT * blockT * p2x + - blockT * blockT * blockT * p3x, - ); - const isBlocked = (x: number) => x === blockX; - - const th2 = computeTrajectoryThresholds( - cp, - 100, - 500, - 800, - 500, - [], - isBlocked, - ); - // The blocked tile is in the untargetable zone, but unlike SAMs, the - // impassable scan should still detect it. - expect(th2.tSamIntercept).toBeLessThan(1.0); - }); - - test("buildNukeTrajectory passes isBlocked through", () => { - const data = buildNukeTrajectory( - 100, - 500, - 800, - 500, - MAP_H, - true, - [], - (x: number) => x >= 400 && x <= 500, - ); - expect(data.tSamIntercept).toBeLessThan(1.0); }); - test("buildNukeTrajectory works without isBlocked (backwards compatible)", () => { + test("buildNukeTrajectory combines control points and thresholds", () => { const data = buildNukeTrajectory(100, 500, 800, 500, MAP_H, true, []); expect(data.tSamIntercept).toBe(1.0); expect(data.p0x).toBe(100);