feat(replay): schema dispatch
This commit is contained in:
parent
998aeacbb2
commit
43b75d7b0e
3 changed files with 205 additions and 6 deletions
|
|
@ -97,6 +97,35 @@ offset `<= t`:
|
|||
|
||||
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
|
||||
|
||||
This module imports **only** `@cozy-games/move-log` (to validate the envelope) and
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// @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.
|
||||
|
|
@ -55,17 +55,71 @@ import { assertMoveLog } from '@cozy-games/move-log'
|
|||
* @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 {
|
||||
/**
|
||||
* @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 (progress / state reducers)
|
||||
* @param {{ fullBoard?: boolean }} [options] - `fullBoard` flag-gates full-board
|
||||
* mode (default OFF: `state()`/`onState` are inert and the state reducer is
|
||||
* never called). The minimal, documented feature-flag seam for this engine.
|
||||
* @param {{ fullBoard?: boolean, readers?: Record<number, EnvelopeReader> }} [options] -
|
||||
* `fullBoard` flag-gates full-board mode (default OFF: `state()`/`onState` are
|
||||
* 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 = {}) {
|
||||
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') {
|
||||
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
|
||||
// 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
|
||||
/** @type {{ offset: number, record: Event }[]} */
|
||||
this._events = sorted.map(record => ({ offset: record.t - baseT, record }))
|
||||
|
|
|
|||
116
packages/replay/test/schema-dispatch.test.js
Normal file
116
packages/replay/test/schema-dispatch.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue