Files
OpenFrontIO/tests/core/game/TransportShipUtils.test.ts
T
Navaneeth PrabhaandGitHub 1a34321ac8 Fix transport ships targeting unreachable inland-lake shores (#4577)
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
2026-07-14 14:21:59 -07:00

64 lines
2.4 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 { canBuildTransportShip } from "../../../src/core/game/TransportShipUtils";
import { createGame, L, W } from "../pathfinding/_fixtures";
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);
}
// 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 (unowned)
if (inBox(x, y, 15, 20, 6, 11)) set(x, y, W); // carve lake
}
}
return createGame({ width, height, grid });
}
describe("canBuildTransportShip", () => {
it("allows a transport toward a reachable ocean shore near a lake click", () => {
const game = buildLakeMap();
const attacker = addPlayer(game, game.ref(3, 8), "attacker");
// Click on the (unowned) target island next to the inland lake. The nearest
// owned shore is lake-facing and unreachable by the attacker's boats, but a
// reachable ocean-facing shore exists — so the boat must still be buildable.
const clickNearLake = game.ref(14, 8);
const src = canBuildTransportShip(game, attacker, clickNearLake);
expect(src).not.toBe(false);
expect(game.isShore(src as TileRef)).toBe(true);
expect((attacker as Player).smallID()).toBe(game.ownerID(src as TileRef));
});
});