mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +00:00
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:
@@ -377,16 +377,16 @@ export class AttacksDisplay extends LitElement implements Layer {
|
||||
"text-left text-aquarius inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
|
||||
translate: false,
|
||||
})}
|
||||
${!boat.retreating()
|
||||
? this.renderButton({
|
||||
content: "❌",
|
||||
${boat.transportShipState().isRetreating
|
||||
? html`<span class="ml-auto truncate text-aquarius"
|
||||
>(${translateText("events_display.retreating")}...)</span
|
||||
>`
|
||||
: this.renderButton({
|
||||
content: "\u274C",
|
||||
onClick: () => this.emitBoatCancelIntent(boat.id()),
|
||||
className: "ml-auto text-left shrink-0",
|
||||
disabled: boat.retreating(),
|
||||
})
|
||||
: html`<span class="ml-auto truncate text-aquarius"
|
||||
>(${translateText("events_display.retreating")}...)</span
|
||||
>`}
|
||||
disabled: boat.transportShipState().isRetreating,
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
|
||||
@@ -456,12 +456,17 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
private handleWarShipEvent(unit: UnitView) {
|
||||
if (unit.retreating()) {
|
||||
this.drawSprite(unit, colord("rgb(0,180,255)"));
|
||||
if (unit.warshipState().state !== "patrolling" && unit.isActive()) {
|
||||
if (unit.warshipState().isInCombat) {
|
||||
this.drawSprite(unit, colord("rgb(200,0,0)"));
|
||||
} else {
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
this.drawRetreatCross(unit);
|
||||
return;
|
||||
}
|
||||
|
||||
if (unit.targetUnitId()) {
|
||||
if (unit.warshipState().isInCombat) {
|
||||
this.drawSprite(unit, colord("rgb(200,0,0)"));
|
||||
return;
|
||||
}
|
||||
@@ -469,6 +474,27 @@ export class UnitLayer implements Layer {
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
|
||||
private drawRetreatCross(unit: UnitView) {
|
||||
// Blink: 500ms on, 500ms off
|
||||
if (Math.floor(Date.now() / 500) % 2 === 0) return;
|
||||
const x = this.game.x(unit.tile());
|
||||
const y = this.game.y(unit.tile());
|
||||
const ctx = this.context;
|
||||
ctx.save();
|
||||
const cx = x + 0.5;
|
||||
const cy = y + 0.5;
|
||||
ctx.lineCap = "square";
|
||||
ctx.strokeStyle = "rgb(36,36,36)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy - 1.5);
|
||||
ctx.lineTo(cx, cy + 1.5);
|
||||
ctx.moveTo(cx - 1.5, cy);
|
||||
ctx.lineTo(cx + 1.5, cy);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private handleShellEvent(unit: UnitView) {
|
||||
const rel = this.relationship(unit);
|
||||
|
||||
|
||||
@@ -122,7 +122,8 @@ export class NavalTarget extends Target {
|
||||
if (
|
||||
!this.ended &&
|
||||
(!this.unit.isActive() ||
|
||||
(this.unit.type() === UnitType.TransportShip && this.unit.retreating()))
|
||||
(this.unit.type() === UnitType.TransportShip &&
|
||||
this.unit.transportShipState().isRetreating))
|
||||
) {
|
||||
this.ended = true;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export class BoatRetreatExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
unit.orderBoatRetreat();
|
||||
unit.updateTransportShipState({ isRetreating: true });
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ export class DeleteUnitExecution implements Execution {
|
||||
}
|
||||
this.mg = mg;
|
||||
|
||||
const unit = this.player.units().find((u) => u.id() === this.unitId);
|
||||
if (!unit) {
|
||||
const unit = this.mg.unit(this.unitId);
|
||||
if (!unit || unit.owner() !== this.player) {
|
||||
console.warn(
|
||||
`SECURITY: unit ${this.unitId} not found or not owned by player ${this.player.displayName()}`,
|
||||
);
|
||||
|
||||
@@ -28,7 +28,9 @@ export class MoveWarshipExecution implements Execution {
|
||||
console.warn(`MoveWarshipExecution: warship ${unitId} is not active`);
|
||||
continue;
|
||||
}
|
||||
warship.setPatrolTile(this.position);
|
||||
warship.updateWarshipState({
|
||||
patrolTile: this.position,
|
||||
});
|
||||
warship.setTargetTile(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,14 +198,14 @@ export class TransportShipExecution implements Execution {
|
||||
// Checked every tick (not just on graph rebuild) because graph rebuilds
|
||||
// are throttled and the tile may already be water before the version bumps.
|
||||
if (this.dst !== null && this.mg.isWater(this.dst)) {
|
||||
if (!this.boat.retreating()) {
|
||||
this.boat.orderBoatRetreat();
|
||||
if (!this.boat.transportShipState().isRetreating) {
|
||||
this.boat.updateTransportShipState({ isRetreating: true });
|
||||
}
|
||||
// Reset cached retreat destination so it's recomputed from current position
|
||||
this.retreatDst = null;
|
||||
}
|
||||
|
||||
if (this.boat.retreating()) {
|
||||
if (this.boat.transportShipState().isRetreating) {
|
||||
// Resolve retreat destination once, based on current boat location when retreat begins.
|
||||
this.retreatDst ??= this.attacker.bestTransportShipSpawn(
|
||||
this.boat.tile(),
|
||||
|
||||
@@ -10,9 +10,11 @@ export class UpgradeStructureExecution implements Execution {
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.structure = this.player
|
||||
.units()
|
||||
.find((unit) => unit.id() === this.unitId);
|
||||
this.structure = mg.unit(this.unitId);
|
||||
if (this.structure && this.structure.owner() !== this.player) {
|
||||
console.warn(`structure not owned by player`);
|
||||
this.structure = undefined;
|
||||
}
|
||||
|
||||
if (this.structure === undefined) {
|
||||
console.warn(`structure is undefined`);
|
||||
|
||||
@@ -21,10 +21,10 @@ export class WarshipExecution implements Execution {
|
||||
private pathfinder: WaterPathFinder;
|
||||
private lastShellAttack = 0;
|
||||
private alreadySentShell = new Set<Unit>();
|
||||
private retreatPortTile: TileRef | undefined;
|
||||
private retreatingForRepair = false;
|
||||
private docked = false;
|
||||
private lastManualMoveTickRetreatDisabled = 0;
|
||||
private lastObservedPatrolTile: TileRef | undefined;
|
||||
private activeHealingRemainder = 0;
|
||||
private lastEmittedCombat = false;
|
||||
|
||||
constructor(
|
||||
private input: (UnitParams<UnitType.Warship> & OwnerComp) | Unit,
|
||||
@@ -53,6 +53,7 @@ export class WarshipExecution implements Execution {
|
||||
this.input,
|
||||
);
|
||||
}
|
||||
this.lastObservedPatrolTile = this.warship.warshipState().patrolTile;
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
@@ -60,20 +61,24 @@ export class WarshipExecution implements Execution {
|
||||
this.warship.delete();
|
||||
return;
|
||||
}
|
||||
const isInCombat = this.warship.warshipState().isInCombat ?? false;
|
||||
if (this.lastEmittedCombat && !isInCombat) {
|
||||
this.warship.touch();
|
||||
}
|
||||
this.lastEmittedCombat = isInCombat;
|
||||
const healthBeforeHealing = this.warship.health();
|
||||
|
||||
this.healWarship();
|
||||
this.handleManualPatrolOverride();
|
||||
|
||||
if (this.docked) {
|
||||
if (this.warship.warshipState().state === "docked") {
|
||||
if (this.currentRetreatPort() === undefined) {
|
||||
this.docked = false;
|
||||
this.cancelRepairRetreat();
|
||||
}
|
||||
if (this.isFullyHealed()) {
|
||||
this.docked = false;
|
||||
this.cancelRepairRetreat();
|
||||
}
|
||||
if (this.docked) {
|
||||
if (this.warship.warshipState().state === "docked") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -92,21 +97,17 @@ export class WarshipExecution implements Execution {
|
||||
|
||||
this.warship.setTargetUnit(this.findTargetUnit());
|
||||
|
||||
// Always patrol for movement
|
||||
this.patrol();
|
||||
|
||||
// Movement can change what is actually in range, so recompute before acting.
|
||||
this.warship.setTargetUnit(this.findTargetUnit());
|
||||
|
||||
// Priority 1: Shoot transport ship if in range
|
||||
if (this.warship.targetUnit()?.type() === UnitType.TransportShip) {
|
||||
this.shootTarget();
|
||||
this.patrol();
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 2: Fight enemy warship if in range
|
||||
if (this.warship.targetUnit()?.type() === UnitType.Warship) {
|
||||
this.shootTarget();
|
||||
this.patrol();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,23 +116,35 @@ export class WarshipExecution implements Execution {
|
||||
this.huntDownTradeShip();
|
||||
return;
|
||||
}
|
||||
|
||||
this.patrol();
|
||||
}
|
||||
|
||||
private healWarship(): void {
|
||||
const owner = this.warship.owner();
|
||||
const passiveHealing = this.mg.config().warshipPassiveHealing();
|
||||
const passiveHealingRange = this.mg.config().warshipPassiveHealingRange();
|
||||
const passiveHealingRangeSquared =
|
||||
passiveHealingRange * passiveHealingRange;
|
||||
const warshipTile = this.warship.tile();
|
||||
|
||||
const isNearPort = this.mg
|
||||
.nearbyUnits(warshipTile, passiveHealingRange, [UnitType.Port])
|
||||
.some(({ unit }) => unit.owner() === owner);
|
||||
let isNearPort = false;
|
||||
for (const port of owner.units(UnitType.Port)) {
|
||||
const distSquared = this.mg.euclideanDistSquared(
|
||||
warshipTile,
|
||||
port.tile(),
|
||||
);
|
||||
if (distSquared <= passiveHealingRangeSquared) {
|
||||
isNearPort = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNearPort) {
|
||||
this.warship.modifyHealth(passiveHealing);
|
||||
}
|
||||
|
||||
if (this.docked) {
|
||||
if (this.warship.warshipState().state === "docked") {
|
||||
this.applyActiveDockedHealing();
|
||||
}
|
||||
}
|
||||
@@ -139,7 +152,6 @@ export class WarshipExecution implements Execution {
|
||||
private isFullyHealed(): boolean {
|
||||
const maxHealth = this.mg.config().unitInfo(UnitType.Warship).maxHealth;
|
||||
if (typeof maxHealth !== "number") {
|
||||
console.warn("Warship maxHealth is not a number, disabling retreat");
|
||||
return true;
|
||||
}
|
||||
return this.warship.health() >= maxHealth;
|
||||
@@ -148,7 +160,14 @@ export class WarshipExecution implements Execution {
|
||||
private shouldStartRepairRetreat(
|
||||
healthBeforeHealing = this.warship.health(),
|
||||
): boolean {
|
||||
if (this.retreatingForRepair) {
|
||||
if (this.warship.warshipState().state !== "patrolling") {
|
||||
return false;
|
||||
}
|
||||
const manualMoveRetreatDisabledDuration = 50;
|
||||
if (
|
||||
this.mg.ticks() - this.lastManualMoveTickRetreatDisabled <
|
||||
manualMoveRetreatDisabledDuration
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
@@ -156,7 +175,6 @@ export class WarshipExecution implements Execution {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Only retreat if there's a friendly port
|
||||
const ports = this.warship.owner().units(UnitType.Port);
|
||||
return ports.length > 0;
|
||||
}
|
||||
@@ -188,37 +206,159 @@ export class WarshipExecution implements Execution {
|
||||
return nearest?.tile();
|
||||
}
|
||||
|
||||
private findRetreatAggroTarget(): Unit | undefined {
|
||||
return this.findBestTarget([UnitType.TransportShip, UnitType.Warship]);
|
||||
}
|
||||
|
||||
private findTargetUnit(): Unit | undefined {
|
||||
return this.findBestTarget(
|
||||
[UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared target selection: searches nearby units of given types,
|
||||
* filters common exclusions (self, friendly, docked, already-shelled),
|
||||
* picks best by type priority (lower index = higher priority) then distance.
|
||||
*
|
||||
* When `includeTradeShips` is true, applies trade-ship-specific filters
|
||||
* (safe from pirates, patrol range, water component, allied destination).
|
||||
*/
|
||||
private findBestTarget(
|
||||
types: UnitType[],
|
||||
includeTradeShips = false,
|
||||
): Unit | undefined {
|
||||
const mg = this.mg;
|
||||
const config = mg.config();
|
||||
const owner = this.warship.owner();
|
||||
|
||||
const ships = mg.nearbyUnits(
|
||||
this.warship.tile(),
|
||||
config.warshipTargettingRange(),
|
||||
types,
|
||||
);
|
||||
|
||||
// Trade-ship-specific state, lazily computed.
|
||||
let hasPort: boolean | undefined;
|
||||
let patrolTile: number | undefined;
|
||||
let patrolRangeSquared: number | undefined;
|
||||
let warshipComponent: number | null | undefined = undefined;
|
||||
|
||||
let bestUnit: Unit | undefined = undefined;
|
||||
let bestTypePriority = 0;
|
||||
let bestDistSquared = 0;
|
||||
|
||||
for (const { unit, distSquared } of ships) {
|
||||
if (
|
||||
unit === this.warship ||
|
||||
unit.owner() === owner ||
|
||||
!owner.canAttackPlayer(unit.owner(), true) ||
|
||||
this.alreadySentShell.has(unit) ||
|
||||
(unit.type() === UnitType.Warship &&
|
||||
unit.warshipState().state === "docked")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = unit.type();
|
||||
|
||||
if (includeTradeShips && type === UnitType.TradeShip) {
|
||||
if (hasPort === undefined) {
|
||||
hasPort = owner.unitCount(UnitType.Port) > 0;
|
||||
patrolTile = this.warship.warshipState().patrolTile;
|
||||
patrolRangeSquared = config.warshipPatrolRange() ** 2;
|
||||
}
|
||||
if (
|
||||
!hasPort ||
|
||||
patrolTile === undefined ||
|
||||
unit.isSafeFromPirates() ||
|
||||
unit.targetUnit()?.owner() === owner ||
|
||||
unit.targetUnit()?.owner().isFriendly(owner)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (warshipComponent === undefined) {
|
||||
warshipComponent = mg.getWaterComponent(this.warship.tile());
|
||||
}
|
||||
if (
|
||||
warshipComponent !== null &&
|
||||
!mg.hasWaterComponent(unit.tile(), warshipComponent)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
mg.euclideanDistSquared(patrolTile, unit.tile()) > patrolRangeSquared!
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const typePriority =
|
||||
type === UnitType.TransportShip ? 0 : type === UnitType.Warship ? 1 : 2;
|
||||
|
||||
if (
|
||||
bestUnit === undefined ||
|
||||
typePriority < bestTypePriority ||
|
||||
(typePriority === bestTypePriority && distSquared < bestDistSquared)
|
||||
) {
|
||||
bestUnit = unit;
|
||||
bestTypePriority = typePriority;
|
||||
bestDistSquared = distSquared;
|
||||
}
|
||||
}
|
||||
|
||||
return bestUnit;
|
||||
}
|
||||
|
||||
private startRepairRetreat(): void {
|
||||
const portTile = this.findNearestPort();
|
||||
if (portTile === undefined) {
|
||||
return;
|
||||
}
|
||||
this.retreatingForRepair = true;
|
||||
this.retreatPortTile = portTile;
|
||||
this.docked = false;
|
||||
this.warship.updateWarshipState({
|
||||
retreatPort: portTile,
|
||||
state: "retreating",
|
||||
});
|
||||
this.activeHealingRemainder = 0;
|
||||
this.warship.setRetreating(true);
|
||||
this.warship.setTargetUnit(undefined);
|
||||
}
|
||||
|
||||
private cancelRepairRetreat(clearTargetTile = true): void {
|
||||
this.retreatingForRepair = false;
|
||||
this.activeHealingRemainder = 0;
|
||||
this.warship.setRetreating(false);
|
||||
this.retreatPortTile = undefined;
|
||||
this.warship.updateWarshipState({
|
||||
state: "patrolling",
|
||||
retreatPort: undefined,
|
||||
});
|
||||
if (clearTargetTile) {
|
||||
this.warship.setTargetTile(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private handleManualPatrolOverride(): void {
|
||||
const patrolTile = this.warship.warshipState().patrolTile;
|
||||
if (
|
||||
this.lastObservedPatrolTile !== undefined &&
|
||||
patrolTile !== this.lastObservedPatrolTile
|
||||
) {
|
||||
this.lastManualMoveTickRetreatDisabled = this.mg.ticks();
|
||||
if (this.warship.warshipState().state !== "patrolling") {
|
||||
this.cancelRepairRetreat(false);
|
||||
}
|
||||
}
|
||||
this.lastObservedPatrolTile = patrolTile;
|
||||
}
|
||||
|
||||
private handleRepairRetreat(): boolean {
|
||||
if (!this.retreatingForRepair) {
|
||||
if (this.warship.warshipState().state === "patrolling") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isFullyHealed()) {
|
||||
this.cancelRepairRetreat();
|
||||
return false;
|
||||
const retreatAggroTarget = this.findRetreatAggroTarget();
|
||||
if (retreatAggroTarget) {
|
||||
this.warship.setTargetUnit(retreatAggroTarget);
|
||||
this.shootTarget();
|
||||
// Fall through — continue retreating toward port even while firing back.
|
||||
}
|
||||
|
||||
if (!this.refreshRetreatPortTile()) {
|
||||
@@ -226,9 +366,12 @@ export class WarshipExecution implements Execution {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.warship.setTargetUnit(undefined);
|
||||
// Only clear the target when there's no active aggro target this tick.
|
||||
if (!retreatAggroTarget) {
|
||||
this.warship.setTargetUnit(undefined);
|
||||
}
|
||||
|
||||
const retreatPortTile = this.retreatPortTile;
|
||||
const retreatPortTile = this.warship.warshipState().retreatPort;
|
||||
if (retreatPortTile === undefined) {
|
||||
return false;
|
||||
}
|
||||
@@ -249,10 +392,16 @@ export class WarshipExecution implements Execution {
|
||||
if (port && !this.isPortFullOfHealing(port, this.warship)) {
|
||||
// Port has capacity - dock here
|
||||
this.warship.setTargetTile(undefined);
|
||||
this.docked = true;
|
||||
this.warship.updateWarshipState({
|
||||
state: "docked",
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
// Port is full - don't cancel retreat, keep waiting near port
|
||||
// Port is full - wait near port, but leave if already fully healed
|
||||
if (this.isFullyHealed()) {
|
||||
this.cancelRepairRetreat();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -269,12 +418,16 @@ export class WarshipExecution implements Execution {
|
||||
case PathStatus.NEXT:
|
||||
this.warship.move(result.node);
|
||||
break;
|
||||
case PathStatus.NOT_FOUND:
|
||||
this.retreatPortTile = this.findNearestAvailablePortTile(this.warship);
|
||||
if (this.retreatPortTile === undefined) {
|
||||
case PathStatus.NOT_FOUND: {
|
||||
const newPort = this.findNearestAvailablePortTile();
|
||||
this.warship.updateWarshipState({
|
||||
retreatPort: newPort,
|
||||
});
|
||||
if (newPort === undefined) {
|
||||
this.cancelRepairRetreat();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -286,31 +439,40 @@ export class WarshipExecution implements Execution {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentRetreatPort = this.warship.warshipState().retreatPort;
|
||||
|
||||
// Check if current retreat port still exists
|
||||
const currentPortExists =
|
||||
this.retreatPortTile !== undefined &&
|
||||
ports.some((port) => port.tile() === this.retreatPortTile);
|
||||
currentRetreatPort !== undefined &&
|
||||
ports.some((port) => port.tile() === currentRetreatPort);
|
||||
|
||||
if (!currentPortExists) {
|
||||
this.retreatPortTile = this.findNearestAvailablePortTile(this.warship);
|
||||
return this.retreatPortTile !== undefined;
|
||||
const newPort = this.findNearestAvailablePortTile();
|
||||
this.warship.updateWarshipState({
|
||||
retreatPort: newPort,
|
||||
});
|
||||
return newPort !== undefined;
|
||||
}
|
||||
|
||||
// Check if current port is now full of healing (not counting arrived warships)
|
||||
const currentPort = ports.find((p) => p.tile() === this.retreatPortTile);
|
||||
const currentPort = ports.find((p) => p.tile() === currentRetreatPort);
|
||||
if (currentPort && this.isPortFullOfHealing(currentPort)) {
|
||||
// Current port is at healing capacity, look for alternatives
|
||||
const alternativePort = this.findNearestAvailablePortTile();
|
||||
const alternativePort = this.findNearestAvailablePort();
|
||||
if (alternativePort) {
|
||||
this.retreatPortTile = alternativePort;
|
||||
this.warship.updateWarshipState({
|
||||
retreatPort: alternativePort,
|
||||
});
|
||||
}
|
||||
return this.retreatPortTile !== undefined;
|
||||
return this.warship.warshipState().retreatPort !== undefined;
|
||||
}
|
||||
|
||||
// Check if a significantly closer port is available
|
||||
const closerPort = this.findBetterPortTile();
|
||||
if (closerPort && closerPort !== this.retreatPortTile) {
|
||||
this.retreatPortTile = closerPort;
|
||||
if (closerPort && closerPort !== currentRetreatPort) {
|
||||
this.warship.updateWarshipState({
|
||||
retreatPort: closerPort,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -331,7 +493,7 @@ export class WarshipExecution implements Execution {
|
||||
.filter(({ unit: ship }) => {
|
||||
if (excludeShip && ship === excludeShip) return false;
|
||||
if (ship.owner() !== owner) return false;
|
||||
if (!ship.retreating()) return false;
|
||||
if (ship.warshipState().state === "patrolling") return false;
|
||||
if (ship.targetTile() !== undefined) return false;
|
||||
return true;
|
||||
})
|
||||
@@ -345,6 +507,9 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
|
||||
const dockedShips = this.dockedShipsAtPort(dockedPort);
|
||||
if (!dockedShips.some((ship) => ship === this.warship)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const healingPool =
|
||||
dockedPort.level() * this.mg.config().warshipPortHealingBonusPerLevel();
|
||||
@@ -365,43 +530,21 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
|
||||
private currentRetreatPort(): Unit | undefined {
|
||||
if (this.retreatPortTile === undefined) {
|
||||
const retreatPort = this.warship.warshipState().retreatPort;
|
||||
if (retreatPort === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.warship
|
||||
.owner()
|
||||
.units(UnitType.Port)
|
||||
.find((port) => port.tile() === this.retreatPortTile);
|
||||
.find((port) => port.tile() === retreatPort);
|
||||
}
|
||||
|
||||
private findBetterPortTile(): TileRef | undefined {
|
||||
const warshipTile = this.warship.tile();
|
||||
const currentDistance = this.retreatPortTile
|
||||
? this.mg.euclideanDistSquared(warshipTile, this.retreatPortTile)
|
||||
: Infinity;
|
||||
const bestTile = this.findNearestAvailablePortTile(this.warship);
|
||||
if (!bestTile) {
|
||||
return undefined;
|
||||
}
|
||||
const bestDistance = this.mg.euclideanDistSquared(warshipTile, bestTile);
|
||||
if (
|
||||
bestDistance <
|
||||
currentDistance * this.mg.config().warshipPortSwitchThreshold()
|
||||
) {
|
||||
return bestTile;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private findNearestAvailablePortTile(
|
||||
private nearestAvailablePortTile(
|
||||
excludeShip?: Unit,
|
||||
): TileRef | undefined {
|
||||
): { tile: TileRef; distSquared: number } | undefined {
|
||||
const ports = this.warship.owner().units(UnitType.Port);
|
||||
if (ports.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const warshipTile = this.warship.tile();
|
||||
const warshipComponent = this.mg.getWaterComponent(warshipTile);
|
||||
if (warshipComponent === null) {
|
||||
@@ -410,6 +553,7 @@ export class WarshipExecution implements Execution {
|
||||
|
||||
let bestTile: TileRef | undefined = undefined;
|
||||
let bestDistance = Infinity;
|
||||
|
||||
for (const port of ports) {
|
||||
if (this.isPortFullOfHealing(port, excludeShip)) {
|
||||
continue;
|
||||
@@ -427,99 +571,43 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
return bestTile;
|
||||
return bestTile !== undefined
|
||||
? { tile: bestTile, distSquared: bestDistance }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private findTargetUnit(): Unit | undefined {
|
||||
const mg = this.mg;
|
||||
const config = mg.config();
|
||||
const owner = this.warship.owner();
|
||||
const hasPort = owner.unitCount(UnitType.Port) > 0;
|
||||
const patrolTile = this.warship.patrolTile()!;
|
||||
const patrolRangeSquared = config.warshipPatrolRange() ** 2;
|
||||
private findNearestAvailablePort(): TileRef | undefined {
|
||||
return this.nearestAvailablePortTile()?.tile;
|
||||
}
|
||||
|
||||
// Lazy: only computed if a TradeShip candidate forces the component check.
|
||||
// `undefined` = not yet computed; `null` = computed, no component found.
|
||||
let warshipComponent: number | null | undefined = undefined;
|
||||
private findBetterPortTile(): TileRef | undefined {
|
||||
const result = this.nearestAvailablePortTile();
|
||||
if (!result) return undefined;
|
||||
|
||||
const ships = mg.nearbyUnits(
|
||||
this.warship.tile()!,
|
||||
config.warshipTargettingRange(),
|
||||
[UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
|
||||
);
|
||||
|
||||
let bestUnit: Unit | undefined = undefined;
|
||||
let bestTypePriority = 0;
|
||||
let bestDistSquared = 0;
|
||||
|
||||
for (const { unit, distSquared } of ships) {
|
||||
if (
|
||||
unit.owner() === owner ||
|
||||
unit === this.warship ||
|
||||
!owner.canAttackPlayer(unit.owner(), true) ||
|
||||
this.alreadySentShell.has(unit)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = unit.type();
|
||||
if (type === UnitType.TradeShip) {
|
||||
if (
|
||||
!hasPort ||
|
||||
unit.isSafeFromPirates() ||
|
||||
unit.targetUnit()?.owner() === owner || // trade ship is coming to my port
|
||||
unit.targetUnit()?.owner().isFriendly(owner) // trade ship is coming to my ally
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (warshipComponent === undefined) {
|
||||
warshipComponent = mg.getWaterComponent(this.warship.tile());
|
||||
}
|
||||
if (
|
||||
warshipComponent !== null &&
|
||||
!mg.hasWaterComponent(unit.tile(), warshipComponent)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
mg.euclideanDistSquared(patrolTile, unit.tile()) > patrolRangeSquared
|
||||
) {
|
||||
// Prevent warship from chasing trade ship that is too far away from
|
||||
// the patrol tile to prevent warships from wandering around the map.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const typePriority =
|
||||
type === UnitType.TransportShip ? 0 : type === UnitType.Warship ? 1 : 2;
|
||||
|
||||
if (bestUnit === undefined) {
|
||||
bestUnit = unit;
|
||||
bestTypePriority = typePriority;
|
||||
bestDistSquared = distSquared;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match existing `sort()` semantics:
|
||||
// - Lower priority is better (TransportShip < Warship < TradeShip).
|
||||
// - For same type, smaller distance is better.
|
||||
// - For exact ties, keep the first encountered (stable sort behavior).
|
||||
if (
|
||||
typePriority < bestTypePriority ||
|
||||
(typePriority === bestTypePriority && distSquared < bestDistSquared)
|
||||
) {
|
||||
bestUnit = unit;
|
||||
bestTypePriority = typePriority;
|
||||
bestDistSquared = distSquared;
|
||||
}
|
||||
let currentDistance = Infinity;
|
||||
const currentRetreatPort = this.warship.warshipState().retreatPort;
|
||||
if (currentRetreatPort !== undefined) {
|
||||
currentDistance = this.mg.euclideanDistSquared(
|
||||
this.warship.tile(),
|
||||
currentRetreatPort,
|
||||
);
|
||||
}
|
||||
|
||||
return bestUnit;
|
||||
if (
|
||||
result.distSquared <
|
||||
currentDistance * this.mg.config().warshipPortSwitchThreshold()
|
||||
) {
|
||||
return result.tile;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private findNearestAvailablePortTile(): TileRef | undefined {
|
||||
return this.nearestAvailablePortTile(this.warship)?.tile;
|
||||
}
|
||||
|
||||
private shootTarget() {
|
||||
this.warship.updateWarshipState({ isInCombat: true });
|
||||
const shellAttackRate = this.mg.config().warshipShellAttackRate();
|
||||
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
|
||||
if (this.warship.targetUnit()?.type() !== UnitType.TransportShip) {
|
||||
@@ -544,6 +632,7 @@ 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(
|
||||
@@ -601,7 +690,7 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
|
||||
isDocked(): boolean {
|
||||
return this.docked;
|
||||
return (this.warship?.warshipState().state ?? "patrolling") === "docked";
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
@@ -617,7 +706,7 @@ export class WarshipExecution implements Execution {
|
||||
// Get warship's water component for connectivity check
|
||||
const warshipComponent = this.mg.getWaterComponent(this.warship.tile());
|
||||
|
||||
const patrolTile = this.warship.patrolTile();
|
||||
const patrolTile = this.warship.warshipState().patrolTile;
|
||||
if (patrolTile === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export class NationWarshipBehavior {
|
||||
return (
|
||||
target &&
|
||||
p.isActive() &&
|
||||
!p.retreating() &&
|
||||
!p.transportShipState().isRetreating &&
|
||||
this.game.ownerID(target) === this.player?.smallID() &&
|
||||
p.owner().smallID() !== this.player?.smallID()
|
||||
);
|
||||
@@ -162,7 +162,7 @@ export class NationWarshipBehavior {
|
||||
if (
|
||||
!transport.isActive() ||
|
||||
target === undefined ||
|
||||
transport.retreating()
|
||||
transport.transportShipState().isRetreating
|
||||
) {
|
||||
this.trackedIncomingTransportShips.delete(transport);
|
||||
this.dealtWithTransportShip.delete(transport);
|
||||
@@ -194,7 +194,7 @@ export class NationWarshipBehavior {
|
||||
true,
|
||||
) ||
|
||||
this.player.units(UnitType.Warship).filter((p) => {
|
||||
const patrolTile = p.patrolTile();
|
||||
const patrolTile = p.warshipState().patrolTile;
|
||||
return (
|
||||
patrolTile !== undefined &&
|
||||
this.game.manhattanDist(target, patrolTile) < 90
|
||||
@@ -259,7 +259,7 @@ export class NationWarshipBehavior {
|
||||
const warship = this.player
|
||||
.units(UnitType.Warship)
|
||||
.filter((p) => {
|
||||
const patrolTile = p.patrolTile();
|
||||
const patrolTile = p.warshipState().patrolTile;
|
||||
return (
|
||||
patrolTile !== undefined &&
|
||||
// Dont send ships which are already traveling
|
||||
@@ -274,7 +274,7 @@ export class NationWarshipBehavior {
|
||||
})[0];
|
||||
|
||||
if (warship) {
|
||||
warship.setPatrolTile(tile);
|
||||
warship.updateWarshipState({ patrolTile: tile });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-7
@@ -26,6 +26,19 @@ export type PlayerID = string;
|
||||
export type Tick = number;
|
||||
export type Gold = bigint;
|
||||
|
||||
export type WarshipState = {
|
||||
state: "patrolling" | "retreating" | "docked";
|
||||
patrolTile?: TileRef;
|
||||
retreatPort?: TileRef;
|
||||
isInCombat?: boolean;
|
||||
lastCombatTick: number;
|
||||
};
|
||||
|
||||
export type TransportShipState = {
|
||||
isRetreating: boolean;
|
||||
troops: number;
|
||||
};
|
||||
|
||||
export const AllPlayers = "AllPlayers" as const;
|
||||
|
||||
// export type GameUpdates = Record<GameUpdateType, GameUpdate[]>;
|
||||
@@ -617,9 +630,10 @@ export interface Unit {
|
||||
|
||||
// Health
|
||||
hasHealth(): boolean;
|
||||
retreating(): boolean;
|
||||
setRetreating(retreating: boolean): void;
|
||||
orderBoatRetreat(): void;
|
||||
warshipState(): WarshipState;
|
||||
updateWarshipState(update: Partial<WarshipState>): void;
|
||||
transportShipState(): TransportShipState;
|
||||
updateTransportShipState(update: Partial<TransportShipState>): void;
|
||||
health(): number;
|
||||
modifyHealth(delta: number, attacker?: Player): void;
|
||||
|
||||
@@ -647,10 +661,6 @@ export interface Unit {
|
||||
level(): number;
|
||||
increaseLevel(): void;
|
||||
decreaseLevel(destroyer?: Player): void;
|
||||
|
||||
// Warships
|
||||
setPatrolTile(tile: TileRef): void;
|
||||
patrolTile(): TileRef | undefined;
|
||||
}
|
||||
|
||||
export interface TerraNullius {
|
||||
@@ -870,6 +880,7 @@ export interface Game extends GameMap {
|
||||
setPaused(paused: boolean): void;
|
||||
|
||||
// Units
|
||||
unit(id: number): Unit | undefined;
|
||||
units(...types: UnitType[]): Unit[];
|
||||
unitCount(type: UnitType): number;
|
||||
unitInfo(type: UnitType): UnitInfo;
|
||||
|
||||
@@ -97,6 +97,7 @@ export class GameImpl implements Game {
|
||||
private motionPlanRecords: MotionPlanRecord[] = [];
|
||||
private planDrivenUnitIds = new Set<number>();
|
||||
private unitGrid: UnitGrid;
|
||||
private _unitMap = new Map<number, Unit>();
|
||||
|
||||
private playerTeams: Team[] = [];
|
||||
private botTeam: Team = ColoredTeams.Bot;
|
||||
@@ -287,6 +288,10 @@ export class GameImpl implements Game {
|
||||
this._waterManager.queueTile(tile);
|
||||
}
|
||||
|
||||
unit(id: number): Unit | undefined {
|
||||
return this._unitMap.get(id);
|
||||
}
|
||||
|
||||
units(...types: UnitType[]): Unit[] {
|
||||
return Array.from(this._players.values()).flatMap((p) => p.units(...types));
|
||||
}
|
||||
@@ -955,9 +960,11 @@ export class GameImpl implements Game {
|
||||
|
||||
addUnit(u: Unit) {
|
||||
this.unitGrid.addUnit(u);
|
||||
this._unitMap.set(u.id(), u);
|
||||
}
|
||||
removeUnit(u: Unit) {
|
||||
this.unitGrid.removeUnit(u);
|
||||
this._unitMap.delete(u.id());
|
||||
this.planDrivenUnitIds.delete(u.id());
|
||||
if (u.hasTrainStation()) {
|
||||
this._railNetwork.removeStation(u);
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
Team,
|
||||
Tick,
|
||||
TrainType,
|
||||
TransportShipState,
|
||||
UnitType,
|
||||
WarshipState,
|
||||
} from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
|
||||
@@ -137,7 +139,8 @@ export interface UnitUpdate {
|
||||
lastPos: TileRef;
|
||||
isActive: boolean;
|
||||
reachedTarget: boolean;
|
||||
retreating: boolean;
|
||||
warshipState?: WarshipState;
|
||||
transportShipState?: TransportShipState;
|
||||
targetable: boolean;
|
||||
markedForDeletion: number | false;
|
||||
targetUnitId?: number; // Only for trade ships
|
||||
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
TerraNullius,
|
||||
Tick,
|
||||
TrainType,
|
||||
TransportShipState,
|
||||
UnitInfo,
|
||||
UnitType,
|
||||
WarshipState,
|
||||
} from "./Game";
|
||||
import { GameMap, TileRef } from "./GameMap";
|
||||
import {
|
||||
@@ -115,14 +117,28 @@ export class UnitView {
|
||||
troops(): number {
|
||||
return this.data.troops;
|
||||
}
|
||||
retreating(): boolean {
|
||||
if (
|
||||
this.type() !== UnitType.TransportShip &&
|
||||
this.type() !== UnitType.Warship
|
||||
) {
|
||||
throw Error("Must be a transport ship or warship");
|
||||
warshipState(): WarshipState {
|
||||
if (this.data.warshipState === undefined) {
|
||||
throw new Error("warshipState called on non-warship unit");
|
||||
}
|
||||
return this.data.retreating;
|
||||
return this.data.warshipState;
|
||||
}
|
||||
updateWarshipState(_update: Partial<WarshipState>): void {
|
||||
throw new Error("updateWarshipState is not supported on UnitView");
|
||||
}
|
||||
isInCombat(): boolean {
|
||||
return this.data.warshipState?.isInCombat ?? false;
|
||||
}
|
||||
touch(): void {
|
||||
throw new Error("touch is not supported on UnitView");
|
||||
}
|
||||
transportShipState(): TransportShipState {
|
||||
return this.data.transportShipState ?? { isRetreating: false, troops: 0 };
|
||||
}
|
||||
updateTransportShipState(
|
||||
_update: Pick<TransportShipState, "isRetreating">,
|
||||
): void {
|
||||
throw new Error("updateTransportShipState is not supported on UnitView");
|
||||
}
|
||||
tile(): TileRef {
|
||||
return this.data.pos;
|
||||
|
||||
+95
-22
@@ -6,9 +6,11 @@ import {
|
||||
Tick,
|
||||
TrainType,
|
||||
TrajectoryTile,
|
||||
TransportShipState,
|
||||
Unit,
|
||||
UnitInfo,
|
||||
UnitType,
|
||||
WarshipState,
|
||||
} from "./Game";
|
||||
import { GameImpl } from "./GameImpl";
|
||||
import { TileRef } from "./GameMap";
|
||||
@@ -21,7 +23,8 @@ export class UnitImpl implements Unit {
|
||||
private _targetUnit: Unit | undefined;
|
||||
private _health: bigint;
|
||||
private _lastTile: TileRef;
|
||||
private _retreating: boolean = false;
|
||||
private _transportShipState: TransportShipState | undefined = undefined;
|
||||
private _warshipState: WarshipState | undefined = undefined;
|
||||
private _targetedBySAM = false;
|
||||
private _reachedTarget = false;
|
||||
private _wasDestroyedByEnemy: boolean = false;
|
||||
@@ -33,7 +36,6 @@ export class UnitImpl implements Unit {
|
||||
// Number of missiles in cooldown, if empty all missiles are ready.
|
||||
private _missileTimerQueue: number[] = [];
|
||||
private _hasTrainStation: boolean = false;
|
||||
private _patrolTile: TileRef | undefined;
|
||||
private _level: number = 1;
|
||||
private _targetable: boolean = true;
|
||||
private _loaded: boolean | undefined;
|
||||
@@ -61,8 +63,16 @@ export class UnitImpl implements Unit {
|
||||
"lastSetSafeFromPirates" in params
|
||||
? (params.lastSetSafeFromPirates ?? 0)
|
||||
: 0;
|
||||
this._patrolTile =
|
||||
"patrolTile" in params ? (params.patrolTile ?? undefined) : undefined;
|
||||
if (this._type === UnitType.TransportShip) {
|
||||
this._transportShipState = { isRetreating: false, troops: 0 };
|
||||
}
|
||||
if ("patrolTile" in params) {
|
||||
this._warshipState = {
|
||||
state: "patrolling",
|
||||
patrolTile: params.patrolTile,
|
||||
lastCombatTick: -100,
|
||||
};
|
||||
}
|
||||
this._targetUnit =
|
||||
"targetUnit" in params ? (params.targetUnit ?? undefined) : undefined;
|
||||
this._loaded =
|
||||
@@ -92,14 +102,6 @@ export class UnitImpl implements Unit {
|
||||
return this._targetable;
|
||||
}
|
||||
|
||||
setPatrolTile(tile: TileRef): void {
|
||||
this._patrolTile = tile;
|
||||
}
|
||||
|
||||
patrolTile(): TileRef | undefined {
|
||||
return this._patrolTile;
|
||||
}
|
||||
|
||||
isUnit(): this is Unit {
|
||||
return true;
|
||||
}
|
||||
@@ -128,7 +130,14 @@ export class UnitImpl implements Unit {
|
||||
lastOwnerID: this._lastOwner?.smallID(),
|
||||
isActive: this._active,
|
||||
reachedTarget: this._reachedTarget,
|
||||
retreating: this._retreating,
|
||||
warshipState:
|
||||
this._warshipState !== undefined
|
||||
? { ...this.warshipState() }
|
||||
: undefined,
|
||||
transportShipState:
|
||||
this._transportShipState !== undefined
|
||||
? this.transportShipState()
|
||||
: undefined,
|
||||
pos: this._tile,
|
||||
markedForDeletion: this._deletionAt ?? false,
|
||||
targetable: this._targetable,
|
||||
@@ -232,6 +241,13 @@ export class UnitImpl implements Unit {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
attacker !== undefined &&
|
||||
delta < 0 &&
|
||||
this._warshipState !== undefined
|
||||
) {
|
||||
this._warshipState.lastCombatTick = this.mg.ticks();
|
||||
}
|
||||
this._health = nextHealth;
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
if (this._health === 0n) {
|
||||
@@ -335,22 +351,79 @@ export class UnitImpl implements Unit {
|
||||
return this._destroyer;
|
||||
}
|
||||
|
||||
retreating(): boolean {
|
||||
return this._retreating;
|
||||
warshipState(): WarshipState {
|
||||
if (this._warshipState === undefined) {
|
||||
throw new Error("warshipState called on non-warship unit");
|
||||
}
|
||||
this._warshipState.isInCombat = this.isInCombat();
|
||||
return this._warshipState;
|
||||
}
|
||||
|
||||
setRetreating(retreating: boolean): void {
|
||||
if (this._retreating !== retreating) {
|
||||
this._retreating = retreating;
|
||||
updateWarshipState(update: Partial<WarshipState>): void {
|
||||
if (this._warshipState === undefined) {
|
||||
throw new Error("updateWarshipState called on non-warship unit");
|
||||
}
|
||||
if (update.isInCombat) {
|
||||
this.markInCombat();
|
||||
}
|
||||
const merged = { ...this._warshipState, ...update };
|
||||
if (
|
||||
merged.state === this._warshipState.state &&
|
||||
merged.patrolTile === this._warshipState.patrolTile &&
|
||||
merged.retreatPort === this._warshipState.retreatPort
|
||||
)
|
||||
return;
|
||||
this._warshipState = {
|
||||
state: merged.state,
|
||||
patrolTile: merged.patrolTile,
|
||||
retreatPort: merged.retreatPort,
|
||||
lastCombatTick: this._warshipState.lastCombatTick,
|
||||
};
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
|
||||
isInCombat(): boolean {
|
||||
return this.mg.ticks() - this._warshipState!.lastCombatTick <= 3;
|
||||
}
|
||||
|
||||
private markInCombat(): void {
|
||||
const wasInCombat = this.isInCombat();
|
||||
this._warshipState!.lastCombatTick = this.mg.ticks();
|
||||
if (!wasInCombat) {
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
orderBoatRetreat() {
|
||||
if (this.type() !== UnitType.TransportShip) {
|
||||
throw new Error("Cannot retreat " + this.type());
|
||||
transportShipState(): TransportShipState {
|
||||
if (this._transportShipState === undefined) {
|
||||
throw new Error("transportShipState called on non-transport-ship unit");
|
||||
}
|
||||
return {
|
||||
isRetreating: this._transportShipState.isRetreating,
|
||||
troops: this._troops,
|
||||
};
|
||||
}
|
||||
|
||||
updateTransportShipState(update: Partial<TransportShipState>): void {
|
||||
if (this._transportShipState === undefined) {
|
||||
throw new Error(
|
||||
"updateTransportShipState called on non-transport-ship unit",
|
||||
);
|
||||
}
|
||||
let changed = false;
|
||||
if (
|
||||
update.isRetreating !== undefined &&
|
||||
this._transportShipState.isRetreating !== update.isRetreating
|
||||
) {
|
||||
this._transportShipState = {
|
||||
...this._transportShipState,
|
||||
isRetreating: update.isRetreating,
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
this.setRetreating(true);
|
||||
}
|
||||
|
||||
isUnderConstruction(): boolean {
|
||||
|
||||
@@ -139,7 +139,7 @@ describe("Attack", () => {
|
||||
expect(ship.troops()).toBe(boat_troops);
|
||||
expect(ship.isActive()).toBe(true);
|
||||
|
||||
ship.orderBoatRetreat();
|
||||
ship.updateTransportShipState({ isRetreating: true });
|
||||
game.executeNextTick();
|
||||
|
||||
expect(ship.isActive()).toBe(false);
|
||||
|
||||
@@ -406,7 +406,7 @@ describe("Disconnected", () => {
|
||||
);
|
||||
expect(expectedRetreatTile).not.toBe(false);
|
||||
|
||||
transportShip.orderBoatRetreat();
|
||||
transportShip.updateTransportShipState({ isRetreating: true });
|
||||
executeTicks(game, 2);
|
||||
|
||||
expect(transportShip.targetTile()).toBe(expectedRetreatTile);
|
||||
@@ -465,7 +465,7 @@ describe("Disconnected", () => {
|
||||
toInt(player1.troops()) + expectedTroopGrowth,
|
||||
);
|
||||
|
||||
transportShip.orderBoatRetreat();
|
||||
transportShip.updateTransportShipState({ isRetreating: true });
|
||||
executeTicks(game, 1);
|
||||
|
||||
expect(transportShip.isActive()).toBe(false);
|
||||
|
||||
+310
-9
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,9 +57,9 @@ describe("Warship multi-selection (MoveWarshipExecution)", () => {
|
||||
|
||||
executeTicks(game, 5);
|
||||
|
||||
expect(w1.patrolTile()).toBe(sharedTarget);
|
||||
expect(w2.patrolTile()).toBe(sharedTarget);
|
||||
expect(w3.patrolTile()).toBe(sharedTarget);
|
||||
expect(w1.warshipState().patrolTile).toBe(sharedTarget);
|
||||
expect(w2.warshipState().patrolTile).toBe(sharedTarget);
|
||||
expect(w3.warshipState().patrolTile).toBe(sharedTarget);
|
||||
});
|
||||
|
||||
test("moving multiple warships to different targets works independently", () => {
|
||||
@@ -81,8 +81,8 @@ describe("Warship multi-selection (MoveWarshipExecution)", () => {
|
||||
|
||||
executeTicks(game, 5);
|
||||
|
||||
expect(w1.patrolTile()).toBe(target1);
|
||||
expect(w2.patrolTile()).toBe(target2);
|
||||
expect(w1.warshipState().patrolTile).toBe(target1);
|
||||
expect(w2.warshipState().patrolTile).toBe(target2);
|
||||
});
|
||||
|
||||
test("enemy cannot move player's warships via MoveWarshipExecution", () => {
|
||||
@@ -97,7 +97,7 @@ describe("Warship multi-selection (MoveWarshipExecution)", () => {
|
||||
0,
|
||||
);
|
||||
|
||||
expect(w1.patrolTile()).toBe(originalTile);
|
||||
expect(w1.warshipState().patrolTile).toBe(originalTile);
|
||||
});
|
||||
|
||||
test("MoveWarshipExecution on destroyed warship does not throw", () => {
|
||||
@@ -138,7 +138,7 @@ describe("Warship multi-selection (MoveWarshipExecution)", () => {
|
||||
|
||||
executeTicks(game, 5);
|
||||
|
||||
expect(w1.patrolTile()).toBe(target);
|
||||
expect(w2.patrolTile()).toBe(p2tile); // unchanged — wrong owner
|
||||
expect(w1.warshipState().patrolTile).toBe(target);
|
||||
expect(w2.warshipState().patrolTile).toBe(p2tile); // unchanged — wrong owner
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("TradeShipExecution", () => {
|
||||
} as any;
|
||||
|
||||
piratePort = {
|
||||
id: vi.fn(() => 201),
|
||||
tile: vi.fn(() => 56),
|
||||
owner: vi.fn(() => pirate),
|
||||
isActive: vi.fn(() => true),
|
||||
@@ -63,6 +64,7 @@ describe("TradeShipExecution", () => {
|
||||
} as any;
|
||||
|
||||
piratePort2 = {
|
||||
id: vi.fn(() => 202),
|
||||
tile: vi.fn(() => 75),
|
||||
owner: vi.fn(() => pirate),
|
||||
isActive: vi.fn(() => true),
|
||||
@@ -71,6 +73,7 @@ describe("TradeShipExecution", () => {
|
||||
} as any;
|
||||
|
||||
srcPort = {
|
||||
id: vi.fn(() => 101),
|
||||
tile: vi.fn(() => 10),
|
||||
owner: vi.fn(() => origOwner),
|
||||
isActive: vi.fn(() => true),
|
||||
@@ -79,6 +82,7 @@ describe("TradeShipExecution", () => {
|
||||
} as any;
|
||||
|
||||
dstPort = {
|
||||
id: vi.fn(() => 102),
|
||||
tile: vi.fn(() => 100),
|
||||
owner: vi.fn(() => dstOwner),
|
||||
isActive: vi.fn(() => true),
|
||||
|
||||
Reference in New Issue
Block a user