refactor: core generateBoard function

This commit is contained in:
ayo 2026-07-04 11:24:25 +02:00
parent 5a7eea3e12
commit d7a9f61a9e
3 changed files with 125 additions and 3 deletions

View file

@ -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'

View file

@ -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<number>} 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<number>} exclude - coordinate keys never to mine (first-click safety)
* @param {import('../grid/grid.js').Grid<Cell>} grid
* @returns {Set<number>} 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<number> }} [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 }
}

View file

@ -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)