diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 50731b37d4..cecfca32e9 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -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) ); } diff --git a/tests/ImpassableTerrain.test.ts b/tests/ImpassableTerrain.test.ts index f44ad82df8..7645164a73 100644 --- a/tests/ImpassableTerrain.test.ts +++ b/tests/ImpassableTerrain.test.ts @@ -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, @@ -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"; @@ -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 { @@ -61,11 +70,17 @@ function buildTerrain( return { data, numLandTiles }; } -async function setupImpassableGame(humans: PlayerInfo[] = []): Promise { +async function setupImpassableGame( + humans: PlayerInfo[] = [], + wallY: [number, number] = [0, MAP_H], +): Promise { 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 }, @@ -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", () => {