feat(core): board injection
This commit is contained in:
parent
916d04d0d9
commit
8099820e79
5 changed files with 215 additions and 7 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue