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
|
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 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
|
## Offsets
|
||||||
|
|
||||||
Each event fires at its **offset** — its recorded `t` minus the first event's
|
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)
|
this._lastProgress = /** @type {number | null} */ (null)
|
||||||
/** @type {Set<(update: { position: number, state: any }) => void>} */
|
/** @type {Set<(update: { position: number, state: any }) => void>} */
|
||||||
this._stateHandlers = new Set()
|
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). */
|
/** 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)
|
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
|
* Start (or resume) playback from the current position. Any event already due
|
||||||
* at the current position fires synchronously; the rest are scheduled at their
|
* at the current position fires synchronously; the rest are scheduled at their
|
||||||
|
|
@ -330,6 +349,7 @@ export class PlaybackClock {
|
||||||
this._position = t
|
this._position = t
|
||||||
this._emitProgressIfChanged()
|
this._emitProgressIfChanged()
|
||||||
if (this._cursor !== before) this._emitState()
|
if (this._cursor !== before) this._emitState()
|
||||||
|
this._maybeEmitEnded()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deliver an event to all subscribers. */
|
/** Deliver an event to all subscribers. */
|
||||||
|
|
@ -347,6 +367,22 @@ export class PlaybackClock {
|
||||||
for (const handler of this._progressHandlers) handler(update)
|
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. */
|
/** Push a reconstructed board state to subscribers — only in active full-board mode. */
|
||||||
_emitState() {
|
_emitState() {
|
||||||
if (!this._fullBoard || this._stateHandlers.size === 0) return
|
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