Files
OpenFrontIO/src/core/execution/WarshipExecution.ts
T
VariableVince 9c24d29824 AFK team mate v2: better ship handling + tests + bugfix (#2396)
## Description:

See PR https://github.com/openfrontio/OpenFrontIO/pull/2203

It was reverted. This unreverts it, with an added fix for boat troops
not getting returned to owner. And small comment updates. And a const
for boatOwner to re-use.

The added bugfix is check for this.sourceTile === null in the retreat()
function in AttackExecution. A boat attack always sets removeTroops to
false because the troops were already removed from owner troops when the
boat departed. They don't have to be removed again in AttackExecution
init, when the boat lands and the attack starts. But at the end of the
attack, in retreat() in AttackExecution, the starting/boat troops still
need to be returned to the owner. That's why even if removeTroops is
false, when sourceTile is not null (only when it's a boat attack) we add
back the troops to the owner.

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

tryout33

---------

Co-authored-by: Fx Morin <28154542+FxMorin@users.noreply.github.com>
Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com>
2025-11-18 14:57:33 -08:00

283 lines
8.2 KiB
TypeScript

import {
Execution,
Game,
isUnit,
OwnerComp,
Unit,
UnitParams,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
import { ShellExecution } from "./ShellExecution";
export class WarshipExecution implements Execution {
private random: PseudoRandom;
private warship: Unit;
private mg: Game;
private pathfinder: PathFinder;
private lastShellAttack = 0;
private alreadySentShell = new Set<Unit>();
constructor(
private input: (UnitParams<UnitType.Warship> & OwnerComp) | Unit,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
this.pathfinder = PathFinder.Mini(mg, 10_000, true, 100);
this.random = new PseudoRandom(mg.ticks());
if (isUnit(this.input)) {
this.warship = this.input;
} else {
const spawn = this.input.owner.canBuild(
UnitType.Warship,
this.input.patrolTile,
);
if (spawn === false) {
console.warn(
`Failed to spawn warship for ${this.input.owner.name()} at ${this.input.patrolTile}`,
);
return;
}
this.warship = this.input.owner.buildUnit(
UnitType.Warship,
spawn,
this.input,
);
}
}
tick(ticks: number): void {
if (this.warship.health() <= 0) {
this.warship.delete();
return;
}
const hasPort = this.warship.owner().unitCount(UnitType.Port) > 0;
if (hasPort) {
this.warship.modifyHealth(1);
}
this.warship.setTargetUnit(this.findTargetUnit());
if (this.warship.targetUnit()?.type() === UnitType.TradeShip) {
this.huntDownTradeShip();
return;
}
this.patrol();
if (this.warship.targetUnit() !== undefined) {
this.shootTarget();
return;
}
}
private findTargetUnit(): Unit | undefined {
const hasPort = this.warship.owner().unitCount(UnitType.Port) > 0;
const patrolRangeSquared = this.mg.config().warshipPatrolRange() ** 2;
const ships = this.mg.nearbyUnits(
this.warship.tile()!,
this.mg.config().warshipTargettingRange(),
[UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
);
const potentialTargets: { unit: Unit; distSquared: number }[] = [];
for (const { unit, distSquared } of ships) {
if (
unit.owner() === this.warship.owner() ||
unit === this.warship ||
unit.owner().isFriendly(this.warship.owner(), true) ||
this.alreadySentShell.has(unit)
) {
continue;
}
if (unit.type() === UnitType.TradeShip) {
if (
!hasPort ||
unit.isSafeFromPirates() ||
unit.targetUnit()?.owner() === this.warship.owner() || // trade ship is coming to my port
unit.targetUnit()?.owner().isFriendly(this.warship.owner()) // trade ship is coming to my ally
) {
continue;
}
if (
this.mg.euclideanDistSquared(
this.warship.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;
}
}
potentialTargets.push({ unit: unit, distSquared });
}
return potentialTargets.sort((a, b) => {
const { unit: unitA, distSquared: distA } = a;
const { unit: unitB, distSquared: distB } = b;
// Prioritize Transport Ships above all other units
if (
unitA.type() === UnitType.TransportShip &&
unitB.type() !== UnitType.TransportShip
)
return -1;
if (
unitA.type() !== UnitType.TransportShip &&
unitB.type() === UnitType.TransportShip
)
return 1;
// Then prioritize Warships.
if (
unitA.type() === UnitType.Warship &&
unitB.type() !== UnitType.Warship
)
return -1;
if (
unitA.type() !== UnitType.Warship &&
unitB.type() === UnitType.Warship
)
return 1;
// If both are the same type, sort by distance (lower `distSquared` means closer)
return distA - distB;
})[0]?.unit;
}
private shootTarget() {
const shellAttackRate = this.mg.config().warshipShellAttackRate();
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
if (this.warship.targetUnit()?.type() !== UnitType.TransportShip) {
// Warships don't need to reload when attacking transport ships.
this.lastShellAttack = this.mg.ticks();
}
this.mg.addExecution(
new ShellExecution(
this.warship.tile(),
this.warship.owner(),
this.warship,
this.warship.targetUnit()!,
),
);
if (!this.warship.targetUnit()!.hasHealth()) {
// Don't send multiple shells to target that can be oneshotted
this.alreadySentShell.add(this.warship.targetUnit()!);
this.warship.setTargetUnit(undefined);
return;
}
}
}
private huntDownTradeShip() {
for (let i = 0; i < 2; i++) {
// target is trade ship so capture it.
const result = this.pathfinder.nextTile(
this.warship.tile(),
this.warship.targetUnit()!.tile(),
5,
);
switch (result.type) {
case PathFindResultType.Completed:
this.warship.owner().captureUnit(this.warship.targetUnit()!);
this.warship.setTargetUnit(undefined);
this.warship.move(this.warship.tile());
return;
case PathFindResultType.NextTile:
this.warship.move(result.node);
break;
case PathFindResultType.Pending:
this.warship.touch();
break;
case PathFindResultType.PathNotFound:
console.log(`path not found to target`);
break;
}
}
}
private patrol() {
if (this.warship.targetTile() === undefined) {
this.warship.setTargetTile(this.randomTile());
if (this.warship.targetTile() === undefined) {
return;
}
}
const result = this.pathfinder.nextTile(
this.warship.tile(),
this.warship.targetTile()!,
);
switch (result.type) {
case PathFindResultType.Completed:
this.warship.setTargetTile(undefined);
this.warship.move(result.node);
break;
case PathFindResultType.NextTile:
this.warship.move(result.node);
break;
case PathFindResultType.Pending:
this.warship.touch();
return;
case PathFindResultType.PathNotFound:
console.warn(`path not found to target tile`);
this.warship.setTargetTile(undefined);
break;
}
}
isActive(): boolean {
return this.warship?.isActive();
}
activeDuringSpawnPhase(): boolean {
return false;
}
randomTile(allowShoreline: boolean = false): TileRef | undefined {
let warshipPatrolRange = this.mg.config().warshipPatrolRange();
const maxAttemptBeforeExpand: number = 500;
let attempts: number = 0;
let expandCount: number = 0;
while (expandCount < 3) {
const x =
this.mg.x(this.warship.patrolTile()!) +
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
const y =
this.mg.y(this.warship.patrolTile()!) +
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
if (!this.mg.isValidCoord(x, y)) {
continue;
}
const tile = this.mg.ref(x, y);
if (
!this.mg.isOcean(tile) ||
(!allowShoreline && this.mg.isShoreline(tile))
) {
attempts++;
if (attempts === maxAttemptBeforeExpand) {
expandCount++;
attempts = 0;
warshipPatrolRange =
warshipPatrolRange + Math.floor(warshipPatrolRange / 2);
}
continue;
}
return tile;
}
console.warn(
`Failed to find random tile for warship for ${this.warship.owner().name()}`,
);
if (!allowShoreline) {
// If we failed to find a tile on the ocean, try again but allow shoreline
return this.randomTile(true);
}
return undefined;
}
}