cozy-games/packages/mnswpr/docs/headless-core-and-client-design.md
2026-07-04 07:49:46 +02:00

433 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.