diff --git a/src/lib/common-types.ts b/src/lib/common-types.ts index 95e057a..ed2fc9a 100644 --- a/src/lib/common-types.ts +++ b/src/lib/common-types.ts @@ -4,6 +4,9 @@ export type TileDescriptor = { left: number; top: number; tileId: number; + number?: number; + width?: number; + height?: number; }; export type TileAdjancency = { diff --git a/src/lib/levels-factory.ts b/src/lib/levels-factory.ts index 2a368fc..cd64fae 100644 --- a/src/lib/levels-factory.ts +++ b/src/lib/levels-factory.ts @@ -1,4 +1,4 @@ -import { randomSubarray, range } from "./utils"; +import { randomSubarray, range, isSolvableFromNumbers } from "./utils"; /** * Generate a level of a given size @@ -14,7 +14,15 @@ export default (size: number) => { throw new Error(`Cannot generate level of size: <${size}>`); } + const gridSize = Math.sqrt(size); + let tileSet: number[]; + + // Keep generating until we get a solvable configuration + do { + tileSet = randomSubarray(range(size), size); + } while (!isSolvableFromNumbers(tileSet, gridSize)); + return { - tileSet: randomSubarray(range(size), size), + tileSet, }; }; diff --git a/src/lib/utils.test.js b/src/lib/utils.test.js index 9110fa3..b493aa9 100644 --- a/src/lib/utils.test.js +++ b/src/lib/utils.test.js @@ -42,3 +42,112 @@ describe('distanceBetween', () => { expect(utils.distanceBetween(tileA, tileB).neighbours).toBe(false); }); }); + +describe('isSolvableFromNumbers', () => { + const gridSize = 4; + + it('should detect the solved state as solvable', () => { + const solvedState = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]; + expect(utils.isSolvableFromNumbers(solvedState, gridSize)).toBe(true); + }); + + it('should detect a solvable configuration (even inversions, blank on odd row from bottom)', () => { + // This configuration has 0 inversions (even) and blank is on row 4 from bottom (odd) + // Row 0 from top = Row 4 from bottom in a 4x4 grid + const solvableState = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 15]; + expect(utils.isSolvableFromNumbers(solvableState, gridSize)).toBe(true); + }); + + it('should detect a solvable configuration (odd inversions, blank on even row from bottom)', () => { + // Blank at position 11: row = floor(11/4) = 2, rowFromBottom = 4-2 = 2 (even) + // Exactly 1 inversion: 2 > 1 (odd inversions) + // Rule: even row from bottom + odd inversions = solvable + const solvableState = [2, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16, 12, 13, 14, 15]; + expect(utils.isSolvableFromNumbers(solvableState, gridSize)).toBe(true); + }); + + it('should detect an unsolvable configuration (odd inversions, blank on odd row from bottom)', () => { + // Blank at position 15 (row 3 from top, row 1 from bottom = odd) + // Exactly 1 inversion: 15 > 14 (odd inversions) + // Rule: odd row from bottom + odd inversions = unsolvable + const unsolvableState = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 14, 16]; + expect(utils.isSolvableFromNumbers(unsolvableState, gridSize)).toBe(false); + }); + + it('should detect an unsolvable configuration (even inversions, blank on even row from bottom)', () => { + // Blank at position 11 (row 2 from top, row 2 from bottom = even) + // 0 inversions (even) + // Rule: even row from bottom + even inversions = unsolvable + const unsolvableState = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16, 12, 13, 14, 15]; + expect(utils.isSolvableFromNumbers(unsolvableState, gridSize)).toBe(false); + }); + + it('should handle blank tile in different positions correctly', () => { + // Blank at position 0 (row 0 from top, row 4 from bottom = even from bottom) + // Exactly 1 inversion: 2 > 1 (odd inversions) + // Rule: even row from bottom + odd inversions = solvable + const state = [16, 2, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; + expect(utils.isSolvableFromNumbers(state, gridSize)).toBe(true); + }); + + it('should correctly count inversions excluding the blank tile', () => { + // Blank at position 11 (row 2 from top, row 2 from bottom = even) + // Exactly 1 inversion: 2 > 1 (odd inversions) + // Rule: even row from bottom + odd inversions = solvable + const state = [2, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16, 12, 13, 14, 15]; + expect(utils.isSolvableFromNumbers(state, gridSize)).toBe(true); + }); +}); + +describe('isSolvable', () => { + const gridSize = 4; + const tileSize = 100; + + it('should detect solvable tile configurations', () => { + const tiles = [ + { tileId: 0, number: 1, row: 0, column: 0, left: 0, top: 0, width: tileSize, height: tileSize }, + { tileId: 1, number: 2, row: 0, column: 1, left: tileSize, top: 0, width: tileSize, height: tileSize }, + { tileId: 2, number: 3, row: 0, column: 2, left: tileSize * 2, top: 0, width: tileSize, height: tileSize }, + { tileId: 3, number: 4, row: 0, column: 3, left: tileSize * 3, top: 0, width: tileSize, height: tileSize }, + { tileId: 4, number: 5, row: 1, column: 0, left: 0, top: tileSize, width: tileSize, height: tileSize }, + { tileId: 5, number: 6, row: 1, column: 1, left: tileSize, top: tileSize, width: tileSize, height: tileSize }, + { tileId: 6, number: 7, row: 1, column: 2, left: tileSize * 2, top: tileSize, width: tileSize, height: tileSize }, + { tileId: 7, number: 8, row: 1, column: 3, left: tileSize * 3, top: tileSize, width: tileSize, height: tileSize }, + { tileId: 8, number: 9, row: 2, column: 0, left: 0, top: tileSize * 2, width: tileSize, height: tileSize }, + { tileId: 9, number: 10, row: 2, column: 1, left: tileSize, top: tileSize * 2, width: tileSize, height: tileSize }, + { tileId: 10, number: 11, row: 2, column: 2, left: tileSize * 2, top: tileSize * 2, width: tileSize, height: tileSize }, + { tileId: 11, number: 12, row: 2, column: 3, left: tileSize * 3, top: tileSize * 2, width: tileSize, height: tileSize }, + { tileId: 12, number: 13, row: 3, column: 0, left: 0, top: tileSize * 3, width: tileSize, height: tileSize }, + { tileId: 13, number: 14, row: 3, column: 1, left: tileSize, top: tileSize * 3, width: tileSize, height: tileSize }, + { tileId: 14, number: 15, row: 3, column: 2, left: tileSize * 2, top: tileSize * 3, width: tileSize, height: tileSize }, + { tileId: 15, number: 16, row: 3, column: 3, left: tileSize * 3, top: tileSize * 3, width: tileSize, height: tileSize }, + ]; + + expect(utils.isSolvable(tiles, gridSize)).toBe(true); + }); + + it('should detect unsolvable tile configurations', () => { + // Swap positions 13 and 14 (numbers 14 and 15) creating 1 inversion + // Blank remains at bottom (odd row from bottom), so this is unsolvable + const tiles = [ + { tileId: 0, number: 1, row: 0, column: 0, left: 0, top: 0, width: tileSize, height: tileSize }, + { tileId: 1, number: 2, row: 0, column: 1, left: tileSize, top: 0, width: tileSize, height: tileSize }, + { tileId: 2, number: 3, row: 0, column: 2, left: tileSize * 2, top: 0, width: tileSize, height: tileSize }, + { tileId: 3, number: 4, row: 0, column: 3, left: tileSize * 3, top: 0, width: tileSize, height: tileSize }, + { tileId: 4, number: 5, row: 1, column: 0, left: 0, top: tileSize, width: tileSize, height: tileSize }, + { tileId: 5, number: 6, row: 1, column: 1, left: tileSize, top: tileSize, width: tileSize, height: tileSize }, + { tileId: 6, number: 7, row: 1, column: 2, left: tileSize * 2, top: tileSize, width: tileSize, height: tileSize }, + { tileId: 7, number: 8, row: 1, column: 3, left: tileSize * 3, top: tileSize, width: tileSize, height: tileSize }, + { tileId: 8, number: 9, row: 2, column: 0, left: 0, top: tileSize * 2, width: tileSize, height: tileSize }, + { tileId: 9, number: 10, row: 2, column: 1, left: tileSize, top: tileSize * 2, width: tileSize, height: tileSize }, + { tileId: 10, number: 11, row: 2, column: 2, left: tileSize * 2, top: tileSize * 2, width: tileSize, height: tileSize }, + { tileId: 11, number: 12, row: 2, column: 3, left: tileSize * 3, top: tileSize * 2, width: tileSize, height: tileSize }, + { tileId: 12, number: 13, row: 3, column: 0, left: 0, top: tileSize * 3, width: tileSize, height: tileSize }, + { tileId: 13, number: 15, row: 3, column: 1, left: tileSize, top: tileSize * 3, width: tileSize, height: tileSize }, + { tileId: 14, number: 14, row: 3, column: 2, left: tileSize * 2, top: tileSize * 3, width: tileSize, height: tileSize }, + { tileId: 15, number: 16, row: 3, column: 3, left: tileSize * 3, top: tileSize * 3, width: tileSize, height: tileSize }, + ]; + + expect(utils.isSolvable(tiles, gridSize)).toBe(false); + }); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 567d37a..78c0ac5 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -114,3 +114,74 @@ export const invert = ( arr[indexB][field] = sw; }); }; + +/** + * Check if a puzzle configuration is solvable from an array of numbers. + * For a 15-puzzle (4x4 grid), a configuration is solvable if: + * - The blank is on an even row (counting from bottom) and inversions is odd, OR + * - The blank is on an odd row (counting from bottom) and inversions is even + * + * An inversion is a pair of tiles where a higher-numbered tile appears + * before a lower-numbered tile in row-major order (excluding the blank). + * + * @param numbers Array of numbers representing the puzzle state + * @param gridSize Size of the grid (e.g., 4 for a 4x4 grid) + * @returns {boolean} True if the puzzle is solvable, false otherwise + */ +export const isSolvableFromNumbers = ( + numbers: number[], + gridSize: number +): boolean => { + // Find the blank tile (number === gridSize * gridSize) + const blankValue = gridSize * gridSize; + const blankIndex = numbers.indexOf(blankValue); + + // Calculate the row of the blank tile from the bottom (1-indexed) + const blankRow = Math.floor(blankIndex / gridSize); + const blankRowFromBottom = gridSize - blankRow; + + // Count inversions (excluding the blank tile) + let inversions = 0; + for (let i = 0; i < numbers.length; i++) { + if (numbers[i] === blankValue) continue; + + for (let j = i + 1; j < numbers.length; j++) { + if (numbers[j] === blankValue) continue; + + if (numbers[i] > numbers[j]) { + inversions++; + } + } + } + + // For a 4x4 grid (15-puzzle): + // If blank is on an even row from bottom, inversions must be odd + // If blank is on an odd row from bottom, inversions must be even + if (blankRowFromBottom % 2 === 0) { + return inversions % 2 === 1; + } else { + return inversions % 2 === 0; + } +}; + +/** + * Check if a puzzle configuration is solvable. + * For a 15-puzzle (4x4 grid), a configuration is solvable if: + * - The blank is on an even row (counting from bottom) and inversions is odd, OR + * - The blank is on an odd row (counting from bottom) and inversions is even + * + * An inversion is a pair of tiles where a higher-numbered tile appears + * before a lower-numbered tile in row-major order (excluding the blank). + * + * @param tiles Array of tile descriptors + * @param gridSize Size of the grid (e.g., 4 for a 4x4 grid) + * @returns {boolean} True if the puzzle is solvable, false otherwise + */ +export const isSolvable = ( + tiles: TileDescriptor[], + gridSize: number +): boolean => { + // Get the numbers in row-major order + const numbers = tiles.map(tile => tile.number!); // Use non-null assertion since we know tiles have numbers in the game + return isSolvableFromNumbers(numbers, gridSize); +};