feat: mnswpr core

This commit is contained in:
ayo 2026-07-04 07:49:46 +02:00
parent 7cbf9baaa8
commit 042d6b26e9
13 changed files with 1285 additions and 0 deletions

View 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
}
}

View 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
}

View 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
}

View 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'

View 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
}

View 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
}

View 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
}

View 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 }
}

View 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)
}

View 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() }
}
}

View 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 12:** 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.

View file

@ -20,6 +20,9 @@
".": { ".": {
"default": "./dist/mnswpr.js" "default": "./dist/mnswpr.js"
}, },
"./core": {
"default": "./core/index.js"
},
"./dist/*": { "./dist/*": {
"default": "./dist/*" "default": "./dist/*"
}, },

View 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)
}
}