Files
OpenFrontIO/tests/pathfinding/playground/api/pathfinding.ts
T
Evan Pelle a7f992e9b0 refactor: convert to npm-workspaces monorepo (engine/core-public/shared/client/server)
Restructure the single src/ tree into an npm-workspaces monorepo under
packages/, rename core -> engine, extract a types-only core-public layer,
and break the pre-existing engine -> client dependency cycle.

Structure (packages/):
  core-public  public API/wire schemas + shared enums (clean leaf)
  shared       framework-agnostic helpers (clean leaf)
  engine       deterministic simulation (was src/core)
  client       rendering/UI (was src/client)
  server       coordination (was src/server)

Dependency DAG: engine -> {core-public, shared}; client -> {core-public,
shared, engine}; server -> {core-public, engine}.

- npm workspaces: root package.json workspaces + per-package package.json;
  tsconfig.base.json holds shared options + path aliases
  (core-public/* shared/* engine/* client/* server/*) resolved uniformly by
  tsc, Vite (resolve.tsconfigPaths), Vitest, and tsx. Lockfile regenerated.
- core-public: moved Schemas/ApiSchemas/CosmeticSchemas/StatsSchemas/
  ClanApiSchemas/WorkerSchemas/Base64/PatternDecoder; extracted the enums
  (GameTypes), GameEvent type, emoji table, and GraphicsOverrides schema.
  Engine re-exports the moved enums/types so existing imports keep working.
- Broke engine -> client cycle:
  - renderNumber/renderTroops -> shared/format
  - NameBoxCalculator moved into engine
  - username validation returns translation key + params; client translates
  - applyStateUpdate moved to client (operates on the render-only PlayerState)
  - Config/UnitGrid/execution-Util/GameImpl now use structural read
    interfaces (engine/game/ReadViews: PlayerLike/UnitLike/GameLike) instead
    of importing client view classes; client imports view classes from a new
    client/view barrel; deleted the engine/game/GameView re-export shim.
- Build/deploy updated: vite.config, index.html, eslint, Dockerfile
  (copies packages/ + tsconfig.base.json before npm ci), .vscode, tests.

Verified: tsc --noEmit clean; 1364 + 65 tests pass; production vite build
succeeds; engine has zero client/server imports; core-public and shared are
dependency leaves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:51:03 +00:00

245 lines
6.0 KiB
TypeScript

import { TileRef } from "engine/game/GameMap.js";
import { PathFinding } from "engine/pathfinding/PathFinder.js";
import { SteppingPathFinder } from "engine/pathfinding/types.js";
import { DebugSpan } from "engine/utilities/DebugSpan.js";
import { getAdapter } from "../../utils.js";
import { COMPARISON_ADAPTERS, loadMap } from "./maps.js";
// Primary result with debug info
interface PrimaryResult {
path: Array<[number, number]> | null;
length: number;
time: number;
debug: {
nodePath: Array<[number, number]> | null;
initialPath: Array<[number, number]> | null;
cachedSegmentsUsed: number | null;
timings: Record<string, number>;
};
}
// Comparison result (path + timing only)
interface ComparisonResult {
adapter: string;
path: Array<[number, number]> | null;
length: number;
time: number;
}
export interface PathfindResult {
primary: PrimaryResult;
comparisons: ComparisonResult[];
}
// Cache adapters per map
const adapterCache = new Map<
string,
Map<string, SteppingPathFinder<TileRef>>
>();
/**
* Get or create an adapter for a map
*/
function getOrCreateAdapter(
mapName: string,
adapterName: string,
game: any,
): SteppingPathFinder<TileRef> {
if (!adapterCache.has(mapName)) {
adapterCache.set(mapName, new Map());
}
const mapAdapters = adapterCache.get(mapName)!;
if (!mapAdapters.has(adapterName)) {
mapAdapters.set(adapterName, getAdapter(game, adapterName));
}
return mapAdapters.get(adapterName)!;
}
/**
* Convert TileRef array to coordinate array
*/
function pathToCoords(
path: TileRef[] | null,
game: any,
): Array<[number, number]> | null {
if (!path) return null;
return path.map((tile) => [game.x(tile), game.y(tile)]);
}
/**
* Extract timings from DebugSpan hierarchy
* Flattens nested spans into { spanName: duration } format
*/
function extractTimings(span: {
name: string;
duration?: number;
children: any[];
}): Record<string, number> {
const timings: Record<string, number> = {};
if (span.duration !== undefined) {
timings[span.name] = span.duration;
}
for (const child of span.children) {
Object.assign(timings, extractTimings(child));
}
return timings;
}
/**
* Compute primary path using PathFinding.Water with debug info
*/
function computePrimaryPath(
game: any,
fromRef: TileRef,
toRef: TileRef,
): PrimaryResult {
const miniMap = game.miniMap();
// Use standard PathFinding.Water
const pf = PathFinding.Water(game);
// Enable DebugSpan to capture internal state
DebugSpan.enable();
const path = pf.findPath(fromRef, toRef);
// Get span data and disable
const span = DebugSpan.getLastSpan();
DebugSpan.disable();
// Convert node path (miniMap coords) to full map coords
let nodePath: Array<[number, number]> | null = null;
const spanNodePath = span?.data?.nodePath as TileRef[] | undefined;
if (spanNodePath) {
nodePath = spanNodePath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
// Convert initialPath (miniMap TileRefs) to full map coords
let initialPath: Array<[number, number]> | null = null;
const spanInitialPath = span?.data?.initialPath as TileRef[] | undefined;
if (spanInitialPath) {
initialPath = spanInitialPath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
let cachedSegmentsUsed: number | null = null;
if (span?.data?.cachedSegmentsUsed !== undefined) {
cachedSegmentsUsed = span.data.cachedSegmentsUsed as number;
}
// Extract timings from span hierarchy
const timings = span ? extractTimings(span) : {};
return {
path: pathToCoords(path, game),
length: path ? path.length : 0,
time: timings["hpa:findPath"] || 0,
debug: {
nodePath,
initialPath,
cachedSegmentsUsed,
timings,
},
};
}
/**
* Compute comparison path using adapter
*/
function computeComparisonPath(
adapter: SteppingPathFinder<TileRef>,
game: any,
fromRef: TileRef,
toRef: TileRef,
adapterName: string,
): ComparisonResult {
const start = performance.now();
const path = adapter.findPath(fromRef, toRef);
const time = performance.now() - start;
return {
adapter: adapterName,
path: pathToCoords(path, game),
length: path ? path.length : 0,
time,
};
}
/**
* Compute pathfinding between two points
*/
export async function computePath(
mapName: string,
from: [number, number],
to: [number, number],
options: { adapters?: string[] } = {},
): Promise<PathfindResult> {
const { game } = await loadMap(mapName);
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute primary path (PathFinding.Water with debug)
const primary = computePrimaryPath(game, fromRef, toRef);
// Compute comparison paths
const selectedAdapters = options.adapters ?? COMPARISON_ADAPTERS;
const comparisons: ComparisonResult[] = [];
for (const adapterName of selectedAdapters) {
if (!COMPARISON_ADAPTERS.includes(adapterName)) {
console.warn(`Unknown adapter: ${adapterName}, skipping`);
continue;
}
try {
const adapter = getOrCreateAdapter(mapName, adapterName, game);
const result = computeComparisonPath(
adapter,
game,
fromRef,
toRef,
adapterName,
);
comparisons.push(result);
} catch (error) {
console.error(`Error with adapter ${adapterName}:`, error);
comparisons.push({
adapter: adapterName,
path: null,
length: 0,
time: 0,
});
}
}
return { primary, comparisons };
}
/**
* Clear pathfinding adapter caches
*/
export function clearAdapterCaches() {
adapterCache.clear();
}