feat(move-log): serialization
This commit is contained in:
parent
3bfb1931cc
commit
e1c168a9bb
3 changed files with 297 additions and 69 deletions
|
|
@ -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<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
|
||||
`t`, so `T` stays a pure game payload with no required shape.
|
||||
`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.
|
||||
|
||||
`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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T>[] }} 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<T>[]} [events] - ordered events, each `{ t, event }`
|
||||
* @returns {MoveLog<T>}
|
||||
* @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<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
|
||||
* @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<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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DummyEvent>[]} */
|
||||
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<DummyEvent>} */
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue