diff --git a/packages/mnswpr/core/index.js b/packages/mnswpr/core/index.js index 6b67c4d..d99384a 100644 --- a/packages/mnswpr/core/index.js +++ b/packages/mnswpr/core/index.js @@ -19,7 +19,7 @@ export { mulberry32, randInt } from './session/rng.js' // Layer 2 — Minesweeper rules & pure board generation export { MinesweeperRules } from './minesweeper/rules.js' -export { generateBoard } from './minesweeper/board.js' +export { generateBoard, validateLayout } from './minesweeper/board.js' // Shared level presets (also consumed by the DOM client) export { levels } from '../levels.js' diff --git a/packages/mnswpr/core/minesweeper/board.js b/packages/mnswpr/core/minesweeper/board.js index dd1d5a2..65a4789 100644 --- a/packages/mnswpr/core/minesweeper/board.js +++ b/packages/mnswpr/core/minesweeper/board.js @@ -83,6 +83,66 @@ export function fillMines(rng, config, exclude, grid) { return placed } +/** + * Assert a plain layout object (as produced by {@link generateBoard}) is + * well-formed before it's injected into a game: correct dimensions, cell shape, + * a mine count that matches its own cells, and in-bounds mine positions. Throws a + * clear error on the first problem so a malformed board can't silently corrupt + * win detection or adjacency. Returns the layout for convenient chaining. + * + * @param {unknown} layout + * @returns {Layout} + */ +export function validateLayout(layout) { + if (layout === null || typeof layout !== 'object') { + throw new TypeError(`validateLayout: expected a layout object (got ${layout === null ? 'null' : typeof layout})`) + } + const { rows, cols, mines, cells, mineLocations } = /** @type {any} */ (layout) + if (!Number.isInteger(rows) || !Number.isInteger(cols) || rows < 1 || cols < 1) { + throw new RangeError(`validateLayout: rows/cols must be positive integers (got ${rows}x${cols})`) + } + if (!Array.isArray(cells) || cells.length !== rows) { + throw new RangeError(`validateLayout: cells must have ${rows} rows (got ${Array.isArray(cells) ? cells.length : typeof cells})`) + } + let mineCount = 0 + for (let r = 0; r < rows; r++) { + const row = cells[r] + if (!Array.isArray(row) || row.length !== cols) { + throw new RangeError(`validateLayout: row ${r} must have ${cols} cells (got ${Array.isArray(row) ? row.length : typeof row})`) + } + for (let c = 0; c < cols; c++) { + const cell = row[c] + if (cell === null || typeof cell !== 'object' || typeof cell.mine !== 'boolean' || !Number.isInteger(cell.adjacent)) { + throw new TypeError(`validateLayout: cell (${r},${c}) must be { mine: boolean, adjacent: integer }`) + } + if (cell.mine) mineCount++ + } + } + if (!Number.isInteger(mines) || mines < 0 || mines > rows * cols) { + throw new RangeError(`validateLayout: mines must be an integer in [0, ${rows * cols}] (got ${mines})`) + } + if (mineCount !== mines) { + throw new RangeError(`validateLayout: mines (${mines}) disagrees with ${mineCount} mined cells`) + } + // mineLocations is optional, but if present it must agree with the grid — the + // "dimensions vs. mine positions" cross-check. + if (mineLocations !== undefined) { + if (!Array.isArray(mineLocations) || mineLocations.length !== mineCount) { + throw new RangeError(`validateLayout: mineLocations must list all ${mineCount} mines (got ${Array.isArray(mineLocations) ? mineLocations.length : typeof mineLocations})`) + } + for (const loc of mineLocations) { + if (!Array.isArray(loc) || loc.length !== 2) { + throw new TypeError(`validateLayout: each mineLocation must be a [r, c] pair (got ${JSON.stringify(loc)})`) + } + const [r, c] = loc + if (!Number.isInteger(r) || !Number.isInteger(c) || r < 0 || c < 0 || r >= rows || c >= cols || !cells[r][c].mine) { + throw new RangeError(`validateLayout: mineLocation [${r}, ${c}] is out of bounds or not a mined cell`) + } + } + } + return /** @type {Layout} */ (layout) +} + /** * 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 diff --git a/packages/mnswpr/core/minesweeper/rules.js b/packages/mnswpr/core/minesweeper/rules.js index e1b0eea..e68c3f5 100644 --- a/packages/mnswpr/core/minesweeper/rules.js +++ b/packages/mnswpr/core/minesweeper/rules.js @@ -1,7 +1,7 @@ // @ts-check import { Grid } from '../grid/grid.js' import { eightWay } from '../grid/neighbors.js' -import { placeMines, excludeAround } from './board.js' +import { placeMines, excludeAround, validateLayout } from './board.js' import { floodReveal, countFlagsAround, allMines } from './reveal.js' /** @@ -35,11 +35,16 @@ function reveal(state, r, c) { // First reveal: generate the board now, excluding this cell's neighborhood, so // the opening click is always safe and the seed fully determines the layout. + // Injected boards (fromLayout) arrive with minesPlaced already true and skip + // this — their opening reveal plays exactly as the layout dictates. if (!state.minesPlaced) { placeMines(state.seed, state.config, excludeAround(state.config, r, c), state.grid) state.minesPlaced = true - state.phase = 'active' } + // fresh → active on the first real reveal, whether the board was just generated + // or injected pre-built — decoupled from placement so both paths transition the + // same way. + if (state.phase === 'fresh') state.phase = 'active' if (cell.mine) { cell.status = 'revealed' @@ -140,6 +145,36 @@ export const MinesweeperRules = { } }, + /** + * Build a game state from an explicit, pre-built layout (as returned by + * `generateBoard`) instead of generating one from a seed. Parallel to + * {@link init}: it yields a `State` a `GameSession` can drive identically — + * same rules, same transitions — the only difference being that the board is + * fixed up front, so the opening reveal is NOT made safe (first-click safety is + * a property of internal generation, not of a caller-supplied board). The + * layout is validated first and a malformed one throws. + * + * @param {import('./board.js').Layout} layout + * @param {{ seed?: number }} [opts] - seed is metadata only (no generation happens); defaults to 0 + * @returns {State} + */ + fromLayout(layout, { seed = 0 } = {}) { + validateLayout(layout) + const { rows, cols, mines } = layout + return { + seed, + config: { rows, cols, mines }, + grid: new Grid(rows, cols, (r, c) => ({ + mine: layout.cells[r][c].mine, + adjacent: layout.cells[r][c].adjacent, + status: 'hidden' + })), + phase: 'fresh', + minesPlaced: true, + revealedSafe: 0 + } + }, + /** @param {State} state @returns {Phase} */ status(state) { return state.phase diff --git a/packages/mnswpr/core/session/session.js b/packages/mnswpr/core/session/session.js index eba12ef..5b0c41d 100644 --- a/packages/mnswpr/core/session/session.js +++ b/packages/mnswpr/core/session/session.js @@ -11,13 +11,18 @@ */ export class GameSession { /** + * Start from either `{ seed, config }` (the rules generate the board) or a + * pre-built `{ state }` (e.g. a rules factory that injected an explicit board); + * `state` wins when both are given. The session stays generic — it just holds + * whatever state the rules produced. + * * @param {Rules} rules - * @param {{ seed: number, config: object, clock?: () => number }} opts + * @param {{ seed?: number, config?: object, state?: object, clock?: () => number }} opts */ - constructor(rules, { seed, config, clock = () => 0 }) { + constructor(rules, { seed, config, state, clock = () => 0 }) { this.rules = rules this.clock = clock - this.state = rules.init(seed, config) + this.state = state ?? rules.init(seed, config) /** @type {Array<{ move: object, t: number }>} */ this._log = [] this._t0 = null diff --git a/packages/mnswpr/test/core.test.js b/packages/mnswpr/test/core.test.js index 715b979..3187c26 100644 --- a/packages/mnswpr/test/core.test.js +++ b/packages/mnswpr/test/core.test.js @@ -6,7 +6,7 @@ import { dirname, join } from 'node:path' import { Grid, eightWay, orthogonal, GameSession, replay, mulberry32, - MinesweeperRules, generateBoard, levels + MinesweeperRules, generateBoard, validateLayout, levels } from '../core/index.js' import { placeMines, excludeAround } from '../core/minesweeper/board.js' @@ -247,6 +247,114 @@ describe('rules (Layer 2)', () => { }) }) +describe('board injection (fromLayout, Layer 2)', () => { + // A hand-built 3x3 with a single mine at (0,0). Adjacency: the mine's three + // neighbors (0,1),(1,0),(1,1) are 1; everything else is 0. + const knownLayout = () => ({ + rows: 3, + cols: 3, + mines: 1, + cells: [ + [{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }], + [{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }], + [{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }] + ], + mineLocations: [[0, 0]] + }) + + it('creates a game instance from an injected layout and scripts moves to known states', () => { + const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(knownLayout()) }) + expect(session.status()).toBe('fresh') + + // Reveal a numbered (non-zero) cell: it activates the game and reveals just + // itself, carrying the layout's adjacency — proof the injected board is live. + const first = session.applyMove({ type: 'reveal', r: 0, c: 1 }) + expect(session.status()).toBe('active') + expect(first.events[0].cells).toEqual([{ r: 0, c: 1, adjacent: 1 }]) + + // Stepping on the injected mine loses and reports it (before any flood wins). + const boom = session.applyMove({ type: 'reveal', r: 0, c: 0 }) + expect(session.status()).toBe('lost') + expect(boom.events[0]).toMatchObject({ type: 'explode', r: 0, c: 0 }) + expect(boom.events[0].mines).toEqual([{ r: 0, c: 0 }]) + }) + + it('a zero cell floods the connected safe region on an injected board', () => { + const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(knownLayout()) }) + // (2,2) is a zero far from the mine; flood opens all 8 safe cells → win. + const flood = session.applyMove({ type: 'reveal', r: 2, c: 2 }) + expect(flood.events[0].cells.length).toBe(8) + expect(session.status()).toBe('won') + }) + + it('an injected board can be played to a win, every safe cell revealed', () => { + const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(knownLayout()) }) + // Reveal all 8 non-mine cells; avoid (0,0). + for (let r = 0; r < 3; r++) { + for (let c = 0; c < 3; c++) { + if (r === 0 && c === 0) continue + session.applyMove({ type: 'reveal', r, c }) + } + } + expect(session.status()).toBe('won') + }) + + it('plays identically to a generated board: injecting a generated layout reproduces its play', () => { + // Generate a board, then inject that exact layout. Revealing any cell must + // report the layout's own adjacency, and mines must sit where the layout says. + const layout = generateBoard(9, 9, 10, { seed: 42, safeCell: { r: 4, c: 4 } }) + const state = MinesweeperRules.fromLayout(layout) + const { events } = MinesweeperRules.apply(state, { type: 'reveal', r: 4, c: 4 }) + expect(state.phase).toBe('active') + // (4,4) was forced safe; its revealed adjacency matches the layout. + const seen = events[0].cells.find(cell => cell.r === 4 && cell.c === 4) + expect(seen.adjacent).toBe(layout.cells[4][4].adjacent) + // The mines are exactly the layout's mines. + const mines = [] + state.grid.forEach((cell, r, c) => { if (cell.mine) mines.push([r, c]) }) + expect(mines.sort()).toEqual([...layout.mineLocations].sort()) + }) + + it('two sessions from the same layout with the same script transition identically', () => { + const script = [{ type: 'reveal', r: 2, c: 2 }, { type: 'flag', r: 0, c: 0 }, { type: 'reveal', r: 0, c: 1 }] + const run = () => { + const s = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(knownLayout()) }) + return script.map(m => s.applyMove(m).view) + } + expect(run()).toEqual(run()) + }) + + it('rejects malformed layouts with a clear error', () => { + expect(() => MinesweeperRules.fromLayout(null)).toThrow(TypeError) + expect(() => MinesweeperRules.fromLayout('nope')).toThrow(TypeError) + // dimensions don't match the cells grid + expect(() => MinesweeperRules.fromLayout({ ...knownLayout(), rows: 4 })).toThrow(RangeError) + // mine count disagrees with the actual mined cells + expect(() => MinesweeperRules.fromLayout({ ...knownLayout(), mines: 2 })).toThrow(RangeError) + // a cell missing its shape + const badCells = knownLayout() + badCells.cells[1][1] = { mine: false } // no `adjacent` + expect(() => MinesweeperRules.fromLayout(badCells)).toThrow(TypeError) + // mineLocations pointing at a non-mined cell + const badLocs = knownLayout() + badLocs.mineLocations = [[2, 2]] + expect(() => MinesweeperRules.fromLayout(badLocs)).toThrow(RangeError) + }) + + it('validateLayout accepts a freshly generated board', () => { + expect(() => validateLayout(generateBoard(16, 16, 40, { seed: 3 }))).not.toThrow() + }) + + it('leaves existing seed-based construction unaffected', () => { + // No `state` supplied ⇒ the session still generates from seed/config as before. + const session = new GameSession(MinesweeperRules, { seed: 3, config: beginner }) + const { events } = session.applyMove({ type: 'reveal', r: 0, c: 0 }) + expect(session.status()).toBe('active') + expect(session.state.grid.at(0, 0).mine).toBe(false) // first-click safety intact + expect(events[0].cells.length).toBeGreaterThan(1) + }) +}) + describe('session + timing (Layer 1)', () => { it('reports authoritative elapsed time from the injected clock', () => { const clock = makeClock()