chore: bump packages for release

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

View file

@ -114,6 +114,7 @@ The **Firebase config in `leader-board.js` is intentionally public and committed
- **Code style is enforced by ESLint Stylistic**, not Prettier: 2-space indent, single quotes, **no semicolons**, no trailing commas, spaces inside `{ braces }` but not `[brackets]`. Run `pnpm lint:fix` before committing. Both `**/*.js` and `**/*.css` are linted (CSS via `@eslint/css`).
- 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)

View file

@ -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';

View file

@ -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)

View file

@ -1,3 +1,17 @@
/**
* A completed-game entry offered to the leaderboard. `score` is the ranked value
* (sorted per the configured order); `category` selects the board; `time_stamp`
* is denormalized into day/week/month buckets on write.
*
* @typedef {Object} ScoreEntry
* @property {number} score
* @property {string} category
* @property {string} playerId
* @property {Date | number | string} time_stamp
* @property {string} [name] - display name; defaults to 'Anonymous'
* @property {string} [status] - outcome the default qualifier checks against `config.passingStatus`
* @property {Object} [meta] - optional extra fields carried through to storage
*/
/**
* The WRITE surface of the leaderboard: submitting a completed game the
* 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;
};

View file

@ -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) {

View file

@ -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",

View file

@ -1,18 +1,36 @@
/**
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
* A recorded mnswpr move-log entry the `@cozy-games/move-log` / ADR-002 §1
* envelope shape as instantiated by Minesweeper: a surfaced `type` discriminator
* plus an opaque `payload` carrying the move's `{ r, c }`. This is what the replay
* engine hands the reducers (the only fields they read).
*
* @typedef {{ type: string, payload: { r: number, c: number } }} MnswprRecord
*/
/**
* Map a recorded move-event back to the rules move that produced it: `flag` and
* `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;
};
};

View file

@ -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
}
}

View file

@ -1,5 +1,5 @@
/**
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
* @typedef {import('./replay-common.js').MnswprRecord} MnswprRecord
* @typedef {import('../core/minesweeper/board.js').Layout} Layout
*/
/**
@ -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;

View file

@ -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

View file

@ -1,14 +1,14 @@
/**
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
* @typedef {import('./replay-common.js').MnswprRecord} MnswprRecord
* @typedef {import('../core/minesweeper/board.js').Layout} Layout
* @typedef {{ 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;

View file

@ -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)

View file

@ -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;
/**

View file

@ -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
}

View file

@ -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)) {

View file

@ -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"];
/**
* The GameRules contract consumed by GameSession/replay: init / apply / status /
* project, plus serialize / deserialize for snapshotting. Deterministic and
* DOM-free.
*/
export const MinesweeperRules: any;
export namespace MinesweeperRules {
/**
* @param {number} seed
* @param {Config} config
* @returns {State}
*/
export function init(seed: number, config: Config): State;
/**
* Build a game state from an explicit, pre-built layout (as returned by
* `generateBoard`) instead of generating one from a seed. Parallel to
* {@link init}: it yields a `State` a `GameSession` can drive identically
* same rules, same transitions the only difference being that the board is
* fixed up front, so the opening reveal is NOT made safe (first-click safety is
* a property of internal generation, not of a caller-supplied board). The
* layout is validated first and a malformed one throws.
*
* @param {import('./board.js').Layout} layout
* @param {{ seed?: number }} [opts] - seed is metadata only (no generation happens); defaults to 0
* @returns {State}
*/
export function fromLayout(layout: import("./board.js").Layout, { seed }?: {
seed?: number;
}): State;
/** @param {State} state @returns {Phase} */
export function status(state: State): Phase;
/**
* Fold a move into the state. Terminal states are absorbing.
* @param {State} state
* @param {Move} move
* @returns {{ state: State, events: Event[] }}
*/
export function apply(state: State, move: Move): {
state: State;
events: Event[];
};
export { project };
/**
* Classify an applied move into a typed move-event kind, or `null` if the move
* was a no-op (the rules produced no events e.g. clicking a revealed cell).
* The session stamps the returned `{ type, r, c }` with `t` and `seq`. This is
* the seam that turns a raw `Move` into the {@link MoveEvent} vocabulary and,
* critically, splits a `flag` move into `flag`/`unflag` by its outcome.
*
* @param {Move} move
* @param {Event[]} events - the rules events this move produced
* @returns {{ type: MoveEventType, r: number, c: number } | null}
*/
export function toMoveEvent(move: Move, events: Event[]): {
type: MoveEventType;
r: number;
c: number;
} | null;
/**
* Snapshot a game state as a plain, JSON-safe object: the whole board (every
* cell's mine/adjacent/status, via the Layer-0 grid serializer) plus phase and
* progress. Inverse of {@link deserialize}. The `grid` instance is the only
* non-JSON-safe field of `State`; everything else (seed, config, flags) is
* already plain data.
*
* @param {State} state
* @returns {Snapshot}
*/
export function serialize(state: State): Snapshot;
/**
* Rebuild a game state from {@link serialize} output (or its JSON round-trip).
* Cells are cloned so the revived state shares no references with the snapshot.
*
* @param {Snapshot} snap
* @returns {State}
*/
export function deserialize(snap: Snapshot): State;
}
/**
* Minesweeper as a pure, deterministic state machine no DOM, no wall clock.
* `GameSession` (Layer 1) drives it; the client renders the events it emits.
@ -78,6 +146,42 @@ export type Move = {
* `GameSession` (Layer 1) drives it; the client renders the events it emits.
*/
export type Event = object;
/**
* Minesweeper as a pure, deterministic state machine no DOM, no wall clock.
* `GameSession` (Layer 1) drives it; the client renders the events it emits.
*/
export type Snapshot = {
seed: number;
config: Config;
phase: Phase;
minesPlaced: boolean;
revealedSafe: number;
grid: {
rows: number;
cols: number;
cells: Cell[];
};
};
/**
* Minesweeper as a pure, deterministic state machine no DOM, no wall clock.
* `GameSession` (Layer 1) drives it; the client renders the events it emits.
*/
export type ProjectedCell = {
r: number;
c: number;
status: "revealed";
adjacent: number;
mine: boolean;
} | {
r: number;
c: number;
status: "flagged";
} | {
r: number;
c: number;
status: "hidden";
mine: true;
};
/**
* The typed move-event vocabulary emitted by the session (one per effective
* move). This is the game's public event language consumed later by the shared
@ -104,4 +208,19 @@ export type MoveEvent = {
t: number;
seq: number;
};
/**
* Project full state down to what a client is allowed to know: revealed cells
* (+ their adjacency), flags, and only once the game is over the mines. An
* unrevealed mine is NEVER included mid-game, so this is safe to send over a wire
* (invariant #3). Hidden, unrevealed, non-mine cells are simply omitted.
*
* @param {State} state
* @returns {{ config: Config, phase: Phase, cells: ProjectedCell[] }}
*/
declare function project(state: State): {
config: Config;
phase: Phase;
cells: ProjectedCell[];
};
import { Grid } from '../grid/grid.js';
export {};

View file

@ -15,6 +15,8 @@ import { floodReveal, countFlagsAround, allMines } from './reveal.js'
* @typedef {{ seed: number, config: Config, grid: Grid<Cell>, phase: Phase, minesPlaced: boolean, revealedSafe: number }} State
* @typedef {{ 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) {

View file

@ -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",

View file

@ -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

View file

@ -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.

View file

@ -0,0 +1,41 @@
# Changelog
## 0.1.0 — envelope realigned to ADR-002 §1 (BREAKING, pre-freeze)
**Intentional breaking change.** Safe now because no production logs have been
recorded yet (the downstream consumer, cozy-platform, ships dark pre-cutover).
Once real games write logs the format is permanent, so this had to land first.
The envelope now matches cozy-games **ADR-002 §1** and the cozy-platform
implementation that proved it (S0-102 round-trip replay) — **while keeping the
generics** that let a second game reuse the package untouched:
- **Entry shape:** `{ seq, t, event: T }``{ seq, clientTs, type, payload, receivedTs? }`.
The game event splits into the ADR's surfaced `type` **discriminator** plus an
OPAQUE `payload`. (`t` → `clientTs`.) `receivedTs?` is unchanged (optional, additive).
- **Generics kept & strengthened:** `MoveEvent<T>` / `MoveLog<T>`
`MoveEvent<TType extends string = string, TPayload = unknown>` /
`MoveLog<TType extends string = string, TPayload = unknown>`. Minesweeper
instantiates `MoveLog<'reveal'|'flag'|'unflag'|'chord', { r, c }>`; a second game
supplies its own params with no package change. The defaults give game-blind
code the erased (`string` / `unknown`) form. Shipped as generic `.d.ts`.
- **`schema_version`:** package-owned number `1` → a **caller-supplied string**
naming the game's move-event vocabulary (e.g. `"mnswpr-moves/1"`), carried
verbatim (ADR §2). The `SCHEMA_VERSION` export and the numeric container-version
concept are removed.
- **`createMoveLog(schemaVersion, events?)`** now takes the vocabulary version as
its first argument.
- **`assertMoveLog`** enforces the new invariants (non-empty string
`schema_version`; integer strictly-increasing `seq`; finite `clientTs`;
non-empty string `type`; **present** `payload` — any value, never inspected;
finite `receivedTs` when present) and still rejects without mutating.
Helpers (`isMoveLog`, `serializeMoveLog`, `deserializeMoveLog`, `withReceivedTs`)
are kept and adapted; the JSON round-trip stays lossless. The package remains
game-agnostic (imports no game types, never inspects `payload`, treats `type` as
an opaque non-empty string).
## 0.0.1
Initial workspace release: `{ seq, t, event }` entries with a numeric,
package-owned `schema_version: 1`.

View file

@ -1,62 +1,112 @@
# @cozy-games/move-log
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.

View file

@ -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;

View file

@ -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') {

View file

@ -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",

View file

@ -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))

View file

@ -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)
// A foreign reader MUST normalize to canonical records; validate them under
// the source version so a half-normalized generation can't reach the engine.
assertMoveLog({ schema_version: version, events: records })
return records
}
const records = read(envelope)
// Every reader MUST normalize to canonical move-log records; enforce it here so
// no downstream generation can feed the engine a malformed or half-normalized log.
assertMoveLog({ schema_version: SCHEMA_VERSION, events: records })
return records
// Default: the envelope itself must be a well-formed canonical move log.
return assertMoveLog(envelope).events
}
export class PlaybackClock {
/**
* @param {Envelope} envelope - a valid move-log envelope (validated here)
* @param {Deps} [deps] - injected time source + scheduler (default: real host)
* @param {ReplayAdapter<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}

View file

@ -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', () => {

View file

@ -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()

View file

@ -42,16 +42,18 @@ function fakeScheduler(start = 0) {
}
}
// Events at offsets 0, 100, 350 (t rebased from 1000). Payload is opaque to the clock.
const VERSION = 'dummy-game/1'
// Events at offsets 0, 100, 350 (clientTs rebased from 1000). Payload is opaque to the clock.
function envelope() {
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', () => {

View file

@ -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)

View file

@ -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)
})
})

View file

@ -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",