diff --git a/AGENTS.md b/AGENTS.md index 2185952..33a08b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,7 @@ The **Firebase config in `leader-board.js` is intentionally public and committed - **Code style is enforced by ESLint Stylistic**, not Prettier: 2-space indent, single quotes, **no semicolons**, no trailing commas, spaces inside `{ braces }` but not `[brackets]`. Run `pnpm lint:fix` before committing. Both `**/*.js` and `**/*.css` are linted (CSS via `@eslint/css`). - The engine uses **plain functions and `var`/`let` closures**, not classes; `packages/utils/` and `apps/mnswpr/modules/` use ES classes. Match the surrounding style of the file you edit. +- **Types are generated, not authored.** Source stays JS + JSDoc (`// @ts-check`); `tsc` is a build-time tool that emits `.d.ts` from that JSDoc so published `@cozy-games/*` packages ship types. Declarations are emitted **co-located** next to each source file and **committed** — `pnpm build:types` (`scripts/build-types.mjs`) deletes the previous ones and re-runs `tsc -p tsconfig.types.json`, since TypeScript won't emit over an existing `.d.ts` (TS5055). The type-check runs `strict: false` and covers only the files named in that config's `include` list — adding a new published source file means adding it there, or it ships without types. After touching JSDoc on an included file, run `pnpm build:types` and commit the regenerated declarations. ## Release & git hooks (maintainer workflow) diff --git a/packages/leaderboard/leader-board.d.ts b/packages/leaderboard/leader-board.d.ts index fd8d4dd..29edd48 100644 --- a/packages/leaderboard/leader-board.d.ts +++ b/packages/leaderboard/leader-board.d.ts @@ -55,13 +55,18 @@ export class LeaderBoardService { /** * Read surface — render the ranked list with a duration tab bar. * @see LeaderBoardReader#render + * @param {String} category + * @param {String} title + * @param {String} [duration] + * @returns {Promise} */ - render(category: any, title: any, duration: any): Promise; + render(category: string, title: string, duration?: string): Promise; /** * Write surface — submit a completed game (archive + ranked entry). * @see LeaderBoardWriter#submit + * @param {import('./leaderboard-write.js').ScoreEntry} entry */ - submit(entry: any): Promise; + submit(entry: import("./leaderboard-write.js").ScoreEntry): Promise; } import { LeaderBoardReader } from './leaderboard-read.js'; import { LeaderBoardWriter } from './leaderboard-write.js'; diff --git a/packages/leaderboard/leader-board.js b/packages/leaderboard/leader-board.js index ff47627..9b3ec77 100644 --- a/packages/leaderboard/leader-board.js +++ b/packages/leaderboard/leader-board.js @@ -50,6 +50,10 @@ export class LeaderBoardService { /** * Read surface — render the ranked list with a duration tab bar. * @see LeaderBoardReader#render + * @param {String} category + * @param {String} title + * @param {String} [duration] + * @returns {Promise} */ render(category, title, duration) { return this.reader.render(category, title, duration) @@ -58,6 +62,7 @@ export class LeaderBoardService { /** * Write surface — submit a completed game (archive + ranked entry). * @see LeaderBoardWriter#submit + * @param {import('./leaderboard-write.js').ScoreEntry} entry */ submit(entry) { return this.writer.submit(entry) diff --git a/packages/leaderboard/leaderboard-write.d.ts b/packages/leaderboard/leaderboard-write.d.ts index d87943f..4b44605 100644 --- a/packages/leaderboard/leaderboard-write.d.ts +++ b/packages/leaderboard/leaderboard-write.d.ts @@ -1,3 +1,17 @@ +/** + * A completed-game entry offered to the leaderboard. `score` is the ranked value + * (sorted per the configured order); `category` selects the board; `time_stamp` + * is denormalized into day/week/month buckets on write. + * + * @typedef {Object} ScoreEntry + * @property {number} score + * @property {string} category + * @property {string} playerId + * @property {Date | number | string} time_stamp + * @property {string} [name] - display name; defaults to 'Anonymous' + * @property {string} [status] - outcome the default qualifier checks against `config.passingStatus` + * @property {Object} [meta] - optional extra fields carried through to storage + */ /** * The WRITE surface of the leaderboard: submitting a completed game — the * personal archive plus, if it qualifies, a ranked entry with denormalized @@ -33,7 +47,30 @@ export class LeaderBoardWriter { * qualifies, also writes a ranked entry with denormalized bucket keys. Both * writes go through the adapter, so the storage backend is pluggable. The * caller owns display-name/nickname UX. - * @param {Object} entry - { name, playerId, score, category, time_stamp, status?, meta? } + * @param {ScoreEntry} entry */ - submit(entry: any): Promise; + submit(entry: ScoreEntry): Promise; } +/** + * A completed-game entry offered to the leaderboard. `score` is the ranked value + * (sorted per the configured order); `category` selects the board; `time_stamp` + * is denormalized into day/week/month buckets on write. + */ +export type ScoreEntry = { + score: number; + category: string; + playerId: string; + time_stamp: Date | number | string; + /** + * - display name; defaults to 'Anonymous' + */ + name?: string; + /** + * - outcome the default qualifier checks against `config.passingStatus` + */ + status?: string; + /** + * - optional extra fields carried through to storage + */ + meta?: any; +}; diff --git a/packages/leaderboard/leaderboard-write.js b/packages/leaderboard/leaderboard-write.js index 651dd2c..f02b592 100644 --- a/packages/leaderboard/leaderboard-write.js +++ b/packages/leaderboard/leaderboard-write.js @@ -1,5 +1,20 @@ import { buckets } from '@cozy-games/utils/date-bucket/date-bucket.js' +/** + * A completed-game entry offered to the leaderboard. `score` is the ranked value + * (sorted per the configured order); `category` selects the board; `time_stamp` + * is denormalized into day/week/month buckets on write. + * + * @typedef {Object} ScoreEntry + * @property {number} score + * @property {string} category + * @property {string} playerId + * @property {Date | number | string} time_stamp + * @property {string} [name] - display name; defaults to 'Anonymous' + * @property {string} [status] - outcome the default qualifier checks against `config.passingStatus` + * @property {Object} [meta] - optional extra fields carried through to storage + */ + /** * The WRITE surface of the leaderboard: submitting a completed game — the * personal archive plus, if it qualifies, a ranked entry with denormalized @@ -46,7 +61,7 @@ export class LeaderBoardWriter { * qualifies, also writes a ranked entry with denormalized bucket keys. Both * writes go through the adapter, so the storage backend is pluggable. The * caller owns display-name/nickname UX. - * @param {Object} entry - { name, playerId, score, category, time_stamp, status?, meta? } + * @param {ScoreEntry} entry */ async submit(entry) { if (this.adapter.archive) { diff --git a/packages/leaderboard/package.json b/packages/leaderboard/package.json index 8063e89..9a3844c 100644 --- a/packages/leaderboard/package.json +++ b/packages/leaderboard/package.json @@ -1,6 +1,6 @@ { "name": "@cozy-games/leaderboard", - "version": "0.0.1", + "version": "0.0.2", "description": "Generic, game-agnostic Firestore leaderboard with time-windowed views", "author": "Ayo Ayco", "type": "module", diff --git a/packages/mnswpr/adapters/replay-common.d.ts b/packages/mnswpr/adapters/replay-common.d.ts index 4c9cf28..86d6f10 100644 --- a/packages/mnswpr/adapters/replay-common.d.ts +++ b/packages/mnswpr/adapters/replay-common.d.ts @@ -1,18 +1,36 @@ /** - * @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent + * A recorded mnswpr move-log entry — the `@cozy-games/move-log` / ADR-002 §1 + * envelope shape as instantiated by Minesweeper: a surfaced `type` discriminator + * plus an opaque `payload` carrying the move's `{ r, c }`. This is what the replay + * engine hands the reducers (the only fields they read). + * + * @typedef {{ type: string, payload: { r: number, c: number } }} MnswprRecord */ /** - * Map a recorded move-event back to the rules move that produced it: `flag` and - * `unflag` are both the toggle move `flag`; `reveal` and `chord` pass through. - * Unknown kinds are ignored. Shared by the mnswpr replay adapters (progress and - * full-board state) so both replay a stream through the core rules identically. + * Map a recorded move-log entry back to the rules move that produced it: `flag` + * and `unflag` are both the toggle move `flag`; `reveal` and `chord` pass + * through. Unknown kinds are ignored. Shared by the mnswpr replay adapters + * (progress and full-board state) so both replay a stream through the core rules + * identically. * - * @param {MnswprMoveEvent} e + * @param {MnswprRecord} record * @returns {{ type: 'reveal' | 'flag' | 'chord', r: number, c: number } | null} */ -export function toMove(e: MnswprMoveEvent): { +export function toMove(record: MnswprRecord): { type: "reveal" | "flag" | "chord"; r: number; c: number; } | null; -export type MnswprMoveEvent = import("../core/minesweeper/rules.js").MoveEvent; +/** + * A recorded mnswpr move-log entry — the `@cozy-games/move-log` / ADR-002 §1 + * envelope shape as instantiated by Minesweeper: a surfaced `type` discriminator + * plus an opaque `payload` carrying the move's `{ r, c }`. This is what the replay + * engine hands the reducers (the only fields they read). + */ +export type MnswprRecord = { + type: string; + payload: { + r: number; + c: number; + }; +}; diff --git a/packages/mnswpr/adapters/replay-common.js b/packages/mnswpr/adapters/replay-common.js index 13c6195..ce5c42c 100644 --- a/packages/mnswpr/adapters/replay-common.js +++ b/packages/mnswpr/adapters/replay-common.js @@ -1,24 +1,34 @@ // @ts-check /** - * @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent + * A recorded mnswpr move-log entry — the `@cozy-games/move-log` / ADR-002 §1 + * envelope shape as instantiated by Minesweeper: a surfaced `type` discriminator + * plus an opaque `payload` carrying the move's `{ r, c }`. This is what the replay + * engine hands the reducers (the only fields they read). + * + * @typedef {{ type: string, payload: { r: number, c: number } }} MnswprRecord */ /** - * Map a recorded move-event back to the rules move that produced it: `flag` and - * `unflag` are both the toggle move `flag`; `reveal` and `chord` pass through. - * Unknown kinds are ignored. Shared by the mnswpr replay adapters (progress and - * full-board state) so both replay a stream through the core rules identically. + * Map a recorded move-log entry back to the rules move that produced it: `flag` + * and `unflag` are both the toggle move `flag`; `reveal` and `chord` pass + * through. Unknown kinds are ignored. Shared by the mnswpr replay adapters + * (progress and full-board state) so both replay a stream through the core rules + * identically. * - * @param {MnswprMoveEvent} e + * @param {MnswprRecord} record * @returns {{ type: 'reveal' | 'flag' | 'chord', r: number, c: number } | null} */ -export function toMove(e) { - switch (e && e.type) { - case 'reveal': return { type: 'reveal', r: e.r, c: e.c } - case 'chord': return { type: 'chord', r: e.r, c: e.c } +export function toMove(record) { + if (!record) return null + const { type, payload } = record + const r = payload ? payload.r : 0 + const c = payload ? payload.c : 0 + switch (type) { + case 'reveal': return { type: 'reveal', r, c } + case 'chord': return { type: 'chord', r, c } case 'flag': - case 'unflag': return { type: 'flag', r: e.r, c: e.c } + case 'unflag': return { type: 'flag', r, c } default: return null } } diff --git a/packages/mnswpr/adapters/replay-progress.d.ts b/packages/mnswpr/adapters/replay-progress.d.ts index 65c29a3..c338e2c 100644 --- a/packages/mnswpr/adapters/replay-progress.d.ts +++ b/packages/mnswpr/adapters/replay-progress.d.ts @@ -1,5 +1,5 @@ /** - * @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent + * @typedef {import('./replay-common.js').MnswprRecord} MnswprRecord * @typedef {import('../core/minesweeper/board.js').Layout} Layout */ /** @@ -10,18 +10,17 @@ * `revealed safe cells / total safe cells * 100`. * * Why it needs the board: a single `reveal` or `chord` event floods MANY cells, - * but the recorded move-event only carries `{ type, r, c }` — not how many cells - * opened. So the reducer takes the board as closure input (consistent with the - * interface design) and replays the moves through the pure core rules. That makes - * reveals flood, chords reveal via their (non-flagged) neighbors, and - * flags/unflags only gate chords — never advancing progress themselves — with no - * cell double-counted. The engine stays game-blind; all interpretation is here. + * but the recorded entry only carries the move's `type` + `{ r, c }` payload — not + * how many cells opened. So the reducer takes the board as closure input + * (consistent with the interface design) and replays the moves through the pure + * core rules. That makes reveals flood, chords reveal via their (non-flagged) + * neighbors, and flags/unflags only gate chords — never advancing progress + * themselves — with no cell double-counted. The engine stays game-blind; all + * interpretation is here. * * @param {Layout} layout - the recorded board (as produced by `generateBoard`) - * @returns {(events: { event: MnswprMoveEvent }[]) => number} a reducer to `[0, 100]` + * @returns {(events: MnswprRecord[]) => number} a reducer to `[0, 100]` */ -export function createProgressReducer(layout: Layout): (events: { - event: MnswprMoveEvent; -}[]) => number; -export type MnswprMoveEvent = import("../core/minesweeper/rules.js").MoveEvent; +export function createProgressReducer(layout: Layout): (events: MnswprRecord[]) => number; +export type MnswprRecord = import("./replay-common.js").MnswprRecord; export type Layout = import("../core/minesweeper/board.js").Layout; diff --git a/packages/mnswpr/adapters/replay-progress.js b/packages/mnswpr/adapters/replay-progress.js index 26552c9..53e22ff 100644 --- a/packages/mnswpr/adapters/replay-progress.js +++ b/packages/mnswpr/adapters/replay-progress.js @@ -3,7 +3,7 @@ import { MinesweeperRules } from '../core/minesweeper/rules.js' import { toMove } from './replay-common.js' /** - * @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent + * @typedef {import('./replay-common.js').MnswprRecord} MnswprRecord * @typedef {import('../core/minesweeper/board.js').Layout} Layout */ @@ -15,15 +15,16 @@ import { toMove } from './replay-common.js' * `revealed safe cells / total safe cells * 100`. * * Why it needs the board: a single `reveal` or `chord` event floods MANY cells, - * but the recorded move-event only carries `{ type, r, c }` — not how many cells - * opened. So the reducer takes the board as closure input (consistent with the - * interface design) and replays the moves through the pure core rules. That makes - * reveals flood, chords reveal via their (non-flagged) neighbors, and - * flags/unflags only gate chords — never advancing progress themselves — with no - * cell double-counted. The engine stays game-blind; all interpretation is here. + * but the recorded entry only carries the move's `type` + `{ r, c }` payload — not + * how many cells opened. So the reducer takes the board as closure input + * (consistent with the interface design) and replays the moves through the pure + * core rules. That makes reveals flood, chords reveal via their (non-flagged) + * neighbors, and flags/unflags only gate chords — never advancing progress + * themselves — with no cell double-counted. The engine stays game-blind; all + * interpretation is here. * * @param {Layout} layout - the recorded board (as produced by `generateBoard`) - * @returns {(events: { event: MnswprMoveEvent }[]) => number} a reducer to `[0, 100]` + * @returns {(events: MnswprRecord[]) => number} a reducer to `[0, 100]` */ export function createProgressReducer(layout) { const totalSafe = layout.rows * layout.cols - layout.mines @@ -32,7 +33,7 @@ export function createProgressReducer(layout) { if (totalSafe === 0) return 100 let state = MinesweeperRules.fromLayout(layout) for (const record of events) { - const move = toMove(record.event) + const move = toMove(record) if (move) state = MinesweeperRules.apply(state, move).state } return (state.revealedSafe / totalSafe) * 100 diff --git a/packages/mnswpr/adapters/replay-state.d.ts b/packages/mnswpr/adapters/replay-state.d.ts index 866d5be..854d453 100644 --- a/packages/mnswpr/adapters/replay-state.d.ts +++ b/packages/mnswpr/adapters/replay-state.d.ts @@ -1,14 +1,14 @@ /** - * @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent + * @typedef {import('./replay-common.js').MnswprRecord} MnswprRecord * @typedef {import('../core/minesweeper/board.js').Layout} Layout * @typedef {{ mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }} BoardCell * @typedef {{ rows: number, cols: number, phase: string, revealedSafe: number, cells: BoardCell[][] }} BoardState */ /** * The full-board state reducer for Minesweeper — mnswpr's implementation of the - * replay engine's `StateReducer` seam (replay-05). - * Given the ordered slice of move-events played so far, it reconstructs the - * COMPLETE board at that point: every cell's mine/adjacent/status plus the phase. + * replay engine's `StateReducer` seam (replay-05). Given the ordered + * slice of move-log entries played so far, it reconstructs the COMPLETE board at + * that point: every cell's mine/adjacent/status plus the phase. * * Like the progress reducer, it takes the board as closure input and replays the * moves through the pure core rules — so reveals flood, chords open their @@ -17,12 +17,10 @@ * any position and rebuild the board there. * * @param {Layout} layout - the recorded board (as produced by `generateBoard`) - * @returns {(events: { event: MnswprMoveEvent }[]) => BoardState} + * @returns {(events: MnswprRecord[]) => BoardState} */ -export function createStateReducer(layout: Layout): (events: { - event: MnswprMoveEvent; -}[]) => BoardState; -export type MnswprMoveEvent = import("../core/minesweeper/rules.js").MoveEvent; +export function createStateReducer(layout: Layout): (events: MnswprRecord[]) => BoardState; +export type MnswprRecord = import("./replay-common.js").MnswprRecord; export type Layout = import("../core/minesweeper/board.js").Layout; export type BoardCell = { mine: boolean; diff --git a/packages/mnswpr/adapters/replay-state.js b/packages/mnswpr/adapters/replay-state.js index 27320fe..a4f0bed 100644 --- a/packages/mnswpr/adapters/replay-state.js +++ b/packages/mnswpr/adapters/replay-state.js @@ -3,7 +3,7 @@ import { MinesweeperRules } from '../core/minesweeper/rules.js' import { toMove } from './replay-common.js' /** - * @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent + * @typedef {import('./replay-common.js').MnswprRecord} MnswprRecord * @typedef {import('../core/minesweeper/board.js').Layout} Layout * @typedef {{ mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }} BoardCell * @typedef {{ rows: number, cols: number, phase: string, revealedSafe: number, cells: BoardCell[][] }} BoardState @@ -11,9 +11,9 @@ import { toMove } from './replay-common.js' /** * The full-board state reducer for Minesweeper — mnswpr's implementation of the - * replay engine's `StateReducer` seam (replay-05). - * Given the ordered slice of move-events played so far, it reconstructs the - * COMPLETE board at that point: every cell's mine/adjacent/status plus the phase. + * replay engine's `StateReducer` seam (replay-05). Given the ordered + * slice of move-log entries played so far, it reconstructs the COMPLETE board at + * that point: every cell's mine/adjacent/status plus the phase. * * Like the progress reducer, it takes the board as closure input and replays the * moves through the pure core rules — so reveals flood, chords open their @@ -22,13 +22,13 @@ import { toMove } from './replay-common.js' * any position and rebuild the board there. * * @param {Layout} layout - the recorded board (as produced by `generateBoard`) - * @returns {(events: { event: MnswprMoveEvent }[]) => BoardState} + * @returns {(events: MnswprRecord[]) => BoardState} */ export function createStateReducer(layout) { return function state(events) { let s = MinesweeperRules.fromLayout(layout) for (const record of events) { - const move = toMove(record.event) + const move = toMove(record) if (move) s = MinesweeperRules.apply(s, move).state } return toBoard(s) diff --git a/packages/mnswpr/core/grid/grid.d.ts b/packages/mnswpr/core/grid/grid.d.ts index 383e2f8..b55a51f 100644 --- a/packages/mnswpr/core/grid/grid.d.ts +++ b/packages/mnswpr/core/grid/grid.d.ts @@ -15,11 +15,24 @@ export class Grid { cols: number; /** @type {Cell[]} */ _cells: Cell[]; - /** @returns {boolean} */ - inBounds(r: any, c: any): boolean; - /** @returns {Cell} */ - at(r: any, c: any): Cell; - set(r: any, c: any, cell: any): void; + /** + * @param {number} r + * @param {number} c + * @returns {boolean} + */ + inBounds(r: number, c: number): boolean; + /** + * @param {number} r + * @param {number} c + * @returns {Cell} + */ + at(r: number, c: number): Cell; + /** + * @param {number} r + * @param {number} c + * @param {Cell} cell + */ + set(r: number, c: number, cell: Cell): void; /** @param {(cell: Cell, r: number, c: number) => void} fn */ forEach(fn: (cell: Cell, r: number, c: number) => void): void; /** diff --git a/packages/mnswpr/core/grid/grid.js b/packages/mnswpr/core/grid/grid.js index fcfa2a0..bbc662d 100644 --- a/packages/mnswpr/core/grid/grid.js +++ b/packages/mnswpr/core/grid/grid.js @@ -26,16 +26,29 @@ export class Grid { } } - /** @returns {boolean} */ + /** + * @param {number} r + * @param {number} c + * @returns {boolean} + */ inBounds(r, c) { return r >= 0 && c >= 0 && r < this.rows && c < this.cols } - /** @returns {Cell} */ + /** + * @param {number} r + * @param {number} c + * @returns {Cell} + */ at(r, c) { return this._cells[r * this.cols + c] } + /** + * @param {number} r + * @param {number} c + * @param {Cell} cell + */ set(r, c, cell) { this._cells[r * this.cols + c] = cell } diff --git a/packages/mnswpr/core/minesweeper/reveal.js b/packages/mnswpr/core/minesweeper/reveal.js index a104755..7735928 100644 --- a/packages/mnswpr/core/minesweeper/reveal.js +++ b/packages/mnswpr/core/minesweeper/reveal.js @@ -24,9 +24,11 @@ export function floodReveal(grid, startR, startC) { start.status = 'revealed' revealed.push({ r: startR, c: startC, adjacent: start.adjacent }) + /** @type {[number, number][]} */ const queue = [[startR, startC]] while (queue.length) { - const [r, c] = queue.shift() + // Safe: the `while (queue.length)` guard guarantees a value here. + const [r, c] = /** @type {[number, number]} */ (queue.shift()) // Only blank cells propagate; numbers are a boundary. if (grid.at(r, c).adjacent !== 0) continue for (const [nr, nc] of eightWay(grid, r, c)) { diff --git a/packages/mnswpr/core/minesweeper/rules.d.ts b/packages/mnswpr/core/minesweeper/rules.d.ts index b01b49e..05f20d9 100644 --- a/packages/mnswpr/core/minesweeper/rules.d.ts +++ b/packages/mnswpr/core/minesweeper/rules.d.ts @@ -8,6 +8,8 @@ * @typedef {{ seed: number, config: Config, grid: Grid, phase: Phase, minesPlaced: boolean, revealedSafe: number }} State * @typedef {{ type: 'reveal', r: number, c: number } | { type: 'flag', r: number, c: number } | { type: 'chord', r: number, c: number }} Move * @typedef {object} Event + * @typedef {{ seed: number, config: Config, phase: Phase, minesPlaced: boolean, revealedSafe: number, grid: { rows: number, cols: number, cells: Cell[] } }} Snapshot + * @typedef {{ r: number, c: number, status: 'revealed', adjacent: number, mine: boolean } | { r: number, c: number, status: 'flagged' } | { r: number, c: number, status: 'hidden', mine: true }} ProjectedCell */ /** * The typed move-event vocabulary emitted by the session (one per effective @@ -23,12 +25,78 @@ */ /** The move-event vocabulary as runtime data (the `MoveEvent` `type` domain). */ export const MOVE_EVENT_TYPES: readonly ["reveal", "flag", "unflag", "chord"]; -/** - * The GameRules contract consumed by GameSession/replay: init / apply / status / - * project, plus serialize / deserialize for snapshotting. Deterministic and - * DOM-free. - */ -export const MinesweeperRules: any; +export namespace MinesweeperRules { + /** + * @param {number} seed + * @param {Config} config + * @returns {State} + */ + export function init(seed: number, config: Config): State; + /** + * Build a game state from an explicit, pre-built layout (as returned by + * `generateBoard`) instead of generating one from a seed. Parallel to + * {@link init}: it yields a `State` a `GameSession` can drive identically — + * same rules, same transitions — the only difference being that the board is + * fixed up front, so the opening reveal is NOT made safe (first-click safety is + * a property of internal generation, not of a caller-supplied board). The + * layout is validated first and a malformed one throws. + * + * @param {import('./board.js').Layout} layout + * @param {{ seed?: number }} [opts] - seed is metadata only (no generation happens); defaults to 0 + * @returns {State} + */ + export function fromLayout(layout: import("./board.js").Layout, { seed }?: { + seed?: number; + }): State; + /** @param {State} state @returns {Phase} */ + export function status(state: State): Phase; + /** + * Fold a move into the state. Terminal states are absorbing. + * @param {State} state + * @param {Move} move + * @returns {{ state: State, events: Event[] }} + */ + export function apply(state: State, move: Move): { + state: State; + events: Event[]; + }; + export { project }; + /** + * Classify an applied move into a typed move-event kind, or `null` if the move + * was a no-op (the rules produced no events — e.g. clicking a revealed cell). + * The session stamps the returned `{ type, r, c }` with `t` and `seq`. This is + * the seam that turns a raw `Move` into the {@link MoveEvent} vocabulary and, + * critically, splits a `flag` move into `flag`/`unflag` by its outcome. + * + * @param {Move} move + * @param {Event[]} events - the rules events this move produced + * @returns {{ type: MoveEventType, r: number, c: number } | null} + */ + export function toMoveEvent(move: Move, events: Event[]): { + type: MoveEventType; + r: number; + c: number; + } | null; + /** + * Snapshot a game state as a plain, JSON-safe object: the whole board (every + * cell's mine/adjacent/status, via the Layer-0 grid serializer) plus phase and + * progress. Inverse of {@link deserialize}. The `grid` instance is the only + * non-JSON-safe field of `State`; everything else (seed, config, flags) is + * already plain data. + * + * @param {State} state + * @returns {Snapshot} + */ + export function serialize(state: State): Snapshot; + /** + * Rebuild a game state from {@link serialize} output (or its JSON round-trip). + * Cells are cloned so the revived state shares no references with the snapshot. + * + * @param {Snapshot} snap + * @returns {State} + */ + export function deserialize(snap: Snapshot): State; +} /** * Minesweeper as a pure, deterministic state machine — no DOM, no wall clock. * `GameSession` (Layer 1) drives it; the client renders the events it emits. @@ -78,6 +146,42 @@ export type Move = { * `GameSession` (Layer 1) drives it; the client renders the events it emits. */ export type Event = object; +/** + * Minesweeper as a pure, deterministic state machine — no DOM, no wall clock. + * `GameSession` (Layer 1) drives it; the client renders the events it emits. + */ +export type Snapshot = { + seed: number; + config: Config; + phase: Phase; + minesPlaced: boolean; + revealedSafe: number; + grid: { + rows: number; + cols: number; + cells: Cell[]; + }; +}; +/** + * Minesweeper as a pure, deterministic state machine — no DOM, no wall clock. + * `GameSession` (Layer 1) drives it; the client renders the events it emits. + */ +export type ProjectedCell = { + r: number; + c: number; + status: "revealed"; + adjacent: number; + mine: boolean; +} | { + r: number; + c: number; + status: "flagged"; +} | { + r: number; + c: number; + status: "hidden"; + mine: true; +}; /** * The typed move-event vocabulary emitted by the session (one per effective * move). This is the game's public event language — consumed later by the shared @@ -104,4 +208,19 @@ export type MoveEvent = { t: number; seq: number; }; +/** + * Project full state down to what a client is allowed to know: revealed cells + * (+ their adjacency), flags, and — only once the game is over — the mines. An + * unrevealed mine is NEVER included mid-game, so this is safe to send over a wire + * (invariant #3). Hidden, unrevealed, non-mine cells are simply omitted. + * + * @param {State} state + * @returns {{ config: Config, phase: Phase, cells: ProjectedCell[] }} + */ +declare function project(state: State): { + config: Config; + phase: Phase; + cells: ProjectedCell[]; +}; import { Grid } from '../grid/grid.js'; +export {}; diff --git a/packages/mnswpr/core/minesweeper/rules.js b/packages/mnswpr/core/minesweeper/rules.js index 56e6238..4f8bb20 100644 --- a/packages/mnswpr/core/minesweeper/rules.js +++ b/packages/mnswpr/core/minesweeper/rules.js @@ -15,6 +15,8 @@ import { floodReveal, countFlagsAround, allMines } from './reveal.js' * @typedef {{ seed: number, config: Config, grid: Grid, phase: Phase, minesPlaced: boolean, revealedSafe: number }} State * @typedef {{ type: 'reveal', r: number, c: number } | { type: 'flag', r: number, c: number } | { type: 'chord', r: number, c: number }} Move * @typedef {object} Event + * @typedef {{ seed: number, config: Config, phase: Phase, minesPlaced: boolean, revealedSafe: number, grid: { rows: number, cols: number, cells: Cell[] } }} Snapshot + * @typedef {{ r: number, c: number, status: 'revealed', adjacent: number, mine: boolean } | { r: number, c: number, status: 'flagged' } | { r: number, c: number, status: 'hidden', mine: true }} ProjectedCell */ /** @@ -171,9 +173,11 @@ function chord(state, r, c) { * (invariant #3). Hidden, unrevealed, non-mine cells are simply omitted. * * @param {State} state + * @returns {{ config: Config, phase: Phase, cells: ProjectedCell[] }} */ function project(state) { const terminal = state.phase === 'won' || state.phase === 'lost' + /** @type {ProjectedCell[]} */ const cells = [] state.grid.forEach((cell, r, c) => { if (cell.status === 'revealed') cells.push({ r, c, status: 'revealed', adjacent: cell.adjacent, mine: cell.mine }) @@ -291,7 +295,7 @@ export const MinesweeperRules = { * already plain data. * * @param {State} state - * @returns {{ seed: number, config: Config, phase: Phase, minesPlaced: boolean, revealedSafe: number, grid: { rows: number, cols: number, cells: Cell[] } }} + * @returns {Snapshot} */ serialize(state) { return { @@ -308,7 +312,7 @@ export const MinesweeperRules = { * Rebuild a game state from {@link serialize} output (or its JSON round-trip). * Cells are cloned so the revived state shares no references with the snapshot. * - * @param {ReturnType} snap + * @param {Snapshot} snap * @returns {State} */ deserialize(snap) { diff --git a/packages/mnswpr/package.json b/packages/mnswpr/package.json index 658e551..f06600b 100644 --- a/packages/mnswpr/package.json +++ b/packages/mnswpr/package.json @@ -1,6 +1,6 @@ { "name": "@cozy-games/mnswpr", - "version": "0.4.36", + "version": "0.5.0", "description": "Classic Minesweeper browser game", "author": "Ayo", "type": "module", diff --git a/packages/mnswpr/test/replay-progress.test.js b/packages/mnswpr/test/replay-progress.test.js index 9347ffb..f0be5c0 100644 --- a/packages/mnswpr/test/replay-progress.test.js +++ b/packages/mnswpr/test/replay-progress.test.js @@ -9,7 +9,7 @@ function record(layout, moves) { const events = [] session.onMove(e => events.push(e)) for (const m of moves) session.applyMove(m) - return { events: events.map(event => ({ seq: event.seq, t: event.t, event })), session } + return { events: events.map(event => ({ seq: event.seq, clientTs: event.t, type: event.type, payload: { r: event.r, c: event.c } })), session } } // 3x3, single mine at (0,0); adjacency computed. Total safe = 8. @@ -33,7 +33,7 @@ describe('mnswpr progress reducer (percent-cleared)', () => { const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout) }) const events = [] - session.onMove(e => events.push({ seq: e.seq, t: e.t, event: e })) + session.onMove(e => events.push({ seq: e.seq, clientTs: e.t, type: e.type, payload: { r: e.r, c: e.c } })) for (let r = 0; r < 9; r++) { for (let c = 0; c < 9; c++) { if (!session.state.grid.at(r, c).mine) session.applyMove({ type: 'reveal', r, c }) @@ -72,7 +72,7 @@ describe('mnswpr progress reducer (percent-cleared)', () => { { type: 'flag', r: 0, c: 0 }, // flag the mine { type: 'flag', r: 0, c: 0 } // unflag it ]) - expect(events.map(e => e.event.type)).toEqual(['reveal', 'flag', 'unflag']) + expect(events.map(e => e.type)).toEqual(['reveal', 'flag', 'unflag']) const afterReveal = progress(events.slice(0, 1)) expect(afterReveal).toBeCloseTo(12.5, 5) // 1 / 8 @@ -90,7 +90,7 @@ describe('mnswpr progress reducer (percent-cleared)', () => { { type: 'flag', r: 0, c: 0 }, { type: 'chord', r: 0, c: 1 } ]) - expect(events.map(e => e.event.type)).toEqual(['reveal', 'flag', 'chord']) + expect(events.map(e => e.type)).toEqual(['reveal', 'flag', 'chord']) expect(progress(events.slice(0, 1))).toBeCloseTo(12.5, 5) // 1/8 after reveal expect(progress(events.slice(0, 2))).toBeCloseTo(12.5, 5) // flag doesn't advance diff --git a/packages/mnswpr/test/replay-state.test.js b/packages/mnswpr/test/replay-state.test.js index a62f77d..267191f 100644 --- a/packages/mnswpr/test/replay-state.test.js +++ b/packages/mnswpr/test/replay-state.test.js @@ -8,7 +8,7 @@ function record(layout, moves) { const events = [] session.onMove(e => events.push(e)) for (const m of moves) session.applyMove(m) - return { events: events.map(event => ({ seq: event.seq, t: event.t, event })), session } + return { events: events.map(event => ({ seq: event.seq, clientTs: event.t, type: event.type, payload: { r: event.r, c: event.c } })), session } } // 3x3, single mine at (0,0). Total safe = 8. diff --git a/packages/move-log/CHANGELOG.md b/packages/move-log/CHANGELOG.md new file mode 100644 index 0000000..2777425 --- /dev/null +++ b/packages/move-log/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +## 0.1.0 — envelope realigned to ADR-002 §1 (BREAKING, pre-freeze) + +**Intentional breaking change.** Safe now because no production logs have been +recorded yet (the downstream consumer, cozy-platform, ships dark pre-cutover). +Once real games write logs the format is permanent, so this had to land first. + +The envelope now matches cozy-games **ADR-002 §1** and the cozy-platform +implementation that proved it (S0-102 round-trip replay) — **while keeping the +generics** that let a second game reuse the package untouched: + +- **Entry shape:** `{ seq, t, event: T }` → `{ seq, clientTs, type, payload, receivedTs? }`. + The game event splits into the ADR's surfaced `type` **discriminator** plus an + OPAQUE `payload`. (`t` → `clientTs`.) `receivedTs?` is unchanged (optional, additive). +- **Generics kept & strengthened:** `MoveEvent` / `MoveLog` → + `MoveEvent` / + `MoveLog`. Minesweeper + instantiates `MoveLog<'reveal'|'flag'|'unflag'|'chord', { r, c }>`; a second game + supplies its own params with no package change. The defaults give game-blind + code the erased (`string` / `unknown`) form. Shipped as generic `.d.ts`. +- **`schema_version`:** package-owned number `1` → a **caller-supplied string** + naming the game's move-event vocabulary (e.g. `"mnswpr-moves/1"`), carried + verbatim (ADR §2). The `SCHEMA_VERSION` export and the numeric container-version + concept are removed. +- **`createMoveLog(schemaVersion, events?)`** now takes the vocabulary version as + its first argument. +- **`assertMoveLog`** enforces the new invariants (non-empty string + `schema_version`; integer strictly-increasing `seq`; finite `clientTs`; + non-empty string `type`; **present** `payload` — any value, never inspected; + finite `receivedTs` when present) and still rejects without mutating. + +Helpers (`isMoveLog`, `serializeMoveLog`, `deserializeMoveLog`, `withReceivedTs`) +are kept and adapted; the JSON round-trip stays lossless. The package remains +game-agnostic (imports no game types, never inspects `payload`, treats `type` as +an opaque non-empty string). + +## 0.0.1 + +Initial workspace release: `{ seq, t, event }` entries with a numeric, +package-owned `schema_version: 1`. diff --git a/packages/move-log/README.md b/packages/move-log/README.md index 183b7f6..46a4da1 100644 --- a/packages/move-log/README.md +++ b/packages/move-log/README.md @@ -1,62 +1,112 @@ # @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. +A **game-agnostic** container for a recorded run of move events — the generic +envelope of cozy-games **ADR-002 §1**. It wraps any game's move stream in a +schema-versioned, ordered log of `{ seq, clientTs, type, payload }` entries and +never looks inside a `payload`. ```js import { createMoveLog, withReceivedTs, - serializeMoveLog, deserializeMoveLog, isMoveLog, SCHEMA_VERSION + serializeMoveLog, deserializeMoveLog, isMoveLog, assertMoveLog } from '@cozy-games/move-log' -// `T` is your game's own event vocabulary — supplied by you, unknown to us. -const log = createMoveLog([ - { seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } }, - { seq: 2, t: 50, event: { type: 'flag', r: 1, c: 2 } } +// The first argument is YOUR game's move-event vocabulary version — a string, +// carried verbatim. `type` names a move in that vocabulary; `payload` is your +// game's opaque move data (never inspected here). +const log = createMoveLog('mnswpr-moves/1', [ + { seq: 1, clientTs: 0, type: 'reveal', payload: { r: 0, c: 0 } }, + { seq: 2, clientTs: 50, type: 'flag', payload: { r: 1, c: 2 } } ]) -// → { schema_version: 1, events: [ { seq, t, event }, ... ] } +// → { schema_version: 'mnswpr-moves/1', events: [ { seq, clientTs, type, payload }, ... ] } const json = serializeMoveLog(log) // → JSON string 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 +// → each event now also carries `receivedTs`; still a valid log ``` -## Shape +## Shape — generic over the game -| field | type | meaning | -| ---------------- | ----------------- | -------------------------------------------------- | -| `schema_version` | `1` | the move-log container version | -| `events` | `MoveEvent[]` | ordered, each `{ seq, t, event, receivedTs? }` | +```ts +// Parameterized over the game's move-event discriminator and payload, with +// defaults so game-blind code can use the erased (string / unknown) form. +interface MoveEvent { + seq: number // integer, STRICTLY INCREASING (starts at 1, survives resume) — log metadata + clientTs: number // finite ms timestamp (client clock) — log metadata + type: TType // the game's move-event discriminator (ADR §1: `type: T`) + payload: TPayload // game-specific move data — OPAQUE to the package (never inspected) + receivedTs?: number // OPTIONAL, additive: consumer-side receipt time +} -`MoveEvent = { seq: number, t: number, event: T, receivedTs?: number }` — the -log owns the per-event recording metadata (a strictly increasing `seq`, a -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. +interface MoveLog { + schema_version: string // the GAME's move-event vocabulary version, verbatim + events: MoveEvent[] +} +``` -`deserializeMoveLog` round-trips a serialized log with full fidelity (order, -timestamps, sequence numbers, and any `receivedTs`) 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. +The move splits into a surfaced `type` **discriminator** plus an **opaque** +`payload` — exactly ADR-002 §1 — so game-agnostic tooling (progress overlays, +analytics, log inspection) can key off the event *kind* without a game adapter, +while the package never looks inside `payload` and treats `type` only as an +opaque non-empty string. The log owns the recording metadata (`seq`, `clientTs`, +and an optional `receivedTs`); the game owns `type` + `payload`. -## Versioning: `receivedTs` is additive within `schema_version: 1` +**Genericness is the point:** a second game reuses this package with **zero +changes** — it just instantiates its own vocabulary. Minesweeper uses +`MoveLog<'reveal' | 'flag' | 'unflag' | 'chord', { r: number; c: number }>`; the +defaults give persistence/routing code a usable `MoveLog` with `type: string` + +opaque `payload`. -`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. +```js +/** @type {import('@cozy-games/move-log').MoveLog<'a' | 'b', { x: number }>} */ +const log = createMoveLog('made-up-game/1', [ + { seq: 1, clientTs: 0, type: 'a', payload: { x: 1 } } +]) +``` + +`.d.ts` declarations ship with the package (generated from the JSDoc), so +consumers get the exact generic `MoveEvent` / `MoveLog` types with no ambient +stand-in. + +## `schema_version` is the game's vocabulary version — a string, verbatim + +Per ADR-002 §2, *"the event vocabulary and log `schema_version` live in the +game's package."* The version identifies the **game's move-event vocabulary** +(e.g. `"mnswpr-moves/1"`), supplied by the caller and carried **verbatim** so a +reader of a forever-stored log always knows how to replay it. This package owns +no version of its own and never rewrites the one you pass. `receivedTs` remains +optional and additive — a log is valid whether every event, some events, or no +events carry it, and a reader that doesn't know the field simply ignores it. + +## API + +- `createMoveLog(schemaVersion, events?)` — stamp `schema_version` with the + supplied string verbatim, copy the events (no aliasing), validate, return the log. +- `assertMoveLog(value)` — throw a distinct, field-specific error on any + violation; return the log otherwise. **Reject, never repair** (no mutation, + truncation, or coercion). +- `isMoveLog(value)` — non-throwing wrapper around `assertMoveLog`. +- `serializeMoveLog(log)` / `deserializeMoveLog(json)` — lossless JSON round-trip + (order, `seq`, `clientTs`, `type`, `payload`, `receivedTs`); rejects malformed + input, never returning a partially-parsed log. +- `withReceivedTs(log, stamp)` — return a new log with a received-side timestamp + attached to each event for which `stamp` returns a finite number; input untouched. + +### Invariants enforced by `assertMoveLog` + +`schema_version` is a non-empty string; `events` is an array; every entry is a +plain object with an **integer, strictly-increasing** `seq`, a **finite** +`clientTs`, a **non-empty string** `type`, and a **plain-object** `payload` (not +an array or `null`); `receivedTs` is finite when present. ## 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. +vocabulary. `type`/`payload` are 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.d.ts b/packages/move-log/index.d.ts index 7c0e8d9..6ad3817 100644 --- a/packages/move-log/index.d.ts +++ b/packages/move-log/index.d.ts @@ -1,23 +1,27 @@ /** - * 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. + * Assert a value is a well-formed move log — a non-empty string `schema_version` + * and a valid events array — throwing a clear, specific error otherwise. Returns + * the value (typed as the erased, game-blind form) for chaining; never mutates. * * @param {unknown} value - * @returns {MoveLog} + * @returns {MoveLog} */ -export function assertMoveLog(value: unknown): MoveLog; +export function assertMoveLog(value: unknown): MoveLog; /** - * Build a move log from an ordered list of `{ seq, t, event }` records. Pure and + * Build a move log for a game's move-event vocabulary. `schemaVersion` names that + * vocabulary (e.g. `"mnswpr-moves/1"`) and is stored verbatim. 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. + * strictly increasing `seq`), never the shape of a `payload`. Order is preserved + * and entries are copied, so the log never aliases the caller's array. Generic + * over the game — infers `TType`/`TPayload` from `events`. * - * @template T - * @param {MoveEvent[]} [events] - ordered events, each `{ seq, t, event, receivedTs? }` - * @returns {MoveLog} + * @template {string} [TType=string] + * @template [TPayload=unknown] + * @param {string} schemaVersion - the game's move-event vocabulary version, stored verbatim + * @param {MoveEvent[]} [events] - ordered entries, each `{ seq, clientTs, type, payload, receivedTs? }` + * @returns {MoveLog} */ -export function createMoveLog(events?: MoveEvent[]): MoveLog; +export function createMoveLog(schemaVersion: string, events?: MoveEvent[]): MoveLog; /** * 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 @@ -25,15 +29,16 @@ export function createMoveLog(events?: MoveEvent[]): MoveLog; * 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} log - * @param {(event: MoveEvent, index: number) => number | undefined} stamp - * @returns {MoveLog} + * @template {string} TType + * @template TPayload + * @param {MoveLog} log + * @param {(event: MoveEvent, index: number) => number | undefined} stamp + * @returns {MoveLog} */ -export function withReceivedTs(log: MoveLog, stamp: (event: MoveEvent, index: number) => number | undefined): MoveLog; +export function withReceivedTs(log: MoveLog, stamp: (event: MoveEvent, index: number) => number | undefined): MoveLog; /** - * 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`. + * Non-throwing type guard: is `value` a well-formed move log? Checks the + * container invariants only — remains blind to each `payload`. * * @param {unknown} value * @returns {boolean} @@ -43,81 +48,50 @@ export function isMoveLog(value: unknown): boolean; * 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} log + * @template {string} TType + * @template TPayload + * @param {MoveLog} log * @returns {string} */ -export function serializeMoveLog(log: MoveLog): string; +export function serializeMoveLog(log: MoveLog): string; /** * 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}. + * order, `seq`, `clientTs`, `type`, `payload`, and any `receivedTs` 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} + * @returns {MoveLog} */ -export function deserializeMoveLog(json: string): MoveLog; +export function deserializeMoveLog(json: string): MoveLog; /** - * `@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. + * A single recorded move event — the log-owned recording metadata plus the + * game's surfaced `type` + opaque `payload`: * - * 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 recording metadata (`seq` + `t`, and an optional received-side - * `receivedTs`) so `T` can stay a pure game payload with no required shape; the - * module never inspects the inside of an `event`. + * - `seq` — integer, STRICTLY INCREASING across the log (starts at 1, survives resume). + * - `clientTs` — finite millisecond timestamp from the client clock. + * - `type` — the game's move-event discriminator (ADR §1 `type: T`); a non-empty string. + * - `payload` — game-specific move data; OPAQUE to the package (never inspected). + * - `receivedTs` — OPTIONAL, additive: a consumer-side receipt time (finite ms). * - * Extraction to a standalone published package comes later; for now it lives as - * a shared workspace module alongside `packages/utils`. + * Generic over the game's discriminator `TType` and payload `TPayload`, with + * defaults so game-blind code can use the erased form. */ -/** - * The move-log schema version. - * - * 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 - */ -export const SCHEMA_VERSION: SchemaVersion; -/** - * A single recorded event: the log-owned recording metadata — a strictly - * increasing sequence number `seq`, a source-side timestamp `t` (milliseconds), - * 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. - */ -export type MoveEvent = { +export type MoveEvent = { seq: number; - t: number; - event: T; + clientTs: number; + type: TType; + payload: TPayload; receivedTs?: number; }; /** - * 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. + * The container: a schema-versioned, ordered array of recorded entries for one + * run. `schema_version` is the game's move-event vocabulary version, verbatim. + * Generic over the game (same parameters as {@link MoveEvent}); JSON-safe as long + * as every `payload` is. */ -export type MoveLog = { - schema_version: SchemaVersion; - events: MoveEvent[]; +export type MoveLog = { + schema_version: string; + events: MoveEvent[]; }; -/** - * The move-log schema version. - * - * 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. - */ -export type SchemaVersion = 1; diff --git a/packages/move-log/index.js b/packages/move-log/index.js index 7c0bf6c..f438e07 100644 --- a/packages/move-log/index.js +++ b/packages/move-log/index.js @@ -2,60 +2,78 @@ /** * `@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. + * events, implementing the generic envelope of cozy-games ADR-002 §1. * - * 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 recording metadata (`seq` + `t`, and an optional received-side - * `receivedTs`) so `T` can stay a pure game payload with no required shape; the - * module never inspects the inside of an `event`. + * Each recorded entry is `{ seq, clientTs, type, payload }` (+ an optional, + * additive `receivedTs`): the log-owned recording metadata plus a surfaced move + * `type` discriminator (a string naming a member of the consuming game's + * move-event vocabulary) and an OPAQUE `payload` carrying that game's move data. * - * Extraction to a standalone published package comes later; for now it lives as - * a shared workspace module alongside `packages/utils`. + * The container is **generic over the game** — `MoveEvent` and + * `MoveLog` are parameterized so a second game reuses this + * package with ZERO changes: Minesweeper instantiates + * `MoveLog<'reveal'|'flag'|'unflag'|'chord', { r: number, c: number }>`; another + * game supplies its own `TType`/`TPayload`. The defaults (`string`, `unknown`) + * give game-blind persistence/routing/inspection code a usable erased form. At + * runtime the package imports NO game types, never inspects the inside of a + * `payload`, and treats `type` only as an opaque non-empty string — independence + * enforced by a dependency-graph guard in the tests. + * + * The container carries a `schema_version`: a caller-supplied STRING identifying + * the game's frozen move-event vocabulary (e.g. `"mnswpr-moves/1"`), stored + * verbatim so a reader of a forever-stored log always knows how to replay it (ADR + * §2). The package owns no version of its own. */ /** - * The move-log schema version. + * A single recorded move event — the log-owned recording metadata plus the + * game's surfaced `type` + opaque `payload`: * - * 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. + * - `seq` — integer, STRICTLY INCREASING across the log (starts at 1, survives resume). + * - `clientTs` — finite millisecond timestamp from the client clock. + * - `type` — the game's move-event discriminator (ADR §1 `type: T`); a non-empty string. + * - `payload` — game-specific move data; OPAQUE to the package (never inspected). + * - `receivedTs` — OPTIONAL, additive: a consumer-side receipt time (finite ms). * - * @typedef {1} SchemaVersion - */ -export const SCHEMA_VERSION = /** @type {SchemaVersion} */ (1) - -/** - * A single recorded event: the log-owned recording metadata — a strictly - * increasing sequence number `seq`, a source-side timestamp `t` (milliseconds), - * 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. + * Generic over the game's discriminator `TType` and payload `TPayload`, with + * defaults so game-blind code can use the erased form. * - * @template T - * @typedef {{ seq: number, t: number, event: T, receivedTs?: number }} MoveEvent + * @template {string} [TType=string] + * @template [TPayload=unknown] + * @typedef {{ seq: number, clientTs: number, type: TType, payload: TPayload, receivedTs?: number }} MoveEvent */ /** - * 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. + * The container: a schema-versioned, ordered array of recorded entries for one + * run. `schema_version` is the game's move-event vocabulary version, verbatim. + * Generic over the game (same parameters as {@link MoveEvent}); JSON-safe as long + * as every `payload` is. * - * @template T - * @typedef {{ schema_version: SchemaVersion, events: MoveEvent[] }} MoveLog + * @template {string} [TType=string] + * @template [TPayload=unknown] + * @typedef {{ schema_version: string, events: MoveEvent[] }} MoveLog */ /** - * 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. + * Assert a schema version is a non-empty string — the game's move-event + * vocabulary version, carried verbatim (ADR §2). Throws otherwise. + * + * @param {unknown} version + */ +function assertSchemaVersion(version) { + if (typeof version !== 'string' || version.length === 0) { + throw new TypeError(`move-log: schema_version must be a non-empty string (got ${JSON.stringify(version)})`) + } +} + +/** + * Validate an events array: each entry must be a `{ seq, clientTs, type, payload }` + * object with an integer `seq`, a finite `clientTs`, a non-empty string `type`, + * and a PRESENT `payload` (any value — never inspected); `seq` must be STRICTLY + * INCREASING; a present `receivedTs` must be finite. Throws a distinct, + * field-specific error on the first problem — never leaves a caller with a + * half-checked array. Reject, never repair: nothing is mutated, truncated, or + * coerced. The inside of `payload` is never inspected (game-blind). * * @param {unknown} events */ @@ -65,14 +83,8 @@ function assertEvents(events) { } 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})`) - } - 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 (e === null || typeof e !== 'object' || Array.isArray(e)) { + throw new TypeError(`move-log: events[${i}] must be an object (got ${e === null ? 'null' : Array.isArray(e) ? 'array' : typeof e})`) } if (!Number.isInteger(e.seq)) { throw new TypeError(`move-log: events[${i}].seq must be an integer (got ${JSON.stringify(e.seq)})`) @@ -80,6 +92,16 @@ function assertEvents(events) { if (e.seq <= prevSeq) { throw new RangeError(`move-log: events[${i}].seq must be strictly increasing (got ${e.seq} after ${prevSeq})`) } + if (typeof e.clientTs !== 'number' || !Number.isFinite(e.clientTs)) { + throw new TypeError(`move-log: events[${i}].clientTs must be a finite number (got ${JSON.stringify(e.clientTs)})`) + } + if (typeof e.type !== 'string' || e.type.length === 0) { + throw new TypeError(`move-log: events[${i}].type must be a non-empty string (got ${JSON.stringify(e.type)})`) + } + // `payload` must be PRESENT but is otherwise opaque — any value, never inspected. + if (!('payload' in e) || e.payload === undefined) { + throw new TypeError(`move-log: events[${i}].payload must be present`) + } 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)})`) } @@ -88,55 +110,60 @@ function assertEvents(events) { } /** - * 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). + * Copy one entry 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). `payload` is copied by reference: it's the + * game's opaque data and the log never clones or inspects it. * - * @template T - * @param {MoveEvent} e - * @returns {MoveEvent} + * @template {string} TType + * @template TPayload + * @param {MoveEvent} e + * @returns {MoveEvent} */ function copyEvent(e) { - /** @type {MoveEvent} */ - const out = { seq: e.seq, t: e.t, event: e.event } + /** @type {MoveEvent} */ + const out = { seq: e.seq, clientTs: e.clientTs, type: e.type, payload: e.payload } 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 - * events array — throwing a clear, specific error otherwise. Returns the value - * (typed) for chaining; never mutates. + * Assert a value is a well-formed move log — a non-empty string `schema_version` + * and a valid events array — throwing a clear, specific error otherwise. Returns + * the value (typed as the erased, game-blind form) for chaining; never mutates. * * @param {unknown} value - * @returns {MoveLog} + * @returns {MoveLog} */ 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})`) - } + assertSchemaVersion(v.schema_version) assertEvents(v.events) return v } /** - * Build a move log from an ordered list of `{ seq, t, event }` records. Pure and + * Build a move log for a game's move-event vocabulary. `schemaVersion` names that + * vocabulary (e.g. `"mnswpr-moves/1"`) and is stored verbatim. 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. + * strictly increasing `seq`), never the shape of a `payload`. Order is preserved + * and entries are copied, so the log never aliases the caller's array. Generic + * over the game — infers `TType`/`TPayload` from `events`. * - * @template T - * @param {MoveEvent[]} [events] - ordered events, each `{ seq, t, event, receivedTs? }` - * @returns {MoveLog} + * @template {string} [TType=string] + * @template [TPayload=unknown] + * @param {string} schemaVersion - the game's move-event vocabulary version, stored verbatim + * @param {MoveEvent[]} [events] - ordered entries, each `{ seq, clientTs, type, payload, receivedTs? }` + * @returns {MoveLog} */ -export function createMoveLog(events = []) { +export function createMoveLog(schemaVersion, events = []) { + assertSchemaVersion(schemaVersion) assertEvents(events) return { - schema_version: SCHEMA_VERSION, + schema_version: schemaVersion, events: events.map(copyEvent) } } @@ -148,10 +175,11 @@ export function createMoveLog(events = []) { * 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} log - * @param {(event: MoveEvent, index: number) => number | undefined} stamp - * @returns {MoveLog} + * @template {string} TType + * @template TPayload + * @param {MoveLog} log + * @param {(event: MoveEvent, index: number) => number | undefined} stamp + * @returns {MoveLog} */ export function withReceivedTs(log, stamp) { assertMoveLog(log) @@ -167,8 +195,8 @@ export function withReceivedTs(log, stamp) { } /** - * 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`. + * Non-throwing type guard: is `value` a well-formed move log? Checks the + * container invariants only — remains blind to each `payload`. * * @param {unknown} value * @returns {boolean} @@ -186,8 +214,9 @@ export function isMoveLog(value) { * 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} log + * @template {string} TType + * @template TPayload + * @param {MoveLog} log * @returns {string} */ export function serializeMoveLog(log) { @@ -197,13 +226,13 @@ export function serializeMoveLog(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}. + * order, `seq`, `clientTs`, `type`, `payload`, and any `receivedTs` 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} + * @returns {MoveLog} */ export function deserializeMoveLog(json) { if (typeof json !== 'string') { diff --git a/packages/move-log/package.json b/packages/move-log/package.json index fe3479f..e15ba28 100644 --- a/packages/move-log/package.json +++ b/packages/move-log/package.json @@ -1,9 +1,8 @@ { "name": "@cozy-games/move-log", - "version": "0.0.1", + "version": "0.1.0", "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", diff --git a/packages/move-log/test/move-log.test.js b/packages/move-log/test/move-log.test.js index f81a60d..8333d36 100644 --- a/packages/move-log/test/move-log.test.js +++ b/packages/move-log/test/move-log.test.js @@ -8,84 +8,152 @@ import { dirname, join } from 'node:path' // module resolves and is importable by other packages. import { createMoveLog, withReceivedTs, - serializeMoveLog, deserializeMoveLog, isMoveLog, assertMoveLog, SCHEMA_VERSION + serializeMoveLog, deserializeMoveLog, isMoveLog, assertMoveLog } 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. - * The move log must type-check and work against any `T` the consumer supplies. - * @typedef {{ kind: 'tick' } | { kind: 'boom', power: number }} DummyEvent - */ +// A dummy vocabulary version string, defined HERE, in the test — deliberately not +// tied to the module. The move log carries whatever version string the consumer +// supplies, verbatim, and validates only its own envelope invariants. +const VERSION = 'dummy-game/1' describe('@cozy-games/move-log', () => { - /** @type {import('@cozy-games/move-log').MoveEvent[]} */ + /** @type {import('@cozy-games/move-log').MoveEvent[]} */ const events = [ - { 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 + { seq: 1, clientTs: 0, type: 'tick', payload: {} }, + { seq: 2, clientTs: 50, type: 'boom', payload: { power: 3 } }, + { seq: 5, clientTs: 120, type: 'tick', payload: {} } // gaps allowed; strictly increasing ] - it('exposes schema_version typed as 1', () => { - expect(SCHEMA_VERSION).toBe(1) + it('carries the caller-supplied schema_version string verbatim', () => { + const log = createMoveLog('mnswpr-moves/1', []) + expect(log.schema_version).toBe('mnswpr-moves/1') + // survives create → serialize → deserialize unchanged + const restored = deserializeMoveLog(serializeMoveLog(createMoveLog('mnswpr-moves/1', events))) + expect(restored.schema_version).toBe('mnswpr-moves/1') }) it('wraps an ordered, timestamped, sequenced stream for a dummy vocabulary', () => { - /** @type {import('@cozy-games/move-log').MoveLog} */ - const log = createMoveLog(events) + /** @type {import('@cozy-games/move-log').MoveLog} */ + const log = createMoveLog(VERSION, events) - expect(log.schema_version).toBe(1) + expect(log.schema_version).toBe(VERSION) expect(log.events).toHaveLength(3) 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({ seq: 2, t: 50, event: { kind: 'boom', power: 3 } }) + expect(log.events.map(e => e.clientTs)).toEqual([0, 50, 120]) + expect(log.events[1]).toEqual({ seq: 2, clientTs: 50, type: 'boom', payload: { 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 = [{ 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 + expect(createMoveLog(VERSION)).toEqual({ schema_version: VERSION, events: [] }) + const input = [{ seq: 1, clientTs: 1, type: 'tick', payload: {} }] + const log = createMoveLog(VERSION, input) + input[0].clientTs = 999 + expect(log.events[0].clientTs).toBe(1) // log kept its own copy + }) + + it('rejects a non-string / empty schema_version', () => { + // @ts-expect-error — deliberately wrong type + expect(() => createMoveLog(1, [])).toThrow(TypeError) + expect(() => createMoveLog('', [])).toThrow(TypeError) + // @ts-expect-error — deliberately missing + expect(() => createMoveLog(undefined, [])).toThrow(TypeError) }) it('rejects a non-monotonic seq at construction', () => { - expect(() => createMoveLog([ - { seq: 2, t: 0, event: {} }, - { seq: 1, t: 1, event: {} } + expect(() => createMoveLog(VERSION, [ + { seq: 2, clientTs: 0, type: 'a', payload: {} }, + { seq: 1, clientTs: 1, type: 'b', payload: {} } ])).toThrow(RangeError) }) }) +describe('generic over the game — a second, made-up vocabulary needs zero package changes', () => { + // A non-Minesweeper vocabulary defined ENTIRELY here: `TType = 'a' | 'b'`, a + // payload shape of `{ x: number }`. The package type-checks and validates it with + // no change — mirror of the consumer's "adding a game_type needs no generic change". + it('builds and validates MoveLog<"a" | "b", { x: number }>', () => { + /** @type {import('@cozy-games/move-log').MoveLog<'a' | 'b', { x: number }>} */ + const log = createMoveLog('made-up-game/1', [ + { seq: 1, clientTs: 0, type: 'a', payload: { x: 1 } }, + { seq: 2, clientTs: 10, type: 'b', payload: { x: 2 } } + ]) + expect(isMoveLog(log)).toBe(true) + expect(log.schema_version).toBe('made-up-game/1') + expect(log.events.map(e => e.type)).toEqual(['a', 'b']) + expect(log.events.map(e => e.payload.x)).toEqual([1, 2]) + // round-trips losslessly like any other log + expect(deserializeMoveLog(serializeMoveLog(log))).toEqual(log) + }) + + it('Minesweeper instantiates its own discriminator + payload with the same package', () => { + /** @typedef {'reveal' | 'flag' | 'unflag' | 'chord'} MnswprType */ + /** @type {import('@cozy-games/move-log').MoveLog} */ + const log = createMoveLog('mnswpr-moves/1', [ + { seq: 1, clientTs: 0, type: 'reveal', payload: { r: 0, c: 0 } }, + { seq: 2, clientTs: 5, type: 'flag', payload: { r: 1, c: 2 } } + ]) + expect(log.events.map(e => e.type)).toEqual(['reveal', 'flag']) + expect(log.events[0].payload).toEqual({ r: 0, c: 0 }) + }) +}) + +describe('payload is opaque — any present value is accepted, never inspected', () => { + it('accepts object, array, primitive, and null payloads', () => { + const log = createMoveLog(VERSION, [ + { seq: 1, clientTs: 0, type: 'obj', payload: { a: 1 } }, + { seq: 2, clientTs: 1, type: 'arr', payload: [1, 2, 3] }, + { seq: 3, clientTs: 2, type: 'str', payload: 'hello' }, + { seq: 4, clientTs: 3, type: 'num', payload: 42 }, + { seq: 5, clientTs: 4, type: 'nul', payload: null } + ]) + expect(isMoveLog(log)).toBe(true) + expect(deserializeMoveLog(serializeMoveLog(log))).toEqual(log) + }) + + it('rejects a MISSING payload (the only payload invariant)', () => { + expect(() => createMoveLog(VERSION, [ + // @ts-expect-error — deliberately missing payload + { seq: 1, clientTs: 0, type: 'x' } + ])).toThrow(TypeError) + expect(() => deserializeMoveLog(JSON.stringify({ + schema_version: VERSION, events: [{ seq: 1, clientTs: 0, type: 'x' }] + }))).toThrow(TypeError) + }) +}) + 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 } } + { seq: 1, clientTs: 0, type: 'reveal', payload: { r: 0, c: 0 } }, + { seq: 2, clientTs: 50, type: 'flag', payload: { r: 1, c: 2 } }, + { seq: 3, clientTs: 90, type: 'chord', payload: { r: 4, c: 4 } } ] - it('preserves order, timestamps, and sequence numbers exactly', () => { - const log = createMoveLog(events) + it('preserves order, timestamps, types, payloads, and sequence numbers exactly', () => { + const log = createMoveLog(VERSION, events) const restored = deserializeMoveLog(serializeMoveLog(log)) expect(restored).toEqual(log) // full structural fidelity + expect(restored.schema_version).toBe(VERSION) 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']) + expect(restored.events.map(e => e.clientTs)).toEqual([0, 50, 90]) + expect(restored.events.map(e => e.type)).toEqual(['reveal', 'flag', 'chord']) + expect(restored.events.map(e => e.payload)).toEqual([{ r: 0, c: 0 }, { r: 1, c: 2 }, { r: 4, c: 4 }]) }) it('serializeMoveLog produces a JSON string parseable back to the same object', () => { - const log = createMoveLog(events) + const log = createMoveLog(VERSION, 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)) + const valid = serializeMoveLog(createMoveLog(VERSION, events)) + const entry = { seq: 1, clientTs: 0, type: 'reveal', payload: {} } // not a string // @ts-expect-error — deliberately wrong type @@ -93,21 +161,26 @@ describe('serialization round-trip', () => { // 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) + expect(() => deserializeMoveLog(JSON.stringify({ events: [] }))).toThrow(TypeError) + // non-string schema_version + expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [] }))).toThrow(TypeError) + // empty-string schema_version + expect(() => deserializeMoveLog(JSON.stringify({ schema_version: '', events: [] }))).toThrow(TypeError) // 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) + expect(() => deserializeMoveLog(JSON.stringify({ schema_version: VERSION, events: 'nope' }))).toThrow(TypeError) + // non-integer seq + expect(() => deserializeMoveLog(JSON.stringify({ schema_version: VERSION, events: [{ ...entry, seq: 1.5 }] }))).toThrow(TypeError) + // non-finite clientTs + expect(() => deserializeMoveLog(JSON.stringify({ schema_version: VERSION, events: [{ ...entry, clientTs: 'soon' }] }))).toThrow(TypeError) + // empty / non-string type + expect(() => deserializeMoveLog(JSON.stringify({ schema_version: VERSION, events: [{ ...entry, type: '' }] }))).toThrow(TypeError) + expect(() => deserializeMoveLog(JSON.stringify({ schema_version: VERSION, events: [{ ...entry, type: 7 }] }))).toThrow(TypeError) + // missing payload + expect(() => deserializeMoveLog(JSON.stringify({ schema_version: VERSION, events: [{ seq: 1, clientTs: 0, type: 'reveal' }] }))).toThrow(TypeError) // shuffled / non-monotonic seq const shuffled = JSON.stringify({ - schema_version: 1, - events: [{ seq: 3, t: 0, event: {} }, { seq: 1, t: 1, event: {} }] + schema_version: VERSION, + events: [{ seq: 3, clientTs: 0, type: 'a', payload: {} }, { seq: 1, clientTs: 1, type: 'b', payload: {} }] }) expect(() => deserializeMoveLog(shuffled)).toThrow(RangeError) @@ -115,7 +188,8 @@ describe('serialization round-trip', () => { 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(JSON.stringify({ schema_version: VERSION, events: [{ ...entry, type: '' }] }))), + captureMessage(() => deserializeMoveLog(JSON.stringify({ schema_version: VERSION, events: [{ seq: 1, clientTs: 0, type: 'reveal' }] }))), captureMessage(() => deserializeMoveLog(shuffled)) ] expect(new Set(messages).size).toBe(messages.length) @@ -126,8 +200,8 @@ describe('serialization round-trip', () => { 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: {} }] + schema_version: VERSION, + events: [{ seq: 1, clientTs: 0, type: 'ok', payload: { ok: true } }, { seq: 2, clientTs: 'bad', type: 'x', payload: {} }] }) let result = 'sentinel' expect(() => { result = deserializeMoveLog(partlyBad) }).toThrow(TypeError) @@ -135,24 +209,24 @@ describe('serialization round-trip', () => { }) it('isMoveLog / assertMoveLog agree on validity', () => { - const log = createMoveLog(events) + const log = createMoveLog(VERSION, 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) + expect(isMoveLog({ schema_version: VERSION, events: [{ seq: 1, clientTs: 0, type: 'x' }] })).toBe(false) }) }) 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 } } + { seq: 1, clientTs: 0, type: 'reveal', payload: { r: 0, c: 0 } }, + { seq: 2, clientTs: 50, type: 'flag', payload: { 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 } + const log = createMoveLog(VERSION, [ + { seq: 1, clientTs: 0, type: 'a', payload: {}, receivedTs: 1000 }, + { seq: 2, clientTs: 50, type: 'b', payload: {}, receivedTs: 1060 } ]) expect(log.events[0].receivedTs).toBe(1000) const restored = deserializeMoveLog(serializeMoveLog(log)) @@ -161,9 +235,9 @@ describe('received timestamps (additive receivedTs)', () => { }) 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 + const log = createMoveLog(VERSION, [ + { seq: 1, clientTs: 0, type: 'a', payload: {}, receivedTs: 1000 }, + { seq: 2, clientTs: 50, type: 'b', payload: {} } // no receivedTs ]) expect(isMoveLog(log)).toBe(true) expect('receivedTs' in log.events[1]).toBe(false) @@ -171,7 +245,7 @@ describe('received timestamps (additive receivedTs)', () => { }) it('REGRESSION: a log with no receivedTs anywhere stays fully valid and leaks no key', () => { - const log = createMoveLog(base) + const log = createMoveLog(VERSION, base) expect(isMoveLog(log)).toBe(true) expect(log.events.every(e => !('receivedTs' in e))).toBe(true) const restored = deserializeMoveLog(serializeMoveLog(log)) @@ -180,29 +254,29 @@ describe('received timestamps (additive receivedTs)', () => { }) 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(() => createMoveLog(VERSION, [{ seq: 1, clientTs: 0, type: 'x', payload: {}, receivedTs: 'soon' }])).toThrow(TypeError) + expect(() => createMoveLog(VERSION, [{ seq: 1, clientTs: 0, type: 'x', payload: {}, receivedTs: Infinity }])).toThrow(TypeError) expect(() => deserializeMoveLog(JSON.stringify({ - schema_version: 1, - events: [{ seq: 1, t: 0, event: {}, receivedTs: 'later' }] + schema_version: VERSION, + events: [{ seq: 1, clientTs: 0, type: 'x', payload: {}, receivedTs: 'later' }] }))).toThrow(TypeError) }) it('withReceivedTs attaches host-received times additively without mutating the input', () => { - const log = createMoveLog(base) + const log = createMoveLog(VERSION, 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 + // output stamped, still a valid log, round-trips expect(stamped.events.map(e => e.receivedTs)).toEqual([910, 920]) - expect(stamped.schema_version).toBe(1) + expect(stamped.schema_version).toBe(VERSION) expect(deserializeMoveLog(serializeMoveLog(stamped))).toEqual(stamped) }) it('withReceivedTs leaves events unstamped when the stamp returns undefined', () => { - const log = createMoveLog(base) + const log = createMoveLog(VERSION, 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) @@ -210,20 +284,9 @@ describe('received timestamps (additive receivedTs)', () => { }) it('withReceivedTs rejects a stamp that returns a non-finite number', () => { - const log = createMoveLog(base) + const log = createMoveLog(VERSION, 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)', () => { @@ -258,14 +321,19 @@ describe('integration: wraps a real mnswpr event stream (core-06)', () => { 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 }))) + // Wrap the stream: the log-owned metadata (seq, clientTs) is lifted to the log + // level; the surfaced `type` discriminator + opaque game `payload` (the move + // coords) stay put — exactly the ADR §1 split. + const log = createMoveLog('mnswpr-moves/1', emitted.map(e => ({ + seq: e.seq, clientTs: e.t, type: e.type, payload: { r: e.r, c: e.c } + }))) const restored = deserializeMoveLog(serializeMoveLog(log)) expect(restored).toEqual(log) + expect(restored.schema_version).toBe('mnswpr-moves/1') 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']) + expect(restored.events.map(e => e.clientTs)).toEqual([1050, 1100, 1150, 1200, 1250]) + expect(restored.events.map(e => e.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)) diff --git a/packages/replay/index.js b/packages/replay/index.js index a3fce9c..f08ba5f 100644 --- a/packages/replay/index.js +++ b/packages/replay/index.js @@ -1,14 +1,14 @@ // @ts-check -import { assertMoveLog, SCHEMA_VERSION } from '@cozy-games/move-log' +import { assertMoveLog } from '@cozy-games/move-log' /** * `@cozy-games/replay` — the core of a game-agnostic replay engine. * * {@link PlaybackClock} re-drives a move-log envelope over time: it schedules - * each recorded event to fire at its OFFSET (its `t` relative to the first + * each recorded event to fire at its OFFSET (its `clientTs` relative to the first * event) as playback time advances, and supports play / pause / seek. It * consumes ONLY the generic envelope (`@cozy-games/move-log`) and never inspects - * the inside of an `event` — no game types cross this boundary. + * the inside of a `payload` — no game types cross this boundary. * * The clock and scheduler are injected (mirroring the core session's * injected-clock seam), so tests drive it with fake timers or a hand-rolled @@ -16,8 +16,8 @@ import { assertMoveLog, SCHEMA_VERSION } from '@cozy-games/move-log' */ /** - * @typedef {import('@cozy-games/move-log').MoveEvent} Event - * @typedef {import('@cozy-games/move-log').MoveLog} Envelope + * @typedef {import('@cozy-games/move-log').MoveEvent} Event + * @typedef {import('@cozy-games/move-log').MoveLog} Envelope * @typedef {{ * clock?: () => number, * setTimeout?: (fn: () => void, ms: number) => any, @@ -28,22 +28,20 @@ import { assertMoveLog, SCHEMA_VERSION } from '@cozy-games/move-log' /** * A reducer supplied by a game adapter: given the ordered slice of events played * so far (offset <= the current position), return a completion percentage in - * `[0, 100]`. Typed generically over the game's event vocabulary `T`. The engine - * clamps the result and never inspects an event's payload — all interpretation - * lives in this reducer. + * `[0, 100]`. The engine clamps the result and never inspects an event's payload — + * all interpretation lives in this reducer. * - * @template T - * @typedef {(events: import('@cozy-games/move-log').MoveEvent[]) => number} ProgressReducer + * @typedef {(events: Event[]) => number} ProgressReducer */ /** * A reducer supplied by a game adapter for full-board replay: given the ordered * slice of events played so far, reconstruct the complete game state `S` at that - * point. Typed generically over the event vocabulary `T` and the (opaque) state - * `S`. Powers the flag-gated full-board mode; the engine treats `S` as a black box. + * point. Typed generically over the (opaque) state `S`. Powers the flag-gated + * full-board mode; the engine treats `S` as a black box. * - * @template T, S - * @typedef {(events: import('@cozy-games/move-log').MoveEvent[]) => S} StateReducer + * @template S + * @typedef {(events: Event[]) => S} StateReducer */ /** @@ -51,41 +49,40 @@ import { assertMoveLog, SCHEMA_VERSION } from '@cozy-games/move-log' * enters the engine. Both methods are optional; the engine calls whichever the * mode needs and never interprets an event itself. See `docs/adapter-interface.md`. * - * @template T - * @typedef {{ progress?: ProgressReducer, state?: StateReducer }} ReplayAdapter + * @typedef {{ progress?: ProgressReducer, state?: StateReducer }} ReplayAdapter */ /** - * A version reader: normalize a raw envelope of its generation into the canonical - * ordered `MoveEvent` records the engine plays. One engine build can therefore - * replay envelopes from multiple format generations. + * A version reader: normalize a raw envelope of a FOREIGN/legacy generation into + * the canonical ordered move-log records the engine plays. One engine build can + * therefore replay envelopes from multiple format generations. A canonical + * `@cozy-games/move-log` envelope needs no reader — it plays directly. * * @typedef {(envelope: any) => Event[]} EnvelopeReader */ -/** v1 is the canonical format itself — its `events` are already the records. */ -function readV1(envelope) { - return envelope.events -} - /** - * The built-in dispatch table: `schema_version → reader`. Adding a real future - * generation is exactly one entry here (plus its normalizer). Callers can also - * supply extra/override readers per instance via the `readers` option. + * Built-in readers, keyed by `schema_version` string. Empty by default: a + * canonical move-log envelope IS the record set. This exists only as the base a + * caller's per-instance `readers` merge onto, to normalize foreign generations. * - * @type {Record} + * @type {Record} */ -const ENVELOPE_READERS = { [SCHEMA_VERSION]: readV1 } +const ENVELOPE_READERS = {} /** - * Dispatch on an envelope's `schema_version` to the matching reader and return - * the canonical `MoveEvent` records. Unknown/unsupported versions fail LOUDLY - * with a specific error — never a silent best-effort parse. Whatever a reader - * returns is validated as a canonical move log, so a half-normalized generation - * can't reach the engine. + * Resolve an envelope to the canonical ordered move-log records the engine plays. + * + * A canonical `@cozy-games/move-log` envelope plays directly: it is validated in + * full (via {@link assertMoveLog}) and its `events` are the records. A caller may + * also register per-version `readers` to normalize a FOREIGN generation back to + * canonical records, keyed by that generation's `schema_version` string — when + * one matches it wins, and whatever it returns is validated so a half-normalized + * log can't reach the engine. Either way, malformed input fails LOUDLY with a + * field-specific error — never a silent best-effort parse. * * @param {any} envelope - * @param {Record} [extraReaders] - added/overriding readers + * @param {Record} [extraReaders] - added/overriding readers, keyed by schema_version * @returns {Event[]} */ export function readEnvelope(envelope, extraReaders) { @@ -93,25 +90,25 @@ export function readEnvelope(envelope, extraReaders) { throw new TypeError(`readEnvelope: expected an envelope object (got ${envelope === null ? 'null' : typeof envelope})`) } const readers = extraReaders ? { ...ENVELOPE_READERS, ...extraReaders } : ENVELOPE_READERS - const version = envelope.schema_version - const read = readers[version] - if (typeof read !== 'function') { - const supported = Object.keys(readers).map(Number).sort((a, b) => a - b).join(', ') - throw new RangeError(`readEnvelope: unsupported envelope schema_version ${JSON.stringify(version)} (supported: ${supported})`) + const version = /** @type {any} */ (envelope).schema_version + const read = typeof version === 'string' ? readers[version] : undefined + if (read) { + const records = read(envelope) + // A foreign reader MUST normalize to canonical records; validate them under + // the source version so a half-normalized generation can't reach the engine. + assertMoveLog({ schema_version: version, events: records }) + return records } - const records = read(envelope) - // Every reader MUST normalize to canonical move-log records; enforce it here so - // no downstream generation can feed the engine a malformed or half-normalized log. - assertMoveLog({ schema_version: SCHEMA_VERSION, events: records }) - return records + // Default: the envelope itself must be a well-formed canonical move log. + return assertMoveLog(envelope).events } export class PlaybackClock { /** * @param {Envelope} envelope - a valid move-log envelope (validated here) * @param {Deps} [deps] - injected time source + scheduler (default: real host) - * @param {ReplayAdapter} [adapter] - game adapter (progress / state reducers) - * @param {{ fullBoard?: boolean, readers?: Record }} [options] - + * @param {ReplayAdapter} [adapter] - game adapter (progress / state reducers) + * @param {{ fullBoard?: boolean, readers?: Record }} [options] - * `fullBoard` flag-gates full-board mode (default OFF: `state()`/`onState` are * inert and the state reducer is never called) — the minimal, documented * feature-flag seam for this engine. `readers` adds/overrides schema-version @@ -140,10 +137,10 @@ export class PlaybackClock { // Sort by recorded time (tie-break by seq) and rebase to offsets so the first // event sits at offset 0 — "recorded offset relative to playback time". - const sorted = [...records].sort((a, b) => a.t - b.t || a.seq - b.seq) - const baseT = sorted.length ? sorted[0].t : 0 + const sorted = [...records].sort((a, b) => a.clientTs - b.clientTs || a.seq - b.seq) + const baseT = sorted.length ? sorted[0].clientTs : 0 /** @type {{ offset: number, record: Event }[]} */ - this._events = sorted.map(record => ({ offset: record.t - baseT, record })) + this._events = sorted.map(record => ({ offset: record.clientTs - baseT, record })) this._duration = this._events.length ? this._events[this._events.length - 1].offset : 0 // Playback state. Invariant: `_cursor` === number of events whose offset is @@ -222,7 +219,8 @@ export class PlaybackClock { /** * Subscribe to delivered events. The handler receives the raw envelope record - * (`{ seq, t, event, ... }`) — the payload stays opaque. Returns an unsubscribe. + * (`{ seq, clientTs, type, payload, ... }`) — the payload stays opaque. Returns + * an unsubscribe. * * @param {(event: Event) => void} handler * @returns {() => void} diff --git a/packages/replay/test/full-board-mode.test.js b/packages/replay/test/full-board-mode.test.js index 2e7c9cb..914255b 100644 --- a/packages/replay/test/full-board-mode.test.js +++ b/packages/replay/test/full-board-mode.test.js @@ -3,6 +3,8 @@ import { describe, it, expect } from 'vitest' import { PlaybackClock } from '@cozy-games/replay' import { createMoveLog } from '@cozy-games/move-log' +const VERSION = 'mnswpr-moves/1' + // Real mnswpr run + state reducer — imported by the TEST (relative), so no game // dependency enters the engine's manifest. import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js' @@ -64,12 +66,12 @@ for (const step of [ nowClock = step.at session.applyMove(step.move) } -const records = emitted.map(e => ({ seq: e.seq, t: e.t, event: e })) -const envelope = createMoveLog(records) +const records = emitted.map(e => ({ seq: e.seq, clientTs: e.t, type: e.type, payload: { r: e.r, c: e.c } })) +const envelope = createMoveLog(VERSION, records) const reduce = createStateReducer(board) // Independent ground truth: reduce over records at offset <= t. -const truth = t => reduce(records.filter(r => (r.t - baseT) <= t)) +const truth = t => reduce(records.filter(r => (r.clientTs - baseT) <= t)) describe('full-board mode — flag gating (inert by default)', () => { it('does nothing when the flag is off, even with a state reducer', () => { diff --git a/packages/replay/test/partial-logs.test.js b/packages/replay/test/partial-logs.test.js index 193a9ab..b567052 100644 --- a/packages/replay/test/partial-logs.test.js +++ b/packages/replay/test/partial-logs.test.js @@ -3,6 +3,8 @@ import { describe, it, expect } from 'vitest' import { PlaybackClock } from '@cozy-games/replay' import { createMoveLog } from '@cozy-games/move-log' +const VERSION = 'mnswpr-moves/1' + // Real mnswpr run + reducers — imported by the TEST (relative), so no game // dependency enters the engine's manifest. import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js' @@ -62,7 +64,7 @@ function truncatedEnvelope() { // ...stream cut here — no terminal event. const baseT = 1000 return { - envelope: createMoveLog(emitted.map(e => ({ seq: e.seq, t: e.t, event: e }))), + envelope: createMoveLog(VERSION, emitted.map(e => ({ seq: e.seq, clientTs: e.t, type: e.type, payload: { r: e.r, c: e.c } }))), lastOffset: 1200 - baseT // 200 } } @@ -134,7 +136,7 @@ describe('the "ended" signal', () => { const emitted = [] session.onMove(e => emitted.push(e)) now = 1000; session.applyMove({ type: 'reveal', r: 2, c: 2 }) // floods all 8 → won - const complete = createMoveLog(emitted.map(e => ({ seq: e.seq, t: e.t, event: e }))) + const complete = createMoveLog(VERSION, emitted.map(e => ({ seq: e.seq, clientTs: e.t, type: e.type, payload: { r: e.r, c: e.c } }))) const s = fakeScheduler() const clock = new PlaybackClock(complete, s) @@ -164,7 +166,7 @@ describe('the "ended" signal', () => { }) it('does not fire for an empty envelope', () => { - const clock = new PlaybackClock(createMoveLog([]), fakeScheduler()) + const clock = new PlaybackClock(createMoveLog(VERSION, []), fakeScheduler()) const ends = [] clock.onEnd(() => ends.push(1)) clock.play() diff --git a/packages/replay/test/playback-clock.test.js b/packages/replay/test/playback-clock.test.js index b1f2c82..2c593a6 100644 --- a/packages/replay/test/playback-clock.test.js +++ b/packages/replay/test/playback-clock.test.js @@ -42,16 +42,18 @@ function fakeScheduler(start = 0) { } } -// Events at offsets 0, 100, 350 (t rebased from 1000). Payload is opaque to the clock. +const VERSION = 'dummy-game/1' + +// Events at offsets 0, 100, 350 (clientTs rebased from 1000). Payload is opaque to the clock. function envelope() { - return createMoveLog([ - { seq: 1, t: 1000, event: { type: 'reveal', r: 0, c: 0 } }, - { seq: 2, t: 1100, event: { type: 'flag', r: 1, c: 2 } }, - { seq: 3, t: 1350, event: { type: 'chord', r: 4, c: 4 } } + return createMoveLog(VERSION, [ + { seq: 1, clientTs: 1000, type: 'reveal', payload: { r: 0, c: 0 } }, + { seq: 2, clientTs: 1100, type: 'flag', payload: { r: 1, c: 2 } }, + { seq: 3, clientTs: 1350, type: 'chord', payload: { r: 4, c: 4 } } ]) } -const typesOf = records => records.map(r => r.event.type) +const typesOf = records => records.map(r => r.type) describe('PlaybackClock — construction & shape', () => { it('rebases to offsets: duration is the last offset, first event at 0', () => { @@ -67,7 +69,7 @@ describe('PlaybackClock — construction & shape', () => { }) it('handles an empty envelope gracefully', () => { - const clock = new PlaybackClock(createMoveLog([]), fakeScheduler()) + const clock = new PlaybackClock(createMoveLog(VERSION, []), fakeScheduler()) const seen = [] clock.on(r => seen.push(r)) expect(clock.duration).toBe(0) @@ -82,7 +84,7 @@ describe('PlaybackClock — play / pause with an injected scheduler', () => { const s = fakeScheduler() const clock = new PlaybackClock(envelope(), s) const seen = [] - clock.on(r => seen.push({ type: r.event.type, at: s.clock() })) + clock.on(r => seen.push({ type: r.type, at: s.clock() })) clock.play() // offset-0 event fires synchronously on play @@ -101,7 +103,7 @@ describe('PlaybackClock — play / pause with an injected scheduler', () => { const s = fakeScheduler() const clock = new PlaybackClock(envelope(), s) const seen = [] - clock.on(r => seen.push(r.event.type)) + clock.on(r => seen.push(r.type)) clock.play() s.advance(150) // past offset 100, between 100 and 350 @@ -118,7 +120,7 @@ describe('PlaybackClock — play / pause with an injected scheduler', () => { const s = fakeScheduler() const clock = new PlaybackClock(envelope(), s) const seen = [] - clock.on(r => seen.push({ type: r.event.type, at: s.clock() })) + clock.on(r => seen.push({ type: r.type, at: s.clock() })) clock.play() s.advance(150) @@ -171,7 +173,7 @@ describe('PlaybackClock — seek determinism', () => { const s = fakeScheduler() const clock = new PlaybackClock(envelope(), s) const seen = [] - clock.on(r => seen.push(r.event.type)) + clock.on(r => seen.push(r.type)) clock.play() s.advance(50) // only offset-0 delivered so far @@ -205,7 +207,7 @@ describe('PlaybackClock — with vi fake timers', () => { // Default deps ⇒ Date.now + global setTimeout, both faked by vi. const clock = new PlaybackClock(envelope()) const seen = [] - clock.on(r => seen.push(r.event.type)) + clock.on(r => seen.push(r.type)) clock.play() expect(seen).toEqual(['reveal']) // offset 0 immediate @@ -224,7 +226,7 @@ describe('PlaybackClock — progress reducer (adapter seam)', () => { /** * A dummy adapter defined HERE, in the test — the engine interprets nothing. * @typedef {{ type: string }} DummyEvent - * @type {import('@cozy-games/replay').ProgressReducer} + * @type {import('@cozy-games/replay').ProgressReducer} */ const byCount = events => (events.length / 3) * 100 // 3 = total in envelope() @@ -282,16 +284,16 @@ describe('game-agnosticism guard (envelope only, no game imports)', () => { const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..') const GAME_REFERENCES = /mnswpr|minesweeper/i - it('engine never interprets an event payload (no `.event` access in engine source)', () => { + it('engine never interprets a move payload (no `.payload` access in engine source)', () => { const offenders = [] walk(pkgDir, file => { if (!file.endsWith('.js') || file.includes('/test/')) return const code = readFileSync(file, 'utf8') .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/.*$/gm, '') - if (/\.event\b/.test(code)) offenders.push(file) + if (/\.payload\b/.test(code)) offenders.push(file) }) - expect(offenders).toEqual([]) // engine references only envelope metadata (seq/t) + opaque records + expect(offenders).toEqual([]) // engine references only envelope metadata (seq/clientTs) + opaque records }) it('manifest depends only on the envelope, never a game package', () => { diff --git a/packages/replay/test/progress-mode.test.js b/packages/replay/test/progress-mode.test.js index ffc135f..361bffa 100644 --- a/packages/replay/test/progress-mode.test.js +++ b/packages/replay/test/progress-mode.test.js @@ -3,6 +3,8 @@ import { describe, it, expect } from 'vitest' import { PlaybackClock } from '@cozy-games/replay' import { createMoveLog } from '@cozy-games/move-log' +const VERSION = 'mnswpr-moves/1' + // A real mnswpr run + its reducer — imported by the TEST via relative paths, so // no game dependency enters the replay engine's manifest. import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js' @@ -74,8 +76,8 @@ for (const step of script) { truthPoints.push({ offset: step.at - baseT, revealedSafe: session.state.revealedSafe }) } -const records = emitted.map(e => ({ seq: e.seq, t: e.t, event: e })) -const envelope = createMoveLog(records) +const records = emitted.map(e => ({ seq: e.seq, clientTs: e.t, type: e.type, payload: { r: e.r, c: e.c } })) +const envelope = createMoveLog(VERSION, records) // Ground truth for the mnswpr reducer: revealedSafe / total at a given offset — // derived from the session, NOT from the reducer under test. @@ -90,7 +92,7 @@ function mnswprTruth(offset) { const totalEvents = records.length const dummyReduce = events => (events.length / totalEvents) * 100 function dummyTruth(offset) { - return (records.filter(r => (r.t - baseT) <= offset).length / totalEvents) * 100 + return (records.filter(r => (r.clientTs - baseT) <= offset).length / totalEvents) * 100 } const CASES = [ @@ -99,7 +101,7 @@ const CASES = [ ] describe.each(CASES)('progress mode — same code path, adapter: $name', ({ adapter, truth }) => { - const clamp = t => Math.max(0, Math.min(t, envelope.events[envelope.events.length - 1].t - baseT)) + const clamp = t => Math.max(0, Math.min(t, envelope.events[envelope.events.length - 1].clientTs - baseT)) it('progress() matches the source run at multiple points (via seek)', () => { const clock = new PlaybackClock(envelope, fakeScheduler(), adapter) diff --git a/packages/replay/test/schema-dispatch.test.js b/packages/replay/test/schema-dispatch.test.js index 9fb6228..3e2e8d5 100644 --- a/packages/replay/test/schema-dispatch.test.js +++ b/packages/replay/test/schema-dispatch.test.js @@ -3,6 +3,8 @@ import { describe, it, expect } from 'vitest' import { PlaybackClock, readEnvelope } from '@cozy-games/replay' import { createMoveLog } from '@cozy-games/move-log' +const VERSION = 'mnswpr-moves/1' + function fakeScheduler(start = 0) { let now = start let nextId = 1 @@ -32,40 +34,55 @@ function fakeScheduler(start = 0) { } } -// A canonical v1 envelope. -const v1 = () => createMoveLog([ - { seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } }, - { seq: 2, t: 100, event: { type: 'flag', r: 1, c: 1 } }, - { seq: 3, t: 250, event: { type: 'chord', r: 2, c: 2 } } +// A canonical `@cozy-games/move-log` envelope. +const canonical = () => createMoveLog(VERSION, [ + { seq: 1, clientTs: 0, type: 'reveal', payload: { r: 0, c: 0 } }, + { seq: 2, clientTs: 100, type: 'flag', payload: { r: 1, c: 1 } }, + { seq: 3, clientTs: 250, type: 'chord', payload: { r: 2, c: 2 } } ]) -describe('schema_version dispatch — v1 (built-in)', () => { - it('replays a v1 envelope through the dispatch path (not a bypass)', () => { +describe('canonical move-log envelopes play directly', () => { + it('replays a canonical envelope through readEnvelope (not a bypass)', () => { const s = fakeScheduler() - const clock = new PlaybackClock(v1(), s) + const clock = new PlaybackClock(canonical(), s) const seen = [] - clock.on(r => seen.push(r.event.type)) + clock.on(r => seen.push(r.type)) clock.play() s.advance(250) expect(seen).toEqual(['reveal', 'flag', 'chord']) }) - it('readEnvelope returns the canonical records for v1', () => { - const records = readEnvelope(v1()) + it('readEnvelope returns the canonical records', () => { + const records = readEnvelope(canonical()) expect(records.map(r => r.seq)).toEqual([1, 2, 3]) - expect(records[0].event.type).toBe('reveal') + expect(records[0].type).toBe('reveal') + expect(records[0].payload).toEqual({ r: 0, c: 0 }) + }) + + it('is game-agnostic: any valid string schema_version plays (no allow-list)', () => { + // A different game's vocabulary version — the engine plays it without any + // per-game registration; genericness, not gatekeeping. + const otherGame = createMoveLog('some-other-game/3', [ + { seq: 1, clientTs: 0, type: 'jump', payload: { x: 1 } } + ]) + const records = readEnvelope(otherGame) + expect(records.map(r => r.type)).toEqual(['jump']) }) }) -describe('schema_version dispatch — unknown versions fail loudly', () => { - it('throws a specific error for a synthetic future version (99)', () => { - const future = { schema_version: 99, events: [{ seq: 1, t: 0, event: {} }] } - expect(() => new PlaybackClock(future, fakeScheduler())).toThrow(/unsupported envelope schema_version 99 \(supported: 1\)/) - expect(() => readEnvelope(future)).toThrow(RangeError) +describe('malformed envelopes fail loudly', () => { + it('throws for a non-string schema_version', () => { + const bad = { schema_version: 99, events: [{ seq: 1, clientTs: 0, type: 'x', payload: {} }] } + expect(() => new PlaybackClock(/** @type {any} */ (bad), fakeScheduler())).toThrow(TypeError) + expect(() => readEnvelope(bad)).toThrow(/schema_version must be a non-empty string/) }) it('throws for a missing schema_version', () => { - expect(() => readEnvelope({ events: [] })).toThrow(/unsupported envelope schema_version undefined/) + expect(() => readEnvelope({ events: [] })).toThrow(/schema_version must be a non-empty string/) + }) + + it('throws for a valid version but malformed events (not a canonical log)', () => { + expect(() => readEnvelope({ schema_version: VERSION })).toThrow(/events must be an array/) }) it('rejects a non-object envelope', () => { @@ -74,43 +91,49 @@ describe('schema_version dispatch — unknown versions fail loudly', () => { }) }) -describe('schema_version dispatch — adding a version', () => { - // A toy v2 format defined ENTIRELY in the test: a different field layout that a - // normalizer maps back to canonical { seq, t, event }. Adding it is one entry. +describe('foreign generations normalize via a supplied reader (keyed by version string)', () => { + // A toy FOREIGN format defined ENTIRELY in the test: a different field layout + // that a normalizer maps back to canonical { seq, clientTs, type, payload }. + // Adding support is one reader entry, keyed by its schema_version string. const v2Envelope = { - schema_version: 2, + schema_version: 'toy-v2/1', log: [ - { n: 1, ts: 0, payload: { type: 'reveal', r: 0, c: 0 } }, - { n: 2, ts: 120, payload: { type: 'flag', r: 1, c: 1 } } + { n: 1, ts: 0, kind: 'reveal', data: { r: 0, c: 0 } }, + { n: 2, ts: 120, kind: 'flag', data: { r: 1, c: 1 } } ] } - const readV2 = env => env.log.map(e => ({ seq: e.n, t: e.ts, event: e.payload })) + const readV2 = env => env.log.map(e => ({ seq: e.n, clientTs: e.ts, type: e.kind, payload: e.data })) - it('replays a v2 fixture via a supplied reader (same code path)', () => { + it('replays a foreign fixture via a supplied reader (same code path)', () => { const s = fakeScheduler() - const clock = new PlaybackClock(v2Envelope, s, {}, { readers: { 2: readV2 } }) + const clock = new PlaybackClock(v2Envelope, s, {}, { readers: { 'toy-v2/1': readV2 } }) const seen = [] - clock.on(r => seen.push(r.event.type)) + clock.on(r => seen.push(r.type)) clock.play() s.advance(120) expect(seen).toEqual(['reveal', 'flag']) }) - it('readEnvelope normalizes v2 to canonical records with an extra reader', () => { - const records = readEnvelope(v2Envelope, { 2: readV2 }) + it('readEnvelope normalizes the foreign format to canonical records', () => { + const records = readEnvelope(v2Envelope, { 'toy-v2/1': readV2 }) expect(records).toEqual([ - { seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } }, - { seq: 2, t: 120, event: { type: 'flag', r: 1, c: 1 } } + { seq: 1, clientTs: 0, type: 'reveal', payload: { r: 0, c: 0 } }, + { seq: 2, clientTs: 120, type: 'flag', payload: { r: 1, c: 1 } } ]) }) - it('still rejects v2 when no reader is registered', () => { - expect(() => readEnvelope(v2Envelope)).toThrow(/unsupported envelope schema_version 2 \(supported: 1\)/) + it('fails loudly for the foreign format when no reader is registered', () => { + // Default path validates it AS a canonical log — it has no `events`, so it + // fails with the field-specific error, not a silent parse. + expect(() => readEnvelope(v2Envelope)).toThrow(/events must be an array/) }) it('validates a reader that returns a malformed (non-canonical) log', () => { // A buggy reader whose output breaks the monotonic-seq invariant is caught. - const badReader = () => [{ seq: 2, t: 0, event: {} }, { seq: 1, t: 1, event: {} }] - expect(() => readEnvelope({ schema_version: 3 }, { 3: badReader })).toThrow(RangeError) + const badReader = () => [ + { seq: 2, clientTs: 0, type: 'a', payload: {} }, + { seq: 1, clientTs: 1, type: 'b', payload: {} } + ] + expect(() => readEnvelope({ schema_version: 'toy-bad/1' }, { 'toy-bad/1': badReader })).toThrow(RangeError) }) }) diff --git a/packages/utils/package.json b/packages/utils/package.json index 24439c8..af98d27 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -3,7 +3,6 @@ "version": "0.0.1", "description": "Shared, dependency-free browser utilities for Cozy Games (storage, timer, logger, loading, date buckets)", "author": "Ayo Ayco", - "private": true, "type": "module", "repository": { "type": "git",