refactor: core generateBoard function
This commit is contained in:
parent
5a7eea3e12
commit
d7a9f61a9e
3 changed files with 125 additions and 3 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue