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

Relates to #3725

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

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

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

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

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


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

Impassable terrain is transparent on the thumbnail:

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

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

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

Models used: GLM 5.2 and MiMo 2.5 Pro 😄

## Please complete the following:

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

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

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

456 lines
16 KiB
TypeScript

import { encodeTerrainTile } from "../src/client/render/gl/utils/ColorUtils";
import { AttackExecution } from "../src/core/execution/AttackExecution";
import { NationAllianceBehavior } from "../src/core/execution/nation/NationAllianceBehavior";
import { NationEmojiBehavior } from "../src/core/execution/nation/NationEmojiBehavior";
import { NationNukeBehavior } from "../src/core/execution/nation/NationNukeBehavior";
import { NukeExecution } from "../src/core/execution/NukeExecution";
import { AiAttackBehavior } from "../src/core/execution/utils/AiAttackBehavior";
import {
Difficulty,
Game,
GameMapSize,
GameMapType,
GameMode,
GameType,
Player,
PlayerInfo,
PlayerType,
TerrainType,
UnitType,
} from "../src/core/game/Game";
import { createGame } from "../src/core/game/GameImpl";
import { genTerrainFromBin } from "../src/core/game/TerrainMapLoader";
import { UserSettings } from "../src/core/game/UserSettings";
import { PseudoRandom } from "../src/core/PseudoRandom";
import { GameConfig } from "../src/core/Schemas";
import { TestConfig } from "./util/TestConfig";
import { executeTicks } from "./util/utils";
// ─── Terrain byte constants (must match GameMapImpl) ────────────────────
const LAND_PLAINS = 0b10000000; // isLand=1, magnitude=0 (Plains)
const IMPASSABLE = 0b10011111; // isLand=1, magnitude=31 (Impassable)
const MAP_W = 200;
const MAP_H = 200;
const MINI_W = 100;
const MINI_H = 100;
// The impassable wall is a vertical strip at x = WALL_X.
const WALL_X = 100;
const WALL_WIDTH = 2;
function buildTerrain(
width: number,
height: number,
wallX: number,
wallWidth: number,
): { data: Uint8Array; numLandTiles: number } {
const data = new Uint8Array(width * height);
let numLandTiles = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (x >= wallX && x < wallX + wallWidth) {
data[idx] = IMPASSABLE;
// Impassable tiles are NOT counted as land tiles.
} else {
data[idx] = LAND_PLAINS;
numLandTiles++;
}
}
}
return { data, numLandTiles };
}
async function setupImpassableGame(humans: PlayerInfo[] = []): Promise<Game> {
vi.spyOn(console, "debug").mockImplementation(() => {});
const full = buildTerrain(MAP_W, MAP_H, WALL_X, WALL_WIDTH);
const mini = buildTerrain(MINI_W, MINI_H, Math.floor(WALL_X / 2), 1);
const gameMap = await genTerrainFromBin(
{ width: MAP_W, height: MAP_H, num_land_tiles: full.numLandTiles },
full.data,
);
const miniGameMap = await genTerrainFromBin(
{ width: MINI_W, height: MINI_H, num_land_tiles: mini.numLandTiles },
mini.data,
);
const gameConfig: GameConfig = {
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
gameMode: GameMode.FFA,
gameType: GameType.Singleplayer,
difficulty: Difficulty.Medium,
nations: "default",
donateGold: false,
donateTroops: false,
bots: 0,
infiniteGold: true,
infiniteTroops: true,
instantBuild: true,
randomSpawn: false,
};
const config = new TestConfig(gameConfig, new UserSettings(), false);
const game = createGame(humans, [], gameMap, miniGameMap, config);
game.endSpawnPhase();
return game;
}
describe("Impassable Terrain", () => {
let game: Game;
let player: Player;
let other: Player;
afterEach(() => {
vi.restoreAllMocks();
});
beforeEach(async () => {
game = await setupImpassableGame([
new PlayerInfo("player", PlayerType.Human, "c1", "player_id"),
new PlayerInfo("other", PlayerType.Human, "c2", "other_id"),
]);
// Override nuke settings for deterministic tests.
(game.config() as TestConfig).nukeMagnitudes = vi.fn(() => ({
inner: 5,
outer: 5,
}));
(game.config() as TestConfig).nukeAllianceBreakThreshold = vi.fn(() => 999);
(game.config() as TestConfig).setDefaultNukeSpeed(50);
player = game.player("player_id");
other = game.player("other_id");
});
// ── Terrain classification ──────────────────────────────────────────
test("isImpassable returns true for impassable tiles, false for plains", () => {
expect(game.isImpassable(game.ref(50, 50))).toBe(false);
expect(game.isImpassable(game.ref(WALL_X, 50))).toBe(true);
expect(game.isImpassable(game.ref(WALL_X + 1, 50))).toBe(true);
});
test("terrainType returns Impassable for impassable tiles", () => {
expect(game.terrainType(game.ref(WALL_X, 50))).toBe(TerrainType.Impassable);
});
test("isLand returns true for impassable (solid for pathfinding)", () => {
expect(game.isLand(game.ref(WALL_X, 50))).toBe(true);
});
test("numLandTiles excludes impassable tiles", () => {
expect(game.numLandTiles()).toBe(MAP_W * MAP_H - WALL_WIDTH * MAP_H);
});
// ── Ownership ────────────────────────────────────────────────────────
test("conquer throws on impassable tiles", () => {
expect(() => player.conquer(game.ref(WALL_X, 50))).toThrow(/impassable/);
});
test("conquer succeeds on normal land", () => {
expect(() => player.conquer(game.ref(50, 50))).not.toThrow();
expect(game.hasOwner(game.ref(50, 50))).toBe(true);
});
// ── Attacks ──────────────────────────────────────────────────────────
test("canAttack returns false for impassable tiles", () => {
expect(player.canAttack(game.ref(WALL_X, 50))).toBe(false);
});
test("canAttack returns false for impassable tiles even when adjacent to owned land", () => {
player.conquer(game.ref(WALL_X - 1, 50));
expect(player.canAttack(game.ref(WALL_X, 50))).toBe(false);
});
test("attack does not expand into impassable tiles", () => {
// Other player owns tiles adjacent to the wall on the right side.
for (let y = 48; y <= 52; y++) {
other.conquer(game.ref(WALL_X + 2, y));
}
// Player owns tiles on the left side, also adjacent to the wall.
for (let y = 48; y <= 52; y++) {
player.conquer(game.ref(WALL_X - 2, y));
}
// Player attacks the other player.
game.addExecution(new AttackExecution(1000, player, other.id()));
executeTicks(game, 50);
// Impassable tiles should never be owned by the attacker.
for (let y = 48; y <= 52; y++) {
expect(game.ownerID(game.ref(WALL_X, y))).not.toBe(player.smallID());
expect(game.ownerID(game.ref(WALL_X + 1, y))).not.toBe(player.smallID());
}
});
// ── Nukes: targeting ─────────────────────────────────────────────────
test("canBuild(AtomBomb) returns false for impassable target", () => {
expect(player.canBuild(UnitType.AtomBomb, game.ref(WALL_X, 50))).toBe(
false,
);
});
test("canBuild(MIRV) returns false for impassable target", () => {
expect(player.canBuild(UnitType.MIRV, game.ref(WALL_X, 50))).toBe(false);
});
test("nuke execution deactivates when targeting impassable tile", () => {
player.conquer(game.ref(10, 10));
player.buildUnit(UnitType.MissileSilo, game.ref(10, 10), {});
const nuke = new NukeExecution(
UnitType.AtomBomb,
player,
game.ref(WALL_X, 10),
);
game.addExecution(nuke);
executeTicks(game, 5);
expect(nuke.isActive()).toBe(false);
});
// ── Nukes: blast radius ───────────────────────────────────────────────
test("nuke blast does not destroy or flood impassable tiles", () => {
player.conquer(game.ref(10, 100));
player.buildUnit(UnitType.MissileSilo, game.ref(10, 100), {});
// Other player owns a tile just left of the wall.
other.conquer(game.ref(WALL_X - 1, 100));
const nuke = new NukeExecution(
UnitType.AtomBomb,
player,
game.ref(WALL_X - 1, 100),
game.ref(10, 100),
);
game.addExecution(nuke);
executeTicks(game, 30);
// Impassable tiles should still be land and impassable (not flooded).
for (let y = 95; y <= 105; y++) {
const t = game.ref(WALL_X, y);
expect(game.isLand(t)).toBe(true);
expect(game.isImpassable(t)).toBe(true);
}
});
// ── Nukes: trajectory ─────────────────────────────────────────────────
test("nuke trajectory blocked by impassable terrain", () => {
player.conquer(game.ref(20, 100));
player.buildUnit(UnitType.MissileSilo, game.ref(20, 100), {});
// Target is on the right side of the wall — trajectory must cross it.
const target = game.ref(150, 100);
expect(game.isImpassable(target)).toBe(false);
const nuke = new NukeExecution(UnitType.AtomBomb, player, target);
game.addExecution(nuke);
executeTicks(game, 10);
// Should have been blocked.
expect(nuke.isActive()).toBe(false);
});
test("nuke can launch when trajectory does not cross impassable terrain", () => {
player.conquer(game.ref(20, 100));
player.buildUnit(UnitType.MissileSilo, game.ref(20, 100), {});
// Target is on the same (left) side — no impassable terrain in between.
const target = game.ref(50, 100);
expect(game.isImpassable(target)).toBe(false);
const nuke = new NukeExecution(UnitType.AtomBomb, player, target);
game.addExecution(nuke);
executeTicks(game, 40);
// Should have detonated and deactivated normally.
expect(nuke.isActive()).toBe(false);
});
// ── Water conversion guard ────────────────────────────────────────────
test("setWater does not convert impassable tiles", () => {
const t = game.ref(WALL_X, 50);
expect(game.isImpassable(t)).toBe(true);
game.map().setWater(t);
expect(game.isLand(t)).toBe(true);
expect(game.isImpassable(t)).toBe(true);
});
// ── Rendering ─────────────────────────────────────────────────────────
test("encodeTerrainTile renders impassable as the map background colour", () => {
const out = new Uint8Array(4);
encodeTerrainTile(IMPASSABLE, out, 0);
// Must match the clear colour in Renderer.ts drawBaseLayer():
// gl.clearColor(60/255, 60/255, 60/255) → rgb(60, 60, 60).
expect(out[0]).toBe(60);
expect(out[1]).toBe(60);
expect(out[2]).toBe(60);
expect(out[3]).toBe(255);
});
test("encodeTerrainTile renders plains normally (not background)", () => {
const out = new Uint8Array(4);
encodeTerrainTile(LAND_PLAINS, out, 0);
// Plains: r=190, g=220, b=138 — clearly different from background.
expect(out[0]).toBe(190);
expect(out[1]).toBe(220);
expect(out[2]).toBe(138);
});
// ── Nation AI: attack behavior near impassable terrain ───────────────
describe("Nation AI attack behavior near impassable terrain", () => {
let nation: Player;
let enemy: Player;
beforeEach(() => {
// Create a nation player that owns tiles adjacent to the impassable
// wall AND directly adjacent to the enemy (no TerraNullius gap).
nation = game.player("player_id");
enemy = game.player("other_id");
// Nation owns the two columns right next to the wall (full height to
// avoid any unowned passable tiles at the top/bottom borders).
for (let y = 0; y < MAP_H; y++) {
nation.conquer(game.ref(WALL_X - 1, y));
nation.conquer(game.ref(WALL_X - 2, y));
}
// Enemy owns the five columns to the left of the nation (full height).
for (let y = 0; y < MAP_H; y++) {
for (let x = WALL_X - 7; x <= WALL_X - 3; x++) {
enemy.conquer(game.ref(x, y));
}
}
// Give both players plenty of troops — nation is stronger so the
// "weakest" strategy will actually attack.
nation.addTroops(200000);
enemy.addTroops(50000);
});
test("hasNonNukedTerraNullius does not falsely detect impassable tiles as TerraNullius", () => {
// The nation borders impassable terrain (the wall at WALL_X).
// With the fix, nearby() should NOT include TerraNullius from
// impassable tiles, and the nation should be able to attack the enemy.
//
// Verify the core behavior: nearby() returns the enemy but NOT
// TerraNullius (impassable tiles are excluded).
const nearby = nation.nearby();
const hasTerraNullius = nearby.some((n) => !n.isPlayer());
const hasEnemy = nearby.some((n) => n === enemy);
expect(hasTerraNullius).toBe(false);
expect(hasEnemy).toBe(true);
// Also verify that sendAttack on the enemy works (it creates an
// AttackExecution targeting the enemy, not TerraNullius).
const emojiBehavior = new NationEmojiBehavior(
new PseudoRandom(42),
game,
nation,
);
const allianceBehavior = new NationAllianceBehavior(
new PseudoRandom(42),
game,
nation,
emojiBehavior,
);
const attackBehavior = new AiAttackBehavior(
new PseudoRandom(42),
game,
nation,
0.0, // triggerRatio — always ready to attack
0.0, // reserveRatio — no reserve needed
0.0, // expandRatio
allianceBehavior,
emojiBehavior,
);
// Directly send an attack on the enemy — this should succeed.
const sent = attackBehavior.sendAttack(enemy, true);
expect(sent).toBe(true);
// Tick the game so the AttackExecution's init() runs and creates
// the actual Attack object on the player.
executeTicks(game, 1);
// The nation should have an outgoing attack targeting the enemy.
const attacksOnEnemy = nation
.outgoingAttacks()
.filter((a) => a.target() === enemy);
expect(attacksOnEnemy.length).toBeGreaterThan(0);
});
});
// ── Nation AI: nuke trajectory over impassable terrain ───────────────
describe("NationNukeBehavior trajectory over impassable terrain", () => {
let nukePlayer: Player;
beforeEach(() => {
nukePlayer = game.player("player_id");
(game.config() as TestConfig).infiniteGold = () => true;
(game.config() as TestConfig).instantBuild = () => true;
(game.config() as TestConfig).nukeMagnitudes = vi.fn(() => ({
inner: 5,
outer: 5,
}));
(game.config() as TestConfig).nukeAllianceBreakThreshold = vi.fn(
() => 999,
);
(game.config() as TestConfig).setDefaultNukeSpeed(50);
});
test("NationNukeBehavior skips nuke targets whose trajectory crosses impassable terrain", () => {
// Build a silo on the left side of the wall.
nukePlayer.conquer(game.ref(20, 100));
nukePlayer.buildUnit(UnitType.MissileSilo, game.ref(20, 100), {});
// Enemy owns tiles on the RIGHT side of the wall — trajectory must
// cross the impassable wall.
const enemy = game.player("other_id");
enemy.conquer(game.ref(150, 100));
// Build a NationNukeBehavior and call maybeSendNuke.
const emojiBehavior = new NationEmojiBehavior(
new PseudoRandom(42),
game,
nukePlayer,
);
const allianceBehavior = new NationAllianceBehavior(
new PseudoRandom(42),
game,
nukePlayer,
emojiBehavior,
);
const attackBehavior = new AiAttackBehavior(
new PseudoRandom(42),
game,
nukePlayer,
0.0,
0.0,
0.0,
allianceBehavior,
emojiBehavior,
);
const nukeBehavior = new NationNukeBehavior(
new PseudoRandom(42),
game,
nukePlayer,
attackBehavior,
emojiBehavior,
);
// Set the enemy as a hostile target so the nuke behavior considers them.
nukePlayer.updateRelation(enemy, -100);
// Run maybeSendNuke — it should NOT launch a nuke because the
// trajectory crosses impassable terrain.
nukeBehavior.maybeSendNuke();
// No nukes should have been launched.
const nukes = nukePlayer.units(UnitType.AtomBomb, UnitType.HydrogenBomb);
expect(nukes.length).toBe(0);
});
});
});