diff --git a/packages/move-log/README.md b/packages/move-log/README.md index 0925293..183b7f6 100644 --- a/packages/move-log/README.md +++ b/packages/move-log/README.md @@ -4,7 +4,8 @@ A **game-agnostic** container for a recorded run of move events. It wraps any ga ```js import { - createMoveLog, serializeMoveLog, deserializeMoveLog, isMoveLog, SCHEMA_VERSION + createMoveLog, withReceivedTs, + serializeMoveLog, deserializeMoveLog, isMoveLog, SCHEMA_VERSION } from '@cozy-games/move-log' // `T` is your game's own event vocabulary — supplied by you, unknown to us. @@ -16,23 +17,39 @@ const log = createMoveLog([ const json = serializeMoveLog(log) // → JSON string const restored = deserializeMoveLog(json) // → validated MoveLog, or throws + +// A consumer records WHEN it received events (host clock), additively: +const stamped = withReceivedTs(restored, () => hostNow()) +// → each event now also carries `receivedTs`; still a valid v1 log ``` ## Shape -| field | type | meaning | -| ---------------- | ----------------- | --------------------------------------------- | -| `schema_version` | `1` | the move-log container version | -| `events` | `MoveEvent[]` | ordered, each `{ seq, t, event }` | +| field | type | meaning | +| ---------------- | ----------------- | -------------------------------------------------- | +| `schema_version` | `1` | the move-log container version | +| `events` | `MoveEvent[]` | ordered, each `{ seq, t, event, receivedTs? }` | -`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. +`MoveEvent = { seq: number, t: number, event: T, receivedTs?: number }` — the +log owns the per-event recording metadata (a strictly increasing `seq`, a +source-side timestamp `t`, and an **optional** received-side `receivedTs`), so +`T` stays a pure game payload with no required shape. `receivedTs` is +purpose-neutral: it records only *that* a consumer received the event at some +time, never why or from where. `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. +timestamps, sequence numbers, and any `receivedTs`) 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. + +## Versioning: `receivedTs` is additive within `schema_version: 1` + +`receivedTs` was added **without** bumping `schema_version`. It is optional and +purely additive: a v1 log is valid whether every event, some events, or no +events carry a `receivedTs`, and a reader that doesn't know the field simply +ignores it. A version bump is reserved for *breaking* container changes (a +renamed/removed field or a newly required one), which would be dispatched on in +`deserializeMoveLog`. See the `SCHEMA_VERSION` doc comment for the full policy. ## Invariant: zero game-specific imports diff --git a/packages/move-log/index.js b/packages/move-log/index.js index f617b3f..7c0bf6c 100644 --- a/packages/move-log/index.js +++ b/packages/move-log/index.js @@ -8,16 +8,23 @@ * * 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 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`. + * per-event recording metadata (`seq` + `t`, and an optional received-side + * `receivedTs`) 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`. */ /** - * The move-log schema version. Bump ONLY on a breaking change to the container - * shape itself — never for changes to a game's `T` vocabulary. + * The move-log schema version. + * + * Versioning policy: OPTIONAL, purely additive fields (an event gaining an + * optional `receivedTs`, say) do NOT bump this — a v1 reader ignores fields it + * doesn't know, and a log written with them stays a valid v1 log. Bump ONLY on a + * breaking change to the container shape (a renamed/removed field, a newly + * *required* field), which would need dispatch on read. Never bump for changes + * to a game's `T` vocabulary. * * @typedef {1} SchemaVersion */ @@ -25,11 +32,14 @@ export const SCHEMA_VERSION = /** @type {SchemaVersion} */ (1) /** * 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`. + * increasing sequence number `seq`, a source-side timestamp `t` (milliseconds), + * and an OPTIONAL received-side timestamp `receivedTs` a consumer may attach when + * it received the event — plus the game's opaque payload `event`. Generic over + * the game's event type `T`. `receivedTs` is purpose-neutral: the log records + * only THAT it was received at some time, never why or from where. * * @template T - * @typedef {{ seq: number, t: number, event: T }} MoveEvent + * @typedef {{ seq: number, t: number, event: T, receivedTs?: number }} MoveEvent */ /** @@ -70,10 +80,29 @@ function assertEvents(events) { if (e.seq <= prevSeq) { throw new RangeError(`move-log: events[${i}].seq must be strictly increasing (got ${e.seq} after ${prevSeq})`) } + if (e.receivedTs !== undefined && (typeof e.receivedTs !== 'number' || !Number.isFinite(e.receivedTs))) { + throw new TypeError(`move-log: events[${i}].receivedTs must be a finite number when present (got ${JSON.stringify(e.receivedTs)})`) + } prevSeq = e.seq }) } +/** + * Copy one event record to the canonical field set, carrying `receivedTs` + * through only when it's actually present (so absent stays absent — no + * `receivedTs: undefined` keys leak into the log or its JSON). + * + * @template T + * @param {MoveEvent} e + * @returns {MoveEvent} + */ +function copyEvent(e) { + /** @type {MoveEvent} */ + const out = { seq: e.seq, t: e.t, event: e.event } + if (e.receivedTs !== undefined) out.receivedTs = e.receivedTs + return out +} + /** * 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 @@ -101,17 +130,42 @@ export function assertMoveLog(value) { * entries are copied, so the log never aliases the caller's array. * * @template T - * @param {MoveEvent[]} [events] - ordered events, each `{ seq, t, event }` + * @param {MoveEvent[]} [events] - ordered events, each `{ seq, t, event, receivedTs? }` * @returns {MoveLog} */ export function createMoveLog(events = []) { assertEvents(events) return { schema_version: SCHEMA_VERSION, - events: events.map(({ seq, t, event }) => ({ seq, t, event })) + events: events.map(copyEvent) } } +/** + * Return a new move log with a received-side timestamp attached to each event + * for which `stamp` returns a finite number; events where `stamp` returns + * `undefined` are left as-is (keeping any existing `receivedTs`). This is how a + * consumer records WHEN it received events — the log never cares where the value + * came from. Pure: the input log is not mutated. + * + * @template T + * @param {MoveLog} log + * @param {(event: MoveEvent, index: number) => number | undefined} stamp + * @returns {MoveLog} + */ +export function withReceivedTs(log, stamp) { + assertMoveLog(log) + const events = log.events.map((e, i) => { + const rt = stamp(e, i) + if (rt === undefined) return copyEvent(e) + if (typeof rt !== 'number' || !Number.isFinite(rt)) { + throw new TypeError(`withReceivedTs: stamp must return a finite number or undefined (got ${JSON.stringify(rt)} at index ${i})`) + } + return { ...copyEvent(e), receivedTs: rt } + }) + return { schema_version: log.schema_version, events } +} + /** * 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`. diff --git a/packages/move-log/test/move-log.test.js b/packages/move-log/test/move-log.test.js index e9a9ace..f81a60d 100644 --- a/packages/move-log/test/move-log.test.js +++ b/packages/move-log/test/move-log.test.js @@ -7,7 +7,8 @@ import { dirname, join } from 'node:path' // Imported via the PACKAGE NAME (not a relative path) to prove the workspace // module resolves and is importable by other packages. import { - createMoveLog, serializeMoveLog, deserializeMoveLog, isMoveLog, assertMoveLog, SCHEMA_VERSION + createMoveLog, withReceivedTs, + serializeMoveLog, deserializeMoveLog, isMoveLog, assertMoveLog, SCHEMA_VERSION } from '@cozy-games/move-log' // A REAL mnswpr session/event stream — imported by the TEST, never the module. @@ -142,6 +143,89 @@ describe('serialization round-trip', () => { }) }) +describe('received timestamps (additive receivedTs)', () => { + const base = [ + { seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } }, + { seq: 2, t: 50, event: { type: 'flag', r: 1, c: 2 } } + ] + + it('accepts an optional receivedTs per event and round-trips it', () => { + const log = createMoveLog([ + { seq: 1, t: 0, event: { k: 'a' }, receivedTs: 1000 }, + { seq: 2, t: 50, event: { k: 'b' }, receivedTs: 1060 } + ]) + expect(log.events[0].receivedTs).toBe(1000) + const restored = deserializeMoveLog(serializeMoveLog(log)) + expect(restored).toEqual(log) + expect(restored.events.map(e => e.receivedTs)).toEqual([1000, 1060]) + }) + + it('is valid with receivedTs on only some events', () => { + const log = createMoveLog([ + { seq: 1, t: 0, event: { k: 'a' }, receivedTs: 1000 }, + { seq: 2, t: 50, event: { k: 'b' } } // no receivedTs + ]) + expect(isMoveLog(log)).toBe(true) + expect('receivedTs' in log.events[1]).toBe(false) + expect(deserializeMoveLog(serializeMoveLog(log))).toEqual(log) + }) + + it('REGRESSION: a log with no receivedTs anywhere stays fully valid and leaks no key', () => { + const log = createMoveLog(base) + expect(isMoveLog(log)).toBe(true) + expect(log.events.every(e => !('receivedTs' in e))).toBe(true) + const restored = deserializeMoveLog(serializeMoveLog(log)) + expect(restored).toEqual(log) + expect(restored.events.every(e => !('receivedTs' in e))).toBe(true) + }) + + it('rejects a non-finite / non-numeric receivedTs', () => { + expect(() => createMoveLog([{ seq: 1, t: 0, event: {}, receivedTs: 'soon' }])).toThrow(TypeError) + expect(() => createMoveLog([{ seq: 1, t: 0, event: {}, receivedTs: Infinity }])).toThrow(TypeError) + expect(() => deserializeMoveLog(JSON.stringify({ + schema_version: 1, + events: [{ seq: 1, t: 0, event: {}, receivedTs: 'later' }] + }))).toThrow(TypeError) + }) + + it('withReceivedTs attaches host-received times additively without mutating the input', () => { + const log = createMoveLog(base) + let clock = 900 + const stamped = withReceivedTs(log, () => (clock += 10)) + + // input untouched + expect(log.events.every(e => !('receivedTs' in e))).toBe(true) + // output stamped, still a valid v1 log, round-trips + expect(stamped.events.map(e => e.receivedTs)).toEqual([910, 920]) + expect(stamped.schema_version).toBe(1) + expect(deserializeMoveLog(serializeMoveLog(stamped))).toEqual(stamped) + }) + + it('withReceivedTs leaves events unstamped when the stamp returns undefined', () => { + const log = createMoveLog(base) + const stamped = withReceivedTs(log, (e) => (e.seq === 1 ? 1234 : undefined)) + expect(stamped.events[0].receivedTs).toBe(1234) + expect('receivedTs' in stamped.events[1]).toBe(false) + expect(isMoveLog(stamped)).toBe(true) + }) + + it('withReceivedTs rejects a stamp that returns a non-finite number', () => { + const log = createMoveLog(base) + expect(() => withReceivedTs(log, () => NaN)).toThrow(TypeError) + }) + + it('VERSIONING: receivedTs is additive — same schema_version with or without it', () => { + const without = createMoveLog(base) + const withRt = withReceivedTs(without, () => 1000) + expect(without.schema_version).toBe(SCHEMA_VERSION) + expect(withRt.schema_version).toBe(SCHEMA_VERSION) + expect(SCHEMA_VERSION).toBe(1) + // a v1 reader accepts both shapes + expect(isMoveLog(without)).toBe(true) + expect(isMoveLog(withRt)).toBe(true) + }) +}) + 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 = {