From 4564f9e434f3e0cb95d72d11b2de972997729426 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 26 Dec 2025 23:54:56 +0100 Subject: [PATCH] Refactor TransportShipExecution for improved pathfinding and routing - Replace targetTransportTile with MultiSourceAnyTargetBFS bestTransportShipRoute for enhanced route determination - Update pathfinding logic to handle retreat paths --- src/core/execution/TransportShipExecution.ts | 199 ++++++------ src/core/game/TransportShipUtils.ts | 285 ++++++++++++++++-- .../pathfinding/MultiSourceAnyTargetBFS.ts | 48 +-- 3 files changed, 398 insertions(+), 134 deletions(-) diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 6e37e066d..56e5533d1 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -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 { diff --git a/src/core/game/TransportShipUtils.ts b/src/core/game/TransportShipUtils.ts index a0af53526..d7b190e49 100644 --- a/src/core/game/TransportShipUtils.ts +++ b/src/core/game/TransportShipUtils.ts @@ -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, + 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(); + 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( diff --git a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts index 70f1778e4..144381972 100644 --- a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts +++ b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts @@ -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; } } -