From 770a78d1b61f828e79aa5a29ef5c2de6a06345a6 Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Thu, 21 May 2026 20:18:50 +0200 Subject: [PATCH 1/2] docs(docs): add cardshed scope to commit-format allow-list (#139) --- .claude/rules/commit-format.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/rules/commit-format.md b/.claude/rules/commit-format.md index 6d0077b..fbc9bb5 100644 --- a/.claude/rules/commit-format.md +++ b/.claude/rules/commit-format.md @@ -23,6 +23,7 @@ type(scope): description (#issue) | `mcp` | KNOWRAG `apps/mcp/` | | `ingest` | KNOWRAG ingestion pipeline (crawling/embedding/chunking) | | `reliquary` | Anything under `@lab/ll-RELIQUARY/` (browser-based collectable artifact game) | +| `cardshed` | Anything under `@lab/ll-CARDSHED/` (CARD SHED card game — pure-logic core + UI + bots) | | `repo` | Repo-wide hygiene (gitignore, scaffolding, untracked-path triage) | | `platform` | `.bin/`, `.shared/`, top-level CLI | | `stacks` | Anything under `@ops/`, `@dev/`, `@prod/` (any single zone-stack) | From 481aa73280785bcea6a6158c941e69726250879a Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Thu, 21 May 2026 20:19:16 +0200 Subject: [PATCH 2/2] feat(cardshed): bootstrap deterministic core rules engine (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executes PRPs/cardshed-02-core-prp.md. Adds @lab/ll-CARDSHED/apps/core/ — a pure, side-effect-free TypeScript module encoding CARD SHED v2.0. Engine surface (src/core/): - types.ts Shared Contract + domain entities + view projections - prng.ts mulberry32 + seeded Fisher-Yates (Math.random forbidden in core/) - rules.ts full Rules Engine API (createDeck, shuffleDeck, dealInitialHands, startNewRound, validateAttack, submitAttack, canBeat, submitBeat, stopDefending, drawToMinimum, checkWin, advanceTurn*, getLegalActions, createPublicView, createPrivateView, pendingAttackCardCount) - index.ts public re-exports Tests (78+ vitest tests across 14 files): - deck, shuffle, attack-validation, can-beat, submit-{attack,beat}, stop-defending, draw-to-minimum, check-win, turn-flow, views, legal-actions - conservation.property.test.ts: 4 fast-check properties × 200 iters each (conservation, hidden-info, player-ref integrity, termination) - integration: scripted 3- and 4-player rounds to RoundEnded - scripts/sim-smoke.ts: 1000 random-legal-bot games (0 violations, 100% terminate, <30s) Tooling: - tsconfig (ES2022, strict, bundler resolution, no DOM) - vitest 2.1, fast-check 3.23, tsx 4 - eslint 9 flat config with no-restricted-syntax bans for Math.random / Date.now / new Date / performance.now / crypto.getRandomValues in src/core/** Cross-PRP resolutions (PRPs/cardshed-02-core-prp.md updated): 1. beatenPairs live in pendingAttack; flushed to discard on BOTH stopDefending branches (not just FULL DEFENCE) 2. pendingAttackCardCount(pa) helper exported and used by invariants 3. checkWin runs after refill in BOTH stopDefending branches — defender can win on round-trailing turn; PRP 3 must not assume "winner = attacker" 4. deck[0] = bottom (trump face), deck.pop() = top (drawn next) 5. Card.id = "{letter}-{rank}-{slotHex}[-{saltB36}]"; startNewRound salts ids with round seed → deterministic but per-round disjoint id sets Validation gates: tsc 0 errors · eslint 0 errors · vitest 82/82 · sim-smoke 1000/1000 reaches RoundEnded with 0 conservation violations in 3.7s. PRPs/cardshed-02-core-prp.md force-added (PRPs/ is blanket-gitignored; mirrors the precedent set by cardshed-01-blueprint.md in #137). --- @lab/ll-CARDSHED/apps/core/.gitignore | 5 + @lab/ll-CARDSHED/apps/core/eslint.config.js | 50 + @lab/ll-CARDSHED/apps/core/package-lock.json | 3555 +++++++++++++++++ @lab/ll-CARDSHED/apps/core/package.json | 27 + .../apps/core/scripts/sim-smoke.ts | 55 + .../__snapshots__/shuffle.test.ts.snap | 3 + .../apps/core/src/core/__tests__/_bot.ts | 78 + .../apps/core/src/core/__tests__/_helpers.ts | 89 + .../core/__tests__/attack-validation.test.ts | 81 + .../core/src/core/__tests__/can-beat.test.ts | 43 + .../core/src/core/__tests__/check-win.test.ts | 21 + .../__tests__/conservation.property.test.ts | 89 + .../apps/core/src/core/__tests__/deck.test.ts | 83 + .../core/__tests__/draw-to-minimum.test.ts | 40 + .../src/core/__tests__/integration.test.ts | 47 + .../src/core/__tests__/legal-actions.test.ts | 78 + .../core/src/core/__tests__/shuffle.test.ts | 59 + .../src/core/__tests__/stop-defending.test.ts | 130 + .../src/core/__tests__/submit-attack.test.ts | 70 + .../src/core/__tests__/submit-beat.test.ts | 80 + .../core/src/core/__tests__/turn-flow.test.ts | 72 + .../core/src/core/__tests__/views.test.ts | 50 + @lab/ll-CARDSHED/apps/core/src/core/index.ts | 23 + @lab/ll-CARDSHED/apps/core/src/core/prng.ts | 25 + @lab/ll-CARDSHED/apps/core/src/core/rules.ts | 672 ++++ @lab/ll-CARDSHED/apps/core/src/core/types.ts | 150 + @lab/ll-CARDSHED/apps/core/tsconfig.json | 20 + @lab/ll-CARDSHED/apps/core/vitest.config.ts | 9 + PRPs/cardshed-02-core-prp.md | 1066 +++++ 29 files changed, 6770 insertions(+) create mode 100644 @lab/ll-CARDSHED/apps/core/.gitignore create mode 100644 @lab/ll-CARDSHED/apps/core/eslint.config.js create mode 100644 @lab/ll-CARDSHED/apps/core/package-lock.json create mode 100644 @lab/ll-CARDSHED/apps/core/package.json create mode 100644 @lab/ll-CARDSHED/apps/core/scripts/sim-smoke.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/__snapshots__/shuffle.test.ts.snap create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/_bot.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/_helpers.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/attack-validation.test.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/can-beat.test.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/check-win.test.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/conservation.property.test.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/deck.test.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/draw-to-minimum.test.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/integration.test.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/legal-actions.test.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/shuffle.test.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/stop-defending.test.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/submit-attack.test.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/submit-beat.test.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/turn-flow.test.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/__tests__/views.test.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/index.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/prng.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/rules.ts create mode 100644 @lab/ll-CARDSHED/apps/core/src/core/types.ts create mode 100644 @lab/ll-CARDSHED/apps/core/tsconfig.json create mode 100644 @lab/ll-CARDSHED/apps/core/vitest.config.ts create mode 100644 PRPs/cardshed-02-core-prp.md diff --git a/@lab/ll-CARDSHED/apps/core/.gitignore b/@lab/ll-CARDSHED/apps/core/.gitignore new file mode 100644 index 0000000..26e69ca --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.DS_Store +coverage/ diff --git a/@lab/ll-CARDSHED/apps/core/eslint.config.js b/@lab/ll-CARDSHED/apps/core/eslint.config.js new file mode 100644 index 0000000..ca45c0e --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/eslint.config.js @@ -0,0 +1,50 @@ +import tseslint from "typescript-eslint"; + +const determinismBans = [ + { + selector: "MemberExpression[object.name='Math'][property.name='random']", + message: "Math.random is forbidden in core/**. Use mulberry32(seed) via prng.ts.", + }, + { + selector: "MemberExpression[object.name='Date'][property.name='now']", + message: "Date.now is forbidden in core/**. The engine has no concept of time.", + }, + { + selector: "NewExpression[callee.name='Date']", + message: "new Date() is forbidden in core/**. The engine has no concept of time.", + }, + { + selector: "MemberExpression[object.name='performance'][property.name='now']", + message: "performance.now is forbidden in core/**.", + }, + { + selector: + "MemberExpression[object.name='crypto'][property.name='getRandomValues']", + message: "crypto.getRandomValues is forbidden in core/**.", + }, +]; + +export default tseslint.config( + { + ignores: ["node_modules", "dist", "scripts/**", "**/__tests__/**"], + }, + ...tseslint.configs.recommended, + { + files: ["src/core/**/*.ts"], + languageOptions: { + parserOptions: { + project: false, + ecmaVersion: 2022, + sourceType: "module", + }, + }, + rules: { + "no-restricted-syntax": ["error", ...determinismBans], + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "@typescript-eslint/no-explicit-any": "warn", + }, + }, +); diff --git a/@lab/ll-CARDSHED/apps/core/package-lock.json b/@lab/ll-CARDSHED/apps/core/package-lock.json new file mode 100644 index 0000000..d89a1f8 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/package-lock.json @@ -0,0 +1,3555 @@ +{ + "name": "@cardshed/core", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@cardshed/core", + "version": "0.0.0", + "devDependencies": { + "@types/node": "^25.9.1", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@typescript-eslint/parser": "^8.18.0", + "eslint": "^9.17.0", + "fast-check": "^3.23.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "typescript-eslint": "^8.18.0", + "vitest": "^2.1.8" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", + "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/type-utils": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.4", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", + "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", + "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.4", + "@typescript-eslint/types": "^8.59.4", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", + "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", + "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", + "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", + "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.4", + "@typescript-eslint/tsconfig-utils": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", + "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", + "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", + "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.4", + "@typescript-eslint/parser": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/@lab/ll-CARDSHED/apps/core/package.json b/@lab/ll-CARDSHED/apps/core/package.json new file mode 100644 index 0000000..cd15482 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/package.json @@ -0,0 +1,27 @@ +{ + "name": "@cardshed/core", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "CARD SHED deterministic core rules engine (pure TypeScript)", + "main": "src/core/index.ts", + "types": "src/core/index.ts", + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint src --max-warnings 0", + "test": "vitest run", + "test:watch": "vitest", + "sim-smoke": "tsx scripts/sim-smoke.ts" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@typescript-eslint/parser": "^8.18.0", + "eslint": "^9.17.0", + "fast-check": "^3.23.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "typescript-eslint": "^8.18.0", + "vitest": "^2.1.8" + } +} diff --git a/@lab/ll-CARDSHED/apps/core/scripts/sim-smoke.ts b/@lab/ll-CARDSHED/apps/core/scripts/sim-smoke.ts new file mode 100644 index 0000000..14bb131 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/scripts/sim-smoke.ts @@ -0,0 +1,55 @@ +// 1000 random-legal-bot games. Asserts 0 conservation violations and +// 100% reach RoundEnded. Exits non-zero on failure. +import { runRandomLegalRound } from "../src/core/__tests__/_bot.js"; +import { allCardIds, totalCards } from "../src/core/__tests__/_helpers.js"; + +const N = 1000; +const startMs = Number(process.hrtime.bigint() / 1_000_000n); + +let conservationViolations = 0; +let dupViolations = 0; +let nonTerminations = 0; +const turnsHistogram: number[] = []; + +for (let i = 0; i < N; i++) { + const playerCount: 3 | 4 = i % 2 === 0 ? 3 : 4; + const run = runRandomLegalRound(i * 17 + 1, playerCount, i * 31 + 13); + if (run.endedAt !== "RoundEnded") nonTerminations++; + turnsHistogram.push(run.turnCount); + for (const s of run.states) { + if (totalCards(s) !== 52) conservationViolations++; + const ids = allCardIds(s); + if (new Set(ids).size !== 52 || ids.length !== 52) dupViolations++; + } +} + +const endMs = Number(process.hrtime.bigint() / 1_000_000n); +const elapsedMs = endMs - startMs; + +turnsHistogram.sort((a, b) => a - b); +const mean = turnsHistogram.reduce((s, n) => s + n, 0) / turnsHistogram.length; +const p50 = turnsHistogram[Math.floor(turnsHistogram.length * 0.5)] ?? 0; +const p95 = turnsHistogram[Math.floor(turnsHistogram.length * 0.95)] ?? 0; + +const reachedPct = ((N - nonTerminations) / N) * 100; +const ok = conservationViolations === 0 && dupViolations === 0 && nonTerminations === 0; + +console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); +console.log(` CARDSHED core — simulation smoke (${N} games)`); +console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); +console.log(`📋 Results`); +console.log(` ${conservationViolations === 0 ? "✅" : "❌"} Conservation violations: ${conservationViolations}`); +console.log(` ${dupViolations === 0 ? "✅" : "❌"} Card-duplication violations: ${dupViolations}`); +console.log(` ${nonTerminations === 0 ? "✅" : "❌"} Reached RoundEnded: ${reachedPct.toFixed(1)}% (${N - nonTerminations}/${N})`); +console.log(`📋 Performance`); +console.log(` Elapsed: ${elapsedMs} ms`); +console.log(` Turns per round — mean ${mean.toFixed(1)} · p50 ${p50} · p95 ${p95}`); +console.log(`────────────────────────────────────────────`); +if (ok) { + console.log(` ✅ Result: PASS`); +} else { + console.log(` ❌ Result: FAIL`); +} +console.log(`────────────────────────────────────────────`); + +process.exit(ok ? 0 : 1); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/__snapshots__/shuffle.test.ts.snap b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/__snapshots__/shuffle.test.ts.snap new file mode 100644 index 0000000..acb2e50 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/__snapshots__/shuffle.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`shuffleDeck > byte-identical fixture for shuffleDeck(createDeck(), 42) 1`] = `"S-4-29,S-10-2f,D-3-0e,D-14-19,S-14-33,H-10-22,C-8-06,S-12-31,H-11-23,S-6-2b,D-12-17,C-4-02,D-2-0d,D-5-10,S-3-28,S-13-32,H-3-1b,C-7-05,S-11-30,C-11-09,S-2-27,C-5-03,S-7-2c,S-8-2d,D-8-13,C-6-04,H-6-1e,H-13-25,H-9-21,D-6-11,C-3-01,H-2-1a,D-4-0f,C-2-00,D-10-15,S-9-2e,D-7-12,C-9-07,C-13-0b,H-5-1d,H-12-24,C-12-0a,D-9-14,H-14-26,H-4-1c,C-14-0c,D-13-18,C-10-08,H-8-20,S-5-2a,D-11-16,H-7-1f"`; diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/_bot.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/_bot.ts new file mode 100644 index 0000000..320f206 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/_bot.ts @@ -0,0 +1,78 @@ +import { + getLegalActions, + startNewRound, + submitAttack, + submitBeat, + stopDefending, +} from "../rules.js"; +import { mulberry32 } from "../prng.js"; +import type { Action, MatchState } from "../types.js"; + +export interface BotRunResult { + states: MatchState[]; + endedAt: "RoundEnded" | "MatchEnded" | "TurnCap"; + turnCount: number; +} + +// Drive a single round to completion using a seeded random-legal bot. +// Caps turns at `maxTurns` to detect infinite loops. +export function runRandomLegalRound( + initialSeed: number, + playerCount: 3 | 4, + rngSeed: number, + maxTurns = 500, +): BotRunResult { + let state = startNewRound(null, initialSeed, { + matchId: `bot-${initialSeed}-${rngSeed}`, + players: Array.from({ length: playerCount }, (_, i) => ({ + id: `p${i + 1}`, + name: `P${i + 1}`, + })), + }); + const states: MatchState[] = [state]; + const rng = mulberry32(rngSeed); + + for (let turn = 0; turn < maxTurns; turn++) { + if (state.round.phase === "RoundEnded" || state.round.phase === "MatchEnded") { + return { states, endedAt: state.round.phase, turnCount: turn }; + } + + // Determine whose turn it is. + const actorId = + state.round.phase === "AwaitingAttack" + ? state.round.attackerId + : state.round.defenderId!; + const actions = getLegalActions(state, actorId); + if (actions.length === 0) { + throw new Error( + `INVARIANT: no legal actions for ${actorId} in phase ${state.round.phase}`, + ); + } + const pick = actions[Math.floor(rng() * actions.length)]!; + state = apply(state, pick); + states.push(state); + } + + return { states, endedAt: "TurnCap", turnCount: maxTurns }; +} + +function apply(state: MatchState, a: Action): MatchState { + let r; + switch (a.kind) { + case "Attack": + r = submitAttack(state, a.playerId, a.cardIds); + break; + case "Beat": + r = submitBeat(state, a.playerId, a.attackCardId, a.counterCardId); + break; + case "Stop": + r = stopDefending(state, a.playerId); + break; + } + if (!r.ok) { + throw new Error( + `INVARIANT: bot picked an illegal action: ${a.kind} → ${r.error.code} (${r.error.message})`, + ); + } + return r.state; +} diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/_helpers.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/_helpers.ts new file mode 100644 index 0000000..c786a4d --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/_helpers.ts @@ -0,0 +1,89 @@ +import type { Card, MatchState, PlayerSetup, Rank, Suit } from "../types.js"; +import { startNewRound } from "../rules.js"; + +export function setupMatch(opts: { + playerCount: 3 | 4; + seed: number; + matchId?: string; +}): MatchState { + const players: PlayerSetup[] = Array.from( + { length: opts.playerCount }, + (_, i) => ({ id: `p${i + 1}`, name: `Player ${i + 1}` }), + ); + return startNewRound(null, opts.seed, { + matchId: opts.matchId ?? "m1", + players, + }); +} + +export function c(suit: Suit, rank: Rank, tag = ""): Card { + return { id: `${suit}-${rank}-${tag || "x"}`, suit, rank }; +} + +// Forge a tiny custom state for surgical tests. Caller is responsible for +// supplying a 52-card union when the conservation invariant matters. +export function buildState(opts: { + trump: Suit; + hands: Card[][]; + deck?: Card[]; + discard?: Card[]; + phase?: MatchState["round"]["phase"]; + attackerSeat?: number; +}): MatchState { + const playerCount = opts.hands.length; + const players = opts.hands.map((hand, idx) => ({ + id: `p${idx + 1}`, + name: `Player ${idx + 1}`, + hand: hand.slice(), + score: 0, + isActive: true, + seatIndex: idx, + })); + const attackerSeat = opts.attackerSeat ?? 0; + const attacker = players[attackerSeat]!; + const dealerSeat = (attackerSeat - 1 + playerCount) % playerCount; + return { + matchId: "test", + roundNumber: 1, + players, + deck: opts.deck ?? [], + discard: opts.discard ?? [], + round: { + trump: opts.trump, + dealerId: players[dealerSeat]!.id, + attackerId: attacker.id, + defenderId: null, + phase: opts.phase ?? "AwaitingAttack", + pendingAttack: null, + }, + winner: null, + rngSeed: 0, + }; +} + +// Total cards everywhere — for conservation checks. +export function totalCards(state: MatchState): number { + const inHands = state.players.reduce((n, p) => n + p.hand.length, 0); + const inDeck = state.deck.length; + const inDiscard = state.discard.length; + const pa = state.round.pendingAttack; + const inPending = pa + ? pa.unbeatenCards.length + 2 * pa.beatenPairs.length + : 0; + return inHands + inDeck + inDiscard + inPending; +} + +export function allCardIds(state: MatchState): string[] { + const ids: string[] = []; + for (const p of state.players) for (const c of p.hand) ids.push(c.id); + for (const c of state.deck) ids.push(c.id); + for (const c of state.discard) ids.push(c.id); + const pa = state.round.pendingAttack; + if (pa) { + for (const c of pa.unbeatenCards) ids.push(c.id); + for (const bp of pa.beatenPairs) { + ids.push(bp.attack.id, bp.counter.id); + } + } + return ids; +} diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/attack-validation.test.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/attack-validation.test.ts new file mode 100644 index 0000000..491ca66 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/attack-validation.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { validateAttack } from "../rules.js"; +import type { Card, Rank, Suit } from "../types.js"; + +function c(suit: Suit, rank: Rank, tag = "x"): Card { + return { id: `${suit}-${rank}-${tag}`, suit, rank }; +} + +describe("validateAttack", () => { + const hand: Card[] = [ + c(0, 7, "a"), + c(1, 7, "b"), + c(2, 7, "c"), + c(3, 7, "d"), + c(0, 9, "e"), + c(1, 9, "f"), + c(2, 10, "g"), + c(0, 5, "h"), + ]; + + it("1-card attack is valid", () => { + expect(validateAttack([hand[0]!], hand).ok).toBe(true); + }); + + it("3-card pair + kicker is valid", () => { + expect(validateAttack([hand[0]!, hand[1]!, hand[6]!], hand).ok).toBe(true); + }); + + it("5-card two pairs of distinct ranks + kicker is valid", () => { + // 7♣ 7♦ + 9♣ 9♦ + 10♥ + expect( + validateAttack([hand[0]!, hand[1]!, hand[4]!, hand[5]!, hand[6]!], hand).ok, + ).toBe(true); + }); + + it("rejects 0/2/4/6 cards", () => { + for (const size of [0, 2, 4, 6]) { + const subset = hand.slice(0, size); + const r = validateAttack(subset, hand); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("ATTACK_INVALID_SIZE"); + } + }); + + it("3-card with no pair", () => { + const r = validateAttack([hand[0]!, hand[6]!, hand[7]!], hand); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("ATTACK_NO_PAIR"); + }); + + it("5-card with only one pair (trips + 2 unrelated)", () => { + // three 7s (one pair-rank) + 10 + 5 — only one distinct pair-rank + const r = validateAttack( + [hand[0]!, hand[1]!, hand[2]!, hand[6]!, hand[7]!], + hand, + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("ATTACK_NO_TWO_PAIRS"); + }); + + it("5-card with quad (4 same rank) + kicker — only ONE distinct pair-rank", () => { + const r = validateAttack( + [hand[0]!, hand[1]!, hand[2]!, hand[3]!, hand[6]!], + hand, + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("ATTACK_NO_TWO_PAIRS"); + }); + + it("cards not in hand → ATTACK_CARD_NOT_OWNED", () => { + const r = validateAttack([c(0, 2, "ghost")], hand); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("ATTACK_CARD_NOT_OWNED"); + }); + + it("duplicate card ids → ATTACK_DUPLICATE_CARD", () => { + const r = validateAttack([hand[0]!, hand[0]!, hand[1]!], hand); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("ATTACK_DUPLICATE_CARD"); + }); +}); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/can-beat.test.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/can-beat.test.ts new file mode 100644 index 0000000..24a3f17 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/can-beat.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { canBeat } from "../rules.js"; +import type { Card, Rank, Suit } from "../types.js"; + +function c(suit: Suit, rank: Rank): Card { + return { id: `${suit}-${rank}`, suit, rank }; +} + +const TRUMP: Suit = 3; // Spades + +describe("canBeat", () => { + it("same suit, higher rank, non-trump attack → true", () => { + expect(canBeat(c(1, 7), c(1, 9), TRUMP)).toBe(true); + }); + + it("same suit, lower rank, non-trump attack → false", () => { + expect(canBeat(c(1, 9), c(1, 7), TRUMP)).toBe(false); + }); + + it("same suit, equal rank → false", () => { + expect(canBeat(c(1, 7), c(1, 7), TRUMP)).toBe(false); + }); + + it("different non-trump suits (counter non-trump) → false", () => { + expect(canBeat(c(1, 9), c(2, 14), TRUMP)).toBe(false); + }); + + it("trump beats non-trump attack → true (any trump rank)", () => { + expect(canBeat(c(1, 14), c(TRUMP, 2), TRUMP)).toBe(true); + }); + + it("higher trump beats trump attack → true", () => { + expect(canBeat(c(TRUMP, 7), c(TRUMP, 9), TRUMP)).toBe(true); + }); + + it("lower trump cannot beat trump attack → false", () => { + expect(canBeat(c(TRUMP, 9), c(TRUMP, 7), TRUMP)).toBe(false); + }); + + it("non-trump different suit vs trump attack → false", () => { + expect(canBeat(c(TRUMP, 9), c(1, 14), TRUMP)).toBe(false); + }); +}); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/check-win.test.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/check-win.test.ts new file mode 100644 index 0000000..9c9d3f7 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/check-win.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { checkWin } from "../rules.js"; +import { buildState, c } from "./_helpers.js"; + +describe("checkWin", () => { + it("returns true only when deck is empty AND hand is empty", () => { + const s = buildState({ trump: 3, hands: [[], [c(0, 2, "a")], [c(0, 3, "b")]], deck: [] }); + expect(checkWin(s, "p1")).toBe(true); + expect(checkWin(s, "p2")).toBe(false); + }); + + it("returns false when deck non-empty even if hand empty", () => { + const s = buildState({ trump: 3, hands: [[], [c(0, 3, "b")]], deck: [c(0, 2, "t")] }); + expect(checkWin(s, "p1")).toBe(false); + }); + + it("returns false when hand non-empty even if deck empty", () => { + const s = buildState({ trump: 3, hands: [[c(0, 3, "h")], []], deck: [] }); + expect(checkWin(s, "p1")).toBe(false); + }); +}); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/conservation.property.test.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/conservation.property.test.ts new file mode 100644 index 0000000..0b20ae6 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/conservation.property.test.ts @@ -0,0 +1,89 @@ +import { describe, it } from "vitest"; +import fc from "fast-check"; +import { runRandomLegalRound } from "./_bot.js"; +import { allCardIds, totalCards } from "./_helpers.js"; +import { createPublicView } from "../rules.js"; + +describe("conservation property", () => { + it("for any sequence of legal actions, card conservation holds (>=200 iters)", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 1_000_000 }), + fc.integer({ min: 1, max: 1_000_000 }), + fc.constantFrom<3 | 4>(3, 4), + (deckSeed, botSeed, playerCount) => { + const run = runRandomLegalRound(deckSeed, playerCount, botSeed); + for (const s of run.states) { + if (totalCards(s) !== 52) return false; + const ids = allCardIds(s); + const unique = new Set(ids); + if (ids.length !== 52) return false; + if (unique.size !== 52) return false; + } + return true; + }, + ), + { numRuns: 200 }, + ); + }); + + it("hidden hands never appear in createPublicView (>=200 iters)", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 1_000_000 }), + fc.integer({ min: 1, max: 1_000_000 }), + fc.constantFrom<3 | 4>(3, 4), + (deckSeed, botSeed, playerCount) => { + const run = runRandomLegalRound(deckSeed, playerCount, botSeed); + // Spot-check final state and one mid state + const samples = [run.states[0]!, run.states[run.states.length - 1]!]; + for (const s of samples) { + const view = createPublicView(s); + for (const p of view.players) { + if (p.hand.publiclyKnown.length !== 0) return false; + // The shape check: no `cards` key with full hand + if ((p.hand as unknown as { cards?: unknown }).cards) return false; + } + } + return true; + }, + ), + { numRuns: 200 }, + ); + }); + + it("attackerId and defenderId always reference a real player (>=200 iters)", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 1_000_000 }), + fc.integer({ min: 1, max: 1_000_000 }), + fc.constantFrom<3 | 4>(3, 4), + (deckSeed, botSeed, playerCount) => { + const run = runRandomLegalRound(deckSeed, playerCount, botSeed); + for (const s of run.states) { + const ids = new Set(s.players.map((p) => p.id)); + if (!ids.has(s.round.attackerId)) return false; + if (s.round.defenderId !== null && !ids.has(s.round.defenderId)) return false; + } + return true; + }, + ), + { numRuns: 200 }, + ); + }); + + it("under random legal actions, every round terminates within 500 turns (>=200 iters)", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 1_000_000 }), + fc.integer({ min: 1, max: 1_000_000 }), + fc.constantFrom<3 | 4>(3, 4), + (deckSeed, botSeed, playerCount) => { + const run = runRandomLegalRound(deckSeed, playerCount, botSeed); + return run.endedAt === "RoundEnded"; + }, + ), + { numRuns: 200 }, + ); + }); +}); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/deck.test.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/deck.test.ts new file mode 100644 index 0000000..484217c --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/deck.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { createDeck, startNewRound } from "../rules.js"; + +describe("createDeck", () => { + it("returns 52 unique cards", () => { + const deck = createDeck(); + expect(deck).toHaveLength(52); + const ids = new Set(deck.map((c) => c.id)); + expect(ids.size).toBe(52); + }); + + it("has 4 suits × 13 ranks", () => { + const deck = createDeck(); + const bySuit = new Map(); + for (const c of deck) bySuit.set(c.suit, (bySuit.get(c.suit) ?? 0) + 1); + expect(bySuit.get(0)).toBe(13); + expect(bySuit.get(1)).toBe(13); + expect(bySuit.get(2)).toBe(13); + expect(bySuit.get(3)).toBe(13); + }); + + it("contains every rank 2..14 in each suit", () => { + const deck = createDeck(); + for (let s = 0; s < 4; s++) { + for (let r = 2; r <= 14; r++) { + const has = deck.find((c) => c.suit === s && c.rank === r); + expect(has).toBeDefined(); + } + } + }); + + it("ids are unsalted by default — stable format {letter}-{rank}-{slotHex}", () => { + const ids = createDeck().map((c) => c.id); + expect(ids[0]).toBe("C-2-00"); + expect(ids[51]).toBe("S-14-33"); + // No suffix beyond the slot hex + for (const id of ids) expect(id.split("-")).toHaveLength(3); + }); + + it("createDeck(salt) appends a base36 salt suffix", () => { + const salt = 42; + const ids = createDeck(salt).map((c) => c.id); + const expectedSuffix = salt.toString(36); // "1c" + expect(ids[0]).toBe(`C-2-00-${expectedSuffix}`); + expect(ids[51]).toBe(`S-14-33-${expectedSuffix}`); + for (const id of ids) expect(id.split("-")).toHaveLength(4); + }); + + it("different salts produce disjoint id sets", () => { + const a = new Set(createDeck(42).map((c) => c.id)); + const b = new Set(createDeck(43).map((c) => c.id)); + let overlap = 0; + for (const id of a) if (b.has(id)) overlap++; + expect(overlap).toBe(0); + }); + + it("startNewRound produces salted ids that uniquely tag the round", () => { + // Verified by reading any card from the produced deck/hand state. + // Round 1 seed 100, round 2 seed 200 → distinct salts → no id overlap. + const r1 = startNewRound(null, 100, { + matchId: "m1", + players: [ + { id: "p1", name: "A" }, + { id: "p2", name: "B" }, + { id: "p3", name: "C" }, + ], + }); + const r2 = startNewRound(r1, 200); + const r1Ids = new Set([ + ...r1.players.flatMap((p) => p.hand.map((c) => c.id)), + ...r1.deck.map((c) => c.id), + ]); + const r2Ids = new Set([ + ...r2.players.flatMap((p) => p.hand.map((c) => c.id)), + ...r2.deck.map((c) => c.id), + ]); + expect(r1Ids.size).toBe(52); + expect(r2Ids.size).toBe(52); + let overlap = 0; + for (const id of r1Ids) if (r2Ids.has(id)) overlap++; + expect(overlap).toBe(0); + }); +}); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/draw-to-minimum.test.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/draw-to-minimum.test.ts new file mode 100644 index 0000000..6b9560c --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/draw-to-minimum.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { drawToMinimum } from "../rules.js"; +import { c } from "./_helpers.js"; + +describe("drawToMinimum", () => { + it("draws from top (end) of deck until hand reaches target", () => { + const hand = [c(0, 5, "a"), c(0, 6, "b")]; + const deck = [c(1, 2, "t1"), c(1, 3, "t2"), c(1, 4, "t3"), c(1, 5, "t4"), c(1, 6, "t5")]; + const r = drawToMinimum(hand, deck, 5); + expect(r.hand).toHaveLength(5); + // Drawn from top — last 3 of deck consumed. + expect(r.deck).toHaveLength(2); + }); + + it("stops when deck empty even if hand below target", () => { + const hand = [c(0, 5, "a")]; + const deck = [c(1, 2, "t1"), c(1, 3, "t2")]; + const r = drawToMinimum(hand, deck, 5); + expect(r.hand).toHaveLength(3); + expect(r.deck).toHaveLength(0); + }); + + it("does not draw if hand already at target", () => { + const hand = [c(0, 5, "a"), c(0, 6, "b"), c(0, 7, "c"), c(0, 8, "d"), c(0, 9, "e")]; + const deck = [c(1, 2, "t")]; + const r = drawToMinimum(hand, deck, 5); + expect(r.hand).toHaveLength(5); + expect(r.deck).toHaveLength(1); + }); + + it("does not mutate input arrays", () => { + const hand = [c(0, 5, "a")]; + const deck = [c(1, 2, "t1"), c(1, 3, "t2")]; + const handBefore = hand.length; + const deckBefore = deck.length; + drawToMinimum(hand, deck, 5); + expect(hand).toHaveLength(handBefore); + expect(deck).toHaveLength(deckBefore); + }); +}); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/integration.test.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/integration.test.ts new file mode 100644 index 0000000..81af05c --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/integration.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { runRandomLegalRound } from "./_bot.js"; +import { createPrivateView } from "../rules.js"; +import { setupMatch, totalCards } from "./_helpers.js"; + +describe("integration", () => { + it("a scripted 3-player round runs to RoundEnded", () => { + const run = runRandomLegalRound(1234, 3, 5678); + expect(run.endedAt).toBe("RoundEnded"); + const final = run.states[run.states.length - 1]!; + expect(final.winner).not.toBeNull(); + expect(totalCards(final)).toBe(52); + }); + + it("a scripted 4-player round runs to RoundEnded", () => { + const run = runRandomLegalRound(4321, 4, 9999); + expect(run.endedAt).toBe("RoundEnded"); + const final = run.states[run.states.length - 1]!; + expect(final.winner).not.toBeNull(); + expect(totalCards(final)).toBe(52); + }); + + it("createPrivateView has enough info to resume — viewerHand + public state", () => { + const s = setupMatch({ playerCount: 3, seed: 1001 }); + const viewer = s.players[0]!; + const view = createPrivateView(s, viewer.id); + expect(view.viewerId).toBe(viewer.id); + expect(view.viewerHand.length).toBe(viewer.hand.length); + expect(view.deckCount).toBe(s.deck.length); + expect(view.round.attackerId).toBe(s.round.attackerId); + expect(view.round.trump).toBe(s.round.trump); + }); + + it("conservation holds across every state in a full 3-player run", () => { + const run = runRandomLegalRound(7777, 3, 8888); + for (const s of run.states) { + expect(totalCards(s)).toBe(52); + } + }); + + it("conservation holds across every state in a full 4-player run", () => { + const run = runRandomLegalRound(2222, 4, 3333); + for (const s of run.states) { + expect(totalCards(s)).toBe(52); + } + }); +}); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/legal-actions.test.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/legal-actions.test.ts new file mode 100644 index 0000000..89e6cab --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/legal-actions.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { + canBeat, + getLegalActions, + submitAttack, + validateAttack, +} from "../rules.js"; +import { buildState, c, setupMatch } from "./_helpers.js"; + +describe("getLegalActions", () => { + it("returns Stop whenever defender is awaiting", () => { + const s = setupMatch({ playerCount: 3, seed: 71 }); + const attacker = s.players.find((p) => p.id === s.round.attackerId)!; + const a = submitAttack(s, attacker.id, [attacker.hand[0]!.id]); + if (!a.ok) throw new Error(); + const actions = getLegalActions(a.state, a.state.round.defenderId!); + const stops = actions.filter((act) => act.kind === "Stop"); + expect(stops).toHaveLength(1); + }); + + it("every returned Attack passes validateAttack against the attacker's hand", () => { + const s = setupMatch({ playerCount: 3, seed: 72 }); + const attacker = s.players.find((p) => p.id === s.round.attackerId)!; + const actions = getLegalActions(s, attacker.id); + for (const action of actions) { + if (action.kind !== "Attack") continue; + const cards = action.cardIds.map((id) => attacker.hand.find((c) => c.id === id)!); + const v = validateAttack(cards, attacker.hand); + expect(v.ok).toBe(true); + } + }); + + it("every returned Beat satisfies canBeat(attack, counter, trump)", () => { + const s = setupMatch({ playerCount: 3, seed: 73 }); + const attacker = s.players.find((p) => p.id === s.round.attackerId)!; + const a = submitAttack(s, attacker.id, [attacker.hand[0]!.id]); + if (!a.ok) throw new Error(); + const defenderId = a.state.round.defenderId!; + const defender = a.state.players.find((p) => p.id === defenderId)!; + const actions = getLegalActions(a.state, defenderId); + const pa = a.state.round.pendingAttack!; + const trump = a.state.round.trump; + for (const action of actions) { + if (action.kind !== "Beat") continue; + const attack = pa.unbeatenCards.find((c) => c.id === action.attackCardId)!; + const counter = defender.hand.find((c) => c.id === action.counterCardId)!; + expect(canBeat(attack, counter, trump)).toBe(true); + } + }); + + it("wrong-phase / wrong-player → empty list", () => { + const s = buildState({ trump: 3, hands: [[c(0, 2, "x")], [c(0, 3, "y")]] }); + // It's p1's turn to attack; p2 asking for legal actions → empty + expect(getLegalActions(s, "p2")).toEqual([]); + }); + + it("emits singleton + pair-based 3-card attacks where applicable", () => { + const hand = [ + c(0, 7, "a"), + c(1, 7, "b"), + c(2, 10, "k"), + ]; + const s = buildState({ trump: 3, hands: [hand, []] }); + const actions = getLegalActions(s, "p1"); + const attackActions = actions.filter((a) => a.kind === "Attack"); + expect(attackActions.length).toBeGreaterThan(0); + // Includes the (7♣, 7♦, 10♥) pair+kicker + const found = attackActions.find( + (a) => + a.kind === "Attack" && + a.cardIds.length === 3 && + a.cardIds.includes(hand[0]!.id) && + a.cardIds.includes(hand[1]!.id) && + a.cardIds.includes(hand[2]!.id), + ); + expect(found).toBeDefined(); + }); +}); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/shuffle.test.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/shuffle.test.ts new file mode 100644 index 0000000..ff29b2b --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/shuffle.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { createDeck, shuffleDeck, determineTrumpFromBottomCard } from "../rules.js"; + +describe("shuffleDeck", () => { + it("is reproducible for the same seed", () => { + const a = shuffleDeck(createDeck(), 42); + const b = shuffleDeck(createDeck(), 42); + expect(a.map((c) => c.id)).toEqual(b.map((c) => c.id)); + }); + + it("differs across different seeds", () => { + const a = shuffleDeck(createDeck(), 42); + const b = shuffleDeck(createDeck(), 43); + expect(a.map((c) => c.id)).not.toEqual(b.map((c) => c.id)); + }); + + it("preserves the multiset of 52 cards", () => { + const d = createDeck(); + const s = shuffleDeck(d, 42); + expect(s).toHaveLength(52); + const dIds = new Set(d.map((c) => c.id)); + const sIds = new Set(s.map((c) => c.id)); + expect(dIds).toEqual(sIds); + }); + + it("does not mutate input", () => { + const d = createDeck(); + const before = d.map((c) => c.id).join(","); + shuffleDeck(d, 42); + const after = d.map((c) => c.id).join(","); + expect(after).toBe(before); + }); + + it("freezes input safe — Object.freeze does not break shuffle", () => { + const d = Object.freeze(createDeck().slice()); + const s = shuffleDeck(d as unknown as ReturnType, 7); + expect(s).toHaveLength(52); + }); + + it("bottom card determines trump", () => { + const s = shuffleDeck(createDeck(), 99); + const trump = determineTrumpFromBottomCard(s); + expect(trump).toBe(s[0]!.suit); + }); + + it("bottom trump card stays in the deck (drawable)", () => { + const s = shuffleDeck(createDeck(), 99); + // bottom present at index 0 + expect(s).toHaveLength(52); + expect(s[0]).toBeDefined(); + }); + + it("byte-identical fixture for shuffleDeck(createDeck(), 42)", () => { + const s = shuffleDeck(createDeck(), 42); + // Cross-runtime determinism check: record once, then pin. + const fingerprint = s.map((c) => c.id).join(","); + expect(fingerprint).toMatchSnapshot(); + }); +}); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/stop-defending.test.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/stop-defending.test.ts new file mode 100644 index 0000000..a194916 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/stop-defending.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { submitAttack, submitBeat, stopDefending } from "../rules.js"; +import { buildState, c, totalCards } from "./_helpers.js"; + +describe("stopDefending", () => { + it("FULL DEFENCE: defender becomes attacker, pairs go to discard", () => { + // Deck large enough for both attacker refill AND defender refill so neither + // triggers a deck-empty win condition. + const s = buildState({ + trump: 3, + hands: [[c(1, 7, "atk")], [c(1, 9, "d1")], []], + deck: [ + c(0, 2, "t1"), + c(0, 3, "t2"), + c(0, 4, "t3"), + c(0, 5, "t4"), + c(0, 6, "t5"), + c(0, 7, "t6"), + c(0, 8, "t7"), + c(0, 10, "t8"), + c(0, 11, "t9"), + c(0, 12, "t10"), + ], + }); + const a = submitAttack(s, "p1", [c(1, 7, "atk").id]); + if (!a.ok) throw new Error(); + const b = submitBeat(a.state, "p2", c(1, 7, "atk").id, c(1, 9, "d1").id); + if (!b.ok) throw new Error(); + const r = stopDefending(b.state, "p2"); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.state.round.attackerId).toBe("p2"); + expect(r.state.round.defenderId).toBeNull(); + expect(r.state.round.phase).toBe("AwaitingAttack"); + expect(r.state.round.pendingAttack).toBeNull(); + // Beaten pair flushed to discard + expect(r.state.discard).toHaveLength(2); + expect(totalCards(r.state)).toBe( + totalCards(s) /* same set, no draws beyond setup */, + ); + }); + + it("PARTIAL DEFENCE: defender takes unbeaten cards, next attacker is leftOf(defender)", () => { + const s = buildState({ + trump: 3, + hands: [ + [c(1, 7, "atk"), c(2, 7, "atk2"), c(0, 5, "kick")], + [c(0, 14, "useless")], + [], + ], + // Deck big enough for both attacker refill (to 5 = 5 draws) and defender refill. + deck: [ + c(3, 2, "t1"), + c(3, 3, "t2"), + c(3, 4, "t3"), + c(3, 5, "t4"), + c(3, 6, "t5"), + c(3, 7, "t6"), + c(3, 8, "t7"), + ], + }); + const a = submitAttack(s, "p1", [ + c(1, 7, "atk").id, + c(2, 7, "atk2").id, + c(0, 5, "kick").id, + ]); + if (!a.ok) throw new Error(); + // Defender beats none, just stops + const r = stopDefending(a.state, "p2"); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.state.round.attackerId).toBe("p3"); // leftOf(defender p2) = p3 + expect(r.state.round.phase).toBe("AwaitingAttack"); + // Defender took 3 cards and refilled to at least 5 + const def = r.state.players.find((p) => p.id === "p2")!; + expect(def.hand.length).toBeGreaterThanOrEqual(5); + }); + + it("PARTIAL DEFENCE then empty deck → no refill, hand may end below 5", () => { + // Attacker must keep ≥1 card after attacking so they don't win immediately + // when the deck is empty (checkWin = deck===0 && hand===0). + const s = buildState({ + trump: 3, + hands: [ + [c(1, 7, "atk"), c(2, 7, "atk2"), c(0, 5, "kick"), c(3, 9, "atk-keep")], + [c(0, 14, "huge")], + [], + ], + deck: [], // empty + }); + const a = submitAttack(s, "p1", [ + c(1, 7, "atk").id, + c(2, 7, "atk2").id, + c(0, 5, "kick").id, + ]); + if (!a.ok) throw new Error(); + const r = stopDefending(a.state, "p2"); + if (!r.ok) throw new Error(); + const def = r.state.players.find((p) => p.id === "p2")!; + // Took 3, started with 1 → 4 cards, deck empty so no refill. + expect(def.hand).toHaveLength(4); + }); + + it("win triggers on full defence with deck empty + defender ends at 0 hand", () => { + // Defender has exactly one card to beat exactly one attack, deck empty. + // Attacker keeps a spare card so submitAttack does NOT trigger an attacker win + // (checkWin would otherwise fire as deck===0 && hand===0 for the attacker). + const s = buildState({ + trump: 3, + hands: [[c(1, 7, "atk"), c(3, 5, "spare")], [c(1, 9, "d1")], []], + deck: [], + }); + const a = submitAttack(s, "p1", [c(1, 7, "atk").id]); + if (!a.ok) throw new Error(); + const b = submitBeat(a.state, "p2", c(1, 7, "atk").id, c(1, 9, "d1").id); + if (!b.ok) throw new Error(); + const r = stopDefending(b.state, "p2"); + if (!r.ok) throw new Error(); + expect(r.state.winner).toBe("p2"); + expect(r.state.round.phase).toBe("RoundEnded"); + }); + + it("rejects when no pending attack", () => { + const s = buildState({ trump: 3, hands: [[], []], phase: "AwaitingDefense" }); + s.round.defenderId = "p2"; + const r = stopDefending(s, "p2"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("NO_PENDING_ATTACK"); + }); +}); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/submit-attack.test.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/submit-attack.test.ts new file mode 100644 index 0000000..288eaad --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/submit-attack.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { submitAttack } from "../rules.js"; +import { buildState, c, setupMatch, totalCards } from "./_helpers.js"; + +describe("submitAttack", () => { + it("attacker sends 3-card pair+kicker, refills to 5, phase = AwaitingDefense", () => { + const hand = [c(0, 7, "a"), c(1, 7, "b"), c(2, 10, "k"), c(0, 5, "x"), c(0, 6, "y")]; + const deck = [c(3, 2, "t1"), c(3, 3, "t2"), c(3, 4, "t3")]; + const s = buildState({ trump: 3, hands: [hand, [], []], deck }); + const r = submitAttack(s, "p1", [hand[0]!.id, hand[1]!.id, hand[2]!.id]); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.state.round.phase).toBe("AwaitingDefense"); + expect(r.state.round.defenderId).toBe("p2"); + expect(r.state.round.pendingAttack?.unbeatenCards).toHaveLength(3); + const attacker = r.state.players.find((p) => p.id === "p1")!; + expect(attacker.hand).toHaveLength(5); + }); + + it("rejects when phase is not AwaitingAttack", () => { + const s = buildState({ + trump: 3, + hands: [[c(0, 7, "a")], []], + phase: "AwaitingDefense", + }); + const r = submitAttack(s, "p1", [c(0, 7, "a").id]); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("PHASE_NOT_AWAITING_ATTACK"); + }); + + it("rejects when caller is not the current attacker", () => { + const s = buildState({ + trump: 3, + hands: [[c(0, 7, "a")], [c(1, 7, "b")]], + }); + const r = submitAttack(s, "p2", [c(1, 7, "b").id]); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("NOT_YOUR_TURN"); + }); + + it("rejects illegal attack shape", () => { + const hand = [c(0, 7, "a"), c(1, 7, "b"), c(2, 10, "k")]; + const s = buildState({ trump: 3, hands: [hand, []] }); + // 2-card attack — illegal size + const r = submitAttack(s, "p1", [hand[0]!.id, hand[2]!.id]); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("ATTACK_INVALID_SIZE"); + }); + + it("preserves card conservation through the action", () => { + const s = setupMatch({ playerCount: 3, seed: 100 }); + const totalBefore = totalCards(s); + expect(totalBefore).toBe(52); + const attacker = s.players.find((p) => p.id === s.round.attackerId)!; + const card = attacker.hand[0]!; + const r = submitAttack(s, attacker.id, [card.id]); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(totalCards(r.state)).toBe(52); + }); + + it("input state is not mutated", () => { + const s = setupMatch({ playerCount: 3, seed: 101 }); + const attacker = s.players.find((p) => p.id === s.round.attackerId)!; + const beforeHandIds = attacker.hand.map((c) => c.id).join(","); + submitAttack(s, attacker.id, [attacker.hand[0]!.id]); + const after = s.players.find((p) => p.id === s.round.attackerId)!; + expect(after.hand.map((c) => c.id).join(",")).toBe(beforeHandIds); + }); +}); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/submit-beat.test.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/submit-beat.test.ts new file mode 100644 index 0000000..38dc7c0 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/submit-beat.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { submitAttack, submitBeat } from "../rules.js"; +import { buildState, c } from "./_helpers.js"; + +describe("submitBeat", () => { + function attackedState() { + // Attacker hand minus the cards they used (single card 7♦) + // After submitAttack, attacker refills to 5 from deck. + const attackerHand = [c(1, 7, "atk")]; + const defenderHand = [c(1, 9, "d1"), c(0, 9, "d2"), c(3, 2, "trump-low")]; + const deck = [c(0, 2, "t1"), c(0, 3, "t2"), c(0, 4, "t3"), c(0, 5, "t4")]; + const s = buildState({ + trump: 3, + hands: [attackerHand, defenderHand, []], + deck, + }); + const r = submitAttack(s, "p1", [attackerHand[0]!.id]); + if (!r.ok) throw new Error("setup attack failed"); + return r.state; + } + + it("moves the attack from unbeatenCards into beatenPairs", () => { + const s = attackedState(); + const r = submitBeat(s, "p2", c(1, 7, "atk").id, c(1, 9, "d1").id); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.state.round.pendingAttack?.unbeatenCards).toHaveLength(0); + expect(r.state.round.pendingAttack?.beatenPairs).toHaveLength(1); + }); + + it("transitions phase to Resolving when all attacks beaten", () => { + const s = attackedState(); + const r = submitBeat(s, "p2", c(1, 7, "atk").id, c(1, 9, "d1").id); + if (!r.ok) throw new Error(); + expect(r.state.round.phase).toBe("Resolving"); + }); + + it("rejects when counter cannot beat attack", () => { + // For BEAT_ILLEGAL we need a strictly losing combo: e.g. attacker has 7♦ in unbeatenCards; + // counter 6♥ — different non-trump suit, not trump → illegal. + const sBad = buildState({ + trump: 3, + hands: [[c(1, 7, "atk")], [c(2, 6, "bad")], []], + deck: [c(0, 2, "t1"), c(0, 3, "t2"), c(0, 4, "t3"), c(0, 5, "t4")], + }); + const a = submitAttack(sBad, "p1", [c(1, 7, "atk").id]); + if (!a.ok) throw new Error(); + const r = submitBeat(a.state, "p2", c(1, 7, "atk").id, c(2, 6, "bad").id); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("BEAT_ILLEGAL"); + }); + + it("rejects when not in defense phase", () => { + const s = buildState({ trump: 3, hands: [[], []], phase: "AwaitingAttack" }); + const r = submitBeat(s, "p2", "any", "any"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("PHASE_NOT_DEFENSE"); + }); + + it("rejects when caller is not the defender", () => { + const s = attackedState(); + const r = submitBeat(s, "p3", c(1, 7, "atk").id, "anything"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("NOT_YOUR_TURN"); + }); + + it("rejects when counter card not in defender's hand", () => { + const s = attackedState(); + const r = submitBeat(s, "p2", c(1, 7, "atk").id, "ghost-card-id"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("BEAT_CARD_NOT_OWNED"); + }); + + it("rejects when attack card is not pending", () => { + const s = attackedState(); + const r = submitBeat(s, "p2", "ghost-attack", c(1, 9, "d1").id); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe("BEAT_TARGET_NOT_FOUND"); + }); +}); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/turn-flow.test.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/turn-flow.test.ts new file mode 100644 index 0000000..4ce43a6 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/turn-flow.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { rotateDealer, startNewRound, submitAttack, submitBeat, stopDefending } from "../rules.js"; +import { setupMatch, totalCards } from "./_helpers.js"; + +describe("turn flow", () => { + it("startNewRound deals 5 to each player, sets trump from bottom card", () => { + const s = setupMatch({ playerCount: 3, seed: 7 }); + for (const p of s.players) expect(p.hand).toHaveLength(5); + expect(s.deck).toHaveLength(52 - 3 * 5); + expect(s.discard).toHaveLength(0); + expect(s.round.phase).toBe("AwaitingAttack"); + expect(totalCards(s)).toBe(52); + }); + + it("attacker is seat after dealer (clockwise)", () => { + const s = setupMatch({ playerCount: 4, seed: 11 }); + const dealer = s.players.find((p) => p.id === s.round.dealerId)!; + const attacker = s.players.find((p) => p.id === s.round.attackerId)!; + expect(attacker.seatIndex).toBe((dealer.seatIndex + 1) % 4); + }); + + it("rotateDealer moves the dealer clockwise by one seat", () => { + const s = setupMatch({ playerCount: 3, seed: 12 }); + const dealerBefore = s.players.find((p) => p.id === s.round.dealerId)!; + const r = rotateDealer(s); + const dealerAfter = r.players.find((p) => p.id === r.round.dealerId)!; + expect(dealerAfter.seatIndex).toBe((dealerBefore.seatIndex + 1) % 3); + }); + + it("dealer rotates clockwise on a new round triggered by startNewRound(prev, seed)", () => { + const s1 = setupMatch({ playerCount: 4, seed: 21 }); + const dealer1 = s1.players.find((p) => p.id === s1.round.dealerId)!; + const s2 = startNewRound(s1, 22); + const dealer2 = s2.players.find((p) => p.id === s2.round.dealerId)!; + expect(dealer2.seatIndex).toBe((dealer1.seatIndex + 1) % 4); + expect(s2.roundNumber).toBe(2); + }); + + it("full attack→partial defence→next attacker rotation maintains conservation", () => { + const s = setupMatch({ playerCount: 3, seed: 33 }); + expect(totalCards(s)).toBe(52); + const attacker = s.players.find((p) => p.id === s.round.attackerId)!; + const attackCard = attacker.hand[0]!; + const a = submitAttack(s, attacker.id, [attackCard.id]); + expect(a.ok).toBe(true); + if (!a.ok) return; + expect(totalCards(a.state)).toBe(52); + + const defenderId = a.state.round.defenderId!; + // Defender simply stops without beating + const b = stopDefending(a.state, defenderId); + expect(b.ok).toBe(true); + if (!b.ok) return; + expect(totalCards(b.state)).toBe(52); + + // Defender holds at least the original attack card + const def = b.state.players.find((p) => p.id === defenderId)!; + expect(def.hand.some((c) => c.id === attackCard.id)).toBe(true); + }); + + it("does not mutate previous round state when starting a new round", () => { + const s1 = setupMatch({ playerCount: 3, seed: 44 }); + const r1Snapshot = JSON.stringify(s1); + startNewRound(s1, 45); + expect(JSON.stringify(s1)).toBe(r1Snapshot); + }); + + // Touch submitBeat in the integration so it isn't dead-referenced. + it("submitBeat is part of the turn flow API", () => { + expect(typeof submitBeat).toBe("function"); + }); +}); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/__tests__/views.test.ts b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/views.test.ts new file mode 100644 index 0000000..a97d83b --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/__tests__/views.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { createPrivateView, createPublicView } from "../rules.js"; +import { setupMatch } from "./_helpers.js"; + +describe("views", () => { + it("createPublicView strips opponent hand contents (only count)", () => { + const s = setupMatch({ playerCount: 3, seed: 55 }); + const view = createPublicView(s); + for (const p of view.players) { + expect(p.hand.publiclyKnown).toHaveLength(0); + expect(typeof p.hand.count).toBe("number"); + expect(p.hand.count).toBeGreaterThan(0); + } + // No Card[] on PublicPlayerInfo for opponents + for (const p of view.players) { + expect("count" in p.hand).toBe(true); + } + }); + + it("createPublicView exposes deck and discard COUNT only", () => { + const s = setupMatch({ playerCount: 3, seed: 56 }); + const view = createPublicView(s); + expect(typeof view.deckCount).toBe("number"); + expect(typeof view.discardCount).toBe("number"); + // shape: no `deck` / `discard` keys with Card[] payload + expect((view as unknown as { deck?: unknown }).deck).toBeUndefined(); + expect((view as unknown as { discard?: unknown }).discard).toBeUndefined(); + }); + + it("createPrivateView reveals only the viewer's own hand", () => { + const s = setupMatch({ playerCount: 3, seed: 57 }); + const view = createPrivateView(s, "p1"); + const viewer = s.players.find((p) => p.id === "p1")!; + expect(view.viewerHand.map((c) => c.id).sort()).toEqual( + viewer.hand.map((c) => c.id).sort(), + ); + // Opponent hands still hidden in the public projection portion + for (const p of view.players) { + expect(p.hand.publiclyKnown).toHaveLength(0); + } + }); + + it("mutating the returned view does NOT mutate state", () => { + const s = setupMatch({ playerCount: 3, seed: 58 }); + const view = createPublicView(s); + view.players[0]!.hand.publiclyKnown.push({ id: "ghost", suit: 0, rank: 2 }); + const fresh = createPublicView(s); + expect(fresh.players[0]!.hand.publiclyKnown).toHaveLength(0); + }); +}); diff --git a/@lab/ll-CARDSHED/apps/core/src/core/index.ts b/@lab/ll-CARDSHED/apps/core/src/core/index.ts new file mode 100644 index 0000000..5acc219 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/index.ts @@ -0,0 +1,23 @@ +export * from "./types.js"; +export { + createDeck, + shuffleDeck, + determineTrumpFromBottomCard, + dealInitialHands, + startNewRound, + validateAttack, + canBeat, + submitAttack, + submitBeat, + stopDefending, + drawToMinimum, + checkWin, + advanceTurnAfterFullDefense, + advanceTurnAfterPartialDefense, + rotateDealer, + getLegalActions, + createPublicView, + createPrivateView, + pendingAttackCardCount, +} from "./rules.js"; +export { mulberry32, shuffleInPlaceCopy } from "./prng.js"; diff --git a/@lab/ll-CARDSHED/apps/core/src/core/prng.ts b/@lab/ll-CARDSHED/apps/core/src/core/prng.ts new file mode 100644 index 0000000..5aaed45 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/prng.ts @@ -0,0 +1,25 @@ +// mulberry32 — 32-bit deterministic PRNG. Period 2^32. NOT a CSPRNG. +// Reference: https://gist.github.com/tommyettinger/46a3c64a2ecfe7c4ce5c +export function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Seeded Fisher-Yates on a COPY. Input array is never mutated. +export function shuffleInPlaceCopy(arr: readonly T[], seed: number): T[] { + const out = arr.slice(); + const rand = mulberry32(seed); + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + const tmp = out[i]!; + out[i] = out[j]!; + out[j] = tmp; + } + return out; +} diff --git a/@lab/ll-CARDSHED/apps/core/src/core/rules.ts b/@lab/ll-CARDSHED/apps/core/src/core/rules.ts new file mode 100644 index 0000000..5e9614c --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/rules.ts @@ -0,0 +1,672 @@ +import { shuffleInPlaceCopy } from "./prng.js"; +import type { + Action, + ActionResult, + BeatenPair, + Card, + GameEvent, + MatchState, + PendingAttack, + Player, + PlayerSetup, + PrivatePlayerView, + PublicGameView, + Rank, + RoundState, + Suit, + ValidationError, +} from "./types.js"; + +// ─── Helpers ───────────────────────────────────────────────────────────── +const SUIT_LETTERS: Record = { 0: "C", 1: "D", 2: "H", 3: "S" }; + +function err(code: string, message: string): { ok: false; error: ValidationError } { + return { ok: false, error: { code, message } }; +} + +function errResult(code: string, message: string): ActionResult { + return { ok: false, error: { code, message } }; +} + +function clone(x: T): T { + return structuredClone(x); +} + +function findPlayer(state: MatchState, id: string): Player { + const p = state.players.find((pp) => pp.id === id); + if (!p) throw new Error(`INVARIANT: player ${id} not found`); + return p; +} + +function leftOf(state: MatchState, id: string): string { + const p = findPlayer(state, id); + const next = (p.seatIndex + 1) % state.players.length; + const np = state.players.find((pp) => pp.seatIndex === next); + if (!np) throw new Error(`INVARIANT: seat ${next} not found`); + return np.id; +} + +export function pendingAttackCardCount(pa: PendingAttack | null): number { + if (!pa) return 0; + return pa.unbeatenCards.length + 2 * pa.beatenPairs.length; +} + +// ─── Deck / setup ──────────────────────────────────────────────────────── + +// Canonical 52-card deck, suit-major, rank-ascending. +// id format: +// - createDeck() → "{suitLetter}-{rank}-{slotHex}" e.g. "S-14-33" +// - createDeck(salt) → "{suitLetter}-{rank}-{slotHex}-{saltB36}" e.g. "S-14-33-1c" +// Salt is opaque (base36 of (salt >>> 0)). Use it to disambiguate cards across +// matches or rounds in long-running sessions. Card.id is OPAQUE — do NOT parse +// to extract suit/rank; always use card.suit / card.rank. +export function createDeck(salt?: number): Card[] { + const out: Card[] = []; + const suffix = salt === undefined ? "" : `-${(salt >>> 0).toString(36)}`; + for (let s = 0 as Suit; s <= 3; s = ((s + 1) as Suit)) { + for (let r = 2 as Rank; r <= 14; r = ((r + 1) as Rank)) { + const idx = s * 13 + (r - 2); + out.push({ + id: `${SUIT_LETTERS[s]}-${r}-${idx.toString(16).padStart(2, "0")}${suffix}`, + suit: s, + rank: r, + }); + } + if (s === 3) break; // guard the cast + } + return out; +} + +export function shuffleDeck(deck: Card[], seed: number): Card[] { + return shuffleInPlaceCopy(deck, seed); +} + +export function determineTrumpFromBottomCard(deck: Card[]): Suit { + const bottom = deck[0]; + if (!bottom) throw new Error("INVARIANT: deck is empty"); + return bottom.suit; +} + +// Deals 5 cards to each player by popping from the TOP (deck.pop()). +// Bottom card (trump face) remains and will be drawn last. +export function dealInitialHands( + deck: Card[], + playerCount: 3 | 4, +): { hands: Card[][]; deck: Card[] } { + const remaining = deck.slice(); + const hands: Card[][] = Array.from({ length: playerCount }, () => []); + for (let round = 0; round < 5; round++) { + for (let p = 0; p < playerCount; p++) { + const c = remaining.pop(); + if (!c) throw new Error("INVARIANT: deck exhausted during initial deal"); + hands[p]!.push(c); + } + } + return { hands, deck: remaining }; +} + +// startNewRound — initial setup OR rotate dealer + reshuffle + redeal. +export function startNewRound( + prev: MatchState | null, + seed: number, + setup?: { matchId: string; players: PlayerSetup[] }, +): MatchState { + let players: Player[]; + let matchId: string; + let roundNumber: number; + let dealerSeat: number; + + if (prev === null) { + if (!setup) { + throw new Error("INVARIANT: startNewRound(null, ...) requires setup with players"); + } + if (setup.players.length !== 3 && setup.players.length !== 4) { + throw new Error("INVARIANT: playerCount must be 3 or 4"); + } + players = setup.players.map((ps, idx) => ({ + id: ps.id, + name: ps.name, + hand: [], + score: 0, + isActive: true, + seatIndex: idx, + })); + matchId = setup.matchId; + roundNumber = 1; + dealerSeat = 0; + } else { + matchId = prev.matchId; + roundNumber = prev.roundNumber + 1; + const prevDealer = prev.players.find((p) => p.id === prev.round.dealerId); + if (!prevDealer) throw new Error("INVARIANT: previous dealer not found"); + dealerSeat = (prevDealer.seatIndex + 1) % prev.players.length; + players = prev.players.map((p) => ({ + ...p, + hand: [], + isActive: true, + })); + } + + const playerCount = players.length as 3 | 4; + // Salt card ids with the round seed so each round's cards are uniquely tagged. + const shuffled = shuffleDeck(createDeck(seed), seed); + const { hands, deck } = dealInitialHands(shuffled, playerCount); + + for (let i = 0; i < playerCount; i++) { + players[i]!.hand = hands[i]!; + } + + const trump = determineTrumpFromBottomCard(shuffled); + const dealer = players.find((p) => p.seatIndex === dealerSeat)!; + const attackerSeat = (dealerSeat + 1) % playerCount; + const attacker = players.find((p) => p.seatIndex === attackerSeat)!; + + const round: RoundState = { + trump, + dealerId: dealer.id, + attackerId: attacker.id, + defenderId: null, + phase: "AwaitingAttack", + pendingAttack: null, + }; + + return { + matchId, + roundNumber, + players, + deck, + discard: [], + round, + winner: null, + rngSeed: seed, + }; +} + +// ─── Attack validation ─────────────────────────────────────────────────── + +export function validateAttack( + cards: Card[], + hand: Card[], +): { ok: true } | { ok: false; error: ValidationError } { + if (![1, 3, 5].includes(cards.length)) { + return err( + "ATTACK_INVALID_SIZE", + `Attack must be 1/3/5 cards, got ${cards.length}`, + ); + } + const seenIds = new Set(); + for (const c of cards) { + if (seenIds.has(c.id)) { + return err("ATTACK_DUPLICATE_CARD", `Duplicate card ${c.id}`); + } + seenIds.add(c.id); + if (!hand.find((h) => h.id === c.id)) { + return err("ATTACK_CARD_NOT_OWNED", `Card ${c.id} not in hand`); + } + } + + if (cards.length === 1) return { ok: true }; + + const byRank = new Map(); + for (const c of cards) byRank.set(c.rank, (byRank.get(c.rank) ?? 0) + 1); + const distinctPairRanks = [...byRank.values()].filter((n) => n >= 2).length; + + if (cards.length === 3) { + if (distinctPairRanks === 0) { + return err("ATTACK_NO_PAIR", "3-card attack needs a pair"); + } + return { ok: true }; + } + + // cards.length === 5 + if (distinctPairRanks < 2) { + return err( + "ATTACK_NO_TWO_PAIRS", + "5-card attack needs two pairs of DISTINCT ranks", + ); + } + return { ok: true }; +} + +// ─── canBeat ───────────────────────────────────────────────────────────── + +export function canBeat(attack: Card, counter: Card, trump: Suit): boolean { + if (attack.suit === trump) { + return counter.suit === trump && counter.rank > attack.rank; + } + if (counter.suit === trump) return true; + return counter.suit === attack.suit && counter.rank > attack.rank; +} + +// ─── drawToMinimum ─────────────────────────────────────────────────────── + +export function drawToMinimum( + hand: Card[], + deck: Card[], + target: number, +): { hand: Card[]; deck: Card[] } { + const h = hand.slice(); + const d = deck.slice(); + while (h.length < target && d.length > 0) { + const c = d.pop()!; + h.push(c); + } + return { hand: h, deck: d }; +} + +// ─── checkWin ──────────────────────────────────────────────────────────── + +export function checkWin(state: MatchState, playerId: string): boolean { + const p = findPlayer(state, playerId); + return state.deck.length === 0 && p.hand.length === 0; +} + +// ─── submitAttack ──────────────────────────────────────────────────────── + +export function submitAttack( + state: MatchState, + playerId: string, + cardIds: string[], +): ActionResult { + if (state.round.phase !== "AwaitingAttack") { + return errResult("PHASE_NOT_AWAITING_ATTACK", `Phase is ${state.round.phase}`); + } + if (state.round.attackerId !== playerId) { + return errResult("NOT_YOUR_TURN", `${playerId} is not the attacker`); + } + const attacker = findPlayer(state, playerId); + + // Resolve cardIds against attacker's hand (preserving order from cardIds for stable replay). + const cards: Card[] = []; + for (const cid of cardIds) { + const card = attacker.hand.find((c) => c.id === cid); + if (!card) { + return errResult("ATTACK_CARD_NOT_OWNED", `Card ${cid} not in hand`); + } + cards.push(card); + } + const v = validateAttack(cards, attacker.hand); + if (!v.ok) return { ok: false, error: v.error }; + + const next = clone(state); + const nAttacker = findPlayer(next, playerId); + const idSet = new Set(cardIds); + nAttacker.hand = nAttacker.hand.filter((c) => !idSet.has(c.id)); + const defenderId = leftOf(next, playerId); + next.round.pendingAttack = { + attackerId: playerId, + defenderId, + unbeatenCards: cards.map((c) => ({ ...c })), + beatenPairs: [], + }; + + // Refill attacker BEFORE win check. + const before = nAttacker.hand.length; + const refill = drawToMinimum(nAttacker.hand, next.deck, 5); + const drawn = refill.hand.length - before; + nAttacker.hand = refill.hand; + next.deck = refill.deck; + + const events: GameEvent[] = [ + { type: "AttackSubmitted", attackerId: playerId, defenderId, cardIds }, + ]; + if (drawn > 0) events.push({ type: "CardsDrawn", playerId, count: drawn }); + + if (checkWin(next, playerId)) { + next.round.phase = "RoundEnded"; + next.winner = playerId; + events.push({ type: "RoundWon", winnerId: playerId }); + return { ok: true, state: next, events }; + } + + next.round.phase = "AwaitingDefense"; + next.round.defenderId = defenderId; + events.push({ type: "TurnChanged", nextAttackerId: playerId, phase: next.round.phase }); + return { ok: true, state: next, events }; +} + +// ─── submitBeat ────────────────────────────────────────────────────────── + +export function submitBeat( + state: MatchState, + defenderId: string, + attackCardId: string, + counterCardId: string, +): ActionResult { + if ( + state.round.phase !== "AwaitingDefense" && + state.round.phase !== "Resolving" + ) { + return errResult("PHASE_NOT_DEFENSE", `Phase is ${state.round.phase}`); + } + if (state.round.defenderId !== defenderId) { + return errResult("NOT_YOUR_TURN", `${defenderId} is not the defender`); + } + const pa = state.round.pendingAttack; + if (!pa) return errResult("NO_PENDING_ATTACK", "No pending attack to beat"); + + const attack = pa.unbeatenCards.find((c) => c.id === attackCardId); + if (!attack) { + return errResult( + "BEAT_TARGET_NOT_FOUND", + `${attackCardId} is not an unbeaten attack card`, + ); + } + const defender = findPlayer(state, defenderId); + const counter = defender.hand.find((c) => c.id === counterCardId); + if (!counter) { + return errResult( + "BEAT_CARD_NOT_OWNED", + `${counterCardId} not in defender's hand`, + ); + } + if (!canBeat(attack, counter, state.round.trump)) { + return errResult("BEAT_ILLEGAL", `${counterCardId} cannot beat ${attackCardId}`); + } + + const next = clone(state); + const nDefender = findPlayer(next, defenderId); + nDefender.hand = nDefender.hand.filter((c) => c.id !== counterCardId); + const npa = next.round.pendingAttack!; + npa.unbeatenCards = npa.unbeatenCards.filter((c) => c.id !== attackCardId); + npa.beatenPairs.push({ attack: { ...attack }, counter: { ...counter } }); + next.round.phase = + npa.unbeatenCards.length === 0 ? "Resolving" : "AwaitingDefense"; + + return { + ok: true, + state: next, + events: [{ type: "CardBeaten", defenderId, attackCardId, counterCardId }], + }; +} + +// ─── stopDefending ─────────────────────────────────────────────────────── + +export function stopDefending( + state: MatchState, + defenderId: string, +): ActionResult { + if ( + state.round.phase !== "AwaitingDefense" && + state.round.phase !== "Resolving" + ) { + return errResult("PHASE_NOT_DEFENSE", `Phase is ${state.round.phase}`); + } + if (state.round.defenderId !== defenderId) { + return errResult("NOT_YOUR_TURN", `${defenderId} is not the defender`); + } + const pa = state.round.pendingAttack; + if (!pa) return errResult("NO_PENDING_ATTACK", "No pending attack to stop"); + + const next = clone(state); + const nDefender = findPlayer(next, defenderId); + const npa = next.round.pendingAttack!; + const events: GameEvent[] = []; + + if (npa.unbeatenCards.length === 0) { + // FULL DEFENCE: flush all beaten pairs to discard, defender becomes attacker. + for (const { attack, counter } of npa.beatenPairs) { + next.discard.push(attack, counter); + } + events.push({ + type: "FullDefense", + defenderId, + discardedPairs: npa.beatenPairs.length, + }); + + const before = nDefender.hand.length; + const refill = drawToMinimum(nDefender.hand, next.deck, 5); + const drawn = refill.hand.length - before; + nDefender.hand = refill.hand; + next.deck = refill.deck; + if (drawn > 0) + events.push({ type: "CardsDrawn", playerId: defenderId, count: drawn }); + + if (checkWin(next, defenderId)) { + next.round.phase = "RoundEnded"; + next.winner = defenderId; + next.round.pendingAttack = null; + events.push({ type: "RoundWon", winnerId: defenderId }); + return { ok: true, state: next, events }; + } + + next.round.attackerId = defenderId; + next.round.defenderId = null; + next.round.pendingAttack = null; + next.round.phase = "AwaitingAttack"; + events.push({ + type: "TurnChanged", + nextAttackerId: defenderId, + phase: "AwaitingAttack", + }); + return { ok: true, state: next, events }; + } + + // PARTIAL / NO DEFENCE: defender takes unbeaten cards into hand; + // beaten pairs also go to discard (already-defeated cards leave play). + const taken = npa.unbeatenCards.slice(); + for (const c of taken) nDefender.hand.push(c); + for (const { attack, counter } of npa.beatenPairs) { + next.discard.push(attack, counter); + } + events.push({ + type: "DefenseStopped", + defenderId, + takenCardIds: taken.map((c) => c.id), + }); + + const before = nDefender.hand.length; + const refill = drawToMinimum(nDefender.hand, next.deck, 5); + const drawn = refill.hand.length - before; + nDefender.hand = refill.hand; + next.deck = refill.deck; + if (drawn > 0) + events.push({ type: "CardsDrawn", playerId: defenderId, count: drawn }); + + if (checkWin(next, defenderId)) { + next.round.phase = "RoundEnded"; + next.winner = defenderId; + next.round.pendingAttack = null; + events.push({ type: "RoundWon", winnerId: defenderId }); + return { ok: true, state: next, events }; + } + + const nextAttacker = leftOf(next, defenderId); + next.round.attackerId = nextAttacker; + next.round.defenderId = null; + next.round.pendingAttack = null; + next.round.phase = "AwaitingAttack"; + events.push({ + type: "TurnChanged", + nextAttackerId: nextAttacker, + phase: "AwaitingAttack", + }); + return { ok: true, state: next, events }; +} + +// ─── advanceTurn* (thin pure helpers around stopDefending semantics) ───── + +export function advanceTurnAfterFullDefense(state: MatchState): MatchState { + if (!state.round.defenderId) { + throw new Error("INVARIANT: cannot advance after full defense with no defender"); + } + const r = stopDefending(state, state.round.defenderId); + if (!r.ok) throw new Error(`INVARIANT: advanceTurnAfterFullDefense failed: ${r.error.code}`); + return r.state; +} + +export function advanceTurnAfterPartialDefense(state: MatchState): MatchState { + if (!state.round.defenderId) { + throw new Error("INVARIANT: cannot advance after partial defense with no defender"); + } + const r = stopDefending(state, state.round.defenderId); + if (!r.ok) throw new Error(`INVARIANT: advanceTurnAfterPartialDefense failed: ${r.error.code}`); + return r.state; +} + +export function rotateDealer(state: MatchState): MatchState { + const next = clone(state); + const dealer = findPlayer(next, next.round.dealerId); + const nextDealerSeat = (dealer.seatIndex + 1) % next.players.length; + const newDealer = next.players.find((p) => p.seatIndex === nextDealerSeat); + if (!newDealer) throw new Error("INVARIANT: next dealer seat not found"); + next.round.dealerId = newDealer.id; + return next; +} + +// ─── getLegalActions ───────────────────────────────────────────────────── + +export function getLegalActions(state: MatchState, playerId: string): Action[] { + const phase = state.round.phase; + + if (phase === "AwaitingAttack" && state.round.attackerId === playerId) { + const attacker = state.players.find((p) => p.id === playerId); + if (!attacker) return []; + return enumerateAttacks(attacker.hand, playerId); + } + + if ( + (phase === "AwaitingDefense" || phase === "Resolving") && + state.round.defenderId === playerId + ) { + const defender = state.players.find((p) => p.id === playerId); + if (!defender || !state.round.pendingAttack) return []; + const out: Action[] = []; + for (const attack of state.round.pendingAttack.unbeatenCards) { + for (const counter of defender.hand) { + if (canBeat(attack, counter, state.round.trump)) { + out.push({ + kind: "Beat", + playerId, + attackCardId: attack.id, + counterCardId: counter.id, + }); + } + } + } + out.push({ kind: "Stop", playerId }); + return out; + } + + return []; +} + +function enumerateAttacks(hand: Card[], playerId: string): Action[] { + const seen = new Set(); + const out: Action[] = []; + + const push = (cardIds: string[]) => { + const key = [...cardIds].sort().join(","); + if (seen.has(key)) return; + seen.add(key); + out.push({ kind: "Attack", playerId, cardIds }); + }; + + // 1-card attacks + for (const c of hand) push([c.id]); + + // Group by rank for pair detection. + const byRank = new Map(); + for (const c of hand) { + const arr = byRank.get(c.rank) ?? []; + arr.push(c); + byRank.set(c.rank, arr); + } + const ranksWithPairs = [...byRank.entries()].filter(([, cs]) => cs.length >= 2); + + // 3-card attacks: pick two cards of one rank as the pair, then any third card as kicker. + for (const [rank, cs] of ranksWithPairs) { + for (let i = 0; i < cs.length; i++) { + for (let j = i + 1; j < cs.length; j++) { + for (const kicker of hand) { + if (kicker.rank === rank) continue; // kicker must NOT be of pair rank (else it'd be a triple, still a "pair + kicker" — rules don't ban this, but we'll allow it) + push([cs[i]!.id, cs[j]!.id, kicker.id]); + } + // Allow kicker of same rank too (still passes validateAttack — the pair exists). + for (const kicker of cs) { + if (kicker.id === cs[i]!.id || kicker.id === cs[j]!.id) continue; + push([cs[i]!.id, cs[j]!.id, kicker.id]); + } + } + } + } + + // 5-card attacks: two pairs of DISTINCT ranks + a kicker. + for (let a = 0; a < ranksWithPairs.length; a++) { + for (let b = a + 1; b < ranksWithPairs.length; b++) { + const [rA, csA] = ranksWithPairs[a]!; + const [rB, csB] = ranksWithPairs[b]!; + for (let i = 0; i < csA.length; i++) { + for (let j = i + 1; j < csA.length; j++) { + for (let k = 0; k < csB.length; k++) { + for (let l = k + 1; l < csB.length; l++) { + for (const kicker of hand) { + if (kicker.rank === rA && (kicker.id === csA[i]!.id || kicker.id === csA[j]!.id)) + continue; + if (kicker.rank === rB && (kicker.id === csB[k]!.id || kicker.id === csB[l]!.id)) + continue; + push([csA[i]!.id, csA[j]!.id, csB[k]!.id, csB[l]!.id, kicker.id]); + } + } + } + } + } + } + } + + return out; +} + +// ─── Views ─────────────────────────────────────────────────────────────── + +export function createPublicView( + state: MatchState, + _viewerId?: string, +): PublicGameView { + return { + matchId: state.matchId, + roundNumber: state.roundNumber, + players: state.players.map((p) => ({ + id: p.id, + name: p.name, + seatIndex: p.seatIndex, + score: p.score, + isActive: p.isActive, + hand: { ownerId: p.id, count: p.hand.length, publiclyKnown: [] }, + })), + deckCount: state.deck.length, + discardCount: state.discard.length, + round: { + trump: state.round.trump, + dealerId: state.round.dealerId, + attackerId: state.round.attackerId, + defenderId: state.round.defenderId, + phase: state.round.phase, + pendingAttack: state.round.pendingAttack + ? { + attackerId: state.round.pendingAttack.attackerId, + defenderId: state.round.pendingAttack.defenderId, + unbeatenCards: state.round.pendingAttack.unbeatenCards.map((c) => ({ + ...c, + })), + beatenPairs: state.round.pendingAttack.beatenPairs.map( + (bp): BeatenPair => ({ + attack: { ...bp.attack }, + counter: { ...bp.counter }, + }), + ), + } + : null, + }, + winner: state.winner, + }; +} + +export function createPrivateView( + state: MatchState, + viewerId: string, +): PrivatePlayerView { + const pub = createPublicView(state, viewerId); + const viewer = findPlayer(state, viewerId); + return { ...pub, viewerId, viewerHand: viewer.hand.map((c) => ({ ...c })) }; +} diff --git a/@lab/ll-CARDSHED/apps/core/src/core/types.ts b/@lab/ll-CARDSHED/apps/core/src/core/types.ts new file mode 100644 index 0000000..5259ef9 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/src/core/types.ts @@ -0,0 +1,150 @@ +// Shared Contract — frozen, byte-identical to PRP 3. +export type Suit = 0 | 1 | 2 | 3; // Clubs, Diamonds, Hearts, Spades +export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14; + +export interface Card { + id: string; + suit: Suit; + rank: Rank; +} + +export type TurnState = + | "Dealing" + | "AwaitingAttack" + | "AwaitingDefense" + | "Resolving" + | "RoundEnded" + | "MatchEnded"; + +export interface ValidationError { + code: string; + message: string; + details?: unknown; +} + +export type ActionResult = + | { ok: true; state: MatchState; events: GameEvent[]; value?: T } + | { ok: false; error: ValidationError }; + +// Domain entities + +export interface Player { + id: string; + name: string; + hand: Card[]; + score: number; + isActive: boolean; + seatIndex: number; +} + +export interface BeatenPair { + attack: Card; + counter: Card; +} + +export interface PendingAttack { + attackerId: string; + defenderId: string; + unbeatenCards: Card[]; + beatenPairs: BeatenPair[]; +} + +export interface RoundState { + trump: Suit; + dealerId: string; + attackerId: string; + defenderId: string | null; + phase: TurnState; + pendingAttack: PendingAttack | null; +} + +export interface MatchState { + matchId: string; + roundNumber: number; + players: Player[]; // ordered by seatIndex + deck: Card[]; // deck[0] = bottom (trump face); deck[length-1] = top (drawn next) + discard: Card[]; + round: RoundState; + winner: string | null; + rngSeed: number; +} + +// Action union — discriminated on `kind` +export type Action = + | { kind: "Attack"; playerId: string; cardIds: string[] } + | { kind: "Beat"; playerId: string; attackCardId: string; counterCardId: string } + | { kind: "Stop"; playerId: string }; + +// GameEvent union — append-only audit / animation trail +export type GameEvent = + | { type: "RoundStarted"; roundNumber: number; dealerId: string; trump: Suit } + | { type: "TrumpSelected"; trump: Suit; bottomCardId: string } + | { type: "CardsDealt"; perPlayer: { playerId: string; count: number }[] } + | { + type: "AttackSubmitted"; + attackerId: string; + defenderId: string; + cardIds: string[]; + } + | { + type: "CardBeaten"; + defenderId: string; + attackCardId: string; + counterCardId: string; + } + | { type: "DefenseStopped"; defenderId: string; takenCardIds: string[] } + | { type: "FullDefense"; defenderId: string; discardedPairs: number } + | { type: "CardsDrawn"; playerId: string; count: number } + | { type: "TurnChanged"; nextAttackerId: string; phase: TurnState } + | { type: "RoundWon"; winnerId: string } + | { type: "MatchEnded"; winnerId: string }; + +// View projections — used by PRP 3 for UI + network + +export interface HandSummary { + ownerId: string; + count: number; + publiclyKnown: Card[]; +} + +export interface PublicPlayerInfo { + id: string; + name: string; + seatIndex: number; + score: number; + isActive: boolean; + hand: HandSummary; +} + +export interface PublicGameView { + matchId: string; + roundNumber: number; + players: PublicPlayerInfo[]; + deckCount: number; + discardCount: number; + round: { + trump: Suit; + dealerId: string; + attackerId: string; + defenderId: string | null; + phase: TurnState; + pendingAttack: { + attackerId: string; + defenderId: string; + unbeatenCards: Card[]; + beatenPairs: BeatenPair[]; + } | null; + }; + winner: string | null; +} + +export interface PrivatePlayerView extends PublicGameView { + viewerId: string; + viewerHand: Card[]; +} + +// Setup input — the engine is pure; the caller supplies the player roster. +export interface PlayerSetup { + id: string; + name: string; +} diff --git a/@lab/ll-CARDSHED/apps/core/tsconfig.json b/@lab/ll-CARDSHED/apps/core/tsconfig.json new file mode 100644 index 0000000..b3fd2af --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": false, + "types": ["node"] + }, + "include": ["src", "scripts"] +} diff --git a/@lab/ll-CARDSHED/apps/core/vitest.config.ts b/@lab/ll-CARDSHED/apps/core/vitest.config.ts new file mode 100644 index 0000000..ea3c136 --- /dev/null +++ b/@lab/ll-CARDSHED/apps/core/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/__tests__/**/*.test.ts"], + reporters: ["default"], + testTimeout: 20000, + }, +}); diff --git a/PRPs/cardshed-02-core-prp.md b/PRPs/cardshed-02-core-prp.md new file mode 100644 index 0000000..f5b825e --- /dev/null +++ b/PRPs/cardshed-02-core-prp.md @@ -0,0 +1,1066 @@ +name: "CARD SHED — Deterministic Core PRP (v2.0 rules)" +description: | + Implementation-grade PRP for the pure-logic CARD SHED rules engine. + Encodes the v2.0 rules as a side-effect-free TypeScript module with a + Vitest suite that proves correctness, invariants, and hidden-info hygiene. + Inputs: PRPs/01-strategy-blueprint.md (locked decisions), PRPs/02-deterministic-core.md (frozen brief), PRPs/03-experience-distribution.md (consumer contract). + +--- + +## Cross-PRP Contradictions Flagged + +These conflicts were surfaced while reconciling the brief (PRP 2), the blueprint (PRP 1), and the consumer brief (PRP 3). All five were resolved by the operator during PRP execution (see `Resolved:` lines below). PRP 3 MUST consume these resolutions verbatim. + +1. **Beaten-pair custody (PRP 2 vs PRP 2).** The brief's `PendingAttack` type carries `beatenPairs: {attack, counter}[]` as live pending state, but the `stopDefending` pseudocode under FULL DEFENCE comments "move all beatenPairs flat → discard (already there, no-op)". Either beatens live inside `pendingAttack` until `stopDefending`, or `submitBeat` moves each beaten pair into `discard` eagerly. Both are defensible; they differ in where the card-conservation accountant looks. **Resolved:** `beatenPairs` live inside `pendingAttack` for the duration of the turn (better debuggability, simpler `createPublicView`), and `stopDefending` flushes them into `discard` in **both** branches — on FULL DEFENCE *and* on partial defence (already-beaten cards leave play with the rest of the table pile). The conservation invariant counts beaten pairs under `pendingAttack`, not `discard`, while a turn is in flight. Implementation: `rules.ts:submitBeat` pushes into `npa.beatenPairs`; `rules.ts:stopDefending` flushes to `next.discard` in both the full-defence and partial-defence branches. + +2. **Conservation invariant operand (PRP 2 success-criteria vs PRP 2 property-test text).** Success Criteria says `sum(hands) + deck + discard + pendingAttack === 52`. The property-test bullet then writes `sum(pendingAttack)`. These reconcile only if `pendingAttack` is interpreted as "all cards currently held by the pending-attack object" = `unbeatenCards.length + 2 * beatenPairs.length`. **Resolved:** the helper `pendingAttackCardCount(pa)` is exported from `rules.ts` and used by every invariant assertion (test helper `totalCards(state)` and the property-test conservation check). + +3. **Win-check on partial-defence path (PRP 2 pseudocode silent).** `submitAttack` calls `checkWin(state, attackerId)` after attacker refill. `stopDefending` does not call `checkWin` on the defender after partial-defence refill — but if the deck is empty and the defender ended partial defence holding 0 cards (e.g. they had to take 0 unbeaten cards because they beat none but had 0 hand before… edge of edges), the rules say end-of-turn hand size 0 + deck empty = win. **Resolved:** `checkWin(state, defenderId)` runs after refill in **both** `stopDefending` branches (full and partial). Because `checkWin` requires `deck.length === 0 && hand.length === 0`, this only fires when the round has genuinely exhausted the deck — a fully-defending defender with cards left in the deck refills and becomes the next attacker as normal. PRP 3 consumers MUST NOT assume "winner = current attacker"; a defender can win on the trailing-end of a round. + +4. **Bottom-card / top-of-deck convention (PRP 2 implicit).** The brief uses `peekBottom`, `drawTop`, and `deck.pop()` interchangeably. Standard conventions vary: index 0 = top vs index 0 = bottom. The trump card sits at the *bottom* and must be drawn *last*. **Resolved:** `deck[0]` = bottom (trump face), `deck[deck.length - 1]` = top (drawn next). `drawTop` returns `deck.pop()`; `peekBottom` returns `deck[0]`. Documented inline in `types.ts` on the `MatchState.deck` field. + +5. **Card-id format (PRP 2 vs PRP 3 examples).** Brief example: `"S-14-uuid"`. PRP 3 message examples use `"S-14-abc"`, `"H-14-def"`, `"C-9-ghi"`. Suit prefix letter encoding (`S/H/D/C`) is not formally specified relative to the numeric `Suit = 0|1|2|3` mapping in the Shared Contract. **Resolved:** the canonical `Card.id` is `"${suitLetter}-${rank}-${slotHex}[-${saltB36}]"` where `suitLetter` is `C|D|H|S` for `Suit 0|1|2|3`, `slotHex` is the 2-digit hex of the card's canonical slot index (`suit*13 + rank-2`), and the optional `saltB36` is `(salt >>> 0).toString(36)` derived from the round seed. `createDeck()` (no argument) produces unsalted ids like `S-14-33`; `createDeck(salt)` produces salted ids like `S-14-33-1c`; `startNewRound` passes the round seed as the salt so each round's 52 cards have **deterministic but uniquely-tagged** ids — disjoint across rounds and matches. The letter and slot-hex are presentational only; equality is by full `id` string. Bots, the engine, and PRP 3 wire code MUST NEVER parse the id to extract suit/rank — always use `card.suit` / `card.rank`. + +--- + +## Purpose + +Build the **deterministic core** of CARD SHED: a pure, side-effect-free TypeScript module that encodes Rules v2.0. Given a `MatchState` and a player `Action`, the core returns the next `MatchState` plus a list of emitted `GameEvent`s, or a structured `ValidationError`. A Vitest suite proves it. + +## Core Principles + +1. **Purity is non-negotiable.** No `Math.random`, no `Date.now`, no `console.*` in `src/core/`, no module-level mutable state. Every reducer returns a *new* `MatchState`. +2. **Errors are values.** Validation failures return `{ ok: false, error }`. Throws are reserved for invariant violations (programmer bugs). +3. **Hidden information is law.** Opponent hands and deck order never leak through any exported function except `createPrivateView(state, viewerId)` for the viewer's own hand. +4. **Determinism through seeded RNG.** `shuffleDeck(deck, seed)` is the *only* place randomness happens, and only when given a numeric seed. Same seed in → same permutation out, on every JS runtime. +5. **Conservation is invariant.** Across every legal state transition, the 52 cards are accounted for exactly once. + +--- + +## Goal + +Deliver three files (and only these three, plus a `package.json` + `tsconfig.json` + `vitest.config.ts` if the project does not already have them): + +1. `src/core/types.ts` — declares every data structure in the Shared Contract + the entities listed in §"Data models". +2. `src/core/rules.ts` — implements every function on the **Rules Engine API** surface. +3. `src/core/__tests__/*.test.ts` — Vitest suite covering deck, attack validation, beat legality, turn flow, conservation, and hidden-info redaction. + +The output is a black box that PRP 3's UI, networking, AI, and simulation harness import without ever reaching into core internals. + +## Why + +The rules engine is the **truth machine** of CARD SHED. UI, networking, AI, and analytics are all transformations over its state or its event stream. If the core is wrong, the whole game is wrong. If the core is impure, replay, anti-cheat, and bot simulation all break. + +Building this layer first (and independently of UI/networking) lets PRP 3 wrap a stable interface, and lets bot simulators in PRP 3 generate millions of games for balance analytics from a known-good engine. + +## What + +A TypeScript module that exposes the Rules Engine API surface defined below. The module is consumed by: +- The hot-seat MVP (PRP 3, browser, direct import). +- The bot ladder (PRP 3, browser + node, direct import). +- The simulation harness (PRP 3, node, direct import). +- The eventual Rust server (PRP 1, mechanical port — the TS implementation is the canonical spec). + +### Success Criteria + +- [ ] All types defined in `src/core/types.ts` are referenced from `rules.ts` and exported. +- [ ] Every rule function listed in the **Rules Engine API** section is implemented and unit-tested. +- [ ] **Card conservation invariant**: `sum(player.hand.length) + deck.length + discard.length + pendingAttackCardCount(round.pendingAttack) === 52` at every state transition. +- [ ] **No hidden-info leak**: `createPublicView(state, viewerId?)` strips opponents' hand contents and the deck order. `createPrivateView(state, viewerId)` reveals only the viewer's own hand. +- [ ] **Deterministic shuffling**: `shuffleDeck(deck, 42)` returns byte-identical output on every run; cross-checked against a recorded fixture. +- [ ] **No mutation of input state**: every reducer returns a new `MatchState`. A `freezeDeep` test catches accidental mutation. +- [ ] **Every action returns `{ ok: true, state, events }` or `{ ok: false, error: { code, message } }`.** No throws on validation errors. +- [ ] **Determinism guards**: ESLint configured to forbid `Math.random`, `Date.now`, `new Date()`, `performance.now`, `crypto.getRandomValues` inside `src/core/**` (allowed only inside the seeded shuffle helper, where it never appears anyway). +- [ ] **All Vitest tests below pass** (§"Test cases" in this PRP). +- [ ] **Property-based tests run ≥ 200 iterations** each and report zero invariant violations. +- [ ] **Simulation smoke**: 1000 random-legal-bot games complete in < 30s with 0 conservation violations and 100% reaching `RoundEnded` or `MatchEnded`. + +--- + +## All Needed Context + +### CARD SHED — Rules v2.0 summary (canonical, frozen) + +- **Players & deck.** 3 or 4 players, single 52-card deck. Suits `Clubs(0), Diamonds(1), Hearts(2), Spades(3)`. Ranks `2..10, J(11), Q(12), K(13), A(14)`. +- **Trump.** Bottom card of shuffled deck declared trump. The card itself **stays in the deck** and is drawn normally when its turn comes. +- **Attack.** Attacker sends exactly one of: + - 1 card (any), or + - 3 cards = a pair (same rank) + 1 kicker, or + - 5 cards = two pairs of *different* ranks + 1 kicker. + After sending, attacker refills to 5 from deck if hand < 5 and deck > 0. +- **Defence.** Defender beats received cards in any order: + - Non-trump attack → same-suit higher rank, OR any trump. + - Trump attack → higher trump only. + - Defender may stop at any time. +- **Resolution.** + - **Full defence** (every attack card beaten): all (attack, counter) pairs go to discard, defender refills to 5, **defender immediately becomes attacker** and must send a new attack. + - **Partial / no defence**: unbeaten cards go into defender's hand, defender refills to ≥ 5, **next clockwise player becomes attacker**. +- **No reshuffles.** Once the deck is empty, hands may shrink below 5. +- **Win.** End-of-turn hand size 0 *after* the refill attempt, which is only possible when `deck.length === 0`. +- **New round.** Winner recorded, dealer rotates clockwise, gather + reshuffle (new seed), new bottom-card trump, deal 5 to each. + +### Shared Contract (frozen — byte-identical to PRP 3) + +```ts +// src/core/types.ts — Shared Contract block (do not diverge from PRP 3) +export type Suit = 0 | 1 | 2 | 3; // Clubs, Diamonds, Hearts, Spades +export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14; + +export interface Card { + id: string; // immutable per card instance, e.g. "S-14-7f3a" + suit: Suit; + rank: Rank; +} + +export type TurnState = + | "Dealing" + | "AwaitingAttack" + | "AwaitingDefense" + | "Resolving" + | "RoundEnded" + | "MatchEnded"; + +export type ValidationError = { + code: string; // machine-readable, e.g. "ATTACK_INVALID_COMBO" + message: string; // human-readable + details?: unknown; +}; + +export type ActionResult = + | { ok: true; state: MatchState; events: GameEvent[]; value?: T } + | { ok: false; error: ValidationError }; +``` + +### Rules Engine API (the surface PRP 3 will call) + +```ts +// Pure, deterministic. No I/O, no Math.random outside the seeded shuffle. +createDeck(): Card[] +shuffleDeck(deck: Card[], seed: number): Card[] // seed REQUIRED for determinism +dealInitialHands(deck: Card[], playerCount: 3 | 4): { hands: Card[][]; deck: Card[] } +determineTrumpFromBottomCard(deck: Card[]): Suit +startNewRound(prev: MatchState | null, seed: number): MatchState + +validateAttack(cards: Card[], playerHand: Card[]): { ok: true } | { ok: false; error: ValidationError } +submitAttack(state: MatchState, playerId: string, cardIds: string[]): ActionResult +canBeat(attackCard: Card, counterCard: Card, trump: Suit): boolean +submitBeat(state: MatchState, defenderId: string, attackCardId: string, counterCardId: string): ActionResult +stopDefending(state: MatchState, defenderId: string): ActionResult + +drawToMinimum(hand: Card[], deck: Card[], target: number): { hand: Card[]; deck: Card[] } +checkWin(state: MatchState, playerId: string): boolean + +advanceTurnAfterFullDefense(state: MatchState): MatchState +advanceTurnAfterPartialDefense(state: MatchState): MatchState +rotateDealer(state: MatchState): MatchState + +getLegalActions(state: MatchState, playerId: string): Action[] +createPublicView(state: MatchState, viewerId?: string): PublicGameView +createPrivateView(state: MatchState, viewerId: string): PrivatePlayerView +``` + +### Documentation & References + +```yaml +# MUST READ — frozen design inputs +- file: PRPs/02-deterministic-core.md + why: source of truth for rules, success criteria, test taxonomy, anti-patterns + +- file: PRPs/cardshed-01-blueprint.md + why: locked tech-stack + TurnState vocab + module taxonomy + sections: §1 Executive Summary, §2 Tech Stack, §6 Clean Local Architecture, §9 Cross-PRP Implications + +- file: PRPs/03-experience-distribution.md + why: confirms Shared Contract block and engine API surface + sections: "Shared Contract", "Rules-engine surface", §C "Networking Protocol" (for Card.id wire shape) + +# External — pinned tooling and patterns +- url: https://vitest.dev/guide/ + why: Vitest v2 config + run modes; suite uses `vitest run` (one-shot, CI-friendly) + critical: with type `module` package.json, default config works; no jest globals needed + +- url: https://github.com/dubzzz/fast-check + why: property-based tests for invariants; fast-check ≥ 3.x is the standard JS library + critical: use `fc.assert(fc.property(...), { numRuns: 200 })` for invariant tests + +- url: https://en.wikipedia.org/wiki/Xorshift#xorshift+ + why: background for the chosen PRNG family (mulberry32) — small, deterministic, public-domain + +- url: https://gist.github.com/tommyettinger/46a3c64a2ecfe7c4ce5c + why: canonical mulberry32 reference; passes for our use (shuffle, not crypto) + critical: NOT a CSPRNG; do NOT use for security; perfect for game shuffle + +- url: https://bost.ocks.org/mike/shuffle/ + why: Fisher-Yates shuffle reference; the only correct uniform shuffle for a finite deck + critical: iterate i from length-1 down to 1; swap with random index in [0..i] + +- url: https://github.com/davidbau/seedrandom + why: optional alternative PRNG if mulberry32 fails portability tests; included as fallback note only + +# Codebase references (existing TS conventions — for tsconfig + tooling shape only) +- file: @lab/ll-RELIQUARY/apps/ui/tsconfig.json + why: TS strict-mode baseline used elsewhere in the repo (ES2022, bundler resolution, strict) + critical: mirror these compiler options for the core package; no JSX needed in core/ + +- file: @lab/ll-RELIQUARY/apps/ui/package.json + why: precedent for vitest ^2.1.x + typescript ^5.7.x + "type": "module" + critical: do NOT introduce a NEW major version of vitest or typescript without justification +``` + +### Current codebase tree + +```bash +PRPs/ +├── 01-strategy-blueprint.md # locked +├── 02-deterministic-core.md # THIS PRP'S BRIEF +├── 03-experience-distribution.md # sibling, parallel +├── INDEX.md +├── cardshed-01-blueprint.md # locked output of PRP 1 +└── templates/ + └── prp_base.md + +@lab/ +├── ll-RELIQUARY/ # TS+Vite+Tailwind reference +└── ll-KNOWRAG/ # TS+Vite reference +``` + +There is **no existing `src/core/` directory** in the repo. CARD SHED has not landed any code yet — this PRP creates the first slice. The destination is intentionally left ambiguous in the brief; **proposed location is `@lab/ll-CARDSHED/apps/core/`** (mirroring the `@lab//apps//` convention from RELIQUARY and KNOWRAG). Confirm with operator before the first commit. + +### Desired codebase tree (additions) + +```bash +@lab/ll-CARDSHED/apps/core/ +├── package.json +├── tsconfig.json +├── vitest.config.ts +├── .eslintrc.cjs # forbids Math.random/Date.now in src/core/** +├── src/ +│ └── core/ +│ ├── index.ts # public re-exports +│ ├── types.ts # all data shapes +│ ├── rules.ts # all engine functions +│ ├── prng.ts # mulberry32 + seeded Fisher-Yates +│ └── __tests__/ +│ ├── deck.test.ts +│ ├── shuffle.test.ts +│ ├── attack-validation.test.ts +│ ├── can-beat.test.ts +│ ├── submit-attack.test.ts +│ ├── submit-beat.test.ts +│ ├── stop-defending.test.ts +│ ├── draw-to-minimum.test.ts +│ ├── check-win.test.ts +│ ├── turn-flow.test.ts +│ ├── views.test.ts +│ ├── legal-actions.test.ts +│ ├── conservation.property.test.ts +│ └── integration.test.ts +└── scripts/ + └── sim-smoke.ts # 1000-game random-legal smoke +``` + +### Known Gotchas & Library Quirks + +```ts +// CRITICAL: Vitest v2 with "type": "module" needs vitest.config.ts (ESM), not .js. +// CRITICAL: TypeScript's `readonly` does NOT prevent runtime mutation; use Object.freeze +// or a deep-freeze helper inside tests to catch mutation regressions. +// CRITICAL: Array.prototype.sort is NOT stable across all engines historically; for +// determinism, always use an explicit comparator and stable inputs. +// CRITICAL: JSON.stringify property order is NOT guaranteed across engines for +// non-string keys; do not use stringified state as an equality proxy without sort. +// CRITICAL: Math.random() is BANNED in src/core/**. Use prng.mulberry32(seed) only. +// CRITICAL: Date.now() / new Date() are BANNED in src/core/** — the engine has no +// concept of time. Timestamps belong to PRP 3's event emitter, not the core. +// CRITICAL: structuredClone is available in Node 17+ and all modern browsers; use it +// freely for the immutable-reducer pattern. Falls back to JSON clone in tests +// if needed (but plain Card/MatchState are JSON-safe). +// CRITICAL: fast-check shrinks failing inputs to a minimal counterexample — let it. +// Do NOT add `try { ... } catch {}` around fc.assert; the failure IS the signal. +// CRITICAL: When refilling the deck with drawToMinimum, the brief is explicit: +// NEVER reshuffle the discard pile. Once deck.length === 0, draws stop. +// CRITICAL: checkWin MUST run AFTER refill, not before. A player at 0 cards with deck +// non-empty will be refilled to 5 and is NOT a winner. +// CRITICAL: 5-card attack requires TWO DISTINCT RANKS for the pairs. Four-of-a-kind +// (e.g. 7♣ 7♦ 7♥ 7♠ + kicker) is INVALID despite being two pairs by raw rank. +// This is the most subtle attack rule. +// CRITICAL: Bottom card of the deck (deck[0]) is the trump face. It STAYS in the deck. +// It is drawn last (after deck.pop() has emptied everything above it). +// CRITICAL: The "next clockwise" player after partial defence is left of the DEFENDER, +// not left of the original attacker. Verify with the seat-index lookup helper. +// CRITICAL: PendingAttack.beatenPairs are NOT in discard until stopDefending resolves +// the turn as a full defence. While the turn is in flight, beaten cards live +// inside pendingAttack. (See "Contradictions flagged" #1 — proposed resolution.) +``` + +--- + +## Implementation Blueprint + +### Data models (src/core/types.ts) + +Define, in this order, exporting every symbol: + +```ts +// Shared Contract — frozen +export type Suit = 0 | 1 | 2 | 3; +export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14; +export interface Card { id: string; suit: Suit; rank: Rank; } +export type TurnState = "Dealing" | "AwaitingAttack" | "AwaitingDefense" | "Resolving" | "RoundEnded" | "MatchEnded"; +export type ValidationError = { code: string; message: string; details?: unknown }; +export type ActionResult = + | { ok: true; state: MatchState; events: GameEvent[]; value?: T } + | { ok: false; error: ValidationError }; + +// Domain entities +export interface Player { + id: string; + name: string; + hand: Card[]; + score: number; + isActive: boolean; + seatIndex: number; // 0..playerCount-1, fixed for the match +} + +export interface BeatenPair { attack: Card; counter: Card; } + +export interface PendingAttack { + attackerId: string; + defenderId: string; + unbeatenCards: Card[]; // attack cards not yet beaten + beatenPairs: BeatenPair[]; // beaten this turn; flushed to discard on stop +} + +export interface RoundState { + trump: Suit; + dealerId: string; + attackerId: string; + defenderId: string | null; + phase: TurnState; + pendingAttack: PendingAttack | null; +} + +export interface MatchState { + matchId: string; + roundNumber: number; + players: Player[]; // ordered by seatIndex + deck: Card[]; // deck[0] = bottom (trump face); deck[length-1] = top (drawn next) + discard: Card[]; // face-down; opponents see only count + round: RoundState; + winner: string | null; // playerId or null + rngSeed: number; // seed used for the current round's shuffle +} + +// Action union — discriminated on `kind` +export type Action = + | { kind: "Attack"; playerId: string; cardIds: string[] } + | { kind: "Beat"; playerId: string; attackCardId: string; counterCardId: string } + | { kind: "Stop"; playerId: string }; + +// GameEvent union — append-only audit / animation trail +export type GameEvent = + | { type: "RoundStarted"; roundNumber: number; dealerId: string; trump: Suit } + | { type: "TrumpSelected"; trump: Suit; bottomCardId: string } + | { type: "CardsDealt"; perPlayer: { playerId: string; count: number }[] } + | { type: "AttackSubmitted"; attackerId: string; defenderId: string; cardIds: string[] } + | { type: "CardBeaten"; defenderId: string; attackCardId: string; counterCardId: string } + | { type: "DefenseStopped"; defenderId: string; takenCardIds: string[] } + | { type: "FullDefense"; defenderId: string; discardedPairs: number } + | { type: "CardsDrawn"; playerId: string; count: number } + | { type: "TurnChanged"; nextAttackerId: string; phase: TurnState } + | { type: "RoundWon"; winnerId: string } + | { type: "MatchEnded"; winnerId: string }; + +// View projections — used by PRP 3 for UI + network +export interface HandSummary { + ownerId: string; + count: number; + publiclyKnown: Card[]; // cards opponents must know per rules (currently empty in v2.0) +} + +export interface PublicPlayerInfo { + id: string; + name: string; + seatIndex: number; + score: number; + isActive: boolean; + hand: HandSummary; +} + +export interface PublicGameView { + matchId: string; + roundNumber: number; + players: PublicPlayerInfo[]; + deckCount: number; + discardCount: number; + round: { + trump: Suit; + dealerId: string; + attackerId: string; + defenderId: string | null; + phase: TurnState; + pendingAttack: { + attackerId: string; + defenderId: string; + unbeatenCards: Card[]; // public — visible on the table + beatenPairs: BeatenPair[]; // public — visible on the table + } | null; + }; + winner: string | null; +} + +export interface PrivatePlayerView extends PublicGameView { + viewerId: string; + viewerHand: Card[]; // full reveal of viewer's own hand +} +``` + +### Per-function pseudocode (rules.ts) + +```ts +// ─── prng.ts ──────────────────────────────────────────────────────────── +// CRITICAL: mulberry32 is a 32-bit PRNG; period 2^32, perfect for shuffle. +// CRITICAL: NEVER expose a function in core/ that calls Math.random. +export function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// ─── createDeck ───────────────────────────────────────────────────────── +// Returns 52 unique cards. Order is canonical (suit-major, rank-ascending). +// `id` is "{suitLetter}-{rank}-{indexHex}", deterministic and unique. +function createDeck(): Card[] { + const suits: Suit[] = [0, 1, 2, 3]; + const letters = ["C", "D", "H", "S"]; + const out: Card[] = []; + for (const s of suits) { + for (let r = 2 as Rank; r <= 14; r++) { + const idx = s * 13 + (r - 2); + out.push({ id: `${letters[s]}-${r}-${idx.toString(16).padStart(2, "0")}`, suit: s, rank: r as Rank }); + } + } + return out; +} + +// ─── shuffleDeck ──────────────────────────────────────────────────────── +// PATTERN: seeded Fisher-Yates on a COPY of input. Input is never mutated. +function shuffleDeck(deck: Card[], seed: number): Card[] { + const out = deck.slice(); + const rand = mulberry32(seed); + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} + +// ─── determineTrumpFromBottomCard ─────────────────────────────────────── +// CRITICAL: bottom = deck[0]. The card is NOT removed. Trump suit = its suit. +function determineTrumpFromBottomCard(deck: Card[]): Suit { return deck[0].suit; } + +// ─── dealInitialHands ─────────────────────────────────────────────────── +// Deals 5 cards to each player by popping from the TOP (deck.pop()). +// Bottom card (trump face) remains and will be drawn last. +function dealInitialHands(deck: Card[], playerCount: 3 | 4): { hands: Card[][]; deck: Card[] } { + const remaining = deck.slice(); + const hands: Card[][] = Array.from({ length: playerCount }, () => []); + for (let round = 0; round < 5; round++) { + for (let p = 0; p < playerCount; p++) { + const c = remaining.pop(); + if (!c) throw new Error("INVARIANT: deck exhausted during initial deal"); + hands[p].push(c); + } + } + return { hands, deck: remaining }; +} + +// ─── validateAttack ───────────────────────────────────────────────────── +function validateAttack(cards: Card[], hand: Card[]): { ok: true } | { ok: false; error: ValidationError } { + if (![1, 3, 5].includes(cards.length)) return err("ATTACK_INVALID_SIZE", `Attack must be 1/3/5 cards, got ${cards.length}`); + const seenIds = new Set(); + for (const c of cards) { + if (seenIds.has(c.id)) return err("ATTACK_DUPLICATE_CARD", `Duplicate card ${c.id}`); + seenIds.add(c.id); + if (!hand.find((h) => h.id === c.id)) return err("ATTACK_CARD_NOT_OWNED", `Card ${c.id} not in hand`); + } + if (cards.length === 1) return { ok: true }; + // group by rank + const byRank = new Map(); + for (const c of cards) byRank.set(c.rank, (byRank.get(c.rank) ?? 0) + 1); + const pairs = [...byRank.entries()].filter(([, n]) => n >= 2); + if (cards.length === 3) { + if (pairs.length === 0) return err("ATTACK_NO_PAIR", "3-card attack needs a pair"); + return { ok: true }; + } + // cards.length === 5 + const distinctPairRanks = pairs.length; // # of DISTINCT ranks with count ≥ 2 + if (distinctPairRanks < 2) return err("ATTACK_NO_TWO_PAIRS", "5-card attack needs two pairs of DISTINCT ranks"); + return { ok: true }; +} + +// ─── canBeat ──────────────────────────────────────────────────────────── +function canBeat(attack: Card, counter: Card, trump: Suit): boolean { + if (attack.suit === trump) return counter.suit === trump && counter.rank > attack.rank; + return (counter.suit === attack.suit && counter.rank > attack.rank) || counter.suit === trump; +} + +// ─── submitAttack ─────────────────────────────────────────────────────── +// PATTERN: validate → immutable update → emit events → advance phase. +function submitAttack(state: MatchState, playerId: string, cardIds: string[]): ActionResult { + if (state.round.phase !== "AwaitingAttack") return errResult("PHASE_NOT_AWAITING_ATTACK", `Phase is ${state.round.phase}`); + if (state.round.attackerId !== playerId) return errResult("NOT_YOUR_TURN", `${playerId} is not the attacker`); + const attacker = findPlayer(state, playerId); + const cards: Card[] = []; + for (const cid of cardIds) { + const card = attacker.hand.find((c) => c.id === cid); + if (!card) return errResult("ATTACK_CARD_NOT_OWNED", `Card ${cid} not in hand`); + cards.push(card); + } + const v = validateAttack(cards, attacker.hand); + if (!v.ok) return { ok: false, error: v.error }; + + // Immutable update + const next = clone(state); + const nAttacker = findPlayer(next, playerId); + nAttacker.hand = nAttacker.hand.filter((c) => !cardIds.includes(c.id)); + const defenderId = leftOf(next, playerId); + next.round.pendingAttack = { attackerId: playerId, defenderId, unbeatenCards: cards, beatenPairs: [] }; + + // Refill attacker BEFORE win check + const refill = drawToMinimum(nAttacker.hand, next.deck, 5); + const drawn = refill.hand.length - nAttacker.hand.length; + nAttacker.hand = refill.hand; + next.deck = refill.deck; + + const events: GameEvent[] = [ + { type: "AttackSubmitted", attackerId: playerId, defenderId, cardIds }, + ...(drawn > 0 ? [{ type: "CardsDrawn", playerId, count: drawn } as GameEvent] : []), + ]; + + if (checkWin(next, playerId)) { + next.round.phase = "RoundEnded"; + next.winner = playerId; + events.push({ type: "RoundWon", winnerId: playerId }); + return { ok: true, state: next, events }; + } + + next.round.phase = "AwaitingDefense"; + next.round.defenderId = defenderId; + events.push({ type: "TurnChanged", nextAttackerId: playerId, phase: next.round.phase }); + return { ok: true, state: next, events }; +} + +// ─── submitBeat ───────────────────────────────────────────────────────── +function submitBeat(state: MatchState, defenderId: string, attackCardId: string, counterCardId: string): ActionResult { + if (state.round.phase !== "AwaitingDefense" && state.round.phase !== "Resolving") + return errResult("PHASE_NOT_DEFENSE", `Phase is ${state.round.phase}`); + if (state.round.defenderId !== defenderId) return errResult("NOT_YOUR_TURN", `${defenderId} is not the defender`); + const pa = state.round.pendingAttack; + if (!pa) return errResult("NO_PENDING_ATTACK", "No pending attack to beat"); + const attack = pa.unbeatenCards.find((c) => c.id === attackCardId); + if (!attack) return errResult("BEAT_TARGET_NOT_FOUND", `${attackCardId} is not an unbeaten attack card`); + const defender = findPlayer(state, defenderId); + const counter = defender.hand.find((c) => c.id === counterCardId); + if (!counter) return errResult("BEAT_CARD_NOT_OWNED", `${counterCardId} not in defender's hand`); + if (!canBeat(attack, counter, state.round.trump)) return errResult("BEAT_ILLEGAL", `${counterCardId} cannot beat ${attackCardId}`); + + const next = clone(state); + const nDefender = findPlayer(next, defenderId); + nDefender.hand = nDefender.hand.filter((c) => c.id !== counterCardId); + const npa = next.round.pendingAttack!; + npa.unbeatenCards = npa.unbeatenCards.filter((c) => c.id !== attackCardId); + npa.beatenPairs.push({ attack, counter }); + next.round.phase = npa.unbeatenCards.length === 0 ? "Resolving" : "AwaitingDefense"; + + return { + ok: true, + state: next, + events: [{ type: "CardBeaten", defenderId, attackCardId, counterCardId }], + }; +} + +// ─── stopDefending ────────────────────────────────────────────────────── +function stopDefending(state: MatchState, defenderId: string): ActionResult { + if (state.round.phase !== "AwaitingDefense" && state.round.phase !== "Resolving") + return errResult("PHASE_NOT_DEFENSE", `Phase is ${state.round.phase}`); + if (state.round.defenderId !== defenderId) return errResult("NOT_YOUR_TURN", `${defenderId} is not the defender`); + const pa = state.round.pendingAttack; + if (!pa) return errResult("NO_PENDING_ATTACK", "No pending attack to stop"); + + const next = clone(state); + const nDefender = findPlayer(next, defenderId); + const npa = next.round.pendingAttack!; + const events: GameEvent[] = []; + + if (npa.unbeatenCards.length === 0) { + // FULL DEFENCE: flush all beaten pairs to discard, defender becomes attacker. + for (const { attack, counter } of npa.beatenPairs) next.discard.push(attack, counter); + events.push({ type: "FullDefense", defenderId, discardedPairs: npa.beatenPairs.length }); + const refill = drawToMinimum(nDefender.hand, next.deck, 5); + const drawn = refill.hand.length - nDefender.hand.length; + nDefender.hand = refill.hand; + next.deck = refill.deck; + if (drawn > 0) events.push({ type: "CardsDrawn", playerId: defenderId, count: drawn }); + + // Win-check after refill (PRP-flag #3) + if (checkWin(next, defenderId)) { + next.round.phase = "RoundEnded"; + next.winner = defenderId; + next.round.pendingAttack = null; + events.push({ type: "RoundWon", winnerId: defenderId }); + return { ok: true, state: next, events }; + } + + next.round.attackerId = defenderId; + next.round.defenderId = null; + next.round.pendingAttack = null; + next.round.phase = "AwaitingAttack"; + events.push({ type: "TurnChanged", nextAttackerId: defenderId, phase: "AwaitingAttack" }); + return { ok: true, state: next, events }; + } + + // PARTIAL / NO DEFENCE: defender takes unbeaten cards; beaten pairs ALSO go to discard + // (they were beaten, after all — the pile of cards in the middle is the discard). + const taken = npa.unbeatenCards.slice(); + for (const c of taken) nDefender.hand.push(c); + for (const { attack, counter } of npa.beatenPairs) next.discard.push(attack, counter); + events.push({ type: "DefenseStopped", defenderId, takenCardIds: taken.map((c) => c.id) }); + + const refill = drawToMinimum(nDefender.hand, next.deck, 5); + const drawn = refill.hand.length - nDefender.hand.length; + nDefender.hand = refill.hand; + next.deck = refill.deck; + if (drawn > 0) events.push({ type: "CardsDrawn", playerId: defenderId, count: drawn }); + + // Win-check on defender after partial-defence refill (PRP-flag #3) + if (checkWin(next, defenderId)) { + next.round.phase = "RoundEnded"; + next.winner = defenderId; + next.round.pendingAttack = null; + events.push({ type: "RoundWon", winnerId: defenderId }); + return { ok: true, state: next, events }; + } + + const nextAttacker = leftOf(next, defenderId); + next.round.attackerId = nextAttacker; + next.round.defenderId = null; + next.round.pendingAttack = null; + next.round.phase = "AwaitingAttack"; + events.push({ type: "TurnChanged", nextAttackerId: nextAttacker, phase: "AwaitingAttack" }); + return { ok: true, state: next, events }; +} + +// ─── drawToMinimum ────────────────────────────────────────────────────── +// CRITICAL: pops from the END of deck (top). Stops when deck empty. +function drawToMinimum(hand: Card[], deck: Card[], target: number): { hand: Card[]; deck: Card[] } { + const h = hand.slice(); + const d = deck.slice(); + while (h.length < target && d.length > 0) { + const c = d.pop()!; + h.push(c); + } + return { hand: h, deck: d }; +} + +// ─── checkWin ─────────────────────────────────────────────────────────── +function checkWin(state: MatchState, playerId: string): boolean { + const p = findPlayer(state, playerId); + return state.deck.length === 0 && p.hand.length === 0; +} + +// ─── getLegalActions ──────────────────────────────────────────────────── +// Used by bots and by the UI's "highlight legal cards" affordance. +// CRITICAL: enumerates all *minimal* attack combinations; full enumeration +// of 5-card combos can be exponential — cap at the player's hand only. +function getLegalActions(state: MatchState, playerId: string): Action[] { /* see Implementation Plan task T11 */ } + +// ─── views ────────────────────────────────────────────────────────────── +function createPublicView(state: MatchState, _viewerId?: string): PublicGameView { + return { + matchId: state.matchId, + roundNumber: state.roundNumber, + players: state.players.map((p) => ({ + id: p.id, name: p.name, seatIndex: p.seatIndex, score: p.score, isActive: p.isActive, + hand: { ownerId: p.id, count: p.hand.length, publiclyKnown: [] }, + })), + deckCount: state.deck.length, + discardCount: state.discard.length, + round: { + trump: state.round.trump, + dealerId: state.round.dealerId, + attackerId: state.round.attackerId, + defenderId: state.round.defenderId, + phase: state.round.phase, + pendingAttack: state.round.pendingAttack + ? { + attackerId: state.round.pendingAttack.attackerId, + defenderId: state.round.pendingAttack.defenderId, + unbeatenCards: state.round.pendingAttack.unbeatenCards.slice(), + beatenPairs: state.round.pendingAttack.beatenPairs.slice(), + } + : null, + }, + winner: state.winner, + }; +} + +function createPrivateView(state: MatchState, viewerId: string): PrivatePlayerView { + const pub = createPublicView(state, viewerId); + const viewer = findPlayer(state, viewerId); + return { ...pub, viewerId, viewerHand: viewer.hand.slice() }; +} + +// ─── helpers ──────────────────────────────────────────────────────────── +function findPlayer(state: MatchState, id: string): Player { /* throw on missing — invariant */ } +function leftOf(state: MatchState, id: string): string { /* (seatIndex + 1) mod playerCount */ } +function clone(x: T): T { return structuredClone(x); } +function err(code: string, message: string): { ok: false; error: ValidationError } { return { ok: false, error: { code, message } }; } +function errResult(code: string, message: string): ActionResult { return { ok: false, error: { code, message } }; } +export function pendingAttackCardCount(pa: PendingAttack | null): number { + if (!pa) return 0; + return pa.unbeatenCards.length + 2 * pa.beatenPairs.length; +} +``` + +### List of tasks (in order) + +```yaml +T1 — SCAFFOLD package: + CREATE @lab/ll-CARDSHED/apps/core/package.json + - name: "@cardshed/core" + - type: "module" + - scripts: { build, typecheck, lint, test } + - devDependencies: typescript ^5.7, vitest ^2.1, eslint ^9, @typescript-eslint/* ^8, fast-check ^3.23, tsx ^4 + CREATE @lab/ll-CARDSHED/apps/core/tsconfig.json + - MIRROR pattern from: @lab/ll-RELIQUARY/apps/ui/tsconfig.json + - REMOVE jsx setting (no React in core) + - REMOVE DOM lib (Node-only) + CREATE @lab/ll-CARDSHED/apps/core/vitest.config.ts + - includeSource: src/**/*.ts + - test.include: src/**/__tests__/**/*.test.ts + CREATE @lab/ll-CARDSHED/apps/core/.eslintrc.cjs + - rule: "no-restricted-syntax" forbidding Math.random, Date.now, new Date(), performance.now, crypto.getRandomValues in src/core/** + +T2 — TYPES first: + CREATE src/core/types.ts + - EXPORT every symbol listed under "Data models" above + - VERIFY: tsc --noEmit passes with zero errors + +T3 — PRNG: + CREATE src/core/prng.ts + - EXPORT mulberry32(seed): () => number + - EXPORT shuffleInPlaceCopy(arr, seed): T[] (used by shuffleDeck) + +T4 — RULES skeleton: + CREATE src/core/rules.ts + - IMPORT all types from ./types + - IMPORT mulberry32 from ./prng + - DECLARE all function signatures from the Rules Engine API + - LEAVE bodies as `throw new Error("TODO")` to keep tsc green + +T5 — Implement createDeck + shuffleDeck + determineTrumpFromBottomCard: + - createDeck returns 52 unique cards in canonical order + - shuffleDeck uses mulberry32 + Fisher-Yates on a COPY + - determineTrumpFromBottomCard reads deck[0].suit + +T6 — Implement dealInitialHands + drawToMinimum: + - dealInitialHands pops 5 per player from top + - drawToMinimum stops at deck empty (no reshuffle) + +T7 — Implement validateAttack + canBeat: + - validateAttack covers all 9 listed error cases + - canBeat handles trump-vs-trump, non-trump-vs-non-trump, mixed + +T8 — Implement submitAttack: + - validate → clone → mutate clone → emit events → check win → advance phase + - PROPAGATE validation errors via ActionResult { ok: false } + +T9 — Implement submitBeat + stopDefending: + - submitBeat moves attack from unbeatenCards into beatenPairs + - stopDefending branches on unbeatenCards.length === 0 + - FULL DEFENCE: flush pairs to discard, refill defender, checkWin, defender becomes attacker + - PARTIAL: take unbeaten into hand, flush pairs to discard, refill, checkWin, rotate to leftOf(defender) + +T10 — Implement startNewRound + rotateDealer + advanceTurn* helpers: + - startNewRound(null, seed) creates fresh MatchState (caller supplies player roster via a thin overload or a separate setup step) + - startNewRound(prev, seed) rotates dealer + reshuffles + redeals + new trump + - advanceTurnAfterFullDefense / advanceTurnAfterPartialDefense are pure helpers around stopDefending's branches + +T11 — Implement getLegalActions: + - If phase === AwaitingAttack and playerId === attackerId: + - emit one Attack action per legal singleton (every card in hand) + - emit one Attack action per (pair-rank, kicker-card) combination + - emit one Attack action per (pair1-rank, pair2-rank, kicker-card) combination with pair1 != pair2 + - If phase === AwaitingDefense and playerId === defenderId: + - for each unbeaten attack card, emit one Beat action per legal counter in hand + - emit one Stop action + - Else: empty array + - CAP combinatorial blow-up by deduplicating by card-id-set + +T12 — Implement createPublicView + createPrivateView: + - PublicGameView strips hand contents (only count) + - PrivateView extends Public + reveals viewer's hand + - Both are pure projections; mutating the view MUST NOT affect state (use .slice() on arrays) + +T13 — Helper utilities: + - findPlayer (throws on missing — invariant violation) + - leftOf (next clockwise by seatIndex) + - pendingAttackCardCount + - clone (structuredClone wrapper) + +T14 — TESTS — deck + shuffle: + CREATE __tests__/deck.test.ts + - 52 unique cards, 4 suits × 13 ranks + - ids unique + CREATE __tests__/shuffle.test.ts + - shuffleDeck(d, 42) is reproducible (compare two runs) + - shuffleDeck(d, 42) !== shuffleDeck(d, 43) + - shuffleDeck preserves multiset (same 52 cards) + - input deck is NOT mutated (frozen check) + +T15 — TESTS — attack validation (all 9 cases from brief): + CREATE __tests__/attack-validation.test.ts + +T16 — TESTS — canBeat (all 8 cases from brief): + CREATE __tests__/can-beat.test.ts + +T17 — TESTS — submitAttack, submitBeat, stopDefending: + CREATE __tests__/submit-attack.test.ts + CREATE __tests__/submit-beat.test.ts + CREATE __tests__/stop-defending.test.ts + - cover full-defence counterattack + - cover partial-defence rotation to leftOf(defender) + - cover win on partial-defence with deck empty + 0 hand + +T18 — TESTS — turn flow + endgame: + CREATE __tests__/turn-flow.test.ts + CREATE __tests__/check-win.test.ts + - win only fires when deck.length === 0 + - dealer rotates clockwise across rounds + +T19 — TESTS — views: + CREATE __tests__/views.test.ts + - createPublicView has no Card[] in opponents' hand + - createPrivateView reveals only viewer's hand + - mutating the returned view does not mutate state + +T20 — TESTS — legal-actions: + CREATE __tests__/legal-actions.test.ts + - Stop is included whenever defender is awaiting + - every returned Attack passes validateAttack + +T21 — TESTS — property-based conservation: + CREATE __tests__/conservation.property.test.ts + - fast-check: for any sequence of legal actions, conservation holds + - fast-check: no card duplication across (hands ∪ deck ∪ discard ∪ pendingAttack) + - fast-check: hidden hands absent from createPublicView for non-viewers + - fast-check: 1000 random legal sequences terminate + +T22 — TESTS — integration: + CREATE __tests__/integration.test.ts + - scripted 3-player round to RoundEnded + - scripted 4-player round to RoundEnded + - full-defence → counterattack → wraps correctly + - reconnection snapshot: createPrivateView is sufficient to resume + +T23 — Simulation smoke: + CREATE scripts/sim-smoke.ts + - run 1000 random-legal-bot games + - assert 0 conservation violations + - assert 100% reach RoundEnded + - measure mean turns/round (sanity, not balance) + - exit non-zero on failure + +T24 — Lint + type-check + test gate: + - npm run typecheck (npx tsc --noEmit) → 0 errors + - npm run lint (npx eslint src/) → 0 errors, 0 warnings + - npm test (npx vitest run) → all green + - npx tsx scripts/sim-smoke.ts → 0 violations +``` + +### Integration Points + +```yaml +PACKAGE: + - location proposal: "@lab/ll-CARDSHED/apps/core" + - name: "@cardshed/core" + - exports: src/core/index.ts (re-exports rules.ts + types.ts) + - consumers: PRP 3's UI, AI, and simulation modules import from "@cardshed/core" + +CONFIG: + - tsconfig: strict, ES2022, bundler resolution, no DOM lib + - eslint: forbid Math.random, Date.now, new Date, performance.now in src/core/** + - vitest: includeSource for __tests__; reporters: default + junit (optional CI) + +NO OTHER INTEGRATION POINTS: + - No DATABASE — core is pure + - No ROUTES — core is pure + - No NETWORK — core is pure +``` + +--- + +## Validation Loop + +### Level 1: Syntax & Style + +```bash +cd @lab/ll-CARDSHED/apps/core +npm install +npx tsc --noEmit +npx eslint src/ + +# Expected: 0 errors, 0 warnings. If errors, READ the message and fix at the source. +# The ESLint config forbids Math.random / Date.now / new Date / performance.now +# inside src/core/** — any violation is a determinism bug, not a style nit. +``` + +### Level 2: Unit + Property Tests + +```bash +cd @lab/ll-CARDSHED/apps/core +npx vitest run src/core + +# Expected: every test in §"Test cases" below passes. +# Property-based tests run with { numRuns: 200 } minimum. +# A single failure on a property test produces a shrunken counterexample — record it, +# fix the bug, re-run; never widen the property to make red green. +``` + +### Level 3: Simulation Smoke + +```bash +cd @lab/ll-CARDSHED/apps/core +npx tsx scripts/sim-smoke.ts + +# Acceptance: +# - completes in < 30 seconds on a developer laptop +# - reports 0 conservation violations +# - reports 100% of games reaching RoundEnded +# - prints mean / p50 / p95 turns per round (sanity only; no thresholds) +# Failure modes: +# - hang → infinite-loop in turn rotation; suspect leftOf or phase advance +# - conservation drift → suspect stopDefending's discard accounting +# - 0% RoundEnded → suspect checkWin never firing; verify deck exhaustion path +``` + +### Test cases (lifted from brief §"Test cases" — every one must be a Vitest `it()`) + +**Deck & shuffle (`deck.test.ts`, `shuffle.test.ts`)** +- `createDeck` returns 52 unique cards (4 × 13). +- `shuffleDeck(d, 42)` is reproducible for the same seed. +- `shuffleDeck(d, 42)` differs from `shuffleDeck(d, 43)`. +- Bottom card (`deck[0]`) determines trump. +- Bottom trump card remains drawable (drawn last; no special exclusion). +- `shuffleDeck` does not mutate its input. + +**Attack validation (`attack-validation.test.ts`)** +- 1-card attack → valid. +- 3-card with pair + kicker → valid. +- 5-card with two distinct pairs + kicker → valid. +- 0 / 2 / 4 / 6 card attack → `ATTACK_INVALID_SIZE`. +- 3-card no pair → `ATTACK_NO_PAIR`. +- 5-card with only one pair (e.g. trips + 2 unrelated) → `ATTACK_NO_TWO_PAIRS`. +- 5-card with same-rank quad as both pairs → `ATTACK_NO_TWO_PAIRS` (two **distinct** ranks required). +- Cards not in hand → `ATTACK_CARD_NOT_OWNED`. +- Duplicate card IDs → `ATTACK_DUPLICATE_CARD`. + +**Defence (`can-beat.test.ts`)** +- Same suit, higher rank, non-trump attack → true. +- Same suit, lower rank, non-trump attack → false. +- Same suit, equal rank → false. +- Different non-trump suit (counter is non-trump) → false. +- Trump vs non-trump attack → true. +- Higher trump vs trump attack → true. +- Lower trump vs trump attack → false. +- Non-trump (different non-trump suit) vs trump attack → false. + +**Turn flow (`submit-attack.test.ts`, `submit-beat.test.ts`, `stop-defending.test.ts`, `turn-flow.test.ts`)** +- Attacker sends 3-card, refills to 5, phase becomes `AwaitingDefense`. +- Defender beats one of three cards, stops → 2 unbeaten cards added to hand, defender refills to ≥ 5, next clockwise player becomes attacker. +- Defender beats all (full defence) → discards all, refills to 5, defender becomes attacker, must send next attack. +- Deck exhaustion: `drawToMinimum` stops, hand may end below 5. +- Win triggers only when end-of-turn hand size is 0 AND deck is empty. +- Dealer rotates clockwise on new round (`rotateDealer`). +- `submitAttack` rejects when `phase !== AwaitingAttack`. +- `submitAttack` rejects when caller is not the current attacker. +- `submitBeat` rejects when `phase` is neither `AwaitingDefense` nor `Resolving`. +- `submitBeat` rejects when `counter` cannot beat `attack`. + +**Views (`views.test.ts`)** +- `createPublicView` has no `Card[]` in opponents' `hand` (only count). +- `createPublicView` has `deckCount` and `discardCount` (no order leak). +- `createPrivateView` reveals only the viewer's own hand. +- Mutating the returned `view.players[0].hand` does NOT mutate `state`. + +**Legal actions (`legal-actions.test.ts`)** +- `getLegalActions` returns `Stop` whenever defender is awaiting. +- Every returned `Attack` action's `cardIds` passes `validateAttack`. +- Every returned `Beat` action satisfies `canBeat(attack, counter, trump)`. +- A wrong-phase or wrong-player call returns `[]`. + +**Property-based (`conservation.property.test.ts`, ≥ 200 iters)** +- Card conservation: `Σ player.hand.length + deck.length + discard.length + pendingAttackCardCount(round.pendingAttack) === 52` across every legal action. +- No card duplication: the union of all card-locations contains exactly 52 distinct ids. +- Hidden hands: for any non-viewer, `createPublicView` omits their hand contents. +- Turn index validity: `attackerId` and `defenderId` always reference a real player. +- Termination: under random legal actions, every game reaches `RoundEnded` within a bounded number of turns (e.g. ≤ 500). + +**Integration (`integration.test.ts`)** +- Complete 3-player round runs to `RoundEnded` under scripted actions. +- Complete 4-player round runs to `RoundEnded` under scripted actions. +- Full defence → counterattack → wraps correctly (defender's hand size, deck count, discard count all balance). +- Reconnection snapshot: `createPrivateView(state, viewerId)` is sufficient to resume — i.e. it includes everything the UI needs to render the current legal moves. + +## Final Validation Checklist + +- [ ] All tests pass: `npx vitest run src/core` +- [ ] No type errors: `npx tsc --noEmit` +- [ ] No lint errors: `npx eslint src/` (the no-`Math.random` rule must be active and passing) +- [ ] Simulation smoke passes: `npx tsx scripts/sim-smoke.ts` (1000 games, < 30s, 0 violations) +- [ ] Conservation invariant verified in property test (≥ 200 iters) +- [ ] `createPublicView` leaks no hidden hands (covered by `views.test.ts` and the property test) +- [ ] `shuffleDeck(d, 42)` returns the recorded fixture (deterministic across runs) +- [ ] No `Math.random` in `src/core/**` (`grep -R "Math.random" src/core` is empty) +- [ ] No `Date.now` / `new Date()` / `performance.now` in `src/core/**` +- [ ] No mutation of input `MatchState` (every reducer returns a fresh object; covered by an `Object.freeze` test wrapper) +- [ ] Every action either returns `{ ok: true, state, events }` or `{ ok: false, error }` — `grep -R "throw " src/core/rules.ts` returns only invariant-violation throws (`INVARIANT:` prefix) +- [ ] `TurnState` vocabulary matches the locked Shared Contract verbatim (no extra or renamed members) +- [ ] Shared Contract block in `types.ts` is byte-identical to PRP 3's +- [ ] All cross-PRP contradictions flagged at the top of this PRP have been reviewed by a human + +--- + +## Anti-Patterns to Avoid (pure-logic core specific) + +- ❌ **Throwing on validation errors.** Return `{ ok: false, error }`. Throws are reserved for INVARIANT violations (e.g. "deck exhausted during initial deal" — that's a programmer bug, not user input). +- ❌ **Mutating `state` in place.** Every reducer returns a fresh `MatchState` via `structuredClone` (or Immer if introduced — but `structuredClone` is enough). +- ❌ **Calling `Math.random()` anywhere outside `prng.ts`.** Bots, tests, and shuffles all go through `mulberry32(seed)`. ESLint enforces this. +- ❌ **Leaking hidden hands into `createPublicView`.** Opponent `hand` MUST be a count, not a `Card[]`. +- ❌ **Reshuffling the discard pile back into the deck.** Explicitly forbidden by Rules v2.0. +- ❌ **Inventing new rule variants.** CARD SHED v2.0 is frozen. Variant exploration belongs to PRP 3's simulation harness, behind an explicit `RuleVariant` flag — and is NOT this PRP's scope. +- ❌ **Allowing a win check before the refill attempt has run.** `checkWin` is always called *after* `drawToMinimum`. This is a one-line bug that costs hours to debug. +- ❌ **Coupling to UI, networking, analytics, or time.** Those live in PRP 3. The core does not import React, fetch, ws, console, or Date. +- ❌ **Diverging the Shared Contract from PRP 3.** Any change here requires a coordinated change to `PRPs/03-experience-distribution.md`. +- ❌ **In-place Fisher-Yates on the caller's deck.** Always shuffle a `.slice()`. Reducers do not mutate inputs. +- ❌ **Parsing `Card.id` to extract suit/rank.** The id is opaque; always read `card.suit` / `card.rank`. +- ❌ **Time-dependent behavior.** No timeouts, no `setTimeout`, no "default if undefined" branches that touch wall-clock time. +- ❌ **Hidden global state.** No module-level mutable variables in `src/core/**`. The engine is referentially transparent given `(state, action)`. + +--- + +## Self-Scored Confidence + +**8 / 10 for one-pass implementation success.** + +Why 8 and not 10: +- The brief is unusually complete (frozen rules, frozen types, explicit pseudocode, explicit test list), which is what an 8 looks like — almost everything is decided. +- Two points withheld for the three real risks: (a) the `beatenPairs` custody ambiguity flagged at the top — if the human review resolves it differently from the proposed answer, the `stopDefending` + conservation property test both need a small refactor; (b) the partial-defence win-check edge case is not in the brief and may not match PRP 3's expectation if PRP 3 assumes win only fires on attacker turns; (c) `getLegalActions` for 5-card attacks has exponential potential and the brief is silent on whether to cap or deduplicate — the proposed plan caps via id-set dedup, which is defensible but not validated against a bot consumer yet. + +Confidence rises to 9 if a human pre-resolves the three contradictions before execution starts.