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];