From bafe1db28500f04e23c8066f629278a0c94b7ede Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 4 Jul 2026 12:56:29 +0200 Subject: [PATCH] feat(replay): replay engine --- packages/replay/README.md | 55 +++++ packages/replay/index.js | 197 +++++++++++++++ packages/replay/package.json | 25 ++ packages/replay/test/playback-clock.test.js | 253 ++++++++++++++++++++ pnpm-lock.yaml | 6 + 5 files changed, 536 insertions(+) create mode 100644 packages/replay/README.md create mode 100644 packages/replay/index.js create mode 100644 packages/replay/package.json create mode 100644 packages/replay/test/playback-clock.test.js diff --git a/packages/replay/README.md b/packages/replay/README.md new file mode 100644 index 0000000..894b0fc --- /dev/null +++ b/packages/replay/README.md @@ -0,0 +1,55 @@ +# @cozy-games/replay + +A **game-agnostic** replay engine. `PlaybackClock` re-drives a +[`@cozy-games/move-log`](../move-log) envelope over time — scheduling each +recorded event to fire at its offset — with `play` / `pause` / `seek`. + +```js +import { PlaybackClock } from '@cozy-games/replay' + +const clock = new PlaybackClock(envelope) // a valid move-log envelope +const off = clock.on(record => apply(record.event)) // record = { seq, t, event, ... } + +clock.play() // events fire at their recorded offsets +clock.pause() // freeze at the current position +clock.seek(1500) // jump to 1500ms — delivers exactly the events at offset <= 1500 +``` + +## Offsets + +Each event fires at its **offset** — its recorded `t` minus the first event's +`t`, so playback time `0` is the first event. `duration` is the last event's +offset. + +## Injected clock + scheduler + +The time source and scheduler are injected (mirroring the core session's +injected-clock seam), so tests get exact, deterministic timing: + +```js +new PlaybackClock(envelope, { clock, setTimeout, clearTimeout }) +``` + +They default to the real host (`Date.now` + global timers). Under a deterministic +injected scheduler — or `vi.useFakeTimers()` — events fire **exactly** at their +offsets (tolerance 0). Under the real host scheduler the tolerance is the host's +timer resolution (a few ms), the same bound as any `setTimeout`. + +## Seek is deterministic + +The clock keeps one invariant: `cursor` = the number of events whose offset is +`<= position`. So after `seek(t)` the delivered set is exactly the events at +offset `<= t`: + +- **Forward** (`seek` ahead, or playback advancing) delivers each newly-passed + event once, in order. +- **Backward** rewinds the cursor without delivering; passing those offsets again + going forward re-delivers them (so scrub-back-then-replay works). + +No event is ever delivered twice for a single forward pass, and none is dropped. + +## Invariant: envelope only, no game types + +This module imports **only** `@cozy-games/move-log` (to validate the envelope) and +never a game package. It treats every `event` payload as opaque. Enforced by a +dependency-graph guard in `test/playback-clock.test.js`. diff --git a/packages/replay/index.js b/packages/replay/index.js new file mode 100644 index 0000000..ff5717b --- /dev/null +++ b/packages/replay/index.js @@ -0,0 +1,197 @@ +// @ts-check +import { assertMoveLog } from '@cozy-games/move-log' + +/** + * `@cozy-games/replay` — the core of a game-agnostic replay engine. + * + * {@link PlaybackClock} re-drives a move-log envelope over time: it schedules + * each recorded event to fire at its OFFSET (its `t` relative to the first + * event) as playback time advances, and supports play / pause / seek. It + * consumes ONLY the generic envelope (`@cozy-games/move-log`) and never inspects + * the inside of an `event` — no game types cross this boundary. + * + * The clock and scheduler are injected (mirroring the core session's + * injected-clock seam), so tests drive it with fake timers or a hand-rolled + * scheduler for exact, deterministic timing. + */ + +/** + * @typedef {import('@cozy-games/move-log').MoveEvent} Event + * @typedef {import('@cozy-games/move-log').MoveLog} Envelope + * @typedef {{ + * clock?: () => number, + * setTimeout?: (fn: () => void, ms: number) => any, + * clearTimeout?: (handle: any) => void + * }} Deps + */ + +export class PlaybackClock { + /** + * @param {Envelope} envelope - a valid move-log envelope (validated here) + * @param {Deps} [deps] - injected time source + scheduler (default: real host) + */ + constructor(envelope, deps = {}) { + assertMoveLog(envelope) + + const { + clock = () => Date.now(), + setTimeout = (fn, ms) => globalThis.setTimeout(fn, ms), + clearTimeout = (handle) => globalThis.clearTimeout(handle) + } = deps + this._now = clock + this._setTimeout = setTimeout + this._clearTimeout = clearTimeout + + // 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 baseT = sorted.length ? sorted[0].t : 0 + /** @type {{ offset: number, record: Event }[]} */ + this._events = sorted.map(record => ({ offset: record.t - baseT, record })) + this._duration = this._events.length ? this._events[this._events.length - 1].offset : 0 + + // Playback state. Invariant: `_cursor` === number of events whose offset is + // <= the current position; events below the cursor have been delivered in the + // current forward pass. This single source of truth makes seek deterministic. + this._position = 0 + this._cursor = 0 + this._playing = false + /** @type {any} */ + this._timer = null + this._anchorClock = 0 + this._anchorPosition = 0 + /** @type {Set<(event: Event) => void>} */ + this._handlers = new Set() + } + + /** Total playback length in ms (offset of the last event; 0 if empty). */ + get duration() { + return this._duration + } + + /** @returns {boolean} */ + isPlaying() { + return this._playing + } + + /** Current playback position in ms, clamped to `[0, duration]`. */ + position() { + return this._livePosition() + } + + /** + * Subscribe to delivered events. The handler receives the raw envelope record + * (`{ seq, t, event, ... }`) — the payload stays opaque. Returns an unsubscribe. + * + * @param {(event: Event) => void} handler + * @returns {() => void} + */ + on(handler) { + this._handlers.add(handler) + return () => this._handlers.delete(handler) + } + + /** + * Start (or resume) playback from the current position. Any event already due + * at the current position fires synchronously; the rest are scheduled at their + * offsets. No-op if already playing or already at the end. + */ + play() { + if (this._playing) return + this._playing = true + this._anchorClock = this._now() + this._anchorPosition = this._position + this._scheduleNext() + } + + /** Pause playback, freezing the position where it currently is. */ + pause() { + if (!this._playing) return + this._position = this._livePosition() + this._playing = false + this._clearTimer() + } + + /** + * Seek to playback time `t` (ms, clamped to `[0, duration]`). Deterministic: + * afterwards the delivered set is exactly the events at offset <= t. Moving + * forward delivers the newly-passed events in order (each exactly once); moving + * backward rewinds the cursor without delivering, so a later forward pass + * re-delivers them. Re-schedules if playing. + * + * @param {number} t + */ + seek(t) { + if (typeof t !== 'number' || Number.isNaN(t)) { + throw new TypeError(`PlaybackClock.seek: t must be a number (got ${typeof t})`) + } + const wasPlaying = this._playing + this._clearTimer() + this._advanceTo(t) + if (wasPlaying) { + this._anchorClock = this._now() + this._anchorPosition = this._position + this._scheduleNext() + } + } + + // ---- internals ---- + + /** Live position: derived from the clock while playing, else the stored value. */ + _livePosition() { + if (!this._playing) return this._position + const raw = this._anchorPosition + (this._now() - this._anchorClock) + return Math.min(Math.max(raw, 0), this._duration) + } + + /** + * Move the cursor to match `target` position: emit events crossed going + * forward (once each), un-count events going backward (no emit). Sets position. + * @param {number} target + */ + _advanceTo(target) { + const t = Math.min(Math.max(target, 0), this._duration) + while (this._cursor < this._events.length && this._events[this._cursor].offset <= t) { + this._emit(this._events[this._cursor].record) + this._cursor++ + } + while (this._cursor > 0 && this._events[this._cursor - 1].offset > t) { + this._cursor-- + } + this._position = t + } + + /** Deliver an event to all subscribers. */ + _emit(record) { + for (const handler of this._handlers) handler(record) + } + + /** + * Deliver anything already due, then arm a timer for the next pending event. + * Ends playback when the cursor reaches the last event. + */ + _scheduleNext() { + this._clearTimer() + if (!this._playing) return + this._advanceTo(this._livePosition()) + if (this._cursor >= this._events.length) { + this._position = this._duration + this._playing = false + return + } + const delay = Math.max(0, this._events[this._cursor].offset - this._livePosition()) + this._timer = this._setTimeout(() => this._onTimer(), delay) + } + + _onTimer() { + this._timer = null + if (this._playing) this._scheduleNext() + } + + _clearTimer() { + if (this._timer !== null) { + this._clearTimeout(this._timer) + this._timer = null + } + } +} diff --git a/packages/replay/package.json b/packages/replay/package.json new file mode 100644 index 0000000..a8a6ba5 --- /dev/null +++ b/packages/replay/package.json @@ -0,0 +1,25 @@ +{ + "name": "@cozy-games/replay", + "version": "0.0.1", + "description": "Game-agnostic replay engine — a playback clock (play/pause/seek + event scheduling) that re-drives a move-log envelope over time", + "author": "Ayo Ayco", + "private": true, + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/ayo-run/mnswpr" + }, + "main": "index.js", + "exports": { + ".": { + "default": "./index.js" + }, + "./*": { + "default": "./*" + } + }, + "dependencies": { + "@cozy-games/move-log": "workspace:*" + }, + "license": "BSD-2-Clause" +} diff --git a/packages/replay/test/playback-clock.test.js b/packages/replay/test/playback-clock.test.js new file mode 100644 index 0000000..9bc5765 --- /dev/null +++ b/packages/replay/test/playback-clock.test.js @@ -0,0 +1,253 @@ +// @ts-check +import { describe, it, expect, vi, afterEach } from 'vitest' +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +// Imported via the PACKAGE NAME to prove the new workspace module resolves. +import { PlaybackClock } from '@cozy-games/replay' +import { createMoveLog } from '@cozy-games/move-log' + +/** + * A hand-rolled deterministic scheduler — the injected-clock seam in action, with + * zero reliance on vi internals. `advance(ms)` fires due timers in time order, + * picking up timers scheduled from within a firing callback. + */ +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 + } + } +} + +// Events at offsets 0, 100, 350 (t rebased from 1000). Payload is opaque to the clock. +function envelope() { + return createMoveLog([ + { seq: 1, t: 1000, event: { type: 'reveal', r: 0, c: 0 } }, + { seq: 2, t: 1100, event: { type: 'flag', r: 1, c: 2 } }, + { seq: 3, t: 1350, event: { type: 'chord', r: 4, c: 4 } } + ]) +} + +const typesOf = records => records.map(r => r.event.type) + +describe('PlaybackClock — construction & shape', () => { + it('rebases to offsets: duration is the last offset, first event at 0', () => { + const clock = new PlaybackClock(envelope(), fakeScheduler()) + expect(clock.duration).toBe(350) + expect(clock.position()).toBe(0) + expect(clock.isPlaying()).toBe(false) + }) + + it('validates the envelope (rejects a non-envelope)', () => { + expect(() => new PlaybackClock(/** @type {any} */ (null))).toThrow() + expect(() => new PlaybackClock(/** @type {any} */ ({ schema_version: 2, events: [] }))).toThrow() + }) + + it('handles an empty envelope gracefully', () => { + const clock = new PlaybackClock(createMoveLog([]), fakeScheduler()) + const seen = [] + clock.on(r => seen.push(r)) + expect(clock.duration).toBe(0) + clock.play() + expect(clock.isPlaying()).toBe(false) // nothing to play → ends immediately + expect(seen).toEqual([]) + }) +}) + +describe('PlaybackClock — play / pause with an injected scheduler', () => { + it('fires events at their recorded offsets, exactly', () => { + const s = fakeScheduler() + const clock = new PlaybackClock(envelope(), s) + const seen = [] + clock.on(r => seen.push({ type: r.event.type, at: s.clock() })) + + clock.play() + // offset-0 event fires synchronously on play + expect(seen).toEqual([{ type: 'reveal', at: 0 }]) + + s.advance(100) + expect(seen[1]).toEqual({ type: 'flag', at: 100 }) + + s.advance(250) // reach offset 350 + expect(seen[2]).toEqual({ type: 'chord', at: 350 }) + expect(clock.isPlaying()).toBe(false) // ended + expect(clock.position()).toBe(350) + }) + + it('pause freezes position and stops delivery', () => { + const s = fakeScheduler() + const clock = new PlaybackClock(envelope(), s) + const seen = [] + clock.on(r => seen.push(r.event.type)) + + clock.play() + s.advance(150) // past offset 100, between 100 and 350 + clock.pause() + expect(clock.position()).toBe(150) + expect(seen).toEqual(['reveal', 'flag']) + + s.advance(1000) // no timers should fire while paused + expect(seen).toEqual(['reveal', 'flag']) + expect(clock.position()).toBe(150) + }) + + it('resumes from the paused position', () => { + const s = fakeScheduler() + const clock = new PlaybackClock(envelope(), s) + const seen = [] + clock.on(r => seen.push({ type: r.event.type, at: s.clock() })) + + clock.play() + s.advance(150) + clock.pause() + clock.play() // resume at 150; next event at 350 ⇒ 200ms away + s.advance(200) + expect(seen.map(e => e.type)).toEqual(['reveal', 'flag', 'chord']) + expect(seen[2].at).toBe(350) // still fires at its true offset + }) +}) + +describe('PlaybackClock — seek determinism', () => { + it('seek forward delivers exactly the events at offset <= t, in order, once', () => { + const clock = new PlaybackClock(envelope(), fakeScheduler()) + const seen = [] + clock.on(r => seen.push(r)) + + clock.seek(200) // offsets 0 and 100 are <= 200; 350 is not + expect(typesOf(seen)).toEqual(['reveal', 'flag']) + expect(clock.position()).toBe(200) + + clock.seek(200) // no movement ⇒ no new deliveries + expect(typesOf(seen)).toEqual(['reveal', 'flag']) + }) + + it('seek boundary is inclusive (offset === t fires)', () => { + const clock = new PlaybackClock(envelope(), fakeScheduler()) + const seen = [] + clock.on(r => seen.push(r)) + clock.seek(100) + expect(typesOf(seen)).toEqual(['reveal', 'flag']) // offset 100 included + }) + + it('seek backward re-schedules with no duplicate or dropped events', () => { + const clock = new PlaybackClock(envelope(), fakeScheduler()) + const seen = [] + clock.on(r => seen.push(r)) + + clock.seek(400) // deliver all three + expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord']) + + clock.seek(50) // rewind — no delivery; only offset-0 stays "passed" + expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord']) // unchanged + + clock.seek(400) // forward again re-delivers the re-crossed events, once each + expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord', 'flag', 'chord']) + }) + + it('seek while playing re-anchors and keeps firing correctly', () => { + const s = fakeScheduler() + const clock = new PlaybackClock(envelope(), s) + const seen = [] + clock.on(r => seen.push(r.event.type)) + + clock.play() + s.advance(50) // only offset-0 delivered so far + expect(seen).toEqual(['reveal']) + + clock.seek(120) // jump forward while playing ⇒ deliver offset-100 event + expect(seen).toEqual(['reveal', 'flag']) + + s.advance(230) // reach 350 ⇒ final event + expect(seen).toEqual(['reveal', 'flag', 'chord']) + expect(clock.isPlaying()).toBe(false) + }) + + it('never delivers an event twice within a single forward pass', () => { + const s = fakeScheduler() + const clock = new PlaybackClock(envelope(), s) + const seqs = [] + clock.on(r => seqs.push(r.seq)) + clock.play() + s.advance(1000) + expect(seqs).toEqual([1, 2, 3]) // each once, in order + }) +}) + +describe('PlaybackClock — with vi fake timers', () => { + afterEach(() => { vi.useRealTimers() }) + + it('play/pause/seek work under vi.useFakeTimers()', () => { + vi.useFakeTimers() + vi.setSystemTime(0) + // Default deps ⇒ Date.now + global setTimeout, both faked by vi. + const clock = new PlaybackClock(envelope()) + const seen = [] + clock.on(r => seen.push(r.event.type)) + + clock.play() + expect(seen).toEqual(['reveal']) // offset 0 immediate + vi.advanceTimersByTime(100) + expect(seen).toEqual(['reveal', 'flag']) + clock.pause() + vi.advanceTimersByTime(1000) + expect(seen).toEqual(['reveal', 'flag']) // paused ⇒ frozen + clock.play() + vi.advanceTimersByTime(250) + expect(seen).toEqual(['reveal', 'flag', 'chord']) + }) +}) + +describe('game-agnosticism guard (envelope only, no game imports)', () => { + const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..') + const GAME_REFERENCES = /mnswpr|minesweeper/i + + 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 } + expect(Object.keys(deps).filter(name => GAME_REFERENCES.test(name))).toEqual([]) + }) + + it('no source file imports or references a game package', () => { + 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 (GAME_REFERENCES.test(code)) offenders.push(file) + }) + expect(offenders).toEqual([]) + }) +}) + +function walk(dir, fn) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules') continue + const p = join(dir, name) + if (statSync(p).isDirectory()) walk(p, fn) + else fn(p) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b71e138..b000b97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,12 @@ importers: packages/move-log: {} + packages/replay: + dependencies: + '@cozy-games/move-log': + specifier: workspace:* + version: link:../move-log + packages/utils: {} packages: