Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/core/game/GameMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,11 +309,20 @@ export class GameMapImpl implements GameMap {
}
}

// True when the tile touches the map boundary or an impassable tile.
// Impassable terrain acts like the map edge for enclosure checks: a
// cluster hugging it cannot be "surrounded" from that side.
isOnEdgeOfMap(ref: TileRef): boolean {
const x = this.x(ref);
const y = this.y(ref);
const w = this.width_;
const x = ref % w;
if (x === 0 || x === w - 1 || ref < w || ref >= (this.height_ - 1) * w) {
return true;
}
return (
x === 0 || x === this.width() - 1 || y === 0 || y === this.height() - 1
this.isImpassable(ref - 1) ||
this.isImpassable(ref + 1) ||
this.isImpassable(ref - w) ||
this.isImpassable(ref + w)
);
}

Expand Down
93 changes: 89 additions & 4 deletions tests/ImpassableTerrain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AttackExecution } from "../src/core/execution/AttackExecution";
import { NationAllianceBehavior } from "../src/core/execution/nation/NationAllianceBehavior";
import { NationEmojiBehavior } from "../src/core/execution/nation/NationEmojiBehavior";
import { NukeExecution } from "../src/core/execution/NukeExecution";
import { PlayerExecution } from "../src/core/execution/PlayerExecution";
import { AiAttackBehavior } from "../src/core/execution/utils/AiAttackBehavior";
import {
Difficulty,
Expand All @@ -18,8 +19,10 @@ import {
UnitType,
} from "../src/core/game/Game";
import { createGame } from "../src/core/game/GameImpl";
import { TileRef } from "../src/core/game/GameMap";
import { genTerrainFromBin } from "../src/core/game/TerrainMapLoader";
import { UserSettings } from "../src/core/game/UserSettings";
import { PathFinding } from "../src/core/pathfinding/PathFinder";
import { PseudoRandom } from "../src/core/PseudoRandom";
import { GameConfig } from "../src/core/Schemas";
import { TestConfig } from "./util/TestConfig";
Expand All @@ -43,13 +46,19 @@ function buildTerrain(
height: number,
wallX: number,
wallWidth: number,
wallY: [number, number] = [0, height],
): { 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) {
if (
x >= wallX &&
x < wallX + wallWidth &&
y >= wallY[0] &&
y < wallY[1]
) {
data[idx] = IMPASSABLE;
// Impassable tiles are NOT counted as land tiles.
} else {
Expand All @@ -61,11 +70,17 @@ function buildTerrain(
return { data, numLandTiles };
}

async function setupImpassableGame(humans: PlayerInfo[] = []): Promise<Game> {
async function setupImpassableGame(
humans: PlayerInfo[] = [],
wallY: [number, number] = [0, MAP_H],
): 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 full = buildTerrain(MAP_W, MAP_H, WALL_X, WALL_WIDTH, wallY);
const mini = buildTerrain(MINI_W, MINI_H, Math.floor(WALL_X / 2), 1, [
Math.floor(wallY[0] / 2),
Math.ceil(wallY[1] / 2),
]);

const gameMap = await genTerrainFromBin(
{ width: MAP_W, height: MAP_H, num_land_tiles: full.numLandTiles },
Expand Down Expand Up @@ -155,6 +170,76 @@ describe("Impassable Terrain", () => {
expect(game.hasOwner(game.ref(50, 50))).toBe(true);
});

// ── Map edge / enclosure ─────────────────────────────────────────────

test("isOnEdgeOfMap is true next to impassable terrain, false elsewhere", () => {
expect(game.isOnEdgeOfMap(game.ref(WALL_X - 1, 50))).toBe(true);
expect(game.isOnEdgeOfMap(game.ref(WALL_X + WALL_WIDTH, 50))).toBe(true);
expect(game.isOnEdgeOfMap(game.ref(WALL_X - 2, 50))).toBe(false);
expect(game.isOnEdgeOfMap(game.ref(50, 50))).toBe(false);
expect(game.isOnEdgeOfMap(game.ref(0, 50))).toBe(true);
expect(game.isOnEdgeOfMap(game.ref(50, MAP_H - 1))).toBe(true);
});

test("cluster hugging the impassable wall is not annexed", async () => {
// Short wall segment so the enclosure flood fill cannot walk along
// unowned impassable tiles to the real map edge; every escape route
// from the pocket must be through enemy-owned land or the wall.
const g = await setupImpassableGame(
[
new PlayerInfo("p", PlayerType.Human, "c1", "p_id"),
new PlayerInfo("o", PlayerType.Human, "c2", "o_id"),
],
[45, 58],
);
const p = g.player("p_id");
const o = g.player("o_id");
g.addExecution(new PlayerExecution(p));
g.addExecution(new PlayerExecution(o));

// Player's main (largest) cluster, far from the wall.
for (let x = 10; x < 20; x++) {
for (let y = 10; y < 20; y++) {
p.conquer(g.ref(x, y));
}
}
// Small pocket against the wall; the wall is its fourth side.
const pocket: TileRef[] = [];
for (let x = WALL_X - 3; x < WALL_X; x++) {
for (let y = 50; y < 53; y++) {
pocket.push(g.ref(x, y));
p.conquer(g.ref(x, y));
}
}
// Other player owns everything around the pocket and the wall.
for (let x = WALL_X - 10; x < WALL_X + 10; x++) {
for (let y = 40; y < 63; y++) {
const t = g.ref(x, y);
if (g.ownerID(t) === 0 && !g.isImpassable(t)) o.conquer(t);
}
}

// Mirror NoInverseAnnexation: let cluster calc run, then change tiles.
executeTicks(g, 20);
o.conquer(g.ref(WALL_X - 10, 39));
p.conquer(g.ref(20, 10));
executeTicks(g, 50);

for (const t of pocket) {
expect(g.ownerID(t)).toBe(p.smallID());
}
});

// ── Rail pathfinding ─────────────────────────────────────────────────

test("rail pathfinding does not route through impassable terrain", () => {
const path = PathFinding.Rail(game).findPath(
game.ref(WALL_X - 10, 50),
game.ref(WALL_X + 10, 50),
);
expect(path).toBeNull();
});

// ── Attacks ──────────────────────────────────────────────────────────

test("canAttack returns false for impassable tiles", () => {
Expand Down
Loading