From dbd4beef11ec1079606426ea0f3d25d1d689be66 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:56:30 +0200 Subject: [PATCH] feat: add impassable terrain for non-rectangular maps --- map-generator/README.md | 10 + map-generator/map_generator.go | 80 +++- .../controllers/BuildPreviewController.ts | 27 +- src/client/render/gl/utils/ColorUtils.ts | 14 +- src/client/render/gl/utils/NukeTrajectory.ts | 78 ++- src/client/view/GameView.ts | 3 + src/core/configuration/Config.ts | 2 + src/core/execution/AttackExecution.ts | 6 +- src/core/execution/NationExecution.ts | 6 +- src/core/execution/NukeExecution.ts | 21 +- src/core/execution/Util.ts | 3 +- .../execution/nation/NationNukeBehavior.ts | 40 ++ src/core/execution/utils/AiAttackBehavior.ts | 11 +- src/core/game/Game.ts | 1 + src/core/game/GameImpl.ts | 6 + src/core/game/GameMap.ts | 18 +- src/core/game/PlayerImpl.ts | 16 +- src/core/game/WaterManager.ts | 17 +- tests/ImpassableTerrain.test.ts | 451 ++++++++++++++++++ tests/NukeTrajectory.test.ts | 134 ++++++ 20 files changed, 906 insertions(+), 38 deletions(-) create mode 100644 tests/ImpassableTerrain.test.ts create mode 100644 tests/NukeTrajectory.test.ts diff --git a/map-generator/README.md b/map-generator/README.md index af089f8f3..5efd142d8 100644 --- a/map-generator/README.md +++ b/map-generator/README.md @@ -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: diff --git a/map-generator/map_generator.go b/map-generator/map_generator.go index 83a96054c..db7dd8bae 100644 --- a/map-generator/map_generator.go +++ b/map-generator/map_generator.go @@ -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 { diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts index 3dfaa22ca..f79f98802 100644 --- a/src/client/controllers/BuildPreviewController.ts +++ b/src/client/controllers/BuildPreviewController.ts @@ -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 { diff --git a/src/client/render/gl/utils/ColorUtils.ts b/src/client/render/gl/utils/ColorUtils.ts index 038332bdf..017c3daa3 100644 --- a/src/client/render/gl/utils/ColorUtils.ts +++ b/src/client/render/gl/utils/ColorUtils.ts @@ -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; diff --git a/src/client/render/gl/utils/NukeTrajectory.ts b/src/client/render/gl/utils/NukeTrajectory.ts index d020a6542..d2016574a 100644 --- a/src/client/render/gl/utils/NukeTrajectory.ts +++ b/src/client/render/gl/utils/NukeTrajectory.ts @@ -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 }; } diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts index 961db0a0f..3d5ba9053 100644 --- a/src/client/view/GameView.ts +++ b/src/client/view/GameView.ts @@ -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); } diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 16d7ebee6..1c78b936d 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -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`); } diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index ea41ab03c..6271cfb37 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -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; diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index 6cc60cdb3..d62e24c52 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -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) diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 395ec8767..284724e75 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -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), diff --git a/src/core/execution/Util.ts b/src/core/execution/Util.ts index d3e062a6a..5642e41ff 100644 --- a/src/core/execution/Util.ts +++ b/src/core/execution/Util.ts @@ -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)); diff --git a/src/core/execution/nation/NationNukeBehavior.ts b/src/core/execution/nation/NationNukeBehavior.ts index f6b6809b2..90ecdd543 100644 --- a/src/core/execution/nation/NationNukeBehavior.ts +++ b/src/core/execution/nation/NationNukeBehavior.ts @@ -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); diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index 59efba946..70efd5a76 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -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; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index dfb751e0a..63e3d160e 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -334,6 +334,7 @@ export enum TerrainType { Highland, Mountain, Ocean, + Impassable, } export enum PlayerType { diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 04a1473a0..6484a79f8 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -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); } diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 39b87f3fd..fa9e58184 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -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; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 3cfe71034..526eb83d7 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -477,7 +477,10 @@ export class PlayerImpl implements Player { const ns: Set = 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), ), )) { diff --git a/src/core/game/WaterManager.ts b/src/core/game/WaterManager.ts index 4af979fc6..09478c597 100644 --- a/src/core/game/WaterManager.ts +++ b/src/core/game/WaterManager.ts @@ -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; diff --git a/tests/ImpassableTerrain.test.ts b/tests/ImpassableTerrain.test.ts new file mode 100644 index 000000000..f0bc49b1f --- /dev/null +++ b/tests/ImpassableTerrain.test.ts @@ -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 { + 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); + }); + }); +}); diff --git a/tests/NukeTrajectory.test.ts b/tests/NukeTrajectory.test.ts new file mode 100644 index 000000000..332d136c2 --- /dev/null +++ b/tests/NukeTrajectory.test.ts @@ -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); + }); +});