Refactor TransportShipExecution for improved pathfinding and routing

- Replace targetTransportTile with MultiSourceAnyTargetBFS bestTransportShipRoute for enhanced route determination
- Update pathfinding logic to handle retreat paths
This commit is contained in:
scamiv
2025-12-26 23:54:56 +01:00
parent 65ca00d54f
commit 4564f9e434
3 changed files with 398 additions and 134 deletions
+111 -88
View File
@@ -10,9 +10,10 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { targetTransportTile } from "../game/TransportShipUtils";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import {
bestTransportShipRoute,
boatPathFromTileToShore,
} from "../game/TransportShipUtils";
import { AttackExecution } from "./AttackExecution";
const malusForRetreat = 25;
@@ -28,12 +29,13 @@ export class TransportShipExecution implements Execution {
private target: Player | TerraNullius;
// TODO make private
public path: TileRef[];
public path: TileRef[] = [];
private dst: TileRef | null;
private boat: Unit;
private pathFinder: PathFinder;
private forwardPath: TileRef[] = [];
private pathIndex = 0;
private usingReverseRetreatPath = false;
private originalOwner: Player;
@@ -70,7 +72,6 @@ export class TransportShipExecution implements Execution {
this.lastMove = ticks;
this.mg = mg;
this.pathFinder = PathFinder.Mini(mg, 10_000, true, 100);
if (
this.attacker.unitCount(UnitType.TransportShip) >=
@@ -100,40 +101,33 @@ export class TransportShipExecution implements Execution {
this.startTroops = Math.min(this.startTroops, this.attacker.troops());
this.dst = targetTransportTile(this.mg, this.ref);
if (this.dst === null) {
const route = bestTransportShipRoute(
this.mg,
this.attacker,
this.ref,
this.src,
);
if (route === false) {
console.warn(
`${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`,
`${this.attacker} cannot send ship to ${this.target}, no route found`,
);
this.active = false;
return;
}
const closestTileSrc = this.attacker.canBuild(
UnitType.TransportShip,
this.dst,
);
if (closestTileSrc === false) {
console.warn(`can't build transport ship`);
// Basic affordability/availability checks (avoid relying on transport-ship spawn heuristics).
const boatCost = this.mg.unitInfo(UnitType.TransportShip).cost(this.mg, this.attacker);
if (!this.attacker.isAlive() || this.attacker.gold() < boatCost) {
this.active = false;
return;
}
if (this.src === null) {
// Only update the src if it's not already set
// because we assume that the src is set to the best spawn tile
this.src = closestTileSrc;
} else {
if (
this.mg.owner(this.src) !== this.attacker ||
!this.mg.isShore(this.src)
) {
console.warn(
`src is not a shore tile or not owned by: ${this.attacker.name()}`,
);
this.src = closestTileSrc;
}
}
this.src = route.src;
this.dst = route.dst;
this.forwardPath = route.path;
this.path = route.path;
this.pathIndex = 0;
this.usingReverseRetreatPath = false;
this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, {
troops: this.startTroops,
@@ -217,67 +211,96 @@ export class TransportShipExecution implements Execution {
if (this.boat.targetTile() !== this.dst) {
this.boat.setTargetTile(this.dst);
}
}
}
const result = this.pathFinder.nextTile(this.boat.tile(), this.dst);
switch (result.type) {
case PathFindResultType.Completed:
if (this.mg.owner(this.dst) === this.attacker) {
const deaths = this.boat.troops() * (malusForRetreat / 100);
const survivors = this.boat.troops() - deaths;
this.attacker.addTroops(survivors);
this.boat.delete(false);
this.active = false;
// Record stats
this.mg
.stats()
.boatArriveTroops(this.attacker, this.target, survivors);
if (deaths) {
this.mg.displayMessage(
`Attack cancelled, ${renderTroops(deaths)} soldiers killed during retreat.`,
MessageType.ATTACK_CANCELLED,
this.attacker.id(),
);
}
return;
}
this.attacker.conquer(this.dst);
if (this.target.isPlayer() && this.attacker.isFriendly(this.target)) {
this.attacker.addTroops(this.boat.troops());
} else {
this.mg.addExecution(
new AttackExecution(
this.boat.troops(),
this.attacker,
this.targetID,
// Retreat is just the existing forward path in reverse (hot-path friendly).
// Fallback to a recompute only if we can't safely reverse (e.g. path invalidated).
if (!this.usingReverseRetreatPath) {
const curr = this.boat.tile();
const idx = curr === null ? -1 : this.forwardPath.indexOf(curr);
if (idx >= 0) {
this.path = this.forwardPath.slice(0, idx + 1).reverse();
this.pathIndex = 0;
this.usingReverseRetreatPath = true;
} else {
const retreatPath = boatPathFromTileToShore(
this.mg,
curr!,
this.dst,
false,
),
);
);
if (retreatPath !== null) {
this.path = retreatPath;
this.pathIndex = 0;
this.usingReverseRetreatPath = true;
}
}
}
this.boat.delete(false);
this.active = false;
// Record stats
this.mg
.stats()
.boatArriveTroops(this.attacker, this.target, this.boat.troops());
return;
case PathFindResultType.NextTile:
this.boat.move(result.node);
break;
case PathFindResultType.Pending:
break;
case PathFindResultType.PathNotFound:
// TODO: add to poisoned port list
console.warn(`path not found to dst`);
this.attacker.addTroops(this.boat.troops());
this.boat.delete(false);
this.active = false;
return;
}
} else {
this.usingReverseRetreatPath = false;
}
if (this.path.length === 0 || this.pathIndex >= this.path.length - 1) {
// Treat as arrived; should be rare (e.g. src==dst edge).
this.finish();
return;
}
const next = this.path[this.pathIndex + 1];
if (next === undefined) {
this.finish();
return;
}
this.boat.move(next);
this.pathIndex++;
if (this.dst !== null && next === this.dst) {
this.finish();
return;
}
}
private finish() {
if (this.dst === null) {
this.active = false;
return;
}
if (this.mg.owner(this.dst) === this.attacker) {
const deaths = this.boat.troops() * (malusForRetreat / 100);
const survivors = this.boat.troops() - deaths;
this.attacker.addTroops(survivors);
this.boat.delete(false);
this.active = false;
this.mg.stats().boatArriveTroops(this.attacker, this.target, survivors);
if (deaths) {
this.mg.displayMessage(
`Attack cancelled, ${renderTroops(deaths)} soldiers killed during retreat.`,
MessageType.ATTACK_CANCELLED,
this.attacker.id(),
);
}
return;
}
this.attacker.conquer(this.dst);
if (this.target.isPlayer() && this.attacker.isFriendly(this.target)) {
this.attacker.addTroops(this.boat.troops());
} else {
this.mg.addExecution(
new AttackExecution(
this.boat.troops(),
this.attacker,
this.targetID,
this.dst,
false,
),
);
}
this.boat.delete(false);
this.active = false;
this.mg.stats().boatArriveTroops(this.attacker, this.target, this.boat.troops());
}
owner(): Player {
+257 -28
View File
@@ -1,8 +1,260 @@
import { PathFindResultType } from "../pathfinding/AStar";
import { MiniAStar } from "../pathfinding/MiniAStar";
import { MultiSourceAnyTargetBFS } from "../pathfinding/MultiSourceAnyTargetBFS";
import { Game, Player, UnitType } from "./Game";
import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap";
type BoatRoute = {
src: TileRef;
dst: TileRef;
path: TileRef[];
};
let boatBfs: MultiSourceAnyTargetBFS | null = null;
let boatBfsNumTiles = 0;
function getBoatBfs(gm: GameMap): MultiSourceAnyTargetBFS {
const numTiles = gm.width() * gm.height();
if (boatBfs === null || boatBfsNumTiles !== numTiles) {
boatBfs = new MultiSourceAnyTargetBFS(numTiles);
boatBfsNumTiles = numTiles;
}
return boatBfs;
}
function insertTopK(
items: { tile: TileRef; dist: number }[],
tile: TileRef,
dist: number,
k: number,
) {
if (items.length === 0) {
items.push({ tile, dist });
return;
}
if (items.length === k && dist >= items[items.length - 1]!.dist) {
return;
}
let i = items.length;
items.push({ tile, dist });
while (i > 0 && items[i - 1]!.dist > dist) {
items[i] = items[i - 1]!;
i--;
}
items[i] = { tile, dist };
if (items.length > k) items.pop();
}
function shoreTargetsNearClick(
gm: Game,
attacker: Player,
click: TileRef,
targetOwner: Player | ReturnType<Game["terraNullius"]>,
maxTargets: number,
scanRadiusTN: number,
): TileRef[] {
// Explicit target: if user clicks a shore tile, use that exact shore.
if (gm.isShore(click) && gm.owner(click) !== attacker) {
const owner = gm.owner(click);
if (!owner.isPlayer() || !attacker.isFriendly(owner)) {
return [click];
}
return [];
}
// Water click: search a larger area but return only the closest shore tile.
// This prevents "snapping" to far-away shores while still being usable on open water.
if (gm.isWater(click)) {
const cx = gm.x(click);
const cy = gm.y(click);
const r = 50;
let best: TileRef | null = null;
let bestDist = Infinity;
for (let y = cy - r; y <= cy + r; y++) {
for (let x = cx - r; x <= cx + r; x++) {
if (!gm.isValidCoord(x, y)) continue;
const tile = gm.ref(x, y);
if (!gm.isShore(tile)) continue;
const owner = gm.owner(tile);
if (owner === attacker) continue;
if (owner.isPlayer() && attacker.isFriendly(owner)) continue;
const dist = Math.abs(x - cx) + Math.abs(y - cy);
if (dist < bestDist) {
bestDist = dist;
best = tile;
}
}
}
return best === null ? [] : [best];
}
// Default behavior: scan a bounding box near the click for candidate shore tiles.
// (Previously, player targets used all border tiles, which could pick very distant shores.)
const top: { tile: TileRef; dist: number }[] = [];
const cx = gm.x(click);
const cy = gm.y(click);
const r = scanRadiusTN;
for (let y = cy - r; y <= cy + r; y++) {
for (let x = cx - r; x <= cx + r; x++) {
if (!gm.isValidCoord(x, y)) continue;
const tile = gm.ref(x, y);
if (!gm.isShore(tile)) continue;
if (targetOwner.isPlayer()) {
if (gm.owner(tile) !== targetOwner) continue;
} else {
if (gm.hasOwner(tile)) continue;
}
if (gm.owner(tile) === attacker) continue;
const dist = Math.abs(x - cx) + Math.abs(y - cy);
insertTopK(top, tile, dist, maxTargets);
}
}
return top.map((x) => x.tile);
}
function adjacentWaterTiles(gm: GameMap, shore: TileRef): TileRef[] {
const out: TileRef[] = [];
for (const n of gm.neighbors(shore)) {
if (gm.isWater(n)) out.push(n);
}
return out;
}
function pickLandingForTargetWater(
gm: GameMap,
click: TileRef,
targetWater: TileRef,
targetShores: readonly TileRef[],
): TileRef | null {
// targetShores are already sorted by closeness to click; first adjacency wins.
for (const shore of targetShores) {
for (const n of gm.neighbors(shore)) {
if (n === targetWater) return shore;
}
}
// Fallback: should not happen if targetWater was built from these shores.
let best: TileRef | null = null;
let bestDist = Infinity;
for (const shore of targetShores) {
if (!gm.neighbors(shore).some((n) => gm.isWater(n))) continue;
const d = gm.manhattanDist(click, shore);
if (d < bestDist) {
bestDist = d;
best = shore;
}
}
return best;
}
export function boatPathFromTileToShore(
gm: GameMap,
startTile: TileRef,
dstShore: TileRef,
): TileRef[] | null {
if (!gm.isValidRef(startTile) || !gm.isValidRef(dstShore)) return null;
if (!gm.isShore(dstShore)) return null;
const targetWater = adjacentWaterTiles(gm, dstShore);
if (targetWater.length === 0) return null;
const bfs = getBoatBfs(gm);
let seedNodes: TileRef[] = [];
let seedOrigins: TileRef[] = [];
if (gm.isWater(startTile)) {
seedNodes = [startTile];
seedOrigins = [startTile];
} else if (gm.isShore(startTile)) {
const adj = adjacentWaterTiles(gm, startTile);
if (adj.length === 0) return null;
seedNodes = adj;
seedOrigins = adj.map(() => startTile);
} else {
return null;
}
const result = bfs.findWaterPathFromSeeds(gm, seedNodes, seedOrigins, targetWater, {
kingMoves: true,
noCornerCutting: true,
maxVisited: 300_000,
});
if (result === null) return null;
if (gm.isWater(startTile)) {
return [...result.path, dstShore];
}
return [startTile, ...result.path, dstShore];
}
export function bestTransportShipRoute(
gm: Game,
attacker: Player,
clickTile: TileRef,
preferredSrc: TileRef | null = null,
maxTargetShores = 96,
): BoatRoute | false {
const other = gm.owner(clickTile);
if (other === attacker) return false;
if (other.isPlayer() && attacker.isFriendly(other)) return false;
const targetShores = shoreTargetsNearClick(
gm,
attacker,
clickTile,
other,
maxTargetShores,
10,
);
if (targetShores.length === 0) return false;
const targetWater: TileRef[] = [];
for (const shore of targetShores) {
targetWater.push(...adjacentWaterTiles(gm, shore));
}
if (targetWater.length === 0) return false;
const sourceShores: TileRef[] =
preferredSrc !== null && gm.isValidRef(preferredSrc)
? [preferredSrc]
: candidateShoreTiles(gm, attacker, clickTile);
const seedNodeToOrigin = new Map<TileRef, TileRef>();
for (const shore of sourceShores) {
if (!gm.isValidRef(shore)) continue;
if (gm.owner(shore) !== attacker) continue;
if (!gm.isShore(shore)) continue;
for (const w of adjacentWaterTiles(gm, shore)) {
if (!seedNodeToOrigin.has(w)) {
seedNodeToOrigin.set(w, shore);
}
}
}
if (seedNodeToOrigin.size === 0) return false;
const seedNodes: TileRef[] = [];
const seedOrigins: TileRef[] = [];
for (const [node, origin] of seedNodeToOrigin.entries()) {
seedNodes.push(node);
seedOrigins.push(origin);
}
const bfs = getBoatBfs(gm);
const result = bfs.findWaterPathFromSeeds(gm, seedNodes, seedOrigins, targetWater, {
kingMoves: true,
noCornerCutting: true,
// Hard budget to avoid pathological cases; tweak as needed.
maxVisited: 300_000,
});
if (result === null) return false;
const dst = pickLandingForTargetWater(gm, clickTile, result.target, targetShores);
if (dst === null) return false;
const src = result.source;
// Full route includes the shore endpoints to drive unit movement.
const path = [src, ...result.path, dst];
return { src, dst, path };
}
export function canBuildTransportShip(
game: Game,
player: Player,
@@ -144,32 +396,9 @@ export function bestShoreDeploymentSource(
player: Player,
target: TileRef,
): TileRef | false {
const t = targetTransportTile(gm, target);
if (t === null) return false;
const candidates = candidateShoreTiles(gm, player, t);
if (candidates.length === 0) return false;
const aStar = new MiniAStar(gm, gm.miniMap(), candidates, t, 1_000_000, 1);
const result = aStar.compute();
if (result !== PathFindResultType.Completed) {
console.warn(`bestShoreDeploymentSource: path not found: ${result}`);
return false;
}
const path = aStar.reconstructPath();
if (path.length === 0) {
return false;
}
const potential = path[0];
// Since mini a* downscales the map, we need to check the neighbors
// of the potential tile to find a valid deployment point
const neighbors = gm
.neighbors(potential)
.filter((n) => gm.isShore(n) && gm.owner(n) === player);
if (neighbors.length === 0) {
return false;
}
return neighbors[0];
const route = bestTransportShipRoute(gm, player, target, null);
if (route === false) return false;
return route.src;
}
export function candidateShoreTiles(
+30 -18
View File
@@ -41,7 +41,17 @@ export class MultiSourceAnyTargetBFS {
targets: readonly TileRef[],
opts: MultiSourceAnyTargetBFSOptions = {},
): MultiSourceAnyTargetBFSResult | null {
if (sources.length === 0 || targets.length === 0) return null;
return this.findWaterPathFromSeeds(gm, sources, sources, targets, opts);
}
findWaterPathFromSeeds(
gm: GameMap,
seedNodes: readonly TileRef[],
seedOrigins: readonly TileRef[],
targets: readonly TileRef[],
opts: MultiSourceAnyTargetBFSOptions = {},
): MultiSourceAnyTargetBFSResult | null {
if (seedNodes.length === 0 || targets.length === 0) return null;
const stamp = this.nextStamp();
@@ -58,14 +68,17 @@ export class MultiSourceAnyTargetBFS {
let head = 0;
let tail = 0;
for (const s of sources) {
if (s < 0 || s >= this.visitedStamp.length) continue;
if (!gm.isWater(s)) continue;
if (this.visitedStamp[s] === stamp) continue;
this.visitedStamp[s] = stamp;
this.prev[s] = -1;
this.startOf[s] = s;
this.queue[tail++] = s;
const count = Math.min(seedNodes.length, seedOrigins.length);
for (let i = 0; i < count; i++) {
const node = seedNodes[i]!;
const origin = seedOrigins[i]!;
if (node < 0 || node >= this.visitedStamp.length) continue;
if (!gm.isWater(node)) continue;
if (this.visitedStamp[node] === stamp) continue;
this.visitedStamp[node] = stamp;
this.prev[node] = -1;
this.startOf[node] = origin;
this.queue[tail++] = node;
}
if (tail === 0) return null;
@@ -94,7 +107,7 @@ export class MultiSourceAnyTargetBFS {
if (gm.isWater(n) && this.visitedStamp[n] !== stamp) {
this.visit(n, node, stamp);
this.queue[tail++] = n;
if (++visitedCount >= maxVisited) return null;
if (++visitedCount > maxVisited) return null;
}
}
if (node < lastRowStart) {
@@ -102,7 +115,7 @@ export class MultiSourceAnyTargetBFS {
if (gm.isWater(s) && this.visitedStamp[s] !== stamp) {
this.visit(s, node, stamp);
this.queue[tail++] = s;
if (++visitedCount >= maxVisited) return null;
if (++visitedCount > maxVisited) return null;
}
}
if (x !== 0) {
@@ -110,7 +123,7 @@ export class MultiSourceAnyTargetBFS {
if (gm.isWater(wv) && this.visitedStamp[wv] !== stamp) {
this.visit(wv, node, stamp);
this.queue[tail++] = wv;
if (++visitedCount >= maxVisited) return null;
if (++visitedCount > maxVisited) return null;
}
}
if (x !== w - 1) {
@@ -118,7 +131,7 @@ export class MultiSourceAnyTargetBFS {
if (gm.isWater(ev) && this.visitedStamp[ev] !== stamp) {
this.visit(ev, node, stamp);
this.queue[tail++] = ev;
if (++visitedCount >= maxVisited) return null;
if (++visitedCount > maxVisited) return null;
}
}
@@ -134,7 +147,7 @@ export class MultiSourceAnyTargetBFS {
) {
this.visit(nw, node, stamp);
this.queue[tail++] = nw;
if (++visitedCount >= maxVisited) return null;
if (++visitedCount > maxVisited) return null;
}
}
if (node >= w && x !== w - 1) {
@@ -146,7 +159,7 @@ export class MultiSourceAnyTargetBFS {
) {
this.visit(ne, node, stamp);
this.queue[tail++] = ne;
if (++visitedCount >= maxVisited) return null;
if (++visitedCount > maxVisited) return null;
}
}
if (node < lastRowStart && x !== 0) {
@@ -158,7 +171,7 @@ export class MultiSourceAnyTargetBFS {
) {
this.visit(sw, node, stamp);
this.queue[tail++] = sw;
if (++visitedCount >= maxVisited) return null;
if (++visitedCount > maxVisited) return null;
}
}
if (node < lastRowStart && x !== w - 1) {
@@ -170,7 +183,7 @@ export class MultiSourceAnyTargetBFS {
) {
this.visit(se, node, stamp);
this.queue[tail++] = se;
if (++visitedCount >= maxVisited) return null;
if (++visitedCount > maxVisited) return null;
}
}
}
@@ -201,4 +214,3 @@ export class MultiSourceAnyTargetBFS {
return this.stamp;
}
}