feat(replay): replay engine
This commit is contained in:
parent
0dab463e84
commit
bafe1db285
5 changed files with 536 additions and 0 deletions
55
packages/replay/README.md
Normal file
55
packages/replay/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# @cozy-games/replay
|
||||||
|
|
||||||
|
A **game-agnostic** replay engine. `PlaybackClock` re-drives a
|
||||||
|
[`@cozy-games/move-log`](../move-log) envelope over time — scheduling each
|
||||||
|
recorded event to fire at its offset — with `play` / `pause` / `seek`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { PlaybackClock } from '@cozy-games/replay'
|
||||||
|
|
||||||
|
const clock = new PlaybackClock(envelope) // a valid move-log envelope
|
||||||
|
const off = clock.on(record => apply(record.event)) // record = { seq, t, event, ... }
|
||||||
|
|
||||||
|
clock.play() // events fire at their recorded offsets
|
||||||
|
clock.pause() // freeze at the current position
|
||||||
|
clock.seek(1500) // jump to 1500ms — delivers exactly the events at offset <= 1500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Offsets
|
||||||
|
|
||||||
|
Each event fires at its **offset** — its recorded `t` minus the first event's
|
||||||
|
`t`, so playback time `0` is the first event. `duration` is the last event's
|
||||||
|
offset.
|
||||||
|
|
||||||
|
## Injected clock + scheduler
|
||||||
|
|
||||||
|
The time source and scheduler are injected (mirroring the core session's
|
||||||
|
injected-clock seam), so tests get exact, deterministic timing:
|
||||||
|
|
||||||
|
```js
|
||||||
|
new PlaybackClock(envelope, { clock, setTimeout, clearTimeout })
|
||||||
|
```
|
||||||
|
|
||||||
|
They default to the real host (`Date.now` + global timers). Under a deterministic
|
||||||
|
injected scheduler — or `vi.useFakeTimers()` — events fire **exactly** at their
|
||||||
|
offsets (tolerance 0). Under the real host scheduler the tolerance is the host's
|
||||||
|
timer resolution (a few ms), the same bound as any `setTimeout`.
|
||||||
|
|
||||||
|
## Seek is deterministic
|
||||||
|
|
||||||
|
The clock keeps one invariant: `cursor` = the number of events whose offset is
|
||||||
|
`<= position`. So after `seek(t)` the delivered set is exactly the events at
|
||||||
|
offset `<= t`:
|
||||||
|
|
||||||
|
- **Forward** (`seek` ahead, or playback advancing) delivers each newly-passed
|
||||||
|
event once, in order.
|
||||||
|
- **Backward** rewinds the cursor without delivering; passing those offsets again
|
||||||
|
going forward re-delivers them (so scrub-back-then-replay works).
|
||||||
|
|
||||||
|
No event is ever delivered twice for a single forward pass, and none is dropped.
|
||||||
|
|
||||||
|
## Invariant: envelope only, no game types
|
||||||
|
|
||||||
|
This module imports **only** `@cozy-games/move-log` (to validate the envelope) and
|
||||||
|
never a game package. It treats every `event` payload as opaque. Enforced by a
|
||||||
|
dependency-graph guard in `test/playback-clock.test.js`.
|
||||||
197
packages/replay/index.js
Normal file
197
packages/replay/index.js
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
// @ts-check
|
||||||
|
import { assertMoveLog } from '@cozy-games/move-log'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `@cozy-games/replay` — the core of a game-agnostic replay engine.
|
||||||
|
*
|
||||||
|
* {@link PlaybackClock} re-drives a move-log envelope over time: it schedules
|
||||||
|
* each recorded event to fire at its OFFSET (its `t` relative to the first
|
||||||
|
* event) as playback time advances, and supports play / pause / seek. It
|
||||||
|
* consumes ONLY the generic envelope (`@cozy-games/move-log`) and never inspects
|
||||||
|
* the inside of an `event` — no game types cross this boundary.
|
||||||
|
*
|
||||||
|
* The clock and scheduler are injected (mirroring the core session's
|
||||||
|
* injected-clock seam), so tests drive it with fake timers or a hand-rolled
|
||||||
|
* scheduler for exact, deterministic timing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('@cozy-games/move-log').MoveEvent<any>} Event
|
||||||
|
* @typedef {import('@cozy-games/move-log').MoveLog<any>} Envelope
|
||||||
|
* @typedef {{
|
||||||
|
* clock?: () => number,
|
||||||
|
* setTimeout?: (fn: () => void, ms: number) => any,
|
||||||
|
* clearTimeout?: (handle: any) => void
|
||||||
|
* }} Deps
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class PlaybackClock {
|
||||||
|
/**
|
||||||
|
* @param {Envelope} envelope - a valid move-log envelope (validated here)
|
||||||
|
* @param {Deps} [deps] - injected time source + scheduler (default: real host)
|
||||||
|
*/
|
||||||
|
constructor(envelope, deps = {}) {
|
||||||
|
assertMoveLog(envelope)
|
||||||
|
|
||||||
|
const {
|
||||||
|
clock = () => Date.now(),
|
||||||
|
setTimeout = (fn, ms) => globalThis.setTimeout(fn, ms),
|
||||||
|
clearTimeout = (handle) => globalThis.clearTimeout(handle)
|
||||||
|
} = deps
|
||||||
|
this._now = clock
|
||||||
|
this._setTimeout = setTimeout
|
||||||
|
this._clearTimeout = clearTimeout
|
||||||
|
|
||||||
|
// Sort by recorded time (tie-break by seq) and rebase to offsets so the first
|
||||||
|
// event sits at offset 0 — "recorded offset relative to playback time".
|
||||||
|
const sorted = [...envelope.events].sort((a, b) => a.t - b.t || a.seq - b.seq)
|
||||||
|
const baseT = sorted.length ? sorted[0].t : 0
|
||||||
|
/** @type {{ offset: number, record: Event }[]} */
|
||||||
|
this._events = sorted.map(record => ({ offset: record.t - baseT, record }))
|
||||||
|
this._duration = this._events.length ? this._events[this._events.length - 1].offset : 0
|
||||||
|
|
||||||
|
// Playback state. Invariant: `_cursor` === number of events whose offset is
|
||||||
|
// <= the current position; events below the cursor have been delivered in the
|
||||||
|
// current forward pass. This single source of truth makes seek deterministic.
|
||||||
|
this._position = 0
|
||||||
|
this._cursor = 0
|
||||||
|
this._playing = false
|
||||||
|
/** @type {any} */
|
||||||
|
this._timer = null
|
||||||
|
this._anchorClock = 0
|
||||||
|
this._anchorPosition = 0
|
||||||
|
/** @type {Set<(event: Event) => void>} */
|
||||||
|
this._handlers = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total playback length in ms (offset of the last event; 0 if empty). */
|
||||||
|
get duration() {
|
||||||
|
return this._duration
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
isPlaying() {
|
||||||
|
return this._playing
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current playback position in ms, clamped to `[0, duration]`. */
|
||||||
|
position() {
|
||||||
|
return this._livePosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to delivered events. The handler receives the raw envelope record
|
||||||
|
* (`{ seq, t, event, ... }`) — the payload stays opaque. Returns an unsubscribe.
|
||||||
|
*
|
||||||
|
* @param {(event: Event) => void} handler
|
||||||
|
* @returns {() => void}
|
||||||
|
*/
|
||||||
|
on(handler) {
|
||||||
|
this._handlers.add(handler)
|
||||||
|
return () => this._handlers.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
|
||||||
|
* offsets. No-op if already playing or already at the end.
|
||||||
|
*/
|
||||||
|
play() {
|
||||||
|
if (this._playing) return
|
||||||
|
this._playing = true
|
||||||
|
this._anchorClock = this._now()
|
||||||
|
this._anchorPosition = this._position
|
||||||
|
this._scheduleNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pause playback, freezing the position where it currently is. */
|
||||||
|
pause() {
|
||||||
|
if (!this._playing) return
|
||||||
|
this._position = this._livePosition()
|
||||||
|
this._playing = false
|
||||||
|
this._clearTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to playback time `t` (ms, clamped to `[0, duration]`). Deterministic:
|
||||||
|
* afterwards the delivered set is exactly the events at offset <= t. Moving
|
||||||
|
* forward delivers the newly-passed events in order (each exactly once); moving
|
||||||
|
* backward rewinds the cursor without delivering, so a later forward pass
|
||||||
|
* re-delivers them. Re-schedules if playing.
|
||||||
|
*
|
||||||
|
* @param {number} t
|
||||||
|
*/
|
||||||
|
seek(t) {
|
||||||
|
if (typeof t !== 'number' || Number.isNaN(t)) {
|
||||||
|
throw new TypeError(`PlaybackClock.seek: t must be a number (got ${typeof t})`)
|
||||||
|
}
|
||||||
|
const wasPlaying = this._playing
|
||||||
|
this._clearTimer()
|
||||||
|
this._advanceTo(t)
|
||||||
|
if (wasPlaying) {
|
||||||
|
this._anchorClock = this._now()
|
||||||
|
this._anchorPosition = this._position
|
||||||
|
this._scheduleNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- internals ----
|
||||||
|
|
||||||
|
/** Live position: derived from the clock while playing, else the stored value. */
|
||||||
|
_livePosition() {
|
||||||
|
if (!this._playing) return this._position
|
||||||
|
const raw = this._anchorPosition + (this._now() - this._anchorClock)
|
||||||
|
return Math.min(Math.max(raw, 0), this._duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the cursor to match `target` position: emit events crossed going
|
||||||
|
* forward (once each), un-count events going backward (no emit). Sets position.
|
||||||
|
* @param {number} target
|
||||||
|
*/
|
||||||
|
_advanceTo(target) {
|
||||||
|
const t = Math.min(Math.max(target, 0), this._duration)
|
||||||
|
while (this._cursor < this._events.length && this._events[this._cursor].offset <= t) {
|
||||||
|
this._emit(this._events[this._cursor].record)
|
||||||
|
this._cursor++
|
||||||
|
}
|
||||||
|
while (this._cursor > 0 && this._events[this._cursor - 1].offset > t) {
|
||||||
|
this._cursor--
|
||||||
|
}
|
||||||
|
this._position = t
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deliver an event to all subscribers. */
|
||||||
|
_emit(record) {
|
||||||
|
for (const handler of this._handlers) handler(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver anything already due, then arm a timer for the next pending event.
|
||||||
|
* Ends playback when the cursor reaches the last event.
|
||||||
|
*/
|
||||||
|
_scheduleNext() {
|
||||||
|
this._clearTimer()
|
||||||
|
if (!this._playing) return
|
||||||
|
this._advanceTo(this._livePosition())
|
||||||
|
if (this._cursor >= this._events.length) {
|
||||||
|
this._position = this._duration
|
||||||
|
this._playing = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const delay = Math.max(0, this._events[this._cursor].offset - this._livePosition())
|
||||||
|
this._timer = this._setTimeout(() => this._onTimer(), delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
_onTimer() {
|
||||||
|
this._timer = null
|
||||||
|
if (this._playing) this._scheduleNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearTimer() {
|
||||||
|
if (this._timer !== null) {
|
||||||
|
this._clearTimeout(this._timer)
|
||||||
|
this._timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
packages/replay/package.json
Normal file
25
packages/replay/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "@cozy-games/replay",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Game-agnostic replay engine — a playback clock (play/pause/seek + event scheduling) that re-drives a move-log envelope over time",
|
||||||
|
"author": "Ayo Ayco",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/ayo-run/mnswpr"
|
||||||
|
},
|
||||||
|
"main": "index.js",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"default": "./index.js"
|
||||||
|
},
|
||||||
|
"./*": {
|
||||||
|
"default": "./*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@cozy-games/move-log": "workspace:*"
|
||||||
|
},
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
}
|
||||||
253
packages/replay/test/playback-clock.test.js
Normal file
253
packages/replay/test/playback-clock.test.js
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
// @ts-check
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
|
||||||
|
// Imported via the PACKAGE NAME to prove the new workspace module resolves.
|
||||||
|
import { PlaybackClock } from '@cozy-games/replay'
|
||||||
|
import { createMoveLog } from '@cozy-games/move-log'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hand-rolled deterministic scheduler — the injected-clock seam in action, with
|
||||||
|
* zero reliance on vi internals. `advance(ms)` fires due timers in time order,
|
||||||
|
* picking up timers scheduled from within a firing callback.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events at offsets 0, 100, 350 (t rebased from 1000). Payload is opaque to the clock.
|
||||||
|
function envelope() {
|
||||||
|
return createMoveLog([
|
||||||
|
{ seq: 1, t: 1000, event: { type: 'reveal', r: 0, c: 0 } },
|
||||||
|
{ seq: 2, t: 1100, event: { type: 'flag', r: 1, c: 2 } },
|
||||||
|
{ seq: 3, t: 1350, event: { type: 'chord', r: 4, c: 4 } }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const typesOf = records => records.map(r => r.event.type)
|
||||||
|
|
||||||
|
describe('PlaybackClock — construction & shape', () => {
|
||||||
|
it('rebases to offsets: duration is the last offset, first event at 0', () => {
|
||||||
|
const clock = new PlaybackClock(envelope(), fakeScheduler())
|
||||||
|
expect(clock.duration).toBe(350)
|
||||||
|
expect(clock.position()).toBe(0)
|
||||||
|
expect(clock.isPlaying()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates the envelope (rejects a non-envelope)', () => {
|
||||||
|
expect(() => new PlaybackClock(/** @type {any} */ (null))).toThrow()
|
||||||
|
expect(() => new PlaybackClock(/** @type {any} */ ({ schema_version: 2, events: [] }))).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles an empty envelope gracefully', () => {
|
||||||
|
const clock = new PlaybackClock(createMoveLog([]), fakeScheduler())
|
||||||
|
const seen = []
|
||||||
|
clock.on(r => seen.push(r))
|
||||||
|
expect(clock.duration).toBe(0)
|
||||||
|
clock.play()
|
||||||
|
expect(clock.isPlaying()).toBe(false) // nothing to play → ends immediately
|
||||||
|
expect(seen).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PlaybackClock — play / pause with an injected scheduler', () => {
|
||||||
|
it('fires events at their recorded offsets, exactly', () => {
|
||||||
|
const s = fakeScheduler()
|
||||||
|
const clock = new PlaybackClock(envelope(), s)
|
||||||
|
const seen = []
|
||||||
|
clock.on(r => seen.push({ type: r.event.type, at: s.clock() }))
|
||||||
|
|
||||||
|
clock.play()
|
||||||
|
// offset-0 event fires synchronously on play
|
||||||
|
expect(seen).toEqual([{ type: 'reveal', at: 0 }])
|
||||||
|
|
||||||
|
s.advance(100)
|
||||||
|
expect(seen[1]).toEqual({ type: 'flag', at: 100 })
|
||||||
|
|
||||||
|
s.advance(250) // reach offset 350
|
||||||
|
expect(seen[2]).toEqual({ type: 'chord', at: 350 })
|
||||||
|
expect(clock.isPlaying()).toBe(false) // ended
|
||||||
|
expect(clock.position()).toBe(350)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pause freezes position and stops delivery', () => {
|
||||||
|
const s = fakeScheduler()
|
||||||
|
const clock = new PlaybackClock(envelope(), s)
|
||||||
|
const seen = []
|
||||||
|
clock.on(r => seen.push(r.event.type))
|
||||||
|
|
||||||
|
clock.play()
|
||||||
|
s.advance(150) // past offset 100, between 100 and 350
|
||||||
|
clock.pause()
|
||||||
|
expect(clock.position()).toBe(150)
|
||||||
|
expect(seen).toEqual(['reveal', 'flag'])
|
||||||
|
|
||||||
|
s.advance(1000) // no timers should fire while paused
|
||||||
|
expect(seen).toEqual(['reveal', 'flag'])
|
||||||
|
expect(clock.position()).toBe(150)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resumes from the paused position', () => {
|
||||||
|
const s = fakeScheduler()
|
||||||
|
const clock = new PlaybackClock(envelope(), s)
|
||||||
|
const seen = []
|
||||||
|
clock.on(r => seen.push({ type: r.event.type, at: s.clock() }))
|
||||||
|
|
||||||
|
clock.play()
|
||||||
|
s.advance(150)
|
||||||
|
clock.pause()
|
||||||
|
clock.play() // resume at 150; next event at 350 ⇒ 200ms away
|
||||||
|
s.advance(200)
|
||||||
|
expect(seen.map(e => e.type)).toEqual(['reveal', 'flag', 'chord'])
|
||||||
|
expect(seen[2].at).toBe(350) // still fires at its true offset
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PlaybackClock — seek determinism', () => {
|
||||||
|
it('seek forward delivers exactly the events at offset <= t, in order, once', () => {
|
||||||
|
const clock = new PlaybackClock(envelope(), fakeScheduler())
|
||||||
|
const seen = []
|
||||||
|
clock.on(r => seen.push(r))
|
||||||
|
|
||||||
|
clock.seek(200) // offsets 0 and 100 are <= 200; 350 is not
|
||||||
|
expect(typesOf(seen)).toEqual(['reveal', 'flag'])
|
||||||
|
expect(clock.position()).toBe(200)
|
||||||
|
|
||||||
|
clock.seek(200) // no movement ⇒ no new deliveries
|
||||||
|
expect(typesOf(seen)).toEqual(['reveal', 'flag'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('seek boundary is inclusive (offset === t fires)', () => {
|
||||||
|
const clock = new PlaybackClock(envelope(), fakeScheduler())
|
||||||
|
const seen = []
|
||||||
|
clock.on(r => seen.push(r))
|
||||||
|
clock.seek(100)
|
||||||
|
expect(typesOf(seen)).toEqual(['reveal', 'flag']) // offset 100 included
|
||||||
|
})
|
||||||
|
|
||||||
|
it('seek backward re-schedules with no duplicate or dropped events', () => {
|
||||||
|
const clock = new PlaybackClock(envelope(), fakeScheduler())
|
||||||
|
const seen = []
|
||||||
|
clock.on(r => seen.push(r))
|
||||||
|
|
||||||
|
clock.seek(400) // deliver all three
|
||||||
|
expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord'])
|
||||||
|
|
||||||
|
clock.seek(50) // rewind — no delivery; only offset-0 stays "passed"
|
||||||
|
expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord']) // unchanged
|
||||||
|
|
||||||
|
clock.seek(400) // forward again re-delivers the re-crossed events, once each
|
||||||
|
expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord', 'flag', 'chord'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('seek while playing re-anchors and keeps firing correctly', () => {
|
||||||
|
const s = fakeScheduler()
|
||||||
|
const clock = new PlaybackClock(envelope(), s)
|
||||||
|
const seen = []
|
||||||
|
clock.on(r => seen.push(r.event.type))
|
||||||
|
|
||||||
|
clock.play()
|
||||||
|
s.advance(50) // only offset-0 delivered so far
|
||||||
|
expect(seen).toEqual(['reveal'])
|
||||||
|
|
||||||
|
clock.seek(120) // jump forward while playing ⇒ deliver offset-100 event
|
||||||
|
expect(seen).toEqual(['reveal', 'flag'])
|
||||||
|
|
||||||
|
s.advance(230) // reach 350 ⇒ final event
|
||||||
|
expect(seen).toEqual(['reveal', 'flag', 'chord'])
|
||||||
|
expect(clock.isPlaying()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never delivers an event twice within a single forward pass', () => {
|
||||||
|
const s = fakeScheduler()
|
||||||
|
const clock = new PlaybackClock(envelope(), s)
|
||||||
|
const seqs = []
|
||||||
|
clock.on(r => seqs.push(r.seq))
|
||||||
|
clock.play()
|
||||||
|
s.advance(1000)
|
||||||
|
expect(seqs).toEqual([1, 2, 3]) // each once, in order
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PlaybackClock — with vi fake timers', () => {
|
||||||
|
afterEach(() => { vi.useRealTimers() })
|
||||||
|
|
||||||
|
it('play/pause/seek work under vi.useFakeTimers()', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(0)
|
||||||
|
// Default deps ⇒ Date.now + global setTimeout, both faked by vi.
|
||||||
|
const clock = new PlaybackClock(envelope())
|
||||||
|
const seen = []
|
||||||
|
clock.on(r => seen.push(r.event.type))
|
||||||
|
|
||||||
|
clock.play()
|
||||||
|
expect(seen).toEqual(['reveal']) // offset 0 immediate
|
||||||
|
vi.advanceTimersByTime(100)
|
||||||
|
expect(seen).toEqual(['reveal', 'flag'])
|
||||||
|
clock.pause()
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
expect(seen).toEqual(['reveal', 'flag']) // paused ⇒ frozen
|
||||||
|
clock.play()
|
||||||
|
vi.advanceTimersByTime(250)
|
||||||
|
expect(seen).toEqual(['reveal', 'flag', 'chord'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('game-agnosticism guard (envelope only, no game imports)', () => {
|
||||||
|
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const GAME_REFERENCES = /mnswpr|minesweeper/i
|
||||||
|
|
||||||
|
it('manifest depends only on the envelope, never a game package', () => {
|
||||||
|
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
|
||||||
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
|
||||||
|
expect(Object.keys(deps).filter(name => GAME_REFERENCES.test(name))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no source file imports or references a game package', () => {
|
||||||
|
const offenders = []
|
||||||
|
walk(pkgDir, file => {
|
||||||
|
if (!file.endsWith('.js') || file.includes('/test/')) return
|
||||||
|
const code = readFileSync(file, 'utf8')
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
|
.replace(/\/\/.*$/gm, '')
|
||||||
|
if (GAME_REFERENCES.test(code)) offenders.push(file)
|
||||||
|
})
|
||||||
|
expect(offenders).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function walk(dir, fn) {
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
if (name === 'node_modules') continue
|
||||||
|
const p = join(dir, name)
|
||||||
|
if (statSync(p).isDirectory()) walk(p, fn)
|
||||||
|
else fn(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -93,6 +93,12 @@ importers:
|
||||||
|
|
||||||
packages/move-log: {}
|
packages/move-log: {}
|
||||||
|
|
||||||
|
packages/replay:
|
||||||
|
dependencies:
|
||||||
|
'@cozy-games/move-log':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../move-log
|
||||||
|
|
||||||
packages/utils: {}
|
packages/utils: {}
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue