From cdd871e5aa5a1367bcee09059ce594db3f51190a Mon Sep 17 00:00:00 2001 From: TKTK123456 <103334266+TKTK123456@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:14:04 -0400 Subject: [PATCH 1/6] Made touching impassible terrain count as being on the edge of the map --- src/core/game/GameMap.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 50731b37d4..92ab6527f7 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -207,12 +207,18 @@ export class GameMapImpl implements GameMap { // Terrain getters (immutable) isLand(ref: TileRef): boolean { - return Boolean(this.terrain[ref] & (1 << GameMapImpl.IS_LAND_BIT)); + return ( + Boolean(this.terrain[ref] & (1 << GameMapImpl.IS_LAND_BIT)) && + !( + (this.terrain[ref] & GameMapImpl.MAGNITUDE_MASK) === + GameMapImpl.IMPASSABLE_MAGNITUDE + ) + ); } isImpassable(ref: TileRef): boolean { return ( - this.isLand(ref) && + Boolean(this.terrain[ref] & (1 << GameMapImpl.IS_LAND_BIT)) && (this.terrain[ref] & GameMapImpl.MAGNITUDE_MASK) === GameMapImpl.IMPASSABLE_MAGNITUDE ); @@ -313,7 +319,14 @@ export class GameMapImpl implements GameMap { const x = this.x(ref); const y = this.y(ref); return ( - x === 0 || x === this.width() - 1 || y === 0 || y === this.height() - 1 + x === 0 || + x === this.width() - 1 || + y === 0 || + y === this.height() - 1 || + this.isImpassable(this.ref(x + 1, y)) || + this.isImpassable(this.ref(x - 1, y)) || + this.isImpassable(this.ref(x, y + 1)) || + this.isImpassable(this.ref(x, y - 1)) ); } From 33b683d9a1dd4c7d8f2f5c140f4a63b877264546 Mon Sep 17 00:00:00 2001 From: TKTK123456 <103334266+TKTK123456@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:20:59 -0400 Subject: [PATCH 2/6] removed isImpassible in unneeded location --- src/core/pathfinding/algorithms/AStar.Rail.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/core/pathfinding/algorithms/AStar.Rail.ts b/src/core/pathfinding/algorithms/AStar.Rail.ts index f012195086..2c40eda00c 100644 --- a/src/core/pathfinding/algorithms/AStar.Rail.ts +++ b/src/core/pathfinding/algorithms/AStar.Rail.ts @@ -52,26 +52,25 @@ class RailAdapter implements AStarAdapter { let count = 0; const x = node % this.width; const fromShoreline = this.gameMap.isShoreline(node); - const isImpassable = this.gameMap.isImpassable(node); if (node >= this.width) { const n = node - this.width; - if (this.isTraversable(n, fromShoreline, isImpassable)) + if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; } if (node < this._numNodes - this.width) { const n = node + this.width; - if (this.isTraversable(n, fromShoreline, isImpassable)) + if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; } if (x !== 0) { const n = node - 1; - if (this.isTraversable(n, fromShoreline, isImpassable)) + if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; } if (x !== this.width - 1) { const n = node + 1; - if (this.isTraversable(n, fromShoreline, isImpassable)) + if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; } @@ -81,9 +80,7 @@ class RailAdapter implements AStarAdapter { private isTraversable( to: number, fromShoreline: boolean, - isImpassable: boolean, ): boolean { - if (isImpassable) return false; const toWater = this.gameMap.isWater(to); if (!toWater) return true; return fromShoreline || this.gameMap.isShoreline(to); From 5019c72176f1790c133b8ed094f16124c69ab2e0 Mon Sep 17 00:00:00 2001 From: TKTK123456 <103334266+TKTK123456@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:22:56 -0400 Subject: [PATCH 3/6] Formatted --- src/core/pathfinding/algorithms/AStar.Rail.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/core/pathfinding/algorithms/AStar.Rail.ts b/src/core/pathfinding/algorithms/AStar.Rail.ts index 2c40eda00c..095115b965 100644 --- a/src/core/pathfinding/algorithms/AStar.Rail.ts +++ b/src/core/pathfinding/algorithms/AStar.Rail.ts @@ -55,32 +55,25 @@ class RailAdapter implements AStarAdapter { if (node >= this.width) { const n = node - this.width; - if (this.isTraversable(n, fromShoreline)) - buffer[count++] = n; + if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; } if (node < this._numNodes - this.width) { const n = node + this.width; - if (this.isTraversable(n, fromShoreline)) - buffer[count++] = n; + if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; } if (x !== 0) { const n = node - 1; - if (this.isTraversable(n, fromShoreline)) - buffer[count++] = n; + if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; } if (x !== this.width - 1) { const n = node + 1; - if (this.isTraversable(n, fromShoreline)) - buffer[count++] = n; + if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; } return count; } - private isTraversable( - to: number, - fromShoreline: boolean, - ): boolean { + private isTraversable(to: number, fromShoreline: boolean): boolean { const toWater = this.gameMap.isWater(to); if (!toWater) return true; return fromShoreline || this.gameMap.isShoreline(to); From ff148a5052bd5e895de8ac2cfac46fdd4ee114f3 Mon Sep 17 00:00:00 2001 From: TKTK123456 <103334266+TKTK123456@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:42:12 -0400 Subject: [PATCH 4/6] Fixed bugs/Updated tests --- src/core/game/GameImpl.ts | 6 +++--- src/core/game/GameMap.ts | 10 ++++++---- tests/ImpassableTerrain.test.ts | 11 ++++++----- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 2f4eed923f..d3f072e7a9 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -747,12 +747,12 @@ export class GameImpl implements Game { } conquer(owner: PlayerImpl, tile: TileRef): void { - if (!this.isLand(tile)) { - throw Error(`cannot conquer water`); - } if (this.isImpassable(tile)) { throw Error(`cannot conquer impassable terrain`); } + if (!this.isLand(tile)) { + throw Error(`cannot conquer water`); + } const previousOwner = this.owner(tile) as TerraNullius | PlayerImpl; if (previousOwner.isPlayer()) { previousOwner._lastTileChange = this._ticks; diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 92ab6527f7..7888bc5be8 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -242,7 +242,10 @@ export class GameMapImpl implements GameMap { } isShoreline(ref: TileRef): boolean { - return Boolean(this.terrain[ref] & (1 << GameMapImpl.SHORELINE_BIT)); + return ( + Boolean(this.terrain[ref] & (1 << GameMapImpl.SHORELINE_BIT)) && + !this.isImpassable(ref) + ); } magnitude(ref: TileRef): number { @@ -357,7 +360,7 @@ export class GameMapImpl implements GameMap { // Helper methods isWater(ref: TileRef): boolean { - return !this.isLand(ref); + return !this.isLand(ref) && !this.isImpassable(ref); } isShore(ref: TileRef): boolean { @@ -373,12 +376,11 @@ 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; } + if (this.isImpassable(ref)) return TerrainType.Impassable; return TerrainType.Ocean; } diff --git a/tests/ImpassableTerrain.test.ts b/tests/ImpassableTerrain.test.ts index f44ad82df8..2606ea533b 100644 --- a/tests/ImpassableTerrain.test.ts +++ b/tests/ImpassableTerrain.test.ts @@ -136,8 +136,8 @@ describe("Impassable Terrain", () => { 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("isLand returns false for impassable (can't pathfind trains through it)", () => { + expect(game.isLand(game.ref(WALL_X, 50))).toBe(false); }); test("numLandTiles excludes impassable tiles", () => { @@ -227,10 +227,10 @@ describe("Impassable Terrain", () => { game.addExecution(nuke); executeTicks(game, 30); - // Impassable tiles should still be land and impassable (not flooded). + // Impassable tiles should still not 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.isLand(t)).toBe(false); expect(game.isImpassable(t)).toBe(true); } }); @@ -283,7 +283,8 @@ describe("Impassable Terrain", () => { 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.isLand(t)).toBe(false); + expect(game.isWater(t)).toBe(false); expect(game.isImpassable(t)).toBe(true); }); From c7fc0d19c3786fcbd5e96c383c0fb1b202a14681 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 24 Aug 2026 21:22:54 -0700 Subject: [PATCH 5/6] fix: narrow impassable-as-map-edge to isOnEdgeOfMap and add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep only the isOnEdgeOfMap change, which by itself resolves #4907 (a cluster hugging an impassable wall can no longer be "surrounded" from that side), and rewrite it with the same bounds-guarded ref±1/ref±w idiom used by isBorder/isOceanShore. Revert the isLand/isWater/isShoreline redefinition, the AStar.Rail guard removal and the conquer() reorder: making impassable neither land nor water let RailAdapter.isTraversable() treat impassable tiles as plain land, so rail A* routed straight through walls (reverting #4903), and client `!isLand` water branches started firing on walls. Tests: isOnEdgeOfMap adjacency, the #4907 annexation scenario, and rail pathfinding across the wall. Each fails without its fix. Co-Authored-By: Claude Fable 5 --- src/core/game/GameImpl.ts | 6 +- src/core/game/GameMap.ts | 42 +++++------ src/core/pathfinding/algorithms/AStar.Rail.ts | 20 ++++-- tests/ImpassableTerrain.test.ts | 72 +++++++++++++++++-- 4 files changed, 102 insertions(+), 38 deletions(-) diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index d3f072e7a9..2f4eed923f 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -747,12 +747,12 @@ export class GameImpl implements Game { } conquer(owner: PlayerImpl, tile: TileRef): void { - if (this.isImpassable(tile)) { - throw Error(`cannot conquer impassable terrain`); - } 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; diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 7888bc5be8..cecfca32e9 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -207,18 +207,12 @@ export class GameMapImpl implements GameMap { // Terrain getters (immutable) isLand(ref: TileRef): boolean { - return ( - Boolean(this.terrain[ref] & (1 << GameMapImpl.IS_LAND_BIT)) && - !( - (this.terrain[ref] & GameMapImpl.MAGNITUDE_MASK) === - GameMapImpl.IMPASSABLE_MAGNITUDE - ) - ); + return Boolean(this.terrain[ref] & (1 << GameMapImpl.IS_LAND_BIT)); } isImpassable(ref: TileRef): boolean { return ( - Boolean(this.terrain[ref] & (1 << GameMapImpl.IS_LAND_BIT)) && + this.isLand(ref) && (this.terrain[ref] & GameMapImpl.MAGNITUDE_MASK) === GameMapImpl.IMPASSABLE_MAGNITUDE ); @@ -242,10 +236,7 @@ export class GameMapImpl implements GameMap { } isShoreline(ref: TileRef): boolean { - return ( - Boolean(this.terrain[ref] & (1 << GameMapImpl.SHORELINE_BIT)) && - !this.isImpassable(ref) - ); + return Boolean(this.terrain[ref] & (1 << GameMapImpl.SHORELINE_BIT)); } magnitude(ref: TileRef): number { @@ -318,18 +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(this.ref(x + 1, y)) || - this.isImpassable(this.ref(x - 1, y)) || - this.isImpassable(this.ref(x, y + 1)) || - this.isImpassable(this.ref(x, y - 1)) + this.isImpassable(ref - 1) || + this.isImpassable(ref + 1) || + this.isImpassable(ref - w) || + this.isImpassable(ref + w) ); } @@ -360,7 +353,7 @@ export class GameMapImpl implements GameMap { // Helper methods isWater(ref: TileRef): boolean { - return !this.isLand(ref) && !this.isImpassable(ref); + return !this.isLand(ref); } isShore(ref: TileRef): boolean { @@ -376,11 +369,12 @@ 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; } - if (this.isImpassable(ref)) return TerrainType.Impassable; return TerrainType.Ocean; } diff --git a/src/core/pathfinding/algorithms/AStar.Rail.ts b/src/core/pathfinding/algorithms/AStar.Rail.ts index 095115b965..f012195086 100644 --- a/src/core/pathfinding/algorithms/AStar.Rail.ts +++ b/src/core/pathfinding/algorithms/AStar.Rail.ts @@ -52,28 +52,38 @@ class RailAdapter implements AStarAdapter { let count = 0; const x = node % this.width; const fromShoreline = this.gameMap.isShoreline(node); + const isImpassable = this.gameMap.isImpassable(node); if (node >= this.width) { const n = node - this.width; - if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; + if (this.isTraversable(n, fromShoreline, isImpassable)) + buffer[count++] = n; } if (node < this._numNodes - this.width) { const n = node + this.width; - if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; + if (this.isTraversable(n, fromShoreline, isImpassable)) + buffer[count++] = n; } if (x !== 0) { const n = node - 1; - if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; + if (this.isTraversable(n, fromShoreline, isImpassable)) + buffer[count++] = n; } if (x !== this.width - 1) { const n = node + 1; - if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; + if (this.isTraversable(n, fromShoreline, isImpassable)) + buffer[count++] = n; } return count; } - private isTraversable(to: number, fromShoreline: boolean): boolean { + private isTraversable( + to: number, + fromShoreline: boolean, + isImpassable: boolean, + ): boolean { + if (isImpassable) return false; const toWater = this.gameMap.isWater(to); if (!toWater) return true; return fromShoreline || this.gameMap.isShoreline(to); diff --git a/tests/ImpassableTerrain.test.ts b/tests/ImpassableTerrain.test.ts index 2606ea533b..0bcd5efe7b 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"; @@ -136,8 +139,8 @@ describe("Impassable Terrain", () => { expect(game.terrainType(game.ref(WALL_X, 50))).toBe(TerrainType.Impassable); }); - test("isLand returns false for impassable (can't pathfind trains through it)", () => { - expect(game.isLand(game.ref(WALL_X, 50))).toBe(false); + test("isLand returns true for impassable (solid for pathfinding)", () => { + expect(game.isLand(game.ref(WALL_X, 50))).toBe(true); }); test("numLandTiles excludes impassable tiles", () => { @@ -155,6 +158,64 @@ 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", () => { + game.addExecution(new PlayerExecution(player)); + game.addExecution(new PlayerExecution(other)); + + // Player's main (largest) cluster, far from the wall. + for (let x = 10; x < 20; x++) { + for (let y = 10; y < 20; y++) { + player.conquer(game.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(game.ref(x, y)); + player.conquer(game.ref(x, y)); + } + } + // Other player owns everything around the pocket on the other 3 sides. + for (let x = WALL_X - 10; x < WALL_X; x++) { + for (let y = 40; y < 63; y++) { + const t = game.ref(x, y); + if (game.ownerID(t) === 0) other.conquer(t); + } + } + + // Mirror NoInverseAnnexation: let cluster calc run, then change tiles. + executeTicks(game, 20); + other.conquer(game.ref(WALL_X - 10, 39)); + player.conquer(game.ref(20, 10)); + executeTicks(game, 50); + + for (const t of pocket) { + expect(game.ownerID(t)).toBe(player.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", () => { @@ -227,10 +288,10 @@ describe("Impassable Terrain", () => { game.addExecution(nuke); executeTicks(game, 30); - // Impassable tiles should still not be land and impassable (not flooded). + // 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(false); + expect(game.isLand(t)).toBe(true); expect(game.isImpassable(t)).toBe(true); } }); @@ -283,8 +344,7 @@ describe("Impassable Terrain", () => { const t = game.ref(WALL_X, 50); expect(game.isImpassable(t)).toBe(true); game.map().setWater(t); - expect(game.isLand(t)).toBe(false); - expect(game.isWater(t)).toBe(false); + expect(game.isLand(t)).toBe(true); expect(game.isImpassable(t)).toBe(true); }); From f5a356070da5cc4bd031d44952559052ffc31223 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 24 Aug 2026 22:06:30 -0700 Subject: [PATCH 6/6] test: make the wall-annexation test a real regression guard On main, isEnclosed (#5027) walks unowned impassable tiles like land, so with a full-height wall the flood fill reached the literal map edge and rescued the pocket even without the isOnEdgeOfMap fix. Use a short wall segment ringed by enemy territory so the pocket is annexed pre-fix and survives only because impassable now counts as a map edge. Co-Authored-By: Claude Fable 5 --- tests/ImpassableTerrain.test.ts | 62 +++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/tests/ImpassableTerrain.test.ts b/tests/ImpassableTerrain.test.ts index 0bcd5efe7b..7645164a73 100644 --- a/tests/ImpassableTerrain.test.ts +++ b/tests/ImpassableTerrain.test.ts @@ -46,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 { @@ -64,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 }, @@ -169,40 +181,52 @@ describe("Impassable Terrain", () => { expect(game.isOnEdgeOfMap(game.ref(50, MAP_H - 1))).toBe(true); }); - test("cluster hugging the impassable wall is not annexed", () => { - game.addExecution(new PlayerExecution(player)); - game.addExecution(new PlayerExecution(other)); + 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++) { - player.conquer(game.ref(x, 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(game.ref(x, y)); - player.conquer(game.ref(x, y)); + pocket.push(g.ref(x, y)); + p.conquer(g.ref(x, y)); } } - // Other player owns everything around the pocket on the other 3 sides. - for (let x = WALL_X - 10; x < WALL_X; x++) { + // 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 = game.ref(x, y); - if (game.ownerID(t) === 0) other.conquer(t); + 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(game, 20); - other.conquer(game.ref(WALL_X - 10, 39)); - player.conquer(game.ref(20, 10)); - executeTicks(game, 50); + 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(game.ownerID(t)).toBe(player.smallID()); + expect(g.ownerID(t)).toBe(p.smallID()); } });