feat(move-log): serialization

This commit is contained in:
ayo 2026-07-04 12:29:47 +02:00
parent 3bfb1931cc
commit e1c168a9bb
3 changed files with 297 additions and 69 deletions

View file

@ -3,25 +3,36 @@
A **game-agnostic** container for a recorded run of move events. It wraps any game's event stream in a schema-versioned, ordered, timestamped log. A **game-agnostic** container for a recorded run of move events. It wraps any game's event stream in a schema-versioned, ordered, timestamped log.
```js ```js
import { createMoveLog, isMoveLog, SCHEMA_VERSION } from '@cozy-games/move-log' import {
createMoveLog, serializeMoveLog, deserializeMoveLog, isMoveLog, SCHEMA_VERSION
} from '@cozy-games/move-log'
// `T` is your game's own event vocabulary — supplied by you, unknown to us. // `T` is your game's own event vocabulary — supplied by you, unknown to us.
const log = createMoveLog([ const log = createMoveLog([
{ t: 0, event: { type: 'reveal', r: 0, c: 0 } }, { seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
{ t: 50, event: { type: 'flag', r: 1, c: 2 } } { seq: 2, t: 50, event: { type: 'flag', r: 1, c: 2 } }
]) ])
// → { schema_version: 1, events: [ { t, event }, ... ] } // → { schema_version: 1, events: [ { seq, t, event }, ... ] }
const json = serializeMoveLog(log) // → JSON string
const restored = deserializeMoveLog(json) // → validated MoveLog, or throws
``` ```
## Shape ## Shape
| field | type | meaning | | field | type | meaning |
| ---------------- | ----------------- | ---------------------------------------- | | ---------------- | ----------------- | --------------------------------------------- |
| `schema_version` | `1` | the move-log container version | | `schema_version` | `1` | the move-log container version |
| `events` | `MoveEvent<T>[]` | ordered, each `{ t, event }` | | `events` | `MoveEvent<T>[]` | ordered, each `{ seq, t, event }` |
`MoveEvent<T> = { t: number, event: T }` — the log owns the per-event timestamp `MoveEvent<T> = { seq: number, t: number, event: T }` — the log owns the
`t`, so `T` stays a pure game payload with no required shape. per-event recording metadata (a strictly increasing `seq` and a timestamp `t`),
so `T` stays a pure game payload with no required shape.
`deserializeMoveLog` round-trips a serialized log with full fidelity (order,
timestamps, sequence numbers) and rejects malformed input — bad JSON, missing or
wrong-typed fields, or non-monotonic `seq` — with a clear error, never returning
a partially-parsed log.
## Invariant: zero game-specific imports ## Invariant: zero game-specific imports

View file

@ -1,15 +1,15 @@
// @ts-check // @ts-check
/** /**
* `@cozy-games/move-log` a game-blind container for a recorded run of move * `@cozy-games/move-log` a game-agnostic container for a recorded run of move
* events. It wraps ANY game's event stream: `T` is the consuming game's own * events. It wraps ANY game's event stream: `T` is the consuming game's own
* event vocabulary (mnswpr's `MoveEvent` union from core-06 is the first `T`), * event vocabulary (mnswpr's `MoveEvent` union from core-06 is the first `T`),
* supplied by the caller. * supplied by the caller.
* *
* This module imports NO game types that independence is the whole point and * This module imports NO game types that independence is the whole point and
* is enforced by a dependency-graph guard in the tests. The log owns the * is enforced by a dependency-graph guard in the tests. The log owns the
* per-event timestamp (`{ t, event }`) so `T` can stay a pure game payload with * per-event recording metadata (`seq` + `t`) so `T` can stay a pure game payload
* no required shape; the module never inspects the inside of an event. * with no required shape; the module never inspects the inside of an `event`.
* *
* Extraction to a standalone published package comes later; for now it lives as * Extraction to a standalone published package comes later; for now it lives as
* a shared workspace module alongside `packages/utils`. * a shared workspace module alongside `packages/utils`.
@ -24,55 +24,142 @@
export const SCHEMA_VERSION = /** @type {SchemaVersion} */ (1) export const SCHEMA_VERSION = /** @type {SchemaVersion} */ (1)
/** /**
* A single recorded event: the game's opaque payload `event` plus the * A single recorded event: the log-owned recording metadata a strictly
* log-owned timestamp `t` (milliseconds). Generic over the game's event type * increasing sequence number `seq` and a timestamp `t` (milliseconds) plus the
* `T`. * game's opaque payload `event`. Generic over the game's event type `T`.
* *
* @template T * @template T
* @typedef {{ t: number, event: T }} MoveEvent * @typedef {{ seq: number, t: number, event: T }} MoveEvent
*/ */
/** /**
* The container: a schema-versioned, ordered array of timestamped events for one * The container: a schema-versioned, ordered array of timestamped, sequenced
* recorded run. Generic over the game's event vocabulary `T`. JSON-safe as long * events for one recorded run. Generic over the game's event vocabulary `T`.
* as `T` is. * JSON-safe as long as `T` is.
* *
* @template T * @template T
* @typedef {{ schema_version: SchemaVersion, events: MoveEvent<T>[] }} MoveLog * @typedef {{ schema_version: SchemaVersion, events: MoveEvent<T>[] }} MoveLog
*/ */
/** /**
* Build a move log from an ordered list of already-timestamped events. Pure and * Validate an events array: each entry must be a `{ seq, t, event }` with an
* game-blind: it validates only the log's own invariants (each entry is a * integer `seq`, a finite numeric `t`, and a present `event`; and `seq` must be
* `{ t: number, event }`), never the shape of `T`. Order is preserved as given. * STRICTLY INCREASING across the array. Throws a distinct, field-specific error
* on the first problem never leaves a caller with a half-checked array.
* *
* @template T * @param {unknown} events
* @param {MoveEvent<T>[]} [events] - ordered events, each `{ t, event }`
* @returns {MoveLog<T>}
*/ */
export function createMoveLog(events = []) { function assertEvents(events) {
if (!Array.isArray(events)) { if (!Array.isArray(events)) {
throw new TypeError(`createMoveLog: events must be an array (got ${events === null ? 'null' : typeof events})`) throw new TypeError(`move-log: events must be an array (got ${events === null ? 'null' : typeof events})`)
} }
const copied = events.map((e, i) => { let prevSeq = -Infinity
if (e === null || typeof e !== 'object' || typeof e.t !== 'number' || !('event' in e)) { events.forEach((e, i) => {
throw new TypeError(`createMoveLog: events[${i}] must be { t: number, event }`) if (e === null || typeof e !== 'object') {
throw new TypeError(`move-log: events[${i}] must be an object (got ${e === null ? 'null' : typeof e})`)
} }
return { t: e.t, event: e.event } if (!('event' in e)) {
throw new TypeError(`move-log: events[${i}] is missing 'event'`)
}
if (typeof e.t !== 'number' || !Number.isFinite(e.t)) {
throw new TypeError(`move-log: events[${i}].t must be a finite number (got ${JSON.stringify(e.t)})`)
}
if (!Number.isInteger(e.seq)) {
throw new TypeError(`move-log: events[${i}].seq must be an integer (got ${JSON.stringify(e.seq)})`)
}
if (e.seq <= prevSeq) {
throw new RangeError(`move-log: events[${i}].seq must be strictly increasing (got ${e.seq} after ${prevSeq})`)
}
prevSeq = e.seq
}) })
return { schema_version: SCHEMA_VERSION, events: copied }
} }
/** /**
* Runtime type guard: is `value` a well-formed move log of the current schema * Assert a value is a well-formed move log correct `schema_version` and a valid
* version? Checks the container invariants only remains blind to `T`. * events array throwing a clear, specific error otherwise. Returns the value
* (typed) for chaining; never mutates.
*
* @param {unknown} value
* @returns {MoveLog<any>}
*/
export function assertMoveLog(value) {
if (value === null || typeof value !== 'object') {
throw new TypeError(`move-log: expected an object (got ${value === null ? 'null' : typeof value})`)
}
const v = /** @type {any} */ (value)
if (v.schema_version !== SCHEMA_VERSION) {
throw new RangeError(`move-log: unsupported schema_version ${JSON.stringify(v.schema_version)} (expected ${SCHEMA_VERSION})`)
}
assertEvents(v.events)
return v
}
/**
* Build a move log from an ordered list of `{ seq, t, event }` records. Pure and
* game-agnostic: it validates only the log's own invariants (metadata types and
* strictly increasing `seq`), never the shape of `T`. Order is preserved and
* entries are copied, so the log never aliases the caller's array.
*
* @template T
* @param {MoveEvent<T>[]} [events] - ordered events, each `{ seq, t, event }`
* @returns {MoveLog<T>}
*/
export function createMoveLog(events = []) {
assertEvents(events)
return {
schema_version: SCHEMA_VERSION,
events: events.map(({ seq, t, event }) => ({ seq, t, event }))
}
}
/**
* Non-throwing type guard: is `value` a well-formed move log of the current
* schema version? Checks the container invariants only remains blind to `T`.
* *
* @param {unknown} value * @param {unknown} value
* @returns {boolean} * @returns {boolean}
*/ */
export function isMoveLog(value) { export function isMoveLog(value) {
if (value === null || typeof value !== 'object') return false try {
const v = /** @type {any} */ (value) assertMoveLog(value)
if (v.schema_version !== SCHEMA_VERSION || !Array.isArray(v.events)) return false return true
return v.events.every(e => e !== null && typeof e === 'object' && typeof e.t === 'number' && 'event' in e) } catch {
return false
}
}
/**
* Serialize a move log to a JSON string. Validates first, so a malformed log is
* rejected here rather than emitted. Inverse of {@link deserializeMoveLog}.
*
* @template T
* @param {MoveLog<T>} log
* @returns {string}
*/
export function serializeMoveLog(log) {
assertMoveLog(log)
return JSON.stringify(log)
}
/**
* Parse and validate a JSON string into a move log, with full fidelity: event
* order, timestamps, and sequence numbers survive the round-trip exactly.
* Rejects malformed input (bad JSON, missing/typed-wrong fields, non-monotonic
* `seq`) with a clear error and NEVER returns a partially-parsed log. Inverse of
* {@link serializeMoveLog}.
*
* @param {string} json
* @returns {MoveLog<any>}
*/
export function deserializeMoveLog(json) {
if (typeof json !== 'string') {
throw new TypeError(`deserializeMoveLog: expected a JSON string (got ${json === null ? 'null' : typeof json})`)
}
let parsed
try {
parsed = JSON.parse(json)
} catch (err) {
throw new SyntaxError(`deserializeMoveLog: invalid JSON — ${/** @type {Error} */ (err).message}`, { cause: err })
}
return assertMoveLog(parsed)
} }

View file

@ -4,9 +4,15 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
// Imported via the PACKAGE NAME (not a relative path) to prove the new workspace // Imported via the PACKAGE NAME (not a relative path) to prove the workspace
// module resolves and is importable by other packages. // module resolves and is importable by other packages.
import { createMoveLog, isMoveLog, SCHEMA_VERSION } from '@cozy-games/move-log' import {
createMoveLog, serializeMoveLog, deserializeMoveLog, isMoveLog, assertMoveLog, SCHEMA_VERSION
} from '@cozy-games/move-log'
// A REAL mnswpr session/event stream — imported by the TEST, never the module.
// Relative path (not the package name) so no game dependency enters the manifest.
import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js'
/** /**
* A dummy event vocabulary defined HERE, in the test deliberately NOT mnswpr's. * A dummy event vocabulary defined HERE, in the test deliberately NOT mnswpr's.
@ -17,57 +23,172 @@ import { createMoveLog, isMoveLog, SCHEMA_VERSION } from '@cozy-games/move-log'
describe('@cozy-games/move-log', () => { describe('@cozy-games/move-log', () => {
/** @type {import('@cozy-games/move-log').MoveEvent<DummyEvent>[]} */ /** @type {import('@cozy-games/move-log').MoveEvent<DummyEvent>[]} */
const events = [ const events = [
{ t: 0, event: { kind: 'tick' } }, { seq: 1, t: 0, event: { kind: 'tick' } },
{ t: 50, event: { kind: 'boom', power: 3 } }, { seq: 2, t: 50, event: { kind: 'boom', power: 3 } },
{ t: 120, event: { kind: 'tick' } } { seq: 5, t: 120, event: { kind: 'tick' } } // gaps allowed; strictly increasing
] ]
it('exposes schema_version typed as 1', () => { it('exposes schema_version typed as 1', () => {
expect(SCHEMA_VERSION).toBe(1) expect(SCHEMA_VERSION).toBe(1)
}) })
it('wraps an ordered, timestamped event stream for a dummy vocabulary', () => { it('wraps an ordered, timestamped, sequenced stream for a dummy vocabulary', () => {
/** @type {import('@cozy-games/move-log').MoveLog<DummyEvent>} */ /** @type {import('@cozy-games/move-log').MoveLog<DummyEvent>} */
const log = createMoveLog(events) const log = createMoveLog(events)
expect(log.schema_version).toBe(1) expect(log.schema_version).toBe(1)
expect(log.events).toHaveLength(3) expect(log.events).toHaveLength(3)
// order preserved, per-event timestamps present expect(log.events.map(e => e.seq)).toEqual([1, 2, 5])
expect(log.events.map(e => e.t)).toEqual([0, 50, 120]) expect(log.events.map(e => e.t)).toEqual([0, 50, 120])
expect(log.events[1]).toEqual({ t: 50, event: { kind: 'boom', power: 3 } }) expect(log.events[1]).toEqual({ seq: 2, t: 50, event: { kind: 'boom', power: 3 } })
}) })
it('defaults to an empty run and copies entries (no aliasing of the input)', () => { it('defaults to an empty run and copies entries (no aliasing of the input)', () => {
expect(createMoveLog()).toEqual({ schema_version: 1, events: [] }) expect(createMoveLog()).toEqual({ schema_version: 1, events: [] })
const input = [{ t: 1, event: { kind: 'tick' } }] const input = [{ seq: 1, t: 1, event: { kind: 'tick' } }]
const log = createMoveLog(input) const log = createMoveLog(input)
input[0].t = 999 input[0].t = 999
expect(log.events[0].t).toBe(1) // log kept its own copy expect(log.events[0].t).toBe(1) // log kept its own copy
}) })
it('is JSON-safe: stringify → parse round-trips without loss', () => { it('rejects a non-monotonic seq at construction', () => {
expect(() => createMoveLog([
{ seq: 2, t: 0, event: {} },
{ seq: 1, t: 1, event: {} }
])).toThrow(RangeError)
})
})
describe('serialization round-trip', () => {
const events = [
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
{ seq: 2, t: 50, event: { type: 'flag', r: 1, c: 2 } },
{ seq: 3, t: 90, event: { type: 'chord', r: 4, c: 4 } }
]
it('preserves order, timestamps, and sequence numbers exactly', () => {
const log = createMoveLog(events) const log = createMoveLog(events)
expect(JSON.parse(JSON.stringify(log))).toEqual(log) const restored = deserializeMoveLog(serializeMoveLog(log))
expect(restored).toEqual(log) // full structural fidelity
expect(restored.events.map(e => e.seq)).toEqual([1, 2, 3])
expect(restored.events.map(e => e.t)).toEqual([0, 50, 90])
expect(restored.events.map(e => e.event.type)).toEqual(['reveal', 'flag', 'chord'])
}) })
it('rejects malformed input with a clear error', () => { it('serializeMoveLog produces a JSON string parseable back to the same object', () => {
// @ts-expect-error — not an array const log = createMoveLog(events)
expect(() => createMoveLog('nope')).toThrow(TypeError) const json = serializeMoveLog(log)
expect(() => createMoveLog([{ event: { kind: 'tick' } }])).toThrow(TypeError) // missing t expect(typeof json).toBe('string')
expect(() => createMoveLog([{ t: 5 }])).toThrow(TypeError) // missing event expect(JSON.parse(json)).toEqual(log)
expect(() => createMoveLog([{ t: 'soon', event: {} }])).toThrow(TypeError) // t not a number
}) })
it('isMoveLog recognizes well-formed logs and rejects others', () => { it('rejects each malformed fixture with a distinct, clear error', () => {
expect(isMoveLog(createMoveLog(events))).toBe(true) const valid = serializeMoveLog(createMoveLog(events))
// not a string
// @ts-expect-error — deliberately wrong type
expect(() => deserializeMoveLog({})).toThrow(TypeError)
// invalid JSON syntax
expect(() => deserializeMoveLog('{not json')).toThrow(SyntaxError)
// missing schema_version
expect(() => deserializeMoveLog(JSON.stringify({ events: [] }))).toThrow(RangeError)
// wrong schema_version
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 2, events: [] }))).toThrow(RangeError)
// events not an array
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: 'nope' }))).toThrow(TypeError)
// missing 'event' field
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [{ seq: 1, t: 0 }] }))).toThrow(TypeError)
// bad timestamp type
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [{ seq: 1, t: 'soon', event: {} }] }))).toThrow(TypeError)
// bad seq type
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [{ seq: 1.5, t: 0, event: {} }] }))).toThrow(TypeError)
// shuffled / non-monotonic seq
const shuffled = JSON.stringify({
schema_version: 1,
events: [{ seq: 3, t: 0, event: {} }, { seq: 1, t: 1, event: {} }]
})
expect(() => deserializeMoveLog(shuffled)).toThrow(RangeError)
// distinct messages, not one generic error
const messages = [
captureMessage(() => deserializeMoveLog('{not json')),
captureMessage(() => deserializeMoveLog(JSON.stringify({ events: [] }))),
captureMessage(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [{ seq: 1, t: 0 }] }))),
captureMessage(() => deserializeMoveLog(shuffled))
]
expect(new Set(messages).size).toBe(messages.length)
// sanity: the valid fixture still deserializes
expect(isMoveLog(deserializeMoveLog(valid))).toBe(true)
})
it('never returns a partially-parsed log (throws before returning)', () => {
const partlyBad = JSON.stringify({
schema_version: 1,
events: [{ seq: 1, t: 0, event: { ok: true } }, { seq: 2, t: 'bad', event: {} }]
})
let result = 'sentinel'
expect(() => { result = deserializeMoveLog(partlyBad) }).toThrow(TypeError)
expect(result).toBe('sentinel') // assignment never happened
})
it('isMoveLog / assertMoveLog agree on validity', () => {
const log = createMoveLog(events)
expect(isMoveLog(log)).toBe(true)
expect(assertMoveLog(log)).toBe(log)
expect(isMoveLog(null)).toBe(false) expect(isMoveLog(null)).toBe(false)
expect(isMoveLog({ schema_version: 2, events: [] })).toBe(false) // wrong version expect(isMoveLog({ schema_version: 1, events: [{ seq: 1, t: 0 }] })).toBe(false)
expect(isMoveLog({ schema_version: 1, events: 'nope' })).toBe(false)
expect(isMoveLog({ schema_version: 1, events: [{ event: {} }] })).toBe(false) // no timestamp
}) })
}) })
describe('game-blindness guard (zero game-specific imports)', () => { describe('integration: wraps a real mnswpr event stream (core-06)', () => {
// 3x3, single mine at (0,0); adjacency computed. Lets us script exact moves.
const layout = {
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]]
}
it('records an mnswpr session end-to-end and round-trips it losslessly', () => {
let now = 1000
const clock = () => now
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout), clock })
// Capture the real core-06 move-events ({ type, r, c, t, seq }).
/** @type {any[]} */
const emitted = []
session.onMove(e => emitted.push(e))
now = 1050; session.applyMove({ type: 'reveal', r: 0, c: 1 })
now = 1100; session.applyMove({ type: 'flag', r: 0, c: 0 })
now = 1150; session.applyMove({ type: 'flag', r: 0, c: 0 }) // unflag
now = 1200; session.applyMove({ type: 'flag', r: 0, c: 0 }) // re-flag the mine
now = 1250; session.applyMove({ type: 'chord', r: 0, c: 1 }) // 1 flag == value → chord
expect(emitted.map(e => e.type)).toEqual(['reveal', 'flag', 'unflag', 'flag', 'chord'])
// Wrap the stream: lift the recording metadata (seq, t) to the log level.
const log = createMoveLog(emitted.map(e => ({ seq: e.seq, t: e.t, event: e })))
const restored = deserializeMoveLog(serializeMoveLog(log))
expect(restored).toEqual(log)
expect(restored.events.map(e => e.seq)).toEqual([1, 2, 3, 4, 5])
expect(restored.events.map(e => e.t)).toEqual([1050, 1100, 1150, 1200, 1250])
expect(restored.events.map(e => e.event.type)).toEqual(['reveal', 'flag', 'unflag', 'flag', 'chord'])
// sequence is strictly increasing — the log's own invariant, verified on real data
const seqs = restored.events.map(e => e.seq)
expect(seqs).toEqual([...seqs].sort((a, b) => a - b))
})
})
describe('game-agnosticism guard (zero game-specific imports)', () => {
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..') const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
// The set of game packages the move log must never depend on or import. // The set of game packages the move log must never depend on or import.
@ -88,7 +209,7 @@ describe('game-blindness guard (zero game-specific imports)', () => {
const offenders = [] const offenders = []
walk(pkgDir, file => { walk(pkgDir, file => {
if (!file.endsWith('.js')) return if (!file.endsWith('.js')) return
if (file.includes('/test/')) return // the guard itself names games on purpose if (file.includes('/test/')) return // the test may name/import games on purpose
// Strip comments so prose that *names* a game isn't a false positive. // Strip comments so prose that *names* a game isn't a false positive.
const code = readFileSync(file, 'utf8') const code = readFileSync(file, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\*[\s\S]*?\*\//g, '')
@ -99,6 +220,15 @@ describe('game-blindness guard (zero game-specific imports)', () => {
}) })
}) })
function captureMessage(fn) {
try {
fn()
return null
} catch (err) {
return err.message
}
}
function walk(dir, fn) { function walk(dir, fn) {
for (const name of readdirSync(dir)) { for (const name of readdirSync(dir)) {
if (name === 'node_modules') continue if (name === 'node_modules') continue