From 84287b8dfad1e48bbd9c185ca11a643fa6915c45 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 23 Apr 2025 10:16:43 -0700 Subject: [PATCH] Multi src astar (#594) ## Description: Samples border shore tiles and uses multi-a* for determining the transport ship spawn cell. ## 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 | 28 +++-- src/client/graphics/layers/RadialMenu.ts | 7 +- src/core/Schemas.ts | 4 +- src/core/execution/ExecutionManager.ts | 6 +- src/core/execution/MIRVExecution.ts | 2 +- src/core/execution/PortExecution.ts | 2 +- src/core/execution/SAMMissileExecution.ts | 2 +- src/core/execution/ShellExecution.ts | 2 +- src/core/execution/TransportShipExecution.ts | 12 ++- src/core/execution/WarshipExecution.ts | 2 +- src/core/game/TransportShipUtils.ts | 108 ++++++++----------- src/core/pathfinding/MiniAStar.ts | 22 ++-- src/core/pathfinding/PathFinding.ts | 13 +-- src/core/pathfinding/SerialAStar.ts | 83 ++++++++++---- 14 files changed, 167 insertions(+), 126 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index eba0c6e25..1b8aefe22 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -11,7 +11,7 @@ import { import { createGameRecord } from "../core/Util"; import { ServerConfig } from "../core/configuration/Config"; import { getConfig } from "../core/configuration/ConfigLoader"; -import { Team, UnitType } from "../core/game/Game"; +import { Cell, Team, UnitType } from "../core/game/Game"; import { TileRef } from "../core/game/GameMap"; import { ErrorUpdate, @@ -372,13 +372,25 @@ export class ClientGameRunner { this.shouldBoat(tile, bu.canBuild) && this.gameView.isLand(tile) ) { - this.eventBus.emit( - new SendBoatAttackIntentEvent( - this.gameView.owner(tile).id(), - cell, - this.myPlayer.troops() * this.renderer.uiState.attackRatio, - ), - ); + this.myPlayer + .bestTransportShipSpawn(this.gameView.ref(cell.x, cell.y)) + .then((spawn: number | false) => { + let spawnCell = null; + if (spawn !== false) { + spawnCell = new Cell( + this.gameView.x(spawn), + this.gameView.y(spawn), + ); + } + this.eventBus.emit( + new SendBoatAttackIntentEvent( + this.gameView.owner(tile).id(), + cell, + this.myPlayer.troops() * this.renderer.uiState.attackRatio, + spawnCell, + ), + ); + }); } const owner = this.gameView.owner(tile); diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 92a6377b1..a459764ff 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -387,8 +387,9 @@ export class RadialMenu implements Layer { // 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; + let spawnTile: Cell | null = null; + if (spawn !== false) { + spawnTile = new Cell(this.g.x(spawn), this.g.y(spawn)); } this.eventBus.emit( @@ -396,7 +397,7 @@ export class RadialMenu implements Layer { this.g.owner(tile).id(), this.clickedCell, this.uiState.attackRatio * myPlayer.troops(), - new Cell(this.g.x(spawn), this.g.y(spawn)), + spawnTile, ), ); }); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index c817f556d..3fb370804 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -198,8 +198,8 @@ export const BoatAttackIntentSchema = BaseIntentSchema.extend({ troops: z.number().nullable(), dstX: z.number(), dstY: z.number(), - srcX: z.number(), - srcY: z.number(), + srcX: z.number().nullable().optional(), + srcY: z.number().nullable().optional(), }); export const AllianceRequestIntentSchema = BaseIntentSchema.extend({ diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 06ed621bf..83795f90d 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -65,12 +65,16 @@ export class Executor { this.mg.ref(intent.x, intent.y), ); case "boat": + let src = null; + if (intent.srcX != null || intent.srcY != null) { + src = this.mg.ref(intent.srcX, intent.srcY); + } return new TransportShipExecution( playerID, intent.targetID, this.mg.ref(intent.dstX, intent.dstY), intent.troops, - this.mg.ref(intent.srcX, intent.srcY), + src, ); case "allianceRequest": return new AllianceRequestExecution(playerID, intent.recipient); diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index d2b118c0a..55b928c17 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -50,7 +50,7 @@ export class MirvExecution implements Execution { this.random = new PseudoRandom(mg.ticks() + simpleHash(this.senderID)); this.mg = mg; - this.pathFinder = PathFinder.Mini(mg, 10_000, true); + this.pathFinder = PathFinder.Mini(mg, 10_000); this.player = mg.player(this.senderID); this.targetPlayer = this.mg.owner(this.dst); diff --git a/src/core/execution/PortExecution.ts b/src/core/execution/PortExecution.ts index 6e44eeb56..0e61470ad 100644 --- a/src/core/execution/PortExecution.ts +++ b/src/core/execution/PortExecution.ts @@ -76,7 +76,7 @@ export class PortExecution implements Execution { } const port = this.random.randElement(ports); - const pf = PathFinder.Mini(this.mg, 2500, false); + const pf = PathFinder.Mini(this.mg, 2500); this.mg.addExecution( new TradeShipExecution(this.player().id(), this.port, port, pf), ); diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts index 4691808c4..33d769fd9 100644 --- a/src/core/execution/SAMMissileExecution.ts +++ b/src/core/execution/SAMMissileExecution.ts @@ -26,7 +26,7 @@ export class SAMMissileExecution implements Execution { ) {} init(mg: Game, ticks: number): void { - this.pathFinder = PathFinder.Mini(mg, 2000, true, 10); + this.pathFinder = PathFinder.Mini(mg, 2000, 10); this.mg = mg; } diff --git a/src/core/execution/ShellExecution.ts b/src/core/execution/ShellExecution.ts index d44159ed8..4f0d306f7 100644 --- a/src/core/execution/ShellExecution.ts +++ b/src/core/execution/ShellExecution.ts @@ -19,7 +19,7 @@ export class ShellExecution implements Execution { ) {} init(mg: Game, ticks: number): void { - this.pathFinder = PathFinder.Mini(mg, 2000, true, 10); + this.pathFinder = PathFinder.Mini(mg, 2000, 10); this.mg = mg; } diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 9e3dd4396..d9b001739 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -64,7 +64,7 @@ export class TransportShipExecution implements Execution { this.lastMove = ticks; this.mg = mg; - this.pathFinder = PathFinder.Mini(mg, 10_000, false, 10); + this.pathFinder = PathFinder.Mini(mg, 10_000, 10); this.attacker = mg.player(this.attackerID); @@ -128,6 +128,16 @@ export class TransportShipExecution implements Execution { // 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; + } else { + if ( + this.mg.owner(this.src) != this.attacker || + !this.mg.isShore(this.src) + ) { + console.warn( + `src is not a shore tile or not owned by: ${this.attacker.name()}`, + ); + this.src = closestTileSrc; + } } this.boat = this.attacker.buildUnit( diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index c676403e5..db467d55d 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -40,7 +40,7 @@ export class WarshipExecution implements Execution { this.active = false; return; } - this.pathfinder = PathFinder.Mini(mg, 5000, false); + this.pathfinder = PathFinder.Mini(mg, 5000); this._owner = mg.player(this.playerID); this.mg = mg; this.patrolTile = this.patrolCenterTile; diff --git a/src/core/game/TransportShipUtils.ts b/src/core/game/TransportShipUtils.ts index 2af76b56c..a5d5bf7df 100644 --- a/src/core/game/TransportShipUtils.ts +++ b/src/core/game/TransportShipUtils.ts @@ -1,5 +1,5 @@ import { PathFindResultType } from "../pathfinding/AStar"; -import { PathFinder } from "../pathfinding/PathFinding"; +import { MiniAStar } from "../pathfinding/MiniAStar"; import { Game, Player, UnitType } from "./Game"; import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap"; @@ -139,19 +139,44 @@ export function closestShoreFromPlayer( }); } -/** - * 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 { +): TileRef | false { target = targetTransportTile(gm, target); if (target == null) { - return null; + return false; } + + const candidates = candidateShoreTiles(gm, player, target); + const aStar = new MiniAStar(gm, gm.miniMap(), candidates, target, 500_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, @@ -166,9 +191,11 @@ export function bestShoreDeploymentSource( maxY: null, }; - for (const tile of player.borderTiles()) { - if (!gm.isShore(tile)) continue; + 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); @@ -194,66 +221,25 @@ export function bestShoreDeploymentSource( } } + // 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); - 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}`); - } - } + return candidates; } function closestShoreTN( diff --git a/src/core/pathfinding/MiniAStar.ts b/src/core/pathfinding/MiniAStar.ts index 6ddab13c6..2c51a7a38 100644 --- a/src/core/pathfinding/MiniAStar.ts +++ b/src/core/pathfinding/MiniAStar.ts @@ -3,31 +3,33 @@ import { GameMap, TileRef } from "../game/GameMap"; import { AStar, PathFindResultType } from "./AStar"; import { SerialAStar } from "./SerialAStar"; -// TODO: test this, get it work export class MiniAStar implements AStar { - private aStar: SerialAStar; + private aStar: AStar; constructor( private gameMap: GameMap, private miniMap: GameMap, - private src: TileRef, + src: TileRef | TileRef[], private dst: TileRef, - private canMove: (t: TileRef) => boolean, - private iterations: number, - private maxTries: number, + iterations: number, + maxTries: number, ) { - const miniSrc = this.miniMap.ref( - Math.floor(gameMap.x(src) / 2), - Math.floor(gameMap.y(src) / 2), + const srcArray: TileRef[] = Array.isArray(src) ? src : [src]; + const miniSrc = srcArray.map((srcPoint) => + this.miniMap.ref( + Math.floor(gameMap.x(srcPoint) / 2), + Math.floor(gameMap.y(srcPoint) / 2), + ), ); + const miniDst = this.miniMap.ref( Math.floor(gameMap.x(dst) / 2), Math.floor(gameMap.y(dst) / 2), ); + this.aStar = new SerialAStar( miniSrc, miniDst, - canMove, iterations, maxTries, this.miniMap, diff --git a/src/core/pathfinding/PathFinding.ts b/src/core/pathfinding/PathFinding.ts index 2a8ac3192..4760587e4 100644 --- a/src/core/pathfinding/PathFinding.ts +++ b/src/core/pathfinding/PathFinding.ts @@ -16,24 +16,13 @@ export class PathFinder { private newAStar: (curr: TileRef, dst: TileRef) => AStar, ) {} - public static Mini( - game: Game, - iterations: number, - canMoveOnLand: boolean, - maxTries: number = 20, - ) { + public static Mini(game: Game, iterations: number, maxTries: number = 20) { return new PathFinder(game, (curr: TileRef, dst: TileRef) => { return new MiniAStar( game.map(), game.miniMap(), curr, dst, - (tr: TileRef): boolean => { - if (canMoveOnLand) { - return true; - } - return game.miniMap().isWater(tr); - }, iterations, maxTries, ); diff --git a/src/core/pathfinding/SerialAStar.ts b/src/core/pathfinding/SerialAStar.ts index 3b3ac9193..8f047ccfa 100644 --- a/src/core/pathfinding/SerialAStar.ts +++ b/src/core/pathfinding/SerialAStar.ts @@ -4,29 +4,42 @@ import { GameMap, TileRef } from "../game/GameMap"; import { AStar, PathFindResultType } from "./AStar"; export class SerialAStar implements AStar { - private fwdOpenSet: PriorityQueue<{ tile: TileRef; fScore: number }>; - private bwdOpenSet: PriorityQueue<{ tile: TileRef; fScore: number }>; + private fwdOpenSet: PriorityQueue<{ + tile: TileRef; + fScore: number; + }>; + + private bwdOpenSet: PriorityQueue<{ + tile: TileRef; + fScore: number; + }>; + private fwdCameFrom: Map; private bwdCameFrom: Map; private fwdGScore: Map; private bwdGScore: Map; private meetingPoint: TileRef | null; public completed: boolean; + private sources: TileRef[]; + private closestSource: TileRef; constructor( - private src: TileRef, + src: TileRef | TileRef[], private dst: TileRef, - private canMove: (t: TileRef) => boolean, private iterations: number, private maxTries: number, private gameMap: GameMap, ) { - this.fwdOpenSet = new PriorityQueue<{ tile: TileRef; fScore: number }>( - (a, b) => a.fScore - b.fScore, - ); - this.bwdOpenSet = new PriorityQueue<{ tile: TileRef; fScore: number }>( - (a, b) => a.fScore - b.fScore, - ); + this.fwdOpenSet = new PriorityQueue<{ + tile: TileRef; + fScore: number; + }>((a, b) => a.fScore - b.fScore); + + this.bwdOpenSet = new PriorityQueue<{ + tile: TileRef; + fScore: number; + }>((a, b) => a.fScore - b.fScore); + this.fwdCameFrom = new Map(); this.bwdCameFrom = new Map(); this.fwdGScore = new Map(); @@ -34,13 +47,32 @@ export class SerialAStar implements AStar { this.meetingPoint = null; this.completed = false; - // Initialize forward search - this.fwdGScore.set(src, 0); - this.fwdOpenSet.enqueue({ tile: src, fScore: this.heuristic(src, dst) }); + this.sources = Array.isArray(src) ? src : [src]; + this.closestSource = this.findClosestSource(dst); - // Initialize backward search + // Initialize forward search with source point(s) + this.sources.forEach((startPoint) => { + this.fwdGScore.set(startPoint, 0); + this.fwdOpenSet.enqueue({ + tile: startPoint, + fScore: this.heuristic(startPoint, dst), + }); + }); + + // Initialize backward search from destination this.bwdGScore.set(dst, 0); - this.bwdOpenSet.enqueue({ tile: dst, fScore: this.heuristic(dst, src) }); + this.bwdOpenSet.enqueue({ + tile: dst, + fScore: this.heuristic(dst, this.findClosestSource(dst)), + }); + } + + private findClosestSource(tile: TileRef): TileRef { + return this.sources.reduce((closest, source) => + this.heuristic(tile, source) < this.heuristic(tile, closest) + ? source + : closest, + ); } compute(): PathFindResultType { @@ -60,8 +92,9 @@ export class SerialAStar implements AStar { // Process forward search const fwdCurrent = this.fwdOpenSet.dequeue()!.tile; + + // Check if we've found a meeting point if (this.bwdGScore.has(fwdCurrent)) { - // We found a meeting point! this.meetingPoint = fwdCurrent; this.completed = true; return PathFindResultType.Completed; @@ -71,8 +104,9 @@ export class SerialAStar implements AStar { // Process backward search const bwdCurrent = this.bwdOpenSet.dequeue()!.tile; + + // Check if we've found a meeting point if (this.fwdGScore.has(bwdCurrent)) { - // We found a meeting point! this.meetingPoint = bwdCurrent; this.completed = true; return PathFindResultType.Completed; @@ -89,8 +123,8 @@ export class SerialAStar implements AStar { private expandTileRef(current: TileRef, isForward: boolean) { for (const neighbor of this.gameMap.neighbors(current)) { if ( - neighbor != (isForward ? this.dst : this.src) && - !this.canMove(neighbor) + neighbor != (isForward ? this.dst : this.closestSource) && + !this.gameMap.isWater(neighbor) ) continue; @@ -106,21 +140,22 @@ export class SerialAStar implements AStar { gScore.set(neighbor, tentativeGScore); const fScore = tentativeGScore + - this.heuristic(neighbor, isForward ? this.dst : this.src); + this.heuristic(neighbor, isForward ? this.dst : this.closestSource); openSet.enqueue({ tile: neighbor, fScore: fScore }); } } } private heuristic(a: TileRef, b: TileRef): number { - // TODO use wrapped try { return ( - 1.1 * Math.abs(this.gameMap.x(a) - this.gameMap.x(b)) + - Math.abs(this.gameMap.y(a) - this.gameMap.y(b)) + 1.1 * + (Math.abs(this.gameMap.x(a) - this.gameMap.x(b)) + + Math.abs(this.gameMap.y(a) - this.gameMap.y(b))) ); } catch { consolex.log("uh oh"); + return 0; } } @@ -130,6 +165,7 @@ export class SerialAStar implements AStar { // Reconstruct path from start to meeting point const fwdPath: TileRef[] = [this.meetingPoint]; let current = this.meetingPoint; + while (this.fwdCameFrom.has(current)) { current = this.fwdCameFrom.get(current)!; fwdPath.unshift(current); @@ -137,6 +173,7 @@ export class SerialAStar implements AStar { // Reconstruct path from meeting point to goal current = this.meetingPoint; + while (this.bwdCameFrom.has(current)) { current = this.bwdCameFrom.get(current)!; fwdPath.push(current);