fix (pathfinding): prioritize best connected water neighbor in ShoreCoercingTransformer (#2937)

## Description:

**Describe the PR.**

This PR improves how pathfinding finds a starting water tile when
launching a transport ship from a shore.

Previously, the code simply picked the first water neighbor it found.
This caused issues where, if a boat were traveling east, it might launch
out of a northern tile from a shore.

<img width="896" height="353" alt="image"
src="https://github.com/user-attachments/assets/69d83012-3397-43b3-8ab0-9ebde6ffea97"
/>

<img width="342" height="219" alt="image"
src="https://github.com/user-attachments/assets/a191f5cf-97da-4e34-a191-55ce14c794f0"
/>

The new logic checks all water neighbors and picks the "best" one by
counting how many water tiles surround it. This ensures transport ships
launch into the main body of water instead of suboptimal positions.

If two tiles have water neighbors with the same score, they are
tie-broken through a euclidean distance check.

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

Scisyph

---------

Co-authored-by: WilliamT-byte <williamt2023@tamu.edu>
Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com>
This commit is contained in:
WillTHomeGit
2026-01-18 09:19:55 -06:00
committed by GitHub
parent c179249cdd
commit c123adc0ef
2 changed files with 27 additions and 16 deletions
+2 -2
View File
@@ -47,8 +47,8 @@ export class PathFinding {
return PathFinderBuilder.create(pf)
.wrap((pf) => new ComponentCheckTransformer(pf, componentCheckFn))
.wrap((pf) => new SmoothingWaterTransformer(pf, miniMap))
.wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
.wrap((pf) => new ShoreCoercingTransformer(pf, game.map()))
.buildWithStepper(tileStepperConfig(game));
}
@@ -57,8 +57,8 @@ export class PathFinding {
const pf = new AStarWater(miniMap);
return PathFinderBuilder.create(pf)
.wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
.wrap((pf) => new ShoreCoercingTransformer(pf, game.map()))
.buildWithStepper(tileStepperConfig(game));
}
@@ -1,5 +1,3 @@
// Shore-coercing transformer that converts shore tiles to water tiles for pathfinding
import { GameMap, TileRef } from "../../game/GameMap";
import { PathFinder } from "../types";
@@ -7,9 +5,6 @@ import { PathFinder } from "../types";
* Wraps a PathFinder to handle shore tiles.
* Coerces shore tiles to nearby water tiles before pathfinding,
* then fixes the path extremes to include the original shore tiles.
*
* Works at whatever resolution the map provides - can be used with
* full map or minimap-based pathfinders.
*/
export class ShoreCoercingTransformer implements PathFinder<number> {
constructor(
@@ -34,20 +29,18 @@ export class ShoreCoercingTransformer implements PathFinder<number> {
return null;
}
// Coerce to tile
const coercedTo = this.coerceToWater(to);
if (coercedTo.water === null) {
return null;
}
// Search on water tiles
const fromTiles = waterFrom.length === 1 ? waterFrom[0] : waterFrom;
const path = this.inner.findPath(fromTiles, coercedTo.water);
if (!path || path.length === 0) {
return null;
}
// Look up the actual path start in the map
// Restore original start shore tile
const originalShore = waterToOriginal.get(path[0]);
if (originalShore !== undefined && originalShore !== null) {
path.unshift(originalShore);
@@ -67,25 +60,43 @@ export class ShoreCoercingTransformer implements PathFinder<number> {
/**
* Coerce a tile to water for pathfinding.
* If tile is already water, returns it unchanged.
* If tile is shore (land with water neighbor), finds the nearest water neighbor.
* If tile is shore, finds the best adjacent water neighbor.
*/
private coerceToWater(tile: TileRef): {
water: TileRef | null;
original: TileRef | null;
} {
// If already water, no coercion needed
if (this.map.isWater(tile)) {
return { water: tile, original: null };
}
// Find adjacent water neighbor
let best: TileRef | null = null;
let maxScore = -1;
for (const n of this.map.neighbors(tile)) {
if (this.map.isWater(n)) {
return { water: n, original: tile };
if (!this.map.isWater(n)) continue;
// Score by water neighbor count (connectivity)
const score = this.countWaterNeighbors(n);
// Pick highest connectivity
if (score > maxScore) {
maxScore = score;
best = n;
}
}
// No water neighbor found - let HPA* handle at minimap level
if (best !== null) {
return { water: best, original: tile };
}
return { water: null, original: tile };
}
private countWaterNeighbors(tile: TileRef): number {
let count = 0;
for (const n of this.map.neighbors(tile)) {
if (this.map.isWater(n)) count++;
}
return count;
}
}