From fd3b584fff0451e2074f1dd82dac2e6dd4a0f7d5 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 11 Mar 2026 04:40:00 +0000 Subject: [PATCH] feat(03-02): implement MatchEngine validation pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/__tests__/MatchEngine.test.ts | 157 ++++++++++++++++++++++++++++++ src/matching/MatchEngine.ts | 72 ++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 src/__tests__/MatchEngine.test.ts create mode 100644 src/matching/MatchEngine.ts diff --git a/src/__tests__/MatchEngine.test.ts b/src/__tests__/MatchEngine.test.ts new file mode 100644 index 0000000..ab4f0cc --- /dev/null +++ b/src/__tests__/MatchEngine.test.ts @@ -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; + let matchEngine: MatchEngine; + let grid: Tile[][]; + + beforeEach(() => { + events = new TypedEventEmitter(); + 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 + }); + }); +}); diff --git a/src/matching/MatchEngine.ts b/src/matching/MatchEngine.ts new file mode 100644 index 0000000..51609a9 --- /dev/null +++ b/src/matching/MatchEngine.ts @@ -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 + ) {} + + /** + * 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 + }; + } +}