feat(replay): state reducer & full board playback
This commit is contained in:
parent
072d2f7369
commit
998aeacbb2
8 changed files with 442 additions and 25 deletions
24
packages/mnswpr/adapters/replay-common.js
Normal file
24
packages/mnswpr/adapters/replay-common.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
|
||||
*/
|
||||
|
||||
/**
|
||||
* Map a recorded move-event back to the rules move that produced it: `flag` and
|
||||
* `unflag` are both the toggle move `flag`; `reveal` and `chord` pass through.
|
||||
* Unknown kinds are ignored. Shared by the mnswpr replay adapters (progress and
|
||||
* full-board state) so both replay a stream through the core rules identically.
|
||||
*
|
||||
* @param {MnswprMoveEvent} e
|
||||
* @returns {{ type: 'reveal' | 'flag' | 'chord', r: number, c: number } | null}
|
||||
*/
|
||||
export function toMove(e) {
|
||||
switch (e && e.type) {
|
||||
case 'reveal': return { type: 'reveal', r: e.r, c: e.c }
|
||||
case 'chord': return { type: 'chord', r: e.r, c: e.c }
|
||||
case 'flag':
|
||||
case 'unflag': return { type: 'flag', r: e.r, c: e.c }
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
// @ts-check
|
||||
import { MinesweeperRules } from '../core/minesweeper/rules.js'
|
||||
import { toMove } from './replay-common.js'
|
||||
|
||||
/**
|
||||
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
|
||||
|
|
@ -37,21 +38,3 @@ export function createProgressReducer(layout) {
|
|||
return (state.revealedSafe / totalSafe) * 100
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a recorded move-event back to the rules move that produced it: `flag` and
|
||||
* `unflag` are both the toggle move `flag`; `reveal` and `chord` pass through.
|
||||
* Unknown kinds are ignored.
|
||||
*
|
||||
* @param {MnswprMoveEvent} e
|
||||
* @returns {{ type: 'reveal' | 'flag' | 'chord', r: number, c: number } | null}
|
||||
*/
|
||||
function toMove(e) {
|
||||
switch (e && e.type) {
|
||||
case 'reveal': return { type: 'reveal', r: e.r, c: e.c }
|
||||
case 'chord': return { type: 'chord', r: e.r, c: e.c }
|
||||
case 'flag':
|
||||
case 'unflag': return { type: 'flag', r: e.r, c: e.c }
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
packages/mnswpr/adapters/replay-state.js
Normal file
57
packages/mnswpr/adapters/replay-state.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// @ts-check
|
||||
import { MinesweeperRules } from '../core/minesweeper/rules.js'
|
||||
import { toMove } from './replay-common.js'
|
||||
|
||||
/**
|
||||
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
|
||||
* @typedef {import('../core/minesweeper/board.js').Layout} Layout
|
||||
* @typedef {{ mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }} BoardCell
|
||||
* @typedef {{ rows: number, cols: number, phase: string, revealedSafe: number, cells: BoardCell[][] }} BoardState
|
||||
*/
|
||||
|
||||
/**
|
||||
* The full-board state reducer for Minesweeper — mnswpr's implementation of the
|
||||
* replay engine's `StateReducer<MnswprMoveEvent, BoardState>` seam (replay-05).
|
||||
* Given the ordered slice of move-events played so far, it reconstructs the
|
||||
* COMPLETE board at that point: every cell's mine/adjacent/status plus the phase.
|
||||
*
|
||||
* Like the progress reducer, it takes the board as closure input and replays the
|
||||
* moves through the pure core rules — so reveals flood, chords open their
|
||||
* neighbors, and flags toggle — giving an exact reconstruction at any event
|
||||
* index. Statelessly a function of the slice, so the engine can jump (seek) to
|
||||
* any position and rebuild the board there.
|
||||
*
|
||||
* @param {Layout} layout - the recorded board (as produced by `generateBoard`)
|
||||
* @returns {(events: { event: MnswprMoveEvent }[]) => BoardState}
|
||||
*/
|
||||
export function createStateReducer(layout) {
|
||||
return function state(events) {
|
||||
let s = MinesweeperRules.fromLayout(layout)
|
||||
for (const record of events) {
|
||||
const move = toMove(record.event)
|
||||
if (move) s = MinesweeperRules.apply(s, move).state
|
||||
}
|
||||
return toBoard(s)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a core game state into a plain, render-ready 2D board snapshot.
|
||||
* @param {import('../core/minesweeper/rules.js').State} s
|
||||
* @returns {BoardState}
|
||||
*/
|
||||
function toBoard(s) {
|
||||
const { rows, cols } = s.config
|
||||
/** @type {BoardCell[][]} */
|
||||
const cells = []
|
||||
for (let r = 0; r < rows; r++) {
|
||||
/** @type {BoardCell[]} */
|
||||
const row = []
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const cell = s.grid.at(r, c)
|
||||
row.push({ mine: cell.mine, adjacent: cell.adjacent, status: cell.status })
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
return { rows, cols, phase: s.phase, revealedSafe: s.revealedSafe, cells }
|
||||
}
|
||||
104
packages/mnswpr/test/replay-state.test.js
Normal file
104
packages/mnswpr/test/replay-state.test.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { GameSession, MinesweeperRules } from '../core/index.js'
|
||||
import { createStateReducer } from '../adapters/replay-state.js'
|
||||
|
||||
// Drive a session over an injected layout, capturing the real move-event stream.
|
||||
function record(layout, moves) {
|
||||
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout) })
|
||||
const events = []
|
||||
session.onMove(e => events.push(e))
|
||||
for (const m of moves) session.applyMove(m)
|
||||
return { events: events.map(event => ({ seq: event.seq, t: event.t, event })), session }
|
||||
}
|
||||
|
||||
// 3x3, single mine at (0,0). Total safe = 8.
|
||||
const smallBoard = () => ({
|
||||
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]]
|
||||
})
|
||||
|
||||
describe('mnswpr state reducer (full-board reconstruction)', () => {
|
||||
const layout = smallBoard()
|
||||
const { events } = record(layout, [
|
||||
{ type: 'reveal', r: 0, c: 1 },
|
||||
{ type: 'reveal', r: 1, c: 0 },
|
||||
{ type: 'flag', r: 0, c: 0 },
|
||||
{ type: 'reveal', r: 2, c: 2 } // floods the rest
|
||||
])
|
||||
const state = createStateReducer(layout)
|
||||
const status = (b, r, c) => b.cells[r][c].status
|
||||
|
||||
it('empty slice → pristine board (all hidden, fresh)', () => {
|
||||
const b = state([])
|
||||
expect(b.rows).toBe(3)
|
||||
expect(b.cols).toBe(3)
|
||||
expect(b.phase).toBe('fresh')
|
||||
expect(b.revealedSafe).toBe(0)
|
||||
expect(b.cells.flat().every(c => c.status === 'hidden')).toBe(true)
|
||||
})
|
||||
|
||||
it('reconstructs the exact board state at several event indices', () => {
|
||||
// index 1 — after reveal(0,1): just that cell open
|
||||
let b = state(events.slice(0, 1))
|
||||
expect(status(b, 0, 1)).toBe('revealed')
|
||||
expect(status(b, 0, 0)).toBe('hidden')
|
||||
expect(status(b, 1, 0)).toBe('hidden')
|
||||
expect(b.revealedSafe).toBe(1)
|
||||
expect(b.phase).toBe('active')
|
||||
|
||||
// index 2 — after reveal(1,0)
|
||||
b = state(events.slice(0, 2))
|
||||
expect(status(b, 0, 1)).toBe('revealed')
|
||||
expect(status(b, 1, 0)).toBe('revealed')
|
||||
expect(b.revealedSafe).toBe(2)
|
||||
|
||||
// index 3 — after flag(0,0): mine flagged, nothing new revealed
|
||||
b = state(events.slice(0, 3))
|
||||
expect(status(b, 0, 0)).toBe('flagged')
|
||||
expect(b.revealedSafe).toBe(2)
|
||||
|
||||
// index 4 — after reveal(2,2): floods all safe cells; mine stays flagged; won
|
||||
b = state(events.slice(0, 4))
|
||||
expect(b.phase).toBe('won')
|
||||
expect(b.revealedSafe).toBe(8)
|
||||
for (let r = 0; r < 3; r++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
expect(status(b, r, c)).toBe(r === 0 && c === 0 ? 'flagged' : 'revealed')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('carries mine/adjacent metadata for rendering', () => {
|
||||
const b = state(events)
|
||||
expect(b.cells[0][0].mine).toBe(true)
|
||||
expect(b.cells[0][1].mine).toBe(false)
|
||||
expect(b.cells[0][1].adjacent).toBe(1)
|
||||
})
|
||||
|
||||
it('is stateless: the same slice reconstructs an equal board', () => {
|
||||
expect(state(events.slice(0, 2))).toEqual(state(events.slice(0, 2)))
|
||||
// and reconstructing a shorter slice after a longer one is unaffected
|
||||
const full = state(events)
|
||||
expect(state(events.slice(0, 1)).revealedSafe).toBe(1)
|
||||
expect(full.revealedSafe).toBe(8)
|
||||
})
|
||||
|
||||
it('counts chord reveals in the reconstructed board', () => {
|
||||
// reveal a number, flag the mine, then chord it open.
|
||||
const { events: chordEvents } = record(smallBoard(), [
|
||||
{ type: 'reveal', r: 0, c: 1 },
|
||||
{ type: 'flag', r: 0, c: 0 },
|
||||
{ type: 'chord', r: 0, c: 1 }
|
||||
])
|
||||
const b = createStateReducer(smallBoard())(chordEvents)
|
||||
expect(b.revealedSafe).toBe(8)
|
||||
expect(b.cells[2][2].status).toBe('revealed')
|
||||
})
|
||||
})
|
||||
|
|
@ -49,6 +49,21 @@ at offset `≤ t`, so the emitted `progress` matches the original run's progress
|
|||
Subscribe before playing to catch every update; call `progress()` for the current
|
||||
value at any time.
|
||||
|
||||
### Full-board mode (flag-gated)
|
||||
|
||||
With a `state` reducer and the `fullBoard` flag, the engine reconstructs the whole
|
||||
board at any point — `state()` for the current board, `onState` for a stream, and
|
||||
`seek(t)` rebuilds the exact state at `t`:
|
||||
|
||||
```js
|
||||
const clock = new PlaybackClock(envelope, {}, adapter, { fullBoard: true })
|
||||
clock.onState(({ position, state }) => render(state))
|
||||
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).
|
||||
|
||||
## Offsets
|
||||
|
||||
Each event fires at its **offset** — its recorded `t` minus the first event's
|
||||
|
|
|
|||
|
|
@ -16,12 +16,14 @@ new PlaybackClock(envelope, deps, adapter)
|
|||
```
|
||||
|
||||
```ts
|
||||
// Typed generically over the game's event vocabulary T.
|
||||
// Typed generically over the game's event vocabulary T (and state S).
|
||||
type ReplayAdapter<T> = {
|
||||
progress?: ProgressReducer<T>
|
||||
state?: StateReducer<T, S>
|
||||
}
|
||||
|
||||
type ProgressReducer<T> = (events: MoveEvent<T>[]) => number // 0–100
|
||||
type ProgressReducer<T> = (events: MoveEvent<T>[]) => number // 0–100
|
||||
type StateReducer<T, S> = (events: MoveEvent<T>[]) => S // full game state
|
||||
```
|
||||
|
||||
`MoveEvent<T>` is the move-log record `{ seq, t, event, receivedTs? }`, where
|
||||
|
|
@ -55,6 +57,32 @@ clock.seek(1500)
|
|||
clock.progress() // → e.g. 42
|
||||
```
|
||||
|
||||
## `state(events) → S` (full-board mode)
|
||||
|
||||
The second reducer reconstructs the **complete game state** at a playback point
|
||||
from the ordered slice of events delivered so far. It powers full-board replay —
|
||||
rebuilding the whole board on seek, not just a percentage.
|
||||
|
||||
- **Input:** `MoveEvent<T>[]` — the played-so-far slice, in order.
|
||||
- **Output:** the game's own state type `S` (opaque to the engine). For mnswpr
|
||||
it's a 2D board snapshot: `{ rows, cols, phase, revealedSafe, cells }`.
|
||||
- **When:** `clock.state()` returns the current reconstruction, and `onState`
|
||||
streams `{ position, state }` as playback advances or seeks.
|
||||
|
||||
### Flag-gated
|
||||
|
||||
Full-board mode is **off by default** and gated behind a runtime flag — the
|
||||
engine's minimal, documented feature-flag seam:
|
||||
|
||||
```js
|
||||
new PlaybackClock(envelope, deps, adapter, { fullBoard: true })
|
||||
```
|
||||
|
||||
When the flag is **off** (default), the mode is fully inert: `state()` returns
|
||||
`null`, `onState` never fires, and the state reducer is never invoked (no
|
||||
reconstruction cost). When **on** with a `state` reducer supplied, `state()` and
|
||||
`onState` reconstruct the board — and `seek(t)` rebuilds the exact state at `t`.
|
||||
|
||||
## Contract rules
|
||||
|
||||
- **The engine calls the reducer; it never interprets events itself.** Engine
|
||||
|
|
|
|||
|
|
@ -36,27 +36,44 @@ import { assertMoveLog } from '@cozy-games/move-log'
|
|||
* @typedef {(events: import('@cozy-games/move-log').MoveEvent<T>[]) => number} ProgressReducer
|
||||
*/
|
||||
|
||||
/**
|
||||
* A reducer supplied by a game adapter for full-board replay: given the ordered
|
||||
* slice of events played so far, reconstruct the complete game state `S` at that
|
||||
* point. Typed generically over the event vocabulary `T` and the (opaque) state
|
||||
* `S`. Powers the flag-gated full-board mode; the engine treats `S` as a black box.
|
||||
*
|
||||
* @template T, S
|
||||
* @typedef {(events: import('@cozy-games/move-log').MoveEvent<T>[]) => S} StateReducer
|
||||
*/
|
||||
|
||||
/**
|
||||
* The replay game-adapter contract (v0) — the seam through which game meaning
|
||||
* enters the engine. Currently one optional method; more join as the contract
|
||||
* grows. See `docs/adapter-interface.md`.
|
||||
* enters the engine. Both methods are optional; the engine calls whichever the
|
||||
* mode needs and never interprets an event itself. See `docs/adapter-interface.md`.
|
||||
*
|
||||
* @template T
|
||||
* @typedef {{ progress?: ProgressReducer<T> }} ReplayAdapter
|
||||
* @typedef {{ progress?: ProgressReducer<T>, state?: StateReducer<T, any> }} ReplayAdapter
|
||||
*/
|
||||
|
||||
export class PlaybackClock {
|
||||
/**
|
||||
* @param {Envelope} envelope - a valid move-log envelope (validated here)
|
||||
* @param {Deps} [deps] - injected time source + scheduler (default: real host)
|
||||
* @param {ReplayAdapter<any>} [adapter] - game adapter (e.g. a progress reducer)
|
||||
* @param {ReplayAdapter<any>} [adapter] - game adapter (progress / state reducers)
|
||||
* @param {{ fullBoard?: boolean }} [options] - `fullBoard` flag-gates full-board
|
||||
* mode (default OFF: `state()`/`onState` are inert and the state reducer is
|
||||
* never called). The minimal, documented feature-flag seam for this engine.
|
||||
*/
|
||||
constructor(envelope, deps = {}, adapter = {}) {
|
||||
constructor(envelope, deps = {}, adapter = {}, options = {}) {
|
||||
assertMoveLog(envelope)
|
||||
if (adapter.progress !== undefined && typeof adapter.progress !== 'function') {
|
||||
throw new TypeError('PlaybackClock: adapter.progress must be a function when provided')
|
||||
}
|
||||
if (adapter.state !== undefined && typeof adapter.state !== 'function') {
|
||||
throw new TypeError('PlaybackClock: adapter.state must be a function when provided')
|
||||
}
|
||||
this._adapter = adapter
|
||||
this._fullBoard = options.fullBoard === true
|
||||
|
||||
const {
|
||||
clock = () => Date.now(),
|
||||
|
|
@ -91,6 +108,8 @@ export class PlaybackClock {
|
|||
this._progressHandlers = new Set()
|
||||
/** Last progress value pushed, so unchanged progress (e.g. a flag) is not re-emitted. */
|
||||
this._lastProgress = /** @type {number | null} */ (null)
|
||||
/** @type {Set<(update: { position: number, state: any }) => void>} */
|
||||
this._stateHandlers = new Set()
|
||||
}
|
||||
|
||||
/** Total playback length in ms (offset of the last event; 0 if empty). */
|
||||
|
|
@ -127,6 +146,22 @@ export class PlaybackClock {
|
|||
return Math.min(100, Math.max(0, pct))
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-board mode: the reconstructed game state at the current position, via the
|
||||
* adapter's `state` reducer over the delivered slice. Returns `null` unless the
|
||||
* `fullBoard` flag is on AND a state reducer was supplied — so the mode is inert
|
||||
* (and the reducer never runs) by default. The state shape `S` is the adapter's;
|
||||
* the engine treats it as opaque.
|
||||
*
|
||||
* @returns {any}
|
||||
*/
|
||||
state() {
|
||||
if (!this._fullBoard) return null
|
||||
const reduce = this._adapter.state
|
||||
if (typeof reduce !== 'function') return null
|
||||
return reduce(this._events.slice(0, this._cursor).map(e => e.record))
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to delivered events. The handler receives the raw envelope record
|
||||
* (`{ seq, t, event, ... }`) — the payload stays opaque. Returns an unsubscribe.
|
||||
|
|
@ -156,6 +191,20 @@ export class PlaybackClock {
|
|||
return () => this._progressHandlers.delete(handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-board mode subscription: receive `{ position, state }` whenever the
|
||||
* delivered set changes (play, seek forward, seek backward), where `state` is
|
||||
* the adapter's reconstruction at that position. Inert unless the `fullBoard`
|
||||
* flag is on and a state reducer was supplied. Returns an unsubscribe.
|
||||
*
|
||||
* @param {(update: { position: number, state: any }) => void} handler
|
||||
* @returns {() => void}
|
||||
*/
|
||||
onState(handler) {
|
||||
this._stateHandlers.add(handler)
|
||||
return () => this._stateHandlers.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
|
||||
|
|
@ -216,6 +265,7 @@ export class PlaybackClock {
|
|||
*/
|
||||
_advanceTo(target) {
|
||||
const t = Math.min(Math.max(target, 0), this._duration)
|
||||
const before = this._cursor
|
||||
while (this._cursor < this._events.length && this._events[this._cursor].offset <= t) {
|
||||
this._emit(this._events[this._cursor].record)
|
||||
this._cursor++
|
||||
|
|
@ -225,6 +275,7 @@ export class PlaybackClock {
|
|||
}
|
||||
this._position = t
|
||||
this._emitProgressIfChanged()
|
||||
if (this._cursor !== before) this._emitState()
|
||||
}
|
||||
|
||||
/** Deliver an event to all subscribers. */
|
||||
|
|
@ -242,6 +293,14 @@ export class PlaybackClock {
|
|||
for (const handler of this._progressHandlers) handler(update)
|
||||
}
|
||||
|
||||
/** Push a reconstructed board state to subscribers — only in active full-board mode. */
|
||||
_emitState() {
|
||||
if (!this._fullBoard || this._stateHandlers.size === 0) return
|
||||
if (typeof this._adapter.state !== 'function') return
|
||||
const update = { position: this._position, state: this.state() }
|
||||
for (const handler of this._stateHandlers) handler(update)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver anything already due, then arm a timer for the next pending event.
|
||||
* Ends playback when the cursor reaches the last event.
|
||||
|
|
|
|||
147
packages/replay/test/full-board-mode.test.js
Normal file
147
packages/replay/test/full-board-mode.test.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// @ts-check
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { PlaybackClock } from '@cozy-games/replay'
|
||||
import { createMoveLog } from '@cozy-games/move-log'
|
||||
|
||||
// Real mnswpr run + state reducer — imported by the TEST (relative), so no game
|
||||
// dependency enters the engine's manifest.
|
||||
import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.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]]
|
||||
}
|
||||
|
||||
// Record a real run: reveal, reveal, flag the mine, then flood the rest.
|
||||
let nowClock = 0
|
||||
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(board), clock: () => nowClock })
|
||||
const emitted = []
|
||||
session.onMove(e => emitted.push(e))
|
||||
const baseT = 1000
|
||||
for (const step of [
|
||||
{ at: 1000, move: { type: 'reveal', r: 0, c: 1 } },
|
||||
{ at: 1100, move: { type: 'reveal', r: 1, c: 0 } },
|
||||
{ at: 1200, move: { type: 'flag', r: 0, c: 0 } },
|
||||
{ at: 1300, move: { type: 'reveal', r: 2, c: 2 } }
|
||||
]) {
|
||||
nowClock = step.at
|
||||
session.applyMove(step.move)
|
||||
}
|
||||
const records = emitted.map(e => ({ seq: e.seq, t: e.t, event: e }))
|
||||
const envelope = createMoveLog(records)
|
||||
const reduce = createStateReducer(board)
|
||||
|
||||
// Independent ground truth: reduce over records at offset <= t.
|
||||
const truth = t => reduce(records.filter(r => (r.t - baseT) <= t))
|
||||
|
||||
describe('full-board mode — flag gating (inert by default)', () => {
|
||||
it('does nothing when the flag is off, even with a state reducer', () => {
|
||||
const clock = new PlaybackClock(envelope, fakeScheduler(), { state: reduce })
|
||||
const updates = []
|
||||
clock.onState(u => updates.push(u))
|
||||
clock.seek(clock.duration)
|
||||
expect(clock.state()).toBe(null)
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
|
||||
it('is inert when the flag is on but no state reducer is supplied', () => {
|
||||
const clock = new PlaybackClock(envelope, fakeScheduler(), {}, { fullBoard: true })
|
||||
clock.seek(clock.duration)
|
||||
expect(clock.state()).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('full-board mode — reconstruction (flag on)', () => {
|
||||
const make = () => new PlaybackClock(envelope, fakeScheduler(), { state: reduce }, { fullBoard: true })
|
||||
|
||||
it('state() reconstructs the board at multiple seek points', () => {
|
||||
const clock = make()
|
||||
for (const t of [-5, 0, 50, 100, 150, 200, 300, 400]) {
|
||||
clock.seek(t)
|
||||
const clamped = Math.max(0, Math.min(t, clock.duration))
|
||||
expect(clock.state()).toEqual(truth(clamped))
|
||||
}
|
||||
})
|
||||
|
||||
it('seek reconstructs the correct concrete state (forward then backward)', () => {
|
||||
const clock = make()
|
||||
|
||||
clock.seek(clock.duration) // end
|
||||
let b = clock.state()
|
||||
expect(b.phase).toBe('won')
|
||||
expect(b.revealedSafe).toBe(8)
|
||||
expect(b.cells[0][0].status).toBe('flagged')
|
||||
|
||||
clock.seek(100) // back to just after the two opening reveals
|
||||
b = clock.state()
|
||||
expect(b.phase).toBe('active')
|
||||
expect(b.revealedSafe).toBe(2)
|
||||
expect(b.cells[0][1].status).toBe('revealed')
|
||||
expect(b.cells[2][2].status).toBe('hidden')
|
||||
|
||||
clock.seek(0) // back to the very first reveal
|
||||
expect(clock.state().revealedSafe).toBe(1)
|
||||
})
|
||||
|
||||
it('onState streams a reconstruction on every delivery (incl. the flag)', () => {
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(envelope, s, { state: reduce }, { fullBoard: true })
|
||||
const updates = []
|
||||
clock.onState(u => updates.push(u))
|
||||
clock.play()
|
||||
s.advance(400)
|
||||
expect(updates.map(u => u.position)).toEqual([0, 100, 200, 300])
|
||||
expect(updates.at(-1).state.phase).toBe('won')
|
||||
})
|
||||
|
||||
it('onState fires on backward seek', () => {
|
||||
const clock = make()
|
||||
const updates = []
|
||||
clock.onState(u => updates.push(u))
|
||||
clock.seek(clock.duration) // forward (delivers all at once → 1 update)
|
||||
clock.seek(0) // backward → 1 update
|
||||
expect(updates).toHaveLength(2)
|
||||
expect(updates.at(-1).state.revealedSafe).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects a non-function state reducer at construction', () => {
|
||||
expect(() => new PlaybackClock(envelope, fakeScheduler(), { state: /** @type {any} */ (5) })).toThrow(TypeError)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue