feat(replay): progress reducer interface
This commit is contained in:
parent
bafe1db285
commit
6f9a8031e9
4 changed files with 198 additions and 1 deletions
|
|
@ -15,6 +15,23 @@ clock.pause() // freeze at the current position
|
||||||
clock.seek(1500) // jump to 1500ms — delivers exactly the events at offset <= 1500
|
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
|
## Offsets
|
||||||
|
|
||||||
Each event fires at its **offset** — its recorded `t` minus the first event's
|
Each event fires at its **offset** — its recorded `t` minus the first event's
|
||||||
|
|
|
||||||
66
packages/replay/docs/adapter-interface.md
Normal file
66
packages/replay/docs/adapter-interface.md
Normal 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 // 0–100
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
@ -25,13 +25,38 @@ import { assertMoveLog } from '@cozy-games/move-log'
|
||||||
* }} Deps
|
* }} 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 {
|
export class PlaybackClock {
|
||||||
/**
|
/**
|
||||||
* @param {Envelope} envelope - a valid move-log envelope (validated here)
|
* @param {Envelope} envelope - a valid move-log envelope (validated here)
|
||||||
* @param {Deps} [deps] - injected time source + scheduler (default: real host)
|
* @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)
|
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 {
|
const {
|
||||||
clock = () => Date.now(),
|
clock = () => Date.now(),
|
||||||
|
|
@ -79,6 +104,25 @@ export class PlaybackClock {
|
||||||
return this._livePosition()
|
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
|
* Subscribe to delivered events. The handler receives the raw envelope record
|
||||||
* (`{ seq, t, event, ... }`) — the payload stays opaque. Returns an unsubscribe.
|
* (`{ seq, t, event, ... }`) — the payload stays opaque. Returns an unsubscribe.
|
||||||
|
|
|
||||||
|
|
@ -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)', () => {
|
describe('game-agnosticism guard (envelope only, no game imports)', () => {
|
||||||
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
const GAME_REFERENCES = /mnswpr|minesweeper/i
|
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', () => {
|
it('manifest depends only on the envelope, never a game package', () => {
|
||||||
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
|
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
|
||||||
const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue