Files
OpenFrontIO/tests/NukeTrajectory.test.ts
T
FloPinguin 805f0968b1 Add impassable terrain 🗺️ (#4340)
## Description:

Relates to #3725

Adds a new **Impassable** terrain type that enables non-rectangular maps
and creates impassable barriers on the map. Painted with pure black
(`#000`) in the map editor's `image.png`.

**Encoding:** Impassable terrain is encoded in the binary format as
`isLand=1, magnitude=31` (previously unused). The Go map generator
detects `#000` pixels and produces this encoding. The map generator's
minimap downscaling gives impassable highest priority (Impassable >
Water > Land). Thumbnails render impassable as transparent so the map
picker background shows through.

**Rendering:** Impassable tiles render as the map background colour
(`rgb(60, 60, 60)`, matching `gl.clearColor` in `Renderer.ts`), making
them visually indistinguishable from the area outside the map quad. This
enables maps to appear non-rectangular.

**Gameplay restrictions:** Impassable terrain cannot be:
- Owned (`conquer()` throws)
- Attacked (`AttackExecution` skips impassable tiles in both `tick()`
and `addNeighbors()`)
- Nuked (targeting rejected in `nukeSpawn()`, blast radius filtered in
`tilesToDestroy()`)
- Spawned on (nations, human players, and structures all reject
impassable tiles)
- Converted to water (guarded in `WaterManager` and `setWater()`)

**Nuke trajectories:** Nuke trajectories cannot cross impassable
terrain, matching the existing map-border enforcement. This is checked
at launch time in `NukeExecution.tick()`. The client-side trajectory
preview turns red with a red X where the arc crosses impassable terrain
(reusing the existing SAM-intercept visual pipeline in
`NukeTrajectory.ts`). The nuke ghost preview is completely hidden when
hovering over impassable terrain (same as hovering outside the map).


https://github.com/user-attachments/assets/ff131146-9749-41e0-892a-617e5cd16c54

Impassable terrain is transparent on the thumbnail:

<img width="213" height="152" alt="Screenshot 2026-06-18 211640"
src="https://github.com/user-attachments/assets/ede16f8c-9239-4ab1-be5d-0ba81cce5e9e"
/>

Tested with water nukes, made sure there is no water depth gradient near
the impassable terrain, just like at the world border:

<img width="774" height="771" alt="Screenshot 2026-06-18 212348"
src="https://github.com/user-attachments/assets/4429069d-911b-48e8-91e3-7307d42c9397"
/>

Models used: GLM 5.2 and MiMo 2.5 Pro 😄

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FloPinguin
2026-06-19 14:54:09 -07:00

135 lines
4.2 KiB
TypeScript

import {
buildNukeTrajectory,
computeNukeControlPoints,
computeTrajectoryThresholds,
type SAMInfo,
} from "../src/client/render/gl/utils/NukeTrajectory";
// A large map height so the parabola arc isn't clamped.
const MAP_H = 1000;
// Helper: build control points for a straight horizontal trajectory.
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", () => {
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.
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)", () => {
const data = buildNukeTrajectory(100, 500, 800, 500, MAP_H, true, []);
expect(data.tSamIntercept).toBe(1.0);
expect(data.p0x).toBe(100);
expect(data.p3x).toBe(800);
});
});