Adds scripts/check-content.mjs, a zero-dependency scanner for commits, branch names, PR text, and contributed lines. It checks structural rules (attribution trailers and footers, session links) and a maintainer-managed reserved-terms list, shipped as salted digests in .repo-policy.json. Findings report a location and a masked preview, never the matched term. Wired up opt-in: the pre-commit hook scans the staged diff and branch name, a new commit-msg hook scans the message, and the Checks workflow gates PRs and pushes to main. The digest list ships empty; structural checks run regardless.
13 KiB
AGENTS.md
Guidance for AI coding agents working in this repository.
What this is
Classic Minesweeper as a vanilla web game — no framework, no TypeScript (JSDoc + // @ts-check only). Deployed at mnswpr.com (Netlify) and published to npm as @cozy-games/mnswpr. The game engine has zero runtime dependencies; only the website adds Firebase.
mnswpr is the main test app. It's the reference app for the monorepo and the default target for local runs — .claude/launch.json launches it (dev on :5173, preview on :4173), and it's what you should build/run/preview when verifying changes to the shared packages or tooling.
Commands
Workspace-wide commands run from the root; per-app commands target the app by name with pnpm's -F filter (apps are named <name> — there are no mnswpr-specific root scripts).
pnpm i # install (pnpm is required — this is a pnpm workspace)
pnpm test # run the Vitest suite once (jsdom)
pnpm test:watch # run Vitest in watch mode
pnpm lint # eslint . (JS + CSS); runs automatically on pre-commit
pnpm lint:fix # eslint --fix
pnpm build:lib # build the publishable library -> packages/mnswpr/dist
pnpm -F mnswpr run dev # Firestore emulator + auto-seed + dev server (emulators:exec) — most common; needs JDK 21+
pnpm -F mnswpr run dev:no-db # plain vite, no emulator (UI-only work / no JDK)
pnpm -F mnswpr run build # build the website -> apps/mnswpr/dist
pnpm -F mnswpr run build:preview # build the app and serve the production preview
Infra (local CLI only — no web dashboards)
Every infra operation — provision, deploy hosting, deploy DB, manage env — is doable from the CLI, and every configuration/schema is codified in-repo. Nothing lives only in a web dashboard. There are two distinct layers, both owned by the app:
1. App infra config — declarative, committed, deployed state. These files ARE the source of truth; deploying just pushes them up. For mnswpr, all under apps/mnswpr/:
| File | Codifies |
|---|---|
firebase.json |
Firestore + emulator wiring (rules/indexes paths, emulator ports) |
.firebaserc |
Firebase project aliases (default/prod/dev) |
firestore.rules |
Firestore security rules (server-side access control) |
firestore.indexes.json |
Firestore indexes (none needed — documented inline) |
netlify.toml |
Netlify hosting: build command, publish dir, redirects, headers, build env |
.env.example |
The full env-var contract; real prod values are set as Netlify env vars via CLI, never committed |
2. App infra tools — the CLIs that act on that config. They are versioned devDependencies of the app (not npx-on-demand, not global installs), so pnpm install pins them and every machine gets the same version. mnswpr depends on firebase-tools and netlify-cli; its scripts call the firebase/netlify binaries directly (pnpm puts the app's node_modules/.bin on PATH). A future app using a different stack (Postgres, a different host) declares its CLIs as its devDependencies and backs the same generic script names — so pnpm -F <name> run deploy:db stays uniform.
Each app owns its infra scripts in its own package.json under generic, tech-agnostic names (deploy:db, not deploy:firestore) — run them by targeting the app with pnpm's -F filter (no root wrapper scripts):
pnpm -F mnswpr run db:start # local DB emulator (mnswpr -> Firestore), standalone
pnpm -F mnswpr run db:seed # seed the running local emulator
pnpm -F mnswpr run db:stop # kill a stray/orphaned Firestore emulator holding :8080
pnpm -F mnswpr run deploy:db # deploy DB rules/indexes (-> firebase deploy --only firestore)
pnpm -F mnswpr run deploy:site # build + deploy hosting (-> netlify deploy --prod --dir=dist)
One-time per app / per machine (all CLI, no dashboard):
pnpm -F mnswpr exec firebase login # auth the Firebase CLI
pnpm -F mnswpr exec netlify login # auth the Netlify CLI
pnpm -F mnswpr exec netlify link # bind the app dir to its Netlify site (writes .netlify/, gitignored)
Managing hosting env vars via CLI (keeps prod Firebase keys + VITE_LB_NAMESPACE=mw out of git while still reproducible):
pnpm -F mnswpr exec netlify env:set VITE_LB_NAMESPACE mw # set one var
pnpm -F mnswpr exec netlify env:import .env.production # bulk-import from a local (gitignored) env file
pnpm -F mnswpr exec netlify env:list # inspect what's set
Non-npm tools get a setup script instead of a devDependency. The Firestore emulator needs Java (it's a JVM program), which isn't an npm package — so pnpm install runs a root postinstall (scripts/ensure-java.mjs) that installs a user-local Temurin JRE 21 into ~/.local without sudo when java is missing — idempotent, non-fatal, and auto-skipped on CI / SKIP_JRE_SETUP / unsupported platforms. Any future infra tool that isn't on npm follows the same pattern (a checked-in setup script), never a manual install step.
Tests are co-located with the package they exercise (packages/utils/test/, packages/mnswpr/test/) and run under Vitest with a jsdom environment (root config in vitest.config.js). They cover the shared utils and drive the engine through real DOM events (mount #app, dispatch mouse events, assert on cell/grid attributes). For anything visual or input-timing related, also verify by running pnpm -F mnswpr run dev and playing.
Node version: .nvmrc pins lts/*.
Repository layout (Cozy Games monorepo, pnpm workspace)
This is the Cozy Games monorepo. Workspaces are declared in pnpm-workspace.yaml as apps/*, packages/*, and sites/*. utils/ is now a real workspace package (@cozy-games/utils), imported by name — no more ../utils relative paths.
apps/mnswpr/— packagemnswpr,@cozy-games/mnswpr's host, the mnswpr.com website. Consumes the engine and leaderboard viaworkspace:*(import mnswpr from '@cozy-games/mnswpr/mnswpr.js') and wires them together inapps/mnswpr/main.js. Owns its Firebase config (firebase.json,firestore.rules,.firebaserc) and app-specific scripts (apps/mnswpr/scripts/). A future app (e.g. sudoku) gets its ownapps/<name>/and itspackage.jsonnameis just the app name (<name>, unscoped) so it's addressable directly by name (pnpm -F <name> run <script>).packages/mnswpr/—@cozy-games/mnswpr, the standalone, framework-free game engine published to npm.packages/mnswpr/mnswpr.jsis the whole engine;levels.jsdefines the four difficulty presets. Depends only on@cozy-games/utils.packages/leaderboard/—@cozy-games/leaderboard, a backend-agnostic, time-windowed leaderboard (adapter-injected storage).packages/utils/—@cozy-games/utils, shared services with no dependencies, re-exported fromindex.js:StorageService,TimerService(pretty()time formatting used by both engine and leaderboard),LoggerService,LoadingService, and date-bucket helpers.sites/— docs (Astro Starlight) and UI demos. Placeholders for now.
Architecture
The engine is decoupled from the app via two hooks. Minesweeper(appId, version, hooks) (packages/mnswpr/mnswpr.js) is a classic constructor function that imperatively builds a <table> grid in the DOM. It knows nothing about Firebase or leaderboards. The app injects behavior through:
hooks.levelChanged(setting)— fired when the difficulty level changes; the app uses this to re-fetch and render the leaderboard for that level.hooks.gameDone(game)— fired when a game ends (win or loss) with agameobject (time,status,level,time_stamp,isMobile); the app uses this to submit the score.
When adding engine features that the website needs to react to, prefer adding a new hook over reaching into the app — that separation is what keeps the library publishable on its own. (There are already TODO markers in the engine for an afterGridGenerated hook.)
Game state lives in DOM attributes, not a JS model. The grid's overall state is the game-status attribute on the <table> (inactive → active → over/win → done). Each cell carries data-status (default, highlighted, flagged, clicked, empty) and data-value (adjacent mine count). Mine positions are the one exception: kept in minesArray as [row, col] pairs. When changing game logic, read/write these attributes consistently — helpers like getStatus/setStatus, isMine, isFlagged are the intended accessors.
First-click safety: the first clicked cell is never a mine — if it is, transferMine() relocates it to a non-neighboring empty cell before revealing.
Input handling is intricate. Mouse (left/right/middle, plus simultaneous left+right "chording") and touch (long-press to flag) are handled through a state machine of flags (isLeft, isRight, pressed, bothPressed, skip, isBusy) in initializeEventHandlers/initializeTouchEventHandlers. isBusy debounces input (MOBILE_BUSY_DELAY/PC_BUSY_DELAY). Tread carefully here — small changes easily break chording or mobile flagging.
Test mode: set TEST_MODE = true at the top of packages/mnswpr/mnswpr.js to render mine positions as visual hints and enable debug logging.
Leaderboard / Firebase (apps/mnswpr/modules/)
LeaderBoardService (leader-board.js) reads/writes Firestore (firebase/firestore/lite). Structure: top-10 per level in mw-leaders/{level}/games, all sessions in mw-all/{browserId}/games, and remote runtime configuration in mw-config. A score is only offered to the leaderboard when it beats the current 10th place and matches the server-side passingStatus.
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.
UserService (user.js) derives a non-cryptographic browserId fingerprint from navigator/screen properties to attribute scores without accounts.
Conventions
- Code style is enforced by ESLint Stylistic, not Prettier: 2-space indent, single quotes, no semicolons, no trailing commas, spaces inside
{ braces }but not[brackets]. Runpnpm lint:fixbefore committing. Both**/*.jsand**/*.cssare linted (CSS via@eslint/css). - The engine uses plain functions and
var/letclosures, not classes;packages/utils/andapps/mnswpr/modules/use ES classes. 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 + theChecksworkflow) against.repo-policy.json. Write commit messages in plain project voice; no tool-attribution trailers or footers, noCo-Authored-By:line for anyone outside the policy'sallowedCoAuthors, 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. - Types are generated, not authored. Source stays JS + JSDoc (
// @ts-check);tscis a build-time tool that emits.d.tsfrom that JSDoc so published@cozy-games/*packages ship types. Declarations are emitted co-located next to each source file and committed —pnpm build:types(scripts/build-types.mjs) deletes the previous ones and re-runstsc -p tsconfig.types.json, since TypeScript won't emit over an existing.d.ts(TS5055). The type-check runsstrict: falseand covers only the files named in that config'sincludelist — adding a new published source file means adding it there, or it ships without types. After touching JSDoc on an included file, runpnpm build:typesand commit the regenerated declarations.
Release & git hooks (maintainer workflow)
- Husky hooks:
pre-commitrunspnpm lint, the secret scan, and the content check (staged diff + branch name);commit-msgruns the content check over the message;post-commitauto-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, runsbumpp(version bump + tag) inpackages/mnswpr/, thenscripts/release.jsforce-pushes areleasebranch and tags to remotesgh/sh. Pushing av*tag triggers.github/workflows/release.yml(changelogithub) to publish GitHub release notes. Only run this when explicitly releasing.