chore: bump packages for release

This commit is contained in:
ayo 2026-07-16 20:33:57 +02:00
parent 9b220829f7
commit 32cf38c452
33 changed files with 911 additions and 482 deletions

View file

@ -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`). - **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. - 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) ## Release & git hooks (maintainer workflow)

View file

@ -55,13 +55,18 @@ export class LeaderBoardService {
/** /**
* Read surface render the ranked list with a duration tab bar. * Read surface render the ranked list with a duration tab bar.
* @see LeaderBoardReader#render * @see LeaderBoardReader#render
* @param {String} category
* @param {String} title
* @param {String} [duration]
* @returns {Promise<HTMLDivElement>}
*/ */
render(category: any, title: any, duration: any): Promise<HTMLDivElement>; render(category: string, title: string, duration?: string): Promise<HTMLDivElement>;
/** /**
* Write surface submit a completed game (archive + ranked entry). * Write surface submit a completed game (archive + ranked entry).
* @see LeaderBoardWriter#submit * @see LeaderBoardWriter#submit
* @param {import('./leaderboard-write.js').ScoreEntry} entry
*/ */
submit(entry: any): Promise<void>; submit(entry: import("./leaderboard-write.js").ScoreEntry): Promise<void>;
} }
import { LeaderBoardReader } from './leaderboard-read.js'; import { LeaderBoardReader } from './leaderboard-read.js';
import { LeaderBoardWriter } from './leaderboard-write.js'; import { LeaderBoardWriter } from './leaderboard-write.js';

View file

@ -50,6 +50,10 @@ export class LeaderBoardService {
/** /**
* Read surface render the ranked list with a duration tab bar. * Read surface render the ranked list with a duration tab bar.
* @see LeaderBoardReader#render * @see LeaderBoardReader#render
* @param {String} category
* @param {String} title
* @param {String} [duration]
* @returns {Promise<HTMLDivElement>}
*/ */
render(category, title, duration) { render(category, title, duration) {
return this.reader.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). * Write surface submit a completed game (archive + ranked entry).
* @see LeaderBoardWriter#submit * @see LeaderBoardWriter#submit
* @param {import('./leaderboard-write.js').ScoreEntry} entry
*/ */
submit(entry) { submit(entry) {
return this.writer.submit(entry) return this.writer.submit(entry)

View file

@ -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 * The WRITE surface of the leaderboard: submitting a completed game the
* personal archive plus, if it qualifies, a ranked entry with denormalized * 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 * qualifies, also writes a ranked entry with denormalized bucket keys. Both
* writes go through the adapter, so the storage backend is pluggable. The * writes go through the adapter, so the storage backend is pluggable. The
* caller owns display-name/nickname UX. * caller owns display-name/nickname UX.
* @param {Object} entry - { name, playerId, score, category, time_stamp, status?, meta? } * @param {ScoreEntry} entry
*/ */
submit(entry: any): Promise<void>; submit(entry: ScoreEntry): Promise<void>;
} }
/**
* 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;
};

View file

@ -1,5 +1,20 @@
import { buckets } from '@cozy-games/utils/date-bucket/date-bucket.js' 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 * The WRITE surface of the leaderboard: submitting a completed game the
* personal archive plus, if it qualifies, a ranked entry with denormalized * 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 * qualifies, also writes a ranked entry with denormalized bucket keys. Both
* writes go through the adapter, so the storage backend is pluggable. The * writes go through the adapter, so the storage backend is pluggable. The
* caller owns display-name/nickname UX. * caller owns display-name/nickname UX.
* @param {Object} entry - { name, playerId, score, category, time_stamp, status?, meta? } * @param {ScoreEntry} entry
*/ */
async submit(entry) { async submit(entry) {
if (this.adapter.archive) { if (this.adapter.archive) {

View file

@ -1,6 +1,6 @@
{ {
"name": "@cozy-games/leaderboard", "name": "@cozy-games/leaderboard",
"version": "0.0.1", "version": "0.0.2",
"description": "Generic, game-agnostic Firestore leaderboard with time-windowed views", "description": "Generic, game-agnostic Firestore leaderboard with time-windowed views",
"author": "Ayo Ayco", "author": "Ayo Ayco",
"type": "module", "type": "module",

View file

@ -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 * Map a recorded move-log entry back to the rules move that produced it: `flag`
* `unflag` are both the toggle move `flag`; `reveal` and `chord` pass through. * and `unflag` are both the toggle move `flag`; `reveal` and `chord` pass
* Unknown kinds are ignored. Shared by the mnswpr replay adapters (progress and * through. Unknown kinds are ignored. Shared by the mnswpr replay adapters
* full-board state) so both replay a stream through the core rules identically. * (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} * @returns {{ type: 'reveal' | 'flag' | 'chord', r: number, c: number } | null}
*/ */
export function toMove(e: MnswprMoveEvent): { export function toMove(record: MnswprRecord): {
type: "reveal" | "flag" | "chord"; type: "reveal" | "flag" | "chord";
r: number; r: number;
c: number; c: number;
} | null; } | 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;
};
};

View file

@ -1,24 +1,34 @@
// @ts-check // @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 * Map a recorded move-log entry back to the rules move that produced it: `flag`
* `unflag` are both the toggle move `flag`; `reveal` and `chord` pass through. * and `unflag` are both the toggle move `flag`; `reveal` and `chord` pass
* Unknown kinds are ignored. Shared by the mnswpr replay adapters (progress and * through. Unknown kinds are ignored. Shared by the mnswpr replay adapters
* full-board state) so both replay a stream through the core rules identically. * (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} * @returns {{ type: 'reveal' | 'flag' | 'chord', r: number, c: number } | null}
*/ */
export function toMove(e) { export function toMove(record) {
switch (e && e.type) { if (!record) return null
case 'reveal': return { type: 'reveal', r: e.r, c: e.c } const { type, payload } = record
case 'chord': return { type: 'chord', r: e.r, c: e.c } 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 'flag':
case 'unflag': return { type: 'flag', r: e.r, c: e.c } case 'unflag': return { type: 'flag', r, c }
default: return null default: return null
} }
} }

View file

@ -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 * @typedef {import('../core/minesweeper/board.js').Layout} Layout
*/ */
/** /**
@ -10,18 +10,17 @@
* `revealed safe cells / total safe cells * 100`. * `revealed safe cells / total safe cells * 100`.
* *
* Why it needs the board: a single `reveal` or `chord` event floods MANY cells, * 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 * but the recorded entry only carries the move's `type` + `{ r, c }` payload not
* opened. So the reducer takes the board as closure input (consistent with the * how many cells opened. So the reducer takes the board as closure input
* interface design) and replays the moves through the pure core rules. That makes * (consistent with the interface design) and replays the moves through the pure
* reveals flood, chords reveal via their (non-flagged) neighbors, and * core rules. That makes reveals flood, chords reveal via their (non-flagged)
* flags/unflags only gate chords never advancing progress themselves with no * neighbors, and flags/unflags only gate chords never advancing progress
* cell double-counted. The engine stays game-blind; all interpretation is here. * 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`) * @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: { export function createProgressReducer(layout: Layout): (events: MnswprRecord[]) => number;
event: MnswprMoveEvent; export type MnswprRecord = import("./replay-common.js").MnswprRecord;
}[]) => number;
export type MnswprMoveEvent = import("../core/minesweeper/rules.js").MoveEvent;
export type Layout = import("../core/minesweeper/board.js").Layout; export type Layout = import("../core/minesweeper/board.js").Layout;

View file

@ -3,7 +3,7 @@ import { MinesweeperRules } from '../core/minesweeper/rules.js'
import { toMove } from './replay-common.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 {import('../core/minesweeper/board.js').Layout} Layout
*/ */
@ -15,15 +15,16 @@ import { toMove } from './replay-common.js'
* `revealed safe cells / total safe cells * 100`. * `revealed safe cells / total safe cells * 100`.
* *
* Why it needs the board: a single `reveal` or `chord` event floods MANY cells, * 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 * but the recorded entry only carries the move's `type` + `{ r, c }` payload not
* opened. So the reducer takes the board as closure input (consistent with the * how many cells opened. So the reducer takes the board as closure input
* interface design) and replays the moves through the pure core rules. That makes * (consistent with the interface design) and replays the moves through the pure
* reveals flood, chords reveal via their (non-flagged) neighbors, and * core rules. That makes reveals flood, chords reveal via their (non-flagged)
* flags/unflags only gate chords never advancing progress themselves with no * neighbors, and flags/unflags only gate chords never advancing progress
* cell double-counted. The engine stays game-blind; all interpretation is here. * 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`) * @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) { export function createProgressReducer(layout) {
const totalSafe = layout.rows * layout.cols - layout.mines const totalSafe = layout.rows * layout.cols - layout.mines
@ -32,7 +33,7 @@ export function createProgressReducer(layout) {
if (totalSafe === 0) return 100 if (totalSafe === 0) return 100
let state = MinesweeperRules.fromLayout(layout) let state = MinesweeperRules.fromLayout(layout)
for (const record of events) { for (const record of events) {
const move = toMove(record.event) const move = toMove(record)
if (move) state = MinesweeperRules.apply(state, move).state if (move) state = MinesweeperRules.apply(state, move).state
} }
return (state.revealedSafe / totalSafe) * 100 return (state.revealedSafe / totalSafe) * 100

View file

@ -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 {import('../core/minesweeper/board.js').Layout} Layout
* @typedef {{ mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }} BoardCell * @typedef {{ mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }} BoardCell
* @typedef {{ rows: number, cols: number, phase: string, revealedSafe: number, cells: BoardCell[][] }} BoardState * @typedef {{ rows: number, cols: number, phase: string, revealedSafe: number, cells: BoardCell[][] }} BoardState
*/ */
/** /**
* The full-board state reducer for Minesweeper mnswpr's implementation of the * The full-board state reducer for Minesweeper mnswpr's implementation of the
* replay engine's `StateReducer<MnswprMoveEvent, BoardState>` seam (replay-05). * replay engine's `StateReducer<BoardState>` seam (replay-05). Given the ordered
* Given the ordered slice of move-events played so far, it reconstructs the * slice of move-log entries played so far, it reconstructs the COMPLETE board at
* COMPLETE board at that point: every cell's mine/adjacent/status plus the phase. * 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 * 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 * moves through the pure core rules so reveals flood, chords open their
@ -17,12 +17,10 @@
* any position and rebuild the board there. * any position and rebuild the board there.
* *
* @param {Layout} layout - the recorded board (as produced by `generateBoard`) * @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: { export function createStateReducer(layout: Layout): (events: MnswprRecord[]) => BoardState;
event: MnswprMoveEvent; export type MnswprRecord = import("./replay-common.js").MnswprRecord;
}[]) => BoardState;
export type MnswprMoveEvent = import("../core/minesweeper/rules.js").MoveEvent;
export type Layout = import("../core/minesweeper/board.js").Layout; export type Layout = import("../core/minesweeper/board.js").Layout;
export type BoardCell = { export type BoardCell = {
mine: boolean; mine: boolean;

View file

@ -3,7 +3,7 @@ import { MinesweeperRules } from '../core/minesweeper/rules.js'
import { toMove } from './replay-common.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 {import('../core/minesweeper/board.js').Layout} Layout
* @typedef {{ mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }} BoardCell * @typedef {{ mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }} BoardCell
* @typedef {{ rows: number, cols: number, phase: string, revealedSafe: number, cells: BoardCell[][] }} BoardState * @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 * The full-board state reducer for Minesweeper mnswpr's implementation of the
* replay engine's `StateReducer<MnswprMoveEvent, BoardState>` seam (replay-05). * replay engine's `StateReducer<BoardState>` seam (replay-05). Given the ordered
* Given the ordered slice of move-events played so far, it reconstructs the * slice of move-log entries played so far, it reconstructs the COMPLETE board at
* COMPLETE board at that point: every cell's mine/adjacent/status plus the phase. * 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 * 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 * 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. * any position and rebuild the board there.
* *
* @param {Layout} layout - the recorded board (as produced by `generateBoard`) * @param {Layout} layout - the recorded board (as produced by `generateBoard`)
* @returns {(events: { event: MnswprMoveEvent }[]) => BoardState} * @returns {(events: MnswprRecord[]) => BoardState}
*/ */
export function createStateReducer(layout) { export function createStateReducer(layout) {
return function state(events) { return function state(events) {
let s = MinesweeperRules.fromLayout(layout) let s = MinesweeperRules.fromLayout(layout)
for (const record of events) { for (const record of events) {
const move = toMove(record.event) const move = toMove(record)
if (move) s = MinesweeperRules.apply(s, move).state if (move) s = MinesweeperRules.apply(s, move).state
} }
return toBoard(s) return toBoard(s)

View file

@ -15,11 +15,24 @@ export class Grid<Cell> {
cols: number; cols: number;
/** @type {Cell[]} */ /** @type {Cell[]} */
_cells: Cell[]; _cells: Cell[];
/** @returns {boolean} */ /**
inBounds(r: any, c: any): boolean; * @param {number} r
/** @returns {Cell} */ * @param {number} c
at(r: any, c: any): Cell; * @returns {boolean}
set(r: any, c: any, cell: any): void; */
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 */ /** @param {(cell: Cell, r: number, c: number) => void} fn */
forEach(fn: (cell: Cell, r: number, c: number) => void): void; forEach(fn: (cell: Cell, r: number, c: number) => void): void;
/** /**

View file

@ -26,16 +26,29 @@ export class Grid {
} }
} }
/** @returns {boolean} */ /**
* @param {number} r
* @param {number} c
* @returns {boolean}
*/
inBounds(r, c) { inBounds(r, c) {
return r >= 0 && c >= 0 && r < this.rows && c < this.cols return r >= 0 && c >= 0 && r < this.rows && c < this.cols
} }
/** @returns {Cell} */ /**
* @param {number} r
* @param {number} c
* @returns {Cell}
*/
at(r, c) { at(r, c) {
return this._cells[r * this.cols + c] return this._cells[r * this.cols + c]
} }
/**
* @param {number} r
* @param {number} c
* @param {Cell} cell
*/
set(r, c, cell) { set(r, c, cell) {
this._cells[r * this.cols + c] = cell this._cells[r * this.cols + c] = cell
} }

View file

@ -24,9 +24,11 @@ export function floodReveal(grid, startR, startC) {
start.status = 'revealed' start.status = 'revealed'
revealed.push({ r: startR, c: startC, adjacent: start.adjacent }) revealed.push({ r: startR, c: startC, adjacent: start.adjacent })
/** @type {[number, number][]} */
const queue = [[startR, startC]] const queue = [[startR, startC]]
while (queue.length) { 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. // Only blank cells propagate; numbers are a boundary.
if (grid.at(r, c).adjacent !== 0) continue if (grid.at(r, c).adjacent !== 0) continue
for (const [nr, nc] of eightWay(grid, r, c)) { for (const [nr, nc] of eightWay(grid, r, c)) {

View file

@ -8,6 +8,8 @@
* @typedef {{ seed: number, config: Config, grid: Grid<Cell>, phase: Phase, minesPlaced: boolean, revealedSafe: number }} State * @typedef {{ seed: number, config: Config, grid: Grid<Cell>, 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 {{ type: 'reveal', r: number, c: number } | { type: 'flag', r: number, c: number } | { type: 'chord', r: number, c: number }} Move
* @typedef {object} Event * @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 * 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). */ /** The move-event vocabulary as runtime data (the `MoveEvent` `type` domain). */
export const MOVE_EVENT_TYPES: readonly ["reveal", "flag", "unflag", "chord"]; export const MOVE_EVENT_TYPES: readonly ["reveal", "flag", "unflag", "chord"];
/** export namespace MinesweeperRules {
* The GameRules contract consumed by GameSession/replay: init / apply / status / /**
* project, plus serialize / deserialize for snapshotting. Deterministic and * @param {number} seed
* DOM-free. * @param {Config} config
*/ * @returns {State}
export const MinesweeperRules: any; */
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. * Minesweeper as a pure, deterministic state machine no DOM, no wall clock.
* `GameSession` (Layer 1) drives it; the client renders the events it emits. * `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. * `GameSession` (Layer 1) drives it; the client renders the events it emits.
*/ */
export type Event = object; 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 * 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 * move). This is the game's public event language consumed later by the shared
@ -104,4 +208,19 @@ export type MoveEvent = {
t: number; t: number;
seq: 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'; import { Grid } from '../grid/grid.js';
export {};

View file

@ -15,6 +15,8 @@ import { floodReveal, countFlagsAround, allMines } from './reveal.js'
* @typedef {{ seed: number, config: Config, grid: Grid<Cell>, phase: Phase, minesPlaced: boolean, revealedSafe: number }} State * @typedef {{ seed: number, config: Config, grid: Grid<Cell>, 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 {{ type: 'reveal', r: number, c: number } | { type: 'flag', r: number, c: number } | { type: 'chord', r: number, c: number }} Move
* @typedef {object} Event * @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. * (invariant #3). Hidden, unrevealed, non-mine cells are simply omitted.
* *
* @param {State} state * @param {State} state
* @returns {{ config: Config, phase: Phase, cells: ProjectedCell[] }}
*/ */
function project(state) { function project(state) {
const terminal = state.phase === 'won' || state.phase === 'lost' const terminal = state.phase === 'won' || state.phase === 'lost'
/** @type {ProjectedCell[]} */
const cells = [] const cells = []
state.grid.forEach((cell, r, c) => { state.grid.forEach((cell, r, c) => {
if (cell.status === 'revealed') cells.push({ r, c, status: 'revealed', adjacent: cell.adjacent, mine: cell.mine }) 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. * already plain data.
* *
* @param {State} state * @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) { serialize(state) {
return { return {
@ -308,7 +312,7 @@ export const MinesweeperRules = {
* Rebuild a game state from {@link serialize} output (or its JSON round-trip). * 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. * Cells are cloned so the revived state shares no references with the snapshot.
* *
* @param {ReturnType<typeof MinesweeperRules.serialize>} snap * @param {Snapshot} snap
* @returns {State} * @returns {State}
*/ */
deserialize(snap) { deserialize(snap) {

View file

@ -1,6 +1,6 @@
{ {
"name": "@cozy-games/mnswpr", "name": "@cozy-games/mnswpr",
"version": "0.4.36", "version": "0.5.0",
"description": "Classic Minesweeper browser game", "description": "Classic Minesweeper browser game",
"author": "Ayo", "author": "Ayo",
"type": "module", "type": "module",

View file

@ -9,7 +9,7 @@ function record(layout, moves) {
const events = [] const events = []
session.onMove(e => events.push(e)) session.onMove(e => events.push(e))
for (const m of moves) session.applyMove(m) 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. // 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 session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout) })
const events = [] 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 r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) { for (let c = 0; c < 9; c++) {
if (!session.state.grid.at(r, c).mine) session.applyMove({ type: 'reveal', r, 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 }, // flag the mine
{ type: 'flag', r: 0, c: 0 } // unflag it { 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)) const afterReveal = progress(events.slice(0, 1))
expect(afterReveal).toBeCloseTo(12.5, 5) // 1 / 8 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: 'flag', r: 0, c: 0 },
{ type: 'chord', r: 0, c: 1 } { 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, 1))).toBeCloseTo(12.5, 5) // 1/8 after reveal
expect(progress(events.slice(0, 2))).toBeCloseTo(12.5, 5) // flag doesn't advance expect(progress(events.slice(0, 2))).toBeCloseTo(12.5, 5) // flag doesn't advance

View file

@ -8,7 +8,7 @@ function record(layout, moves) {
const events = [] const events = []
session.onMove(e => events.push(e)) session.onMove(e => events.push(e))
for (const m of moves) session.applyMove(m) 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. // 3x3, single mine at (0,0). Total safe = 8.

View file

@ -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<T>` / `MoveLog<T>`
`MoveEvent<TType extends string = string, TPayload = unknown>` /
`MoveLog<TType extends string = string, TPayload = unknown>`. 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`.

View file

@ -1,62 +1,112 @@
# @cozy-games/move-log # @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 ```js
import { import {
createMoveLog, withReceivedTs, createMoveLog, withReceivedTs,
serializeMoveLog, deserializeMoveLog, isMoveLog, SCHEMA_VERSION serializeMoveLog, deserializeMoveLog, isMoveLog, assertMoveLog
} from '@cozy-games/move-log' } from '@cozy-games/move-log'
// `T` is your game's own event vocabulary — supplied by you, unknown to us. // The first argument is YOUR game's move-event vocabulary version — a string,
const log = createMoveLog([ // carried verbatim. `type` names a move in that vocabulary; `payload` is your
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } }, // game's opaque move data (never inspected here).
{ seq: 2, t: 50, event: { type: 'flag', r: 1, c: 2 } } 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 json = serializeMoveLog(log) // → JSON string
const restored = deserializeMoveLog(json) // → validated MoveLog, or throws const restored = deserializeMoveLog(json) // → validated MoveLog, or throws
// A consumer records WHEN it received events (host clock), additively: // A consumer records WHEN it received events (host clock), additively:
const stamped = withReceivedTs(restored, () => hostNow()) 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 | ```ts
| ---------------- | ----------------- | -------------------------------------------------- | // Parameterized over the game's move-event discriminator and payload, with
| `schema_version` | `1` | the move-log container version | // defaults so game-blind code can use the erased (string / unknown) form.
| `events` | `MoveEvent<T>[]` | ordered, each `{ seq, t, event, receivedTs? }` | interface MoveEvent<TType extends string = string, TPayload = unknown> {
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<T> = { seq: number, t: number, event: T, receivedTs?: number }` — the interface MoveLog<TType extends string = string, TPayload = unknown> {
log owns the per-event recording metadata (a strictly increasing `seq`, a schema_version: string // the GAME's move-event vocabulary version, verbatim
source-side timestamp `t`, and an **optional** received-side `receivedTs`), so events: MoveEvent<TType, TPayload>[]
`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.
`deserializeMoveLog` round-trips a serialized log with full fidelity (order, The move splits into a surfaced `type` **discriminator** plus an **opaque**
timestamps, sequence numbers, and any `receivedTs`) and rejects malformed input — `payload` — exactly ADR-002 §1 — so game-agnostic tooling (progress overlays,
bad JSON, missing or wrong-typed fields, or non-monotonic `seq` — with a clear analytics, log inspection) can key off the event *kind* without a game adapter,
error, never returning a partially-parsed log. 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 ```js
purely additive: a v1 log is valid whether every event, some events, or no /** @type {import('@cozy-games/move-log').MoveLog<'a' | 'b', { x: number }>} */
events carry a `receivedTs`, and a reader that doesn't know the field simply const log = createMoveLog('made-up-game/1', [
ignores it. A version bump is reserved for *breaking* container changes (a { seq: 1, clientTs: 0, type: 'a', payload: { x: 1 } }
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. ```
`.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 ## Invariant: zero game-specific imports
This module **must never import a game package** (e.g. mnswpr) or any game 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 vocabulary. `type`/`payload` are always supplied by the consumer; the log only
opaque payloads. This independence is the whole point — it lets one move-log ever sees opaque payloads. This independence is the whole point — it lets one
format serve every game. 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
The rule is enforced by a dependency-graph guard in `test/move-log.test.js` game references). Keep it green.
(scans the package's source and manifest for game references). Keep it green.

View file

@ -1,23 +1,27 @@
/** /**
* Assert a value is a well-formed move log correct `schema_version` and a valid * Assert a value is a well-formed move log a non-empty string `schema_version`
* events array throwing a clear, specific error otherwise. Returns the value * and a valid events array throwing a clear, specific error otherwise. Returns
* (typed) for chaining; never mutates. * the value (typed as the erased, game-blind form) for chaining; never mutates.
* *
* @param {unknown} value * @param {unknown} value
* @returns {MoveLog<any>} * @returns {MoveLog}
*/ */
export function assertMoveLog(value: unknown): MoveLog<any>; 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 * 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 * strictly increasing `seq`), never the shape of a `payload`. Order is preserved
* entries are copied, so the log never aliases the caller's array. * and entries are copied, so the log never aliases the caller's array. Generic
* over the game infers `TType`/`TPayload` from `events`.
* *
* @template T * @template {string} [TType=string]
* @param {MoveEvent<T>[]} [events] - ordered events, each `{ seq, t, event, receivedTs? }` * @template [TPayload=unknown]
* @returns {MoveLog<T>} * @param {string} schemaVersion - the game's move-event vocabulary version, stored verbatim
* @param {MoveEvent<TType, TPayload>[]} [events] - ordered entries, each `{ seq, clientTs, type, payload, receivedTs? }`
* @returns {MoveLog<TType, TPayload>}
*/ */
export function createMoveLog<T>(events?: MoveEvent<T>[]): MoveLog<T>; export function createMoveLog<TType extends string = string, TPayload = unknown>(schemaVersion: string, events?: MoveEvent<TType, TPayload>[]): MoveLog<TType, TPayload>;
/** /**
* Return a new move log with a received-side timestamp attached to each event * 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 * for which `stamp` returns a finite number; events where `stamp` returns
@ -25,15 +29,16 @@ export function createMoveLog<T>(events?: MoveEvent<T>[]): MoveLog<T>;
* consumer records WHEN it received events the log never cares where the value * consumer records WHEN it received events the log never cares where the value
* came from. Pure: the input log is not mutated. * came from. Pure: the input log is not mutated.
* *
* @template T * @template {string} TType
* @param {MoveLog<T>} log * @template TPayload
* @param {(event: MoveEvent<T>, index: number) => number | undefined} stamp * @param {MoveLog<TType, TPayload>} log
* @returns {MoveLog<T>} * @param {(event: MoveEvent<TType, TPayload>, index: number) => number | undefined} stamp
* @returns {MoveLog<TType, TPayload>}
*/ */
export function withReceivedTs<T>(log: MoveLog<T>, stamp: (event: MoveEvent<T>, index: number) => number | undefined): MoveLog<T>; export function withReceivedTs<TType extends string, TPayload>(log: MoveLog<TType, TPayload>, stamp: (event: MoveEvent<TType, TPayload>, index: number) => number | undefined): MoveLog<TType, TPayload>;
/** /**
* Non-throwing type guard: is `value` a well-formed move log of the current * Non-throwing type guard: is `value` a well-formed move log? Checks the
* schema version? Checks the container invariants only remains blind to `T`. * container invariants only remains blind to each `payload`.
* *
* @param {unknown} value * @param {unknown} value
* @returns {boolean} * @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 * Serialize a move log to a JSON string. Validates first, so a malformed log is
* rejected here rather than emitted. Inverse of {@link deserializeMoveLog}. * rejected here rather than emitted. Inverse of {@link deserializeMoveLog}.
* *
* @template T * @template {string} TType
* @param {MoveLog<T>} log * @template TPayload
* @param {MoveLog<TType, TPayload>} log
* @returns {string} * @returns {string}
*/ */
export function serializeMoveLog<T>(log: MoveLog<T>): string; export function serializeMoveLog<TType extends string, TPayload>(log: MoveLog<TType, TPayload>): string;
/** /**
* Parse and validate a JSON string into a move log, with full fidelity: event * Parse and validate a JSON string into a move log, with full fidelity: event
* order, timestamps, and sequence numbers survive the round-trip exactly. * order, `seq`, `clientTs`, `type`, `payload`, and any `receivedTs` survive the
* Rejects malformed input (bad JSON, missing/typed-wrong fields, non-monotonic * round-trip exactly. Rejects malformed input (bad JSON, missing/typed-wrong
* `seq`) with a clear error and NEVER returns a partially-parsed log. Inverse of * fields, non-monotonic `seq`) with a clear error and NEVER returns a
* {@link serializeMoveLog}. * partially-parsed log. Inverse of {@link serializeMoveLog}.
* *
* @param {string} json * @param {string} json
* @returns {MoveLog<any>} * @returns {MoveLog}
*/ */
export function deserializeMoveLog(json: string): MoveLog<any>; export function deserializeMoveLog(json: string): MoveLog;
/** /**
* `@cozy-games/move-log` a game-agnostic container for a recorded run of move * A single recorded move event the log-owned recording metadata plus the
* events. It wraps ANY game's event stream: `T` is the consuming game's own * game's surfaced `type` + opaque `payload`:
* event vocabulary (mnswpr's `MoveEvent` union from core-06 is the first `T`),
* supplied by the caller.
* *
* This module imports NO game types that independence is the whole point and * - `seq` integer, STRICTLY INCREASING across the log (starts at 1, survives resume).
* is enforced by a dependency-graph guard in the tests. The log owns the * - `clientTs` finite millisecond timestamp from the client clock.
* per-event recording metadata (`seq` + `t`, and an optional received-side * - `type` the game's move-event discriminator (ADR §1 `type: T`); a non-empty string.
* `receivedTs`) so `T` can stay a pure game payload with no required shape; the * - `payload` game-specific move data; OPAQUE to the package (never inspected).
* module never inspects the inside of an `event`. * - `receivedTs` OPTIONAL, additive: a consumer-side receipt time (finite ms).
* *
* Extraction to a standalone published package comes later; for now it lives as * Generic over the game's discriminator `TType` and payload `TPayload`, with
* a shared workspace module alongside `packages/utils`. * defaults so game-blind code can use the erased form.
*/ */
/** export type MoveEvent<TType extends string = string, TPayload = unknown> = {
* 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<T> = {
seq: number; seq: number;
t: number; clientTs: number;
event: T; type: TType;
payload: TPayload;
receivedTs?: number; receivedTs?: number;
}; };
/** /**
* The container: a schema-versioned, ordered array of timestamped, sequenced * The container: a schema-versioned, ordered array of recorded entries for one
* events for one recorded run. Generic over the game's event vocabulary `T`. * run. `schema_version` is the game's move-event vocabulary version, verbatim.
* JSON-safe as long as `T` is. * Generic over the game (same parameters as {@link MoveEvent}); JSON-safe as long
* as every `payload` is.
*/ */
export type MoveLog<T> = { export type MoveLog<TType extends string = string, TPayload = unknown> = {
schema_version: SchemaVersion; schema_version: string;
events: MoveEvent<T>[]; events: MoveEvent<TType, TPayload>[];
}; };
/**
* 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;

View file

@ -2,60 +2,78 @@
/** /**
* `@cozy-games/move-log` a game-agnostic container for a recorded run of move * `@cozy-games/move-log` a game-agnostic container for a recorded run of move
* events. It wraps ANY game's event stream: `T` is the consuming game's own * events, implementing the generic envelope of cozy-games ADR-002 §1.
* event vocabulary (mnswpr's `MoveEvent` union from core-06 is the first `T`),
* supplied by the caller.
* *
* This module imports NO game types that independence is the whole point and * Each recorded entry is `{ seq, clientTs, type, payload }` (+ an optional,
* is enforced by a dependency-graph guard in the tests. The log owns the * additive `receivedTs`): the log-owned recording metadata plus a surfaced move
* per-event recording metadata (`seq` + `t`, and an optional received-side * `type` discriminator (a string naming a member of the consuming game's
* `receivedTs`) so `T` can stay a pure game payload with no required shape; the * move-event vocabulary) and an OPAQUE `payload` carrying that game's move data.
* module never inspects the inside of an `event`.
* *
* Extraction to a standalone published package comes later; for now it lives as * The container is **generic over the game** `MoveEvent<TType, TPayload>` and
* a shared workspace module alongside `packages/utils`. * `MoveLog<TType, TPayload>` 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 * - `seq` integer, STRICTLY INCREASING across the log (starts at 1, survives resume).
* optional `receivedTs`, say) do NOT bump this a v1 reader ignores fields it * - `clientTs` finite millisecond timestamp from the client clock.
* doesn't know, and a log written with them stays a valid v1 log. Bump ONLY on a * - `type` the game's move-event discriminator (ADR §1 `type: T`); a non-empty string.
* breaking change to the container shape (a renamed/removed field, a newly * - `payload` game-specific move data; OPAQUE to the package (never inspected).
* *required* field), which would need dispatch on read. Never bump for changes * - `receivedTs` OPTIONAL, additive: a consumer-side receipt time (finite ms).
* to a game's `T` vocabulary.
* *
* @typedef {1} SchemaVersion * Generic over the game's discriminator `TType` and payload `TPayload`, with
*/ * defaults so game-blind code can use the erased form.
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.
* *
* @template T * @template {string} [TType=string]
* @typedef {{ seq: number, t: number, event: T, receivedTs?: number }} MoveEvent * @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 * The container: a schema-versioned, ordered array of recorded entries for one
* events for one recorded run. Generic over the game's event vocabulary `T`. * run. `schema_version` is the game's move-event vocabulary version, verbatim.
* JSON-safe as long as `T` is. * Generic over the game (same parameters as {@link MoveEvent}); JSON-safe as long
* as every `payload` is.
* *
* @template T * @template {string} [TType=string]
* @typedef {{ schema_version: SchemaVersion, events: MoveEvent<T>[] }} MoveLog * @template [TPayload=unknown]
* @typedef {{ schema_version: string, events: MoveEvent<TType, TPayload>[] }} MoveLog
*/ */
/** /**
* Validate an events array: each entry must be a `{ seq, t, event }` with an * Assert a schema version is a non-empty string the game's move-event
* integer `seq`, a finite numeric `t`, and a present `event`; and `seq` must be * vocabulary version, carried verbatim (ADR §2). Throws otherwise.
* STRICTLY INCREASING across the array. Throws a distinct, field-specific error *
* on the first problem never leaves a caller with a half-checked array. * @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 * @param {unknown} events
*/ */
@ -65,14 +83,8 @@ function assertEvents(events) {
} }
let prevSeq = -Infinity let prevSeq = -Infinity
events.forEach((e, i) => { events.forEach((e, i) => {
if (e === null || typeof e !== 'object') { if (e === null || typeof e !== 'object' || Array.isArray(e)) {
throw new TypeError(`move-log: events[${i}] must be an object (got ${e === null ? 'null' : typeof e})`) throw new TypeError(`move-log: events[${i}] must be an object (got ${e === null ? 'null' : Array.isArray(e) ? 'array' : 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 (!Number.isInteger(e.seq)) { if (!Number.isInteger(e.seq)) {
throw new TypeError(`move-log: events[${i}].seq must be an integer (got ${JSON.stringify(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) { if (e.seq <= prevSeq) {
throw new RangeError(`move-log: events[${i}].seq must be strictly increasing (got ${e.seq} after ${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))) { 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)})`) 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` * Copy one entry to the canonical field set, carrying `receivedTs` through only
* through only when it's actually present (so absent stays absent no * when it's actually present (so absent stays absent no `receivedTs: undefined`
* `receivedTs: undefined` keys leak into the log or its JSON). * 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 * @template {string} TType
* @param {MoveEvent<T>} e * @template TPayload
* @returns {MoveEvent<T>} * @param {MoveEvent<TType, TPayload>} e
* @returns {MoveEvent<TType, TPayload>}
*/ */
function copyEvent(e) { function copyEvent(e) {
/** @type {MoveEvent<T>} */ /** @type {MoveEvent<TType, TPayload>} */
const out = { seq: e.seq, t: e.t, event: e.event } const out = { seq: e.seq, clientTs: e.clientTs, type: e.type, payload: e.payload }
if (e.receivedTs !== undefined) out.receivedTs = e.receivedTs if (e.receivedTs !== undefined) out.receivedTs = e.receivedTs
return out return out
} }
/** /**
* Assert a value is a well-formed move log correct `schema_version` and a valid * Assert a value is a well-formed move log a non-empty string `schema_version`
* events array throwing a clear, specific error otherwise. Returns the value * and a valid events array throwing a clear, specific error otherwise. Returns
* (typed) for chaining; never mutates. * the value (typed as the erased, game-blind form) for chaining; never mutates.
* *
* @param {unknown} value * @param {unknown} value
* @returns {MoveLog<any>} * @returns {MoveLog}
*/ */
export function assertMoveLog(value) { export function assertMoveLog(value) {
if (value === null || typeof value !== 'object') { if (value === null || typeof value !== 'object') {
throw new TypeError(`move-log: expected an object (got ${value === null ? 'null' : typeof value})`) throw new TypeError(`move-log: expected an object (got ${value === null ? 'null' : typeof value})`)
} }
const v = /** @type {any} */ (value) const v = /** @type {any} */ (value)
if (v.schema_version !== SCHEMA_VERSION) { assertSchemaVersion(v.schema_version)
throw new RangeError(`move-log: unsupported schema_version ${JSON.stringify(v.schema_version)} (expected ${SCHEMA_VERSION})`)
}
assertEvents(v.events) assertEvents(v.events)
return v 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 * 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 * strictly increasing `seq`), never the shape of a `payload`. Order is preserved
* entries are copied, so the log never aliases the caller's array. * and entries are copied, so the log never aliases the caller's array. Generic
* over the game infers `TType`/`TPayload` from `events`.
* *
* @template T * @template {string} [TType=string]
* @param {MoveEvent<T>[]} [events] - ordered events, each `{ seq, t, event, receivedTs? }` * @template [TPayload=unknown]
* @returns {MoveLog<T>} * @param {string} schemaVersion - the game's move-event vocabulary version, stored verbatim
* @param {MoveEvent<TType, TPayload>[]} [events] - ordered entries, each `{ seq, clientTs, type, payload, receivedTs? }`
* @returns {MoveLog<TType, TPayload>}
*/ */
export function createMoveLog(events = []) { export function createMoveLog(schemaVersion, events = []) {
assertSchemaVersion(schemaVersion)
assertEvents(events) assertEvents(events)
return { return {
schema_version: SCHEMA_VERSION, schema_version: schemaVersion,
events: events.map(copyEvent) 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 * consumer records WHEN it received events the log never cares where the value
* came from. Pure: the input log is not mutated. * came from. Pure: the input log is not mutated.
* *
* @template T * @template {string} TType
* @param {MoveLog<T>} log * @template TPayload
* @param {(event: MoveEvent<T>, index: number) => number | undefined} stamp * @param {MoveLog<TType, TPayload>} log
* @returns {MoveLog<T>} * @param {(event: MoveEvent<TType, TPayload>, index: number) => number | undefined} stamp
* @returns {MoveLog<TType, TPayload>}
*/ */
export function withReceivedTs(log, stamp) { export function withReceivedTs(log, stamp) {
assertMoveLog(log) 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 * Non-throwing type guard: is `value` a well-formed move log? Checks the
* schema version? Checks the container invariants only remains blind to `T`. * container invariants only remains blind to each `payload`.
* *
* @param {unknown} value * @param {unknown} value
* @returns {boolean} * @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 * Serialize a move log to a JSON string. Validates first, so a malformed log is
* rejected here rather than emitted. Inverse of {@link deserializeMoveLog}. * rejected here rather than emitted. Inverse of {@link deserializeMoveLog}.
* *
* @template T * @template {string} TType
* @param {MoveLog<T>} log * @template TPayload
* @param {MoveLog<TType, TPayload>} log
* @returns {string} * @returns {string}
*/ */
export function serializeMoveLog(log) { 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 * Parse and validate a JSON string into a move log, with full fidelity: event
* order, timestamps, and sequence numbers survive the round-trip exactly. * order, `seq`, `clientTs`, `type`, `payload`, and any `receivedTs` survive the
* Rejects malformed input (bad JSON, missing/typed-wrong fields, non-monotonic * round-trip exactly. Rejects malformed input (bad JSON, missing/typed-wrong
* `seq`) with a clear error and NEVER returns a partially-parsed log. Inverse of * fields, non-monotonic `seq`) with a clear error and NEVER returns a
* {@link serializeMoveLog}. * partially-parsed log. Inverse of {@link serializeMoveLog}.
* *
* @param {string} json * @param {string} json
* @returns {MoveLog<any>} * @returns {MoveLog}
*/ */
export function deserializeMoveLog(json) { export function deserializeMoveLog(json) {
if (typeof json !== 'string') { if (typeof json !== 'string') {

View file

@ -1,9 +1,8 @@
{ {
"name": "@cozy-games/move-log", "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", "description": "Game-blind, schema-versioned log of a recorded run of move events — generic over the game's own event vocabulary",
"author": "Ayo Ayco", "author": "Ayo Ayco",
"private": true,
"type": "module", "type": "module",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -8,84 +8,152 @@ import { dirname, join } from 'node:path'
// module resolves and is importable by other packages. // module resolves and is importable by other packages.
import { import {
createMoveLog, withReceivedTs, createMoveLog, withReceivedTs,
serializeMoveLog, deserializeMoveLog, isMoveLog, assertMoveLog, SCHEMA_VERSION serializeMoveLog, deserializeMoveLog, isMoveLog, assertMoveLog
} from '@cozy-games/move-log' } from '@cozy-games/move-log'
// A REAL mnswpr session/event stream — imported by the TEST, never the module. // 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. // Relative path (not the package name) so no game dependency enters the manifest.
import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js' import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js'
/** // A dummy vocabulary version string, defined HERE, in the test — deliberately not
* A dummy event vocabulary defined HERE, in the test deliberately NOT mnswpr's. // tied to the module. The move log carries whatever version string the consumer
* The move log must type-check and work against any `T` the consumer supplies. // supplies, verbatim, and validates only its own envelope invariants.
* @typedef {{ kind: 'tick' } | { kind: 'boom', power: number }} DummyEvent const VERSION = 'dummy-game/1'
*/
describe('@cozy-games/move-log', () => { describe('@cozy-games/move-log', () => {
/** @type {import('@cozy-games/move-log').MoveEvent<DummyEvent>[]} */ /** @type {import('@cozy-games/move-log').MoveEvent[]} */
const events = [ const events = [
{ seq: 1, t: 0, event: { kind: 'tick' } }, { seq: 1, clientTs: 0, type: 'tick', payload: {} },
{ seq: 2, t: 50, event: { kind: 'boom', power: 3 } }, { seq: 2, clientTs: 50, type: 'boom', payload: { power: 3 } },
{ seq: 5, t: 120, event: { kind: 'tick' } } // gaps allowed; strictly increasing { seq: 5, clientTs: 120, type: 'tick', payload: {} } // gaps allowed; strictly increasing
] ]
it('exposes schema_version typed as 1', () => { it('carries the caller-supplied schema_version string verbatim', () => {
expect(SCHEMA_VERSION).toBe(1) 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', () => { it('wraps an ordered, timestamped, sequenced stream for a dummy vocabulary', () => {
/** @type {import('@cozy-games/move-log').MoveLog<DummyEvent>} */ /** @type {import('@cozy-games/move-log').MoveLog} */
const log = createMoveLog(events) 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).toHaveLength(3)
expect(log.events.map(e => e.seq)).toEqual([1, 2, 5]) expect(log.events.map(e => e.seq)).toEqual([1, 2, 5])
expect(log.events.map(e => e.t)).toEqual([0, 50, 120]) expect(log.events.map(e => e.clientTs)).toEqual([0, 50, 120])
expect(log.events[1]).toEqual({ seq: 2, t: 50, event: { kind: 'boom', power: 3 } }) 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)', () => { it('defaults to an empty run and copies entries (no aliasing of the input)', () => {
expect(createMoveLog()).toEqual({ schema_version: 1, events: [] }) expect(createMoveLog(VERSION)).toEqual({ schema_version: VERSION, events: [] })
const input = [{ seq: 1, t: 1, event: { kind: 'tick' } }] const input = [{ seq: 1, clientTs: 1, type: 'tick', payload: {} }]
const log = createMoveLog(input) const log = createMoveLog(VERSION, input)
input[0].t = 999 input[0].clientTs = 999
expect(log.events[0].t).toBe(1) // log kept its own copy 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', () => { it('rejects a non-monotonic seq at construction', () => {
expect(() => createMoveLog([ expect(() => createMoveLog(VERSION, [
{ seq: 2, t: 0, event: {} }, { seq: 2, clientTs: 0, type: 'a', payload: {} },
{ seq: 1, t: 1, event: {} } { seq: 1, clientTs: 1, type: 'b', payload: {} }
])).toThrow(RangeError) ])).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<MnswprType, { r: number, c: number }>} */
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', () => { describe('serialization round-trip', () => {
const events = [ const events = [
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } }, { seq: 1, clientTs: 0, type: 'reveal', payload: { r: 0, c: 0 } },
{ seq: 2, t: 50, event: { type: 'flag', r: 1, c: 2 } }, { seq: 2, clientTs: 50, type: 'flag', payload: { r: 1, c: 2 } },
{ seq: 3, t: 90, event: { type: 'chord', r: 4, c: 4 } } { seq: 3, clientTs: 90, type: 'chord', payload: { r: 4, c: 4 } }
] ]
it('preserves order, timestamps, and sequence numbers exactly', () => { it('preserves order, timestamps, types, payloads, and sequence numbers exactly', () => {
const log = createMoveLog(events) const log = createMoveLog(VERSION, events)
const restored = deserializeMoveLog(serializeMoveLog(log)) const restored = deserializeMoveLog(serializeMoveLog(log))
expect(restored).toEqual(log) // full structural fidelity 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.seq)).toEqual([1, 2, 3])
expect(restored.events.map(e => e.t)).toEqual([0, 50, 90]) expect(restored.events.map(e => e.clientTs)).toEqual([0, 50, 90])
expect(restored.events.map(e => e.event.type)).toEqual(['reveal', 'flag', 'chord']) 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', () => { 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) const json = serializeMoveLog(log)
expect(typeof json).toBe('string') expect(typeof json).toBe('string')
expect(JSON.parse(json)).toEqual(log) expect(JSON.parse(json)).toEqual(log)
}) })
it('rejects each malformed fixture with a distinct, clear error', () => { 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 // not a string
// @ts-expect-error — deliberately wrong type // @ts-expect-error — deliberately wrong type
@ -93,21 +161,26 @@ describe('serialization round-trip', () => {
// invalid JSON syntax // invalid JSON syntax
expect(() => deserializeMoveLog('{not json')).toThrow(SyntaxError) expect(() => deserializeMoveLog('{not json')).toThrow(SyntaxError)
// missing schema_version // missing schema_version
expect(() => deserializeMoveLog(JSON.stringify({ events: [] }))).toThrow(RangeError) expect(() => deserializeMoveLog(JSON.stringify({ events: [] }))).toThrow(TypeError)
// wrong schema_version // non-string schema_version
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 2, events: [] }))).toThrow(RangeError) 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 // events not an array
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: 'nope' }))).toThrow(TypeError) expect(() => deserializeMoveLog(JSON.stringify({ schema_version: VERSION, events: 'nope' }))).toThrow(TypeError)
// missing 'event' field // non-integer seq
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [{ seq: 1, t: 0 }] }))).toThrow(TypeError) expect(() => deserializeMoveLog(JSON.stringify({ schema_version: VERSION, events: [{ ...entry, seq: 1.5 }] }))).toThrow(TypeError)
// bad timestamp type // non-finite clientTs
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [{ seq: 1, t: 'soon', event: {} }] }))).toThrow(TypeError) expect(() => deserializeMoveLog(JSON.stringify({ schema_version: VERSION, events: [{ ...entry, clientTs: 'soon' }] }))).toThrow(TypeError)
// bad seq type // empty / non-string 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: [{ ...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 // shuffled / non-monotonic seq
const shuffled = JSON.stringify({ const shuffled = JSON.stringify({
schema_version: 1, schema_version: VERSION,
events: [{ seq: 3, t: 0, event: {} }, { seq: 1, t: 1, event: {} }] events: [{ seq: 3, clientTs: 0, type: 'a', payload: {} }, { seq: 1, clientTs: 1, type: 'b', payload: {} }]
}) })
expect(() => deserializeMoveLog(shuffled)).toThrow(RangeError) expect(() => deserializeMoveLog(shuffled)).toThrow(RangeError)
@ -115,7 +188,8 @@ describe('serialization round-trip', () => {
const messages = [ const messages = [
captureMessage(() => deserializeMoveLog('{not json')), captureMessage(() => deserializeMoveLog('{not json')),
captureMessage(() => deserializeMoveLog(JSON.stringify({ events: [] }))), 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)) captureMessage(() => deserializeMoveLog(shuffled))
] ]
expect(new Set(messages).size).toBe(messages.length) 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)', () => { it('never returns a partially-parsed log (throws before returning)', () => {
const partlyBad = JSON.stringify({ const partlyBad = JSON.stringify({
schema_version: 1, schema_version: VERSION,
events: [{ seq: 1, t: 0, event: { ok: true } }, { seq: 2, t: 'bad', event: {} }] events: [{ seq: 1, clientTs: 0, type: 'ok', payload: { ok: true } }, { seq: 2, clientTs: 'bad', type: 'x', payload: {} }]
}) })
let result = 'sentinel' let result = 'sentinel'
expect(() => { result = deserializeMoveLog(partlyBad) }).toThrow(TypeError) expect(() => { result = deserializeMoveLog(partlyBad) }).toThrow(TypeError)
@ -135,24 +209,24 @@ describe('serialization round-trip', () => {
}) })
it('isMoveLog / assertMoveLog agree on validity', () => { it('isMoveLog / assertMoveLog agree on validity', () => {
const log = createMoveLog(events) const log = createMoveLog(VERSION, events)
expect(isMoveLog(log)).toBe(true) expect(isMoveLog(log)).toBe(true)
expect(assertMoveLog(log)).toBe(log) expect(assertMoveLog(log)).toBe(log)
expect(isMoveLog(null)).toBe(false) 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)', () => { describe('received timestamps (additive receivedTs)', () => {
const base = [ const base = [
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } }, { seq: 1, clientTs: 0, type: 'reveal', payload: { r: 0, c: 0 } },
{ seq: 2, t: 50, event: { type: 'flag', r: 1, c: 2 } } { seq: 2, clientTs: 50, type: 'flag', payload: { r: 1, c: 2 } }
] ]
it('accepts an optional receivedTs per event and round-trips it', () => { it('accepts an optional receivedTs per event and round-trips it', () => {
const log = createMoveLog([ const log = createMoveLog(VERSION, [
{ seq: 1, t: 0, event: { k: 'a' }, receivedTs: 1000 }, { seq: 1, clientTs: 0, type: 'a', payload: {}, receivedTs: 1000 },
{ seq: 2, t: 50, event: { k: 'b' }, receivedTs: 1060 } { seq: 2, clientTs: 50, type: 'b', payload: {}, receivedTs: 1060 }
]) ])
expect(log.events[0].receivedTs).toBe(1000) expect(log.events[0].receivedTs).toBe(1000)
const restored = deserializeMoveLog(serializeMoveLog(log)) const restored = deserializeMoveLog(serializeMoveLog(log))
@ -161,9 +235,9 @@ describe('received timestamps (additive receivedTs)', () => {
}) })
it('is valid with receivedTs on only some events', () => { it('is valid with receivedTs on only some events', () => {
const log = createMoveLog([ const log = createMoveLog(VERSION, [
{ seq: 1, t: 0, event: { k: 'a' }, receivedTs: 1000 }, { seq: 1, clientTs: 0, type: 'a', payload: {}, receivedTs: 1000 },
{ seq: 2, t: 50, event: { k: 'b' } } // no receivedTs { seq: 2, clientTs: 50, type: 'b', payload: {} } // no receivedTs
]) ])
expect(isMoveLog(log)).toBe(true) expect(isMoveLog(log)).toBe(true)
expect('receivedTs' in log.events[1]).toBe(false) 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', () => { 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(isMoveLog(log)).toBe(true)
expect(log.events.every(e => !('receivedTs' in e))).toBe(true) expect(log.events.every(e => !('receivedTs' in e))).toBe(true)
const restored = deserializeMoveLog(serializeMoveLog(log)) const restored = deserializeMoveLog(serializeMoveLog(log))
@ -180,29 +254,29 @@ describe('received timestamps (additive receivedTs)', () => {
}) })
it('rejects a non-finite / non-numeric receivedTs', () => { it('rejects a non-finite / non-numeric receivedTs', () => {
expect(() => createMoveLog([{ seq: 1, t: 0, event: {}, receivedTs: 'soon' }])).toThrow(TypeError) expect(() => createMoveLog(VERSION, [{ seq: 1, clientTs: 0, type: 'x', payload: {}, 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: Infinity }])).toThrow(TypeError)
expect(() => deserializeMoveLog(JSON.stringify({ expect(() => deserializeMoveLog(JSON.stringify({
schema_version: 1, schema_version: VERSION,
events: [{ seq: 1, t: 0, event: {}, receivedTs: 'later' }] events: [{ seq: 1, clientTs: 0, type: 'x', payload: {}, receivedTs: 'later' }]
}))).toThrow(TypeError) }))).toThrow(TypeError)
}) })
it('withReceivedTs attaches host-received times additively without mutating the input', () => { it('withReceivedTs attaches host-received times additively without mutating the input', () => {
const log = createMoveLog(base) const log = createMoveLog(VERSION, base)
let clock = 900 let clock = 900
const stamped = withReceivedTs(log, () => (clock += 10)) const stamped = withReceivedTs(log, () => (clock += 10))
// input untouched // input untouched
expect(log.events.every(e => !('receivedTs' in e))).toBe(true) 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.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) expect(deserializeMoveLog(serializeMoveLog(stamped))).toEqual(stamped)
}) })
it('withReceivedTs leaves events unstamped when the stamp returns undefined', () => { 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)) const stamped = withReceivedTs(log, (e) => (e.seq === 1 ? 1234 : undefined))
expect(stamped.events[0].receivedTs).toBe(1234) expect(stamped.events[0].receivedTs).toBe(1234)
expect('receivedTs' in stamped.events[1]).toBe(false) 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', () => { 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) 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)', () => { 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']) 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. // Wrap the stream: the log-owned metadata (seq, clientTs) is lifted to the log
const log = createMoveLog(emitted.map(e => ({ seq: e.seq, t: e.t, event: e }))) // 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)) const restored = deserializeMoveLog(serializeMoveLog(log))
expect(restored).toEqual(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.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.clientTs)).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.type)).toEqual(['reveal', 'flag', 'unflag', 'flag', 'chord'])
// sequence is strictly increasing — the log's own invariant, verified on real data // sequence is strictly increasing — the log's own invariant, verified on real data
const seqs = restored.events.map(e => e.seq) const seqs = restored.events.map(e => e.seq)
expect(seqs).toEqual([...seqs].sort((a, b) => a - b)) expect(seqs).toEqual([...seqs].sort((a, b) => a - b))

View file

@ -1,14 +1,14 @@
// @ts-check // @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. * `@cozy-games/replay` the core of a game-agnostic replay engine.
* *
* {@link PlaybackClock} re-drives a move-log envelope over time: it schedules * {@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 * event) as playback time advances, and supports play / pause / seek. It
* consumes ONLY the generic envelope (`@cozy-games/move-log`) and never inspects * 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 * 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 * 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<any>} Event * @typedef {import('@cozy-games/move-log').MoveEvent} Event
* @typedef {import('@cozy-games/move-log').MoveLog<any>} Envelope * @typedef {import('@cozy-games/move-log').MoveLog} Envelope
* @typedef {{ * @typedef {{
* clock?: () => number, * clock?: () => number,
* setTimeout?: (fn: () => void, ms: number) => any, * 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 * 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 * so far (offset <= the current position), return a completion percentage in
* `[0, 100]`. Typed generically over the game's event vocabulary `T`. The engine * `[0, 100]`. The engine clamps the result and never inspects an event's payload
* clamps the result and never inspects an event's payload all interpretation * all interpretation lives in this reducer.
* lives in this reducer.
* *
* @template T * @typedef {(events: Event[]) => number} ProgressReducer
* @typedef {(events: import('@cozy-games/move-log').MoveEvent<T>[]) => number} ProgressReducer
*/ */
/** /**
* A reducer supplied by a game adapter for full-board replay: given the ordered * 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 * 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 * point. Typed generically over the (opaque) state `S`. Powers the flag-gated
* `S`. Powers the flag-gated full-board mode; the engine treats `S` as a black box. * full-board mode; the engine treats `S` as a black box.
* *
* @template T, S * @template S
* @typedef {(events: import('@cozy-games/move-log').MoveEvent<T>[]) => S} StateReducer * @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 * 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`. * mode needs and never interprets an event itself. See `docs/adapter-interface.md`.
* *
* @template T * @typedef {{ progress?: ProgressReducer, state?: StateReducer<any> }} ReplayAdapter
* @typedef {{ progress?: ProgressReducer<T>, state?: StateReducer<T, any> }} ReplayAdapter
*/ */
/** /**
* A version reader: normalize a raw envelope of its generation into the canonical * A version reader: normalize a raw envelope of a FOREIGN/legacy generation into
* ordered `MoveEvent` records the engine plays. One engine build can therefore * the canonical ordered move-log records the engine plays. One engine build can
* replay envelopes from multiple format generations. * 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 * @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 * Built-in readers, keyed by `schema_version` string. Empty by default: a
* generation is exactly one entry here (plus its normalizer). Callers can also * canonical move-log envelope IS the record set. This exists only as the base a
* supply extra/override readers per instance via the `readers` option. * caller's per-instance `readers` merge onto, to normalize foreign generations.
* *
* @type {Record<number, EnvelopeReader>} * @type {Record<string, EnvelopeReader>}
*/ */
const ENVELOPE_READERS = { [SCHEMA_VERSION]: readV1 } const ENVELOPE_READERS = {}
/** /**
* Dispatch on an envelope's `schema_version` to the matching reader and return * Resolve an envelope to the canonical ordered move-log records the engine plays.
* the canonical `MoveEvent` records. Unknown/unsupported versions fail LOUDLY *
* with a specific error never a silent best-effort parse. Whatever a reader * A canonical `@cozy-games/move-log` envelope plays directly: it is validated in
* returns is validated as a canonical move log, so a half-normalized generation * full (via {@link assertMoveLog}) and its `events` are the records. A caller may
* can't reach the engine. * 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 {any} envelope
* @param {Record<number, EnvelopeReader>} [extraReaders] - added/overriding readers * @param {Record<string, EnvelopeReader>} [extraReaders] - added/overriding readers, keyed by schema_version
* @returns {Event[]} * @returns {Event[]}
*/ */
export function readEnvelope(envelope, extraReaders) { 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})`) throw new TypeError(`readEnvelope: expected an envelope object (got ${envelope === null ? 'null' : typeof envelope})`)
} }
const readers = extraReaders ? { ...ENVELOPE_READERS, ...extraReaders } : ENVELOPE_READERS const readers = extraReaders ? { ...ENVELOPE_READERS, ...extraReaders } : ENVELOPE_READERS
const version = envelope.schema_version const version = /** @type {any} */ (envelope).schema_version
const read = readers[version] const read = typeof version === 'string' ? readers[version] : undefined
if (typeof read !== 'function') { if (read) {
const supported = Object.keys(readers).map(Number).sort((a, b) => a - b).join(', ') const records = read(envelope)
throw new RangeError(`readEnvelope: unsupported envelope schema_version ${JSON.stringify(version)} (supported: ${supported})`) // 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) // Default: the envelope itself must be a well-formed canonical move log.
// Every reader MUST normalize to canonical move-log records; enforce it here so return assertMoveLog(envelope).events
// no downstream generation can feed the engine a malformed or half-normalized log.
assertMoveLog({ schema_version: SCHEMA_VERSION, events: records })
return records
} }
export class PlaybackClock { export class PlaybackClock {
/** /**
* @param {Envelope} envelope - a valid move-log envelope (validated here) * @param {Envelope} envelope - a valid move-log envelope (validated here)
* @param {Deps} [deps] - injected time source + scheduler (default: real host) * @param {Deps} [deps] - injected time source + scheduler (default: real host)
* @param {ReplayAdapter<any>} [adapter] - game adapter (progress / state reducers) * @param {ReplayAdapter} [adapter] - game adapter (progress / state reducers)
* @param {{ fullBoard?: boolean, readers?: Record<number, EnvelopeReader> }} [options] - * @param {{ fullBoard?: boolean, readers?: Record<string, EnvelopeReader> }} [options] -
* `fullBoard` flag-gates full-board mode (default OFF: `state()`/`onState` are * `fullBoard` flag-gates full-board mode (default OFF: `state()`/`onState` are
* inert and the state reducer is never called) the minimal, documented * inert and the state reducer is never called) the minimal, documented
* feature-flag seam for this engine. `readers` adds/overrides schema-version * 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 // 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". // 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 sorted = [...records].sort((a, b) => a.clientTs - b.clientTs || a.seq - b.seq)
const baseT = sorted.length ? sorted[0].t : 0 const baseT = sorted.length ? sorted[0].clientTs : 0
/** @type {{ offset: number, record: Event }[]} */ /** @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 this._duration = this._events.length ? this._events[this._events.length - 1].offset : 0
// Playback state. Invariant: `_cursor` === number of events whose offset is // 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 * 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 * @param {(event: Event) => void} handler
* @returns {() => void} * @returns {() => void}

View file

@ -3,6 +3,8 @@ import { describe, it, expect } from 'vitest'
import { PlaybackClock } from '@cozy-games/replay' import { PlaybackClock } from '@cozy-games/replay'
import { createMoveLog } from '@cozy-games/move-log' 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 // Real mnswpr run + state reducer — imported by the TEST (relative), so no game
// dependency enters the engine's manifest. // dependency enters the engine's manifest.
import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js' import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js'
@ -64,12 +66,12 @@ for (const step of [
nowClock = step.at nowClock = step.at
session.applyMove(step.move) session.applyMove(step.move)
} }
const records = emitted.map(e => ({ seq: e.seq, t: e.t, event: e })) const records = emitted.map(e => ({ seq: e.seq, clientTs: e.t, type: e.type, payload: { r: e.r, c: e.c } }))
const envelope = createMoveLog(records) const envelope = createMoveLog(VERSION, records)
const reduce = createStateReducer(board) const reduce = createStateReducer(board)
// Independent ground truth: reduce over records at offset <= t. // 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)', () => { describe('full-board mode — flag gating (inert by default)', () => {
it('does nothing when the flag is off, even with a state reducer', () => { it('does nothing when the flag is off, even with a state reducer', () => {

View file

@ -3,6 +3,8 @@ import { describe, it, expect } from 'vitest'
import { PlaybackClock } from '@cozy-games/replay' import { PlaybackClock } from '@cozy-games/replay'
import { createMoveLog } from '@cozy-games/move-log' import { createMoveLog } from '@cozy-games/move-log'
const VERSION = 'mnswpr-moves/1'
// Real mnswpr run + reducers — imported by the TEST (relative), so no game // Real mnswpr run + reducers — imported by the TEST (relative), so no game
// dependency enters the engine's manifest. // dependency enters the engine's manifest.
import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js' import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js'
@ -62,7 +64,7 @@ function truncatedEnvelope() {
// ...stream cut here — no terminal event. // ...stream cut here — no terminal event.
const baseT = 1000 const baseT = 1000
return { 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 lastOffset: 1200 - baseT // 200
} }
} }
@ -134,7 +136,7 @@ describe('the "ended" signal', () => {
const emitted = [] const emitted = []
session.onMove(e => emitted.push(e)) session.onMove(e => emitted.push(e))
now = 1000; session.applyMove({ type: 'reveal', r: 2, c: 2 }) // floods all 8 → won 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 s = fakeScheduler()
const clock = new PlaybackClock(complete, s) const clock = new PlaybackClock(complete, s)
@ -164,7 +166,7 @@ describe('the "ended" signal', () => {
}) })
it('does not fire for an empty envelope', () => { it('does not fire for an empty envelope', () => {
const clock = new PlaybackClock(createMoveLog([]), fakeScheduler()) const clock = new PlaybackClock(createMoveLog(VERSION, []), fakeScheduler())
const ends = [] const ends = []
clock.onEnd(() => ends.push(1)) clock.onEnd(() => ends.push(1))
clock.play() clock.play()

View file

@ -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() { function envelope() {
return createMoveLog([ return createMoveLog(VERSION, [
{ seq: 1, t: 1000, event: { type: 'reveal', r: 0, c: 0 } }, { seq: 1, clientTs: 1000, type: 'reveal', payload: { r: 0, c: 0 } },
{ seq: 2, t: 1100, event: { type: 'flag', r: 1, c: 2 } }, { seq: 2, clientTs: 1100, type: 'flag', payload: { r: 1, c: 2 } },
{ seq: 3, t: 1350, event: { type: 'chord', r: 4, c: 4 } } { 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', () => { describe('PlaybackClock — construction & shape', () => {
it('rebases to offsets: duration is the last offset, first event at 0', () => { 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', () => { it('handles an empty envelope gracefully', () => {
const clock = new PlaybackClock(createMoveLog([]), fakeScheduler()) const clock = new PlaybackClock(createMoveLog(VERSION, []), fakeScheduler())
const seen = [] const seen = []
clock.on(r => seen.push(r)) clock.on(r => seen.push(r))
expect(clock.duration).toBe(0) expect(clock.duration).toBe(0)
@ -82,7 +84,7 @@ describe('PlaybackClock — play / pause with an injected scheduler', () => {
const s = fakeScheduler() const s = fakeScheduler()
const clock = new PlaybackClock(envelope(), s) const clock = new PlaybackClock(envelope(), s)
const seen = [] 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() clock.play()
// offset-0 event fires synchronously on play // offset-0 event fires synchronously on play
@ -101,7 +103,7 @@ describe('PlaybackClock — play / pause with an injected scheduler', () => {
const s = fakeScheduler() const s = fakeScheduler()
const clock = new PlaybackClock(envelope(), s) const clock = new PlaybackClock(envelope(), s)
const seen = [] const seen = []
clock.on(r => seen.push(r.event.type)) clock.on(r => seen.push(r.type))
clock.play() clock.play()
s.advance(150) // past offset 100, between 100 and 350 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 s = fakeScheduler()
const clock = new PlaybackClock(envelope(), s) const clock = new PlaybackClock(envelope(), s)
const seen = [] 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() clock.play()
s.advance(150) s.advance(150)
@ -171,7 +173,7 @@ describe('PlaybackClock — seek determinism', () => {
const s = fakeScheduler() const s = fakeScheduler()
const clock = new PlaybackClock(envelope(), s) const clock = new PlaybackClock(envelope(), s)
const seen = [] const seen = []
clock.on(r => seen.push(r.event.type)) clock.on(r => seen.push(r.type))
clock.play() clock.play()
s.advance(50) // only offset-0 delivered so far 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. // Default deps ⇒ Date.now + global setTimeout, both faked by vi.
const clock = new PlaybackClock(envelope()) const clock = new PlaybackClock(envelope())
const seen = [] const seen = []
clock.on(r => seen.push(r.event.type)) clock.on(r => seen.push(r.type))
clock.play() clock.play()
expect(seen).toEqual(['reveal']) // offset 0 immediate 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. * A dummy adapter defined HERE, in the test the engine interprets nothing.
* @typedef {{ type: string }} DummyEvent * @typedef {{ type: string }} DummyEvent
* @type {import('@cozy-games/replay').ProgressReducer<DummyEvent>} * @type {import('@cozy-games/replay').ProgressReducer}
*/ */
const byCount = events => (events.length / 3) * 100 // 3 = total in envelope() 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 pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
const GAME_REFERENCES = /mnswpr|minesweeper/i 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 = [] const offenders = []
walk(pkgDir, file => { walk(pkgDir, file => {
if (!file.endsWith('.js') || file.includes('/test/')) return if (!file.endsWith('.js') || file.includes('/test/')) return
const code = readFileSync(file, 'utf8') const code = readFileSync(file, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '') .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', () => { it('manifest depends only on the envelope, never a game package', () => {

View file

@ -3,6 +3,8 @@ import { describe, it, expect } from 'vitest'
import { PlaybackClock } from '@cozy-games/replay' import { PlaybackClock } from '@cozy-games/replay'
import { createMoveLog } from '@cozy-games/move-log' 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 // A real mnswpr run + its reducer — imported by the TEST via relative paths, so
// no game dependency enters the replay engine's manifest. // no game dependency enters the replay engine's manifest.
import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js' 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 }) truthPoints.push({ offset: step.at - baseT, revealedSafe: session.state.revealedSafe })
} }
const records = emitted.map(e => ({ seq: e.seq, t: e.t, event: e })) const records = emitted.map(e => ({ seq: e.seq, clientTs: e.t, type: e.type, payload: { r: e.r, c: e.c } }))
const envelope = createMoveLog(records) const envelope = createMoveLog(VERSION, records)
// Ground truth for the mnswpr reducer: revealedSafe / total at a given offset — // Ground truth for the mnswpr reducer: revealedSafe / total at a given offset —
// derived from the session, NOT from the reducer under test. // derived from the session, NOT from the reducer under test.
@ -90,7 +92,7 @@ function mnswprTruth(offset) {
const totalEvents = records.length const totalEvents = records.length
const dummyReduce = events => (events.length / totalEvents) * 100 const dummyReduce = events => (events.length / totalEvents) * 100
function dummyTruth(offset) { 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 = [ const CASES = [
@ -99,7 +101,7 @@ const CASES = [
] ]
describe.each(CASES)('progress mode — same code path, adapter: $name', ({ adapter, truth }) => { 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)', () => { it('progress() matches the source run at multiple points (via seek)', () => {
const clock = new PlaybackClock(envelope, fakeScheduler(), adapter) const clock = new PlaybackClock(envelope, fakeScheduler(), adapter)

View file

@ -3,6 +3,8 @@ import { describe, it, expect } from 'vitest'
import { PlaybackClock, readEnvelope } from '@cozy-games/replay' import { PlaybackClock, readEnvelope } from '@cozy-games/replay'
import { createMoveLog } from '@cozy-games/move-log' import { createMoveLog } from '@cozy-games/move-log'
const VERSION = 'mnswpr-moves/1'
function fakeScheduler(start = 0) { function fakeScheduler(start = 0) {
let now = start let now = start
let nextId = 1 let nextId = 1
@ -32,40 +34,55 @@ function fakeScheduler(start = 0) {
} }
} }
// A canonical v1 envelope. // A canonical `@cozy-games/move-log` envelope.
const v1 = () => createMoveLog([ const canonical = () => createMoveLog(VERSION, [
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } }, { seq: 1, clientTs: 0, type: 'reveal', payload: { r: 0, c: 0 } },
{ seq: 2, t: 100, event: { type: 'flag', r: 1, c: 1 } }, { seq: 2, clientTs: 100, type: 'flag', payload: { r: 1, c: 1 } },
{ seq: 3, t: 250, event: { type: 'chord', r: 2, c: 2 } } { seq: 3, clientTs: 250, type: 'chord', payload: { r: 2, c: 2 } }
]) ])
describe('schema_version dispatch — v1 (built-in)', () => { describe('canonical move-log envelopes play directly', () => {
it('replays a v1 envelope through the dispatch path (not a bypass)', () => { it('replays a canonical envelope through readEnvelope (not a bypass)', () => {
const s = fakeScheduler() const s = fakeScheduler()
const clock = new PlaybackClock(v1(), s) const clock = new PlaybackClock(canonical(), s)
const seen = [] const seen = []
clock.on(r => seen.push(r.event.type)) clock.on(r => seen.push(r.type))
clock.play() clock.play()
s.advance(250) s.advance(250)
expect(seen).toEqual(['reveal', 'flag', 'chord']) expect(seen).toEqual(['reveal', 'flag', 'chord'])
}) })
it('readEnvelope returns the canonical records for v1', () => { it('readEnvelope returns the canonical records', () => {
const records = readEnvelope(v1()) const records = readEnvelope(canonical())
expect(records.map(r => r.seq)).toEqual([1, 2, 3]) 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', () => { describe('malformed envelopes fail loudly', () => {
it('throws a specific error for a synthetic future version (99)', () => { it('throws for a non-string schema_version', () => {
const future = { schema_version: 99, events: [{ seq: 1, t: 0, event: {} }] } const bad = { schema_version: 99, events: [{ seq: 1, clientTs: 0, type: 'x', payload: {} }] }
expect(() => new PlaybackClock(future, fakeScheduler())).toThrow(/unsupported envelope schema_version 99 \(supported: 1\)/) expect(() => new PlaybackClock(/** @type {any} */ (bad), fakeScheduler())).toThrow(TypeError)
expect(() => readEnvelope(future)).toThrow(RangeError) expect(() => readEnvelope(bad)).toThrow(/schema_version must be a non-empty string/)
}) })
it('throws for a missing schema_version', () => { 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', () => { 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', () => { describe('foreign generations normalize via a supplied reader (keyed by version string)', () => {
// A toy v2 format defined ENTIRELY in the test: a different field layout that a // A toy FOREIGN format defined ENTIRELY in the test: a different field layout
// normalizer maps back to canonical { seq, t, event }. Adding it is one entry. // 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 = { const v2Envelope = {
schema_version: 2, schema_version: 'toy-v2/1',
log: [ log: [
{ n: 1, ts: 0, payload: { type: 'reveal', r: 0, c: 0 } }, { n: 1, ts: 0, kind: 'reveal', data: { r: 0, c: 0 } },
{ n: 2, ts: 120, payload: { type: 'flag', r: 1, c: 1 } } { 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 s = fakeScheduler()
const clock = new PlaybackClock(v2Envelope, s, {}, { readers: { 2: readV2 } }) const clock = new PlaybackClock(v2Envelope, s, {}, { readers: { 'toy-v2/1': readV2 } })
const seen = [] const seen = []
clock.on(r => seen.push(r.event.type)) clock.on(r => seen.push(r.type))
clock.play() clock.play()
s.advance(120) s.advance(120)
expect(seen).toEqual(['reveal', 'flag']) expect(seen).toEqual(['reveal', 'flag'])
}) })
it('readEnvelope normalizes v2 to canonical records with an extra reader', () => { it('readEnvelope normalizes the foreign format to canonical records', () => {
const records = readEnvelope(v2Envelope, { 2: readV2 }) const records = readEnvelope(v2Envelope, { 'toy-v2/1': readV2 })
expect(records).toEqual([ expect(records).toEqual([
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } }, { seq: 1, clientTs: 0, type: 'reveal', payload: { r: 0, c: 0 } },
{ seq: 2, t: 120, event: { type: 'flag', r: 1, c: 1 } } { seq: 2, clientTs: 120, type: 'flag', payload: { r: 1, c: 1 } }
]) ])
}) })
it('still rejects v2 when no reader is registered', () => { it('fails loudly for the foreign format when no reader is registered', () => {
expect(() => readEnvelope(v2Envelope)).toThrow(/unsupported envelope schema_version 2 \(supported: 1\)/) // 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', () => { it('validates a reader that returns a malformed (non-canonical) log', () => {
// A buggy reader whose output breaks the monotonic-seq invariant is caught. // A buggy reader whose output breaks the monotonic-seq invariant is caught.
const badReader = () => [{ seq: 2, t: 0, event: {} }, { seq: 1, t: 1, event: {} }] const badReader = () => [
expect(() => readEnvelope({ schema_version: 3 }, { 3: badReader })).toThrow(RangeError) { 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)
}) })
}) })

View file

@ -3,7 +3,6 @@
"version": "0.0.1", "version": "0.0.1",
"description": "Shared, dependency-free browser utilities for Cozy Games (storage, timer, logger, loading, date buckets)", "description": "Shared, dependency-free browser utilities for Cozy Games (storage, timer, logger, loading, date buckets)",
"author": "Ayo Ayco", "author": "Ayo Ayco",
"private": true,
"type": "module", "type": "module",
"repository": { "repository": {
"type": "git", "type": "git",