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.
|
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
|
```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.
|
// `T` is your game's own event vocabulary — supplied by you, unknown to us.
|
||||||
const log = createMoveLog([
|
const log = createMoveLog([
|
||||||
{ t: 0, event: { type: 'reveal', r: 0, c: 0 } },
|
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
|
||||||
{ t: 50, event: { type: 'flag', r: 1, c: 2 } }
|
{ 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
|
## 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 `{ t, event }` |
|
| `events` | `MoveEvent<T>[]` | ordered, each `{ seq, t, event }` |
|
||||||
|
|
||||||
`MoveEvent<T> = { t: number, event: T }` — the log owns the per-event timestamp
|
`MoveEvent<T> = { seq: number, t: number, event: T }` — the log owns the
|
||||||
`t`, so `T` stays a pure game payload with no required shape.
|
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
|
## Invariant: zero game-specific imports
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
// @ts-check
|
// @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
|
* 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`),
|
* event vocabulary (mnswpr's `MoveEvent` union from core-06 is the first `T`),
|
||||||
* supplied by the caller.
|
* supplied by the caller.
|
||||||
*
|
*
|
||||||
* 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 timestamp (`{ t, event }`) so `T` can stay a pure game payload with
|
* per-event recording metadata (`seq` + `t`) so `T` can stay a pure game payload
|
||||||
* no required shape; the module never inspects the inside of an event.
|
* 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`.
|
||||||
|
|
@ -24,55 +24,142 @@
|
||||||
export const SCHEMA_VERSION = /** @type {SchemaVersion} */ (1)
|
export const SCHEMA_VERSION = /** @type {SchemaVersion} */ (1)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single recorded event: the game's opaque payload `event` plus the
|
* A single recorded event: the log-owned recording metadata — a strictly
|
||||||
* log-owned timestamp `t` (milliseconds). Generic over the game's event type
|
* increasing sequence number `seq` and a timestamp `t` (milliseconds) — plus the
|
||||||
* `T`.
|
* game's opaque payload `event`. Generic over the game's event type `T`.
|
||||||
*
|
*
|
||||||
* @template 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
|
* The container: a schema-versioned, ordered array of timestamped, sequenced
|
||||||
* recorded run. Generic over the game's event vocabulary `T`. JSON-safe as long
|
* events for one recorded run. Generic over the game's event vocabulary `T`.
|
||||||
* as `T` is.
|
* JSON-safe as long as `T` is.
|
||||||
*
|
*
|
||||||
* @template T
|
* @template T
|
||||||
* @typedef {{ schema_version: SchemaVersion, events: MoveEvent<T>[] }} MoveLog
|
* @typedef {{ schema_version: SchemaVersion, events: MoveEvent<T>[] }} MoveLog
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a move log from an ordered list of already-timestamped events. Pure and
|
* Validate an events array: each entry must be a `{ seq, t, event }` with an
|
||||||
* game-blind: it validates only the log's own invariants (each entry is a
|
* integer `seq`, a finite numeric `t`, and a present `event`; and `seq` must be
|
||||||
* `{ t: number, event }`), never the shape of `T`. Order is preserved as given.
|
* 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 {unknown} events
|
||||||
* @param {MoveEvent<T>[]} [events] - ordered events, each `{ t, event }`
|
|
||||||
* @returns {MoveLog<T>}
|
|
||||||
*/
|
*/
|
||||||
export function createMoveLog(events = []) {
|
function assertEvents(events) {
|
||||||
if (!Array.isArray(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) => {
|
let prevSeq = -Infinity
|
||||||
if (e === null || typeof e !== 'object' || typeof e.t !== 'number' || !('event' in e)) {
|
events.forEach((e, i) => {
|
||||||
throw new TypeError(`createMoveLog: events[${i}] must be { t: number, event }`)
|
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
|
* Assert a value is a well-formed move log — correct `schema_version` and a valid
|
||||||
* version? Checks the container invariants only — remains blind to `T`.
|
* 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
|
* @param {unknown} value
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export function isMoveLog(value) {
|
export function isMoveLog(value) {
|
||||||
if (value === null || typeof value !== 'object') return false
|
try {
|
||||||
const v = /** @type {any} */ (value)
|
assertMoveLog(value)
|
||||||
if (v.schema_version !== SCHEMA_VERSION || !Array.isArray(v.events)) return false
|
return true
|
||||||
return v.events.every(e => e !== null && typeof e === 'object' && typeof e.t === 'number' && 'event' in e)
|
} 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 { fileURLToPath } from 'node:url'
|
||||||
import { dirname, join } from 'node:path'
|
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.
|
// 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.
|
* 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', () => {
|
describe('@cozy-games/move-log', () => {
|
||||||
/** @type {import('@cozy-games/move-log').MoveEvent<DummyEvent>[]} */
|
/** @type {import('@cozy-games/move-log').MoveEvent<DummyEvent>[]} */
|
||||||
const events = [
|
const events = [
|
||||||
{ t: 0, event: { kind: 'tick' } },
|
{ seq: 1, t: 0, event: { kind: 'tick' } },
|
||||||
{ t: 50, event: { kind: 'boom', power: 3 } },
|
{ seq: 2, t: 50, event: { kind: 'boom', power: 3 } },
|
||||||
{ t: 120, event: { kind: 'tick' } }
|
{ seq: 5, t: 120, event: { kind: 'tick' } } // gaps allowed; strictly increasing
|
||||||
]
|
]
|
||||||
|
|
||||||
it('exposes schema_version typed as 1', () => {
|
it('exposes schema_version typed as 1', () => {
|
||||||
expect(SCHEMA_VERSION).toBe(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>} */
|
/** @type {import('@cozy-games/move-log').MoveLog<DummyEvent>} */
|
||||||
const log = createMoveLog(events)
|
const log = createMoveLog(events)
|
||||||
|
|
||||||
expect(log.schema_version).toBe(1)
|
expect(log.schema_version).toBe(1)
|
||||||
expect(log.events).toHaveLength(3)
|
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.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)', () => {
|
it('defaults to an empty run and copies entries (no aliasing of the input)', () => {
|
||||||
expect(createMoveLog()).toEqual({ schema_version: 1, events: [] })
|
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)
|
const log = createMoveLog(input)
|
||||||
input[0].t = 999
|
input[0].t = 999
|
||||||
expect(log.events[0].t).toBe(1) // log kept its own copy
|
expect(log.events[0].t).toBe(1) // log kept its own copy
|
||||||
})
|
})
|
||||||
|
|
||||||
it('is JSON-safe: stringify → parse round-trips without loss', () => {
|
it('rejects a non-monotonic seq at construction', () => {
|
||||||
|
expect(() => createMoveLog([
|
||||||
|
{ seq: 2, t: 0, event: {} },
|
||||||
|
{ seq: 1, t: 1, event: {} }
|
||||||
|
])).toThrow(RangeError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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 log = createMoveLog(events)
|
||||||
expect(JSON.parse(JSON.stringify(log))).toEqual(log)
|
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('rejects malformed input with a clear error', () => {
|
it('serializeMoveLog produces a JSON string parseable back to the same object', () => {
|
||||||
// @ts-expect-error — not an array
|
const log = createMoveLog(events)
|
||||||
expect(() => createMoveLog('nope')).toThrow(TypeError)
|
const json = serializeMoveLog(log)
|
||||||
expect(() => createMoveLog([{ event: { kind: 'tick' } }])).toThrow(TypeError) // missing t
|
expect(typeof json).toBe('string')
|
||||||
expect(() => createMoveLog([{ t: 5 }])).toThrow(TypeError) // missing event
|
expect(JSON.parse(json)).toEqual(log)
|
||||||
expect(() => createMoveLog([{ t: 'soon', event: {} }])).toThrow(TypeError) // t not a number
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('isMoveLog recognizes well-formed logs and rejects others', () => {
|
it('rejects each malformed fixture with a distinct, clear error', () => {
|
||||||
expect(isMoveLog(createMoveLog(events))).toBe(true)
|
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(null)).toBe(false)
|
||||||
expect(isMoveLog({ schema_version: 2, events: [] })).toBe(false) // wrong version
|
expect(isMoveLog({ schema_version: 1, events: [{ seq: 1, t: 0 }] })).toBe(false)
|
||||||
expect(isMoveLog({ schema_version: 1, events: 'nope' })).toBe(false)
|
|
||||||
expect(isMoveLog({ schema_version: 1, events: [{ event: {} }] })).toBe(false) // no timestamp
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('game-blindness guard (zero game-specific imports)', () => {
|
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)), '..')
|
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
|
||||||
// The set of game packages the move log must never depend on or import.
|
// 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 = []
|
const offenders = []
|
||||||
walk(pkgDir, file => {
|
walk(pkgDir, file => {
|
||||||
if (!file.endsWith('.js')) return
|
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.
|
// Strip comments so prose that *names* a game isn't a false positive.
|
||||||
const code = readFileSync(file, 'utf8')
|
const code = readFileSync(file, 'utf8')
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
.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) {
|
function walk(dir, fn) {
|
||||||
for (const name of readdirSync(dir)) {
|
for (const name of readdirSync(dir)) {
|
||||||
if (name === 'node_modules') continue
|
if (name === 'node_modules') continue
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue