From 916d04d0d9f579bd80b8d33c2f73c7e15e36ac92 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 4 Jul 2026 11:32:01 +0200 Subject: [PATCH] feat(mnswpr): first-move safety --- packages/mnswpr/core/minesweeper/board.js | 29 +++++++++++-- packages/mnswpr/test/core.test.js | 51 +++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/packages/mnswpr/core/minesweeper/board.js b/packages/mnswpr/core/minesweeper/board.js index 6a3202b..dd1d5a2 100644 --- a/packages/mnswpr/core/minesweeper/board.js +++ b/packages/mnswpr/core/minesweeper/board.js @@ -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 }} [options] + * @param {{ rng?: () => number, seed?: number, exclude?: Set, 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 = [] diff --git a/packages/mnswpr/test/core.test.js b/packages/mnswpr/test/core.test.js index a93505e..715b979 100644 --- a/packages/mnswpr/test/core.test.js +++ b/packages/mnswpr/test/core.test.js @@ -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)