diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index 05ece46bf..bd29e5521 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -634,29 +634,59 @@ export class WarshipExecution implements Execution { private huntDownTradeShip() { this.warship.updateWarshipState({ isInCombat: true }); for (let i = 0; i < 2; i++) { - // target is trade ship so capture it. - const result = this.pathfinder.next( - this.warship.tile(), - this.warship.targetUnit()!.tile(), - 5, - ); + const target = this.warship.targetUnit()!; + const targetTile = target.tile(); + const dist = this.mg.manhattanDist(this.warship.tile(), targetTile); + + if (dist <= 5) { + this.warship.owner().captureUnit(target); + this.warship.setTargetUnit(undefined); + this.warship.touch(); + return; + } + + // When close, the minimap (2x scale) produces diagonal upscaled paths that + // make it hard to converge. Use direct greedy movement instead. + if (dist <= 20) { + const nextTile = this.bestNeighborToward(targetTile); + if (nextTile !== undefined) { + this.warship.move(nextTile); + continue; + } + } + + const result = this.pathfinder.next(this.warship.tile(), targetTile, 5); switch (result.status) { case PathStatus.COMPLETE: - this.warship.owner().captureUnit(this.warship.targetUnit()!); + this.warship.owner().captureUnit(target); this.warship.setTargetUnit(undefined); - this.warship.move(this.warship.tile()); + this.warship.touch(); return; case PathStatus.NEXT: this.warship.move(result.node); break; - case PathStatus.NOT_FOUND: { + case PathStatus.NOT_FOUND: console.log(`path not found to target`); break; - } } } } + private bestNeighborToward(targetTile: TileRef): TileRef | undefined { + const warshipTile = this.warship.tile(); + let best: TileRef | undefined; + let bestDist = this.mg.manhattanDist(warshipTile, targetTile); + this.mg.forEachNeighbor(warshipTile, (neighbor) => { + if (!this.mg.isWater(neighbor)) return; + const d = this.mg.manhattanDist(neighbor, targetTile); + if (d < bestDist) { + bestDist = d; + best = neighbor; + } + }); + return best; + } + private patrol() { if (this.warship.targetTile() === undefined) { this.warship.setTargetTile(this.randomTile()); diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts index c88664f2e..ffb7dbcd2 100644 --- a/tests/Warship.test.ts +++ b/tests/Warship.test.ts @@ -852,4 +852,71 @@ describe("Warship", () => { game.executeNextTick(); expect(warship.tile()).not.toBe(tileBeforeCombat); }); + + test("Warship captures trade ship immediately when already within capture range", async () => { + // Trade ship is within Manhattan distance 5 — should be captured on the first tick + // via the dist <= 5 fast path in huntDownTradeShip, without needing pathfinding. + player1.buildUnit(UnitType.Port, game.ref(coastX, 8), {}); + const warship = player1.buildUnit( + UnitType.Warship, + game.ref(coastX + 1, 8), + { + patrolTile: game.ref(coastX + 1, 8), + }, + ); + const tradeShip = player2.buildUnit( + UnitType.TradeShip, + game.ref(coastX + 1, 11), // Manhattan distance 3 from warship + { + targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 11), {}), + }, + ); + + const execution = new WarshipExecution(warship); + const executionInternals = execution as unknown as { + findTargetUnit: () => typeof tradeShip | undefined; + }; + execution.init(game, game.ticks()); + vi.spyOn(executionInternals, "findTargetUnit").mockReturnValue(tradeShip); + + expect(tradeShip.owner().id()).toBe(player2.id()); + execution.tick(game.ticks()); + expect(tradeShip.owner()).toBe(player1); + }); + + test("Warship uses greedy pursuit to capture trade ship within 20 tiles", async () => { + // Trade ship is within the 20-tile greedy range but outside the 5-tile instant-capture + // range. The warship should use direct neighbor movement (not minimap pathfinding) + // and close the gap cleanly. + player1.buildUnit(UnitType.Port, game.ref(coastX, 3), {}); + const warship = player1.buildUnit( + UnitType.Warship, + game.ref(coastX + 1, 3), + { + patrolTile: game.ref(coastX + 1, 3), + }, + ); + const tradeShip = player2.buildUnit( + UnitType.TradeShip, + game.ref(coastX + 1, 13), // Manhattan distance 10 — within greedy range + { + targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 13), {}), + }, + ); + + const execution = new WarshipExecution(warship); + const executionInternals = execution as unknown as { + findTargetUnit: () => typeof tradeShip | undefined; + }; + execution.init(game, game.ticks()); + vi.spyOn(executionInternals, "findTargetUnit").mockReturnValue(tradeShip); + + expect(tradeShip.owner().id()).toBe(player2.id()); + // 10 tiles at 2 steps/tick = 5 ticks minimum + for (let i = 0; i < 10; i++) { + execution.tick(game.ticks()); + if (tradeShip.owner() === player1) break; + } + expect(tradeShip.owner()).toBe(player1); + }); });