feat(mnswpr): first-move safety

This commit is contained in:
ayo 2026-07-04 11:32:01 +02:00
parent d7a9f61a9e
commit 916d04d0d9
2 changed files with 76 additions and 4 deletions

View file

@ -94,24 +94,45 @@ export function fillMines(rng, config, exclude, grid) {
* wrapped with {@link mulberry32}, keeping generation reproducible and free of
* `Math.random` (invariant #4).
*
* First-move safety: pass `safeCell: { r, c }` to guarantee that cell is never a
* mine the coordinate-friendly front door to the low-level `exclude` set, so
* callers don't have to know the `r * cols + c` key encoding. It merges with any
* `exclude` given, and the capacity check below rejects layouts where the mines
* can't fit once it's carved out. For 3x3 first-click *flood* safety (the clicked
* cell plus its 8 neighbors), see {@link excludeAround}.
*
* @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]
* @param {{ rng?: () => number, seed?: number, exclude?: Set<number>, safeCell?: { r: number, c: number } }} [options]
* @returns {Layout} a plain, serializable layout object
*/
export function generateBoard(rows, cols, mines, { rng, seed = 0, exclude = NO_EXCLUDE } = {}) {
export function generateBoard(rows, cols, mines, { rng, seed = 0, exclude = NO_EXCLUDE, safeCell } = {}) {
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
// Resolve the first-move-safe cell into the exclude set (non-mutating: never
// touch a caller-owned set). An out-of-bounds safeCell fails loudly rather than
// silently excluding nothing and handing back a board that could mine it.
let excludeSet = exclude
if (safeCell !== undefined) {
const { r, c } = safeCell
if (!Number.isInteger(r) || !Number.isInteger(c) || r < 0 || c < 0 || r >= rows || c >= cols) {
throw new RangeError(`generateBoard: safeCell must be an in-bounds { r, c } (got { r: ${r}, c: ${c} } on ${rows}x${cols})`)
}
excludeSet = new Set(exclude)
excludeSet.add(r * cols + c)
}
const capacity = rows * cols - excludeSet.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)
fillMines(rng ?? mulberry32(seed), config, excludeSet, grid)
/** @type {LayoutCell[][]} */
const cells = []

View file

@ -148,6 +148,57 @@ describe('generateBoard (Layer 2, pure)', () => {
})
})
describe('generateBoard first-move-safe (safeCell)', () => {
it('never mines the safe cell across randomized runs (property-style)', () => {
// Dense board so the safe cell is a demanding constraint, swept over many
// injected-RNG streams — the guarantee must hold for every seed, not one.
for (let seed = 0; seed < 200; seed++) {
const layout = generateBoard(9, 9, 70, { rng: mulberry32(seed), safeCell: { r: 4, c: 5 } })
expect(layout.cells[4][5].mine).toBe(false)
// and it's a genuine constraint on top of a correct board: exact mine count
expect(layout.mineLocations).toHaveLength(70)
}
})
it('holds at edge coordinates — every corner and a border cell', () => {
const corners = [{ r: 0, c: 0 }, { r: 0, c: 8 }, { r: 8, c: 0 }, { r: 8, c: 8 }]
const borders = [{ r: 0, c: 4 }, { r: 4, c: 0 }, { r: 8, c: 4 }, { r: 4, c: 8 }]
for (const safeCell of [...corners, ...borders]) {
const layout = generateBoard(9, 9, 70, { seed: 123, safeCell })
expect(layout.cells[safeCell.r][safeCell.c].mine).toBe(false)
}
})
it('generates a max-density board (mines = cells 1) with the one safe cell blank', () => {
// 3x3 with 8 mines: every cell except the safe one must be a mine.
const layout = generateBoard(3, 3, 8, { seed: 5, safeCell: { r: 1, c: 1 } })
expect(layout.mineLocations).toHaveLength(8)
expect(layout.cells[1][1].mine).toBe(false)
let mines = 0
for (const row of layout.cells) for (const cell of row) if (cell.mine) mines++
expect(mines).toBe(8)
})
it('merges with an existing exclude set rather than replacing it', () => {
const exclude = new Set([0]) // key 0 == cell (0,0)
const layout = generateBoard(5, 5, 23, { seed: 9, exclude, safeCell: { r: 4, c: 4 } })
expect(layout.cells[0][0].mine).toBe(false) // from exclude
expect(layout.cells[4][4].mine).toBe(false) // from safeCell
expect(layout.mineLocations).toHaveLength(23) // 25 2 excluded
})
it('rejects configurations where the mines cannot fit with the safe cell excluded', () => {
// 3x3 = 9 cells, one reserved for safety ⇒ capacity 8; 9 mines can't fit.
expect(() => generateBoard(3, 3, 9, { safeCell: { r: 0, c: 0 } })).toThrow(RangeError)
})
it('rejects an out-of-bounds or non-integer safe cell', () => {
expect(() => generateBoard(9, 9, 10, { safeCell: { r: 9, c: 0 } })).toThrow(RangeError)
expect(() => generateBoard(9, 9, 10, { safeCell: { r: -1, c: 0 } })).toThrow(RangeError)
expect(() => generateBoard(9, 9, 10, { safeCell: { r: 0, c: 1.5 } })).toThrow(RangeError)
})
})
describe('rules (Layer 2)', () => {
it('first reveal is never a mine and opens a region', () => {
const s = MinesweeperRules.init(3, beginner)