mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 06:53:02 +00:00
Resolves #4555 ## Description: Fixes the bug where transport boats could not be sent to certain targets (e.g. between the arrows on the Four Islands map) when the attacker's territory was far enough away that it approached the target from its inland-lake side. **Root cause:** The transport landing tile was selected purely by distance. `targetTransportTile()` called `SpatialQuery.closestShore()`, which returns the nearest shore owned by the target using a Manhattan-distance BFS and only checks `isShore && isLand && owned` — it never considers whether that shore is reachable by water. Water bodies are tracked as connected components (an inland lake is a separate component from the surrounding ocean), and the *source* selection (`closestShoreByWater()`) correctly requires the attacker to have a shore in the same water component as the destination. So when the nearest owned shore happened to face a disconnected inland lake, the source search found no shore in the lake's component and the transport silently failed — even though the same target had an ocean-facing shore that the attacker's boats could actually reach. **Fix:** Make destination selection reachability-aware so a lake-facing shore is never chosen when a reachable one exists. - Added `SpatialQuery.closestReachableShore(targetOwner, attacker, tile)`: it first collects the set of water components adjacent to the attacker's own shoreline, then returns the nearest target-owned shore whose water component is in that reachable set. Shores that only border a disconnected water body (an inland lake) are skipped. It returns `null` only when the target has no reachable shore at all. - `targetTransportTile()` now takes the attacker and delegates to `closestReachableShore()` instead of `closestShore()`. Its two callers — `canBuildTransportShip()` and `TransportShipExecution.init()` — already have the attacker in hand and pass it through, so both the build-time check and the actual execution agree on the same reachable destination. - `closestShore()` is left unchanged; this adds a reachability-aware variant rather than altering existing behavior. **Testing performed:** Reproduced the bug first on a generated map with an inland lake enclosed by a thick land moat (confirmed the lake resolves to a separate water component and that `closestShoreByWater()` returns `null` for the lake-facing destination while a reachable ocean shore exists). Added unit tests for `closestReachableShore()` (picks the reachable ocean shore; returns `null` when every target shore is in an unreachable water body) and a `canBuildTransportShip()` regression test for the lake scenario. Full suite: 1878 tests pass; `tsc --noEmit`, ESLint, and Prettier are all clean. ## Please complete the following: - [x] I have added screenshots for all UI updates — no UI changes in this PR - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file — no user-facing text added in this PR - [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: Navaneeth Prabha#0825
316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { SpawnExecution } from "../../../src/core/execution/SpawnExecution";
|
|
import {
|
|
Game,
|
|
Player,
|
|
PlayerInfo,
|
|
PlayerType,
|
|
} from "../../../src/core/game/Game";
|
|
import { TileRef } from "../../../src/core/game/GameMap";
|
|
import { SpatialQuery } from "../../../src/core/pathfinding/spatial/SpatialQuery";
|
|
import { createGame, L, W } from "./_fixtures";
|
|
|
|
// Spawns player and **expands territory** via getSpawnTiles (euclidean dist 4)
|
|
// Ref: src/core/execution/Util.ts
|
|
function addPlayer(game: Game, tile: TileRef, id: string = "test"): Player {
|
|
const info = new PlayerInfo(id, PlayerType.Human, null, `${id}_id`);
|
|
game.addPlayer(info);
|
|
game.addExecution(new SpawnExecution("game_id", info, tile));
|
|
game.executeNextTick();
|
|
game.executeNextTick();
|
|
return game.player(info.id);
|
|
}
|
|
|
|
describe("SpatialQuery", () => {
|
|
describe("closestShore", () => {
|
|
it("finds shore tile owned by player", () => {
|
|
// prettier-ignore
|
|
const game = createGame({
|
|
width: 5, height: 5, grid: [
|
|
W, W, W, W, W,
|
|
W, L, L, L, W,
|
|
W, L, L, L, W,
|
|
W, L, L, L, W,
|
|
W, W, W, W, W,
|
|
],
|
|
});
|
|
|
|
const spatial = new SpatialQuery(game);
|
|
const player = addPlayer(game, game.ref(2, 2));
|
|
|
|
// All land tiles owned by player because of spawn expansion
|
|
const result = spatial.closestShore(player, game.ref(2, 2));
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(game.isShore(result!)).toBe(true);
|
|
expect(game.ownerID(result!)).toBe(player.smallID());
|
|
});
|
|
|
|
it("returns null when no shore within maxDist", () => {
|
|
// prettier-ignore
|
|
const game = createGame({
|
|
width: 7, height: 7, grid: [
|
|
W, W, W, W, W, W, W,
|
|
W, L, L, L, L, L, W,
|
|
W, L, L, L, L, L, W,
|
|
W, L, L, L, L, L, W,
|
|
W, L, L, L, L, L, W,
|
|
W, L, L, L, L, L, W,
|
|
W, W, W, W, W, W, W,
|
|
],
|
|
});
|
|
|
|
const spatial = new SpatialQuery(game);
|
|
const player = addPlayer(game, game.ref(3, 3));
|
|
|
|
// maxDist=1 from center (3,3) - shore is 2 tiles away
|
|
const result = spatial.closestShore(player, game.ref(3, 3), 1);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("finds shore on player's island (two separate islands)", () => {
|
|
// prettier-ignore
|
|
const game = createGame({
|
|
width: 8, height: 4, grid: [
|
|
L, L, W, W, W, W, L, L,
|
|
L, L, W, W, W, W, L, L,
|
|
L, L, W, W, W, W, L, L,
|
|
L, L, W, W, W, W, L, L,
|
|
],
|
|
});
|
|
|
|
const spatial = new SpatialQuery(game);
|
|
const player = addPlayer(game, game.ref(0, 0));
|
|
|
|
const result = spatial.closestShore(player, game.ref(0, 2));
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(game.isShore(result!)).toBe(true);
|
|
expect(game.ownerID(result!)).toBe(player.smallID());
|
|
expect(game.x(result!)).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
it("finds shore even if no land path exists (two separate islands)", () => {
|
|
// prettier-ignore
|
|
const game = createGame({
|
|
width: 8, height: 4, grid: [
|
|
L, L, W, W, W, W, L, L,
|
|
L, L, W, W, W, W, L, L,
|
|
L, L, W, W, W, W, L, L,
|
|
L, L, W, W, W, W, L, L,
|
|
],
|
|
});
|
|
|
|
const spatial = new SpatialQuery(game);
|
|
const player = addPlayer(game, game.ref(0, 0));
|
|
|
|
const result = spatial.closestShore(player, game.ref(7, 2));
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(game.isShore(result!)).toBe(true);
|
|
expect(game.ownerID(result!)).toBe(player.smallID());
|
|
expect(game.x(result!)).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
it("finds shore for terra nullius when land is unclaimed", () => {
|
|
// prettier-ignore
|
|
const game = createGame({
|
|
width: 5, height: 5, grid: [
|
|
W, W, W, W, W,
|
|
W, L, L, L, W,
|
|
W, L, L, L, W,
|
|
W, L, L, L, W,
|
|
W, W, W, W, W,
|
|
],
|
|
});
|
|
|
|
const spatial = new SpatialQuery(game);
|
|
const terraNullius = game.terraNullius();
|
|
|
|
const result = spatial.closestShore(terraNullius, game.ref(2, 2));
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(game.isShore(result!)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("closestShoreByWater", () => {
|
|
it("returns null for terra nullius", () => {
|
|
// prettier-ignore
|
|
const game = createGame({
|
|
width: 5, height: 5, grid: [
|
|
W, W, W, W, W,
|
|
W, L, L, L, W,
|
|
W, L, L, L, W,
|
|
W, L, L, L, W,
|
|
W, W, W, W, W,
|
|
],
|
|
});
|
|
|
|
const spatial = new SpatialQuery(game);
|
|
const terraNullius = game.terraNullius();
|
|
|
|
const result = spatial.closestShoreByWater(terraNullius, game.ref(0, 0));
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when target is on land", () => {
|
|
// prettier-ignore
|
|
const game = createGame({
|
|
width: 5, height: 5, grid: [
|
|
W, W, W, W, W,
|
|
W, L, L, L, W,
|
|
W, L, L, L, W,
|
|
W, L, L, L, W,
|
|
W, W, W, W, W,
|
|
],
|
|
});
|
|
|
|
const spatial = new SpatialQuery(game);
|
|
const player = addPlayer(game, game.ref(2, 2));
|
|
|
|
const result = spatial.closestShoreByWater(player, game.ref(2, 2));
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when target is in disconnected water body", () => {
|
|
// prettier-ignore
|
|
const game = createGame({
|
|
width: 14, height: 6, grid: [
|
|
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
|
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
|
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
|
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
|
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
|
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
|
],
|
|
});
|
|
|
|
const spatial = new SpatialQuery(game);
|
|
const player = addPlayer(game, game.ref(3, 2));
|
|
const result = spatial.closestShoreByWater(player, game.ref(13, 2));
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("finds shore via long water path around island", () => {
|
|
// prettier-ignore
|
|
const game = createGame({
|
|
width: 18, height: 14, grid: [
|
|
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
|
|
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
|
|
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
|
|
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
|
|
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
|
|
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
|
|
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
|
|
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
|
|
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
|
|
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
|
|
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
|
|
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
|
|
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
|
|
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, L,
|
|
],
|
|
});
|
|
|
|
const spatial = new SpatialQuery(game);
|
|
const player = addPlayer(game, game.ref(4, 4));
|
|
|
|
const target = game.ref(17, 13);
|
|
const result = spatial.closestShoreByWater(player, target);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(game.isShore(result!)).toBe(true);
|
|
expect(game.ownerID(result!)).toBe(player.smallID());
|
|
});
|
|
});
|
|
|
|
describe("closestReachableShore", () => {
|
|
// Target island (cols 10-24, rows 1-16) with an interior lake
|
|
// (cols 15-20, rows 6-11) enclosed by a thick land moat, so the lake is a
|
|
// separate water component from the ocean. Attacker island on the left.
|
|
function buildLakeMap(): Game {
|
|
const width = 26;
|
|
const height = 18;
|
|
const grid: string[] = new Array(width * height).fill(W);
|
|
const set = (x: number, y: number, v: string) =>
|
|
(grid[y * width + x] = v);
|
|
const inBox = (
|
|
x: number,
|
|
y: number,
|
|
x0: number,
|
|
x1: number,
|
|
y0: number,
|
|
y1: number,
|
|
) => x >= x0 && x <= x1 && y >= y0 && y <= y1;
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
if (inBox(x, y, 1, 5, 6, 11)) set(x, y, L); // attacker island
|
|
if (inBox(x, y, 10, 24, 1, 16)) set(x, y, L); // target island
|
|
if (inBox(x, y, 15, 20, 6, 11)) set(x, y, W); // carve lake
|
|
}
|
|
}
|
|
return createGame({ width, height, grid });
|
|
}
|
|
|
|
it("skips a lake-facing shore for one the attacker can reach by water", () => {
|
|
const game = buildLakeMap();
|
|
const attacker = addPlayer(game, game.ref(3, 8), "attacker");
|
|
const target = addPlayer(game, game.ref(12, 3), "target");
|
|
const spatial = new SpatialQuery(game);
|
|
|
|
// Clicking target land next to the lake: the nearest owned shore is
|
|
// lake-facing (unreachable), so the reachability-blind closestShore picks
|
|
// a shore the attacker's ocean boats can never reach.
|
|
const clickNearLake = game.ref(14, 8);
|
|
const oldPick = spatial.closestShore(
|
|
game.owner(clickNearLake),
|
|
clickNearLake,
|
|
);
|
|
expect(oldPick).not.toBeNull();
|
|
expect(spatial.closestShoreByWater(attacker, oldPick!)).toBeNull();
|
|
|
|
// The reachability-aware pick must return a shore the attacker can reach.
|
|
const result = spatial.closestReachableShore(
|
|
target,
|
|
attacker,
|
|
clickNearLake,
|
|
);
|
|
expect(result).not.toBeNull();
|
|
expect(game.isShore(result!)).toBe(true);
|
|
expect(game.ownerID(result!)).toBe(target.smallID());
|
|
expect(spatial.closestShoreByWater(attacker, result!)).not.toBeNull();
|
|
});
|
|
|
|
it("returns null when every target shore is in an unreachable water body", () => {
|
|
// Land wall (cols 2-11) fully separates left water (cols 0-1) from right
|
|
// water (cols 12-13): two disconnected seas.
|
|
// prettier-ignore
|
|
const game = createGame({
|
|
width: 14, height: 6, grid: [
|
|
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
|
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
|
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
|
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
|
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
|
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
|
|
],
|
|
});
|
|
const attacker = addPlayer(game, game.ref(3, 2), "attacker"); // left sea
|
|
const target = addPlayer(game, game.ref(10, 3), "target"); // right sea
|
|
const spatial = new SpatialQuery(game);
|
|
|
|
const result = spatial.closestReachableShore(
|
|
target,
|
|
attacker,
|
|
game.ref(10, 3),
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
});
|