From 042d6b26e97b44231a0bd032e89350905fab11f4 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 4 Jul 2026 07:49:46 +0200 Subject: [PATCH] feat: mnswpr core --- packages/mnswpr/core/grid/grid.js | 62 +++ packages/mnswpr/core/grid/neighbors.js | 44 ++ packages/mnswpr/core/grid/serialize.js | 33 ++ packages/mnswpr/core/index.js | 24 + packages/mnswpr/core/minesweeper/board.js | 63 +++ packages/mnswpr/core/minesweeper/reveal.js | 64 +++ packages/mnswpr/core/minesweeper/rules.js | 165 +++++++ packages/mnswpr/core/session/replay.js | 41 ++ packages/mnswpr/core/session/rng.js | 29 ++ packages/mnswpr/core/session/session.js | 72 +++ .../docs/headless-core-and-client-design.md | 433 ++++++++++++++++++ packages/mnswpr/package.json | 3 + packages/mnswpr/test/core.test.js | 252 ++++++++++ 13 files changed, 1285 insertions(+) create mode 100644 packages/mnswpr/core/grid/grid.js create mode 100644 packages/mnswpr/core/grid/neighbors.js create mode 100644 packages/mnswpr/core/grid/serialize.js create mode 100644 packages/mnswpr/core/index.js create mode 100644 packages/mnswpr/core/minesweeper/board.js create mode 100644 packages/mnswpr/core/minesweeper/reveal.js create mode 100644 packages/mnswpr/core/minesweeper/rules.js create mode 100644 packages/mnswpr/core/session/replay.js create mode 100644 packages/mnswpr/core/session/rng.js create mode 100644 packages/mnswpr/core/session/session.js create mode 100644 packages/mnswpr/docs/headless-core-and-client-design.md create mode 100644 packages/mnswpr/test/core.test.js diff --git a/packages/mnswpr/core/grid/grid.js b/packages/mnswpr/core/grid/grid.js new file mode 100644 index 0000000..fcfa2a0 --- /dev/null +++ b/packages/mnswpr/core/grid/grid.js @@ -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} + */ + 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 + } +} diff --git a/packages/mnswpr/core/grid/neighbors.js b/packages/mnswpr/core/grid/neighbors.js new file mode 100644 index 0000000..e54fea0 --- /dev/null +++ b/packages/mnswpr/core/grid/neighbors.js @@ -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 +} diff --git a/packages/mnswpr/core/grid/serialize.js b/packages/mnswpr/core/grid/serialize.js new file mode 100644 index 0000000..835a5d4 --- /dev/null +++ b/packages/mnswpr/core/grid/serialize.js @@ -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} 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} + */ +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 +} diff --git a/packages/mnswpr/core/index.js b/packages/mnswpr/core/index.js new file mode 100644 index 0000000..c0e4672 --- /dev/null +++ b/packages/mnswpr/core/index.js @@ -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' diff --git a/packages/mnswpr/core/minesweeper/board.js b/packages/mnswpr/core/minesweeper/board.js new file mode 100644 index 0000000..47a5d29 --- /dev/null +++ b/packages/mnswpr/core/minesweeper/board.js @@ -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} 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} exclude - coordinate keys never to mine (first-click safety) + * @param {import('../grid/grid.js').Grid} grid + * @returns {Set} 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 +} diff --git a/packages/mnswpr/core/minesweeper/reveal.js b/packages/mnswpr/core/minesweeper/reveal.js new file mode 100644 index 0000000..a104755 --- /dev/null +++ b/packages/mnswpr/core/minesweeper/reveal.js @@ -0,0 +1,64 @@ +// @ts-check +import { eightWay } from '../grid/neighbors.js' + +/** + * @typedef {import('./board.js').Cell} Cell + * @typedef {import('../grid/grid.js').Grid} 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 +} diff --git a/packages/mnswpr/core/minesweeper/rules.js b/packages/mnswpr/core/minesweeper/rules.js new file mode 100644 index 0000000..e1b0eea --- /dev/null +++ b/packages/mnswpr/core/minesweeper/rules.js @@ -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, 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 +} diff --git a/packages/mnswpr/core/session/replay.js b/packages/mnswpr/core/session/replay.js new file mode 100644 index 0000000..0eed938 --- /dev/null +++ b/packages/mnswpr/core/session/replay.js @@ -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 } +} diff --git a/packages/mnswpr/core/session/rng.js b/packages/mnswpr/core/session/rng.js new file mode 100644 index 0000000..cc84236 --- /dev/null +++ b/packages/mnswpr/core/session/rng.js @@ -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) +} diff --git a/packages/mnswpr/core/session/session.js b/packages/mnswpr/core/session/session.js new file mode 100644 index 0000000..eba12ef --- /dev/null +++ b/packages/mnswpr/core/session/session.js @@ -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() } + } +} diff --git a/packages/mnswpr/docs/headless-core-and-client-design.md b/packages/mnswpr/docs/headless-core-and-client-design.md new file mode 100644 index 0000000..2912c5d --- /dev/null +++ b/packages/mnswpr/docs/headless-core-and-client-design.md @@ -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 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 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 + data-status/data-value attributes. +Cell = { mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' } + +// State +{ + grid: Grid, + 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 (`` 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 `
`; `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. diff --git a/packages/mnswpr/package.json b/packages/mnswpr/package.json index b3042cb..85a1e5c 100644 --- a/packages/mnswpr/package.json +++ b/packages/mnswpr/package.json @@ -20,6 +20,9 @@ ".": { "default": "./dist/mnswpr.js" }, + "./core": { + "default": "./core/index.js" + }, "./dist/*": { "default": "./dist/*" }, diff --git a/packages/mnswpr/test/core.test.js b/packages/mnswpr/test/core.test.js new file mode 100644 index 0000000..7a37855 --- /dev/null +++ b/packages/mnswpr/test/core.test.js @@ -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) + } +}