feat(replay): add a defined "ended" signal (onEnd)
This commit is contained in:
parent
43b75d7b0e
commit
0b9866e05f
3 changed files with 227 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
174
packages/replay/test/partial-logs.test.js
Normal file
174
packages/replay/test/partial-logs.test.js
Normal 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([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue