Compare commits
72 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5109b8dc9 | |||
| 93e8258270 | |||
| f4ac3b0c12 | |||
| 3b17a596c6 | |||
| f6b71023ec | |||
| 5b19bcd324 | |||
| 6e9cb93a12 | |||
| a26c2c5055 | |||
| 310b657e32 | |||
| e50eb001db | |||
| af649d9c90 | |||
| 900983fb14 | |||
|
|
6727a3013c | ||
| 0a3571e2f4 | |||
| 287dc0885b | |||
| a7247859ac | |||
| 32cf38c452 | |||
| 9b220829f7 | |||
| 2aaba5d904 | |||
| a0e2970b55 | |||
| 3418be3d7b | |||
| 2d7f7d0ebb | |||
| 41829086c5 | |||
| 17724ebe8f | |||
| cc44789829 | |||
| 9079ab0848 | |||
| 26277986a0 | |||
| c2ffb0d7f6 | |||
| cd2b30ecc8 | |||
| 1ca1d63ea7 | |||
| 0b9866e05f | |||
| 43b75d7b0e | |||
| 998aeacbb2 | |||
| 072d2f7369 | |||
| 6b38088d4b | |||
| 6f9a8031e9 | |||
| bafe1db285 | |||
| 0dab463e84 | |||
| 4ccec5a116 | |||
| e1c168a9bb | |||
| 3bfb1931cc | |||
| dd9e020214 | |||
| 6c90cde0f9 | |||
| 0823d43c75 | |||
| d3d84aa9b8 | |||
| d482b17976 | |||
| 8099820e79 | |||
| 916d04d0d9 | |||
| d7a9f61a9e | |||
| 5a7eea3e12 | |||
| 744825c771 | |||
| 9aec20edbf | |||
| 6afd15aa35 | |||
| 8b66249edf | |||
| 5ec22fe37e | |||
| 062b912549 | |||
| 042d6b26e9 | |||
| 7cbf9baaa8 | |||
| 0532fd6d66 | |||
| 7685a6dea7 | |||
| 74551f2023 | |||
| 969ebba067 | |||
| 5f6e389258 | |||
| 7b68c677ef | |||
| 4fa88fb431 | |||
| 28b90b1dab | |||
| 207e253c69 | |||
| b6f43751d9 | |||
| 8afd8942aa | |||
| 0704c1e2ec | |||
| 8774bc8092 | |||
| 21bfce4c04 |
66 changed files with 14330 additions and 2326 deletions
23
.env.development
Normal file
23
.env.development
Normal 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
21
.env.example
Normal 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
94
.github/workflows/checks.yml
vendored
Normal 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"
|
||||
26
.github/workflows/release.yml
vendored
26
.github/workflows/release.yml
vendored
|
|
@ -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}}
|
||||
21
.gitignore
vendored
21
.gitignore
vendored
|
|
@ -3,5 +3,22 @@ dist/
|
|||
|
||||
.claude
|
||||
|
||||
*.*~
|
||||
*.*swp
|
||||
# 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
2
.husky/commit-msg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
echo "content-check (commit message)..."
|
||||
node scripts/check-content.mjs --message "$1"
|
||||
|
|
@ -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
11
.repo-policy.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": 1,
|
||||
"salt": "40f076a2ae183c348be3e11a8b58ebac",
|
||||
"digests": [],
|
||||
"toolCoAuthors": [
|
||||
"*@anthropic.com",
|
||||
"*@openai.com",
|
||||
"*@cursor.com",
|
||||
"*@devin.ai"
|
||||
]
|
||||
}
|
||||
5
.secretlintignore
Normal file
5
.secretlintignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Paths secretlint should not scan (vendored, generated, or lockfiles).
|
||||
node_modules
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
*.log
|
||||
7
.secretlintrc.json
Normal file
7
.secretlintrc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"rules": [
|
||||
{
|
||||
"id": "@secretlint/secretlint-rule-preset-recommend"
|
||||
}
|
||||
]
|
||||
}
|
||||
122
AGENTS.md
122
AGENTS.md
|
|
@ -4,42 +4,105 @@ Guidance for AI coding agents working in this repository.
|
|||
|
||||
## What this is
|
||||
|
||||
Classic Minesweeper as a vanilla web game — no framework, no TypeScript (JSDoc + `// @ts-check` only). Deployed at [mnswpr.com](https://mnswpr.com) (Netlify) and published to npm as `@ayo-run/mnswpr`. The game engine has **zero runtime dependencies**; only the website adds Firebase.
|
||||
Classic Minesweeper as a vanilla web game — no framework, no TypeScript (JSDoc + `// @ts-check` only). Deployed at [mnswpr.com](https://mnswpr.com) (Netlify), 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 — this is a pnpm workspace)
|
||||
pnpm dev # run the website dev server (vite app) — most common
|
||||
pnpm build # build the website -> app/dist
|
||||
pnpm build:lib # build the publishable library -> lib/dist
|
||||
pnpm lint # eslint . (JS + CSS); runs automatically on pre-commit
|
||||
pnpm lint:fix # eslint --fix
|
||||
pnpm build:preview # build the app and serve the production preview
|
||||
pnpm 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
|
||||
```
|
||||
|
||||
Tests live in `test/` and run under **Vitest** with a jsdom environment (config in `vitest.config.js`). They cover the shared utils and drive the engine through real DOM events (mount `#app`, dispatch mouse events, assert on cell/grid attributes). For anything visual or input-timing related, also verify by running `pnpm dev` and playing.
|
||||
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 (pnpm workspace)
|
||||
## Repository layout
|
||||
|
||||
Workspaces are declared in `pnpm-workspace.yaml` as `lib` and `app`. Note that `utils/` is **not** a workspace package — it's a plain shared folder imported by relative path from both `lib` and `app`.
|
||||
A single package (`mnswpr`) rooted at the repo root.
|
||||
|
||||
- **`lib/`** — `@ayo-run/mnswpr`, the standalone, framework-free game engine. This is what gets published to npm. `lib/mnswpr.js` is the whole engine; `lib/levels.js` defines the four difficulty presets. Depends only on `utils/`.
|
||||
- **`app/`** — the mnswpr.com website. Consumes the library via `workspace:*` (`import mnswpr from '@ayo-run/mnswpr/mnswpr.js'`) and adds the Firebase leaderboard. `app/main.js` wires the engine to the leaderboard through the engine's hooks.
|
||||
- **`utils/`** — shared services with no dependencies, re-exported from `utils/index.js`: `StorageService` (localStorage/sessionStorage JSON wrapper), `TimerService` (game clock + `pretty()` time formatting used by both engine and leaderboard), `LoggerService`, `LoadingService`.
|
||||
- **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
|
||||
|
||||
**The engine is decoupled from the app via two hooks.** `Minesweeper(appId, version, hooks)` (`lib/mnswpr.js`) is a classic constructor function that imperatively builds a `<table>` grid in the DOM. It knows nothing about Firebase or leaderboards. The app injects behavior through:
|
||||
`main.js` is ~75 lines and is the whole app: it wires three npm packages together and owns nothing else.
|
||||
|
||||
- `hooks.levelChanged(setting)` — fired when the difficulty level changes; the app uses this to re-fetch and render the leaderboard for that level.
|
||||
- `hooks.gameDone(game)` — fired when a game ends (win or loss) with a `game` object (`time`, `status`, `level`, `time_stamp`, `isMobile`); the app uses this to submit the score.
|
||||
**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:
|
||||
|
||||
When adding engine features that the website needs to react to, prefer adding a new hook over reaching into the app — that separation is what keeps the library publishable on its own. (There are already `TODO` markers in the engine for an `afterGridGenerated` hook.)
|
||||
- `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.
|
||||
|
||||
|
|
@ -47,22 +110,27 @@ When adding engine features that the website needs to react to, prefer adding a
|
|||
|
||||
**Input handling is intricate.** Mouse (left/right/middle, plus simultaneous left+right "chording") and touch (long-press to flag) are handled through a state machine of flags (`isLeft`, `isRight`, `pressed`, `bothPressed`, `skip`, `isBusy`) in `initializeEventHandlers`/`initializeTouchEventHandlers`. `isBusy` debounces input (`MOBILE_BUSY_DELAY`/`PC_BUSY_DELAY`). Tread carefully here — small changes easily break chording or mobile flagging.
|
||||
|
||||
**Test mode:** set `TEST_MODE = true` at the top of `lib/mnswpr.js` to render mine positions as visual hints and enable debug logging.
|
||||
**Test mode:** 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 (`app/modules/`)
|
||||
## Leaderboard / Firebase
|
||||
|
||||
`LeaderBoardService` (`leader-board.js`) reads/writes Firestore (`firebase/firestore/lite`). Structure: top-10 per level in `mw-leaders/{level}/games`, all sessions in `mw-all/{browserId}/games`, and remote runtime `configuration` in `mw-config`. A score is only offered to the leaderboard when it beats the current 10th place *and* matches the server-side `passingStatus`.
|
||||
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`.
|
||||
|
||||
The **Firebase config in `leader-board.js` is intentionally public and committed** — for a client-only Firebase app the API key is not a secret (access is governed by Firestore security rules), so don't treat it as a leaked credential or try to move it to env vars.
|
||||
**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.
|
||||
|
||||
`UserService` (`user.js`) derives a non-cryptographic `browserId` fingerprint from navigator/screen properties to attribute scores without accounts.
|
||||
**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`).
|
||||
- The engine uses **plain functions and `var`/`let` closures**, not classes; `utils/` and `app/modules/` use ES classes. Match the surrounding style of the file you edit.
|
||||
- `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.
|
||||
|
||||
## Release & git hooks (maintainer workflow)
|
||||
## Git hooks
|
||||
|
||||
- **Husky hooks:** `pre-commit` runs `pnpm lint`; `post-commit` auto-pushes to two extra remotes (`git push gh`, `git push sh`). If those remotes aren't configured locally, expect post-commit failures — that's environmental, not a code problem.
|
||||
- **Releasing** (`pnpm release`) builds the lib, runs `bumpp` (version bump + tag) in `lib/`, then `scripts/release.js` force-pushes a `release` branch and tags to remotes `gh`/`sh`. Pushing a `v*` tag triggers `.github/workflows/release.yml` (`changelogithub`) to publish GitHub release notes. Only run this when explicitly releasing.
|
||||
- `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
7
CLAUDE.md
Normal 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
131
CONTRIBUTING.md
Normal 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
37
LICENSE
|
|
@ -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.
|
||||
|
|
|
|||
57
README.md
57
README.md
|
|
@ -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
|
||||

|
||||
|
||||
## 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,19 +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 — and because it has come a long way in modernizing along the way — I now also use it as a **playground for coding agents**. It's a small, framework-free, well-scoped codebase, which makes it a great sandbox to see how AI agents read, reason about, and change real code. To help them get their bearings quickly, the repo ships an [`AGENTS.md`](./AGENTS.md) describing the architecture and conventions.
|
||||
Because a big part of this project's purpose is to track how the software development industry evolves, 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.
|
||||
|
|
@ -48,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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
45
app/main.js
45
app/main.js
|
|
@ -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()
|
||||
|
|
@ -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.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
175
docs/deployment.md
Normal 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
|
||||
219
docs/firebase-leaderboards.md
Normal file
219
docs/firebase-leaderboards.md
Normal 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.
|
||||
56
docs/firestore-emulator.md
Normal file
56
docs/firestore-emulator.md
Normal 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.
|
||||
160
docs/leaderboard-env-migration.md
Normal file
160
docs/leaderboard-env-migration.md
Normal 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.
|
||||
|
|
@ -49,5 +49,11 @@ export default defineConfig([
|
|||
}]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/scripts/**/*.{js,mjs,cjs}'],
|
||||
languageOptions: {
|
||||
globals: globals.node
|
||||
}
|
||||
},
|
||||
globalIgnores(['**/dist'])
|
||||
])
|
||||
|
|
|
|||
7
firebase/.firebaserc
Normal file
7
firebase/.firebaserc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"projects": {
|
||||
"default": "secure-moment-188701",
|
||||
"prod": "secure-moment-188701",
|
||||
"dev": "secure-moment-188701"
|
||||
}
|
||||
}
|
||||
15
firebase/firebase.json
Normal file
15
firebase/firebase.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"emulators": {
|
||||
"firestore": {
|
||||
"port": 8080
|
||||
},
|
||||
"ui": {
|
||||
"enabled": true
|
||||
},
|
||||
"singleProjectMode": true
|
||||
}
|
||||
}
|
||||
5
firebase/firestore.indexes.json
Normal file
5
firebase/firestore.indexes.json
Normal 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
78
firebase/firestore.rules
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
24
lib/LICENSE
24
lib/LICENSE
|
|
@ -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.
|
||||
157
lib/README.md
157
lib/README.md
|
|
@ -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)_
|
||||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
142
lib/mnswpr.css
142
lib/mnswpr.css
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
871
lib/mnswpr.js
871
lib/mnswpr.js
|
|
@ -1,871 +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
|
||||
// Mine positions stored as a Set of numeric keys (row * cols + col) for O(1) lookup
|
||||
let mines = new Set()
|
||||
// Cells currently highlighted, so removeHighlights only resets these (<=9) instead of the whole grid
|
||||
let highlightedCells = []
|
||||
// Count of safe (non-mine) cells revealed, so the win check is O(1) instead of scanning the whole grid
|
||||
let revealedSafeCount = 0
|
||||
|
||||
this.initialize = function() {
|
||||
const headingElement = document.createElement('h1')
|
||||
const gameBoard = document.createElement('div')
|
||||
|
||||
const versionLink = version === 'dev' ? 'dev' : `<a href="https://github.com/ayo-run/mnswpr/releases/tag/v${version}">v${version}</a>`
|
||||
|
||||
headingElement.innerHTML = `<span>Minesweeper</span><sup>${versionLink}</sup>`
|
||||
document.title = `mnswpr [${version}]`
|
||||
gameBoard.setAttribute('id', 'game-board')
|
||||
gameBoard.append(initializeToolbar(), grid, initializeFootbar())
|
||||
if(appElement) {
|
||||
appElement.innerHTML = ''
|
||||
appElement.append(headingElement, gameBoard)
|
||||
}
|
||||
initializeGlobalEventHandlers()
|
||||
generateGrid({ initial: true })
|
||||
}
|
||||
|
||||
function initializeFootbar() {
|
||||
const footBar = document.createElement('div')
|
||||
|
||||
const resetButton = document.createElement('button')
|
||||
resetButton.innerText = 'Reset'
|
||||
resetButton.onmousedown = () => generateGrid()
|
||||
footBar.append(resetButton)
|
||||
|
||||
let levelsDropdown = document.createElement('select')
|
||||
levelsDropdown.onchange = () => updateSetting(levelsDropdown.value)
|
||||
|
||||
const levelsKeys = Object.keys(levels)
|
||||
levelsKeys.forEach(key => {
|
||||
const levelOption = document.createElement('option')
|
||||
levelOption.value = levels[key].id
|
||||
levelOption.text = levels[key].name
|
||||
if (setting.id === levelOption.value) {
|
||||
levelOption.selected = true
|
||||
}
|
||||
levelsDropdown.add(levelOption, null)
|
||||
})
|
||||
|
||||
|
||||
|
||||
if (TEST_MODE) {
|
||||
const testLevel = document.createElement('span')
|
||||
testLevel.innerText = 'Test Mode'
|
||||
footBar.append(testLevel)
|
||||
} else {
|
||||
footBar.append(levelsDropdown)
|
||||
}
|
||||
|
||||
return footBar
|
||||
}
|
||||
|
||||
function initializeToolbar() {
|
||||
const toolbar = document.createElement('div')
|
||||
const toolbarItems = []
|
||||
|
||||
const flagsWrapper = document.createElement('div')
|
||||
flagsWrapper.append(flagsDisplay)
|
||||
flagsWrapper.style.height = '20px'
|
||||
toolbar.append(flagsWrapper)
|
||||
toolbarItems.push(flagsWrapper)
|
||||
|
||||
const smileyWrapper = document.createElement('div')
|
||||
smileyWrapper.append(smileyDisplay)
|
||||
// toolbar.append(smileyWrapper);
|
||||
// toolbarItems.push(smileyWrapper);
|
||||
|
||||
const timerWrapper = document.createElement('div')
|
||||
timerWrapper.append(timerDisplay)
|
||||
timerWrapper.style.height = '20px'
|
||||
toolbar.append(timerWrapper)
|
||||
toolbarItems.push(timerWrapper)
|
||||
|
||||
toolbar.style.cursor = 'pointer'
|
||||
toolbar.style.padding = '10px 35px'
|
||||
toolbar.style.display = 'flex'
|
||||
toolbar.style.justifyContent = 'space-between'
|
||||
toolbar.onmousedown = () => generateGrid()
|
||||
|
||||
return toolbar
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the game level
|
||||
* @param {String} key
|
||||
*/
|
||||
function updateSetting(key) {
|
||||
setting = levels[key]
|
||||
storageService.saveToLocal('setting', setting)
|
||||
generateGrid({ initial: true })
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate the Game Board
|
||||
* @param {{
|
||||
* initial: boolean
|
||||
* }} options - Game Board Options
|
||||
*/
|
||||
function generateGrid(options = { initial: false }) {
|
||||
firstClick = true
|
||||
grid.innerHTML = ''
|
||||
grid.oncontextmenu = () => false
|
||||
flagsCount = setting.mines
|
||||
mines.clear()
|
||||
highlightedCells = []
|
||||
revealedSafeCount = 0
|
||||
|
||||
for (let i = 0; i < setting.rows; i++) {
|
||||
let row = grid.insertRow(i)
|
||||
row.oncontextmenu = () => false
|
||||
for (let j=0; j<setting.cols; j++) {
|
||||
let cell = row.insertCell(j)
|
||||
initializeEventHandlers(cell)
|
||||
|
||||
if ('ontouchstart' in document.documentElement) {
|
||||
isMobile = true
|
||||
initializeTouchEventHandlers(cell)
|
||||
}
|
||||
|
||||
let status = document.createAttribute('data-status')
|
||||
status.value = 'default'
|
||||
cell.setAttributeNode(status)
|
||||
}
|
||||
}
|
||||
|
||||
let gameStatus = document.createAttribute('game-status')
|
||||
gameStatus.value = 'inactive'
|
||||
grid.setAttributeNode(gameStatus)
|
||||
|
||||
if (appElement) {
|
||||
appElement.style.margin = '0 auto'
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: add hook afterGridGenerated
|
||||
* - for initializing the leaderboard
|
||||
*/
|
||||
if (options.initial)
|
||||
hooks.levelChanged(setting)
|
||||
|
||||
timerService.initialize(timerDisplay)
|
||||
updateFlagsCountDisplay()
|
||||
addMines(setting.mines)
|
||||
|
||||
}
|
||||
|
||||
function setBusy() {
|
||||
isBusy = true
|
||||
if (isMobile) {
|
||||
setTimeout(() => isBusy = false, MOBILE_BUSY_DELAY)
|
||||
} else {
|
||||
setTimeout(() => isBusy = false, PC_BUSY_DELAY)
|
||||
}
|
||||
}
|
||||
|
||||
function updateFlagsCountDisplay(count = flagsCount) {
|
||||
if (grid.getAttribute('game-status') != 'win') {
|
||||
flagsDisplay.innerHTML = `${count}`
|
||||
return
|
||||
}
|
||||
flagsDisplay.innerHTML = '😁'
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLTableCellElement} cell
|
||||
*/
|
||||
function initializeTouchEventHandlers(cell) {
|
||||
let ontouchleave = function() {
|
||||
if (clickedCell === this) {
|
||||
clickedCell = undefined
|
||||
}
|
||||
}
|
||||
cell.addEventListener('touchleave', ontouchleave)
|
||||
|
||||
let ontouchend = function() {
|
||||
endTouchTimer()
|
||||
}
|
||||
cell.addEventListener('touchend', ontouchend)
|
||||
|
||||
let ontouchstart = function(e) {
|
||||
isMobile = true
|
||||
if (!isBusy && typeof e === 'object') {
|
||||
startTouchTimer(this)
|
||||
}
|
||||
}
|
||||
cell.addEventListener('touchstart', ontouchstart)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the document/window/grid-level input handlers. These don't depend on
|
||||
* any individual cell, so they're set up once instead of being reassigned
|
||||
* for every cell during grid generation.
|
||||
*/
|
||||
function initializeGlobalEventHandlers() {
|
||||
document.onkeydown = function(e) {
|
||||
if (e.keyCode == 32 || e.keyCode == 113) {
|
||||
generateGrid()
|
||||
if ('preventDefault' in e) {
|
||||
e.preventDefault()
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
resetMouseEventFlags()
|
||||
}
|
||||
|
||||
window.onblur = function() {
|
||||
resetMouseEventFlags()
|
||||
}
|
||||
|
||||
grid.onmouseleave = function() {
|
||||
removeHighlights()
|
||||
}
|
||||
|
||||
document.oncontextmenu = () => false
|
||||
|
||||
document.onmouseup = function() {
|
||||
resetMouseEventFlags()
|
||||
}
|
||||
|
||||
document.onmousedown = function(e) {
|
||||
isMobile = false
|
||||
switch (e.button) {
|
||||
case 0: pressed = 'left'; isLeft = true; break
|
||||
case 1: pressed = 'middle'; break
|
||||
case 2: isRight = true; break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initializeEventHandlers(_cell) {
|
||||
|
||||
let cell = _cell
|
||||
skip = false
|
||||
skipCondition = false
|
||||
|
||||
resetMouseEventFlags()
|
||||
|
||||
// Set grid status to active on first click
|
||||
cell.onmouseup = function(e) {
|
||||
pressed = undefined
|
||||
let dont = false
|
||||
|
||||
if (bothPressed) {
|
||||
bothPressed = false
|
||||
if (e.button == '2') {
|
||||
skipCondition = true
|
||||
} else if (e.button == '0') {
|
||||
dont = true
|
||||
}
|
||||
if (getStatus(this) == 'clicked') {
|
||||
middleClickCell(this)
|
||||
return
|
||||
}
|
||||
}
|
||||
switch(e.button) {
|
||||
case 0: {
|
||||
isLeft = false
|
||||
if (skipCondition) {
|
||||
skip = true
|
||||
}
|
||||
break
|
||||
}
|
||||
case 2: isRight = false; break
|
||||
}
|
||||
removeHighlights()
|
||||
if (skip || dont) {
|
||||
skip = false
|
||||
skipCondition = false
|
||||
return
|
||||
}
|
||||
if (!isBusy && typeof e === 'object' && e.button != 2) {
|
||||
mouseUpCallBackArray[e.button].call(_this, this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
cell.onmousedown = function(e) {
|
||||
skip = false
|
||||
if (!isBusy && typeof e === 'object') {
|
||||
switch(e.button) {
|
||||
case 0: isLeft = true; break
|
||||
case 2: isRight = true; break
|
||||
}
|
||||
|
||||
if (isLeft && isRight) {
|
||||
bothPressed = true
|
||||
highlightSurroundingCell(this)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.button == '1') {
|
||||
pressed = 'middle'
|
||||
highlightSurroundingCell(this)
|
||||
} else if (e.button == '0') {
|
||||
pressed = 'left'
|
||||
if (getStatus(this) == 'clicked') {
|
||||
highlightSurroundingCell(this)
|
||||
} else {
|
||||
highlightCell(this)
|
||||
}
|
||||
}
|
||||
|
||||
if (e.button == '2') mouseDownCallBackArray[e.button].call(_this, this)
|
||||
}
|
||||
}
|
||||
|
||||
cell.onmousemove = function(e) {
|
||||
if ((pressed || bothPressed) && typeof e === 'object') {
|
||||
removeHighlights()
|
||||
/*
|
||||
if (!isEqual(clickedCell, cell)) {
|
||||
clickedCell = undefined;
|
||||
}
|
||||
*/
|
||||
if (pressed == 'middle' || (isLeft && isRight)) {
|
||||
highlightSurroundingCell(this)
|
||||
} else if (pressed == 'left') {
|
||||
if (getStatus(this) == 'clicked') {
|
||||
highlightSurroundingCell(this)
|
||||
} else {
|
||||
highlightCell(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cell.oncontextmenu = () => false
|
||||
cell.onselectstart = () => false
|
||||
cell.setAttribute('unselectable', 'on')
|
||||
}
|
||||
|
||||
function isEqual(x, y) {
|
||||
if (!x) return false
|
||||
return x === y
|
||||
}
|
||||
|
||||
function startTouchTimer(cell) {
|
||||
if (isEqual(clickedCell, cell)) {
|
||||
return
|
||||
}
|
||||
clickedCell = cell
|
||||
setTimeout(() => {
|
||||
if (isEqual(clickedCell, cell)) {
|
||||
rightClickCell(cell)
|
||||
setBusy()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function endTouchTimer() {
|
||||
clickedCell = undefined
|
||||
}
|
||||
|
||||
function resetMouseEventFlags() {
|
||||
pressed = undefined
|
||||
bothPressed = undefined
|
||||
isLeft = false
|
||||
isRight = false
|
||||
removeHighlights()
|
||||
skip = true
|
||||
}
|
||||
|
||||
function addMines(minesCount) {
|
||||
//Add mines randomly
|
||||
for (let i=0; i<minesCount; i++) {
|
||||
let row = Math.floor(Math.random() * setting.rows)
|
||||
let col = Math.floor(Math.random() * setting.cols)
|
||||
let cell = grid.rows[row].cells[col]
|
||||
if (isMine(cell)) {
|
||||
transferMine()
|
||||
} else {
|
||||
mines.add(mineKey(row, col))
|
||||
}
|
||||
if (TEST_MODE){
|
||||
cell.innerHTML = 'X'
|
||||
}
|
||||
}
|
||||
if (TEST_MODE) {
|
||||
printMines()
|
||||
}
|
||||
}
|
||||
|
||||
function revealMines() {
|
||||
if (grid.getAttribute('game-status') == 'done') return
|
||||
//Highlight all mines in red
|
||||
const win = grid.getAttribute('game-status') == 'win'
|
||||
for (let i=0; i<setting.rows; i++) {
|
||||
for(let j=0; j<setting.cols; j++) {
|
||||
let cell = grid.rows[i].cells[j]
|
||||
if (win) {
|
||||
handleWinRevelation(cell)
|
||||
} else {
|
||||
handleLostRevelation(cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
grid.setAttribute('game-status', 'done')
|
||||
|
||||
const time = timerService.stop()
|
||||
const game = {
|
||||
time,
|
||||
status: win ? 'win' : 'loss',
|
||||
level: setting.id,
|
||||
time_stamp: new Date(),
|
||||
isMobile
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: add hook after gameSession send back `game`
|
||||
* - for sending the game score to the db
|
||||
*/
|
||||
hooks.gameDone(game)
|
||||
|
||||
}
|
||||
|
||||
function handleWinRevelation(cell) {
|
||||
updateFlagsCountDisplay(0)
|
||||
if (isMine(cell)) {
|
||||
cell.innerHTML = ':)'
|
||||
cell.className = 'correct'
|
||||
setStatus(cell, 'clicked')
|
||||
let correct = document.createAttribute('title')
|
||||
correct.value = 'Correct'
|
||||
cell.setAttributeNode(correct)
|
||||
setStatus(cell, 'clicked')
|
||||
}
|
||||
}
|
||||
|
||||
function handleLostRevelation(cell) {
|
||||
if (isFlagged(cell)) {
|
||||
cell.className = 'flag'
|
||||
if (!isMine(cell)) {
|
||||
cell.innerHTML = 'X'
|
||||
cell.className = 'wrong'
|
||||
let wrong = document.createAttribute('title')
|
||||
wrong.value = 'Wrong'
|
||||
cell.setAttributeNode(wrong)
|
||||
} else {
|
||||
cell.innerHTML = ':)'
|
||||
cell.className = 'correct'
|
||||
let correct = document.createAttribute('title')
|
||||
correct.value = 'Correct'
|
||||
cell.setAttributeNode(correct)
|
||||
}
|
||||
} else {
|
||||
if (isMine(cell)) {
|
||||
cell.className = 'mine'
|
||||
setStatus(cell, 'clicked')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isOpen(cell) {
|
||||
return cell.innerHTML !== '' && !isFlagged(cell)
|
||||
}
|
||||
|
||||
function isFlagged(cell) {
|
||||
return getStatus(cell) == 'flagged'
|
||||
}
|
||||
|
||||
function mineKey(row, col) {
|
||||
return row * setting.cols + col
|
||||
}
|
||||
|
||||
function isMine(cell) {
|
||||
return mines.has(mineKey(getRow(cell), getCol(cell)))
|
||||
}
|
||||
|
||||
function checkLevelCompletion() {
|
||||
const safeCells = setting.rows * setting.cols - setting.mines
|
||||
if (revealedSafeCount >= safeCells && grid.getAttribute('game-status') == 'active') {
|
||||
grid.setAttribute('game-status', 'win')
|
||||
revealMines()
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(cell, status) {
|
||||
cell.setAttribute('data-status', status)
|
||||
}
|
||||
|
||||
function getCol(cell) {
|
||||
return cell.cellIndex
|
||||
}
|
||||
|
||||
function getRow(cell) {
|
||||
return cell.parentNode.rowIndex
|
||||
}
|
||||
|
||||
function getStatus(cell) {
|
||||
if (!cell) return undefined
|
||||
return cell.getAttribute('data-status')
|
||||
}
|
||||
|
||||
function middleClickCell(cell) {
|
||||
if (grid.getAttribute('game-status') != 'active' || getStatus(cell) !== 'clicked') {
|
||||
return
|
||||
}
|
||||
// check for number of surrounding flags
|
||||
const valueString = cell.getAttribute('data-value')
|
||||
let cellValue = parseInt(valueString, 10)
|
||||
let flagCount = countFlagsAround(cell)
|
||||
|
||||
if (flagCount === cellValue) {
|
||||
clickSurrounding(cell)
|
||||
if (TEST_MODE) loggerService.debug('middle click', cell)
|
||||
}
|
||||
}
|
||||
|
||||
function countFlagsAround(cell) {
|
||||
let flagCount = 0
|
||||
let cellRow = cell.parentNode.rowIndex
|
||||
let cellCol = cell.cellIndex
|
||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
|
||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
|
||||
if (isFlagged(grid.rows[i].cells[j])) flagCount++
|
||||
}
|
||||
}
|
||||
return flagCount
|
||||
}
|
||||
|
||||
function clickSurrounding(cell) {
|
||||
if (grid.getAttribute('game-status') != 'active') return
|
||||
let cellRow = cell.parentNode.rowIndex
|
||||
let cellCol = cell.cellIndex
|
||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
|
||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
|
||||
let currentCell = grid.rows[i].cells[j]
|
||||
if (getStatus(currentCell) == 'flagged') continue
|
||||
openCell(currentCell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function increaseFlagsCount() {
|
||||
flagsCount++
|
||||
updateFlagsCountDisplay()
|
||||
}
|
||||
|
||||
function decreaseFlagsCount() {
|
||||
flagsCount--
|
||||
updateFlagsCountDisplay()
|
||||
}
|
||||
|
||||
function activateGame() {
|
||||
grid.setAttribute('game-status', 'active')
|
||||
// start timer
|
||||
timerService.start()
|
||||
}
|
||||
|
||||
function gameIsDone() {
|
||||
return grid.getAttribute('game-status') == 'over' || grid.getAttribute('game-status') == 'done'
|
||||
}
|
||||
|
||||
function removeHighlights() {
|
||||
for (let i = 0; i < highlightedCells.length; i++) {
|
||||
const currentCell = highlightedCells[i]
|
||||
// guard: a tracked cell may have been flagged/clicked since it was highlighted
|
||||
if (getStatus(currentCell) == 'highlighted') setStatus(currentCell, 'default')
|
||||
}
|
||||
highlightedCells = []
|
||||
}
|
||||
|
||||
function highlightCell(cell) {
|
||||
if (isFlagged(cell)) return
|
||||
if (!gameIsDone() && getStatus(cell) == 'default') {
|
||||
setStatus(cell, 'highlighted') // currentCell.classList.add('highlight');
|
||||
highlightedCells.push(cell)
|
||||
}
|
||||
}
|
||||
|
||||
function highlightSurroundingCell(cell) {
|
||||
let cellRow = cell.parentNode.rowIndex
|
||||
let cellCol = cell.cellIndex
|
||||
|
||||
highlightCell(cell)
|
||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
|
||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
|
||||
let currentCell = grid.rows[i].cells[j]
|
||||
highlightCell(currentCell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rightClickCell(cell) {
|
||||
if (isFlagged(cell)) setBusy()
|
||||
if (grid.getAttribute('game-status') == 'inactive') {
|
||||
activateGame()
|
||||
}
|
||||
if (grid.getAttribute('game-status') != 'active') return
|
||||
if (getStatus(cell) != 'clicked' && getStatus(cell) != 'empty') {
|
||||
if (getStatus(cell) == 'default' || getStatus(cell) == 'highlighted') {
|
||||
if (flagsCount <= 0) return
|
||||
cell.className = 'flag'
|
||||
decreaseFlagsCount()
|
||||
setStatus(cell, 'flagged')
|
||||
} else {
|
||||
cell.className = ''
|
||||
increaseFlagsCount()
|
||||
setStatus(cell, 'default')
|
||||
}
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(100)
|
||||
}
|
||||
if (TEST_MODE) loggerService.debug('right click', cell)
|
||||
}
|
||||
}
|
||||
|
||||
function clickCell(cell) {
|
||||
if (isFlagged(cell)) setBusy()
|
||||
if (grid.getAttribute('game-status') == 'inactive') {
|
||||
activateGame()
|
||||
}
|
||||
if (grid.getAttribute('game-status') != 'active') return
|
||||
//Check if the end-user clicked on a mine
|
||||
if (TEST_MODE) loggerService.debug('click', cell)
|
||||
if (getStatus(cell) == 'flagged' || grid.getAttribute('game-status') == 'over') {
|
||||
return
|
||||
} else if (getStatus(cell) == 'clicked') {
|
||||
middleClickCell(cell)
|
||||
return
|
||||
} else if (isMine(cell) && firstClick) {
|
||||
// cell.setAttribute('data-mine', 'false');
|
||||
mines.delete(mineKey(getRow(cell), getCol(cell)))
|
||||
transferMine(cell)
|
||||
if (TEST_MODE) printMines()
|
||||
}
|
||||
|
||||
openCell(cell)
|
||||
}
|
||||
|
||||
function printMines() {
|
||||
let count = 0
|
||||
for (let i = 0; i < setting.rows; i++) {
|
||||
for (let j = 0; j < setting.cols; j++) {
|
||||
if (isMine(grid.rows[i].cells[j])) {
|
||||
loggerService.debug(count++ + ' - mine: [' + i + ',' + j + ']')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function transferMine(cell = undefined) {
|
||||
let found = false
|
||||
do {
|
||||
let row = Math.floor(Math.random() * setting.rows)
|
||||
let col = Math.floor(Math.random() * setting.cols)
|
||||
const transferMineToCell = grid.rows[row].cells[col]
|
||||
if (isMine(transferMineToCell) || transferMineToCell === cell || isNeighbor(cell, transferMineToCell)) {
|
||||
continue
|
||||
} else {
|
||||
mines.add(mineKey(row, col))
|
||||
if (TEST_MODE){
|
||||
transferMineToCell.innerHTML = 'X'
|
||||
if (TEST_MODE) loggerService.debug('transferred mine to: ' + row + ', ' + col)
|
||||
}
|
||||
// TODO: refactor maybe
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
found = true
|
||||
return
|
||||
}
|
||||
} while(!found)
|
||||
}
|
||||
|
||||
function isNeighbor(cell, nextCell) {
|
||||
if (cell === undefined) {
|
||||
return
|
||||
}
|
||||
const rowDifference = Math.abs(getRow(cell) - getRow(nextCell))
|
||||
const colDifference = Math.abs(getCol(cell) - getCol(nextCell))
|
||||
|
||||
return (rowDifference === 1) && (colDifference === 1)
|
||||
}
|
||||
|
||||
function countMinesAround(cell) {
|
||||
let mineCount=0
|
||||
let cellRow = cell.parentNode.rowIndex
|
||||
let cellCol = cell.cellIndex
|
||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1,setting.rows-1); i++) {
|
||||
const rows = grid.rows[i]
|
||||
if (!rows) continue
|
||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1,setting.cols-1); j++) {
|
||||
const cell = rows.cells[j]
|
||||
const mine = isMine(cell)
|
||||
if (cell && mine) {
|
||||
mineCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
return mineCount
|
||||
}
|
||||
|
||||
function updateCellValue(cell, value) {
|
||||
const spanElement = document.createElement('span')
|
||||
spanElement.innerHTML = value
|
||||
cell.innerHTML = ''
|
||||
cell.appendChild(spanElement)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal a single known-non-mine cell. Returns true when the cell is blank
|
||||
* (no adjacent mines), which is the signal to keep flooding from it.
|
||||
* @param {HTMLTableCellElement} cell
|
||||
*/
|
||||
function revealSafeCell(cell) {
|
||||
// A cell can be re-opened via chording, so only count the first reveal
|
||||
const wasOpen = getStatus(cell) == 'clicked' || getStatus(cell) == 'empty'
|
||||
cell.className = 'clicked'
|
||||
setStatus(cell, 'clicked')
|
||||
if (!wasOpen) revealedSafeCount++
|
||||
|
||||
const mineCount = countMinesAround(cell)
|
||||
if (mineCount == 0) {
|
||||
updateCellValue(cell, ' ')
|
||||
setStatus(cell, 'empty')
|
||||
return true
|
||||
}
|
||||
|
||||
updateCellValue(cell, mineCount.toString())
|
||||
const dataValue = document.createAttribute('data-value')
|
||||
dataValue.value = mineCount.toString()
|
||||
cell.setAttributeNode(dataValue)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Iteratively reveal the connected region of blank cells, starting from a
|
||||
* cell that has already been revealed as blank. Uses an explicit queue with
|
||||
* isOpen() as the visited guard instead of recursing back through clickCell,
|
||||
* so there are no redundant per-cell checks and no deep call stack.
|
||||
* @param {HTMLTableCellElement} startCell
|
||||
*/
|
||||
function handleEmpty(startCell) {
|
||||
const queue = [startCell]
|
||||
while (queue.length) {
|
||||
const cell = queue.shift()
|
||||
const cellRow = cell.parentNode.rowIndex
|
||||
const cellCol = cell.cellIndex
|
||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
|
||||
const rows = grid.rows[i]
|
||||
if (!rows) continue
|
||||
for (let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
|
||||
const neighbor = rows.cells[j]
|
||||
if (!neighbor || neighbor === cell) continue
|
||||
if (isFlagged(neighbor)) {
|
||||
setBusy()
|
||||
continue
|
||||
}
|
||||
if (isOpen(neighbor)) continue
|
||||
// blank cells have no adjacent mines, so every neighbor here is safe
|
||||
if (revealSafeCell(neighbor)) queue.push(neighbor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openCell(cell) {
|
||||
if (grid.getAttribute('game-status') != 'active') return
|
||||
|
||||
firstClick = false
|
||||
|
||||
if (isMine(cell)) {
|
||||
cell.className = 'clicked'
|
||||
setStatus(cell, 'clicked')
|
||||
revealMines()
|
||||
flagsDisplay.innerHTML = '😱'
|
||||
grid.setAttribute('game-status', 'over')
|
||||
return
|
||||
}
|
||||
|
||||
if (revealSafeCell(cell)) handleEmpty(cell)
|
||||
checkLevelCompletion()
|
||||
}
|
||||
}
|
||||
|
||||
export default Minesweeper
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"name": "@ayo-run/mnswpr",
|
||||
"version": "0.4.35",
|
||||
"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"
|
||||
}
|
||||
|
|
@ -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
35
netlify.toml
Normal 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"
|
||||
41
package.json
41
package.json
|
|
@ -1,41 +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": {
|
||||
"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",
|
||||
"dev": "vite app",
|
||||
"start": "vite app",
|
||||
"build": "vite build app",
|
||||
"build:lib": "vite build lib",
|
||||
"publish:lib": "cd lib && npm publish",
|
||||
"release": "pnpm build:lib && pnpm -F @ayo-run/mnswpr run release && pnpm publish:lib",
|
||||
"build:preview": "pnpm -F app run build:preview",
|
||||
"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",
|
||||
"vitest": "^4.1.9"
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.9",
|
||||
"web-component-base": "^5.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b"
|
||||
}
|
||||
|
|
|
|||
11717
pnpm-lock.yaml
11717
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +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
BIN
screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
458
scripts/check-content.mjs
Normal file
458
scripts/check-content.mjs
Normal 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
99
scripts/ensure-java.mjs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Post-install convenience: make a Java runtime available for the Firebase
|
||||
* Firestore emulator (used by `pnpm 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
135
scripts/export-legends.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
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}`)
|
||||
|
|
@ -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
103
scripts/seed-dev-scores.js
Normal 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)
|
||||
}
|
||||
306
scripts/test/check-content.test.js
Normal file
306
scripts/test/check-content.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -10,7 +10,6 @@
|
|||
|
||||
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="./main.css" />
|
||||
<link rel="stylesheet" href="../utils/loading/loading.css" />
|
||||
<style>
|
||||
:host, :root{
|
||||
--mnswpr-transition: 10s ease-in-out;
|
||||
|
|
@ -31,9 +30,16 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="body-wrapper">
|
||||
<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
101
src/legends.html
Normal 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>
|
||||
|
|
@ -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
76
src/main.js
Normal 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()
|
||||
100
src/modules/nickname/nickname.js
Normal file
100
src/modules/nickname/nickname.js
Normal 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))
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
0
app/vite-env.d.ts → src/vite-env.d.ts
vendored
0
app/vite-env.d.ts → src/vite-env.d.ts
vendored
|
|
@ -1,31 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { levels } from '../lib/levels.js'
|
||||
|
||||
describe('levels', () => {
|
||||
it('defines the four expected difficulty presets', () => {
|
||||
expect(Object.keys(levels)).toEqual([
|
||||
'beginner',
|
||||
'intermediate',
|
||||
'expert',
|
||||
'nightmare'
|
||||
])
|
||||
})
|
||||
|
||||
it('gives every level a well-formed, consistent shape', () => {
|
||||
for (const [key, level] of Object.entries(levels)) {
|
||||
expect(typeof level.rows).toBe('number')
|
||||
expect(typeof level.cols).toBe('number')
|
||||
expect(typeof level.mines).toBe('number')
|
||||
expect(typeof level.name).toBe('string')
|
||||
// the map key must match the level's own id, since lookups use both
|
||||
expect(level.id).toBe(key)
|
||||
}
|
||||
})
|
||||
|
||||
it('never has more mines than there are cells', () => {
|
||||
for (const level of Object.values(levels)) {
|
||||
expect(level.mines).toBeLessThan(level.rows * level.cols)
|
||||
expect(level.mines).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import Minesweeper from '../lib/mnswpr.js'
|
||||
|
||||
// Build a fresh board mounted on #app and return its grid <table>.
|
||||
function mountGame() {
|
||||
document.body.innerHTML = '<div id="app"></div>'
|
||||
const game = new Minesweeper('app', 'dev')
|
||||
game.initialize()
|
||||
return document.getElementById('grid')
|
||||
}
|
||||
|
||||
function leftClick(cell) {
|
||||
cell.dispatchEvent(new MouseEvent('mousedown', { button: 0, bubbles: true }))
|
||||
cell.dispatchEvent(new MouseEvent('mouseup', { button: 0, bubbles: true }))
|
||||
}
|
||||
|
||||
function rightClick(cell) {
|
||||
cell.dispatchEvent(new MouseEvent('mousedown', { button: 2, bubbles: true }))
|
||||
}
|
||||
|
||||
function everyCell(grid, fn) {
|
||||
for (let i = 0; i < grid.rows.length; i++) {
|
||||
for (let j = 0; j < grid.rows[i].cells.length; j++) {
|
||||
fn(grid.rows[i].cells[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Minesweeper board', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
// Fake timers stop the requestAnimationFrame game clock from running during tests.
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// Mount a game on a custom board injected via the cached 'setting' localStorage key.
|
||||
function mountCustomGame(setting, hooks) {
|
||||
localStorage.setItem('setting', JSON.stringify(setting))
|
||||
document.body.innerHTML = '<div id="app"></div>'
|
||||
const game = new Minesweeper('app', 'dev', hooks)
|
||||
game.initialize()
|
||||
return document.getElementById('grid')
|
||||
}
|
||||
|
||||
it('renders the beginner grid (9x9) by default', () => {
|
||||
const grid = mountGame()
|
||||
expect(grid.rows.length).toBe(9)
|
||||
everyCell(grid, cell => expect(cell.parentNode.cells.length).toBe(9))
|
||||
})
|
||||
|
||||
it('starts inactive with every cell in the default state', () => {
|
||||
const grid = mountGame()
|
||||
expect(grid.getAttribute('game-status')).toBe('inactive')
|
||||
everyCell(grid, cell => expect(cell.getAttribute('data-status')).toBe('default'))
|
||||
})
|
||||
|
||||
it('activates the game and reveals the cell on the first click', () => {
|
||||
const grid = mountGame()
|
||||
const cell = grid.rows[0].cells[0]
|
||||
leftClick(cell)
|
||||
expect(grid.getAttribute('game-status')).not.toBe('inactive')
|
||||
expect(cell.getAttribute('data-status')).not.toBe('default')
|
||||
})
|
||||
|
||||
it('never loses on the first click, across many random boards', () => {
|
||||
// Exercises mine placement + first-click mine transfer (the Set-backed logic).
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const grid = mountGame()
|
||||
leftClick(grid.rows[0].cells[0])
|
||||
expect(grid.getAttribute('game-status')).not.toBe('over')
|
||||
}
|
||||
})
|
||||
|
||||
it('flags and unflags a cell on right click', () => {
|
||||
const grid = mountGame()
|
||||
const cell = grid.rows[0].cells[0]
|
||||
|
||||
rightClick(cell)
|
||||
expect(cell.getAttribute('data-status')).toBe('flagged')
|
||||
expect(cell.className).toBe('flag')
|
||||
|
||||
rightClick(cell)
|
||||
expect(cell.getAttribute('data-status')).toBe('default')
|
||||
})
|
||||
|
||||
it('resets the board back to the inactive state', () => {
|
||||
const grid = mountGame()
|
||||
leftClick(grid.rows[0].cells[0])
|
||||
expect(grid.getAttribute('game-status')).not.toBe('inactive')
|
||||
|
||||
const resetButton = document.querySelector('#game-board button')
|
||||
resetButton.dispatchEvent(new MouseEvent('mousedown', { button: 0, bubbles: true }))
|
||||
|
||||
expect(grid.getAttribute('game-status')).toBe('inactive')
|
||||
everyCell(grid, cell => expect(cell.getAttribute('data-status')).toBe('default'))
|
||||
})
|
||||
|
||||
it('highlights the pressed cell and clears it when the press moves away', () => {
|
||||
const grid = mountGame()
|
||||
const a = grid.rows[5].cells[5]
|
||||
const b = grid.rows[5].cells[6]
|
||||
const c = grid.rows[5].cells[7]
|
||||
|
||||
// Press-and-hold left on A -> A highlighted.
|
||||
a.dispatchEvent(new MouseEvent('mousedown', { button: 0, bubbles: true }))
|
||||
expect(a.getAttribute('data-status')).toBe('highlighted')
|
||||
|
||||
// Drag the held press across B then C. Each move must clear the previous
|
||||
// highlight and leave no stale ones behind.
|
||||
b.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
|
||||
c.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
|
||||
|
||||
expect(a.getAttribute('data-status')).toBe('default')
|
||||
expect(b.getAttribute('data-status')).toBe('default')
|
||||
expect(c.getAttribute('data-status')).toBe('highlighted')
|
||||
|
||||
// Exactly one cell should remain highlighted across the whole grid.
|
||||
let highlighted = 0
|
||||
everyCell(grid, cell => {
|
||||
if (cell.getAttribute('data-status') === 'highlighted') highlighted++
|
||||
})
|
||||
expect(highlighted).toBe(1)
|
||||
})
|
||||
|
||||
it('declares a win once every safe cell is revealed', () => {
|
||||
// A mine-free 3x3 board: one click cascades to reveal all 9 safe cells.
|
||||
let finished = null
|
||||
const grid = mountCustomGame(
|
||||
{ rows: 3, cols: 3, mines: 0, id: 'test', name: 'test' },
|
||||
{ levelChanged: () => {}, gameDone: (g) => { finished = g.status } }
|
||||
)
|
||||
|
||||
leftClick(grid.rows[1].cells[1])
|
||||
|
||||
expect(finished).toBe('win')
|
||||
expect(grid.getAttribute('game-status')).toBe('done')
|
||||
everyCell(grid, cell => expect(cell.getAttribute('data-status')).not.toBe('default'))
|
||||
})
|
||||
|
||||
it('does not declare a win until the last safe cell is revealed', () => {
|
||||
// 1x3 board with the single mine pinned to the middle column.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
||||
const grid = mountCustomGame({ rows: 1, cols: 3, mines: 1, id: 'test', name: 'test' })
|
||||
|
||||
// First safe cell: adjacent to the mine, shows "1", no cascade -> not yet won.
|
||||
leftClick(grid.rows[0].cells[0])
|
||||
expect(grid.getAttribute('game-status')).toBe('active')
|
||||
|
||||
// Revealing the remaining safe cell completes the board.
|
||||
leftClick(grid.rows[0].cells[2])
|
||||
expect(grid.getAttribute('game-status')).toBe('done')
|
||||
})
|
||||
|
||||
it('flood fill stops at a flagged cell and never reveals it', () => {
|
||||
// 1x4 board with the mine pinned to the last column.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.99)
|
||||
const grid = mountCustomGame({ rows: 1, cols: 4, mines: 1, id: 'test', name: 'test' })
|
||||
|
||||
// Flag a safe cell sitting between the blank region and the mine.
|
||||
// (full press + release so the internal right-button flag resets)
|
||||
const flag = grid.rows[0].cells[2]
|
||||
flag.dispatchEvent(new MouseEvent('mousedown', { button: 2, bubbles: true }))
|
||||
flag.dispatchEvent(new MouseEvent('mouseup', { button: 2, bubbles: true }))
|
||||
expect(flag.getAttribute('data-status')).toBe('flagged')
|
||||
|
||||
// Click the far blank cell; the cascade must stop at the flag.
|
||||
leftClick(grid.rows[0].cells[0])
|
||||
|
||||
expect(grid.rows[0].cells[0].getAttribute('data-status')).toBe('empty')
|
||||
expect(flag.getAttribute('data-status')).toBe('flagged')
|
||||
// A safe cell is still hidden (behind the flag), so it is not a win.
|
||||
expect(grid.getAttribute('game-status')).toBe('active')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { StorageService } from '../utils/storage/storage.js'
|
||||
|
||||
describe('StorageService', () => {
|
||||
let storage
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
storage = new StorageService()
|
||||
})
|
||||
|
||||
it('round-trips an object through localStorage', () => {
|
||||
storage.saveToLocal('setting', { rows: 9, cols: 9, mines: 10 })
|
||||
expect(storage.getFromLocal('setting')).toEqual({ rows: 9, cols: 9, mines: 10 })
|
||||
})
|
||||
|
||||
it('round-trips an object through sessionStorage', () => {
|
||||
storage.saveToSession('setting', { id: 'beginner' })
|
||||
expect(storage.getFromSession('setting')).toEqual({ id: 'beginner' })
|
||||
})
|
||||
|
||||
it('returns null for a missing key', () => {
|
||||
expect(storage.getFromLocal('missing')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a stored undefined as undefined, not a thrown parse error', () => {
|
||||
storage.saveToLocal('setting', undefined)
|
||||
expect(storage.getFromLocal('setting')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { TimerService } from '../utils/timer/timer.js'
|
||||
|
||||
describe('TimerService.pretty', () => {
|
||||
const timer = new TimerService()
|
||||
|
||||
it('returns undefined for a falsy duration', () => {
|
||||
expect(timer.pretty(0)).toBeUndefined()
|
||||
expect(timer.pretty(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops leading zero-value units', () => {
|
||||
// 1500ms -> 1 second, 5 tenths, with hours/minutes stripped
|
||||
expect(timer.pretty(1500)).toBe('01.5')
|
||||
})
|
||||
|
||||
it('keeps minutes once they are non-zero', () => {
|
||||
// 65_000ms -> 1 minute 5 seconds
|
||||
expect(timer.pretty(65_000)).toBe('01:05.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TimerService.clean', () => {
|
||||
const timer = new TimerService()
|
||||
|
||||
it('blanks out a zeroed unit', () => {
|
||||
expect(timer.clean('00', ':')).toBe('')
|
||||
})
|
||||
|
||||
it('appends the separator to a non-zero unit', () => {
|
||||
expect(timer.clean('05', ':')).toBe('05:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TimerService rendering', () => {
|
||||
let display
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
display = document.createElement('span')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows 0 before the timer starts', () => {
|
||||
const timer = new TimerService()
|
||||
timer.initialize(display)
|
||||
expect(display.innerHTML).toBe('0')
|
||||
})
|
||||
|
||||
it('drives updates with requestAnimationFrame, not a 1ms interval', () => {
|
||||
const raf = vi.spyOn(window, 'requestAnimationFrame')
|
||||
const interval = vi.spyOn(window, 'setInterval')
|
||||
|
||||
const timer = new TimerService()
|
||||
timer.initialize(display)
|
||||
timer.start()
|
||||
|
||||
expect(raf).toHaveBeenCalled()
|
||||
expect(interval).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only touches the DOM when the displayed value changes', () => {
|
||||
const timer = new TimerService()
|
||||
timer.initialize(display)
|
||||
timer.start()
|
||||
|
||||
let writes = 0
|
||||
const span = { set innerHTML(_v) { writes++ } }
|
||||
timer.display = span
|
||||
|
||||
// Same tenth-of-a-second value across frames -> a single DOM write.
|
||||
timer.time = 1500
|
||||
timer.render()
|
||||
timer.render()
|
||||
timer.render()
|
||||
expect(writes).toBe(1)
|
||||
|
||||
// A new value -> one more write.
|
||||
timer.time = 1600
|
||||
timer.render()
|
||||
expect(writes).toBe(2)
|
||||
})
|
||||
|
||||
it('returns the elapsed time in milliseconds from stop()', () => {
|
||||
const timer = new TimerService()
|
||||
timer.initialize(display)
|
||||
timer.start()
|
||||
vi.advanceTimersByTime(2500)
|
||||
timer.tick()
|
||||
expect(timer.stop()).toBe(2500)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export * from './logger/logger.js'
|
||||
export * from './storage/storage.js'
|
||||
export * from './timer/timer.js'
|
||||
export * from './loading/loading.js'
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = ''
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { LoggerService } from '../logger/logger'
|
||||
|
||||
export class TimerService {
|
||||
|
||||
constructor() {
|
||||
this.loggerService = new LoggerService()
|
||||
this.time = 0
|
||||
this.rendered = undefined
|
||||
}
|
||||
|
||||
initialize(el) {
|
||||
if (!el) return
|
||||
|
||||
this.display = el
|
||||
this.startTime = undefined
|
||||
if (this.id !== undefined) {
|
||||
this.stop()
|
||||
}
|
||||
this.time = 0
|
||||
this.render()
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.running || !this.display) return
|
||||
|
||||
this.running = true
|
||||
this.startTime = Date.now()
|
||||
this.tick()
|
||||
this.loggerService.debug('started timer')
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false
|
||||
if (this.id !== undefined) {
|
||||
window.cancelAnimationFrame(this.id)
|
||||
}
|
||||
this.id = undefined
|
||||
this.loggerService.debug('stopped timer')
|
||||
return this.time
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the elapsed time and schedule the next frame.
|
||||
* Driven by requestAnimationFrame so it aligns with the browser's paint
|
||||
* cadence and pauses automatically when the tab is hidden — instead of the
|
||||
* old fixed 1ms interval that fired ~1000 times a second.
|
||||
*/
|
||||
tick() {
|
||||
this.time = Date.now() - this.startTime
|
||||
this.render()
|
||||
if (this.running) {
|
||||
this.id = window.requestAnimationFrame(() => this.tick())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to the DOM only when the visible value actually changes. The display
|
||||
* has 100ms (tenths-of-a-second) resolution, so most frames are a no-op and
|
||||
* cost no reflow.
|
||||
*/
|
||||
render() {
|
||||
if (!this.display) return
|
||||
const text = this.pretty(this.time) || '0'
|
||||
if (text !== this.rendered) {
|
||||
this.display.innerHTML = text
|
||||
this.rendered = text
|
||||
}
|
||||
}
|
||||
|
||||
pretty(duration) {
|
||||
if (!duration) return undefined
|
||||
var milliseconds = parseInt((duration % 1000) / 100),
|
||||
seconds = Math.floor((duration / 1000) % 60),
|
||||
minutes = Math.floor((duration / (1000 * 60)) % 60),
|
||||
hours = Math.floor((duration / (1000 * 60 * 60)) % 24)
|
||||
|
||||
hours = (hours < 10) ? `0${hours}` : hours
|
||||
minutes = (minutes < 10) ? `0${minutes}` : minutes
|
||||
seconds = (seconds < 10) ? `0${seconds}` : seconds
|
||||
|
||||
return `${this.clean(hours, ':')}${this.clean(minutes, ':')}${this.clean(seconds, '.')}${this.clean(milliseconds, '')}`
|
||||
}
|
||||
|
||||
clean(str, separator) {
|
||||
return (str === '00') ? '' : `${str}${separator}`
|
||||
}
|
||||
}
|
||||
22
vite.config.js
Normal file
22
vite.config.js
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config'
|
|||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['test/**/*.test.js']
|
||||
include: ['test/**/*.test.js', 'scripts/test/**/*.test.js']
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue