feat(05-02): implement shuffleTiles() method with Fisher-Yates shuffle

- shuffleTiles() redistributes tile types while preserving positions
- Clears selection to prevent stale tile references
- Emits board:shuffling before and board:shuffled after
- Handles partially cleared boards correctly
- Added comprehensive test coverage for shuffle functionality
This commit is contained in:
2026-03-11 10:05:43 +00:00
parent 9b54d6d9c1
commit da052132b0
2 changed files with 402 additions and 6 deletions
@@ -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<number, number>();
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<number, number>();
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<number, number>();
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<string, { row: number; col: number }> = 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);
});
});
});
+88 -6
View File
@@ -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<GameEvents> {
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 });
}
}