From 71849b47cd7043bce651576d32717159b5ee78f9 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 21 Apr 2025 19:49:17 -0700 Subject: [PATCH] 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: evan Co-authored-by: evan --- src/client/ClientGameRunner.ts | 12 +- src/client/Transport.ts | 9 +- src/client/graphics/layers/BuildMenu.ts | 2 +- src/client/graphics/layers/RadialMenu.ts | 34 ++- src/core/GameRunner.ts | 12 +- src/core/Schemas.ts | 6 +- src/core/Util.ts | 70 +---- src/core/execution/ExecutionManager.ts | 3 +- src/core/execution/FakeHumanExecution.ts | 2 + src/core/execution/TransportShipExecution.ts | 18 +- src/core/game/Game.ts | 6 +- src/core/game/GameView.ts | 4 + src/core/game/PlayerImpl.ts | 97 +------ src/core/game/TransportShipUtils.ts | 272 +++++++++++++++++++ src/core/worker/Worker.worker.ts | 20 ++ src/core/worker/WorkerClient.ts | 31 +++ src/core/worker/WorkerMessages.ts | 22 +- src/server/gatekeeper | 2 +- tests/Attack.test.ts | 6 +- 19 files changed, 438 insertions(+), 190 deletions(-) create mode 100644 src/core/game/TransportShipUtils.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 25862517d..eba0c6e25 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -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( diff --git a/src/client/Transport.ts b/src/client/Transport.ts index d3c570a9b..013b5d298 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -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, }); } diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index 9873fc571..ed3a477d8 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -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 { diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index e161f7217..474bfc932 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -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) { diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index c4b3ab3f7..8a3eba036 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -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); + } } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 7fc8e63a5..c817f556d 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -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({ diff --git a/src/core/Util.ts b/src/core/Util.ts index eaf83edae..62304353c 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -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++) { diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 968455cc1..06ed621bf 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -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); diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 3b336fee7..bf311b8c3 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -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; diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 056a39a46..9e3dd4396 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -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, diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index ae73974b3..cde489f37 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -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; } diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index d13390db1..3f7d9bf5e 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -242,6 +242,10 @@ export class PlayerView { return this.game.worker.playerProfile(this.smallID()); } + bestTransportShipSpawn(targetTile: TileRef): Promise { + return this.game.worker.transportShipSpawn(this.id(), targetTile); + } + transitiveTargets(): PlayerView[] { return [...this.targets(), ...this.allies().flatMap((p) => p.targets())]; } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 3c1de6733..09892124f 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -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[] { diff --git a/src/core/game/TransportShipUtils.ts b/src/core/game/TransportShipUtils.ts new file mode 100644 index 000000000..2133f1083 --- /dev/null +++ b/src/core/game/TransportShipUtils.ts @@ -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 = { + 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]; +} diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index de6617509..ad004bfc7 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -6,6 +6,7 @@ import { PlayerActionsResultMessage, PlayerBorderTilesResultMessage, PlayerProfileResultMessage, + TransportShipSpawnResultMessage, WorkerMessage, } from "./WorkerMessages"; @@ -120,6 +121,25 @@ ctx.addEventListener("message", async (e: MessageEvent) => { 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); } diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index d17eabcb8..db9698cc3 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -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 { + 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(); diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 57174f37e..944a0e280 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -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; diff --git a/src/server/gatekeeper b/src/server/gatekeeper index adef17d11..8324db940 160000 --- a/src/server/gatekeeper +++ b/src/server/gatekeeper @@ -1 +1 @@ -Subproject commit adef17d115590c506b8b957390755f1f1b1c2beb +Subproject commit 8324db9408ce63097f750589bbea6b913127b60f diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index f9c46bcc3..81f3b49d7 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -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];