Compare commits
28 commits
feat/core-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3418be3d7b | |||
| 2d7f7d0ebb | |||
| 41829086c5 | |||
| 17724ebe8f | |||
| cc44789829 | |||
| 9079ab0848 | |||
| 26277986a0 | |||
| c2ffb0d7f6 | |||
| cd2b30ecc8 | |||
| 1ca1d63ea7 | |||
| 0b9866e05f | |||
| 43b75d7b0e | |||
| 998aeacbb2 | |||
| 072d2f7369 | |||
| 6b38088d4b | |||
| 6f9a8031e9 | |||
| bafe1db285 | |||
| 0dab463e84 | |||
| 4ccec5a116 | |||
| e1c168a9bb | |||
| 3bfb1931cc | |||
| dd9e020214 | |||
| 6c90cde0f9 | |||
| 0823d43c75 | |||
| d3d84aa9b8 | |||
| d482b17976 | |||
| 8099820e79 | |||
| 916d04d0d9 |
49 changed files with 5052 additions and 415 deletions
|
|
@ -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.
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 .",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
272
packages/leaderboard/leaderboard-read.js
Normal file
272
packages/leaderboard/leaderboard-read.js
Normal 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)
|
||||
}
|
||||
}
|
||||
80
packages/leaderboard/leaderboard-write.js
Normal file
80
packages/leaderboard/leaderboard-write.js
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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:*"
|
||||
|
|
|
|||
102
packages/leaderboard/test/firebase-adapter.test.js
Normal file
102
packages/leaderboard/test/firebase-adapter.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
592
packages/leaderboard/test/leaderboard-element.test.js
Normal file
592
packages/leaderboard/test/leaderboard-element.test.js
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
123
packages/leaderboard/test/read-write-separation.test.js
Normal file
123
packages/leaderboard/test/read-write-separation.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
48
packages/leaderboard/test/supabase-adapter.test.js
Normal file
48
packages/leaderboard/test/supabase-adapter.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
24
packages/mnswpr/adapters/replay-common.js
Normal file
24
packages/mnswpr/adapters/replay-common.js
Normal 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
|
||||
}
|
||||
}
|
||||
40
packages/mnswpr/adapters/replay-progress.js
Normal file
40
packages/mnswpr/adapters/replay-progress.js
Normal 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
|
||||
}
|
||||
}
|
||||
57
packages/mnswpr/adapters/replay-state.js
Normal file
57
packages/mnswpr/adapters/replay-state.js
Normal 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 }
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
}
|
||||
|
||||
|
|
|
|||
123
packages/mnswpr/test/replay-progress.test.js
Normal file
123
packages/mnswpr/test/replay-progress.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
104
packages/mnswpr/test/replay-state.test.js
Normal file
104
packages/mnswpr/test/replay-state.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
62
packages/move-log/README.md
Normal file
62
packages/move-log/README.md
Normal 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
219
packages/move-log/index.js
Normal 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)
|
||||
}
|
||||
22
packages/move-log/package.json
Normal file
22
packages/move-log/package.json
Normal 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"
|
||||
}
|
||||
323
packages/move-log/test/move-log.test.js
Normal file
323
packages/move-log/test/move-log.test.js
Normal 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
150
packages/replay/README.md
Normal 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() // 0–100 (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`.
|
||||
94
packages/replay/docs/adapter-interface.md
Normal file
94
packages/replay/docs/adapter-interface.md
Normal 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 // 0–100
|
||||
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
422
packages/replay/index.js
Normal 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 (0–100) 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
|
||||
}
|
||||
}
|
||||
}
|
||||
25
packages/replay/package.json
Normal file
25
packages/replay/package.json
Normal 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"
|
||||
}
|
||||
147
packages/replay/test/full-board-mode.test.js
Normal file
147
packages/replay/test/full-board-mode.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
174
packages/replay/test/partial-logs.test.js
Normal file
174
packages/replay/test/partial-logs.test.js
Normal 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([])
|
||||
})
|
||||
})
|
||||
323
packages/replay/test/playback-clock.test.js
Normal file
323
packages/replay/test/playback-clock.test.js
Normal 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)
|
||||
}
|
||||
}
|
||||
184
packages/replay/test/progress-mode.test.js
Normal file
184
packages/replay/test/progress-mode.test.js
Normal 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
|
||||
})
|
||||
})
|
||||
116
packages/replay/test/schema-dispatch.test.js
Normal file
116
packages/replay/test/schema-dispatch.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,3 +12,5 @@ allowBuilds:
|
|||
sharp: false
|
||||
unix-dgram: false
|
||||
web-component-base: false
|
||||
minimumReleaseAgeExclude:
|
||||
- web-component-base@5.0.0
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue