mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:00:42 +00:00
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: <DISCORD USERNAME> evan --------- Co-authored-by: evan <openfrontio@gmail.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
+2
-2
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<TileRef, TileRef>;
|
||||
private bwdCameFrom: Map<TileRef, TileRef>;
|
||||
private fwdGScore: Map<TileRef, number>;
|
||||
private bwdGScore: Map<TileRef, number>;
|
||||
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<TileRef, TileRef>();
|
||||
this.bwdCameFrom = new Map<TileRef, TileRef>();
|
||||
this.fwdGScore = new Map<TileRef, number>();
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user