feat(replay): add a defined "ended" signal (onEnd)

This commit is contained in:
ayo 2026-07-04 13:39:48 +02:00
parent 43b75d7b0e
commit 0b9866e05f
3 changed files with 227 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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([])
})
})