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
This commit is contained in:
FloPinguin
2026-06-19 23:54:09 +02:00
committed by GitHub
parent 6e892839e8
commit 805f0968b1
27 changed files with 2530 additions and 50 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, the color of a tile is determined dynamically based on its **Terrain Type** and **Magnitude**.
- Ocean default color definition: `../src/client/render/gl/render-settings.json` (user changeable via settings)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 MiB

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

+85 -10
View File
@@ -42,6 +42,7 @@ type TerrainType uint8
const (
Land TerrainType = iota
Water
Impassable
)
// Terrain represents the properties of a single map tile.
@@ -91,15 +92,20 @@ type GeneratorArgs struct {
//
// 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. |
// | **#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.
func GenerateMap(ctx context.Context, args GeneratorArgs) (MapResult, error) {
@@ -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}
@@ -156,13 +167,19 @@ func GenerateMap(ctx context.Context, args GeneratorArgs) (MapResult, error) {
removeSmallIslands(ctx, terrain, minIslandSize, args.RemoveSmall)
processWater(ctx, terrain, args.RemoveSmall)
// Water adjacent to impassable terrain should be deep (no depth gradient),
// just like water at the map edge. Override the BFS-calculated magnitude
// so these tiles render as the deepest shade.
setImpassableNeighborWaterDepth(ctx, terrain)
terrain4x := createMiniMap(terrain)
removeSmallIslands(ctx, terrain4x, minIslandSize/2, args.RemoveSmall)
processWater(ctx, terrain4x, false)
setImpassableNeighborWaterDepth(ctx, terrain4x)
terrain16x := createMiniMap(terrain4x)
processWater(ctx, terrain16x, false)
setImpassableNeighborWaterDepth(ctx, terrain16x)
thumb := createMapThumbnail(ctx, terrain4x, 0.5)
webp, err := convertToWebP(ThumbData{
@@ -239,8 +256,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 +276,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 +326,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 +336,7 @@ func processShore(ctx context.Context, terrain [][]Terrain) []Coord {
}
}
}
// Impassable tiles: never shoreline (renders as background, no outline)
}
}
@@ -361,6 +392,33 @@ func processDistToLand(ctx context.Context, shorelineWaters []Coord, terrain [][
}
}
// setImpassableNeighborWaterDepth forces water tiles adjacent to impassable
// terrain to deep-water magnitude. Without this, the processDistToLand BFS
// assigns them a shallow magnitude (close to "land"), producing a visible
// depth gradient next to impassable terrain. Impassable terrain is void —
// like the map edge — so the water beside it should be uniformly deep.
func setImpassableNeighborWaterDepth(ctx context.Context, terrain [][]Terrain) {
width := len(terrain)
height := len(terrain[0])
const deepMagnitude = 20 // packed as 10 (÷2), matches max render depth
var buf [4]Coord
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
if terrain[x][y].Type != Water {
continue
}
n := neighborCoords(x, y, width, height, &buf)
for _, c := range buf[:n] {
if terrain[c.X][c.Y].Type == Impassable {
terrain[x][y].Magnitude = deepMagnitude
break
}
}
}
}
}
// neighborCoords fills out with the valid orthogonal neighbours of (x, y) and
// returns the count. out must be a caller-allocated [4]Coord buffer; by
// reusing the same buffer across calls the caller avoids any heap allocation.
@@ -567,6 +625,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 +638,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 +721,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 +731,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 {
+3 -3
View File
@@ -3,17 +3,17 @@
"id": "LosAngeles",
"map": {
"height": 2276,
"num_land_tiles": 2066156,
"num_land_tiles": 2055581,
"width": 1800
},
"map16x": {
"height": 569,
"num_land_tiles": 123167,
"num_land_tiles": 122366,
"width": 450
},
"map4x": {
"height": 1138,
"num_land_tiles": 508420,
"num_land_tiles": 505680,
"width": 900
},
"multiplayer_frequency": 8,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

@@ -78,13 +78,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(
@@ -149,6 +151,7 @@ export class BuildPreviewController implements Controller {
this.game.height(),
this.uiState.rocketDirectionUp,
traj.sams,
traj.isBlocked,
),
);
}
@@ -196,6 +199,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)
@@ -382,7 +390,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,29 @@ 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
*/
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 +884,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 +1077,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 +1086,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;
@@ -1079,6 +1082,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),
),
)) {
+20 -2
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);
}
@@ -295,6 +299,9 @@ export class WaterManager {
const sMaxY = Math.min(h - 1, cMaxY + MAX_MAG_DIST * 2);
// Seed from coastline water tiles inside the seed box.
// Impassable terrain is void (like the map edge), so water tiles
// adjacent only to impassable terrain are NOT coastline — they should
// be uniformly deep with no depth gradient.
for (let by = sMinY; by <= sMaxY; by++) {
const rowStart = by * w;
for (let bx = sMinX; bx <= sMaxX; bx++) {
@@ -302,7 +309,7 @@ export class WaterManager {
if (!map.isWater(tile) || stampArr[tile] === stamp) continue;
const end = pushNeighbors(tile, nb, 0);
for (let i = 0; i < end; i++) {
if (map.isLand(nb[i])) {
if (map.isLand(nb[i]) && !map.isImpassable(nb[i])) {
stampArr[tile] = stamp;
distArr[tile] = 0;
magQueue.push(tile);
@@ -373,10 +380,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;
+455
View File
@@ -0,0 +1,455 @@
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> {
vi.spyOn(console, "debug").mockImplementation(() => {});
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;
afterEach(() => {
vi.restoreAllMocks();
});
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);
});
});