feat: mnswpr core
This commit is contained in:
parent
7cbf9baaa8
commit
042d6b26e9
13 changed files with 1285 additions and 0 deletions
62
packages/mnswpr/core/grid/grid.js
Normal file
62
packages/mnswpr/core/grid/grid.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer 0 — a dense 2D container of opaque cells. Knows nothing about game
|
||||||
|
* meaning (no "mine", no "reveal"). Future home: @cozy-games/grid.
|
||||||
|
*
|
||||||
|
* @template Cell
|
||||||
|
*/
|
||||||
|
export class Grid {
|
||||||
|
/**
|
||||||
|
* @param {number} rows
|
||||||
|
* @param {number} cols
|
||||||
|
* @param {(r: number, c: number) => Cell} [fill] - factory for each cell
|
||||||
|
*/
|
||||||
|
constructor(rows, cols, fill) {
|
||||||
|
this.rows = rows
|
||||||
|
this.cols = cols
|
||||||
|
/** @type {Cell[]} */
|
||||||
|
this._cells = new Array(rows * cols)
|
||||||
|
if (fill) {
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
this._cells[r * cols + c] = fill(r, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
inBounds(r, c) {
|
||||||
|
return r >= 0 && c >= 0 && r < this.rows && c < this.cols
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Cell} */
|
||||||
|
at(r, c) {
|
||||||
|
return this._cells[r * this.cols + c]
|
||||||
|
}
|
||||||
|
|
||||||
|
set(r, c, cell) {
|
||||||
|
this._cells[r * this.cols + c] = cell
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {(cell: Cell, r: number, c: number) => void} fn */
|
||||||
|
forEach(fn) {
|
||||||
|
for (let r = 0; r < this.rows; r++) {
|
||||||
|
for (let c = 0; c < this.cols; c++) {
|
||||||
|
fn(this.at(r, c), r, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template Out
|
||||||
|
* @param {(cell: Cell, r: number, c: number) => Out} fn
|
||||||
|
* @returns {Grid<Out>}
|
||||||
|
*/
|
||||||
|
map(fn) {
|
||||||
|
const out = new Grid(this.rows, this.cols)
|
||||||
|
this.forEach((cell, r, c) => out.set(r, c, fn(cell, r, c)))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
44
packages/mnswpr/core/grid/neighbors.js
Normal file
44
packages/mnswpr/core/grid/neighbors.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neighbor STRATEGIES — the extraction seam. A game injects the topology it
|
||||||
|
* wants; the grid layer never assumes one. Minesweeper uses `eightWay`; a future
|
||||||
|
* Sudoku would inject its row/col/box `peers`. Each returns in-bounds [r, c]
|
||||||
|
* coordinate pairs.
|
||||||
|
*
|
||||||
|
* @typedef {{ inBounds: (r: number, c: number) => boolean }} Boundable
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All 8 surrounding cells (orthogonal + diagonal).
|
||||||
|
* @param {Boundable} grid
|
||||||
|
* @returns {Array<[number, number]>}
|
||||||
|
*/
|
||||||
|
export function eightWay(grid, r, c) {
|
||||||
|
const out = []
|
||||||
|
for (let dr = -1; dr <= 1; dr++) {
|
||||||
|
for (let dc = -1; dc <= 1; dc++) {
|
||||||
|
if (dr === 0 && dc === 0) continue
|
||||||
|
const nr = r + dr
|
||||||
|
const nc = c + dc
|
||||||
|
if (grid.inBounds(nr, nc)) out.push([nr, nc])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 4 orthogonal cells (N/E/S/W).
|
||||||
|
* @param {Boundable} grid
|
||||||
|
* @returns {Array<[number, number]>}
|
||||||
|
*/
|
||||||
|
export function orthogonal(grid, r, c) {
|
||||||
|
const out = []
|
||||||
|
const deltas = [[-1, 0], [1, 0], [0, -1], [0, 1]]
|
||||||
|
for (const [dr, dc] of deltas) {
|
||||||
|
const nr = r + dr
|
||||||
|
const nc = c + dc
|
||||||
|
if (grid.inBounds(nr, nc)) out.push([nr, nc])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
33
packages/mnswpr/core/grid/serialize.js
Normal file
33
packages/mnswpr/core/grid/serialize.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// @ts-check
|
||||||
|
import { Grid } from './grid.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain-JSON serialization of a Grid. Used by the move log / server persistence
|
||||||
|
* (Layer 1) and by replay. Cells must themselves be JSON-serializable.
|
||||||
|
*
|
||||||
|
* @template Cell
|
||||||
|
* @param {Grid<Cell>} grid
|
||||||
|
* @returns {{ rows: number, cols: number, cells: Cell[] }}
|
||||||
|
*/
|
||||||
|
export function toJSON(grid) {
|
||||||
|
const cells = []
|
||||||
|
grid.forEach(cell => cells.push(cell))
|
||||||
|
return { rows: grid.rows, cols: grid.cols, cells }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template Cell
|
||||||
|
* @param {{ rows: number, cols: number, cells: Cell[] }} data
|
||||||
|
* @param {(cell: Cell) => Cell} [reviveCell]
|
||||||
|
* @returns {Grid<Cell>}
|
||||||
|
*/
|
||||||
|
export function fromJSON(data, reviveCell) {
|
||||||
|
const grid = new Grid(data.rows, data.cols)
|
||||||
|
for (let r = 0; r < data.rows; r++) {
|
||||||
|
for (let c = 0; c < data.cols; c++) {
|
||||||
|
const cell = data.cells[r * data.cols + c]
|
||||||
|
grid.set(r, c, reviveCell ? reviveCell(cell) : cell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return grid
|
||||||
|
}
|
||||||
24
packages/mnswpr/core/index.js
Normal file
24
packages/mnswpr/core/index.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `@ayo-run/mnswpr/core` — the headless, isomorphic Minesweeper core. No DOM, no
|
||||||
|
* wall clock. Runs identically in a browser (offline play) or on a server
|
||||||
|
* (authoritative timing / replay verification). See
|
||||||
|
* docs/headless-core-and-client-design.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Layer 0 — generic grid (future @cozy-games/grid)
|
||||||
|
export { Grid } from './grid/grid.js'
|
||||||
|
export { eightWay, orthogonal } from './grid/neighbors.js'
|
||||||
|
export { toJSON, fromJSON } from './grid/serialize.js'
|
||||||
|
|
||||||
|
// Layer 1 — generic session & authority (future @cozy-games/game-session)
|
||||||
|
export { GameSession } from './session/session.js'
|
||||||
|
export { replay } from './session/replay.js'
|
||||||
|
export { mulberry32, randInt } from './session/rng.js'
|
||||||
|
|
||||||
|
// Layer 2 — Minesweeper rules
|
||||||
|
export { MinesweeperRules } from './minesweeper/rules.js'
|
||||||
|
|
||||||
|
// Shared level presets (also consumed by the DOM client)
|
||||||
|
export { levels } from '../levels.js'
|
||||||
63
packages/mnswpr/core/minesweeper/board.js
Normal file
63
packages/mnswpr/core/minesweeper/board.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
// @ts-check
|
||||||
|
import { mulberry32, randInt } from '../session/rng.js'
|
||||||
|
import { eightWay } from '../grid/neighbors.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ rows: number, cols: number, mines: number, id?: string }} Config
|
||||||
|
* @typedef {{ mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }} Cell
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set of cells kept mine-free for first-click safety: the clicked cell, plus
|
||||||
|
* its 8 neighbors when the board has room for all mines outside that 3x3. Falls
|
||||||
|
* back to just the clicked cell on boards too dense to spare the neighborhood.
|
||||||
|
*
|
||||||
|
* @param {Config} config
|
||||||
|
* @returns {Set<number>} coordinate keys (r * cols + c)
|
||||||
|
*/
|
||||||
|
export function excludeAround(config, r, c) {
|
||||||
|
const { rows, cols, mines } = config
|
||||||
|
const set = new Set([r * cols + c])
|
||||||
|
const roomFor3x3 = rows * cols - 9 >= mines
|
||||||
|
if (roomFor3x3) {
|
||||||
|
for (let dr = -1; dr <= 1; dr++) {
|
||||||
|
for (let dc = -1; dc <= 1; dc++) {
|
||||||
|
const nr = r + dr
|
||||||
|
const nc = c + dc
|
||||||
|
if (nr >= 0 && nc >= 0 && nr < rows && nc < cols) set.add(nr * cols + nc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministically place mines and compute adjacency counts, mutating the grid
|
||||||
|
* in place. Pure function of (seed, config, exclude) — same inputs, same board.
|
||||||
|
*
|
||||||
|
* @param {number} seed
|
||||||
|
* @param {Config} config
|
||||||
|
* @param {Set<number>} exclude - coordinate keys never to mine (first-click safety)
|
||||||
|
* @param {import('../grid/grid.js').Grid<Cell>} grid
|
||||||
|
* @returns {Set<number>} the mined coordinate keys
|
||||||
|
*/
|
||||||
|
export function placeMines(seed, config, exclude, grid) {
|
||||||
|
const rng = mulberry32(seed)
|
||||||
|
const { rows, cols, mines } = config
|
||||||
|
const placed = new Set()
|
||||||
|
while (placed.size < mines) {
|
||||||
|
const key = randInt(rng, rows) * cols + randInt(rng, cols)
|
||||||
|
if (placed.has(key) || exclude.has(key)) continue
|
||||||
|
placed.add(key)
|
||||||
|
}
|
||||||
|
grid.forEach((cell, r, c) => { cell.mine = placed.has(r * cols + c) })
|
||||||
|
grid.forEach((cell, r, c) => {
|
||||||
|
if (cell.mine) { cell.adjacent = 0; return }
|
||||||
|
let n = 0
|
||||||
|
for (const [nr, nc] of eightWay(grid, r, c)) {
|
||||||
|
if (grid.at(nr, nc).mine) n++
|
||||||
|
}
|
||||||
|
cell.adjacent = n
|
||||||
|
})
|
||||||
|
return placed
|
||||||
|
}
|
||||||
64
packages/mnswpr/core/minesweeper/reveal.js
Normal file
64
packages/mnswpr/core/minesweeper/reveal.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
// @ts-check
|
||||||
|
import { eightWay } from '../grid/neighbors.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./board.js').Cell} Cell
|
||||||
|
* @typedef {import('../grid/grid.js').Grid<Cell>} MineGrid
|
||||||
|
* @typedef {{ r: number, c: number, adjacent: number }} RevealedCell
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal starting at (r, c) and flood-fill outward while cells are blank (zero
|
||||||
|
* adjacent mines), stopping at numbers and flags. Mutates cell statuses; returns
|
||||||
|
* the newly-revealed cells (never includes already-revealed/flagged cells, so
|
||||||
|
* callers can count these as fresh safe reveals). The start cell must be a known
|
||||||
|
* non-mine, hidden cell.
|
||||||
|
*
|
||||||
|
* @param {MineGrid} grid
|
||||||
|
* @returns {RevealedCell[]}
|
||||||
|
*/
|
||||||
|
export function floodReveal(grid, startR, startC) {
|
||||||
|
/** @type {RevealedCell[]} */
|
||||||
|
const revealed = []
|
||||||
|
const start = grid.at(startR, startC)
|
||||||
|
start.status = 'revealed'
|
||||||
|
revealed.push({ r: startR, c: startC, adjacent: start.adjacent })
|
||||||
|
|
||||||
|
const queue = [[startR, startC]]
|
||||||
|
while (queue.length) {
|
||||||
|
const [r, c] = queue.shift()
|
||||||
|
// Only blank cells propagate; numbers are a boundary.
|
||||||
|
if (grid.at(r, c).adjacent !== 0) continue
|
||||||
|
for (const [nr, nc] of eightWay(grid, r, c)) {
|
||||||
|
const n = grid.at(nr, nc)
|
||||||
|
if (n.status === 'revealed' || n.status === 'flagged') continue
|
||||||
|
// A blank cell has no adjacent mines, so its neighbors are all safe.
|
||||||
|
n.status = 'revealed'
|
||||||
|
revealed.push({ r: nr, c: nc, adjacent: n.adjacent })
|
||||||
|
if (n.adjacent === 0) queue.push([nr, nc])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return revealed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {MineGrid} grid
|
||||||
|
* @returns {number} count of flagged cells around (r, c)
|
||||||
|
*/
|
||||||
|
export function countFlagsAround(grid, r, c) {
|
||||||
|
let flags = 0
|
||||||
|
for (const [nr, nc] of eightWay(grid, r, c)) {
|
||||||
|
if (grid.at(nr, nc).status === 'flagged') flags++
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {MineGrid} grid
|
||||||
|
* @returns {Array<{ r: number, c: number }>} every mined coordinate
|
||||||
|
*/
|
||||||
|
export function allMines(grid) {
|
||||||
|
const out = []
|
||||||
|
grid.forEach((cell, r, c) => { if (cell.mine) out.push({ r, c }) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
165
packages/mnswpr/core/minesweeper/rules.js
Normal file
165
packages/mnswpr/core/minesweeper/rules.js
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
// @ts-check
|
||||||
|
import { Grid } from '../grid/grid.js'
|
||||||
|
import { eightWay } from '../grid/neighbors.js'
|
||||||
|
import { placeMines, excludeAround } from './board.js'
|
||||||
|
import { floodReveal, countFlagsAround, allMines } from './reveal.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minesweeper as a pure, deterministic state machine — no DOM, no wall clock.
|
||||||
|
* `GameSession` (Layer 1) drives it; the client renders the events it emits.
|
||||||
|
*
|
||||||
|
* @typedef {import('./board.js').Config} Config
|
||||||
|
* @typedef {import('./board.js').Cell} Cell
|
||||||
|
* @typedef {'fresh' | 'active' | 'won' | 'lost'} Phase
|
||||||
|
* @typedef {{ seed: number, config: Config, grid: Grid<Cell>, phase: Phase, minesPlaced: boolean, revealedSafe: number }} State
|
||||||
|
* @typedef {{ type: 'reveal', r: number, c: number } | { type: 'flag', r: number, c: number } | { type: 'chord', r: number, c: number }} Move
|
||||||
|
* @typedef {object} Event
|
||||||
|
*/
|
||||||
|
|
||||||
|
const freshCell = () => ({ mine: false, adjacent: 0, status: 'hidden' })
|
||||||
|
|
||||||
|
/** @param {State} state */
|
||||||
|
function isWin(state) {
|
||||||
|
const { rows, cols, mines } = state.config
|
||||||
|
return state.revealedSafe >= rows * cols - mines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal a single cell (with first-click safety and flood-fill).
|
||||||
|
* @param {State} state
|
||||||
|
* @returns {{ state: State, events: Event[] }}
|
||||||
|
*/
|
||||||
|
function reveal(state, r, c) {
|
||||||
|
const cell = state.grid.at(r, c)
|
||||||
|
if (!cell || cell.status !== 'hidden') return { state, events: [] }
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
if (!state.minesPlaced) {
|
||||||
|
placeMines(state.seed, state.config, excludeAround(state.config, r, c), state.grid)
|
||||||
|
state.minesPlaced = true
|
||||||
|
state.phase = 'active'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell.mine) {
|
||||||
|
cell.status = 'revealed'
|
||||||
|
state.phase = 'lost'
|
||||||
|
return { state, events: [{ type: 'explode', r, c, mines: allMines(state.grid) }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const revealed = floodReveal(state.grid, r, c)
|
||||||
|
state.revealedSafe += revealed.length
|
||||||
|
/** @type {Event[]} */
|
||||||
|
const events = [{ type: 'reveal', cells: revealed }]
|
||||||
|
if (isWin(state)) { state.phase = 'won'; events.push({ type: 'win' }) }
|
||||||
|
return { state, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a flag on a hidden cell.
|
||||||
|
* @param {State} state
|
||||||
|
* @returns {{ state: State, events: Event[] }}
|
||||||
|
*/
|
||||||
|
function flag(state, r, c) {
|
||||||
|
const cell = state.grid.at(r, c)
|
||||||
|
if (!cell || cell.status === 'revealed') return { state, events: [] }
|
||||||
|
cell.status = cell.status === 'flagged' ? 'hidden' : 'flagged'
|
||||||
|
return { state, events: [{ type: 'flag', r, c, flagged: cell.status === 'flagged' }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chord: on a revealed number whose adjacent flags equal its value, reveal every
|
||||||
|
* non-flagged neighbor (any of which may be a mine → loss).
|
||||||
|
* @param {State} state
|
||||||
|
* @returns {{ state: State, events: Event[] }}
|
||||||
|
*/
|
||||||
|
function chord(state, r, c) {
|
||||||
|
const { grid } = state
|
||||||
|
const cell = grid.at(r, c)
|
||||||
|
if (!cell || cell.status !== 'revealed' || cell.adjacent === 0) return { state, events: [] }
|
||||||
|
if (countFlagsAround(grid, r, c) !== cell.adjacent) return { state, events: [] }
|
||||||
|
|
||||||
|
/** @type {import('./reveal.js').RevealedCell[]} */
|
||||||
|
const revealedCells = []
|
||||||
|
for (const [nr, nc] of eightWay(grid, r, c)) {
|
||||||
|
const n = grid.at(nr, nc)
|
||||||
|
if (n.status !== 'hidden') continue
|
||||||
|
if (n.mine) {
|
||||||
|
n.status = 'revealed'
|
||||||
|
state.phase = 'lost'
|
||||||
|
return { state, events: [{ type: 'explode', r: nr, c: nc, mines: allMines(grid) }] }
|
||||||
|
}
|
||||||
|
for (const rev of floodReveal(grid, nr, nc)) revealedCells.push(rev)
|
||||||
|
}
|
||||||
|
state.revealedSafe += revealedCells.length
|
||||||
|
|
||||||
|
/** @type {Event[]} */
|
||||||
|
const events = []
|
||||||
|
if (revealedCells.length) events.push({ type: 'reveal', cells: revealedCells })
|
||||||
|
if (isWin(state)) { state.phase = 'won'; events.push({ type: 'win' }) }
|
||||||
|
return { state, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project full state down to what a client is allowed to know: revealed cells
|
||||||
|
* (+ their adjacency), flags, and — only once the game is over — the mines. An
|
||||||
|
* unrevealed mine is NEVER included mid-game, so this is safe to send over a wire
|
||||||
|
* (invariant #3). Hidden, unrevealed, non-mine cells are simply omitted.
|
||||||
|
*
|
||||||
|
* @param {State} state
|
||||||
|
*/
|
||||||
|
function project(state) {
|
||||||
|
const terminal = state.phase === 'won' || state.phase === 'lost'
|
||||||
|
const cells = []
|
||||||
|
state.grid.forEach((cell, r, c) => {
|
||||||
|
if (cell.status === 'revealed') cells.push({ r, c, status: 'revealed', adjacent: cell.adjacent, mine: cell.mine })
|
||||||
|
else if (cell.status === 'flagged') cells.push({ r, c, status: 'flagged' })
|
||||||
|
else if (terminal && cell.mine) cells.push({ r, c, status: 'hidden', mine: true })
|
||||||
|
})
|
||||||
|
return { config: state.config, phase: state.phase, cells }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GameRules contract consumed by GameSession/replay: init / apply / status /
|
||||||
|
* project. Deterministic and DOM-free.
|
||||||
|
*/
|
||||||
|
export const MinesweeperRules = {
|
||||||
|
/**
|
||||||
|
* @param {number} seed
|
||||||
|
* @param {Config} config
|
||||||
|
* @returns {State}
|
||||||
|
*/
|
||||||
|
init(seed, config) {
|
||||||
|
return {
|
||||||
|
seed,
|
||||||
|
config,
|
||||||
|
grid: new Grid(config.rows, config.cols, freshCell),
|
||||||
|
phase: 'fresh',
|
||||||
|
minesPlaced: false,
|
||||||
|
revealedSafe: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {State} state @returns {Phase} */
|
||||||
|
status(state) {
|
||||||
|
return state.phase
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold a move into the state. Terminal states are absorbing.
|
||||||
|
* @param {State} state
|
||||||
|
* @param {Move} move
|
||||||
|
* @returns {{ state: State, events: Event[] }}
|
||||||
|
*/
|
||||||
|
apply(state, move) {
|
||||||
|
if (state.phase === 'won' || state.phase === 'lost') return { state, events: [] }
|
||||||
|
switch (move.type) {
|
||||||
|
case 'reveal': return reveal(state, move.r, move.c)
|
||||||
|
case 'flag': return flag(state, move.r, move.c)
|
||||||
|
case 'chord': return chord(state, move.r, move.c)
|
||||||
|
default: return { state, events: [] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
project
|
||||||
|
}
|
||||||
41
packages/mnswpr/core/session/replay.js
Normal file
41
packages/mnswpr/core/session/replay.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministically re-run a submitted game from `{ seed, config, log }` and
|
||||||
|
* report the authoritative outcome. A server calls this at submit time to verify
|
||||||
|
* a score without trusting the client: it recomputes the result and time from the
|
||||||
|
* moves, and rejects logs that don't terminate or whose timestamps aren't
|
||||||
|
* monotonic. (The "verifiable-replay" anti-cheat path — no live server needed.)
|
||||||
|
*
|
||||||
|
* @param {{ init: Function, apply: Function, status: Function }} rules
|
||||||
|
* @param {{ seed: number, config: object, log: Array<{ move: object, t: number }> }} submission
|
||||||
|
* @returns {{ status: string, time: number, valid: boolean, reason: string | null }}
|
||||||
|
*/
|
||||||
|
export function replay(rules, { seed, config, log }) {
|
||||||
|
let state = rules.init(seed, config)
|
||||||
|
let monotonic = true
|
||||||
|
let prevT = -Infinity
|
||||||
|
let t0 = null
|
||||||
|
let tEnd = null
|
||||||
|
|
||||||
|
for (const { move, t } of log) {
|
||||||
|
if (t < prevT) monotonic = false
|
||||||
|
prevT = t
|
||||||
|
const before = rules.status(state)
|
||||||
|
state = rules.apply(state, move).state
|
||||||
|
const after = rules.status(state)
|
||||||
|
if (before === 'fresh' && after !== 'fresh' && t0 === null) t0 = t
|
||||||
|
if ((after === 'won' || after === 'lost') && tEnd === null) tEnd = t
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = rules.status(state)
|
||||||
|
const terminal = status === 'won' || status === 'lost'
|
||||||
|
const time = t0 !== null && tEnd !== null ? tEnd - t0 : 0
|
||||||
|
const valid = monotonic && terminal
|
||||||
|
const reason = valid
|
||||||
|
? null
|
||||||
|
: !monotonic
|
||||||
|
? 'non-monotonic timestamps'
|
||||||
|
: 'log does not reach a terminal state'
|
||||||
|
return { status, time, valid, reason }
|
||||||
|
}
|
||||||
29
packages/mnswpr/core/session/rng.js
Normal file
29
packages/mnswpr/core/session/rng.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic, seedable PRNG (mulberry32). A given seed always yields the same
|
||||||
|
* sequence — this is what makes board generation reproducible and `replay()`
|
||||||
|
* possible. Uses `Math.imul` (integer math), NOT `Math.random`, so it stays
|
||||||
|
* inside the determinism guard.
|
||||||
|
*
|
||||||
|
* @param {number} seed - any 32-bit integer
|
||||||
|
* @returns {() => number} a function returning floats in [0, 1)
|
||||||
|
*/
|
||||||
|
export function mulberry32(seed) {
|
||||||
|
let a = seed >>> 0
|
||||||
|
return function () {
|
||||||
|
a = (a + 0x6D2B79F5) | 0
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integer in [0, n) from an rng function.
|
||||||
|
* @param {() => number} rng
|
||||||
|
* @param {number} n
|
||||||
|
*/
|
||||||
|
export function randInt(rng, n) {
|
||||||
|
return Math.floor(rng() * n)
|
||||||
|
}
|
||||||
72
packages/mnswpr/core/session/session.js
Normal file
72
packages/mnswpr/core/session/session.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer 1 — owns lifecycle, the (injected) clock, and the move log; delegates all
|
||||||
|
* game meaning to an injected `rules` object. This is where timing authority
|
||||||
|
* lives: on the client `clock` is `Date.now` (cosmetic); on a server it is the
|
||||||
|
* server's clock (authoritative). The session never calls a wall clock itself.
|
||||||
|
* Future home: @cozy-games/game-session.
|
||||||
|
*
|
||||||
|
* @typedef {{ init: Function, apply: Function, status: Function, project: Function }} Rules
|
||||||
|
*/
|
||||||
|
export class GameSession {
|
||||||
|
/**
|
||||||
|
* @param {Rules} rules
|
||||||
|
* @param {{ seed: number, config: object, clock?: () => number }} opts
|
||||||
|
*/
|
||||||
|
constructor(rules, { seed, config, clock = () => 0 }) {
|
||||||
|
this.rules = rules
|
||||||
|
this.clock = clock
|
||||||
|
this.state = rules.init(seed, config)
|
||||||
|
/** @type {Array<{ move: object, t: number }>} */
|
||||||
|
this._log = []
|
||||||
|
this._t0 = null
|
||||||
|
this._tEnd = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a move: stamp it, fold it through the rules, and return the projected
|
||||||
|
* view + events + authoritative elapsed time.
|
||||||
|
* @param {object} move
|
||||||
|
*/
|
||||||
|
applyMove(move) {
|
||||||
|
const t = this.clock()
|
||||||
|
const before = this.rules.status(this.state)
|
||||||
|
const { state, events } = this.rules.apply(this.state, move)
|
||||||
|
this.state = state
|
||||||
|
this._log.push({ move, t })
|
||||||
|
|
||||||
|
const after = this.rules.status(state)
|
||||||
|
// Timer starts on the first move that leaves 'fresh' (the opening reveal).
|
||||||
|
if (before === 'fresh' && after !== 'fresh' && this._t0 === null) this._t0 = t
|
||||||
|
if ((after === 'won' || after === 'lost') && this._tEnd === null) this._tEnd = t
|
||||||
|
|
||||||
|
return { events, view: this.rules.project(state), time: this.elapsed() }
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
return this.rules.status(this.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
view() {
|
||||||
|
return this.rules.project(this.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
return this._log.slice()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authoritative elapsed ms: first move → terminal move (or → now if ongoing). */
|
||||||
|
elapsed() {
|
||||||
|
if (this._t0 === null) return 0
|
||||||
|
const end = this._tEnd !== null ? this._tEnd : this.clock()
|
||||||
|
return end - this._t0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The signed-off result, or null while the game is still in progress. */
|
||||||
|
result() {
|
||||||
|
const status = this.status()
|
||||||
|
if (status !== 'won' && status !== 'lost') return null
|
||||||
|
return { status, time: this.elapsed(), seed: this.state.seed, config: this.state.config, log: this.log() }
|
||||||
|
}
|
||||||
|
}
|
||||||
433
packages/mnswpr/docs/headless-core-and-client-design.md
Normal file
433
packages/mnswpr/docs/headless-core-and-client-design.md
Normal file
|
|
@ -0,0 +1,433 @@
|
||||||
|
# Headless core + client design — `@ayo-run/mnswpr/core` and the client that consumes it
|
||||||
|
|
||||||
|
Design for splitting the Minesweeper engine into a **headless, isomorphic core**
|
||||||
|
(runs identically in a browser or on a server) and a **thin client** that renders
|
||||||
|
it. The core is internally layered so the generic bottom can later be lifted out
|
||||||
|
into `@cozy-games/grid` + `@cozy-games/game-session` once a second game (Sudoku)
|
||||||
|
exists to validate the abstraction. We are **not** building the generic grid
|
||||||
|
engine now — only structuring for it.
|
||||||
|
|
||||||
|
Goals, in priority order:
|
||||||
|
|
||||||
|
1. **Headless core** — game state is a plain data model, no DOM, no wall clock.
|
||||||
|
2. **Server-authoritative capable** — the same core can run on a server that owns
|
||||||
|
the RNG, the clock, and the move sequence, so leaderboard times are witnessed,
|
||||||
|
not claimed. (Closes the score-spoofing gap from the security review.)
|
||||||
|
3. **Backwards compatible** — the existing DOM UI, CSS, and jsdom tests keep
|
||||||
|
working; today's offline play is just "the core with a local transport."
|
||||||
|
4. **Extraction-ready** — a clean seam between generic (grid/session) and
|
||||||
|
Minesweeper-specific (mines/reveal) code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Package layout & the seam
|
||||||
|
|
||||||
|
**Decision (settled): one published package, core exposed as a sub-path.** Open-
|
||||||
|
source consumers keep installing a single `@ayo-run/mnswpr` and get the headless
|
||||||
|
core for free at `@ayo-run/mnswpr/core` — no second package to publish, version,
|
||||||
|
or document. The DOM client stays the default entry (`.`).
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/mnswpr/ # @ayo-run/mnswpr — ONE published package
|
||||||
|
mnswpr.js # "." → DOM client (browser entry; today's default)
|
||||||
|
levels.js # shared by client + core (level presets)
|
||||||
|
core/ # "./core" → headless, isomorphic, ZERO DOM, ZERO wall-clock
|
||||||
|
index.js # the sub-path entry — public core API
|
||||||
|
grid/ # Layer 0 → future @cozy-games/grid
|
||||||
|
grid.js # Grid<Cell> container, coords, inBounds
|
||||||
|
neighbors.js # neighbor STRATEGIES (eightWay, orthogonal, …)
|
||||||
|
serialize.js
|
||||||
|
session/ # Layer 1 → future @cozy-games/game-session
|
||||||
|
session.js # GameSession: lifecycle, injected clock, move log
|
||||||
|
rng.js # seedable deterministic PRNG (mulberry32)
|
||||||
|
replay.js # replay(rules, {seed, config, log}) → verify
|
||||||
|
minesweeper/ # Layer 2 → Minesweeper-specific rules
|
||||||
|
rules.js # GameRules impl: init/apply/status/project
|
||||||
|
board.js # deterministic board gen + first-click safety
|
||||||
|
reveal.js # flood-fill, chording
|
||||||
|
client/ # DOM client internals (consume ./core) — added in migration
|
||||||
|
renderer.js # events → DOM (the ONLY place document is touched)
|
||||||
|
input-adapter.js # gestures → Move intents
|
||||||
|
transport.js # LocalTransport (RemoteTransport added later)
|
||||||
|
timer-view.js
|
||||||
|
```
|
||||||
|
|
||||||
|
`package.json` `exports` (sub-path is the only surface change consumers see):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"exports": {
|
||||||
|
".": { "default": "./dist/mnswpr.js" }, // DOM client (browser) — unchanged default
|
||||||
|
"./core": { "default": "./core/index.js" }, // headless, isomorphic core (source ESM)
|
||||||
|
"./dist/*": { "default": "./dist/*" },
|
||||||
|
"./*": { "default": "./*" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `import mnswpr from '@ayo-run/mnswpr'` → DOM client, exactly as today.
|
||||||
|
- `import { GameSession, MinesweeperRules } from '@ayo-run/mnswpr/core'` → headless.
|
||||||
|
The core entry pulls in **zero DOM**, so a server (later) imports it without
|
||||||
|
dragging in browser code, and the browser bundle for `.` never includes the
|
||||||
|
core's server-only helpers.
|
||||||
|
|
||||||
|
**The seam** = the boundary *inside* `core/` between `grid/` + `session/`
|
||||||
|
(generic) and `minesweeper/` (specific). Exposing the core via a sub-path does
|
||||||
|
**not** compromise the future extraction — the publish surface and the internal
|
||||||
|
layering are orthogonal. Rules of the seam:
|
||||||
|
|
||||||
|
- `grid/` and `session/` **never import** `minesweeper/`.
|
||||||
|
- `minesweeper/` depends on `grid/` + `session/` only through their public
|
||||||
|
interfaces (below) — no reaching into internals.
|
||||||
|
- Anything Minesweeper-specific that leaks *down* (8-way adjacency, "mine",
|
||||||
|
"reveal") is a bug in the layering. Adjacency is **injected**, not assumed.
|
||||||
|
|
||||||
|
When Sudoku lands, `core/grid/` and `core/session/` move out verbatim into
|
||||||
|
`@cozy-games/grid` + `@cozy-games/game-session`; `core/minesweeper/` (and a new
|
||||||
|
`sudoku/`) depend on them, and `@ayo-run/mnswpr/core` re-exports from them so the
|
||||||
|
consumer-facing sub-path is unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Layer 0 — generic grid (`grid/`)
|
||||||
|
|
||||||
|
A dumb, dense 2D container of opaque cells. Knows nothing about game meaning.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Coord is a plain [r, c] tuple everywhere (cheap, serializable).
|
||||||
|
// Grid<Cell> — Cell is opaque to this layer.
|
||||||
|
class Grid {
|
||||||
|
constructor(rows, cols, fill) // fill: (r, c) => Cell
|
||||||
|
get rows(); get cols()
|
||||||
|
at(r, c) // → Cell (throws/undefined out of bounds)
|
||||||
|
set(r, c, cell)
|
||||||
|
inBounds(r, c) // → boolean
|
||||||
|
forEach(fn) // fn(cell, r, c)
|
||||||
|
map(fn) // → Grid of new cells
|
||||||
|
clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neighbor STRATEGY — the critical extraction seam. Injected, never baked in.
|
||||||
|
// eightWay is Minesweeper's; Sudoku would inject `peers` (row ∪ col ∪ box).
|
||||||
|
const eightWay = (grid, r, c) => [...8 in-bounds diagonal+orthogonal coords]
|
||||||
|
const orthogonal = (grid, r, c) => [...4 in-bounds N/E/S/W coords]
|
||||||
|
```
|
||||||
|
|
||||||
|
`serialize.js`: `toJSON(grid)` / `fromJSON(data, reviveCell)` — used by the
|
||||||
|
session for the move log and by the server for persistence/replay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Layer 1 — generic session & authority (`session/`)
|
||||||
|
|
||||||
|
The most reusable layer, and the one that makes server authority possible. It
|
||||||
|
owns **lifecycle, time, and the move log**, and delegates game meaning to an
|
||||||
|
injected `GameRules` object.
|
||||||
|
|
||||||
|
### The rules contract (what any game implements)
|
||||||
|
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* @template State, Move, Event
|
||||||
|
* A pure, deterministic game definition. NO Date, NO Math.random, NO DOM.
|
||||||
|
*/
|
||||||
|
const GameRules = {
|
||||||
|
init(seed, config), // → State (deterministic from seed)
|
||||||
|
apply(state, move, rng), // → { state, events } (pure; rng passed in)
|
||||||
|
status(state), // → 'active' | 'won' | 'lost'
|
||||||
|
project(state) // → ClientView (hides secrets; see §4)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### The session
|
||||||
|
|
||||||
|
```js
|
||||||
|
class GameSession {
|
||||||
|
/**
|
||||||
|
* @param rules a GameRules implementation
|
||||||
|
* @param opts.seed number — seeds board gen + RNG (server-held for authority)
|
||||||
|
* @param opts.config game config (level/difficulty)
|
||||||
|
* @param opts.clock () => number — INJECTED time source (authority lives here)
|
||||||
|
*/
|
||||||
|
constructor(rules, opts)
|
||||||
|
|
||||||
|
applyMove(move) // stamps t=clock(); appends {move, t} to log;
|
||||||
|
// rules.apply(...); returns projected events + view.
|
||||||
|
status() // 'active' | 'won' | 'lost'
|
||||||
|
view() // rules.project(state) — safe to send to a client
|
||||||
|
elapsed() // authoritative: t(last decisive move) − t(first move)
|
||||||
|
log() // [{move, t}] — the audit trail
|
||||||
|
result() // on terminal: { status, time, seed, config, log }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two decisions that unlock everything:
|
||||||
|
|
||||||
|
- **Injected clock.** The session never calls `Date.now()`. The *caller* supplies
|
||||||
|
the clock. On the client it's `Date.now` (cosmetic). On the server it's the
|
||||||
|
server's clock — so `elapsed()` is authoritative and unforgeable.
|
||||||
|
- **Injected, seedable RNG** (`rng.js`, e.g. mulberry32 seeded by `opts.seed`).
|
||||||
|
Board generation is a pure function of `seed` (+ first click), so a run is
|
||||||
|
**bit-for-bit reproducible**. The server holds the seed; the client never sees
|
||||||
|
it mid-game (or it could regenerate the board and cheat).
|
||||||
|
|
||||||
|
### Replay-verify (`replay.js`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Re-runs a submitted game from scratch and returns the authoritative outcome.
|
||||||
|
// The server uses this to VERIFY a client-submitted { seed, config, log }:
|
||||||
|
// - does the log actually solve the board?
|
||||||
|
// - is the move timeline monotonic and within plausibility bounds?
|
||||||
|
// - does the recomputed time match the claimed time?
|
||||||
|
replay(rules, { seed, config, log }) // → { status, time, valid, reason? }
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the lighter "verifiable replay" anti-cheat path from the prior discussion:
|
||||||
|
no live per-move server needed — just call `replay()` in a Cloud Function at
|
||||||
|
submit time. It requires exactly this headless core and nothing else.
|
||||||
|
|
||||||
|
> **Determinism is a hard rule for Layers 1–2:** no `Date.now()`, no
|
||||||
|
> `Math.random()`, no `new Date()` inside core logic — all injected. This is what
|
||||||
|
> makes replay reproducible and tests deterministic. (Same constraint we already
|
||||||
|
> follow elsewhere in the repo.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Layer 2 — Minesweeper rules (`minesweeper/`)
|
||||||
|
|
||||||
|
### State (plain data — the DOM's job is gone)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Cell — replaces the <td> + data-status/data-value attributes.
|
||||||
|
Cell = { mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }
|
||||||
|
|
||||||
|
// State
|
||||||
|
{
|
||||||
|
grid: Grid<Cell>,
|
||||||
|
config: { rows, cols, mines, id }, // from levels.js
|
||||||
|
phase: 'fresh' | 'active' | 'won' | 'lost',
|
||||||
|
minesPlaced: boolean // false until the first reveal (safety)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Moves (intents — what the client sends)
|
||||||
|
|
||||||
|
```js
|
||||||
|
Move =
|
||||||
|
| { type: 'reveal', r, c }
|
||||||
|
| { type: 'flag', r, c } // toggle
|
||||||
|
| { type: 'chord', r, c } // reveal neighbors when flag-count satisfied
|
||||||
|
```
|
||||||
|
|
||||||
|
### Events (deltas — what the renderer/server emits)
|
||||||
|
|
||||||
|
```js
|
||||||
|
Event =
|
||||||
|
| { type: 'reveal', cells: [{ r, c, adjacent }] } // whole flood-filled region
|
||||||
|
| { type: 'flag', r, c, flagged: boolean }
|
||||||
|
| { type: 'explode', r, c, mines: [{ r, c }] } // loss reveal
|
||||||
|
| { type: 'win' }
|
||||||
|
```
|
||||||
|
|
||||||
|
Emitting **deltas** (not full state) is what lets the client render incrementally
|
||||||
|
*and* lets an authoritative server withhold the rest of the board.
|
||||||
|
|
||||||
|
### First-click safety, done right
|
||||||
|
|
||||||
|
Generate the board **on the first reveal**, from `(seed, firstClick, config)`,
|
||||||
|
excluding the first cell and its neighbors from mine placement. Replaces today's
|
||||||
|
`transferMine()` relocation. Benefits: nothing exists to leak before move 1, the
|
||||||
|
first click is provably safe, and generation stays a pure seed function.
|
||||||
|
|
||||||
|
### `reveal.js`
|
||||||
|
|
||||||
|
- Flood-fill of the connected zero-adjacency region (ports `handleEmpty`), returns
|
||||||
|
the revealed cells as one `reveal` event.
|
||||||
|
- Chording (ports the left+right behavior), returns a `reveal` or `explode`.
|
||||||
|
- Win = every non-mine cell revealed. Loss = a mine revealed.
|
||||||
|
|
||||||
|
### Hidden-information projection (`project.js`) — the authority crux
|
||||||
|
|
||||||
|
```js
|
||||||
|
project(state) // → ClientView
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns only what a client is allowed to know: revealed cells + their adjacency,
|
||||||
|
flags, and phase. **Unrevealed mine positions are never included** (until a
|
||||||
|
terminal `explode`/`win`, when the full board is disclosed for the reveal
|
||||||
|
animation and verification). The server sends `view()` / event deltas — never the
|
||||||
|
raw state, never the seed. A client therefore cannot see unrevealed mines even if
|
||||||
|
it inspects every byte it receives.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The client (`packages/mnswpr` → consumer of the core)
|
||||||
|
|
||||||
|
The client keeps today's look, CSS, and DOM shape (`<table>` with
|
||||||
|
`game-status` / `data-status` / `data-value`) — but it becomes a **consumer** of
|
||||||
|
core state, not the owner of it. Four parts:
|
||||||
|
|
||||||
|
```
|
||||||
|
InputAdapter Transport Renderer TimerView
|
||||||
|
(gestures → Move) ──▶ (Local | Remote) ──▶ Event[] ──▶ (deltas → DOM) (time → DOM)
|
||||||
|
│
|
||||||
|
├─ Local : in-process GameSession (offline / npm engine)
|
||||||
|
└─ Remote: HTTP/WS to server (ranked / authoritative)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transport — one interface, two implementations (mirrors the leaderboard adapter pattern)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// The client talks ONLY to this — it never knows if the game runs here or on a server.
|
||||||
|
Transport = {
|
||||||
|
start(config) // → { view, time } begin a game
|
||||||
|
send(move) // → { events, view, time } apply a move
|
||||||
|
onEvent(cb) // (Remote may push server events; Local resolves inline)
|
||||||
|
result() // → { status, time, … } when terminal
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`LocalTransport`** wraps a `GameSession` with `clock = Date.now`. This is
|
||||||
|
today's behavior exactly: fast, offline, timing cosmetic (still spoofable — fine
|
||||||
|
for casual/offline and the standalone npm engine).
|
||||||
|
- **`RemoteTransport`** forwards moves to the server, which holds the authoritative
|
||||||
|
`GameSession`, and streams back projected events. Timing is server-owned. Used
|
||||||
|
for ranked play; pair with **App Check** so only the real app can submit.
|
||||||
|
|
||||||
|
### Renderer
|
||||||
|
|
||||||
|
`render(container, view)` builds the initial `<table>`; `applyEvents(events)`
|
||||||
|
mutates the DOM from deltas. This is the *only* place `document` is touched. It
|
||||||
|
reuses the current markup/attributes so existing CSS and the jsdom tests survive.
|
||||||
|
(It is basically today's DOM-building code, inverted: driven by events instead of
|
||||||
|
owning state.)
|
||||||
|
|
||||||
|
### InputAdapter
|
||||||
|
|
||||||
|
Keep the existing mouse/touch state machine (left/right/middle, chording,
|
||||||
|
long-press-to-flag, `isBusy` debounce) — but instead of mutating the DOM, it
|
||||||
|
**emits `Move` intents** to the transport. This is the trickiest existing code;
|
||||||
|
porting it as "same gestures, different output" keeps the hard-won input feel.
|
||||||
|
|
||||||
|
### TimerView & `gameDone`
|
||||||
|
|
||||||
|
- `TimerService` splits in two: the **authoritative clock** moves into
|
||||||
|
`GameSession` (injected); the **display** becomes a dumb `TimerView` that shows
|
||||||
|
`transport`-reported time. In Remote mode it shows server time (optionally a
|
||||||
|
locally-interpolated estimate reconciled on each server message).
|
||||||
|
- The current `hooks.gameDone(game)` fires from the terminal event. In **Local**
|
||||||
|
mode the client builds `game` as today. In **Remote** mode the **server**
|
||||||
|
produces the authoritative `{ time, status }` and writes/sign the leaderboard
|
||||||
|
result — the client submits nothing it could forge. That is the whole point.
|
||||||
|
|
||||||
|
The public constructor stays hook-shaped for compatibility, e.g.:
|
||||||
|
|
||||||
|
```js
|
||||||
|
Minesweeper(appId, version, {
|
||||||
|
transport: new LocalTransport({ level }), // or RemoteTransport({ endpoint })
|
||||||
|
levelChanged(setting) { … },
|
||||||
|
gameDone(game) { … } // Local: client-built; Remote: server-authoritative
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Two run modes, one codebase
|
||||||
|
|
||||||
|
| | **Local / offline** | **Server-authoritative** |
|
||||||
|
|---|---|---|
|
||||||
|
| Where the core runs | in the browser | on the server |
|
||||||
|
| Clock | `Date.now` (cosmetic) | server clock (authoritative) |
|
||||||
|
| Board/seed | in browser | server-held, never sent |
|
||||||
|
| Transport | `LocalTransport` | `RemoteTransport` |
|
||||||
|
| Leaderboard trust | claimed (spoofable) | witnessed / verified |
|
||||||
|
| Needs a server tier | no | yes (Function/Worker + session store) |
|
||||||
|
| Use | offline play, published npm engine | ranked play |
|
||||||
|
|
||||||
|
The published `@ayo-run/mnswpr` stays fully functional standalone (Local mode).
|
||||||
|
Ranked play opts into Remote. Same renderer, same input, same rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Server-readiness invariants (the offline build MUST hold these)
|
||||||
|
|
||||||
|
Going offline-first is only "server-easy later" if the offline build refuses a few
|
||||||
|
tempting in-process shortcuts. Each maps to a concrete failure if violated. These
|
||||||
|
are the disciplines that make the server additive rather than a rewrite:
|
||||||
|
|
||||||
|
1. **Transport is `async`.** `send(move)` returns a Promise (or fires a callback)
|
||||||
|
even though `LocalTransport` resolves instantly. *Violation:* client code
|
||||||
|
assumes synchronous returns → every `RemoteTransport` call breaks.
|
||||||
|
2. **Only serializable messages cross the Transport.** Moves in; Events + a
|
||||||
|
projected view out; plain JSON. Never hand the client a live `GameSession`,
|
||||||
|
`Grid`, or `State`. *Violation:* nothing survives a network hop.
|
||||||
|
3. **The Renderer consumes only `project(state)` + events** — never raw mine
|
||||||
|
positions, even offline where it technically has them. *Violation:* server mode
|
||||||
|
(which withholds the board) needs a renderer rewrite; board-secrecy stops being
|
||||||
|
a drop-in.
|
||||||
|
4. **The core is deterministic now** — seeded RNG + injected clock + a working
|
||||||
|
`replay()`, even though offline play doesn't need them. *Violation:*
|
||||||
|
retrofitting determinism into board generation later is a rewrite, and the
|
||||||
|
verify/authority path has no substrate.
|
||||||
|
5. **The client is stateless about rules.** All win/loss/reveal logic lives in the
|
||||||
|
core; the client only renders. *Violation:* client-side rule shortcuts aren't
|
||||||
|
authoritative on a server.
|
||||||
|
|
||||||
|
A determinism guard (see §10 Testing) fails the build if `Date`/`Math.random`
|
||||||
|
appear in `core/` outside the injected `clock`/`rng` seams.
|
||||||
|
|
||||||
|
## 8. Mapping from today's engine
|
||||||
|
|
||||||
|
| Today (`packages/mnswpr/mnswpr.js`) | Moves to |
|
||||||
|
|---|---|
|
||||||
|
| `document.createElement` grid build | client **Renderer** |
|
||||||
|
| `data-status` / `data-value` / `game-status` attrs | core **State** (`Cell`, `phase`) |
|
||||||
|
| `getStatus`/`setStatus`/`isMine`/`isFlagged` | core model ops |
|
||||||
|
| `minesArray` + `transferMine` (first-click safety) | `minesweeper/board.js` (seeded gen) |
|
||||||
|
| `handleEmpty` flood-fill, chording | `minesweeper/reveal.js` |
|
||||||
|
| mouse/touch handlers, chording, long-press | client **InputAdapter** (emits Moves) |
|
||||||
|
| `TimerService` (`Date.now`, rAF, DOM write) | clock → `GameSession`; display → `TimerView` |
|
||||||
|
| `hooks.gameDone(game)` | terminal Event → Local builds `game` / Remote = server |
|
||||||
|
| `levels.js` | `minesweeper/levels.js` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Migration plan (each step keeps the suite green)
|
||||||
|
|
||||||
|
1. **Core, headless & tested.** Port board gen, flood-fill, chording, win/loss
|
||||||
|
into `core/` as pure functions with unit tests over plain data (fast, no
|
||||||
|
DOM). Add seeded RNG + `replay()`. **← this diff.**
|
||||||
|
2. **Renderer + LocalTransport.** Build the event-driven Renderer that reproduces
|
||||||
|
today's exact DOM, and `LocalTransport` around `GameSession`.
|
||||||
|
3. **Port InputAdapter** to emit `Move`s into the transport instead of mutating
|
||||||
|
the DOM.
|
||||||
|
4. **Swap `apps/mnswpr/main.js`** to construct the client with `LocalTransport`.
|
||||||
|
Behavior is identical to today — the existing jsdom tests (real DOM events on
|
||||||
|
`#app`) are the regression harness and must stay green.
|
||||||
|
5. **(Later) Remote.** Add a server runtime hosting `GameSession` authoritatively
|
||||||
|
+ `RemoteTransport` + App Check; server writes verified scores.
|
||||||
|
6. **(Later) Extract** `grid/` + `session/` into `@cozy-games/grid` +
|
||||||
|
`@cozy-games/game-session` once Sudoku exists.
|
||||||
|
|
||||||
|
## 10. Testing strategy
|
||||||
|
|
||||||
|
- **Core:** pure data-model unit tests — deterministic via fixed seeds; property
|
||||||
|
tests (e.g. flood-fill never reveals a mine; win ⇔ all non-mines revealed).
|
||||||
|
- **Replay:** generate a random valid game, feed its log to `replay()`, assert
|
||||||
|
`valid` and matching time; mutate the log and assert rejection.
|
||||||
|
- **Client:** keep the current jsdom tests (mount, dispatch real mouse events,
|
||||||
|
assert on cell/grid attributes) — now exercising Renderer + InputAdapter +
|
||||||
|
LocalTransport end-to-end.
|
||||||
|
- **Determinism guard:** a lint/test that fails if `Date`/`Math.random` appear in
|
||||||
|
`core/` outside the injected `clock`/`rng` seams.
|
||||||
|
|
||||||
|
## 11. Open decisions
|
||||||
|
|
||||||
|
- **Package boundary:** ✅ *resolved* — one `@ayo-run/mnswpr` package, core at the
|
||||||
|
`./core` sub-path (§1). Extraction to `@cozy-games/grid` + `@cozy-games/game-session`
|
||||||
|
deferred to when Sudoku lands; the sub-path stays stable across that move.
|
||||||
|
- **Enforcement level (verifiable-replay vs full authority):** *deferred.* Ship
|
||||||
|
offline-only for now (`LocalTransport`, cosmetic timing — matches today's UX).
|
||||||
|
The deterministic core + `replay()` are built now so either path is a later
|
||||||
|
add-on, not a rewrite.
|
||||||
|
- **Remote transport / server host / latency & cost:** *deferred* (offline-first).
|
||||||
|
HTTP-vs-WebSocket, Netlify Function vs Worker, and the session store are picked
|
||||||
|
when we do the server; the `Transport` interface reserves the seam.
|
||||||
|
|
@ -20,6 +20,9 @@
|
||||||
".": {
|
".": {
|
||||||
"default": "./dist/mnswpr.js"
|
"default": "./dist/mnswpr.js"
|
||||||
},
|
},
|
||||||
|
"./core": {
|
||||||
|
"default": "./core/index.js"
|
||||||
|
},
|
||||||
"./dist/*": {
|
"./dist/*": {
|
||||||
"default": "./dist/*"
|
"default": "./dist/*"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
252
packages/mnswpr/test/core.test.js
Normal file
252
packages/mnswpr/test/core.test.js
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Grid, eightWay, orthogonal,
|
||||||
|
GameSession, replay, mulberry32,
|
||||||
|
MinesweeperRules, levels
|
||||||
|
} from '../core/index.js'
|
||||||
|
import { placeMines, excludeAround } from '../core/minesweeper/board.js'
|
||||||
|
|
||||||
|
const beginner = levels.beginner
|
||||||
|
|
||||||
|
// Reveal every non-mine cell of a session's board, reading the (already-placed)
|
||||||
|
// state — a deterministic way to drive a game to a win without hardcoding the
|
||||||
|
// layout. Returns the events for assertions.
|
||||||
|
function playToWin(session, clock) {
|
||||||
|
// First reveal seeds the board (first-click safe); pick a corner.
|
||||||
|
session.applyMove({ type: 'reveal', r: 0, c: 0 })
|
||||||
|
const { grid, config } = session.state
|
||||||
|
for (let r = 0; r < config.rows; r++) {
|
||||||
|
for (let c = 0; c < config.cols; c++) {
|
||||||
|
if (!grid.at(r, c).mine && grid.at(r, c).status !== 'revealed') {
|
||||||
|
if (clock) clock.tick()
|
||||||
|
session.applyMove({ type: 'reveal', r, c })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('grid + neighbors (Layer 0)', () => {
|
||||||
|
it('addresses cells and reports bounds', () => {
|
||||||
|
const g = new Grid(3, 4, (r, c) => `${r},${c}`)
|
||||||
|
expect(g.rows).toBe(3)
|
||||||
|
expect(g.cols).toBe(4)
|
||||||
|
expect(g.at(2, 3)).toBe('2,3')
|
||||||
|
expect(g.inBounds(2, 3)).toBe(true)
|
||||||
|
expect(g.inBounds(3, 0)).toBe(false)
|
||||||
|
expect(g.inBounds(-1, 0)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('eightWay yields 8 neighbors interior, 3 in a corner', () => {
|
||||||
|
const g = new Grid(5, 5)
|
||||||
|
expect(eightWay(g, 2, 2)).toHaveLength(8)
|
||||||
|
expect(eightWay(g, 0, 0)).toHaveLength(3)
|
||||||
|
expect(orthogonal(g, 2, 2)).toHaveLength(4)
|
||||||
|
expect(orthogonal(g, 0, 0)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rng (Layer 1)', () => {
|
||||||
|
it('is deterministic for a given seed', () => {
|
||||||
|
const a = mulberry32(12345)
|
||||||
|
const b = mulberry32(12345)
|
||||||
|
const seqA = [a(), a(), a()]
|
||||||
|
const seqB = [b(), b(), b()]
|
||||||
|
expect(seqA).toEqual(seqB)
|
||||||
|
expect(mulberry32(1)()).not.toBe(mulberry32(2)())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('board generation (Layer 2)', () => {
|
||||||
|
it('places exactly `mines` mines, deterministically from the seed', () => {
|
||||||
|
const mk = () => {
|
||||||
|
const g = new Grid(beginner.rows, beginner.cols, () => ({ mine: false, adjacent: 0, status: 'hidden' }))
|
||||||
|
const placed = placeMines(42, beginner, excludeAround(beginner, 0, 0), g)
|
||||||
|
return { g, placed }
|
||||||
|
}
|
||||||
|
const one = mk()
|
||||||
|
const two = mk()
|
||||||
|
expect(one.placed.size).toBe(beginner.mines)
|
||||||
|
expect([...one.placed].sort()).toEqual([...two.placed].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the first-click 3x3 neighborhood mine-free', () => {
|
||||||
|
const g = new Grid(beginner.rows, beginner.cols, () => ({ mine: false, adjacent: 0, status: 'hidden' }))
|
||||||
|
placeMines(7, beginner, excludeAround(beginner, 4, 4), g)
|
||||||
|
for (let dr = -1; dr <= 1; dr++) {
|
||||||
|
for (let dc = -1; dc <= 1; dc++) {
|
||||||
|
expect(g.at(4 + dr, 4 + dc).mine).toBe(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes adjacency as the 8-way mine count', () => {
|
||||||
|
const g = new Grid(beginner.rows, beginner.cols, () => ({ mine: false, adjacent: 0, status: 'hidden' }))
|
||||||
|
placeMines(99, beginner, excludeAround(beginner, 0, 0), g)
|
||||||
|
g.forEach((cell, r, c) => {
|
||||||
|
if (cell.mine) return
|
||||||
|
let n = 0
|
||||||
|
for (const [nr, nc] of eightWay(g, r, c)) if (g.at(nr, nc).mine) n++
|
||||||
|
expect(cell.adjacent).toBe(n)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rules (Layer 2)', () => {
|
||||||
|
it('first reveal is never a mine and opens a region', () => {
|
||||||
|
const s = MinesweeperRules.init(3, beginner)
|
||||||
|
const { state, events } = MinesweeperRules.apply(s, { type: 'reveal', r: 0, c: 0 })
|
||||||
|
expect(state.phase).toBe('active')
|
||||||
|
expect(state.grid.at(0, 0).mine).toBe(false)
|
||||||
|
expect(events[0].type).toBe('reveal')
|
||||||
|
// first click is inside a mine-free 3x3, so it's blank and floods
|
||||||
|
expect(events[0].cells.length).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('revealing a mine loses and emits explode with all mines', () => {
|
||||||
|
const s = MinesweeperRules.init(5, beginner)
|
||||||
|
MinesweeperRules.apply(s, { type: 'reveal', r: 0, c: 0 })
|
||||||
|
const mine = firstMine(s.grid)
|
||||||
|
const { state, events } = MinesweeperRules.apply(s, { type: 'reveal', r: mine.r, c: mine.c })
|
||||||
|
expect(state.phase).toBe('lost')
|
||||||
|
expect(events[0].type).toBe('explode')
|
||||||
|
expect(events[0].mines.length).toBe(beginner.mines)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles flags and blocks revealing a flagged cell', () => {
|
||||||
|
let s = MinesweeperRules.init(1, beginner)
|
||||||
|
s = MinesweeperRules.apply(s, { type: 'reveal', r: 0, c: 0 }).state
|
||||||
|
const hidden = firstHidden(s.grid)
|
||||||
|
let r = MinesweeperRules.apply(s, { type: 'flag', r: hidden.r, c: hidden.c })
|
||||||
|
expect(r.events[0]).toMatchObject({ type: 'flag', flagged: true })
|
||||||
|
// revealing a flagged cell is a no-op
|
||||||
|
r = MinesweeperRules.apply(r.state, { type: 'reveal', r: hidden.r, c: hidden.c })
|
||||||
|
expect(r.events).toHaveLength(0)
|
||||||
|
expect(r.state.grid.at(hidden.r, hidden.c).status).toBe('flagged')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('project never leaks an unrevealed mine mid-game, but reveals mines when over', () => {
|
||||||
|
const s = MinesweeperRules.init(8, beginner)
|
||||||
|
const active = MinesweeperRules.apply(s, { type: 'reveal', r: 0, c: 0 }).state
|
||||||
|
const view = MinesweeperRules.project(active)
|
||||||
|
for (const cell of view.cells) {
|
||||||
|
if (cell.status !== 'revealed') expect(cell.mine).not.toBe(true)
|
||||||
|
}
|
||||||
|
// now lose and re-project
|
||||||
|
const mine = firstMine(active.grid)
|
||||||
|
const lost = MinesweeperRules.apply(active, { type: 'reveal', r: mine.r, c: mine.c }).state
|
||||||
|
const overView = MinesweeperRules.project(lost)
|
||||||
|
expect(overView.cells.some(c => c.status === 'hidden' && c.mine === true)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('session + timing (Layer 1)', () => {
|
||||||
|
it('reports authoritative elapsed time from the injected clock', () => {
|
||||||
|
const clock = makeClock()
|
||||||
|
const session = new GameSession(MinesweeperRules, { seed: 21, config: beginner, clock })
|
||||||
|
expect(session.elapsed()).toBe(0) // no move yet
|
||||||
|
playToWin(session, clock)
|
||||||
|
expect(session.status()).toBe('won')
|
||||||
|
// time is (terminal tick − first tick), owned by the clock, not the client
|
||||||
|
expect(session.elapsed()).toBeGreaterThan(0)
|
||||||
|
const result = session.result()
|
||||||
|
expect(result.status).toBe('won')
|
||||||
|
expect(result.seed).toBe(21)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('replay verification (Layer 1)', () => {
|
||||||
|
it('accepts a genuine winning log and recomputes the same time', () => {
|
||||||
|
const clock = makeClock()
|
||||||
|
const session = new GameSession(MinesweeperRules, { seed: 33, config: beginner, clock })
|
||||||
|
playToWin(session, clock)
|
||||||
|
const submission = session.result()
|
||||||
|
const verdict = replay(MinesweeperRules, submission)
|
||||||
|
expect(verdict.valid).toBe(true)
|
||||||
|
expect(verdict.status).toBe('won')
|
||||||
|
expect(verdict.time).toBe(submission.time)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a truncated (non-terminal) log', () => {
|
||||||
|
const clock = makeClock()
|
||||||
|
const session = new GameSession(MinesweeperRules, { seed: 33, config: beginner, clock })
|
||||||
|
playToWin(session, clock)
|
||||||
|
const submission = session.result()
|
||||||
|
const truncated = { ...submission, log: submission.log.slice(0, 2) }
|
||||||
|
const verdict = replay(MinesweeperRules, truncated)
|
||||||
|
expect(verdict.valid).toBe(false)
|
||||||
|
expect(verdict.reason).toMatch(/terminal/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-monotonic timestamps', () => {
|
||||||
|
const clock = makeClock()
|
||||||
|
const session = new GameSession(MinesweeperRules, { seed: 33, config: beginner, clock })
|
||||||
|
playToWin(session, clock)
|
||||||
|
const submission = session.result()
|
||||||
|
const tampered = { ...submission, log: submission.log.map((e, i) => ({ ...e, t: i === 1 ? -5 : e.t })) }
|
||||||
|
const verdict = replay(MinesweeperRules, tampered)
|
||||||
|
expect(verdict.valid).toBe(false)
|
||||||
|
expect(verdict.reason).toMatch(/monotonic/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is deterministic: same seed + same log ⇒ same board', () => {
|
||||||
|
const a = MinesweeperRules.init(777, beginner)
|
||||||
|
const b = MinesweeperRules.init(777, beginner)
|
||||||
|
MinesweeperRules.apply(a, { type: 'reveal', r: 2, c: 2 })
|
||||||
|
MinesweeperRules.apply(b, { type: 'reveal', r: 2, c: 2 })
|
||||||
|
const minesA = []
|
||||||
|
const minesB = []
|
||||||
|
a.grid.forEach((cell, r, c) => { if (cell.mine) minesA.push(r * beginner.cols + c) })
|
||||||
|
b.grid.forEach((cell, r, c) => { if (cell.mine) minesB.push(r * beginner.cols + c) })
|
||||||
|
expect(minesA).toEqual(minesB)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('determinism guard (invariant #4)', () => {
|
||||||
|
it('no Date/Math.random in core/ outside the rng seam', () => {
|
||||||
|
const coreDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'core')
|
||||||
|
const offenders = []
|
||||||
|
walk(coreDir, file => {
|
||||||
|
if (!file.endsWith('.js')) return
|
||||||
|
// Scan code only — strip comments so prose that *names* these APIs
|
||||||
|
// (e.g. "uses Math.imul, not Math.random") isn't a false positive.
|
||||||
|
const code = readFileSync(file, 'utf8')
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
|
.replace(/\/\/.*$/gm, '')
|
||||||
|
if (/\bMath\.random\b/.test(code) || /\bDate\.now\b/.test(code) || /\bnew Date\b/.test(code)) {
|
||||||
|
offenders.push(file)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(offenders).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
function makeClock() {
|
||||||
|
let t = 1000
|
||||||
|
return Object.assign(() => t, { tick() { t += 50 } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstMine(grid) {
|
||||||
|
let found = null
|
||||||
|
grid.forEach((cell, r, c) => { if (!found && cell.mine) found = { r, c } })
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstHidden(grid) {
|
||||||
|
let found = null
|
||||||
|
grid.forEach((cell, r, c) => { if (!found && cell.status === 'hidden' && !cell.mine) found = { r, c } })
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(dir, fn) {
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
const p = join(dir, name)
|
||||||
|
if (statSync(p).isDirectory()) walk(p, fn)
|
||||||
|
else fn(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue