feat(replay): progress reducer interface

This commit is contained in:
ayo 2026-07-04 13:06:58 +02:00
parent bafe1db285
commit 6f9a8031e9
4 changed files with 198 additions and 1 deletions

View file

@ -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() // 0100 (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

View file

@ -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<T>`
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<T> = {
progress?: ProgressReducer<T>
}
type ProgressReducer<T> = (events: MoveEvent<T>[]) => number // 0100
```
`MoveEvent<T>` 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<T>[]` — 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.

View file

@ -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<T>[]) => 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<T> }} 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<any>} [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 (0100) 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.

View file

@ -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<DummyEvent>}
*/
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 }