Compare commits

...

87 commits

Author SHA1 Message Date
Ayo
d5109b8dc9 chore: update readme
Some checks failed
Checks / lint (push) Has been cancelled
Checks / test (push) Has been cancelled
Checks / content (push) Has been cancelled
2026-07-18 22:26:19 +02:00
Ayo
93e8258270 chore: update repo policy to check tool co-authors
Some checks are pending
Checks / lint (push) Waiting to run
Checks / test (push) Waiting to run
Checks / content (push) Waiting to run
2026-07-18 22:04:35 +02:00
Ayo
f4ac3b0c12 chore: organize repo
Some checks are pending
Checks / lint (push) Waiting to run
Checks / test (push) Waiting to run
Checks / content (push) Waiting to run
2026-07-18 22:00:07 +02:00
Ayo
3b17a596c6 feat: use app version instead of packate
Some checks are pending
Checks / lint (push) Waiting to run
Checks / test (push) Waiting to run
Checks / content (push) Waiting to run
2026-07-18 21:20:57 +02:00
Ayo
f6b71023ec chore: flatten the app workspace
Some checks are pending
Checks / lint (push) Waiting to run
Checks / test (push) Waiting to run
Checks / content (push) Waiting to run
2026-07-18 21:12:24 +02:00
Ayo
5b19bcd324 chore: init agents instructions
Some checks are pending
Checks / lint (push) Waiting to run
Checks / test (push) Waiting to run
Checks / content (push) Waiting to run
2026-07-18 20:59:25 +02:00
ayo
6e9cb93a12
chore: restore mnswpr to a standalone app repo (#54)
Some checks are pending
Checks / lint (push) Waiting to run
Checks / test (push) Waiting to run
Checks / content (push) Waiting to run
Move the @cozy-games/* packages, sites, docs, and package-publish tooling out
to github.com/ayo-run/cozy-games. The app now consumes those packages from npm
instead of the workspace. Root config, workspace globs, and CI target the app.
2026-07-18 20:47:19 +02:00
Ayo
a26c2c5055 feat(mnswpr): type GameSession.onMove 2026-07-17 22:56:51 +02:00
Ayo
310b657e32 chore: use pnpm publish in publish-lib script 2026-07-17 22:45:48 +02:00
Ayo
e50eb001db feat(mnswpr): re-export mnswpr types 2026-07-17 22:34:35 +02:00
Ayo
af649d9c90 chore: set correct licenses 2026-07-17 08:38:12 +02:00
Ayo
900983fb14 chore: bump leaderboard to fix missing dep 2026-07-17 08:12:40 +02:00
dependabot[bot]
6727a3013c
chore(deps-dev): bump vite from 8.0.3 to 8.0.16 (#50)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.3 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 21:45:30 +02:00
ayo
0a3571e2f4
chore: add contribution content checks (#53)
Adds scripts/check-content.mjs, a zero-dependency scanner for commits, branch
names, PR text, and contributed lines. It checks structural rules (attribution
trailers and footers, session links) and a maintainer-managed reserved-terms
list, shipped as salted digests in .repo-policy.json. Findings report a location
and a masked preview, never the matched term.

Wired up opt-in: the pre-commit hook scans the staged diff and branch name, a new
commit-msg hook scans the message, and the Checks workflow gates PRs and pushes
to main. The digest list ships empty; structural checks run regardless.
2026-07-16 21:17:44 +02:00
ayo
287dc0885b
chore: relicense to MIT (#52) 2026-07-16 21:15:49 +02:00
Ayo
a7247859ac chore: bump packages for release 2026-07-16 20:43:03 +02:00
Ayo
32cf38c452 chore: bump packages for release 2026-07-16 20:33:57 +02:00
ayo
9b220829f7
feat: package types, usability improvements 2026-07-16 19:07:13 +02:00
ayo
2aaba5d904
chore: update docs 2026-07-16 16:10:46 +02:00
Ayo
a0e2970b55 chore: update packages published status 2026-07-06 01:56:44 +02:00
Ayo
3418be3d7b chore: move to @cozy-games/mnswpr 2026-07-06 00:11:22 +02:00
Ayo
2d7f7d0ebb chore: build scripts 2026-07-06 00:09:02 +02:00
Ayo
41829086c5 chore: fix typo in readme :) 2026-07-05 13:18:14 +02:00
Ayo
17724ebe8f chore: update README note on project evolution 2026-07-05 13:17:19 +02:00
Ayo
cc44789829 refactor: use wcb v5 createElement-safe static props 2026-07-05 13:03:08 +02:00
Ayo
9079ab0848 refactor(leaderboard-element): wcb usage improvements 2026-07-05 06:43:12 +02:00
Ayo
26277986a0 refactor(leaderboard-element): use web-component-base APIs 2026-07-04 14:39:15 +02:00
Ayo
c2ffb0d7f6 feat(leaderboard): read-write separation 2026-07-04 13:54:03 +02:00
Ayo
cd2b30ecc8 feat(leaderboard): bring your own backend 2026-07-04 13:45:57 +02:00
Ayo
1ca1d63ea7 chore: update readme 2026-07-04 13:42:45 +02:00
Ayo
0b9866e05f feat(replay): add a defined "ended" signal (onEnd) 2026-07-04 13:39:48 +02:00
Ayo
43b75d7b0e feat(replay): schema dispatch 2026-07-04 13:34:32 +02:00
Ayo
998aeacbb2 feat(replay): state reducer & full board playback 2026-07-04 13:29:52 +02:00
Ayo
072d2f7369 feat(replay): progress mode 2026-07-04 13:21:48 +02:00
Ayo
6b38088d4b feat(mnswpr): progress reducer adapter 2026-07-04 13:17:40 +02:00
Ayo
6f9a8031e9 feat(replay): progress reducer interface 2026-07-04 13:06:58 +02:00
Ayo
bafe1db285 feat(replay): replay engine 2026-07-04 12:56:29 +02:00
Ayo
0dab463e84 chore: update readme with note & package status 2026-07-04 12:36:23 +02:00
Ayo
4ccec5a116 feat(move-log): accept timestamps 2026-07-04 12:34:16 +02:00
Ayo
e1c168a9bb feat(move-log): serialization 2026-07-04 12:29:47 +02:00
Ayo
3bfb1931cc chore: update readme :) 2026-07-04 12:23:31 +02:00
Ayo
dd9e020214 chore: update packages status on readme 2026-07-04 12:21:59 +02:00
Ayo
6c90cde0f9 feat(move-log): game-agnostic move events 2026-07-04 12:21:06 +02:00
Ayo
0823d43c75 feat(core): move events 2026-07-04 12:02:10 +02:00
Ayo
d3d84aa9b8 feat(core): resume games 2026-07-04 11:52:38 +02:00
Ayo
d482b17976 feat(core): serializeable game state 2026-07-04 11:48:08 +02:00
Ayo
8099820e79 feat(core): board injection 2026-07-04 11:39:55 +02:00
Ayo
916d04d0d9 feat(mnswpr): first-move safety 2026-07-04 11:32:01 +02:00
Ayo
d7a9f61a9e refactor: core generateBoard function 2026-07-04 11:24:25 +02:00
Ayo
5a7eea3e12 chore: update packages table with published or documented :) 2026-07-04 11:14:05 +02:00
Ayo
744825c771 chore: fix messed up markdown :) 2026-07-04 10:46:30 +02:00
Ayo
9aec20edbf chore: add roadmap to readme 2026-07-04 10:35:55 +02:00
Ayo
6afd15aa35 chore: headless design 2026-07-04 10:05:43 +02:00
Ayo
8b66249edf chore: Add CONTRIBUTING.md guide for humans 2026-07-04 09:55:54 +02:00
Ayo
5ec22fe37e chore: add contribution guidance 2026-07-04 09:19:22 +02:00
Ayo
062b912549 feat: mnswpr client 2026-07-04 08:17:54 +02:00
Ayo
042d6b26e9 feat: mnswpr core 2026-07-04 07:49:46 +02:00
Ayo
7cbf9baaa8 chore: security hardening 2026-07-03 23:04:52 +02:00
Ayo
0532fd6d66 chore: infra tooling and configs 2026-07-03 21:54:29 +02:00
Ayo
7685a6dea7 chore: monorepo scripts (dev, build, db, etc) 2026-07-03 20:25:33 +02:00
Ayo
74551f2023 chore: move mnswpr db migration doc to app dir 2026-07-03 16:14:15 +02:00
Ayo
969ebba067 chore: restructure to new cozy-games monorepo
Reviewed-on: https://git.ayo.run/ayo/cozy-games/pulls/1
Co-authored-by: Ayo <ayo@ayco.io>
Co-committed-by: Ayo <ayo@ayco.io>
2026-07-03 13:53:13 +00:00
Ayo
5f6e389258 chore: update readme message 2026-07-03 15:46:50 +02:00
Ayo
7b68c677ef chore: add message of move to cozy-games 2026-07-03 15:45:20 +02:00
Ayo
4fa88fb431 feat: reusable, time-windowed leaderboard (@cozy-games/leaderboard)
Extract the leaderboard into a generic, backend-agnostic package and add
rolling Today/Week/Month/All-Time windows, a web component, and local-first
development against the Firestore emulator.

Package (leaderboard/):
- LeaderBoardService: backend-agnostic core via a storage-adapter seam, with
  Firebase and Supabase adapters (Supabase client injected, no added dep)
- Rolling time windows (last 24h / 7d / 30d) with hover tooltips; top-N by score
- <cozy-leaderboard> web component built on web-component-base: compose the UI
  in HTML, configure the backend once in JS
- Every user-facing string is configurable (labels, tooltips, empty/loading/
  error messages, anonymous name) so i18n lives in the app; README + CONFIGURATION

App (mnswpr.com):
- Compose the board declaratively in index.html via <cozy-leaderboard>
- Nickname + randomized greeting bar; score submission through the element
- Legends: the current all-time leaders frozen into a static /legends page
- Firebase config and leaderboard namespace via Vite env vars; emulator-first
  local dev (VITE_FIRESTORE_EMULATOR)

Firebase schema-as-code:
- firebase.json, .firebaserc, firestore.rules (public reads, create-only scores,
  no client updates/deletes, namespace-generalized), empty indexes (rolling
  windows need none)
- prod (mw-*) vs dev/test (mw-test-*) separation by collection namespace
- emulator config, seed script, and docs (firebase-leaderboards.md,
  leaderboard-env-migration.md, AYO.md)

Utils/tests: UTC date-bucket helper (retained as metadata) with Vitest coverage.

Reviewed-on: https://git.ayo.run/ayo/mnswpr/pulls/1
Co-authored-by: Ayo <ayo@ayco.io>
Co-committed-by: Ayo <ayo@ayco.io>
2026-07-03 13:42:03 +02:00
Ayo
28b90b1dab chore: update readme 2026-07-03 10:32:15 +02:00
Ayo
207e253c69 chore: add sreenshot 2026-07-03 10:29:17 +02:00
Ayo
b6f43751d9 chore: manual npm login on publish 2026-07-03 10:14:32 +02:00
Ayo
8afd8942aa chore: release v0.4.36
Some checks failed
Release / release (push) Has been cancelled
2026-07-03 10:12:31 +02:00
Ayo
0704c1e2ec chore: README.md is same in repo as package 2026-07-03 10:11:56 +02:00
Ayo
8774bc8092 chore: update readme 2026-07-03 10:00:25 +02:00
Ayo
21bfce4c04 chore: refactor the release script 2026-07-03 09:16:32 +02:00
Ayo
2038badb13 chore: release v0.4.35
Some checks failed
Release / release (push) Has been cancelled
2026-07-02 21:54:45 +02:00
Ayo
76e95d20da refactor: flood-fill logic 2026-07-02 21:54:09 +02:00
Ayo
a1742e3a58 perf: win check now tracks safe cells revealed instead of scanning the whole grid 2026-07-02 21:17:29 +02:00
Ayo
a2d8d975ec refactor: separate globalEventHandlers from cell-specific event handlers 2026-07-02 21:10:53 +02:00
Ayo
749b47fe40 perf: track a small list of highlighted cells 2026-07-02 20:55:03 +02:00
Ayo
6e6cf2c4f8 perf: render timer less frequently 2026-07-02 20:46:46 +02:00
Ayo
bf26d3e80a test: add tests 2026-07-02 20:31:29 +02:00
Ayo
1bd3135dac perf: improve mines lookup 2026-07-02 20:31:14 +02:00
Ayo
81f310e7cc chore: initial agentic instructions 2026-07-02 19:32:52 +02:00
Ayo
35e9f4441e chore: update package manager 2026-06-24 14:21:01 +02:00
Ayo
8fcaeecf41 chore: release v0.4.34
Some checks failed
Release / release (push) Has been cancelled
2026-06-22 22:15:42 +02:00
Ayo
18a7713149 feat(app): remove links to npm & github 2026-06-22 22:15:31 +02:00
Ayo
b8a1116494 chore: release v0.4.33
Some checks failed
Release / release (push) Has been cancelled
2026-06-22 22:06:50 +02:00
Ayo
113492cb97 feat(app): link to version releaseReset 2026-06-22 22:06:28 +02:00
Ayo
deeae32bfd chore: new release package script 2026-06-22 21:58:45 +02:00
62 changed files with 14956 additions and 1910 deletions

23
.env.development Normal file
View file

@ -0,0 +1,23 @@
# Development Firebase config (project: secure-moment-188701).
# These keys are public, not secrets — access is governed by firestore.rules —
# so this file is committed. Used by `pnpm dev`.
# Production values are supplied as Netlify env vars at build time (see
# docs/firebase-leaderboards.md); never put production values here.
VITE_FIREBASE_API_KEY=AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ
VITE_FIREBASE_AUTH_DOMAIN=moment-188701.firebaseapp.com
VITE_FIREBASE_DATABASE_URL=https://moment-188701.firebaseio.com
VITE_FIREBASE_PROJECT_ID=secure-moment-188701
VITE_FIREBASE_STORAGE_BUCKET=secure-moment-188701.firebasestorage.app
VITE_FIREBASE_MESSAGING_SENDER_ID=113827947104
VITE_FIREBASE_APP_ID=1:113827947104:web:b176f746d8358302c51905
VITE_FIREBASE_MEASUREMENT_ID=G-LZRDY0TG46
# Leaderboard collection namespace. Dev/test uses a separate set of collections
# (mw-test-scores / mw-test-all / mw-test-config) in the SAME Firebase project,
# so local play never pollutes the production board. Production sets this to `mw`.
VITE_LB_NAMESPACE=mw-test
# Local dev runs against the Firestore emulator by default (no cloud, no deploy).
# Start it with `pnpm emulators` and fill it via `pnpm seed:emulator`.
# To use the cloud `mw-test` namespace instead, set this empty in app/.env.local.
VITE_FIRESTORE_EMULATOR=1

21
.env.example Normal file
View file

@ -0,0 +1,21 @@
# Firebase config template. Copy the keys you need into an env file:
# - app/.env.development -> dev values (committed; used by `pnpm dev`)
# - app/.env.production -> prod values (gitignored; optional, for local
# prod builds and the Legends export). In hosting
# (Netlify) set these as environment variables.
# These Firebase keys are public (not secrets); access is governed by
# firestore.rules.
VITE_FIREBASE_API_KEY=
VITE_FIREBASE_AUTH_DOMAIN=
VITE_FIREBASE_DATABASE_URL=
VITE_FIREBASE_PROJECT_ID=
VITE_FIREBASE_STORAGE_BUCKET=
VITE_FIREBASE_MESSAGING_SENDER_ID=
VITE_FIREBASE_APP_ID=
VITE_FIREBASE_MEASUREMENT_ID=
# Leaderboard collection namespace: `mw` in production, `mw-test` for dev/previews.
VITE_LB_NAMESPACE=
# Set to 1 to point the app at a local Firestore emulator (dev only).
VITE_FIRESTORE_EMULATOR=

94
.github/workflows/checks.yml vendored Normal file
View file

@ -0,0 +1,94 @@
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"

View file

@ -1,26 +0,0 @@
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}}

23
.gitignore vendored
View file

@ -1,5 +1,24 @@
node_modules/
dist/
*.*~
*.*swp
.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).
.env.production
.env.local
.env.*.local
# Netlify CLI site link (written by `netlify link`)
.netlify/
# Firebase emulator artifacts
firebase-debug.log
firestore-debug.log
ui-debug.log
.emulator-data/
*~
*swo
*swp

2
.husky/commit-msg Normal file
View file

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

View file

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

11
.repo-policy.json Normal file
View file

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

5
.secretlintignore Normal file
View file

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

7
.secretlintrc.json Normal file
View file

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

136
AGENTS.md Normal file
View file

@ -0,0 +1,136 @@
# AGENTS.md
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>`.
## 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 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
```
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.
Node version: `.nvmrc` pins `lts/*`.
## Repository layout
A single package (`mnswpr`) rooted at the repo root.
- **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).
## 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.** `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(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.
**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.
**First-click safety:** the first clicked cell is never a mine — if it is, `transferMine()` relocates it to a non-neighboring empty cell before revealing.
**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.
## Leaderboard / Firebase
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`.
**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.
**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.
## 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.
## Git hooks
- `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.

7
CLAUDE.md Normal file
View file

@ -0,0 +1,7 @@
# 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).

131
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,131 @@
# 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,24 +1,21 @@
BSD 2-Clause License
MIT License
Copyright (c) 2019, Ayo Ayco
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:
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:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of 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.
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.

View file

@ -3,16 +3,35 @@
Play it here: [mnswpr.com](https://mnswpr.com). This is the classic game **Minesweeper** built with vanilla web technologies (i.e., no framework dependency).
Technology Stack: HTML, JS, and CSS; [Google Firebase](https://firebase.google.com) for leader board store; [Netlify](https://netlify.com) for hosting
![mnswpr gameplay](https://git.ayo.run/ayo/mnswpr/raw/branch/main/screenshot.png)
## Usage
## 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
## Ways to Use
The web is a wonderful, free, and open platform to create and distribute value. You can use **mnswpr** in different ways:
- as a deployed [web app](https://mnswpr.com)
- as a [library](https://npmx.dev/package/@ayo-run/mnswpr) with `npm i @ayo-run/mnswpr`
- as a [library](https://npmx.dev/package/@cozy-games/mnswpr) with `npm i @cozy-games/mnswpr`
- as a `web component` (coming soon).
Using it as a library takes only a few lines — mount it onto any element by `id`:
```js
import '@cozy-games/mnswpr/mnswpr.css'
import mnswpr from '@cozy-games/mnswpr'
const game = new mnswpr('app')
game.initialize()
```
## Tooling
The project has gone through years of existence. It started from 2019 when tooling was massively different. I have [modernized it](https://elk.zone/social.ayco.io/@ayo/116333804543330938) since and have witnessed how much easier and faster it is to build now - even without web frameworks or LLMs!
@ -21,17 +40,35 @@ 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 & workspace management
- [PNPM](https://pnpm.io/installation) for dependency 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**.
## Development
Technology Stack: HTML, JS, and CSS; [Google Firebase](https://firebase.google.com) for leader board store; [Netlify](https://netlify.com) for hosting
To start development, you need [`node`](https://nodejs.org/en/download). I highly recommend [`pnpm`](https://pnpm.io/installation) to be used as well. Once you know you have this, you can do the following:
1. Install dependencies: `pnpm i`
2. Start the dev server: `pnpm run dev`
## You just want to play?
The rest of the everyday commands:
*👉 The live site is here: [mnswpr.com](https://mnswpr.com)*
```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
```
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.
## Contributing
Contributions are welcome! See [`AGENTS.md`](./AGENTS.md) for the architecture, conventions, and release workflow before opening a pull request.
## 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.
@ -46,13 +83,15 @@ Can I make a page with complex interactions (more on this later) without any lib
## What I have learned:
1. JS is awesome ✨
1. We don't always necessarily *need* JS frameworks (or TS) ✨
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 you're app than you are initially aware of ✨
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 help issues in browser caching (when shipping versions fast) ✨
1. Hash in bundled filenames helps avoid issues with browser caching (when shipping versions fast) ✨
## License
[MIT](./LICENSE)
---

View file

View file

@ -1,45 +0,0 @@
import mnswpr from '@ayo-run/mnswpr/mnswpr.js'
import '@ayo-run/mnswpr/mnswpr.css'
import * as pkg from '@ayo-run/mnswpr/package.json'
import { LoadingService } from '../utils/'
import { LeaderBoardService } from './modules/leader-board/leader-board.js'
const leaderBoardService = new LeaderBoardService()
const loadingService = new LoadingService()
const version = import.meta.env.MODE === 'development'
? 'dev'
: pkg.version
const initializeGameBoard = async (level) => {
const prevousLeaderBoard = document.getElementById('leaderboard')
const prevousLoadingWrapper = document.getElementById('loading-wrapper')
prevousLoadingWrapper?.remove()
const loadingWrapper = document.createElement('div')
loadingWrapper.id = 'loading-wrapper'
loadingService.addLoading(loadingWrapper)
const appElement = document.getElementById('app')
if (prevousLeaderBoard){
const parent = prevousLeaderBoard.parentNode
parent.replaceChild(loadingWrapper, prevousLeaderBoard)
}else{
appElement.append(loadingWrapper)
}
const leaderBoardWrapper = await leaderBoardService.update(level.id, `Best Times (${level.name})`)
leaderBoardWrapper.id = 'leaderboard'
appElement.replaceChild(leaderBoardWrapper, loadingWrapper)
}
const sendGameResult = (game) => {
leaderBoardService.send(game, 'time')
}
const game = new mnswpr('app', version, {
levelChanged: (level) => initializeGameBoard(level),
gameDone: (game) => sendGameResult(game)
})
game.initialize()

View file

@ -1,176 +0,0 @@
import { TimerService } from '../../../utils/timer/timer'
import { LoggerService } from '../../../utils/logger/logger'
import { UserService } from '../user/user'
import { initializeApp } from 'firebase/app'
import {
getFirestore, doc, getDocs, getDoc, setDoc, collection, query, orderBy, limit
} from 'firebase/firestore/lite'
export class LeaderBoardService {
timerService = new TimerService()
loggerService = new LoggerService()
user = new UserService()
/**
*
* Create the Leader Board service
* @param {String} leaders
* @param {String} all
* @param {String} configuration
*/
constructor() {
// necessary keys to interact with firebase
// not a secret
// https://stackoverflow.com/questions/37482366/is-it-safe-to-expose-firebase-apikey-to-the-public/37484053#37484053
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const config = {
apiKey: 'AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ',
authDomain: 'moment-188701.firebaseapp.com',
databaseURL: 'https://moment-188701.firebaseio.com',
projectId: 'secure-moment-188701',
storageBucket: 'secure-moment-188701.firebasestorage.app',
messagingSenderId: '113827947104',
appId: '1:113827947104:web:b176f746d8358302c51905',
measurementId: 'G-LZRDY0TG46'
}
const app = initializeApp(config)
this._store = getFirestore(app)
const configRef = doc(this.store, 'mw-config', 'configuration')
getDoc(configRef)
.then(res => {
this.configuration = res.data()
})
}
get store() {
return this._store
}
/**
* Update the leader board
* @param {String} level - the id of the game level
* @param {String} title - the displayed name of the game level
* @returns {HTMLDivElement} - element with the rendered leader board
*/
async update(level, title) {
const displayElement = document.createElement('div')
this.lastPlace = Number.MAX_SAFE_INTEGER
const q = query(
collection(this.store, 'mw-leaders', level, 'games'),
orderBy('time'),
limit(10)
)
this.topListSnapshot = await getDocs(q)
this.renderList(displayElement, title, this.topListSnapshot.docs)
return displayElement
}
renderList(displayElement, title, docs) {
if (!displayElement) return
displayElement.innerHTML = ''
const leaderHeading = document.createElement('h3')
leaderHeading.innerText = title
leaderHeading.style.borderBottom = '1px solid #c0c0c0'
leaderHeading.style.paddingBottom = '10px'
displayElement.style.maxWidth = '270px'
displayElement.style.margin = '0 auto'
const leaderList = document.createElement('div')
leaderList.innerHTML = ''
leaderList.style.listStyle = 'none'
leaderList.style.textAlign = 'left'
leaderList.style.marginTop = '-15px'
if (docs && docs.length) {
let i = 1
docs.forEach(game => {
if (game) {
const prettyTime = this.timerService.pretty(game.data().time)
const name = game.data().name || 'Anonymous'
const item = document.createElement('div')
item.style.display = 'flex'
const nameElement =document.createElement('div')
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.cursor = 'pointer'
nameElement.style.fontWeight = 'bold'
nameElement.style.fontStyle = 'italic'
// nameElement.onmousedown = () => console.log(game.data());
const indexElement = document.createElement('div')
indexElement.innerText = `#${i++}`
const timeElement = document.createElement('div')
timeElement.innerText = prettyTime
item.append(indexElement, nameElement, timeElement)
leaderList.append(item)
}
})
if (docs.length >= 10) {
this.lastPlace = docs[9].data().time
}
displayElement.append(leaderHeading, leaderList)
} else {
const message = document.createElement('em')
message.innerText = 'Be the first to the top!'
displayElement.append(leaderHeading, message)
}
}
async send(game, key) {
const sessionId = new Date().toDateString().replace(/\s/g, '_')
const gameId = new Date().toTimeString().replace(/\s/g, '_')
const data = { }
data[gameId] = game
const sessionRef = doc(this.store, 'mw-all', this.user.browserId, 'games', sessionId)
await setDoc(sessionRef, data, { merge: true })
const winningCondigion = (
this.configuration
&& game.status === this.configuration.passingStatus
&& game[key] < this.lastPlace
)
if (winningCondigion) {
let name = window.prompt(this.configuration.message)
if (!name) {
name = 'Anonymous'
}
const newGame = {
name,
browserId: this.user.browserId,
...game
}
const gameScoreRef = doc(collection(this.store, 'mw-leaders', game.level, 'games'))
await setDoc(gameScoreRef, newGame)
}
}
configurationPromt() {
if (!this.configuration) {
this.loggerService.debug('Failed to fetch server configuration. Please contact your developer.')
}
}
}

View file

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

175
docs/deployment.md Normal file
View file

@ -0,0 +1,175 @@
# 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

@ -0,0 +1,219 @@
# Firebase & Leaderboards
How the mnswpr.com leaderboards are stored, how to deploy the schema, and what
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)
package. The app wires minesweeper's specifics (finish-time as the `score`,
ascending sort, time formatting) in [`app/main.js`](../src/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
new, queryable collection; the four tabs are just different filters over it.
### Data model
Collections (all under namespace `mw`):
| Path | Purpose | Access |
| --- | --- | --- |
| `mw-scores/{level}/games/{id}` | Ranked entries for the live boards | public read, validated create-only |
| `mw-all/{browserId}/games/{session}` | Per-browser archive of every game | read/write |
| `mw-config/configuration` | Server config (`passingStatus`, `message`) | public read, no client write |
| `mw-leaders/{level}/games/{id}` | **Legacy** all-time board, now frozen into Legends | read-only |
Each `mw-scores` entry:
```js
{
name, // display nickname
playerId, // browser fingerprint (UserService)
score, // ranked value — minesweeper: finish time in ms
category, // segmentation — minesweeper: level id
time_stamp, // when the game finished — drives the rolling time windows
day, // "2026-07-03" (UTC calendar day) — retained, not queried
week, // "2026-W27" (ISO week) — retained, not queried
month, // "2026-07" (UTC calendar month) — retained, not queried
meta // optional extras — minesweeper: { isMobile }
}
```
### How the time windows work
The windows are **rolling**, based on `time_stamp`, and strictly nested:
- Today → `where('time_stamp', '>=', now - 24h)` — last 24 hours
- Week → `where('time_stamp', '>=', now - 7d)` — last 7 days
- Month → `where('time_stamp', '>=', now - 30d)` — last 30 days
- All Time → no time filter, `orderBy('score')` + `limit(10)`
Because Firestore requires the inequality field (`time_stamp`) to sort first, the
adapter fetches the in-window rows and ranks them by `score` client-side (Supabase
does it server-side). A score in the 7-day window is always in the 30-day window,
so a player can be top-10 for the week but rank out of a busier month — that's
competition, not a boundary quirk.
> The `day`/`week`/`month` bucket keys are still written to each entry (and
> validated by the rules) but are **no longer used for querying** — they're kept
> as denormalized metadata and for a possible calendar-bucket mode.
## Schema as code
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)
and `dev` (`secure-moment-188701`).
- [`firestore.rules`](../firebase/firestore.rules) — access + validation rules.
- [`firestore.indexes.json`](../firebase/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.
## Environments (dev vs production)
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):
| Environment | `VITE_LB_NAMESPACE` | Collections |
| --- | --- | --- |
| Production (Netlify) | `mw` | `mw-scores`, `mw-all`, `mw-config` |
| Local `pnpm dev` / previews | `mw-test` | `mw-test-scores`, `mw-test-all`, `mw-test-config` |
The Firebase web config (`VITE_FIREBASE_*`) is **identical** in both — same
project — so the only difference is the namespace. `app/main.js` defaults to the
**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)
(the keys are public). Production sets `VITE_FIREBASE_*` **and**
`VITE_LB_NAMESPACE=mw` as Netlify environment variables. `.env.production` and
`.env*.local` stay gitignored.
> The full rationale and rollout steps are in
> [leaderboard-env-migration.md](leaderboard-env-migration.md).
### 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.
```bash
pnpm exec 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:
```bash
# production (default alias 'prod')
pnpm exec firebase deploy --only firestore:rules,firestore:indexes --project prod
# development database
pnpm exec 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
> 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
Firestore's automatic single-field indexes.
## Local Firestore emulator (default for local dev)
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)
locally (so you validate them before deploying). The flag
`VITE_FIRESTORE_EMULATOR=1` is set in [`app/.env.development`](../.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).
Everyday dev loop:
```bash
pnpm run dev # emulator (+ UI) on :8080, auto-seeded with sample scores, + app dev server
```
`dev` seeds the fresh emulator for you (via `emulators:exec "node scripts/seed-dev-scores.js; vite"`). Use the standalone `db:start` / `db:seed` scripts only when running the emulator separately from the app.
Wiring: `app/main.js` passes `{ emulator: { host, port } }` to `FirebaseAdapter`,
which calls `connectFirestoreEmulator`. If the emulator isn't running the board
just shows "unavailable" (a refused connection) — start it, or opt out.
**Opting out** (no Java, or you want the real cloud `mw-test`): create
`app/.env.local` with `VITE_FIRESTORE_EMULATOR=` (empty) — `.env.local` overrides
`.env.development` and is gitignored.
The emulator is disposable — its data is gone on stop unless you use Firebase's
`--import`/`--export-on-exit`.
## Console fallback
Everything above is doable via the CLI. If you can't use it:
- **Indexes**: nothing to do — the queries use automatic single-field indexes.
(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)).
- **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
eligible for the board (minesweeper: `"win"`).
## 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
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:
```bash
node scripts/export-legends.js
```
The Firebase config for this one-off is hardcoded in the script (public keys).
## Reusing the leaderboard for another game
`@cozy-games/leaderboard` knows nothing about minesweeper **or** Firebase — the
ranked value is a generic `score` and all storage goes through an injected
adapter. Another game supplies its own adapter and config:
```js
import { LeaderBoardService } from '@cozy-games/leaderboard/leader-board.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
new LeaderBoardService({
adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'yourgame' }),
scoreOrder: 'desc', // 'desc' when higher is better (points); 'asc' for time
formatScore: v => `${v} pts`,
qualifies: entry => true // default: server passingStatus vs entry.status
})
```
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).
Then submit `{ name, playerId, score, category, time_stamp, status?, meta? }`
and render with `render(category, title, duration)`.
**No composite indexes needed.** Rolling windows filter on `time_stamp` and
all-time sorts by `score` — both use Firestore's automatic single-field indexes,
in either sort direction, so a `desc`-scored game needs no extra index work.

View file

@ -0,0 +1,56 @@
# 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

@ -0,0 +1,160 @@
# Migration: env-var-driven prod/test separation
## Why
Today the leaderboard has **no separation between production and test data**.
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
local play pollutes the production leaderboard.
We keep **one Firebase project** (there is no separate prod project — see
[docs/firebase-leaderboards.md](firebase-leaderboards.md)) and separate data by
**collection namespace**, chosen at build time via an env var:
| Environment | `VITE_LB_NAMESPACE` | Collections |
| --- | --- | --- |
| Production (Netlify) | `mw` | `mw-scores`, `mw-all`, `mw-config` |
| Local dev / previews | `mw-test` | `mw-test-scores`, `mw-test-all`, `mw-test-config` |
The Firebase web config (`VITE_FIREBASE_*`) is **identical** in both — same
project — so the *only* thing distinguishing prod from test is the namespace.
Existing production data under `mw-*` is untouched; `mw-test-*` starts empty.
## Current vs target
| Piece | Now | After |
| --- | --- | --- |
| Namespace | hardcoded `'mw'` in [app/main.js](../src/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) |
| `.firebaserc` | `REPLACE_WITH_PROD_PROJECT_ID` placeholder | `secure-moment-188701` |
## Steps
### 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`
- 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
**test** namespace so a missing/misconfigured var can never write to production:
```js
new FirebaseAdapter({
firebaseConfig,
namespace: import.meta.env.VITE_LB_NAMESPACE || 'mw-test'
})
```
Production must set `VITE_LB_NAMESPACE=mw` explicitly. (Fail-safe: the worst case
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
*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
`{ns}-leaders/{cat}/games/{id}` share the same 4-segment shape, so each block is
guarded by a regex on the captured collection name.
```
rules_version = '2'
service cloud.firestore {
match /databases/{database}/documents {
// Ranked, windowed scores — public read, validated create-only.
match /{scores}/{category}/games/{game} {
allow read: if scores.matches('mw(-[a-z]+)?-scores');
allow create: if scores.matches('mw(-[a-z]+)?-scores') && isValidScore(category);
allow update, delete: if false;
}
// Legacy all-time (now frozen into Legends) — read-only.
match /{leaders}/{category}/games/{game} {
allow read: if leaders.matches('mw(-[a-z]+)?-leaders');
allow write: if false;
}
// Per-browser personal archive — permissive, as before.
match /{all}/{browserId}/games/{session} {
allow read, write: if all.matches('mw(-[a-z]+)?-all');
}
// Server config — public read, no client write.
match /{config}/{document} {
allow read: if config.matches('mw(-[a-z]+)?-config');
allow write: if false;
}
function isValidScore(category) {
let d = request.resource.data;
return d.score is number
&& d.score >= 0
&& d.category == category
&& d.name is string
&& d.name.size() <= 24
&& d.day is string
&& d.week is string
&& d.month is string;
}
}
}
```
The regex `mw(-[a-z]+)?-scores` matches `mw-scores` and `mw-test-scores`
(and e.g. `mw-preview-scores`).
### 4. Indexes — none needed
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
any namespace.
### 5. Seed the test config doc
Windowed qualification reads `{ns}-config/configuration`. Create
`mw-test-config/configuration` once (copy `passingStatus` and `message` from the
prod `mw-config/configuration`) via the Console, or a tiny one-off script. If it
is absent the default qualifier simply allows all entries — acceptable for test.
### 6. Point `.firebaserc` at the real project
Both aliases target the one project we actually have:
```json
{ "projects": { "default": "secure-moment-188701", "prod": "secure-moment-188701", "dev": "secure-moment-188701" } }
```
(If a separate prod Firebase project is ever created, split these then.)
### 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_*`).
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
rules, and a malformed write is rejected.
## Not affected
- **Legends** — static, already exported from legacy `mw-leaders`; no runtime DB.
- **Data migration** — none. Prod keeps `mw-*`; test starts fresh in `mw-test-*`.
- **The `@cozy-games/leaderboard` package** — already namespace-parameterized;
only the app's wiring changes.
## Rollback
Set `VITE_LB_NAMESPACE=mw` everywhere (or revert [app/main.js](../src/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

@ -49,5 +49,11 @@ export default defineConfig([
}]
}
},
{
files: ['**/scripts/**/*.{js,mjs,cjs}'],
languageOptions: {
globals: globals.node
}
},
globalIgnores(['**/dist'])
])

7
firebase/.firebaserc Normal file
View file

@ -0,0 +1,7 @@
{
"projects": {
"default": "secure-moment-188701",
"prod": "secure-moment-188701",
"dev": "secure-moment-188701"
}
}

15
firebase/firebase.json Normal file
View file

@ -0,0 +1,15 @@
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"emulators": {
"firestore": {
"port": 8080
},
"ui": {
"enabled": true
},
"singleProjectMode": true
}
}

View file

@ -0,0 +1,5 @@
{
"comment": "Time windows are rolling (time_stamp >= cutoff) and all-time sorts by score — both use Firestore's automatic single-field indexes, so no composite indexes are required.",
"indexes": [],
"fieldOverrides": []
}

78
firebase/firestore.rules Normal file
View file

@ -0,0 +1,78 @@
rules_version = '2'
// Security rules for the mnswpr leaderboard. Firestore is schemaless, so these
// 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.
//
// 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
// one project — and any future namespace. Firestore ORs all matching rules, and
// `{ns}-scores`, `{ns}-all` and `{ns}-leaders` share a 4-segment shape, so each
// 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
service cloud.firestore {
match /databases/{database}/documents {
// Ranked, time-windowed scores — public read, validated create-only.
match /{scores}/{category}/games/{game} {
allow read: if scores.matches('mw(-[a-z]+)?-scores');
allow create: if scores.matches('mw(-[a-z]+)?-scores') && isValidScore(category);
allow update, delete: if false;
}
// Legacy all-time collection, frozen into the static Legends page — read-only.
match /{leaders}/{category}/games/{game} {
allow read: if leaders.matches('mw(-[a-z]+)?-leaders');
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.
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 delete: if false;
}
// Server config (passingStatus, message) — public read, no client write.
match /{config}/{document} {
allow read: if config.matches('mw(-[a-z]+)?-config');
allow write: if false;
}
// Defense-in-depth: deny anything not matched above. Firestore already
// default-denies unmatched paths; this makes the intent explicit and is a
// backstop if a future rule is added carelessly. (Allows are OR-ed, so this
// never revokes the specific grants above.)
match /{document=**} {
allow read, write: if false;
}
// A well-formed score: numeric score, bounded name, string bucket keys, and
// a category matching the path. Rules have no clock, so they can't verify
// the buckets are correctly derived from time_stamp — that stays the client's
// job. Extra fields (playerId, time_stamp, meta) are allowed.
function isValidScore(category) {
let d = request.resource.data;
return d.score is number
&& d.score >= 0
&& d.category == category
&& d.name is string
&& d.name.size() <= 24
&& d.day is string
&& d.week is string
&& d.month is string;
}
}
}

View file

@ -1,24 +0,0 @@
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.

View file

@ -1,157 +0,0 @@
# 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)_

View file

@ -1,31 +0,0 @@
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'
}
}

View file

@ -1,142 +0,0 @@
#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;
}
}

View file

@ -1,844 +0,0 @@
// @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
let minesArray = []
this.initialize = function() {
const headingElement = document.createElement('h1')
const gameBoard = document.createElement('div')
headingElement.innerHTML = `<span>Minesweeper</span><sup>${version}</sup>`
document.title = `mnswpr [${version}]`
gameBoard.setAttribute('id', 'game-board')
gameBoard.append(initializeToolbar(), grid, initializeFootbar())
if(appElement) {
appElement.innerHTML = ''
appElement.append(headingElement, gameBoard)
}
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
minesArray = []
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)
}
function initializeEventHandlers(_cell) {
let cell = _cell
skip = false
skipCondition = false
resetMouseEventFlags()
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
}
}
// 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 {
minesArray.push([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 isMine(cell) {
return getIndex(minesArray, cell) > -1
}
function removeItem(arr, cell) {
const index = getIndex(arr, cell)
if (index > -1) {
arr.splice(index, 1)
}
}
function getIndex(arr, cell) {
const row = cell.parentNode.rowIndex
const col = cell.cellIndex
let index = -1
for (let i = 0; i < arr.length; i++) {
let rowCol = arr[i]
if (rowCol[0] === row && rowCol[1] === col) {
index = i
break
}
}
return index
}
function checkLevelCompletion() {
let levelComplete = true
for (let i=0; i<setting.rows; i++) {
for(let j=0; j<setting.cols; j++) {
const cell = grid.rows[i].cells[j]
if (!isMine(cell) && cell.innerHTML=='') levelComplete=false
}
}
if (levelComplete && 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<setting.rows; i++) {
const rows = grid.rows[i]
if (!rows) continue
for(let j=0; j<setting.cols; j++) {
let currentCell = grid.rows[i].cells[j]
if (getStatus(currentCell) == 'highlighted') setStatus(currentCell, 'default')
}
}
}
function highlightCell(cell) {
if (isFlagged(cell)) return
if (!gameIsDone() && getStatus(cell) == 'default') setStatus(cell, 'highlighted') // currentCell.classList.add('highlight');
}
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');
removeItem(minesArray, 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) || isNeighbor(cell, transferMineToCell)) {
continue
} else {
minesArray.push([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)
}
function handleEmpty(cell) {
updateCellValue(cell, ' ')
let cellRow = cell.parentNode.rowIndex
let cellCol = cell.cellIndex
setStatus(cell, 'empty')
//Reveal all adjacent cells as they do not have a mine
for (let y = Math.max(cellRow-1,0); y <= Math.min(cellRow+1, setting.rows - 1); y++) {
const rows = grid.rows[y]
if (!rows) continue
for(let x = Math.max(cellCol-1,0); x <= Math.min(cellCol+1, setting.cols - 1); x++) {
//Recursive Call
const cell = rows.cells[x]
if (cell && !isOpen(cell)) {
clickCell(cell)
}
}
}
}
function openCell(cell) {
if (grid.getAttribute('game-status') != 'active') return
cell.className='clicked'
setStatus(cell, 'clicked')
firstClick = false
if (isMine(cell)) {
revealMines()
flagsDisplay.innerHTML = '&#128561;'
grid.setAttribute('game-status', 'over')
} else {
const mineCount = countMinesAround(cell)
if (mineCount==0) {
handleEmpty(cell)
} else {
updateCellValue(cell, mineCount.toString())
const dataValue = document.createAttribute('data-value')
dataValue.value = mineCount.toString()
cell.setAttributeNode(dataValue)
}
//Count and display the number of adjacent mines
checkLevelCompletion()
}
}
}
export default Minesweeper

View file

@ -1,32 +0,0 @@
{
"name": "@ayo-run/mnswpr",
"version": "0.4.32",
"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"
}

View file

@ -1,12 +0,0 @@
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'
}
}
})

35
netlify.toml Normal file
View file

@ -0,0 +1,35 @@
# 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,38 +1,58 @@
{
"name": "monorepo",
"name": "mnswpr",
"version": "0.0.1",
"private": true,
"description": "Classic Minesweeper browser game",
"description": "mnswpr \u2014 the mnswpr.com web app",
"author": "Ayo Ayco",
"type": "module",
"main": "main.js",
"repository": {
"type": "git",
"url": "https://github.com/ayo-run/mnswpr"
},
"homepage": "https://mnswpr.com",
"scripts": {
"test": "echo \"Warn: no test specified\"",
"dev": "vite app",
"start": "vite app",
"build": "vite build app",
"build:lib": "vite build lib",
"release:lib": "pnpm -F @ayo-run/mnswpr run release",
"publish:lib": "cd lib && npm publish",
"build:preview": "pnpm -F app run build:preview",
"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",
"prepare": "husky",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
"lint:fix": "eslint . --fix",
"scan:secrets": "secretlint --secretlintignore .secretlintignore \"**/*\""
},
"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",
"vite": "^8.0.3"
"typescript": "^5.9.3",
"vite": "^8.0.16",
"vitest": "^4.1.9",
"web-component-base": "^5.0.0"
},
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b"
}

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,15 @@
packages:
- "lib"
- "app"
# 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`.
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'

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

458
scripts/check-content.mjs Normal file
View file

@ -0,0 +1,458 @@
// @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)))

99
scripts/ensure-java.mjs Normal file
View file

@ -0,0 +1,99 @@
/**
* 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
}

135
scripts/export-legends.js Normal file
View file

@ -0,0 +1,135 @@
/**
* One-time generator: snapshot the all-time leaders from the legacy
* `mw-leaders/{level}/games` collection and write them into legends.html as
* a FULLY-RENDERED, static page no runtime JS, no Firebase at page load.
*
* The data never changes, so the page is a frozen artifact. Re-run only if you
* ever need to regenerate it. Run it from the repo root:
* node scripts/export-legends.js
*/
import { writeFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { initializeApp } from 'firebase/app'
import {
getFirestore, getDocs, collection, query, orderBy, limit
} from 'firebase/firestore/lite'
import { levels } from '@cozy-games/mnswpr/levels.js'
// Mirror of TimerService.pretty() (@cozy-games/utils timer) — inlined so this
// generator has no cross-module import chain to resolve under raw Node.
const clean = (str, separator) => (str === '00' ? '' : `${str}${separator}`)
const pretty = duration => {
if (!duration) return undefined
const milliseconds = parseInt((duration % 1000) / 100)
const seconds = Math.floor((duration / 1000) % 60)
const minutes = Math.floor((duration / (1000 * 60)) % 60)
const hours = Math.floor((duration / (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, '.')}${clean(milliseconds, '')}`
}
// Hardcoded for this one-time run (public, non-secret keys — access is governed
// by Firestore rules). This is the project the deployed app has always used.
const firebaseConfig = {
apiKey: 'AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ',
authDomain: 'moment-188701.firebaseapp.com',
databaseURL: 'https://moment-188701.firebaseio.com',
projectId: 'secure-moment-188701',
storageBucket: 'secure-moment-188701.firebasestorage.app',
messagingSenderId: '113827947104',
appId: '1:113827947104:web:b176f746d8358302c51905',
measurementId: 'G-LZRDY0TG46'
}
console.log(`Exporting Legends from project: ${firebaseConfig.projectId}`)
const TOP_N = 10
const escapeHtml = str => String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const app = initializeApp(firebaseConfig)
const store = getFirestore(app)
const sections = []
for (const id of Object.keys(levels)) {
const q = query(
collection(store, 'mw-leaders', id, 'games'),
orderBy('time'),
limit(TOP_N)
)
const snapshot = await getDocs(q)
const rows = snapshot.docs.map(d => {
const data = d.data()
return { name: data.name || 'Anonymous', time: data.time }
})
console.log(`${id}: ${rows.length} record(s)`)
const items = rows.map((r, i) =>
` <li><span class="rank">#${i + 1}</span><span class="name" title="${escapeHtml(r.name)}">${escapeHtml(r.name)}</span><span class="time">${pretty(r.time)}</span></li>`
).join('\n')
sections.push(` <section>
<h3>${escapeHtml(levels[id].name)}</h3>
<ol>
${items}
</ol>
</section>`)
}
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="Description" content="Minesweeper Legends — Epic pre-version-one top scores." />
<title>Minesweeper Legends 🏆</title>
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="./main.css" />
<style>
.legends section { max-width: 270px; margin: 0 auto 30px; }
.legends h3 { border-bottom: 1px solid #c0c0c0; padding-bottom: 10px; }
.legends ol { list-style: none; padding: 0; margin: 0; text-align: left; }
.legends li { display: flex; }
.legends .name {
flex: 1;
padding: 0 5px;
font-weight: bold;
font-style: italic;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
</head>
<body>
<!-- Generated by scripts/export-legends.js do not edit by hand. -->
<div id="body-wrapper">
<h1>🏆 Legends</h1>
<p><em>Epic scores the top times set before version 1.0.</em></p>
<div class="legends">
${sections.join('\n')}
</div>
<div id="legends-link">
<a href="/"> Back to the game</a>
</div>
</div>
</body>
</html>
`
const __dirname = dirname(fileURLToPath(import.meta.url))
const out = resolve(__dirname, '../src/legends.html')
writeFileSync(out, html)
console.log(`\nWrote ${out}`)

View file

@ -1,29 +1,71 @@
// forked from https://github.com/elk-zone/elk/blob/main/scripts/release.ts
/**
* 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
*/
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)})`)
console.log('Fetch remote gh repo')
await git.fetch('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('Checkout release branch')
await git.checkout(['-b', 'release', '--track', 'gh/release'])
console.log(`Fetching ${DEPLOY_REMOTE}`)
await git.fetch(DEPLOY_REMOTE)
console.log(`Reset to main branch (${hash})`)
await git.reset(['--hard', hash])
try {
console.log('Checking out release')
await git.checkout(['-b', 'release', '--track', `${DEPLOY_REMOTE}/release`])
console.log('Push to release branch')
await git.push(['--force', 'gh'])
console.log(`Resetting release to main (${hash.slice(0, 8)})`)
await git.reset(['--hard', hash])
console.log('Checkout main branch')
await git.checkout('main')
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('Deleting local release branch')
await git.branch(['-D', 'release'])
for (const remote of TAG_REMOTES) {
console.log(`Pushing tags to ${remote}`)
await git.push([remote, '--tags'])
}
// TODO: handle multiple remotes with a data structure
console.log('Push tags')
await git.push(['--tags'])
await git.push(['--tags', 'gh'])
await git.push(['--tags', 'sh'])
console.log(`\n✓ Released ${latest} — Netlify is building from ${DEPLOY_REMOTE}/release`)

103
scripts/seed-dev-scores.js Normal file
View file

@ -0,0 +1,103 @@
/**
* Seed the DEVELOPMENT leaderboard with sample scores so the boards aren't empty
* while developing. Writes to the `mw-test-*` namespace by default.
*
* Two ways to run it:
*
* 1. Against the local Firestore EMULATOR (recommended no cloud, no deploy):
* pnpm emulators # in one terminal
* pnpm seed:emulator # in another
* (seed:emulator sets FIRESTORE_EMULATOR_HOST so writes hit the emulator.)
*
* 2. Against the real cloud dev database: REQUIRES the generalized
* 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
*
* Run from the repo root. 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'
const firebaseConfig = {
apiKey: 'AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ',
authDomain: 'moment-188701.firebaseapp.com',
databaseURL: 'https://moment-188701.firebaseio.com',
projectId: 'secure-moment-188701',
storageBucket: 'secure-moment-188701.firebasestorage.app',
messagingSenderId: '113827947104',
appId: '1:113827947104:web:b176f746d8358302c51905'
}
const namespace = process.env.LB_NAMESPACE || 'mw-test'
// Per-level rough time ranges (ms) so sample times look believable.
const LEVELS = {
beginner: [1800, 12000],
intermediate: [28000, 95000],
expert: [90000, 260000],
nightmare: [180000, 620000]
}
const NAMES = [
'Ayo', 'Sweeper', 'MineWizard', 'jenjereren', 'wasd', 'kaboom',
'flagella', 'ClickClick', 'Torpedo', 'S-Davies', 'Anonymous', 'zero-day',
'BoomBot', 'gridlock', 'NoNiner', 'chord-lord', 'tenten', 'quicksilver'
]
// Ages (days ago) spread so Today / Week / Month / All Time all get entries.
const AGE_DAYS = [0, 0, 0, 1, 2, 4, 6, 10, 18, 27, 45, 80]
const now = Date.now()
const rand = (min, max) => Math.floor(min + Math.random() * (max - min))
const pick = arr => arr[Math.floor(Math.random() * arr.length)]
const store = getFirestore(initializeApp(firebaseConfig))
// Point at a local Firestore emulator when FIRESTORE_EMULATOR_HOST is set
// (e.g. "127.0.0.1:8080"). Writes then hit the local emulator, not the cloud.
if (process.env.FIRESTORE_EMULATOR_HOST) {
const [host, port] = process.env.FIRESTORE_EMULATOR_HOST.split(':')
connectFirestoreEmulator(store, host, Number(port))
console.log(`Using Firestore emulator at ${process.env.FIRESTORE_EMULATOR_HOST}`)
}
let ok = 0
let failed = 0
let firstError
for (const [level, [min, max]] of Object.entries(LEVELS)) {
for (let i = 0; i < AGE_DAYS.length; i++) {
const stamp = new Date(now - AGE_DAYS[i] * 86400000 - rand(0, 3600000))
const bucket = buckets(stamp)
const entry = {
name: pick(NAMES),
playerId: `seed-${level}-${i}`,
score: rand(min, max),
category: level,
time_stamp: stamp,
day: bucket.day,
week: bucket.week,
month: bucket.month,
meta: { isMobile: Math.random() < 0.3, seed: true }
}
try {
await setDoc(doc(collection(store, `${namespace}-scores`, level, 'games')), entry)
ok++
} catch (error) {
failed++
firstError = firstError || (error.code || error.message)
}
}
console.log(`${level}: seeded`)
}
console.log(`\nDone. Wrote ${ok} sample score(s) to ${namespace}-scores, ${failed} failed.`)
if (failed) {
console.error(`\nWrites failed (${firstError}). If this is 'permission-denied', deploy the rules first:`)
console.error(' npx firebase deploy --only firestore:rules --project dev')
process.exit(1)
}

View file

@ -0,0 +1,306 @@
// 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()
})
})

View file

@ -10,13 +10,12 @@
<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;
}
nav {
sup {
a {
color: white;
text-decoration-color: orange;
@ -31,14 +30,16 @@
</head>
<body>
<div id="body-wrapper">
<nav>
<a target="_blank" href="https://npmx.dev/package/@ayo-run/mnswpr">npm</a>
&middot;
<a target="_blank" href="https://github.com/ayo-run/mnswpr">github</a>
</nav>
<div id="greeting"></div>
<div id="app">
Please use Chrome or Firefox.
</div>
<cozy-leaderboard category="beginner" title="Best Times" format="time" score-order="asc"></cozy-leaderboard>
<div id="legends-link">
<a href="/legends.html">🏆 Legends</a>
</div>
</div>
<script type="module" src="./main.js"></script>
</body>

101
src/legends.html Normal file
View file

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="Description" content="Minesweeper Legends — Epic pre-version-one top scores." />
<title>Minesweeper Legends 🏆</title>
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="./main.css" />
<style>
.legends section { max-width: 270px; margin: 0 auto 30px; }
.legends h3 { border-bottom: 1px solid #c0c0c0; padding-bottom: 10px; }
.legends ol { list-style: none; padding: 0; margin: 0; text-align: left; }
.legends li { display: flex; }
.legends .name {
flex: 1;
padding: 0 5px;
font-weight: bold;
font-style: italic;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
</head>
<body>
<!-- Generated by scripts/export-legends.js — do not edit by hand. -->
<div id="body-wrapper">
<h1>🏆 Legends</h1>
<p><em>Epic scores — the top times set before version 1.0.</em></p>
<div class="legends">
<section>
<h3>Noobs</h3>
<ol>
<li><span class="rank">#1</span><span class="name" title="kaelynaverymumm">kaelynaverymumm</span><span class="time">01.9</span></li>
<li><span class="rank">#2</span><span class="name" title="isji">isji</span><span class="time">02.0</span></li>
<li><span class="rank">#3</span><span class="name" title="Anonymous">Anonymous</span><span class="time">02.9</span></li>
<li><span class="rank">#4</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03.7</span></li>
<li><span class="rank">#5</span><span class="name" title="S-Davies">S-Davies</span><span class="time">03.8</span></li>
<li><span class="rank">#6</span><span class="name" title="kaelynaverymumm">kaelynaverymumm</span><span class="time">04.0</span></li>
<li><span class="rank">#7</span><span class="name" title="jenn">jenn</span><span class="time">04.1</span></li>
<li><span class="rank">#8</span><span class="name" title="jenjereren">jenjereren</span><span class="time">04.1</span></li>
<li><span class="rank">#9</span><span class="name" title="BIGTHICCDADDY!!">BIGTHICCDADDY!!</span><span class="time">04.4</span></li>
<li><span class="rank">#10</span><span class="name" title="XZ123627703">XZ123627703</span><span class="time">04.5</span></li>
</ol>
</section>
<section>
<h3>Normies</h3>
<ol>
<li><span class="rank">#1</span><span class="name" title="jenjereren">jenjereren</span><span class="time">29.1</span></li>
<li><span class="rank">#2</span><span class="name" title="jenjereren">jenjereren</span><span class="time">29.3</span></li>
<li><span class="rank">#3</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.1</span></li>
<li><span class="rank">#4</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.4</span></li>
<li><span class="rank">#5</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.5</span></li>
<li><span class="rank">#6</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.6</span></li>
<li><span class="rank">#7</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.8</span></li>
<li><span class="rank">#8</span><span class="name" title="jenjereren">jenjereren</span><span class="time">30.9</span></li>
<li><span class="rank">#9</span><span class="name" title="jenjereren">jenjereren</span><span class="time">31.4</span></li>
<li><span class="rank">#10</span><span class="name" title="jenjereren">jenjereren</span><span class="time">31.5</span></li>
</ol>
</section>
<section>
<h3>Torment</h3>
<ol>
<li><span class="rank">#1</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:34.6</span></li>
<li><span class="rank">#2</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:36.6</span></li>
<li><span class="rank">#3</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:38.5</span></li>
<li><span class="rank">#4</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:38.9</span></li>
<li><span class="rank">#5</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:39.3</span></li>
<li><span class="rank">#6</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:39.4</span></li>
<li><span class="rank">#7</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:39.7</span></li>
<li><span class="rank">#8</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:45.1</span></li>
<li><span class="rank">#9</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:45.8</span></li>
<li><span class="rank">#10</span><span class="name" title="jenjereren">jenjereren</span><span class="time">01:46.1</span></li>
</ol>
</section>
<section>
<h3>Hell</h3>
<ol>
<li><span class="rank">#1</span><span class="name" title="wasd">wasd</span><span class="time">01:13.6</span></li>
<li><span class="rank">#2</span><span class="name" title="wasd">wasd</span><span class="time">02:40.0</span></li>
<li><span class="rank">#3</span><span class="name" title="jenjereren">jenjereren</span><span class="time">02:57.3</span></li>
<li><span class="rank">#4</span><span class="name" title="jenjereren">jenjereren</span><span class="time">02:57.7</span></li>
<li><span class="rank">#5</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:02.8</span></li>
<li><span class="rank">#6</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:06.0</span></li>
<li><span class="rank">#7</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:06.4</span></li>
<li><span class="rank">#8</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:15.8</span></li>
<li><span class="rank">#9</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:16.4</span></li>
<li><span class="rank">#10</span><span class="name" title="jenjereren">jenjereren</span><span class="time">03:19.5</span></li>
</ol>
</section>
</div>
<div id="legends-link">
<a href="/">← Back to the game</a>
</div>
</div>
</body>
</html>

View file

@ -23,3 +23,23 @@ em {
#body-wrapper {
display: inline-block;
}
#greeting {
margin: 10px auto;
font-size: 0.95em;
color: #DDDDDD;
}
#legends-link {
margin: 20px auto;
}
#legends-link a {
color: #DDDDDD;
text-decoration-color: orange;
transition: 500ms ease-in-out;
}
#legends-link a:hover {
text-decoration-thickness: 2px;
}

76
src/main.js Normal file
View file

@ -0,0 +1,76 @@
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 { configureLeaderboard } from '@cozy-games/leaderboard/leaderboard-element.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
import { NicknameService } from './modules/nickname/nickname.js'
import { UserService } from './modules/user/user.js'
// Firebase config comes from Vite env vars so dev and production point at
// different databases: dev values live in app/.env.development (committed —
// these keys are public, not secrets, and access is governed by
// firestore.rules); production values are supplied as Netlify env vars.
// https://stackoverflow.com/questions/37482366/is-it-safe-to-expose-firebase-apikey-to-the-public/37484053#37484053
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
}
const user = new UserService()
const nickname = new NicknameService()
// The leaderboard UI is composed declaratively as <cozy-leaderboard> in
// index.html; here we only wire its backend once. Namespace selects prod (`mw`)
// vs test (`mw-test`) collections in the same Firebase project, defaulting to
// test so a missing env var can never write into the production board.
// Local dev runs against the Firestore emulator by default (VITE_FIRESTORE_EMULATOR
// in app/.env.development); production builds always use real Firestore.
const useEmulator = ['1', 'true', 'yes'].includes(String(import.meta.env.VITE_FIRESTORE_EMULATOR))
configureLeaderboard({
adapter: new FirebaseAdapter({
firebaseConfig,
namespace: import.meta.env.VITE_LB_NAMESPACE || 'mw-test',
emulator: useEmulator ? { host: '127.0.0.1', port: 8080 } : undefined
})
})
const version = import.meta.env.MODE === 'development'
? 'dev'
: pkg.version
// Ask for a nickname on first visit and show the greeting bar.
nickname.ensure()
const greeting = document.getElementById('greeting')
if (greeting) nickname.render(greeting)
const board = document.querySelector('cozy-leaderboard')
const game = new mnswpr('app', version, {
// Point the board at the current level; the element re-renders reactively and
// keeps the selected duration tab.
levelChanged: (level) => {
board.setAttribute('title', `Best Times (${level.name})`)
board.setAttribute('category', level.id)
},
// Submit the finished game through the element's service.
gameDone: (game) => board.submit({
name: nickname.get(),
playerId: user.browserId,
score: game.time,
category: game.level,
time_stamp: game.time_stamp,
status: game.status,
meta: { isMobile: game.isMobile }
})
})
game.initialize()

View file

@ -0,0 +1,100 @@
import { StorageService } from '@cozy-games/utils'
const NICK_KEY = 'nickname'
const MAX_LENGTH = 24
/**
* A few friendly greetings; `{nick}` is replaced with the player's nickname.
*/
const GREETINGS = [
'Welcome back, {nick}!',
'Good to see you, {nick}!',
'Hey {nick}, ready to sweep?',
'Let\'s go, {nick}!',
'Happy sweeping, {nick}!',
'Nice to have you, {nick}!'
]
/**
* Owns the player's display nickname: prompts for it once on first visit,
* persists it in localStorage, and renders a friendly greeting bar. The
* leaderboard package stays name-agnostic the app passes this nickname in.
*/
export class NicknameService {
constructor() {
this.storage = new StorageService()
}
/**
* The stored nickname, or 'Anonymous' if none has been set yet.
* @returns {String}
*/
get() {
return this.storage.getFromLocal(NICK_KEY) || 'Anonymous'
}
/**
* Prompt for a nickname the first time only used on page load.
*/
ensure() {
if (!this.storage.getFromLocal(NICK_KEY)) {
this.prompt()
}
}
/**
* Ask for a nickname and persist it (trimmed, capped). Cancelling keeps the
* current value. A failing/unsupported prompt must never break page load, so
* it is guarded the player just stays 'Anonymous'.
*/
prompt() {
let next
try {
next = window.prompt('Pick a nickname for the leaderboard', this.storage.getFromLocal(NICK_KEY) || '')
} catch {
return
}
if (next && next.trim()) {
this.storage.saveToLocal(NICK_KEY, next.trim().slice(0, MAX_LENGTH))
}
}
/**
* Render the greeting bar into a container. Clicking the nickname (or the
* pencil) re-prompts and re-renders in place.
* @param {HTMLElement} container
*/
render(container) {
if (!container) return
container.innerHTML = ''
const template = GREETINGS[Math.floor(Math.random() * GREETINGS.length)]
const [before, after] = template.split('{nick}')
const nameElement = document.createElement('span')
nameElement.innerText = this.get()
nameElement.style.fontWeight = 'bold'
nameElement.style.fontStyle = 'italic'
nameElement.style.cursor = 'pointer'
nameElement.style.textDecoration = 'underline'
nameElement.style.textDecorationColor = 'orange'
nameElement.setAttribute('title', 'Click to change your nickname')
const icon = document.createElement('span')
icon.innerText = ' ✏️'
icon.style.cursor = 'pointer'
icon.style.fontSize = '0.8em'
icon.style.opacity = '0.6'
icon.setAttribute('title', 'Click to change your nickname')
const change = () => {
this.prompt()
this.render(container)
}
nameElement.onclick = change
icon.onclick = change
container.append(document.createTextNode(before), nameElement, icon, document.createTextNode(after))
}
}

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

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

View file

@ -1,55 +0,0 @@
.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);
}
}

View file

@ -1,13 +0,0 @@
/**
* 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 = ''
}
}

View file

@ -1,13 +0,0 @@
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)
}
}
}

View file

@ -1,23 +0,0 @@
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)
}
}

View file

@ -1,63 +0,0 @@
import { LoggerService } from '../logger/logger'
const INTERVAL = 1
export class TimerService {
constructor() {
this.loggerService = new LoggerService()
}
initialize(el) {
if (!el) return
this.display = el
this.startTime = undefined
if (this.id) {
this.stop()
}
this.updateDisplay()
}
start() {
if (this.running || !this.display) return
this.running = true
this.startTime = new Date().getTime()
this.id = window.setInterval(() => this.updateDisplay(), INTERVAL)
this.loggerService.debug(`started timer id: ${this.id}`)
}
stop() {
this.running = false
clearInterval(this.id)
this.loggerService.debug(`stopped timer id: ${this.id}`)
this.id = undefined
return this.time
}
updateDisplay() {
let currentTime = new Date().getTime() - this.startTime
this.time = Math.floor(currentTime / INTERVAL)
this.display.innerHTML = this.pretty(this.time) || '0'
}
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}`
}
}

22
vite.config.js Normal file
View file

@ -0,0 +1,22 @@
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')
}
}
}
})

8
vitest.config.js Normal file
View file

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