feat(mnswpr): first-move safety
This commit is contained in:
parent
d7a9f61a9e
commit
916d04d0d9
2 changed files with 76 additions and 4 deletions
|
|
@ -94,24 +94,45 @@ export function fillMines(rng, config, exclude, grid) {
|
||||||
* wrapped with {@link mulberry32}, keeping generation reproducible and free of
|
* wrapped with {@link mulberry32}, keeping generation reproducible and free of
|
||||||
* `Math.random` (invariant #4).
|
* `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} rows - number of rows (board height)
|
||||||
* @param {number} cols - number of columns (board width)
|
* @param {number} cols - number of columns (board width)
|
||||||
* @param {number} mines - number of mines to place
|
* @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
|
* @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) {
|
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})`)
|
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) {
|
if (!Number.isInteger(mines) || mines < 0 || mines > capacity) {
|
||||||
throw new RangeError(`generateBoard: mines must be an integer in [0, ${capacity}] (got ${mines})`)
|
throw new RangeError(`generateBoard: mines must be an integer in [0, ${capacity}] (got ${mines})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = { rows, cols, mines }
|
const config = { rows, cols, mines }
|
||||||
const grid = new Grid(rows, cols, () => ({ mine: false, adjacent: 0, status: 'hidden' }))
|
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[][]} */
|
/** @type {LayoutCell[][]} */
|
||||||
const cells = []
|
const cells = []
|
||||||
|
|
|
||||||
|
|
@ -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)', () => {
|
describe('rules (Layer 2)', () => {
|
||||||
it('first reveal is never a mine and opens a region', () => {
|
it('first reveal is never a mine and opens a region', () => {
|
||||||
const s = MinesweeperRules.init(3, beginner)
|
const s = MinesweeperRules.init(3, beginner)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue