From 998aeacbb2dc5dceaaa3920fc7504258ed9d0fe9 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 4 Jul 2026 13:29:52 +0200 Subject: [PATCH] feat(replay): state reducer & full board playback --- packages/mnswpr/adapters/replay-common.js | 24 +++ packages/mnswpr/adapters/replay-progress.js | 19 +-- packages/mnswpr/adapters/replay-state.js | 57 +++++++ packages/mnswpr/test/replay-state.test.js | 104 +++++++++++++ packages/replay/README.md | 15 ++ packages/replay/docs/adapter-interface.md | 32 +++- packages/replay/index.js | 69 ++++++++- packages/replay/test/full-board-mode.test.js | 147 +++++++++++++++++++ 8 files changed, 442 insertions(+), 25 deletions(-) create mode 100644 packages/mnswpr/adapters/replay-common.js create mode 100644 packages/mnswpr/adapters/replay-state.js create mode 100644 packages/mnswpr/test/replay-state.test.js create mode 100644 packages/replay/test/full-board-mode.test.js diff --git a/packages/mnswpr/adapters/replay-common.js b/packages/mnswpr/adapters/replay-common.js new file mode 100644 index 0000000..13c6195 --- /dev/null +++ b/packages/mnswpr/adapters/replay-common.js @@ -0,0 +1,24 @@ +// @ts-check + +/** + * @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent + */ + +/** + * Map a recorded move-event back to the rules move that produced it: `flag` and + * `unflag` are both the toggle move `flag`; `reveal` and `chord` pass through. + * Unknown kinds are ignored. Shared by the mnswpr replay adapters (progress and + * full-board state) so both replay a stream through the core rules identically. + * + * @param {MnswprMoveEvent} e + * @returns {{ type: 'reveal' | 'flag' | 'chord', r: number, c: number } | null} + */ +export function toMove(e) { + switch (e && e.type) { + case 'reveal': return { type: 'reveal', r: e.r, c: e.c } + case 'chord': return { type: 'chord', r: e.r, c: e.c } + case 'flag': + case 'unflag': return { type: 'flag', r: e.r, c: e.c } + default: return null + } +} diff --git a/packages/mnswpr/adapters/replay-progress.js b/packages/mnswpr/adapters/replay-progress.js index b203320..26552c9 100644 --- a/packages/mnswpr/adapters/replay-progress.js +++ b/packages/mnswpr/adapters/replay-progress.js @@ -1,5 +1,6 @@ // @ts-check import { MinesweeperRules } from '../core/minesweeper/rules.js' +import { toMove } from './replay-common.js' /** * @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent @@ -37,21 +38,3 @@ export function createProgressReducer(layout) { return (state.revealedSafe / totalSafe) * 100 } } - -/** - * Map a recorded move-event back to the rules move that produced it: `flag` and - * `unflag` are both the toggle move `flag`; `reveal` and `chord` pass through. - * Unknown kinds are ignored. - * - * @param {MnswprMoveEvent} e - * @returns {{ type: 'reveal' | 'flag' | 'chord', r: number, c: number } | null} - */ -function toMove(e) { - switch (e && e.type) { - case 'reveal': return { type: 'reveal', r: e.r, c: e.c } - case 'chord': return { type: 'chord', r: e.r, c: e.c } - case 'flag': - case 'unflag': return { type: 'flag', r: e.r, c: e.c } - default: return null - } -} diff --git a/packages/mnswpr/adapters/replay-state.js b/packages/mnswpr/adapters/replay-state.js new file mode 100644 index 0000000..27320fe --- /dev/null +++ b/packages/mnswpr/adapters/replay-state.js @@ -0,0 +1,57 @@ +// @ts-check +import { MinesweeperRules } from '../core/minesweeper/rules.js' +import { toMove } from './replay-common.js' + +/** + * @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent + * @typedef {import('../core/minesweeper/board.js').Layout} Layout + * @typedef {{ mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }} BoardCell + * @typedef {{ rows: number, cols: number, phase: string, revealedSafe: number, cells: BoardCell[][] }} BoardState + */ + +/** + * The full-board state reducer for Minesweeper — mnswpr's implementation of the + * replay engine's `StateReducer` seam (replay-05). + * Given the ordered slice of move-events played so far, it reconstructs the + * COMPLETE board at that point: every cell's mine/adjacent/status plus the phase. + * + * Like the progress reducer, it takes the board as closure input and replays the + * moves through the pure core rules — so reveals flood, chords open their + * neighbors, and flags toggle — giving an exact reconstruction at any event + * index. Statelessly a function of the slice, so the engine can jump (seek) to + * any position and rebuild the board there. + * + * @param {Layout} layout - the recorded board (as produced by `generateBoard`) + * @returns {(events: { event: MnswprMoveEvent }[]) => BoardState} + */ +export function createStateReducer(layout) { + return function state(events) { + let s = MinesweeperRules.fromLayout(layout) + for (const record of events) { + const move = toMove(record.event) + if (move) s = MinesweeperRules.apply(s, move).state + } + return toBoard(s) + } +} + +/** + * Project a core game state into a plain, render-ready 2D board snapshot. + * @param {import('../core/minesweeper/rules.js').State} s + * @returns {BoardState} + */ +function toBoard(s) { + const { rows, cols } = s.config + /** @type {BoardCell[][]} */ + const cells = [] + for (let r = 0; r < rows; r++) { + /** @type {BoardCell[]} */ + const row = [] + for (let c = 0; c < cols; c++) { + const cell = s.grid.at(r, c) + row.push({ mine: cell.mine, adjacent: cell.adjacent, status: cell.status }) + } + cells.push(row) + } + return { rows, cols, phase: s.phase, revealedSafe: s.revealedSafe, cells } +} diff --git a/packages/mnswpr/test/replay-state.test.js b/packages/mnswpr/test/replay-state.test.js new file mode 100644 index 0000000..a62f77d --- /dev/null +++ b/packages/mnswpr/test/replay-state.test.js @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest' +import { GameSession, MinesweeperRules } from '../core/index.js' +import { createStateReducer } from '../adapters/replay-state.js' + +// Drive a session over an injected layout, capturing the real move-event stream. +function record(layout, moves) { + const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout) }) + const events = [] + session.onMove(e => events.push(e)) + for (const m of moves) session.applyMove(m) + return { events: events.map(event => ({ seq: event.seq, t: event.t, event })), session } +} + +// 3x3, single mine at (0,0). Total safe = 8. +const smallBoard = () => ({ + rows: 3, + cols: 3, + mines: 1, + cells: [ + [{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }], + [{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }], + [{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }] + ], + mineLocations: [[0, 0]] +}) + +describe('mnswpr state reducer (full-board reconstruction)', () => { + const layout = smallBoard() + const { events } = record(layout, [ + { type: 'reveal', r: 0, c: 1 }, + { type: 'reveal', r: 1, c: 0 }, + { type: 'flag', r: 0, c: 0 }, + { type: 'reveal', r: 2, c: 2 } // floods the rest + ]) + const state = createStateReducer(layout) + const status = (b, r, c) => b.cells[r][c].status + + it('empty slice → pristine board (all hidden, fresh)', () => { + const b = state([]) + expect(b.rows).toBe(3) + expect(b.cols).toBe(3) + expect(b.phase).toBe('fresh') + expect(b.revealedSafe).toBe(0) + expect(b.cells.flat().every(c => c.status === 'hidden')).toBe(true) + }) + + it('reconstructs the exact board state at several event indices', () => { + // index 1 — after reveal(0,1): just that cell open + let b = state(events.slice(0, 1)) + expect(status(b, 0, 1)).toBe('revealed') + expect(status(b, 0, 0)).toBe('hidden') + expect(status(b, 1, 0)).toBe('hidden') + expect(b.revealedSafe).toBe(1) + expect(b.phase).toBe('active') + + // index 2 — after reveal(1,0) + b = state(events.slice(0, 2)) + expect(status(b, 0, 1)).toBe('revealed') + expect(status(b, 1, 0)).toBe('revealed') + expect(b.revealedSafe).toBe(2) + + // index 3 — after flag(0,0): mine flagged, nothing new revealed + b = state(events.slice(0, 3)) + expect(status(b, 0, 0)).toBe('flagged') + expect(b.revealedSafe).toBe(2) + + // index 4 — after reveal(2,2): floods all safe cells; mine stays flagged; won + b = state(events.slice(0, 4)) + expect(b.phase).toBe('won') + expect(b.revealedSafe).toBe(8) + for (let r = 0; r < 3; r++) { + for (let c = 0; c < 3; c++) { + expect(status(b, r, c)).toBe(r === 0 && c === 0 ? 'flagged' : 'revealed') + } + } + }) + + it('carries mine/adjacent metadata for rendering', () => { + const b = state(events) + expect(b.cells[0][0].mine).toBe(true) + expect(b.cells[0][1].mine).toBe(false) + expect(b.cells[0][1].adjacent).toBe(1) + }) + + it('is stateless: the same slice reconstructs an equal board', () => { + expect(state(events.slice(0, 2))).toEqual(state(events.slice(0, 2))) + // and reconstructing a shorter slice after a longer one is unaffected + const full = state(events) + expect(state(events.slice(0, 1)).revealedSafe).toBe(1) + expect(full.revealedSafe).toBe(8) + }) + + it('counts chord reveals in the reconstructed board', () => { + // reveal a number, flag the mine, then chord it open. + const { events: chordEvents } = record(smallBoard(), [ + { type: 'reveal', r: 0, c: 1 }, + { type: 'flag', r: 0, c: 0 }, + { type: 'chord', r: 0, c: 1 } + ]) + const b = createStateReducer(smallBoard())(chordEvents) + expect(b.revealedSafe).toBe(8) + expect(b.cells[2][2].status).toBe('revealed') + }) +}) diff --git a/packages/replay/README.md b/packages/replay/README.md index 71fc2e1..cd61282 100644 --- a/packages/replay/README.md +++ b/packages/replay/README.md @@ -49,6 +49,21 @@ at offset `≤ t`, so the emitted `progress` matches the original run's progress Subscribe before playing to catch every update; call `progress()` for the current value at any time. +### Full-board mode (flag-gated) + +With a `state` reducer and the `fullBoard` flag, the engine reconstructs the whole +board at any point — `state()` for the current board, `onState` for a stream, and +`seek(t)` rebuilds the exact state at `t`: + +```js +const clock = new PlaybackClock(envelope, {}, adapter, { fullBoard: true }) +clock.onState(({ position, state }) => render(state)) +clock.seek(1500) // state() now reflects the board at 1500ms +``` + +Off by default: without the flag, `state()` is `null`, `onState` never fires, and +the reducer never runs. See [docs/adapter-interface.md](./docs/adapter-interface.md). + ## Offsets Each event fires at its **offset** — its recorded `t` minus the first event's diff --git a/packages/replay/docs/adapter-interface.md b/packages/replay/docs/adapter-interface.md index 3141c8c..6db5043 100644 --- a/packages/replay/docs/adapter-interface.md +++ b/packages/replay/docs/adapter-interface.md @@ -16,12 +16,14 @@ new PlaybackClock(envelope, deps, adapter) ``` ```ts -// Typed generically over the game's event vocabulary T. +// Typed generically over the game's event vocabulary T (and state S). type ReplayAdapter = { progress?: ProgressReducer + state?: StateReducer } -type ProgressReducer = (events: MoveEvent[]) => number // 0–100 +type ProgressReducer = (events: MoveEvent[]) => number // 0–100 +type StateReducer = (events: MoveEvent[]) => S // full game state ``` `MoveEvent` is the move-log record `{ seq, t, event, receivedTs? }`, where @@ -55,6 +57,32 @@ clock.seek(1500) clock.progress() // → e.g. 42 ``` +## `state(events) → S` (full-board mode) + +The second reducer reconstructs the **complete game state** at a playback point +from the ordered slice of events delivered so far. It powers full-board replay — +rebuilding the whole board on seek, not just a percentage. + +- **Input:** `MoveEvent[]` — the played-so-far slice, in order. +- **Output:** the game's own state type `S` (opaque to the engine). For mnswpr + it's a 2D board snapshot: `{ rows, cols, phase, revealedSafe, cells }`. +- **When:** `clock.state()` returns the current reconstruction, and `onState` + streams `{ position, state }` as playback advances or seeks. + +### Flag-gated + +Full-board mode is **off by default** and gated behind a runtime flag — the +engine's minimal, documented feature-flag seam: + +```js +new PlaybackClock(envelope, deps, adapter, { fullBoard: true }) +``` + +When the flag is **off** (default), the mode is fully inert: `state()` returns +`null`, `onState` never fires, and the state reducer is never invoked (no +reconstruction cost). When **on** with a `state` reducer supplied, `state()` and +`onState` reconstruct the board — and `seek(t)` rebuilds the exact state at `t`. + ## Contract rules - **The engine calls the reducer; it never interprets events itself.** Engine diff --git a/packages/replay/index.js b/packages/replay/index.js index 50edf25..6e28cc3 100644 --- a/packages/replay/index.js +++ b/packages/replay/index.js @@ -36,27 +36,44 @@ import { assertMoveLog } from '@cozy-games/move-log' * @typedef {(events: import('@cozy-games/move-log').MoveEvent[]) => number} ProgressReducer */ +/** + * A reducer supplied by a game adapter for full-board replay: given the ordered + * slice of events played so far, reconstruct the complete game state `S` at that + * point. Typed generically over the event vocabulary `T` and the (opaque) state + * `S`. Powers the flag-gated full-board mode; the engine treats `S` as a black box. + * + * @template T, S + * @typedef {(events: import('@cozy-games/move-log').MoveEvent[]) => S} StateReducer + */ + /** * The replay game-adapter contract (v0) — the seam through which game meaning - * enters the engine. Currently one optional method; more join as the contract - * grows. See `docs/adapter-interface.md`. + * enters the engine. Both methods are optional; the engine calls whichever the + * mode needs and never interprets an event itself. See `docs/adapter-interface.md`. * * @template T - * @typedef {{ progress?: ProgressReducer }} ReplayAdapter + * @typedef {{ progress?: ProgressReducer, state?: StateReducer }} ReplayAdapter */ export class PlaybackClock { /** * @param {Envelope} envelope - a valid move-log envelope (validated here) * @param {Deps} [deps] - injected time source + scheduler (default: real host) - * @param {ReplayAdapter} [adapter] - game adapter (e.g. a progress reducer) + * @param {ReplayAdapter} [adapter] - game adapter (progress / state reducers) + * @param {{ fullBoard?: boolean }} [options] - `fullBoard` flag-gates full-board + * mode (default OFF: `state()`/`onState` are inert and the state reducer is + * never called). The minimal, documented feature-flag seam for this engine. */ - constructor(envelope, deps = {}, adapter = {}) { + constructor(envelope, deps = {}, adapter = {}, options = {}) { assertMoveLog(envelope) if (adapter.progress !== undefined && typeof adapter.progress !== 'function') { throw new TypeError('PlaybackClock: adapter.progress must be a function when provided') } + if (adapter.state !== undefined && typeof adapter.state !== 'function') { + throw new TypeError('PlaybackClock: adapter.state must be a function when provided') + } this._adapter = adapter + this._fullBoard = options.fullBoard === true const { clock = () => Date.now(), @@ -91,6 +108,8 @@ export class PlaybackClock { this._progressHandlers = new Set() /** Last progress value pushed, so unchanged progress (e.g. a flag) is not re-emitted. */ this._lastProgress = /** @type {number | null} */ (null) + /** @type {Set<(update: { position: number, state: any }) => void>} */ + this._stateHandlers = new Set() } /** Total playback length in ms (offset of the last event; 0 if empty). */ @@ -127,6 +146,22 @@ export class PlaybackClock { return Math.min(100, Math.max(0, pct)) } + /** + * Full-board mode: the reconstructed game state at the current position, via the + * adapter's `state` reducer over the delivered slice. Returns `null` unless the + * `fullBoard` flag is on AND a state reducer was supplied — so the mode is inert + * (and the reducer never runs) by default. The state shape `S` is the adapter's; + * the engine treats it as opaque. + * + * @returns {any} + */ + state() { + if (!this._fullBoard) return null + const reduce = this._adapter.state + if (typeof reduce !== 'function') return null + return reduce(this._events.slice(0, this._cursor).map(e => e.record)) + } + /** * Subscribe to delivered events. The handler receives the raw envelope record * (`{ seq, t, event, ... }`) — the payload stays opaque. Returns an unsubscribe. @@ -156,6 +191,20 @@ export class PlaybackClock { return () => this._progressHandlers.delete(handler) } + /** + * Full-board mode subscription: receive `{ position, state }` whenever the + * delivered set changes (play, seek forward, seek backward), where `state` is + * the adapter's reconstruction at that position. Inert unless the `fullBoard` + * flag is on and a state reducer was supplied. Returns an unsubscribe. + * + * @param {(update: { position: number, state: any }) => void} handler + * @returns {() => void} + */ + onState(handler) { + this._stateHandlers.add(handler) + return () => this._stateHandlers.delete(handler) + } + /** * Start (or resume) playback from the current position. Any event already due * at the current position fires synchronously; the rest are scheduled at their @@ -216,6 +265,7 @@ export class PlaybackClock { */ _advanceTo(target) { const t = Math.min(Math.max(target, 0), this._duration) + const before = this._cursor while (this._cursor < this._events.length && this._events[this._cursor].offset <= t) { this._emit(this._events[this._cursor].record) this._cursor++ @@ -225,6 +275,7 @@ export class PlaybackClock { } this._position = t this._emitProgressIfChanged() + if (this._cursor !== before) this._emitState() } /** Deliver an event to all subscribers. */ @@ -242,6 +293,14 @@ export class PlaybackClock { for (const handler of this._progressHandlers) handler(update) } + /** Push a reconstructed board state to subscribers — only in active full-board mode. */ + _emitState() { + if (!this._fullBoard || this._stateHandlers.size === 0) return + if (typeof this._adapter.state !== 'function') return + const update = { position: this._position, state: this.state() } + for (const handler of this._stateHandlers) handler(update) + } + /** * Deliver anything already due, then arm a timer for the next pending event. * Ends playback when the cursor reaches the last event. diff --git a/packages/replay/test/full-board-mode.test.js b/packages/replay/test/full-board-mode.test.js new file mode 100644 index 0000000..2e7c9cb --- /dev/null +++ b/packages/replay/test/full-board-mode.test.js @@ -0,0 +1,147 @@ +// @ts-check +import { describe, it, expect } from 'vitest' +import { PlaybackClock } from '@cozy-games/replay' +import { createMoveLog } from '@cozy-games/move-log' + +// Real mnswpr run + state reducer — imported by the TEST (relative), so no game +// dependency enters the engine's manifest. +import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js' +import { createStateReducer } from '../../mnswpr/adapters/replay-state.js' + +function fakeScheduler(start = 0) { + let now = start + let nextId = 1 + const timers = new Map() + return { + clock: () => now, + setTimeout: (fn, ms) => { + const id = nextId++ + timers.set(id, { at: now + Math.max(0, ms), fn }) + return id + }, + clearTimeout: (id) => { timers.delete(id) }, + advance(ms) { + const target = now + ms + for (;;) { + let due = null + for (const [id, timer] of timers) { + if (timer.at <= target && (due === null || timer.at < due.at)) due = { id, ...timer } + } + if (!due) break + timers.delete(due.id) + now = due.at + due.fn() + } + now = target + } + } +} + +const board = { + rows: 3, + cols: 3, + mines: 1, + cells: [ + [{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }], + [{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }], + [{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }] + ], + mineLocations: [[0, 0]] +} + +// Record a real run: reveal, reveal, flag the mine, then flood the rest. +let nowClock = 0 +const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(board), clock: () => nowClock }) +const emitted = [] +session.onMove(e => emitted.push(e)) +const baseT = 1000 +for (const step of [ + { at: 1000, move: { type: 'reveal', r: 0, c: 1 } }, + { at: 1100, move: { type: 'reveal', r: 1, c: 0 } }, + { at: 1200, move: { type: 'flag', r: 0, c: 0 } }, + { at: 1300, move: { type: 'reveal', r: 2, c: 2 } } +]) { + nowClock = step.at + session.applyMove(step.move) +} +const records = emitted.map(e => ({ seq: e.seq, t: e.t, event: e })) +const envelope = createMoveLog(records) +const reduce = createStateReducer(board) + +// Independent ground truth: reduce over records at offset <= t. +const truth = t => reduce(records.filter(r => (r.t - baseT) <= t)) + +describe('full-board mode — flag gating (inert by default)', () => { + it('does nothing when the flag is off, even with a state reducer', () => { + const clock = new PlaybackClock(envelope, fakeScheduler(), { state: reduce }) + const updates = [] + clock.onState(u => updates.push(u)) + clock.seek(clock.duration) + expect(clock.state()).toBe(null) + expect(updates).toEqual([]) + }) + + it('is inert when the flag is on but no state reducer is supplied', () => { + const clock = new PlaybackClock(envelope, fakeScheduler(), {}, { fullBoard: true }) + clock.seek(clock.duration) + expect(clock.state()).toBe(null) + }) +}) + +describe('full-board mode — reconstruction (flag on)', () => { + const make = () => new PlaybackClock(envelope, fakeScheduler(), { state: reduce }, { fullBoard: true }) + + it('state() reconstructs the board at multiple seek points', () => { + const clock = make() + for (const t of [-5, 0, 50, 100, 150, 200, 300, 400]) { + clock.seek(t) + const clamped = Math.max(0, Math.min(t, clock.duration)) + expect(clock.state()).toEqual(truth(clamped)) + } + }) + + it('seek reconstructs the correct concrete state (forward then backward)', () => { + const clock = make() + + clock.seek(clock.duration) // end + let b = clock.state() + expect(b.phase).toBe('won') + expect(b.revealedSafe).toBe(8) + expect(b.cells[0][0].status).toBe('flagged') + + clock.seek(100) // back to just after the two opening reveals + b = clock.state() + expect(b.phase).toBe('active') + expect(b.revealedSafe).toBe(2) + expect(b.cells[0][1].status).toBe('revealed') + expect(b.cells[2][2].status).toBe('hidden') + + clock.seek(0) // back to the very first reveal + expect(clock.state().revealedSafe).toBe(1) + }) + + it('onState streams a reconstruction on every delivery (incl. the flag)', () => { + const s = fakeScheduler() + const clock = new PlaybackClock(envelope, s, { state: reduce }, { fullBoard: true }) + const updates = [] + clock.onState(u => updates.push(u)) + clock.play() + s.advance(400) + expect(updates.map(u => u.position)).toEqual([0, 100, 200, 300]) + expect(updates.at(-1).state.phase).toBe('won') + }) + + it('onState fires on backward seek', () => { + const clock = make() + const updates = [] + clock.onState(u => updates.push(u)) + clock.seek(clock.duration) // forward (delivers all at once → 1 update) + clock.seek(0) // backward → 1 update + expect(updates).toHaveLength(2) + expect(updates.at(-1).state.revealedSafe).toBe(1) + }) + + it('rejects a non-function state reducer at construction', () => { + expect(() => new PlaybackClock(envelope, fakeScheduler(), { state: /** @type {any} */ (5) })).toThrow(TypeError) + }) +})