diff --git a/gsd-framework/src/__tests__/GridManager.test.ts b/gsd-framework/src/__tests__/GridManager.test.ts index 2212a35..df73310 100644 --- a/gsd-framework/src/__tests__/GridManager.test.ts +++ b/gsd-framework/src/__tests__/GridManager.test.ts @@ -4,6 +4,7 @@ import { GridManager } from '../managers/GridManager'; import { Tile } from '../models/Tile'; import { TypedEventEmitter } from '../game/EventEmitter'; import { GameEvents } from '../types'; +import { NoMovesDetector } from '../detection/NoMovesDetector'; describe('GridManager', () => { let gridManager: GridManager; @@ -175,4 +176,317 @@ describe('GridManager', () => { expect(gridManager.selectedTilesList.length).toBe(0); }); }); + + describe('random board generation', () => { + it('should create a grid with exactly 10 pairs of each of the 16 types', () => { + gridManager.initializeGrid(); + const typeCounts = new Map(); + + for (let row = 0; row < 10; row++) { + for (let col = 0; col < 16; col++) { + const tile = gridManager.getTileAt(row, col); + expect(tile).not.toBeNull(); + if (tile) { + const count = typeCounts.get(tile.type) || 0; + typeCounts.set(tile.type, count + 1); + } + } + } + + // Should have exactly 16 types + expect(typeCounts.size).toBe(16); + + // Each type should have exactly 10 pairs (20 tiles each) + for (const [type, count] of typeCounts.entries()) { + expect(count).toBe(10); + } + }); + + it('should produce different tile arrangements on successive calls (statistical)', () => { + // Generate two boards and compare first row arrangements + gridManager.initializeGrid(); + const firstBoard: number[] = []; + for (let col = 0; col < 16; col++) { + const tile = gridManager.getTileAt(0, col); + if (tile) firstBoard.push(tile.type); + } + + gridManager.initializeGrid(); + const secondBoard: number[] = []; + for (let col = 0; col < 16; col++) { + const tile = gridManager.getTileAt(0, col); + if (tile) secondBoard.push(tile.type); + } + + // Arrays should be different (statistically very unlikely to be same with shuffle) + expect(firstBoard).not.toEqual(secondBoard); + }); + + it('should assign correct positions to all tiles', () => { + gridManager.initializeGrid(); + + for (let row = 0; row < 10; row++) { + for (let col = 0; col < 16; col++) { + const tile = gridManager.getTileAt(row, col); + expect(tile).not.toBeNull(); + if (tile) { + expect(tile.position.row).toBe(row); + expect(tile.position.col).toBe(col); + } + } + } + }); + + it('should emit board:generated event with solvable=true when solvable board found', () => { + const emitSpy = vi.spyOn(mockEmitter, 'emit'); + + gridManager.initializeGrid(); + + // Should have emitted board:generated event + const calls = emitSpy.mock.calls.filter(call => call[0] === 'board:generated'); + expect(calls.length).toBeGreaterThanOrEqual(1); + + // Check event payload structure + const eventPayload = calls[0][1] as { solvable: boolean; attempts: number }; + expect(eventPayload).toHaveProperty('solvable'); + expect(eventPayload).toHaveProperty('attempts'); + expect(typeof eventPayload.solvable).toBe('boolean'); + expect(typeof eventPayload.attempts).toBe('number'); + }); + + it('should emit board:generated event with attempts count', () => { + const emitSpy = vi.spyOn(mockEmitter, 'emit'); + + gridManager.initializeGrid(); + + const calls = emitSpy.mock.calls.filter(call => call[0] === 'board:generated'); + expect(calls.length).toBeGreaterThanOrEqual(1); + + const eventPayload = calls[0][1] as { solvable: boolean; attempts: number }; + expect(eventPayload.attempts).toBeGreaterThanOrEqual(1); + expect(eventPayload.attempts).toBeLessThanOrEqual(100); + }); + + it('should generate solvable boards when possible, or fallback to last board', () => { + // This test verifies the board generation logic works correctly + // Either a solvable board is found OR the fallback mechanism is used + + const emitSpy = vi.spyOn(mockEmitter, 'emit'); + + gridManager.initializeGrid(); + + const calls = emitSpy.mock.calls.filter(call => call[0] === 'board:generated'); + expect(calls.length).toBeGreaterThanOrEqual(1); + + const eventPayload = calls[0][1] as { solvable: boolean; attempts: number }; + + // Verify the event was emitted with valid data + expect(typeof eventPayload.solvable).toBe('boolean'); + expect(eventPayload.attempts).toBeGreaterThanOrEqual(1); + expect(eventPayload.attempts).toBeLessThanOrEqual(100); + + // If solvable, verify the board actually has valid moves + if (eventPayload.solvable) { + const tiles = gridManager.getAllTiles(); + const hasValidMoves = NoMovesDetector.hasValidMoves(tiles); + expect(hasValidMoves).toBe(true); + } + + // If not solvable (fallback), verify attempts = 100 + if (!eventPayload.solvable) { + expect(eventPayload.attempts).toBe(100); + } + }); + }); + + describe('shuffleTiles', () => { + beforeEach(() => { + gridManager.initializeGrid(); + }); + + it('should collect all uncleared tiles and preserve tile count', () => { + const beforeCount = gridManager.getAllTiles().flat().filter(t => !t.cleared).length; + + gridManager.shuffleTiles(); + + const afterCount = gridManager.getAllTiles().flat().filter(t => !t.cleared).length; + expect(afterCount).toBe(beforeCount); + }); + + it('should preserve type distribution (same count of each type)', () => { + // Get type distribution before shuffle + const beforeCounts = new Map(); + for (let row = 0; row < 10; row++) { + for (let col = 0; col < 16; col++) { + const tile = gridManager.getTileAt(row, col); + if (tile && !tile.cleared) { + const count = beforeCounts.get(tile.type) || 0; + beforeCounts.set(tile.type, count + 1); + } + } + } + + gridManager.shuffleTiles(); + + // Get type distribution after shuffle + const afterCounts = new Map(); + for (let row = 0; row < 10; row++) { + for (let col = 0; col < 16; col++) { + const tile = gridManager.getTileAt(row, col); + if (tile && !tile.cleared) { + const count = afterCounts.get(tile.type) || 0; + afterCounts.set(tile.type, count + 1); + } + } + } + + // Compare distributions + expect(afterCounts.size).toBe(beforeCounts.size); + for (const [type, count] of beforeCounts.entries()) { + expect(afterCounts.get(type)).toBe(count); + } + }); + + it('should produce different type arrangements on successive calls (statistical)', () => { + // Get types before shuffle + const beforeTypes: number[] = []; + for (let row = 0; row < 10; row++) { + for (let col = 0; col < 16; col++) { + const tile = gridManager.getTileAt(row, col); + if (tile) beforeTypes.push(tile.type); + } + } + + gridManager.shuffleTiles(); + + // Get types after shuffle + const afterTypes: number[] = []; + for (let row = 0; row < 10; row++) { + for (let col = 0; col < 16; col++) { + const tile = gridManager.getTileAt(row, col); + if (tile) afterTypes.push(tile.type); + } + } + + // Arrays should be different (statistically very unlikely to be same with Fisher-Yates) + expect(afterTypes).not.toEqual(beforeTypes); + }); + + it('should clear selection (selectedTilesList is empty after)', () => { + // Select some tiles first + const tile1 = gridManager.getTileAt(0, 0); + const tile2 = gridManager.getTileAt(0, 1); + if (tile1 && tile2) { + gridManager.selectTile(tile1); + gridManager.selectTile(tile2); + expect(gridManager.selectedTilesList.length).toBe(2); + } + + gridManager.shuffleTiles(); + + expect(gridManager.selectedTilesList.length).toBe(0); + }); + + it('should emit board:shuffling event before shuffle', () => { + const emitSpy = vi.spyOn(mockEmitter, 'emit'); + + gridManager.shuffleTiles(); + + // Should have emitted board:shuffling event + const shufflingCalls = emitSpy.mock.calls.filter(call => call[0] === 'board:shuffling'); + expect(shufflingCalls.length).toBe(1); + + // Check event payload + const payload = shufflingCalls[0][1] as { tilesRemaining: number }; + expect(payload).toHaveProperty('tilesRemaining'); + expect(payload.tilesRemaining).toBe(160); + }); + + it('should emit board:shuffled event after shuffle', () => { + const emitSpy = vi.spyOn(mockEmitter, 'emit'); + + gridManager.shuffleTiles(); + + // Should have emitted board:shuffled event + const shuffledCalls = emitSpy.mock.calls.filter(call => call[0] === 'board:shuffled'); + expect(shuffledCalls.length).toBe(1); + + // Check event payload + const payload = shuffledCalls[0][1] as { tilesRemaining: number }; + expect(payload).toHaveProperty('tilesRemaining'); + expect(payload.tilesRemaining).toBe(160); + }); + + it('should emit events in correct order (shuffling before shuffled)', () => { + const emitSpy = vi.spyOn(mockEmitter, 'emit'); + + gridManager.shuffleTiles(); + + // Get all board:shuffl* events (note: 'board:shuffle' won't match 'board:shuffling') + const shuffleEvents = emitSpy.mock.calls + .filter(call => call[0] === 'board:shuffling' || call[0] === 'board:shuffled') + .map(call => call[0]); + + expect(shuffleEvents[0]).toBe('board:shuffling'); + expect(shuffleEvents[1]).toBe('board:shuffled'); + }); + + it('should preserve tile positions (tiles stay in same grid locations)', () => { + // Store original positions + const originalPositions: Map = new Map(); + for (let row = 0; row < 10; row++) { + for (let col = 0; col < 16; col++) { + const tile = gridManager.getTileAt(row, col); + if (tile) { + originalPositions.set(tile.id, { ...tile.position }); + } + } + } + + gridManager.shuffleTiles(); + + // Verify positions are unchanged + for (let row = 0; row < 10; row++) { + for (let col = 0; col < 16; col++) { + const tile = gridManager.getTileAt(row, col); + if (tile) { + const original = originalPositions.get(tile.id); + expect(original).toBeDefined(); + expect(tile.position.row).toBe(original!.row); + expect(tile.position.col).toBe(original!.col); + } + } + } + }); + + it('should handle partially cleared board (skip cleared tiles)', () => { + // Clear some tiles first + const tile1 = gridManager.getTileAt(0, 0); + const tile2 = gridManager.getTileAt(0, 1); + if (tile1 && tile2) { + tile1.cleared = true; + tile2.cleared = true; + } + + const remainingBefore = gridManager.getAllTiles().flat().filter(t => !t.cleared).length; + expect(remainingBefore).toBe(158); + + const emitSpy = vi.spyOn(mockEmitter, 'emit'); + + gridManager.shuffleTiles(); + + // Check tilesRemaining in events reflects cleared tiles + const shufflingCalls = emitSpy.mock.calls.filter(call => call[0] === 'board:shuffling'); + const payload = shufflingCalls[0][1] as { tilesRemaining: number }; + expect(payload.tilesRemaining).toBe(158); + + // Verify cleared tiles are still cleared + expect(tile1?.cleared).toBe(true); + expect(tile2?.cleared).toBe(true); + + // Verify remaining count unchanged + const remainingAfter = gridManager.getAllTiles().flat().filter(t => !t.cleared).length; + expect(remainingAfter).toBe(158); + }); + }); }); diff --git a/gsd-framework/src/managers/GridManager.ts b/gsd-framework/src/managers/GridManager.ts index 1e20bec..e50c89a 100644 --- a/gsd-framework/src/managers/GridManager.ts +++ b/gsd-framework/src/managers/GridManager.ts @@ -8,6 +8,7 @@ import { Tile } from '../models/Tile'; import { TilePosition, GameEvents } from '../types'; import { TypedEventEmitter } from '../game/EventEmitter'; import { CONFIG } from '../config'; +import { NoMovesDetector } from '../detection/NoMovesDetector'; export class GridManager { private tiles: Tile[][] = []; @@ -20,20 +21,58 @@ export class GridManager { } /** - * Initialize the grid with tiles based on CONFIG dimensions + * Initialize the grid with randomized tiles and verify solvability + * Retries up to 100 times to find a solvable board */ initializeGrid(): void { - this.tiles = []; + const maxAttempts = 100; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // Generate random board + this.generateRandomGrid(); + + // Verify solvability using existing NoMovesDetector + if (NoMovesDetector.hasValidMoves(this.tiles)) { + // Solvable board found + this.events.emit('board:generated', { solvable: true, attempts: attempt }); + return; + } + } + + // Fallback: accept last generated board (rely on auto-shuffle to recover) + console.warn('Board generation: max attempts reached, accepting board'); + this.events.emit('board:generated', { solvable: false, attempts: maxAttempts }); + } + + /** + * Generate a randomized grid using Fisher-Yates shuffle + * Creates 16 types x 10 pairs = 160 tiles with random arrangement + */ + private generateRandomGrid(): void { + // 1. Create flat array of tile types (16 types x 10 pairs = 160 tiles) + const types: number[] = []; + for (let type = 0; type < 16; type++) { + for (let pair = 0; pair < CONFIG.grid.pairsPerType; pair++) { + types.push(type); + } + } + + // 2. Shuffle using Fisher-Yates algorithm + for (let i = types.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [types[i], types[j]] = [types[j], types[i]]; + } + + // 3. Place shuffled types in grid + this.tiles = []; + let typeIndex = 0; for (let row = 0; row < CONFIG.grid.rows; row++) { const rowTiles: Tile[] = []; for (let col = 0; col < CONFIG.grid.cols; col++) { const id = `tile-${row}-${col}`; - // Assign types 0-15 repeating to create pairs - const type = (row * CONFIG.grid.cols + col) % 16; + const type = types[typeIndex++]; const position: TilePosition = { row, col }; - const tile = new Tile(id, type, position); - rowTiles.push(tile); + rowTiles.push(new Tile(id, type, position)); } this.tiles.push(rowTiles); } @@ -129,4 +168,47 @@ export class GridManager { getEvents(): TypedEventEmitter { return this.events; } + + /** + * Shuffle remaining tiles by redistributing types while preserving positions + * Clears selection and emits events for UI feedback + */ + shuffleTiles(): void { + // 1. Collect uncleared tiles and their positions + const unclearedTiles: Tile[] = []; + + for (let row = 0; row < CONFIG.grid.rows; row++) { + for (let col = 0; col < CONFIG.grid.cols; col++) { + const tile = this.tiles[row][col]; + if (!tile.cleared) { + unclearedTiles.push(tile); + } + } + } + + const tilesRemaining = unclearedTiles.length; + + // 2. Emit shuffling event before modification + this.events.emit('board:shuffling', { tilesRemaining }); + + // 3. Extract types from uncleared tiles + const types = unclearedTiles.map(t => t.type); + + // 4. Shuffle types using Fisher-Yates + for (let i = types.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [types[i], types[j]] = [types[j], types[i]]; + } + + // 5. Reassign shuffled types back to tiles (positions preserved) + for (let i = 0; i < unclearedTiles.length; i++) { + unclearedTiles[i].type = types[i]; + } + + // 6. Clear selection to prevent stale references + this.deselectAll(); + + // 7. Emit shuffled event after completion + this.events.emit('board:shuffled', { tilesRemaining }); + } }