From 43b75d7b0e04b3322b9240976d86c7a2d8aff910 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 4 Jul 2026 13:34:32 +0200 Subject: [PATCH] feat(replay): schema dispatch --- packages/replay/README.md | 29 +++++ packages/replay/index.js | 66 ++++++++++- packages/replay/test/schema-dispatch.test.js | 116 +++++++++++++++++++ 3 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 packages/replay/test/schema-dispatch.test.js diff --git a/packages/replay/README.md b/packages/replay/README.md index cd61282..5d87e38 100644 --- a/packages/replay/README.md +++ b/packages/replay/README.md @@ -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 diff --git a/packages/replay/index.js b/packages/replay/index.js index 6e28cc3..c962f3b 100644 --- a/packages/replay/index.js +++ b/packages/replay/index.js @@ -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, state?: StateReducer }} 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} + */ +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} [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} [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 }} [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 })) diff --git a/packages/replay/test/schema-dispatch.test.js b/packages/replay/test/schema-dispatch.test.js new file mode 100644 index 0000000..9fb6228 --- /dev/null +++ b/packages/replay/test/schema-dispatch.test.js @@ -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) + }) +})