From e1c168a9bb7a818aefe0a1102a7c0a86fc1a9d70 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 4 Jul 2026 12:29:47 +0200 Subject: [PATCH] feat(move-log): serialization --- packages/move-log/README.md | 31 ++-- packages/move-log/index.js | 145 ++++++++++++++---- packages/move-log/test/move-log.test.js | 190 ++++++++++++++++++++---- 3 files changed, 297 insertions(+), 69 deletions(-) diff --git a/packages/move-log/README.md b/packages/move-log/README.md index 3f6c94f..0925293 100644 --- a/packages/move-log/README.md +++ b/packages/move-log/README.md @@ -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. ```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. const log = createMoveLog([ - { t: 0, event: { type: 'reveal', r: 0, c: 0 } }, - { t: 50, event: { type: 'flag', r: 1, c: 2 } } + { seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } }, + { 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 -| field | type | meaning | -| ---------------- | ----------------- | ---------------------------------------- | -| `schema_version` | `1` | the move-log container version | -| `events` | `MoveEvent[]` | ordered, each `{ t, event }` | +| field | type | meaning | +| ---------------- | ----------------- | --------------------------------------------- | +| `schema_version` | `1` | the move-log container version | +| `events` | `MoveEvent[]` | ordered, each `{ seq, t, event }` | -`MoveEvent = { t: number, event: T }` — the log owns the per-event timestamp -`t`, so `T` stays a pure game payload with no required shape. +`MoveEvent = { seq: number, t: number, event: T }` — the log owns the +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 diff --git a/packages/move-log/index.js b/packages/move-log/index.js index 011c0ac..f617b3f 100644 --- a/packages/move-log/index.js +++ b/packages/move-log/index.js @@ -1,15 +1,15 @@ // @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 * event vocabulary (mnswpr's `MoveEvent` union from core-06 is the first `T`), * supplied by the caller. * * 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 - * per-event timestamp (`{ t, event }`) so `T` can stay a pure game payload with - * no required shape; the module never inspects the inside of an event. + * per-event recording metadata (`seq` + `t`) so `T` can stay a pure game payload + * 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 * a shared workspace module alongside `packages/utils`. @@ -24,55 +24,142 @@ export const SCHEMA_VERSION = /** @type {SchemaVersion} */ (1) /** - * A single recorded event: the game's opaque payload `event` plus the - * log-owned timestamp `t` (milliseconds). Generic over the game's event type - * `T`. + * A single recorded event: the log-owned recording metadata — a strictly + * increasing sequence number `seq` and a timestamp `t` (milliseconds) — plus the + * game's opaque payload `event`. Generic over the game's event type `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 - * recorded run. Generic over the game's event vocabulary `T`. JSON-safe as long - * as `T` is. + * The container: a schema-versioned, ordered array of timestamped, sequenced + * events for one recorded run. Generic over the game's event vocabulary `T`. + * JSON-safe as long as `T` is. * * @template T * @typedef {{ schema_version: SchemaVersion, events: MoveEvent[] }} MoveLog */ /** - * Build a move log from an ordered list of already-timestamped events. Pure and - * game-blind: it validates only the log's own invariants (each entry is a - * `{ t: number, event }`), never the shape of `T`. Order is preserved as given. + * Validate an events array: each entry must be a `{ seq, t, event }` with an + * integer `seq`, a finite numeric `t`, and a present `event`; and `seq` must be + * 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 {MoveEvent[]} [events] - ordered events, each `{ t, event }` - * @returns {MoveLog} + * @param {unknown} events */ -export function createMoveLog(events = []) { +function assertEvents(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) => { - if (e === null || typeof e !== 'object' || typeof e.t !== 'number' || !('event' in e)) { - throw new TypeError(`createMoveLog: events[${i}] must be { t: number, event }`) + let prevSeq = -Infinity + events.forEach((e, i) => { + 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 - * version? Checks the container invariants only — remains blind to `T`. + * Assert a value is a well-formed move log — correct `schema_version` and a valid + * events array — throwing a clear, specific error otherwise. Returns the value + * (typed) for chaining; never mutates. + * + * @param {unknown} value + * @returns {MoveLog} + */ +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[]} [events] - ordered events, each `{ seq, t, event }` + * @returns {MoveLog} + */ +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 * @returns {boolean} */ export function isMoveLog(value) { - if (value === null || typeof value !== 'object') return false - const v = /** @type {any} */ (value) - if (v.schema_version !== SCHEMA_VERSION || !Array.isArray(v.events)) return false - return v.events.every(e => e !== null && typeof e === 'object' && typeof e.t === 'number' && 'event' in e) + try { + assertMoveLog(value) + return true + } 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} 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} + */ +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) } diff --git a/packages/move-log/test/move-log.test.js b/packages/move-log/test/move-log.test.js index 0ddbc03..e9a9ace 100644 --- a/packages/move-log/test/move-log.test.js +++ b/packages/move-log/test/move-log.test.js @@ -4,9 +4,15 @@ import { readFileSync, readdirSync, statSync } from 'node:fs' import { fileURLToPath } from 'node:url' 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. -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. @@ -17,57 +23,172 @@ import { createMoveLog, isMoveLog, SCHEMA_VERSION } from '@cozy-games/move-log' describe('@cozy-games/move-log', () => { /** @type {import('@cozy-games/move-log').MoveEvent[]} */ const events = [ - { t: 0, event: { kind: 'tick' } }, - { t: 50, event: { kind: 'boom', power: 3 } }, - { t: 120, event: { kind: 'tick' } } + { seq: 1, t: 0, event: { kind: 'tick' } }, + { seq: 2, t: 50, event: { kind: 'boom', power: 3 } }, + { seq: 5, t: 120, event: { kind: 'tick' } } // gaps allowed; strictly increasing ] it('exposes schema_version typed as 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} */ const log = createMoveLog(events) expect(log.schema_version).toBe(1) 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[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)', () => { 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) input[0].t = 999 expect(log.events[0].t).toBe(1) // log kept its own copy }) - it('is JSON-safe: stringify → parse round-trips without loss', () => { - const log = createMoveLog(events) - expect(JSON.parse(JSON.stringify(log))).toEqual(log) - }) - - it('rejects malformed input with a clear error', () => { - // @ts-expect-error — not an array - expect(() => createMoveLog('nope')).toThrow(TypeError) - expect(() => createMoveLog([{ event: { kind: 'tick' } }])).toThrow(TypeError) // missing t - expect(() => createMoveLog([{ t: 5 }])).toThrow(TypeError) // missing event - expect(() => createMoveLog([{ t: 'soon', event: {} }])).toThrow(TypeError) // t not a number - }) - - it('isMoveLog recognizes well-formed logs and rejects others', () => { - expect(isMoveLog(createMoveLog(events))).toBe(true) - expect(isMoveLog(null)).toBe(false) - expect(isMoveLog({ schema_version: 2, events: [] })).toBe(false) // wrong version - expect(isMoveLog({ schema_version: 1, events: 'nope' })).toBe(false) - expect(isMoveLog({ schema_version: 1, events: [{ event: {} }] })).toBe(false) // no timestamp + it('rejects a non-monotonic seq at construction', () => { + expect(() => createMoveLog([ + { seq: 2, t: 0, event: {} }, + { seq: 1, t: 1, event: {} } + ])).toThrow(RangeError) }) }) -describe('game-blindness guard (zero game-specific imports)', () => { +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 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('serializeMoveLog produces a JSON string parseable back to the same object', () => { + const log = createMoveLog(events) + const json = serializeMoveLog(log) + expect(typeof json).toBe('string') + expect(JSON.parse(json)).toEqual(log) + }) + + it('rejects each malformed fixture with a distinct, clear error', () => { + 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({ schema_version: 1, events: [{ seq: 1, t: 0 }] })).toBe(false) + }) +}) + +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)), '..') // 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 = [] walk(pkgDir, file => { 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. const code = readFileSync(file, 'utf8') .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) { for (const name of readdirSync(dir)) { if (name === 'node_modules') continue