Files
OpenFrontIO/tests/pathfinding/playground/server.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

261 lines
6.8 KiB
TypeScript

import compression from "compression";
import express, { Request, Response } from "express";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import {
clearCache as clearMapCache,
getMapMetadata,
listMaps,
} from "./api/maps.js";
import { clearAdapterCaches, computePath } from "./api/pathfinding.js";
import { computeSpatialQuery } from "./api/spatialQuery.js";
const app = express();
const PORT = process.env.PORT ?? 5555;
// Middleware
app.use(compression()); // gzip compression for large responses
app.use(express.json({ limit: "50mb" })); // JSON body parser with larger limit
// Serve static files from public directory
const publicDir = join(dirname(fileURLToPath(import.meta.url)), "public");
app.use(express.static(publicDir));
// API Routes
/**
* GET /api/maps
* List all available maps
*/
app.get("/api/maps", (req: Request, res: Response) => {
try {
const maps = listMaps();
res.json({ maps });
} catch (error) {
console.error("Error listing maps:", error);
res.status(500).json({
error: "Failed to list maps",
message: error instanceof Error ? error.message : String(error),
});
}
});
/**
* GET /api/maps/:name
* Get map metadata (map data, dimensions)
*/
app.get(
"/api/maps/:name",
async (req: Request<{ name: string }>, res: Response) => {
try {
const { name } = req.params;
const metadata = await getMapMetadata(name);
res.json(metadata);
} catch (error) {
console.error(`Error loading map ${req.params.name}:`, error);
if (error instanceof Error && error.message.includes("ENOENT")) {
res.status(404).json({
error: "Map not found",
message: `Map "${req.params.name}" does not exist`,
});
} else {
res.status(500).json({
error: "Failed to load map",
message: error instanceof Error ? error.message : String(error),
});
}
}
},
);
/**
* GET /api/maps/:name/thumbnail
* Get map thumbnail image
*/
app.get(
"/api/maps/:name/thumbnail",
(req: Request<{ name: string }>, res: Response) => {
try {
const { name } = req.params;
const thumbnailPath = join(
dirname(fileURLToPath(import.meta.url)),
"resources/maps",
name,
"thumbnail.webp",
);
res.sendFile(thumbnailPath);
} catch (error) {
console.error(`Error loading thumbnail for ${req.params.name}:`, error);
res.status(404).json({
error: "Thumbnail not found",
message: error instanceof Error ? error.message : String(error),
});
}
},
);
/**
* POST /api/pathfind
* Compute pathfinding between two points
*
* Request body:
* {
* map: string,
* from: [x, y],
* to: [x, y],
* adapters?: string[] // Optional: which comparison adapters to run
* }
*
* Response:
* {
* primary: { path, length, time, debug: { nodePath, initialPath, timings } },
* comparisons: [{ adapter, path, length, time }, ...]
* }
*/
app.post("/api/pathfind", async (req: Request, res: Response) => {
try {
const { map, from, to, adapters } = req.body;
// Validate request
if (!map || !from || !to) {
return res.status(400).json({
error: "Invalid request",
message: "Missing required fields: map, from, to",
});
}
if (
!Array.isArray(from) ||
from.length !== 2 ||
!Array.isArray(to) ||
to.length !== 2
) {
return res.status(400).json({
error: "Invalid coordinates",
message: "from and to must be [x, y] coordinate arrays",
});
}
// Compute paths
const result = await computePath(
map,
from as [number, number],
to as [number, number],
{ adapters },
);
res.json(result);
} catch (error) {
console.error("Error computing path:", error);
if (error instanceof Error && error.message.includes("is not water")) {
res.status(400).json({
error: "Invalid coordinates",
message: error.message,
});
} else {
res.status(500).json({
error: "Failed to compute path",
message: error instanceof Error ? error.message : String(error),
});
}
}
});
/**
* POST /api/spatial-query
* Compute spatial query for transport ship (closestShoreByWater)
*
* Request body:
* {
* map: string,
* ownedTiles: number[], // Array of tile indices (y * width + x)
* target: [x, y]
* }
*/
app.post("/api/spatial-query", async (req: Request, res: Response) => {
try {
const { map, ownedTiles, target } = req.body;
if (!map || !ownedTiles || !target) {
return res.status(400).json({
error: "Invalid request",
message: "Missing required fields: map, ownedTiles, target",
});
}
if (!Array.isArray(ownedTiles)) {
return res.status(400).json({
error: "Invalid ownedTiles",
message: "ownedTiles must be an array of tile indices",
});
}
if (!Array.isArray(target) || target.length !== 2) {
return res.status(400).json({
error: "Invalid target",
message: "target must be [x, y] coordinate array",
});
}
const result = await computeSpatialQuery(
map,
ownedTiles,
target as [number, number],
);
res.json(result);
} catch (error) {
console.error("Error computing spatial query:", error);
res.status(500).json({
error: "Failed to compute spatial query",
message: error instanceof Error ? error.message : String(error),
});
}
});
/**
* POST /api/cache/clear
* Clear all caches (useful for development)
*/
app.post("/api/cache/clear", (req: Request, res: Response) => {
try {
clearMapCache();
clearAdapterCaches();
res.json({ message: "Caches cleared successfully" });
} catch (error) {
console.error("Error clearing caches:", error);
res.status(500).json({
error: "Failed to clear caches",
message: error instanceof Error ? error.message : String(error),
});
}
});
// Error handling middleware
app.use((err: Error, req: Request, res: Response, next: any) => {
console.error("Unhandled error:", err);
res.status(500).json({
error: "Internal server error",
message: err.message,
});
});
// Start server
app.listen(PORT, (error?: Error) => {
if (error) {
console.error("Failed to start server", error);
process.exit(1);
}
console.log(`
╔════════════════════════════════════════════════════════════╗
║ Pathfinding Playground Server ║
╚════════════════════════════════════════════════════════════╝
Server running at: http://localhost:${PORT}
Press Ctrl+C to stop
`);
});