diff --git a/packages/replay/README.md b/packages/replay/README.md index 894b0fc..6d869cc 100644 --- a/packages/replay/README.md +++ b/packages/replay/README.md @@ -15,6 +15,23 @@ clock.pause() // freeze at the current position clock.seek(1500) // jump to 1500ms — delivers exactly the events at offset <= 1500 ``` +## Progress via a game adapter + +The engine never interprets events. To show completion percent, supply a game +**adapter** with a `progress(events) → %` reducer at construction: + +```js +const adapter = { progress: (events) => (events.length / total) * 100 } +const clock = new PlaybackClock(envelope, {}, adapter) + +clock.seek(1500) +clock.progress() // 0–100 (clamped), or null if no adapter supplied +``` + +The reducer receives the ordered slice of events delivered so far; the engine +clamps the result and stays blind to the payload. See +[docs/adapter-interface.md](./docs/adapter-interface.md) for the full contract. + ## 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 new file mode 100644 index 0000000..3141c8c --- /dev/null +++ b/packages/replay/docs/adapter-interface.md @@ -0,0 +1,66 @@ +# Replay adapter interface + +The replay engine is **game-agnostic**: it schedules and delivers the events in a +[`@cozy-games/move-log`](../../move-log) envelope over time, but it never +interprets what an event *means*. All game meaning enters through a **game +adapter** — the seam defined here. This is the concrete realization of the +progress-reducer item in +[ADR 0002](../../../docs/decisions/0002-game-adapter-pattern.md). + +## `ReplayAdapter` + +An adapter is a plain object supplied at construction: + +```js +new PlaybackClock(envelope, deps, adapter) +``` + +```ts +// Typed generically over the game's event vocabulary T. +type ReplayAdapter = { + progress?: ProgressReducer +} + +type ProgressReducer = (events: MoveEvent[]) => number // 0–100 +``` + +`MoveEvent` is the move-log record `{ seq, t, event, receivedTs? }`, where +`event` is the game's own payload — opaque to the engine. + +## `progress(events) → %` + +The only adapter method today. It maps the **ordered slice of events delivered so +far** (every event whose offset ≤ the current playback position) to a completion +percentage. + +- **Input:** `MoveEvent[]` — the played-so-far slice, in order. To compute a + percentage the adapter typically needs a total (e.g. total safe cells); it owns + that context, usually by closing over the board it was built from. The engine + passes only the slice. +- **Output:** a number in `[0, 100]`. The engine **clamps** the result into range + and throws if the reducer returns a non-number, so an adapter can be permissive. +- **When:** call `clock.progress()` at any time. It returns `null` if no adapter + (or no `progress`) was supplied — the engine never invents a percentage. + +```js +// A minesweeper-style adapter, built over its board (illustrative): +const adapter = { + progress: (events) => { + const revealed = events.filter(e => e.event.type === 'reveal').length + return (revealed / totalSafeCells) * 100 + } +} +const clock = new PlaybackClock(envelope, {}, adapter) +clock.seek(1500) +clock.progress() // → e.g. 42 +``` + +## Contract rules + +- **The engine calls the reducer; it never interprets events itself.** Engine + source references only envelope types (`MoveEvent` / `MoveLog`) and the log's + recording metadata (`seq`, `t`) — never an event's `.event` payload. This is + enforced by a guard in `test/playback-clock.test.js`. +- **The adapter owns all game meaning** — event vocabulary, progress math, and + (as the contract grows) state reduction and terminal predicates per ADR 0002. +- **Typed generically over `T`** so one engine serves every game. diff --git a/packages/replay/index.js b/packages/replay/index.js index ff5717b..e1d19f9 100644 --- a/packages/replay/index.js +++ b/packages/replay/index.js @@ -25,13 +25,38 @@ import { assertMoveLog } from '@cozy-games/move-log' * }} Deps */ +/** + * A reducer supplied by a game adapter: given the ordered slice of events played + * so far (offset <= the current position), return a completion percentage in + * `[0, 100]`. Typed generically over the game's event vocabulary `T`. The engine + * clamps the result and never inspects an event's payload — all interpretation + * lives in this reducer. + * + * @template T + * @typedef {(events: import('@cozy-games/move-log').MoveEvent[]) => number} ProgressReducer + */ + +/** + * 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`. + * + * @template T + * @typedef {{ progress?: ProgressReducer }} 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) */ - constructor(envelope, deps = {}) { + constructor(envelope, deps = {}, adapter = {}) { assertMoveLog(envelope) + if (adapter.progress !== undefined && typeof adapter.progress !== 'function') { + throw new TypeError('PlaybackClock: adapter.progress must be a function when provided') + } + this._adapter = adapter const { clock = () => Date.now(), @@ -79,6 +104,25 @@ export class PlaybackClock { return this._livePosition() } + /** + * Completion percentage (0–100) at the current position, via the adapter's + * progress reducer — or `null` if no reducer was supplied. The engine hands the + * reducer the ordered slice of events delivered so far and clamps its result; + * it never interprets an event payload itself (that's the adapter's job). + * + * @returns {number | null} + */ + progress() { + const reduce = this._adapter.progress + if (typeof reduce !== 'function') return null + const delivered = this._events.slice(0, this._cursor).map(e => e.record) + const pct = reduce(delivered) + if (typeof pct !== 'number' || Number.isNaN(pct)) { + throw new TypeError(`PlaybackClock.progress: reducer must return a number (got ${typeof pct})`) + } + return Math.min(100, Math.max(0, pct)) + } + /** * Subscribe to delivered events. The handler receives the raw envelope record * (`{ seq, t, event, ... }`) — the payload stays opaque. Returns an unsubscribe. diff --git a/packages/replay/test/playback-clock.test.js b/packages/replay/test/playback-clock.test.js index 9bc5765..b1f2c82 100644 --- a/packages/replay/test/playback-clock.test.js +++ b/packages/replay/test/playback-clock.test.js @@ -220,10 +220,80 @@ describe('PlaybackClock — with vi fake timers', () => { }) }) +describe('PlaybackClock — progress reducer (adapter seam)', () => { + /** + * A dummy adapter defined HERE, in the test — the engine interprets nothing. + * @typedef {{ type: string }} DummyEvent + * @type {import('@cozy-games/replay').ProgressReducer} + */ + const byCount = events => (events.length / 3) * 100 // 3 = total in envelope() + + it('runs against a dummy adapter: progress reflects the delivered slice', () => { + const clock = new PlaybackClock(envelope(), fakeScheduler(), { progress: byCount }) + expect(clock.progress()).toBe(0) // nothing delivered yet + + clock.seek(0) // offset-0 event delivered ⇒ 1/3 + expect(clock.progress()).toBeCloseTo(33.333, 2) + + clock.seek(100) // 2/3 + expect(clock.progress()).toBeCloseTo(66.667, 2) + + clock.seek(400) // all 3 ⇒ 100 + expect(clock.progress()).toBe(100) + + clock.seek(50) // rewind ⇒ back to 1/3 + expect(clock.progress()).toBeCloseTo(33.333, 2) + }) + + it('advances as playback advances', () => { + const s = fakeScheduler() + const clock = new PlaybackClock(envelope(), s, { progress: byCount }) + clock.play() + expect(clock.progress()).toBeCloseTo(33.333, 2) // offset-0 fired on play + s.advance(100) + expect(clock.progress()).toBeCloseTo(66.667, 2) + s.advance(250) + expect(clock.progress()).toBe(100) + }) + + it('returns null when no adapter (or no progress reducer) is supplied', () => { + expect(new PlaybackClock(envelope(), fakeScheduler()).progress()).toBe(null) + expect(new PlaybackClock(envelope(), fakeScheduler(), {}).progress()).toBe(null) + }) + + it('clamps the reducer output into [0, 100]', () => { + const over = new PlaybackClock(envelope(), fakeScheduler(), { progress: () => 999 }) + const under = new PlaybackClock(envelope(), fakeScheduler(), { progress: () => -50 }) + expect(over.progress()).toBe(100) + expect(under.progress()).toBe(0) + }) + + it('throws if the reducer returns a non-number', () => { + const clock = new PlaybackClock(envelope(), fakeScheduler(), { progress: () => /** @type {any} */ ('nope') }) + expect(() => clock.progress()).toThrow(TypeError) + }) + + it('rejects a non-function progress at construction', () => { + expect(() => new PlaybackClock(envelope(), fakeScheduler(), { progress: /** @type {any} */ (42) })).toThrow(TypeError) + }) +}) + describe('game-agnosticism guard (envelope only, no game imports)', () => { const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..') const GAME_REFERENCES = /mnswpr|minesweeper/i + it('engine never interprets an event payload (no `.event` access in engine source)', () => { + const offenders = [] + walk(pkgDir, file => { + if (!file.endsWith('.js') || file.includes('/test/')) return + const code = readFileSync(file, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, '') + if (/\.event\b/.test(code)) offenders.push(file) + }) + expect(offenders).toEqual([]) // engine references only envelope metadata (seq/t) + opaque records + }) + it('manifest depends only on the envelope, never a game package', () => { const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }