2661 PR 3/3 Warship Manual Override, Aggro Override, and Heal-at-Port Command (#3501)

Part of [#2661](https://github.com/openfrontio/OpenFrontIO/issues/2661)
(split into 3 PRs so they are not too large..)

## Description:

Part 3/3 of
[#2661](https://github.com/openfrontio/OpenFrontIO/issues/2661).

This PR adds the retreat control and override behavior for warships:

- Manual override: moving a warship manually cancels retreat and
suppresses auto-retreat for 5 seconds
- Aggro override: a retreating warship will aggro a nearby enemy
transport or warship before continuing retreat
- Heal-at-port command for sending a warship to a friendly port manually
- Friendly-port validation for HealAtPortExecution
- Regression tests for manual override, aggro override, and heal-at-port
behavior



## 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:

zixer._

---------

Co-authored-by: iamlewis <lewismmmm@gmail.com>
Co-authored-by: evanpelle <evanpelle@gmail.com>
This commit is contained in:
Zixer1
2026-04-30 13:54:28 -06:00
committed by GitHub
co-authored by iamlewis evanpelle
parent f1d0136a06
commit 742a544a69
20 changed files with 781 additions and 246 deletions
+310 -9
View File
@@ -174,7 +174,7 @@ describe("Warship", () => {
executeTicks(game, 10);
expect(warship.patrolTile()).toBe(game.ref(coastX + 5, 15));
expect(warship.warshipState().patrolTile).toBe(game.ref(coastX + 5, 15));
});
test("Warship does not not target trade ships outside of patrol range", async () => {
@@ -283,7 +283,7 @@ describe("Warship", () => {
[warship.id()],
game.ref(coastX + 5, 15),
).init(game, 0);
expect(warship.patrolTile()).toBe(originalPatrolTile);
expect(warship.warshipState().patrolTile).toBe(originalPatrolTile);
});
test("MoveWarshipExecution fails if warship is not active", async () => {
@@ -301,7 +301,7 @@ describe("Warship", () => {
[warship.id()],
game.ref(coastX + 5, 15),
).init(game, 0);
expect(warship.patrolTile()).toBe(originalPatrolTile);
expect(warship.warshipState().patrolTile).toBe(originalPatrolTile);
});
test("MoveWarshipExecution fails gracefully if warship not found", async () => {
@@ -346,7 +346,7 @@ describe("Warship", () => {
game.executeNextTick();
expect(warship.retreating()).toBe(true);
expect(warship.warshipState().state).not.toBe("patrolling");
const distanceToPort = game.euclideanDistSquared(
warship.tile(),
homePort.tile(),
@@ -432,7 +432,7 @@ describe("Warship", () => {
exec1.isDocked() &&
!exec2.isDocked() &&
warship2DistanceToPort <= 25 &&
warship2.retreating()
warship2.warshipState().state !== "patrolling"
) {
break;
}
@@ -445,7 +445,7 @@ describe("Warship", () => {
expect(exec1.isDocked()).toBe(true);
expect(exec2.isDocked()).toBe(false);
expect(warship2DistanceToPort).toBeLessThanOrEqual(25);
expect(warship2.retreating()).toBe(true);
expect(warship2.warshipState().state).not.toBe("patrolling");
});
test("Warship cancels docking if its retreat port is destroyed", async () => {
@@ -481,7 +481,7 @@ describe("Warship", () => {
game.executeNextTick();
expect(warshipExecution.isDocked()).toBe(false);
expect(warship.retreating()).toBe(false);
expect(warship.warshipState().state).toBe("patrolling");
});
test("Warship drops a stale target after patrol movement changes range", async () => {
@@ -521,8 +521,9 @@ describe("Warship", () => {
});
execution.tick(game.ticks());
expect(warship.tile()).toBe(movedTile);
execution.tick(game.ticks());
expect(warship.targetUnit()).toBeUndefined();
});
@@ -549,6 +550,306 @@ describe("Warship", () => {
warship.modifyHealth(-300);
game.executeNextTick();
expect(warship.retreating()).toBe(false);
expect(warship.warshipState().state).toBe("patrolling");
});
test("Low-health warship retreats AND fires at nearby enemy warship", async () => {
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipRetreatHealthThreshold = () => 600;
game.config().warshipTargettingRange = () => 5;
game.config().warshipShellAttackRate = () => 10_000;
player1.buildUnit(UnitType.Port, game.ref(coastX, 5), {});
const warship = player1.buildUnit(
UnitType.Warship,
game.ref(coastX + 1, 15),
{
patrolTile: game.ref(coastX + 1, 15),
},
);
const enemyWarship = player2.buildUnit(
UnitType.Warship,
game.ref(coastX + 2, 15),
{
patrolTile: game.ref(coastX + 2, 15),
},
);
game.addExecution(new WarshipExecution(warship));
game.addExecution(new WarshipExecution(enemyWarship));
game.executeNextTick();
warship.modifyHealth(-700);
game.executeNextTick();
// New behavior: retreat starts immediately even with enemy nearby
expect(warship.warshipState().state).not.toBe("patrolling");
// AND the warship still targets the enemy to fire back while retreating
expect(warship.targetUnit()).toBe(enemyWarship);
});
test("Retreating warship aggroes nearby enemy transport before continuing retreat", async () => {
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipRetreatHealthThreshold = () => 600;
game.config().warshipTargettingRange = () => 5;
game.config().warshipShellAttackRate = () => 10_000;
const homePort = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
const warship = player1.buildUnit(
UnitType.Warship,
game.ref(coastX + 6, 12),
{
patrolTile: game.ref(coastX + 6, 12),
},
);
game.addExecution(new WarshipExecution(warship));
game.executeNextTick();
warship.modifyHealth(-700);
for (let i = 0; i < 10; i++) {
game.executeNextTick();
if (
warship.warshipState().state !== "patrolling" &&
warship.targetTile() === homePort.tile() &&
warship.tile() !== homePort.tile()
) {
break;
}
}
expect(warship.warshipState().state).not.toBe("patrolling");
expect(warship.targetTile()).toBe(homePort.tile());
const enemyTransport = player2.buildUnit(
UnitType.TransportShip,
game.ref(coastX + 5, 12),
{
targetTile: game.ref(coastX + 5, 12),
},
);
game.executeNextTick();
expect(warship.warshipState().state).not.toBe("patrolling");
expect(warship.targetTile()).toBe(homePort.tile());
expect(warship.targetUnit()).toBe(enemyTransport);
});
test("Manual MoveWarshipExecution cancels retreat and keeps manual order", async () => {
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipRetreatHealthThreshold = () => 600;
const homePortTile = game.ref(coastX, 10);
player1.buildUnit(UnitType.Port, homePortTile, {});
const warship = player1.buildUnit(
UnitType.Warship,
game.ref(coastX + 1, 11),
{
patrolTile: game.ref(coastX + 1, 11),
},
);
game.addExecution(new WarshipExecution(warship));
game.executeNextTick();
warship.modifyHealth(-700);
executeTicks(game, 20);
expect(warship.warshipState().state).not.toBe("patrolling");
const manualPatrolTile = game.ref(coastX + 5, 15);
game.addExecution(
new MoveWarshipExecution(player1, [warship.id()], manualPatrolTile),
);
executeTicks(game, 2);
expect(warship.warshipState().state).toBe("patrolling");
expect(warship.warshipState().patrolTile).toBe(manualPatrolTile);
expect(warship.targetTile()).not.toBe(homePortTile);
});
test("Manual MoveWarshipExecution suppresses auto-retreat for 5 seconds before retreat starts", async () => {
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipRetreatHealthThreshold = () => 600;
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
const warship = player1.buildUnit(
UnitType.Warship,
game.ref(coastX + 1, 11),
{
patrolTile: game.ref(coastX + 1, 11),
},
);
game.addExecution(new WarshipExecution(warship));
game.executeNextTick();
const manualPatrolTile = game.ref(coastX + 6, 15);
game.addExecution(
new MoveWarshipExecution(player1, [warship.id()], manualPatrolTile),
);
game.executeNextTick();
warship.modifyHealth(-700);
game.executeNextTick();
expect(warship.warshipState().state).toBe("patrolling");
expect(warship.warshipState().patrolTile).toBe(manualPatrolTile);
executeTicks(game, 48);
expect(warship.warshipState().state).toBe("patrolling");
let resumedRetreat = false;
for (let i = 0; i < 5; i++) {
game.executeNextTick();
if (warship.warshipState().state !== "patrolling") {
resumedRetreat = true;
break;
}
}
expect(resumedRetreat).toBe(true);
});
test("Warship isInCombat becomes true when hit by a shell from an enemy", async () => {
game.config().warshipPassiveHealing = () => 0;
const warship = player1.buildUnit(
UnitType.Warship,
game.ref(coastX + 1, 10),
{ patrolTile: game.ref(coastX + 1, 10) },
);
game.addExecution(new WarshipExecution(warship));
game.executeNextTick();
expect(warship.warshipState().isInCombat).toBe(false);
// Simulate incoming shell damage from an enemy player
warship.modifyHealth(-50, player2);
expect(warship.warshipState().isInCombat).toBe(true);
});
test("Warship isInCombat becomes true when firing at an enemy", async () => {
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipShellAttackRate = () => 0;
game.config().warshipTargettingRange = () => 5;
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
const warship = player1.buildUnit(
UnitType.Warship,
game.ref(coastX + 1, 10),
{ patrolTile: game.ref(coastX + 1, 10) },
);
const enemyWarship = player2.buildUnit(
UnitType.Warship,
game.ref(coastX + 2, 10),
{ patrolTile: game.ref(coastX + 2, 10) },
);
game.addExecution(new WarshipExecution(warship));
game.executeNextTick();
expect(warship.warshipState().isInCombat).toBe(false);
// Give warship a target and tick — shootTarget sets inCombat
warship.setTargetUnit(enemyWarship);
game.executeNextTick();
expect(warship.warshipState().isInCombat).toBe(true);
});
test("Docked warship is not targeted by enemy warship", async () => {
game.config().warshipPassiveHealing = () => 0;
game.config().warshipDockingRange = () => 5;
game.config().warshipRetreatHealthThreshold = () => 900;
game.config().warshipTargettingRange = () => 20;
const portTile = game.ref(coastX, 10);
player1.buildUnit(UnitType.Port, portTile, {});
const friendlyWarship = player1.buildUnit(
UnitType.Warship,
game.ref(coastX + 1, 10),
{ patrolTile: game.ref(coastX + 1, 10) },
);
const exec = new WarshipExecution(friendlyWarship);
game.addExecution(exec);
const enemyWarship = player2.buildUnit(
UnitType.Warship,
game.ref(coastX + 2, 10),
{ patrolTile: game.ref(coastX + 2, 10) },
);
const enemyExec = new WarshipExecution(enemyWarship);
game.addExecution(enemyExec);
game.executeNextTick();
friendlyWarship.modifyHealth(-300);
// Wait until friendly warship docks
for (let i = 0; i < 80; i++) {
game.executeNextTick();
if (exec.isDocked()) break;
}
expect(exec.isDocked()).toBe(true);
expect(friendlyWarship.warshipState().state).toBe("docked");
// Enemy warship should not be targeting the docked warship
game.executeNextTick();
expect(enemyWarship.targetUnit()).not.toBe(friendlyWarship);
});
test("Retreating warship continues moving to port after firing back", async () => {
game.config().warshipPortHealingBonusPerLevel = () => 0;
game.config().warshipRetreatHealthThreshold = () => 600;
game.config().warshipTargettingRange = () => 5;
game.config().warshipShellAttackRate = () => 10_000;
const homePort = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
const warship = player1.buildUnit(
UnitType.Warship,
game.ref(coastX + 6, 12),
{ patrolTile: game.ref(coastX + 6, 12) },
);
game.addExecution(new WarshipExecution(warship));
game.executeNextTick();
warship.modifyHealth(-700);
// Wait until retreating and heading to port
for (let i = 0; i < 15; i++) {
game.executeNextTick();
if (
warship.warshipState().state !== "patrolling" &&
warship.targetTile() === homePort.tile()
) {
break;
}
}
expect(warship.warshipState().state).not.toBe("patrolling");
expect(warship.targetTile()).toBe(homePort.tile());
const tileBeforeCombat = warship.tile();
const enemyTransport = player2.buildUnit(
UnitType.TransportShip,
game.ref(coastX + 5, 12),
{ targetTile: game.ref(coastX + 5, 12) },
);
// After encountering enemy: still retreating, still targeting port,
// AND targeting the enemy transport simultaneously
game.executeNextTick();
expect(warship.warshipState().state).not.toBe("patrolling");
expect(warship.targetTile()).toBe(homePort.tile());
expect(warship.targetUnit()).toBe(enemyTransport);
// Warship should still be moving (not frozen at tileBeforeCombat)
game.executeNextTick();
expect(warship.tile()).not.toBe(tileBeforeCombat);
});
});