diff --git a/_bmad-output/implementation-artifacts/1-2-game-logic-module.md b/_bmad-output/implementation-artifacts/1-2-game-logic-module.md new file mode 100644 index 0000000..bcb545f --- /dev/null +++ b/_bmad-output/implementation-artifacts/1-2-game-logic-module.md @@ -0,0 +1,121 @@ +# Story 1.2: Game Logic Module + +Status: review + +## Story + +As a developer, +I want a pure JavaScript game logic module with complete unit tests, +so that all game mechanics are correct and independently testable before connecting to the UI. + +## Acceptance Criteria + +1. `initGame()` returns a game state with a 4x4 grid (2D array of zeros) with exactly 2 random tiles placed (90% chance value 2, 10% chance value 4), score 0, won false, keepPlaying false +2. `move(state, direction)` returns a new state object (never mutates input) with tiles slid to the leading edge, identical adjacent tiles merged using leading-edge order, once-per-move merge rule enforced, score delta equals sum of merged tile values, and one new random tile (90/10) added to empty cell +3. When no tiles can move in the requested direction, `move()` returns the original state unchanged (no new tile spawned) +4. `isGameOver(state)` returns true only when the board is full AND no adjacent tiles share the same value +5. When a merge creates a tile with value 2048, the won flag is set to true in the returned state +6. All functions have passing Vitest unit tests covering edge cases (full board, corner merges, chain prevention) + +## Tasks / Subtasks + +- [x] Task 1: Create game state structure and initGame() (AC: #1) + - [x] Create `src/lib/game-logic.js` with the canonical game state shape + - [x] Implement `createEmptyGrid()`, `getEmptyCells(grid)`, `addRandomTile(grid)` + - [x] Implement `initGame()` — creates empty grid, adds 2 random tiles + - [x] Write tests for initGame: grid is 4x4, exactly 2 non-zero cells, values are 2 or 4 + +- [x] Task 2: Implement slide and merge logic (AC: #2, #3) + - [x] Implement `slideRow`, `mergeRow`, `processRow` functions + - [x] Implement `move(state, direction)` with direction normalization + - [x] Return original state unchanged if no tiles moved (AC: #3) + - [x] Write tests: single merge, chain prevention [2,2,2,2]→[4,4,0,0], leading-edge order, no-op, score delta + +- [x] Task 3: Implement game over detection (AC: #4) + - [x] Implement `canMove(grid)` and `isGameOver(state)` + - [x] Write tests: empty cells, full board with/without matches + +- [x] Task 4: Implement win detection (AC: #5) + - [x] Win detection in move() — checks for WIN_VALUE after merging + - [x] Write tests: 1024+1024 sets won=true, no re-trigger, keepPlaying prevents trigger + +- [x] Task 5: Run full test suite and verify all edge cases (AC: #6) + - [x] All 38 tests pass via `npx vitest run` + - [x] Immutability verified: original state never modified + - [x] All four directions tested for slide and merge + +## Dev Notes + +### Architecture Compliance + +- **File location:** `src/lib/game-logic.js` — pure JS module, ZERO Svelte imports, ZERO DOM access +- **Immutability:** ALL functions return NEW objects/arrays, NEVER mutate input +- **Constants:** Import GRID_SIZE, WIN_VALUE, SPAWN_PROBABILITY, DIRECTIONS from `./constants.js` +- **State shape:** `{ grid: number[][], score: number, won: boolean, keepPlaying: boolean }` — canonical, do not add fields +- **Exports:** `initGame`, `move`, `canMove`, `isGameOver`, `addRandomTile`, `getEmptyCells`, `createEmptyGrid` + +### Critical Anti-Patterns (DO NOT) + +- DO NOT import Svelte, DOM APIs, or any browser APIs +- DO NOT mutate input state — always return new objects (use `grid.map(row => [...row])` for cloning) +- DO NOT use `Math.random()` directly in merge/slide logic — only in `addRandomTile` and `initGame` +- DO NOT merge more than once per tile per move (e.g., `[2,2,2,2]` must produce `[4,4,0,0]`, NOT `[8,0,0,0]`) +- DO NOT add animation or UI concerns to this module + +### Merge Algorithm Reference + +The merge algorithm must match the original 2048: +1. **Slide** all non-zero values toward the leading edge (remove gaps) +2. **Merge** adjacent equal values, starting from the leading edge +3. **Slide** again to fill gaps created by merges +4. Leading edge = direction of movement (left=index 0, right=index 3, up=row 0, down=row 3) +5. Once-per-move: a tile created by merging cannot merge again in the same move + +### Direction Normalization Strategy + +Normalize all directions to "process left" by rotating the grid: +- LEFT: process rows as-is +- RIGHT: reverse each row → process → reverse back +- UP: transpose grid → process → transpose back +- DOWN: transpose + reverse → process → reverse + transpose back + +### Previous Story Intelligence + +From Story 1.1: +- Constants module at `src/lib/constants.js` with GRID_SIZE=4, WIN_VALUE=2048, SPAWN_PROBABILITY=0.9 +- Vitest configured, test files use `src/lib/*.test.js` pattern +- Existing test file `src/lib/game-logic.test.js` has constants validation tests — extend this file + +### Project Structure Notes + +- Tests co-located: `src/lib/game-logic.test.js` next to `src/lib/game-logic.js` +- Extend existing test file (don't create a new one) + +### References + +- [Source: _bmad-output/planning-artifacts/architecture.md#Frontend Architecture - Game Logic Module] +- [Source: _bmad-output/planning-artifacts/architecture.md#Implementation Patterns - Communication Patterns] +- [Source: _bmad-output/planning-artifacts/architecture.md#Data Flow] +- [Source: _bmad-output/planning-artifacts/epics.md#Story 1.2] + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 4.6 (1M context) + +### Debug Log References + +### Completion Notes List + +- Pure JS game logic module with 7 exported functions: createEmptyGrid, getEmptyCells, addRandomTile, initGame, move, canMove, isGameOver +- Direction normalization strategy: rotate grid to normalize all moves to "process left" +- Once-per-move merge rule enforced: [2,2,2,2] → [4,4,0,0] +- Immutability enforced: all functions return new objects +- Win detection: checks for 2048 after merging, respects won and keepPlaying flags +- 38 tests passing covering all edge cases + +### File List + +- src/lib/game-logic.js (new — pure game logic module) +- src/lib/game-logic.test.js (updated — 38 tests covering all game mechanics) diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index d8427f7..8bd08f2 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -45,7 +45,7 @@ development_status: # Epic 1: Play a Complete Game (Desktop) epic-1: in-progress 1-1-project-scaffold-and-dev-environment: review - 1-2-game-logic-module: backlog + 1-2-game-logic-module: review 1-3-game-board-and-tile-rendering: backlog 1-4-game-header-score-display-and-new-game-button: backlog 1-5-keyboard-input-and-interactive-gameplay: backlog diff --git a/src/lib/game-logic.js b/src/lib/game-logic.js new file mode 100644 index 0000000..9b846f1 --- /dev/null +++ b/src/lib/game-logic.js @@ -0,0 +1,180 @@ +import { GRID_SIZE, WIN_VALUE, SPAWN_PROBABILITY, DIRECTIONS } from './constants.js'; + +export function createEmptyGrid() { + return Array.from({ length: GRID_SIZE }, () => Array(GRID_SIZE).fill(0)); +} + +export function getEmptyCells(grid) { + const cells = []; + for (let row = 0; row < GRID_SIZE; row++) { + for (let col = 0; col < GRID_SIZE; col++) { + if (grid[row][col] === 0) { + cells.push({ row, col }); + } + } + } + return cells; +} + +export function addRandomTile(grid) { + const empty = getEmptyCells(grid); + if (empty.length === 0) return grid; + + const { row, col } = empty[Math.floor(Math.random() * empty.length)]; + const value = Math.random() < SPAWN_PROBABILITY ? 2 : 4; + + const newGrid = grid.map(r => [...r]); + newGrid[row][col] = value; + return newGrid; +} + +export function initGame() { + let grid = createEmptyGrid(); + grid = addRandomTile(grid); + grid = addRandomTile(grid); + return { + grid, + score: 0, + won: false, + keepPlaying: false, + }; +} + +function slideRow(row) { + return row.filter(v => v !== 0); +} + +function mergeRow(row) { + const merged = []; + let score = 0; + let i = 0; + while (i < row.length) { + if (i + 1 < row.length && row[i] === row[i + 1]) { + const val = row[i] * 2; + merged.push(val); + score += val; + i += 2; + } else { + merged.push(row[i]); + i += 1; + } + } + return { merged, score }; +} + +function processRow(row) { + const slid = slideRow(row); + const { merged, score } = mergeRow(slid); + const result = [...merged]; + while (result.length < GRID_SIZE) { + result.push(0); + } + return { result, score }; +} + +function transpose(grid) { + return grid[0].map((_, col) => grid.map(row => row[col])); +} + +function reverseRows(grid) { + return grid.map(row => [...row].reverse()); +} + +function gridsEqual(a, b) { + for (let r = 0; r < GRID_SIZE; r++) { + for (let c = 0; c < GRID_SIZE; c++) { + if (a[r][c] !== b[r][c]) return false; + } + } + return true; +} + +export function move(state, direction) { + let grid = state.grid.map(r => [...r]); + let totalScore = 0; + + // Normalize to "process left" + if (direction === DIRECTIONS.RIGHT) { + grid = reverseRows(grid); + } else if (direction === DIRECTIONS.UP) { + grid = transpose(grid); + } else if (direction === DIRECTIONS.DOWN) { + grid = reverseRows(transpose(grid)); + } + + // Process each row + const newGrid = []; + for (let r = 0; r < GRID_SIZE; r++) { + const { result, score } = processRow(grid[r]); + newGrid.push(result); + totalScore += score; + } + + // Reverse normalization + let finalGrid; + if (direction === DIRECTIONS.RIGHT) { + finalGrid = reverseRows(newGrid); + } else if (direction === DIRECTIONS.UP) { + finalGrid = transpose(newGrid); + } else if (direction === DIRECTIONS.DOWN) { + finalGrid = transpose(reverseRows(newGrid)); + } else { + finalGrid = newGrid; + } + + // Check if anything moved + if (gridsEqual(state.grid, finalGrid)) { + return state; + } + + // Add random tile + finalGrid = addRandomTile(finalGrid); + + // Check for win + let won = state.won; + if (!won && !state.keepPlaying) { + for (let r = 0; r < GRID_SIZE; r++) { + for (let c = 0; c < GRID_SIZE; c++) { + if (finalGrid[r][c] === WIN_VALUE) { + won = true; + } + } + } + } + + return { + grid: finalGrid, + score: state.score + totalScore, + won, + keepPlaying: state.keepPlaying, + }; +} + +export function canMove(grid) { + // Check for empty cells + for (let r = 0; r < GRID_SIZE; r++) { + for (let c = 0; c < GRID_SIZE; c++) { + if (grid[r][c] === 0) return true; + } + } + + // Check for adjacent matches (horizontal) + for (let r = 0; r < GRID_SIZE; r++) { + for (let c = 0; c < GRID_SIZE - 1; c++) { + if (grid[r][c] === grid[r][c + 1]) return true; + } + } + + // Check for adjacent matches (vertical) + for (let r = 0; r < GRID_SIZE - 1; r++) { + for (let c = 0; c < GRID_SIZE; c++) { + if (grid[r][c] === grid[r + 1][c]) return true; + } + } + + return false; +} + +export function isGameOver(state) { + return !canMove(state.grid); +} diff --git a/src/lib/game-logic.test.js b/src/lib/game-logic.test.js index ccb1d4c..310edd6 100644 --- a/src/lib/game-logic.test.js +++ b/src/lib/game-logic.test.js @@ -1,5 +1,14 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { GRID_SIZE, WIN_VALUE, SPAWN_PROBABILITY, DIRECTIONS, TILE_COLORS } from './constants.js'; +import { + createEmptyGrid, + getEmptyCells, + addRandomTile, + initGame, + move, + canMove, + isGameOver, +} from './game-logic.js'; describe('constants', () => { it('should have correct grid size', () => { @@ -31,3 +40,399 @@ describe('constants', () => { expect(TILE_COLORS.super.bg).toBe('#3c3a32'); }); }); + +describe('createEmptyGrid', () => { + it('should create a 4x4 grid of zeros', () => { + const grid = createEmptyGrid(); + expect(grid).toHaveLength(4); + grid.forEach(row => { + expect(row).toHaveLength(4); + row.forEach(cell => expect(cell).toBe(0)); + }); + }); +}); + +describe('getEmptyCells', () => { + it('should return all cells for empty grid', () => { + const grid = createEmptyGrid(); + expect(getEmptyCells(grid)).toHaveLength(16); + }); + + it('should return correct empty cells', () => { + const grid = [ + [2, 0, 0, 0], + [0, 4, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]; + expect(getEmptyCells(grid)).toHaveLength(14); + }); + + it('should return empty array for full grid', () => { + const grid = [ + [2, 4, 8, 16], + [32, 64, 128, 256], + [512, 1024, 2048, 2], + [4, 8, 16, 32], + ]; + expect(getEmptyCells(grid)).toHaveLength(0); + }); +}); + +describe('addRandomTile', () => { + it('should add exactly one tile to an empty grid', () => { + const grid = createEmptyGrid(); + const newGrid = addRandomTile(grid); + const nonZero = newGrid.flat().filter(v => v !== 0); + expect(nonZero).toHaveLength(1); + expect([2, 4]).toContain(nonZero[0]); + }); + + it('should not mutate the original grid', () => { + const grid = createEmptyGrid(); + const original = grid.map(r => [...r]); + addRandomTile(grid); + expect(grid).toEqual(original); + }); + + it('should return same grid if no empty cells', () => { + const grid = [ + [2, 4, 8, 16], + [32, 64, 128, 256], + [512, 1024, 2048, 2], + [4, 8, 16, 32], + ]; + const result = addRandomTile(grid); + expect(result).toEqual(grid); + }); +}); + +describe('initGame', () => { + it('should return a valid game state', () => { + const state = initGame(); + expect(state.grid).toHaveLength(4); + expect(state.score).toBe(0); + expect(state.won).toBe(false); + expect(state.keepPlaying).toBe(false); + }); + + it('should have exactly 2 tiles', () => { + const state = initGame(); + const nonZero = state.grid.flat().filter(v => v !== 0); + expect(nonZero).toHaveLength(2); + }); + + it('should only spawn 2s and 4s', () => { + for (let i = 0; i < 20; i++) { + const state = initGame(); + const tiles = state.grid.flat().filter(v => v !== 0); + tiles.forEach(v => expect([2, 4]).toContain(v)); + } + }); +}); + +describe('move', () => { + function makeState(grid, score = 0, won = false, keepPlaying = false) { + return { grid, score, won, keepPlaying }; + } + + describe('slide left', () => { + it('should slide tiles to the left', () => { + const state = makeState([ + [0, 0, 0, 2], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + // Mock random to control tile placement + const result = move(state, DIRECTIONS.LEFT); + expect(result.grid[0][0]).toBe(2); + expect(result).not.toBe(state); // new object + }); + + it('should merge equal adjacent tiles', () => { + const state = makeState([ + [2, 2, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const result = move(state, DIRECTIONS.LEFT); + expect(result.grid[0][0]).toBe(4); + expect(result.score).toBe(4); + }); + + it('should enforce once-per-move merge rule [2,2,2,2] -> [4,4,0,0]', () => { + const state = makeState([ + [2, 2, 2, 2], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const result = move(state, DIRECTIONS.LEFT); + expect(result.grid[0][0]).toBe(4); + expect(result.grid[0][1]).toBe(4); + expect(result.score).toBe(8); + }); + + it('should use leading-edge merge order [4,2,2,0] -> [4,4,0,0]', () => { + const state = makeState([ + [4, 2, 2, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const result = move(state, DIRECTIONS.LEFT); + expect(result.grid[0][0]).toBe(4); + expect(result.grid[0][1]).toBe(4); + }); + }); + + describe('slide right', () => { + it('should slide tiles to the right', () => { + const state = makeState([ + [2, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const result = move(state, DIRECTIONS.RIGHT); + expect(result.grid[0][3]).toBe(2); + }); + + it('should merge right [0,0,2,2] -> [0,0,0,4]', () => { + const state = makeState([ + [0, 0, 2, 2], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const result = move(state, DIRECTIONS.RIGHT); + expect(result.grid[0][3]).toBe(4); + expect(result.score).toBe(4); + }); + }); + + describe('slide up', () => { + it('should slide tiles up', () => { + const state = makeState([ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [2, 0, 0, 0], + ]); + const result = move(state, DIRECTIONS.UP); + expect(result.grid[0][0]).toBe(2); + }); + + it('should merge up', () => { + const state = makeState([ + [2, 0, 0, 0], + [2, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const result = move(state, DIRECTIONS.UP); + expect(result.grid[0][0]).toBe(4); + expect(result.score).toBe(4); + }); + }); + + describe('slide down', () => { + it('should slide tiles down', () => { + const state = makeState([ + [2, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const result = move(state, DIRECTIONS.DOWN); + expect(result.grid[3][0]).toBe(2); + }); + + it('should merge down', () => { + const state = makeState([ + [0, 0, 0, 0], + [0, 0, 0, 0], + [2, 0, 0, 0], + [2, 0, 0, 0], + ]); + const result = move(state, DIRECTIONS.DOWN); + expect(result.grid[3][0]).toBe(4); + expect(result.score).toBe(4); + }); + }); + + describe('no-op moves', () => { + it('should return same state if no tiles can move', () => { + const state = makeState([ + [2, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const result = move(state, DIRECTIONS.LEFT); + // tile is already at left edge, can't move left + expect(result).toBe(state); + }); + }); + + describe('immutability', () => { + it('should never mutate the original state', () => { + const state = makeState([ + [2, 2, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const originalGrid = state.grid.map(r => [...r]); + move(state, DIRECTIONS.LEFT); + expect(state.grid).toEqual(originalGrid); + expect(state.score).toBe(0); + }); + }); + + describe('score accumulation', () => { + it('should accumulate score across moves', () => { + const state = makeState([ + [2, 2, 4, 4], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], 10); + const result = move(state, DIRECTIONS.LEFT); + // 2+2=4, 4+4=8 → score delta = 12 + expect(result.score).toBe(22); + }); + }); + + describe('spawns new tile', () => { + it('should add one new tile after a valid move', () => { + const state = makeState([ + [0, 0, 0, 2], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + const result = move(state, DIRECTIONS.LEFT); + const nonZero = result.grid.flat().filter(v => v !== 0); + // Original 1 tile + 1 spawned = 2 + expect(nonZero.length).toBe(2); + }); + }); +}); + +describe('canMove', () => { + it('should return true if empty cells exist', () => { + const grid = [ + [2, 4, 8, 16], + [32, 64, 128, 256], + [512, 1024, 2048, 0], + [4, 8, 16, 32], + ]; + expect(canMove(grid)).toBe(true); + }); + + it('should return true if horizontal adjacent match exists', () => { + const grid = [ + [2, 4, 8, 16], + [32, 64, 128, 256], + [512, 1024, 2048, 2], + [4, 8, 8, 32], + ]; + expect(canMove(grid)).toBe(true); + }); + + it('should return true if vertical adjacent match exists', () => { + const grid = [ + [2, 4, 8, 16], + [32, 64, 128, 256], + [512, 1024, 2048, 2], + [4, 8, 16, 2], + ]; + expect(canMove(grid)).toBe(true); + }); + + it('should return false if board full with no matches', () => { + const grid = [ + [2, 4, 8, 16], + [32, 64, 128, 256], + [512, 1024, 2048, 2], + [4, 8, 16, 32], + ]; + expect(canMove(grid)).toBe(false); + }); +}); + +describe('isGameOver', () => { + it('should return false for a fresh game', () => { + const state = initGame(); + expect(isGameOver(state)).toBe(false); + }); + + it('should return true for full board with no moves', () => { + const state = { + grid: [ + [2, 4, 8, 16], + [32, 64, 128, 256], + [512, 1024, 2048, 2], + [4, 8, 16, 32], + ], + score: 0, + won: false, + keepPlaying: false, + }; + expect(isGameOver(state)).toBe(true); + }); +}); + +describe('win detection', () => { + it('should set won=true when 2048 is created', () => { + const state = { + grid: [ + [1024, 1024, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + score: 0, + won: false, + keepPlaying: false, + }; + const result = move(state, DIRECTIONS.LEFT); + expect(result.won).toBe(true); + expect(result.grid[0][0]).toBe(2048); + }); + + it('should not re-trigger won if already won', () => { + const state = { + grid: [ + [1024, 1024, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + score: 0, + won: true, + keepPlaying: false, + }; + const result = move(state, DIRECTIONS.LEFT); + expect(result.won).toBe(true); + }); + + it('should not trigger won when keepPlaying is true', () => { + const state = { + grid: [ + [1024, 1024, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + score: 0, + won: false, + keepPlaying: true, + }; + const result = move(state, DIRECTIONS.LEFT); + expect(result.won).toBe(false); + }); +});