Fix warship diagonal chase and improve trade ship capture reliability (#3807)

## Description:

The warship pathfinder operates on a 2x downscaled mini-map, and
upscaling mini-map paths back to full coordinates produces diagonal
interpolated steps. At close range (< 20 tiles), the entire path
consists of these diagonal moves, causing the warship to approach the
trade ship at an awkward angle and never converge cleanly.
## 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:

evan
This commit is contained in:
Evan
2026-04-30 16:37:44 -06:00
committed by GitHub
parent 4f20d2b332
commit ccb80f4245
2 changed files with 107 additions and 10 deletions
+40 -10
View File
@@ -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());
+67
View File
@@ -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);
});
});