From 75c0351d446eefb6a5729dcedf66e5f32814d70b Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 7 Apr 2026 20:53:10 +0700 Subject: [PATCH] feat: add board generation, remaining tiles, and shuffle Co-Authored-By: Claude Sonnet 4.6 --- src/game/board.ts | 91 ++++++++++++++++++++++++++++++++++++++++ tests/game/board.test.ts | 91 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/game/board.ts create mode 100644 tests/game/board.test.ts diff --git a/src/game/board.ts b/src/game/board.ts new file mode 100644 index 0000000..d05fc24 --- /dev/null +++ b/src/game/board.ts @@ -0,0 +1,91 @@ +import { Board, Difficulty, TileData, Point } from "../types"; +import { DIFFICULTY_CONFIGS } from "./constants"; +import { getEmojisForDifficulty } from "./emoji"; + +let nextTileId = 0; + +export function createBoard(difficulty: Difficulty): Board { + const config = DIFFICULTY_CONFIGS[difficulty]; + const { rows, cols } = config; + const emojis = getEmojisForDifficulty(difficulty); + + // Create pairs + const tiles: TileData[] = []; + nextTileId = 0; + for (const emoji of emojis) { + tiles.push({ emoji, id: nextTileId++ }); + tiles.push({ emoji, id: nextTileId++ }); + } + + // Fisher-Yates shuffle + for (let i = tiles.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [tiles[i], tiles[j]] = [tiles[j], tiles[i]]; + } + + // Place into grid + const board: Board = []; + let idx = 0; + for (let r = 0; r < rows; r++) { + const row: (TileData | null)[] = []; + for (let c = 0; c < cols; c++) { + row.push(tiles[idx++]); + } + board.push(row); + } + + return board; +} + +export function getRemainingTiles(board: Board): { tile: TileData; pos: Point }[] { + const result: { tile: TileData; pos: Point }[] = []; + for (let r = 0; r < board.length; r++) { + for (let c = 0; c < board[r].length; c++) { + const cell = board[r][c]; + if (cell !== null) { + result.push({ tile: cell, pos: { row: r, col: c } }); + } + } + } + return result; +} + +export function shuffleBoard(board: Board): Board { + const rows = board.length; + const cols = board[0].length; + + const tiles: TileData[] = []; + const occupiedPositions: Point[] = []; + const emptyPositions: Point[] = []; + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + if (board[r][c] !== null) { + tiles.push(board[r][c]!); + occupiedPositions.push({ row: r, col: c }); + } else { + emptyPositions.push({ row: r, col: c }); + } + } + } + + // Fisher-Yates shuffle the tiles + for (let i = tiles.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [tiles[i], tiles[j]] = [tiles[j], tiles[i]]; + } + + // Create new board with nulls + const newBoard: Board = []; + for (let r = 0; r < rows; r++) { + newBoard.push(new Array(cols).fill(null)); + } + + // Place shuffled tiles back into occupied positions + for (let i = 0; i < tiles.length; i++) { + const pos = occupiedPositions[i]; + newBoard[pos.row][pos.col] = tiles[i]; + } + + return newBoard; +} diff --git a/tests/game/board.test.ts b/tests/game/board.test.ts new file mode 100644 index 0000000..8191c4f --- /dev/null +++ b/tests/game/board.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { createBoard, getRemainingTiles, shuffleBoard } from "../../src/game/board"; + +describe("createBoard", () => { + it("creates a board with correct dimensions for easy", () => { + const board = createBoard("easy"); + expect(board.length).toBe(4); + expect(board[0].length).toBe(6); + }); + + it("creates a board with correct dimensions for medium", () => { + const board = createBoard("medium"); + expect(board.length).toBe(6); + expect(board[0].length).toBe(8); + }); + + it("creates a board with correct dimensions for hard", () => { + const board = createBoard("hard"); + expect(board.length).toBe(8); + expect(board[0].length).toBe(10); + }); + + it("every tile has a matching pair", () => { + const board = createBoard("easy"); + const emojiCounts = new Map(); + for (const row of board) { + for (const cell of row) { + if (cell) { + emojiCounts.set(cell.emoji, (emojiCounts.get(cell.emoji) ?? 0) + 1); + } + } + } + for (const [, count] of emojiCounts) { + expect(count).toBe(2); + } + }); + + it("all tiles have unique ids", () => { + const board = createBoard("medium"); + const ids = new Set(); + for (const row of board) { + for (const cell of row) { + if (cell) { + expect(ids.has(cell.id)).toBe(false); + ids.add(cell.id); + } + } + } + }); +}); + +describe("getRemainingTiles", () => { + it("returns all tiles from a full board", () => { + const board = createBoard("easy"); + const remaining = getRemainingTiles(board); + expect(remaining.length).toBe(24); + }); + + it("excludes null cells", () => { + const board = createBoard("easy"); + board[0][0] = null; + board[0][1] = null; + const remaining = getRemainingTiles(board); + expect(remaining.length).toBe(22); + }); +}); + +describe("shuffleBoard", () => { + it("preserves tile count after shuffle", () => { + const board = createBoard("easy"); + board[0][0] = null; + board[0][1] = null; + const shuffled = shuffleBoard(board); + const remaining = getRemainingTiles(shuffled); + expect(remaining.length).toBe(22); + }); + + it("keeps null positions as occupied cells and vice versa", () => { + const board = createBoard("easy"); + board[0][0] = null; + board[1][2] = null; + const shuffled = shuffleBoard(board); + let nullCount = 0; + for (const row of shuffled) { + for (const cell of row) { + if (cell === null) nullCount++; + } + } + expect(nullCount).toBe(2); + }); +});