feat: add impassable terrain for non-rectangular maps

This commit is contained in:
FloPinguin
2026-06-18 19:56:30 +02:00
parent ca5342d6bf
commit dbd4beef11
20 changed files with 906 additions and 38 deletions
+10
View File
@@ -86,6 +86,16 @@ If you are doing work in image editing software or using automated tools, `./map
- `Pixel` -> `Terrain Type & Magnitude` mapping in `GenerateMap`
- `Terrain Type` -> `Thumbnail Color` mapping in `getThumbnailColor`
### Impassable Terrain
Pure black pixels (`#000000` / `rgb(0, 0, 0)` with alpha ≥ 20) are encoded as **impassable terrain**. This is a solid, static void that:
- Cannot be owned, attacked, or nuked.
- Nuke trajectories cannot pass over it (just as they cannot leave the map border).
- Renders as the map background colour, making the map appear non-rectangular.
Use impassable terrain to carve out non-rectangular map shapes or to create barriers that divide regions without water.
In-Game, terrain is rendered using themes. The color of a tile is determined dynamically based on
its **Terrain Type** and **Magnitude**. Theme Files:
+61 -19
View File
@@ -42,6 +42,7 @@ type TerrainType uint8
const (
Land TerrainType = iota
Water
Impassable
)
// Terrain represents the properties of a single map tile.
@@ -90,15 +91,20 @@ type GeneratorArgs struct {
// For Water tiles, "Magnitude" is calculated during generation as the distance to the nearest land.
//
// Pixel -> Terrain & Magnitude mapping
// | Input Condition | Terrain Type | Magnitude | Notes |
// | :----------------- | :-------------- | :----------------- | :------------------------------- |
// | **Alpha < 20** | Water | Distance to Land\* | Transparent pixels become water. |
// | **Blue = 106** | Water | Distance to Land\* | Specific key color for water. |
// | **Blue < 140** | Land (Plains) | 0 | Clamped to minimum magnitude. |
// | **Blue 140 - 158** | Land (Plains) | 0 - 9 | |
// | **Blue 159 - 178** | Land (Highland) | 10 - 19 | |
// | **Blue 179 - 200** | Land (Mountain) | 20 - 30 | |
// | **Blue > 200** | Land (Mountain) | 30 | Clamped to maximum magnitude. |
// | Input Condition | Terrain Type | Magnitude | Notes |
// | :----------------- | :--------------- | :----------------- | :------------------------------- |
// | **Alpha < 20** | Water | Distance to Land\* | Transparent pixels become water. |
// | **Blue = 106** | Water | Distance to Land\* | Specific key color for water. |
// | **#000 (black)** | Impassable | 31 (fixed) | Solid void; cannot be owned/attacked/nuked. |
// | **Blue < 140** | Land (Plains) | 0 | Clamped to minimum magnitude. |
// | **Blue 140 - 158** | Land (Plains) | 0 - 9 | |
// | **Blue 159 - 178** | Land (Highland) | 10 - 19 | |
// | **Blue 179 - 200** | Land (Mountain) | 20 - 30 | |
// | **Blue > 200** | Land (Mountain) | 30 | Clamped to maximum magnitude. |
//
// Impassable terrain is encoded in the binary format as isLand=1 + magnitude=31.
// It renders as the map background colour (making the map appear non-rectangular)
// and cannot be owned, attacked, or nuked. Nuke trajectories cannot cross it.
//
// Misc Notes
// - It normalizes map width/height to multiples of 4 for the mini map downscaling.
@@ -132,14 +138,19 @@ func GenerateMap(ctx context.Context, args GeneratorArgs) (MapResult, error) {
// Process each pixel
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
_, _, b, a := img.At(x, y).RGBA()
r, g, b, a := img.At(x, y).RGBA()
// Convert from 16-bit to 8-bit values
alpha := uint8(a >> 8)
red := uint8(r >> 8)
green := uint8(g >> 8)
blue := uint8(b >> 8)
alpha := uint8(a >> 8)
if alpha < 20 || blue == 106 {
// Transparent or specific blue value = water
terrain[x][y] = Terrain{Type: Water}
} else if red == 0 && green == 0 && blue == 0 {
// Pure black (#000) = impassable terrain
terrain[x][y] = Terrain{Type: Impassable}
} else {
// Land
terrain[x][y] = Terrain{Type: Land}
@@ -239,8 +250,9 @@ func convertToWebP(thumb ThumbData) ([]byte, error) {
// createMiniMap downscales the terrain grid by half.
// It maps 2x2 blocks of input tiles to a single output tile.
// The logic prioritizes Water: if any of the 4 source tiles is Water,
// the resulting mini-map tile becomes Water.
// Priority: Impassable > Water > Land. If any of the 4 source tiles is
// Impassable, the result is Impassable; else if any is Water, the result is
// Water; otherwise the last Land tile wins.
func createMiniMap(tm [][]Terrain) [][]Terrain {
width := len(tm)
height := len(tm[0])
@@ -258,12 +270,24 @@ func createMiniMap(tm [][]Terrain) [][]Terrain {
miniX := x / 2
miniY := y / 2
if miniX < miniWidth && miniY < miniHeight {
// If any of the 4 tiles has water, mini tile is water
if miniMap[miniX][miniY].Type != Water {
miniMap[miniX][miniY] = tm[x][y]
}
if miniX >= miniWidth || miniY >= miniHeight {
continue
}
src := tm[x][y]
dst := &miniMap[miniX][miniY]
// Impassable wins over everything; once set, keep it.
if dst.Type == Impassable {
continue
}
if src.Type == Impassable {
*dst = src
continue
}
// Water wins over land; once set to water, keep it.
if dst.Type == Water {
continue
}
*dst = src
}
}
@@ -296,7 +320,7 @@ func processShore(ctx context.Context, terrain [][]Terrain) []Coord {
break
}
}
} else {
} else if tile.Type == Water {
// Water tile adjacent to land is shoreline
for _, c := range buf[:n] {
if terrain[c.X][c.Y].Type == Land {
@@ -306,6 +330,7 @@ func processShore(ctx context.Context, terrain [][]Terrain) []Coord {
}
}
}
// Impassable tiles: never shoreline (renders as background, no outline)
}
}
@@ -567,6 +592,9 @@ func removeSmallIslands(ctx context.Context, terrain [][]Terrain, minSize int, r
// - Bit 5: Ocean
// - Bits 0-4: Magnitude (0-31). For Water, this is (Distance / 2).
//
// Impassable tiles are encoded as 0b10011111 (isLand=1, magnitude=31) and are
// NOT counted in numLandTiles (they cannot be owned/attacked/nuked).
//
// Returns the packed data and the count of land tiles.
func packTerrain(ctx context.Context, terrain [][]Terrain) (data []byte, numLandTiles int) {
width := len(terrain)
@@ -577,6 +605,14 @@ func packTerrain(ctx context.Context, terrain [][]Terrain) (data []byte, numLand
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
tile := terrain[x][y]
if tile.Type == Impassable {
// Impassable: isLand=1, magnitude=31, no shoreline, no ocean.
// Not counted as a land tile (can't be owned/attacked/nuked).
packedData[y*width+x] = 0b10011111
continue
}
var packedByte byte = 0
if tile.Type == Land {
@@ -652,6 +688,9 @@ type RGBA struct {
// color schemes.
//
// For thumbnail purposes, the terrain type -> color mapping:
// - Impassable: (Transparent) — renders as the map background in-game, so
// the thumbnail matches by being transparent (the map picker background
// shows through).
// - Water Shoreline: (Transparent)
// - Deep Water: (Transparent)
// - Land Shoreline: `rgb(204, 203, 158)`
@@ -659,6 +698,9 @@ type RGBA struct {
// - Highlands (Mag 10-19): `rgb(220, 203, 158)` - `rgb(238, 221, 176)`
// - Mountains (Mag >= 20): `rgb(240, 240, 240)` - `rgb(245, 245, 245)`
func getThumbnailColor(t Terrain) RGBA {
if t.Type == Impassable {
return RGBA{R: 0, G: 0, B: 0, A: 0}
}
if t.Type == Water {
// Shoreline water
if t.Shoreline {
@@ -72,13 +72,15 @@ export class BuildPreviewController implements Controller {
private lastGhostData: GhostPreviewData | null = null;
// Static inputs for the nuke trajectory preview (source silo + threatening
// 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.
// 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.
private nukeTrajectoryStatic: {
srcX: number;
srcY: number;
sams: SAMInfo[];
isBlocked: (x: number, y: number) => boolean;
} | null = null;
constructor(
@@ -143,6 +145,7 @@ export class BuildPreviewController implements Controller {
this.game.height(),
this.uiState.rocketDirectionUp,
traj.sams,
traj.isBlocked,
),
);
}
@@ -190,6 +193,11 @@ export class BuildPreviewController implements Controller {
);
if (this.game.isValidCoord(tile.x, tile.y)) {
tileRef = this.game.ref(tile.x, tile.y);
// Impassable terrain is a void — treat hovering over it the same as
// hovering outside the map (no ghost, no trajectory, no blast circle).
if (this.game.isImpassable(tileRef)) {
tileRef = undefined;
}
}
// Check if targeting an ally (for nuke warning visual)
@@ -364,7 +372,18 @@ 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.
this.nukeTrajectoryStatic = { srcX, srcY, sams };
// 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,
sams,
isBlocked: (x: number, y: number) => {
if (!this.game.isValidCoord(x, y)) return false;
return this.game.isImpassable(this.game.ref(x, y));
},
};
}
private clearNukeTrajectory(): void {
+13 -1
View File
@@ -45,6 +45,11 @@ const DEEP_WATER_BASE: readonly [number, number, number] = hexToRgb(
* bit 6: isShoreline
* bit 5: isOcean (water only)
* bits 0-4: magnitude (0-31)
*
* Impassable terrain is encoded as isLand=1 + magnitude=31. It renders as
* the map background colour (matching `gl.clearColor` in Renderer.ts) so the
* map appears non-rectangular — the impassable regions are visually
* indistinguishable from the area outside the map.
*/
/** Encode one terrain byte → RGBA, writing into `out[offset..offset+3]`. */
export function encodeTerrainTile(
@@ -59,7 +64,14 @@ export function encodeTerrainTile(
let r: number, g: number, b: number;
if (isLand && isShoreline) {
// Impassable terrain: render as the map background colour so it blends
// with the area outside the map quad. Must match the clear colour in
// Renderer.ts drawBaseLayer(): gl.clearColor(60/255, 60/255, 60/255).
if (isLand && magnitude === 31) {
r = 60;
g = 60;
b = 60;
} else if (isLand && isShoreline) {
// Shore (sand)
r = 204;
g = 203;
+76 -2
View File
@@ -125,10 +125,19 @@ function refineCrossing(
/**
* Sample the Bezier curve at regular t intervals and find color threshold
* t-values for untargetable zones and SAM intercept.
* t-values for untargetable zones, SAM intercept, and impassable terrain.
*
* 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: {
@@ -146,6 +155,7 @@ export function computeTrajectoryThresholds(
dstX: number,
dstY: number,
sams: readonly SAMInfo[],
isBlocked?: (x: number, y: number) => boolean,
): {
tUntargetableStart: number;
tUntargetableEnd: number;
@@ -154,6 +164,7 @@ export function computeTrajectoryThresholds(
let tUntargetableStart = -1;
let tUntargetableEnd = -1;
let tSamIntercept = 1.0;
let tBlocked = 1.0;
const dt = 1.0 / THRESHOLD_SAMPLES;
@@ -232,12 +243,66 @@ 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,
@@ -247,6 +312,7 @@ export function buildNukeTrajectory(
mapH: number,
directionUp: boolean,
sams: readonly SAMInfo[],
isBlocked?: (x: number, y: number) => boolean,
): NukeTrajectoryData {
const cp = computeNukeControlPoints(
srcX,
@@ -256,6 +322,14 @@ export function buildNukeTrajectory(
mapH,
directionUp,
);
const th = computeTrajectoryThresholds(cp, srcX, srcY, dstX, dstY, sams);
const th = computeTrajectoryThresholds(
cp,
srcX,
srcY,
dstX,
dstY,
sams,
isBlocked,
);
return { ...cp, ...th };
}
+3
View File
@@ -1112,6 +1112,9 @@ export class GameView implements GameMap {
isLand(ref: TileRef): boolean {
return this._map.isLand(ref);
}
isImpassable(ref: TileRef): boolean {
return this._map.isImpassable(ref);
}
isOceanShore(ref: TileRef): boolean {
return this._map.isOceanShore(ref);
}
+2
View File
@@ -588,6 +588,8 @@ export class Config {
mag = 120;
speed = 25;
break;
case TerrainType.Impassable:
throw new Error(`impassable terrain cannot be attacked`);
default:
throw new Error(`terrain type ${type} not supported`);
}
+5 -1
View File
@@ -296,7 +296,10 @@ export class AttackExecution implements Execution {
if (this.map.ownerID(tileToConquer) !== this.targetSmallID || !onBorder) {
continue;
}
if (!this.map.isLand(tileToConquer)) {
if (
!this.map.isLand(tileToConquer) ||
this.map.isImpassable(tileToConquer)
) {
continue;
}
this.addNeighbors(tileToConquer);
@@ -341,6 +344,7 @@ export class AttackExecution implements Execution {
const neighbor = this.nbuf[i];
if (
this.map.isWater(neighbor) ||
this.map.isImpassable(neighbor) ||
this.map.ownerID(neighbor) !== this.targetSmallID
) {
continue;
+5 -1
View File
@@ -274,7 +274,11 @@ export class NationExecution implements Execution {
continue;
}
const tile = this.mg.ref(x, y);
if (this.mg.isLand(tile) && !this.mg.hasOwner(tile)) {
if (
this.mg.isLand(tile) &&
!this.mg.hasOwner(tile) &&
!this.mg.isImpassable(tile)
) {
if (
this.mg.terrainType(tile) === TerrainType.Mountain &&
this.random.chance(2)
+19 -2
View File
@@ -107,14 +107,20 @@ export class NukeExecution implements Execution {
const threshold = radiiSq[i0] * (1 - frac) + radiiSq[i1] * frac;
if (d2 > threshold) continue;
}
result.add(this.mg.ref(px, py));
const tile = this.mg.ref(px, py);
if (this.mg.isImpassable(tile)) continue;
result.add(tile);
}
}
this.tilesToDestroyCache = result;
} else {
this.tilesToDestroyCache = this.mg.bfs(this.dst, (_, n: TileRef) => {
const d2 = this.mg?.euclideanDistSquared(this.dst, n) ?? 0;
return d2 <= outer2 && (d2 <= inner2 || rand.chance(2));
return (
d2 <= outer2 &&
(d2 <= inner2 || rand.chance(2)) &&
!this.mg.isImpassable(n)
);
});
}
return this.tilesToDestroyCache;
@@ -184,6 +190,17 @@ export class NukeExecution implements Execution {
return;
}
this.src = spawn;
// Nuke trajectories cannot pass over impassable terrain, just as they
// cannot exceed the map border. Check the full parabola path before
// launching; if any tile is impassable, abort the launch.
const path = this.pathFinder.findPath(spawn, this.dst) ?? [];
for (const tile of path) {
if (this.mg.isImpassable(tile)) {
console.warn(`nuke trajectory crosses impassable terrain`);
this.active = false;
return;
}
}
this.nuke = this.player.buildUnit(this.nukeType, spawn, {
targetTile: this.dst,
trajectory: this.getTrajectory(this.dst),
+2 -1
View File
@@ -143,7 +143,8 @@ export function getSpawnTiles(
): TileRef[] | null {
const spawnTiles = Array.from(gm.bfs(tile, euclDistFN(tile, 4, true)));
const isInvalid = (t: TileRef) => gm.hasOwner(t) || !gm.isLand(t);
const isInvalid = (t: TileRef) =>
gm.hasOwner(t) || !gm.isLand(t) || gm.isImpassable(t);
if (!requireAllValid) {
return spawnTiles.filter((t) => !isInvalid(t));
@@ -147,6 +147,12 @@ export class NationNukeBehavior {
continue;
}
// On all difficulties, avoid trajectories that cross impassable terrain
// (the simulation aborts such launches — see NukeExecution).
if (this.isTrajectoryBlockedByImpassable(spawnTile, tile)) {
continue;
}
const value = this.nukeTileScore(tile, silos, structures, nukeType);
if (value > bestValue) {
bestTile = tile;
@@ -627,6 +633,30 @@ export class NationNukeBehavior {
return false;
}
/**
* Check if the parabolic nuke trajectory from spawnTile to targetTile
* crosses any impassable terrain. Mirrors the check in NukeExecution that
* aborts such launches if we don't check here, the AI wastes gold on
* nukes that silently fail at launch time.
*/
private isTrajectoryBlockedByImpassable(
spawnTile: TileRef,
targetTile: TileRef,
): boolean {
const pathFinder = UniversalPathFinding.Parabola(this.game, {
increment: this.game.config().defaultNukeSpeed(),
distanceBasedHeight: true,
directionUp: true,
});
const path = pathFinder.findPath(spawnTile, targetTile) ?? [];
for (const tile of path) {
if (this.game.isImpassable(tile)) {
return true;
}
}
return false;
}
private isValidNukeTile(t: TileRef, nukeTarget: Player | null): boolean {
const difficulty = this.game.config().gameConfig().difficulty;
@@ -855,6 +885,10 @@ 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,
@@ -1044,6 +1078,8 @@ 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).
const unblockedSilos: Unit[] = [];
for (const silo of silos) {
if (
@@ -1051,6 +1087,10 @@ export class NationNukeBehavior {
silo.tile(),
failedTarget.targetTile,
failedTarget.coveringSamIds,
) &&
!this.isTrajectoryBlockedByImpassable(
silo.tile(),
failedTarget.targetTile,
)
) {
unblockedSilos.push(silo);
+10 -1
View File
@@ -57,6 +57,7 @@ export class AiAttackBehavior {
.filter(
(t) =>
this.game.isLand(t) &&
!this.game.isImpassable(t) &&
this.game.ownerID(t) !== this.player?.smallID(),
);
const playerNeighbors = this.player.nearby();
@@ -171,6 +172,9 @@ export class AiAttackBehavior {
if (!this.game.isLand(randTile)) {
continue;
}
if (this.game.isImpassable(randTile)) {
continue;
}
const owner = this.game.owner(randTile);
if (owner === this.player) {
continue;
@@ -764,7 +768,11 @@ export class AiAttackBehavior {
private hasLandBorderWithTerraNullius(): boolean {
for (const border of this.player.borderTiles()) {
for (const neighbor of this.game.neighbors(border)) {
if (this.game.isLand(neighbor) && !this.game.hasOwner(neighbor)) {
if (
this.game.isLand(neighbor) &&
!this.game.isImpassable(neighbor) &&
!this.game.hasOwner(neighbor)
) {
return true;
}
}
@@ -809,6 +817,7 @@ export class AiAttackBehavior {
if (!this.game.isValidCoord(nx, ny)) continue;
const tile = this.game.ref(nx, ny);
if (!this.game.isLand(tile)) continue;
if (this.game.isImpassable(tile)) continue;
if (this.game.hasOwner(tile)) continue;
if (this.game.hasFallout(tile)) continue;
if (!canBuildTransportShip(this.game, this.player, tile)) continue;
+1
View File
@@ -334,6 +334,7 @@ export enum TerrainType {
Highland,
Mountain,
Ocean,
Impassable,
}
export enum PlayerType {
+6
View File
@@ -694,6 +694,9 @@ export class GameImpl implements Game {
if (!this.isLand(tile)) {
throw Error(`cannot conquer water`);
}
if (this.isImpassable(tile)) {
throw Error(`cannot conquer impassable terrain`);
}
const previousOwner = this.owner(tile) as TerraNullius | PlayerImpl;
if (previousOwner.isPlayer()) {
previousOwner._lastTileChange = this._ticks;
@@ -1075,6 +1078,9 @@ export class GameImpl implements Game {
isLand(ref: TileRef): boolean {
return this._map.isLand(ref);
}
isImpassable(ref: TileRef): boolean {
return this._map.isImpassable(ref);
}
isOceanShore(ref: TileRef): boolean {
return this._map.isOceanShore(ref);
}
+17 -1
View File
@@ -15,6 +15,7 @@ export interface GameMap {
isValidCoord(x: number, y: number): boolean;
// Terrain getters
isLand(ref: TileRef): boolean;
isImpassable(ref: TileRef): boolean;
isOceanShore(ref: TileRef): boolean;
isOcean(ref: TileRef): boolean;
isShoreline(ref: TileRef): boolean;
@@ -118,6 +119,11 @@ export class GameMapImpl implements GameMap {
private static readonly SHORELINE_BIT = 6;
private static readonly OCEAN_BIT = 5;
private static readonly MAGNITUDE_MASK = 0x1f; // 11111 in binary
// Land tiles with magnitude == IMPASSABLE_MAGNITUDE are impassable terrain:
// solid ground that cannot be owned, attacked, or nuked, and that nuke
// trajectories cannot cross. Rendered as the map background colour so the
// map appears non-rectangular.
private static readonly IMPASSABLE_MAGNITUDE = 31;
// State bits (Uint16Array)
private static readonly PLAYER_ID_MASK = 0xfff;
@@ -207,6 +213,14 @@ export class GameMapImpl implements GameMap {
return Boolean(this.terrain[ref] & (1 << GameMapImpl.IS_LAND_BIT));
}
isImpassable(ref: TileRef): boolean {
return (
this.isLand(ref) &&
(this.terrain[ref] & GameMapImpl.MAGNITUDE_MASK) ===
GameMapImpl.IMPASSABLE_MAGNITUDE
);
}
isOceanShore(ref: TileRef): boolean {
if (!this.isLand(ref)) {
return false;
@@ -237,7 +251,7 @@ export class GameMapImpl implements GameMap {
}
setWater(ref: TileRef): void {
if (!this.isLand(ref)) return;
if (!this.isLand(ref) || this.isImpassable(ref)) return;
this.terrain[ref] = 0; // Lake water: no land, no ocean, no shoreline, magnitude 0
this.numLandTiles_--;
}
@@ -349,6 +363,8 @@ export class GameMapImpl implements GameMap {
terrainType(ref: TileRef): TerrainType {
if (this.isLand(ref)) {
const magnitude = this.magnitude(ref);
if (magnitude >= GameMapImpl.IMPASSABLE_MAGNITUDE)
return TerrainType.Impassable;
if (magnitude < 10) return TerrainType.Plains;
if (magnitude < 20) return TerrainType.Highland;
return TerrainType.Mountain;
+12 -4
View File
@@ -477,7 +477,10 @@ export class PlayerImpl implements Player {
const ns: Set<Player | TerraNullius> = new Set();
for (const border of this.borderTiles()) {
for (const neighbor of this.mg.map().neighbors(border)) {
if (this.mg.map().isLand(neighbor)) {
if (
this.mg.map().isLand(neighbor) &&
!this.mg.map().isImpassable(neighbor)
) {
const owner = this.mg.map().ownerID(neighbor);
if (owner !== this.smallID()) {
ns.add(
@@ -526,6 +529,7 @@ export class PlayerImpl implements Player {
if (!map.isValidCoord(nx, ny)) continue;
const tile = map.ref(nx, ny);
if (!map.isLand(tile)) continue;
if (map.isImpassable(tile)) continue;
if (!map.hasOwner(tile) && map.hasFallout(tile)) continue;
const owner = map.ownerID(tile);
if (owner !== this.smallID()) {
@@ -1380,6 +1384,10 @@ export class PlayerImpl implements Player {
if (mg.isSpawnImmunityActive()) {
return false;
}
// Impassable terrain cannot be nuked.
if (mg.isImpassable(tile)) {
return false;
}
const owner = this.mg.owner(tile);
// Allow nuking teammates after the game is over (aftergame fun)
const gameOver = mg.getWinner() !== null;
@@ -1463,7 +1471,7 @@ export class PlayerImpl implements Player {
}
landBasedUnitSpawn(tile: TileRef): TileRef | false {
return this.mg.isLand(tile) ? tile : false;
return this.mg.isLand(tile) && !this.mg.isImpassable(tile) ? tile : false;
}
landBasedStructureSpawn(
@@ -1620,7 +1628,7 @@ export class PlayerImpl implements Player {
return false;
}
if (!this.mg.isLand(tile)) {
if (!this.mg.isLand(tile) || this.mg.isImpassable(tile)) {
return false;
}
if (this.mg.hasOwner(tile)) {
@@ -1629,7 +1637,7 @@ export class PlayerImpl implements Player {
for (const t of this.mg.bfs(
tile,
andFN(
(gm, t) => !gm.hasOwner(t) && gm.isLand(t),
(gm, t) => !gm.hasOwner(t) && gm.isLand(t) && !gm.isImpassable(t),
manhattanDistFN(tile, 200),
),
)) {
+16 -1
View File
@@ -54,7 +54,11 @@ export class WaterManager {
const converted: TileRef[] = [];
for (const tile of this._pendingWaterTiles) {
// Tile may have been conquered between queueing and flushing
if (this.map.isLand(tile) && !this.map.hasOwner(tile)) {
if (
this.map.isLand(tile) &&
!this.map.hasOwner(tile) &&
!this.map.isImpassable(tile)
) {
if (this.map.hasFallout(tile)) {
this.map.setFallout(tile, false);
}
@@ -373,10 +377,21 @@ export class WaterManager {
}
}
for (const tile of tilesToCheck) {
// Impassable tiles never get shoreline — they render as the map
// background, so no sand/water outline should appear around them.
if (map.isImpassable(tile)) {
if (map.isShoreline(tile)) {
map.clearShorelineBit(tile);
changed.add(tile);
}
continue;
}
const tileIsLand = map.isLand(tile);
let hasOpposite = false;
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
// Impassable neighbors don't create shorelines (void, not coast).
if (map.isImpassable(nb[i])) continue;
if (map.isLand(nb[i]) !== tileIsLand) {
hasOpposite = true;
break;
+451
View File
@@ -0,0 +1,451 @@
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 {
Difficulty,
Game,
GameMapSize,
GameMapType,
GameMode,
GameType,
Player,
PlayerInfo,
PlayerType,
TerrainType,
UnitType,
} from "../src/core/game/Game";
import { createGame } from "../src/core/game/GameImpl";
import { genTerrainFromBin } from "../src/core/game/TerrainMapLoader";
import { UserSettings } from "../src/core/game/UserSettings";
import { PseudoRandom } from "../src/core/PseudoRandom";
import { GameConfig } from "../src/core/Schemas";
import { TestConfig } from "./util/TestConfig";
import { executeTicks } from "./util/utils";
// ─── Terrain byte constants (must match GameMapImpl) ────────────────────
const LAND_PLAINS = 0b10000000; // isLand=1, magnitude=0 (Plains)
const IMPASSABLE = 0b10011111; // isLand=1, magnitude=31 (Impassable)
const MAP_W = 200;
const MAP_H = 200;
const MINI_W = 100;
const MINI_H = 100;
// The impassable wall is a vertical strip at x = WALL_X.
const WALL_X = 100;
const WALL_WIDTH = 2;
function buildTerrain(
width: number,
height: number,
wallX: number,
wallWidth: number,
): { 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) {
data[idx] = IMPASSABLE;
// Impassable tiles are NOT counted as land tiles.
} else {
data[idx] = LAND_PLAINS;
numLandTiles++;
}
}
}
return { data, numLandTiles };
}
async function setupImpassableGame(humans: PlayerInfo[] = []): Promise<Game> {
console.debug = () => {};
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 },
full.data,
);
const miniGameMap = await genTerrainFromBin(
{ width: MINI_W, height: MINI_H, num_land_tiles: mini.numLandTiles },
mini.data,
);
const gameConfig: GameConfig = {
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
gameMode: GameMode.FFA,
gameType: GameType.Singleplayer,
difficulty: Difficulty.Medium,
nations: "default",
donateGold: false,
donateTroops: false,
bots: 0,
infiniteGold: true,
infiniteTroops: true,
instantBuild: true,
randomSpawn: false,
};
const config = new TestConfig(gameConfig, new UserSettings(), false);
const game = createGame(humans, [], gameMap, miniGameMap, config);
game.endSpawnPhase();
return game;
}
describe("Impassable Terrain", () => {
let game: Game;
let player: Player;
let other: Player;
beforeEach(async () => {
game = await setupImpassableGame([
new PlayerInfo("player", PlayerType.Human, "c1", "player_id"),
new PlayerInfo("other", PlayerType.Human, "c2", "other_id"),
]);
// Override nuke settings for deterministic tests.
(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);
player = game.player("player_id");
other = game.player("other_id");
});
// ── Terrain classification ──────────────────────────────────────────
test("isImpassable returns true for impassable tiles, false for plains", () => {
expect(game.isImpassable(game.ref(50, 50))).toBe(false);
expect(game.isImpassable(game.ref(WALL_X, 50))).toBe(true);
expect(game.isImpassable(game.ref(WALL_X + 1, 50))).toBe(true);
});
test("terrainType returns Impassable for impassable tiles", () => {
expect(game.terrainType(game.ref(WALL_X, 50))).toBe(TerrainType.Impassable);
});
test("isLand returns true for impassable (solid for pathfinding)", () => {
expect(game.isLand(game.ref(WALL_X, 50))).toBe(true);
});
test("numLandTiles excludes impassable tiles", () => {
expect(game.numLandTiles()).toBe(MAP_W * MAP_H - WALL_WIDTH * MAP_H);
});
// ── Ownership ────────────────────────────────────────────────────────
test("conquer throws on impassable tiles", () => {
expect(() => player.conquer(game.ref(WALL_X, 50))).toThrow(/impassable/);
});
test("conquer succeeds on normal land", () => {
expect(() => player.conquer(game.ref(50, 50))).not.toThrow();
expect(game.hasOwner(game.ref(50, 50))).toBe(true);
});
// ── Attacks ──────────────────────────────────────────────────────────
test("canAttack returns false for impassable tiles", () => {
expect(player.canAttack(game.ref(WALL_X, 50))).toBe(false);
});
test("canAttack returns false for impassable tiles even when adjacent to owned land", () => {
player.conquer(game.ref(WALL_X - 1, 50));
expect(player.canAttack(game.ref(WALL_X, 50))).toBe(false);
});
test("attack does not expand into impassable tiles", () => {
// Other player owns tiles adjacent to the wall on the right side.
for (let y = 48; y <= 52; y++) {
other.conquer(game.ref(WALL_X + 2, y));
}
// Player owns tiles on the left side, also adjacent to the wall.
for (let y = 48; y <= 52; y++) {
player.conquer(game.ref(WALL_X - 2, y));
}
// Player attacks the other player.
game.addExecution(new AttackExecution(1000, player, other.id()));
executeTicks(game, 50);
// Impassable tiles should never be owned by the attacker.
for (let y = 48; y <= 52; y++) {
expect(game.ownerID(game.ref(WALL_X, y))).not.toBe(player.smallID());
expect(game.ownerID(game.ref(WALL_X + 1, y))).not.toBe(player.smallID());
}
});
// ── Nukes: targeting ─────────────────────────────────────────────────
test("canBuild(AtomBomb) returns false for impassable target", () => {
expect(player.canBuild(UnitType.AtomBomb, game.ref(WALL_X, 50))).toBe(
false,
);
});
test("canBuild(MIRV) returns false for impassable target", () => {
expect(player.canBuild(UnitType.MIRV, game.ref(WALL_X, 50))).toBe(false);
});
test("nuke execution deactivates when targeting impassable tile", () => {
player.conquer(game.ref(10, 10));
player.buildUnit(UnitType.MissileSilo, game.ref(10, 10), {});
const nuke = new NukeExecution(
UnitType.AtomBomb,
player,
game.ref(WALL_X, 10),
);
game.addExecution(nuke);
executeTicks(game, 5);
expect(nuke.isActive()).toBe(false);
});
// ── Nukes: blast radius ───────────────────────────────────────────────
test("nuke blast does not destroy or flood impassable tiles", () => {
player.conquer(game.ref(10, 100));
player.buildUnit(UnitType.MissileSilo, game.ref(10, 100), {});
// Other player owns a tile just left of the wall.
other.conquer(game.ref(WALL_X - 1, 100));
const nuke = new NukeExecution(
UnitType.AtomBomb,
player,
game.ref(WALL_X - 1, 100),
game.ref(10, 100),
);
game.addExecution(nuke);
executeTicks(game, 30);
// Impassable tiles should still be land and impassable (not flooded).
for (let y = 95; y <= 105; y++) {
const t = game.ref(WALL_X, y);
expect(game.isLand(t)).toBe(true);
expect(game.isImpassable(t)).toBe(true);
}
});
// ── Nukes: trajectory ─────────────────────────────────────────────────
test("nuke trajectory blocked by 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.
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);
});
test("nuke can launch when trajectory does not cross impassable terrain", () => {
player.conquer(game.ref(20, 100));
player.buildUnit(UnitType.MissileSilo, game.ref(20, 100), {});
// Target is on the same (left) side — no impassable terrain in between.
const target = game.ref(50, 100);
expect(game.isImpassable(target)).toBe(false);
const nuke = new NukeExecution(UnitType.AtomBomb, player, target);
game.addExecution(nuke);
executeTicks(game, 40);
// Should have detonated and deactivated normally.
expect(nuke.isActive()).toBe(false);
});
// ── Water conversion guard ────────────────────────────────────────────
test("setWater does not convert impassable tiles", () => {
const t = game.ref(WALL_X, 50);
expect(game.isImpassable(t)).toBe(true);
game.map().setWater(t);
expect(game.isLand(t)).toBe(true);
expect(game.isImpassable(t)).toBe(true);
});
// ── Rendering ─────────────────────────────────────────────────────────
test("encodeTerrainTile renders impassable as the map background colour", () => {
const out = new Uint8Array(4);
encodeTerrainTile(IMPASSABLE, out, 0);
// Must match the clear colour in Renderer.ts drawBaseLayer():
// gl.clearColor(60/255, 60/255, 60/255) → rgb(60, 60, 60).
expect(out[0]).toBe(60);
expect(out[1]).toBe(60);
expect(out[2]).toBe(60);
expect(out[3]).toBe(255);
});
test("encodeTerrainTile renders plains normally (not background)", () => {
const out = new Uint8Array(4);
encodeTerrainTile(LAND_PLAINS, out, 0);
// Plains: r=190, g=220, b=138 — clearly different from background.
expect(out[0]).toBe(190);
expect(out[1]).toBe(220);
expect(out[2]).toBe(138);
});
// ── Nation AI: attack behavior near impassable terrain ───────────────
describe("Nation AI attack behavior near impassable terrain", () => {
let nation: Player;
let enemy: Player;
beforeEach(() => {
// Create a nation player that owns tiles adjacent to the impassable
// wall AND directly adjacent to the enemy (no TerraNullius gap).
nation = game.player("player_id");
enemy = game.player("other_id");
// Nation owns the two columns right next to the wall (full height to
// avoid any unowned passable tiles at the top/bottom borders).
for (let y = 0; y < MAP_H; y++) {
nation.conquer(game.ref(WALL_X - 1, y));
nation.conquer(game.ref(WALL_X - 2, y));
}
// Enemy owns the five columns to the left of the nation (full height).
for (let y = 0; y < MAP_H; y++) {
for (let x = WALL_X - 7; x <= WALL_X - 3; x++) {
enemy.conquer(game.ref(x, y));
}
}
// Give both players plenty of troops — nation is stronger so the
// "weakest" strategy will actually attack.
nation.addTroops(200000);
enemy.addTroops(50000);
});
test("hasNonNukedTerraNullius does not falsely detect impassable tiles as TerraNullius", () => {
// The nation borders impassable terrain (the wall at WALL_X).
// With the fix, nearby() should NOT include TerraNullius from
// impassable tiles, and the nation should be able to attack the enemy.
//
// Verify the core behavior: nearby() returns the enemy but NOT
// TerraNullius (impassable tiles are excluded).
const nearby = nation.nearby();
const hasTerraNullius = nearby.some((n) => !n.isPlayer());
const hasEnemy = nearby.some((n) => n === enemy);
expect(hasTerraNullius).toBe(false);
expect(hasEnemy).toBe(true);
// Also verify that sendAttack on the enemy works (it creates an
// AttackExecution targeting the enemy, not TerraNullius).
const emojiBehavior = new NationEmojiBehavior(
new PseudoRandom(42),
game,
nation,
);
const allianceBehavior = new NationAllianceBehavior(
new PseudoRandom(42),
game,
nation,
emojiBehavior,
);
const attackBehavior = new AiAttackBehavior(
new PseudoRandom(42),
game,
nation,
0.0, // triggerRatio — always ready to attack
0.0, // reserveRatio — no reserve needed
0.0, // expandRatio
allianceBehavior,
emojiBehavior,
);
// Directly send an attack on the enemy — this should succeed.
const sent = attackBehavior.sendAttack(enemy, true);
expect(sent).toBe(true);
// Tick the game so the AttackExecution's init() runs and creates
// the actual Attack object on the player.
executeTicks(game, 1);
// The nation should have an outgoing attack targeting the enemy.
const attacksOnEnemy = nation
.outgoingAttacks()
.filter((a) => a.target() === enemy);
expect(attacksOnEnemy.length).toBeGreaterThan(0);
});
});
// ── 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);
});
});
});
+134
View File
@@ -0,0 +1,134 @@
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);
});
});