feat(move-log): game-agnostic move events
This commit is contained in:
parent
0823d43c75
commit
6c90cde0f9
6 changed files with 256 additions and 11 deletions
|
|
@ -15,7 +15,7 @@ A growing collection of small browser games and the shared, reusable packages th
|
||||||
| -------------------- | -------------- | --------- | ---------- |
|
| -------------------- | -------------- | --------- | ---------- |
|
||||||
| `mnswpr` (game core) | 🚧 Development | | |
|
| `mnswpr` (game core) | 🚧 Development | | |
|
||||||
| leaderboard | ✅ Built | | |
|
| leaderboard | ✅ Built | | |
|
||||||
| move-log envelope | 🔮 Planned | | |
|
| move-log | 🔮 Planned | | |
|
||||||
| replay engine | 🔮 Planned | | |
|
| replay engine | 🔮 Planned | | |
|
||||||
| rating math | 🔮 Planned | | |
|
| rating math | 🔮 Planned | | |
|
||||||
| `sudoku` (game core) | 🔮 Planned | | |
|
| `sudoku` (game core) | 🔮 Planned | | |
|
||||||
|
|
|
||||||
34
packages/move-log/README.md
Normal file
34
packages/move-log/README.md
Normal file
|
|
@ -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<T>[]` | ordered, each `{ 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
78
packages/move-log/index.js
Normal file
78
packages/move-log/index.js
Normal file
|
|
@ -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<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.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param {MoveEvent<T>[]} [events] - ordered events, each `{ t, event }`
|
||||||
|
* @returns {MoveLog<T>}
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
}
|
||||||
22
packages/move-log/package.json
Normal file
22
packages/move-log/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
109
packages/move-log/test/move-log.test.js
Normal file
109
packages/move-log/test/move-log.test.js
Normal file
|
|
@ -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<DummyEvent>[]} */
|
||||||
|
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<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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,7 +37,7 @@ importers:
|
||||||
version: 29.1.1
|
version: 29.1.1
|
||||||
secretlint:
|
secretlint:
|
||||||
specifier: ^13.0.2
|
specifier: ^13.0.2
|
||||||
version: 13.0.2
|
version: 13.0.2(supports-color@10.2.2)
|
||||||
simple-git:
|
simple-git:
|
||||||
specifier: ^3.33.0
|
specifier: ^3.33.0
|
||||||
version: 3.33.0(supports-color@10.2.2)
|
version: 3.33.0(supports-color@10.2.2)
|
||||||
|
|
@ -91,6 +91,8 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../utils
|
version: link:../utils
|
||||||
|
|
||||||
|
packages/move-log: {}
|
||||||
|
|
||||||
packages/utils: {}
|
packages/utils: {}
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
@ -8498,18 +8500,18 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@secretlint/types': 13.0.2
|
'@secretlint/types': 13.0.2
|
||||||
|
|
||||||
'@secretlint/config-loader@13.0.2':
|
'@secretlint/config-loader@13.0.2(supports-color@10.2.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@secretlint/profiler': 13.0.2
|
'@secretlint/profiler': 13.0.2
|
||||||
'@secretlint/resolver': 13.0.2
|
'@secretlint/resolver': 13.0.2
|
||||||
'@secretlint/types': 13.0.2
|
'@secretlint/types': 13.0.2
|
||||||
ajv: 8.20.0
|
ajv: 8.20.0
|
||||||
debug: 4.4.3(supports-color@10.2.2)
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@secretlint/core@13.0.2':
|
'@secretlint/core@13.0.2(supports-color@10.2.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@secretlint/profiler': 13.0.2
|
'@secretlint/profiler': 13.0.2
|
||||||
'@secretlint/types': 13.0.2
|
'@secretlint/types': 13.0.2
|
||||||
|
|
@ -8534,10 +8536,10 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@secretlint/node@13.0.2':
|
'@secretlint/node@13.0.2(supports-color@10.2.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@secretlint/config-loader': 13.0.2
|
'@secretlint/config-loader': 13.0.2(supports-color@10.2.2)
|
||||||
'@secretlint/core': 13.0.2
|
'@secretlint/core': 13.0.2(supports-color@10.2.2)
|
||||||
'@secretlint/formatter': 13.0.2
|
'@secretlint/formatter': 13.0.2
|
||||||
'@secretlint/profiler': 13.0.2
|
'@secretlint/profiler': 13.0.2
|
||||||
'@secretlint/source-creator': 13.0.2
|
'@secretlint/source-creator': 13.0.2
|
||||||
|
|
@ -12697,7 +12699,7 @@ snapshots:
|
||||||
iconv-lite: 0.7.2
|
iconv-lite: 0.7.2
|
||||||
unpipe: 1.0.0
|
unpipe: 1.0.0
|
||||||
|
|
||||||
rc-config-loader@4.1.4:
|
rc-config-loader@4.1.4(supports-color@10.2.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@10.2.2)
|
debug: 4.4.3(supports-color@10.2.2)
|
||||||
js-yaml: 4.3.0
|
js-yaml: 4.3.0
|
||||||
|
|
@ -12968,11 +12970,11 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
xmlchars: 2.2.0
|
xmlchars: 2.2.0
|
||||||
|
|
||||||
secretlint@13.0.2:
|
secretlint@13.0.2(supports-color@10.2.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@secretlint/config-creator': 13.0.2
|
'@secretlint/config-creator': 13.0.2
|
||||||
'@secretlint/formatter': 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/profiler': 13.0.2
|
||||||
'@secretlint/resolver': 13.0.2
|
'@secretlint/resolver': 13.0.2
|
||||||
'@secretlint/walker': 13.0.2
|
'@secretlint/walker': 13.0.2
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue