import { describe, expect, it } from 'vitest' import { canMove, createGame, createGameFromGrid, highestTile, move, toValueGrid, } from './engine.js' import { nextInt, nextState } from './rng.js' import type { Direction, GameState } from './types.js' /** Apply a sequence of moves, returning the final state. */ function play(state: GameState, directions: Direction[]): GameState { return directions.reduce((current, direction) => move(current, direction).state, state) } /** * A board with no spawns to get in the way: `createGameFromGrid` plus a single * move still spawns one tile, so assertions compare the cells we care about * rather than the whole grid where a spawn could land. */ const board = (values: number[][], seed = 12345) => createGameFromGrid(values, { seed }) describe('createGame', () => { it('starts with two tiles', () => { const game = createGame({ seed: 8675309 }) expect(game.tiles).toHaveLength(2) }) it('starts every tile as a 2 or a 4', () => { for (let i = 0; i < 50; i++) { const game = createGame({ seed: 1000 + i }) for (const tile of game.tiles) { expect([2, 4]).toContain(tile.value) } } }) it('starts with a zero score, no moves, not won and not over', () => { const game = createGame({ seed: 8675309 }) expect(game.score).toBe(0) expect(game.moves).toBe(0) expect(game.won).toBe(false) expect(game.over).toBe(false) }) it('honours a custom size and starting tile count', () => { const game = createGame({ seed: 8675309, size: 5, startingTiles: 3 }) expect(game.size).toBe(5) expect(game.tiles).toHaveLength(3) expect(toValueGrid(game)).toHaveLength(5) }) it('rejects a board smaller than 2', () => { expect(() => createGame({ size: 1 })).toThrow(RangeError) }) it('gives every tile a unique id', () => { const game = createGame({ seed: 8675309, size: 4, startingTiles: 4 }) const ids = new Set(game.tiles.map((tile) => tile.id)) expect(ids.size).toBe(game.tiles.length) }) }) describe('determinism', () => { it('produces an identical opening board for the same seed', () => { const a = createGame({ seed: 20260904 }) const b = createGame({ seed: 20260904 }) expect(toValueGrid(a)).toEqual(toValueGrid(b)) }) it('produces identical boards after the same moves', () => { const moves: Direction[] = ['left', 'up', 'right', 'down', 'left', 'left', 'up'] const a = play(createGame({ seed: 20260904 }), moves) const b = play(createGame({ seed: 20260904 }), moves) expect(toValueGrid(a)).toEqual(toValueGrid(b)) expect(a.score).toBe(b.score) expect(a.moves).toBe(b.moves) }) it('produces different boards for different seeds', () => { const grids = new Set( Array.from({ length: 12 }, (_, i) => JSON.stringify(toValueGrid(createGame({ seed: 7919 * (i + 1) })))), ) // Collisions are possible but 12 identical openings would mean broken seeding. expect(grids.size).toBeGreaterThan(1) }) it('advances rng state on every spawn', () => { const game = createGame({ seed: 8675309 }) const after = move(game, 'left') if (after.moved) expect(after.state.rngState).not.toBe(game.rngState) }) }) describe('sliding', () => { it('slides a lone tile to the left wall', () => { const state = board([ [0, 0, 0, 2], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const result = move(state, 'left') expect(result.moved).toBe(true) expect(toValueGrid(result.state)[0]?.[0]).toBe(2) }) it('slides right, up and down correctly', () => { const values = [ [2, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ] expect(toValueGrid(move(board(values), 'right').state)[0]?.[3]).toBe(2) expect(toValueGrid(move(board(values), 'down').state)[3]?.[0]).toBe(2) const bottom = [ [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [2, 0, 0, 0], ] expect(toValueGrid(move(board(bottom), 'up').state)[0]?.[0]).toBe(2) }) it('stacks tiles against the wall in order', () => { const state = board([ [0, 2, 0, 4], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const grid = toValueGrid(move(state, 'left').state) expect(grid[0]?.[0]).toBe(2) expect(grid[0]?.[1]).toBe(4) }) }) describe('merging', () => { it('merges two equal tiles into their double', () => { const state = board([ [2, 2, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const result = move(state, 'left') expect(toValueGrid(result.state)[0]?.[0]).toBe(4) expect(result.merged).toEqual([4]) expect(result.gained).toBe(4) expect(result.state.score).toBe(4) }) it('merges across a gap', () => { const state = board([ [2, 0, 2, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const result = move(state, 'left') expect(toValueGrid(result.state)[0]?.[0]).toBe(4) expect(result.merged).toEqual([4]) }) it('merges each tile at most once per move', () => { const state = board([ [2, 2, 2, 2], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const result = move(state, 'left') const row = toValueGrid(result.state)[0] expect(row?.[0]).toBe(4) expect(row?.[1]).toBe(4) expect(result.merged).toEqual([4, 4]) expect(result.gained).toBe(8) }) it('resolves the pair nearest the wall first', () => { const state = board([ [4, 4, 2, 2], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const result = move(state, 'left') const row = toValueGrid(result.state)[0] expect(row?.[0]).toBe(8) expect(row?.[1]).toBe(4) }) it('does not merge unequal neighbours', () => { const state = board([ [2, 4, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const result = move(state, 'left') expect(result.merged).toEqual([]) expect(result.gained).toBe(0) }) it('merges down a column', () => { const state = board([ [2, 0, 0, 0], [2, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const result = move(state, 'down') expect(toValueGrid(result.state)[3]?.[0]).toBe(4) }) }) describe('spawning', () => { it('adds exactly one tile after a move that changed the board', () => { const state = board([ [2, 2, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const result = move(state, 'left') // Two tiles merged into one, plus one spawn. expect(result.state.tiles).toHaveLength(2) }) it('does not spawn when the move changed nothing', () => { const state = board([ [2, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const result = move(state, 'left') expect(result.moved).toBe(false) expect(result.state).toBe(state) expect(result.state.tiles).toHaveLength(1) expect(result.state.moves).toBe(0) }) it('spawns roughly three 2s for every 4', () => { let twos = 0 let fours = 0 for (let i = 0; i < 400; i++) { const game = createGame({ seed: 104729 * (i + 1), startingTiles: 1 }) const value = game.tiles[0]?.value if (value === 2) twos++ if (value === 4) fours++ } expect(twos + fours).toBe(400) // 75/25, the shared format's rule, not the 90/10 of standard 2048. A wide // band, since this asserts the rule rather than the generator. expect(fours / 400).toBeGreaterThan(0.15) expect(fours / 400).toBeLessThan(0.35) }) }) describe('tile identity', () => { it('records where a tile moved from', () => { const state = board([ [0, 0, 0, 2], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const result = move(state, 'left') const moved = result.state.tiles.find((tile) => tile.previous !== null) expect(moved?.previous).toEqual({ row: 0, col: 3 }) expect(moved?.row).toBe(0) expect(moved?.col).toBe(0) }) it('keeps a tile id stable across a move', () => { const state = board([ [0, 0, 0, 2], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const original = state.tiles[0] const result = move(state, 'left') const moved = result.state.tiles.find((tile) => tile.previous !== null) expect(moved?.id).toBe(original?.id) }) it('records both sources of a merge with their pre-move positions', () => { const state = board([ [2, 0, 2, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const result = move(state, 'left') const mergedTile = result.state.tiles.find((tile) => tile.mergedFrom !== null) expect(mergedTile?.value).toBe(4) const sources = mergedTile?.mergedFrom expect(sources).toHaveLength(2) const columns = sources?.map((tile) => tile.col).sort() expect(columns).toEqual([0, 2]) }) it('gives a merged tile a fresh id, not a reused one', () => { const state = board([ [2, 2, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const beforeIds = new Set(state.tiles.map((tile) => tile.id)) const result = move(state, 'left') const mergedTile = result.state.tiles.find((tile) => tile.mergedFrom !== null) expect(mergedTile).toBeDefined() expect(beforeIds.has(mergedTile?.id ?? -1)).toBe(false) }) it('clears stale animation metadata on the next move', () => { const state = board([ [0, 0, 0, 2], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const first = move(state, 'left') const second = move(first.state, 'down') // Nothing may still claim to have merged two moves ago. for (const tile of second.state.tiles) { if (tile.mergedFrom) { expect(tile.mergedFrom).toHaveLength(2) } } /* A vertical move can only change a tile's row, so any `previous` naming a different column is left over from the horizontal move before it. This used to assert that nothing carried previous = (0,3), the cell the first move emptied. That passed by luck: it only held while no spawn happened to land there, and a tile genuinely starting at (0,3) and moving down is correct rather than stale. */ const stale = second.state.tiles.filter( (tile) => tile.previous !== null && tile.previous.col !== tile.col, ) expect(stale).toEqual([]) }) }) describe('immutability', () => { it('never mutates the state it was given', () => { const state = board([ [2, 2, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const snapshot = JSON.stringify(toValueGrid(state)) move(state, 'left') expect(JSON.stringify(toValueGrid(state))).toBe(snapshot) expect(state.score).toBe(0) expect(state.moves).toBe(0) }) it('supports undo by keeping the previous state', () => { const start = board([ [2, 2, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const after = move(start, 'left').state expect(after.score).toBe(4) // "Undo" is just putting the old value back — the engine owns no stack. expect(start.score).toBe(0) expect(toValueGrid(start)[0]?.[1]).toBe(2) }) }) describe('game over', () => { it('is over when the board is full with no equal neighbours', () => { const state = board([ [2, 4, 2, 4], [4, 2, 4, 2], [2, 4, 2, 4], [4, 2, 4, 2], ]) expect(state.over).toBe(true) expect(canMove(state)).toBe(false) }) it('is not over when a full board still has a mergeable pair', () => { const state = board([ [2, 2, 4, 8], [4, 8, 16, 32], [8, 16, 32, 64], [16, 32, 64, 128], ]) expect(state.over).toBe(false) expect(canMove(state)).toBe(true) }) it('is not over while an empty cell remains', () => { const state = board([ [2, 4, 8, 16], [4, 8, 16, 32], [8, 16, 32, 64], [16, 32, 64, 0], ]) expect(state.over).toBe(false) }) it('refuses moves once over', () => { const state = board([ [2, 4, 2, 4], [4, 2, 4, 2], [2, 4, 2, 4], [4, 2, 4, 2], ]) const result = move(state, 'left') expect(result.moved).toBe(false) expect(result.state).toBe(state) }) it('detects a vertical pair as a legal move', () => { const state = board([ [2, 4, 8, 16], [2, 8, 16, 32], [8, 16, 32, 64], [16, 32, 64, 128], ]) expect(canMove(state)).toBe(true) }) }) describe('winning', () => { it('sets won when a tile reaches 2048', () => { const state = board([ [1024, 1024, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) expect(state.won).toBe(false) const result = move(state, 'left') expect(result.state.won).toBe(true) }) it('stays won after further moves', () => { const state = board([ [1024, 1024, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) const after = play(state, ['left', 'down', 'right']) expect(after.won).toBe(true) }) it('honours a custom winning value', () => { const state = createGameFromGrid( [ [4, 4, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ], { seed: 12345, winningValue: 8 }, ) expect(move(state, 'left').state.won).toBe(true) }) }) describe('helpers', () => { it('reports the highest tile', () => { const state = board([ [2, 4, 0, 0], [0, 128, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) expect(highestTile(state)).toBe(128) }) it('reports 0 for an empty board', () => { const state = board([ [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], ]) expect(highestTile(state)).toBe(0) }) it('rejects a non-square grid', () => { expect(() => createGameFromGrid([[2, 2], [2]])).toThrow(RangeError) }) }) describe('rng', () => { it('is stable for a given seed', () => { expect(nextState(12345)).toBe(nextState(12345)) expect(nextState(1)).not.toBe(nextState(2)) }) it('stays inside the generator’s modulus', () => { let state = 20260904 for (let i = 0; i < 5000; i++) { state = nextState(state) expect(Number.isInteger(state)).toBe(true) expect(state).toBeGreaterThanOrEqual(0) expect(state).toBeLessThan(2147483647) } }) it('bounds nextInt below max', () => { let state = 99 for (let i = 0; i < 1000; i++) { const result = nextInt(state, 16) expect(result.value).toBeGreaterThanOrEqual(0) expect(result.value).toBeLessThan(16) state = result.state } }) it('does not immediately repeat itself', () => { let state = 777 const seen = new Set() for (let i = 0; i < 1000; i++) { state = nextState(state) seen.add(state) } expect(seen.size).toBeGreaterThan(990) }) })