mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
feat: add impassable terrain for non-rectangular maps
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -334,6 +334,7 @@ export enum TerrainType {
|
||||
Highland,
|
||||
Mountain,
|
||||
Ocean,
|
||||
Impassable,
|
||||
}
|
||||
|
||||
export enum PlayerType {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user