diff --git a/packages/mnswpr/core/index.js b/packages/mnswpr/core/index.js index c0e4672..6b67c4d 100644 --- a/packages/mnswpr/core/index.js +++ b/packages/mnswpr/core/index.js @@ -17,8 +17,9 @@ export { GameSession } from './session/session.js' export { replay } from './session/replay.js' export { mulberry32, randInt } from './session/rng.js' -// Layer 2 — Minesweeper rules +// Layer 2 — Minesweeper rules & pure board generation export { MinesweeperRules } from './minesweeper/rules.js' +export { generateBoard } from './minesweeper/board.js' // Shared level presets (also consumed by the DOM client) export { levels } from '../levels.js' diff --git a/packages/mnswpr/core/minesweeper/board.js b/packages/mnswpr/core/minesweeper/board.js index 47a5d29..6a3202b 100644 --- a/packages/mnswpr/core/minesweeper/board.js +++ b/packages/mnswpr/core/minesweeper/board.js @@ -1,12 +1,18 @@ // @ts-check +import { Grid } from '../grid/grid.js' import { mulberry32, randInt } from '../session/rng.js' import { eightWay } from '../grid/neighbors.js' /** * @typedef {{ rows: number, cols: number, mines: number, id?: string }} Config * @typedef {{ mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }} Cell + * @typedef {{ mine: boolean, adjacent: number }} LayoutCell + * @typedef {{ rows: number, cols: number, mines: number, cells: LayoutCell[][], mineLocations: [number, number][] }} Layout */ +/** Shared empty exclude set for generation without first-click safety. */ +const NO_EXCLUDE = new Set() + /** * The set of cells kept mine-free for first-click safety: the clicked cell, plus * its 8 neighbors when the board has room for all mines outside that 3x3. Falls @@ -34,6 +40,7 @@ export function excludeAround(config, r, c) { /** * Deterministically place mines and compute adjacency counts, mutating the grid * in place. Pure function of (seed, config, exclude) — same inputs, same board. + * Thin convenience wrapper over {@link fillMines} that builds the RNG from a seed. * * @param {number} seed * @param {Config} config @@ -42,7 +49,21 @@ export function excludeAround(config, r, c) { * @returns {Set} the mined coordinate keys */ export function placeMines(seed, config, exclude, grid) { - const rng = mulberry32(seed) + return fillMines(mulberry32(seed), config, exclude, grid) +} + +/** + * The injected-RNG seam under {@link placeMines}: place mines and compute + * adjacency counts, mutating the grid in place. Takes an rng function (any + * `() => [0, 1)`), so callers own determinism — same rng sequence, same board. + * + * @param {() => number} rng + * @param {Config} config + * @param {Set} exclude - coordinate keys never to mine (first-click safety) + * @param {import('../grid/grid.js').Grid} grid + * @returns {Set} the mined coordinate keys + */ +export function fillMines(rng, config, exclude, grid) { const { rows, cols, mines } = config const placed = new Set() while (placed.size < mines) { @@ -61,3 +82,50 @@ export function placeMines(seed, config, exclude, grid) { }) return placed } + +/** + * Pure, Node-runnable board generation: given a size, a mine count, and an + * injected RNG, produce a plain layout object — no DOM, no I/O, no `Grid` class + * leaking out. This is the headless entry point behind `@ayo-run/mnswpr/core`; + * the DOM client reaches the same generator lazily through `MinesweeperRules`. + * + * The injected `rng` is the determinism seam: the same rng sequence always + * yields the same layout. `seed` is a convenience — when no `rng` is given it is + * wrapped with {@link mulberry32}, keeping generation reproducible and free of + * `Math.random` (invariant #4). + * + * @param {number} rows - number of rows (board height) + * @param {number} cols - number of columns (board width) + * @param {number} mines - number of mines to place + * @param {{ rng?: () => number, seed?: number, exclude?: Set }} [options] + * @returns {Layout} a plain, serializable layout object + */ +export function generateBoard(rows, cols, mines, { rng, seed = 0, exclude = NO_EXCLUDE } = {}) { + if (!Number.isInteger(rows) || !Number.isInteger(cols) || rows < 1 || cols < 1) { + throw new RangeError(`generateBoard: rows/cols must be positive integers (got ${rows}x${cols})`) + } + const capacity = rows * cols - exclude.size + if (!Number.isInteger(mines) || mines < 0 || mines > capacity) { + throw new RangeError(`generateBoard: mines must be an integer in [0, ${capacity}] (got ${mines})`) + } + + const config = { rows, cols, mines } + const grid = new Grid(rows, cols, () => ({ mine: false, adjacent: 0, status: 'hidden' })) + fillMines(rng ?? mulberry32(seed), config, exclude, grid) + + /** @type {LayoutCell[][]} */ + const cells = [] + /** @type {[number, number][]} */ + const mineLocations = [] + for (let r = 0; r < rows; r++) { + /** @type {LayoutCell[]} */ + const row = [] + for (let c = 0; c < cols; c++) { + const cell = grid.at(r, c) + row.push({ mine: cell.mine, adjacent: cell.adjacent }) + if (cell.mine) mineLocations.push([r, c]) + } + cells.push(row) + } + return { rows, cols, mines, cells, mineLocations } +} diff --git a/packages/mnswpr/test/core.test.js b/packages/mnswpr/test/core.test.js index 7a37855..a93505e 100644 --- a/packages/mnswpr/test/core.test.js +++ b/packages/mnswpr/test/core.test.js @@ -6,7 +6,7 @@ import { dirname, join } from 'node:path' import { Grid, eightWay, orthogonal, GameSession, replay, mulberry32, - MinesweeperRules, levels + MinesweeperRules, generateBoard, levels } from '../core/index.js' import { placeMines, excludeAround } from '../core/minesweeper/board.js' @@ -95,6 +95,59 @@ describe('board generation (Layer 2)', () => { }) }) +describe('generateBoard (Layer 2, pure)', () => { + it('is callable in plain Node and returns a plain layout object', () => { + // Non-square (rows ≠ cols) pins the rows-first orientation. + const layout = generateBoard(4, 7, 5, { seed: 42 }) + expect(layout.rows).toBe(4) + expect(layout.cols).toBe(7) + expect(layout.mines).toBe(5) + expect(layout.cells).toHaveLength(4) + expect(layout.cells.every(row => row.length === 7)).toBe(true) + // plain data only — no Grid instance, no class methods leaking out + expect(layout.cells[0][0]).toEqual({ mine: expect.any(Boolean), adjacent: expect.any(Number) }) + expect(layout).toEqual(JSON.parse(JSON.stringify(layout))) + // places exactly `mines` mines + expect(layout.mineLocations).toHaveLength(5) + let mineCount = 0 + for (const row of layout.cells) for (const cell of row) if (cell.mine) mineCount++ + expect(mineCount).toBe(5) + }) + + it('same injected RNG (seed) → identical layout', () => { + const a = generateBoard(16, 16, 40, { rng: mulberry32(123) }) + const b = generateBoard(16, 16, 40, { rng: mulberry32(123) }) + expect(a).toEqual(b) + // and the seed convenience wrapper matches an explicitly injected mulberry32 + expect(generateBoard(16, 16, 40, { seed: 123 })).toEqual(a) + // a different seed diverges + expect(generateBoard(16, 16, 40, { seed: 124 })).not.toEqual(a) + }) + + it('computes adjacency as the 8-way mine count', () => { + const layout = generateBoard(9, 9, 10, { seed: 99 }) + const g = new Grid(9, 9, (r, c) => layout.cells[r][c]) + layout.cells.forEach((row, r) => row.forEach((cell, c) => { + if (cell.mine) return + let n = 0 + for (const [nr, nc] of eightWay(g, r, c)) if (g.at(nr, nc).mine) n++ + expect(cell.adjacent).toBe(n) + })) + }) + + it('honors an exclude set (e.g. first-click safety)', () => { + const exclude = new Set([0]) // key 0 == cell (0,0) + const layout = generateBoard(9, 9, 10, { seed: 7, exclude }) + expect(layout.cells[0][0].mine).toBe(false) + }) + + it('rejects impossible dimensions and mine counts', () => { + expect(() => generateBoard(0, 9, 1)).toThrow(RangeError) + expect(() => generateBoard(3, 3, 10)).toThrow(RangeError) // more mines than cells + expect(() => generateBoard(3, 3, -1)).toThrow(RangeError) + }) +}) + describe('rules (Layer 2)', () => { it('first reveal is never a mine and opens a region', () => { const s = MinesweeperRules.init(3, beginner)