Compare commits

...

42 commits

Author SHA1 Message Date
Ayo
3418be3d7b chore: move to @cozy-games/mnswpr 2026-07-06 00:11:22 +02:00
Ayo
2d7f7d0ebb chore: build scripts 2026-07-06 00:09:02 +02:00
Ayo
41829086c5 chore: fix typo in readme :) 2026-07-05 13:18:14 +02:00
Ayo
17724ebe8f chore: update README note on project evolution 2026-07-05 13:17:19 +02:00
Ayo
cc44789829 refactor: use wcb v5 createElement-safe static props 2026-07-05 13:03:08 +02:00
Ayo
9079ab0848 refactor(leaderboard-element): wcb usage improvements 2026-07-05 06:43:12 +02:00
Ayo
26277986a0 refactor(leaderboard-element): use web-component-base APIs 2026-07-04 14:39:15 +02:00
Ayo
c2ffb0d7f6 feat(leaderboard): read-write separation 2026-07-04 13:54:03 +02:00
Ayo
cd2b30ecc8 feat(leaderboard): bring your own backend 2026-07-04 13:45:57 +02:00
Ayo
1ca1d63ea7 chore: update readme 2026-07-04 13:42:45 +02:00
Ayo
0b9866e05f feat(replay): add a defined "ended" signal (onEnd) 2026-07-04 13:39:48 +02:00
Ayo
43b75d7b0e feat(replay): schema dispatch 2026-07-04 13:34:32 +02:00
Ayo
998aeacbb2 feat(replay): state reducer & full board playback 2026-07-04 13:29:52 +02:00
Ayo
072d2f7369 feat(replay): progress mode 2026-07-04 13:21:48 +02:00
Ayo
6b38088d4b feat(mnswpr): progress reducer adapter 2026-07-04 13:17:40 +02:00
Ayo
6f9a8031e9 feat(replay): progress reducer interface 2026-07-04 13:06:58 +02:00
Ayo
bafe1db285 feat(replay): replay engine 2026-07-04 12:56:29 +02:00
Ayo
0dab463e84 chore: update readme with note & package status 2026-07-04 12:36:23 +02:00
Ayo
4ccec5a116 feat(move-log): accept timestamps 2026-07-04 12:34:16 +02:00
Ayo
e1c168a9bb feat(move-log): serialization 2026-07-04 12:29:47 +02:00
Ayo
3bfb1931cc chore: update readme :) 2026-07-04 12:23:31 +02:00
Ayo
dd9e020214 chore: update packages status on readme 2026-07-04 12:21:59 +02:00
Ayo
6c90cde0f9 feat(move-log): game-agnostic move events 2026-07-04 12:21:06 +02:00
Ayo
0823d43c75 feat(core): move events 2026-07-04 12:02:10 +02:00
Ayo
d3d84aa9b8 feat(core): resume games 2026-07-04 11:52:38 +02:00
Ayo
d482b17976 feat(core): serializeable game state 2026-07-04 11:48:08 +02:00
Ayo
8099820e79 feat(core): board injection 2026-07-04 11:39:55 +02:00
Ayo
916d04d0d9 feat(mnswpr): first-move safety 2026-07-04 11:32:01 +02:00
Ayo
d7a9f61a9e refactor: core generateBoard function 2026-07-04 11:24:25 +02:00
Ayo
5a7eea3e12 chore: update packages table with published or documented :) 2026-07-04 11:14:05 +02:00
Ayo
744825c771 chore: fix messed up markdown :) 2026-07-04 10:46:30 +02:00
Ayo
9aec20edbf chore: add roadmap to readme 2026-07-04 10:35:55 +02:00
Ayo
6afd15aa35 chore: headless design 2026-07-04 10:05:43 +02:00
Ayo
8b66249edf chore: Add CONTRIBUTING.md guide for humans 2026-07-04 09:55:54 +02:00
Ayo
5ec22fe37e chore: add contribution guidance 2026-07-04 09:19:22 +02:00
Ayo
062b912549 feat: mnswpr client 2026-07-04 08:17:54 +02:00
Ayo
042d6b26e9 feat: mnswpr core 2026-07-04 07:49:46 +02:00
Ayo
7cbf9baaa8 chore: security hardening 2026-07-03 23:04:52 +02:00
Ayo
0532fd6d66 chore: infra tooling and configs 2026-07-03 21:54:29 +02:00
Ayo
7685a6dea7 chore: monorepo scripts (dev, build, db, etc) 2026-07-03 20:25:33 +02:00
Ayo
74551f2023 chore: move mnswpr db migration doc to app dir 2026-07-03 16:14:15 +02:00
Ayo
969ebba067 chore: restructure to new cozy-games monorepo
Reviewed-on: https://git.ayo.run/ayo/cozy-games/pulls/1
Co-authored-by: Ayo <ayo@ayco.io>
Co-committed-by: Ayo <ayo@ayco.io>
2026-07-03 13:53:13 +00:00
111 changed files with 18782 additions and 1028 deletions

9
.gitignore vendored
View file

@ -1,14 +1,14 @@
node_modules/ node_modules/
dist/ dist/
# generated at publish time from the root README.md (see scripts/publish-lib.js) # generated at publish time from apps/mnswpr/README.md (see scripts/publish-lib.js)
lib/README.md packages/mnswpr/README.md
.claude .claude
# Production / local Firebase config values are not committed — set them as # Production / local Firebase config values are not committed — set them as
# Netlify env vars, or keep them in a local, gitignored env file. Dev values # Netlify env vars, or keep them in a local, gitignored env file. Dev values
# live in the committed app/.env.development (public, non-secret keys). # live in the committed apps/mnswpr/.env.development (public, non-secret keys).
.env.production .env.production
.env.local .env.local
.env.*.local .env.*.local
@ -22,3 +22,6 @@ ui-debug.log
*~ *~
*swo *swo
*swp *swp
# TEMPORARY - or unnecesary if done
apps/mnswpr/MIGRATION.md

View file

@ -1,2 +1,11 @@
echo "pre-commit..." echo "pre-commit..."
npm run lint npm run lint
# Secret scan of staged files — blocks committing tokens, private keys, or a
# stray .env.production before it ever reaches history. Config: .secretlintrc.json
# / .secretlintignore. Full-tree scan: `pnpm run scan:secrets`.
staged=$(git diff --cached --name-only --diff-filter=ACM)
if [ -n "$staged" ]; then
echo "secret-scan (staged files)..."
printf '%s\n' "$staged" | xargs -r -d '\n' npx secretlint --secretlintignore .secretlintignore
fi

5
.secretlintignore Normal file
View file

@ -0,0 +1,5 @@
# Paths secretlint should not scan (vendored, generated, or lockfiles).
node_modules
dist
pnpm-lock.yaml
*.log

7
.secretlintrc.json Normal file
View file

@ -0,0 +1,7 @@
{
"rules": [
{
"id": "@secretlint/secretlint-rule-preset-recommend"
}
]
}

View file

@ -4,37 +4,90 @@ Guidance for AI coding agents working in this repository.
## What this is ## What this is
Classic Minesweeper as a vanilla web game — no framework, no TypeScript (JSDoc + `// @ts-check` only). Deployed at [mnswpr.com](https://mnswpr.com) (Netlify) and published to npm as `@ayo-run/mnswpr`. The game engine has **zero runtime dependencies**; only the website adds Firebase. Classic Minesweeper as a vanilla web game — no framework, no TypeScript (JSDoc + `// @ts-check` only). Deployed at [mnswpr.com](https://mnswpr.com) (Netlify) and published to npm as `@cozy-games/mnswpr`. The game engine has **zero runtime dependencies**; only the website adds Firebase.
**`mnswpr` is the main test app.** It's the reference app for the monorepo and the default target for local runs — `.claude/launch.json` launches it (`dev` on :5173, `preview` on :4173), and it's what you should build/run/preview when verifying changes to the shared packages or tooling.
## Commands ## Commands
Workspace-wide commands run from the root; per-app commands target the app by name with pnpm's `-F` filter (apps are named `<name>` — there are no mnswpr-specific root scripts).
```bash ```bash
pnpm i # install (pnpm is required — this is a pnpm workspace) pnpm i # install (pnpm is required — this is a pnpm workspace)
pnpm dev # run the website dev server (vite app) — most common
pnpm build # build the website -> app/dist
pnpm build:lib # build the publishable library -> lib/dist
pnpm lint # eslint . (JS + CSS); runs automatically on pre-commit
pnpm lint:fix # eslint --fix
pnpm build:preview # build the app and serve the production preview
pnpm test # run the Vitest suite once (jsdom) pnpm test # run the Vitest suite once (jsdom)
pnpm test:watch # run Vitest in watch mode pnpm test:watch # run Vitest in watch mode
pnpm lint # eslint . (JS + CSS); runs automatically on pre-commit
pnpm lint:fix # eslint --fix
pnpm build:lib # build the publishable library -> packages/mnswpr/dist
pnpm -F mnswpr run dev # Firestore emulator + auto-seed + dev server (emulators:exec) — most common; needs JDK 21+
pnpm -F mnswpr run dev:no-db # plain vite, no emulator (UI-only work / no JDK)
pnpm -F mnswpr run build # build the website -> apps/mnswpr/dist
pnpm -F mnswpr run build:preview # build the app and serve the production preview
``` ```
Tests live in `test/` and run under **Vitest** with a jsdom environment (config in `vitest.config.js`). They cover the shared utils and drive the engine through real DOM events (mount `#app`, dispatch mouse events, assert on cell/grid attributes). For anything visual or input-timing related, also verify by running `pnpm dev` and playing. ### Infra (local CLI only — no web dashboards)
**Every infra operation — provision, deploy hosting, deploy DB, manage env — is doable from the CLI, and every configuration/schema is codified in-repo.** Nothing lives only in a web dashboard. There are two distinct layers, both owned by the app:
**1. App infra *config* — declarative, committed, deployed state.** These files ARE the source of truth; deploying just pushes them up. For `mnswpr`, all under `apps/mnswpr/`:
| File | Codifies |
| --- | --- |
| `firebase.json` | Firestore + emulator wiring (rules/indexes paths, emulator ports) |
| `.firebaserc` | Firebase project aliases (`default`/`prod`/`dev`) |
| `firestore.rules` | Firestore security rules (server-side access control) |
| `firestore.indexes.json` | Firestore indexes (none needed — documented inline) |
| `netlify.toml` | Netlify hosting: build command, publish dir, redirects, headers, build env |
| `.env.example` | The full env-var contract; real prod values are set as Netlify env vars via CLI, never committed |
**2. App infra *tools* — the CLIs that act on that config.** They are versioned **devDependencies** of the app (not `npx`-on-demand, not global installs), so `pnpm install` pins them and every machine gets the same version. `mnswpr` depends on `firebase-tools` and `netlify-cli`; its scripts call the `firebase`/`netlify` binaries directly (pnpm puts the app's `node_modules/.bin` on `PATH`). A future app using a different stack (Postgres, a different host) declares *its* CLIs as *its* devDependencies and backs the same generic script names — so `pnpm -F <name> run deploy:db` stays uniform.
Each app **owns its infra scripts** in its own `package.json` under generic, tech-agnostic names (`deploy:db`, not `deploy:firestore`) — run them by targeting the app with pnpm's `-F` filter (no root wrapper scripts):
```bash
pnpm -F mnswpr run db:start # local DB emulator (mnswpr -> Firestore), standalone
pnpm -F mnswpr run db:seed # seed the running local emulator
pnpm -F mnswpr run db:stop # kill a stray/orphaned Firestore emulator holding :8080
pnpm -F mnswpr run deploy:db # deploy DB rules/indexes (-> firebase deploy --only firestore)
pnpm -F mnswpr run deploy:site # build + deploy hosting (-> netlify deploy --prod --dir=dist)
```
**One-time per app / per machine (all CLI, no dashboard):**
```bash
pnpm -F mnswpr exec firebase login # auth the Firebase CLI
pnpm -F mnswpr exec netlify login # auth the Netlify CLI
pnpm -F mnswpr exec netlify link # bind the app dir to its Netlify site (writes .netlify/, gitignored)
```
**Managing hosting env vars via CLI** (keeps prod Firebase keys + `VITE_LB_NAMESPACE=mw` out of git while still reproducible):
```bash
pnpm -F mnswpr exec netlify env:set VITE_LB_NAMESPACE mw # set one var
pnpm -F mnswpr exec netlify env:import .env.production # bulk-import from a local (gitignored) env file
pnpm -F mnswpr exec netlify env:list # inspect what's set
```
**Non-npm tools get a setup script instead of a devDependency.** The Firestore emulator needs **Java** (it's a JVM program), which isn't an npm package — so `pnpm install` runs a root `postinstall` (`scripts/ensure-java.mjs`) that installs a user-local Temurin JRE 21 into `~/.local` without `sudo` when `java` is missing — idempotent, non-fatal, and auto-skipped on `CI` / `SKIP_JRE_SETUP` / unsupported platforms. Any future infra tool that isn't on npm follows the same pattern (a checked-in setup script), never a manual install step.
Tests are co-located with the package they exercise (`packages/utils/test/`, `packages/mnswpr/test/`) and run under **Vitest** with a jsdom environment (root config in `vitest.config.js`). They cover the shared utils and drive the engine through real DOM events (mount `#app`, dispatch mouse events, assert on cell/grid attributes). For anything visual or input-timing related, also verify by running `pnpm -F mnswpr run dev` and playing.
Node version: `.nvmrc` pins `lts/*`. Node version: `.nvmrc` pins `lts/*`.
## Repository layout (pnpm workspace) ## Repository layout (Cozy Games monorepo, pnpm workspace)
Workspaces are declared in `pnpm-workspace.yaml` as `lib` and `app`. Note that `utils/` is **not** a workspace package — it's a plain shared folder imported by relative path from both `lib` and `app`. 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.
- **`lib/`** — `@ayo-run/mnswpr`, the standalone, framework-free game engine. This is what gets published to npm. `lib/mnswpr.js` is the whole engine; `lib/levels.js` defines the four difficulty presets. Depends only on `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>`).
- **`app/`** — the mnswpr.com website. Consumes the library via `workspace:*` (`import mnswpr from '@ayo-run/mnswpr/mnswpr.js'`) and adds the Firebase leaderboard. `app/main.js` wires the engine to the leaderboard through the engine's hooks. - **`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`.
- **`utils/`** — shared services with no dependencies, re-exported from `utils/index.js`: `StorageService` (localStorage/sessionStorage JSON wrapper), `TimerService` (game clock + `pretty()` time formatting used by both engine and leaderboard), `LoggerService`, `LoadingService`. - **`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.
## Architecture ## Architecture
**The engine is decoupled from the app via two hooks.** `Minesweeper(appId, version, hooks)` (`lib/mnswpr.js`) is a classic constructor function that imperatively builds a `<table>` grid in the DOM. It knows nothing about Firebase or leaderboards. The app injects behavior through: **The engine is decoupled from the app via two hooks.** `Minesweeper(appId, version, hooks)` (`packages/mnswpr/mnswpr.js`) is a classic constructor function that imperatively builds a `<table>` grid in the DOM. It knows nothing about Firebase or leaderboards. The app injects behavior through:
- `hooks.levelChanged(setting)` — fired when the difficulty level changes; the app uses this to re-fetch and render the leaderboard for that level. - `hooks.levelChanged(setting)` — fired when the difficulty level changes; the app uses this to re-fetch and render the leaderboard for that level.
- `hooks.gameDone(game)` — fired when a game ends (win or loss) with a `game` object (`time`, `status`, `level`, `time_stamp`, `isMobile`); the app uses this to submit the score. - `hooks.gameDone(game)` — fired when a game ends (win or loss) with a `game` object (`time`, `status`, `level`, `time_stamp`, `isMobile`); the app uses this to submit the score.
@ -47,9 +100,9 @@ When adding engine features that the website needs to react to, prefer adding a
**Input handling is intricate.** Mouse (left/right/middle, plus simultaneous left+right "chording") and touch (long-press to flag) are handled through a state machine of flags (`isLeft`, `isRight`, `pressed`, `bothPressed`, `skip`, `isBusy`) in `initializeEventHandlers`/`initializeTouchEventHandlers`. `isBusy` debounces input (`MOBILE_BUSY_DELAY`/`PC_BUSY_DELAY`). Tread carefully here — small changes easily break chording or mobile flagging. **Input handling is intricate.** Mouse (left/right/middle, plus simultaneous left+right "chording") and touch (long-press to flag) are handled through a state machine of flags (`isLeft`, `isRight`, `pressed`, `bothPressed`, `skip`, `isBusy`) in `initializeEventHandlers`/`initializeTouchEventHandlers`. `isBusy` debounces input (`MOBILE_BUSY_DELAY`/`PC_BUSY_DELAY`). Tread carefully here — small changes easily break chording or mobile flagging.
**Test mode:** set `TEST_MODE = true` at the top of `lib/mnswpr.js` to render mine positions as visual hints and enable debug logging. **Test mode:** set `TEST_MODE = true` at the top of `packages/mnswpr/mnswpr.js` to render mine positions as visual hints and enable debug logging.
## Leaderboard / Firebase (`app/modules/`) ## Leaderboard / Firebase (`apps/mnswpr/modules/`)
`LeaderBoardService` (`leader-board.js`) reads/writes Firestore (`firebase/firestore/lite`). Structure: top-10 per level in `mw-leaders/{level}/games`, all sessions in `mw-all/{browserId}/games`, and remote runtime `configuration` in `mw-config`. A score is only offered to the leaderboard when it beats the current 10th place *and* matches the server-side `passingStatus`. `LeaderBoardService` (`leader-board.js`) reads/writes Firestore (`firebase/firestore/lite`). Structure: top-10 per level in `mw-leaders/{level}/games`, all sessions in `mw-all/{browserId}/games`, and remote runtime `configuration` in `mw-config`. A score is only offered to the leaderboard when it beats the current 10th place *and* matches the server-side `passingStatus`.
@ -60,9 +113,9 @@ The **Firebase config in `leader-board.js` is intentionally public and committed
## Conventions ## Conventions
- **Code style is enforced by ESLint Stylistic**, not Prettier: 2-space indent, single quotes, **no semicolons**, no trailing commas, spaces inside `{ braces }` but not `[brackets]`. Run `pnpm lint:fix` before committing. Both `**/*.js` and `**/*.css` are linted (CSS via `@eslint/css`). - **Code style is enforced by ESLint Stylistic**, not Prettier: 2-space indent, single quotes, **no semicolons**, no trailing commas, spaces inside `{ braces }` but not `[brackets]`. Run `pnpm lint:fix` before committing. Both `**/*.js` and `**/*.css` are linted (CSS via `@eslint/css`).
- The engine uses **plain functions and `var`/`let` closures**, not classes; `utils/` and `app/modules/` use ES classes. Match the surrounding style of the file you edit. - The engine uses **plain functions and `var`/`let` closures**, not classes; `packages/utils/` and `apps/mnswpr/modules/` use ES classes. Match the surrounding style of the file you edit.
## Release & git hooks (maintainer workflow) ## Release & git hooks (maintainer workflow)
- **Husky hooks:** `pre-commit` runs `pnpm lint`; `post-commit` auto-pushes to two extra remotes (`git push gh`, `git push sh`). If those remotes aren't configured locally, expect post-commit failures — that's environmental, not a code problem. - **Husky hooks:** `pre-commit` runs `pnpm lint`; `post-commit` auto-pushes to two extra remotes (`git push gh`, `git push sh`). If those remotes aren't configured locally, expect post-commit failures — that's environmental, not a code problem.
- **Releasing** (`pnpm release`) builds the lib, runs `bumpp` (version bump + tag) in `lib/`, then `scripts/release.js` force-pushes a `release` branch and tags to remotes `gh`/`sh`. Pushing a `v*` tag triggers `.github/workflows/release.yml` (`changelogithub`) to publish GitHub release notes. Only run this when explicitly releasing. - **Releasing** (`pnpm release`) builds the lib, runs `bumpp` (version bump + tag) in `packages/mnswpr/`, then `scripts/release.js` force-pushes a `release` branch and tags to remotes `gh`/`sh`. Pushing a `v*` tag triggers `.github/workflows/release.yml` (`changelogithub`) to publish GitHub release notes. Only run this when explicitly releasing.

92
AYO.md
View file

@ -1,92 +0,0 @@
# 🧹 AYO — Leaderboard Migration Checklist
Manual steps to finish the leaderboard rollout. **All code changes are already
applied** in the working tree — these are the external actions on Firebase and
Netlify that have to be done by hand.
> One project (`secure-moment-188701`), one difference between environments: the
> **collection namespace**. Production uses `mw-*`, dev/test uses `mw-test-*`.
> Full rationale: [`docs/leaderboard-env-migration.md`](docs/leaderboard-env-migration.md).
---
## ✅ Step 1 — Deploy Firestore rules + indexes
From the repo root:
```bash
npx firebase login
npx firebase deploy --only firestore:rules,firestore:indexes --project prod
```
- Uses committed [`firestore.rules`](firestore.rules) + [`firestore.indexes.json`](firestore.indexes.json).
- `prod``secure-moment-188701` (via [`.firebaserc`](.firebaserc)).
- ⚠️ Deploying **replaces** the console rules. The committed rules cover every
collection (`mw-*` and `mw-test-*`), so it's safe — but review first.
- No composite indexes to build — rolling windows (`time_stamp >=`) and all-time
(`orderBy score`) use Firestore's automatic single-field indexes.
## ✅ Step 2 — Set Netlify environment variables
In the Netlify site settings, add:
| Variable | Value |
| --- | --- |
| `VITE_FIREBASE_API_KEY` … (all 8) | **same as [`app/.env.development`](app/.env.development)** (same project) |
| `VITE_LB_NAMESPACE` | **`mw`** ← makes production use the `mw-*` collections |
> Local dev already uses `mw-test` via the committed `.env.development` — nothing
> to do there.
## ✅ Step 3 — (Optional) Seed the test config doc
Create `mw-test-config/configuration` in Firestore with the same `passingStatus`
and `message` as the prod `mw-config/configuration`.
Skip it and the test board still works — the default qualifier just accepts all
wins in test.
## ✅ Step 4 — Seed the dev database with sample scores
> ⚠️ **Must run _after_ Step 1.** The seed writes to `mw-test-scores`, which is
> only allowed once the generalized rules are deployed — otherwise every write
> returns `permission-denied`. (No indexes to wait on — the windows use
> automatic single-field indexes.)
Populate the dev boards so they aren't empty while developing:
```bash
(cd app && node ../scripts/seed-dev-scores.js)
```
- Uses [`scripts/seed-dev-scores.js`](scripts/seed-dev-scores.js) — ~12 sample
scores per level, timestamps spread across today / this week / this month /
older so **all four tabs** populate.
- Dev-only and idempotent-ish (re-running just adds more rows); it never touches
the production `mw-*` collections.
> 💡 **Local dev uses the emulator by default — this cloud seed is optional.**
> `pnpm dev` points at the local **Firestore emulator** (needs a JDK): run
> `pnpm emulators` + `pnpm seed:emulator` and you're set — no deploy, no cloud.
> The cloud seed above is only needed for a hosted/preview environment. To opt
> out of the emulator, set `VITE_FIRESTORE_EMULATOR=` empty in `app/.env.local`.
> See [`docs/firebase-leaderboards.md`](docs/firebase-leaderboards.md#local-firestore-emulator-default-for-local-dev).
## ✅ Step 5 — Verify
| Environment | How | Expected |
| --- | --- | --- |
| **Production** (`mw`) | Win a game on the live site | Score shows; doc in `mw-scores/{level}/games` |
| **Local dev** (`mw-test`) | `pnpm dev` (after Step 4) | Board shows sample scores; winning adds to `mw-test-scores/{level}/games`; prod `mw-scores` untouched |
| **Rules** | Read both boards | Reads succeed; a malformed write is rejected |
---
## 📌 Still open (not blocking)
- **Nothing is committed yet** — all changes are in the working tree.
- **`scripts/export-legends.js`** still hard-codes the (dev = prod) Firebase keys
from the one-off Legends export. It's identical to `app/.env.development`; can
be de-duped to read from the env file on request.
- **Legends** is already frozen into static HTML ([`app/legends.html`](app/legends.html))
— no action needed.

122
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,122 @@
# Contributing to Cozy Games
Thanks for your interest in contributing! This guide covers what you need to
develop, test, and submit changes.
> Working as an AI coding agent? See [AGENTS.md](AGENTS.md) for machine-oriented
> guidance. This document is for human contributors.
## Prerequisites
- **Node.js** — the version pinned in [`.nvmrc`](.nvmrc) (`lts/*`). With
[nvm](https://github.com/nvm-sh/nvm): `nvm use`.
- **pnpm** — this is a [pnpm](https://pnpm.io) workspace; **pnpm is required**
(npm/yarn will not work). The repo pins its pnpm version via the
`packageManager` field, so the simplest way to get the right one is Corepack:
`corepack enable`.
- **Java (JDK 11+, 21 recommended)** — only needed to run the local Firestore
emulator (used by the mnswpr app's leaderboard in development). `pnpm install`
tries to install a user-local Temurin JRE automatically; if that is skipped (CI
or an unsupported platform) install a JDK yourself. You can avoid Java entirely
with the `dev:no-db` script below.
## Project Structure
- `apps/` - Playable games (each deploys independently)
- `packages/` - Shared, publishable libraries
- `sites/` - Docs (Astro Starlight) and UI demos — placeholders for now
Each app owns its own backend config (e.g. mnswpr's Firestore rules live in
`apps/mnswpr/`); the shared packages stay backend-agnostic.
## Setup
```bash
pnpm i # install all workspace dependencies
```
## Workspace commands (run from the repo root)
```bash
pnpm test # run the whole test suite once (Vitest, jsdom)
pnpm test:watch # tests in watch mode
pnpm lint # eslint (JS + CSS)
pnpm lint:fix # eslint --fix
pnpm build:lib # build the publishable engine -> packages/mnswpr/dist
```
## Running a game locally
Apps aren't run from the root — target one by name with pnpm's `-F` filter. Apps
are named `<name>` (e.g. `mnswpr`), so every app runs the same way
(`pnpm -F <name> run <script>`):
```bash
pnpm -F mnswpr run dev # Vite dev server + Firestore emulator (auto-seeded) — needs Java
pnpm -F mnswpr run dev:no-db # Vite only, no emulator (UI work, or no Java)
pnpm -F mnswpr run build # build the app -> apps/mnswpr/dist
pnpm -F mnswpr run preview # preview the production build
```
## Tests
Tests run under **Vitest** with a **jsdom** environment and live next to the code
they exercise (`packages/*/test/`). They drive real behavior — e.g. mounting the
game and dispatching DOM events — not just isolated unit calls. Run `pnpm test`
(or `pnpm test:watch`) before opening a PR, and add tests for new behavior.
## Code style
Style is enforced by **ESLint (Stylistic)**, not Prettier:
- 2-space indent, single quotes, **no semicolons**, no trailing commas
- spaces inside `{ braces }` but not `[brackets]`
- both `**/*.js` and `**/*.css` are linted
Run `pnpm lint:fix` before committing. The codebase is **plain JavaScript with
JSDoc + `// @ts-check`** — no TypeScript. Match the style and patterns of the file
you're editing (the game engine uses plain functions and closures; `packages/utils`
and the app modules use ES classes).
A **pre-commit hook** runs the linter and a secret scan automatically — commits
fail if either does. Keep credentials and any `.env.production` out of commits.
## Infra & local backend (optional)
Only relevant if you're working on backend-touching features. Every app **owns its
own backend config** (declarative, committed in-repo) and its tooling (CLIs as
devDependencies) — no web dashboards. For mnswpr (Firestore + Netlify), the local
DB emulator is all a contributor needs:
```bash
pnpm -F mnswpr run db:start # start the local Firestore emulator (standalone) — needs Java
pnpm -F mnswpr run db:seed # seed the running emulator with sample data
pnpm -F mnswpr run db:stop # stop a stray emulator holding :8080
```
Deploy scripts (`deploy:db`, `deploy:site`) also exist but require project
credentials, so they're for maintainers. See
[apps/mnswpr/README.md](apps/mnswpr/README.md) and that app's `docs/` for the full
backend reference.
## Project structure & decisions
The repo layout is in the [README](README.md#layout). Significant architecture
choices are recorded in [`docs/decisions/`](docs/decisions/) — worth a read before
a large change. Shared packages stay backend-agnostic; each app owns its own
backend config.
## Submitting changes
1. Create a branch off `main`.
2. Make a focused change.
3. Run `pnpm lint` and `pnpm test` — both must pass.
4. Write clear, conventional commit messages (`feat:`, `fix:`, `chore:`, …).
5. Open a pull request describing **what** changed and **why**; link any related issue.
For anything large, open an issue to discuss the approach first.
## License
By contributing, you agree that your contributions are licensed under the
project's [BSD-2-Clause license](LICENSE).

138
README.md
View file

@ -1,123 +1,45 @@
> [!IMPORTANT] # Cozy Games
> **mnswpr has a new home.** Development now happens in the **Cozy Games** monorepo:
> **[git.ayo.run/ayo/cozy-games](https://git.ayo.run/ayo/cozy-games)**.
# Play Minesweeper Online for Free A growing collection of small browser games and the shared, reusable packages that power them.
[![Netlify Status](https://api.netlify.com/api/v1/badges/172478bd-afc5-4e47-95ba-d9ab814248fb/deploy-status)](https://app.netlify.com/sites/mnswpr/deploys)
Play it here: [mnswpr.com](https://mnswpr.com). This is the classic game **Minesweeper** built with vanilla web technologies (i.e., no framework dependency). > [!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.
![mnswpr gameplay](https://git.ayo.run/ayo/mnswpr/raw/branch/main/screenshot.png) # Roadmap
## How to Play - **Public APIs** — game-agnostic modules (core, move-log, replay, leaderboard, rating) built inside the first game.
- **Second Game** — validate those APIs by adding a second game through the adapter alone.
- **Reusable Packages** — extract proven modules into standalone, versioned packages.
- **Adapters** — freeze the adapter contract for third-party games to build against.
The goal is to reveal every safe cell without detonating a mine. Your **first click is always safe**. ## Packages
- **Left click** — reveal a cell | Package | Develop | Publish | Document |
- **Right click** — flag / unflag a suspected mine | -------------------- | -------------- | ------- | -------- |
- **Left + right click together** (chording) — reveal the neighbors of a satisfied number | `mnswpr` (game core) | ✅ Built | | |
- **Touch** — tap to reveal, long-press to flag | 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`.
## Ways to Use
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 `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'
const game = new mnswpr('app')
game.initialize()
```
## Tooling
The project has gone through years of existence. It started from 2019 when tooling was massively different. I have [modernized it](https://elk.zone/social.ayco.io/@ayo/116333804543330938) since and have witnessed how much easier and faster it is to build now - even without web frameworks or LLMs!
As of now the tooling I use are:
- [Vite](https://vite.dev/) for bundling and development server
- [Eslint](https://eslint.org) for JS linting & [CSS linting](https://eslint.org/blog/2025/02/eslint-css-support/)
- [ESLint Stylistic](https://eslint.style) for JS formatting
- [Husky](https://typicode.github.io/husky/) for git hooks
- [PNPM](https://pnpm.io/installation) for dependency & workspace management
- and a bunch of automation using scripts and Continuous Integration actions
Because a big part of this project's purpose is to track how the software development industry evolves — and because it has come a long way in modernizing along the way — I now also use it as a **playground for coding agents**. It's a small, framework-free, well-scoped codebase, which makes it a great sandbox to see how AI agents read, reason about, and change real code. To help them get their bearings quickly, the repo ships an [`AGENTS.md`](./AGENTS.md) describing the architecture and conventions.
## Development
Technology Stack: HTML, JS, and CSS; [Google Firebase](https://firebase.google.com) for leader board store; [Netlify](https://netlify.com) for hosting
To start development, you need [`node`](https://nodejs.org/en/download). I highly recommend [`pnpm`](https://pnpm.io/installation) to be used as well. Once you know you have this, you can do the following:
1. Install dependencies: `pnpm i`
2. Start the dev server: `pnpm run dev`
The rest of the everyday commands:
```bash
pnpm test # run the Vitest suite
pnpm lint # ESLint (JS + CSS)
pnpm lint:fix # ESLint with autofix
pnpm build # build the website
pnpm build:lib # build the publishable library
```
### Leaderboard (local Firestore emulator)
The leader board is backed by [Google Firestore](https://firebase.google.com). For local development the app talks to the **Firestore emulator** by default — fully local, no cloud, no deploy. The flag `VITE_FIRESTORE_EMULATOR=1` is already set in `app/.env.development`.
You need a **JDK 21+** installed (the emulator runs on Java; `firebase-tools` itself is fetched on demand via `npx`). Then, in two terminals:
```bash
pnpm emulators # terminal 1 — start the Firestore emulator on :8080 (+ UI)
pnpm seed:emulator # terminal 2, once — fill it with sample scores
pnpm dev # terminal 2 — run the app against the emulator
```
If the emulator isn't running, the board simply shows *"unavailable"* (a refused connection). To skip the emulator — for quick UI-only work, or if you don't have a JDK — set `VITE_FIRESTORE_EMULATOR=` (empty) in a local, gitignored `app/.env.local`; the app then uses the cloud `mw-test` namespace instead.
See [`docs/firebase-leaderboards.md`](./docs/firebase-leaderboards.md) for the full data model, security rules, environments, and deployment.
## Contributing ## Contributing
Contributions are welcome! See [`AGENTS.md`](./AGENTS.md) for the architecture, conventions, and release workflow before opening a pull request. Setup, running games locally, testing, code style, and local infra all live in
**[CONTRIBUTING.md](CONTRIBUTING.md)**. In short — this is a
## You just want to play? [pnpm](https://pnpm.io) workspace:
*👉 The live site is here: [mnswpr.com](https://mnswpr.com)*
## Background
One day, while working in my home office, I heard loud and fast mouse clicks coming from our bedroom. It's my wife, playing her favorite game (Minesweeper) on a crappy website full of advertisements.
I can't allow this, it's a security issue. 🤣
But it is also an opportunity.
I wanted to give her the same game, with a similar leader board she can dominate. And this is also a chance for me to dig deeper into vanilla JS.
Can I make a page with complex interactions (more on this later) without any library dependency?
## What I have learned:
1. JS is awesome ✨
1. We don't always *need* JS frameworks (or TS) ✨
1. Even subtle UI changes *can improve* user gameplay experience ✨
1. There's more ways to break your app than you are initially aware of ✨
1. Competition motivates users to use your app more ✨
1. Hash in bundled filenames helps avoid issues with browser caching (when shipping versions fast) ✨
```bash
pnpm i # install
pnpm test # run the test suite
pnpm -F mnswpr run dev # run the Minesweeper app locally
```
See [apps/mnswpr/README.md](apps/mnswpr/README.md) for the game itself, and each
package's README for library usage.
## License ## License
[BSD-2-Clause](./LICENSE) BSD-2-Clause © Ayo Ayco
---
_Just keep building._<br>
_A project by [Ayo](https://ayo.ayco.io)_

View file

@ -1,19 +0,0 @@
{
"name": "app",
"version": "0.0.1",
"description": "the mnswpr.com web app",
"private": true,
"main": "main.js",
"scripts": {
"build": "vite build",
"preview": "vite preview",
"build:preview": "npm run build && npm run preview"
},
"devDependencies": {
"@ayo-run/mnswpr": "workspace:*",
"@cozy-games/leaderboard": "workspace:*",
"firebase": "^12.11.0",
"web-component-base": "^4.1.2"
},
"author": "Ayo Ayco"
}

View file

@ -3,28 +3,7 @@
Play it here: [mnswpr.com](https://mnswpr.com). This is the classic game **Minesweeper** built with vanilla web technologies (i.e., no framework dependency). Play it here: [mnswpr.com](https://mnswpr.com). This is the classic game **Minesweeper** built with vanilla web technologies (i.e., no framework dependency).
<!-- TODO: replace with the actual screenshot/GIF once supplied (confirm final path/filename) --> ![mnswpr gameplay](https://git.ayo.run/ayo/mnswpr/raw/branch/main/screenshot.png)
![mnswpr gameplay](app/public/screenshot.png)
Technology Stack: HTML, JS, and CSS; [Google Firebase](https://firebase.google.com) for leader board store; [Netlify](https://netlify.com) for hosting
## Usage
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 `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'
const game = new mnswpr('app')
game.initialize()
```
## How to Play ## How to Play
@ -35,6 +14,25 @@ The goal is to reveal every safe cell without detonating a mine. Your **first cl
- **Left + right click together** (chording) — reveal the neighbors of a satisfied number - **Left + right click together** (chording) — reveal the neighbors of a satisfied number
- **Touch** — tap to reveal, long-press to flag - **Touch** — tap to reveal, long-press to flag
## Ways to Use
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/@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 '@cozy-games/mnswpr/mnswpr.css'
import mnswpr from '@cozy-games/mnswpr'
const game = new mnswpr('app')
game.initialize()
```
## Tooling ## Tooling
The project has gone through years of existence. It started from 2019 when tooling was massively different. I have [modernized it](https://elk.zone/social.ayco.io/@ayo/116333804543330938) since and have witnessed how much easier and faster it is to build now - even without web frameworks or LLMs! The project has gone through years of existence. It started from 2019 when tooling was massively different. I have [modernized it](https://elk.zone/social.ayco.io/@ayo/116333804543330938) since and have witnessed how much easier and faster it is to build now - even without web frameworks or LLMs!
@ -49,20 +47,73 @@ As of now the tooling I use are:
Because a big part of this project's purpose is to track how the software development industry evolves — and because it has come a long way in modernizing along the way — I now also use it as a **playground for coding agents**. It's a small, framework-free, well-scoped codebase, which makes it a great sandbox to see how AI agents read, reason about, and change real code. To help them get their bearings quickly, the repo ships an [`AGENTS.md`](./AGENTS.md) describing the architecture and conventions. Because a big part of this project's purpose is to track how the software development industry evolves — and because it has come a long way in modernizing along the way — I now also use it as a **playground for coding agents**. It's a small, framework-free, well-scoped codebase, which makes it a great sandbox to see how AI agents read, reason about, and change real code. To help them get their bearings quickly, the repo ships an [`AGENTS.md`](./AGENTS.md) describing the architecture and conventions.
## Development ## Development
Technology Stack: HTML, JS, and CSS; [Google Firebase](https://firebase.google.com) for leader board store; [Netlify](https://netlify.com) for hosting
To start development, you need [`node`](https://nodejs.org/en/download). I highly recommend [`pnpm`](https://pnpm.io/installation) to be used as well. Once you know you have this, you can do the following: To start development, you need [`node`](https://nodejs.org/en/download). I highly recommend [`pnpm`](https://pnpm.io/installation) to be used as well. Once you know you have this, you can do the following:
1. Install dependencies: `pnpm i` 1. Install dependencies: `pnpm i`
2. Start the dev server: `pnpm run dev` 2. Start the dev server: `pnpm -F mnswpr run dev`
The rest of the everyday commands: The rest of the everyday commands:
```bash ```bash
pnpm test # run the Vitest suite pnpm test # run the Vitest suite (workspace-wide)
pnpm lint # ESLint (JS + CSS) pnpm lint # ESLint (JS + CSS)
pnpm lint:fix # ESLint with autofix pnpm lint:fix # ESLint with autofix
pnpm build # build the website pnpm -F mnswpr run build # build the website
pnpm build:lib # build the publishable library pnpm build:lib # build the publishable library
``` ```
### Leaderboard (local Firestore emulator)
The leader board is backed by [Google Firestore](https://firebase.google.com). For local development the app talks to the **Firestore emulator** by default — fully local, no cloud, no deploy. The flag `VITE_FIRESTORE_EMULATOR=1` is already set in `app/.env.development`.
`dev` is the whole loop in one command — it wraps Vite in `firebase emulators:exec`, so the Firestore emulator (on :8080, + UI) comes up, gets **seeded with sample scores automatically**, and the app dev server starts against it; everything shuts down when you stop it. (Each `dev` starts a fresh in-memory emulator, so the auto-seed writes exactly one clean set every time — no accumulation.) `firebase-tools` is a pinned **devDependency** of this app (installed by `pnpm install`, invoked as the `firebase` binary), so the only extra prerequisite is **Java** — the Firestore emulator is a Java program (`java -jar cloud-firestore-emulator-*.jar`), and firebase-tools does not bundle a JRE.
**Usually automatic.** `pnpm install` runs a root `postinstall` ([`scripts/ensure-java.mjs`](../../scripts/ensure-java.mjs)) that installs a user-local Temurin JRE 21 into `~/.local` (no `sudo`) when `java` isn't already on your PATH. It's idempotent and never fails the install, and it skips when `CI` or `SKIP_JRE_SETUP=1` is set, or on unsupported platforms.
If that skipped and you need Java (or prefer a system-wide install), do it manually — **install a JRE (Java 11+; 21 recommended):**
```bash
# Debian / Ubuntu / Pop!_OS
sudo apt update && sudo apt install -y openjdk-21-jre-headless
# Fedora
sudo dnf install -y java-21-openjdk-headless
# macOS (Homebrew)
brew install openjdk@21
java -version # verify: should print "openjdk 21.x" (or 11+)
```
No `sudo`? Install a JRE into your home directory instead (no root needed):
```bash
curl -fsSL -o /tmp/jre21.tgz "https://api.adoptium.net/v3/binary/latest/21/ga/linux/x64/jre/hotspot/normal/eclipse"
mkdir -p ~/.local/lib && tar xzf /tmp/jre21.tgz -C ~/.local/lib
ln -sf ~/.local/lib/jdk-21*-jre/bin/java ~/.local/bin/java # ~/.local/bin is already on PATH
java -version
```
Without Java, `dev` and `db:start` fail with `Could not spawn 'java -version'`. Install it to a permanent location — a JRE unpacked under `/tmp` disappears when the OS cleans temp files. Then just:
```bash
pnpm -F mnswpr run dev # emulator (:8080 + UI) + auto-seed + app dev server — one command
```
That's the everyday loop. The other DB scripts are for when you want to run pieces separately:
```bash
pnpm -F mnswpr run db:start # emulator only (stays up across app restarts); pair with dev:no-db
pnpm -F mnswpr run db:seed # seed a separately-running emulator (what dev does for you)
pnpm -F mnswpr run db:stop # kill a stray/orphaned emulator holding :8080
```
To skip the emulator entirely — for quick UI-only work, or if you don't have a JDK — run `pnpm -F mnswpr run dev:no-db` (plain Vite) and set `VITE_FIRESTORE_EMULATOR=` (empty) in a local, gitignored `app/.env.local`; the app then uses the cloud `mw-test` namespace instead.
See [`docs/firebase-leaderboards.md`](./docs/firebase-leaderboards.md) for the full data model, security rules, environments, and deployment.
## Contributing ## Contributing
Contributions are welcome! See [`AGENTS.md`](./AGENTS.md) for the architecture, conventions, and release workflow before opening a pull request. Contributions are welcome! See [`AGENTS.md`](./AGENTS.md) for the architecture, conventions, and release workflow before opening a pull request.

View file

@ -98,23 +98,26 @@ Dev config lives in the committed [`app/.env.development`](../app/.env.developme
### One-time CLI setup ### One-time CLI setup
The Firebase project already exists — no need to create it. `firebase-tools` is a pinned devDependency of this app (installed by `pnpm install`),
so run it as the `firebase` binary via pnpm — no global install, no `npx`. The
Firebase project already exists — no need to create it.
```bash ```bash
npx firebase login pnpm -F mnswpr exec firebase login
``` ```
### Deploy rules + indexes ### Deploy rules + indexes
Deploys go to **production** by default. Target a project explicitly with The `deploy:db` script (`pnpm -F mnswpr run deploy:db`) deploys everything under
`--project`: `firestore` to the **default** project. To target a specific project or a subset,
call the CLI directly:
```bash ```bash
# production (default alias 'prod') # production (default alias 'prod')
npx firebase deploy --only firestore:rules,firestore:indexes --project prod pnpm -F mnswpr exec firebase deploy --only firestore:rules,firestore:indexes --project prod
# development database # development database
npx firebase deploy --only firestore:rules,firestore:indexes --project dev pnpm -F mnswpr exec firebase deploy --only firestore:rules,firestore:indexes --project dev
``` ```
> ⚠️ Deploying **replaces** whatever rules currently live in the Console. The > ⚠️ Deploying **replaces** whatever rules currently live in the Console. The
@ -133,16 +136,17 @@ locally (so you validate them before deploying). The flag
`VITE_FIRESTORE_EMULATOR=1` is set in [`app/.env.development`](../app/.env.development). `VITE_FIRESTORE_EMULATOR=1` is set in [`app/.env.development`](../app/.env.development).
> Prerequisite: the Firestore emulator is a Java process, so you need a JDK > Prerequisite: the Firestore emulator is a Java process, so you need a JDK
> (11+) installed. `firebase-tools` is fetched on demand via `npx`. > (11+) installed. `firebase-tools` is a pinned devDependency of this app
> (installed by `pnpm install`, run as the `firebase` binary).
Everyday dev loop: Everyday dev loop:
```bash ```bash
pnpm emulators # terminal 1: Firestore emulator (+ Emulator UI) on :8080 pnpm -F mnswpr run dev # emulator (+ UI) on :8080, auto-seeded with sample scores, + app dev server
pnpm seed:emulator # terminal 2, once: fill it with sample scores
pnpm dev # terminal 2: app runs against the local emulator
``` ```
`dev` seeds the fresh emulator for you (via `emulators:exec "node scripts/seed-dev-scores.js; vite"`). Use the standalone `db:start` / `db:seed` scripts only when running the emulator separately from the app.
Wiring: `app/main.js` passes `{ emulator: { host, port } }` to `FirebaseAdapter`, Wiring: `app/main.js` passes `{ emulator: { host, port } }` to `FirebaseAdapter`,
which calls `connectFirestoreEmulator`. If the emulator isn't running the board which calls `connectFirestoreEmulator`. If the emulator isn't running the board
just shows "unavailable" (a refused connection) — start it, or opt out. just shows "unavailable" (a refused connection) — start it, or opt out.

View file

@ -139,8 +139,8 @@ Both aliases target the one project we actually have:
### 7. Deploy & verify ### 7. Deploy & verify
1. `npx firebase deploy --only firestore:rules,firestore:indexes --project prod` 1. `pnpm -F mnswpr exec firebase deploy --only firestore:rules,firestore:indexes --project prod`
2. Set the Netlify env vars (`VITE_LB_NAMESPACE=mw` + `VITE_FIREBASE_*`). 2. Set the Netlify env vars via CLI: `pnpm -F mnswpr exec netlify env:import .env.production` (or `netlify env:set VITE_LB_NAMESPACE mw` + each `VITE_FIREBASE_*`).
3. `pnpm dev`, win a game → confirm the write lands in **`mw-test-scores`**, and 3. `pnpm dev`, win a game → confirm the write lands in **`mw-test-scores`**, and
the prod `mw-scores` is untouched (Firestore console). the prod `mw-scores` is untouched (Firestore console).
4. Confirm reads on both `mw-scores` and `mw-test-scores` succeed under the new 4. Confirm reads on both `mw-scores` and `mw-test-scores` succeed under the new

View file

@ -4,13 +4,10 @@ rules_version = '2'
// rules are the enforced schema: public reads for the boards, validated // rules are the enforced schema: public reads for the boards, validated
// create-only writes for scores, no client writes to server config. // create-only writes for scores, no client writes to server config.
// //
// SECURITY MODEL: the Firebase web config is public (it ships to every browser), // The Firebase web config that ships in the client is public by design (it is
// so these rules — not secrecy of the config or the codebase — are what protect // not a secret); these rules — not secrecy — are what govern access. No
// the data. No collection allows a client to UPDATE or DELETE existing docs, so // collection allows a client to update or delete existing docs. Keep any
// no client (with or without the source) can wipe the production boards. The // admin/service-account credentials out of the repo and locked down in GCP.
// powerful vectors (Admin SDK / service-account keys, and IAM console access)
// bypass rules entirely — keep those out of the repo and locked down in GCP.
// See docs/firebase-leaderboards.md ("Protecting production data").
// //
// Rules match by namespace SUFFIX (regex) rather than a literal prefix, so the // Rules match by namespace SUFFIX (regex) rather than a literal prefix, so the
// same set covers production (`mw-*`) and test (`mw-test-*`) collections in the // same set covers production (`mw-*`) and test (`mw-test-*`) collections in the
@ -19,7 +16,7 @@ rules_version = '2'
// block is guarded by a regex on the captured collection name. // block is guarded by a regex on the captured collection name.
// //
// Deploy with (see docs/firebase-leaderboards.md): // Deploy with (see docs/firebase-leaderboards.md):
// npx firebase deploy --only firestore:rules --project prod // pnpm -F mnswpr exec firebase deploy --only firestore:rules --project prod
service cloud.firestore { service cloud.firestore {
match /databases/{database}/documents { match /databases/{database}/documents {
@ -36,13 +33,15 @@ service cloud.firestore {
allow write: if false; allow write: if false;
} }
// Per-browser personal archive. Write-only from the client (the app never // Per-browser personal archive. Not readable by clients, append/merge only,
// reads it back), append/merge only, and NEVER deletable. This is the only // and never deletable. The doc is a map of gameId -> game object (one doc
// client-writable collection, so denying delete/read here removes the last // per day, merged as games finish); the entry-count cap keeps a single doc
// way a client could destroy or harvest data. // bounded (Firestore already limits any doc to 1 MiB). 500 is far above any
// real day of play.
match /{all}/{browserId}/games/{session} { match /{all}/{browserId}/games/{session} {
allow read: if false; allow read: if false;
allow create, update: if all.matches('mw(-[a-z]+)?-all'); allow create, update: if all.matches('mw(-[a-z]+)?-all')
&& request.resource.data.size() <= 500;
allow delete: if false; allow delete: if false;
} }

View file

@ -10,7 +10,6 @@
<link rel="shortcut icon" type="image/png" href="/favicon.ico" /> <link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="./main.css" /> <link rel="stylesheet" href="./main.css" />
<link rel="stylesheet" href="../utils/loading/loading.css" />
<style> <style>
:host, :root{ :host, :root{
--mnswpr-transition: 10s ease-in-out; --mnswpr-transition: 10s ease-in-out;

View file

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

View file

@ -1,4 +1,4 @@
import { StorageService } from '../../../utils/index.js' import { StorageService } from '@cozy-games/utils'
const NICK_KEY = 'nickname' const NICK_KEY = 'nickname'
const MAX_LENGTH = 24 const MAX_LENGTH = 24

36
apps/mnswpr/netlify.toml Normal file
View file

@ -0,0 +1,36 @@
# Netlify hosting config for mnswpr.com — the app-level, declarative hosting
# state, codified here so hosting is reproducible from the repo and deployed via
# CLI (`pnpm -F mnswpr run deploy:site`), never the web dashboard. This file is
# app infra *config*; the CLI that reads it (`netlify-cli`) is app infra *tooling*
# and lives in this app's devDependencies. See AGENTS.md "Infra".
# The `deploy:site` script pre-builds locally and uploads `dist` via the CLI, so
# this [build] block is only exercised when Netlify builds the site from git
# (dashboard "base directory" = apps/mnswpr). It is a pnpm workspace, so
# `pnpm install` from here installs the whole monorepo before building.
[build]
command = "pnpm install --frozen-lockfile && pnpm run build"
publish = "dist"
[build.environment]
NODE_VERSION = "20"
# Production leaderboard namespace (dev/test uses `mw-test`). Applies to
# git-triggered builds; local CLI builds read app/.env.production instead.
# Firebase VITE_* keys are public but are set as Netlify env vars (via
# `netlify env:set` / `netlify env:import`, not the dashboard) to keep prod
# values out of git — see apps/mnswpr/.env.example.
VITE_LB_NAMESPACE = "mw"
# Pretty URL for the frozen Legends page (built as /legends.html).
[[redirects]]
from = "/legends"
to = "/legends.html"
status = 200
# Baseline security headers applied to every response.
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "SAMEORIGIN"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"

30
apps/mnswpr/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "mnswpr",
"version": "0.0.1",
"description": "the mnswpr.com web app",
"private": true,
"type": "module",
"main": "main.js",
"scripts": {
"dev": "firebase emulators:exec --only firestore --ui \"node scripts/seed-dev-scores.js; vite\"",
"dev:no-db": "vite",
"build": "vite build",
"preview": "vite preview",
"build:preview": "npm run build && npm run preview",
"deploy:db": "firebase deploy --only firestore",
"deploy:site": "npm run build && netlify deploy --prod --dir=dist",
"db:start": "firebase emulators:start --only firestore",
"db:seed": "FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 node scripts/seed-dev-scores.js",
"db:stop": "pkill -f '[c]loud-firestore-emulator'; pkill -f '[f]irebase.* emulators:'; true"
},
"devDependencies": {
"@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": "^5.0.0"
},
"author": "Ayo Ayco"
}

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,12 +1,12 @@
/** /**
* One-time generator: snapshot the all-time leaders from the legacy * One-time generator: snapshot the all-time leaders from the legacy
* `mw-leaders/{level}/games` collection and write them into app/legends.html as * `mw-leaders/{level}/games` collection and write them into legends.html as
* a FULLY-RENDERED, static page no runtime JS, no Firebase at page load. * a FULLY-RENDERED, static page no runtime JS, no Firebase at page load.
* *
* The data never changes, so the page is a frozen artifact. Re-run only if you * The data never changes, so the page is a frozen artifact. Re-run only if you
* ever need to regenerate it. Because `firebase` is an app-workspace dependency, * ever need to regenerate it. Because `firebase` is an app-workspace dependency,
* run it so Node can resolve it, e.g. from the app directory: * run it so Node can resolve it, e.g. from the app directory:
* (cd app && node ../scripts/export-legends.js) * (cd apps/mnswpr && node scripts/export-legends.js)
*/ */
import { writeFileSync } from 'node:fs' import { writeFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path' import { resolve, dirname } from 'node:path'
@ -17,9 +17,9 @@ import {
getFirestore, getDocs, collection, query, orderBy, limit getFirestore, getDocs, collection, query, orderBy, limit
} from 'firebase/firestore/lite' } from 'firebase/firestore/lite'
import { levels } from '../lib/levels.js' import { levels } from '@cozy-games/mnswpr/levels.js'
// Mirror of TimerService.pretty() (utils/timer/timer.js) — inlined so this // Mirror of TimerService.pretty() (@cozy-games/utils timer) — inlined so this
// generator has no cross-module import chain to resolve under raw Node. // generator has no cross-module import chain to resolve under raw Node.
const clean = (str, separator) => (str === '00' ? '' : `${str}${separator}`) const clean = (str, separator) => (str === '00' ? '' : `${str}${separator}`)
const pretty = duration => { const pretty = duration => {
@ -131,6 +131,6 @@ ${sections.join('\n')}
` `
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const out = resolve(__dirname, '../app/legends.html') const out = resolve(__dirname, '../legends.html')
writeFileSync(out, html) writeFileSync(out, html)
console.log(`\nWrote ${out}`) console.log(`\nWrote ${out}`)

View file

@ -13,7 +13,7 @@
* firestore.rules to be deployed first (they permit writes to * firestore.rules to be deployed first (they permit writes to
* `mw-test-scores`), else every write returns permission-denied: * `mw-test-scores`), else every write returns permission-denied:
* npx firebase deploy --only firestore:rules --project dev * npx firebase deploy --only firestore:rules --project dev
* (cd app && node ../scripts/seed-dev-scores.js) * (cd apps/mnswpr && node scripts/seed-dev-scores.js)
* *
* `firebase` is an app-workspace dependency, so run it from the app directory so * `firebase` is an app-workspace dependency, so run it from the app directory so
* Node can resolve it. Optional namespace override: LB_NAMESPACE=mw-test. * Node can resolve it. Optional namespace override: LB_NAMESPACE=mw-test.
@ -21,7 +21,7 @@
import { initializeApp } from 'firebase/app' import { initializeApp } from 'firebase/app'
import { getFirestore, connectFirestoreEmulator, doc, collection, setDoc } from 'firebase/firestore/lite' import { getFirestore, connectFirestoreEmulator, doc, collection, setDoc } from 'firebase/firestore/lite'
import { buckets } from '../utils/date-bucket/date-bucket.js' import { buckets } from '@cozy-games/utils/date-bucket/date-bucket.js'
const firebaseConfig = { const firebaseConfig = {
apiKey: 'AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ', apiKey: 'AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ',

View file

@ -0,0 +1,30 @@
# 0001. Backend-agnostic package boundary
**Status:** Accepted · **Repo:** cozy-games · **Date:** 2026-07
## Context
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.
## Decision
Packages in this repo are **backend-agnostic and permission-agnostic**: they
expose capability, never storage or authorization. Any backend — database, auth,
server-side logic — is supplied by the consuming application through injected
adapters and hooks (see 0002). Package code contains no storage-, deployment-, or
authorization-specific logic.
## Rationale (technical)
- Permission-agnostic packages are correct library design: authorization and
storage belong to each deployment (e.g. via security rules and server-side
contexts), not to library code.
- Backend-agnostic packages are more adoptable, testable, and contributable; any
app or backend can consume them.
- Standalone packages are more reusable and testable than a monolithic app.
## Consequences
- Public API changes in these packages are semver events for downstream consumers.
- Contributions must not introduce coupling to any specific backend or deployment.
- These decision records cover package and architecture decisions only; storage,
deployment, and operations choices belong to each consuming application, not to
this repo.

View file

@ -0,0 +1,34 @@
# 0002. Game-agnostic cores behind a game-adapter interface
**Status:** Accepted · **Repo:** cozy-games · **Date:** 2026-07
## Context
Multiple games (Minesweeper today, Sudoku planned) share generic infrastructure:
move logging, replay, timing, and leaderboards. Building this per-game duplicates
logic; abstracting prematurely risks wrong boundaries.
## Decision
Core modules are written **game-agnostic from day one**; each game supplies an
**adapter**. Package extraction and npm publishing are deferred until a second
game consumes the seam ("create seams, not packages").
### The adapter contract (v0, will be frozen after the second game ships)
A game adapter supplies:
1. **Event vocabulary** — a typed set of move events (Minesweeper: `reveal | flag | unflag | chord`). Core code handles only the generic envelope `MoveEvent<T>`: `{ seq, clientTs, type: T, payload }` wrapped in a log carrying `schema_version`.
2. **Progress reducer**`progress(events) → percent` for progress display.
3. **State reducer**`apply(state, event) → state` for full replay and validation.
4. **Terminal predicate**`isTerminal(state) → win | loss | null`; generic timing code measures first event → terminal event.
5. **Board payload type** — serialized layout (e.g. grid + mine positions) stored as typed JSON under a `game_type` discriminator.
6. **Headless core** — pure functions (`generateBoard`, reducers, predicates) runnable in Node with no DOM dependency; presentation is a separate layer.
7. **Board injection & resume** — constructors accepting an externally supplied board and a mid-game state snapshot.
### Rules
- Core/engine modules import no game-specific types.
- The event vocabulary and log schema_version live in the game's package; recorded logs are replayable forever (schema changes are versioned, never breaking).
- Generic storage columns (id, player, outcome, timestamps, game_type) never require migration to add a game; new games add a payload type and adapter only.
## Consequences
- Adding a game = writing one adapter; infrastructure is untouched.
- The replay engine and the log envelope become extractable packages once validated by the second adapter.
- The adapter interface is frozen and versioned after the second game ships; breaking changes thereafter require a new decision record.

View file

@ -0,0 +1,23 @@
# 0003. Stored board layouts, not reproducible seeds
**Status:** Accepted · **Repo:** cozy-games · **Date:** 2026-07
## Context
Replay and shared-board features require two plays of the same board. Two options:
(a) seeded PRNG generation, where a seed reproduces the board; (b) serialize and
store the full board layout at game start, referenced by an id.
## Decision
Store the full layout (b). Boards are serialized as the game's payload (per
0002 §5) and referenced by an unguessable `game_id`.
## Rationale
- **No generator lock-in:** seeded reproduction breaks if the generation algorithm ever changes (bugfix, difficulty tuning, library swap). Stored layouts are immune to generator-version drift — old games replay forever.
- **No refactor required:** existing generators keep working; serialization is additive.
- **Simpler single-use semantics:** "a board is played once per player" is a fact about a stored entity, not a rule about seed distribution.
- Layout size is trivial (a Minesweeper expert board < 1KB).
## Consequences
- Games recorded before layout storage existed cannot be replayed (archived instead).
- `game_id` values may appear in URLs → must be unguessable (no sequential IDs).
- Stored layout data is provided to clients as game rules require.

14
docs/decisions/README.md Normal file
View file

@ -0,0 +1,14 @@
# Decision records
Short documents that capture a significant technical decision — its context, the
choice made, the reasoning, and the consequences. One file per decision, numbered
and append-only. (This format is commonly called an **Architecture Decision
Record**, or ADR.)
Records are immutable once accepted. A later decision that changes an earlier one
is added as a new record that supersedes it, rather than editing the old one — so
the history of *why* stays intact.
- [0001 — Backend-agnostic package boundary](0001-package-boundary.md)
- [0002 — Game-agnostic cores behind a game-adapter interface](0002-game-adapter-pattern.md)
- [0003 — Stored board layouts, not reproducible seeds](0003-stored-boards-not-seeds.md)

View file

@ -50,7 +50,7 @@ export default defineConfig([
} }
}, },
{ {
files: ['scripts/**/*.js'], files: ['**/scripts/**/*.{js,mjs,cjs}'],
languageOptions: { languageOptions: {
globals: globals.node globals: globals.node
} }

View file

@ -1,164 +0,0 @@
import { WebComponent } from 'web-component-base'
import { LeaderBoardService } from './leader-board.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).
*
* Composition lives in HTML attributes; the storage backend (adapter) is set
* once in JS via configureLeaderboard(), because env-var config can't live in
* static markup.
*
* <cozy-leaderboard category="beginner" title="Best Times" format="time"></cozy-leaderboard>
*/
// Shared config for every element on the page, set once via configureLeaderboard().
let sharedConfig = {}
const instances = new Set()
/**
* Configure the backend + defaults for all <cozy-leaderboard> elements. Call
* once at startup, after building your adapter (Firebase/Supabase/). User-facing
* strings (labels, emptyMessages, loadingText, errorText, anonymousName) can be
* passed here to localize without changing the package.
* @param {Object} options - { adapter, scoreOrder?, format?, formatScore?, qualifies?, labels?, emptyMessages?, loadingText?, errorText?, anonymousName? }
*/
export function configureLeaderboard(options = {}) {
sharedConfig = { ...sharedConfig, ...options }
instances.forEach(el => el._mount())
}
const clean = (str, separator) => (str === '00' ? '' : `${str}${separator}`)
// ms -> pretty time, e.g. 4200 -> "04.2" (mirrors utils/timer pretty()).
const prettyTime = ms => {
if (!ms) return '0'
const milliseconds = parseInt((ms % 1000) / 100)
const seconds = Math.floor((ms / 1000) % 60)
const minutes = Math.floor((ms / (1000 * 60)) % 60)
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24)
const hh = hours < 10 ? `0${hours}` : `${hours}`
const mm = minutes < 10 ? `0${minutes}` : `${minutes}`
const ss = seconds < 10 ? `0${seconds}` : `${seconds}`
return `${clean(hh, ':')}${clean(mm, ':')}${clean(ss, '.')}${milliseconds}`
}
// Built-in score formatters, selectable via the `format` attribute.
const FORMATTERS = { time: prettyTime }
const resolveFormat = name => FORMATTERS[name]
export class CozyLeaderboard extends WebComponent {
static get observedAttributes() {
return ['category', 'title', 'duration', 'score-order', 'format']
}
// WCB lifecycle: register/unregister so configureLeaderboard() can re-render.
onInit() {
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()
}
// 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).
_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'))
|| sharedConfig.formatScore
|| resolveFormat(sharedConfig.format)
|| String
this._svc = new LeaderBoardService({
adapter,
scoreOrder: this.getAttribute('score-order') || sharedConfig.scoreOrder || 'asc',
formatScore,
qualifies: this.qualifies || sharedConfig.qualifies,
// User-facing strings — pass through so apps localize without touching the package.
labels: this.labels || sharedConfig.labels,
tooltips: this.tooltips || sharedConfig.tooltips,
emptyMessages: this.emptyMessages || sharedConfig.emptyMessages,
loadingText: this.loadingText || sharedConfig.loadingText,
errorText: this.errorText || sharedConfig.errorText,
anonymousName: this.anonymousName || sharedConfig.anonymousName
})
return this._svc
}
/**
* (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.
*/
_mount(durationArg) {
if (!this.isConnected) return
const service = this._service()
if (!service) {
this.replaceChildren(this._message('Leaderboard not configured.'))
return
}
let duration = durationArg
if (duration === undefined && !this._mounted) {
duration = this.getAttribute('duration') || undefined
}
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.'))
})
}
/**
* Submit a finished game through this element's service keeps score
* submission a one-liner from the host app.
* @param {Object} entry
*/
submit(entry) {
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')) {
customElements.define('cozy-leaderboard', CozyLeaderboard)
}

View file

@ -1,8 +1,8 @@
{ {
"name": "monorepo", "name": "cozy-games",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"description": "Classic Minesweeper browser game", "description": "Cozy Games monorepo — apps, shared packages, and sites",
"author": "Ayo Ayco", "author": "Ayo Ayco",
"type": "module", "type": "module",
"repository": { "repository": {
@ -12,29 +12,29 @@
"homepage": "https://mnswpr.com", "homepage": "https://mnswpr.com",
"scripts": { "scripts": {
"test": "vitest run", "test": "vitest run",
"dev": "pnpm -F mnswpr dev",
"test:watch": "vitest", "test:watch": "vitest",
"dev": "vite app", "build": "pnpm -r --filter \"./packages/*\" run build",
"start": "vite app", "build:lib": "vite build packages/mnswpr",
"emulators": "npx -y firebase-tools emulators:start --only firestore",
"seed:emulator": "cd app && FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 node ../scripts/seed-dev-scores.js",
"build": "vite build app",
"build:lib": "vite build lib",
"publish:lib": "node scripts/publish-lib.js", "publish:lib": "node scripts/publish-lib.js",
"release": "pnpm build:lib && pnpm -F @ayo-run/mnswpr run release && pnpm publish:lib", "release": "pnpm build:lib && pnpm -F @cozy-games/mnswpr run release && pnpm publish:lib",
"build:preview": "pnpm -F app run build:preview", "postinstall": "node scripts/ensure-java.mjs",
"prepare": "husky", "prepare": "husky",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix" "lint:fix": "eslint . --fix",
"scan:secrets": "secretlint --secretlintignore .secretlintignore \"**/*\""
}, },
"devDependencies": { "devDependencies": {
"@eslint/css": "^1.1.0", "@eslint/css": "^1.1.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@secretlint/secretlint-rule-preset-recommend": "^13.0.2",
"@stylistic/eslint-plugin": "^5.10.0", "@stylistic/eslint-plugin": "^5.10.0",
"bumpp": "^11.0.1", "bumpp": "^11.0.1",
"eslint": "^10.1.0", "eslint": "^10.1.0",
"globals": "^17.4.0", "globals": "^17.4.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"secretlint": "^13.0.2",
"simple-git": "^3.33.0", "simple-git": "^3.33.0",
"vite": "^8.0.3", "vite": "^8.0.3",
"vitest": "^4.1.9" "vitest": "^4.1.9"

View file

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

View file

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

View file

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

View file

@ -0,0 +1,65 @@
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/).
*
* 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, 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 - 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]
* @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.reader = new LeaderBoardReader(options)
this.writer = new LeaderBoardWriter(options)
}
/**
* Read surface render the ranked list with a duration tab bar.
* @see LeaderBoardReader#render
*/
render(category, title, duration) {
return this.reader.render(category, title, duration)
}
/**
* Write surface submit a completed game (archive + ranked entry).
* @see LeaderBoardWriter#submit
*/
submit(entry) {
return this.writer.submit(entry)
}
}

View file

@ -0,0 +1,300 @@
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` (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
* static markup.
*
* <cozy-leaderboard category="beginner" title="Best Times" format="time"></cozy-leaderboard>
*/
// Shared config for every element on the page, set once via configureLeaderboard().
let sharedConfig = {}
const instances = new Set()
/**
* Configure the backend + defaults for all <cozy-leaderboard> elements. Call
* once at startup, after building your adapter (Firebase/Supabase/). User-facing
* strings (labels, emptyMessages, loadingText, errorText, anonymousName) can be
* passed here to localize without changing the package.
* @param {Object} options - { adapter, scoreOrder?, format?, formatScore?, qualifies?, labels?, emptyMessages?, loadingText?, errorText?, anonymousName? }
*/
export function configureLeaderboard(options = {}) {
sharedConfig = { ...sharedConfig, ...options }
instances.forEach(el => el._mount())
}
const clean = (str, separator) => (str === '00' ? '' : `${str}${separator}`)
// ms -> pretty time, e.g. 4200 -> "04.2" (mirrors utils/timer pretty()).
const prettyTime = ms => {
if (!ms) return '0'
const milliseconds = parseInt((ms % 1000) / 100)
const seconds = Math.floor((ms / 1000) % 60)
const minutes = Math.floor((ms / (1000 * 60)) % 60)
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24)
const hh = hours < 10 ? `0${hours}` : `${hours}`
const mm = minutes < 10 ? `0${minutes}` : `${minutes}`
const ss = seconds < 10 ? `0${seconds}` : `${seconds}`
return `${clean(hh, ':')}${clean(mm, ':')}${clean(ss, '.')}${milliseconds}`
}
// Built-in score formatters, selectable via the `format` attribute.
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 {
// 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: ''
}
// 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)
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, 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.props.format)
|| sharedConfig.formatScore
|| resolveFormat(sharedConfig.format)
|| String
this._svc = new LeaderBoardService({
adapter,
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.
labels: this.labels || sharedConfig.labels,
tooltips: this.tooltips || sharedConfig.tooltips,
emptyMessages: this.emptyMessages || sharedConfig.emptyMessages,
loadingText: this.loadingText || sharedConfig.loadingText,
errorText: this.errorText || sharedConfig.errorText,
anonymousName: this.anonymousName || sharedConfig.anonymousName
})
return this._svc
}
/**
* (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._connected) return
const service = this._service()
if (!service) {
this._view = { board: false }
this._paint()
return
}
const duration = durationArg
?? this._activeDuration
?? (this.props.duration || 'today')
this._activeDuration = duration
this._load(service, duration)
}
_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()
}
/**
* 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()
}
/**
* Submit a finished game through this element's service keeps score
* submission a one-liner from the host app.
* @param {Object} entry
*/
submit(entry) {
const service = this._service()
if (service) return service.submit(entry)
}
}
if (!customElements.get('cozy-leaderboard')) {
customElements.define('cozy-leaderboard', CozyLeaderboard)
}

View file

@ -1,4 +1,12 @@
import { buckets } from '../utils/date-bucket/date-bucket.js' /**
* 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 const DAY_MS = 24 * 60 * 60 * 1000
@ -6,9 +14,10 @@ const DAY_MS = 24 * 60 * 60 * 1000
* The four time windows are ROLLING: each shows entries from the last `ms` * The four time windows are ROLLING: each shows entries from the last `ms`
* milliseconds (strictly nested 24h 7d 30d all), sorted by score. * 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 * `ms: null` is the all-time view (no time filter). `title` is the hover tooltip
* that spells out the window. * that spells out the window. Exported so view layers (e.g. the
* `<cozy-leaderboard>` element) can render the tab bar themselves.
*/ */
const DURATIONS = [ export const DURATIONS = [
{ id: 'today', label: 'Today', ms: DAY_MS, title: 'Last 24 hours' }, { id: 'today', label: 'Today', ms: DAY_MS, title: 'Last 24 hours' },
{ id: 'week', label: 'Week', ms: 7 * DAY_MS, title: 'Last 7 days' }, { 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: 'month', label: 'Month', ms: 30 * DAY_MS, title: 'Last 30 days' },
@ -18,7 +27,7 @@ const DURATIONS = [
/** /**
* Default empty-state messages challenging but friendly, and game-agnostic. * Default empty-state messages challenging but friendly, and game-agnostic.
* One is picked at random each render. Override per app via the `emptyMessages` * One is picked at random each render. Override per app via the `emptyMessages`
* option (see below) so localization stays out of this package. * option so localization stays out of this package.
*/ */
const EMPTY_MESSAGES = [ const EMPTY_MESSAGES = [
'Be the first to enter the leader board!', 'Be the first to enter the leader board!',
@ -32,26 +41,18 @@ const EMPTY_MESSAGES = [
] ]
/** /**
* Generic, game- AND backend-agnostic leaderboard. Nothing here knows about * Read-only leaderboard view: windows, sorting (via the adapter), and rendering.
* minesweeper or Firebase: the ranked value is a plain `score`, sorted in the * Nothing here writes; the ranked value is a plain `score` displayed through an
* configured direction and displayed through an injected formatter, while all * injected formatter, and all query I/O is delegated to an injected adapter's
* storage I/O is delegated to an injected adapter (see adapters/). Games wire * `listScores`. Safe to wire to a read-only / less-privileged backend instance.
* their specifics through the constructor options.
*
* 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
*/ */
export class LeaderBoardService { export class LeaderBoardReader {
/** /**
* @param {Object} options * @param {Object} options
* @param {Object} options.adapter - storage backend (e.g. FirebaseAdapter, SupabaseAdapter) * @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 {'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 {(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.labels] - optional tab-label overrides keyed by duration id
* @param {Object} [options.tooltips] - optional tab hover-text 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.emptyMessages] - empty-state messages (one picked at random); localize here
@ -63,7 +64,6 @@ export class LeaderBoardService {
this.adapter = options.adapter this.adapter = options.adapter
this.scoreOrder = options.scoreOrder === 'desc' ? 'desc' : 'asc' this.scoreOrder = options.scoreOrder === 'desc' ? 'desc' : 'asc'
this.formatScore = options.formatScore || (value => String(value)) this.formatScore = options.formatScore || (value => String(value))
this.qualifies = options.qualifies || (entry => this._defaultQualifies(entry))
this.labels = options.labels || {} this.labels = options.labels || {}
this.tooltips = options.tooltips || {} this.tooltips = options.tooltips || {}
@ -74,36 +74,42 @@ export class LeaderBoardService {
this.loadingText = options.loadingText || 'Loading…' this.loadingText = options.loadingText || 'Loading…'
this.errorText = options.errorText || 'Leaderboard unavailable right now.' this.errorText = options.errorText || 'Leaderboard unavailable right now.'
this.anonymousName = options.anonymousName || 'Anonymous' this.anonymousName = options.anonymousName || 'Anonymous'
Promise.resolve(this.adapter.getConfig())
.then(config => {
this.configuration = config
})
.catch(() => {})
} }
/** /**
* Default ranking gate: if the server config names a `passingStatus`, only * Display label for a duration window (override-aware).
* entries whose `status` matches qualify; otherwise every entry qualifies. * @param {{ id: String, label: String }} duration - a DURATIONS entry
*/ */
_defaultQualifies(entry) { label(duration) {
const passing = this.configuration && this.configuration.passingStatus
if (!passing) return true
return entry.status === passing
}
_label(duration) {
return this.labels[duration.id] || duration.label return this.labels[duration.id] || duration.label
} }
_tooltip(duration) { /**
* 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 return this.tooltips[duration.id] || duration.title
} }
_emptyMessage() { /** One empty-state message, picked at random. */
emptyMessage() {
return this.emptyMessages[Math.floor(Math.random() * this.emptyMessages.length)] 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 * Backend-neutral query descriptor for a category and time window. `since` is
* the rolling cutoff (entries with `time_stamp >= since`); `null` means * the rolling cutoff (entries with `time_stamp >= since`); `null` means
@ -140,7 +146,7 @@ export class LeaderBoardService {
wrapper.style.margin = '0 auto' wrapper.style.margin = '0 auto'
const heading = document.createElement('h3') const heading = document.createElement('h3')
heading.innerText = title heading.textContent = title
heading.style.borderBottom = '1px solid #c0c0c0' heading.style.borderBottom = '1px solid #c0c0c0'
heading.style.paddingBottom = '10px' heading.style.paddingBottom = '10px'
wrapper.append(heading) wrapper.append(heading)
@ -164,9 +170,9 @@ export class LeaderBoardService {
DURATIONS.forEach(d => { DURATIONS.forEach(d => {
const tab = document.createElement('button') const tab = document.createElement('button')
tab.innerText = this._label(d) tab.textContent = this.label(d)
tab.type = 'button' tab.type = 'button'
tab.setAttribute('title', this._tooltip(d)) tab.setAttribute('title', this.tooltip(d))
this._styleTab(tab, d.id === duration) this._styleTab(tab, d.id === duration)
tab.onclick = () => activate(d.id) tab.onclick = () => activate(d.id)
tabs[d.id] = tab tabs[d.id] = tab
@ -203,7 +209,7 @@ export class LeaderBoardService {
listWrapper.innerHTML = '' listWrapper.innerHTML = ''
const loading = document.createElement('em') const loading = document.createElement('em')
loading.innerText = this.loadingText loading.textContent = this.loadingText
listWrapper.append(loading) listWrapper.append(loading)
try { try {
@ -214,7 +220,7 @@ export class LeaderBoardService {
if (this._loadToken !== token) return if (this._loadToken !== token) return
listWrapper.innerHTML = '' listWrapper.innerHTML = ''
const message = document.createElement('em') const message = document.createElement('em')
message.innerText = this.errorText message.textContent = this.errorText
listWrapper.append(message) listWrapper.append(message)
} }
} }
@ -224,7 +230,7 @@ export class LeaderBoardService {
if (!rows || !rows.length) { if (!rows || !rows.length) {
const message = document.createElement('em') const message = document.createElement('em')
message.innerText = this._emptyMessage() message.textContent = this.emptyMessage()
listWrapper.append(message) listWrapper.append(message)
return return
} }
@ -239,11 +245,12 @@ export class LeaderBoardService {
item.style.display = 'flex' item.style.display = 'flex'
const indexElement = document.createElement('div') const indexElement = document.createElement('div')
indexElement.innerText = `#${i++}` indexElement.textContent = `#${i++}`
const nameElement = document.createElement('div') const nameElement = document.createElement('div')
const name = data.name || this.anonymousName const name = data.name || this.anonymousName
nameElement.innerHTML = name // textContent, never innerHTML: names are player-controlled input.
nameElement.textContent = name
nameElement.setAttribute('title', name) nameElement.setAttribute('title', name)
nameElement.style.textOverflow = 'ellipsis' nameElement.style.textOverflow = 'ellipsis'
nameElement.style.whiteSpace = 'nowrap' nameElement.style.whiteSpace = 'nowrap'
@ -254,7 +261,7 @@ export class LeaderBoardService {
nameElement.style.flex = '1' nameElement.style.flex = '1'
const scoreElement = document.createElement('div') const scoreElement = document.createElement('div')
scoreElement.innerText = this.formatScore(data.score) scoreElement.textContent = this.formatScore(data.score)
item.append(indexElement, nameElement, scoreElement) item.append(indexElement, nameElement, scoreElement)
list.append(item) list.append(item)
@ -262,41 +269,4 @@ export class LeaderBoardService {
listWrapper.append(list) listWrapper.append(list)
} }
/**
* Submit a completed game. Always archives it (personal history); if it
* qualifies, also writes a ranked entry with denormalized bucket keys. Both
* writes go through the adapter, so the storage backend is pluggable. The
* caller owns display-name/nickname UX.
* @param {Object} entry - { name, playerId, score, category, time_stamp, status?, meta? }
*/
async submit(entry) {
if (this.adapter.archive) {
await this.adapter.archive({
playerId: entry.playerId,
score: entry.score,
category: entry.category,
time_stamp: entry.time_stamp,
meta: entry.meta
})
}
if (!this.qualifies(entry)) return
const stamp = entry.time_stamp instanceof Date ? entry.time_stamp : new Date(entry.time_stamp)
const bucket = buckets(stamp)
const scoreDoc = {
name: entry.name || 'Anonymous',
playerId: entry.playerId,
score: entry.score,
category: entry.category,
time_stamp: entry.time_stamp,
day: bucket.day,
week: bucket.week,
month: bucket.month
}
if (entry.meta) scoreDoc.meta = entry.meta
await this.adapter.addScore(entry.category, scoreDoc)
}
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,24 @@
// @ts-check
/**
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
*/
/**
* Map a recorded move-event back to the rules move that produced it: `flag` and
* `unflag` are both the toggle move `flag`; `reveal` and `chord` pass through.
* Unknown kinds are ignored. Shared by the mnswpr replay adapters (progress and
* full-board state) so both replay a stream through the core rules identically.
*
* @param {MnswprMoveEvent} e
* @returns {{ type: 'reveal' | 'flag' | 'chord', r: number, c: number } | null}
*/
export function toMove(e) {
switch (e && e.type) {
case 'reveal': return { type: 'reveal', r: e.r, c: e.c }
case 'chord': return { type: 'chord', r: e.r, c: e.c }
case 'flag':
case 'unflag': return { type: 'flag', r: e.r, c: e.c }
default: return null
}
}

View file

@ -0,0 +1,40 @@
// @ts-check
import { MinesweeperRules } from '../core/minesweeper/rules.js'
import { toMove } from './replay-common.js'
/**
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
* @typedef {import('../core/minesweeper/board.js').Layout} Layout
*/
/**
* The percent-cleared progress reducer for Minesweeper mnswpr's first concrete
* implementation of the replay engine's `ProgressReducer<MnswprMoveEvent>` seam
* (see `@cozy-games/replay` / replay-02). Given the ordered slice of move-events
* played so far, it returns completion as
* `revealed safe cells / total safe cells * 100`.
*
* Why it needs the board: a single `reveal` or `chord` event floods MANY cells,
* but the recorded move-event only carries `{ type, r, c }` not how many cells
* opened. So the reducer takes the board as closure input (consistent with the
* interface design) and replays the moves through the pure core rules. That makes
* reveals flood, chords reveal via their (non-flagged) neighbors, and
* flags/unflags only gate chords never advancing progress themselves with no
* cell double-counted. The engine stays game-blind; all interpretation is here.
*
* @param {Layout} layout - the recorded board (as produced by `generateBoard`)
* @returns {(events: { event: MnswprMoveEvent }[]) => number} a reducer to `[0, 100]`
*/
export function createProgressReducer(layout) {
const totalSafe = layout.rows * layout.cols - layout.mines
return function progress(events) {
// A board with no safe cells is vacuously fully cleared (and avoids /0).
if (totalSafe === 0) return 100
let state = MinesweeperRules.fromLayout(layout)
for (const record of events) {
const move = toMove(record.event)
if (move) state = MinesweeperRules.apply(state, move).state
}
return (state.revealedSafe / totalSafe) * 100
}
}

View file

@ -0,0 +1,57 @@
// @ts-check
import { MinesweeperRules } from '../core/minesweeper/rules.js'
import { toMove } from './replay-common.js'
/**
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
* @typedef {import('../core/minesweeper/board.js').Layout} Layout
* @typedef {{ mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }} BoardCell
* @typedef {{ rows: number, cols: number, phase: string, revealedSafe: number, cells: BoardCell[][] }} BoardState
*/
/**
* The full-board state reducer for Minesweeper mnswpr's implementation of the
* replay engine's `StateReducer<MnswprMoveEvent, BoardState>` seam (replay-05).
* Given the ordered slice of move-events played so far, it reconstructs the
* COMPLETE board at that point: every cell's mine/adjacent/status plus the phase.
*
* Like the progress reducer, it takes the board as closure input and replays the
* moves through the pure core rules so reveals flood, chords open their
* neighbors, and flags toggle giving an exact reconstruction at any event
* index. Statelessly a function of the slice, so the engine can jump (seek) to
* any position and rebuild the board there.
*
* @param {Layout} layout - the recorded board (as produced by `generateBoard`)
* @returns {(events: { event: MnswprMoveEvent }[]) => BoardState}
*/
export function createStateReducer(layout) {
return function state(events) {
let s = MinesweeperRules.fromLayout(layout)
for (const record of events) {
const move = toMove(record.event)
if (move) s = MinesweeperRules.apply(s, move).state
}
return toBoard(s)
}
}
/**
* Project a core game state into a plain, render-ready 2D board snapshot.
* @param {import('../core/minesweeper/rules.js').State} s
* @returns {BoardState}
*/
function toBoard(s) {
const { rows, cols } = s.config
/** @type {BoardCell[][]} */
const cells = []
for (let r = 0; r < rows; r++) {
/** @type {BoardCell[]} */
const row = []
for (let c = 0; c < cols; c++) {
const cell = s.grid.at(r, c)
row.push({ mine: cell.mine, adjacent: cell.adjacent, status: cell.status })
}
cells.push(row)
}
return { rows, cols, phase: s.phase, revealedSafe: s.revealedSafe, cells }
}

View file

@ -0,0 +1,114 @@
// @ts-check
/**
* The renderer the ONLY place the client touches game DOM. It consumes core
* events + the projected view and reproduces the exact attributes/classes the
* game has always used (`data-status`, `data-value`, class names), so existing
* CSS and the jsdom tests are unaffected. It reads nothing from the core beyond
* the projected view, so board secrecy stays a drop-in later (invariant #3).
*/
function cellAt(grid, r, c) {
return grid.rows[r].cells[c]
}
/** Reveal a safe cell — matches the old revealSafeCell() DOM exactly. */
function renderRevealed(grid, r, c, adjacent) {
const cell = cellAt(grid, r, c)
cell.className = 'clicked'
cell.setAttribute('data-status', 'clicked')
const span = document.createElement('span')
if (adjacent === 0) {
span.innerHTML = ' '
cell.innerHTML = ''
cell.appendChild(span)
cell.setAttribute('data-status', 'empty')
} else {
span.innerHTML = String(adjacent)
cell.innerHTML = ''
cell.appendChild(span)
cell.setAttribute('data-value', String(adjacent))
}
}
/** Toggle a flag — matches the old rightClickCell() DOM. */
function renderFlag(grid, r, c, flagged) {
const cell = cellAt(grid, r, c)
if (flagged) {
cell.className = 'flag'
cell.setAttribute('data-status', 'flagged')
} else {
cell.className = ''
cell.setAttribute('data-status', 'default')
}
}
/** @returns {Set<string>} "r,c" keys of every mine in the terminal view */
function mineSet(view) {
const set = new Set()
for (const cell of view.cells) {
if (cell.mine) set.add(`${cell.r},${cell.c}`)
}
return set
}
/**
* Apply per-move events to the grid (reveal / flag / the clicked mine on
* explode). Terminal board reveal is handled separately by revealBoard().
* @param {HTMLTableElement} grid
* @param {object[]} events
*/
export function renderEvents(grid, events) {
for (const ev of events) {
if (ev.type === 'reveal') {
for (const c of ev.cells) renderRevealed(grid, c.r, c.c, c.adjacent)
} else if (ev.type === 'flag') {
renderFlag(grid, ev.r, ev.c, ev.flagged)
} else if (ev.type === 'explode') {
const cell = cellAt(grid, ev.r, ev.c)
cell.className = 'clicked'
cell.setAttribute('data-status', 'clicked')
}
}
}
/**
* Reveal the whole board at game end, reproducing the old handleWinRevelation /
* handleLostRevelation output. On a win, mines are marked correct; on a loss,
* unflagged mines detonate and wrong flags are marked.
* @param {HTMLTableElement} grid
* @param {{ phase: string, cells: object[] }} view
* @param {{ rows: number, cols: number }} setting
*/
export function revealBoard(grid, view, setting) {
const won = view.phase === 'won'
const mines = mineSet(view)
for (let r = 0; r < setting.rows; r++) {
for (let c = 0; c < setting.cols; c++) {
const cell = cellAt(grid, r, c)
const isMine = mines.has(`${r},${c}`)
const isFlagged = cell.getAttribute('data-status') === 'flagged'
if (won) {
if (isMine) {
cell.innerHTML = ':)'
cell.className = 'correct'
cell.setAttribute('data-status', 'clicked')
cell.setAttribute('title', 'Correct')
}
} else if (isFlagged) {
if (isMine) {
cell.innerHTML = ':)'
cell.className = 'correct'
cell.setAttribute('title', 'Correct')
} else {
cell.innerHTML = 'X'
cell.className = 'wrong'
cell.setAttribute('title', 'Wrong')
}
} else if (isMine) {
cell.className = 'mine'
cell.setAttribute('data-status', 'clicked')
}
}
}
}

View file

@ -0,0 +1,48 @@
// @ts-check
import { GameSession } from '../core/session/session.js'
/**
* In-process transport: runs a `GameSession` locally and emits its events.
*
* The Transport interface is intentionally async `send()` returns a Promise
* so a future `RemoteTransport` (server-authoritative) is a drop-in with no
* change to the client (server-readiness invariant #1). Rendering is driven by
* `onEvent`, which Local fires synchronously inside `send()` and Remote would
* fire when the server replies; the client never depends on `send()`'s timing.
*
* Only serializable messages cross this boundary (moves in; events + a projected
* view out) never the live session/state (invariant #2).
*/
export class LocalTransport {
/**
* @param {object} rules - a GameRules implementation (e.g. MinesweeperRules)
* @param {{ seed: number, config: object, clock?: () => number }} opts
*/
constructor(rules, opts) {
this.session = new GameSession(rules, opts)
this._subs = []
}
/** @param {(payload: { events: object[], view: object, time: number }) => void} cb */
onEvent(cb) {
this._subs.push(cb)
return () => { this._subs = this._subs.filter(s => s !== cb) }
}
/**
* Apply a move and emit the resulting events/view. Returns a Promise for
* interface parity with a remote transport.
* @param {object} move
* @returns {Promise<{ events: object[], view: object, time: number }>}
*/
send(move) {
const payload = this.session.applyMove(move)
for (const cb of this._subs) cb(payload)
return Promise.resolve(payload)
}
status() { return this.session.status() }
view() { return this.session.view() }
result() { return this.session.result() }
elapsed() { return this.session.elapsed() }
}

View file

@ -0,0 +1,62 @@
// @ts-check
/**
* Layer 0 a dense 2D container of opaque cells. Knows nothing about game
* meaning (no "mine", no "reveal"). Future home: @cozy-games/grid.
*
* @template Cell
*/
export class Grid {
/**
* @param {number} rows
* @param {number} cols
* @param {(r: number, c: number) => Cell} [fill] - factory for each cell
*/
constructor(rows, cols, fill) {
this.rows = rows
this.cols = cols
/** @type {Cell[]} */
this._cells = new Array(rows * cols)
if (fill) {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
this._cells[r * cols + c] = fill(r, c)
}
}
}
}
/** @returns {boolean} */
inBounds(r, c) {
return r >= 0 && c >= 0 && r < this.rows && c < this.cols
}
/** @returns {Cell} */
at(r, c) {
return this._cells[r * this.cols + c]
}
set(r, c, cell) {
this._cells[r * this.cols + c] = cell
}
/** @param {(cell: Cell, r: number, c: number) => void} fn */
forEach(fn) {
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
fn(this.at(r, c), r, c)
}
}
}
/**
* @template Out
* @param {(cell: Cell, r: number, c: number) => Out} fn
* @returns {Grid<Out>}
*/
map(fn) {
const out = new Grid(this.rows, this.cols)
this.forEach((cell, r, c) => out.set(r, c, fn(cell, r, c)))
return out
}
}

View file

@ -0,0 +1,44 @@
// @ts-check
/**
* Neighbor STRATEGIES the extraction seam. A game injects the topology it
* wants; the grid layer never assumes one. Minesweeper uses `eightWay`; a future
* Sudoku would inject its row/col/box `peers`. Each returns in-bounds [r, c]
* coordinate pairs.
*
* @typedef {{ inBounds: (r: number, c: number) => boolean }} Boundable
*/
/**
* All 8 surrounding cells (orthogonal + diagonal).
* @param {Boundable} grid
* @returns {Array<[number, number]>}
*/
export function eightWay(grid, r, c) {
const out = []
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue
const nr = r + dr
const nc = c + dc
if (grid.inBounds(nr, nc)) out.push([nr, nc])
}
}
return out
}
/**
* The 4 orthogonal cells (N/E/S/W).
* @param {Boundable} grid
* @returns {Array<[number, number]>}
*/
export function orthogonal(grid, r, c) {
const out = []
const deltas = [[-1, 0], [1, 0], [0, -1], [0, 1]]
for (const [dr, dc] of deltas) {
const nr = r + dr
const nc = c + dc
if (grid.inBounds(nr, nc)) out.push([nr, nc])
}
return out
}

View file

@ -0,0 +1,33 @@
// @ts-check
import { Grid } from './grid.js'
/**
* Plain-JSON serialization of a Grid. Used by the move log / server persistence
* (Layer 1) and by replay. Cells must themselves be JSON-serializable.
*
* @template Cell
* @param {Grid<Cell>} grid
* @returns {{ rows: number, cols: number, cells: Cell[] }}
*/
export function toJSON(grid) {
const cells = []
grid.forEach(cell => cells.push(cell))
return { rows: grid.rows, cols: grid.cols, cells }
}
/**
* @template Cell
* @param {{ rows: number, cols: number, cells: Cell[] }} data
* @param {(cell: Cell) => Cell} [reviveCell]
* @returns {Grid<Cell>}
*/
export function fromJSON(data, reviveCell) {
const grid = new Grid(data.rows, data.cols)
for (let r = 0; r < data.rows; r++) {
for (let c = 0; c < data.cols; c++) {
const cell = data.cells[r * data.cols + c]
grid.set(r, c, reviveCell ? reviveCell(cell) : cell)
}
}
return grid
}

View file

@ -0,0 +1,25 @@
// @ts-check
/**
* `@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.
*/
// Layer 0 — generic grid (future @cozy-games/grid)
export { Grid } from './grid/grid.js'
export { eightWay, orthogonal } from './grid/neighbors.js'
export { toJSON, fromJSON } from './grid/serialize.js'
// Layer 1 — generic session & authority (future @cozy-games/game-session)
export { GameSession } from './session/session.js'
export { replay } from './session/replay.js'
export { mulberry32, randInt } from './session/rng.js'
// Layer 2 — Minesweeper rules & pure board generation
export { MinesweeperRules, MOVE_EVENT_TYPES } from './minesweeper/rules.js'
export { generateBoard, validateLayout } from './minesweeper/board.js'
// Shared level presets (also consumed by the DOM client)
export { levels } from '../levels.js'

View file

@ -0,0 +1,212 @@
// @ts-check
import { Grid } from '../grid/grid.js'
import { mulberry32, randInt } from '../session/rng.js'
import { eightWay } from '../grid/neighbors.js'
/**
* @typedef {{ rows: number, cols: number, mines: number, id?: string }} Config
* @typedef {{ mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }} Cell
* @typedef {{ mine: boolean, adjacent: number }} LayoutCell
* @typedef {{ rows: number, cols: number, mines: number, cells: LayoutCell[][], mineLocations: [number, number][] }} Layout
*/
/** Shared empty exclude set for generation without first-click safety. */
const NO_EXCLUDE = new Set()
/**
* The set of cells kept mine-free for first-click safety: the clicked cell, plus
* its 8 neighbors when the board has room for all mines outside that 3x3. Falls
* back to just the clicked cell on boards too dense to spare the neighborhood.
*
* @param {Config} config
* @returns {Set<number>} coordinate keys (r * cols + c)
*/
export function excludeAround(config, r, c) {
const { rows, cols, mines } = config
const set = new Set([r * cols + c])
const roomFor3x3 = rows * cols - 9 >= mines
if (roomFor3x3) {
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
const nr = r + dr
const nc = c + dc
if (nr >= 0 && nc >= 0 && nr < rows && nc < cols) set.add(nr * cols + nc)
}
}
}
return set
}
/**
* Deterministically place mines and compute adjacency counts, mutating the grid
* in place. Pure function of (seed, config, exclude) same inputs, same board.
* Thin convenience wrapper over {@link fillMines} that builds the RNG from a seed.
*
* @param {number} seed
* @param {Config} config
* @param {Set<number>} exclude - coordinate keys never to mine (first-click safety)
* @param {import('../grid/grid.js').Grid<Cell>} grid
* @returns {Set<number>} the mined coordinate keys
*/
export function placeMines(seed, config, exclude, grid) {
return fillMines(mulberry32(seed), config, exclude, grid)
}
/**
* The injected-RNG seam under {@link placeMines}: place mines and compute
* adjacency counts, mutating the grid in place. Takes an rng function (any
* `() => [0, 1)`), so callers own determinism same rng sequence, same board.
*
* @param {() => number} rng
* @param {Config} config
* @param {Set<number>} exclude - coordinate keys never to mine (first-click safety)
* @param {import('../grid/grid.js').Grid<Cell>} grid
* @returns {Set<number>} the mined coordinate keys
*/
export function fillMines(rng, config, exclude, grid) {
const { rows, cols, mines } = config
const placed = new Set()
while (placed.size < mines) {
const key = randInt(rng, rows) * cols + randInt(rng, cols)
if (placed.has(key) || exclude.has(key)) continue
placed.add(key)
}
grid.forEach((cell, r, c) => { cell.mine = placed.has(r * cols + c) })
grid.forEach((cell, r, c) => {
if (cell.mine) { cell.adjacent = 0; return }
let n = 0
for (const [nr, nc] of eightWay(grid, r, c)) {
if (grid.at(nr, nc).mine) n++
}
cell.adjacent = n
})
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 `@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
* yields the same layout. `seed` is a convenience when no `rng` is given it is
* 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>, 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, 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})`)
}
// 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, excludeSet, grid)
/** @type {LayoutCell[][]} */
const cells = []
/** @type {[number, number][]} */
const mineLocations = []
for (let r = 0; r < rows; r++) {
/** @type {LayoutCell[]} */
const row = []
for (let c = 0; c < cols; c++) {
const cell = grid.at(r, c)
row.push({ mine: cell.mine, adjacent: cell.adjacent })
if (cell.mine) mineLocations.push([r, c])
}
cells.push(row)
}
return { rows, cols, mines, cells, mineLocations }
}

View file

@ -0,0 +1,64 @@
// @ts-check
import { eightWay } from '../grid/neighbors.js'
/**
* @typedef {import('./board.js').Cell} Cell
* @typedef {import('../grid/grid.js').Grid<Cell>} MineGrid
* @typedef {{ r: number, c: number, adjacent: number }} RevealedCell
*/
/**
* Reveal starting at (r, c) and flood-fill outward while cells are blank (zero
* adjacent mines), stopping at numbers and flags. Mutates cell statuses; returns
* the newly-revealed cells (never includes already-revealed/flagged cells, so
* callers can count these as fresh safe reveals). The start cell must be a known
* non-mine, hidden cell.
*
* @param {MineGrid} grid
* @returns {RevealedCell[]}
*/
export function floodReveal(grid, startR, startC) {
/** @type {RevealedCell[]} */
const revealed = []
const start = grid.at(startR, startC)
start.status = 'revealed'
revealed.push({ r: startR, c: startC, adjacent: start.adjacent })
const queue = [[startR, startC]]
while (queue.length) {
const [r, c] = queue.shift()
// Only blank cells propagate; numbers are a boundary.
if (grid.at(r, c).adjacent !== 0) continue
for (const [nr, nc] of eightWay(grid, r, c)) {
const n = grid.at(nr, nc)
if (n.status === 'revealed' || n.status === 'flagged') continue
// A blank cell has no adjacent mines, so its neighbors are all safe.
n.status = 'revealed'
revealed.push({ r: nr, c: nc, adjacent: n.adjacent })
if (n.adjacent === 0) queue.push([nr, nc])
}
}
return revealed
}
/**
* @param {MineGrid} grid
* @returns {number} count of flagged cells around (r, c)
*/
export function countFlagsAround(grid, r, c) {
let flags = 0
for (const [nr, nc] of eightWay(grid, r, c)) {
if (grid.at(nr, nc).status === 'flagged') flags++
}
return flags
}
/**
* @param {MineGrid} grid
* @returns {Array<{ r: number, c: number }>} every mined coordinate
*/
export function allMines(grid) {
const out = []
grid.forEach((cell, r, c) => { if (cell.mine) out.push({ r, c }) })
return out
}

View file

@ -0,0 +1,325 @@
// @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, validateLayout } from './board.js'
import { floodReveal, countFlagsAround, allMines } from './reveal.js'
/**
* Minesweeper as a pure, deterministic state machine no DOM, no wall clock.
* `GameSession` (Layer 1) drives it; the client renders the events it emits.
*
* @typedef {import('./board.js').Config} Config
* @typedef {import('./board.js').Cell} Cell
* @typedef {'fresh' | 'active' | 'won' | 'lost'} Phase
* @typedef {{ seed: number, config: Config, grid: Grid<Cell>, phase: Phase, minesPlaced: boolean, revealedSafe: number }} State
* @typedef {{ type: 'reveal', r: number, c: number } | { type: 'flag', r: number, c: number } | { type: 'chord', r: number, c: number }} Move
* @typedef {object} Event
*/
/**
* 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
return state.revealedSafe >= rows * cols - mines
}
/**
* Reveal a single cell (with first-click safety and flood-fill).
* @param {State} state
* @returns {{ state: State, events: Event[] }}
*/
function reveal(state, r, c) {
const cell = state.grid.at(r, c)
if (!cell || cell.status !== 'hidden') return { state, events: [] }
// 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
}
// 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'
state.phase = 'lost'
return { state, events: [{ type: 'explode', r, c, mines: allMines(state.grid) }] }
}
const revealed = floodReveal(state.grid, r, c)
state.revealedSafe += revealed.length
/** @type {Event[]} */
const events = [{ type: 'reveal', cells: revealed }]
if (isWin(state)) { state.phase = 'won'; events.push({ type: 'win' }) }
return { state, events }
}
/**
* Toggle a flag on a hidden cell.
* @param {State} state
* @returns {{ state: State, events: Event[] }}
*/
function flag(state, r, c) {
const cell = state.grid.at(r, c)
if (!cell || cell.status === 'revealed') return { state, events: [] }
cell.status = cell.status === 'flagged' ? 'hidden' : 'flagged'
return { state, events: [{ type: 'flag', r, c, flagged: cell.status === 'flagged' }] }
}
/**
* Chord: on a revealed number whose adjacent flags equal its value, reveal every
* non-flagged neighbor (any of which may be a mine loss).
* @param {State} state
* @returns {{ state: State, events: Event[] }}
*/
function chord(state, r, c) {
const { grid } = state
const cell = grid.at(r, c)
if (!cell || cell.status !== 'revealed' || cell.adjacent === 0) return { state, events: [] }
if (countFlagsAround(grid, r, c) !== cell.adjacent) return { state, events: [] }
/** @type {import('./reveal.js').RevealedCell[]} */
const revealedCells = []
for (const [nr, nc] of eightWay(grid, r, c)) {
const n = grid.at(nr, nc)
if (n.status !== 'hidden') continue
if (n.mine) {
n.status = 'revealed'
state.phase = 'lost'
return { state, events: [{ type: 'explode', r: nr, c: nc, mines: allMines(grid) }] }
}
for (const rev of floodReveal(grid, nr, nc)) revealedCells.push(rev)
}
state.revealedSafe += revealedCells.length
/** @type {Event[]} */
const events = []
if (revealedCells.length) events.push({ type: 'reveal', cells: revealedCells })
if (isWin(state)) { state.phase = 'won'; events.push({ type: 'win' }) }
return { state, events }
}
/**
* Project full state down to what a client is allowed to know: revealed cells
* (+ their adjacency), flags, and only once the game is over the mines. An
* unrevealed mine is NEVER included mid-game, so this is safe to send over a wire
* (invariant #3). Hidden, unrevealed, non-mine cells are simply omitted.
*
* @param {State} state
*/
function project(state) {
const terminal = state.phase === 'won' || state.phase === 'lost'
const cells = []
state.grid.forEach((cell, r, c) => {
if (cell.status === 'revealed') cells.push({ r, c, status: 'revealed', adjacent: cell.adjacent, mine: cell.mine })
else if (cell.status === 'flagged') cells.push({ r, c, status: 'flagged' })
else if (terminal && cell.mine) cells.push({ r, c, status: 'hidden', mine: true })
})
return { config: state.config, phase: state.phase, cells }
}
/**
* The GameRules contract consumed by GameSession/replay: init / apply / status /
* project, plus serialize / deserialize for snapshotting. Deterministic and
* DOM-free.
*/
export const MinesweeperRules = {
/**
* @param {number} seed
* @param {Config} config
* @returns {State}
*/
init(seed, config) {
return {
seed,
config,
grid: new Grid(config.rows, config.cols, freshCell),
phase: 'fresh',
minesPlaced: false,
revealedSafe: 0
}
},
/**
* 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
},
/**
* Fold a move into the state. Terminal states are absorbing.
* @param {State} state
* @param {Move} move
* @returns {{ state: State, events: Event[] }}
*/
apply(state, move) {
if (state.phase === 'won' || state.phase === 'lost') return { state, events: [] }
switch (move.type) {
case 'reveal': return reveal(state, move.r, move.c)
case 'flag': return flag(state, move.r, move.c)
case 'chord': return chord(state, move.r, move.c)
default: return { state, events: [] }
}
},
project,
/**
* Classify an applied move into a typed move-event kind, or `null` if the move
* was a no-op (the rules produced no events e.g. clicking a revealed cell).
* The session stamps the returned `{ type, r, c }` with `t` and `seq`. This is
* the seam that turns a raw `Move` into the {@link MoveEvent} vocabulary and,
* critically, splits a `flag` move into `flag`/`unflag` by its outcome.
*
* @param {Move} move
* @param {Event[]} events - the rules events this move produced
* @returns {{ type: MoveEventType, r: number, c: number } | null}
*/
toMoveEvent(move, events) {
if (!events || events.length === 0) return null
switch (move.type) {
case 'reveal': return { type: 'reveal', r: move.r, c: move.c }
case 'chord': return { type: 'chord', r: move.r, c: move.c }
case 'flag': {
const flagged = events.find(e => e.type === 'flag')
if (!flagged) return null
return { type: flagged.flagged ? 'flag' : 'unflag', r: move.r, c: move.c }
}
default: return null
}
},
/**
* Snapshot a game state as a plain, JSON-safe object: the whole board (every
* cell's mine/adjacent/status, via the Layer-0 grid serializer) plus phase and
* progress. Inverse of {@link deserialize}. The `grid` instance is the only
* non-JSON-safe field of `State`; everything else (seed, config, flags) is
* already plain data.
*
* @param {State} state
* @returns {{ seed: number, config: Config, phase: Phase, minesPlaced: boolean, revealedSafe: number, grid: { rows: number, cols: number, cells: Cell[] } }}
*/
serialize(state) {
return {
seed: state.seed,
config: state.config,
phase: state.phase,
minesPlaced: state.minesPlaced,
revealedSafe: state.revealedSafe,
grid: gridToJSON(state.grid)
}
},
/**
* Rebuild a game state from {@link serialize} output (or its JSON round-trip).
* Cells are cloned so the revived state shares no references with the snapshot.
*
* @param {ReturnType<typeof MinesweeperRules.serialize>} snap
* @returns {State}
*/
deserialize(snap) {
assertStateSnapshot(snap)
return {
seed: snap.seed,
config: snap.config,
phase: snap.phase,
minesPlaced: snap.minesPlaced,
revealedSafe: snap.revealedSafe,
grid: gridFromJSON(snap.grid, cell => ({ ...cell }))
}
}
}

View file

@ -0,0 +1,41 @@
// @ts-check
/**
* Deterministically re-run a submitted game from `{ seed, config, log }` and
* report the authoritative outcome. A server calls this at submit time to verify
* a score without trusting the client: it recomputes the result and time from the
* moves, and rejects logs that don't terminate or whose timestamps aren't
* monotonic. (The "verifiable-replay" anti-cheat path no live server needed.)
*
* @param {{ init: Function, apply: Function, status: Function }} rules
* @param {{ seed: number, config: object, log: Array<{ move: object, t: number }> }} submission
* @returns {{ status: string, time: number, valid: boolean, reason: string | null }}
*/
export function replay(rules, { seed, config, log }) {
let state = rules.init(seed, config)
let monotonic = true
let prevT = -Infinity
let t0 = null
let tEnd = null
for (const { move, t } of log) {
if (t < prevT) monotonic = false
prevT = t
const before = rules.status(state)
state = rules.apply(state, move).state
const after = rules.status(state)
if (before === 'fresh' && after !== 'fresh' && t0 === null) t0 = t
if ((after === 'won' || after === 'lost') && tEnd === null) tEnd = t
}
const status = rules.status(state)
const terminal = status === 'won' || status === 'lost'
const time = t0 !== null && tEnd !== null ? tEnd - t0 : 0
const valid = monotonic && terminal
const reason = valid
? null
: !monotonic
? 'non-monotonic timestamps'
: 'log does not reach a terminal state'
return { status, time, valid, reason }
}

View file

@ -0,0 +1,29 @@
// @ts-check
/**
* Deterministic, seedable PRNG (mulberry32). A given seed always yields the same
* sequence this is what makes board generation reproducible and `replay()`
* possible. Uses `Math.imul` (integer math), NOT `Math.random`, so it stays
* inside the determinism guard.
*
* @param {number} seed - any 32-bit integer
* @returns {() => number} a function returning floats in [0, 1)
*/
export function mulberry32(seed) {
let a = seed >>> 0
return function () {
a = (a + 0x6D2B79F5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/**
* Integer in [0, n) from an rng function.
* @param {() => number} rng
* @param {number} n
*/
export function randInt(rng, n) {
return Math.floor(rng() * n)
}

View file

@ -0,0 +1,174 @@
// @ts-check
/**
* Layer 1 owns lifecycle, the (injected) clock, and the move log; delegates all
* game meaning to an injected `rules` object. This is where timing authority
* lives: on the client `clock` is `Date.now` (cosmetic); on a server it is the
* 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, 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, state?: object, clock?: () => number }} opts
*/
constructor(rules, { seed, config, state, clock = () => 0 }) {
this.rules = rules
this.clock = clock
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)
}
/**
* Apply a move: stamp it, fold it through the rules, and return the projected
* view + events + authoritative elapsed time.
* @param {object} move
*/
applyMove(move) {
const t = this.clock()
const before = this.rules.status(this.state)
const { state, events } = this.rules.apply(this.state, move)
this.state = state
this._log.push({ move, t })
const after = this.rules.status(state)
// Timer starts on the first move that leaves 'fresh' (the opening reveal).
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)
}
view() {
return this.rules.project(this.state)
}
log() {
return this._log.slice()
}
/** Authoritative elapsed ms: first move → terminal move (or → now if ongoing). */
elapsed() {
if (this._t0 === null) return 0
const end = this._tEnd !== null ? this._tEnd : this.clock()
return end - this._t0
}
/** The signed-off result, or null while the game is still in progress. */
result() {
const status = this.status()
if (status !== 'won' && status !== 'lost') return null
return { status, time: this.elapsed(), seed: this.state.seed, config: this.state.config, log: this.log() }
}
/**
* Full, JSON-safe snapshot of the whole session game state (board + per-cell
* status, via the rules' own serializer) plus the move log and timing anchors
* (`t0`/`tEnd`) that `elapsed()` derives from. Everything needed to later resume
* (core-05); the live `clock` is deliberately excluded (it's re-injected on
* {@link GameSession.deserialize}). Requires the rules to implement `serialize`.
*
* @returns {{ state: object, log: Array<{ move: object, t: number }>, t0: number | null, tEnd: number | null, seq: number }}
*/
serialize() {
if (typeof this.rules.serialize !== 'function') {
throw new TypeError('GameSession.serialize: rules must implement serialize(state)')
}
return {
state: this.rules.serialize(this.state),
log: this._log.map(({ move, t }) => ({ move, t })),
t0: this._t0,
tEnd: this._tEnd,
seq: this._seq
}
}
/**
* Rebuild a session from a {@link serialize} snapshot (or its JSON round-trip).
* The clock is re-injected it's a live function, not serializable while the
* log and timing anchors are restored so `elapsed()` resumes correctly.
* Requires the rules to implement `deserialize`.
*
* @param {Rules} rules
* @param {{ state: object, log: Array<{ move: object, t: number }>, t0: number | null, tEnd: number | null }} snapshot
* @param {{ clock?: () => number }} [opts]
* @returns {GameSession}
*/
static deserialize(rules, snapshot, { clock = () => 0 } = {}) {
if (typeof rules.deserialize !== 'function') {
throw new TypeError('GameSession.deserialize: rules must implement deserialize(snapshot)')
}
if (snapshot === null || typeof snapshot !== 'object') {
throw new TypeError(`GameSession.deserialize: expected a snapshot object (got ${snapshot === null ? 'null' : typeof snapshot})`)
}
const { state, log, t0, tEnd, seq } = snapshot
if (!Array.isArray(log)) {
throw new TypeError('GameSession.deserialize: snapshot.log must be an array')
}
for (const entry of log) {
if (entry === null || typeof entry !== 'object' || typeof entry.t !== 'number' || entry.move === null || typeof entry.move !== 'object') {
throw new TypeError('GameSession.deserialize: each log entry must be { move: object, t: number }')
}
}
if (!(t0 === null || typeof t0 === 'number') || !(tEnd === null || typeof tEnd === 'number')) {
throw new TypeError('GameSession.deserialize: snapshot.t0/tEnd must each be a number or null')
}
if (!(seq === undefined || (Number.isInteger(seq) && seq >= 0))) {
throw new TypeError('GameSession.deserialize: snapshot.seq must be a non-negative integer')
}
// rules.deserialize validates and revives the game-state half.
const session = new GameSession(rules, { state: rules.deserialize(state), clock })
session._log = log.map(({ move, t }) => ({ move, t }))
session._t0 = t0
session._tEnd = tEnd
// Continue the sequence where it left off so move-event seq keeps rising
// across a resume (absent in a pre-move-event snapshot ⇒ start at 0).
session._seq = seq ?? 0
return session
}
}

View file

@ -0,0 +1,437 @@
# 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
it. The core is internally layered so the generic bottom can later be lifted out
into `@cozy-games/grid` + `@cozy-games/game-session` once a second game (Sudoku)
exists to validate the abstraction. We are **not** building the generic grid
engine now — only structuring for it.
Goals, in priority order:
1. **Headless core** — game state is a plain data model, no DOM, no wall clock.
2. **Authoritative-host capable** — the same core can run on a host that owns
the RNG, the clock, and the move sequence, so game state and timing can be
computed by an authoritative host rather than solely on the client.
3. **Backwards compatible** — the existing DOM UI, CSS, and jsdom tests keep
working; today's offline play is just "the core with a local transport."
4. **Extraction-ready** — a clean seam between generic (grid/session) and
Minesweeper-specific (mines/reveal) code.
---
## 1. Package layout & the seam
**Decision (settled): one published package, core exposed as a sub-path.** Open-
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/ # @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
index.js # the sub-path entry — public core API
grid/ # Layer 0 → future @cozy-games/grid
grid.js # Grid<Cell> container, coords, inBounds
neighbors.js # neighbor STRATEGIES (eightWay, orthogonal, …)
serialize.js
session/ # Layer 1 → future @cozy-games/game-session
session.js # GameSession: lifecycle, injected clock, move log
rng.js # seedable deterministic PRNG (mulberry32)
replay.js # replay(rules, {seed, config, log}) → validate
minesweeper/ # Layer 2 → Minesweeper-specific rules
rules.js # GameRules impl: init/apply/status/project
board.js # deterministic board gen + first-click safety
reveal.js # flood-fill, chording
client/ # DOM client internals (consume ./core)
renderer.js # events → DOM (the ONLY place document is touched) — EXTRACTED
transport.js # LocalTransport (RemoteTransport added later) — EXTRACTED
# Decision: the input state machine (mouse/touch/chording/long-press) and the
# timer stay INLINE in mnswpr.js for now — not extracted to input-adapter.js /
# timer-view.js. The input code is intricate and under-tested (no chord/touch/
# middle-click coverage yet), so extraction is deferred until those
# characterization tests exist or a concrete need arises. See §9.
```
`package.json` `exports` (sub-path is the only surface change consumers see):
```jsonc
"exports": {
".": { "default": "./dist/mnswpr.js" }, // DOM client (browser) — unchanged default
"./core": { "default": "./core/index.js" }, // headless, isomorphic core (source ESM)
"./dist/*": { "default": "./dist/*" },
"./*": { "default": "./*" }
}
```
- `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.
**The seam** = the boundary *inside* `core/` between `grid/` + `session/`
(generic) and `minesweeper/` (specific). Exposing the core via a sub-path does
**not** compromise the future extraction — the publish surface and the internal
layering are orthogonal. Rules of the seam:
- `grid/` and `session/` **never import** `minesweeper/`.
- `minesweeper/` depends on `grid/` + `session/` only through their public
interfaces (below) — no reaching into internals.
- Anything Minesweeper-specific that leaks *down* (8-way adjacency, "mine",
"reveal") is a bug in the layering. Adjacency is **injected**, not assumed.
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 `@cozy-games/mnswpr/core` re-exports from them so the
consumer-facing sub-path is unchanged.
---
## 2. Layer 0 — generic grid (`grid/`)
A dumb, dense 2D container of opaque cells. Knows nothing about game meaning.
```js
// Coord is a plain [r, c] tuple everywhere (cheap, serializable).
// Grid<Cell> — Cell is opaque to this layer.
class Grid {
constructor(rows, cols, fill) // fill: (r, c) => Cell
get rows(); get cols()
at(r, c) // → Cell (throws/undefined out of bounds)
set(r, c, cell)
inBounds(r, c) // → boolean
forEach(fn) // fn(cell, r, c)
map(fn) // → Grid of new cells
clone()
}
// Neighbor STRATEGY — the critical extraction seam. Injected, never baked in.
// eightWay is Minesweeper's; Sudoku would inject `peers` (row col box).
const eightWay = (grid, r, c) => [...8 in-bounds diagonal+orthogonal coords]
const orthogonal = (grid, r, c) => [...4 in-bounds N/E/S/W coords]
```
`serialize.js`: `toJSON(grid)` / `fromJSON(data, reviveCell)` — used by the
session for the move log and by the server for persistence/replay.
---
## 3. Layer 1 — generic session & host authority (`session/`)
The most reusable layer, and the one that makes an authoritative host possible. It
owns **lifecycle, time, and the move log**, and delegates game meaning to an
injected `GameRules` object.
### The rules contract (what any game implements)
```js
/**
* @template State, Move, Event
* A pure, deterministic game definition. NO Date, NO Math.random, NO DOM.
*/
const GameRules = {
init(seed, config), // → State (deterministic from seed)
apply(state, move, rng), // → { state, events } (pure; rng passed in)
status(state), // → 'active' | 'won' | 'lost'
project(state) // → ClientView (hides secrets; see §4)
}
```
### The session
```js
class GameSession {
/**
* @param rules a GameRules implementation
* @param opts.seed number — seeds board gen + RNG (host-held when a host owns the session)
* @param opts.config game config (level/difficulty)
* @param opts.clock () => number — INJECTED time source (whoever runs the session owns it)
*/
constructor(rules, opts)
applyMove(move) // stamps t=clock(); appends {move, t} to log;
// rules.apply(...); returns projected events + view.
status() // 'active' | 'won' | 'lost'
view() // rules.project(state) — safe to send to a client
elapsed() // authoritative: t(last decisive move) t(first move)
log() // [{move, t}] — the audit trail
result() // on terminal: { status, time, seed, config, log }
}
```
Two decisions that unlock everything:
- **Injected clock.** The session never calls `Date.now()`. The *caller* supplies
the clock. On the client it's `Date.now`. On an authoritative host it's the
host's clock — so `elapsed()` is owned by whoever runs the session.
- **Injected, seedable RNG** (`rng.js`, e.g. mulberry32 seeded by `opts.seed`).
Board generation is a pure function of `seed` (+ first click), so a run is
**bit-for-bit reproducible**. A host that owns the session holds the seed and
sends only projected views, so unrevealed board state need not be sent to the
client mid-game.
### Replay (`replay.js`)
```js
// Re-runs a game from scratch from its recorded inputs and returns the
// recomputed outcome. A host can use this to validate a submitted
// { seed, config, log }:
// - does the log actually solve the board?
// - is the move timeline monotonic and within plausible bounds?
// - does the recomputed time match the recorded time?
replay(rules, { seed, config, log }) // → { status, time, valid, reason? }
```
Because the core is deterministic, a full game can be reconstructed from its
inputs alone — no live per-move host needed; `replay()` can run wherever a host
processes a submission. It requires exactly this headless core and nothing else.
> **Determinism is a hard rule for Layers 12:** no `Date.now()`, no
> `Math.random()`, no `new Date()` inside core logic — all injected. This is what
> makes replay reproducible and tests deterministic. (Same constraint we already
> follow elsewhere in the repo.)
---
## 4. Layer 2 — Minesweeper rules (`minesweeper/`)
### State (plain data — the DOM's job is gone)
```js
// Cell — replaces the <td> + data-status/data-value attributes.
Cell = { mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }
// State
{
grid: Grid<Cell>,
config: { rows, cols, mines, id }, // from levels.js
phase: 'fresh' | 'active' | 'won' | 'lost',
minesPlaced: boolean // false until the first reveal (safety)
}
```
### Moves (intents — what the client sends)
```js
Move =
| { type: 'reveal', r, c }
| { type: 'flag', r, c } // toggle
| { type: 'chord', r, c } // reveal neighbors when flag-count satisfied
```
### Events (deltas — what the renderer/server emits)
```js
Event =
| { type: 'reveal', cells: [{ r, c, adjacent }] } // whole flood-filled region
| { type: 'flag', r, c, flagged: boolean }
| { type: 'explode', r, c, mines: [{ r, c }] } // loss reveal
| { type: 'win' }
```
Emitting **deltas** (not full state) is what lets the client render incrementally
*and* lets an authoritative host withhold the rest of the board.
### First-click safety, done right
Generate the board **on the first reveal**, from `(seed, firstClick, config)`,
excluding the first cell and its neighbors from mine placement. Replaces today's
`transferMine()` relocation. Benefits: nothing exists to leak before move 1, the
first click is provably safe, and generation stays a pure seed function.
### `reveal.js`
- Flood-fill of the connected zero-adjacency region (ports `handleEmpty`), returns
the revealed cells as one `reveal` event.
- Chording (ports the left+right behavior), returns a `reveal` or `explode`.
- Win = every non-mine cell revealed. Loss = a mine revealed.
### Hidden-information projection (`project.js`)
```js
project(state) // → ClientView
```
Returns only what a client is allowed to know: revealed cells + their adjacency,
flags, and phase. **Unrevealed mine positions are never included** (until a
terminal `explode`/`win`, when the full board is disclosed for the reveal
animation). A host sends `view()` / event deltas — never the raw state, never the
seed. A client therefore cannot see unrevealed mines even if it inspects every
byte it receives.
---
## 5. The client (`packages/mnswpr` → consumer of the core)
The client keeps today's look, CSS, and DOM shape (`<table>` with
`game-status` / `data-status` / `data-value`) — but it becomes a **consumer** of
core state, not the owner of it. Four parts:
```
InputAdapter Transport Renderer TimerView
(gestures → Move) ──▶ (Local | Remote) ──▶ Event[] ──▶ (deltas → DOM) (time → DOM)
├─ Local : in-process GameSession (offline / npm engine)
└─ Remote: HTTP/WS to a host (authoritative host)
```
### Transport — one interface, two implementations (mirrors the leaderboard adapter pattern)
```js
// The client talks ONLY to this — it never knows if the game runs here or on a server.
Transport = {
start(config) // → { view, time } begin a game
send(move) // → { events, view, time } apply a move
onEvent(cb) // (Remote may push server events; Local resolves inline)
result() // → { status, time, … } when terminal
}
```
- **`LocalTransport`** wraps a `GameSession` with `clock = Date.now`. This is
today's behavior exactly: fast, offline, timing owned by the client — fine for
offline play and the standalone npm engine.
- **`RemoteTransport`** forwards moves to a host, which holds the authoritative
`GameSession`, and streams back projected events. Timing is host-owned. Used
when a game runs on an authoritative host.
### Renderer
`render(container, view)` builds the initial `<table>`; `applyEvents(events)`
mutates the DOM from deltas. This is the *only* place `document` is touched. It
reuses the current markup/attributes so existing CSS and the jsdom tests survive.
(It is basically today's DOM-building code, inverted: driven by events instead of
owning state.)
### InputAdapter
Keep the existing mouse/touch state machine (left/right/middle, chording,
long-press-to-flag, `isBusy` debounce) — but instead of mutating the DOM, it
**emits `Move` intents** to the transport. This is the trickiest existing code;
porting it as "same gestures, different output" keeps the hard-won input feel.
### TimerView & `gameDone`
- `TimerService` splits in two: the **authoritative clock** moves into
`GameSession` (injected); the **display** becomes a dumb `TimerView` that shows
`transport`-reported time. In Remote mode it shows server time (optionally a
locally-interpolated estimate reconciled on each server message).
- The current `hooks.gameDone(game)` fires from the terminal event. In **Local**
mode the client builds `game` as today. In **Remote** mode the **host**
produces the authoritative `{ time, status }` and records the leaderboard
result — the client renders it rather than computing it.
The public constructor stays hook-shaped for compatibility, e.g.:
```js
Minesweeper(appId, version, {
transport: new LocalTransport({ level }), // or RemoteTransport({ endpoint })
levelChanged(setting) { … },
gameDone(game) { … } // Local: client-built; Remote: host-authoritative
})
```
---
## 6. Two run modes, one codebase
| | **Local / offline** | **Authoritative host** |
|---|---|---|
| Where the core runs | in the browser | on a host |
| Clock | `Date.now` | host clock |
| Board/seed | in browser | host-held, sent as rules allow |
| Transport | `LocalTransport` | `RemoteTransport` |
| Needs a host tier | no | yes (Function/Worker + session store) |
| Use | offline play, published npm engine | host-owned sessions |
The published `@cozy-games/mnswpr` stays fully functional standalone (Local mode).
Running on a host opts into Remote. Same renderer, same input, same rules.
---
## 7. Server-readiness invariants (the offline build MUST hold these)
Going offline-first is only "server-easy later" if the offline build refuses a few
tempting in-process shortcuts. Each maps to a concrete failure if violated. These
are the disciplines that make the server additive rather than a rewrite:
1. **Transport is `async`.** `send(move)` returns a Promise (or fires a callback)
even though `LocalTransport` resolves instantly. *Violation:* client code
assumes synchronous returns → every `RemoteTransport` call breaks.
2. **Only serializable messages cross the Transport.** Moves in; Events + a
projected view out; plain JSON. Never hand the client a live `GameSession`,
`Grid`, or `State`. *Violation:* nothing survives a network hop.
3. **The Renderer consumes only `project(state)` + events** — never raw mine
positions, even offline where it technically has them. *Violation:* host mode
(which withholds unrevealed board state) needs a renderer rewrite; hidden-
information projection stops being a drop-in.
4. **The core is deterministic now** — seeded RNG + injected clock + a working
`replay()`, even though offline play doesn't need them. *Violation:*
retrofitting determinism into board generation later is a rewrite, and the
replay/validation path has no substrate.
5. **The client is stateless about rules.** All win/loss/reveal logic lives in the
core; the client only renders. *Violation:* client-side rule shortcuts aren't
authoritative on a server.
A determinism guard (see §10 Testing) fails the build if `Date`/`Math.random`
appear in `core/` outside the injected `clock`/`rng` seams.
## 8. Mapping from today's engine
| Today (`packages/mnswpr/mnswpr.js`) | Moves to |
|---|---|
| `document.createElement` grid build | client **Renderer** |
| `data-status` / `data-value` / `game-status` attrs | core **State** (`Cell`, `phase`) |
| `getStatus`/`setStatus`/`isMine`/`isFlagged` | core model ops |
| `minesArray` + `transferMine` (first-click safety) | `minesweeper/board.js` (seeded gen) |
| `handleEmpty` flood-fill, chording | `minesweeper/reveal.js` |
| mouse/touch handlers, chording, long-press | client **InputAdapter** (emits Moves) |
| `TimerService` (`Date.now`, rAF, DOM write) | clock → `GameSession`; display → `TimerView` |
| `hooks.gameDone(game)` | terminal Event → Local builds `game` / Remote = server |
| `levels.js` | `minesweeper/levels.js` |
---
## 9. Migration plan (each step keeps the suite green)
1. **Core, headless & tested.** Port board gen, flood-fill, chording, win/loss
into `core/` as pure functions with unit tests over plain data (fast, no
DOM). Add seeded RNG + `replay()`. **← this diff.**
2. **Renderer + LocalTransport.** Build the event-driven Renderer that reproduces
today's exact DOM, and `LocalTransport` around `GameSession`.
3. **Port InputAdapter** to emit `Move`s into the transport instead of mutating
the DOM.
4. **Swap `apps/mnswpr/main.js`** to construct the client with `LocalTransport`.
Behavior is identical to today — the existing jsdom tests (real DOM events on
`#app`) are the regression harness and must stay green.
5. **(Later) Remote.** Add a host runtime running `GameSession` authoritatively
+ `RemoteTransport`; the host records results.
6. **(Later) Extract** `grid/` + `session/` into `@cozy-games/grid` +
`@cozy-games/game-session` once Sudoku exists.
## 10. Testing strategy
- **Core:** pure data-model unit tests — deterministic via fixed seeds; property
tests (e.g. flood-fill never reveals a mine; win ⇔ all non-mines revealed).
- **Replay:** generate a random valid game, feed its log to `replay()`, assert
`valid` and matching time; mutate the log and assert rejection.
- **Client:** keep the current jsdom tests (mount, dispatch real mouse events,
assert on cell/grid attributes) — now exercising Renderer + InputAdapter +
LocalTransport end-to-end.
- **Determinism guard:** a lint/test that fails if `Date`/`Math.random` appear in
`core/` outside the injected `clock`/`rng` seams.
## 11. Open decisions
- **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
offline-only for now (`LocalTransport`, client-owned timing — matches today's UX).
The deterministic core + `replay()` are built now so either path is a later
add-on, not a rewrite.
- **Remote transport / server host / latency & cost:** *deferred* (offline-first).
HTTP-vs-WebSocket, Netlify Function vs Worker, and the session store are picked
when we do the server; the `Transport` interface reserves the seam.

View file

@ -6,30 +6,39 @@
import './mnswpr.css' import './mnswpr.css'
import { import {
LoggerService,
StorageService, StorageService,
TimerService TimerService
} from '../utils/index.js' } from '@cozy-games/utils'
import { levels } from './levels.js' import { levels } from './levels.js'
import { MinesweeperRules } from './core/index.js'
import { LocalTransport } from './client/transport.js'
import { renderEvents, revealBoard } from './client/renderer.js'
const TEST_MODE = false // set to true if you want to test the game with visual hints const TEST_MODE = false // set to true if you want to test the game with visual hints
const MOBILE_BUSY_DELAY = 250 const MOBILE_BUSY_DELAY = 250
const PC_BUSY_DELAY = 500 const PC_BUSY_DELAY = 500
/** /**
* Create Minesweeper game board * Create Minesweeper game board.
*
* This is the DOM CLIENT: it builds the board, handles input, and renders. All
* game state and rules live in the headless core (`./core`); the client drives
* it through a Transport and paints the events it emits (see
* docs/headless-core-and-client-design.md).
*
* @param {String} appId * @param {String} appId
* @param {String} version * @param {String} version
* @param {{ * @param {{
* levelChanged: (setting: any) => void, * levelChanged: (setting: any) => void,
* gameDone: (game: any) => void * gameDone: (game: any) => void
* } | undefined } hooks * } | undefined } hooks
* @param {{ seed?: number }} [options] - `seed` pins the (deterministic) board,
* mainly for tests/replay; omit for a fresh random game each time.
*/ */
const Minesweeper = function(appId, version, hooks = undefined) { const Minesweeper = function(appId, version, hooks = undefined, options = {}) {
const _this = this const _this = this
const storageService = new StorageService() const storageService = new StorageService()
const timerService = new TimerService() const timerService = new TimerService()
const loggerService = new LoggerService()
if (!hooks) { if (!hooks) {
hooks = { hooks = {
@ -38,6 +47,8 @@ const Minesweeper = function(appId, version, hooks = undefined) {
} }
} }
const configuredSeed = options.seed
let grid = document.createElement('table') let grid = document.createElement('table')
grid.setAttribute('id', 'grid') grid.setAttribute('id', 'grid')
let flagsDisplay = document.createElement('span') let flagsDisplay = document.createElement('span')
@ -66,7 +77,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
highlightSurroundingCell, // middle-click down highlightSurroundingCell, // middle-click down
rightClickCell // right-click down rightClickCell // right-click down
] ]
let firstClick = true
let isBusy = false let isBusy = false
let clickedCell let clickedCell
let cachedSetting = storageService.getFromLocal('setting') let cachedSetting = storageService.getFromLocal('setting')
@ -82,12 +92,10 @@ const Minesweeper = function(appId, version, hooks = undefined) {
} }
storageService.saveToLocal('setting', setting) storageService.saveToLocal('setting', setting)
let flagsCount = setting.mines let flagsCount = setting.mines
// Mine positions stored as a Set of numeric keys (row * cols + col) for O(1) lookup
let mines = new Set()
// Cells currently highlighted, so removeHighlights only resets these (<=9) instead of the whole grid // Cells currently highlighted, so removeHighlights only resets these (<=9) instead of the whole grid
let highlightedCells = [] let highlightedCells = []
// Count of safe (non-mine) cells revealed, so the win check is O(1) instead of scanning the whole grid // The headless game for the current board; recreated on every generateGrid.
let revealedSafeCount = 0 let transport
this.initialize = function() { this.initialize = function() {
const headingElement = document.createElement('h1') const headingElement = document.createElement('h1')
@ -129,8 +137,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
levelsDropdown.add(levelOption, null) levelsDropdown.add(levelOption, null)
}) })
if (TEST_MODE) { if (TEST_MODE) {
const testLevel = document.createElement('span') const testLevel = document.createElement('span')
testLevel.innerText = 'Test Mode' testLevel.innerText = 'Test Mode'
@ -144,24 +150,20 @@ const Minesweeper = function(appId, version, hooks = undefined) {
function initializeToolbar() { function initializeToolbar() {
const toolbar = document.createElement('div') const toolbar = document.createElement('div')
const toolbarItems = []
const flagsWrapper = document.createElement('div') const flagsWrapper = document.createElement('div')
flagsWrapper.append(flagsDisplay) flagsWrapper.append(flagsDisplay)
flagsWrapper.style.height = '20px' flagsWrapper.style.height = '20px'
toolbar.append(flagsWrapper) toolbar.append(flagsWrapper)
toolbarItems.push(flagsWrapper)
const smileyWrapper = document.createElement('div') const smileyWrapper = document.createElement('div')
smileyWrapper.append(smileyDisplay) smileyWrapper.append(smileyDisplay)
// toolbar.append(smileyWrapper); // toolbar.append(smileyWrapper);
// toolbarItems.push(smileyWrapper);
const timerWrapper = document.createElement('div') const timerWrapper = document.createElement('div')
timerWrapper.append(timerDisplay) timerWrapper.append(timerDisplay)
timerWrapper.style.height = '20px' timerWrapper.style.height = '20px'
toolbar.append(timerWrapper) toolbar.append(timerWrapper)
toolbarItems.push(timerWrapper)
toolbar.style.cursor = 'pointer' toolbar.style.cursor = 'pointer'
toolbar.style.padding = '10px 35px' toolbar.style.padding = '10px 35px'
@ -182,7 +184,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
generateGrid({ initial: true }) generateGrid({ initial: true })
} }
/** /**
* Generate the Game Board * Generate the Game Board
* @param {{ * @param {{
@ -190,13 +191,10 @@ const Minesweeper = function(appId, version, hooks = undefined) {
* }} options - Game Board Options * }} options - Game Board Options
*/ */
function generateGrid(options = { initial: false }) { function generateGrid(options = { initial: false }) {
firstClick = true
grid.innerHTML = '' grid.innerHTML = ''
grid.oncontextmenu = () => false grid.oncontextmenu = () => false
flagsCount = setting.mines flagsCount = setting.mines
mines.clear()
highlightedCells = [] highlightedCells = []
revealedSafeCount = 0
for (let i = 0; i < setting.rows; i++) { for (let i = 0; i < setting.rows; i++) {
let row = grid.insertRow(i) let row = grid.insertRow(i)
@ -224,6 +222,12 @@ const Minesweeper = function(appId, version, hooks = undefined) {
appElement.style.margin = '0 auto' appElement.style.margin = '0 auto'
} }
// A fresh headless game. Mines are placed by the core on the first reveal
// (first-click safe), so there is nothing to seed into the DOM here.
const gameSeed = configuredSeed !== undefined ? configuredSeed : Math.floor(Math.random() * 0x7fffffff)
transport = new LocalTransport(MinesweeperRules, { seed: gameSeed, config: setting, clock: () => Date.now() })
transport.onEvent(onCoreEvent)
/** /**
* TODO: add hook afterGridGenerated * TODO: add hook afterGridGenerated
* - for initializing the leaderboard * - for initializing the leaderboard
@ -233,8 +237,51 @@ const Minesweeper = function(appId, version, hooks = undefined) {
timerService.initialize(timerDisplay) timerService.initialize(timerDisplay)
updateFlagsCountDisplay() updateFlagsCountDisplay()
addMines(setting.mines) }
/**
* Paint the events from a core move, and drive the win/loss transition. Fired
* synchronously by the LocalTransport; a RemoteTransport would fire it on the
* server's reply with no change here.
* @param {{ events: object[], view: object }} payload
*/
function onCoreEvent(payload) {
renderEvents(grid, payload.events)
for (const ev of payload.events) {
if (ev.type === 'explode' || ev.type === 'win') {
finishGame(payload.view)
break
}
}
}
/** Terminal transition: reveal the board, update displays, fire gameDone. */
function finishGame(view) {
const won = view.phase === 'won'
if (won) {
grid.setAttribute('game-status', 'win')
updateFlagsCountDisplay(0)
} else {
flagsDisplay.innerHTML = '&#128561;'
grid.setAttribute('game-status', 'over')
}
revealBoard(grid, view, setting)
grid.setAttribute('game-status', 'done')
const time = timerService.stop()
const game = {
time,
status: won ? 'win' : 'loss',
level: setting.id,
time_stamp: new Date(),
isMobile
}
/**
* TODO: add hook after gameSession send back `game`
* - for sending the game score to the db
*/
hooks.gameDone(game)
} }
function setBusy() { function setBusy() {
@ -403,11 +450,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
cell.onmousemove = function(e) { cell.onmousemove = function(e) {
if ((pressed || bothPressed) && typeof e === 'object') { if ((pressed || bothPressed) && typeof e === 'object') {
removeHighlights() removeHighlights()
/*
if (!isEqual(clickedCell, cell)) {
clickedCell = undefined;
}
*/
if (pressed == 'middle' || (isLeft && isRight)) { if (pressed == 'middle' || (isLeft && isRight)) {
highlightSurroundingCell(this) highlightSurroundingCell(this)
} else if (pressed == 'left') { } else if (pressed == 'left') {
@ -456,187 +498,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
skip = true skip = true
} }
function addMines(minesCount) {
//Add mines randomly
for (let i=0; i<minesCount; i++) {
let row = Math.floor(Math.random() * setting.rows)
let col = Math.floor(Math.random() * setting.cols)
let cell = grid.rows[row].cells[col]
if (isMine(cell)) {
transferMine()
} else {
mines.add(mineKey(row, col))
}
if (TEST_MODE){
cell.innerHTML = 'X'
}
}
if (TEST_MODE) {
printMines()
}
}
function revealMines() {
if (grid.getAttribute('game-status') == 'done') return
//Highlight all mines in red
const win = grid.getAttribute('game-status') == 'win'
for (let i=0; i<setting.rows; i++) {
for(let j=0; j<setting.cols; j++) {
let cell = grid.rows[i].cells[j]
if (win) {
handleWinRevelation(cell)
} else {
handleLostRevelation(cell)
}
}
}
grid.setAttribute('game-status', 'done')
const time = timerService.stop()
const game = {
time,
status: win ? 'win' : 'loss',
level: setting.id,
time_stamp: new Date(),
isMobile
}
/**
* TODO: add hook after gameSession send back `game`
* - for sending the game score to the db
*/
hooks.gameDone(game)
}
function handleWinRevelation(cell) {
updateFlagsCountDisplay(0)
if (isMine(cell)) {
cell.innerHTML = ':)'
cell.className = 'correct'
setStatus(cell, 'clicked')
let correct = document.createAttribute('title')
correct.value = 'Correct'
cell.setAttributeNode(correct)
setStatus(cell, 'clicked')
}
}
function handleLostRevelation(cell) {
if (isFlagged(cell)) {
cell.className = 'flag'
if (!isMine(cell)) {
cell.innerHTML = 'X'
cell.className = 'wrong'
let wrong = document.createAttribute('title')
wrong.value = 'Wrong'
cell.setAttributeNode(wrong)
} else {
cell.innerHTML = ':)'
cell.className = 'correct'
let correct = document.createAttribute('title')
correct.value = 'Correct'
cell.setAttributeNode(correct)
}
} else {
if (isMine(cell)) {
cell.className = 'mine'
setStatus(cell, 'clicked')
}
}
}
function isOpen(cell) {
return cell.innerHTML !== '' && !isFlagged(cell)
}
function isFlagged(cell) {
return getStatus(cell) == 'flagged'
}
function mineKey(row, col) {
return row * setting.cols + col
}
function isMine(cell) {
return mines.has(mineKey(getRow(cell), getCol(cell)))
}
function checkLevelCompletion() {
const safeCells = setting.rows * setting.cols - setting.mines
if (revealedSafeCount >= safeCells && grid.getAttribute('game-status') == 'active') {
grid.setAttribute('game-status', 'win')
revealMines()
}
}
function setStatus(cell, status) {
cell.setAttribute('data-status', status)
}
function getCol(cell) {
return cell.cellIndex
}
function getRow(cell) {
return cell.parentNode.rowIndex
}
function getStatus(cell) {
if (!cell) return undefined
return cell.getAttribute('data-status')
}
function middleClickCell(cell) {
if (grid.getAttribute('game-status') != 'active' || getStatus(cell) !== 'clicked') {
return
}
// check for number of surrounding flags
const valueString = cell.getAttribute('data-value')
let cellValue = parseInt(valueString, 10)
let flagCount = countFlagsAround(cell)
if (flagCount === cellValue) {
clickSurrounding(cell)
if (TEST_MODE) loggerService.debug('middle click', cell)
}
}
function countFlagsAround(cell) {
let flagCount = 0
let cellRow = cell.parentNode.rowIndex
let cellCol = cell.cellIndex
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
if (isFlagged(grid.rows[i].cells[j])) flagCount++
}
}
return flagCount
}
function clickSurrounding(cell) {
if (grid.getAttribute('game-status') != 'active') return
let cellRow = cell.parentNode.rowIndex
let cellCol = cell.cellIndex
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
let currentCell = grid.rows[i].cells[j]
if (getStatus(currentCell) == 'flagged') continue
openCell(currentCell)
}
}
}
function increaseFlagsCount() {
flagsCount++
updateFlagsCountDisplay()
}
function decreaseFlagsCount() {
flagsCount--
updateFlagsCountDisplay()
}
function activateGame() { function activateGame() {
grid.setAttribute('game-status', 'active') grid.setAttribute('game-status', 'active')
// start timer // start timer
@ -659,7 +520,7 @@ const Minesweeper = function(appId, version, hooks = undefined) {
function highlightCell(cell) { function highlightCell(cell) {
if (isFlagged(cell)) return if (isFlagged(cell)) return
if (!gameIsDone() && getStatus(cell) == 'default') { if (!gameIsDone() && getStatus(cell) == 'default') {
setStatus(cell, 'highlighted') // currentCell.classList.add('highlight'); setStatus(cell, 'highlighted')
highlightedCells.push(cell) highlightedCells.push(cell)
} }
} }
@ -677,28 +538,61 @@ const Minesweeper = function(appId, version, hooks = undefined) {
} }
} }
function increaseFlagsCount() {
flagsCount++
updateFlagsCountDisplay()
}
function decreaseFlagsCount() {
flagsCount--
updateFlagsCountDisplay()
}
function isFlagged(cell) {
return getStatus(cell) == 'flagged'
}
function setStatus(cell, status) {
cell.setAttribute('data-status', status)
}
function getCol(cell) {
return cell.cellIndex
}
function getRow(cell) {
return cell.parentNode.rowIndex
}
function getStatus(cell) {
if (!cell) return undefined
return cell.getAttribute('data-status')
}
// ---- input → core move adapters ----
function rightClickCell(cell) { function rightClickCell(cell) {
if (isFlagged(cell)) setBusy() if (isFlagged(cell)) setBusy()
if (grid.getAttribute('game-status') == 'inactive') { if (grid.getAttribute('game-status') == 'inactive') {
activateGame() activateGame()
} }
if (grid.getAttribute('game-status') != 'active') return if (grid.getAttribute('game-status') != 'active') return
if (getStatus(cell) != 'clicked' && getStatus(cell) != 'empty') {
if (getStatus(cell) == 'default' || getStatus(cell) == 'highlighted') { const status = getStatus(cell)
if (status == 'clicked' || status == 'empty') return
const move = { type: 'flag', r: getRow(cell), c: getCol(cell) }
if (status == 'default' || status == 'highlighted') {
if (flagsCount <= 0) return if (flagsCount <= 0) return
cell.className = 'flag' transport.send(move) // renderer paints the flag
decreaseFlagsCount() decreaseFlagsCount()
setStatus(cell, 'flagged')
} else { } else {
cell.className = '' transport.send(move) // toggles the flag off
increaseFlagsCount() increaseFlagsCount()
setStatus(cell, 'default')
} }
if ('vibrate' in navigator) { if ('vibrate' in navigator) {
navigator.vibrate(100) navigator.vibrate(100)
} }
if (TEST_MODE) loggerService.debug('right click', cell)
}
} }
function clickCell(cell) { function clickCell(cell) {
@ -707,164 +601,26 @@ const Minesweeper = function(appId, version, hooks = undefined) {
activateGame() activateGame()
} }
if (grid.getAttribute('game-status') != 'active') return if (grid.getAttribute('game-status') != 'active') return
//Check if the end-user clicked on a mine if (isFlagged(cell) || grid.getAttribute('game-status') == 'over') {
if (TEST_MODE) loggerService.debug('click', cell)
if (getStatus(cell) == 'flagged' || grid.getAttribute('game-status') == 'over') {
return
} else if (getStatus(cell) == 'clicked') {
middleClickCell(cell)
return
} else if (isMine(cell) && firstClick) {
// cell.setAttribute('data-mine', 'false');
mines.delete(mineKey(getRow(cell), getCol(cell)))
transferMine(cell)
if (TEST_MODE) printMines()
}
openCell(cell)
}
function printMines() {
let count = 0
for (let i = 0; i < setting.rows; i++) {
for (let j = 0; j < setting.cols; j++) {
if (isMine(grid.rows[i].cells[j])) {
loggerService.debug(count++ + ' - mine: [' + i + ',' + j + ']')
}
}
}
}
function transferMine(cell = undefined) {
let found = false
do {
let row = Math.floor(Math.random() * setting.rows)
let col = Math.floor(Math.random() * setting.cols)
const transferMineToCell = grid.rows[row].cells[col]
if (isMine(transferMineToCell) || transferMineToCell === cell || isNeighbor(cell, transferMineToCell)) {
continue
} else {
mines.add(mineKey(row, col))
if (TEST_MODE){
transferMineToCell.innerHTML = 'X'
if (TEST_MODE) loggerService.debug('transferred mine to: ' + row + ', ' + col)
}
// TODO: refactor maybe
// eslint-disable-next-line no-useless-assignment
found = true
return
}
} while(!found)
}
function isNeighbor(cell, nextCell) {
if (cell === undefined) {
return
}
const rowDifference = Math.abs(getRow(cell) - getRow(nextCell))
const colDifference = Math.abs(getCol(cell) - getCol(nextCell))
return (rowDifference === 1) && (colDifference === 1)
}
function countMinesAround(cell) {
let mineCount=0
let cellRow = cell.parentNode.rowIndex
let cellCol = cell.cellIndex
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1,setting.rows-1); i++) {
const rows = grid.rows[i]
if (!rows) continue
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1,setting.cols-1); j++) {
const cell = rows.cells[j]
const mine = isMine(cell)
if (cell && mine) {
mineCount++
}
}
}
return mineCount
}
function updateCellValue(cell, value) {
const spanElement = document.createElement('span')
spanElement.innerHTML = value
cell.innerHTML = ''
cell.appendChild(spanElement)
}
/**
* Reveal a single known-non-mine cell. Returns true when the cell is blank
* (no adjacent mines), which is the signal to keep flooding from it.
* @param {HTMLTableCellElement} cell
*/
function revealSafeCell(cell) {
// A cell can be re-opened via chording, so only count the first reveal
const wasOpen = getStatus(cell) == 'clicked' || getStatus(cell) == 'empty'
cell.className = 'clicked'
setStatus(cell, 'clicked')
if (!wasOpen) revealedSafeCount++
const mineCount = countMinesAround(cell)
if (mineCount == 0) {
updateCellValue(cell, ' ')
setStatus(cell, 'empty')
return true
}
updateCellValue(cell, mineCount.toString())
const dataValue = document.createAttribute('data-value')
dataValue.value = mineCount.toString()
cell.setAttributeNode(dataValue)
return false
}
/**
* Iteratively reveal the connected region of blank cells, starting from a
* cell that has already been revealed as blank. Uses an explicit queue with
* isOpen() as the visited guard instead of recursing back through clickCell,
* so there are no redundant per-cell checks and no deep call stack.
* @param {HTMLTableCellElement} startCell
*/
function handleEmpty(startCell) {
const queue = [startCell]
while (queue.length) {
const cell = queue.shift()
const cellRow = cell.parentNode.rowIndex
const cellCol = cell.cellIndex
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
const rows = grid.rows[i]
if (!rows) continue
for (let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
const neighbor = rows.cells[j]
if (!neighbor || neighbor === cell) continue
if (isFlagged(neighbor)) {
setBusy()
continue
}
if (isOpen(neighbor)) continue
// blank cells have no adjacent mines, so every neighbor here is safe
if (revealSafeCell(neighbor)) queue.push(neighbor)
}
}
}
}
function openCell(cell) {
if (grid.getAttribute('game-status') != 'active') return
firstClick = false
if (isMine(cell)) {
cell.className = 'clicked'
setStatus(cell, 'clicked')
revealMines()
flagsDisplay.innerHTML = '&#128561;'
grid.setAttribute('game-status', 'over')
return return
} }
if (revealSafeCell(cell)) handleEmpty(cell) const r = getRow(cell)
checkLevelCompletion() const c = getCol(cell)
// An already-open number chords; anything else is a reveal. The core places
// mines on the first reveal (first-click safe), so no transfer is needed.
if (getStatus(cell) == 'clicked') {
transport.send({ type: 'chord', r, c })
return
}
transport.send({ type: 'reveal', r, c })
}
function middleClickCell(cell) {
if (grid.getAttribute('game-status') != 'active' || getStatus(cell) !== 'clicked') {
return
}
transport.send({ type: 'chord', r: getRow(cell), c: getCol(cell) })
} }
} }

View file

@ -1,5 +1,5 @@
{ {
"name": "@ayo-run/mnswpr", "name": "@cozy-games/mnswpr",
"version": "0.4.36", "version": "0.4.36",
"description": "Classic Minesweeper browser game", "description": "Classic Minesweeper browser game",
"author": "Ayo", "author": "Ayo",
@ -10,13 +10,20 @@
}, },
"homepage": "https://mnswpr.com", "homepage": "https://mnswpr.com",
"scripts": { "scripts": {
"release": "bumpp && node ../scripts/release.js" "build": "vite build",
"release": "bumpp && node ../../scripts/release.js"
}, },
"main": "mnswpr.js", "main": "mnswpr.js",
"devDependencies": {
"@cozy-games/utils": "workspace:*"
},
"exports": { "exports": {
".": { ".": {
"default": "./dist/mnswpr.js" "default": "./dist/mnswpr.js"
}, },
"./core": {
"default": "./core/index.js"
},
"./dist/*": { "./dist/*": {
"default": "./dist/*" "default": "./dist/*"
}, },

View file

@ -0,0 +1,788 @@
import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import {
Grid, eightWay, orthogonal,
GameSession, replay, mulberry32,
MinesweeperRules, generateBoard, validateLayout, MOVE_EVENT_TYPES, levels
} from '../core/index.js'
import { placeMines, excludeAround } from '../core/minesweeper/board.js'
const beginner = levels.beginner
// Reveal every non-mine cell of a session's board, reading the (already-placed)
// state — a deterministic way to drive a game to a win without hardcoding the
// layout. Returns the events for assertions.
function playToWin(session, clock) {
// First reveal seeds the board (first-click safe); pick a corner.
session.applyMove({ type: 'reveal', r: 0, c: 0 })
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') {
if (clock) clock.tick()
session.applyMove({ type: 'reveal', r, c })
}
}
}
}
describe('grid + neighbors (Layer 0)', () => {
it('addresses cells and reports bounds', () => {
const g = new Grid(3, 4, (r, c) => `${r},${c}`)
expect(g.rows).toBe(3)
expect(g.cols).toBe(4)
expect(g.at(2, 3)).toBe('2,3')
expect(g.inBounds(2, 3)).toBe(true)
expect(g.inBounds(3, 0)).toBe(false)
expect(g.inBounds(-1, 0)).toBe(false)
})
it('eightWay yields 8 neighbors interior, 3 in a corner', () => {
const g = new Grid(5, 5)
expect(eightWay(g, 2, 2)).toHaveLength(8)
expect(eightWay(g, 0, 0)).toHaveLength(3)
expect(orthogonal(g, 2, 2)).toHaveLength(4)
expect(orthogonal(g, 0, 0)).toHaveLength(2)
})
})
describe('rng (Layer 1)', () => {
it('is deterministic for a given seed', () => {
const a = mulberry32(12345)
const b = mulberry32(12345)
const seqA = [a(), a(), a()]
const seqB = [b(), b(), b()]
expect(seqA).toEqual(seqB)
expect(mulberry32(1)()).not.toBe(mulberry32(2)())
})
})
describe('board generation (Layer 2)', () => {
it('places exactly `mines` mines, deterministically from the seed', () => {
const mk = () => {
const g = new Grid(beginner.rows, beginner.cols, () => ({ mine: false, adjacent: 0, status: 'hidden' }))
const placed = placeMines(42, beginner, excludeAround(beginner, 0, 0), g)
return { g, placed }
}
const one = mk()
const two = mk()
expect(one.placed.size).toBe(beginner.mines)
expect([...one.placed].sort()).toEqual([...two.placed].sort())
})
it('keeps the first-click 3x3 neighborhood mine-free', () => {
const g = new Grid(beginner.rows, beginner.cols, () => ({ mine: false, adjacent: 0, status: 'hidden' }))
placeMines(7, beginner, excludeAround(beginner, 4, 4), g)
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
expect(g.at(4 + dr, 4 + dc).mine).toBe(false)
}
}
})
it('computes adjacency as the 8-way mine count', () => {
const g = new Grid(beginner.rows, beginner.cols, () => ({ mine: false, adjacent: 0, status: 'hidden' }))
placeMines(99, beginner, excludeAround(beginner, 0, 0), g)
g.forEach((cell, r, c) => {
if (cell.mine) return
let n = 0
for (const [nr, nc] of eightWay(g, r, c)) if (g.at(nr, nc).mine) n++
expect(cell.adjacent).toBe(n)
})
})
})
describe('generateBoard (Layer 2, pure)', () => {
it('is callable in plain Node and returns a plain layout object', () => {
// Non-square (rows ≠ cols) pins the rows-first orientation.
const layout = generateBoard(4, 7, 5, { seed: 42 })
expect(layout.rows).toBe(4)
expect(layout.cols).toBe(7)
expect(layout.mines).toBe(5)
expect(layout.cells).toHaveLength(4)
expect(layout.cells.every(row => row.length === 7)).toBe(true)
// plain data only — no Grid instance, no class methods leaking out
expect(layout.cells[0][0]).toEqual({ mine: expect.any(Boolean), adjacent: expect.any(Number) })
expect(layout).toEqual(JSON.parse(JSON.stringify(layout)))
// places exactly `mines` mines
expect(layout.mineLocations).toHaveLength(5)
let mineCount = 0
for (const row of layout.cells) for (const cell of row) if (cell.mine) mineCount++
expect(mineCount).toBe(5)
})
it('same injected RNG (seed) → identical layout', () => {
const a = generateBoard(16, 16, 40, { rng: mulberry32(123) })
const b = generateBoard(16, 16, 40, { rng: mulberry32(123) })
expect(a).toEqual(b)
// and the seed convenience wrapper matches an explicitly injected mulberry32
expect(generateBoard(16, 16, 40, { seed: 123 })).toEqual(a)
// a different seed diverges
expect(generateBoard(16, 16, 40, { seed: 124 })).not.toEqual(a)
})
it('computes adjacency as the 8-way mine count', () => {
const layout = generateBoard(9, 9, 10, { seed: 99 })
const g = new Grid(9, 9, (r, c) => layout.cells[r][c])
layout.cells.forEach((row, r) => row.forEach((cell, c) => {
if (cell.mine) return
let n = 0
for (const [nr, nc] of eightWay(g, r, c)) if (g.at(nr, nc).mine) n++
expect(cell.adjacent).toBe(n)
}))
})
it('honors an exclude set (e.g. first-click safety)', () => {
const exclude = new Set([0]) // key 0 == cell (0,0)
const layout = generateBoard(9, 9, 10, { seed: 7, exclude })
expect(layout.cells[0][0].mine).toBe(false)
})
it('rejects impossible dimensions and mine counts', () => {
expect(() => generateBoard(0, 9, 1)).toThrow(RangeError)
expect(() => generateBoard(3, 3, 10)).toThrow(RangeError) // more mines than cells
expect(() => generateBoard(3, 3, -1)).toThrow(RangeError)
})
})
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)
const { state, events } = MinesweeperRules.apply(s, { type: 'reveal', r: 0, c: 0 })
expect(state.phase).toBe('active')
expect(state.grid.at(0, 0).mine).toBe(false)
expect(events[0].type).toBe('reveal')
// first click is inside a mine-free 3x3, so it's blank and floods
expect(events[0].cells.length).toBeGreaterThan(1)
})
it('revealing a mine loses and emits explode with all mines', () => {
const s = MinesweeperRules.init(5, beginner)
MinesweeperRules.apply(s, { type: 'reveal', r: 0, c: 0 })
const mine = firstMine(s.grid)
const { state, events } = MinesweeperRules.apply(s, { type: 'reveal', r: mine.r, c: mine.c })
expect(state.phase).toBe('lost')
expect(events[0].type).toBe('explode')
expect(events[0].mines.length).toBe(beginner.mines)
})
it('toggles flags and blocks revealing a flagged cell', () => {
let s = MinesweeperRules.init(1, beginner)
s = MinesweeperRules.apply(s, { type: 'reveal', r: 0, c: 0 }).state
const hidden = firstHidden(s.grid)
let r = MinesweeperRules.apply(s, { type: 'flag', r: hidden.r, c: hidden.c })
expect(r.events[0]).toMatchObject({ type: 'flag', flagged: true })
// revealing a flagged cell is a no-op
r = MinesweeperRules.apply(r.state, { type: 'reveal', r: hidden.r, c: hidden.c })
expect(r.events).toHaveLength(0)
expect(r.state.grid.at(hidden.r, hidden.c).status).toBe('flagged')
})
it('project never leaks an unrevealed mine mid-game, but reveals mines when over', () => {
const s = MinesweeperRules.init(8, beginner)
const active = MinesweeperRules.apply(s, { type: 'reveal', r: 0, c: 0 }).state
const view = MinesweeperRules.project(active)
for (const cell of view.cells) {
if (cell.status !== 'revealed') expect(cell.mine).not.toBe(true)
}
// now lose and re-project
const mine = firstMine(active.grid)
const lost = MinesweeperRules.apply(active, { type: 'reveal', r: mine.r, c: mine.c }).state
const overView = MinesweeperRules.project(lost)
expect(overView.cells.some(c => c.status === 'hidden' && c.mine === true)).toBe(true)
})
})
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()
const session = new GameSession(MinesweeperRules, { seed: 21, config: beginner, clock })
expect(session.elapsed()).toBe(0) // no move yet
playToWin(session, clock)
expect(session.status()).toBe('won')
// time is (terminal tick first tick), owned by the clock, not the client
expect(session.elapsed()).toBeGreaterThan(0)
const result = session.result()
expect(result.status).toBe('won')
expect(result.seed).toBe(21)
})
})
describe('replay verification (Layer 1)', () => {
it('accepts a genuine winning log and recomputes the same time', () => {
const clock = makeClock()
const session = new GameSession(MinesweeperRules, { seed: 33, config: beginner, clock })
playToWin(session, clock)
const submission = session.result()
const verdict = replay(MinesweeperRules, submission)
expect(verdict.valid).toBe(true)
expect(verdict.status).toBe('won')
expect(verdict.time).toBe(submission.time)
})
it('rejects a truncated (non-terminal) log', () => {
const clock = makeClock()
const session = new GameSession(MinesweeperRules, { seed: 33, config: beginner, clock })
playToWin(session, clock)
const submission = session.result()
const truncated = { ...submission, log: submission.log.slice(0, 2) }
const verdict = replay(MinesweeperRules, truncated)
expect(verdict.valid).toBe(false)
expect(verdict.reason).toMatch(/terminal/)
})
it('rejects non-monotonic timestamps', () => {
const clock = makeClock()
const session = new GameSession(MinesweeperRules, { seed: 33, config: beginner, clock })
playToWin(session, clock)
const submission = session.result()
const tampered = { ...submission, log: submission.log.map((e, i) => ({ ...e, t: i === 1 ? -5 : e.t })) }
const verdict = replay(MinesweeperRules, tampered)
expect(verdict.valid).toBe(false)
expect(verdict.reason).toMatch(/monotonic/)
})
it('is deterministic: same seed + same log ⇒ same board', () => {
const a = MinesweeperRules.init(777, beginner)
const b = MinesweeperRules.init(777, beginner)
MinesweeperRules.apply(a, { type: 'reveal', r: 2, c: 2 })
MinesweeperRules.apply(b, { type: 'reveal', r: 2, c: 2 })
const minesA = []
const minesB = []
a.grid.forEach((cell, r, c) => { if (cell.mine) minesA.push(r * beginner.cols + c) })
b.grid.forEach((cell, r, c) => { if (cell.mine) minesB.push(r * beginner.cols + c) })
expect(minesA).toEqual(minesB)
})
})
describe('determinism guard (invariant #4)', () => {
it('no Date/Math.random in core/ outside the rng seam', () => {
const coreDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'core')
const offenders = []
walk(coreDir, file => {
if (!file.endsWith('.js')) return
// Scan code only — strip comments so prose that *names* these APIs
// (e.g. "uses Math.imul, not Math.random") isn't a false positive.
const code = readFileSync(file, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')
if (/\bMath\.random\b/.test(code) || /\bDate\.now\b/.test(code) || /\bnew Date\b/.test(code)) {
offenders.push(file)
}
})
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(start = 1000) {
let t = start
return Object.assign(() => t, { tick() { t += 50 } })
}
function firstMine(grid) {
let found = null
grid.forEach((cell, r, c) => { if (!found && cell.mine) found = { r, c } })
return found
}
function firstHidden(grid) {
let found = null
grid.forEach((cell, r, c) => { if (!found && cell.status === 'hidden' && !cell.mine) found = { r, c } })
return found
}
function walk(dir, fn) {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p, fn)
else fn(p)
}
}

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { levels } from '../lib/levels.js' import { levels } from '../levels.js'
describe('levels', () => { describe('levels', () => {
it('defines the four expected difficulty presets', () => { it('defines the four expected difficulty presets', () => {

View file

@ -1,5 +1,20 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import Minesweeper from '../lib/mnswpr.js' import Minesweeper from '../mnswpr.js'
import { MinesweeperRules } from '../core/index.js'
// Ask the (deterministic) core for a seed whose first-click-safe board places
// the single mine at `targetCol`. Lets the DOM tests below pin a layout honestly
// via the client's `seed` option, instead of hoping a Math.random value lands it.
function findSeedForMine(setting, firstClick, targetCol) {
for (let seed = 1; seed < 100000; seed++) {
const state = MinesweeperRules.init(seed, setting)
MinesweeperRules.apply(state, { type: 'reveal', r: firstClick.r, c: firstClick.c })
let mineCol = -1
state.grid.forEach((cell, r, c) => { if (cell.mine) mineCol = c })
if (mineCol === targetCol) return seed
}
throw new Error('no seed found for target mine column')
}
// Build a fresh board mounted on #app and return its grid <table>. // Build a fresh board mounted on #app and return its grid <table>.
function mountGame() { function mountGame() {
@ -38,11 +53,12 @@ describe('Minesweeper board', () => {
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
// Mount a game on a custom board injected via the cached 'setting' localStorage key. // Mount a game on a custom board injected via the cached 'setting' localStorage
function mountCustomGame(setting, hooks) { // key. `options.seed` pins the (deterministic) mine layout.
function mountCustomGame(setting, hooks, options) {
localStorage.setItem('setting', JSON.stringify(setting)) localStorage.setItem('setting', JSON.stringify(setting))
document.body.innerHTML = '<div id="app"></div>' document.body.innerHTML = '<div id="app"></div>'
const game = new Minesweeper('app', 'dev', hooks) const game = new Minesweeper('app', 'dev', hooks, options)
game.initialize() game.initialize()
return document.getElementById('grid') return document.getElementById('grid')
} }
@ -143,25 +159,27 @@ describe('Minesweeper board', () => {
}) })
it('does not declare a win until the last safe cell is revealed', () => { it('does not declare a win until the last safe cell is revealed', () => {
// 1x3 board with the single mine pinned to the middle column. // 1x3 board; pin the single mine to the middle column so neither end cascades.
vi.spyOn(Math, 'random').mockReturnValue(0.5) const setting = { rows: 1, cols: 3, mines: 1, id: 'test', name: 'test' }
const grid = mountCustomGame({ rows: 1, cols: 3, mines: 1, id: 'test', name: 'test' }) const seed = findSeedForMine(setting, { r: 0, c: 0 }, 1)
const grid = mountCustomGame(setting, undefined, { seed })
// First safe cell: adjacent to the mine, shows "1", no cascade -> not yet won. // First safe cell (col 0): adjacent to the mine, shows "1", no cascade -> not yet won.
leftClick(grid.rows[0].cells[0]) leftClick(grid.rows[0].cells[0])
expect(grid.getAttribute('game-status')).toBe('active') expect(grid.getAttribute('game-status')).toBe('active')
// Revealing the remaining safe cell completes the board. // Revealing the remaining safe cell (col 2) completes the board.
leftClick(grid.rows[0].cells[2]) leftClick(grid.rows[0].cells[2])
expect(grid.getAttribute('game-status')).toBe('done') expect(grid.getAttribute('game-status')).toBe('done')
}) })
it('flood fill stops at a flagged cell and never reveals it', () => { it('flood fill stops at a flagged cell and never reveals it', () => {
// 1x4 board with the mine pinned to the last column. // 1x4 board; pin the mine to the last column so cols 0-2 are a safe run.
vi.spyOn(Math, 'random').mockReturnValue(0.99) const setting = { rows: 1, cols: 4, mines: 1, id: 'test', name: 'test' }
const grid = mountCustomGame({ rows: 1, cols: 4, mines: 1, id: 'test', name: 'test' }) const seed = findSeedForMine(setting, { r: 0, c: 0 }, 3)
const grid = mountCustomGame(setting, undefined, { seed })
// Flag a safe cell sitting between the blank region and the mine. // Flag a safe cell (col 2) sitting between the blank region and the mine.
// (full press + release so the internal right-button flag resets) // (full press + release so the internal right-button flag resets)
const flag = grid.rows[0].cells[2] const flag = grid.rows[0].cells[2]
flag.dispatchEvent(new MouseEvent('mousedown', { button: 2, bubbles: true })) flag.dispatchEvent(new MouseEvent('mousedown', { button: 2, bubbles: true }))

View file

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

View file

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

View file

@ -0,0 +1,62 @@
# @cozy-games/move-log
A **game-agnostic** container for a recorded run of move events. It wraps any game's event stream in a schema-versioned, ordered, timestamped log.
```js
import {
createMoveLog, withReceivedTs,
serializeMoveLog, deserializeMoveLog, isMoveLog, SCHEMA_VERSION
} from '@cozy-games/move-log'
// `T` is your game's own event vocabulary — supplied by you, unknown to us.
const log = createMoveLog([
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
{ seq: 2, t: 50, event: { type: 'flag', r: 1, c: 2 } }
])
// → { schema_version: 1, events: [ { seq, t, event }, ... ] }
const json = serializeMoveLog(log) // → JSON string
const restored = deserializeMoveLog(json) // → validated MoveLog, or throws
// A consumer records WHEN it received events (host clock), additively:
const stamped = withReceivedTs(restored, () => hostNow())
// → each event now also carries `receivedTs`; still a valid v1 log
```
## Shape
| field | type | meaning |
| ---------------- | ----------------- | -------------------------------------------------- |
| `schema_version` | `1` | the move-log container version |
| `events` | `MoveEvent<T>[]` | ordered, each `{ seq, t, event, receivedTs? }` |
`MoveEvent<T> = { seq: number, t: number, event: T, receivedTs?: number }` — the
log owns the per-event recording metadata (a strictly increasing `seq`, a
source-side timestamp `t`, and an **optional** received-side `receivedTs`), so
`T` stays a pure game payload with no required shape. `receivedTs` is
purpose-neutral: it records only *that* a consumer received the event at some
time, never why or from where.
`deserializeMoveLog` round-trips a serialized log with full fidelity (order,
timestamps, sequence numbers, and any `receivedTs`) and rejects malformed input —
bad JSON, missing or wrong-typed fields, or non-monotonic `seq` — with a clear
error, never returning a partially-parsed log.
## Versioning: `receivedTs` is additive within `schema_version: 1`
`receivedTs` was added **without** bumping `schema_version`. It is optional and
purely additive: a v1 log is valid whether every event, some events, or no
events carry a `receivedTs`, and a reader that doesn't know the field simply
ignores it. A version bump is reserved for *breaking* container changes (a
renamed/removed field or a newly required one), which would be dispatched on in
`deserializeMoveLog`. See the `SCHEMA_VERSION` doc comment for the full policy.
## Invariant: zero game-specific imports
This module **must never import a game package** (e.g. mnswpr) or any game
vocabulary. `T` is always supplied by the consumer; the log only ever sees
opaque payloads. This independence is the whole point — it lets one move-log
format serve every game.
The rule is enforced by a dependency-graph guard in `test/move-log.test.js`
(scans the package's source and manifest for game references). Keep it green.

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

@ -0,0 +1,219 @@
// @ts-check
/**
* `@cozy-games/move-log` a game-agnostic container for a recorded run of move
* events. It wraps ANY game's event stream: `T` is the consuming game's own
* event vocabulary (mnswpr's `MoveEvent` union from core-06 is the first `T`),
* supplied by the caller.
*
* This module imports NO game types that independence is the whole point and
* is enforced by a dependency-graph guard in the tests. The log owns the
* per-event recording metadata (`seq` + `t`, and an optional received-side
* `receivedTs`) so `T` can stay a pure game payload with no required shape; the
* module never inspects the inside of an `event`.
*
* Extraction to a standalone published package comes later; for now it lives as
* a shared workspace module alongside `packages/utils`.
*/
/**
* The move-log schema version.
*
* Versioning policy: OPTIONAL, purely additive fields (an event gaining an
* optional `receivedTs`, say) do NOT bump this a v1 reader ignores fields it
* doesn't know, and a log written with them stays a valid v1 log. Bump ONLY on a
* breaking change to the container shape (a renamed/removed field, a newly
* *required* field), which would need dispatch on read. Never bump for changes
* to a game's `T` vocabulary.
*
* @typedef {1} SchemaVersion
*/
export const SCHEMA_VERSION = /** @type {SchemaVersion} */ (1)
/**
* A single recorded event: the log-owned recording metadata a strictly
* increasing sequence number `seq`, a source-side timestamp `t` (milliseconds),
* and an OPTIONAL received-side timestamp `receivedTs` a consumer may attach when
* it received the event plus the game's opaque payload `event`. Generic over
* the game's event type `T`. `receivedTs` is purpose-neutral: the log records
* only THAT it was received at some time, never why or from where.
*
* @template T
* @typedef {{ seq: number, t: number, event: T, receivedTs?: number }} MoveEvent
*/
/**
* The container: a schema-versioned, ordered array of timestamped, sequenced
* events for one recorded run. Generic over the game's event vocabulary `T`.
* JSON-safe as long as `T` is.
*
* @template T
* @typedef {{ schema_version: SchemaVersion, events: MoveEvent<T>[] }} MoveLog
*/
/**
* Validate an events array: each entry must be a `{ seq, t, event }` with an
* integer `seq`, a finite numeric `t`, and a present `event`; and `seq` must be
* STRICTLY INCREASING across the array. Throws a distinct, field-specific error
* on the first problem never leaves a caller with a half-checked array.
*
* @param {unknown} events
*/
function assertEvents(events) {
if (!Array.isArray(events)) {
throw new TypeError(`move-log: events must be an array (got ${events === null ? 'null' : typeof events})`)
}
let prevSeq = -Infinity
events.forEach((e, i) => {
if (e === null || typeof e !== 'object') {
throw new TypeError(`move-log: events[${i}] must be an object (got ${e === null ? 'null' : typeof e})`)
}
if (!('event' in e)) {
throw new TypeError(`move-log: events[${i}] is missing 'event'`)
}
if (typeof e.t !== 'number' || !Number.isFinite(e.t)) {
throw new TypeError(`move-log: events[${i}].t must be a finite number (got ${JSON.stringify(e.t)})`)
}
if (!Number.isInteger(e.seq)) {
throw new TypeError(`move-log: events[${i}].seq must be an integer (got ${JSON.stringify(e.seq)})`)
}
if (e.seq <= prevSeq) {
throw new RangeError(`move-log: events[${i}].seq must be strictly increasing (got ${e.seq} after ${prevSeq})`)
}
if (e.receivedTs !== undefined && (typeof e.receivedTs !== 'number' || !Number.isFinite(e.receivedTs))) {
throw new TypeError(`move-log: events[${i}].receivedTs must be a finite number when present (got ${JSON.stringify(e.receivedTs)})`)
}
prevSeq = e.seq
})
}
/**
* Copy one event record to the canonical field set, carrying `receivedTs`
* through only when it's actually present (so absent stays absent no
* `receivedTs: undefined` keys leak into the log or its JSON).
*
* @template T
* @param {MoveEvent<T>} e
* @returns {MoveEvent<T>}
*/
function copyEvent(e) {
/** @type {MoveEvent<T>} */
const out = { seq: e.seq, t: e.t, event: e.event }
if (e.receivedTs !== undefined) out.receivedTs = e.receivedTs
return out
}
/**
* Assert a value is a well-formed move log correct `schema_version` and a valid
* events array throwing a clear, specific error otherwise. Returns the value
* (typed) for chaining; never mutates.
*
* @param {unknown} value
* @returns {MoveLog<any>}
*/
export function assertMoveLog(value) {
if (value === null || typeof value !== 'object') {
throw new TypeError(`move-log: expected an object (got ${value === null ? 'null' : typeof value})`)
}
const v = /** @type {any} */ (value)
if (v.schema_version !== SCHEMA_VERSION) {
throw new RangeError(`move-log: unsupported schema_version ${JSON.stringify(v.schema_version)} (expected ${SCHEMA_VERSION})`)
}
assertEvents(v.events)
return v
}
/**
* Build a move log from an ordered list of `{ seq, t, event }` records. Pure and
* game-agnostic: it validates only the log's own invariants (metadata types and
* strictly increasing `seq`), never the shape of `T`. Order is preserved and
* entries are copied, so the log never aliases the caller's array.
*
* @template T
* @param {MoveEvent<T>[]} [events] - ordered events, each `{ seq, t, event, receivedTs? }`
* @returns {MoveLog<T>}
*/
export function createMoveLog(events = []) {
assertEvents(events)
return {
schema_version: SCHEMA_VERSION,
events: events.map(copyEvent)
}
}
/**
* Return a new move log with a received-side timestamp attached to each event
* for which `stamp` returns a finite number; events where `stamp` returns
* `undefined` are left as-is (keeping any existing `receivedTs`). This is how a
* consumer records WHEN it received events the log never cares where the value
* came from. Pure: the input log is not mutated.
*
* @template T
* @param {MoveLog<T>} log
* @param {(event: MoveEvent<T>, index: number) => number | undefined} stamp
* @returns {MoveLog<T>}
*/
export function withReceivedTs(log, stamp) {
assertMoveLog(log)
const events = log.events.map((e, i) => {
const rt = stamp(e, i)
if (rt === undefined) return copyEvent(e)
if (typeof rt !== 'number' || !Number.isFinite(rt)) {
throw new TypeError(`withReceivedTs: stamp must return a finite number or undefined (got ${JSON.stringify(rt)} at index ${i})`)
}
return { ...copyEvent(e), receivedTs: rt }
})
return { schema_version: log.schema_version, events }
}
/**
* Non-throwing type guard: is `value` a well-formed move log of the current
* schema version? Checks the container invariants only remains blind to `T`.
*
* @param {unknown} value
* @returns {boolean}
*/
export function isMoveLog(value) {
try {
assertMoveLog(value)
return true
} catch {
return false
}
}
/**
* Serialize a move log to a JSON string. Validates first, so a malformed log is
* rejected here rather than emitted. Inverse of {@link deserializeMoveLog}.
*
* @template T
* @param {MoveLog<T>} log
* @returns {string}
*/
export function serializeMoveLog(log) {
assertMoveLog(log)
return JSON.stringify(log)
}
/**
* Parse and validate a JSON string into a move log, with full fidelity: event
* order, timestamps, and sequence numbers survive the round-trip exactly.
* Rejects malformed input (bad JSON, missing/typed-wrong fields, non-monotonic
* `seq`) with a clear error and NEVER returns a partially-parsed log. Inverse of
* {@link serializeMoveLog}.
*
* @param {string} json
* @returns {MoveLog<any>}
*/
export function deserializeMoveLog(json) {
if (typeof json !== 'string') {
throw new TypeError(`deserializeMoveLog: expected a JSON string (got ${json === null ? 'null' : typeof json})`)
}
let parsed
try {
parsed = JSON.parse(json)
} catch (err) {
throw new SyntaxError(`deserializeMoveLog: invalid JSON — ${/** @type {Error} */ (err).message}`, { cause: err })
}
return assertMoveLog(parsed)
}

View file

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

View file

@ -0,0 +1,323 @@
// @ts-check
import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
// Imported via the PACKAGE NAME (not a relative path) to prove the workspace
// module resolves and is importable by other packages.
import {
createMoveLog, withReceivedTs,
serializeMoveLog, deserializeMoveLog, isMoveLog, assertMoveLog, SCHEMA_VERSION
} from '@cozy-games/move-log'
// A REAL mnswpr session/event stream — imported by the TEST, never the module.
// Relative path (not the package name) so no game dependency enters the manifest.
import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js'
/**
* A dummy event vocabulary defined HERE, in the test deliberately NOT mnswpr's.
* The move log must type-check and work against any `T` the consumer supplies.
* @typedef {{ kind: 'tick' } | { kind: 'boom', power: number }} DummyEvent
*/
describe('@cozy-games/move-log', () => {
/** @type {import('@cozy-games/move-log').MoveEvent<DummyEvent>[]} */
const events = [
{ seq: 1, t: 0, event: { kind: 'tick' } },
{ seq: 2, t: 50, event: { kind: 'boom', power: 3 } },
{ seq: 5, t: 120, event: { kind: 'tick' } } // gaps allowed; strictly increasing
]
it('exposes schema_version typed as 1', () => {
expect(SCHEMA_VERSION).toBe(1)
})
it('wraps an ordered, timestamped, sequenced stream for a dummy vocabulary', () => {
/** @type {import('@cozy-games/move-log').MoveLog<DummyEvent>} */
const log = createMoveLog(events)
expect(log.schema_version).toBe(1)
expect(log.events).toHaveLength(3)
expect(log.events.map(e => e.seq)).toEqual([1, 2, 5])
expect(log.events.map(e => e.t)).toEqual([0, 50, 120])
expect(log.events[1]).toEqual({ seq: 2, t: 50, event: { kind: 'boom', power: 3 } })
})
it('defaults to an empty run and copies entries (no aliasing of the input)', () => {
expect(createMoveLog()).toEqual({ schema_version: 1, events: [] })
const input = [{ seq: 1, t: 1, event: { kind: 'tick' } }]
const log = createMoveLog(input)
input[0].t = 999
expect(log.events[0].t).toBe(1) // log kept its own copy
})
it('rejects a non-monotonic seq at construction', () => {
expect(() => createMoveLog([
{ seq: 2, t: 0, event: {} },
{ seq: 1, t: 1, event: {} }
])).toThrow(RangeError)
})
})
describe('serialization round-trip', () => {
const events = [
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
{ seq: 2, t: 50, event: { type: 'flag', r: 1, c: 2 } },
{ seq: 3, t: 90, event: { type: 'chord', r: 4, c: 4 } }
]
it('preserves order, timestamps, and sequence numbers exactly', () => {
const log = createMoveLog(events)
const restored = deserializeMoveLog(serializeMoveLog(log))
expect(restored).toEqual(log) // full structural fidelity
expect(restored.events.map(e => e.seq)).toEqual([1, 2, 3])
expect(restored.events.map(e => e.t)).toEqual([0, 50, 90])
expect(restored.events.map(e => e.event.type)).toEqual(['reveal', 'flag', 'chord'])
})
it('serializeMoveLog produces a JSON string parseable back to the same object', () => {
const log = createMoveLog(events)
const json = serializeMoveLog(log)
expect(typeof json).toBe('string')
expect(JSON.parse(json)).toEqual(log)
})
it('rejects each malformed fixture with a distinct, clear error', () => {
const valid = serializeMoveLog(createMoveLog(events))
// not a string
// @ts-expect-error — deliberately wrong type
expect(() => deserializeMoveLog({})).toThrow(TypeError)
// invalid JSON syntax
expect(() => deserializeMoveLog('{not json')).toThrow(SyntaxError)
// missing schema_version
expect(() => deserializeMoveLog(JSON.stringify({ events: [] }))).toThrow(RangeError)
// wrong schema_version
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 2, events: [] }))).toThrow(RangeError)
// events not an array
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: 'nope' }))).toThrow(TypeError)
// missing 'event' field
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [{ seq: 1, t: 0 }] }))).toThrow(TypeError)
// bad timestamp type
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [{ seq: 1, t: 'soon', event: {} }] }))).toThrow(TypeError)
// bad seq type
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [{ seq: 1.5, t: 0, event: {} }] }))).toThrow(TypeError)
// shuffled / non-monotonic seq
const shuffled = JSON.stringify({
schema_version: 1,
events: [{ seq: 3, t: 0, event: {} }, { seq: 1, t: 1, event: {} }]
})
expect(() => deserializeMoveLog(shuffled)).toThrow(RangeError)
// distinct messages, not one generic error
const messages = [
captureMessage(() => deserializeMoveLog('{not json')),
captureMessage(() => deserializeMoveLog(JSON.stringify({ events: [] }))),
captureMessage(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [{ seq: 1, t: 0 }] }))),
captureMessage(() => deserializeMoveLog(shuffled))
]
expect(new Set(messages).size).toBe(messages.length)
// sanity: the valid fixture still deserializes
expect(isMoveLog(deserializeMoveLog(valid))).toBe(true)
})
it('never returns a partially-parsed log (throws before returning)', () => {
const partlyBad = JSON.stringify({
schema_version: 1,
events: [{ seq: 1, t: 0, event: { ok: true } }, { seq: 2, t: 'bad', event: {} }]
})
let result = 'sentinel'
expect(() => { result = deserializeMoveLog(partlyBad) }).toThrow(TypeError)
expect(result).toBe('sentinel') // assignment never happened
})
it('isMoveLog / assertMoveLog agree on validity', () => {
const log = createMoveLog(events)
expect(isMoveLog(log)).toBe(true)
expect(assertMoveLog(log)).toBe(log)
expect(isMoveLog(null)).toBe(false)
expect(isMoveLog({ schema_version: 1, events: [{ seq: 1, t: 0 }] })).toBe(false)
})
})
describe('received timestamps (additive receivedTs)', () => {
const base = [
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
{ seq: 2, t: 50, event: { type: 'flag', r: 1, c: 2 } }
]
it('accepts an optional receivedTs per event and round-trips it', () => {
const log = createMoveLog([
{ seq: 1, t: 0, event: { k: 'a' }, receivedTs: 1000 },
{ seq: 2, t: 50, event: { k: 'b' }, receivedTs: 1060 }
])
expect(log.events[0].receivedTs).toBe(1000)
const restored = deserializeMoveLog(serializeMoveLog(log))
expect(restored).toEqual(log)
expect(restored.events.map(e => e.receivedTs)).toEqual([1000, 1060])
})
it('is valid with receivedTs on only some events', () => {
const log = createMoveLog([
{ seq: 1, t: 0, event: { k: 'a' }, receivedTs: 1000 },
{ seq: 2, t: 50, event: { k: 'b' } } // no receivedTs
])
expect(isMoveLog(log)).toBe(true)
expect('receivedTs' in log.events[1]).toBe(false)
expect(deserializeMoveLog(serializeMoveLog(log))).toEqual(log)
})
it('REGRESSION: a log with no receivedTs anywhere stays fully valid and leaks no key', () => {
const log = createMoveLog(base)
expect(isMoveLog(log)).toBe(true)
expect(log.events.every(e => !('receivedTs' in e))).toBe(true)
const restored = deserializeMoveLog(serializeMoveLog(log))
expect(restored).toEqual(log)
expect(restored.events.every(e => !('receivedTs' in e))).toBe(true)
})
it('rejects a non-finite / non-numeric receivedTs', () => {
expect(() => createMoveLog([{ seq: 1, t: 0, event: {}, receivedTs: 'soon' }])).toThrow(TypeError)
expect(() => createMoveLog([{ seq: 1, t: 0, event: {}, receivedTs: Infinity }])).toThrow(TypeError)
expect(() => deserializeMoveLog(JSON.stringify({
schema_version: 1,
events: [{ seq: 1, t: 0, event: {}, receivedTs: 'later' }]
}))).toThrow(TypeError)
})
it('withReceivedTs attaches host-received times additively without mutating the input', () => {
const log = createMoveLog(base)
let clock = 900
const stamped = withReceivedTs(log, () => (clock += 10))
// input untouched
expect(log.events.every(e => !('receivedTs' in e))).toBe(true)
// output stamped, still a valid v1 log, round-trips
expect(stamped.events.map(e => e.receivedTs)).toEqual([910, 920])
expect(stamped.schema_version).toBe(1)
expect(deserializeMoveLog(serializeMoveLog(stamped))).toEqual(stamped)
})
it('withReceivedTs leaves events unstamped when the stamp returns undefined', () => {
const log = createMoveLog(base)
const stamped = withReceivedTs(log, (e) => (e.seq === 1 ? 1234 : undefined))
expect(stamped.events[0].receivedTs).toBe(1234)
expect('receivedTs' in stamped.events[1]).toBe(false)
expect(isMoveLog(stamped)).toBe(true)
})
it('withReceivedTs rejects a stamp that returns a non-finite number', () => {
const log = createMoveLog(base)
expect(() => withReceivedTs(log, () => NaN)).toThrow(TypeError)
})
it('VERSIONING: receivedTs is additive — same schema_version with or without it', () => {
const without = createMoveLog(base)
const withRt = withReceivedTs(without, () => 1000)
expect(without.schema_version).toBe(SCHEMA_VERSION)
expect(withRt.schema_version).toBe(SCHEMA_VERSION)
expect(SCHEMA_VERSION).toBe(1)
// a v1 reader accepts both shapes
expect(isMoveLog(without)).toBe(true)
expect(isMoveLog(withRt)).toBe(true)
})
})
describe('integration: wraps a real mnswpr event stream (core-06)', () => {
// 3x3, single mine at (0,0); adjacency computed. Lets us script exact moves.
const layout = {
rows: 3,
cols: 3,
mines: 1,
cells: [
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
],
mineLocations: [[0, 0]]
}
it('records an mnswpr session end-to-end and round-trips it losslessly', () => {
let now = 1000
const clock = () => now
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout), clock })
// Capture the real core-06 move-events ({ type, r, c, t, seq }).
/** @type {any[]} */
const emitted = []
session.onMove(e => emitted.push(e))
now = 1050; session.applyMove({ type: 'reveal', r: 0, c: 1 })
now = 1100; session.applyMove({ type: 'flag', r: 0, c: 0 })
now = 1150; session.applyMove({ type: 'flag', r: 0, c: 0 }) // unflag
now = 1200; session.applyMove({ type: 'flag', r: 0, c: 0 }) // re-flag the mine
now = 1250; session.applyMove({ type: 'chord', r: 0, c: 1 }) // 1 flag == value → chord
expect(emitted.map(e => e.type)).toEqual(['reveal', 'flag', 'unflag', 'flag', 'chord'])
// Wrap the stream: lift the recording metadata (seq, t) to the log level.
const log = createMoveLog(emitted.map(e => ({ seq: e.seq, t: e.t, event: e })))
const restored = deserializeMoveLog(serializeMoveLog(log))
expect(restored).toEqual(log)
expect(restored.events.map(e => e.seq)).toEqual([1, 2, 3, 4, 5])
expect(restored.events.map(e => e.t)).toEqual([1050, 1100, 1150, 1200, 1250])
expect(restored.events.map(e => e.event.type)).toEqual(['reveal', 'flag', 'unflag', 'flag', 'chord'])
// sequence is strictly increasing — the log's own invariant, verified on real data
const seqs = restored.events.map(e => e.seq)
expect(seqs).toEqual([...seqs].sort((a, b) => a - b))
})
})
describe('game-agnosticism guard (zero game-specific imports)', () => {
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
// The set of game packages the move log must never depend on or import.
const GAME_REFERENCES = /mnswpr|minesweeper/i
it('manifest declares no dependency on a game package', () => {
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
const deps = {
...pkg.dependencies,
...pkg.devDependencies,
...pkg.peerDependencies
}
const offenders = Object.keys(deps).filter(name => GAME_REFERENCES.test(name))
expect(offenders).toEqual([])
})
it('no source file imports or references a game package', () => {
const offenders = []
walk(pkgDir, file => {
if (!file.endsWith('.js')) return
if (file.includes('/test/')) return // the test may name/import games on purpose
// Strip comments so prose that *names* a game isn't a false positive.
const code = readFileSync(file, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')
if (GAME_REFERENCES.test(code)) offenders.push(file)
})
expect(offenders).toEqual([])
})
})
function captureMessage(fn) {
try {
fn()
return null
} catch (err) {
return err.message
}
}
function walk(dir, fn) {
for (const name of readdirSync(dir)) {
if (name === 'node_modules') continue
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p, fn)
else fn(p)
}
}

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

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

View file

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

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

@ -0,0 +1,422 @@
// @ts-check
import { assertMoveLog, SCHEMA_VERSION } from '@cozy-games/move-log'
/**
* `@cozy-games/replay` the core of a game-agnostic replay engine.
*
* {@link PlaybackClock} re-drives a move-log envelope over time: it schedules
* each recorded event to fire at its OFFSET (its `t` relative to the first
* event) as playback time advances, and supports play / pause / seek. It
* consumes ONLY the generic envelope (`@cozy-games/move-log`) and never inspects
* the inside of an `event` no game types cross this boundary.
*
* The clock and scheduler are injected (mirroring the core session's
* injected-clock seam), so tests drive it with fake timers or a hand-rolled
* scheduler for exact, deterministic timing.
*/
/**
* @typedef {import('@cozy-games/move-log').MoveEvent<any>} Event
* @typedef {import('@cozy-games/move-log').MoveLog<any>} Envelope
* @typedef {{
* clock?: () => number,
* setTimeout?: (fn: () => void, ms: number) => any,
* clearTimeout?: (handle: any) => void
* }} Deps
*/
/**
* A reducer supplied by a game adapter: given the ordered slice of events played
* so far (offset <= the current position), return a completion percentage in
* `[0, 100]`. Typed generically over the game's event vocabulary `T`. The engine
* clamps the result and never inspects an event's payload all interpretation
* lives in this reducer.
*
* @template T
* @typedef {(events: import('@cozy-games/move-log').MoveEvent<T>[]) => number} ProgressReducer
*/
/**
* A reducer supplied by a game adapter for full-board replay: given the ordered
* slice of events played so far, reconstruct the complete game state `S` at that
* point. Typed generically over the event vocabulary `T` and the (opaque) state
* `S`. Powers the flag-gated full-board mode; the engine treats `S` as a black box.
*
* @template T, S
* @typedef {(events: import('@cozy-games/move-log').MoveEvent<T>[]) => S} StateReducer
*/
/**
* The replay game-adapter contract (v0) the seam through which game meaning
* enters the engine. Both methods are optional; the engine calls whichever the
* mode needs and never interprets an event itself. See `docs/adapter-interface.md`.
*
* @template T
* @typedef {{ progress?: ProgressReducer<T>, state?: StateReducer<T, any> }} ReplayAdapter
*/
/**
* A version reader: normalize a raw envelope of its generation into the canonical
* ordered `MoveEvent` records the engine plays. One engine build can therefore
* replay envelopes from multiple format generations.
*
* @typedef {(envelope: any) => Event[]} EnvelopeReader
*/
/** v1 is the canonical format itself — its `events` are already the records. */
function readV1(envelope) {
return envelope.events
}
/**
* The built-in dispatch table: `schema_version → reader`. Adding a real future
* generation is exactly one entry here (plus its normalizer). Callers can also
* supply extra/override readers per instance via the `readers` option.
*
* @type {Record<number, EnvelopeReader>}
*/
const ENVELOPE_READERS = { [SCHEMA_VERSION]: readV1 }
/**
* Dispatch on an envelope's `schema_version` to the matching reader and return
* the canonical `MoveEvent` records. Unknown/unsupported versions fail LOUDLY
* with a specific error never a silent best-effort parse. Whatever a reader
* returns is validated as a canonical move log, so a half-normalized generation
* can't reach the engine.
*
* @param {any} envelope
* @param {Record<number, EnvelopeReader>} [extraReaders] - added/overriding readers
* @returns {Event[]}
*/
export function readEnvelope(envelope, extraReaders) {
if (envelope === null || typeof envelope !== 'object') {
throw new TypeError(`readEnvelope: expected an envelope object (got ${envelope === null ? 'null' : typeof envelope})`)
}
const readers = extraReaders ? { ...ENVELOPE_READERS, ...extraReaders } : ENVELOPE_READERS
const version = envelope.schema_version
const read = readers[version]
if (typeof read !== 'function') {
const supported = Object.keys(readers).map(Number).sort((a, b) => a - b).join(', ')
throw new RangeError(`readEnvelope: unsupported envelope schema_version ${JSON.stringify(version)} (supported: ${supported})`)
}
const records = read(envelope)
// Every reader MUST normalize to canonical move-log records; enforce it here so
// no downstream generation can feed the engine a malformed or half-normalized log.
assertMoveLog({ schema_version: SCHEMA_VERSION, events: records })
return records
}
export class PlaybackClock {
/**
* @param {Envelope} envelope - a valid move-log envelope (validated here)
* @param {Deps} [deps] - injected time source + scheduler (default: real host)
* @param {ReplayAdapter<any>} [adapter] - game adapter (progress / state reducers)
* @param {{ fullBoard?: boolean, readers?: Record<number, EnvelopeReader> }} [options] -
* `fullBoard` flag-gates full-board mode (default OFF: `state()`/`onState` are
* inert and the state reducer is never called) the minimal, documented
* feature-flag seam for this engine. `readers` adds/overrides schema-version
* readers for this instance (see {@link readEnvelope}).
*/
constructor(envelope, deps = {}, adapter = {}, options = {}) {
// Dispatch on schema_version → the matching reader's canonical records.
const records = readEnvelope(envelope, options.readers)
if (adapter.progress !== undefined && typeof adapter.progress !== 'function') {
throw new TypeError('PlaybackClock: adapter.progress must be a function when provided')
}
if (adapter.state !== undefined && typeof adapter.state !== 'function') {
throw new TypeError('PlaybackClock: adapter.state must be a function when provided')
}
this._adapter = adapter
this._fullBoard = options.fullBoard === true
const {
clock = () => Date.now(),
setTimeout = (fn, ms) => globalThis.setTimeout(fn, ms),
clearTimeout = (handle) => globalThis.clearTimeout(handle)
} = deps
this._now = clock
this._setTimeout = setTimeout
this._clearTimeout = clearTimeout
// Sort by recorded time (tie-break by seq) and rebase to offsets so the first
// event sits at offset 0 — "recorded offset relative to playback time".
const sorted = [...records].sort((a, b) => a.t - b.t || a.seq - b.seq)
const baseT = sorted.length ? sorted[0].t : 0
/** @type {{ offset: number, record: Event }[]} */
this._events = sorted.map(record => ({ offset: record.t - baseT, record }))
this._duration = this._events.length ? this._events[this._events.length - 1].offset : 0
// Playback state. Invariant: `_cursor` === number of events whose offset is
// <= the current position; events below the cursor have been delivered in the
// current forward pass. This single source of truth makes seek deterministic.
this._position = 0
this._cursor = 0
this._playing = false
/** @type {any} */
this._timer = null
this._anchorClock = 0
this._anchorPosition = 0
/** @type {Set<(event: Event) => void>} */
this._handlers = new Set()
/** @type {Set<(update: { position: number, progress: number }) => void>} */
this._progressHandlers = new Set()
/** Last progress value pushed, so unchanged progress (e.g. a flag) is not re-emitted. */
this._lastProgress = /** @type {number | null} */ (null)
/** @type {Set<(update: { position: number, state: any }) => void>} */
this._stateHandlers = new Set()
/** @type {Set<(update: { position: number }) => void>} */
this._endHandlers = new Set()
/** True once the end has been reached; re-arms when playback moves back before it. */
this._ended = false
}
/** Total playback length in ms (offset of the last event; 0 if empty). */
get duration() {
return this._duration
}
/** @returns {boolean} */
isPlaying() {
return this._playing
}
/** Current playback position in ms, clamped to `[0, duration]`. */
position() {
return this._livePosition()
}
/**
* Completion percentage (0100) at the current position, via the adapter's
* progress reducer or `null` if no reducer was supplied. The engine hands the
* reducer the ordered slice of events delivered so far and clamps its result;
* it never interprets an event payload itself (that's the adapter's job).
*
* @returns {number | null}
*/
progress() {
const reduce = this._adapter.progress
if (typeof reduce !== 'function') return null
const delivered = this._events.slice(0, this._cursor).map(e => e.record)
const pct = reduce(delivered)
if (typeof pct !== 'number' || Number.isNaN(pct)) {
throw new TypeError(`PlaybackClock.progress: reducer must return a number (got ${typeof pct})`)
}
return Math.min(100, Math.max(0, pct))
}
/**
* Full-board mode: the reconstructed game state at the current position, via the
* adapter's `state` reducer over the delivered slice. Returns `null` unless the
* `fullBoard` flag is on AND a state reducer was supplied so the mode is inert
* (and the reducer never runs) by default. The state shape `S` is the adapter's;
* the engine treats it as opaque.
*
* @returns {any}
*/
state() {
if (!this._fullBoard) return null
const reduce = this._adapter.state
if (typeof reduce !== 'function') return null
return reduce(this._events.slice(0, this._cursor).map(e => e.record))
}
/**
* Subscribe to delivered events. The handler receives the raw envelope record
* (`{ seq, t, event, ... }`) the payload stays opaque. Returns an unsubscribe.
*
* @param {(event: Event) => void} handler
* @returns {() => void}
*/
on(handler) {
this._handlers.add(handler)
return () => this._handlers.delete(handler)
}
/**
* Progress-mode subscription: receive `{ position, progress }` updates as
* playback advances the "percent complete over elapsed time" signal. Fires
* only when the percentage actually changes (so flags/unflags, which don't
* advance progress, emit nothing), on play, seek forward, and seek backward.
* Requires an adapter with a `progress` reducer; without one it never emits.
* Subscribe before playing to catch every update; use {@link progress} for the
* current value at any time. Returns an unsubscribe.
*
* @param {(update: { position: number, progress: number }) => void} handler
* @returns {() => void}
*/
onProgress(handler) {
this._progressHandlers.add(handler)
return () => this._progressHandlers.delete(handler)
}
/**
* Full-board mode subscription: receive `{ position, state }` whenever the
* delivered set changes (play, seek forward, seek backward), where `state` is
* the adapter's reconstruction at that position. Inert unless the `fullBoard`
* flag is on and a state reducer was supplied. Returns an unsubscribe.
*
* @param {(update: { position: number, state: any }) => void} handler
* @returns {() => void}
*/
onState(handler) {
this._stateHandlers.add(handler)
return () => this._stateHandlers.delete(handler)
}
/**
* Subscribe to the "ended" signal: fires with `{ position }` when playback
* reaches the last recorded event's offset the SAME signal whether the run
* completed or the recording was truncated mid-game (the engine has no notion
* of a terminal event). Fires once on reaching the end and re-arms if playback
* moves back before it. Returns an unsubscribe.
*
* @param {(update: { position: number }) => void} handler
* @returns {() => void}
*/
onEnd(handler) {
this._endHandlers.add(handler)
return () => this._endHandlers.delete(handler)
}
/**
* Start (or resume) playback from the current position. Any event already due
* at the current position fires synchronously; the rest are scheduled at their
* offsets. No-op if already playing or already at the end.
*/
play() {
if (this._playing) return
this._playing = true
this._anchorClock = this._now()
this._anchorPosition = this._position
this._scheduleNext()
}
/** Pause playback, freezing the position where it currently is. */
pause() {
if (!this._playing) return
this._position = this._livePosition()
this._playing = false
this._clearTimer()
}
/**
* Seek to playback time `t` (ms, clamped to `[0, duration]`). Deterministic:
* afterwards the delivered set is exactly the events at offset <= t. Moving
* forward delivers the newly-passed events in order (each exactly once); moving
* backward rewinds the cursor without delivering, so a later forward pass
* re-delivers them. Re-schedules if playing.
*
* @param {number} t
*/
seek(t) {
if (typeof t !== 'number' || Number.isNaN(t)) {
throw new TypeError(`PlaybackClock.seek: t must be a number (got ${typeof t})`)
}
const wasPlaying = this._playing
this._clearTimer()
this._advanceTo(t)
if (wasPlaying) {
this._anchorClock = this._now()
this._anchorPosition = this._position
this._scheduleNext()
}
}
// ---- internals ----
/** Live position: derived from the clock while playing, else the stored value. */
_livePosition() {
if (!this._playing) return this._position
const raw = this._anchorPosition + (this._now() - this._anchorClock)
return Math.min(Math.max(raw, 0), this._duration)
}
/**
* Move the cursor to match `target` position: emit events crossed going
* forward (once each), un-count events going backward (no emit). Sets position.
* @param {number} target
*/
_advanceTo(target) {
const t = Math.min(Math.max(target, 0), this._duration)
const before = this._cursor
while (this._cursor < this._events.length && this._events[this._cursor].offset <= t) {
this._emit(this._events[this._cursor].record)
this._cursor++
}
while (this._cursor > 0 && this._events[this._cursor - 1].offset > t) {
this._cursor--
}
this._position = t
this._emitProgressIfChanged()
if (this._cursor !== before) this._emitState()
this._maybeEmitEnded()
}
/** Deliver an event to all subscribers. */
_emit(record) {
for (const handler of this._handlers) handler(record)
}
/** Push a progress update to subscribers, but only when the percentage moved. */
_emitProgressIfChanged() {
if (this._progressHandlers.size === 0) return
const progress = this.progress()
if (progress === null || progress === this._lastProgress) return
this._lastProgress = progress
const update = { position: this._position, progress }
for (const handler of this._progressHandlers) handler(update)
}
/**
* Fire "ended" once when every recorded event has been delivered (the timeline
* reached the last event's offset); re-arm when playback moves back before it.
* Terminal-agnostic: a truncated recording ends here exactly like a complete one.
*/
_maybeEmitEnded() {
const atEnd = this._events.length > 0 && this._cursor >= this._events.length
if (atEnd && !this._ended) {
this._ended = true
const update = { position: this._position }
for (const handler of this._endHandlers) handler(update)
} else if (!atEnd) {
this._ended = false
}
}
/** Push a reconstructed board state to subscribers — only in active full-board mode. */
_emitState() {
if (!this._fullBoard || this._stateHandlers.size === 0) return
if (typeof this._adapter.state !== 'function') return
const update = { position: this._position, state: this.state() }
for (const handler of this._stateHandlers) handler(update)
}
/**
* Deliver anything already due, then arm a timer for the next pending event.
* Ends playback when the cursor reaches the last event.
*/
_scheduleNext() {
this._clearTimer()
if (!this._playing) return
this._advanceTo(this._livePosition())
if (this._cursor >= this._events.length) {
this._position = this._duration
this._playing = false
return
}
const delay = Math.max(0, this._events[this._cursor].offset - this._livePosition())
this._timer = this._setTimeout(() => this._onTimer(), delay)
}
_onTimer() {
this._timer = null
if (this._playing) this._scheduleNext()
}
_clearTimer() {
if (this._timer !== null) {
this._clearTimeout(this._timer)
this._timer = null
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,116 @@
// @ts-check
import { describe, it, expect } from 'vitest'
import { PlaybackClock, readEnvelope } from '@cozy-games/replay'
import { createMoveLog } from '@cozy-games/move-log'
function fakeScheduler(start = 0) {
let now = start
let nextId = 1
const timers = new Map()
return {
clock: () => now,
setTimeout: (fn, ms) => {
const id = nextId++
timers.set(id, { at: now + Math.max(0, ms), fn })
return id
},
clearTimeout: (id) => { timers.delete(id) },
advance(ms) {
const target = now + ms
for (;;) {
let due = null
for (const [id, timer] of timers) {
if (timer.at <= target && (due === null || timer.at < due.at)) due = { id, ...timer }
}
if (!due) break
timers.delete(due.id)
now = due.at
due.fn()
}
now = target
}
}
}
// A canonical v1 envelope.
const v1 = () => createMoveLog([
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
{ seq: 2, t: 100, event: { type: 'flag', r: 1, c: 1 } },
{ seq: 3, t: 250, event: { type: 'chord', r: 2, c: 2 } }
])
describe('schema_version dispatch — v1 (built-in)', () => {
it('replays a v1 envelope through the dispatch path (not a bypass)', () => {
const s = fakeScheduler()
const clock = new PlaybackClock(v1(), s)
const seen = []
clock.on(r => seen.push(r.event.type))
clock.play()
s.advance(250)
expect(seen).toEqual(['reveal', 'flag', 'chord'])
})
it('readEnvelope returns the canonical records for v1', () => {
const records = readEnvelope(v1())
expect(records.map(r => r.seq)).toEqual([1, 2, 3])
expect(records[0].event.type).toBe('reveal')
})
})
describe('schema_version dispatch — unknown versions fail loudly', () => {
it('throws a specific error for a synthetic future version (99)', () => {
const future = { schema_version: 99, events: [{ seq: 1, t: 0, event: {} }] }
expect(() => new PlaybackClock(future, fakeScheduler())).toThrow(/unsupported envelope schema_version 99 \(supported: 1\)/)
expect(() => readEnvelope(future)).toThrow(RangeError)
})
it('throws for a missing schema_version', () => {
expect(() => readEnvelope({ events: [] })).toThrow(/unsupported envelope schema_version undefined/)
})
it('rejects a non-object envelope', () => {
// @ts-expect-error — deliberately wrong type
expect(() => readEnvelope(null)).toThrow(TypeError)
})
})
describe('schema_version dispatch — adding a version', () => {
// A toy v2 format defined ENTIRELY in the test: a different field layout that a
// normalizer maps back to canonical { seq, t, event }. Adding it is one entry.
const v2Envelope = {
schema_version: 2,
log: [
{ n: 1, ts: 0, payload: { type: 'reveal', r: 0, c: 0 } },
{ n: 2, ts: 120, payload: { type: 'flag', r: 1, c: 1 } }
]
}
const readV2 = env => env.log.map(e => ({ seq: e.n, t: e.ts, event: e.payload }))
it('replays a v2 fixture via a supplied reader (same code path)', () => {
const s = fakeScheduler()
const clock = new PlaybackClock(v2Envelope, s, {}, { readers: { 2: readV2 } })
const seen = []
clock.on(r => seen.push(r.event.type))
clock.play()
s.advance(120)
expect(seen).toEqual(['reveal', 'flag'])
})
it('readEnvelope normalizes v2 to canonical records with an extra reader', () => {
const records = readEnvelope(v2Envelope, { 2: readV2 })
expect(records).toEqual([
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
{ seq: 2, t: 120, event: { type: 'flag', r: 1, c: 1 } }
])
})
it('still rejects v2 when no reader is registered', () => {
expect(() => readEnvelope(v2Envelope)).toThrow(/unsupported envelope schema_version 2 \(supported: 1\)/)
})
it('validates a reader that returns a malformed (non-canonical) log', () => {
// A buggy reader whose output breaks the monotonic-seq invariant is caught.
const badReader = () => [{ seq: 2, t: 0, event: {} }, { seq: 1, t: 1, event: {} }]
expect(() => readEnvelope({ schema_version: 3 }, { 3: badReader })).toThrow(RangeError)
})
})

View file

@ -0,0 +1,22 @@
{
"name": "@cozy-games/utils",
"version": "0.0.1",
"description": "Shared, dependency-free browser utilities for Cozy Games (storage, timer, logger, loading, date buckets)",
"author": "Ayo Ayco",
"private": true,
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/ayo-run/mnswpr"
},
"main": "index.js",
"exports": {
".": {
"default": "./index.js"
},
"./*": {
"default": "./*"
}
},
"license": "BSD-2-Clause"
}

Some files were not shown because too many files have changed in this diff Show more