diff --git a/gsd-framework/src/__tests__/PathFinder.test.ts b/gsd-framework/src/__tests__/PathFinder.test.ts index 0c920cd..7a5c2f6 100644 --- a/gsd-framework/src/__tests__/PathFinder.test.ts +++ b/gsd-framework/src/__tests__/PathFinder.test.ts @@ -1,6 +1,27 @@ // src/__tests__/PathFinder.test.ts - Tests for PathFinder class -import { describe, it, expect } from 'vitest'; -import { PathNode, MatchResult } from '../types'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { PathNode, MatchResult, Tile, TilePosition } from '../types'; +import { PathFinder } from '../matching/PathFinder'; +import { CONFIG } from '../config'; + +// Helper function to create a test grid +function createTestGrid(clearedPositions: TilePosition[][]): Tile[][] { + const grid: Tile[][] = []; + for (let row = 0; row < CONFIG.grid.rows; row++) { + grid[row] = []; + for (let col = 0; col < CONFIG.grid.cols; col++) { + // Check if this position should be cleared + const isCleared = clearedPositions.some(pos => pos[0].row === row && pos[0].col === col); + grid[row][col] = { + id: `tile-${row}-${col}`, + type: 0, + position: { row, col }, + cleared: isCleared + }; + } + } + return grid; +} describe('PathFinder Types', () => { describe('PathNode interface', () => { @@ -106,3 +127,202 @@ describe('PathFinder Types', () => { }); }); }); + +describe('PathFinder', () => { + describe('Test 1: Direct horizontal path (0 turns)', () => { + it('should find path when tiles are on same row with cleared tiles between', () => { + // Create grid with cleared path from (0,0) to (0,4) + const clearedPositions = [ + [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }, { row: 0, col: 3 }, { row: 0, col: 4 }] + ]; + const grid = createTestGrid(clearedPositions); + + const start = { row: 0, col: 0 }; + const end = { row: 0, col: 4 }; + + const result = PathFinder.findPath(start, end, grid); + + expect(result).not.toBeNull(); + expect(result!.turns).toBe(0); + expect(result!.path).toHaveLength(5); + expect(result!.path[0]).toEqual(start); + expect(result!.path[4]).toEqual(end); + }); + }); + + describe('Test 2: Direct vertical path (0 turns)', () => { + it('should find path when tiles are on same column with cleared tiles between', () => { + const clearedPositions = [ + [{ row: 0, col: 0 }, { row: 1, col: 0 }, { row: 2, col: 0 }, { row: 3, col: 0 }, { row: 4, col: 0 }] + ]; + const grid = createTestGrid(clearedPositions); + + const start = { row: 0, col: 0 }; + const end = { row: 4, col: 0 }; + + const result = PathFinder.findPath(start, end, grid); + + expect(result).not.toBeNull(); + expect(result!.turns).toBe(0); + expect(result!.path).toHaveLength(5); + expect(result!.path[0]).toEqual(start); + expect(result!.path[4]).toEqual(end); + }); + }); + + describe('Test 3: L-shaped path (1 turn)', () => { + it('should find path with one turn around a corner', () => { + // Create L-shaped path: (0,0) -> (0,2) -> (2,2) + const clearedPositions = [ + [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }], + [{ row: 1, col: 2 }], + [{ row: 2, col: 2 }] + ]; + const grid = createTestGrid(clearedPositions); + + const start = { row: 0, col: 0 }; + const end = { row: 2, col: 2 }; + + const result = PathFinder.findPath(start, end, grid); + + expect(result).not.toBeNull(); + expect(result!.turns).toBe(1); + expect(result!.path[0]).toEqual(start); + expect(result!.path[result!.path.length - 1]).toEqual(end); + }); + }); + + describe('Test 4: Z-shaped path (2 turns)', () => { + it('should find path with two turns (Z-shaped)', () => { + // Create Z-shaped path: (0,0) -> (0,2) -> (2,2) -> (2,4) + const clearedPositions = [ + [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }], + [{ row: 1, col: 2 }], + [{ row: 2, col: 2 }, { row: 2, col: 3 }, { row: 2, col: 4 }] + ]; + const grid = createTestGrid(clearedPositions); + + const start = { row: 0, col: 0 }; + const end = { row: 2, col: 4 }; + + const result = PathFinder.findPath(start, end, grid); + + expect(result).not.toBeNull(); + expect(result!.turns).toBe(2); + expect(result!.path[0]).toEqual(start); + expect(result!.path[result!.path.length - 1]).toEqual(end); + }); + }); + + describe('Test 5: Path with 3 turns is rejected', () => { + it('should return null when path requires 3 or more turns', () => { + // Create a path that requires 3 turns + const clearedPositions = [ + [{ row: 0, col: 0 }, { row: 0, col: 1 }], + [{ row: 1, col: 1 }, { row: 2, col: 1 }], + [{ row: 2, col: 2 }], + [{ row: 3, col: 2 }, { row: 4, col: 2 }] + ]; + const grid = createTestGrid(clearedPositions); + + const start = { row: 0, col: 0 }; + const end = { row: 4, col: 2 }; + + const result = PathFinder.findPath(start, end, grid, 2); + + expect(result).toBeNull(); + }); + }); + + describe('Test 6: Path through uncleared tile is rejected', () => { + it('should return null when direct path is blocked by uncleared tile', () => { + // Clear only start and end, but not the middle + const clearedPositions = [ + [{ row: 0, col: 0 }], + [{ row: 0, col: 2 }] + ]; + const grid = createTestGrid(clearedPositions); + + const start = { row: 0, col: 0 }; + const end = { row: 0, col: 2 }; + + const result = PathFinder.findPath(start, end, grid); + + expect(result).toBeNull(); + }); + }); + + describe('Test 7: No path exists returns null', () => { + it('should return null when no valid path exists', () => { + // Clear only isolated tiles with no connection + const clearedPositions = [ + [{ row: 0, col: 0 }], + [{ row: 5, col: 5 }] + ]; + const grid = createTestGrid(clearedPositions); + + const start = { row: 0, col: 0 }; + const end = { row: 5, col: 5 }; + + const result = PathFinder.findPath(start, end, grid); + + expect(result).toBeNull(); + }); + }); + + describe('Test 8: Returns path including start and end positions', () => { + it('should include both start and end positions in the path', () => { + const clearedPositions = [ + [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }] + ]; + const grid = createTestGrid(clearedPositions); + + const start = { row: 0, col: 0 }; + const end = { row: 0, col: 2 }; + + const result = PathFinder.findPath(start, end, grid); + + expect(result).not.toBeNull(); + expect(result!.path[0]).toEqual(start); + expect(result!.path[result!.path.length - 1]).toEqual(end); + }); + }); + + describe('Test 9: Correctly counts turns (direction changes only)', () => { + it('should count only direction changes as turns', () => { + // Path: right 3 steps, down 1 step, right 2 steps = 1 turn + const clearedPositions = [ + [{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }, { row: 0, col: 3 }], + [{ row: 1, col: 3 }], + [{ row: 1, col: 4 }, { row: 1, col: 5 }] + ]; + const grid = createTestGrid(clearedPositions); + + const start = { row: 0, col: 0 }; + const end = { row: 1, col: 5 }; + + const result = PathFinder.findPath(start, end, grid); + + expect(result).not.toBeNull(); + expect(result!.turns).toBe(1); // Only one direction change (right -> down) + }); + }); + + describe('Test 10: Start position with direction=-1 has 0 turns', () => { + it('should not count the first move as a turn', () => { + // Moving in any direction from start should be 0 turns initially + const clearedPositions = [ + [{ row: 0, col: 0 }, { row: 0, col: 1 }] + ]; + const grid = createTestGrid(clearedPositions); + + const start = { row: 0, col: 0 }; + const end = { row: 0, col: 1 }; + + const result = PathFinder.findPath(start, end, grid); + + expect(result).not.toBeNull(); + expect(result!.turns).toBe(0); // First move doesn't count as turn + }); + }); +}); diff --git a/gsd-framework/src/matching/PathFinder.ts b/gsd-framework/src/matching/PathFinder.ts new file mode 100644 index 0000000..19c9747 --- /dev/null +++ b/gsd-framework/src/matching/PathFinder.ts @@ -0,0 +1,115 @@ +// src/matching/PathFinder.ts - BFS pathfinding algorithm with turn counting +import { TilePosition, Tile, PathNode } from '../types'; +import { CONFIG } from '../config'; + +/** + * PathFinder implements BFS pathfinding with turn constraints + * Finds paths between two tiles with maximum 2 turns (3 straight lines) + */ +export class PathFinder { + /** + * Direction encoding: 0=up, 1=right, 2=down, 3=left + * Using these deltas: row changes by -1/+1, col changes by -1/+1 + */ + private static readonly DIRECTIONS = [ + { row: -1, col: 0 }, // 0: up + { row: 0, col: 1 }, // 1: right + { row: 1, col: 0 }, // 2: down + { row: 0, col: -1 } // 3: left + ]; + + /** + * Finds a valid path between two tiles with maximum turns constraint + * @param start - Starting tile position + * @param end - Ending tile position + * @param grid - 2D array of tiles + * @param maxTurns - Maximum allowed turns (default: 2) + * @returns PathNode if valid path found, null otherwise + */ + static findPath( + start: TilePosition, + end: TilePosition, + grid: Tile[][], + maxTurns: number = 2 + ): PathNode | null { + // Initialize BFS queue with start node + // Start with direction=-1 (no direction yet), 0 turns, path containing start position + const queue: PathNode[] = [{ + row: start.row, + col: start.col, + direction: -1, // No direction yet (first move doesn't count as turn) + turns: 0, + path: [{ row: start.row, col: start.col }] + }]; + + // Track visited states to avoid cycles + // State key: "row,col,direction" - same position with different direction is different state + const visited = new Set(); + + while (queue.length > 0) { + const currentNode = queue.shift()!; + const { row, col, direction, turns, path } = currentNode; + + // Check if we reached the destination + if (row === end.row && col === end.col) { + return currentNode; + } + + // Skip if already exceeded max turns + if (turns > maxTurns) { + continue; + } + + // Try all 4 directions + for (let newDirection = 0; newDirection < 4; newDirection++) { + const newRow = row + PathFinder.DIRECTIONS[newDirection].row; + const newCol = col + PathFinder.DIRECTIONS[newDirection].col; + + // Check bounds + if (newRow < 0 || newRow >= CONFIG.grid.rows || + newCol < 0 || newCol >= CONFIG.grid.cols) { + continue; + } + + // Check if passable (tile must be cleared) + const tile = grid[newRow][newCol]; + if (!tile.cleared) { + continue; + } + + // Calculate turn increment + // First move (direction=-1) doesn't count as turn + // Changing direction counts as turn, continuing straight doesn't + let turnIncrement = 0; + if (direction !== -1 && newDirection !== direction) { + turnIncrement = 1; + } + + const newTurns = turns + turnIncrement; + + // Create state key for visited tracking + const stateKey = `${newRow},${newCol},${newDirection}`; + + // Skip if this state already visited + if (visited.has(stateKey)) { + continue; + } + + // Mark as visited + visited.add(stateKey); + + // Add to queue with extended path + queue.push({ + row: newRow, + col: newCol, + direction: newDirection, + turns: newTurns, + path: [...path, { row: newRow, col: newCol }] + }); + } + } + + // No valid path found + return null; + } +}