Compare commits

..

No commits in common. "main" and "pre-cozy-games" have entirely different histories.

76 changed files with 3700 additions and 13269 deletions

View file

@ -1,94 +0,0 @@
name: Checks
on:
pull_request:
push:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Set node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Set node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
content:
runs-on: ubuntu-latest
steps:
# Full history: the scan walks a commit range.
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
# No install — the scanner has no dependencies.
- name: Scan the pull request range
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: |
git fetch --no-tags --quiet origin "$BASE_REF"
node scripts/check-content.mjs --range "origin/$BASE_REF...HEAD"
# Title and body reach the script as files, never as inline shell.
- name: Scan the pull request title and body
if: github.event_name == 'pull_request'
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
run: |
printf '%s\n' "$PR_TITLE" > "$RUNNER_TEMP/pr-title.txt"
printf '%s\n' "$PR_BODY" > "$RUNNER_TEMP/pr-body.txt"
node scripts/check-content.mjs --text "$RUNNER_TEMP/pr-title.txt" --text "$RUNNER_TEMP/pr-body.txt"
- name: Scan the head branch name
if: github.event_name == 'pull_request'
env:
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: node scripts/check-content.mjs --branch "$HEAD_REF"
- name: Scan the pushed range
if: github.event_name == 'push'
env:
PUSH_BEFORE: ${{ github.event.before }}
PUSH_AFTER: ${{ github.sha }}
run: |
# A new branch or a force-push can leave `before` unreachable.
before="$PUSH_BEFORE"
if ! git rev-parse --quiet --verify "$before^{commit}" > /dev/null; then
before="$(git rev-parse "$PUSH_AFTER~1")"
fi
node scripts/check-content.mjs --range "$before..$PUSH_AFTER"

26
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: Release
permissions:
contents: write
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- run: npx changelogithub
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

8
.gitignore vendored
View file

@ -1,18 +1,18 @@
node_modules/
dist/
# generated at publish time from the root README.md (see scripts/publish-lib.js)
lib/README.md
.claude
# Production / local Firebase config values are not committed — set them as
# Netlify env vars, or keep them in a local, gitignored env file. Dev values
# live in the committed .env.development (public, non-secret keys).
# live in the committed app/.env.development (public, non-secret keys).
.env.production
.env.local
.env.*.local
# Netlify CLI site link (written by `netlify link`)
.netlify/
# Firebase emulator artifacts
firebase-debug.log
firestore-debug.log

View file

@ -1,2 +0,0 @@
echo "content-check (commit message)..."
node scripts/check-content.mjs --message "$1"

View file

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

View file

@ -1,11 +0,0 @@
{
"version": 1,
"salt": "40f076a2ae183c348be3e11a8b58ebac",
"digests": [],
"toolCoAuthors": [
"*@anthropic.com",
"*@openai.com",
"*@cursor.com",
"*@devin.ai"
]
}

View file

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

View file

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

122
AGENTS.md
View file

@ -4,105 +4,42 @@ Guidance for AI coding agents working in this repository.
## What this is
Classic Minesweeper as a vanilla web game — no framework, no TypeScript (JSDoc + `// @ts-check` only). Deployed at [mnswpr.com](https://mnswpr.com) (Netlify), with a Firestore-backed leaderboard.
**This repo is only the app.** The engine, leaderboard, and shared services (`@cozy-games/mnswpr`, `@cozy-games/leaderboard`, `@cozy-games/utils`) live in [ayo-run/cozy-games](https://github.com/ayo-run/cozy-games) and are consumed here **from npm** — there is no local `packages/` to edit. A change to game mechanics, leaderboard internals, or the shared services belongs in that repo and arrives here as a version bump in `package.json`.
Structurally it is a single package at the repo root — no workspace, no `apps/` nesting. Every script runs from the root with plain `pnpm run <script>`.
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.
## Commands
All commands run from the repo root.
```bash
pnpm i # install (pnpm is required)
pnpm test # run the Vitest suite once (jsdom)
pnpm test:watch # run Vitest in watch mode
pnpm i # install (pnpm is required — this is a pnpm workspace)
pnpm dev # run the website dev server (vite app) — most common
pnpm build # build the website -> app/dist
pnpm build:lib # build the publishable library -> lib/dist
pnpm lint # eslint . (JS + CSS); runs automatically on pre-commit
pnpm lint:fix # eslint --fix
pnpm scan:secrets # secretlint over the tree
pnpm run dev # Firestore emulator + auto-seed + dev server (emulators:exec) — most common; needs JDK 21+
pnpm run dev:no-db # plain vite, no emulator (UI-only work / no JDK)
pnpm run build # build the website -> dist
pnpm run preview # serve the production build
pnpm run build:preview # build the app and serve the production preview
pnpm build:preview # build the app and serve the production preview
pnpm test # run the Vitest suite once (jsdom)
pnpm test:watch # run Vitest in watch mode
```
Run a single test file or name: `pnpm vitest run scripts/test/check-content.test.js` · `pnpm vitest run -t 'partial name'`.
### 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. All at the repo root:
| 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** (not `npx`-on-demand, not global installs), so `pnpm install` pins them and every machine gets the same version. The app depends on `firebase-tools` and `netlify-cli`; its scripts call the `firebase`/`netlify` binaries directly (pnpm puts `node_modules/.bin` on `PATH`).
Infra scripts live in `package.json` under generic, tech-agnostic names (`deploy:db`, not `deploy:firestore`), so swapping the underlying stack doesn't change the command you type:
```bash
pnpm run db:start # local DB emulator (mnswpr -> Firestore), standalone
pnpm run db:seed # seed the running local emulator
pnpm run db:stop # kill a stray/orphaned Firestore emulator holding :8080
pnpm run deploy:db # deploy DB rules/indexes (-> firebase deploy --only firestore)
pnpm run deploy:site # build + deploy hosting (-> netlify deploy --prod --dir=dist)
```
**One-time per app / per machine (all CLI, no dashboard):**
```bash
pnpm exec firebase login # auth the Firebase CLI
pnpm exec netlify login # auth the Netlify CLI
pnpm 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 exec netlify env:set VITE_LB_NAMESPACE mw # set one var
pnpm exec netlify env:import .env.production # bulk-import from a local (gitignored) env file
pnpm 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 run under **Vitest** with a jsdom environment (config in `vitest.config.js`), which collects `test/**/*.test.js` and `scripts/test/**/*.test.js`. Today only `scripts/test/` exists (the content scanner); app-level tests go in `test/`. Engine and shared-package tests live in the cozy-games repo, not here. For anything visual or input-timing related, verify by running `pnpm run dev` and playing.
Tests live in `test/` and run under **Vitest** with a jsdom environment (config in `vitest.config.js`). They cover the shared utils and drive the engine through real DOM events (mount `#app`, dispatch mouse events, assert on cell/grid attributes). For anything visual or input-timing related, also verify by running `pnpm dev` and playing.
Node version: `.nvmrc` pins `lts/*`.
## Repository layout
## Repository layout (pnpm workspace)
A single package (`mnswpr`) rooted at the repo root.
Workspaces are declared in `pnpm-workspace.yaml` as `lib` and `app`. Note that `utils/` is **not** a workspace package — it's a plain shared folder imported by relative path from both `lib` and `app`.
- **root** — the mnswpr.com website. `main.js` composes the npm packages; `index.html` + `main.css` are the shell. Infra config lives here too (`firebase.json`, `firestore.rules`, `.firebaserc`, `netlify.toml`, `vite.config.js`).
- **`modules/`** — the two app-owned services (`user/`, `nickname/`).
- **`docs/`** — backend documentation (Firestore data model, env migration).
- **`scripts/`** — tooling: `seed-dev-scores.js` and `export-legends.js` (app scripts), plus `check-content.mjs` (content policy scanner, tested in `scripts/test/`) and `ensure-java.mjs` (postinstall JRE bootstrap).
- **`lib/`** — `@ayo-run/mnswpr`, the standalone, framework-free game engine. This is what gets published to npm. `lib/mnswpr.js` is the whole engine; `lib/levels.js` defines the four difficulty presets. Depends only on `utils/`.
- **`app/`** — the mnswpr.com website. Consumes the library via `workspace:*` (`import mnswpr from '@ayo-run/mnswpr/mnswpr.js'`) and adds the Firebase leaderboard. `app/main.js` wires the engine to the leaderboard through the engine's hooks.
- **`utils/`** — shared services with no dependencies, re-exported from `utils/index.js`: `StorageService` (localStorage/sessionStorage JSON wrapper), `TimerService` (game clock + `pretty()` time formatting used by both engine and leaderboard), `LoggerService`, `LoadingService`.
## Architecture
`main.js` is ~75 lines and is the whole app: it wires three npm packages together and owns nothing else.
**The engine is decoupled from the app via two hooks.** `Minesweeper(appId, version, hooks)` (`lib/mnswpr.js`) is a classic constructor function that imperatively builds a `<table>` grid in the DOM. It knows nothing about Firebase or leaderboards. The app injects behavior through:
**The engine is decoupled from the app via two hooks.** `new mnswpr(appId, version, hooks)` is a constructor function that imperatively builds a `<table>` grid in the DOM. It knows nothing about Firebase or leaderboards. The app injects behavior through:
- `hooks.levelChanged(setting)` — fired when the difficulty level changes; the app uses this to re-fetch and render the leaderboard for that level.
- `hooks.gameDone(game)` — fired when a game ends (win or loss) with a `game` object (`time`, `status`, `level`, `time_stamp`, `isMobile`); the app uses this to submit the score.
- `hooks.levelChanged(level)` — fired when the difficulty level changes; the app repoints the leaderboard element at that level (`title` + `category` attributes).
- `hooks.gameDone(game)` — fired when a game ends (win or loss) with a `game` object (`time`, `status`, `level`, `time_stamp`, `isMobile`); the app maps it onto `board.submit({ name, playerId, score, category, … })`.
If the app needs to react to something new in the engine, the right fix is a **new hook in the cozy-games repo**, not app code reaching into engine internals — that separation is what keeps the library publishable on its own.
**The leaderboard is composed declaratively.** `<cozy-leaderboard>` is placed in `index.html` and re-renders reactively off its attributes; `main.js` only calls `configureLeaderboard({ adapter: new FirebaseAdapter(...) })` once to bind the backend.
The engine-internal notes below describe code that lives in the cozy-games repo — keep them in mind when reasoning about behavior you observe here, but edit them there.
When adding engine features that the website needs to react to, prefer adding a new hook over reaching into the app — that separation is what keeps the library publishable on its own. (There are already `TODO` markers in the engine for an `afterGridGenerated` hook.)
**Game state lives in DOM attributes, not a JS model.** The grid's overall state is the `game-status` attribute on the `<table>` (`inactive` → `active``over`/`win` → `done`). Each cell carries `data-status` (`default`, `highlighted`, `flagged`, `clicked`, `empty`) and `data-value` (adjacent mine count). Mine positions are the one exception: kept in `minesArray` as `[row, col]` pairs. When changing game logic, read/write these attributes consistently — helpers like `getStatus`/`setStatus`, `isMine`, `isFlagged` are the intended accessors.
@ -110,27 +47,22 @@ The engine-internal notes below describe code that lives in the cozy-games repo
**Input handling is intricate.** Mouse (left/right/middle, plus simultaneous left+right "chording") and touch (long-press to flag) are handled through a state machine of flags (`isLeft`, `isRight`, `pressed`, `bothPressed`, `skip`, `isBusy`) in `initializeEventHandlers`/`initializeTouchEventHandlers`. `isBusy` debounces input (`MOBILE_BUSY_DELAY`/`PC_BUSY_DELAY`). Tread carefully here — small changes easily break chording or mobile flagging.
**Test mode:** the engine has a `TEST_MODE` flag that renders mine positions as visual hints and enables debug logging — it lives in the engine source in the cozy-games repo, so it is not toggleable from this repo.
**Test mode:** set `TEST_MODE = true` at the top of `lib/mnswpr.js` to render mine positions as visual hints and enable debug logging.
## Leaderboard / Firebase
## Leaderboard / Firebase (`app/modules/`)
Storage goes through `FirebaseAdapter` from `@cozy-games/leaderboard`; this repo supplies only configuration. Full data model, security rules, environments, and deployment: `docs/firebase-leaderboards.md`.
`LeaderBoardService` (`leader-board.js`) reads/writes Firestore (`firebase/firestore/lite`). Structure: top-10 per level in `mw-leaders/{level}/games`, all sessions in `mw-all/{browserId}/games`, and remote runtime `configuration` in `mw-config`. A score is only offered to the leaderboard when it beats the current 10th place *and* matches the server-side `passingStatus`.
**Firebase config comes from `VITE_FIREBASE_*` env vars** — dev values are committed in `.env.development`, production values are Netlify env vars. For a client-only Firebase app the API key is **not a secret** (access is governed by `firestore.rules`), so don't treat the committed dev config as a leaked credential or try to hide it.
The **Firebase config in `leader-board.js` is intentionally public and committed** — for a client-only Firebase app the API key is not a secret (access is governed by Firestore security rules), so don't treat it as a leaked credential or try to move it to env vars.
**Namespace guards production.** `VITE_LB_NAMESPACE` selects the Firestore collection prefix and defaults to `mw-test`, so a missing env var can never write into the production board (`mw`). `VITE_FIRESTORE_EMULATOR=1` (set in `.env.development`) points dev at the local emulator; production builds always use real Firestore.
App-owned modules in `modules/`: `UserService` (`user/user.js`) derives a non-cryptographic `browserId` fingerprint from navigator/screen properties to attribute scores without accounts; `NicknameService` (`nickname/nickname.js`) prompts for a display name on first visit and renders the greeting bar.
`UserService` (`user.js`) derives a non-cryptographic `browserId` fingerprint from navigator/screen properties to attribute scores without accounts.
## Conventions
- **Code style is enforced by ESLint Stylistic**, not Prettier: 2-space indent, single quotes, **no semicolons**, no trailing commas, spaces inside `{ braces }` but not `[brackets]`. Run `pnpm lint:fix` before committing. Both `**/*.js` and `**/*.css` are linted (CSS via `@eslint/css`).
- `modules/` uses ES classes; `scripts/` uses plain functions. Match the surrounding style of the file you edit.
- **Content policy.** Commit messages, branch names, PR text, and contributed lines are checked by `scripts/check-content.mjs` (hooks + the `Checks` workflow) against `.repo-policy.json`. Write commit messages in plain project voice; no tool-attribution trailers or footers, no `Co-Authored-By:` line for a non-human contributor (the policy's `toolCoAuthors` list — human co-authors are always fine), no session links.
- The same scanner matches text against a maintainer-managed reserved-terms list. Findings report a location and a masked preview, never the term. If one flags your change, reword it or ask a maintainer — don't edit `.repo-policy.json`.
- **Source stays JS + JSDoc (`// @ts-check`)** — no TypeScript. Nothing is published from this repo, so there is no type-generation step here; the `.d.ts` files shipped by `@cozy-games/*` are generated in the cozy-games repo.
- The engine uses **plain functions and `var`/`let` closures**, not classes; `utils/` and `app/modules/` use ES classes. Match the surrounding style of the file you edit.
## Git hooks
## Release & git hooks (maintainer workflow)
- `pre-commit` runs `pnpm lint`, the secret scan, and the content check (staged diff + branch name); `commit-msg` runs the content check over the message; `post-commit` auto-pushes to two extra remotes (`git push gh`, `git push sh`). If those remotes aren't configured locally, expect post-commit failures — that's environmental, not a code problem.
- CI (`.github/workflows/checks.yml`) runs the same three gates on pull requests and pushes to `main`: `lint`, `test`, and the content scan over the PR commit range.
- **Husky hooks:** `pre-commit` runs `pnpm lint`; `post-commit` auto-pushes to two extra remotes (`git push gh`, `git push sh`). If those remotes aren't configured locally, expect post-commit failures — that's environmental, not a code problem.
- **Releasing** (`pnpm release`) builds the lib, runs `bumpp` (version bump + tag) in `lib/`, then `scripts/release.js` force-pushes a `release` branch and tags to remotes `gh`/`sh`. Pushing a `v*` tag triggers `.github/workflows/release.yml` (`changelogithub`) to publish GitHub release notes. Only run this when explicitly releasing.

92
AYO.md Normal file
View file

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

View file

@ -1,7 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
See **[AGENTS.md](AGENTS.md)** — it is the single source of guidance for coding agents in this repo, covering commands, infra, architecture, and conventions. Read it before making changes.
Human-oriented setup and contribution guidance lives in [CONTRIBUTING.md](CONTRIBUTING.md).

View file

@ -1,131 +0,0 @@
# 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****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
This repo is a single package at the root — the mnswpr.com web app. `main.js`
and `index.html` are the app itself, `modules/` holds the app-owned services,
`scripts/` holds tooling, and `docs/` documents the backend. The game engine and
leaderboard come from npm ([ayo-run/cozy-games](https://github.com/ayo-run/cozy-games));
there is no local `packages/` to edit.
Backend config (Firestore rules, Firebase project aliases, Netlify hosting) is
committed at the root — see [AGENTS.md](AGENTS.md) "Infra".
## Setup
```bash
pnpm i # install dependencies
```
## Commands (all 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
```
## Running the game locally
```bash
pnpm run dev # Vite dev server + Firestore emulator (auto-seeded) — needs Java
pnpm run dev:no-db # Vite only, no emulator (UI work, or no Java)
pnpm run build # build the app -> dist
pnpm run preview # preview the production build
```
## Tests
Tests run under **Vitest** with a **jsdom** environment and live next to the code
they exercise (`test/`, `scripts/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 (`modules/` uses ES classes; `scripts/` uses plain functions).
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 run db:start # start the local Firestore emulator (standalone) — needs Java
pnpm run db:seed # seed the running emulator with sample data
pnpm 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 — the first-time setup is documented in
[`docs/deployment.md`](docs/deployment.md). See [README.md](README.md) and
[`docs/`](docs/) for the full backend reference.
## Project structure
The repo layout and architecture are documented in [AGENTS.md](AGENTS.md) —
worth a read before a large change.
## 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.
## Commit & PR hygiene
Write commit messages in plain project voice — what changed and why. Keep them
free of AI-tool attribution trailers or footers (including `Co-Authored-By:`
lines crediting a coding assistant) and of session links; the same goes for PR
titles and bodies. Co-authoring with another *person* is always fine — use any
address you like, there is no contributor allowlist.
CI also checks your changes against a maintainer-managed reserved-terms list. If a
check flags your change, reword it — or ask a maintainer if it isn't clear why.
The checks run locally too: the pre-commit hook scans staged changes and your
branch name, and the commit-msg hook scans the message. If a line legitimately
needs wording a check objects to (prose *about* these topics, say), put a
`content-policy: allow-next-line` comment above it and mention why in the PR.
## License
By contributing, you agree that your contributions are licensed under the
project's [MIT license](LICENSE).

37
LICENSE
View file

@ -1,21 +1,24 @@
MIT License
BSD 2-Clause License
Copyright (c) 2019 Ayo Ayco
Copyright (c) 2019, Ayo Ayco
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View file

@ -1,6 +1,10 @@
# Play Minesweeper Online for Free
[![Netlify Status](https://api.netlify.com/api/v1/badges/172478bd-afc5-4e47-95ba-d9ab814248fb/deploy-status)](https://app.netlify.com/sites/mnswpr/deploys)
> [!IMPORTANT]
> **mnswpr has a new home.** Development now happens in the **Cozy Games** monorepo:
> **[git.ayo.run/ayo/cozy-games](https://git.ayo.run/ayo/cozy-games)**.
Play it here: [mnswpr.com](https://mnswpr.com). This is the classic game **Minesweeper** built with vanilla web technologies (i.e., no framework dependency).
![mnswpr gameplay](https://git.ayo.run/ayo/mnswpr/raw/branch/main/screenshot.png)
@ -14,19 +18,20 @@ The goal is to reveal every safe cell without detonating a mine. Your **first cl
- **Left + right click together** (chording) — reveal the neighbors of a satisfied number
- **Touch** — tap to reveal, long-press to flag
## Ways to Use
The web is a wonderful, free, and open platform to create and distribute value. You can use **mnswpr** in different ways:
- as a deployed [web app](https://mnswpr.com)
- as a [library](https://npmx.dev/package/@cozy-games/mnswpr) with `npm i @cozy-games/mnswpr`
- as a [library](https://npmx.dev/package/@ayo-run/mnswpr) with `npm i @ayo-run/mnswpr`
- as a `web component` (coming soon).
Using it as a library takes only a few lines — mount it onto any element by `id`:
```js
import '@cozy-games/mnswpr/mnswpr.css'
import mnswpr from '@cozy-games/mnswpr'
import '@ayo-run/mnswpr/mnswpr.css'
import mnswpr from '@ayo-run/mnswpr'
const game = new mnswpr('app')
game.initialize()
@ -40,10 +45,10 @@ As of now the tooling I use are:
- [Eslint](https://eslint.org) for JS linting & [CSS linting](https://eslint.org/blog/2025/02/eslint-css-support/)
- [ESLint Stylistic](https://eslint.style) for JS formatting
- [Husky](https://typicode.github.io/husky/) for git hooks
- [PNPM](https://pnpm.io/installation) for dependency management
- [PNPM](https://pnpm.io/installation) for dependency & workspace management
- and a bunch of automation using scripts and Continuous Integration actions
Because a big part of this project's purpose is to track how the software development industry evolves, I now also use it as a **playground for coding agents**.
Because a big part of this project's purpose is to track how the software development industry evolves — and because it has come a long way in modernizing along the way — I now also use it as a **playground for coding agents**. It's a small, framework-free, well-scoped codebase, which makes it a great sandbox to see how AI agents read, reason about, and change real code. To help them get their bearings quickly, the repo ships an [`AGENTS.md`](./AGENTS.md) describing the architecture and conventions.
## Development
@ -56,20 +61,37 @@ To start development, you need [`node`](https://nodejs.org/en/download). I highl
The rest of the everyday commands:
```bash
pnpm test # run the Vitest suite
pnpm lint # ESLint (JS + CSS)
pnpm lint:fix # ESLint with autofix
pnpm run build # build the website
pnpm run preview # serve the production build
pnpm test # run the Vitest suite
pnpm lint # ESLint (JS + CSS)
pnpm lint:fix # ESLint with autofix
pnpm build # build the website
pnpm build:lib # build the publishable library
```
The game engine and leaderboard are consumed from npm — they live in
[ayo-run/cozy-games](https://github.com/ayo-run/cozy-games), not here.
### Leaderboard (local Firestore emulator)
The leader board is backed by [Google Firestore](https://firebase.google.com). For local development the app talks to the **Firestore emulator** by default — fully local, no cloud, no deploy. The flag `VITE_FIRESTORE_EMULATOR=1` is already set in `app/.env.development`.
You need a **JDK 21+** installed (the emulator runs on Java; `firebase-tools` itself is fetched on demand via `npx`). Then, in two terminals:
```bash
pnpm emulators # terminal 1 — start the Firestore emulator on :8080 (+ UI)
pnpm seed:emulator # terminal 2, once — fill it with sample scores
pnpm dev # terminal 2 — run the app against the emulator
```
If the emulator isn't running, the board simply shows *"unavailable"* (a refused connection). To skip the emulator — for quick UI-only work, or if you don't have a JDK — set `VITE_FIRESTORE_EMULATOR=` (empty) in a local, gitignored `app/.env.local`; the app then uses the cloud `mw-test` namespace instead.
See [`docs/firebase-leaderboards.md`](./docs/firebase-leaderboards.md) for the full data model, security rules, environments, and deployment.
## Contributing
Contributions are welcome! See [`AGENTS.md`](./AGENTS.md) for the architecture, conventions, and release workflow before opening a pull request.
## You just want to play?
*👉 The live site is here: [mnswpr.com](https://mnswpr.com)*
## Background
One day, while working in my home office, I heard loud and fast mouse clicks coming from our bedroom. It's my wife, playing her favorite game (Minesweeper) on a crappy website full of advertisements.
@ -89,9 +111,11 @@ Can I make a page with complex interactions (more on this later) without any lib
1. Competition motivates users to use your app more ✨
1. Hash in bundled filenames helps avoid issues with browser caching (when shipping versions fast) ✨
## License
[MIT](./LICENSE)
[BSD-2-Clause](./LICENSE)
---

View file

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

View file

@ -1,9 +1,6 @@
import mnswpr from '@cozy-games/mnswpr/mnswpr.js'
import '@cozy-games/mnswpr/mnswpr.css'
import '@cozy-games/utils/loading/loading.css'
// The app's own version — this is what the heading shows and what the v* tags
// and GitHub releases track. Not the engine's version (@cozy-games/mnswpr).
import * as pkg from '../package.json'
import mnswpr from '@ayo-run/mnswpr/mnswpr.js'
import '@ayo-run/mnswpr/mnswpr.css'
import * as pkg from '@ayo-run/mnswpr/package.json'
import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
import { NicknameService } from './modules/nickname/nickname.js'

View file

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

19
app/package.json Normal file
View file

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

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

14
app/vite.config.js Normal file
View file

@ -0,0 +1,14 @@
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
// Multi-page build: the game (index.html) and the frozen Legends page.
export default defineConfig({
build: {
rollupOptions: {
input: {
main: resolve(import.meta.dirname, 'index.html'),
legends: resolve(import.meta.dirname, 'legends.html')
}
}
}
})

View file

@ -1,175 +0,0 @@
# First-time deployment
Everything needed to take this repo from a fresh clone to a live deploy of
[mnswpr.com](https://mnswpr.com). Once it's set up you won't need this again —
day to day, `pnpm release` is the whole story.
Two services are involved: **Netlify** hosts the site, **Firebase** (Firestore)
stores the leaderboard. Both are driven entirely from the CLI; nothing here
requires a web dashboard except one Netlify setting noted below.
For the conceptual split between infra *config* (committed, declarative) and
infra *tools* (pinned devDependencies), see AGENTS.md → "Infra".
---
## 0. Prerequisites
```bash
pnpm i # installs firebase-tools and netlify-cli as pinned devDependencies
```
Both CLIs are invoked through `pnpm exec`, so you never need a global install.
---
## 1. Netlify
### Log in and link the repo
```bash
pnpm exec netlify login
pnpm exec netlify link # binds this directory to the site; writes .netlify/ (gitignored)
```
If you know the site ID you can skip the prompts:
```bash
pnpm exec netlify link --id <site-id>
```
Losing `.netlify/` is harmless — it's local state. Re-run `netlify link`.
### Set the base directory
The site's **base directory must be the repo root**. This is the one setting
that isn't in a config file — it lives on the site itself (Site configuration →
Build & deploy → Base directory) and must be **empty**.
If it points at a subdirectory that doesn't exist, git-triggered builds fail
before they start.
### Set the environment variables
This is the step most likely to be missed, because it only affects one of the
two deploy paths:
- **`pnpm run deploy:site`** builds *locally* and uploads `dist/`. It reads your
local, gitignored `.env.production` — Netlify's env vars are never consulted.
- **`pnpm release`** force-pushes the `release` branch, and **Netlify builds it
from git**. That build runs on Netlify's machines with no access to any local
file, so it needs the variables set on the site.
A site that deploys fine via `deploy:site` can still ship a broken build from
git if these are missing — the Firebase config comes out `undefined` and the
leaderboard fails for every visitor.
The eight values are the same ones committed in `.env.development`: `prod` and
`dev` are the *same* Firebase project (see `firebase/.firebaserc`), separated
only by collection namespace. So you can set them straight from that file:
```bash
grep -E '^VITE_FIREBASE_' .env.development | while IFS='=' read -r k v; do
pnpm exec netlify env:set "$k" "$v"
done
```
That covers:
```
VITE_FIREBASE_API_KEY VITE_FIREBASE_STORAGE_BUCKET
VITE_FIREBASE_AUTH_DOMAIN VITE_FIREBASE_MESSAGING_SENDER_ID
VITE_FIREBASE_DATABASE_URL VITE_FIREBASE_APP_ID
VITE_FIREBASE_PROJECT_ID VITE_FIREBASE_MEASUREMENT_ID
```
Two variables are deliberately **not** in that list:
- **`VITE_LB_NAMESPACE`** is already set to `mw` in `netlify.toml`, so it ships
with the repo. (`src/main.js` defaults to `mw-test` when it's missing, so a
misconfigured build writes to the test board, never production.)
- **`VITE_FIRESTORE_EMULATOR`** must stay **absent or empty** in production.
`src/main.js` treats `1`/`true`/`yes` as "connect to a local emulator", so if
it leaks into the site's environment the live leaderboard silently tries to
reach `127.0.0.1:8080` and fails for everyone. It belongs only in
`.env.development`.
Verify what's actually set:
```bash
pnpm exec netlify env:list
```
> **These are not secrets.** Firebase Web API keys are public by design — they
> identify the project, they don't authorize anything. Access is governed by
> `firebase/firestore.rules`. They're kept out of git as prod hygiene, not
> because exposure is dangerous; the dev values are committed for this reason.
---
## 2. Firebase
### Log in
```bash
pnpm exec firebase login
```
### Deploy the security rules
**Do this before the first real release.** `firebase/firestore.rules` is the
source of truth for who can read and write the leaderboard, but it only takes
effect once deployed — a project can be running rules set by hand in the console
long ago, which won't match the repo.
```bash
pnpm run deploy:db # firebase --config firebase/firebase.json deploy --only firestore
```
That deploys rules *and* indexes. Note that `prod` and `dev` are the same
Firebase project, so a rules deploy affects both environments at once.
If the rules are stale, leaderboard writes fail with permission-denied even
though the site loads fine — worth confirming after the first deploy by
finishing a game and checking the score appears.
---
## 3. Deploy
Two paths, both supported:
```bash
pnpm run deploy:site # build locally, upload dist/ via the Netlify CLI
```
or the full release, which is what tags a version and triggers the git build:
```bash
pnpm release # lint + test, then bumpp, then sync the release branch
```
`pnpm release` runs `scripts/release.js`, which force-pushes `main` to the
`release` branch on the `gh` remote. **That push is the deploy** — Netlify
watches that branch. It refuses to run from a branch other than `main` or with a
dirty working tree.
---
## Verifying a first deploy
1. `pnpm exec netlify env:list` shows all eight `VITE_FIREBASE_*` and no
`VITE_FIRESTORE_EMULATOR`.
2. The site loads and the version in the heading matches `package.json`.
3. Finish a game — your score appears on the board. This is the real end-to-end
check: it exercises the env vars, the rules, and the namespace together.
4. The leaderboard shows the `mw` namespace, not `mw-test`.
## Related
- [`firebase-leaderboards.md`](./firebase-leaderboards.md) — data model, rules,
environments, deployment details
- [`firestore-emulator.md`](./firestore-emulator.md) — running the leaderboard
locally
- [`leaderboard-env-migration.md`](./leaderboard-env-migration.md) — history of
the env-var/namespace migration

View file

@ -6,9 +6,9 @@ still has to be touched in the Firebase Console.
## Overview
Leaderboards are powered by Firestore (`firebase/firestore/lite`) through the
reusable, game-agnostic [`@cozy-games/leaderboard`](https://github.com/ayo-run/cozy-games)
reusable, game-agnostic [`@cozy-games/leaderboard`](../leaderboard/leader-board.js)
package. The app wires minesweeper's specifics (finish-time as the `score`,
ascending sort, time formatting) in [`app/main.js`](../src/main.js).
ascending sort, time formatting) in [`app/main.js`](../app/main.js).
The board offers four time windows — **Today** (default), **Week**, **Month**,
**All Time** — selected by tabs. Each played game that qualifies is written to a
@ -64,11 +64,11 @@ competition, not a boundary quirk.
The Firestore schema (security rules + indexes) lives in the repo:
- [`firebase.json`](../firebase/firebase.json) — points at the rules and indexes files.
- [`.firebaserc`](../firebase/.firebaserc) — project aliases: `prod` (default, live site)
- [`firebase.json`](../firebase.json) — points at the rules and indexes files.
- [`.firebaserc`](../.firebaserc) — project aliases: `prod` (default, live site)
and `dev` (`secure-moment-188701`).
- [`firestore.rules`](../firebase/firestore.rules) — access + validation rules.
- [`firestore.indexes.json`](../firebase/firestore.indexes.json) — **empty**: rolling
- [`firestore.rules`](../firestore.rules) — access + validation rules.
- [`firestore.indexes.json`](../firestore.indexes.json) — **empty**: rolling
windows (`time_stamp >=`) and all-time (`orderBy('score')`) use Firestore's
automatic single-field indexes, so no composite indexes are needed.
@ -76,7 +76,7 @@ The Firestore schema (security rules + indexes) lives in the repo:
There is **one Firebase project** (`secure-moment-188701`). Production and test
data are separated not by project but by **collection namespace**, chosen with
the `VITE_LB_NAMESPACE` env var read in [`app/main.js`](../src/main.js):
the `VITE_LB_NAMESPACE` env var read in [`app/main.js`](../app/main.js):
| Environment | `VITE_LB_NAMESPACE` | Collections |
| --- | --- | --- |
@ -88,7 +88,7 @@ project — so the only difference is the namespace. `app/main.js` defaults to t
**test** namespace, so a missing/misconfigured var can never write into the
production board; production must set `VITE_LB_NAMESPACE=mw` explicitly.
Dev config lives in the committed [`app/.env.development`](../.env.development)
Dev config lives in the committed [`app/.env.development`](../app/.env.development)
(the keys are public). Production sets `VITE_FIREBASE_*` **and**
`VITE_LB_NAMESPACE=mw` as Netlify environment variables. `.env.production` and
`.env*.local` stay gitignored.
@ -98,30 +98,27 @@ Dev config lives in the committed [`app/.env.development`](../.env.development)
### One-time CLI setup
`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.
The Firebase project already exists — no need to create it.
```bash
pnpm exec firebase login
npx firebase login
```
### Deploy rules + indexes
The `deploy:db` script (`pnpm run deploy:db`) deploys everything under
`firestore` to the **default** project. To target a specific project or a subset,
call the CLI directly:
Deploys go to **production** by default. Target a project explicitly with
`--project`:
```bash
# production (default alias 'prod')
pnpm exec firebase deploy --only firestore:rules,firestore:indexes --project prod
npx firebase deploy --only firestore:rules,firestore:indexes --project prod
# development database
pnpm exec firebase deploy --only firestore:rules,firestore:indexes --project dev
npx firebase deploy --only firestore:rules,firestore:indexes --project dev
```
> ⚠️ Deploying **replaces** whatever rules currently live in the Console. The
> committed [`firestore.rules`](../firebase/firestore.rules) is written to cover every
> committed [`firestore.rules`](../firestore.rules) is written to cover every
> collection the app uses, so a deploy is safe — but review it first.
No composite indexes are required — the rolling-window and all-time queries use
@ -131,22 +128,21 @@ Firestore's automatic single-field indexes.
Local development runs against the **Firebase Local Emulator Suite** by default —
no cloud, no deploy, no auth, no `permission-denied` — and it loads the committed
[`firestore.rules`](../firebase/firestore.rules) and [`firestore.indexes.json`](../firebase/firestore.indexes.json)
[`firestore.rules`](../firestore.rules) and [`firestore.indexes.json`](../firestore.indexes.json)
locally (so you validate them before deploying). The flag
`VITE_FIRESTORE_EMULATOR=1` is set in [`app/.env.development`](../.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
> (11+) installed. `firebase-tools` is a pinned devDependency of this app
> (installed by `pnpm install`, run as the `firebase` binary).
> (11+) installed. `firebase-tools` is fetched on demand via `npx`.
Everyday dev loop:
```bash
pnpm run dev # emulator (+ UI) on :8080, auto-seeded with sample scores, + app dev server
pnpm emulators # terminal 1: Firestore emulator (+ Emulator UI) on :8080
pnpm seed:emulator # terminal 2, once: fill it with sample scores
pnpm dev # terminal 2: app runs against the local emulator
```
`dev` seeds the fresh emulator for you (via `emulators:exec "node scripts/seed-dev-scores.js; vite"`). Use the standalone `db:start` / `db:seed` scripts only when running the emulator separately from the app.
Wiring: `app/main.js` passes `{ emulator: { host, port } }` to `FirebaseAdapter`,
which calls `connectFirestoreEmulator`. If the emulator isn't running the board
just shows "unavailable" (a refused connection) — start it, or opt out.
@ -166,7 +162,7 @@ Everything above is doable via the CLI. If you can't use it:
(If you ever add a query that needs a composite index, Firestore prints a
console error with a direct link to create it.)
- **Rules**: edit them directly under **Firestore → Rules** in the Console
(paste from [`firestore.rules`](../firebase/firestore.rules)).
(paste from [`firestore.rules`](../firestore.rules)).
- **Server config**: `mw-config/configuration` (`passingStatus`, `message`) is
**always** managed by hand in the Console — it is read-only to clients and has
no code representation. `passingStatus` is the `status` value that makes a game
@ -175,16 +171,17 @@ Everything above is doable via the CLI. If you can't use it:
## Legends (frozen hall of fame)
The old all-time leaders are preserved as a **fully-rendered static page**
[`legends.html`](../src/legends.html). The records are baked straight into the
[`app/legends.html`](../app/legends.html). The records are baked straight into the
HTML with times pre-formatted; there is **no JavaScript and no Firebase** at page
load. The data never changes.
The page is generated by [`scripts/export-legends.js`](../scripts/export-legends.js),
which snapshots `mw-leaders/{level}/games` and writes the whole `legends.html`.
It only needs re-running if you ever want to regenerate. Run it from the repo root:
It only needs re-running if you ever want to regenerate. Because `firebase` is an
app-workspace dependency, run it so Node can resolve it:
```bash
node scripts/export-legends.js
(cd app && node ../scripts/export-legends.js)
```
The Firebase config for this one-off is hardcoded in the script (public keys).
@ -209,7 +206,7 @@ new LeaderBoardService({
To run on **Supabase** instead, swap in `SupabaseAdapter` — nothing else in the
game changes. The adapter interface and the Supabase table/SQL schema are
documented in the [@cozy-games/leaderboard README](https://github.com/ayo-run/cozy-games).
documented in [`leaderboard/README.md`](../leaderboard/README.md).
Then submit `{ name, playerId, score, category, time_stamp, status?, meta? }`
and render with `render(category, title, duration)`.

View file

@ -1,56 +0,0 @@
# Leaderboard: the local Firestore emulator
How to run the leaderboard locally. For the data model, security rules,
environments, and deployment, see [`firebase-leaderboards.md`](./firebase-leaderboards.md).
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 `.env.development`.
`dev` is the whole loop in one command — it wraps Vite in `firebase emulators:exec`, so the Firestore emulator (on :8080, + UI) comes up, gets **seeded with sample scores automatically**, and the app dev server starts against it; everything shuts down when you stop it. (Each `dev` starts a fresh in-memory emulator, so the auto-seed writes exactly one clean set every time — no accumulation.) `firebase-tools` is a pinned **devDependency** of this app (installed by `pnpm install`, invoked as the `firebase` binary), so the only extra prerequisite is **Java** — the Firestore emulator is a Java program (`java -jar cloud-firestore-emulator-*.jar`), and firebase-tools does not bundle a JRE.
## Java
**Usually automatic.** `pnpm install` runs a root `postinstall` ([`scripts/ensure-java.mjs`](../scripts/ensure-java.mjs)) that installs a user-local Temurin JRE 21 into `~/.local` (no `sudo`) when `java` isn't already on your PATH. It's idempotent and never fails the install, and it skips when `CI` or `SKIP_JRE_SETUP=1` is set, or on unsupported platforms.
If that skipped and you need Java (or prefer a system-wide install), do it manually — **install a JRE (Java 11+; 21 recommended):**
```bash
# Debian / Ubuntu / Pop!_OS
sudo apt update && sudo apt install -y openjdk-21-jre-headless
# Fedora
sudo dnf install -y java-21-openjdk-headless
# macOS (Homebrew)
brew install openjdk@21
java -version # verify: should print "openjdk 21.x" (or 11+)
```
No `sudo`? Install a JRE into your home directory instead (no root needed):
```bash
curl -fsSL -o /tmp/jre21.tgz "https://api.adoptium.net/v3/binary/latest/21/ga/linux/x64/jre/hotspot/normal/eclipse"
mkdir -p ~/.local/lib && tar xzf /tmp/jre21.tgz -C ~/.local/lib
ln -sf ~/.local/lib/jdk-21*-jre/bin/java ~/.local/bin/java # ~/.local/bin is already on PATH
java -version
```
Without Java, `dev` and `db:start` fail with `Could not spawn 'java -version'`. Install it to a permanent location — a JRE unpacked under `/tmp` disappears when the OS cleans temp files.
## Running it
```bash
pnpm 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 run db:start # emulator only (stays up across app restarts); pair with dev:no-db
pnpm run db:seed # seed a separately-running emulator (what dev does for you)
pnpm run db:stop # kill a stray/orphaned emulator holding :8080
```
## Skipping the emulator
To skip it entirely — for quick UI-only work, or if you don't have a JDK — run `pnpm run dev:no-db` (plain Vite) and set `VITE_FIRESTORE_EMULATOR=` (empty) in a local, gitignored `.env.local`; the app then uses the cloud `mw-test` namespace instead.

View file

@ -7,7 +7,7 @@ Every environment — a developer running `pnpm dev`, a Netlify preview, and the
live site — reads and writes the same collections (`mw-scores`, `mw-all`,
`mw-config`, legacy `mw-leaders`) in the single Firebase project
`secure-moment-188701`. `version`/`import.meta.env.MODE` only affects UI text
(in the [@cozy-games/mnswpr engine](https://github.com/ayo-run/cozy-games)); it never changes collection names. So
([lib/mnswpr.js:96](../lib/mnswpr.js:96)); it never changes collection names. So
local play pollutes the production leaderboard.
We keep **one Firebase project** (there is no separate prod project — see
@ -27,7 +27,7 @@ Existing production data under `mw-*` is untouched; `mw-test-*` starts empty.
| Piece | Now | After |
| --- | --- | --- |
| Namespace | hardcoded `'mw'` in [app/main.js](../src/main.js) | `import.meta.env.VITE_LB_NAMESPACE` |
| Namespace | hardcoded `'mw'` in [app/main.js](../app/main.js) | `import.meta.env.VITE_LB_NAMESPACE` |
| Dev writes | into prod `mw-*` | into `mw-test-*` |
| Rules | only `mw-*` matches | any `mw` / `mw-test` namespace |
| Indexes | `games` collection group | unchanged (already covers all namespaces) |
@ -37,14 +37,14 @@ Existing production data under `mw-*` is untouched; `mw-test-*` starts empty.
### 1. Add the namespace env var
- [app/.env.development](../.env.development): add `VITE_LB_NAMESPACE=mw-test`
- [app/.env.example](../.env.example): document `VITE_LB_NAMESPACE`
- [app/.env.development](../app/.env.development): add `VITE_LB_NAMESPACE=mw-test`
- [app/.env.example](../app/.env.example): document `VITE_LB_NAMESPACE`
- Netlify (production build): set `VITE_LB_NAMESPACE=mw` **and** the same
`VITE_FIREBASE_*` values as dev (same project).
### 2. Read it in the app
In [app/main.js](../src/main.js), replace the hardcoded namespace. Default to the
In [app/main.js](../app/main.js), replace the hardcoded namespace. Default to the
**test** namespace so a missing/misconfigured var can never write to production:
```js
@ -59,7 +59,7 @@ of a missing var is an empty test board, never prod pollution.)
### 3. Generalize the security rules
Rewrite [firestore.rules](../firebase/firestore.rules) so a rule matches by the namespace
Rewrite [firestore.rules](../firestore.rules) so a rule matches by the namespace
*suffix* instead of a literal `mw-` prefix. Firestore ORs all matching rules, so
one set of generic blocks covers `mw`, `mw-test`, and any future namespace.
Note: `{ns}-scores/{cat}/games/{id}`, `{ns}-all/{id}/games/{s}` and
@ -117,7 +117,7 @@ The regex `mw(-[a-z]+)?-scores` matches `mw-scores` and `mw-test-scores`
Time windows are rolling (`time_stamp >=`) and all-time sorts by `score`, so the
queries use Firestore's automatic single-field indexes.
[firestore.indexes.json](../firebase/firestore.indexes.json) is empty — nothing to add for
[firestore.indexes.json](../firestore.indexes.json) is empty — nothing to add for
any namespace.
### 5. Seed the test config doc
@ -139,8 +139,8 @@ Both aliases target the one project we actually have:
### 7. Deploy & verify
1. `pnpm exec firebase deploy --only firestore:rules,firestore:indexes --project prod`
2. Set the Netlify env vars via CLI: `pnpm exec netlify env:import .env.production` (or `netlify env:set VITE_LB_NAMESPACE mw` + each `VITE_FIREBASE_*`).
1. `npx firebase deploy --only firestore:rules,firestore:indexes --project prod`
2. Set the Netlify env vars (`VITE_LB_NAMESPACE=mw` + `VITE_FIREBASE_*`).
3. `pnpm dev`, win a game → confirm the write lands in **`mw-test-scores`**, and
the prod `mw-scores` is untouched (Firestore console).
4. Confirm reads on both `mw-scores` and `mw-test-scores` succeed under the new
@ -155,6 +155,6 @@ Both aliases target the one project we actually have:
## Rollback
Set `VITE_LB_NAMESPACE=mw` everywhere (or revert [app/main.js](../src/main.js) to
Set `VITE_LB_NAMESPACE=mw` everywhere (or revert [app/main.js](../app/main.js) to
the hardcoded `'mw'`) to return to shared collections. The generalized rules are
a superset of the old ones, so they stay valid either way.

View file

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

View file

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

View file

@ -0,0 +1,234 @@
# Leaderboard configuration reference
Complete reference for configuring `@cozy-games/leaderboard`. There are two ways
to use it — a **JS API** and a **web component** — and they share the same option
set. The element simply forwards options to the service under the hood.
- [Options at a glance](#options-at-a-glance)
- [JS API — `LeaderBoardService`](#js-api--leaderboardservice)
- [Web component — `<cozy-leaderboard>`](#web-component--cozy-leaderboard)
- [Adapters (backend config)](#adapters-backend-config)
- [Localization (i18n)](#localization-i18n)
- [Score entry shape](#score-entry-shape)
- [Where config is read / precedence](#where-config-is-read--precedence)
---
## Options at a glance
| option | type | default | purpose |
| --- | --- | --- | --- |
| `adapter` | object | — (**required**) | storage backend — a `FirebaseAdapter`, `SupabaseAdapter`, or your own |
| `scoreOrder` | `'asc'` \| `'desc'` | `'asc'` | `asc` = lower is better (time); `desc` = higher is better (points) |
| `formatScore` | `(value) => string` | `String(value)` | how a score is displayed |
| `qualifies` | `(entry) => boolean` | server `passingStatus` vs `entry.status` | whether a submitted entry is ranked |
| `labels` | object | `{today,week,month,all}` English | tab labels, keyed by `today`/`week`/`month`/`all` |
| `tooltips` | object | `Last 24 hours` / `Last 7 days` / `Last 30 days` / `All time` | tab hover text, keyed by `today`/`week`/`month`/`all` |
| `emptyMessages` | `string[]` | built-in pool | messages for an empty window (one picked at random) |
| `loadingText` | string | `Loading…` | shown while a window loads |
| `errorText` | string | `Leaderboard unavailable right now.` | shown when a window fails to load |
| `anonymousName` | string | `Anonymous` | fallback display name for entries without one |
Every user-facing string (`labels`, `emptyMessages`, `loadingText`, `errorText`,
`anonymousName`) is an option — see [Localization](#localization-i18n).
---
## JS API — `LeaderBoardService`
```js
import { LeaderBoardService } from '@cozy-games/leaderboard/leader-board.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
const service = new LeaderBoardService({
adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'mw' }),
scoreOrder: 'asc',
formatScore: ms => prettyTime(ms)
})
```
### Methods
- **`render(category, title, duration?) → Promise<HTMLElement>`**
Builds the board (duration tabs + list) for `category` with heading `title`.
`duration` is one of `today` (default) · `week` · `month` · `all`. When omitted
on a re-render, the last-selected tab is kept. Append the returned element to
the page.
Windows are **rolling** and strictly nested: `today` = last 24h, `week` = last
7 days, `month` = last 30 days, `all` = everything — each shows the top scores
whose `time_stamp` falls in the window. Each tab's hover text (`tooltips`)
spells this out.
- **`submit(entry) → Promise`**
Records a finished game (archives it; ranks it if it `qualifies`). See
[Score entry shape](#score-entry-shape).
---
## Web component — `<cozy-leaderboard>`
Configure the backend once in JS, then compose the UI in HTML.
```html
<script type="module">
import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
configureLeaderboard({
adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'mw' }),
scoreOrder: 'asc',
format: 'time'
})
</script>
<cozy-leaderboard category="beginner" title="Best Times" format="time"></cozy-leaderboard>
```
### `configureLeaderboard(options)`
Sets shared defaults for **every** `<cozy-leaderboard>` on the page. Accepts all
the [options above](#options-at-a-glance), plus `format` (a preset shorthand for
`formatScore` — see below). Call it once at startup. Calling it again re-renders
mounted elements.
### Attributes
| attribute | values | purpose |
| --- | --- | --- |
| `category` | string | which board to show (e.g. a difficulty/level id) |
| `title` | string | heading text above the tabs |
| `duration` | `today` \| `week` \| `month` \| `all` | initial tab (default `today`) |
| `score-order` | `asc` \| `desc` | overrides the shared `scoreOrder` |
| `format` | `time` \| `number` \| `plain` | score display preset (below) |
Attributes are reactive: change `category`/`title` at runtime and the board
re-renders, keeping the selected duration tab.
### `format` presets
| `format` | result |
| --- | --- |
| `time` | milliseconds → `mm:ss.t` (e.g. `01:34.6`) |
| `number` / `plain` / unset | `String(value)` |
For anything else, set a `formatScore` function (via `configureLeaderboard` or the
per-element property).
### Override properties & method
Set these JS properties on an element to override the shared config for that one
element (useful for multi-board or multi-backend pages):
`adapter`, `formatScore`, `qualifies`, `labels`, `emptyMessages`, `loadingText`,
`errorText`, `anonymousName`.
```js
const el = document.querySelector('cozy-leaderboard')
el.emptyMessages = ['¡Sé el primero!']
el.formatScore = pts => `${pts} pts`
```
- **`submit(entry)`** — submit a finished game through this element's service:
```js
el.submit({ name: 'Ayo', playerId: 'abc', score: 4200, category: 'beginner', time_stamp: new Date(), status: 'win' })
```
---
## Adapters (backend config)
### `FirebaseAdapter`
```js
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator })
```
| option | type | default | purpose |
| --- | --- | --- | --- |
| `firebaseConfig` | object | — | Firebase web config (public; access governed by rules) |
| `namespace` | string | `lb` | collection prefix → `{ns}-scores`, `{ns}-all`, `{ns}-config` |
| `emulator` | `{ host?, port? }` | — | point at a local Firestore emulator (dev only); host `127.0.0.1`, port `8080` |
Needs the `firebase` peer dependency. Rolling-window and all-time queries use
Firestore's automatic single-field indexes — no composite indexes to deploy.
### `SupabaseAdapter`
```js
import { createClient } from '@supabase/supabase-js'
import { SupabaseAdapter } from '@cozy-games/leaderboard/adapters/supabase.js'
new SupabaseAdapter({ client: createClient(url, anonKey), namespace: 'mw' })
```
| option | type | default | purpose |
| --- | --- | --- | --- |
| `client` | supabase-js client | — | you construct it (package takes no supabase dep) |
| `namespace` | string | `lb` | table prefix → `{ns}_scores`, `{ns}_archive`, `{ns}_config` |
See the [README](./README.md#supabase-postgres) for the SQL schema.
---
## Localization (i18n)
This package ships **English defaults only** — localization is your app's job.
Pass translated copy for every user-facing string:
```js
configureLeaderboard({
adapter,
labels: { today: 'Hoy', week: 'Semana', month: 'Mes', all: 'Histórico' },
emptyMessages: [
'¡Sé el primero en el marcador!',
'Aún no hay puntajes. ¡Reclama la cima!'
],
loadingText: 'Cargando…',
errorText: 'Marcador no disponible ahora.',
anonymousName: 'Anónimo'
})
```
The same keys work when constructing `LeaderBoardService` directly. To switch
languages at runtime, call `configureLeaderboard()` again with the new strings
(mounted elements re-render).
---
## Score entry shape
The object passed to `submit(entry)`:
```js
{
name, // string — display name (falls back to `anonymousName`)
playerId, // string — opaque id (e.g. a browser fingerprint)
score, // number — the ranked value (minesweeper: finish time in ms)
category, // string — which board (minesweeper: level id)
time_stamp, // Date — when the game finished (used to compute day/week/month)
status, // string, optional — read by the default `qualifies`
meta // object, optional — extra fields to store (e.g. { isMobile })
}
```
The package computes the `day`/`week`/`month` bucket keys from `time_stamp`
(UTC) before storing — you don't set those.
---
## Where config is read / precedence
- **JS API:** options are read when you construct `LeaderBoardService`.
- **Web component:** the element builds its service on first render (on connect),
reading — in order — its **own attribute/property**, then the shared
**`configureLeaderboard()`** value, then the **package default**:
```
per-element attribute/property > configureLeaderboard(...) > built-in default
```
The service is cached per element, so set overrides **before** the element
connects (or set the property and clear the element's `_svc` to force a
rebuild). `score-order` and `format` are read from attributes; the function/
array/string overrides are read from properties.

248
leaderboard/README.md Normal file
View file

@ -0,0 +1,248 @@
# @cozy-games/leaderboard
A generic, framework-free leaderboard with **Today / Week / Month / All Time**
time windows. It is game-agnostic (the ranked value is a plain `score`) and
**backend-agnostic**: all storage I/O goes through an injected *adapter*, so you
can run it on Firebase, Supabase, or anything else.
```
LeaderBoardService (core: windows, sorting, rendering, submit)
│ uses
Adapter ── FirebaseAdapter | SupabaseAdapter | your own
```
## Quick start
```js
import { LeaderBoardService } from '@cozy-games/leaderboard/leader-board.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
const service = new LeaderBoardService({
adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'mw' }),
scoreOrder: 'asc', // 'asc' = lower is better (time); 'desc' = points
formatScore: ms => prettyTime(ms) // how a score is displayed
})
// render into the page (Today tab by default)
document.body.append(await service.render('beginner', 'Best Times'))
// submit a finished game
service.submit({
name: 'Ayo',
playerId: 'browser-abc',
score: 4200,
category: 'beginner',
time_stamp: new Date(),
status: 'win', // optional; used by default qualifier
meta: { isMobile: false } // optional extras
})
```
### Service options
| option | required | meaning |
| --- | --- | --- |
| `adapter` | yes | storage backend (see below) |
| `scoreOrder` | no | `'asc'` (default) or `'desc'` |
| `formatScore` | no | `(value) => string`; defaults to `String(value)` |
| `qualifies` | no | `(entry) => boolean`; default ranks entries whose `status` matches the server config's `passingStatus` (all entries if none) |
| `labels` | no | override tab labels, keyed by `today`/`week`/`month`/`all` |
| `tooltips` | no | override tab hover text, keyed by `today`/`week`/`month`/`all` |
| `emptyMessages` | no | `string[]` shown when a window has no scores (one picked at random) |
| `loadingText` | no | text shown while a window loads (default `Loading…`) |
| `errorText` | no | text shown when a window fails to load |
| `anonymousName` | no | fallback display name for entries without one (default `Anonymous`) |
**Localization** lives with you, not this package: every user-facing string —
tab `labels`, `emptyMessages`, `loadingText`, `errorText`, `anonymousName` — is an
option, so you pass your translated copy (from `<cozy-leaderboard>` use it via
`configureLeaderboard({ ... })`). The package only ships English defaults.
📖 **Full reference:** every option, the web-component attributes/properties,
adapters, i18n, and precedence rules are documented in
[CONFIGURATION.md](./CONFIGURATION.md).
## Use it as a web component
Prefer to **compose the UI in your HTML** instead of wiring it in JavaScript? The
package ships a `<cozy-leaderboard>` custom element. Configure the backend once,
then drop the element anywhere in your markup — the board (duration tabs + list)
renders itself.
```html
<!-- 1. configure the backend once (the only JS you need) -->
<script type="module">
import { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
configureLeaderboard({
adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'mw' })
})
</script>
<!-- 2. compose the UI declaratively, anywhere -->
<cozy-leaderboard category="beginner" title="Best Times" format="time"></cozy-leaderboard>
```
No build step required — it works straight from a CDN too:
```html
<script type="module">
import { configureLeaderboard } from 'https://esm.sh/@cozy-games/leaderboard/leaderboard-element.js'
import { FirebaseAdapter } from 'https://esm.sh/@cozy-games/leaderboard/adapters/firebase.js'
configureLeaderboard({ adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'mw' }) })
</script>
<cozy-leaderboard category="expert" title="Legends" format="time" score-order="asc"></cozy-leaderboard>
```
### Attributes
| attribute | meaning |
| --- | --- |
| `category` | which board to show (e.g. a difficulty/level id) |
| `title` | heading text above the tabs |
| `duration` | initial tab: `today` (default) · `week` · `month` · `all` |
| `score-order` | `asc` (lower is better, default) or `desc` (higher is better) |
| `format` | score display preset: `time` (ms → `mm:ss.t`), `number`, or `plain` |
Change `category`/`title` at runtime and the board re-renders reactively while
**keeping the selected duration tab** — e.g. `el.setAttribute('category', 'expert')`.
### Properties & methods
- `.adapter`, `.formatScore`, `.qualifies` — per-element overrides of the shared
`configureLeaderboard()` defaults (for advanced/multi-backend pages).
- `.submit(entry)` — submit a finished game through this element's service:
```js
document.querySelector('cozy-leaderboard').submit({
name: 'Ayo', playerId: 'browser-abc', score: 4200,
category: 'beginner', time_stamp: new Date(), status: 'win'
})
```
## Why a web component?
`<cozy-leaderboard>` is built on **[web-component-base](https://github.com/ayo-run/wcb)**
(WCB) — a zero-dependency, tiny base class for reactive custom elements
([webcomponent.io](https://webcomponent.io)). Shipping the leaderboard as a web
component means:
- **Framework-agnostic & native.** A custom element is part of the platform, so
it drops into React, Vue, Svelte, Angular, Astro, or a plain HTML file
identically — no per-framework wrappers.
- **Declarative composition.** You place the board where it belongs in your
markup and set attributes, instead of imperative `createElement`/`append`
plumbing in JS.
- **Reactive.** Change an attribute (`category`, `title`, …) and the element
updates itself — the reactivity model WCB is built around.
- **Encapsulated & reusable.** One tag, one contract; reuse it across pages and
projects without copy-pasting wiring.
- **No build step.** Works today in every modern browser, straight from a CDN —
no compilers, transpilers, or polyfills. That "just use the platform" ethos is
exactly what WCB is for.
Want to author your own custom elements this way? Check out
**[webcomponent.io](https://webcomponent.io)** and
**[web-component-base](https://github.com/ayo-run/wcb)**.
## Choosing a backend
### Firebase (Firestore)
```js
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
const adapter = new FirebaseAdapter({ firebaseConfig, namespace: 'mw' })
```
Needs the `firebase` peer dependency. Uses collections
`{ns}-scores/{category}/games`, `{ns}-all/{playerId}/games`,
`{ns}-config/configuration`. Time windows are **rolling** (`time_stamp >= cutoff`)
and all-time sorts by `score`, so only Firestore's automatic single-field indexes
are needed — no composite indexes to deploy.
For local development, pass `emulator` to run against the
[Firestore emulator](https://firebase.google.com/docs/emulator-suite) — no cloud,
no deploy:
```js
new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator: { host: '127.0.0.1', port: 8080 } })
```
### Supabase (Postgres)
```js
import { createClient } from '@supabase/supabase-js'
import { SupabaseAdapter } from '@cozy-games/leaderboard/adapters/supabase.js'
const client = createClient(url, anonKey)
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
```
You construct the supabase client yourself (the package takes no supabase
dependency). Create these tables (namespace `mw` shown):
```sql
create table mw_scores (
id bigint generated always as identity primary key,
name text not null check (char_length(name) <= 24),
player_id text,
score numeric not null,
category text not null,
time_stamp timestamptz not null default now(),
day text not null, -- '2026-07-03'
week text not null, -- '2026-W27'
month text not null, -- '2026-07'
meta jsonb
);
-- rolling-window lookups (time_stamp >= cutoff) + top-N by score
create index on mw_scores (category, time_stamp);
create index on mw_scores (category, score);
create table mw_archive (
id bigint generated always as identity primary key,
player_id text,
score numeric,
category text,
time_stamp timestamptz not null default now(),
meta jsonb
);
create table mw_config (
id text primary key, -- 'configuration'
passingStatus text,
message text
);
-- public read on the boards, append-only inserts on scores
alter table mw_scores enable row level security;
create policy read_scores on mw_scores for select using (true);
create policy write_scores on mw_scores for insert with check (score is not null);
alter table mw_config enable row level security;
create policy read_config on mw_config for select using (true);
```
Note: the `day`/`week`/`month` columns are still written (kept as metadata) but
windows query by `time_stamp`. For a `desc` game, add `(category, score desc)`.
## Writing your own adapter
An adapter is any object implementing:
```ts
getConfig(): Promise<object | undefined>
listScores(q: {
category: string,
since: Date | null, // rolling cutoff (time_stamp >= since); null = All Time
order: 'asc' | 'desc',
limit: number
}): Promise<Array<{ name: string, score: number, ...}>>
addScore(category: string, entry: object): Promise<void>
archive?(entry: object): Promise<void> // optional personal history
```
`listScores` must return the top `limit` records for the window, best-first,
exposing at least `name` and `score`. The core builds `entry` and the query
descriptor; the adapter only performs raw reads/writes. That's the whole
contract — implement it against any store.

View file

@ -0,0 +1,75 @@
import { initializeApp } from 'firebase/app'
import {
getFirestore, connectFirestoreEmulator,
doc, getDoc, getDocs, setDoc, collection, query, where, orderBy, limit
} from 'firebase/firestore/lite'
/**
* Firestore storage adapter for LeaderBoardService. Requires the `firebase`
* peer dependency. Collections are namespaced: `{ns}-scores/{category}/games`,
* `{ns}-all/{playerId}/games`, and `{ns}-config/configuration`.
*/
export class FirebaseAdapter {
/**
* @param {Object} options
* @param {Object} options.firebaseConfig - Firebase app config (public; access governed by security rules)
* @param {String} [options.namespace] - collection prefix
* @param {{ host?: string, port?: number }} [options.emulator] - point at a local Firestore emulator (dev/test only)
*/
constructor(options = {}) {
this.namespace = options.namespace || 'lb'
const app = initializeApp(options.firebaseConfig)
this.store = getFirestore(app)
if (options.emulator) {
const { host = '127.0.0.1', port = 8080 } = options.emulator
connectFirestoreEmulator(this.store, host, port)
}
}
async getConfig() {
const ref = doc(this.store, `${this.namespace}-config`, 'configuration')
const snapshot = await getDoc(ref)
return snapshot.data()
}
/**
* @param {Object} q - { category, since, order, limit }
* @returns {Promise<Object[]>} plain score records, best first
*/
async listScores(q) {
const games = collection(this.store, `${this.namespace}-scores`, q.category, 'games')
if (q.since) {
// Rolling window: Firestore requires the inequality field to sort first,
// so fetch the in-window rows (newest first, capped) and rank by score
// client-side. The cap is a safety bound for busy windows.
const snapshot = await getDocs(query(
games, where('time_stamp', '>=', q.since), orderBy('time_stamp', 'desc'), limit(500)
))
const rows = snapshot.docs.map(d => d.data())
rows.sort((a, b) => q.order === 'desc' ? b.score - a.score : a.score - b.score)
return rows.slice(0, q.limit)
}
const snapshot = await getDocs(query(games, orderBy('score', q.order), limit(q.limit)))
return snapshot.docs.map(d => d.data())
}
async addScore(category, entry) {
const ref = doc(collection(this.store, `${this.namespace}-scores`, category, 'games'))
await setDoc(ref, entry)
}
async archive(entry) {
const sessionId = new Date().toDateString().replace(/\s/g, '_')
const gameId = new Date().toTimeString().replace(/\s/g, '_')
const data = {}
data[gameId] = {
score: entry.score,
category: entry.category,
time_stamp: entry.time_stamp,
...(entry.meta || {})
}
const ref = doc(this.store, `${this.namespace}-all`, entry.playerId, 'games', sessionId)
await setDoc(ref, data, { merge: true })
}
}

View file

@ -0,0 +1,79 @@
/**
* Supabase (Postgres) storage adapter for LeaderBoardService.
*
* You pass in a supabase-js client you constructed yourself, so this package
* takes NO supabase dependency:
*
* import { createClient } from '@supabase/supabase-js'
* const client = createClient(url, anonKey)
* const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
*
* Expects three tables (see leaderboard/README.md for the SQL + row-level
* security): `{ns}_scores`, `{ns}_archive`, `{ns}_config`. Column names are
* snake_case; this adapter maps the generic entry's `playerId` <-> `player_id`.
*/
export class SupabaseAdapter {
/**
* @param {Object} options
* @param {Object} options.client - a supabase-js client
* @param {String} [options.namespace] - table prefix
*/
constructor(options = {}) {
this.client = options.client
this.namespace = options.namespace || 'lb'
}
async getConfig() {
const { data } = await this.client
.from(`${this.namespace}_config`)
.select('*')
.eq('id', 'configuration')
.maybeSingle()
return data || undefined
}
/**
* @param {Object} q - { category, since, order, limit }
* @returns {Promise<Object[]>} plain score records (must expose `name`, `score`)
*/
async listScores(q) {
let builder = this.client
.from(`${this.namespace}_scores`)
.select('*')
.eq('category', q.category)
// Rolling window: Postgres does the range + order + limit server-side.
if (q.since) builder = builder.gte('time_stamp', q.since.toISOString())
const { data, error } = await builder
.order('score', { ascending: q.order === 'asc' })
.limit(q.limit)
if (error) throw error
return data || []
}
async addScore(category, entry) {
const { error } = await this.client.from(`${this.namespace}_scores`).insert({
name: entry.name,
player_id: entry.playerId,
score: entry.score,
category: entry.category,
time_stamp: entry.time_stamp,
day: entry.day,
week: entry.week,
month: entry.month,
meta: entry.meta || null
})
if (error) throw error
}
async archive(entry) {
const { error } = await this.client.from(`${this.namespace}_archive`).insert({
player_id: entry.playerId,
score: entry.score,
category: entry.category,
time_stamp: entry.time_stamp,
meta: entry.meta || null
})
if (error) throw error
}
}

302
leaderboard/leader-board.js Normal file
View file

@ -0,0 +1,302 @@
import { buckets } from '../utils/date-bucket/date-bucket.js'
const DAY_MS = 24 * 60 * 60 * 1000
/**
* The four time windows are ROLLING: each shows entries from the last `ms`
* milliseconds (strictly nested 24h 7d 30d all), sorted by score.
* `ms: null` is the all-time view (no time filter). `title` is the hover tooltip
* that spells out the window.
*/
const DURATIONS = [
{ id: 'today', label: 'Today', ms: DAY_MS, title: 'Last 24 hours' },
{ id: 'week', label: 'Week', ms: 7 * DAY_MS, title: 'Last 7 days' },
{ id: 'month', label: 'Month', ms: 30 * DAY_MS, title: 'Last 30 days' },
{ id: 'all', label: 'All Time', ms: null, title: 'All time' }
]
/**
* Default empty-state messages challenging but friendly, and game-agnostic.
* One is picked at random each render. Override per app via the `emptyMessages`
* option (see below) so localization stays out of this package.
*/
const EMPTY_MESSAGES = [
'Be the first to enter the leader board!',
'No scores yet — claim the top spot!',
'This board is wide open. Conquer it!',
'No champions here yet. Will it be you?',
'Blank slate — set the score to beat!',
'Nobody\'s here yet. Be the first!',
'The top spot is up for grabs. Take it!',
'Empty board. Time to make your mark!'
]
/**
* Generic, game- AND backend-agnostic leaderboard. Nothing here knows about
* minesweeper or Firebase: the ranked value is a plain `score`, sorted in the
* configured direction and displayed through an injected formatter, while all
* storage I/O is delegated to an injected adapter (see adapters/). Games wire
* their specifics through the constructor options.
*
* An adapter implements:
* - getConfig(): Promise<Object|undefined>
* - listScores({ category, field, value, order, limit }): Promise<Object[]>
* - addScore(category, entry): Promise<void>
* - archive(entry): Promise<void> // optional personal history
*/
export class LeaderBoardService {
/**
* @param {Object} options
* @param {Object} options.adapter - storage backend (e.g. FirebaseAdapter, SupabaseAdapter)
* @param {'asc'|'desc'} [options.scoreOrder] - 'asc' = lower is better (e.g. time), 'desc' = higher is better
* @param {(value: number) => string} [options.formatScore] - display formatter for a score
* @param {(entry: Object) => boolean} [options.qualifies] - whether an entry is ranked; defaults to server passingStatus vs entry.status
* @param {Object} [options.labels] - optional tab-label overrides keyed by duration id
* @param {Object} [options.tooltips] - optional tab hover-text overrides keyed by duration id
* @param {string[]} [options.emptyMessages] - empty-state messages (one picked at random); localize here
* @param {string} [options.loadingText] - shown while a window loads
* @param {string} [options.errorText] - shown when a window fails to load
* @param {string} [options.anonymousName] - fallback display name for entries without one
*/
constructor(options = {}) {
this.adapter = options.adapter
this.scoreOrder = options.scoreOrder === 'desc' ? 'desc' : 'asc'
this.formatScore = options.formatScore || (value => String(value))
this.qualifies = options.qualifies || (entry => this._defaultQualifies(entry))
this.labels = options.labels || {}
this.tooltips = options.tooltips || {}
// User-facing strings — override to localize; the package ships English defaults.
this.emptyMessages = (Array.isArray(options.emptyMessages) && options.emptyMessages.length)
? options.emptyMessages
: EMPTY_MESSAGES
this.loadingText = options.loadingText || 'Loading…'
this.errorText = options.errorText || 'Leaderboard unavailable right now.'
this.anonymousName = options.anonymousName || 'Anonymous'
Promise.resolve(this.adapter.getConfig())
.then(config => {
this.configuration = config
})
.catch(() => {})
}
/**
* 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
}
_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
* 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.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)
}
}

View file

@ -0,0 +1,164 @@
import { WebComponent } from 'web-component-base'
import { LeaderBoardService } from './leader-board.js'
/**
* `<cozy-leaderboard>` a custom element that lets a developer compose the
* leaderboard UI declaratively in HTML instead of wiring it in JavaScript.
*
* Built on `web-component-base` (WebComponent base class + lifecycle hooks). It
* extends WCB for the custom-element scaffolding (onInit/onDestroy, connect
* handling) and delegates all inner DOM the duration tabs and the ranked
* list to the existing LeaderBoardService, which already builds and manages
* that DOM (including in-place tab swaps).
*
* Composition lives in HTML attributes; the storage backend (adapter) is set
* once in JS via configureLeaderboard(), because env-var config can't live in
* static markup.
*
* <cozy-leaderboard category="beginner" title="Best Times" format="time"></cozy-leaderboard>
*/
// Shared config for every element on the page, set once via configureLeaderboard().
let sharedConfig = {}
const instances = new Set()
/**
* Configure the backend + defaults for all <cozy-leaderboard> elements. Call
* once at startup, after building your adapter (Firebase/Supabase/). User-facing
* strings (labels, emptyMessages, loadingText, errorText, anonymousName) can be
* passed here to localize without changing the package.
* @param {Object} options - { adapter, scoreOrder?, format?, formatScore?, qualifies?, labels?, emptyMessages?, loadingText?, errorText?, anonymousName? }
*/
export function configureLeaderboard(options = {}) {
sharedConfig = { ...sharedConfig, ...options }
instances.forEach(el => el._mount())
}
const clean = (str, separator) => (str === '00' ? '' : `${str}${separator}`)
// ms -> pretty time, e.g. 4200 -> "04.2" (mirrors utils/timer pretty()).
const prettyTime = ms => {
if (!ms) return '0'
const milliseconds = parseInt((ms % 1000) / 100)
const seconds = Math.floor((ms / 1000) % 60)
const minutes = Math.floor((ms / (1000 * 60)) % 60)
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24)
const hh = hours < 10 ? `0${hours}` : `${hours}`
const mm = minutes < 10 ? `0${minutes}` : `${minutes}`
const ss = seconds < 10 ? `0${seconds}` : `${seconds}`
return `${clean(hh, ':')}${clean(mm, ':')}${clean(ss, '.')}${milliseconds}`
}
// Built-in score formatters, selectable via the `format` attribute.
const FORMATTERS = { time: prettyTime }
const resolveFormat = name => FORMATTERS[name]
export class CozyLeaderboard extends WebComponent {
static get observedAttributes() {
return ['category', 'title', 'duration', 'score-order', 'format']
}
// WCB lifecycle: register/unregister so configureLeaderboard() can re-render.
onInit() {
instances.add(this)
}
onDestroy() {
instances.delete(this)
}
// Attributes are read directly (getAttribute) rather than through WCB's typed
// props proxy, so optional/empty values never trip its type enforcement.
attributeChangedCallback(name, previousValue, currentValue) {
if (previousValue === currentValue || !this.isConnected) return
this._mount(name === 'duration' ? (currentValue || undefined) : undefined)
}
// WCB calls render() on connect; we treat it as "(re)mount the board".
render() {
this._mount()
}
// Per-element override properties (public): adapter, formatScore, qualifies,
// labels, emptyMessages, loadingText, errorText, anonymousName. Each falls back
// to the shared configureLeaderboard() value, then the package default. Set
// them before the element connects (or clear `_svc` to force a rebuild).
_service() {
if (this._svc) return this._svc
const adapter = this.adapter || sharedConfig.adapter
if (!adapter) return null
const formatScore = this.formatScore
|| resolveFormat(this.getAttribute('format'))
|| sharedConfig.formatScore
|| resolveFormat(sharedConfig.format)
|| String
this._svc = new LeaderBoardService({
adapter,
scoreOrder: this.getAttribute('score-order') || sharedConfig.scoreOrder || 'asc',
formatScore,
qualifies: this.qualifies || sharedConfig.qualifies,
// User-facing strings — pass through so apps localize without touching the package.
labels: this.labels || sharedConfig.labels,
tooltips: this.tooltips || sharedConfig.tooltips,
emptyMessages: this.emptyMessages || sharedConfig.emptyMessages,
loadingText: this.loadingText || sharedConfig.loadingText,
errorText: this.errorText || sharedConfig.errorText,
anonymousName: this.anonymousName || sharedConfig.anonymousName
})
return this._svc
}
/**
* (Re)render the board. The first successful mount honors the author's
* `duration` attribute; later mounts preserve the service's remembered
* duration (so switching category keeps the selected tab) unless a duration
* is passed explicitly.
*/
_mount(durationArg) {
if (!this.isConnected) return
const service = this._service()
if (!service) {
this.replaceChildren(this._message('Leaderboard not configured.'))
return
}
let duration = durationArg
if (duration === undefined && !this._mounted) {
duration = this.getAttribute('duration') || undefined
}
const token = (this._token || 0) + 1
this._token = token
service.render(this.getAttribute('category') || '', this.getAttribute('title') || '', duration)
.then(el => {
if (this._token !== token) return
this.replaceChildren(el)
this._mounted = true
})
.catch(() => {
if (this._token !== token) return
this.replaceChildren(this._message('Leaderboard unavailable right now.'))
})
}
/**
* Submit a finished game through this element's service keeps score
* submission a one-liner from the host app.
* @param {Object} entry
*/
submit(entry) {
const service = this._service()
if (service) return service.submit(entry)
}
_message(text) {
const em = document.createElement('em')
em.innerText = text
return em
}
}
if (!customElements.get('cozy-leaderboard')) {
customElements.define('cozy-leaderboard', CozyLeaderboard)
}

39
leaderboard/package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "@cozy-games/leaderboard",
"version": "0.0.1",
"description": "Generic, game-agnostic Firestore leaderboard with time-windowed views",
"author": "Ayo Ayco",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/ayo-run/mnswpr"
},
"main": "leader-board.js",
"exports": {
".": {
"default": "./dist/leader-board.js"
},
"./dist/*": {
"default": "./dist/*"
},
"./*": {
"default": "./*"
}
},
"files": [
"./*",
"./dist"
],
"dependencies": {
"web-component-base": "^4.1.2"
},
"peerDependencies": {
"firebase": "^12.11.0"
},
"peerDependenciesMeta": {
"firebase": {
"optional": true
}
},
"license": "BSD-2-Clause"
}

View file

@ -0,0 +1,15 @@
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: {
entry: resolve(import.meta.dirname, './leader-board.js'),
name: 'leaderboard',
fileName: 'leader-board'
},
rollupOptions: {
external: [/^firebase/]
}
}
})

24
lib/LICENSE Normal file
View file

@ -0,0 +1,24 @@
BSD 2-Clause License
Copyright (c) 2019, Ayo Ayco
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

102
lib/README.md Normal file
View file

@ -0,0 +1,102 @@
# Play Minesweeper Online for Free
[![Netlify Status](https://api.netlify.com/api/v1/badges/172478bd-afc5-4e47-95ba-d9ab814248fb/deploy-status)](https://app.netlify.com/sites/mnswpr/deploys)
Play it here: [mnswpr.com](https://mnswpr.com). This is the classic game **Minesweeper** built with vanilla web technologies (i.e., no framework dependency).
<!-- TODO: replace with the actual screenshot/GIF once supplied (confirm final path/filename) -->
![mnswpr gameplay](app/public/screenshot.png)
Technology Stack: HTML, JS, and CSS; [Google Firebase](https://firebase.google.com) for leader board store; [Netlify](https://netlify.com) for hosting
## Usage
The web is a wonderful, free, and open platform to create and distribute value. You can use **mnswpr** in different ways:
- as a deployed [web app](https://mnswpr.com)
- as a [library](https://npmx.dev/package/@ayo-run/mnswpr) with `npm i @ayo-run/mnswpr`
- as a `web component` (coming soon).
Using it as a library takes only a few lines — mount it onto any element by `id`:
```js
import '@ayo-run/mnswpr/mnswpr.css'
import mnswpr from '@ayo-run/mnswpr'
const game = new mnswpr('app')
game.initialize()
```
## How to Play
The goal is to reveal every safe cell without detonating a mine. Your **first click is always safe**.
- **Left click** — reveal a cell
- **Right click** — flag / unflag a suspected mine
- **Left + right click together** (chording) — reveal the neighbors of a satisfied number
- **Touch** — tap to reveal, long-press to flag
## Tooling
The project has gone through years of existence. It started from 2019 when tooling was massively different. I have [modernized it](https://elk.zone/social.ayco.io/@ayo/116333804543330938) since and have witnessed how much easier and faster it is to build now - even without web frameworks or LLMs!
As of now the tooling I use are:
- [Vite](https://vite.dev/) for bundling and development server
- [Eslint](https://eslint.org) for JS linting & [CSS linting](https://eslint.org/blog/2025/02/eslint-css-support/)
- [ESLint Stylistic](https://eslint.style) for JS formatting
- [Husky](https://typicode.github.io/husky/) for git hooks
- [PNPM](https://pnpm.io/installation) for dependency & workspace management
- and a bunch of automation using scripts and Continuous Integration actions
Because a big part of this project's purpose is to track how the software development industry evolves — and because it has come a long way in modernizing along the way — I now also use it as a **playground for coding agents**. It's a small, framework-free, well-scoped codebase, which makes it a great sandbox to see how AI agents read, reason about, and change real code. To help them get their bearings quickly, the repo ships an [`AGENTS.md`](./AGENTS.md) describing the architecture and conventions.
## Development
To start development, you need [`node`](https://nodejs.org/en/download). I highly recommend [`pnpm`](https://pnpm.io/installation) to be used as well. Once you know you have this, you can do the following:
1. Install dependencies: `pnpm i`
2. Start the dev server: `pnpm run dev`
The rest of the everyday commands:
```bash
pnpm test # run the Vitest suite
pnpm lint # ESLint (JS + CSS)
pnpm lint:fix # ESLint with autofix
pnpm build # build the website
pnpm build:lib # build the publishable library
```
## Contributing
Contributions are welcome! See [`AGENTS.md`](./AGENTS.md) for the architecture, conventions, and release workflow before opening a pull request.
## You just want to play?
*👉 The live site is here: [mnswpr.com](https://mnswpr.com)*
## Background
One day, while working in my home office, I heard loud and fast mouse clicks coming from our bedroom. It's my wife, playing her favorite game (Minesweeper) on a crappy website full of advertisements.
I can't allow this, it's a security issue. 🤣
But it is also an opportunity.
I wanted to give her the same game, with a similar leader board she can dominate. And this is also a chance for me to dig deeper into vanilla JS.
Can I make a page with complex interactions (more on this later) without any library dependency?
## What I have learned:
1. JS is awesome ✨
1. We don't always *need* JS frameworks (or TS) ✨
1. Even subtle UI changes *can improve* user gameplay experience ✨
1. There's more ways to break your app than you are initially aware of ✨
1. Competition motivates users to use your app more ✨
1. Hash in bundled filenames helps avoid issues with browser caching (when shipping versions fast) ✨
## License
[BSD-2-Clause](./LICENSE)
---
_Just keep building._<br>
_A project by [Ayo](https://ayo.ayco.io)_

157
lib/TUTORIAL.md Normal file
View file

@ -0,0 +1,157 @@
# Build your own web browser game with `mnswpr`
Have you ever wondered how games on a web browser are built? Believe it or not, anything you see on a web browser can be built by anyone. That's why the web is so great: it is free and open for everyone to enjoy!
In this guide, we will use **mnswpr** as a simple building block for you to build your own browser game. I will walk you through the steps to create your own Minesweeper browser game from scratch.
If you want to skip to the ending, all the code are in this repository: [minimal-mnswpr](https://github.com/ayo-run/minimal-mnswpr)
First, let's go through the requirements.
## Requirements
It is assumed that you have some knowledge in HTML and JavaScript. You can easily read about this and play around examples online. Some knowledge on using a terminal and a text editor is also required.
You will need a computer with [node.js](https://nodejs.org/en/download).
If you are familiar with HTML and JavaScript, and has a computer with `node.js` installed... let's now start with the project setup!
## Project Setup
Open the terminal and confirm that you have `node.js`.
```bash
# verify the node.js version
node --version # Should print the version
```
Next, create a directory where we'll write some code for your game.
```bash
# on mac or linux
mkdir my-game
cd my-game
```
Once your terminal is in the new directory `my-game`, we will initialize the JavaScript project using the Node Package Manager or `npm`. Type the following on your terminal:
```bash
npm init
```
This will start the `npm` initialization interface, which will ask you some questions. You can think of what you want to answer, but if you want to go with the defaults, you can just press the `Enter` key repeatedly for each until the questions are done.
The last question will ask you if everything is OK:
```bash
Is this OK? (yes)
# Don't be shy, you can just press ENTER again
```
Next, we will add `vite` as a development tool for bundling and as a development server.
<details>
<summary>Additional info on Vite...</summary>
Making web pages work in different browsers often brings challenges brought about by differences in technological implementations and limitations. Vite helps us so that our code will work in different environments without us worrying about issues in compatibility and performance. </details><br />
```bash
npm i -D vite
```
Now that the JS project is initialized and we have a development environment with `vite`, we will install **mnswpr** as a dependency:
```bash
npm i @ayo-run/mnswpr
```
Finally, you can run the installed `vite` dev server by running the following:
```bash
# `npx` here is the execute command for npm
npx vite # will run the vite dev server
```
Vite will now show the address you can type to your browser to see your project. It will show something like this:
```bash
VITE v8.0.3 ready in 128 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help
```
You can then open the "Local" address (e.g., http://localhost:5173) on your browser.
Congratulations. You now have your project setup! It's time to write some code.
## Write Some Code
Believe it or not, you have done the hard part. Now we start the fun part: putting the parts of your game together!
There are mainly 3 kinds of code that work together in a web page: HTML, JavaScript or JS, and Cascading Style Sheets or CSS.
In this guide, we work mostly with HTML & JS to focus on the basics.
### The HTML
Using your favorite text editor, create a file named `index.html` with the following content:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Minesweeper Game</title>
<style>
html, body {
background-color: black;
color: white;
}
</style>
</head>
<body>
<h1>My Minesweeper Game</h1>
<div id="app"></div>
<script type="module" src="main.js"></script>
</body>
</html>
```
<details>
<summary>Additional info on `index.html`</summary>
The file name `index.html` is important. It is the default file that Web browsers look for in any given path/directory as the web page it will show.
</details>
<br />
If you have your browser opened to the Local address `vite` just showed earlier, you should see your very first web page with a title **My Minesweeper Game**
Exciting right? You can try editing the text inside `<h1>...</h1>` to see the web page change as well. :)
Take a second to read through the content of your `index.html`. The `<div>` element there with `id="app"` attribute will be where the game board will be rendered.
Now we just need JavaScript to do this. You will find the `<script>` tag that has the `src="main.js"` attribute, which means the web page is ready to load that JavaScript... but this file doesn't exist yet. So let's write the code for that.
### The JavaScript
Create a new file named `main.js` with the following content:
```js
/**
* main.js
*/
import '@ayo-run/mnswpr/mnswpr.css'
import mnswpr from '@ayo-run/mnswpr'
const game = new mnswpr('app')
game.initialize()
```
When you create this `main.js` file, the dev server will instantly update the web page for you and you should now see your minesweeper browser game!
---
_Just keep building._<br>
_A project by [Ayo](https://ayo.ayco.io)_

31
lib/levels.js Normal file
View file

@ -0,0 +1,31 @@
export const levels = {
beginner: {
rows: 9,
cols: 9,
mines: 10,
id: 'beginner',
name: 'Noobs'
},
intermediate: {
rows: 16,
cols: 16,
mines: 40,
id: 'intermediate',
name: 'Normies'
},
expert: {
rows: 16,
cols: 30,
mines: 99,
id: 'expert',
name: 'Torment'
},
nightmare: {
rows: 20,
cols: 30,
mines: 150,
id: 'nightmare',
name: 'Hell'
}
}

142
lib/mnswpr.css Normal file
View file

@ -0,0 +1,142 @@
#grid {
margin-left: auto;
margin-right: auto;
border-spacing: 0px;
border: 4px solid #c0c0c0;
background: #c0c0c0;
font-family: courier new;
}
#grid tr td span {
font-size: 16px;
font-weight: bold;
position: absolute;
margin: -8.5px 0 0 -4.5px;
}
#grid tr td {
width: 15px;
height: 15px;
max-width: 15px;
max-height: 15px;
min-width: 15px;
min-height: 15px;
text-align: center;
font-size: xx-small;
cursor: default;
padding: 0px;
background: #c0d0c0;
border: 1px solid #c0c0c0;
-moz-user-select: none; -webkit-user-select: none; -ms-user-select:none; user-select:none; -o-user-select:none;
}
#grid TR TD[data-status=default],
#grid TR TD[data-status=flagged] {
border: 1px outset #ececec;
padding: 0px;
}
#grid TR TD.mine {
background: #dd5050;
border: 1px inset orange;
padding: 0px;
}
#grid TR TD[data-value="1"] {
color: #0000ff !important;
}
#grid TR TD[data-value="2"] {
color: #008100;
}
#grid TR TD[data-value="3"] {
color: #ff1300;
}
#grid TR TD[data-value="4"] {
color: #000083;
}
#grid TR TD[data-value="5"] {
color: #810500;
}
#grid TR TD[data-value="6"] {
color: #2a9494;
}
#grid TR TD[data-value="7"] {
color: #000000;
}
#grid TR TD[data-value="8"] {
color: #808080;
}
#grid TR TD.flag {
background: #80d080;
}
#grid TR TD.wrong span,
#grid TR TD.correct span {
font-size: xx-small !important;
}
#grid TR TD.wrong {
background: orange;
color: #FF0000;
}
#grid TR TD.correct {
background: #00FF00 !important;
color: #000000;
}
div.hint-wrapper {
max-width: 500px;
padding: 0 30px;
margin: 0 auto 30px;
}
span.hint,
ol.instructions li {
font-size: small;
text-align: left !important;
}
button {
margin: 12px;
}
h1 {
text-align: center;
font-weight: bold;
font-size: 32px;
background: -webkit-linear-gradient(90deg,#ff8a00,#e52e71);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
-moz-user-select: none; -webkit-user-select: none; -ms-user-select:none; user-select:none; -o-user-select:none;
}
h1 span {
text-transform: uppercase;
}
h1 sup {
font-size: small
}
/** mobile **/
@media only screen and (max-width: 823px) {
#grid tr td {
width: 20px;
height: 20px;
max-width: 20px;
max-height: 20px;
min-width: 20px;
min-height: 20px;
}
}

871
lib/mnswpr.js Normal file
View file

@ -0,0 +1,871 @@
// @ts-check
/**
* import styles for vite bundling
*/
import './mnswpr.css'
import {
LoggerService,
StorageService,
TimerService
} from '../utils/index.js'
import { levels } from './levels.js'
const TEST_MODE = false // set to true if you want to test the game with visual hints
const MOBILE_BUSY_DELAY = 250
const PC_BUSY_DELAY = 500
/**
* Create Minesweeper game board
* @param {String} appId
* @param {String} version
* @param {{
* levelChanged: (setting: any) => void,
* gameDone: (game: any) => void
* } | undefined } hooks
*/
const Minesweeper = function(appId, version, hooks = undefined) {
const _this = this
const storageService = new StorageService()
const timerService = new TimerService()
const loggerService = new LoggerService()
if (!hooks) {
hooks = {
levelChanged: () => {},
gameDone: () => {}
}
}
let grid = document.createElement('table')
grid.setAttribute('id', 'grid')
let flagsDisplay = document.createElement('span')
let smileyDisplay = document.createElement('span')
let timerDisplay = document.createElement('span')
let appElement = document.getElementById(appId)
if (!appElement) {
const body = document.getElementsByTagName('body')[0]
appElement = document.createElement('div')
body.append(appElement)
}
let isMobile = false
let isLeft = false
let isRight = false
let pressed = undefined
let bothPressed = undefined
let skip = false
let skipCondition = false
let mouseUpCallBackArray = [
clickCell,
middleClickCell
]
let mouseDownCallBackArray = [
highlightCell, // left-click down
highlightSurroundingCell, // middle-click down
rightClickCell // right-click down
]
let firstClick = true
let isBusy = false
let clickedCell
let cachedSetting = storageService.getFromLocal('setting')
let setting = cachedSetting || levels.beginner
if (TEST_MODE) {
setting = {
rows: 10,
cols: 10,
mines: 10,
id: 'test',
name: 'test'
}
}
storageService.saveToLocal('setting', setting)
let flagsCount = setting.mines
// Mine positions stored as a Set of numeric keys (row * cols + col) for O(1) lookup
let mines = new Set()
// Cells currently highlighted, so removeHighlights only resets these (<=9) instead of the whole grid
let highlightedCells = []
// Count of safe (non-mine) cells revealed, so the win check is O(1) instead of scanning the whole grid
let revealedSafeCount = 0
this.initialize = function() {
const headingElement = document.createElement('h1')
const gameBoard = document.createElement('div')
const versionLink = version === 'dev' ? 'dev' : `<a href="https://github.com/ayo-run/mnswpr/releases/tag/v${version}">v${version}</a>`
headingElement.innerHTML = `<span>Minesweeper</span><sup>${versionLink}</sup>`
document.title = `mnswpr [${version}]`
gameBoard.setAttribute('id', 'game-board')
gameBoard.append(initializeToolbar(), grid, initializeFootbar())
if(appElement) {
appElement.innerHTML = ''
appElement.append(headingElement, gameBoard)
}
initializeGlobalEventHandlers()
generateGrid({ initial: true })
}
function initializeFootbar() {
const footBar = document.createElement('div')
const resetButton = document.createElement('button')
resetButton.innerText = 'Reset'
resetButton.onmousedown = () => generateGrid()
footBar.append(resetButton)
let levelsDropdown = document.createElement('select')
levelsDropdown.onchange = () => updateSetting(levelsDropdown.value)
const levelsKeys = Object.keys(levels)
levelsKeys.forEach(key => {
const levelOption = document.createElement('option')
levelOption.value = levels[key].id
levelOption.text = levels[key].name
if (setting.id === levelOption.value) {
levelOption.selected = true
}
levelsDropdown.add(levelOption, null)
})
if (TEST_MODE) {
const testLevel = document.createElement('span')
testLevel.innerText = 'Test Mode'
footBar.append(testLevel)
} else {
footBar.append(levelsDropdown)
}
return footBar
}
function initializeToolbar() {
const toolbar = document.createElement('div')
const toolbarItems = []
const flagsWrapper = document.createElement('div')
flagsWrapper.append(flagsDisplay)
flagsWrapper.style.height = '20px'
toolbar.append(flagsWrapper)
toolbarItems.push(flagsWrapper)
const smileyWrapper = document.createElement('div')
smileyWrapper.append(smileyDisplay)
// toolbar.append(smileyWrapper);
// toolbarItems.push(smileyWrapper);
const timerWrapper = document.createElement('div')
timerWrapper.append(timerDisplay)
timerWrapper.style.height = '20px'
toolbar.append(timerWrapper)
toolbarItems.push(timerWrapper)
toolbar.style.cursor = 'pointer'
toolbar.style.padding = '10px 35px'
toolbar.style.display = 'flex'
toolbar.style.justifyContent = 'space-between'
toolbar.onmousedown = () => generateGrid()
return toolbar
}
/**
* Updates the game level
* @param {String} key
*/
function updateSetting(key) {
setting = levels[key]
storageService.saveToLocal('setting', setting)
generateGrid({ initial: true })
}
/**
* Generate the Game Board
* @param {{
* initial: boolean
* }} options - Game Board Options
*/
function generateGrid(options = { initial: false }) {
firstClick = true
grid.innerHTML = ''
grid.oncontextmenu = () => false
flagsCount = setting.mines
mines.clear()
highlightedCells = []
revealedSafeCount = 0
for (let i = 0; i < setting.rows; i++) {
let row = grid.insertRow(i)
row.oncontextmenu = () => false
for (let j=0; j<setting.cols; j++) {
let cell = row.insertCell(j)
initializeEventHandlers(cell)
if ('ontouchstart' in document.documentElement) {
isMobile = true
initializeTouchEventHandlers(cell)
}
let status = document.createAttribute('data-status')
status.value = 'default'
cell.setAttributeNode(status)
}
}
let gameStatus = document.createAttribute('game-status')
gameStatus.value = 'inactive'
grid.setAttributeNode(gameStatus)
if (appElement) {
appElement.style.margin = '0 auto'
}
/**
* TODO: add hook afterGridGenerated
* - for initializing the leaderboard
*/
if (options.initial)
hooks.levelChanged(setting)
timerService.initialize(timerDisplay)
updateFlagsCountDisplay()
addMines(setting.mines)
}
function setBusy() {
isBusy = true
if (isMobile) {
setTimeout(() => isBusy = false, MOBILE_BUSY_DELAY)
} else {
setTimeout(() => isBusy = false, PC_BUSY_DELAY)
}
}
function updateFlagsCountDisplay(count = flagsCount) {
if (grid.getAttribute('game-status') != 'win') {
flagsDisplay.innerHTML = `${count}`
return
}
flagsDisplay.innerHTML = '&#128513;'
}
/**
*
* @param {HTMLTableCellElement} cell
*/
function initializeTouchEventHandlers(cell) {
let ontouchleave = function() {
if (clickedCell === this) {
clickedCell = undefined
}
}
cell.addEventListener('touchleave', ontouchleave)
let ontouchend = function() {
endTouchTimer()
}
cell.addEventListener('touchend', ontouchend)
let ontouchstart = function(e) {
isMobile = true
if (!isBusy && typeof e === 'object') {
startTouchTimer(this)
}
}
cell.addEventListener('touchstart', ontouchstart)
}
/**
* Wire the document/window/grid-level input handlers. These don't depend on
* any individual cell, so they're set up once instead of being reassigned
* for every cell during grid generation.
*/
function initializeGlobalEventHandlers() {
document.onkeydown = function(e) {
if (e.keyCode == 32 || e.keyCode == 113) {
generateGrid()
if ('preventDefault' in e) {
e.preventDefault()
} else {
return false
}
}
resetMouseEventFlags()
}
window.onblur = function() {
resetMouseEventFlags()
}
grid.onmouseleave = function() {
removeHighlights()
}
document.oncontextmenu = () => false
document.onmouseup = function() {
resetMouseEventFlags()
}
document.onmousedown = function(e) {
isMobile = false
switch (e.button) {
case 0: pressed = 'left'; isLeft = true; break
case 1: pressed = 'middle'; break
case 2: isRight = true; break
}
}
}
function initializeEventHandlers(_cell) {
let cell = _cell
skip = false
skipCondition = false
resetMouseEventFlags()
// Set grid status to active on first click
cell.onmouseup = function(e) {
pressed = undefined
let dont = false
if (bothPressed) {
bothPressed = false
if (e.button == '2') {
skipCondition = true
} else if (e.button == '0') {
dont = true
}
if (getStatus(this) == 'clicked') {
middleClickCell(this)
return
}
}
switch(e.button) {
case 0: {
isLeft = false
if (skipCondition) {
skip = true
}
break
}
case 2: isRight = false; break
}
removeHighlights()
if (skip || dont) {
skip = false
skipCondition = false
return
}
if (!isBusy && typeof e === 'object' && e.button != 2) {
mouseUpCallBackArray[e.button].call(_this, this)
}
}
cell.onmousedown = function(e) {
skip = false
if (!isBusy && typeof e === 'object') {
switch(e.button) {
case 0: isLeft = true; break
case 2: isRight = true; break
}
if (isLeft && isRight) {
bothPressed = true
highlightSurroundingCell(this)
return
}
if (e.button == '1') {
pressed = 'middle'
highlightSurroundingCell(this)
} else if (e.button == '0') {
pressed = 'left'
if (getStatus(this) == 'clicked') {
highlightSurroundingCell(this)
} else {
highlightCell(this)
}
}
if (e.button == '2') mouseDownCallBackArray[e.button].call(_this, this)
}
}
cell.onmousemove = function(e) {
if ((pressed || bothPressed) && typeof e === 'object') {
removeHighlights()
/*
if (!isEqual(clickedCell, cell)) {
clickedCell = undefined;
}
*/
if (pressed == 'middle' || (isLeft && isRight)) {
highlightSurroundingCell(this)
} else if (pressed == 'left') {
if (getStatus(this) == 'clicked') {
highlightSurroundingCell(this)
} else {
highlightCell(this)
}
}
}
}
cell.oncontextmenu = () => false
cell.onselectstart = () => false
cell.setAttribute('unselectable', 'on')
}
function isEqual(x, y) {
if (!x) return false
return x === y
}
function startTouchTimer(cell) {
if (isEqual(clickedCell, cell)) {
return
}
clickedCell = cell
setTimeout(() => {
if (isEqual(clickedCell, cell)) {
rightClickCell(cell)
setBusy()
}
}, 500)
}
function endTouchTimer() {
clickedCell = undefined
}
function resetMouseEventFlags() {
pressed = undefined
bothPressed = undefined
isLeft = false
isRight = false
removeHighlights()
skip = true
}
function addMines(minesCount) {
//Add mines randomly
for (let i=0; i<minesCount; i++) {
let row = Math.floor(Math.random() * setting.rows)
let col = Math.floor(Math.random() * setting.cols)
let cell = grid.rows[row].cells[col]
if (isMine(cell)) {
transferMine()
} else {
mines.add(mineKey(row, col))
}
if (TEST_MODE){
cell.innerHTML = 'X'
}
}
if (TEST_MODE) {
printMines()
}
}
function revealMines() {
if (grid.getAttribute('game-status') == 'done') return
//Highlight all mines in red
const win = grid.getAttribute('game-status') == 'win'
for (let i=0; i<setting.rows; i++) {
for(let j=0; j<setting.cols; j++) {
let cell = grid.rows[i].cells[j]
if (win) {
handleWinRevelation(cell)
} else {
handleLostRevelation(cell)
}
}
}
grid.setAttribute('game-status', 'done')
const time = timerService.stop()
const game = {
time,
status: win ? 'win' : 'loss',
level: setting.id,
time_stamp: new Date(),
isMobile
}
/**
* TODO: add hook after gameSession send back `game`
* - for sending the game score to the db
*/
hooks.gameDone(game)
}
function handleWinRevelation(cell) {
updateFlagsCountDisplay(0)
if (isMine(cell)) {
cell.innerHTML = ':)'
cell.className = 'correct'
setStatus(cell, 'clicked')
let correct = document.createAttribute('title')
correct.value = 'Correct'
cell.setAttributeNode(correct)
setStatus(cell, 'clicked')
}
}
function handleLostRevelation(cell) {
if (isFlagged(cell)) {
cell.className = 'flag'
if (!isMine(cell)) {
cell.innerHTML = 'X'
cell.className = 'wrong'
let wrong = document.createAttribute('title')
wrong.value = 'Wrong'
cell.setAttributeNode(wrong)
} else {
cell.innerHTML = ':)'
cell.className = 'correct'
let correct = document.createAttribute('title')
correct.value = 'Correct'
cell.setAttributeNode(correct)
}
} else {
if (isMine(cell)) {
cell.className = 'mine'
setStatus(cell, 'clicked')
}
}
}
function isOpen(cell) {
return cell.innerHTML !== '' && !isFlagged(cell)
}
function isFlagged(cell) {
return getStatus(cell) == 'flagged'
}
function mineKey(row, col) {
return row * setting.cols + col
}
function isMine(cell) {
return mines.has(mineKey(getRow(cell), getCol(cell)))
}
function checkLevelCompletion() {
const safeCells = setting.rows * setting.cols - setting.mines
if (revealedSafeCount >= safeCells && grid.getAttribute('game-status') == 'active') {
grid.setAttribute('game-status', 'win')
revealMines()
}
}
function setStatus(cell, status) {
cell.setAttribute('data-status', status)
}
function getCol(cell) {
return cell.cellIndex
}
function getRow(cell) {
return cell.parentNode.rowIndex
}
function getStatus(cell) {
if (!cell) return undefined
return cell.getAttribute('data-status')
}
function middleClickCell(cell) {
if (grid.getAttribute('game-status') != 'active' || getStatus(cell) !== 'clicked') {
return
}
// check for number of surrounding flags
const valueString = cell.getAttribute('data-value')
let cellValue = parseInt(valueString, 10)
let flagCount = countFlagsAround(cell)
if (flagCount === cellValue) {
clickSurrounding(cell)
if (TEST_MODE) loggerService.debug('middle click', cell)
}
}
function countFlagsAround(cell) {
let flagCount = 0
let cellRow = cell.parentNode.rowIndex
let cellCol = cell.cellIndex
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
if (isFlagged(grid.rows[i].cells[j])) flagCount++
}
}
return flagCount
}
function clickSurrounding(cell) {
if (grid.getAttribute('game-status') != 'active') return
let cellRow = cell.parentNode.rowIndex
let cellCol = cell.cellIndex
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
let currentCell = grid.rows[i].cells[j]
if (getStatus(currentCell) == 'flagged') continue
openCell(currentCell)
}
}
}
function increaseFlagsCount() {
flagsCount++
updateFlagsCountDisplay()
}
function decreaseFlagsCount() {
flagsCount--
updateFlagsCountDisplay()
}
function activateGame() {
grid.setAttribute('game-status', 'active')
// start timer
timerService.start()
}
function gameIsDone() {
return grid.getAttribute('game-status') == 'over' || grid.getAttribute('game-status') == 'done'
}
function removeHighlights() {
for (let i = 0; i < highlightedCells.length; i++) {
const currentCell = highlightedCells[i]
// guard: a tracked cell may have been flagged/clicked since it was highlighted
if (getStatus(currentCell) == 'highlighted') setStatus(currentCell, 'default')
}
highlightedCells = []
}
function highlightCell(cell) {
if (isFlagged(cell)) return
if (!gameIsDone() && getStatus(cell) == 'default') {
setStatus(cell, 'highlighted') // currentCell.classList.add('highlight');
highlightedCells.push(cell)
}
}
function highlightSurroundingCell(cell) {
let cellRow = cell.parentNode.rowIndex
let cellCol = cell.cellIndex
highlightCell(cell)
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]
highlightCell(currentCell)
}
}
}
function rightClickCell(cell) {
if (isFlagged(cell)) setBusy()
if (grid.getAttribute('game-status') == 'inactive') {
activateGame()
}
if (grid.getAttribute('game-status') != 'active') return
if (getStatus(cell) != 'clicked' && getStatus(cell) != 'empty') {
if (getStatus(cell) == 'default' || getStatus(cell) == 'highlighted') {
if (flagsCount <= 0) return
cell.className = 'flag'
decreaseFlagsCount()
setStatus(cell, 'flagged')
} else {
cell.className = ''
increaseFlagsCount()
setStatus(cell, 'default')
}
if ('vibrate' in navigator) {
navigator.vibrate(100)
}
if (TEST_MODE) loggerService.debug('right click', cell)
}
}
function clickCell(cell) {
if (isFlagged(cell)) setBusy()
if (grid.getAttribute('game-status') == 'inactive') {
activateGame()
}
if (grid.getAttribute('game-status') != 'active') return
//Check if the end-user clicked on a mine
if (TEST_MODE) loggerService.debug('click', cell)
if (getStatus(cell) == 'flagged' || grid.getAttribute('game-status') == 'over') {
return
} else if (getStatus(cell) == 'clicked') {
middleClickCell(cell)
return
} else if (isMine(cell) && firstClick) {
// cell.setAttribute('data-mine', 'false');
mines.delete(mineKey(getRow(cell), getCol(cell)))
transferMine(cell)
if (TEST_MODE) printMines()
}
openCell(cell)
}
function printMines() {
let count = 0
for (let i = 0; i < setting.rows; i++) {
for (let j = 0; j < setting.cols; j++) {
if (isMine(grid.rows[i].cells[j])) {
loggerService.debug(count++ + ' - mine: [' + i + ',' + j + ']')
}
}
}
}
function transferMine(cell = undefined) {
let found = false
do {
let row = Math.floor(Math.random() * setting.rows)
let col = Math.floor(Math.random() * setting.cols)
const transferMineToCell = grid.rows[row].cells[col]
if (isMine(transferMineToCell) || transferMineToCell === cell || isNeighbor(cell, transferMineToCell)) {
continue
} else {
mines.add(mineKey(row, col))
if (TEST_MODE){
transferMineToCell.innerHTML = 'X'
if (TEST_MODE) loggerService.debug('transferred mine to: ' + row + ', ' + col)
}
// TODO: refactor maybe
// eslint-disable-next-line no-useless-assignment
found = true
return
}
} while(!found)
}
function isNeighbor(cell, nextCell) {
if (cell === undefined) {
return
}
const rowDifference = Math.abs(getRow(cell) - getRow(nextCell))
const colDifference = Math.abs(getCol(cell) - getCol(nextCell))
return (rowDifference === 1) && (colDifference === 1)
}
function countMinesAround(cell) {
let mineCount=0
let cellRow = cell.parentNode.rowIndex
let cellCol = cell.cellIndex
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1,setting.rows-1); i++) {
const rows = grid.rows[i]
if (!rows) continue
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1,setting.cols-1); j++) {
const cell = rows.cells[j]
const mine = isMine(cell)
if (cell && mine) {
mineCount++
}
}
}
return mineCount
}
function updateCellValue(cell, value) {
const spanElement = document.createElement('span')
spanElement.innerHTML = value
cell.innerHTML = ''
cell.appendChild(spanElement)
}
/**
* Reveal a single known-non-mine cell. Returns true when the cell is blank
* (no adjacent mines), which is the signal to keep flooding from it.
* @param {HTMLTableCellElement} cell
*/
function revealSafeCell(cell) {
// A cell can be re-opened via chording, so only count the first reveal
const wasOpen = getStatus(cell) == 'clicked' || getStatus(cell) == 'empty'
cell.className = 'clicked'
setStatus(cell, 'clicked')
if (!wasOpen) revealedSafeCount++
const mineCount = countMinesAround(cell)
if (mineCount == 0) {
updateCellValue(cell, ' ')
setStatus(cell, 'empty')
return true
}
updateCellValue(cell, mineCount.toString())
const dataValue = document.createAttribute('data-value')
dataValue.value = mineCount.toString()
cell.setAttributeNode(dataValue)
return false
}
/**
* Iteratively reveal the connected region of blank cells, starting from a
* cell that has already been revealed as blank. Uses an explicit queue with
* isOpen() as the visited guard instead of recursing back through clickCell,
* so there are no redundant per-cell checks and no deep call stack.
* @param {HTMLTableCellElement} startCell
*/
function handleEmpty(startCell) {
const queue = [startCell]
while (queue.length) {
const cell = queue.shift()
const cellRow = cell.parentNode.rowIndex
const cellCol = cell.cellIndex
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
const rows = grid.rows[i]
if (!rows) continue
for (let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
const neighbor = rows.cells[j]
if (!neighbor || neighbor === cell) continue
if (isFlagged(neighbor)) {
setBusy()
continue
}
if (isOpen(neighbor)) continue
// blank cells have no adjacent mines, so every neighbor here is safe
if (revealSafeCell(neighbor)) queue.push(neighbor)
}
}
}
}
function openCell(cell) {
if (grid.getAttribute('game-status') != 'active') return
firstClick = false
if (isMine(cell)) {
cell.className = 'clicked'
setStatus(cell, 'clicked')
revealMines()
flagsDisplay.innerHTML = '&#128561;'
grid.setAttribute('game-status', 'over')
return
}
if (revealSafeCell(cell)) handleEmpty(cell)
checkLevelCompletion()
}
}
export default Minesweeper

32
lib/package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "@ayo-run/mnswpr",
"version": "0.4.36",
"description": "Classic Minesweeper browser game",
"author": "Ayo",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/ayo-run/mnswpr"
},
"homepage": "https://mnswpr.com",
"scripts": {
"release": "bumpp && node ../scripts/release.js"
},
"main": "mnswpr.js",
"exports": {
".": {
"default": "./dist/mnswpr.js"
},
"./dist/*": {
"default": "./dist/*"
},
"./*": {
"default": "./*"
}
},
"files": [
"./*",
"./dist"
],
"license": "BSD-2-Clause"
}

12
lib/vite.config.js Normal file
View file

@ -0,0 +1,12 @@
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: {
entry: resolve(import.meta.dirname, './mnswpr.js'),
name: 'mnswpr',
fileName: 'mnswpr'
}
}
})

View file

@ -1,35 +0,0 @@
# 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 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" = the repo root — no longer `apps/mnswpr`).
[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 .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"

View file

@ -1,58 +1,43 @@
{
"name": "mnswpr",
"name": "monorepo",
"version": "0.0.1",
"private": true,
"description": "mnswpr \u2014 the mnswpr.com web app",
"description": "Classic Minesweeper browser game",
"author": "Ayo Ayco",
"type": "module",
"main": "main.js",
"repository": {
"type": "git",
"url": "https://github.com/ayo-run/mnswpr"
},
"homepage": "https://mnswpr.com",
"scripts": {
"dev": "firebase --config firebase/firebase.json emulators:exec --only firestore --ui \"node scripts/seed-dev-scores.js; vite\"",
"dev:no-db": "vite",
"build": "vite build",
"preview": "vite preview",
"build:preview": "pnpm run build && pnpm run preview",
"test": "vitest run",
"test:watch": "vitest",
"deploy:db": "firebase --config firebase/firebase.json deploy --only firestore",
"deploy:site": "pnpm run build && netlify deploy --prod --dir=dist",
"db:start": "firebase --config firebase/firebase.json 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",
"release": "pnpm lint && pnpm test && bumpp && node scripts/release.js",
"postinstall": "node scripts/ensure-java.mjs",
"dev": "vite app",
"start": "vite app",
"emulators": "npx -y firebase-tools emulators:start --only firestore",
"seed:emulator": "cd app && FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 node ../scripts/seed-dev-scores.js",
"build": "vite build app",
"build:lib": "vite build lib",
"publish:lib": "node scripts/publish-lib.js",
"release": "pnpm build:lib && pnpm -F @ayo-run/mnswpr run release && pnpm publish:lib",
"build:preview": "pnpm -F app run build:preview",
"prepare": "husky",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"scan:secrets": "secretlint --secretlintignore .secretlintignore \"**/*\""
"lint:fix": "eslint . --fix"
},
"devDependencies": {
"@cozy-games/leaderboard": "^0.1.1",
"@cozy-games/mnswpr": "^0.5.4",
"@cozy-games/utils": "^0.0.3",
"@eslint/css": "^1.1.0",
"@eslint/js": "^10.0.1",
"@secretlint/secretlint-rule-preset-recommend": "^13.0.2",
"@stylistic/eslint-plugin": "^5.10.0",
"bumpp": "^11.0.1",
"eslint": "^10.1.0",
"firebase": "^12.11.0",
"firebase-tools": "^15.22.4",
"globals": "^17.4.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"netlify-cli": "^26.1.0",
"secretlint": "^13.0.2",
"simple-git": "^3.33.0",
"typescript": "^5.9.3",
"vite": "^8.0.16",
"vitest": "^4.1.9",
"web-component-base": "^5.0.0"
"vite": "^8.0.3",
"vitest": "^4.1.9"
},
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b"
}

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,8 @@
# Not a workspace — this repo is a single package at the root. pnpm 11 reads its
# settings from this file, so it stays for `allowBuilds` / `minimumReleaseAgeExclude`.
packages:
- "lib"
- "app"
- "leaderboard"
allowBuilds:
'@firebase/util': false
'@parcel/watcher': false
esbuild: false
netlify-cli: false
protobufjs: false
re2: false
sharp: false
unix-dgram: false
web-component-base: false
minimumReleaseAgeExclude:
- web-component-base@5.0.0
- '@cozy-games/mnswpr@0.5.4'

View file

@ -1,458 +0,0 @@
// @ts-check
// Checks commits, branch names, and contributed text against the repo's content
// policy: no automated-tool attribution, no session links, and a
// maintainer-managed reserved-terms list (shipped as salted digests in
// .repo-policy.json).
//
// Zero dependencies (node: builtins only), so CI can run it without installing.
// Usage: node scripts/check-content.mjs --staged --branch "$(git branch --show-current)"
//
// Escape hatches, for text that carries a pattern legitimately: `content-policy:
// ignore-file` in a file's first lines skips the whole file (this one and its
// test, which quote the patterns); `content-policy: allow-next-line` skips the
// line after it.
// content-policy: ignore-file
import { createHmac } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const root = resolve(import.meta.dirname, '..')
/** Opt-out marker, honored in the first lines of a scanned file. */
const IGNORE_MARKER = 'content-policy: ignore-file'
/** Opt-out marker for the single line that follows it. */
const ALLOW_MARKER = 'content-policy: allow-next-line'
/**
* @typedef {object} Policy
* @property {number} version
* @property {string} salt - HMAC key for the digest list.
* @property {string[]} digests - Reserved-term digests, maintainer-managed.
* @property {string[]} toolCoAuthors - Non-human co-author addresses to reject;
* a leading `*` matches a suffix. Every other address is a human, and passes.
*/
/**
* @typedef {object} Finding
* @property {string} location - `file:line`, `commit message <sha>`, or `branch name`.
* @property {string} rule
* @property {string} detail
*/
/**
* Read the repo's content policy.
* @param {string} [file]
* @returns {Policy}
*/
export function loadPolicy(file = resolve(root, '.repo-policy.json')) {
return JSON.parse(readFileSync(file, 'utf8'))
}
/**
* Every 1-, 2-, and 3-gram of the text's alphanumeric tokens. Multi-token grams
* are emitted both space-joined and concatenated, so `foo-bar` and `FooBar` both
* reach the same candidate.
* @param {string} text
* @returns {string[]} deduplicated candidates
*/
export function buildCandidates(text) {
const tokens = String(text).toLowerCase().match(/[a-z0-9]+/g) || []
const candidates = new Set()
for (let i = 0; i < tokens.length; i++) {
for (let n = 1; n <= 3 && i + n <= tokens.length; n++) {
const gram = tokens.slice(i, i + n)
if (n === 1) candidates.add(gram[0])
else {
candidates.add(gram.join(' '))
candidates.add(gram.join(''))
}
}
}
return [...candidates]
}
/**
* @param {string} candidate
* @param {string} salt
* @returns {string} hex digest
*/
export function digestCandidate(candidate, salt) {
return createHmac('sha256', salt).update(candidate).digest('hex')
}
/**
* First character plus asterisks enough to act on a finding without echoing
* the term into a log.
* @param {string} term
* @returns {string}
*/
export function maskTerm(term) {
const text = String(term)
return text.slice(0, 1) + '*'.repeat(Math.max(text.length - 1, 0))
}
/**
* Match a text's candidates against the policy's digest list.
* @param {string} text
* @param {Policy} policy
* @returns {{ index: number, preview: string }[]} one entry per matched digest
*/
export function matchReserved(text, policy) {
const digests = policy.digests || []
if (digests.length === 0) return []
const matches = []
const seen = new Set()
for (const candidate of buildCandidates(text)) {
const index = digests.indexOf(digestCandidate(candidate, policy.salt))
if (index === -1 || seen.has(index)) continue
seen.add(index)
matches.push({ index, preview: maskTerm(candidate) })
}
return matches
}
/** Single-line patterns, matched against text, paths, and branch names. */
const STRUCTURAL_RULES = [
{
rule: 'tool-attribution',
pattern: /(generated|created|written|authored)\s+(with|by)\b.{0,40}\b(code|agent|assistant|bot|ai|llm)\b/i
},
{ rule: 'tool-attribution', pattern: /🤖/u },
{ rule: 'tool-attribution', pattern: /\b(ai|llm)[-\s](generated|assisted|authored|written)\b/i },
{ rule: 'session-link', pattern: /https?:\/\/\S+\/sessions?\/[0-9a-zA-Z_-]{8,}/i },
{ rule: 'session-link', pattern: /\bsession[_-][0-9a-zA-Z]{16,}\b/i }
]
const CO_AUTHOR_PATTERN = /^\s*co-authored-by:\s*(.+)$/i
const EMAIL_PATTERN = /<([^>]+)>/
/**
* Is this co-author address a tool rather than a person? Humans are the default:
* only addresses matching the policy's tool list are rejected.
* @param {string} email
* @param {string[]} [tools] - Exact emails, or `*@domain` suffix patterns.
* @returns {boolean}
*/
export function isToolCoAuthor(email, tools = []) {
const value = String(email).trim().toLowerCase()
if (!value) return false
return tools.some((entry) => {
const pattern = String(entry).trim().toLowerCase()
return pattern.startsWith('*') ? value.endsWith(pattern.slice(1)) : value === pattern
})
}
/**
* @param {string} line
* @returns {string} the line, trimmed and capped for reporting
*/
function preview(line) {
const text = line.trim()
return text.length > 120 ? `${text.slice(0, 120)}` : text
}
/**
* Structural checks for one line. The line is quoted back: these patterns are
* vendor-neutral and public, unlike the reserved-terms list.
* @param {string} line
* @param {Policy} policy
* @returns {{ rule: string, detail: string }[]}
*/
export function checkStructural(line, policy) {
const findings = []
const reported = new Set()
for (const { rule, pattern } of STRUCTURAL_RULES) {
if (reported.has(rule) || !pattern.test(line)) continue
reported.add(rule)
findings.push({ rule, detail: preview(line) })
}
const trailer = CO_AUTHOR_PATTERN.exec(line)
if (trailer) {
const email = (EMAIL_PATTERN.exec(trailer[1]) || [])[1] || ''
if (isToolCoAuthor(email, policy.toolCoAuthors)) {
findings.push({ rule: 'tool-co-author', detail: preview(line) })
}
}
return findings
}
/**
* @param {string | undefined} line - The line before the one being scanned.
* @returns {boolean}
*/
export function isAllowedByMarker(line) {
return typeof line === 'string' && line.includes(ALLOW_MARKER)
}
/**
* Run every check over a text, line by line.
* @param {string} text
* @param {(lineNo: number) => string} locate - Builds a finding's location.
* @param {Policy} policy
* @returns {Finding[]}
*/
export function scanText(text, locate, policy) {
const findings = []
const lines = String(text).split('\n')
lines.forEach((line, index) => {
if (isAllowedByMarker(lines[index - 1])) return
const location = locate(index + 1)
for (const finding of checkStructural(line, policy)) findings.push({ location, ...finding })
for (const match of matchReserved(line, policy)) {
findings.push({ location, rule: 'reserved-term', detail: `digest #${match.index} (${match.preview})` })
}
})
return findings
}
/**
* A changed path gets the same checks as text. A reserved-term hit reports the
* path's position rather than the path, so a flagged name never lands in a log.
* @param {string} path
* @param {number} index - Position in the scanned path list.
* @param {Policy} policy
* @returns {Finding[]}
*/
export function checkPath(path, index, policy) {
const findings = checkStructural(path, policy).map((finding) => ({ location: `path ${path}`, ...finding }))
for (const match of matchReserved(path, policy)) {
findings.push({ location: `path #${index + 1}`, rule: 'reserved-term', detail: `digest #${match.index} (${match.preview})` })
}
return findings
}
/**
* Added lines and their line numbers in the new file, from a unified diff.
* @param {string} diff
* @returns {{ file: string, line: number, text: string }[]}
*/
export function parseAddedLines(diff) {
const added = []
let file = null
let lineNo = 0
for (const line of diff.split('\n')) {
if (line.startsWith('+++ ')) {
const path = line.slice(4).trim()
file = path === '/dev/null' ? null : path.replace(/^b\//, '')
continue
}
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
if (hunk) {
lineNo = Number(hunk[1])
continue
}
if (!file || line.startsWith('---')) continue
if (line.startsWith('+')) added.push({ file, line: lineNo++, text: line.slice(1) })
else if (line.startsWith(' ')) lineNo++
}
return added
}
/**
* Split `a...b` or `a..b`.
* @param {string} spec
* @returns {{ a: string, b: string, merged: boolean }}
*/
export function parseRange(spec) {
const merged = spec.includes('...')
const [a, b] = spec.split(merged ? '...' : '..')
if (!a || !b) throw new Error(`not a commit range: ${spec}`)
return { a, b, merged }
}
/**
* @param {string[]} args
* @returns {string}
*/
function git(args) {
return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 })
}
/**
* @param {string} content
* @returns {boolean}
*/
function hasIgnoreMarker(content) {
return content.split('\n', 20).some((line) => line.includes(IGNORE_MARKER))
}
/**
* A file's lines at a revision, so a diffed line can be read in context.
* @param {string} rev - Empty for the index.
* @param {string} path
* @returns {string[] | null} null when the file opts out of the scan
*/
function readLinesAt(rev, path) {
try {
const content = git(['show', `${rev}:${path}`])
return hasIgnoreMarker(content) ? null : content.split('\n')
}
catch {
return []
}
}
/**
* @param {string} nameOnly - Output of `git diff --name-only`.
* @param {Policy} policy
* @returns {Finding[]}
*/
function checkPaths(nameOnly, policy) {
return nameOnly.split('\n').filter(Boolean).flatMap((path, index) => checkPath(path, index, policy))
}
/**
* @param {string} diff
* @param {string} rev - Revision to read ignore markers from; empty for the index.
* @param {Policy} policy
* @returns {Finding[]}
*/
function checkAddedLines(diff, rev, policy) {
const findings = []
const blobs = new Map()
for (const { file, line, text } of parseAddedLines(diff)) {
if (!blobs.has(file)) blobs.set(file, readLinesAt(rev, file))
const lines = blobs.get(file)
if (lines === null || isAllowedByMarker(lines[line - 2])) continue
findings.push(...scanText(text, () => `${file}:${line}`, policy))
}
return findings
}
/**
* @param {Policy} policy
* @returns {Finding[]}
*/
function checkStaged(policy) {
const diff = git(['diff', '--cached', '--unified=0', '--diff-filter=ACM'])
return [
...checkAddedLines(diff, '', policy),
...checkPaths(git(['diff', '--cached', '--name-only', '--diff-filter=ACMR']), policy)
]
}
/**
* @param {string} spec
* @param {Policy} policy
* @returns {Finding[]}
*/
function checkRange(spec, policy) {
const { a, b, merged } = parseRange(spec)
const findings = []
for (const sha of git(['rev-list', `${a}..${b}`]).split('\n').filter(Boolean)) {
const message = git(['show', '-s', '--format=%B', sha])
findings.push(...scanText(message, () => `commit message ${sha.slice(0, 8)}`, policy))
}
const diffSpec = merged ? `${a}...${b}` : `${a}..${b}`
findings.push(...checkAddedLines(git(['diff', '--unified=0', diffSpec]), b, policy))
findings.push(...checkPaths(git(['diff', '--name-only', diffSpec]), policy))
return findings
}
/**
* Strip the comments git adds to a commit-message file.
* @param {string} file
* @returns {string}
*/
function readMessage(file) {
const text = readFileSync(file, 'utf8')
const scissors = text.indexOf('\n# ------------------------ >8 ------------------------')
const body = scissors === -1 ? text : text.slice(0, scissors)
return body.split('\n').filter((line) => !line.startsWith('#')).join('\n')
}
/**
* @param {string} file
* @returns {string | null} null when the file is binary or unreadable
*/
function readTracked(file) {
try {
const content = readFileSync(resolve(root, file))
return content.includes(0) ? null : content.toString('utf8')
}
catch {
return null
}
}
/**
* @param {Policy} policy
* @returns {Finding[]}
*/
function checkAll(policy) {
const findings = []
git(['ls-files']).split('\n').filter(Boolean).forEach((file, index) => {
findings.push(...checkPath(file, index, policy))
const content = readTracked(file)
if (content === null || hasIgnoreMarker(content)) return
findings.push(...scanText(content, (line) => `${file}:${line}`, policy))
})
return findings
}
const USAGE = `Usage: node scripts/check-content.mjs <mode>...
--staged staged added lines and file paths
--message <file> a commit-message file
--range <a>...<b> commit messages, added lines, and paths in a range
--text <file> arbitrary text (a PR title and body)
--branch <name> a branch name
--all every tracked file (maintainer sweep)`
/**
* @param {Finding[]} findings
*/
function report(findings) {
for (const { location, rule, detail } of findings) console.error(`${location} ${rule}: ${detail}`)
if (findings.length) {
console.error(`\n${findings.length} content policy finding(s) — see CONTRIBUTING.md ("Commit & PR hygiene").`)
}
}
/**
* @param {string[]} argv
* @returns {number} exit code
*/
export function main(argv) {
const policy = loadPolicy()
const findings = []
if (argv.length === 0) {
console.error(USAGE)
return 2
}
for (let i = 0; i < argv.length; i++) {
const mode = argv[i]
if (mode === '--staged') findings.push(...checkStaged(policy))
else if (mode === '--all') findings.push(...checkAll(policy))
else if (mode === '--message') findings.push(...scanText(readMessage(argv[++i]), () => 'commit message', policy))
else if (mode === '--text') {
const file = argv[++i]
findings.push(...scanText(readFileSync(file, 'utf8'), (line) => `${file}:${line}`, policy))
}
else if (mode === '--range') findings.push(...checkRange(argv[++i], policy))
else if (mode === '--branch') findings.push(...checkBranchArg(argv[++i], policy))
else {
console.error(`unknown mode: ${mode}\n\n${USAGE}`)
return 2
}
}
report(findings)
return findings.length ? 1 : 0
}
/**
* A detached HEAD has no branch name nothing to check.
* @param {string} name
* @param {Policy} policy
* @returns {Finding[]}
*/
function checkBranchArg(name, policy) {
return name && name.trim() ? scanText(name.trim(), () => 'branch name', policy) : []
}
if (process.argv[1] === fileURLToPath(import.meta.url)) process.exit(main(process.argv.slice(2)))

View file

@ -1,99 +0,0 @@
/**
* Post-install convenience: make a Java runtime available for the Firebase
* Firestore emulator (used by `pnpm run 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 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 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 (README.md).`)
return
}
if (!hasTool('tar')) {
warn('`tar` not found — cannot unpack the JRE. Install Java manually (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
}

View file

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

17
scripts/publish-lib.js Normal file
View file

@ -0,0 +1,17 @@
// Publishes @ayo-run/mnswpr to npm using the root project README.md as the
// package's README (what npmjs.com displays).
//
// lib/README.md is a generated copy (gitignored) — the source of truth is the
// root README.md. The library's own guide lives in lib/TUTORIAL.md.
import { execSync } from 'node:child_process'
import { copyFileSync } from 'node:fs'
import { resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const libDir = resolve(root, 'lib')
console.log('Copying root README.md into lib/ for the published package')
copyFileSync(resolve(root, 'README.md'), resolve(libDir, 'README.md'))
execSync('npm login')
execSync('npm publish', { cwd: libDir, stdio: 'inherit' })

View file

@ -1,71 +1,30 @@
/**
* Release: sync the `release` branch to `main` and push the tags.
*
* Run via `pnpm release`, which is `bumpp && node scripts/release.js` bumpp
* bumps package.json, commits, and tags first, so by the time this runs `main`
* already carries the release commit.
*
* Netlify builds mnswpr.com from the `release` branch on the `gh` remote, so the
* force-push below IS the deploy. Everything before it is a guard against
* shipping a tree you didn't mean to ship.
*
* Originally forked from https://github.com/elk-zone/elk/blob/main/scripts/release.ts
*/
// forked from https://github.com/elk-zone/elk/blob/main/scripts/release.ts
import { simpleGit } from 'simple-git'
// Netlify watches gh/release; the others just mirror tags.
const DEPLOY_REMOTE = 'gh'
const TAG_REMOTES = ['origin', 'gh', 'sh']
const git = simpleGit()
const fail = (message) => {
console.error(`\n${message}`)
process.exit(1)
}
const status = await git.status()
if (status.current !== 'main')
fail(`Release must run from main — you are on "${status.current}".`)
if (!status.isClean())
fail('Working tree is dirty. Commit or stash before releasing.')
const hash = await git.revparse(['main'])
const { latest } = await git.tags()
console.log(`Releasing ${latest} (main @ ${hash.slice(0, 8)})`)
const remote = 'gh'
// A leftover local `release` branch from an interrupted run would make the
// checkout below fail, so drop it before recreating it from the remote.
const branches = await git.branchLocal()
if (branches.all.includes('release')) {
console.log('Removing a stale local release branch')
await git.branch(['-D', 'release'])
}
console.log('Fetch remote gh repo')
await git.fetch(remote)
console.log(`Fetching ${DEPLOY_REMOTE}`)
await git.fetch(DEPLOY_REMOTE)
console.log('Checkout release branch')
await git.checkout(['-b', 'release', '--track', 'gh/release'])
try {
console.log('Checking out release')
await git.checkout(['-b', 'release', '--track', `${DEPLOY_REMOTE}/release`])
console.log(`Reset to main branch (${hash})`)
await git.reset(['--hard', hash])
console.log(`Resetting release to main (${hash.slice(0, 8)})`)
await git.reset(['--hard', hash])
console.log('Push to release branch')
await git.push(['--force', remote])
console.log(`Pushing release to ${DEPLOY_REMOTE} — this triggers the Netlify deploy`)
await git.push([DEPLOY_REMOTE, 'release', '--force'])
} finally {
// Never strand the user on the release branch, even if a step above threw.
await git.checkout('main')
const after = await git.branchLocal()
if (after.all.includes('release')) await git.branch(['-D', 'release'])
}
console.log('Checkout main branch')
await git.checkout('main')
for (const remote of TAG_REMOTES) {
console.log(`Pushing tags to ${remote}`)
await git.push([remote, '--tags'])
}
console.log('Deleting local release branch')
await git.branch(['-D', 'release'])
console.log(`\n✓ Released ${latest} — Netlify is building from ${DEPLOY_REMOTE}/release`)
// TODO: handle multiple remotes with a data structure
console.log('Push tags')
await git.push(['--tags'])
await git.push(['--tags', remote])
await git.push(['--tags', 'sh'])

View file

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

View file

@ -1,306 +0,0 @@
// This file quotes the patterns the scanner looks for, so it opts out of the scan.
// content-policy: ignore-file
import { describe, it, expect } from 'vitest'
import {
buildCandidates,
digestCandidate,
maskTerm,
matchReserved,
isToolCoAuthor,
checkStructural,
scanText,
isAllowedByMarker,
parseAddedLines,
parseRange,
loadPolicy
} from '../check-content.mjs'
const policy = loadPolicy()
/** The policy ships an empty digest list; tests add their own dummy terms. */
function withTerms(...terms) {
return { ...policy, digests: terms.map((term) => digestCandidate(term, policy.salt)) }
}
describe('buildCandidates', () => {
it('splits on everything that is not a letter or digit', () => {
expect(buildCandidates('Foo-bar_BAZ')).toContain('foo')
expect(buildCandidates('Foo-bar_BAZ')).toContain('bar')
expect(buildCandidates('Foo-bar_BAZ')).toContain('baz')
})
it('emits 1-, 2-, and 3-grams of consecutive tokens', () => {
expect(buildCandidates('one two three')).toEqual([
'one', 'one two', 'onetwo', 'one two three', 'onetwothree',
'two', 'two three', 'twothree',
'three'
])
})
it('emits both the space-joined and concatenated form for n >= 2', () => {
const candidates = buildCandidates('wombat cactus')
expect(candidates).toContain('wombat cactus')
expect(candidates).toContain('wombatcactus')
})
it('reaches the same candidate from every separator and case', () => {
for (const text of ['wombat cactus', 'Wombat-Cactus', 'wombat_cactus', 'WombatCactus!']) {
expect(buildCandidates(text)).toContain('wombatcactus')
}
})
it('deduplicates repeats', () => {
const candidates = buildCandidates('go go go')
expect(candidates.filter((c) => c === 'go')).toHaveLength(1)
expect(candidates.filter((c) => c === 'go go')).toHaveLength(1)
})
it('handles empty and token-free text', () => {
expect(buildCandidates('')).toEqual([])
expect(buildCandidates('--- !!! ---')).toEqual([])
})
})
describe('digestCandidate', () => {
it('is a stable, salted hex digest', () => {
const digest = digestCandidate('wombat cactus', policy.salt)
expect(digest).toMatch(/^[0-9a-f]{64}$/)
expect(digestCandidate('wombat cactus', policy.salt)).toBe(digest)
})
it('depends on the salt', () => {
expect(digestCandidate('wombat cactus', 'other-salt')).not.toBe(digestCandidate('wombat cactus', policy.salt))
})
})
describe('matchReserved', () => {
it('finds nothing when the digest list is empty', () => {
expect(matchReserved('wombat cactus', policy)).toEqual([])
})
it('matches a term inside a longer text and reports its list index', () => {
const loaded = withTerms('aardvark', 'wombat cactus')
expect(matchReserved('a fix for the wombat cactus bug', loaded)).toEqual([
{ index: 1, preview: 'w************' }
])
})
// Text yields both forms as candidates, so a digest of the concatenated form
// catches every separator and casing — the form to list for a two-word term.
it('matches every spelling of a term digested in its concatenated form', () => {
const loaded = withTerms('wombatcactus')
for (const text of ['the WombatCactus module', 'a wombat cactus fix', 'wombat-cactus']) {
expect(matchReserved(text, loaded), text).toEqual([{ index: 0, preview: 'w***********' }])
}
})
it('does not match the concatenated spelling from a space-joined digest', () => {
expect(matchReserved('the WombatCactus module', withTerms('wombat cactus'))).toEqual([])
})
it('leaves unrelated text alone', () => {
expect(matchReserved('a fix for the wombat bug', withTerms('wombat cactus'))).toEqual([])
})
it('reports each matched digest once', () => {
expect(matchReserved('wombat cactus, wombat cactus', withTerms('wombat cactus'))).toHaveLength(1)
})
})
describe('maskTerm', () => {
it('keeps the first character and hides the rest', () => {
expect(maskTerm('wombat cactus')).toBe('w************')
expect(maskTerm('a')).toBe('a')
expect(maskTerm('')).toBe('')
})
})
describe('isToolCoAuthor', () => {
it('matches a tool address by domain, case-insensitively', () => {
expect(isToolCoAuthor('noreply@anthropic.com', policy.toolCoAuthors)).toBe(true)
expect(isToolCoAuthor('NoReply@OpenAI.com', policy.toolCoAuthors)).toBe(true)
})
it('leaves dependabot alone — it is honest automation, not AI attribution', () => {
expect(isToolCoAuthor('49699333+dependabot[bot]@users.noreply.github.com', policy.toolCoAuthors)).toBe(false)
})
it('treats every other address as a human — contributors are not allowlisted', () => {
expect(isToolCoAuthor('jane@example.com', policy.toolCoAuthors)).toBe(false)
expect(isToolCoAuthor('ayo@ayco.io', policy.toolCoAuthors)).toBe(false)
expect(isToolCoAuthor('1234+someone@users.noreply.github.com', policy.toolCoAuthors)).toBe(false)
expect(isToolCoAuthor('', policy.toolCoAuthors)).toBe(false)
})
it('does not match a lookalike domain', () => {
expect(isToolCoAuthor('someone@anthropic.com.example.com', policy.toolCoAuthors)).toBe(false)
})
})
describe('checkStructural: co-author trailers', () => {
it('allows an outside human contributor using any address', () => {
expect(checkStructural('Co-authored-by: Jane Dev <jane@example.com>', policy)).toEqual([])
expect(checkStructural('Co-authored-by: Ayo <ayo@ayco.io>', policy)).toEqual([])
})
it('flags a tool trailer, whatever the casing', () => {
const findings = checkStructural('Co-Authored-By: Some Model <noreply@anthropic.com>', policy)
expect(findings).toHaveLength(1)
expect(findings[0].rule).toBe('tool-co-author')
})
it('allows a trailer with no address — a human typo, not a tool', () => {
expect(checkStructural('Co-authored-by: Jane Dev', policy)).toEqual([])
})
it('ignores prose that merely mentions co-authors', () => {
expect(checkStructural('The docs explain how co-authored-by trailers work', policy)).toEqual([])
})
})
describe('checkStructural: tool attribution', () => {
it('flags an attribution footer', () => {
for (const line of [
'Generated with SomeTool Code',
'Created by an assistant',
'Authored with a coding agent',
'written by some bot',
'🤖 a marker',
'This is an AI-generated change',
'llm-assisted refactor'
]) {
expect(checkStructural(line, policy), line).toHaveLength(1)
}
})
it('leaves normal project text alone', () => {
for (const line of [
'fix: send the right userAgent header',
'chore(deps): bump vite from 8.0.2 to 8.0.3',
'Dependabot will resolve any conflicts with this PR as long as you do not alter it yourself.',
'You can trigger a rebase by commenting on this PR.',
'The changelog is generated from the commit history',
'Report generated by the build on every release',
'feat: add an agent-facing docs page',
'This board was created by the player, not the seed',
'The AI opponent picks a move at random'
]) {
expect(checkStructural(line, policy), line).toEqual([])
}
})
})
describe('checkStructural: session links', () => {
it('flags a session URL or token', () => {
for (const line of [
'See https://example.com/session/abc123def456ghi7 for context',
'https://tool.example.com/sessions/AbC1-2345_678',
'ref session_0123456789abcdefghij'
]) {
expect(checkStructural(line, policy), line).toHaveLength(1)
}
})
it('leaves ordinary links and session wording alone', () => {
for (const line of [
'See https://example.com/docs/sessions for the session guide',
'The play session ends when the timer stops',
'store.set("session_id", id)',
'https://example.com/session/ab12'
]) {
expect(checkStructural(line, policy), line).toEqual([])
}
})
})
describe('scanText', () => {
it('reports the location of each finding', () => {
const text = 'chore: tidy up\n\nGenerated with SomeTool Code'
expect(scanText(text, (line) => `MSG:${line}`, policy)).toEqual([
{ location: 'MSG:3', rule: 'tool-attribution', detail: 'Generated with SomeTool Code' }
])
})
it('never echoes a reserved term, only its index and a masked preview', () => {
const findings = scanText('a wombat cactus fix', () => 'branch name', withTerms('wombat cactus'))
expect(findings).toEqual([
{ location: 'branch name', rule: 'reserved-term', detail: 'digest #0 (w************)' }
])
expect(JSON.stringify(findings)).not.toContain('wombat')
})
it('passes clean text', () => {
expect(scanText('chore: add contribution content checks', () => 'commit message', policy)).toEqual([])
})
it('skips the line after an allow marker, and only that line', () => {
const text = [
'<!-- content-policy: allow-next-line -->',
'a page about AI-assisted development',
'a second AI-assisted line'
].join('\n')
const findings = scanText(text, (line) => `notes.md:${line}`, policy)
expect(findings).toHaveLength(1)
expect(findings[0].location).toBe('notes.md:3')
})
})
describe('isAllowedByMarker', () => {
it('recognizes the marker in a comment of any flavor', () => {
expect(isAllowedByMarker('<!-- content-policy: allow-next-line -->')).toBe(true)
expect(isAllowedByMarker('// content-policy: allow-next-line')).toBe(true)
})
it('is false for anything else, including a missing line', () => {
expect(isAllowedByMarker('// just a comment')).toBe(false)
expect(isAllowedByMarker(undefined)).toBe(false)
})
})
describe('parseAddedLines', () => {
const diff = [
'diff --git a/docs/notes.md b/docs/notes.md',
'index 1234567..89abcde 100644',
'--- a/docs/notes.md',
'+++ b/docs/notes.md',
'@@ -4,0 +5,2 @@ heading',
'+first added line',
'+second added line',
'diff --git a/old.md b/old.md',
'--- a/old.md',
'+++ /dev/null',
'@@ -1 +0,0 @@',
'-a removed line',
''
].join('\n')
it('pulls added lines with their new line numbers', () => {
expect(parseAddedLines(diff)).toEqual([
{ file: 'docs/notes.md', line: 5, text: 'first added line' },
{ file: 'docs/notes.md', line: 6, text: 'second added line' }
])
})
it('ignores removed lines and deleted files', () => {
expect(parseAddedLines(diff).some((added) => added.text.includes('removed'))).toBe(false)
})
it('returns nothing for an empty diff', () => {
expect(parseAddedLines('')).toEqual([])
})
})
describe('parseRange', () => {
it('reads a merge-base range', () => {
expect(parseRange('origin/main...HEAD')).toEqual({ a: 'origin/main', b: 'HEAD', merged: true })
})
it('reads a two-dot range', () => {
expect(parseRange('abc123..def456')).toEqual({ a: 'abc123', b: 'def456', merged: false })
})
it('rejects a non-range', () => {
expect(() => parseRange('HEAD')).toThrow()
})
})

41
test/date-bucket.test.js Normal file
View file

@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { dayKey, weekKey, monthKey, buckets } from '../utils/date-bucket/date-bucket.js'
describe('date-bucket', () => {
it('builds a zero-padded UTC day key', () => {
expect(dayKey(new Date('2026-07-03T12:00:00Z'))).toBe('2026-07-03')
expect(dayKey(new Date('2026-01-09T00:00:00Z'))).toBe('2026-01-09')
})
it('builds a UTC month key', () => {
expect(monthKey(new Date('2026-07-03T12:00:00Z'))).toBe('2026-07')
expect(monthKey(new Date('2026-12-31T23:59:59Z'))).toBe('2026-12')
})
it('builds an ISO week key', () => {
// 2026-07-03 falls in ISO week 27
expect(weekKey(new Date('2026-07-03T12:00:00Z'))).toBe('2026-W27')
// 2026-01-01 is a Thursday -> ISO week 1 of 2026
expect(weekKey(new Date('2026-01-01T00:00:00Z'))).toBe('2026-W01')
})
it('rolls the ISO week-year back at the January boundary', () => {
// 2027-01-01 is a Friday -> still ISO week 53 of 2026
expect(weekKey(new Date('2027-01-01T00:00:00Z'))).toBe('2026-W53')
})
it('uses UTC, not local time', () => {
// Just before UTC midnight is still the 3rd in UTC
expect(dayKey(new Date('2026-07-03T23:59:59Z'))).toBe('2026-07-03')
// Just after is the 4th
expect(dayKey(new Date('2026-07-04T00:00:01Z'))).toBe('2026-07-04')
})
it('returns all three keys together', () => {
expect(buckets(new Date('2026-07-03T12:00:00Z'))).toEqual({
day: '2026-07-03',
week: '2026-W27',
month: '2026-07'
})
})
})

31
test/levels.test.js Normal file
View file

@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { levels } from '../lib/levels.js'
describe('levels', () => {
it('defines the four expected difficulty presets', () => {
expect(Object.keys(levels)).toEqual([
'beginner',
'intermediate',
'expert',
'nightmare'
])
})
it('gives every level a well-formed, consistent shape', () => {
for (const [key, level] of Object.entries(levels)) {
expect(typeof level.rows).toBe('number')
expect(typeof level.cols).toBe('number')
expect(typeof level.mines).toBe('number')
expect(typeof level.name).toBe('string')
// the map key must match the level's own id, since lookups use both
expect(level.id).toBe(key)
}
})
it('never has more mines than there are cells', () => {
for (const level of Object.values(levels)) {
expect(level.mines).toBeLessThan(level.rows * level.cols)
expect(level.mines).toBeGreaterThan(0)
}
})
})

179
test/minesweeper.test.js Normal file
View file

@ -0,0 +1,179 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import Minesweeper from '../lib/mnswpr.js'
// Build a fresh board mounted on #app and return its grid <table>.
function mountGame() {
document.body.innerHTML = '<div id="app"></div>'
const game = new Minesweeper('app', 'dev')
game.initialize()
return document.getElementById('grid')
}
function leftClick(cell) {
cell.dispatchEvent(new MouseEvent('mousedown', { button: 0, bubbles: true }))
cell.dispatchEvent(new MouseEvent('mouseup', { button: 0, bubbles: true }))
}
function rightClick(cell) {
cell.dispatchEvent(new MouseEvent('mousedown', { button: 2, bubbles: true }))
}
function everyCell(grid, fn) {
for (let i = 0; i < grid.rows.length; i++) {
for (let j = 0; j < grid.rows[i].cells.length; j++) {
fn(grid.rows[i].cells[j])
}
}
}
describe('Minesweeper board', () => {
beforeEach(() => {
localStorage.clear()
// Fake timers stop the requestAnimationFrame game clock from running during tests.
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
// Mount a game on a custom board injected via the cached 'setting' localStorage key.
function mountCustomGame(setting, hooks) {
localStorage.setItem('setting', JSON.stringify(setting))
document.body.innerHTML = '<div id="app"></div>'
const game = new Minesweeper('app', 'dev', hooks)
game.initialize()
return document.getElementById('grid')
}
it('renders the beginner grid (9x9) by default', () => {
const grid = mountGame()
expect(grid.rows.length).toBe(9)
everyCell(grid, cell => expect(cell.parentNode.cells.length).toBe(9))
})
it('starts inactive with every cell in the default state', () => {
const grid = mountGame()
expect(grid.getAttribute('game-status')).toBe('inactive')
everyCell(grid, cell => expect(cell.getAttribute('data-status')).toBe('default'))
})
it('activates the game and reveals the cell on the first click', () => {
const grid = mountGame()
const cell = grid.rows[0].cells[0]
leftClick(cell)
expect(grid.getAttribute('game-status')).not.toBe('inactive')
expect(cell.getAttribute('data-status')).not.toBe('default')
})
it('never loses on the first click, across many random boards', () => {
// Exercises mine placement + first-click mine transfer (the Set-backed logic).
for (let i = 0; i < 30; i++) {
const grid = mountGame()
leftClick(grid.rows[0].cells[0])
expect(grid.getAttribute('game-status')).not.toBe('over')
}
})
it('flags and unflags a cell on right click', () => {
const grid = mountGame()
const cell = grid.rows[0].cells[0]
rightClick(cell)
expect(cell.getAttribute('data-status')).toBe('flagged')
expect(cell.className).toBe('flag')
rightClick(cell)
expect(cell.getAttribute('data-status')).toBe('default')
})
it('resets the board back to the inactive state', () => {
const grid = mountGame()
leftClick(grid.rows[0].cells[0])
expect(grid.getAttribute('game-status')).not.toBe('inactive')
const resetButton = document.querySelector('#game-board button')
resetButton.dispatchEvent(new MouseEvent('mousedown', { button: 0, bubbles: true }))
expect(grid.getAttribute('game-status')).toBe('inactive')
everyCell(grid, cell => expect(cell.getAttribute('data-status')).toBe('default'))
})
it('highlights the pressed cell and clears it when the press moves away', () => {
const grid = mountGame()
const a = grid.rows[5].cells[5]
const b = grid.rows[5].cells[6]
const c = grid.rows[5].cells[7]
// Press-and-hold left on A -> A highlighted.
a.dispatchEvent(new MouseEvent('mousedown', { button: 0, bubbles: true }))
expect(a.getAttribute('data-status')).toBe('highlighted')
// Drag the held press across B then C. Each move must clear the previous
// highlight and leave no stale ones behind.
b.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
c.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
expect(a.getAttribute('data-status')).toBe('default')
expect(b.getAttribute('data-status')).toBe('default')
expect(c.getAttribute('data-status')).toBe('highlighted')
// Exactly one cell should remain highlighted across the whole grid.
let highlighted = 0
everyCell(grid, cell => {
if (cell.getAttribute('data-status') === 'highlighted') highlighted++
})
expect(highlighted).toBe(1)
})
it('declares a win once every safe cell is revealed', () => {
// A mine-free 3x3 board: one click cascades to reveal all 9 safe cells.
let finished = null
const grid = mountCustomGame(
{ rows: 3, cols: 3, mines: 0, id: 'test', name: 'test' },
{ levelChanged: () => {}, gameDone: (g) => { finished = g.status } }
)
leftClick(grid.rows[1].cells[1])
expect(finished).toBe('win')
expect(grid.getAttribute('game-status')).toBe('done')
everyCell(grid, cell => expect(cell.getAttribute('data-status')).not.toBe('default'))
})
it('does not declare a win until the last safe cell is revealed', () => {
// 1x3 board with the single mine pinned to the middle column.
vi.spyOn(Math, 'random').mockReturnValue(0.5)
const grid = mountCustomGame({ rows: 1, cols: 3, mines: 1, id: 'test', name: 'test' })
// First safe cell: adjacent to the mine, shows "1", no cascade -> not yet won.
leftClick(grid.rows[0].cells[0])
expect(grid.getAttribute('game-status')).toBe('active')
// Revealing the remaining safe cell completes the board.
leftClick(grid.rows[0].cells[2])
expect(grid.getAttribute('game-status')).toBe('done')
})
it('flood fill stops at a flagged cell and never reveals it', () => {
// 1x4 board with the mine pinned to the last column.
vi.spyOn(Math, 'random').mockReturnValue(0.99)
const grid = mountCustomGame({ rows: 1, cols: 4, mines: 1, id: 'test', name: 'test' })
// Flag a safe cell sitting between the blank region and the mine.
// (full press + release so the internal right-button flag resets)
const flag = grid.rows[0].cells[2]
flag.dispatchEvent(new MouseEvent('mousedown', { button: 2, bubbles: true }))
flag.dispatchEvent(new MouseEvent('mouseup', { button: 2, bubbles: true }))
expect(flag.getAttribute('data-status')).toBe('flagged')
// Click the far blank cell; the cascade must stop at the flag.
leftClick(grid.rows[0].cells[0])
expect(grid.rows[0].cells[0].getAttribute('data-status')).toBe('empty')
expect(flag.getAttribute('data-status')).toBe('flagged')
// A safe cell is still hidden (behind the flag), so it is not a win.
expect(grid.getAttribute('game-status')).toBe('active')
})
})

31
test/storage.test.js Normal file
View file

@ -0,0 +1,31 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { StorageService } from '../utils/storage/storage.js'
describe('StorageService', () => {
let storage
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
storage = new StorageService()
})
it('round-trips an object through localStorage', () => {
storage.saveToLocal('setting', { rows: 9, cols: 9, mines: 10 })
expect(storage.getFromLocal('setting')).toEqual({ rows: 9, cols: 9, mines: 10 })
})
it('round-trips an object through sessionStorage', () => {
storage.saveToSession('setting', { id: 'beginner' })
expect(storage.getFromSession('setting')).toEqual({ id: 'beginner' })
})
it('returns null for a missing key', () => {
expect(storage.getFromLocal('missing')).toBeNull()
})
it('treats a stored undefined as undefined, not a thrown parse error', () => {
storage.saveToLocal('setting', undefined)
expect(storage.getFromLocal('setting')).toBeUndefined()
})
})

95
test/timer.test.js Normal file
View file

@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { TimerService } from '../utils/timer/timer.js'
describe('TimerService.pretty', () => {
const timer = new TimerService()
it('returns undefined for a falsy duration', () => {
expect(timer.pretty(0)).toBeUndefined()
expect(timer.pretty(undefined)).toBeUndefined()
})
it('drops leading zero-value units', () => {
// 1500ms -> 1 second, 5 tenths, with hours/minutes stripped
expect(timer.pretty(1500)).toBe('01.5')
})
it('keeps minutes once they are non-zero', () => {
// 65_000ms -> 1 minute 5 seconds
expect(timer.pretty(65_000)).toBe('01:05.0')
})
})
describe('TimerService.clean', () => {
const timer = new TimerService()
it('blanks out a zeroed unit', () => {
expect(timer.clean('00', ':')).toBe('')
})
it('appends the separator to a non-zero unit', () => {
expect(timer.clean('05', ':')).toBe('05:')
})
})
describe('TimerService rendering', () => {
let display
beforeEach(() => {
vi.useFakeTimers()
display = document.createElement('span')
})
afterEach(() => {
vi.useRealTimers()
})
it('shows 0 before the timer starts', () => {
const timer = new TimerService()
timer.initialize(display)
expect(display.innerHTML).toBe('0')
})
it('drives updates with requestAnimationFrame, not a 1ms interval', () => {
const raf = vi.spyOn(window, 'requestAnimationFrame')
const interval = vi.spyOn(window, 'setInterval')
const timer = new TimerService()
timer.initialize(display)
timer.start()
expect(raf).toHaveBeenCalled()
expect(interval).not.toHaveBeenCalled()
})
it('only touches the DOM when the displayed value changes', () => {
const timer = new TimerService()
timer.initialize(display)
timer.start()
let writes = 0
const span = { set innerHTML(_v) { writes++ } }
timer.display = span
// Same tenth-of-a-second value across frames -> a single DOM write.
timer.time = 1500
timer.render()
timer.render()
timer.render()
expect(writes).toBe(1)
// A new value -> one more write.
timer.time = 1600
timer.render()
expect(writes).toBe(2)
})
it('returns the elapsed time in milliseconds from stop()', () => {
const timer = new TimerService()
timer.initialize(display)
timer.start()
vi.advanceTimersByTime(2500)
timer.tick()
expect(timer.stop()).toBe(2500)
})
})

View file

@ -0,0 +1,55 @@
/**
* Time-bucket keys for leaderboards, computed in UTC so every player shares the
* same boundaries worldwide. Each key is a plain string denormalized onto a
* score document, letting a "Today / Week / Month" query be a cheap equality
* filter instead of a range scan.
*/
const pad = n => (n < 10 ? `0${n}` : `${n}`)
/**
* Calendar-day key, e.g. `2026-07-03`.
* @param {Date} date
* @returns {String}
*/
export const dayKey = date => {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
}
/**
* Calendar-month key, e.g. `2026-07`.
* @param {Date} date
* @returns {String}
*/
export const monthKey = date => {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}`
}
/**
* ISO-8601 week key, e.g. `2026-W27`. Weeks start Monday and the week-numbering
* year can differ from the calendar year around January 1st, so we derive the
* year from the week's Thursday.
* @param {Date} date
* @returns {String}
*/
export const weekKey = date => {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
const dayNum = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum)
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
const week = Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
return `${d.getUTCFullYear()}-W${pad(week)}`
}
/**
* All three bucket keys for a moment in time.
* @param {Date} date
* @returns {{ day: String, week: String, month: String }}
*/
export const buckets = date => {
return {
day: dayKey(date),
week: weekKey(date),
month: monthKey(date)
}
}

6
utils/index.js Normal file
View file

@ -0,0 +1,6 @@
export * from './logger/logger.js'
export * from './storage/storage.js'
export * from './timer/timer.js'
export * from './loading/loading.js'
export * from './date-bucket/date-bucket.js'

55
utils/loading/loading.css Normal file
View file

@ -0,0 +1,55 @@
.lds-ellipsis {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.lds-ellipsis div {
position: absolute;
top: 33px;
width: 13px;
height: 13px;
border-radius: 50%;
background: #fff;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {
left: 8px;
animation: lds-ellipsis1 0.6s infinite;
}
.lds-ellipsis div:nth-child(2) {
left: 8px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(3) {
left: 32px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(4) {
left: 56px;
animation: lds-ellipsis3 0.6s infinite;
}
@keyframes lds-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes lds-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes lds-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}

13
utils/loading/loading.js Normal file
View file

@ -0,0 +1,13 @@
/**
* import styles for vite bundling
*/
import './loading.css'
export class LoadingService {
addLoading(element) {
element.innerHTML = '<div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div>'
}
removeLoading(element) {
element.innerHTML = ''
}
}

13
utils/logger/logger.js Normal file
View file

@ -0,0 +1,13 @@
export class LoggerService {
debug(message, data) {
if (typeof message === 'string') {
if (data) {
console.log(message, data)
} else {
console.log(message)
}
} else {
console.warn(`LoggerService.debug expects a string as first parameter but got a ${typeof message}`, message)
}
}
}

23
utils/storage/storage.js Normal file
View file

@ -0,0 +1,23 @@
export class StorageService {
constructor() {
}
saveToLocal(key, value) {
localStorage.setItem(key, JSON.stringify(value))
}
saveToSession(key, value) {
sessionStorage.setItem(key, JSON.stringify(value))
}
getFromLocal(key) {
const data = localStorage.getItem(key)
if (data !== 'undefined') return JSON.parse(data)
}
getFromSession(key) {
const data = sessionStorage.getItem(key)
if (data !== 'undefined') return JSON.parse(data)
}
}

87
utils/timer/timer.js Normal file
View file

@ -0,0 +1,87 @@
import { LoggerService } from '../logger/logger'
export class TimerService {
constructor() {
this.loggerService = new LoggerService()
this.time = 0
this.rendered = undefined
}
initialize(el) {
if (!el) return
this.display = el
this.startTime = undefined
if (this.id !== undefined) {
this.stop()
}
this.time = 0
this.render()
}
start() {
if (this.running || !this.display) return
this.running = true
this.startTime = Date.now()
this.tick()
this.loggerService.debug('started timer')
}
stop() {
this.running = false
if (this.id !== undefined) {
window.cancelAnimationFrame(this.id)
}
this.id = undefined
this.loggerService.debug('stopped timer')
return this.time
}
/**
* Recompute the elapsed time and schedule the next frame.
* Driven by requestAnimationFrame so it aligns with the browser's paint
* cadence and pauses automatically when the tab is hidden instead of the
* old fixed 1ms interval that fired ~1000 times a second.
*/
tick() {
this.time = Date.now() - this.startTime
this.render()
if (this.running) {
this.id = window.requestAnimationFrame(() => this.tick())
}
}
/**
* Write to the DOM only when the visible value actually changes. The display
* has 100ms (tenths-of-a-second) resolution, so most frames are a no-op and
* cost no reflow.
*/
render() {
if (!this.display) return
const text = this.pretty(this.time) || '0'
if (text !== this.rendered) {
this.display.innerHTML = text
this.rendered = text
}
}
pretty(duration) {
if (!duration) return undefined
var milliseconds = parseInt((duration % 1000) / 100),
seconds = Math.floor((duration / 1000) % 60),
minutes = Math.floor((duration / (1000 * 60)) % 60),
hours = Math.floor((duration / (1000 * 60 * 60)) % 24)
hours = (hours < 10) ? `0${hours}` : hours
minutes = (minutes < 10) ? `0${minutes}` : minutes
seconds = (seconds < 10) ? `0${seconds}` : seconds
return `${this.clean(hours, ':')}${this.clean(minutes, ':')}${this.clean(seconds, '.')}${this.clean(milliseconds, '')}`
}
clean(str, separator) {
return (str === '00') ? '' : `${str}${separator}`
}
}

View file

@ -1,22 +0,0 @@
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
const root = resolve(import.meta.dirname, 'src')
// App source lives in src/; env files and the build output stay at the repo root.
export default defineConfig({
root,
// .env* files sit at the repo root next to package.json, not in src/.
envDir: import.meta.dirname,
build: {
outDir: resolve(import.meta.dirname, 'dist'),
emptyOutDir: true,
// Multi-page build: the game (index.html) and the frozen Legends page.
rollupOptions: {
input: {
main: resolve(root, 'index.html'),
legends: resolve(root, 'legends.html')
}
}
}
})

View file

@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
include: ['test/**/*.test.js', 'scripts/test/**/*.test.js']
include: ['test/**/*.test.js']
}
})