diff --git a/packages/replay/README.md b/packages/replay/README.md index 6d869cc..71fc2e1 100644 --- a/packages/replay/README.md +++ b/packages/replay/README.md @@ -32,6 +32,23 @@ 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. +### Progress mode: a signal over time + +`onProgress` turns the clock + reducer into a live "percent complete over elapsed +time" signal — updates as playback advances, on seek forward, and on seek back: + +```js +const clock = new PlaybackClock(envelope, {}, adapter) +clock.onProgress(({ position, progress }) => draw(position, progress)) +clock.play() +``` + +Fidelity is exact: at playback time `t` the delivered set is precisely the events +at offset `≤ t`, so the emitted `progress` matches the original run's progress at +`t`. Updates fire only when the percentage moves (a flag/unflag emits nothing). +Subscribe before playing to catch every update; call `progress()` for the current +value at any time. + ## Offsets Each event fires at its **offset** — its recorded `t` minus the first event's diff --git a/packages/replay/index.js b/packages/replay/index.js index e1d19f9..50edf25 100644 --- a/packages/replay/index.js +++ b/packages/replay/index.js @@ -87,6 +87,10 @@ export class PlaybackClock { this._anchorPosition = 0 /** @type {Set<(event: Event) => void>} */ this._handlers = new Set() + /** @type {Set<(update: { position: number, progress: number }) => void>} */ + this._progressHandlers = new Set() + /** Last progress value pushed, so unchanged progress (e.g. a flag) is not re-emitted. */ + this._lastProgress = /** @type {number | null} */ (null) } /** Total playback length in ms (offset of the last event; 0 if empty). */ @@ -135,6 +139,23 @@ export class PlaybackClock { return () => this._handlers.delete(handler) } + /** + * Progress-mode subscription: receive `{ position, progress }` updates as + * playback advances — the "percent complete over elapsed time" signal. Fires + * only when the percentage actually changes (so flags/unflags, which don't + * advance progress, emit nothing), on play, seek forward, and seek backward. + * Requires an adapter with a `progress` reducer; without one it never emits. + * Subscribe before playing to catch every update; use {@link progress} for the + * current value at any time. Returns an unsubscribe. + * + * @param {(update: { position: number, progress: number }) => void} handler + * @returns {() => void} + */ + onProgress(handler) { + this._progressHandlers.add(handler) + return () => this._progressHandlers.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 @@ -203,6 +224,7 @@ export class PlaybackClock { this._cursor-- } this._position = t + this._emitProgressIfChanged() } /** Deliver an event to all subscribers. */ @@ -210,6 +232,16 @@ export class PlaybackClock { for (const handler of this._handlers) handler(record) } + /** Push a progress update to subscribers, but only when the percentage moved. */ + _emitProgressIfChanged() { + if (this._progressHandlers.size === 0) return + const progress = this.progress() + if (progress === null || progress === this._lastProgress) return + this._lastProgress = progress + const update = { position: this._position, progress } + for (const handler of this._progressHandlers) handler(update) + } + /** * Deliver anything already due, then arm a timer for the next pending event. * Ends playback when the cursor reaches the last event. diff --git a/packages/replay/test/progress-mode.test.js b/packages/replay/test/progress-mode.test.js new file mode 100644 index 0000000..ffc135f --- /dev/null +++ b/packages/replay/test/progress-mode.test.js @@ -0,0 +1,184 @@ +// @ts-check +import { describe, it, expect } from 'vitest' +import { PlaybackClock } from '@cozy-games/replay' +import { createMoveLog } from '@cozy-games/move-log' + +// A real mnswpr run + its reducer — imported by the TEST via relative paths, so +// no game dependency enters the replay engine's manifest. +import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js' +import { createProgressReducer } from '../../mnswpr/adapters/replay-progress.js' + +/** Deterministic injected scheduler (the injected-clock seam), no vi needed. */ +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 + } + } +} + +// 3x3, single mine at (0,0). Total safe = 8. +const layout = () => ({ + rows: 3, + cols: 3, + mines: 1, + cells: [ + [{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }], + [{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }], + [{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }] + ], + mineLocations: [[0, 0]] +}) + +// Drive a real session, recording BOTH the emitted move-events and the ground +// truth (revealedSafe) after each move — an independent source of truth. +const board = layout() +const TOTAL_SAFE = 8 +const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(board), clock: () => nowClock }) +let nowClock = 0 +const emitted = [] +session.onMove(e => emitted.push(e)) +const truthPoints = [] // { offset, revealedSafe } after each move + +const script = [ + { at: 1000, move: { type: 'reveal', r: 0, c: 1 } }, // +1 safe cell + { at: 1100, move: { type: 'reveal', r: 1, c: 0 } }, // +1 safe cell + { at: 1200, move: { type: 'flag', r: 0, c: 0 } }, // flag the mine — no progress + { at: 1300, move: { type: 'reveal', r: 2, c: 2 } } // floods the rest → all 8 +] +const baseT = script[0].at +for (const step of script) { + nowClock = step.at + session.applyMove(step.move) + truthPoints.push({ offset: step.at - baseT, revealedSafe: session.state.revealedSafe }) +} + +const records = emitted.map(e => ({ seq: e.seq, t: e.t, event: e })) +const envelope = createMoveLog(records) + +// Ground truth for the mnswpr reducer: revealedSafe / total at a given offset — +// derived from the session, NOT from the reducer under test. +function mnswprTruth(offset) { + let revealedSafe = 0 + for (const p of truthPoints) if (p.offset <= offset) revealedSafe = p.revealedSafe + return (revealedSafe / TOTAL_SAFE) * 100 +} + +// A second, unrelated adapter: percent of events delivered. Ground truth is the +// count of records at offset <= t. +const totalEvents = records.length +const dummyReduce = events => (events.length / totalEvents) * 100 +function dummyTruth(offset) { + return (records.filter(r => (r.t - baseT) <= offset).length / totalEvents) * 100 +} + +const CASES = [ + { name: 'mnswpr percent-cleared', adapter: { progress: createProgressReducer(board) }, truth: mnswprTruth }, + { name: 'dummy percent-of-events', adapter: { progress: dummyReduce }, truth: dummyTruth } +] + +describe.each(CASES)('progress mode — same code path, adapter: $name', ({ adapter, truth }) => { + const clamp = t => Math.max(0, Math.min(t, envelope.events[envelope.events.length - 1].t - baseT)) + + it('progress() matches the source run at multiple points (via seek)', () => { + const clock = new PlaybackClock(envelope, fakeScheduler(), adapter) + for (const t of [-10, 0, 50, 100, 150, 200, 250, 300, 400]) { + clock.seek(t) + expect(clock.progress()).toBeCloseTo(truth(clamp(t)), 5) + } + }) + + it('progress() matches the source run while playing (fake timers)', () => { + const s = fakeScheduler() + const clock = new PlaybackClock(envelope, s, adapter) + const updates = [] + clock.onProgress(u => updates.push(u)) + clock.play() + + let last = 0 + for (const cp of [0, 100, 200, 300]) { + s.advance(cp - last) + last = cp + expect(clock.progress()).toBeCloseTo(truth(cp), 5) + } + expect(clock.progress()).toBeCloseTo(truth(clock.duration), 5) + + // The pushed signal is non-decreasing during forward play and ends at 100. + const vals = updates.map(u => u.progress) + expect(vals).toEqual([...vals].sort((a, b) => a - b)) + expect(vals.at(-1)).toBeCloseTo(truth(clock.duration), 5) + // Each emitted update matches ground truth at the position it reports. + for (const u of updates) expect(u.progress).toBeCloseTo(truth(u.position), 5) + }) + + it('seek forward and backward move the progress signal correctly', () => { + const clock = new PlaybackClock(envelope, fakeScheduler(), adapter) + const vals = [] + clock.onProgress(u => vals.push(u.progress)) + + clock.seek(clock.duration) // forward to the end + expect(clock.progress()).toBeCloseTo(truth(clock.duration), 5) + + clock.seek(0) // jump back to the start + expect(clock.progress()).toBeCloseTo(truth(0), 5) + + expect(vals[0]).toBeCloseTo(truth(clock.duration), 5) // went up first + expect(vals.at(-1)).toBeCloseTo(truth(0), 5) // then down + expect(vals.at(-1)).toBeLessThan(vals[0]) + }) +}) + +describe('progress mode — signal behavior', () => { + it('does not emit for events that leave progress unchanged (flags)', () => { + // mnswpr: the flag at offset 200 must NOT produce a progress update. + const s = fakeScheduler() + const clock = new PlaybackClock(envelope, s, { progress: createProgressReducer(board) }) + const updates = [] + clock.onProgress(u => updates.push(u)) + clock.play() + s.advance(400) + // reveals at 0, 100, 300 changed progress; the flag at 200 did not. + expect(updates.map(u => u.position)).toEqual([0, 100, 300]) + expect(updates.map(u => Math.round(u.progress))).toEqual([13, 25, 100]) + }) + + it('emits nothing when no progress adapter is supplied', () => { + const clock = new PlaybackClock(envelope, fakeScheduler()) + const updates = [] + clock.onProgress(u => updates.push(u)) + clock.seek(clock.duration) + expect(updates).toEqual([]) + expect(clock.progress()).toBe(null) + }) + + it('unsubscribe stops progress delivery', () => { + const clock = new PlaybackClock(envelope, fakeScheduler(), { progress: dummyReduce }) + const updates = [] + const off = clock.onProgress(u => updates.push(u)) + clock.seek(100) + off() + clock.seek(300) + expect(updates).toHaveLength(1) // only the first jump delivered + }) +})