From 0b9866e05fbe6e97d62b9fc2f4a6536b8e8f5cbe Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 4 Jul 2026 13:39:48 +0200 Subject: [PATCH] feat(replay): add a defined "ended" signal (onEnd) --- packages/replay/README.md | 17 +++ packages/replay/index.js | 36 +++++ packages/replay/test/partial-logs.test.js | 174 ++++++++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 packages/replay/test/partial-logs.test.js diff --git a/packages/replay/README.md b/packages/replay/README.md index 5d87e38..adddfa2 100644 --- a/packages/replay/README.md +++ b/packages/replay/README.md @@ -64,6 +64,23 @@ clock.seek(1500) // state() now reflects the board at 1500ms Off by default: without the flag, `state()` is `null`, `onState` never fires, and the reducer never runs. See [docs/adapter-interface.md](./docs/adapter-interface.md). +### The "ended" signal & partial recordings + +`onEnd` fires with `{ position }` when playback reaches the **last recorded +event's offset**: + +```js +clock.onEnd(({ position }) => showEndScreen()) +``` + +The engine has no notion of a "terminal" event, so this is the *same* signal +whether the run completed or the recording was **truncated mid-game**. A partial +log plays up to its last event and stops cleanly in both progress and full-board +modes — no error for the mere absence of a terminal event. Progress **freezes at +its last computed value** (no extrapolation, no jump to 100%), because it's purely +the reducer over the delivered events. The signal re-arms if you seek back before +the end. + ## 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 c962f3b..a3fce9c 100644 --- a/packages/replay/index.js +++ b/packages/replay/index.js @@ -164,6 +164,10 @@ export class PlaybackClock { this._lastProgress = /** @type {number | null} */ (null) /** @type {Set<(update: { position: number, state: any }) => void>} */ this._stateHandlers = new Set() + /** @type {Set<(update: { position: number }) => void>} */ + this._endHandlers = new Set() + /** True once the end has been reached; re-arms when playback moves back before it. */ + this._ended = false } /** Total playback length in ms (offset of the last event; 0 if empty). */ @@ -259,6 +263,21 @@ export class PlaybackClock { return () => this._stateHandlers.delete(handler) } + /** + * Subscribe to the "ended" signal: fires with `{ position }` when playback + * reaches the last recorded event's offset — the SAME signal whether the run + * completed or the recording was truncated mid-game (the engine has no notion + * of a terminal event). Fires once on reaching the end and re-arms if playback + * moves back before it. Returns an unsubscribe. + * + * @param {(update: { position: number }) => void} handler + * @returns {() => void} + */ + onEnd(handler) { + this._endHandlers.add(handler) + return () => this._endHandlers.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 @@ -330,6 +349,7 @@ export class PlaybackClock { this._position = t this._emitProgressIfChanged() if (this._cursor !== before) this._emitState() + this._maybeEmitEnded() } /** Deliver an event to all subscribers. */ @@ -347,6 +367,22 @@ export class PlaybackClock { for (const handler of this._progressHandlers) handler(update) } + /** + * Fire "ended" once when every recorded event has been delivered (the timeline + * reached the last event's offset); re-arm when playback moves back before it. + * Terminal-agnostic: a truncated recording ends here exactly like a complete one. + */ + _maybeEmitEnded() { + const atEnd = this._events.length > 0 && this._cursor >= this._events.length + if (atEnd && !this._ended) { + this._ended = true + const update = { position: this._position } + for (const handler of this._endHandlers) handler(update) + } else if (!atEnd) { + this._ended = false + } + } + /** Push a reconstructed board state to subscribers — only in active full-board mode. */ _emitState() { if (!this._fullBoard || this._stateHandlers.size === 0) return diff --git a/packages/replay/test/partial-logs.test.js b/packages/replay/test/partial-logs.test.js new file mode 100644 index 0000000..193a9ab --- /dev/null +++ b/packages/replay/test/partial-logs.test.js @@ -0,0 +1,174 @@ +// @ts-check +import { describe, it, expect } from 'vitest' +import { PlaybackClock } from '@cozy-games/replay' +import { createMoveLog } from '@cozy-games/move-log' + +// Real mnswpr run + reducers — imported by the TEST (relative), so no game +// dependency enters the engine's manifest. +import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js' +import { createProgressReducer } from '../../mnswpr/adapters/replay-progress.js' +import { createStateReducer } from '../../mnswpr/adapters/replay-state.js' + +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 + } + } +} + +const board = () => ({ + 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]] +}) + +// A TRUNCATED recording: two opening reveals, then the log just stops — the game +// is still 'active' (2 of 8 safe cells), no win/loss ever recorded. +function truncatedEnvelope() { + let now = 0 + const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(board()), clock: () => now }) + const emitted = [] + session.onMove(e => emitted.push(e)) + now = 1000; session.applyMove({ type: 'reveal', r: 0, c: 1 }) + now = 1200; session.applyMove({ type: 'reveal', r: 1, c: 0 }) + // ...stream cut here — no terminal event. + const baseT = 1000 + return { + envelope: createMoveLog(emitted.map(e => ({ seq: e.seq, t: e.t, event: e }))), + lastOffset: 1200 - baseT // 200 + } +} + +describe('partial/incomplete recordings — progress mode', () => { + it('replays to the last event without error and freezes progress (no jump to 100%)', () => { + const { envelope, lastOffset } = truncatedEnvelope() + const s = fakeScheduler() + const clock = new PlaybackClock(envelope, s, { progress: createProgressReducer(board()) }) + + const progressUpdates = [] + const ends = [] + clock.onProgress(u => progressUpdates.push(u)) + clock.onEnd(u => ends.push(u)) + + expect(() => { clock.play(); s.advance(10_000) }).not.toThrow() // long past the last event + + // Frozen at the true value for 2/8 safe cells — NOT extrapolated to 100. + expect(clock.progress()).toBeCloseTo(25, 5) + expect(clock.isPlaying()).toBe(false) + + // "ended" fired once, at the last event's offset. + expect(ends).toHaveLength(1) + expect(ends[0].position).toBe(lastOffset) + + // Progress held at its last value after the stream ended. + expect(progressUpdates.at(-1).progress).toBeCloseTo(25, 5) + }) + + it('progress stays frozen when time advances past the end', () => { + const { envelope } = truncatedEnvelope() + const s = fakeScheduler() + const clock = new PlaybackClock(envelope, s, { progress: createProgressReducer(board()) }) + clock.play() + s.advance(300) + const atEnd = clock.progress() + s.advance(5000) // way past + expect(clock.progress()).toBe(atEnd) // no drift, no extrapolation + expect(atEnd).toBeCloseTo(25, 5) + }) +}) + +describe('partial/incomplete recordings — full-board mode', () => { + it('reconstructs the last recorded state without error and ends cleanly', () => { + const { envelope, lastOffset } = truncatedEnvelope() + const s = fakeScheduler() + const clock = new PlaybackClock(envelope, s, { state: createStateReducer(board()) }, { fullBoard: true }) + const ends = [] + clock.onEnd(u => ends.push(u)) + + expect(() => { clock.play(); s.advance(10_000) }).not.toThrow() + + const b = clock.state() + expect(b.phase).toBe('active') // never reached a terminal state — and that's fine + expect(b.revealedSafe).toBe(2) + expect(b.cells[0][1].status).toBe('revealed') + expect(b.cells[2][2].status).toBe('hidden') // rest still unopened + + expect(ends).toHaveLength(1) + expect(ends[0].position).toBe(lastOffset) + }) +}) + +describe('the "ended" signal', () => { + it('fires identically for a complete run and a truncated one', () => { + // Complete run: play the board to a win. + let now = 0 + const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(board()), clock: () => now }) + const emitted = [] + session.onMove(e => emitted.push(e)) + now = 1000; session.applyMove({ type: 'reveal', r: 2, c: 2 }) // floods all 8 → won + const complete = createMoveLog(emitted.map(e => ({ seq: e.seq, t: e.t, event: e }))) + + const s = fakeScheduler() + const clock = new PlaybackClock(complete, s) + const ends = [] + clock.onEnd(u => ends.push(u)) + clock.play() + s.advance(1000) + expect(ends).toHaveLength(1) + expect(ends[0].position).toBe(clock.duration) // last event's offset + }) + + it('fires on seek to the end and re-arms after seeking back', () => { + const { envelope } = truncatedEnvelope() + const clock = new PlaybackClock(envelope, fakeScheduler()) + const ends = [] + clock.onEnd(() => ends.push(1)) + + clock.seek(clock.duration) // reach the end + expect(ends).toHaveLength(1) + + clock.seek(clock.duration) // still at the end — no re-fire + expect(ends).toHaveLength(1) + + clock.seek(0) // move back — re-arm + clock.seek(clock.duration) // reach the end again + expect(ends).toHaveLength(2) + }) + + it('does not fire for an empty envelope', () => { + const clock = new PlaybackClock(createMoveLog([]), fakeScheduler()) + const ends = [] + clock.onEnd(() => ends.push(1)) + clock.play() + clock.seek(0) + expect(ends).toEqual([]) + }) +})