Compare commits

...

28 commits

Author SHA1 Message Date
Ayo
3418be3d7b chore: move to @cozy-games/mnswpr 2026-07-06 00:11:22 +02:00
Ayo
2d7f7d0ebb chore: build scripts 2026-07-06 00:09:02 +02:00
Ayo
41829086c5 chore: fix typo in readme :) 2026-07-05 13:18:14 +02:00
Ayo
17724ebe8f chore: update README note on project evolution 2026-07-05 13:17:19 +02:00
Ayo
cc44789829 refactor: use wcb v5 createElement-safe static props 2026-07-05 13:03:08 +02:00
Ayo
9079ab0848 refactor(leaderboard-element): wcb usage improvements 2026-07-05 06:43:12 +02:00
Ayo
26277986a0 refactor(leaderboard-element): use web-component-base APIs 2026-07-04 14:39:15 +02:00
Ayo
c2ffb0d7f6 feat(leaderboard): read-write separation 2026-07-04 13:54:03 +02:00
Ayo
cd2b30ecc8 feat(leaderboard): bring your own backend 2026-07-04 13:45:57 +02:00
Ayo
1ca1d63ea7 chore: update readme 2026-07-04 13:42:45 +02:00
Ayo
0b9866e05f feat(replay): add a defined "ended" signal (onEnd) 2026-07-04 13:39:48 +02:00
Ayo
43b75d7b0e feat(replay): schema dispatch 2026-07-04 13:34:32 +02:00
Ayo
998aeacbb2 feat(replay): state reducer & full board playback 2026-07-04 13:29:52 +02:00
Ayo
072d2f7369 feat(replay): progress mode 2026-07-04 13:21:48 +02:00
Ayo
6b38088d4b feat(mnswpr): progress reducer adapter 2026-07-04 13:17:40 +02:00
Ayo
6f9a8031e9 feat(replay): progress reducer interface 2026-07-04 13:06:58 +02:00
Ayo
bafe1db285 feat(replay): replay engine 2026-07-04 12:56:29 +02:00
Ayo
0dab463e84 chore: update readme with note & package status 2026-07-04 12:36:23 +02:00
Ayo
4ccec5a116 feat(move-log): accept timestamps 2026-07-04 12:34:16 +02:00
Ayo
e1c168a9bb feat(move-log): serialization 2026-07-04 12:29:47 +02:00
Ayo
3bfb1931cc chore: update readme :) 2026-07-04 12:23:31 +02:00
Ayo
dd9e020214 chore: update packages status on readme 2026-07-04 12:21:59 +02:00
Ayo
6c90cde0f9 feat(move-log): game-agnostic move events 2026-07-04 12:21:06 +02:00
Ayo
0823d43c75 feat(core): move events 2026-07-04 12:02:10 +02:00
Ayo
d3d84aa9b8 feat(core): resume games 2026-07-04 11:52:38 +02:00
Ayo
d482b17976 feat(core): serializeable game state 2026-07-04 11:48:08 +02:00
Ayo
8099820e79 feat(core): board injection 2026-07-04 11:39:55 +02:00
Ayo
916d04d0d9 feat(mnswpr): first-move safety 2026-07-04 11:32:01 +02:00
49 changed files with 5052 additions and 415 deletions

View file

@ -4,7 +4,7 @@ Guidance for AI coding agents working in this repository.
## What this is ## What this is
Classic Minesweeper as a vanilla web game — no framework, no TypeScript (JSDoc + `// @ts-check` only). Deployed at [mnswpr.com](https://mnswpr.com) (Netlify) and published to npm as `@ayo-run/mnswpr`. The game engine has **zero runtime dependencies**; only the website adds Firebase. Classic Minesweeper as a vanilla web game — no framework, no TypeScript (JSDoc + `// @ts-check` only). Deployed at [mnswpr.com](https://mnswpr.com) (Netlify) and published to npm as `@cozy-games/mnswpr`. The game engine has **zero runtime dependencies**; only the website adds Firebase.
**`mnswpr` is the main test app.** It's the reference app for the monorepo and the default target for local runs — `.claude/launch.json` launches it (`dev` on :5173, `preview` on :4173), and it's what you should build/run/preview when verifying changes to the shared packages or tooling. **`mnswpr` is the main test app.** It's the reference app for the monorepo and the default target for local runs — `.claude/launch.json` launches it (`dev` on :5173, `preview` on :4173), and it's what you should build/run/preview when verifying changes to the shared packages or tooling.
@ -79,8 +79,8 @@ Node version: `.nvmrc` pins `lts/*`.
This is the **Cozy Games** monorepo. Workspaces are declared in `pnpm-workspace.yaml` as `apps/*`, `packages/*`, and `sites/*`. `utils/` is now a real workspace package (`@cozy-games/utils`), imported by name — no more `../utils` relative paths. This is the **Cozy Games** monorepo. Workspaces are declared in `pnpm-workspace.yaml` as `apps/*`, `packages/*`, and `sites/*`. `utils/` is now a real workspace package (`@cozy-games/utils`), imported by name — no more `../utils` relative paths.
- **`apps/mnswpr/`** — package `mnswpr`, `@ayo-run/mnswpr`'s host, the mnswpr.com website. Consumes the engine and leaderboard via `workspace:*` (`import mnswpr from '@ayo-run/mnswpr/mnswpr.js'`) and wires them together in `apps/mnswpr/main.js`. Owns its Firebase config (`firebase.json`, `firestore.rules`, `.firebaserc`) and app-specific scripts (`apps/mnswpr/scripts/`). A future app (e.g. sudoku) gets its own `apps/<name>/` and its `package.json` `name` is just the app name (`<name>`, unscoped) so it's addressable directly by name (`pnpm -F <name> run <script>`). - **`apps/mnswpr/`** — package `mnswpr`, `@cozy-games/mnswpr`'s host, the mnswpr.com website. Consumes the engine and leaderboard via `workspace:*` (`import mnswpr from '@cozy-games/mnswpr/mnswpr.js'`) and wires them together in `apps/mnswpr/main.js`. Owns its Firebase config (`firebase.json`, `firestore.rules`, `.firebaserc`) and app-specific scripts (`apps/mnswpr/scripts/`). A future app (e.g. sudoku) gets its own `apps/<name>/` and its `package.json` `name` is just the app name (`<name>`, unscoped) so it's addressable directly by name (`pnpm -F <name> run <script>`).
- **`packages/mnswpr/`** — `@ayo-run/mnswpr`, the standalone, framework-free game engine published to npm. `packages/mnswpr/mnswpr.js` is the whole engine; `levels.js` defines the four difficulty presets. Depends only on `@cozy-games/utils`. - **`packages/mnswpr/`** — `@cozy-games/mnswpr`, the standalone, framework-free game engine published to npm. `packages/mnswpr/mnswpr.js` is the whole engine; `levels.js` defines the four difficulty presets. Depends only on `@cozy-games/utils`.
- **`packages/leaderboard/`** — `@cozy-games/leaderboard`, a backend-agnostic, time-windowed leaderboard (adapter-injected storage). - **`packages/leaderboard/`** — `@cozy-games/leaderboard`, a backend-agnostic, time-windowed leaderboard (adapter-injected storage).
- **`packages/utils/`** — `@cozy-games/utils`, shared services with no dependencies, re-exported from `index.js`: `StorageService`, `TimerService` (`pretty()` time formatting used by both engine and leaderboard), `LoggerService`, `LoadingService`, and date-bucket helpers. - **`packages/utils/`** — `@cozy-games/utils`, shared services with no dependencies, re-exported from `index.js`: `StorageService`, `TimerService` (`pretty()` time formatting used by both engine and leaderboard), `LoggerService`, `LoadingService`, and date-bucket helpers.
- **`sites/`** — docs (Astro Starlight) and UI demos. Placeholders for now. - **`sites/`** — docs (Astro Starlight) and UI demos. Placeholders for now.

View file

@ -2,6 +2,9 @@
A growing collection of small browser games and the shared, reusable packages that power them. A growing collection of small browser games and the shared, reusable packages that power them.
> [!Note]
> This repo was originally for [mnswpr](https://mnswpr.com) (see its [README](apps/mnswpr/README.md)) which has been evolved in *2026* to understand AI-assisted development. The purpose of mnswpr has always included understanding the web development landscape and this has changed significantly with the rise of LLMs.
# Roadmap # Roadmap
- **Public APIs** — game-agnostic modules (core, move-log, replay, leaderboard, rating) built inside the first game. - **Public APIs** — game-agnostic modules (core, move-log, replay, leaderboard, rating) built inside the first game.
@ -11,14 +14,14 @@ A growing collection of small browser games and the shared, reusable packages th
## Packages ## Packages
| Package | State | Published | Documented | | Package | Develop | Publish | Document |
| -------------------- | -------------- | --------- | ---------- | | -------------------- | -------------- | ------- | -------- |
| `mnswpr` (game core) | 🚧 Development | | | | `mnswpr` (game core) | ✅ Built | | |
| leaderboard | ✅ Built | | | | leaderboard | ✅ Built | | |
| move-log envelope | 🔮 Planned | | | | move-log | ✅ Built | | |
| replay engine | 🔮 Planned | | | | replay engine | ✅ Built | | |
| rating math | 🔮 Planned | | | | rating math | 🚧 Development | | |
| `sudoku` (game core) | 🔮 Planned | | | | `sudoku` (game core) | 🔮 Planned | | |
> Note: `@ayo-run/mnswpr` on npm predates this project and will be deprecated in favor of `cozy-games/mnswpr`. > Note: `@ayo-run/mnswpr` on npm predates this project and will be deprecated in favor of `cozy-games/mnswpr`.

View file

@ -20,14 +20,14 @@ The goal is to reveal every safe cell without detonating a mine. Your **first cl
The web is a wonderful, free, and open platform to create and distribute value. You can use **mnswpr** in different ways: The web is a wonderful, free, and open platform to create and distribute value. You can use **mnswpr** in different ways:
- as a deployed [web app](https://mnswpr.com) - as a deployed [web app](https://mnswpr.com)
- as a [library](https://npmx.dev/package/@ayo-run/mnswpr) with `npm i @ayo-run/mnswpr` - as a [library](https://npmx.dev/package/@cozy-games/mnswpr) with `npm i @cozy-games/mnswpr`
- as a `web component` (coming soon). - as a `web component` (coming soon).
Using it as a library takes only a few lines — mount it onto any element by `id`: Using it as a library takes only a few lines — mount it onto any element by `id`:
```js ```js
import '@ayo-run/mnswpr/mnswpr.css' import '@cozy-games/mnswpr/mnswpr.css'
import mnswpr from '@ayo-run/mnswpr' import mnswpr from '@cozy-games/mnswpr'
const game = new mnswpr('app') const game = new mnswpr('app')
game.initialize() game.initialize()

View file

@ -1,7 +1,7 @@
import mnswpr from '@ayo-run/mnswpr/mnswpr.js' import mnswpr from '@cozy-games/mnswpr/mnswpr.js'
import '@ayo-run/mnswpr/mnswpr.css' import '@cozy-games/mnswpr/mnswpr.css'
import '@cozy-games/utils/loading/loading.css' import '@cozy-games/utils/loading/loading.css'
import * as pkg from '@ayo-run/mnswpr/package.json' import * as pkg from '@cozy-games/mnswpr/package.json'
import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js' import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js' import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
import { NicknameService } from './modules/nickname/nickname.js' import { NicknameService } from './modules/nickname/nickname.js'

View file

@ -18,13 +18,13 @@
"db:stop": "pkill -f '[c]loud-firestore-emulator'; pkill -f '[f]irebase.* emulators:'; true" "db:stop": "pkill -f '[c]loud-firestore-emulator'; pkill -f '[f]irebase.* emulators:'; true"
}, },
"devDependencies": { "devDependencies": {
"@ayo-run/mnswpr": "workspace:*", "@cozy-games/mnswpr": "workspace:*",
"@cozy-games/leaderboard": "workspace:*", "@cozy-games/leaderboard": "workspace:*",
"@cozy-games/utils": "workspace:*", "@cozy-games/utils": "workspace:*",
"firebase": "^12.11.0", "firebase": "^12.11.0",
"firebase-tools": "^15.22.4", "firebase-tools": "^15.22.4",
"netlify-cli": "^26.1.0", "netlify-cli": "^26.1.0",
"web-component-base": "^4.1.2" "web-component-base": "^5.0.0"
}, },
"author": "Ayo Ayco" "author": "Ayo Ayco"
} }

View file

@ -17,7 +17,7 @@ import {
getFirestore, getDocs, collection, query, orderBy, limit getFirestore, getDocs, collection, query, orderBy, limit
} from 'firebase/firestore/lite' } from 'firebase/firestore/lite'
import { levels } from '@ayo-run/mnswpr/levels.js' import { levels } from '@cozy-games/mnswpr/levels.js'
// Mirror of TimerService.pretty() (@cozy-games/utils timer) — inlined so this // Mirror of TimerService.pretty() (@cozy-games/utils timer) — inlined so this
// generator has no cross-module import chain to resolve under raw Node. // generator has no cross-module import chain to resolve under raw Node.

View file

@ -3,7 +3,7 @@
**Status:** Accepted · **Repo:** cozy-games · **Date:** 2026-07 **Status:** Accepted · **Repo:** cozy-games · **Date:** 2026-07
## Context ## Context
cozy-games is a collection of reusable game modules (`@ayo-run/mnswpr`, the cozy-games is a collection of reusable game modules (`@cozy-games/mnswpr`, the
leaderboard package, future extractions). Applications built on these packages leaderboard package, future extractions). Applications built on these packages
differ in how they store data, authenticate users, and enforce rules. differ in how they store data, authenticate users, and enforce rules.

View file

@ -14,9 +14,10 @@
"test": "vitest run", "test": "vitest run",
"dev": "pnpm -F mnswpr dev", "dev": "pnpm -F mnswpr dev",
"test:watch": "vitest", "test:watch": "vitest",
"build": "pnpm -r --filter \"./packages/*\" run build",
"build:lib": "vite build packages/mnswpr", "build:lib": "vite build packages/mnswpr",
"publish:lib": "node scripts/publish-lib.js", "publish:lib": "node scripts/publish-lib.js",
"release": "pnpm build:lib && pnpm -F @ayo-run/mnswpr run release && pnpm publish:lib", "release": "pnpm build:lib && pnpm -F @cozy-games/mnswpr run release && pnpm publish:lib",
"postinstall": "node scripts/ensure-java.mjs", "postinstall": "node scripts/ensure-java.mjs",
"prepare": "husky", "prepare": "husky",
"lint": "eslint .", "lint": "eslint .",

View file

@ -103,7 +103,8 @@ mounted elements.
| `format` | `time` \| `number` \| `plain` | score display preset (below) | | `format` | `time` \| `number` \| `plain` | score display preset (below) |
Attributes are reactive: change `category`/`title` at runtime and the board Attributes are reactive: change `category`/`title` at runtime and the board
re-renders, keeping the selected duration tab. re-renders, keeping the selected duration tab. Changing `score-order`/`format`
rebuilds the element's service so the new order/preset takes effect.
### `format` presets ### `format` presets
@ -230,5 +231,6 @@ The package computes the `day`/`week`/`month` bucket keys from `time_stamp`
The service is cached per element, so set overrides **before** the element The service is cached per element, so set overrides **before** the element
connects (or set the property and clear the element's `_svc` to force a connects (or set the property and clear the element's `_svc` to force a
rebuild). `score-order` and `format` are read from attributes; the function/ rebuild — changing the `score-order`/`format` attributes does this
array/string overrides are read from properties. automatically). `score-order` and `format` are read from attributes; the
function/array/string overrides are read from properties.

View file

@ -147,13 +147,63 @@ Want to author your own custom elements this way? Check out
**[webcomponent.io](https://webcomponent.io)** and **[webcomponent.io](https://webcomponent.io)** and
**[web-component-base](https://github.com/ayo-run/wcb)**. **[web-component-base](https://github.com/ayo-run/wcb)**.
## Separable read & write surfaces
The service is composed of two independently importable halves, so you never pull
in code you don't use — and can point each half at a differently-privileged
backend instance:
| Surface | Import | Uses | Adapter methods |
| ------- | ------ | ---- | --------------- |
| **Read / subscribe** | `@cozy-games/leaderboard/leaderboard-read.js``LeaderBoardReader` | `render()` — query a window + render the list | `listScores` |
| **Write** | `@cozy-games/leaderboard/leaderboard-write.js``LeaderBoardWriter` | `submit()` — archive + ranked entry | `addScore`, optional `archive`, `getConfig` |
```js
// Read-only page (public, less-privileged instance) — no write code loaded:
import { LeaderBoardReader } from '@cozy-games/leaderboard/leaderboard-read.js'
const board = new LeaderBoardReader({ adapter: readAdapter, formatScore })
document.body.append(await board.render('beginner', 'Best Times'))
// Server / trusted path (privileged instance) — no DOM or render code loaded:
import { LeaderBoardWriter } from '@cozy-games/leaderboard/leaderboard-write.js'
const writer = new LeaderBoardWriter({ adapter: writeAdapter })
await writer.submit(entry)
```
The read module imports **no** write-path code (no bucket-key computation, no
write adapter calls) and the write module imports **no** read/render code (no
DOM, no `listScores`) — which also keeps each surface trivial to test in
isolation. `LeaderBoardService` (and `<cozy-leaderboard>`) remain the combined
facade — same `render()` + `submit()` API — for consumers that want both; it just
composes a `LeaderBoardReader` and a `LeaderBoardWriter` (exposed as `.reader` /
`.writer`).
## Choosing a backend ## Choosing a backend
### Bring your own backend instance (injection)
Both adapters let the **consumer own the backend instance** — including a
privileged/admin-level or server-side one — rather than the package creating its
own. This is the injection point per adapter:
| Adapter | Injection point | Internal init fallback |
| ---------- | -------------------------- | ----------------------------------- |
| Supabase | `client` (a supabase-js client you build) — **always** consumer-supplied; the package takes no supabase dependency | none — a client is required |
| Firebase | `store` (a Firestore instance you build) | `firebaseConfig` → the package initializes its own app |
Supply a privileged instance and every read/write runs against it — the package
adds no auth or app lifecycle of its own.
### Firebase (Firestore) ### Firebase (Firestore)
```js ```js
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js' import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
// (a) let the package initialize from a public config:
const adapter = new FirebaseAdapter({ firebaseConfig, namespace: 'mw' }) const adapter = new FirebaseAdapter({ firebaseConfig, namespace: 'mw' })
// (b) OR inject a Firestore instance you built (e.g. privileged/server-side):
const adapter = new FirebaseAdapter({ store: myFirestore, namespace: 'mw' })
``` ```
Needs the `firebase` peer dependency. Uses collections Needs the `firebase` peer dependency. Uses collections
@ -162,9 +212,10 @@ Needs the `firebase` peer dependency. Uses collections
and all-time sorts by `score`, so only Firestore's automatic single-field indexes and all-time sorts by `score`, so only Firestore's automatic single-field indexes
are needed — no composite indexes to deploy. are needed — no composite indexes to deploy.
For local development, pass `emulator` to run against the With an injected `store` the package initializes nothing and owns no app
lifecycle. For internal init, pass `emulator` to run against the
[Firestore emulator](https://firebase.google.com/docs/emulator-suite) — no cloud, [Firestore emulator](https://firebase.google.com/docs/emulator-suite) — no cloud,
no deploy: no deploy (wire the emulator into your own store if you inject one):
```js ```js
new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator: { host: '127.0.0.1', port: 8080 } }) new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator: { host: '127.0.0.1', port: 8080 } })

View file

@ -12,13 +12,28 @@ import {
export class FirebaseAdapter { export class FirebaseAdapter {
/** /**
* Supply EITHER a ready Firestore instance via `store` (the injection point
* e.g. a privileged/server-side setup, or an app you already initialized), OR a
* `firebaseConfig` for the package to initialize its own app. `store` wins when
* both are given; with an injected store the package initializes nothing and
* owns no app lifecycle (so `emulator` a convenience of internal init is
* ignored; wire the emulator into your own store).
*
* @param {Object} options * @param {Object} options
* @param {Object} options.firebaseConfig - Firebase app config (public; access governed by security rules) * @param {Object} [options.store] - a Firestore instance to use as-is (injection point)
* @param {Object} [options.firebaseConfig] - Firebase app config for internal init (public; access governed by security rules)
* @param {String} [options.namespace] - collection prefix * @param {String} [options.namespace] - collection prefix
* @param {{ host?: string, port?: number }} [options.emulator] - point at a local Firestore emulator (dev/test only) * @param {{ host?: string, port?: number }} [options.emulator] - point the internally-created store at a local Firestore emulator (dev/test only)
*/ */
constructor(options = {}) { constructor(options = {}) {
this.namespace = options.namespace || 'lb' this.namespace = options.namespace || 'lb'
if (options.store) {
this.store = options.store
return
}
if (!options.firebaseConfig) {
throw new TypeError('FirebaseAdapter: provide either `store` (a Firestore instance) or `firebaseConfig`')
}
const app = initializeApp(options.firebaseConfig) const app = initializeApp(options.firebaseConfig)
this.store = getFirestore(app) this.store = getFirestore(app)
if (options.emulator) { if (options.emulator) {

View file

@ -1,302 +1,65 @@
import { buckets } from '@cozy-games/utils/date-bucket/date-bucket.js' import { LeaderBoardReader } from './leaderboard-read.js'
import { LeaderBoardWriter } from './leaderboard-write.js'
const DAY_MS = 24 * 60 * 60 * 1000
/**
* The four time windows are ROLLING: each shows entries from the last `ms`
* milliseconds (strictly nested 24h 7d 30d all), sorted by score.
* `ms: null` is the all-time view (no time filter). `title` is the hover tooltip
* that spells out the window.
*/
const DURATIONS = [
{ id: 'today', label: 'Today', ms: DAY_MS, title: 'Last 24 hours' },
{ id: 'week', label: 'Week', ms: 7 * DAY_MS, title: 'Last 7 days' },
{ id: 'month', label: 'Month', ms: 30 * DAY_MS, title: 'Last 30 days' },
{ id: 'all', label: 'All Time', ms: null, title: 'All time' }
]
/**
* Default empty-state messages challenging but friendly, and game-agnostic.
* One is picked at random each render. Override per app via the `emptyMessages`
* option (see below) so localization stays out of this package.
*/
const EMPTY_MESSAGES = [
'Be the first to enter the leader board!',
'No scores yet — claim the top spot!',
'This board is wide open. Conquer it!',
'No champions here yet. Will it be you?',
'Blank slate — set the score to beat!',
'Nobody\'s here yet. Be the first!',
'The top spot is up for grabs. Take it!',
'Empty board. Time to make your mark!'
]
/** /**
* Generic, game- AND backend-agnostic leaderboard. Nothing here knows about * Generic, game- AND backend-agnostic leaderboard. Nothing here knows about
* minesweeper or Firebase: the ranked value is a plain `score`, sorted in the * minesweeper or Firebase: the ranked value is a plain `score`, sorted in the
* configured direction and displayed through an injected formatter, while all * configured direction and displayed through an injected formatter, while all
* storage I/O is delegated to an injected adapter (see adapters/). Games wire * storage I/O is delegated to an injected adapter (see adapters/).
* their specifics through the constructor options. *
* This is the COMBINED facade: it simply composes the two separable surfaces
* {@link LeaderBoardReader} (read/subscribe/render) and {@link LeaderBoardWriter}
* (submit) for consumers that want both in one object. Consumers who need only
* one half import it directly and pull in nothing from the other:
*
* import { LeaderBoardReader } from '@cozy-games/leaderboard/leaderboard-read.js'
* import { LeaderBoardWriter } from '@cozy-games/leaderboard/leaderboard-write.js'
*
* Public API is unchanged: `render()` (read) and `submit()` (write). The reader
* and writer are also exposed as `.reader` / `.writer` for direct access. Reads
* and writes may be wired to differently-privileged backend instances by passing
* the surfaces different adapters (construct a Reader and Writer directly).
* *
* An adapter implements: * An adapter implements:
* - getConfig(): Promise<Object|undefined> * - getConfig(): Promise<Object|undefined>
* - listScores({ category, field, value, order, limit }): Promise<Object[]> * - listScores({ category, since, order, limit }): Promise<Object[]> // read
* - addScore(category, entry): Promise<void> * - addScore(category, entry): Promise<void> // write
* - archive(entry): Promise<void> // optional personal history * - archive(entry): Promise<void> // optional personal history // write
*/ */
export class LeaderBoardService { export class LeaderBoardService {
/** /**
* @param {Object} options * @param {Object} options - see {@link LeaderBoardReader} and {@link LeaderBoardWriter} for the full set
* @param {Object} options.adapter - storage backend (e.g. FirebaseAdapter, SupabaseAdapter) * @param {Object} options.adapter - storage backend (e.g. FirebaseAdapter, SupabaseAdapter)
* @param {'asc'|'desc'} [options.scoreOrder] - 'asc' = lower is better (e.g. time), 'desc' = higher is better * @param {'asc'|'desc'} [options.scoreOrder]
* @param {(value: number) => string} [options.formatScore] - display formatter for a score * @param {(value: number) => string} [options.formatScore]
* @param {(entry: Object) => boolean} [options.qualifies] - whether an entry is ranked; defaults to server passingStatus vs entry.status * @param {(entry: Object) => boolean} [options.qualifies]
* @param {Object} [options.labels] - optional tab-label overrides keyed by duration id * @param {Object} [options.labels]
* @param {Object} [options.tooltips] - optional tab hover-text overrides keyed by duration id * @param {Object} [options.tooltips]
* @param {string[]} [options.emptyMessages] - empty-state messages (one picked at random); localize here * @param {string[]} [options.emptyMessages]
* @param {string} [options.loadingText] - shown while a window loads * @param {string} [options.loadingText]
* @param {string} [options.errorText] - shown when a window fails to load * @param {string} [options.errorText]
* @param {string} [options.anonymousName] - fallback display name for entries without one * @param {string} [options.anonymousName]
*/ */
constructor(options = {}) { constructor(options = {}) {
this.adapter = options.adapter this.adapter = options.adapter
this.scoreOrder = options.scoreOrder === 'desc' ? 'desc' : 'asc' this.reader = new LeaderBoardReader(options)
this.formatScore = options.formatScore || (value => String(value)) this.writer = new LeaderBoardWriter(options)
this.qualifies = options.qualifies || (entry => this._defaultQualifies(entry))
this.labels = options.labels || {}
this.tooltips = options.tooltips || {}
// User-facing strings — override to localize; the package ships English defaults.
this.emptyMessages = (Array.isArray(options.emptyMessages) && options.emptyMessages.length)
? options.emptyMessages
: EMPTY_MESSAGES
this.loadingText = options.loadingText || 'Loading…'
this.errorText = options.errorText || 'Leaderboard unavailable right now.'
this.anonymousName = options.anonymousName || 'Anonymous'
Promise.resolve(this.adapter.getConfig())
.then(config => {
this.configuration = config
})
.catch(() => {})
} }
/** /**
* Default ranking gate: if the server config names a `passingStatus`, only * Read surface render the ranked list with a duration tab bar.
* entries whose `status` matches qualify; otherwise every entry qualifies. * @see LeaderBoardReader#render
*/ */
_defaultQualifies(entry) { render(category, title, duration) {
const passing = this.configuration && this.configuration.passingStatus return this.reader.render(category, title, duration)
if (!passing) return true
return entry.status === passing
}
_label(duration) {
return this.labels[duration.id] || duration.label
}
_tooltip(duration) {
return this.tooltips[duration.id] || duration.title
}
_emptyMessage() {
return this.emptyMessages[Math.floor(Math.random() * this.emptyMessages.length)]
} }
/** /**
* Backend-neutral query descriptor for a category and time window. `since` is * Write surface submit a completed game (archive + ranked entry).
* the rolling cutoff (entries with `time_stamp >= since`); `null` means * @see LeaderBoardWriter#submit
* all-time (no time filter). The adapter turns this into a real query.
*/ */
_descriptor(category, duration) { submit(entry) {
return { return this.writer.submit(entry)
category,
since: duration.ms ? new Date(Date.now() - duration.ms) : null,
order: this.scoreOrder,
limit: 10
}
}
/**
* Render the leaderboard for a category with a duration tab bar. When
* `duration` is omitted the last-selected tab is reused (so switching game
* category keeps the player on the same window), defaulting to "today".
* Returns the wrapper element; tab clicks re-query in place.
* @param {String} category
* @param {String} title
* @param {String} [duration]
* @returns {Promise<HTMLDivElement>}
*/
async render(category, title, duration) {
this.category = category
this.title = title
if (duration) this.duration = duration
if (!this.duration) this.duration = 'today'
duration = this.duration
const wrapper = document.createElement('div')
wrapper.style.maxWidth = '270px'
wrapper.style.margin = '0 auto'
const heading = document.createElement('h3')
heading.innerText = title
heading.style.borderBottom = '1px solid #c0c0c0'
heading.style.paddingBottom = '10px'
wrapper.append(heading)
const tabBar = document.createElement('div')
tabBar.style.display = 'flex'
tabBar.style.justifyContent = 'center'
tabBar.style.gap = '8px'
tabBar.style.marginBottom = '10px'
wrapper.append(tabBar)
const listWrapper = document.createElement('div')
wrapper.append(listWrapper)
const tabs = {}
const activate = (id) => {
this.duration = id
Object.entries(tabs).forEach(([tabId, el]) => this._styleTab(el, tabId === id))
this._loadList(listWrapper, category, DURATIONS.find(d => d.id === id))
}
DURATIONS.forEach(d => {
const tab = document.createElement('button')
tab.innerText = this._label(d)
tab.type = 'button'
tab.setAttribute('title', this._tooltip(d))
this._styleTab(tab, d.id === duration)
tab.onclick = () => activate(d.id)
tabs[d.id] = tab
tabBar.append(tab)
})
// Return the wrapper (heading + tabs) right away and fill the list
// asynchronously, so a slow or failing query never blocks the UI.
this._loadList(listWrapper, category, DURATIONS.find(d => d.id === duration))
return wrapper
}
_styleTab(tab, active) {
tab.style.background = 'none'
tab.style.border = 'none'
tab.style.cursor = 'pointer'
tab.style.padding = '2px 4px'
tab.style.fontSize = '0.85em'
tab.style.color = active ? '#ffffff' : '#999999'
tab.style.fontWeight = active ? 'bold' : 'normal'
tab.style.borderBottom = active ? '2px solid orange' : '2px solid transparent'
}
/**
* Load a window's entries into the list area, showing a loading placeholder
* and turning any failure (e.g. the backend being unreachable) into a
* message instead of an unhandled rejection. Guards against races when the
* player switches tabs quickly by tagging the in-flight request.
*/
async _loadList(listWrapper, category, duration) {
const token = (this._loadToken || 0) + 1
this._loadToken = token
listWrapper.innerHTML = ''
const loading = document.createElement('em')
loading.innerText = this.loadingText
listWrapper.append(loading)
try {
const rows = await this.adapter.listScores(this._descriptor(category, duration))
if (this._loadToken !== token) return
this._renderList(listWrapper, rows)
} catch {
if (this._loadToken !== token) return
listWrapper.innerHTML = ''
const message = document.createElement('em')
message.innerText = this.errorText
listWrapper.append(message)
}
}
_renderList(listWrapper, rows) {
listWrapper.innerHTML = ''
if (!rows || !rows.length) {
const message = document.createElement('em')
message.innerText = this._emptyMessage()
listWrapper.append(message)
return
}
const list = document.createElement('div')
list.style.listStyle = 'none'
list.style.textAlign = 'left'
let i = 1
rows.forEach(data => {
const item = document.createElement('div')
item.style.display = 'flex'
const indexElement = document.createElement('div')
indexElement.innerText = `#${i++}`
const nameElement = document.createElement('div')
const name = data.name || this.anonymousName
nameElement.innerHTML = name
nameElement.setAttribute('title', name)
nameElement.style.textOverflow = 'ellipsis'
nameElement.style.whiteSpace = 'nowrap'
nameElement.style.overflow = 'hidden'
nameElement.style.padding = '0 5px'
nameElement.style.fontWeight = 'bold'
nameElement.style.fontStyle = 'italic'
nameElement.style.flex = '1'
const scoreElement = document.createElement('div')
scoreElement.innerText = this.formatScore(data.score)
item.append(indexElement, nameElement, scoreElement)
list.append(item)
})
listWrapper.append(list)
}
/**
* Submit a completed game. Always archives it (personal history); if it
* 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? }
*/
async submit(entry) {
if (this.adapter.archive) {
await this.adapter.archive({
playerId: entry.playerId,
score: entry.score,
category: entry.category,
time_stamp: entry.time_stamp,
meta: entry.meta
})
}
if (!this.qualifies(entry)) return
const stamp = entry.time_stamp instanceof Date ? entry.time_stamp : new Date(entry.time_stamp)
const bucket = buckets(stamp)
const scoreDoc = {
name: entry.name || 'Anonymous',
playerId: entry.playerId,
score: entry.score,
category: entry.category,
time_stamp: entry.time_stamp,
day: bucket.day,
week: bucket.week,
month: bucket.month
}
if (entry.meta) scoreDoc.meta = entry.meta
await this.adapter.addScore(entry.category, scoreDoc)
} }
} }

View file

@ -1,15 +1,24 @@
import { WebComponent } from 'web-component-base' import { WebComponent, html } from 'web-component-base'
import { LeaderBoardService } from './leader-board.js' import { LeaderBoardService } from './leader-board.js'
import { DURATIONS } from './leaderboard-read.js'
/** /**
* `<cozy-leaderboard>` a custom element that lets a developer compose the * `<cozy-leaderboard>` a custom element that lets a developer compose the
* leaderboard UI declaratively in HTML instead of wiring it in JavaScript. * leaderboard UI declaratively in HTML instead of wiring it in JavaScript.
* *
* Built on `web-component-base` (WebComponent base class + lifecycle hooks). It * Built on `web-component-base` (WCB) the idiomatic way: the observed
* extends WCB for the custom-element scaffolding (onInit/onDestroy, connect * attributes are declared as `static props` (typed defaults; the base derives
* handling) and delegates all inner DOM the duration tabs and the ranked * observedAttributes and feeds values into the reactive `this.props`), the view
* list to the existing LeaderBoardService, which already builds and manages * is a pure `html` template over a precomputed view-state object, and change
* that DOM (including in-place tab swaps). * reactions arrive through the `onChanges` hook. Data access and user-facing
* strings stay in {@link LeaderBoardService} / LeaderBoardReader the element
* only turns query results into templates.
*
* (WCB 5 is required: v4 wrote each prop default onto the element as an
* attribute inside the constructor, which the custom-elements spec forbids and
* which broke `document.createElement('cozy-leaderboard')`. v5 defers that
* reflection to connect and never clobbers authored attributes, making
* `static props` safe here.)
* *
* Composition lives in HTML attributes; the storage backend (adapter) is set * Composition lives in HTML attributes; the storage backend (adapter) is set
* once in JS via configureLeaderboard(), because env-var config can't live in * once in JS via configureLeaderboard(), because env-var config can't live in
@ -53,49 +62,140 @@ const prettyTime = ms => {
const FORMATTERS = { time: prettyTime } const FORMATTERS = { time: prettyTime }
const resolveFormat = name => FORMATTERS[name] const resolveFormat = name => FORMATTERS[name]
// Inline styles (as WCB `html` style objects) — the same visual output the
// LeaderBoardReader produces for the imperative JS composition path.
const STYLES = {
wrapper: { maxWidth: '270px', margin: '0 auto' },
heading: { borderBottom: '1px solid #c0c0c0', paddingBottom: '10px' },
tabBar: { display: 'flex', justifyContent: 'center', gap: '8px', marginBottom: '10px' },
list: { listStyle: 'none', textAlign: 'left' },
row: { display: 'flex' },
name: {
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
padding: '0 5px',
fontWeight: 'bold',
fontStyle: 'italic',
flex: '1'
}
}
const tabStyle = active => ({
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px 4px',
fontSize: '0.85em',
color: active ? '#ffffff' : '#999999',
fontWeight: active ? 'bold' : 'normal',
borderBottom: active ? '2px solid orange' : '2px solid transparent'
})
export class CozyLeaderboard extends WebComponent { export class CozyLeaderboard extends WebComponent {
static get observedAttributes() { // Declared attributes (kebab-cased on the element: score-order). String
return ['category', 'title', 'duration', 'score-order', 'format'] // defaults keep `this.props.*` typed as strings, so an unset or emptied
// attribute reads as '' rather than a coerced boolean. The base class derives
// observedAttributes from these keys.
static props = {
category: '',
title: '',
duration: '',
scoreOrder: '',
format: ''
} }
// WCB lifecycle: register/unregister so configureLeaderboard() can re-render. // View state the template renders from. Either { board: false } (not
// configured) or { board: true, tabs, active, list } where list is
// { rows: [{ index, name, score }] } or { message } (loading/empty/error).
_view = { board: false }
// Selected duration window; survives category changes and re-connects.
// null until the board first mounts, so the `duration` attribute is honored.
_activeDuration = null
_connected = false
_token = 0
// WCB lifecycle: connect mounts the board, disconnect unregisters. The
// instances set lets configureLeaderboard() re-mount live elements.
onInit() { onInit() {
this._connected = true
instances.add(this) instances.add(this)
}
onDestroy() {
instances.delete(this)
}
// Attributes are read directly (getAttribute) rather than through WCB's typed
// props proxy, so optional/empty values never trip its type enforcement.
attributeChangedCallback(name, previousValue, currentValue) {
if (previousValue === currentValue || !this.isConnected) return
this._mount(name === 'duration' ? (currentValue || undefined) : undefined)
}
// WCB calls render() on connect; we treat it as "(re)mount the board".
render() {
this._mount() this._mount()
} }
onDestroy() {
this._connected = false
instances.delete(this)
}
/**
* WCB change hook `property` is the camelCase prop name (WCB 5). A title
* change needs no re-query: the heading reads `this.props.title`, so the base
* class's own render already updated it. score-order/format changes rebuild
* the service so the new config actually takes effect.
*/
onChanges({ property }) {
// Invalidate the cached service even while disconnected, so a scoreOrder/
// format change made on a detached element takes effect on re-connect.
if (property === 'scoreOrder' || property === 'format') this._svc = null
if (!this._connected || property === 'title') return
this._mount(property === 'duration' ? (this.props.duration || undefined) : undefined)
}
get template() {
const view = this._view
if (!view.board) return html`<em>Leaderboard not configured.</em>`
return html`
<div style=${STYLES.wrapper}>
<h3 style=${STYLES.heading}>${this.props.title}</h3>
<div style=${STYLES.tabBar}>
${view.tabs.map(tab => html`
<button
type="button"
title=${tab.tooltip}
data-duration=${tab.id}
style=${tabStyle(tab.id === view.active)}
onclick=${() => this._selectTab(tab.id)}
>${tab.label}</button>
`)}
</div>
<div>
${view.list.rows
? html`
<div style=${STYLES.list}>
${view.list.rows.map(row => html`
<div style=${STYLES.row}>
<div>#${row.index}</div>
<div title=${row.name} style=${STYLES.name}>${row.name}</div>
<div>${row.score}</div>
</div>
`)}
</div>`
: html`<em>${view.list.message}</em>`}
</div>
</div>
`
}
// Per-element override properties (public): adapter, formatScore, qualifies, // Per-element override properties (public): adapter, formatScore, qualifies,
// labels, emptyMessages, loadingText, errorText, anonymousName. Each falls back // labels, tooltips, emptyMessages, loadingText, errorText, anonymousName.
// to the shared configureLeaderboard() value, then the package default. Set // Each falls back to the shared configureLeaderboard() value, then the
// them before the element connects (or clear `_svc` to force a rebuild). // package default. Set them before the element connects (or clear `_svc` to
// force a rebuild). Rich values stay plain properties — WCB props are
// attribute-backed and only carry serializable primitives.
_service() { _service() {
if (this._svc) return this._svc if (this._svc) return this._svc
const adapter = this.adapter || sharedConfig.adapter const adapter = this.adapter || sharedConfig.adapter
if (!adapter) return null if (!adapter) return null
const formatScore = this.formatScore const formatScore = this.formatScore
|| resolveFormat(this.getAttribute('format')) || resolveFormat(this.props.format)
|| sharedConfig.formatScore || sharedConfig.formatScore
|| resolveFormat(sharedConfig.format) || resolveFormat(sharedConfig.format)
|| String || String
this._svc = new LeaderBoardService({ this._svc = new LeaderBoardService({
adapter, adapter,
scoreOrder: this.getAttribute('score-order') || sharedConfig.scoreOrder || 'asc', scoreOrder: this.props.scoreOrder || sharedConfig.scoreOrder || 'asc',
formatScore, formatScore,
qualifies: this.qualifies || sharedConfig.qualifies, qualifies: this.qualifies || sharedConfig.qualifies,
// User-facing strings — pass through so apps localize without touching the package. // User-facing strings — pass through so apps localize without touching the package.
@ -110,36 +210,78 @@ export class CozyLeaderboard extends WebComponent {
} }
/** /**
* (Re)render the board. The first successful mount honors the author's * (Re)mount the board. The first mount honors the author's `duration`
* `duration` attribute; later mounts preserve the service's remembered * attribute; later mounts keep the selected duration (so switching category
* duration (so switching category keeps the selected tab) unless a duration * keeps the selected tab) unless a duration is passed explicitly.
* is passed explicitly.
*/ */
_mount(durationArg) { _mount(durationArg) {
if (!this.isConnected) return if (!this._connected) return
const service = this._service() const service = this._service()
if (!service) { if (!service) {
this.replaceChildren(this._message('Leaderboard not configured.')) this._view = { board: false }
this._paint()
return return
} }
const duration = durationArg
?? this._activeDuration
?? (this.props.duration || 'today')
this._activeDuration = duration
this._load(service, duration)
}
let duration = durationArg _selectTab(id) {
if (duration === undefined && !this._mounted) { this._activeDuration = id
duration = this.getAttribute('duration') || undefined this._load(this._service(), id)
}
/**
* Query one duration window and project the result into view state: a
* loading message immediately, then rows / a random empty message / the
* error text. The token guards against a stale response (quick tab or
* category switches) overwriting a newer one.
*/
async _load(service, durationId) {
const reader = service.reader
const tabs = DURATIONS.map(d => ({ id: d.id, label: reader.label(d), tooltip: reader.tooltip(d) }))
const board = list => ({ board: true, tabs, active: durationId, list })
const token = ++this._token
this._view = board({ message: reader.loadingText })
this._paint()
let list
try {
const rows = await reader.list(this.props.category, durationId)
list = (rows && rows.length)
? {
rows: rows.map((row, index) => ({
index: index + 1,
name: row.name || reader.anonymousName,
score: reader.formatScore(row.score)
}))
}
: { message: reader.emptyMessage() }
} catch {
list = { message: reader.errorText }
} }
if (token !== this._token) return
this._view = board(list)
this._paint()
}
const token = (this._token || 0) + 1 /**
this._token = token * Render the current view state through WCB. WCB's render() replaces the
service.render(this.getAttribute('category') || '', this.getAttribute('title') || '', duration) * whole subtree (no diffing yet), which would drop focus from a clicked
.then(el => { * duration tab the one behavior the base class can't preserve for us so
if (this._token !== token) return * focus is handed to the replacement tab explicitly.
this.replaceChildren(el) */
this._mounted = true _paint() {
}) // getRootNode(), not document: inside a shadow root, document.activeElement
.catch(() => { // is retargeted to the host and the focused tab would go undetected.
if (this._token !== token) return const focused = this.getRootNode().activeElement
this.replaceChildren(this._message('Leaderboard unavailable right now.')) const focusedTab = focused && this.contains(focused) ? focused.dataset.duration : undefined
}) this.render()
if (focusedTab) this.querySelector(`button[data-duration="${focusedTab}"]`)?.focus()
} }
/** /**
@ -151,12 +293,6 @@ export class CozyLeaderboard extends WebComponent {
const service = this._service() const service = this._service()
if (service) return service.submit(entry) if (service) return service.submit(entry)
} }
_message(text) {
const em = document.createElement('em')
em.innerText = text
return em
}
} }
if (!customElements.get('cozy-leaderboard')) { if (!customElements.get('cozy-leaderboard')) {

View file

@ -0,0 +1,272 @@
/**
* The READ / subscribe surface of the leaderboard: querying a time window and
* rendering the ranked list. Importable WITHOUT any write-path code no
* `submit`, no bucket-key computation, no write adapter calls so read-only
* consumers (and tests) pull in nothing they don't need.
*
* Pairs with `leaderboard-write.js` (the write half) and `leader-board.js` (the
* combined facade). The READ side only ever calls `adapter.listScores`.
*/
const DAY_MS = 24 * 60 * 60 * 1000
/**
* The four time windows are ROLLING: each shows entries from the last `ms`
* milliseconds (strictly nested 24h 7d 30d all), sorted by score.
* `ms: null` is the all-time view (no time filter). `title` is the hover tooltip
* that spells out the window. Exported so view layers (e.g. the
* `<cozy-leaderboard>` element) can render the tab bar themselves.
*/
export const DURATIONS = [
{ id: 'today', label: 'Today', ms: DAY_MS, title: 'Last 24 hours' },
{ id: 'week', label: 'Week', ms: 7 * DAY_MS, title: 'Last 7 days' },
{ id: 'month', label: 'Month', ms: 30 * DAY_MS, title: 'Last 30 days' },
{ id: 'all', label: 'All Time', ms: null, title: 'All time' }
]
/**
* Default empty-state messages challenging but friendly, and game-agnostic.
* One is picked at random each render. Override per app via the `emptyMessages`
* option so localization stays out of this package.
*/
const EMPTY_MESSAGES = [
'Be the first to enter the leader board!',
'No scores yet — claim the top spot!',
'This board is wide open. Conquer it!',
'No champions here yet. Will it be you?',
'Blank slate — set the score to beat!',
'Nobody\'s here yet. Be the first!',
'The top spot is up for grabs. Take it!',
'Empty board. Time to make your mark!'
]
/**
* Read-only leaderboard view: windows, sorting (via the adapter), and rendering.
* Nothing here writes; the ranked value is a plain `score` displayed through an
* injected formatter, and all query I/O is delegated to an injected adapter's
* `listScores`. Safe to wire to a read-only / less-privileged backend instance.
*/
export class LeaderBoardReader {
/**
* @param {Object} options
* @param {Object} options.adapter - storage backend; the READ side uses `listScores`
* @param {'asc'|'desc'} [options.scoreOrder] - 'asc' = lower is better (e.g. time), 'desc' = higher is better
* @param {(value: number) => string} [options.formatScore] - display formatter for a score
* @param {Object} [options.labels] - optional tab-label overrides keyed by duration id
* @param {Object} [options.tooltips] - optional tab hover-text overrides keyed by duration id
* @param {string[]} [options.emptyMessages] - empty-state messages (one picked at random); localize here
* @param {string} [options.loadingText] - shown while a window loads
* @param {string} [options.errorText] - shown when a window fails to load
* @param {string} [options.anonymousName] - fallback display name for entries without one
*/
constructor(options = {}) {
this.adapter = options.adapter
this.scoreOrder = options.scoreOrder === 'desc' ? 'desc' : 'asc'
this.formatScore = options.formatScore || (value => String(value))
this.labels = options.labels || {}
this.tooltips = options.tooltips || {}
// User-facing strings — override to localize; the package ships English defaults.
this.emptyMessages = (Array.isArray(options.emptyMessages) && options.emptyMessages.length)
? options.emptyMessages
: EMPTY_MESSAGES
this.loadingText = options.loadingText || 'Loading…'
this.errorText = options.errorText || 'Leaderboard unavailable right now.'
this.anonymousName = options.anonymousName || 'Anonymous'
}
/**
* Display label for a duration window (override-aware).
* @param {{ id: String, label: String }} duration - a DURATIONS entry
*/
label(duration) {
return this.labels[duration.id] || duration.label
}
/**
* Hover tooltip for a duration window (override-aware).
* @param {{ id: String, title: String }} duration - a DURATIONS entry
*/
tooltip(duration) {
return this.tooltips[duration.id] || duration.title
}
/** One empty-state message, picked at random. */
emptyMessage() {
return this.emptyMessages[Math.floor(Math.random() * this.emptyMessages.length)]
}
/**
* Data-level query: the ranked entries for a category and duration window,
* without any DOM. View layers that render themselves (e.g. the
* `<cozy-leaderboard>` element) use this instead of {@link render}.
* @param {String} category
* @param {String} durationId - a DURATIONS id ('today' | 'week' | 'month' | 'all')
* @returns {Promise<Object[]>}
*/
async list(category, durationId) {
const duration = DURATIONS.find(d => d.id === durationId)
return this.adapter.listScores(this._descriptor(category, duration))
}
/**
* Backend-neutral query descriptor for a category and time window. `since` is
* the rolling cutoff (entries with `time_stamp >= since`); `null` means
* all-time (no time filter). The adapter turns this into a real query.
*/
_descriptor(category, duration) {
return {
category,
since: duration.ms ? new Date(Date.now() - duration.ms) : null,
order: this.scoreOrder,
limit: 10
}
}
/**
* Render the leaderboard for a category with a duration tab bar. When
* `duration` is omitted the last-selected tab is reused (so switching game
* category keeps the player on the same window), defaulting to "today".
* Returns the wrapper element; tab clicks re-query in place.
* @param {String} category
* @param {String} title
* @param {String} [duration]
* @returns {Promise<HTMLDivElement>}
*/
async render(category, title, duration) {
this.category = category
this.title = title
if (duration) this.duration = duration
if (!this.duration) this.duration = 'today'
duration = this.duration
const wrapper = document.createElement('div')
wrapper.style.maxWidth = '270px'
wrapper.style.margin = '0 auto'
const heading = document.createElement('h3')
heading.textContent = title
heading.style.borderBottom = '1px solid #c0c0c0'
heading.style.paddingBottom = '10px'
wrapper.append(heading)
const tabBar = document.createElement('div')
tabBar.style.display = 'flex'
tabBar.style.justifyContent = 'center'
tabBar.style.gap = '8px'
tabBar.style.marginBottom = '10px'
wrapper.append(tabBar)
const listWrapper = document.createElement('div')
wrapper.append(listWrapper)
const tabs = {}
const activate = (id) => {
this.duration = id
Object.entries(tabs).forEach(([tabId, el]) => this._styleTab(el, tabId === id))
this._loadList(listWrapper, category, DURATIONS.find(d => d.id === id))
}
DURATIONS.forEach(d => {
const tab = document.createElement('button')
tab.textContent = this.label(d)
tab.type = 'button'
tab.setAttribute('title', this.tooltip(d))
this._styleTab(tab, d.id === duration)
tab.onclick = () => activate(d.id)
tabs[d.id] = tab
tabBar.append(tab)
})
// Return the wrapper (heading + tabs) right away and fill the list
// asynchronously, so a slow or failing query never blocks the UI.
this._loadList(listWrapper, category, DURATIONS.find(d => d.id === duration))
return wrapper
}
_styleTab(tab, active) {
tab.style.background = 'none'
tab.style.border = 'none'
tab.style.cursor = 'pointer'
tab.style.padding = '2px 4px'
tab.style.fontSize = '0.85em'
tab.style.color = active ? '#ffffff' : '#999999'
tab.style.fontWeight = active ? 'bold' : 'normal'
tab.style.borderBottom = active ? '2px solid orange' : '2px solid transparent'
}
/**
* Load a window's entries into the list area, showing a loading placeholder
* and turning any failure (e.g. the backend being unreachable) into a
* message instead of an unhandled rejection. Guards against races when the
* player switches tabs quickly by tagging the in-flight request.
*/
async _loadList(listWrapper, category, duration) {
const token = (this._loadToken || 0) + 1
this._loadToken = token
listWrapper.innerHTML = ''
const loading = document.createElement('em')
loading.textContent = this.loadingText
listWrapper.append(loading)
try {
const rows = await this.adapter.listScores(this._descriptor(category, duration))
if (this._loadToken !== token) return
this._renderList(listWrapper, rows)
} catch {
if (this._loadToken !== token) return
listWrapper.innerHTML = ''
const message = document.createElement('em')
message.textContent = this.errorText
listWrapper.append(message)
}
}
_renderList(listWrapper, rows) {
listWrapper.innerHTML = ''
if (!rows || !rows.length) {
const message = document.createElement('em')
message.textContent = this.emptyMessage()
listWrapper.append(message)
return
}
const list = document.createElement('div')
list.style.listStyle = 'none'
list.style.textAlign = 'left'
let i = 1
rows.forEach(data => {
const item = document.createElement('div')
item.style.display = 'flex'
const indexElement = document.createElement('div')
indexElement.textContent = `#${i++}`
const nameElement = document.createElement('div')
const name = data.name || this.anonymousName
// textContent, never innerHTML: names are player-controlled input.
nameElement.textContent = name
nameElement.setAttribute('title', name)
nameElement.style.textOverflow = 'ellipsis'
nameElement.style.whiteSpace = 'nowrap'
nameElement.style.overflow = 'hidden'
nameElement.style.padding = '0 5px'
nameElement.style.fontWeight = 'bold'
nameElement.style.fontStyle = 'italic'
nameElement.style.flex = '1'
const scoreElement = document.createElement('div')
scoreElement.textContent = this.formatScore(data.score)
item.append(indexElement, nameElement, scoreElement)
list.append(item)
})
listWrapper.append(list)
}
}

View file

@ -0,0 +1,80 @@
import { buckets } from '@cozy-games/utils/date-bucket/date-bucket.js'
/**
* The WRITE surface of the leaderboard: submitting a completed game the
* personal archive plus, if it qualifies, a ranked entry with denormalized
* day/week/month bucket keys. Importable WITHOUT any read/render code no DOM,
* no list rendering, no `listScores` so a consumer can wire writes to a
* separate, more-privileged backend instance (leaderboard-01) and pull in none
* of the read surface.
*
* The WRITE side calls `adapter.archive` (optional) and `adapter.addScore`; it
* also reads the ranking `config` once (an adapter call, not read/render code)
* to power the default qualifier.
*/
export class LeaderBoardWriter {
/**
* @param {Object} options
* @param {Object} options.adapter - storage backend; the WRITE side uses `addScore`, optional `archive`, and `getConfig`
* @param {(entry: Object) => boolean} [options.qualifies] - whether an entry is ranked; defaults to server passingStatus vs entry.status
*/
constructor(options = {}) {
this.adapter = options.adapter
this.qualifies = options.qualifies || (entry => this._defaultQualifies(entry))
// The default qualifier depends on the server's ranking config, so load it
// once. This is a config READ via the adapter — it pulls in no read/render
// module, keeping the write surface importable on its own.
Promise.resolve(this.adapter && this.adapter.getConfig ? this.adapter.getConfig() : undefined)
.then(config => { this.configuration = config })
.catch(() => {})
}
/**
* Default ranking gate: if the server config names a `passingStatus`, only
* entries whose `status` matches qualify; otherwise every entry qualifies.
*/
_defaultQualifies(entry) {
const passing = this.configuration && this.configuration.passingStatus
if (!passing) return true
return entry.status === passing
}
/**
* Submit a completed game. Always archives it (personal history); if it
* 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? }
*/
async submit(entry) {
if (this.adapter.archive) {
await this.adapter.archive({
playerId: entry.playerId,
score: entry.score,
category: entry.category,
time_stamp: entry.time_stamp,
meta: entry.meta
})
}
if (!this.qualifies(entry)) return
const stamp = entry.time_stamp instanceof Date ? entry.time_stamp : new Date(entry.time_stamp)
const bucket = buckets(stamp)
const scoreDoc = {
name: entry.name || 'Anonymous',
playerId: entry.playerId,
score: entry.score,
category: entry.category,
time_stamp: entry.time_stamp,
day: bucket.day,
week: bucket.week,
month: bucket.month
}
if (entry.meta) scoreDoc.meta = entry.meta
await this.adapter.addScore(entry.category, scoreDoc)
}
}

View file

@ -9,6 +9,9 @@
"url": "https://github.com/ayo-run/mnswpr" "url": "https://github.com/ayo-run/mnswpr"
}, },
"main": "leader-board.js", "main": "leader-board.js",
"scripts": {
"build": "vite build"
},
"exports": { "exports": {
".": { ".": {
"default": "./dist/leader-board.js" "default": "./dist/leader-board.js"
@ -25,7 +28,7 @@
"./dist" "./dist"
], ],
"dependencies": { "dependencies": {
"web-component-base": "^4.1.2" "web-component-base": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@cozy-games/utils": "workspace:*" "@cozy-games/utils": "workspace:*"

View file

@ -0,0 +1,102 @@
// @ts-check
import { describe, it, expect, beforeEach, vi } from 'vitest'
// Stub the firebase SDK at the adapter boundary so no real app/network is touched.
const fb = vi.hoisted(() => ({
initializeApp: vi.fn(() => ({ __app: true })),
getFirestore: vi.fn(() => ({ __internalStore: true })),
connectFirestoreEmulator: vi.fn(),
doc: vi.fn((...args) => ({ __ref: args })),
getDoc: vi.fn(async () => ({ data: () => ({ passingStatus: 'ok' }) })),
getDocs: vi.fn(async () => ({ docs: [] })),
setDoc: vi.fn(async () => {}),
collection: vi.fn((...args) => ({ __col: args })),
query: vi.fn((...args) => ({ __q: args })),
where: vi.fn((...args) => ({ __where: args })),
orderBy: vi.fn((...args) => ({ __order: args })),
limit: vi.fn((...args) => ({ __limit: args }))
}))
vi.mock('firebase/app', () => ({ initializeApp: fb.initializeApp }))
vi.mock('firebase/firestore/lite', () => ({
getFirestore: fb.getFirestore,
connectFirestoreEmulator: fb.connectFirestoreEmulator,
doc: fb.doc,
getDoc: fb.getDoc,
getDocs: fb.getDocs,
setDoc: fb.setDoc,
collection: fb.collection,
query: fb.query,
where: fb.where,
orderBy: fb.orderBy,
limit: fb.limit
}))
import { FirebaseAdapter } from '../adapters/firebase.js'
beforeEach(() => vi.clearAllMocks())
describe('FirebaseAdapter — consumer-supplied (injected) store', () => {
it('uses an injected store as-is and initializes nothing', () => {
// Stand-in for a privileged / server-side Firestore instance.
const store = { __adminStore: true }
const adapter = new FirebaseAdapter({ store, namespace: 'mw' })
expect(adapter.store).toBe(store)
expect(fb.initializeApp).not.toHaveBeenCalled()
expect(fb.getFirestore).not.toHaveBeenCalled()
})
it('performs a write through the injected store', async () => {
const store = { __adminStore: true }
const adapter = new FirebaseAdapter({ store, namespace: 'mw' })
const entry = { name: 'A', score: 42, category: 'beginner', time_stamp: 123 }
await adapter.addScore('beginner', entry)
// The collection was built from OUR store, and the entry was written.
expect(fb.collection).toHaveBeenCalledWith(store, 'mw-scores', 'beginner', 'games')
expect(fb.setDoc).toHaveBeenCalledTimes(1)
expect(fb.setDoc.mock.calls[0][1]).toEqual(entry)
})
it('archives through the injected store', async () => {
const store = { __adminStore: true }
const adapter = new FirebaseAdapter({ store, namespace: 'mw' })
await adapter.archive({ playerId: 'p1', score: 9, category: 'beginner', time_stamp: 5 })
expect(fb.doc).toHaveBeenCalledWith(store, 'mw-all', 'p1', 'games', expect.any(String))
expect(fb.setDoc).toHaveBeenCalledTimes(1)
})
it('does not auto-connect the emulator for an injected store (consumer owns it)', () => {
new FirebaseAdapter({ store: { __s: 1 }, namespace: 'mw', emulator: { host: 'x', port: 1 } })
expect(fb.connectFirestoreEmulator).not.toHaveBeenCalled()
})
it('prefers the injected store when both store and firebaseConfig are given', () => {
const store = { __adminStore: true }
const adapter = new FirebaseAdapter({ store, firebaseConfig: { projectId: 'p' } })
expect(adapter.store).toBe(store)
expect(fb.initializeApp).not.toHaveBeenCalled()
})
})
describe('FirebaseAdapter — internal init (existing path, unbroken)', () => {
it('still initializes its own store from firebaseConfig and writes', async () => {
const adapter = new FirebaseAdapter({ firebaseConfig: { projectId: 'p' }, namespace: 'mw' })
expect(fb.initializeApp).toHaveBeenCalledWith({ projectId: 'p' })
expect(fb.getFirestore).toHaveBeenCalledTimes(1)
expect(adapter.store).toEqual({ __internalStore: true })
await adapter.addScore('beginner', { score: 1 })
expect(fb.setDoc).toHaveBeenCalledTimes(1)
})
it('still connects the emulator when configured', () => {
new FirebaseAdapter({ firebaseConfig: {}, emulator: { host: '127.0.0.1', port: 8080 } })
expect(fb.connectFirestoreEmulator).toHaveBeenCalledWith({ __internalStore: true }, '127.0.0.1', 8080)
})
it('throws a clear error when neither store nor firebaseConfig is given', () => {
expect(() => new FirebaseAdapter({})).toThrow(TypeError)
})
})

View file

@ -0,0 +1,592 @@
// @ts-check
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { configureLeaderboard } from '../leaderboard-element.js'
/**
* Characterization tests for <cozy-leaderboard>: every externally observable
* behavior of the element, written against the element's public surface only
* (attributes, properties, produced DOM, adapter calls) so the implementation
* can be refactored while this suite stays green.
*
* Two behaviors are deliberately NOT pinned here:
* - entry names render via innerHTML today (an XSS hazard); only the text is
* asserted, so a safe text rendering also passes.
* - score-order / format attribute CHANGES after the first mount are silently
* ignored today (the per-element service caches its config); honoring them
* is an allowed improvement.
*/
const DEFAULT_EMPTY_MESSAGES = [
'Be the first to enter the leader board!',
'No scores yet — claim the top spot!',
'This board is wide open. Conquer it!',
'No champions here yet. Will it be you?',
'Blank slate — set the score to beat!',
'Nobody\'s here yet. Be the first!',
'The top spot is up for grabs. Take it!',
'Empty board. Time to make your mark!'
]
const makeAdapter = (rows = [], overrides = {}) => ({
listScores: vi.fn(async () => rows),
addScore: vi.fn(async () => {}),
archive: vi.fn(async () => {}),
getConfig: vi.fn(async () => undefined),
...overrides
})
// Reset every shared-config key; `undefined` falls through all fallback chains.
const resetSharedConfig = () => configureLeaderboard({
adapter: undefined,
scoreOrder: undefined,
format: undefined,
formatScore: undefined,
qualifies: undefined,
labels: undefined,
tooltips: undefined,
emptyMessages: undefined,
loadingText: undefined,
errorText: undefined,
anonymousName: undefined
})
/**
* Create a disconnected element, apply attributes and per-element override
* properties (they must be set before connect), then connect it the
* documented composition flow.
*/
const mount = (attrs = {}, props = {}) => {
const el = /** @type {any} */ (document.createElement('cozy-leaderboard'))
Object.entries(attrs).forEach(([name, value]) => el.setAttribute(name, value))
Object.assign(el, props)
document.body.append(el)
return el
}
const tabButtons = el => [...el.querySelectorAll('button')]
const tabByLabel = (el, label) => tabButtons(el).find(b => b.textContent === label)
const rowsText = el => el.textContent
beforeEach(() => resetSharedConfig())
afterEach(() => { document.body.innerHTML = '' })
describe('unconfigured state', () => {
it('renders the not-configured message when no adapter exists anywhere', async () => {
const el = mount({ category: 'beginner', title: 'Best Times' })
await vi.waitFor(() => {
const em = el.querySelector('em')
expect(em).toBeTruthy()
expect(em.textContent).toBe('Leaderboard not configured.')
})
})
it('mounts the board when configureLeaderboard() supplies an adapter later', async () => {
const el = mount({ category: 'beginner', title: 'Best Times' })
await vi.waitFor(() => expect(el.textContent).toContain('Leaderboard not configured.'))
const adapter = makeAdapter([{ name: 'Ada', score: 3 }])
configureLeaderboard({ adapter })
await vi.waitFor(() => {
expect(el.querySelector('h3')).toBeTruthy()
expect(rowsText(el)).toContain('Ada')
})
})
})
describe('board structure', () => {
it('renders heading, four duration tabs with tooltips, and ranked rows', async () => {
const adapter = makeAdapter([
{ name: 'Ada', score: 3 },
{ name: 'Bo', score: 5 }
])
const el = mount({ category: 'beginner', title: 'Best Times' }, { adapter })
await vi.waitFor(() => expect(rowsText(el)).toContain('Ada'))
const heading = el.querySelector('h3')
expect(heading.textContent).toBe('Best Times')
const tabs = tabButtons(el)
expect(tabs.map(t => t.textContent)).toEqual(['Today', 'Week', 'Month', 'All Time'])
expect(tabs.map(t => t.getAttribute('title'))).toEqual([
'Last 24 hours', 'Last 7 days', 'Last 30 days', 'All time'
])
tabs.forEach(t => expect(t.type).toBe('button'))
// Ranked rows: index, name (with hover title), formatted score.
expect(rowsText(el)).toContain('#1')
expect(rowsText(el)).toContain('#2')
expect(rowsText(el)).toContain('3')
expect(rowsText(el)).toContain('5')
const nameCells = [...el.querySelectorAll('[title="Ada"], [title="Bo"]')]
expect(nameCells).toHaveLength(2)
})
it('shows the loading text while the query is in flight', async () => {
let resolve
const adapter = makeAdapter([], {
listScores: vi.fn(() => new Promise(r => { resolve = r }))
})
const el = mount({ category: 'beginner', title: 'Best Times' }, { adapter })
await vi.waitFor(() => expect(rowsText(el)).toContain('Loading…'))
expect(el.querySelector('h3')).toBeTruthy() // heading + tabs render before data
resolve([{ name: 'Ada', score: 3 }])
await vi.waitFor(() => expect(rowsText(el)).toContain('Ada'))
expect(rowsText(el)).not.toContain('Loading…')
})
it('marks the active tab and leaves the others inactive', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best Times' }, { adapter })
await vi.waitFor(() => expect(tabButtons(el)).toHaveLength(4))
const today = tabByLabel(el, 'Today')
const week = tabByLabel(el, 'Week')
expect(today.style.fontWeight).toBe('bold')
expect(today.style.borderBottom).toContain('orange')
expect(week.style.fontWeight).toBe('normal')
expect(week.style.borderBottom).toContain('transparent')
})
})
describe('queries', () => {
it('queries the \'today\' rolling window by default, limited to 10', async () => {
const adapter = makeAdapter([])
mount({ category: 'beginner', title: 'Best Times' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
const q = adapter.listScores.mock.calls[0][0]
expect(q.category).toBe('beginner')
expect(q.order).toBe('asc')
expect(q.limit).toBe(10)
expect(q.since).toBeInstanceOf(Date)
const dayMs = 24 * 60 * 60 * 1000
expect(Math.abs(Date.now() - dayMs - q.since.getTime())).toBeLessThan(5000)
})
it('honors the duration attribute on first mount (all → no time filter)', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best', duration: 'all' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
expect(adapter.listScores.mock.calls[0][0].since).toBeNull()
await vi.waitFor(() =>
expect(tabByLabel(el, 'All Time').style.fontWeight).toBe('bold'))
})
it('passes score-order=\'desc\' through to the query', async () => {
const adapter = makeAdapter([])
mount({ category: 'beginner', title: 'Best', 'score-order': 'desc' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
expect(adapter.listScores.mock.calls[0][0].order).toBe('desc')
})
it('re-queries the clicked tab window in place', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
await vi.waitFor(() => expect(tabButtons(el)).toHaveLength(4))
tabByLabel(el, 'Week').click()
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
const q = adapter.listScores.mock.calls[1][0]
const weekMs = 7 * 24 * 60 * 60 * 1000
expect(Math.abs(Date.now() - weekMs - q.since.getTime())).toBeLessThan(5000)
await vi.waitFor(() =>
expect(tabByLabel(el, 'Week').style.fontWeight).toBe('bold'))
expect(tabByLabel(el, 'Today').style.fontWeight).toBe('normal')
})
it('keeps focus on the clicked tab', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
await vi.waitFor(() => expect(tabButtons(el)).toHaveLength(4))
const week = tabByLabel(el, 'Week')
week.focus()
week.click()
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
expect(document.activeElement?.textContent).toBe('Week')
})
})
describe('attribute changes after mount', () => {
it('category change re-queries with the new category and keeps the selected tab', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
await vi.waitFor(() => expect(tabButtons(el)).toHaveLength(4))
tabByLabel(el, 'Week').click()
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
el.setAttribute('category', 'expert')
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(3))
const q = adapter.listScores.mock.calls[2][0]
expect(q.category).toBe('expert')
const weekMs = 7 * 24 * 60 * 60 * 1000
expect(Math.abs(Date.now() - weekMs - q.since.getTime())).toBeLessThan(5000)
await vi.waitFor(() =>
expect(tabByLabel(el, 'Week').style.fontWeight).toBe('bold'))
})
it('title change updates the heading', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
await vi.waitFor(() => expect(el.querySelector('h3')).toBeTruthy())
el.setAttribute('title', 'Best Times (Expert)')
await vi.waitFor(() =>
expect(el.querySelector('h3').textContent).toBe('Best Times (Expert)'))
})
it('duration change switches the window', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(1))
el.setAttribute('duration', 'month')
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
const q = adapter.listScores.mock.calls[1][0]
const monthMs = 30 * 24 * 60 * 60 * 1000
expect(Math.abs(Date.now() - monthMs - q.since.getTime())).toBeLessThan(5000)
await vi.waitFor(() =>
expect(tabByLabel(el, 'Month').style.fontWeight).toBe('bold'))
})
it('a stale query result never overwrites a newer one', async () => {
const deferred = {}
const adapter = makeAdapter([], {
listScores: vi.fn(({ category }) => new Promise(r => { deferred[category] = r }))
})
const el = mount({ category: 'aaa', title: 'Best' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(1))
el.setAttribute('category', 'bbb')
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
deferred.bbb([{ name: 'NewRow', score: 2 }])
await vi.waitFor(() => expect(rowsText(el)).toContain('NewRow'))
deferred.aaa([{ name: 'StaleRow', score: 1 }]) // stale response arrives late
await new Promise(r => setTimeout(r, 20))
expect(rowsText(el)).not.toContain('StaleRow')
expect(rowsText(el)).toContain('NewRow')
})
})
describe('empty and error states', () => {
it('shows one of the empty-state messages when there are no rows', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
await vi.waitFor(() => {
const messages = [...el.querySelectorAll('em')].map(em => em.textContent)
expect(messages.some(m => DEFAULT_EMPTY_MESSAGES.includes(m))).toBe(true)
})
})
it('turns a failing query into the error message', async () => {
const adapter = makeAdapter([], {
listScores: vi.fn(async () => { throw new Error('backend down') })
})
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
await vi.waitFor(() =>
expect(rowsText(el)).toContain('Leaderboard unavailable right now.'))
expect(el.querySelector('h3')).toBeTruthy() // heading + tabs survive the failure
})
})
describe('formatting and user-facing strings', () => {
it('format=\'time\' renders scores with the pretty-time formatter', async () => {
const adapter = makeAdapter([
{ name: 'Ada', score: 4200 },
{ name: 'Bo', score: 61300 }
])
const el = mount({ category: 'b', title: 'T', format: 'time' }, { adapter })
await vi.waitFor(() => {
expect(rowsText(el)).toContain('04.2')
expect(rowsText(el)).toContain('01:01.3')
})
})
it('a per-element formatScore property wins over the format attribute', async () => {
const adapter = makeAdapter([{ name: 'Ada', score: 4200 }])
const el = mount(
{ category: 'b', title: 'T', format: 'time' },
{ adapter, formatScore: v => `${v}pts` }
)
await vi.waitFor(() => expect(rowsText(el)).toContain('4200pts'))
})
it('falls back to shared-config format from configureLeaderboard()', async () => {
const adapter = makeAdapter([{ name: 'Ada', score: 4200 }])
configureLeaderboard({ adapter, format: 'time' })
const el = mount({ category: 'b', title: 'T' })
await vi.waitFor(() => expect(rowsText(el)).toContain('04.2'))
})
it('honors per-element string overrides (loadingText, errorText, anonymousName)', async () => {
let reject
const adapter = makeAdapter([], {
listScores: vi.fn(() => new Promise((_, rj) => { reject = rj }))
})
const el = mount({ category: 'b', title: 'T' }, {
adapter,
loadingText: 'Hold on…',
errorText: 'Nope.',
anonymousName: 'Mystery Player'
})
await vi.waitFor(() => expect(rowsText(el)).toContain('Hold on…'))
reject(new Error('x'))
await vi.waitFor(() => expect(rowsText(el)).toContain('Nope.'))
})
it('uses the anonymous name for rows without a name', async () => {
const adapter = makeAdapter([{ score: 9 }])
const el = mount({ category: 'b', title: 'T' }, { adapter, anonymousName: 'Mystery' })
await vi.waitFor(() => expect(rowsText(el)).toContain('Mystery'))
})
it('honors shared-config labels, tooltips and emptyMessages', async () => {
const adapter = makeAdapter([])
configureLeaderboard({
adapter,
labels: { today: 'Heute' },
tooltips: { today: 'Letzte 24 Stunden' },
emptyMessages: ['Nichts hier.']
})
const el = mount({ category: 'b', title: 'T' })
await vi.waitFor(() => {
const tab = tabByLabel(el, 'Heute')
expect(tab).toBeTruthy()
expect(tab.getAttribute('title')).toBe('Letzte 24 Stunden')
expect(rowsText(el)).toContain('Nichts hier.')
})
})
})
describe('submit()', () => {
const entry = () => ({
name: 'Zed',
playerId: 'p1',
score: 42,
category: 'beginner',
time_stamp: new Date('2026-07-03T12:00:00Z'),
status: 'win',
meta: { isMobile: false }
})
it('archives and writes a ranked entry with bucket keys', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'T' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
await el.submit(entry())
expect(adapter.archive).toHaveBeenCalledWith({
playerId: 'p1',
score: 42,
category: 'beginner',
time_stamp: entry().time_stamp,
meta: { isMobile: false }
})
expect(adapter.addScore).toHaveBeenCalledTimes(1)
const [category, doc] = adapter.addScore.mock.calls[0]
expect(category).toBe('beginner')
expect(doc).toMatchObject({
name: 'Zed',
playerId: 'p1',
score: 42,
day: '2026-07-03',
week: '2026-W27',
month: '2026-07'
})
})
it('skips the ranked write when the entry does not qualify', async () => {
const adapter = makeAdapter([], {
getConfig: vi.fn(async () => ({ passingStatus: 'win' }))
})
const el = mount({ category: 'beginner', title: 'T' }, { adapter })
await vi.waitFor(() => expect(adapter.getConfig).toHaveBeenCalled())
await new Promise(r => setTimeout(r, 0)) // let the writer store the config
await el.submit({ ...entry(), status: 'lose' })
expect(adapter.archive).toHaveBeenCalledTimes(1)
expect(adapter.addScore).not.toHaveBeenCalled()
})
it('honors a per-element qualifies override', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'beginner', title: 'T' }, {
adapter,
qualifies: () => false
})
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
await el.submit(entry())
expect(adapter.addScore).not.toHaveBeenCalled()
})
it('is a safe no-op on an unconfigured element', async () => {
const el = mount({ category: 'beginner', title: 'T' })
await vi.waitFor(() => expect(rowsText(el)).toContain('Leaderboard not configured.'))
expect(el.submit(entry())).toBeUndefined()
})
})
describe('regressions pinned after review', () => {
it('renders entry names as text, never as HTML (element path)', async () => {
const adapter = makeAdapter([{ name: '<img src=x onerror=alert(1)>', score: 1 }])
const el = mount({ category: 'b', title: 'T' }, { adapter })
await vi.waitFor(() => expect(rowsText(el)).toContain('<img src=x onerror=alert(1)>'))
expect(el.querySelector('img')).toBeNull()
})
it('an invalid duration id shows the error text without querying the adapter', async () => {
const adapter = makeAdapter([{ name: 'Ada', score: 1 }])
const el = mount({ category: 'b', title: 'T', duration: 'yesterday' }, { adapter })
await vi.waitFor(() =>
expect(rowsText(el)).toContain('Leaderboard unavailable right now.'))
expect(adapter.listScores).not.toHaveBeenCalled()
})
it('a title change does not re-query the backend', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'b', title: 'T' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(1))
el.setAttribute('title', 'New Title')
await vi.waitFor(() => expect(el.querySelector('h3').textContent).toBe('New Title'))
expect(adapter.listScores).toHaveBeenCalledTimes(1)
})
it('a score-order change after mount takes effect on the next query', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'b', title: 'T', 'score-order': 'asc' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(1))
expect(adapter.listScores.mock.calls[0][0].order).toBe('asc')
el.setAttribute('score-order', 'desc')
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
expect(adapter.listScores.mock.calls[1][0].order).toBe('desc')
})
it('a format change after mount takes effect on the next render', async () => {
const adapter = makeAdapter([{ name: 'Ada', score: 4200 }])
const el = mount({ category: 'b', title: 'T' }, { adapter })
await vi.waitFor(() => expect(rowsText(el)).toContain('4200'))
el.setAttribute('format', 'time')
await vi.waitFor(() => expect(rowsText(el)).toContain('04.2'))
})
it('a score-order change while detached takes effect on re-connect', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'b', title: 'T', 'score-order': 'asc' }, { adapter })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(1))
el.remove()
el.setAttribute('score-order', 'desc')
document.body.append(el)
await vi.waitFor(() => {
const calls = adapter.listScores.mock.calls
expect(calls[calls.length - 1][0].order).toBe('desc')
})
})
it('configureLeaderboard() re-mounts every live element, keeping each selected tab', async () => {
const adapter = makeAdapter([])
configureLeaderboard({ adapter })
const one = mount({ category: 'a', title: 'One' })
const two = mount({ category: 'b', title: 'Two' })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
tabByLabel(two, 'Week').click()
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(3))
// Re-configuring re-mounts BOTH live elements (string overrides won't
// apply to their already-cached services — pinned separately below).
configureLeaderboard({})
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(5))
// element two kept its Week selection through the shared re-mount
await vi.waitFor(() => {
expect(tabByLabel(two, 'Week').style.fontWeight).toBe('bold')
expect(tabByLabel(one, 'Today').style.fontWeight).toBe('bold')
})
})
it('a detached element is not re-mounted by configureLeaderboard, but remembers its tab on re-connect', async () => {
const adapter = makeAdapter([])
const el = mount({ category: 'b', title: 'T' }, { adapter })
await vi.waitFor(() => expect(tabButtons(el)).toHaveLength(4))
tabByLabel(el, 'Month').click()
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
el.remove()
const callsWhileDetached = adapter.listScores.mock.calls.length
configureLeaderboard({ loadingText: 'Wait…' })
expect(adapter.listScores.mock.calls.length).toBe(callsWhileDetached)
document.body.append(el)
await vi.waitFor(() =>
expect(tabByLabel(el, 'Month').style.fontWeight).toBe('bold'))
})
it('keeps the cached service (old adapter) when configureLeaderboard swaps adapters later', async () => {
const first = makeAdapter([])
configureLeaderboard({ adapter: first })
mount({ category: 'b', title: 'T' })
await vi.waitFor(() => expect(first.listScores).toHaveBeenCalledTimes(1))
const second = makeAdapter([])
configureLeaderboard({ adapter: second })
await vi.waitFor(() => expect(first.listScores).toHaveBeenCalledTimes(2))
expect(second.listScores).not.toHaveBeenCalled() // documented caching semantics
})
it('keeps the cached service (old strings) when configureLeaderboard changes strings later', async () => {
const adapter = makeAdapter([])
configureLeaderboard({ adapter })
const el = mount({ category: 'b', title: 'T' })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(1))
configureLeaderboard({ emptyMessages: ['Nothing here yet.'] })
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
// the already-built service keeps its original strings (documented caching)
expect(rowsText(el)).not.toContain('Nothing here yet.')
})
it('keeps focus on a clicked tab when composed inside a shadow root', async () => {
const adapter = makeAdapter([])
const host = document.createElement('div')
const shadow = host.attachShadow({ mode: 'open' })
const el = /** @type {any} */ (document.createElement('cozy-leaderboard'))
el.setAttribute('category', 'b')
el.setAttribute('title', 'T')
el.adapter = adapter
shadow.append(el)
document.body.append(host)
await vi.waitFor(() => expect(el.querySelectorAll('button')).toHaveLength(4))
const week = [...el.querySelectorAll('button')].find(b => b.textContent === 'Week')
week.focus()
week.click()
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
expect(shadow.activeElement?.textContent).toBe('Week')
})
it('pins prettyTime edge cases through the format attribute', async () => {
const adapter = makeAdapter([
{ name: 'Zero', score: 0 },
{ name: 'Minute', score: 60000 }
])
const el = mount({ category: 'b', title: 'T', format: 'time' }, { adapter })
await vi.waitFor(() => {
expect(rowsText(el)).toContain('Zero')
expect(rowsText(el)).toContain('0') // falsy score -> '0'
expect(rowsText(el)).toContain('01:0') // exact minute -> '01:0'
})
})
})

View file

@ -0,0 +1,123 @@
// @ts-check
import { describe, it, expect, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { LeaderBoardReader } from '../leaderboard-read.js'
import { LeaderBoardWriter } from '../leaderboard-write.js'
import { LeaderBoardService } from '../leader-board.js'
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
const source = (file) => readFileSync(join(pkgDir, file), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments
.replace(/\/\/.*$/gm, '') // strip line comments
describe('read/write surface separation — imports', () => {
it('the READ module pulls in no write-path code', () => {
const code = source('leaderboard-read.js')
// no import of the write module or its unique dependency (bucket keys)
expect(code).not.toMatch(/from\s+['"][^'"]*leaderboard-write/)
expect(code).not.toMatch(/date-bucket|buckets/)
// no write operations
expect(code).not.toMatch(/\bsubmit\b|\baddScore\b|\barchive\b/)
})
it('the WRITE module pulls in no read/render code', () => {
const code = source('leaderboard-write.js')
expect(code).not.toMatch(/from\s+['"][^'"]*leaderboard-read/)
// no DOM / rendering / listing
expect(code).not.toMatch(/\bdocument\b|\brender\b|\blistScores\b|createElement/)
})
})
describe('read surface — usable standalone (no writes)', () => {
it('renders a ranked list via listScores and never calls write methods', async () => {
const listScores = vi.fn(async () => [{ name: 'Ada', score: 10 }, { name: 'Bo', score: 20 }])
// A deliberately read-only adapter: no addScore/archive at all.
const adapter = { listScores }
const reader = new LeaderBoardReader({ adapter, scoreOrder: 'asc', formatScore: String })
const el = await reader.render('beginner', 'Best Times')
expect(el.querySelector('h3')).toBeTruthy() // heading rendered
expect(el.querySelectorAll('button')).toHaveLength(4) // four duration tabs
expect(listScores).toHaveBeenCalledTimes(1) // 'today' window loaded once
// The list fills asynchronously — wait for the rows to appear (names render as text).
await vi.waitFor(() => {
expect(el.textContent).toContain('Ada')
expect(el.textContent).toContain('Bo')
})
// Reader exposes no write API.
expect(typeof (/** @type {any} */ (reader).submit)).toBe('undefined')
})
it('renders entry names as text, never as HTML (names are player-controlled)', async () => {
const listScores = vi.fn(async () => [{ name: '<img src=x onerror=alert(1)>', score: 1 }])
const reader = new LeaderBoardReader({ adapter: { listScores } })
const el = await reader.render('beginner', 'Best Times')
await vi.waitFor(() =>
expect(el.textContent).toContain('<img src=x onerror=alert(1)>'))
expect(el.querySelector('img')).toBeNull()
})
})
describe('write surface — usable standalone with an injected instance', () => {
it('submits a completed game through the injected adapter (archive + ranked write)', async () => {
const archive = vi.fn(async () => {})
const addScore = vi.fn(async () => {})
// Injected, write-capable adapter (no listScores/render needed).
const adapter = { archive, addScore }
const writer = new LeaderBoardWriter({ adapter })
await writer.submit({
name: 'Ada', playerId: 'p1', score: 42, category: 'beginner', time_stamp: Date.UTC(2026, 6, 3)
})
expect(archive).toHaveBeenCalledTimes(1)
expect(addScore).toHaveBeenCalledTimes(1)
const [category, doc] = addScore.mock.calls[0]
expect(category).toBe('beginner')
expect(doc).toMatchObject({ name: 'Ada', playerId: 'p1', score: 42 })
// denormalized bucket keys are computed on the write side
expect(doc).toEqual(expect.objectContaining({ day: expect.any(String), week: expect.any(String), month: expect.any(String) }))
// Writer exposes no read/render API.
expect(typeof (/** @type {any} */ (writer).render)).toBe('undefined')
})
it('honors an explicit qualifies gate (skips the ranked write, still archives)', async () => {
const archive = vi.fn(async () => {})
const addScore = vi.fn(async () => {})
const writer = new LeaderBoardWriter({ adapter: { archive, addScore }, qualifies: () => false })
await writer.submit({ playerId: 'p1', score: 1, category: 'beginner', time_stamp: 0 })
expect(archive).toHaveBeenCalledTimes(1)
expect(addScore).not.toHaveBeenCalled()
})
})
describe('facade — combined surface unchanged (regression)', () => {
it('LeaderBoardService delegates render (read) and submit (write)', async () => {
const listScores = vi.fn(async () => [{ name: 'Ada', score: 10 }])
const archive = vi.fn(async () => {})
const addScore = vi.fn(async () => {})
const adapter = { listScores, archive, addScore }
const service = new LeaderBoardService({ adapter, formatScore: String })
// read
const el = await service.render('beginner', 'Best')
expect(el.querySelector('h3')).toBeTruthy()
expect(listScores).toHaveBeenCalled()
await vi.waitFor(() => expect(el.textContent).toContain('Ada'))
// write
await service.submit({ name: 'Ada', playerId: 'p1', score: 5, category: 'beginner', time_stamp: 0 })
expect(addScore).toHaveBeenCalledTimes(1)
// composed surfaces are reachable
expect(service.reader).toBeInstanceOf(LeaderBoardReader)
expect(service.writer).toBeInstanceOf(LeaderBoardWriter)
})
})

View file

@ -0,0 +1,48 @@
// @ts-check
import { describe, it, expect, vi } from 'vitest'
import { SupabaseAdapter } from '../adapters/supabase.js'
// A chainable stand-in for a supabase-js client (consumer-constructed — the
// package takes no supabase dependency). A privileged/service-role client is
// supplied the same way.
function mockClient() {
const insert = vi.fn(async () => ({ error: null }))
const from = vi.fn(() => ({ insert }))
return { from, insert }
}
describe('SupabaseAdapter — consumer-supplied client (injection point exists)', () => {
it('uses the injected client as-is', () => {
const client = mockClient()
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
expect(adapter.client).toBe(client)
})
it('performs a write through the injected client', async () => {
const client = mockClient()
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
await adapter.addScore('beginner', {
name: 'A', playerId: 'p1', score: 5, category: 'beginner', time_stamp: 't'
})
expect(client.from).toHaveBeenCalledWith('mw_scores')
expect(client.insert).toHaveBeenCalledTimes(1)
expect(client.insert.mock.calls[0][0]).toMatchObject({ name: 'A', player_id: 'p1', score: 5 })
})
it('archives through the injected client', async () => {
const client = mockClient()
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
await adapter.archive({ playerId: 'p1', score: 9, category: 'beginner', time_stamp: 't' })
expect(client.from).toHaveBeenCalledWith('mw_archive')
expect(client.insert.mock.calls[0][0]).toMatchObject({ player_id: 'p1', score: 9 })
})
it('surfaces backend errors from the injected client', async () => {
const insert = vi.fn(async () => ({ error: new Error('permission denied') }))
const client = { from: vi.fn(() => ({ insert })) }
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
await expect(adapter.addScore('beginner', { playerId: 'p' })).rejects.toThrow('permission denied')
})
})

View file

@ -61,7 +61,7 @@ npm i -D vite
Now that the JS project is initialized and we have a development environment with `vite`, we will install **mnswpr** as a dependency: Now that the JS project is initialized and we have a development environment with `vite`, we will install **mnswpr** as a dependency:
```bash ```bash
npm i @ayo-run/mnswpr npm i @cozy-games/mnswpr
``` ```
Finally, you can run the installed `vite` dev server by running the following: Finally, you can run the installed `vite` dev server by running the following:
@ -141,8 +141,8 @@ Create a new file named `main.js` with the following content:
/** /**
* main.js * main.js
*/ */
import '@ayo-run/mnswpr/mnswpr.css' import '@cozy-games/mnswpr/mnswpr.css'
import mnswpr from '@ayo-run/mnswpr' import mnswpr from '@cozy-games/mnswpr'
const game = new mnswpr('app') const game = new mnswpr('app')
game.initialize() game.initialize()

View file

@ -0,0 +1,24 @@
// @ts-check
/**
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
*/
/**
* 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.
*
* @param {MnswprMoveEvent} e
* @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 }
case 'flag':
case 'unflag': return { type: 'flag', r: e.r, c: e.c }
default: return null
}
}

View file

@ -0,0 +1,40 @@
// @ts-check
import { MinesweeperRules } from '../core/minesweeper/rules.js'
import { toMove } from './replay-common.js'
/**
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
* @typedef {import('../core/minesweeper/board.js').Layout} Layout
*/
/**
* The percent-cleared progress reducer for Minesweeper mnswpr's first concrete
* implementation of the replay engine's `ProgressReducer<MnswprMoveEvent>` seam
* (see `@cozy-games/replay` / replay-02). Given the ordered slice of move-events
* played so far, it returns completion as
* `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.
*
* @param {Layout} layout - the recorded board (as produced by `generateBoard`)
* @returns {(events: { event: MnswprMoveEvent }[]) => number} a reducer to `[0, 100]`
*/
export function createProgressReducer(layout) {
const totalSafe = layout.rows * layout.cols - layout.mines
return function progress(events) {
// A board with no safe cells is vacuously fully cleared (and avoids /0).
if (totalSafe === 0) return 100
let state = MinesweeperRules.fromLayout(layout)
for (const record of events) {
const move = toMove(record.event)
if (move) state = MinesweeperRules.apply(state, move).state
}
return (state.revealedSafe / totalSafe) * 100
}
}

View file

@ -0,0 +1,57 @@
// @ts-check
import { MinesweeperRules } from '../core/minesweeper/rules.js'
import { toMove } from './replay-common.js'
/**
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
* @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.
*
* 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
* neighbors, and flags toggle giving an exact reconstruction at any event
* index. Statelessly a function of the slice, so the engine can jump (seek) to
* any position and rebuild the board there.
*
* @param {Layout} layout - the recorded board (as produced by `generateBoard`)
* @returns {(events: { event: MnswprMoveEvent }[]) => BoardState}
*/
export function createStateReducer(layout) {
return function state(events) {
let s = MinesweeperRules.fromLayout(layout)
for (const record of events) {
const move = toMove(record.event)
if (move) s = MinesweeperRules.apply(s, move).state
}
return toBoard(s)
}
}
/**
* Project a core game state into a plain, render-ready 2D board snapshot.
* @param {import('../core/minesweeper/rules.js').State} s
* @returns {BoardState}
*/
function toBoard(s) {
const { rows, cols } = s.config
/** @type {BoardCell[][]} */
const cells = []
for (let r = 0; r < rows; r++) {
/** @type {BoardCell[]} */
const row = []
for (let c = 0; c < cols; c++) {
const cell = s.grid.at(r, c)
row.push({ mine: cell.mine, adjacent: cell.adjacent, status: cell.status })
}
cells.push(row)
}
return { rows, cols, phase: s.phase, revealedSafe: s.revealedSafe, cells }
}

View file

@ -1,7 +1,7 @@
// @ts-check // @ts-check
/** /**
* `@ayo-run/mnswpr/core` the headless, isomorphic Minesweeper core. No DOM, no * `@cozy-games/mnswpr/core` the headless, isomorphic Minesweeper core. No DOM, no
* wall clock. Runs identically in a browser (offline play) or on a server * wall clock. Runs identically in a browser (offline play) or on a server
* (authoritative timing / replay verification). See * (authoritative timing / replay verification). See
* docs/headless-core-and-client-design.md. * docs/headless-core-and-client-design.md.
@ -18,8 +18,8 @@ export { replay } from './session/replay.js'
export { mulberry32, randInt } from './session/rng.js' export { mulberry32, randInt } from './session/rng.js'
// Layer 2 — Minesweeper rules & pure board generation // Layer 2 — Minesweeper rules & pure board generation
export { MinesweeperRules } from './minesweeper/rules.js' export { MinesweeperRules, MOVE_EVENT_TYPES } from './minesweeper/rules.js'
export { generateBoard } from './minesweeper/board.js' export { generateBoard, validateLayout } from './minesweeper/board.js'
// Shared level presets (also consumed by the DOM client) // Shared level presets (also consumed by the DOM client)
export { levels } from '../levels.js' export { levels } from '../levels.js'

View file

@ -83,10 +83,70 @@ export function fillMines(rng, config, exclude, grid) {
return placed return placed
} }
/**
* Assert a plain layout object (as produced by {@link generateBoard}) is
* well-formed before it's injected into a game: correct dimensions, cell shape,
* a mine count that matches its own cells, and in-bounds mine positions. Throws a
* clear error on the first problem so a malformed board can't silently corrupt
* win detection or adjacency. Returns the layout for convenient chaining.
*
* @param {unknown} layout
* @returns {Layout}
*/
export function validateLayout(layout) {
if (layout === null || typeof layout !== 'object') {
throw new TypeError(`validateLayout: expected a layout object (got ${layout === null ? 'null' : typeof layout})`)
}
const { rows, cols, mines, cells, mineLocations } = /** @type {any} */ (layout)
if (!Number.isInteger(rows) || !Number.isInteger(cols) || rows < 1 || cols < 1) {
throw new RangeError(`validateLayout: rows/cols must be positive integers (got ${rows}x${cols})`)
}
if (!Array.isArray(cells) || cells.length !== rows) {
throw new RangeError(`validateLayout: cells must have ${rows} rows (got ${Array.isArray(cells) ? cells.length : typeof cells})`)
}
let mineCount = 0
for (let r = 0; r < rows; r++) {
const row = cells[r]
if (!Array.isArray(row) || row.length !== cols) {
throw new RangeError(`validateLayout: row ${r} must have ${cols} cells (got ${Array.isArray(row) ? row.length : typeof row})`)
}
for (let c = 0; c < cols; c++) {
const cell = row[c]
if (cell === null || typeof cell !== 'object' || typeof cell.mine !== 'boolean' || !Number.isInteger(cell.adjacent)) {
throw new TypeError(`validateLayout: cell (${r},${c}) must be { mine: boolean, adjacent: integer }`)
}
if (cell.mine) mineCount++
}
}
if (!Number.isInteger(mines) || mines < 0 || mines > rows * cols) {
throw new RangeError(`validateLayout: mines must be an integer in [0, ${rows * cols}] (got ${mines})`)
}
if (mineCount !== mines) {
throw new RangeError(`validateLayout: mines (${mines}) disagrees with ${mineCount} mined cells`)
}
// mineLocations is optional, but if present it must agree with the grid — the
// "dimensions vs. mine positions" cross-check.
if (mineLocations !== undefined) {
if (!Array.isArray(mineLocations) || mineLocations.length !== mineCount) {
throw new RangeError(`validateLayout: mineLocations must list all ${mineCount} mines (got ${Array.isArray(mineLocations) ? mineLocations.length : typeof mineLocations})`)
}
for (const loc of mineLocations) {
if (!Array.isArray(loc) || loc.length !== 2) {
throw new TypeError(`validateLayout: each mineLocation must be a [r, c] pair (got ${JSON.stringify(loc)})`)
}
const [r, c] = loc
if (!Number.isInteger(r) || !Number.isInteger(c) || r < 0 || c < 0 || r >= rows || c >= cols || !cells[r][c].mine) {
throw new RangeError(`validateLayout: mineLocation [${r}, ${c}] is out of bounds or not a mined cell`)
}
}
}
return /** @type {Layout} */ (layout)
}
/** /**
* Pure, Node-runnable board generation: given a size, a mine count, and an * Pure, Node-runnable board generation: given a size, a mine count, and an
* injected RNG, produce a plain layout object no DOM, no I/O, no `Grid` class * injected RNG, produce a plain layout object no DOM, no I/O, no `Grid` class
* leaking out. This is the headless entry point behind `@ayo-run/mnswpr/core`; * leaking out. This is the headless entry point behind `@cozy-games/mnswpr/core`;
* the DOM client reaches the same generator lazily through `MinesweeperRules`. * the DOM client reaches the same generator lazily through `MinesweeperRules`.
* *
* The injected `rng` is the determinism seam: the same rng sequence always * The injected `rng` is the determinism seam: the same rng sequence always
@ -94,24 +154,45 @@ export function fillMines(rng, config, exclude, grid) {
* wrapped with {@link mulberry32}, keeping generation reproducible and free of * wrapped with {@link mulberry32}, keeping generation reproducible and free of
* `Math.random` (invariant #4). * `Math.random` (invariant #4).
* *
* First-move safety: pass `safeCell: { r, c }` to guarantee that cell is never a
* mine the coordinate-friendly front door to the low-level `exclude` set, so
* callers don't have to know the `r * cols + c` key encoding. It merges with any
* `exclude` given, and the capacity check below rejects layouts where the mines
* can't fit once it's carved out. For 3x3 first-click *flood* safety (the clicked
* cell plus its 8 neighbors), see {@link excludeAround}.
*
* @param {number} rows - number of rows (board height) * @param {number} rows - number of rows (board height)
* @param {number} cols - number of columns (board width) * @param {number} cols - number of columns (board width)
* @param {number} mines - number of mines to place * @param {number} mines - number of mines to place
* @param {{ rng?: () => number, seed?: number, exclude?: Set<number> }} [options] * @param {{ rng?: () => number, seed?: number, exclude?: Set<number>, safeCell?: { r: number, c: number } }} [options]
* @returns {Layout} a plain, serializable layout object * @returns {Layout} a plain, serializable layout object
*/ */
export function generateBoard(rows, cols, mines, { rng, seed = 0, exclude = NO_EXCLUDE } = {}) { export function generateBoard(rows, cols, mines, { rng, seed = 0, exclude = NO_EXCLUDE, safeCell } = {}) {
if (!Number.isInteger(rows) || !Number.isInteger(cols) || rows < 1 || cols < 1) { if (!Number.isInteger(rows) || !Number.isInteger(cols) || rows < 1 || cols < 1) {
throw new RangeError(`generateBoard: rows/cols must be positive integers (got ${rows}x${cols})`) throw new RangeError(`generateBoard: rows/cols must be positive integers (got ${rows}x${cols})`)
} }
const capacity = rows * cols - exclude.size
// Resolve the first-move-safe cell into the exclude set (non-mutating: never
// touch a caller-owned set). An out-of-bounds safeCell fails loudly rather than
// silently excluding nothing and handing back a board that could mine it.
let excludeSet = exclude
if (safeCell !== undefined) {
const { r, c } = safeCell
if (!Number.isInteger(r) || !Number.isInteger(c) || r < 0 || c < 0 || r >= rows || c >= cols) {
throw new RangeError(`generateBoard: safeCell must be an in-bounds { r, c } (got { r: ${r}, c: ${c} } on ${rows}x${cols})`)
}
excludeSet = new Set(exclude)
excludeSet.add(r * cols + c)
}
const capacity = rows * cols - excludeSet.size
if (!Number.isInteger(mines) || mines < 0 || mines > capacity) { if (!Number.isInteger(mines) || mines < 0 || mines > capacity) {
throw new RangeError(`generateBoard: mines must be an integer in [0, ${capacity}] (got ${mines})`) throw new RangeError(`generateBoard: mines must be an integer in [0, ${capacity}] (got ${mines})`)
} }
const config = { rows, cols, mines } const config = { rows, cols, mines }
const grid = new Grid(rows, cols, () => ({ mine: false, adjacent: 0, status: 'hidden' })) const grid = new Grid(rows, cols, () => ({ mine: false, adjacent: 0, status: 'hidden' }))
fillMines(rng ?? mulberry32(seed), config, exclude, grid) fillMines(rng ?? mulberry32(seed), config, excludeSet, grid)
/** @type {LayoutCell[][]} */ /** @type {LayoutCell[][]} */
const cells = [] const cells = []

View file

@ -1,7 +1,8 @@
// @ts-check // @ts-check
import { Grid } from '../grid/grid.js' import { Grid } from '../grid/grid.js'
import { toJSON as gridToJSON, fromJSON as gridFromJSON } from '../grid/serialize.js'
import { eightWay } from '../grid/neighbors.js' import { eightWay } from '../grid/neighbors.js'
import { placeMines, excludeAround } from './board.js' import { placeMines, excludeAround, validateLayout } from './board.js'
import { floodReveal, countFlagsAround, allMines } from './reveal.js' import { floodReveal, countFlagsAround, allMines } from './reveal.js'
/** /**
@ -16,8 +17,66 @@ import { floodReveal, countFlagsAround, allMines } from './reveal.js'
* @typedef {object} Event * @typedef {object} Event
*/ */
/**
* 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
* envelope. `type` + `r`/`c` are game meaning (classified here); `t` (injected
* clock) and `seq` (monotonic) are stamped by `GameSession` when it emits.
*
* Note `flag` vs `unflag`: both come from a `flag` move the distinction is the
* outcome (did the toggle set or clear the flag), which only the rules can tell.
*
* @typedef {'reveal' | 'flag' | 'unflag' | 'chord'} MoveEventType
* @typedef {{ type: MoveEventType, r: number, c: number, t: number, seq: number }} MoveEvent
*/
/** The move-event vocabulary as runtime data (the `MoveEvent` `type` domain). */
export const MOVE_EVENT_TYPES = /** @type {const} */ (['reveal', 'flag', 'unflag', 'chord'])
const freshCell = () => ({ mine: false, adjacent: 0, status: 'hidden' }) const freshCell = () => ({ mine: false, adjacent: 0, status: 'hidden' })
/** Valid game phases and per-cell statuses — the closed sets a snapshot may name. */
const PHASES = new Set(['fresh', 'active', 'won', 'lost'])
const CELL_STATUSES = new Set(['hidden', 'flagged', 'revealed'])
/**
* Assert a serialized game state (from {@link MinesweeperRules.serialize}, or its
* JSON round-trip) is well-formed before reviving it, so a corrupt or truncated
* snapshot fails with a clear error instead of quietly producing a broken game.
*
* @param {unknown} snap
*/
function assertStateSnapshot(snap) {
if (snap === null || typeof snap !== 'object') {
throw new TypeError(`deserialize: expected a state snapshot object (got ${snap === null ? 'null' : typeof snap})`)
}
const s = /** @type {any} */ (snap)
if (typeof s.seed !== 'number') throw new TypeError('deserialize: snapshot.seed must be a number')
const cfg = s.config
if (cfg === null || typeof cfg !== 'object' || !Number.isInteger(cfg.rows) || !Number.isInteger(cfg.cols) || !Number.isInteger(cfg.mines)) {
throw new TypeError('deserialize: snapshot.config must be { rows, cols, mines } integers')
}
if (!PHASES.has(s.phase)) throw new RangeError(`deserialize: snapshot.phase must be one of ${[...PHASES].join('/')} (got ${JSON.stringify(s.phase)})`)
if (typeof s.minesPlaced !== 'boolean') throw new TypeError('deserialize: snapshot.minesPlaced must be a boolean')
if (!Number.isInteger(s.revealedSafe) || s.revealedSafe < 0) throw new RangeError('deserialize: snapshot.revealedSafe must be a non-negative integer')
const g = s.grid
if (g === null || typeof g !== 'object' || !Number.isInteger(g.rows) || !Number.isInteger(g.cols) || !Array.isArray(g.cells)) {
throw new TypeError('deserialize: snapshot.grid must be { rows, cols, cells[] }')
}
if (g.rows !== cfg.rows || g.cols !== cfg.cols) {
throw new RangeError(`deserialize: grid ${g.rows}x${g.cols} disagrees with config ${cfg.rows}x${cfg.cols}`)
}
if (g.cells.length !== g.rows * g.cols) {
throw new RangeError(`deserialize: grid.cells must have ${g.rows * g.cols} entries (got ${g.cells.length})`)
}
for (let i = 0; i < g.cells.length; i++) {
const cell = g.cells[i]
if (cell === null || typeof cell !== 'object' || typeof cell.mine !== 'boolean' || !Number.isInteger(cell.adjacent) || !CELL_STATUSES.has(cell.status)) {
throw new TypeError(`deserialize: grid.cells[${i}] must be { mine: boolean, adjacent: integer, status: hidden/flagged/revealed }`)
}
}
}
/** @param {State} state */ /** @param {State} state */
function isWin(state) { function isWin(state) {
const { rows, cols, mines } = state.config const { rows, cols, mines } = state.config
@ -35,11 +94,16 @@ function reveal(state, r, c) {
// First reveal: generate the board now, excluding this cell's neighborhood, so // First reveal: generate the board now, excluding this cell's neighborhood, so
// the opening click is always safe and the seed fully determines the layout. // the opening click is always safe and the seed fully determines the layout.
// Injected boards (fromLayout) arrive with minesPlaced already true and skip
// this — their opening reveal plays exactly as the layout dictates.
if (!state.minesPlaced) { if (!state.minesPlaced) {
placeMines(state.seed, state.config, excludeAround(state.config, r, c), state.grid) placeMines(state.seed, state.config, excludeAround(state.config, r, c), state.grid)
state.minesPlaced = true state.minesPlaced = true
state.phase = 'active'
} }
// fresh → active on the first real reveal, whether the board was just generated
// or injected pre-built — decoupled from placement so both paths transition the
// same way.
if (state.phase === 'fresh') state.phase = 'active'
if (cell.mine) { if (cell.mine) {
cell.status = 'revealed' cell.status = 'revealed'
@ -121,7 +185,8 @@ function project(state) {
/** /**
* The GameRules contract consumed by GameSession/replay: init / apply / status / * The GameRules contract consumed by GameSession/replay: init / apply / status /
* project. Deterministic and DOM-free. * project, plus serialize / deserialize for snapshotting. Deterministic and
* DOM-free.
*/ */
export const MinesweeperRules = { export const MinesweeperRules = {
/** /**
@ -140,6 +205,36 @@ export const MinesweeperRules = {
} }
}, },
/**
* 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}
*/
fromLayout(layout, { seed = 0 } = {}) {
validateLayout(layout)
const { rows, cols, mines } = layout
return {
seed,
config: { rows, cols, mines },
grid: new Grid(rows, cols, (r, c) => ({
mine: layout.cells[r][c].mine,
adjacent: layout.cells[r][c].adjacent,
status: 'hidden'
})),
phase: 'fresh',
minesPlaced: true,
revealedSafe: 0
}
},
/** @param {State} state @returns {Phase} */ /** @param {State} state @returns {Phase} */
status(state) { status(state) {
return state.phase return state.phase
@ -161,5 +256,70 @@ export const MinesweeperRules = {
} }
}, },
project 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}
*/
toMoveEvent(move, events) {
if (!events || events.length === 0) return null
switch (move.type) {
case 'reveal': return { type: 'reveal', r: move.r, c: move.c }
case 'chord': return { type: 'chord', r: move.r, c: move.c }
case 'flag': {
const flagged = events.find(e => e.type === 'flag')
if (!flagged) return null
return { type: flagged.flagged ? 'flag' : 'unflag', r: move.r, c: move.c }
}
default: return 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 {{ seed: number, config: Config, phase: Phase, minesPlaced: boolean, revealedSafe: number, grid: { rows: number, cols: number, cells: Cell[] } }}
*/
serialize(state) {
return {
seed: state.seed,
config: state.config,
phase: state.phase,
minesPlaced: state.minesPlaced,
revealedSafe: state.revealedSafe,
grid: gridToJSON(state.grid)
}
},
/**
* 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
* @returns {State}
*/
deserialize(snap) {
assertStateSnapshot(snap)
return {
seed: snap.seed,
config: snap.config,
phase: snap.phase,
minesPlaced: snap.minesPlaced,
revealedSafe: snap.revealedSafe,
grid: gridFromJSON(snap.grid, cell => ({ ...cell }))
}
}
} }

View file

@ -7,21 +7,46 @@
* server's clock (authoritative). The session never calls a wall clock itself. * server's clock (authoritative). The session never calls a wall clock itself.
* Future home: @cozy-games/game-session. * Future home: @cozy-games/game-session.
* *
* @typedef {{ init: Function, apply: Function, status: Function, project: Function }} Rules * @typedef {{ init: Function, apply: Function, status: Function, project: Function, serialize?: Function, deserialize?: Function, toMoveEvent?: Function }} Rules
*/ */
export class GameSession { export class GameSession {
/** /**
* Start from either `{ seed, config }` (the rules generate the board) or a
* pre-built `{ state }` (e.g. a rules factory that injected an explicit board);
* `state` wins when both are given. The session stays generic it just holds
* whatever state the rules produced.
*
* @param {Rules} rules * @param {Rules} rules
* @param {{ seed: number, config: object, clock?: () => number }} opts * @param {{ seed?: number, config?: object, state?: object, clock?: () => number }} opts
*/ */
constructor(rules, { seed, config, clock = () => 0 }) { constructor(rules, { seed, config, state, clock = () => 0 }) {
this.rules = rules this.rules = rules
this.clock = clock this.clock = clock
this.state = rules.init(seed, config) this.state = state ?? rules.init(seed, config)
/** @type {Array<{ move: object, t: number }>} */ /** @type {Array<{ move: object, t: number }>} */
this._log = [] this._log = []
this._t0 = null this._t0 = null
this._tEnd = null this._tEnd = null
// Move-event emission: subscribers + a monotonic sequence counter (last seq
// assigned; 0 = none yet). Seq is part of the snapshot so it keeps rising
// across a resume rather than restarting.
/** @type {Set<(event: object) => void>} */
this._moveHandlers = new Set()
this._seq = 0
}
/**
* Subscribe to typed move-events one per effective move (reveal / flag /
* unflag / chord), each carrying `{ type, r, c, t, seq }`. Returns an
* unsubscribe function. Pure in-process pub/sub: no DOM, no rendering. Requires
* the rules to implement `toMoveEvent`.
*
* @param {(event: object) => void} handler
* @returns {() => void} unsubscribe
*/
onMove(handler) {
this._moveHandlers.add(handler)
return () => this._moveHandlers.delete(handler)
} }
/** /**
@ -41,9 +66,20 @@ export class GameSession {
if (before === 'fresh' && after !== 'fresh' && this._t0 === null) this._t0 = t if (before === 'fresh' && after !== 'fresh' && this._t0 === null) this._t0 = t
if ((after === 'won' || after === 'lost') && this._tEnd === null) this._tEnd = t if ((after === 'won' || after === 'lost') && this._tEnd === null) this._tEnd = t
// Emit a typed move-event for effective moves (rules classify; no-ops → null).
if (typeof this.rules.toMoveEvent === 'function') {
const kind = this.rules.toMoveEvent(move, events)
if (kind) this._emitMove({ ...kind, t, seq: ++this._seq })
}
return { events, view: this.rules.project(state), time: this.elapsed() } return { events, view: this.rules.project(state), time: this.elapsed() }
} }
/** @param {object} event */
_emitMove(event) {
for (const handler of this._moveHandlers) handler(event)
}
status() { status() {
return this.rules.status(this.state) return this.rules.status(this.state)
} }
@ -69,4 +105,70 @@ export class GameSession {
if (status !== 'won' && status !== 'lost') return null if (status !== 'won' && status !== 'lost') return null
return { status, time: this.elapsed(), seed: this.state.seed, config: this.state.config, log: this.log() } return { status, time: this.elapsed(), seed: this.state.seed, config: this.state.config, log: this.log() }
} }
/**
* Full, JSON-safe snapshot of the whole session game state (board + per-cell
* status, via the rules' own serializer) plus the move log and timing anchors
* (`t0`/`tEnd`) that `elapsed()` derives from. Everything needed to later resume
* (core-05); the live `clock` is deliberately excluded (it's re-injected on
* {@link GameSession.deserialize}). Requires the rules to implement `serialize`.
*
* @returns {{ state: object, log: Array<{ move: object, t: number }>, t0: number | null, tEnd: number | null, seq: number }}
*/
serialize() {
if (typeof this.rules.serialize !== 'function') {
throw new TypeError('GameSession.serialize: rules must implement serialize(state)')
}
return {
state: this.rules.serialize(this.state),
log: this._log.map(({ move, t }) => ({ move, t })),
t0: this._t0,
tEnd: this._tEnd,
seq: this._seq
}
}
/**
* Rebuild a session from a {@link serialize} snapshot (or its JSON round-trip).
* The clock is re-injected it's a live function, not serializable while the
* log and timing anchors are restored so `elapsed()` resumes correctly.
* Requires the rules to implement `deserialize`.
*
* @param {Rules} rules
* @param {{ state: object, log: Array<{ move: object, t: number }>, t0: number | null, tEnd: number | null }} snapshot
* @param {{ clock?: () => number }} [opts]
* @returns {GameSession}
*/
static deserialize(rules, snapshot, { clock = () => 0 } = {}) {
if (typeof rules.deserialize !== 'function') {
throw new TypeError('GameSession.deserialize: rules must implement deserialize(snapshot)')
}
if (snapshot === null || typeof snapshot !== 'object') {
throw new TypeError(`GameSession.deserialize: expected a snapshot object (got ${snapshot === null ? 'null' : typeof snapshot})`)
}
const { state, log, t0, tEnd, seq } = snapshot
if (!Array.isArray(log)) {
throw new TypeError('GameSession.deserialize: snapshot.log must be an array')
}
for (const entry of log) {
if (entry === null || typeof entry !== 'object' || typeof entry.t !== 'number' || entry.move === null || typeof entry.move !== 'object') {
throw new TypeError('GameSession.deserialize: each log entry must be { move: object, t: number }')
}
}
if (!(t0 === null || typeof t0 === 'number') || !(tEnd === null || typeof tEnd === 'number')) {
throw new TypeError('GameSession.deserialize: snapshot.t0/tEnd must each be a number or null')
}
if (!(seq === undefined || (Number.isInteger(seq) && seq >= 0))) {
throw new TypeError('GameSession.deserialize: snapshot.seq must be a non-negative integer')
}
// rules.deserialize validates and revives the game-state half.
const session = new GameSession(rules, { state: rules.deserialize(state), clock })
session._log = log.map(({ move, t }) => ({ move, t }))
session._t0 = t0
session._tEnd = tEnd
// Continue the sequence where it left off so move-event seq keeps rising
// across a resume (absent in a pre-move-event snapshot ⇒ start at 0).
session._seq = seq ?? 0
return session
}
} }

View file

@ -1,4 +1,4 @@
# Headless core + client design — `@ayo-run/mnswpr/core` and the client that consumes it # Headless core + client design — `@cozy-games/mnswpr/core` and the client that consumes it
Design for splitting the Minesweeper engine into a **headless, isomorphic core** Design for splitting the Minesweeper engine into a **headless, isomorphic core**
(runs identically in a browser or on a server) and a **thin client** that renders (runs identically in a browser or on a server) and a **thin client** that renders
@ -23,12 +23,12 @@ Goals, in priority order:
## 1. Package layout & the seam ## 1. Package layout & the seam
**Decision (settled): one published package, core exposed as a sub-path.** Open- **Decision (settled): one published package, core exposed as a sub-path.** Open-
source consumers keep installing a single `@ayo-run/mnswpr` and get the headless source consumers keep installing a single `@cozy-games/mnswpr` and get the headless
core for free at `@ayo-run/mnswpr/core` — no second package to publish, version, core for free at `@cozy-games/mnswpr/core` — no second package to publish, version,
or document. The DOM client stays the default entry (`.`). or document. The DOM client stays the default entry (`.`).
``` ```
packages/mnswpr/ # @ayo-run/mnswpr — ONE published package packages/mnswpr/ # @cozy-games/mnswpr — ONE published package
mnswpr.js # "." → DOM client (browser entry; today's default) mnswpr.js # "." → DOM client (browser entry; today's default)
levels.js # shared by client + core (level presets) levels.js # shared by client + core (level presets)
core/ # "./core" → headless, isomorphic, ZERO DOM, ZERO wall-clock core/ # "./core" → headless, isomorphic, ZERO DOM, ZERO wall-clock
@ -66,8 +66,8 @@ packages/mnswpr/ # @ayo-run/mnswpr — ONE published package
} }
``` ```
- `import mnswpr from '@ayo-run/mnswpr'` → DOM client, exactly as today. - `import mnswpr from '@cozy-games/mnswpr'` → DOM client, exactly as today.
- `import { GameSession, MinesweeperRules } from '@ayo-run/mnswpr/core'` → headless. - `import { GameSession, MinesweeperRules } from '@cozy-games/mnswpr/core'` → headless.
The core entry pulls in **zero DOM**, so a server (later) imports it without The core entry pulls in **zero DOM**, so a server (later) imports it without
dragging in browser code, and the browser bundle for `.` never includes the dragging in browser code, and the browser bundle for `.` never includes the
core's server-only helpers. core's server-only helpers.
@ -85,7 +85,7 @@ layering are orthogonal. Rules of the seam:
When Sudoku lands, `core/grid/` and `core/session/` move out verbatim into When Sudoku lands, `core/grid/` and `core/session/` move out verbatim into
`@cozy-games/grid` + `@cozy-games/game-session`; `core/minesweeper/` (and a new `@cozy-games/grid` + `@cozy-games/game-session`; `core/minesweeper/` (and a new
`sudoku/`) depend on them, and `@ayo-run/mnswpr/core` re-exports from them so the `sudoku/`) depend on them, and `@cozy-games/mnswpr/core` re-exports from them so the
consumer-facing sub-path is unchanged. consumer-facing sub-path is unchanged.
--- ---
@ -346,7 +346,7 @@ Minesweeper(appId, version, {
| Needs a host tier | no | yes (Function/Worker + session store) | | Needs a host tier | no | yes (Function/Worker + session store) |
| Use | offline play, published npm engine | host-owned sessions | | Use | offline play, published npm engine | host-owned sessions |
The published `@ayo-run/mnswpr` stays fully functional standalone (Local mode). The published `@cozy-games/mnswpr` stays fully functional standalone (Local mode).
Running on a host opts into Remote. Same renderer, same input, same rules. Running on a host opts into Remote. Same renderer, same input, same rules.
--- ---
@ -425,7 +425,7 @@ appear in `core/` outside the injected `clock`/`rng` seams.
## 11. Open decisions ## 11. Open decisions
- **Package boundary:***resolved* — one `@ayo-run/mnswpr` package, core at the - **Package boundary:***resolved* — one `@cozy-games/mnswpr` package, core at the
`./core` sub-path (§1). Extraction to `@cozy-games/grid` + `@cozy-games/game-session` `./core` sub-path (§1). Extraction to `@cozy-games/grid` + `@cozy-games/game-session`
deferred to when Sudoku lands; the sub-path stays stable across that move. deferred to when Sudoku lands; the sub-path stays stable across that move.
- **Host mode (replay-validation vs live authority):** *deferred.* Ship - **Host mode (replay-validation vs live authority):** *deferred.* Ship

View file

@ -1,5 +1,5 @@
{ {
"name": "@ayo-run/mnswpr", "name": "@cozy-games/mnswpr",
"version": "0.4.36", "version": "0.4.36",
"description": "Classic Minesweeper browser game", "description": "Classic Minesweeper browser game",
"author": "Ayo", "author": "Ayo",
@ -10,6 +10,7 @@
}, },
"homepage": "https://mnswpr.com", "homepage": "https://mnswpr.com",
"scripts": { "scripts": {
"build": "vite build",
"release": "bumpp && node ../../scripts/release.js" "release": "bumpp && node ../../scripts/release.js"
}, },
"main": "mnswpr.js", "main": "mnswpr.js",

View file

@ -6,7 +6,7 @@ import { dirname, join } from 'node:path'
import { import {
Grid, eightWay, orthogonal, Grid, eightWay, orthogonal,
GameSession, replay, mulberry32, GameSession, replay, mulberry32,
MinesweeperRules, generateBoard, levels MinesweeperRules, generateBoard, validateLayout, MOVE_EVENT_TYPES, levels
} from '../core/index.js' } from '../core/index.js'
import { placeMines, excludeAround } from '../core/minesweeper/board.js' import { placeMines, excludeAround } from '../core/minesweeper/board.js'
@ -148,6 +148,57 @@ describe('generateBoard (Layer 2, pure)', () => {
}) })
}) })
describe('generateBoard first-move-safe (safeCell)', () => {
it('never mines the safe cell across randomized runs (property-style)', () => {
// Dense board so the safe cell is a demanding constraint, swept over many
// injected-RNG streams — the guarantee must hold for every seed, not one.
for (let seed = 0; seed < 200; seed++) {
const layout = generateBoard(9, 9, 70, { rng: mulberry32(seed), safeCell: { r: 4, c: 5 } })
expect(layout.cells[4][5].mine).toBe(false)
// and it's a genuine constraint on top of a correct board: exact mine count
expect(layout.mineLocations).toHaveLength(70)
}
})
it('holds at edge coordinates — every corner and a border cell', () => {
const corners = [{ r: 0, c: 0 }, { r: 0, c: 8 }, { r: 8, c: 0 }, { r: 8, c: 8 }]
const borders = [{ r: 0, c: 4 }, { r: 4, c: 0 }, { r: 8, c: 4 }, { r: 4, c: 8 }]
for (const safeCell of [...corners, ...borders]) {
const layout = generateBoard(9, 9, 70, { seed: 123, safeCell })
expect(layout.cells[safeCell.r][safeCell.c].mine).toBe(false)
}
})
it('generates a max-density board (mines = cells 1) with the one safe cell blank', () => {
// 3x3 with 8 mines: every cell except the safe one must be a mine.
const layout = generateBoard(3, 3, 8, { seed: 5, safeCell: { r: 1, c: 1 } })
expect(layout.mineLocations).toHaveLength(8)
expect(layout.cells[1][1].mine).toBe(false)
let mines = 0
for (const row of layout.cells) for (const cell of row) if (cell.mine) mines++
expect(mines).toBe(8)
})
it('merges with an existing exclude set rather than replacing it', () => {
const exclude = new Set([0]) // key 0 == cell (0,0)
const layout = generateBoard(5, 5, 23, { seed: 9, exclude, safeCell: { r: 4, c: 4 } })
expect(layout.cells[0][0].mine).toBe(false) // from exclude
expect(layout.cells[4][4].mine).toBe(false) // from safeCell
expect(layout.mineLocations).toHaveLength(23) // 25 2 excluded
})
it('rejects configurations where the mines cannot fit with the safe cell excluded', () => {
// 3x3 = 9 cells, one reserved for safety ⇒ capacity 8; 9 mines can't fit.
expect(() => generateBoard(3, 3, 9, { safeCell: { r: 0, c: 0 } })).toThrow(RangeError)
})
it('rejects an out-of-bounds or non-integer safe cell', () => {
expect(() => generateBoard(9, 9, 10, { safeCell: { r: 9, c: 0 } })).toThrow(RangeError)
expect(() => generateBoard(9, 9, 10, { safeCell: { r: -1, c: 0 } })).toThrow(RangeError)
expect(() => generateBoard(9, 9, 10, { safeCell: { r: 0, c: 1.5 } })).toThrow(RangeError)
})
})
describe('rules (Layer 2)', () => { describe('rules (Layer 2)', () => {
it('first reveal is never a mine and opens a region', () => { it('first reveal is never a mine and opens a region', () => {
const s = MinesweeperRules.init(3, beginner) const s = MinesweeperRules.init(3, beginner)
@ -196,6 +247,423 @@ describe('rules (Layer 2)', () => {
}) })
}) })
describe('board injection (fromLayout, Layer 2)', () => {
// A hand-built 3x3 with a single mine at (0,0). Adjacency: the mine's three
// neighbors (0,1),(1,0),(1,1) are 1; everything else is 0.
const knownLayout = () => ({
rows: 3,
cols: 3,
mines: 1,
cells: [
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
],
mineLocations: [[0, 0]]
})
it('creates a game instance from an injected layout and scripts moves to known states', () => {
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(knownLayout()) })
expect(session.status()).toBe('fresh')
// Reveal a numbered (non-zero) cell: it activates the game and reveals just
// itself, carrying the layout's adjacency — proof the injected board is live.
const first = session.applyMove({ type: 'reveal', r: 0, c: 1 })
expect(session.status()).toBe('active')
expect(first.events[0].cells).toEqual([{ r: 0, c: 1, adjacent: 1 }])
// Stepping on the injected mine loses and reports it (before any flood wins).
const boom = session.applyMove({ type: 'reveal', r: 0, c: 0 })
expect(session.status()).toBe('lost')
expect(boom.events[0]).toMatchObject({ type: 'explode', r: 0, c: 0 })
expect(boom.events[0].mines).toEqual([{ r: 0, c: 0 }])
})
it('a zero cell floods the connected safe region on an injected board', () => {
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(knownLayout()) })
// (2,2) is a zero far from the mine; flood opens all 8 safe cells → win.
const flood = session.applyMove({ type: 'reveal', r: 2, c: 2 })
expect(flood.events[0].cells.length).toBe(8)
expect(session.status()).toBe('won')
})
it('an injected board can be played to a win, every safe cell revealed', () => {
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(knownLayout()) })
// Reveal all 8 non-mine cells; avoid (0,0).
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
if (r === 0 && c === 0) continue
session.applyMove({ type: 'reveal', r, c })
}
}
expect(session.status()).toBe('won')
})
it('plays identically to a generated board: injecting a generated layout reproduces its play', () => {
// Generate a board, then inject that exact layout. Revealing any cell must
// report the layout's own adjacency, and mines must sit where the layout says.
const layout = generateBoard(9, 9, 10, { seed: 42, safeCell: { r: 4, c: 4 } })
const state = MinesweeperRules.fromLayout(layout)
const { events } = MinesweeperRules.apply(state, { type: 'reveal', r: 4, c: 4 })
expect(state.phase).toBe('active')
// (4,4) was forced safe; its revealed adjacency matches the layout.
const seen = events[0].cells.find(cell => cell.r === 4 && cell.c === 4)
expect(seen.adjacent).toBe(layout.cells[4][4].adjacent)
// The mines are exactly the layout's mines.
const mines = []
state.grid.forEach((cell, r, c) => { if (cell.mine) mines.push([r, c]) })
expect(mines.sort()).toEqual([...layout.mineLocations].sort())
})
it('two sessions from the same layout with the same script transition identically', () => {
const script = [{ type: 'reveal', r: 2, c: 2 }, { type: 'flag', r: 0, c: 0 }, { type: 'reveal', r: 0, c: 1 }]
const run = () => {
const s = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(knownLayout()) })
return script.map(m => s.applyMove(m).view)
}
expect(run()).toEqual(run())
})
it('rejects malformed layouts with a clear error', () => {
expect(() => MinesweeperRules.fromLayout(null)).toThrow(TypeError)
expect(() => MinesweeperRules.fromLayout('nope')).toThrow(TypeError)
// dimensions don't match the cells grid
expect(() => MinesweeperRules.fromLayout({ ...knownLayout(), rows: 4 })).toThrow(RangeError)
// mine count disagrees with the actual mined cells
expect(() => MinesweeperRules.fromLayout({ ...knownLayout(), mines: 2 })).toThrow(RangeError)
// a cell missing its shape
const badCells = knownLayout()
badCells.cells[1][1] = { mine: false } // no `adjacent`
expect(() => MinesweeperRules.fromLayout(badCells)).toThrow(TypeError)
// mineLocations pointing at a non-mined cell
const badLocs = knownLayout()
badLocs.mineLocations = [[2, 2]]
expect(() => MinesweeperRules.fromLayout(badLocs)).toThrow(RangeError)
})
it('validateLayout accepts a freshly generated board', () => {
expect(() => validateLayout(generateBoard(16, 16, 40, { seed: 3 }))).not.toThrow()
})
it('leaves existing seed-based construction unaffected', () => {
// No `state` supplied ⇒ the session still generates from seed/config as before.
const session = new GameSession(MinesweeperRules, { seed: 3, config: beginner })
const { events } = session.applyMove({ type: 'reveal', r: 0, c: 0 })
expect(session.status()).toBe('active')
expect(session.state.grid.at(0, 0).mine).toBe(false) // first-click safety intact
expect(events[0].cells.length).toBeGreaterThan(1)
})
})
describe('state serialization (serialize / deserialize)', () => {
// Drive a fresh session to a genuine MID-GAME state: opening flood + a flag on
// a mine (which stays flagged and never needs revealing to win). Returns the
// session, its clock, and the flagged mine's coordinates.
function midGame(seed = 21) {
const clock = makeClock()
const session = new GameSession(MinesweeperRules, { seed, config: beginner, clock })
clock.tick()
session.applyMove({ type: 'reveal', r: 0, c: 0 }) // opening flood → active
const mine = firstMine(session.state.grid)
clock.tick()
session.applyMove({ type: 'flag', r: mine.r, c: mine.c })
return { session, clock, mine }
}
it('round-trips a mid-game snapshot through JSON without loss', () => {
const { session } = midGame()
expect(session.status()).toBe('active') // genuinely mid-game, not fresh/finished
const snap = session.serialize()
const roundTripped = JSON.parse(JSON.stringify(snap))
expect(roundTripped).toEqual(snap) // JSON-safe: stringify → parse is lossless
// deserialize → re-serialize reproduces the exact snapshot
const revived = GameSession.deserialize(MinesweeperRules, roundTripped, { clock: makeClock() })
expect(revived.serialize()).toEqual(snap)
})
it('snapshot covers layout, revealed + flagged cells, and session/clock state', () => {
const { session, mine } = midGame()
const snap = session.serialize()
// board layout
expect(snap.state.grid.rows).toBe(beginner.rows)
expect(snap.state.grid.cols).toBe(beginner.cols)
expect(snap.state.grid.cells).toHaveLength(beginner.rows * beginner.cols)
expect(snap.state.grid.cells[0]).toEqual({ mine: expect.any(Boolean), adjacent: expect.any(Number), status: expect.any(String) })
// per-cell state: the flood left revealed cells, and our flagged mine is flagged
expect(snap.state.grid.cells.some(c => c.status === 'revealed')).toBe(true)
expect(snap.state.grid.cells[mine.r * beginner.cols + mine.c].status).toBe('flagged')
// session / clock state: timer started (t0 set), still running (tEnd null), log intact
expect(snap.t0).not.toBeNull()
expect(snap.tEnd).toBeNull()
expect(snap.log).toHaveLength(2)
expect(snap.log[0]).toEqual({ move: { type: 'reveal', r: 0, c: 0 }, t: expect.any(Number) })
})
it('a deserialized session resumes and plays identically to the original', () => {
const playToWin = (session, clock) => {
const { grid, config } = session.state
for (let r = 0; r < config.rows; r++) {
for (let c = 0; c < config.cols; c++) {
if (!grid.at(r, c).mine && grid.at(r, c).status !== 'revealed') {
clock.tick()
session.applyMove({ type: 'reveal', r, c })
}
}
}
}
const { session, clock } = midGame(33)
const snap = session.serialize()
// Revive on a clock continuing from the same instant, so timing stays aligned.
const reviveClock = makeClock(clock())
const revived = GameSession.deserialize(MinesweeperRules, JSON.parse(JSON.stringify(snap)), { clock: reviveClock })
playToWin(session, clock)
playToWin(revived, reviveClock)
expect(revived.status()).toBe(session.status())
expect(revived.status()).toBe('won')
expect(revived.view()).toEqual(session.view())
expect(revived.elapsed()).toBe(session.elapsed())
expect(revived.result()).toEqual(session.result())
})
it('rules.serialize / deserialize round-trips a state and revives a real Grid', () => {
const state = MinesweeperRules.init(7, beginner)
MinesweeperRules.apply(state, { type: 'reveal', r: 0, c: 0 })
const snap = MinesweeperRules.serialize(state)
const revived = MinesweeperRules.deserialize(JSON.parse(JSON.stringify(snap)))
expect(revived.grid).toBeInstanceOf(Grid) // not a plain object — a live Grid
expect(revived.grid.at(0, 0)).toEqual(state.grid.at(0, 0))
expect(MinesweeperRules.serialize(revived)).toEqual(snap)
})
it('serialize throws clearly when the rules lack a serializer', () => {
const bareRules = { init: () => ({}), apply: s => ({ state: s, events: [] }), status: () => 'fresh', project: s => s }
const session = new GameSession(bareRules, { state: {} })
expect(() => session.serialize()).toThrow(TypeError)
})
})
describe('resume from serialized state (Layer 1)', () => {
// Reveal every remaining non-mine cell in row-major order, ticking the injected
// clock before each move — a deterministic way to finish an in-progress board.
const finish = (session, clock) => {
const { grid, config } = session.state
for (let r = 0; r < config.rows; r++) {
for (let c = 0; c < config.cols; c++) {
if (!grid.at(r, c).mine && grid.at(r, c).status !== 'revealed') {
clock.tick()
session.applyMove({ type: 'reveal', r, c })
}
}
}
}
const validSnapshot = () => {
const s = new GameSession(MinesweeperRules, { seed: 1, config: beginner, clock: makeClock() })
s.applyMove({ type: 'reveal', r: 0, c: 0 })
return JSON.parse(JSON.stringify(s.serialize()))
}
it('play N moves → serialize → restore → finish: final state identical to an uninterrupted run', () => {
// Uninterrupted reference run.
const clockU = makeClock()
const uninterrupted = new GameSession(MinesweeperRules, { seed: 77, config: beginner, clock: clockU })
clockU.tick()
uninterrupted.applyMove({ type: 'reveal', r: 0, c: 0 })
finish(uninterrupted, clockU)
// Interrupted: identical opening move, snapshot, restore on the SAME clock
// instant, then finish with the identical remaining moves.
const clockI = makeClock()
const before = new GameSession(MinesweeperRules, { seed: 77, config: beginner, clock: clockI })
clockI.tick()
before.applyMove({ type: 'reveal', r: 0, c: 0 })
const snap = JSON.parse(JSON.stringify(before.serialize()))
const resumed = GameSession.deserialize(MinesweeperRules, snap, { clock: clockI })
finish(resumed, clockI)
expect(resumed.status()).toBe('won')
expect(resumed.view()).toEqual(uninterrupted.view()) // identical board + progress
expect(resumed.result()).toEqual(uninterrupted.result()) // identical log + time
})
it('elapsed time continues (not reset) after restore, on the injected clock', () => {
const clock = makeClock() // 1000
const s = new GameSession(MinesweeperRules, { seed: 21, config: beginner, clock })
clock.tick() // 1050
s.applyMove({ type: 'reveal', r: 0, c: 0 }) // t0 = 1050
clock.tick(); clock.tick() // 1150
expect(s.elapsed()).toBe(100) // 1150 1050
const snap = JSON.parse(JSON.stringify(s.serialize()))
// Resume on a fresh clock that continues from the same instant (1150).
const reviveClock = makeClock(clock())
const resumed = GameSession.deserialize(MinesweeperRules, snap, { clock: reviveClock })
// Preserved, not reset to zero.
expect(resumed.elapsed()).toBe(100)
// And it keeps advancing on the injected clock as play continues.
reviveClock.tick() // 1200
const mine = firstMine(resumed.state.grid)
resumed.applyMove({ type: 'flag', r: mine.r, c: mine.c })
expect(resumed.elapsed()).toBe(150) // 1200 1050
expect(resumed.elapsed()).toBeGreaterThan(100)
})
it('a finished (won/lost) session round-trips with its final elapsed frozen', () => {
const clock = makeClock()
const s = new GameSession(MinesweeperRules, { seed: 5, config: beginner, clock })
clock.tick()
s.applyMove({ type: 'reveal', r: 0, c: 0 })
finish(s, clock)
expect(s.status()).toBe('won')
const finalTime = s.elapsed()
const resumed = GameSession.deserialize(
MinesweeperRules,
JSON.parse(JSON.stringify(s.serialize())),
{ clock: makeClock(999999) } // a wildly different clock must not change a frozen time
)
expect(resumed.status()).toBe('won')
expect(resumed.elapsed()).toBe(finalTime)
})
it('rejects invalid or corrupt snapshots with a clear error', () => {
const good = validSnapshot()
// envelope corruption (caught by GameSession.deserialize)
expect(() => GameSession.deserialize(MinesweeperRules, null)).toThrow(TypeError)
expect(() => GameSession.deserialize(MinesweeperRules, { ...good, log: 'nope' })).toThrow(TypeError)
expect(() => GameSession.deserialize(MinesweeperRules, { ...good, log: [{ move: {}, t: 'soon' }] })).toThrow(TypeError)
expect(() => GameSession.deserialize(MinesweeperRules, { ...good, t0: 'later' })).toThrow(TypeError)
// game-state corruption (delegated to rules.deserialize)
expect(() => GameSession.deserialize(MinesweeperRules, { ...good, state: null })).toThrow(TypeError)
expect(() => GameSession.deserialize(MinesweeperRules, { ...good, state: { ...good.state, phase: 'bogus' } })).toThrow(RangeError)
const truncated = { ...good, state: { ...good.state, grid: { ...good.state.grid, cells: good.state.grid.cells.slice(1) } } }
expect(() => GameSession.deserialize(MinesweeperRules, truncated)).toThrow(RangeError)
})
it('rules.deserialize rejects a malformed state snapshot directly', () => {
expect(() => MinesweeperRules.deserialize(null)).toThrow(TypeError)
expect(() => MinesweeperRules.deserialize({})).toThrow(TypeError) // missing config/grid
const good = validSnapshot().state
expect(() => MinesweeperRules.deserialize({ ...good, grid: { ...good.grid, rows: good.grid.rows + 1 } })).toThrow(RangeError)
})
})
describe('typed move-events (onMove)', () => {
// A 3x3 with a single mine at (0,0); adjacency computed for it. Lets us script
// exact coordinates and outcomes for a fully-asserted event stream.
const knownLayout = () => ({
rows: 3,
cols: 3,
mines: 1,
cells: [
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
],
mineLocations: [[0, 0]]
})
const injected = clock =>
new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(knownLayout()), clock })
it('exports the move-event vocabulary', () => {
expect(MOVE_EVENT_TYPES).toEqual(['reveal', 'flag', 'unflag', 'chord'])
})
it('emits the full typed event stream for a scripted game (all four types + no-op)', () => {
const clock = makeClock()
const session = injected(clock)
const events = []
session.onMove(e => events.push(e))
clock.tick() // 1050
session.applyMove({ type: 'reveal', r: 0, c: 1 }) // number cell → reveals itself
clock.tick() // 1100
session.applyMove({ type: 'flag', r: 0, c: 0 }) // flag the mine
clock.tick() // 1150
session.applyMove({ type: 'flag', r: 0, c: 0 }) // toggle off → unflag
clock.tick() // 1200
session.applyMove({ type: 'flag', r: 0, c: 0 }) // flag again
clock.tick() // 1250
session.applyMove({ type: 'chord', r: 0, c: 1 }) // 1 adjacent flag == value → chord
clock.tick() // 1300
session.applyMove({ type: 'reveal', r: 0, c: 1 }) // already revealed → no-op, no event
expect(events).toEqual([
{ type: 'reveal', r: 0, c: 1, t: 1050, seq: 1 },
{ type: 'flag', r: 0, c: 0, t: 1100, seq: 2 },
{ type: 'unflag', r: 0, c: 0, t: 1150, seq: 3 },
{ type: 'flag', r: 0, c: 0, t: 1200, seq: 4 },
{ type: 'chord', r: 0, c: 1, t: 1250, seq: 5 }
])
})
it('timestamps come from the injected clock', () => {
const clock = makeClock(5000)
const session = injected(clock)
let event
session.onMove(e => { event = e })
clock.tick() // 5050
session.applyMove({ type: 'reveal', r: 2, c: 2 })
expect(event.t).toBe(5050)
})
it('onMove returns an unsubscribe that stops delivery', () => {
const session = injected(makeClock())
const events = []
const off = session.onMove(e => events.push(e))
session.applyMove({ type: 'flag', r: 0, c: 0 })
off()
session.applyMove({ type: 'flag', r: 0, c: 0 }) // unflag — not delivered
expect(events).toHaveLength(1)
expect(events[0].type).toBe('flag')
})
it('sequence numbers strictly increase across a resume (core-05)', () => {
const clock = makeClock()
const session = injected(clock)
const seqs = []
session.onMove(e => seqs.push(e.seq))
clock.tick(); session.applyMove({ type: 'reveal', r: 0, c: 1 }) // seq 1
clock.tick(); session.applyMove({ type: 'flag', r: 0, c: 0 }) // seq 2
// Serialize → resume; the counter must continue, not restart.
const snap = JSON.parse(JSON.stringify(session.serialize()))
expect(snap.seq).toBe(2)
const resumed = GameSession.deserialize(MinesweeperRules, snap, { clock: makeClock(clock()) })
const resumedSeqs = []
resumed.onMove(e => resumedSeqs.push(e.seq))
resumed.applyMove({ type: 'flag', r: 0, c: 0 }) // unflag → seq 3, not 1
expect(resumedSeqs).toEqual([3])
// strictly increasing over the whole session
const all = [...seqs, ...resumedSeqs]
expect(all).toEqual([...all].sort((a, b) => a - b))
expect(new Set(all).size).toBe(all.length) // no duplicates
})
it('a losing reveal still emits a reveal move-event', () => {
const clock = makeClock()
const session = injected(clock)
let event
session.onMove(e => { event = e })
clock.tick()
session.applyMove({ type: 'reveal', r: 0, c: 0 }) // steps on the mine
expect(session.status()).toBe('lost')
expect(event).toEqual({ type: 'reveal', r: 0, c: 0, t: 1050, seq: 1 })
})
})
describe('session + timing (Layer 1)', () => { describe('session + timing (Layer 1)', () => {
it('reports authoritative elapsed time from the injected clock', () => { it('reports authoritative elapsed time from the injected clock', () => {
const clock = makeClock() const clock = makeClock()
@ -275,12 +743,27 @@ describe('determinism guard (invariant #4)', () => {
}) })
expect(offenders).toEqual([]) expect(offenders).toEqual([])
}) })
it('no DOM/rendering coupling in core/ (headless invariant)', () => {
const coreDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'core')
const offenders = []
walk(coreDir, file => {
if (!file.endsWith('.js')) return
const code = readFileSync(file, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')
if (/\b(document|window|navigator|localStorage|requestAnimationFrame)\b/.test(code) || /from ['"][^'"]*(client|renderer)/.test(code)) {
offenders.push(file)
}
})
expect(offenders).toEqual([])
})
}) })
// ---- helpers ---- // ---- helpers ----
function makeClock() { function makeClock(start = 1000) {
let t = 1000 let t = start
return Object.assign(() => t, { tick() { t += 50 } }) return Object.assign(() => t, { tick() { t += 50 } })
} }

View file

@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest'
import { GameSession, MinesweeperRules, generateBoard, levels } from '../core/index.js'
import { createProgressReducer } from '../adapters/replay-progress.js'
// Drive a session over an injected layout, capturing the real core-06 move-event
// stream (onMove). Returns the emitted events and the session.
function record(layout, moves) {
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout) })
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 }
}
// 3x3, single mine at (0,0); adjacency computed. Total safe = 8.
const smallBoard = () => ({
rows: 3,
cols: 3,
mines: 1,
cells: [
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
],
mineLocations: [[0, 0]]
})
describe('mnswpr progress reducer (percent-cleared)', () => {
it('progresses a scripted full game from 0% to 100%, monotonically', () => {
// A real generated board; reveal every safe cell in row-major order to win.
const layout = generateBoard(9, 9, 10, { seed: 7, safeCell: { r: 0, c: 0 } })
const progress = createProgressReducer(layout)
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout) })
const events = []
session.onMove(e => events.push({ seq: e.seq, t: e.t, event: e }))
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 })
}
}
expect(session.status()).toBe('won')
expect(progress([])).toBe(0) // nothing revealed yet
expect(progress(events)).toBe(100) // full clear
expect(progress(events.slice(0, 1))).toBeGreaterThan(0) // first reveal opens a region
// monotonic non-decreasing across every prefix
let prev = -1
for (let k = 0; k <= events.length; k++) {
const p = progress(events.slice(0, k))
expect(p).toBeGreaterThanOrEqual(prev)
prev = p
}
})
it('counts flooded reveals by cells opened, not by event count', () => {
const layout = smallBoard()
const progress = createProgressReducer(layout)
// Revealing the far corner (2,2) floods every one of the 8 safe cells.
const { events } = record(layout, [{ type: 'reveal', r: 2, c: 2 }])
expect(events).toHaveLength(1) // one event...
expect(progress(events)).toBe(100) // ...but all 8 safe cells cleared
})
it('flag and unflag events leave progress unchanged', () => {
const layout = smallBoard()
const progress = createProgressReducer(layout)
// reveal one numbered cell (1/8), then flag + unflag the mine.
const { events } = record(layout, [
{ type: 'reveal', r: 0, c: 1 }, // reveals just itself (adjacent 1)
{ 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'])
const afterReveal = progress(events.slice(0, 1))
expect(afterReveal).toBeCloseTo(12.5, 5) // 1 / 8
expect(progress(events.slice(0, 2))).toBe(afterReveal) // + flag: unchanged
expect(progress(events)).toBe(afterReveal) // + unflag: unchanged
})
it('counts the cells a chord reveals', () => {
const layout = smallBoard()
const progress = createProgressReducer(layout)
// reveal (0,1)=1, flag the mine (0,0), then chord (0,1): its 1 flag matches
// its value, so it reveals the rest of the board.
const { events } = record(layout, [
{ type: 'reveal', r: 0, c: 1 },
{ type: 'flag', r: 0, c: 0 },
{ type: 'chord', r: 0, c: 1 }
])
expect(events.map(e => e.event.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
expect(progress(events)).toBe(100) // chord clears the rest
})
it('reflects a partial game (lost run stops where it stopped)', () => {
// Reveal a couple of cells then step on the mine — progress reflects only the
// safe cells cleared before the loss.
const layout = smallBoard()
const progress = createProgressReducer(layout)
const { events, session } = record(layout, [
{ type: 'reveal', r: 0, c: 1 },
{ type: 'reveal', r: 1, c: 0 },
{ type: 'reveal', r: 0, c: 0 } // mine → lost
])
expect(session.status()).toBe('lost')
expect(progress(events)).toBeCloseTo(25, 5) // 2 of 8 safe cells cleared
})
it('is usable as the replay engine progress adapter (shape check)', () => {
// The reducer matches ProgressReducer<T>: (events) => number in [0,100].
const { rows, cols, mines } = levels.beginner
const progress = createProgressReducer(generateBoard(rows, cols, mines, { seed: 1 }))
const v = progress([])
expect(typeof v).toBe('number')
expect(v).toBeGreaterThanOrEqual(0)
expect(v).toBeLessThanOrEqual(100)
})
})

View file

@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest'
import { GameSession, MinesweeperRules } from '../core/index.js'
import { createStateReducer } from '../adapters/replay-state.js'
// Drive a session over an injected layout, capturing the real move-event stream.
function record(layout, moves) {
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout) })
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 }
}
// 3x3, single mine at (0,0). Total safe = 8.
const smallBoard = () => ({
rows: 3,
cols: 3,
mines: 1,
cells: [
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
],
mineLocations: [[0, 0]]
})
describe('mnswpr state reducer (full-board reconstruction)', () => {
const layout = smallBoard()
const { events } = record(layout, [
{ type: 'reveal', r: 0, c: 1 },
{ type: 'reveal', r: 1, c: 0 },
{ type: 'flag', r: 0, c: 0 },
{ type: 'reveal', r: 2, c: 2 } // floods the rest
])
const state = createStateReducer(layout)
const status = (b, r, c) => b.cells[r][c].status
it('empty slice → pristine board (all hidden, fresh)', () => {
const b = state([])
expect(b.rows).toBe(3)
expect(b.cols).toBe(3)
expect(b.phase).toBe('fresh')
expect(b.revealedSafe).toBe(0)
expect(b.cells.flat().every(c => c.status === 'hidden')).toBe(true)
})
it('reconstructs the exact board state at several event indices', () => {
// index 1 — after reveal(0,1): just that cell open
let b = state(events.slice(0, 1))
expect(status(b, 0, 1)).toBe('revealed')
expect(status(b, 0, 0)).toBe('hidden')
expect(status(b, 1, 0)).toBe('hidden')
expect(b.revealedSafe).toBe(1)
expect(b.phase).toBe('active')
// index 2 — after reveal(1,0)
b = state(events.slice(0, 2))
expect(status(b, 0, 1)).toBe('revealed')
expect(status(b, 1, 0)).toBe('revealed')
expect(b.revealedSafe).toBe(2)
// index 3 — after flag(0,0): mine flagged, nothing new revealed
b = state(events.slice(0, 3))
expect(status(b, 0, 0)).toBe('flagged')
expect(b.revealedSafe).toBe(2)
// index 4 — after reveal(2,2): floods all safe cells; mine stays flagged; won
b = state(events.slice(0, 4))
expect(b.phase).toBe('won')
expect(b.revealedSafe).toBe(8)
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
expect(status(b, r, c)).toBe(r === 0 && c === 0 ? 'flagged' : 'revealed')
}
}
})
it('carries mine/adjacent metadata for rendering', () => {
const b = state(events)
expect(b.cells[0][0].mine).toBe(true)
expect(b.cells[0][1].mine).toBe(false)
expect(b.cells[0][1].adjacent).toBe(1)
})
it('is stateless: the same slice reconstructs an equal board', () => {
expect(state(events.slice(0, 2))).toEqual(state(events.slice(0, 2)))
// and reconstructing a shorter slice after a longer one is unaffected
const full = state(events)
expect(state(events.slice(0, 1)).revealedSafe).toBe(1)
expect(full.revealedSafe).toBe(8)
})
it('counts chord reveals in the reconstructed board', () => {
// reveal a number, flag the mine, then chord it open.
const { events: chordEvents } = record(smallBoard(), [
{ type: 'reveal', r: 0, c: 1 },
{ type: 'flag', r: 0, c: 0 },
{ type: 'chord', r: 0, c: 1 }
])
const b = createStateReducer(smallBoard())(chordEvents)
expect(b.revealedSafe).toBe(8)
expect(b.cells[2][2].status).toBe('revealed')
})
})

View file

@ -0,0 +1,62 @@
# @cozy-games/move-log
A **game-agnostic** container for a recorded run of move events. It wraps any game's event stream in a schema-versioned, ordered, timestamped log.
```js
import {
createMoveLog, withReceivedTs,
serializeMoveLog, deserializeMoveLog, isMoveLog, SCHEMA_VERSION
} from '@cozy-games/move-log'
// `T` is your game's own event vocabulary — supplied by you, unknown to us.
const log = createMoveLog([
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
{ seq: 2, t: 50, event: { type: 'flag', r: 1, c: 2 } }
])
// → { schema_version: 1, events: [ { seq, t, event }, ... ] }
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
```
## Shape
| field | type | meaning |
| ---------------- | ----------------- | -------------------------------------------------- |
| `schema_version` | `1` | the move-log container version |
| `events` | `MoveEvent<T>[]` | ordered, each `{ seq, t, event, receivedTs? }` |
`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.
`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.
## Versioning: `receivedTs` is additive within `schema_version: 1`
`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.
## 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.

219
packages/move-log/index.js Normal file
View file

@ -0,0 +1,219 @@
// @ts-check
/**
* `@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.
*
* 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`.
*
* Extraction to a standalone published package comes later; for now it lives as
* a shared workspace module alongside `packages/utils`.
*/
/**
* The move-log schema version.
*
* 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 = /** @type {SchemaVersion} */ (1)
/**
* A single recorded event: the log-owned recording metadata a strictly
* increasing sequence number `seq`, a source-side timestamp `t` (milliseconds),
* and an OPTIONAL received-side timestamp `receivedTs` a consumer may attach when
* it received the event plus the game's opaque payload `event`. Generic over
* the game's event type `T`. `receivedTs` is purpose-neutral: the log records
* only THAT it was received at some time, never why or from where.
*
* @template T
* @typedef {{ seq: number, t: number, event: T, 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.
*
* @template T
* @typedef {{ schema_version: SchemaVersion, events: MoveEvent<T>[] }} 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.
*
* @param {unknown} events
*/
function assertEvents(events) {
if (!Array.isArray(events)) {
throw new TypeError(`move-log: events must be an array (got ${events === null ? 'null' : typeof 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 (!Number.isInteger(e.seq)) {
throw new TypeError(`move-log: events[${i}].seq must be an integer (got ${JSON.stringify(e.seq)})`)
}
if (e.seq <= prevSeq) {
throw new RangeError(`move-log: events[${i}].seq must be strictly increasing (got ${e.seq} after ${prevSeq})`)
}
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)})`)
}
prevSeq = e.seq
})
}
/**
* 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).
*
* @template T
* @param {MoveEvent<T>} e
* @returns {MoveEvent<T>}
*/
function copyEvent(e) {
/** @type {MoveEvent<T>} */
const out = { seq: e.seq, t: e.t, event: e.event }
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.
*
* @param {unknown} value
* @returns {MoveLog<any>}
*/
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})`)
}
assertEvents(v.events)
return v
}
/**
* Build a move log from an ordered list of `{ seq, t, event }` records. 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.
*
* @template T
* @param {MoveEvent<T>[]} [events] - ordered events, each `{ seq, t, event, receivedTs? }`
* @returns {MoveLog<T>}
*/
export function createMoveLog(events = []) {
assertEvents(events)
return {
schema_version: SCHEMA_VERSION,
events: events.map(copyEvent)
}
}
/**
* 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
* `undefined` are left as-is (keeping any existing `receivedTs`). This is how a
* 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>}
*/
export function withReceivedTs(log, stamp) {
assertMoveLog(log)
const events = log.events.map((e, i) => {
const rt = stamp(e, i)
if (rt === undefined) return copyEvent(e)
if (typeof rt !== 'number' || !Number.isFinite(rt)) {
throw new TypeError(`withReceivedTs: stamp must return a finite number or undefined (got ${JSON.stringify(rt)} at index ${i})`)
}
return { ...copyEvent(e), receivedTs: rt }
})
return { schema_version: log.schema_version, events }
}
/**
* 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`.
*
* @param {unknown} value
* @returns {boolean}
*/
export function isMoveLog(value) {
try {
assertMoveLog(value)
return true
} catch {
return false
}
}
/**
* 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
* @returns {string}
*/
export function serializeMoveLog(log) {
assertMoveLog(log)
return JSON.stringify(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}.
*
* @param {string} json
* @returns {MoveLog<any>}
*/
export function deserializeMoveLog(json) {
if (typeof json !== 'string') {
throw new TypeError(`deserializeMoveLog: expected a JSON string (got ${json === null ? 'null' : typeof json})`)
}
let parsed
try {
parsed = JSON.parse(json)
} catch (err) {
throw new SyntaxError(`deserializeMoveLog: invalid JSON — ${/** @type {Error} */ (err).message}`, { cause: err })
}
return assertMoveLog(parsed)
}

View file

@ -0,0 +1,22 @@
{
"name": "@cozy-games/move-log",
"version": "0.0.1",
"description": "Game-blind, schema-versioned log of a recorded run of move events — generic over the game's own event vocabulary",
"author": "Ayo Ayco",
"private": true,
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/ayo-run/mnswpr"
},
"main": "index.js",
"exports": {
".": {
"default": "./index.js"
},
"./*": {
"default": "./*"
}
},
"license": "BSD-2-Clause"
}

View file

@ -0,0 +1,323 @@
// @ts-check
import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
// Imported via the PACKAGE NAME (not a relative path) to prove the workspace
// module resolves and is importable by other packages.
import {
createMoveLog, withReceivedTs,
serializeMoveLog, deserializeMoveLog, isMoveLog, assertMoveLog, SCHEMA_VERSION
} 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
*/
describe('@cozy-games/move-log', () => {
/** @type {import('@cozy-games/move-log').MoveEvent<DummyEvent>[]} */
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
]
it('exposes schema_version typed as 1', () => {
expect(SCHEMA_VERSION).toBe(1)
})
it('wraps an ordered, timestamped, sequenced stream for a dummy vocabulary', () => {
/** @type {import('@cozy-games/move-log').MoveLog<DummyEvent>} */
const log = createMoveLog(events)
expect(log.schema_version).toBe(1)
expect(log.events).toHaveLength(3)
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 } })
})
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
})
it('rejects a non-monotonic seq at construction', () => {
expect(() => createMoveLog([
{ seq: 2, t: 0, event: {} },
{ seq: 1, t: 1, event: {} }
])).toThrow(RangeError)
})
})
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 } }
]
it('preserves order, timestamps, and sequence numbers exactly', () => {
const log = createMoveLog(events)
const restored = deserializeMoveLog(serializeMoveLog(log))
expect(restored).toEqual(log) // full structural fidelity
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'])
})
it('serializeMoveLog produces a JSON string parseable back to the same object', () => {
const log = createMoveLog(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))
// not a string
// @ts-expect-error — deliberately wrong type
expect(() => deserializeMoveLog({})).toThrow(TypeError)
// 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)
// 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)
// shuffled / non-monotonic seq
const shuffled = JSON.stringify({
schema_version: 1,
events: [{ seq: 3, t: 0, event: {} }, { seq: 1, t: 1, event: {} }]
})
expect(() => deserializeMoveLog(shuffled)).toThrow(RangeError)
// distinct messages, not one generic error
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(shuffled))
]
expect(new Set(messages).size).toBe(messages.length)
// sanity: the valid fixture still deserializes
expect(isMoveLog(deserializeMoveLog(valid))).toBe(true)
})
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: {} }]
})
let result = 'sentinel'
expect(() => { result = deserializeMoveLog(partlyBad) }).toThrow(TypeError)
expect(result).toBe('sentinel') // assignment never happened
})
it('isMoveLog / assertMoveLog agree on validity', () => {
const log = createMoveLog(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)
})
})
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 } }
]
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 }
])
expect(log.events[0].receivedTs).toBe(1000)
const restored = deserializeMoveLog(serializeMoveLog(log))
expect(restored).toEqual(log)
expect(restored.events.map(e => e.receivedTs)).toEqual([1000, 1060])
})
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
])
expect(isMoveLog(log)).toBe(true)
expect('receivedTs' in log.events[1]).toBe(false)
expect(deserializeMoveLog(serializeMoveLog(log))).toEqual(log)
})
it('REGRESSION: a log with no receivedTs anywhere stays fully valid and leaks no key', () => {
const log = createMoveLog(base)
expect(isMoveLog(log)).toBe(true)
expect(log.events.every(e => !('receivedTs' in e))).toBe(true)
const restored = deserializeMoveLog(serializeMoveLog(log))
expect(restored).toEqual(log)
expect(restored.events.every(e => !('receivedTs' in e))).toBe(true)
})
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(() => deserializeMoveLog(JSON.stringify({
schema_version: 1,
events: [{ seq: 1, t: 0, event: {}, receivedTs: 'later' }]
}))).toThrow(TypeError)
})
it('withReceivedTs attaches host-received times additively without mutating the input', () => {
const log = createMoveLog(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
expect(stamped.events.map(e => e.receivedTs)).toEqual([910, 920])
expect(stamped.schema_version).toBe(1)
expect(deserializeMoveLog(serializeMoveLog(stamped))).toEqual(stamped)
})
it('withReceivedTs leaves events unstamped when the stamp returns undefined', () => {
const log = createMoveLog(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)
expect(isMoveLog(stamped)).toBe(true)
})
it('withReceivedTs rejects a stamp that returns a non-finite number', () => {
const log = createMoveLog(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)', () => {
// 3x3, single mine at (0,0); adjacency computed. Lets us script exact moves.
const layout = {
rows: 3,
cols: 3,
mines: 1,
cells: [
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
],
mineLocations: [[0, 0]]
}
it('records an mnswpr session end-to-end and round-trips it losslessly', () => {
let now = 1000
const clock = () => now
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout), clock })
// Capture the real core-06 move-events ({ type, r, c, t, seq }).
/** @type {any[]} */
const emitted = []
session.onMove(e => emitted.push(e))
now = 1050; session.applyMove({ type: 'reveal', r: 0, c: 1 })
now = 1100; session.applyMove({ type: 'flag', r: 0, c: 0 })
now = 1150; session.applyMove({ type: 'flag', r: 0, c: 0 }) // unflag
now = 1200; session.applyMove({ type: 'flag', r: 0, c: 0 }) // re-flag the mine
now = 1250; session.applyMove({ type: 'chord', r: 0, c: 1 }) // 1 flag == value → chord
expect(emitted.map(e => e.type)).toEqual(['reveal', 'flag', 'unflag', 'flag', 'chord'])
// Wrap the stream: lift the recording metadata (seq, t) to the log level.
const log = createMoveLog(emitted.map(e => ({ seq: e.seq, t: e.t, event: e })))
const restored = deserializeMoveLog(serializeMoveLog(log))
expect(restored).toEqual(log)
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'])
// 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))
})
})
describe('game-agnosticism guard (zero game-specific imports)', () => {
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
// The set of game packages the move log must never depend on or import.
const GAME_REFERENCES = /mnswpr|minesweeper/i
it('manifest declares no dependency on a game package', () => {
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
const deps = {
...pkg.dependencies,
...pkg.devDependencies,
...pkg.peerDependencies
}
const offenders = Object.keys(deps).filter(name => GAME_REFERENCES.test(name))
expect(offenders).toEqual([])
})
it('no source file imports or references a game package', () => {
const offenders = []
walk(pkgDir, file => {
if (!file.endsWith('.js')) return
if (file.includes('/test/')) return // the test may name/import games on purpose
// Strip comments so prose that *names* a game isn't a false positive.
const code = readFileSync(file, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')
if (GAME_REFERENCES.test(code)) offenders.push(file)
})
expect(offenders).toEqual([])
})
})
function captureMessage(fn) {
try {
fn()
return null
} catch (err) {
return err.message
}
}
function walk(dir, fn) {
for (const name of readdirSync(dir)) {
if (name === 'node_modules') continue
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p, fn)
else fn(p)
}
}

150
packages/replay/README.md Normal file
View file

@ -0,0 +1,150 @@
# @cozy-games/replay
A **game-agnostic** replay engine. `PlaybackClock` re-drives a
[`@cozy-games/move-log`](../move-log) envelope over time — scheduling each
recorded event to fire at its offset — with `play` / `pause` / `seek`.
```js
import { PlaybackClock } from '@cozy-games/replay'
const clock = new PlaybackClock(envelope) // a valid move-log envelope
const off = clock.on(record => apply(record.event)) // record = { seq, t, event, ... }
clock.play() // events fire at their recorded offsets
clock.pause() // freeze at the current position
clock.seek(1500) // jump to 1500ms — delivers exactly the events at offset <= 1500
```
## Progress via a game adapter
The engine never interprets events. To show completion percent, supply a game
**adapter** with a `progress(events) → %` reducer at construction:
```js
const adapter = { progress: (events) => (events.length / total) * 100 }
const clock = new PlaybackClock(envelope, {}, adapter)
clock.seek(1500)
clock.progress() // 0100 (clamped), or null if no adapter supplied
```
The reducer receives the ordered slice of events delivered so far; the engine
clamps the result and stays blind to the payload. See
[docs/adapter-interface.md](./docs/adapter-interface.md) for the full contract.
### Progress mode: a signal over time
`onProgress` turns the clock + reducer into a live "percent complete over elapsed
time" signal — updates as playback advances, on seek forward, and on seek back:
```js
const clock = new PlaybackClock(envelope, {}, adapter)
clock.onProgress(({ position, progress }) => draw(position, progress))
clock.play()
```
Fidelity is exact: at playback time `t` the delivered set is precisely the events
at offset `≤ t`, so the emitted `progress` matches the original run's progress at
`t`. Updates fire only when the percentage moves (a flag/unflag emits nothing).
Subscribe before playing to catch every update; call `progress()` for the current
value at any time.
### Full-board mode (flag-gated)
With a `state` reducer and the `fullBoard` flag, the engine reconstructs the whole
board at any point — `state()` for the current board, `onState` for a stream, and
`seek(t)` rebuilds the exact state at `t`:
```js
const clock = new PlaybackClock(envelope, {}, adapter, { fullBoard: true })
clock.onState(({ position, state }) => render(state))
clock.seek(1500) // state() now reflects the board at 1500ms
```
Off by default: without the flag, `state()` is `null`, `onState` never fires, and
the reducer never runs. See [docs/adapter-interface.md](./docs/adapter-interface.md).
### The "ended" signal & partial recordings
`onEnd` fires with `{ position }` when playback reaches the **last recorded
event's offset**:
```js
clock.onEnd(({ position }) => showEndScreen())
```
The engine has no notion of a "terminal" event, so this is the *same* signal
whether the run completed or the recording was **truncated mid-game**. A partial
log plays up to its last event and stops cleanly in both progress and full-board
modes — no error for the mere absence of a terminal event. Progress **freezes at
its last computed value** (no extrapolation, no jump to 100%), because it's purely
the reducer over the delivered events. The signal re-arms if you seek back before
the end.
## Offsets
Each event fires at its **offset** — its recorded `t` minus the first event's
`t`, so playback time `0` is the first event. `duration` is the last event's
offset.
## Injected clock + scheduler
The time source and scheduler are injected (mirroring the core session's
injected-clock seam), so tests get exact, deterministic timing:
```js
new PlaybackClock(envelope, { clock, setTimeout, clearTimeout })
```
They default to the real host (`Date.now` + global timers). Under a deterministic
injected scheduler — or `vi.useFakeTimers()` — events fire **exactly** at their
offsets (tolerance 0). Under the real host scheduler the tolerance is the host's
timer resolution (a few ms), the same bound as any `setTimeout`.
## Seek is deterministic
The clock keeps one invariant: `cursor` = the number of events whose offset is
`<= position`. So after `seek(t)` the delivered set is exactly the events at
offset `<= t`:
- **Forward** (`seek` ahead, or playback advancing) delivers each newly-passed
event once, in order.
- **Backward** rewinds the cursor without delivering; passing those offsets again
going forward re-delivers them (so scrub-back-then-replay works).
No event is ever delivered twice for a single forward pass, and none is dropped.
## Schema-version dispatch
One engine build replays envelopes from multiple format generations. On
construction the envelope is routed by its `schema_version` through a dispatch
table to a version-specific **reader** that normalizes it into the canonical
`MoveEvent` records the engine plays:
```js
readEnvelope(envelope) // → canonical records, or throws
```
- **v1** is the only built-in reader today (the canonical format itself).
- **Unknown versions fail loudly** — never a silent best-effort parse:
```
readEnvelope: unsupported envelope schema_version 99 (supported: 1)
```
- **Adding a generation is one entry.** Register a reader for this instance via
the `readers` option (or add to the built-in table for a permanent version):
```js
const v2 = (env) => env.log.map(e => ({ seq: e.n, t: e.ts, event: e.payload }))
new PlaybackClock(v2Envelope, {}, adapter, { readers: { 2: v2 } })
```
Whatever a reader returns is validated as a canonical move log, so a
half-normalized generation can never reach the engine.
## Invariant: envelope only, no game types
This module imports **only** `@cozy-games/move-log` (to validate the envelope) and
never a game package. It treats every `event` payload as opaque. Enforced by a
dependency-graph guard in `test/playback-clock.test.js`.

View file

@ -0,0 +1,94 @@
# Replay adapter interface
The replay engine is **game-agnostic**: it schedules and delivers the events in a
[`@cozy-games/move-log`](../../move-log) envelope over time, but it never
interprets what an event *means*. All game meaning enters through a **game
adapter** — the seam defined here. This is the concrete realization of the
progress-reducer item in
[ADR 0002](../../../docs/decisions/0002-game-adapter-pattern.md).
## `ReplayAdapter<T>`
An adapter is a plain object supplied at construction:
```js
new PlaybackClock(envelope, deps, adapter)
```
```ts
// Typed generically over the game's event vocabulary T (and state S).
type ReplayAdapter<T> = {
progress?: ProgressReducer<T>
state?: StateReducer<T, S>
}
type ProgressReducer<T> = (events: MoveEvent<T>[]) => number // 0100
type StateReducer<T, S> = (events: MoveEvent<T>[]) => S // full game state
```
`MoveEvent<T>` is the move-log record `{ seq, t, event, receivedTs? }`, where
`event` is the game's own payload — opaque to the engine.
## `progress(events) → %`
The only adapter method today. It maps the **ordered slice of events delivered so
far** (every event whose offset ≤ the current playback position) to a completion
percentage.
- **Input:** `MoveEvent<T>[]` — the played-so-far slice, in order. To compute a
percentage the adapter typically needs a total (e.g. total safe cells); it owns
that context, usually by closing over the board it was built from. The engine
passes only the slice.
- **Output:** a number in `[0, 100]`. The engine **clamps** the result into range
and throws if the reducer returns a non-number, so an adapter can be permissive.
- **When:** call `clock.progress()` at any time. It returns `null` if no adapter
(or no `progress`) was supplied — the engine never invents a percentage.
```js
// A minesweeper-style adapter, built over its board (illustrative):
const adapter = {
progress: (events) => {
const revealed = events.filter(e => e.event.type === 'reveal').length
return (revealed / totalSafeCells) * 100
}
}
const clock = new PlaybackClock(envelope, {}, adapter)
clock.seek(1500)
clock.progress() // → e.g. 42
```
## `state(events) → S` (full-board mode)
The second reducer reconstructs the **complete game state** at a playback point
from the ordered slice of events delivered so far. It powers full-board replay —
rebuilding the whole board on seek, not just a percentage.
- **Input:** `MoveEvent<T>[]` — the played-so-far slice, in order.
- **Output:** the game's own state type `S` (opaque to the engine). For mnswpr
it's a 2D board snapshot: `{ rows, cols, phase, revealedSafe, cells }`.
- **When:** `clock.state()` returns the current reconstruction, and `onState`
streams `{ position, state }` as playback advances or seeks.
### Flag-gated
Full-board mode is **off by default** and gated behind a runtime flag — the
engine's minimal, documented feature-flag seam:
```js
new PlaybackClock(envelope, deps, adapter, { fullBoard: true })
```
When the flag is **off** (default), the mode is fully inert: `state()` returns
`null`, `onState` never fires, and the state reducer is never invoked (no
reconstruction cost). When **on** with a `state` reducer supplied, `state()` and
`onState` reconstruct the board — and `seek(t)` rebuilds the exact state at `t`.
## Contract rules
- **The engine calls the reducer; it never interprets events itself.** Engine
source references only envelope types (`MoveEvent` / `MoveLog`) and the log's
recording metadata (`seq`, `t`) — never an event's `.event` payload. This is
enforced by a guard in `test/playback-clock.test.js`.
- **The adapter owns all game meaning** — event vocabulary, progress math, and
(as the contract grows) state reduction and terminal predicates per ADR 0002.
- **Typed generically over `T`** so one engine serves every game.

422
packages/replay/index.js Normal file
View file

@ -0,0 +1,422 @@
// @ts-check
import { assertMoveLog, SCHEMA_VERSION } 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
* 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 clock and scheduler are injected (mirroring the core session's
* injected-clock seam), so tests drive it with fake timers or a hand-rolled
* scheduler for exact, deterministic timing.
*/
/**
* @typedef {import('@cozy-games/move-log').MoveEvent<any>} Event
* @typedef {import('@cozy-games/move-log').MoveLog<any>} Envelope
* @typedef {{
* clock?: () => number,
* setTimeout?: (fn: () => void, ms: number) => any,
* clearTimeout?: (handle: any) => void
* }} Deps
*/
/**
* 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.
*
* @template T
* @typedef {(events: import('@cozy-games/move-log').MoveEvent<T>[]) => number} ProgressReducer
*/
/**
* A reducer supplied by a game adapter for full-board replay: given the ordered
* 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.
*
* @template T, S
* @typedef {(events: import('@cozy-games/move-log').MoveEvent<T>[]) => S} StateReducer
*/
/**
* The replay game-adapter contract (v0) the seam through which game meaning
* 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
*/
/**
* 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.
*
* @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.
*
* @type {Record<number, EnvelopeReader>}
*/
const ENVELOPE_READERS = { [SCHEMA_VERSION]: readV1 }
/**
* 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.
*
* @param {any} envelope
* @param {Record<number, EnvelopeReader>} [extraReaders] - added/overriding readers
* @returns {Event[]}
*/
export function readEnvelope(envelope, extraReaders) {
if (envelope === null || typeof envelope !== 'object') {
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 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
}
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] -
* `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
* readers for this instance (see {@link readEnvelope}).
*/
constructor(envelope, deps = {}, adapter = {}, options = {}) {
// Dispatch on schema_version → the matching reader's canonical records.
const records = readEnvelope(envelope, options.readers)
if (adapter.progress !== undefined && typeof adapter.progress !== 'function') {
throw new TypeError('PlaybackClock: adapter.progress must be a function when provided')
}
if (adapter.state !== undefined && typeof adapter.state !== 'function') {
throw new TypeError('PlaybackClock: adapter.state must be a function when provided')
}
this._adapter = adapter
this._fullBoard = options.fullBoard === true
const {
clock = () => Date.now(),
setTimeout = (fn, ms) => globalThis.setTimeout(fn, ms),
clearTimeout = (handle) => globalThis.clearTimeout(handle)
} = deps
this._now = clock
this._setTimeout = setTimeout
this._clearTimeout = clearTimeout
// 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
/** @type {{ offset: number, record: Event }[]} */
this._events = sorted.map(record => ({ offset: record.t - 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
// <= the current position; events below the cursor have been delivered in the
// current forward pass. This single source of truth makes seek deterministic.
this._position = 0
this._cursor = 0
this._playing = false
/** @type {any} */
this._timer = null
this._anchorClock = 0
this._anchorPosition = 0
/** @type {Set<(event: Event) => void>} */
this._handlers = new Set()
/** @type {Set<(update: { position: number, progress: number }) => void>} */
this._progressHandlers = new Set()
/** Last progress value pushed, so unchanged progress (e.g. a flag) is not re-emitted. */
this._lastProgress = /** @type {number | null} */ (null)
/** @type {Set<(update: { position: number, state: any }) => void>} */
this._stateHandlers = new Set()
/** @type {Set<(update: { position: number }) => void>} */
this._endHandlers = new Set()
/** True once the end has been reached; re-arms when playback moves back before it. */
this._ended = false
}
/** Total playback length in ms (offset of the last event; 0 if empty). */
get duration() {
return this._duration
}
/** @returns {boolean} */
isPlaying() {
return this._playing
}
/** Current playback position in ms, clamped to `[0, duration]`. */
position() {
return this._livePosition()
}
/**
* Completion percentage (0100) at the current position, via the adapter's
* progress reducer or `null` if no reducer was supplied. The engine hands the
* reducer the ordered slice of events delivered so far and clamps its result;
* it never interprets an event payload itself (that's the adapter's job).
*
* @returns {number | null}
*/
progress() {
const reduce = this._adapter.progress
if (typeof reduce !== 'function') return null
const delivered = this._events.slice(0, this._cursor).map(e => e.record)
const pct = reduce(delivered)
if (typeof pct !== 'number' || Number.isNaN(pct)) {
throw new TypeError(`PlaybackClock.progress: reducer must return a number (got ${typeof pct})`)
}
return Math.min(100, Math.max(0, pct))
}
/**
* Full-board mode: the reconstructed game state at the current position, via the
* adapter's `state` reducer over the delivered slice. Returns `null` unless the
* `fullBoard` flag is on AND a state reducer was supplied so the mode is inert
* (and the reducer never runs) by default. The state shape `S` is the adapter's;
* the engine treats it as opaque.
*
* @returns {any}
*/
state() {
if (!this._fullBoard) return null
const reduce = this._adapter.state
if (typeof reduce !== 'function') return null
return reduce(this._events.slice(0, this._cursor).map(e => e.record))
}
/**
* Subscribe to delivered events. The handler receives the raw envelope record
* (`{ seq, t, event, ... }`) the payload stays opaque. Returns an unsubscribe.
*
* @param {(event: Event) => void} handler
* @returns {() => void}
*/
on(handler) {
this._handlers.add(handler)
return () => this._handlers.delete(handler)
}
/**
* Progress-mode subscription: receive `{ position, progress }` updates as
* playback advances the "percent complete over elapsed time" signal. Fires
* only when the percentage actually changes (so flags/unflags, which don't
* advance progress, emit nothing), on play, seek forward, and seek backward.
* Requires an adapter with a `progress` reducer; without one it never emits.
* Subscribe before playing to catch every update; use {@link progress} for the
* current value at any time. Returns an unsubscribe.
*
* @param {(update: { position: number, progress: number }) => void} handler
* @returns {() => void}
*/
onProgress(handler) {
this._progressHandlers.add(handler)
return () => this._progressHandlers.delete(handler)
}
/**
* Full-board mode subscription: receive `{ position, state }` whenever the
* delivered set changes (play, seek forward, seek backward), where `state` is
* the adapter's reconstruction at that position. Inert unless the `fullBoard`
* flag is on and a state reducer was supplied. Returns an unsubscribe.
*
* @param {(update: { position: number, state: any }) => void} handler
* @returns {() => void}
*/
onState(handler) {
this._stateHandlers.add(handler)
return () => this._stateHandlers.delete(handler)
}
/**
* Subscribe to the "ended" signal: fires with `{ position }` when playback
* reaches the last recorded event's offset the SAME signal whether the run
* completed or the recording was truncated mid-game (the engine has no notion
* of a terminal event). Fires once on reaching the end and re-arms if playback
* moves back before it. Returns an unsubscribe.
*
* @param {(update: { position: number }) => void} handler
* @returns {() => void}
*/
onEnd(handler) {
this._endHandlers.add(handler)
return () => this._endHandlers.delete(handler)
}
/**
* Start (or resume) playback from the current position. Any event already due
* at the current position fires synchronously; the rest are scheduled at their
* offsets. No-op if already playing or already at the end.
*/
play() {
if (this._playing) return
this._playing = true
this._anchorClock = this._now()
this._anchorPosition = this._position
this._scheduleNext()
}
/** Pause playback, freezing the position where it currently is. */
pause() {
if (!this._playing) return
this._position = this._livePosition()
this._playing = false
this._clearTimer()
}
/**
* Seek to playback time `t` (ms, clamped to `[0, duration]`). Deterministic:
* afterwards the delivered set is exactly the events at offset <= t. Moving
* forward delivers the newly-passed events in order (each exactly once); moving
* backward rewinds the cursor without delivering, so a later forward pass
* re-delivers them. Re-schedules if playing.
*
* @param {number} t
*/
seek(t) {
if (typeof t !== 'number' || Number.isNaN(t)) {
throw new TypeError(`PlaybackClock.seek: t must be a number (got ${typeof t})`)
}
const wasPlaying = this._playing
this._clearTimer()
this._advanceTo(t)
if (wasPlaying) {
this._anchorClock = this._now()
this._anchorPosition = this._position
this._scheduleNext()
}
}
// ---- internals ----
/** Live position: derived from the clock while playing, else the stored value. */
_livePosition() {
if (!this._playing) return this._position
const raw = this._anchorPosition + (this._now() - this._anchorClock)
return Math.min(Math.max(raw, 0), this._duration)
}
/**
* Move the cursor to match `target` position: emit events crossed going
* forward (once each), un-count events going backward (no emit). Sets position.
* @param {number} target
*/
_advanceTo(target) {
const t = Math.min(Math.max(target, 0), this._duration)
const before = this._cursor
while (this._cursor < this._events.length && this._events[this._cursor].offset <= t) {
this._emit(this._events[this._cursor].record)
this._cursor++
}
while (this._cursor > 0 && this._events[this._cursor - 1].offset > t) {
this._cursor--
}
this._position = t
this._emitProgressIfChanged()
if (this._cursor !== before) this._emitState()
this._maybeEmitEnded()
}
/** Deliver an event to all subscribers. */
_emit(record) {
for (const handler of this._handlers) handler(record)
}
/** Push a progress update to subscribers, but only when the percentage moved. */
_emitProgressIfChanged() {
if (this._progressHandlers.size === 0) return
const progress = this.progress()
if (progress === null || progress === this._lastProgress) return
this._lastProgress = progress
const update = { position: this._position, progress }
for (const handler of this._progressHandlers) handler(update)
}
/**
* Fire "ended" once when every recorded event has been delivered (the timeline
* reached the last event's offset); re-arm when playback moves back before it.
* Terminal-agnostic: a truncated recording ends here exactly like a complete one.
*/
_maybeEmitEnded() {
const atEnd = this._events.length > 0 && this._cursor >= this._events.length
if (atEnd && !this._ended) {
this._ended = true
const update = { position: this._position }
for (const handler of this._endHandlers) handler(update)
} else if (!atEnd) {
this._ended = false
}
}
/** Push a reconstructed board state to subscribers — only in active full-board mode. */
_emitState() {
if (!this._fullBoard || this._stateHandlers.size === 0) return
if (typeof this._adapter.state !== 'function') return
const update = { position: this._position, state: this.state() }
for (const handler of this._stateHandlers) handler(update)
}
/**
* Deliver anything already due, then arm a timer for the next pending event.
* Ends playback when the cursor reaches the last event.
*/
_scheduleNext() {
this._clearTimer()
if (!this._playing) return
this._advanceTo(this._livePosition())
if (this._cursor >= this._events.length) {
this._position = this._duration
this._playing = false
return
}
const delay = Math.max(0, this._events[this._cursor].offset - this._livePosition())
this._timer = this._setTimeout(() => this._onTimer(), delay)
}
_onTimer() {
this._timer = null
if (this._playing) this._scheduleNext()
}
_clearTimer() {
if (this._timer !== null) {
this._clearTimeout(this._timer)
this._timer = null
}
}
}

View file

@ -0,0 +1,25 @@
{
"name": "@cozy-games/replay",
"version": "0.0.1",
"description": "Game-agnostic replay engine — a playback clock (play/pause/seek + event scheduling) that re-drives a move-log envelope over time",
"author": "Ayo Ayco",
"private": true,
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/ayo-run/mnswpr"
},
"main": "index.js",
"exports": {
".": {
"default": "./index.js"
},
"./*": {
"default": "./*"
}
},
"dependencies": {
"@cozy-games/move-log": "workspace:*"
},
"license": "BSD-2-Clause"
}

View file

@ -0,0 +1,147 @@
// @ts-check
import { describe, it, expect } from 'vitest'
import { PlaybackClock } from '@cozy-games/replay'
import { createMoveLog } from '@cozy-games/move-log'
// 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'
import { createStateReducer } from '../../mnswpr/adapters/replay-state.js'
function fakeScheduler(start = 0) {
let now = start
let nextId = 1
const timers = new Map()
return {
clock: () => now,
setTimeout: (fn, ms) => {
const id = nextId++
timers.set(id, { at: now + Math.max(0, ms), fn })
return id
},
clearTimeout: (id) => { timers.delete(id) },
advance(ms) {
const target = now + ms
for (;;) {
let due = null
for (const [id, timer] of timers) {
if (timer.at <= target && (due === null || timer.at < due.at)) due = { id, ...timer }
}
if (!due) break
timers.delete(due.id)
now = due.at
due.fn()
}
now = target
}
}
}
const board = {
rows: 3,
cols: 3,
mines: 1,
cells: [
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
],
mineLocations: [[0, 0]]
}
// Record a real run: reveal, reveal, flag the mine, then flood the rest.
let nowClock = 0
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(board), clock: () => nowClock })
const emitted = []
session.onMove(e => emitted.push(e))
const baseT = 1000
for (const step of [
{ at: 1000, move: { type: 'reveal', r: 0, c: 1 } },
{ at: 1100, move: { type: 'reveal', r: 1, c: 0 } },
{ at: 1200, move: { type: 'flag', r: 0, c: 0 } },
{ at: 1300, move: { type: 'reveal', r: 2, c: 2 } }
]) {
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 reduce = createStateReducer(board)
// Independent ground truth: reduce over records at offset <= t.
const truth = t => reduce(records.filter(r => (r.t - baseT) <= t))
describe('full-board mode — flag gating (inert by default)', () => {
it('does nothing when the flag is off, even with a state reducer', () => {
const clock = new PlaybackClock(envelope, fakeScheduler(), { state: reduce })
const updates = []
clock.onState(u => updates.push(u))
clock.seek(clock.duration)
expect(clock.state()).toBe(null)
expect(updates).toEqual([])
})
it('is inert when the flag is on but no state reducer is supplied', () => {
const clock = new PlaybackClock(envelope, fakeScheduler(), {}, { fullBoard: true })
clock.seek(clock.duration)
expect(clock.state()).toBe(null)
})
})
describe('full-board mode — reconstruction (flag on)', () => {
const make = () => new PlaybackClock(envelope, fakeScheduler(), { state: reduce }, { fullBoard: true })
it('state() reconstructs the board at multiple seek points', () => {
const clock = make()
for (const t of [-5, 0, 50, 100, 150, 200, 300, 400]) {
clock.seek(t)
const clamped = Math.max(0, Math.min(t, clock.duration))
expect(clock.state()).toEqual(truth(clamped))
}
})
it('seek reconstructs the correct concrete state (forward then backward)', () => {
const clock = make()
clock.seek(clock.duration) // end
let b = clock.state()
expect(b.phase).toBe('won')
expect(b.revealedSafe).toBe(8)
expect(b.cells[0][0].status).toBe('flagged')
clock.seek(100) // back to just after the two opening reveals
b = clock.state()
expect(b.phase).toBe('active')
expect(b.revealedSafe).toBe(2)
expect(b.cells[0][1].status).toBe('revealed')
expect(b.cells[2][2].status).toBe('hidden')
clock.seek(0) // back to the very first reveal
expect(clock.state().revealedSafe).toBe(1)
})
it('onState streams a reconstruction on every delivery (incl. the flag)', () => {
const s = fakeScheduler()
const clock = new PlaybackClock(envelope, s, { state: reduce }, { fullBoard: true })
const updates = []
clock.onState(u => updates.push(u))
clock.play()
s.advance(400)
expect(updates.map(u => u.position)).toEqual([0, 100, 200, 300])
expect(updates.at(-1).state.phase).toBe('won')
})
it('onState fires on backward seek', () => {
const clock = make()
const updates = []
clock.onState(u => updates.push(u))
clock.seek(clock.duration) // forward (delivers all at once → 1 update)
clock.seek(0) // backward → 1 update
expect(updates).toHaveLength(2)
expect(updates.at(-1).state.revealedSafe).toBe(1)
})
it('rejects a non-function state reducer at construction', () => {
expect(() => new PlaybackClock(envelope, fakeScheduler(), { state: /** @type {any} */ (5) })).toThrow(TypeError)
})
})

View file

@ -0,0 +1,174 @@
// @ts-check
import { describe, it, expect } from 'vitest'
import { PlaybackClock } from '@cozy-games/replay'
import { createMoveLog } from '@cozy-games/move-log'
// 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'
import { createProgressReducer } from '../../mnswpr/adapters/replay-progress.js'
import { createStateReducer } from '../../mnswpr/adapters/replay-state.js'
function fakeScheduler(start = 0) {
let now = start
let nextId = 1
const timers = new Map()
return {
clock: () => now,
setTimeout: (fn, ms) => {
const id = nextId++
timers.set(id, { at: now + Math.max(0, ms), fn })
return id
},
clearTimeout: (id) => { timers.delete(id) },
advance(ms) {
const target = now + ms
for (;;) {
let due = null
for (const [id, timer] of timers) {
if (timer.at <= target && (due === null || timer.at < due.at)) due = { id, ...timer }
}
if (!due) break
timers.delete(due.id)
now = due.at
due.fn()
}
now = target
}
}
}
const board = () => ({
rows: 3,
cols: 3,
mines: 1,
cells: [
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
],
mineLocations: [[0, 0]]
})
// A TRUNCATED recording: two opening reveals, then the log just stops — the game
// is still 'active' (2 of 8 safe cells), no win/loss ever recorded.
function truncatedEnvelope() {
let now = 0
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(board()), clock: () => now })
const emitted = []
session.onMove(e => emitted.push(e))
now = 1000; session.applyMove({ type: 'reveal', r: 0, c: 1 })
now = 1200; session.applyMove({ type: 'reveal', r: 1, c: 0 })
// ...stream cut here — no terminal event.
const baseT = 1000
return {
envelope: createMoveLog(emitted.map(e => ({ seq: e.seq, t: e.t, event: e }))),
lastOffset: 1200 - baseT // 200
}
}
describe('partial/incomplete recordings — progress mode', () => {
it('replays to the last event without error and freezes progress (no jump to 100%)', () => {
const { envelope, lastOffset } = truncatedEnvelope()
const s = fakeScheduler()
const clock = new PlaybackClock(envelope, s, { progress: createProgressReducer(board()) })
const progressUpdates = []
const ends = []
clock.onProgress(u => progressUpdates.push(u))
clock.onEnd(u => ends.push(u))
expect(() => { clock.play(); s.advance(10_000) }).not.toThrow() // long past the last event
// Frozen at the true value for 2/8 safe cells — NOT extrapolated to 100.
expect(clock.progress()).toBeCloseTo(25, 5)
expect(clock.isPlaying()).toBe(false)
// "ended" fired once, at the last event's offset.
expect(ends).toHaveLength(1)
expect(ends[0].position).toBe(lastOffset)
// Progress held at its last value after the stream ended.
expect(progressUpdates.at(-1).progress).toBeCloseTo(25, 5)
})
it('progress stays frozen when time advances past the end', () => {
const { envelope } = truncatedEnvelope()
const s = fakeScheduler()
const clock = new PlaybackClock(envelope, s, { progress: createProgressReducer(board()) })
clock.play()
s.advance(300)
const atEnd = clock.progress()
s.advance(5000) // way past
expect(clock.progress()).toBe(atEnd) // no drift, no extrapolation
expect(atEnd).toBeCloseTo(25, 5)
})
})
describe('partial/incomplete recordings — full-board mode', () => {
it('reconstructs the last recorded state without error and ends cleanly', () => {
const { envelope, lastOffset } = truncatedEnvelope()
const s = fakeScheduler()
const clock = new PlaybackClock(envelope, s, { state: createStateReducer(board()) }, { fullBoard: true })
const ends = []
clock.onEnd(u => ends.push(u))
expect(() => { clock.play(); s.advance(10_000) }).not.toThrow()
const b = clock.state()
expect(b.phase).toBe('active') // never reached a terminal state — and that's fine
expect(b.revealedSafe).toBe(2)
expect(b.cells[0][1].status).toBe('revealed')
expect(b.cells[2][2].status).toBe('hidden') // rest still unopened
expect(ends).toHaveLength(1)
expect(ends[0].position).toBe(lastOffset)
})
})
describe('the "ended" signal', () => {
it('fires identically for a complete run and a truncated one', () => {
// Complete run: play the board to a win.
let now = 0
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(board()), clock: () => now })
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 s = fakeScheduler()
const clock = new PlaybackClock(complete, s)
const ends = []
clock.onEnd(u => ends.push(u))
clock.play()
s.advance(1000)
expect(ends).toHaveLength(1)
expect(ends[0].position).toBe(clock.duration) // last event's offset
})
it('fires on seek to the end and re-arms after seeking back', () => {
const { envelope } = truncatedEnvelope()
const clock = new PlaybackClock(envelope, fakeScheduler())
const ends = []
clock.onEnd(() => ends.push(1))
clock.seek(clock.duration) // reach the end
expect(ends).toHaveLength(1)
clock.seek(clock.duration) // still at the end — no re-fire
expect(ends).toHaveLength(1)
clock.seek(0) // move back — re-arm
clock.seek(clock.duration) // reach the end again
expect(ends).toHaveLength(2)
})
it('does not fire for an empty envelope', () => {
const clock = new PlaybackClock(createMoveLog([]), fakeScheduler())
const ends = []
clock.onEnd(() => ends.push(1))
clock.play()
clock.seek(0)
expect(ends).toEqual([])
})
})

View file

@ -0,0 +1,323 @@
// @ts-check
import { describe, it, expect, vi, afterEach } from 'vitest'
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
// Imported via the PACKAGE NAME to prove the new workspace module resolves.
import { PlaybackClock } from '@cozy-games/replay'
import { createMoveLog } from '@cozy-games/move-log'
/**
* A hand-rolled deterministic scheduler the injected-clock seam in action, with
* zero reliance on vi internals. `advance(ms)` fires due timers in time order,
* picking up timers scheduled from within a firing callback.
*/
function fakeScheduler(start = 0) {
let now = start
let nextId = 1
const timers = new Map()
return {
clock: () => now,
setTimeout: (fn, ms) => {
const id = nextId++
timers.set(id, { at: now + Math.max(0, ms), fn })
return id
},
clearTimeout: (id) => { timers.delete(id) },
advance(ms) {
const target = now + ms
for (;;) {
let due = null
for (const [id, timer] of timers) {
if (timer.at <= target && (due === null || timer.at < due.at)) due = { id, ...timer }
}
if (!due) break
timers.delete(due.id)
now = due.at
due.fn()
}
now = target
}
}
}
// Events at offsets 0, 100, 350 (t 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 } }
])
}
const typesOf = records => records.map(r => r.event.type)
describe('PlaybackClock — construction & shape', () => {
it('rebases to offsets: duration is the last offset, first event at 0', () => {
const clock = new PlaybackClock(envelope(), fakeScheduler())
expect(clock.duration).toBe(350)
expect(clock.position()).toBe(0)
expect(clock.isPlaying()).toBe(false)
})
it('validates the envelope (rejects a non-envelope)', () => {
expect(() => new PlaybackClock(/** @type {any} */ (null))).toThrow()
expect(() => new PlaybackClock(/** @type {any} */ ({ schema_version: 2, events: [] }))).toThrow()
})
it('handles an empty envelope gracefully', () => {
const clock = new PlaybackClock(createMoveLog([]), fakeScheduler())
const seen = []
clock.on(r => seen.push(r))
expect(clock.duration).toBe(0)
clock.play()
expect(clock.isPlaying()).toBe(false) // nothing to play → ends immediately
expect(seen).toEqual([])
})
})
describe('PlaybackClock — play / pause with an injected scheduler', () => {
it('fires events at their recorded offsets, exactly', () => {
const s = fakeScheduler()
const clock = new PlaybackClock(envelope(), s)
const seen = []
clock.on(r => seen.push({ type: r.event.type, at: s.clock() }))
clock.play()
// offset-0 event fires synchronously on play
expect(seen).toEqual([{ type: 'reveal', at: 0 }])
s.advance(100)
expect(seen[1]).toEqual({ type: 'flag', at: 100 })
s.advance(250) // reach offset 350
expect(seen[2]).toEqual({ type: 'chord', at: 350 })
expect(clock.isPlaying()).toBe(false) // ended
expect(clock.position()).toBe(350)
})
it('pause freezes position and stops delivery', () => {
const s = fakeScheduler()
const clock = new PlaybackClock(envelope(), s)
const seen = []
clock.on(r => seen.push(r.event.type))
clock.play()
s.advance(150) // past offset 100, between 100 and 350
clock.pause()
expect(clock.position()).toBe(150)
expect(seen).toEqual(['reveal', 'flag'])
s.advance(1000) // no timers should fire while paused
expect(seen).toEqual(['reveal', 'flag'])
expect(clock.position()).toBe(150)
})
it('resumes from the paused position', () => {
const s = fakeScheduler()
const clock = new PlaybackClock(envelope(), s)
const seen = []
clock.on(r => seen.push({ type: r.event.type, at: s.clock() }))
clock.play()
s.advance(150)
clock.pause()
clock.play() // resume at 150; next event at 350 ⇒ 200ms away
s.advance(200)
expect(seen.map(e => e.type)).toEqual(['reveal', 'flag', 'chord'])
expect(seen[2].at).toBe(350) // still fires at its true offset
})
})
describe('PlaybackClock — seek determinism', () => {
it('seek forward delivers exactly the events at offset <= t, in order, once', () => {
const clock = new PlaybackClock(envelope(), fakeScheduler())
const seen = []
clock.on(r => seen.push(r))
clock.seek(200) // offsets 0 and 100 are <= 200; 350 is not
expect(typesOf(seen)).toEqual(['reveal', 'flag'])
expect(clock.position()).toBe(200)
clock.seek(200) // no movement ⇒ no new deliveries
expect(typesOf(seen)).toEqual(['reveal', 'flag'])
})
it('seek boundary is inclusive (offset === t fires)', () => {
const clock = new PlaybackClock(envelope(), fakeScheduler())
const seen = []
clock.on(r => seen.push(r))
clock.seek(100)
expect(typesOf(seen)).toEqual(['reveal', 'flag']) // offset 100 included
})
it('seek backward re-schedules with no duplicate or dropped events', () => {
const clock = new PlaybackClock(envelope(), fakeScheduler())
const seen = []
clock.on(r => seen.push(r))
clock.seek(400) // deliver all three
expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord'])
clock.seek(50) // rewind — no delivery; only offset-0 stays "passed"
expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord']) // unchanged
clock.seek(400) // forward again re-delivers the re-crossed events, once each
expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord', 'flag', 'chord'])
})
it('seek while playing re-anchors and keeps firing correctly', () => {
const s = fakeScheduler()
const clock = new PlaybackClock(envelope(), s)
const seen = []
clock.on(r => seen.push(r.event.type))
clock.play()
s.advance(50) // only offset-0 delivered so far
expect(seen).toEqual(['reveal'])
clock.seek(120) // jump forward while playing ⇒ deliver offset-100 event
expect(seen).toEqual(['reveal', 'flag'])
s.advance(230) // reach 350 ⇒ final event
expect(seen).toEqual(['reveal', 'flag', 'chord'])
expect(clock.isPlaying()).toBe(false)
})
it('never delivers an event twice within a single forward pass', () => {
const s = fakeScheduler()
const clock = new PlaybackClock(envelope(), s)
const seqs = []
clock.on(r => seqs.push(r.seq))
clock.play()
s.advance(1000)
expect(seqs).toEqual([1, 2, 3]) // each once, in order
})
})
describe('PlaybackClock — with vi fake timers', () => {
afterEach(() => { vi.useRealTimers() })
it('play/pause/seek work under vi.useFakeTimers()', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
// 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.play()
expect(seen).toEqual(['reveal']) // offset 0 immediate
vi.advanceTimersByTime(100)
expect(seen).toEqual(['reveal', 'flag'])
clock.pause()
vi.advanceTimersByTime(1000)
expect(seen).toEqual(['reveal', 'flag']) // paused ⇒ frozen
clock.play()
vi.advanceTimersByTime(250)
expect(seen).toEqual(['reveal', 'flag', 'chord'])
})
})
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>}
*/
const byCount = events => (events.length / 3) * 100 // 3 = total in envelope()
it('runs against a dummy adapter: progress reflects the delivered slice', () => {
const clock = new PlaybackClock(envelope(), fakeScheduler(), { progress: byCount })
expect(clock.progress()).toBe(0) // nothing delivered yet
clock.seek(0) // offset-0 event delivered ⇒ 1/3
expect(clock.progress()).toBeCloseTo(33.333, 2)
clock.seek(100) // 2/3
expect(clock.progress()).toBeCloseTo(66.667, 2)
clock.seek(400) // all 3 ⇒ 100
expect(clock.progress()).toBe(100)
clock.seek(50) // rewind ⇒ back to 1/3
expect(clock.progress()).toBeCloseTo(33.333, 2)
})
it('advances as playback advances', () => {
const s = fakeScheduler()
const clock = new PlaybackClock(envelope(), s, { progress: byCount })
clock.play()
expect(clock.progress()).toBeCloseTo(33.333, 2) // offset-0 fired on play
s.advance(100)
expect(clock.progress()).toBeCloseTo(66.667, 2)
s.advance(250)
expect(clock.progress()).toBe(100)
})
it('returns null when no adapter (or no progress reducer) is supplied', () => {
expect(new PlaybackClock(envelope(), fakeScheduler()).progress()).toBe(null)
expect(new PlaybackClock(envelope(), fakeScheduler(), {}).progress()).toBe(null)
})
it('clamps the reducer output into [0, 100]', () => {
const over = new PlaybackClock(envelope(), fakeScheduler(), { progress: () => 999 })
const under = new PlaybackClock(envelope(), fakeScheduler(), { progress: () => -50 })
expect(over.progress()).toBe(100)
expect(under.progress()).toBe(0)
})
it('throws if the reducer returns a non-number', () => {
const clock = new PlaybackClock(envelope(), fakeScheduler(), { progress: () => /** @type {any} */ ('nope') })
expect(() => clock.progress()).toThrow(TypeError)
})
it('rejects a non-function progress at construction', () => {
expect(() => new PlaybackClock(envelope(), fakeScheduler(), { progress: /** @type {any} */ (42) })).toThrow(TypeError)
})
})
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)', () => {
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)
})
expect(offenders).toEqual([]) // engine references only envelope metadata (seq/t) + opaque records
})
it('manifest depends only on the envelope, never a game package', () => {
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
expect(Object.keys(deps).filter(name => GAME_REFERENCES.test(name))).toEqual([])
})
it('no source file imports or references a game package', () => {
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 (GAME_REFERENCES.test(code)) offenders.push(file)
})
expect(offenders).toEqual([])
})
})
function walk(dir, fn) {
for (const name of readdirSync(dir)) {
if (name === 'node_modules') continue
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p, fn)
else fn(p)
}
}

View file

@ -0,0 +1,184 @@
// @ts-check
import { describe, it, expect } from 'vitest'
import { PlaybackClock } from '@cozy-games/replay'
import { createMoveLog } from '@cozy-games/move-log'
// 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'
import { createProgressReducer } from '../../mnswpr/adapters/replay-progress.js'
/** Deterministic injected scheduler (the injected-clock seam), no vi needed. */
function fakeScheduler(start = 0) {
let now = start
let nextId = 1
const timers = new Map()
return {
clock: () => now,
setTimeout: (fn, ms) => {
const id = nextId++
timers.set(id, { at: now + Math.max(0, ms), fn })
return id
},
clearTimeout: (id) => { timers.delete(id) },
advance(ms) {
const target = now + ms
for (;;) {
let due = null
for (const [id, timer] of timers) {
if (timer.at <= target && (due === null || timer.at < due.at)) due = { id, ...timer }
}
if (!due) break
timers.delete(due.id)
now = due.at
due.fn()
}
now = target
}
}
}
// 3x3, single mine at (0,0). Total safe = 8.
const layout = () => ({
rows: 3,
cols: 3,
mines: 1,
cells: [
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
],
mineLocations: [[0, 0]]
})
// Drive a real session, recording BOTH the emitted move-events and the ground
// truth (revealedSafe) after each move — an independent source of truth.
const board = layout()
const TOTAL_SAFE = 8
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(board), clock: () => nowClock })
let nowClock = 0
const emitted = []
session.onMove(e => emitted.push(e))
const truthPoints = [] // { offset, revealedSafe } after each move
const script = [
{ at: 1000, move: { type: 'reveal', r: 0, c: 1 } }, // +1 safe cell
{ at: 1100, move: { type: 'reveal', r: 1, c: 0 } }, // +1 safe cell
{ at: 1200, move: { type: 'flag', r: 0, c: 0 } }, // flag the mine — no progress
{ at: 1300, move: { type: 'reveal', r: 2, c: 2 } } // floods the rest → all 8
]
const baseT = script[0].at
for (const step of script) {
nowClock = step.at
session.applyMove(step.move)
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)
// Ground truth for the mnswpr reducer: revealedSafe / total at a given offset —
// derived from the session, NOT from the reducer under test.
function mnswprTruth(offset) {
let revealedSafe = 0
for (const p of truthPoints) if (p.offset <= offset) revealedSafe = p.revealedSafe
return (revealedSafe / TOTAL_SAFE) * 100
}
// A second, unrelated adapter: percent of events delivered. Ground truth is the
// count of records at offset <= t.
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
}
const CASES = [
{ name: 'mnswpr percent-cleared', adapter: { progress: createProgressReducer(board) }, truth: mnswprTruth },
{ name: 'dummy percent-of-events', adapter: { progress: dummyReduce }, truth: dummyTruth }
]
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))
it('progress() matches the source run at multiple points (via seek)', () => {
const clock = new PlaybackClock(envelope, fakeScheduler(), adapter)
for (const t of [-10, 0, 50, 100, 150, 200, 250, 300, 400]) {
clock.seek(t)
expect(clock.progress()).toBeCloseTo(truth(clamp(t)), 5)
}
})
it('progress() matches the source run while playing (fake timers)', () => {
const s = fakeScheduler()
const clock = new PlaybackClock(envelope, s, adapter)
const updates = []
clock.onProgress(u => updates.push(u))
clock.play()
let last = 0
for (const cp of [0, 100, 200, 300]) {
s.advance(cp - last)
last = cp
expect(clock.progress()).toBeCloseTo(truth(cp), 5)
}
expect(clock.progress()).toBeCloseTo(truth(clock.duration), 5)
// The pushed signal is non-decreasing during forward play and ends at 100.
const vals = updates.map(u => u.progress)
expect(vals).toEqual([...vals].sort((a, b) => a - b))
expect(vals.at(-1)).toBeCloseTo(truth(clock.duration), 5)
// Each emitted update matches ground truth at the position it reports.
for (const u of updates) expect(u.progress).toBeCloseTo(truth(u.position), 5)
})
it('seek forward and backward move the progress signal correctly', () => {
const clock = new PlaybackClock(envelope, fakeScheduler(), adapter)
const vals = []
clock.onProgress(u => vals.push(u.progress))
clock.seek(clock.duration) // forward to the end
expect(clock.progress()).toBeCloseTo(truth(clock.duration), 5)
clock.seek(0) // jump back to the start
expect(clock.progress()).toBeCloseTo(truth(0), 5)
expect(vals[0]).toBeCloseTo(truth(clock.duration), 5) // went up first
expect(vals.at(-1)).toBeCloseTo(truth(0), 5) // then down
expect(vals.at(-1)).toBeLessThan(vals[0])
})
})
describe('progress mode — signal behavior', () => {
it('does not emit for events that leave progress unchanged (flags)', () => {
// mnswpr: the flag at offset 200 must NOT produce a progress update.
const s = fakeScheduler()
const clock = new PlaybackClock(envelope, s, { progress: createProgressReducer(board) })
const updates = []
clock.onProgress(u => updates.push(u))
clock.play()
s.advance(400)
// reveals at 0, 100, 300 changed progress; the flag at 200 did not.
expect(updates.map(u => u.position)).toEqual([0, 100, 300])
expect(updates.map(u => Math.round(u.progress))).toEqual([13, 25, 100])
})
it('emits nothing when no progress adapter is supplied', () => {
const clock = new PlaybackClock(envelope, fakeScheduler())
const updates = []
clock.onProgress(u => updates.push(u))
clock.seek(clock.duration)
expect(updates).toEqual([])
expect(clock.progress()).toBe(null)
})
it('unsubscribe stops progress delivery', () => {
const clock = new PlaybackClock(envelope, fakeScheduler(), { progress: dummyReduce })
const updates = []
const off = clock.onProgress(u => updates.push(u))
clock.seek(100)
off()
clock.seek(300)
expect(updates).toHaveLength(1) // only the first jump delivered
})
})

View file

@ -0,0 +1,116 @@
// @ts-check
import { describe, it, expect } from 'vitest'
import { PlaybackClock, readEnvelope } from '@cozy-games/replay'
import { createMoveLog } from '@cozy-games/move-log'
function fakeScheduler(start = 0) {
let now = start
let nextId = 1
const timers = new Map()
return {
clock: () => now,
setTimeout: (fn, ms) => {
const id = nextId++
timers.set(id, { at: now + Math.max(0, ms), fn })
return id
},
clearTimeout: (id) => { timers.delete(id) },
advance(ms) {
const target = now + ms
for (;;) {
let due = null
for (const [id, timer] of timers) {
if (timer.at <= target && (due === null || timer.at < due.at)) due = { id, ...timer }
}
if (!due) break
timers.delete(due.id)
now = due.at
due.fn()
}
now = target
}
}
}
// 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 } }
])
describe('schema_version dispatch — v1 (built-in)', () => {
it('replays a v1 envelope through the dispatch path (not a bypass)', () => {
const s = fakeScheduler()
const clock = new PlaybackClock(v1(), s)
const seen = []
clock.on(r => seen.push(r.event.type))
clock.play()
s.advance(250)
expect(seen).toEqual(['reveal', 'flag', 'chord'])
})
it('readEnvelope returns the canonical records for v1', () => {
const records = readEnvelope(v1())
expect(records.map(r => r.seq)).toEqual([1, 2, 3])
expect(records[0].event.type).toBe('reveal')
})
})
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)
})
it('throws for a missing schema_version', () => {
expect(() => readEnvelope({ events: [] })).toThrow(/unsupported envelope schema_version undefined/)
})
it('rejects a non-object envelope', () => {
// @ts-expect-error — deliberately wrong type
expect(() => readEnvelope(null)).toThrow(TypeError)
})
})
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.
const v2Envelope = {
schema_version: 2,
log: [
{ n: 1, ts: 0, payload: { type: 'reveal', r: 0, c: 0 } },
{ n: 2, ts: 120, payload: { type: 'flag', r: 1, c: 1 } }
]
}
const readV2 = env => env.log.map(e => ({ seq: e.n, t: e.ts, event: e.payload }))
it('replays a v2 fixture via a supplied reader (same code path)', () => {
const s = fakeScheduler()
const clock = new PlaybackClock(v2Envelope, s, {}, { readers: { 2: readV2 } })
const seen = []
clock.on(r => seen.push(r.event.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 })
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 } }
])
})
it('still rejects v2 when no reader is registered', () => {
expect(() => readEnvelope(v2Envelope)).toThrow(/unsupported envelope schema_version 2 \(supported: 1\)/)
})
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)
})
})

View file

@ -37,7 +37,7 @@ importers:
version: 29.1.1 version: 29.1.1
secretlint: secretlint:
specifier: ^13.0.2 specifier: ^13.0.2
version: 13.0.2 version: 13.0.2(supports-color@10.2.2)
simple-git: simple-git:
specifier: ^3.33.0 specifier: ^3.33.0
version: 3.33.0(supports-color@10.2.2) version: 3.33.0(supports-color@10.2.2)
@ -50,12 +50,12 @@ importers:
apps/mnswpr: apps/mnswpr:
devDependencies: devDependencies:
'@ayo-run/mnswpr':
specifier: workspace:*
version: link:../../packages/mnswpr
'@cozy-games/leaderboard': '@cozy-games/leaderboard':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/leaderboard version: link:../../packages/leaderboard
'@cozy-games/mnswpr':
specifier: workspace:*
version: link:../../packages/mnswpr
'@cozy-games/utils': '@cozy-games/utils':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/utils version: link:../../packages/utils
@ -69,8 +69,8 @@ importers:
specifier: ^26.1.0 specifier: ^26.1.0
version: 26.1.0(@types/node@25.5.2)(picomatch@4.0.4)(supports-color@10.2.2) version: 26.1.0(@types/node@25.5.2)(picomatch@4.0.4)(supports-color@10.2.2)
web-component-base: web-component-base:
specifier: ^4.1.2 specifier: ^5.0.0
version: 4.1.2 version: 5.0.0
packages/leaderboard: packages/leaderboard:
dependencies: dependencies:
@ -78,8 +78,8 @@ importers:
specifier: ^12.11.0 specifier: ^12.11.0
version: 12.11.0 version: 12.11.0
web-component-base: web-component-base:
specifier: ^4.1.2 specifier: ^5.0.0
version: 4.1.2 version: 5.0.0
devDependencies: devDependencies:
'@cozy-games/utils': '@cozy-games/utils':
specifier: workspace:* specifier: workspace:*
@ -91,6 +91,14 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../utils version: link:../utils
packages/move-log: {}
packages/replay:
dependencies:
'@cozy-games/move-log':
specifier: workspace:*
version: link:../move-log
packages/utils: {} packages/utils: {}
packages: packages:
@ -6404,8 +6412,8 @@ packages:
wcwidth@1.0.1: wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
web-component-base@4.1.2: web-component-base@5.0.0:
resolution: {integrity: sha512-Jti4FHgCcwtsFWJ+PPwFNhTm5AJVIHrTXDew0rk2Y3b8EChRIE5xr6fmUni43tOhc6w8HatvR3k+qnxjXr/7Mw==} resolution: {integrity: sha512-93W5bhqy0YkyOFI+4V6U7BNVbLR242CyGDKLuNIYekq2J4P4kg3qcWPbtQDNpa7LvZz/M8p86PzI+Mmq5/fvkw==}
web-streams-polyfill@3.3.3: web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
@ -8498,18 +8506,18 @@ snapshots:
dependencies: dependencies:
'@secretlint/types': 13.0.2 '@secretlint/types': 13.0.2
'@secretlint/config-loader@13.0.2': '@secretlint/config-loader@13.0.2(supports-color@10.2.2)':
dependencies: dependencies:
'@secretlint/profiler': 13.0.2 '@secretlint/profiler': 13.0.2
'@secretlint/resolver': 13.0.2 '@secretlint/resolver': 13.0.2
'@secretlint/types': 13.0.2 '@secretlint/types': 13.0.2
ajv: 8.20.0 ajv: 8.20.0
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
rc-config-loader: 4.1.4 rc-config-loader: 4.1.4(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@secretlint/core@13.0.2': '@secretlint/core@13.0.2(supports-color@10.2.2)':
dependencies: dependencies:
'@secretlint/profiler': 13.0.2 '@secretlint/profiler': 13.0.2
'@secretlint/types': 13.0.2 '@secretlint/types': 13.0.2
@ -8534,10 +8542,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@secretlint/node@13.0.2': '@secretlint/node@13.0.2(supports-color@10.2.2)':
dependencies: dependencies:
'@secretlint/config-loader': 13.0.2 '@secretlint/config-loader': 13.0.2(supports-color@10.2.2)
'@secretlint/core': 13.0.2 '@secretlint/core': 13.0.2(supports-color@10.2.2)
'@secretlint/formatter': 13.0.2 '@secretlint/formatter': 13.0.2
'@secretlint/profiler': 13.0.2 '@secretlint/profiler': 13.0.2
'@secretlint/source-creator': 13.0.2 '@secretlint/source-creator': 13.0.2
@ -12697,7 +12705,7 @@ snapshots:
iconv-lite: 0.7.2 iconv-lite: 0.7.2
unpipe: 1.0.0 unpipe: 1.0.0
rc-config-loader@4.1.4: rc-config-loader@4.1.4(supports-color@10.2.2):
dependencies: dependencies:
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
js-yaml: 4.3.0 js-yaml: 4.3.0
@ -12968,11 +12976,11 @@ snapshots:
dependencies: dependencies:
xmlchars: 2.2.0 xmlchars: 2.2.0
secretlint@13.0.2: secretlint@13.0.2(supports-color@10.2.2):
dependencies: dependencies:
'@secretlint/config-creator': 13.0.2 '@secretlint/config-creator': 13.0.2
'@secretlint/formatter': 13.0.2 '@secretlint/formatter': 13.0.2
'@secretlint/node': 13.0.2 '@secretlint/node': 13.0.2(supports-color@10.2.2)
'@secretlint/profiler': 13.0.2 '@secretlint/profiler': 13.0.2
'@secretlint/resolver': 13.0.2 '@secretlint/resolver': 13.0.2
'@secretlint/walker': 13.0.2 '@secretlint/walker': 13.0.2
@ -13861,7 +13869,7 @@ snapshots:
dependencies: dependencies:
defaults: 1.0.4 defaults: 1.0.4
web-component-base@4.1.2: {} web-component-base@5.0.0: {}
web-streams-polyfill@3.3.3: {} web-streams-polyfill@3.3.3: {}

View file

@ -12,3 +12,5 @@ allowBuilds:
sharp: false sharp: false
unix-dgram: false unix-dgram: false
web-component-base: false web-component-base: false
minimumReleaseAgeExclude:
- web-component-base@5.0.0

View file

@ -1,4 +1,4 @@
// Publishes @ayo-run/mnswpr to npm using the mnswpr app README.md as the // Publishes @cozy-games/mnswpr to npm using the mnswpr app README.md as the
// package's README (what npmjs.com displays). // package's README (what npmjs.com displays).
// //
// packages/mnswpr/README.md is a generated copy (gitignored) — the source of // packages/mnswpr/README.md is a generated copy (gitignored) — the source of