mirror of
https://github.com/tiennm99/try-bmad.git
synced 2026-08-05 06:23:06 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user