Files
OpenFrontIO/src/core/game/TransportShipUtils.ts
T
DevelopingTom af0b8a8d50 Configurable immunity timer (#2763)
## Description:

Resolve discussions about stalled PR
https://github.com/openfrontio/OpenFrontIO/pull/2460

<img width="724" height="348" alt="image"
src="https://github.com/user-attachments/assets/c2c9fa79-cace-431a-9ca4-b3656612fa9d"
/>

Changes:
- Added a `Player::canAttackPlayer(other)` function to determine whether
a player can be attacked.
- This function is now used in most places where a fight can occur:
    - AttackExecution (land attacks)
    - Naval invasion
    - Warship fight
- Nukes can't be thrown during the truce
- Immunity only affect human players. Nations and bot will fight as
usual, and can be fought against.
- The immunity timer uses minutes in the modal window.

UI:

- The immunity phase is displayed with a timer bar at the top. This is
from the original PR, to be discussed if it's not deemed visible enough:

<img width="632" height="215" alt="image"
src="https://github.com/user-attachments/assets/f5ab9aa0-bd4f-4503-b8d6-b40b121fba65"
/>


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

IngloriousTom

---------

Co-authored-by: newyearnewphil <git@nynp.dev>
2026-01-03 20:04:48 -08:00

263 lines
6.4 KiB
TypeScript

import { PathFindResultType } from "../pathfinding/AStar";
import { MiniAStar } from "../pathfinding/MiniAStar";
import { Game, Player, UnitType } from "./Game";
import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap";
export function canBuildTransportShip(
game: Game,
player: Player,
tile: TileRef,
): TileRef | false {
if (
player.unitCount(UnitType.TransportShip) >= game.config().boatMaxNumber()
) {
return false;
}
const dst = targetTransportTile(game, tile);
if (dst === null) {
return false;
}
const other = game.owner(tile);
if (other === player) {
return false;
}
if (other.isPlayer() && !player.canAttackPlayer(other)) {
return false;
}
if (game.isOceanShore(dst)) {
let myPlayerBordersOcean = false;
for (const bt of player.borderTiles()) {
if (game.isOceanShore(bt)) {
myPlayerBordersOcean = true;
break;
}
}
let otherPlayerBordersOcean = false;
if (!game.hasOwner(tile)) {
otherPlayerBordersOcean = true;
} else {
for (const bt of (other as Player).borderTiles()) {
if (game.isOceanShore(bt)) {
otherPlayerBordersOcean = true;
break;
}
}
}
if (myPlayerBordersOcean && otherPlayerBordersOcean) {
return transportShipSpawn(game, player, dst);
} else {
return false;
}
}
// Now we are boating in a lake, so do a bfs from target until we find
// a border tile owned by the player
const tiles = game.bfs(
dst,
andFN(
manhattanDistFN(dst, 300),
(_, t: TileRef) => game.isLake(t) || game.isShore(t),
),
);
const sorted = Array.from(tiles).sort(
(a, b) => game.manhattanDist(dst, a) - game.manhattanDist(dst, b),
);
for (const t of sorted) {
if (game.owner(t) === player) {
return transportShipSpawn(game, player, t);
}
}
return false;
}
function transportShipSpawn(
game: Game,
player: Player,
targetTile: TileRef,
): TileRef | false {
if (!game.isShore(targetTile)) {
return false;
}
const spawn = closestShoreFromPlayer(game, player, targetTile);
if (spawn === null) {
return false;
}
return spawn;
}
export function sourceDstOceanShore(
gm: Game,
src: Player,
tile: TileRef,
): [TileRef | null, TileRef | null] {
const dst = gm.owner(tile);
const srcTile = closestShoreFromPlayer(gm, src, tile);
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestShoreTN(gm, tile, 50);
}
return [srcTile, dstTile];
}
export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null {
const dst = gm.playerBySmallID(gm.ownerID(tile));
let dstTile: TileRef | null = null;
if (dst.isPlayer()) {
dstTile = closestShoreFromPlayer(gm, dst as Player, tile);
} else {
dstTile = closestShoreTN(gm, tile, 50);
}
return dstTile;
}
export function closestShoreFromPlayer(
gm: GameMap,
player: Player,
target: TileRef,
): TileRef | null {
const shoreTiles = Array.from(player.borderTiles()).filter((t) =>
gm.isShore(t),
);
if (shoreTiles.length === 0) {
return null;
}
return shoreTiles.reduce((closest, current) => {
const closestDistance = gm.manhattanDist(target, closest);
const currentDistance = gm.manhattanDist(target, current);
return currentDistance < closestDistance ? current : closest;
});
}
export function bestShoreDeploymentSource(
gm: Game,
player: Player,
target: TileRef,
): TileRef | false {
const t = targetTransportTile(gm, target);
if (t === null) return false;
const candidates = candidateShoreTiles(gm, player, t);
if (candidates.length === 0) return false;
const aStar = new MiniAStar(gm, gm.miniMap(), candidates, t, 1_000_000, 1);
const result = aStar.compute();
if (result !== PathFindResultType.Completed) {
console.warn(`bestShoreDeploymentSource: path not found: ${result}`);
return false;
}
const path = aStar.reconstructPath();
if (path.length === 0) {
return false;
}
const potential = path[0];
// Since mini a* downscales the map, we need to check the neighbors
// of the potential tile to find a valid deployment point
const neighbors = gm
.neighbors(potential)
.filter((n) => gm.isShore(n) && gm.owner(n) === player);
if (neighbors.length === 0) {
return false;
}
return neighbors[0];
}
export function candidateShoreTiles(
gm: Game,
player: Player,
target: TileRef,
): TileRef[] {
let closestManhattanDistance = Infinity;
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
let bestByManhattan: TileRef | null = null;
const extremumTiles: Record<string, TileRef | null> = {
minX: null,
minY: null,
maxX: null,
maxY: null,
};
const borderShoreTiles = Array.from(player.borderTiles()).filter((t) =>
gm.isShore(t),
);
for (const tile of borderShoreTiles) {
const distance = gm.manhattanDist(tile, target);
const cell = gm.cell(tile);
// Manhattan-closest tile
if (distance < closestManhattanDistance) {
closestManhattanDistance = distance;
bestByManhattan = tile;
}
// Extremum tiles
if (cell.x < minX) {
minX = cell.x;
extremumTiles.minX = tile;
} else if (cell.y < minY) {
minY = cell.y;
extremumTiles.minY = tile;
} else if (cell.x > maxX) {
maxX = cell.x;
extremumTiles.maxX = tile;
} else if (cell.y > maxY) {
maxY = cell.y;
extremumTiles.maxY = tile;
}
}
// Calculate sampling interval to ensure we get at most 50 tiles
const samplingInterval = Math.max(
10,
Math.ceil(borderShoreTiles.length / 50),
);
const sampledTiles = borderShoreTiles.filter(
(_, index) => index % samplingInterval === 0,
);
const candidates = [
bestByManhattan,
extremumTiles.minX,
extremumTiles.minY,
extremumTiles.maxX,
extremumTiles.maxY,
...sampledTiles,
].filter(Boolean) as number[];
return candidates;
}
function closestShoreTN(
gm: GameMap,
tile: TileRef,
searchDist: number,
): TileRef | null {
const tn = Array.from(
gm.bfs(
tile,
andFN((_, t) => !gm.hasOwner(t), manhattanDistFN(tile, searchDist)),
),
)
.filter((t) => gm.isShore(t))
.sort((a, b) => gm.manhattanDist(tile, a) - gm.manhattanDist(tile, b));
if (tn.length === 0) {
return null;
}
return tn[0];
}