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