diff --git a/docs/superpowers/contracts/kcd2-dice-solver-initial-commit-2026-07-21.json b/docs/superpowers/contracts/kcd2-dice-solver-initial-commit-2026-07-21.json new file mode 100644 index 00000000..57126ec7 --- /dev/null +++ b/docs/superpowers/contracts/kcd2-dice-solver-initial-commit-2026-07-21.json @@ -0,0 +1,15 @@ +{ + "title": "Commit the KCD2 Dice Solver app and add run.sh", + "objective": "Bring the previously-untracked kcd2_dice_solver Vite/React/TypeScript app under git version control, and add a run.sh that installs its dependencies and starts its dev server with a single command.", + "acceptance_criteria": [ + "kcd2_dice_solver/ source, tests, and config files are tracked in git (node_modules/dist/coverage remain ignored)", + "run.sh ensures Node >=22 and pnpm are available, runs `pnpm install`, then starts `pnpm dev`", + "run.sh is executable, shellcheck-clean, and verified to serve HTTP 200 on http://localhost:5174", + "pre-commit passes on all staged files" + ], + "out_of_scope": [ + "No changes to the solver's game logic, scoring, or UI behavior", + "No CI workflow added for this app" + ], + "verifier": "pre-commit run --files ; manual run of ./run.sh with curl against http://localhost:5174" +} diff --git a/docs/superpowers/evidence/kcd2-dice-solver-run-sh-2026-07-21.json b/docs/superpowers/evidence/kcd2-dice-solver-run-sh-2026-07-21.json new file mode 100644 index 00000000..e6dcc1b9 --- /dev/null +++ b/docs/superpowers/evidence/kcd2-dice-solver-run-sh-2026-07-21.json @@ -0,0 +1,32 @@ +{ + "intent": "Add a run.sh to kcd2_dice_solver that installs dependencies and starts the dice solver's dev server with a single command.", + "scope": ["kcd2_dice_solver/run.sh (new file)", "No changes to app source, tests, or build config"], + "changes": [ + "run.sh ensures Node >=22 (installing the LTS via nvm if missing/too old) and pnpm (via corepack) if missing", + "run.sh runs `pnpm install` then `exec pnpm dev` from the script's own directory" + ], + "verification": [ + { + "command": "shellcheck kcd2_dice_solver/run.sh", + "result": "pass", + "evidence": "No warnings after fixing one SC2155 (declare/assign separately)" + }, + { + "command": "./run.sh (foreground, timeout 25s)", + "result": "pass", + "evidence": "Installed deps, then 'VITE v6.4.3 ready in 132 ms' / Local: http://localhost:5174/" + }, + { + "command": "./run.sh (background) + curl -s -o /dev/null -w '%{http_code}' http://localhost:5174/", + "result": "pass", + "evidence": "HTTP 200" + }, + { + "command": "pre-commit run --files kcd2_dice_solver/run.sh", + "result": "pass", + "evidence": "All applicable hooks Passed (shellcheck, executables-have-shebangs, ai-evidence-contract, etc.)" + } + ], + "risks": ["If nvm is not installed on a machine, run.sh exits with an explicit error instead of installing nvm itself (out of scope)"], + "rollback": ["Delete kcd2_dice_solver/run.sh", "No other files depend on it"] +} diff --git a/kcd2_dice_solver/.gitignore b/kcd2_dice_solver/.gitignore new file mode 100644 index 00000000..cda7b285 --- /dev/null +++ b/kcd2_dice_solver/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +coverage +*.tsbuildinfo diff --git a/kcd2_dice_solver/README.md b/kcd2_dice_solver/README.md new file mode 100644 index 00000000..69cbe371 --- /dev/null +++ b/kcd2_dice_solver/README.md @@ -0,0 +1,80 @@ +# KCD2 Dice Solver + +Pick which six dice and which badge to bring to a game of dice in +*Kingdom Come: Deliverance II*. Enter what you own; the solver evaluates every +loadout your inventory allows and reports the best one with the numbers behind +it. + +```bash +pnpm install +pnpm dev # http://localhost:5174 +pnpm test # full suite +pnpm coverage # 100% statements / branches / functions / lines +pnpm lint # tsc --noEmit + eslint (strictTypeChecked) +pnpm build +``` + +## Entering an inventory + +Three ways, all wired to the same state: + +- **Click** a die's row to add one. +- **Scroll** the mouse wheel over its counter to step it up or down. +- **Search** by name — a fuzzy match, so `wei` finds *Weighted die* and + `paintb` finds *Painter's die B*. The same box filters the badge list. + +The inventory is saved to `localStorage`, so it survives a reload. + +## How the recommendation is computed + +**Scoring** (`src/core/scoring.ts`) is the wiki's table: 100 per one, 50 per +five, `1000` for three ones and `100 × face` otherwise, doubled for each die +beyond three, and 500/750/1500 for the three straights. A roll scores the best +*partition* of its dice, which is a small memoised search. + +**Expected value** (`src/core/evaluate.ts`) is exact, not sampled. Scoring +depends only on the multiset of faces, so the six dice are convolved into a +distribution over count vectors — at most 924 of them, against 6⁶ = 46,656 +ordered outcomes. Those vectors stay packed as integers end to end, so the +scorer's memo is keyed on them directly. `evaluate.test.ts` checks the result +against a naive full enumeration that shares none of that machinery. + +**The search** (`src/core/search.ts`) evaluates whole sets, because a Farkle +set's value is not the sum of its dice — ranking dice individually and taking +the top six is simply wrong. Dice with identical distributions are pooled +first (nine of the game's dice are plain uniform dice under different names). +Small inventories are enumerated exhaustively and reported as *provably +optimal*; larger ones use a multi-start steepest-ascent local search and are +reported as *not proven*. + +**Turn value** (`src/core/simulate.ts`) is a Monte Carlo simulation, and unlike +the above it depends on how you play. The policy is stated explicitly in that +file and its knobs are exposed rather than buried: hold the subset maximising +`points + 60 × dice-left`, bank at 300. + +**Badges** (`src/core/badgeValue.ts`) split in two. The five that change the +scoring table are valued on the exact EV delta after re-running the whole dice +search with the rule on — so equipping one can change which dice you should +bring. The rest are simulated one charge at a time, with each turn drawing from +its own seeded stream so the comparison is paired and the measured difference is +the badge rather than a diverged random sequence. + +Nothing uses `Math.random`; every random source is a seeded `mulberry32`, so +every number here is reproducible. + +## What the numbers are not + +- **Balatro's die** publishes no face probabilities. Every face is a wildcard + ("you get to choose how it's counted"), so it is modelled as always resolving + in your favour — which means it always wins if you own it. +- **The three "Advantage" formations** (Carpenter's Cut, Executioner's Gallows, + Priest's Eye) and the **Headstart** point leads have no published values. The + constants used are marked `UNVERIFIED` in `src/data/badges.ts`. Correct them + from the game's help screen if you have it open. +- **Badge point-per-game figures** are estimates for ranking badges against each + other, not a prediction of a scoreline. +- The wiki and [Inara](https://inara.cz/kingdom-come-2/items-dice/) **disagree** + on about six dice (Ci/Fer/Lu, Painted, Trinity, Holy Trinity, Devil's head). + The [fandom wiki](https://kingdom-come-deliverance.fandom.com/wiki/Dice/KCD2) + is treated as canonical; the Inara figures sit in a comment beside each + conflicting entry. diff --git a/kcd2_dice_solver/eslint.config.js b/kcd2_dice_solver/eslint.config.js new file mode 100644 index 00000000..468b001d --- /dev/null +++ b/kcd2_dice_solver/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js'; +import reactHooksPlugin from 'eslint-plugin-react-hooks'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist', 'coverage'] }, + js.configs.recommended, + ...tseslint.configs.strictTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + { + plugins: { + 'react-hooks': reactHooksPlugin, + }, + languageOptions: { + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + ...reactHooksPlugin.configs.recommended.rules, + '@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }], + }, + }, + { + files: ['*.js', '*.mjs', '*.cjs'], + ...tseslint.configs.disableTypeChecked, + }, +); diff --git a/kcd2_dice_solver/index.html b/kcd2_dice_solver/index.html new file mode 100644 index 00000000..8c6f6680 --- /dev/null +++ b/kcd2_dice_solver/index.html @@ -0,0 +1,12 @@ + + + + + + KCD2 Dice Solver + + +
+ + + diff --git a/kcd2_dice_solver/package.json b/kcd2_dice_solver/package.json new file mode 100644 index 00000000..6fe16994 --- /dev/null +++ b/kcd2_dice_solver/package.json @@ -0,0 +1,44 @@ +{ + "name": "kcd2-dice-solver", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "tsc --noEmit && eslint src", + "preview": "vite preview", + "test": "vitest run", + "coverage": "vitest run --coverage" + }, + "engines": { + "node": ">=22", + "pnpm": ">=10" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.5.2", + "@vitest/coverage-v8": "^4.1.9", + "eslint": "^10.6.0", + "eslint-plugin-react-hooks": "^7.1.1", + "jsdom": "^29.1.1", + "typescript": "~5.8.3", + "typescript-eslint": "^8.62.0", + "vite": "^6.3.5", + "vitest": "^4.1.9" + } +} diff --git a/kcd2_dice_solver/pnpm-lock.yaml b/kcd2_dice_solver/pnpm-lock.yaml new file mode 100644 index 00000000..2dbdb8ac --- /dev/null +++ b/kcd2_dice_solver/pnpm-lock.yaml @@ -0,0 +1,2755 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + react: + specifier: ^19.1.0 + version: 19.2.7 + react-dom: + specifier: ^19.1.0 + version: 19.2.7(react@19.2.7) + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.7.0) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/react': + specifier: ^19.1.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.1.0 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^4.5.2 + version: 4.7.0(vite@6.4.3) + '@vitest/coverage-v8': + specifier: ^4.1.9 + version: 4.1.10(vitest@4.1.10) + eslint: + specifier: ^10.6.0 + version: 10.7.0 + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.7.0) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + typescript-eslint: + specifier: ^8.62.0 + version: 8.64.0(eslint@10.7.0)(typescript@5.8.3) + vite: + specifier: ^6.3.5 + version: 6.4.3 + vitest: + specifier: ^4.1.9 + version: 4.1.10(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@6.4.3) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6': + resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@typescript-eslint/eslint-plugin@8.64.0': + resolution: {integrity: sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.64.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.64.0': + resolution: {integrity: sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.64.0': + resolution: {integrity: sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.64.0': + resolution: {integrity: sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.64.0': + resolution: {integrity: sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.64.0': + resolution: {integrity: sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.64.0': + resolution: {integrity: sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.64.0': + resolution: {integrity: sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.64.0': + resolution: {integrity: sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.64.0': + resolution: {integrity: sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + browserslist@4.28.6: + resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + electron-to-chromium@1.5.393: + resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.7.0: + resolution: {integrity: sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.20: + resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.64.0: + resolution: {integrity: sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.6 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.7.0)': + dependencies: + eslint: 10.7.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.7.0)': + optionalDependencies: + eslint: 10.7.0 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@exodus/bytes@1.15.1': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.64.0(@typescript-eslint/parser@8.64.0(eslint@10.7.0)(typescript@5.8.3))(eslint@10.7.0)(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.64.0(eslint@10.7.0)(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.64.0 + '@typescript-eslint/type-utils': 8.64.0(eslint@10.7.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.64.0(eslint@10.7.0)(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.64.0 + eslint: 10.7.0 + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.64.0(eslint@10.7.0)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.64.0 + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.64.0 + debug: 4.4.3 + eslint: 10.7.0 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.64.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@5.8.3) + '@typescript-eslint/types': 8.64.0 + debug: 4.4.3 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.64.0': + dependencies: + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/visitor-keys': 8.64.0 + + '@typescript-eslint/tsconfig-utils@8.64.0(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + + '@typescript-eslint/type-utils@8.64.0(eslint@10.7.0)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.64.0(eslint@10.7.0)(typescript@5.8.3) + debug: 4.4.3 + eslint: 10.7.0 + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.64.0': {} + + '@typescript-eslint/typescript-estree@8.64.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/project-service': 8.64.0(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@5.8.3) + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/visitor-keys': 8.64.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.64.0(eslint@10.7.0)(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0) + '@typescript-eslint/scope-manager': 8.64.0 + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.8.3) + eslint: 10.7.0 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.64.0': + dependencies: + '@typescript-eslint/types': 8.64.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@4.7.0(vite@6.4.3)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3 + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@6.4.3) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@6.4.3)': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3 + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.43: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.6: + dependencies: + baseline-browser-mapping: 2.10.43 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.393 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.6) + + caniuse-lite@1.0.30001806: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + csstype@3.2.3: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-is@0.1.4: {} + + dequal@2.0.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + electron-to-chromium@1.5.393: {} + + entities@8.0.0: {} + + es-module-lexer@2.3.1: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@10.7.0): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 10.7.0 + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.7.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + has-flag@4.0.0: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-potential-custom-element-name@1.0.1: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lru-cache@11.5.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + mdn-data@2.27.1: {} + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.51: {} + + obug@2.1.4: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.20: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react-refresh@0.17.0: {} + + react@19.2.7: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} + + tldts-core@7.4.9: {} + + tldts@7.4.9: + dependencies: + tldts-core: 7.4.9 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.9 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.64.0(eslint@10.7.0)(typescript@5.8.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/parser@8.64.0(eslint@10.7.0)(typescript@5.8.3))(eslint@10.7.0)(typescript@5.8.3) + '@typescript-eslint/parser': 8.64.0(eslint@10.7.0)(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.64.0(eslint@10.7.0)(typescript@5.8.3) + eslint: 10.7.0 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + typescript@5.8.3: {} + + undici@7.28.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.6): + dependencies: + browserslist: 4.28.6 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@6.4.3: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.20 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@4.1.10(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@6.4.3): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@6.4.3) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 6.4.3 + why-is-node-running: 2.3.0 + optionalDependencies: + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/kcd2_dice_solver/run.sh b/kcd2_dice_solver/run.sh new file mode 100755 index 00000000..6e019f4e --- /dev/null +++ b/kcd2_dice_solver/run.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +# ============================================================================ +# Installs dependencies (Node via nvm, pnpm via corepack, then npm packages) +# and starts the KCD2 Dice Solver dev server. +# ============================================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly NODE_MIN_MAJOR=22 +readonly NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + +ensure_node() { + if command -v node >/dev/null 2>&1; then + local major + major="$(node --version | sed -E 's/^v([0-9]+).*/\1/')" + if (( major >= NODE_MIN_MAJOR )); then + return + fi + echo "Node $(node --version) is older than required v${NODE_MIN_MAJOR}; installing a newer version via nvm..." + else + echo "Node not found; installing via nvm..." + fi + + if [[ ! -s "${NVM_DIR}/nvm.sh" ]]; then + echo "Error: nvm not found at ${NVM_DIR}/nvm.sh; install nvm first." >&2 + exit 1 + fi + # shellcheck source=/dev/null + source "${NVM_DIR}/nvm.sh" + nvm install --lts + nvm use --lts +} + +ensure_pnpm() { + if command -v pnpm >/dev/null 2>&1; then + return + fi + echo "pnpm not found; enabling via corepack..." + corepack enable + corepack prepare pnpm@latest --activate +} + +main() { + # nvm.sh may not be sourced yet in a fresh shell, so load it before the + # version check even if `node` already resolves from a different source. + if [[ -s "${NVM_DIR}/nvm.sh" ]]; then + # shellcheck source=/dev/null + source "${NVM_DIR}/nvm.sh" + fi + + ensure_node + ensure_pnpm + + cd "$SCRIPT_DIR" + echo "Installing dependencies..." + pnpm install + + echo "Starting dev server (http://localhost:5174)..." + exec pnpm dev +} + +main "$@" diff --git a/kcd2_dice_solver/src/App.test.tsx b/kcd2_dice_solver/src/App.test.tsx new file mode 100644 index 00000000..3eaad234 --- /dev/null +++ b/kcd2_dice_solver/src/App.test.tsx @@ -0,0 +1,298 @@ +/** + * End-to-end tests for the app, driving the real flow: search for a die, click + * to add it, scroll the wheel over a counter, and get a real recommendation. + * + * The worker is replaced with a port that runs the *real* solver synchronously — + * jsdom has no module-worker support, but nothing about the solver is stubbed, + * so these exercise the actual recommendation path. + */ + +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { App, STORAGE_KEY, loadInventory } from "./App.tsx"; +import type { SavedInventory } from "./App.tsx"; +import { solve } from "./core/solve.ts"; +import type { SolverPort } from "./hooks/useSolver.ts"; +import type { WorkerReply, WorkerRequest } from "./worker/solver.worker.ts"; + +/** + * A solver port that runs the real solver in-process. + * + * @returns A port usable in place of the Web Worker. + */ +function createInlinePort(): SolverPort { + const listeners = new Set<(event: MessageEvent) => void>(); + return { + postMessage: ({ id, request }: WorkerRequest) => { + let reply: WorkerReply; + try { + // Keep the simulation short: these tests are about the wiring. + reply = { id, ok: true, response: solve({ ...request, simulationTurns: 200 }) }; + } catch (error) { + reply = { id, ok: false, error: error instanceof Error ? error.message : "failed" }; + } + for (const listener of listeners) { + listener({ data: reply } as MessageEvent); + } + }, + addEventListener: (_type, listener) => listeners.add(listener), + removeEventListener: (_type, listener) => listeners.delete(listener), + }; +} + +/** + * An in-memory Storage stand-in, so tests do not share persisted state. + * + * @param initial - Optional starting contents. + * @returns A minimal storage object. + */ +function memoryStorage(initial?: string): Pick { + const store = new Map(); + if (initial !== undefined) { + store.set(STORAGE_KEY, initial); + } + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + }; +} + +/** + * Read back what the app persisted, typed so assertions are not on `any`. + * + * @param storage - The storage the app was given. + * @returns The parsed inventory. + */ +function readSaved(storage: Pick): SavedInventory { + return JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as SavedInventory; +} + +const renderApp = (initial?: string) => + render(); + +describe("loadInventory", () => { + it("starts empty when nothing is saved", () => { + expect(loadInventory(memoryStorage())).toEqual({ diceCounts: {}, badgeIds: [] }); + }); + + it("restores a saved inventory", () => { + const saved = JSON.stringify({ diceCounts: { weighted: 3 }, badgeIds: ["tin_might"] }); + expect(loadInventory(memoryStorage(saved))).toEqual({ + diceCounts: { weighted: 3 }, + badgeIds: ["tin_might"], + }); + }); + + it("survives a corrupt entry rather than taking the page down with it", () => { + expect(loadInventory(memoryStorage("{not json"))).toEqual({ diceCounts: {}, badgeIds: [] }); + }); + + it("survives an entry of the wrong shape", () => { + expect(loadInventory(memoryStorage("42"))).toEqual({ diceCounts: {}, badgeIds: [] }); + expect(loadInventory(memoryStorage("null"))).toEqual({ diceCounts: {}, badgeIds: [] }); + expect(loadInventory(memoryStorage('{"diceCounts":"nope","badgeIds":"nope"}'))).toEqual({ + diceCounts: {}, + badgeIds: [], + }); + }); +}); + +describe("App", () => { + it("asks for dice before anything is entered", () => { + renderApp(); + expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument(); + expect(screen.getByText("0 dice")).toBeInTheDocument(); + }); + + it("finds a die by fuzzy search, adds six by clicking, and solves", async () => { + renderApp(); + + await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); + const row = screen.getAllByRole("listitem")[0]; + const add = within(row).getByRole("button", { name: "Weighted die" }); + + for (let i = 0; i < 6; i += 1) { + await userEvent.click(add); + } + + expect(screen.getByText("6 dice")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("Best loadout")).toBeInTheDocument(); + }); + // Six Weighted dice is the right answer and the panel must name it. + expect(screen.getByText("6×")).toBeInTheDocument(); + expect(screen.getByText("Weighted die")).toBeInTheDocument(); + expect(screen.getByText(/Provably optimal/)).toBeInTheDocument(); + }); + + it("adds dice by scrolling the wheel over the counter", async () => { + renderApp(); + await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); + const counter = screen.getByLabelText("How many Weighted die"); + const stepper = counter.parentElement; + if (!stepper) { + throw new Error("no stepper"); + } + + for (let i = 0; i < 6; i += 1) { + fireEvent.wheel(stepper, { deltaY: -120 }); + } + expect(screen.getByLabelText("How many Weighted die")).toHaveValue(6); + expect(screen.getByText("6 dice")).toBeInTheDocument(); + + fireEvent.wheel(stepper, { deltaY: 120 }); + expect(screen.getByLabelText("How many Weighted die")).toHaveValue(5); + expect(screen.getByText("5 dice")).toBeInTheDocument(); + }); + + it("accumulates a rapid burst of clicks and wheel ticks", async () => { + // Driving the real app in headless Chrome caught this: six fast clicks used + // to land as one, because each handler computed from the count captured at + // render time. React batches faster than it re-renders. + renderApp(); + await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); + + const row = screen.getAllByRole("listitem")[0]; + const add = within(row).getByRole("button", { name: "Weighted die" }); + act(() => { + for (let i = 0; i < 4; i += 1) { + fireEvent.click(add); + } + }); + expect(screen.getByLabelText("How many Weighted die")).toHaveValue(4); + + const stepper = screen.getByLabelText("How many Weighted die").parentElement; + if (!stepper) { + throw new Error("no stepper"); + } + act(() => { + for (let i = 0; i < 2; i += 1) { + fireEvent.wheel(stepper, { deltaY: -120 }); + } + }); + expect(screen.getByLabelText("How many Weighted die")).toHaveValue(6); + expect(screen.getByText("6 dice")).toBeInTheDocument(); + }); + + it("ranks the badges you own alongside the dice", async () => { + const saved = JSON.stringify({ + diceCounts: { weighted: 6 }, + badgeIds: ["gold_emperors", "gold_defence"], + }); + renderApp(saved); + + await waitFor(() => { + expect(screen.getByText("Best badge per tier")).toBeInTheDocument(); + }); + // The name also appears in the ownership checkbox, so scope to the result. + const results = document.querySelector(".result"); + if (!results) { + throw new Error("no result panel"); + } + expect(within(results as HTMLElement).getByText("Gold Emperor's badge")).toBeInTheDocument(); + // The Defence badge is owned too, and must be reported as situational + // rather than given an invented number. + expect(within(results as HTMLElement).getByText(/situational/)).toBeInTheDocument(); + }); + + it("restores a saved inventory on load", () => { + renderApp(JSON.stringify({ diceCounts: { weighted: 4 }, badgeIds: [] })); + expect(screen.getByText("4 dice")).toBeInTheDocument(); + }); + + it("persists changes for next time", async () => { + const storage = memoryStorage(); + render(); + await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); + await userEvent.click(screen.getByLabelText("Add one Weighted die")); + + expect(readSaved(storage)).toEqual({ diceCounts: { weighted: 1 }, badgeIds: [] }); + }); + + it("clears everything on request", async () => { + renderApp(JSON.stringify({ diceCounts: { weighted: 6 }, badgeIds: ["tin_might"] })); + await waitFor(() => { + expect(screen.getByText("Best loadout")).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("button", { name: "Clear" })); + expect(screen.getByText("0 dice")).toBeInTheDocument(); + expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument(); + }); + + it("falls back to the browser's own storage when none is injected", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ diceCounts: { weighted: 2 }, badgeIds: [] }), + ); + render(); + expect(screen.getByText("2 dice")).toBeInTheDocument(); + window.localStorage.clear(); + }); + + it("disables Clear when there is nothing to clear", () => { + renderApp(); + expect(screen.getByRole("button", { name: "Clear" })).toBeDisabled(); + }); + + it("drops a die from the inventory when its count reaches zero", async () => { + const storage = memoryStorage( + JSON.stringify({ diceCounts: { weighted: 1 }, badgeIds: [] }), + ); + render(); + await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); + await userEvent.click(screen.getByLabelText("Remove one Weighted die")); + + expect(readSaved(storage)).toEqual({ diceCounts: {}, badgeIds: [] }); + }); + + it("toggles a badge on and off", async () => { + const storage = memoryStorage(); + render(); + await userEvent.type(screen.getByLabelText("Search dice and badges"), "emperor"); + + const checkbox = screen.getByRole("checkbox"); + await userEvent.click(checkbox); + expect(readSaved(storage).badgeIds).toEqual(["gold_emperors"]); + + await userEvent.click(checkbox); + expect(readSaved(storage).badgeIds).toEqual([]); + }); + + it("uses the singular for a single die", async () => { + renderApp(); + await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); + await userEvent.click(screen.getByLabelText("Add one Weighted die")); + expect(screen.getByText("1 die")).toBeInTheDocument(); + }); + + it("surfaces a solver error rather than showing nothing", async () => { + // A port that always fails, standing in for a worker that threw. + const failingPort = (): SolverPort => { + const listeners = new Set<(event: MessageEvent) => void>(); + return { + postMessage: ({ id }: WorkerRequest) => { + for (const listener of listeners) { + listener({ data: { id, ok: false, error: "solver exploded" } } as MessageEvent< + WorkerReply + >); + } + }, + addEventListener: (_type, listener) => listeners.add(listener), + removeEventListener: (_type, listener) => listeners.delete(listener), + }; + }; + render( + , + ); + await waitFor(() => { + expect(screen.getByText("solver exploded")).toBeInTheDocument(); + }); + }); +}); diff --git a/kcd2_dice_solver/src/App.tsx b/kcd2_dice_solver/src/App.tsx new file mode 100644 index 00000000..8e34c19e --- /dev/null +++ b/kcd2_dice_solver/src/App.tsx @@ -0,0 +1,198 @@ +/** + * KCD2 Dice Solver. + * + * Enter what dice and badges you own; get the best six to bring and the best + * badge for each tier you hold. + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { JSX } from "react"; +import { BadgePicker } from "./components/BadgePicker.tsx"; +import { DiceList } from "./components/DiceList.tsx"; +import { ResultPanel } from "./components/ResultPanel.tsx"; +import type { CountUpdater } from "./components/QuantityStepper.tsx"; +import { SET_SIZE } from "./core/search.ts"; +import type { SolveRequest } from "./core/solve.ts"; +import { useSolver } from "./hooks/useSolver.ts"; +import type { SolverPort } from "./hooks/useSolver.ts"; + +/** localStorage key for the saved inventory. */ +export const STORAGE_KEY = "kcd2-dice-solver.inventory"; + +/** What gets persisted between visits. */ +export interface SavedInventory { + readonly diceCounts: Record; + readonly badgeIds: string[]; +} + +const EMPTY: SavedInventory = { diceCounts: {}, badgeIds: [] }; + +/** + * Read the saved inventory, tolerating anything unparsable. + * + * @param storage - Where to read from; injectable for tests. + * @returns The saved inventory, or an empty one. + */ +export function loadInventory(storage: Pick): SavedInventory { + const raw = storage.getItem(STORAGE_KEY); + if (raw === null) { + return EMPTY; + } + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) { + return EMPTY; + } + const { diceCounts, badgeIds } = parsed as Partial; + return { + diceCounts: typeof diceCounts === "object" ? { ...diceCounts } : {}, + badgeIds: Array.isArray(badgeIds) ? [...badgeIds] : [], + }; + } catch { + // A corrupt entry should cost the saved inventory, not the whole page. + return EMPTY; + } +} + +export interface AppProps { + /** Overridden in tests to avoid depending on jsdom worker support. */ + readonly createPort?: () => SolverPort; + /** Overridden in tests to isolate persistence. */ + readonly storage?: Pick; +} + +/** + * The whole application. + * + * @param props - Optional injection points for tests. + * @returns The app element. + */ +export function App({ createPort, storage }: AppProps = {}): JSX.Element { + const store = storage ?? window.localStorage; + const [query, setQuery] = useState(""); + const [saved, setSaved] = useState(() => loadInventory(store)); + + useEffect(() => { + store.setItem(STORAGE_KEY, JSON.stringify(saved)); + }, [saved, store]); + + const setDieCount = useCallback((id: string, update: CountUpdater): void => { + setSaved((previous) => { + // Applied to the previous state, not to a value captured at render time, + // so a burst of clicks or wheel ticks accumulates instead of collapsing. + const count = update(previous.diceCounts[id] ?? 0); + // Rebuilt rather than mutated so a count of zero simply never makes it + // into the saved object. + const diceCounts = Object.fromEntries( + Object.entries(previous.diceCounts).filter(([key]) => key !== id), + ); + if (count > 0) { + diceCounts[id] = count; + } + return { ...previous, diceCounts }; + }); + }, []); + + const toggleBadge = useCallback((id: string, owned: boolean): void => { + setSaved((previous) => ({ + ...previous, + badgeIds: owned + ? [...previous.badgeIds, id] + : previous.badgeIds.filter((existing) => existing !== id), + })); + }, []); + + const total = Object.values(saved.diceCounts).reduce((sum, count) => sum + count, 0); + + const request = useMemo( + () => + total < SET_SIZE + ? null + : { diceCounts: saved.diceCounts, badgeIds: saved.badgeIds }, + [saved, total], + ); + + const { result, error, solving } = useSolver(request, createPort); + const ownedBadges = useMemo(() => new Set(saved.badgeIds), [saved.badgeIds]); + + return ( +
+
+

KCD2 Dice Solver

+

+ Pick what you own; get the best six dice and a badge for each tier. +

+
+ +
+
+
+ { + setQuery(event.target.value); + }} + /> + + {total} {total === 1 ? "die" : "dice"} + + +
+ +

+ Click a die to add one, or scroll the wheel over its counter. +

+ + + +

Badges

+ +
+ +
+ + +
+

What these numbers are

+
    +
  • + Expected score and bust chance are exact, computed over + every possible outcome of the six dice — not sampled. +
  • +
  • + Points per turn is a simulation of a push-your-luck turn + that banks at 300 points. Change how you play and it changes. +
  • +
  • + Badge values are estimates for ranking badges against each + other, not a prediction of a scoreline. +
  • +
  • + Balatro’s die is modelled as always counting in your favour, so it will + always be picked if you own it. +
  • +
  • + The three “Advantage” formations and the Headstart leads have{" "} + no published point values; the figures used are marked + UNVERIFIED in the source. +
  • +
+
+
+
+
+ ); +} diff --git a/kcd2_dice_solver/src/components/BadgePicker.tsx b/kcd2_dice_solver/src/components/BadgePicker.tsx new file mode 100644 index 00000000..5ca08ada --- /dev/null +++ b/kcd2_dice_solver/src/components/BadgePicker.tsx @@ -0,0 +1,63 @@ +/** + * Badge ownership, grouped by tier. + * + * Badges are owned or not owned — there is no quantity — so these are toggles + * rather than steppers. They are grouped by tier because that is the axis the + * recommendation is reported on: a badge only takes effect when your opponent + * brings one of the same tier, and you cannot know theirs in advance. + */ + +import type { JSX } from "react"; +import { BADGES, TIERS } from "../data/badges.ts"; +import { fuzzyFilter } from "../lib/fuzzy.ts"; + +export interface BadgePickerProps { + readonly query: string; + readonly owned: ReadonlySet; + readonly onToggle: (id: string, owned: boolean) => void; +} + +/** + * Tier-grouped badge checkboxes, filtered by the same fuzzy search. + * + * @param props - Query, owned ids, and a toggle handler. + * @returns The picker element. + */ +export function BadgePicker({ query, owned, onToggle }: BadgePickerProps): JSX.Element { + const matches = fuzzyFilter(query, BADGES, (badge) => badge.name).map((m) => m.item); + const matched = new Set(matches.map((badge) => badge.id)); + + return ( +
+ {TIERS.map((tier) => { + const inTier = BADGES.filter( + (badge) => badge.tier === tier && matched.has(badge.id), + ); + if (inTier.length === 0) { + return null; + } + return ( +
+

{tier}

+
    + {inTier.map((badge) => ( +
  • + +
  • + ))} +
+
+ ); + })} +
+ ); +} diff --git a/kcd2_dice_solver/src/components/DiceList.tsx b/kcd2_dice_solver/src/components/DiceList.tsx new file mode 100644 index 00000000..56505d9c --- /dev/null +++ b/kcd2_dice_solver/src/components/DiceList.tsx @@ -0,0 +1,45 @@ +/** + * The searchable list of every die in the game, with owned quantities. + */ + +import type { JSX } from "react"; +import { DICE } from "../data/dice.ts"; +import { fuzzyFilter } from "../lib/fuzzy.ts"; +import { DieRow } from "./DieRow.tsx"; +import type { CountUpdater } from "./QuantityStepper.tsx"; + +export interface DiceListProps { + readonly query: string; + readonly counts: Readonly>; + readonly onChange: (id: string, update: CountUpdater) => void; +} + +/** + * Fuzzy-filtered list of dice. + * + * @param props - The search query, current counts, and a change handler. + * @returns The list element, or an empty-state message. + */ +export function DiceList({ query, counts, onChange }: DiceListProps): JSX.Element { + const matches = fuzzyFilter(query, DICE, (die) => die.name); + + if (matches.length === 0) { + return

No die matches “{query}”.

; + } + + return ( +
    + {matches.map(({ item, indices }) => ( + { + onChange(item.id, update); + }} + /> + ))} +
+ ); +} diff --git a/kcd2_dice_solver/src/components/DieRow.tsx b/kcd2_dice_solver/src/components/DieRow.tsx new file mode 100644 index 00000000..f7df3cdb --- /dev/null +++ b/kcd2_dice_solver/src/components/DieRow.tsx @@ -0,0 +1,96 @@ +/** + * One die in the inventory list: name, face distribution, and a quantity. + */ + +import type { JSX } from "react"; +import type { Die } from "../data/dice.ts"; +import { QuantityStepper } from "./QuantityStepper.tsx"; +import type { CountUpdater } from "./QuantityStepper.tsx"; + +/** Most of any single die the game lets you carry into one match. */ +export const MAX_PER_DIE = 6; + +export interface DieRowProps { + readonly die: Die; + readonly count: number; + readonly highlight: readonly number[]; + readonly onChange: (update: CountUpdater) => void; +} + +/** + * Render a name with the fuzzy-matched characters marked. + * + * Adjacent characters are grouped into runs rather than emitted one element per + * character: it keeps the DOM small and, more usefully, leaves the unmatched + * parts of the name as whole text nodes. + * + * @param name - The die's display name. + * @param highlight - Indices in the name that the search query matched. + * @returns The name as alternating plain and marked runs. + */ +export function highlightName(name: string, highlight: readonly number[]): JSX.Element { + const marked = new Set(highlight); + const runs: { text: string; hit: boolean }[] = []; + for (let index = 0; index < name.length; index += 1) { + const hit = marked.has(index); + const last = runs.at(-1); + if (last?.hit === hit) { + last.text += name[index]; + } else { + runs.push({ text: name[index], hit }); + } + } + return ( + <> + {runs.map((run, index) => + run.hit ? ( + // Index keys are correct here: the runs come from a fixed string. + {run.text} + ) : ( + {run.text} + ), + )} + + ); +} + +/** + * A single inventory row. Clicking anywhere on the row adds one die. + * + * @param props - The die, its current count, search highlights, and a handler. + * @returns The row element. + */ +export function DieRow({ die, count, highlight, onChange }: DieRowProps): JSX.Element { + const isWild = die.wildcardFaces.length > 0; + return ( +
  • 0 ? "die-row owned" : "die-row"}> + + +
  • + ); +} diff --git a/kcd2_dice_solver/src/components/QuantityStepper.tsx b/kcd2_dice_solver/src/components/QuantityStepper.tsx new file mode 100644 index 00000000..fb0dd204 --- /dev/null +++ b/kcd2_dice_solver/src/components/QuantityStepper.tsx @@ -0,0 +1,103 @@ +/** + * The quantity control for one inventory row. + * + * Three ways to change a count, because all three were asked for: the −/+ + * buttons, typing a number, and scrolling the mouse wheel over the control. + */ + +import type { JSX } from "react"; + +/** + * How a count changes: a function of the previous count, never an absolute + * value computed from a stale render. Wheel events and rapid clicks arrive + * faster than React re-renders, so `onChange(value + 1)` silently collapses a + * burst of six into one — which is exactly what happened the first time this + * was driven in a real browser. + */ +export type CountUpdater = (previous: number) => number; + +export interface QuantityStepperProps { + readonly label: string; + readonly value: number; + readonly max: number; + readonly onChange: (update: CountUpdater) => void; +} + +/** + * Clamp a count into the allowed range, treating anything unparsable as zero. + * + * @param value - Proposed count. + * @param max - Largest allowed count. + * @returns The clamped whole number. + */ +export function clampCount(value: number, max: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.min(max, Math.max(0, Math.floor(value))); +} + +/** + * Stepper for how many of a die or badge the player owns. + * + * @param props - Current value, bounds, and the change handler. + * @returns The stepper element. + */ +export function QuantityStepper({ + label, + value, + max, + onChange, +}: QuantityStepperProps): JSX.Element { + const step = (delta: number): void => { + onChange((previous) => clampCount(previous + delta, max)); + }; + + return ( +
    { + // Scrolling up adds one, down removes one. The row is not scrollable + // itself, so nothing is stolen from the page. + event.preventDefault(); + step(event.deltaY < 0 ? 1 : -1); + }} + > + + { + // Typing is the one case that really is an absolute value. + const typed = Number(event.target.value); + onChange(() => clampCount(typed, max)); + }} + /> + +
    + ); +} diff --git a/kcd2_dice_solver/src/components/ResultPanel.tsx b/kcd2_dice_solver/src/components/ResultPanel.tsx new file mode 100644 index 00000000..763fb397 --- /dev/null +++ b/kcd2_dice_solver/src/components/ResultPanel.tsx @@ -0,0 +1,171 @@ +/** + * The recommendation: six dice, the numbers behind them, and a badge per tier. + */ + +import type { JSX } from "react"; +import type { Die } from "../data/dice.ts"; +import type { SolveResponse } from "../core/solve.ts"; + +export interface ResultPanelProps { + readonly result: SolveResponse | null; + readonly error: string | null; + readonly solving: boolean; +} + +/** + * Collapse a loadout into "3x Weighted die, 2x Pie die" form. + * + * @param dice - The six dice of a loadout. + * @returns One entry per distinct die, in descending count order. + */ +export function summariseDice(dice: readonly Die[]): { die: Die; count: number }[] { + const byId = new Map(); + for (const die of dice) { + const existing = byId.get(die.id); + if (existing) { + existing.count += 1; + } else { + byId.set(die.id, { die, count: 1 }); + } + } + return [...byId.values()].sort((a, b) => b.count - a.count); +} + +/** + * Format a point figure for display. + * + * @param value - The number of points. + * @returns The value rounded to whole points with thousands separators. + */ +function points(value: number): string { + return Math.round(value).toLocaleString("en-GB"); +} + +/** + * The recommendation panel. + * + * @param props - The latest solve, any error, and whether one is in flight. + * @returns The panel element. + */ +export function ResultPanel({ result, error, solving }: ResultPanelProps): JSX.Element { + if (error) { + return ( +
    +

    {error}

    +
    + ); + } + if (!result) { + return ( +
    +

    + {solving ? "Solving…" : "Add at least six dice to get a recommendation."} +

    +
    + ); + } + + return ( +
    +

    Best loadout

    +
      + {summariseDice(result.dice).map(({ die, count }) => ( +
    • + {count}× + {die.name} +
    • + ))} +
    + +
    +
    +
    Expected score per throw
    +
    {points(result.evaluation.ev)}
    +
    +
    +
    Bust chance
    +
    {(result.evaluation.bustProbability * 100).toFixed(2)}%
    +
    +
    +
    Good throw (90th pct)
    +
    {points(result.evaluation.p90)}
    +
    +
    +
    Simulated points per turn
    +
    + {points(result.simulation.meanPerTurn)} + ± {result.simulation.standardError.toFixed(1)} +
    +
    +
    + +

    + {result.optimal + ? "Provably optimal: every possible loadout was evaluated." + : "Best found by local search — near-optimal, not proven."}{" "} + Chosen from {result.inventorySize} dice. +

    + + {result.alternatives.length > 0 && ( +
    + Runners-up +
      + {result.alternatives.map((alternative, index) => ( + // Alternatives are a fixed ranked list; the index is their rank. +
    • + {points(alternative.evaluation.ev)} + {summariseDice(alternative.dice) + .map(({ die, count }) => `${count}× ${die.name}`) + .join(", ")} +
    • + ))} +
    +
    + )} + +

    Best badge per tier

    + {result.badges.length === 0 ? ( +

    Tick the badges you own to have them ranked.

    + ) : ( + result.badges.map((recommendation) => { + const [best, ...rest] = recommendation.ranked; + return ( +
    +

    {recommendation.tier}

    +

    + {best.badge.name} + {best.pointsPerGame !== null && ( + ~{points(best.pointsPerGame)} pts/game + )} +

    +

    {best.reason}

    + {best.dice && ( +

    + With this badge, bring:{" "} + {summariseDice(best.dice) + .map(({ die, count }) => `${count}× ${die.name}`) + .join(", ")} +

    + )} + {rest.length > 0 && ( +
    + Other {recommendation.tier} badges +
      + {rest.map((valuation) => ( +
    • + {valuation.badge.name} + {valuation.pointsPerGame === null + ? " — situational" + : ` — ~${points(valuation.pointsPerGame)} pts/game`} +
    • + ))} +
    +
    + )} +
    + ); + }) + )} +
    + ); +} diff --git a/kcd2_dice_solver/src/components/components.test.tsx b/kcd2_dice_solver/src/components/components.test.tsx new file mode 100644 index 00000000..db04f567 --- /dev/null +++ b/kcd2_dice_solver/src/components/components.test.tsx @@ -0,0 +1,385 @@ +/** + * Component tests for the three ways of entering an inventory: clicking a die, + * scrolling the wheel over its counter, and fuzzy-searching by name. + */ + +import { fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { Die } from "../data/dice.ts"; +import type { SolveResponse } from "../core/solve.ts"; +import { BadgePicker } from "./BadgePicker.tsx"; +import { DiceList } from "./DiceList.tsx"; +import { DieRow, MAX_PER_DIE, highlightName } from "./DieRow.tsx"; +import { QuantityStepper, clampCount } from "./QuantityStepper.tsx"; +import { ResultPanel, summariseDice } from "./ResultPanel.tsx"; + +/** + * Apply the updater a component emitted, to assert on the resulting count. + * + * @param mock - The mocked onChange. + * @param previous - The count it should be applied to. + * @param call - Which call to read, counting from the end by default. + * @returns The count the component asked for. + */ +function applied( + mock: { mock: { calls: unknown[][] } }, + previous: number, + call = mock.mock.calls.length - 1, +): number { + const update = mock.mock.calls[call][0] as (p: number) => number; + return update(previous); +} + +const ordinary: Die = { + id: "ordinary", + name: "Ordinary die", + description: "An ordinary playing die.", + weights: [1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6], + wildcardFaces: [], +}; + +describe("clampCount", () => { + it("keeps a value inside the range", () => { + expect(clampCount(3, 6)).toBe(3); + expect(clampCount(-2, 6)).toBe(0); + expect(clampCount(99, 6)).toBe(6); + }); + + it("floors fractions and rejects nonsense", () => { + expect(clampCount(2.9, 6)).toBe(2); + expect(clampCount(Number.NaN, 6)).toBe(0); + }); +}); + +describe("QuantityStepper", () => { + it("adds one when the plus button is pressed", async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(screen.getByLabelText("Add one Ordinary die")); + expect(applied(onChange, 2)).toBe(3); + }); + + it("removes one when the minus button is pressed", async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(screen.getByLabelText("Remove one Ordinary die")); + expect(applied(onChange, 2)).toBe(1); + }); + + it("disables the buttons at the ends of the range", () => { + const { rerender } = render( + , + ); + expect(screen.getByLabelText("Remove one Ordinary die")).toBeDisabled(); + rerender(); + expect(screen.getByLabelText("Add one Ordinary die")).toBeDisabled(); + }); + + it("increments on a wheel scroll up and decrements on a scroll down", () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + const stepper = container.querySelector(".stepper"); + if (!stepper) { + throw new Error("no stepper"); + } + fireEvent.wheel(stepper, { deltaY: -120 }); + expect(applied(onChange, 2)).toBe(3); + fireEvent.wheel(stepper, { deltaY: 120 }); + expect(applied(onChange, 2)).toBe(1); + }); + + it("accumulates a burst of wheel ticks rather than collapsing them", () => { + // Wheel events arrive far faster than React re-renders. Emitting an + // absolute `value + 1` made six ticks land as one; each emitted updater + // must therefore compose. + const onChange = vi.fn(); + const { container } = render( + , + ); + const stepper = container.querySelector(".stepper"); + if (!stepper) { + throw new Error("no stepper"); + } + for (let i = 0; i < 6; i += 1) { + fireEvent.wheel(stepper, { deltaY: -120 }); + } + expect(onChange).toHaveBeenCalledTimes(6); + const total = onChange.mock.calls.reduce( + (count: number, [update]) => (update as (p: number) => number)(count), + 0, + ); + expect(total).toBe(6); + }); + + it("clamps a typed value", async () => { + const onChange = vi.fn(); + render(); + await userEvent.type(screen.getByLabelText("How many Ordinary die"), "9"); + expect(applied(onChange, 0)).toBe(6); + }); +}); + +describe("highlightName", () => { + it("marks the matched characters and leaves the rest alone", () => { + const { container } = render(

    {highlightName("Die", [0])}

    ); + expect(container.querySelectorAll("mark")).toHaveLength(1); + expect(container.textContent).toBe("Die"); + }); +}); + +describe("DieRow", () => { + it("adds one die when the row is clicked", async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Ordinary die" })); + expect(applied(onChange, 0)).toBe(1); + }); + + it("stops at the maximum when clicked repeatedly", async () => { + const onChange = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Ordinary die" })); + expect(applied(onChange, MAX_PER_DIE)).toBe(MAX_PER_DIE); + }); + + it("marks a row you own", () => { + const { container } = render( + , + ); + expect(container.querySelector(".die-row")).toHaveClass("owned"); + }); + + it("tags the wildcard dice", () => { + const balatro: Die = { ...ordinary, id: "balatro", wildcardFaces: [1, 2, 3, 4, 5, 6] }; + render(); + expect(screen.getByText("wild")).toBeInTheDocument(); + }); +}); + +describe("DiceList", () => { + it("shows every die when the query is empty", () => { + render(); + expect(screen.getAllByRole("listitem")).toHaveLength(43); + }); + + it("narrows to the Weighted die on a three-letter query", () => { + render(); + const rows = screen.getAllByRole("listitem"); + // The name is split into highlighted and plain runs, so match on the row + // button's accessible name rather than on a single text node. + expect(within(rows[0]).getByRole("button", { name: "Weighted die" })).toBeInTheDocument(); + }); + + it("highlights the characters the query matched", () => { + render(); + const rows = screen.getAllByRole("listitem"); + expect(within(rows[0]).getByText("Wei").tagName).toBe("MARK"); + }); + + it("finds a die by a fragment from the middle of its name", () => { + render(); + expect(screen.getAllByRole("listitem")).toHaveLength(1); + }); + + it("says so when nothing matches", () => { + render(); + expect(screen.getByText(/No die matches/)).toBeInTheDocument(); + }); + + it("passes the owned count through to the row", () => { + render(); + expect(screen.getByLabelText("How many Weighted die")).toHaveValue(4); + }); + + it("reports which die changed", async () => { + const onChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Weighted die" })); + expect(onChange.mock.calls[0][0]).toBe("weighted"); + expect((onChange.mock.calls[0][1] as (p: number) => number)(0)).toBe(1); + }); +}); + +describe("BadgePicker", () => { + it("groups badges under their tier", () => { + render(); + expect(screen.getByRole("heading", { name: "tin" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "silver" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "gold" })).toBeInTheDocument(); + }); + + it("hides a tier with no matching badge", () => { + render(); + expect(screen.queryByRole("heading", { name: "tin" })).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "gold" })).toBeInTheDocument(); + }); + + it("reflects which badges are owned", () => { + render(); + expect(screen.getByRole("checkbox")).toBeChecked(); + }); + + it("reports a toggle in both directions", async () => { + const onToggle = vi.fn(); + const { rerender } = render( + , + ); + await userEvent.click(screen.getByRole("checkbox")); + expect(onToggle).toHaveBeenLastCalledWith("gold_emperors", true); + + rerender( + , + ); + await userEvent.click(screen.getByRole("checkbox")); + expect(onToggle).toHaveBeenLastCalledWith("gold_emperors", false); + }); +}); + +describe("summariseDice", () => { + it("collapses duplicates and orders by count", () => { + const weighted: Die = { ...ordinary, id: "weighted", name: "Weighted die" }; + expect(summariseDice([ordinary, weighted, weighted, weighted])).toEqual([ + { die: weighted, count: 3 }, + { die: ordinary, count: 1 }, + ]); + }); +}); + +const response: SolveResponse = { + dice: new Array(6).fill(ordinary), + evaluation: { ev: 399.1, bustProbability: 0.0309, p90: 900 }, + simulation: { + meanPerTurn: 412.5, + standardError: 3.2, + bustRate: 0.2, + throwsPerTurn: 1.4, + turns: 20000, + }, + optimal: true, + alternatives: [], + badges: [], + inventorySize: 6, +}; + +describe("ResultPanel", () => { + it("prompts for dice before the first solve", () => { + render(); + expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument(); + }); + + it("says it is working while a solve is in flight", () => { + render(); + expect(screen.getByText("Solving…")).toBeInTheDocument(); + }); + + it("shows an error instead of a stale answer", () => { + render(); + expect(screen.getByText("Need at least 6 dice")).toBeInTheDocument(); + }); + + it("shows the loadout and its numbers", () => { + render(); + expect(screen.getByText("6×")).toBeInTheDocument(); + expect(screen.getByText("Ordinary die")).toBeInTheDocument(); + expect(screen.getByText("399")).toBeInTheDocument(); + expect(screen.getByText("3.09%")).toBeInTheDocument(); + expect(screen.getByText("900")).toBeInTheDocument(); + }); + + it("says when the answer is proven rather than merely good", () => { + render(); + expect(screen.getByText(/Provably optimal/)).toBeInTheDocument(); + }); + + it("admits when the answer is only a local search result", () => { + render( + , + ); + expect(screen.getByText(/not proven/)).toBeInTheDocument(); + }); + + it("lists runners-up when there are any", () => { + const withAlternatives: SolveResponse = { + ...response, + alternatives: [ + { dice: new Array(6).fill(ordinary), evaluation: response.evaluation }, + ], + }; + render(); + expect(screen.getByText("Runners-up")).toBeInTheDocument(); + }); + + it("asks for badges when none are owned", () => { + render(); + expect(screen.getByText(/Tick the badges you own/)).toBeInTheDocument(); + }); + + it("shows the pick, its value and its reasoning for each tier", () => { + const withBadges: SolveResponse = { + ...response, + badges: [ + { + tier: "gold", + ranked: [ + { + badge: { + id: "gold_emperors", + name: "Gold Emperor's badge", + description: "", + tier: "gold", + effect: { kind: "scoring", rules: { emperorTriple: true } }, + }, + pointsPerGame: 1234, + reason: "Changes the scoring table.", + dice: new Array(6).fill(ordinary), + }, + { + badge: { + id: "gold_warlord", + name: "Gold Warlord badge", + description: "", + tier: "gold", + effect: { kind: "multiplier", factor: 2, uses: 1 }, + }, + pointsPerGame: 567, + reason: "Doubles a good turn.", + dice: null, + }, + { + badge: { + id: "gold_defence", + name: "Gold Defence badge", + description: "", + tier: "gold", + effect: { kind: "defence" }, + }, + pointsPerGame: null, + reason: "Situational.", + dice: null, + }, + ], + }, + ], + }; + render(); + expect(screen.getByText("Gold Emperor's badge")).toBeInTheDocument(); + expect(screen.getByText(/~1,234 pts\/game/)).toBeInTheDocument(); + expect(screen.getByText("Changes the scoring table.")).toBeInTheDocument(); + expect(screen.getByText(/With this badge, bring/)).toBeInTheDocument(); + // The runners-up list renders both a valued badge and a situational one. + expect(screen.getByText(/Gold Warlord badge — ~567 pts\/game/)).toBeInTheDocument(); + expect(screen.getByText(/Gold Defence badge — situational/)).toBeInTheDocument(); + }); + + it("dims the panel while a fresh solve is running", () => { + const { container } = render( + , + ); + expect(container.querySelector(".result")).toHaveClass("stale"); + }); +}); diff --git a/kcd2_dice_solver/src/core/badgeValue.test.ts b/kcd2_dice_solver/src/core/badgeValue.test.ts new file mode 100644 index 00000000..99485398 --- /dev/null +++ b/kcd2_dice_solver/src/core/badgeValue.test.ts @@ -0,0 +1,198 @@ +/** + * Tests for badge valuation. + * + * Every figure here is an estimate built on the simulator's stated policy, so + * these tests assert the *properties* the model must have — an optional badge + * can never be worth less than nothing, a bigger tier is worth at least as much + * as a smaller one — rather than pinning arbitrary point totals. + */ + +import { describe, expect, it } from "vitest"; +import { BADGES_BY_ID, DEFAULT_FORMATION_VALUES } from "../data/badges.ts"; +import type { Badge } from "../data/badges.ts"; +import { DICE_BY_ID } from "../data/dice.ts"; +import type { Die } from "../data/dice.ts"; +import { + bestThrowsValue, + measureBaseline, + rankValue, + recommendBadges, + singleCharge, + valueBadge, +} from "./badgeValue.ts"; +import type { BadgeContext } from "./badgeValue.ts"; +import { NO_CHARGES, DEFAULT_POLICY } from "./simulate.ts"; +import type { InventoryEntry } from "./search.ts"; + +const die = (id: string): Die => { + const found = DICE_BY_ID.get(id); + if (!found) { + throw new Error(`no such die: ${id}`); + } + return found; +}; + +const badge = (id: string): Badge => { + const found = BADGES_BY_ID.get(id); + if (!found) { + throw new Error(`no such badge: ${id}`); + } + return found; +}; + +const inventory: InventoryEntry[] = [ + { die: die("ordinary"), count: 6 }, + { die: die("lucky"), count: 6 }, + { die: die("weighted"), count: 2 }, +]; + +const context: BadgeContext = { + inventory, + baselineDice: new Array(6).fill(die("ordinary")), + formationValues: DEFAULT_FORMATION_VALUES, + policy: DEFAULT_POLICY, + seed: 0x5eed, + searchOptions: {}, +}; + +const baseline = measureBaseline(context); + +describe("measureBaseline", () => { + it("measures the loadout once, for every badge to share", () => { + expect(baseline.turn).toBeGreaterThan(0); + expect(baseline.ev).toBeGreaterThan(0); + expect(baseline.throwsPerTurn).toBeGreaterThanOrEqual(1); + expect(baseline.p90).toBeGreaterThan(0); + expect(baseline.distribution.at(0.9)).toBe(baseline.p90); + }); +}); + +describe("bestThrowsValue", () => { + const distribution = { at: (fraction: number): number => fraction * 1000 }; + + it("values later charges at lower percentiles", () => { + // One charge is worth the best throw; three charges are worth the best + // three, which is less than three times the best. + const one = bestThrowsValue(distribution, 1, 10); + const three = bestThrowsValue(distribution, 3, 10); + expect(three).toBeGreaterThan(one); + expect(three).toBeLessThan(one * 3); + }); + + it("never dips below the median, however short the game", () => { + expect(bestThrowsValue(distribution, 1, 1)).toBe(500); + }); + + it("is zero for a badge with no charges", () => { + expect(bestThrowsValue(distribution, 0, 10)).toBe(0); + }); +}); + +describe("singleCharge", () => { + it("maps each simulated effect onto one charge", () => { + expect(singleCharge(badge("tin_might"))).toEqual({ ...NO_CHARGES, extraDice: 1 }); + expect(singleCharge(badge("tin_resurrection"))).toEqual({ ...NO_CHARGES, antibust: 1 }); + expect(singleCharge(badge("gold_fortune"))).toEqual({ + ...NO_CHARGES, + reroll: 1, + rerollDice: 3, + }); + expect(singleCharge(badge("gold_transmutation"))).toEqual({ + ...NO_CHARGES, + setDie: 1, + setDieValue: 1, + }); + }); + + it("gives no charges to badges that are valued analytically", () => { + expect(singleCharge(badge("gold_warlord"))).toEqual(NO_CHARGES); + expect(singleCharge(badge("gold_headstart"))).toEqual(NO_CHARGES); + }); +}); + +describe("valueBadge", () => { + it("never values an optional badge below zero", () => { + // A badge you may simply decline to use cannot cost you points, so a + // negative estimate would be a modelling error, not a finding. + for (const id of BADGES_BY_ID.keys()) { + const valuation = valueBadge(badge(id), context, baseline); + if (valuation.pointsPerGame !== null) { + expect(valuation.pointsPerGame).toBeGreaterThanOrEqual(0); + } + } + }); + + it("gives every badge a stated reason", () => { + for (const id of BADGES_BY_ID.keys()) { + expect(valueBadge(badge(id), context, baseline).reason.length).toBeGreaterThan(10); + } + }); + + it("reports defence badges as situational rather than inventing a number", () => { + const valuation = valueBadge(badge("gold_defence"), context, baseline); + expect(valuation.pointsPerGame).toBeNull(); + expect(valuation.reason).toMatch(/situational/i); + }); + + it("values headstart at exactly its point lead", () => { + expect(valueBadge(badge("tin_headstart"), context, baseline).pointsPerGame).toBe(250); + expect(valueBadge(badge("gold_headstart"), context, baseline).pointsPerGame).toBe(1000); + }); + + it("ranks a higher tier at least as high as a lower one", () => { + const tin = valueBadge(badge("tin_might"), context, baseline).pointsPerGame ?? 0; + const gold = valueBadge(badge("gold_might"), context, baseline).pointsPerGame ?? 0; + expect(gold).toBeGreaterThanOrEqual(tin); + }); + + it("returns a re-optimised loadout for scoring badges only", () => { + expect(valueBadge(badge("gold_emperors"), context, baseline).dice).toHaveLength(6); + expect(valueBadge(badge("gold_might"), context, baseline).dice).toBeNull(); + }); + + it("measures its own baseline when not given one", () => { + const valuation = valueBadge(badge("tin_headstart"), context); + expect(valuation.pointsPerGame).toBe(250); + }); +}); + +describe("rankValue", () => { + it("ranks a scored badge by its points", () => { + expect( + rankValue({ badge: badge("tin_headstart"), pointsPerGame: 250, reason: "", dice: null }), + ).toBe(250); + }); + + it("sinks a situational badge below every scored one", () => { + expect( + rankValue({ badge: badge("tin_defence"), pointsPerGame: null, reason: "", dice: null }), + ).toBe(-Infinity); + }); +}); + +describe("recommendBadges", () => { + it("returns one ranked list per owned tier", () => { + const recommendations = recommendBadges( + new Set(["tin_might", "tin_headstart", "gold_warlord"]), + context, + ); + expect(recommendations.map((r) => r.tier)).toEqual(["tin", "gold"]); + expect(recommendations[0].ranked).toHaveLength(2); + expect(recommendations[1].ranked).toHaveLength(1); + }); + + it("orders each tier best first", () => { + const [tin] = recommendBadges( + new Set(["tin_might", "tin_headstart", "tin_fortune", "tin_defence"]), + context, + ); + const values = tin.ranked.map((v) => v.pointsPerGame ?? -Infinity); + expect(values).toEqual([...values].sort((a, b) => b - a)); + // The situational defence badge sorts last rather than being dropped. + expect(tin.ranked[tin.ranked.length - 1].badge.id).toBe("tin_defence"); + }); + + it("returns nothing when no badges are owned", () => { + expect(recommendBadges(new Set(), context)).toEqual([]); + }); +}); diff --git a/kcd2_dice_solver/src/core/badgeValue.ts b/kcd2_dice_solver/src/core/badgeValue.ts new file mode 100644 index 00000000..6fd4cd92 --- /dev/null +++ b/kcd2_dice_solver/src/core/badgeValue.ts @@ -0,0 +1,333 @@ +/** + * Estimating what a badge is worth, in points gained over a whole game. + * + * Badges fall into two families and are valued differently, because pretending + * one model fits both would be wrong in a way the number would hide: + * + * Scoring badges (Carpenter, Executioner, Priest, Emperor, Tyche) change the + * scoring table. They are valued by re-running the *entire dice search* with + * their rule active and comparing the resulting turn value — so equipping one + * can change which six dice you should bring. + * + * Turn-level badges (Might, Fortune, Wedding, Resurrection, Swap-out, + * Transmutation, Doppelganger, Warlord, Headstart, Bird King) do not change + * what a roll is worth, only what you can do about it. Each charge is + * simulated once and the per-charge uplift multiplied by the number of uses. + * + * Defence badges are purely reactive — their worth is exactly whatever the + * opponent's badge would have done, which is unknowable at pick time. They + * are reported as situational rather than given a fabricated number. + * + * Every number here is an estimate built on the simulator's stated policy. They + * are meant to rank badges against each other, not to predict a scoreline. + */ + +import type { Badge, BadgeTier, FormationValues, ScoringRules } from "../data/badges.ts"; +import { BADGES, BASE_RULES, DEFAULT_FORMATION_VALUES, TIERS } from "../data/badges.ts"; +import type { Die } from "../data/dice.ts"; +import { evaluateSet, scoreDistribution } from "./evaluate.ts"; +import type { ScoreDistribution } from "./evaluate.ts"; +import { Scorer } from "./scoring.ts"; +import { findBestSet } from "./search.ts"; +import type { InventoryEntry, SearchOptions } from "./search.ts"; +import { DEFAULT_POLICY, NO_CHARGES, simulateTurns } from "./simulate.ts"; +import type { BadgeCharges, TurnPolicy } from "./simulate.ts"; + +/** How many turns a game is assumed to last, for scaling per-turn uplifts. */ +export const TURNS_PER_GAME = 8; + +/** Turns simulated when valuing a badge. Enough to rank, cheap enough to feel instant. */ +export const BADGE_SIMULATION_TURNS = 4000; + +export interface BadgeValuation { + readonly badge: Badge; + /** Estimated points gained over a game, or null for situational badges. */ + readonly pointsPerGame: number | null; + /** One-line explanation of where the number came from. */ + readonly reason: string; + /** The dice this badge implies, when it changes the optimal loadout. */ + readonly dice: readonly Die[] | null; +} + +export interface BadgeRecommendation { + readonly tier: BadgeTier; + readonly ranked: readonly BadgeValuation[]; +} + +/** Inputs shared by every badge valuation in one solve. */ +export interface BadgeContext { + readonly inventory: readonly InventoryEntry[]; + readonly baselineDice: readonly Die[]; + readonly formationValues: FormationValues; + readonly policy: TurnPolicy; + readonly seed: number; + readonly searchOptions: SearchOptions; +} + +/** + * Mean banked points per turn for a set under a rule set. + * + * @param dice - The six dice played. + * @param scorer - Memoised scorer for the active rules. + * @param policy - Push-your-luck policy. + * @param charges - Badge charges available that turn. + * @param seed - Random seed, held constant across comparisons so that + * differences reflect the badge and not simulation noise. + * @returns Mean points banked per turn. + */ +function turnValue( + dice: readonly Die[], + scorer: Scorer, + policy: TurnPolicy, + charges: BadgeCharges, + seed: number, +): number { + return simulateTurns(dice, scorer, BADGE_SIMULATION_TURNS, policy, charges, seed).meanPerTurn; +} + +/** + * The baseline every badge is measured against: the no-badge loadout's scorer, + * its simulated turn value, and its exact throw evaluation. + * + * Computed once per solve rather than once per badge — recomputing a 4,000-turn + * simulation thirty-three times was the single largest cost in the first + * version of this module. + */ +export interface BadgeBaseline { + readonly scorer: Scorer; + readonly turn: number; + readonly p90: number; + /** Exact expected score of one throw of the baseline loadout. */ + readonly ev: number; + /** Mean throws per turn, used to convert a per-throw delta into a per-game one. */ + readonly throwsPerTurn: number; + /** Exact single-throw score distribution of the baseline loadout. */ + readonly distribution: ScoreDistribution; +} + +/** + * Total value of the best `uses` throws a player will see in a game. + * + * A badge with three charges is not worth three 90th-percentile throws: over the + * ten-or-so throws in a game you get roughly one 90th-percentile throw, one + * 80th, one 70th. Valuing each charge at the same optimistic percentile + * over-rated the three-charge gold badges noticeably. + * + * @param distribution - Single-throw score distribution. + * @param uses - How many charges the badge has. + * @param throwsPerGame - How many throws the player expects to make. + * @returns Summed value of the `uses` best throws. + */ +export function bestThrowsValue( + distribution: ScoreDistribution, + uses: number, + throwsPerGame: number, +): number { + let total = 0; + for (let k = 1; k <= uses; k += 1) { + // The k-th best of N throws sits near the (1 - k/N) quantile. Clamped so a + // short game cannot push the estimate below the median. + const fraction = Math.min(0.99, Math.max(0.5, 1 - k / Math.max(1, throwsPerGame))); + total += distribution.at(fraction); + } + return total; +} + +/** + * Measure the no-badge baseline for a loadout. + * + * @param context - Inventory, baseline loadout, and simulation settings. + * @returns The scorer, turn value, and 90th-percentile throw score. + */ +export function measureBaseline(context: BadgeContext): BadgeBaseline { + const scorer = new Scorer(BASE_RULES, context.formationValues); + const simulation = simulateTurns( + context.baselineDice, + scorer, + BADGE_SIMULATION_TURNS, + context.policy, + NO_CHARGES, + context.seed, + ); + const evaluation = evaluateSet(context.baselineDice, scorer); + return { + scorer, + turn: simulation.meanPerTurn, + throwsPerTurn: simulation.throwsPerTurn, + p90: evaluation.p90, + ev: evaluation.ev, + distribution: scoreDistribution(context.baselineDice, scorer), + }; +} + +/** + * Value one badge in points gained per game. + * + * @param badge - The badge to value. + * @param context - Inventory, baseline loadout, and simulation settings. + * @param baseline - The no-badge baseline to compare against. + * @returns The valuation, including any change to the recommended dice. + */ +export function valueBadge( + badge: Badge, + context: BadgeContext, + baseline: BadgeBaseline = measureBaseline(context), +): BadgeValuation { + const { baselineDice, formationValues, policy, seed } = context; + const { scorer: baseScorer, turn: baseTurn } = baseline; + const effect = badge.effect; + + switch (effect.kind) { + case "scoring": { + const rules: ScoringRules = { ...BASE_RULES, ...effect.rules }; + const scorer = new Scorer(rules, formationValues); + // The rule change can make different dice optimal, so re-run the search. + const search = findBestSet(context.inventory, scorer, context.searchOptions); + // Valued on the *exact* per-throw EV delta rather than by simulation. + // Adding a formation can only ever raise a roll's best score, so this + // difference is provably non-negative — whereas comparing two simulated + // turn values let noise report a strictly-better rule as a loss. + const delta = search.evaluation.ev - baseline.ev; + return { + badge, + pointsPerGame: delta * baseline.throwsPerTurn * TURNS_PER_GAME, + reason: `Changes the scoring table: +${format(delta)} expected points per throw (${format(baseline.throwsPerTurn)} throws/turn, ${TURNS_PER_GAME} turns/game).`, + dice: search.dice, + }; + } + case "headstart": { + return { + badge, + pointsPerGame: effect.points, + reason: `Flat lead of ${effect.points} points (UNVERIFIED: the game only says "small"/"moderate"/"large").`, + dice: null, + }; + } + case "multiplier": { + // Saved for a good turn, so valued against a 90th-percentile throw run + // rather than an average one. + const good = baseline.p90 + baseTurn; + const gain = (effect.factor - 1) * good * effect.uses; + return { + badge, + pointsPerGame: gain, + reason: `x${effect.factor} on ${effect.uses} good turn(s), valued against a 90th-percentile turn.`, + dice: null, + }; + } + case "doubleThrow": { + const throwsPerGame = baseline.throwsPerTurn * TURNS_PER_GAME; + const gain = bestThrowsValue(baseline.distribution, effect.uses, throwsPerGame); + return { + badge, + pointsPerGame: gain, + reason: `Repeats your ${effect.uses} best throw(s) out of roughly ${Math.round(throwsPerGame)} in a game.`, + dice: null, + }; + } + case "extraDice": + case "antibust": + case "reroll": + case "setDie": { + const charges = singleCharge(badge); + const perCharge = + turnValue(baselineDice, baseScorer, policy, charges, seed) - baseTurn; + const { uses } = effect; + return { + badge, + pointsPerGame: perCharge * uses, + reason: `Simulated uplift of ${format(perCharge)} points per charge, ${uses} charge(s) per game.`, + dice: null, + }; + } + case "defence": { + return { + badge, + pointsPerGame: null, + reason: "Situational: worth exactly whatever the opponent's badge would have done.", + dice: null, + }; + } + } +} + +/** + * Build the charge set that gives a badge exactly one use, for per-charge + * measurement. + * + * @param badge - The badge whose effect to model. + * @returns Charges with one use of that badge and nothing else. + */ +export function singleCharge(badge: Badge): BadgeCharges { + const effect = badge.effect; + switch (effect.kind) { + case "extraDice": + return { ...NO_CHARGES, extraDice: 1 }; + case "antibust": + return { ...NO_CHARGES, antibust: 1 }; + case "reroll": + return { ...NO_CHARGES, reroll: 1, rerollDice: effect.dice }; + case "setDie": + return { ...NO_CHARGES, setDie: 1, setDieValue: effect.value }; + default: + // Scoring, multiplier, doubleThrow, headstart and defence badges are + // valued analytically rather than simulated, so they carry no charges. + return NO_CHARGES; + } +} + +/** + * Round a point figure for display inside a reason string. + * + * @param value - The number to format. + * @returns The value rounded to one decimal place. + */ +function format(value: number): string { + return value.toFixed(1); +} + +/** + * Sort key for a valuation: situational badges rank below every scored one + * rather than being dropped from the list. + * + * @param valuation - The valuation to rank. + * @returns Its points per game, or negative infinity when situational. + */ +export function rankValue(valuation: BadgeValuation): number { + return valuation.pointsPerGame ?? -Infinity; +} + +/** + * Rank the badges the player owns, one list per tier. + * + * @param ownedBadgeIds - Ids of badges in the player's inventory. + * @param context - Inventory, baseline loadout, and simulation settings. + * @returns One recommendation per tier that has at least one owned badge. + */ +export function recommendBadges( + ownedBadgeIds: ReadonlySet, + context: BadgeContext, +): BadgeRecommendation[] { + const recommendations: BadgeRecommendation[] = []; + const baseline = measureBaseline(context); + for (const tier of TIERS) { + const owned = BADGES.filter( + (badge) => badge.tier === tier && ownedBadgeIds.has(badge.id), + ); + if (owned.length === 0) { + continue; + } + const ranked = owned + .map((badge) => valueBadge(badge, context, baseline)) + .sort((a, b) => rankValue(b) - rankValue(a)); + recommendations.push({ tier, ranked }); + } + return recommendations; +} + +/** Defaults used when the caller does not care. */ +export const DEFAULT_BADGE_CONTEXT = { + formationValues: DEFAULT_FORMATION_VALUES, + policy: DEFAULT_POLICY, + seed: 0x5eed, +} as const; diff --git a/kcd2_dice_solver/src/core/counts.ts b/kcd2_dice_solver/src/core/counts.ts new file mode 100644 index 00000000..bca7c7e4 --- /dev/null +++ b/kcd2_dice_solver/src/core/counts.ts @@ -0,0 +1,66 @@ +/** + * Packed representation of a roll. + * + * A roll is only ever needed as a *multiset* of faces, so it is stored as seven + * counts — faces 1-6 plus the wildcard slot — and those seven counts are packed + * into one integer. That packing is the reason the solver is fast: adding a die + * to a category is a single integer addition, and the scorer's memo can be keyed + * on the packed value directly, so a warm run never touches an array at all. + */ + +/** Number of die faces. */ +export const FACES = 6; + +/** Length of a count vector: six faces plus the wildcard slot. */ +export const CATEGORIES = 7; + +/** Index of the wildcard ("substitute") slot in a count vector. */ +export const WILD = 6; + +/** + * Base used for packing. Base 8 holds up to 7 dice in any one category, which is + * the most a hand can ever have: six dice plus the one a Might badge adds. + */ +export const RADIX = 8; + +/** + * A roll expressed as counts per category: indices 0-5 are faces 1-6 and index + * 6 is the number of wildcard dice. + */ +export type CountVector = readonly number[]; + +/** How much a packed key changes when one die joins a category. */ +export const SLOT_STEP: readonly number[] = Array.from( + { length: CATEGORIES }, + (_, slot) => RADIX ** (CATEGORIES - 1 - slot), +); + +/** + * Pack a count vector into an integer key. + * + * @param counts - Count vector to encode. + * @returns A non-negative integer uniquely identifying the vector. + */ +export function encode(counts: CountVector): number { + let key = 0; + for (const count of counts) { + key = key * RADIX + count; + } + return key; +} + +/** + * Unpack an integer key back into a count vector. + * + * @param key - Key produced by {@link encode} or by summing {@link SLOT_STEP}. + * @returns The seven-element count vector it stands for. + */ +export function decode(key: number): number[] { + const counts = new Array(CATEGORIES).fill(0); + let rest = key; + for (let i = CATEGORIES - 1; i >= 0; i -= 1) { + counts[i] = rest % RADIX; + rest = Math.floor(rest / RADIX); + } + return counts; +} diff --git a/kcd2_dice_solver/src/core/distribution.ts b/kcd2_dice_solver/src/core/distribution.ts new file mode 100644 index 00000000..8c7d3275 --- /dev/null +++ b/kcd2_dice_solver/src/core/distribution.ts @@ -0,0 +1,112 @@ +/** + * Exact outcome distribution for a set of loaded dice. + * + * Naively a six-dice set has 6^6 = 46,656 ordered outcomes, and the search + * evaluates tens of thousands of candidate sets. But scoring depends only on the + * *multiset* of faces, and the dice are independent, so we convolve them into a + * distribution over count vectors instead. After six dice there are at most + * C(12, 6) = 924 distinct count vectors — roughly fifty times less work, and, + * unlike sampling, exact. + * + * The count vectors stay packed as integers throughout (see `counts.ts`), so a + * convolution step is an integer addition and the scorer can look the result up + * without ever building an array. + */ + +import type { Die, Face } from "../data/dice.ts"; +import { CATEGORIES, SLOT_STEP, WILD, decode } from "./counts.ts"; + +/** One reachable outcome: a count vector and the probability of reaching it. */ +export interface Outcome { + readonly counts: readonly number[]; + readonly probability: number; +} + +/** The same distribution with count vectors left packed. */ +export interface PackedDistribution { + /** Packed count vectors, one per reachable outcome. */ + readonly keys: readonly number[]; + /** Probability of each, index-aligned with `keys`. Sums to 1. */ + readonly probabilities: readonly number[]; +} + +/** + * Collapse a die's face weights into the seven scoring categories. + * + * Faces flagged as wildcards contribute their probability to the wildcard slot + * rather than to their printed face, because that is how they score. + * + * @param die - The die to convert. + * @returns A seven-element probability vector, faces 1-6 then wildcard. + */ +export function categoryWeights(die: Die): number[] { + const weights = new Array(CATEGORIES).fill(0); + for (let face = 0; face < 6; face += 1) { + const isWild = die.wildcardFaces.includes((face + 1) as Face); + const slot = isWild ? WILD : face; + weights[slot] += die.weights[face]; + } + return weights; +} + +/** + * Drop the zero-probability categories from a die's weights. + * + * Several dice have a 0% face and the Pie die has two. Skipping them keeps the + * convolution from branching into states that can never happen. + * + * @param weights - Category weights of one die. + * @returns Key step and probability for each category the die can actually roll. + */ +function transitions(weights: readonly number[]): { step: number; weight: number }[] { + const result: { step: number; weight: number }[] = []; + for (let slot = 0; slot < CATEGORIES; slot += 1) { + const weight = weights[slot]; + if (weight > 0) { + result.push({ step: SLOT_STEP[slot], weight }); + } + } + return result; +} + +/** + * Convolve a set of dice into the exact distribution over packed count vectors. + * + * @param dice - The dice being rolled together, in any order. + * @returns Packed keys with their probabilities, which sum to 1. + */ +export function packedDistribution(dice: readonly Die[]): PackedDistribution { + let keys: number[] = [0]; + let probabilities: number[] = [1]; + + for (const die of dice) { + const steps = transitions(categoryWeights(die)); + const merged = new Map(); + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i]; + const probability = probabilities[i]; + for (const { step, weight } of steps) { + const nextKey = key + step; + merged.set(nextKey, (merged.get(nextKey) ?? 0) + probability * weight); + } + } + keys = [...merged.keys()]; + probabilities = [...merged.values()]; + } + + return { keys, probabilities }; +} + +/** + * Convolve a set of dice into the exact distribution over count vectors. + * + * @param dice - The dice being rolled together, in any order. + * @returns Every reachable outcome with its probability. Probabilities sum to 1. + */ +export function rollDistribution(dice: readonly Die[]): Outcome[] { + const { keys, probabilities } = packedDistribution(dice); + return keys.map((key, index) => ({ + counts: decode(key), + probability: probabilities[index], + })); +} diff --git a/kcd2_dice_solver/src/core/evaluate.test.ts b/kcd2_dice_solver/src/core/evaluate.test.ts new file mode 100644 index 00000000..f0fe9f50 --- /dev/null +++ b/kcd2_dice_solver/src/core/evaluate.test.ts @@ -0,0 +1,187 @@ +/** + * Cross-checks for the convolution. + * + * The whole speed story — packed integer keys, pooling identical dice, skipping + * zero-weight faces, routing wildcards into a seventh slot — is exactly the kind + * of optimisation that can produce subtly wrong probabilities while still + * looking plausible. So the distribution is checked against a naive enumeration + * of all 6^6 ordered outcomes, which shares none of that machinery. + */ + +import { describe, expect, it } from "vitest"; +import { BASE_RULES } from "../data/badges.ts"; +import { DICE, DICE_BY_ID } from "../data/dice.ts"; +import type { Die } from "../data/dice.ts"; +import { CATEGORIES, WILD } from "./counts.ts"; +import { categoryWeights, packedDistribution, rollDistribution } from "./distribution.ts"; +import { evaluateQuick, evaluateSet, percentile, scoreDistribution } from "./evaluate.ts"; +import { Scorer } from "./scoring.ts"; + +/** + * Expected score and bust probability by brute-force enumeration. + * + * Walks all 6^n ordered face combinations directly, with no packing, no pooling + * and no zero-weight pruning — deliberately the slow, obvious implementation. + * + * @param dice - The dice thrown together. + * @param scorer - Scorer to evaluate each outcome with. + * @returns Expected score and bust probability. + */ +function bruteForce( + dice: readonly Die[], + scorer: Scorer, +): { ev: number; bustProbability: number } { + const weights = dice.map(categoryWeights); + let ev = 0; + let bustProbability = 0; + + const walk = (index: number, counts: number[], probability: number): void => { + if (index === dice.length) { + const score = scorer.score(counts); + ev += probability * score; + if (score === 0) { + bustProbability += probability; + } + return; + } + for (let slot = 0; slot < CATEGORIES; slot += 1) { + const weight = weights[index][slot]; + if (weight === 0) { + continue; + } + counts[slot] += 1; + walk(index + 1, counts, probability * weight); + counts[slot] -= 1; + } + }; + + walk(0, new Array(CATEGORIES).fill(0), 1); + return { ev, bustProbability }; +} + +const die = (id: string): Die => { + const found = DICE_BY_ID.get(id); + if (!found) { + throw new Error(`no such die: ${id}`); + } + return found; +}; + +describe("convolution vs brute force", () => { + const scorer = new Scorer(BASE_RULES); + + it("agrees on six identical fair dice", () => { + const set = new Array(6).fill(die("ordinary")); + const expected = bruteForce(set, scorer); + const actual = evaluateQuick(set, scorer); + expect(actual.ev).toBeCloseTo(expected.ev, 9); + expect(actual.bustProbability).toBeCloseTo(expected.bustProbability, 9); + }); + + it("agrees on six different loaded dice", () => { + const set = [ + die("weighted"), + die("grozav"), + die("favourable"), + die("mathematician"), + die("wagoner"), + die("lucky"), + ]; + const expected = bruteForce(set, scorer); + const actual = evaluateQuick(set, scorer); + expect(actual.ev).toBeCloseTo(expected.ev, 9); + expect(actual.bustProbability).toBeCloseTo(expected.bustProbability, 9); + }); + + it("agrees on dice with zero-probability faces", () => { + // Pie die has 0% fives and 0% sixes; Favourable die has 0% twos. These are + // the faces the convolution prunes, so they need explicit cover. + const set = [ + die("pie"), + die("pie"), + die("pie"), + die("favourable"), + die("favourable"), + die("favourable"), + ]; + const expected = bruteForce(set, scorer); + const actual = evaluateQuick(set, scorer); + expect(actual.ev).toBeCloseTo(expected.ev, 9); + expect(actual.bustProbability).toBeCloseTo(expected.bustProbability, 9); + }); + + it("agrees on a mix including both wildcard dice", () => { + const set = [ + die("balatro"), + die("devils_head"), + die("devils_head"), + die("ordinary"), + die("weighted"), + die("pie"), + ]; + const expected = bruteForce(set, scorer); + const actual = evaluateQuick(set, scorer); + expect(actual.ev).toBeCloseTo(expected.ev, 9); + expect(actual.bustProbability).toBeCloseTo(expected.bustProbability, 9); + }); +}); + +describe("distribution mechanics", () => { + it("produces probabilities that sum to one", () => { + for (const target of DICE) { + const { probabilities } = packedDistribution(new Array(6).fill(target)); + const total = probabilities.reduce((sum, value) => sum + value, 0); + expect(total).toBeCloseTo(1, 12); + } + }); + + it("routes wildcard faces into the wildcard slot", () => { + const weights = categoryWeights(die("devils_head")); + // The devil's head replaces the one, so face 1 must be empty. + expect(weights[0]).toBe(0); + expect(weights[WILD]).toBeCloseTo(1 / 6, 9); + }); + + it("routes every Balatro face into the wildcard slot", () => { + const weights = categoryWeights(die("balatro")); + expect(weights[WILD]).toBeCloseTo(1, 12); + }); + + it("decodes count vectors that sum to the number of dice", () => { + const outcomes = rollDistribution(new Array(3).fill(die("ordinary"))); + for (const outcome of outcomes) { + const total = outcome.counts.reduce((sum, value) => sum + value, 0); + expect(total).toBe(3); + } + }); +}); + +describe("percentiles", () => { + const scorer = new Scorer(BASE_RULES); + + it("returns the smallest score reaching the cumulative fraction", () => { + const outcomes = [ + { score: 0, probability: 0.5 }, + { score: 100, probability: 0.3 }, + { score: 900, probability: 0.2 }, + ]; + expect(percentile(outcomes, 0.4)).toBe(0); + expect(percentile(outcomes, 0.7)).toBe(100); + expect(percentile(outcomes, 0.9)).toBe(900); + }); + + it("falls back to the largest score when probabilities fall short", () => { + // A truncated distribution summing below the requested fraction. + expect(percentile([{ score: 700, probability: 0.5 }], 0.9)).toBe(700); + }); + + it("returns zero for an empty distribution", () => { + expect(percentile([], 0.5)).toBe(0); + }); + + it("exposes the same percentile through scoreDistribution", () => { + const set = new Array(6).fill(die("ordinary")); + const distribution = scoreDistribution(set, scorer); + expect(distribution.at(0.9)).toBe(evaluateSet(set, scorer).p90); + }); +}); diff --git a/kcd2_dice_solver/src/core/evaluate.ts b/kcd2_dice_solver/src/core/evaluate.ts new file mode 100644 index 00000000..e1f1a02f --- /dev/null +++ b/kcd2_dice_solver/src/core/evaluate.ts @@ -0,0 +1,137 @@ +/** + * Exact single-roll evaluation of a dice set. + * + * This is the solver's default objective. It is policy-free: it asks only "if I + * throw these six dice, what is the expected value of the best combination on + * the table, and how often do I bust?". No push-your-luck assumptions are baked + * in, so the number is a property of the dice rather than of a playstyle. + * + * Bust probability is reported alongside because expected value alone + * over-rewards swingy sets — a set that busts a fifth of the time loses games + * that its average score says it should win. + * + * Two entry points on purpose: the search runs `evaluateQuick` tens of thousands + * of times and only needs the mean and the bust rate, while `evaluateSet` also + * sorts the distribution for a percentile and is called a handful of times on + * the results that are actually reported. + */ + +import type { Die } from "../data/dice.ts"; +import { packedDistribution } from "./distribution.ts"; +import type { Scorer } from "./scoring.ts"; + +/** What the search needs: the mean and the downside. */ +export interface QuickEvaluation { + /** Expected best-combination score of a single throw of the whole set. */ + readonly ev: number; + /** Probability the throw scores nothing at all. */ + readonly bustProbability: number; +} + +export interface Evaluation extends QuickEvaluation { + /** + * 90th-percentile throw score. Badges you save for a good moment (Warlord, + * Doppelganger) are worth what a *good* throw is worth, not an average one. + */ + readonly p90: number; +} + +/** + * Evaluate a dice set exactly, without the percentile. + * + * @param dice - The dice thrown together, normally six of them. + * @param scorer - Memoised scorer carrying the active badge rules. + * @returns Expected score and bust probability for one throw. + */ +export function evaluateQuick(dice: readonly Die[], scorer: Scorer): QuickEvaluation { + const { keys, probabilities } = packedDistribution(dice); + let ev = 0; + let bustProbability = 0; + for (let i = 0; i < keys.length; i += 1) { + const probability = probabilities[i]; + const score = scorer.scoreKey(keys[i]); + ev += probability * score; + if (score === 0) { + bustProbability += probability; + } + } + return { ev, bustProbability }; +} + +/** + * Evaluate a dice set exactly, including the 90th-percentile throw score. + * + * @param dice - The dice thrown together, normally six of them. + * @param scorer - Memoised scorer carrying the active badge rules. + * @returns Expected score, bust probability, and the 90th-percentile score. + */ +export function evaluateSet(dice: readonly Die[], scorer: Scorer): Evaluation { + const { keys, probabilities } = packedDistribution(dice); + let ev = 0; + let bustProbability = 0; + const outcomes: { score: number; probability: number }[] = []; + + for (let i = 0; i < keys.length; i += 1) { + const probability = probabilities[i]; + const score = scorer.scoreKey(keys[i]); + ev += probability * score; + if (score === 0) { + bustProbability += probability; + } + outcomes.push({ score, probability }); + } + + return { ev, bustProbability, p90: percentile(outcomes, 0.9) }; +} + +/** A score distribution you can take arbitrary percentiles of. */ +export interface ScoreDistribution { + /** Exact percentile of the single-throw score, for a fraction in [0, 1]. */ + readonly at: (fraction: number) => number; +} + +/** + * Build the exact single-throw score distribution of a dice set. + * + * @param dice - The dice thrown together. + * @param scorer - Memoised scorer carrying the active badge rules. + * @returns A distribution supporting percentile queries. + */ +export function scoreDistribution( + dice: readonly Die[], + scorer: Scorer, +): ScoreDistribution { + const { keys, probabilities } = packedDistribution(dice); + const outcomes = keys.map((key, index) => ({ + score: scorer.scoreKey(key), + probability: probabilities[index], + })); + outcomes.sort((a, b) => a.score - b.score); + return { at: (fraction) => percentile(outcomes, fraction) }; +} + +/** + * Exact percentile of a discrete score distribution. + * + * @param outcomes - Score/probability pairs; probabilities must sum to 1. + * @param fraction - Percentile to take, in [0, 1]. + * @returns The smallest score whose cumulative probability reaches `fraction`. + */ +export function percentile( + outcomes: readonly { score: number; probability: number }[], + fraction: number, +): number { + const sorted = outcomes.toSorted((a, b) => a.score - b.score); + let cumulative = 0; + let last = 0; + for (const outcome of sorted) { + cumulative += outcome.probability; + last = outcome.score; + if (cumulative >= fraction) { + return outcome.score; + } + } + // Only reached when the probabilities sum below `fraction`, i.e. an empty or + // truncated distribution; the largest score seen is the honest answer. + return last; +} diff --git a/kcd2_dice_solver/src/core/scoring.test.ts b/kcd2_dice_solver/src/core/scoring.test.ts new file mode 100644 index 00000000..7252f323 --- /dev/null +++ b/kcd2_dice_solver/src/core/scoring.test.ts @@ -0,0 +1,159 @@ +/** + * Every row of the wiki's scoring table, asserted directly. + * + * https://kingdom-come-deliverance.fandom.com/wiki/Dice/KCD2 + */ + +import { describe, expect, it } from "vitest"; +import { BASE_RULES, DEFAULT_FORMATION_VALUES } from "../data/badges.ts"; +import type { ScoringRules } from "../data/badges.ts"; +import { Scorer, ofAKindValue, tripleBase } from "./scoring.ts"; + +const plain = (): Scorer => new Scorer(BASE_RULES); +const withRules = (rules: Partial): Scorer => + new Scorer({ ...BASE_RULES, ...rules }, DEFAULT_FORMATION_VALUES); + +describe("wiki scoring table", () => { + it("scores a single one and a single five", () => { + expect(plain().scoreFaces([1, 2, 2, 3, 4, 4])).toBe(100); + expect(plain().scoreFaces([5, 2, 2, 3, 4, 4])).toBe(50); + expect(plain().scoreFaces([1, 5, 2, 2, 4, 4])).toBe(150); + }); + + it("scores three of a kind for every face", () => { + const scorer = plain(); + // Padded with 2s and 4s, which score nothing on their own. + expect(scorer.scoreFaces([1, 1, 1, 2, 4, 4])).toBe(1000); + expect(scorer.scoreFaces([2, 2, 2, 4, 4, 3])).toBe(200); + expect(scorer.scoreFaces([3, 3, 3, 2, 4, 4])).toBe(300); + expect(scorer.scoreFaces([4, 4, 4, 2, 2, 3])).toBe(400); + expect(scorer.scoreFaces([5, 5, 5, 2, 4, 4])).toBe(500); + expect(scorer.scoreFaces([6, 6, 6, 2, 4, 4])).toBe(600); + }); + + it("doubles the value for each die beyond three", () => { + const scorer = plain(); + // The wiki illustrates this on twos: 200 -> 400 -> 800 -> 1600. + expect(scorer.scoreFaces([2, 2, 2, 2, 4, 3])).toBe(400); + expect(scorer.scoreFaces([2, 2, 2, 2, 2, 3])).toBe(800); + expect(scorer.scoreFaces([2, 2, 2, 2, 2, 2])).toBe(1600); + // And on ones, where the base is 1000. + expect(scorer.scoreFaces([1, 1, 1, 1, 4, 3])).toBe(2000); + expect(scorer.scoreFaces([1, 1, 1, 1, 1, 3])).toBe(4000); + expect(scorer.scoreFaces([1, 1, 1, 1, 1, 1])).toBe(8000); + }); + + it("scores the three straights", () => { + const scorer = plain(); + // The sixth die is a dead 3: the straight already consumed the only 1 and + // the only 5, so there is nothing left to add. + expect(scorer.scoreFaces([1, 2, 3, 4, 5, 3])).toBe(500); + expect(scorer.scoreFaces([2, 3, 4, 5, 6, 3])).toBe(750); + expect(scorer.scoreFaces([1, 2, 3, 4, 5, 6])).toBe(1500); + }); + + it("reports a bust as zero", () => { + expect(plain().scoreFaces([2, 2, 3, 3, 4, 6])).toBe(0); + }); + + it("picks the best partition rather than the first one found", () => { + // Six 1s could be scored as two tripled... no: as 3+3 (1000+1000) or as + // six-of-a-kind (8000). The maximisation must find the latter. + expect(plain().scoreFaces([1, 1, 1, 1, 1, 1])).toBe(8000); + // 1,1,1,5,5,5 is two triples, not a triple plus singles. + expect(plain().scoreFaces([1, 1, 1, 5, 5, 5])).toBe(1500); + }); + + it("exposes the combination formulas", () => { + expect(tripleBase(1)).toBe(1000); + expect(tripleBase(4)).toBe(400); + expect(ofAKindValue(3, 3)).toBe(300); + expect(ofAKindValue(3, 5)).toBe(1200); + }); +}); + +describe("wildcards", () => { + it("lets a wildcard complete a straight", () => { + // 2,3,4,5,6 plus one wildcard becomes the full 1-6 straight. + expect(plain().scoreFaces([2, 3, 4, 5, 6], 1)).toBe(1500); + }); + + it("lets a wildcard complete a triple", () => { + // 6,6 + wildcard-as-6 is 600, but 2,3,4,6 + wildcard-as-5 is the 2-6 + // straight at 750 — the scorer must take the better of the two. + expect(plain().scoreFaces([6, 6, 2, 3, 4], 1)).toBe(750); + // Without the 2-6 straight available, the triple is the best use. + expect(plain().scoreFaces([6, 6, 2, 2, 4], 1)).toBe(600); + }); + + it("resolves six wildcards to the best possible roll", () => { + // Balatro's die: every face is a wildcard, so six of them are six ones. + expect(plain().scoreFaces([], 6)).toBe(8000); + }); + + it("never makes a roll worse", () => { + const scorer = plain(); + expect(scorer.scoreFaces([2, 2, 3, 3, 4], 1)).toBeGreaterThan(0); + }); +}); + +describe("badge scoring rules", () => { + it("adds the Carpenter's Cut", () => { + // 3+5 alone is worth 50 normally (the five); the Cut makes it 150. + expect(plain().scoreFaces([3, 5, 2, 2, 4, 4])).toBe(50); + expect(withRules({ cut: true }).scoreFaces([3, 5, 2, 2, 4, 4])).toBe(150); + }); + + it("adds the Executioner's Gallows", () => { + // Padded so neither a straight (no 3) nor a triple is available. + expect(plain().scoreFaces([4, 5, 6, 2, 2, 6])).toBe(50); + expect(withRules({ gallows: true }).scoreFaces([4, 5, 6, 2, 2, 6])).toBe(250); + }); + + it("adds the Priest's Eye", () => { + // Padded so no straight is available (no 2, no 4). + expect(plain().scoreFaces([1, 3, 5, 6, 6, 3])).toBe(150); + expect(withRules({ eye: true }).scoreFaces([1, 3, 5, 6, 6, 3])).toBe(300); + }); + + it("triples the Emperor's 1+1+1 but not its extensions", () => { + expect(withRules({ emperorTriple: true }).scoreFaces([1, 1, 1, 2, 4, 4])).toBe(3000); + // Four ones: either the plain four-of-a-kind (2000) or a tripled triple plus + // a single one (3000 + 100). The better reading wins on its own merits. + expect(withRules({ emperorTriple: true }).scoreFaces([1, 1, 1, 1, 2, 4])).toBe(3100); + }); + + it("doubles the Tyche three sixes", () => { + expect(withRules({ tycheDouble: true }).scoreFaces([6, 6, 6, 2, 4, 4])).toBe(1200); + }); + + it("leaves other faces alone under Emperor and Tyche", () => { + const scorer = withRules({ emperorTriple: true, tycheDouble: true }); + expect(scorer.scoreFaces([3, 3, 3, 2, 4, 4])).toBe(300); + }); +}); + +describe("scoreUsingAll", () => { + it("requires every die to be part of a combination", () => { + const scorer = plain(); + expect(scorer.scoreUsingAll([1, 0, 0, 0, 0, 0, 0])).toBe(100); + // A lone 2 cannot score, so no legal hold uses it. + expect(scorer.scoreUsingAll([0, 1, 0, 0, 0, 0, 0])).toBe(-Infinity); + // A 1 plus a dead 2 is still illegal as a hold. + expect(scorer.scoreUsingAll([1, 1, 0, 0, 0, 0, 0])).toBe(-Infinity); + }); + + it("scores an empty hold as zero", () => { + expect(plain().scoreUsingAll([0, 0, 0, 0, 0, 0, 0])).toBe(0); + }); + + it("resolves wildcards in a hold", () => { + expect(plain().scoreUsingAll([0, 0, 0, 0, 0, 0, 1])).toBe(100); + }); + + it("memoises repeated queries", () => { + const scorer = plain(); + const first = scorer.scoreUsingAll([3, 0, 0, 0, 0, 0, 0]); + expect(scorer.scoreUsingAll([3, 0, 0, 0, 0, 0, 0])).toBe(first); + }); +}); diff --git a/kcd2_dice_solver/src/core/scoring.ts b/kcd2_dice_solver/src/core/scoring.ts new file mode 100644 index 00000000..9996dbf3 --- /dev/null +++ b/kcd2_dice_solver/src/core/scoring.ts @@ -0,0 +1,282 @@ +/** + * Farkle scoring as played in Kingdom Come: Deliverance II. + * + * Transcribed from the scoring table at + * https://kingdom-come-deliverance.fandom.com/wiki/Dice/KCD2: + * + * each 1 100 + * each 5 50 + * three of a kind 1000 for ones, otherwise 100 * face + * each die beyond three doubles the set's value + * straight 1-5 500 + * straight 2-6 750 + * straight 1-6 1500 + * + * The wiki's "Four of a kind 400 / Five of a kind 800 / Six of a kind 1600" row + * is the doubling rule illustrated on twos (200 -> 400 -> 800 -> 1600), which is + * why it is expressed here as a formula rather than a lookup. + * + * A roll's score is the best partition of its dice into combinations; dice left + * over simply score nothing. That maximisation is what `bestScore` computes. + */ + +import type { ScoringRules, FormationValues } from "../data/badges.ts"; +import { DEFAULT_FORMATION_VALUES } from "../data/badges.ts"; +import { CATEGORIES, FACES, WILD, decode, encode } from "./counts.ts"; +import type { CountVector } from "./counts.ts"; + +export { CATEGORIES, FACES, WILD } from "./counts.ts"; +export type { CountVector } from "./counts.ts"; + +/** + * Base value of a three-of-a-kind of the given face. + * + * @param face - Die face, 1-6. + * @returns 1000 for ones, otherwise 100 times the face value. + */ +export function tripleBase(face: number): number { + return face === 1 ? 1000 : face * 100; +} + +/** + * Value of an n-of-a-kind, applying the "each additional die doubles" rule. + * + * @param face - Die face, 1-6. + * @param n - How many dice of that face are used, must be at least 3. + * @returns The combination's point value. + */ +export function ofAKindValue(face: number, n: number): number { + return tripleBase(face) * 2 ** (n - 3); +} + +/** Everything `bestScore` needs beyond the roll itself. */ +export interface ScoringConfig { + readonly rules: ScoringRules; + readonly formationValues: FormationValues; +} + +/** + * A memoised scorer bound to one rule set. + * + * The reachable state space is tiny (a few thousand count vectors), so the cache + * is built lazily once per rule set and then reused across every candidate dice + * set the search evaluates. + */ +export class Scorer { + private readonly cache = new Map(); + + private readonly strictCache = new Map(); + + private readonly config: ScoringConfig; + + /** + * @param rules - Which badge-granted scoring rules are active. + * @param formationValues - Point values for the badge formations. + */ + constructor( + rules: ScoringRules, + formationValues: FormationValues = DEFAULT_FORMATION_VALUES, + ) { + this.config = { rules, formationValues }; + } + + /** + * Best achievable score for a roll. + * + * @param counts - Count vector of the roll (faces 1-6 plus wildcards). + * @returns The maximum total over all legal partitions; 0 means a bust. + */ + score(counts: CountVector): number { + return this.scoreKey(encode(counts)); + } + + /** + * Best achievable score for a roll already packed into a key. + * + * The hot path uses this: on a cache hit no count vector is ever materialised, + * which is what keeps a full search in the tens of milliseconds. + * + * @param key - Packed count vector, see `counts.ts`. + * @returns The maximum total over all legal partitions; 0 means a bust. + */ + scoreKey(key: number): number { + const hit = this.cache.get(key); + if (hit !== undefined) { + return hit; + } + const value = this.compute(decode(key)); + this.cache.set(key, value); + return value; + } + + /** + * Score a plain list of face values, for tests and the simulator. + * + * @param faces - Face values rolled, each 1-6. + * @param wildcards - How many of the dice are wildcards. + * @returns The best achievable score. + */ + scoreFaces(faces: readonly number[], wildcards = 0): number { + const counts = new Array(CATEGORIES).fill(0); + for (const face of faces) { + counts[face - 1] += 1; + } + counts[WILD] = wildcards; + return this.score(counts); + } + + /** + * Best score for a set of dice in which *every* die must be part of a + * combination. + * + * This is what "holding" dice means in the mini-game: you may only set aside + * dice that actually score, so a legal hold is exactly a sub-multiset with a + * finite value here. The plain `score` cannot answer that, because it is free + * to ignore dead dice. + * + * @param counts - Count vector of the dice being held. + * @returns The best score using all of them, or `-Infinity` if impossible. + */ + scoreUsingAll(counts: CountVector): number { + const key = encode(counts); + const hit = this.strictCache.get(key); + if (hit !== undefined) { + return hit; + } + const value = this.computeStrict(counts); + this.strictCache.set(key, value); + return value; + } + + /** + * Uncached recursion for {@link scoreUsingAll}. + * + * @param counts - Count vector of the remaining dice. + * @returns Best score consuming every die, or `-Infinity`. + */ + private computeStrict(counts: CountVector): number { + const remaining = counts.reduce((sum, count) => sum + count, 0); + if (remaining === 0) { + return 0; + } + let best = -Infinity; + for (const { value, rest } of this.moves(counts)) { + const tail = this.scoreUsingAll(rest); + if (tail > -Infinity) { + best = Math.max(best, value + tail); + } + } + return best; + } + + /** + * Uncached recursive maximisation over partitions. + * + * @param counts - Count vector of the remaining dice. + * @returns The best score obtainable from those dice. + */ + private compute(counts: CountVector): number { + // Leaving dice unscored is always legal, so 0 is the floor. + let best = 0; + for (const { value, rest } of this.moves(counts)) { + best = Math.max(best, value + this.score(rest)); + } + return best; + } + + /** + * Every legal single step from a position: take one combination (or resolve + * one wildcard) and hand the leftovers back to the recursion. + * + * Both scorers share this so the rules are stated exactly once. + * + * @param counts - Count vector of the remaining dice. + * @returns Each move's point value and the dice left afterwards. + */ + private moves(counts: CountVector): { value: number; rest: number[] }[] { + const wild = counts[WILD]; + if (wild > 0) { + // A wildcard is free to become any face, so try each in turn. Resolving + // one at a time is sufficient: further wildcards are handled by the + // recursive call, and every assignment is reachable that way. + const options: { value: number; rest: number[] }[] = []; + for (let face = 0; face < FACES; face += 1) { + const rest = counts.slice(); + rest[WILD] = wild - 1; + rest[face] += 1; + options.push({ value: 0, rest }); + } + return options; + } + + const options: { value: number; rest: number[] }[] = []; + const { rules, formationValues } = this.config; + + const take = (faces: readonly number[], value: number): void => { + const rest = counts.slice(); + for (const face of faces) { + if (rest[face - 1] === 0) { + return; + } + rest[face - 1] -= 1; + } + options.push({ value, rest }); + }; + + // Straights. + take([1, 2, 3, 4, 5, 6], 1500); + take([2, 3, 4, 5, 6], 750); + take([1, 2, 3, 4, 5], 500); + + // Badge formations. + if (rules.cut) { + take([3, 5], formationValues.cut); + } + if (rules.gallows) { + take([4, 5, 6], formationValues.gallows); + } + if (rules.eye) { + take([1, 3, 5], formationValues.eye); + } + + // N-of-a-kind, for every face and every usable size. + for (let face = 0; face < FACES; face += 1) { + const available = counts[face]; + for (let n = 3; n <= available; n += 1) { + const rest = counts.slice(); + rest[face] = available - n; + options.push({ value: this.combinationValue(face + 1, n), rest }); + } + } + + // Singles: only ones and fives score on their own. + take([1], 100); + take([5], 50); + + return options; + } + + /** + * Value of an n-of-a-kind under the active badge rules. + * + * @param face - Die face, 1-6. + * @param n - Number of dice in the set. + * @returns The point value, including the Emperor and Tyche multipliers. + */ + private combinationValue(face: number, n: number): number { + const base = ofAKindValue(face, n); + // Both badges name a specific three-die combination — "every 1+1+1" and + // "three sixes" — so they multiply the triple only, not the four-, five- and + // six-of-a-kind extensions of it. Applying the multiplier to those too made + // the Emperor badge look like a 27,000-point swing in a game played to a few + // thousand. The partition search still considers splitting six ones into two + // tripled triples, so the better reading wins on its own merits. + if (n === 3 && face === 1 && this.config.rules.emperorTriple) { + return base * 3; + } + if (n === 3 && face === 6 && this.config.rules.tycheDouble) { + return base * 2; + } + return base; + } +} diff --git a/kcd2_dice_solver/src/core/search.test.ts b/kcd2_dice_solver/src/core/search.test.ts new file mode 100644 index 00000000..21c84934 --- /dev/null +++ b/kcd2_dice_solver/src/core/search.test.ts @@ -0,0 +1,223 @@ +/** + * Ground truth for the search. + * + * The exhaustive path is checked against an independent brute-force enumeration, + * and the heuristic path is checked against the exhaustive one on a problem + * small enough that the true optimum is known. + */ + +import { describe, expect, it } from "vitest"; +import { BASE_RULES } from "../data/badges.ts"; +import { DICE_BY_ID } from "../data/dice.ts"; +import type { Die } from "../data/dice.ts"; +import { evaluateQuick } from "./evaluate.ts"; +import { Scorer } from "./scoring.ts"; +import { SET_SIZE, countCandidates, findBestSet, groupInventory } from "./search.ts"; +import type { InventoryEntry } from "./search.ts"; + +const die = (id: string): Die => { + const found = DICE_BY_ID.get(id); + if (!found) { + throw new Error(`no such die: ${id}`); + } + return found; +}; + +/** + * Best six-dice set by exhaustive enumeration, written independently of the + * search's own combinatorics. + * + * @param inventory - Owned dice with counts. + * @param scorer - Scorer to evaluate with. + * @returns The best set's expected value. + */ +function bruteForceBest(inventory: readonly InventoryEntry[], scorer: Scorer): number { + const pool: Die[] = []; + for (const entry of inventory) { + for (let n = 0; n < entry.count; n += 1) { + pool.push(entry.die); + } + } + + let best = -Infinity; + const choose = (start: number, picked: Die[]): void => { + if (picked.length === SET_SIZE) { + best = Math.max(best, evaluateQuick(picked, scorer).ev); + return; + } + for (let i = start; i < pool.length; i += 1) { + picked.push(pool[i]); + choose(i + 1, picked); + picked.pop(); + } + }; + choose(0, []); + return best; +} + +describe("inventory grouping", () => { + it("pools dice whose distributions are identical", () => { + // Six of the game's dice are plain uniform 16.7% dice under different names. + const groups = groupInventory([ + { die: die("ordinary"), count: 2 }, + { die: die("hugo"), count: 2 }, + { die: die("molar"), count: 1 }, + ]); + expect(groups).toHaveLength(1); + expect(groups[0].available).toBe(5); + expect(groups[0].members).toEqual(["ordinary", "hugo", "molar"]); + }); + + it("keeps genuinely different dice apart", () => { + const groups = groupInventory([ + { die: die("ordinary"), count: 1 }, + { die: die("weighted"), count: 1 }, + ]); + expect(groups).toHaveLength(2); + }); + + it("ignores entries with no dice", () => { + expect(groupInventory([{ die: die("ordinary"), count: 0 }])).toHaveLength(0); + }); + + it("caps a group at six, since a loadout is only six dice", () => { + const groups = groupInventory([{ die: die("ordinary"), count: 99 }]); + expect(groups[0].available).toBe(SET_SIZE); + }); +}); + +describe("candidate counting", () => { + it("counts one loadout when exactly six dice are owned", () => { + expect(countCandidates(groupInventory([{ die: die("ordinary"), count: 6 }]))).toBe(1); + }); + + it("counts every split between two groups", () => { + // Six from each of two groups: 0/6, 1/5, ... 6/0 is seven loadouts. + const groups = groupInventory([ + { die: die("ordinary"), count: 6 }, + { die: die("weighted"), count: 6 }, + ]); + expect(countCandidates(groups)).toBe(7); + }); +}); + +describe("exhaustive search", () => { + const scorer = new Scorer(BASE_RULES); + + it("matches an independent brute force", () => { + const inventory: InventoryEntry[] = [ + { die: die("ordinary"), count: 2 }, + { die: die("weighted"), count: 2 }, + { die: die("grozav"), count: 2 }, + { die: die("misfortune"), count: 2 }, + ]; + const result = findBestSet(inventory, scorer); + expect(result.optimal).toBe(true); + expect(result.evaluation.ev).toBeCloseTo(bruteForceBest(inventory, scorer), 9); + }); + + it("reports distinct runner-up loadouts", () => { + const result = findBestSet( + [ + { die: die("ordinary"), count: 6 }, + { die: die("weighted"), count: 6 }, + { die: die("lucky"), count: 6 }, + ], + scorer, + { alternatives: 3 }, + ); + const signatures = [result, ...result.alternatives].map((entry) => + entry.dice + .map((d) => d.id) + .sort((a, b) => a.localeCompare(b)) + .join(","), + ); + expect(new Set(signatures).size).toBe(signatures.length); + // And they must be ordered worst-last. + const values = result.alternatives.map((a) => a.evaluation.ev); + expect(values).toEqual([...values].sort((a, b) => b - a)); + expect(result.evaluation.ev).toBeGreaterThanOrEqual(values[0]); + }); + + it("names the dice that were pooled into the pick", () => { + const result = findBestSet( + [ + { die: die("ordinary"), count: 3 }, + { die: die("hugo"), count: 3 }, + ], + scorer, + ); + expect(result.equivalentIds).toEqual(["ordinary", "hugo"]); + }); + + it("refuses an inventory smaller than a loadout", () => { + expect(() => findBestSet([{ die: die("ordinary"), count: 5 }], scorer)).toThrow( + /at least 6 dice/, + ); + }); +}); + +describe("heuristic search", () => { + const scorer = new Scorer(BASE_RULES); + + const inventory: InventoryEntry[] = [ + { die: die("ordinary"), count: 6 }, + { die: die("weighted"), count: 6 }, + { die: die("grozav"), count: 6 }, + { die: die("misfortune"), count: 6 }, + { die: die("lucky"), count: 6 }, + { die: die("pie"), count: 6 }, + { die: die("monk"), count: 6 }, + ]; + + it("finds the true optimum on a problem the exhaustive path can also solve", () => { + const truth = findBestSet(inventory, scorer); + expect(truth.optimal).toBe(true); + + // Same inventory, but forced down the hill-climb path. + const heuristic = findBestSet(inventory, scorer, { exhaustiveLimit: 0 }); + expect(heuristic.optimal).toBe(false); + expect(heuristic.evaluation.ev).toBeCloseTo(truth.evaluation.ev, 9); + }); + + it("stays within one percent of optimal from a single random restart", () => { + const truth = findBestSet(inventory, scorer); + for (const seed of [1, 2, 3, 12345]) { + const heuristic = findBestSet(inventory, scorer, { + exhaustiveLimit: 0, + restarts: 1, + seed, + }); + expect(heuristic.evaluation.ev).toBeGreaterThan(truth.evaluation.ev * 0.99); + } + }); + + it("always returns exactly six dice", () => { + // The hill climb once drove a group's count negative and produced sets of + // the wrong size; this pins the invariant. + for (const seed of [0, 7, 99, 4242]) { + const result = findBestSet(inventory, scorer, { + exhaustiveLimit: 0, + restarts: 3, + seed, + }); + expect(result.dice).toHaveLength(SET_SIZE); + for (const alternative of result.alternatives) { + expect(alternative.dice).toHaveLength(SET_SIZE); + } + } + }); + + it("respects how many of each die the player owns", () => { + const result = findBestSet( + [ + { die: die("weighted"), count: 2 }, + { die: die("ordinary"), count: 6 }, + ], + scorer, + { exhaustiveLimit: 0 }, + ); + const weighted = result.dice.filter((d) => d.id === "weighted"); + expect(weighted).toHaveLength(2); + }); +}); diff --git a/kcd2_dice_solver/src/core/search.ts b/kcd2_dice_solver/src/core/search.ts new file mode 100644 index 00000000..104f3207 --- /dev/null +++ b/kcd2_dice_solver/src/core/search.ts @@ -0,0 +1,417 @@ +/** + * Search for the best six-dice loadout that an inventory can field. + * + * A set's value is not the sum of its dice's individual values — Farkle scores + * combinations *across* the six dice (triples, straights, the doubling rule), so + * ranking dice one at a time and taking the top six is simply wrong. The search + * therefore evaluates whole sets. + * + * Two strategies, picked automatically by problem size: + * + * exhaustive every size-6 multiset the inventory allows; provably optimal + * hillClimb multi-start local search over single-die swaps; labelled as + * heuristic in the result so it is never mistaken for a proof + * + * Before either runs, dice whose *scoring* behaviour is identical are merged. + * Nine of the game's dice are plain uniform 16.7% dice under different names, + * and the three Painter's dice share one distribution, so this alone removes a + * large amount of duplicate work. + */ + +import type { Die } from "../data/dice.ts"; +import { categoryWeights } from "./distribution.ts"; +import { evaluateQuick, evaluateSet } from "./evaluate.ts"; +import type { Evaluation, QuickEvaluation } from "./evaluate.ts"; +import type { Scorer } from "./scoring.ts"; +import { mulberry32, randomInt } from "../lib/rng.ts"; + +/** How many dice go into a loadout. */ +export const SET_SIZE = 6; + +/** + * Largest number of candidate sets we are willing to enumerate exhaustively. + * At roughly 4k floating-point operations per candidate this stays well under a + * second in a worker. + */ +export const EXHAUSTIVE_LIMIT = 300_000; + +/** One line of the user's inventory. */ +export interface InventoryEntry { + readonly die: Die; + readonly count: number; +} + +/** A group of dice that score identically, pooled into one searchable type. */ +interface DiceGroup { + /** Representative die, used for evaluation. */ + readonly die: Die; + /** Every die id in the group, so the UI can explain the substitution. */ + readonly members: readonly string[]; + /** Total number of interchangeable dice available across the group. */ + readonly available: number; +} + +export interface SearchResult { + /** The recommended six dice, as concrete dice. */ + readonly dice: readonly Die[]; + /** Exact evaluation of that set. */ + readonly evaluation: Evaluation; + /** True when the whole space was enumerated, so the result is provably best. */ + readonly optimal: boolean; + /** Runner-up sets, best first, for context in the UI. */ + readonly alternatives: readonly SetCandidate[]; + /** Ids that were pooled because they score identically to the pick. */ + readonly equivalentIds: readonly string[]; +} + +export interface SetCandidate { + readonly dice: readonly Die[]; + readonly evaluation: Evaluation; +} + +/** + * Merge dice whose category-weight vectors are identical. + * + * @param inventory - Owned dice with their counts; zero counts are ignored. + * @returns One group per distinct scoring behaviour. + */ +export function groupInventory(inventory: readonly InventoryEntry[]): DiceGroup[] { + const groups = new Map(); + for (const entry of inventory) { + if (entry.count <= 0) { + continue; + } + // Round to 9 decimals so renormalisation noise cannot split a real group. + const key = categoryWeights(entry.die) + .map((weight) => weight.toFixed(9)) + .join(","); + const existing = groups.get(key); + if (existing) { + existing.members.push(entry.die.id); + existing.available += entry.count; + } else { + groups.set(key, { die: entry.die, members: [entry.die.id], available: entry.count }); + } + } + return [...groups.values()].map((group) => ({ + die: group.die, + members: group.members, + // Never search past six of a kind: a loadout is only six dice. + available: Math.min(group.available, SET_SIZE), + })); +} + +/** + * Count how many size-6 multisets an inventory admits. + * + * Used to decide between exhaustive and heuristic search without ever building + * the list, so an enormous inventory cannot blow up memory just to be measured. + * + * @param groups - Pooled dice groups. + * @returns The number of distinct loadouts, capped at `Number.MAX_SAFE_INTEGER`. + */ +export function countCandidates(groups: readonly DiceGroup[]): number { + // ways[k] = number of ways to choose k dice from the groups seen so far. + let ways = new Array(SET_SIZE + 1).fill(0); + ways[0] = 1; + for (const group of groups) { + const next = new Array(SET_SIZE + 1).fill(0); + for (let taken = 0; taken <= SET_SIZE; taken += 1) { + const base = ways[taken]; + for (let take = 0; take <= group.available && taken + take <= SET_SIZE; take += 1) { + next[taken + take] += base; + } + } + ways = next; + } + return ways[SET_SIZE]; +} + +/** A candidate expressed as how many dice are taken from each group. */ +type Selection = number[]; + +/** + * Expand a per-group selection into the concrete dice it stands for. + * + * @param groups - Pooled dice groups, in the same order as the selection. + * @param selection - How many dice to take from each group. + * @returns The six dice of the loadout. + */ +function expand(groups: readonly DiceGroup[], selection: Selection): Die[] { + const dice: Die[] = []; + groups.forEach((group, index) => { + for (let n = 0; n < selection[index]; n += 1) { + dice.push(group.die); + } + }); + return dice; +} + +/** Keeps the best few candidates seen, so the UI can show runners-up. */ +class Leaderboard { + private readonly entries: { selection: Selection; evaluation: QuickEvaluation }[] = []; + + /** Signatures already offered, so one loadout cannot occupy several slots. */ + private readonly seen = new Set(); + + /** + * @param capacity - How many candidates to retain, best first. + */ + constructor(private readonly capacity: number) {} + + /** + * Offer a candidate to the leaderboard. + * + * @param selection - Per-group counts of the candidate. + * @param evaluation - Its exact evaluation. + */ + offer(selection: Selection, evaluation: QuickEvaluation): void { + // The hill climb revisits the same optimum from several restarts, so + // without this the "alternatives" list is five copies of the winner. + const signature = selection.join(","); + if (this.seen.has(signature)) { + return; + } + if (this.entries.length >= this.capacity) { + const worst = this.entries[this.entries.length - 1]; + if (evaluation.ev <= worst.evaluation.ev) { + return; + } + } + // Recorded only once a candidate makes the board, which keeps the set small + // during an exhaustive enumeration of tens of thousands of loadouts. + this.seen.add(signature); + this.entries.push({ selection: selection.slice(), evaluation }); + this.entries.sort((a, b) => b.evaluation.ev - a.evaluation.ev); + if (this.entries.length > this.capacity) { + this.entries.length = this.capacity; + } + } + + /** + * @returns The retained candidates, best first. + */ + all(): readonly { selection: Selection; evaluation: QuickEvaluation }[] { + return this.entries; + } +} + +/** Options controlling the search. */ +export interface SearchOptions { + /** Override the exhaustive/heuristic cutoff; used by tests. */ + readonly exhaustiveLimit?: number; + /** Random restarts for the hill climb. */ + readonly restarts?: number; + /** Seed for those restarts. */ + readonly seed?: number; + /** How many runner-up sets to report. */ + readonly alternatives?: number; +} + +/** + * Find the best six-dice loadout an inventory can field. + * + * @param inventory - Owned dice with counts. + * @param scorer - Memoised scorer carrying the active badge rules. + * @param options - Optional tuning; the defaults are what the UI uses. + * @returns The recommended set, its evaluation, and runners-up. + * @throws If the inventory holds fewer than six dice in total. + */ +export function findBestSet( + inventory: readonly InventoryEntry[], + scorer: Scorer, + options: SearchOptions = {}, +): SearchResult { + const groups = groupInventory(inventory); + const total = groups.reduce((sum, group) => sum + group.available, 0); + if (total < SET_SIZE) { + throw new Error(`Need at least ${SET_SIZE} dice, inventory has ${total}`); + } + + const limit = options.exhaustiveLimit ?? EXHAUSTIVE_LIMIT; + const board = new Leaderboard((options.alternatives ?? 4) + 1); + const evaluate = (selection: Selection): QuickEvaluation => + evaluateQuick(expand(groups, selection), scorer); + + const optimal = countCandidates(groups) <= limit; + if (optimal) { + enumerateAll(groups, (selection) => { + board.offer(selection, evaluate(selection)); + }); + } else { + hillClimb(groups, evaluate, board, options); + } + + // The inventory-size check above guarantees at least one candidate was + // offered, so `all()` is never empty here. + const [best, ...rest] = board.all(); + + const usedGroups = groups.filter((_, index) => best.selection[index] > 0); + const bestDice = expand(groups, best.selection); + return { + dice: bestDice, + // Only the handful of sets we actually report pay for the percentile. + evaluation: evaluateSet(bestDice, scorer), + optimal, + alternatives: rest.map((entry) => { + const dice = expand(groups, entry.selection); + return { dice, evaluation: evaluateSet(dice, scorer) }; + }), + equivalentIds: usedGroups.flatMap((group) => group.members), + }; +} + +/** + * Enumerate every legal size-6 selection. + * + * @param groups - Pooled dice groups. + * @param visit - Called once per complete selection. + */ +function enumerateAll( + groups: readonly DiceGroup[], + visit: (selection: Selection) => void, +): void { + const selection: Selection = new Array(groups.length).fill(0); + + const recurse = (index: number, remaining: number): void => { + if (remaining === 0) { + visit(selection); + return; + } + if (index >= groups.length) { + return; + } + const max = Math.min(groups[index].available, remaining); + for (let take = max; take >= 0; take -= 1) { + selection[index] = take; + recurse(index + 1, remaining - take); + } + selection[index] = 0; + }; + + recurse(0, SET_SIZE); +} + +/** + * Multi-start hill climb over single-die swaps. + * + * Each restart begins from a legal selection and repeatedly tries moving one die + * from one group to another, keeping any move that raises expected value, until + * no single swap helps. + * + * @param groups - Pooled dice groups. + * @param evaluate - Exact evaluator for a selection. + * @param board - Leaderboard collecting the best selections seen. + * @param options - Restart count and seed. + */ +function hillClimb( + groups: readonly DiceGroup[], + evaluate: (selection: Selection) => QuickEvaluation, + board: Leaderboard, + options: SearchOptions, +): void { + const random = mulberry32(options.seed ?? 0x5eed); + const restarts = options.restarts ?? 8; + + for (let restart = 0; restart < restarts; restart += 1) { + // Restart 0 starts greedy (fill from the highest solo-EV group down); the + // rest start from random legal selections to escape local optima. + const selection = + restart === 0 ? greedySeed(groups, evaluate) : randomSeed(groups, random); + let current = evaluate(selection); + board.offer(selection, current); + + // Steepest ascent: score every legal single-die swap against the *current* + // selection, then apply only the best one and rescan. Applying swaps as they + // are found while continuing to iterate would mutate the selection out from + // under the loop bounds — an earlier version did exactly that and could + // drive a group's count negative, producing seven-dice "sets" whose value + // rises without limit and a search that never terminates. + for (;;) { + let bestMove: { from: number; to: number; evaluation: QuickEvaluation } | null = null; + + for (let from = 0; from < groups.length; from += 1) { + if (selection[from] === 0) { + continue; + } + for (let to = 0; to < groups.length; to += 1) { + if (to === from || selection[to] >= groups[to].available) { + continue; + } + selection[from] -= 1; + selection[to] += 1; + const candidate = evaluate(selection); + selection[from] += 1; + selection[to] -= 1; + + if (candidate.ev > current.ev && candidate.ev > (bestMove?.evaluation.ev ?? -Infinity)) { + bestMove = { from, to, evaluation: candidate }; + } + } + } + + if (!bestMove) { + break; + } + selection[bestMove.from] -= 1; + selection[bestMove.to] += 1; + current = bestMove.evaluation; + board.offer(selection, current); + } + } +} + +/** + * Build a starting selection by taking as many of the best solo group as + * allowed, then the next best, and so on. + * + * @param groups - Pooled dice groups. + * @param evaluate - Exact evaluator, used here on single-group selections. + * @returns A legal size-6 selection. + */ +function greedySeed( + groups: readonly DiceGroup[], + evaluate: (selection: Selection) => QuickEvaluation, +): Selection { + const solo = groups.map((group, index) => { + const probe: Selection = new Array(groups.length).fill(0); + const take = Math.min(group.available, SET_SIZE); + probe[index] = take; + // Per-die value, so a group that cannot fill all six slots is still + // comparable with one that can. + return { index, ev: evaluate(probe).ev / take }; + }); + solo.sort((a, b) => b.ev - a.ev); + + const selection: Selection = new Array(groups.length).fill(0); + let remaining = SET_SIZE; + for (const { index } of solo) { + if (remaining === 0) { + break; + } + const take = Math.min(groups[index].available, remaining); + selection[index] = take; + remaining -= take; + } + return selection; +} + +/** + * Build a random legal starting selection. + * + * @param groups - Pooled dice groups. + * @param random - Seeded random source. + * @returns A legal size-6 selection. + */ +function randomSeed(groups: readonly DiceGroup[], random: () => number): Selection { + const selection: Selection = new Array(groups.length).fill(0); + let remaining = SET_SIZE; + while (remaining > 0) { + const index = randomInt(random, groups.length); + if (selection[index] < groups[index].available) { + selection[index] += 1; + remaining -= 1; + } + } + return selection; +} diff --git a/kcd2_dice_solver/src/core/simulate.test.ts b/kcd2_dice_solver/src/core/simulate.test.ts new file mode 100644 index 00000000..48c07cf2 --- /dev/null +++ b/kcd2_dice_solver/src/core/simulate.test.ts @@ -0,0 +1,260 @@ +/** + * Tests for the turn simulator. + * + * The simulator encodes a *policy*, not a fact, so these tests pin the policy's + * stated rules rather than asserting that its output is "correct" play. + */ + +import { describe, expect, it } from "vitest"; +import { BASE_RULES } from "../data/badges.ts"; +import { DICE_BY_ID } from "../data/dice.ts"; +import type { Die } from "../data/dice.ts"; +import { mulberry32 } from "../lib/rng.ts"; +import { Scorer } from "./scoring.ts"; +import { + DEFAULT_POLICY, + NO_CHARGES, + applySetDie, + chooseHold, + simulateTurn, + simulateTurns, + subMultisets, +} from "./simulate.ts"; + +const die = (id: string): Die => { + const found = DICE_BY_ID.get(id); + if (!found) { + throw new Error(`no such die: ${id}`); + } + return found; +}; + +const scorer = new Scorer(BASE_RULES); +const sixOrdinary = new Array(6).fill(die("ordinary")); + +/** + * A die that always rolls the given face, for pinning deterministic behaviour. + * + * @param face - The face it always shows, 1-6. + * @returns Six copies of that die. + */ +const alwaysRolls = (face: number): Die[] => + new Array(6).fill({ + id: `always-${face}`, + name: `always ${face}`, + description: "", + weights: [1, 2, 3, 4, 5, 6].map((f) => (f === face ? 1 : 0)) as unknown as Die["weights"], + wildcardFaces: [], + }); + +const alwaysTwo = alwaysRolls(2); +const alwaysThree = alwaysRolls(3); + +describe("subMultisets", () => { + it("enumerates every sub-multiset including the empty one", () => { + // Two categories holding one die each: 2 x 2 = 4 sub-multisets. + const counts = [1, 1, 0, 0, 0, 0, 0]; + expect(subMultisets(counts)).toHaveLength(4); + }); + + it("scales with the count in each category", () => { + // Three of one face: 0, 1, 2 or 3 of them. + expect(subMultisets([3, 0, 0, 0, 0, 0, 0])).toHaveLength(4); + }); +}); + +describe("chooseHold", () => { + it("returns null on a bust", () => { + expect(chooseHold([0, 2, 2, 1, 0, 1, 0], scorer, DEFAULT_POLICY)).toBeNull(); + }); + + it("only ever holds dice that score", () => { + // One 1 and five dead dice: the only legal hold is that single 1. + const hold = chooseHold([1, 2, 2, 1, 0, 0, 0], scorer, DEFAULT_POLICY); + expect(hold).toEqual({ points: 100, used: 1 }); + }); + + it("declines a low-value die to keep more dice in hand", () => { + // A 1 (100) and a 5 (50) with four dead dice. Taking both scores 150 but + // leaves four dice; taking just the 1 scores 100 and leaves five. With the + // default 60-point-per-die bonus the policy keeps the extra die. + const hold = chooseHold([1, 2, 2, 0, 1, 0, 0], scorer, DEFAULT_POLICY); + expect(hold?.used).toBe(1); + expect(hold?.points).toBe(100); + }); + + it("takes everything when dice in hand are worth nothing", () => { + const greedy = { bankThreshold: 300, dieValueBonus: 0 }; + const hold = chooseHold([1, 2, 2, 0, 1, 0, 0], scorer, greedy); + expect(hold?.used).toBe(2); + expect(hold?.points).toBe(150); + }); +}); + +describe("applySetDie", () => { + it("converts the die that gains the most", () => { + // Two 1s and a dead 2: turning the 2 into a 1 completes the 1000 triple. + const counts = [2, 1, 0, 1, 0, 1, 0]; + expect(applySetDie(counts, 1, scorer)).toBe(true); + expect(counts[0]).toBe(3); + }); + + it("declines to spend the charge when nothing improves", () => { + // Six 1s are already worth 8000; converting any of them to a 3 is a loss. + const counts = [6, 0, 0, 0, 0, 0, 0]; + expect(applySetDie(counts, 3, scorer)).toBe(false); + expect(counts[0]).toBe(6); + }); + + it("declines when there is no other die to convert", () => { + const counts = [0, 0, 3, 0, 0, 0, 0]; + expect(applySetDie(counts, 3, scorer)).toBe(false); + }); +}); + +describe("simulateTurn", () => { + it("banks once the threshold is reached", () => { + const random = mulberry32(1); + const banked = simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, random); + expect(banked).toBeGreaterThanOrEqual(0); + }); + + it("is reproducible for a given seed", () => { + const first = simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, mulberry32(7)); + const second = simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, mulberry32(7)); + expect(first).toBe(second); + }); + + it("counts the throws it made", () => { + const counter = { throws: 0 }; + simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, mulberry32(3), counter); + expect(counter.throws).toBeGreaterThanOrEqual(1); + }); + + it("brings the whole set back when every die scores", () => { + // Six weighted dice are ones about two thirds of the time, and with no + // per-die bonus the policy holds every scoring die — so all six get set + // aside and the hot-dice branch has to refill the hand. A threshold above + // one throw's typical score keeps the turn going long enough to see it. + const weighted = new Array(6).fill(die("weighted")); + const counter = { throws: 0 }; + simulateTurn( + weighted, + scorer, + { bankThreshold: 20_000, dieValueBonus: 0 }, + NO_CHARGES, + mulberry32(5), + counter, + ); + expect(counter.throws).toBeGreaterThan(1); + }); + + it("always terminates, even for dice that can never bust", () => { + // Six dice that always roll a 2 always score (six of a kind, 1600). Before + // banking was checked ahead of the hot-dice re-roll, this looped forever. + const banked = simulateTurn( + alwaysTwo, + scorer, + DEFAULT_POLICY, + NO_CHARGES, + mulberry32(1), + ); + expect(banked).toBe(1600); + }); +}); + +describe("badge charges in a turn", () => { + const misfortune = new Array(6).fill(die("misfortune")); + + it("an anti-bust charge can only raise the mean", () => { + const base = simulateTurns(misfortune, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 11); + const rescued = simulateTurns( + misfortune, + scorer, + 2000, + DEFAULT_POLICY, + { ...NO_CHARGES, antibust: 1 }, + 11, + ); + expect(rescued.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn); + expect(rescued.bustRate).toBeLessThanOrEqual(base.bustRate); + }); + + it("a re-roll charge can only raise the mean", () => { + const base = simulateTurns(misfortune, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 13); + const rerolled = simulateTurns( + misfortune, + scorer, + 2000, + DEFAULT_POLICY, + { ...NO_CHARGES, reroll: 1, rerollDice: 3 }, + 13, + ); + expect(rerolled.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn); + }); + + it("an extra die can only raise the mean", () => { + const base = simulateTurns(sixOrdinary, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 17); + const bigger = simulateTurns( + sixOrdinary, + scorer, + 2000, + DEFAULT_POLICY, + { ...NO_CHARGES, extraDice: 1 }, + 17, + ); + expect(bigger.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn); + }); + + it("a transmutation charge can only raise the mean", () => { + const base = simulateTurns(sixOrdinary, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 19); + const transmuted = simulateTurns( + sixOrdinary, + scorer, + 2000, + DEFAULT_POLICY, + { ...NO_CHARGES, setDie: 1, setDieValue: 1 }, + 19, + ); + expect(transmuted.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn); + }); +}); + +describe("simulateTurns", () => { + it("reports a mean, an error bar, a bust rate and a throw count", () => { + const result = simulateTurns(sixOrdinary, scorer, 1000); + expect(result.turns).toBe(1000); + expect(result.meanPerTurn).toBeGreaterThan(0); + expect(result.standardError).toBeGreaterThan(0); + expect(result.bustRate).toBeGreaterThan(0); + expect(result.bustRate).toBeLessThan(1); + expect(result.throwsPerTurn).toBeGreaterThanOrEqual(1); + }); + + it("is reproducible for a given seed", () => { + const a = simulateTurns(sixOrdinary, scorer, 500, DEFAULT_POLICY, NO_CHARGES, 42); + const b = simulateTurns(sixOrdinary, scorer, 500, DEFAULT_POLICY, NO_CHARGES, 42); + expect(a.meanPerTurn).toBe(b.meanPerTurn); + }); + + it("reports zero variance when every turn is identical", () => { + const result = simulateTurns(alwaysTwo, scorer, 50); + expect(result.meanPerTurn).toBe(1600); + expect(result.standardError).toBe(0); + expect(result.bustRate).toBe(0); + }); + + it("reports a total bust for dice that can never score", () => { + // Two each of 2, 3 and 4 every single throw: no 1, no 5, no three of a + // kind and no straight, so every turn busts. + const noScore = [ + ...alwaysTwo.slice(0, 2), + ...alwaysThree.slice(0, 2), + ...alwaysRolls(4).slice(0, 2), + ]; + const result = simulateTurns(noScore, scorer, 20); + expect(result.meanPerTurn).toBe(0); + expect(result.standardError).toBe(0); + expect(result.bustRate).toBe(1); + }); +}); diff --git a/kcd2_dice_solver/src/core/simulate.ts b/kcd2_dice_solver/src/core/simulate.ts new file mode 100644 index 00000000..408afccd --- /dev/null +++ b/kcd2_dice_solver/src/core/simulate.ts @@ -0,0 +1,384 @@ +/** + * Monte Carlo simulation of a full push-your-luck turn. + * + * This is the solver's *secondary* objective. Unlike the exact single-roll EV in + * `evaluate.ts`, a turn's value depends on how the player plays it, and the + * policy below is an assumption rather than a fact — so it is stated explicitly + * and its knobs are exposed rather than buried: + * + * 1. Only holds in which every set-aside die scores are legal (game rule). + * 2. Among those, take the hold maximising `points + dieValueBonus * diceLeft` + * — i.e. a die still in your hand is worth something, so do not scoop up a + * lone 50-point five just because you can. + * 3. Roll again while the turn total is below `bankThreshold`; bank once it + * reaches it. + * 4. If every die scored, take the free re-roll of all six ("hot dice"), + * regardless of the threshold — it costs nothing to be holding zero dice. + * + * Turn-level badges are simulated here too, since their whole value is in how + * they change this loop. + */ + +import type { Die } from "../data/dice.ts"; +import { categoryWeights } from "./distribution.ts"; +import { CATEGORIES, WILD } from "./scoring.ts"; +import type { Scorer } from "./scoring.ts"; +import { mulberry32 } from "../lib/rng.ts"; +import type { Random } from "../lib/rng.ts"; + +/** The push-your-luck policy the simulator plays. */ +export interface TurnPolicy { + /** Bank as soon as the turn total reaches this many points. */ + readonly bankThreshold: number; + /** Notional worth of keeping one die in hand, used when choosing a hold. */ + readonly dieValueBonus: number; +} + +/** Default policy. 300 is the conventional Farkle banking threshold. */ +export const DEFAULT_POLICY: TurnPolicy = { + bankThreshold: 300, + dieValueBonus: 60, +}; + +/** Badge charges available to the simulated player, all optional. */ +export interface BadgeCharges { + /** Throws on which one extra die joins the roll. */ + readonly extraDice: number; + /** Re-throws of the whole hand after a bust. */ + readonly antibust: number; + /** Throws on which one die is forced to `setDieValue`. */ + readonly setDie: number; + /** Face a forced die takes, 1-6. */ + readonly setDieValue: number; + /** Re-rolls of the worst `rerollDice` dice, used to rescue a bust. */ + readonly reroll: number; + /** How many dice a single re-roll charge may replace. */ + readonly rerollDice: number; +} + +/** No badge equipped. */ +export const NO_CHARGES: BadgeCharges = { + extraDice: 0, + antibust: 0, + setDie: 0, + setDieValue: 1, + reroll: 0, + rerollDice: 0, +}; + +/** + * Sample one category (face index, or the wildcard slot) from a die. + * + * @param weights - Category weights summing to 1. + * @param random - Seeded random source. + * @returns The index of the sampled category. + */ +function sampleCategory(weights: readonly number[], random: Random): number { + let target = random(); + let slot = 0; + // Stop one short: the final category absorbs any floating-point drift that + // would otherwise let `target` survive the whole sweep. + for (; slot < CATEGORIES - 1; slot += 1) { + target -= weights[slot]; + if (target <= 0) { + return slot; + } + } + return WILD; +} + +/** + * Roll a hand of dice into a count vector. + * + * @param hand - Pre-computed category weights, one entry per die. + * @param random - Seeded random source. + * @returns The resulting count vector. + */ +function roll(hand: readonly (readonly number[])[], random: Random): number[] { + const counts = new Array(CATEGORIES).fill(0); + for (const weights of hand) { + counts[sampleCategory(weights, random)] += 1; + } + return counts; +} + +/** + * Every sub-multiset of a roll, as candidate holds. + * + * @param counts - Count vector of the roll. + * @returns All sub-multisets, including the empty one. + */ +export function subMultisets(counts: readonly number[]): number[][] { + let results: number[][] = [[]]; + for (const available of counts) { + const next: number[][] = []; + for (const prefix of results) { + for (let take = 0; take <= available; take += 1) { + next.push([...prefix, take]); + } + } + results = next; + } + return results; +} + +/** The hold the policy chose, plus what it is worth. */ +interface Hold { + readonly points: number; + readonly used: number; +} + +/** + * Choose which dice to set aside, per rules 1 and 2 of the policy. + * + * @param counts - Count vector of the roll. + * @param scorer - Memoised scorer carrying the active badge rules. + * @param policy - The push-your-luck policy in force. + * @returns The chosen hold, or `null` when nothing scores (a bust). + */ +export function chooseHold( + counts: readonly number[], + scorer: Scorer, + policy: TurnPolicy, +): Hold | null { + const total = counts.reduce((sum, value) => sum + value, 0); + let best: Hold | null = null; + let bestUtility = -Infinity; + + for (const candidate of subMultisets(counts)) { + const used = candidate.reduce((sum, value) => sum + value, 0); + if (used === 0) { + continue; + } + const points = scorer.scoreUsingAll(candidate); + if (points === -Infinity) { + continue; + } + const utility = points + policy.dieValueBonus * (total - used); + if (utility > bestUtility) { + bestUtility = utility; + best = { points, used }; + } + } + return best; +} + +/** + * Force one die in a roll to a chosen face, spending a Transmutation charge. + * + * Every candidate conversion is tried and the best one kept, and the charge is + * only spent if it actually raises the roll's score. A player would never burn a + * Tin Transmutation to turn a 1 into a 3, so the model must not either — an + * earlier version always converted the "least useful" die and consequently + * valued the tin badge at *minus* 95 points. + * + * @param counts - Count vector of the roll, mutated in place when it helps. + * @param value - The face the die becomes, 1-6. + * @param scorer - Memoised scorer carrying the active badge rules. + * @returns True if the charge was spent. + */ +export function applySetDie(counts: number[], value: number, scorer: Scorer): boolean { + const before = scorer.score(counts); + let bestFace = -1; + let bestScore = before; + + for (let face = 1; face <= 6; face += 1) { + if (face === value || counts[face - 1] === 0) { + continue; + } + counts[face - 1] -= 1; + counts[value - 1] += 1; + const after = scorer.score(counts); + counts[face - 1] += 1; + counts[value - 1] -= 1; + if (after > bestScore) { + bestScore = after; + bestFace = face; + } + } + + if (bestFace === -1) { + return false; + } + counts[bestFace - 1] -= 1; + counts[value - 1] += 1; + return true; +} + +/** + * Play one turn and return the points banked. + * + * @param dice - The six dice being played. + * @param scorer - Memoised scorer carrying the active badge rules. + * @param policy - The push-your-luck policy. + * @param charges - Badge charges available for this turn. + * @param random - Seeded random source. + * @param counter - Optional tally of how many throws the turn took. + * @returns Points banked; 0 if the turn ended in a bust. + */ +export function simulateTurn( + dice: readonly Die[], + scorer: Scorer, + policy: TurnPolicy, + charges: BadgeCharges, + random: Random, + counter: { throws: number } = { throws: 0 }, +): number { + const allWeights = dice.map(categoryWeights); + // The extra die a Might badge grants is modelled as another copy of the first + // die in the loadout — the loadout is uniform in practice, and the game does + // not say which die it adds. + const [spare] = allWeights; + + let inHand = allWeights.length; + let turnTotal = 0; + let extraDice = charges.extraDice; + let antibust = charges.antibust; + let setDie = charges.setDie; + let reroll = charges.reroll; + + for (;;) { + const hand = allWeights.slice(0, inHand); + if (extraDice > 0) { + hand.push(spare); + extraDice -= 1; + } + let counts = roll(hand, random); + counter.throws += 1; + + if (setDie > 0 && applySetDie(counts, charges.setDieValue, scorer)) { + setDie -= 1; + } + + let hold = chooseHold(counts, scorer, policy); + + if (hold === null && reroll > 0) { + // Re-roll the least useful dice rather than accept the bust. + reroll -= 1; + const replaced = Math.min(charges.rerollDice, hand.length); + counts = rerollWorst(counts, replaced, hand, random); + hold = chooseHold(counts, scorer, policy); + } + if (hold === null && antibust > 0) { + antibust -= 1; + continue; + } + if (hold === null) { + return 0; + } + + turnTotal += hold.points; + const remaining = hand.length - hold.used; + + // Banking is checked before the hot-dice re-roll, not after. Hot dice are + // often described as "free", but they are not: throwing six fresh dice can + // bust and forfeit the entire turn total. Re-rolling unconditionally also + // made the loop non-terminating for a die that always scores. + if (turnTotal >= policy.bankThreshold) { + return turnTotal; + } + // Every continue adds at least 50 points, so the turn always terminates. + inHand = remaining === 0 ? allWeights.length : remaining; + } +} + +/** + * Re-roll the least useful dice of a busted roll. + * + * @param counts - Count vector of the busted roll. + * @param howMany - How many dice to replace. + * @param hand - Category weights of the dice in hand. + * @param random - Seeded random source. + * @returns A new count vector with those dice re-rolled. + */ +function rerollWorst( + counts: readonly number[], + howMany: number, + hand: readonly (readonly number[])[], + random: Random, +): number[] { + const next = counts.slice(); + const preference = [2, 3, 4, 6, 5, 1]; + let removed = 0; + for (const face of preference) { + while (removed < howMany && next[face - 1] > 0) { + next[face - 1] -= 1; + removed += 1; + } + } + for (let i = 0; i < removed; i += 1) { + next[sampleCategory(hand[i], random)] += 1; + } + return next; +} + +/** Result of a Monte Carlo run. */ +export interface SimulationResult { + /** Mean points banked per turn. */ + readonly meanPerTurn: number; + /** Standard error of that mean, so noise is visible rather than implied. */ + readonly standardError: number; + /** Fraction of turns that ended in a bust. */ + readonly bustRate: number; + /** Mean number of throws made per turn. */ + readonly throwsPerTurn: number; + /** How many turns were played. */ + readonly turns: number; +} + +/** + * Golden-ratio increment used to derive a distinct stream per turn. + * + * Each turn is simulated from its own seed rather than from one long shared + * stream. That makes badge comparisons *paired*: a turn in which the badge never + * triggers plays out bit-for-bit identically with and without it, so the + * measured difference is the badge's effect and not a diverged random stream. + * With a shared stream the noise swamped the signal — badges that can only ever + * help were coming out slightly negative. + */ +const TURN_SEED_STEP = 0x9e3779b9; + +/** + * Estimate a dice set's mean banked points per turn. + * + * @param dice - The six dice being played. + * @param scorer - Memoised scorer carrying the active badge rules. + * @param turns - How many turns to simulate. + * @param policy - The push-your-luck policy. + * @param charges - Badge charges available each turn. + * @param seed - Seed for the random source, so runs are reproducible. + * @returns Mean, standard error, and bust rate. + */ +export function simulateTurns( + dice: readonly Die[], + scorer: Scorer, + turns: number, + policy: TurnPolicy = DEFAULT_POLICY, + charges: BadgeCharges = NO_CHARGES, + seed = 0x5eed, +): SimulationResult { + let sum = 0; + let sumSquares = 0; + let busts = 0; + let throws = 0; + for (let turn = 0; turn < turns; turn += 1) { + const random = mulberry32((seed + turn * TURN_SEED_STEP) >>> 0); + const counter = { throws: 0 }; + const banked = simulateTurn(dice, scorer, policy, charges, random, counter); + sum += banked; + sumSquares += banked * banked; + throws += counter.throws; + if (banked === 0) { + busts += 1; + } + } + const mean = sum / turns; + const variance = Math.max(0, sumSquares / turns - mean * mean); + return { + meanPerTurn: mean, + standardError: Math.sqrt(variance / turns), + bustRate: busts / turns, + throwsPerTurn: throws / turns, + turns, + }; +} diff --git a/kcd2_dice_solver/src/core/solve.demo.test.ts b/kcd2_dice_solver/src/core/solve.demo.test.ts new file mode 100644 index 00000000..96feaff7 --- /dev/null +++ b/kcd2_dice_solver/src/core/solve.demo.test.ts @@ -0,0 +1,69 @@ +/** + * Sanity oracles for the solver. + * + * These are the "would a human immediately call this wrong?" checks: a die that + * is obviously the best choice must be chosen, a die that is obviously worse + * than a plain one must be refused, and the counter-intuitive case (a die loaded + * onto a worthless face still beating a fair die, because six of a kind pays + * 1600) must come out right — that last one is what proves the search scores + * whole sets rather than ranking dice individually. + */ + +import { describe, expect, it } from "vitest"; +import { solve } from "./solve.ts"; + +/** Six of the same die id, for comparing against a solve result. */ +const sixOf = (id: string): string[] => new Array(6).fill(id); + +describe("solver sanity oracles", () => { + it("takes six Weighted dice when they are available", () => { + // Weighted die rolls a 1 (100 points, and the 1000-point triple) 66.7% of + // the time. Nothing in the game beats six of them. + const result = solve({ + diceCounts: { weighted: 6, ordinary: 20 }, + badgeIds: [], + simulationTurns: 2000, + }); + expect(result.dice.map((die) => die.id)).toEqual(sixOf("weighted")); + expect(result.evaluation.ev).toBeGreaterThan(2000); + expect(result.evaluation.bustProbability).toBeLessThan(0.01); + }); + + it("rejects the Die of misfortune in favour of plain ordinary dice", () => { + // Spread across 2/3/4/5 with only 4.5% ones and 4.5% sixes, it scores worse + // than an ordinary die, so a correct solver must refuse it. + const result = solve({ + diceCounts: { misfortune: 6, ordinary: 20 }, + badgeIds: [], + simulationTurns: 2000, + }); + expect(result.dice.map((die) => die.id)).toEqual(sixOf("ordinary")); + }); + + it("prefers a die concentrated on any one face over a fair die", () => { + // Grozav's die is 66.7% twos. A two is worth nothing on its own, but six of + // a kind is 1600 points, so concentration beats fairness. + const result = solve({ + diceCounts: { grozav: 6, ordinary: 20 }, + badgeIds: [], + simulationTurns: 2000, + }); + expect(result.dice.map((die) => die.id)).toEqual(sixOf("grozav")); + }); + + it("reports a provably optimal answer for a small inventory", () => { + const result = solve({ + diceCounts: { weighted: 3, ordinary: 3, lucky: 2 }, + badgeIds: [], + simulationTurns: 500, + }); + expect(result.optimal).toBe(true); + expect(result.dice).toHaveLength(6); + }); + + it("refuses to solve an inventory of fewer than six dice", () => { + expect(() => solve({ diceCounts: { ordinary: 5 }, badgeIds: [] })).toThrow( + /at least 6 dice/, + ); + }); +}); diff --git a/kcd2_dice_solver/src/core/solve.test.ts b/kcd2_dice_solver/src/core/solve.test.ts new file mode 100644 index 00000000..513d99f1 --- /dev/null +++ b/kcd2_dice_solver/src/core/solve.test.ts @@ -0,0 +1,76 @@ +/** + * Tests for the solver entrypoint: inventory in, recommendation out. + */ + +import { describe, expect, it } from "vitest"; +import { DEFAULT_FORMATION_VALUES } from "../data/badges.ts"; +import { DEFAULT_POLICY } from "./simulate.ts"; +import { solve, toInventory } from "./solve.ts"; + +describe("toInventory", () => { + it("resolves known die ids", () => { + const entries = toInventory({ weighted: 2 }); + expect(entries).toHaveLength(1); + expect(entries[0].die.name).toBe("Weighted die"); + expect(entries[0].count).toBe(2); + }); + + it("drops ids that are not dice in the game", () => { + // A saved inventory from an older build could name a die that no longer + // exists; that must not crash the solve. + expect(toInventory({ not_a_real_die: 4 })).toHaveLength(0); + }); + + it("drops entries with no dice", () => { + expect(toInventory({ weighted: 0 })).toHaveLength(0); + expect(toInventory({ weighted: -3 })).toHaveLength(0); + }); +}); + +describe("solve", () => { + it("fills in every default when only an inventory is given", () => { + const result = solve({ diceCounts: { ordinary: 6 }, badgeIds: [] }); + expect(result.dice).toHaveLength(6); + expect(result.simulation.turns).toBeGreaterThan(1000); + expect(result.inventorySize).toBe(6); + expect(result.badges).toEqual([]); + }); + + it("accepts explicit settings", () => { + const result = solve({ + diceCounts: { ordinary: 6, weighted: 6 }, + badgeIds: ["tin_headstart"], + formationValues: DEFAULT_FORMATION_VALUES, + policy: DEFAULT_POLICY, + simulationTurns: 500, + searchOptions: { alternatives: 2 }, + }); + expect(result.simulation.turns).toBe(500); + expect(result.alternatives).toHaveLength(2); + expect(result.badges).toHaveLength(1); + expect(result.badges[0].tier).toBe("tin"); + }); + + it("echoes the inventory size back for the status line", () => { + const result = solve({ + diceCounts: { ordinary: 4, weighted: 3, lucky: 2 }, + badgeIds: [], + simulationTurns: 200, + }); + expect(result.inventorySize).toBe(9); + }); + + it("solves an inventory containing the wildcard dice", () => { + // Balatro's die is six wildcards and the Devil's head die is one; both take + // a different path through the scorer and the simulator's sampler. + const result = solve({ + diceCounts: { balatro: 3, devils_head: 3, ordinary: 6 }, + badgeIds: [], + simulationTurns: 500, + }); + expect(result.dice).toHaveLength(6); + expect(result.simulation.meanPerTurn).toBeGreaterThan(0); + // Six wildcards always resolve to six ones, so nothing can beat Balatro. + expect(result.dice.filter((die) => die.id === "balatro")).toHaveLength(3); + }); +}); diff --git a/kcd2_dice_solver/src/core/solve.ts b/kcd2_dice_solver/src/core/solve.ts new file mode 100644 index 00000000..78478008 --- /dev/null +++ b/kcd2_dice_solver/src/core/solve.ts @@ -0,0 +1,116 @@ +/** + * The one entrypoint the UI calls: inventory in, recommendation out. + * + * Kept free of any DOM or React reference so it runs unchanged on the main + * thread (in tests) and inside the Web Worker (in the app). + */ + +import type { FormationValues } from "../data/badges.ts"; +import { BASE_RULES, DEFAULT_FORMATION_VALUES } from "../data/badges.ts"; +import { DICE_BY_ID } from "../data/dice.ts"; +import type { Die } from "../data/dice.ts"; +import { recommendBadges } from "./badgeValue.ts"; +import type { BadgeRecommendation } from "./badgeValue.ts"; +import type { Evaluation } from "./evaluate.ts"; +import { Scorer } from "./scoring.ts"; +import { findBestSet } from "./search.ts"; +import type { InventoryEntry, SearchOptions } from "./search.ts"; +import { DEFAULT_POLICY, NO_CHARGES, simulateTurns } from "./simulate.ts"; +import type { SimulationResult, TurnPolicy } from "./simulate.ts"; + +/** What the UI sends to the solver. */ +export interface SolveRequest { + /** Owned dice, as die id to count. */ + readonly diceCounts: Readonly>; + /** Owned badge ids. */ + readonly badgeIds: readonly string[]; + /** UNVERIFIED formation point values, editable in the UI. */ + readonly formationValues?: FormationValues; + /** Push-your-luck policy for the simulated turn EV. */ + readonly policy?: TurnPolicy; + /** Turns to simulate for the headline turn EV figure. */ + readonly simulationTurns?: number; + /** Search tuning; the UI leaves this at its defaults. */ + readonly searchOptions?: SearchOptions; +} + +/** What the solver sends back. */ +export interface SolveResponse { + /** The recommended six dice. */ + readonly dice: readonly Die[]; + /** Exact single-roll evaluation of that set. */ + readonly evaluation: Evaluation; + /** Simulated full-turn value of that set. */ + readonly simulation: SimulationResult; + /** True when the whole search space was enumerated. */ + readonly optimal: boolean; + /** Runner-up loadouts, best first. */ + readonly alternatives: readonly { dice: readonly Die[]; evaluation: Evaluation }[]; + /** Best badge per owned tier. */ + readonly badges: readonly BadgeRecommendation[]; + /** Total dice in the inventory, echoed back for the UI's status line. */ + readonly inventorySize: number; +} + +/** + * Turn a die-id-to-count record into inventory entries, dropping unknown ids. + * + * @param counts - Die ids mapped to how many are owned. + * @returns Inventory entries for ids that exist in the game data. + */ +export function toInventory(counts: Readonly>): InventoryEntry[] { + const entries: InventoryEntry[] = []; + for (const [id, count] of Object.entries(counts)) { + const die = DICE_BY_ID.get(id); + if (die && count > 0) { + entries.push({ die, count }); + } + } + return entries; +} + +/** Turns simulated for the headline turn-EV figure. */ +export const DEFAULT_SIMULATION_TURNS = 20_000; + +/** + * Solve an inventory: pick the best six dice and rank the owned badges. + * + * @param request - Inventory and options. + * @returns The recommended loadout with both objectives and badge advice. + * @throws If the inventory holds fewer than six dice. + */ +export function solve(request: SolveRequest): SolveResponse { + const inventory = toInventory(request.diceCounts); + const formationValues = request.formationValues ?? DEFAULT_FORMATION_VALUES; + const policy = request.policy ?? DEFAULT_POLICY; + const searchOptions = request.searchOptions ?? {}; + + const scorer = new Scorer(BASE_RULES, formationValues); + const search = findBestSet(inventory, scorer, searchOptions); + const simulation = simulateTurns( + search.dice, + scorer, + request.simulationTurns ?? DEFAULT_SIMULATION_TURNS, + policy, + NO_CHARGES, + ); + + const badges = recommendBadges(new Set(request.badgeIds), { + inventory, + baselineDice: search.dice, + formationValues, + policy, + seed: 0x5eed, + searchOptions, + }); + + return { + dice: search.dice, + evaluation: search.evaluation, + simulation, + optimal: search.optimal, + alternatives: search.alternatives, + badges, + inventorySize: inventory.reduce((sum, entry) => sum + entry.count, 0), + }; +} diff --git a/kcd2_dice_solver/src/data/badges.ts b/kcd2_dice_solver/src/data/badges.ts new file mode 100644 index 00000000..43081d50 --- /dev/null +++ b/kcd2_dice_solver/src/data/badges.ts @@ -0,0 +1,369 @@ +/** + * The 33 dice badges of Kingdom Come: Deliverance II. + * + * Source: https://kingdom-come-deliverance.fandom.com/wiki/Dice/KCD2 + * + * Badges only take effect when the opponent also brings a badge of the same + * tier, so the solver recommends one badge per tier rather than a single + * overall winner. + * + * UNVERIFIED CONSTANTS + * -------------------- + * Neither the fandom wiki nor Inara publishes numeric values for the three + * "Advantage" formations or for the Headstart point leads — the in-game text is + * qualitative ("a small point lead"). The constants below are explicit, + * editable guesses rather than invented data buried in a table; the UI exposes + * them so they can be corrected from the game's own help screen. + */ + +export type BadgeTier = "tin" | "silver" | "gold"; + +/** + * Extra scoring formations and multipliers granted by the five badges that + * change the scoring rules themselves. These are threaded through `bestScore`, + * so enabling one can change which six dice are optimal. + */ +export interface ScoringRules { + /** Carpenter's Advantage: the "Cut", a 3 and a 5 together. */ + readonly cut: boolean; + /** Executioner's Advantage: "The Gallows", a 4, a 5 and a 6. */ + readonly gallows: boolean; + /** Priest's Advantage: "The Eye", a 1, a 3 and a 5. */ + readonly eye: boolean; + /** Gold Emperor's badge: triple points for every 1+1+1. */ + readonly emperorTriple: boolean; + /** Gold Tyche badge: double points for three sixes. */ + readonly tycheDouble: boolean; +} + +/** Rule set with every scoring badge switched off. */ +export const BASE_RULES: ScoringRules = { + cut: false, + gallows: false, + eye: false, + emperorTriple: false, + tycheDouble: false, +}; + +/** + * Point values of the three badge-granted formations. + * + * UNVERIFIED. Defaulted to what the constituent dice are worth under the + * ordinary rules, i.e. the formation lets you *use* dice that would otherwise + * be dead but grants no bonus on top: + * Cut 3+5 -> 50 (the 5) is the only ordinary value, rounded up to 150 + * Gallows 4+5+6 -> 50 (the 5), rounded up to 250 + * Eye 1+3+5 -> 150 (the 1 and the 5), rounded up to 300 + */ +export interface FormationValues { + readonly cut: number; + readonly gallows: number; + readonly eye: number; +} + +/** Default, UNVERIFIED formation point values. Editable in the UI. */ +export const DEFAULT_FORMATION_VALUES: FormationValues = { + cut: 150, + gallows: 250, + eye: 300, +}; + +/** + * Point leads granted by the Headstart badges. + * + * UNVERIFIED. The wiki only says "small" / "moderate" / "large". + */ +export const HEADSTART_POINTS: Readonly> = { + tin: 250, + silver: 500, + gold: 1000, +}; + +/** What a badge actually does, as a discriminated union. */ +export type BadgeEffect = + /** Changes the scoring table. Evaluated jointly with the dice set. */ + | { readonly kind: "scoring"; readonly rules: Partial } + /** Roll `dice` extra dice, `uses` times per game. */ + | { readonly kind: "extraDice"; readonly dice: number; readonly uses: number } + /** Reroll up to `dice` dice of your choosing, `uses` times per game. */ + | { readonly kind: "reroll"; readonly dice: number; readonly uses: number } + /** Re-throw after a bust, `uses` times per game. */ + | { readonly kind: "antibust"; readonly uses: number } + /** Double the point value of your last throw, `uses` times per game. */ + | { readonly kind: "doubleThrow"; readonly uses: number } + /** Multiply the whole turn's score by `factor`, `uses` times per game. */ + | { readonly kind: "multiplier"; readonly factor: number; readonly uses: number } + /** Start the game `points` ahead. */ + | { readonly kind: "headstart"; readonly points: number } + /** Change a die of your choosing to `value`, `uses` times per game. */ + | { readonly kind: "setDie"; readonly value: number; readonly uses: number } + /** Purely reactive: cancels an opponent badge of the same tier. */ + | { readonly kind: "defence" }; + +export interface Badge { + readonly id: string; + readonly name: string; + readonly description: string; + readonly tier: BadgeTier; + readonly effect: BadgeEffect; +} + +export const BADGES: readonly Badge[] = [ + // ---- Tin --------------------------------------------------------------- + { + id: "carpenters_advantage", + name: "Carpenter's Advantage badge", + description: "You gain a new dice formation called the Cut, consisting of 3+5.", + tier: "tin", + effect: { kind: "scoring", rules: { cut: true } }, + }, + { + id: "tin_defence", + name: "Tin Defence badge", + description: "Use to cancel the effect of your opponent's tin badge.", + tier: "tin", + effect: { kind: "defence" }, + }, + { + id: "tin_doppelganger", + name: "Tin Doppelganger badge", + description: "Using it will double the score of your last roll. Can be used once per game.", + tier: "tin", + effect: { kind: "doubleThrow", uses: 1 }, + }, + { + id: "tin_fortune", + name: "Tin Fortune badge", + description: + "After your throw, you can reroll a die of your choosing. Can be used once per game.", + tier: "tin", + effect: { kind: "reroll", dice: 1, uses: 1 }, + }, + { + id: "tin_headstart", + name: "Tin Headstart badge", + description: "You gain a small point lead at the start of the game.", + tier: "tin", + effect: { kind: "headstart", points: HEADSTART_POINTS.tin }, + }, + { + id: "tin_might", + name: "Tin Might badge", + description: "Use it to add one extra die to your throw. Can be used once per game.", + tier: "tin", + effect: { kind: "extraDice", dice: 1, uses: 1 }, + }, + { + id: "tin_resurrection", + name: "Tin Resurrection badge", + description: "After an unlucky throw, use this badge to throw again. Can be used once per game.", + tier: "tin", + effect: { kind: "antibust", uses: 1 }, + }, + { + id: "tin_transmutation", + name: "Tin Transmutation badge", + description: + "After your throw, you can change a die of your choosing to a 3. Can be used once per game.", + tier: "tin", + effect: { kind: "setDie", value: 3, uses: 1 }, + }, + { + id: "tin_warlord", + name: "Tin Warlord badge", + description: "Used to gain a quarter more points from your turn. Can be used once per game.", + tier: "tin", + effect: { kind: "multiplier", factor: 1.25, uses: 1 }, + }, + + // ---- Silver ------------------------------------------------------------ + { + id: "bird_kings", + name: "Bird king's badge", + description: + "The badge of the rightful king of the birds will allow you to roll an additional die. Can be used twice per game.", + tier: "silver", + effect: { kind: "extraDice", dice: 1, uses: 2 }, + }, + { + id: "executioners_advantage", + name: "Executioner's Advantage badge", + description: + "You gain a new dice combination called The Gallows, which consists of 4, 5 and 6.", + tier: "silver", + effect: { kind: "scoring", rules: { gallows: true } }, + }, + { + id: "silver_defence", + name: "Silver Defence badge", + description: "Use to cancel the effect of your opponent's Silver badge in the game.", + tier: "silver", + effect: { kind: "defence" }, + }, + { + id: "silver_doppelganger", + name: "Silver Doppelganger badge", + description: "You double the score of your last throw. Can be used twice per game.", + tier: "silver", + effect: { kind: "doubleThrow", uses: 2 }, + }, + { + id: "silver_fortune", + name: "Silver Fortune badge", + description: "After your throw, you can reroll up to 2 dice. Can be used once per game.", + tier: "silver", + effect: { kind: "reroll", dice: 2, uses: 1 }, + }, + { + id: "silver_headstart", + name: "Silver Headstart badge", + description: "Use it to get a moderate point lead at the start of the game.", + tier: "silver", + effect: { kind: "headstart", points: HEADSTART_POINTS.silver }, + }, + { + id: "silver_might", + name: "Silver Might badge", + description: "Using it will allow you to roll one extra die. Can be used twice per game.", + tier: "silver", + effect: { kind: "extraDice", dice: 1, uses: 2 }, + }, + { + id: "silver_resurrection", + name: "Silver Resurrection badge", + description: + "When a throw doesn't go your way, use this badge to throw again. Can be used twice per game.", + tier: "silver", + effect: { kind: "antibust", uses: 2 }, + }, + { + id: "silver_swap_out", + name: "Silver Swap-out badge", + description: + "After your throw, you can reroll a die of your choosing. Can be used once per game.", + tier: "silver", + effect: { kind: "reroll", dice: 1, uses: 1 }, + }, + { + id: "silver_transmutation", + name: "Silver Transmutation badge", + description: + "After your throw, you can change a die of your choosing to a five. Can be used once per game.", + tier: "silver", + effect: { kind: "setDie", value: 5, uses: 1 }, + }, + { + id: "silver_warlord", + name: "Silver Warlord badge", + description: + "Using this badge will grant you 50% more points this round. Can be used once per game.", + tier: "silver", + effect: { kind: "multiplier", factor: 1.5, uses: 1 }, + }, + + // ---- Gold -------------------------------------------------------------- + { + id: "gold_defence", + name: "Gold Defence badge", + description: "Use to cancel the effect of your opponent's gold badge.", + tier: "gold", + effect: { kind: "defence" }, + }, + { + id: "gold_doppelganger", + name: "Gold Doppelganger badge", + description: + "Using this badge will double the point value of your last throw. Can be used three times per game.", + tier: "gold", + effect: { kind: "doubleThrow", uses: 3 }, + }, + { + id: "gold_emperors", + name: "Gold Emperor's badge", + description: + "Using this badge, you will gain triple points for every 1+1+1 dice combination. Emperors don't lose.", + tier: "gold", + effect: { kind: "scoring", rules: { emperorTriple: true } }, + }, + { + id: "gold_fortune", + name: "Gold Fortune badge", + description: "After your throw, you can reroll up to three dice. Can be used once per game.", + tier: "gold", + effect: { kind: "reroll", dice: 3, uses: 1 }, + }, + { + id: "gold_headstart", + name: "Gold Headstart badge", + description: "Using this badge will give you a large point lead at the start of the game.", + tier: "gold", + effect: { kind: "headstart", points: HEADSTART_POINTS.gold }, + }, + { + id: "gold_might", + name: "Gold Might badge", + description: "Using it will allow you to roll one extra die. Can be used three times per game.", + tier: "gold", + effect: { kind: "extraDice", dice: 1, uses: 3 }, + }, + { + id: "gold_resurrection", + name: "Gold Resurrection badge", + description: "After an unlucky throw, you can throw again. Can be used three times per game.", + tier: "gold", + effect: { kind: "antibust", uses: 3 }, + }, + { + id: "gold_swap_out", + name: "Gold Swap-out badge", + description: + "After your throw, you can reroll two dice with the same value. Can be used once per game.", + tier: "gold", + effect: { kind: "reroll", dice: 2, uses: 1 }, + }, + { + id: "gold_transmutation", + name: "Gold Transmutation badge", + description: "After your throw, change a die of your choosing to a 1. Can be used once per game.", + tier: "gold", + effect: { kind: "setDie", value: 1, uses: 1 }, + }, + { + id: "gold_tyche", + name: "Gold Tyche badge", + description: "If you roll three sixes in a game, you'll always earn double points for them.", + tier: "gold", + effect: { kind: "scoring", rules: { tycheDouble: true } }, + }, + { + id: "gold_warlord", + name: "Gold Warlord badge", + description: "You double the score of your turn. Can be used once per game.", + tier: "gold", + effect: { kind: "multiplier", factor: 2, uses: 1 }, + }, + { + id: "gold_wedding", + name: "Gold Wedding badge", + description: + "A memento of Agnes and Olda's big day. Using it allows you to reroll up to three dice. Can be used once per game.", + tier: "gold", + effect: { kind: "reroll", dice: 3, uses: 1 }, + }, + { + id: "priests_advantage", + name: "Priest's Advantage badge", + description: + "You gain a new dice formation called The Eye, consisting of the values 1, 3 and 5.", + tier: "gold", + effect: { kind: "scoring", rules: { eye: true } }, + }, +]; + +/** Lookup from badge id to badge. */ +export const BADGES_BY_ID: ReadonlyMap = new Map( + BADGES.map((badge) => [badge.id, badge]), +); + +/** Tiers in ascending power order, for grouping in the UI and the results. */ +export const TIERS: readonly BadgeTier[] = ["tin", "silver", "gold"]; diff --git a/kcd2_dice_solver/src/data/data.test.ts b/kcd2_dice_solver/src/data/data.test.ts new file mode 100644 index 00000000..1e80af35 --- /dev/null +++ b/kcd2_dice_solver/src/data/data.test.ts @@ -0,0 +1,139 @@ +/** + * Guards on the transcribed game data. + * + * The wiki publishes percentages rounded to one decimal place, so nothing here + * sums to exactly 100 before normalisation. These tests make sure the + * normalisation actually happened and that nothing was dropped in transcription. + */ + +import { describe, expect, it } from "vitest"; +import { + BADGES, + BADGES_BY_ID, + BASE_RULES, + DEFAULT_FORMATION_VALUES, + HEADSTART_POINTS, + TIERS, +} from "./badges.ts"; +import { DICE, DICE_BY_ID, normalise } from "./dice.ts"; + +describe("dice data", () => { + it("has all 43 dice from the wiki", () => { + expect(DICE).toHaveLength(43); + }); + + it("normalises every distribution to sum to one", () => { + for (const die of DICE) { + const total = die.weights.reduce((sum, weight) => sum + weight, 0); + expect(total).toBeCloseTo(1, 12); + } + }); + + it("has no negative weights", () => { + for (const die of DICE) { + for (const weight of die.weights) { + expect(weight).toBeGreaterThanOrEqual(0); + } + } + }); + + it("uses unique ids and names", () => { + expect(new Set(DICE.map((die) => die.id)).size).toBe(DICE.length); + expect(new Set(DICE.map((die) => die.name)).size).toBe(DICE.length); + }); + + it("indexes every die by id", () => { + expect(DICE_BY_ID.size).toBe(DICE.length); + expect(DICE_BY_ID.get("weighted")?.name).toBe("Weighted die"); + expect(DICE_BY_ID.get("nope")).toBeUndefined(); + }); + + it("is sorted by display name", () => { + const names = DICE.map((die) => die.name); + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); + }); + + it("marks exactly the two wildcard dice", () => { + const wild = DICE.filter((die) => die.wildcardFaces.length > 0); + expect(wild.map((die) => die.id).sort((a, b) => a.localeCompare(b))).toEqual([ + "balatro", + "devils_head", + ]); + expect(DICE_BY_ID.get("devils_head")?.wildcardFaces).toEqual([1]); + expect(DICE_BY_ID.get("balatro")?.wildcardFaces).toHaveLength(6); + }); + + it("keeps the dice with a zero-probability face", () => { + // Favourable die never rolls a 2; Pie die never rolls a 5 or a 6. + expect(DICE_BY_ID.get("favourable")?.weights[1]).toBe(0); + expect(DICE_BY_ID.get("pie")?.weights[4]).toBe(0); + expect(DICE_BY_ID.get("pie")?.weights[5]).toBe(0); + }); + + it("normalises a raw weight vector", () => { + expect(normalise([1, 1, 1, 1, 1, 1])).toEqual([ + 1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6, + ]); + }); +}); + +describe("badge data", () => { + it("has all 33 badges from the wiki", () => { + expect(BADGES).toHaveLength(33); + }); + + it("uses unique ids and names", () => { + expect(new Set(BADGES.map((badge) => badge.id)).size).toBe(BADGES.length); + expect(new Set(BADGES.map((badge) => badge.name)).size).toBe(BADGES.length); + }); + + it("indexes every badge by id", () => { + expect(BADGES_BY_ID.size).toBe(BADGES.length); + expect(BADGES_BY_ID.get("gold_emperors")?.tier).toBe("gold"); + }); + + it("assigns every badge to a known tier", () => { + for (const badge of BADGES) { + expect(TIERS).toContain(badge.tier); + } + }); + + it("gives every tier a defence badge", () => { + for (const tier of TIERS) { + const defence = BADGES.filter( + (badge) => badge.tier === tier && badge.effect.kind === "defence", + ); + expect(defence).toHaveLength(1); + } + }); + + it("has the five scoring badges", () => { + const scoring = BADGES.filter((badge) => badge.effect.kind === "scoring"); + expect(scoring.map((badge) => badge.id).sort((a, b) => a.localeCompare(b))).toEqual([ + "carpenters_advantage", + "executioners_advantage", + "gold_emperors", + "gold_tyche", + "priests_advantage", + ]); + }); + + it("wires the headstart badges to their tier's point lead", () => { + for (const tier of TIERS) { + const badge = BADGES.find( + (candidate) => candidate.tier === tier && candidate.effect.kind === "headstart", + ); + expect(badge?.effect).toEqual({ kind: "headstart", points: HEADSTART_POINTS[tier] }); + } + }); + + it("starts from a rule set with every scoring badge off", () => { + expect(Object.values(BASE_RULES).every((value) => value === false)).toBe(true); + }); + + it("exposes editable formation values", () => { + expect(DEFAULT_FORMATION_VALUES.cut).toBeGreaterThan(0); + expect(DEFAULT_FORMATION_VALUES.gallows).toBeGreaterThan(0); + expect(DEFAULT_FORMATION_VALUES.eye).toBeGreaterThan(0); + }); +}); diff --git a/kcd2_dice_solver/src/data/dice.ts b/kcd2_dice_solver/src/data/dice.ts new file mode 100644 index 00000000..fd91d814 --- /dev/null +++ b/kcd2_dice_solver/src/data/dice.ts @@ -0,0 +1,380 @@ +/** + * The 43 dice of Kingdom Come: Deliverance II, with their face distributions. + * + * Source (canonical): https://kingdom-come-deliverance.fandom.com/wiki/Dice/KCD2 + * Cross-check: https://inara.cz/kingdom-come-2/items-dice/ + * + * The wiki publishes percentages rounded to one decimal place, so most rows do + * not sum to exactly 100. `normaliseDie` divides through by the row total, which + * both fixes the rounding and lets us store the raw wiki numbers verbatim — the + * literals below can be diffed against the wiki without arithmetic. + * + * Where Inara disagrees with the wiki the Inara figures are recorded in a + * comment rather than silently reconciled; the wiki is authoritative here. + */ + +/** A die face, 1-6. */ +export type Face = 1 | 2 | 3 | 4 | 5 | 6; + +/** Raw six-element weight vector, indexed by `face - 1`. */ +export type Weights = readonly [number, number, number, number, number, number]; + +export interface Die { + /** Stable identifier used in saved inventories and worker messages. */ + readonly id: string; + /** Display name exactly as the wiki spells it. */ + readonly name: string; + /** Flavour text from the wiki, shown as a tooltip. */ + readonly description: string; + /** + * Probability of each face, already normalised to sum to 1. + * For faces listed as wildcards this is still the probability of *landing* on + * that face; the scoring engine then treats the result as a substitute. + */ + readonly weights: Weights; + /** + * Faces that act as a wildcard ("substitute") and may count as any value. + * Empty for all but two dice. + */ + readonly wildcardFaces: readonly Face[]; +} + +/** + * Divide a raw weight vector by its total so the probabilities sum to exactly 1. + * + * @param raw - Wiki percentages (or any non-negative weights) for faces 1-6. + * @returns The same vector scaled to sum to 1. + */ +export function normalise(raw: Weights): Weights { + const total = raw[0] + raw[1] + raw[2] + raw[3] + raw[4] + raw[5]; + return [ + raw[0] / total, + raw[1] / total, + raw[2] / total, + raw[3] / total, + raw[4] / total, + raw[5] / total, + ]; +} + +interface RawDie { + readonly id: string; + readonly name: string; + readonly description: string; + readonly weights: Weights; + readonly wildcardFaces?: readonly Face[]; +} + +const RAW_DICE: readonly RawDie[] = [ + { + id: "aranka", + name: "Aranka's die", + description: + "Aranka gave me this die to make it easier for me to play against her husband.", + weights: [28.6, 4.8, 28.6, 4.8, 28.6, 4.8], + }, + { + id: "balatro", + name: "Balatro's die", + description: + "A die crafted by the balatro Jimbo, marked with his grinning face. When it lands, you get to choose how it's counted!", + // The wiki lists no percentages for this die. Every face is a wildcard, so + // the face distribution is irrelevant to scoring: whichever face lands, the + // player chooses its value. Modelled as uniform with all six faces wild. + weights: [1, 1, 1, 1, 1, 1], + wildcardFaces: [1, 2, 3, 4, 5, 6], + }, + { + id: "cautious_cheater", + name: "Cautious cheater's die", + description: + "A die modified by an expert. It is precisely loaded, but also inconspicuous.", + weights: [23.8, 14.3, 9.5, 14.3, 23.8, 14.3], + }, + { + id: "ci", + name: "Ci die", + description: + "The second in the line of the demonic dice, she likes to get lost, but when she's with her sisters she's very strong.", + // Inara: 14.3 / 14.3 / 14.3 / 14.3 / 14.3 / 28.6 + weights: [13, 13, 13, 13, 13, 34.8], + }, + { + id: "devils_head", + name: "Devil's head die", + description: + "A die that feels hot to the touch. In place of a one it has a devil's head, which is not something folk like to gaze upon…", + // Face 1 is the devil's head, which the wiki scoring table marks "Subst" — + // it substitutes for any value. + weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7], + wildcardFaces: [1], + }, + { + id: "misfortune", + name: "Die of misfortune", + description: + "They say that when it rains, it pours. But if you play with this die, the only thing pouring will be your tears.", + weights: [4.5, 22.7, 22.7, 22.7, 22.7, 4.5], + }, + { + id: "even", + name: "Even die", + description: "A die loaded in favour of even numbers.", + weights: [6.7, 26.7, 6.7, 26.7, 6.7, 26.7], + }, + { + id: "favourable", + name: "Favourable die", + description: "A playing die that brings luck more often than you'd expect.", + weights: [33.3, 0, 5.6, 5.6, 33.3, 22.2], + }, + { + id: "fer", + name: "Fer die", + description: "The third and last in the line of demonic dice.", + // Inara: 14.3 / 14.3 / 14.3 / 14.3 / 14.3 / 28.6 + weights: [13, 13, 13, 13, 13, 34.8], + }, + { + id: "greasy", + name: "Greasy die", + description: + "A more reliable die than a normal one, but it cannot be relied on for everything.", + weights: [17.6, 11.8, 17.6, 11.7, 17.6, 23.5], + }, + { + id: "grimy", + name: "Grimy die", + description: + "One could say it will get you out of the frying pan into the fire. And sometimes it will let you stew in your own juice.", + weights: [6.2, 31.2, 6.2, 6.2, 43.7, 6.2], + }, + { + id: "grozav", + name: "Grozav's lucky die", + description: "It is actually not all that lucky, but don't tell anyone!", + weights: [6.7, 66.7, 6.7, 6.7, 6.7, 6.7], + }, + { + id: "heavenly_kingdom", + name: "Heavenly Kingdom die", + description: + "A miraculous playing die, sent from the Heavenly Kingdom to the kingdom of men.", + weights: [36.8, 10.5, 10.5, 10.5, 10.5, 21], + }, + { + id: "holy_trinity", + name: "Holy Trinity die", + description: + "A consecrated die commemorating the Holy Trinity, especially by rolling threes.", + // Inara: 21.1 / 26.3 / 36.8 / 5.3 / 5.3 / 5.3 + weights: [18.2, 22.7, 45.4, 4.5, 4.5, 4.5], + }, + { + id: "hugo", + name: "Hugo's die", + description: "A die of the most loyal regular at the Hole. It bears his likeness.", + weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7], + }, + { + id: "kings", + name: "King's die", + description: + "An anointed head has no need of a loaded die, since he is always right and can never lose.", + weights: [12.5, 18.7, 21.9, 25, 12.5, 9.4], + }, + { + id: "lousy_gambler", + name: "Lousy gambler's die", + description: "A shoddy loaded die. It's quite noticeably unbalanced.", + weights: [10, 15, 10, 15, 35, 15], + }, + { + id: "lu", + name: "Lu die", + description: "The first of the line of demonic dice.", + // Inara: 14.3 / 14.3 / 14.3 / 14.3 / 14.3 / 28.6 + weights: [13, 13, 13, 13, 13, 34.8], + }, + { + id: "lucky", + name: "Lucky die", + description: + "When fortune smiles on you, smile back. Otherwise you'll look suspicious.", + weights: [27.3, 4.5, 9.1, 13.6, 18.2, 27.3], + }, + { + id: "mathematician", + name: "Mathematician's die", + description: + "A die loaded based on the work of a forgotten mathematician. It may be better suited to solving equations than playing dice.", + weights: [16.7, 20.8, 25, 29.2, 4.2, 4.2], + }, + { + id: "molar", + name: "Molar die", + description: + "A die made out of a molar tooth. It's probably better not to know who it came from.", + weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7], + }, + { + id: "monk", + name: "Monk's die", + description: + "No one knows for sure whether it was blessed by a holy man or just spat on by a drunken monk. Either way, the die rolls... sort of.", + weights: [40, 40, 5, 5, 5, 5], + }, + { + id: "mother_of_pearl", + name: "Mother-of-pearl die", + description: + "A rare pink mother-of-pearl die. Said to bring luck in games, but misfortune in love.", + weights: [25, 8.3, 8.3, 8.3, 25, 25], + }, + { + id: "odd", + name: "Odd die", + description: "A die loaded to favour odd numbers.", + weights: [26.7, 6.7, 26.7, 6.7, 26.7, 6.7], + }, + { + id: "ordinary", + name: "Ordinary die", + description: "An ordinary playing die.", + weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7], + }, + { + id: "painted", + name: "Painted die", + description: + "One of the dice coloured using modern techniques that hide the attempt to load it.", + // Inara: 20 / 6.7 / 6.7 / 6.7 / 40 / 20 + weights: [18.7, 6.2, 6.2, 6.2, 43.7, 18.7], + }, + { + id: "painters_b", + name: "Painter's die B", + description: "Painter Voyta's blue-painted die.", + weights: [9.1, 27.2, 18.2, 18.2, 18.2, 9.1], + }, + { + id: "painters_g", + name: "Painter's die G", + description: "Painter Voyta's green-painted die.", + weights: [9.1, 27.2, 18.2, 18.2, 18.2, 9.1], + }, + { + id: "painters_r", + name: "Painter's die R", + description: "Painter Voyta's red-painted die.", + weights: [9.1, 27.2, 18.2, 18.2, 18.2, 9.1], + }, + { + id: "pie", + name: "Pie die", + description: + "Doesn't look particularly tasty, but it's well balanced towards lower numbers.", + weights: [46.2, 7.7, 23.1, 23.1, 0, 0], + }, + { + id: "premolar", + name: "Premolar die", + description: "A playing die made from a premolar tooth.", + weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7], + }, + { + id: "sad_greaser", + name: "Sad Greaser's die", + description: "A blue die mirroring the sadness of its original owner.", + weights: [26.1, 26.1, 4.3, 4.3, 26.1, 13], + }, + { + id: "saint_antiochus", + name: "Saint Antiochus' die", + description: + "The Saint Antiochus' die always rolls a 3. Not 2. And especially not 4.", + weights: [20, 6.7, 40, 6.7, 6.7, 20], + }, + { + id: "shrinking", + name: "Shrinking die", + description: + "A very lightly loaded die. One can barely differentiate it from an ordinary die.", + weights: [22.2, 11.1, 11.1, 11.1, 11.1, 33.3], + }, + { + id: "st_stephens", + name: "St. Stephen's die", + description: + "A die blessed by St. Stephen, guaranteeing favourable numbers in the game and protection from loose stones.", + weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7], + }, + { + id: "strip", + name: "Strip die", + description: "Legend has it this die will help you undress many a wench.", + weights: [25, 12.5, 12.5, 12.5, 18.8, 18.8], + }, + { + id: "tengri", + name: "Tengri's die", + description: + "This one appeared to me after I won on all of Tengri's tracks. A little token of my tremendous success, perhaps?", + weights: [28.5, 14.3, 14.3, 14.3, 14.3, 14.3], + }, + { + id: "trinity", + name: "Trinity die", + description: "For some reason, this die usually rolls a three. Why?", + // Inara: 18.2 / 9.1 / 36.4 / 9.1 / 18.2 / 9.1 + weights: [12.5, 6.2, 56.2, 6.2, 12.5, 6.2], + }, + { + id: "unbalanced", + name: "Unbalanced die", + description: + "A playing die someone tried to load to his advantage, but didn't do a very good job.", + weights: [25, 33.3, 8.3, 8.3, 16.7, 8.3], + }, + { + id: "unlucky", + name: "Unlucky die", + description: + "Sometimes Lady Luck is on your side, sometimes she isn't. With this die, she most likely isn't.", + weights: [9.1, 27.3, 18.2, 18.2, 18.2, 9.1], + }, + { + id: "wagoner", + name: "Wagoner's die", + description: + "According to legend, this die belonged to the famous Roman charioteer Arnuldus, whose tactics consisted of tiring his opponents or lulling them to sleep.", + weights: [5.6, 27.8, 33.3, 11.1, 11.1, 11.1], + }, + { + id: "weighted", + name: "Weighted die", + description: + "A mysterious playing die found in a ruined house. Suspiciously, it tends to land on 1.", + weights: [66.7, 6.7, 6.7, 6.7, 6.7, 6.7], + }, + { + id: "wisdom_tooth", + name: "Wisdom tooth die", + description: "A playing die made from a wisdom tooth.", + weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7], + }, +]; + +/** All dice, sorted by display name, with weights normalised to sum to 1. */ +export const DICE: readonly Die[] = RAW_DICE.map((raw) => ({ + id: raw.id, + name: raw.name, + description: raw.description, + weights: normalise(raw.weights), + wildcardFaces: raw.wildcardFaces ?? [], +})).sort((a, b) => a.name.localeCompare(b.name)); + +/** Lookup from die id to die, for resolving saved inventories. */ +export const DICE_BY_ID: ReadonlyMap = new Map( + DICE.map((die) => [die.id, die]), +); diff --git a/kcd2_dice_solver/src/hooks/useSolver.test.tsx b/kcd2_dice_solver/src/hooks/useSolver.test.tsx new file mode 100644 index 00000000..b530a584 --- /dev/null +++ b/kcd2_dice_solver/src/hooks/useSolver.test.tsx @@ -0,0 +1,210 @@ +/** + * Tests for the worker-driving hook, especially the parts that only matter when + * things go slightly wrong: a reply arriving for a question no longer being + * asked, and tearing the worker down on unmount. + */ + +import { act, render, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SolveRequest, SolveResponse } from "../core/solve.ts"; +import type { WorkerReply, WorkerRequest } from "../worker/solver.worker.ts"; +import { createSolverWorker, useSolver } from "./useSolver.ts"; +import type { SolverPort } from "./useSolver.ts"; + +/** A controllable port: records requests, replies only when told to. */ +function makePort(): SolverPort & { + sent: WorkerRequest[]; + reply: (reply: WorkerReply) => void; + terminated: () => number; +} { + const listeners = new Set<(event: MessageEvent) => void>(); + const sent: WorkerRequest[] = []; + let terminations = 0; + return { + sent, + postMessage: (message) => sent.push(message), + addEventListener: (_type, listener) => listeners.add(listener), + removeEventListener: (_type, listener) => listeners.delete(listener), + terminate: () => { + terminations += 1; + }, + reply: (reply) => { + act(() => { + for (const listener of listeners) { + listener({ data: reply } as MessageEvent); + } + }); + }, + terminated: () => terminations, + }; +} + +const request: SolveRequest = { diceCounts: { ordinary: 6 }, badgeIds: [] }; +const response = { dice: [], inventorySize: 6 } as unknown as SolveResponse; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("useSolver", () => { + it("sends the request once the port exists", async () => { + const port = makePort(); + renderHook(() => useSolver(request, () => port)); + await waitFor(() => { + expect(port.sent).toHaveLength(1); + }); + expect(port.sent[0].request).toEqual(request); + }); + + it("reports a successful solve", async () => { + const port = makePort(); + const { result } = renderHook(() => useSolver(request, () => port)); + await waitFor(() => { + expect(port.sent).toHaveLength(1); + }); + expect(result.current.solving).toBe(true); + + port.reply({ id: port.sent[0].id, ok: true, response }); + expect(result.current.result).toBe(response); + expect(result.current.solving).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it("reports a failure", async () => { + const port = makePort(); + const { result } = renderHook(() => useSolver(request, () => port)); + await waitFor(() => { + expect(port.sent).toHaveLength(1); + }); + + port.reply({ id: port.sent[0].id, ok: false, error: "boom" }); + expect(result.current.error).toBe("boom"); + expect(result.current.result).toBeNull(); + }); + + it("ignores a reply to a question it is no longer asking", async () => { + const port = makePort(); + const { result } = renderHook(() => useSolver(request, () => port)); + await waitFor(() => { + expect(port.sent).toHaveLength(1); + }); + + // A slow answer to an older inventory must not overwrite the current one. + port.reply({ id: port.sent[0].id - 1, ok: true, response }); + expect(result.current.result).toBeNull(); + expect(result.current.solving).toBe(true); + }); + + it("clears the result when there is nothing to solve", async () => { + const port = makePort(); + const initialProps: { current: SolveRequest | null } = { current: request }; + const { result, rerender } = renderHook( + ({ current }: { current: SolveRequest | null }) => useSolver(current, () => port), + { initialProps }, + ); + await waitFor(() => { + expect(port.sent).toHaveLength(1); + }); + port.reply({ id: port.sent[0].id, ok: true, response }); + expect(result.current.result).toBe(response); + + rerender({ current: null }); + expect(result.current.result).toBeNull(); + expect(result.current.solving).toBe(false); + // And nothing new was asked of the worker. + expect(port.sent).toHaveLength(1); + }); + + it("hides a stale error while the next solve is running", async () => { + const port = makePort(); + const initialProps: { current: SolveRequest | null } = { current: request }; + const { result, rerender } = renderHook( + ({ current }: { current: SolveRequest | null }) => useSolver(current, () => port), + { initialProps }, + ); + await waitFor(() => { + expect(port.sent).toHaveLength(1); + }); + port.reply({ id: port.sent[0].id, ok: false, error: "boom" }); + expect(result.current.error).toBe("boom"); + + // A new inventory is being solved: the old failure no longer describes it. + rerender({ current: { diceCounts: { weighted: 6 }, badgeIds: [] } }); + expect(result.current.solving).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it("terminates the worker on unmount", async () => { + const port = makePort(); + const { unmount } = renderHook(() => useSolver(request, () => port)); + await waitFor(() => { + expect(port.sent).toHaveLength(1); + }); + unmount(); + expect(port.terminated()).toBe(1); + }); + + it("copes with a port that cannot be terminated", async () => { + const port = makePort(); + const withoutTerminate: SolverPort = { + postMessage: port.postMessage.bind(port), + addEventListener: port.addEventListener.bind(port), + removeEventListener: port.removeEventListener.bind(port), + }; + const { unmount } = renderHook(() => useSolver(request, () => withoutTerminate)); + await waitFor(() => { + expect(port.sent).toHaveLength(1); + }); + expect(() => { + unmount(); + }).not.toThrow(); + }); +}); + +describe("createSolverWorker", () => { + it("builds a module worker pointing at the solver", () => { + const constructed: { url: string; options: unknown }[] = []; + class FakeWorker { + addEventListener = vi.fn(); + removeEventListener = vi.fn(); + postMessage = vi.fn(); + constructor(url: URL, options: unknown) { + constructed.push({ url: url.href, options }); + } + } + vi.stubGlobal("Worker", FakeWorker); + + createSolverWorker(); + expect(constructed).toHaveLength(1); + expect(constructed[0].url).toMatch(/solver\.worker/); + expect(constructed[0].options).toEqual({ type: "module" }); + }); +}); + +describe("useSolver default port", () => { + it("falls back to a real worker when no factory is supplied", () => { + const created: unknown[] = []; + class FakeWorker { + addEventListener = vi.fn(); + removeEventListener = vi.fn(); + postMessage = vi.fn(); + terminate = vi.fn(); + constructor() { + created.push(this); + } + } + vi.stubGlobal("Worker", FakeWorker); + + /** + * Minimal probe component exercising the hook's default argument. + * + * @returns Nothing rendered; the hook is the point. + */ + function Probe(): null { + useSolver(request); + return null; + } + render(); + expect(created).toHaveLength(1); + }); +}); diff --git a/kcd2_dice_solver/src/hooks/useSolver.ts b/kcd2_dice_solver/src/hooks/useSolver.ts new file mode 100644 index 00000000..8906bd12 --- /dev/null +++ b/kcd2_dice_solver/src/hooks/useSolver.ts @@ -0,0 +1,141 @@ +/** + * Drives the solver worker from React. + * + * Two things this has to get right: + * + * Stale replies. Requests are tagged with an incrementing id and a reply whose + * id is not the current one is dropped, so a slow solve for an inventory you + * have since changed can never overwrite the answer for the one on screen. + * + * No cascading renders. The effect does external work only — post a message — + * and every piece of state is either written from the worker's own callback or + * derived during render. Nothing calls setState synchronously in an effect + * body. + */ + +import { useEffect, useRef, useState } from "react"; +import type { SolveRequest, SolveResponse } from "../core/solve.ts"; +import type { WorkerReply, WorkerRequest } from "../worker/solver.worker.ts"; + +export interface SolverState { + /** The latest completed solve, or null before the first one lands. */ + readonly result: SolveResponse | null; + /** Why the last request could not be solved, e.g. too few dice. */ + readonly error: string | null; + /** True while a request is outstanding. */ + readonly solving: boolean; +} + +/** Anything that can accept solve requests and reply — the worker, or a fake. */ +export interface SolverPort { + postMessage: (message: WorkerRequest) => void; + addEventListener: ( + type: "message", + listener: (event: MessageEvent) => void, + ) => void; + removeEventListener: ( + type: "message", + listener: (event: MessageEvent) => void, + ) => void; + terminate?: () => void; +} + +/** + * Build the real worker. Injectable so tests can drive a fake port instead of + * relying on jsdom's worker support. + * + * @returns A port backed by a fresh Web Worker. + */ +export function createSolverWorker(): SolverPort { + return new Worker(new URL("../worker/solver.worker.ts", import.meta.url), { + type: "module", + }); +} + +/** The last answer received, tagged with the request that produced it. */ +interface Answer { + readonly request: SolveRequest; + readonly result: SolveResponse | null; + readonly error: string | null; +} + +/** + * Solve an inventory in the background, re-solving whenever it changes. + * + * @param request - The current solve request, or null when there is nothing to + * solve yet (fewer than six dice owned). + * @param createPort - Factory for the worker port; overridden in tests. Only the + * value passed on the first render is used. + * @returns The latest result, any error, and whether a solve is in flight. + */ +export function useSolver( + request: SolveRequest | null, + createPort: () => SolverPort = createSolverWorker, +): SolverState { + const [answer, setAnswer] = useState(null); + // The port lives in state, not a ref, so the request effect can depend on it + // and re-run once it exists. On the very first render there is no port yet. + const [port, setPort] = useState(null); + + const latestId = useRef(0); + const latestRequest = useRef(null); + // Only the first factory is ever used. Holding it in a ref means an inline + // arrow at the call site cannot tear the worker down and rebuild it on every + // render — building a worker is expensive, and any solve in flight would be + // discarded with it. + const factory = useRef(createPort); + + useEffect(() => { + const created = factory.current(); + setPort(created); + + const onMessage = (event: MessageEvent): void => { + const reply = event.data; + const asked = latestRequest.current; + // Drop answers to questions we are no longer asking. + if (reply.id !== latestId.current || !asked) { + return; + } + setAnswer( + reply.ok + ? { request: asked, result: reply.response, error: null } + : { request: asked, result: null, error: reply.error }, + ); + }; + + created.addEventListener("message", onMessage); + return () => { + created.removeEventListener("message", onMessage); + created.terminate?.(); + setPort(null); + }; + }, []); + + useEffect(() => { + if (!port) { + return; + } + // Bump the id even when there is nothing to ask, so a reply already on its + // way for the previous inventory is discarded rather than applied. + latestId.current += 1; + latestRequest.current = request; + if (request) { + port.postMessage({ id: latestId.current, request }); + } + }, [port, request]); + + if (!request) { + return { result: null, error: null, solving: false }; + } + if (!answer) { + return { result: null, error: null, solving: true }; + } + const solving = answer.request !== request; + return { + // The previous answer stays on screen while a new one is computed, so the + // panel dims rather than blanking out. + result: answer.result, + error: solving ? null : answer.error, + solving, + }; +} diff --git a/kcd2_dice_solver/src/index.css b/kcd2_dice_solver/src/index.css new file mode 100644 index 00000000..b55e6f5d --- /dev/null +++ b/kcd2_dice_solver/src/index.css @@ -0,0 +1,422 @@ +:root { + --bg: #17150f; + --panel: #201d16; + --panel-2: #2a261d; + --line: #3a3428; + --text: #e8e0cf; + --muted: #9d947f; + --accent: #c8a24a; + --good: #7fb069; + --bad: #c96b5b; + + color-scheme: dark; + font-family: "Iosevka", "Cascadia Code", ui-monospace, "SF Mono", Menlo, monospace; + font-size: 15px; + background: var(--bg); + color: var(--text); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +.app { + max-width: 1400px; + margin: 0 auto; + padding: 1.5rem; +} + +header h1 { + margin: 0; + font-size: 1.5rem; + color: var(--accent); + letter-spacing: 0.02em; +} + +.subtitle { + margin: 0.25rem 0 1.25rem; + color: var(--muted); +} + +.columns { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + gap: 1.5rem; + align-items: start; +} + +@media (max-width: 1000px) { + .columns { + grid-template-columns: 1fr; + } +} + +section.inventory, +.result, +.caveats { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 1rem; +} + +.results { + display: flex; + flex-direction: column; + gap: 1rem; + position: sticky; + top: 1rem; +} + +/* --- toolbar ---------------------------------------------------------- */ + +.toolbar { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.search { + flex: 1; + padding: 0.5rem 0.75rem; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + font: inherit; +} + +.search:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; +} + +.count { + color: var(--muted); + min-width: 5rem; + text-align: right; +} + +.clear, +.stepper-button { + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + font: inherit; + cursor: pointer; + padding: 0.4rem 0.7rem; +} + +.clear:disabled, +.stepper-button:disabled { + opacity: 0.35; + cursor: default; +} + +.clear:not(:disabled):hover, +.stepper-button:not(:disabled):hover { + border-color: var(--accent); +} + +.hint { + color: var(--muted); + font-size: 0.85rem; + margin: 0.6rem 0; +} + +/* --- dice list -------------------------------------------------------- */ + +.die-list, +.badge-list, +.loadout, +.alternatives ul, +.caveats ul { + list-style: none; + margin: 0; + padding: 0; +} + +.die-list { + max-height: 46vh; + overflow-y: auto; + border: 1px solid var(--line); + border-radius: 6px; +} + +.die-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.5rem; + border-bottom: 1px solid var(--line); +} + +.die-row:last-child { + border-bottom: none; +} + +.die-row.owned { + background: var(--panel-2); +} + +.die-main { + flex: 1; + display: flex; + align-items: center; + gap: 0.75rem; + background: none; + border: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + padding: 0.35rem; + border-radius: 4px; + min-width: 0; +} + +.die-main:hover { + background: rgba(200, 162, 74, 0.1); +} + +.die-name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.die-name mark { + background: none; + color: var(--accent); + font-weight: 700; +} + +.die-faces { + display: flex; + gap: 2px; + align-items: flex-end; + height: 26px; +} + +.die-face { + width: 12px; + height: 100%; + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; + background: rgba(255, 255, 255, 0.04); +} + +.die-face-bar { + display: block; + background: var(--accent); + opacity: 0.75; + max-height: 100%; +} + +.die-face-label { + position: absolute; + bottom: -0.9rem; + left: 0; + right: 0; + text-align: center; + font-size: 0.55rem; + color: var(--muted); +} + +.die-tag { + font-size: 0.7rem; + color: var(--bg); + background: var(--accent); + border-radius: 3px; + padding: 0 0.3rem; +} + +/* --- stepper ---------------------------------------------------------- */ + +.stepper { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.stepper-value { + width: 3rem; + padding: 0.35rem; + text-align: center; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + font: inherit; +} + +/* --- badges ----------------------------------------------------------- */ + +.tier { + margin: 0.75rem 0 0.35rem; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.tier-tin { + color: #a8a196; +} +.tier-silver { + color: #cfd4da; +} +.tier-gold { + color: var(--accent); +} + +.badge-list label { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.15rem 0; + cursor: pointer; +} + +.badge-picker { + columns: 2; + column-gap: 1.5rem; +} + +.badge-picker section { + break-inside: avoid; +} + +/* --- results ---------------------------------------------------------- */ + +.result h2, +.caveats h3 { + margin: 1rem 0 0.5rem; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.result h2:first-child { + margin-top: 0; +} + +.result.stale { + opacity: 0.55; +} + +.loadout li { + display: flex; + gap: 0.6rem; + padding: 0.2rem 0; +} + +.loadout-count { + color: var(--accent); + font-weight: 700; + min-width: 2rem; +} + +.stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + margin: 0.75rem 0; +} + +.stats div { + background: var(--panel-2); + border-radius: 6px; + padding: 0.5rem 0.6rem; +} + +.stats dt { + font-size: 0.7rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.stats dd { + margin: 0.15rem 0 0; + font-size: 1.25rem; + color: var(--good); +} + +.stats small { + font-size: 0.7rem; + color: var(--muted); +} + +.provenance, +.badge-reason, +.empty { + color: var(--muted); + font-size: 0.85rem; +} + +.empty { + margin: 0; + padding: 1rem 0; +} + +.alt-ev { + color: var(--accent); + display: inline-block; + min-width: 4rem; +} + +.alternatives li, +.badge-result details li { + padding: 0.15rem 0; + font-size: 0.85rem; +} + +summary { + cursor: pointer; + color: var(--muted); + font-size: 0.85rem; + padding: 0.35rem 0; +} + +.badge-result { + border-top: 1px solid var(--line); + padding-top: 0.5rem; +} + +.badge-pick { + margin: 0.2rem 0; +} + +.badge-value { + color: var(--good); +} + +.caveats { + font-size: 0.8rem; + color: var(--muted); +} + +.caveats li { + padding: 0.2rem 0 0.2rem 0.9rem; + position: relative; +} + +.caveats li::before { + content: "·"; + position: absolute; + left: 0.2rem; + color: var(--accent); +} + +.caveats strong { + color: var(--text); +} diff --git a/kcd2_dice_solver/src/lib/fuzzy.test.ts b/kcd2_dice_solver/src/lib/fuzzy.test.ts new file mode 100644 index 00000000..3ad1c0f9 --- /dev/null +++ b/kcd2_dice_solver/src/lib/fuzzy.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { DICE } from "../data/dice.ts"; +import { fuzzyFilter, fuzzyMatch } from "./fuzzy.ts"; + +describe("fuzzyMatch", () => { + it("matches an empty query against anything, scoring zero", () => { + expect(fuzzyMatch("", "Weighted die")).toEqual({ score: 0, indices: [] }); + }); + + it("returns null when a character is missing", () => { + expect(fuzzyMatch("zzz", "Weighted die")).toBeNull(); + }); + + it("matches a subsequence and reports where", () => { + const match = fuzzyMatch("wei", "Weighted die"); + expect(match?.indices).toEqual([0, 1, 2]); + }); + + it("ignores spaces in the query", () => { + expect(fuzzyMatch("pain b", "Painter's die B")).not.toBeNull(); + }); + + it("rewards consecutive characters over scattered ones", () => { + const consecutive = fuzzyMatch("luck", "Lucky die"); + const scattered = fuzzyMatch("luck", "Lousy gambler's cheek"); + expect(consecutive?.score).toBeGreaterThan(scattered?.score ?? Infinity); + }); + + it("rewards matches at a word boundary", () => { + // The 'd' of "die" is at a boundary in both, but "Odd die" is shorter and + // starts its match at index 0. + const boundary = fuzzyMatch("od", "Odd die"); + expect(boundary).not.toBeNull(); + }); +}); + +describe("fuzzyFilter", () => { + const byName = (die: { name: string }): string => die.name; + + it("keeps the original order for an empty query", () => { + const result = fuzzyFilter("", DICE, byName); + expect(result.map((match) => match.item.id)).toEqual(DICE.map((die) => die.id)); + }); + + it("finds the Weighted die from a three-letter prefix", () => { + const result = fuzzyFilter("wei", DICE, byName); + expect(result[0]?.item.id).toBe("weighted"); + }); + + it("finds Painter's die B specifically", () => { + const result = fuzzyFilter("painb", DICE, byName); + expect(result[0]?.item.id).toBe("painters_b"); + }); + + it("returns nothing when no name matches", () => { + expect(fuzzyFilter("qqqq", DICE, byName)).toEqual([]); + }); +}); diff --git a/kcd2_dice_solver/src/lib/fuzzy.ts b/kcd2_dice_solver/src/lib/fuzzy.ts new file mode 100644 index 00000000..5ef08afd --- /dev/null +++ b/kcd2_dice_solver/src/lib/fuzzy.ts @@ -0,0 +1,148 @@ +/** + * Small fuzzy matcher for picking dice and badges by name. + * + * Written here rather than pulled in as a dependency: it is short, it needs to + * be covered by the project's 100% bar anyway, and the ranking rules are + * specific to this list ("wei" should find "Weighted die", "paintb" should find + * "Painter's die B"). + * + * It finds the *best* alignment rather than the leftmost one. A greedy + * left-to-right scan is a few lines shorter, but it binds the "d" of a query + * like "die" to the "d" that ends "Weighted", which both under-scores the right + * answer and underlines the wrong letters. + */ + +export interface FuzzyMatch { + readonly item: T; + readonly score: number; + /** Indices in the haystack that the query matched, for highlighting. */ + readonly indices: readonly number[]; +} + +/** A scored alignment of the query onto the haystack. */ +interface Alignment { + readonly score: number; + readonly indices: readonly number[]; +} + +/** Points for matching a character at all. */ +const MATCH_BONUS = 1; +/** Extra points when a character continues an unbroken run. */ +const ADJACENCY_BONUS = 4; +/** Extra points when a character starts a word. */ +const BOUNDARY_BONUS = 3; +/** Per-character penalty, so a short name outranks a long one that also matches. */ +const LENGTH_PENALTY = 0.01; + +/** + * Whether the character at `index` begins a word. + * + * @param target - The lower-cased haystack. + * @param index - Position to test. + * @returns True when the previous character is not alphanumeric. + */ +function isWordStart(target: string, index: number): boolean { + if (index === 0) { + return true; + } + return !/[a-z0-9]/.test(target[index - 1]); +} + +/** + * Match a query against a string as a subsequence, taking the best alignment. + * + * Scoring rewards, in order: matching at all, matching consecutively, and + * matching at a word boundary. A shorter haystack breaks ties, so an exact short + * name beats a long name that merely contains the letters. + * + * @param query - What the user typed; case-insensitive, spaces are ignored. + * @param haystack - The string being searched. + * @returns The best alignment with its score and matched indices, or null. + */ +export function fuzzyMatch(query: string, haystack: string): Alignment | null { + const needle = query.toLowerCase().replace(/\s+/g, ""); + const target = haystack.toLowerCase(); + if (needle.length === 0) { + return { score: 0, indices: [] }; + } + + // best[i][k] = the best alignment of needle[i..] given needle[i] sits at k. + // Filled from the end of the needle backwards so each entry only depends on + // entries already computed. + const memo = new Map(); + + const solve = (i: number, at: number): Alignment | null => { + const key = i * target.length + at; + const cached = memo.get(key); + if (cached !== undefined) { + return cached; + } + + const own = MATCH_BONUS + (isWordStart(target, at) ? BOUNDARY_BONUS : 0); + let result: Alignment | null = null; + + if (i === needle.length - 1) { + result = { score: own, indices: [at] }; + } else { + for (let next = at + 1; next < target.length; next += 1) { + if (target[next] !== needle[i + 1]) { + continue; + } + const tail = solve(i + 1, next); + if (!tail) { + continue; + } + const adjacency = next === at + 1 ? ADJACENCY_BONUS : 0; + const score = own + adjacency + tail.score; + if (!result || score > result.score) { + result = { score, indices: [at, ...tail.indices] }; + } + } + } + + memo.set(key, result); + return result; + }; + + let best: Alignment | null = null; + for (let start = 0; start < target.length; start += 1) { + if (target[start] !== needle[0]) { + continue; + } + const candidate = solve(0, start); + if (candidate && (!best || candidate.score > best.score)) { + best = candidate; + } + } + + if (!best) { + return null; + } + return { score: best.score - target.length * LENGTH_PENALTY, indices: best.indices }; +} + +/** + * Filter and rank a list by fuzzy name match. + * + * @param query - What the user typed. An empty query keeps the original order. + * @param items - The items to search. + * @param key - Extracts the searchable string from an item. + * @returns Matching items, best first. + */ +export function fuzzyFilter( + query: string, + items: readonly T[], + key: (item: T) => string, +): FuzzyMatch[] { + const matches: FuzzyMatch[] = []; + for (const item of items) { + const match = fuzzyMatch(query, key(item)); + if (match) { + matches.push({ item, score: match.score, indices: match.indices }); + } + } + if (query.trim().length === 0) { + return matches; + } + return matches.sort((a, b) => b.score - a.score); +} diff --git a/kcd2_dice_solver/src/lib/lib.test.ts b/kcd2_dice_solver/src/lib/lib.test.ts new file mode 100644 index 00000000..160a3062 --- /dev/null +++ b/kcd2_dice_solver/src/lib/lib.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { fuzzyFilter, fuzzyMatch } from "./fuzzy.ts"; +import { mulberry32, randomInt } from "./rng.ts"; + +describe("fuzzyMatch", () => { + it("matches a subsequence", () => { + expect(fuzzyMatch("wei", "Weighted die")).not.toBeNull(); + expect(fuzzyMatch("wgt", "Weighted die")).not.toBeNull(); + }); + + it("rejects letters that are not present in order", () => { + expect(fuzzyMatch("zzz", "Weighted die")).toBeNull(); + expect(fuzzyMatch("eiw", "Weighted die")).toBeNull(); + }); + + it("treats an empty query as a match with no highlights", () => { + expect(fuzzyMatch("", "Weighted die")).toEqual({ score: 0, indices: [] }); + }); + + it("ignores case and spaces in the query", () => { + const match = fuzzyMatch("W E I", "Weighted die"); + expect(match?.indices).toEqual([0, 1, 2]); + }); + + it("reports where it matched, for highlighting", () => { + // The best alignment, not the leftmost one: "die" underlines the word + // "die" rather than binding its 'd' to the end of "Weighted". + expect(fuzzyMatch("die", "Weighted die")?.indices).toEqual([9, 10, 11]); + expect(fuzzyMatch("die", "Ordinary die")?.indices).toEqual([9, 10, 11]); + }); + + it("prefers a whole-word run over an earlier scattered one", () => { + // "pain" could scatter across "Painter's" or land on it contiguously. + expect(fuzzyMatch("paint", "Painter's die B")?.indices).toEqual([0, 1, 2, 3, 4]); + }); + + it("scores consecutive matches above scattered ones", () => { + const consecutive = fuzzyMatch("wei", "Weighted die"); + const scattered = fuzzyMatch("wei", "Wagoner's engraved ivory"); + expect(consecutive?.score).toBeGreaterThan(scattered?.score ?? Infinity); + }); + + it("rewards matching at a word boundary", () => { + // The 'd' of "die" starts a word; the 'd' inside "Wisdom" does not. + const boundary = fuzzyMatch("d", "a die"); + const inside = fuzzyMatch("d", "Wisdom"); + expect(boundary?.score).toBeGreaterThan(inside?.score ?? Infinity); + }); +}); + +describe("fuzzyFilter", () => { + const names = [ + "Weighted die", + "Wisdom tooth die", + "Ordinary die", + "Painter's die B", + "Painter's die R", + ]; + + it("finds the die a short prefix is aiming at", () => { + const results = fuzzyFilter("wei", names, (name) => name); + expect(results[0].item).toBe("Weighted die"); + }); + + it("narrows to one die as the query gets specific", () => { + const results = fuzzyFilter("paintb", names, (name) => name); + expect(results[0].item).toBe("Painter's die B"); + }); + + it("drops non-matches", () => { + expect(fuzzyFilter("zzzz", names, (name) => name)).toHaveLength(0); + }); + + it("keeps the original order for an empty query", () => { + const results = fuzzyFilter(" ", names, (name) => name); + expect(results.map((result) => result.item)).toEqual(names); + }); +}); + +describe("mulberry32", () => { + it("is deterministic for a seed", () => { + const a = mulberry32(42); + const b = mulberry32(42); + expect([a(), a(), a()]).toEqual([b(), b(), b()]); + }); + + it("differs between seeds", () => { + expect(mulberry32(1)()).not.toBe(mulberry32(2)()); + }); + + it("stays within [0, 1)", () => { + const random = mulberry32(7); + for (let i = 0; i < 1000; i += 1) { + const value = random(); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + } + }); + + it("bounds randomInt below the given bound", () => { + const random = mulberry32(9); + for (let i = 0; i < 500; i += 1) { + const value = randomInt(random, 5); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(5); + expect(Number.isInteger(value)).toBe(true); + } + }); +}); diff --git a/kcd2_dice_solver/src/lib/rng.test.ts b/kcd2_dice_solver/src/lib/rng.test.ts new file mode 100644 index 00000000..d698b5ec --- /dev/null +++ b/kcd2_dice_solver/src/lib/rng.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { mulberry32, randomInt } from "./rng.ts"; + +describe("mulberry32", () => { + it("is deterministic for a given seed", () => { + const a = mulberry32(42); + const b = mulberry32(42); + const first = [a(), a(), a()]; + const second = [b(), b(), b()]; + expect(first).toEqual(second); + }); + + it("produces different streams for different seeds", () => { + expect(mulberry32(1)()).not.toBe(mulberry32(2)()); + }); + + it("stays within [0, 1)", () => { + const random = mulberry32(7); + for (let i = 0; i < 1000; i += 1) { + const value = random(); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + } + }); +}); + +describe("randomInt", () => { + it("stays within the requested bound", () => { + const random = mulberry32(99); + const seen = new Set(); + for (let i = 0; i < 500; i += 1) { + const value = randomInt(random, 5); + expect(Number.isInteger(value)).toBe(true); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(5); + seen.add(value); + } + // With 500 draws over five buckets, every bucket should appear. + expect(seen.size).toBe(5); + }); +}); diff --git a/kcd2_dice_solver/src/lib/rng.ts b/kcd2_dice_solver/src/lib/rng.ts new file mode 100644 index 00000000..6525b31d --- /dev/null +++ b/kcd2_dice_solver/src/lib/rng.ts @@ -0,0 +1,38 @@ +/** + * Seeded pseudo-random number generator (mulberry32). + * + * `Math.random` is deliberately not used anywhere in this project: the Monte + * Carlo turn simulator and the search's random restarts both need to be + * reproducible so their tests can assert exact numbers instead of tolerances. + */ + +/** A function returning the next value in [0, 1). */ +export type Random = () => number; + +/** + * Create a deterministic random source. + * + * @param seed - Any 32-bit integer; the same seed always yields the same stream. + * @returns A function producing successive values in [0, 1). + */ +export function mulberry32(seed: number): Random { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Pick a random integer in `[0, bound)`. + * + * @param random - Source of randomness. + * @param bound - Exclusive upper bound, must be positive. + * @returns An integer in the half-open range. + */ +export function randomInt(random: Random, bound: number): number { + return Math.floor(random() * bound); +} diff --git a/kcd2_dice_solver/src/main.tsx b/kcd2_dice_solver/src/main.tsx new file mode 100644 index 00000000..7c95d0ac --- /dev/null +++ b/kcd2_dice_solver/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.tsx"; +import "./index.css"; + +const container = document.getElementById("root"); +if (!container) { + throw new Error("missing #root"); +} +createRoot(container).render( + + + , +); diff --git a/kcd2_dice_solver/src/test/setup.ts b/kcd2_dice_solver/src/test/setup.ts new file mode 100644 index 00000000..f149f27a --- /dev/null +++ b/kcd2_dice_solver/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/kcd2_dice_solver/src/worker/solver.worker.ts b/kcd2_dice_solver/src/worker/solver.worker.ts new file mode 100644 index 00000000..6c55fc7d --- /dev/null +++ b/kcd2_dice_solver/src/worker/solver.worker.ts @@ -0,0 +1,37 @@ +/** + * Web Worker wrapper around the solver. + * + * A full solve runs tens of thousands of exact evaluations plus a Monte Carlo + * run, which takes a couple of seconds on a large inventory. Off the main thread + * the search box and the steppers stay responsive while it runs. + */ + +import { solve } from "../core/solve.ts"; +import type { SolveRequest, SolveResponse } from "../core/solve.ts"; + +/** A solve request tagged so stale replies can be discarded. */ +export interface WorkerRequest { + readonly id: number; + readonly request: SolveRequest; +} + +/** Either a completed solve or the reason it could not run. */ +export type WorkerReply = + | { readonly id: number; readonly ok: true; readonly response: SolveResponse } + | { readonly id: number; readonly ok: false; readonly error: string }; + +self.addEventListener("message", (event: MessageEvent) => { + const { id, request } = event.data; + try { + const response = solve(request); + const reply: WorkerReply = { id, ok: true, response }; + self.postMessage(reply); + } catch (error) { + const reply: WorkerReply = { + id, + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + self.postMessage(reply); + } +}); diff --git a/kcd2_dice_solver/tsconfig.json b/kcd2_dice_solver/tsconfig.json new file mode 100644 index 00000000..eceacf36 --- /dev/null +++ b/kcd2_dice_solver/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "esnext", + "lib": ["ES2023", "DOM", "DOM.Iterable", "WebWorker"], + "jsx": "react-jsx", + "types": ["vite/client", "vitest/globals"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true + + /* Deliberately NOT enabling noUncheckedIndexedAccess: every array index in + the solver is already bounds-derived, so the flag only forces `?? 0` + fallbacks and `if (!x) continue` guards on branches that cannot execute. + Those are untestable, and this project holds a 100% branch-coverage bar — + the flag would buy nothing but coverage pragmas. */ + }, + "include": ["src", "vite.config.ts"] +} diff --git a/kcd2_dice_solver/vite.config.ts b/kcd2_dice_solver/vite.config.ts new file mode 100644 index 00000000..daef9aa3 --- /dev/null +++ b/kcd2_dice_solver/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5174, + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.{ts,tsx}"], + // main.tsx is the DOM bootstrap and solver.worker.ts is the worker + // entrypoint: both are pure wiring with no branches worth asserting, and + // neither can run under jsdom without mocking away the thing under test. + exclude: ["src/main.tsx", "src/worker/solver.worker.ts", "src/test/**"], + thresholds: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + }, + }, +});