// Pure game logic functions extracted for testability const BOARD_WIDTH = 10; const BOARD_HEIGHT = 20; const GARBAGE_COLOR = '#666666'; // Tetromino definitions const TETROMINOS = { I: { shape: [[0,0,0,0], [1,1,1,1], [0,0,0,0], [0,0,0,0]], color: '#00ffff' }, O: { shape: [[1,1], [1,1]], color: '#ffff00' }, T: { shape: [[0,1,0], [1,1,1], [0,0,0]], color: '#800080' }, S: { shape: [[0,1,1], [1,1,0], [0,0,0]], color: '#00ff00' }, Z: { shape: [[1,1,0], [0,1,1], [0,0,0]], color: '#ff0000' }, J: { shape: [[1,0,0], [1,1,1], [0,0,0]], color: '#0000ff' }, L: { shape: [[0,0,1], [1,1,1], [0,0,0]], color: '#ffa500' } }; const TETROMINO_KEYS = Object.keys(TETROMINOS); /** * Create an empty board * @returns {number[][]} Empty 20x10 board */ function createEmptyBoard() { return Array(BOARD_HEIGHT).fill(null).map(() => Array(BOARD_WIDTH).fill(0)); } /** * Get a piece from its type * @param {string} pieceType - Type of tetromino (I, O, T, S, Z, J, L) * @returns {object} Piece object with shape and color */ function getPieceFromType(pieceType) { const tetromino = TETROMINOS[pieceType]; return { type: pieceType, shape: JSON.parse(JSON.stringify(tetromino.shape)), color: tetromino.color, x: Math.floor(BOARD_WIDTH / 2) - Math.floor(tetromino.shape[0].length / 2), y: 0 }; } /** * Check if a position is valid for a piece * @param {object} piece - The piece to check * @param {number} x - X position * @param {number} y - Y position * @param {number[][]} board - The game board * @param {number[][]} shape - Optional custom shape (for rotation) * @returns {boolean} True if position is valid */ function isValidPosition(piece, x, y, board, shape = null) { const pieceShape = shape || piece.shape; for (let row = 0; row < pieceShape.length; row++) { for (let col = 0; col < pieceShape[row].length; col++) { if (pieceShape[row][col]) { const newX = x + col; const newY = y + row; // Check bounds if (newX < 0 || newX >= BOARD_WIDTH || newY >= BOARD_HEIGHT) { return false; } // Check collision with locked pieces (only if on board) if (newY >= 0 && board[newY][newX]) { return false; } } } } return true; } /** * Calculate ghost piece Y position * @param {object} piece - The current piece * @param {number[][]} board - The game board * @returns {number|null} Ghost Y position or null if no piece */ function getGhostY(piece, board) { if (!piece) return null; let ghostY = piece.y; while (isValidPosition(piece, piece.x, ghostY + 1, board)) { ghostY++; } return ghostY; } /** * Check if board has overflowed (blocks in top 2 rows) * @param {number[][]} board - The game board * @returns {boolean} True if board has overflowed */ function checkBoardOverflow(board) { for (let row = 0; row < 2; row++) { for (let col = 0; col < BOARD_WIDTH; col++) { if (board[row][col] !== 0) { return true; } } } return false; } /** * Add garbage row to board * @param {number[][]} board - The game board * @param {object} currentPiece - Current falling piece (may be null) * @returns {object} Object with new board, updated piece, and gap position */ function addGarbageRow(board, currentPiece) { // Remove top row const newBoard = board.slice(1); // Add garbage row with random gap const garbageRow = Array(BOARD_WIDTH).fill(GARBAGE_COLOR); const gap = Math.floor(Math.random() * BOARD_WIDTH); garbageRow[gap] = 0; newBoard.push(garbageRow); // Push current piece up by 1 row let updatedPiece = currentPiece; if (currentPiece) { updatedPiece = { ...currentPiece, y: currentPiece.y - 1 }; } return { board: newBoard, currentPiece: updatedPiece, gap }; } /** * Lock a piece to the board * @param {object} piece - The piece to lock * @param {number[][]} board - The game board * @returns {number[][]} New board with piece locked */ function lockPieceToBoard(piece, board) { const newBoard = board.map(row => [...row]); for (let row = 0; row < piece.shape.length; row++) { for (let col = 0; col < piece.shape[row].length; col++) { if (piece.shape[row][col]) { const boardY = piece.y + row; const boardX = piece.x + col; if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) { newBoard[boardY][boardX] = piece.color; } } } } return newBoard; } /** * Clear completed rows from board * @param {number[][]} board - The game board * @returns {object} Object with new board and rows cleared count */ function clearRows(board) { let rowsCleared = 0; const newBoard = board.map(row => [...row]); for (let row = BOARD_HEIGHT - 1; row >= 0; row--) { if (newBoard[row].every(cell => cell !== 0)) { newBoard.splice(row, 1); newBoard.unshift(Array(BOARD_WIDTH).fill(0)); rowsCleared++; row++; // Check same row again } } return { board: newBoard, rowsCleared }; } /** * Generate a shuffled bag of 7 tetrominoes * @returns {string[]} Array of 7 piece types */ function generatePieceBag() { const bag = [...TETROMINO_KEYS]; for (let i = bag.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [bag[i], bag[j]] = [bag[j], bag[i]]; } return bag; } /** * Create a piece queue with multiple bags * @param {number} bagCount - Number of bags to generate * @returns {string[]} Array of piece types */ function createPieceQueue(bagCount = 14) { const queue = []; for (let i = 0; i < bagCount; i++) { queue.push(...generatePieceBag()); } return queue; } module.exports = { BOARD_WIDTH, BOARD_HEIGHT, GARBAGE_COLOR, TETROMINOS, TETROMINO_KEYS, createEmptyBoard, getPieceFromType, isValidPosition, getGhostY, checkBoardOverflow, addGarbageRow, lockPieceToBoard, clearRows, generatePieceBag, createPieceQueue };