From 30ea2f42c2500ad28fb94cca5fee5e355f404047 Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 6 Mar 2026 18:13:31 -0500 Subject: [PATCH] feat: add GameMap and A* pathfinding with tests Co-Authored-By: Claude Opus 4.6 --- server/src/map/GameMap.ts | 56 ++++++++++++++ server/src/map/__tests__/pathfinding.test.ts | 44 +++++++++++ server/src/map/pathfinding.ts | 81 ++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 server/src/map/GameMap.ts create mode 100644 server/src/map/__tests__/pathfinding.test.ts create mode 100644 server/src/map/pathfinding.ts diff --git a/server/src/map/GameMap.ts b/server/src/map/GameMap.ts new file mode 100644 index 0000000..8c44a55 --- /dev/null +++ b/server/src/map/GameMap.ts @@ -0,0 +1,56 @@ +import { WORLD_WIDTH, WORLD_HEIGHT, type Position } from '@dflike/shared'; + +export interface PointOfInterest { + type: 'food' | 'rest'; + position: Position; +} + +export class GameMap { + readonly width: number; + readonly height: number; + private obstacles: Set = new Set(); + private pois: PointOfInterest[] = []; + + constructor(width = WORLD_WIDTH, height = WORLD_HEIGHT) { + this.width = width; + this.height = height; + } + + private key(x: number, y: number): string { + return `${x},${y}`; + } + + setObstacle(x: number, y: number): void { + this.obstacles.add(this.key(x, y)); + } + + isWalkable(x: number, y: number): boolean { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return false; + return !this.obstacles.has(this.key(x, y)); + } + + addPointOfInterest(poi: PointOfInterest): void { + this.pois.push(poi); + } + + getPointsOfInterest(type?: 'food' | 'rest'): PointOfInterest[] { + if (type) return this.pois.filter(p => p.type === type); + return this.pois; + } + + getObstacles(): Position[] { + return [...this.obstacles].map(k => { + const [x, y] = k.split(',').map(Number); + return { x, y }; + }); + } + + getRandomWalkable(): Position { + let x: number, y: number; + do { + x = Math.floor(Math.random() * this.width); + y = Math.floor(Math.random() * this.height); + } while (!this.isWalkable(x, y)); + return { x, y }; + } +} diff --git a/server/src/map/__tests__/pathfinding.test.ts b/server/src/map/__tests__/pathfinding.test.ts new file mode 100644 index 0000000..f9dd7dd --- /dev/null +++ b/server/src/map/__tests__/pathfinding.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { findPath } from '../pathfinding.js'; +import { GameMap } from '../GameMap.js'; + +describe('findPath (A*)', () => { + it('finds a straight path with no obstacles', () => { + const map = new GameMap(10, 10); + const path = findPath(map, { x: 0, y: 0 }, { x: 3, y: 0 }); + expect(path).not.toBeNull(); + expect(path!.length).toBeGreaterThan(0); + expect(path![path!.length - 1]).toEqual({ x: 3, y: 0 }); + }); + + it('finds a path around an obstacle', () => { + const map = new GameMap(10, 10); + map.setObstacle(1, 0); + map.setObstacle(1, 1); + const path = findPath(map, { x: 0, y: 0 }, { x: 2, y: 0 }); + expect(path).not.toBeNull(); + expect(path!.some(p => p.x === 1 && p.y === 0)).toBe(false); + expect(path![path!.length - 1]).toEqual({ x: 2, y: 0 }); + }); + + it('returns null if no path exists', () => { + const map = new GameMap(5, 5); + // Wall off the target + for (let y = 0; y < 5; y++) map.setObstacle(2, y); + const path = findPath(map, { x: 0, y: 0 }, { x: 4, y: 0 }); + expect(path).toBeNull(); + }); + + it('returns empty path if start equals goal', () => { + const map = new GameMap(10, 10); + const path = findPath(map, { x: 3, y: 3 }, { x: 3, y: 3 }); + expect(path).toEqual([]); + }); + + it('does not include the start position in the path', () => { + const map = new GameMap(10, 10); + const path = findPath(map, { x: 0, y: 0 }, { x: 2, y: 0 }); + expect(path).not.toBeNull(); + expect(path![0]).not.toEqual({ x: 0, y: 0 }); + }); +}); diff --git a/server/src/map/pathfinding.ts b/server/src/map/pathfinding.ts new file mode 100644 index 0000000..57c8be8 --- /dev/null +++ b/server/src/map/pathfinding.ts @@ -0,0 +1,81 @@ +import type { Position } from '@dflike/shared'; +import type { GameMap } from './GameMap.js'; + +interface Node { + x: number; + y: number; + g: number; + h: number; + f: number; + parent: Node | null; +} + +function heuristic(a: Position, b: Position): number { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); +} + +const NEIGHBORS = [ + { dx: 0, dy: -1 }, + { dx: 0, dy: 1 }, + { dx: -1, dy: 0 }, + { dx: 1, dy: 0 }, +]; + +export function findPath(map: GameMap, start: Position, goal: Position): Position[] | null { + if (start.x === goal.x && start.y === goal.y) return []; + + const open: Node[] = []; + const closed = new Set(); + const key = (x: number, y: number) => `${x},${y}`; + + const startNode: Node = { + x: start.x, y: start.y, + g: 0, h: heuristic(start, goal), f: heuristic(start, goal), + parent: null, + }; + open.push(startNode); + + while (open.length > 0) { + // Find lowest f + let bestIdx = 0; + for (let i = 1; i < open.length; i++) { + if (open[i].f < open[bestIdx].f) bestIdx = i; + } + const current = open.splice(bestIdx, 1)[0]; + + if (current.x === goal.x && current.y === goal.y) { + // Reconstruct path (excluding start) + const path: Position[] = []; + let node: Node | null = current; + while (node && !(node.x === start.x && node.y === start.y)) { + path.push({ x: node.x, y: node.y }); + node = node.parent; + } + return path.reverse(); + } + + closed.add(key(current.x, current.y)); + + for (const { dx, dy } of NEIGHBORS) { + const nx = current.x + dx; + const ny = current.y + dy; + if (!map.isWalkable(nx, ny) || closed.has(key(nx, ny))) continue; + + const g = current.g + 1; + const h = heuristic({ x: nx, y: ny }, goal); + const existing = open.find(n => n.x === nx && n.y === ny); + + if (existing) { + if (g < existing.g) { + existing.g = g; + existing.f = g + h; + existing.parent = current; + } + } else { + open.push({ x: nx, y: ny, g, h, f: g + h, parent: current }); + } + } + } + + return null; // No path found +}