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
|
```js
|
||||||
import {
|
import {
|
||||||
createMoveLog, serializeMoveLog, deserializeMoveLog, isMoveLog, SCHEMA_VERSION
|
createMoveLog, withReceivedTs,
|
||||||
|
serializeMoveLog, deserializeMoveLog, isMoveLog, SCHEMA_VERSION
|
||||||
} from '@cozy-games/move-log'
|
} 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.
|
||||||
|
|
@ -16,23 +17,39 @@ const log = createMoveLog([
|
||||||
|
|
||||||
const json = serializeMoveLog(log) // → JSON string
|
const json = serializeMoveLog(log) // → JSON string
|
||||||
const restored = deserializeMoveLog(json) // → validated MoveLog, or throws
|
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
|
## 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 `{ seq, t, event }` |
|
| `events` | `MoveEvent<T>[]` | ordered, each `{ seq, t, event, receivedTs? }` |
|
||||||
|
|
||||||
`MoveEvent<T> = { seq: number, t: number, event: T }` — the log owns the
|
`MoveEvent<T> = { seq: number, t: number, event: T, receivedTs?: number }` — the
|
||||||
per-event recording metadata (a strictly increasing `seq` and a timestamp `t`),
|
log owns the per-event recording metadata (a strictly increasing `seq`, a
|
||||||
so `T` stays a pure game payload with no required shape.
|
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,
|
`deserializeMoveLog` round-trips a serialized log with full fidelity (order,
|
||||||
timestamps, sequence numbers) and rejects malformed input — bad JSON, missing or
|
timestamps, sequence numbers, and any `receivedTs`) and rejects malformed input —
|
||||||
wrong-typed fields, or non-monotonic `seq` — with a clear error, never returning
|
bad JSON, missing or wrong-typed fields, or non-monotonic `seq` — with a clear
|
||||||
a partially-parsed log.
|
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
|
## Invariant: zero game-specific imports
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,23 @@
|
||||||
*
|
*
|
||||||
* 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 recording metadata (`seq` + `t`) so `T` can stay a pure game payload
|
* per-event recording metadata (`seq` + `t`, and an optional received-side
|
||||||
* with no required shape; the module never inspects the inside of an `event`.
|
* `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
|
* 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`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The move-log schema version. Bump ONLY on a breaking change to the container
|
* The move-log schema version.
|
||||||
* shape itself — never for changes to a game's `T` vocabulary.
|
*
|
||||||
|
* 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
|
* @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
|
* A single recorded event: the log-owned recording metadata — a strictly
|
||||||
* increasing sequence number `seq` and a timestamp `t` (milliseconds) — plus the
|
* increasing sequence number `seq`, a source-side timestamp `t` (milliseconds),
|
||||||
* game's opaque payload `event`. Generic over the game's event type `T`.
|
* 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
|
* @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) {
|
if (e.seq <= prevSeq) {
|
||||||
throw new RangeError(`move-log: events[${i}].seq must be strictly increasing (got ${e.seq} after ${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
|
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
|
* 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
|
* 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.
|
* entries are copied, so the log never aliases the caller's array.
|
||||||
*
|
*
|
||||||
* @template T
|
* @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>}
|
* @returns {MoveLog<T>}
|
||||||
*/
|
*/
|
||||||
export function createMoveLog(events = []) {
|
export function createMoveLog(events = []) {
|
||||||
assertEvents(events)
|
assertEvents(events)
|
||||||
return {
|
return {
|
||||||
schema_version: SCHEMA_VERSION,
|
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
|
* 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`.
|
* 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
|
// 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 {
|
import {
|
||||||
createMoveLog, serializeMoveLog, deserializeMoveLog, isMoveLog, assertMoveLog, SCHEMA_VERSION
|
createMoveLog, withReceivedTs,
|
||||||
|
serializeMoveLog, deserializeMoveLog, isMoveLog, assertMoveLog, SCHEMA_VERSION
|
||||||
} from '@cozy-games/move-log'
|
} from '@cozy-games/move-log'
|
||||||
|
|
||||||
// A REAL mnswpr session/event stream — imported by the TEST, never the module.
|
// 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)', () => {
|
describe('integration: wraps a real mnswpr event stream (core-06)', () => {
|
||||||
// 3x3, single mine at (0,0); adjacency computed. Lets us script exact moves.
|
// 3x3, single mine at (0,0); adjacency computed. Lets us script exact moves.
|
||||||
const layout = {
|
const layout = {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue