feat: implement game logic module with 38 tests (Story 1.2)

Pure JS game engine with initGame, move, canMove, isGameOver.
Implements leading-edge merge order, once-per-move rule, 90/10 tile
spawning, win detection (2048), and immutable state updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 21:41:30 +07:00
co-authored by Claude Opus 4.6
parent 0bdf7aea61
commit d2899c4fd4
4 changed files with 708 additions and 2 deletions
@@ -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)
@@ -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
+180
View File
@@ -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);
}
+406 -1
View File
@@ -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);
});
});