feat(replay): schema dispatch

This commit is contained in:
ayo 2026-07-04 13:34:32 +02:00
parent 998aeacbb2
commit 43b75d7b0e
3 changed files with 205 additions and 6 deletions

View file

@ -97,6 +97,35 @@ offset `<= t`:
No event is ever delivered twice for a single forward pass, and none is dropped. No event is ever delivered twice for a single forward pass, and none is dropped.
## Schema-version dispatch
One engine build replays envelopes from multiple format generations. On
construction the envelope is routed by its `schema_version` through a dispatch
table to a version-specific **reader** that normalizes it into the canonical
`MoveEvent` records the engine plays:
```js
readEnvelope(envelope) // → canonical records, or throws
```
- **v1** is the only built-in reader today (the canonical format itself).
- **Unknown versions fail loudly** — never a silent best-effort parse:
```
readEnvelope: unsupported envelope schema_version 99 (supported: 1)
```
- **Adding a generation is one entry.** Register a reader for this instance via
the `readers` option (or add to the built-in table for a permanent version):
```js
const v2 = (env) => env.log.map(e => ({ seq: e.n, t: e.ts, event: e.payload }))
new PlaybackClock(v2Envelope, {}, adapter, { readers: { 2: v2 } })
```
Whatever a reader returns is validated as a canonical move log, so a
half-normalized generation can never reach the engine.
## Invariant: envelope only, no game types ## Invariant: envelope only, no game types
This module imports **only** `@cozy-games/move-log` (to validate the envelope) and This module imports **only** `@cozy-games/move-log` (to validate the envelope) and

View file

@ -1,5 +1,5 @@
// @ts-check // @ts-check
import { assertMoveLog } from '@cozy-games/move-log' import { assertMoveLog, SCHEMA_VERSION } from '@cozy-games/move-log'
/** /**
* `@cozy-games/replay` the core of a game-agnostic replay engine. * `@cozy-games/replay` the core of a game-agnostic replay engine.
@ -55,17 +55,71 @@ import { assertMoveLog } from '@cozy-games/move-log'
* @typedef {{ progress?: ProgressReducer<T>, state?: StateReducer<T, any> }} ReplayAdapter * @typedef {{ progress?: ProgressReducer<T>, state?: StateReducer<T, any> }} ReplayAdapter
*/ */
/**
* A version reader: normalize a raw envelope of its generation into the canonical
* ordered `MoveEvent` records the engine plays. One engine build can therefore
* replay envelopes from multiple format generations.
*
* @typedef {(envelope: any) => Event[]} EnvelopeReader
*/
/** v1 is the canonical format itself — its `events` are already the records. */
function readV1(envelope) {
return envelope.events
}
/**
* The built-in dispatch table: `schema_version → reader`. Adding a real future
* generation is exactly one entry here (plus its normalizer). Callers can also
* supply extra/override readers per instance via the `readers` option.
*
* @type {Record<number, EnvelopeReader>}
*/
const ENVELOPE_READERS = { [SCHEMA_VERSION]: readV1 }
/**
* Dispatch on an envelope's `schema_version` to the matching reader and return
* the canonical `MoveEvent` records. Unknown/unsupported versions fail LOUDLY
* with a specific error never a silent best-effort parse. Whatever a reader
* returns is validated as a canonical move log, so a half-normalized generation
* can't reach the engine.
*
* @param {any} envelope
* @param {Record<number, EnvelopeReader>} [extraReaders] - added/overriding readers
* @returns {Event[]}
*/
export function readEnvelope(envelope, extraReaders) {
if (envelope === null || typeof envelope !== 'object') {
throw new TypeError(`readEnvelope: expected an envelope object (got ${envelope === null ? 'null' : typeof envelope})`)
}
const readers = extraReaders ? { ...ENVELOPE_READERS, ...extraReaders } : ENVELOPE_READERS
const version = envelope.schema_version
const read = readers[version]
if (typeof read !== 'function') {
const supported = Object.keys(readers).map(Number).sort((a, b) => a - b).join(', ')
throw new RangeError(`readEnvelope: unsupported envelope schema_version ${JSON.stringify(version)} (supported: ${supported})`)
}
const records = read(envelope)
// Every reader MUST normalize to canonical move-log records; enforce it here so
// no downstream generation can feed the engine a malformed or half-normalized log.
assertMoveLog({ schema_version: SCHEMA_VERSION, events: records })
return records
}
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 (progress / state reducers) * @param {ReplayAdapter<any>} [adapter] - game adapter (progress / state reducers)
* @param {{ fullBoard?: boolean }} [options] - `fullBoard` flag-gates full-board * @param {{ fullBoard?: boolean, readers?: Record<number, EnvelopeReader> }} [options] -
* mode (default OFF: `state()`/`onState` are inert and the state reducer is * `fullBoard` flag-gates full-board mode (default OFF: `state()`/`onState` are
* never called). The minimal, documented feature-flag seam for this engine. * inert and the state reducer is never called) the minimal, documented
* feature-flag seam for this engine. `readers` adds/overrides schema-version
* readers for this instance (see {@link readEnvelope}).
*/ */
constructor(envelope, deps = {}, adapter = {}, options = {}) { constructor(envelope, deps = {}, adapter = {}, options = {}) {
assertMoveLog(envelope) // Dispatch on schema_version → the matching reader's canonical records.
const records = readEnvelope(envelope, options.readers)
if (adapter.progress !== undefined && typeof adapter.progress !== 'function') { if (adapter.progress !== undefined && typeof adapter.progress !== 'function') {
throw new TypeError('PlaybackClock: adapter.progress must be a function when provided') throw new TypeError('PlaybackClock: adapter.progress must be a function when provided')
} }
@ -86,7 +140,7 @@ export class PlaybackClock {
// Sort by recorded time (tie-break by seq) and rebase to offsets so the first // Sort by recorded time (tie-break by seq) and rebase to offsets so the first
// event sits at offset 0 — "recorded offset relative to playback time". // event sits at offset 0 — "recorded offset relative to playback time".
const sorted = [...envelope.events].sort((a, b) => a.t - b.t || a.seq - b.seq) const sorted = [...records].sort((a, b) => a.t - b.t || a.seq - b.seq)
const baseT = sorted.length ? sorted[0].t : 0 const baseT = sorted.length ? sorted[0].t : 0
/** @type {{ offset: number, record: Event }[]} */ /** @type {{ offset: number, record: Event }[]} */
this._events = sorted.map(record => ({ offset: record.t - baseT, record })) this._events = sorted.map(record => ({ offset: record.t - baseT, record }))

View file

@ -0,0 +1,116 @@
// @ts-check
import { describe, it, expect } from 'vitest'
import { PlaybackClock, readEnvelope } from '@cozy-games/replay'
import { createMoveLog } from '@cozy-games/move-log'
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
}
}
}
// A canonical v1 envelope.
const v1 = () => createMoveLog([
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
{ seq: 2, t: 100, event: { type: 'flag', r: 1, c: 1 } },
{ seq: 3, t: 250, event: { type: 'chord', r: 2, c: 2 } }
])
describe('schema_version dispatch — v1 (built-in)', () => {
it('replays a v1 envelope through the dispatch path (not a bypass)', () => {
const s = fakeScheduler()
const clock = new PlaybackClock(v1(), s)
const seen = []
clock.on(r => seen.push(r.event.type))
clock.play()
s.advance(250)
expect(seen).toEqual(['reveal', 'flag', 'chord'])
})
it('readEnvelope returns the canonical records for v1', () => {
const records = readEnvelope(v1())
expect(records.map(r => r.seq)).toEqual([1, 2, 3])
expect(records[0].event.type).toBe('reveal')
})
})
describe('schema_version dispatch — unknown versions fail loudly', () => {
it('throws a specific error for a synthetic future version (99)', () => {
const future = { schema_version: 99, events: [{ seq: 1, t: 0, event: {} }] }
expect(() => new PlaybackClock(future, fakeScheduler())).toThrow(/unsupported envelope schema_version 99 \(supported: 1\)/)
expect(() => readEnvelope(future)).toThrow(RangeError)
})
it('throws for a missing schema_version', () => {
expect(() => readEnvelope({ events: [] })).toThrow(/unsupported envelope schema_version undefined/)
})
it('rejects a non-object envelope', () => {
// @ts-expect-error — deliberately wrong type
expect(() => readEnvelope(null)).toThrow(TypeError)
})
})
describe('schema_version dispatch — adding a version', () => {
// A toy v2 format defined ENTIRELY in the test: a different field layout that a
// normalizer maps back to canonical { seq, t, event }. Adding it is one entry.
const v2Envelope = {
schema_version: 2,
log: [
{ n: 1, ts: 0, payload: { type: 'reveal', r: 0, c: 0 } },
{ n: 2, ts: 120, payload: { type: 'flag', r: 1, c: 1 } }
]
}
const readV2 = env => env.log.map(e => ({ seq: e.n, t: e.ts, event: e.payload }))
it('replays a v2 fixture via a supplied reader (same code path)', () => {
const s = fakeScheduler()
const clock = new PlaybackClock(v2Envelope, s, {}, { readers: { 2: readV2 } })
const seen = []
clock.on(r => seen.push(r.event.type))
clock.play()
s.advance(120)
expect(seen).toEqual(['reveal', 'flag'])
})
it('readEnvelope normalizes v2 to canonical records with an extra reader', () => {
const records = readEnvelope(v2Envelope, { 2: readV2 })
expect(records).toEqual([
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
{ seq: 2, t: 120, event: { type: 'flag', r: 1, c: 1 } }
])
})
it('still rejects v2 when no reader is registered', () => {
expect(() => readEnvelope(v2Envelope)).toThrow(/unsupported envelope schema_version 2 \(supported: 1\)/)
})
it('validates a reader that returns a malformed (non-canonical) log', () => {
// A buggy reader whose output breaks the monotonic-seq invariant is caught.
const badReader = () => [{ seq: 2, t: 0, event: {} }, { seq: 1, t: 1, event: {} }]
expect(() => readEnvelope({ schema_version: 3 }, { 3: badReader })).toThrow(RangeError)
})
})