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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
|
|
|||
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
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 (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.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Reference in a new issue