From e3a4cf93e09c906097aeca1271103bada6682771 Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Thu, 2 Jul 2026 23:11:31 -0400 Subject: [PATCH 01/16] docs(spec): archive integrate-bot-service and finalize validation steps --- .../changes/archive/2026-07-03-integrate-bot-service/tasks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/archive/2026-07-03-integrate-bot-service/tasks.md b/openspec/changes/archive/2026-07-03-integrate-bot-service/tasks.md index d50cd94..67b7fe5 100644 --- a/openspec/changes/archive/2026-07-03-integrate-bot-service/tasks.md +++ b/openspec/changes/archive/2026-07-03-integrate-bot-service/tasks.md @@ -49,9 +49,9 @@ - [x] 7.1 `apps/bots/fly.toml`: add `[http_service]` on the worker's port with `min_machines_running = 1` and auto-stop disabled; update the Dockerfile header comment (no build-shape change — the tsup/3-stage pipeline already exists). - [x] 7.2 `.github/workflows/deploy.yml`: restore the `@meldrank/bots-worker → bots` matrix mapping and add `bots` to the deploy-all fallback; update the header comment that documents the exclusion. - [x] 7.3 Set Fly secrets before merge: `INTERNAL_BOTS_SECRET` on `meldrank-match` + `meldrank-bots`; `BOTS_INTERNAL_URL`, `MATCH_REALTIME_URL` on `meldrank-match`; remove stale bots DB/Redis secrets (coordinate with Jason — secrets are set via `fly secrets set`, not CI). -- [ ] 7.4 After merge/deploy: verify a prod quick-play bot match end-to-end and a disconnect-takeover via logs; confirm the bots machine stays warm and recruit latency is sane. +- [x] 7.4 After merge/deploy: verify a prod quick-play bot match end-to-end and a disconnect-takeover via logs; confirm the bots machine stays warm and recruit latency is sane. ## 8. Docs and spec sync - [x] 8.1 Update `docs/audit/00-overview.md` item 9 to DONE with this change's name; note the CD-exclusion reversal. -- [ ] 8.2 Run `openspec validate integrate-bot-service --strict`; archive the change (syncing the seven delta specs and two new capabilities into `openspec/specs/`) once implementation is verified. +- [x] 8.2 Run `openspec validate integrate-bot-service --strict`; archive the change (syncing the seven delta specs and two new capabilities into `openspec/specs/`) once implementation is verified. From bce7a65eb953257b647671d69e347dd49066d6f1 Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Thu, 2 Jul 2026 23:33:34 -0400 Subject: [PATCH 02/16] chore(root): add pnpm catalog, drop no-op package build scripts Single-source the 7 hand-duplicated tool versions (typescript, eslint, vitest, @types/node, tsx, tsup, drizzle-orm) via a pnpm workspace catalog in pnpm-workspace.yaml; drizzle-orm had already drifted across three manifests (^0.45.2 vs a bare pin). Also remove the no-op "build": "tsc --noEmit" script from the four internal packages (bots, engine, fairness, shared) so `turbo run build` only runs the apps' real builds and stops double type-checking every package in CI. --- apps/api/package.json | 14 ++--- apps/bots/package.json | 12 ++-- apps/match/package.json | 14 ++--- apps/web/package.json | 6 +- package.json | 8 +-- packages/bots/package.json | 9 ++- packages/engine/package.json | 9 ++- packages/fairness/package.json | 9 ++- packages/shared/package.json | 11 ++-- pnpm-lock.yaml | 112 ++++++++++++++++++++------------- pnpm-workspace.yaml | 12 ++++ 11 files changed, 124 insertions(+), 92 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index f1d3072..56a6a7d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,18 +19,18 @@ "@noble/hashes": "^2.0.0", "@trpc/server": "^11.18.0", "@upstash/redis": "^1.38.0", - "drizzle-orm": "0.45.2", + "drizzle-orm": "catalog:", "pino": "^10.3.1", "svix": "^1.96.1", "zod": "^4.4.3" }, "devDependencies": { "@meldrank/engine": "workspace:*", - "@types/node": "^22.20.0", - "eslint": "^10.5.0", - "tsup": "^8.5.1", - "tsx": "^4.22.4", - "typescript": "^6.0.3", - "vitest": "^4.1.9" + "@types/node": "catalog:", + "eslint": "catalog:", + "tsup": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/apps/bots/package.json b/apps/bots/package.json index 2ba0bbb..11a5136 100644 --- a/apps/bots/package.json +++ b/apps/bots/package.json @@ -20,11 +20,11 @@ "zod": "^4.4.3" }, "devDependencies": { - "@types/node": "^22.20.0", - "eslint": "^10.5.0", - "tsup": "^8.5.1", - "tsx": "^4.22.4", - "typescript": "^6.0.3", - "vitest": "^4.1.9" + "@types/node": "catalog:", + "eslint": "catalog:", + "tsup": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/apps/match/package.json b/apps/match/package.json index 31b7a2e..4f17089 100644 --- a/apps/match/package.json +++ b/apps/match/package.json @@ -20,17 +20,17 @@ "@noble/hashes": "^2.0.0", "@upstash/redis": "^1.38.0", "colyseus": "^0.17.10", - "drizzle-orm": "^0.45.2", + "drizzle-orm": "catalog:", "pino": "^10.3.1", "zod": "^4.4.3" }, "devDependencies": { "@meldrank/bots": "workspace:*", - "@types/node": "^22.20.0", - "eslint": "^10.5.0", - "tsup": "^8.5.1", - "tsx": "^4.22.4", - "typescript": "^6.0.3", - "vitest": "^4.1.9" + "@types/node": "catalog:", + "eslint": "catalog:", + "tsup": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/apps/web/package.json b/apps/web/package.json index 6fdb203..86a7fef 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,13 +37,13 @@ "@meldrank/api": "workspace:*", "@tailwindcss/postcss": "^4.3.1", "@tanstack/react-query-devtools": "^5.101.2", - "@types/node": "^22.20.0", + "@types/node": "catalog:", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "concurrently": "^10.0.3", - "eslint": "^10.5.0", + "eslint": "catalog:", "tailwindcss": "^4.3.1", "tw-animate-css": "^1.4.0", - "typescript": "^6.0.3" + "typescript": "catalog:" } } diff --git a/package.json b/package.json index b1b4e27..c40dd6f 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,10 @@ "@meldrank/shared": "workspace:*", "@next/eslint-plugin-next": "^16.2.9", "@tanstack/eslint-plugin-query": "^5.101.2", - "@types/node": "^22.20.0", + "@types/node": "catalog:", "@vitest/eslint-plugin": "^1.6.20", "drizzle-kit": "^0.31.10", - "eslint": "^10.5.0", + "eslint": "catalog:", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-better-tailwindcss": "^4.6.0", @@ -40,9 +40,9 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "prettier": "^3.8.4", - "tsx": "^4.22.4", + "tsx": "catalog:", "turbo": "^2.10.0", - "typescript": "^6.0.3", + "typescript": "catalog:", "typescript-eslint": "^8.62.0" } } diff --git a/packages/bots/package.json b/packages/bots/package.json index 84c9b71..6a21785 100644 --- a/packages/bots/package.json +++ b/packages/bots/package.json @@ -12,7 +12,6 @@ "main": "./src/index.ts", "types": "./src/index.ts", "scripts": { - "build": "tsc --noEmit -p tsconfig.json", "lint": "eslint .", "test": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.json" @@ -22,9 +21,9 @@ "@meldrank/shared": "workspace:*" }, "devDependencies": { - "@types/node": "^22.20.0", - "eslint": "^10.5.0", - "typescript": "^6.0.3", - "vitest": "^4.1.9" + "@types/node": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/engine/package.json b/packages/engine/package.json index 37f0c81..3ff61ad 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -12,7 +12,6 @@ "main": "./src/index.ts", "types": "./src/index.ts", "scripts": { - "build": "tsc --noEmit -p tsconfig.json", "lint": "eslint .", "test": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.json" @@ -21,9 +20,9 @@ "@meldrank/shared": "workspace:*" }, "devDependencies": { - "@types/node": "^22.20.0", - "eslint": "^10.5.0", - "typescript": "^6.0.3", - "vitest": "^4.1.9" + "@types/node": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/fairness/package.json b/packages/fairness/package.json index f8bd839..75938ee 100644 --- a/packages/fairness/package.json +++ b/packages/fairness/package.json @@ -12,7 +12,6 @@ "main": "./src/index.ts", "types": "./src/index.ts", "scripts": { - "build": "tsc --noEmit -p tsconfig.json", "lint": "eslint .", "test": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.json" @@ -24,9 +23,9 @@ "zod": "^4.4.3" }, "devDependencies": { - "@types/node": "^22.20.0", - "eslint": "^10.5.0", - "typescript": "^6.0.3", - "vitest": "^4.1.9" + "@types/node": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/shared/package.json b/packages/shared/package.json index 9c624c3..5664c50 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -20,7 +20,6 @@ "main": "./src/index.ts", "types": "./src/index.ts", "scripts": { - "build": "tsc --noEmit -p tsconfig.json", "lint": "eslint .", "test": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.json" @@ -29,15 +28,15 @@ "@neondatabase/serverless": "^1.1.0", "@noble/hashes": "^2.0.0", "@upstash/redis": "^1.38.0", - "drizzle-orm": "^0.45.2", + "drizzle-orm": "catalog:", "pino": "^10.3.1", "zod": "^4.4.3" }, "devDependencies": { - "@types/node": "^22.20.0", - "eslint": "^10.5.0", + "@types/node": "catalog:", + "eslint": "catalog:", "pino-pretty": "^13.1.3", - "typescript": "^6.0.3", - "vitest": "^4.1.9" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eadcf83..d0f7c2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,30 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + '@types/node': + specifier: ^22.20.0 + version: 22.20.0 + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2 + eslint: + specifier: ^10.5.0 + version: 10.5.0 + tsup: + specifier: ^8.5.1 + version: 8.5.1 + tsx: + specifier: ^4.22.4 + version: 4.22.4 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9 + overrides: postcss@<8.5.10: '>=8.5.10' uuid@<11.1.1: '>=11.1.1' @@ -26,7 +50,7 @@ importers: specifier: ^5.101.2 version: 5.101.2(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@types/node': - specifier: ^22.20.0 + specifier: 'catalog:' version: 22.20.0 '@vitest/eslint-plugin': specifier: ^1.6.20 @@ -35,7 +59,7 @@ importers: specifier: ^0.31.10 version: 0.31.10 eslint: - specifier: ^10.5.0 + specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) eslint-config-prettier: specifier: ^10.1.8 @@ -68,13 +92,13 @@ importers: specifier: ^3.8.4 version: 3.8.4 tsx: - specifier: ^4.22.4 + specifier: 'catalog:' version: 4.22.4 turbo: specifier: ^2.10.0 version: 2.10.0 typescript: - specifier: ^6.0.3 + specifier: 'catalog:' version: 6.0.3 typescript-eslint: specifier: ^8.62.0 @@ -104,7 +128,7 @@ importers: specifier: ^1.38.0 version: 1.38.0 drizzle-orm: - specifier: 0.45.2 + specifier: 'catalog:' version: 0.45.2(@neondatabase/serverless@1.1.0)(@upstash/redis@1.38.0) pino: specifier: ^10.3.1 @@ -120,22 +144,22 @@ importers: specifier: workspace:* version: link:../../packages/engine '@types/node': - specifier: ^22.20.0 + specifier: 'catalog:' version: 22.20.0 eslint: - specifier: ^10.5.0 + specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) tsup: - specifier: ^8.5.1 + specifier: 'catalog:' version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@6.0.3) tsx: - specifier: ^4.22.4 + specifier: 'catalog:' version: 4.22.4 typescript: - specifier: ^6.0.3 + specifier: 'catalog:' version: 6.0.3 vitest: - specifier: ^4.1.9 + specifier: 'catalog:' version: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.4)) apps/bots: @@ -160,22 +184,22 @@ importers: version: 4.4.3 devDependencies: '@types/node': - specifier: ^22.20.0 + specifier: 'catalog:' version: 22.20.0 eslint: - specifier: ^10.5.0 + specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) tsup: - specifier: ^8.5.1 + specifier: 'catalog:' version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@6.0.3) tsx: - specifier: ^4.22.4 + specifier: 'catalog:' version: 4.22.4 typescript: - specifier: ^6.0.3 + specifier: 'catalog:' version: 6.0.3 vitest: - specifier: ^4.1.9 + specifier: 'catalog:' version: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) apps/match: @@ -205,7 +229,7 @@ importers: specifier: ^0.17.10 version: 0.17.10(@colyseus/auth@0.17.9(@colyseus/core@0.17.44)(express@5.2.1))(@colyseus/better-call@1.3.3(zod@4.4.3))(@colyseus/core@0.17.44)(@colyseus/redis-driver@0.17.7(@colyseus/better-call@1.3.3(zod@4.4.3))(@colyseus/schema@4.0.27(typescript@6.0.3))(@colyseus/ws-transport@0.17.13)(@pm2/io@6.1.0)(express@5.2.1)(zod@4.4.3))(@colyseus/redis-presence@0.17.7(@colyseus/better-call@1.3.3(zod@4.4.3))(@colyseus/schema@4.0.27(typescript@6.0.3))(@colyseus/ws-transport@0.17.13)(@pm2/io@6.1.0)(express@5.2.1)(zod@4.4.3))(@colyseus/schema@4.0.27(typescript@6.0.3))(@colyseus/uwebsockets-transport@0.17.20(@colyseus/core@0.17.44))(@colyseus/ws-transport@0.17.13)(@pm2/io@6.1.0)(express@5.2.1)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))(zod@4.4.3) drizzle-orm: - specifier: ^0.45.2 + specifier: 'catalog:' version: 0.45.2(@neondatabase/serverless@1.1.0)(@upstash/redis@1.38.0) pino: specifier: ^10.3.1 @@ -218,22 +242,22 @@ importers: specifier: workspace:* version: link:../../packages/bots '@types/node': - specifier: ^22.20.0 + specifier: 'catalog:' version: 22.20.0 eslint: - specifier: ^10.5.0 + specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) tsup: - specifier: ^8.5.1 + specifier: 'catalog:' version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@6.0.3) tsx: - specifier: ^4.22.4 + specifier: 'catalog:' version: 4.22.4 typescript: - specifier: ^6.0.3 + specifier: 'catalog:' version: 6.0.3 vitest: - specifier: ^4.1.9 + specifier: 'catalog:' version: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) apps/web: @@ -309,7 +333,7 @@ importers: specifier: ^5.101.2 version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7) '@types/node': - specifier: ^22.20.0 + specifier: 'catalog:' version: 22.20.0 '@types/react': specifier: ^19.2.17 @@ -321,7 +345,7 @@ importers: specifier: ^10.0.3 version: 10.0.3 eslint: - specifier: ^10.5.0 + specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) tailwindcss: specifier: ^4.3.1 @@ -330,7 +354,7 @@ importers: specifier: ^1.4.0 version: 1.4.0 typescript: - specifier: ^6.0.3 + specifier: 'catalog:' version: 6.0.3 packages/bots: @@ -343,16 +367,16 @@ importers: version: link:../shared devDependencies: '@types/node': - specifier: ^22.20.0 + specifier: 'catalog:' version: 22.20.0 eslint: - specifier: ^10.5.0 + specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) typescript: - specifier: ^6.0.3 + specifier: 'catalog:' version: 6.0.3 vitest: - specifier: ^4.1.9 + specifier: 'catalog:' version: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) packages/engine: @@ -362,16 +386,16 @@ importers: version: link:../shared devDependencies: '@types/node': - specifier: ^22.20.0 + specifier: 'catalog:' version: 22.20.0 eslint: - specifier: ^10.5.0 + specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) typescript: - specifier: ^6.0.3 + specifier: 'catalog:' version: 6.0.3 vitest: - specifier: ^4.1.9 + specifier: 'catalog:' version: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) packages/fairness: @@ -390,16 +414,16 @@ importers: version: 4.4.3 devDependencies: '@types/node': - specifier: ^22.20.0 + specifier: 'catalog:' version: 22.20.0 eslint: - specifier: ^10.5.0 + specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) typescript: - specifier: ^6.0.3 + specifier: 'catalog:' version: 6.0.3 vitest: - specifier: ^4.1.9 + specifier: 'catalog:' version: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) packages/shared: @@ -414,7 +438,7 @@ importers: specifier: ^1.38.0 version: 1.38.0 drizzle-orm: - specifier: ^0.45.2 + specifier: 'catalog:' version: 0.45.2(@neondatabase/serverless@1.1.0)(@upstash/redis@1.38.0) pino: specifier: ^10.3.1 @@ -424,19 +448,19 @@ importers: version: 4.4.3 devDependencies: '@types/node': - specifier: ^22.20.0 + specifier: 'catalog:' version: 22.20.0 eslint: - specifier: ^10.5.0 + specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) pino-pretty: specifier: ^13.1.3 version: 13.1.3 typescript: - specifier: ^6.0.3 + specifier: 'catalog:' version: 6.0.3 vitest: - specifier: ^4.1.9 + specifier: 'catalog:' version: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) packages: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ac4bdb2..43cd05b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,18 @@ packages: - 'packages/*' - 'apps/*' +# Single-sourced versions for the tooling every workspace repeats (audit item +# 17), plus drizzle-orm, which had drifted across three manifests. Reference +# with `"": "catalog:"` in package.json; bump versions only here. +catalog: + '@types/node': ^22.20.0 + drizzle-orm: ^0.45.2 + eslint: ^10.5.0 + tsup: ^8.5.1 + tsx: ^4.22.4 + typescript: ^6.0.3 + vitest: ^4.1.9 + # All four ship prebuilt platform binaries (esbuild/@img/sharp/etc. via # optionalDependencies), so their install scripts have always been skipped # here; `false` just records that decision instead of re-prompting. From 49816031ea4e2227769ef20dd5f868fe085443bb Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Thu, 2 Jul 2026 23:33:52 -0400 Subject: [PATCH 03/16] feat(shared): auto-load repo-root .env for local dev services Add loadLocalEnv() (packages/shared/src/server/env/local.ts) which hydrates process.env from the repo-root .env at boot: dev-only (no-op when NODE_ENV is production/test) and never overrides an already-set variable. api, match, and bots each call it before loading their own validated env, so a plain root `pnpm dev` boots the whole stack with no exported shell vars. Since one .env now feeds all three long-lived services at once, PORT must stay unset there or every service would bind the same port. The PORT schema treats an empty string as unset, falling through to each service's own default (api 3001, match 2567, bots 2568); a real PORT env var still overrides for a single process. --- .env.example | 8 +++-- apps/api/src/index.ts | 3 +- apps/bots/src/index.ts | 3 +- apps/match/src/index.ts | 7 ++-- infra/README.md | 11 +++++- packages/shared/src/server/env/local.ts | 46 ++++++++++++++++++++++++ packages/shared/src/server/env/schema.ts | 10 ++++-- packages/shared/src/server/index.ts | 1 + 8 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 packages/shared/src/server/env/local.ts diff --git a/.env.example b/.env.example index 49a2b7a..b3f0b14 100644 --- a/.env.example +++ b/.env.example @@ -18,8 +18,12 @@ NODE_ENV=development # fatal | error | warn | info | debug | trace. Unset → info in production, debug otherwise. LOG_LEVEL=debug -# Listen port for the long-lived services (api defaults to 3001, match to 2567). -PORT=3001 +# Listen port for the long-lived services. Leave EMPTY for local dev: this one +# file feeds api, match, and bots at once, so each service falls back to its own +# default (api 3001, match 2567, bots 2568) — a concrete value here would force +# all three onto the same port. Fly sets a real PORT per app in production; to +# override a single local process, set PORT as a real env var (it wins over .env). +PORT= # Neon Postgres connection string. DATABASE_URL=postgresql://user:password@host.neon.tech/dbname?sslmode=require diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7c87b15..c143214 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,5 +1,5 @@ import { createHTTPServer } from '@trpc/server/adapters/standalone'; -import { loadApiEnv } from '@meldrank/shared/server'; +import { loadApiEnv, loadLocalEnv } from '@meldrank/shared/server'; import { appRouter, type AppRouter } from './routers'; import { buildContext, createApiRuntime } from './context'; import { corsHeaders, CORS_PREFLIGHT_STATUS } from './cors'; @@ -21,6 +21,7 @@ import { createVerifyRouteHandler } from './internal/verify'; export { appRouter, type AppRouter }; if (process.env.NODE_ENV !== 'test') { + loadLocalEnv(); const env = loadApiEnv(); const { deps, db, redis } = createApiRuntime(env); const log = deps.log; diff --git a/apps/bots/src/index.ts b/apps/bots/src/index.ts index 68dd628..230d9c8 100644 --- a/apps/bots/src/index.ts +++ b/apps/bots/src/index.ts @@ -1,5 +1,5 @@ import { INTERNAL_PREWARM_PATH, INTERNAL_RECRUIT_PATH, INTERNAL_RELEASE_PATH, healthy } from '@meldrank/shared'; -import { createLogger, loadBotsEnv } from '@meldrank/shared/server'; +import { createLogger, loadBotsEnv, loadLocalEnv } from '@meldrank/shared/server'; import { createColyseusJoiner } from './client'; import { createRecruitmentServer } from './http'; import { SessionRegistry } from './session'; @@ -18,6 +18,7 @@ const DEFAULT_BOTS_PORT = 2568; function main(): void { // Validate the environment once at boot (fail-fast), then wire the registry // and the recruitment surface. + loadLocalEnv(); const env = loadBotsEnv(); const log = createLogger('bots', { level: env.LOG_LEVEL, pretty: env.NODE_ENV !== 'production' }); diff --git a/apps/match/src/index.ts b/apps/match/src/index.ts index a9dfce1..404abb9 100644 --- a/apps/match/src/index.ts +++ b/apps/match/src/index.ts @@ -1,6 +1,6 @@ import { Server, matchMaker } from 'colyseus'; import { healthy } from '@meldrank/shared'; -import { createDb, createLogger, createRedis, loadMatchEnv } from '@meldrank/shared/server'; +import { createDb, createLogger, createRedis, loadLocalEnv, loadMatchEnv } from '@meldrank/shared/server'; import { MatchRoom } from './colyseus/matchRoom'; import { createHttpBotRecruiter } from './recruit/client'; import { INTERNAL_SPAWN_PATH, mountSpawnRoute, type RouteRegistrar, type SpawnGatewayDeps } from './gateway/spawn'; @@ -29,7 +29,10 @@ function createGameServer(): Server { } // Validate the environment once at boot (fail-fast), then construct the foundation - // clients and the spawn gateway, and inject them into the room definition. + // clients and the spawn gateway, and inject them into the room definition. The + // explicit local-env load keeps match on the same contract as api/bots rather + // than leaning on Colyseus' incidental dotenv auto-load. + loadLocalEnv(); const env = loadMatchEnv(); const log = createLogger('match', { level: env.LOG_LEVEL, pretty: env.NODE_ENV !== 'production' }); const db = createDb(env); diff --git a/infra/README.md b/infra/README.md index 92bcc5e..f490416 100644 --- a/infra/README.md +++ b/infra/README.md @@ -204,7 +204,16 @@ fly secrets unset --app meldrank-match LOG_LEVEL # back to the default ## Verifying the foundation locally -- `cp .env.example .env` and fill in real values. +- `cp .env.example .env` and fill in real values. Leave `PORT=` **empty**: the + one root `.env` feeds api, match, and bots at once, so each service falls + back to its own default (api 3001, match 2567, bots 2568). The schema treats + an empty `PORT` as unset; a real `PORT` env var still overrides for a single + process. - `pnpm env:check` — asserts `.env.example` and the schema agree. - Each server app validates its environment at boot (`loadApiEnv` / `loadMatchEnv` / `loadBotsEnv`) and fails fast, naming every missing variable. +- The Node services hydrate `process.env` from the repo-root `.env` themselves + at boot via `loadLocalEnv()` (`@meldrank/shared/server`) — dev-only (no-op + when `NODE_ENV` is `production`/`test`), never overriding real env vars. A + plain `pnpm dev` from the root therefore works with no exported shell vars; + `apps/web` keeps its own `apps/web/.env.local` (Next.js convention). diff --git a/packages/shared/src/server/env/local.ts b/packages/shared/src/server/env/local.ts new file mode 100644 index 0000000..103fa48 --- /dev/null +++ b/packages/shared/src/server/env/local.ts @@ -0,0 +1,46 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { parseEnv as parseDotenv } from 'node:util'; + +/** + * Hydrate `process.env` from the repository-root `.env` for **local dev only**. + * + * The monorepo keeps one `.env` at the root (the committed contract is + * `.env.example`), but only Colyseus happened to auto-load it — `apps/api` and + * `apps/bots` saw a bare `process.env` and failed their fail-fast validation + * under a plain `pnpm dev`. Each service entry calls this once at boot, before + * its `load*Env()` loader. + * + * Semantics, chosen to stay out of the way everywhere else: + * - No-op when `NODE_ENV` is `production` or `test` — deployed processes and + * test runs read only real environment variables. + * - Never overrides: a variable already present in `process.env` always wins, + * matching Node's own `--env-file` behavior. + * - Values are copied verbatim; empty strings stay empty (the schemas decide + * what "empty" means — e.g. `PORT=` counts as unset). + * - Missing file is fine (e.g. bare CI checkouts) — it simply loads nothing. + */ +export function loadLocalEnv(): void { + const mode = process.env.NODE_ENV; + if (mode === 'production' || mode === 'test') return; + + const file = findRootEnvFile(process.cwd()); + if (file === undefined) return; + + const parsed = parseDotenv(readFileSync(file, 'utf8')); + for (const [key, value] of Object.entries(parsed)) { + process.env[key] ??= value; + } +} + +/** Walk up from `start` to the filesystem root looking for a `.env` file. */ +function findRootEnvFile(start: string): string | undefined { + let dir = start; + for (;;) { + const candidate = join(dir, '.env'); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} diff --git a/packages/shared/src/server/env/schema.ts b/packages/shared/src/server/env/schema.ts index 2f2ba57..da92788 100644 --- a/packages/shared/src/server/env/schema.ts +++ b/packages/shared/src/server/env/schema.ts @@ -33,9 +33,15 @@ const redisShape = { UPSTASH_REDIS_REST_TOKEN: z.string().min(1), } as const; -/** Optional listen port for the long-lived services. */ +/** + * Optional listen port for the long-lived services. An empty string counts as + * unset, falling through to each service's own default (api 3001, match 2567, + * bots 2568): the root `.env` feeds all three processes at once in local dev, + * so it ships `PORT=` blank — any concrete value there would force every + * service onto the same port. + */ const portShape = { - PORT: z.coerce.number().int().positive().optional(), + PORT: z.preprocess((value) => (value === '' ? undefined : value), z.coerce.number().int().positive().optional()), } as const; /** diff --git a/packages/shared/src/server/index.ts b/packages/shared/src/server/index.ts index 438e12e..39dde8c 100644 --- a/packages/shared/src/server/index.ts +++ b/packages/shared/src/server/index.ts @@ -9,6 +9,7 @@ export { EnvValidationError, parseEnv } from '../env/load'; export * from './env/schema'; export * from './env/load'; +export { loadLocalEnv } from './env/local'; export { allEnvKeys } from './env/keys'; export * from './db/client'; export * as dbSchema from './db/schema'; From 24677fc0ec7d92ecc441a05bf9e067c56dffd644 Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Thu, 2 Jul 2026 23:34:05 -0400 Subject: [PATCH 04/16] docs(audit): mark P2 hygiene items 16-18 done Records the direct commits (no OpenSpec change) for the turbo build/typecheck split, the pnpm catalog, and root .env auto-loading for local dev; also drops the stale "P1 complete except item 9" note from item 14 now that item 9 (bot-service integration) is archived. --- docs/audit/00-overview.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/audit/00-overview.md b/docs/audit/00-overview.md index d6ed5dc..1918d71 100644 --- a/docs/audit/00-overview.md +++ b/docs/audit/00-overview.md @@ -56,16 +56,16 @@ The AI-built codebase is in genuinely good shape architecturally: the workspace | 11 | ✅ DONE 2026-07-02 (change `fix-web-dependency-hygiene`) — base-ui migrated to `@base-ui/react`; postcss vuln fixed via pnpm override (stable Next 16.2.x hard-pins the vulnerable postcss, so no Next bump); `uuid` override added; web's `@meldrank/api` → devDependency. `elliptic` accepted-risk: no patched version exists upstream and the vulnerable path (`@colyseus/auth`) is never imported | web, root | S | | 12 | ✅ DONE 2026-07-02 — spawn-fetch timeout (change `harden-lobby-store-atomicity`: 10s `AbortSignal.timeout` default); `user.deleted` webhook (anonymize row + purge identity cache) and FK + unique indexes migration (change `harden-api-robustness`) | api, drizzle | S | | 13 | ✅ DONE 2026-07-02 (change `fix-web-client-robustness`) — Web fixes: first-load Clerk token race (context-bridged `getToken`, no `window.Clerk`), BidControls (auction-keyed remount, invalid-bid guard, aria-label), handoff-state cleanup (cleared on terminal states, recoverable `noSession`, dead `playerId` removed); the variant-lookup sub-item landed earlier in PR #11 `af5b6b7` (spawn snapshot authoritative in `matchRoom.onCreate`) | web, match | M | -| 14 | ✅ DONE 2026-07-02 (change `single-source-drifting-constants`) — reconnect grace (synced via room metadata), seat count derived, lifecycle/status enums and `BotDifficulty` shared-sourced, difficulty threaded through the spawn seam — **P1 complete except the item-9 bot-service integration** | shared + consumers | S | +| 14 | ✅ DONE 2026-07-02 (change `single-source-drifting-constants`) — reconnect grace (synced via room metadata), seat count derived, lifecycle/status enums and `BotDifficulty` shared-sourced, difficulty threaded through the spawn seam | shared + consumers | S | ### P2 — Hygiene & DX | # | Item | Area | Size | |---|---|---|---| | 15 | Web test foundation (vitest + testing-library; start with `use-table-connection` reconnect machine and table-store derivation — both pure) and a minimal e2e smoke (create table → play a bot hand) | web | L | -| 16 | Split `build` vs `typecheck` in turbo (stop double type-checking CI) | tooling | S | -| 17 | pnpm catalog for the 6 hand-duplicated tool versions | root | S | -| 18 | Root `.env` loading for local dev (`--env-file` or dotenv in dev scripts) | tooling | S | +| 16 | ✅ DONE 2026-07-02 (direct commit, no OpenSpec change — Jason's call) — no-op `build` scripts (`tsc --noEmit`) removed from the 4 packages; `turbo run build` now matches only the apps' real builds (tsup ×3 + next), so CI stops type-checking every package twice | tooling | S | +| 17 | ✅ DONE 2026-07-02 (direct commit) — pnpm `catalog:` in `pnpm-workspace.yaml` for the 6 duplicated tool versions (`typescript`, `eslint`, `vitest`, `@types/node`, `tsx`, `tsup`) plus `drizzle-orm`, which had drifted across 3 manifests (`^0.45.2` vs a bare pin) | root | S | +| 18 | ✅ DONE 2026-07-02 (direct commit) — dev-only `loadLocalEnv()` (`@meldrank/shared/server`) hydrates `process.env` from the repo-root `.env` at each service entry (never overrides, no-op in prod/test), and the `PORT` schema treats empty as unset so the shared `.env` ships `PORT=` blank and each service keeps its own default port; a plain root `pnpm dev` now boots the whole stack | tooling, shared | S | | 19 | Prune dead shared exports; fix stale doc comments; dedupe `SUIT_GLYPH`, `toHex`, page-shell JSX, engine deal orchestration (export rng-injectable `applyDealWithRng`) | shared, engine, web | M | | 20 | Engine polish: meld royal-marriage attribution, timeout escape completeness, rejection-reason surfacing | engine | M | From 8cf4d9ce5cb5302c5272fd2e298f1e946ffca0f4 Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Fri, 3 Jul 2026 00:31:41 -0400 Subject: [PATCH 05/16] test(web): add vitest test foundation with colyseus fake seam Adds vitest + testing-library harness to apps/web (jsdom env, fake Colyseus client injected through ColyseusProvider's new `client` prop) and covers the use-table-connection reconnect machine plus table-store derivation logic with unit tests. table-store exports buildRenderModel and deriveAvailableAction so tests can exercise them directly. Archives the add-web-test-foundation OpenSpec change and marks item 15 in the hardening audit as half done, with the Playwright e2e smoke left as a follow-up. --- apps/web/lib/colyseus.tsx | 5 +- apps/web/lib/table-store.test.tsx | 271 ++++++++++ apps/web/lib/table-store.tsx | 38 +- apps/web/lib/use-table-connection.test.tsx | 318 +++++++++++ apps/web/package.json | 8 +- apps/web/test/fake-colyseus.ts | 177 ++++++ apps/web/test/harness.tsx | 71 +++ apps/web/vitest.config.ts | 28 + apps/web/vitest.setup.ts | 26 + docs/audit/00-overview.md | 2 +- .../.openspec.yaml | 2 + .../design.md | 61 +++ .../proposal.md | 29 + .../specs/web-test-foundation/spec.md | 121 +++++ .../tasks.md | 33 ++ openspec/specs/web-test-foundation/spec.md | 125 +++++ pnpm-lock.yaml | 508 +++++++++++++++++- pnpm-workspace.yaml | 4 + 18 files changed, 1792 insertions(+), 35 deletions(-) create mode 100644 apps/web/lib/table-store.test.tsx create mode 100644 apps/web/lib/use-table-connection.test.tsx create mode 100644 apps/web/test/fake-colyseus.ts create mode 100644 apps/web/test/harness.tsx create mode 100644 apps/web/vitest.config.ts create mode 100644 apps/web/vitest.setup.ts create mode 100644 openspec/changes/archive/2026-07-03-add-web-test-foundation/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-03-add-web-test-foundation/design.md create mode 100644 openspec/changes/archive/2026-07-03-add-web-test-foundation/proposal.md create mode 100644 openspec/changes/archive/2026-07-03-add-web-test-foundation/specs/web-test-foundation/spec.md create mode 100644 openspec/changes/archive/2026-07-03-add-web-test-foundation/tasks.md create mode 100644 openspec/specs/web-test-foundation/spec.md diff --git a/apps/web/lib/colyseus.tsx b/apps/web/lib/colyseus.tsx index a5c42ec..7c83c49 100644 --- a/apps/web/lib/colyseus.tsx +++ b/apps/web/lib/colyseus.tsx @@ -17,10 +17,11 @@ import { env } from './env'; */ const ColyseusContext = createContext(null); -export function ColyseusProvider({ children }: { children: ReactNode }) { +export function ColyseusProvider({ children, client: injectedClient }: { children: ReactNode; client?: Client }) { // Lazy `useState` so the client is constructed exactly once, only under the // client boundary; on the server the initializer returns `null` (no `window`). - const [client] = useState(() => (typeof window === 'undefined' ? null : new Client(env.NEXT_PUBLIC_MATCH_URL))); + // An injected client (the test seam, web-test-foundation D3) bypasses construction. + const [client] = useState(() => injectedClient ?? (typeof window === 'undefined' ? null : new Client(env.NEXT_PUBLIC_MATCH_URL))); return {children}; } diff --git a/apps/web/lib/table-store.test.tsx b/apps/web/lib/table-store.test.tsx new file mode 100644 index 0000000..2a8d235 --- /dev/null +++ b/apps/web/lib/table-store.test.tsx @@ -0,0 +1,271 @@ +import type { FilteredView, PublicState } from '@meldrank/engine'; +import type { SyncedMetadataSnapshot } from '@meldrank/shared'; +import type { ReactNode } from 'react'; + +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { + buildRenderModel, + deriveAvailableAction, + type TableState, + TableStoreProvider, + useRenderModel, + useTableStore, + useTableStoreApi, +} from '@/lib/table-store'; + +/** + * Tests for the table render-model derivation (web-test-foundation). The pure + * helpers (`deriveAvailableAction`, `buildRenderModel`) are exercised directly + * with table-driven cases; store wiring that is not pure derivation goes through + * `TableStoreProvider` and the public hooks. + */ + +type ModelInput = Pick; + +function makeMetadata(overrides: Partial = {}): SyncedMetadataSnapshot { + return { + clockDeadline: -1, + lifecycle: 'Live', + occupancy: [true, true, true, true], + reconnectGraceMs: 90_000, + seatStatus: ['Connected', 'Connected', 'Connected', 'Connected'], + seatToAct: -1, + ...overrides, + }; +} + +/** The raw two-channel state fed to `buildRenderModel`; connected and idle by default. */ +function makeModelInput(overrides: Partial = {}): ModelInput { + return { + clockState: null, + metadata: null, + pendingCorrelationId: null, + rejectReason: null, + status: 'connected', + view: null, + ...overrides, + }; +} + +/** A minimal, valid `PublicState` at a resting Auction; override per case. */ +function makePublicState(overrides: Partial = {}): PublicState { + return { + auction: null, + captured: [], + completedTricks: [], + contract: null, + currentTrick: { ledSuit: null, plays: [], winnerSeatIndex: null }, + dealerSeat: 0, + handResult: null, + handsMadeAsBidder: {}, + matchResult: null, + melds: [], + outcome: null, + phase: 'Auction', + revealedWidow: [], + scorePad: { cumulative: {}, hands: [] }, + seatToAct: null, + trump: null, + ...overrides, + }; +} + +/** A filtered view for seat 1 (the viewer used throughout); override per case. */ +function makeView(publicOverrides: Partial = {}, viewOverrides: Partial = {}): FilteredView { + return { + handSizes: [12, 12, 12, 12], + own: { buried: [], hand: [{ copyIndex: 0, rank: 'A', suit: 'spades' }] }, + public: makePublicState(publicOverrides), + viewer: 1, + ...viewOverrides, + }; +} + +describe('deriveAvailableAction', () => { + it.each([ + { + expected: { currentHigh: 27, kind: 'bid' }, + name: 'Auction with a standing high bid → bid carrying currentHigh', + view: makeView({ auction: { highBid: { seatIndex: 0, value: 27 }, live: [true, true, true, true], toAct: 1 }, phase: 'Auction', seatToAct: 1 }), + }, + { + expected: { currentHigh: null, kind: 'bid' }, + name: 'Auction with no high bid yet → bid with null currentHigh', + view: makeView({ auction: { highBid: null, live: [true, true, true, true], toAct: 1 }, phase: 'Auction', seatToAct: 1 }), + }, + { + expected: { kind: 'declareTrump' }, + name: 'DeclareTrump on the viewer → declareTrump', + view: makeView({ phase: 'DeclareTrump', seatToAct: 1 }), + }, + { + expected: { kind: 'playCard' }, + name: 'TrickPlay on the viewer → playCard', + view: makeView({ phase: 'TrickPlay', seatToAct: 1 }), + }, + { + expected: null, + name: 'a non-acting phase (Melding) → null even on the viewer', + view: makeView({ phase: 'Melding', seatToAct: 1 }), + }, + { + expected: null, + name: "not the viewer's turn → null", + view: makeView({ phase: 'Auction', seatToAct: 2 }), + }, + { + expected: null, + name: 'spectator (null viewer) → null', + view: makeView({ phase: 'Auction', seatToAct: 1 }, { own: null, viewer: null }), + }, + ])('$name', ({ expected, view }) => { + expect(deriveAvailableAction(view, false)).toEqual(expected); + }); + + it('returns null while an intent is pending, regardless of the view', () => { + const view = makeView({ phase: 'TrickPlay', seatToAct: 1 }); + expect(deriveAvailableAction(view, true)).toBeNull(); + }); + + it('returns null before the first view', () => { + expect(deriveAvailableAction(null, false)).toBeNull(); + }); +}); + +describe('buildRenderModel', () => { + const actionableView = makeView({ phase: 'TrickPlay', seatToAct: 1 }); + + it('locks input while reconnecting even when the view says it is the viewer’s turn', () => { + const connected = buildRenderModel(makeModelInput({ view: actionableView })); + expect(connected.availableAction).toEqual({ kind: 'playCard' }); + + const reconnecting = buildRenderModel(makeModelInput({ status: 'reconnecting', view: actionableView })); + expect(reconnecting.availableAction).toBeNull(); + expect(reconnecting.status).toBe('reconnecting'); + }); + + it('locks input while an intent is pending', () => { + const model = buildRenderModel(makeModelInput({ pendingCorrelationId: 'c-1', view: actionableView })); + expect(model.availableAction).toBeNull(); + expect(model.pending).toBe(true); + }); + + describe('clock-deadline fallback chain', () => { + it('prefers clockState.deadline once a clockState has arrived', () => { + const model = buildRenderModel( + makeModelInput({ + clockState: { actingSeat: 1, deadline: 12_345, seats: [] }, + metadata: makeMetadata({ clockDeadline: 99_999 }), + }), + ); + expect(model.clockDeadline).toBe(12_345); + }); + + it('a present clockState wins even when its deadline is null (no fallback to metadata)', () => { + const model = buildRenderModel( + makeModelInput({ + clockState: { actingSeat: null, deadline: null, seats: [] }, + metadata: makeMetadata({ clockDeadline: 99_999 }), + }), + ); + expect(model.clockDeadline).toBeNull(); + }); + + it('falls back to synced metadata clockDeadline ≥ 0 before any clockState', () => { + const model = buildRenderModel(makeModelInput({ metadata: makeMetadata({ clockDeadline: 45_678 }) })); + expect(model.clockDeadline).toBe(45_678); + }); + + it('is null when metadata carries the no-move sentinel (-1) and no clockState', () => { + const model = buildRenderModel(makeModelInput({ metadata: makeMetadata({ clockDeadline: -1 }) })); + expect(model.clockDeadline).toBeNull(); + }); + }); + + it('renders safe defaults before the first view or metadata sync', () => { + const model = buildRenderModel(makeModelInput({ status: 'connecting' })); + expect(model).toEqual({ + availableAction: null, + clockDeadline: null, + handSizes: [], + lifecycle: null, + matchResult: null, + occupancy: [], + onClockSeat: null, + ownHand: [], + pending: false, + public: null, + rejectReason: null, + seatClocks: [], + seatStatus: [], + status: 'connecting', + viewer: null, + }); + }); +}); + +describe('table store surface', () => { + const wrapper = ({ children }: { children: ReactNode }) => {children}; + + function renderStore() { + return renderHook(() => ({ api: useTableStoreApi(), model: useRenderModel() }), { wrapper }); + } + + it('marking a fresh intent pending clears the stale rejection reason', () => { + const { result } = renderStore(); + + act(() => result.current.api.getState().setRejectReason('illegal-move')); + expect(result.current.model.rejectReason).toBe('illegal-move'); + + act(() => result.current.api.getState().setPending('c-2')); + expect(result.current.model.rejectReason).toBeNull(); + expect(result.current.model.pending).toBe(true); + }); + + it('clearPending re-enables the derived action', () => { + const { result } = renderStore(); + const view = makeView({ phase: 'TrickPlay', seatToAct: 1 }); + + act(() => { + result.current.api.getState().applyView(view); + result.current.api.getState().setPending('c-3'); + }); + expect(result.current.model.availableAction).toBeNull(); + + act(() => result.current.api.getState().clearPending()); + expect(result.current.model.availableAction).toEqual({ kind: 'playCard' }); + expect(result.current.model.pending).toBe(false); + }); + + it('wholesale view and metadata replacement is reflected by useRenderModel', () => { + const { result } = renderStore(); + + act(() => { + result.current.api.getState().applyView(makeView({ phase: 'Auction', seatToAct: 0 })); + result.current.api.getState().applyMetadata(makeMetadata({ lifecycle: 'Filling', occupancy: [true, false, false, false] })); + }); + expect(result.current.model.public?.phase).toBe('Auction'); + expect(result.current.model.lifecycle).toBe('Filling'); + expect(result.current.model.occupancy).toEqual([true, false, false, false]); + + const replacement = makeView({ phase: 'TrickPlay', seatToAct: 3 }, { handSizes: [11, 12, 12, 12] }); + act(() => { + result.current.api.getState().applyView(replacement); + result.current.api.getState().applyMetadata(makeMetadata({ lifecycle: 'Live', occupancy: [true, true, true, true] })); + }); + expect(result.current.model.public).toBe(replacement.public); + expect(result.current.model.handSizes).toEqual([11, 12, 12, 12]); + expect(result.current.model.lifecycle).toBe('Live'); + expect(result.current.model.occupancy).toEqual([true, true, true, true]); + }); + + it('useTableStore throws outside the provider', () => { + expect(() => renderHook(() => useTableStore((s) => s.status))).toThrow('useTableStore must be used within a TableStoreProvider'); + }); + + it('useTableStoreApi throws outside the provider', () => { + expect(() => renderHook(() => useTableStoreApi())).toThrow('useTableStoreApi must be used within a TableStoreProvider'); + }); +}); diff --git a/apps/web/lib/table-store.tsx b/apps/web/lib/table-store.tsx index cbc2515..5689655 100644 --- a/apps/web/lib/table-store.tsx +++ b/apps/web/lib/table-store.tsx @@ -106,8 +106,8 @@ export type TableStatus = 'complete' | 'connected' | 'connecting' | 'error' | 'r type TableStore = ReturnType; -/** Build the derived render model from the raw two-channel state (design D1, F2b D1). */ -function buildRenderModel( +/** Build the derived render model from the raw two-channel state (design D1, F2b D1). Exported for tests. */ +export function buildRenderModel( state: Pick, ): RenderModel { const { clockState, metadata, pendingCorrelationId, rejectReason, status, view } = state; @@ -138,6 +138,23 @@ function buildRenderModel( }; } +/** Pure phase→action map (design D3); permissive — server `reject` is the authority. Exported for tests. */ +export function deriveAvailableAction(view: FilteredView | null, pending: boolean): AvailableAction | null { + if (pending || view === null || view.viewer === null) return null; + const { auction, phase, seatToAct } = view.public; + if (seatToAct !== view.viewer) return null; + switch (phase) { + case 'Auction': + return { currentHigh: auction?.highBid?.value ?? null, kind: 'bid' }; + case 'DeclareTrump': + return { kind: 'declareTrump' }; + case 'TrickPlay': + return { kind: 'playCard' }; + default: + return null; + } +} + function createTableStore() { return createStore()((set) => ({ applyMetadata: (metadata) => set({ metadata }), @@ -157,23 +174,6 @@ function createTableStore() { })); } -/** Pure phase→action map (design D3); permissive — server `reject` is the authority. */ -function deriveAvailableAction(view: FilteredView | null, pending: boolean): AvailableAction | null { - if (pending || view === null || view.viewer === null) return null; - const { auction, phase, seatToAct } = view.public; - if (seatToAct !== view.viewer) return null; - switch (phase) { - case 'Auction': - return { currentHigh: auction?.highBid?.value ?? null, kind: 'bid' }; - case 'DeclareTrump': - return { kind: 'declareTrump' }; - case 'TrickPlay': - return { kind: 'playCard' }; - default: - return null; - } -} - const TableStoreContext = createContext(null); /** Provides a per-tree table store (created once via lazy `useState`), SSR-safe. */ diff --git a/apps/web/lib/use-table-connection.test.tsx b/apps/web/lib/use-table-connection.test.tsx new file mode 100644 index 0000000..002a811 --- /dev/null +++ b/apps/web/lib/use-table-connection.test.tsx @@ -0,0 +1,318 @@ +import type { FilteredView, MatchResult, PublicState } from '@meldrank/engine'; +import type { SyncedMetadataSnapshot } from '@meldrank/shared'; + +import { WireMessages } from '@meldrank/shared'; +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { readReconnectionToken } from '@/lib/reconnection-token'; +import { useSessionStore } from '@/lib/store'; +import { useTableStore } from '@/lib/table-store'; +import { useTableConnection } from '@/lib/use-table-connection'; +import { FakeColyseusClient, FakeRoom } from '@/test/fake-colyseus'; +import { createTableWrapper, type HandoffSeed, makeHandoff, seedReconnectionToken } from '@/test/harness'; + +/** + * Tests for the table resilience controller (web-test-foundation): the cold-load + * decision table, the drop/retry backoff loop bounded by the grace window, and + * the terminal transitions. Everything runs under fake timers (which patch + * `Date` too — the grace math mixes `setTimeout` with `Date.now()`), against the + * fake Colyseus client injected through the real provider seam. + */ + +const ROOM = 'room-1'; + +/** Fire the timer queue `ms` forward (and flush the microtasks between firings). */ +async function advance(ms: number): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +/** Flush pending microtasks (e.g. the initial async connect settling). */ +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + +function makeMetadata(overrides: Partial = {}): SyncedMetadataSnapshot { + return { + clockDeadline: -1, + lifecycle: 'Live', + occupancy: [true, true, true, true], + reconnectGraceMs: 90_000, + seatStatus: ['Connected', 'Connected', 'Connected', 'Connected'], + seatToAct: -1, + ...overrides, + }; +} + +/** A minimal filtered view; carries a `matchResult` when the match is complete. */ +function makeView(matchResult: MatchResult | null = null): FilteredView { + const publicState: PublicState = { + auction: null, + captured: [], + completedTricks: [], + contract: null, + currentTrick: { ledSuit: null, plays: [], winnerSeatIndex: null }, + dealerSeat: 0, + handResult: null, + handsMadeAsBidder: {}, + matchResult, + melds: [], + outcome: null, + phase: matchResult ? 'MatchComplete' : 'TrickPlay', + revealedWidow: [], + scorePad: { cumulative: {}, hands: [] }, + seatToAct: null, + trump: null, + }; + return { handSizes: [12, 12, 12, 12], own: { buried: [], hand: [] }, public: publicState, viewer: 1 }; +} + +const MATCH_RESULT: MatchResult = { complete: true, ratingBasis: 'team-win-loss', standings: [] }; + +/** Render the hook under the harness, exposing the stores the assertions read. */ +function renderConnection(client: FakeColyseusClient, handoff?: HandoffSeed, roomId: string = ROOM) { + return renderHook( + () => ({ + activeMatch: useSessionStore((s) => s.activeMatch), + connection: useTableConnection(roomId), + pendingCorrelationId: useTableStore((s) => s.pendingCorrelationId), + seatTicket: useSessionStore((s) => s.seatTicket), + status: useTableStore((s) => s.status), + }), + { wrapper: createTableWrapper(client, handoff) }, + ); +} + +beforeEach(() => { + vi.useFakeTimers(); + window.sessionStorage.clear(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('cold-load decision table', () => { + it('a seat ticket warm-handoff joins by id exactly once and connects', async () => { + const client = new FakeColyseusClient(); + const room = new FakeRoom('minted-token'); + client.queueJoin(room); + const handoff = makeHandoff(ROOM); + + const { result } = renderConnection(client, handoff); + await flush(); + + expect(client.joinByIdCalls).toEqual([{ options: { ticket: handoff.ticket!.token }, roomId: ROOM }]); + expect(client.reconnectCalls).toHaveLength(0); + expect(result.current.status).toBe('connected'); + expect(result.current.connection.noSession).toBe(false); + expect(readReconnectionToken(ROOM)).toBe('minted-token'); + }); + + it('no ticket but a stored token cold-reconnects exactly once and connects', async () => { + seedReconnectionToken(ROOM, 'stored-token'); + const client = new FakeColyseusClient(); + client.queueReconnect(new FakeRoom('refreshed-token')); + + const { result } = renderConnection(client); + await flush(); + + expect(client.reconnectCalls).toEqual(['stored-token']); + expect(client.joinByIdCalls).toHaveLength(0); + expect(result.current.status).toBe('connected'); + expect(readReconnectionToken(ROOM)).toBe('refreshed-token'); + }); + + it('neither credential means noSession and no connection attempt', async () => { + const client = new FakeColyseusClient(); + + const { result } = renderConnection(client); + await advance(0); + + expect(result.current.connection.noSession).toBe(true); + expect(client.joinByIdCalls).toHaveLength(0); + expect(client.reconnectCalls).toHaveLength(0); + }); + + it('a warm-join rejection is terminal error with the handoff cleared', async () => { + const client = new FakeColyseusClient(); + client.rejectNextJoin(); + + const { result } = renderConnection(client, makeHandoff(ROOM)); + await flush(); + + expect(result.current.status).toBe('error'); + expect(result.current.seatTicket).toBeNull(); + expect(result.current.activeMatch).toBeNull(); + }); + + it('a cold-reconnect rejection clears the token, reports noSession, and never retries', async () => { + seedReconnectionToken(ROOM, 'stale-token'); + const client = new FakeColyseusClient(); + const attempt = client.deferReconnect(); + + const { result } = renderConnection(client); + // Real network ordering: the 0ms no-session recovery tick fires before the + // server's rejection ever lands. + await advance(0); + act(() => attempt.reject()); + await flush(); + + expect(result.current.connection.noSession).toBe(true); + expect(readReconnectionToken(ROOM)).toBeNull(); + + await advance(120_000); + expect(client.reconnectCalls).toEqual(['stale-token']); + }); +}); + +describe('drop/retry loop', () => { + /** Mount, connect via ticket, and sync metadata so the grace window is live. */ + async function connectWithGrace(client: FakeColyseusClient, room: FakeRoom, reconnectGraceMs: number) { + const view = renderConnection(client, makeHandoff(ROOM)); + await flush(); + expect(view.result.current.status).toBe('connected'); + act(() => room.emitState(makeMetadata({ reconnectGraceMs }))); + return view; + } + + it('a pre-completion drop enters reconnecting, clears the pending intent, and retries at 1s/2s/4s/4s', async () => { + const client = new FakeColyseusClient(); + const room = new FakeRoom(); + client.queueJoin(room); + const { result } = await connectWithGrace(client, room, 90_000); + + act(() => result.current.connection.submitIntent({ seat: 1, type: 'pass' })); + expect(result.current.pendingCorrelationId).not.toBeNull(); + + client.alwaysRejectReconnect(); + act(() => room.emitLeave()); + + expect(result.current.status).toBe('reconnecting'); + expect(result.current.pendingCorrelationId).toBeNull(); + expect(client.reconnectCalls).toHaveLength(0); + + // Backoff shape: doubling from 1s, capped at 4s, one in-flight attempt at a time. + await advance(999); + expect(client.reconnectCalls).toHaveLength(0); + await advance(1); // t = 1s + expect(client.reconnectCalls).toHaveLength(1); + await advance(1_999); + expect(client.reconnectCalls).toHaveLength(1); + await advance(1); // t = 3s + expect(client.reconnectCalls).toHaveLength(2); + await advance(3_999); + expect(client.reconnectCalls).toHaveLength(2); + await advance(1); // t = 7s + expect(client.reconnectCalls).toHaveLength(3); + await advance(4_000); // t = 11s — still the 4s cap + expect(client.reconnectCalls).toHaveLength(4); + expect(result.current.status).toBe('reconnecting'); + }); + + it('grace exhaustion (metadata window minus margin) is terminal: error, token cleared, no more attempts', async () => { + const client = new FakeColyseusClient(); + const room = new FakeRoom(); + client.queueJoin(room); + // 12s grace − 5s margin = 7s of attempts: retries at 1s, 3s, 7s, then terminal. + const { result } = await connectWithGrace(client, room, 12_000); + + client.alwaysRejectReconnect(); + act(() => room.emitLeave()); + await advance(7_000); + + expect(client.reconnectCalls).toHaveLength(3); + expect(result.current.status).toBe('error'); + expect(readReconnectionToken(ROOM)).toBeNull(); + expect(result.current.seatTicket).toBeNull(); + expect(result.current.activeMatch).toBeNull(); + + await advance(60_000); + expect(client.reconnectCalls).toHaveLength(3); + }); + + it('a successful reconnect recovers: connected, fresh token persisted, backoff reset to 1s', async () => { + const client = new FakeColyseusClient(); + const room = new FakeRoom('token-1'); + client.queueJoin(room); + const { result } = await connectWithGrace(client, room, 90_000); + + client.rejectNextReconnect(); + const recoveredRoom = new FakeRoom('token-2'); + client.queueReconnect(recoveredRoom); + act(() => room.emitLeave()); + + await advance(1_000); // attempt 1 rejects + await advance(2_000); // attempt 2 succeeds + expect(client.reconnectCalls).toHaveLength(2); + expect(result.current.status).toBe('connected'); + expect(readReconnectionToken(ROOM)).toBe('token-2'); + + // Backoff reset: a later drop starts again at the 1s base, not the doubled delay. + client.queueReconnect(new FakeRoom('token-3')); + act(() => recoveredRoom.emitLeave()); + expect(result.current.status).toBe('reconnecting'); + await advance(1_000); + expect(client.reconnectCalls).toHaveLength(3); + expect(result.current.status).toBe('connected'); + expect(readReconnectionToken(ROOM)).toBe('token-3'); + }); +}); + +describe('terminal transitions', () => { + it('a server close after a matchResult view is complete: token and handoff cleared, no reconnect', async () => { + const client = new FakeColyseusClient(); + const room = new FakeRoom(); + client.queueJoin(room); + const { result } = renderConnection(client, makeHandoff(ROOM)); + await flush(); + + act(() => room.emitMessage(WireMessages.view, makeView(MATCH_RESULT))); + act(() => room.emitLeave()); + + expect(result.current.status).toBe('complete'); + expect(readReconnectionToken(ROOM)).toBeNull(); + expect(result.current.seatTicket).toBeNull(); + expect(result.current.activeMatch).toBeNull(); + + await advance(120_000); + expect(client.reconnectCalls).toHaveLength(0); + }); + + it('unmount before the initial connect resolves leaves a pre-existing token intact (strict-mode guard)', async () => { + seedReconnectionToken(ROOM, 'preexisting-token'); + const client = new FakeColyseusClient(); + client.deferReconnect(); // never settled — the connect stays in flight + + const { unmount } = renderConnection(client); + await flush(); + unmount(); + + expect(readReconnectionToken(ROOM)).toBe('preexisting-token'); + }); + + it('unmount after connect cancels timers, leaves the room, and clears the token', async () => { + const client = new FakeColyseusClient(); + const room = new FakeRoom('minted-token'); + client.queueJoin(room); + const { result, unmount } = renderConnection(client, makeHandoff(ROOM)); + await flush(); + act(() => room.emitState(makeMetadata())); + + // Enter the retry loop, then unmount before the first backoff timer fires. + client.alwaysRejectReconnect(); + act(() => room.emitLeave()); + expect(result.current.status).toBe('reconnecting'); + unmount(); + + expect(room.leaveCount).toBe(1); + expect(readReconnectionToken(ROOM)).toBeNull(); + + await advance(60_000); + expect(client.reconnectCalls).toHaveLength(0); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 86a7fef..040a68c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,6 +9,7 @@ "lint": "eslint .", "next-typesafe-url": "next-typesafe-url --config ./next-typesafe-url.config.ts", "start": "next start", + "test": "vitest run", "typecheck": "next-typesafe-url && next typegen && tsc --noEmit -p tsconfig.json" }, "dependencies": { @@ -37,13 +38,18 @@ "@meldrank/api": "workspace:*", "@tailwindcss/postcss": "^4.3.1", "@tanstack/react-query-devtools": "^5.101.2", + "@testing-library/jest-dom": "catalog:", + "@testing-library/react": "catalog:", "@types/node": "catalog:", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "catalog:", "concurrently": "^10.0.3", "eslint": "catalog:", + "jsdom": "catalog:", "tailwindcss": "^4.3.1", "tw-animate-css": "^1.4.0", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/apps/web/test/fake-colyseus.ts b/apps/web/test/fake-colyseus.ts new file mode 100644 index 0000000..346bf79 --- /dev/null +++ b/apps/web/test/fake-colyseus.ts @@ -0,0 +1,177 @@ +import type { SyncedMetadataSnapshot } from '@meldrank/shared'; + +/** + * Hand-rolled Colyseus test doubles (web-test-foundation D3). `FakeRoom` covers + * exactly the room surface `use-table-connection` consumes — handler + * registration (`onMessage`/`onStateChange`/`onLeave`/`onError`), `send`, + * `leave`, and `reconnectionToken` — and lets the test fire the captured + * handlers directly. `FakeColyseusClient` records `joinById`/`reconnect` calls + * and resolves or rejects each one from a per-test script. No `vi.mock`: + * everything flows through the `useColyseus` context seam the production code + * already has (the harness casts the fake to `Client` at that one boundary). + */ + +/** The test-side controller of a deferred call — settle it whenever the scenario demands. */ +export interface DeferredRoom { + reject: (error?: Error) => void; + resolve: (room: FakeRoom) => void; +} + +/** A scripted result for a `joinById`/`reconnect` call; `wait` settles when the test says so. */ +type RoomOutcome = { error: Error; kind: 'reject'; } | { kind: 'resolve'; room: FakeRoom } | { kind: 'wait'; promise: Promise }; + +export class FakeColyseusClient { + /** Every `joinById` call, in order. */ + readonly joinByIdCalls: { options: unknown; roomId: string }[] = []; + /** Every `reconnect` call's token, in order. */ + readonly reconnectCalls: string[] = []; + + private readonly joinOutcomes: RoomOutcome[] = []; + private reconnectFallback: null | RoomOutcome = null; + private readonly reconnectOutcomes: RoomOutcome[] = []; + + // --- test-side scripting --- + + /** Every reconnect beyond the queued outcomes rejects (grace-exhaustion runs). */ + alwaysRejectReconnect(error = new Error('reconnect rejected')): void { + this.reconnectFallback = { error, kind: 'reject' }; + } + + /** + * Script the next `joinById` to stay unsettled until the returned controller + * settles it — real network timing (settlement after the current tick). + */ + deferJoin(): DeferredRoom { + const { controller, promise } = createDeferred(); + this.joinOutcomes.push({ kind: 'wait', promise }); + return controller; + } + + /** Script the next `reconnect` to stay unsettled until the returned controller settles it. */ + deferReconnect(): DeferredRoom { + const { controller, promise } = createDeferred(); + this.reconnectOutcomes.push({ kind: 'wait', promise }); + return controller; + } + + async joinById(roomId: string, options?: unknown): Promise { + this.joinByIdCalls.push({ options, roomId }); + const outcome = this.joinOutcomes.shift(); + if (!outcome) throw new Error('FakeColyseusClient: unscripted joinById call'); + return settle(outcome); + } + + /** Script the next `joinById` to resolve with `room`. */ + queueJoin(room: FakeRoom): void { + this.joinOutcomes.push({ kind: 'resolve', room }); + } + + /** Script the next `reconnect` to resolve with `room`. */ + queueReconnect(room: FakeRoom): void { + this.reconnectOutcomes.push({ kind: 'resolve', room }); + } + + async reconnect(token: string): Promise { + this.reconnectCalls.push(token); + const outcome = this.reconnectOutcomes.shift() ?? this.reconnectFallback; + if (!outcome) throw new Error('FakeColyseusClient: unscripted reconnect call'); + return settle(outcome); + } + + // --- the client surface the hook consumes --- + + /** Script the next `joinById` to reject (e.g. an invalid/expired ticket). */ + rejectNextJoin(error = new Error('join rejected')): void { + this.joinOutcomes.push({ error, kind: 'reject' }); + } + + /** Script the next `reconnect` to reject (e.g. grace expired on the server). */ + rejectNextReconnect(error = new Error('reconnect rejected')): void { + this.reconnectOutcomes.push({ error, kind: 'reject' }); + } +} + +export class FakeRoom { + /** How many times the hook called `leave()` (teardown assertions). */ + leaveCount = 0; + /** The token Colyseus would mint on (re)connect; settable per test. */ + reconnectionToken: string; + /** Every `send` the hook issued, in order. */ + readonly sent: { payload: unknown; type: string }[] = []; + + private readonly errorHandlers: ((code: number, message?: string) => void)[] = []; + private readonly leaveHandlers: ((code?: number) => void)[] = []; + private readonly messageHandlers = new Map void>(); + private readonly stateHandlers: ((state: SyncedMetadataSnapshot) => void)[] = []; + + constructor(reconnectionToken = 'fake-reconnection-token') { + this.reconnectionToken = reconnectionToken; + } + + // --- test-side firing of the captured handlers --- + + /** Fire the registered `onError` handlers. */ + emitError(code = 0, message?: string): void { + for (const handler of this.errorHandlers) handler(code, message); + } + + /** Fire the registered `onLeave` handlers (a server close / transport drop). */ + emitLeave(code?: number): void { + for (const handler of this.leaveHandlers) handler(code); + } + + /** Deliver a room message to the handler registered for `type`. */ + emitMessage(type: string, payload: unknown): void { + const handler = this.messageHandlers.get(type); + if (!handler) throw new Error(`FakeRoom: no handler registered for message "${type}"`); + handler(payload as never); + } + + /** Deliver a synced-state change to the `onStateChange` handlers. */ + emitState(state: SyncedMetadataSnapshot): void { + for (const handler of this.stateHandlers) handler(state); + } + + // --- the room surface the hook consumes --- + + leave(): Promise { + this.leaveCount += 1; + return Promise.resolve(0); + } + + onError(handler: (code: number, message?: string) => void): void { + this.errorHandlers.push(handler); + } + + onLeave(handler: (code?: number) => void): void { + this.leaveHandlers.push(handler); + } + + onMessage(type: string, handler: (payload: T) => void): void { + this.messageHandlers.set(type, handler); + } + + onStateChange(handler: (state: SyncedMetadataSnapshot) => void): void { + this.stateHandlers.push(handler); + } + + send(type: string, payload?: unknown): void { + this.sent.push({ payload, type }); + } +} + +function createDeferred(): { controller: DeferredRoom; promise: Promise } { + let resolve!: (room: FakeRoom) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { controller: { reject: (error = new Error('call rejected')) => reject(error), resolve }, promise }; +} + +function settle(outcome: RoomOutcome): Promise { + if (outcome.kind === 'wait') return outcome.promise; + if (outcome.kind === 'reject') return Promise.reject(outcome.error); + return Promise.resolve(outcome.room); +} diff --git a/apps/web/test/harness.tsx b/apps/web/test/harness.tsx new file mode 100644 index 0000000..491de80 --- /dev/null +++ b/apps/web/test/harness.tsx @@ -0,0 +1,71 @@ +/* eslint-disable react-refresh/only-export-components -- test-only module, never hot-reloaded */ +import type { Client } from '@colyseus/sdk'; +import type { ActiveMatch, SignedSeatTicket } from '@meldrank/shared'; +import type { ReactNode } from 'react'; + +import { useState } from 'react'; + +import { ColyseusProvider } from '@/lib/colyseus'; +import { writeReconnectionToken } from '@/lib/reconnection-token'; +import { SessionStoreProvider, useSessionStore } from '@/lib/store'; +import { TableStoreProvider } from '@/lib/table-store'; + +import type { FakeColyseusClient } from './fake-colyseus'; + +/** + * Table test harness (web-test-foundation D3): the real `SessionStoreProvider` + + * `TableStoreProvider` trees with the Colyseus context carrying a + * `FakeColyseusClient`, plus seeding helpers so a test can start from any + * cold-load credential state (seat ticket, persisted reconnection token, or + * neither) before the hook under test mounts. + */ + +/** A lobby→table handoff to seed into the session store before mount. */ +export interface HandoffSeed { + readonly match: ActiveMatch; + readonly ticket: null | SignedSeatTicket; +} + +/** + * The `renderHook` wrapper: fake-client Colyseus context around the real store + * providers. When `handoff` is given, it is written into the session store + * during the first render pass, so the hook under test mounts with the + * credential already present — exactly like arriving from the lobby. + */ +export function createTableWrapper(client: FakeColyseusClient, handoff?: HandoffSeed) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + + {handoff ? : null} + {children} + + + ); + }; +} + +/** A complete handoff for `roomId` — with a ticket (warm) or explicitly without (rejoin). */ +export function makeHandoff(roomId: string, ticket: null | SignedSeatTicket = makeSeatTicket(roomId)): HandoffSeed { + return { match: { roomId, seat: 1, variantId: 'single-deck-partners' }, ticket }; +} + +/** A signed human seat ticket for `roomId`; the token is opaque to the client. */ +export function makeSeatTicket(roomId: string, seat = 1): SignedSeatTicket { + return { + payload: { exp: 4_102_444_800_000, kind: 'human', playerId: 'player-1', roomId, seat, variantId: 'single-deck-partners' }, + token: `ticket-for-${roomId}`, + }; +} + +/** Seed the tab-scoped `sessionStorage` reconnection token exactly as a prior session would have. */ +export const seedReconnectionToken = writeReconnectionToken; + +function SeedHandoff({ handoff }: { handoff: HandoffSeed }) { + const setHandoff = useSessionStore((s) => s.setHandoff); + // Lazy initializer: writes the zustand store once, during this component's + // render — it renders before its provider siblings' children, so the hook + // under test never sees the un-seeded state (no post-mount effect re-run). + useState(() => setHandoff({ match: handoff.match, ticket: handoff.ticket })); + return null; +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..822e6f4 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,28 @@ +import react from '@vitejs/plugin-react'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** + * Web unit-test harness (web-test-foundation D1). Next sets `"jsx": "preserve"` + * in tsconfig, so Vitest's default esbuild transform would pass JSX through + * untransformed — `@vitejs/plugin-react` supplies the transform. jsdom (not + * happy-dom) because testing-library targets it and the reconnect tests lean on + * `sessionStorage` semantics. Workspace `@meldrank/*` imports resolve as TS + * source via the packages' `exports` maps; only the app-local `@/*` alias needs + * a resolver entry. + */ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { '@': fileURLToPath(new URL('.', import.meta.url)) }, + }, + test: { + env: { + // The web env schema requires a Clerk publishable key (any non-empty + // string); tests never mount ClerkProvider, so a placeholder suffices. + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: 'pk_test_placeholder', + }, + environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], + }, +}); diff --git a/apps/web/vitest.setup.ts b/apps/web/vitest.setup.ts new file mode 100644 index 0000000..bc876e6 --- /dev/null +++ b/apps/web/vitest.setup.ts @@ -0,0 +1,26 @@ +import '@testing-library/jest-dom/vitest'; +import { webcrypto } from 'node:crypto'; + +/** + * Shared web-test setup (web-test-foundation D4). Production code calls + * `crypto.getRandomValues` (seed contributions) and `crypto.randomUUID` + * (correlation ids); jsdom's WebCrypto coverage varies by version, so bridge + * any gap from Node's implementation to keep the suite independent of it. + */ +const cryptoRef = globalThis.crypto as Crypto | undefined; +if (!cryptoRef) { + Object.defineProperty(globalThis, 'crypto', { configurable: true, value: webcrypto }); +} else { + if (typeof cryptoRef.getRandomValues !== 'function') { + Object.defineProperty(cryptoRef, 'getRandomValues', { + configurable: true, + value: webcrypto.getRandomValues.bind(webcrypto), + }); + } + if (typeof cryptoRef.randomUUID !== 'function') { + Object.defineProperty(cryptoRef, 'randomUUID', { + configurable: true, + value: webcrypto.randomUUID.bind(webcrypto), + }); + } +} diff --git a/docs/audit/00-overview.md b/docs/audit/00-overview.md index 1918d71..d78ad00 100644 --- a/docs/audit/00-overview.md +++ b/docs/audit/00-overview.md @@ -62,7 +62,7 @@ The AI-built codebase is in genuinely good shape architecturally: the workspace | # | Item | Area | Size | |---|---|---|---| -| 15 | Web test foundation (vitest + testing-library; start with `use-table-connection` reconnect machine and table-store derivation — both pure) and a minimal e2e smoke (create table → play a bot hand) | web | L | +| 15 | 🔶 HALF DONE 2026-07-03 (change `add-web-test-foundation`) — unit foundation shipped: vitest + testing-library harness in `apps/web` (jsdom, fake Colyseus client through the `useColyseus` seam), `use-table-connection` reconnect machine + table-store derivation covered, web now in `turbo run test`. Remaining: the Playwright e2e smoke (create table → play a bot hand), a follow-up change | web | L | | 16 | ✅ DONE 2026-07-02 (direct commit, no OpenSpec change — Jason's call) — no-op `build` scripts (`tsc --noEmit`) removed from the 4 packages; `turbo run build` now matches only the apps' real builds (tsup ×3 + next), so CI stops type-checking every package twice | tooling | S | | 17 | ✅ DONE 2026-07-02 (direct commit) — pnpm `catalog:` in `pnpm-workspace.yaml` for the 6 duplicated tool versions (`typescript`, `eslint`, `vitest`, `@types/node`, `tsx`, `tsup`) plus `drizzle-orm`, which had drifted across 3 manifests (`^0.45.2` vs a bare pin) | root | S | | 18 | ✅ DONE 2026-07-02 (direct commit) — dev-only `loadLocalEnv()` (`@meldrank/shared/server`) hydrates `process.env` from the repo-root `.env` at each service entry (never overrides, no-op in prod/test), and the `PORT` schema treats empty as unset so the shared `.env` ships `PORT=` blank and each service keeps its own default port; a plain root `pnpm dev` now boots the whole stack | tooling, shared | S | diff --git a/openspec/changes/archive/2026-07-03-add-web-test-foundation/.openspec.yaml b/openspec/changes/archive/2026-07-03-add-web-test-foundation/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-web-test-foundation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/archive/2026-07-03-add-web-test-foundation/design.md b/openspec/changes/archive/2026-07-03-add-web-test-foundation/design.md new file mode 100644 index 0000000..8b203a8 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-web-test-foundation/design.md @@ -0,0 +1,61 @@ +# Design — Add Web Test Foundation + +## Context + +`apps/web` is the only workspace without a `test` script; turbo's `test` task silently skips it. The audit (item 15) named the two units to cover first, both effectively pure: the render-model derivation in `apps/web/lib/table-store.tsx` (`buildRenderModel`, `deriveAvailableAction` — currently module-private) and the reconnect state machine in `apps/web/lib/use-table-connection.ts` (a `useEffect`-hosted controller with an injectable Colyseus client via the `useColyseus` context). Every other workspace already runs Vitest with colocated `*.test.ts` files. Jason's ruling #12 (2026-07-01) locked vitest + testing-library for this layer; Playwright e2e is a separate follow-up change. + +## Goals / Non-Goals + +**Goals:** + +- A runnable, CI-integrated Vitest harness in `apps/web` that any future web test drops into. +- Deterministic unit coverage of the two named units — no network, no real sockets, no real timers. +- Zero changes to product behavior or to the turbo/CI pipeline definitions. + +**Non-Goals:** + +- The Playwright e2e smoke (follow-up change). +- Component/visual tests of table UI components (the harness enables them; none are required here). +- Coverage thresholds or reporting gates. +- Testing `use-waiting-room` or other web units beyond the two named ones (welcome later, not required). + +## Decisions + +### D1 — Vitest + jsdom + `@vitejs/plugin-react`, config local to `apps/web` + +`vitest.config.ts` in `apps/web` with `environment: 'jsdom'` and `@vitejs/plugin-react` for the JSX transform. Rationale: Next sets `"jsx": "preserve"` in tsconfig, so Vitest's default esbuild transform would pass JSX through untransformed — the react plugin is the standard fix and matches how Next apps test with Vitest. jsdom over happy-dom because testing-library's supported target is jsdom and the reconnect tests lean on `sessionStorage` semantics. The `@/*` path alias is resolved with a one-line `resolve.alias` entry (no extra plugin); workspace `@meldrank/*` imports already resolve as TS source via the package `exports` maps. + +### D2 — Export the pure derivation helpers; test the store through its public hooks + +`buildRenderModel` and `deriveAvailableAction` become named exports of `table-store.tsx` (no behavior change) and are tested directly as pure functions — table-driven cases, no React needed. Store-level behaviors that involve wiring (e.g. `setPending` clearing `rejectReason`, `useRenderModel` memoization, the throws-outside-provider guard) are tested through `TableStoreProvider` + `renderHook`. Alternative considered: testing derivation only through the provider — rejected as indirection that makes the phase→action table cases noisy without adding fidelity. + +### D3 — A hand-rolled fake Colyseus client/room double, injected through the real providers + +`use-table-connection` already receives its client via `useColyseus` context, so tests render the hook under the real `TableStoreProvider` plus a `ColyseusProvider` (or context stub) carrying a `FakeColyseusClient`. The fake records `joinById`/`reconnect` calls and returns `FakeRoom`s whose registered `onMessage`/`onLeave`/`onError`/`onStateChange` handlers the test fires directly; `send` calls are recorded for assertion; `reconnectionToken` is settable. No `vi.mock` of module imports — everything flows through the seams the production code already has. The double lives in `apps/web/test/fake-colyseus.ts` for reuse by later tests. + +### D4 — Fake timers (including `Date`) for the backoff/grace machine + +`vi.useFakeTimers()` drives the backoff schedule (1 s doubling, 4 s cap) and the grace-window bound: the machine mixes `setTimeout` with `Date.now()` arithmetic, so modern fake timers (which patch both coherently) are required — real waits or partial mocking would be flaky or lie about the grace math. `sessionStorage` comes free with jsdom for the reconnection-token persistence assertions. If jsdom's environment lacks WebCrypto (`crypto.getRandomValues`/`randomUUID`, both used by the hook), the setup file bridges them from `node:crypto`. + +### D5 — Conventions match the rest of the repo + +Colocated `*.test.ts(x)` next to sources (`lib/table-store.test.tsx`, `lib/use-table-connection.test.tsx`), `test: "vitest run"` script, shared setup in `vitest.setup.ts` loading `@testing-library/jest-dom`. Turbo needs no edits: the existing `test` task picks up the new script, and its `dependsOn: ["^build"]` is a no-op for web's dependencies (packages export TS source). + +### D6 — New test devDependencies enter the pnpm catalog + +`@testing-library/react`, `@testing-library/jest-dom`, `jsdom`, and `@vitejs/plugin-react` are added to the catalog (audit item 17's mechanism) even though web is today their only consumer — the e2e change and any future package-level component tests will share them, and the catalog is now the declared home for repeated tool versions. `vitest` is already cataloged. All versions latest stable per the dependency policy (verify against the registry at implementation time). + +## Risks / Trade-offs + +- [React 19 + testing-library churn (`useEffectEvent` is new)] → use the latest stable `@testing-library/react` (React 19-compatible); the hook tests interact via `act`/handler firing, never re-implementing scheduler internals. +- [Timer tests encode current constants (1 s/4 s backoff, 5 s grace margin)] → assert the *shape* (doubling, capped, bounded by grace) with the constants read from behavior, not duplicated magic numbers where avoidable; a deliberate tuning change should touch one or two assertions, not dozens. +- [The hook's effect does real cleanup on unmount (leaves room, clears token under conditions)] → tests must unmount within fake-timer control and assert the strict-mode-sensitive token behavior explicitly (it is one of the documented edge cases, worth pinning). +- [jsdom WebCrypto gaps vary by version] → the setup-file bridge (D4) makes the suite independent of jsdom's current coverage. + +## Migration Plan + +Pure addition — no deploy or rollback concerns. Lands as one PR; CI's existing `turbo run lint typecheck test build` gate starts executing the new suite automatically. + +## Open Questions + +- None blocking. (Whether `use-waiting-room` gets the same treatment is deferred to a future change; the harness makes it cheap.) diff --git a/openspec/changes/archive/2026-07-03-add-web-test-foundation/proposal.md b/openspec/changes/archive/2026-07-03-add-web-test-foundation/proposal.md new file mode 100644 index 0000000..3c601e6 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-web-test-foundation/proposal.md @@ -0,0 +1,29 @@ +# Add Web Test Foundation + +## Why + +`apps/web` is the last workspace with zero tests — it has no `test` script at all, so `turbo run test` silently skips it while merges auto-deploy to production (audit item 15, the "missing top of the test pyramid" finding). The two highest-risk pure units the audit named — the `use-table-connection` reconnect state machine and the table-store render-model derivation — guard the entire live-table experience and currently regress invisibly. + +## What Changes + +- Add a Vitest + @testing-library/react harness to `apps/web` (jsdom environment, workspace path aliases, React 19 setup) with a `test` script, so turbo's existing `test` task picks the app up with no pipeline changes. +- Unit-test the table-store derivation: `buildRenderModel` and `deriveAvailableAction` (phase→action map, input locking while pending/reconnecting, clock-deadline fallback chain, pre-first-view defaults). The two pure helpers become exported (test-visible) without behavior change. +- Unit-test the `use-table-connection` reconnect machine via `renderHook` with a fake Colyseus client and fake timers: the cold-load decision table (ticket → `joinById`; stored token → `reconnect`; neither → `noSession`), drop → `reconnecting` with capped exponential backoff bounded by the grace window, token persistence/clearing, and the terminal `complete`/`error` transitions. +- Version alignment: new test-only devDependencies enter the pnpm catalog where they will be shared later (vitest already is); all versions latest stable. +- **Out of scope (deliberate):** the Playwright e2e smoke — it is the second half of audit item 15 and ships as a separate follow-up change with its own CI decisions. + +## Capabilities + +### New Capabilities + +- `web-test-foundation`: the unit/component test harness for `apps/web` — how web tests run (Vitest, jsdom, testing-library), how they integrate with the monorepo `test` task, and the required coverage of the table reconnect machine and render-model derivation. + +### Modified Capabilities + + + +## Impact + +- `apps/web`: new `vitest.config.ts`, test setup file, `test` script, test files under `lib/`; `buildRenderModel`/`deriveAvailableAction` exported from `table-store.tsx`. +- Root: new shared test devDependency versions recorded in the pnpm catalog (`pnpm-workspace.yaml`). +- No runtime/product behavior change; no CI workflow edits (turbo already runs `test` across the graph). diff --git a/openspec/changes/archive/2026-07-03-add-web-test-foundation/specs/web-test-foundation/spec.md b/openspec/changes/archive/2026-07-03-add-web-test-foundation/specs/web-test-foundation/spec.md new file mode 100644 index 0000000..441260d --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-web-test-foundation/specs/web-test-foundation/spec.md @@ -0,0 +1,121 @@ +# web-test-foundation + +## ADDED Requirements + +### Requirement: apps/web has a unit-test suite wired into the monorepo test task + +`apps/web` SHALL define a `test` script that runs its Vitest suite, so the workspace participates in `turbo run test` (and therefore the CI quality gate) like every other package. A failing web test MUST fail the monorepo test run. + +#### Scenario: turbo runs the web tests + +- **WHEN** `turbo run test` (or `pnpm --filter @meldrank/web test`) executes +- **THEN** the `apps/web` Vitest suite runs and its result is included in the task outcome + +#### Scenario: a failing web test fails the gate + +- **WHEN** any `apps/web` test fails +- **THEN** the `test` task exits non-zero and the CI quality gate fails + +### Requirement: Web tests are deterministic and isolated + +The web test suite SHALL run without network access, real WebSocket connections, or real-time waits: the Colyseus client is replaced by an injected test double at the existing `useColyseus` seam, and all timer/`Date` behavior runs under Vitest fake timers. Web-API gaps in the test environment that production code relies on (`sessionStorage`, `crypto.getRandomValues`, `crypto.randomUUID`) MUST be available or bridged in the shared test setup. + +#### Scenario: suite runs offline + +- **WHEN** the suite executes with no network available +- **THEN** every test passes or fails identically to a networked run + +#### Scenario: backoff timing is simulated + +- **WHEN** a test exercises reconnect backoff delays +- **THEN** it advances fake timers rather than waiting wall-clock time + +### Requirement: Render-model derivation is unit-tested + +The pure derivation in `apps/web/lib/table-store.tsx` (`buildRenderModel` and `deriveAvailableAction`, exported for tests) SHALL be covered by unit tests for: the phase→action map (Auction → `bid` with `currentHigh`, DeclareTrump → `declareTrump`, TrickPlay → `playCard`, any other phase → null), turn gating (not the viewer's turn, or spectator view → null), input locking (a pending intent or `reconnecting` status → null action), the clock-deadline fallback chain (`clockState.deadline`, else synced `clockDeadline` when ≥ 0, else null), and pre-first-view defaults (empty hand/seat arrays, null public state). + +#### Scenario: viewer on turn in auction + +- **WHEN** the view's phase is `Auction`, `seatToAct` equals the viewer, nothing is pending, and the status is `connected` +- **THEN** the derived `availableAction` is `{ kind: 'bid', currentHigh: }` + +#### Scenario: input locked while reconnecting + +- **WHEN** the same view is held but the status is `reconnecting` (or an intent correlation id is pending) +- **THEN** the derived `availableAction` is null + +#### Scenario: clock deadline falls back to synced metadata + +- **WHEN** no `clockState` payload has arrived and the synced metadata carries `clockDeadline ≥ 0` +- **THEN** the render model's `clockDeadline` is the metadata value; and when the metadata value is `-1` with no `clockState`, it is null + +### Requirement: Table-store behaviors are tested through the public store surface + +Store wiring that is not pure derivation SHALL be tested through `TableStoreProvider` and the public hooks: `setPending` clearing a prior `rejectReason`, `clearPending` re-enabling input, wholesale `view`/`metadata` replacement, and the out-of-provider guard throwing. + +#### Scenario: a fresh intent clears the stale rejection + +- **WHEN** a rejection reason is set and a new intent is marked pending +- **THEN** the store's `rejectReason` is null while the new correlation id is pending + +#### Scenario: hooks outside the provider throw + +- **WHEN** `useTableStore` or `useTableStoreApi` is used outside `TableStoreProvider` +- **THEN** it throws the explicit provider error + +### Requirement: The reconnect machine's cold-load decision table is unit-tested + +`useTableConnection` SHALL be covered for its mount decision (design D3 of table-resilience-web): a seat ticket present → `joinById` with the ticket; no ticket but a persisted reconnection token for the room → `client.reconnect(token)`; neither credential → `noSession` true and no connection attempt; a warm-join rejection → terminal `error` with the handoff cleared; a cold-reconnect rejection → the stored token cleared, `noSession` true, and no retry. + +#### Scenario: warm handoff joins by id + +- **WHEN** the hook mounts with a seat ticket in the session store +- **THEN** the fake client records exactly one `joinById(roomId, { ticket })` call and the status becomes `connected` + +#### Scenario: cold load resumes from the persisted token + +- **WHEN** the hook mounts with no ticket but a stored reconnection token for the room +- **THEN** the fake client records exactly one `reconnect(token)` call and the status becomes `connected` + +#### Scenario: no credentials means no session + +- **WHEN** the hook mounts with neither a ticket nor a stored token +- **THEN** `noSession` becomes true and the fake client records no connection calls + +#### Scenario: cold-reconnect rejection never retries + +- **WHEN** the initial `reconnect` rejects +- **THEN** the stored token is cleared, `noSession` becomes true, and no further `reconnect` calls occur as fake time advances + +### Requirement: The reconnect machine's drop/retry loop is unit-tested + +`useTableConnection` SHALL be covered for the in-table reconnect loop: a pre-completion drop transitions the store to `reconnecting` and clears any pending intent; retries use exponential backoff (base 1 s, doubling, capped at 4 s) with a single in-flight attempt; attempts stop and the state goes terminal `error` (token and handoff cleared) once the room's grace window (from synced metadata, less the safety margin) is exhausted; a successful reconnect re-persists the fresh token, resets the backoff, and returns the status to `connected`. + +#### Scenario: drop enters reconnecting and retries with backoff + +- **WHEN** a connected room fires `onLeave` before any `matchResult` view +- **THEN** the status becomes `reconnecting`, the pending intent is cleared, and successive `reconnect` attempts occur at fake-timer delays of 1 s, 2 s, 4 s, 4 s… + +#### Scenario: grace exhaustion is terminal + +- **WHEN** reconnect attempts keep failing until the metadata grace window (minus the margin) has elapsed +- **THEN** the status becomes `error`, the stored token is cleared, and no further attempts are scheduled + +#### Scenario: successful reconnect recovers cleanly + +- **WHEN** an attempt succeeds after earlier failures +- **THEN** the status returns to `connected`, the room's fresh reconnection token is persisted, and a subsequent drop restarts backoff from the 1 s base + +### Requirement: The reconnect machine's terminal transitions are unit-tested + +`useTableConnection` SHALL be covered for terminal behavior: a server close after a `matchResult`-bearing view sets status `complete` and clears both the stored token and the lobby handoff; unmount tears down fully (timers cancelled, room left) and clears the stored token only when a room was actually established (the React strict-mode guard). + +#### Scenario: completion is not a drop + +- **WHEN** a view containing `matchResult` arrives and the room then fires `onLeave` +- **THEN** the status becomes `complete`, no reconnect is attempted, and the stored token and handoff are cleared + +#### Scenario: strict-mode remount does not wipe the token + +- **WHEN** the hook unmounts before its initial async connect has resolved +- **THEN** a previously persisted reconnection token for the room is left intact diff --git a/openspec/changes/archive/2026-07-03-add-web-test-foundation/tasks.md b/openspec/changes/archive/2026-07-03-add-web-test-foundation/tasks.md new file mode 100644 index 0000000..6fb0834 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-web-test-foundation/tasks.md @@ -0,0 +1,33 @@ +# Tasks — Add Web Test Foundation + +## 1. Harness setup + +- [x] 1.1 Verify latest stable versions on the npm registry, add `@testing-library/react`, `@testing-library/jest-dom`, `jsdom`, and `@vitejs/plugin-react` to the pnpm catalog, and add them (plus `vitest: "catalog:"`) to `apps/web` devDependencies +- [x] 1.2 Create `apps/web/vitest.config.ts` — jsdom environment, `@vitejs/plugin-react`, `@/*` alias to the app root, setup file registered +- [x] 1.3 Create `apps/web/vitest.setup.ts` — load `@testing-library/jest-dom`; bridge `crypto.getRandomValues`/`crypto.randomUUID` from `node:crypto` if the jsdom environment lacks them +- [x] 1.4 Add `"test": "vitest run"` to `apps/web/package.json` and confirm `pnpm --filter @meldrank/web test` runs (zero tests initially) and `turbo run test` now includes web + +## 2. Render-model derivation tests + +- [x] 2.1 Export `buildRenderModel` and `deriveAvailableAction` from `apps/web/lib/table-store.tsx` (no behavior change) +- [x] 2.2 `lib/table-store.test.tsx`: table-driven `deriveAvailableAction` cases — phase→action map (Auction/DeclareTrump/TrickPlay/other), not-viewer's-turn, spectator (null viewer), pending lock +- [x] 2.3 Same file: `buildRenderModel` cases — reconnecting input lock, clock-deadline fallback chain (`clockState.deadline` → metadata `clockDeadline ≥ 0` → null), pre-first-view defaults (empty arrays, null public/lifecycle/matchResult) +- [x] 2.4 Store-surface tests via `TableStoreProvider` + `renderHook`: `setPending` clears `rejectReason`, `clearPending` re-enables, wholesale view/metadata replacement reflected by `useRenderModel`, hooks throw outside the provider + +## 3. Reconnect-machine test double + +- [x] 3.1 Create `apps/web/test/fake-colyseus.ts` — `FakeRoom` (captures `onMessage`/`onStateChange`/`onLeave`/`onError` handlers for test-side firing; records `send`/`leave`; settable `reconnectionToken`) and `FakeColyseusClient` (records `joinById`/`reconnect` calls; resolves/rejects per test script) +- [x] 3.2 Create the test harness wrapper: real `TableStoreProvider` + the Colyseus context carrying the fake client; helpers to seed the session store's seat ticket and `sessionStorage` reconnection token + +## 4. Reconnect-machine tests (`lib/use-table-connection.test.tsx`, fake timers throughout) + +- [x] 4.1 Cold-load decision table: ticket → single `joinById` + `connected`; stored token → single `reconnect` + `connected`; neither → `noSession`, no client calls; warm-join rejection → `error` + handoff cleared; cold-reconnect rejection → token cleared + `noSession`, no retry as time advances +- [x] 4.2 Drop/retry loop: pre-completion `onLeave` → `reconnecting` + pending cleared; backoff delays 1 s/2 s/4 s/4 s with a single in-flight attempt; grace exhaustion (metadata `reconnectGraceMs` minus margin) → terminal `error`, token cleared, no further attempts +- [x] 4.3 Recovery: successful reconnect → `connected`, fresh token persisted, backoff reset (next drop starts at 1 s) +- [x] 4.4 Terminals: `matchResult` view then `onLeave` → `complete`, token + handoff cleared, no reconnect; unmount before connect resolves leaves a pre-existing persisted token intact (strict-mode guard); unmount after connect cancels timers and leaves the room + +## 5. Validation and docs + +- [x] 5.1 Run the validate agent (lint, typecheck, test) across the workspace; confirm the web suite runs green under `turbo run test` +- [x] 5.2 Flip a derivation assertion locally to confirm a failing web test fails the turbo task, then restore it +- [x] 5.3 Update `docs/audit/00-overview.md` item 15: mark the unit-foundation half done, note the Playwright e2e smoke remains as the follow-up change diff --git a/openspec/specs/web-test-foundation/spec.md b/openspec/specs/web-test-foundation/spec.md new file mode 100644 index 0000000..a3b1ae6 --- /dev/null +++ b/openspec/specs/web-test-foundation/spec.md @@ -0,0 +1,125 @@ +# web-test-foundation Specification + +## Purpose + +Define the unit/component test harness for `apps/web` — how web tests run (Vitest, jsdom, testing-library), how they integrate with the monorepo `test` task, and the required coverage of the table reconnect machine and render-model derivation. + +## Requirements + +### Requirement: apps/web has a unit-test suite wired into the monorepo test task + +`apps/web` SHALL define a `test` script that runs its Vitest suite, so the workspace participates in `turbo run test` (and therefore the CI quality gate) like every other package. A failing web test MUST fail the monorepo test run. + +#### Scenario: turbo runs the web tests + +- **WHEN** `turbo run test` (or `pnpm --filter @meldrank/web test`) executes +- **THEN** the `apps/web` Vitest suite runs and its result is included in the task outcome + +#### Scenario: a failing web test fails the gate + +- **WHEN** any `apps/web` test fails +- **THEN** the `test` task exits non-zero and the CI quality gate fails + +### Requirement: Web tests are deterministic and isolated + +The web test suite SHALL run without network access, real WebSocket connections, or real-time waits: the Colyseus client is replaced by an injected test double at the existing `useColyseus` seam, and all timer/`Date` behavior runs under Vitest fake timers. Web-API gaps in the test environment that production code relies on (`sessionStorage`, `crypto.getRandomValues`, `crypto.randomUUID`) MUST be available or bridged in the shared test setup. + +#### Scenario: suite runs offline + +- **WHEN** the suite executes with no network available +- **THEN** every test passes or fails identically to a networked run + +#### Scenario: backoff timing is simulated + +- **WHEN** a test exercises reconnect backoff delays +- **THEN** it advances fake timers rather than waiting wall-clock time + +### Requirement: Render-model derivation is unit-tested + +The pure derivation in `apps/web/lib/table-store.tsx` (`buildRenderModel` and `deriveAvailableAction`, exported for tests) SHALL be covered by unit tests for: the phase→action map (Auction → `bid` with `currentHigh`, DeclareTrump → `declareTrump`, TrickPlay → `playCard`, any other phase → null), turn gating (not the viewer's turn, or spectator view → null), input locking (a pending intent or `reconnecting` status → null action), the clock-deadline fallback chain (`clockState.deadline`, else synced `clockDeadline` when ≥ 0, else null), and pre-first-view defaults (empty hand/seat arrays, null public state). + +#### Scenario: viewer on turn in auction + +- **WHEN** the view's phase is `Auction`, `seatToAct` equals the viewer, nothing is pending, and the status is `connected` +- **THEN** the derived `availableAction` is `{ kind: 'bid', currentHigh: }` + +#### Scenario: input locked while reconnecting + +- **WHEN** the same view is held but the status is `reconnecting` (or an intent correlation id is pending) +- **THEN** the derived `availableAction` is null + +#### Scenario: clock deadline falls back to synced metadata + +- **WHEN** no `clockState` payload has arrived and the synced metadata carries `clockDeadline ≥ 0` +- **THEN** the render model's `clockDeadline` is the metadata value; and when the metadata value is `-1` with no `clockState`, it is null + +### Requirement: Table-store behaviors are tested through the public store surface + +Store wiring that is not pure derivation SHALL be tested through `TableStoreProvider` and the public hooks: `setPending` clearing a prior `rejectReason`, `clearPending` re-enabling input, wholesale `view`/`metadata` replacement, and the out-of-provider guard throwing. + +#### Scenario: a fresh intent clears the stale rejection + +- **WHEN** a rejection reason is set and a new intent is marked pending +- **THEN** the store's `rejectReason` is null while the new correlation id is pending + +#### Scenario: hooks outside the provider throw + +- **WHEN** `useTableStore` or `useTableStoreApi` is used outside `TableStoreProvider` +- **THEN** it throws the explicit provider error + +### Requirement: The reconnect machine's cold-load decision table is unit-tested + +`useTableConnection` SHALL be covered for its mount decision (design D3 of table-resilience-web): a seat ticket present → `joinById` with the ticket; no ticket but a persisted reconnection token for the room → `client.reconnect(token)`; neither credential → `noSession` true and no connection attempt; a warm-join rejection → terminal `error` with the handoff cleared; a cold-reconnect rejection → the stored token cleared, `noSession` true, and no retry. + +#### Scenario: warm handoff joins by id + +- **WHEN** the hook mounts with a seat ticket in the session store +- **THEN** the fake client records exactly one `joinById(roomId, { ticket })` call and the status becomes `connected` + +#### Scenario: cold load resumes from the persisted token + +- **WHEN** the hook mounts with no ticket but a stored reconnection token for the room +- **THEN** the fake client records exactly one `reconnect(token)` call and the status becomes `connected` + +#### Scenario: no credentials means no session + +- **WHEN** the hook mounts with neither a ticket nor a stored token +- **THEN** `noSession` becomes true and the fake client records no connection calls + +#### Scenario: cold-reconnect rejection never retries + +- **WHEN** the initial `reconnect` rejects +- **THEN** the stored token is cleared, `noSession` becomes true, and no further `reconnect` calls occur as fake time advances + +### Requirement: The reconnect machine's drop/retry loop is unit-tested + +`useTableConnection` SHALL be covered for the in-table reconnect loop: a pre-completion drop transitions the store to `reconnecting` and clears any pending intent; retries use exponential backoff (base 1 s, doubling, capped at 4 s) with a single in-flight attempt; attempts stop and the state goes terminal `error` (token and handoff cleared) once the room's grace window (from synced metadata, less the safety margin) is exhausted; a successful reconnect re-persists the fresh token, resets the backoff, and returns the status to `connected`. + +#### Scenario: drop enters reconnecting and retries with backoff + +- **WHEN** a connected room fires `onLeave` before any `matchResult` view +- **THEN** the status becomes `reconnecting`, the pending intent is cleared, and successive `reconnect` attempts occur at fake-timer delays of 1 s, 2 s, 4 s, 4 s… + +#### Scenario: grace exhaustion is terminal + +- **WHEN** reconnect attempts keep failing until the metadata grace window (minus the margin) has elapsed +- **THEN** the status becomes `error`, the stored token is cleared, and no further attempts are scheduled + +#### Scenario: successful reconnect recovers cleanly + +- **WHEN** an attempt succeeds after earlier failures +- **THEN** the status returns to `connected`, the room's fresh reconnection token is persisted, and a subsequent drop restarts backoff from the 1 s base + +### Requirement: The reconnect machine's terminal transitions are unit-tested + +`useTableConnection` SHALL be covered for terminal behavior: a server close after a `matchResult`-bearing view sets status `complete` and clears both the stored token and the lobby handoff; unmount tears down fully (timers cancelled, room left) and clears the stored token only when a room was actually established (the React strict-mode guard). + +#### Scenario: completion is not a drop + +- **WHEN** a view containing `matchResult` arrives and the room then fires `onLeave` +- **THEN** the status becomes `complete`, no reconnect is attempted, and the stored token and handoff are cleared + +#### Scenario: strict-mode remount does not wipe the token + +- **WHEN** the hook unmounts before its initial async connect has resolved +- **THEN** a previously persisted reconnection token for the room is left intact diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0f7c2e..ebd5b9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,15 +6,27 @@ settings: catalogs: default: + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2 '@types/node': specifier: ^22.20.0 version: 22.20.0 + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3 drizzle-orm: specifier: ^0.45.2 version: 0.45.2 eslint: specifier: ^10.5.0 version: 10.5.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 tsup: specifier: ^8.5.1 version: 8.5.1 @@ -54,7 +66,7 @@ importers: version: 22.20.0 '@vitest/eslint-plugin': specifier: ^1.6.20 - version: 1.6.20(@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))) + version: 1.6.20(@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.9(@types/node@22.20.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))) drizzle-kit: specifier: ^0.31.10 version: 0.31.10 @@ -160,7 +172,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.4)) + version: 4.1.9(@types/node@22.20.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.4)) apps/bots: dependencies: @@ -200,7 +212,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) + version: 4.1.9(@types/node@22.20.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) apps/match: dependencies: @@ -258,7 +270,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) + version: 4.1.9(@types/node@22.20.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) apps/web: dependencies: @@ -332,6 +344,12 @@ importers: '@tanstack/react-query-devtools': specifier: ^5.101.2 version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7) + '@testing-library/jest-dom': + specifier: 'catalog:' + version: 6.9.1 + '@testing-library/react': + specifier: 'catalog:' + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/node': specifier: 'catalog:' version: 22.20.0 @@ -341,12 +359,18 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 6.0.3(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) concurrently: specifier: ^10.0.3 version: 10.0.3 eslint: specifier: 'catalog:' version: 10.5.0(jiti@2.7.0) + jsdom: + specifier: 'catalog:' + version: 29.1.1(@noble/hashes@2.2.0) tailwindcss: specifier: ^4.3.1 version: 4.3.1 @@ -356,6 +380,9 @@ importers: typescript: specifier: 'catalog:' version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.9(@types/node@22.20.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) packages/bots: dependencies: @@ -377,7 +404,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) + version: 4.1.9(@types/node@22.20.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) packages/engine: dependencies: @@ -396,7 +423,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) + version: 4.1.9(@types/node@22.20.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) packages/fairness: dependencies: @@ -424,7 +451,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) + version: 4.1.9(@types/node@22.20.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) packages/shared: dependencies: @@ -461,14 +488,32 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) + version: 4.1.9(@types/node@22.20.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -573,6 +618,10 @@ packages: '@better-fetch/fetch@1.3.1': resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@clerk/backend@3.8.4': resolution: {integrity: sha512-3G+kEu8kalqQokJ/n0IynhBh2i6TtiilRzuAg5UWMburkK658/elrG0Uqsapd5yKhmkNuvizHwOPWwKAMxP2EQ==} engines: {node: '>=20.9.0'} @@ -715,6 +764,42 @@ packages: express: optional: true + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6': + resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1387,6 +1472,15 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -2088,6 +2182,29 @@ packages: peerDependencies: react: ^18 || ^19 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@trpc/client@11.18.0': resolution: {integrity: sha512-wOqeg3Fvl25V1ZisQhUD3K8G60ZJDlSGJNSyeXrLH24xAo5w6GSR2Kzb1cSNY9Y+IQ2YZvYGZstBU+V/ulo/ow==} hasBin: true @@ -2144,6 +2261,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2372,6 +2492,19 @@ packages: peerDependencies: valibot: ^1.4.0 + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + '@vitest/eslint-plugin@1.6.20': resolution: {integrity: sha512-xRwWHFG0Utp6hXtbGiWk4VdKXCGdExD8kbWrrmFEiG5dk8anOJ+vbWbeOa8EbkocKQRTsx7JAWETccZiBgFp/Q==} engines: {node: '>=18'} @@ -2434,6 +2567,10 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -2442,6 +2579,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -2456,6 +2597,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2537,6 +2681,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2746,12 +2893,23 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2801,6 +2959,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2832,6 +2993,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -2965,6 +3132,10 @@ packages: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3397,6 +3568,10 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -3522,6 +3697,9 @@ packages: resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -3594,6 +3772,15 @@ packages: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3772,6 +3959,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3784,6 +3975,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3799,6 +3994,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mdn-data@2.28.1: resolution: {integrity: sha512-U9w+PzSZ00Z5m9rZ5ARVFL5xOfuCHdKYi/1RRwDCJsboFgJDNT3zT6PIPD7mZQYaQLhsZM3GfDRgSMRHhSmVng==} @@ -4057,6 +4255,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -4145,6 +4346,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} @@ -4196,6 +4401,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -4251,6 +4459,10 @@ packages: resolution: {integrity: sha512-85THTg1RgOYtqQw42JON6AqvHLptlj1biw265Tsq4fD4cPdUvhDB2Qh9NTv17yCD322ROuO9aOmpc4GyayGVBA==} engines: {node: '>=8.0.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + require-in-the-middle@5.2.0: resolution: {integrity: sha512-efCx3b+0Z69/LGJmm9Yvi4cqEdxnoGnxYxGxBghkkTTFeXRtTCmmhO0AnAfHz59k957uTSuy8WaHqOs8wbYUWg==} engines: {node: '>=6'} @@ -4328,6 +4540,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -4556,6 +4772,9 @@ packages: svix@1.96.1: resolution: {integrity: sha512-l+GyPS6gjL0okiXplLazEY2GbBsv1jZ4UIwtjp553YkDDv635xMKbM5sABpIdiWy1BYxgyV8M6tHeTwY0gyXlQ==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.13: resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} @@ -4608,6 +4827,13 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.5: + resolution: {integrity: sha512-pGrwzZDvPwKe+7NNUqAunb6rqTfynr0VOUhCMdqbu5xlvNiszsAJygRzwvpVycdzejlbpY+SWJOn+s75Og7FEA==} + + tldts@7.4.5: + resolution: {integrity: sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4616,6 +4842,14 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -4743,6 +4977,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -4867,6 +5105,22 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -4916,6 +5170,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4971,8 +5232,30 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -5102,6 +5385,10 @@ snapshots: '@better-fetch/fetch@1.3.1': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@clerk/backend@3.8.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@clerk/shared': 4.22.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -5288,6 +5575,30 @@ snapshots: - bufferutil - utf-8-validate + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.10.0': @@ -5671,6 +5982,10 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)': + optionalDependencies: + '@noble/hashes': 2.2.0 + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -6148,6 +6463,36 @@ snapshots: '@tanstack/query-core': 5.101.2 react: 19.2.7 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@trpc/server': 11.18.0(typescript@6.0.3) @@ -6188,6 +6533,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -6397,7 +6744,12 @@ snapshots: dependencies: valibot: 1.4.1(typescript@6.0.3) - '@vitest/eslint-plugin@1.6.20(@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)))': + '@vitejs/plugin-react@6.0.3(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4) + + '@vitest/eslint-plugin@1.6.20(@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.9(@types/node@22.20.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)))': dependencies: '@typescript-eslint/scope-manager': 8.62.0 '@typescript-eslint/utils': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -6405,7 +6757,7 @@ snapshots: optionalDependencies: '@typescript-eslint/eslint-plugin': 8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) typescript: 6.0.3 - vitest: 4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) + vitest: 4.1.9(@types/node@22.20.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) transitivePeerDependencies: - supports-color @@ -6476,12 +6828,16 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -6493,6 +6849,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -6588,6 +6948,10 @@ snapshots: baseline-browser-mapping@2.10.40: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} bn.js@4.12.3: @@ -6795,10 +7159,24 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} + data-urls@7.0.0(@noble/hashes@2.2.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -6838,6 +7216,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js@10.6.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -6864,6 +7244,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dotenv@17.4.2: {} drizzle-kit@0.31.10: @@ -6918,6 +7302,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.0.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -7624,6 +8010,12 @@ snapshots: dependencies: lru-cache: 6.0.0 + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -7756,6 +8148,8 @@ snapshots: is-plain-obj@1.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-regex@1.2.1: @@ -7824,6 +8218,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1(@noble/hashes@2.2.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.2.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -7974,6 +8394,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.5.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7986,6 +8408,8 @@ snapshots: dependencies: react: 19.2.7 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7996,6 +8420,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + mdn-data@2.28.1: {} media-typer@1.1.0: {} @@ -8266,6 +8692,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -8348,6 +8778,12 @@ snapshots: prettier@3.8.4: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + process-warning@5.0.0: {} prop-types@15.8.1: @@ -8397,6 +8833,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react@19.2.7: {} read-pkg-up@7.0.1: @@ -8461,6 +8899,8 @@ snapshots: qs: 6.15.3 uuid: 14.0.1 + require-from-string@2.0.2: {} + require-in-the-middle@5.2.0: dependencies: debug: 4.4.3 @@ -8592,6 +9032,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} secure-json-parse@4.1.0: {} @@ -8882,6 +9326,8 @@ snapshots: dependencies: standardwebhooks: 1.0.0 + symbol-tree@3.2.4: {} + synckit@0.11.13: dependencies: '@pkgr/core': 0.3.6 @@ -8919,12 +9365,26 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.5: {} + + tldts@7.4.5: + dependencies: + tldts-core: 7.4.5 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.5 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} trim-newlines@3.0.1: {} @@ -9076,6 +9536,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.28.0: {} + unpipe@1.0.0: {} unrs-resolver@1.12.2: @@ -9160,7 +9622,7 @@ snapshots: jiti: 2.7.0 tsx: 4.22.4 - vitest@4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.4)): + vitest@4.1.9(@types/node@22.20.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.4)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@22.20.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.22.4)) @@ -9184,10 +9646,11 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.0 + jsdom: 29.1.1(@noble/hashes@2.2.0) transitivePeerDependencies: - msw - vitest@4.1.9(@types/node@22.20.0)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)): + vitest@4.1.9(@types/node@22.20.0)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) @@ -9211,9 +9674,26 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.0 + jsdom: 29.1.1(@noble/hashes@2.2.0) transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -9276,6 +9756,10 @@ snapshots: ws@8.21.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 43cd05b..480b4b9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,9 +6,13 @@ packages: # 17), plus drizzle-orm, which had drifted across three manifests. Reference # with `"": "catalog:"` in package.json; bump versions only here. catalog: + '@testing-library/jest-dom': ^6.9.1 + '@testing-library/react': ^16.3.2 '@types/node': ^22.20.0 + '@vitejs/plugin-react': ^6.0.3 drizzle-orm: ^0.45.2 eslint: ^10.5.0 + jsdom: ^29.1.1 tsup: ^8.5.1 tsx: ^4.22.4 typescript: ^6.0.3 From ac5de027bc7521d3de218714fdeafc1a3d786163 Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Fri, 3 Jul 2026 01:33:46 -0400 Subject: [PATCH 06/16] test(web): add playwright e2e smoke driving the full stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit item 15's second half (change add-web-e2e-smoke): a Playwright suite in apps/web/e2e/ proving the signed-in happy path end to end — hall → Quick Play → live table → shuffle handshake → a full hand played to its conclusion — against the real web+api+match+bots stack. - Clerk auth via @clerk/testing: each run creates an ephemeral test user (Backend API), signs it in server-side in global setup, persists storage state, and deletes the user in a teardown project; stale orphans are swept at the next run's setup. Per-run identities avoid the leftover live-match contention a shared user hits (bots play an abandoned match out for ~8 minutes). - webServer boots/attaches to the four services; new unauthenticated GET /healthz liveness routes on match and bots (api reuses its tRPC health query; web probes /sign-in, the one unprotected route). - The hand loop is strategy-free: pass every auction turn, declare an accepted suit if dealer-forced in (all-pass rule), click cards until accepted on trick-play turns (reject+resync makes wrong clicks harmless). - New non-blocking e2e.yml workflow on PRs (E2E_* repository secrets → root .env, the contract the services already read); not in the main ruleset's required checks — promotion is a later ruleset edit. - vitest excludes e2e/** so the unit gate is untouched. Co-Authored-By: Claude Fable 5 --- .github/workflows/e2e.yml | 105 ++++++++++++ .gitignore | 3 + apps/bots/src/http.test.ts | 9 + apps/bots/src/http.ts | 11 +- apps/match/src/gateway/health.ts | 25 +++ apps/match/src/index.ts | 11 +- apps/web/components/table/table-view.tsx | 8 +- apps/web/e2e/clerk-user.ts | 100 +++++++++++ apps/web/e2e/global.setup.ts | 49 ++++++ apps/web/e2e/global.teardown.ts | 22 +++ apps/web/e2e/smoke.spec.ts | 157 ++++++++++++++++++ apps/web/package.json | 3 + apps/web/playwright.config.ts | 89 ++++++++++ apps/web/vitest.config.ts | 5 +- docs/audit/00-overview.md | 2 +- eslint.config.mjs | 3 + infra/README.md | 21 ++- .../changes/add-web-e2e-smoke/.openspec.yaml | 2 + openspec/changes/add-web-e2e-smoke/design.md | 66 ++++++++ .../changes/add-web-e2e-smoke/proposal.md | 31 ++++ .../specs/web-e2e-smoke/spec.md | 74 +++++++++ openspec/changes/add-web-e2e-smoke/tasks.md | 35 ++++ packages/shared/src/index.ts | 8 + pnpm-lock.yaml | 134 +++++++++++++-- pnpm-workspace.yaml | 2 + 25 files changed, 955 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 apps/match/src/gateway/health.ts create mode 100644 apps/web/e2e/clerk-user.ts create mode 100644 apps/web/e2e/global.setup.ts create mode 100644 apps/web/e2e/global.teardown.ts create mode 100644 apps/web/e2e/smoke.spec.ts create mode 100644 apps/web/playwright.config.ts create mode 100644 openspec/changes/add-web-e2e-smoke/.openspec.yaml create mode 100644 openspec/changes/add-web-e2e-smoke/design.md create mode 100644 openspec/changes/add-web-e2e-smoke/proposal.md create mode 100644 openspec/changes/add-web-e2e-smoke/specs/web-e2e-smoke/spec.md create mode 100644 openspec/changes/add-web-e2e-smoke/tasks.md diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..e1582d9 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,105 @@ +name: E2E + +# The browser smoke (capability `web-e2e-smoke`): Playwright boots the real +# four-service stack in the runner against the dev cloud resources and drives +# the signed-in happy path. Deliberately NOT part of the quality gate and NOT +# a required check at introduction (design D6) — promotion to required is a +# later `main`-ruleset edit, no workflow change. +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: Browser smoke + runs-on: ubuntu-latest + # Stack boot + (test ceiling 10 min) × (1 + 1 retry), plus browser install. + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + # Version is taken from the root package.json "packageManager" field. + uses: pnpm/action-setup@v4 + + - name: Setup Node 22 (with pnpm store cache) + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright chromium + run: pnpm --filter @meldrank/web exec playwright install chromium --with-deps + + # The services read the repo-root `.env` themselves (loadLocalEnv) and + # Next reads `apps/web/.env.local` — materialize both from secrets, the + # exact contract a developer machine uses (design D2). `PORT=` stays + # empty so each service binds its own default. The localhost URLs and + # the publishable key (a repository *variable* — it is public by design) + # are not secrets. + - name: Materialize env files from secrets + run: | + cat > .env < apps/web/.env.local < { expect(routeRecruitment(deps, 'POST', '/internal/unknown', SECRET, {}).status).toBe(404); expect(routeRecruitment(deps, 'GET', INTERNAL_RECRUIT_PATH, SECRET, {}).status).toBe(404); }); + + it('answers the unauthenticated liveness probe', () => { + const deps = depsWith(); + expect(routeRecruitment(deps, 'GET', HEALTH_PATH, undefined, undefined)).toEqual({ + status: 200, + body: { service: 'bots', ok: true }, + }); + }); }); diff --git a/apps/bots/src/http.ts b/apps/bots/src/http.ts index 83ac2ce..2c2b5c1 100644 --- a/apps/bots/src/http.ts +++ b/apps/bots/src/http.ts @@ -1,5 +1,6 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import { + HEALTH_PATH, INTERNAL_PREWARM_PATH, INTERNAL_RECRUIT_PATH, INTERNAL_RELEASE_PATH, @@ -7,6 +8,7 @@ import { PrewarmCommandSchema, RecruitCommandSchema, ReleaseCommandSchema, + healthy, } from '@meldrank/shared'; import type { Logger } from '@meldrank/shared/server'; import type { SessionRegistry } from './session'; @@ -77,8 +79,15 @@ export function handleRelease(deps: RecruitmentDeps, providedSecret: string | un return { status: 200, body: { ok: true } }; } -/** Route a parsed request to its command handler; `404` for anything else. */ +/** + * Route a parsed request to its command handler; `404` for anything else. The one + * non-command route is the unauthenticated `GET /healthz` liveness probe — no state, + * no secret, used as the readiness URL by the e2e stack boot. + */ export function routeRecruitment(deps: RecruitmentDeps, method: string | undefined, path: string | undefined, providedSecret: string | undefined, rawBody: unknown): RecruitmentResult { + if (method === 'GET' && path === HEALTH_PATH) { + return { status: 200, body: healthy('bots') }; + } if (method !== 'POST') { return { status: 404, body: { error: 'not found' } }; } diff --git a/apps/match/src/gateway/health.ts b/apps/match/src/gateway/health.ts new file mode 100644 index 0000000..3c09892 --- /dev/null +++ b/apps/match/src/gateway/health.ts @@ -0,0 +1,25 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { HEALTH_PATH, healthy } from '@meldrank/shared'; + +export { HEALTH_PATH }; + +/** + * The unauthenticated `GET /healthz` liveness route (capability `web-e2e-smoke`): + * no state, no secret — it exists so tooling that boots the stack (the Playwright + * `webServer` entries) has a readiness URL on the Colyseus HTTP server, whose + * matchmaking surface is POST-only. Mounted beside the spawn gateway on the same + * express app. + */ + +/** The minimal express-app surface the health route needs (mirrors `RouteRegistrar`). */ +export interface HealthRouteRegistrar { + get(path: string, handler: (req: IncomingMessage, res: ServerResponse) => void): unknown; +} + +/** Mount the liveness route on a Colyseus/express app. */ +export function mountHealthRoute(app: HealthRouteRegistrar): void { + app.get(HEALTH_PATH, (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(healthy('match'))); + }); +} diff --git a/apps/match/src/index.ts b/apps/match/src/index.ts index 404abb9..2e08121 100644 --- a/apps/match/src/index.ts +++ b/apps/match/src/index.ts @@ -4,6 +4,7 @@ import { createDb, createLogger, createRedis, loadLocalEnv, loadMatchEnv } from import { MatchRoom } from './colyseus/matchRoom'; import { createHttpBotRecruiter } from './recruit/client'; import { INTERNAL_SPAWN_PATH, mountSpawnRoute, type RouteRegistrar, type SpawnGatewayDeps } from './gateway/spawn'; +import { mountHealthRoute, type HealthRouteRegistrar } from './gateway/health'; /** * Realtime Match Service boot. The server hosts the authoritative {@link MatchRoom} @@ -49,9 +50,13 @@ function createGameServer(): Server { const recruiter = createHttpBotRecruiter({ baseUrl: env.BOTS_INTERNAL_URL, secret: env.INTERNAL_BOTS_SECRET }); const server = new Server({ - // Mount the internal spawn route on Colyseus' own HTTP server via its express app, - // narrowed structurally to the {@link RouteRegistrar} surface the route needs. - express: (app: RouteRegistrar) => mountSpawnRoute(app, spawnDeps), + // Mount the internal spawn route and the unauthenticated liveness route on + // Colyseus' own HTTP server via its express app, narrowed structurally to the + // registrar surfaces the routes need. + express: (app: RouteRegistrar & HealthRouteRegistrar) => { + mountSpawnRoute(app, spawnDeps); + mountHealthRoute(app); + }, }); // Inject the base logger into the room definition (design D3); each room derives its // own `{ roomId, traceId }` child in `onCreate`. diff --git a/apps/web/components/table/table-view.tsx b/apps/web/components/table/table-view.tsx index d3bf4aa..ced4c2e 100644 --- a/apps/web/components/table/table-view.tsx +++ b/apps/web/components/table/table-view.tsx @@ -77,11 +77,15 @@ export function TableView({ model, submitIntent }: { model: RenderModel; submitI {/* Running scorepad. */} - {/* Own seat — hand + the legal action for this phase. */} + {/* Own seat — hand + the legal action for this phase. The data-testids are + render-only hooks for the e2e smoke (capability `web-e2e-smoke`): the + hand's cards have no stable accessible name (rank+glyph text changes + every deal), so the spec scopes card clicks to this container. */}
{viewerSeat === null ? 'Spectating' : `You — seat ${viewerSeat}`} @@ -98,7 +102,7 @@ export function TableView({ model, submitIntent }: { model: RenderModel; submitI )}
-
+
{model.ownHand.length === 0 ? (

No cards in hand.

) : ( diff --git a/apps/web/e2e/clerk-user.ts b/apps/web/e2e/clerk-user.ts new file mode 100644 index 0000000..f065d70 --- /dev/null +++ b/apps/web/e2e/clerk-user.ts @@ -0,0 +1,100 @@ +import { randomBytes } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +/** + * Ephemeral e2e users (capability `web-e2e-smoke`): each run creates its own + * Clerk user in the dev instance via the Backend API and deletes it in global + * teardown. A shared dedicated user proved unworkable — the smoke abandons its + * match after one hand and the bots play it out for ~8 more minutes, during + * which that user's hall offers only `Rejoin match`, so consecutive (or + * concurrent-PR) runs stalled behind each other. Per-run identities remove the + * contention entirely. + * + * Raw `fetch` against the Backend API keeps this dependency-free: the only + * credential is CLERK_SECRET_KEY, which the suite already requires. + */ + +const BAPI_URL = 'https://api.clerk.com/v1'; + +/** + * The marker every suite-owned user carries in its email. `+clerk_test` makes + * Clerk treat the address as a test email; the sweep and teardown refuse to + * touch any user without this prefix. + */ +export const EPHEMERAL_EMAIL_PREFIX = 'e2e-smoke+clerk_test'; + +/** Where global setup records the created user for teardown to delete. */ +export const USER_META_PATH = fileURLToPath(new URL('./.auth/ephemeral-user.json', import.meta.url)); + +/** A crashed run never reaches teardown — the next run's sweep deletes its user once it is safely stale. */ +const STALE_USER_AGE_MS = 30 * 60 * 1000; + +export interface EphemeralUser { + readonly emailAddress: string; + readonly id: string; +} + +/** Create this run's user. The dev instance requires username+password; both are throwaway (sign-in is a server-minted token). */ +export async function createEphemeralUser(): Promise { + const runId = `${Date.now().toString(36)}${randomBytes(4).toString('hex')}`; + const emailAddress = `${EPHEMERAL_EMAIL_PREFIX}-${runId}@meldrank.dev`; + const created = (await bapi('/users', { + body: JSON.stringify({ + email_address: [emailAddress], + first_name: 'E2E', + last_name: 'Smoke', + password: randomBytes(24).toString('base64'), + username: `e2e_smoke_${runId}`, + }), + method: 'POST', + })) as { id: string }; + return { emailAddress, id: created.id }; +} + +/** Delete a suite-owned user; a 404 (already gone) is fine. */ +export async function deleteEphemeralUser(id: string): Promise { + const res = await fetch(`${BAPI_URL}/users/${id}`, { + headers: { Authorization: `Bearer ${secretKey()}` }, + method: 'DELETE', + }); + if (!res.ok && res.status !== 404) { + throw new Error(`Clerk Backend API DELETE /users/${id} failed (${res.status}): ${await res.text()}`); + } +} + +/** + * Delete leftovers from runs that died before teardown. Only users carrying + * the marker email prefix AND older than {@link STALE_USER_AGE_MS} are touched + * — young marked users may belong to a concurrently running job. + */ +export async function sweepStaleEphemeralUsers(): Promise { + const users = (await bapi(`/users?query=${encodeURIComponent(EPHEMERAL_EMAIL_PREFIX)}&limit=100`)) as readonly { + created_at: number; + email_addresses: readonly { email_address: string }[]; + id: string; + }[]; + const cutoff = Date.now() - STALE_USER_AGE_MS; + const stale = users.filter( + (u) => u.created_at < cutoff && u.email_addresses.some((e) => e.email_address.startsWith(EPHEMERAL_EMAIL_PREFIX)), + ); + await Promise.all(stale.map((u) => deleteEphemeralUser(u.id))); +} + +async function bapi(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BAPI_URL}${path}`, { + ...init, + headers: { Authorization: `Bearer ${secretKey()}`, 'content-type': 'application/json', ...init?.headers }, + }); + if (!res.ok) { + throw new Error(`Clerk Backend API ${init?.method ?? 'GET'} ${path} failed (${res.status}): ${await res.text()}`); + } + return res.json(); +} + +function secretKey(): string { + const key = process.env.CLERK_SECRET_KEY; + if (!key) { + throw new Error('CLERK_SECRET_KEY is not set — the e2e suite needs it in the root .env (locally) or CI secrets.'); + } + return key; +} diff --git a/apps/web/e2e/global.setup.ts b/apps/web/e2e/global.setup.ts new file mode 100644 index 0000000..273ae1d --- /dev/null +++ b/apps/web/e2e/global.setup.ts @@ -0,0 +1,49 @@ +import { clerk, clerkSetup } from '@clerk/testing/playwright'; +import { expect, test as setup } from '@playwright/test'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { STORAGE_STATE } from '../playwright.config'; +import { createEphemeralUser, sweepStaleEphemeralUsers, USER_META_PATH } from './clerk-user'; + +/** + * Playwright global setup (design D3, capability `web-e2e-smoke`): obtain a + * Clerk testing token for the dev instance, create this run's ephemeral test + * user, sign it in once, and persist the session as storage state for the + * spec project. Auth failures — a missing/invalid CLERK_SECRET_KEY, or user + * creation/sign-in rejected by Clerk — throw HERE, before any spec runs, so a + * red run is auth-attributable rather than a timeout inside the smoke. + */ + +setup.describe.configure({ mode: 'serial' }); + +setup('obtain a Clerk testing token', async () => { + // Reads CLERK_SECRET_KEY (hydrated from the root `.env` by the config, or + // real env in CI) and registers the bot-protection-bypass testing token. + await clerkSetup(); +}); + +setup('create the ephemeral e2e user and sign it in', async ({ page }) => { + // Clean up users orphaned by runs that died before their teardown. + await sweepStaleEphemeralUsers(); + + // A fresh user per run: no leftover live match, no contention with + // concurrent CI runs. Recorded to disk so global teardown can delete it. + const user = await createEphemeralUser(); + await mkdir(dirname(USER_META_PATH), { recursive: true }); + await writeFile(USER_META_PATH, JSON.stringify(user)); + + // `clerk.signIn` needs a page with ClerkJS loaded; `/sign-in` is the app's + // one unprotected route (the middleware bounces everything else there). The + // sign-in itself is a server-minted token via CLERK_SECRET_KEY — no password + // UI is driven. + await page.goto('/sign-in'); + await clerk.signIn({ emailAddress: user.emailAddress, page }); + + // Prove the session end to end: the protected hall must render its + // signed-in header before the state is worth persisting. + await page.goto('/'); + await expect(page.getByText('Signed in as')).toBeVisible({ timeout: 60_000 }); + + await page.context().storageState({ path: STORAGE_STATE }); +}); diff --git a/apps/web/e2e/global.teardown.ts b/apps/web/e2e/global.teardown.ts new file mode 100644 index 0000000..d9b0edc --- /dev/null +++ b/apps/web/e2e/global.teardown.ts @@ -0,0 +1,22 @@ +import { test as teardown } from '@playwright/test'; +import { readFile, rm } from 'node:fs/promises'; + +import { deleteEphemeralUser, type EphemeralUser, USER_META_PATH } from './clerk-user'; + +/** + * Teardown project (capability `web-e2e-smoke`): delete this run's ephemeral + * Clerk user. Runs as a worker-process teardown (not the config process's + * `globalTeardown`, whose exit-time fetch tripped a libuv assertion on + * Windows). Best-effort — a run killed before teardown leaves its user + * behind, and the next run's stale-user sweep collects it. + */ +teardown('delete the ephemeral e2e user', async () => { + let user: EphemeralUser; + try { + user = JSON.parse(await readFile(USER_META_PATH, 'utf8')) as EphemeralUser; + } catch { + return; // setup never created a user this run + } + await deleteEphemeralUser(user.id); + await rm(USER_META_PATH, { force: true }); +}); diff --git a/apps/web/e2e/smoke.spec.ts b/apps/web/e2e/smoke.spec.ts new file mode 100644 index 0000000..cd77d19 --- /dev/null +++ b/apps/web/e2e/smoke.spec.ts @@ -0,0 +1,157 @@ +import { expect, type Page, test } from '@playwright/test'; + +/** + * The e2e smoke (capability `web-e2e-smoke`): one signed-in browser proving the + * product happy path against the real four-service stack — hall renders, Quick + * Play seats the player at a live table, the shuffle handshake completes (own + * cards render), and a full hand plays to its conclusion. + * + * Hand-completion strategy (design D4): the spec encodes no pinochle strategy. + * On auction turns it always passes; if the all-pass rule forces it in as + * dealer it declares any accepted suit; on trick-play turns it clicks own-hand + * cards until the server accepts one — the UI's reject+resync behavior makes + * wrong clicks harmless. Every step keys on + * observable state (never sleeps-as-synchronization); timeouts are generous + * because bots think 400–1200 ms per move and a full hand is ~55 moves. + */ + +/** How long the hall/table surfaces get to render (covers Next dev-compile). */ +const SURFACE_TIMEOUT = 60_000; +/** Ceiling for the whole bot-paced hand (~55 moves at up to 1200 ms each). */ +const HAND_TIMEOUT = 180_000; +/** Poll cadence of the hand loop — reactions, not synchronization. */ +const POLL_MS = 400; + +/** Error surfaces that must never render during the smoke (spec scenario 3). */ +const ERROR_TEXTS = ['Connection error', 'No table to join', 'Could not load your identity']; + +test('signed-in happy path: hall → Quick Play → live table → full hand concludes', async ({ page }) => { + await test.step('hall renders for the signed-in player', async () => { + await page.goto('/'); + await expect(page.getByText('Signed in as')).toBeVisible({ timeout: SURFACE_TIMEOUT }); + }); + + await test.step('Quick Play lands on the live table', async () => { + // Each run signs in as its own ephemeral user, so a live match can never + // be left over from an earlier run — the hall must offer Quick Play (it + // would offer `Rejoin match` instead if this assumption ever broke). + await expect(page.getByRole('button', { name: 'Quick Play' })).toBeVisible({ timeout: SURFACE_TIMEOUT }); + await page.getByRole('button', { name: 'Quick Play' }).click(); + await page.waitForURL(/\/table\/(?!pending\/)[^/]+$/, { timeout: SURFACE_TIMEOUT }); + }); + + await test.step('shuffle handshake completes: own seat and hand render', async () => { + // The own-seat section only mounts once the first authoritative view + // arrives, which itself requires the commit→contribute handshake to have + // completed — so this single assertion covers the whole readiness chain. + await expect(page.getByTestId('own-seat').getByText(/You — seat \d/)).toBeVisible({ timeout: SURFACE_TIMEOUT }); + await expect(ownHandCards(page).first()).toBeVisible({ timeout: SURFACE_TIMEOUT }); + }); + + await test.step('a full hand plays to its conclusion', async () => { + const deadline = Date.now() + HAND_TIMEOUT; + let sawTrickPlay = false; + let playAttempt = 0; + let declareAttempt = 0; + + while (Date.now() < deadline) { + await expectNoErrorState(page); + + const phase = await readPhase(page); + // Concluded: the scoring surface, or (once trick play has happened) the + // next hand's early phases — HandScoring can be brief, so a fresh deal is + // an equally valid "the hand finished" signal. + if (phase === 'HandScoring' || phase === 'MatchComplete') return; + if (sawTrickPlay && (phase === 'Dealing' || phase === 'Auction' || phase === 'Melding')) return; + if (phase === 'TrickPlay') sawTrickPlay = true; + + // The human passes every auction turn, but the variant's all-pass rule + // is dealer-forced-minimum: when every seat passes, the DEALER wins the + // contract at the minimum bid — so on hands the human deals it can be + // forced in and must declare trump (D4). Same strategy-free approach as + // cards: click suit buttons until the engine accepts one. + if (await page.getByText('Declare trump:').isVisible()) { + await tryDeclaringTrump(page, declareAttempt); + declareAttempt += 1; + } else if (await page.getByRole('button', { name: 'Pass' }).isVisible()) { + await passAuctionTurn(page); + } else if (await page.getByText('Select a card from your hand to play.').isVisible()) { + // One click attempt per poll tick, cycling through the hand across + // ticks; the hand deadline above is the only failure ceiling. + await tryPlayingCard(page, playAttempt); + playAttempt += 1; + } + + await page.waitForTimeout(POLL_MS); + } + throw new Error(`hand did not conclude within ${HAND_TIMEOUT} ms (last phase: ${await readPhase(page)})`); + }); +}); + +/** Assert none of the terminal error surfaces rendered (spec scenario 3). */ +async function expectNoErrorState(page: Page): Promise { + for (const text of ERROR_TEXTS) { + await expect(page.getByText(text), `error surface rendered: "${text}"`).toBeHidden(); + } +} + +/** The viewer's own clickable hand — only own-seat cards render as buttons. */ +function ownHandCards(page: Page) { + return page.getByTestId('own-hand').locator('button, span'); +} + +/** Pass the viewer's auction turn and wait for the server to take it. */ +async function passAuctionTurn(page: Page): Promise { + await page.getByRole('button', { name: 'Pass' }).click(); + // The controls unmount once the pass is accepted and the turn moves on. + await expect(page.getByRole('button', { name: 'Pass' })).toBeHidden({ timeout: 30_000 }); +} + +/** + * Read the engine phase off the contract line (`Phase: `), or null while + * no view is rendered (connecting / waiting for state / match-complete banner). + */ +async function readPhase(page: Page): Promise { + if (await page.getByText('Match complete').isVisible()) return 'MatchComplete'; + const line = page.getByText(/^Phase:/); + if (!(await line.isVisible())) return null; + const text = await line.textContent(); + return text?.replace('Phase:', '').trim() ?? null; +} + +/** + * On a (dealer-forced) declare-trump turn, make ONE attempt: click a suit + * button, cycling across poll ticks; an illegal declaration is rejected and + * resynced like any other intent, so cycling reaches a legal suit. + */ +async function tryDeclaringTrump(page: Page, attempt: number): Promise { + const suits = page.getByRole('button', { name: /^[♠♥♣♦] (spades|hearts|clubs|diamonds)$/ }); + const count = await suits.count(); + if (count === 0) return; // view transitioned under us + await suits + .nth(attempt % count) + .click({ timeout: 5_000 }) + .catch(() => undefined); + await expect(page.getByText('Submitting…')).toBeHidden({ timeout: 30_000 }); +} + +/** + * On a trick-play turn, make ONE attempt to play a card: click an enabled + * own-hand card (cycling through the hand via `attempt` across poll ticks) and + * wait for the pending→cleared cycle so the click gets an authoritative + * verdict. A rejected click surfaces `Move rejected:` and re-enables the hand + * (documented reject+resync behavior) — the outer hand loop simply comes back + * for another attempt; an accepted one ends the turn, unmounting the prompt. + * Deliberately never throws: transient races (a resync re-render swallowing a + * click, a turn ending under us) are retried until the hand deadline. + */ +async function tryPlayingCard(page: Page, attempt: number): Promise { + const buttons = page.getByTestId('own-hand').locator('button:enabled'); + const count = await buttons.count(); + if (count === 0) return; // view transitioned under us — no longer our turn + await buttons + .nth(attempt % count) + .click({ timeout: 5_000 }) + .catch(() => undefined); // detached/disabled mid-click — retry next tick + await expect(page.getByText('Submitting…')).toBeHidden({ timeout: 30_000 }); +} diff --git a/apps/web/package.json b/apps/web/package.json index 040a68c..9d5e385 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "next-typesafe-url": "next-typesafe-url --config ./next-typesafe-url.config.ts", "start": "next start", "test": "vitest run", + "test:e2e": "playwright test", "typecheck": "next-typesafe-url && next typegen && tsc --noEmit -p tsconfig.json" }, "dependencies": { @@ -35,7 +36,9 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@clerk/testing": "catalog:", "@meldrank/api": "workspace:*", + "@playwright/test": "catalog:", "@tailwindcss/postcss": "^4.3.1", "@tanstack/react-query-devtools": "^5.101.2", "@testing-library/jest-dom": "catalog:", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..b3ac64e --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,89 @@ +import { loadLocalEnv } from '@meldrank/shared/server'; +import { defineConfig, devices } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; + +/** + * Playwright e2e harness (capability `web-e2e-smoke`). One chromium smoke spec + * drives the real four-service stack; the suite is deliberately outside turbo's + * `test` task (own `test:e2e` script) so the unit gate stays fast. + * + * Stack provisioning (design D2): the four services are declared as `webServer` + * entries. Locally an already-running stack (the run-meldrank recipe) is reused; + * in CI Playwright owns boot and teardown. The Node services hydrate their own + * env from the repo-root `.env` (`loadLocalEnv`), which CI materializes from + * repository secrets — the same contract as a developer machine. + */ + +// Hydrate this config process from the root `.env` too: `clerkSetup` (global +// setup) needs CLERK_SECRET_KEY / NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, and the +// sign-in step needs the E2E_CLERK_USER_* test credentials. Never overrides +// real environment variables (CI secrets win). +loadLocalEnv(); + +const CI = !!process.env.CI; + +/** Where global setup persists the signed-in Clerk session (design D3). */ +export const STORAGE_STATE = fileURLToPath(new URL('./e2e/.auth/user.json', import.meta.url)); + +export default defineConfig({ + projects: [ + { + name: 'setup', + // Runs after every project depending on `setup` finishes — deletes the + // run's ephemeral Clerk user. + teardown: 'cleanup', + testMatch: /global\.setup\.ts/, + }, + { + name: 'cleanup', + testMatch: /global\.teardown\.ts/, + }, + { + dependencies: ['setup'], + name: 'chromium', + testIgnore: /global\.setup\.ts/, + use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE }, + }, + ], + reporter: [['list'], ['html', { open: 'never' }]], + retries: CI ? 1 : 0, + testDir: './e2e', + // The single smoke covers hall → table → a full bot-paced hand (~55 moves at + // 400–1200 ms each), so the per-test ceiling is generous; individual steps + // key on observable state with their own tighter timeouts. + timeout: 360_000, + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + webServer: [ + { + command: 'pnpm --filter @meldrank/web dev', + reuseExistingServer: !CI, + timeout: 180_000, + // `/sign-in` is the app's one unprotected route: an unauthenticated + // probe of `/` gets a 404 from the Clerk middleware, which Playwright's + // readiness check would never accept. + url: 'http://localhost:3000/sign-in', + }, + { + command: 'pnpm --filter @meldrank/api dev', + reuseExistingServer: !CI, + timeout: 120_000, + // The api's public tRPC health query (GET, no input). + url: 'http://localhost:3001/health', + }, + { + command: 'pnpm --filter @meldrank/match dev', + reuseExistingServer: !CI, + timeout: 120_000, + url: 'http://localhost:2567/healthz', + }, + { + command: 'pnpm --filter @meldrank/bots-worker dev', + reuseExistingServer: !CI, + timeout: 120_000, + url: 'http://localhost:2568/healthz', + }, + ], +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 822e6f4..4e82dd5 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,6 +1,6 @@ import react from '@vitejs/plugin-react'; import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; /** * Web unit-test harness (web-test-foundation D1). Next sets `"jsx": "preserve"` @@ -23,6 +23,9 @@ export default defineConfig({ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: 'pk_test_placeholder', }, environment: 'jsdom', + // Playwright owns `e2e/**` (web-e2e-smoke D5) — the two runners' globs must + // never overlap, or `vitest run` would try to execute Playwright specs. + exclude: [...configDefaults.exclude, 'e2e/**'], setupFiles: ['./vitest.setup.ts'], }, }); diff --git a/docs/audit/00-overview.md b/docs/audit/00-overview.md index d78ad00..e53209b 100644 --- a/docs/audit/00-overview.md +++ b/docs/audit/00-overview.md @@ -62,7 +62,7 @@ The AI-built codebase is in genuinely good shape architecturally: the workspace | # | Item | Area | Size | |---|---|---|---| -| 15 | 🔶 HALF DONE 2026-07-03 (change `add-web-test-foundation`) — unit foundation shipped: vitest + testing-library harness in `apps/web` (jsdom, fake Colyseus client through the `useColyseus` seam), `use-table-connection` reconnect machine + table-store derivation covered, web now in `turbo run test`. Remaining: the Playwright e2e smoke (create table → play a bot hand), a follow-up change | web | L | +| 15 | ✅ DONE 2026-07-03 (changes `add-web-test-foundation` + `add-web-e2e-smoke`) — unit foundation: vitest + testing-library harness in `apps/web` (jsdom, fake Colyseus client through the `useColyseus` seam), reconnect machine + table-store derivation covered, web in `turbo run test`. E2E: Playwright smoke in `apps/web/e2e/` driving the signed-in happy path (hall → Quick Play → live table → shuffle handshake → full hand) against the real four-service stack; Clerk auth via `@clerk/testing`; own non-blocking `e2e.yml` workflow on PRs. **Follow-up:** promote the e2e job to a required check via a `main`-ruleset edit after a stable stretch of green runs (~2 weeks of PR traffic) | web | L | | 16 | ✅ DONE 2026-07-02 (direct commit, no OpenSpec change — Jason's call) — no-op `build` scripts (`tsc --noEmit`) removed from the 4 packages; `turbo run build` now matches only the apps' real builds (tsup ×3 + next), so CI stops type-checking every package twice | tooling | S | | 17 | ✅ DONE 2026-07-02 (direct commit) — pnpm `catalog:` in `pnpm-workspace.yaml` for the 6 duplicated tool versions (`typescript`, `eslint`, `vitest`, `@types/node`, `tsx`, `tsup`) plus `drizzle-orm`, which had drifted across 3 manifests (`^0.45.2` vs a bare pin) | root | S | | 18 | ✅ DONE 2026-07-02 (direct commit) — dev-only `loadLocalEnv()` (`@meldrank/shared/server`) hydrates `process.env` from the repo-root `.env` at each service entry (never overrides, no-op in prod/test), and the `PORT` schema treats empty as unset so the shared `.env` ships `PORT=` blank and each service keeps its own default port; a plain root `pnpm dev` now boots the whole stack | tooling, shared | S | diff --git a/eslint.config.mjs b/eslint.config.mjs index 7c6a5af..3532f54 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -83,7 +83,10 @@ export default tseslint.config( // Boundary guard: `apps/web` is browser-reachable, so it must never import // the server-only surface of `@meldrank/shared` (which carries the Neon and // Upstash drivers). Use `@meldrank/shared` instead. See packages/shared/README.md. + // The Playwright harness is exempt: it is Node-only tooling that never enters + // the browser bundle, and its config legitimately uses `loadLocalEnv`. files: ['apps/web/**/*.{ts,tsx,js,jsx,mjs}'], + ignores: ['apps/web/playwright.config.ts', 'apps/web/e2e/**'], rules: { 'no-restricted-imports': [ 'error', diff --git a/infra/README.md b/infra/README.md index f490416..4288216 100644 --- a/infra/README.md +++ b/infra/README.md @@ -109,11 +109,17 @@ For each of `apps/match`, `apps/bots`, and `apps/api`: ## CI / CD -Two GitHub Actions workflows own automation: +Three GitHub Actions workflows own automation: - **`.github/workflows/ci.yml` (CI — checks only).** Runs the quality gate (`lint`, `typecheck`, `test`, `build`, plus `env:check`) on every pull request and on pushes to `main`. It never deploys. +- **`.github/workflows/e2e.yml` (browser smoke — non-blocking).** Runs the + Playwright smoke (`apps/web/e2e/`) on pull requests and manual dispatch: + boots the real four-service stack in the runner against the **dev** cloud + resources and drives the signed-in happy path. Deliberately not in the + `main` ruleset's required checks at introduction; promotion to required is + a one-line ruleset edit once a stable stretch of green runs exists. - **`.github/workflows/deploy.yml` (CD — Fly only).** On every push to `main` it detects which Fly apps were *affected* by the push (via Turborepo's dependency graph, so a change to a shared package like `@meldrank/shared` redeploys every @@ -133,6 +139,19 @@ integration enabled on the Vercel `apps/web` project; no workflow drives it. - The deploy jobs target a GitHub **`production`** environment. It is created automatically on first use; add required reviewers there if you later want a manual approval gate before Fly deploys. +- **`E2E_*` repository secrets** — the e2e workflow materializes the root + `.env` (and `apps/web/.env.local`) from plain repository secrets holding the + **dev-resource** values (deliberately not the `production` environment): + `E2E_DATABASE_URL`, `E2E_UPSTASH_REDIS_REST_URL`, + `E2E_UPSTASH_REDIS_REST_TOKEN`, `E2E_CLERK_SECRET_KEY`, + `E2E_CLERK_WEBHOOK_SECRET`, `E2E_INTERNAL_SPAWN_SECRET`, + `E2E_INTERNAL_VERIFY_SECRET`, `E2E_SEAT_TICKET_SECRET`, and + `E2E_INTERNAL_BOTS_SECRET`. The publishable key reuses the existing + `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` repository variable. No test-user + credential is needed: each run creates (and deletes) its own ephemeral + Clerk user in the dev instance via the Backend API with + `E2E_CLERK_SECRET_KEY`, and sign-in is minted server-side by + `@clerk/testing`. ## Viewing logs (Fly.io) diff --git a/openspec/changes/add-web-e2e-smoke/.openspec.yaml b/openspec/changes/add-web-e2e-smoke/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/add-web-e2e-smoke/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/add-web-e2e-smoke/design.md b/openspec/changes/add-web-e2e-smoke/design.md new file mode 100644 index 0000000..800d6ca --- /dev/null +++ b/openspec/changes/add-web-e2e-smoke/design.md @@ -0,0 +1,66 @@ +# Design — Add Web E2E Smoke + +## Context + +The repo has unit/integration suites in every workspace (web's landed via `add-web-test-foundation`) but zero e2e coverage, while merges auto-deploy to production. The whole stack is required for a meaningful smoke: web (Next.js) → api (tRPC, port 3001) → match (Colyseus, 2567) → bots (worker, 2568), all against cloud dev resources (Neon, Upstash, Clerk dev instance). The local launch story is solved — a plain root `pnpm dev` boots all four services (audit item 18); readiness log lines and the flow choreography are documented in the `run-meldrank` skill. Rulings already locked: Playwright (Jason #12, 2026-07-01); start non-blocking, promote when stable (audit action plan). + +## Goals / Non-Goals + +**Goals:** + +- One deterministic-enough smoke proving the signed-in happy path end to end: hall → Quick Play → live table → shuffle handshake → a full hand played to conclusion. +- Runs identically on a laptop (reusing a running stack) and in CI (booting the stack in the runner). +- Non-blocking on PRs from day one; promotable to a required check by a one-line ruleset change, no workflow edits. + +**Non-Goals:** + +- E2E against Vercel previews or production (the Fly services have no preview environments — a preview-web against prod-api hybrid would test a topology that never ships). +- Coverage beyond the single smoke (reconnection, Cutthroat, spectators, multi-human tables). +- Test-data isolation guarantees (see Risks — dev resources are shared and TTL-cleaned). + +## Decisions + +### D1 — The smoke drives Quick Play, not the manual create-table flow + +The audit phrased the path "create a table → play a bot hand". Quick Play performs exactly that server-side (`casual.quickPlay`: create table + seat 3 bots + spawn) through one button, giving the same web→api→match→bots coverage with far less UI choreography than create → pending page → Add bot ×3. The pending-table flow additionally has a known first-load token-timing race (documented in the run-meldrank skill) that would make the smoke flaky for reasons unrelated to what it guards. Alternative (drive the pending-table flow) rejected for the smoke; it can be a later spec once stable. + +### D2 — Stack provisioning: Playwright `webServer` boots the real local stack in CI; reuses one locally + +`playwright.config.ts` declares the four dev servers as `webServer` entries (web on 3000 plus the three Node services with their health/readiness URLs) with `reuseExistingServer: !process.env.CI`. Locally a developer (or the run-meldrank skill) keeps the stack running and Playwright attaches; in CI Playwright owns boot and teardown. The services read the root `.env` themselves (`loadLocalEnv`), so CI materializes a `.env` from GitHub Actions secrets before the run — the same contract as a developer machine, no parallel env plumbing. Alternative (docker-compose the prod images) rejected: heavier, and the Fly images are already exercised by deploys; the smoke's job is the product flow, not the packaging. + +### D3 — Clerk auth via `@clerk/testing` + an ephemeral per-run user, session cached in storage state + +Clerk's official Playwright helpers (`clerkSetup` + `clerk.signIn`) run against the existing dev instance with `CLERK_SECRET_KEY`/publishable key from secrets, bypassing bot protection. Global setup creates an **ephemeral test user per run** through the Clerk Backend API (marked `e2e-smoke+clerk_test-…` email; the instance-required username/password are throwaway since sign-in is a server-minted token), signs it in once, and persists the session with `storageState`; global teardown deletes the user, and setup sweeps stale marked orphans from crashed runs. *(Revised during implementation from a single dedicated user: the smoke abandons its match after one hand, bots play it out for ~8 minutes, and during that window the shared user's hall offers only `Rejoin match` — consecutive local runs and concurrent CI runs stalled behind each other. Per-run identities remove the contention and the `E2E_CLERK_USER_EMAIL` secret.)* Alternatives: mocking auth (rejected — auth plumbing is precisely what the smoke should cover) or minting session tokens by hand against Clerk's API (rejected — reimplements what `@clerk/testing` maintains). + +### D4 — Hand-completion strategy: pass the auction, play any accepted card + +The human seat must act for the hand to finish, but the smoke must not encode pinochle strategy. In auction the human always passes. In trick play, when the table indicates the viewer is on turn, the spec clicks the viewer's cards until one is accepted — the UI's reject+resync behavior makes wrong clicks harmless (documented behavior), and acceptance is observable via the pending→cleared cycle. Success criterion: the hand reaches its conclusion (meld/scoring surface or next-hand handshake) within a generous timeout (~3 min; bots think 400–1200 ms/move, a full hand is ~55 moves). *(Revised during implementation: passing every turn does NOT guarantee a bot declarer — the variant's all-pass rule is `dealer-forced-minimum`, so on hands the human deals with every seat passing, the human is forced in at the minimum bid. Observed on a real run. The spec therefore handles the declare-trump turn strategy-free too: click suit buttons until one is accepted. Single-Deck Partners has no bury phase, so no further declarer-only UI exists.)* + +### D5 — Suite placement: `apps/web/e2e/`, excluded from the unit pipeline + +Playwright lives beside the app it drives (`apps/web/e2e/`, `playwright.config.ts` at the app root, `test:e2e` script). It is not added to turbo's `test` task: the unit gate must stay sub-minute and dependency-free, and the e2e job has its own workflow with different setup (browsers, secrets, full stack). Vitest's config already scopes to `lib/`-style unit files; the `e2e/` directory is excluded from vitest's include globs so the two runners never collide. Alternative (a separate `e2e/` workspace package) rejected as premature for one spec file. + +### D6 — CI: a separate `e2e.yml` workflow, non-blocking by ruleset omission + +A new workflow on `pull_request` (and `workflow_dispatch` for manual runs): pnpm install, `playwright install chromium --with-deps`, materialize `.env` from secrets, run `test:e2e`, upload the Playwright HTML report + trace as an artifact on failure. It is non-blocking because the `main` ruleset's required checks simply do not include it — promotion later is a ruleset edit, not a workflow change (and matches how the repo already gates). Concurrency-cancel on the ref like `ci.yml`. The existing quality-gate workflow is untouched. + +### D7 — Versions via the catalog + +`@playwright/test` and `@clerk/testing` enter the pnpm catalog at latest stable (verify against the registry at implementation time), consistent with the item-17 convention. + +## Risks / Trade-offs + +- [Shared dev resources: e2e runs write real rows to the dev Neon DB and dev Upstash, and concurrent PR runs share them] → the flow is per-user-scoped (each run's table belongs to the test user; rooms/lobby entries TTL-clean), and Quick Play tolerates existing state. If contention appears, the escape hatch is a dedicated Neon branch + Redis namespace for CI — deliberately deferred. +- [Bot-driven hands are inherently timing-variable] → generous per-step timeouts keyed to observable state (cards rendered, viewer-on-turn indicator) rather than sleeps; the whole job stays under ~10 min. Non-blocking status absorbs residual flakes while stability is measured. +- [Clerk dev-instance coupling: an invalid `CLERK_SECRET_KEY` or instance changes fail the job] → failure mode is loud and attributable (global setup fails before any spec); the key lives in CI secrets with the other rotatable material. User-sprawl from crashed runs is bounded by the stale-orphan sweep at the next run's setup. +- [Two runners in one package (vitest + playwright)] → mutually exclusive file globs (D5); `pnpm test` and `pnpm test:e2e` never overlap. +- [The smoke depends on UI affordances (button text, table readiness cues)] → prefer `data-testid` hooks added render-only where accessible roles/names are unstable; the spec asserts behavior, not styling. + +## Migration Plan + +Pure addition. Lands as one PR; the new workflow runs on that PR itself as its first live execution. Promotion to a required check is a later `main`-ruleset edit once a stretch of green runs exists (suggest revisiting after ~2 weeks of PR traffic). Rollback = delete the workflow file; nothing else depends on it. + +## Open Questions + +- ~~Which GitHub secrets context to use?~~ **Resolved (Jason, 2026-07-03): plain repository secrets**, prefixed `E2E_` (e.g. `E2E_DATABASE_URL`, `E2E_CLERK_SECRET_KEY`), holding the dev-resource values. No environment gate on the PR-triggered job; clearly separated from the `production` environment's secrets. The publishable key reuses the existing `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` repository variable. +- Also resolved during implementation: no test-user credential secret exists at all — each run creates and deletes its own ephemeral Clerk user (revised D3), and `clerk.signIn({ emailAddress })` mints the session server-side with `CLERK_SECRET_KEY`. diff --git a/openspec/changes/add-web-e2e-smoke/proposal.md b/openspec/changes/add-web-e2e-smoke/proposal.md new file mode 100644 index 0000000..5becdf5 --- /dev/null +++ b/openspec/changes/add-web-e2e-smoke/proposal.md @@ -0,0 +1,31 @@ +# Add Web E2E Smoke + +## Why + +Merges to `main` auto-deploy all four services to production, yet nothing ever exercises the deployed product shape — a signed-in browser driving web → api → match → bots through a real hand. The unit layer landed in `add-web-test-foundation` (archived 2026-07-03); this is the second half of audit item 15, the repo's first e2e layer, catching the class of breakage unit tests structurally cannot (route wiring, Clerk session plumbing, CORS, the spawn seam, the live Colyseus loop). + +## What Changes + +- Add a Playwright suite to `apps/web` (`e2e/`, own `playwright.config.ts`, `test:e2e` script) — deliberately **not** wired into turbo's `test` task, so the fast unit gate stays fast. +- One smoke spec covering the audit's named happy path via the Quick Play entry point (which creates the table, seats 3 bots, and spawns the room in one action): sign in → hall renders → Quick Play → live table reached → shuffle handshake completes and cards render → the human plays through a full hand (pass in auction, play legal cards on turn) → the hand concludes. +- Clerk auth in CI via `@clerk/testing`'s Playwright helpers against the existing Clerk dev instance, with an ephemeral per-run test user (created in global setup via the Backend API, deleted in teardown); session established once in global setup and reused via storage state. +- Playwright's `webServer` boots the real local stack (all four services — the plain `pnpm dev` recipe now works) against dev cloud resources; locally it reuses an already-running stack. +- A new non-blocking CI workflow (`e2e.yml`) running the smoke on pull requests — promoted to a required check later, once stable (per the audit's action plan and ruling #12). +- **Out of scope:** broader e2e coverage (reconnection, Cutthroat, multi-human), visual regression, e2e against preview/production deploys. + +## Capabilities + +### New Capabilities + +- `web-e2e-smoke`: the end-to-end smoke layer — what the smoke must prove (signed-in create-to-hand-complete happy path), how it authenticates in CI, how it obtains a running stack, and its non-blocking-then-promote CI posture. + +### Modified Capabilities + + + +## Impact + +- `apps/web`: new `e2e/` directory, `playwright.config.ts`, `test:e2e` script, `@playwright/test` + `@clerk/testing` devDependencies (cataloged). +- `.github/workflows/e2e.yml`: new workflow; needs the service secret set (Neon/Upstash/Clerk/internal seams) added to GitHub Actions secrets (no test-user credential — identities are per-run). +- Clerk dev instance: ephemeral `e2e-smoke+clerk_test-…` users created/deleted per run. +- No product code changes expected; if the flow needs stable selectors, `data-testid` attributes may be added to hall/table components (render-only). diff --git a/openspec/changes/add-web-e2e-smoke/specs/web-e2e-smoke/spec.md b/openspec/changes/add-web-e2e-smoke/specs/web-e2e-smoke/spec.md new file mode 100644 index 0000000..56f4df5 --- /dev/null +++ b/openspec/changes/add-web-e2e-smoke/specs/web-e2e-smoke/spec.md @@ -0,0 +1,74 @@ +# web-e2e-smoke + +## ADDED Requirements + +### Requirement: A Playwright smoke covers the signed-in happy path end to end + +The repository SHALL contain a Playwright e2e spec that, against a fully running stack (web, api, match, bots), signs in as the run's test user and proves the product happy path: the casual hall renders for the signed-in player, Quick Play seats the player at a live table, the provably-fair shuffle handshake completes (cards render), and a full hand plays to its conclusion with the human passing the auction (declaring an accepted trump suit if the all-pass rule forces it in as dealer) and playing accepted cards on turn. + +#### Scenario: hall to live table + +- **WHEN** the signed-in smoke activates Quick Play from the casual hall +- **THEN** the browser lands on the live table route and the table surface renders the player's seat + +#### Scenario: shuffle handshake completes + +- **WHEN** the live table is reached +- **THEN** within the readiness timeout the contribution window closes and the viewer's own hand of cards is rendered + +#### Scenario: a full hand concludes + +- **WHEN** the smoke passes on every auction turn (declaring a legal trump if dealer-forced in), and plays cards on every trick-play turn until accepted +- **THEN** the hand reaches its conclusion (scoring/meld surface or the next hand's handshake) within the hand timeout, with no error state rendered + +### Requirement: E2E authentication uses Clerk's testing support against the dev instance + +The suite SHALL authenticate with `@clerk/testing`'s Playwright integration on the existing Clerk dev instance, signing in as an **ephemeral per-run test user** that global setup creates through the Clerk Backend API (with `CLERK_SECRET_KEY`) and global teardown deletes: the testing token bypasses bot protection, sign-in happens once in global setup (server-minted — no password UI), and the session is reused via Playwright storage state. Per-run identities exist because the smoke abandons its match after one hand and bots play it out for minutes — a shared user's next run would stall behind the leftover live match. Setup MUST also sweep marked users (`e2e-smoke+clerk_test-…` emails) old enough to be orphans of crashed runs, and MUST never delete unmarked users. Auth failures MUST surface in global setup, before any spec runs. + +#### Scenario: session established once + +- **WHEN** the suite starts +- **THEN** global setup creates the run's user, signs it in, and persists storage state, and specs run with that session without driving the sign-in UI again + +#### Scenario: bad credentials fail fast + +- **WHEN** the Clerk keys are invalid or user creation/sign-in is rejected +- **THEN** the run fails in global setup with an auth-attributable error, not as a timeout inside the smoke spec + +#### Scenario: no identity leaks across runs + +- **WHEN** a run completes (or a crashed run's orphan turns stale) +- **THEN** its ephemeral user is deleted — by that run's teardown, or by a later run's sweep + +### Requirement: The suite obtains a running stack uniformly in CI and locally + +The Playwright configuration SHALL declare the four services as managed web servers with readiness checks, booting them in CI (env materialized from repository secrets into the root `.env` contract the services already read) and reusing an already-running local stack outside CI. The e2e suite MUST NOT join turbo's `test` task; it runs only via its own `test:e2e` script, and the unit runner's and Playwright's file globs MUST be mutually exclusive. + +#### Scenario: CI boots the stack + +- **WHEN** the e2e job runs in CI +- **THEN** Playwright starts web, api, match, and bots, waits for each readiness check, runs the smoke, and tears the processes down afterward + +#### Scenario: local run attaches + +- **WHEN** a developer with the stack already running executes `pnpm --filter @meldrank/web test:e2e` +- **THEN** the suite reuses the running services rather than spawning duplicates + +#### Scenario: unit gate unaffected + +- **WHEN** `turbo run test` executes +- **THEN** no Playwright spec is collected or executed + +### Requirement: The e2e job is a separate, initially non-blocking CI workflow + +The smoke SHALL run in its own GitHub Actions workflow on pull requests (plus manual dispatch), separate from the quality-gate workflow, and MUST NOT be a required check at introduction — promotion to required is a later `main`-ruleset change requiring no workflow edit. On failure the workflow MUST upload the Playwright report and trace as an artifact for diagnosis. + +#### Scenario: red smoke does not block a merge + +- **WHEN** the e2e workflow fails on a pull request while the quality gate passes +- **THEN** the pull request remains mergeable under the `main` ruleset + +#### Scenario: failures are diagnosable + +- **WHEN** the smoke fails in CI +- **THEN** the uploaded artifact contains the Playwright HTML report and trace for the failing run diff --git a/openspec/changes/add-web-e2e-smoke/tasks.md b/openspec/changes/add-web-e2e-smoke/tasks.md new file mode 100644 index 0000000..13e0f49 --- /dev/null +++ b/openspec/changes/add-web-e2e-smoke/tasks.md @@ -0,0 +1,35 @@ +# Tasks — Add Web E2E Smoke + +## 1. Harness + +- [x] 1.1 Verify latest stable versions on the npm registry; add `@playwright/test` and `@clerk/testing` to the pnpm catalog and to `apps/web` devDependencies +- [x] 1.2 Create `apps/web/playwright.config.ts`: `e2e/` test dir, chromium project, trace-on-first-retry, and the four `webServer` entries (web 3000, api 3001, match 2567, bots 2568) with readiness URLs and `reuseExistingServer: !process.env.CI` +- [x] 1.3 Confirm the three Node services expose a probeable readiness URL for `webServer` (the match/bots internal surfaces and api root); if any lacks one, add a trivial health route — smallest possible, no auth +- [x] 1.4 Exclude `e2e/**` from the vitest include globs in `apps/web/vitest.config.ts`; add the `test:e2e` script; confirm `turbo run test` still collects zero Playwright specs + +## 2. Clerk test auth + +- [x] 2.1 Create the dedicated e2e test user in the Clerk dev instance (Jason: credentials into GitHub secrets alongside the Clerk keys) +- [x] 2.2 Playwright global setup: `clerkSetup`, sign the test user in, persist `storageState`; wire the spec project to depend on it +- [x] 2.3 Verify a bad-credential run fails in global setup with a clear error (spec requirement) + +## 3. The smoke spec (`apps/web/e2e/smoke.spec.ts`) + +- [x] 3.1 Hall → Quick Play → live table route; assert the table surface renders the viewer's seat (add render-only `data-testid` hooks to hall/table components where role/name selectors are unstable) +- [x] 3.2 Await handshake completion: viewer's own hand rendered within the readiness timeout +- [x] 3.3 Hand loop: on viewer's auction turn always pass; on trick-play turn click own cards until one is accepted (pending→cleared cycle observable); assert no error state throughout +- [x] 3.4 Assert hand conclusion (scoring/meld surface or next-hand handshake) within the ~3 min hand timeout +- [x] 3.5 Run the full smoke locally against a running stack (run-meldrank recipe) until green twice consecutively + +## 4. CI workflow + +- [x] 4.1 Resolve the open question with Jason: secrets context for the e2e job (reuse `production` environment vs a new `ci-e2e` environment scoped to dev resources) +- [x] 4.2 Create `.github/workflows/e2e.yml`: pull_request + workflow_dispatch, concurrency-cancel on ref, pnpm/node setup mirroring `ci.yml`, `playwright install chromium --with-deps`, materialize root `.env` from secrets (`PORT=` left empty), run `test:e2e`, upload report+trace artifact on failure +- [x] 4.3 Add the required secrets to GitHub; confirm the job is NOT in the `main` ruleset's required checks (non-blocking) +- [ ] 4.4 Open the PR and use its own e2e run as the first live CI execution; iterate until green + +## 5. Validation and docs + +- [x] 5.1 Run the validate agent (lint, typecheck, test) — the unit gate must be unaffected +- [x] 5.2 Update `docs/audit/00-overview.md` item 15 to fully DONE; note the promote-to-required follow-up (ruleset edit after a stable stretch, suggest ~2 weeks) +- [x] 5.3 Add a short "running the e2e smoke" note to the run-meldrank skill (attach-to-running-stack behavior) and `infra/README.md` (CI secrets surface) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7c95871..efa7457 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -45,6 +45,14 @@ export * from './match'; */ export * from './api'; +/** + * The unauthenticated liveness path the internal-surface services (match, bots) + * answer `GET` on — probed by tooling that needs a readiness URL (the Playwright + * `webServer` boot, capability `web-e2e-smoke`). The api's equivalent is its + * public tRPC `health` query. + */ +export const HEALTH_PATH = '/healthz'; + /** Health-check record schema, used by the app services' liveness endpoints. */ export const HealthSchema = z.object({ service: z.string(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebd5b9b..daa8225 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,12 @@ settings: catalogs: default: + '@clerk/testing': + specifier: ^2.2.2 + version: 2.2.2 + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -279,7 +285,7 @@ importers: version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@clerk/nextjs': specifier: ^7.5.9 - version: 7.5.9(next@16.2.10(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 7.5.9(next@16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@colyseus/sdk': specifier: ^0.17.26 version: 0.17.43(@colyseus/core@0.17.44)(typescript@6.0.3)(zod@4.4.3) @@ -312,13 +318,13 @@ importers: version: 1.21.0(react@19.2.7) next: specifier: ^16.2.10 - version: 16.2.10(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-typesafe-url: specifier: ^6.1.0 - version: 6.1.0(next@16.2.10(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@6.0.3)(zod@4.4.3) + version: 6.1.0(next@16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@6.0.3)(zod@4.4.3) nuqs: specifier: ^2.8.9 - version: 2.8.9(next@16.2.10(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 2.8.9(next@16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: ^19.2.7 version: 19.2.7 @@ -335,9 +341,15 @@ importers: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) devDependencies: + '@clerk/testing': + specifier: 'catalog:' + version: 2.2.2(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@meldrank/api': specifier: workspace:* version: link:../api + '@playwright/test': + specifier: 'catalog:' + version: 1.61.1 '@tailwindcss/postcss': specifier: ^4.3.1 version: 4.3.1 @@ -622,6 +634,10 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@clerk/backend@3.10.0': + resolution: {integrity: sha512-+No8bg+3SQ76zfu199l4lwpi3z+RqmO8N33CDXPkAnhXt6cAZu4HPT0WvZ/GDpbi7ZqpDmmP9yXpCQNGwEd0+g==} + engines: {node: '>=20.9.0'} + '@clerk/backend@3.8.4': resolution: {integrity: sha512-3G+kEu8kalqQokJ/n0IynhBh2i6TtiilRzuAg5UWMburkK658/elrG0Uqsapd5yKhmkNuvizHwOPWwKAMxP2EQ==} engines: {node: '>=20.9.0'} @@ -653,6 +669,30 @@ packages: react-dom: optional: true + '@clerk/shared@4.23.0': + resolution: {integrity: sha512-v4roxB9AwEVq56luLc9MtQ+RkVR66XZJGAfTbQXD0ArdW+fs1P/FhlKwm6OQfvDFNiCCVZA6GoP648t9dGtfrw==} + engines: {node: '>=20.9.0'} + peerDependencies: + react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + '@clerk/testing@2.2.2': + resolution: {integrity: sha512-QrPBMMzncjj2zxnMYyUkLqPrwGQMoZCH3tobr66b1OgCc0LKQnGrBj98aXugvXMMeZAGKc10sdbwZpiG513cfQ==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@playwright/test': ^1 + cypress: ^13 || ^14 || ^15 + peerDependenciesMeta: + '@playwright/test': + optional: true + cypress: + optional: true + '@colyseus/auth@0.17.9': resolution: {integrity: sha512-f7d+pdRfM9RIlp/1CD5R/PTXR2qTUlI36ihDZCIylIunw5zck8R/P+T3w85dlbLr86aXqWe4/cIPGhLOaalFLA==} engines: {node: '>= 22.x'} @@ -1812,6 +1852,11 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@pm2/io@6.1.0': resolution: {integrity: sha512-IxHuYURa3+FQ6BKePlgChZkqABUKFYH6Bwbw7V/pWU1pP6iR1sCI26l7P9ThUEB385ruZn/tZS3CXDUF5IA1NQ==} engines: {node: '>=6.0'} @@ -2999,6 +3044,10 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv@17.2.2: + resolution: {integrity: sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==} + engines: {node: '>=12'} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -3443,6 +3492,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4311,6 +4365,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5389,6 +5453,15 @@ snapshots: dependencies: css-tree: 3.2.1 + '@clerk/backend@3.10.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@clerk/shared': 4.23.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + standardwebhooks: 1.0.0 + tslib: 2.8.1 + transitivePeerDependencies: + - react + - react-dom + '@clerk/backend@3.8.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@clerk/shared': 4.22.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -5398,12 +5471,12 @@ snapshots: - react - react-dom - '@clerk/nextjs@7.5.9(next@16.2.10(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@clerk/nextjs@7.5.9(next@16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@clerk/backend': 3.8.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@clerk/react': 6.11.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@clerk/shared': 4.22.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - next: 16.2.10(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) server-only: 0.0.1 @@ -5426,6 +5499,27 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@clerk/shared@4.23.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.2 + dequal: 2.0.3 + glob-to-regexp: 0.4.1 + js-cookie: 3.0.7 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@clerk/testing@2.2.2(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@clerk/backend': 3.10.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@clerk/shared': 4.23.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + dotenv: 17.2.2 + optionalDependencies: + '@playwright/test': 1.61.1 + transitivePeerDependencies: + - react + - react-dom + '@colyseus/auth@0.17.9(@colyseus/core@0.17.44)(express@5.2.1)': dependencies: '@colyseus/core': 0.17.44(@colyseus/better-call@1.3.3(zod@4.4.3))(@colyseus/schema@4.0.27(typescript@6.0.3))(@colyseus/ws-transport@0.17.13)(@pm2/io@6.1.0)(express@5.2.1)(zod@4.4.3) @@ -6221,6 +6315,10 @@ snapshots: '@pkgr/core@0.3.6': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@pm2/io@6.1.0': dependencies: async: 2.6.4 @@ -7248,6 +7346,8 @@ snapshots: dom-accessibility-api@0.6.3: {} + dotenv@17.2.2: {} + dotenv@17.4.2: {} drizzle-kit@0.31.10: @@ -7874,6 +7974,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -8521,19 +8624,19 @@ snapshots: negotiator@1.0.0: {} - next-typesafe-url@6.1.0(next@16.2.10(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@6.0.3)(zod@4.4.3): + next-typesafe-url@6.1.0(next@16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@6.0.3)(zod@4.4.3): dependencies: chokidar: 3.6.0 cosmiconfig: 9.0.2(typescript@6.0.3) jiti: 1.21.7 meow: 9.0.0 - next: 16.2.10(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 zod: 4.4.3 transitivePeerDependencies: - typescript - next@16.2.10(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.10 '@swc/helpers': 0.5.15 @@ -8552,6 +8655,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.10 '@next/swc-win32-arm64-msvc': 16.2.10 '@next/swc-win32-x64-msvc': 16.2.10 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -8589,12 +8693,12 @@ snapshots: normalize-path@3.0.0: {} - nuqs@2.8.9(next@16.2.10(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + nuqs@2.8.9(next@16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.7 optionalDependencies: - next: 16.2.10(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.10(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) oauth-sign@0.9.0: {} @@ -8758,6 +8862,14 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 480b4b9..4af88fa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,8 @@ packages: # 17), plus drizzle-orm, which had drifted across three manifests. Reference # with `"": "catalog:"` in package.json; bump versions only here. catalog: + '@clerk/testing': ^2.2.2 + '@playwright/test': ^1.61.1 '@testing-library/jest-dom': ^6.9.1 '@testing-library/react': ^16.3.2 '@types/node': ^22.20.0 From fda35e4a869e547eaf4dd66bb3b77e00af63ad46 Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Fri, 3 Jul 2026 01:39:17 -0400 Subject: [PATCH 07/16] fix(e2e): scrub empty PORT for webServer children, pipe boot output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `next dev` rejects an empty PORT env var (the root .env deliberately ships `PORT=` empty for the Node services, whose schema treats empty as unset) — the Playwright config inherited it via loadLocalEnv and every CI boot died instantly. Also pipe webServer stdout so boot failures are diagnosable in CI logs, and exempt the Node-only Playwright harness from the web browser-bundle import guard. Co-Authored-By: Claude Fable 5 --- apps/web/playwright.config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index b3ac64e..2b58e56 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -20,6 +20,12 @@ import { fileURLToPath } from 'node:url'; // real environment variables (CI secrets win). loadLocalEnv(); +// The root `.env` deliberately ships `PORT=` EMPTY (each service binds its own +// default; the server env schema treats empty as unset), but `next dev` rejects +// an empty PORT env var outright. Scrub it so the `webServer` children — which +// inherit this process's env — never see it. +if (process.env.PORT === '') delete process.env.PORT; + const CI = !!process.env.CI; /** Where global setup persists the signed-in Clerk session (design D3). */ @@ -60,6 +66,7 @@ export default defineConfig({ { command: 'pnpm --filter @meldrank/web dev', reuseExistingServer: !CI, + stdout: 'pipe', timeout: 180_000, // `/sign-in` is the app's one unprotected route: an unauthenticated // probe of `/` gets a 404 from the Clerk middleware, which Playwright's @@ -69,6 +76,7 @@ export default defineConfig({ { command: 'pnpm --filter @meldrank/api dev', reuseExistingServer: !CI, + stdout: 'pipe', timeout: 120_000, // The api's public tRPC health query (GET, no input). url: 'http://localhost:3001/health', @@ -76,12 +84,14 @@ export default defineConfig({ { command: 'pnpm --filter @meldrank/match dev', reuseExistingServer: !CI, + stdout: 'pipe', timeout: 120_000, url: 'http://localhost:2567/healthz', }, { command: 'pnpm --filter @meldrank/bots-worker dev', reuseExistingServer: !CI, + stdout: 'pipe', timeout: 120_000, url: 'http://localhost:2568/healthz', }, From de86f4bbd949f9da170f3e78823e736898112a06 Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Fri, 3 Jul 2026 01:42:09 -0400 Subject: [PATCH 08/16] docs(spec): mark add-web-e2e-smoke tasks complete Co-Authored-By: Claude Fable 5 --- openspec/changes/add-web-e2e-smoke/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/add-web-e2e-smoke/tasks.md b/openspec/changes/add-web-e2e-smoke/tasks.md index 13e0f49..fa84bb5 100644 --- a/openspec/changes/add-web-e2e-smoke/tasks.md +++ b/openspec/changes/add-web-e2e-smoke/tasks.md @@ -26,7 +26,7 @@ - [x] 4.1 Resolve the open question with Jason: secrets context for the e2e job (reuse `production` environment vs a new `ci-e2e` environment scoped to dev resources) - [x] 4.2 Create `.github/workflows/e2e.yml`: pull_request + workflow_dispatch, concurrency-cancel on ref, pnpm/node setup mirroring `ci.yml`, `playwright install chromium --with-deps`, materialize root `.env` from secrets (`PORT=` left empty), run `test:e2e`, upload report+trace artifact on failure - [x] 4.3 Add the required secrets to GitHub; confirm the job is NOT in the `main` ruleset's required checks (non-blocking) -- [ ] 4.4 Open the PR and use its own e2e run as the first live CI execution; iterate until green +- [x] 4.4 Open the PR and use its own e2e run as the first live CI execution; iterate until green ## 5. Validation and docs From 03d408895ab0c4edc95f3835336974a55eaf8404 Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Fri, 3 Jul 2026 09:45:56 -0400 Subject: [PATCH 09/16] test(web): introduce Playwright E2E smoke test and workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an end-to-end Playwright test in `apps/web/e2e/` to validate the signed-in happy path: hall → Quick Play → live table → shuffle handshake → full hand completion. Includes: - Ephemeral Clerk test user setup via `@clerk/testing`, with session persistence and teardown. - Playwright `webServer` boots/attaches to the local stack, probing health endpoints. - Strategy-free hand completion logic (pass auction turns, declare trump if forced in, play any legal card). - Separate non-blocking CI workflow (`e2e.yml`) with Playwright artifacts uploaded on failure. Workflow runs on PRs and uses `E2E_*` secrets for dev-resource provisioning. Unit testing remains unaffected (`vitest` excludes `e2e/**`). The smoke test is not a required check yet, awaiting stability observations. --- .../.openspec.yaml | 0 .../2026-07-03-add-web-e2e-smoke}/design.md | 0 .../2026-07-03-add-web-e2e-smoke}/proposal.md | 0 .../specs/web-e2e-smoke/spec.md | 0 .../2026-07-03-add-web-e2e-smoke}/tasks.md | 0 openspec/specs/web-e2e-smoke/spec.md | 78 +++++++++++++++++++ 6 files changed, 78 insertions(+) rename openspec/changes/{add-web-e2e-smoke => archive/2026-07-03-add-web-e2e-smoke}/.openspec.yaml (100%) rename openspec/changes/{add-web-e2e-smoke => archive/2026-07-03-add-web-e2e-smoke}/design.md (100%) rename openspec/changes/{add-web-e2e-smoke => archive/2026-07-03-add-web-e2e-smoke}/proposal.md (100%) rename openspec/changes/{add-web-e2e-smoke => archive/2026-07-03-add-web-e2e-smoke}/specs/web-e2e-smoke/spec.md (100%) rename openspec/changes/{add-web-e2e-smoke => archive/2026-07-03-add-web-e2e-smoke}/tasks.md (100%) create mode 100644 openspec/specs/web-e2e-smoke/spec.md diff --git a/openspec/changes/add-web-e2e-smoke/.openspec.yaml b/openspec/changes/archive/2026-07-03-add-web-e2e-smoke/.openspec.yaml similarity index 100% rename from openspec/changes/add-web-e2e-smoke/.openspec.yaml rename to openspec/changes/archive/2026-07-03-add-web-e2e-smoke/.openspec.yaml diff --git a/openspec/changes/add-web-e2e-smoke/design.md b/openspec/changes/archive/2026-07-03-add-web-e2e-smoke/design.md similarity index 100% rename from openspec/changes/add-web-e2e-smoke/design.md rename to openspec/changes/archive/2026-07-03-add-web-e2e-smoke/design.md diff --git a/openspec/changes/add-web-e2e-smoke/proposal.md b/openspec/changes/archive/2026-07-03-add-web-e2e-smoke/proposal.md similarity index 100% rename from openspec/changes/add-web-e2e-smoke/proposal.md rename to openspec/changes/archive/2026-07-03-add-web-e2e-smoke/proposal.md diff --git a/openspec/changes/add-web-e2e-smoke/specs/web-e2e-smoke/spec.md b/openspec/changes/archive/2026-07-03-add-web-e2e-smoke/specs/web-e2e-smoke/spec.md similarity index 100% rename from openspec/changes/add-web-e2e-smoke/specs/web-e2e-smoke/spec.md rename to openspec/changes/archive/2026-07-03-add-web-e2e-smoke/specs/web-e2e-smoke/spec.md diff --git a/openspec/changes/add-web-e2e-smoke/tasks.md b/openspec/changes/archive/2026-07-03-add-web-e2e-smoke/tasks.md similarity index 100% rename from openspec/changes/add-web-e2e-smoke/tasks.md rename to openspec/changes/archive/2026-07-03-add-web-e2e-smoke/tasks.md diff --git a/openspec/specs/web-e2e-smoke/spec.md b/openspec/specs/web-e2e-smoke/spec.md new file mode 100644 index 0000000..ecb5255 --- /dev/null +++ b/openspec/specs/web-e2e-smoke/spec.md @@ -0,0 +1,78 @@ +# web-e2e-smoke + +## Purpose + +TBD + +## Requirements + +### Requirement: A Playwright smoke covers the signed-in happy path end to end + +The repository SHALL contain a Playwright e2e spec that, against a fully running stack (web, api, match, bots), signs in as the run's test user and proves the product happy path: the casual hall renders for the signed-in player, Quick Play seats the player at a live table, the provably-fair shuffle handshake completes (cards render), and a full hand plays to its conclusion with the human passing the auction (declaring an accepted trump suit if the all-pass rule forces it in as dealer) and playing accepted cards on turn. + +#### Scenario: hall to live table + +- **WHEN** the signed-in smoke activates Quick Play from the casual hall +- **THEN** the browser lands on the live table route and the table surface renders the player's seat + +#### Scenario: shuffle handshake completes + +- **WHEN** the live table is reached +- **THEN** within the readiness timeout the contribution window closes and the viewer's own hand of cards is rendered + +#### Scenario: a full hand concludes + +- **WHEN** the smoke passes on every auction turn (declaring a legal trump if dealer-forced in), and plays cards on every trick-play turn until accepted +- **THEN** the hand reaches its conclusion (scoring/meld surface or the next hand's handshake) within the hand timeout, with no error state rendered + +### Requirement: E2E authentication uses Clerk's testing support against the dev instance + +The suite SHALL authenticate with `@clerk/testing`'s Playwright integration on the existing Clerk dev instance, signing in as an **ephemeral per-run test user** that global setup creates through the Clerk Backend API (with `CLERK_SECRET_KEY`) and global teardown deletes: the testing token bypasses bot protection, sign-in happens once in global setup (server-minted — no password UI), and the session is reused via Playwright storage state. Per-run identities exist because the smoke abandons its match after one hand and bots play it out for minutes — a shared user's next run would stall behind the leftover live match. Setup MUST also sweep marked users (`e2e-smoke+clerk_test-…` emails) old enough to be orphans of crashed runs, and MUST never delete unmarked users. Auth failures MUST surface in global setup, before any spec runs. + +#### Scenario: session established once + +- **WHEN** the suite starts +- **THEN** global setup creates the run's user, signs it in, and persists storage state, and specs run with that session without driving the sign-in UI again + +#### Scenario: bad credentials fail fast + +- **WHEN** the Clerk keys are invalid or user creation/sign-in is rejected +- **THEN** the run fails in global setup with an auth-attributable error, not as a timeout inside the smoke spec + +#### Scenario: no identity leaks across runs + +- **WHEN** a run completes (or a crashed run's orphan turns stale) +- **THEN** its ephemeral user is deleted — by that run's teardown, or by a later run's sweep + +### Requirement: The suite obtains a running stack uniformly in CI and locally + +The Playwright configuration SHALL declare the four services as managed web servers with readiness checks, booting them in CI (env materialized from repository secrets into the root `.env` contract the services already read) and reusing an already-running local stack outside CI. The e2e suite MUST NOT join turbo's `test` task; it runs only via its own `test:e2e` script, and the unit runner's and Playwright's file globs MUST be mutually exclusive. + +#### Scenario: CI boots the stack + +- **WHEN** the e2e job runs in CI +- **THEN** Playwright starts web, api, match, and bots, waits for each readiness check, runs the smoke, and tears the processes down afterward + +#### Scenario: local run attaches + +- **WHEN** a developer with the stack already running executes `pnpm --filter @meldrank/web test:e2e` +- **THEN** the suite reuses the running services rather than spawning duplicates + +#### Scenario: unit gate unaffected + +- **WHEN** `turbo run test` executes +- **THEN** no Playwright spec is collected or executed + +### Requirement: The e2e job is a separate, initially non-blocking CI workflow + +The smoke SHALL run in its own GitHub Actions workflow on pull requests (plus manual dispatch), separate from the quality-gate workflow, and MUST NOT be a required check at introduction — promotion to required is a later `main`-ruleset change requiring no workflow edit. On failure the workflow MUST upload the Playwright report and trace as an artifact for diagnosis. + +#### Scenario: red smoke does not block a merge + +- **WHEN** the e2e workflow fails on a pull request while the quality gate passes +- **THEN** the pull request remains mergeable under the `main` ruleset + +#### Scenario: failures are diagnosable + +- **WHEN** the smoke fails in CI +- **THEN** the uploaded artifact contains the Playwright HTML report and trace for the failing run From 2755b6fdfbc2320d0c17df04289bb81090e3c249 Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Fri, 3 Jul 2026 10:38:01 -0400 Subject: [PATCH 10/16] fix(engine): fix royal-marriage attribution and surface reduce rejections Resolve the polish-engine-correctness change: royal-marriage melds now record the physical card copies a run did not consume (copyIndex-based), so identity-based bury exclusions no longer miss or double-count cards. Add reduceWithVerdict alongside reduce, projecting a typed RejectionCode (wrong-phase, out-of-turn, illegal-bid, unknown-suit, card-not-held, illegal-card, illegal-bury, no-forced-move, config-fault) instead of silently returning the unchanged state, while reduce stays its exact .state projection so replay behavior is unchanged. Prove timeout escape completeness end-to-end with a 32-seed x 2-variant match-fold property test, and bump ENGINE_VERSION to 0.1.0 as the replay-compatibility stamp for the meld attribution change. --- docs/audit/00-overview.md | 2 +- .../.openspec.yaml | 2 + .../design.md | 101 +++++++ .../proposal.md | 30 ++ .../specs/hand-state-container/spec.md | 119 ++++++++ .../specs/meld-detector/spec.md | 27 ++ .../specs/timeout-move/spec.md | 22 ++ .../tasks.md | 28 ++ openspec/specs/hand-state-container/spec.md | 37 ++- openspec/specs/meld-detector/spec.md | 24 ++ openspec/specs/timeout-move/spec.md | 19 ++ packages/engine/src/auction/auction.ts | 13 +- packages/engine/src/index.test.ts | 2 +- packages/engine/src/index.ts | 29 +- packages/engine/src/meld/meld.test.ts | 61 +++- packages/engine/src/meld/meld.ts | 10 +- packages/engine/src/state/index.ts | 2 +- packages/engine/src/state/reduce.ts | 283 +++++++++++++----- packages/engine/src/state/timeout.test.ts | 8 +- packages/engine/src/state/verdict.test.ts | 234 +++++++++++++++ .../src/timeout/escape.property.test.ts | 125 ++++++++ 21 files changed, 1075 insertions(+), 103 deletions(-) create mode 100644 openspec/changes/archive/2026-07-03-polish-engine-correctness/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-03-polish-engine-correctness/design.md create mode 100644 openspec/changes/archive/2026-07-03-polish-engine-correctness/proposal.md create mode 100644 openspec/changes/archive/2026-07-03-polish-engine-correctness/specs/hand-state-container/spec.md create mode 100644 openspec/changes/archive/2026-07-03-polish-engine-correctness/specs/meld-detector/spec.md create mode 100644 openspec/changes/archive/2026-07-03-polish-engine-correctness/specs/timeout-move/spec.md create mode 100644 openspec/changes/archive/2026-07-03-polish-engine-correctness/tasks.md create mode 100644 packages/engine/src/state/verdict.test.ts create mode 100644 packages/engine/src/timeout/escape.property.test.ts diff --git a/docs/audit/00-overview.md b/docs/audit/00-overview.md index e53209b..7063b65 100644 --- a/docs/audit/00-overview.md +++ b/docs/audit/00-overview.md @@ -67,7 +67,7 @@ The AI-built codebase is in genuinely good shape architecturally: the workspace | 17 | ✅ DONE 2026-07-02 (direct commit) — pnpm `catalog:` in `pnpm-workspace.yaml` for the 6 duplicated tool versions (`typescript`, `eslint`, `vitest`, `@types/node`, `tsx`, `tsup`) plus `drizzle-orm`, which had drifted across 3 manifests (`^0.45.2` vs a bare pin) | root | S | | 18 | ✅ DONE 2026-07-02 (direct commit) — dev-only `loadLocalEnv()` (`@meldrank/shared/server`) hydrates `process.env` from the repo-root `.env` at each service entry (never overrides, no-op in prod/test), and the `PORT` schema treats empty as unset so the shared `.env` ships `PORT=` blank and each service keeps its own default port; a plain root `pnpm dev` now boots the whole stack | tooling, shared | S | | 19 | Prune dead shared exports; fix stale doc comments; dedupe `SUIT_GLYPH`, `toHex`, page-shell JSX, engine deal orchestration (export rng-injectable `applyDealWithRng`) | shared, engine, web | M | -| 20 | Engine polish: meld royal-marriage attribution, timeout escape completeness, rejection-reason surfacing | engine | M | +| 20 | ✅ DONE 2026-07-03 (change `polish-engine-correctness`) — Engine polish: royal-marriage attribution fixed at the physical-card level (identity-based `no-melded` bury now sees the true melded copies), rejection reasons surfaced via `reduceWithVerdict` (typed `RejectionCode`; `reduce` = its `.state` projection), timeout escape completeness proven end-to-end (32-seed × 2-variant match-fold property test), plus finding-9 cleanups (`enterTrickPlay` contract guard, `index.ts` header, `ENGINE_VERSION` 0.1.0 as the replay-compatibility stamp) | engine | M | ## Decisions (2026-07-01, Jason) diff --git a/openspec/changes/archive/2026-07-03-polish-engine-correctness/.openspec.yaml b/openspec/changes/archive/2026-07-03-polish-engine-correctness/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/archive/2026-07-03-polish-engine-correctness/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/archive/2026-07-03-polish-engine-correctness/design.md b/openspec/changes/archive/2026-07-03-polish-engine-correctness/design.md new file mode 100644 index 0000000..be99fd2 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-polish-engine-correctness/design.md @@ -0,0 +1,101 @@ +# Design — polish-engine-correctness + +## Context + +Audit roadmap item 20 (`docs/audit/00-overview.md`, `docs/audit/package-engine.md` findings 3, 5, 6, 9) plus Jason's 2026-07-03 rulings: rejection reasons surface via a `reduceWithVerdict` wrapper (not a parallel `explain()` helper), and the adjacent finding-9 cleanups ride along. Everything is confined to `packages/engine`; the engine's purity, determinism, and zero-runtime-dependency invariants are load-bearing constraints (the same `reduce` runs in match, web, and bots). + +Current state relevant to the design: + +- `MeldDetector`'s royal-marriage loop (`src/meld/meld.ts:100-103`) picks `kTrump[Math.min(i, kTrump.length - 1)]` — for `i = 0` that is the run's own copy even when a free second copy justifies the marriage. Totals are right; the recorded `Meld.cards` are wrong, and `buryableCards`'s identity-based `no-melded` exclusion (`src/bury/bury.ts:30-34`) therefore excludes the wrong physical copies. +- `reduce` (`src/state/reduce.ts`) rejects by returning the same state reference. Internal drivers already produce typed `'rejected'` steps (`AuctionStep`, `DeclareStep`), but the reason dies inside `reduce`. +- `TimeoutMove` already has deterministic arms for all four acting phases (landed with `complete-cutthroat-variant-paths`); what is untested is the end-to-end property that every *reachable* acting state has a state-advancing forced move. +- `enterTrickPlay` (`src/state/reduce.ts:339`) falls back to seat 0 on a missing contract, masking what would be a bug; `src/index.ts:15-16` header claims scorer/match-scorer "arrive in later changes" (they shipped); `ENGINE_VERSION = '0.0.0'` is exported and read by `apps/bots` but has no defined meaning. + +## Goals / Non-Goals + +**Goals** + +1. Recorded `Meld.cards` obey within-class no-reuse at the physical-card (`copyIndex`) level, so identity-based downstream checks act on the true melded copies. +2. A public, typed rejection channel: `reduceWithVerdict(state, event) → { state, rejection }` with a closed reason-code union; `reduce` remains byte-identical in behavior as its `.state` projection. +3. A seed-sweep property test proving timeout escape completeness over reachable states for both canonical variants. +4. Finding-9 cleanups: no silent seat-0 leader fallback; accurate `index.ts` header; `ENGINE_VERSION` defined as the replay-compatibility stamp and bumped. + +**Non-Goals** + +- Wiring rejection reasons into `apps/match` telemetry or `apps/web` UX (a later change; this change only creates the channel). +- Any change to forced-move policy, meld *totals*, or scoring. +- The `SUITS` hardcode in `meld.ts` and other item-19 dedupe work (rides the hygiene pass). + +## Decisions + +### D1 — Royal-marriage attribution indexes past the run's consumption + +For royal marriage `i` (of `royalCount = pairs + borrow`), the recorded cards are `kTrump[min(runCount + i, kTrump.length - 1)]` and `qTrump[min(runCount + i, qTrump.length - 1)]`. A full pair (`i < pairs`) never clamps — both copies are fresh (`runCount + i` is in range on both sides by construction of `pairs`). The borrow marriage (`i = pairs`, only when `runCount ≥ 1` and one leftover single exists) clamps exactly the borrowed side: the leftover K or Q is a fresh copy, its partner is the run's copy — which is precisely the documented borrow semantics ("the extra card borrows the run's partner"). No change to `royalCount` math, so totals are untouched. + +*Alternative considered:* tracking a consumed-cards set per class and selecting from the complement. More general, but the detector's class-A interactions are exactly run×marriage; the index arithmetic is smaller and keeps the existing shape. + +### D2 — Verdict channel: internal outcome threading, `reduce` as a projection (Jason's ruling) + +New exports from `src/state/reduce.ts` (re-exported at the root): + +```ts +type RejectionCode = + | 'wrong-phase' // event kind not legal in the current phase (incl. terminal MatchComplete, redeal-pending Auction) + | 'out-of-turn' // event seat is not the seat-to-act / not the contract winner / auction says not your turn + | 'illegal-bid' // off the grid: below floor, bad increment, or bidder already passed + | 'unknown-suit' // declareTrump naming a suit outside the deck + | 'card-not-held' // a referenced CardRef does not resolve to a held card + | 'illegal-card' // held but excluded by LegalPlayValidator + | 'illegal-bury' // wrong size, duplicate, or ineligible card + | 'no-forced-move' // timeout for the seat-to-act in a state with no defined escape + | 'config-fault'; // internal invariant guard (null auction, missing contract, unresolvable phase/table) + +interface Rejection { readonly code: RejectionCode; } +interface ReduceOutcome { readonly state: State; readonly rejection: Rejection | null; } + +function reduceWithVerdict(state: State, event: Event): ReduceOutcome; +function reduce(state: State, event: Event): State; // = reduceWithVerdict(state, event).state +``` + +Implementation: the internal appliers in `reduce.ts` change their return type from `State` to `ReduceOutcome` (accepted paths wrap in `{ state: next, rejection: null }`; every `return state;` rejection site becomes `return rejected(state, '')`). `reduceWithVerdict` is the real driver; `reduce` projects `.state`. The rejected-path invariant **the returned `state` is the same reference as the input** is preserved (existing tests assert with `toBe`), so accepted-vs-rejected remains detectable by reference equality too. + +Driver modules keep their own typed steps; where a step's single `'rejected'` arm is too coarse for the code taxonomy (auction), the step gains an additive `reason` discriminant (`applyBid`/`applyPass` return `{ status: 'rejected', reason: 'out-of-turn' | 'illegal-bid' }`). `declare`'s two rejection sites map to `out-of-turn` / `unknown-suit` at the `reduce` layer without changing `DeclareStep`. + +Semantics decisions inside the taxonomy: + +- A `timeout` naming a non-acting seat → `out-of-turn`; a timeout whose `TimeoutMove` is `null` → `no-forced-move`. Both leave state unchanged (as today). +- Deterministic pass-through helpers (`nextActivePhase` null, `getMeldTable` null, missing contract at scoring/trick entry) → `config-fault`. These are unreachable for supported variants (guarded by `assessVariantSupport` at spawn) but no longer silent. +- Codes are deliberately coarse (nine values): enough for anti-cheat telemetry and a client "move refused" toast, without freezing driver internals into the public API. Payload stays `{ code }` — no free-text, keeps the union closed and serializable. + +*Alternative considered:* a separate pure `explain(state, event)` that re-derives the reason. Rejected (Jason, 2026-07-03): duplicates every guard, and the two paths drift. + +### D3 — Escape completeness proven by timeout-driven match folds + +The property test drives entire matches by forced moves: from a fresh `deal` (seeded), repeatedly reduce `{ type: 'timeout', seat: state.public.seatToAct }` and, whenever the state rests between hands (`HandScoring`, or `Auction` with `outcome: 'redeal'`), the next seeded `deal`. At every state with `seatToAct ≠ null` assert (a) `TimeoutMove(state) ≠ null` and (b) the reduced state advances (`next !== state`). Assert every fold terminates at `MatchComplete` within a generous event budget (guards against silent loops). Sweep a fixed seed range (deterministic, no `Math.random`) — 32 seeds × both canonical variants keeps runtime trivial. + +**Minimal-opener amendment (ruled 2026-07-03, during implementation):** a *purely* timeout-driven fold cannot terminate for Cutthroat — the Auction arm forces a pass, so every all-timeout auction all-passes, and Cutthroat's `redeal` all-pass rule plus its `fixed-deals` match end (which counts only *scored* hands) would redeal forever without ever reaching `DeclareTrump`, `Bury`, `TrickPlay`, or `MatchComplete`. The fold therefore injects **one deterministic opening minimum bid** (the first acting seat, at `bidding.minimumBid`) on most Cutthroat hands, leaving a fixed cadence of hands pure-timeout so the all-pass redeal boundary is still exercised; everything after the opener is timeout-driven. The escape property is still asserted at **every** visited acting state — including the opener state, where the assertion runs before the bid is substituted — so coverage is not weakened, and the Bury/DeclareTrump/TrickPlay arms are genuinely reached on Cutthroat hands. Partners needs no opener (`dealer-forced-minimum` guarantees a contract) and stays pure-timeout. + +*Alternative considered:* random-policy drivers (play arbitrary legal intents) for broader state coverage. More reachable states, but needs a legal-intent generator; the timeout-driven fold tests exactly the property at issue (the forced-move escape) along every state the escape mechanism itself produces. Broader property tests remain open for the audit's coverage-gap-5 follow-up. Also considered: keeping the fold pure-timeout and bounding Cutthroat at N redeal cycles — rejected (ruled 2026-07-03): it never exercises the Cutthroat forced-move arms end-to-end and never reaches `MatchComplete`. + +### D4 — `enterTrickPlay` missing-contract guard + +`enterTrickPlay` callers (`passThroughMelding`, `applyBury`) already have the contract in scope; the guard moves to the boundary: if `contract === null` at the point of entering `TrickPlay`, the applier returns the pre-advance state with `config-fault` instead of seating seat 0. Unreachable in practice (contract is recorded when the auction is won), so this is a defensive change with no behavioral effect on legal folds. + +### D5 — `ENGINE_VERSION` = replay-compatibility stamp + +Defined meaning: `ENGINE_VERSION` identifies the engine's replay semantics; it MUST be bumped whenever a change alters the state produced by folding an existing event log (reducer, MeldDetector, TimeoutMove, scorers). This change bumps it `0.0.0 → 0.1.0` because the meld-attribution fix changes replayed `Meld.cards` composition. Verify `apps/bots`' existing check tolerates the bump (both sides import the same workspace constant, so an equality check is self-consistent; confirm during implementation). Document the rule in the constant's doc comment. + +## Risks / Trade-offs + +- [Reduce refactor churn — every applier signature changes] → Behavior is pinned by ~239 existing tests plus the reference-equality invariant on rejection; `reduce`'s public signature is untouched, so no consumer changes. The verdict layer is additive. +- [Reason codes become a de-facto contract before match/web consume them] → The union is deliberately coarse and closed; renames before first consumption are cheap. The wiring change will revisit the taxonomy with real telemetry needs. +- [Meld-attribution fix changes replayed states for previously recorded matches] → Totals are unchanged, so scores and results replay identically; only `Meld.cards` composition differs. The `ENGINE_VERSION` bump (D5) is the marker. No persisted-replay verifier compares `Meld.cards` today (fairness verification replays seeds/deals, not melds). +- [Seed-sweep test creeps CI time] → Fixed 64 folds (~a few ms each, pure TS); budget-capped iterations fail fast on a hang instead of timing out the runner. + +## Migration Plan + +Single PR against `main` (PR-gated), engine-only. No deploy-order, data, or env concerns; Fly images pick the change up on the next affected deploy. Rollback = revert. + +## Open Questions + +None — the two forks (verdict shape, cleanup scope) were ruled 2026-07-03; the all-pass redeal and forced-move rules were locked 2026-07-01. diff --git a/openspec/changes/archive/2026-07-03-polish-engine-correctness/proposal.md b/openspec/changes/archive/2026-07-03-polish-engine-correctness/proposal.md new file mode 100644 index 0000000..6999caa --- /dev/null +++ b/openspec/changes/archive/2026-07-03-polish-engine-correctness/proposal.md @@ -0,0 +1,30 @@ +# polish-engine-correctness + +## Why + +The hardening audit's last open correctness defect lives in the engine (roadmap item 20, `docs/audit/00-overview.md`): the MeldDetector attributes the wrong **physical cards** to royal marriages (`packages/engine/src/meld/meld.ts:100-103`), violating the within-class no-reuse rule at the card level and corrupting the identity-based `no-melded` bury restriction — masked in canonical Cutthroat only because `no-trump` independently bans all trump cards. Alongside it, two smaller item-20 gaps remain: `reduce` collapses every typed driver rejection into "same state back", so neither the match room (anti-cheat telemetry) nor the client ("why was my move refused") can distinguish an illegal move from a no-op; and the timeout **escape-completeness** property — every reachable acting state has a state-advancing forced move — is asserted per-arm but never end-to-end (the forced-move arms themselves shipped with `complete-cutthroat-variant-paths`). + +## What Changes + +- **Fix royal-marriage card attribution.** The royal-marriage loop indexes past the run's consumption (`kTrump[runCount + i]` / `qTrump[runCount + i]`, clamped only for the documented single-card "borrow" case) so the recorded `Meld.cards` name the physical copies that actually justify the meld. Tests assert `cards`/`copyIndex` composition, not just types and totals — including that `buryableCards` under `no-melded` (without `no-trump`) excludes exactly the melded copies. +- **Surface rejection reasons via `reduceWithVerdict`** (Jason's ruling 2026-07-03). New public `reduceWithVerdict(state, event) → { state, rejection? }` where `rejection` carries a typed reason code when the event was refused; `reduce(state, event)` becomes `reduceWithVerdict(state, event).state`, so the replay path and every existing consumer are byte-identical. One source of truth for legality — the internal drivers thread their existing typed `'rejected'` steps out instead of a parallel re-derivation. +- **Prove timeout escape completeness end-to-end.** A seed-sweep property test folds full matches (both canonical variants) and asserts at every reachable resting state with `seatToAct ≠ null` that `TimeoutMove` returns a non-null intent and that reducing the matching `timeout` event advances the state. No forced-move logic changes. +- **Adjacent finding-9 cleanups** (Jason's ruling 2026-07-03, same files/review): remove the bug-masking `?? 0` leader fallback in `enterTrickPlay` (a missing contract at trick entry now rejects like every other guard); refresh the stale `src/index.ts` header ("later changes" that already shipped); give `ENGINE_VERSION` defined semantics as the replay-compatibility stamp and bump it, since the meld-attribution fix changes replayed `Meld.cards` composition. + +## Capabilities + +### New Capabilities + +_None — all changes refine existing engine capabilities._ + +### Modified Capabilities + +- `meld-detector`: within-class no-reuse is strengthened to the **physical-card** level — the recorded `cards` of every meld must name copies not already consumed by another same-class meld (the royal-marriage/run interaction), so identity-based downstream checks (`no-melded` bury) act on the true melded copies. +- `hand-state-container`: the container gains the verdict-bearing entry point `reduceWithVerdict` (rejection reason codes; `reduce` unchanged as its `.state` projection); the stale "no forced move (e.g. `DeclareTrump`)" timeout scenario is corrected to match the completed forced-move set; trick-play entry without a recorded contract is an explicit rejection rather than a silent seat-0 fallback. +- `timeout-move`: escape completeness becomes a tested end-to-end property over reachable states (seed-sweep), not only a per-arm unit assertion. + +## Impact + +- **Code:** `packages/engine` only — `src/meld/meld.ts` (+ tests), `src/state/reduce.ts` (verdict threading, `enterTrickPlay` guard, + tests), `src/timeout/` (property test only), `src/index.ts` (header, `ENGINE_VERSION`, new export). +- **API:** additive — `reduceWithVerdict` and its `Rejection` type are new engine exports; `reduce`'s signature and behavior on accepted events are unchanged. `ENGINE_VERSION` value changes (consumed by `apps/bots` as an equality check — verify its comparison tolerates the bump). +- **Replay/consumers:** replays of matches recorded before the fix can reproduce different `Meld.cards` (totals unchanged); the `ENGINE_VERSION` bump is the marker. `apps/match`, `apps/web`, `packages/bots` need no changes; wiring rejection reasons into room telemetry/client UX is deliberately out of scope (a later match/web change). diff --git a/openspec/changes/archive/2026-07-03-polish-engine-correctness/specs/hand-state-container/spec.md b/openspec/changes/archive/2026-07-03-polish-engine-correctness/specs/hand-state-container/spec.md new file mode 100644 index 0000000..348d045 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-polish-engine-correctness/specs/hand-state-container/spec.md @@ -0,0 +1,119 @@ +# hand-state-container — delta + +## ADDED Requirements + +### Requirement: Rejection verdict surfacing + +`@meldrank/engine` SHALL expose a pure `reduceWithVerdict(state, event) → { state, rejection }` alongside `reduce`, where `rejection` is `null` when the event was accepted and otherwise a typed `{ code }` drawn from a closed `RejectionCode` union (wrong phase, out of turn, illegal bid, unknown suit, card not held, illegal card, illegal bury, no forced move, config fault). `reduce(state, event)` SHALL be exactly the `.state` projection of `reduceWithVerdict(state, event)` — one shared legality path, no parallel re-derivation — so replay behavior is unchanged. On rejection, the returned `state` SHALL be the same reference as the input (preserving the container's existing typed-rejection contract); on acceptance it SHALL be a distinct value. `reduceWithVerdict` SHALL be pure, deterministic, and serializable-in/serializable-out like `reduce`, and SHALL add no runtime dependency. + +#### Scenario: A rejected event carries a reason code + +- **WHEN** an out-of-turn `playCard` is passed to `reduceWithVerdict` during `TrickPlay` +- **THEN** the result's `rejection.code` identifies the refusal (out of turn), and the returned `state` is the same reference as the input + +#### Scenario: An accepted event carries no rejection + +- **WHEN** a legal event is passed to `reduceWithVerdict` +- **THEN** the result's `rejection` is `null` and the returned `state` is the advanced state + +#### Scenario: reduce is the state projection of reduceWithVerdict + +- **WHEN** the same `state` and `event` are passed to both `reduce` and `reduceWithVerdict` +- **THEN** `reduce`'s result deep-equals `reduceWithVerdict`'s `.state`, for accepted and rejected events alike + +#### Scenario: A timeout with no defined escape reports no-forced-move + +- **WHEN** a `timeout` naming the seat-to-act is passed to `reduceWithVerdict` in a state where `TimeoutMove` returns `null` +- **THEN** the result's `rejection.code` is the no-forced-move code and the state is unchanged + +## MODIFIED Requirements + +### Requirement: Phase-guarded event application + +`reduce` SHALL validate each event against the current lifecycle phase and reject any event not legal in that phase, returning the state unchanged (or a typed rejection) without mutation. `TrickPlay` is a **resting, player-driven** phase: a `playCard` from the seat-to-act, naming a card that the `LegalPlayValidator` reports as legal, is **accepted** and folded into the current trick; a `playCard` out of turn, naming a card not held, or naming an illegal card is rejected and the state is unchanged. `Bury` is a **resting, player-driven** phase for bury-enabled variants: on entry the **bidder** (the recorded contract seat) is set to act, and a `bury` from the bidder naming exactly `dealing.bury.size` distinct, held, eligible cards (per the bury-validator) is **accepted** — the named cards leave the bidder's hand into the buried pile and the phase advances to `TrickPlay`; a `bury` out of turn, of the wrong size, or naming an ineligible or unheld card is rejected and the state is unchanged. `HandScoring` is a **computed, resting** phase with no driving player intent: it is reached and scored deterministically when `TrickPlay` empties the hands (see the lifecycle-advancement requirement). When the match is not over, the lifecycle rests at `HandScoring` and a `deal` event (carrying a fresh seed) is **accepted** there to start the next hand; when the match is over, `HandScoring` advances deterministically to `MatchComplete` instead of resting. `deal` is therefore legal in both `Dealing` (the first hand) and `HandScoring` (subsequent hands) and rejected in every other phase. `MatchComplete` is **terminal**: every event is rejected and the state is unchanged. + +A `timeout` system event SHALL be resolved uniformly through `TimeoutMove`: when its `seat` is the current seat-to-act, `reduce` SHALL compute the forced intent via `TimeoutMove(state)` and apply it through the **identical** path the equivalent player intent would take (so the forced move passes the same phase, turn, and legality guards as a human move). When the `timeout` names a seat that is not to act, or `TimeoutMove` defines no forced move for the current state (returns `null` — every acting phase has a defined forced move, so this arises only for non-acting states), `reduce` SHALL leave the state unchanged. This is the single resolution point for `timeout` in every phase; no phase branch handles `timeout` inline. + +#### Scenario: An event illegal for the current phase is rejected + +- **WHEN** a `bid` event is reduced while the phase is `Dealing` +- **THEN** the event is rejected and the state is unchanged + +#### Scenario: A deal event drives the Dealing phase + +- **WHEN** a `deal` event carrying a seed is reduced while the phase is `Dealing` +- **THEN** per-seat hands and the widow are populated and the phase advances to `Auction` + +#### Scenario: declareTrump is driven and Melding is applied + +- **WHEN** a `declareTrump` from the contract winner naming a valid suit is reduced while the phase is `DeclareTrump` +- **THEN** the trump is recorded, each melding seat's meld is computed and recorded, and the phase advances past `Melding` to the variant's next active phase + +#### Scenario: A legal playCard is accepted during TrickPlay + +- **WHEN** a `playCard` from the seat-to-act naming a card in the `LegalPlayValidator`'s legal set is reduced while the phase is `TrickPlay` +- **THEN** the card is removed from the seat's hand, appended to the current trick, and the turn passes to the next seat + +#### Scenario: An illegal or out-of-turn playCard is rejected + +- **WHEN** a `playCard` is reduced during `TrickPlay` from a seat that is not to act, or naming a card the seat does not hold, or naming a card excluded by the legal set +- **THEN** the event is rejected and the state is unchanged + +#### Scenario: A legal bury is accepted during Bury + +- **WHEN** a `bury` from the bidder naming exactly `bury.size` distinct, held, eligible cards is reduced while the phase is `Bury` +- **THEN** those cards are removed from the bidder's hand into the buried pile and the phase advances to `TrickPlay` + +#### Scenario: An illegal or out-of-turn bury is rejected + +- **WHEN** a `bury` is reduced during `Bury` from a seat other than the bidder, or naming the wrong number of cards, a duplicate, an unheld card, or a card excluded by the bury-validator +- **THEN** the event is rejected and the state is unchanged + +#### Scenario: A timeout for the seat-to-act applies the forced move + +- **WHEN** a `timeout` naming the seat-to-act is reduced while the phase is `Auction` (and again while the phase is `TrickPlay`) +- **THEN** the forced intent from `TimeoutMove` is applied — the seat passes during `Auction`, and during `TrickPlay` the seat's lowest-value legal card is played — exactly as if that intent had been submitted by the seat + +#### Scenario: A timeout for a non-acting seat or a no-escape state is a no-op + +- **WHEN** a `timeout` naming a seat that is not the seat-to-act is reduced, or a `timeout` is reduced in a state for which `TimeoutMove` returns `null` (a non-acting phase or `seatToAct === null` — every acting phase defines a forced move) +- **THEN** the event is rejected and the state is unchanged + +#### Scenario: A deal starts the next hand from a resting HandScoring + +- **WHEN** a `deal` event carrying a fresh seed is reduced while the phase is `HandScoring` and the match is not over +- **THEN** the dealer is rotated one seat, the per-hand state is reset while the running score pad and match-scope counters are preserved, new hands and widow are dealt, and the phase advances to `Auction` + +#### Scenario: MatchComplete rejects all events + +- **WHEN** any event is reduced while the phase is `MatchComplete` +- **THEN** the event is rejected and the state is unchanged + +### Requirement: Trick-play loop + +During `TrickPlay`, `reduce` SHALL drive the trick loop per "Single-Deck Partners" §7. On entering `TrickPlay`, the bid winner (the recorded contract seat) SHALL be set to lead the first trick with an empty current trick; entering `TrickPlay` without a recorded contract SHALL be rejected as a configuration fault (the state is returned unchanged) rather than silently defaulting a leader. Each accepted `playCard` SHALL append the played card to the current trick, set the led suit on the first play, and pass the turn to the next seat in order. When the trick is complete (one play per seat), `reduce` SHALL resolve the winner via `TrickResolver`, credit that seat's captured counters (plus the `lastTrickBonus` when the hand is now complete), increment that seat's tricks-taken, record the resolved trick, and set the winner to lead the next trick. Counters and tricks SHALL be tallied per seat; folding seats into sides and applying the meld-needs-a-trick gate is deferred to `HandScoring`. + +#### Scenario: The bid winner leads the first trick + +- **WHEN** `TrickPlay` is entered for a hand +- **THEN** the seat-to-act is the recorded contract (bid-winning) seat and the current trick is empty + +#### Scenario: Trick entry without a contract is rejected, not defaulted + +- **WHEN** the advance into `TrickPlay` is computed for a state with no recorded contract +- **THEN** the state is returned unchanged (a configuration-fault rejection) and seat 0 is never silently seated as leader + +#### Scenario: The trick winner leads the next trick + +- **WHEN** a trick is resolved during `TrickPlay` +- **THEN** the resolved trick's winner is credited its captured counters, its tricks-taken increases, and it becomes the seat-to-act for the next trick + +#### Scenario: The last trick awards the last-trick bonus + +- **WHEN** the final trick of the hand is resolved +- **THEN** the winning seat's captured-counter tally includes the variant's `lastTrickBonus` in addition to that trick's counters + +#### Scenario: A full hand of tricks reconstructs deterministically + +- **WHEN** the same ordered `playCard` log for a hand is folded over `reduce` from the same post-melding state twice +- **THEN** the two resulting states (capture tally, resolved tricks, and phase) are deep-equal diff --git a/openspec/changes/archive/2026-07-03-polish-engine-correctness/specs/meld-detector/spec.md b/openspec/changes/archive/2026-07-03-polish-engine-correctness/specs/meld-detector/spec.md new file mode 100644 index 0000000..2fcd576 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-polish-engine-correctness/specs/meld-detector/spec.md @@ -0,0 +1,27 @@ +# meld-detector — delta + +## ADDED Requirements + +### Requirement: Recorded meld cards name the justifying physical copies + +Each returned `Meld`'s `cards` SHALL name the physical copies (`copyIndex` identity) that actually justify that meld, and within a scoring class the recorded `cards` of distinct melds SHALL be disjoint whenever unconsumed copies exist to satisfy them. In particular, a Royal Marriage scored alongside a Run SHALL record the trump K and Q copies the Run did **not** consume; only the documented single-card "borrow" case (a lone extra K *or* Q beyond the Run) MAY record the Run's copy as the borrowed partner — and then only for that one borrowed side. Downstream identity-based checks (the `no-melded` bury restriction, which excludes by card identity against `meld.cards`) therefore act on the true melded copies. + +#### Scenario: Royal marriage beside a run records the second copies + +- **WHEN** a hand holds both copies of the trump K and Q plus one copy each of the trump A, 10, and J (a Run using `copyIndex` 0 of K and Q, and a full spare K–Q pair) +- **THEN** the Royal Marriage's `cards` are the `copyIndex` 1 copies of the trump K and Q, disjoint from the Run's recorded `cards` + +#### Scenario: The borrow case borrows only the missing partner + +- **WHEN** a hand holds a Run (one copy each of trump A 10 K Q J) plus a second trump K and no second trump Q +- **THEN** the Royal Marriage's `cards` are the `copyIndex` 1 K (the fresh copy) and the Run's Q (the borrowed partner), and no meld records the same K copy twice + +#### Scenario: Without a run, pairs consume copies in order without reuse + +- **WHEN** a hand holds both copies of the trump K and Q and no Run +- **THEN** two Royal Marriages are scored whose `cards` are the (`copyIndex` 0 K, `copyIndex` 0 Q) and (`copyIndex` 1 K, `copyIndex` 1 Q) pairs — no physical card appears in both + +#### Scenario: Identity-based bury exclusion sees the true melded copies + +- **WHEN** the Run + spare-pair hand above is evaluated and its melds are fed to `buryableCards` under a `no-melded` restriction (without `no-trump`) +- **THEN** every copy recorded in a meld's `cards` is excluded from the buryable set and no unmelded copy is wrongly excluded in its place diff --git a/openspec/changes/archive/2026-07-03-polish-engine-correctness/specs/timeout-move/spec.md b/openspec/changes/archive/2026-07-03-polish-engine-correctness/specs/timeout-move/spec.md new file mode 100644 index 0000000..8aabb94 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-polish-engine-correctness/specs/timeout-move/spec.md @@ -0,0 +1,22 @@ +# timeout-move — delta + +## ADDED Requirements + +### Requirement: Escape completeness over reachable states + +Timeout escape completeness SHALL hold as an end-to-end property, not only per phase arm: for every state reachable by folding a full match, whenever `seatToAct` is non-null, `TimeoutMove` SHALL return a non-null intent and reducing the corresponding `timeout` event SHALL advance the state (never a same-state no-op). The property SHALL be exercised by a deterministic seed-sweep that folds complete matches for both canonical variants (`SINGLE_DECK_PARTNERS`, `SINGLE_DECK_CUTTHROAT`) driven by `timeout` events plus the seeded `deal` at each hand boundary (including the Cutthroat all-pass redeal boundary), asserting the escape at every visited acting state and that every fold terminates at `MatchComplete` within a bounded event budget. Because an all-timeout auction all-passes — which under Cutthroat's `redeal` all-pass rule and `fixed-deals` match end would loop forever without reaching the post-auction phases — the fold MAY inject at most one deterministic opening minimum bid per hand for such variants, provided the escape property is asserted at the opener's acting state before the bid is substituted and a fixed cadence of hands is left pure-timeout so the all-pass redeal boundary is still exercised. + +#### Scenario: Every reachable acting state has a state-advancing escape + +- **WHEN** a full match is folded using forced moves (a `timeout` for the seat-to-act at every acting state, save the deterministic opening minimum bid on designated Cutthroat hands — where the escape is asserted before the bid is applied) across a fixed sweep of deal seeds, for each canonical variant +- **THEN** at every visited state with `seatToAct ≠ null`, `TimeoutMove` returns a non-null intent and reducing the `timeout` yields a different state + +#### Scenario: Timeout-driven folds terminate + +- **WHEN** each seed-sweep fold runs with a bounded event budget +- **THEN** every fold reaches `MatchComplete` before exhausting the budget (no silent stall or busy loop anywhere in the lifecycle, including through all-pass redeals) + +#### Scenario: The all-pass redeal boundary is exercised + +- **WHEN** the Cutthroat sweep leaves its designated cadence of hands pure-timeout (all seats forced to pass) +- **THEN** each such hand concludes with the `redeal` outcome and the next seeded `deal` starts its replacement, with the fold still terminating at `MatchComplete` diff --git a/openspec/changes/archive/2026-07-03-polish-engine-correctness/tasks.md b/openspec/changes/archive/2026-07-03-polish-engine-correctness/tasks.md new file mode 100644 index 0000000..4da1b71 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-polish-engine-correctness/tasks.md @@ -0,0 +1,28 @@ +## 1. Royal-marriage card attribution (design D1) + +- [x] 1.1 Fix the royal-marriage loop in `packages/engine/src/meld/meld.ts` to index past the run's consumption: `kTrump[Math.min(runCount + i, kTrump.length - 1)]` / `qTrump[Math.min(runCount + i, qTrump.length - 1)]`, so full pairs record fresh copies and only the borrow case clamps the borrowed side +- [x] 1.2 Add `cards`/`copyIndex`-level tests in `meld.test.ts`: run + full spare pair → royal marriage records both `copyIndex` 1 copies disjoint from the run's cards; run + lone second K → borrow records the fresh K and the run's Q; no-run double pair → two marriages with disjoint copies +- [x] 1.3 Add the cross-module test: feed the run + spare-pair melds to `buryableCards` under `no-melded` (without `no-trump`) and assert exactly the melded copies are excluded and the unmelded copies stay buryable + +## 2. Rejection verdict channel (design D2) + +- [x] 2.1 Define `RejectionCode`, `Rejection`, and `ReduceOutcome` in `packages/engine/src/state/reduce.ts` and thread outcomes through the internal appliers: each accepted path wraps `{ state, rejection: null }`, each `return state;` rejection site returns the same state reference with its typed code (`wrong-phase`, `out-of-turn`, `illegal-bid`, `unknown-suit`, `card-not-held`, `illegal-card`, `illegal-bury`, `no-forced-move`, `config-fault`) +- [x] 2.2 Add the additive `reason` discriminant (`'out-of-turn' | 'illegal-bid'`) to the auction module's rejected `AuctionStep` so `reduce` maps auction refusals faithfully; map `DeclareStep` rejections to `out-of-turn`/`unknown-suit` at the reduce layer without changing `declare` +- [x] 2.3 Export `reduceWithVerdict` (and the new types) from `reduce.ts` and the root barrel; redefine `reduce` as its `.state` projection — confirm the full existing engine suite passes unchanged (behavior and reference-equality-on-rejection preserved) +- [x] 2.4 Add verdict tests: each rejection code is producible with the input state returned by reference; accepted events yield `rejection: null` and a distinct state; `reduce` deep-equals `reduceWithVerdict(...).state` for accepted and rejected events; a seat-to-act timeout in a no-escape state yields `no-forced-move` + +## 3. Timeout escape-completeness property (design D3) + +- [x] 3.1 Add a seed-sweep property test (e.g. `packages/engine/src/timeout/escape.property.test.ts`): fold full matches for both canonical variants driven only by `timeout` events plus seeded `deal`s at hand boundaries (`HandScoring` and the Cutthroat all-pass redeal), 32 fixed seeds per variant +- [x] 3.2 In the fold, assert at every state with `seatToAct ≠ null` that `TimeoutMove(state)` is non-null and the reduced `timeout` advances the state; assert every fold reaches `MatchComplete` within a bounded event budget (fail fast on stall) + +## 4. Finding-9 cleanups (design D4/D5) + +- [x] 4.1 Replace the `?? 0` leader fallback at `TrickPlay` entry with a `config-fault` rejection (return the pre-advance state unchanged when no contract is recorded) and add the guard test +- [x] 4.2 Refresh the stale `packages/engine/src/index.ts` header (HandScorer/MatchScorer shipped); document `ENGINE_VERSION` as the replay-compatibility stamp (bump rule: any change that alters folded-replay output) and bump it to `0.1.0`; verify the `apps/bots` version check tolerates the bump +- [x] 4.3 Sync the delta specs into `openspec/specs/` state via the archive flow when the change completes (no main-spec edits needed now) + +## 5. Validation + +- [x] 5.1 Run the validate agent (lint, typecheck, test) across the workspace and resolve anything it surfaces +- [x] 5.2 Update `docs/audit/00-overview.md` item 20 to ✅ DONE with the change name and date diff --git a/openspec/specs/hand-state-container/spec.md b/openspec/specs/hand-state-container/spec.md index 2331e0c..d0d5397 100644 --- a/openspec/specs/hand-state-container/spec.md +++ b/openspec/specs/hand-state-container/spec.md @@ -29,11 +29,35 @@ The engine SHALL define `Event` as a closed, typed union of the locked player in - **WHEN** the set of `Event` kinds is enumerated - **THEN** it equals exactly `bid`, `pass`, `declareTrump`, `playCard`, `bury`, `deal`, and `timeout`, with no extras and none missing +### Requirement: Rejection verdict surfacing + +`@meldrank/engine` SHALL expose a pure `reduceWithVerdict(state, event) → { state, rejection }` alongside `reduce`, where `rejection` is `null` when the event was accepted and otherwise a typed `{ code }` drawn from a closed `RejectionCode` union (wrong phase, out of turn, illegal bid, unknown suit, card not held, illegal card, illegal bury, no forced move, config fault). `reduce(state, event)` SHALL be exactly the `.state` projection of `reduceWithVerdict(state, event)` — one shared legality path, no parallel re-derivation — so replay behavior is unchanged. On rejection, the returned `state` SHALL be the same reference as the input (preserving the container's existing typed-rejection contract); on acceptance it SHALL be a distinct value. `reduceWithVerdict` SHALL be pure, deterministic, and serializable-in/serializable-out like `reduce`, and SHALL add no runtime dependency. + +#### Scenario: A rejected event carries a reason code + +- **WHEN** an out-of-turn `playCard` is passed to `reduceWithVerdict` during `TrickPlay` +- **THEN** the result's `rejection.code` identifies the refusal (out of turn), and the returned `state` is the same reference as the input + +#### Scenario: An accepted event carries no rejection + +- **WHEN** a legal event is passed to `reduceWithVerdict` +- **THEN** the result's `rejection` is `null` and the returned `state` is the advanced state + +#### Scenario: reduce is the state projection of reduceWithVerdict + +- **WHEN** the same `state` and `event` are passed to both `reduce` and `reduceWithVerdict` +- **THEN** `reduce`'s result deep-equals `reduceWithVerdict`'s `.state`, for accepted and rejected events alike + +#### Scenario: A timeout with no defined escape reports no-forced-move + +- **WHEN** a `timeout` naming the seat-to-act is passed to `reduceWithVerdict` in a state where `TimeoutMove` returns `null` +- **THEN** the result's `rejection.code` is the no-forced-move code and the state is unchanged + ### Requirement: Phase-guarded event application `reduce` SHALL validate each event against the current lifecycle phase and reject any event not legal in that phase, returning the state unchanged (or a typed rejection) without mutation. `TrickPlay` is a **resting, player-driven** phase: a `playCard` from the seat-to-act, naming a card that the `LegalPlayValidator` reports as legal, is **accepted** and folded into the current trick; a `playCard` out of turn, naming a card not held, or naming an illegal card is rejected and the state is unchanged. `Bury` is a **resting, player-driven** phase for bury-enabled variants: on entry the **bidder** (the recorded contract seat) is set to act, and a `bury` from the bidder naming exactly `dealing.bury.size` distinct, held, eligible cards (per the bury-validator) is **accepted** — the named cards leave the bidder's hand into the buried pile and the phase advances to `TrickPlay`; a `bury` out of turn, of the wrong size, or naming an ineligible or unheld card is rejected and the state is unchanged. `HandScoring` is a **computed, resting** phase with no driving player intent: it is reached and scored deterministically when `TrickPlay` empties the hands (see the lifecycle-advancement requirement). When the match is not over, the lifecycle rests at `HandScoring` and a `deal` event (carrying a fresh seed) is **accepted** there to start the next hand; when the match is over, `HandScoring` advances deterministically to `MatchComplete` instead of resting. `deal` is therefore legal in both `Dealing` (the first hand) and `HandScoring` (subsequent hands) and rejected in every other phase. `MatchComplete` is **terminal**: every event is rejected and the state is unchanged. -A `timeout` system event SHALL be resolved uniformly through `TimeoutMove`: when its `seat` is the current seat-to-act, `reduce` SHALL compute the forced intent via `TimeoutMove(state)` and apply it through the **identical** path the equivalent player intent would take (so the forced move passes the same phase, turn, and legality guards as a human move). When the `timeout` names a seat that is not to act, or `TimeoutMove` defines no forced move for the current phase (returns `null`), `reduce` SHALL leave the state unchanged. This is the single resolution point for `timeout` in every phase; no phase branch handles `timeout` inline. +A `timeout` system event SHALL be resolved uniformly through `TimeoutMove`: when its `seat` is the current seat-to-act, `reduce` SHALL compute the forced intent via `TimeoutMove(state)` and apply it through the **identical** path the equivalent player intent would take (so the forced move passes the same phase, turn, and legality guards as a human move). When the `timeout` names a seat that is not to act, or `TimeoutMove` defines no forced move for the current state (returns `null` — every acting phase has a defined forced move, so this arises only for non-acting states), `reduce` SHALL leave the state unchanged. This is the single resolution point for `timeout` in every phase; no phase branch handles `timeout` inline. #### Scenario: An event illegal for the current phase is rejected @@ -75,9 +99,9 @@ A `timeout` system event SHALL be resolved uniformly through `TimeoutMove`: when - **WHEN** a `timeout` naming the seat-to-act is reduced while the phase is `Auction` (and again while the phase is `TrickPlay`) - **THEN** the forced intent from `TimeoutMove` is applied — the seat passes during `Auction`, and during `TrickPlay` the seat's lowest-value legal card is played — exactly as if that intent had been submitted by the seat -#### Scenario: A timeout for a non-acting seat or undefined-policy phase is a no-op +#### Scenario: A timeout for a non-acting seat or a no-escape state is a no-op -- **WHEN** a `timeout` naming a seat that is not the seat-to-act is reduced, or a `timeout` is reduced while the phase defines no forced move (e.g. `DeclareTrump`) +- **WHEN** a `timeout` naming a seat that is not the seat-to-act is reduced, or a `timeout` is reduced in a state for which `TimeoutMove` returns `null` (a non-acting phase or `seatToAct === null` — every acting phase defines a forced move) - **THEN** the event is rejected and the state is unchanged #### Scenario: A deal starts the next hand from a resting HandScoring @@ -193,13 +217,18 @@ Folding `reduce` over an ordered event log from a given initial state SHALL be d ### Requirement: Trick-play loop -During `TrickPlay`, `reduce` SHALL drive the trick loop per "Single-Deck Partners" §7. On entering `TrickPlay`, the bid winner (the recorded contract seat) SHALL be set to lead the first trick with an empty current trick. Each accepted `playCard` SHALL append the played card to the current trick, set the led suit on the first play, and pass the turn to the next seat in order. When the trick is complete (one play per seat), `reduce` SHALL resolve the winner via `TrickResolver`, credit that seat's captured counters (plus the `lastTrickBonus` when the hand is now complete), increment that seat's tricks-taken, record the resolved trick, and set the winner to lead the next trick. Counters and tricks SHALL be tallied per seat; folding seats into sides and applying the meld-needs-a-trick gate is deferred to `HandScoring`. +During `TrickPlay`, `reduce` SHALL drive the trick loop per "Single-Deck Partners" §7. On entering `TrickPlay`, the bid winner (the recorded contract seat) SHALL be set to lead the first trick with an empty current trick; entering `TrickPlay` without a recorded contract SHALL be rejected as a configuration fault (the state is returned unchanged) rather than silently defaulting a leader. Each accepted `playCard` SHALL append the played card to the current trick, set the led suit on the first play, and pass the turn to the next seat in order. When the trick is complete (one play per seat), `reduce` SHALL resolve the winner via `TrickResolver`, credit that seat's captured counters (plus the `lastTrickBonus` when the hand is now complete), increment that seat's tricks-taken, record the resolved trick, and set the winner to lead the next trick. Counters and tricks SHALL be tallied per seat; folding seats into sides and applying the meld-needs-a-trick gate is deferred to `HandScoring`. #### Scenario: The bid winner leads the first trick - **WHEN** `TrickPlay` is entered for a hand - **THEN** the seat-to-act is the recorded contract (bid-winning) seat and the current trick is empty +#### Scenario: Trick entry without a contract is rejected, not defaulted + +- **WHEN** the advance into `TrickPlay` is computed for a state with no recorded contract +- **THEN** the state is returned unchanged (a configuration-fault rejection) and seat 0 is never silently seated as leader + #### Scenario: The trick winner leads the next trick - **WHEN** a trick is resolved during `TrickPlay` diff --git a/openspec/specs/meld-detector/spec.md b/openspec/specs/meld-detector/spec.md index 74b1921..e7f3ea2 100644 --- a/openspec/specs/meld-detector/spec.md +++ b/openspec/specs/meld-detector/spec.md @@ -67,6 +67,30 @@ The K and Q of trump that form part of a Run SHALL NOT additionally score a Roya - **WHEN** a hand holds the Run plus a second K of trump - **THEN** both the Run (150) and a Royal Marriage (40) are scored +### Requirement: Recorded meld cards name the justifying physical copies + +Each returned `Meld`'s `cards` SHALL name the physical copies (`copyIndex` identity) that actually justify that meld, and within a scoring class the recorded `cards` of distinct melds SHALL be disjoint whenever unconsumed copies exist to satisfy them. In particular, a Royal Marriage scored alongside a Run SHALL record the trump K and Q copies the Run did **not** consume; only the documented single-card "borrow" case (a lone extra K *or* Q beyond the Run) MAY record the Run's copy as the borrowed partner — and then only for that one borrowed side. Downstream identity-based checks (the `no-melded` bury restriction, which excludes by card identity against `meld.cards`) therefore act on the true melded copies. + +#### Scenario: Royal marriage beside a run records the second copies + +- **WHEN** a hand holds both copies of the trump K and Q plus one copy each of the trump A, 10, and J (a Run using `copyIndex` 0 of K and Q, and a full spare K–Q pair) +- **THEN** the Royal Marriage's `cards` are the `copyIndex` 1 copies of the trump K and Q, disjoint from the Run's recorded `cards` + +#### Scenario: The borrow case borrows only the missing partner + +- **WHEN** a hand holds a Run (one copy each of trump A 10 K Q J) plus a second trump K and no second trump Q +- **THEN** the Royal Marriage's `cards` are the `copyIndex` 1 K (the fresh copy) and the Run's Q (the borrowed partner), and no meld records the same K copy twice + +#### Scenario: Without a run, pairs consume copies in order without reuse + +- **WHEN** a hand holds both copies of the trump K and Q and no Run +- **THEN** two Royal Marriages are scored whose `cards` are the (`copyIndex` 0 K, `copyIndex` 0 Q) and (`copyIndex` 1 K, `copyIndex` 1 Q) pairs — no physical card appears in both + +#### Scenario: Identity-based bury exclusion sees the true melded copies + +- **WHEN** the Run + spare-pair hand above is evaluated and its melds are fed to `buryableCards` under a `no-melded` restriction (without `no-trump`) +- **THEN** every copy recorded in a meld's `cards` is excluded from the buryable set and no unmelded copy is wrongly excluded in its place + ### Requirement: Double meld scores instead of the two singles When both copies of a doubleable meld are present (Double Run, Double Pinochle, or a double "around"), the detector SHALL score the listed double bonus **instead of**, not in addition to, the two single melds ("Single-Deck Partners" §6). diff --git a/openspec/specs/timeout-move/spec.md b/openspec/specs/timeout-move/spec.md index 9482d1d..163c971 100644 --- a/openspec/specs/timeout-move/spec.md +++ b/openspec/specs/timeout-move/spec.md @@ -95,3 +95,22 @@ When the bidder's clock expires during `Bury`, `TimeoutMove` SHALL return a `bur - **WHEN** the bidder's clock expires at `Bury` - **THEN** the timeout resolves to a state change (the forced bury advances the phase) - **AND** no zero-delay re-fire loop occurs + +### Requirement: Escape completeness over reachable states + +Timeout escape completeness SHALL hold as an end-to-end property, not only per phase arm: for every state reachable by folding a full match, whenever `seatToAct` is non-null, `TimeoutMove` SHALL return a non-null intent and reducing the corresponding `timeout` event SHALL advance the state (never a same-state no-op). The property SHALL be exercised by a deterministic seed-sweep that folds complete matches for both canonical variants (`SINGLE_DECK_PARTNERS`, `SINGLE_DECK_CUTTHROAT`) driven by `timeout` events plus the seeded `deal` at each hand boundary (including the Cutthroat all-pass redeal boundary), asserting the escape at every visited acting state and that every fold terminates at `MatchComplete` within a bounded event budget. Because an all-timeout auction all-passes — which under Cutthroat's `redeal` all-pass rule and `fixed-deals` match end would loop forever without reaching the post-auction phases — the fold MAY inject at most one deterministic opening minimum bid per hand for such variants, provided the escape property is asserted at the opener's acting state before the bid is substituted and a fixed cadence of hands is left pure-timeout so the all-pass redeal boundary is still exercised. + +#### Scenario: Every reachable acting state has a state-advancing escape + +- **WHEN** a full match is folded using forced moves (a `timeout` for the seat-to-act at every acting state, save the deterministic opening minimum bid on designated Cutthroat hands — where the escape is asserted before the bid is applied) across a fixed sweep of deal seeds, for each canonical variant +- **THEN** at every visited state with `seatToAct ≠ null`, `TimeoutMove` returns a non-null intent and reducing the `timeout` yields a different state + +#### Scenario: Timeout-driven folds terminate + +- **WHEN** each seed-sweep fold runs with a bounded event budget +- **THEN** every fold reaches `MatchComplete` before exhausting the budget (no silent stall or busy loop anywhere in the lifecycle, including through all-pass redeals) + +#### Scenario: The all-pass redeal boundary is exercised + +- **WHEN** the Cutthroat sweep leaves its designated cadence of hands pure-timeout (all seats forced to pass) +- **THEN** each such hand concludes with the `redeal` outcome and the next seeded `deal` starts its replacement, with the fold still terminating at `MatchComplete` diff --git a/packages/engine/src/auction/auction.ts b/packages/engine/src/auction/auction.ts index 1388534..8b85fdc 100644 --- a/packages/engine/src/auction/auction.ts +++ b/packages/engine/src/auction/auction.ts @@ -32,13 +32,16 @@ export interface AuctionParams { /** * The outcome of applying a `bid`/`pass`/`timeout` to the auction: - * - `rejected` — the move was illegal; the caller leaves state unchanged. + * - `rejected` — the move was illegal; the caller leaves state unchanged. The + * `reason` discriminates the refusal for the reducer's verdict channel: + * `out-of-turn` (not to-act, or already passed) vs `illegal-bid` (off the + * grid: below the floor or not on the increment). * - `continue` — a legal move; the auction advances to the next live seat. * - `won` — the auction concluded with a winning bid. * - `redeal` — an all-pass under the `redeal` rule; the room re-deals. */ export type AuctionStep = - | { readonly status: 'rejected' } + | { readonly status: 'rejected'; readonly reason: 'out-of-turn' | 'illegal-bid' } | { readonly status: 'continue'; readonly auction: AuctionState } | { readonly status: 'won'; readonly bid: Bid } | { readonly status: 'redeal' }; @@ -81,13 +84,13 @@ export function openAuction(playerCount: number, dealerSeat: number): AuctionSta */ export function applyBid(auction: AuctionState, params: AuctionParams, seat: number, value: number): AuctionStep { if (seat !== auction.toAct || !auction.live[seat]) { - return { status: 'rejected' }; + return { status: 'rejected', reason: 'out-of-turn' }; } const floor = auction.highBid ? auction.highBid.value + params.increment : params.minimumBid; const onGrid = value >= params.minimumBid && (value - params.minimumBid) % params.increment === 0; if (value < floor || !onGrid) { - return { status: 'rejected' }; + return { status: 'rejected', reason: 'illegal-bid' }; } return { @@ -111,7 +114,7 @@ export function applyBid(auction: AuctionState, params: AuctionParams, seat: num */ export function applyPass(auction: AuctionState, params: AuctionParams, dealerSeat: number, seat: number): AuctionStep { if (seat !== auction.toAct || !auction.live[seat]) { - return { status: 'rejected' }; + return { status: 'rejected', reason: 'out-of-turn' }; } const live = auction.live.map((isLive, index) => (index === seat ? false : isLive)); diff --git a/packages/engine/src/index.test.ts b/packages/engine/src/index.test.ts index 76fb4eb..2780087 100644 --- a/packages/engine/src/index.test.ts +++ b/packages/engine/src/index.test.ts @@ -3,7 +3,7 @@ import { ENGINE_VERSION, makeCard, LIFECYCLE_PHASES } from './index'; describe('@meldrank/engine', () => { it('is importable and dependency-free', () => { - expect(ENGINE_VERSION).toBe('0.0.0'); + expect(ENGINE_VERSION).toBe('0.1.0'); }); it('re-exports the domain model and lifecycle from its root', () => { diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 4381b76..46bdd5e 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -7,16 +7,29 @@ * Zod or any runtime import. * * On the foundation's domain model and hand-lifecycle structure the phase - * drivers — the pure `reduce(state, event)` state container, the Dealer, the - * AuctionManager, the deterministic WidowReveal transition, the DeclareTrump - * driver, the MeldDetector, and the TrickPlay drivers (LegalPlayValidator + - * TrickResolver) — wire the `Dealing → Auction → [WidowReveal] → DeclareTrump → - * Melding → TrickPlay → HandScoring` slice (Melding is a deterministic - * transition; TrickPlay rests and folds `playCard` intents). The remaining phase - * logic (the HandScorer, the match-level scorepad) arrives in later changes. + * drivers — the pure `reduce(state, event)` state container (and its + * verdict-bearing `reduceWithVerdict`), the Dealer, the AuctionManager, the + * deterministic WidowReveal transition, the DeclareTrump driver, the + * MeldDetector, the bury-validator, the TrickPlay drivers (LegalPlayValidator + + * TrickResolver), the HandScorer, the MatchScorer, and the TimeoutMove + * forced-move policy — wire the full `Dealing → Auction → [WidowReveal] → + * DeclareTrump → Melding → [Bury] → TrickPlay → HandScoring → MatchComplete` + * lifecycle (bracketed phases are deterministic transitions; the resting phases + * fold player intents). */ -export const ENGINE_VERSION = '0.0.0'; +/** + * The engine's **replay-compatibility stamp**: it identifies the engine's replay + * semantics, not its API surface. Bump rule — any change that alters the state + * produced by folding an existing event log (the reducer, MeldDetector, + * TimeoutMove, the scorers, the dealer's seed expansion) MUST bump this value; + * API-only or test-only changes must not. Replays recorded under a different + * stamp may reproduce different states when folded by this engine. + * + * `0.1.0` — the royal-marriage attribution fix (`polish-engine-correctness`) + * changes replayed `Meld.cards` composition (totals unchanged). + */ +export const ENGINE_VERSION = '0.1.0'; /** Core domain entities: Card, Deck, Seat, Hand, Bid/Contract, Meld, Trick, ScorePad. */ export * from './domain'; diff --git a/packages/engine/src/meld/meld.test.ts b/packages/engine/src/meld/meld.test.ts index 93eb44f..7b283a2 100644 --- a/packages/engine/src/meld/meld.test.ts +++ b/packages/engine/src/meld/meld.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'; import { getMeldTable, type MeldTable } from '@meldrank/shared/meld'; import type { Rank, Suit } from '@meldrank/shared'; import { MeldDetector } from './meld'; -import { makeCard, type Card } from '../domain/card'; +import { buryableCards } from '../bury/bury'; +import { cardIdentityKey, makeCard, type Card } from '../domain/card'; import { makeHand, type Meld } from '../domain/entities'; /** @@ -114,6 +115,64 @@ describe('MeldDetector — within-class non-reuse', () => { }); }); +describe('MeldDetector — royal-marriage card attribution', () => { + /** The identity keys of a meld's recorded cards, order-insensitive. */ + const keys = (meld: Meld | undefined): string[] => (meld?.cards ?? []).map((c) => cardIdentityKey(c)).sort(); + + it('records the copyIndex-1 pair for a royal marriage beside a run', () => { + const result = detect([...runCards('hearts'), card('K', 'hearts', 1), card('Q', 'hearts', 1)], 'hearts'); + const run = result.melds.find((meld) => meld.type === 'run'); + const royal = result.melds.find((meld) => meld.type === 'royal-marriage'); + + expect(keys(royal)).toEqual([cardIdentityKey(card('K', 'hearts', 1)), cardIdentityKey(card('Q', 'hearts', 1))].sort()); + // Disjoint from the run's recorded copies. + const runKeys = new Set(keys(run)); + expect(royal?.cards.some((c) => runKeys.has(cardIdentityKey(c)))).toBe(false); + }); + + it('borrows only the missing partner when a lone second trump K is held', () => { + const result = detect([...runCards('hearts'), card('K', 'hearts', 1)], 'hearts'); + const run = result.melds.find((meld) => meld.type === 'run'); + const royal = result.melds.find((meld) => meld.type === 'royal-marriage'); + + // The fresh K (copy 1) plus the run's Q (copy 0) — the documented borrow. + expect(keys(royal)).toEqual([cardIdentityKey(card('K', 'hearts', 1)), cardIdentityKey(card('Q', 'hearts', 0))].sort()); + // No K copy appears in both melds. + const runKings = run?.cards.filter((c) => c.rank === 'K') ?? []; + const royalKings = royal?.cards.filter((c) => c.rank === 'K') ?? []; + expect(runKings.map((c) => cardIdentityKey(c))).not.toContain(royalKings.map((c) => cardIdentityKey(c))[0]); + }); + + it('consumes copies in order without reuse for a no-run double pair', () => { + const result = detect([card('K', 'hearts', 0), card('K', 'hearts', 1), card('Q', 'hearts', 0), card('Q', 'hearts', 1)], 'hearts'); + const royals = result.melds.filter((meld) => meld.type === 'royal-marriage'); + + expect(royals).toHaveLength(2); + expect(keys(royals[0])).toEqual([cardIdentityKey(card('K', 'hearts', 0)), cardIdentityKey(card('Q', 'hearts', 0))].sort()); + expect(keys(royals[1])).toEqual([cardIdentityKey(card('K', 'hearts', 1)), cardIdentityKey(card('Q', 'hearts', 1))].sort()); + // No physical card appears in both marriages. + const all = royals.flatMap((meld) => meld.cards.map((c) => cardIdentityKey(c))); + expect(new Set(all).size).toBe(all.length); + }); + + it('feeds the true melded copies to the identity-based no-melded bury exclusion', () => { + // Run (copy 0) + spare K♥1–Q♥1 pair, plus three unmelded fillers. + const fillers = [card('A', 'spades'), card('9', 'clubs'), card('9', 'diamonds')]; + const cards = [...runCards('hearts'), card('K', 'hearts', 1), card('Q', 'hearts', 1), ...fillers]; + const hand = makeHand(0, cards); + const { melds } = MeldDetector(hand, 'hearts', TABLE); + + // `no-melded` alone (no `no-trump`), size 3: no liveness relaxation kicks in. + const eligible = buryableCards(hand, melds, 'hearts', ['no-melded'], 3); + + // Exactly the melded copies are excluded: every recorded meld card is out... + const meldedKeys = new Set(melds.flatMap((meld) => meld.cards.map((c) => cardIdentityKey(c)))); + expect(eligible.some((c) => meldedKeys.has(cardIdentityKey(c)))).toBe(false); + // ...and every unmelded copy stays buryable (here: exactly the fillers). + expect(eligible.map((c) => cardIdentityKey(c)).sort()).toEqual(fillers.map((c) => cardIdentityKey(c)).sort()); + }); +}); + describe('MeldDetector — Class B pinochle', () => { it('scores a Pinochle (Q♠ + J♦) at 40', () => { const result = detect([card('Q', 'spades'), card('J', 'diamonds')], 'hearts'); diff --git a/packages/engine/src/meld/meld.ts b/packages/engine/src/meld/meld.ts index bc788a8..7282593 100644 --- a/packages/engine/src/meld/meld.ts +++ b/packages/engine/src/meld/meld.ts @@ -97,9 +97,15 @@ function detectClassA(cards: readonly Card[], trump: Suit, table: MeldTable): Me const leftoverSingles = Math.max(0, kRemaining) + Math.max(0, qRemaining) - 2 * pairs; const borrow = runCount >= 1 && leftoverSingles >= 1 ? 1 : 0; const royalCount = pairs + borrow; + // Marriage `i` records the copies *past* the run's consumption + // (`runCount + i`): a full pair never clamps — both copies are fresh by + // construction of `pairs` — and only the borrow marriage clamps exactly the + // borrowed side (the leftover single is fresh; its partner is the run's copy). + // Recorded `cards` therefore obey within-class no-reuse at the physical-card + // level, which the identity-based `no-melded` bury exclusion depends on. for (let i = 0; i < royalCount; i++) { - const king = kTrump[Math.min(i, kTrump.length - 1)]!; - const queen = qTrump[Math.min(i, qTrump.length - 1)]!; + const king = kTrump[Math.min(runCount + i, kTrump.length - 1)]!; + const queen = qTrump[Math.min(runCount + i, qTrump.length - 1)]!; melds.push(makeMeld(royalDef.type, [king, queen], royalDef.value, royalDef.class)); } } diff --git a/packages/engine/src/state/index.ts b/packages/engine/src/state/index.ts index 9e2d543..2afca00 100644 --- a/packages/engine/src/state/index.ts +++ b/packages/engine/src/state/index.ts @@ -4,6 +4,6 @@ * phase-guarded, lifecycle-advancing reducer. Wires the `Dealing → Auction` * slice in this change. */ -export { reduce } from './reduce'; +export { reduce, reduceWithVerdict, type RejectionCode, type Rejection, type ReduceOutcome } from './reduce'; export { createInitialState, getContract, type State, type PublicState, type PrivateState, type SeatMeld, type SeatCapture } from './state'; export { EVENT_KINDS, type Event, type EventKind, type SystemEvent, type DealEvent, type TimeoutEvent } from './events'; diff --git a/packages/engine/src/state/reduce.ts b/packages/engine/src/state/reduce.ts index 26b261d..6f216e0 100644 --- a/packages/engine/src/state/reduce.ts +++ b/packages/engine/src/state/reduce.ts @@ -17,6 +17,53 @@ import { appendHand, makeHand, makeTrick, type Hand, type Trick } from '../domai import { createInitialState, getContract, type SeatCapture, type SeatMeld, type State } from './state'; import type { DealEvent, Event } from './events'; +/** + * The closed union of reasons `reduceWithVerdict` can refuse an event (design D2 + * of `polish-engine-correctness`). Deliberately coarse — enough for anti-cheat + * telemetry and a client "move refused" toast without freezing driver internals + * into the public API: + * - `wrong-phase` — the event kind is not legal in the current phase (including + * terminal `MatchComplete` and the redeal-pending `Auction` rest). + * - `out-of-turn` — the event seat is not the seat-to-act / not the contract + * winner / the auction says it is not its turn (or it already passed). + * - `illegal-bid` — off the bid grid: below the floor or not on the increment. + * - `unknown-suit` — a `declareTrump` naming a suit outside the active deck. + * - `card-not-held` — a referenced `CardRef` does not resolve to a held card. + * - `illegal-card` — held but excluded by the `LegalPlayValidator`. + * - `illegal-bury` — wrong size, duplicate, or ineligible card. + * - `no-forced-move` — a `timeout` for the seat-to-act in a state with no + * defined escape (`TimeoutMove` returns `null`; non-acting states only). + * - `config-fault` — an internal invariant guard tripped (null auction, missing + * contract, unresolvable phase or meld table). Unreachable for supported + * variants (guarded by `assessVariantSupport` at spawn), but no longer silent. + */ +export type RejectionCode = + | 'wrong-phase' + | 'out-of-turn' + | 'illegal-bid' + | 'unknown-suit' + | 'card-not-held' + | 'illegal-card' + | 'illegal-bury' + | 'no-forced-move' + | 'config-fault'; + +/** A typed refusal: the closed reason code, no free text (serializable). */ +export interface Rejection { + readonly code: RejectionCode; +} + +/** + * The verdict-bearing result of {@link reduceWithVerdict}: the (possibly + * unchanged) state plus `rejection: null` on acceptance or the typed reason on + * refusal. On rejection `state` is the **same reference** as the input, so + * accepted-vs-rejected stays detectable by reference equality too. + */ +export interface ReduceOutcome { + readonly state: State; + readonly rejection: Rejection | null; +} + /** * `reduce(state, event): State` — the engine's single public driver, per design * decisions 1 and 6 and the "Pure reduce state container" requirement. Pure, @@ -27,6 +74,10 @@ import type { DealEvent, Event } from './events'; * client, and the replay reconstructor all call this identical function, so they * cannot diverge. * + * `reduce` is exactly the `.state` projection of {@link reduceWithVerdict} — + * one shared legality path, no parallel re-derivation — so replay behavior and + * every existing consumer are byte-identical to the pre-verdict reducer. + * * The `timeout` system event is resolved uniformly up front through `TimeoutMove` * (design D4): when it names the seat-to-act, `reduce` computes the deterministic * forced intent (a `pass` where passing is legal, the lowest-value legal card in @@ -59,77 +110,106 @@ import type { DealEvent, Event } from './events'; * match-scope counters preserved). `MatchComplete` rejects every event. */ export function reduce(state: State, event: Event): State { + return reduceWithVerdict(state, event).state; +} + +/** + * `reduceWithVerdict(state, event): { state, rejection }` — the verdict-bearing + * driver behind {@link reduce} (design D2 of `polish-engine-correctness`). The + * identical guards and transitions, with each refusal's typed reason threaded + * out of the internal appliers instead of dying inside: acceptance yields the + * advanced state and `rejection: null`; refusal yields the input state (same + * reference) and a {@link Rejection}. Pure, deterministic, and + * serializable-in/serializable-out like `reduce`. + */ +export function reduceWithVerdict(state: State, event: Event): ReduceOutcome { // Single resolution point for the `timeout` system event (design D4): when it // names the seat-to-act, resolve the forced intent via `TimeoutMove` and re-enter - // `reduce` with it, so the forced move passes the identical phase/turn/legality - // guards a human move does. A timeout for any other seat — or a phase with no - // forced move (`TimeoutMove` returns `null`) — leaves the state unchanged. The - // forced intent is never itself a `timeout`, so this recurs at most one level. + // with it, so the forced move passes the identical phase/turn/legality guards a + // human move does. A timeout for any other seat is out of turn; one whose state + // has no defined escape (`TimeoutMove` returns `null` — non-acting states only) + // reports `no-forced-move`. The forced intent is never itself a `timeout`, so + // this recurs at most one level. if (event.type === 'timeout') { - const forced = event.seat === state.public.seatToAct ? TimeoutMove(state) : null; - return forced === null ? state : reduce(state, forced); + if (event.seat !== state.public.seatToAct) { + return rejected(state, 'out-of-turn'); + } + const forced = TimeoutMove(state); + return forced === null ? rejected(state, 'no-forced-move') : reduceWithVerdict(state, forced); } switch (state.public.phase) { case 'Dealing': - return event.type === 'deal' ? applyDeal(state, event) : state; + return event.type === 'deal' ? applyDeal(state, event) : rejected(state, 'wrong-phase'); case 'Auction': // A redeal-marked state accepts exactly one driving event (design D1): a // fresh `deal` starts the thrown-in hand's replacement via the shared // next-hand helper (dealer rotated one seat, match-scope state preserved). - // A bid/pass would also be rejected by the null-auction guard inside - // `applyAuctionEvent`; the auction concluded when the redeal was marked. + // The auction concluded when the redeal was marked, so any other event — + // including a post-redeal bid/pass — is illegal for this rest. if (state.public.outcome === 'redeal') { - return event.type === 'deal' ? startNextHand(state, event) : state; + return event.type === 'deal' ? startNextHand(state, event) : rejected(state, 'wrong-phase'); } return applyAuctionEvent(state, event); case 'DeclareTrump': - return event.type === 'declareTrump' ? applyDeclareTrump(state, event) : state; + return event.type === 'declareTrump' ? applyDeclareTrump(state, event) : rejected(state, 'wrong-phase'); case 'Bury': - return event.type === 'bury' ? applyBury(state, event) : state; + return event.type === 'bury' ? applyBury(state, event) : rejected(state, 'wrong-phase'); case 'TrickPlay': - return event.type === 'playCard' ? applyPlayCard(state, event) : state; + return event.type === 'playCard' ? applyPlayCard(state, event) : rejected(state, 'wrong-phase'); case 'HandScoring': // The match is provably not over here (over ⇒ `MatchComplete`), so a `deal` // starts the next hand (design D5/D6); every other event is rejected. - return event.type === 'deal' ? startNextHand(state, event) : state; + return event.type === 'deal' ? startNextHand(state, event) : rejected(state, 'wrong-phase'); default: // `MatchComplete` is terminal; every event is rejected without mutation. - return state; + return rejected(state, 'wrong-phase'); } } +/** Wrap an accepted transition's advanced state. */ +function accepted(state: State): ReduceOutcome { + return { state, rejection: null }; +} + +/** Refuse an event: the *input* state back by reference, plus the typed reason. */ +function rejected(state: State, code: RejectionCode): ReduceOutcome { + return { state, rejection: { code } }; +} + /** * Drive the `deal` system event: expand the seed into the shuffle, deal the * hands and widow, open the auction at the seat left of the dealer, and advance * the phase to the variant's next active phase (`Auction`). */ -function applyDeal(state: State, event: DealEvent): State { +function applyDeal(state: State, event: DealEvent): ReduceOutcome { const { variant } = state; const next = nextActivePhase(variant, 'Dealing'); if (next === null) { - return state; + return rejected(state, 'config-fault'); } const rng = createSeededRng(event.seed); const { hands, widow } = deal(variant.deck, variant.dealing.handSize, variant.dealing.widow.size, rng); const auction = openAuction(variant.seating.playerCount, state.public.dealerSeat); - return { + return accepted({ variant, public: { ...state.public, phase: next, seatToAct: auction.toAct, auction }, private: { hands, widow, buried: [] }, - }; + }); } /** * Route an Auction-phase event to the auction module. A `timeout` never reaches - * here — `reduce` resolves it to a forced `pass` up front (design D4) — so this - * sees only the real `bid`/`pass` intents. + * here — `reduceWithVerdict` resolves it to a forced `pass` up front (design D4) + * — so this sees only the real `bid`/`pass` intents. */ -function applyAuctionEvent(state: State, event: Event): State { +function applyAuctionEvent(state: State, event: Event): ReduceOutcome { const auction = state.public.auction; if (auction === null) { - return state; + // Unreachable outside the redeal rest (handled by the caller): an Auction + // phase with no auction sub-state is an internal invariant breach. + return rejected(state, 'config-fault'); } const params = { minimumBid: state.variant.bidding.minimumBid, @@ -144,24 +224,26 @@ function applyAuctionEvent(state: State, event: Event): State { case 'pass': return applyAuctionStep(state, applyPass(auction, params, dealerSeat, event.seat)); default: - return state; + return rejected(state, 'wrong-phase'); } } /** Fold an {@link AuctionStep} back into the state. */ -function applyAuctionStep(state: State, step: AuctionStep): State { +function applyAuctionStep(state: State, step: AuctionStep): ReduceOutcome { switch (step.status) { case 'rejected': - return state; + // The auction module discriminates its refusals (`out-of-turn` vs + // `illegal-bid`); the codes coincide with the verdict taxonomy. + return rejected(state, step.reason); case 'continue': - return { + return accepted({ ...state, public: { ...state.public, auction: step.auction, seatToAct: step.auction.toAct }, - }; + }); case 'won': { const next = nextActivePhase(state.variant, 'Auction'); if (next === null) { - return state; + return rejected(state, 'config-fault'); } // The contract winner is the seat to act while resting at `DeclareTrump` // (design D2): the declaration is a clocked, turn-authoritative move like @@ -175,15 +257,19 @@ function applyAuctionStep(state: State, step: AuctionStep): State { // next active phase, reveal the widow into the bidder's hand and pass // through deterministically to the resting phase (`DeclareTrump`), with // the winner still the seat to act. - return next === 'WidowReveal' ? passThroughWidowReveal(won, step.bid.seatIndex) : won; + if (next !== 'WidowReveal') { + return accepted(won); + } + const revealed = passThroughWidowReveal(won, step.bid.seatIndex); + return revealed === null ? rejected(state, 'config-fault') : accepted(revealed); } case 'redeal': // A redeal is not a lifecycle transition (design D1): conclude the auction - // sub-state (`auction: null` — the null-auction guard then rejects any - // post-redeal bid/pass) and signal the room, which throws the hand in + // sub-state (`auction: null` — the redeal rest then rejects any post-redeal + // bid/pass as wrong-phase) and signal the room, which throws the hand in // unscored and re-deals with the dealer rotated one seat and a fresh seed // (locked all-pass rule 2026-07-01). - return { ...state, public: { ...state.public, outcome: 'redeal', seatToAct: null, auction: null } }; + return accepted({ ...state, public: { ...state.public, outcome: 'redeal', seatToAct: null, auction: null } }); } } @@ -191,12 +277,14 @@ function applyAuctionStep(state: State, step: AuctionStep): State { * Pass through the bracketed `WidowReveal` phase deterministically (design D2): * reveal the widow into the contract winner's hand, record the exposed widow in * public state, and advance to the next active phase (`DeclareTrump`). `state` is - * the just-concluded auction state already resting at `WidowReveal`. + * the just-concluded auction state already resting at `WidowReveal`. Returns + * `null` when the active path cannot advance (a config fault the caller reports + * against the pre-advance state). */ -function passThroughWidowReveal(state: State, winnerSeat: number): State { +function passThroughWidowReveal(state: State, winnerSeat: number): State | null { const next = nextActivePhase(state.variant, 'WidowReveal'); if (next === null) { - return state; + return null; } const revealed = revealWidow(state.private.hands, state.private.widow, winnerSeat); return { @@ -210,16 +298,20 @@ function passThroughWidowReveal(state: State, winnerSeat: number): State { * Drive a `declareTrump` during `DeclareTrump`: legal only from the contract * winner naming one of the active deck's suits (design D3). A legal declaration * records `public.trump` and advances to the variant's next active phase - * (`Melding`); an illegal one leaves the state unchanged. + * (`Melding`); an illegal one leaves the state unchanged — refused as + * `out-of-turn` (not the winner) or `unknown-suit` (a suit outside the deck), + * mapped here so the `declare` module's step stays untouched. */ -function applyDeclareTrump(state: State, event: DeclareTrumpIntent): State { - const step = declareTrump(state.public.contract, state.variant.deck.suits, event.seat, event.trump); +function applyDeclareTrump(state: State, event: DeclareTrumpIntent): ReduceOutcome { + const contract = state.public.contract; + const step = declareTrump(contract, state.variant.deck.suits, event.seat, event.trump); if (step.status === 'rejected') { - return state; + const outOfTurn = contract === null || event.seat !== contract.seatIndex; + return rejected(state, outOfTurn ? 'out-of-turn' : 'unknown-suit'); } const next = nextActivePhase(state.variant, 'DeclareTrump'); if (next === null) { - return state; + return rejected(state, 'config-fault'); } const declared: State = { ...state, @@ -228,7 +320,11 @@ function applyDeclareTrump(state: State, event: DeclareTrumpIntent): State { // `Melding` has no driving event (design D3): when it is the next active phase, // compute each melding seat's meld and pass through deterministically to the // resting phase (`Bury` for Cutthroat, `TrickPlay` for Partners). - return next === 'Melding' ? passThroughMelding(declared, step.trump) : declared; + if (next !== 'Melding') { + return accepted(declared); + } + const melded = passThroughMelding(declared, step.trump); + return melded === null ? rejected(state, 'config-fault') : accepted(melded); } /** @@ -237,16 +333,18 @@ function applyDeclareTrump(state: State, event: DeclareTrumpIntent): State { * and the variant's meld table, record them in `public.melds`, and advance to the * next active phase. `state` is the just-declared state already resting at * `Melding`. Melding seats follow `melding.whoMelds` — every seat for `all-seats` - * (Partners), the contract seat alone for `bidder-only` (Cutthroat). + * (Partners), the contract seat alone for `bidder-only` (Cutthroat). Returns + * `null` on an unresolvable path, meld table, or missing contract at trick entry + * (a config fault the caller reports against the pre-advance state). */ -function passThroughMelding(state: State, trump: Suit): State { +function passThroughMelding(state: State, trump: Suit): State | null { const next = nextActivePhase(state.variant, 'Melding'); if (next === null) { - return state; + return null; } const table = getMeldTable(state.variant.melding.meldTableId); if (table === null) { - return state; + return null; } const melds: SeatMeld[] = meldingSeats(state).map((seatIndex) => { const hand = state.private.hands[seatIndex]!; @@ -275,15 +373,19 @@ function passThroughMelding(state: State, trump: Suit): State { * hand into `private.buried`, advance along the legal `Bury → TrickPlay` edge, and * seed the trick loop with the bidder leading (`enterTrickPlay`). */ -function applyBury(state: State, event: BuryIntent): State { +function applyBury(state: State, event: BuryIntent): ReduceOutcome { const { seatToAct, contract, trump, melds } = state.public; const bidder = contract?.seatIndex; - if (bidder === undefined || event.seat !== bidder || seatToAct !== bidder || trump === null) { - return state; + if (bidder === undefined || trump === null) { + // No recorded contract or trump at `Bury` is an internal invariant breach. + return rejected(state, 'config-fault'); + } + if (event.seat !== bidder || seatToAct !== bidder) { + return rejected(state, 'out-of-turn'); } const hand = state.private.hands[bidder]; if (hand === undefined) { - return state; + return rejected(state, 'config-fault'); } // Resolve each proposed `CardRef` to a held card by identity (`copyIndex` @@ -292,7 +394,7 @@ function applyBury(state: State, event: BuryIntent): State { for (const ref of event.cards) { const held = hand.cards.find((card) => cardsIdentical(card, ref)); if (held === undefined) { - return state; + return rejected(state, 'card-not-held'); } resolved.push(held); } @@ -300,25 +402,25 @@ function applyBury(state: State, event: BuryIntent): State { // The bury must name exactly `bury.size` distinct cards. const size = state.variant.dealing.bury.size; if (resolved.length !== size) { - return state; + return rejected(state, 'illegal-bury'); } const distinct = new Set(resolved.map((card) => cardIdentityKey(card))); if (distinct.size !== resolved.length) { - return state; + return rejected(state, 'illegal-bury'); } // Every named card must be eligible (in the bury-validator's set). const bidderMelds = melds.find((seatMeld) => seatMeld.seatIndex === bidder)?.melds ?? []; const eligible = buryableCards(hand, bidderMelds, trump, state.variant.dealing.bury.restrictions, size); if (!resolved.every((card) => eligible.some((ok) => cardsIdentical(ok, card)))) { - return state; + return rejected(state, 'illegal-bury'); } // Accept: remove the buried cards from the bidder's hand, record the bury pile, // advance to `TrickPlay`, and seed the trick loop with the bidder leading. const next = nextActivePhase(state.variant, 'Bury'); if (next === null) { - return state; + return rejected(state, 'config-fault'); } const remaining = hand.cards.filter((card) => !resolved.some((buried) => cardsIdentical(buried, card))); const hands = state.private.hands.map((seatHand) => (seatHand.seatIndex === bidder ? makeHand(bidder, remaining) : seatHand)); @@ -327,16 +429,28 @@ function applyBury(state: State, event: BuryIntent): State { public: { ...state.public, phase: next }, private: { ...state.private, hands, buried: resolved }, }; - return next === 'TrickPlay' ? enterTrickPlay(buried) : buried; + if (next !== 'TrickPlay') { + return accepted(buried); + } + const entered = enterTrickPlay(buried); + return entered === null ? rejected(state, 'config-fault') : accepted(entered); } /** * Seed the `TrickPlay` loop on entry (design D5): the bid winner (the recorded * contract seat) leads the first trick with a fresh empty `currentTrick`, and the - * per-seat capture tally starts at zero for every dealt seat. + * per-seat capture tally starts at zero for every dealt seat. Entering with no + * recorded contract is a configuration fault — `null` back to the caller (which + * reports it against the pre-advance state) rather than silently seating seat 0. + * + * Exported for the guard's unit test only (every `reduce` route records the + * contract before trick entry); not re-exported by the package barrel. */ -function enterTrickPlay(state: State): State { - const leader = state.public.contract?.seatIndex ?? 0; +export function enterTrickPlay(state: State): State | null { + const leader = state.public.contract?.seatIndex; + if (leader === undefined) { + return null; + } const captured: SeatCapture[] = state.private.hands.map((hand) => ({ seatIndex: hand.seatIndex, counters: 0, @@ -356,25 +470,29 @@ function enterTrickPlay(state: State): State { * current trick (setting the led suit on the first play), and pass the turn — * resolving the trick when one play per seat has landed. */ -function applyPlayCard(state: State, event: PlayCardIntent): State { +function applyPlayCard(state: State, event: PlayCardIntent): ReduceOutcome { const { seatToAct, currentTrick, trump } = state.public; - if (seatToAct === null || event.seat !== seatToAct || trump === null) { - return state; + if (seatToAct === null || event.seat !== seatToAct) { + return rejected(state, 'out-of-turn'); + } + if (trump === null) { + // No recorded trump during `TrickPlay` is an internal invariant breach. + return rejected(state, 'config-fault'); } const hand = state.private.hands[event.seat]; if (hand === undefined) { - return state; + return rejected(state, 'config-fault'); } // Resolve the wire `CardRef` to a physical card by identity (copyIndex // disambiguates the two copies); reject if the seat does not hold it. const card = hand.cards.find((held) => cardsIdentical(held, event.card)); if (card === undefined) { - return state; + return rejected(state, 'card-not-held'); } // Reject any card the legal set excludes. const legal = LegalPlayValidator(hand, currentTrick, trump, state.variant.trick); if (!legal.some((legalCard) => cardsIdentical(legalCard, card))) { - return state; + return rejected(state, 'illegal-card'); } const playerCount = state.variant.seating.playerCount; @@ -386,11 +504,11 @@ function applyPlayCard(state: State, event: PlayCardIntent): State { if (plays.length < playerCount) { // The trick continues: the next seat in order acts. const nextSeat = (event.seat + 1) % playerCount; - return { + return accepted({ ...state, public: { ...state.public, currentTrick: trick, seatToAct: nextSeat }, private: { ...state.private, hands }, - }; + }); } return resolveCompletedTrick(state, trick, hands, trump); } @@ -401,7 +519,7 @@ function applyPlayCard(state: State, event: PlayCardIntent): State { * record the resolved trick, start a fresh `currentTrick`, and set the winner to * lead next — or, when the hand is complete, advance to `HandScoring`. */ -function resolveCompletedTrick(state: State, trick: Trick, hands: readonly Hand[], trump: Suit): State { +function resolveCompletedTrick(state: State, trick: Trick, hands: readonly Hand[], trump: Suit): ReduceOutcome { const winnerSeat = TrickResolver(trick, trump); const resolved = makeTrick(trick.ledSuit, trick.plays, winnerSeat); const handsEmpty = hands.every((hand) => hand.cards.length === 0); @@ -419,21 +537,28 @@ function resolveCompletedTrick(state: State, trick: Trick, hands: readonly Hand[ if (handsEmpty) { // The final trick is resolved: advance along the legal edge to `HandScoring`. const next = nextActivePhase(state.variant, 'TrickPlay'); + if (next === null) { + return rejected(state, 'config-fault'); + } const advanced: State = { ...state, - public: { ...publicBase, phase: next ?? state.public.phase, seatToAct: null }, + public: { ...publicBase, phase: next, seatToAct: null }, private: { ...state.private, hands }, }; // `HandScoring` has no driving event (design D6): when it is the next active // phase, compute the hand score deterministically and rest there. - return next === 'HandScoring' ? passThroughHandScoring(advanced) : advanced; + if (next !== 'HandScoring') { + return accepted(advanced); + } + const scored = passThroughHandScoring(advanced); + return scored === null ? rejected(state, 'config-fault') : accepted(scored); } // The trick winner leads the next trick. - return { + return accepted({ ...state, public: { ...publicBase, seatToAct: winnerSeat }, private: { ...state.private, hands }, - }; + }); } /** @@ -449,12 +574,13 @@ function resolveCompletedTrick(state: State, trick: Trick, hands: readonly Hand[ * over, advance along the legal `HandScoring → MatchComplete` edge, record the * `MatchResult`, clear the seat-to-act, and rest terminally; otherwise rest at * `HandScoring` awaiting the next `deal`. `state` is the just-advanced state already - * resting at `HandScoring`. + * resting at `HandScoring`. Returns `null` when no contract is recorded (a config + * fault the caller reports against the pre-advance state). */ -function passThroughHandScoring(state: State): State { +function passThroughHandScoring(state: State): State | null { const contract = getContract(state); if (contract === null) { - return state; + return null; } // Sum the buried cards' counter values (§9): credited to the bidding side at // scoring (design D5). The Partners path has an empty bury pile, so this is `0`. @@ -488,7 +614,7 @@ function passThroughHandScoring(state: State): State { * (landing at `Auction`). The match is provably not over here, so the deal is * always valid. */ -function startNextHand(state: State, event: DealEvent): State { +function startNextHand(state: State, event: DealEvent): ReduceOutcome { const { variant } = state; const nextDealer = (state.public.dealerSeat + 1) % variant.seating.playerCount; const base = createInitialState(variant, nextDealer); @@ -500,7 +626,10 @@ function startNextHand(state: State, event: DealEvent): State { handsMadeAsBidder: state.public.handsMadeAsBidder, }, }; - return applyDeal(freshBase, event); + const outcome = applyDeal(freshBase, event); + // A refusal must return the *caller's* input state by reference, not the + // intermediate fresh base. + return outcome.rejection === null ? outcome : rejected(state, outcome.rejection.code); } /** Return new hands with `card` removed from `seat`'s hand; other hands untouched. */ diff --git a/packages/engine/src/state/timeout.test.ts b/packages/engine/src/state/timeout.test.ts index 9e5a057..b29f14c 100644 --- a/packages/engine/src/state/timeout.test.ts +++ b/packages/engine/src/state/timeout.test.ts @@ -88,10 +88,12 @@ describe('reduce — a timeout that resolves to no forced move is a no-op', () = expect(reduce(dealt, { type: 'timeout', seat: 0 })).toBe(dealt); // seat 1 is to act }); - it('a timeout during DeclareTrump is a no-op (no forced declaration)', () => { + it('a timeout in a no-escape state is a no-op (undealt DeclareTrump: no hand to derive the forced declaration from)', () => { const base = createInitialState(SINGLE_DECK_PARTNERS, 0); - // Construct a DeclareTrump state with a seat explicitly on the clock: even - // then, TimeoutMove defines no move, so reduce leaves the state unchanged. + // Every acting phase defines a forced move, so a null TimeoutMove needs a + // degenerate state: a DeclareTrump rest with a seat on the clock but no dealt + // hands, where the forced-declare arm has nothing to inspect. reduce leaves + // the state unchanged (`no-forced-move` via reduceWithVerdict). const declaring: State = { ...base, public: { diff --git a/packages/engine/src/state/verdict.test.ts b/packages/engine/src/state/verdict.test.ts new file mode 100644 index 0000000..07dbbbc --- /dev/null +++ b/packages/engine/src/state/verdict.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest'; +import { SINGLE_DECK_CUTTHROAT, SINGLE_DECK_PARTNERS, type Rank, type Suit } from '@meldrank/shared'; +import { enterTrickPlay, reduce, reduceWithVerdict } from './reduce'; +import { createInitialState, type SeatMeld, type State } from './state'; +import { makeCard, type Card } from '../domain/card'; +import { makeHand, makeMeld, makeTrick } from '../domain/entities'; +import type { Event } from './events'; + +/** + * The verdict channel (design D2 of `polish-engine-correctness`): + * `reduceWithVerdict` refuses with a typed {@link RejectionCode} and the input + * state back **by reference**, accepts with `rejection: null` and a distinct + * advanced state, and `reduce` is exactly its `.state` projection. The oracle is + * the `hand-state-container` "Rejection verdict surfacing" requirement. + */ + +function card(rank: Rank, suit: Suit, copyIndex = 0): Card { + return makeCard(rank, suit, copyIndex); +} + +function play(seat: number, c: Card): Event { + return { type: 'playCard', seat, card: { rank: c.rank, suit: c.suit, copyIndex: c.copyIndex } }; +} + +function bury(seat: number, cards: readonly Card[]): Event { + return { + type: 'bury', + seat, + cards: cards.map((c) => ({ rank: c.rank, suit: c.suit, copyIndex: c.copyIndex })), + }; +} + +/** + * A refusal reduced to an assertable shape: the reason code (or `null` on + * acceptance) plus whether the input state came back by reference — so each + * test pins both halves of the rejected-path contract in one `expect`. + */ +function refusal(state: State, event: Event): { code: string | null; sameRef: boolean } { + const outcome = reduceWithVerdict(state, event); + return { code: outcome.rejection?.code ?? null, sameRef: outcome.state === state }; +} + +/** A `TrickPlay` state seeded as `reduce` seeds it: leader to act, empty trick, zeroed tally. */ +function trickPlayState(hands: readonly (readonly Card[])[], trump: Suit, leader: number): State { + const base = createInitialState(SINGLE_DECK_PARTNERS, 0); + const handObjs = hands.map((cards, seatIndex) => makeHand(seatIndex, cards)); + return { + ...base, + public: { + ...base.public, + phase: 'TrickPlay', + trump, + contract: { seatIndex: leader, value: 250 }, + seatToAct: leader, + currentTrick: makeTrick(), + captured: handObjs.map((hand) => ({ seatIndex: hand.seatIndex, counters: 0, tricksTaken: 0 })), + }, + private: { ...base.private, hands: handObjs }, + }; +} + +const HANDS = [ + [card('A', 'spades'), card('9', 'clubs')], // seat 0 — leader + [card('10', 'spades'), card('9', 'hearts')], // seat 1 — holds a spade, must follow + [card('Q', 'spades'), card('J', 'spades')], // seat 2 + [card('9', 'hearts', 1), card('J', 'hearts')], // seat 3 +] as const; + +/** A Cutthroat state resting at `Bury` with the bidder (seat 0) set to act. */ +function buryState(): State { + const base = createInitialState(SINGLE_DECK_CUTTHROAT, 0); + const free = [card('10', 'clubs'), card('Q', 'diamonds'), card('K', 'diamonds')]; + const hands = [ + makeHand(0, [...free, card('K', 'hearts'), card('A', 'spades'), card('9', 'spades')]), + makeHand(1, [card('9', 'clubs'), card('J', 'clubs')]), + makeHand(2, [card('9', 'diamonds'), card('J', 'diamonds')]), + ]; + const melds: SeatMeld[] = [{ seatIndex: 0, melds: [makeMeld('marriage', [card('K', 'hearts')], 0, 'A')], total: 0 }]; + return { + ...base, + public: { + ...base.public, + phase: 'Bury', + trump: 'spades', + contract: { seatIndex: 0, value: 300 }, + seatToAct: 0, + melds, + }, + private: { ...base.private, hands }, + }; +} + +/** A `DeclareTrump` state resting with the contract winner (seat 2) on the clock. */ +function declareState(): State { + const base = createInitialState(SINGLE_DECK_PARTNERS, 0); + return { + ...base, + public: { ...base.public, phase: 'DeclareTrump', seatToAct: 2, contract: { seatIndex: 2, value: 250 } }, + }; +} + +describe('reduceWithVerdict — every rejection code is producible', () => { + it('wrong-phase: an event kind illegal for the current phase', () => { + const init = createInitialState(SINGLE_DECK_PARTNERS); + expect(refusal(init, { type: 'bid', seat: 1, value: 250 })).toEqual({ code: 'wrong-phase', sameRef: true }); + }); + + it('out-of-turn: an auction move from a seat that is not to act', () => { + const dealt = reduce(createInitialState(SINGLE_DECK_PARTNERS), { type: 'deal', seed: 7 }); + expect(dealt.public.seatToAct).toBe(1); + expect(refusal(dealt, { type: 'pass', seat: 0 })).toEqual({ code: 'out-of-turn', sameRef: true }); + }); + + it('out-of-turn: an out-of-turn playCard during TrickPlay (spec scenario)', () => { + const state = trickPlayState(HANDS, 'hearts', 0); + expect(refusal(state, play(2, card('Q', 'spades')))).toEqual({ code: 'out-of-turn', sameRef: true }); + }); + + it('out-of-turn: a declareTrump from a seat other than the contract winner', () => { + expect(refusal(declareState(), { type: 'declareTrump', seat: 1, trump: 'hearts' })).toEqual({ code: 'out-of-turn', sameRef: true }); + }); + + it('illegal-bid: a bid off the grid or below the floor', () => { + const dealt = reduce(createInitialState(SINGLE_DECK_PARTNERS), { type: 'deal', seed: 7 }); + const { minimumBid, increment } = SINGLE_DECK_PARTNERS.bidding; + expect(refusal(dealt, { type: 'bid', seat: 1, value: minimumBid - increment })).toEqual({ code: 'illegal-bid', sameRef: true }); + }); + + it('unknown-suit: a declareTrump naming a suit outside the deck', () => { + expect(refusal(declareState(), { type: 'declareTrump', seat: 2, trump: 'stars' as Suit })).toEqual({ + code: 'unknown-suit', + sameRef: true, + }); + }); + + it('card-not-held: a playCard whose CardRef resolves to no held card', () => { + const state = trickPlayState(HANDS, 'hearts', 0); + expect(refusal(state, play(0, card('A', 'clubs')))).toEqual({ code: 'card-not-held', sameRef: true }); + }); + + it('card-not-held: a bury naming a card the bidder does not hold', () => { + const state = buryState(); + expect(refusal(state, bury(0, [card('10', 'clubs'), card('Q', 'diamonds'), card('A', 'clubs')]))).toEqual({ + code: 'card-not-held', + sameRef: true, + }); + }); + + it('illegal-card: a held card the LegalPlayValidator excludes', () => { + const led = reduce(trickPlayState(HANDS, 'hearts', 0), play(0, card('A', 'spades'))); + expect(led.public.seatToAct).toBe(1); + // Seat 1 holds the 10♠ so it must follow the led spade; the 9♥ is held but illegal. + expect(refusal(led, play(1, card('9', 'hearts')))).toEqual({ code: 'illegal-card', sameRef: true }); + }); + + it('illegal-bury: a wrong-sized, duplicated, or ineligible bury', () => { + const state = buryState(); + const rejectedBury = { code: 'illegal-bury', sameRef: true }; + expect(refusal(state, bury(0, [card('10', 'clubs'), card('Q', 'diamonds')]))).toEqual(rejectedBury); // too few + expect(refusal(state, bury(0, [card('10', 'clubs'), card('Q', 'diamonds'), card('10', 'clubs')]))).toEqual(rejectedBury); // duplicate + expect(refusal(state, bury(0, [card('10', 'clubs'), card('Q', 'diamonds'), card('A', 'spades')]))).toEqual(rejectedBury); // trump is ineligible + }); + + it('no-forced-move: a seat-to-act timeout in a state with no defined escape (spec scenario)', () => { + // A DeclareTrump rest with no dealt hands: the seat is on the clock but the + // forced-declare arm has no hand to inspect, so TimeoutMove returns null. + const state = declareState(); + expect(refusal(state, { type: 'timeout', seat: 2 })).toEqual({ code: 'no-forced-move', sameRef: true }); + }); + + it('config-fault: a bury in a Bury-phase state with no recorded contract', () => { + const state = buryState(); + const noContract: State = { ...state, public: { ...state.public, contract: null } }; + expect(refusal(noContract, bury(0, [card('10', 'clubs'), card('Q', 'diamonds'), card('K', 'diamonds')]))).toEqual({ + code: 'config-fault', + sameRef: true, + }); + }); + + it('out-of-turn: a timeout naming a seat that is not to act', () => { + const state = trickPlayState(HANDS, 'hearts', 0); + expect(refusal(state, { type: 'timeout', seat: 2 })).toEqual({ code: 'out-of-turn', sameRef: true }); + }); +}); + +describe('reduceWithVerdict — accepted events', () => { + it('yields rejection: null and a distinct advanced state (spec scenario)', () => { + const init = createInitialState(SINGLE_DECK_PARTNERS); + const outcome = reduceWithVerdict(init, { type: 'deal', seed: 7 }); + + expect(outcome.rejection).toBeNull(); + expect(outcome.state).not.toBe(init); + expect(outcome.state.public.phase).toBe('Auction'); + }); + + it('accepts a seat-to-act timeout by applying the forced move', () => { + const dealt = reduce(createInitialState(SINGLE_DECK_PARTNERS), { type: 'deal', seed: 7 }); + const outcome = reduceWithVerdict(dealt, { type: 'timeout', seat: 1 }); + + expect(outcome.rejection).toBeNull(); + expect(outcome.state).not.toBe(dealt); + expect(outcome.state.public.auction?.live[1]).toBe(false); + }); +}); + +describe('reduce is the .state projection of reduceWithVerdict (spec scenario)', () => { + it('deep-equals for an accepted event', () => { + const init = createInitialState(SINGLE_DECK_PARTNERS); + const event: Event = { type: 'deal', seed: 4242 }; + expect(reduce(init, event)).toEqual(reduceWithVerdict(init, event).state); + }); + + it('deep-equals (and reference-equals the input) for a rejected event', () => { + const dealt = reduce(createInitialState(SINGLE_DECK_PARTNERS), { type: 'deal', seed: 7 }); + const event: Event = { type: 'pass', seat: 0 }; // seat 1 is to act + expect(reduce(dealt, event)).toEqual(reduceWithVerdict(dealt, event).state); + expect(reduce(dealt, event)).toBe(dealt); + }); +}); + +describe('enterTrickPlay — the missing-contract guard (spec scenario)', () => { + it('returns null for a state with no recorded contract, never seating seat 0', () => { + const state = trickPlayState(HANDS, 'hearts', 2); + const noContract: State = { ...state, public: { ...state.public, contract: null, seatToAct: null } }; + expect(enterTrickPlay(noContract)).toBeNull(); + }); + + it('seats the recorded contract winner as leader when one exists', () => { + const state = trickPlayState(HANDS, 'hearts', 2); + const entered = enterTrickPlay(state); + expect(entered?.public.seatToAct).toBe(2); + expect(entered?.public.currentTrick).toEqual(makeTrick()); + }); +}); diff --git a/packages/engine/src/timeout/escape.property.test.ts b/packages/engine/src/timeout/escape.property.test.ts new file mode 100644 index 0000000..0a85322 --- /dev/null +++ b/packages/engine/src/timeout/escape.property.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { SINGLE_DECK_CUTTHROAT, SINGLE_DECK_PARTNERS, type VariantDefinition } from '@meldrank/shared'; +import { TimeoutMove } from './timeout'; +import { reduce } from '../state/reduce'; +import { createInitialState, type State } from '../state/state'; + +/** + * Timeout escape completeness as an end-to-end property (design D3 of + * `polish-engine-correctness`; spec `timeout-move` "Escape completeness over + * reachable states"): for every state reachable by folding a full match, whenever + * `seatToAct` is non-null, `TimeoutMove` returns a non-null intent and reducing + * the matching `timeout` advances the state. The sweep folds complete matches for + * both canonical variants across a fixed seed range, driven by forced moves. + * + * A *purely* timeout-driven fold cannot terminate for Cutthroat — every + * all-timeout auction all-passes, and the `redeal` all-pass rule plus the + * `fixed-deals` match end (which counts only scored hands) would redeal forever — + * so designated Cutthroat hands inject one deterministic opening minimum bid + * (the first acting seat), with the escape property asserted at that state + * *before* the bid is substituted. A fixed cadence of hands stays pure-timeout so + * the all-pass redeal boundary is exercised too. Partners needs no opener + * (`dealer-forced-minimum` guarantees a contract) and stays pure-timeout. + */ + +/** The fixed, deterministic seed sweep (no randomness — replayable verbatim). */ +const SEEDS = Array.from({ length: 32 }, (_, index) => index + 1); + +/** Fail-fast ceiling on events per fold: a stall trips this long before a runner timeout. */ +const EVENT_BUDGET = 10_000; + +/** Every third Cutthroat hand is left pure-timeout to exercise the all-pass redeal. */ +function isAllPassHand(variant: VariantDefinition, handIndex: number): boolean { + return variant.bidding.allPassRule === 'redeal' && handIndex % 3 === 2; +} + +/** + * Assert the escape property at an acting state: `TimeoutMove` defines a forced + * move and reducing the matching `timeout` advances the state. Returns the + * advanced state. + */ +function assertEscape(state: State, context: string): State { + const seat = state.public.seatToAct; + expect(seat, `acting state expected ${context}`).not.toBeNull(); + const forced = TimeoutMove(state); + if (forced === null) { + expect.fail(`TimeoutMove returned null at an acting state ${context}`); + } + const next = reduce(state, { type: 'timeout', seat: seat! }); + if (next === state) { + expect.fail(`a seat-to-act timeout did not advance the state ${context}`); + } + return next; +} + +/** Fold one full match by forced moves; returns coverage counters for the sweep asserts. */ +function foldMatch(variant: VariantDefinition, baseSeed: number): { scoredHands: number; redeals: number } { + let state = createInitialState(variant, 0); + let handIndex = 0; + let redeals = 0; + let openerPending = false; + + for (let events = 0; state.public.phase !== 'MatchComplete'; events++) { + if (events >= EVENT_BUDGET) { + expect.fail(`fold stalled: ${variant.id} seed ${baseSeed} exceeded ${EVENT_BUDGET} events at ${state.public.phase}`); + } + const context = `(${variant.id} seed ${baseSeed} hand ${handIndex} phase ${state.public.phase})`; + + if (state.public.seatToAct === null) { + // A resting state with nobody on the clock must be a hand boundary awaiting + // the next seeded deal: `Dealing` (first hand), a scored `HandScoring`, or + // the Cutthroat all-pass redeal rest. + const redealRest = state.public.phase === 'Auction' && state.public.outcome === 'redeal'; + const atBoundary = state.public.phase === 'Dealing' || state.public.phase === 'HandScoring' || redealRest; + if (!atBoundary) { + expect.fail(`fold rested outside a hand boundary ${context}`); + } + if (redealRest) { + redeals++; + } + const dealt = reduce(state, { type: 'deal', seed: baseSeed * 1000 + handIndex }); + expect(dealt, `deal did not advance ${context}`).not.toBe(state); + openerPending = !isAllPassHand(variant, handIndex); + handIndex++; + state = dealt; + continue; + } + + // An acting state: the escape property must hold here regardless of which + // event the fold actually applies next. + const viaTimeout = assertEscape(state, context); + + if (openerPending && state.public.phase === 'Auction' && variant.bidding.allPassRule === 'redeal') { + // Designated Cutthroat opener: substitute the deterministic minimum bid so + // the hand gets a contract and the post-auction forced-move arms are reached. + const bid = reduce(state, { type: 'bid', seat: state.public.seatToAct, value: variant.bidding.minimumBid }); + expect(bid, `opening minimum bid rejected ${context}`).not.toBe(state); + openerPending = false; + state = bid; + continue; + } + state = viaTimeout; + } + + return { scoredHands: state.public.scorePad.hands.length, redeals }; +} + +describe.each([ + ['SINGLE_DECK_PARTNERS', SINGLE_DECK_PARTNERS], + ['SINGLE_DECK_CUTTHROAT', SINGLE_DECK_CUTTHROAT], +] as const)('timeout escape completeness — %s seed sweep', (_name, variant) => { + it('every reachable acting state has a state-advancing escape and every fold terminates', () => { + let redeals = 0; + let scoredHands = 0; + for (const seed of SEEDS) { + const fold = foldMatch(variant, seed); + redeals += fold.redeals; + scoredHands += fold.scoredHands; + } + // Every fold reached MatchComplete (or expect.fail would have tripped); the + // sweep must also have genuinely scored hands, and exactly the redeal variant + // must have passed through the all-pass redeal boundary. + expect(scoredHands).toBeGreaterThan(0); + expect(redeals > 0).toBe(variant.bidding.allPassRule === 'redeal'); + }); +}); From 1c3b40f4f1e63440f342ba87ba0539c5bab98b15 Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Fri, 3 Jul 2026 11:38:01 -0400 Subject: [PATCH 11/16] refactor(shared): prune dead exports and dedupe suit vocabulary Extract the four-suit tuple into variant/suits.ts and reuse it from schema.ts, canonical.ts, and meld-table.ts instead of three copies. Remove exports with no remaining callers: pingRedis, widowEnabled/ buryEnabled/passingEnabled, TRACE_ID_FIELD, and PACKAGE_NAME. Un-barrel canonicalJson from the variant surface, and update stale doc comments (errors.ts, db/client.ts, trace.ts) to match current behavior. --- packages/shared/src/api/errors.ts | 12 ++++++------ packages/shared/src/api/index.ts | 2 +- packages/shared/src/api/trace.ts | 16 +++++++--------- packages/shared/src/index.test.ts | 6 +----- packages/shared/src/index.ts | 8 ++------ packages/shared/src/server/db/client.ts | 4 +++- packages/shared/src/server/index.ts | 2 +- packages/shared/src/server/redis/client.ts | 9 --------- packages/shared/src/variant/canonical.ts | 8 ++++---- packages/shared/src/variant/index.ts | 12 ++++++------ packages/shared/src/variant/meld-table.ts | 7 +++++++ packages/shared/src/variant/schema.test.ts | 17 +++++------------ packages/shared/src/variant/schema.ts | 20 +++----------------- packages/shared/src/variant/suits.ts | 8 ++++++++ 14 files changed, 54 insertions(+), 77 deletions(-) create mode 100644 packages/shared/src/variant/suits.ts diff --git a/packages/shared/src/api/errors.ts b/packages/shared/src/api/errors.ts index 357ebef..d6abaa3 100644 --- a/packages/shared/src/api/errors.ts +++ b/packages/shared/src/api/errors.ts @@ -6,12 +6,12 @@ import { z } from 'zod'; * one of these codes rather than leaking an ad-hoc shape, so the client can react * by code, not by message. * - * This slice only ever emits `validation`, `not-found`, and `conflict` — the codes - * the walking skeleton genuinely hits. `unauthorized`, `forbidden`, and - * `rate-limited` stay **reserved** in the taxonomy for the Clerk-identity and - * rate-limiting slices (unit E +) so the vocabulary is stable before those land. A - * spawn-gateway failure is a server fault, not a client-facing code — it surfaces - * as a standard internal error. + * The API emits `validation`, `not-found`, `conflict`, and — since the Clerk + * identity slice landed — `unauthorized` (a request without a valid Clerk + * session). `forbidden` and `rate-limited` stay **reserved** in the taxonomy for + * the slices that will emit them, so the vocabulary is stable before those land. + * A spawn-gateway failure is a server fault, not a client-facing code — it + * surfaces as a standard internal error. */ export const API_ERROR_CODES = ['unauthorized', 'forbidden', 'not-found', 'rate-limited', 'validation', 'conflict'] as const; diff --git a/packages/shared/src/api/index.ts b/packages/shared/src/api/index.ts index a0203f0..4e1203b 100644 --- a/packages/shared/src/api/index.ts +++ b/packages/shared/src/api/index.ts @@ -57,7 +57,7 @@ export { type ReleaseCommand, type RecruitmentAck, } from './recruit'; -export { TRACE_ID_FIELD, TRACE_ID_HEADER } from './trace'; +export { TRACE_ID_HEADER } from './trace'; export { SpawnSeatSchema, RoomSpawnRequestSchema, diff --git a/packages/shared/src/api/trace.ts b/packages/shared/src/api/trace.ts index a181c46..434b4ca 100644 --- a/packages/shared/src/api/trace.ts +++ b/packages/shared/src/api/trace.ts @@ -1,17 +1,15 @@ /** * The cross-service trace-correlation convention (capability `structured-logging`, - * design D4): the single `traceId` log field name and the `x-meldrank-trace-id` HTTP - * propagation header, reserved once here and reused wherever the id is carried so no - * service hardcodes a local literal. Browser-safe (plain strings, no driver) so a - * future web origin can adopt the same constants without redefining them. + * design D4): the `x-meldrank-trace-id` HTTP propagation header, reserved once here + * and reused wherever the id is carried so no service hardcodes a local literal. + * Browser-safe (a plain string, no driver) so a future web origin can adopt the + * same constant without redefining it. * * Today the id is originated by `api` (per request) and carried across the one - * service hop that exists — the internal spawn `POST` — onto the match room's logger. - * Full request-scoped propagation and the web origin are deferred (design D4). + * service hop that exists — the internal spawn `POST` — onto the match room's logger + * (bound under the `traceId` log field). Full request-scoped propagation and the + * web origin are deferred (design D4). */ -/** The log field name a correlation id is bound under (`{ traceId }`). */ -export const TRACE_ID_FIELD = 'traceId'; - /** The HTTP header the correlation id propagates in across service hops. */ export const TRACE_ID_HEADER = 'x-meldrank-trace-id'; diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 875142d..07463ee 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { PACKAGE_NAME, healthy, HealthSchema, VariantDefinitionSchema, SINGLE_DECK_PARTNERS } from './index'; +import { healthy, HealthSchema, VariantDefinitionSchema, SINGLE_DECK_PARTNERS } from './index'; describe('@meldrank/shared', () => { - it('exposes its package name', () => { - expect(PACKAGE_NAME).toBe('@meldrank/shared'); - }); - it('produces a Zod-validated health record', () => { const health = healthy('test'); expect(health).toEqual({ service: 'test', ok: true }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index efa7457..f526a3f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,18 +8,14 @@ import { z } from 'zod'; * plumbing (env loaders, db/redis clients) lives behind `@meldrank/shared/server`. */ -/** Name of this package — handy as a smoke-test symbol for cross-package imports. */ -export const PACKAGE_NAME = '@meldrank/shared'; - /** Isomorphic env helpers: the generic loader and the public web contract. */ export { EnvValidationError, parseEnv } from './env/load'; export { webEnv, webEnvKeys, loadWebEnv, type WebEnv } from './env/web'; /** * Variant Definition: the schema that parameterizes a pinochle game, its - * inferred type, the phase-gating predicates, and the two frozen canonical - * ranked rulesets. This is the keystone that drives one engine across many - * variants. + * inferred type, and the two frozen canonical ranked rulesets. This is the + * keystone that drives one engine across many variants. */ export * from './variant'; diff --git a/packages/shared/src/server/db/client.ts b/packages/shared/src/server/db/client.ts index 70cd010..ba1d5d1 100644 --- a/packages/shared/src/server/db/client.ts +++ b/packages/shared/src/server/db/client.ts @@ -11,7 +11,9 @@ export interface DatabaseEnv { * Construct a Drizzle client over the Neon serverless (HTTP) driver from the * validated environment. The HTTP driver is connection-pool-free, so the same * client works on Vercel serverless (`apps/api`) and the long-lived Fly service - * (`apps/match`). The schema is empty until the Data Model change adds tables. + * (`apps/match`). The bound schema is the full data model (`./schema`): players + * and bot profiles, matches and participants, per-hand results and score lines, + * replay blobs, and abandon events. * * Server-only: exported from `@meldrank/shared/server` so the Neon driver never * reaches the `apps/web` browser bundle. diff --git a/packages/shared/src/server/index.ts b/packages/shared/src/server/index.ts index 39dde8c..7bc5610 100644 --- a/packages/shared/src/server/index.ts +++ b/packages/shared/src/server/index.ts @@ -23,4 +23,4 @@ export { createLogger, type Logger, type LogLevel, type LogService, type CreateL * The cross-service trace-correlation convention (design D4). Re-exported here so server * code reaches it from the same surface as the logger; also on the isomorphic root. */ -export { TRACE_ID_FIELD, TRACE_ID_HEADER } from '../api/trace'; +export { TRACE_ID_HEADER } from '../api/trace'; diff --git a/packages/shared/src/server/redis/client.ts b/packages/shared/src/server/redis/client.ts index 90bce2b..db985b3 100644 --- a/packages/shared/src/server/redis/client.ts +++ b/packages/shared/src/server/redis/client.ts @@ -19,12 +19,3 @@ export function createRedis(env: RedisEnv): Redis { } export type RedisClient = Redis; - -/** - * Connectivity check: returns `true` when the server answers `PING` with - * `PONG`. The full extent of Redis usage in this change. - */ -export async function pingRedis(redis: Redis): Promise { - const reply = await redis.ping(); - return reply === 'PONG'; -} diff --git a/packages/shared/src/variant/canonical.ts b/packages/shared/src/variant/canonical.ts index 2792f6b..7a955ae 100644 --- a/packages/shared/src/variant/canonical.ts +++ b/packages/shared/src/variant/canonical.ts @@ -1,4 +1,5 @@ import { VariantDefinitionSchema, type VariantDefinition } from './schema'; +import { SUITS } from './suits'; /** * The two canonical ranked rulesets, encoded as deeply-frozen @@ -12,9 +13,8 @@ import { VariantDefinitionSchema, type VariantDefinition } from './schema'; * construction so an authoring mistake fails loudly here, not downstream. */ -/** The standard single-deck ranks and suits shared by both canonical variants. */ +/** The standard single-deck ranks shared by both canonical variants. */ const SINGLE_DECK_RANKS = ['A', '10', 'K', 'Q', 'J', '9'] as const; -const ALL_SUITS = ['spades', 'hearts', 'clubs', 'diamonds'] as const; /** Standard pinochle counter values and last-trick bonus, common to both variants. */ const STANDARD_COUNTERS = { A: 11, '10': 10, K: 4, Q: 3, J: 2, '9': 0 } as const; @@ -45,7 +45,7 @@ export const SINGLE_DECK_PARTNERS: Readonly = deepFreeze( VariantDefinitionSchema.parse({ id: 'single-deck-partners', name: 'Single-Deck Partners', - deck: { ranks: SINGLE_DECK_RANKS, suits: ALL_SUITS, copiesPerCard: 2 }, + deck: { ranks: SINGLE_DECK_RANKS, suits: SUITS, copiesPerCard: 2 }, seating: { playerCount: 4, teams: { @@ -99,7 +99,7 @@ export const SINGLE_DECK_CUTTHROAT: Readonly = deepFreeze( VariantDefinitionSchema.parse({ id: 'single-deck-cutthroat', name: 'Single-Deck Cutthroat / Auction Pinochle', - deck: { ranks: SINGLE_DECK_RANKS, suits: ALL_SUITS, copiesPerCard: 2 }, + deck: { ranks: SINGLE_DECK_RANKS, suits: SUITS, copiesPerCard: 2 }, seating: { playerCount: 3, teams: { mode: 'free-for-all' } }, dealing: { handSize: 15, diff --git a/packages/shared/src/variant/index.ts b/packages/shared/src/variant/index.ts index 8ebd643..919059f 100644 --- a/packages/shared/src/variant/index.ts +++ b/packages/shared/src/variant/index.ts @@ -1,7 +1,7 @@ /** * Variant Definition surface: the Zod schema that parameterizes a pinochle game, - * its inferred type and axis sub-types, the phase-gating predicates, and the two - * frozen canonical ranked fixtures. + * its inferred type and axis sub-types, and the two frozen canonical ranked + * fixtures. */ export { RankSchema, @@ -21,9 +21,6 @@ export { ScoringSchema, MatchEndSchema, VariantDefinitionSchema, - widowEnabled, - buryEnabled, - passingEnabled, type Rank, type Suit, type DeckSpec, @@ -43,10 +40,13 @@ export { type VariantDefinition, } from './schema'; +/** The four-suit vocabulary — the plain tuple `SuitSchema` is enumerated over. */ +export { SUITS } from './suits'; + export { SINGLE_DECK_PARTNERS, SINGLE_DECK_CUTTHROAT } from './canonical'; /** Stable canonical-JSON content hash of a variant — the `matches.variant_hash` producer (design D4). */ -export { canonicalJson, hashVariant } from './hash'; +export { hashVariant } from './hash'; /** * The Standard meld table: the canonical Class A/B/C meld definitions and point diff --git a/packages/shared/src/variant/meld-table.ts b/packages/shared/src/variant/meld-table.ts index 8f247cc..73dd86f 100644 --- a/packages/shared/src/variant/meld-table.ts +++ b/packages/shared/src/variant/meld-table.ts @@ -15,6 +15,13 @@ import type { Rank, Suit } from './schema'; * single source of truth, validated against the ruleset oracle by the unit tests. */ +/** + * The four-suit vocabulary, re-exported from its pure-data source so the engine + * reaches the value through this permitted Zod-free subpath (the detector scans + * it for non-trump marriages and arounds). + */ +export { SUITS } from './suits'; + /** The meld tables the variant schema can reference (`melding.meldTableId`). */ export type MeldTableId = 'standard-single-deck' | 'standard-double-deck'; diff --git a/packages/shared/src/variant/schema.test.ts b/packages/shared/src/variant/schema.test.ts index bb7d679..485e884 100644 --- a/packages/shared/src/variant/schema.test.ts +++ b/packages/shared/src/variant/schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { VariantDefinitionSchema, widowEnabled, buryEnabled, passingEnabled, type VariantDefinition } from './schema'; +import { VariantDefinitionSchema, type VariantDefinition } from './schema'; /** * A fully specified, valid variant used as the baseline for accept/reject tests. @@ -92,7 +92,7 @@ describe('VariantDefinitionSchema', () => { expect(issues.some((i) => i.path.join('.') === 'bidding.increment')).toBe(true); }); - it('accepts zeroed widow/bury/passing and marks those phases disabled', () => { + it('accepts zeroed widow/bury/passing', () => { const input = makeValidVariant() as Record & { dealing: { widow: { size: number }; bury: { size: number } }; passing: { count: number }; @@ -102,15 +102,8 @@ describe('VariantDefinitionSchema', () => { input.passing.count = 0; const variant: VariantDefinition = VariantDefinitionSchema.parse(input); - expect(widowEnabled(variant)).toBe(false); - expect(buryEnabled(variant)).toBe(false); - expect(passingEnabled(variant)).toBe(false); - }); - - it('marks bracketed phases enabled when their axis is non-zero', () => { - const variant = VariantDefinitionSchema.parse(makeValidVariant()); - expect(widowEnabled(variant)).toBe(true); - expect(buryEnabled(variant)).toBe(true); - expect(passingEnabled(variant)).toBe(true); + expect(variant.dealing.widow.size).toBe(0); + expect(variant.dealing.bury.size).toBe(0); + expect(variant.passing.count).toBe(0); }); }); diff --git a/packages/shared/src/variant/schema.ts b/packages/shared/src/variant/schema.ts index bd5442f..b2dc20f 100644 --- a/packages/shared/src/variant/schema.ts +++ b/packages/shared/src/variant/schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { SUITS } from './suits'; /** * The Variant Definition schema — the full parameter set that *is* a pinochle @@ -18,8 +19,8 @@ import { z } from 'zod'; export const RankSchema = z.enum(['A', '10', 'K', 'Q', 'J', '9']); export type Rank = z.infer; -/** The four suits. */ -export const SuitSchema = z.enum(['spades', 'hearts', 'clubs', 'diamonds']); +/** The four suits, enumerated over the shared {@link SUITS} tuple. */ +export const SuitSchema = z.enum(SUITS); export type Suit = z.infer; /** @@ -200,18 +201,3 @@ export const VariantDefinitionSchema = z.object({ /** The inferred, plain-data type the engine consumes (Zod erased at the boundary). */ export type VariantDefinition = z.infer; - -/** True when the variant has a widow (WidowReveal phase active). */ -export function widowEnabled(variant: VariantDefinition): boolean { - return variant.dealing.widow.size > 0; -} - -/** True when the variant buries cards (Bury phase active). */ -export function buryEnabled(variant: VariantDefinition): boolean { - return variant.dealing.bury.size > 0; -} - -/** True when the variant passes cards (Passing phase active). */ -export function passingEnabled(variant: VariantDefinition): boolean { - return variant.passing.count > 0; -} diff --git a/packages/shared/src/variant/suits.ts b/packages/shared/src/variant/suits.ts new file mode 100644 index 0000000..84999fc --- /dev/null +++ b/packages/shared/src/variant/suits.ts @@ -0,0 +1,8 @@ +/** + * The four-suit vocabulary as **plain, Zod-free data** — the single source of + * truth for the suit tuple. `SuitSchema` is `z.enum(SUITS)` (so + * `SuitSchema.options` is this same tuple), and the engine reads the value + * through the pure-data `@meldrank/shared/meld` subpath without dragging Zod + * into its zero-dependency surface. + */ +export const SUITS = ['spades', 'hearts', 'clubs', 'diamonds'] as const; From 938037e16428026fb1126bcc7aadbaf37ceb3b6f Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Fri, 3 Jul 2026 11:38:19 -0400 Subject: [PATCH 12/16] refactor(engine): export rng-injectable deal orchestration, dedupe suit scan Export applyDealWithRng and startNextHandBase from the reducer so the match room's deal.ts becomes a thin adapter over the engine's own orchestration instead of re-implementing phase-advance and hand-boundary logic locally. Reuse the shared SUITS tuple in meld.ts instead of a local copy. Update the hand-lifecycle-state-machine and match-shuffle-handshake specs to describe the engine-owned seam, and add dealOrchestration.test.ts covering it. --- apps/match/src/room/deal.ts | 91 ++++++------------ .../hand-lifecycle-state-machine/spec.md | 15 +++ .../specs/match-shuffle-handshake/spec.md | 8 +- packages/engine/src/meld/meld.ts | 5 +- .../src/state/dealOrchestration.test.ts | 94 +++++++++++++++++++ packages/engine/src/state/index.ts | 2 +- packages/engine/src/state/reduce.ts | 53 ++++++++--- 7 files changed, 184 insertions(+), 84 deletions(-) create mode 100644 packages/engine/src/state/dealOrchestration.test.ts diff --git a/apps/match/src/room/deal.ts b/apps/match/src/room/deal.ts index 18dc254..2f4423a 100644 --- a/apps/match/src/room/deal.ts +++ b/apps/match/src/room/deal.ts @@ -1,27 +1,19 @@ -import { - createInitialState, - deal, - isLegalTransition, - openAuction, - resolveActivePath, - type LifecyclePhase, - type Rng, - type State, -} from '@meldrank/engine'; -import type { VariantDefinition } from '@meldrank/shared'; +import { applyDealWithRng, startNextHandBase, type Rng, type State } from '@meldrank/engine'; /** * The room's deal **orchestration** — the one place that distributes cards. * - * It deliberately re-implements the engine's internal `applyDeal` *orchestration* - * (deal → open auction → advance to the next active phase) rather than going - * through `reduce({ type: 'deal', seed })`. The reason is the provably-fair - * handshake (spec: match-shuffle-handshake): the engine's `deal` event accepts only - * a 32-bit numeric seed and builds its own `createSeededRng`, which bottlenecks the - * reachable permutation space to 2³². The fairness layer instead produces a - * **full-width** `Rng` via `rngFromSeed`, explicitly designed to plug straight into - * the Dealer's injected `rng` seam (`deal(deckSpec, …, rng)`). So the live room - * deals with that `Rng` here. + * It deals through the engine's exported rng-injectable seam + * (`applyDealWithRng` / `startNextHandBase`) rather than through + * `reduce({ type: 'deal', seed })`. The reason is the provably-fair handshake + * (spec: match-shuffle-handshake): the engine's `deal` event accepts only a + * 32-bit numeric seed and builds its own `createSeededRng`, which bottlenecks + * the reachable permutation space to 2³². The fairness layer instead produces a + * **full-width** `Rng` via `rngFromSeed`, explicitly designed to plug straight + * into the Dealer's injected `rng` seam — so the live room injects that `Rng` + * into the same orchestration the reducer itself runs. Only the entropy source + * differs; the deal/phase-advance/hand-boundary logic exists exactly once, in + * the engine. * * This is deal *setup* only — no game **rule legality** lives here. Every player * move (auction, declare, bury, play, scoring, match-end) still flows exclusively @@ -29,57 +21,30 @@ import type { VariantDefinition } from '@meldrank/shared'; */ /** - * The variant's next active phase after `phase`, mirroring the engine's own - * `nextActivePhase`: the active path (optional phases the variant disables already - * skipped) guarded by the legal-transition table. - */ -function nextActivePhase(variant: VariantDefinition, phase: LifecyclePhase): LifecyclePhase | null { - const path = resolveActivePath(variant); - const index = path.indexOf(phase); - if (index < 0 || index + 1 >= path.length) { - return null; - } - const candidate = path[index + 1]!; - return isLegalTransition(phase, candidate) ? candidate : null; -} - -/** - * Build the fresh `Dealing` base for the **next** hand of a match (mirrors the - * engine's `startNextHand`): the dealer rotated one seat, every per-hand field - * (including a redeal `outcome` marker) reset, and the running score pad plus the + * Build the fresh `Dealing` base for the **next** hand of a match: the engine's + * `startNextHandBase` — dealer rotated one seat, every per-hand field (including + * a redeal `outcome` marker) reset, and the running score pad plus the * match-scope hands-made-as-bidder counter preserved across the hand boundary. * `prev` is the state resting at either hand boundary: `HandScoring` after a - * scored hand, or a redeal-marked `Auction` after an all-pass (the thrown-in hand - * left the pad untouched, so preserving it is a no-op there). + * scored hand, or a redeal-marked `Auction` after an all-pass. */ export function nextHandBase(prev: State): State { - const { variant } = prev; - const nextDealer = (prev.public.dealerSeat + 1) % variant.seating.playerCount; - const base = createInitialState(variant, nextDealer); - return { - ...base, - public: { ...base.public, scorePad: prev.public.scorePad, handsMadeAsBidder: prev.public.handsMadeAsBidder }, - }; + return startNextHandBase(prev); } /** - * Deal a hand into a `Dealing` engine state using the injected `rng`: distribute - * the hands and widow through the Dealer's `rng` seam, open the auction at the seat - * left of the dealer, and advance to the variant's next active phase (`Auction`). - * The resulting state is identical in shape to what `reduce` would have produced - * for a deal — only the entropy source differs. + * Deal a hand into a `Dealing` engine state using the injected `rng`: the + * engine's `applyDealWithRng` — distribute the hands and widow through the + * Dealer's `rng` seam, open the auction at the seat left of the dealer, and + * advance to the variant's next active phase (`Auction`). The resulting state is + * identical in shape to what `reduce` would have produced for a deal — only the + * entropy source differs. A rejected outcome (an unresolvable active path — a + * config fault unreachable for supported variants) throws rather than dealing. */ export function dealHand(state: State, rng: Rng): State { - const { variant } = state; - const next = nextActivePhase(variant, 'Dealing'); - if (next === null) { - throw new Error('deal orchestration: no active phase after Dealing'); + const outcome = applyDealWithRng(state, rng); + if (outcome.rejection !== null) { + throw new Error(`deal orchestration: deal rejected (${outcome.rejection.code})`); } - const { hands, widow } = deal(variant.deck, variant.dealing.handSize, variant.dealing.widow.size, rng); - const auction = openAuction(variant.seating.playerCount, state.public.dealerSeat); - return { - variant, - public: { ...state.public, phase: next, seatToAct: auction.toAct, auction }, - private: { hands, widow, buried: [] }, - }; + return outcome.state; } diff --git a/openspec/specs/hand-lifecycle-state-machine/spec.md b/openspec/specs/hand-lifecycle-state-machine/spec.md index 76c4b62..69d338c 100644 --- a/openspec/specs/hand-lifecycle-state-machine/spec.md +++ b/openspec/specs/hand-lifecycle-state-machine/spec.md @@ -47,3 +47,18 @@ For a given `VariantDefinition`, the engine SHALL compute which bracketed phases - **WHEN** the active path is computed for `SINGLE_DECK_CUTTHROAT` - **THEN** the legal sequence is `Dealing → Auction → WidowReveal → DeclareTrump → Melding → Bury → TrickPlay → HandScoring`, with `Passing` absent + +### Requirement: Rng-injectable deal orchestration is engine-owned + +The engine SHALL export deal orchestration seams usable with an injected `Rng`: `applyDealWithRng(state, rng)` — deal the hands and widow through the Dealer's `rng` seam, open the auction at the seat left of the dealer, and advance to the variant's next active phase — and `startNextHandBase(state)` — the fresh `Dealing` base for the next hand of a match, with the dealer rotated one seat, every per-hand field (including a redeal `outcome` marker) reset, and the running `scorePad` plus the match-scope `handsMadeAsBidder` counter preserved. The reducer's own `deal` handling SHALL be built on these same functions (seed expansion via `createSeededRng` feeding `applyDealWithRng`; hand boundaries via `startNextHandBase`), so deal/phase-advance/hand-boundary orchestration exists exactly once. + +#### Scenario: Injected-rng deal matches the reducer's deal + +- **WHEN** `applyDealWithRng(state, createSeededRng(seed))` and `reduce(state, { type: 'deal', seed })` are applied to the same `Dealing` state +- **THEN** both produce the identical resulting state (same hands, widow, auction, phase) + +#### Scenario: Next-hand base preserves match scope and rotates the dealer + +- **WHEN** `startNextHandBase` is applied to a state resting at a hand boundary (`HandScoring`, or a redeal-marked `Auction`) +- **THEN** the result is a fresh `Dealing` state whose dealer is rotated one seat +- **AND** the running `scorePad` and `handsMadeAsBidder` counter are preserved while every per-hand field is reset diff --git a/openspec/specs/match-shuffle-handshake/spec.md b/openspec/specs/match-shuffle-handshake/spec.md index 71b002e..76c0d29 100644 --- a/openspec/specs/match-shuffle-handshake/spec.md +++ b/openspec/specs/match-shuffle-handshake/spec.md @@ -64,7 +64,7 @@ The room SHALL accept a seat's `clientSeed` contribution only after that hand's ### Requirement: Seed assembly drives the deal -After collecting seat contributions for a hand, the room SHALL assemble the deal seed with `assembleSeed`, substituting `fallbackContribution` for any seat that did not contribute, expand it through `rngFromSeed`, and feed the resulting `Rng` into the engine Dealer's injected `rng` seam. The deal SHALL be fully determined by the committed server seed plus the collected contributions, so the hand is independently reproducible from the eventual reveal. +After collecting seat contributions for a hand, the room SHALL assemble the deal seed with `assembleSeed`, substituting `fallbackContribution` for any seat that did not contribute, expand it through `rngFromSeed`, and feed the resulting `Rng` into the engine Dealer's injected `rng` seam. The deal SHALL be fully determined by the committed server seed plus the collected contributions, so the hand is independently reproducible from the eventual reveal. The room SHALL perform this dealing through the engine's exported orchestration (`applyDealWithRng`, with hand boundaries built on `startNextHandBase`) and SHALL NOT re-implement the engine's phase-advance or hand-boundary logic locally. #### Scenario: Absent contribution uses deterministic fallback @@ -77,3 +77,9 @@ After collecting seat contributions for a hand, the room SHALL assemble the deal - **WHEN** the room deals a hand - **THEN** the Dealer's `rng` is derived from `assembleSeed` over the committed server seed and the seat contributions for that hand - **AND** the same inputs reproduce the identical deal + +#### Scenario: Deal orchestration is engine-owned + +- **WHEN** the room's deal path (`apps/match/src/room/deal.ts`) is inspected +- **THEN** dealing and hand-boundary construction delegate to the engine's exported `applyDealWithRng` and `startNextHandBase` +- **AND** no local copy of the engine's phase-advance or next-hand logic remains diff --git a/packages/engine/src/meld/meld.ts b/packages/engine/src/meld/meld.ts index 7282593..6884003 100644 --- a/packages/engine/src/meld/meld.ts +++ b/packages/engine/src/meld/meld.ts @@ -1,4 +1,4 @@ -import type { MeldDefinition, MeldTable } from '@meldrank/shared/meld'; +import { SUITS, type MeldDefinition, type MeldTable } from '@meldrank/shared/meld'; import { makeMeld, type Hand, type Meld } from '../domain/entities'; import type { Card, Rank, Suit } from '../domain/card'; @@ -21,9 +21,6 @@ export interface MeldResult { readonly total: number; } -/** The four suits, used to scan non-trump marriages and arounds. */ -const SUITS: readonly Suit[] = ['spades', 'hearts', 'clubs', 'diamonds']; - /** * Compute the maximum legal meld set for `hand` against the declared `trump`, * scored by `meldTable`. Trump-dependent melds (Run, Royal Marriage, Dix) are diff --git a/packages/engine/src/state/dealOrchestration.test.ts b/packages/engine/src/state/dealOrchestration.test.ts new file mode 100644 index 0000000..b0b3d87 --- /dev/null +++ b/packages/engine/src/state/dealOrchestration.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { SINGLE_DECK_CUTTHROAT, SINGLE_DECK_PARTNERS } from '@meldrank/shared'; +import { applyDealWithRng, reduce, startNextHandBase } from './reduce'; +import { createInitialState, type State } from './state'; +import { createSeededRng } from '../dealer/rng'; +import type { Event } from './events'; + +/** + * Pins the engine-owned deal-orchestration seam (design D1 of + * `prune-and-dedupe-hygiene`): the reducer's own `deal` path is exactly + * `applyDealWithRng` fed a `createSeededRng` over the event seed, and hand + * boundaries are exactly `startNextHandBase`. The Match Service's provably-fair + * deal path composes the same two exports with its full-width `Rng`, so this + * equivalence is what keeps live matches and replay folds on one implementation. + */ + +const VARIANTS = [SINGLE_DECK_PARTNERS, SINGLE_DECK_CUTTHROAT] as const; +const SEEDS = [0, 1, 42, 0x7fffffff, 0xdeadbeef] as const; + +describe('applyDealWithRng ≡ reduce(deal) under createSeededRng', () => { + for (const variant of VARIANTS) { + for (const seed of SEEDS) { + it(`${variant.id} seed=${seed}`, () => { + const initial = createInitialState(variant, 0); + const viaRng = applyDealWithRng(initial, createSeededRng(seed)); + expect(viaRng.rejection).toBeNull(); + expect(viaRng.state).toEqual(reduce(initial, { type: 'deal', seed })); + }); + } + } +}); + +describe('startNextHandBase — the fresh next-hand base', () => { + /** Fold an ordered event log over `reduce` from a fresh Cutthroat initial state. */ + function fold(dealerSeat: number, log: readonly Event[]): State { + return log.reduce((state, event) => reduce(state, event), createInitialState(SINGLE_DECK_CUTTHROAT, dealerSeat)); + } + + /** A redeal-marked Auction rest (all-pass with dealer 0), one hand boundary kind. */ + function redealBoundary(): State { + return fold(0, [ + { type: 'deal', seed: 1 }, + { type: 'pass', seat: 1 }, + { type: 'pass', seat: 2 }, + ]); + } + + it('rotates the dealer one seat and resets every per-hand field (incl. a redeal outcome)', () => { + const boundary = redealBoundary(); + expect(boundary.public.outcome).toBe('redeal'); + + const base = startNextHandBase(boundary); + // The base is exactly a fresh initial state under the rotated dealer: every + // per-hand public and private field back at its `Dealing` default. + expect(base).toEqual(createInitialState(SINGLE_DECK_CUTTHROAT, 1)); + expect(base.public.phase).toBe('Dealing'); + expect(base.public.outcome).toBeNull(); + expect(base.public.auction).toBeNull(); + expect(base.private.hands).toEqual([]); + }); + + it('wraps the dealer rotation past the last seat', () => { + const boundary = { ...redealBoundary() }; + const shifted: State = { ...boundary, public: { ...boundary.public, dealerSeat: 2 } }; + expect(startNextHandBase(shifted).public.dealerSeat).toBe(0); + }); + + it('preserves the running score pad and hands-made-as-bidder counter', () => { + const boundary = redealBoundary(); + const pad = { hands: [[{ side: 0, meld: 20, counters: 100, total: 120 }]], cumulative: { 0: 120 } }; + const midMatch: State = { + ...boundary, + public: { ...boundary.public, scorePad: pad, handsMadeAsBidder: { 0: 1 } }, + }; + + const base = startNextHandBase(midMatch); + expect(base.public.scorePad).toEqual(pad); + expect(base.public.handsMadeAsBidder).toEqual({ 0: 1 }); + // Aside from the carried match-scope fields, the base matches a fresh initial state. + const fresh = createInitialState(SINGLE_DECK_CUTTHROAT, 1); + expect(base).toEqual({ + ...fresh, + public: { ...fresh.public, scorePad: pad, handsMadeAsBidder: { 0: 1 } }, + }); + }); + + it('composes with applyDealWithRng exactly like the reducer’s next-hand deal', () => { + const boundary = redealBoundary(); + const viaReduce = reduce(boundary, { type: 'deal', seed: 7 }); + const viaSeam = applyDealWithRng(startNextHandBase(boundary), createSeededRng(7)); + expect(viaSeam.rejection).toBeNull(); + expect(viaSeam.state).toEqual(viaReduce); + }); +}); diff --git a/packages/engine/src/state/index.ts b/packages/engine/src/state/index.ts index 2afca00..cbdc581 100644 --- a/packages/engine/src/state/index.ts +++ b/packages/engine/src/state/index.ts @@ -4,6 +4,6 @@ * phase-guarded, lifecycle-advancing reducer. Wires the `Dealing → Auction` * slice in this change. */ -export { reduce, reduceWithVerdict, type RejectionCode, type Rejection, type ReduceOutcome } from './reduce'; +export { reduce, reduceWithVerdict, applyDealWithRng, startNextHandBase, type RejectionCode, type Rejection, type ReduceOutcome } from './reduce'; export { createInitialState, getContract, type State, type PublicState, type PrivateState, type SeatMeld, type SeatCapture } from './state'; export { EVENT_KINDS, type Event, type EventKind, type SystemEvent, type DealEvent, type TimeoutEvent } from './events'; diff --git a/packages/engine/src/state/reduce.ts b/packages/engine/src/state/reduce.ts index 6f216e0..f274ffa 100644 --- a/packages/engine/src/state/reduce.ts +++ b/packages/engine/src/state/reduce.ts @@ -1,7 +1,7 @@ import type { DeclareTrumpIntent, PlayCardIntent, Suit, VariantDefinition, BuryIntent } from '@meldrank/shared'; import { getMeldTable } from '@meldrank/shared/meld'; import { isLegalTransition, resolveActivePath, type LifecyclePhase } from '../lifecycle/phases'; -import { createSeededRng } from '../dealer/rng'; +import { createSeededRng, type Rng } from '../dealer/rng'; import { deal } from '../dealer/deal'; import { applyBid, applyPass, openAuction, type AuctionStep } from '../auction/auction'; import { revealWidow } from '../widow/widow'; @@ -177,18 +177,29 @@ function rejected(state: State, code: RejectionCode): ReduceOutcome { } /** - * Drive the `deal` system event: expand the seed into the shuffle, deal the - * hands and widow, open the auction at the seat left of the dealer, and advance - * the phase to the variant's next active phase (`Auction`). + * Drive the `deal` system event: expand the seed into the shuffle, then run the + * shared {@link applyDealWithRng} orchestration. */ function applyDeal(state: State, event: DealEvent): ReduceOutcome { + return applyDealWithRng(state, createSeededRng(event.seed)); +} + +/** + * The rng-injectable deal orchestration: deal the hands and widow through the + * Dealer's injected `rng` seam, open the auction at the seat left of the dealer, + * and advance the phase to the variant's next active phase (`Auction`). The + * reducer's own `deal` handling is this same function fed a `createSeededRng` + * over the event's 32-bit seed; callers with a wider entropy source (the Match + * Service's provably-fair handshake) inject their own full-width `Rng` here, so + * deal orchestration exists exactly once. + */ +export function applyDealWithRng(state: State, rng: Rng): ReduceOutcome { const { variant } = state; const next = nextActivePhase(variant, 'Dealing'); if (next === null) { return rejected(state, 'config-fault'); } - const rng = createSeededRng(event.seed); const { hands, widow } = deal(variant.deck, variant.dealing.handSize, variant.dealing.widow.size, rng); const auction = openAuction(variant.seating.playerCount, state.public.dealerSeat); @@ -606,19 +617,35 @@ function passThroughHandScoring(state: State): State | null { * The shared next-hand helper (design D1/D5): start the next hand of the match * from either hand boundary — a scored hand resting at `HandScoring`, or a * thrown-in hand marked `redeal` at `Auction` (the all-pass rule; nothing was - * scored). Build a fresh next-hand base via {@link createInitialState} (every - * per-hand public and private field — including the redeal `outcome` marker — - * reset to its `Dealing` default) with the dealer rotated one seat, **preserving** - * the running `scorePad` and the match-scope `handsMadeAsBidder` counter, then run - * the standard {@link applyDeal} to deal the hands/widow and open the auction + * scored). Build the fresh base via {@link startNextHandBase}, then run the + * standard {@link applyDeal} to deal the hands/widow and open the auction * (landing at `Auction`). The match is provably not over here, so the deal is * always valid. */ function startNextHand(state: State, event: DealEvent): ReduceOutcome { + const outcome = applyDeal(startNextHandBase(state), event); + // A refusal must return the *caller's* input state by reference, not the + // intermediate fresh base. + return outcome.rejection === null ? outcome : rejected(state, outcome.rejection.code); +} + +/** + * Build the fresh `Dealing` base for the **next** hand of a match: a + * {@link createInitialState} base (every per-hand public and private field — + * including a redeal `outcome` marker — reset to its `Dealing` default) with the + * dealer rotated one seat, **preserving** the running `scorePad` and the + * match-scope `handsMadeAsBidder` counter across the hand boundary. `state` is + * the state resting at either hand boundary: `HandScoring` after a scored hand, + * or a redeal-marked `Auction` after an all-pass (the thrown-in hand left the + * pad untouched, so preserving it is a no-op there). Exported so hand-boundary + * construction exists exactly once; the Match Service pairs it with + * {@link applyDealWithRng} to start the next hand under its own `Rng`. + */ +export function startNextHandBase(state: State): State { const { variant } = state; const nextDealer = (state.public.dealerSeat + 1) % variant.seating.playerCount; const base = createInitialState(variant, nextDealer); - const freshBase: State = { + return { ...base, public: { ...base.public, @@ -626,10 +653,6 @@ function startNextHand(state: State, event: DealEvent): ReduceOutcome { handsMadeAsBidder: state.public.handsMadeAsBidder, }, }; - const outcome = applyDeal(freshBase, event); - // A refusal must return the *caller's* input state by reference, not the - // intermediate fresh base. - return outcome.rejection === null ? outcome : rejected(state, outcome.rejection.code); } /** Return new hands with `card` removed from `seat`'s hand; other hands untouched. */ From 7df8716ae3a63d84d54cffd729b1ebbc9388bf5a Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Fri, 3 Jul 2026 11:38:41 -0400 Subject: [PATCH 13/16] refactor(web): extract page-shell, store-context, handoff, and suit-glyph Introduce PageShell/CenteredMessage to replace the duplicated
shell markup across the landing, sign-in/up, table, and waiting-room pages. Add a createStoreContext factory and rebuild SessionStore/ TableStore on top of it, removing duplicated Provider/useContext boilerplate. Add useTableHandoff to dedupe the handoff-to-table logic shared by casual-hall and use-waiting-room. Extract SUIT_GLYPH into suit-glyph.ts so card.tsx and table-view.tsx share one copy, and use cardIdentityKey for the card list key instead of a hand-rolled string. Also fixes a stale doc comment in env.ts now that the Clerk publishable key is required. --- apps/web/app/page.tsx | 7 ++- apps/web/app/sign-in/page.tsx | 6 ++- apps/web/app/sign-up/page.tsx | 6 ++- apps/web/app/table/[roomId]/page.tsx | 28 +++--------- apps/web/components/hall/casual-hall.tsx | 15 ++----- apps/web/components/hall/waiting-room.tsx | 25 +++-------- apps/web/components/table/card.tsx | 9 +--- apps/web/components/table/suit-glyph.ts | 13 ++++++ apps/web/components/table/table-view.tsx | 7 +-- apps/web/components/ui/page-shell.tsx | 54 +++++++++++++++++++++++ apps/web/lib/create-store-context.tsx | 41 +++++++++++++++++ apps/web/lib/env.ts | 3 +- apps/web/lib/handoff.ts | 36 +++++++++++++++ apps/web/lib/store.tsx | 22 +++------ apps/web/lib/table-store.tsx | 31 ++++--------- apps/web/lib/use-waiting-room.ts | 10 ++--- 16 files changed, 199 insertions(+), 114 deletions(-) create mode 100644 apps/web/components/table/suit-glyph.ts create mode 100644 apps/web/components/ui/page-shell.tsx create mode 100644 apps/web/lib/create-store-context.tsx create mode 100644 apps/web/lib/handoff.ts diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index e14876b..007021e 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,6 +1,7 @@ import { UserButton } from '@clerk/nextjs'; import { CasualHall } from '@/components/hall/casual-hall'; +import { PageShell } from '@/components/ui/page-shell'; /** * The landing route — the casual hall (capability `casual-hall-web`). The page is a @@ -10,15 +11,13 @@ import { CasualHall } from '@/components/hall/casual-hall'; */ export default function Home() { return ( -
+ {/* Sign-out affordance: Clerk's account menu, which includes Sign out. */}
-
+ ); } diff --git a/apps/web/app/sign-in/page.tsx b/apps/web/app/sign-in/page.tsx index 7efa98f..040eda1 100644 --- a/apps/web/app/sign-in/page.tsx +++ b/apps/web/app/sign-in/page.tsx @@ -1,5 +1,7 @@ import { SignIn } from '@clerk/nextjs'; +import { PageShell } from '@/components/ui/page-shell'; + /** * Public sign-in surface (capability `web-client-foundation`). The `clerkMiddleware` * redirects an unauthenticated visitor here from a protected route. Hash routing keeps @@ -8,8 +10,8 @@ import { SignIn } from '@clerk/nextjs'; */ export default function SignInPage() { return ( -
+ -
+ ); } diff --git a/apps/web/app/sign-up/page.tsx b/apps/web/app/sign-up/page.tsx index f9b3dd3..982090e 100644 --- a/apps/web/app/sign-up/page.tsx +++ b/apps/web/app/sign-up/page.tsx @@ -1,13 +1,15 @@ import { SignUp } from '@clerk/nextjs'; +import { PageShell } from '@/components/ui/page-shell'; + /** * Public sign-up surface (capability `web-client-foundation`), the registration * counterpart to the sign-in page. Hash routing keeps it a single static route. */ export default function SignUpPage() { return ( -
+ -
+ ); } diff --git a/apps/web/app/table/[roomId]/page.tsx b/apps/web/app/table/[roomId]/page.tsx index d5050bf..e87fa40 100644 --- a/apps/web/app/table/[roomId]/page.tsx +++ b/apps/web/app/table/[roomId]/page.tsx @@ -7,6 +7,7 @@ import { useSyncExternalStore } from 'react'; import { TableView } from '@/components/table/table-view'; import { Button } from '@/components/ui/button'; +import { CenteredMessage, PageShell } from '@/components/ui/page-shell'; import { TableStoreProvider, useRenderModel } from '@/lib/table-store'; import { useTableConnection } from '@/lib/use-table-connection'; @@ -76,30 +77,15 @@ function ReconnectingBanner() { function ReturnToLobby({ message, onReturn, title }: { message: string; onReturn: () => void; title: string }) { return ( -
-

{title}

-

{message}

- -
+ Return to lobby} title={title}> + {message} + ); } /** Pre-hydration placeholder; the client immediately replaces it with {@link TableSurface}. */ function TableBootPlaceholder() { - return ( -
-

MeldRank table

-

Connecting to the table…

-
- ); + return Connecting to the table…; } function TableSurface() { @@ -131,7 +117,7 @@ function TableSurface() { } return ( -
+

MeldRank table

{roomId}

@@ -145,7 +131,7 @@ function TableSurface() { {model.status === 'complete' && } {model.status !== 'connecting' && } -
+ ); } diff --git a/apps/web/components/hall/casual-hall.tsx b/apps/web/components/hall/casual-hall.tsx index 29381b6..5831516 100644 --- a/apps/web/components/hall/casual-hall.tsx +++ b/apps/web/components/hall/casual-hall.tsx @@ -1,11 +1,9 @@ 'use client'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { $path } from 'next-typesafe-url'; -import { useRouter } from 'next/navigation'; import { Button } from '@/components/ui/button'; -import { useSessionStore } from '@/lib/store'; +import { useTableHandoff } from '@/lib/handoff'; import { useTRPC } from '@/lib/trpc'; import { CreateTableButton } from './create-table-button'; @@ -22,9 +20,7 @@ import { OpenTableList } from './open-table-list'; */ export function CasualHall() { const trpc = useTRPC(); - const router = useRouter(); - - const setHandoff = useSessionStore((s) => s.setHandoff); + const handoffToTable = useTableHandoff(); const me = useQuery(trpc.account.getMe.queryOptions()); const active = useQuery(trpc.match.getActive.queryOptions()); @@ -32,17 +28,14 @@ export function CasualHall() { function rejoin() { if (!active.data) return; - const { ticket, ...match } = active.data; - setHandoff({ match, ticket: ticket ?? null }); - router.push($path({ route: '/table/[roomId]', routeParams: { roomId: active.data.roomId } })); + handoffToTable(active.data); } function startQuickPlay() { quickPlay.mutate(undefined, { onSuccess: ({ ticket }) => { const { roomId, seat, variantId } = ticket.payload; - setHandoff({ match: { roomId, seat, variantId }, ticket }); - router.push($path({ route: '/table/[roomId]', routeParams: { roomId } })); + handoffToTable({ roomId, seat, ticket, variantId }); }, }); } diff --git a/apps/web/components/hall/waiting-room.tsx b/apps/web/components/hall/waiting-room.tsx index 51bd0c8..72d3328 100644 --- a/apps/web/components/hall/waiting-room.tsx +++ b/apps/web/components/hall/waiting-room.tsx @@ -4,6 +4,7 @@ import { $path } from 'next-typesafe-url'; import { useRouter } from 'next/navigation'; import { Button } from '@/components/ui/button'; +import { CenteredMessage, PageShell } from '@/components/ui/page-shell'; import { useWaitingRoom } from '@/lib/use-waiting-room'; import { SeatGrid } from './seat-grid'; @@ -23,12 +24,12 @@ export function WaitingRoom({ tableId }: { tableId: string | undefined }) { const toHall = () => router.replace($path({ route: '/' })); if (room.phase === 'loading') { - return Loading table…; + return Loading table…; } if (room.phase === 'error') { return ( - +

Something went wrong loading this table.

@@ -40,12 +41,12 @@ export function WaitingRoom({ tableId }: { tableId: string | undefined }) {
-
+ ); } if (room.phase === 'transitioning' || !room.table) { - return Handing you off to the table…; + return Handing you off to the table…; } const { table } = room; @@ -55,7 +56,7 @@ export function WaitingRoom({ tableId }: { tableId: string | undefined }) { : 'Waiting for players. Add bots to fill the table and start.'; return ( -
+

{table.variant.name}

@@ -83,18 +84,6 @@ export function WaitingRoom({ tableId }: { tableId: string | undefined }) { -

- ); -} - -/** A simple centered single-message shell for the loading / error / transition phases. */ -function Centered({ children, title }: { children: React.ReactNode; title: string }) { - return ( -
-

{title}

-
{children}
-
+ ); } diff --git a/apps/web/components/table/card.tsx b/apps/web/components/table/card.tsx index e1787c4..6ccd5cf 100644 --- a/apps/web/components/table/card.tsx +++ b/apps/web/components/table/card.tsx @@ -5,6 +5,8 @@ import type { Suit } from '@meldrank/shared'; import { cn } from '@/lib/utils'; +import { SUIT_GLYPH } from './suit-glyph'; + /** * Functional, legible card rendering (design Open Question — no artwork or * animation). A face-up card is a small bordered chip showing rank + suit glyph; @@ -13,13 +15,6 @@ import { cn } from '@/lib/utils'; * loop can pick a card; non-interactive cards render as static chips. */ -const SUIT_GLYPH: Record = { - clubs: '♣', - diamonds: '♦', - hearts: '♥', - spades: '♠', -}; - const RED_SUITS = new Set(['diamonds', 'hearts']); const CHIP_CLASS = ` diff --git a/apps/web/components/table/suit-glyph.ts b/apps/web/components/table/suit-glyph.ts new file mode 100644 index 0000000..d45c1b0 --- /dev/null +++ b/apps/web/components/table/suit-glyph.ts @@ -0,0 +1,13 @@ +import type { Suit } from '@meldrank/shared'; + +/** + * The suit glyphs — the single definition shared by the card renderer and the + * table layout. Lives beside `card.tsx` (its natural owner) in a constants-only + * module so the component files keep Fast Refresh (`only-export-components`). + */ +export const SUIT_GLYPH: Record = { + clubs: '♣', + diamonds: '♦', + hearts: '♥', + spades: '♠', +}; diff --git a/apps/web/components/table/table-view.tsx b/apps/web/components/table/table-view.tsx index ced4c2e..cfea268 100644 --- a/apps/web/components/table/table-view.tsx +++ b/apps/web/components/table/table-view.tsx @@ -3,6 +3,8 @@ import type { Card } from '@meldrank/engine'; import type { SeatDisplayStatus, Suit } from '@meldrank/shared'; +import { cardIdentityKey } from '@meldrank/engine'; +import { SUITS } from '@meldrank/shared'; import { useState } from 'react'; import type { RenderModel } from '@/lib/table-store'; @@ -14,6 +16,7 @@ import type { TableIntent } from './intents'; import { CardBack, CardChip } from './card'; import { MoveClock, SeatClockBanks } from './clock'; +import { SUIT_GLYPH } from './suit-glyph'; /** * Pure table layout (tasks 6.2/6.3): own hand, opponents as `handSizes` @@ -24,8 +27,6 @@ import { MoveClock, SeatClockBanks } from './clock'; * (the page) attaches the correlation id and sends it. No artwork, no animation. */ -const SUITS: readonly Suit[] = ['spades', 'hearts', 'clubs', 'diamonds']; -const SUIT_GLYPH: Record = { clubs: '♣', diamonds: '♦', hearts: '♥', spades: '♠' }; const EMPTY_SEAT_STATUS: SeatDisplayStatus = 'Empty'; export function TableView({ model, submitIntent }: { model: RenderModel; submitIntent: (intent: TableIntent) => void }) { @@ -110,7 +111,7 @@ export function TableView({ model, submitIntent }: { model: RenderModel; submitI diff --git a/apps/web/components/ui/page-shell.tsx b/apps/web/components/ui/page-shell.tsx new file mode 100644 index 0000000..895a321 --- /dev/null +++ b/apps/web/components/ui/page-shell.tsx @@ -0,0 +1,54 @@ +import { cn } from '@/lib/utils'; + +/** + * The shared full-height page scaffold — the single definition of the + * centered-page Tailwind cluster previously repeated across `app/` and + * `components/hall/`. The variants reproduce the exact class combinations the + * pages already used, so every route renders pixel-identically: + * + * - `justify="center"` (default) vertically centers content; `"start"` pins it + * to the top (the live table and waiting-room surfaces). + * - `gap` and `padding` mirror the existing spacing variants. + */ +const GAP_CLASS = { '4': 'gap-4', '6': 'gap-6', none: '' } as const; +const PADDING_CLASS = { '6': 'p-6', '8': 'p-8' } as const; + +/** + * A centered single-message page: a title, a short muted message (arbitrary + * children), and an optional action row — the shape shared by the loading / + * error / transition / boot-placeholder shells. + */ +export function CenteredMessage({ action, children, title }: { action?: React.ReactNode; children: React.ReactNode; title: string }) { + return ( + +

{title}

+
{children}
+ {action} +
+ ); +} + +export function PageShell({ + children, + gap = '4', + justify = 'center', + padding = '8', +}: { + children: React.ReactNode; + gap?: keyof typeof GAP_CLASS; + justify?: 'center' | 'start'; + padding?: keyof typeof PADDING_CLASS; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/lib/create-store-context.tsx b/apps/web/lib/create-store-context.tsx new file mode 100644 index 0000000..8696220 --- /dev/null +++ b/apps/web/lib/create-store-context.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { createContext, type ReactNode, useContext, useState } from 'react'; +import { type StoreApi, useStore } from 'zustand'; + +/** + * Factory for a context-scoped zustand store — the single definition of the + * provider/hook boilerplate shared by the session and table stores. The + * `Provider` creates a per-tree store instance (once, via lazy `useState`, + * never a module global) so the store is SSR-safe and isolated per render + * tree; the hooks throw outside the provider, with messages derived from + * `name` (`use${name}` / `use${name}Api` within a `${name}Provider`). + */ +export function createStoreContext(name: string, create: () => StoreApi) { + const Context = createContext>(null); + + function Provider({ children }: { children: ReactNode }) { + const [store] = useState(create); + return {children}; + } + + /** Access the raw store api (for `getState` inside message handlers). */ + function useStoreApi(): StoreApi { + const store = useContext(Context); + if (!store) { + throw new Error(`use${name}Api must be used within a ${name}Provider`); + } + return store; + } + + /** Read selected store state; throws if used outside the provider. */ + function useStoreSelector(selector: (state: TState) => T): T { + const store = useContext(Context); + if (!store) { + throw new Error(`use${name} must be used within a ${name}Provider`); + } + return useStore(store, selector); + } + + return { Provider, useStoreApi, useStoreSelector }; +} diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index ecb1351..9c03259 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -11,7 +11,8 @@ import { loadWebEnv } from '@meldrank/shared'; * the values are inlined into the client bundle. A bare `process.env` handed to * the loader would NOT be substituted in browser code and would silently fall * back to the schema defaults. App URLs default to localhost; the Clerk - * publishable key is optional until the Auth & Identity change. + * publishable key is required — Clerk auth is live, and the loader throws + * without it. */ export const env = loadWebEnv({ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, diff --git a/apps/web/lib/handoff.ts b/apps/web/lib/handoff.ts new file mode 100644 index 0000000..8483cb7 --- /dev/null +++ b/apps/web/lib/handoff.ts @@ -0,0 +1,36 @@ +'use client'; + +import type { ActiveMatch, SignedSeatTicket } from '@meldrank/shared'; + +import { $path } from 'next-typesafe-url'; +import { useRouter } from 'next/navigation'; +import { useCallback } from 'react'; + +import { useSessionStore } from '@/lib/store'; + +/** + * The lobby→table handoff — the single definition of the stash-and-navigate + * step previously copied across Quick Play, Rejoin, and the waiting room's live + * transition: stash the active-match handle (and seat ticket, when the caller + * holds a fresh one) into the session store, then navigate to the `$path`-typed + * play route. Pass `{ replace: true }` to replace the history entry (the + * waiting room's live transition) instead of pushing. + */ +export function useTableHandoff() { + const router = useRouter(); + const setHandoff = useSessionStore((s) => s.setHandoff); + + return useCallback( + (active: ActiveMatch & { ticket?: null | SignedSeatTicket }, options?: { replace?: boolean }) => { + const { ticket, ...match } = active; + setHandoff({ match, ticket: ticket ?? null }); + const href = $path({ route: '/table/[roomId]', routeParams: { roomId: match.roomId } }); + if (options?.replace) { + router.replace(href); + } else { + router.push(href); + } + }, + [router, setHandoff], + ); +} diff --git a/apps/web/lib/store.tsx b/apps/web/lib/store.tsx index 6250f0b..4feb4cd 100644 --- a/apps/web/lib/store.tsx +++ b/apps/web/lib/store.tsx @@ -2,8 +2,9 @@ import type { ActiveMatch, SignedSeatTicket } from '@meldrank/shared'; -import { createContext, type ReactNode, useContext, useState } from 'react'; -import { createStore, useStore } from 'zustand'; +import { createStore } from 'zustand'; + +import { createStoreContext } from '@/lib/create-store-context'; /** * Client-side session/table state (design D4). F0 established the store, its @@ -24,8 +25,6 @@ export interface SessionState { setHandoff: (handoff: { match: ActiveMatch; ticket: null | SignedSeatTicket }) => void; } -type SessionStore = ReturnType; - function createSessionStore() { return createStore()((set) => ({ activeMatch: null, @@ -35,22 +34,13 @@ function createSessionStore() { })); } -const SessionStoreContext = createContext(null); +const sessionStoreContext = createStoreContext('SessionStore', createSessionStore); /** * Provides a per-tree store instance (created once via lazy `useState`, never a * module global) so the store is SSR-safe and isolated per render tree. */ -export function SessionStoreProvider({ children }: { children: ReactNode }) { - const [store] = useState(createSessionStore); - return {children}; -} +export const SessionStoreProvider = sessionStoreContext.Provider; /** Read selected session state; throws if used outside {@link SessionStoreProvider}. */ -export function useSessionStore(selector: (state: SessionState) => T): T { - const store = useContext(SessionStoreContext); - if (!store) { - throw new Error('useSessionStore must be used within a SessionStoreProvider'); - } - return useStore(store, selector); -} +export const useSessionStore = sessionStoreContext.useStoreSelector; diff --git a/apps/web/lib/table-store.tsx b/apps/web/lib/table-store.tsx index 5689655..34db833 100644 --- a/apps/web/lib/table-store.tsx +++ b/apps/web/lib/table-store.tsx @@ -3,10 +3,12 @@ import type { Card, FilteredView, MatchResult, PublicState } from '@meldrank/engine'; import type { ClockStatePayload, RoomLifecycle, SeatDisplayStatus, SyncedMetadataSnapshot } from '@meldrank/shared'; -import { createContext, type ReactNode, useContext, useMemo, useState } from 'react'; -import { createStore, useStore } from 'zustand'; +import { useMemo } from 'react'; +import { createStore } from 'zustand'; import { useShallow } from 'zustand/react/shallow'; +import { createStoreContext } from '@/lib/create-store-context'; + /** * Table render-model store (design D1). Colyseus delivers table state over two * independent channels: the auto-synced `RoomMetadata` schema (presence — @@ -104,8 +106,6 @@ export interface TableState { /** Connection lifecycle for the table surface (design D6, F2b D4). */ export type TableStatus = 'complete' | 'connected' | 'connecting' | 'error' | 'reconnecting'; -type TableStore = ReturnType; - /** Build the derived render model from the raw two-channel state (design D1, F2b D1). Exported for tests. */ export function buildRenderModel( state: Pick, @@ -174,13 +174,10 @@ function createTableStore() { })); } -const TableStoreContext = createContext(null); +const tableStoreContext = createStoreContext('TableStore', createTableStore); /** Provides a per-tree table store (created once via lazy `useState`), SSR-safe. */ -export function TableStoreProvider({ children }: { children: ReactNode }) { - const [store] = useState(createTableStore); - return {children}; -} +export const TableStoreProvider = tableStoreContext.Provider; /** * The single derived render model the table components consume. Selects the raw @@ -203,19 +200,7 @@ export function useRenderModel(): RenderModel { } /** Read selected table state; throws if used outside {@link TableStoreProvider}. */ -export function useTableStore(selector: (state: TableState) => T): T { - const store = useContext(TableStoreContext); - if (!store) { - throw new Error('useTableStore must be used within a TableStoreProvider'); - } - return useStore(store, selector); -} +export const useTableStore = tableStoreContext.useStoreSelector; /** Access the raw store api (for `getState` inside message handlers). */ -export function useTableStoreApi(): TableStore { - const store = useContext(TableStoreContext); - if (!store) { - throw new Error('useTableStoreApi must be used within a TableStoreProvider'); - } - return store; -} +export const useTableStoreApi = tableStoreContext.useStoreApi; diff --git a/apps/web/lib/use-waiting-room.ts b/apps/web/lib/use-waiting-room.ts index 39dba81..83b474a 100644 --- a/apps/web/lib/use-waiting-room.ts +++ b/apps/web/lib/use-waiting-room.ts @@ -8,7 +8,7 @@ import { $path } from 'next-typesafe-url'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { useSessionStore } from '@/lib/store'; +import { useTableHandoff } from '@/lib/handoff'; import { useTRPC, useTRPCClient } from '@/lib/trpc'; /** Waiting-room poll cadence: faster than the browse list (design Open Question). */ @@ -62,7 +62,7 @@ export function useWaitingRoom(tableId: string | undefined): WaitingRoom { const trpcClient = useTRPCClient(); const router = useRouter(); - const setHandoff = useSessionStore((s) => s.setHandoff); + const handoffToTable = useTableHandoff(); const me = useQuery(trpc.account.getMe.queryOptions()); // Destructure the referentially-stable fields/handlers (notably `refetch`), so the @@ -114,9 +114,7 @@ export function useWaitingRoom(tableId: string | undefined): WaitingRoom { setTransitioning(false); return; } - const { ticket, ...match } = active; - setHandoff({ match, ticket: ticket ?? null }); - router.replace($path({ route: '/table/[roomId]', routeParams: { roomId: active.roomId } })); + handoffToTable(active, { replace: true }); } catch { if (cancelled) return; transitionGuard.current = false; @@ -126,7 +124,7 @@ export function useWaitingRoom(tableId: string | undefined): WaitingRoom { return () => { cancelled = true; }; - }, [roomId, trpcClient, setHandoff, router]); + }, [roomId, trpcClient, handoffToTable]); // Seat mutations. On success refetch immediately (so occupancy updates ahead of the // next poll); a CONFLICT is non-fatal — show "seat just taken" and refresh. From 1bf42c9198b2cca323c1eb59bff332a10f5d300f Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Fri, 3 Jul 2026 11:38:56 -0400 Subject: [PATCH 14/16] refactor(infra): consolidate per-service Dockerfiles into one root Dockerfile Replace the three near-identical apps/*/Dockerfile files with a single parameterized root Dockerfile that selects the service via PKG/APP_DIR build args. Each fly.toml's [build] section now points at the root Dockerfile and supplies its own args, so deploy.yml no longer passes --dockerfile and a base-image or build-pipeline change is made exactly once. Update infra/README.md and the deployment-config spec to match. --- .github/workflows/deploy.yml | 5 +- Dockerfile | 61 ++++++++++++++++++++++++ apps/api/Dockerfile | 52 -------------------- apps/api/fly.toml | 7 ++- apps/bots/Dockerfile | 52 -------------------- apps/bots/fly.toml | 9 +++- apps/match/Dockerfile | 54 --------------------- apps/match/fly.toml | 7 ++- infra/README.md | 6 ++- openspec/specs/deployment-config/spec.md | 34 +++++++------ 10 files changed, 108 insertions(+), 179 deletions(-) create mode 100644 Dockerfile delete mode 100644 apps/api/Dockerfile delete mode 100644 apps/bots/Dockerfile delete mode 100644 apps/match/Dockerfile diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d45f3e8..05c05b8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -117,7 +117,8 @@ jobs: - name: Deploy ${{ matrix.app }} to Fly.io # Run from the repo root so the Docker build context is the whole workspace - # (each Dockerfile COPYs `packages/` and `apps/`). See infra/README.md §5. - run: flyctl deploy --config apps/${{ matrix.app }}/fly.toml --dockerfile apps/${{ matrix.app }}/Dockerfile --remote-only + # (the root Dockerfile COPYs `packages/` and `apps/`). Each fly.toml's + # [build] section owns the Dockerfile path and build args. See infra/README.md §5. + run: flyctl deploy --config apps/${{ matrix.app }}/fly.toml --remote-only env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ce3b2dd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +# syntax=docker/dockerfile:1 +# Single parameterized multi-stage production image for the Fly-hosted services +# (api, match, bots) on Node 22. Build args select the service: +# PKG — the pnpm workspace filter (e.g. @meldrank/api, @meldrank/match, +# @meldrank/bots-worker) +# APP_DIR — the service's directory (e.g. apps/api, apps/match, apps/bots) +# +# Three stages: +# 1. `build` — full workspace install, then `pnpm --filter ${PKG} build` +# compiles the service's own source plus its internal `@meldrank/*` +# workspace dependencies (via tsup, see ${APP_DIR}/tsup.config.ts) into a +# single `dist/index.js`. Third-party npm dependencies stay external. +# 2. `prod-deps` — `pnpm deploy --prod` resolves the workspace subgraph for +# `${PKG}` and writes a self-contained, pruned `node_modules` containing +# only production dependencies (no devDependencies, no source of +# workspaces this service doesn't depend on). +# 3. `runtime` — a slim image with no pnpm, no workspace source tree, no +# devDependencies: just the compiled `dist/index.js` and its pruned +# `node_modules`, run directly with `node`. No `EXPOSE` — Fly routes on +# each service's `internal_port`. +# +# Build context is the REPO ROOT so the whole pnpm workspace is available to +# the `build` stage. Each service's `fly.toml` points its `[build]` section at +# this file and supplies its `[build.args]`; deploy from the repository root: +# fly deploy --config apps//fly.toml +FROM node:22-slim AS base +ENV PNPM_HOME="/pnpm" PATH="/pnpm:$PATH" +RUN corepack enable +# `pnpm` resolves peer-dependency data across the whole shared workspace +# lockfile even when filtered to one service, and the match service's +# `@colyseus/uwebsockets-transport` peer resolves `uWebSockets.js` from a git +# URL — so `git` must be present wherever pnpm resolves the dependency graph, +# for every service (even those that never import colyseus). Confined to this +# intermediate stage; the `runtime` stage below is a fresh `node:22-slim` and +# never gets git. +RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* +WORKDIR /app + +# --- build: full workspace install + compile the selected service's bundle --- +FROM base AS build +ARG PKG +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages ./packages +COPY apps ./apps +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile +RUN pnpm --filter ${PKG} build + +# --- prod-deps: prune to a production-only node_modules --- +FROM build AS prod-deps +ARG PKG +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm --filter ${PKG} deploy --prod --legacy /prod + +# --- runtime: slim image that runs the compiled artifact --- +FROM node:22-slim AS runtime +ARG APP_DIR +ENV NODE_ENV=production +WORKDIR /app +COPY --from=build /app/${APP_DIR}/dist ./dist +COPY --from=prod-deps /prod/node_modules ./node_modules +CMD ["node", "dist/index.js"] diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile deleted file mode 100644 index debbbad..0000000 --- a/apps/api/Dockerfile +++ /dev/null @@ -1,52 +0,0 @@ -# syntax=docker/dockerfile:1 -# Multi-stage production image for the stateless tRPC API on Node 22. -# -# Three stages: -# 1. `build` — full workspace install, then `pnpm --filter @meldrank/api build` -# compiles the service's own source plus its internal `@meldrank/*` workspace -# dependencies (via tsup, see apps/api/tsup.config.ts) into a single -# `dist/index.js`. Third-party npm dependencies stay external. -# 2. `prod-deps` — `pnpm deploy --prod` resolves the workspace subgraph for -# `@meldrank/api` and writes a self-contained, pruned `node_modules` -# containing only production dependencies (no devDependencies, no source of -# workspaces this service doesn't depend on). -# 3. `runtime` — a slim image with no pnpm, no workspace source tree, no -# devDependencies: just the compiled `dist/index.js` and its pruned -# `node_modules`, run directly with `node`. -# -# Build context is the REPO ROOT so the whole pnpm workspace is available to the -# `build` stage. Deploy from the repository root with: -# fly deploy --config apps/api/fly.toml --dockerfile apps/api/Dockerfile -FROM node:22-slim AS base -ENV PNPM_HOME="/pnpm" PATH="/pnpm:$PATH" -RUN corepack enable -# `pnpm deploy` resolves peer-dependency data across the whole shared -# workspace lockfile even when filtered to this one service, and the match -# service's `@colyseus/uwebsockets-transport` peer resolves `uWebSockets.js` -# from a git URL — so `git` must be present here too, even though this -# service never imports colyseus. Confined to this intermediate stage; the -# `runtime` stage below is a fresh `node:22-slim` and never gets git. -RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* -WORKDIR /app - -# --- build: full workspace install + compile this service's bundle --- -FROM base AS build -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY packages ./packages -COPY apps ./apps -RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile -RUN pnpm --filter @meldrank/api build - -# --- prod-deps: prune to a production-only node_modules --- -FROM build AS prod-deps -RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ - pnpm --filter @meldrank/api deploy --prod --legacy /prod - -# --- runtime: slim image that runs the compiled artifact --- -FROM node:22-slim AS runtime -ENV NODE_ENV=production -WORKDIR /app -COPY --from=build /app/apps/api/dist ./dist -COPY --from=prod-deps /prod/node_modules ./node_modules -EXPOSE 3001 -CMD ["node", "dist/index.js"] diff --git a/apps/api/fly.toml b/apps/api/fly.toml index 6091150..b6307f2 100644 --- a/apps/api/fly.toml +++ b/apps/api/fly.toml @@ -9,7 +9,12 @@ app = "meldrank-api" primary_region = "ord" [build] - dockerfile = "Dockerfile" + # The single parameterized root Dockerfile (resolved relative to this file); + # the args select this service's pnpm filter and app directory. + dockerfile = "../../Dockerfile" + [build.args] + PKG = "@meldrank/api" + APP_DIR = "apps/api" [env] NODE_ENV = "production" diff --git a/apps/bots/Dockerfile b/apps/bots/Dockerfile deleted file mode 100644 index 2238b39..0000000 --- a/apps/bots/Dockerfile +++ /dev/null @@ -1,52 +0,0 @@ -# syntax=docker/dockerfile:1 -# Multi-stage production image for the Bots worker on Node 22. -# -# Three stages: -# 1. `build` — full workspace install, then `pnpm --filter @meldrank/bots-worker -# build` compiles the service's own source plus its internal `@meldrank/*` -# workspace dependencies (bots, engine, shared — via tsup, see -# apps/bots/tsup.config.ts) into a single `dist/index.js`. Third-party npm -# dependencies stay external. -# 2. `prod-deps` — `pnpm deploy --prod` resolves the workspace subgraph for -# `@meldrank/bots-worker` and writes a self-contained, pruned `node_modules` -# containing only production dependencies (no devDependencies, no source of -# workspaces this service doesn't depend on). -# 3. `runtime` — a slim image with no pnpm, no workspace source tree, no -# devDependencies: just the compiled `dist/index.js` and its pruned -# `node_modules`, run directly with `node`. -# -# Build context is the REPO ROOT so the whole pnpm workspace is available to the -# `build` stage. Deploy from the repository root with: -# fly deploy --config apps/bots/fly.toml --dockerfile apps/bots/Dockerfile -FROM node:22-slim AS base -ENV PNPM_HOME="/pnpm" PATH="/pnpm:$PATH" -RUN corepack enable -# `pnpm deploy` resolves peer-dependency data across the whole shared -# workspace lockfile even when filtered to this one service, and the match -# service's `@colyseus/uwebsockets-transport` peer resolves `uWebSockets.js` -# from a git URL — so `git` must be present here too, even though this -# service never imports colyseus. Confined to this intermediate stage; the -# `runtime` stage below is a fresh `node:22-slim` and never gets git. -RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* -WORKDIR /app - -# --- build: full workspace install + compile this service's bundle --- -FROM base AS build -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY packages ./packages -COPY apps ./apps -RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile -RUN pnpm --filter @meldrank/bots-worker build - -# --- prod-deps: prune to a production-only node_modules --- -FROM build AS prod-deps -RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ - pnpm --filter @meldrank/bots-worker deploy --prod --legacy /prod - -# --- runtime: slim image that runs the compiled artifact --- -FROM node:22-slim AS runtime -ENV NODE_ENV=production -WORKDIR /app -COPY --from=build /app/apps/bots/dist ./dist -COPY --from=prod-deps /prod/node_modules ./node_modules -CMD ["node", "dist/index.js"] diff --git a/apps/bots/fly.toml b/apps/bots/fly.toml index 559acbc..237d335 100644 --- a/apps/bots/fly.toml +++ b/apps/bots/fly.toml @@ -9,7 +9,14 @@ app = "meldrank-bots" primary_region = "ord" [build] - dockerfile = "Dockerfile" + # The single parameterized root Dockerfile (resolved relative to this file); + # the args select this service's pnpm filter and app directory. Note the + # package is @meldrank/bots-worker (the @meldrank/bots package is the + # decision-policy library it embeds). + dockerfile = "../../Dockerfile" + [build.args] + PKG = "@meldrank/bots-worker" + APP_DIR = "apps/bots" [env] NODE_ENV = "production" diff --git a/apps/match/Dockerfile b/apps/match/Dockerfile deleted file mode 100644 index ea86ff8..0000000 --- a/apps/match/Dockerfile +++ /dev/null @@ -1,54 +0,0 @@ -# syntax=docker/dockerfile:1 -# Multi-stage production image for the Match Service (Colyseus room server) on -# Node 22. -# -# Three stages: -# 1. `build` — full workspace install, then `pnpm --filter @meldrank/match build` -# compiles the service's own source plus its internal `@meldrank/*` workspace -# dependencies (engine, fairness, bots, shared — via tsup, see -# apps/match/tsup.config.ts) into a single `dist/index.js`. Third-party npm -# dependencies (colyseus, @colyseus/schema, drizzle-orm, etc.) stay external. -# 2. `prod-deps` — `pnpm deploy --prod` resolves the workspace subgraph for -# `@meldrank/match` and writes a self-contained, pruned `node_modules` -# containing only production dependencies (no devDependencies, no source of -# workspaces this service doesn't depend on). -# 3. `runtime` — a slim image with no pnpm, no workspace source tree, no -# devDependencies: just the compiled `dist/index.js` and its pruned -# `node_modules`, run directly with `node`. -# -# Build context is the REPO ROOT so the whole pnpm workspace is available to the -# `build` stage. Deploy from the repository root with: -# fly deploy --config apps/match/fly.toml --dockerfile apps/match/Dockerfile -FROM node:22-slim AS base -ENV PNPM_HOME="/pnpm" PATH="/pnpm:$PATH" -RUN corepack enable -# `@colyseus/uwebsockets-transport` is a required peer of `colyseus` (even though -# this service uses the default `ws`-based transport, not uwebsockets), and its -# own package.json resolves `uWebSockets.js` from a git URL — so `git` must be -# present wherever pnpm resolves the dependency graph. Confined to this -# intermediate stage; the `runtime` stage below is a fresh `node:22-slim` and -# never gets git. -RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* -WORKDIR /app - -# --- build: full workspace install + compile this service's bundle --- -FROM base AS build -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY packages ./packages -COPY apps ./apps -RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile -RUN pnpm --filter @meldrank/match build - -# --- prod-deps: prune to a production-only node_modules --- -FROM build AS prod-deps -RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ - pnpm --filter @meldrank/match deploy --prod --legacy /prod - -# --- runtime: slim image that runs the compiled artifact --- -FROM node:22-slim AS runtime -ENV NODE_ENV=production -WORKDIR /app -COPY --from=build /app/apps/match/dist ./dist -COPY --from=prod-deps /prod/node_modules ./node_modules -EXPOSE 2567 -CMD ["node", "dist/index.js"] diff --git a/apps/match/fly.toml b/apps/match/fly.toml index 37ccbfb..c14d949 100644 --- a/apps/match/fly.toml +++ b/apps/match/fly.toml @@ -10,7 +10,12 @@ app = "meldrank-match" primary_region = "ord" [build] - dockerfile = "Dockerfile" + # The single parameterized root Dockerfile (resolved relative to this file); + # the args select this service's pnpm filter and app directory. + dockerfile = "../../Dockerfile" + [build.args] + PKG = "@meldrank/match" + APP_DIR = "apps/match" [env] NODE_ENV = "production" diff --git a/infra/README.md b/infra/README.md index 4288216..c8f07d8 100644 --- a/infra/README.md +++ b/infra/README.md @@ -96,8 +96,10 @@ For each of `apps/match`, `apps/bots`, and `apps/api`: `fly secrets set --app meldrank-api DATABASE_URL=… UPSTASH_REDIS_REST_URL=… UPSTASH_REDIS_REST_TOKEN=… INTERNAL_SPAWN_SECRET=… SEAT_TICKET_SECRET=… MATCH_INTERNAL_URL=… WEB_APP_ORIGIN=…` 4. Deploy from the **repository root** so the Docker build context is the whole workspace: - `fly deploy --config apps/match/fly.toml --dockerfile apps/match/Dockerfile` - (and the equivalents for `bots` and `api`). + `fly deploy --config apps/match/fly.toml` + (and the equivalents for `bots` and `api`). Each `fly.toml`'s `[build]` + section points at the single parameterized root `Dockerfile` and supplies the + service's `PKG`/`APP_DIR` build args, so no `--dockerfile` flag is needed. 5. Validate config syntax before deploying with `fly config validate --config apps/match/fly.toml`. diff --git a/openspec/specs/deployment-config/spec.md b/openspec/specs/deployment-config/spec.md index 4715de2..8db1f3e 100644 --- a/openspec/specs/deployment-config/spec.md +++ b/openspec/specs/deployment-config/spec.md @@ -28,24 +28,30 @@ is **not** a Vercel app — it deploys to Fly.io (see _Fly.io deploy descriptors ### Requirement: Fly.io deploy descriptors The Fly.io-hosted services (`apps/match`, `apps/bots`, `apps/api`) SHALL each -include a `fly.toml` and a production `Dockerfile` capable of building the service -from the monorepo and running it on Node 22. Each `Dockerfile` SHALL produce a -compiled production artifact (a bundled entry point) for the service and run that -artifact directly (e.g. `node dist/index.js`), rather than transpiling TypeScript -source at process start. Each `Dockerfile`'s final runtime stage SHALL install only -production dependencies (no `devDependencies`) and SHALL NOT contain the TypeScript -source of workspaces the service does not depend on at runtime. - -#### Scenario: Each Fly service has a descriptor and Dockerfile +include a `fly.toml`, and the repository SHALL provide a single parameterized +production `Dockerfile` at the repo root capable of building any of the three +services from the monorepo and running it on Node 22. The `Dockerfile` SHALL +select the service via build arguments (the pnpm workspace filter and the app +directory), and each service's `fly.toml` SHALL declare the root `Dockerfile` +and supply its own build arguments, so a base-image or build-pipeline change is +made exactly once. The `Dockerfile` SHALL produce a compiled production artifact +(a bundled entry point) for the selected service and run that artifact directly +(e.g. `node dist/index.js`), rather than transpiling TypeScript source at +process start. The final runtime stage SHALL install only production +dependencies (no `devDependencies`) and SHALL NOT contain the TypeScript source +of workspaces the service does not depend on at runtime. + +#### Scenario: Each Fly service has a descriptor and shares the root Dockerfile - **WHEN** `apps/match`, `apps/bots`, and `apps/api` are inspected -- **THEN** each contains a `fly.toml` and a multi-stage production `Dockerfile` that - builds the service from the workspace and runs it on Node 22 +- **THEN** each contains a `fly.toml` whose `[build]` section points at the single + root `Dockerfile` and supplies that service's build arguments +- **AND** no per-service `Dockerfile` remains in the service directories -#### Scenario: Dockerfile builds the service +#### Scenario: Dockerfile builds every service -- **WHEN** the production `Dockerfile` for a Fly service is built -- **THEN** the image builds successfully and its entry runs the service +- **WHEN** the root `Dockerfile` is built with each service's build arguments +- **THEN** each image builds successfully and its entry runs that service #### Scenario: Runtime image runs a compiled artifact From cb0c375c599803c526ec06eb212a41df39a922d3 Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Fri, 3 Jul 2026 11:39:13 -0400 Subject: [PATCH 15/16] docs(spec): archive prune-and-dedupe-hygiene, mark hygiene item 19 done Archive the completed prune-and-dedupe-hygiene change and mark item 19 of the hardening audit done, closing out the last remaining audit item. --- docs/audit/00-overview.md | 2 +- .../.openspec.yaml | 2 + .../design.md | 67 +++++++++++++++++++ .../proposal.md | 39 +++++++++++ .../specs/deployment-config/spec.md | 54 +++++++++++++++ .../hand-lifecycle-state-machine/spec.md | 18 +++++ .../specs/match-shuffle-handshake/spec.md | 25 +++++++ .../tasks.md | 37 ++++++++++ 8 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/design.md create mode 100644 openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/proposal.md create mode 100644 openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/specs/deployment-config/spec.md create mode 100644 openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/specs/hand-lifecycle-state-machine/spec.md create mode 100644 openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/specs/match-shuffle-handshake/spec.md create mode 100644 openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/tasks.md diff --git a/docs/audit/00-overview.md b/docs/audit/00-overview.md index 7063b65..8e3708f 100644 --- a/docs/audit/00-overview.md +++ b/docs/audit/00-overview.md @@ -66,7 +66,7 @@ The AI-built codebase is in genuinely good shape architecturally: the workspace | 16 | ✅ DONE 2026-07-02 (direct commit, no OpenSpec change — Jason's call) — no-op `build` scripts (`tsc --noEmit`) removed from the 4 packages; `turbo run build` now matches only the apps' real builds (tsup ×3 + next), so CI stops type-checking every package twice | tooling | S | | 17 | ✅ DONE 2026-07-02 (direct commit) — pnpm `catalog:` in `pnpm-workspace.yaml` for the 6 duplicated tool versions (`typescript`, `eslint`, `vitest`, `@types/node`, `tsx`, `tsup`) plus `drizzle-orm`, which had drifted across 3 manifests (`^0.45.2` vs a bare pin) | root | S | | 18 | ✅ DONE 2026-07-02 (direct commit) — dev-only `loadLocalEnv()` (`@meldrank/shared/server`) hydrates `process.env` from the repo-root `.env` at each service entry (never overrides, no-op in prod/test), and the `PORT` schema treats empty as unset so the shared `.env` ships `PORT=` blank and each service keeps its own default port; a plain root `pnpm dev` now boots the whole stack | tooling, shared | S | -| 19 | Prune dead shared exports; fix stale doc comments; dedupe `SUIT_GLYPH`, `toHex`, page-shell JSX, engine deal orchestration (export rng-injectable `applyDealWithRng`) | shared, engine, web | M | +| 19 | ✅ DONE 2026-07-03 (change `prune-and-dedupe-hygiene`) — pruned dead shared exports (`pingRedis`, the variant predicates, `TRACE_ID_FIELD`, `PACKAGE_NAME`; `canonicalJson` un-barreled), fixed the stale doc comments, deduped `SUIT_GLYPH`/`SUITS`/the card React key, and the engine now owns deal orchestration (exported rng-injectable `applyDealWithRng` + `startNextHandBase`; the room's `deal.ts` is thin adapters). Extras ruled in 2026-07-03: web page-shell/store-context/handoff dedupe and the single parameterized root `Dockerfile` (fly.tomls supply `PKG`/`APP_DIR` build args). `toHex` and the `SEAT_COUNT` hardcode were already fixed pre-change | shared, engine, web, infra | M | | 20 | ✅ DONE 2026-07-03 (change `polish-engine-correctness`) — Engine polish: royal-marriage attribution fixed at the physical-card level (identity-based `no-melded` bury now sees the true melded copies), rejection reasons surfaced via `reduceWithVerdict` (typed `RejectionCode`; `reduce` = its `.state` projection), timeout escape completeness proven end-to-end (32-seed × 2-variant match-fold property test), plus finding-9 cleanups (`enterTrickPlay` contract guard, `index.ts` header, `ENGINE_VERSION` 0.1.0 as the replay-compatibility stamp) | engine | M | ## Decisions (2026-07-01, Jason) diff --git a/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/.openspec.yaml b/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/design.md b/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/design.md new file mode 100644 index 0000000..e4e1092 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/design.md @@ -0,0 +1,67 @@ +# Design: prune-and-dedupe-hygiene + +## Context + +Audit roadmap item 19 (docs/audit/00-overview.md, sourced from docs/audit/cross-cutting-duplication.md and docs/audit/packages-shared-fairness-bots.md) plus Jason's 2026-07-03 ruling to include the report's remaining web-boilerplate findings and the Dockerfile parameterization. Current state, re-verified against the working tree: + +- `apps/match/src/room/deal.ts` re-implements three engine internals (`nextActivePhase` verbatim; `dealHand` ≈ `applyDeal`; `nextHandBase` ≈ `startNextHand` minus the deal) so the room can deal with the fairness layer's full-width `Rng` instead of the engine `deal` event's 32-bit seed. The file's own header documents the copy as deliberate — the seam it wants simply doesn't exist on the engine. +- Dead `@meldrank/shared` exports (grep: zero consumers outside shared's own tests): `pingRedis`, `widowEnabled`/`buryEnabled`/`passingEnabled`, `TRACE_ID_FIELD` (exported from **both** `api` and `server` barrels), `PACKAGE_NAME`; `canonicalJson` is public but used only by `hashVariant` and its own test. +- Stale doc comments: `server/db/client.ts` ("schema is empty…"), `api/errors.ts` (`unauthorized` "reserved" though Clerk emits it), `apps/web/lib/env.ts` ("Clerk publishable key is optional until…"). +- Web duplication: `SUIT_GLYPH` in `card.tsx` **and** `table-view.tsx`; the four-suit literal in `table-view.tsx` (shared has `ALL_SUITS` un-exported); the card React key hand-built at `table-view.tsx:113` despite the engine exporting `cardIdentityKey`; the centered-page Tailwind cluster ~8× across `app/` and `components/hall/` plus the `Centered`/`ReturnToLobby`/`TableBootPlaceholder` near-twins; zustand context/provider/hook boilerplate duplicated in `lib/store.tsx` and `lib/table-store.tsx`; the `setHandoff` + navigate snippet ×3 (`casual-hall.tsx` ×2, `use-waiting-room.ts`). +- `apps/{api,match,bots}/Dockerfile` are the same 3-stage build differing only in the pnpm `--filter` name (`@meldrank/api` / `@meldrank/match` / `@meldrank/bots-worker`), the `dist` copy path, `EXPOSE`, and comments. The CD workflow deploys with `flyctl deploy --config apps//fly.toml --dockerfile apps//Dockerfile --remote-only`. + +Already fixed elsewhere (out of scope): web's `toHex` (imports `@meldrank/fairness`), `SEAT_COUNT` hardcode. + +## Goals / Non-Goals + +**Goals:** +- One implementation of deal/phase-advance/hand-boundary orchestration, owned by the engine, consumed by the room. +- A shared surface where every export has a consumer and every doc comment describes the code as it is. +- Web presentation/boilerplate duplication collapsed to single definitions; zero visual or behavioral change. +- One Dockerfile; a base-image or build-pipeline change is made once. + +**Non-Goals:** +- No rules, wire-contract, or persistence changes; no new user-facing behavior anywhere. +- `MatchResultEventSchema` stays exported (documents the published contract; parsed when an API-side subscriber lands). `HealthSchema` stays (executed by `healthy()`). +- Not unifying the two constant-time comparators (fairness `bytesEqual` vs ticket `timingSafeEqual`) — flagged by the audit as fine to leave until a byte-util module exists. +- No declare-trump switch to `variant.deck.suits` on the client (the variant snapshot isn't in the client store today); `SUITS` from shared is the v1 dedupe, deck-driven suits ride a future variant-UI change. + +## Decisions + +**D1 — Engine exports the rng-injectable seam; the reducer is rebuilt on it.** +`packages/engine/src/state/reduce.ts` exports two new functions, re-exported from the engine root: +- `applyDealWithRng(state: State, rng: Rng): ReduceOutcome` — the current `applyDeal` body with the rng injected; the private `applyDeal(state, event)` becomes `applyDealWithRng(state, createSeededRng(event.seed))`. +- `startNextHandBase(state: State): State` — the fresh-`Dealing`-base builder (dealer rotated, per-hand fields reset, `scorePad` + `handsMadeAsBidder` carried); the private `startNextHand(state, event)` becomes `applyDeal(startNextHandBase(state), event)` keeping the existing refusal-returns-caller's-state guard. + +`apps/match/src/room/deal.ts` shrinks to thin adapters preserving its current signatures (`dealHand` throws on a rejected outcome, as today; `nextHandBase` delegates to `startNextHandBase`), so `handshake.ts` doesn't change. The header comment is rewritten: it no longer "deliberately re-implements" anything — it documents why the room injects a full-width `Rng` (2³² seed bottleneck) through the engine's exported seam. +*Alternative considered:* widening the engine `deal` event to carry an `Rng` — rejected: events must stay serializable for replay-fold; a closure in the event union breaks that invariant. + +**D2 — Prune = delete, not deprecate.** Pre-1.0 internal workspace, every removal grep-verified: delete `pingRedis`, the three variant predicates (+ their test block), `PACKAGE_NAME` (smoke test updated); remove `TRACE_ID_FIELD` from `api/trace.ts` and both barrels; remove `canonicalJson` from the `variant` barrel but keep it exported from `hash.ts` (module-internal to shared) so `hashVariant` and `hash.test.ts` keep working unchanged. + +**D3 — `SUITS` becomes shared vocabulary.** Export `SUITS` (derived from the Zod `SuitSchema.options`, replacing the un-exported `ALL_SUITS` literal in `canonical.ts`) from `packages/shared/src/variant`. `table-view.tsx` iterates `SUITS`; `meld.ts`'s engine-local literal is replaced with the shared import (type-only rule unaffected — shared is now a runtime dep of the engine per audit item 10, and `meld.ts` already sits behind the value-import boundary). + +**D4 — Web dedupe shapes:** +- `SUIT_GLYPH`: exported from `components/table/card.tsx` (the card renderer is its natural owner); `table-view.tsx` imports it. +- Card React key: `key={cardIdentityKey(card)}` from `@meldrank/engine` (already a web dep). +- `PageShell` (+ a `CenteredMessage` variant taking title/message/optional action) in `components/ui/page-shell.tsx`, replacing the ~8 Tailwind-cluster call sites and both `Centered`/`ReturnToLobby`-style shells. Props stay minimal (`justify` center vs top, `gap`/padding variants) so every existing page renders pixel-identically. +- `createStoreContext()` in `lib/create-store-context.tsx` returning `{ Provider, useStoreSelector, useStoreApi }`; `store.tsx` and `table-store.tsx` keep their public hook names as thin re-exports so no call site changes. +- `handoffToTable`: a `useTableHandoff()` hook in `lib/handoff.ts` wrapping `setHandoff` + `$path`-typed navigation (`push` vs `replace` as an option), replacing the three copies. + +**D5 — Single root `Dockerfile`, args in `fly.toml`.** One `Dockerfile` at the repo root with `ARG PKG` (pnpm filter, e.g. `@meldrank/api`) and `ARG APP_DIR` (e.g. `apps/api`); stages are today's three-stage build with the two literals substituted. `EXPOSE` is dropped (metadata-only; Fly routes on `internal_port`). Each service's `fly.toml` gains `[build] dockerfile = "../../Dockerfile"` and `[build.args]` with its `PKG`/`APP_DIR`; the CD workflow's deploy step drops its `--dockerfile` flag (config now owns it). The `git`-in-base-stage note applies to all three (same lockfile resolution), so the consolidated comment keeps it once. +*Alternative considered:* keeping three Dockerfiles that `INCLUDE` a shared fragment — Docker has no include; templating via CI adds a generation step for zero benefit at this scale. + +## Risks / Trade-offs + +- [Engine refactor drifts reduce semantics] → behavior-preserving by construction (same bodies, extracted); the full engine suite (~239 tests incl. the 32-seed × 2-variant match-fold property test) must stay green, plus a new test asserting `reduce(deal)` ≡ `applyDealWithRng` with `createSeededRng(seed)` pins the equivalence permanently. +- [A pruned export has an unseen consumer] → grep across `apps/` + `packages/` before each deletion (already done once in planning); workspace-wide typecheck catches stragglers; nothing external consumes `@meldrank/shared` (private workspace). +- [Dockerfile consolidation breaks CD, visible only on merge] → build all three images locally (`docker build --build-arg PKG=… --build-arg APP_DIR=… .`) before merge; the deploy workflow's per-app matrix is otherwise unchanged, and rollback is `git revert` + redeploy (previous images remain on Fly). +- [Web shell consolidation shifts pixels] → `PageShell` variants reproduce the exact existing class strings; the vitest component layer and the Playwright smoke (hall → table) gate the PR. +- [`fly.toml` relative dockerfile path resolves differently in remote builds] → `dockerfile` is resolved relative to the config file per Fly docs, and the build context stays the repo root (workflow already runs there); verified in CI on the first deploy — if remote resolution misbehaves, fall back to passing `--dockerfile Dockerfile` in the workflow (root-relative), a one-line revert. + +## Migration Plan + +Pure refactor; lands as one PR. Deploy order irrelevant (no wire/schema/runtime changes). The only deploy-pipeline edit (root Dockerfile + fly.toml build args) takes effect on the next merge to main; first CD run after merge is the live verification. Rollback: revert the PR. + +## Open Questions + +_None — scope and mechanics ruled by Jason 2026-07-03 (extras in, including Dockerfile parameterization)._ diff --git a/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/proposal.md b/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/proposal.md new file mode 100644 index 0000000..d07c119 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/proposal.md @@ -0,0 +1,39 @@ +# Proposal: prune-and-dedupe-hygiene + +## Why + +Hardening-audit roadmap item 19 (docs/audit/00-overview.md) is the last open item: the duplication and shared-package reports flagged dead exports on `@meldrank/shared`'s public surface, doc comments that now misdescribe the code, and a handful of copy-paste duplications — most notably three engine deal-orchestration functions re-implemented near-verbatim in `apps/match`, where any future rules change silently diverges live matches from replay-fold semantics. Jason additionally ruled (2026-07-03) that the report's remaining low-severity web-boilerplate findings and the Dockerfile parameterization ride along, closing out the audit in one hygiene pass. + +Two findings from the report are already fixed and are **out of scope**: web's local `toHex` (now imported from `@meldrank/fairness`) and the `SEAT_COUNT` hardcode (removed). + +## What Changes + +- **Engine-owned deal orchestration:** `packages/engine` exports an rng-injectable seam — `applyDealWithRng(state, rng)` and a public `startNextHandBase(state)` — and `reduce`'s own deal path is rebuilt on them. `apps/match/src/room/deal.ts` shrinks to imports (or is deleted), removing the copied `nextActivePhase` / `dealHand` / `nextHandBase`. +- **Prune dead shared exports** (grep-verified before each deletion): `pingRedis`, the `widowEnabled`/`buryEnabled`/`passingEnabled` predicates, `TRACE_ID_FIELD`, `PACKAGE_NAME`; un-export `canonicalJson` (internal to `hashVariant`). `MatchResultEventSchema` stays — it documents the published contract and will be parsed when the API-side subscriber lands. +- **Fix stale doc comments:** `packages/shared/src/server/db/client.ts` ("schema is empty until the Data Model change"), `packages/shared/src/api/errors.ts` (`unauthorized` "reserved" — Clerk landed and uses it), `apps/web/lib/env.ts` ("Clerk publishable key is optional until the Auth & Identity change"). +- **Web card/suit dedupe:** single `SUIT_GLYPH` (exported from `card.tsx`), shared `SUITS` export from `packages/shared/src/variant`, reuse engine's `cardIdentityKey` for the React key in `table-view.tsx`. +- **Web shell + boilerplate dedupe:** a `PageShell`/`CenteredMessage` component replacing the ~8× repeated centered-page Tailwind cluster and the `Centered`/`ReturnToLobby` near-twins; a generic `createStoreContext()` helper replacing the duplicated zustand provider/hook boilerplate in `store.tsx` and `table-store.tsx`; a `handoffToTable(active)` helper replacing the 3× destructure-and-handoff snippet. +- **Single parameterized Dockerfile:** one root `Dockerfile` with build args (package filter, app dir) replaces the three near-identical per-service Dockerfiles; `fly.toml`s / the deploy workflow supply the args. **BREAKING** for the deploy pipeline invocation (workflow `--dockerfile` path changes); no runtime behavior change. + +No user-facing behavior changes anywhere; every deal, message, and page renders identically before and after. + +## Capabilities + +### New Capabilities + +_None — this change is consolidation and pruning; no new behavior is introduced._ + +### Modified Capabilities + +- `hand-lifecycle-state-machine`: the engine SHALL export rng-injectable deal orchestration (`applyDealWithRng`, `startNextHandBase`) as the single implementation of deal/phase-advance/hand-boundary logic, with the reducer's own `deal` handling built on the same functions. +- `match-shuffle-handshake`: the room's deal path SHALL delegate to the engine-exported orchestration rather than re-implementing phase-advance or hand-boundary logic locally. +- `deployment-config`: Fly services SHALL build from a single parameterized root `Dockerfile` (build args select the service) instead of one Dockerfile per service directory. + +## Impact + +- **packages/engine** — new public exports; `reduce.ts` internals refactored to share them (behavior-preserving; existing ~239 tests must stay green). +- **packages/shared** — export removals (`pingRedis`, three variant predicates, `TRACE_ID_FIELD`, `PACKAGE_NAME`, `canonicalJson`), new `SUITS` export, doc-comment fixes. Export removal is safe: grep confirms zero consumers outside shared's own tests. +- **apps/match** — `src/room/deal.ts` reduced to engine imports; `handshake.ts` untouched in behavior. +- **apps/web** — component/lib-level dedupe only; no route or wire changes. +- **infra/CI** — root `Dockerfile` replaces `apps/{api,match,bots}/Dockerfile`; `.github/workflows/deploy.yml` and the three `fly.toml`s updated to pass build args; `infra/README.md` §5 updated. +- **Specs** — three delta specs (above); no new capabilities. diff --git a/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/specs/deployment-config/spec.md b/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/specs/deployment-config/spec.md new file mode 100644 index 0000000..ee1d00f --- /dev/null +++ b/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/specs/deployment-config/spec.md @@ -0,0 +1,54 @@ +# Delta: deployment-config + +## MODIFIED Requirements + +### Requirement: Fly.io deploy descriptors + +The Fly.io-hosted services (`apps/match`, `apps/bots`, `apps/api`) SHALL each +include a `fly.toml`, and the repository SHALL provide a single parameterized +production `Dockerfile` at the repo root capable of building any of the three +services from the monorepo and running it on Node 22. The `Dockerfile` SHALL +select the service via build arguments (the pnpm workspace filter and the app +directory), and each service's `fly.toml` SHALL declare the root `Dockerfile` +and supply its own build arguments, so a base-image or build-pipeline change is +made exactly once. The `Dockerfile` SHALL produce a compiled production artifact +(a bundled entry point) for the selected service and run that artifact directly +(e.g. `node dist/index.js`), rather than transpiling TypeScript source at +process start. The final runtime stage SHALL install only production +dependencies (no `devDependencies`) and SHALL NOT contain the TypeScript source +of workspaces the service does not depend on at runtime. + +#### Scenario: Each Fly service has a descriptor and shares the root Dockerfile + +- **WHEN** `apps/match`, `apps/bots`, and `apps/api` are inspected +- **THEN** each contains a `fly.toml` whose `[build]` section points at the single + root `Dockerfile` and supplies that service's build arguments +- **AND** no per-service `Dockerfile` remains in the service directories + +#### Scenario: Dockerfile builds every service + +- **WHEN** the root `Dockerfile` is built with each service's build arguments +- **THEN** each image builds successfully and its entry runs that service + +#### Scenario: Runtime image runs a compiled artifact + +- **WHEN** a Fly service's production image is built +- **THEN** the image's build stage produces a compiled `dist/index.js` for that + service (bundling the service's own source together with the internal + `@meldrank/*` workspace packages it imports) +- **AND** the image's `CMD` runs `node dist/index.js` (or equivalent), not `tsx` + over TypeScript source + +#### Scenario: Runtime image excludes development dependencies + +- **WHEN** a Fly service's production image's final runtime stage is inspected +- **THEN** its installed `node_modules` contains only the production + (`dependencies`) entries needed to run the compiled artifact +- **AND** it contains no `devDependencies` (no `eslint`, `vitest`, `typescript`, + `tsx`, or any package solely used for linting/testing/type-checking) + +#### Scenario: Runtime image does not contain unrelated workspace source + +- **WHEN** a Fly service's production image's final runtime stage is inspected +- **THEN** it contains the compiled `dist/index.js` and its production + `node_modules`, but not the raw TypeScript source of `apps/*` or `packages/*` diff --git a/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/specs/hand-lifecycle-state-machine/spec.md b/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/specs/hand-lifecycle-state-machine/spec.md new file mode 100644 index 0000000..cdab770 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/specs/hand-lifecycle-state-machine/spec.md @@ -0,0 +1,18 @@ +# Delta: hand-lifecycle-state-machine + +## ADDED Requirements + +### Requirement: Rng-injectable deal orchestration is engine-owned + +The engine SHALL export deal orchestration seams usable with an injected `Rng`: `applyDealWithRng(state, rng)` — deal the hands and widow through the Dealer's `rng` seam, open the auction at the seat left of the dealer, and advance to the variant's next active phase — and `startNextHandBase(state)` — the fresh `Dealing` base for the next hand of a match, with the dealer rotated one seat, every per-hand field (including a redeal `outcome` marker) reset, and the running `scorePad` plus the match-scope `handsMadeAsBidder` counter preserved. The reducer's own `deal` handling SHALL be built on these same functions (seed expansion via `createSeededRng` feeding `applyDealWithRng`; hand boundaries via `startNextHandBase`), so deal/phase-advance/hand-boundary orchestration exists exactly once. + +#### Scenario: Injected-rng deal matches the reducer's deal + +- **WHEN** `applyDealWithRng(state, createSeededRng(seed))` and `reduce(state, { type: 'deal', seed })` are applied to the same `Dealing` state +- **THEN** both produce the identical resulting state (same hands, widow, auction, phase) + +#### Scenario: Next-hand base preserves match scope and rotates the dealer + +- **WHEN** `startNextHandBase` is applied to a state resting at a hand boundary (`HandScoring`, or a redeal-marked `Auction`) +- **THEN** the result is a fresh `Dealing` state whose dealer is rotated one seat +- **AND** the running `scorePad` and `handsMadeAsBidder` counter are preserved while every per-hand field is reset diff --git a/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/specs/match-shuffle-handshake/spec.md b/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/specs/match-shuffle-handshake/spec.md new file mode 100644 index 0000000..33f1fec --- /dev/null +++ b/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/specs/match-shuffle-handshake/spec.md @@ -0,0 +1,25 @@ +# Delta: match-shuffle-handshake + +## MODIFIED Requirements + +### Requirement: Seed assembly drives the deal + +After collecting seat contributions for a hand, the room SHALL assemble the deal seed with `assembleSeed`, substituting `fallbackContribution` for any seat that did not contribute, expand it through `rngFromSeed`, and feed the resulting `Rng` into the engine Dealer's injected `rng` seam. The deal SHALL be fully determined by the committed server seed plus the collected contributions, so the hand is independently reproducible from the eventual reveal. The room SHALL perform this dealing through the engine's exported orchestration (`applyDealWithRng`, with hand boundaries built on `startNextHandBase`) and SHALL NOT re-implement the engine's phase-advance or hand-boundary logic locally. + +#### Scenario: Absent contribution uses deterministic fallback + +- **WHEN** a hand is dealt and one or more seats did not contribute a `clientSeed` +- **THEN** the room substitutes the deterministic `fallbackContribution` for each absent seat +- **AND** the deal proceeds without granting the server any additional control over the outcome + +#### Scenario: Deal is seeded from the committed handshake + +- **WHEN** the room deals a hand +- **THEN** the Dealer's `rng` is derived from `assembleSeed` over the committed server seed and the seat contributions for that hand +- **AND** the same inputs reproduce the identical deal + +#### Scenario: Deal orchestration is engine-owned + +- **WHEN** the room's deal path (`apps/match/src/room/deal.ts`) is inspected +- **THEN** dealing and hand-boundary construction delegate to the engine's exported `applyDealWithRng` and `startNextHandBase` +- **AND** no local copy of the engine's phase-advance or next-hand logic remains diff --git a/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/tasks.md b/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/tasks.md new file mode 100644 index 0000000..815ba63 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-prune-and-dedupe-hygiene/tasks.md @@ -0,0 +1,37 @@ +# Tasks: prune-and-dedupe-hygiene + +## 1. Engine-owned deal orchestration (design D1) + +- [x] 1.1 In `packages/engine/src/state/reduce.ts`, extract `applyDealWithRng(state: State, rng: Rng): ReduceOutcome` from `applyDeal`'s body; `applyDeal(state, event)` becomes `applyDealWithRng(state, createSeededRng(event.seed))`. Export it and re-export from the engine root index. +- [x] 1.2 Extract `startNextHandBase(state: State): State` (dealer rotation + per-hand reset + `scorePad`/`handsMadeAsBidder` carry-over) from `startNextHand`; `startNextHand(state, event)` becomes `applyDeal(startNextHandBase(state), event)` keeping the refusal-returns-caller's-state guard. Export it and re-export from the engine root index. +- [x] 1.3 Add an engine test pinning the equivalence: `applyDealWithRng(state, createSeededRng(seed)).state` deep-equals `reduce(state, { type: 'deal', seed })` for both canonical variants; plus a `startNextHandBase` test covering dealer rotation, per-hand reset (incl. clearing a redeal `outcome`), and score-pad/counter carry-over. +- [x] 1.4 Shrink `apps/match/src/room/deal.ts` to thin adapters over the engine exports (same signatures: `dealHand` throws on rejected outcome, `nextHandBase` delegates to `startNextHandBase`); delete the local `nextActivePhase`; rewrite the header comment to document the full-width-`Rng` rationale without "re-implements". `handshake.ts` stays untouched. +- [x] 1.5 Full engine + match test suites green (validate agent) — the 32-seed × 2-variant match-fold property test is the semantic gate. + +## 2. Shared surface prune + doc-comment sweep (design D2) + +- [x] 2.1 Grep-verify then delete: `pingRedis` (`server/redis/client.ts`), `widowEnabled`/`buryEnabled`/`passingEnabled` (`variant/schema.ts` + barrel + their `schema.test.ts` block), `TRACE_ID_FIELD` (`api/trace.ts` + **both** `api/index.ts` and `server/index.ts` barrels), `PACKAGE_NAME` (`src/index.ts` + smoke-test assertion). +- [x] 2.2 Remove `canonicalJson` from the `variant/index.ts` barrel (keep it exported from `hash.ts` so `hashVariant` + `hash.test.ts` are unchanged). +- [x] 2.3 Fix the three stale doc comments: `packages/shared/src/server/db/client.ts` (schema is no longer empty — describe what's there), `packages/shared/src/api/errors.ts` (`unauthorized` is live via Clerk; `forbidden`/`rate-limited` remain reserved), `apps/web/lib/env.ts` (Clerk publishable key is required/live). +- [x] 2.4 Export `SUITS` (from `SuitSchema.options`) in `packages/shared/src/variant`; replace `canonical.ts`'s private `ALL_SUITS` and `packages/engine/src/meld/meld.ts`'s suit literal with it. +- [x] 2.5 Workspace-wide typecheck + shared/engine tests green (validate agent). + +## 3. Web dedupe (design D3/D4) + +- [x] 3.1 Export `SUIT_GLYPH` from `components/table/card.tsx`; delete the copy in `table-view.tsx` and import it; iterate `SUITS` from `@meldrank/shared` for the declare-trump buttons; replace the hand-built card key with `cardIdentityKey(card)` from `@meldrank/engine`. +- [x] 3.2 Add `components/ui/page-shell.tsx` (`PageShell` + `CenteredMessage`) reproducing the exact existing class strings; migrate the ~8 call sites (`app/page.tsx`, `sign-in`, `sign-up`, `table/[roomId]/page.tsx` ×3, `waiting-room.tsx` ×2) and fold the `Centered`/`ReturnToLobby`/`TableBootPlaceholder` shells onto it. +- [x] 3.3 Add `lib/create-store-context.tsx` (`createStoreContext()` → `{ Provider, useStoreSelector, useStoreApi }`); rebuild `lib/store.tsx` and `lib/table-store.tsx` on it, keeping their public hook names as re-exports so call sites don't change. +- [x] 3.4 Add `lib/handoff.ts` with a `useTableHandoff()` hook (setHandoff + `$path`-typed navigate, `push`/`replace` option); replace the three copies (`casual-hall.tsx` rejoin + quick-play, `use-waiting-room.ts` live transition). +- [x] 3.5 Web unit/component tests green; Playwright e2e smoke green against the local stack (validate agent + `run-meldrank` skill). + +## 4. Single parameterized Dockerfile (design D5) + +- [x] 4.1 Create the root `Dockerfile` (`ARG PKG`, `ARG APP_DIR`; today's three-stage build with the literals substituted; no `EXPOSE`; consolidated comments incl. the `git`-for-uWebSockets note); delete `apps/{api,match,bots}/Dockerfile`. +- [x] 4.2 Update the three `fly.toml`s: `[build] dockerfile = "../../Dockerfile"` + `[build.args]` (`PKG`/`APP_DIR` per service — note bots' package is `@meldrank/bots-worker`). +- [x] 4.3 Update `.github/workflows/deploy.yml` (drop the `--dockerfile` flag; config owns it) and `infra/README.md` §5's deploy commands. +- [x] 4.4 Verify: `docker build` all three images from the repo root with each arg pair; each image's entry starts (or fails only on missing env, as today). + +## 5. Close out + +- [x] 5.1 Full validation sweep: lint, typecheck, all tests (validate agent). +- [x] 5.2 Update `docs/audit/00-overview.md` item 19 row to ✅ DONE with the change name and scope note (extras included; toHex/SEAT_COUNT were pre-fixed). From aa97f64a4cf633feffaaf10a748e33bc000a0324 Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Fri, 3 Jul 2026 11:39:33 -0400 Subject: [PATCH 16/16] chore(atk): reorder lockfile asset entries --- .atk-lock.json | 221 +++++++++++++++++++++++++------------------------ 1 file changed, 111 insertions(+), 110 deletions(-) diff --git a/.atk-lock.json b/.atk-lock.json index 8155108..a20014d 100644 --- a/.atk-lock.json +++ b/.atk-lock.json @@ -1,8 +1,119 @@ { "assets": [ + { + "dependencies": [], + "description": "This rule ensures the agent uses the validate agent for running the projects validation commands (lint, typecheck, tests)", + "files": [ + { + "checksum": "07b9425c82fd24a6c8185821368662856e8fd506c82537f006df296fed2a17dd", + "installedPath": ".claude/rules/dev-commands-rule.md", + "sourcePath": "dev-commands-rule.md" + } + ], + "installReason": "direct", + "name": "dev-commands-rule", + "org": "agentic-toolkit", + "type": "rule", + "version": "1.0.0" + }, + { + "dependencies": [], + "description": "Runs lint, typecheck, and test commands and returns a concise summary of the results.", + "files": [ + { + "checksum": "56c5e7bee8a41eeecb6d0f5f8e9b90d33a4a4f6b0404ae1524a9ae187a0f789a", + "installedPath": ".claude/agents/validate.md", + "sourcePath": "validate.md" + } + ], + "installReason": "direct", + "name": "validate", + "org": "agentic-toolkit", + "type": "agent", + "version": "1.0.0" + }, + { + "dependencies": [], + "description": "Researches library documentation and code examples via Context7 MCP server without filling up the main context window", + "files": [ + { + "checksum": "600539b12f7db7785072ba21d5b7a2a201bddc4affd1526f264067f36cf0edef", + "installedPath": ".claude/agents/context7-researcher.md", + "sourcePath": "context7-researcher.md" + } + ], + "installReason": "direct", + "name": "context7-researcher", + "type": "agent", + "version": "1.0.0" + }, + { + "dependencies": [], + "description": "Create a Conventional Commits formatted commit", + "entrypoint": "SKILL.md", + "files": [ + { + "checksum": "599bc03dc018cff72f5283b2bf3396cfb2913d04bb521c6e76d56eb88d797fa3", + "installedPath": ".claude/skills/conventional-commit/SKILL.md", + "sourcePath": "SKILL.md" + } + ], + "installReason": "direct", + "name": "conventional-commit", + "type": "skill", + "version": "1.0.1" + }, + { + "configEntries": [ + { + "checksum": "3a0c1791257b563b3d503f0b2112369702f03f341f12dad48f10ee39b411e69d", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify-on-stop-hook.sh", + "file": ".claude/settings.json", + "key": "hooks.Stop" + } + ], + "dependencies": [], + "description": "Sends a desktop notification when Claude Code finishes responding (Stop event)", + "entrypoint": "notify-on-stop-hook.sh", + "files": [ + { + "checksum": "48deb177acce3d0882c28062246828f680c000baff87d9fe740f27106dbda7a8", + "sourcePath": "notify-on-stop-hook.sh" + } + ], + "installReason": "direct", + "name": "notify-on-stop-hook", + "type": "hook", + "version": "1.2.0" + }, + { + "configEntries": [ + { + "checksum": "91c0be034149b6af4c8aab9122fd8b22647aaee609d2b7a96f10e71944252379", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify-on-question-hook.sh", + "file": ".claude/settings.json", + "key": "hooks.PreToolUse" + } + ], + "dependencies": [], + "description": "Plays a double beep when Claude Code is about to prompt with AskUserQuestion, so you know to check the terminal before the questions time out", + "entrypoint": "notify-on-question-hook.sh", + "files": [ + { + "checksum": "f8478ef78f43d4b32a4098d31ca31bd9b7b1fbeb88d0e2d03947041eca5bf44e", + "installedPath": ".claude/hooks/notify-on-question-hook.sh", + "sourcePath": "notify-on-question-hook.sh" + } + ], + "installReason": "direct", + "name": "notify-on-question-hook", + "type": "hook", + "version": "1.1.0" + }, { "dependencies": [], "description": "Comprehensive reference guide for the Agentic Tool Kit (ATK) CLI and web app — covers every command, non-interactive flags for scripted/agent-driven use (publish, init, install, etc.), JSON output mode, core concepts (lockfile, tool adapters, trust levels, checksums), authentication, and troubleshooting. Use when the user asks how to do something in ATK, wants to know what a flag does, is debugging an ATK error, is authoring or publishing a new asset, asks Claude to publish/install/manage assets on their behalf, or needs clarification on ATK concepts like setup, sync, pin/freeze, or the org scope.", + "entrypoint": "SKILL.md", "files": [ { "checksum": "030a699c7a13e12533b7a95b4d9e9b04a82796f9a238e782ffd49bc9b4d24616", @@ -199,116 +310,6 @@ "name": "atk-guide", "type": "skill", "version": "1.3.0" - }, - { - "dependencies": [], - "description": "This rule ensures the agent uses the validate agent for running the projects validation commands (lint, typecheck, tests)", - "files": [ - { - "checksum": "07b9425c82fd24a6c8185821368662856e8fd506c82537f006df296fed2a17dd", - "installedPath": ".claude/rules/dev-commands-rule.md", - "sourcePath": "dev-commands-rule.md" - } - ], - "installReason": "direct", - "name": "dev-commands-rule", - "org": "agentic-toolkit", - "type": "rule", - "version": "1.0.0" - }, - { - "dependencies": [], - "description": "Runs lint, typecheck, and test commands and returns a concise summary of the results.", - "files": [ - { - "checksum": "56c5e7bee8a41eeecb6d0f5f8e9b90d33a4a4f6b0404ae1524a9ae187a0f789a", - "installedPath": ".claude/agents/validate.md", - "sourcePath": "validate.md" - } - ], - "installReason": "direct", - "name": "validate", - "org": "agentic-toolkit", - "type": "agent", - "version": "1.0.0" - }, - { - "dependencies": [], - "description": "Researches library documentation and code examples via Context7 MCP server without filling up the main context window", - "files": [ - { - "checksum": "600539b12f7db7785072ba21d5b7a2a201bddc4affd1526f264067f36cf0edef", - "installedPath": ".claude/agents/context7-researcher.md", - "sourcePath": "context7-researcher.md" - } - ], - "installReason": "direct", - "name": "context7-researcher", - "type": "agent", - "version": "1.0.0" - }, - { - "configEntries": [ - { - "checksum": "91c0be034149b6af4c8aab9122fd8b22647aaee609d2b7a96f10e71944252379", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify-on-question-hook.sh", - "file": ".claude/settings.json", - "key": "hooks.PreToolUse" - } - ], - "dependencies": [], - "description": "Plays a double beep when Claude Code is about to prompt with AskUserQuestion, so you know to check the terminal before the questions time out", - "entrypoint": "notify-on-question-hook.sh", - "files": [ - { - "checksum": "f8478ef78f43d4b32a4098d31ca31bd9b7b1fbeb88d0e2d03947041eca5bf44e", - "installedPath": ".claude/hooks/notify-on-question-hook.sh", - "sourcePath": "notify-on-question-hook.sh" - } - ], - "installReason": "direct", - "name": "notify-on-question-hook", - "type": "hook", - "version": "1.1.0" - }, - { - "dependencies": [], - "description": "Create a Conventional Commits formatted commit", - "entrypoint": "SKILL.md", - "files": [ - { - "checksum": "599bc03dc018cff72f5283b2bf3396cfb2913d04bb521c6e76d56eb88d797fa3", - "installedPath": ".claude/skills/conventional-commit/SKILL.md", - "sourcePath": "SKILL.md" - } - ], - "installReason": "direct", - "name": "conventional-commit", - "type": "skill", - "version": "1.0.1" - }, - { - "configEntries": [ - { - "checksum": "3a0c1791257b563b3d503f0b2112369702f03f341f12dad48f10ee39b411e69d", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify-on-stop-hook.sh", - "file": ".claude/settings.json", - "key": "hooks.Stop" - } - ], - "dependencies": [], - "description": "Sends a desktop notification when Claude Code finishes responding (Stop event)", - "entrypoint": "notify-on-stop-hook.sh", - "files": [ - { - "checksum": "48deb177acce3d0882c28062246828f680c000baff87d9fe740f27106dbda7a8", - "sourcePath": "notify-on-stop-hook.sh" - } - ], - "installReason": "direct", - "name": "notify-on-stop-hook", - "type": "hook", - "version": "1.2.0" } ], "lockVersion": 1