- Replace socket.io relay with Colyseus 0.15 authoritative server - GameRoom with GameState schema (players, units, resources) - Pure TS services: CombatResolver, EconomyService, PathfindingService, UnitManager - POST /api/create-room → 4-char invite code - React/MUI LobbyScreen: Create (shows code + START GAME) / Join by code - ColyseusClient: joinOrCreate/join by room type = invite code - Nginx: static assets direct, all else proxied to Colyseus (WS upgrade) - Content-hashed JS bundles for Cloudflare cache-busting - 1-player lobbies: START GAME button bypasses 2-player wait
301 lines
8.9 KiB
TypeScript
301 lines
8.9 KiB
TypeScript
/**
|
|
* PathfindingService.test.ts — Tests for server-side pathfinding service.
|
|
*
|
|
* Tests: obstacle avoidance, valid/invalid moves, straight-line paths.
|
|
*/
|
|
|
|
// ── Mock easystarjs ────────────────────────────────────────────────
|
|
const mockFindPath = jest.fn();
|
|
|
|
const mockEasyStarInstance = {
|
|
setIterationsPerCalculation: jest.fn(),
|
|
enableDiagonals: jest.fn(),
|
|
enableCornerCutting: jest.fn(),
|
|
setGrid: jest.fn(),
|
|
setAcceptableTiles: jest.fn(),
|
|
setTileCost: jest.fn(),
|
|
setAdditionalPointCost: jest.fn(),
|
|
findPath: mockFindPath,
|
|
calculate: jest.fn(),
|
|
avoidAdditionalPoint: jest.fn(),
|
|
stopAvoidingAdditionalPoint: jest.fn(),
|
|
stopAvoidingAllAdditionalPoints: jest.fn(),
|
|
enableSync: jest.fn(),
|
|
disableSync: jest.fn(),
|
|
disableDiagonals: jest.fn(),
|
|
disableCornerCutting: jest.fn(),
|
|
setDirectionalCondition: jest.fn(),
|
|
removeAllDirectionalConditions: jest.fn(),
|
|
removeAdditionalPointCost: jest.fn(),
|
|
removeAllAdditionalPointCosts: jest.fn(),
|
|
cancelPath: jest.fn(),
|
|
};
|
|
|
|
jest.mock("easystarjs", () => ({
|
|
js: jest.fn(() => mockEasyStarInstance),
|
|
}));
|
|
|
|
import { PathfindingService } from "../src/systems/PathfindingService";
|
|
|
|
// Helper: build a grid with a wall (blocked cells = 1) for obstacle tests
|
|
function gridWithWall(
|
|
width: number,
|
|
height: number,
|
|
wallX: number,
|
|
wallY: number,
|
|
wallLength: number,
|
|
): number[][] {
|
|
const grid: number[][] = [];
|
|
for (let y = 0; y < height; y++) {
|
|
const row: number[] = [];
|
|
for (let x = 0; x < width; x++) {
|
|
// Block a vertical wall at wallX, from wallY to wallY+wallLength-1
|
|
if (x === wallX && y >= wallY && y < wallY + wallLength) {
|
|
row.push(1);
|
|
} else {
|
|
row.push(0);
|
|
}
|
|
}
|
|
grid.push(row);
|
|
}
|
|
return grid;
|
|
}
|
|
|
|
describe("PathfindingService", () => {
|
|
let service: PathfindingService;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
service = new PathfindingService(10, 10);
|
|
});
|
|
|
|
// ── Constructor ─────────────────────────────────────────────────
|
|
describe("constructor", () => {
|
|
test("creates an EasyStar instance with correct config", () => {
|
|
expect(mockEasyStarInstance.setIterationsPerCalculation).toHaveBeenCalledWith(1000);
|
|
expect(mockEasyStarInstance.enableDiagonals).toHaveBeenCalled();
|
|
expect(mockEasyStarInstance.enableCornerCutting).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ── setGrid ─────────────────────────────────────────────────────
|
|
describe("setGrid", () => {
|
|
test("sets the grid and acceptable tiles on EasyStar", () => {
|
|
const grid = [
|
|
[0, 0],
|
|
[0, 1],
|
|
];
|
|
|
|
service.setGrid(grid);
|
|
|
|
expect(mockEasyStarInstance.setGrid).toHaveBeenCalledWith(grid);
|
|
expect(mockEasyStarInstance.setAcceptableTiles).toHaveBeenCalledWith([0]);
|
|
});
|
|
});
|
|
|
|
// ── setWalkable ─────────────────────────────────────────────────
|
|
describe("setWalkable", () => {
|
|
test("marks a tile as blocked (1) and updates EasyStar grid", () => {
|
|
const grid = [
|
|
[0, 0],
|
|
[0, 0],
|
|
];
|
|
service.setGrid(grid);
|
|
|
|
service.setWalkable(0, 0, false);
|
|
|
|
// Should call setGrid with updated grid
|
|
const expectedGrid = [
|
|
[1, 0],
|
|
[0, 0],
|
|
];
|
|
expect(mockEasyStarInstance.setGrid).toHaveBeenCalledWith(expectedGrid);
|
|
});
|
|
|
|
test("marks a tile as walkable (0)", () => {
|
|
const grid = [
|
|
[1, 0],
|
|
[0, 0],
|
|
];
|
|
service.setGrid(grid);
|
|
|
|
service.setWalkable(0, 0, true);
|
|
|
|
const expectedGrid = [
|
|
[0, 0],
|
|
[0, 0],
|
|
];
|
|
expect(mockEasyStarInstance.setGrid).toHaveBeenCalledWith(expectedGrid);
|
|
});
|
|
|
|
test("no-ops on out-of-bounds coordinates", () => {
|
|
const grid = [
|
|
[0, 0],
|
|
[0, 0],
|
|
];
|
|
service.setGrid(grid);
|
|
|
|
// Reset mock call count after setGrid
|
|
mockEasyStarInstance.setGrid.mockClear();
|
|
|
|
service.setWalkable(-1, 0, false);
|
|
service.setWalkable(0, 99, false);
|
|
service.setWalkable(5, 0, false);
|
|
|
|
expect(mockEasyStarInstance.setGrid).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("no-ops when value unchanged", () => {
|
|
const grid = [
|
|
[0, 0],
|
|
[0, 0],
|
|
];
|
|
service.setGrid(grid);
|
|
mockEasyStarInstance.setGrid.mockClear();
|
|
|
|
service.setWalkable(0, 0, true); // already 0
|
|
|
|
expect(mockEasyStarInstance.setGrid).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ── findPath ────────────────────────────────────────────────────
|
|
describe("findPath", () => {
|
|
test("resolves with path when EasyStar finds one", async () => {
|
|
const grid = gridWithWall(5, 5, 2, 0, 5); // full-height wall at x=2
|
|
service.setGrid(grid);
|
|
|
|
const expectedPath = [
|
|
{ x: 0, y: 0 },
|
|
{ x: 1, y: 0 },
|
|
{ x: 1, y: 1 },
|
|
{ x: 1, y: 2 },
|
|
{ x: 2, y: 2 }, // actually blocked in our grid — but EasyStar mock returns whatever we pass
|
|
];
|
|
|
|
// Make findPath invoke the callback with the path
|
|
mockFindPath.mockImplementationOnce(
|
|
(sx: number, sy: number, ex: number, ey: number, cb: Function) => {
|
|
cb(expectedPath);
|
|
},
|
|
);
|
|
|
|
const result = await service.findPath({ x: 0, y: 0 }, { x: 4, y: 0 });
|
|
|
|
expect(result).toEqual(expectedPath);
|
|
expect(mockEasyStarInstance.findPath).toHaveBeenCalledWith(
|
|
0, 0, 4, 0, expect.any(Function),
|
|
);
|
|
expect(mockEasyStarInstance.calculate).toHaveBeenCalled();
|
|
});
|
|
|
|
test("resolves with null when no path exists", async () => {
|
|
const grid = gridWithWall(5, 5, 2, 0, 5); // full-height wall
|
|
service.setGrid(grid);
|
|
|
|
mockFindPath.mockImplementationOnce(
|
|
(sx: number, sy: number, ex: number, ey: number, cb: Function) => {
|
|
cb(null);
|
|
},
|
|
);
|
|
|
|
const result = await service.findPath({ x: 0, y: 0 }, { x: 4, y: 0 });
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── isValidMove ─────────────────────────────────────────────────
|
|
describe("isValidMove", () => {
|
|
test("returns true when a path exists between adjacent tiles", async () => {
|
|
const grid = [
|
|
[0, 0, 0],
|
|
[0, 0, 0],
|
|
[0, 0, 0],
|
|
];
|
|
service.setGrid(grid);
|
|
|
|
const validPath = [
|
|
{ x: 0, y: 0 },
|
|
{ x: 1, y: 0 },
|
|
];
|
|
|
|
mockFindPath.mockImplementationOnce(
|
|
(sx: number, sy: number, ex: number, ey: number, cb: Function) => {
|
|
cb(validPath);
|
|
},
|
|
);
|
|
|
|
const result = await service.isValidMove({ x: 0, y: 0 }, { x: 1, y: 0 });
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
test("returns false when no path exists", async () => {
|
|
// Two tiles separated by a wall
|
|
const grid = [
|
|
[0, 1, 0],
|
|
[0, 1, 0],
|
|
[0, 0, 0],
|
|
];
|
|
service.setGrid(grid);
|
|
|
|
mockFindPath.mockImplementationOnce(
|
|
(sx: number, sy: number, ex: number, ey: number, cb: Function) => {
|
|
cb(null);
|
|
},
|
|
);
|
|
|
|
const result = await service.isValidMove({ x: 0, y: 0 }, { x: 2, y: 0 });
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
test("returns false when to tile is blocked", async () => {
|
|
const grid = [
|
|
[0, 0],
|
|
[0, 1], // (1,1) is blocked
|
|
];
|
|
service.setGrid(grid);
|
|
|
|
mockFindPath.mockImplementationOnce(
|
|
(sx: number, sy: number, ex: number, ey: number, cb: Function) => {
|
|
cb(null);
|
|
},
|
|
);
|
|
|
|
const result = await service.isValidMove({ x: 0, y: 0 }, { x: 1, y: 1 });
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── Straight-line path ──────────────────────────────────────────
|
|
describe("straight-line path", () => {
|
|
test("returns direct horizontal path on clear grid", async () => {
|
|
const grid = [
|
|
[0, 0, 0, 0, 0],
|
|
[0, 0, 0, 0, 0],
|
|
];
|
|
service.setGrid(grid);
|
|
|
|
const straightPath = [
|
|
{ x: 0, y: 0 },
|
|
{ x: 1, y: 0 },
|
|
{ x: 2, y: 0 },
|
|
{ x: 3, y: 0 },
|
|
];
|
|
|
|
mockFindPath.mockImplementationOnce(
|
|
(sx: number, sy: number, ex: number, ey: number, cb: Function) => {
|
|
cb(straightPath);
|
|
},
|
|
);
|
|
|
|
const result = await service.findPath({ x: 0, y: 0 }, { x: 3, y: 0 });
|
|
|
|
expect(result).toEqual(straightPath);
|
|
expect(result?.length).toBe(4);
|
|
// All tiles should be on the same row
|
|
expect(result?.every((t: { x: number; y: number }) => t.y === 0)).toBe(true);
|
|
});
|
|
});
|
|
});
|