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
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.
@ -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.
- **`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>`).
- **`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`.
- **`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/`** — `@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/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.

View file

@ -2,6 +2,9 @@
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
- **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
| Package | State | Published | Documented |
| -------------------- | -------------- | --------- | ---------- |
| `mnswpr` (game core) | 🚧 Development | | |
| leaderboard | ✅ Built | | |
| move-log envelope | 🔮 Planned | | |
| replay engine | 🔮 Planned | | |
| rating math | 🔮 Planned | | |
| `sudoku` (game core) | 🔮 Planned | | |
| Package | Develop | Publish | Document |
| -------------------- | -------------- | ------- | -------- |
| `mnswpr` (game core) | ✅ Built | | |
| leaderboard | ✅ Built | | |
| move-log | ✅ Built | | |
| replay engine | ✅ Built | | |
| rating math | 🚧 Development | | |
| `sudoku` (game core) | 🔮 Planned | | |
> 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:
- 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).
Using it as a library takes only a few lines — mount it onto any element by `id`:
```js
import '@ayo-run/mnswpr/mnswpr.css'
import mnswpr from '@ayo-run/mnswpr'
import '@cozy-games/mnswpr/mnswpr.css'
import mnswpr from '@cozy-games/mnswpr'
const game = new mnswpr('app')
game.initialize()

View file

@ -1,7 +1,7 @@
import mnswpr from '@ayo-run/mnswpr/mnswpr.js'
import '@ayo-run/mnswpr/mnswpr.css'
import mnswpr from '@cozy-games/mnswpr/mnswpr.js'
import '@cozy-games/mnswpr/mnswpr.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 { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.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"
},
"devDependencies": {
"@ayo-run/mnswpr": "workspace:*",
"@cozy-games/mnswpr": "workspace:*",
"@cozy-games/leaderboard": "workspace:*",
"@cozy-games/utils": "workspace:*",
"firebase": "^12.11.0",
"firebase-tools": "^15.22.4",
"netlify-cli": "^26.1.0",
"web-component-base": "^4.1.2"
"web-component-base": "^5.0.0"
},
"author": "Ayo Ayco"
}

View file

@ -17,7 +17,7 @@ import {
getFirestore, getDocs, collection, query, orderBy, limit
} 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
// 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
## 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
differ in how they store data, authenticate users, and enforce rules.

View file

@ -14,9 +14,10 @@
"test": "vitest run",
"dev": "pnpm -F mnswpr dev",
"test:watch": "vitest",
"build": "pnpm -r --filter \"./packages/*\" run build",
"build:lib": "vite build packages/mnswpr",
"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",
"prepare": "husky",
"lint": "eslint .",

View file

@ -103,7 +103,8 @@ mounted elements.
| `format` | `time` \| `number` \| `plain` | score display preset (below) |
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
@ -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
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/
array/string overrides are read from properties.
rebuild — changing the `score-order`/`format` attributes does this
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
**[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
### 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)
```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' })
// (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
@ -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
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,
no deploy:
no deploy (wire the emulator into your own store if you inject one):
```js
new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator: { host: '127.0.0.1', port: 8080 } })

View file

@ -12,13 +12,28 @@ import {
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.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 {{ 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 = {}) {
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)
this.store = getFirestore(app)
if (options.emulator) {

View file

@ -1,302 +1,65 @@
import { buckets } from '@cozy-games/utils/date-bucket/date-bucket.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!'
]
import { LeaderBoardReader } from './leaderboard-read.js'
import { LeaderBoardWriter } from './leaderboard-write.js'
/**
* Generic, game- AND backend-agnostic leaderboard. Nothing here knows about
* minesweeper or Firebase: the ranked value is a plain `score`, sorted in the
* configured direction and displayed through an injected formatter, while all
* storage I/O is delegated to an injected adapter (see adapters/). Games wire
* their specifics through the constructor options.
* storage I/O is delegated to an injected adapter (see adapters/).
*
* 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:
* - getConfig(): Promise<Object|undefined>
* - listScores({ category, field, value, order, limit }): Promise<Object[]>
* - addScore(category, entry): Promise<void>
* - archive(entry): Promise<void> // optional personal history
* - listScores({ category, since, order, limit }): Promise<Object[]> // read
* - addScore(category, entry): Promise<void> // write
* - archive(entry): Promise<void> // optional personal history // write
*/
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 {'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 {(entry: Object) => boolean} [options.qualifies] - whether an entry is ranked; defaults to server passingStatus vs entry.status
* @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
* @param {'asc'|'desc'} [options.scoreOrder]
* @param {(value: number) => string} [options.formatScore]
* @param {(entry: Object) => boolean} [options.qualifies]
* @param {Object} [options.labels]
* @param {Object} [options.tooltips]
* @param {string[]} [options.emptyMessages]
* @param {string} [options.loadingText]
* @param {string} [options.errorText]
* @param {string} [options.anonymousName]
*/
constructor(options = {}) {
this.adapter = options.adapter
this.scoreOrder = options.scoreOrder === 'desc' ? 'desc' : 'asc'
this.formatScore = options.formatScore || (value => String(value))
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(() => {})
this.reader = new LeaderBoardReader(options)
this.writer = new LeaderBoardWriter(options)
}
/**
* Default ranking gate: if the server config names a `passingStatus`, only
* entries whose `status` matches qualify; otherwise every entry qualifies.
* Read surface render the ranked list with a duration tab bar.
* @see LeaderBoardReader#render
*/
_defaultQualifies(entry) {
const passing = this.configuration && this.configuration.passingStatus
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)]
render(category, title, duration) {
return this.reader.render(category, title, 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.
* Write surface submit a completed game (archive + ranked entry).
* @see LeaderBoardWriter#submit
*/
_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.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)
submit(entry) {
return this.writer.submit(entry)
}
}

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 { DURATIONS } from './leaderboard-read.js'
/**
* `<cozy-leaderboard>` a custom element that lets a developer compose the
* leaderboard UI declaratively in HTML instead of wiring it in JavaScript.
*
* Built on `web-component-base` (WebComponent base class + lifecycle hooks). It
* extends WCB for the custom-element scaffolding (onInit/onDestroy, connect
* handling) and delegates all inner DOM the duration tabs and the ranked
* list to the existing LeaderBoardService, which already builds and manages
* that DOM (including in-place tab swaps).
* Built on `web-component-base` (WCB) the idiomatic way: the observed
* attributes are declared as `static props` (typed defaults; the base derives
* observedAttributes and feeds values into the reactive `this.props`), the view
* is a pure `html` template over a precomputed view-state object, and change
* 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
* 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 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 {
static get observedAttributes() {
return ['category', 'title', 'duration', 'score-order', 'format']
// Declared attributes (kebab-cased on the element: score-order). String
// 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() {
this._connected = true
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()
}
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,
// labels, emptyMessages, loadingText, errorText, anonymousName. Each falls back
// to the shared configureLeaderboard() value, then the package default. Set
// them before the element connects (or clear `_svc` to force a rebuild).
// labels, tooltips, emptyMessages, loadingText, errorText, anonymousName.
// Each falls back to the shared configureLeaderboard() value, then the
// 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() {
if (this._svc) return this._svc
const adapter = this.adapter || sharedConfig.adapter
if (!adapter) return null
const formatScore = this.formatScore
|| resolveFormat(this.getAttribute('format'))
|| resolveFormat(this.props.format)
|| sharedConfig.formatScore
|| resolveFormat(sharedConfig.format)
|| String
this._svc = new LeaderBoardService({
adapter,
scoreOrder: this.getAttribute('score-order') || sharedConfig.scoreOrder || 'asc',
scoreOrder: this.props.scoreOrder || sharedConfig.scoreOrder || 'asc',
formatScore,
qualifies: this.qualifies || sharedConfig.qualifies,
// 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
* `duration` attribute; later mounts preserve the service's remembered
* duration (so switching category keeps the selected tab) unless a duration
* is passed explicitly.
* (Re)mount the board. The first mount honors the author's `duration`
* attribute; later mounts keep the selected duration (so switching category
* keeps the selected tab) unless a duration is passed explicitly.
*/
_mount(durationArg) {
if (!this.isConnected) return
if (!this._connected) return
const service = this._service()
if (!service) {
this.replaceChildren(this._message('Leaderboard not configured.'))
this._view = { board: false }
this._paint()
return
}
const duration = durationArg
?? this._activeDuration
?? (this.props.duration || 'today')
this._activeDuration = duration
this._load(service, duration)
}
let duration = durationArg
if (duration === undefined && !this._mounted) {
duration = this.getAttribute('duration') || undefined
_selectTab(id) {
this._activeDuration = id
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
service.render(this.getAttribute('category') || '', this.getAttribute('title') || '', duration)
.then(el => {
if (this._token !== token) return
this.replaceChildren(el)
this._mounted = true
})
.catch(() => {
if (this._token !== token) return
this.replaceChildren(this._message('Leaderboard unavailable right now.'))
})
/**
* Render the current view state through WCB. WCB's render() replaces the
* whole subtree (no diffing yet), which would drop focus from a clicked
* duration tab the one behavior the base class can't preserve for us so
* focus is handed to the replacement tab explicitly.
*/
_paint() {
// getRootNode(), not document: inside a shadow root, document.activeElement
// is retargeted to the host and the focused tab would go undetected.
const focused = this.getRootNode().activeElement
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()
if (service) return service.submit(entry)
}
_message(text) {
const em = document.createElement('em')
em.innerText = text
return em
}
}
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"
},
"main": "leader-board.js",
"scripts": {
"build": "vite build"
},
"exports": {
".": {
"default": "./dist/leader-board.js"
@ -25,7 +28,7 @@
"./dist"
],
"dependencies": {
"web-component-base": "^4.1.2"
"web-component-base": "^5.0.0"
},
"devDependencies": {
"@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:
```bash
npm i @ayo-run/mnswpr
npm i @cozy-games/mnswpr
```
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
*/
import '@ayo-run/mnswpr/mnswpr.css'
import mnswpr from '@ayo-run/mnswpr'
import '@cozy-games/mnswpr/mnswpr.css'
import mnswpr from '@cozy-games/mnswpr'
const game = new mnswpr('app')
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
/**
* `@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
* (authoritative timing / replay verification). See
* docs/headless-core-and-client-design.md.
@ -18,8 +18,8 @@ export { replay } from './session/replay.js'
export { mulberry32, randInt } from './session/rng.js'
// Layer 2 — Minesweeper rules & pure board generation
export { MinesweeperRules } from './minesweeper/rules.js'
export { generateBoard } from './minesweeper/board.js'
export { MinesweeperRules, MOVE_EVENT_TYPES } from './minesweeper/rules.js'
export { generateBoard, validateLayout } from './minesweeper/board.js'
// Shared level presets (also consumed by the DOM client)
export { levels } from '../levels.js'

View file

@ -83,10 +83,70 @@ export function fillMines(rng, config, exclude, grid) {
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
* 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 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
* `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} cols - number of columns (board width)
* @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
*/
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) {
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) {
throw new RangeError(`generateBoard: mines must be an integer in [0, ${capacity}] (got ${mines})`)
}
const config = { rows, cols, mines }
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[][]} */
const cells = []

View file

@ -1,7 +1,8 @@
// @ts-check
import { Grid } from '../grid/grid.js'
import { toJSON as gridToJSON, fromJSON as gridFromJSON } from '../grid/serialize.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'
/**
@ -16,8 +17,66 @@ import { floodReveal, countFlagsAround, allMines } from './reveal.js'
* @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' })
/** 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 */
function isWin(state) {
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
// 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) {
placeMines(state.seed, state.config, excludeAround(state.config, r, c), state.grid)
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) {
cell.status = 'revealed'
@ -121,7 +185,8 @@ function project(state) {
/**
* 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 = {
/**
@ -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} */
status(state) {
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.
* 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 {
/**
* 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 {{ 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.clock = clock
this.state = rules.init(seed, config)
this.state = state ?? rules.init(seed, config)
/** @type {Array<{ move: object, t: number }>} */
this._log = []
this._t0 = 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 ((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() }
}
/** @param {object} event */
_emitMove(event) {
for (const handler of this._moveHandlers) handler(event)
}
status() {
return this.rules.status(this.state)
}
@ -69,4 +105,70 @@ export class GameSession {
if (status !== 'won' && status !== 'lost') return null
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**
(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
**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
core for free at `@ayo-run/mnswpr/core` — no second package to publish, version,
source consumers keep installing a single `@cozy-games/mnswpr` and get the headless
core for free at `@cozy-games/mnswpr/core` — no second package to publish, version,
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)
levels.js # shared by client + core (level presets)
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 { GameSession, MinesweeperRules } from '@ayo-run/mnswpr/core'` → headless.
- `import mnswpr from '@cozy-games/mnswpr'` → DOM client, exactly as today.
- `import { GameSession, MinesweeperRules } from '@cozy-games/mnswpr/core'` → headless.
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
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
`@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.
---
@ -346,7 +346,7 @@ Minesweeper(appId, version, {
| Needs a host tier | no | yes (Function/Worker + session store) |
| 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.
---
@ -425,7 +425,7 @@ appear in `core/` outside the injected `clock`/`rng` seams.
## 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`
deferred to when Sudoku lands; the sub-path stays stable across that move.
- **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",
"description": "Classic Minesweeper browser game",
"author": "Ayo",
@ -10,6 +10,7 @@
},
"homepage": "https://mnswpr.com",
"scripts": {
"build": "vite build",
"release": "bumpp && node ../../scripts/release.js"
},
"main": "mnswpr.js",

View file

@ -6,7 +6,7 @@ import { dirname, join } from 'node:path'
import {
Grid, eightWay, orthogonal,
GameSession, replay, mulberry32,
MinesweeperRules, generateBoard, levels
MinesweeperRules, generateBoard, validateLayout, MOVE_EVENT_TYPES, levels
} from '../core/index.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)', () => {
it('first reveal is never a mine and opens a region', () => {
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)', () => {
it('reports authoritative elapsed time from the injected clock', () => {
const clock = makeClock()
@ -275,12 +743,27 @@ describe('determinism guard (invariant #4)', () => {
})
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 ----
function makeClock() {
let t = 1000
function makeClock(start = 1000) {
let t = start
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
secretlint:
specifier: ^13.0.2
version: 13.0.2
version: 13.0.2(supports-color@10.2.2)
simple-git:
specifier: ^3.33.0
version: 3.33.0(supports-color@10.2.2)
@ -50,12 +50,12 @@ importers:
apps/mnswpr:
devDependencies:
'@ayo-run/mnswpr':
specifier: workspace:*
version: link:../../packages/mnswpr
'@cozy-games/leaderboard':
specifier: workspace:*
version: link:../../packages/leaderboard
'@cozy-games/mnswpr':
specifier: workspace:*
version: link:../../packages/mnswpr
'@cozy-games/utils':
specifier: workspace:*
version: link:../../packages/utils
@ -69,8 +69,8 @@ importers:
specifier: ^26.1.0
version: 26.1.0(@types/node@25.5.2)(picomatch@4.0.4)(supports-color@10.2.2)
web-component-base:
specifier: ^4.1.2
version: 4.1.2
specifier: ^5.0.0
version: 5.0.0
packages/leaderboard:
dependencies:
@ -78,8 +78,8 @@ importers:
specifier: ^12.11.0
version: 12.11.0
web-component-base:
specifier: ^4.1.2
version: 4.1.2
specifier: ^5.0.0
version: 5.0.0
devDependencies:
'@cozy-games/utils':
specifier: workspace:*
@ -91,6 +91,14 @@ importers:
specifier: workspace:*
version: link:../utils
packages/move-log: {}
packages/replay:
dependencies:
'@cozy-games/move-log':
specifier: workspace:*
version: link:../move-log
packages/utils: {}
packages:
@ -6404,8 +6412,8 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
web-component-base@4.1.2:
resolution: {integrity: sha512-Jti4FHgCcwtsFWJ+PPwFNhTm5AJVIHrTXDew0rk2Y3b8EChRIE5xr6fmUni43tOhc6w8HatvR3k+qnxjXr/7Mw==}
web-component-base@5.0.0:
resolution: {integrity: sha512-93W5bhqy0YkyOFI+4V6U7BNVbLR242CyGDKLuNIYekq2J4P4kg3qcWPbtQDNpa7LvZz/M8p86PzI+Mmq5/fvkw==}
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
@ -8498,18 +8506,18 @@ snapshots:
dependencies:
'@secretlint/types': 13.0.2
'@secretlint/config-loader@13.0.2':
'@secretlint/config-loader@13.0.2(supports-color@10.2.2)':
dependencies:
'@secretlint/profiler': 13.0.2
'@secretlint/resolver': 13.0.2
'@secretlint/types': 13.0.2
ajv: 8.20.0
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:
- supports-color
'@secretlint/core@13.0.2':
'@secretlint/core@13.0.2(supports-color@10.2.2)':
dependencies:
'@secretlint/profiler': 13.0.2
'@secretlint/types': 13.0.2
@ -8534,10 +8542,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@secretlint/node@13.0.2':
'@secretlint/node@13.0.2(supports-color@10.2.2)':
dependencies:
'@secretlint/config-loader': 13.0.2
'@secretlint/core': 13.0.2
'@secretlint/config-loader': 13.0.2(supports-color@10.2.2)
'@secretlint/core': 13.0.2(supports-color@10.2.2)
'@secretlint/formatter': 13.0.2
'@secretlint/profiler': 13.0.2
'@secretlint/source-creator': 13.0.2
@ -12697,7 +12705,7 @@ snapshots:
iconv-lite: 0.7.2
unpipe: 1.0.0
rc-config-loader@4.1.4:
rc-config-loader@4.1.4(supports-color@10.2.2):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
js-yaml: 4.3.0
@ -12968,11 +12976,11 @@ snapshots:
dependencies:
xmlchars: 2.2.0
secretlint@13.0.2:
secretlint@13.0.2(supports-color@10.2.2):
dependencies:
'@secretlint/config-creator': 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/resolver': 13.0.2
'@secretlint/walker': 13.0.2
@ -13861,7 +13869,7 @@ snapshots:
dependencies:
defaults: 1.0.4
web-component-base@4.1.2: {}
web-component-base@5.0.0: {}
web-streams-polyfill@3.3.3: {}

View file

@ -12,3 +12,5 @@ allowBuilds:
sharp: false
unix-dgram: 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).
//
// packages/mnswpr/README.md is a generated copy (gitignored) — the source of