feat(03-02): implement MatchEngine validation pipeline

- Implemented MatchEngine class with validateMatch method
- Multi-stage validation: type check → position check → pathfinding
- Type check happens before expensive pathfinding (fail-fast optimization)
- Returns MatchResult with valid flag, reason, path, turns, and score
- All 8 test cases covering all validation branches
- Score calculated using Scoring.calculate

Test coverage:
- Different tile types → valid=false, reason='different-type'
- Same tile ID → valid=false, reason='same-tile'
- Valid 0-turn path → valid=true, turns=0, score=150
- Valid 1-turn path → valid=true, turns=1, score=125
- Valid 2-turn path → valid=true, turns=2, score=100
- 3+ turn path → valid=false, reason='too-many-turns'
- No path (blocked) → valid=false, reason='no-path'
- Score calculation correct for valid matches


🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-03-11 04:40:00 +00:00
co-authored by Claude
parent 6829c2f025
commit fd3b584fff
2 changed files with 229 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
// src/__tests__/MatchEngine.test.ts - Test suite for MatchEngine
import { describe, it, expect, beforeEach } from 'vitest';
import { MatchEngine } from '../matching/MatchEngine';
import { GridManager } from '../managers/GridManager';
import { TypedEventEmitter } from '../game/EventEmitter';
import { GameEvents, Tile, TilePosition } from '../types';
import { CONFIG } from '../config';
describe('MatchEngine', () => {
let gridManager: GridManager;
let events: TypedEventEmitter<GameEvents>;
let matchEngine: MatchEngine;
let grid: Tile[][];
beforeEach(() => {
events = new TypedEventEmitter<GameEvents>();
gridManager = new GridManager(events);
gridManager.initializeGrid();
matchEngine = new MatchEngine(gridManager, events);
grid = gridManager.getAllTiles();
});
describe('validateMatch', () => {
it('should return valid=false with reason different-type for different tile types', () => {
const tile1 = grid[0][0]; // type 0
const tile2 = grid[0][1]; // type 1
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(false);
expect(result.reason).toBe('different-type');
});
it('should return valid=false with reason same-tile for same tile ID', () => {
const tile1 = grid[0][0];
const tile2 = grid[0][0]; // Same tile
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(false);
expect(result.reason).toBe('same-tile');
});
it('should return valid=true with path and turns=0 for matching tiles with valid 0-turn path', () => {
// Create two tiles of same type in same row with cleared tiles between
const tile1 = grid[0][0];
const tile2 = grid[0][1];
// Make them same type
(tile2 as any).type = tile1.type;
// Clear tiles between them (they're adjacent, so no tiles between)
// Mark destination as cleared so it's passable
(tile2 as any).cleared = true;
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(true);
expect(result.path).toBeDefined();
expect(result.turns).toBe(0);
expect(result.score).toBeDefined();
});
it('should return valid=true with path and turns=1 for matching tiles with valid 1-turn path', () => {
// Create L-shaped path scenario
const tile1 = grid[0][0];
const tile2 = grid[1][1];
// Make them same type
(tile2 as any).type = tile1.type;
// Clear the path: (0,0) -> (0,1) -> (1,1)
grid[0][1].cleared = true;
tile2.cleared = true;
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(true);
expect(result.path).toBeDefined();
expect(result.turns).toBe(1);
expect(result.score).toBeDefined();
});
it('should return valid=true with path and turns=2 for matching tiles with valid 2-turn path', () => {
// Create Z-shaped path scenario
const tile1 = grid[0][0];
const tile2 = grid[2][1];
// Make them same type
(tile2 as any).type = tile1.type;
// Clear the path: (0,0) -> (0,1) -> (1,1) -> (2,1)
grid[0][1].cleared = true;
grid[1][1].cleared = true;
tile2.cleared = true;
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(true);
expect(result.path).toBeDefined();
expect(result.turns).toBe(2);
expect(result.score).toBeDefined();
});
it('should return valid=false with reason too-many-turns for matching tiles with 3+ turn path', () => {
// Create a path that requires 3 turns
const tile1 = grid[0][0];
const tile2 = grid[2][2];
// Make them same type
(tile2 as any).type = tile1.type;
// Clear a winding path that requires 3 turns
grid[0][1].cleared = true;
grid[0][2].cleared = true;
grid[1][2].cleared = true;
tile2.cleared = true;
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(false);
expect(result.reason).toBe('too-many-turns');
expect(result.turns).toBeGreaterThan(2);
});
it('should return valid=false with reason no-path for matching tiles with no valid path (blocked)', () => {
const tile1 = grid[0][0];
const tile2 = grid[0][2];
// Make them same type
(tile2 as any).type = tile1.type;
// Don't clear any tiles - path is blocked
// Only destination is cleared
tile2.cleared = true;
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(false);
expect(result.reason).toBe('no-path');
});
it('should calculate correct score for valid matches', () => {
const tile1 = grid[0][0];
const tile2 = grid[0][1];
// Make them same type
(tile2 as any).type = tile1.type;
tile2.cleared = true;
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(true);
expect(result.score).toBe(150); // 0-turn = 150 points
});
});
});
+72
View File
@@ -0,0 +1,72 @@
// src/matching/MatchEngine.ts - Match validation pipeline
/**
* MatchEngine validates tile matches using a multi-stage pipeline
* Stage 1: Type check (fast fail)
* Stage 2: Position check (same tile)
* Stage 3: Pathfinding (expensive - only if types match)
*/
import { Tile, MatchResult, GameEvents } from '../types';
import { GridManager } from '../managers/GridManager';
import { TypedEventEmitter } from '../game/EventEmitter';
import { PathFinder } from './PathFinder';
import { Scoring } from './Scoring';
export class MatchEngine {
constructor(
private gridManager: GridManager,
private events: TypedEventEmitter<GameEvents>
) {}
/**
* Validate if two tiles can be matched
* Uses fail-fast pipeline: type check → position check → pathfinding
* @param tile1 - First tile
* @param tile2 - Second tile
* @returns MatchResult with validity, reason, path, turns, and score
*/
validateMatch(tile1: Tile, tile2: Tile): MatchResult {
// Stage 1: Type check (cheap - fail fast)
if (tile1.type !== tile2.type) {
return { valid: false, reason: 'different-type' };
}
// Stage 2: Position check (prevent matching same tile)
if (tile1.id === tile2.id) {
return { valid: false, reason: 'same-tile' };
}
// Stage 3: Pathfinding (expensive - only if types match)
const grid = this.gridManager.getAllTiles();
const pathResult = PathFinder.findPath(
tile1.position,
tile2.position,
grid,
2 // max 2 turns
);
// Stage 3a: No path found
if (!pathResult) {
return { valid: false, reason: 'no-path' };
}
// Stage 3b: Path has too many turns
if (pathResult.turns > 2) {
return {
valid: false,
reason: 'too-many-turns',
turns: pathResult.turns
};
}
// Stage 4: Success - calculate score
const score = Scoring.calculate(pathResult.turns);
return {
valid: true,
path: pathResult.path,
turns: pathResult.turns,
score
};
}
}