Files
OpenFrontIO/tests/pathfinding/playground/api/maps.ts
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

271 lines
7.1 KiB
TypeScript

import { readdirSync, readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { Game } from "engine/game/Game.js";
import { DebugSpan } from "engine/utilities/DebugSpan.js";
import { setupFromPath } from "../../utils.js";
// Available comparison adapters
// Note: "hpa.cached" runs same algorithm without debug overhead for fair timing comparison
export const COMPARISON_ADAPTERS = [
"hpa.cached",
"hpa",
"a.baseline",
"a.generic",
"a.full",
];
export interface MapInfo {
name: string;
displayName: string;
}
export interface GraphBuildData {
nodes: any[];
edges: any[];
nodesCount: number;
edgesCount: number;
clustersCount: number;
buildTime: number;
}
export interface MapCache {
game: Game;
graphBuildData: GraphBuildData | null;
}
const cache = new Map<string, MapCache>();
/**
* Get the resources/maps directory path
*/
function getMapsDirectory(): string {
return join(
dirname(fileURLToPath(import.meta.url)),
"resources/maps",
);
}
/**
* Format map name to title case with proper spacing
* Handles: underscores, camelCase, existing spaces, and parentheses
*/
function formatMapName(name: string): string {
return (
name
// Replace underscores with spaces
.replace(/_/g, " ")
// Add space before capital letters (for camelCase)
.replace(/([a-z])([A-Z])/g, "$1 $2")
// Convert to lowercase first
.toLowerCase()
// Capitalize first letter of string
.replace(/^\w/, (char) => char.toUpperCase())
// Capitalize after spaces and opening parentheses
.replace(/(\s+|[(])\w/g, (match) => match.toUpperCase())
);
}
/**
* Get list of available maps by reading the resources/maps directory
*/
export function listMaps(): MapInfo[] {
const mapsDir = getMapsDirectory();
const maps: MapInfo[] = [];
try {
const entries = readdirSync(mapsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const name = entry.name;
let displayName = formatMapName(name);
// Try to read displayName from manifest.json
try {
const manifestPath = join(mapsDir, name, "manifest.json");
const manifestData = JSON.parse(readFileSync(manifestPath, "utf-8"));
if (manifestData.name) {
displayName = formatMapName(manifestData.name);
}
} catch (e) {
// If manifest doesn't exist or doesn't have name, use formatted folder name
console.warn(
`Could not read manifest for ${name}:`,
e instanceof Error ? e.message : e,
);
}
maps.push({ name, displayName });
}
}
} catch (e) {
console.error("Failed to read maps directory:", e);
}
return maps.sort((a, b) => a.displayName.localeCompare(b.displayName));
}
/**
* Extract graph build data from DebugSpan
*/
function extractGraphBuildData(): GraphBuildData | null {
const span = DebugSpan.getLastSpan();
if (!span || span.name !== "AbstractGraphBuilder:build") {
return null;
}
return {
nodes: (span.data.nodes as any[]) || [],
edges: (span.data.edges as any[]) || [],
nodesCount: (span.data.nodesCount as number) || 0,
edgesCount: (span.data.edgesCount as number) || 0,
clustersCount: (span.data.clustersCount as number) || 0,
buildTime: span.duration || 0,
};
}
/**
* Load a map from cache or disk
*/
export async function loadMap(mapName: string): Promise<MapCache> {
// Check cache first
if (cache.has(mapName)) {
return cache.get(mapName)!;
}
const mapsDir = getMapsDirectory();
// Enable DebugSpan to capture graph build data
DebugSpan.enable();
// Use the existing setupFromPath utility to load the map
const game = await setupFromPath(mapsDir, mapName, { disableNavMesh: false });
// Capture graph build data from DebugSpan
const graphBuildData = extractGraphBuildData();
DebugSpan.disable();
const cacheEntry: MapCache = { game, graphBuildData };
// Store in cache
cache.set(mapName, cacheEntry);
return cacheEntry;
}
/**
* Get map metadata for client
*/
export async function getMapMetadata(mapName: string) {
const { game, graphBuildData } = await loadMap(mapName);
// Extract map data
const mapData: number[] = [];
for (let y = 0; y < game.height(); y++) {
for (let x = 0; x < game.width(); x++) {
const tile = game.ref(x, y);
mapData.push(game.isWater(tile) ? 1 : 0);
}
}
const graph = game.miniWaterGraph();
const miniMap = game.miniMap();
const clusterSize = graph?.clusterSize ?? 0;
// Use graphBuildData from DebugSpan if available, otherwise fall back to direct access
let allNodes: Array<{ id: number; x: number; y: number }>;
let edges: Array<{
fromId: number;
toId: number;
from: number[];
to: number[];
cost: number;
}>;
if (graphBuildData) {
// Convert nodes from DebugSpan data (AbstractNode format)
allNodes = graphBuildData.nodes.map((node: any) => ({
id: node.id,
x: miniMap.x(node.tile),
y: miniMap.y(node.tile),
}));
// Convert edges from DebugSpan data (AbstractEdge format)
edges = graphBuildData.edges.map((edge: any) => {
const nodeA = graphBuildData.nodes.find((n: any) => n.id === edge.nodeA);
const nodeB = graphBuildData.nodes.find((n: any) => n.id === edge.nodeB);
return {
fromId: edge.nodeA,
toId: edge.nodeB,
from: nodeA
? [miniMap.x(nodeA.tile) * 2, miniMap.y(nodeA.tile) * 2]
: [0, 0],
to: nodeB
? [miniMap.x(nodeB.tile) * 2, miniMap.y(nodeB.tile) * 2]
: [0, 0],
cost: edge.cost,
};
});
console.log(
`Map ${mapName}: ${allNodes.length} nodes, ${edges.length} edges (from DebugSpan, built in ${graphBuildData.buildTime.toFixed(2)}ms)`,
);
} else if (graph) {
// Fallback: extract directly from graph
allNodes = graph.getAllNodes().map((node: any) => ({
id: node.id,
x: miniMap.x(node.tile),
y: miniMap.y(node.tile),
}));
edges = [];
for (let i = 0; i < graph.edgeCount; i++) {
const edge = graph.getEdge(i);
if (!edge) continue;
const nodeA = graph.getNode(edge.nodeA);
const nodeB = graph.getNode(edge.nodeB);
if (!nodeA || !nodeB) continue;
edges.push({
fromId: edge.nodeA,
toId: edge.nodeB,
from: [miniMap.x(nodeA.tile) * 2, miniMap.y(nodeA.tile) * 2],
to: [miniMap.x(nodeB.tile) * 2, miniMap.y(nodeB.tile) * 2],
cost: edge.cost,
});
}
console.log(
`Map ${mapName}: ${allNodes.length} nodes, ${edges.length} edges (fallback)`,
);
} else {
// No graph available
allNodes = [];
edges = [];
console.log(`Map ${mapName}: no graph available`);
}
return {
name: mapName,
width: game.width(),
height: game.height(),
mapData,
graphDebug: {
allNodes,
edges,
clusterSize,
buildTime: graphBuildData?.buildTime,
},
adapters: COMPARISON_ADAPTERS,
};
}
/**
* Clear map cache
*/
export function clearCache() {
cache.clear();
}