From 6c90cde0f98486d8bc392034bb5f5247647a6b22 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 4 Jul 2026 12:21:06 +0200 Subject: [PATCH] feat(move-log): game-agnostic move events --- README.md | 2 +- packages/move-log/README.md | 34 ++++++++ packages/move-log/index.js | 78 +++++++++++++++++ packages/move-log/package.json | 22 +++++ packages/move-log/test/move-log.test.js | 109 ++++++++++++++++++++++++ pnpm-lock.yaml | 22 ++--- 6 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 packages/move-log/README.md create mode 100644 packages/move-log/index.js create mode 100644 packages/move-log/package.json create mode 100644 packages/move-log/test/move-log.test.js diff --git a/README.md b/README.md index 0d37321..43b4068 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A growing collection of small browser games and the shared, reusable packages th | -------------------- | -------------- | --------- | ---------- | | `mnswpr` (game core) | 🚧 Development | | | | leaderboard | ✅ Built | | | -| move-log envelope | 🔮 Planned | | | +| move-log | 🔮 Planned | | | | replay engine | 🔮 Planned | | | | rating math | 🔮 Planned | | | | `sudoku` (game core) | 🔮 Planned | | | diff --git a/packages/move-log/README.md b/packages/move-log/README.md new file mode 100644 index 0000000..3f6c94f --- /dev/null +++ b/packages/move-log/README.md @@ -0,0 +1,34 @@ +# @cozy-games/move-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 +import { createMoveLog, 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 } } +]) +// → { schema_version: 1, events: [ { t, event }, ... ] } +``` + +## Shape + +| field | type | meaning | +| ---------------- | ----------------- | ---------------------------------------- | +| `schema_version` | `1` | the move-log container version | +| `events` | `MoveEvent[]` | ordered, each `{ t, event }` | + +`MoveEvent = { t: number, event: T }` — the log owns the per-event timestamp +`t`, so `T` stays a pure game payload with no required shape. + +## Invariant: zero game-specific imports + +This module **must never import a game package** (e.g. mnswpr) or any game +vocabulary. `T` is always supplied by the consumer; the log only ever sees +opaque payloads. This independence is the whole point — it lets one move-log +format serve every game. + +The rule is enforced by a dependency-graph guard in `test/move-log.test.js` +(scans the package's source and manifest for game references). Keep it green. diff --git a/packages/move-log/index.js b/packages/move-log/index.js new file mode 100644 index 0000000..011c0ac --- /dev/null +++ b/packages/move-log/index.js @@ -0,0 +1,78 @@ +// @ts-check + +/** + * `@cozy-games/move-log` — a game-blind 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. + * + * 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. + * + * @typedef {1} SchemaVersion + */ +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`. + * + * @template T + * @typedef {{ 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. + * + * @template T + * @typedef {{ schema_version: SchemaVersion, events: MoveEvent[] }} 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. + * + * @template T + * @param {MoveEvent[]} [events] - ordered events, each `{ t, event }` + * @returns {MoveLog} + */ +export function createMoveLog(events = []) { + if (!Array.isArray(events)) { + throw new TypeError(`createMoveLog: 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 }`) + } + return { t: e.t, event: e.event } + }) + 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`. + * + * @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) +} diff --git a/packages/move-log/package.json b/packages/move-log/package.json new file mode 100644 index 0000000..9aa0b81 --- /dev/null +++ b/packages/move-log/package.json @@ -0,0 +1,22 @@ +{ + "name": "@cozy-games/move-log", + "version": "0.0.1", + "description": "Game-blind, schema-versioned log of a recorded run of move events — generic over the game's own event vocabulary", + "author": "Ayo Ayco", + "private": true, + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/ayo-run/mnswpr" + }, + "main": "index.js", + "exports": { + ".": { + "default": "./index.js" + }, + "./*": { + "default": "./*" + } + }, + "license": "BSD-2-Clause" +} diff --git a/packages/move-log/test/move-log.test.js b/packages/move-log/test/move-log.test.js new file mode 100644 index 0000000..0ddbc03 --- /dev/null +++ b/packages/move-log/test/move-log.test.js @@ -0,0 +1,109 @@ +// @ts-check +import { describe, it, expect } from 'vitest' +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 +// module resolves and is importable by other packages. +import { createMoveLog, isMoveLog, SCHEMA_VERSION } from '@cozy-games/move-log' + +/** + * A dummy event vocabulary defined HERE, in the test — deliberately NOT mnswpr's. + * The move log must type-check and work against any `T` the consumer supplies. + * @typedef {{ kind: 'tick' } | { kind: 'boom', power: number }} DummyEvent + */ + +describe('@cozy-games/move-log', () => { + /** @type {import('@cozy-games/move-log').MoveEvent[]} */ + const events = [ + { t: 0, event: { kind: 'tick' } }, + { t: 50, event: { kind: 'boom', power: 3 } }, + { t: 120, event: { kind: 'tick' } } + ] + + it('exposes schema_version typed as 1', () => { + expect(SCHEMA_VERSION).toBe(1) + }) + + it('wraps an ordered, timestamped event stream for a dummy vocabulary', () => { + /** @type {import('@cozy-games/move-log').MoveLog} */ + 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.t)).toEqual([0, 50, 120]) + expect(log.events[1]).toEqual({ 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 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 + }) +}) + +describe('game-blindness 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. + const GAME_REFERENCES = /mnswpr|minesweeper/i + + it('manifest declares no dependency on a game package', () => { + const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) + const deps = { + ...pkg.dependencies, + ...pkg.devDependencies, + ...pkg.peerDependencies + } + const offenders = Object.keys(deps).filter(name => GAME_REFERENCES.test(name)) + expect(offenders).toEqual([]) + }) + + it('no source file imports or references a game package', () => { + const offenders = [] + walk(pkgDir, file => { + if (!file.endsWith('.js')) return + if (file.includes('/test/')) return // the guard itself names 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, '') + .replace(/\/\/.*$/gm, '') + if (GAME_REFERENCES.test(code)) offenders.push(file) + }) + expect(offenders).toEqual([]) + }) +}) + +function walk(dir, fn) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules') continue + const p = join(dir, name) + if (statSync(p).isDirectory()) walk(p, fn) + else fn(p) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c60cae0..b71e138 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 29.1.1 secretlint: specifier: ^13.0.2 - version: 13.0.2 + version: 13.0.2(supports-color@10.2.2) simple-git: specifier: ^3.33.0 version: 3.33.0(supports-color@10.2.2) @@ -91,6 +91,8 @@ importers: specifier: workspace:* version: link:../utils + packages/move-log: {} + packages/utils: {} packages: @@ -8498,18 +8500,18 @@ snapshots: dependencies: '@secretlint/types': 13.0.2 - '@secretlint/config-loader@13.0.2': + '@secretlint/config-loader@13.0.2(supports-color@10.2.2)': dependencies: '@secretlint/profiler': 13.0.2 '@secretlint/resolver': 13.0.2 '@secretlint/types': 13.0.2 ajv: 8.20.0 debug: 4.4.3(supports-color@10.2.2) - rc-config-loader: 4.1.4 + rc-config-loader: 4.1.4(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@secretlint/core@13.0.2': + '@secretlint/core@13.0.2(supports-color@10.2.2)': dependencies: '@secretlint/profiler': 13.0.2 '@secretlint/types': 13.0.2 @@ -8534,10 +8536,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@secretlint/node@13.0.2': + '@secretlint/node@13.0.2(supports-color@10.2.2)': dependencies: - '@secretlint/config-loader': 13.0.2 - '@secretlint/core': 13.0.2 + '@secretlint/config-loader': 13.0.2(supports-color@10.2.2) + '@secretlint/core': 13.0.2(supports-color@10.2.2) '@secretlint/formatter': 13.0.2 '@secretlint/profiler': 13.0.2 '@secretlint/source-creator': 13.0.2 @@ -12697,7 +12699,7 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 - rc-config-loader@4.1.4: + rc-config-loader@4.1.4(supports-color@10.2.2): dependencies: debug: 4.4.3(supports-color@10.2.2) js-yaml: 4.3.0 @@ -12968,11 +12970,11 @@ snapshots: dependencies: xmlchars: 2.2.0 - secretlint@13.0.2: + secretlint@13.0.2(supports-color@10.2.2): dependencies: '@secretlint/config-creator': 13.0.2 '@secretlint/formatter': 13.0.2 - '@secretlint/node': 13.0.2 + '@secretlint/node': 13.0.2(supports-color@10.2.2) '@secretlint/profiler': 13.0.2 '@secretlint/resolver': 13.0.2 '@secretlint/walker': 13.0.2