cozy-games/packages/replay/test/partial-logs.test.js

174 lines
6.1 KiB
JavaScript

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