chore: headless design
This commit is contained in:
parent
8b66249edf
commit
6afd15aa35
1 changed files with 52 additions and 52 deletions
|
|
@ -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 1–2:** no `Date.now()`, no
|
> **Determinism is a hard rule for Layers 1–2:** no `Date.now()`, no
|
||||||
> `Math.random()`, no `new Date()` inside core logic — all injected. This is what
|
> `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).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue