feat(03-01): implement BFS pathfinding with turn counting

- Created PathFinder class with static findPath method
- Implements BFS algorithm with turn tracking (max 2 turns default)
- Direction encoding: -1=none/start, 0=up, 1=right, 2=down, 3=left
- Visited state tracking using 'row,col,direction' keys to avoid cycles
- Turn counting: only direction changes count, first move doesn't count
- Boundary checking using CONFIG.grid.rows and CONFIG.grid.cols
- Passable tile check: only cleared tiles are traversable
- Returns PathNode with path array and turn count, or null if no path
- Added 10 comprehensive test cases covering:
  * 0-turn paths (direct horizontal/vertical)
  * 1-turn L-shaped paths
  * 2-turn Z-shaped paths
  * 3+ turn rejection
  * Blocked path detection
  * Path inclusion of start/end positions
  * Correct turn counting behavior
This commit is contained in:
2026-03-11 04:36:00 +00:00
parent d683099ab7
commit 80cd74aec4
2 changed files with 337 additions and 2 deletions
+222 -2
View File
@@ -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
});
});
});
+115
View File
@@ -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<string>();
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;
}
}