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

437 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. **Authoritative-host capable** — the same core can run on a host that owns
the RNG, the clock, and the move sequence, so game state and timing can be
computed by an authoritative host rather than solely on the client.
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}) → validate
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)
renderer.js # events → DOM (the ONLY place document is touched) — EXTRACTED
transport.js # LocalTransport (RemoteTransport added later) — EXTRACTED
# Decision: the input state machine (mouse/touch/chording/long-press) and the
# timer stay INLINE in mnswpr.js for now — not extracted to input-adapter.js /
# timer-view.js. The input code is intricate and under-tested (no chord/touch/
# middle-click coverage yet), so extraction is deferred until those
# characterization tests exist or a concrete need arises. See §9.
```
`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 & host authority (`session/`)
The most reusable layer, and the one that makes an authoritative host 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 (host-held when a host owns the session)
* @param opts.config game config (level/difficulty)
* @param opts.clock () => number — INJECTED time source (whoever runs the session owns it)
*/
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`. On an authoritative host it's the
host's clock — so `elapsed()` is owned by whoever runs the session.
- **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**. A host that owns the session holds the seed and
sends only projected views, so unrevealed board state need not be sent to the
client mid-game.
### Replay (`replay.js`)
```js
// Re-runs a game from scratch from its recorded inputs and returns the
// recomputed outcome. A host can use this to validate a submitted
// { seed, config, log }:
// - does the log actually solve the board?
// - is the move timeline monotonic and within plausible bounds?
// - does the recomputed time match the recorded time?
replay(rules, { seed, config, log }) // → { status, time, valid, reason? }
```
Because the core is deterministic, a full game can be reconstructed from its
inputs alone — no live per-move host needed; `replay()` can run wherever a host
processes a submission. 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 host 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`)
```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). A host 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 a host (authoritative host)
```
### 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 owned by the client — fine for
offline play and the standalone npm engine.
- **`RemoteTransport`** forwards moves to a host, which holds the authoritative
`GameSession`, and streams back projected events. Timing is host-owned. Used
when a game runs on an authoritative host.
### 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 **host**
produces the authoritative `{ time, status }` and records the leaderboard
result — the client renders it rather than computing it.
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: host-authoritative
})
```
---
## 6. Two run modes, one codebase
| | **Local / offline** | **Authoritative host** |
|---|---|---|
| Where the core runs | in the browser | on a host |
| Clock | `Date.now` | host clock |
| Board/seed | in browser | host-held, sent as rules allow |
| Transport | `LocalTransport` | `RemoteTransport` |
| Needs a host tier | no | yes (Function/Worker + session store) |
| Use | offline play, published npm engine | host-owned sessions |
The published `@ayo-run/mnswpr` stays fully functional standalone (Local mode).
Running on a host 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:* host mode
(which withholds unrevealed board state) needs a renderer rewrite; hidden-
information projection 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
replay/validation 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 host runtime running `GameSession` authoritatively
+ `RemoteTransport`; the host records results.
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.
- **Host mode (replay-validation vs live authority):** *deferred.* Ship
offline-only for now (`LocalTransport`, client-owned 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.