- Node ≥ 20 (built on 22).
- pnpm (the only supported package manager here).
Install everything with pnpm and do not hand-pin versions — install latest and let pnpm resolve. pnpm's build-script approvals live in
pnpm-workspace.yaml(allowBuilds/onlyBuiltDependenciesforesbuild,sharp,unrs-resolver).
pnpm dev # Next dev server (Turbopack) → http://localhost:3000
pnpm build # production build
pnpm start # serve the production build
pnpm lint # biome lint (must be clean — 0 errors)
pnpm lint:fix # biome — apply safe + unsafe fixes
pnpm format # biome — format + safe fixes, in place
pnpm typecheck # tsc --noEmit
pnpm test # AVA — the engine test suite
pnpm test:all # scripts/test.sh — format, types, lint, AVA, knip, audit
pnpm sim # difficulty simulation (scripts/sim.ts) — see belowBefore considering a change done: pnpm test:all should pass (it runs the format
check, typecheck, lint, AVA suite, knip unused-code check, and dependency audit), and
for anything structural, pnpm build.
pnpm sim plays full sit-and-go tournaments with a proxy human against each
venue's real AI (real engine, blinds, escalation) and reports win rates — the
tool for tuning any AiProfile or pacing knob in config/venues.ts. Change a
knob, re-run, compare against the "fair" column (1/seats). Deterministic per seed.
pnpm sim # kitchen + the ladder (competent hero, n=200)
pnpm sim garage pub # specific venues; also: ladder | side | all
pnpm sim garage --n 500 # more tournaments = tighter estimate (slower)
pnpm sim --hero casual # beginner | casual | competent | best
pnpm sim garage --skill 0.4 # trial an AI skill without editing configIt's Monte-Carlo-heavy — a venue takes ~2–4 minutes at n=50; high venues
(more iterations) take longer. n=50 has roughly a ±7pp margin; use n≥200
for numbers you'll quote.
Tests are AVA, not Vitest. Config in ava.config.js runs TypeScript via tsx:
export default {
extensions: ['ts'],
nodeArguments: ['--import=tsx'],
workerThreads: false, // so the tsx loader applies to test files
files: ['tests/**/*.test.ts'],
}- The package is ESM (
"type": "module"), which is required for thetsxloader to resolve a single AVA instance — don't remove it. - Only the pure engine is unit-tested (
tests/{cards,handEval,pots,engine,equity,ai}.test.ts). UI is not unit-tested; verify it by running the app. tests/helpers.ts→makeDeck(popOrder)builds a deck whosepop()order is exactlypopOrder, for deterministic scenarios.- The AI/equity tests are Monte-Carlo and take a few seconds; a whole-run timeout under
heavy load can flake — re-run
pnpm testif you see "pending after a timeout".
Add a test for any rules/AI change. Favor invariants (chip conservation, legality) alongside specific scenarios.
- Colours: use theme tokens, never hardcoded
white/black. See design.md. The one deliberate exception isQrCode.tsx(fixed white card for camera scannability) — see data-and-offline.md. set-state-in-effect: no synchronoussetStateinsideuseEffect(a React 19 rule; enforced by convention here — Biome has no equivalent). Patterns used instead:- client-only gate →
useHydrated()(useSyncExternalStore). - "seed a form when a dialog opens" → mount the form only while open (
{open && <Form/>}) and initialize itsuseStatefrom the store (seeProfileDialog/SettingsDialog).
- client-only gate →
- pokersolver is CommonJS: import the default and destructure (
import pkg from 'pokersolver'; const { Hand } = pkg), with types insrc/types/pokersolver.d.ts. - No
Date.now()/Math.random()in engine logic that must be deterministic — pass a seededRng. (They're fine in UI/store code like avatar seeds.) - Persisted profile: changing its shape → bump
PERSIST_VERSIONand add amigratebranch instore/profile.ts. The profile is portable (file / code / QR) via one shared envelope — see data-and-offline.md. - Turbopack root is pinned in
next.config.tssopnpm-workspace.yamlisn't mistaken for a monorepo root. - Images: venue art uses plain
<img>(static art;next/imageadds little for these) — Biome'snoImgElementis off repo-wide. Avatars are inline SVG data URIs.
pnpm add <pkg> # runtime
pnpm add -D <pkg> # devIf pnpm reports ignored build scripts, add the package to onlyBuiltDependencies /
allowBuilds in pnpm-workspace.yaml and re-run pnpm install.
Cloudflare Pages, pure static. next.config.ts sets output: 'export', so
pnpm build writes the whole site to out/ as plain files — no server, no env
vars, no secrets at runtime. Every route prerenders: /play/[venue] enumerates
its paths via generateStaticParams (all venues are known config), and
app/manifest.ts opts in with dynamic = 'force-static'.
Pushes to main deploy automatically via
.github/workflows/deploy-cloudflare-pages.yaml: the full test:all gate runs
first, then wrangler pages deploy out publishes. The workflow needs two repo
secrets — CLOUDFLARE_API_TOKEN (a token with the Cloudflare Pages — Edit
permission) and CLOUDFLARE_ACCOUNT_ID — and a Pages project named pip-web.
Production domain: playpip.io.
The same workflow cuts a release on every push to main, after a green gate +
successful deploy:
- Version auto-bumps — patch by default; put
#minoror#majorin the commit subject (first line only — the body is ignored, so prose mentioning the tokens can't trigger a bump) to bump harder (#majorwins if both appear). - Every subject since the last release tag is read, not just the tip and not just the pushed range. The workflow's concurrency group cancels in-progress runs, so a burst of merges leaves only the last run alive — anchoring on the tag means the surviving run still picks up the markers from the merges whose runs were killed. It is the same range the release notes are generated from.
- The bump is committed before the build, so the deployed PWA reports the new version and its build id is the release commit.
- Then it tags
vX.Y.Z, pushes it, and publishes a GitHub Release with auto-generated notes (diffed from the previous release).
Notes:
- No infinite loop, but
[skip ci]on the release commit is load-bearing: the commit is pushed with a GitHub App token (it has to clear the ruleset onmain), and App pushes do retrigger workflows. Without the marker the deploy re-runs itself on its own release commit, forever. The workflow declarespermissions: contents: writeand checks out withfetch-depth: 0(needed to push, to read the last tag, and to diff release notes). - A manual
workflow_dispatchrun redeploys the current version — no bump, no release (the release steps are gated topushevents). - This pushes directly to
main, which a ruleset otherwise blocks.GITHUB_TOKENcannot be a bypass actor, so the release commit is pushed as a dedicated GitHub App whose credentials live only in this repo's secrets. It is deliberately not the app any other automation uses — sharing it would hand that automation a bypass too.
Two values are injected at build (next.config.ts → env) and inlined into the
client bundle:
NEXT_PUBLIC_APP_VERSION— the human version, read frompackage.json. Shown in the Settings footer (Pip v0.1.0 · <build id>).NEXT_PUBLIC_BUILD_ID— the git short SHA. Uniquely identifies each deploy.
The service worker's cache name is pip-__BUILD_ID__; scripts/stamp-sw.mjs (run
by pnpm build, after next build) stamps the git short SHA into out/sw.js. So
every deploy ships a byte-different sw.js → the browser installs a new worker
→ activate purges the old cache. That's the cache-bust.
On an update the new worker waits rather than taking over silently.
useServiceWorkerUpdate (registered from UpdatePrompt, mounted in the root
layout) detects the waiting worker — re-checking hourly and whenever the tab regains
focus — and shows a "new version is ready → Reload" nudge. Reload posts
SKIP_WAITING; the worker activates and the page reloads onto the new assets. See
data-and-offline.md for the
offline caching strategy itself.