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 |
74 changed files with 18725 additions and 964 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -22,3 +22,6 @@ ui-debug.log
|
||||||
*~
|
*~
|
||||||
*swo
|
*swo
|
||||||
*swp
|
*swp
|
||||||
|
|
||||||
|
# TEMPORARY - or unnecesary if done
|
||||||
|
apps/mnswpr/MIGRATION.md
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,11 @@
|
||||||
echo "pre-commit..."
|
echo "pre-commit..."
|
||||||
npm run lint
|
npm run lint
|
||||||
|
|
||||||
|
# Secret scan of staged files — blocks committing tokens, private keys, or a
|
||||||
|
# stray .env.production before it ever reaches history. Config: .secretlintrc.json
|
||||||
|
# / .secretlintignore. Full-tree scan: `pnpm run scan:secrets`.
|
||||||
|
staged=$(git diff --cached --name-only --diff-filter=ACM)
|
||||||
|
if [ -n "$staged" ]; then
|
||||||
|
echo "secret-scan (staged files)..."
|
||||||
|
printf '%s\n' "$staged" | xargs -r -d '\n' npx secretlint --secretlintignore .secretlintignore
|
||||||
|
fi
|
||||||
|
|
|
||||||
5
.secretlintignore
Normal file
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
71
AGENTS.md
71
AGENTS.md
|
|
@ -4,23 +4,74 @@ Guidance for AI coding agents working in this repository.
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
Classic Minesweeper as a vanilla web game — no framework, no TypeScript (JSDoc + `// @ts-check` only). Deployed at [mnswpr.com](https://mnswpr.com) (Netlify) and published to npm as `@ayo-run/mnswpr`. The game engine has **zero runtime dependencies**; only the website adds Firebase.
|
Classic Minesweeper as a vanilla web game — no framework, no TypeScript (JSDoc + `// @ts-check` only). Deployed at [mnswpr.com](https://mnswpr.com) (Netlify) and published to npm as `@cozy-games/mnswpr`. The game engine has **zero runtime dependencies**; only the website adds Firebase.
|
||||||
|
|
||||||
|
**`mnswpr` is the main test app.** It's the reference app for the monorepo and the default target for local runs — `.claude/launch.json` launches it (`dev` on :5173, `preview` on :4173), and it's what you should build/run/preview when verifying changes to the shared packages or tooling.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
Workspace-wide commands run from the root; per-app commands target the app by name with pnpm's `-F` filter (apps are named `<name>` — there are no mnswpr-specific root scripts).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm i # install (pnpm is required — this is a pnpm workspace)
|
pnpm i # install (pnpm is required — this is a pnpm workspace)
|
||||||
pnpm dev # run the website dev server (vite apps/mnswpr) — most common
|
|
||||||
pnpm build # build the website -> apps/mnswpr/dist
|
|
||||||
pnpm build:lib # build the publishable library -> packages/mnswpr/dist
|
|
||||||
pnpm lint # eslint . (JS + CSS); runs automatically on pre-commit
|
|
||||||
pnpm lint:fix # eslint --fix
|
|
||||||
pnpm build:preview # build the app and serve the production preview
|
|
||||||
pnpm test # run the Vitest suite once (jsdom)
|
pnpm test # run the Vitest suite once (jsdom)
|
||||||
pnpm test:watch # run Vitest in watch mode
|
pnpm test:watch # run Vitest in watch mode
|
||||||
|
pnpm lint # eslint . (JS + CSS); runs automatically on pre-commit
|
||||||
|
pnpm lint:fix # eslint --fix
|
||||||
|
pnpm build:lib # build the publishable library -> packages/mnswpr/dist
|
||||||
|
|
||||||
|
pnpm -F mnswpr run dev # Firestore emulator + auto-seed + dev server (emulators:exec) — most common; needs JDK 21+
|
||||||
|
pnpm -F mnswpr run dev:no-db # plain vite, no emulator (UI-only work / no JDK)
|
||||||
|
pnpm -F mnswpr run build # build the website -> apps/mnswpr/dist
|
||||||
|
pnpm -F mnswpr run build:preview # build the app and serve the production preview
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests 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 dev` and playing.
|
### Infra (local CLI only — no web dashboards)
|
||||||
|
|
||||||
|
**Every infra operation — provision, deploy hosting, deploy DB, manage env — is doable from the CLI, and every configuration/schema is codified in-repo.** Nothing lives only in a web dashboard. There are two distinct layers, both owned by the app:
|
||||||
|
|
||||||
|
**1. App infra *config* — declarative, committed, deployed state.** These files ARE the source of truth; deploying just pushes them up. For `mnswpr`, all under `apps/mnswpr/`:
|
||||||
|
|
||||||
|
| File | Codifies |
|
||||||
|
| --- | --- |
|
||||||
|
| `firebase.json` | Firestore + emulator wiring (rules/indexes paths, emulator ports) |
|
||||||
|
| `.firebaserc` | Firebase project aliases (`default`/`prod`/`dev`) |
|
||||||
|
| `firestore.rules` | Firestore security rules (server-side access control) |
|
||||||
|
| `firestore.indexes.json` | Firestore indexes (none needed — documented inline) |
|
||||||
|
| `netlify.toml` | Netlify hosting: build command, publish dir, redirects, headers, build env |
|
||||||
|
| `.env.example` | The full env-var contract; real prod values are set as Netlify env vars via CLI, never committed |
|
||||||
|
|
||||||
|
**2. App infra *tools* — the CLIs that act on that config.** They are versioned **devDependencies** of the app (not `npx`-on-demand, not global installs), so `pnpm install` pins them and every machine gets the same version. `mnswpr` depends on `firebase-tools` and `netlify-cli`; its scripts call the `firebase`/`netlify` binaries directly (pnpm puts the app's `node_modules/.bin` on `PATH`). A future app using a different stack (Postgres, a different host) declares *its* CLIs as *its* devDependencies and backs the same generic script names — so `pnpm -F <name> run deploy:db` stays uniform.
|
||||||
|
|
||||||
|
Each app **owns its infra scripts** in its own `package.json` under generic, tech-agnostic names (`deploy:db`, not `deploy:firestore`) — run them by targeting the app with pnpm's `-F` filter (no root wrapper scripts):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm -F mnswpr run db:start # local DB emulator (mnswpr -> Firestore), standalone
|
||||||
|
pnpm -F mnswpr run db:seed # seed the running local emulator
|
||||||
|
pnpm -F mnswpr run db:stop # kill a stray/orphaned Firestore emulator holding :8080
|
||||||
|
pnpm -F mnswpr run deploy:db # deploy DB rules/indexes (-> firebase deploy --only firestore)
|
||||||
|
pnpm -F mnswpr run deploy:site # build + deploy hosting (-> netlify deploy --prod --dir=dist)
|
||||||
|
```
|
||||||
|
|
||||||
|
**One-time per app / per machine (all CLI, no dashboard):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm -F mnswpr exec firebase login # auth the Firebase CLI
|
||||||
|
pnpm -F mnswpr exec netlify login # auth the Netlify CLI
|
||||||
|
pnpm -F mnswpr exec netlify link # bind the app dir to its Netlify site (writes .netlify/, gitignored)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Managing hosting env vars via CLI** (keeps prod Firebase keys + `VITE_LB_NAMESPACE=mw` out of git while still reproducible):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm -F mnswpr exec netlify env:set VITE_LB_NAMESPACE mw # set one var
|
||||||
|
pnpm -F mnswpr exec netlify env:import .env.production # bulk-import from a local (gitignored) env file
|
||||||
|
pnpm -F mnswpr exec netlify env:list # inspect what's set
|
||||||
|
```
|
||||||
|
|
||||||
|
**Non-npm tools get a setup script instead of a devDependency.** The Firestore emulator needs **Java** (it's a JVM program), which isn't an npm package — so `pnpm install` runs a root `postinstall` (`scripts/ensure-java.mjs`) that installs a user-local Temurin JRE 21 into `~/.local` without `sudo` when `java` is missing — idempotent, non-fatal, and auto-skipped on `CI` / `SKIP_JRE_SETUP` / unsupported platforms. Any future infra tool that isn't on npm follows the same pattern (a checked-in setup script), never a manual install step.
|
||||||
|
|
||||||
|
Tests are co-located with the package they exercise (`packages/utils/test/`, `packages/mnswpr/test/`) and run under **Vitest** with a jsdom environment (root config in `vitest.config.js`). They cover the shared utils and drive the engine through real DOM events (mount `#app`, dispatch mouse events, assert on cell/grid attributes). For anything visual or input-timing related, also verify by running `pnpm -F mnswpr run dev` and playing.
|
||||||
|
|
||||||
Node version: `.nvmrc` pins `lts/*`.
|
Node version: `.nvmrc` pins `lts/*`.
|
||||||
|
|
||||||
|
|
@ -28,8 +79,8 @@ Node version: `.nvmrc` pins `lts/*`.
|
||||||
|
|
||||||
This is the **Cozy Games** monorepo. Workspaces are declared in `pnpm-workspace.yaml` as `apps/*`, `packages/*`, and `sites/*`. `utils/` is now a real workspace package (`@cozy-games/utils`), imported by name — no more `../utils` relative paths.
|
This is the **Cozy Games** monorepo. Workspaces are declared in `pnpm-workspace.yaml` as `apps/*`, `packages/*`, and `sites/*`. `utils/` is now a real workspace package (`@cozy-games/utils`), imported by name — no more `../utils` relative paths.
|
||||||
|
|
||||||
- **`apps/mnswpr/`** — `@ayo-run/mnswpr`'s host, the mnswpr.com website. Consumes the engine and leaderboard via `workspace:*` (`import mnswpr from '@ayo-run/mnswpr/mnswpr.js'`) and wires them together in `apps/mnswpr/main.js`. Owns its Firebase config (`firebase.json`, `firestore.rules`, `.firebaserc`) and app-specific scripts (`apps/mnswpr/scripts/`). A future app (e.g. sudoku) gets its own `apps/<name>/`.
|
- **`apps/mnswpr/`** — package `mnswpr`, `@cozy-games/mnswpr`'s host, the mnswpr.com website. Consumes the engine and leaderboard via `workspace:*` (`import mnswpr from '@cozy-games/mnswpr/mnswpr.js'`) and wires them together in `apps/mnswpr/main.js`. Owns its Firebase config (`firebase.json`, `firestore.rules`, `.firebaserc`) and app-specific scripts (`apps/mnswpr/scripts/`). A future app (e.g. sudoku) gets its own `apps/<name>/` and its `package.json` `name` is just the app name (`<name>`, unscoped) so it's addressable directly by name (`pnpm -F <name> run <script>`).
|
||||||
- **`packages/mnswpr/`** — `@ayo-run/mnswpr`, the standalone, framework-free game engine published to npm. `packages/mnswpr/mnswpr.js` is the whole engine; `levels.js` defines the four difficulty presets. Depends only on `@cozy-games/utils`.
|
- **`packages/mnswpr/`** — `@cozy-games/mnswpr`, the standalone, framework-free game engine published to npm. `packages/mnswpr/mnswpr.js` is the whole engine; `levels.js` defines the four difficulty presets. Depends only on `@cozy-games/utils`.
|
||||||
- **`packages/leaderboard/`** — `@cozy-games/leaderboard`, a backend-agnostic, time-windowed leaderboard (adapter-injected storage).
|
- **`packages/leaderboard/`** — `@cozy-games/leaderboard`, a backend-agnostic, time-windowed leaderboard (adapter-injected storage).
|
||||||
- **`packages/utils/`** — `@cozy-games/utils`, shared services with no dependencies, re-exported from `index.js`: `StorageService`, `TimerService` (`pretty()` time formatting used by both engine and leaderboard), `LoggerService`, `LoadingService`, and date-bucket helpers.
|
- **`packages/utils/`** — `@cozy-games/utils`, shared services with no dependencies, re-exported from `index.js`: `StorageService`, `TimerService` (`pretty()` time formatting used by both engine and leaderboard), `LoggerService`, `LoadingService`, and date-bucket helpers.
|
||||||
- **`sites/`** — docs (Astro Starlight) and UI demos. Placeholders for now.
|
- **`sites/`** — docs (Astro Starlight) and UI demos. Placeholders for now.
|
||||||
|
|
|
||||||
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).
|
||||||
55
README.md
55
README.md
|
|
@ -1,39 +1,44 @@
|
||||||
# Cozy Games
|
# Cozy Games
|
||||||
|
|
||||||
A monorepo for **Cozy Games** — a growing collection of small browser
|
A growing collection of small browser games and the shared, reusable packages that power them.
|
||||||
games and the shared, reusable packages that power them.
|
|
||||||
|
|
||||||
## Layout
|
> [!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
|
||||||
cozy-games/
|
|
||||||
├── apps/ Playable games (each deploys independently)
|
|
||||||
│ └── mnswpr/ Minesweeper — mnswpr.com (@ayo-run/mnswpr engine + Firebase leaderboard)
|
|
||||||
├── packages/ Shared, publishable libraries
|
|
||||||
│ ├── mnswpr/ @ayo-run/mnswpr — the vanilla Minesweeper game engine
|
|
||||||
│ ├── leaderboard/ @cozy-games/leaderboard — backend-agnostic, time-windowed leaderboard
|
|
||||||
│ └── utils/ @cozy-games/utils — shared browser utilities (storage, timer, …)
|
|
||||||
└── 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
|
- **Public APIs** — game-agnostic modules (core, move-log, replay, leaderboard, rating) built inside the first game.
|
||||||
`apps/mnswpr/`); the shared packages stay backend-agnostic.
|
- **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.
|
||||||
|
|
||||||
## Getting started
|
## Packages
|
||||||
|
|
||||||
This is a [pnpm](https://pnpm.io) workspace (pnpm is required).
|
| Package | Develop | Publish | Document |
|
||||||
|
| -------------------- | -------------- | ------- | -------- |
|
||||||
|
| `mnswpr` (game core) | ✅ Built | | |
|
||||||
|
| leaderboard | ✅ Built | | |
|
||||||
|
| move-log | ✅ Built | | |
|
||||||
|
| replay engine | ✅ Built | | |
|
||||||
|
| rating math | 🚧 Development | | |
|
||||||
|
| `sudoku` (game core) | 🔮 Planned | | |
|
||||||
|
|
||||||
|
> Note: `@ayo-run/mnswpr` on npm predates this project and will be deprecated in favor of `cozy-games/mnswpr`.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
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
|
```bash
|
||||||
pnpm i # install
|
pnpm i # install
|
||||||
pnpm dev # run the mnswpr app (Vite dev server)
|
pnpm test # run the test suite
|
||||||
pnpm test # run all package tests (vitest)
|
pnpm -F mnswpr run dev # run the Minesweeper app locally
|
||||||
pnpm lint # eslint
|
|
||||||
pnpm build # build the mnswpr app -> apps/mnswpr/dist
|
|
||||||
pnpm build:lib # build the engine package -> packages/mnswpr/dist
|
|
||||||
```
|
```
|
||||||
|
|
||||||
See [apps/mnswpr/README.md](apps/mnswpr/README.md) for the game itself, and each package's
|
See [apps/mnswpr/README.md](apps/mnswpr/README.md) for the game itself, and each
|
||||||
README for library usage.
|
package's README for library usage.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
# 🧹 Leaderboard Migration Checklist
|
|
||||||
|
|
||||||
Manual steps to finish the leaderboard rollout for **mnswpr**. **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.
|
|
||||||
|
|
||||||
> All commands below run from this app directory (`apps/mnswpr`) unless noted.
|
|
||||||
|
|
||||||
> 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 this app directory (`apps/mnswpr`):
|
|
||||||
|
|
||||||
```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 [`.env.development`](.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, from this app
|
|
||||||
directory (`apps/mnswpr`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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` from the workspace root 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 `.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 `.env.development`; can
|
|
||||||
be de-duped to read from the env file on request.
|
|
||||||
- **Legends** is already frozen into static HTML ([`legends.html`](legends.html))
|
|
||||||
— no action needed.
|
|
||||||
|
|
@ -20,14 +20,14 @@ The goal is to reveal every safe cell without detonating a mine. Your **first cl
|
||||||
The web is a wonderful, free, and open platform to create and distribute value. You can use **mnswpr** in different ways:
|
The web is a wonderful, free, and open platform to create and distribute value. You can use **mnswpr** in different ways:
|
||||||
|
|
||||||
- as a deployed [web app](https://mnswpr.com)
|
- as a deployed [web app](https://mnswpr.com)
|
||||||
- as a [library](https://npmx.dev/package/@ayo-run/mnswpr) with `npm i @ayo-run/mnswpr`
|
- as a [library](https://npmx.dev/package/@cozy-games/mnswpr) with `npm i @cozy-games/mnswpr`
|
||||||
- as a `web component` (coming soon).
|
- as a `web component` (coming soon).
|
||||||
|
|
||||||
Using it as a library takes only a few lines — mount it onto any element by `id`:
|
Using it as a library takes only a few lines — mount it onto any element by `id`:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import '@ayo-run/mnswpr/mnswpr.css'
|
import '@cozy-games/mnswpr/mnswpr.css'
|
||||||
import mnswpr from '@ayo-run/mnswpr'
|
import mnswpr from '@cozy-games/mnswpr'
|
||||||
|
|
||||||
const game = new mnswpr('app')
|
const game = new mnswpr('app')
|
||||||
game.initialize()
|
game.initialize()
|
||||||
|
|
@ -52,31 +52,65 @@ Technology Stack: HTML, JS, and CSS; [Google Firebase](https://firebase.google.c
|
||||||
|
|
||||||
To start development, you need [`node`](https://nodejs.org/en/download). I highly recommend [`pnpm`](https://pnpm.io/installation) to be used as well. Once you know you have this, you can do the following:
|
To start development, you need [`node`](https://nodejs.org/en/download). I highly recommend [`pnpm`](https://pnpm.io/installation) to be used as well. Once you know you have this, you can do the following:
|
||||||
1. Install dependencies: `pnpm i`
|
1. Install dependencies: `pnpm i`
|
||||||
2. Start the dev server: `pnpm run dev`
|
2. Start the dev server: `pnpm -F mnswpr run dev`
|
||||||
|
|
||||||
The rest of the everyday commands:
|
The rest of the everyday commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm test # run the Vitest suite
|
pnpm test # run the Vitest suite (workspace-wide)
|
||||||
pnpm lint # ESLint (JS + CSS)
|
pnpm lint # ESLint (JS + CSS)
|
||||||
pnpm lint:fix # ESLint with autofix
|
pnpm lint:fix # ESLint with autofix
|
||||||
pnpm build # build the website
|
pnpm -F mnswpr run build # build the website
|
||||||
pnpm build:lib # build the publishable library
|
pnpm build:lib # build the publishable library
|
||||||
```
|
```
|
||||||
|
|
||||||
### Leaderboard (local Firestore emulator)
|
### 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`.
|
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:
|
`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
|
```bash
|
||||||
pnpm emulators # terminal 1 — start the Firestore emulator on :8080 (+ UI)
|
# Debian / Ubuntu / Pop!_OS
|
||||||
pnpm seed:emulator # terminal 2, once — fill it with sample scores
|
sudo apt update && sudo apt install -y openjdk-21-jre-headless
|
||||||
pnpm dev # terminal 2 — run the app against the emulator
|
|
||||||
|
# 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+)
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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.
|
See [`docs/firebase-leaderboards.md`](./docs/firebase-leaderboards.md) for the full data model, security rules, environments, and deployment.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,23 +98,26 @@ Dev config lives in the committed [`app/.env.development`](../app/.env.developme
|
||||||
|
|
||||||
### One-time CLI setup
|
### One-time CLI setup
|
||||||
|
|
||||||
The Firebase project already exists — no need to create it.
|
`firebase-tools` is a pinned devDependency of this app (installed by `pnpm install`),
|
||||||
|
so run it as the `firebase` binary via pnpm — no global install, no `npx`. The
|
||||||
|
Firebase project already exists — no need to create it.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx firebase login
|
pnpm -F mnswpr exec firebase login
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deploy rules + indexes
|
### Deploy rules + indexes
|
||||||
|
|
||||||
Deploys go to **production** by default. Target a project explicitly with
|
The `deploy:db` script (`pnpm -F mnswpr run deploy:db`) deploys everything under
|
||||||
`--project`:
|
`firestore` to the **default** project. To target a specific project or a subset,
|
||||||
|
call the CLI directly:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# production (default alias 'prod')
|
# production (default alias 'prod')
|
||||||
npx firebase deploy --only firestore:rules,firestore:indexes --project prod
|
pnpm -F mnswpr exec firebase deploy --only firestore:rules,firestore:indexes --project prod
|
||||||
|
|
||||||
# development database
|
# development database
|
||||||
npx firebase deploy --only firestore:rules,firestore:indexes --project dev
|
pnpm -F mnswpr exec firebase deploy --only firestore:rules,firestore:indexes --project dev
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ Deploying **replaces** whatever rules currently live in the Console. The
|
> ⚠️ Deploying **replaces** whatever rules currently live in the Console. The
|
||||||
|
|
@ -133,16 +136,17 @@ locally (so you validate them before deploying). The flag
|
||||||
`VITE_FIRESTORE_EMULATOR=1` is set in [`app/.env.development`](../app/.env.development).
|
`VITE_FIRESTORE_EMULATOR=1` is set in [`app/.env.development`](../app/.env.development).
|
||||||
|
|
||||||
> Prerequisite: the Firestore emulator is a Java process, so you need a JDK
|
> Prerequisite: the Firestore emulator is a Java process, so you need a JDK
|
||||||
> (11+) installed. `firebase-tools` is fetched on demand via `npx`.
|
> (11+) installed. `firebase-tools` is a pinned devDependency of this app
|
||||||
|
> (installed by `pnpm install`, run as the `firebase` binary).
|
||||||
|
|
||||||
Everyday dev loop:
|
Everyday dev loop:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm emulators # terminal 1: Firestore emulator (+ Emulator UI) on :8080
|
pnpm -F mnswpr run dev # emulator (+ UI) on :8080, auto-seeded with sample scores, + app dev server
|
||||||
pnpm seed:emulator # terminal 2, once: fill it with sample scores
|
|
||||||
pnpm dev # terminal 2: app runs against the local emulator
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`dev` seeds the fresh emulator for you (via `emulators:exec "node scripts/seed-dev-scores.js; vite"`). Use the standalone `db:start` / `db:seed` scripts only when running the emulator separately from the app.
|
||||||
|
|
||||||
Wiring: `app/main.js` passes `{ emulator: { host, port } }` to `FirebaseAdapter`,
|
Wiring: `app/main.js` passes `{ emulator: { host, port } }` to `FirebaseAdapter`,
|
||||||
which calls `connectFirestoreEmulator`. If the emulator isn't running the board
|
which calls `connectFirestoreEmulator`. If the emulator isn't running the board
|
||||||
just shows "unavailable" (a refused connection) — start it, or opt out.
|
just shows "unavailable" (a refused connection) — start it, or opt out.
|
||||||
|
|
|
||||||
|
|
@ -139,8 +139,8 @@ Both aliases target the one project we actually have:
|
||||||
|
|
||||||
### 7. Deploy & verify
|
### 7. Deploy & verify
|
||||||
|
|
||||||
1. `npx firebase deploy --only firestore:rules,firestore:indexes --project prod`
|
1. `pnpm -F mnswpr exec firebase deploy --only firestore:rules,firestore:indexes --project prod`
|
||||||
2. Set the Netlify env vars (`VITE_LB_NAMESPACE=mw` + `VITE_FIREBASE_*`).
|
2. Set the Netlify env vars via CLI: `pnpm -F mnswpr exec netlify env:import .env.production` (or `netlify env:set VITE_LB_NAMESPACE mw` + each `VITE_FIREBASE_*`).
|
||||||
3. `pnpm dev`, win a game → confirm the write lands in **`mw-test-scores`**, and
|
3. `pnpm dev`, win a game → confirm the write lands in **`mw-test-scores`**, and
|
||||||
the prod `mw-scores` is untouched (Firestore console).
|
the prod `mw-scores` is untouched (Firestore console).
|
||||||
4. Confirm reads on both `mw-scores` and `mw-test-scores` succeed under the new
|
4. Confirm reads on both `mw-scores` and `mw-test-scores` succeed under the new
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,10 @@ rules_version = '2'
|
||||||
// rules are the enforced schema: public reads for the boards, validated
|
// rules are the enforced schema: public reads for the boards, validated
|
||||||
// create-only writes for scores, no client writes to server config.
|
// create-only writes for scores, no client writes to server config.
|
||||||
//
|
//
|
||||||
// SECURITY MODEL: the Firebase web config is public (it ships to every browser),
|
// The Firebase web config that ships in the client is public by design (it is
|
||||||
// so these rules — not secrecy of the config or the codebase — are what protect
|
// not a secret); these rules — not secrecy — are what govern access. No
|
||||||
// the data. No collection allows a client to UPDATE or DELETE existing docs, so
|
// collection allows a client to update or delete existing docs. Keep any
|
||||||
// no client (with or without the source) can wipe the production boards. The
|
// admin/service-account credentials out of the repo and locked down in GCP.
|
||||||
// powerful vectors (Admin SDK / service-account keys, and IAM console access)
|
|
||||||
// bypass rules entirely — keep those out of the repo and locked down in GCP.
|
|
||||||
// See docs/firebase-leaderboards.md ("Protecting production data").
|
|
||||||
//
|
//
|
||||||
// Rules match by namespace SUFFIX (regex) rather than a literal prefix, so the
|
// Rules match by namespace SUFFIX (regex) rather than a literal prefix, so the
|
||||||
// same set covers production (`mw-*`) and test (`mw-test-*`) collections in the
|
// same set covers production (`mw-*`) and test (`mw-test-*`) collections in the
|
||||||
|
|
@ -19,7 +16,7 @@ rules_version = '2'
|
||||||
// block is guarded by a regex on the captured collection name.
|
// block is guarded by a regex on the captured collection name.
|
||||||
//
|
//
|
||||||
// Deploy with (see docs/firebase-leaderboards.md):
|
// Deploy with (see docs/firebase-leaderboards.md):
|
||||||
// npx firebase deploy --only firestore:rules --project prod
|
// pnpm -F mnswpr exec firebase deploy --only firestore:rules --project prod
|
||||||
service cloud.firestore {
|
service cloud.firestore {
|
||||||
match /databases/{database}/documents {
|
match /databases/{database}/documents {
|
||||||
|
|
||||||
|
|
@ -36,13 +33,15 @@ service cloud.firestore {
|
||||||
allow write: if false;
|
allow write: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-browser personal archive. Write-only from the client (the app never
|
// Per-browser personal archive. Not readable by clients, append/merge only,
|
||||||
// reads it back), append/merge only, and NEVER deletable. This is the only
|
// and never deletable. The doc is a map of gameId -> game object (one doc
|
||||||
// client-writable collection, so denying delete/read here removes the last
|
// per day, merged as games finish); the entry-count cap keeps a single doc
|
||||||
// way a client could destroy or harvest data.
|
// bounded (Firestore already limits any doc to 1 MiB). 500 is far above any
|
||||||
|
// real day of play.
|
||||||
match /{all}/{browserId}/games/{session} {
|
match /{all}/{browserId}/games/{session} {
|
||||||
allow read: if false;
|
allow read: if false;
|
||||||
allow create, update: if all.matches('mw(-[a-z]+)?-all');
|
allow create, update: if all.matches('mw(-[a-z]+)?-all')
|
||||||
|
&& request.resource.data.size() <= 500;
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import mnswpr from '@ayo-run/mnswpr/mnswpr.js'
|
import mnswpr from '@cozy-games/mnswpr/mnswpr.js'
|
||||||
import '@ayo-run/mnswpr/mnswpr.css'
|
import '@cozy-games/mnswpr/mnswpr.css'
|
||||||
import '@cozy-games/utils/loading/loading.css'
|
import '@cozy-games/utils/loading/loading.css'
|
||||||
import * as pkg from '@ayo-run/mnswpr/package.json'
|
import * as pkg from '@cozy-games/mnswpr/package.json'
|
||||||
import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
|
import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
|
||||||
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
|
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
|
||||||
import { NicknameService } from './modules/nickname/nickname.js'
|
import { NicknameService } from './modules/nickname/nickname.js'
|
||||||
|
|
|
||||||
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"
|
||||||
|
|
@ -1,20 +1,30 @@
|
||||||
{
|
{
|
||||||
"name": "app",
|
"name": "mnswpr",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "the mnswpr.com web app",
|
"description": "the mnswpr.com web app",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"dev": "firebase emulators:exec --only firestore --ui \"node scripts/seed-dev-scores.js; vite\"",
|
||||||
|
"dev:no-db": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"build:preview": "npm run build && npm run 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": {
|
"devDependencies": {
|
||||||
"@ayo-run/mnswpr": "workspace:*",
|
"@cozy-games/mnswpr": "workspace:*",
|
||||||
"@cozy-games/leaderboard": "workspace:*",
|
"@cozy-games/leaderboard": "workspace:*",
|
||||||
"@cozy-games/utils": "workspace:*",
|
"@cozy-games/utils": "workspace:*",
|
||||||
"firebase": "^12.11.0",
|
"firebase": "^12.11.0",
|
||||||
"web-component-base": "^4.1.2"
|
"firebase-tools": "^15.22.4",
|
||||||
|
"netlify-cli": "^26.1.0",
|
||||||
|
"web-component-base": "^5.0.0"
|
||||||
},
|
},
|
||||||
"author": "Ayo Ayco"
|
"author": "Ayo Ayco"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
getFirestore, getDocs, collection, query, orderBy, limit
|
getFirestore, getDocs, collection, query, orderBy, limit
|
||||||
} from 'firebase/firestore/lite'
|
} from 'firebase/firestore/lite'
|
||||||
|
|
||||||
import { levels } from '@ayo-run/mnswpr/levels.js'
|
import { levels } from '@cozy-games/mnswpr/levels.js'
|
||||||
|
|
||||||
// Mirror of TimerService.pretty() (@cozy-games/utils timer) — inlined so this
|
// Mirror of TimerService.pretty() (@cozy-games/utils timer) — inlined so this
|
||||||
// generator has no cross-module import chain to resolve under raw Node.
|
// generator has no cross-module import chain to resolve under raw Node.
|
||||||
|
|
|
||||||
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: {
|
languageOptions: {
|
||||||
globals: globals.node
|
globals: globals.node
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
package.json
16
package.json
|
|
@ -12,29 +12,29 @@
|
||||||
"homepage": "https://mnswpr.com",
|
"homepage": "https://mnswpr.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"dev": "pnpm -F mnswpr dev",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"dev": "vite apps/mnswpr",
|
"build": "pnpm -r --filter \"./packages/*\" run build",
|
||||||
"start": "vite apps/mnswpr",
|
|
||||||
"emulators": "cd apps/mnswpr && npx -y firebase-tools emulators:start --only firestore",
|
|
||||||
"seed:emulator": "cd apps/mnswpr && FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 node scripts/seed-dev-scores.js",
|
|
||||||
"build": "vite build apps/mnswpr",
|
|
||||||
"build:lib": "vite build packages/mnswpr",
|
"build:lib": "vite build packages/mnswpr",
|
||||||
"publish:lib": "node scripts/publish-lib.js",
|
"publish:lib": "node scripts/publish-lib.js",
|
||||||
"release": "pnpm build:lib && pnpm -F @ayo-run/mnswpr run release && pnpm publish:lib",
|
"release": "pnpm build:lib && pnpm -F @cozy-games/mnswpr run release && pnpm publish:lib",
|
||||||
"build:preview": "pnpm -F app run build:preview",
|
"postinstall": "node scripts/ensure-java.mjs",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix"
|
"lint:fix": "eslint . --fix",
|
||||||
|
"scan:secrets": "secretlint --secretlintignore .secretlintignore \"**/*\""
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/css": "^1.1.0",
|
"@eslint/css": "^1.1.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@secretlint/secretlint-rule-preset-recommend": "^13.0.2",
|
||||||
"@stylistic/eslint-plugin": "^5.10.0",
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
"bumpp": "^11.0.1",
|
"bumpp": "^11.0.1",
|
||||||
"eslint": "^10.1.0",
|
"eslint": "^10.1.0",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"secretlint": "^13.0.2",
|
||||||
"simple-git": "^3.33.0",
|
"simple-git": "^3.33.0",
|
||||||
"vite": "^8.0.3",
|
"vite": "^8.0.3",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,8 @@ mounted elements.
|
||||||
| `format` | `time` \| `number` \| `plain` | score display preset (below) |
|
| `format` | `time` \| `number` \| `plain` | score display preset (below) |
|
||||||
|
|
||||||
Attributes are reactive: change `category`/`title` at runtime and the board
|
Attributes are reactive: change `category`/`title` at runtime and the board
|
||||||
re-renders, keeping the selected duration tab.
|
re-renders, keeping the selected duration tab. Changing `score-order`/`format`
|
||||||
|
rebuilds the element's service so the new order/preset takes effect.
|
||||||
|
|
||||||
### `format` presets
|
### `format` presets
|
||||||
|
|
||||||
|
|
@ -230,5 +231,6 @@ The package computes the `day`/`week`/`month` bucket keys from `time_stamp`
|
||||||
|
|
||||||
The service is cached per element, so set overrides **before** the element
|
The service is cached per element, so set overrides **before** the element
|
||||||
connects (or set the property and clear the element's `_svc` to force a
|
connects (or set the property and clear the element's `_svc` to force a
|
||||||
rebuild). `score-order` and `format` are read from attributes; the function/
|
rebuild — changing the `score-order`/`format` attributes does this
|
||||||
array/string overrides are read from properties.
|
automatically). `score-order` and `format` are read from attributes; the
|
||||||
|
function/array/string overrides are read from properties.
|
||||||
|
|
|
||||||
|
|
@ -147,13 +147,63 @@ Want to author your own custom elements this way? Check out
|
||||||
**[webcomponent.io](https://webcomponent.io)** and
|
**[webcomponent.io](https://webcomponent.io)** and
|
||||||
**[web-component-base](https://github.com/ayo-run/wcb)**.
|
**[web-component-base](https://github.com/ayo-run/wcb)**.
|
||||||
|
|
||||||
|
## Separable read & write surfaces
|
||||||
|
|
||||||
|
The service is composed of two independently importable halves, so you never pull
|
||||||
|
in code you don't use — and can point each half at a differently-privileged
|
||||||
|
backend instance:
|
||||||
|
|
||||||
|
| Surface | Import | Uses | Adapter methods |
|
||||||
|
| ------- | ------ | ---- | --------------- |
|
||||||
|
| **Read / subscribe** | `@cozy-games/leaderboard/leaderboard-read.js` → `LeaderBoardReader` | `render()` — query a window + render the list | `listScores` |
|
||||||
|
| **Write** | `@cozy-games/leaderboard/leaderboard-write.js` → `LeaderBoardWriter` | `submit()` — archive + ranked entry | `addScore`, optional `archive`, `getConfig` |
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Read-only page (public, less-privileged instance) — no write code loaded:
|
||||||
|
import { LeaderBoardReader } from '@cozy-games/leaderboard/leaderboard-read.js'
|
||||||
|
const board = new LeaderBoardReader({ adapter: readAdapter, formatScore })
|
||||||
|
document.body.append(await board.render('beginner', 'Best Times'))
|
||||||
|
|
||||||
|
// Server / trusted path (privileged instance) — no DOM or render code loaded:
|
||||||
|
import { LeaderBoardWriter } from '@cozy-games/leaderboard/leaderboard-write.js'
|
||||||
|
const writer = new LeaderBoardWriter({ adapter: writeAdapter })
|
||||||
|
await writer.submit(entry)
|
||||||
|
```
|
||||||
|
|
||||||
|
The read module imports **no** write-path code (no bucket-key computation, no
|
||||||
|
write adapter calls) and the write module imports **no** read/render code (no
|
||||||
|
DOM, no `listScores`) — which also keeps each surface trivial to test in
|
||||||
|
isolation. `LeaderBoardService` (and `<cozy-leaderboard>`) remain the combined
|
||||||
|
facade — same `render()` + `submit()` API — for consumers that want both; it just
|
||||||
|
composes a `LeaderBoardReader` and a `LeaderBoardWriter` (exposed as `.reader` /
|
||||||
|
`.writer`).
|
||||||
|
|
||||||
## Choosing a backend
|
## Choosing a backend
|
||||||
|
|
||||||
|
### Bring your own backend instance (injection)
|
||||||
|
|
||||||
|
Both adapters let the **consumer own the backend instance** — including a
|
||||||
|
privileged/admin-level or server-side one — rather than the package creating its
|
||||||
|
own. This is the injection point per adapter:
|
||||||
|
|
||||||
|
| Adapter | Injection point | Internal init fallback |
|
||||||
|
| ---------- | -------------------------- | ----------------------------------- |
|
||||||
|
| Supabase | `client` (a supabase-js client you build) — **always** consumer-supplied; the package takes no supabase dependency | none — a client is required |
|
||||||
|
| Firebase | `store` (a Firestore instance you build) | `firebaseConfig` → the package initializes its own app |
|
||||||
|
|
||||||
|
Supply a privileged instance and every read/write runs against it — the package
|
||||||
|
adds no auth or app lifecycle of its own.
|
||||||
|
|
||||||
### Firebase (Firestore)
|
### Firebase (Firestore)
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
|
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
|
||||||
|
|
||||||
|
// (a) let the package initialize from a public config:
|
||||||
const adapter = new FirebaseAdapter({ firebaseConfig, namespace: 'mw' })
|
const adapter = new FirebaseAdapter({ firebaseConfig, namespace: 'mw' })
|
||||||
|
|
||||||
|
// (b) OR inject a Firestore instance you built (e.g. privileged/server-side):
|
||||||
|
const adapter = new FirebaseAdapter({ store: myFirestore, namespace: 'mw' })
|
||||||
```
|
```
|
||||||
|
|
||||||
Needs the `firebase` peer dependency. Uses collections
|
Needs the `firebase` peer dependency. Uses collections
|
||||||
|
|
@ -162,9 +212,10 @@ Needs the `firebase` peer dependency. Uses collections
|
||||||
and all-time sorts by `score`, so only Firestore's automatic single-field indexes
|
and all-time sorts by `score`, so only Firestore's automatic single-field indexes
|
||||||
are needed — no composite indexes to deploy.
|
are needed — no composite indexes to deploy.
|
||||||
|
|
||||||
For local development, pass `emulator` to run against the
|
With an injected `store` the package initializes nothing and owns no app
|
||||||
|
lifecycle. For internal init, pass `emulator` to run against the
|
||||||
[Firestore emulator](https://firebase.google.com/docs/emulator-suite) — no cloud,
|
[Firestore emulator](https://firebase.google.com/docs/emulator-suite) — no cloud,
|
||||||
no deploy:
|
no deploy (wire the emulator into your own store if you inject one):
|
||||||
|
|
||||||
```js
|
```js
|
||||||
new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator: { host: '127.0.0.1', port: 8080 } })
|
new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator: { host: '127.0.0.1', port: 8080 } })
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,28 @@ import {
|
||||||
export class FirebaseAdapter {
|
export class FirebaseAdapter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Supply EITHER a ready Firestore instance via `store` (the injection point —
|
||||||
|
* e.g. a privileged/server-side setup, or an app you already initialized), OR a
|
||||||
|
* `firebaseConfig` for the package to initialize its own app. `store` wins when
|
||||||
|
* both are given; with an injected store the package initializes nothing and
|
||||||
|
* owns no app lifecycle (so `emulator` — a convenience of internal init — is
|
||||||
|
* ignored; wire the emulator into your own store).
|
||||||
|
*
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
* @param {Object} options.firebaseConfig - Firebase app config (public; access governed by security rules)
|
* @param {Object} [options.store] - a Firestore instance to use as-is (injection point)
|
||||||
|
* @param {Object} [options.firebaseConfig] - Firebase app config for internal init (public; access governed by security rules)
|
||||||
* @param {String} [options.namespace] - collection prefix
|
* @param {String} [options.namespace] - collection prefix
|
||||||
* @param {{ host?: string, port?: number }} [options.emulator] - point at a local Firestore emulator (dev/test only)
|
* @param {{ host?: string, port?: number }} [options.emulator] - point the internally-created store at a local Firestore emulator (dev/test only)
|
||||||
*/
|
*/
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.namespace = options.namespace || 'lb'
|
this.namespace = options.namespace || 'lb'
|
||||||
|
if (options.store) {
|
||||||
|
this.store = options.store
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!options.firebaseConfig) {
|
||||||
|
throw new TypeError('FirebaseAdapter: provide either `store` (a Firestore instance) or `firebaseConfig`')
|
||||||
|
}
|
||||||
const app = initializeApp(options.firebaseConfig)
|
const app = initializeApp(options.firebaseConfig)
|
||||||
this.store = getFirestore(app)
|
this.store = getFirestore(app)
|
||||||
if (options.emulator) {
|
if (options.emulator) {
|
||||||
|
|
|
||||||
|
|
@ -1,302 +1,65 @@
|
||||||
import { buckets } from '@cozy-games/utils/date-bucket/date-bucket.js'
|
import { LeaderBoardReader } from './leaderboard-read.js'
|
||||||
|
import { LeaderBoardWriter } from './leaderboard-write.js'
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The four time windows are ROLLING: each shows entries from the last `ms`
|
|
||||||
* milliseconds (strictly nested — 24h ⊆ 7d ⊆ 30d ⊆ all), sorted by score.
|
|
||||||
* `ms: null` is the all-time view (no time filter). `title` is the hover tooltip
|
|
||||||
* that spells out the window.
|
|
||||||
*/
|
|
||||||
const DURATIONS = [
|
|
||||||
{ id: 'today', label: 'Today', ms: DAY_MS, title: 'Last 24 hours' },
|
|
||||||
{ id: 'week', label: 'Week', ms: 7 * DAY_MS, title: 'Last 7 days' },
|
|
||||||
{ id: 'month', label: 'Month', ms: 30 * DAY_MS, title: 'Last 30 days' },
|
|
||||||
{ id: 'all', label: 'All Time', ms: null, title: 'All time' }
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default empty-state messages — challenging but friendly, and game-agnostic.
|
|
||||||
* One is picked at random each render. Override per app via the `emptyMessages`
|
|
||||||
* option (see below) so localization stays out of this package.
|
|
||||||
*/
|
|
||||||
const EMPTY_MESSAGES = [
|
|
||||||
'Be the first to enter the leader board!',
|
|
||||||
'No scores yet — claim the top spot!',
|
|
||||||
'This board is wide open. Conquer it!',
|
|
||||||
'No champions here yet. Will it be you?',
|
|
||||||
'Blank slate — set the score to beat!',
|
|
||||||
'Nobody\'s here yet. Be the first!',
|
|
||||||
'The top spot is up for grabs. Take it!',
|
|
||||||
'Empty board. Time to make your mark!'
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic, game- AND backend-agnostic leaderboard. Nothing here knows about
|
* Generic, game- AND backend-agnostic leaderboard. Nothing here knows about
|
||||||
* minesweeper or Firebase: the ranked value is a plain `score`, sorted in the
|
* minesweeper or Firebase: the ranked value is a plain `score`, sorted in the
|
||||||
* configured direction and displayed through an injected formatter, while all
|
* configured direction and displayed through an injected formatter, while all
|
||||||
* storage I/O is delegated to an injected adapter (see adapters/). Games wire
|
* storage I/O is delegated to an injected adapter (see adapters/).
|
||||||
* their specifics through the constructor options.
|
*
|
||||||
|
* This is the COMBINED facade: it simply composes the two separable surfaces —
|
||||||
|
* {@link LeaderBoardReader} (read/subscribe/render) and {@link LeaderBoardWriter}
|
||||||
|
* (submit) — for consumers that want both in one object. Consumers who need only
|
||||||
|
* one half import it directly and pull in nothing from the other:
|
||||||
|
*
|
||||||
|
* import { LeaderBoardReader } from '@cozy-games/leaderboard/leaderboard-read.js'
|
||||||
|
* import { LeaderBoardWriter } from '@cozy-games/leaderboard/leaderboard-write.js'
|
||||||
|
*
|
||||||
|
* Public API is unchanged: `render()` (read) and `submit()` (write). The reader
|
||||||
|
* and writer are also exposed as `.reader` / `.writer` for direct access. Reads
|
||||||
|
* and writes may be wired to differently-privileged backend instances by passing
|
||||||
|
* the surfaces different adapters (construct a Reader and Writer directly).
|
||||||
*
|
*
|
||||||
* An adapter implements:
|
* An adapter implements:
|
||||||
* - getConfig(): Promise<Object|undefined>
|
* - getConfig(): Promise<Object|undefined>
|
||||||
* - listScores({ category, field, value, order, limit }): Promise<Object[]>
|
* - listScores({ category, since, order, limit }): Promise<Object[]> // read
|
||||||
* - addScore(category, entry): Promise<void>
|
* - addScore(category, entry): Promise<void> // write
|
||||||
* - archive(entry): Promise<void> // optional personal history
|
* - archive(entry): Promise<void> // optional personal history // write
|
||||||
*/
|
*/
|
||||||
export class LeaderBoardService {
|
export class LeaderBoardService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} options
|
* @param {Object} options - see {@link LeaderBoardReader} and {@link LeaderBoardWriter} for the full set
|
||||||
* @param {Object} options.adapter - storage backend (e.g. FirebaseAdapter, SupabaseAdapter)
|
* @param {Object} options.adapter - storage backend (e.g. FirebaseAdapter, SupabaseAdapter)
|
||||||
* @param {'asc'|'desc'} [options.scoreOrder] - 'asc' = lower is better (e.g. time), 'desc' = higher is better
|
* @param {'asc'|'desc'} [options.scoreOrder]
|
||||||
* @param {(value: number) => string} [options.formatScore] - display formatter for a score
|
* @param {(value: number) => string} [options.formatScore]
|
||||||
* @param {(entry: Object) => boolean} [options.qualifies] - whether an entry is ranked; defaults to server passingStatus vs entry.status
|
* @param {(entry: Object) => boolean} [options.qualifies]
|
||||||
* @param {Object} [options.labels] - optional tab-label overrides keyed by duration id
|
* @param {Object} [options.labels]
|
||||||
* @param {Object} [options.tooltips] - optional tab hover-text overrides keyed by duration id
|
* @param {Object} [options.tooltips]
|
||||||
* @param {string[]} [options.emptyMessages] - empty-state messages (one picked at random); localize here
|
* @param {string[]} [options.emptyMessages]
|
||||||
* @param {string} [options.loadingText] - shown while a window loads
|
* @param {string} [options.loadingText]
|
||||||
* @param {string} [options.errorText] - shown when a window fails to load
|
* @param {string} [options.errorText]
|
||||||
* @param {string} [options.anonymousName] - fallback display name for entries without one
|
* @param {string} [options.anonymousName]
|
||||||
*/
|
*/
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.adapter = options.adapter
|
this.adapter = options.adapter
|
||||||
this.scoreOrder = options.scoreOrder === 'desc' ? 'desc' : 'asc'
|
this.reader = new LeaderBoardReader(options)
|
||||||
this.formatScore = options.formatScore || (value => String(value))
|
this.writer = new LeaderBoardWriter(options)
|
||||||
this.qualifies = options.qualifies || (entry => this._defaultQualifies(entry))
|
|
||||||
this.labels = options.labels || {}
|
|
||||||
this.tooltips = options.tooltips || {}
|
|
||||||
|
|
||||||
// User-facing strings — override to localize; the package ships English defaults.
|
|
||||||
this.emptyMessages = (Array.isArray(options.emptyMessages) && options.emptyMessages.length)
|
|
||||||
? options.emptyMessages
|
|
||||||
: EMPTY_MESSAGES
|
|
||||||
this.loadingText = options.loadingText || 'Loading…'
|
|
||||||
this.errorText = options.errorText || 'Leaderboard unavailable right now.'
|
|
||||||
this.anonymousName = options.anonymousName || 'Anonymous'
|
|
||||||
|
|
||||||
Promise.resolve(this.adapter.getConfig())
|
|
||||||
.then(config => {
|
|
||||||
this.configuration = config
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default ranking gate: if the server config names a `passingStatus`, only
|
* Read surface — render the ranked list with a duration tab bar.
|
||||||
* entries whose `status` matches qualify; otherwise every entry qualifies.
|
* @see LeaderBoardReader#render
|
||||||
*/
|
*/
|
||||||
_defaultQualifies(entry) {
|
render(category, title, duration) {
|
||||||
const passing = this.configuration && this.configuration.passingStatus
|
return this.reader.render(category, title, duration)
|
||||||
if (!passing) return true
|
|
||||||
return entry.status === passing
|
|
||||||
}
|
|
||||||
|
|
||||||
_label(duration) {
|
|
||||||
return this.labels[duration.id] || duration.label
|
|
||||||
}
|
|
||||||
|
|
||||||
_tooltip(duration) {
|
|
||||||
return this.tooltips[duration.id] || duration.title
|
|
||||||
}
|
|
||||||
|
|
||||||
_emptyMessage() {
|
|
||||||
return this.emptyMessages[Math.floor(Math.random() * this.emptyMessages.length)]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backend-neutral query descriptor for a category and time window. `since` is
|
* Write surface — submit a completed game (archive + ranked entry).
|
||||||
* the rolling cutoff (entries with `time_stamp >= since`); `null` means
|
* @see LeaderBoardWriter#submit
|
||||||
* all-time (no time filter). The adapter turns this into a real query.
|
|
||||||
*/
|
*/
|
||||||
_descriptor(category, duration) {
|
submit(entry) {
|
||||||
return {
|
return this.writer.submit(entry)
|
||||||
category,
|
|
||||||
since: duration.ms ? new Date(Date.now() - duration.ms) : null,
|
|
||||||
order: this.scoreOrder,
|
|
||||||
limit: 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the leaderboard for a category with a duration tab bar. When
|
|
||||||
* `duration` is omitted the last-selected tab is reused (so switching game
|
|
||||||
* category keeps the player on the same window), defaulting to "today".
|
|
||||||
* Returns the wrapper element; tab clicks re-query in place.
|
|
||||||
* @param {String} category
|
|
||||||
* @param {String} title
|
|
||||||
* @param {String} [duration]
|
|
||||||
* @returns {Promise<HTMLDivElement>}
|
|
||||||
*/
|
|
||||||
async render(category, title, duration) {
|
|
||||||
this.category = category
|
|
||||||
this.title = title
|
|
||||||
if (duration) this.duration = duration
|
|
||||||
if (!this.duration) this.duration = 'today'
|
|
||||||
duration = this.duration
|
|
||||||
|
|
||||||
const wrapper = document.createElement('div')
|
|
||||||
wrapper.style.maxWidth = '270px'
|
|
||||||
wrapper.style.margin = '0 auto'
|
|
||||||
|
|
||||||
const heading = document.createElement('h3')
|
|
||||||
heading.innerText = title
|
|
||||||
heading.style.borderBottom = '1px solid #c0c0c0'
|
|
||||||
heading.style.paddingBottom = '10px'
|
|
||||||
wrapper.append(heading)
|
|
||||||
|
|
||||||
const tabBar = document.createElement('div')
|
|
||||||
tabBar.style.display = 'flex'
|
|
||||||
tabBar.style.justifyContent = 'center'
|
|
||||||
tabBar.style.gap = '8px'
|
|
||||||
tabBar.style.marginBottom = '10px'
|
|
||||||
wrapper.append(tabBar)
|
|
||||||
|
|
||||||
const listWrapper = document.createElement('div')
|
|
||||||
wrapper.append(listWrapper)
|
|
||||||
|
|
||||||
const tabs = {}
|
|
||||||
const activate = (id) => {
|
|
||||||
this.duration = id
|
|
||||||
Object.entries(tabs).forEach(([tabId, el]) => this._styleTab(el, tabId === id))
|
|
||||||
this._loadList(listWrapper, category, DURATIONS.find(d => d.id === id))
|
|
||||||
}
|
|
||||||
|
|
||||||
DURATIONS.forEach(d => {
|
|
||||||
const tab = document.createElement('button')
|
|
||||||
tab.innerText = this._label(d)
|
|
||||||
tab.type = 'button'
|
|
||||||
tab.setAttribute('title', this._tooltip(d))
|
|
||||||
this._styleTab(tab, d.id === duration)
|
|
||||||
tab.onclick = () => activate(d.id)
|
|
||||||
tabs[d.id] = tab
|
|
||||||
tabBar.append(tab)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Return the wrapper (heading + tabs) right away and fill the list
|
|
||||||
// asynchronously, so a slow or failing query never blocks the UI.
|
|
||||||
this._loadList(listWrapper, category, DURATIONS.find(d => d.id === duration))
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
}
|
|
||||||
|
|
||||||
_styleTab(tab, active) {
|
|
||||||
tab.style.background = 'none'
|
|
||||||
tab.style.border = 'none'
|
|
||||||
tab.style.cursor = 'pointer'
|
|
||||||
tab.style.padding = '2px 4px'
|
|
||||||
tab.style.fontSize = '0.85em'
|
|
||||||
tab.style.color = active ? '#ffffff' : '#999999'
|
|
||||||
tab.style.fontWeight = active ? 'bold' : 'normal'
|
|
||||||
tab.style.borderBottom = active ? '2px solid orange' : '2px solid transparent'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a window's entries into the list area, showing a loading placeholder
|
|
||||||
* and turning any failure (e.g. the backend being unreachable) into a
|
|
||||||
* message instead of an unhandled rejection. Guards against races when the
|
|
||||||
* player switches tabs quickly by tagging the in-flight request.
|
|
||||||
*/
|
|
||||||
async _loadList(listWrapper, category, duration) {
|
|
||||||
const token = (this._loadToken || 0) + 1
|
|
||||||
this._loadToken = token
|
|
||||||
|
|
||||||
listWrapper.innerHTML = ''
|
|
||||||
const loading = document.createElement('em')
|
|
||||||
loading.innerText = this.loadingText
|
|
||||||
listWrapper.append(loading)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rows = await this.adapter.listScores(this._descriptor(category, duration))
|
|
||||||
if (this._loadToken !== token) return
|
|
||||||
this._renderList(listWrapper, rows)
|
|
||||||
} catch {
|
|
||||||
if (this._loadToken !== token) return
|
|
||||||
listWrapper.innerHTML = ''
|
|
||||||
const message = document.createElement('em')
|
|
||||||
message.innerText = this.errorText
|
|
||||||
listWrapper.append(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_renderList(listWrapper, rows) {
|
|
||||||
listWrapper.innerHTML = ''
|
|
||||||
|
|
||||||
if (!rows || !rows.length) {
|
|
||||||
const message = document.createElement('em')
|
|
||||||
message.innerText = this._emptyMessage()
|
|
||||||
listWrapper.append(message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = document.createElement('div')
|
|
||||||
list.style.listStyle = 'none'
|
|
||||||
list.style.textAlign = 'left'
|
|
||||||
|
|
||||||
let i = 1
|
|
||||||
rows.forEach(data => {
|
|
||||||
const item = document.createElement('div')
|
|
||||||
item.style.display = 'flex'
|
|
||||||
|
|
||||||
const indexElement = document.createElement('div')
|
|
||||||
indexElement.innerText = `#${i++}`
|
|
||||||
|
|
||||||
const nameElement = document.createElement('div')
|
|
||||||
const name = data.name || this.anonymousName
|
|
||||||
nameElement.innerHTML = name
|
|
||||||
nameElement.setAttribute('title', name)
|
|
||||||
nameElement.style.textOverflow = 'ellipsis'
|
|
||||||
nameElement.style.whiteSpace = 'nowrap'
|
|
||||||
nameElement.style.overflow = 'hidden'
|
|
||||||
nameElement.style.padding = '0 5px'
|
|
||||||
nameElement.style.fontWeight = 'bold'
|
|
||||||
nameElement.style.fontStyle = 'italic'
|
|
||||||
nameElement.style.flex = '1'
|
|
||||||
|
|
||||||
const scoreElement = document.createElement('div')
|
|
||||||
scoreElement.innerText = this.formatScore(data.score)
|
|
||||||
|
|
||||||
item.append(indexElement, nameElement, scoreElement)
|
|
||||||
list.append(item)
|
|
||||||
})
|
|
||||||
|
|
||||||
listWrapper.append(list)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit a completed game. Always archives it (personal history); if it
|
|
||||||
* qualifies, also writes a ranked entry with denormalized bucket keys. Both
|
|
||||||
* writes go through the adapter, so the storage backend is pluggable. The
|
|
||||||
* caller owns display-name/nickname UX.
|
|
||||||
* @param {Object} entry - { name, playerId, score, category, time_stamp, status?, meta? }
|
|
||||||
*/
|
|
||||||
async submit(entry) {
|
|
||||||
if (this.adapter.archive) {
|
|
||||||
await this.adapter.archive({
|
|
||||||
playerId: entry.playerId,
|
|
||||||
score: entry.score,
|
|
||||||
category: entry.category,
|
|
||||||
time_stamp: entry.time_stamp,
|
|
||||||
meta: entry.meta
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.qualifies(entry)) return
|
|
||||||
|
|
||||||
const stamp = entry.time_stamp instanceof Date ? entry.time_stamp : new Date(entry.time_stamp)
|
|
||||||
const bucket = buckets(stamp)
|
|
||||||
const scoreDoc = {
|
|
||||||
name: entry.name || 'Anonymous',
|
|
||||||
playerId: entry.playerId,
|
|
||||||
score: entry.score,
|
|
||||||
category: entry.category,
|
|
||||||
time_stamp: entry.time_stamp,
|
|
||||||
day: bucket.day,
|
|
||||||
week: bucket.week,
|
|
||||||
month: bucket.month
|
|
||||||
}
|
|
||||||
if (entry.meta) scoreDoc.meta = entry.meta
|
|
||||||
|
|
||||||
await this.adapter.addScore(entry.category, scoreDoc)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
import { WebComponent } from 'web-component-base'
|
import { WebComponent, html } from 'web-component-base'
|
||||||
import { LeaderBoardService } from './leader-board.js'
|
import { LeaderBoardService } from './leader-board.js'
|
||||||
|
import { DURATIONS } from './leaderboard-read.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `<cozy-leaderboard>` — a custom element that lets a developer compose the
|
* `<cozy-leaderboard>` — a custom element that lets a developer compose the
|
||||||
* leaderboard UI declaratively in HTML instead of wiring it in JavaScript.
|
* leaderboard UI declaratively in HTML instead of wiring it in JavaScript.
|
||||||
*
|
*
|
||||||
* Built on `web-component-base` (WebComponent base class + lifecycle hooks). It
|
* Built on `web-component-base` (WCB) the idiomatic way: the observed
|
||||||
* extends WCB for the custom-element scaffolding (onInit/onDestroy, connect
|
* attributes are declared as `static props` (typed defaults; the base derives
|
||||||
* handling) and delegates all inner DOM — the duration tabs and the ranked
|
* observedAttributes and feeds values into the reactive `this.props`), the view
|
||||||
* list — to the existing LeaderBoardService, which already builds and manages
|
* is a pure `html` template over a precomputed view-state object, and change
|
||||||
* that DOM (including in-place tab swaps).
|
* reactions arrive through the `onChanges` hook. Data access and user-facing
|
||||||
|
* strings stay in {@link LeaderBoardService} / LeaderBoardReader — the element
|
||||||
|
* only turns query results into templates.
|
||||||
|
*
|
||||||
|
* (WCB ≥5 is required: v4 wrote each prop default onto the element as an
|
||||||
|
* attribute inside the constructor, which the custom-elements spec forbids and
|
||||||
|
* which broke `document.createElement('cozy-leaderboard')`. v5 defers that
|
||||||
|
* reflection to connect and never clobbers authored attributes, making
|
||||||
|
* `static props` safe here.)
|
||||||
*
|
*
|
||||||
* Composition lives in HTML attributes; the storage backend (adapter) is set
|
* Composition lives in HTML attributes; the storage backend (adapter) is set
|
||||||
* once in JS via configureLeaderboard(), because env-var config can't live in
|
* once in JS via configureLeaderboard(), because env-var config can't live in
|
||||||
|
|
@ -53,49 +62,140 @@ const prettyTime = ms => {
|
||||||
const FORMATTERS = { time: prettyTime }
|
const FORMATTERS = { time: prettyTime }
|
||||||
const resolveFormat = name => FORMATTERS[name]
|
const resolveFormat = name => FORMATTERS[name]
|
||||||
|
|
||||||
|
// Inline styles (as WCB `html` style objects) — the same visual output the
|
||||||
|
// LeaderBoardReader produces for the imperative JS composition path.
|
||||||
|
const STYLES = {
|
||||||
|
wrapper: { maxWidth: '270px', margin: '0 auto' },
|
||||||
|
heading: { borderBottom: '1px solid #c0c0c0', paddingBottom: '10px' },
|
||||||
|
tabBar: { display: 'flex', justifyContent: 'center', gap: '8px', marginBottom: '10px' },
|
||||||
|
list: { listStyle: 'none', textAlign: 'left' },
|
||||||
|
row: { display: 'flex' },
|
||||||
|
name: {
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: '0 5px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
flex: '1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabStyle = active => ({
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '2px 4px',
|
||||||
|
fontSize: '0.85em',
|
||||||
|
color: active ? '#ffffff' : '#999999',
|
||||||
|
fontWeight: active ? 'bold' : 'normal',
|
||||||
|
borderBottom: active ? '2px solid orange' : '2px solid transparent'
|
||||||
|
})
|
||||||
|
|
||||||
export class CozyLeaderboard extends WebComponent {
|
export class CozyLeaderboard extends WebComponent {
|
||||||
|
|
||||||
static get observedAttributes() {
|
// Declared attributes (kebab-cased on the element: score-order). String
|
||||||
return ['category', 'title', 'duration', 'score-order', 'format']
|
// defaults keep `this.props.*` typed as strings, so an unset or emptied
|
||||||
|
// attribute reads as '' rather than a coerced boolean. The base class derives
|
||||||
|
// observedAttributes from these keys.
|
||||||
|
static props = {
|
||||||
|
category: '',
|
||||||
|
title: '',
|
||||||
|
duration: '',
|
||||||
|
scoreOrder: '',
|
||||||
|
format: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// WCB lifecycle: register/unregister so configureLeaderboard() can re-render.
|
// View state the template renders from. Either { board: false } (not
|
||||||
|
// configured) or { board: true, tabs, active, list } where list is
|
||||||
|
// { rows: [{ index, name, score }] } or { message } (loading/empty/error).
|
||||||
|
_view = { board: false }
|
||||||
|
// Selected duration window; survives category changes and re-connects.
|
||||||
|
// null until the board first mounts, so the `duration` attribute is honored.
|
||||||
|
_activeDuration = null
|
||||||
|
_connected = false
|
||||||
|
_token = 0
|
||||||
|
|
||||||
|
// WCB lifecycle: connect mounts the board, disconnect unregisters. The
|
||||||
|
// instances set lets configureLeaderboard() re-mount live elements.
|
||||||
onInit() {
|
onInit() {
|
||||||
|
this._connected = true
|
||||||
instances.add(this)
|
instances.add(this)
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy() {
|
|
||||||
instances.delete(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attributes are read directly (getAttribute) rather than through WCB's typed
|
|
||||||
// props proxy, so optional/empty values never trip its type enforcement.
|
|
||||||
attributeChangedCallback(name, previousValue, currentValue) {
|
|
||||||
if (previousValue === currentValue || !this.isConnected) return
|
|
||||||
this._mount(name === 'duration' ? (currentValue || undefined) : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WCB calls render() on connect; we treat it as "(re)mount the board".
|
|
||||||
render() {
|
|
||||||
this._mount()
|
this._mount()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDestroy() {
|
||||||
|
this._connected = false
|
||||||
|
instances.delete(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WCB change hook — `property` is the camelCase prop name (WCB ≥5). A title
|
||||||
|
* change needs no re-query: the heading reads `this.props.title`, so the base
|
||||||
|
* class's own render already updated it. score-order/format changes rebuild
|
||||||
|
* the service so the new config actually takes effect.
|
||||||
|
*/
|
||||||
|
onChanges({ property }) {
|
||||||
|
// Invalidate the cached service even while disconnected, so a scoreOrder/
|
||||||
|
// format change made on a detached element takes effect on re-connect.
|
||||||
|
if (property === 'scoreOrder' || property === 'format') this._svc = null
|
||||||
|
if (!this._connected || property === 'title') return
|
||||||
|
this._mount(property === 'duration' ? (this.props.duration || undefined) : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
const view = this._view
|
||||||
|
if (!view.board) return html`<em>Leaderboard not configured.</em>`
|
||||||
|
return html`
|
||||||
|
<div style=${STYLES.wrapper}>
|
||||||
|
<h3 style=${STYLES.heading}>${this.props.title}</h3>
|
||||||
|
<div style=${STYLES.tabBar}>
|
||||||
|
${view.tabs.map(tab => html`
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title=${tab.tooltip}
|
||||||
|
data-duration=${tab.id}
|
||||||
|
style=${tabStyle(tab.id === view.active)}
|
||||||
|
onclick=${() => this._selectTab(tab.id)}
|
||||||
|
>${tab.label}</button>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
${view.list.rows
|
||||||
|
? html`
|
||||||
|
<div style=${STYLES.list}>
|
||||||
|
${view.list.rows.map(row => html`
|
||||||
|
<div style=${STYLES.row}>
|
||||||
|
<div>#${row.index}</div>
|
||||||
|
<div title=${row.name} style=${STYLES.name}>${row.name}</div>
|
||||||
|
<div>${row.score}</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>`
|
||||||
|
: html`<em>${view.list.message}</em>`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
// Per-element override properties (public): adapter, formatScore, qualifies,
|
// Per-element override properties (public): adapter, formatScore, qualifies,
|
||||||
// labels, emptyMessages, loadingText, errorText, anonymousName. Each falls back
|
// labels, tooltips, emptyMessages, loadingText, errorText, anonymousName.
|
||||||
// to the shared configureLeaderboard() value, then the package default. Set
|
// Each falls back to the shared configureLeaderboard() value, then the
|
||||||
// them before the element connects (or clear `_svc` to force a rebuild).
|
// package default. Set them before the element connects (or clear `_svc` to
|
||||||
|
// force a rebuild). Rich values stay plain properties — WCB props are
|
||||||
|
// attribute-backed and only carry serializable primitives.
|
||||||
_service() {
|
_service() {
|
||||||
if (this._svc) return this._svc
|
if (this._svc) return this._svc
|
||||||
const adapter = this.adapter || sharedConfig.adapter
|
const adapter = this.adapter || sharedConfig.adapter
|
||||||
if (!adapter) return null
|
if (!adapter) return null
|
||||||
const formatScore = this.formatScore
|
const formatScore = this.formatScore
|
||||||
|| resolveFormat(this.getAttribute('format'))
|
|| resolveFormat(this.props.format)
|
||||||
|| sharedConfig.formatScore
|
|| sharedConfig.formatScore
|
||||||
|| resolveFormat(sharedConfig.format)
|
|| resolveFormat(sharedConfig.format)
|
||||||
|| String
|
|| String
|
||||||
this._svc = new LeaderBoardService({
|
this._svc = new LeaderBoardService({
|
||||||
adapter,
|
adapter,
|
||||||
scoreOrder: this.getAttribute('score-order') || sharedConfig.scoreOrder || 'asc',
|
scoreOrder: this.props.scoreOrder || sharedConfig.scoreOrder || 'asc',
|
||||||
formatScore,
|
formatScore,
|
||||||
qualifies: this.qualifies || sharedConfig.qualifies,
|
qualifies: this.qualifies || sharedConfig.qualifies,
|
||||||
// User-facing strings — pass through so apps localize without touching the package.
|
// User-facing strings — pass through so apps localize without touching the package.
|
||||||
|
|
@ -110,36 +210,78 @@ export class CozyLeaderboard extends WebComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* (Re)render the board. The first successful mount honors the author's
|
* (Re)mount the board. The first mount honors the author's `duration`
|
||||||
* `duration` attribute; later mounts preserve the service's remembered
|
* attribute; later mounts keep the selected duration (so switching category
|
||||||
* duration (so switching category keeps the selected tab) unless a duration
|
* keeps the selected tab) unless a duration is passed explicitly.
|
||||||
* is passed explicitly.
|
|
||||||
*/
|
*/
|
||||||
_mount(durationArg) {
|
_mount(durationArg) {
|
||||||
if (!this.isConnected) return
|
if (!this._connected) return
|
||||||
const service = this._service()
|
const service = this._service()
|
||||||
if (!service) {
|
if (!service) {
|
||||||
this.replaceChildren(this._message('Leaderboard not configured.'))
|
this._view = { board: false }
|
||||||
|
this._paint()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const duration = durationArg
|
||||||
|
?? this._activeDuration
|
||||||
|
?? (this.props.duration || 'today')
|
||||||
|
this._activeDuration = duration
|
||||||
|
this._load(service, duration)
|
||||||
|
}
|
||||||
|
|
||||||
let duration = durationArg
|
_selectTab(id) {
|
||||||
if (duration === undefined && !this._mounted) {
|
this._activeDuration = id
|
||||||
duration = this.getAttribute('duration') || undefined
|
this._load(this._service(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query one duration window and project the result into view state: a
|
||||||
|
* loading message immediately, then rows / a random empty message / the
|
||||||
|
* error text. The token guards against a stale response (quick tab or
|
||||||
|
* category switches) overwriting a newer one.
|
||||||
|
*/
|
||||||
|
async _load(service, durationId) {
|
||||||
|
const reader = service.reader
|
||||||
|
const tabs = DURATIONS.map(d => ({ id: d.id, label: reader.label(d), tooltip: reader.tooltip(d) }))
|
||||||
|
const board = list => ({ board: true, tabs, active: durationId, list })
|
||||||
|
|
||||||
|
const token = ++this._token
|
||||||
|
this._view = board({ message: reader.loadingText })
|
||||||
|
this._paint()
|
||||||
|
|
||||||
|
let list
|
||||||
|
try {
|
||||||
|
const rows = await reader.list(this.props.category, durationId)
|
||||||
|
list = (rows && rows.length)
|
||||||
|
? {
|
||||||
|
rows: rows.map((row, index) => ({
|
||||||
|
index: index + 1,
|
||||||
|
name: row.name || reader.anonymousName,
|
||||||
|
score: reader.formatScore(row.score)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
: { message: reader.emptyMessage() }
|
||||||
|
} catch {
|
||||||
|
list = { message: reader.errorText }
|
||||||
}
|
}
|
||||||
|
if (token !== this._token) return
|
||||||
|
this._view = board(list)
|
||||||
|
this._paint()
|
||||||
|
}
|
||||||
|
|
||||||
const token = (this._token || 0) + 1
|
/**
|
||||||
this._token = token
|
* Render the current view state through WCB. WCB's render() replaces the
|
||||||
service.render(this.getAttribute('category') || '', this.getAttribute('title') || '', duration)
|
* whole subtree (no diffing yet), which would drop focus from a clicked
|
||||||
.then(el => {
|
* duration tab — the one behavior the base class can't preserve for us — so
|
||||||
if (this._token !== token) return
|
* focus is handed to the replacement tab explicitly.
|
||||||
this.replaceChildren(el)
|
*/
|
||||||
this._mounted = true
|
_paint() {
|
||||||
})
|
// getRootNode(), not document: inside a shadow root, document.activeElement
|
||||||
.catch(() => {
|
// is retargeted to the host and the focused tab would go undetected.
|
||||||
if (this._token !== token) return
|
const focused = this.getRootNode().activeElement
|
||||||
this.replaceChildren(this._message('Leaderboard unavailable right now.'))
|
const focusedTab = focused && this.contains(focused) ? focused.dataset.duration : undefined
|
||||||
})
|
this.render()
|
||||||
|
if (focusedTab) this.querySelector(`button[data-duration="${focusedTab}"]`)?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -151,12 +293,6 @@ export class CozyLeaderboard extends WebComponent {
|
||||||
const service = this._service()
|
const service = this._service()
|
||||||
if (service) return service.submit(entry)
|
if (service) return service.submit(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
_message(text) {
|
|
||||||
const em = document.createElement('em')
|
|
||||||
em.innerText = text
|
|
||||||
return em
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!customElements.get('cozy-leaderboard')) {
|
if (!customElements.get('cozy-leaderboard')) {
|
||||||
|
|
|
||||||
272
packages/leaderboard/leaderboard-read.js
Normal file
272
packages/leaderboard/leaderboard-read.js
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
/**
|
||||||
|
* The READ / subscribe surface of the leaderboard: querying a time window and
|
||||||
|
* rendering the ranked list. Importable WITHOUT any write-path code — no
|
||||||
|
* `submit`, no bucket-key computation, no write adapter calls — so read-only
|
||||||
|
* consumers (and tests) pull in nothing they don't need.
|
||||||
|
*
|
||||||
|
* Pairs with `leaderboard-write.js` (the write half) and `leader-board.js` (the
|
||||||
|
* combined facade). The READ side only ever calls `adapter.listScores`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four time windows are ROLLING: each shows entries from the last `ms`
|
||||||
|
* milliseconds (strictly nested — 24h ⊆ 7d ⊆ 30d ⊆ all), sorted by score.
|
||||||
|
* `ms: null` is the all-time view (no time filter). `title` is the hover tooltip
|
||||||
|
* that spells out the window. Exported so view layers (e.g. the
|
||||||
|
* `<cozy-leaderboard>` element) can render the tab bar themselves.
|
||||||
|
*/
|
||||||
|
export const DURATIONS = [
|
||||||
|
{ id: 'today', label: 'Today', ms: DAY_MS, title: 'Last 24 hours' },
|
||||||
|
{ id: 'week', label: 'Week', ms: 7 * DAY_MS, title: 'Last 7 days' },
|
||||||
|
{ id: 'month', label: 'Month', ms: 30 * DAY_MS, title: 'Last 30 days' },
|
||||||
|
{ id: 'all', label: 'All Time', ms: null, title: 'All time' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default empty-state messages — challenging but friendly, and game-agnostic.
|
||||||
|
* One is picked at random each render. Override per app via the `emptyMessages`
|
||||||
|
* option so localization stays out of this package.
|
||||||
|
*/
|
||||||
|
const EMPTY_MESSAGES = [
|
||||||
|
'Be the first to enter the leader board!',
|
||||||
|
'No scores yet — claim the top spot!',
|
||||||
|
'This board is wide open. Conquer it!',
|
||||||
|
'No champions here yet. Will it be you?',
|
||||||
|
'Blank slate — set the score to beat!',
|
||||||
|
'Nobody\'s here yet. Be the first!',
|
||||||
|
'The top spot is up for grabs. Take it!',
|
||||||
|
'Empty board. Time to make your mark!'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only leaderboard view: windows, sorting (via the adapter), and rendering.
|
||||||
|
* Nothing here writes; the ranked value is a plain `score` displayed through an
|
||||||
|
* injected formatter, and all query I/O is delegated to an injected adapter's
|
||||||
|
* `listScores`. Safe to wire to a read-only / less-privileged backend instance.
|
||||||
|
*/
|
||||||
|
export class LeaderBoardReader {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {Object} options.adapter - storage backend; the READ side uses `listScores`
|
||||||
|
* @param {'asc'|'desc'} [options.scoreOrder] - 'asc' = lower is better (e.g. time), 'desc' = higher is better
|
||||||
|
* @param {(value: number) => string} [options.formatScore] - display formatter for a score
|
||||||
|
* @param {Object} [options.labels] - optional tab-label overrides keyed by duration id
|
||||||
|
* @param {Object} [options.tooltips] - optional tab hover-text overrides keyed by duration id
|
||||||
|
* @param {string[]} [options.emptyMessages] - empty-state messages (one picked at random); localize here
|
||||||
|
* @param {string} [options.loadingText] - shown while a window loads
|
||||||
|
* @param {string} [options.errorText] - shown when a window fails to load
|
||||||
|
* @param {string} [options.anonymousName] - fallback display name for entries without one
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.adapter = options.adapter
|
||||||
|
this.scoreOrder = options.scoreOrder === 'desc' ? 'desc' : 'asc'
|
||||||
|
this.formatScore = options.formatScore || (value => String(value))
|
||||||
|
this.labels = options.labels || {}
|
||||||
|
this.tooltips = options.tooltips || {}
|
||||||
|
|
||||||
|
// User-facing strings — override to localize; the package ships English defaults.
|
||||||
|
this.emptyMessages = (Array.isArray(options.emptyMessages) && options.emptyMessages.length)
|
||||||
|
? options.emptyMessages
|
||||||
|
: EMPTY_MESSAGES
|
||||||
|
this.loadingText = options.loadingText || 'Loading…'
|
||||||
|
this.errorText = options.errorText || 'Leaderboard unavailable right now.'
|
||||||
|
this.anonymousName = options.anonymousName || 'Anonymous'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display label for a duration window (override-aware).
|
||||||
|
* @param {{ id: String, label: String }} duration - a DURATIONS entry
|
||||||
|
*/
|
||||||
|
label(duration) {
|
||||||
|
return this.labels[duration.id] || duration.label
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hover tooltip for a duration window (override-aware).
|
||||||
|
* @param {{ id: String, title: String }} duration - a DURATIONS entry
|
||||||
|
*/
|
||||||
|
tooltip(duration) {
|
||||||
|
return this.tooltips[duration.id] || duration.title
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One empty-state message, picked at random. */
|
||||||
|
emptyMessage() {
|
||||||
|
return this.emptyMessages[Math.floor(Math.random() * this.emptyMessages.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data-level query: the ranked entries for a category and duration window,
|
||||||
|
* without any DOM. View layers that render themselves (e.g. the
|
||||||
|
* `<cozy-leaderboard>` element) use this instead of {@link render}.
|
||||||
|
* @param {String} category
|
||||||
|
* @param {String} durationId - a DURATIONS id ('today' | 'week' | 'month' | 'all')
|
||||||
|
* @returns {Promise<Object[]>}
|
||||||
|
*/
|
||||||
|
async list(category, durationId) {
|
||||||
|
const duration = DURATIONS.find(d => d.id === durationId)
|
||||||
|
return this.adapter.listScores(this._descriptor(category, duration))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend-neutral query descriptor for a category and time window. `since` is
|
||||||
|
* the rolling cutoff (entries with `time_stamp >= since`); `null` means
|
||||||
|
* all-time (no time filter). The adapter turns this into a real query.
|
||||||
|
*/
|
||||||
|
_descriptor(category, duration) {
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
since: duration.ms ? new Date(Date.now() - duration.ms) : null,
|
||||||
|
order: this.scoreOrder,
|
||||||
|
limit: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the leaderboard for a category with a duration tab bar. When
|
||||||
|
* `duration` is omitted the last-selected tab is reused (so switching game
|
||||||
|
* category keeps the player on the same window), defaulting to "today".
|
||||||
|
* Returns the wrapper element; tab clicks re-query in place.
|
||||||
|
* @param {String} category
|
||||||
|
* @param {String} title
|
||||||
|
* @param {String} [duration]
|
||||||
|
* @returns {Promise<HTMLDivElement>}
|
||||||
|
*/
|
||||||
|
async render(category, title, duration) {
|
||||||
|
this.category = category
|
||||||
|
this.title = title
|
||||||
|
if (duration) this.duration = duration
|
||||||
|
if (!this.duration) this.duration = 'today'
|
||||||
|
duration = this.duration
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div')
|
||||||
|
wrapper.style.maxWidth = '270px'
|
||||||
|
wrapper.style.margin = '0 auto'
|
||||||
|
|
||||||
|
const heading = document.createElement('h3')
|
||||||
|
heading.textContent = title
|
||||||
|
heading.style.borderBottom = '1px solid #c0c0c0'
|
||||||
|
heading.style.paddingBottom = '10px'
|
||||||
|
wrapper.append(heading)
|
||||||
|
|
||||||
|
const tabBar = document.createElement('div')
|
||||||
|
tabBar.style.display = 'flex'
|
||||||
|
tabBar.style.justifyContent = 'center'
|
||||||
|
tabBar.style.gap = '8px'
|
||||||
|
tabBar.style.marginBottom = '10px'
|
||||||
|
wrapper.append(tabBar)
|
||||||
|
|
||||||
|
const listWrapper = document.createElement('div')
|
||||||
|
wrapper.append(listWrapper)
|
||||||
|
|
||||||
|
const tabs = {}
|
||||||
|
const activate = (id) => {
|
||||||
|
this.duration = id
|
||||||
|
Object.entries(tabs).forEach(([tabId, el]) => this._styleTab(el, tabId === id))
|
||||||
|
this._loadList(listWrapper, category, DURATIONS.find(d => d.id === id))
|
||||||
|
}
|
||||||
|
|
||||||
|
DURATIONS.forEach(d => {
|
||||||
|
const tab = document.createElement('button')
|
||||||
|
tab.textContent = this.label(d)
|
||||||
|
tab.type = 'button'
|
||||||
|
tab.setAttribute('title', this.tooltip(d))
|
||||||
|
this._styleTab(tab, d.id === duration)
|
||||||
|
tab.onclick = () => activate(d.id)
|
||||||
|
tabs[d.id] = tab
|
||||||
|
tabBar.append(tab)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return the wrapper (heading + tabs) right away and fill the list
|
||||||
|
// asynchronously, so a slow or failing query never blocks the UI.
|
||||||
|
this._loadList(listWrapper, category, DURATIONS.find(d => d.id === duration))
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
_styleTab(tab, active) {
|
||||||
|
tab.style.background = 'none'
|
||||||
|
tab.style.border = 'none'
|
||||||
|
tab.style.cursor = 'pointer'
|
||||||
|
tab.style.padding = '2px 4px'
|
||||||
|
tab.style.fontSize = '0.85em'
|
||||||
|
tab.style.color = active ? '#ffffff' : '#999999'
|
||||||
|
tab.style.fontWeight = active ? 'bold' : 'normal'
|
||||||
|
tab.style.borderBottom = active ? '2px solid orange' : '2px solid transparent'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a window's entries into the list area, showing a loading placeholder
|
||||||
|
* and turning any failure (e.g. the backend being unreachable) into a
|
||||||
|
* message instead of an unhandled rejection. Guards against races when the
|
||||||
|
* player switches tabs quickly by tagging the in-flight request.
|
||||||
|
*/
|
||||||
|
async _loadList(listWrapper, category, duration) {
|
||||||
|
const token = (this._loadToken || 0) + 1
|
||||||
|
this._loadToken = token
|
||||||
|
|
||||||
|
listWrapper.innerHTML = ''
|
||||||
|
const loading = document.createElement('em')
|
||||||
|
loading.textContent = this.loadingText
|
||||||
|
listWrapper.append(loading)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await this.adapter.listScores(this._descriptor(category, duration))
|
||||||
|
if (this._loadToken !== token) return
|
||||||
|
this._renderList(listWrapper, rows)
|
||||||
|
} catch {
|
||||||
|
if (this._loadToken !== token) return
|
||||||
|
listWrapper.innerHTML = ''
|
||||||
|
const message = document.createElement('em')
|
||||||
|
message.textContent = this.errorText
|
||||||
|
listWrapper.append(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderList(listWrapper, rows) {
|
||||||
|
listWrapper.innerHTML = ''
|
||||||
|
|
||||||
|
if (!rows || !rows.length) {
|
||||||
|
const message = document.createElement('em')
|
||||||
|
message.textContent = this.emptyMessage()
|
||||||
|
listWrapper.append(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = document.createElement('div')
|
||||||
|
list.style.listStyle = 'none'
|
||||||
|
list.style.textAlign = 'left'
|
||||||
|
|
||||||
|
let i = 1
|
||||||
|
rows.forEach(data => {
|
||||||
|
const item = document.createElement('div')
|
||||||
|
item.style.display = 'flex'
|
||||||
|
|
||||||
|
const indexElement = document.createElement('div')
|
||||||
|
indexElement.textContent = `#${i++}`
|
||||||
|
|
||||||
|
const nameElement = document.createElement('div')
|
||||||
|
const name = data.name || this.anonymousName
|
||||||
|
// textContent, never innerHTML: names are player-controlled input.
|
||||||
|
nameElement.textContent = name
|
||||||
|
nameElement.setAttribute('title', name)
|
||||||
|
nameElement.style.textOverflow = 'ellipsis'
|
||||||
|
nameElement.style.whiteSpace = 'nowrap'
|
||||||
|
nameElement.style.overflow = 'hidden'
|
||||||
|
nameElement.style.padding = '0 5px'
|
||||||
|
nameElement.style.fontWeight = 'bold'
|
||||||
|
nameElement.style.fontStyle = 'italic'
|
||||||
|
nameElement.style.flex = '1'
|
||||||
|
|
||||||
|
const scoreElement = document.createElement('div')
|
||||||
|
scoreElement.textContent = this.formatScore(data.score)
|
||||||
|
|
||||||
|
item.append(indexElement, nameElement, scoreElement)
|
||||||
|
list.append(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
listWrapper.append(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
packages/leaderboard/leaderboard-write.js
Normal file
80
packages/leaderboard/leaderboard-write.js
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { buckets } from '@cozy-games/utils/date-bucket/date-bucket.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WRITE surface of the leaderboard: submitting a completed game — the
|
||||||
|
* personal archive plus, if it qualifies, a ranked entry with denormalized
|
||||||
|
* day/week/month bucket keys. Importable WITHOUT any read/render code — no DOM,
|
||||||
|
* no list rendering, no `listScores` — so a consumer can wire writes to a
|
||||||
|
* separate, more-privileged backend instance (leaderboard-01) and pull in none
|
||||||
|
* of the read surface.
|
||||||
|
*
|
||||||
|
* The WRITE side calls `adapter.archive` (optional) and `adapter.addScore`; it
|
||||||
|
* also reads the ranking `config` once (an adapter call, not read/render code)
|
||||||
|
* to power the default qualifier.
|
||||||
|
*/
|
||||||
|
export class LeaderBoardWriter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {Object} options.adapter - storage backend; the WRITE side uses `addScore`, optional `archive`, and `getConfig`
|
||||||
|
* @param {(entry: Object) => boolean} [options.qualifies] - whether an entry is ranked; defaults to server passingStatus vs entry.status
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.adapter = options.adapter
|
||||||
|
this.qualifies = options.qualifies || (entry => this._defaultQualifies(entry))
|
||||||
|
|
||||||
|
// The default qualifier depends on the server's ranking config, so load it
|
||||||
|
// once. This is a config READ via the adapter — it pulls in no read/render
|
||||||
|
// module, keeping the write surface importable on its own.
|
||||||
|
Promise.resolve(this.adapter && this.adapter.getConfig ? this.adapter.getConfig() : undefined)
|
||||||
|
.then(config => { this.configuration = config })
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default ranking gate: if the server config names a `passingStatus`, only
|
||||||
|
* entries whose `status` matches qualify; otherwise every entry qualifies.
|
||||||
|
*/
|
||||||
|
_defaultQualifies(entry) {
|
||||||
|
const passing = this.configuration && this.configuration.passingStatus
|
||||||
|
if (!passing) return true
|
||||||
|
return entry.status === passing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a completed game. Always archives it (personal history); if it
|
||||||
|
* qualifies, also writes a ranked entry with denormalized bucket keys. Both
|
||||||
|
* writes go through the adapter, so the storage backend is pluggable. The
|
||||||
|
* caller owns display-name/nickname UX.
|
||||||
|
* @param {Object} entry - { name, playerId, score, category, time_stamp, status?, meta? }
|
||||||
|
*/
|
||||||
|
async submit(entry) {
|
||||||
|
if (this.adapter.archive) {
|
||||||
|
await this.adapter.archive({
|
||||||
|
playerId: entry.playerId,
|
||||||
|
score: entry.score,
|
||||||
|
category: entry.category,
|
||||||
|
time_stamp: entry.time_stamp,
|
||||||
|
meta: entry.meta
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.qualifies(entry)) return
|
||||||
|
|
||||||
|
const stamp = entry.time_stamp instanceof Date ? entry.time_stamp : new Date(entry.time_stamp)
|
||||||
|
const bucket = buckets(stamp)
|
||||||
|
const scoreDoc = {
|
||||||
|
name: entry.name || 'Anonymous',
|
||||||
|
playerId: entry.playerId,
|
||||||
|
score: entry.score,
|
||||||
|
category: entry.category,
|
||||||
|
time_stamp: entry.time_stamp,
|
||||||
|
day: bucket.day,
|
||||||
|
week: bucket.week,
|
||||||
|
month: bucket.month
|
||||||
|
}
|
||||||
|
if (entry.meta) scoreDoc.meta = entry.meta
|
||||||
|
|
||||||
|
await this.adapter.addScore(entry.category, scoreDoc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
"url": "https://github.com/ayo-run/mnswpr"
|
"url": "https://github.com/ayo-run/mnswpr"
|
||||||
},
|
},
|
||||||
"main": "leader-board.js",
|
"main": "leader-board.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"default": "./dist/leader-board.js"
|
"default": "./dist/leader-board.js"
|
||||||
|
|
@ -25,7 +28,7 @@
|
||||||
"./dist"
|
"./dist"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"web-component-base": "^4.1.2"
|
"web-component-base": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cozy-games/utils": "workspace:*"
|
"@cozy-games/utils": "workspace:*"
|
||||||
|
|
|
||||||
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:
|
Now that the JS project is initialized and we have a development environment with `vite`, we will install **mnswpr** as a dependency:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm i @ayo-run/mnswpr
|
npm i @cozy-games/mnswpr
|
||||||
```
|
```
|
||||||
|
|
||||||
Finally, you can run the installed `vite` dev server by running the following:
|
Finally, you can run the installed `vite` dev server by running the following:
|
||||||
|
|
@ -141,8 +141,8 @@ Create a new file named `main.js` with the following content:
|
||||||
/**
|
/**
|
||||||
* main.js
|
* main.js
|
||||||
*/
|
*/
|
||||||
import '@ayo-run/mnswpr/mnswpr.css'
|
import '@cozy-games/mnswpr/mnswpr.css'
|
||||||
import mnswpr from '@ayo-run/mnswpr'
|
import mnswpr from '@cozy-games/mnswpr'
|
||||||
|
|
||||||
const game = new mnswpr('app')
|
const game = new mnswpr('app')
|
||||||
game.initialize()
|
game.initialize()
|
||||||
|
|
|
||||||
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 './mnswpr.css'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LoggerService,
|
|
||||||
StorageService,
|
StorageService,
|
||||||
TimerService
|
TimerService
|
||||||
} from '@cozy-games/utils'
|
} from '@cozy-games/utils'
|
||||||
import { levels } from './levels.js'
|
import { levels } from './levels.js'
|
||||||
|
import { MinesweeperRules } from './core/index.js'
|
||||||
|
import { LocalTransport } from './client/transport.js'
|
||||||
|
import { renderEvents, revealBoard } from './client/renderer.js'
|
||||||
|
|
||||||
const TEST_MODE = false // set to true if you want to test the game with visual hints
|
const TEST_MODE = false // set to true if you want to test the game with visual hints
|
||||||
const MOBILE_BUSY_DELAY = 250
|
const MOBILE_BUSY_DELAY = 250
|
||||||
const PC_BUSY_DELAY = 500
|
const PC_BUSY_DELAY = 500
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create Minesweeper game board
|
* Create Minesweeper game board.
|
||||||
|
*
|
||||||
|
* This is the DOM CLIENT: it builds the board, handles input, and renders. All
|
||||||
|
* game state and rules live in the headless core (`./core`); the client drives
|
||||||
|
* it through a Transport and paints the events it emits (see
|
||||||
|
* docs/headless-core-and-client-design.md).
|
||||||
|
*
|
||||||
* @param {String} appId
|
* @param {String} appId
|
||||||
* @param {String} version
|
* @param {String} version
|
||||||
* @param {{
|
* @param {{
|
||||||
* levelChanged: (setting: any) => void,
|
* levelChanged: (setting: any) => void,
|
||||||
* gameDone: (game: any) => void
|
* gameDone: (game: any) => void
|
||||||
* } | undefined } hooks
|
* } | undefined } hooks
|
||||||
|
* @param {{ seed?: number }} [options] - `seed` pins the (deterministic) board,
|
||||||
|
* mainly for tests/replay; omit for a fresh random game each time.
|
||||||
*/
|
*/
|
||||||
const Minesweeper = function(appId, version, hooks = undefined) {
|
const Minesweeper = function(appId, version, hooks = undefined, options = {}) {
|
||||||
const _this = this
|
const _this = this
|
||||||
const storageService = new StorageService()
|
const storageService = new StorageService()
|
||||||
const timerService = new TimerService()
|
const timerService = new TimerService()
|
||||||
const loggerService = new LoggerService()
|
|
||||||
|
|
||||||
if (!hooks) {
|
if (!hooks) {
|
||||||
hooks = {
|
hooks = {
|
||||||
|
|
@ -38,6 +47,8 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configuredSeed = options.seed
|
||||||
|
|
||||||
let grid = document.createElement('table')
|
let grid = document.createElement('table')
|
||||||
grid.setAttribute('id', 'grid')
|
grid.setAttribute('id', 'grid')
|
||||||
let flagsDisplay = document.createElement('span')
|
let flagsDisplay = document.createElement('span')
|
||||||
|
|
@ -66,7 +77,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
highlightSurroundingCell, // middle-click down
|
highlightSurroundingCell, // middle-click down
|
||||||
rightClickCell // right-click down
|
rightClickCell // right-click down
|
||||||
]
|
]
|
||||||
let firstClick = true
|
|
||||||
let isBusy = false
|
let isBusy = false
|
||||||
let clickedCell
|
let clickedCell
|
||||||
let cachedSetting = storageService.getFromLocal('setting')
|
let cachedSetting = storageService.getFromLocal('setting')
|
||||||
|
|
@ -82,12 +92,10 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
}
|
}
|
||||||
storageService.saveToLocal('setting', setting)
|
storageService.saveToLocal('setting', setting)
|
||||||
let flagsCount = setting.mines
|
let flagsCount = setting.mines
|
||||||
// Mine positions stored as a Set of numeric keys (row * cols + col) for O(1) lookup
|
|
||||||
let mines = new Set()
|
|
||||||
// Cells currently highlighted, so removeHighlights only resets these (<=9) instead of the whole grid
|
// Cells currently highlighted, so removeHighlights only resets these (<=9) instead of the whole grid
|
||||||
let highlightedCells = []
|
let highlightedCells = []
|
||||||
// Count of safe (non-mine) cells revealed, so the win check is O(1) instead of scanning the whole grid
|
// The headless game for the current board; recreated on every generateGrid.
|
||||||
let revealedSafeCount = 0
|
let transport
|
||||||
|
|
||||||
this.initialize = function() {
|
this.initialize = function() {
|
||||||
const headingElement = document.createElement('h1')
|
const headingElement = document.createElement('h1')
|
||||||
|
|
@ -129,8 +137,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
levelsDropdown.add(levelOption, null)
|
levelsDropdown.add(levelOption, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (TEST_MODE) {
|
if (TEST_MODE) {
|
||||||
const testLevel = document.createElement('span')
|
const testLevel = document.createElement('span')
|
||||||
testLevel.innerText = 'Test Mode'
|
testLevel.innerText = 'Test Mode'
|
||||||
|
|
@ -144,24 +150,20 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
|
|
||||||
function initializeToolbar() {
|
function initializeToolbar() {
|
||||||
const toolbar = document.createElement('div')
|
const toolbar = document.createElement('div')
|
||||||
const toolbarItems = []
|
|
||||||
|
|
||||||
const flagsWrapper = document.createElement('div')
|
const flagsWrapper = document.createElement('div')
|
||||||
flagsWrapper.append(flagsDisplay)
|
flagsWrapper.append(flagsDisplay)
|
||||||
flagsWrapper.style.height = '20px'
|
flagsWrapper.style.height = '20px'
|
||||||
toolbar.append(flagsWrapper)
|
toolbar.append(flagsWrapper)
|
||||||
toolbarItems.push(flagsWrapper)
|
|
||||||
|
|
||||||
const smileyWrapper = document.createElement('div')
|
const smileyWrapper = document.createElement('div')
|
||||||
smileyWrapper.append(smileyDisplay)
|
smileyWrapper.append(smileyDisplay)
|
||||||
// toolbar.append(smileyWrapper);
|
// toolbar.append(smileyWrapper);
|
||||||
// toolbarItems.push(smileyWrapper);
|
|
||||||
|
|
||||||
const timerWrapper = document.createElement('div')
|
const timerWrapper = document.createElement('div')
|
||||||
timerWrapper.append(timerDisplay)
|
timerWrapper.append(timerDisplay)
|
||||||
timerWrapper.style.height = '20px'
|
timerWrapper.style.height = '20px'
|
||||||
toolbar.append(timerWrapper)
|
toolbar.append(timerWrapper)
|
||||||
toolbarItems.push(timerWrapper)
|
|
||||||
|
|
||||||
toolbar.style.cursor = 'pointer'
|
toolbar.style.cursor = 'pointer'
|
||||||
toolbar.style.padding = '10px 35px'
|
toolbar.style.padding = '10px 35px'
|
||||||
|
|
@ -182,7 +184,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
generateGrid({ initial: true })
|
generateGrid({ initial: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the Game Board
|
* Generate the Game Board
|
||||||
* @param {{
|
* @param {{
|
||||||
|
|
@ -190,13 +191,10 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
* }} options - Game Board Options
|
* }} options - Game Board Options
|
||||||
*/
|
*/
|
||||||
function generateGrid(options = { initial: false }) {
|
function generateGrid(options = { initial: false }) {
|
||||||
firstClick = true
|
|
||||||
grid.innerHTML = ''
|
grid.innerHTML = ''
|
||||||
grid.oncontextmenu = () => false
|
grid.oncontextmenu = () => false
|
||||||
flagsCount = setting.mines
|
flagsCount = setting.mines
|
||||||
mines.clear()
|
|
||||||
highlightedCells = []
|
highlightedCells = []
|
||||||
revealedSafeCount = 0
|
|
||||||
|
|
||||||
for (let i = 0; i < setting.rows; i++) {
|
for (let i = 0; i < setting.rows; i++) {
|
||||||
let row = grid.insertRow(i)
|
let row = grid.insertRow(i)
|
||||||
|
|
@ -224,6 +222,12 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
appElement.style.margin = '0 auto'
|
appElement.style.margin = '0 auto'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A fresh headless game. Mines are placed by the core on the first reveal
|
||||||
|
// (first-click safe), so there is nothing to seed into the DOM here.
|
||||||
|
const gameSeed = configuredSeed !== undefined ? configuredSeed : Math.floor(Math.random() * 0x7fffffff)
|
||||||
|
transport = new LocalTransport(MinesweeperRules, { seed: gameSeed, config: setting, clock: () => Date.now() })
|
||||||
|
transport.onEvent(onCoreEvent)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: add hook afterGridGenerated
|
* TODO: add hook afterGridGenerated
|
||||||
* - for initializing the leaderboard
|
* - for initializing the leaderboard
|
||||||
|
|
@ -233,8 +237,51 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
|
|
||||||
timerService.initialize(timerDisplay)
|
timerService.initialize(timerDisplay)
|
||||||
updateFlagsCountDisplay()
|
updateFlagsCountDisplay()
|
||||||
addMines(setting.mines)
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paint the events from a core move, and drive the win/loss transition. Fired
|
||||||
|
* synchronously by the LocalTransport; a RemoteTransport would fire it on the
|
||||||
|
* server's reply with no change here.
|
||||||
|
* @param {{ events: object[], view: object }} payload
|
||||||
|
*/
|
||||||
|
function onCoreEvent(payload) {
|
||||||
|
renderEvents(grid, payload.events)
|
||||||
|
for (const ev of payload.events) {
|
||||||
|
if (ev.type === 'explode' || ev.type === 'win') {
|
||||||
|
finishGame(payload.view)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Terminal transition: reveal the board, update displays, fire gameDone. */
|
||||||
|
function finishGame(view) {
|
||||||
|
const won = view.phase === 'won'
|
||||||
|
if (won) {
|
||||||
|
grid.setAttribute('game-status', 'win')
|
||||||
|
updateFlagsCountDisplay(0)
|
||||||
|
} else {
|
||||||
|
flagsDisplay.innerHTML = '😱'
|
||||||
|
grid.setAttribute('game-status', 'over')
|
||||||
|
}
|
||||||
|
revealBoard(grid, view, setting)
|
||||||
|
grid.setAttribute('game-status', 'done')
|
||||||
|
|
||||||
|
const time = timerService.stop()
|
||||||
|
const game = {
|
||||||
|
time,
|
||||||
|
status: won ? 'win' : 'loss',
|
||||||
|
level: setting.id,
|
||||||
|
time_stamp: new Date(),
|
||||||
|
isMobile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: add hook after gameSession send back `game`
|
||||||
|
* - for sending the game score to the db
|
||||||
|
*/
|
||||||
|
hooks.gameDone(game)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBusy() {
|
function setBusy() {
|
||||||
|
|
@ -403,11 +450,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
cell.onmousemove = function(e) {
|
cell.onmousemove = function(e) {
|
||||||
if ((pressed || bothPressed) && typeof e === 'object') {
|
if ((pressed || bothPressed) && typeof e === 'object') {
|
||||||
removeHighlights()
|
removeHighlights()
|
||||||
/*
|
|
||||||
if (!isEqual(clickedCell, cell)) {
|
|
||||||
clickedCell = undefined;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
if (pressed == 'middle' || (isLeft && isRight)) {
|
if (pressed == 'middle' || (isLeft && isRight)) {
|
||||||
highlightSurroundingCell(this)
|
highlightSurroundingCell(this)
|
||||||
} else if (pressed == 'left') {
|
} else if (pressed == 'left') {
|
||||||
|
|
@ -456,187 +498,6 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
skip = true
|
skip = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMines(minesCount) {
|
|
||||||
//Add mines randomly
|
|
||||||
for (let i=0; i<minesCount; i++) {
|
|
||||||
let row = Math.floor(Math.random() * setting.rows)
|
|
||||||
let col = Math.floor(Math.random() * setting.cols)
|
|
||||||
let cell = grid.rows[row].cells[col]
|
|
||||||
if (isMine(cell)) {
|
|
||||||
transferMine()
|
|
||||||
} else {
|
|
||||||
mines.add(mineKey(row, col))
|
|
||||||
}
|
|
||||||
if (TEST_MODE){
|
|
||||||
cell.innerHTML = 'X'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (TEST_MODE) {
|
|
||||||
printMines()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function revealMines() {
|
|
||||||
if (grid.getAttribute('game-status') == 'done') return
|
|
||||||
//Highlight all mines in red
|
|
||||||
const win = grid.getAttribute('game-status') == 'win'
|
|
||||||
for (let i=0; i<setting.rows; i++) {
|
|
||||||
for(let j=0; j<setting.cols; j++) {
|
|
||||||
let cell = grid.rows[i].cells[j]
|
|
||||||
if (win) {
|
|
||||||
handleWinRevelation(cell)
|
|
||||||
} else {
|
|
||||||
handleLostRevelation(cell)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
grid.setAttribute('game-status', 'done')
|
|
||||||
|
|
||||||
const time = timerService.stop()
|
|
||||||
const game = {
|
|
||||||
time,
|
|
||||||
status: win ? 'win' : 'loss',
|
|
||||||
level: setting.id,
|
|
||||||
time_stamp: new Date(),
|
|
||||||
isMobile
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO: add hook after gameSession send back `game`
|
|
||||||
* - for sending the game score to the db
|
|
||||||
*/
|
|
||||||
hooks.gameDone(game)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWinRevelation(cell) {
|
|
||||||
updateFlagsCountDisplay(0)
|
|
||||||
if (isMine(cell)) {
|
|
||||||
cell.innerHTML = ':)'
|
|
||||||
cell.className = 'correct'
|
|
||||||
setStatus(cell, 'clicked')
|
|
||||||
let correct = document.createAttribute('title')
|
|
||||||
correct.value = 'Correct'
|
|
||||||
cell.setAttributeNode(correct)
|
|
||||||
setStatus(cell, 'clicked')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLostRevelation(cell) {
|
|
||||||
if (isFlagged(cell)) {
|
|
||||||
cell.className = 'flag'
|
|
||||||
if (!isMine(cell)) {
|
|
||||||
cell.innerHTML = 'X'
|
|
||||||
cell.className = 'wrong'
|
|
||||||
let wrong = document.createAttribute('title')
|
|
||||||
wrong.value = 'Wrong'
|
|
||||||
cell.setAttributeNode(wrong)
|
|
||||||
} else {
|
|
||||||
cell.innerHTML = ':)'
|
|
||||||
cell.className = 'correct'
|
|
||||||
let correct = document.createAttribute('title')
|
|
||||||
correct.value = 'Correct'
|
|
||||||
cell.setAttributeNode(correct)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (isMine(cell)) {
|
|
||||||
cell.className = 'mine'
|
|
||||||
setStatus(cell, 'clicked')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOpen(cell) {
|
|
||||||
return cell.innerHTML !== '' && !isFlagged(cell)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFlagged(cell) {
|
|
||||||
return getStatus(cell) == 'flagged'
|
|
||||||
}
|
|
||||||
|
|
||||||
function mineKey(row, col) {
|
|
||||||
return row * setting.cols + col
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMine(cell) {
|
|
||||||
return mines.has(mineKey(getRow(cell), getCol(cell)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkLevelCompletion() {
|
|
||||||
const safeCells = setting.rows * setting.cols - setting.mines
|
|
||||||
if (revealedSafeCount >= safeCells && grid.getAttribute('game-status') == 'active') {
|
|
||||||
grid.setAttribute('game-status', 'win')
|
|
||||||
revealMines()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(cell, status) {
|
|
||||||
cell.setAttribute('data-status', status)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCol(cell) {
|
|
||||||
return cell.cellIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRow(cell) {
|
|
||||||
return cell.parentNode.rowIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatus(cell) {
|
|
||||||
if (!cell) return undefined
|
|
||||||
return cell.getAttribute('data-status')
|
|
||||||
}
|
|
||||||
|
|
||||||
function middleClickCell(cell) {
|
|
||||||
if (grid.getAttribute('game-status') != 'active' || getStatus(cell) !== 'clicked') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// check for number of surrounding flags
|
|
||||||
const valueString = cell.getAttribute('data-value')
|
|
||||||
let cellValue = parseInt(valueString, 10)
|
|
||||||
let flagCount = countFlagsAround(cell)
|
|
||||||
|
|
||||||
if (flagCount === cellValue) {
|
|
||||||
clickSurrounding(cell)
|
|
||||||
if (TEST_MODE) loggerService.debug('middle click', cell)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function countFlagsAround(cell) {
|
|
||||||
let flagCount = 0
|
|
||||||
let cellRow = cell.parentNode.rowIndex
|
|
||||||
let cellCol = cell.cellIndex
|
|
||||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
|
|
||||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
|
|
||||||
if (isFlagged(grid.rows[i].cells[j])) flagCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flagCount
|
|
||||||
}
|
|
||||||
|
|
||||||
function clickSurrounding(cell) {
|
|
||||||
if (grid.getAttribute('game-status') != 'active') return
|
|
||||||
let cellRow = cell.parentNode.rowIndex
|
|
||||||
let cellCol = cell.cellIndex
|
|
||||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
|
|
||||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
|
|
||||||
let currentCell = grid.rows[i].cells[j]
|
|
||||||
if (getStatus(currentCell) == 'flagged') continue
|
|
||||||
openCell(currentCell)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function increaseFlagsCount() {
|
|
||||||
flagsCount++
|
|
||||||
updateFlagsCountDisplay()
|
|
||||||
}
|
|
||||||
|
|
||||||
function decreaseFlagsCount() {
|
|
||||||
flagsCount--
|
|
||||||
updateFlagsCountDisplay()
|
|
||||||
}
|
|
||||||
|
|
||||||
function activateGame() {
|
function activateGame() {
|
||||||
grid.setAttribute('game-status', 'active')
|
grid.setAttribute('game-status', 'active')
|
||||||
// start timer
|
// start timer
|
||||||
|
|
@ -659,7 +520,7 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
function highlightCell(cell) {
|
function highlightCell(cell) {
|
||||||
if (isFlagged(cell)) return
|
if (isFlagged(cell)) return
|
||||||
if (!gameIsDone() && getStatus(cell) == 'default') {
|
if (!gameIsDone() && getStatus(cell) == 'default') {
|
||||||
setStatus(cell, 'highlighted') // currentCell.classList.add('highlight');
|
setStatus(cell, 'highlighted')
|
||||||
highlightedCells.push(cell)
|
highlightedCells.push(cell)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -677,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) {
|
function rightClickCell(cell) {
|
||||||
if (isFlagged(cell)) setBusy()
|
if (isFlagged(cell)) setBusy()
|
||||||
if (grid.getAttribute('game-status') == 'inactive') {
|
if (grid.getAttribute('game-status') == 'inactive') {
|
||||||
activateGame()
|
activateGame()
|
||||||
}
|
}
|
||||||
if (grid.getAttribute('game-status') != 'active') return
|
if (grid.getAttribute('game-status') != 'active') return
|
||||||
if (getStatus(cell) != 'clicked' && getStatus(cell) != 'empty') {
|
|
||||||
if (getStatus(cell) == 'default' || getStatus(cell) == 'highlighted') {
|
const status = getStatus(cell)
|
||||||
if (flagsCount <= 0) return
|
if (status == 'clicked' || status == 'empty') return
|
||||||
cell.className = 'flag'
|
|
||||||
decreaseFlagsCount()
|
const move = { type: 'flag', r: getRow(cell), c: getCol(cell) }
|
||||||
setStatus(cell, 'flagged')
|
if (status == 'default' || status == 'highlighted') {
|
||||||
} else {
|
if (flagsCount <= 0) return
|
||||||
cell.className = ''
|
transport.send(move) // renderer paints the flag
|
||||||
increaseFlagsCount()
|
decreaseFlagsCount()
|
||||||
setStatus(cell, 'default')
|
} else {
|
||||||
}
|
transport.send(move) // toggles the flag off
|
||||||
if ('vibrate' in navigator) {
|
increaseFlagsCount()
|
||||||
navigator.vibrate(100)
|
}
|
||||||
}
|
if ('vibrate' in navigator) {
|
||||||
if (TEST_MODE) loggerService.debug('right click', cell)
|
navigator.vibrate(100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -707,164 +601,26 @@ const Minesweeper = function(appId, version, hooks = undefined) {
|
||||||
activateGame()
|
activateGame()
|
||||||
}
|
}
|
||||||
if (grid.getAttribute('game-status') != 'active') return
|
if (grid.getAttribute('game-status') != 'active') return
|
||||||
//Check if the end-user clicked on a mine
|
if (isFlagged(cell) || grid.getAttribute('game-status') == 'over') {
|
||||||
if (TEST_MODE) loggerService.debug('click', cell)
|
|
||||||
if (getStatus(cell) == 'flagged' || grid.getAttribute('game-status') == 'over') {
|
|
||||||
return
|
|
||||||
} else if (getStatus(cell) == 'clicked') {
|
|
||||||
middleClickCell(cell)
|
|
||||||
return
|
|
||||||
} else if (isMine(cell) && firstClick) {
|
|
||||||
// cell.setAttribute('data-mine', 'false');
|
|
||||||
mines.delete(mineKey(getRow(cell), getCol(cell)))
|
|
||||||
transferMine(cell)
|
|
||||||
if (TEST_MODE) printMines()
|
|
||||||
}
|
|
||||||
|
|
||||||
openCell(cell)
|
|
||||||
}
|
|
||||||
|
|
||||||
function printMines() {
|
|
||||||
let count = 0
|
|
||||||
for (let i = 0; i < setting.rows; i++) {
|
|
||||||
for (let j = 0; j < setting.cols; j++) {
|
|
||||||
if (isMine(grid.rows[i].cells[j])) {
|
|
||||||
loggerService.debug(count++ + ' - mine: [' + i + ',' + j + ']')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function transferMine(cell = undefined) {
|
|
||||||
let found = false
|
|
||||||
do {
|
|
||||||
let row = Math.floor(Math.random() * setting.rows)
|
|
||||||
let col = Math.floor(Math.random() * setting.cols)
|
|
||||||
const transferMineToCell = grid.rows[row].cells[col]
|
|
||||||
if (isMine(transferMineToCell) || transferMineToCell === cell || isNeighbor(cell, transferMineToCell)) {
|
|
||||||
continue
|
|
||||||
} else {
|
|
||||||
mines.add(mineKey(row, col))
|
|
||||||
if (TEST_MODE){
|
|
||||||
transferMineToCell.innerHTML = 'X'
|
|
||||||
if (TEST_MODE) loggerService.debug('transferred mine to: ' + row + ', ' + col)
|
|
||||||
}
|
|
||||||
// TODO: refactor maybe
|
|
||||||
// eslint-disable-next-line no-useless-assignment
|
|
||||||
found = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} while(!found)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNeighbor(cell, nextCell) {
|
|
||||||
if (cell === undefined) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const rowDifference = Math.abs(getRow(cell) - getRow(nextCell))
|
|
||||||
const colDifference = Math.abs(getCol(cell) - getCol(nextCell))
|
|
||||||
|
|
||||||
return (rowDifference === 1) && (colDifference === 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function countMinesAround(cell) {
|
|
||||||
let mineCount=0
|
|
||||||
let cellRow = cell.parentNode.rowIndex
|
|
||||||
let cellCol = cell.cellIndex
|
|
||||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1,setting.rows-1); i++) {
|
|
||||||
const rows = grid.rows[i]
|
|
||||||
if (!rows) continue
|
|
||||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1,setting.cols-1); j++) {
|
|
||||||
const cell = rows.cells[j]
|
|
||||||
const mine = isMine(cell)
|
|
||||||
if (cell && mine) {
|
|
||||||
mineCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mineCount
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCellValue(cell, value) {
|
|
||||||
const spanElement = document.createElement('span')
|
|
||||||
spanElement.innerHTML = value
|
|
||||||
cell.innerHTML = ''
|
|
||||||
cell.appendChild(spanElement)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reveal a single known-non-mine cell. Returns true when the cell is blank
|
|
||||||
* (no adjacent mines), which is the signal to keep flooding from it.
|
|
||||||
* @param {HTMLTableCellElement} cell
|
|
||||||
*/
|
|
||||||
function revealSafeCell(cell) {
|
|
||||||
// A cell can be re-opened via chording, so only count the first reveal
|
|
||||||
const wasOpen = getStatus(cell) == 'clicked' || getStatus(cell) == 'empty'
|
|
||||||
cell.className = 'clicked'
|
|
||||||
setStatus(cell, 'clicked')
|
|
||||||
if (!wasOpen) revealedSafeCount++
|
|
||||||
|
|
||||||
const mineCount = countMinesAround(cell)
|
|
||||||
if (mineCount == 0) {
|
|
||||||
updateCellValue(cell, ' ')
|
|
||||||
setStatus(cell, 'empty')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCellValue(cell, mineCount.toString())
|
|
||||||
const dataValue = document.createAttribute('data-value')
|
|
||||||
dataValue.value = mineCount.toString()
|
|
||||||
cell.setAttributeNode(dataValue)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Iteratively reveal the connected region of blank cells, starting from a
|
|
||||||
* cell that has already been revealed as blank. Uses an explicit queue with
|
|
||||||
* isOpen() as the visited guard instead of recursing back through clickCell,
|
|
||||||
* so there are no redundant per-cell checks and no deep call stack.
|
|
||||||
* @param {HTMLTableCellElement} startCell
|
|
||||||
*/
|
|
||||||
function handleEmpty(startCell) {
|
|
||||||
const queue = [startCell]
|
|
||||||
while (queue.length) {
|
|
||||||
const cell = queue.shift()
|
|
||||||
const cellRow = cell.parentNode.rowIndex
|
|
||||||
const cellCol = cell.cellIndex
|
|
||||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
|
|
||||||
const rows = grid.rows[i]
|
|
||||||
if (!rows) continue
|
|
||||||
for (let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
|
|
||||||
const neighbor = rows.cells[j]
|
|
||||||
if (!neighbor || neighbor === cell) continue
|
|
||||||
if (isFlagged(neighbor)) {
|
|
||||||
setBusy()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (isOpen(neighbor)) continue
|
|
||||||
// blank cells have no adjacent mines, so every neighbor here is safe
|
|
||||||
if (revealSafeCell(neighbor)) queue.push(neighbor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCell(cell) {
|
|
||||||
if (grid.getAttribute('game-status') != 'active') return
|
|
||||||
|
|
||||||
firstClick = false
|
|
||||||
|
|
||||||
if (isMine(cell)) {
|
|
||||||
cell.className = 'clicked'
|
|
||||||
setStatus(cell, 'clicked')
|
|
||||||
revealMines()
|
|
||||||
flagsDisplay.innerHTML = '😱'
|
|
||||||
grid.setAttribute('game-status', 'over')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (revealSafeCell(cell)) handleEmpty(cell)
|
const r = getRow(cell)
|
||||||
checkLevelCompletion()
|
const c = getCol(cell)
|
||||||
|
// An already-open number chords; anything else is a reveal. The core places
|
||||||
|
// mines on the first reveal (first-click safe), so no transfer is needed.
|
||||||
|
if (getStatus(cell) == 'clicked') {
|
||||||
|
transport.send({ type: 'chord', r, c })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transport.send({ type: 'reveal', r, c })
|
||||||
|
}
|
||||||
|
|
||||||
|
function middleClickCell(cell) {
|
||||||
|
if (grid.getAttribute('game-status') != 'active' || getStatus(cell) !== 'clicked') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transport.send({ type: 'chord', r: getRow(cell), c: getCol(cell) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "@ayo-run/mnswpr",
|
"name": "@cozy-games/mnswpr",
|
||||||
"version": "0.4.36",
|
"version": "0.4.36",
|
||||||
"description": "Classic Minesweeper browser game",
|
"description": "Classic Minesweeper browser game",
|
||||||
"author": "Ayo",
|
"author": "Ayo",
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://mnswpr.com",
|
"homepage": "https://mnswpr.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
"release": "bumpp && node ../../scripts/release.js"
|
"release": "bumpp && node ../../scripts/release.js"
|
||||||
},
|
},
|
||||||
"main": "mnswpr.js",
|
"main": "mnswpr.js",
|
||||||
|
|
@ -20,6 +21,9 @@
|
||||||
".": {
|
".": {
|
||||||
"default": "./dist/mnswpr.js"
|
"default": "./dist/mnswpr.js"
|
||||||
},
|
},
|
||||||
|
"./core": {
|
||||||
|
"default": "./core/index.js"
|
||||||
|
},
|
||||||
"./dist/*": {
|
"./dist/*": {
|
||||||
"default": "./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,20 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import Minesweeper from '../mnswpr.js'
|
import Minesweeper from '../mnswpr.js'
|
||||||
|
import { MinesweeperRules } from '../core/index.js'
|
||||||
|
|
||||||
|
// Ask the (deterministic) core for a seed whose first-click-safe board places
|
||||||
|
// the single mine at `targetCol`. Lets the DOM tests below pin a layout honestly
|
||||||
|
// via the client's `seed` option, instead of hoping a Math.random value lands it.
|
||||||
|
function findSeedForMine(setting, firstClick, targetCol) {
|
||||||
|
for (let seed = 1; seed < 100000; seed++) {
|
||||||
|
const state = MinesweeperRules.init(seed, setting)
|
||||||
|
MinesweeperRules.apply(state, { type: 'reveal', r: firstClick.r, c: firstClick.c })
|
||||||
|
let mineCol = -1
|
||||||
|
state.grid.forEach((cell, r, c) => { if (cell.mine) mineCol = c })
|
||||||
|
if (mineCol === targetCol) return seed
|
||||||
|
}
|
||||||
|
throw new Error('no seed found for target mine column')
|
||||||
|
}
|
||||||
|
|
||||||
// Build a fresh board mounted on #app and return its grid <table>.
|
// Build a fresh board mounted on #app and return its grid <table>.
|
||||||
function mountGame() {
|
function mountGame() {
|
||||||
|
|
@ -38,11 +53,12 @@ describe('Minesweeper board', () => {
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mount a game on a custom board injected via the cached 'setting' localStorage key.
|
// Mount a game on a custom board injected via the cached 'setting' localStorage
|
||||||
function mountCustomGame(setting, hooks) {
|
// key. `options.seed` pins the (deterministic) mine layout.
|
||||||
|
function mountCustomGame(setting, hooks, options) {
|
||||||
localStorage.setItem('setting', JSON.stringify(setting))
|
localStorage.setItem('setting', JSON.stringify(setting))
|
||||||
document.body.innerHTML = '<div id="app"></div>'
|
document.body.innerHTML = '<div id="app"></div>'
|
||||||
const game = new Minesweeper('app', 'dev', hooks)
|
const game = new Minesweeper('app', 'dev', hooks, options)
|
||||||
game.initialize()
|
game.initialize()
|
||||||
return document.getElementById('grid')
|
return document.getElementById('grid')
|
||||||
}
|
}
|
||||||
|
|
@ -143,25 +159,27 @@ describe('Minesweeper board', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not declare a win until the last safe cell is revealed', () => {
|
it('does not declare a win until the last safe cell is revealed', () => {
|
||||||
// 1x3 board with the single mine pinned to the middle column.
|
// 1x3 board; pin the single mine to the middle column so neither end cascades.
|
||||||
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
const setting = { rows: 1, cols: 3, mines: 1, id: 'test', name: 'test' }
|
||||||
const grid = mountCustomGame({ rows: 1, cols: 3, mines: 1, id: 'test', name: 'test' })
|
const seed = findSeedForMine(setting, { r: 0, c: 0 }, 1)
|
||||||
|
const grid = mountCustomGame(setting, undefined, { seed })
|
||||||
|
|
||||||
// First safe cell: adjacent to the mine, shows "1", no cascade -> not yet won.
|
// First safe cell (col 0): adjacent to the mine, shows "1", no cascade -> not yet won.
|
||||||
leftClick(grid.rows[0].cells[0])
|
leftClick(grid.rows[0].cells[0])
|
||||||
expect(grid.getAttribute('game-status')).toBe('active')
|
expect(grid.getAttribute('game-status')).toBe('active')
|
||||||
|
|
||||||
// Revealing the remaining safe cell completes the board.
|
// Revealing the remaining safe cell (col 2) completes the board.
|
||||||
leftClick(grid.rows[0].cells[2])
|
leftClick(grid.rows[0].cells[2])
|
||||||
expect(grid.getAttribute('game-status')).toBe('done')
|
expect(grid.getAttribute('game-status')).toBe('done')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('flood fill stops at a flagged cell and never reveals it', () => {
|
it('flood fill stops at a flagged cell and never reveals it', () => {
|
||||||
// 1x4 board with the mine pinned to the last column.
|
// 1x4 board; pin the mine to the last column so cols 0-2 are a safe run.
|
||||||
vi.spyOn(Math, 'random').mockReturnValue(0.99)
|
const setting = { rows: 1, cols: 4, mines: 1, id: 'test', name: 'test' }
|
||||||
const grid = mountCustomGame({ rows: 1, cols: 4, mines: 1, id: 'test', name: 'test' })
|
const seed = findSeedForMine(setting, { r: 0, c: 0 }, 3)
|
||||||
|
const grid = mountCustomGame(setting, undefined, { seed })
|
||||||
|
|
||||||
// Flag a safe cell sitting between the blank region and the mine.
|
// Flag a safe cell (col 2) sitting between the blank region and the mine.
|
||||||
// (full press + release so the internal right-button flag resets)
|
// (full press + release so the internal right-button flag resets)
|
||||||
const flag = grid.rows[0].cells[2]
|
const flag = grid.rows[0].cells[2]
|
||||||
flag.dispatchEvent(new MouseEvent('mousedown', { button: 2, bubbles: true }))
|
flag.dispatchEvent(new MouseEvent('mousedown', { button: 2, bubbles: true }))
|
||||||
|
|
|
||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
11464
pnpm-lock.yaml
11464
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -4,5 +4,13 @@ packages:
|
||||||
- "sites/*"
|
- "sites/*"
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@firebase/util': false
|
'@firebase/util': false
|
||||||
|
'@parcel/watcher': false
|
||||||
|
esbuild: false
|
||||||
|
netlify-cli: false
|
||||||
protobufjs: false
|
protobufjs: false
|
||||||
|
re2: false
|
||||||
|
sharp: false
|
||||||
|
unix-dgram: false
|
||||||
web-component-base: false
|
web-component-base: false
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- web-component-base@5.0.0
|
||||||
|
|
|
||||||
99
scripts/ensure-java.mjs
Normal file
99
scripts/ensure-java.mjs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
/**
|
||||||
|
* Post-install convenience: make a Java runtime available for the Firebase
|
||||||
|
* Firestore emulator (used by `pnpm -F mnswpr dev` / `db:start`). The emulator is
|
||||||
|
* a Java program and firebase-tools does not bundle a JRE.
|
||||||
|
*
|
||||||
|
* Installs a Temurin JRE 21 into the user's home (~/.local) WITHOUT sudo and
|
||||||
|
* symlinks `java` into ~/.local/bin (already on most PATHs). Properties:
|
||||||
|
* - idempotent: does nothing if `java` is already on PATH
|
||||||
|
* - non-fatal: never fails `pnpm install` — on any problem it warns, exits 0
|
||||||
|
* - opt-out: set SKIP_JRE_SETUP=1 (also auto-skips when CI is set)
|
||||||
|
* Only Linux/macOS on x64/arm64 are auto-handled; anything else prints manual
|
||||||
|
* instructions (see apps/mnswpr/README.md).
|
||||||
|
*/
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { existsSync, mkdirSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||||
|
import { homedir, tmpdir, platform, arch } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const log = msg => console.log(`[ensure-java] ${msg}`)
|
||||||
|
const warn = msg => console.warn(`[ensure-java] ${msg}`)
|
||||||
|
|
||||||
|
// This is a convenience, never a blocker — always exit 0.
|
||||||
|
try {
|
||||||
|
await main()
|
||||||
|
} catch (err) {
|
||||||
|
warn(`skipped (${err?.message || err}).`)
|
||||||
|
warn('The Firestore emulator needs Java 11+ — install it manually, see apps/mnswpr/README.md.')
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (process.env.SKIP_JRE_SETUP) return log('SKIP_JRE_SETUP set — skipping.')
|
||||||
|
if (process.env.CI) return log('CI detected — skipping JRE setup.')
|
||||||
|
if (hasJava('java')) return log('Java already on PATH — nothing to do.')
|
||||||
|
|
||||||
|
const adoptOs = { linux: 'linux', darwin: 'mac' }[platform()]
|
||||||
|
const adoptArch = { x64: 'x64', arm64: 'aarch64' }[arch()]
|
||||||
|
if (!adoptOs || !adoptArch) {
|
||||||
|
warn(`no auto-install for ${platform()}/${arch()} — install a JRE 21 manually (apps/mnswpr/README.md).`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!hasTool('tar')) {
|
||||||
|
warn('`tar` not found — cannot unpack the JRE. Install Java manually (apps/mnswpr/README.md).')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = process.env.JAVA_SETUP_PREFIX || join(homedir(), '.local')
|
||||||
|
const libDir = join(prefix, 'lib')
|
||||||
|
const binDir = join(prefix, 'bin')
|
||||||
|
mkdirSync(libDir, { recursive: true })
|
||||||
|
mkdirSync(binDir, { recursive: true })
|
||||||
|
|
||||||
|
const url = `https://api.adoptium.net/v3/binary/latest/21/ga/${adoptOs}/${adoptArch}/jre/hotspot/normal/eclipse`
|
||||||
|
log(`no Java found — downloading Temurin JRE 21 (${adoptOs}/${adoptArch})…`)
|
||||||
|
const res = await fetch(url, { redirect: 'follow' })
|
||||||
|
if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`)
|
||||||
|
const tgz = join(tmpdir(), `temurin-jre-21-${adoptOs}-${adoptArch}.tar.gz`)
|
||||||
|
writeFileSync(tgz, Buffer.from(await res.arrayBuffer()))
|
||||||
|
|
||||||
|
const before = new Set(readdirSync(libDir))
|
||||||
|
const untar = spawnSync('tar', ['xzf', tgz, '-C', libDir], { stdio: 'inherit' })
|
||||||
|
rmSync(tgz, { force: true })
|
||||||
|
if (untar.status !== 0) throw new Error('tar extraction failed')
|
||||||
|
|
||||||
|
// the top-level dir the tarball created (e.g. jdk-21.0.11+10-jre); on re-runs
|
||||||
|
// it already exists, so fall back to the newest matching dir.
|
||||||
|
const jreDir = readdirSync(libDir).find(d => !before.has(d) && /jdk.*jre/i.test(d))
|
||||||
|
|| readdirSync(libDir).filter(d => /jdk.*jre/i.test(d)).sort().pop()
|
||||||
|
if (!jreDir) throw new Error('could not locate the unpacked JRE directory')
|
||||||
|
|
||||||
|
// java is at bin/java (linux) or Contents/Home/bin/java (macOS)
|
||||||
|
const javaBin = [
|
||||||
|
join(libDir, jreDir, 'bin', 'java'),
|
||||||
|
join(libDir, jreDir, 'Contents', 'Home', 'bin', 'java')
|
||||||
|
].find(existsSync)
|
||||||
|
if (!javaBin) throw new Error('java binary not found in the unpacked JRE')
|
||||||
|
|
||||||
|
for (const tool of ['java', 'keytool']) {
|
||||||
|
const src = javaBin.replace(/java$/, tool)
|
||||||
|
const dst = join(binDir, tool)
|
||||||
|
if (existsSync(src)) { rmSync(dst, { force: true }); symlinkSync(src, dst) }
|
||||||
|
}
|
||||||
|
if (!hasJava(join(binDir, 'java'))) throw new Error('the installed java did not run')
|
||||||
|
|
||||||
|
log(`installed JRE 21 → ${join(libDir, jreDir)}`)
|
||||||
|
log(`linked java → ${join(binDir, 'java')}`)
|
||||||
|
if (!(process.env.PATH || '').split(':').includes(binDir)) {
|
||||||
|
warn(`${binDir} is not on your PATH — add it (e.g. in ~/.profile) so \`java\` is found.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasJava(bin) {
|
||||||
|
const r = spawnSync(bin, ['-version'], { stdio: 'ignore' })
|
||||||
|
return !r.error && r.status === 0
|
||||||
|
}
|
||||||
|
function hasTool(name) {
|
||||||
|
const r = spawnSync(name, ['--version'], { stdio: 'ignore' })
|
||||||
|
return !r.error
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Publishes @ayo-run/mnswpr to npm using the mnswpr app README.md as the
|
// Publishes @cozy-games/mnswpr to npm using the mnswpr app README.md as the
|
||||||
// package's README (what npmjs.com displays).
|
// package's README (what npmjs.com displays).
|
||||||
//
|
//
|
||||||
// packages/mnswpr/README.md is a generated copy (gitignored) — the source of
|
// packages/mnswpr/README.md is a generated copy (gitignored) — the source of
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue