Files
OpenFrontIO/tests/core/pathfinding/PathFinding.Water.test.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

278 lines
7.5 KiB
TypeScript

import { beforeAll, describe, expect, it, vi } from "vitest";
import { Game } from "engine/game/Game";
import { TileRef } from "engine/game/GameMap";
import { PathFinding } from "engine/pathfinding/PathFinder";
import {
PathStatus,
SteppingPathFinder,
} from "engine/pathfinding/types";
import { setup } from "../../util/Setup";
import { createGame, L, W } from "./_fixtures";
describe("PathFinding.Water", () => {
let game: Game;
let worldGame: Game;
function createPathFinder(g: Game = game): SteppingPathFinder<TileRef> {
return PathFinding.Water(g);
}
beforeAll(async () => {
game = await setup("ocean_and_land");
worldGame = await setup("world", { disableNavMesh: false });
});
describe("findPath", () => {
it("finds path between adjacent water tiles", () => {
const pathFinder = createPathFinder();
const map = game.map();
const from = map.ref(8, 0);
const to = map.ref(9, 0);
expect(map.isWater(from)).toBe(true);
expect(map.isWater(to)).toBe(true);
const path = pathFinder.findPath(from, to);
expect(path).not.toBeNull();
expect(path!.length).toBe(2);
expect(path![0]).toBe(from);
expect(path![1]).toBe(to);
});
it("returns null for land tiles", () => {
const pathFinder = createPathFinder();
const map = game.map();
const landTile = map.ref(0, 0);
const waterTile = map.ref(8, 0);
expect(map.isLand(landTile)).toBe(true);
expect(map.isShore(landTile)).toBe(false);
expect(map.isWater(waterTile)).toBe(true);
const path = pathFinder.findPath(landTile, waterTile);
expect(path).toBeNull();
});
it("returns single-tile path when from equals to", () => {
const pathFinder = createPathFinder();
const map = game.map();
const waterTile = map.ref(8, 0);
expect(map.isWater(waterTile)).toBe(true);
const path = pathFinder.findPath(waterTile, waterTile);
expect(path).not.toBeNull();
expect(path!.length).toBe(1);
expect(path![0]).toBe(waterTile);
});
it("supports multiple start tiles", () => {
const pathFinder = createPathFinder();
const map = game.map();
const dest = map.ref(8, 0);
const source1 = map.ref(9, 0);
const source2 = map.ref(8, 1);
expect(map.isWater(dest)).toBe(true);
expect(map.isWater(source1)).toBe(true);
expect(map.isWater(source2)).toBe(true);
const from = [source1, source2];
const path = pathFinder.findPath(from, dest);
expect(path).not.toBeNull();
expect(path!.length).toBe(2);
expect(from).toContain(path![0]);
expect(path![1]).toBe(dest);
});
});
describe("path validity", () => {
it("all consecutive tiles in path are connected", () => {
const pathFinder = createPathFinder();
const map = game.map();
// Distant water tiles: (8,0) → (15,4), distance = 11
const from = map.ref(8, 0);
const to = map.ref(15, 4);
expect(map.isWater(from)).toBe(true);
expect(map.isWater(to)).toBe(true);
expect(map.manhattanDist(from, to)).toBe(11);
const path = pathFinder.findPath(from, to);
expect(path).not.toBeNull();
for (let i = 1; i < path!.length; i++) {
const dist = map.manhattanDist(path![i - 1], path![i]);
expect(dist).toEqual(1);
}
});
});
describe("shore handling", () => {
it("path from shore to shore starts and ends on shore", () => {
const pathFinder = createPathFinder();
const map = game.map();
// Shore tiles at (7,0) and (7,6), distance = 6
// Both have water neighbors at (8,0) and (8,6)
const from = map.ref(7, 0);
const to = map.ref(7, 6);
expect(map.isShore(from)).toBe(true);
expect(map.isShore(to)).toBe(true);
expect(map.manhattanDist(from, to)).toBe(6);
const path = pathFinder.findPath(from, to);
expect(path).not.toBeNull();
expect(path![0]).toBe(from);
expect(path![path!.length - 1]).toBe(to);
});
});
describe("determinism", () => {
it("same inputs produce identical paths", () => {
const pathFinder1 = createPathFinder();
const pathFinder2 = createPathFinder();
const map = game.map();
// Distant water tiles: (8,0) → (15,4)
const from = map.ref(8, 0);
const to = map.ref(15, 4);
const path1 = pathFinder1.findPath(from, to);
const path2 = pathFinder2.findPath(from, to);
expect(path1).not.toBeNull();
expect(path2).not.toBeNull();
expect(path1).toEqual(path2);
});
});
describe("World map routes", () => {
it("Spain to France (Mediterranean)", () => {
const pathFinder = createPathFinder(worldGame);
const path = pathFinder.findPath(
worldGame.ref(926, 283),
worldGame.ref(950, 257),
);
expect(path).not.toBeNull();
});
it("Miami to Rio (Atlantic)", () => {
const pathFinder = createPathFinder(worldGame);
const path = pathFinder.findPath(
worldGame.ref(488, 355),
worldGame.ref(680, 658),
);
expect(path).not.toBeNull();
});
it("France to Poland (around Europe)", () => {
const pathFinder = createPathFinder(worldGame);
const path = pathFinder.findPath(
worldGame.ref(950, 257),
worldGame.ref(1033, 175),
);
expect(path).not.toBeNull();
});
it("Miami to Spain (transatlantic)", () => {
const pathFinder = createPathFinder(worldGame);
const path = pathFinder.findPath(
worldGame.ref(488, 355),
worldGame.ref(926, 283),
);
expect(path).not.toBeNull();
});
it("Rio to Poland (South Atlantic to Baltic)", () => {
const pathFinder = createPathFinder(worldGame);
const path = pathFinder.findPath(
worldGame.ref(680, 658),
worldGame.ref(1033, 175),
);
expect(path).not.toBeNull();
});
});
describe("Error handling", () => {
it("returns NOT_FOUND for null source", () => {
const pathFinder = createPathFinder();
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const result = pathFinder.next(
null as unknown as TileRef,
game.ref(8, 0),
);
expect(result.status).toBe(PathStatus.NOT_FOUND);
consoleSpy.mockRestore();
});
it("returns NOT_FOUND for null destination", () => {
const pathFinder = createPathFinder();
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const result = pathFinder.next(
game.ref(8, 0),
null as unknown as TileRef,
);
expect(result.status).toBe(PathStatus.NOT_FOUND);
consoleSpy.mockRestore();
});
});
describe("Known bugs", () => {
it("path can cross 1-tile land barrier", () => {
const syntheticGame = createGame({
width: 10,
height: 1,
grid: [W, L, L, W, L, W, W, L, L, W],
});
const pathFinder = createPathFinder(syntheticGame);
const path = pathFinder.findPath(
syntheticGame.ref(0, 0),
syntheticGame.ref(9, 0),
);
expect(path).not.toBeNull();
});
it("path can cross diagonal land barrier", () => {
const syntheticGame = createGame({
width: 2,
height: 2,
grid: [W, L, L, W],
});
const pathFinder = createPathFinder(syntheticGame);
const path = pathFinder.findPath(
syntheticGame.ref(0, 0),
syntheticGame.ref(1, 1),
);
expect(path).not.toBeNull();
});
});
});