cozy-games/packages/replay/docs/adapter-interface.md

3.8 KiB
Raw Blame History

Replay adapter interface

The replay engine is game-agnostic: it schedules and delivers the events in a @cozy-games/move-log envelope over time, but it never interprets what an event means. All game meaning enters through a game adapter — the seam defined here. This is the concrete realization of the progress-reducer item in ADR 0002.

ReplayAdapter<T>

An adapter is a plain object supplied at construction:

new PlaybackClock(envelope, deps, adapter)
// 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  // 0100
type StateReducer<T, S>   = (events: MoveEvent<T>[]) => S        // full game state

MoveEvent<T> is the move-log record { seq, t, event, receivedTs? }, where event is the game's own payload — opaque to the engine.

progress(events) → %

The only adapter method today. It maps the ordered slice of events delivered so far (every event whose offset ≤ the current playback position) to a completion percentage.

  • Input: MoveEvent<T>[] — the played-so-far slice, in order. To compute a percentage the adapter typically needs a total (e.g. total safe cells); it owns that context, usually by closing over the board it was built from. The engine passes only the slice.
  • Output: a number in [0, 100]. The engine clamps the result into range and throws if the reducer returns a non-number, so an adapter can be permissive.
  • When: call clock.progress() at any time. It returns null if no adapter (or no progress) was supplied — the engine never invents a percentage.
// A minesweeper-style adapter, built over its board (illustrative):
const adapter = {
  progress: (events) => {
    const revealed = events.filter(e => e.event.type === 'reveal').length
    return (revealed / totalSafeCells) * 100
  }
}
const clock = new PlaybackClock(envelope, {}, adapter)
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:

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 source references only envelope types (MoveEvent / MoveLog) and the log's recording metadata (seq, t) — never an event's .event payload. This is enforced by a guard in test/playback-clock.test.js.
  • The adapter owns all game meaning — event vocabulary, progress math, and (as the contract grows) state reduction and terminal predicates per ADR 0002.
  • Typed generically over T so one engine serves every game.