mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:50:42 +00:00
better transport ship spawn (#587)
## Description: Taken from PR #506 Improve transport source tile by considering border extremums Only calculate better spawn tile for humans, and have the sender calculate it and send the src tile in the intent for better performance. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: <DISCORD USERNAME> evan Co-authored-by: evan <openfrontio@gmail.com>
This commit is contained in:
@@ -353,7 +353,13 @@ export class ClientGameRunner {
|
||||
}
|
||||
}
|
||||
this.myPlayer.actions(tile).then((actions) => {
|
||||
console.log(`got actions: ${JSON.stringify(actions)}`);
|
||||
const bu = actions.buildableUnits.find(
|
||||
(bu) => bu.type == UnitType.TransportShip,
|
||||
);
|
||||
if (bu == null) {
|
||||
console.warn(`no transport ship buildable units`);
|
||||
return;
|
||||
}
|
||||
if (actions.canAttack) {
|
||||
this.eventBus.emit(
|
||||
new SendAttackIntentEvent(
|
||||
@@ -362,8 +368,8 @@ export class ClientGameRunner {
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
actions.canBoat !== false &&
|
||||
this.shouldBoat(tile, actions.canBoat) &&
|
||||
bu.canBuild !== false &&
|
||||
this.shouldBoat(tile, bu.canBuild) &&
|
||||
this.gameView.isLand(tile)
|
||||
) {
|
||||
this.eventBus.emit(
|
||||
|
||||
@@ -68,8 +68,9 @@ export class SendAttackIntentEvent implements GameEvent {
|
||||
export class SendBoatAttackIntentEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly targetID: PlayerID,
|
||||
public readonly cell: Cell,
|
||||
public readonly dst: Cell,
|
||||
public readonly troops: number,
|
||||
public readonly src: Cell | null = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -414,8 +415,10 @@ export class Transport {
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
targetID: event.targetID,
|
||||
troops: event.troops,
|
||||
x: event.cell.x,
|
||||
y: event.cell.y,
|
||||
dstX: event.dst.x,
|
||||
dstY: event.dst.y,
|
||||
srcX: event.src?.x,
|
||||
srcY: event.src?.y,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -301,7 +301,7 @@ export class BuildMenu extends LitElement implements Layer {
|
||||
if (!unit) {
|
||||
return false;
|
||||
}
|
||||
return unit[0].canBuild;
|
||||
return unit[0].canBuild !== false;
|
||||
}
|
||||
|
||||
private cost(item: BuildItemDisplay): number {
|
||||
|
||||
@@ -8,7 +8,12 @@ import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
|
||||
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
|
||||
import { consolex } from "../../../core/Consolex";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Cell, PlayerActions, TerraNullius } from "../../../core/game/Game";
|
||||
import {
|
||||
Cell,
|
||||
PlayerActions,
|
||||
TerraNullius,
|
||||
UnitType,
|
||||
} from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { ClientID } from "../../../core/Schemas";
|
||||
@@ -374,15 +379,26 @@ export class RadialMenu implements Layer {
|
||||
);
|
||||
});
|
||||
}
|
||||
if (actions.canBoat) {
|
||||
if (
|
||||
actions.buildableUnits.some((bu) => bu.type == UnitType.TransportShip)
|
||||
) {
|
||||
this.activateMenuElement(Slot.Boat, "#3f6ab1", boatIcon, () => {
|
||||
this.eventBus.emit(
|
||||
new SendBoatAttackIntentEvent(
|
||||
this.g.owner(tile).id(),
|
||||
this.clickedCell,
|
||||
this.uiState.attackRatio * myPlayer.troops(),
|
||||
),
|
||||
);
|
||||
// BestTransportShipSpawn is an expensive operation, so
|
||||
// we calculate it here and send the spawn tile to other clients.
|
||||
myPlayer.bestTransportShipSpawn(tile).then((spawn) => {
|
||||
if (spawn == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.eventBus.emit(
|
||||
new SendBoatAttackIntentEvent(
|
||||
this.g.owner(tile).id(),
|
||||
this.clickedCell,
|
||||
this.uiState.attackRatio * myPlayer.troops(),
|
||||
new Cell(this.g.x(spawn), this.g.y(spawn)),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (actions.canAttack) {
|
||||
|
||||
+11
-1
@@ -16,6 +16,7 @@ import {
|
||||
PlayerType,
|
||||
} from "./game/Game";
|
||||
import { createGame } from "./game/GameImpl";
|
||||
import { TileRef } from "./game/GameMap";
|
||||
import {
|
||||
ErrorUpdate,
|
||||
GameUpdateType,
|
||||
@@ -157,7 +158,6 @@ export class GameRunner {
|
||||
const player = this.game.player(playerID);
|
||||
const tile = this.game.ref(x, y);
|
||||
const actions = {
|
||||
canBoat: player.canBoat(tile),
|
||||
canAttack: player.canAttack(tile),
|
||||
buildableUnits: player.buildableUnits(tile),
|
||||
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
|
||||
@@ -194,4 +194,14 @@ export class GameRunner {
|
||||
borderTiles: player.borderTiles(),
|
||||
} as PlayerBorderTiles;
|
||||
}
|
||||
public bestTransportShipSpawn(
|
||||
playerID: PlayerID,
|
||||
targetTile: TileRef,
|
||||
): TileRef | false {
|
||||
const player = this.game.player(playerID);
|
||||
if (!player.isPlayer()) {
|
||||
throw new Error(`player with id ${playerID} not found`);
|
||||
}
|
||||
return player.bestTransportShipSpawn(targetTile);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -196,8 +196,10 @@ export const BoatAttackIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("boat"),
|
||||
targetID: ID.nullable(),
|
||||
troops: z.number().nullable(),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
dstX: z.number(),
|
||||
dstY: z.number(),
|
||||
srcX: z.number(),
|
||||
srcY: z.number(),
|
||||
});
|
||||
|
||||
export const AllianceRequestIntentSchema = BaseIntentSchema.extend({
|
||||
|
||||
+2
-68
@@ -1,8 +1,8 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import twemoji from "twemoji";
|
||||
import { Cell, Game, Player, Team, Unit } from "./game/Game";
|
||||
import { andFN, GameMap, manhattanDistFN, TileRef } from "./game/GameMap";
|
||||
import { Cell, Team, Unit } from "./game/Game";
|
||||
import { GameMap, TileRef } from "./game/GameMap";
|
||||
import {
|
||||
AllPlayersStats,
|
||||
ClientID,
|
||||
@@ -57,72 +57,6 @@ export function distSortUnit(
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: refactor to new file
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
function closestShoreTN(
|
||||
gm: GameMap,
|
||||
tile: TileRef,
|
||||
searchDist: number,
|
||||
): TileRef {
|
||||
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];
|
||||
}
|
||||
|
||||
export function simpleHash(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
|
||||
@@ -68,8 +68,9 @@ export class Executor {
|
||||
return new TransportShipExecution(
|
||||
playerID,
|
||||
intent.targetID,
|
||||
this.mg.ref(intent.x, intent.y),
|
||||
this.mg.ref(intent.dstX, intent.dstY),
|
||||
intent.troops,
|
||||
this.mg.ref(intent.srcX, intent.srcY),
|
||||
);
|
||||
case "allianceRequest":
|
||||
return new AllianceRequestExecution(playerID, intent.recipient);
|
||||
|
||||
@@ -378,6 +378,7 @@ export class FakeHumanExecution implements Execution {
|
||||
other.id(),
|
||||
closest.y,
|
||||
this.player.troops() / 5,
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -538,6 +539,7 @@ export class FakeHumanExecution implements Execution {
|
||||
this.mg.owner(dst).id(),
|
||||
dst,
|
||||
this.player.troops() / 5,
|
||||
null,
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { targetTransportTile } from "../game/TransportShipUtils";
|
||||
import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { PathFinder } from "../pathfinding/PathFinding";
|
||||
import { targetTransportTile } from "../Util";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
|
||||
export class TransportShipExecution implements Execution {
|
||||
@@ -30,7 +30,6 @@ export class TransportShipExecution implements Execution {
|
||||
|
||||
// TODO make private
|
||||
public path: TileRef[];
|
||||
private src: TileRef | null;
|
||||
private dst: TileRef | null;
|
||||
|
||||
private boat: Unit;
|
||||
@@ -42,6 +41,7 @@ export class TransportShipExecution implements Execution {
|
||||
private targetID: PlayerID | null,
|
||||
private ref: TileRef,
|
||||
private troops: number | null,
|
||||
private src: TileRef | null,
|
||||
) {}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
@@ -113,14 +113,22 @@ export class TransportShipExecution implements Execution {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
const src = this.attacker.canBuild(UnitType.TransportShip, this.dst);
|
||||
if (src == false) {
|
||||
|
||||
const closestTileSrc = this.attacker.canBuild(
|
||||
UnitType.TransportShip,
|
||||
this.dst,
|
||||
);
|
||||
if (closestTileSrc == false) {
|
||||
consolex.warn(`can't build transport ship`);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.src = src;
|
||||
if (this.src == null) {
|
||||
// Only update the src if it's not already set
|
||||
// because we assume that the src is set to the best spawn tile
|
||||
this.src = closestTileSrc;
|
||||
}
|
||||
|
||||
this.boat = this.attacker.buildUnit(
|
||||
UnitType.TransportShip,
|
||||
|
||||
@@ -444,8 +444,9 @@ export interface Player {
|
||||
// Misc
|
||||
toUpdate(): PlayerUpdate;
|
||||
playerProfile(): PlayerProfile;
|
||||
canBoat(tile: TileRef): TileRef | false;
|
||||
tradingPorts(port: Unit): Unit[];
|
||||
// WARNING: this operation is expensive.
|
||||
bestTransportShipSpawn(tile: TileRef): TileRef | false;
|
||||
}
|
||||
|
||||
export interface Game extends GameMap {
|
||||
@@ -502,7 +503,6 @@ export interface Game extends GameMap {
|
||||
}
|
||||
|
||||
export interface PlayerActions {
|
||||
canBoat: TileRef | false;
|
||||
canAttack: boolean;
|
||||
buildableUnits: BuildableUnit[];
|
||||
canSendEmojiAllPlayers: boolean;
|
||||
@@ -510,7 +510,7 @@ export interface PlayerActions {
|
||||
}
|
||||
|
||||
export interface BuildableUnit {
|
||||
canBuild: boolean;
|
||||
canBuild: TileRef | false;
|
||||
type: UnitType;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
@@ -242,6 +242,10 @@ export class PlayerView {
|
||||
return this.game.worker.playerProfile(this.smallID());
|
||||
}
|
||||
|
||||
bestTransportShipSpawn(targetTile: TileRef): Promise<TileRef | false> {
|
||||
return this.game.worker.transportShipSpawn(this.id(), targetTile);
|
||||
}
|
||||
|
||||
transitiveTargets(): PlayerView[] {
|
||||
return [...this.targets(), ...this.allies().flatMap((p) => p.targets())];
|
||||
}
|
||||
|
||||
+10
-87
@@ -4,12 +4,10 @@ import { PseudoRandom } from "../PseudoRandom";
|
||||
import { ClientID } from "../Schemas";
|
||||
import {
|
||||
assertNever,
|
||||
closestShoreFromPlayer,
|
||||
distSortUnit,
|
||||
maxInt,
|
||||
minInt,
|
||||
simpleHash,
|
||||
targetTransportTile,
|
||||
toInt,
|
||||
within,
|
||||
} from "../Util";
|
||||
@@ -44,6 +42,10 @@ import { GameImpl } from "./GameImpl";
|
||||
import { andFN, manhattanDistFN, TileRef } from "./GameMap";
|
||||
import { AttackUpdate, GameUpdateType, PlayerUpdate } from "./GameUpdates";
|
||||
import { TerraNulliusImpl } from "./TerraNulliusImpl";
|
||||
import {
|
||||
bestShoreDeploymentSource as bestTranpsortShipSpawn,
|
||||
canBuildTransportShip,
|
||||
} from "./TransportShipUtils";
|
||||
import { UnitImpl } from "./UnitImpl";
|
||||
|
||||
interface Target {
|
||||
@@ -735,7 +737,7 @@ export class PlayerImpl implements Player {
|
||||
return Object.values(UnitType).map((u) => {
|
||||
return {
|
||||
type: u,
|
||||
canBuild: this.canBuild(u, tile, validTiles) != false,
|
||||
canBuild: this.canBuild(u, tile, validTiles),
|
||||
cost: this.mg.config().unitInfo(u).cost(this),
|
||||
} as BuildableUnit;
|
||||
});
|
||||
@@ -784,7 +786,7 @@ export class PlayerImpl implements Player {
|
||||
case UnitType.SAMMissile:
|
||||
return targetTile;
|
||||
case UnitType.TransportShip:
|
||||
return this.transportShipSpawn(targetTile);
|
||||
return canBuildTransportShip(this.mg, this, targetTile);
|
||||
case UnitType.TradeShip:
|
||||
return this.tradeShipSpawn(targetTile);
|
||||
case UnitType.MissileSilo:
|
||||
@@ -906,17 +908,6 @@ export class PlayerImpl implements Player {
|
||||
return valid;
|
||||
}
|
||||
|
||||
transportShipSpawn(targetTile: TileRef): TileRef | false {
|
||||
if (!this.mg.isShore(targetTile)) {
|
||||
return false;
|
||||
}
|
||||
const spawn = closestShoreFromPlayer(this.mg, this, targetTile);
|
||||
if (spawn == null) {
|
||||
return false;
|
||||
}
|
||||
return spawn;
|
||||
}
|
||||
|
||||
tradeShipSpawn(targetTile: TileRef): TileRef | false {
|
||||
const spawns = this.units(UnitType.Port).filter(
|
||||
(u) => u.tile() == targetTile,
|
||||
@@ -957,78 +948,6 @@ export class PlayerImpl implements Player {
|
||||
return rel;
|
||||
}
|
||||
|
||||
public canBoat(tile: TileRef): TileRef | false {
|
||||
if (
|
||||
this.units(UnitType.TransportShip).length >=
|
||||
this.mg.config().boatMaxNumber()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dst = targetTransportTile(this.mg, tile);
|
||||
if (dst == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const other = this.mg.owner(tile);
|
||||
if (other == this) {
|
||||
return false;
|
||||
}
|
||||
if (other.isPlayer() && this.isFriendly(other)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.mg.isOceanShore(dst)) {
|
||||
let myPlayerBordersOcean = false;
|
||||
for (const bt of this.borderTiles()) {
|
||||
if (this.mg.isOceanShore(bt)) {
|
||||
myPlayerBordersOcean = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let otherPlayerBordersOcean = false;
|
||||
if (!this.mg.hasOwner(tile)) {
|
||||
otherPlayerBordersOcean = true;
|
||||
} else {
|
||||
for (const bt of (other as Player).borderTiles()) {
|
||||
if (this.mg.isOceanShore(bt)) {
|
||||
otherPlayerBordersOcean = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (myPlayerBordersOcean && otherPlayerBordersOcean) {
|
||||
return this.canBuild(UnitType.TransportShip, 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 = this.mg.bfs(
|
||||
dst,
|
||||
andFN(
|
||||
manhattanDistFN(dst, 300),
|
||||
(_, t: TileRef) => this.mg.isLake(t) || this.mg.isShore(t),
|
||||
),
|
||||
);
|
||||
|
||||
const sorted = Array.from(tiles).sort(
|
||||
(a, b) => this.mg.manhattanDist(dst, a) - this.mg.manhattanDist(dst, b),
|
||||
);
|
||||
|
||||
for (const t of sorted) {
|
||||
if (this.mg.owner(t) == this) {
|
||||
return this.canBuild(UnitType.TransportShip, dst);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
createAttack(
|
||||
target: Player | TerraNullius,
|
||||
troops: number,
|
||||
@@ -1097,6 +1016,10 @@ export class PlayerImpl implements Player {
|
||||
}
|
||||
}
|
||||
|
||||
bestTransportShipSpawn(targetTile: TileRef): TileRef | false {
|
||||
return bestTranpsortShipSpawn(this.mg, this, targetTile);
|
||||
}
|
||||
|
||||
// It's a probability list, so if an element appears twice it's because it's
|
||||
// twice more likely to be picked later.
|
||||
tradingPorts(port: Unit): Unit[] {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { PathFinder } from "../pathfinding/PathFinding";
|
||||
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.units(UnitType.TransportShip).length >= 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.isFriendly(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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the best shore tile for deployment among the player's shore tiles for the shortest route.
|
||||
* Calculates paths from 4 extremum tiles and the Manhattan-closest tile.
|
||||
*/
|
||||
export function bestShoreDeploymentSource(
|
||||
gm: Game,
|
||||
player: Player,
|
||||
target: TileRef,
|
||||
): TileRef | null {
|
||||
let closestManhattanDistance = Infinity;
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
|
||||
let bestByManhattan: TileRef = null;
|
||||
const extremumTiles: Record<string, TileRef> = {
|
||||
minX: null,
|
||||
minY: null,
|
||||
maxX: null,
|
||||
maxY: null,
|
||||
};
|
||||
|
||||
for (const tile of player.borderTiles()) {
|
||||
if (!gm.isShore(tile)) continue;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
bestByManhattan,
|
||||
extremumTiles.minX,
|
||||
extremumTiles.minY,
|
||||
extremumTiles.maxX,
|
||||
extremumTiles.maxY,
|
||||
].filter(Boolean);
|
||||
|
||||
if (!candidates.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the shortest actual path distance
|
||||
let closestShoreTile: TileRef | null = null;
|
||||
let closestDistance = Infinity;
|
||||
|
||||
for (const shoreTile of candidates) {
|
||||
const pathDistance = calculatePathDistance(gm, shoreTile, target);
|
||||
|
||||
if (pathDistance !== null && pathDistance < closestDistance) {
|
||||
closestDistance = pathDistance;
|
||||
closestShoreTile = shoreTile;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the Manhattan-closest tile if no path was found
|
||||
return closestShoreTile || bestByManhattan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the distance between two tiles using A*
|
||||
* Returns null if no path is found
|
||||
*/
|
||||
function calculatePathDistance(
|
||||
gm: Game,
|
||||
start: TileRef,
|
||||
target: TileRef,
|
||||
): number | null {
|
||||
let currentTile = start;
|
||||
let tileDistance = 0;
|
||||
const pathFinder = PathFinder.Mini(gm, 20_000, false);
|
||||
|
||||
while (true) {
|
||||
const result = pathFinder.nextTile(currentTile, target);
|
||||
|
||||
if (result.type === PathFindResultType.Completed) {
|
||||
return tileDistance;
|
||||
} else if (result.type === PathFindResultType.NextTile) {
|
||||
currentTile = result.tile;
|
||||
tileDistance++;
|
||||
} else if (
|
||||
result.type === PathFindResultType.PathNotFound ||
|
||||
result.type === PathFindResultType.Pending
|
||||
) {
|
||||
return null;
|
||||
} else {
|
||||
// @ts-expect-error type is never
|
||||
throw new Error(`Unexpected pathfinding result type: ${result.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closestShoreTN(
|
||||
gm: GameMap,
|
||||
tile: TileRef,
|
||||
searchDist: number,
|
||||
): TileRef {
|
||||
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];
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PlayerActionsResultMessage,
|
||||
PlayerBorderTilesResultMessage,
|
||||
PlayerProfileResultMessage,
|
||||
TransportShipSpawnResultMessage,
|
||||
WorkerMessage,
|
||||
} from "./WorkerMessages";
|
||||
|
||||
@@ -120,6 +121,25 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
throw error;
|
||||
}
|
||||
break;
|
||||
case "transport_ship_spawn":
|
||||
if (!gameRunner) {
|
||||
throw new Error("Game runner not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const spawnTile = (await gameRunner).bestTransportShipSpawn(
|
||||
message.playerID,
|
||||
message.targetTile,
|
||||
);
|
||||
sendMessage({
|
||||
type: "transport_ship_spawn_result",
|
||||
id: message.id,
|
||||
result: spawnTile,
|
||||
} as TransportShipSpawnResultMessage);
|
||||
} catch (error) {
|
||||
console.error("Failed to spawn transport ship:", error);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.warn("Unknown message :", message);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PlayerID,
|
||||
PlayerProfile,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { ClientID, GameStartInfo, Turn } from "../Schemas";
|
||||
import { generateID } from "../Util";
|
||||
@@ -188,6 +189,36 @@ export class WorkerClient {
|
||||
});
|
||||
}
|
||||
|
||||
transportShipSpawn(
|
||||
playerID: PlayerID,
|
||||
targetTile: TileRef,
|
||||
): Promise<TileRef | false> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isInitialized) {
|
||||
reject(new Error("Worker not initialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = generateID();
|
||||
|
||||
this.messageHandlers.set(messageId, (message) => {
|
||||
if (
|
||||
message.type === "transport_ship_spawn_result" &&
|
||||
message.result !== undefined
|
||||
) {
|
||||
resolve(message.result);
|
||||
}
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
type: "transport_ship_spawn",
|
||||
id: messageId,
|
||||
playerID: playerID,
|
||||
targetTile: targetTile,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.worker.terminate();
|
||||
this.messageHandlers.clear();
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PlayerID,
|
||||
PlayerProfile,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { ClientID, GameStartInfo, Turn } from "../Schemas";
|
||||
|
||||
@@ -18,7 +19,9 @@ export type WorkerMessageType =
|
||||
| "player_profile"
|
||||
| "player_profile_result"
|
||||
| "player_border_tiles"
|
||||
| "player_border_tiles_result";
|
||||
| "player_border_tiles_result"
|
||||
| "transport_ship_spawn"
|
||||
| "transport_ship_spawn_result";
|
||||
|
||||
// Base interface for all messages
|
||||
interface BaseWorkerMessage {
|
||||
@@ -84,6 +87,17 @@ export interface PlayerBorderTilesResultMessage extends BaseWorkerMessage {
|
||||
result: PlayerBorderTiles;
|
||||
}
|
||||
|
||||
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
|
||||
type: "transport_ship_spawn";
|
||||
playerID: PlayerID;
|
||||
targetTile: TileRef;
|
||||
}
|
||||
|
||||
export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
|
||||
type: "transport_ship_spawn_result";
|
||||
result: TileRef | false;
|
||||
}
|
||||
|
||||
// Union types for type safety
|
||||
export type MainThreadMessage =
|
||||
| HeartbeatMessage
|
||||
@@ -91,7 +105,8 @@ export type MainThreadMessage =
|
||||
| TurnMessage
|
||||
| PlayerActionsMessage
|
||||
| PlayerProfileMessage
|
||||
| PlayerBorderTilesMessage;
|
||||
| PlayerBorderTilesMessage
|
||||
| TransportShipSpawnMessage;
|
||||
|
||||
// Message send from worker
|
||||
export type WorkerMessage =
|
||||
@@ -99,4 +114,5 @@ export type WorkerMessage =
|
||||
| GameUpdateMessage
|
||||
| PlayerActionsResultMessage
|
||||
| PlayerProfileResultMessage
|
||||
| PlayerBorderTilesResultMessage;
|
||||
| PlayerBorderTilesResultMessage
|
||||
| TransportShipSpawnResultMessage;
|
||||
|
||||
+1
-1
Submodule src/server/gatekeeper updated: adef17d115...8324db9408
@@ -19,9 +19,9 @@ let defender: Player;
|
||||
let defenderSpawn: TileRef;
|
||||
let attackerSpawn: TileRef;
|
||||
|
||||
function sendBoat(target: TileRef, troops: number) {
|
||||
function sendBoat(target: TileRef, source: TileRef, troops: number) {
|
||||
game.addExecution(
|
||||
new TransportShipExecution(defender.id(), null, target, troops),
|
||||
new TransportShipExecution(defender.id(), null, target, troops, source),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ describe("Attack", () => {
|
||||
constructionExecution(game, defender.id(), 1, 1, UnitType.MissileSilo);
|
||||
expect(defender.units(UnitType.MissileSilo)).toHaveLength(1);
|
||||
|
||||
sendBoat(game.ref(15, 8), 100);
|
||||
sendBoat(game.ref(15, 8), game.ref(10, 5), 100);
|
||||
|
||||
constructionExecution(game, defender.id(), 0, 15, UnitType.AtomBomb, 3);
|
||||
const nuke = defender.units(UnitType.AtomBomb)[0];
|
||||
|
||||
Reference in New Issue
Block a user