Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/lib/common-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ export type TileDescriptor = {
left: number;
top: number;
tileId: number;
number?: number;
width?: number;
height?: number;
};

export type TileAdjancency = {
Expand Down
12 changes: 10 additions & 2 deletions src/lib/levels-factory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { randomSubarray, range } from "./utils";
import { randomSubarray, range, isSolvableFromNumbers } from "./utils";

/**
* Generate a level of a given size
Expand All @@ -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,
};
};
109 changes: 109 additions & 0 deletions src/lib/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
71 changes: 71 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};