feat(move-log): accept timestamps
This commit is contained in:
parent
e1c168a9bb
commit
4ccec5a116
3 changed files with 176 additions and 21 deletions
|
|
@ -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<T>[]` | ordered, each `{ seq, t, event }` |
|
||||
| field | type | meaning |
|
||||
| ---------------- | ----------------- | -------------------------------------------------- |
|
||||
| `schema_version` | `1` | the move-log container version |
|
||||
| `events` | `MoveEvent<T>[]` | ordered, each `{ seq, t, event, receivedTs? }` |
|
||||
|
||||
`MoveEvent<T> = { 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<T> = { 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T>} e
|
||||
* @returns {MoveEvent<T>}
|
||||
*/
|
||||
function copyEvent(e) {
|
||||
/** @type {MoveEvent<T>} */
|
||||
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<T>[]} [events] - ordered events, each `{ seq, t, event }`
|
||||
* @param {MoveEvent<T>[]} [events] - ordered events, each `{ seq, t, event, receivedTs? }`
|
||||
* @returns {MoveLog<T>}
|
||||
*/
|
||||
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<T>} log
|
||||
* @param {(event: MoveEvent<T>, index: number) => number | undefined} stamp
|
||||
* @returns {MoveLog<T>}
|
||||
*/
|
||||
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`.
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue