Compare commits
42 commits
restructur
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3418be3d7b | |||
| 2d7f7d0ebb | |||
| 41829086c5 | |||
| 17724ebe8f | |||
| cc44789829 | |||
| 9079ab0848 | |||
| 26277986a0 | |||
| c2ffb0d7f6 | |||
| cd2b30ecc8 | |||
| 1ca1d63ea7 | |||
| 0b9866e05f | |||
| 43b75d7b0e | |||
| 998aeacbb2 | |||
| 072d2f7369 | |||
| 6b38088d4b | |||
| 6f9a8031e9 | |||
| bafe1db285 | |||
| 0dab463e84 | |||
| 4ccec5a116 | |||
| e1c168a9bb | |||
| 3bfb1931cc | |||
| dd9e020214 | |||
| 6c90cde0f9 | |||
| 0823d43c75 | |||
| d3d84aa9b8 | |||
| d482b17976 | |||
| 8099820e79 | |||
| 916d04d0d9 | |||
| d7a9f61a9e | |||
| 5a7eea3e12 | |||
| 744825c771 | |||
| 9aec20edbf | |||
| 6afd15aa35 | |||
| 8b66249edf | |||
| 5ec22fe37e | |||
| 062b912549 | |||
| 042d6b26e9 | |||
| 7cbf9baaa8 | |||
| 0532fd6d66 | |||
| 7685a6dea7 | |||
| 74551f2023 | |||
| 969ebba067 |
111 changed files with 18782 additions and 1028 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -1,14 +1,14 @@
|
|||
node_modules/
|
||||
dist/
|
||||
|
||||
# generated at publish time from the root README.md (see scripts/publish-lib.js)
|
||||
lib/README.md
|
||||
# generated at publish time from apps/mnswpr/README.md (see scripts/publish-lib.js)
|
||||
packages/mnswpr/README.md
|
||||
|
||||
.claude
|
||||
|
||||
# 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
|
||||
# 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.local
|
||||
.env.*.local
|
||||
|
|
@ -22,3 +22,6 @@ ui-debug.log
|
|||
*~
|
||||
*swo
|
||||
*swp
|
||||
|
||||
# TEMPORARY - or unnecesary if done
|
||||
apps/mnswpr/MIGRATION.md
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
echo "pre-commit..."
|
||||
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
5
.secretlintignore
Normal 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
7
.secretlintrc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"rules": [
|
||||
{
|
||||
"id": "@secretlint/secretlint-rule-preset-recommend"
|
||||
}
|
||||
]
|
||||
}
|
||||
89
AGENTS.md
89
AGENTS.md
|
|
@ -4,37 +4,90 @@ Guidance for AI coding agents working in this repository.
|
|||
|
||||
## What this is
|
||||
|
||||
Classic Minesweeper as a vanilla web game — no framework, no TypeScript (JSDoc + `// @ts-check` only). Deployed at [mnswpr.com](https://mnswpr.com) (Netlify) and published to npm as `@ayo-run/mnswpr`. The game engine has **zero runtime dependencies**; only the website adds Firebase.
|
||||
Classic Minesweeper as a vanilla web game — no framework, no TypeScript (JSDoc + `// @ts-check` only). Deployed at [mnswpr.com](https://mnswpr.com) (Netlify) and published to npm as `@cozy-games/mnswpr`. The game engine has **zero runtime dependencies**; only the website adds Firebase.
|
||||
|
||||
**`mnswpr` is the main test app.** It's the reference app for the monorepo and the default target for local runs — `.claude/launch.json` launches it (`dev` on :5173, `preview` on :4173), and it's what you should build/run/preview when verifying changes to the shared packages or tooling.
|
||||
|
||||
## 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
|
||||
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: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/*`.
|
||||
|
||||
## 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/`.
|
||||
- **`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.
|
||||
- **`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`.
|
||||
- **`apps/mnswpr/`** — package `mnswpr`, `@cozy-games/mnswpr`'s host, the mnswpr.com website. Consumes the engine and leaderboard via `workspace:*` (`import mnswpr from '@cozy-games/mnswpr/mnswpr.js'`) and wires them together in `apps/mnswpr/main.js`. Owns its Firebase config (`firebase.json`, `firestore.rules`, `.firebaserc`) and app-specific scripts (`apps/mnswpr/scripts/`). A future app (e.g. sudoku) gets its own `apps/<name>/` and its `package.json` `name` is just the app name (`<name>`, unscoped) so it's addressable directly by name (`pnpm -F <name> run <script>`).
|
||||
- **`packages/mnswpr/`** — `@cozy-games/mnswpr`, the standalone, framework-free game engine published to npm. `packages/mnswpr/mnswpr.js` is the whole engine; `levels.js` defines the four difficulty presets. Depends only on `@cozy-games/utils`.
|
||||
- **`packages/leaderboard/`** — `@cozy-games/leaderboard`, a backend-agnostic, time-windowed leaderboard (adapter-injected storage).
|
||||
- **`packages/utils/`** — `@cozy-games/utils`, shared services with no dependencies, re-exported from `index.js`: `StorageService`, `TimerService` (`pretty()` time formatting used by both engine and leaderboard), `LoggerService`, `LoadingService`, and date-bucket helpers.
|
||||
- **`sites/`** — docs (Astro Starlight) and UI demos. Placeholders for now.
|
||||
|
||||
## 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.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.
|
||||
|
||||
**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`.
|
||||
|
||||
|
|
@ -60,9 +113,9 @@ The **Firebase config in `leader-board.js` is intentionally public and committed
|
|||
## 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`).
|
||||
- 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)
|
||||
|
||||
- **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
92
AYO.md
|
|
@ -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
122
CONTRIBUTING.md
Normal 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
138
README.md
|
|
@ -1,123 +1,45 @@
|
|||
> [!IMPORTANT]
|
||||
> **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)**.
|
||||
# Cozy Games
|
||||
|
||||
# Play Minesweeper Online for Free
|
||||
[](https://app.netlify.com/sites/mnswpr/deploys)
|
||||
A growing collection of small browser games and the shared, reusable packages that power them.
|
||||
|
||||
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.
|
||||
|
||||

|
||||
# 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
|
||||
- **Right click** — flag / unflag a suspected mine
|
||||
- **Left + right click together** (chording) — reveal the neighbors of a satisfied number
|
||||
- **Touch** — tap to reveal, long-press to flag
|
||||
| Package | Develop | Publish | Document |
|
||||
| -------------------- | -------------- | ------- | -------- |
|
||||
| `mnswpr` (game core) | ✅ Built | | |
|
||||
| leaderboard | ✅ Built | | |
|
||||
| move-log | ✅ Built | | |
|
||||
| replay engine | ✅ Built | | |
|
||||
| rating math | 🚧 Development | | |
|
||||
| `sudoku` (game core) | 🔮 Planned | | |
|
||||
|
||||
|
||||
## 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.
|
||||
> Note: `@ayo-run/mnswpr` on npm predates this project and will be deprecated in favor of `cozy-games/mnswpr`.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! See [`AGENTS.md`](./AGENTS.md) for the architecture, conventions, and release workflow before opening a pull request.
|
||||
|
||||
## You just want to play?
|
||||
|
||||
*👉 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) ✨
|
||||
Setup, running games locally, testing, code style, and local infra all live in
|
||||
**[CONTRIBUTING.md](CONTRIBUTING.md)**. In short — this is a
|
||||
[pnpm](https://pnpm.io) workspace:
|
||||
|
||||
```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
|
||||
|
||||
[BSD-2-Clause](./LICENSE)
|
||||
|
||||
---
|
||||
|
||||
_Just keep building._<br>
|
||||
_A project by [Ayo](https://ayo.ayco.io)_
|
||||
BSD-2-Clause © Ayo Ayco
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
||||
<!-- TODO: replace with the actual screenshot/GIF once supplied (confirm final path/filename) -->
|
||||

|
||||
|
||||
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
|
||||
|
||||
|
|
@ -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
|
||||
- **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
|
||||
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.
|
||||
|
||||
## 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`
|
||||
2. Start the dev server: `pnpm -F mnswpr 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
|
||||
pnpm test # run the Vitest suite (workspace-wide)
|
||||
pnpm lint # ESLint (JS + CSS)
|
||||
pnpm lint:fix # ESLint with autofix
|
||||
pnpm -F mnswpr run 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`.
|
||||
|
||||
`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
|
||||
|
||||
Contributions are welcome! See [`AGENTS.md`](./AGENTS.md) for the architecture, conventions, and release workflow before opening a pull request.
|
||||
|
|
@ -98,23 +98,26 @@ Dev config lives in the committed [`app/.env.development`](../app/.env.developme
|
|||
|
||||
### 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
|
||||
npx firebase login
|
||||
pnpm -F mnswpr exec firebase login
|
||||
```
|
||||
|
||||
### Deploy rules + indexes
|
||||
|
||||
Deploys go to **production** by default. Target a project explicitly with
|
||||
`--project`:
|
||||
The `deploy:db` script (`pnpm -F mnswpr run deploy:db`) deploys everything under
|
||||
`firestore` to the **default** project. To target a specific project or a subset,
|
||||
call the CLI directly:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
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
|
||||
|
|
@ -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).
|
||||
|
||||
> 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:
|
||||
|
||||
```bash
|
||||
pnpm emulators # terminal 1: Firestore emulator (+ Emulator UI) on :8080
|
||||
pnpm seed:emulator # terminal 2, once: fill it with sample scores
|
||||
pnpm dev # terminal 2: app runs against the local emulator
|
||||
pnpm -F mnswpr run dev # emulator (+ UI) on :8080, auto-seeded with sample scores, + app dev server
|
||||
```
|
||||
|
||||
`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`,
|
||||
which calls `connectFirestoreEmulator`. If the emulator isn't running the board
|
||||
just shows "unavailable" (a refused connection) — start it, or opt out.
|
||||
|
|
@ -139,8 +139,8 @@ Both aliases target the one project we actually have:
|
|||
|
||||
### 7. Deploy & verify
|
||||
|
||||
1. `npx firebase deploy --only firestore:rules,firestore:indexes --project prod`
|
||||
2. Set the Netlify env vars (`VITE_LB_NAMESPACE=mw` + `VITE_FIREBASE_*`).
|
||||
1. `pnpm -F mnswpr exec firebase deploy --only firestore:rules,firestore:indexes --project prod`
|
||||
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
|
||||
the prod `mw-scores` is untouched (Firestore console).
|
||||
4. Confirm reads on both `mw-scores` and `mw-test-scores` succeed under the new
|
||||
|
|
@ -4,13 +4,10 @@ rules_version = '2'
|
|||
// rules are the enforced schema: public reads for the boards, validated
|
||||
// create-only writes for scores, no client writes to server config.
|
||||
//
|
||||
// SECURITY MODEL: the Firebase web config is public (it ships to every browser),
|
||||
// so these rules — not secrecy of the config or the codebase — are what protect
|
||||
// the data. No collection allows a client to UPDATE or DELETE existing docs, so
|
||||
// no client (with or without the source) can wipe the production boards. The
|
||||
// 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").
|
||||
// The Firebase web config that ships in the client is public by design (it is
|
||||
// not a secret); these rules — not secrecy — are what govern access. No
|
||||
// collection allows a client to update or delete existing docs. Keep any
|
||||
// admin/service-account credentials out of the repo and locked down in GCP.
|
||||
//
|
||||
// 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
|
||||
|
|
@ -19,7 +16,7 @@ rules_version = '2'
|
|||
// block is guarded by a regex on the captured collection name.
|
||||
//
|
||||
// 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 {
|
||||
match /databases/{database}/documents {
|
||||
|
||||
|
|
@ -36,13 +33,15 @@ service cloud.firestore {
|
|||
allow write: if false;
|
||||
}
|
||||
|
||||
// Per-browser personal archive. Write-only from the client (the app never
|
||||
// reads it back), append/merge only, and NEVER deletable. This is the only
|
||||
// client-writable collection, so denying delete/read here removes the last
|
||||
// way a client could destroy or harvest data.
|
||||
// Per-browser personal archive. Not readable by clients, append/merge only,
|
||||
// and never deletable. The doc is a map of gameId -> game object (one doc
|
||||
// per day, merged as games finish); the entry-count cap keeps a single doc
|
||||
// bounded (Firestore already limits any doc to 1 MiB). 500 is far above any
|
||||
// real day of play.
|
||||
match /{all}/{browserId}/games/{session} {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -10,7 +10,6 @@
|
|||
|
||||
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="./main.css" />
|
||||
<link rel="stylesheet" href="../utils/loading/loading.css" />
|
||||
<style>
|
||||
:host, :root{
|
||||
--mnswpr-transition: 10s ease-in-out;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import mnswpr from '@ayo-run/mnswpr/mnswpr.js'
|
||||
import '@ayo-run/mnswpr/mnswpr.css'
|
||||
import * as pkg from '@ayo-run/mnswpr/package.json'
|
||||
import mnswpr from '@cozy-games/mnswpr/mnswpr.js'
|
||||
import '@cozy-games/mnswpr/mnswpr.css'
|
||||
import '@cozy-games/utils/loading/loading.css'
|
||||
import * as pkg from '@cozy-games/mnswpr/package.json'
|
||||
import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
|
||||
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
|
||||
import { NicknameService } from './modules/nickname/nickname.js'
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { StorageService } from '../../../utils/index.js'
|
||||
import { StorageService } from '@cozy-games/utils'
|
||||
|
||||
const NICK_KEY = 'nickname'
|
||||
const MAX_LENGTH = 24
|
||||
36
apps/mnswpr/netlify.toml
Normal file
36
apps/mnswpr/netlify.toml
Normal 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
30
apps/mnswpr/package.json
Normal 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"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,12 +1,12 @@
|
|||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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,
|
||||
* 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 { resolve, dirname } from 'node:path'
|
||||
|
|
@ -17,9 +17,9 @@ import {
|
|||
getFirestore, getDocs, collection, query, orderBy, limit
|
||||
} 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.
|
||||
const clean = (str, separator) => (str === '00' ? '' : `${str}${separator}`)
|
||||
const pretty = duration => {
|
||||
|
|
@ -131,6 +131,6 @@ ${sections.join('\n')}
|
|||
`
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const out = resolve(__dirname, '../app/legends.html')
|
||||
const out = resolve(__dirname, '../legends.html')
|
||||
writeFileSync(out, html)
|
||||
console.log(`\nWrote ${out}`)
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
* firestore.rules to be deployed first (they permit writes to
|
||||
* `mw-test-scores`), else every write returns permission-denied:
|
||||
* 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
|
||||
* Node can resolve it. Optional namespace override: LB_NAMESPACE=mw-test.
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
import { initializeApp } from 'firebase/app'
|
||||
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 = {
|
||||
apiKey: 'AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ',
|
||||
30
docs/decisions/0001-package-boundary.md
Normal file
30
docs/decisions/0001-package-boundary.md
Normal 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.
|
||||
34
docs/decisions/0002-game-adapter-pattern.md
Normal file
34
docs/decisions/0002-game-adapter-pattern.md
Normal 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.
|
||||
23
docs/decisions/0003-stored-boards-not-seeds.md
Normal file
23
docs/decisions/0003-stored-boards-not-seeds.md
Normal 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
14
docs/decisions/README.md
Normal 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)
|
||||
|
|
@ -50,7 +50,7 @@ export default defineConfig([
|
|||
}
|
||||
},
|
||||
{
|
||||
files: ['scripts/**/*.js'],
|
||||
files: ['**/scripts/**/*.{js,mjs,cjs}'],
|
||||
languageOptions: {
|
||||
globals: globals.node
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
22
package.json
22
package.json
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"name": "monorepo",
|
||||
"name": "cozy-games",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Classic Minesweeper browser game",
|
||||
"description": "Cozy Games monorepo — apps, shared packages, and sites",
|
||||
"author": "Ayo Ayco",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
|
|
@ -12,29 +12,29 @@
|
|||
"homepage": "https://mnswpr.com",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"dev": "pnpm -F mnswpr dev",
|
||||
"test:watch": "vitest",
|
||||
"dev": "vite app",
|
||||
"start": "vite app",
|
||||
"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",
|
||||
"build": "pnpm -r --filter \"./packages/*\" run build",
|
||||
"build:lib": "vite build packages/mnswpr",
|
||||
"publish:lib": "node scripts/publish-lib.js",
|
||||
"release": "pnpm build:lib && pnpm -F @ayo-run/mnswpr run release && pnpm publish:lib",
|
||||
"build:preview": "pnpm -F app run build:preview",
|
||||
"release": "pnpm build:lib && pnpm -F @cozy-games/mnswpr run release && pnpm publish:lib",
|
||||
"postinstall": "node scripts/ensure-java.mjs",
|
||||
"prepare": "husky",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
"lint:fix": "eslint . --fix",
|
||||
"scan:secrets": "secretlint --secretlintignore .secretlintignore \"**/*\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/css": "^1.1.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@secretlint/secretlint-rule-preset-recommend": "^13.0.2",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"bumpp": "^11.0.1",
|
||||
"eslint": "^10.1.0",
|
||||
"globals": "^17.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"secretlint": "^13.0.2",
|
||||
"simple-git": "^3.33.0",
|
||||
"vite": "^8.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
|
|
|
|||
|
|
@ -103,7 +103,8 @@ mounted elements.
|
|||
| `format` | `time` \| `number` \| `plain` | score display preset (below) |
|
||||
|
||||
Attributes are reactive: change `category`/`title` at runtime and the board
|
||||
re-renders, keeping the selected duration tab.
|
||||
re-renders, keeping the selected duration tab. Changing `score-order`/`format`
|
||||
rebuilds the element's service so the new order/preset takes effect.
|
||||
|
||||
### `format` presets
|
||||
|
||||
|
|
@ -230,5 +231,6 @@ The package computes the `day`/`week`/`month` bucket keys from `time_stamp`
|
|||
|
||||
The service is cached per element, so set overrides **before** the element
|
||||
connects (or set the property and clear the element's `_svc` to force a
|
||||
rebuild). `score-order` and `format` are read from attributes; the function/
|
||||
array/string overrides are read from properties.
|
||||
rebuild — changing the `score-order`/`format` attributes does this
|
||||
automatically). `score-order` and `format` are read from attributes; the
|
||||
function/array/string overrides are read from properties.
|
||||
|
|
@ -147,13 +147,63 @@ Want to author your own custom elements this way? Check out
|
|||
**[webcomponent.io](https://webcomponent.io)** and
|
||||
**[web-component-base](https://github.com/ayo-run/wcb)**.
|
||||
|
||||
## Separable read & write surfaces
|
||||
|
||||
The service is composed of two independently importable halves, so you never pull
|
||||
in code you don't use — and can point each half at a differently-privileged
|
||||
backend instance:
|
||||
|
||||
| Surface | Import | Uses | Adapter methods |
|
||||
| ------- | ------ | ---- | --------------- |
|
||||
| **Read / subscribe** | `@cozy-games/leaderboard/leaderboard-read.js` → `LeaderBoardReader` | `render()` — query a window + render the list | `listScores` |
|
||||
| **Write** | `@cozy-games/leaderboard/leaderboard-write.js` → `LeaderBoardWriter` | `submit()` — archive + ranked entry | `addScore`, optional `archive`, `getConfig` |
|
||||
|
||||
```js
|
||||
// Read-only page (public, less-privileged instance) — no write code loaded:
|
||||
import { LeaderBoardReader } from '@cozy-games/leaderboard/leaderboard-read.js'
|
||||
const board = new LeaderBoardReader({ adapter: readAdapter, formatScore })
|
||||
document.body.append(await board.render('beginner', 'Best Times'))
|
||||
|
||||
// Server / trusted path (privileged instance) — no DOM or render code loaded:
|
||||
import { LeaderBoardWriter } from '@cozy-games/leaderboard/leaderboard-write.js'
|
||||
const writer = new LeaderBoardWriter({ adapter: writeAdapter })
|
||||
await writer.submit(entry)
|
||||
```
|
||||
|
||||
The read module imports **no** write-path code (no bucket-key computation, no
|
||||
write adapter calls) and the write module imports **no** read/render code (no
|
||||
DOM, no `listScores`) — which also keeps each surface trivial to test in
|
||||
isolation. `LeaderBoardService` (and `<cozy-leaderboard>`) remain the combined
|
||||
facade — same `render()` + `submit()` API — for consumers that want both; it just
|
||||
composes a `LeaderBoardReader` and a `LeaderBoardWriter` (exposed as `.reader` /
|
||||
`.writer`).
|
||||
|
||||
## Choosing a backend
|
||||
|
||||
### Bring your own backend instance (injection)
|
||||
|
||||
Both adapters let the **consumer own the backend instance** — including a
|
||||
privileged/admin-level or server-side one — rather than the package creating its
|
||||
own. This is the injection point per adapter:
|
||||
|
||||
| Adapter | Injection point | Internal init fallback |
|
||||
| ---------- | -------------------------- | ----------------------------------- |
|
||||
| Supabase | `client` (a supabase-js client you build) — **always** consumer-supplied; the package takes no supabase dependency | none — a client is required |
|
||||
| Firebase | `store` (a Firestore instance you build) | `firebaseConfig` → the package initializes its own app |
|
||||
|
||||
Supply a privileged instance and every read/write runs against it — the package
|
||||
adds no auth or app lifecycle of its own.
|
||||
|
||||
### Firebase (Firestore)
|
||||
|
||||
```js
|
||||
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
|
||||
|
||||
// (a) let the package initialize from a public config:
|
||||
const adapter = new FirebaseAdapter({ firebaseConfig, namespace: 'mw' })
|
||||
|
||||
// (b) OR inject a Firestore instance you built (e.g. privileged/server-side):
|
||||
const adapter = new FirebaseAdapter({ store: myFirestore, namespace: 'mw' })
|
||||
```
|
||||
|
||||
Needs the `firebase` peer dependency. Uses collections
|
||||
|
|
@ -162,9 +212,10 @@ Needs the `firebase` peer dependency. Uses collections
|
|||
and all-time sorts by `score`, so only Firestore's automatic single-field indexes
|
||||
are needed — no composite indexes to deploy.
|
||||
|
||||
For local development, pass `emulator` to run against the
|
||||
With an injected `store` the package initializes nothing and owns no app
|
||||
lifecycle. For internal init, pass `emulator` to run against the
|
||||
[Firestore emulator](https://firebase.google.com/docs/emulator-suite) — no cloud,
|
||||
no deploy:
|
||||
no deploy (wire the emulator into your own store if you inject one):
|
||||
|
||||
```js
|
||||
new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator: { host: '127.0.0.1', port: 8080 } })
|
||||
|
|
@ -12,13 +12,28 @@ import {
|
|||
export class FirebaseAdapter {
|
||||
|
||||
/**
|
||||
* Supply EITHER a ready Firestore instance via `store` (the injection point —
|
||||
* e.g. a privileged/server-side setup, or an app you already initialized), OR a
|
||||
* `firebaseConfig` for the package to initialize its own app. `store` wins when
|
||||
* both are given; with an injected store the package initializes nothing and
|
||||
* owns no app lifecycle (so `emulator` — a convenience of internal init — is
|
||||
* ignored; wire the emulator into your own store).
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Object} options.firebaseConfig - Firebase app config (public; access governed by security rules)
|
||||
* @param {Object} [options.store] - a Firestore instance to use as-is (injection point)
|
||||
* @param {Object} [options.firebaseConfig] - Firebase app config for internal init (public; access governed by security rules)
|
||||
* @param {String} [options.namespace] - collection prefix
|
||||
* @param {{ host?: string, port?: number }} [options.emulator] - point at a local Firestore emulator (dev/test only)
|
||||
* @param {{ host?: string, port?: number }} [options.emulator] - point the internally-created store at a local Firestore emulator (dev/test only)
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.namespace = options.namespace || 'lb'
|
||||
if (options.store) {
|
||||
this.store = options.store
|
||||
return
|
||||
}
|
||||
if (!options.firebaseConfig) {
|
||||
throw new TypeError('FirebaseAdapter: provide either `store` (a Firestore instance) or `firebaseConfig`')
|
||||
}
|
||||
const app = initializeApp(options.firebaseConfig)
|
||||
this.store = getFirestore(app)
|
||||
if (options.emulator) {
|
||||
65
packages/leaderboard/leader-board.js
Normal file
65
packages/leaderboard/leader-board.js
Normal 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)
|
||||
}
|
||||
}
|
||||
300
packages/leaderboard/leaderboard-element.js
Normal file
300
packages/leaderboard/leaderboard-element.js
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -6,9 +14,10 @@ const DAY_MS = 24 * 60 * 60 * 1000
|
|||
* The four time windows are ROLLING: each shows entries from the last `ms`
|
||||
* milliseconds (strictly nested — 24h ⊆ 7d ⊆ 30d ⊆ all), sorted by score.
|
||||
* `ms: null` is the all-time view (no time filter). `title` is the hover tooltip
|
||||
* that spells out the window.
|
||||
* 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: 'week', label: 'Week', ms: 7 * DAY_MS, title: 'Last 7 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.
|
||||
* 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 = [
|
||||
'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
|
||||
* minesweeper or Firebase: the ranked value is a plain `score`, sorted in the
|
||||
* configured direction and displayed through an injected formatter, while all
|
||||
* storage I/O is delegated to an injected adapter (see adapters/). Games wire
|
||||
* their specifics through the constructor options.
|
||||
*
|
||||
* 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
|
||||
* Read-only leaderboard view: windows, sorting (via the adapter), and rendering.
|
||||
* Nothing here writes; the ranked value is a plain `score` displayed through an
|
||||
* injected formatter, and all query I/O is delegated to an injected adapter's
|
||||
* `listScores`. Safe to wire to a read-only / less-privileged backend instance.
|
||||
*/
|
||||
export class LeaderBoardService {
|
||||
export class LeaderBoardReader {
|
||||
|
||||
/**
|
||||
* @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 {(value: number) => string} [options.formatScore] - display formatter for a score
|
||||
* @param {(entry: Object) => boolean} [options.qualifies] - whether an entry is ranked; defaults to server passingStatus vs entry.status
|
||||
* @param {Object} [options.labels] - optional tab-label overrides keyed by duration id
|
||||
* @param {Object} [options.tooltips] - optional tab hover-text overrides keyed by duration id
|
||||
* @param {string[]} [options.emptyMessages] - empty-state messages (one picked at random); localize here
|
||||
|
|
@ -63,7 +64,6 @@ export class LeaderBoardService {
|
|||
this.adapter = options.adapter
|
||||
this.scoreOrder = options.scoreOrder === 'desc' ? 'desc' : 'asc'
|
||||
this.formatScore = options.formatScore || (value => String(value))
|
||||
this.qualifies = options.qualifies || (entry => this._defaultQualifies(entry))
|
||||
this.labels = options.labels || {}
|
||||
this.tooltips = options.tooltips || {}
|
||||
|
||||
|
|
@ -74,36 +74,42 @@ export class LeaderBoardService {
|
|||
this.loadingText = options.loadingText || 'Loading…'
|
||||
this.errorText = options.errorText || 'Leaderboard unavailable right now.'
|
||||
this.anonymousName = options.anonymousName || 'Anonymous'
|
||||
|
||||
Promise.resolve(this.adapter.getConfig())
|
||||
.then(config => {
|
||||
this.configuration = config
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Default ranking gate: if the server config names a `passingStatus`, only
|
||||
* entries whose `status` matches qualify; otherwise every entry qualifies.
|
||||
* Display label for a duration window (override-aware).
|
||||
* @param {{ id: String, label: String }} duration - a DURATIONS entry
|
||||
*/
|
||||
_defaultQualifies(entry) {
|
||||
const passing = this.configuration && this.configuration.passingStatus
|
||||
if (!passing) return true
|
||||
return entry.status === passing
|
||||
}
|
||||
|
||||
_label(duration) {
|
||||
label(duration) {
|
||||
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
|
||||
}
|
||||
|
||||
_emptyMessage() {
|
||||
/** One empty-state message, picked at random. */
|
||||
emptyMessage() {
|
||||
return this.emptyMessages[Math.floor(Math.random() * this.emptyMessages.length)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Data-level query: the ranked entries for a category and duration window,
|
||||
* without any DOM. View layers that render themselves (e.g. the
|
||||
* `<cozy-leaderboard>` element) use this instead of {@link render}.
|
||||
* @param {String} category
|
||||
* @param {String} durationId - a DURATIONS id ('today' | 'week' | 'month' | 'all')
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
async list(category, durationId) {
|
||||
const duration = DURATIONS.find(d => d.id === durationId)
|
||||
return this.adapter.listScores(this._descriptor(category, duration))
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend-neutral query descriptor for a category and time window. `since` is
|
||||
* the rolling cutoff (entries with `time_stamp >= since`); `null` means
|
||||
|
|
@ -140,7 +146,7 @@ export class LeaderBoardService {
|
|||
wrapper.style.margin = '0 auto'
|
||||
|
||||
const heading = document.createElement('h3')
|
||||
heading.innerText = title
|
||||
heading.textContent = title
|
||||
heading.style.borderBottom = '1px solid #c0c0c0'
|
||||
heading.style.paddingBottom = '10px'
|
||||
wrapper.append(heading)
|
||||
|
|
@ -164,9 +170,9 @@ export class LeaderBoardService {
|
|||
|
||||
DURATIONS.forEach(d => {
|
||||
const tab = document.createElement('button')
|
||||
tab.innerText = this._label(d)
|
||||
tab.textContent = this.label(d)
|
||||
tab.type = 'button'
|
||||
tab.setAttribute('title', this._tooltip(d))
|
||||
tab.setAttribute('title', this.tooltip(d))
|
||||
this._styleTab(tab, d.id === duration)
|
||||
tab.onclick = () => activate(d.id)
|
||||
tabs[d.id] = tab
|
||||
|
|
@ -203,7 +209,7 @@ export class LeaderBoardService {
|
|||
|
||||
listWrapper.innerHTML = ''
|
||||
const loading = document.createElement('em')
|
||||
loading.innerText = this.loadingText
|
||||
loading.textContent = this.loadingText
|
||||
listWrapper.append(loading)
|
||||
|
||||
try {
|
||||
|
|
@ -214,7 +220,7 @@ export class LeaderBoardService {
|
|||
if (this._loadToken !== token) return
|
||||
listWrapper.innerHTML = ''
|
||||
const message = document.createElement('em')
|
||||
message.innerText = this.errorText
|
||||
message.textContent = this.errorText
|
||||
listWrapper.append(message)
|
||||
}
|
||||
}
|
||||
|
|
@ -224,7 +230,7 @@ export class LeaderBoardService {
|
|||
|
||||
if (!rows || !rows.length) {
|
||||
const message = document.createElement('em')
|
||||
message.innerText = this._emptyMessage()
|
||||
message.textContent = this.emptyMessage()
|
||||
listWrapper.append(message)
|
||||
return
|
||||
}
|
||||
|
|
@ -239,11 +245,12 @@ export class LeaderBoardService {
|
|||
item.style.display = 'flex'
|
||||
|
||||
const indexElement = document.createElement('div')
|
||||
indexElement.innerText = `#${i++}`
|
||||
indexElement.textContent = `#${i++}`
|
||||
|
||||
const nameElement = document.createElement('div')
|
||||
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.style.textOverflow = 'ellipsis'
|
||||
nameElement.style.whiteSpace = 'nowrap'
|
||||
|
|
@ -254,7 +261,7 @@ export class LeaderBoardService {
|
|||
nameElement.style.flex = '1'
|
||||
|
||||
const scoreElement = document.createElement('div')
|
||||
scoreElement.innerText = this.formatScore(data.score)
|
||||
scoreElement.textContent = this.formatScore(data.score)
|
||||
|
||||
item.append(indexElement, nameElement, scoreElement)
|
||||
list.append(item)
|
||||
|
|
@ -262,41 +269,4 @@ export class LeaderBoardService {
|
|||
|
||||
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)
|
||||
}
|
||||
}
|
||||
80
packages/leaderboard/leaderboard-write.js
Normal file
80
packages/leaderboard/leaderboard-write.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { buckets } from '@cozy-games/utils/date-bucket/date-bucket.js'
|
||||
|
||||
/**
|
||||
* The WRITE surface of the leaderboard: submitting a completed game — the
|
||||
* personal archive plus, if it qualifies, a ranked entry with denormalized
|
||||
* day/week/month bucket keys. Importable WITHOUT any read/render code — no DOM,
|
||||
* no list rendering, no `listScores` — so a consumer can wire writes to a
|
||||
* separate, more-privileged backend instance (leaderboard-01) and pull in none
|
||||
* of the read surface.
|
||||
*
|
||||
* The WRITE side calls `adapter.archive` (optional) and `adapter.addScore`; it
|
||||
* also reads the ranking `config` once (an adapter call, not read/render code)
|
||||
* to power the default qualifier.
|
||||
*/
|
||||
export class LeaderBoardWriter {
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {Object} options.adapter - storage backend; the WRITE side uses `addScore`, optional `archive`, and `getConfig`
|
||||
* @param {(entry: Object) => boolean} [options.qualifies] - whether an entry is ranked; defaults to server passingStatus vs entry.status
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.adapter = options.adapter
|
||||
this.qualifies = options.qualifies || (entry => this._defaultQualifies(entry))
|
||||
|
||||
// The default qualifier depends on the server's ranking config, so load it
|
||||
// once. This is a config READ via the adapter — it pulls in no read/render
|
||||
// module, keeping the write surface importable on its own.
|
||||
Promise.resolve(this.adapter && this.adapter.getConfig ? this.adapter.getConfig() : undefined)
|
||||
.then(config => { this.configuration = config })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Default ranking gate: if the server config names a `passingStatus`, only
|
||||
* entries whose `status` matches qualify; otherwise every entry qualifies.
|
||||
*/
|
||||
_defaultQualifies(entry) {
|
||||
const passing = this.configuration && this.configuration.passingStatus
|
||||
if (!passing) return true
|
||||
return entry.status === passing
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a completed game. Always archives it (personal history); if it
|
||||
* qualifies, also writes a ranked entry with denormalized bucket keys. Both
|
||||
* writes go through the adapter, so the storage backend is pluggable. The
|
||||
* caller owns display-name/nickname UX.
|
||||
* @param {Object} entry - { name, playerId, score, category, time_stamp, status?, meta? }
|
||||
*/
|
||||
async submit(entry) {
|
||||
if (this.adapter.archive) {
|
||||
await this.adapter.archive({
|
||||
playerId: entry.playerId,
|
||||
score: entry.score,
|
||||
category: entry.category,
|
||||
time_stamp: entry.time_stamp,
|
||||
meta: entry.meta
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.qualifies(entry)) return
|
||||
|
||||
const stamp = entry.time_stamp instanceof Date ? entry.time_stamp : new Date(entry.time_stamp)
|
||||
const bucket = buckets(stamp)
|
||||
const scoreDoc = {
|
||||
name: entry.name || 'Anonymous',
|
||||
playerId: entry.playerId,
|
||||
score: entry.score,
|
||||
category: entry.category,
|
||||
time_stamp: entry.time_stamp,
|
||||
day: bucket.day,
|
||||
week: bucket.week,
|
||||
month: bucket.month
|
||||
}
|
||||
if (entry.meta) scoreDoc.meta = entry.meta
|
||||
|
||||
await this.adapter.addScore(entry.category, scoreDoc)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@
|
|||
"url": "https://github.com/ayo-run/mnswpr"
|
||||
},
|
||||
"main": "leader-board.js",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/leader-board.js"
|
||||
|
|
@ -25,7 +28,10 @@
|
|||
"./dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"web-component-base": "^4.1.2"
|
||||
"web-component-base": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cozy-games/utils": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"firebase": "^12.11.0"
|
||||
102
packages/leaderboard/test/firebase-adapter.test.js
Normal file
102
packages/leaderboard/test/firebase-adapter.test.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// @ts-check
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
// Stub the firebase SDK at the adapter boundary so no real app/network is touched.
|
||||
const fb = vi.hoisted(() => ({
|
||||
initializeApp: vi.fn(() => ({ __app: true })),
|
||||
getFirestore: vi.fn(() => ({ __internalStore: true })),
|
||||
connectFirestoreEmulator: vi.fn(),
|
||||
doc: vi.fn((...args) => ({ __ref: args })),
|
||||
getDoc: vi.fn(async () => ({ data: () => ({ passingStatus: 'ok' }) })),
|
||||
getDocs: vi.fn(async () => ({ docs: [] })),
|
||||
setDoc: vi.fn(async () => {}),
|
||||
collection: vi.fn((...args) => ({ __col: args })),
|
||||
query: vi.fn((...args) => ({ __q: args })),
|
||||
where: vi.fn((...args) => ({ __where: args })),
|
||||
orderBy: vi.fn((...args) => ({ __order: args })),
|
||||
limit: vi.fn((...args) => ({ __limit: args }))
|
||||
}))
|
||||
|
||||
vi.mock('firebase/app', () => ({ initializeApp: fb.initializeApp }))
|
||||
vi.mock('firebase/firestore/lite', () => ({
|
||||
getFirestore: fb.getFirestore,
|
||||
connectFirestoreEmulator: fb.connectFirestoreEmulator,
|
||||
doc: fb.doc,
|
||||
getDoc: fb.getDoc,
|
||||
getDocs: fb.getDocs,
|
||||
setDoc: fb.setDoc,
|
||||
collection: fb.collection,
|
||||
query: fb.query,
|
||||
where: fb.where,
|
||||
orderBy: fb.orderBy,
|
||||
limit: fb.limit
|
||||
}))
|
||||
|
||||
import { FirebaseAdapter } from '../adapters/firebase.js'
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('FirebaseAdapter — consumer-supplied (injected) store', () => {
|
||||
it('uses an injected store as-is and initializes nothing', () => {
|
||||
// Stand-in for a privileged / server-side Firestore instance.
|
||||
const store = { __adminStore: true }
|
||||
const adapter = new FirebaseAdapter({ store, namespace: 'mw' })
|
||||
expect(adapter.store).toBe(store)
|
||||
expect(fb.initializeApp).not.toHaveBeenCalled()
|
||||
expect(fb.getFirestore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('performs a write through the injected store', async () => {
|
||||
const store = { __adminStore: true }
|
||||
const adapter = new FirebaseAdapter({ store, namespace: 'mw' })
|
||||
const entry = { name: 'A', score: 42, category: 'beginner', time_stamp: 123 }
|
||||
|
||||
await adapter.addScore('beginner', entry)
|
||||
|
||||
// The collection was built from OUR store, and the entry was written.
|
||||
expect(fb.collection).toHaveBeenCalledWith(store, 'mw-scores', 'beginner', 'games')
|
||||
expect(fb.setDoc).toHaveBeenCalledTimes(1)
|
||||
expect(fb.setDoc.mock.calls[0][1]).toEqual(entry)
|
||||
})
|
||||
|
||||
it('archives through the injected store', async () => {
|
||||
const store = { __adminStore: true }
|
||||
const adapter = new FirebaseAdapter({ store, namespace: 'mw' })
|
||||
await adapter.archive({ playerId: 'p1', score: 9, category: 'beginner', time_stamp: 5 })
|
||||
expect(fb.doc).toHaveBeenCalledWith(store, 'mw-all', 'p1', 'games', expect.any(String))
|
||||
expect(fb.setDoc).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not auto-connect the emulator for an injected store (consumer owns it)', () => {
|
||||
new FirebaseAdapter({ store: { __s: 1 }, namespace: 'mw', emulator: { host: 'x', port: 1 } })
|
||||
expect(fb.connectFirestoreEmulator).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefers the injected store when both store and firebaseConfig are given', () => {
|
||||
const store = { __adminStore: true }
|
||||
const adapter = new FirebaseAdapter({ store, firebaseConfig: { projectId: 'p' } })
|
||||
expect(adapter.store).toBe(store)
|
||||
expect(fb.initializeApp).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FirebaseAdapter — internal init (existing path, unbroken)', () => {
|
||||
it('still initializes its own store from firebaseConfig and writes', async () => {
|
||||
const adapter = new FirebaseAdapter({ firebaseConfig: { projectId: 'p' }, namespace: 'mw' })
|
||||
expect(fb.initializeApp).toHaveBeenCalledWith({ projectId: 'p' })
|
||||
expect(fb.getFirestore).toHaveBeenCalledTimes(1)
|
||||
expect(adapter.store).toEqual({ __internalStore: true })
|
||||
|
||||
await adapter.addScore('beginner', { score: 1 })
|
||||
expect(fb.setDoc).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still connects the emulator when configured', () => {
|
||||
new FirebaseAdapter({ firebaseConfig: {}, emulator: { host: '127.0.0.1', port: 8080 } })
|
||||
expect(fb.connectFirestoreEmulator).toHaveBeenCalledWith({ __internalStore: true }, '127.0.0.1', 8080)
|
||||
})
|
||||
|
||||
it('throws a clear error when neither store nor firebaseConfig is given', () => {
|
||||
expect(() => new FirebaseAdapter({})).toThrow(TypeError)
|
||||
})
|
||||
})
|
||||
592
packages/leaderboard/test/leaderboard-element.test.js
Normal file
592
packages/leaderboard/test/leaderboard-element.test.js
Normal file
|
|
@ -0,0 +1,592 @@
|
|||
// @ts-check
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { configureLeaderboard } from '../leaderboard-element.js'
|
||||
|
||||
/**
|
||||
* Characterization tests for <cozy-leaderboard>: every externally observable
|
||||
* behavior of the element, written against the element's public surface only
|
||||
* (attributes, properties, produced DOM, adapter calls) so the implementation
|
||||
* can be refactored while this suite stays green.
|
||||
*
|
||||
* Two behaviors are deliberately NOT pinned here:
|
||||
* - entry names render via innerHTML today (an XSS hazard); only the text is
|
||||
* asserted, so a safe text rendering also passes.
|
||||
* - score-order / format attribute CHANGES after the first mount are silently
|
||||
* ignored today (the per-element service caches its config); honoring them
|
||||
* is an allowed improvement.
|
||||
*/
|
||||
|
||||
const DEFAULT_EMPTY_MESSAGES = [
|
||||
'Be the first to enter the leader board!',
|
||||
'No scores yet — claim the top spot!',
|
||||
'This board is wide open. Conquer it!',
|
||||
'No champions here yet. Will it be you?',
|
||||
'Blank slate — set the score to beat!',
|
||||
'Nobody\'s here yet. Be the first!',
|
||||
'The top spot is up for grabs. Take it!',
|
||||
'Empty board. Time to make your mark!'
|
||||
]
|
||||
|
||||
const makeAdapter = (rows = [], overrides = {}) => ({
|
||||
listScores: vi.fn(async () => rows),
|
||||
addScore: vi.fn(async () => {}),
|
||||
archive: vi.fn(async () => {}),
|
||||
getConfig: vi.fn(async () => undefined),
|
||||
...overrides
|
||||
})
|
||||
|
||||
// Reset every shared-config key; `undefined` falls through all fallback chains.
|
||||
const resetSharedConfig = () => configureLeaderboard({
|
||||
adapter: undefined,
|
||||
scoreOrder: undefined,
|
||||
format: undefined,
|
||||
formatScore: undefined,
|
||||
qualifies: undefined,
|
||||
labels: undefined,
|
||||
tooltips: undefined,
|
||||
emptyMessages: undefined,
|
||||
loadingText: undefined,
|
||||
errorText: undefined,
|
||||
anonymousName: undefined
|
||||
})
|
||||
|
||||
/**
|
||||
* Create a disconnected element, apply attributes and per-element override
|
||||
* properties (they must be set before connect), then connect it — the
|
||||
* documented composition flow.
|
||||
*/
|
||||
const mount = (attrs = {}, props = {}) => {
|
||||
const el = /** @type {any} */ (document.createElement('cozy-leaderboard'))
|
||||
Object.entries(attrs).forEach(([name, value]) => el.setAttribute(name, value))
|
||||
Object.assign(el, props)
|
||||
document.body.append(el)
|
||||
return el
|
||||
}
|
||||
|
||||
const tabButtons = el => [...el.querySelectorAll('button')]
|
||||
const tabByLabel = (el, label) => tabButtons(el).find(b => b.textContent === label)
|
||||
const rowsText = el => el.textContent
|
||||
|
||||
beforeEach(() => resetSharedConfig())
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
describe('unconfigured state', () => {
|
||||
it('renders the not-configured message when no adapter exists anywhere', async () => {
|
||||
const el = mount({ category: 'beginner', title: 'Best Times' })
|
||||
await vi.waitFor(() => {
|
||||
const em = el.querySelector('em')
|
||||
expect(em).toBeTruthy()
|
||||
expect(em.textContent).toBe('Leaderboard not configured.')
|
||||
})
|
||||
})
|
||||
|
||||
it('mounts the board when configureLeaderboard() supplies an adapter later', async () => {
|
||||
const el = mount({ category: 'beginner', title: 'Best Times' })
|
||||
await vi.waitFor(() => expect(el.textContent).toContain('Leaderboard not configured.'))
|
||||
|
||||
const adapter = makeAdapter([{ name: 'Ada', score: 3 }])
|
||||
configureLeaderboard({ adapter })
|
||||
await vi.waitFor(() => {
|
||||
expect(el.querySelector('h3')).toBeTruthy()
|
||||
expect(rowsText(el)).toContain('Ada')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('board structure', () => {
|
||||
it('renders heading, four duration tabs with tooltips, and ranked rows', async () => {
|
||||
const adapter = makeAdapter([
|
||||
{ name: 'Ada', score: 3 },
|
||||
{ name: 'Bo', score: 5 }
|
||||
])
|
||||
const el = mount({ category: 'beginner', title: 'Best Times' }, { adapter })
|
||||
|
||||
await vi.waitFor(() => expect(rowsText(el)).toContain('Ada'))
|
||||
|
||||
const heading = el.querySelector('h3')
|
||||
expect(heading.textContent).toBe('Best Times')
|
||||
|
||||
const tabs = tabButtons(el)
|
||||
expect(tabs.map(t => t.textContent)).toEqual(['Today', 'Week', 'Month', 'All Time'])
|
||||
expect(tabs.map(t => t.getAttribute('title'))).toEqual([
|
||||
'Last 24 hours', 'Last 7 days', 'Last 30 days', 'All time'
|
||||
])
|
||||
tabs.forEach(t => expect(t.type).toBe('button'))
|
||||
|
||||
// Ranked rows: index, name (with hover title), formatted score.
|
||||
expect(rowsText(el)).toContain('#1')
|
||||
expect(rowsText(el)).toContain('#2')
|
||||
expect(rowsText(el)).toContain('3')
|
||||
expect(rowsText(el)).toContain('5')
|
||||
const nameCells = [...el.querySelectorAll('[title="Ada"], [title="Bo"]')]
|
||||
expect(nameCells).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows the loading text while the query is in flight', async () => {
|
||||
let resolve
|
||||
const adapter = makeAdapter([], {
|
||||
listScores: vi.fn(() => new Promise(r => { resolve = r }))
|
||||
})
|
||||
const el = mount({ category: 'beginner', title: 'Best Times' }, { adapter })
|
||||
|
||||
await vi.waitFor(() => expect(rowsText(el)).toContain('Loading…'))
|
||||
expect(el.querySelector('h3')).toBeTruthy() // heading + tabs render before data
|
||||
|
||||
resolve([{ name: 'Ada', score: 3 }])
|
||||
await vi.waitFor(() => expect(rowsText(el)).toContain('Ada'))
|
||||
expect(rowsText(el)).not.toContain('Loading…')
|
||||
})
|
||||
|
||||
it('marks the active tab and leaves the others inactive', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const el = mount({ category: 'beginner', title: 'Best Times' }, { adapter })
|
||||
await vi.waitFor(() => expect(tabButtons(el)).toHaveLength(4))
|
||||
|
||||
const today = tabByLabel(el, 'Today')
|
||||
const week = tabByLabel(el, 'Week')
|
||||
expect(today.style.fontWeight).toBe('bold')
|
||||
expect(today.style.borderBottom).toContain('orange')
|
||||
expect(week.style.fontWeight).toBe('normal')
|
||||
expect(week.style.borderBottom).toContain('transparent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('queries', () => {
|
||||
it('queries the \'today\' rolling window by default, limited to 10', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
mount({ category: 'beginner', title: 'Best Times' }, { adapter })
|
||||
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
|
||||
const q = adapter.listScores.mock.calls[0][0]
|
||||
expect(q.category).toBe('beginner')
|
||||
expect(q.order).toBe('asc')
|
||||
expect(q.limit).toBe(10)
|
||||
expect(q.since).toBeInstanceOf(Date)
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
expect(Math.abs(Date.now() - dayMs - q.since.getTime())).toBeLessThan(5000)
|
||||
})
|
||||
|
||||
it('honors the duration attribute on first mount (all → no time filter)', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const el = mount({ category: 'beginner', title: 'Best', duration: 'all' }, { adapter })
|
||||
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
|
||||
expect(adapter.listScores.mock.calls[0][0].since).toBeNull()
|
||||
await vi.waitFor(() =>
|
||||
expect(tabByLabel(el, 'All Time').style.fontWeight).toBe('bold'))
|
||||
})
|
||||
|
||||
it('passes score-order=\'desc\' through to the query', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
mount({ category: 'beginner', title: 'Best', 'score-order': 'desc' }, { adapter })
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
|
||||
expect(adapter.listScores.mock.calls[0][0].order).toBe('desc')
|
||||
})
|
||||
|
||||
it('re-queries the clicked tab window in place', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
|
||||
await vi.waitFor(() => expect(tabButtons(el)).toHaveLength(4))
|
||||
|
||||
tabByLabel(el, 'Week').click()
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
|
||||
const q = adapter.listScores.mock.calls[1][0]
|
||||
const weekMs = 7 * 24 * 60 * 60 * 1000
|
||||
expect(Math.abs(Date.now() - weekMs - q.since.getTime())).toBeLessThan(5000)
|
||||
await vi.waitFor(() =>
|
||||
expect(tabByLabel(el, 'Week').style.fontWeight).toBe('bold'))
|
||||
expect(tabByLabel(el, 'Today').style.fontWeight).toBe('normal')
|
||||
})
|
||||
|
||||
it('keeps focus on the clicked tab', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
|
||||
await vi.waitFor(() => expect(tabButtons(el)).toHaveLength(4))
|
||||
|
||||
const week = tabByLabel(el, 'Week')
|
||||
week.focus()
|
||||
week.click()
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
|
||||
expect(document.activeElement?.textContent).toBe('Week')
|
||||
})
|
||||
})
|
||||
|
||||
describe('attribute changes after mount', () => {
|
||||
it('category change re-queries with the new category and keeps the selected tab', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
|
||||
await vi.waitFor(() => expect(tabButtons(el)).toHaveLength(4))
|
||||
|
||||
tabByLabel(el, 'Week').click()
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
|
||||
|
||||
el.setAttribute('category', 'expert')
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(3))
|
||||
const q = adapter.listScores.mock.calls[2][0]
|
||||
expect(q.category).toBe('expert')
|
||||
const weekMs = 7 * 24 * 60 * 60 * 1000
|
||||
expect(Math.abs(Date.now() - weekMs - q.since.getTime())).toBeLessThan(5000)
|
||||
await vi.waitFor(() =>
|
||||
expect(tabByLabel(el, 'Week').style.fontWeight).toBe('bold'))
|
||||
})
|
||||
|
||||
it('title change updates the heading', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
|
||||
await vi.waitFor(() => expect(el.querySelector('h3')).toBeTruthy())
|
||||
|
||||
el.setAttribute('title', 'Best Times (Expert)')
|
||||
await vi.waitFor(() =>
|
||||
expect(el.querySelector('h3').textContent).toBe('Best Times (Expert)'))
|
||||
})
|
||||
|
||||
it('duration change switches the window', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(1))
|
||||
|
||||
el.setAttribute('duration', 'month')
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
|
||||
const q = adapter.listScores.mock.calls[1][0]
|
||||
const monthMs = 30 * 24 * 60 * 60 * 1000
|
||||
expect(Math.abs(Date.now() - monthMs - q.since.getTime())).toBeLessThan(5000)
|
||||
await vi.waitFor(() =>
|
||||
expect(tabByLabel(el, 'Month').style.fontWeight).toBe('bold'))
|
||||
})
|
||||
|
||||
it('a stale query result never overwrites a newer one', async () => {
|
||||
const deferred = {}
|
||||
const adapter = makeAdapter([], {
|
||||
listScores: vi.fn(({ category }) => new Promise(r => { deferred[category] = r }))
|
||||
})
|
||||
const el = mount({ category: 'aaa', title: 'Best' }, { adapter })
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(1))
|
||||
|
||||
el.setAttribute('category', 'bbb')
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
|
||||
|
||||
deferred.bbb([{ name: 'NewRow', score: 2 }])
|
||||
await vi.waitFor(() => expect(rowsText(el)).toContain('NewRow'))
|
||||
|
||||
deferred.aaa([{ name: 'StaleRow', score: 1 }]) // stale response arrives late
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(rowsText(el)).not.toContain('StaleRow')
|
||||
expect(rowsText(el)).toContain('NewRow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty and error states', () => {
|
||||
it('shows one of the empty-state messages when there are no rows', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
|
||||
await vi.waitFor(() => {
|
||||
const messages = [...el.querySelectorAll('em')].map(em => em.textContent)
|
||||
expect(messages.some(m => DEFAULT_EMPTY_MESSAGES.includes(m))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('turns a failing query into the error message', async () => {
|
||||
const adapter = makeAdapter([], {
|
||||
listScores: vi.fn(async () => { throw new Error('backend down') })
|
||||
})
|
||||
const el = mount({ category: 'beginner', title: 'Best' }, { adapter })
|
||||
await vi.waitFor(() =>
|
||||
expect(rowsText(el)).toContain('Leaderboard unavailable right now.'))
|
||||
expect(el.querySelector('h3')).toBeTruthy() // heading + tabs survive the failure
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatting and user-facing strings', () => {
|
||||
it('format=\'time\' renders scores with the pretty-time formatter', async () => {
|
||||
const adapter = makeAdapter([
|
||||
{ name: 'Ada', score: 4200 },
|
||||
{ name: 'Bo', score: 61300 }
|
||||
])
|
||||
const el = mount({ category: 'b', title: 'T', format: 'time' }, { adapter })
|
||||
await vi.waitFor(() => {
|
||||
expect(rowsText(el)).toContain('04.2')
|
||||
expect(rowsText(el)).toContain('01:01.3')
|
||||
})
|
||||
})
|
||||
|
||||
it('a per-element formatScore property wins over the format attribute', async () => {
|
||||
const adapter = makeAdapter([{ name: 'Ada', score: 4200 }])
|
||||
const el = mount(
|
||||
{ category: 'b', title: 'T', format: 'time' },
|
||||
{ adapter, formatScore: v => `${v}pts` }
|
||||
)
|
||||
await vi.waitFor(() => expect(rowsText(el)).toContain('4200pts'))
|
||||
})
|
||||
|
||||
it('falls back to shared-config format from configureLeaderboard()', async () => {
|
||||
const adapter = makeAdapter([{ name: 'Ada', score: 4200 }])
|
||||
configureLeaderboard({ adapter, format: 'time' })
|
||||
const el = mount({ category: 'b', title: 'T' })
|
||||
await vi.waitFor(() => expect(rowsText(el)).toContain('04.2'))
|
||||
})
|
||||
|
||||
it('honors per-element string overrides (loadingText, errorText, anonymousName)', async () => {
|
||||
let reject
|
||||
const adapter = makeAdapter([], {
|
||||
listScores: vi.fn(() => new Promise((_, rj) => { reject = rj }))
|
||||
})
|
||||
const el = mount({ category: 'b', title: 'T' }, {
|
||||
adapter,
|
||||
loadingText: 'Hold on…',
|
||||
errorText: 'Nope.',
|
||||
anonymousName: 'Mystery Player'
|
||||
})
|
||||
await vi.waitFor(() => expect(rowsText(el)).toContain('Hold on…'))
|
||||
reject(new Error('x'))
|
||||
await vi.waitFor(() => expect(rowsText(el)).toContain('Nope.'))
|
||||
})
|
||||
|
||||
it('uses the anonymous name for rows without a name', async () => {
|
||||
const adapter = makeAdapter([{ score: 9 }])
|
||||
const el = mount({ category: 'b', title: 'T' }, { adapter, anonymousName: 'Mystery' })
|
||||
await vi.waitFor(() => expect(rowsText(el)).toContain('Mystery'))
|
||||
})
|
||||
|
||||
it('honors shared-config labels, tooltips and emptyMessages', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
configureLeaderboard({
|
||||
adapter,
|
||||
labels: { today: 'Heute' },
|
||||
tooltips: { today: 'Letzte 24 Stunden' },
|
||||
emptyMessages: ['Nichts hier.']
|
||||
})
|
||||
const el = mount({ category: 'b', title: 'T' })
|
||||
await vi.waitFor(() => {
|
||||
const tab = tabByLabel(el, 'Heute')
|
||||
expect(tab).toBeTruthy()
|
||||
expect(tab.getAttribute('title')).toBe('Letzte 24 Stunden')
|
||||
expect(rowsText(el)).toContain('Nichts hier.')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit()', () => {
|
||||
const entry = () => ({
|
||||
name: 'Zed',
|
||||
playerId: 'p1',
|
||||
score: 42,
|
||||
category: 'beginner',
|
||||
time_stamp: new Date('2026-07-03T12:00:00Z'),
|
||||
status: 'win',
|
||||
meta: { isMobile: false }
|
||||
})
|
||||
|
||||
it('archives and writes a ranked entry with bucket keys', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const el = mount({ category: 'beginner', title: 'T' }, { adapter })
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
|
||||
|
||||
await el.submit(entry())
|
||||
|
||||
expect(adapter.archive).toHaveBeenCalledWith({
|
||||
playerId: 'p1',
|
||||
score: 42,
|
||||
category: 'beginner',
|
||||
time_stamp: entry().time_stamp,
|
||||
meta: { isMobile: false }
|
||||
})
|
||||
expect(adapter.addScore).toHaveBeenCalledTimes(1)
|
||||
const [category, doc] = adapter.addScore.mock.calls[0]
|
||||
expect(category).toBe('beginner')
|
||||
expect(doc).toMatchObject({
|
||||
name: 'Zed',
|
||||
playerId: 'p1',
|
||||
score: 42,
|
||||
day: '2026-07-03',
|
||||
week: '2026-W27',
|
||||
month: '2026-07'
|
||||
})
|
||||
})
|
||||
|
||||
it('skips the ranked write when the entry does not qualify', async () => {
|
||||
const adapter = makeAdapter([], {
|
||||
getConfig: vi.fn(async () => ({ passingStatus: 'win' }))
|
||||
})
|
||||
const el = mount({ category: 'beginner', title: 'T' }, { adapter })
|
||||
await vi.waitFor(() => expect(adapter.getConfig).toHaveBeenCalled())
|
||||
await new Promise(r => setTimeout(r, 0)) // let the writer store the config
|
||||
|
||||
await el.submit({ ...entry(), status: 'lose' })
|
||||
expect(adapter.archive).toHaveBeenCalledTimes(1)
|
||||
expect(adapter.addScore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors a per-element qualifies override', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const el = mount({ category: 'beginner', title: 'T' }, {
|
||||
adapter,
|
||||
qualifies: () => false
|
||||
})
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalled())
|
||||
|
||||
await el.submit(entry())
|
||||
expect(adapter.addScore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a safe no-op on an unconfigured element', async () => {
|
||||
const el = mount({ category: 'beginner', title: 'T' })
|
||||
await vi.waitFor(() => expect(rowsText(el)).toContain('Leaderboard not configured.'))
|
||||
expect(el.submit(entry())).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('regressions pinned after review', () => {
|
||||
it('renders entry names as text, never as HTML (element path)', async () => {
|
||||
const adapter = makeAdapter([{ name: '<img src=x onerror=alert(1)>', score: 1 }])
|
||||
const el = mount({ category: 'b', title: 'T' }, { adapter })
|
||||
await vi.waitFor(() => expect(rowsText(el)).toContain('<img src=x onerror=alert(1)>'))
|
||||
expect(el.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('an invalid duration id shows the error text without querying the adapter', async () => {
|
||||
const adapter = makeAdapter([{ name: 'Ada', score: 1 }])
|
||||
const el = mount({ category: 'b', title: 'T', duration: 'yesterday' }, { adapter })
|
||||
await vi.waitFor(() =>
|
||||
expect(rowsText(el)).toContain('Leaderboard unavailable right now.'))
|
||||
expect(adapter.listScores).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a title change does not re-query the backend', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const el = mount({ category: 'b', title: 'T' }, { adapter })
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(1))
|
||||
|
||||
el.setAttribute('title', 'New Title')
|
||||
await vi.waitFor(() => expect(el.querySelector('h3').textContent).toBe('New Title'))
|
||||
expect(adapter.listScores).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a score-order change after mount takes effect on the next query', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const el = mount({ category: 'b', title: 'T', 'score-order': 'asc' }, { adapter })
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(1))
|
||||
expect(adapter.listScores.mock.calls[0][0].order).toBe('asc')
|
||||
|
||||
el.setAttribute('score-order', 'desc')
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
|
||||
expect(adapter.listScores.mock.calls[1][0].order).toBe('desc')
|
||||
})
|
||||
|
||||
it('a format change after mount takes effect on the next render', async () => {
|
||||
const adapter = makeAdapter([{ name: 'Ada', score: 4200 }])
|
||||
const el = mount({ category: 'b', title: 'T' }, { adapter })
|
||||
await vi.waitFor(() => expect(rowsText(el)).toContain('4200'))
|
||||
|
||||
el.setAttribute('format', 'time')
|
||||
await vi.waitFor(() => expect(rowsText(el)).toContain('04.2'))
|
||||
})
|
||||
|
||||
it('a score-order change while detached takes effect on re-connect', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const el = mount({ category: 'b', title: 'T', 'score-order': 'asc' }, { adapter })
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(1))
|
||||
|
||||
el.remove()
|
||||
el.setAttribute('score-order', 'desc')
|
||||
document.body.append(el)
|
||||
await vi.waitFor(() => {
|
||||
const calls = adapter.listScores.mock.calls
|
||||
expect(calls[calls.length - 1][0].order).toBe('desc')
|
||||
})
|
||||
})
|
||||
|
||||
it('configureLeaderboard() re-mounts every live element, keeping each selected tab', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
configureLeaderboard({ adapter })
|
||||
const one = mount({ category: 'a', title: 'One' })
|
||||
const two = mount({ category: 'b', title: 'Two' })
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
|
||||
|
||||
tabByLabel(two, 'Week').click()
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(3))
|
||||
|
||||
// Re-configuring re-mounts BOTH live elements (string overrides won't
|
||||
// apply to their already-cached services — pinned separately below).
|
||||
configureLeaderboard({})
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(5))
|
||||
// element two kept its Week selection through the shared re-mount
|
||||
await vi.waitFor(() => {
|
||||
expect(tabByLabel(two, 'Week').style.fontWeight).toBe('bold')
|
||||
expect(tabByLabel(one, 'Today').style.fontWeight).toBe('bold')
|
||||
})
|
||||
})
|
||||
|
||||
it('a detached element is not re-mounted by configureLeaderboard, but remembers its tab on re-connect', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const el = mount({ category: 'b', title: 'T' }, { adapter })
|
||||
await vi.waitFor(() => expect(tabButtons(el)).toHaveLength(4))
|
||||
tabByLabel(el, 'Month').click()
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
|
||||
|
||||
el.remove()
|
||||
const callsWhileDetached = adapter.listScores.mock.calls.length
|
||||
configureLeaderboard({ loadingText: 'Wait…' })
|
||||
expect(adapter.listScores.mock.calls.length).toBe(callsWhileDetached)
|
||||
|
||||
document.body.append(el)
|
||||
await vi.waitFor(() =>
|
||||
expect(tabByLabel(el, 'Month').style.fontWeight).toBe('bold'))
|
||||
})
|
||||
|
||||
it('keeps the cached service (old adapter) when configureLeaderboard swaps adapters later', async () => {
|
||||
const first = makeAdapter([])
|
||||
configureLeaderboard({ adapter: first })
|
||||
mount({ category: 'b', title: 'T' })
|
||||
await vi.waitFor(() => expect(first.listScores).toHaveBeenCalledTimes(1))
|
||||
|
||||
const second = makeAdapter([])
|
||||
configureLeaderboard({ adapter: second })
|
||||
await vi.waitFor(() => expect(first.listScores).toHaveBeenCalledTimes(2))
|
||||
expect(second.listScores).not.toHaveBeenCalled() // documented caching semantics
|
||||
})
|
||||
|
||||
it('keeps the cached service (old strings) when configureLeaderboard changes strings later', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
configureLeaderboard({ adapter })
|
||||
const el = mount({ category: 'b', title: 'T' })
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(1))
|
||||
|
||||
configureLeaderboard({ emptyMessages: ['Nothing here yet.'] })
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
|
||||
// the already-built service keeps its original strings (documented caching)
|
||||
expect(rowsText(el)).not.toContain('Nothing here yet.')
|
||||
})
|
||||
|
||||
it('keeps focus on a clicked tab when composed inside a shadow root', async () => {
|
||||
const adapter = makeAdapter([])
|
||||
const host = document.createElement('div')
|
||||
const shadow = host.attachShadow({ mode: 'open' })
|
||||
const el = /** @type {any} */ (document.createElement('cozy-leaderboard'))
|
||||
el.setAttribute('category', 'b')
|
||||
el.setAttribute('title', 'T')
|
||||
el.adapter = adapter
|
||||
shadow.append(el)
|
||||
document.body.append(host)
|
||||
await vi.waitFor(() => expect(el.querySelectorAll('button')).toHaveLength(4))
|
||||
|
||||
const week = [...el.querySelectorAll('button')].find(b => b.textContent === 'Week')
|
||||
week.focus()
|
||||
week.click()
|
||||
await vi.waitFor(() => expect(adapter.listScores).toHaveBeenCalledTimes(2))
|
||||
expect(shadow.activeElement?.textContent).toBe('Week')
|
||||
})
|
||||
|
||||
it('pins prettyTime edge cases through the format attribute', async () => {
|
||||
const adapter = makeAdapter([
|
||||
{ name: 'Zero', score: 0 },
|
||||
{ name: 'Minute', score: 60000 }
|
||||
])
|
||||
const el = mount({ category: 'b', title: 'T', format: 'time' }, { adapter })
|
||||
await vi.waitFor(() => {
|
||||
expect(rowsText(el)).toContain('Zero')
|
||||
expect(rowsText(el)).toContain('0') // falsy score -> '0'
|
||||
expect(rowsText(el)).toContain('01:0') // exact minute -> '01:0'
|
||||
})
|
||||
})
|
||||
})
|
||||
123
packages/leaderboard/test/read-write-separation.test.js
Normal file
123
packages/leaderboard/test/read-write-separation.test.js
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// @ts-check
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { LeaderBoardReader } from '../leaderboard-read.js'
|
||||
import { LeaderBoardWriter } from '../leaderboard-write.js'
|
||||
import { LeaderBoardService } from '../leader-board.js'
|
||||
|
||||
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const source = (file) => readFileSync(join(pkgDir, file), 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments
|
||||
.replace(/\/\/.*$/gm, '') // strip line comments
|
||||
|
||||
describe('read/write surface separation — imports', () => {
|
||||
it('the READ module pulls in no write-path code', () => {
|
||||
const code = source('leaderboard-read.js')
|
||||
// no import of the write module or its unique dependency (bucket keys)
|
||||
expect(code).not.toMatch(/from\s+['"][^'"]*leaderboard-write/)
|
||||
expect(code).not.toMatch(/date-bucket|buckets/)
|
||||
// no write operations
|
||||
expect(code).not.toMatch(/\bsubmit\b|\baddScore\b|\barchive\b/)
|
||||
})
|
||||
|
||||
it('the WRITE module pulls in no read/render code', () => {
|
||||
const code = source('leaderboard-write.js')
|
||||
expect(code).not.toMatch(/from\s+['"][^'"]*leaderboard-read/)
|
||||
// no DOM / rendering / listing
|
||||
expect(code).not.toMatch(/\bdocument\b|\brender\b|\blistScores\b|createElement/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('read surface — usable standalone (no writes)', () => {
|
||||
it('renders a ranked list via listScores and never calls write methods', async () => {
|
||||
const listScores = vi.fn(async () => [{ name: 'Ada', score: 10 }, { name: 'Bo', score: 20 }])
|
||||
// A deliberately read-only adapter: no addScore/archive at all.
|
||||
const adapter = { listScores }
|
||||
const reader = new LeaderBoardReader({ adapter, scoreOrder: 'asc', formatScore: String })
|
||||
|
||||
const el = await reader.render('beginner', 'Best Times')
|
||||
expect(el.querySelector('h3')).toBeTruthy() // heading rendered
|
||||
expect(el.querySelectorAll('button')).toHaveLength(4) // four duration tabs
|
||||
expect(listScores).toHaveBeenCalledTimes(1) // 'today' window loaded once
|
||||
|
||||
// The list fills asynchronously — wait for the rows to appear (names render as text).
|
||||
await vi.waitFor(() => {
|
||||
expect(el.textContent).toContain('Ada')
|
||||
expect(el.textContent).toContain('Bo')
|
||||
})
|
||||
|
||||
// Reader exposes no write API.
|
||||
expect(typeof (/** @type {any} */ (reader).submit)).toBe('undefined')
|
||||
})
|
||||
|
||||
it('renders entry names as text, never as HTML (names are player-controlled)', async () => {
|
||||
const listScores = vi.fn(async () => [{ name: '<img src=x onerror=alert(1)>', score: 1 }])
|
||||
const reader = new LeaderBoardReader({ adapter: { listScores } })
|
||||
|
||||
const el = await reader.render('beginner', 'Best Times')
|
||||
await vi.waitFor(() =>
|
||||
expect(el.textContent).toContain('<img src=x onerror=alert(1)>'))
|
||||
expect(el.querySelector('img')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('write surface — usable standalone with an injected instance', () => {
|
||||
it('submits a completed game through the injected adapter (archive + ranked write)', async () => {
|
||||
const archive = vi.fn(async () => {})
|
||||
const addScore = vi.fn(async () => {})
|
||||
// Injected, write-capable adapter (no listScores/render needed).
|
||||
const adapter = { archive, addScore }
|
||||
const writer = new LeaderBoardWriter({ adapter })
|
||||
|
||||
await writer.submit({
|
||||
name: 'Ada', playerId: 'p1', score: 42, category: 'beginner', time_stamp: Date.UTC(2026, 6, 3)
|
||||
})
|
||||
|
||||
expect(archive).toHaveBeenCalledTimes(1)
|
||||
expect(addScore).toHaveBeenCalledTimes(1)
|
||||
const [category, doc] = addScore.mock.calls[0]
|
||||
expect(category).toBe('beginner')
|
||||
expect(doc).toMatchObject({ name: 'Ada', playerId: 'p1', score: 42 })
|
||||
// denormalized bucket keys are computed on the write side
|
||||
expect(doc).toEqual(expect.objectContaining({ day: expect.any(String), week: expect.any(String), month: expect.any(String) }))
|
||||
|
||||
// Writer exposes no read/render API.
|
||||
expect(typeof (/** @type {any} */ (writer).render)).toBe('undefined')
|
||||
})
|
||||
|
||||
it('honors an explicit qualifies gate (skips the ranked write, still archives)', async () => {
|
||||
const archive = vi.fn(async () => {})
|
||||
const addScore = vi.fn(async () => {})
|
||||
const writer = new LeaderBoardWriter({ adapter: { archive, addScore }, qualifies: () => false })
|
||||
await writer.submit({ playerId: 'p1', score: 1, category: 'beginner', time_stamp: 0 })
|
||||
expect(archive).toHaveBeenCalledTimes(1)
|
||||
expect(addScore).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('facade — combined surface unchanged (regression)', () => {
|
||||
it('LeaderBoardService delegates render (read) and submit (write)', async () => {
|
||||
const listScores = vi.fn(async () => [{ name: 'Ada', score: 10 }])
|
||||
const archive = vi.fn(async () => {})
|
||||
const addScore = vi.fn(async () => {})
|
||||
const adapter = { listScores, archive, addScore }
|
||||
const service = new LeaderBoardService({ adapter, formatScore: String })
|
||||
|
||||
// read
|
||||
const el = await service.render('beginner', 'Best')
|
||||
expect(el.querySelector('h3')).toBeTruthy()
|
||||
expect(listScores).toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(el.textContent).toContain('Ada'))
|
||||
|
||||
// write
|
||||
await service.submit({ name: 'Ada', playerId: 'p1', score: 5, category: 'beginner', time_stamp: 0 })
|
||||
expect(addScore).toHaveBeenCalledTimes(1)
|
||||
|
||||
// composed surfaces are reachable
|
||||
expect(service.reader).toBeInstanceOf(LeaderBoardReader)
|
||||
expect(service.writer).toBeInstanceOf(LeaderBoardWriter)
|
||||
})
|
||||
})
|
||||
48
packages/leaderboard/test/supabase-adapter.test.js
Normal file
48
packages/leaderboard/test/supabase-adapter.test.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// @ts-check
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { SupabaseAdapter } from '../adapters/supabase.js'
|
||||
|
||||
// A chainable stand-in for a supabase-js client (consumer-constructed — the
|
||||
// package takes no supabase dependency). A privileged/service-role client is
|
||||
// supplied the same way.
|
||||
function mockClient() {
|
||||
const insert = vi.fn(async () => ({ error: null }))
|
||||
const from = vi.fn(() => ({ insert }))
|
||||
return { from, insert }
|
||||
}
|
||||
|
||||
describe('SupabaseAdapter — consumer-supplied client (injection point exists)', () => {
|
||||
it('uses the injected client as-is', () => {
|
||||
const client = mockClient()
|
||||
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
|
||||
expect(adapter.client).toBe(client)
|
||||
})
|
||||
|
||||
it('performs a write through the injected client', async () => {
|
||||
const client = mockClient()
|
||||
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
|
||||
|
||||
await adapter.addScore('beginner', {
|
||||
name: 'A', playerId: 'p1', score: 5, category: 'beginner', time_stamp: 't'
|
||||
})
|
||||
|
||||
expect(client.from).toHaveBeenCalledWith('mw_scores')
|
||||
expect(client.insert).toHaveBeenCalledTimes(1)
|
||||
expect(client.insert.mock.calls[0][0]).toMatchObject({ name: 'A', player_id: 'p1', score: 5 })
|
||||
})
|
||||
|
||||
it('archives through the injected client', async () => {
|
||||
const client = mockClient()
|
||||
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
|
||||
await adapter.archive({ playerId: 'p1', score: 9, category: 'beginner', time_stamp: 't' })
|
||||
expect(client.from).toHaveBeenCalledWith('mw_archive')
|
||||
expect(client.insert.mock.calls[0][0]).toMatchObject({ player_id: 'p1', score: 9 })
|
||||
})
|
||||
|
||||
it('surfaces backend errors from the injected client', async () => {
|
||||
const insert = vi.fn(async () => ({ error: new Error('permission denied') }))
|
||||
const client = { from: vi.fn(() => ({ insert })) }
|
||||
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
|
||||
await expect(adapter.addScore('beginner', { playerId: 'p' })).rejects.toThrow('permission denied')
|
||||
})
|
||||
})
|
||||
|
|
@ -61,7 +61,7 @@ npm i -D vite
|
|||
Now that the JS project is initialized and we have a development environment with `vite`, we will install **mnswpr** as a dependency:
|
||||
|
||||
```bash
|
||||
npm i @ayo-run/mnswpr
|
||||
npm i @cozy-games/mnswpr
|
||||
```
|
||||
|
||||
Finally, you can run the installed `vite` dev server by running the following:
|
||||
|
|
@ -141,8 +141,8 @@ Create a new file named `main.js` with the following content:
|
|||
/**
|
||||
* main.js
|
||||
*/
|
||||
import '@ayo-run/mnswpr/mnswpr.css'
|
||||
import mnswpr from '@ayo-run/mnswpr'
|
||||
import '@cozy-games/mnswpr/mnswpr.css'
|
||||
import mnswpr from '@cozy-games/mnswpr'
|
||||
|
||||
const game = new mnswpr('app')
|
||||
game.initialize()
|
||||
24
packages/mnswpr/adapters/replay-common.js
Normal file
24
packages/mnswpr/adapters/replay-common.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
|
||||
*/
|
||||
|
||||
/**
|
||||
* Map a recorded move-event back to the rules move that produced it: `flag` and
|
||||
* `unflag` are both the toggle move `flag`; `reveal` and `chord` pass through.
|
||||
* Unknown kinds are ignored. Shared by the mnswpr replay adapters (progress and
|
||||
* full-board state) so both replay a stream through the core rules identically.
|
||||
*
|
||||
* @param {MnswprMoveEvent} e
|
||||
* @returns {{ type: 'reveal' | 'flag' | 'chord', r: number, c: number } | null}
|
||||
*/
|
||||
export function toMove(e) {
|
||||
switch (e && e.type) {
|
||||
case 'reveal': return { type: 'reveal', r: e.r, c: e.c }
|
||||
case 'chord': return { type: 'chord', r: e.r, c: e.c }
|
||||
case 'flag':
|
||||
case 'unflag': return { type: 'flag', r: e.r, c: e.c }
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
40
packages/mnswpr/adapters/replay-progress.js
Normal file
40
packages/mnswpr/adapters/replay-progress.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// @ts-check
|
||||
import { MinesweeperRules } from '../core/minesweeper/rules.js'
|
||||
import { toMove } from './replay-common.js'
|
||||
|
||||
/**
|
||||
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
|
||||
* @typedef {import('../core/minesweeper/board.js').Layout} Layout
|
||||
*/
|
||||
|
||||
/**
|
||||
* The percent-cleared progress reducer for Minesweeper — mnswpr's first concrete
|
||||
* implementation of the replay engine's `ProgressReducer<MnswprMoveEvent>` seam
|
||||
* (see `@cozy-games/replay` / replay-02). Given the ordered slice of move-events
|
||||
* played so far, it returns completion as
|
||||
* `revealed safe cells / total safe cells * 100`.
|
||||
*
|
||||
* Why it needs the board: a single `reveal` or `chord` event floods MANY cells,
|
||||
* but the recorded move-event only carries `{ type, r, c }` — not how many cells
|
||||
* opened. So the reducer takes the board as closure input (consistent with the
|
||||
* interface design) and replays the moves through the pure core rules. That makes
|
||||
* reveals flood, chords reveal via their (non-flagged) neighbors, and
|
||||
* flags/unflags only gate chords — never advancing progress themselves — with no
|
||||
* cell double-counted. The engine stays game-blind; all interpretation is here.
|
||||
*
|
||||
* @param {Layout} layout - the recorded board (as produced by `generateBoard`)
|
||||
* @returns {(events: { event: MnswprMoveEvent }[]) => number} a reducer to `[0, 100]`
|
||||
*/
|
||||
export function createProgressReducer(layout) {
|
||||
const totalSafe = layout.rows * layout.cols - layout.mines
|
||||
return function progress(events) {
|
||||
// A board with no safe cells is vacuously fully cleared (and avoids /0).
|
||||
if (totalSafe === 0) return 100
|
||||
let state = MinesweeperRules.fromLayout(layout)
|
||||
for (const record of events) {
|
||||
const move = toMove(record.event)
|
||||
if (move) state = MinesweeperRules.apply(state, move).state
|
||||
}
|
||||
return (state.revealedSafe / totalSafe) * 100
|
||||
}
|
||||
}
|
||||
57
packages/mnswpr/adapters/replay-state.js
Normal file
57
packages/mnswpr/adapters/replay-state.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// @ts-check
|
||||
import { MinesweeperRules } from '../core/minesweeper/rules.js'
|
||||
import { toMove } from './replay-common.js'
|
||||
|
||||
/**
|
||||
* @typedef {import('../core/minesweeper/rules.js').MoveEvent} MnswprMoveEvent
|
||||
* @typedef {import('../core/minesweeper/board.js').Layout} Layout
|
||||
* @typedef {{ mine: boolean, adjacent: number, status: 'hidden' | 'flagged' | 'revealed' }} BoardCell
|
||||
* @typedef {{ rows: number, cols: number, phase: string, revealedSafe: number, cells: BoardCell[][] }} BoardState
|
||||
*/
|
||||
|
||||
/**
|
||||
* The full-board state reducer for Minesweeper — mnswpr's implementation of the
|
||||
* replay engine's `StateReducer<MnswprMoveEvent, BoardState>` seam (replay-05).
|
||||
* Given the ordered slice of move-events played so far, it reconstructs the
|
||||
* COMPLETE board at that point: every cell's mine/adjacent/status plus the phase.
|
||||
*
|
||||
* Like the progress reducer, it takes the board as closure input and replays the
|
||||
* moves through the pure core rules — so reveals flood, chords open their
|
||||
* neighbors, and flags toggle — giving an exact reconstruction at any event
|
||||
* index. Statelessly a function of the slice, so the engine can jump (seek) to
|
||||
* any position and rebuild the board there.
|
||||
*
|
||||
* @param {Layout} layout - the recorded board (as produced by `generateBoard`)
|
||||
* @returns {(events: { event: MnswprMoveEvent }[]) => BoardState}
|
||||
*/
|
||||
export function createStateReducer(layout) {
|
||||
return function state(events) {
|
||||
let s = MinesweeperRules.fromLayout(layout)
|
||||
for (const record of events) {
|
||||
const move = toMove(record.event)
|
||||
if (move) s = MinesweeperRules.apply(s, move).state
|
||||
}
|
||||
return toBoard(s)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a core game state into a plain, render-ready 2D board snapshot.
|
||||
* @param {import('../core/minesweeper/rules.js').State} s
|
||||
* @returns {BoardState}
|
||||
*/
|
||||
function toBoard(s) {
|
||||
const { rows, cols } = s.config
|
||||
/** @type {BoardCell[][]} */
|
||||
const cells = []
|
||||
for (let r = 0; r < rows; r++) {
|
||||
/** @type {BoardCell[]} */
|
||||
const row = []
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const cell = s.grid.at(r, c)
|
||||
row.push({ mine: cell.mine, adjacent: cell.adjacent, status: cell.status })
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
return { rows, cols, phase: s.phase, revealedSafe: s.revealedSafe, cells }
|
||||
}
|
||||
114
packages/mnswpr/client/renderer.js
Normal file
114
packages/mnswpr/client/renderer.js
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
packages/mnswpr/client/transport.js
Normal file
48
packages/mnswpr/client/transport.js
Normal 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() }
|
||||
}
|
||||
62
packages/mnswpr/core/grid/grid.js
Normal file
62
packages/mnswpr/core/grid/grid.js
Normal 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
|
||||
}
|
||||
}
|
||||
44
packages/mnswpr/core/grid/neighbors.js
Normal file
44
packages/mnswpr/core/grid/neighbors.js
Normal 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
|
||||
}
|
||||
33
packages/mnswpr/core/grid/serialize.js
Normal file
33
packages/mnswpr/core/grid/serialize.js
Normal 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
|
||||
}
|
||||
25
packages/mnswpr/core/index.js
Normal file
25
packages/mnswpr/core/index.js
Normal 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'
|
||||
212
packages/mnswpr/core/minesweeper/board.js
Normal file
212
packages/mnswpr/core/minesweeper/board.js
Normal 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 }
|
||||
}
|
||||
64
packages/mnswpr/core/minesweeper/reveal.js
Normal file
64
packages/mnswpr/core/minesweeper/reveal.js
Normal 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
|
||||
}
|
||||
325
packages/mnswpr/core/minesweeper/rules.js
Normal file
325
packages/mnswpr/core/minesweeper/rules.js
Normal 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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
41
packages/mnswpr/core/session/replay.js
Normal file
41
packages/mnswpr/core/session/replay.js
Normal 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 }
|
||||
}
|
||||
29
packages/mnswpr/core/session/rng.js
Normal file
29
packages/mnswpr/core/session/rng.js
Normal 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)
|
||||
}
|
||||
174
packages/mnswpr/core/session/session.js
Normal file
174
packages/mnswpr/core/session/session.js
Normal 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
|
||||
}
|
||||
}
|
||||
437
packages/mnswpr/docs/headless-core-and-client-design.md
Normal file
437
packages/mnswpr/docs/headless-core-and-client-design.md
Normal 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 1–2:** 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.
|
||||
|
|
@ -6,30 +6,39 @@
|
|||
import './mnswpr.css'
|
||||
|
||||
import {
|
||||
LoggerService,
|
||||
StorageService,
|
||||
TimerService
|
||||
} from '../utils/index.js'
|
||||
} from '@cozy-games/utils'
|
||||
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 MOBILE_BUSY_DELAY = 250
|
||||
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} version
|
||||
* @param {{
|
||||
* levelChanged: (setting: any) => void,
|
||||
* gameDone: (game: any) => void
|
||||
* } | 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 storageService = new StorageService()
|
||||
const timerService = new TimerService()
|
||||
const loggerService = new LoggerService()
|
||||
|
||||
if (!hooks) {
|
||||
hooks = {
|
||||
|
|
@ -38,6 +47,8 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
|||
}
|
||||
}
|
||||
|
||||
const configuredSeed = options.seed
|
||||
|
||||
let grid = document.createElement('table')
|
||||
grid.setAttribute('id', 'grid')
|
||||
let flagsDisplay = document.createElement('span')
|
||||
|
|
@ -66,7 +77,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
|||
highlightSurroundingCell, // middle-click down
|
||||
rightClickCell // right-click down
|
||||
]
|
||||
let firstClick = true
|
||||
let isBusy = false
|
||||
let clickedCell
|
||||
let cachedSetting = storageService.getFromLocal('setting')
|
||||
|
|
@ -82,12 +92,10 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
|||
}
|
||||
storageService.saveToLocal('setting', setting)
|
||||
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
|
||||
let highlightedCells = []
|
||||
// Count of safe (non-mine) cells revealed, so the win check is O(1) instead of scanning the whole grid
|
||||
let revealedSafeCount = 0
|
||||
// The headless game for the current board; recreated on every generateGrid.
|
||||
let transport
|
||||
|
||||
this.initialize = function() {
|
||||
const headingElement = document.createElement('h1')
|
||||
|
|
@ -129,8 +137,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
|||
levelsDropdown.add(levelOption, null)
|
||||
})
|
||||
|
||||
|
||||
|
||||
if (TEST_MODE) {
|
||||
const testLevel = document.createElement('span')
|
||||
testLevel.innerText = 'Test Mode'
|
||||
|
|
@ -144,24 +150,20 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
|||
|
||||
function initializeToolbar() {
|
||||
const toolbar = document.createElement('div')
|
||||
const toolbarItems = []
|
||||
|
||||
const flagsWrapper = document.createElement('div')
|
||||
flagsWrapper.append(flagsDisplay)
|
||||
flagsWrapper.style.height = '20px'
|
||||
toolbar.append(flagsWrapper)
|
||||
toolbarItems.push(flagsWrapper)
|
||||
|
||||
const smileyWrapper = document.createElement('div')
|
||||
smileyWrapper.append(smileyDisplay)
|
||||
// toolbar.append(smileyWrapper);
|
||||
// toolbarItems.push(smileyWrapper);
|
||||
|
||||
const timerWrapper = document.createElement('div')
|
||||
timerWrapper.append(timerDisplay)
|
||||
timerWrapper.style.height = '20px'
|
||||
toolbar.append(timerWrapper)
|
||||
toolbarItems.push(timerWrapper)
|
||||
|
||||
toolbar.style.cursor = 'pointer'
|
||||
toolbar.style.padding = '10px 35px'
|
||||
|
|
@ -182,7 +184,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
|||
generateGrid({ initial: true })
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate the Game Board
|
||||
* @param {{
|
||||
|
|
@ -190,13 +191,10 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
|||
* }} options - Game Board Options
|
||||
*/
|
||||
function generateGrid(options = { initial: false }) {
|
||||
firstClick = true
|
||||
grid.innerHTML = ''
|
||||
grid.oncontextmenu = () => false
|
||||
flagsCount = setting.mines
|
||||
mines.clear()
|
||||
highlightedCells = []
|
||||
revealedSafeCount = 0
|
||||
|
||||
for (let i = 0; i < setting.rows; i++) {
|
||||
let row = grid.insertRow(i)
|
||||
|
|
@ -224,6 +222,12 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
|||
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
|
||||
* - for initializing the leaderboard
|
||||
|
|
@ -233,8 +237,51 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
|||
|
||||
timerService.initialize(timerDisplay)
|
||||
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 = '😱'
|
||||
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() {
|
||||
|
|
@ -403,11 +450,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
|||
cell.onmousemove = function(e) {
|
||||
if ((pressed || bothPressed) && typeof e === 'object') {
|
||||
removeHighlights()
|
||||
/*
|
||||
if (!isEqual(clickedCell, cell)) {
|
||||
clickedCell = undefined;
|
||||
}
|
||||
*/
|
||||
if (pressed == 'middle' || (isLeft && isRight)) {
|
||||
highlightSurroundingCell(this)
|
||||
} else if (pressed == 'left') {
|
||||
|
|
@ -456,187 +498,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
|||
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() {
|
||||
grid.setAttribute('game-status', 'active')
|
||||
// start timer
|
||||
|
|
@ -659,7 +520,7 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
|||
function highlightCell(cell) {
|
||||
if (isFlagged(cell)) return
|
||||
if (!gameIsDone() && getStatus(cell) == 'default') {
|
||||
setStatus(cell, 'highlighted') // currentCell.classList.add('highlight');
|
||||
setStatus(cell, 'highlighted')
|
||||
highlightedCells.push(cell)
|
||||
}
|
||||
}
|
||||
|
|
@ -677,27 +538,60 @@ 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) {
|
||||
if (isFlagged(cell)) setBusy()
|
||||
if (grid.getAttribute('game-status') == 'inactive') {
|
||||
activateGame()
|
||||
}
|
||||
if (grid.getAttribute('game-status') != 'active') return
|
||||
if (getStatus(cell) != 'clicked' && getStatus(cell) != 'empty') {
|
||||
if (getStatus(cell) == 'default' || getStatus(cell) == 'highlighted') {
|
||||
if (flagsCount <= 0) return
|
||||
cell.className = 'flag'
|
||||
decreaseFlagsCount()
|
||||
setStatus(cell, 'flagged')
|
||||
} else {
|
||||
cell.className = ''
|
||||
increaseFlagsCount()
|
||||
setStatus(cell, 'default')
|
||||
}
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(100)
|
||||
}
|
||||
if (TEST_MODE) loggerService.debug('right click', cell)
|
||||
|
||||
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
|
||||
transport.send(move) // renderer paints the flag
|
||||
decreaseFlagsCount()
|
||||
} else {
|
||||
transport.send(move) // toggles the flag off
|
||||
increaseFlagsCount()
|
||||
}
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(100)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -707,164 +601,26 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
|||
activateGame()
|
||||
}
|
||||
if (grid.getAttribute('game-status') != 'active') return
|
||||
//Check if the end-user clicked on a mine
|
||||
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 = '😱'
|
||||
grid.setAttribute('game-status', 'over')
|
||||
if (isFlagged(cell) || grid.getAttribute('game-status') == 'over') {
|
||||
return
|
||||
}
|
||||
|
||||
if (revealSafeCell(cell)) handleEmpty(cell)
|
||||
checkLevelCompletion()
|
||||
const r = getRow(cell)
|
||||
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) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "@ayo-run/mnswpr",
|
||||
"name": "@cozy-games/mnswpr",
|
||||
"version": "0.4.36",
|
||||
"description": "Classic Minesweeper browser game",
|
||||
"author": "Ayo",
|
||||
|
|
@ -10,13 +10,20 @@
|
|||
},
|
||||
"homepage": "https://mnswpr.com",
|
||||
"scripts": {
|
||||
"release": "bumpp && node ../scripts/release.js"
|
||||
"build": "vite build",
|
||||
"release": "bumpp && node ../../scripts/release.js"
|
||||
},
|
||||
"main": "mnswpr.js",
|
||||
"devDependencies": {
|
||||
"@cozy-games/utils": "workspace:*"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/mnswpr.js"
|
||||
},
|
||||
"./core": {
|
||||
"default": "./core/index.js"
|
||||
},
|
||||
"./dist/*": {
|
||||
"default": "./dist/*"
|
||||
},
|
||||
788
packages/mnswpr/test/core.test.js
Normal file
788
packages/mnswpr/test/core.test.js
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { levels } from '../lib/levels.js'
|
||||
import { levels } from '../levels.js'
|
||||
|
||||
describe('levels', () => {
|
||||
it('defines the four expected difficulty presets', () => {
|
||||
|
|
@ -1,5 +1,20 @@
|
|||
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>.
|
||||
function mountGame() {
|
||||
|
|
@ -38,11 +53,12 @@ describe('Minesweeper board', () => {
|
|||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// Mount a game on a custom board injected via the cached 'setting' localStorage key.
|
||||
function mountCustomGame(setting, hooks) {
|
||||
// Mount a game on a custom board injected via the cached 'setting' localStorage
|
||||
// key. `options.seed` pins the (deterministic) mine layout.
|
||||
function mountCustomGame(setting, hooks, options) {
|
||||
localStorage.setItem('setting', JSON.stringify(setting))
|
||||
document.body.innerHTML = '<div id="app"></div>'
|
||||
const game = new Minesweeper('app', 'dev', hooks)
|
||||
const game = new Minesweeper('app', 'dev', hooks, options)
|
||||
game.initialize()
|
||||
return document.getElementById('grid')
|
||||
}
|
||||
|
|
@ -143,25 +159,27 @@ describe('Minesweeper board', () => {
|
|||
})
|
||||
|
||||
it('does not declare a win until the last safe cell is revealed', () => {
|
||||
// 1x3 board with the single mine pinned to the middle column.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
||||
const grid = mountCustomGame({ rows: 1, cols: 3, mines: 1, id: 'test', name: 'test' })
|
||||
// 1x3 board; pin the single mine to the middle column so neither end cascades.
|
||||
const setting = { 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])
|
||||
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])
|
||||
expect(grid.getAttribute('game-status')).toBe('done')
|
||||
})
|
||||
|
||||
it('flood fill stops at a flagged cell and never reveals it', () => {
|
||||
// 1x4 board with the mine pinned to the last column.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.99)
|
||||
const grid = mountCustomGame({ rows: 1, cols: 4, mines: 1, id: 'test', name: 'test' })
|
||||
// 1x4 board; pin the mine to the last column so cols 0-2 are a safe run.
|
||||
const setting = { 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)
|
||||
const flag = grid.rows[0].cells[2]
|
||||
flag.dispatchEvent(new MouseEvent('mousedown', { button: 2, bubbles: true }))
|
||||
123
packages/mnswpr/test/replay-progress.test.js
Normal file
123
packages/mnswpr/test/replay-progress.test.js
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { GameSession, MinesweeperRules, generateBoard, levels } from '../core/index.js'
|
||||
import { createProgressReducer } from '../adapters/replay-progress.js'
|
||||
|
||||
// Drive a session over an injected layout, capturing the real core-06 move-event
|
||||
// stream (onMove). Returns the emitted events and the session.
|
||||
function record(layout, moves) {
|
||||
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout) })
|
||||
const events = []
|
||||
session.onMove(e => events.push(e))
|
||||
for (const m of moves) session.applyMove(m)
|
||||
return { events: events.map(event => ({ seq: event.seq, t: event.t, event })), session }
|
||||
}
|
||||
|
||||
// 3x3, single mine at (0,0); adjacency computed. Total safe = 8.
|
||||
const smallBoard = () => ({
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
mines: 1,
|
||||
cells: [
|
||||
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
|
||||
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
|
||||
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
|
||||
],
|
||||
mineLocations: [[0, 0]]
|
||||
})
|
||||
|
||||
describe('mnswpr progress reducer (percent-cleared)', () => {
|
||||
it('progresses a scripted full game from 0% to 100%, monotonically', () => {
|
||||
// A real generated board; reveal every safe cell in row-major order to win.
|
||||
const layout = generateBoard(9, 9, 10, { seed: 7, safeCell: { r: 0, c: 0 } })
|
||||
const progress = createProgressReducer(layout)
|
||||
|
||||
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout) })
|
||||
const events = []
|
||||
session.onMove(e => events.push({ seq: e.seq, t: e.t, event: e }))
|
||||
for (let r = 0; r < 9; r++) {
|
||||
for (let c = 0; c < 9; c++) {
|
||||
if (!session.state.grid.at(r, c).mine) session.applyMove({ type: 'reveal', r, c })
|
||||
}
|
||||
}
|
||||
expect(session.status()).toBe('won')
|
||||
|
||||
expect(progress([])).toBe(0) // nothing revealed yet
|
||||
expect(progress(events)).toBe(100) // full clear
|
||||
expect(progress(events.slice(0, 1))).toBeGreaterThan(0) // first reveal opens a region
|
||||
|
||||
// monotonic non-decreasing across every prefix
|
||||
let prev = -1
|
||||
for (let k = 0; k <= events.length; k++) {
|
||||
const p = progress(events.slice(0, k))
|
||||
expect(p).toBeGreaterThanOrEqual(prev)
|
||||
prev = p
|
||||
}
|
||||
})
|
||||
|
||||
it('counts flooded reveals by cells opened, not by event count', () => {
|
||||
const layout = smallBoard()
|
||||
const progress = createProgressReducer(layout)
|
||||
// Revealing the far corner (2,2) floods every one of the 8 safe cells.
|
||||
const { events } = record(layout, [{ type: 'reveal', r: 2, c: 2 }])
|
||||
expect(events).toHaveLength(1) // one event...
|
||||
expect(progress(events)).toBe(100) // ...but all 8 safe cells cleared
|
||||
})
|
||||
|
||||
it('flag and unflag events leave progress unchanged', () => {
|
||||
const layout = smallBoard()
|
||||
const progress = createProgressReducer(layout)
|
||||
// reveal one numbered cell (1/8), then flag + unflag the mine.
|
||||
const { events } = record(layout, [
|
||||
{ type: 'reveal', r: 0, c: 1 }, // reveals just itself (adjacent 1)
|
||||
{ type: 'flag', r: 0, c: 0 }, // flag the mine
|
||||
{ type: 'flag', r: 0, c: 0 } // unflag it
|
||||
])
|
||||
expect(events.map(e => e.event.type)).toEqual(['reveal', 'flag', 'unflag'])
|
||||
|
||||
const afterReveal = progress(events.slice(0, 1))
|
||||
expect(afterReveal).toBeCloseTo(12.5, 5) // 1 / 8
|
||||
expect(progress(events.slice(0, 2))).toBe(afterReveal) // + flag: unchanged
|
||||
expect(progress(events)).toBe(afterReveal) // + unflag: unchanged
|
||||
})
|
||||
|
||||
it('counts the cells a chord reveals', () => {
|
||||
const layout = smallBoard()
|
||||
const progress = createProgressReducer(layout)
|
||||
// reveal (0,1)=1, flag the mine (0,0), then chord (0,1): its 1 flag matches
|
||||
// its value, so it reveals the rest of the board.
|
||||
const { events } = record(layout, [
|
||||
{ type: 'reveal', r: 0, c: 1 },
|
||||
{ type: 'flag', r: 0, c: 0 },
|
||||
{ type: 'chord', r: 0, c: 1 }
|
||||
])
|
||||
expect(events.map(e => e.event.type)).toEqual(['reveal', 'flag', 'chord'])
|
||||
|
||||
expect(progress(events.slice(0, 1))).toBeCloseTo(12.5, 5) // 1/8 after reveal
|
||||
expect(progress(events.slice(0, 2))).toBeCloseTo(12.5, 5) // flag doesn't advance
|
||||
expect(progress(events)).toBe(100) // chord clears the rest
|
||||
})
|
||||
|
||||
it('reflects a partial game (lost run stops where it stopped)', () => {
|
||||
// Reveal a couple of cells then step on the mine — progress reflects only the
|
||||
// safe cells cleared before the loss.
|
||||
const layout = smallBoard()
|
||||
const progress = createProgressReducer(layout)
|
||||
const { events, session } = record(layout, [
|
||||
{ type: 'reveal', r: 0, c: 1 },
|
||||
{ type: 'reveal', r: 1, c: 0 },
|
||||
{ type: 'reveal', r: 0, c: 0 } // mine → lost
|
||||
])
|
||||
expect(session.status()).toBe('lost')
|
||||
expect(progress(events)).toBeCloseTo(25, 5) // 2 of 8 safe cells cleared
|
||||
})
|
||||
|
||||
it('is usable as the replay engine progress adapter (shape check)', () => {
|
||||
// The reducer matches ProgressReducer<T>: (events) => number in [0,100].
|
||||
const { rows, cols, mines } = levels.beginner
|
||||
const progress = createProgressReducer(generateBoard(rows, cols, mines, { seed: 1 }))
|
||||
const v = progress([])
|
||||
expect(typeof v).toBe('number')
|
||||
expect(v).toBeGreaterThanOrEqual(0)
|
||||
expect(v).toBeLessThanOrEqual(100)
|
||||
})
|
||||
})
|
||||
104
packages/mnswpr/test/replay-state.test.js
Normal file
104
packages/mnswpr/test/replay-state.test.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { GameSession, MinesweeperRules } from '../core/index.js'
|
||||
import { createStateReducer } from '../adapters/replay-state.js'
|
||||
|
||||
// Drive a session over an injected layout, capturing the real move-event stream.
|
||||
function record(layout, moves) {
|
||||
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout) })
|
||||
const events = []
|
||||
session.onMove(e => events.push(e))
|
||||
for (const m of moves) session.applyMove(m)
|
||||
return { events: events.map(event => ({ seq: event.seq, t: event.t, event })), session }
|
||||
}
|
||||
|
||||
// 3x3, single mine at (0,0). Total safe = 8.
|
||||
const smallBoard = () => ({
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
mines: 1,
|
||||
cells: [
|
||||
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
|
||||
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
|
||||
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
|
||||
],
|
||||
mineLocations: [[0, 0]]
|
||||
})
|
||||
|
||||
describe('mnswpr state reducer (full-board reconstruction)', () => {
|
||||
const layout = smallBoard()
|
||||
const { events } = record(layout, [
|
||||
{ type: 'reveal', r: 0, c: 1 },
|
||||
{ type: 'reveal', r: 1, c: 0 },
|
||||
{ type: 'flag', r: 0, c: 0 },
|
||||
{ type: 'reveal', r: 2, c: 2 } // floods the rest
|
||||
])
|
||||
const state = createStateReducer(layout)
|
||||
const status = (b, r, c) => b.cells[r][c].status
|
||||
|
||||
it('empty slice → pristine board (all hidden, fresh)', () => {
|
||||
const b = state([])
|
||||
expect(b.rows).toBe(3)
|
||||
expect(b.cols).toBe(3)
|
||||
expect(b.phase).toBe('fresh')
|
||||
expect(b.revealedSafe).toBe(0)
|
||||
expect(b.cells.flat().every(c => c.status === 'hidden')).toBe(true)
|
||||
})
|
||||
|
||||
it('reconstructs the exact board state at several event indices', () => {
|
||||
// index 1 — after reveal(0,1): just that cell open
|
||||
let b = state(events.slice(0, 1))
|
||||
expect(status(b, 0, 1)).toBe('revealed')
|
||||
expect(status(b, 0, 0)).toBe('hidden')
|
||||
expect(status(b, 1, 0)).toBe('hidden')
|
||||
expect(b.revealedSafe).toBe(1)
|
||||
expect(b.phase).toBe('active')
|
||||
|
||||
// index 2 — after reveal(1,0)
|
||||
b = state(events.slice(0, 2))
|
||||
expect(status(b, 0, 1)).toBe('revealed')
|
||||
expect(status(b, 1, 0)).toBe('revealed')
|
||||
expect(b.revealedSafe).toBe(2)
|
||||
|
||||
// index 3 — after flag(0,0): mine flagged, nothing new revealed
|
||||
b = state(events.slice(0, 3))
|
||||
expect(status(b, 0, 0)).toBe('flagged')
|
||||
expect(b.revealedSafe).toBe(2)
|
||||
|
||||
// index 4 — after reveal(2,2): floods all safe cells; mine stays flagged; won
|
||||
b = state(events.slice(0, 4))
|
||||
expect(b.phase).toBe('won')
|
||||
expect(b.revealedSafe).toBe(8)
|
||||
for (let r = 0; r < 3; r++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
expect(status(b, r, c)).toBe(r === 0 && c === 0 ? 'flagged' : 'revealed')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('carries mine/adjacent metadata for rendering', () => {
|
||||
const b = state(events)
|
||||
expect(b.cells[0][0].mine).toBe(true)
|
||||
expect(b.cells[0][1].mine).toBe(false)
|
||||
expect(b.cells[0][1].adjacent).toBe(1)
|
||||
})
|
||||
|
||||
it('is stateless: the same slice reconstructs an equal board', () => {
|
||||
expect(state(events.slice(0, 2))).toEqual(state(events.slice(0, 2)))
|
||||
// and reconstructing a shorter slice after a longer one is unaffected
|
||||
const full = state(events)
|
||||
expect(state(events.slice(0, 1)).revealedSafe).toBe(1)
|
||||
expect(full.revealedSafe).toBe(8)
|
||||
})
|
||||
|
||||
it('counts chord reveals in the reconstructed board', () => {
|
||||
// reveal a number, flag the mine, then chord it open.
|
||||
const { events: chordEvents } = record(smallBoard(), [
|
||||
{ type: 'reveal', r: 0, c: 1 },
|
||||
{ type: 'flag', r: 0, c: 0 },
|
||||
{ type: 'chord', r: 0, c: 1 }
|
||||
])
|
||||
const b = createStateReducer(smallBoard())(chordEvents)
|
||||
expect(b.revealedSafe).toBe(8)
|
||||
expect(b.cells[2][2].status).toBe('revealed')
|
||||
})
|
||||
})
|
||||
62
packages/move-log/README.md
Normal file
62
packages/move-log/README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# @cozy-games/move-log
|
||||
|
||||
A **game-agnostic** container for a recorded run of move events. It wraps any game's event stream in a schema-versioned, ordered, timestamped log.
|
||||
|
||||
```js
|
||||
import {
|
||||
createMoveLog, withReceivedTs,
|
||||
serializeMoveLog, deserializeMoveLog, isMoveLog, SCHEMA_VERSION
|
||||
} from '@cozy-games/move-log'
|
||||
|
||||
// `T` is your game's own event vocabulary — supplied by you, unknown to us.
|
||||
const log = createMoveLog([
|
||||
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
|
||||
{ seq: 2, t: 50, event: { type: 'flag', r: 1, c: 2 } }
|
||||
])
|
||||
// → { schema_version: 1, events: [ { seq, t, event }, ... ] }
|
||||
|
||||
const json = serializeMoveLog(log) // → JSON string
|
||||
const restored = deserializeMoveLog(json) // → validated MoveLog, or throws
|
||||
|
||||
// A consumer records WHEN it received events (host clock), additively:
|
||||
const stamped = withReceivedTs(restored, () => hostNow())
|
||||
// → each event now also carries `receivedTs`; still a valid v1 log
|
||||
```
|
||||
|
||||
## Shape
|
||||
|
||||
| field | type | meaning |
|
||||
| ---------------- | ----------------- | -------------------------------------------------- |
|
||||
| `schema_version` | `1` | the move-log container version |
|
||||
| `events` | `MoveEvent<T>[]` | ordered, each `{ seq, t, event, receivedTs? }` |
|
||||
|
||||
`MoveEvent<T> = { seq: number, t: number, event: T, receivedTs?: number }` — the
|
||||
log owns the per-event recording metadata (a strictly increasing `seq`, a
|
||||
source-side timestamp `t`, and an **optional** received-side `receivedTs`), so
|
||||
`T` stays a pure game payload with no required shape. `receivedTs` is
|
||||
purpose-neutral: it records only *that* a consumer received the event at some
|
||||
time, never why or from where.
|
||||
|
||||
`deserializeMoveLog` round-trips a serialized log with full fidelity (order,
|
||||
timestamps, sequence numbers, and any `receivedTs`) and rejects malformed input —
|
||||
bad JSON, missing or wrong-typed fields, or non-monotonic `seq` — with a clear
|
||||
error, never returning a partially-parsed log.
|
||||
|
||||
## Versioning: `receivedTs` is additive within `schema_version: 1`
|
||||
|
||||
`receivedTs` was added **without** bumping `schema_version`. It is optional and
|
||||
purely additive: a v1 log is valid whether every event, some events, or no
|
||||
events carry a `receivedTs`, and a reader that doesn't know the field simply
|
||||
ignores it. A version bump is reserved for *breaking* container changes (a
|
||||
renamed/removed field or a newly required one), which would be dispatched on in
|
||||
`deserializeMoveLog`. See the `SCHEMA_VERSION` doc comment for the full policy.
|
||||
|
||||
## Invariant: zero game-specific imports
|
||||
|
||||
This module **must never import a game package** (e.g. mnswpr) or any game
|
||||
vocabulary. `T` is always supplied by the consumer; the log only ever sees
|
||||
opaque payloads. This independence is the whole point — it lets one move-log
|
||||
format serve every game.
|
||||
|
||||
The rule is enforced by a dependency-graph guard in `test/move-log.test.js`
|
||||
(scans the package's source and manifest for game references). Keep it green.
|
||||
219
packages/move-log/index.js
Normal file
219
packages/move-log/index.js
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
// @ts-check
|
||||
|
||||
/**
|
||||
* `@cozy-games/move-log` — a game-agnostic container for a recorded run of move
|
||||
* events. It wraps ANY game's event stream: `T` is the consuming game's own
|
||||
* event vocabulary (mnswpr's `MoveEvent` union from core-06 is the first `T`),
|
||||
* supplied by the caller.
|
||||
*
|
||||
* This module imports NO game types — that independence is the whole point and
|
||||
* is enforced by a dependency-graph guard in the tests. The log owns the
|
||||
* per-event recording metadata (`seq` + `t`, and an optional received-side
|
||||
* `receivedTs`) so `T` can stay a pure game payload with no required shape; the
|
||||
* module never inspects the inside of an `event`.
|
||||
*
|
||||
* Extraction to a standalone published package comes later; for now it lives as
|
||||
* a shared workspace module alongside `packages/utils`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The move-log schema version.
|
||||
*
|
||||
* Versioning policy: OPTIONAL, purely additive fields (an event gaining an
|
||||
* optional `receivedTs`, say) do NOT bump this — a v1 reader ignores fields it
|
||||
* doesn't know, and a log written with them stays a valid v1 log. Bump ONLY on a
|
||||
* breaking change to the container shape (a renamed/removed field, a newly
|
||||
* *required* field), which would need dispatch on read. Never bump for changes
|
||||
* to a game's `T` vocabulary.
|
||||
*
|
||||
* @typedef {1} SchemaVersion
|
||||
*/
|
||||
export const SCHEMA_VERSION = /** @type {SchemaVersion} */ (1)
|
||||
|
||||
/**
|
||||
* A single recorded event: the log-owned recording metadata — a strictly
|
||||
* increasing sequence number `seq`, a source-side timestamp `t` (milliseconds),
|
||||
* and an OPTIONAL received-side timestamp `receivedTs` a consumer may attach when
|
||||
* it received the event — plus the game's opaque payload `event`. Generic over
|
||||
* the game's event type `T`. `receivedTs` is purpose-neutral: the log records
|
||||
* only THAT it was received at some time, never why or from where.
|
||||
*
|
||||
* @template T
|
||||
* @typedef {{ seq: number, t: number, event: T, receivedTs?: number }} MoveEvent
|
||||
*/
|
||||
|
||||
/**
|
||||
* The container: a schema-versioned, ordered array of timestamped, sequenced
|
||||
* events for one recorded run. Generic over the game's event vocabulary `T`.
|
||||
* JSON-safe as long as `T` is.
|
||||
*
|
||||
* @template T
|
||||
* @typedef {{ schema_version: SchemaVersion, events: MoveEvent<T>[] }} MoveLog
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate an events array: each entry must be a `{ seq, t, event }` with an
|
||||
* integer `seq`, a finite numeric `t`, and a present `event`; and `seq` must be
|
||||
* STRICTLY INCREASING across the array. Throws a distinct, field-specific error
|
||||
* on the first problem — never leaves a caller with a half-checked array.
|
||||
*
|
||||
* @param {unknown} events
|
||||
*/
|
||||
function assertEvents(events) {
|
||||
if (!Array.isArray(events)) {
|
||||
throw new TypeError(`move-log: events must be an array (got ${events === null ? 'null' : typeof events})`)
|
||||
}
|
||||
let prevSeq = -Infinity
|
||||
events.forEach((e, i) => {
|
||||
if (e === null || typeof e !== 'object') {
|
||||
throw new TypeError(`move-log: events[${i}] must be an object (got ${e === null ? 'null' : typeof e})`)
|
||||
}
|
||||
if (!('event' in e)) {
|
||||
throw new TypeError(`move-log: events[${i}] is missing 'event'`)
|
||||
}
|
||||
if (typeof e.t !== 'number' || !Number.isFinite(e.t)) {
|
||||
throw new TypeError(`move-log: events[${i}].t must be a finite number (got ${JSON.stringify(e.t)})`)
|
||||
}
|
||||
if (!Number.isInteger(e.seq)) {
|
||||
throw new TypeError(`move-log: events[${i}].seq must be an integer (got ${JSON.stringify(e.seq)})`)
|
||||
}
|
||||
if (e.seq <= prevSeq) {
|
||||
throw new RangeError(`move-log: events[${i}].seq must be strictly increasing (got ${e.seq} after ${prevSeq})`)
|
||||
}
|
||||
if (e.receivedTs !== undefined && (typeof e.receivedTs !== 'number' || !Number.isFinite(e.receivedTs))) {
|
||||
throw new TypeError(`move-log: events[${i}].receivedTs must be a finite number when present (got ${JSON.stringify(e.receivedTs)})`)
|
||||
}
|
||||
prevSeq = e.seq
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy one event record to the canonical field set, carrying `receivedTs`
|
||||
* through only when it's actually present (so absent stays absent — no
|
||||
* `receivedTs: undefined` keys leak into the log or its JSON).
|
||||
*
|
||||
* @template T
|
||||
* @param {MoveEvent<T>} e
|
||||
* @returns {MoveEvent<T>}
|
||||
*/
|
||||
function copyEvent(e) {
|
||||
/** @type {MoveEvent<T>} */
|
||||
const out = { seq: e.seq, t: e.t, event: e.event }
|
||||
if (e.receivedTs !== undefined) out.receivedTs = e.receivedTs
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a value is a well-formed move log — correct `schema_version` and a valid
|
||||
* events array — throwing a clear, specific error otherwise. Returns the value
|
||||
* (typed) for chaining; never mutates.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {MoveLog<any>}
|
||||
*/
|
||||
export function assertMoveLog(value) {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
throw new TypeError(`move-log: expected an object (got ${value === null ? 'null' : typeof value})`)
|
||||
}
|
||||
const v = /** @type {any} */ (value)
|
||||
if (v.schema_version !== SCHEMA_VERSION) {
|
||||
throw new RangeError(`move-log: unsupported schema_version ${JSON.stringify(v.schema_version)} (expected ${SCHEMA_VERSION})`)
|
||||
}
|
||||
assertEvents(v.events)
|
||||
return v
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a move log from an ordered list of `{ seq, t, event }` records. Pure and
|
||||
* game-agnostic: it validates only the log's own invariants (metadata types and
|
||||
* strictly increasing `seq`), never the shape of `T`. Order is preserved and
|
||||
* entries are copied, so the log never aliases the caller's array.
|
||||
*
|
||||
* @template T
|
||||
* @param {MoveEvent<T>[]} [events] - ordered events, each `{ seq, t, event, receivedTs? }`
|
||||
* @returns {MoveLog<T>}
|
||||
*/
|
||||
export function createMoveLog(events = []) {
|
||||
assertEvents(events)
|
||||
return {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
events: events.map(copyEvent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new move log with a received-side timestamp attached to each event
|
||||
* for which `stamp` returns a finite number; events where `stamp` returns
|
||||
* `undefined` are left as-is (keeping any existing `receivedTs`). This is how a
|
||||
* consumer records WHEN it received events — the log never cares where the value
|
||||
* came from. Pure: the input log is not mutated.
|
||||
*
|
||||
* @template T
|
||||
* @param {MoveLog<T>} log
|
||||
* @param {(event: MoveEvent<T>, index: number) => number | undefined} stamp
|
||||
* @returns {MoveLog<T>}
|
||||
*/
|
||||
export function withReceivedTs(log, stamp) {
|
||||
assertMoveLog(log)
|
||||
const events = log.events.map((e, i) => {
|
||||
const rt = stamp(e, i)
|
||||
if (rt === undefined) return copyEvent(e)
|
||||
if (typeof rt !== 'number' || !Number.isFinite(rt)) {
|
||||
throw new TypeError(`withReceivedTs: stamp must return a finite number or undefined (got ${JSON.stringify(rt)} at index ${i})`)
|
||||
}
|
||||
return { ...copyEvent(e), receivedTs: rt }
|
||||
})
|
||||
return { schema_version: log.schema_version, events }
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-throwing type guard: is `value` a well-formed move log of the current
|
||||
* schema version? Checks the container invariants only — remains blind to `T`.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isMoveLog(value) {
|
||||
try {
|
||||
assertMoveLog(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a move log to a JSON string. Validates first, so a malformed log is
|
||||
* rejected here rather than emitted. Inverse of {@link deserializeMoveLog}.
|
||||
*
|
||||
* @template T
|
||||
* @param {MoveLog<T>} log
|
||||
* @returns {string}
|
||||
*/
|
||||
export function serializeMoveLog(log) {
|
||||
assertMoveLog(log)
|
||||
return JSON.stringify(log)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a JSON string into a move log, with full fidelity: event
|
||||
* order, timestamps, and sequence numbers survive the round-trip exactly.
|
||||
* Rejects malformed input (bad JSON, missing/typed-wrong fields, non-monotonic
|
||||
* `seq`) with a clear error and NEVER returns a partially-parsed log. Inverse of
|
||||
* {@link serializeMoveLog}.
|
||||
*
|
||||
* @param {string} json
|
||||
* @returns {MoveLog<any>}
|
||||
*/
|
||||
export function deserializeMoveLog(json) {
|
||||
if (typeof json !== 'string') {
|
||||
throw new TypeError(`deserializeMoveLog: expected a JSON string (got ${json === null ? 'null' : typeof json})`)
|
||||
}
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(json)
|
||||
} catch (err) {
|
||||
throw new SyntaxError(`deserializeMoveLog: invalid JSON — ${/** @type {Error} */ (err).message}`, { cause: err })
|
||||
}
|
||||
return assertMoveLog(parsed)
|
||||
}
|
||||
22
packages/move-log/package.json
Normal file
22
packages/move-log/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "@cozy-games/move-log",
|
||||
"version": "0.0.1",
|
||||
"description": "Game-blind, schema-versioned log of a recorded run of move events — generic over the game's own event vocabulary",
|
||||
"author": "Ayo Ayco",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ayo-run/mnswpr"
|
||||
},
|
||||
"main": "index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./index.js"
|
||||
},
|
||||
"./*": {
|
||||
"default": "./*"
|
||||
}
|
||||
},
|
||||
"license": "BSD-2-Clause"
|
||||
}
|
||||
323
packages/move-log/test/move-log.test.js
Normal file
323
packages/move-log/test/move-log.test.js
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
// @ts-check
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
// Imported via the PACKAGE NAME (not a relative path) to prove the workspace
|
||||
// module resolves and is importable by other packages.
|
||||
import {
|
||||
createMoveLog, withReceivedTs,
|
||||
serializeMoveLog, deserializeMoveLog, isMoveLog, assertMoveLog, SCHEMA_VERSION
|
||||
} from '@cozy-games/move-log'
|
||||
|
||||
// A REAL mnswpr session/event stream — imported by the TEST, never the module.
|
||||
// Relative path (not the package name) so no game dependency enters the manifest.
|
||||
import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js'
|
||||
|
||||
/**
|
||||
* A dummy event vocabulary defined HERE, in the test — deliberately NOT mnswpr's.
|
||||
* The move log must type-check and work against any `T` the consumer supplies.
|
||||
* @typedef {{ kind: 'tick' } | { kind: 'boom', power: number }} DummyEvent
|
||||
*/
|
||||
|
||||
describe('@cozy-games/move-log', () => {
|
||||
/** @type {import('@cozy-games/move-log').MoveEvent<DummyEvent>[]} */
|
||||
const events = [
|
||||
{ seq: 1, t: 0, event: { kind: 'tick' } },
|
||||
{ seq: 2, t: 50, event: { kind: 'boom', power: 3 } },
|
||||
{ seq: 5, t: 120, event: { kind: 'tick' } } // gaps allowed; strictly increasing
|
||||
]
|
||||
|
||||
it('exposes schema_version typed as 1', () => {
|
||||
expect(SCHEMA_VERSION).toBe(1)
|
||||
})
|
||||
|
||||
it('wraps an ordered, timestamped, sequenced stream for a dummy vocabulary', () => {
|
||||
/** @type {import('@cozy-games/move-log').MoveLog<DummyEvent>} */
|
||||
const log = createMoveLog(events)
|
||||
|
||||
expect(log.schema_version).toBe(1)
|
||||
expect(log.events).toHaveLength(3)
|
||||
expect(log.events.map(e => e.seq)).toEqual([1, 2, 5])
|
||||
expect(log.events.map(e => e.t)).toEqual([0, 50, 120])
|
||||
expect(log.events[1]).toEqual({ seq: 2, t: 50, event: { kind: 'boom', power: 3 } })
|
||||
})
|
||||
|
||||
it('defaults to an empty run and copies entries (no aliasing of the input)', () => {
|
||||
expect(createMoveLog()).toEqual({ schema_version: 1, events: [] })
|
||||
const input = [{ seq: 1, t: 1, event: { kind: 'tick' } }]
|
||||
const log = createMoveLog(input)
|
||||
input[0].t = 999
|
||||
expect(log.events[0].t).toBe(1) // log kept its own copy
|
||||
})
|
||||
|
||||
it('rejects a non-monotonic seq at construction', () => {
|
||||
expect(() => createMoveLog([
|
||||
{ seq: 2, t: 0, event: {} },
|
||||
{ seq: 1, t: 1, event: {} }
|
||||
])).toThrow(RangeError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('serialization round-trip', () => {
|
||||
const events = [
|
||||
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
|
||||
{ seq: 2, t: 50, event: { type: 'flag', r: 1, c: 2 } },
|
||||
{ seq: 3, t: 90, event: { type: 'chord', r: 4, c: 4 } }
|
||||
]
|
||||
|
||||
it('preserves order, timestamps, and sequence numbers exactly', () => {
|
||||
const log = createMoveLog(events)
|
||||
const restored = deserializeMoveLog(serializeMoveLog(log))
|
||||
|
||||
expect(restored).toEqual(log) // full structural fidelity
|
||||
expect(restored.events.map(e => e.seq)).toEqual([1, 2, 3])
|
||||
expect(restored.events.map(e => e.t)).toEqual([0, 50, 90])
|
||||
expect(restored.events.map(e => e.event.type)).toEqual(['reveal', 'flag', 'chord'])
|
||||
})
|
||||
|
||||
it('serializeMoveLog produces a JSON string parseable back to the same object', () => {
|
||||
const log = createMoveLog(events)
|
||||
const json = serializeMoveLog(log)
|
||||
expect(typeof json).toBe('string')
|
||||
expect(JSON.parse(json)).toEqual(log)
|
||||
})
|
||||
|
||||
it('rejects each malformed fixture with a distinct, clear error', () => {
|
||||
const valid = serializeMoveLog(createMoveLog(events))
|
||||
|
||||
// not a string
|
||||
// @ts-expect-error — deliberately wrong type
|
||||
expect(() => deserializeMoveLog({})).toThrow(TypeError)
|
||||
// invalid JSON syntax
|
||||
expect(() => deserializeMoveLog('{not json')).toThrow(SyntaxError)
|
||||
// missing schema_version
|
||||
expect(() => deserializeMoveLog(JSON.stringify({ events: [] }))).toThrow(RangeError)
|
||||
// wrong schema_version
|
||||
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 2, events: [] }))).toThrow(RangeError)
|
||||
// events not an array
|
||||
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: 'nope' }))).toThrow(TypeError)
|
||||
// missing 'event' field
|
||||
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [{ seq: 1, t: 0 }] }))).toThrow(TypeError)
|
||||
// bad timestamp type
|
||||
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [{ seq: 1, t: 'soon', event: {} }] }))).toThrow(TypeError)
|
||||
// bad seq type
|
||||
expect(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [{ seq: 1.5, t: 0, event: {} }] }))).toThrow(TypeError)
|
||||
// shuffled / non-monotonic seq
|
||||
const shuffled = JSON.stringify({
|
||||
schema_version: 1,
|
||||
events: [{ seq: 3, t: 0, event: {} }, { seq: 1, t: 1, event: {} }]
|
||||
})
|
||||
expect(() => deserializeMoveLog(shuffled)).toThrow(RangeError)
|
||||
|
||||
// distinct messages, not one generic error
|
||||
const messages = [
|
||||
captureMessage(() => deserializeMoveLog('{not json')),
|
||||
captureMessage(() => deserializeMoveLog(JSON.stringify({ events: [] }))),
|
||||
captureMessage(() => deserializeMoveLog(JSON.stringify({ schema_version: 1, events: [{ seq: 1, t: 0 }] }))),
|
||||
captureMessage(() => deserializeMoveLog(shuffled))
|
||||
]
|
||||
expect(new Set(messages).size).toBe(messages.length)
|
||||
|
||||
// sanity: the valid fixture still deserializes
|
||||
expect(isMoveLog(deserializeMoveLog(valid))).toBe(true)
|
||||
})
|
||||
|
||||
it('never returns a partially-parsed log (throws before returning)', () => {
|
||||
const partlyBad = JSON.stringify({
|
||||
schema_version: 1,
|
||||
events: [{ seq: 1, t: 0, event: { ok: true } }, { seq: 2, t: 'bad', event: {} }]
|
||||
})
|
||||
let result = 'sentinel'
|
||||
expect(() => { result = deserializeMoveLog(partlyBad) }).toThrow(TypeError)
|
||||
expect(result).toBe('sentinel') // assignment never happened
|
||||
})
|
||||
|
||||
it('isMoveLog / assertMoveLog agree on validity', () => {
|
||||
const log = createMoveLog(events)
|
||||
expect(isMoveLog(log)).toBe(true)
|
||||
expect(assertMoveLog(log)).toBe(log)
|
||||
expect(isMoveLog(null)).toBe(false)
|
||||
expect(isMoveLog({ schema_version: 1, events: [{ seq: 1, t: 0 }] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('received timestamps (additive receivedTs)', () => {
|
||||
const base = [
|
||||
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
|
||||
{ seq: 2, t: 50, event: { type: 'flag', r: 1, c: 2 } }
|
||||
]
|
||||
|
||||
it('accepts an optional receivedTs per event and round-trips it', () => {
|
||||
const log = createMoveLog([
|
||||
{ seq: 1, t: 0, event: { k: 'a' }, receivedTs: 1000 },
|
||||
{ seq: 2, t: 50, event: { k: 'b' }, receivedTs: 1060 }
|
||||
])
|
||||
expect(log.events[0].receivedTs).toBe(1000)
|
||||
const restored = deserializeMoveLog(serializeMoveLog(log))
|
||||
expect(restored).toEqual(log)
|
||||
expect(restored.events.map(e => e.receivedTs)).toEqual([1000, 1060])
|
||||
})
|
||||
|
||||
it('is valid with receivedTs on only some events', () => {
|
||||
const log = createMoveLog([
|
||||
{ seq: 1, t: 0, event: { k: 'a' }, receivedTs: 1000 },
|
||||
{ seq: 2, t: 50, event: { k: 'b' } } // no receivedTs
|
||||
])
|
||||
expect(isMoveLog(log)).toBe(true)
|
||||
expect('receivedTs' in log.events[1]).toBe(false)
|
||||
expect(deserializeMoveLog(serializeMoveLog(log))).toEqual(log)
|
||||
})
|
||||
|
||||
it('REGRESSION: a log with no receivedTs anywhere stays fully valid and leaks no key', () => {
|
||||
const log = createMoveLog(base)
|
||||
expect(isMoveLog(log)).toBe(true)
|
||||
expect(log.events.every(e => !('receivedTs' in e))).toBe(true)
|
||||
const restored = deserializeMoveLog(serializeMoveLog(log))
|
||||
expect(restored).toEqual(log)
|
||||
expect(restored.events.every(e => !('receivedTs' in e))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a non-finite / non-numeric receivedTs', () => {
|
||||
expect(() => createMoveLog([{ seq: 1, t: 0, event: {}, receivedTs: 'soon' }])).toThrow(TypeError)
|
||||
expect(() => createMoveLog([{ seq: 1, t: 0, event: {}, receivedTs: Infinity }])).toThrow(TypeError)
|
||||
expect(() => deserializeMoveLog(JSON.stringify({
|
||||
schema_version: 1,
|
||||
events: [{ seq: 1, t: 0, event: {}, receivedTs: 'later' }]
|
||||
}))).toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('withReceivedTs attaches host-received times additively without mutating the input', () => {
|
||||
const log = createMoveLog(base)
|
||||
let clock = 900
|
||||
const stamped = withReceivedTs(log, () => (clock += 10))
|
||||
|
||||
// input untouched
|
||||
expect(log.events.every(e => !('receivedTs' in e))).toBe(true)
|
||||
// output stamped, still a valid v1 log, round-trips
|
||||
expect(stamped.events.map(e => e.receivedTs)).toEqual([910, 920])
|
||||
expect(stamped.schema_version).toBe(1)
|
||||
expect(deserializeMoveLog(serializeMoveLog(stamped))).toEqual(stamped)
|
||||
})
|
||||
|
||||
it('withReceivedTs leaves events unstamped when the stamp returns undefined', () => {
|
||||
const log = createMoveLog(base)
|
||||
const stamped = withReceivedTs(log, (e) => (e.seq === 1 ? 1234 : undefined))
|
||||
expect(stamped.events[0].receivedTs).toBe(1234)
|
||||
expect('receivedTs' in stamped.events[1]).toBe(false)
|
||||
expect(isMoveLog(stamped)).toBe(true)
|
||||
})
|
||||
|
||||
it('withReceivedTs rejects a stamp that returns a non-finite number', () => {
|
||||
const log = createMoveLog(base)
|
||||
expect(() => withReceivedTs(log, () => NaN)).toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('VERSIONING: receivedTs is additive — same schema_version with or without it', () => {
|
||||
const without = createMoveLog(base)
|
||||
const withRt = withReceivedTs(without, () => 1000)
|
||||
expect(without.schema_version).toBe(SCHEMA_VERSION)
|
||||
expect(withRt.schema_version).toBe(SCHEMA_VERSION)
|
||||
expect(SCHEMA_VERSION).toBe(1)
|
||||
// a v1 reader accepts both shapes
|
||||
expect(isMoveLog(without)).toBe(true)
|
||||
expect(isMoveLog(withRt)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration: wraps a real mnswpr event stream (core-06)', () => {
|
||||
// 3x3, single mine at (0,0); adjacency computed. Lets us script exact moves.
|
||||
const layout = {
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
mines: 1,
|
||||
cells: [
|
||||
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
|
||||
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
|
||||
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
|
||||
],
|
||||
mineLocations: [[0, 0]]
|
||||
}
|
||||
|
||||
it('records an mnswpr session end-to-end and round-trips it losslessly', () => {
|
||||
let now = 1000
|
||||
const clock = () => now
|
||||
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(layout), clock })
|
||||
|
||||
// Capture the real core-06 move-events ({ type, r, c, t, seq }).
|
||||
/** @type {any[]} */
|
||||
const emitted = []
|
||||
session.onMove(e => emitted.push(e))
|
||||
|
||||
now = 1050; session.applyMove({ type: 'reveal', r: 0, c: 1 })
|
||||
now = 1100; session.applyMove({ type: 'flag', r: 0, c: 0 })
|
||||
now = 1150; session.applyMove({ type: 'flag', r: 0, c: 0 }) // unflag
|
||||
now = 1200; session.applyMove({ type: 'flag', r: 0, c: 0 }) // re-flag the mine
|
||||
now = 1250; session.applyMove({ type: 'chord', r: 0, c: 1 }) // 1 flag == value → chord
|
||||
|
||||
expect(emitted.map(e => e.type)).toEqual(['reveal', 'flag', 'unflag', 'flag', 'chord'])
|
||||
|
||||
// Wrap the stream: lift the recording metadata (seq, t) to the log level.
|
||||
const log = createMoveLog(emitted.map(e => ({ seq: e.seq, t: e.t, event: e })))
|
||||
const restored = deserializeMoveLog(serializeMoveLog(log))
|
||||
|
||||
expect(restored).toEqual(log)
|
||||
expect(restored.events.map(e => e.seq)).toEqual([1, 2, 3, 4, 5])
|
||||
expect(restored.events.map(e => e.t)).toEqual([1050, 1100, 1150, 1200, 1250])
|
||||
expect(restored.events.map(e => e.event.type)).toEqual(['reveal', 'flag', 'unflag', 'flag', 'chord'])
|
||||
// sequence is strictly increasing — the log's own invariant, verified on real data
|
||||
const seqs = restored.events.map(e => e.seq)
|
||||
expect(seqs).toEqual([...seqs].sort((a, b) => a - b))
|
||||
})
|
||||
})
|
||||
|
||||
describe('game-agnosticism guard (zero game-specific imports)', () => {
|
||||
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
// The set of game packages the move log must never depend on or import.
|
||||
const GAME_REFERENCES = /mnswpr|minesweeper/i
|
||||
|
||||
it('manifest declares no dependency on a game package', () => {
|
||||
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
|
||||
const deps = {
|
||||
...pkg.dependencies,
|
||||
...pkg.devDependencies,
|
||||
...pkg.peerDependencies
|
||||
}
|
||||
const offenders = Object.keys(deps).filter(name => GAME_REFERENCES.test(name))
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
it('no source file imports or references a game package', () => {
|
||||
const offenders = []
|
||||
walk(pkgDir, file => {
|
||||
if (!file.endsWith('.js')) return
|
||||
if (file.includes('/test/')) return // the test may name/import games on purpose
|
||||
// Strip comments so prose that *names* a game isn't a false positive.
|
||||
const code = readFileSync(file, 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/.*$/gm, '')
|
||||
if (GAME_REFERENCES.test(code)) offenders.push(file)
|
||||
})
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function captureMessage(fn) {
|
||||
try {
|
||||
fn()
|
||||
return null
|
||||
} catch (err) {
|
||||
return err.message
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir, fn) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === 'node_modules') continue
|
||||
const p = join(dir, name)
|
||||
if (statSync(p).isDirectory()) walk(p, fn)
|
||||
else fn(p)
|
||||
}
|
||||
}
|
||||
150
packages/replay/README.md
Normal file
150
packages/replay/README.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# @cozy-games/replay
|
||||
|
||||
A **game-agnostic** replay engine. `PlaybackClock` re-drives a
|
||||
[`@cozy-games/move-log`](../move-log) envelope over time — scheduling each
|
||||
recorded event to fire at its offset — with `play` / `pause` / `seek`.
|
||||
|
||||
```js
|
||||
import { PlaybackClock } from '@cozy-games/replay'
|
||||
|
||||
const clock = new PlaybackClock(envelope) // a valid move-log envelope
|
||||
const off = clock.on(record => apply(record.event)) // record = { seq, t, event, ... }
|
||||
|
||||
clock.play() // events fire at their recorded offsets
|
||||
clock.pause() // freeze at the current position
|
||||
clock.seek(1500) // jump to 1500ms — delivers exactly the events at offset <= 1500
|
||||
```
|
||||
|
||||
## Progress via a game adapter
|
||||
|
||||
The engine never interprets events. To show completion percent, supply a game
|
||||
**adapter** with a `progress(events) → %` reducer at construction:
|
||||
|
||||
```js
|
||||
const adapter = { progress: (events) => (events.length / total) * 100 }
|
||||
const clock = new PlaybackClock(envelope, {}, adapter)
|
||||
|
||||
clock.seek(1500)
|
||||
clock.progress() // 0–100 (clamped), or null if no adapter supplied
|
||||
```
|
||||
|
||||
The reducer receives the ordered slice of events delivered so far; the engine
|
||||
clamps the result and stays blind to the payload. See
|
||||
[docs/adapter-interface.md](./docs/adapter-interface.md) for the full contract.
|
||||
|
||||
### Progress mode: a signal over time
|
||||
|
||||
`onProgress` turns the clock + reducer into a live "percent complete over elapsed
|
||||
time" signal — updates as playback advances, on seek forward, and on seek back:
|
||||
|
||||
```js
|
||||
const clock = new PlaybackClock(envelope, {}, adapter)
|
||||
clock.onProgress(({ position, progress }) => draw(position, progress))
|
||||
clock.play()
|
||||
```
|
||||
|
||||
Fidelity is exact: at playback time `t` the delivered set is precisely the events
|
||||
at offset `≤ t`, so the emitted `progress` matches the original run's progress at
|
||||
`t`. Updates fire only when the percentage moves (a flag/unflag emits nothing).
|
||||
Subscribe before playing to catch every update; call `progress()` for the current
|
||||
value at any time.
|
||||
|
||||
### Full-board mode (flag-gated)
|
||||
|
||||
With a `state` reducer and the `fullBoard` flag, the engine reconstructs the whole
|
||||
board at any point — `state()` for the current board, `onState` for a stream, and
|
||||
`seek(t)` rebuilds the exact state at `t`:
|
||||
|
||||
```js
|
||||
const clock = new PlaybackClock(envelope, {}, adapter, { fullBoard: true })
|
||||
clock.onState(({ position, state }) => render(state))
|
||||
clock.seek(1500) // state() now reflects the board at 1500ms
|
||||
```
|
||||
|
||||
Off by default: without the flag, `state()` is `null`, `onState` never fires, and
|
||||
the reducer never runs. See [docs/adapter-interface.md](./docs/adapter-interface.md).
|
||||
|
||||
### The "ended" signal & partial recordings
|
||||
|
||||
`onEnd` fires with `{ position }` when playback reaches the **last recorded
|
||||
event's offset**:
|
||||
|
||||
```js
|
||||
clock.onEnd(({ position }) => showEndScreen())
|
||||
```
|
||||
|
||||
The engine has no notion of a "terminal" event, so this is the *same* signal
|
||||
whether the run completed or the recording was **truncated mid-game**. A partial
|
||||
log plays up to its last event and stops cleanly in both progress and full-board
|
||||
modes — no error for the mere absence of a terminal event. Progress **freezes at
|
||||
its last computed value** (no extrapolation, no jump to 100%), because it's purely
|
||||
the reducer over the delivered events. The signal re-arms if you seek back before
|
||||
the end.
|
||||
|
||||
## Offsets
|
||||
|
||||
Each event fires at its **offset** — its recorded `t` minus the first event's
|
||||
`t`, so playback time `0` is the first event. `duration` is the last event's
|
||||
offset.
|
||||
|
||||
## Injected clock + scheduler
|
||||
|
||||
The time source and scheduler are injected (mirroring the core session's
|
||||
injected-clock seam), so tests get exact, deterministic timing:
|
||||
|
||||
```js
|
||||
new PlaybackClock(envelope, { clock, setTimeout, clearTimeout })
|
||||
```
|
||||
|
||||
They default to the real host (`Date.now` + global timers). Under a deterministic
|
||||
injected scheduler — or `vi.useFakeTimers()` — events fire **exactly** at their
|
||||
offsets (tolerance 0). Under the real host scheduler the tolerance is the host's
|
||||
timer resolution (a few ms), the same bound as any `setTimeout`.
|
||||
|
||||
## Seek is deterministic
|
||||
|
||||
The clock keeps one invariant: `cursor` = the number of events whose offset is
|
||||
`<= position`. So after `seek(t)` the delivered set is exactly the events at
|
||||
offset `<= t`:
|
||||
|
||||
- **Forward** (`seek` ahead, or playback advancing) delivers each newly-passed
|
||||
event once, in order.
|
||||
- **Backward** rewinds the cursor without delivering; passing those offsets again
|
||||
going forward re-delivers them (so scrub-back-then-replay works).
|
||||
|
||||
No event is ever delivered twice for a single forward pass, and none is dropped.
|
||||
|
||||
## Schema-version dispatch
|
||||
|
||||
One engine build replays envelopes from multiple format generations. On
|
||||
construction the envelope is routed by its `schema_version` through a dispatch
|
||||
table to a version-specific **reader** that normalizes it into the canonical
|
||||
`MoveEvent` records the engine plays:
|
||||
|
||||
```js
|
||||
readEnvelope(envelope) // → canonical records, or throws
|
||||
```
|
||||
|
||||
- **v1** is the only built-in reader today (the canonical format itself).
|
||||
- **Unknown versions fail loudly** — never a silent best-effort parse:
|
||||
|
||||
```
|
||||
readEnvelope: unsupported envelope schema_version 99 (supported: 1)
|
||||
```
|
||||
|
||||
- **Adding a generation is one entry.** Register a reader for this instance via
|
||||
the `readers` option (or add to the built-in table for a permanent version):
|
||||
|
||||
```js
|
||||
const v2 = (env) => env.log.map(e => ({ seq: e.n, t: e.ts, event: e.payload }))
|
||||
new PlaybackClock(v2Envelope, {}, adapter, { readers: { 2: v2 } })
|
||||
```
|
||||
|
||||
Whatever a reader returns is validated as a canonical move log, so a
|
||||
half-normalized generation can never reach the engine.
|
||||
|
||||
## Invariant: envelope only, no game types
|
||||
|
||||
This module imports **only** `@cozy-games/move-log` (to validate the envelope) and
|
||||
never a game package. It treats every `event` payload as opaque. Enforced by a
|
||||
dependency-graph guard in `test/playback-clock.test.js`.
|
||||
94
packages/replay/docs/adapter-interface.md
Normal file
94
packages/replay/docs/adapter-interface.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Replay adapter interface
|
||||
|
||||
The replay engine is **game-agnostic**: it schedules and delivers the events in a
|
||||
[`@cozy-games/move-log`](../../move-log) envelope over time, but it never
|
||||
interprets what an event *means*. All game meaning enters through a **game
|
||||
adapter** — the seam defined here. This is the concrete realization of the
|
||||
progress-reducer item in
|
||||
[ADR 0002](../../../docs/decisions/0002-game-adapter-pattern.md).
|
||||
|
||||
## `ReplayAdapter<T>`
|
||||
|
||||
An adapter is a plain object supplied at construction:
|
||||
|
||||
```js
|
||||
new PlaybackClock(envelope, deps, adapter)
|
||||
```
|
||||
|
||||
```ts
|
||||
// Typed generically over the game's event vocabulary T (and state S).
|
||||
type ReplayAdapter<T> = {
|
||||
progress?: ProgressReducer<T>
|
||||
state?: StateReducer<T, S>
|
||||
}
|
||||
|
||||
type ProgressReducer<T> = (events: MoveEvent<T>[]) => number // 0–100
|
||||
type StateReducer<T, S> = (events: MoveEvent<T>[]) => S // full game state
|
||||
```
|
||||
|
||||
`MoveEvent<T>` is the move-log record `{ seq, t, event, receivedTs? }`, where
|
||||
`event` is the game's own payload — opaque to the engine.
|
||||
|
||||
## `progress(events) → %`
|
||||
|
||||
The only adapter method today. It maps the **ordered slice of events delivered so
|
||||
far** (every event whose offset ≤ the current playback position) to a completion
|
||||
percentage.
|
||||
|
||||
- **Input:** `MoveEvent<T>[]` — the played-so-far slice, in order. To compute a
|
||||
percentage the adapter typically needs a total (e.g. total safe cells); it owns
|
||||
that context, usually by closing over the board it was built from. The engine
|
||||
passes only the slice.
|
||||
- **Output:** a number in `[0, 100]`. The engine **clamps** the result into range
|
||||
and throws if the reducer returns a non-number, so an adapter can be permissive.
|
||||
- **When:** call `clock.progress()` at any time. It returns `null` if no adapter
|
||||
(or no `progress`) was supplied — the engine never invents a percentage.
|
||||
|
||||
```js
|
||||
// A minesweeper-style adapter, built over its board (illustrative):
|
||||
const adapter = {
|
||||
progress: (events) => {
|
||||
const revealed = events.filter(e => e.event.type === 'reveal').length
|
||||
return (revealed / totalSafeCells) * 100
|
||||
}
|
||||
}
|
||||
const clock = new PlaybackClock(envelope, {}, adapter)
|
||||
clock.seek(1500)
|
||||
clock.progress() // → e.g. 42
|
||||
```
|
||||
|
||||
## `state(events) → S` (full-board mode)
|
||||
|
||||
The second reducer reconstructs the **complete game state** at a playback point
|
||||
from the ordered slice of events delivered so far. It powers full-board replay —
|
||||
rebuilding the whole board on seek, not just a percentage.
|
||||
|
||||
- **Input:** `MoveEvent<T>[]` — the played-so-far slice, in order.
|
||||
- **Output:** the game's own state type `S` (opaque to the engine). For mnswpr
|
||||
it's a 2D board snapshot: `{ rows, cols, phase, revealedSafe, cells }`.
|
||||
- **When:** `clock.state()` returns the current reconstruction, and `onState`
|
||||
streams `{ position, state }` as playback advances or seeks.
|
||||
|
||||
### Flag-gated
|
||||
|
||||
Full-board mode is **off by default** and gated behind a runtime flag — the
|
||||
engine's minimal, documented feature-flag seam:
|
||||
|
||||
```js
|
||||
new PlaybackClock(envelope, deps, adapter, { fullBoard: true })
|
||||
```
|
||||
|
||||
When the flag is **off** (default), the mode is fully inert: `state()` returns
|
||||
`null`, `onState` never fires, and the state reducer is never invoked (no
|
||||
reconstruction cost). When **on** with a `state` reducer supplied, `state()` and
|
||||
`onState` reconstruct the board — and `seek(t)` rebuilds the exact state at `t`.
|
||||
|
||||
## Contract rules
|
||||
|
||||
- **The engine calls the reducer; it never interprets events itself.** Engine
|
||||
source references only envelope types (`MoveEvent` / `MoveLog`) and the log's
|
||||
recording metadata (`seq`, `t`) — never an event's `.event` payload. This is
|
||||
enforced by a guard in `test/playback-clock.test.js`.
|
||||
- **The adapter owns all game meaning** — event vocabulary, progress math, and
|
||||
(as the contract grows) state reduction and terminal predicates per ADR 0002.
|
||||
- **Typed generically over `T`** so one engine serves every game.
|
||||
422
packages/replay/index.js
Normal file
422
packages/replay/index.js
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
// @ts-check
|
||||
import { assertMoveLog, SCHEMA_VERSION } from '@cozy-games/move-log'
|
||||
|
||||
/**
|
||||
* `@cozy-games/replay` — the core of a game-agnostic replay engine.
|
||||
*
|
||||
* {@link PlaybackClock} re-drives a move-log envelope over time: it schedules
|
||||
* each recorded event to fire at its OFFSET (its `t` relative to the first
|
||||
* event) as playback time advances, and supports play / pause / seek. It
|
||||
* consumes ONLY the generic envelope (`@cozy-games/move-log`) and never inspects
|
||||
* the inside of an `event` — no game types cross this boundary.
|
||||
*
|
||||
* The clock and scheduler are injected (mirroring the core session's
|
||||
* injected-clock seam), so tests drive it with fake timers or a hand-rolled
|
||||
* scheduler for exact, deterministic timing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('@cozy-games/move-log').MoveEvent<any>} Event
|
||||
* @typedef {import('@cozy-games/move-log').MoveLog<any>} Envelope
|
||||
* @typedef {{
|
||||
* clock?: () => number,
|
||||
* setTimeout?: (fn: () => void, ms: number) => any,
|
||||
* clearTimeout?: (handle: any) => void
|
||||
* }} Deps
|
||||
*/
|
||||
|
||||
/**
|
||||
* A reducer supplied by a game adapter: given the ordered slice of events played
|
||||
* so far (offset <= the current position), return a completion percentage in
|
||||
* `[0, 100]`. Typed generically over the game's event vocabulary `T`. The engine
|
||||
* clamps the result and never inspects an event's payload — all interpretation
|
||||
* lives in this reducer.
|
||||
*
|
||||
* @template T
|
||||
* @typedef {(events: import('@cozy-games/move-log').MoveEvent<T>[]) => number} ProgressReducer
|
||||
*/
|
||||
|
||||
/**
|
||||
* A reducer supplied by a game adapter for full-board replay: given the ordered
|
||||
* slice of events played so far, reconstruct the complete game state `S` at that
|
||||
* point. Typed generically over the event vocabulary `T` and the (opaque) state
|
||||
* `S`. Powers the flag-gated full-board mode; the engine treats `S` as a black box.
|
||||
*
|
||||
* @template T, S
|
||||
* @typedef {(events: import('@cozy-games/move-log').MoveEvent<T>[]) => S} StateReducer
|
||||
*/
|
||||
|
||||
/**
|
||||
* The replay game-adapter contract (v0) — the seam through which game meaning
|
||||
* enters the engine. Both methods are optional; the engine calls whichever the
|
||||
* mode needs and never interprets an event itself. See `docs/adapter-interface.md`.
|
||||
*
|
||||
* @template T
|
||||
* @typedef {{ progress?: ProgressReducer<T>, state?: StateReducer<T, any> }} ReplayAdapter
|
||||
*/
|
||||
|
||||
/**
|
||||
* A version reader: normalize a raw envelope of its generation into the canonical
|
||||
* ordered `MoveEvent` records the engine plays. One engine build can therefore
|
||||
* replay envelopes from multiple format generations.
|
||||
*
|
||||
* @typedef {(envelope: any) => Event[]} EnvelopeReader
|
||||
*/
|
||||
|
||||
/** v1 is the canonical format itself — its `events` are already the records. */
|
||||
function readV1(envelope) {
|
||||
return envelope.events
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in dispatch table: `schema_version → reader`. Adding a real future
|
||||
* generation is exactly one entry here (plus its normalizer). Callers can also
|
||||
* supply extra/override readers per instance via the `readers` option.
|
||||
*
|
||||
* @type {Record<number, EnvelopeReader>}
|
||||
*/
|
||||
const ENVELOPE_READERS = { [SCHEMA_VERSION]: readV1 }
|
||||
|
||||
/**
|
||||
* Dispatch on an envelope's `schema_version` to the matching reader and return
|
||||
* the canonical `MoveEvent` records. Unknown/unsupported versions fail LOUDLY
|
||||
* with a specific error — never a silent best-effort parse. Whatever a reader
|
||||
* returns is validated as a canonical move log, so a half-normalized generation
|
||||
* can't reach the engine.
|
||||
*
|
||||
* @param {any} envelope
|
||||
* @param {Record<number, EnvelopeReader>} [extraReaders] - added/overriding readers
|
||||
* @returns {Event[]}
|
||||
*/
|
||||
export function readEnvelope(envelope, extraReaders) {
|
||||
if (envelope === null || typeof envelope !== 'object') {
|
||||
throw new TypeError(`readEnvelope: expected an envelope object (got ${envelope === null ? 'null' : typeof envelope})`)
|
||||
}
|
||||
const readers = extraReaders ? { ...ENVELOPE_READERS, ...extraReaders } : ENVELOPE_READERS
|
||||
const version = envelope.schema_version
|
||||
const read = readers[version]
|
||||
if (typeof read !== 'function') {
|
||||
const supported = Object.keys(readers).map(Number).sort((a, b) => a - b).join(', ')
|
||||
throw new RangeError(`readEnvelope: unsupported envelope schema_version ${JSON.stringify(version)} (supported: ${supported})`)
|
||||
}
|
||||
const records = read(envelope)
|
||||
// Every reader MUST normalize to canonical move-log records; enforce it here so
|
||||
// no downstream generation can feed the engine a malformed or half-normalized log.
|
||||
assertMoveLog({ schema_version: SCHEMA_VERSION, events: records })
|
||||
return records
|
||||
}
|
||||
|
||||
export class PlaybackClock {
|
||||
/**
|
||||
* @param {Envelope} envelope - a valid move-log envelope (validated here)
|
||||
* @param {Deps} [deps] - injected time source + scheduler (default: real host)
|
||||
* @param {ReplayAdapter<any>} [adapter] - game adapter (progress / state reducers)
|
||||
* @param {{ fullBoard?: boolean, readers?: Record<number, EnvelopeReader> }} [options] -
|
||||
* `fullBoard` flag-gates full-board mode (default OFF: `state()`/`onState` are
|
||||
* inert and the state reducer is never called) — the minimal, documented
|
||||
* feature-flag seam for this engine. `readers` adds/overrides schema-version
|
||||
* readers for this instance (see {@link readEnvelope}).
|
||||
*/
|
||||
constructor(envelope, deps = {}, adapter = {}, options = {}) {
|
||||
// Dispatch on schema_version → the matching reader's canonical records.
|
||||
const records = readEnvelope(envelope, options.readers)
|
||||
if (adapter.progress !== undefined && typeof adapter.progress !== 'function') {
|
||||
throw new TypeError('PlaybackClock: adapter.progress must be a function when provided')
|
||||
}
|
||||
if (adapter.state !== undefined && typeof adapter.state !== 'function') {
|
||||
throw new TypeError('PlaybackClock: adapter.state must be a function when provided')
|
||||
}
|
||||
this._adapter = adapter
|
||||
this._fullBoard = options.fullBoard === true
|
||||
|
||||
const {
|
||||
clock = () => Date.now(),
|
||||
setTimeout = (fn, ms) => globalThis.setTimeout(fn, ms),
|
||||
clearTimeout = (handle) => globalThis.clearTimeout(handle)
|
||||
} = deps
|
||||
this._now = clock
|
||||
this._setTimeout = setTimeout
|
||||
this._clearTimeout = clearTimeout
|
||||
|
||||
// Sort by recorded time (tie-break by seq) and rebase to offsets so the first
|
||||
// event sits at offset 0 — "recorded offset relative to playback time".
|
||||
const sorted = [...records].sort((a, b) => a.t - b.t || a.seq - b.seq)
|
||||
const baseT = sorted.length ? sorted[0].t : 0
|
||||
/** @type {{ offset: number, record: Event }[]} */
|
||||
this._events = sorted.map(record => ({ offset: record.t - baseT, record }))
|
||||
this._duration = this._events.length ? this._events[this._events.length - 1].offset : 0
|
||||
|
||||
// Playback state. Invariant: `_cursor` === number of events whose offset is
|
||||
// <= the current position; events below the cursor have been delivered in the
|
||||
// current forward pass. This single source of truth makes seek deterministic.
|
||||
this._position = 0
|
||||
this._cursor = 0
|
||||
this._playing = false
|
||||
/** @type {any} */
|
||||
this._timer = null
|
||||
this._anchorClock = 0
|
||||
this._anchorPosition = 0
|
||||
/** @type {Set<(event: Event) => void>} */
|
||||
this._handlers = new Set()
|
||||
/** @type {Set<(update: { position: number, progress: number }) => void>} */
|
||||
this._progressHandlers = new Set()
|
||||
/** Last progress value pushed, so unchanged progress (e.g. a flag) is not re-emitted. */
|
||||
this._lastProgress = /** @type {number | null} */ (null)
|
||||
/** @type {Set<(update: { position: number, state: any }) => void>} */
|
||||
this._stateHandlers = new Set()
|
||||
/** @type {Set<(update: { position: number }) => void>} */
|
||||
this._endHandlers = new Set()
|
||||
/** True once the end has been reached; re-arms when playback moves back before it. */
|
||||
this._ended = false
|
||||
}
|
||||
|
||||
/** Total playback length in ms (offset of the last event; 0 if empty). */
|
||||
get duration() {
|
||||
return this._duration
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
isPlaying() {
|
||||
return this._playing
|
||||
}
|
||||
|
||||
/** Current playback position in ms, clamped to `[0, duration]`. */
|
||||
position() {
|
||||
return this._livePosition()
|
||||
}
|
||||
|
||||
/**
|
||||
* Completion percentage (0–100) at the current position, via the adapter's
|
||||
* progress reducer — or `null` if no reducer was supplied. The engine hands the
|
||||
* reducer the ordered slice of events delivered so far and clamps its result;
|
||||
* it never interprets an event payload itself (that's the adapter's job).
|
||||
*
|
||||
* @returns {number | null}
|
||||
*/
|
||||
progress() {
|
||||
const reduce = this._adapter.progress
|
||||
if (typeof reduce !== 'function') return null
|
||||
const delivered = this._events.slice(0, this._cursor).map(e => e.record)
|
||||
const pct = reduce(delivered)
|
||||
if (typeof pct !== 'number' || Number.isNaN(pct)) {
|
||||
throw new TypeError(`PlaybackClock.progress: reducer must return a number (got ${typeof pct})`)
|
||||
}
|
||||
return Math.min(100, Math.max(0, pct))
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-board mode: the reconstructed game state at the current position, via the
|
||||
* adapter's `state` reducer over the delivered slice. Returns `null` unless the
|
||||
* `fullBoard` flag is on AND a state reducer was supplied — so the mode is inert
|
||||
* (and the reducer never runs) by default. The state shape `S` is the adapter's;
|
||||
* the engine treats it as opaque.
|
||||
*
|
||||
* @returns {any}
|
||||
*/
|
||||
state() {
|
||||
if (!this._fullBoard) return null
|
||||
const reduce = this._adapter.state
|
||||
if (typeof reduce !== 'function') return null
|
||||
return reduce(this._events.slice(0, this._cursor).map(e => e.record))
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to delivered events. The handler receives the raw envelope record
|
||||
* (`{ seq, t, event, ... }`) — the payload stays opaque. Returns an unsubscribe.
|
||||
*
|
||||
* @param {(event: Event) => void} handler
|
||||
* @returns {() => void}
|
||||
*/
|
||||
on(handler) {
|
||||
this._handlers.add(handler)
|
||||
return () => this._handlers.delete(handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress-mode subscription: receive `{ position, progress }` updates as
|
||||
* playback advances — the "percent complete over elapsed time" signal. Fires
|
||||
* only when the percentage actually changes (so flags/unflags, which don't
|
||||
* advance progress, emit nothing), on play, seek forward, and seek backward.
|
||||
* Requires an adapter with a `progress` reducer; without one it never emits.
|
||||
* Subscribe before playing to catch every update; use {@link progress} for the
|
||||
* current value at any time. Returns an unsubscribe.
|
||||
*
|
||||
* @param {(update: { position: number, progress: number }) => void} handler
|
||||
* @returns {() => void}
|
||||
*/
|
||||
onProgress(handler) {
|
||||
this._progressHandlers.add(handler)
|
||||
return () => this._progressHandlers.delete(handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-board mode subscription: receive `{ position, state }` whenever the
|
||||
* delivered set changes (play, seek forward, seek backward), where `state` is
|
||||
* the adapter's reconstruction at that position. Inert unless the `fullBoard`
|
||||
* flag is on and a state reducer was supplied. Returns an unsubscribe.
|
||||
*
|
||||
* @param {(update: { position: number, state: any }) => void} handler
|
||||
* @returns {() => void}
|
||||
*/
|
||||
onState(handler) {
|
||||
this._stateHandlers.add(handler)
|
||||
return () => this._stateHandlers.delete(handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the "ended" signal: fires with `{ position }` when playback
|
||||
* reaches the last recorded event's offset — the SAME signal whether the run
|
||||
* completed or the recording was truncated mid-game (the engine has no notion
|
||||
* of a terminal event). Fires once on reaching the end and re-arms if playback
|
||||
* moves back before it. Returns an unsubscribe.
|
||||
*
|
||||
* @param {(update: { position: number }) => void} handler
|
||||
* @returns {() => void}
|
||||
*/
|
||||
onEnd(handler) {
|
||||
this._endHandlers.add(handler)
|
||||
return () => this._endHandlers.delete(handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start (or resume) playback from the current position. Any event already due
|
||||
* at the current position fires synchronously; the rest are scheduled at their
|
||||
* offsets. No-op if already playing or already at the end.
|
||||
*/
|
||||
play() {
|
||||
if (this._playing) return
|
||||
this._playing = true
|
||||
this._anchorClock = this._now()
|
||||
this._anchorPosition = this._position
|
||||
this._scheduleNext()
|
||||
}
|
||||
|
||||
/** Pause playback, freezing the position where it currently is. */
|
||||
pause() {
|
||||
if (!this._playing) return
|
||||
this._position = this._livePosition()
|
||||
this._playing = false
|
||||
this._clearTimer()
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to playback time `t` (ms, clamped to `[0, duration]`). Deterministic:
|
||||
* afterwards the delivered set is exactly the events at offset <= t. Moving
|
||||
* forward delivers the newly-passed events in order (each exactly once); moving
|
||||
* backward rewinds the cursor without delivering, so a later forward pass
|
||||
* re-delivers them. Re-schedules if playing.
|
||||
*
|
||||
* @param {number} t
|
||||
*/
|
||||
seek(t) {
|
||||
if (typeof t !== 'number' || Number.isNaN(t)) {
|
||||
throw new TypeError(`PlaybackClock.seek: t must be a number (got ${typeof t})`)
|
||||
}
|
||||
const wasPlaying = this._playing
|
||||
this._clearTimer()
|
||||
this._advanceTo(t)
|
||||
if (wasPlaying) {
|
||||
this._anchorClock = this._now()
|
||||
this._anchorPosition = this._position
|
||||
this._scheduleNext()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
/** Live position: derived from the clock while playing, else the stored value. */
|
||||
_livePosition() {
|
||||
if (!this._playing) return this._position
|
||||
const raw = this._anchorPosition + (this._now() - this._anchorClock)
|
||||
return Math.min(Math.max(raw, 0), this._duration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the cursor to match `target` position: emit events crossed going
|
||||
* forward (once each), un-count events going backward (no emit). Sets position.
|
||||
* @param {number} target
|
||||
*/
|
||||
_advanceTo(target) {
|
||||
const t = Math.min(Math.max(target, 0), this._duration)
|
||||
const before = this._cursor
|
||||
while (this._cursor < this._events.length && this._events[this._cursor].offset <= t) {
|
||||
this._emit(this._events[this._cursor].record)
|
||||
this._cursor++
|
||||
}
|
||||
while (this._cursor > 0 && this._events[this._cursor - 1].offset > t) {
|
||||
this._cursor--
|
||||
}
|
||||
this._position = t
|
||||
this._emitProgressIfChanged()
|
||||
if (this._cursor !== before) this._emitState()
|
||||
this._maybeEmitEnded()
|
||||
}
|
||||
|
||||
/** Deliver an event to all subscribers. */
|
||||
_emit(record) {
|
||||
for (const handler of this._handlers) handler(record)
|
||||
}
|
||||
|
||||
/** Push a progress update to subscribers, but only when the percentage moved. */
|
||||
_emitProgressIfChanged() {
|
||||
if (this._progressHandlers.size === 0) return
|
||||
const progress = this.progress()
|
||||
if (progress === null || progress === this._lastProgress) return
|
||||
this._lastProgress = progress
|
||||
const update = { position: this._position, progress }
|
||||
for (const handler of this._progressHandlers) handler(update)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire "ended" once when every recorded event has been delivered (the timeline
|
||||
* reached the last event's offset); re-arm when playback moves back before it.
|
||||
* Terminal-agnostic: a truncated recording ends here exactly like a complete one.
|
||||
*/
|
||||
_maybeEmitEnded() {
|
||||
const atEnd = this._events.length > 0 && this._cursor >= this._events.length
|
||||
if (atEnd && !this._ended) {
|
||||
this._ended = true
|
||||
const update = { position: this._position }
|
||||
for (const handler of this._endHandlers) handler(update)
|
||||
} else if (!atEnd) {
|
||||
this._ended = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Push a reconstructed board state to subscribers — only in active full-board mode. */
|
||||
_emitState() {
|
||||
if (!this._fullBoard || this._stateHandlers.size === 0) return
|
||||
if (typeof this._adapter.state !== 'function') return
|
||||
const update = { position: this._position, state: this.state() }
|
||||
for (const handler of this._stateHandlers) handler(update)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver anything already due, then arm a timer for the next pending event.
|
||||
* Ends playback when the cursor reaches the last event.
|
||||
*/
|
||||
_scheduleNext() {
|
||||
this._clearTimer()
|
||||
if (!this._playing) return
|
||||
this._advanceTo(this._livePosition())
|
||||
if (this._cursor >= this._events.length) {
|
||||
this._position = this._duration
|
||||
this._playing = false
|
||||
return
|
||||
}
|
||||
const delay = Math.max(0, this._events[this._cursor].offset - this._livePosition())
|
||||
this._timer = this._setTimeout(() => this._onTimer(), delay)
|
||||
}
|
||||
|
||||
_onTimer() {
|
||||
this._timer = null
|
||||
if (this._playing) this._scheduleNext()
|
||||
}
|
||||
|
||||
_clearTimer() {
|
||||
if (this._timer !== null) {
|
||||
this._clearTimeout(this._timer)
|
||||
this._timer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
25
packages/replay/package.json
Normal file
25
packages/replay/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "@cozy-games/replay",
|
||||
"version": "0.0.1",
|
||||
"description": "Game-agnostic replay engine — a playback clock (play/pause/seek + event scheduling) that re-drives a move-log envelope over time",
|
||||
"author": "Ayo Ayco",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ayo-run/mnswpr"
|
||||
},
|
||||
"main": "index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./index.js"
|
||||
},
|
||||
"./*": {
|
||||
"default": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@cozy-games/move-log": "workspace:*"
|
||||
},
|
||||
"license": "BSD-2-Clause"
|
||||
}
|
||||
147
packages/replay/test/full-board-mode.test.js
Normal file
147
packages/replay/test/full-board-mode.test.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// @ts-check
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { PlaybackClock } from '@cozy-games/replay'
|
||||
import { createMoveLog } from '@cozy-games/move-log'
|
||||
|
||||
// Real mnswpr run + state reducer — imported by the TEST (relative), so no game
|
||||
// dependency enters the engine's manifest.
|
||||
import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js'
|
||||
import { createStateReducer } from '../../mnswpr/adapters/replay-state.js'
|
||||
|
||||
function fakeScheduler(start = 0) {
|
||||
let now = start
|
||||
let nextId = 1
|
||||
const timers = new Map()
|
||||
return {
|
||||
clock: () => now,
|
||||
setTimeout: (fn, ms) => {
|
||||
const id = nextId++
|
||||
timers.set(id, { at: now + Math.max(0, ms), fn })
|
||||
return id
|
||||
},
|
||||
clearTimeout: (id) => { timers.delete(id) },
|
||||
advance(ms) {
|
||||
const target = now + ms
|
||||
for (;;) {
|
||||
let due = null
|
||||
for (const [id, timer] of timers) {
|
||||
if (timer.at <= target && (due === null || timer.at < due.at)) due = { id, ...timer }
|
||||
}
|
||||
if (!due) break
|
||||
timers.delete(due.id)
|
||||
now = due.at
|
||||
due.fn()
|
||||
}
|
||||
now = target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const board = {
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
mines: 1,
|
||||
cells: [
|
||||
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
|
||||
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
|
||||
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
|
||||
],
|
||||
mineLocations: [[0, 0]]
|
||||
}
|
||||
|
||||
// Record a real run: reveal, reveal, flag the mine, then flood the rest.
|
||||
let nowClock = 0
|
||||
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(board), clock: () => nowClock })
|
||||
const emitted = []
|
||||
session.onMove(e => emitted.push(e))
|
||||
const baseT = 1000
|
||||
for (const step of [
|
||||
{ at: 1000, move: { type: 'reveal', r: 0, c: 1 } },
|
||||
{ at: 1100, move: { type: 'reveal', r: 1, c: 0 } },
|
||||
{ at: 1200, move: { type: 'flag', r: 0, c: 0 } },
|
||||
{ at: 1300, move: { type: 'reveal', r: 2, c: 2 } }
|
||||
]) {
|
||||
nowClock = step.at
|
||||
session.applyMove(step.move)
|
||||
}
|
||||
const records = emitted.map(e => ({ seq: e.seq, t: e.t, event: e }))
|
||||
const envelope = createMoveLog(records)
|
||||
const reduce = createStateReducer(board)
|
||||
|
||||
// Independent ground truth: reduce over records at offset <= t.
|
||||
const truth = t => reduce(records.filter(r => (r.t - baseT) <= t))
|
||||
|
||||
describe('full-board mode — flag gating (inert by default)', () => {
|
||||
it('does nothing when the flag is off, even with a state reducer', () => {
|
||||
const clock = new PlaybackClock(envelope, fakeScheduler(), { state: reduce })
|
||||
const updates = []
|
||||
clock.onState(u => updates.push(u))
|
||||
clock.seek(clock.duration)
|
||||
expect(clock.state()).toBe(null)
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
|
||||
it('is inert when the flag is on but no state reducer is supplied', () => {
|
||||
const clock = new PlaybackClock(envelope, fakeScheduler(), {}, { fullBoard: true })
|
||||
clock.seek(clock.duration)
|
||||
expect(clock.state()).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('full-board mode — reconstruction (flag on)', () => {
|
||||
const make = () => new PlaybackClock(envelope, fakeScheduler(), { state: reduce }, { fullBoard: true })
|
||||
|
||||
it('state() reconstructs the board at multiple seek points', () => {
|
||||
const clock = make()
|
||||
for (const t of [-5, 0, 50, 100, 150, 200, 300, 400]) {
|
||||
clock.seek(t)
|
||||
const clamped = Math.max(0, Math.min(t, clock.duration))
|
||||
expect(clock.state()).toEqual(truth(clamped))
|
||||
}
|
||||
})
|
||||
|
||||
it('seek reconstructs the correct concrete state (forward then backward)', () => {
|
||||
const clock = make()
|
||||
|
||||
clock.seek(clock.duration) // end
|
||||
let b = clock.state()
|
||||
expect(b.phase).toBe('won')
|
||||
expect(b.revealedSafe).toBe(8)
|
||||
expect(b.cells[0][0].status).toBe('flagged')
|
||||
|
||||
clock.seek(100) // back to just after the two opening reveals
|
||||
b = clock.state()
|
||||
expect(b.phase).toBe('active')
|
||||
expect(b.revealedSafe).toBe(2)
|
||||
expect(b.cells[0][1].status).toBe('revealed')
|
||||
expect(b.cells[2][2].status).toBe('hidden')
|
||||
|
||||
clock.seek(0) // back to the very first reveal
|
||||
expect(clock.state().revealedSafe).toBe(1)
|
||||
})
|
||||
|
||||
it('onState streams a reconstruction on every delivery (incl. the flag)', () => {
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(envelope, s, { state: reduce }, { fullBoard: true })
|
||||
const updates = []
|
||||
clock.onState(u => updates.push(u))
|
||||
clock.play()
|
||||
s.advance(400)
|
||||
expect(updates.map(u => u.position)).toEqual([0, 100, 200, 300])
|
||||
expect(updates.at(-1).state.phase).toBe('won')
|
||||
})
|
||||
|
||||
it('onState fires on backward seek', () => {
|
||||
const clock = make()
|
||||
const updates = []
|
||||
clock.onState(u => updates.push(u))
|
||||
clock.seek(clock.duration) // forward (delivers all at once → 1 update)
|
||||
clock.seek(0) // backward → 1 update
|
||||
expect(updates).toHaveLength(2)
|
||||
expect(updates.at(-1).state.revealedSafe).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects a non-function state reducer at construction', () => {
|
||||
expect(() => new PlaybackClock(envelope, fakeScheduler(), { state: /** @type {any} */ (5) })).toThrow(TypeError)
|
||||
})
|
||||
})
|
||||
174
packages/replay/test/partial-logs.test.js
Normal file
174
packages/replay/test/partial-logs.test.js
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// @ts-check
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { PlaybackClock } from '@cozy-games/replay'
|
||||
import { createMoveLog } from '@cozy-games/move-log'
|
||||
|
||||
// Real mnswpr run + reducers — imported by the TEST (relative), so no game
|
||||
// dependency enters the engine's manifest.
|
||||
import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js'
|
||||
import { createProgressReducer } from '../../mnswpr/adapters/replay-progress.js'
|
||||
import { createStateReducer } from '../../mnswpr/adapters/replay-state.js'
|
||||
|
||||
function fakeScheduler(start = 0) {
|
||||
let now = start
|
||||
let nextId = 1
|
||||
const timers = new Map()
|
||||
return {
|
||||
clock: () => now,
|
||||
setTimeout: (fn, ms) => {
|
||||
const id = nextId++
|
||||
timers.set(id, { at: now + Math.max(0, ms), fn })
|
||||
return id
|
||||
},
|
||||
clearTimeout: (id) => { timers.delete(id) },
|
||||
advance(ms) {
|
||||
const target = now + ms
|
||||
for (;;) {
|
||||
let due = null
|
||||
for (const [id, timer] of timers) {
|
||||
if (timer.at <= target && (due === null || timer.at < due.at)) due = { id, ...timer }
|
||||
}
|
||||
if (!due) break
|
||||
timers.delete(due.id)
|
||||
now = due.at
|
||||
due.fn()
|
||||
}
|
||||
now = target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const board = () => ({
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
mines: 1,
|
||||
cells: [
|
||||
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
|
||||
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
|
||||
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
|
||||
],
|
||||
mineLocations: [[0, 0]]
|
||||
})
|
||||
|
||||
// A TRUNCATED recording: two opening reveals, then the log just stops — the game
|
||||
// is still 'active' (2 of 8 safe cells), no win/loss ever recorded.
|
||||
function truncatedEnvelope() {
|
||||
let now = 0
|
||||
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(board()), clock: () => now })
|
||||
const emitted = []
|
||||
session.onMove(e => emitted.push(e))
|
||||
now = 1000; session.applyMove({ type: 'reveal', r: 0, c: 1 })
|
||||
now = 1200; session.applyMove({ type: 'reveal', r: 1, c: 0 })
|
||||
// ...stream cut here — no terminal event.
|
||||
const baseT = 1000
|
||||
return {
|
||||
envelope: createMoveLog(emitted.map(e => ({ seq: e.seq, t: e.t, event: e }))),
|
||||
lastOffset: 1200 - baseT // 200
|
||||
}
|
||||
}
|
||||
|
||||
describe('partial/incomplete recordings — progress mode', () => {
|
||||
it('replays to the last event without error and freezes progress (no jump to 100%)', () => {
|
||||
const { envelope, lastOffset } = truncatedEnvelope()
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(envelope, s, { progress: createProgressReducer(board()) })
|
||||
|
||||
const progressUpdates = []
|
||||
const ends = []
|
||||
clock.onProgress(u => progressUpdates.push(u))
|
||||
clock.onEnd(u => ends.push(u))
|
||||
|
||||
expect(() => { clock.play(); s.advance(10_000) }).not.toThrow() // long past the last event
|
||||
|
||||
// Frozen at the true value for 2/8 safe cells — NOT extrapolated to 100.
|
||||
expect(clock.progress()).toBeCloseTo(25, 5)
|
||||
expect(clock.isPlaying()).toBe(false)
|
||||
|
||||
// "ended" fired once, at the last event's offset.
|
||||
expect(ends).toHaveLength(1)
|
||||
expect(ends[0].position).toBe(lastOffset)
|
||||
|
||||
// Progress held at its last value after the stream ended.
|
||||
expect(progressUpdates.at(-1).progress).toBeCloseTo(25, 5)
|
||||
})
|
||||
|
||||
it('progress stays frozen when time advances past the end', () => {
|
||||
const { envelope } = truncatedEnvelope()
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(envelope, s, { progress: createProgressReducer(board()) })
|
||||
clock.play()
|
||||
s.advance(300)
|
||||
const atEnd = clock.progress()
|
||||
s.advance(5000) // way past
|
||||
expect(clock.progress()).toBe(atEnd) // no drift, no extrapolation
|
||||
expect(atEnd).toBeCloseTo(25, 5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('partial/incomplete recordings — full-board mode', () => {
|
||||
it('reconstructs the last recorded state without error and ends cleanly', () => {
|
||||
const { envelope, lastOffset } = truncatedEnvelope()
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(envelope, s, { state: createStateReducer(board()) }, { fullBoard: true })
|
||||
const ends = []
|
||||
clock.onEnd(u => ends.push(u))
|
||||
|
||||
expect(() => { clock.play(); s.advance(10_000) }).not.toThrow()
|
||||
|
||||
const b = clock.state()
|
||||
expect(b.phase).toBe('active') // never reached a terminal state — and that's fine
|
||||
expect(b.revealedSafe).toBe(2)
|
||||
expect(b.cells[0][1].status).toBe('revealed')
|
||||
expect(b.cells[2][2].status).toBe('hidden') // rest still unopened
|
||||
|
||||
expect(ends).toHaveLength(1)
|
||||
expect(ends[0].position).toBe(lastOffset)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the "ended" signal', () => {
|
||||
it('fires identically for a complete run and a truncated one', () => {
|
||||
// Complete run: play the board to a win.
|
||||
let now = 0
|
||||
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(board()), clock: () => now })
|
||||
const emitted = []
|
||||
session.onMove(e => emitted.push(e))
|
||||
now = 1000; session.applyMove({ type: 'reveal', r: 2, c: 2 }) // floods all 8 → won
|
||||
const complete = createMoveLog(emitted.map(e => ({ seq: e.seq, t: e.t, event: e })))
|
||||
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(complete, s)
|
||||
const ends = []
|
||||
clock.onEnd(u => ends.push(u))
|
||||
clock.play()
|
||||
s.advance(1000)
|
||||
expect(ends).toHaveLength(1)
|
||||
expect(ends[0].position).toBe(clock.duration) // last event's offset
|
||||
})
|
||||
|
||||
it('fires on seek to the end and re-arms after seeking back', () => {
|
||||
const { envelope } = truncatedEnvelope()
|
||||
const clock = new PlaybackClock(envelope, fakeScheduler())
|
||||
const ends = []
|
||||
clock.onEnd(() => ends.push(1))
|
||||
|
||||
clock.seek(clock.duration) // reach the end
|
||||
expect(ends).toHaveLength(1)
|
||||
|
||||
clock.seek(clock.duration) // still at the end — no re-fire
|
||||
expect(ends).toHaveLength(1)
|
||||
|
||||
clock.seek(0) // move back — re-arm
|
||||
clock.seek(clock.duration) // reach the end again
|
||||
expect(ends).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not fire for an empty envelope', () => {
|
||||
const clock = new PlaybackClock(createMoveLog([]), fakeScheduler())
|
||||
const ends = []
|
||||
clock.onEnd(() => ends.push(1))
|
||||
clock.play()
|
||||
clock.seek(0)
|
||||
expect(ends).toEqual([])
|
||||
})
|
||||
})
|
||||
323
packages/replay/test/playback-clock.test.js
Normal file
323
packages/replay/test/playback-clock.test.js
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
// @ts-check
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
// Imported via the PACKAGE NAME to prove the new workspace module resolves.
|
||||
import { PlaybackClock } from '@cozy-games/replay'
|
||||
import { createMoveLog } from '@cozy-games/move-log'
|
||||
|
||||
/**
|
||||
* A hand-rolled deterministic scheduler — the injected-clock seam in action, with
|
||||
* zero reliance on vi internals. `advance(ms)` fires due timers in time order,
|
||||
* picking up timers scheduled from within a firing callback.
|
||||
*/
|
||||
function fakeScheduler(start = 0) {
|
||||
let now = start
|
||||
let nextId = 1
|
||||
const timers = new Map()
|
||||
return {
|
||||
clock: () => now,
|
||||
setTimeout: (fn, ms) => {
|
||||
const id = nextId++
|
||||
timers.set(id, { at: now + Math.max(0, ms), fn })
|
||||
return id
|
||||
},
|
||||
clearTimeout: (id) => { timers.delete(id) },
|
||||
advance(ms) {
|
||||
const target = now + ms
|
||||
for (;;) {
|
||||
let due = null
|
||||
for (const [id, timer] of timers) {
|
||||
if (timer.at <= target && (due === null || timer.at < due.at)) due = { id, ...timer }
|
||||
}
|
||||
if (!due) break
|
||||
timers.delete(due.id)
|
||||
now = due.at
|
||||
due.fn()
|
||||
}
|
||||
now = target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Events at offsets 0, 100, 350 (t rebased from 1000). Payload is opaque to the clock.
|
||||
function envelope() {
|
||||
return createMoveLog([
|
||||
{ seq: 1, t: 1000, event: { type: 'reveal', r: 0, c: 0 } },
|
||||
{ seq: 2, t: 1100, event: { type: 'flag', r: 1, c: 2 } },
|
||||
{ seq: 3, t: 1350, event: { type: 'chord', r: 4, c: 4 } }
|
||||
])
|
||||
}
|
||||
|
||||
const typesOf = records => records.map(r => r.event.type)
|
||||
|
||||
describe('PlaybackClock — construction & shape', () => {
|
||||
it('rebases to offsets: duration is the last offset, first event at 0', () => {
|
||||
const clock = new PlaybackClock(envelope(), fakeScheduler())
|
||||
expect(clock.duration).toBe(350)
|
||||
expect(clock.position()).toBe(0)
|
||||
expect(clock.isPlaying()).toBe(false)
|
||||
})
|
||||
|
||||
it('validates the envelope (rejects a non-envelope)', () => {
|
||||
expect(() => new PlaybackClock(/** @type {any} */ (null))).toThrow()
|
||||
expect(() => new PlaybackClock(/** @type {any} */ ({ schema_version: 2, events: [] }))).toThrow()
|
||||
})
|
||||
|
||||
it('handles an empty envelope gracefully', () => {
|
||||
const clock = new PlaybackClock(createMoveLog([]), fakeScheduler())
|
||||
const seen = []
|
||||
clock.on(r => seen.push(r))
|
||||
expect(clock.duration).toBe(0)
|
||||
clock.play()
|
||||
expect(clock.isPlaying()).toBe(false) // nothing to play → ends immediately
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlaybackClock — play / pause with an injected scheduler', () => {
|
||||
it('fires events at their recorded offsets, exactly', () => {
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(envelope(), s)
|
||||
const seen = []
|
||||
clock.on(r => seen.push({ type: r.event.type, at: s.clock() }))
|
||||
|
||||
clock.play()
|
||||
// offset-0 event fires synchronously on play
|
||||
expect(seen).toEqual([{ type: 'reveal', at: 0 }])
|
||||
|
||||
s.advance(100)
|
||||
expect(seen[1]).toEqual({ type: 'flag', at: 100 })
|
||||
|
||||
s.advance(250) // reach offset 350
|
||||
expect(seen[2]).toEqual({ type: 'chord', at: 350 })
|
||||
expect(clock.isPlaying()).toBe(false) // ended
|
||||
expect(clock.position()).toBe(350)
|
||||
})
|
||||
|
||||
it('pause freezes position and stops delivery', () => {
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(envelope(), s)
|
||||
const seen = []
|
||||
clock.on(r => seen.push(r.event.type))
|
||||
|
||||
clock.play()
|
||||
s.advance(150) // past offset 100, between 100 and 350
|
||||
clock.pause()
|
||||
expect(clock.position()).toBe(150)
|
||||
expect(seen).toEqual(['reveal', 'flag'])
|
||||
|
||||
s.advance(1000) // no timers should fire while paused
|
||||
expect(seen).toEqual(['reveal', 'flag'])
|
||||
expect(clock.position()).toBe(150)
|
||||
})
|
||||
|
||||
it('resumes from the paused position', () => {
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(envelope(), s)
|
||||
const seen = []
|
||||
clock.on(r => seen.push({ type: r.event.type, at: s.clock() }))
|
||||
|
||||
clock.play()
|
||||
s.advance(150)
|
||||
clock.pause()
|
||||
clock.play() // resume at 150; next event at 350 ⇒ 200ms away
|
||||
s.advance(200)
|
||||
expect(seen.map(e => e.type)).toEqual(['reveal', 'flag', 'chord'])
|
||||
expect(seen[2].at).toBe(350) // still fires at its true offset
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlaybackClock — seek determinism', () => {
|
||||
it('seek forward delivers exactly the events at offset <= t, in order, once', () => {
|
||||
const clock = new PlaybackClock(envelope(), fakeScheduler())
|
||||
const seen = []
|
||||
clock.on(r => seen.push(r))
|
||||
|
||||
clock.seek(200) // offsets 0 and 100 are <= 200; 350 is not
|
||||
expect(typesOf(seen)).toEqual(['reveal', 'flag'])
|
||||
expect(clock.position()).toBe(200)
|
||||
|
||||
clock.seek(200) // no movement ⇒ no new deliveries
|
||||
expect(typesOf(seen)).toEqual(['reveal', 'flag'])
|
||||
})
|
||||
|
||||
it('seek boundary is inclusive (offset === t fires)', () => {
|
||||
const clock = new PlaybackClock(envelope(), fakeScheduler())
|
||||
const seen = []
|
||||
clock.on(r => seen.push(r))
|
||||
clock.seek(100)
|
||||
expect(typesOf(seen)).toEqual(['reveal', 'flag']) // offset 100 included
|
||||
})
|
||||
|
||||
it('seek backward re-schedules with no duplicate or dropped events', () => {
|
||||
const clock = new PlaybackClock(envelope(), fakeScheduler())
|
||||
const seen = []
|
||||
clock.on(r => seen.push(r))
|
||||
|
||||
clock.seek(400) // deliver all three
|
||||
expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord'])
|
||||
|
||||
clock.seek(50) // rewind — no delivery; only offset-0 stays "passed"
|
||||
expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord']) // unchanged
|
||||
|
||||
clock.seek(400) // forward again re-delivers the re-crossed events, once each
|
||||
expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord', 'flag', 'chord'])
|
||||
})
|
||||
|
||||
it('seek while playing re-anchors and keeps firing correctly', () => {
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(envelope(), s)
|
||||
const seen = []
|
||||
clock.on(r => seen.push(r.event.type))
|
||||
|
||||
clock.play()
|
||||
s.advance(50) // only offset-0 delivered so far
|
||||
expect(seen).toEqual(['reveal'])
|
||||
|
||||
clock.seek(120) // jump forward while playing ⇒ deliver offset-100 event
|
||||
expect(seen).toEqual(['reveal', 'flag'])
|
||||
|
||||
s.advance(230) // reach 350 ⇒ final event
|
||||
expect(seen).toEqual(['reveal', 'flag', 'chord'])
|
||||
expect(clock.isPlaying()).toBe(false)
|
||||
})
|
||||
|
||||
it('never delivers an event twice within a single forward pass', () => {
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(envelope(), s)
|
||||
const seqs = []
|
||||
clock.on(r => seqs.push(r.seq))
|
||||
clock.play()
|
||||
s.advance(1000)
|
||||
expect(seqs).toEqual([1, 2, 3]) // each once, in order
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlaybackClock — with vi fake timers', () => {
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
it('play/pause/seek work under vi.useFakeTimers()', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
// Default deps ⇒ Date.now + global setTimeout, both faked by vi.
|
||||
const clock = new PlaybackClock(envelope())
|
||||
const seen = []
|
||||
clock.on(r => seen.push(r.event.type))
|
||||
|
||||
clock.play()
|
||||
expect(seen).toEqual(['reveal']) // offset 0 immediate
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(seen).toEqual(['reveal', 'flag'])
|
||||
clock.pause()
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(seen).toEqual(['reveal', 'flag']) // paused ⇒ frozen
|
||||
clock.play()
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(seen).toEqual(['reveal', 'flag', 'chord'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlaybackClock — progress reducer (adapter seam)', () => {
|
||||
/**
|
||||
* A dummy adapter defined HERE, in the test — the engine interprets nothing.
|
||||
* @typedef {{ type: string }} DummyEvent
|
||||
* @type {import('@cozy-games/replay').ProgressReducer<DummyEvent>}
|
||||
*/
|
||||
const byCount = events => (events.length / 3) * 100 // 3 = total in envelope()
|
||||
|
||||
it('runs against a dummy adapter: progress reflects the delivered slice', () => {
|
||||
const clock = new PlaybackClock(envelope(), fakeScheduler(), { progress: byCount })
|
||||
expect(clock.progress()).toBe(0) // nothing delivered yet
|
||||
|
||||
clock.seek(0) // offset-0 event delivered ⇒ 1/3
|
||||
expect(clock.progress()).toBeCloseTo(33.333, 2)
|
||||
|
||||
clock.seek(100) // 2/3
|
||||
expect(clock.progress()).toBeCloseTo(66.667, 2)
|
||||
|
||||
clock.seek(400) // all 3 ⇒ 100
|
||||
expect(clock.progress()).toBe(100)
|
||||
|
||||
clock.seek(50) // rewind ⇒ back to 1/3
|
||||
expect(clock.progress()).toBeCloseTo(33.333, 2)
|
||||
})
|
||||
|
||||
it('advances as playback advances', () => {
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(envelope(), s, { progress: byCount })
|
||||
clock.play()
|
||||
expect(clock.progress()).toBeCloseTo(33.333, 2) // offset-0 fired on play
|
||||
s.advance(100)
|
||||
expect(clock.progress()).toBeCloseTo(66.667, 2)
|
||||
s.advance(250)
|
||||
expect(clock.progress()).toBe(100)
|
||||
})
|
||||
|
||||
it('returns null when no adapter (or no progress reducer) is supplied', () => {
|
||||
expect(new PlaybackClock(envelope(), fakeScheduler()).progress()).toBe(null)
|
||||
expect(new PlaybackClock(envelope(), fakeScheduler(), {}).progress()).toBe(null)
|
||||
})
|
||||
|
||||
it('clamps the reducer output into [0, 100]', () => {
|
||||
const over = new PlaybackClock(envelope(), fakeScheduler(), { progress: () => 999 })
|
||||
const under = new PlaybackClock(envelope(), fakeScheduler(), { progress: () => -50 })
|
||||
expect(over.progress()).toBe(100)
|
||||
expect(under.progress()).toBe(0)
|
||||
})
|
||||
|
||||
it('throws if the reducer returns a non-number', () => {
|
||||
const clock = new PlaybackClock(envelope(), fakeScheduler(), { progress: () => /** @type {any} */ ('nope') })
|
||||
expect(() => clock.progress()).toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('rejects a non-function progress at construction', () => {
|
||||
expect(() => new PlaybackClock(envelope(), fakeScheduler(), { progress: /** @type {any} */ (42) })).toThrow(TypeError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('game-agnosticism guard (envelope only, no game imports)', () => {
|
||||
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const GAME_REFERENCES = /mnswpr|minesweeper/i
|
||||
|
||||
it('engine never interprets an event payload (no `.event` access in engine source)', () => {
|
||||
const offenders = []
|
||||
walk(pkgDir, file => {
|
||||
if (!file.endsWith('.js') || file.includes('/test/')) return
|
||||
const code = readFileSync(file, 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/.*$/gm, '')
|
||||
if (/\.event\b/.test(code)) offenders.push(file)
|
||||
})
|
||||
expect(offenders).toEqual([]) // engine references only envelope metadata (seq/t) + opaque records
|
||||
})
|
||||
|
||||
it('manifest depends only on the envelope, never a game package', () => {
|
||||
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
|
||||
const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
|
||||
expect(Object.keys(deps).filter(name => GAME_REFERENCES.test(name))).toEqual([])
|
||||
})
|
||||
|
||||
it('no source file imports or references a game package', () => {
|
||||
const offenders = []
|
||||
walk(pkgDir, file => {
|
||||
if (!file.endsWith('.js') || file.includes('/test/')) return
|
||||
const code = readFileSync(file, 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/.*$/gm, '')
|
||||
if (GAME_REFERENCES.test(code)) offenders.push(file)
|
||||
})
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function walk(dir, fn) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === 'node_modules') continue
|
||||
const p = join(dir, name)
|
||||
if (statSync(p).isDirectory()) walk(p, fn)
|
||||
else fn(p)
|
||||
}
|
||||
}
|
||||
184
packages/replay/test/progress-mode.test.js
Normal file
184
packages/replay/test/progress-mode.test.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// @ts-check
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { PlaybackClock } from '@cozy-games/replay'
|
||||
import { createMoveLog } from '@cozy-games/move-log'
|
||||
|
||||
// A real mnswpr run + its reducer — imported by the TEST via relative paths, so
|
||||
// no game dependency enters the replay engine's manifest.
|
||||
import { GameSession, MinesweeperRules } from '../../mnswpr/core/index.js'
|
||||
import { createProgressReducer } from '../../mnswpr/adapters/replay-progress.js'
|
||||
|
||||
/** Deterministic injected scheduler (the injected-clock seam), no vi needed. */
|
||||
function fakeScheduler(start = 0) {
|
||||
let now = start
|
||||
let nextId = 1
|
||||
const timers = new Map()
|
||||
return {
|
||||
clock: () => now,
|
||||
setTimeout: (fn, ms) => {
|
||||
const id = nextId++
|
||||
timers.set(id, { at: now + Math.max(0, ms), fn })
|
||||
return id
|
||||
},
|
||||
clearTimeout: (id) => { timers.delete(id) },
|
||||
advance(ms) {
|
||||
const target = now + ms
|
||||
for (;;) {
|
||||
let due = null
|
||||
for (const [id, timer] of timers) {
|
||||
if (timer.at <= target && (due === null || timer.at < due.at)) due = { id, ...timer }
|
||||
}
|
||||
if (!due) break
|
||||
timers.delete(due.id)
|
||||
now = due.at
|
||||
due.fn()
|
||||
}
|
||||
now = target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3x3, single mine at (0,0). Total safe = 8.
|
||||
const layout = () => ({
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
mines: 1,
|
||||
cells: [
|
||||
[{ mine: true, adjacent: 0 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
|
||||
[{ mine: false, adjacent: 1 }, { mine: false, adjacent: 1 }, { mine: false, adjacent: 0 }],
|
||||
[{ mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }, { mine: false, adjacent: 0 }]
|
||||
],
|
||||
mineLocations: [[0, 0]]
|
||||
})
|
||||
|
||||
// Drive a real session, recording BOTH the emitted move-events and the ground
|
||||
// truth (revealedSafe) after each move — an independent source of truth.
|
||||
const board = layout()
|
||||
const TOTAL_SAFE = 8
|
||||
const session = new GameSession(MinesweeperRules, { state: MinesweeperRules.fromLayout(board), clock: () => nowClock })
|
||||
let nowClock = 0
|
||||
const emitted = []
|
||||
session.onMove(e => emitted.push(e))
|
||||
const truthPoints = [] // { offset, revealedSafe } after each move
|
||||
|
||||
const script = [
|
||||
{ at: 1000, move: { type: 'reveal', r: 0, c: 1 } }, // +1 safe cell
|
||||
{ at: 1100, move: { type: 'reveal', r: 1, c: 0 } }, // +1 safe cell
|
||||
{ at: 1200, move: { type: 'flag', r: 0, c: 0 } }, // flag the mine — no progress
|
||||
{ at: 1300, move: { type: 'reveal', r: 2, c: 2 } } // floods the rest → all 8
|
||||
]
|
||||
const baseT = script[0].at
|
||||
for (const step of script) {
|
||||
nowClock = step.at
|
||||
session.applyMove(step.move)
|
||||
truthPoints.push({ offset: step.at - baseT, revealedSafe: session.state.revealedSafe })
|
||||
}
|
||||
|
||||
const records = emitted.map(e => ({ seq: e.seq, t: e.t, event: e }))
|
||||
const envelope = createMoveLog(records)
|
||||
|
||||
// Ground truth for the mnswpr reducer: revealedSafe / total at a given offset —
|
||||
// derived from the session, NOT from the reducer under test.
|
||||
function mnswprTruth(offset) {
|
||||
let revealedSafe = 0
|
||||
for (const p of truthPoints) if (p.offset <= offset) revealedSafe = p.revealedSafe
|
||||
return (revealedSafe / TOTAL_SAFE) * 100
|
||||
}
|
||||
|
||||
// A second, unrelated adapter: percent of events delivered. Ground truth is the
|
||||
// count of records at offset <= t.
|
||||
const totalEvents = records.length
|
||||
const dummyReduce = events => (events.length / totalEvents) * 100
|
||||
function dummyTruth(offset) {
|
||||
return (records.filter(r => (r.t - baseT) <= offset).length / totalEvents) * 100
|
||||
}
|
||||
|
||||
const CASES = [
|
||||
{ name: 'mnswpr percent-cleared', adapter: { progress: createProgressReducer(board) }, truth: mnswprTruth },
|
||||
{ name: 'dummy percent-of-events', adapter: { progress: dummyReduce }, truth: dummyTruth }
|
||||
]
|
||||
|
||||
describe.each(CASES)('progress mode — same code path, adapter: $name', ({ adapter, truth }) => {
|
||||
const clamp = t => Math.max(0, Math.min(t, envelope.events[envelope.events.length - 1].t - baseT))
|
||||
|
||||
it('progress() matches the source run at multiple points (via seek)', () => {
|
||||
const clock = new PlaybackClock(envelope, fakeScheduler(), adapter)
|
||||
for (const t of [-10, 0, 50, 100, 150, 200, 250, 300, 400]) {
|
||||
clock.seek(t)
|
||||
expect(clock.progress()).toBeCloseTo(truth(clamp(t)), 5)
|
||||
}
|
||||
})
|
||||
|
||||
it('progress() matches the source run while playing (fake timers)', () => {
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(envelope, s, adapter)
|
||||
const updates = []
|
||||
clock.onProgress(u => updates.push(u))
|
||||
clock.play()
|
||||
|
||||
let last = 0
|
||||
for (const cp of [0, 100, 200, 300]) {
|
||||
s.advance(cp - last)
|
||||
last = cp
|
||||
expect(clock.progress()).toBeCloseTo(truth(cp), 5)
|
||||
}
|
||||
expect(clock.progress()).toBeCloseTo(truth(clock.duration), 5)
|
||||
|
||||
// The pushed signal is non-decreasing during forward play and ends at 100.
|
||||
const vals = updates.map(u => u.progress)
|
||||
expect(vals).toEqual([...vals].sort((a, b) => a - b))
|
||||
expect(vals.at(-1)).toBeCloseTo(truth(clock.duration), 5)
|
||||
// Each emitted update matches ground truth at the position it reports.
|
||||
for (const u of updates) expect(u.progress).toBeCloseTo(truth(u.position), 5)
|
||||
})
|
||||
|
||||
it('seek forward and backward move the progress signal correctly', () => {
|
||||
const clock = new PlaybackClock(envelope, fakeScheduler(), adapter)
|
||||
const vals = []
|
||||
clock.onProgress(u => vals.push(u.progress))
|
||||
|
||||
clock.seek(clock.duration) // forward to the end
|
||||
expect(clock.progress()).toBeCloseTo(truth(clock.duration), 5)
|
||||
|
||||
clock.seek(0) // jump back to the start
|
||||
expect(clock.progress()).toBeCloseTo(truth(0), 5)
|
||||
|
||||
expect(vals[0]).toBeCloseTo(truth(clock.duration), 5) // went up first
|
||||
expect(vals.at(-1)).toBeCloseTo(truth(0), 5) // then down
|
||||
expect(vals.at(-1)).toBeLessThan(vals[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('progress mode — signal behavior', () => {
|
||||
it('does not emit for events that leave progress unchanged (flags)', () => {
|
||||
// mnswpr: the flag at offset 200 must NOT produce a progress update.
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(envelope, s, { progress: createProgressReducer(board) })
|
||||
const updates = []
|
||||
clock.onProgress(u => updates.push(u))
|
||||
clock.play()
|
||||
s.advance(400)
|
||||
// reveals at 0, 100, 300 changed progress; the flag at 200 did not.
|
||||
expect(updates.map(u => u.position)).toEqual([0, 100, 300])
|
||||
expect(updates.map(u => Math.round(u.progress))).toEqual([13, 25, 100])
|
||||
})
|
||||
|
||||
it('emits nothing when no progress adapter is supplied', () => {
|
||||
const clock = new PlaybackClock(envelope, fakeScheduler())
|
||||
const updates = []
|
||||
clock.onProgress(u => updates.push(u))
|
||||
clock.seek(clock.duration)
|
||||
expect(updates).toEqual([])
|
||||
expect(clock.progress()).toBe(null)
|
||||
})
|
||||
|
||||
it('unsubscribe stops progress delivery', () => {
|
||||
const clock = new PlaybackClock(envelope, fakeScheduler(), { progress: dummyReduce })
|
||||
const updates = []
|
||||
const off = clock.onProgress(u => updates.push(u))
|
||||
clock.seek(100)
|
||||
off()
|
||||
clock.seek(300)
|
||||
expect(updates).toHaveLength(1) // only the first jump delivered
|
||||
})
|
||||
})
|
||||
116
packages/replay/test/schema-dispatch.test.js
Normal file
116
packages/replay/test/schema-dispatch.test.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// @ts-check
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { PlaybackClock, readEnvelope } from '@cozy-games/replay'
|
||||
import { createMoveLog } from '@cozy-games/move-log'
|
||||
|
||||
function fakeScheduler(start = 0) {
|
||||
let now = start
|
||||
let nextId = 1
|
||||
const timers = new Map()
|
||||
return {
|
||||
clock: () => now,
|
||||
setTimeout: (fn, ms) => {
|
||||
const id = nextId++
|
||||
timers.set(id, { at: now + Math.max(0, ms), fn })
|
||||
return id
|
||||
},
|
||||
clearTimeout: (id) => { timers.delete(id) },
|
||||
advance(ms) {
|
||||
const target = now + ms
|
||||
for (;;) {
|
||||
let due = null
|
||||
for (const [id, timer] of timers) {
|
||||
if (timer.at <= target && (due === null || timer.at < due.at)) due = { id, ...timer }
|
||||
}
|
||||
if (!due) break
|
||||
timers.delete(due.id)
|
||||
now = due.at
|
||||
due.fn()
|
||||
}
|
||||
now = target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A canonical v1 envelope.
|
||||
const v1 = () => createMoveLog([
|
||||
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
|
||||
{ seq: 2, t: 100, event: { type: 'flag', r: 1, c: 1 } },
|
||||
{ seq: 3, t: 250, event: { type: 'chord', r: 2, c: 2 } }
|
||||
])
|
||||
|
||||
describe('schema_version dispatch — v1 (built-in)', () => {
|
||||
it('replays a v1 envelope through the dispatch path (not a bypass)', () => {
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(v1(), s)
|
||||
const seen = []
|
||||
clock.on(r => seen.push(r.event.type))
|
||||
clock.play()
|
||||
s.advance(250)
|
||||
expect(seen).toEqual(['reveal', 'flag', 'chord'])
|
||||
})
|
||||
|
||||
it('readEnvelope returns the canonical records for v1', () => {
|
||||
const records = readEnvelope(v1())
|
||||
expect(records.map(r => r.seq)).toEqual([1, 2, 3])
|
||||
expect(records[0].event.type).toBe('reveal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('schema_version dispatch — unknown versions fail loudly', () => {
|
||||
it('throws a specific error for a synthetic future version (99)', () => {
|
||||
const future = { schema_version: 99, events: [{ seq: 1, t: 0, event: {} }] }
|
||||
expect(() => new PlaybackClock(future, fakeScheduler())).toThrow(/unsupported envelope schema_version 99 \(supported: 1\)/)
|
||||
expect(() => readEnvelope(future)).toThrow(RangeError)
|
||||
})
|
||||
|
||||
it('throws for a missing schema_version', () => {
|
||||
expect(() => readEnvelope({ events: [] })).toThrow(/unsupported envelope schema_version undefined/)
|
||||
})
|
||||
|
||||
it('rejects a non-object envelope', () => {
|
||||
// @ts-expect-error — deliberately wrong type
|
||||
expect(() => readEnvelope(null)).toThrow(TypeError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('schema_version dispatch — adding a version', () => {
|
||||
// A toy v2 format defined ENTIRELY in the test: a different field layout that a
|
||||
// normalizer maps back to canonical { seq, t, event }. Adding it is one entry.
|
||||
const v2Envelope = {
|
||||
schema_version: 2,
|
||||
log: [
|
||||
{ n: 1, ts: 0, payload: { type: 'reveal', r: 0, c: 0 } },
|
||||
{ n: 2, ts: 120, payload: { type: 'flag', r: 1, c: 1 } }
|
||||
]
|
||||
}
|
||||
const readV2 = env => env.log.map(e => ({ seq: e.n, t: e.ts, event: e.payload }))
|
||||
|
||||
it('replays a v2 fixture via a supplied reader (same code path)', () => {
|
||||
const s = fakeScheduler()
|
||||
const clock = new PlaybackClock(v2Envelope, s, {}, { readers: { 2: readV2 } })
|
||||
const seen = []
|
||||
clock.on(r => seen.push(r.event.type))
|
||||
clock.play()
|
||||
s.advance(120)
|
||||
expect(seen).toEqual(['reveal', 'flag'])
|
||||
})
|
||||
|
||||
it('readEnvelope normalizes v2 to canonical records with an extra reader', () => {
|
||||
const records = readEnvelope(v2Envelope, { 2: readV2 })
|
||||
expect(records).toEqual([
|
||||
{ seq: 1, t: 0, event: { type: 'reveal', r: 0, c: 0 } },
|
||||
{ seq: 2, t: 120, event: { type: 'flag', r: 1, c: 1 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('still rejects v2 when no reader is registered', () => {
|
||||
expect(() => readEnvelope(v2Envelope)).toThrow(/unsupported envelope schema_version 2 \(supported: 1\)/)
|
||||
})
|
||||
|
||||
it('validates a reader that returns a malformed (non-canonical) log', () => {
|
||||
// A buggy reader whose output breaks the monotonic-seq invariant is caught.
|
||||
const badReader = () => [{ seq: 2, t: 0, event: {} }, { seq: 1, t: 1, event: {} }]
|
||||
expect(() => readEnvelope({ schema_version: 3 }, { 3: badReader })).toThrow(RangeError)
|
||||
})
|
||||
})
|
||||
22
packages/utils/package.json
Normal file
22
packages/utils/package.json
Normal 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
Loading…
Reference in a new issue