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