chore: headless design

This commit is contained in:
ayo 2026-07-04 10:05:43 +02:00
parent 8b66249edf
commit 6afd15aa35

View file

@ -10,10 +10,9 @@ engine now — only structuring for it.
Goals, in priority order: Goals, in priority order:
1. **Headless core** — game state is a plain data model, no DOM, no wall clock. 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 2. **Authoritative-host capable** — the same core can run on a host that owns
the RNG, the clock, and the move sequence, so leaderboard times are witnessed, the RNG, the clock, and the move sequence, so game state and timing can be
not claimed — closing the gap where a client can otherwise report any finish computed by an authoritative host rather than solely on the client.
time it likes.
3. **Backwards compatible** — the existing DOM UI, CSS, and jsdom tests keep 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." working; today's offline play is just "the core with a local transport."
4. **Extraction-ready** — a clean seam between generic (grid/session) and 4. **Extraction-ready** — a clean seam between generic (grid/session) and
@ -41,7 +40,7 @@ packages/mnswpr/ # @ayo-run/mnswpr — ONE published package
session/ # Layer 1 → future @cozy-games/game-session session/ # Layer 1 → future @cozy-games/game-session
session.js # GameSession: lifecycle, injected clock, move log session.js # GameSession: lifecycle, injected clock, move log
rng.js # seedable deterministic PRNG (mulberry32) rng.js # seedable deterministic PRNG (mulberry32)
replay.js # replay(rules, {seed, config, log}) → verify replay.js # replay(rules, {seed, config, log}) → validate
minesweeper/ # Layer 2 → Minesweeper-specific rules minesweeper/ # Layer 2 → Minesweeper-specific rules
rules.js # GameRules impl: init/apply/status/project rules.js # GameRules impl: init/apply/status/project
board.js # deterministic board gen + first-click safety board.js # deterministic board gen + first-click safety
@ -120,9 +119,9 @@ session for the move log and by the server for persistence/replay.
--- ---
## 3. Layer 1 — generic session & authority (`session/`) ## 3. Layer 1 — generic session & host authority (`session/`)
The most reusable layer, and the one that makes server authority possible. It 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 owns **lifecycle, time, and the move log**, and delegates game meaning to an
injected `GameRules` object. injected `GameRules` object.
@ -147,9 +146,9 @@ const GameRules = {
class GameSession { class GameSession {
/** /**
* @param rules a GameRules implementation * @param rules a GameRules implementation
* @param opts.seed number — seeds board gen + RNG (server-held for authority) * @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.config game config (level/difficulty)
* @param opts.clock () => number — INJECTED time source (authority lives here) * @param opts.clock () => number — INJECTED time source (whoever runs the session owns it)
*/ */
constructor(rules, opts) constructor(rules, opts)
@ -166,27 +165,29 @@ class GameSession {
Two decisions that unlock everything: Two decisions that unlock everything:
- **Injected clock.** The session never calls `Date.now()`. The *caller* supplies - **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 the clock. On the client it's `Date.now`. On an authoritative host it's the
server's clock — so `elapsed()` is authoritative and unforgeable. host's clock — so `elapsed()` is owned by whoever runs the session.
- **Injected, seedable RNG** (`rng.js`, e.g. mulberry32 seeded by `opts.seed`). - **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 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 **bit-for-bit reproducible**. A host that owns the session holds the seed and
it mid-game (or it could regenerate the board and cheat). sends only projected views, so unrevealed board state need not be sent to the
client mid-game.
### Replay-verify (`replay.js`) ### Replay (`replay.js`)
```js ```js
// Re-runs a submitted game from scratch and returns the authoritative outcome. // Re-runs a game from scratch from its recorded inputs and returns the
// The server uses this to VERIFY a client-submitted { seed, config, log }: // recomputed outcome. A host can use this to validate a submitted
// { seed, config, log }:
// - does the log actually solve the board? // - does the log actually solve the board?
// - is the move timeline monotonic and within plausibility bounds? // - is the move timeline monotonic and within plausible bounds?
// - does the recomputed time match the claimed time? // - does the recomputed time match the recorded time?
replay(rules, { seed, config, log }) // → { status, time, valid, reason? } replay(rules, { seed, config, log }) // → { status, time, valid, reason? }
``` ```
This is the lighter "verifiable replay" anti-cheat path: no live per-move server Because the core is deterministic, a full game can be reconstructed from its
needed — just call `replay()` in a Cloud Function at inputs alone — no live per-move host needed; `replay()` can run wherever a host
submit time. It requires exactly this headless core and nothing else. processes a submission. It requires exactly this headless core and nothing else.
> **Determinism is a hard rule for Layers 12:** no `Date.now()`, no > **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 > `Math.random()`, no `new Date()` inside core logic — all injected. This is what
@ -232,7 +233,7 @@ Event =
``` ```
Emitting **deltas** (not full state) is what lets the client render incrementally Emitting **deltas** (not full state) is what lets the client render incrementally
*and* lets an authoritative server withhold the rest of the board. *and* lets an authoritative host withhold the rest of the board.
### First-click safety, done right ### First-click safety, done right
@ -248,7 +249,7 @@ first click is provably safe, and generation stays a pure seed function.
- Chording (ports the left+right behavior), returns a `reveal` or `explode`. - Chording (ports the left+right behavior), returns a `reveal` or `explode`.
- Win = every non-mine cell revealed. Loss = a mine revealed. - Win = every non-mine cell revealed. Loss = a mine revealed.
### Hidden-information projection (`project.js`) — the authority crux ### Hidden-information projection (`project.js`)
```js ```js
project(state) // → ClientView project(state) // → ClientView
@ -257,9 +258,9 @@ project(state) // → ClientView
Returns only what a client is allowed to know: revealed cells + their adjacency, Returns only what a client is allowed to know: revealed cells + their adjacency,
flags, and phase. **Unrevealed mine positions are never included** (until a flags, and phase. **Unrevealed mine positions are never included** (until a
terminal `explode`/`win`, when the full board is disclosed for the reveal terminal `explode`/`win`, when the full board is disclosed for the reveal
animation and verification). The server sends `view()` / event deltas — never the animation). A host sends `view()` / event deltas — never the raw state, never the
raw state, never the seed. A client therefore cannot see unrevealed mines even if seed. A client therefore cannot see unrevealed mines even if it inspects every
it inspects every byte it receives. byte it receives.
--- ---
@ -274,7 +275,7 @@ core state, not the owner of it. Four parts:
(gestures → Move) ──▶ (Local | Remote) ──▶ Event[] ──▶ (deltas → DOM) (time → DOM) (gestures → Move) ──▶ (Local | Remote) ──▶ Event[] ──▶ (deltas → DOM) (time → DOM)
├─ Local : in-process GameSession (offline / npm engine) ├─ Local : in-process GameSession (offline / npm engine)
└─ Remote: HTTP/WS to server (ranked / authoritative) └─ Remote: HTTP/WS to a host (authoritative host)
``` ```
### Transport — one interface, two implementations (mirrors the leaderboard adapter pattern) ### Transport — one interface, two implementations (mirrors the leaderboard adapter pattern)
@ -290,11 +291,11 @@ Transport = {
``` ```
- **`LocalTransport`** wraps a `GameSession` with `clock = Date.now`. This is - **`LocalTransport`** wraps a `GameSession` with `clock = Date.now`. This is
today's behavior exactly: fast, offline, timing cosmetic (still spoofable — fine today's behavior exactly: fast, offline, timing owned by the client — fine for
for casual/offline and the standalone npm engine). offline play and the standalone npm engine.
- **`RemoteTransport`** forwards moves to the server, which holds the authoritative - **`RemoteTransport`** forwards moves to a host, which holds the authoritative
`GameSession`, and streams back projected events. Timing is server-owned. Used `GameSession`, and streams back projected events. Timing is host-owned. Used
for ranked play; pair with **App Check** so only the real app can submit. when a game runs on an authoritative host.
### Renderer ### Renderer
@ -318,9 +319,9 @@ porting it as "same gestures, different output" keeps the hard-won input feel.
`transport`-reported time. In Remote mode it shows server time (optionally a `transport`-reported time. In Remote mode it shows server time (optionally a
locally-interpolated estimate reconciled on each server message). locally-interpolated estimate reconciled on each server message).
- The current `hooks.gameDone(game)` fires from the terminal event. In **Local** - The current `hooks.gameDone(game)` fires from the terminal event. In **Local**
mode the client builds `game` as today. In **Remote** mode the **server** mode the client builds `game` as today. In **Remote** mode the **host**
produces the authoritative `{ time, status }` and writes/sign the leaderboard produces the authoritative `{ time, status }` and records the leaderboard
result — the client submits nothing it could forge. That is the whole point. result — the client renders it rather than computing it.
The public constructor stays hook-shaped for compatibility, e.g.: The public constructor stays hook-shaped for compatibility, e.g.:
@ -328,7 +329,7 @@ The public constructor stays hook-shaped for compatibility, e.g.:
Minesweeper(appId, version, { Minesweeper(appId, version, {
transport: new LocalTransport({ level }), // or RemoteTransport({ endpoint }) transport: new LocalTransport({ level }), // or RemoteTransport({ endpoint })
levelChanged(setting) { … }, levelChanged(setting) { … },
gameDone(game) { … } // Local: client-built; Remote: server-authoritative gameDone(game) { … } // Local: client-built; Remote: host-authoritative
}) })
``` ```
@ -336,18 +337,17 @@ Minesweeper(appId, version, {
## 6. Two run modes, one codebase ## 6. Two run modes, one codebase
| | **Local / offline** | **Server-authoritative** | | | **Local / offline** | **Authoritative host** |
|---|---|---| |---|---|---|
| Where the core runs | in the browser | on the server | | Where the core runs | in the browser | on a host |
| Clock | `Date.now` (cosmetic) | server clock (authoritative) | | Clock | `Date.now` | host clock |
| Board/seed | in browser | server-held, never sent | | Board/seed | in browser | host-held, sent as rules allow |
| Transport | `LocalTransport` | `RemoteTransport` | | Transport | `LocalTransport` | `RemoteTransport` |
| Leaderboard trust | claimed (spoofable) | witnessed / verified | | Needs a host tier | no | yes (Function/Worker + session store) |
| Needs a server tier | no | yes (Function/Worker + session store) | | Use | offline play, published npm engine | host-owned sessions |
| Use | offline play, published npm engine | ranked play |
The published `@ayo-run/mnswpr` stays fully functional standalone (Local mode). The published `@ayo-run/mnswpr` stays fully functional standalone (Local mode).
Ranked play opts into Remote. Same renderer, same input, same rules. Running on a host opts into Remote. Same renderer, same input, same rules.
--- ---
@ -364,13 +364,13 @@ are the disciplines that make the server additive rather than a rewrite:
projected view out; plain JSON. Never hand the client a live `GameSession`, projected view out; plain JSON. Never hand the client a live `GameSession`,
`Grid`, or `State`. *Violation:* nothing survives a network hop. `Grid`, or `State`. *Violation:* nothing survives a network hop.
3. **The Renderer consumes only `project(state)` + events** — never raw mine 3. **The Renderer consumes only `project(state)` + events** — never raw mine
positions, even offline where it technically has them. *Violation:* server mode positions, even offline where it technically has them. *Violation:* host mode
(which withholds the board) needs a renderer rewrite; board-secrecy stops being (which withholds unrevealed board state) needs a renderer rewrite; hidden-
a drop-in. information projection stops being a drop-in.
4. **The core is deterministic now** — seeded RNG + injected clock + a working 4. **The core is deterministic now** — seeded RNG + injected clock + a working
`replay()`, even though offline play doesn't need them. *Violation:* `replay()`, even though offline play doesn't need them. *Violation:*
retrofitting determinism into board generation later is a rewrite, and the retrofitting determinism into board generation later is a rewrite, and the
verify/authority path has no substrate. replay/validation path has no substrate.
5. **The client is stateless about rules.** All win/loss/reveal logic lives in the 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 core; the client only renders. *Violation:* client-side rule shortcuts aren't
authoritative on a server. authoritative on a server.
@ -406,8 +406,8 @@ appear in `core/` outside the injected `clock`/`rng` seams.
4. **Swap `apps/mnswpr/main.js`** to construct the client with `LocalTransport`. 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 Behavior is identical to today — the existing jsdom tests (real DOM events on
`#app`) are the regression harness and must stay green. `#app`) are the regression harness and must stay green.
5. **(Later) Remote.** Add a server runtime hosting `GameSession` authoritatively 5. **(Later) Remote.** Add a host runtime running `GameSession` authoritatively
+ `RemoteTransport` + App Check; server writes verified scores. + `RemoteTransport`; the host records results.
6. **(Later) Extract** `grid/` + `session/` into `@cozy-games/grid` + 6. **(Later) Extract** `grid/` + `session/` into `@cozy-games/grid` +
`@cozy-games/game-session` once Sudoku exists. `@cozy-games/game-session` once Sudoku exists.
@ -428,8 +428,8 @@ appear in `core/` outside the injected `clock`/`rng` seams.
- **Package boundary:***resolved* — one `@ayo-run/mnswpr` package, core at the - **Package boundary:***resolved* — one `@ayo-run/mnswpr` package, core at the
`./core` sub-path (§1). Extraction to `@cozy-games/grid` + `@cozy-games/game-session` `./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. deferred to when Sudoku lands; the sub-path stays stable across that move.
- **Enforcement level (verifiable-replay vs full authority):** *deferred.* Ship - **Host mode (replay-validation vs live authority):** *deferred.* Ship
offline-only for now (`LocalTransport`, cosmetic timing — matches today's UX). 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 The deterministic core + `replay()` are built now so either path is a later
add-on, not a rewrite. add-on, not a rewrite.
- **Remote transport / server host / latency & cost:** *deferred* (offline-first). - **Remote transport / server host / latency & cost:** *deferred* (offline-first).