diff --git a/poker-stakes/.gitignore b/poker-stakes/.gitignore
new file mode 100644
index 0000000..0e75fe5
--- /dev/null
+++ b/poker-stakes/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+coverage
diff --git a/poker-stakes/eslint.config.js b/poker-stakes/eslint.config.js
new file mode 100644
index 0000000..aa051ed
--- /dev/null
+++ b/poker-stakes/eslint.config.js
@@ -0,0 +1,47 @@
+import js from '@eslint/js'
+import react from '@eslint-react/eslint-plugin'
+import vitest from '@vitest/eslint-plugin'
+import { defineConfig } from 'eslint/config'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import globals from 'globals'
+import tseslint from 'typescript-eslint'
+
+export default defineConfig(
+ { ignores: ['dist', 'coverage', 'node_modules'] },
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.strictTypeChecked,
+ tseslint.configs.stylisticTypeChecked,
+ react.configs['strict-type-checked'],
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ globals: globals.browser,
+ parserOptions: {
+ projectService: true,
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ rules: {
+ '@typescript-eslint/explicit-function-return-type': 'error',
+ '@typescript-eslint/consistent-type-imports': 'error',
+ '@typescript-eslint/no-shadow': 'error',
+ 'no-console': 'error',
+ 'eqeqeq': ['error', 'always'],
+ 'prefer-const': ['error', { destructuring: 'all' }],
+ },
+ },
+ {
+ files: ['src/**/*.test.{ts,tsx}', 'src/test/**/*.ts'],
+ extends: [vitest.configs.recommended],
+ languageOptions: { globals: { ...globals.browser, ...globals.node } },
+ },
+ {
+ files: ['eslint.config.js'],
+ extends: [tseslint.configs.disableTypeChecked],
+ },
+)
diff --git a/poker-stakes/index.html b/poker-stakes/index.html
new file mode 100644
index 0000000..0d0b28a
--- /dev/null
+++ b/poker-stakes/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Poker Stakes — lose the hand, pay the price
+
+
+
+
+
+
diff --git a/poker-stakes/package-lock.json b/poker-stakes/package-lock.json
new file mode 100644
index 0000000..ac9494e
--- /dev/null
+++ b/poker-stakes/package-lock.json
@@ -0,0 +1,4852 @@
+{
+ "name": "poker-stakes",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "poker-stakes",
+ "version": "0.1.0",
+ "dependencies": {
+ "react": "19.2.8",
+ "react-dom": "19.2.8"
+ },
+ "devDependencies": {
+ "@eslint-react/eslint-plugin": "5.18.1",
+ "@eslint/js": "10.0.1",
+ "@testing-library/jest-dom": "7.0.0",
+ "@testing-library/react": "16.3.2",
+ "@testing-library/user-event": "14.6.1",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.4",
+ "@vitejs/plugin-react": "6.0.5",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/eslint-plugin": "1.6.24",
+ "eslint": "10.8.0",
+ "eslint-plugin-react-hooks": "7.1.1",
+ "eslint-plugin-react-refresh": "0.5.3",
+ "globals": "17.8.0",
+ "jsdom": "30.0.1",
+ "typescript": "6.0.3",
+ "typescript-eslint": "8.65.0",
+ "vite": "8.2.0",
+ "vitest": "4.1.10"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "6.0.7",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
+ "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.3.0",
+ "@csstools/css-color-parser": "^4.1.10",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
+ "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "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/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.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/core/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
+ "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+ "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
+ "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.0",
+ "@csstools/css-calc": "^3.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
+ "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+ "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint-react/ast": {
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/@eslint-react/ast/-/ast-5.18.1.tgz",
+ "integrity": "sha512-6NTpdv+Z6eHT2lc8DkfJZUG6tUvZQkRBlvBpYuoeen+HIvzz59aOt1YEL4dd6FQlXD4DPbCBGXCjcV879hT8Sg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "^8.65.0",
+ "@typescript-eslint/typescript-estree": "^8.65.0",
+ "@typescript-eslint/utils": "^8.65.0",
+ "string-ts": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "typescript": "*"
+ }
+ },
+ "node_modules/@eslint-react/core": {
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/@eslint-react/core/-/core-5.18.1.tgz",
+ "integrity": "sha512-n2avrghmy22iMFJWGXKacyMMvtaFrPqq1Yydd7L1DKmUe/hubjD4Uc1ZKV/F2xRh8dQ0Gp2b5TVv7H/Nq5DxUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/ast": "5.18.1",
+ "@eslint-react/eslint": "5.18.1",
+ "@eslint-react/shared": "5.18.1",
+ "@eslint-react/var": "5.18.1",
+ "@typescript-eslint/scope-manager": "^8.65.0",
+ "@typescript-eslint/types": "^8.65.0",
+ "@typescript-eslint/utils": "^8.65.0",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "typescript": "*"
+ }
+ },
+ "node_modules/@eslint-react/eslint": {
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/@eslint-react/eslint/-/eslint-5.18.1.tgz",
+ "integrity": "sha512-T7AOWRwc1+gBEQUFoGSsBVHmWpsanGWVU52NTmWxij9ZGIlqbG+JC6vpCBa6IzGPKbulCu4B/np2JIydZQM1pQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/utils": "^8.65.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "typescript": "*"
+ }
+ },
+ "node_modules/@eslint-react/eslint-plugin": {
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/@eslint-react/eslint-plugin/-/eslint-plugin-5.18.1.tgz",
+ "integrity": "sha512-BlgYu//MKKnLHhQhh6E6ChSbEYj/a/cuRnyc+akOUtn8qCEqf2Le+/Rcxjfso3ujDjrq5yZt8E28aNOubtE6WA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/shared": "5.18.1",
+ "eslint-plugin-react-dom": "5.18.1",
+ "eslint-plugin-react-jsx": "5.18.1",
+ "eslint-plugin-react-naming-convention": "5.18.1",
+ "eslint-plugin-react-rsc": "5.18.1",
+ "eslint-plugin-react-web-api": "5.18.1",
+ "eslint-plugin-react-x": "5.18.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "typescript": "*"
+ }
+ },
+ "node_modules/@eslint-react/jsx": {
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/@eslint-react/jsx/-/jsx-5.18.1.tgz",
+ "integrity": "sha512-sT0JQ0yw/prFwtEu0cplzS0fZBpNHL1s7awHrD5xKABpqsVS2wofB52p2TSX2nmuNj011hSUW+xlu58GXYZ/fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/ast": "5.18.1",
+ "@eslint-react/eslint": "5.18.1",
+ "@eslint-react/shared": "5.18.1",
+ "@eslint-react/var": "5.18.1",
+ "@typescript-eslint/types": "^8.65.0",
+ "@typescript-eslint/utils": "^8.65.0",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "typescript": "*"
+ }
+ },
+ "node_modules/@eslint-react/shared": {
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-5.18.1.tgz",
+ "integrity": "sha512-sLtaIw5PGVriltlBfDD/wUvF4+K3EDGuFhoUF2HKSIdhf28glyrcf6s2d0HnjFXUjXKcD325MNWL7Cectvs2GA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/eslint": "5.18.1",
+ "@typescript-eslint/utils": "^8.65.0",
+ "ts-pattern": "^5.9.0",
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "typescript": "*"
+ }
+ },
+ "node_modules/@eslint-react/var": {
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-5.18.1.tgz",
+ "integrity": "sha512-w1OP5/EJ4ocGWSw+nSpZnfehlnAvfVxOrlgx7k+xTHACyoFQZZXrehOYC7M/0ArQA68sOXo0C1ymEfTJE30StQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/ast": "5.18.1",
+ "@eslint-react/eslint": "5.18.1",
+ "@typescript-eslint/scope-manager": "^8.65.0",
+ "@typescript-eslint/types": "^8.65.0",
+ "@typescript-eslint/utils": "^8.65.0",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "typescript": "*"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.23.5",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
+ "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^3.0.5",
+ "debug": "^4.3.1",
+ "minimatch": "^10.2.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
+ "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
+ "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
+ "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
+ "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "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
+ }
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.144.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
+ "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
+ "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz",
+ "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz",
+ "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz",
+ "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz",
+ "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz",
+ "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz",
+ "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
+ "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz",
+ "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz",
+ "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz",
+ "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz",
+ "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz",
+ "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=22",
+ "npm": ">=6",
+ "yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=10 <11"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "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
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/esrecurse": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.18",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
+ "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.65.0",
+ "@typescript-eslint/type-utils": "8.65.0",
+ "@typescript-eslint/utils": "8.65.0",
+ "@typescript-eslint/visitor-keys": "8.65.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.65.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
+ "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.65.0",
+ "@typescript-eslint/types": "^8.65.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
+ "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.65.0",
+ "@typescript-eslint/visitor-keys": "8.65.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
+ "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz",
+ "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.65.0",
+ "@typescript-eslint/typescript-estree": "8.65.0",
+ "@typescript-eslint/utils": "8.65.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
+ "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
+ "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.65.0",
+ "@typescript-eslint/tsconfig-utils": "8.65.0",
+ "@typescript-eslint/types": "8.65.0",
+ "@typescript-eslint/visitor-keys": "8.65.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
+ "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.65.0",
+ "@typescript-eslint/types": "8.65.0",
+ "@typescript-eslint/typescript-estree": "8.65.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
+ "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.65.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
+ "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz",
+ "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.65.0",
+ "@typescript-eslint/types": "8.65.0",
+ "@typescript-eslint/typescript-estree": "8.65.0",
+ "@typescript-eslint/visitor-keys": "8.65.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
+ "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.65.0",
+ "@typescript-eslint/types": "^8.65.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
+ "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.65.0",
+ "@typescript-eslint/visitor-keys": "8.65.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
+ "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
+ "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
+ "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.65.0",
+ "@typescript-eslint/tsconfig-utils": "8.65.0",
+ "@typescript-eslint/types": "8.65.0",
+ "@typescript-eslint/visitor-keys": "8.65.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
+ "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.65.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.67.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz",
+ "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.67.0",
+ "@typescript-eslint/types": "^8.67.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.67.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz",
+ "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.67.0",
+ "@typescript-eslint/visitor-keys": "8.67.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.67.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz",
+ "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.67.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz",
+ "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.67.0",
+ "@typescript-eslint/typescript-estree": "8.67.0",
+ "@typescript-eslint/utils": "8.67.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.67.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz",
+ "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.67.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz",
+ "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.67.0",
+ "@typescript-eslint/tsconfig-utils": "8.67.0",
+ "@typescript-eslint/types": "8.67.0",
+ "@typescript-eslint/visitor-keys": "8.67.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.67.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz",
+ "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.67.0",
+ "@typescript-eslint/types": "8.67.0",
+ "@typescript-eslint/typescript-estree": "8.67.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.67.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz",
+ "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.67.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
+ "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
+ "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.10",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.10",
+ "vitest": "4.1.10"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/eslint-plugin": {
+ "version": "1.6.24",
+ "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.24.tgz",
+ "integrity": "sha512-U5isuChJ58H1M6oRGaIotYbH2gM1rhzKUjtiarqpLNG/3+fzXIXs5RyTLrYQJmQPnXbvIMPFvI9z8ufpFZc8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "^8.58.0",
+ "@typescript-eslint/utils": "^8.58.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/eslint-plugin": "*",
+ "eslint": ">=8.57.0",
+ "typescript": ">=5.0.0",
+ "vitest": "*"
+ },
+ "peerDependenciesMeta": {
+ "@typescript-eslint/eslint-plugin": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ },
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
+ "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.13",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
+ "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/birecord": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/birecord/-/birecord-0.1.2.tgz",
+ "integrity": "sha512-5PAPTTmMpMEb+GuMb5DebfBkipRGyIW9+gtwEBSoDA9xkhHILm04+hZQ702pMksu3d8YAuGkmgTzQWcKqTPScA==",
+ "dev": true,
+ "license": "(MIT OR Apache-2.0)"
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.8",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
+ "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.12",
+ "caniuse-lite": "^1.0.30001809",
+ "electron-to-chromium": "^1.5.402",
+ "node-releases": "^2.0.53",
+ "update-browserslist-db": "^1.3.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001809",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
+ "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/compare-versions": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
+ "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.405",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz",
+ "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "10.8.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
+ "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "packages/*"
+ ],
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@eslint/config-array": "^0.23.5",
+ "@eslint/config-helpers": "^0.7.0",
+ "@eslint/core": "^1.2.1",
+ "@eslint/plugin-kit": "^0.7.2",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "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.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "minimatch": "^10.2.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-dom": {
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-5.18.1.tgz",
+ "integrity": "sha512-0PakQ8iPZ2yq4zi99T28l+NlefxrE/92CpIQ1SbbFT2fqNIjRz1L+ewWIzAsMdyUUjQzcCj8domEJ36y20K8Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/ast": "5.18.1",
+ "@eslint-react/eslint": "5.18.1",
+ "@eslint-react/jsx": "5.18.1",
+ "@eslint-react/shared": "5.18.1",
+ "@typescript-eslint/types": "^8.65.0",
+ "@typescript-eslint/utils": "^8.65.0",
+ "compare-versions": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "typescript": "*"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
+ "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "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"
+ }
+ },
+ "node_modules/eslint-plugin-react-jsx": {
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-jsx/-/eslint-plugin-react-jsx-5.18.1.tgz",
+ "integrity": "sha512-bT8/pcYwp1Anyjnvjqz1jRJBGdo4ydXlR6hciyQUiHzOxAWpIOP61ggAVgbA9JU3qOr4/hldMpGZHdJP5pqfgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/ast": "5.18.1",
+ "@eslint-react/core": "5.18.1",
+ "@eslint-react/eslint": "5.18.1",
+ "@eslint-react/jsx": "5.18.1",
+ "@eslint-react/shared": "5.18.1",
+ "@typescript-eslint/types": "^8.65.0",
+ "@typescript-eslint/utils": "^8.65.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "typescript": "*"
+ }
+ },
+ "node_modules/eslint-plugin-react-naming-convention": {
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-naming-convention/-/eslint-plugin-react-naming-convention-5.18.1.tgz",
+ "integrity": "sha512-fN6U+Q5QOZ7ZJlTn7XsKB0NvRx0qDp+7tV2Zht3nhtKQ2tl4cxFz8QV78l++FqMtBtCNfxTiInuo5u5Xt8Ymjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/ast": "5.18.1",
+ "@eslint-react/core": "5.18.1",
+ "@eslint-react/eslint": "5.18.1",
+ "@eslint-react/shared": "5.18.1",
+ "@eslint-react/var": "5.18.1",
+ "@typescript-eslint/types": "^8.65.0",
+ "@typescript-eslint/utils": "^8.65.0",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "typescript": "*"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz",
+ "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": "^9 || ^10"
+ }
+ },
+ "node_modules/eslint-plugin-react-rsc": {
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-rsc/-/eslint-plugin-react-rsc-5.18.1.tgz",
+ "integrity": "sha512-s792p16e8YNhGA3vgIseDxdrwTuNb+gHn0Gau5PsTZ42d8Zuvi3ZJWLfkAZfvMfblaDev6tSAv2fZzWB0brEIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/ast": "5.18.1",
+ "@eslint-react/core": "5.18.1",
+ "@eslint-react/eslint": "5.18.1",
+ "@eslint-react/shared": "5.18.1",
+ "@eslint-react/var": "5.18.1",
+ "@typescript-eslint/types": "^8.65.0",
+ "@typescript-eslint/utils": "^8.65.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "typescript": "*"
+ }
+ },
+ "node_modules/eslint-plugin-react-web-api": {
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-web-api/-/eslint-plugin-react-web-api-5.18.1.tgz",
+ "integrity": "sha512-Az8kJEM2Bk8PhJR3UBLWBVZkir/2l+qX545PTi8Kx3JdDlK6IlU1vz69/B5tJ/YXo6cJKP3bKTqaAzcT5ikl8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/ast": "5.18.1",
+ "@eslint-react/core": "5.18.1",
+ "@eslint-react/eslint": "5.18.1",
+ "@eslint-react/shared": "5.18.1",
+ "@eslint-react/var": "5.18.1",
+ "@typescript-eslint/types": "^8.65.0",
+ "@typescript-eslint/utils": "^8.65.0",
+ "birecord": "^0.1.2",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "typescript": "*"
+ }
+ },
+ "node_modules/eslint-plugin-react-x": {
+ "version": "5.18.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-x/-/eslint-plugin-react-x-5.18.1.tgz",
+ "integrity": "sha512-i0a17vUMoMhiHjcXz4IYfO42Br6xQkbu+PqVftUHM49i1bbYD+22GFMgNmKkLCMNKXZSQhocKvIKUwhMIbFTQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-react/ast": "5.18.1",
+ "@eslint-react/core": "5.18.1",
+ "@eslint-react/eslint": "5.18.1",
+ "@eslint-react/jsx": "5.18.1",
+ "@eslint-react/shared": "5.18.1",
+ "@eslint-react/var": "5.18.1",
+ "@typescript-eslint/scope-manager": "^8.65.0",
+ "@typescript-eslint/type-utils": "^8.65.0",
+ "@typescript-eslint/types": "^8.65.0",
+ "@typescript-eslint/typescript-estree": "^8.65.0",
+ "@typescript-eslint/utils": "^8.65.0",
+ "compare-versions": "^6.1.1",
+ "string-ts": "^2.3.1",
+ "ts-api-utils": "^2.5.0",
+ "ts-pattern": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "typescript": "*"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
+ "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@types/esrecurse": "^4.3.1",
+ "@types/estree": "^1.0.8",
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
+ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^5.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "17.8.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz",
+ "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "30.0.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
+ "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^6.0.5",
+ "@asamuzakjp/dom-selector": "^8.3.0",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.7",
+ "@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": "^8.9.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.1.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.2.3"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/magicast": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz",
+ "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.53",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
+ "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
+ "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.144.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.2.4",
+ "@rolldown/binding-darwin-arm64": "1.2.4",
+ "@rolldown/binding-darwin-x64": "1.2.4",
+ "@rolldown/binding-freebsd-x64": "1.2.4",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.4",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.4",
+ "@rolldown/binding-linux-arm64-musl": "1.2.4",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.4",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.4",
+ "@rolldown/binding-linux-x64-gnu": "1.2.4",
+ "@rolldown/binding-linux-x64-musl": "1.2.4",
+ "@rolldown/binding-openharmony-arm64": "1.2.4",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.4",
+ "@rolldown/binding-win32-x64-msvc": "1.2.4"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-ts": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/string-ts/-/string-ts-2.3.1.tgz",
+ "integrity": "sha512-xSJq+BS52SaFFAVxuStmx6n5aYZU571uYUnUrPXkPFCfdHyZMMlbP2v2Wx5sNBnAVzq/2+0+mcBLBa3Xa5ubYw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.10",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz",
+ "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.10"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.10",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz",
+ "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/ts-pattern": {
+ "version": "5.9.0",
+ "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.9.0.tgz",
+ "integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz",
+ "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.65.0",
+ "@typescript-eslint/parser": "8.65.0",
+ "@typescript-eslint/typescript-estree": "8.65.0",
+ "@typescript-eslint/utils": "8.65.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/project-service": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
+ "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.65.0",
+ "@typescript-eslint/types": "^8.65.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
+ "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.65.0",
+ "@typescript-eslint/visitor-keys": "8.65.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
+ "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
+ "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
+ "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.65.0",
+ "@typescript-eslint/tsconfig-utils": "8.65.0",
+ "@typescript-eslint/types": "8.65.0",
+ "@typescript-eslint/visitor-keys": "8.65.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
+ "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.65.0",
+ "@typescript-eslint/types": "8.65.0",
+ "@typescript-eslint/typescript-estree": "8.65.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.65.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
+ "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.65.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/undici": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
+ "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
+ "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
+ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.23",
+ "rolldown": "~1.2.0",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@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.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "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
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "17.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
+ "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ }
+ }
+}
diff --git a/poker-stakes/package.json b/poker-stakes/package.json
new file mode 100644
index 0000000..851b2e2
--- /dev/null
+++ b/poker-stakes/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "poker-stakes",
+ "version": "0.1.0",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint . --max-warnings 0",
+ "test": "vitest run",
+ "coverage": "vitest run --coverage",
+ "check": "npm run typecheck && npm run lint && npm run coverage"
+ },
+ "dependencies": {
+ "react": "19.2.8",
+ "react-dom": "19.2.8"
+ },
+ "devDependencies": {
+ "@eslint-react/eslint-plugin": "5.18.1",
+ "@eslint/js": "10.0.1",
+ "@testing-library/jest-dom": "7.0.0",
+ "@testing-library/react": "16.3.2",
+ "@testing-library/user-event": "14.6.1",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.4",
+ "@vitejs/plugin-react": "6.0.5",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/eslint-plugin": "1.6.24",
+ "eslint": "10.8.0",
+ "eslint-plugin-react-hooks": "7.1.1",
+ "eslint-plugin-react-refresh": "0.5.3",
+ "globals": "17.8.0",
+ "jsdom": "30.0.1",
+ "typescript": "6.0.3",
+ "typescript-eslint": "8.65.0",
+ "vite": "8.2.0",
+ "vitest": "4.1.10"
+ },
+ "private": true,
+ "type": "module"
+}
diff --git a/poker-stakes/src/App.test.tsx b/poker-stakes/src/App.test.tsx
new file mode 100644
index 0000000..d75bccc
--- /dev/null
+++ b/poker-stakes/src/App.test.tsx
@@ -0,0 +1,196 @@
+import { render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it } from 'vitest'
+import { DEBTS_STORAGE_KEY } from './stakes/storage'
+import type { SettledDebt } from './stakes/types'
+import { App } from './App'
+
+/** In-memory Storage so each test gets an isolated ledger, never the real localStorage. */
+const memoryStorage = (seed: Record = {}): Storage => {
+ const map = new Map(Object.entries(seed))
+ return {
+ get length(): number {
+ return map.size
+ },
+ clear: (): void => {
+ map.clear()
+ },
+ getItem: (key: string): string | null => map.get(key) ?? null,
+ key: (index: number): string | null => [...map.keys()][index] ?? null,
+ removeItem: (key: string): void => {
+ map.delete(key)
+ },
+ setItem: (key: string, value: string): void => {
+ map.set(key, value)
+ },
+ }
+}
+
+const debt = (overrides: Partial = {}): SettledDebt => ({
+ id: 'd1',
+ taskTypeId: 'pushups',
+ owedUnits: 100,
+ reason: 'cashOut',
+ settledAt: 1,
+ paidUnits: 0,
+ ...overrides,
+})
+
+const storageWithDebts = (debts: SettledDebt[]): Storage =>
+ memoryStorage({ [DEBTS_STORAGE_KEY]: JSON.stringify(debts) })
+
+/** BuyInScreen's pre-filled chip count — the value every test below sits down with. */
+const DEFAULT_BUY_IN = 100
+
+/** The betting controls, or null between hands / before any session exists. */
+const bettingControls = (): HTMLElement | null => document.querySelector('.controls')
+
+/** The first legal action currently offered, or null if no decision is pending. */
+const firstLegalAction = (): HTMLElement | null => {
+ const controls = bettingControls()
+ if (controls === null) {
+ return null
+ }
+ return within(controls).getAllByRole('button')[0] ?? null
+}
+
+/** Plays hands until the session ends, always taking the first legal action offered. */
+const playUntilSettled = async (user: ReturnType, maxActions = 400): Promise => {
+ for (let i = 0; i < maxActions; i += 1) {
+ const deal = screen.queryByText('Deal next hand')
+ if (deal !== null) {
+ await user.click(deal)
+ continue
+ }
+ const action = firstLegalAction()
+ if (action === null) {
+ return
+ }
+ await user.click(action)
+ }
+}
+
+describe('App', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ it('renders the title and the buy-in screen before any session starts', () => {
+ render( )
+ expect(screen.getByText('Poker Stakes')).toBeInTheDocument()
+ expect(screen.getByText('Sit down')).toBeInTheDocument()
+ expect(screen.getByText('No debts settled yet.')).toBeInTheDocument()
+ })
+
+ it('defaults to the real localStorage and a clock-derived seed when no props are given', () => {
+ render( )
+ expect(screen.getByText('Poker Stakes')).toBeInTheDocument()
+ expect(screen.getByText('Sit down')).toBeInTheDocument()
+ })
+
+ it('buys in and deals the opening hand straight away', async () => {
+ const user = userEvent.setup()
+ render( )
+ await user.click(screen.getByText('Sit down'))
+ expect(screen.queryByText('Sit down')).not.toBeInTheDocument()
+ // Sitting down deals the opening hand immediately, so the player faces a decision at once.
+ expect(bettingControls()).not.toBeNull()
+ })
+
+ it('records a debt in the history panel once the session settles', async () => {
+ const user = userEvent.setup()
+ render( )
+ await user.click(screen.getByText('Sit down'))
+ await playUntilSettled(user)
+ // Settling returns to the buy-in screen; the debt is now in the durable ledger. It appears
+ // twice — once in the buy-in gate, once in the history panel — which is the intended layout.
+ expect(screen.queryByText('No debts settled yet.')).not.toBeInTheDocument()
+ expect(screen.getAllByText(/Pushups: /).length).toBeGreaterThan(0)
+ })
+
+ it('blocks a new buy-in while a debt is outstanding, and unblocks once it is logged off', async () => {
+ const user = userEvent.setup()
+ render( )
+ expect(screen.queryByText('Sit down')).not.toBeInTheDocument()
+ expect(screen.getByText(/outstanding debt/)).toBeInTheDocument()
+
+ // The gate and the history panel each render a control for the same debt; either clears it.
+ const [gateInput] = screen.getAllByLabelText('Log units done for Pushups')
+ const [logButton] = screen.getAllByText('Log progress')
+ if (gateInput === undefined || logButton === undefined) {
+ throw new Error('expected the outstanding-debt controls to render')
+ }
+ await user.type(gateInput, '10')
+ await user.click(logButton)
+
+ expect(screen.getByText('Sit down')).toBeInTheDocument()
+ })
+
+ it('blocks buying in when the stored ledger is unreadable (fail-closed)', () => {
+ render( )
+ expect(screen.queryByText('Sit down')).not.toBeInTheDocument()
+ // Both surfaces fail closed independently: the buy-in gate and the history panel.
+ expect(
+ screen.getByText('Your debt history could not be read. Buying in is blocked until this is resolved.'),
+ ).toBeInTheDocument()
+ expect(screen.getByText('Your debt history could not be read.')).toBeInTheDocument()
+ })
+
+ it('cashes out between hands and settles the resulting debt', async () => {
+ const user = userEvent.setup()
+ render( )
+ await user.click(screen.getByText('Sit down'))
+
+ // Play the opening hand to completion so we land in `betweenHands`, where cash-out is legal.
+ for (let i = 0; i < 200 && screen.queryByText('Deal next hand') === null; i += 1) {
+ const action = firstLegalAction()
+ if (action === null) {
+ break
+ }
+ await user.click(action)
+ }
+
+ // Pin the actual numbers: the panel previews `2*buyIn - stack`, and the settled ledger row
+ // must show exactly that same figure. A looser assertion here would pass whether or not a
+ // debt was recorded at all.
+ const preview = screen.getByText(/Current stack: \d+ chips\./)
+ const previewText = preview.textContent
+ const parsed = /Current stack: (\d+) chips\. Cashing out now would owe (\d+) units\./.exec(previewText)
+ if (parsed === null) {
+ throw new Error(`cash-out panel did not match the expected copy: ${previewText}`)
+ }
+ const [, stackText, owedText] = parsed
+ const finalChips = Number(stackText)
+ const previewOwed = Number(owedText)
+ expect(previewOwed).toBe(Math.max(0, 2 * DEFAULT_BUY_IN - finalChips))
+
+ await user.click(screen.getByText('Leave table'))
+
+ // Cashing out settles the session, so the app returns to the (now debt-gated) buy-in screen,
+ // and the ledger row carries the same owed figure the preview promised.
+ expect(screen.getByText(/outstanding debt/)).toBeInTheDocument()
+ expect(screen.getAllByText(`Pushups: 0 / ${String(previewOwed)} done`).length).toBeGreaterThan(0)
+ })
+
+ it('settles a bust at exactly twice the buy-in', async () => {
+ const user = userEvent.setup()
+ const storage = memoryStorage()
+ render( )
+ await user.click(screen.getByText('Sit down'))
+ await playUntilSettled(user)
+
+ const stored: unknown = JSON.parse(storage.getItem(DEBTS_STORAGE_KEY) ?? '[]')
+ expect(Array.isArray(stored)).toBe(true)
+ const debts = stored as SettledDebt[]
+ expect(debts.length).toBeGreaterThan(0)
+ const settled = debts[debts.length - 1]
+ if (settled === undefined) {
+ throw new Error('expected a settled debt')
+ }
+ // Seed 7 + always-take-the-first-legal-action busts deterministically, so this pins the
+ // bust case exactly rather than accepting either branch. Busting to zero is the formula's
+ // natural maximum, not a special case: 2*buyIn - 0.
+ expect(settled.reason).toBe('bust')
+ expect(settled.owedUnits).toBe(2 * DEFAULT_BUY_IN)
+ })
+})
diff --git a/poker-stakes/src/App.tsx b/poker-stakes/src/App.tsx
new file mode 100644
index 0000000..5c3ee5c
--- /dev/null
+++ b/poker-stakes/src/App.tsx
@@ -0,0 +1,58 @@
+import { useState } from 'react'
+import type { ReactElement } from 'react'
+import { BettingControls } from './ui/BettingControls'
+import { BuyInScreen } from './ui/BuyInScreen'
+import { CashOutPanel } from './ui/CashOutPanel'
+import { DebtHistoryPanel } from './ui/DebtHistoryPanel'
+import { HandLog } from './ui/HandLog'
+import { Table } from './ui/Table'
+import { useGame } from './ui/useGame'
+
+interface AppProps {
+ /** Injected so tests can drive a scripted in-memory ledger; defaults to the real localStorage. */
+ storage?: Storage
+ /** Injected so tests can script a deterministic deal; defaults to a per-mount clock read. */
+ seed?: number
+}
+
+export const App = ({ storage = localStorage, seed }: AppProps = {}): ReactElement => {
+ // Lazy init: `Date.now()` must be read once per mount, not on every render — an inline default
+ // would make the component impure and re-seed the deck on each re-render.
+ const [dealSeed] = useState(() => seed ?? Date.now())
+ const { state, buyIn, dealHand, submitPlayerAction, cashOut, logDebtProgress } = useGame(storage, dealSeed)
+ const { debtLoad, buyIn: activeBuyIn, session, handPhase } = state
+
+ return (
+
+
+ {session === null || activeBuyIn === null ? (
+
+ ) : (
+ <>
+
+ {handPhase?.kind === 'awaiting' && (
+
+ )}
+ {/*
+ `handPhase` is never null while a session exists — `useGame.buyIn` deals the opening
+ hand as part of sitting down — so the only two states here are the awaiting/between
+ pair above and below.
+ */}
+ {handPhase?.kind === 'betweenHands' && (
+ <>
+
+
+ Deal next hand
+
+ >
+ )}
+
+ >
+ )}
+
+
+ )
+}
diff --git a/poker-stakes/src/engine/bettingRound.test.ts b/poker-stakes/src/engine/bettingRound.test.ts
new file mode 100644
index 0000000..6b1460d
--- /dev/null
+++ b/poker-stakes/src/engine/bettingRound.test.ts
@@ -0,0 +1,244 @@
+import { describe, expect, it } from 'vitest'
+import { applyAction, legalActions } from './bettingRound'
+import type { BettingState, SeatState } from './bettingRound'
+
+const seat = (overrides: Partial = {}): SeatState => ({
+ stack: 100,
+ committed: 0,
+ hasActed: false,
+ folded: false,
+ allIn: false,
+ ...overrides,
+})
+
+const state = (overrides: Partial = {}): BettingState => ({
+ street: 'preflop',
+ pot: 0,
+ toAct: 'player',
+ seats: { player: seat(), opponent: seat() },
+ betToCall: 0,
+ minRaiseSize: 10,
+ roundOver: false,
+ ...overrides,
+})
+
+const totalChips = (s: BettingState): number =>
+ s.pot + s.seats.player.stack + s.seats.player.committed + s.seats.opponent.stack + s.seats.opponent.committed
+
+describe('legalActions', () => {
+ it('returns no actions once the round is over', () => {
+ expect(legalActions(state({ roundOver: true }))).toEqual([])
+ })
+
+ it('returns no actions for a folded actor', () => {
+ expect(legalActions(state({ seats: { player: seat({ folded: true }), opponent: seat() } }))).toEqual([])
+ })
+
+ it('returns no actions for an all-in actor', () => {
+ expect(
+ legalActions(state({ seats: { player: seat({ allIn: true, stack: 0 }), opponent: seat() } })),
+ ).toEqual([])
+ })
+
+ it('offers check (not call) when nothing is owed', () => {
+ const actions = legalActions(state({ betToCall: 0 }))
+ expect(actions).toContainEqual({ type: 'fold' })
+ expect(actions).toContainEqual({ type: 'check' })
+ expect(actions.some((a) => a.type === 'call')).toBe(false)
+ })
+
+ it('offers call (not check) when a bet is owed', () => {
+ const actions = legalActions(
+ state({ betToCall: 20, seats: { player: seat({ committed: 0 }), opponent: seat({ committed: 20 }) } }),
+ )
+ expect(actions).toContainEqual({ type: 'fold' })
+ expect(actions).toContainEqual({ type: 'call' })
+ expect(actions.some((a) => a.type === 'check')).toBe(false)
+ })
+
+ it('offers bet (not raise) as the open-action name when nothing is committed yet', () => {
+ const actions = legalActions(state({ betToCall: 0 }))
+ expect(actions.some((a) => a.type === 'bet')).toBe(true)
+ expect(actions.some((a) => a.type === 'raise')).toBe(false)
+ })
+
+ it('offers raise (not bet) as the open-action name when a bet is already in', () => {
+ const actions = legalActions(
+ state({ betToCall: 20, seats: { player: seat({ committed: 0 }), opponent: seat({ committed: 20 }) } }),
+ )
+ expect(actions.some((a) => a.type === 'raise')).toBe(true)
+ expect(actions.some((a) => a.type === 'bet')).toBe(false)
+ })
+
+ it('offers a single bet/raise size (all-in only) when stack is too small for a full min-raise', () => {
+ const actions = legalActions(
+ state({
+ betToCall: 20,
+ minRaiseSize: 50,
+ seats: { player: seat({ committed: 0, stack: 25 }), opponent: seat({ committed: 20 }) },
+ }),
+ )
+ const raises = actions.filter((a) => a.type === 'raise')
+ expect(raises).toHaveLength(1)
+ expect(raises[0]).toEqual({ type: 'raise', amount: 25 })
+ })
+
+ it('offers no bet/raise when the actor can only call all-in (stack equals amount owed)', () => {
+ const actions = legalActions(
+ state({
+ betToCall: 20,
+ seats: { player: seat({ committed: 0, stack: 20 }), opponent: seat({ committed: 20 }) },
+ }),
+ )
+ expect(actions.some((a) => a.type === 'bet' || a.type === 'raise')).toBe(false)
+ expect(actions).toContainEqual({ type: 'call' })
+ })
+
+ it('offers both a minimum and an all-in size when stack exceeds the minimum raise', () => {
+ const actions = legalActions(state({ betToCall: 0, minRaiseSize: 10, seats: { player: seat({ stack: 100 }), opponent: seat() } }))
+ const bets = actions.filter((a) => a.type === 'bet')
+ expect(bets).toHaveLength(2)
+ expect(bets[0]).toEqual({ type: 'bet', amount: 10 })
+ expect(bets[1]).toEqual({ type: 'bet', amount: 100 })
+ })
+})
+
+describe('applyAction: fold', () => {
+ it('marks the actor folded and ends the round', () => {
+ const s = state()
+ const next = applyAction(s, { type: 'fold' })
+ expect(next.seats.player.folded).toBe(true)
+ expect(next.roundOver).toBe(true)
+ expect(totalChips(next)).toBe(totalChips(s))
+ })
+})
+
+describe('applyAction: check', () => {
+ it('passes action to the other seat without ending the round if they have not acted', () => {
+ const s = state()
+ const next = applyAction(s, { type: 'check' })
+ expect(next.toAct).toBe('opponent')
+ expect(next.roundOver).toBe(false)
+ })
+
+ it('ends the round when both seats have checked', () => {
+ const s = state()
+ const afterPlayer = applyAction(s, { type: 'check' })
+ const afterOpponent = applyAction(afterPlayer, { type: 'check' })
+ expect(afterOpponent.roundOver).toBe(true)
+ expect(totalChips(afterOpponent)).toBe(totalChips(s))
+ })
+})
+
+describe('applyAction: call', () => {
+ it('matches the bet, ends the round, and preserves total chips (opponent already acted)', () => {
+ const s = state({
+ betToCall: 20,
+ toAct: 'player',
+ seats: {
+ player: seat({ committed: 0, stack: 100 }),
+ opponent: seat({ committed: 20, stack: 80, hasActed: true }),
+ },
+ })
+ const next = applyAction(s, { type: 'call' })
+ expect(next.seats.player.committed).toBe(20)
+ expect(next.seats.player.stack).toBe(80)
+ expect(next.roundOver).toBe(true)
+ expect(totalChips(next)).toBe(totalChips(s))
+ })
+
+ it('does NOT end the round when the other seat has not yet acted (heads-up big-blind option)', () => {
+ // SB completes preflop to match the BB's posted blind; BB has not acted this round yet.
+ const s = state({
+ betToCall: 10,
+ toAct: 'player',
+ seats: {
+ player: seat({ committed: 0, stack: 100 }),
+ opponent: seat({ committed: 10, stack: 90, hasActed: false }),
+ },
+ })
+ const next = applyAction(s, { type: 'call' })
+ expect(next.roundOver).toBe(false)
+ expect(next.toAct).toBe('opponent')
+ expect(totalChips(next)).toBe(totalChips(s))
+ })
+
+ it('goes all-in when the stack is smaller than the call amount owed is not offered, but a call for exactly the stack works', () => {
+ const s = state({
+ betToCall: 20,
+ seats: { player: seat({ committed: 0, stack: 20 }), opponent: seat({ committed: 20 }) },
+ })
+ const next = applyAction(s, { type: 'call' })
+ expect(next.seats.player.stack).toBe(0)
+ expect(next.seats.player.allIn).toBe(true)
+ expect(totalChips(next)).toBe(totalChips(s))
+ })
+})
+
+describe('applyAction: bet/raise', () => {
+ it('opens a bet, updates betToCall and minRaiseSize, and requires the other seat to act again', () => {
+ const s = state({ minRaiseSize: 10 })
+ const next = applyAction(s, { type: 'bet', amount: 10 })
+ expect(next.betToCall).toBe(10)
+ expect(next.minRaiseSize).toBe(10)
+ expect(next.toAct).toBe('opponent')
+ expect(next.roundOver).toBe(false)
+ expect(next.seats.opponent.hasActed).toBe(false)
+ expect(totalChips(next)).toBe(totalChips(s))
+ })
+
+ // A short all-in (a raise that adds LESS than the current minimum) must not lower the floor for
+ // whoever acts next — that is what `Math.max(state.minRaiseSize, raiseSize)` protects. Without
+ // the floor, a 5-chip all-in over a min-raise of 50 would let the next player re-raise by 5.
+ it('does not lower minRaiseSize when an all-in raise is smaller than the current minimum', () => {
+ const s = state({
+ betToCall: 20,
+ minRaiseSize: 50,
+ toAct: 'opponent',
+ seats: {
+ player: seat({ committed: 20, stack: 80, hasActed: true }),
+ opponent: seat({ committed: 0, stack: 25 }),
+ },
+ })
+ // All-in for 25 total: only 5 more than the 20 to call, well under the 50 min-raise.
+ const next = applyAction(s, { type: 'raise', amount: 25 })
+ expect(next.betToCall).toBe(25)
+ expect(next.minRaiseSize).toBe(50)
+ expect(next.seats.opponent.allIn).toBe(true)
+ expect(totalChips(next)).toBe(totalChips(s))
+ })
+
+ it('raises on top of an existing bet and tracks the new min-raise size', () => {
+ const s = state({
+ betToCall: 10,
+ minRaiseSize: 10,
+ toAct: 'opponent',
+ seats: { player: seat({ committed: 10, stack: 90, hasActed: true }), opponent: seat({ committed: 0, stack: 100 }) },
+ })
+ const next = applyAction(s, { type: 'raise', amount: 30 })
+ expect(next.betToCall).toBe(30)
+ expect(next.minRaiseSize).toBe(20)
+ expect(next.toAct).toBe('player')
+ expect(totalChips(next)).toBe(totalChips(s))
+ })
+
+ it('caps spend at the actor stack and marks all-in when betting the full stack', () => {
+ const s = state({ seats: { player: seat({ stack: 40 }), opponent: seat() } })
+ const next = applyAction(s, { type: 'bet', amount: 40 })
+ expect(next.seats.player.stack).toBe(0)
+ expect(next.seats.player.allIn).toBe(true)
+ expect(totalChips(next)).toBe(totalChips(s))
+ })
+})
+
+describe('a full betting round preserves total chips end to end', () => {
+ it('bet, raise, call sequence', () => {
+ let s = state({ minRaiseSize: 10, seats: { player: seat({ stack: 200 }), opponent: seat({ stack: 200 }) } })
+ const initialTotal = totalChips(s)
+ s = applyAction(s, { type: 'bet', amount: 20 })
+ s = applyAction(s, { type: 'raise', amount: 60 })
+ s = applyAction(s, { type: 'call' })
+ expect(s.roundOver).toBe(true)
+ expect(totalChips(s)).toBe(initialTotal)
+ })
+})
diff --git a/poker-stakes/src/engine/bettingRound.ts b/poker-stakes/src/engine/bettingRound.ts
new file mode 100644
index 0000000..ccb747a
--- /dev/null
+++ b/poker-stakes/src/engine/bettingRound.ts
@@ -0,0 +1,139 @@
+import type { Seat, Street } from './types'
+
+export type Action =
+ | { type: 'fold' }
+ | { type: 'check' }
+ | { type: 'call' }
+ | { type: 'bet'; amount: number }
+ | { type: 'raise'; amount: number }
+
+export interface SeatState {
+ stack: number
+ committed: number // chips this seat has put in during the CURRENT betting round
+ hasActed: boolean
+ folded: boolean
+ allIn: boolean
+}
+
+export interface BettingState {
+ street: Street
+ pot: number // chips committed in PREVIOUS rounds, not yet including this round
+ toAct: Seat
+ seats: Record
+ betToCall: number // highest `committed` this round
+ minRaiseSize: number // minimum additional amount a raise must add beyond a call, per no-limit rules
+ roundOver: boolean
+}
+
+const other = (seat: Seat): Seat => (seat === 'player' ? 'opponent' : 'player')
+
+/**
+ * Chips this seat still needs to add to match betToCall.
+ *
+ * The `Math.max(0, ...)` floor is defensive only: `applyAction` always sets `betToCall` to the
+ * highest `committed` in the round, so no seat's `committed` can exceed it and the subtraction is
+ * never negative in practice — the full suite passes with the floor removed. It stays because it
+ * costs nothing and makes the "never negative" contract local to this function rather than an
+ * invariant a reader has to reconstruct from every `betToCall` write site.
+ */
+const owedToCall = (state: BettingState, seat: Seat): number =>
+ Math.max(0, state.betToCall - state.seats[seat].committed)
+
+/** Generates the legal actions for the seat to act. Illegal actions are never representable in the UI. */
+export const legalActions = (state: BettingState): readonly Action[] => {
+ if (state.roundOver) {
+ return []
+ }
+ const actor = state.seats[state.toAct]
+ if (actor.folded || actor.allIn) {
+ return []
+ }
+
+ const toCall = owedToCall(state, state.toAct)
+ const actions: Action[] = [{ type: 'fold' }]
+
+ if (toCall === 0) {
+ actions.push({ type: 'check' })
+ } else {
+ actions.push({ type: 'call' })
+ }
+
+ const maxAdditional = actor.stack
+ const minOpenOrRaiseAdditional = toCall + state.minRaiseSize
+ if (maxAdditional > toCall) {
+ const kind = state.betToCall === 0 ? 'bet' : 'raise'
+ const minAdditional = Math.min(minOpenOrRaiseAdditional, maxAdditional)
+ // Every legal size from the minimum up to an all-in is available; expose min and all-in as
+ // concrete options rather than an open-ended range, since the UI drives a discrete control.
+ actions.push({ type: kind, amount: minAdditional })
+ if (maxAdditional > minAdditional) {
+ actions.push({ type: kind, amount: maxAdditional })
+ }
+ }
+
+ return actions
+}
+
+const applyChips = (seat: SeatState, additional: number): SeatState => {
+ const spend = Math.min(additional, seat.stack)
+ return {
+ ...seat,
+ stack: seat.stack - spend,
+ committed: seat.committed + spend,
+ allIn: seat.stack - spend === 0,
+ }
+}
+
+/** Applies an action, returning the next BettingState. Assumes the action came from legalActions(state). */
+export const applyAction = (state: BettingState, action: Action): BettingState => {
+ const actingSeat = state.toAct
+ const opponentSeat = other(actingSeat)
+
+ if (action.type === 'fold') {
+ return {
+ ...state,
+ seats: { ...state.seats, [actingSeat]: { ...state.seats[actingSeat], folded: true, hasActed: true } },
+ roundOver: true,
+ }
+ }
+
+ if (action.type === 'check') {
+ const nextSeats: Record = {
+ ...state.seats,
+ [actingSeat]: { ...state.seats[actingSeat], hasActed: true },
+ }
+ const bothActed = nextSeats[opponentSeat].hasActed
+ return { ...state, seats: nextSeats, toAct: opponentSeat, roundOver: bothActed }
+ }
+
+ if (action.type === 'call') {
+ const toCall = owedToCall(state, actingSeat)
+ const nextSeats: Record = {
+ ...state.seats,
+ [actingSeat]: { ...applyChips(state.seats[actingSeat], toCall), hasActed: true },
+ }
+ // A call closes the round UNLESS the other seat has never acted this round yet (the
+ // heads-up big-blind-option case: SB completes preflop, BB still gets first say).
+ const bothActed = nextSeats[opponentSeat].hasActed
+ return { ...state, seats: nextSeats, toAct: opponentSeat, roundOver: bothActed }
+ }
+
+ // bet or raise
+ const nextActingSeatState: SeatState = {
+ ...applyChips(state.seats[actingSeat], action.amount),
+ hasActed: true,
+ }
+ const raiseSize = nextActingSeatState.committed - state.betToCall
+ return {
+ ...state,
+ seats: {
+ ...state.seats,
+ [actingSeat]: nextActingSeatState,
+ [opponentSeat]: { ...state.seats[opponentSeat], hasActed: false },
+ },
+ betToCall: nextActingSeatState.committed,
+ minRaiseSize: Math.max(state.minRaiseSize, raiseSize),
+ toAct: opponentSeat,
+ roundOver: false,
+ }
+}
diff --git a/poker-stakes/src/engine/deck.test.ts b/poker-stakes/src/engine/deck.test.ts
new file mode 100644
index 0000000..bb7494d
--- /dev/null
+++ b/poker-stakes/src/engine/deck.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+import { buildDeck, createDealCursor, dealCards, shuffleDeck } from './deck'
+import { createRng } from './rng'
+import type { Card } from './types'
+
+const cardKey = (card: Card): string => `${String(card.rank)}-${card.suit}`
+
+describe('buildDeck', () => {
+ it('builds exactly 52 unique cards', () => {
+ const deck = buildDeck()
+ expect(deck).toHaveLength(52)
+ expect(new Set(deck.map(cardKey)).size).toBe(52)
+ })
+})
+
+describe('shuffleDeck', () => {
+ it('returns a permutation of the input deck', () => {
+ const deck = buildDeck()
+ const shuffled = shuffleDeck(createRng(1), deck)
+ expect(shuffled).toHaveLength(52)
+ expect(new Set(shuffled.map(cardKey))).toEqual(new Set(deck.map(cardKey)))
+ })
+
+ it('is deterministic for a given seed', () => {
+ const deck = buildDeck()
+ const a = shuffleDeck(createRng(42), deck)
+ const b = shuffleDeck(createRng(42), deck)
+ expect(a).toEqual(b)
+ })
+
+ it('produces a different order for a different seed', () => {
+ const deck = buildDeck()
+ const a = shuffleDeck(createRng(1), deck)
+ const b = shuffleDeck(createRng(2), deck)
+ expect(a).not.toEqual(b)
+ })
+
+ it('does not mutate the input deck', () => {
+ const deck = buildDeck()
+ const before = [...deck]
+ shuffleDeck(createRng(7), deck)
+ expect(deck).toEqual(before)
+ })
+})
+
+describe('dealCards', () => {
+ it('deals the requested count and advances the cursor', () => {
+ const deck = shuffleDeck(createRng(3), buildDeck())
+ const cursor0 = createDealCursor(deck)
+ const { cards, cursor: cursor1 } = dealCards(cursor0, 2)
+ expect(cards).toEqual(deck.slice(0, 2))
+ expect(cursor1.next).toBe(2)
+ })
+
+ it('deals sequential batches without overlap', () => {
+ const deck = shuffleDeck(createRng(4), buildDeck())
+ const { cursor: afterHole } = dealCards(createDealCursor(deck), 4)
+ const { cards: flop, cursor: afterFlop } = dealCards(afterHole, 3)
+ expect(flop).toEqual(deck.slice(4, 7))
+ expect(afterFlop.next).toBe(7)
+ })
+
+ it('throws when asked for more cards than remain', () => {
+ const deck = buildDeck()
+ const cursor = createDealCursor(deck)
+ expect(() => dealCards(cursor, 53)).toThrow('not enough cards remaining')
+ })
+})
diff --git a/poker-stakes/src/engine/deck.ts b/poker-stakes/src/engine/deck.ts
new file mode 100644
index 0000000..f06b63e
--- /dev/null
+++ b/poker-stakes/src/engine/deck.ts
@@ -0,0 +1,52 @@
+import type { Rng } from './rng'
+import { nextInt } from './rng'
+import type { Card, Rank, Suit } from './types'
+
+const SUITS: readonly Suit[] = ['clubs', 'diamonds', 'hearts', 'spades']
+const RANKS: readonly Rank[] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
+
+/** Builds a deterministic 52-card deck in fixed suit/rank order. */
+export const buildDeck = (): readonly Card[] => {
+ const deck: Card[] = []
+ for (const suit of SUITS) {
+ for (const rank of RANKS) {
+ deck.push({ rank, suit })
+ }
+ }
+ return deck
+}
+
+/**
+ * Selection-sampling shuffle: repeatedly pulls a random remaining card into
+ * the output. Uses splice (never indexed reads) so no element access is ever
+ * typed `Card | undefined` under noUncheckedIndexedAccess.
+ */
+export const shuffleDeck = (rng: Rng, deck: readonly Card[]): Card[] => {
+ const pool: Card[] = [...deck]
+ const out: Card[] = []
+ while (pool.length > 0) {
+ for (const card of pool.splice(nextInt(rng, pool.length), 1)) {
+ out.push(card)
+ }
+ }
+ return out
+}
+
+export interface DealCursor {
+ deck: readonly Card[]
+ next: number
+}
+
+export const createDealCursor = (deck: readonly Card[]): DealCursor => ({ deck, next: 0 })
+
+/** Deals `count` cards from the cursor, returning the cards and an advanced cursor. */
+export const dealCards = (
+ cursor: DealCursor,
+ count: number,
+): { cards: Card[]; cursor: DealCursor } => {
+ if (cursor.next + count > cursor.deck.length) {
+ throw new Error('dealCards: not enough cards remaining in deck')
+ }
+ const cards = cursor.deck.slice(cursor.next, cursor.next + count)
+ return { cards, cursor: { deck: cursor.deck, next: cursor.next + count } }
+}
diff --git a/poker-stakes/src/engine/handEval.compare.test.ts b/poker-stakes/src/engine/handEval.compare.test.ts
new file mode 100644
index 0000000..5580580
--- /dev/null
+++ b/poker-stakes/src/engine/handEval.compare.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest'
+import { compareHandScore, evaluate7, rankLabel } from './handEval'
+import type { HandScore } from './handEval'
+import { card, seven } from '../test/handEvalFixtures'
+
+describe('evaluate7', () => {
+ it('picks the best 5 of 7 when the best hand uses only some hole+board cards', () => {
+ const hand = seven([
+ card(14, 'clubs'),
+ card(14, 'diamonds'),
+ card(14, 'hearts'),
+ card(14, 'spades'),
+ card(2, 'clubs'),
+ card(3, 'diamonds'),
+ card(4, 'hearts'),
+ ])
+ const score = evaluate7(hand)
+ expect(score.category).toBe('quads')
+ })
+
+ it('matches the best rank5 result across all 21 combinations', () => {
+ const cards = seven([
+ card(10, 'clubs'),
+ card(11, 'clubs'),
+ card(12, 'clubs'),
+ card(13, 'clubs'),
+ card(14, 'clubs'),
+ card(2, 'hearts'),
+ card(2, 'diamonds'),
+ ])
+ const score = evaluate7(cards)
+ expect(score.category).toBe('straightFlush')
+ expect(score.tiebreakers).toEqual([14])
+ })
+})
+
+describe('compareHandScore category comparison', () => {
+ it('ranks a lower category below a higher one in both directions', () => {
+ const high: HandScore = { category: 'quads', tiebreakers: [2] }
+ const low: HandScore = { category: 'highCard', tiebreakers: [14] }
+ expect(compareHandScore(high, low)).toBe(1)
+ expect(compareHandScore(low, high)).toBe(-1)
+ })
+})
+
+describe('compareHandScore tiebreaker padding', () => {
+ it('treats a missing tiebreaker slot as lower than a present one', () => {
+ const a: HandScore = { category: 'highCard', tiebreakers: [14, 10] }
+ const b: HandScore = { category: 'highCard', tiebreakers: [14] }
+ expect(compareHandScore(a, b)).toBe(1)
+ expect(compareHandScore(b, a)).toBe(-1)
+ })
+})
+
+describe('rankLabel', () => {
+ it('labels face cards and ace', () => {
+ expect(rankLabel(14)).toBe('A')
+ expect(rankLabel(13)).toBe('K')
+ expect(rankLabel(12)).toBe('Q')
+ expect(rankLabel(11)).toBe('J')
+ })
+
+ it('labels number cards as their numeral', () => {
+ expect(rankLabel(10)).toBe('10')
+ expect(rankLabel(2)).toBe('2')
+ })
+})
diff --git a/poker-stakes/src/engine/handEval.rank5.test.ts b/poker-stakes/src/engine/handEval.rank5.test.ts
new file mode 100644
index 0000000..4216a26
--- /dev/null
+++ b/poker-stakes/src/engine/handEval.rank5.test.ts
@@ -0,0 +1,174 @@
+import { describe, expect, it } from 'vitest'
+import { buildDeck } from './deck'
+import { compareHandScore, rank5 } from './handEval'
+import type { HandCategory } from './handEval'
+import type { Card } from './types'
+import { card, combinations5, five } from '../test/handEvalFixtures'
+
+describe('rank5 exhaustive classification', () => {
+ it('classifies every 5-card hand out of 52 into the correct standard-count category', () => {
+ const deck = buildDeck()
+ const histogram: Record = {
+ highCard: 0,
+ onePair: 0,
+ twoPair: 0,
+ trips: 0,
+ straight: 0,
+ flush: 0,
+ fullHouse: 0,
+ quads: 0,
+ straightFlush: 0,
+ }
+
+ let total = 0
+ for (const [i0, i1, i2, i3, i4] of combinations5(deck.length)) {
+ const hand = five([deck[i0], deck[i1], deck[i2], deck[i3], deck[i4]] as Card[])
+ const { category } = rank5(hand)
+ histogram[category] += 1
+ total += 1
+ }
+
+ expect(total).toBe(2_598_960)
+ // Straight/flush counts here fold the royal flush into straightFlush (A-high straight flush).
+ expect(histogram).toEqual({
+ highCard: 1_302_540,
+ onePair: 1_098_240,
+ twoPair: 123_552,
+ trips: 54_912,
+ straight: 10_200,
+ flush: 5_108,
+ fullHouse: 3_744,
+ quads: 624,
+ straightFlush: 40,
+ })
+ }, 30_000)
+})
+
+describe('rank5 named fixtures', () => {
+ it('scores a wheel straight (A-2-3-4-5) as 5-high', () => {
+ const hand = five([
+ card(14, 'clubs'),
+ card(2, 'diamonds'),
+ card(3, 'hearts'),
+ card(4, 'spades'),
+ card(5, 'clubs'),
+ ])
+ const score = rank5(hand)
+ expect(score.category).toBe('straight')
+ expect(score.tiebreakers).toEqual([5])
+ })
+
+ it('scores a wheel straight flush as 5-high straight flush', () => {
+ const hand = five([
+ card(14, 'clubs'),
+ card(2, 'clubs'),
+ card(3, 'clubs'),
+ card(4, 'clubs'),
+ card(5, 'clubs'),
+ ])
+ const score = rank5(hand)
+ expect(score.category).toBe('straightFlush')
+ expect(score.tiebreakers).toEqual([5])
+ })
+
+ it('scores a broadway (ace-high) straight correctly', () => {
+ const hand = five([
+ card(14, 'clubs'),
+ card(13, 'diamonds'),
+ card(12, 'hearts'),
+ card(11, 'spades'),
+ card(10, 'clubs'),
+ ])
+ const score = rank5(hand)
+ expect(score.category).toBe('straight')
+ expect(score.tiebreakers).toEqual([14])
+ })
+
+ it('breaks ties between two one-pair hands by kicker', () => {
+ const a = rank5(
+ five([card(9, 'clubs'), card(9, 'diamonds'), card(14, 'hearts'), card(4, 'spades'), card(2, 'clubs')]),
+ )
+ const b = rank5(
+ five([card(9, 'clubs'), card(9, 'diamonds'), card(13, 'hearts'), card(4, 'spades'), card(2, 'clubs')]),
+ )
+ expect(compareHandScore(a, b)).toBe(1)
+ expect(compareHandScore(b, a)).toBe(-1)
+ })
+
+ it('breaks ties between two full houses by trips rank, not pair rank', () => {
+ // 2-2-2-A-A must beat 3-3-3-K-K: trips rank (2 < 3) decides, not the pair kicker (A > K).
+ const twosOverAces = rank5(
+ five([card(2, 'clubs'), card(2, 'diamonds'), card(2, 'hearts'), card(14, 'spades'), card(14, 'clubs')]),
+ )
+ const threesOverKings = rank5(
+ five([card(3, 'clubs'), card(3, 'diamonds'), card(3, 'hearts'), card(13, 'spades'), card(13, 'clubs')]),
+ )
+ expect(compareHandScore(threesOverKings, twosOverAces)).toBe(1)
+ expect(compareHandScore(twosOverAces, threesOverKings)).toBe(-1)
+ })
+
+ it('breaks ties between two two-pair hands by top pair, then bottom pair, then kicker', () => {
+ const acesAndTwos = rank5(
+ five([card(14, 'clubs'), card(14, 'diamonds'), card(2, 'hearts'), card(2, 'spades'), card(5, 'clubs')]),
+ )
+ const acesAndThrees = rank5(
+ five([card(14, 'hearts'), card(14, 'spades'), card(3, 'clubs'), card(3, 'diamonds'), card(4, 'hearts')]),
+ )
+ expect(compareHandScore(acesAndThrees, acesAndTwos)).toBe(1)
+ expect(compareHandScore(acesAndTwos, acesAndThrees)).toBe(-1)
+ })
+
+ it('breaks ties between two quads hands by the quad rank', () => {
+ const fours = rank5(
+ five([card(4, 'clubs'), card(4, 'diamonds'), card(4, 'hearts'), card(4, 'spades'), card(2, 'clubs')]),
+ )
+ const fives = rank5(
+ five([card(5, 'clubs'), card(5, 'diamonds'), card(5, 'hearts'), card(5, 'spades'), card(2, 'diamonds')]),
+ )
+ expect(compareHandScore(fives, fours)).toBe(1)
+ expect(compareHandScore(fours, fives)).toBe(-1)
+ })
+
+ it('breaks ties between two trips hands by trips rank then kickers', () => {
+ const treys = rank5(
+ five([card(3, 'clubs'), card(3, 'diamonds'), card(3, 'hearts'), card(14, 'spades'), card(13, 'clubs')]),
+ )
+ const fours = rank5(
+ five([card(4, 'clubs'), card(4, 'diamonds'), card(4, 'hearts'), card(2, 'spades'), card(3, 'clubs')]),
+ )
+ expect(compareHandScore(fours, treys)).toBe(1)
+ expect(compareHandScore(treys, fours)).toBe(-1)
+ })
+
+ it('breaks ties between two flushes by highest differing card', () => {
+ const aceHighFlush = rank5(
+ five([card(14, 'clubs'), card(9, 'clubs'), card(7, 'clubs'), card(4, 'clubs'), card(2, 'clubs')]),
+ )
+ const kingHighFlush = rank5(
+ five([card(13, 'diamonds'), card(10, 'diamonds'), card(8, 'diamonds'), card(5, 'diamonds'), card(3, 'diamonds')]),
+ )
+ expect(compareHandScore(aceHighFlush, kingHighFlush)).toBe(1)
+ expect(compareHandScore(kingHighFlush, aceHighFlush)).toBe(-1)
+ })
+
+ it('breaks ties between two straights by the high card', () => {
+ const sevenHigh = rank5(
+ five([card(7, 'clubs'), card(6, 'diamonds'), card(5, 'hearts'), card(4, 'spades'), card(3, 'clubs')]),
+ )
+ const eightHigh = rank5(
+ five([card(8, 'clubs'), card(7, 'diamonds'), card(6, 'hearts'), card(5, 'spades'), card(4, 'clubs')]),
+ )
+ expect(compareHandScore(eightHigh, sevenHigh)).toBe(1)
+ expect(compareHandScore(sevenHigh, eightHigh)).toBe(-1)
+ })
+
+ it('recognizes an exact split (identical hand scores)', () => {
+ const a = rank5(
+ five([card(9, 'clubs'), card(9, 'diamonds'), card(14, 'hearts'), card(4, 'spades'), card(2, 'clubs')]),
+ )
+ const b = rank5(
+ five([card(9, 'hearts'), card(9, 'spades'), card(14, 'clubs'), card(4, 'diamonds'), card(2, 'hearts')]),
+ )
+ expect(compareHandScore(a, b)).toBe(0)
+ })
+})
diff --git a/poker-stakes/src/engine/handEval.ts b/poker-stakes/src/engine/handEval.ts
new file mode 100644
index 0000000..314c6ed
--- /dev/null
+++ b/poker-stakes/src/engine/handEval.ts
@@ -0,0 +1,182 @@
+import type { Card, Rank } from './types'
+
+export type HandCategory =
+ | 'highCard'
+ | 'onePair'
+ | 'twoPair'
+ | 'trips'
+ | 'straight'
+ | 'flush'
+ | 'fullHouse'
+ | 'quads'
+ | 'straightFlush'
+
+const CATEGORY_RANK: Record = {
+ highCard: 0,
+ onePair: 1,
+ twoPair: 2,
+ trips: 3,
+ straight: 4,
+ flush: 5,
+ fullHouse: 6,
+ quads: 7,
+ straightFlush: 8,
+}
+
+export interface HandScore {
+ category: HandCategory
+ tiebreakers: readonly number[]
+}
+
+type Five = readonly [Card, Card, Card, Card, Card]
+type Seven = readonly [Card, Card, Card, Card, Card, Card, Card]
+
+/** -1 if a < b, 0 if equal, 1 if a > b. Higher HandScore wins. */
+export const compareHandScore = (a: HandScore, b: HandScore): -1 | 0 | 1 => {
+ const catA = CATEGORY_RANK[a.category]
+ const catB = CATEGORY_RANK[b.category]
+ if (catA !== catB) {
+ return catA > catB ? 1 : -1
+ }
+ const len = Math.max(a.tiebreakers.length, b.tiebreakers.length)
+ for (let i = 0; i < len; i += 1) {
+ const ta = a.tiebreakers[i] ?? 0
+ const tb = b.tiebreakers[i] ?? 0
+ if (ta !== tb) {
+ return ta > tb ? 1 : -1
+ }
+ }
+ return 0
+}
+
+const countBy = (values: readonly T[]): Map => {
+ const counts = new Map()
+ for (const value of values) {
+ counts.set(value, (counts.get(value) ?? 0) + 1)
+ }
+ return counts
+}
+
+/** Ranks sorted descending, deduped, from a set of cards. */
+const descendingRanks = (cards: Five): number[] => {
+ const ranks = cards.map((c) => c.rank)
+ return [...new Set(ranks)].sort((a, b) => b - a)
+}
+
+/** Straight high card, or null if the 5 ranks (deduped) don't form a run of 5. Wheel (A-2-3-4-5) scores as 5-high. */
+const straightHigh = (uniqueDescRanks: readonly number[]): number | null => {
+ if (uniqueDescRanks.length !== 5) {
+ return null
+ }
+ const [r0, r1, r2, r3, r4] = uniqueDescRanks as readonly [number, number, number, number, number]
+ const isRun = r0 - r1 === 1 && r1 - r2 === 1 && r2 - r3 === 1 && r3 - r4 === 1
+ if (isRun) {
+ return r0
+ }
+ const isWheel = r0 === 14 && r1 === 5 && r2 === 4 && r3 === 3 && r4 === 2
+ return isWheel ? 5 : null
+}
+
+/** Evaluates exactly 5 cards. */
+export const rank5 = (cards: Five): HandScore => {
+ const suits = cards.map((c) => c.suit)
+ const isFlush = new Set(suits).size === 1
+
+ const rankCounts = countBy(cards.map((c) => c.rank))
+ const uniqueDesc = descendingRanks(cards)
+ const straightTop = straightHigh(uniqueDesc)
+
+ const groups = [...rankCounts.entries()].sort((a, b) => {
+ if (a[1] !== b[1]) {
+ return b[1] - a[1]
+ }
+ return b[0] - a[0]
+ })
+ const groupCounts = groups.map(([, count]) => count)
+ const groupRanks = groups.map(([rank]) => rank)
+
+ if (isFlush && straightTop !== null) {
+ return { category: 'straightFlush', tiebreakers: [straightTop] }
+ }
+ if (groupCounts[0] === 4) {
+ return { category: 'quads', tiebreakers: groupRanks }
+ }
+ if (groupCounts[0] === 3 && groupCounts[1] === 2) {
+ return { category: 'fullHouse', tiebreakers: groupRanks }
+ }
+ if (isFlush) {
+ return { category: 'flush', tiebreakers: uniqueDesc }
+ }
+ if (straightTop !== null) {
+ return { category: 'straight', tiebreakers: [straightTop] }
+ }
+ if (groupCounts[0] === 3) {
+ return { category: 'trips', tiebreakers: groupRanks }
+ }
+ if (groupCounts[0] === 2 && groupCounts[1] === 2) {
+ return { category: 'twoPair', tiebreakers: groupRanks }
+ }
+ if (groupCounts[0] === 2) {
+ return { category: 'onePair', tiebreakers: groupRanks }
+ }
+ return { category: 'highCard', tiebreakers: uniqueDesc }
+}
+
+type FiveIndices = readonly [number, number, number, number, number]
+
+/** All C(7,5) = 21 five-card index combinations out of positions 0-6, written as a literal tuple. */
+const SEVEN_CHOOSE_FIVE: readonly [FiveIndices, ...FiveIndices[]] = [
+ [0, 1, 2, 3, 4],
+ [0, 1, 2, 3, 5],
+ [0, 1, 2, 3, 6],
+ [0, 1, 2, 4, 5],
+ [0, 1, 2, 4, 6],
+ [0, 1, 2, 5, 6],
+ [0, 1, 3, 4, 5],
+ [0, 1, 3, 4, 6],
+ [0, 1, 3, 5, 6],
+ [0, 1, 4, 5, 6],
+ [0, 2, 3, 4, 5],
+ [0, 2, 3, 4, 6],
+ [0, 2, 3, 5, 6],
+ [0, 2, 4, 5, 6],
+ [0, 3, 4, 5, 6],
+ [1, 2, 3, 4, 5],
+ [1, 2, 3, 4, 6],
+ [1, 2, 3, 5, 6],
+ [1, 2, 4, 5, 6],
+ [1, 3, 4, 5, 6],
+ [2, 3, 4, 5, 6],
+]
+
+/** Best 5-card HandScore out of exactly 7 cards. */
+export const evaluate7 = (cards: Seven): HandScore => {
+ const [firstCombo, ...restCombos] = SEVEN_CHOOSE_FIVE
+ const fiveFor = (combo: FiveIndices): Five =>
+ [cards[combo[0]], cards[combo[1]], cards[combo[2]], cards[combo[3]], cards[combo[4]]] as Five
+
+ let best = rank5(fiveFor(firstCombo))
+ for (const combo of restCombos) {
+ const score = rank5(fiveFor(combo))
+ if (compareHandScore(score, best) > 0) {
+ best = score
+ }
+ }
+ return best
+}
+
+export const rankLabel = (rank: Rank): string => {
+ if (rank === 14) {
+ return 'A'
+ }
+ if (rank === 13) {
+ return 'K'
+ }
+ if (rank === 12) {
+ return 'Q'
+ }
+ if (rank === 11) {
+ return 'J'
+ }
+ return String(rank)
+}
diff --git a/poker-stakes/src/engine/handFlow.test.ts b/poker-stakes/src/engine/handFlow.test.ts
new file mode 100644
index 0000000..b00d361
--- /dev/null
+++ b/poker-stakes/src/engine/handFlow.test.ts
@@ -0,0 +1,199 @@
+import { describe, expect, it } from 'vitest'
+import type { Action, BettingState } from './bettingRound'
+import { legalActions } from './bettingRound'
+import { playHand } from './handFlow'
+import type { HandSetup } from './handFlow'
+import { createRng } from './rng'
+import type { Seat } from './types'
+
+const setup = (overrides: Partial = {}): HandSetup => ({
+ buttonSeat: 'player',
+ bigBlind: 10,
+ smallBlind: 5,
+ stacks: { player: 200, opponent: 200 },
+ ...overrides,
+})
+
+const totalStack = (setupArg: HandSetup): number => setupArg.stacks.player + setupArg.stacks.opponent
+
+/** Wraps a decide() callback, asserting the returned action is always one legalActions offered. */
+const enforceLegal = (
+ decide: (betting: BettingState, seat: Seat) => Action,
+): ((betting: BettingState, seat: Seat) => Action) => {
+ return (betting, seat) => {
+ const action = decide(betting, seat)
+ expect(legalActions(betting)).toContainEqual(action)
+ return action
+ }
+}
+
+/** Builds a decide() callback from a fixed queue of actions, applied in call order regardless of seat. */
+const scripted = (actions: readonly Action[]): ((betting: BettingState, seat: Seat) => Action) => {
+ const queue = [...actions]
+ return enforceLegal(() => {
+ const next = queue.shift()
+ if (next === undefined) {
+ throw new Error('scripted: ran out of scripted actions')
+ }
+ return next
+ })
+}
+
+/** Always checks/calls — used to run a hand to showdown with minimal betting. */
+const alwaysCheckOrCall = enforceLegal((betting) => {
+ const toCall = betting.betToCall - betting.seats[betting.toAct].committed
+ return toCall > 0 ? { type: 'call' } : { type: 'check' }
+})
+
+describe('playHand: showdown', () => {
+ it('runs a full hand to showdown and awards the pot to the better hand', () => {
+ const s = setup()
+ const result = playHand(createRng(1), s, { decide: alwaysCheckOrCall })
+ expect(result.wentToShowdown).toBe(true)
+ expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
+ expect(['player', 'opponent', 'split']).toContain(result.winner)
+ })
+
+ it('is deterministic for a fixed seed', () => {
+ const s = setup()
+ const a = playHand(createRng(99), s, { decide: alwaysCheckOrCall })
+ const b = playHand(createRng(99), s, { decide: alwaysCheckOrCall })
+ expect(a).toEqual(b)
+ })
+})
+
+describe('playHand: fold preflop', () => {
+ it('awards the pot to the non-folder and refunds nothing beyond the folded blind', () => {
+ const s = setup()
+ const decide = scripted([{ type: 'fold' }])
+ const result = playHand(createRng(2), s, { decide })
+ expect(result.wentToShowdown).toBe(false)
+ expect(result.loserSeat).toBe('player')
+ expect(result.winner).toBe('opponent')
+ expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
+ })
+})
+
+describe('playHand: fold postflop', () => {
+ it('carries the pot across streets and awards it on a later fold', () => {
+ const s = setup()
+ // Preflop: player calls, opponent checks (round ends). Flop: opponent bets, player folds.
+ const decide = scripted([{ type: 'call' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'fold' }])
+ const result = playHand(createRng(3), s, { decide })
+ expect(result.wentToShowdown).toBe(false)
+ expect(result.loserSeat).toBe('player')
+ expect(result.potAwarded).toBeGreaterThanOrEqual(s.bigBlind * 2)
+ expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
+ })
+})
+
+describe('playHand: early all-in preserves total chips', () => {
+ /** Shoves all-in whenever a bet/raise is legal, otherwise calls/checks — drives every hand to an all-in quickly. */
+ const shoveOrCheckCall = enforceLegal((betting, seat) => {
+ const actions = legalActions(betting)
+ const shove = actions.find((a) => a.type === 'bet' || a.type === 'raise')
+ if (shove !== undefined) {
+ return shove
+ }
+ return alwaysCheckOrCall(betting, seat)
+ })
+
+ it('handles a short-stacked all-in preflop and resolves at showdown', () => {
+ const s = setup({ stacks: { player: 15, opponent: 200 } })
+ const result = playHand(createRng(4), s, { decide: shoveOrCheckCall })
+ expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
+ expect(result.wentToShowdown).toBe(true)
+ })
+})
+
+describe('playHand: fold — opponent as loser', () => {
+ it('awards the pot to the player when the opponent folds', () => {
+ const s = setup()
+ // Preflop: player calls, opponent gets the option and folds.
+ const decide = scripted([{ type: 'call' }, { type: 'fold' }])
+ const result = playHand(createRng(6), s, { decide })
+ expect(result.loserSeat).toBe('opponent')
+ expect(result.winner).toBe('player')
+ expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
+ })
+})
+
+describe('playHand: uncalled excess is returned in both directions', () => {
+ it('returns excess to the opponent when the opponent bets more and the player folds', () => {
+ const s = setup()
+ // Preflop: call, check. Flop: opponent bets, player folds — opponent's committed ends up
+ // higher than player's, exercising the "opponent higher" branch of returnUncalledExcess.
+ const decide = scripted([{ type: 'call' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'fold' }])
+ const result = playHand(createRng(3), s, { decide })
+ expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
+ })
+
+ it('returns excess to the player when the player bets more and the opponent folds', () => {
+ const s = setup()
+ // Preflop: call, check. Flop: opponent checks first, player bets, opponent folds — player's
+ // committed ends up higher, exercising the "player higher" branch of returnUncalledExcess.
+ const decide = scripted([{ type: 'call' }, { type: 'check' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'fold' }])
+ const result = playHand(createRng(3), s, { decide })
+ expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
+ })
+})
+
+describe('playHand: split pot', () => {
+ it('splits the pot evenly on an exact tie (deterministic seed)', () => {
+ // Seed 15 with check/call-only play produces a genuine board-play tie at showdown.
+ const s = setup()
+ const result = playHand(createRng(15), s, { decide: alwaysCheckOrCall })
+ expect(result.winner).toBe('split')
+ expect(result.potAwarded % 2).toBe(0)
+ expect(result.finalStacks).toEqual({ player: 200, opponent: 200 })
+ })
+
+ it('conserves total chips across many seeds regardless of outcome', () => {
+ const s = setup()
+ for (let seed = 1; seed <= 50; seed += 1) {
+ const result = playHand(createRng(seed), s, { decide: alwaysCheckOrCall })
+ expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
+ expect(result.potAwarded % 2).toBe(0)
+ }
+ })
+})
+
+describe('playHand: button rotation', () => {
+ it('lets the opponent be the button/small blind and act first preflop', () => {
+ const s = setup({ buttonSeat: 'opponent' })
+ const result = playHand(createRng(5), s, { decide: alwaysCheckOrCall })
+ expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
+ })
+})
+
+describe('playHand: a seat all-in from a blind post never acts again', () => {
+ it('never offers a decision to a 0-stack seat on a later street (button posts SB all-in)', () => {
+ // Button posts smallBlind=1 from a 1-chip stack, going all-in preflop. A fresh, buggy
+ // `allIn: false` reset on flop/turn/river would let this 0-stack seat fold/check again.
+ const s = setup({ buttonSeat: 'player', bigBlind: 2, smallBlind: 1, stacks: { player: 1, opponent: 200 } })
+ const seenActingWithZeroStack: Seat[] = []
+ const decide = (betting: BettingState, seat: Seat): Action => {
+ if (betting.seats[seat].stack === 0) {
+ seenActingWithZeroStack.push(seat)
+ }
+ return alwaysCheckOrCall(betting, seat)
+ }
+ const result = playHand(createRng(1), s, { decide })
+ expect(seenActingWithZeroStack).toEqual([])
+ expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
+ })
+
+ it('never offers a decision to a 0-stack seat on a later street (non-button posts BB all-in)', () => {
+ const s = setup({ buttonSeat: 'player', bigBlind: 2, smallBlind: 1, stacks: { player: 200, opponent: 2 } })
+ const seenActingWithZeroStack: Seat[] = []
+ const decide = (betting: BettingState, seat: Seat): Action => {
+ if (betting.seats[seat].stack === 0) {
+ seenActingWithZeroStack.push(seat)
+ }
+ return alwaysCheckOrCall(betting, seat)
+ }
+ const result = playHand(createRng(1), s, { decide })
+ expect(seenActingWithZeroStack).toEqual([])
+ expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
+ })
+})
diff --git a/poker-stakes/src/engine/handFlow.ts b/poker-stakes/src/engine/handFlow.ts
new file mode 100644
index 0000000..0b977ff
--- /dev/null
+++ b/poker-stakes/src/engine/handFlow.ts
@@ -0,0 +1,182 @@
+import type { Action, BettingState } from './bettingRound'
+import { applyAction, legalActions } from './bettingRound'
+import { createDealCursor, dealCards, shuffleDeck } from './deck'
+import { buildDeck } from './deck'
+import { compareHandScore, evaluate7 } from './handEval'
+import type { HandScore } from './handEval'
+import type { Rng } from './rng'
+import type { Card, Seat } from './types'
+
+export interface HeadsUpStacks {
+ player: number
+ opponent: number
+}
+
+export interface HandSetup {
+ buttonSeat: Seat // heads-up: button posts the small blind and acts first preflop
+ bigBlind: number
+ smallBlind: number
+ stacks: HeadsUpStacks
+}
+
+export interface HandResult {
+ winner: Seat | 'split'
+ potAwarded: number
+ wentToShowdown: boolean
+ finalStacks: HeadsUpStacks
+ loserSeat: Seat | null
+ board: readonly Card[]
+ /** Both seats' hole cards, but ONLY at a real showdown — a folder's cards are never revealed. */
+ revealedHoleCards: Record | null
+}
+
+import {
+ initialBettingState,
+ other,
+ potFromBetting,
+ returnUncalledExcess,
+ stacksFromBetting,
+ STREET_ORDER,
+} from './handFlowBetting'
+
+export { potFromBetting } from './handFlowBetting'
+
+
+export interface HandFlowCallbacks {
+ /**
+ * Called whenever it's a seat's turn; must return one of legalActions(betting). Receives ONLY
+ * the acting seat's own hole cards — never the opponent's — so a decision function can never
+ * see cards it shouldn't, by construction rather than by discipline.
+ */
+ decide: (betting: BettingState, seat: Seat, board: readonly Card[], ownHoleCards: readonly Card[]) => Action
+}
+
+/** One paused decision point within a hand: who must act, and on what they may see. */
+export interface HandStep {
+ betting: BettingState
+ seat: Seat
+ board: readonly Card[]
+ ownHoleCards: readonly Card[]
+}
+
+/**
+ * Runs a single hand end-to-end as a generator: posts blinds, deals hole cards, and pauses at
+ * every decision point (`yield`ing a HandStep) instead of calling a callback directly. The caller
+ * resumes it by passing the chosen Action into `.next(action)`. This lets a UI drive the hand one
+ * decision at a time (suspend on the player's turn, wait for a click) without making the engine
+ * itself async — it's suspension, not IO.
+ */
+export function* playHandSteps(rng: Rng, setup: HandSetup): Generator {
+ const deck = shuffleDeck(rng, buildDeck())
+ let cursor = createDealCursor(deck)
+
+ const dealtPlayer = dealCards(cursor, 2)
+ cursor = dealtPlayer.cursor
+ const dealtOpponent = dealCards(cursor, 2)
+ cursor = dealtOpponent.cursor
+ const holeCards: Record = {
+ player: dealtPlayer.cards,
+ opponent: dealtOpponent.cards,
+ }
+
+ let board: Card[] = []
+ let betting = initialBettingState(setup, 'preflop', 0, setup.stacks)
+
+ for (const street of STREET_ORDER) {
+ if (street !== 'preflop') {
+ const settled = returnUncalledExcess(betting)
+ const communityCount = street === 'flop' ? 3 : 1
+ const dealt = dealCards(cursor, communityCount)
+ cursor = dealt.cursor
+ board = [...board, ...dealt.cards]
+ betting = initialBettingState(setup, street, potFromBetting(settled), stacksFromBetting(settled))
+ }
+
+ while (legalActions(betting).length > 0) {
+ const action = yield { betting, seat: betting.toAct, board, ownHoleCards: holeCards[betting.toAct] }
+ betting = applyAction(betting, action)
+ }
+
+ if (betting.seats.player.folded || betting.seats.opponent.folded) {
+ return resolveByFold(betting, board)
+ }
+ }
+
+ return resolveByShowdown(betting, board, holeCards)
+}
+
+/**
+ * Runs a single hand end-to-end: posts blinds, deals hole cards, runs betting rounds interleaved
+ * with community-card deals, and resolves the pot at fold or showdown. Thin synchronous driver
+ * over `playHandSteps` for callers (tests, the PC-only path) that don't need to suspend mid-hand.
+ */
+export const playHand = (rng: Rng, setup: HandSetup, callbacks: HandFlowCallbacks): HandResult => {
+ const gen = playHandSteps(rng, setup)
+ let step = gen.next()
+ while (!step.done) {
+ const { betting, seat, board, ownHoleCards } = step.value
+ step = gen.next(callbacks.decide(betting, seat, board, ownHoleCards))
+ }
+ return step.value
+}
+
+const resolveByFold = (betting: BettingState, board: readonly Card[]): HandResult => {
+ const settled = returnUncalledExcess(betting)
+ const pot = potFromBetting(settled)
+ const loserSeat: Seat = settled.seats.player.folded ? 'player' : 'opponent'
+ const winnerSeat = other(loserSeat)
+ const finalStacks: HeadsUpStacks = {
+ player: settled.seats.player.stack + (winnerSeat === 'player' ? pot : 0),
+ opponent: settled.seats.opponent.stack + (winnerSeat === 'opponent' ? pot : 0),
+ }
+ // A folder's cards are never revealed, even to show what they held.
+ return { winner: winnerSeat, potAwarded: pot, wentToShowdown: false, finalStacks, loserSeat, board, revealedHoleCards: null }
+}
+
+const resolveByShowdown = (
+ betting: BettingState,
+ board: readonly Card[],
+ holeCards: Record,
+): HandResult => {
+ const settled = returnUncalledExcess(betting)
+ const pot = potFromBetting(settled)
+ const playerCards = [...holeCards.player, ...board] as [Card, Card, Card, Card, Card, Card, Card]
+ const opponentCards = [...holeCards.opponent, ...board] as [Card, Card, Card, Card, Card, Card, Card]
+ const playerScore: HandScore = evaluate7(playerCards)
+ const opponentScore: HandScore = evaluate7(opponentCards)
+ const cmp = compareHandScore(playerScore, opponentScore)
+
+ const stacks = stacksFromBetting(settled)
+ if (cmp === 0) {
+ // Split pot: heads-up play always returns uncalled excess before showdown, so both seats'
+ // committed amounts are equal and the pot is provably even — no odd-chip remainder rule needed.
+ const half = pot / 2
+ return {
+ winner: 'split',
+ potAwarded: pot,
+ wentToShowdown: true,
+ finalStacks: {
+ player: stacks.player + half,
+ opponent: stacks.opponent + half,
+ },
+ loserSeat: null,
+ board,
+ revealedHoleCards: holeCards,
+ }
+ }
+
+ const winnerSeat: Seat = cmp > 0 ? 'player' : 'opponent'
+ const loserSeat = other(winnerSeat)
+ return {
+ winner: winnerSeat,
+ potAwarded: pot,
+ wentToShowdown: true,
+ finalStacks: {
+ player: stacks.player + (winnerSeat === 'player' ? pot : 0),
+ opponent: stacks.opponent + (winnerSeat === 'opponent' ? pot : 0),
+ },
+ loserSeat,
+ board,
+ revealedHoleCards: holeCards,
+ }
+}
diff --git a/poker-stakes/src/engine/handFlowBetting.ts b/poker-stakes/src/engine/handFlowBetting.ts
new file mode 100644
index 0000000..211f037
--- /dev/null
+++ b/poker-stakes/src/engine/handFlowBetting.ts
@@ -0,0 +1,89 @@
+import type { BettingState, SeatState } from './bettingRound'
+import type { Seat, Street } from './types'
+import type { HandSetup, HeadsUpStacks } from './handFlow'
+
+/**
+ * Betting-state helpers for handFlow, split out to stay under the 250-line cap.
+ */
+
+export const other = (seat: Seat): Seat => (seat === 'player' ? 'opponent' : 'player')
+
+export const STREET_ORDER: readonly Street[] = ['preflop', 'flop', 'turn', 'river']
+
+export const initialBettingState = (setup: HandSetup, street: Street, pot: number, stacks: HeadsUpStacks): BettingState => {
+ const nonButton = other(setup.buttonSeat)
+ // A seat already at 0 chips (all-in from an earlier street's blind/bet) stays all-in on every
+ // later street — a fresh `allIn: false` here would wrongly offer fold/check on a 0 stack.
+ const buttonSeat: SeatState = {
+ stack: stacks[setup.buttonSeat],
+ committed: 0,
+ hasActed: false,
+ folded: false,
+ allIn: stacks[setup.buttonSeat] === 0,
+ }
+ const nonButtonSeat: SeatState = {
+ stack: stacks[nonButton],
+ committed: 0,
+ hasActed: false,
+ folded: false,
+ allIn: stacks[nonButton] === 0,
+ }
+
+ if (street === 'preflop') {
+ // Heads-up: button posts small blind and acts FIRST preflop.
+ const buttonPosted: SeatState = { ...buttonSeat, ...postBlind(buttonSeat, setup.smallBlind) }
+ const nonButtonPosted: SeatState = { ...nonButtonSeat, ...postBlind(nonButtonSeat, setup.bigBlind) }
+ return {
+ street,
+ pot,
+ toAct: setup.buttonSeat,
+ seats: { [setup.buttonSeat]: buttonPosted, [nonButton]: nonButtonPosted } as Record,
+ betToCall: nonButtonPosted.committed,
+ minRaiseSize: setup.bigBlind,
+ roundOver: false,
+ }
+ }
+
+ // Postflop: non-button (big blind) acts first.
+ return {
+ street,
+ pot,
+ toAct: nonButton,
+ seats: { [setup.buttonSeat]: buttonSeat, [nonButton]: nonButtonSeat } as Record,
+ betToCall: 0,
+ minRaiseSize: setup.bigBlind,
+ roundOver: false,
+ }
+}
+
+export const postBlind = (seatState: SeatState, amount: number): SeatState => {
+ const spend = Math.min(amount, seatState.stack)
+ return { stack: seatState.stack - spend, committed: spend, hasActed: false, folded: false, allIn: seatState.stack - spend === 0 }
+}
+
+/** Returns the uncalled excess of the seat with the higher committed amount, and the state with it returned. */
+export const returnUncalledExcess = (betting: BettingState): BettingState => {
+ const { player, opponent } = betting.seats
+ if (player.committed === opponent.committed) {
+ return betting
+ }
+ const [higher, higherSeat, lowerCommitted] =
+ player.committed > opponent.committed ? [player, 'player' as Seat, opponent.committed] : [opponent, 'opponent' as Seat, player.committed]
+ const excess = higher.committed - lowerCommitted
+ return {
+ ...betting,
+ seats: {
+ ...betting.seats,
+ [higherSeat]: { ...higher, committed: higher.committed - excess, stack: higher.stack + excess },
+ },
+ }
+}
+
+/** The live pot, including both seats' amounts committed on the current street (not yet swept into `betting.pot`). */
+export const potFromBetting = (betting: BettingState): number =>
+ betting.pot + betting.seats.player.committed + betting.seats.opponent.committed
+
+export const stacksFromBetting = (betting: BettingState): HeadsUpStacks => ({
+ player: betting.seats.player.stack,
+ opponent: betting.seats.opponent.stack,
+})
diff --git a/poker-stakes/src/engine/interactiveHand.test.ts b/poker-stakes/src/engine/interactiveHand.test.ts
new file mode 100644
index 0000000..43eb72b
--- /dev/null
+++ b/poker-stakes/src/engine/interactiveHand.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, it } from 'vitest'
+import { legalActions } from './bettingRound'
+import type { HandSetup } from './handFlow'
+import { startInteractiveHand } from './interactiveHand'
+import { createRng } from './rng'
+
+const setup = (overrides: Partial = {}): HandSetup => ({
+ buttonSeat: 'player',
+ bigBlind: 10,
+ smallBlind: 5,
+ stacks: { player: 200, opponent: 200 },
+ ...overrides,
+})
+
+const totalStack = (setupArg: HandSetup): number => setupArg.stacks.player + setupArg.stacks.opponent
+
+/** Drives an interactive hand to completion always checking/calling on the player's turns. */
+const runToCompletion = (hand: ReturnType): void => {
+ while (hand.status.kind === 'awaiting') {
+ const betting = hand.status.step.betting
+ const toCall = betting.betToCall - betting.seats.player.committed
+ hand.submit(toCall > 0 ? { type: 'call' } : { type: 'check' })
+ }
+}
+
+describe('startInteractiveHand', () => {
+ it('pauses on the player seat only, never surfacing the opponent as awaiting', () => {
+ const s = setup()
+ const hand = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
+ expect(hand.status.kind).toBe('awaiting')
+ if (hand.status.kind !== 'awaiting') {
+ throw new Error('unreachable: asserted above')
+ }
+ expect(hand.status.step.seat).toBe('player')
+ })
+
+ it('every offered awaiting action is legal for the current betting state', () => {
+ const s = setup()
+ const hand = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
+ while (hand.status.kind === 'awaiting') {
+ const { step } = hand.status
+ const legal = legalActions(step.betting)
+ const toCall = step.betting.betToCall - step.betting.seats.player.committed
+ const action = toCall > 0 ? { type: 'call' as const } : { type: 'check' as const }
+ expect(legal).toContainEqual(action)
+ hand.submit(action)
+ }
+ })
+
+ it('runs a full hand to completion via alternating player submits and internal opponent turns', () => {
+ const s = setup()
+ const hand = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
+ runToCompletion(hand)
+ expect(hand.status.kind).toBe('complete')
+ if (hand.status.kind !== 'complete') {
+ throw new Error('unreachable: asserted above')
+ }
+ const { result } = hand.status
+ expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
+ })
+
+ it('resolves a preflop player fold without ever pausing again', () => {
+ const s = setup()
+ const hand = startInteractiveHand(createRng(2), createRng(2 ^ 0x9e3779b9), s, 1)
+ expect(hand.status.kind).toBe('awaiting')
+ hand.submit({ type: 'fold' })
+ expect(hand.status.kind).toBe('complete')
+ if (hand.status.kind !== 'complete') {
+ throw new Error('unreachable: asserted above')
+ }
+ expect(hand.status.result.loserSeat).toBe('player')
+ })
+
+ it('throws if submit is called with no decision pending', () => {
+ const s = setup()
+ const hand = startInteractiveHand(createRng(2), createRng(2 ^ 0x9e3779b9), s, 1)
+ hand.submit({ type: 'fold' })
+ expect(hand.status.kind).toBe('complete')
+ expect(() => {
+ hand.submit({ type: 'fold' })
+ }).toThrow('no player decision is pending')
+ })
+
+ it('is deterministic for fixed deal and policy seeds', () => {
+ const s = setup()
+ const a = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
+ runToCompletion(a)
+ const b = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
+ runToCompletion(b)
+ expect(a.status).toEqual(b.status)
+ })
+
+ it('changing opponent iterations does not change the deal (dealRng and policyRng are independent)', () => {
+ const s = setup()
+ const collectBoards = (opponentIterations: number): unknown[] => {
+ const hand = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, opponentIterations)
+ const boards: unknown[] = []
+ while (hand.status.kind === 'awaiting') {
+ const { step } = hand.status
+ boards.push(step.board)
+ const toCall = step.betting.betToCall - step.betting.seats.player.committed
+ hand.submit(toCall > 0 ? { type: 'call' } : { type: 'check' })
+ }
+ return boards
+ }
+ // Different iteration counts consume policyRng differently; if it leaked into dealing, the
+ // board sequence collected at each player-decision pause would diverge between the two runs.
+ expect(collectBoards(1)).toEqual(collectBoards(5))
+ })
+})
diff --git a/poker-stakes/src/engine/interactiveHand.ts b/poker-stakes/src/engine/interactiveHand.ts
new file mode 100644
index 0000000..2c2664d
--- /dev/null
+++ b/poker-stakes/src/engine/interactiveHand.ts
@@ -0,0 +1,64 @@
+import type { Action } from './bettingRound'
+import { legalActions } from './bettingRound'
+import type { HandResult, HandSetup, HandStep } from './handFlow'
+import { playHandSteps } from './handFlow'
+import { decideAction } from './opponentPolicy'
+import type { Rng } from './rng'
+import type { Card } from './types'
+
+/**
+ * Exactly one of the two states holds at any time — a discriminated union instead of two
+ * independently-nullable fields, so "both null" / "both set" are unrepresentable rather than
+ * defensive-but-unreachable states a caller has to guard against.
+ */
+export type HandStatus = { kind: 'awaiting'; step: HandStep } | { kind: 'complete'; result: HandResult }
+
+export interface InteractiveHand {
+ readonly status: HandStatus
+ /** Submits the player's action for the current `awaiting` step, resolves any opponent turns, and advances. */
+ submit: (action: Action) => void
+}
+
+/**
+ * Drives `playHandSteps` to completion, resolving every OPPONENT decision point internally via
+ * `decideAction` (using its own policy RNG, separate from the dealing RNG so tuning `iterations`
+ * never perturbs deal order) and only ever pausing at `status.step` on the PLAYER's turn. This is
+ * the seam a UI drives: read `status`, call `submit(action)` on a click, repeat until `complete`.
+ */
+export const startInteractiveHand = (
+ dealRng: Rng,
+ policyRng: Rng,
+ setup: HandSetup,
+ opponentIterations: number,
+): InteractiveHand => {
+ const gen = playHandSteps(dealRng, setup)
+
+ const advance = (next: IteratorResult): HandStatus => {
+ let step = next
+ while (!step.done) {
+ if (step.value.seat === 'player') {
+ return { kind: 'awaiting', step: step.value }
+ }
+ const { betting, board, ownHoleCards } = step.value
+ const legal = legalActions(betting)
+ const ownHole = ownHoleCards as [Card, Card]
+ const action = decideAction(betting, legal, ownHole, board, policyRng, opponentIterations)
+ step = gen.next(action)
+ }
+ return { kind: 'complete', result: step.value }
+ }
+
+ let status: HandStatus = advance(gen.next())
+
+ return {
+ get status(): HandStatus {
+ return status
+ },
+ submit: (action: Action): void => {
+ if (status.kind !== 'awaiting') {
+ throw new Error('startInteractiveHand: no player decision is pending')
+ }
+ status = advance(gen.next(action))
+ },
+ }
+}
diff --git a/poker-stakes/src/engine/invariants.test.ts b/poker-stakes/src/engine/invariants.test.ts
new file mode 100644
index 0000000..1213168
--- /dev/null
+++ b/poker-stakes/src/engine/invariants.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest'
+import { legalActions } from './bettingRound'
+import { startInteractiveHand } from './interactiveHand'
+import { createRng } from './rng'
+import { applyHandResult, cashOutSession, deriveBlindSize, startSession } from './session'
+import { settleSession } from '../stakes/settlement'
+
+const BUY_IN_CHIPS = 100
+const SEEDS = 40
+const MAX_HANDS = 400
+const POLICY_SEED_SALT = 0x9e3779b9
+
+/**
+ * Property sweep over many seeds, complementing the scripted per-module tests: those pin specific
+ * scenarios, this asserts the properties that must hold for EVERY session the engine can produce.
+ *
+ * Chip conservation is the important one — a heads-up table is a closed system, so if chips are
+ * ever created or destroyed the stakes model is compromised (the settlement formula reads
+ * `finalChips` directly, so invented chips would become forgiven debt).
+ */
+describe('engine invariants across many seeds', () => {
+ it('conserves chips, keeps stacks non-negative, and always settles by the formula', () => {
+ const failures: string[] = []
+ let totalDecisions = 0
+ let sessionsEnded = 0
+
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
+ let session = startSession({ buyInChips: BUY_IN_CHIPS })
+ const dealRng = createRng(seed)
+ const policyRng = createRng(seed ^ POLICY_SEED_SALT)
+ const tableTotal = session.stacks.player + session.stacks.opponent
+
+ for (let hand = 0; hand < MAX_HANDS && session.outcome === null; hand += 1) {
+ const { bigBlind, smallBlind } = deriveBlindSize(BUY_IN_CHIPS)
+ const interactive = startInteractiveHand(
+ dealRng,
+ policyRng,
+ { buttonSeat: session.buttonSeat, bigBlind, smallBlind, stacks: session.stacks },
+ 50,
+ )
+
+ // Always take the first legal action — an arbitrary but deterministic strategy. The
+ // invariants below must hold regardless of how badly the player plays.
+ let guard = 0
+ while (interactive.status.kind === 'awaiting' && guard < 200) {
+ const [first] = legalActions(interactive.status.step.betting)
+ if (first === undefined) {
+ break
+ }
+ interactive.submit(first)
+ totalDecisions += 1
+ guard += 1
+ }
+
+ const status = interactive.status
+ if (status.kind !== 'complete') {
+ failures.push(`seed ${String(seed)} hand ${String(hand)}: hand never completed`)
+ break
+ }
+
+ session = applyHandResult(session, status.result)
+ const sum = session.stacks.player + session.stacks.opponent
+ if (sum !== tableTotal) {
+ failures.push(`seed ${String(seed)} hand ${String(hand)}: chips ${String(sum)} != ${String(tableTotal)}`)
+ }
+ if (session.stacks.player < 0 || session.stacks.opponent < 0) {
+ failures.push(`seed ${String(seed)} hand ${String(hand)}: negative stack`)
+ }
+ }
+
+ if (session.outcome !== null) {
+ sessionsEnded += 1
+ }
+ const ended = session.outcome !== null ? session : cashOutSession(session)
+ const { outcome } = ended
+ if (outcome !== null) {
+ const debt = settleSession({ taskTypeId: 'pushups', chips: BUY_IN_CHIPS }, outcome, 'id', 0)
+ const expected = Math.max(0, 2 * BUY_IN_CHIPS - outcome.finalChips)
+ if (debt.owedUnits !== expected) {
+ failures.push(`seed ${String(seed)}: owed ${String(debt.owedUnits)} != ${String(expected)}`)
+ }
+ if (debt.owedUnits > 2 * BUY_IN_CHIPS) {
+ failures.push(`seed ${String(seed)}: owed ${String(debt.owedUnits)} exceeds the 2x cap`)
+ }
+ }
+ }
+
+ // Guards against the sweep silently doing nothing (an empty loop would otherwise "pass").
+ expect(totalDecisions).toBeGreaterThan(500)
+ expect(sessionsEnded).toBe(SEEDS)
+ expect(failures.slice(0, 10)).toEqual([])
+ })
+})
diff --git a/poker-stakes/src/engine/opponentPolicy.test.ts b/poker-stakes/src/engine/opponentPolicy.test.ts
new file mode 100644
index 0000000..75b2558
--- /dev/null
+++ b/poker-stakes/src/engine/opponentPolicy.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it } from 'vitest'
+import type { Action, BettingState, SeatState } from './bettingRound'
+import { legalActions } from './bettingRound'
+import { chooseAction, decideAction, estimateEquity, potOddsToCall } from './opponentPolicy'
+import { createRng } from './rng'
+import type { Card } from './types'
+
+const card = (rank: Card['rank'], suit: Card['suit']): Card => ({ rank, suit })
+
+const seat = (overrides: Partial = {}): SeatState => ({
+ stack: 100,
+ committed: 0,
+ hasActed: false,
+ folded: false,
+ allIn: false,
+ ...overrides,
+})
+
+const betting = (overrides: Partial = {}): BettingState => ({
+ street: 'preflop',
+ pot: 0,
+ toAct: 'player',
+ seats: { player: seat(), opponent: seat() },
+ betToCall: 0,
+ minRaiseSize: 10,
+ roundOver: false,
+ ...overrides,
+})
+
+describe('chooseAction', () => {
+ it('bets/raises the max size on strong equity when available', () => {
+ const legal: Action[] = [{ type: 'fold' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'bet', amount: 100 }]
+ expect(chooseAction(legal, 0.9, 0.2)).toEqual({ type: 'bet', amount: 100 })
+ })
+
+ it('bets/raises the min size on moderate equity when strong-equity threshold is not met', () => {
+ const legal: Action[] = [{ type: 'fold' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'bet', amount: 100 }]
+ expect(chooseAction(legal, 0.6, 0.2)).toEqual({ type: 'bet', amount: 10 })
+ })
+
+ it('calls/checks when equity clears pot odds but is below the aggressive thresholds', () => {
+ const legal: Action[] = [{ type: 'fold' }, { type: 'call' }]
+ expect(chooseAction(legal, 0.3, 0.2)).toEqual({ type: 'call' })
+ })
+
+ it('folds when equity is below pot odds and folding is available', () => {
+ const legal: Action[] = [{ type: 'fold' }, { type: 'call' }]
+ expect(chooseAction(legal, 0.1, 0.5)).toEqual({ type: 'fold' })
+ })
+
+ it('checks for free (no fold available, e.g. facing no bet) when equity is below thresholds', () => {
+ const legal: Action[] = [{ type: 'check' }]
+ expect(chooseAction(legal, 0.1, 0)).toEqual({ type: 'check' })
+ })
+
+ it('falls back to the only legal action when nothing else matches', () => {
+ const legal: Action[] = [{ type: 'call' }]
+ // High equity but no aggressive option and no fold available: falls through to passive.
+ expect(chooseAction(legal, 0.95, 0.5)).toEqual({ type: 'call' })
+ })
+
+ it('picks the first legal action as a last resort when only aggressive options exist above threshold gating', () => {
+ // Only a single bet is legal and equity/pot-odds are both very low with no fold/passive offered.
+ const legal: Action[] = [{ type: 'bet', amount: 10 }]
+ expect(chooseAction(legal, 0, 1)).toEqual({ type: 'bet', amount: 10 })
+ })
+
+ it('checks/calls when equity is low and there is no fold option available (facing an all-in check)', () => {
+ // No fold offered (e.g. already all-in facing no further decision except check) — falls
+ // through the "fold" branch to the passive one instead.
+ const legal: Action[] = [{ type: 'check' }]
+ expect(chooseAction(legal, 0, 1)).toEqual({ type: 'check' })
+ })
+
+ it('throws when given no legal actions at all', () => {
+ expect(() => chooseAction([], 0.5, 0.5)).toThrow('no legal actions to choose from')
+ })
+})
+
+describe('potOddsToCall', () => {
+ it('is zero when nothing is owed', () => {
+ expect(potOddsToCall(betting({ betToCall: 0 }))).toBe(0)
+ })
+
+ it('computes the fraction of the resulting pot the call would represent', () => {
+ const s = betting({
+ betToCall: 20,
+ pot: 0,
+ seats: { player: seat({ committed: 0 }), opponent: seat({ committed: 20 }) },
+ })
+ // toCall = 20, potAfterCall = 0 + 0 + 20 + 20 = 40, odds = 20/40 = 0.5
+ expect(potOddsToCall(s)).toBe(0.5)
+ })
+})
+
+describe('estimateEquity', () => {
+ it('returns a value in [0, 1]', () => {
+ const ownHole: [Card, Card] = [card(14, 'clubs'), card(14, 'diamonds')]
+ const equity = estimateEquity(ownHole, [], createRng(1), 50)
+ expect(equity).toBeGreaterThanOrEqual(0)
+ expect(equity).toBeLessThanOrEqual(1)
+ })
+
+ it('is deterministic for a fixed seed', () => {
+ const ownHole: [Card, Card] = [card(14, 'clubs'), card(14, 'diamonds')]
+ const a = estimateEquity(ownHole, [], createRng(7), 30)
+ const b = estimateEquity(ownHole, [], createRng(7), 30)
+ expect(a).toBe(b)
+ })
+
+ it('returns 0 for zero iterations', () => {
+ const ownHole: [Card, Card] = [card(2, 'clubs'), card(3, 'diamonds')]
+ expect(estimateEquity(ownHole, [], createRng(1), 0)).toBe(0)
+ })
+
+ it('rates pocket aces higher than a weak hand on average, over many samples', () => {
+ const strongHole: [Card, Card] = [card(14, 'clubs'), card(14, 'diamonds')]
+ const weakHole: [Card, Card] = [card(2, 'hearts'), card(7, 'spades')]
+ const strongEquity = estimateEquity(strongHole, [], createRng(3), 200)
+ const weakEquity = estimateEquity(weakHole, [], createRng(3), 200)
+ expect(strongEquity).toBeGreaterThan(weakEquity)
+ })
+
+ it('estimates equity with a partially dealt board (flop known)', () => {
+ const ownHole: [Card, Card] = [card(14, 'clubs'), card(13, 'clubs')]
+ const board: Card[] = [card(12, 'clubs'), card(11, 'clubs'), card(2, 'hearts')]
+ const equity = estimateEquity(ownHole, board, createRng(5), 40)
+ expect(equity).toBeGreaterThanOrEqual(0)
+ expect(equity).toBeLessThanOrEqual(1)
+ })
+})
+
+describe('decideAction', () => {
+ it('always returns one of the offered legal actions', () => {
+ const s = betting({ betToCall: 0, minRaiseSize: 10, seats: { player: seat({ stack: 100 }), opponent: seat() } })
+ const legal = legalActions(s)
+ const ownHole: [Card, Card] = [card(14, 'clubs'), card(14, 'diamonds')]
+ const action = decideAction(s, legal, ownHole, [], createRng(1), 20)
+ expect(legal).toContainEqual(action)
+ })
+})
diff --git a/poker-stakes/src/engine/opponentPolicy.ts b/poker-stakes/src/engine/opponentPolicy.ts
new file mode 100644
index 0000000..d771368
--- /dev/null
+++ b/poker-stakes/src/engine/opponentPolicy.ts
@@ -0,0 +1,105 @@
+import type { Action, BettingState } from './bettingRound'
+import { buildDeck, shuffleDeck } from './deck'
+import { compareHandScore, evaluate7 } from './handEval'
+import type { Rng } from './rng'
+import type { Card } from './types'
+
+const cardKey = (card: Card): string => `${String(card.rank)}-${card.suit}`
+
+/**
+ * Monte Carlo equity estimate: samples `iterations` plausible completions of the unknown cards
+ * (opponent's hole cards drawn from this seat's perspective, plus any remaining board cards) and
+ * returns the fraction of samples this seat's final hand wins or ties.
+ *
+ * Only ever sees `ownHole` and the current `board` — never the real opponent's hole cards.
+ */
+export const estimateEquity = (
+ ownHole: readonly [Card, Card],
+ board: readonly Card[],
+ rng: Rng,
+ iterations: number,
+): number => {
+ const known = new Set([...ownHole, ...board].map(cardKey))
+ const remaining = buildDeck().filter((card) => !known.has(cardKey(card)))
+ const boardCardsNeeded = 5 - board.length
+
+ let wins = 0
+ for (let i = 0; i < iterations; i += 1) {
+ const shuffled = shuffleDeck(rng, remaining)
+ const opponentHole = shuffled.slice(0, 2) as [Card, Card]
+ const remainingBoard = shuffled.slice(2, 2 + boardCardsNeeded)
+ const fullBoard = [...board, ...remainingBoard]
+
+ const ownSeven = [...ownHole, ...fullBoard] as [Card, Card, Card, Card, Card, Card, Card]
+ const oppSeven = [...opponentHole, ...fullBoard] as [Card, Card, Card, Card, Card, Card, Card]
+ const cmp = compareHandScore(evaluate7(ownSeven), evaluate7(oppSeven))
+ if (cmp >= 0) {
+ wins += 1
+ }
+ }
+ return iterations === 0 ? 0 : wins / iterations
+}
+
+/**
+ * Picks one of the legal actions using a hand-strength/pot-odds heuristic. Never constructs an
+ * action outside `legal` — the returned action is always a member of that list.
+ */
+export const chooseAction = (legal: readonly Action[], equity: number, potOdds: number): Action => {
+ const fold = legal.find((a) => a.type === 'fold')
+ const passive = legal.find((a) => a.type === 'check' || a.type === 'call')
+ const aggressiveActions = legal.filter((a) => a.type === 'bet' || a.type === 'raise')
+ const minAggressive = aggressiveActions[0]
+ const maxAggressive = aggressiveActions[aggressiveActions.length - 1]
+
+ // Strong equity: bet/raise big when possible, otherwise take whatever passive/fold action is offered.
+ if (equity >= 0.7 && maxAggressive !== undefined) {
+ return maxAggressive
+ }
+ // Moderate equity: open/raise a standard (minimum) size when possible.
+ if (equity >= 0.55 && minAggressive !== undefined) {
+ return minAggressive
+ }
+ // Equity clears the price being laid: call/check rather than fold.
+ if (equity >= potOdds && passive !== undefined) {
+ return passive
+ }
+ // Equity doesn't justify continuing: fold if there's a cost to continue, else check for free.
+ if (fold !== undefined) {
+ return fold
+ }
+ if (passive !== undefined) {
+ return passive
+ }
+ const fallback = legal[0]
+ if (fallback === undefined) {
+ throw new Error('chooseAction: no legal actions to choose from')
+ }
+ return fallback
+}
+
+/** Computes pot odds: the price (as equity fraction) required to profitably call the amount owed. */
+export const potOddsToCall = (betting: BettingState): number => {
+ const toCall = betting.betToCall - betting.seats[betting.toAct].committed
+ if (toCall <= 0) {
+ return 0
+ }
+ const potAfterCall = betting.pot + betting.seats.player.committed + betting.seats.opponent.committed + toCall
+ return toCall / potAfterCall
+}
+
+/**
+ * Composes estimateEquity + chooseAction into a full decision for the given betting state.
+ * `ownHole` must be the deciding seat's own hole cards only.
+ */
+export const decideAction = (
+ betting: BettingState,
+ legal: readonly Action[],
+ ownHole: readonly [Card, Card],
+ board: readonly Card[],
+ rng: Rng,
+ iterations: number,
+): Action => {
+ const equity = estimateEquity(ownHole, board, rng, iterations)
+ const potOdds = potOddsToCall(betting)
+ return chooseAction(legal, equity, potOdds)
+}
diff --git a/poker-stakes/src/engine/rng.test.ts b/poker-stakes/src/engine/rng.test.ts
new file mode 100644
index 0000000..3867760
--- /dev/null
+++ b/poker-stakes/src/engine/rng.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest'
+import { createRng, nextFloat, nextInt, pick } from './rng'
+
+describe('rng', () => {
+ it('is deterministic for a given seed', () => {
+ const a = createRng(123)
+ const b = createRng(123)
+ expect([nextFloat(a), nextFloat(a), nextFloat(a)]).toEqual([
+ nextFloat(b),
+ nextFloat(b),
+ nextFloat(b),
+ ])
+ })
+
+ it('produces different sequences for different seeds', () => {
+ expect(nextFloat(createRng(1))).not.toBe(nextFloat(createRng(2)))
+ })
+
+ it('stays within [0, 1)', () => {
+ const rng = createRng(77)
+ for (let i = 0; i < 200; i += 1) {
+ const v = nextFloat(rng)
+ expect(v).toBeGreaterThanOrEqual(0)
+ expect(v).toBeLessThan(1)
+ }
+ })
+
+ it('wraps negative seeds into unsigned space', () => {
+ expect(createRng(-1).s).toBe(0xffffffff)
+ })
+
+ it('nextInt stays within [0, max)', () => {
+ const rng = createRng(9)
+ for (let i = 0; i < 100; i += 1) {
+ const v = nextInt(rng, 4)
+ expect(v).toBeGreaterThanOrEqual(0)
+ expect(v).toBeLessThan(4)
+ }
+ })
+
+ it('pick eventually returns every element of a tuple', () => {
+ const rng = createRng(5)
+ const seen = new Set()
+ for (let i = 0; i < 64; i += 1) {
+ seen.add(pick(rng, ['a', 'b', 'c'] as const))
+ }
+ expect(seen).toEqual(new Set(['a', 'b', 'c']))
+ })
+
+ it('pick is deterministic', () => {
+ expect(pick(createRng(42), [10, 20, 30] as const)).toBe(
+ pick(createRng(42), [10, 20, 30] as const),
+ )
+ })
+})
diff --git a/poker-stakes/src/engine/rng.ts b/poker-stakes/src/engine/rng.ts
new file mode 100644
index 0000000..1c58210
--- /dev/null
+++ b/poker-stakes/src/engine/rng.ts
@@ -0,0 +1,33 @@
+/** Mutable state for a mulberry32 generator. */
+export interface Rng {
+ s: number
+}
+
+export const createRng = (seed: number): Rng => ({ s: seed >>> 0 })
+
+/** Deterministic float in [0, 1). Advances the generator. */
+export const nextFloat = (rng: Rng): number => {
+ rng.s = (rng.s + 0x6d2b79f5) >>> 0
+ let t = rng.s
+ t = Math.imul(t ^ (t >>> 15), t | 1)
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+}
+
+/** Deterministic integer in [0, maxExclusive). */
+export const nextInt = (rng: Rng, maxExclusive: number): number =>
+ Math.floor(nextFloat(rng) * maxExclusive)
+
+/** Deterministic pick from a non-empty tuple. */
+export const pick = (rng: Rng, items: readonly [T, ...T[]]): T => {
+ const idx = nextInt(rng, items.length)
+ let out: T = items[0]
+ let i = 0
+ for (const item of items) {
+ if (i === idx) {
+ out = item
+ }
+ i += 1
+ }
+ return out
+}
diff --git a/poker-stakes/src/engine/session.test.ts b/poker-stakes/src/engine/session.test.ts
new file mode 100644
index 0000000..3e9370c
--- /dev/null
+++ b/poker-stakes/src/engine/session.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from 'vitest'
+import type { Action, BettingState } from './bettingRound'
+import { legalActions } from './bettingRound'
+import { createRng } from './rng'
+import { cashOutSession, deriveBlindSize, playSessionHand, startSession } from './session'
+import type { Seat } from './types'
+
+const enforceLegal = (
+ decide: (betting: BettingState, seat: Seat) => Action,
+): ((betting: BettingState, seat: Seat) => Action) => {
+ return (betting, seat) => {
+ const action = decide(betting, seat)
+ expect(legalActions(betting)).toContainEqual(action)
+ return action
+ }
+}
+
+const alwaysCheckOrCall = enforceLegal((betting) => {
+ const toCall = betting.betToCall - betting.seats[betting.toAct].committed
+ return toCall > 0 ? { type: 'call' } : { type: 'check' }
+})
+
+/** Shoves all-in whenever a bet/raise is legal for the given seat, otherwise checks/calls. */
+const shoveForSeat = (targetSeat: Seat): ((betting: BettingState, seat: Seat) => Action) =>
+ enforceLegal((betting, seat) => {
+ if (seat !== targetSeat) {
+ return alwaysCheckOrCall(betting, seat)
+ }
+ const actions = legalActions(betting)
+ const shove = actions.find((a) => a.type === 'bet' || a.type === 'raise')
+ return shove ?? alwaysCheckOrCall(betting, seat)
+ })
+
+describe('deriveBlindSize', () => {
+ it('floors a tiny buy-in to the minimum viable blinds (BB=2, SB=1)', () => {
+ expect(deriveBlindSize(20)).toEqual({ bigBlind: 2, smallBlind: 1 })
+ })
+
+ it('scales blinds proportionally for a large buy-in', () => {
+ expect(deriveBlindSize(1000)).toEqual({ bigBlind: 10, smallBlind: 5 })
+ })
+
+ it('never produces a small blind equal to the big blind', () => {
+ for (const buyIn of [1, 2, 20, 99, 100, 250, 10_000]) {
+ const { bigBlind, smallBlind } = deriveBlindSize(buyIn)
+ expect(smallBlind).toBeLessThan(bigBlind)
+ expect(smallBlind).toBeGreaterThanOrEqual(1)
+ }
+ })
+})
+
+describe('startSession', () => {
+ it('starts both stacks equal to the buy-in with the player on the button', () => {
+ const session = startSession({ buyInChips: 100 })
+ expect(session.stacks).toEqual({ player: 100, opponent: 100 })
+ expect(session.buttonSeat).toBe('player')
+ expect(session.handsPlayed).toBe(0)
+ expect(session.outcome).toBeNull()
+ })
+})
+
+describe('playSessionHand', () => {
+ it('updates stacks, increments handsPlayed, and rotates the button', () => {
+ const session = startSession({ buyInChips: 200 })
+ const { session: next } = playSessionHand(session, createRng(1), 200, { decide: alwaysCheckOrCall })
+ expect(next.handsPlayed).toBe(1)
+ expect(next.buttonSeat).toBe('opponent')
+ expect(next.stacks.player + next.stacks.opponent).toBe(400)
+ })
+
+ it('throws when called on an already-ended session', () => {
+ const session = startSession({ buyInChips: 200 })
+ const ended = cashOutSession(session)
+ expect(() => playSessionHand(ended, createRng(1), 200, { decide: alwaysCheckOrCall })).toThrow(
+ 'session has already ended',
+ )
+ })
+
+ it('records a bust outcome when the player stack hits zero (deterministic seed)', () => {
+ // Seed 1: player always shoves all-in, opponent always checks/calls — player loses and busts.
+ const session = startSession({ buyInChips: 10 })
+ const { session: after } = playSessionHand(session, createRng(1), 10, { decide: shoveForSeat('player') })
+ expect(after.outcome).toEqual({ reason: 'bust', finalChips: 0 })
+ expect(after.stacks.player).toBe(0)
+ })
+
+ it('records a cash-out-equivalent outcome when the opponent stack hits zero (deterministic seed)', () => {
+ // Seed 100: opponent always shoves all-in, player always checks/calls — opponent loses and busts.
+ const session = startSession({ buyInChips: 10 })
+ const { session: after } = playSessionHand(session, createRng(100), 10, { decide: shoveForSeat('opponent') })
+ expect(after.stacks.opponent).toBe(0)
+ expect(after.outcome).toEqual({ reason: 'cashOut', finalChips: after.stacks.player })
+ expect(after.stacks.player).toBeGreaterThan(0)
+ })
+
+ it('conserves total chips across many hands until the session ends', () => {
+ let session = startSession({ buyInChips: 100 })
+ let seed = 1
+ while (session.outcome === null && session.handsPlayed < 30) {
+ const result = playSessionHand(session, createRng(seed), 100, { decide: alwaysCheckOrCall })
+ session = result.session
+ seed += 1
+ expect(session.stacks.player + session.stacks.opponent).toBe(200)
+ }
+ })
+})
+
+describe('cashOutSession', () => {
+ it('records the current player stack as the outcome', () => {
+ const session = startSession({ buyInChips: 100 })
+ const { session: afterHand } = playSessionHand(session, createRng(1), 100, { decide: alwaysCheckOrCall })
+ const cashedOut = cashOutSession(afterHand)
+ expect(cashedOut.outcome).toEqual({ reason: 'cashOut', finalChips: afterHand.stacks.player })
+ })
+
+ it('throws when called on an already-ended session', () => {
+ const session = startSession({ buyInChips: 100 })
+ const cashedOut = cashOutSession(session)
+ expect(() => cashOutSession(cashedOut)).toThrow('session has already ended')
+ })
+})
diff --git a/poker-stakes/src/engine/session.ts b/poker-stakes/src/engine/session.ts
new file mode 100644
index 0000000..6043c64
--- /dev/null
+++ b/poker-stakes/src/engine/session.ts
@@ -0,0 +1,91 @@
+import type { HandFlowCallbacks, HandResult, HeadsUpStacks } from './handFlow'
+import { playHand } from './handFlow'
+import type { Rng } from './rng'
+import type { Seat } from './types'
+
+export interface SessionConfig {
+ buyInChips: number
+}
+
+export type SessionEndReason = 'cashOut' | 'bust'
+
+export interface SessionOutcome {
+ reason: SessionEndReason
+ finalChips: number // the player's stack at the moment the session ended
+}
+
+export interface SessionState {
+ stacks: HeadsUpStacks
+ buttonSeat: Seat // rotates each hand
+ handsPlayed: number
+ outcome: SessionOutcome | null // null while the session is still live
+}
+
+/** Big blind derived from the buy-in; small blind is always ≥1 and strictly less than BB. */
+export const deriveBlindSize = (buyInChips: number): { bigBlind: number; smallBlind: number } => {
+ const bigBlind = Math.max(2, Math.floor(buyInChips / 100))
+ const smallBlind = Math.floor(bigBlind / 2)
+ return { bigBlind, smallBlind }
+}
+
+export const startSession = (config: SessionConfig): SessionState => ({
+ stacks: { player: config.buyInChips, opponent: config.buyInChips },
+ buttonSeat: 'player',
+ handsPlayed: 0,
+ outcome: null,
+})
+
+const other = (seat: Seat): Seat => (seat === 'player' ? 'opponent' : 'player')
+
+/**
+ * Folds a completed hand's result into the session: updates stacks, rotates the button, and
+ * detects bust/cash-out. Pure — shared by `playSessionHand` (the PC-only synchronous path) and
+ * any interactive driver that runs a hand via `playHandSteps` instead.
+ */
+export const applyHandResult = (session: SessionState, handResult: HandResult): SessionState => {
+ const nextStacks = handResult.finalStacks
+ // Player busting is a real debt event. Opponent busting means the player holds every chip in
+ // play, which settles to owing nothing under the settlement formula — model it the same as a
+ // cash-out (at the max chip count) rather than a special case, since no further hand is possible.
+ let outcome: SessionOutcome | null = null
+ if (nextStacks.player === 0) {
+ outcome = { reason: 'bust', finalChips: 0 }
+ } else if (nextStacks.opponent === 0) {
+ outcome = { reason: 'cashOut', finalChips: nextStacks.player }
+ }
+
+ return {
+ stacks: nextStacks,
+ buttonSeat: other(session.buttonSeat),
+ handsPlayed: session.handsPlayed + 1,
+ outcome,
+ }
+}
+
+/** Plays one hand, updates stacks, rotates the button, and detects bust. No-op if already ended. */
+export const playSessionHand = (
+ session: SessionState,
+ rng: Rng,
+ buyInChips: number,
+ callbacks: HandFlowCallbacks,
+): { session: SessionState; handResult: HandResult } => {
+ if (session.outcome !== null) {
+ throw new Error('playSessionHand: session has already ended')
+ }
+ const { bigBlind, smallBlind } = deriveBlindSize(buyInChips)
+ const handResult = playHand(
+ rng,
+ { buttonSeat: session.buttonSeat, bigBlind, smallBlind, stacks: session.stacks },
+ callbacks,
+ )
+
+ return { session: applyHandResult(session, handResult), handResult }
+}
+
+/** Ends a live session by cash-out, recording the player's current chip count. */
+export const cashOutSession = (session: SessionState): SessionState => {
+ if (session.outcome !== null) {
+ throw new Error('cashOutSession: session has already ended')
+ }
+ return { ...session, outcome: { reason: 'cashOut', finalChips: session.stacks.player } }
+}
diff --git a/poker-stakes/src/engine/types.ts b/poker-stakes/src/engine/types.ts
new file mode 100644
index 0000000..2470bfd
--- /dev/null
+++ b/poker-stakes/src/engine/types.ts
@@ -0,0 +1,13 @@
+/** Card ranks, stored high (Ace = 14). Wheel straights are handled explicitly in handEval. */
+export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14
+
+export type Suit = 'clubs' | 'diamonds' | 'hearts' | 'spades'
+
+export interface Card {
+ rank: Rank
+ suit: Suit
+}
+
+export type Seat = 'player' | 'opponent'
+
+export type Street = 'preflop' | 'flop' | 'turn' | 'river' | 'showdown'
diff --git a/poker-stakes/src/main.test.tsx b/poker-stakes/src/main.test.tsx
new file mode 100644
index 0000000..bfdc538
--- /dev/null
+++ b/poker-stakes/src/main.test.tsx
@@ -0,0 +1,27 @@
+import { act } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { vi } from 'vitest'
+
+beforeEach(() => {
+ vi.resetModules()
+ document.body.innerHTML = ''
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('main', () => {
+ it('refuses to boot without a #root element', async () => {
+ await expect(import('./main')).rejects.toThrow('root element not found')
+ })
+
+ it('mounts the app into #root', async () => {
+ document.body.innerHTML = '
'
+ await act(async () => {
+ await import('./main')
+ })
+ // Asserts the mount happened, not the whole app's copy — App has its own tests for that.
+ expect(document.querySelector('#root')?.textContent).toContain('Poker Stakes')
+ })
+})
diff --git a/poker-stakes/src/main.tsx b/poker-stakes/src/main.tsx
new file mode 100644
index 0000000..54e6318
--- /dev/null
+++ b/poker-stakes/src/main.tsx
@@ -0,0 +1,15 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { App } from './App'
+import './styles.css'
+
+const rootElement = document.getElementById('root')
+if (rootElement === null) {
+ throw new Error('root element not found')
+}
+
+createRoot(rootElement).render(
+
+
+ ,
+)
diff --git a/poker-stakes/src/stakes/settlement.test.ts b/poker-stakes/src/stakes/settlement.test.ts
new file mode 100644
index 0000000..27d2720
--- /dev/null
+++ b/poker-stakes/src/stakes/settlement.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import type { SessionOutcome } from '../engine/session'
+import { settleSession } from './settlement'
+import type { BuyIn } from './types'
+
+const buyIn: BuyIn = { taskTypeId: 'pushups', chips: 100 }
+
+describe('settleSession', () => {
+ const cases: { name: string; outcome: SessionOutcome; owedUnits: number }[] = [
+ { name: 'doubled up', outcome: { reason: 'cashOut', finalChips: 200 }, owedUnits: 0 },
+ { name: 'partial win', outcome: { reason: 'cashOut', finalChips: 150 }, owedUnits: 50 },
+ { name: 'break-even', outcome: { reason: 'cashOut', finalChips: 100 }, owedUnits: 100 },
+ { name: 'partial loss', outcome: { reason: 'cashOut', finalChips: 50 }, owedUnits: 150 },
+ { name: 'bust to zero', outcome: { reason: 'bust', finalChips: 0 }, owedUnits: 200 },
+ ]
+
+ it.each(cases)('$name: owes $owedUnits units', ({ outcome, owedUnits }) => {
+ const debt = settleSession(buyIn, outcome, 'debt-1', 12345)
+ expect(debt.owedUnits).toBe(owedUnits)
+ })
+
+ it('carries the task type, reason, id, and settledAt through unchanged', () => {
+ const outcome: SessionOutcome = { reason: 'bust', finalChips: 0 }
+ const debt = settleSession(buyIn, outcome, 'debt-42', 999)
+ expect(debt).toEqual({
+ id: 'debt-42',
+ taskTypeId: 'pushups',
+ owedUnits: 200,
+ reason: 'bust',
+ settledAt: 999,
+ paidUnits: 0,
+ })
+ })
+})
diff --git a/poker-stakes/src/stakes/settlement.ts b/poker-stakes/src/stakes/settlement.ts
new file mode 100644
index 0000000..3b81f71
--- /dev/null
+++ b/poker-stakes/src/stakes/settlement.ts
@@ -0,0 +1,16 @@
+import type { SessionOutcome } from '../engine/session'
+import type { BuyIn, SettledDebt } from './types'
+
+/**
+ * Converts a finished session's outcome into a debt record. `owedUnits` scales inversely with
+ * how well the player did: `2 × buyIn − finalChips`, floored at 0. Bust (finalChips === 0) needs
+ * no special case — it's just this formula's natural output (2 × buyIn, the cap) for that input.
+ */
+export const settleSession = (buyIn: BuyIn, outcome: SessionOutcome, id: string, settledAt: number): SettledDebt => ({
+ id,
+ taskTypeId: buyIn.taskTypeId,
+ owedUnits: Math.max(0, 2 * buyIn.chips - outcome.finalChips),
+ reason: outcome.reason,
+ settledAt,
+ paidUnits: 0,
+})
diff --git a/poker-stakes/src/stakes/storage.test.ts b/poker-stakes/src/stakes/storage.test.ts
new file mode 100644
index 0000000..45d65a9
--- /dev/null
+++ b/poker-stakes/src/stakes/storage.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it } from 'vitest'
+import { loadDebts, saveDebts } from './storage'
+import type { SettledDebt } from './types'
+
+const debt: SettledDebt = {
+ id: 'd1',
+ taskTypeId: 'pushups',
+ owedUnits: 100,
+ reason: 'cashOut',
+ settledAt: 1,
+ paidUnits: 0,
+}
+
+const makeStorage = (initial: Record = {}): Storage => {
+ const store = new Map(Object.entries(initial))
+ return {
+ getItem: (key: string): string | null => store.get(key) ?? null,
+ setItem: (key: string, value: string): void => {
+ store.set(key, value)
+ },
+ removeItem: (key: string): void => {
+ store.delete(key)
+ },
+ clear: (): void => {
+ store.clear()
+ },
+ key: (): null => null,
+ get length(): number {
+ return store.size
+ },
+ }
+}
+
+const throwingStorage = (): Storage => ({
+ getItem: (): never => {
+ throw new Error('denied')
+ },
+ setItem: (): never => {
+ throw new Error('denied')
+ },
+ removeItem: (): undefined => undefined,
+ clear: (): undefined => undefined,
+ key: (): null => null,
+ length: 0,
+})
+
+describe('loadDebts', () => {
+ it('returns an empty ok list when nothing is stored yet', () => {
+ expect(loadDebts(makeStorage())).toEqual({ status: 'ok', debts: [] })
+ })
+
+ it('returns the stored debts when the shape is valid', () => {
+ const storage = makeStorage({ 'poker-stakes:settledDebts': JSON.stringify([debt]) })
+ expect(loadDebts(storage)).toEqual({ status: 'ok', debts: [debt] })
+ })
+
+ it('is unreadable when getItem throws (storage disabled/denied)', () => {
+ expect(loadDebts(throwingStorage())).toEqual({ status: 'unreadable' })
+ })
+
+ it('is unreadable on malformed JSON', () => {
+ const storage = makeStorage({ 'poker-stakes:settledDebts': '{not json' })
+ expect(loadDebts(storage)).toEqual({ status: 'unreadable' })
+ })
+
+ it('is unreadable when the stored value is not an array', () => {
+ const storage = makeStorage({ 'poker-stakes:settledDebts': JSON.stringify({ not: 'an array' }) })
+ expect(loadDebts(storage)).toEqual({ status: 'unreadable' })
+ })
+
+ it('is unreadable when an array entry is missing required fields', () => {
+ const storage = makeStorage({ 'poker-stakes:settledDebts': JSON.stringify([{ id: 'd1' }]) })
+ expect(loadDebts(storage)).toEqual({ status: 'unreadable' })
+ })
+
+ // Each field's guard is asserted in ISOLATION — all other fields stay valid. The
+ // missing-required-fields case above drops five fields at once, so it would still trip some
+ // other check if any single guard were deleted, leaving that guard effectively untested.
+ it.each([
+ ['id', { id: 42 }],
+ ['taskTypeId', { taskTypeId: null }],
+ ['owedUnits', { owedUnits: 'ten' }],
+ ['reason', { reason: 'quit' }],
+ ['settledAt', { settledAt: '2026-01-01' }],
+ ['paidUnits', { paidUnits: [] }],
+ ])('is unreadable when only %s has the wrong type', (_field, override) => {
+ const storage = makeStorage({ 'poker-stakes:settledDebts': JSON.stringify([{ ...debt, ...override }]) })
+ expect(loadDebts(storage)).toEqual({ status: 'unreadable' })
+ })
+
+ it.each(['id', 'taskTypeId', 'owedUnits', 'reason', 'settledAt', 'paidUnits'])(
+ 'is unreadable when only %s is missing',
+ (field) => {
+ // Built by filtering rather than `delete`, which lint forbids on computed keys.
+ const partial = Object.fromEntries(Object.entries(debt).filter(([key]) => key !== field))
+ const storage = makeStorage({ 'poker-stakes:settledDebts': JSON.stringify([partial]) })
+ expect(loadDebts(storage)).toEqual({ status: 'unreadable' })
+ },
+ )
+
+ it('is unreadable when an array entry has an invalid reason value', () => {
+ const bad = { ...debt, reason: 'quit' }
+ const storage = makeStorage({ 'poker-stakes:settledDebts': JSON.stringify([bad]) })
+ expect(loadDebts(storage)).toEqual({ status: 'unreadable' })
+ })
+
+ it('is unreadable when an array entry is null', () => {
+ const storage = makeStorage({ 'poker-stakes:settledDebts': JSON.stringify([null]) })
+ expect(loadDebts(storage)).toEqual({ status: 'unreadable' })
+ })
+})
+
+describe('saveDebts', () => {
+ it('round-trips through loadDebts', () => {
+ const storage = makeStorage()
+ saveDebts(storage, [debt])
+ expect(loadDebts(storage)).toEqual({ status: 'ok', debts: [debt] })
+ })
+})
diff --git a/poker-stakes/src/stakes/storage.ts b/poker-stakes/src/stakes/storage.ts
new file mode 100644
index 0000000..4383c77
--- /dev/null
+++ b/poker-stakes/src/stakes/storage.ts
@@ -0,0 +1,48 @@
+import type { SettledDebt } from './types'
+
+/** Exported so tests can seed a scripted ledger without duplicating the key string. */
+export const DEBTS_STORAGE_KEY = 'poker-stakes:settledDebts'
+
+export type DebtLoad = { status: 'ok'; debts: SettledDebt[] } | { status: 'unreadable' }
+
+const isSettledDebt = (value: unknown): value is SettledDebt => {
+ if (typeof value !== 'object' || value === null) {
+ return false
+ }
+ const v = value as Record
+ return (
+ typeof v.id === 'string' &&
+ typeof v.taskTypeId === 'string' &&
+ typeof v.owedUnits === 'number' &&
+ (v.reason === 'cashOut' || v.reason === 'bust') &&
+ typeof v.settledAt === 'number' &&
+ typeof v.paidUnits === 'number'
+ )
+}
+
+/** Fail-closed: any read/parse/shape error yields 'unreadable', never a silently-empty list. */
+export const loadDebts = (storage: Storage): DebtLoad => {
+ let raw: string | null
+ try {
+ raw = storage.getItem(DEBTS_STORAGE_KEY)
+ } catch {
+ return { status: 'unreadable' }
+ }
+ if (raw === null) {
+ return { status: 'ok', debts: [] }
+ }
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(raw)
+ } catch {
+ return { status: 'unreadable' }
+ }
+ if (!Array.isArray(parsed) || !parsed.every(isSettledDebt)) {
+ return { status: 'unreadable' }
+ }
+ return { status: 'ok', debts: parsed }
+}
+
+export const saveDebts = (storage: Storage, debts: SettledDebt[]): void => {
+ storage.setItem(DEBTS_STORAGE_KEY, JSON.stringify(debts))
+}
diff --git a/poker-stakes/src/stakes/taskTypes.test.ts b/poker-stakes/src/stakes/taskTypes.test.ts
new file mode 100644
index 0000000..68d8e60
--- /dev/null
+++ b/poker-stakes/src/stakes/taskTypes.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+import { TASK_TYPES } from './taskTypes'
+
+describe('TASK_TYPES', () => {
+ it('is non-empty', () => {
+ expect(TASK_TYPES.length).toBeGreaterThan(0)
+ })
+
+ it('has unique ids', () => {
+ const ids = TASK_TYPES.map((t) => t.id)
+ expect(new Set(ids).size).toBe(ids.length)
+ })
+
+ it('gives every entry a non-empty id and label', () => {
+ for (const taskType of TASK_TYPES) {
+ expect(taskType.id.length).toBeGreaterThan(0)
+ expect(taskType.label.length).toBeGreaterThan(0)
+ }
+ })
+})
diff --git a/poker-stakes/src/stakes/taskTypes.ts b/poker-stakes/src/stakes/taskTypes.ts
new file mode 100644
index 0000000..d963b99
--- /dev/null
+++ b/poker-stakes/src/stakes/taskTypes.ts
@@ -0,0 +1,7 @@
+import type { TaskType } from './types'
+
+export const TASK_TYPES: readonly [TaskType, ...TaskType[]] = [
+ { id: 'pushups', label: 'Pushups' },
+ { id: 'leetcode', label: 'LeetCode problems' },
+ { id: 'anki', label: 'Anki reviews' },
+]
diff --git a/poker-stakes/src/stakes/types.ts b/poker-stakes/src/stakes/types.ts
new file mode 100644
index 0000000..9d9527f
--- /dev/null
+++ b/poker-stakes/src/stakes/types.ts
@@ -0,0 +1,20 @@
+import type { SessionEndReason } from '../engine/session'
+
+export interface TaskType {
+ id: string
+ label: string
+}
+
+export interface BuyIn {
+ taskTypeId: string
+ chips: number
+}
+
+export interface SettledDebt {
+ id: string
+ taskTypeId: string
+ owedUnits: number // 2×buyIn − finalChips, floored at 0
+ reason: SessionEndReason // display-only; never branches settlement math
+ settledAt: number
+ paidUnits: number // partial progress; starts at 0, incremented via UI
+}
diff --git a/poker-stakes/src/styles.css b/poker-stakes/src/styles.css
new file mode 100644
index 0000000..36302e4
--- /dev/null
+++ b/poker-stakes/src/styles.css
@@ -0,0 +1,215 @@
+:root {
+ --ink: #211d1b;
+ --ink-raised-1: #2b2624;
+ --ink-raised-2: #38312e;
+ --line-dark: #463e3a;
+ --text-on-dark: #ecebe9;
+ --muted-on-dark: #aaa09a;
+
+ --accent: #b8862e;
+ --success: #8a9a3c;
+ --warning: #e0a63c;
+ --danger: #e2585f;
+ --on-fill: #211d1b;
+
+ --font-body: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
+ --font-data: ui-monospace, "Cascadia Mono", "JetBrains Mono", Menlo, monospace;
+
+ --space-xs: 4px;
+ --space-sm: 8px;
+ --space-md: 16px;
+ --space-lg: 24px;
+ --space-xl: 32px;
+ --space-xxl: 48px;
+
+ --radius-sm: 8px;
+ --radius-md: 12px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: var(--ink);
+ color: var(--text-on-dark);
+ font-family: var(--font-body);
+ min-height: 100vh;
+}
+
+button {
+ font-family: inherit;
+ font-size: 16px;
+}
+
+.frame {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: var(--space-lg) var(--space-md) var(--space-xxl);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-lg);
+}
+
+.masthead h1 {
+ font-size: 24px;
+ letter-spacing: -0.1px;
+ margin: 0 0 var(--space-xs);
+}
+
+.masthead p {
+ margin: 0;
+ color: var(--muted-on-dark);
+ font-size: 14px;
+}
+
+.panel {
+ background: var(--ink-raised-1);
+ border: 1px solid var(--line-dark);
+ border-radius: var(--radius-md);
+ padding: var(--space-lg);
+}
+
+.button {
+ border: 1px solid var(--line-dark);
+ border-radius: var(--radius-sm);
+ padding: 12px 24px;
+ background: var(--ink-raised-2);
+ color: var(--text-on-dark);
+ cursor: pointer;
+}
+
+.button:hover:not(:disabled) {
+ background: var(--line-dark);
+}
+
+.button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.button--primary {
+ background: var(--accent);
+ color: var(--on-fill);
+ border-color: var(--accent);
+}
+
+.button--danger {
+ background: var(--danger);
+ color: var(--on-fill);
+ border-color: var(--danger);
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ margin-bottom: var(--space-md);
+}
+
+.field label {
+ font-size: 14px;
+ letter-spacing: 0.05em;
+ color: var(--muted-on-dark);
+}
+
+.field input,
+.field select {
+ font-family: inherit;
+ font-size: 16px;
+ padding: 12px;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--line-dark);
+ background: var(--ink-raised-2);
+ color: var(--text-on-dark);
+}
+
+.table {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-md);
+}
+
+.table__seat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-sm);
+}
+
+.table__cards {
+ display: flex;
+ gap: var(--space-xs);
+}
+
+.table__pot {
+ font-family: var(--font-data);
+ font-size: 20px;
+ color: var(--accent);
+}
+
+.card {
+ width: 44px;
+ height: 62px;
+ border-radius: var(--radius-sm);
+ background: var(--text-on-dark);
+ color: var(--ink);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: var(--font-data);
+ font-weight: 600;
+ font-size: 16px;
+ border: 1px solid var(--line-dark);
+}
+
+.card--red {
+ color: var(--danger);
+}
+
+.card--back {
+ background: var(--ink-raised-2);
+ color: var(--muted-on-dark);
+}
+
+.controls {
+ display: flex;
+ gap: var(--space-sm);
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.debt-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-sm);
+}
+
+.debt-list li {
+ border: 1px solid var(--line-dark);
+ border-radius: var(--radius-sm);
+ padding: var(--space-md);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+}
+
+.log-progress {
+ display: flex;
+ gap: var(--space-sm);
+ align-items: center;
+}
+
+.log-progress input {
+ width: 96px;
+}
+
+.hand-log {
+ font-size: 14px;
+ color: var(--muted-on-dark);
+}
diff --git a/poker-stakes/src/test/handEvalFixtures.ts b/poker-stakes/src/test/handEvalFixtures.ts
new file mode 100644
index 0000000..ece72bc
--- /dev/null
+++ b/poker-stakes/src/test/handEvalFixtures.ts
@@ -0,0 +1,37 @@
+import type { Card } from '../engine/types'
+
+/** Shared helpers for the handEval test files, split to stay under the 250-line cap. */
+
+export const card = (rank: Card['rank'], suit: Card['suit']): Card => ({ rank, suit })
+
+
+export const five = (cards: readonly Card[]): [Card, Card, Card, Card, Card] => {
+ if (cards.length !== 5) {
+ throw new Error('five: expected exactly 5 cards')
+ }
+ const [a, b, c, d, e] = cards
+ return [a, b, c, d, e] as [Card, Card, Card, Card, Card]
+}
+
+export const seven = (cards: readonly Card[]): [Card, Card, Card, Card, Card, Card, Card] => {
+ if (cards.length !== 7) {
+ throw new Error('seven: expected exactly 7 cards')
+ }
+ const [a, b, c, d, e, f, g] = cards
+ return [a, b, c, d, e, f, g] as [Card, Card, Card, Card, Card, Card, Card]
+}
+
+/** Generates all C(52,5) 5-card combinations from a 52-card deck as index tuples. */
+export function* combinations5(length: number): Generator {
+ for (let a = 0; a < length; a += 1) {
+ for (let b = a + 1; b < length; b += 1) {
+ for (let c = b + 1; c < length; c += 1) {
+ for (let d = c + 1; d < length; d += 1) {
+ for (let e = d + 1; e < length; e += 1) {
+ yield [a, b, c, d, e]
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/poker-stakes/src/test/setup.ts b/poker-stakes/src/test/setup.ts
new file mode 100644
index 0000000..e87ca3f
--- /dev/null
+++ b/poker-stakes/src/test/setup.ts
@@ -0,0 +1,7 @@
+import '@testing-library/jest-dom/vitest'
+import { cleanup } from '@testing-library/react'
+import { afterEach } from 'vitest'
+
+afterEach(() => {
+ cleanup()
+})
diff --git a/poker-stakes/src/test/useGameFixtures.tsx b/poker-stakes/src/test/useGameFixtures.tsx
new file mode 100644
index 0000000..0748c0d
--- /dev/null
+++ b/poker-stakes/src/test/useGameFixtures.tsx
@@ -0,0 +1,65 @@
+import { StrictMode } from 'react'
+import type { ReactElement, ReactNode } from 'react'
+import type { SettledDebt } from '../stakes/types'
+import type { GameState } from '../ui/useGame'
+
+/**
+ * Shared fixtures for the useGame test files.
+ *
+ * useGame.test.tsx was split by describe-block to stay under the 250-line cap;
+ * these helpers are the part all three files need.
+ */
+
+export const debt = (overrides: Partial = {}): SettledDebt => ({
+ id: 'd1',
+ taskTypeId: 'pushups',
+ owedUnits: 100,
+ reason: 'cashOut',
+ settledAt: 1,
+ paidUnits: 0,
+ ...overrides,
+})
+
+export const initialState: GameState = {
+ debtLoad: { status: 'ok', debts: [] },
+ buyIn: null,
+ session: null,
+ handPhase: null,
+}
+
+export const wrapper = ({ children }: { children: ReactNode }): ReactElement => {children}
+
+/** An in-memory Storage stand-in, so tests never touch real localStorage. */
+export const makeStorage = (): Storage => {
+ const store = new Map()
+ return {
+ getItem: (key: string): string | null => store.get(key) ?? null,
+ setItem: (key: string, value: string): void => {
+ store.set(key, value)
+ },
+ removeItem: (key: string): void => {
+ store.delete(key)
+ },
+ clear: (): void => {
+ store.clear()
+ },
+ key: (): null => null,
+ get length(): number {
+ return store.size
+ },
+ }
+}
+
+/** Storage that throws on every read/write, for the fail-closed paths. */
+export const unreadableStorage = (): Storage => ({
+ getItem: (): never => {
+ throw new Error('denied')
+ },
+ setItem: (): never => {
+ throw new Error('denied')
+ },
+ removeItem: (): undefined => undefined,
+ clear: (): undefined => undefined,
+ key: (): null => null,
+ length: 0,
+})
diff --git a/poker-stakes/src/ui/BettingControls.test.tsx b/poker-stakes/src/ui/BettingControls.test.tsx
new file mode 100644
index 0000000..39ee78c
--- /dev/null
+++ b/poker-stakes/src/ui/BettingControls.test.tsx
@@ -0,0 +1,63 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import type { BettingState, SeatState } from '../engine/bettingRound'
+import { BettingControls } from './BettingControls'
+
+const seat = (overrides: Partial = {}): SeatState => ({
+ stack: 100,
+ committed: 0,
+ hasActed: false,
+ folded: false,
+ allIn: false,
+ ...overrides,
+})
+
+const betting = (overrides: Partial = {}): BettingState => ({
+ street: 'preflop',
+ pot: 0,
+ toAct: 'player',
+ seats: { player: seat(), opponent: seat() },
+ betToCall: 0,
+ minRaiseSize: 10,
+ roundOver: false,
+ ...overrides,
+})
+
+describe('BettingControls', () => {
+ it('renders fold/check/bet when nothing is owed', () => {
+ render( )
+ expect(screen.getByText('Fold')).toBeInTheDocument()
+ expect(screen.getByText('Check')).toBeInTheDocument()
+ expect(screen.getByText('Bet 10')).toBeInTheDocument()
+ expect(screen.getByText('Bet 100')).toBeInTheDocument()
+ expect(screen.queryByText('Call')).not.toBeInTheDocument()
+ })
+
+ it('renders fold/call/raise when a bet is owed', () => {
+ const state = betting({ betToCall: 20, seats: { player: seat(), opponent: seat({ committed: 20 }) } })
+ render( )
+ expect(screen.getByText('Fold')).toBeInTheDocument()
+ expect(screen.getByText('Call')).toBeInTheDocument()
+ expect(screen.getByText('Raise 30')).toBeInTheDocument()
+ })
+
+ it('renders no bet/raise option when the acting seat cannot cover more than a call', () => {
+ const state = betting({
+ betToCall: 20,
+ seats: { player: seat({ stack: 20 }), opponent: seat({ committed: 20 }) },
+ })
+ render( )
+ expect(screen.queryByText(/Raise/)).not.toBeInTheDocument()
+ })
+
+ it('calls onAction with the exact action when a button is clicked', async () => {
+ const user = userEvent.setup()
+ const onAction = vi.fn()
+ render( )
+ await user.click(screen.getByText('Check'))
+ expect(onAction).toHaveBeenCalledWith({ type: 'check' })
+ await user.click(screen.getByText('Bet 10'))
+ expect(onAction).toHaveBeenCalledWith({ type: 'bet', amount: 10 })
+ })
+})
diff --git a/poker-stakes/src/ui/BettingControls.tsx b/poker-stakes/src/ui/BettingControls.tsx
new file mode 100644
index 0000000..3dd9f73
--- /dev/null
+++ b/poker-stakes/src/ui/BettingControls.tsx
@@ -0,0 +1,45 @@
+import type { ReactElement } from 'react'
+import type { Action, BettingState } from '../engine/bettingRound'
+import { legalActions } from '../engine/bettingRound'
+
+const ACTION_LABELS: Record = {
+ fold: 'Fold',
+ check: 'Check',
+ call: 'Call',
+ bet: 'Bet',
+ raise: 'Raise',
+}
+
+const actionKey = (action: Action): string =>
+ action.type === 'bet' || action.type === 'raise' ? `${action.type}-${String(action.amount)}` : action.type
+
+const actionLabel = (action: Action): string =>
+ action.type === 'bet' || action.type === 'raise'
+ ? `${ACTION_LABELS[action.type]} ${String(action.amount)}`
+ : ACTION_LABELS[action.type]
+
+interface BettingControlsProps {
+ betting: BettingState
+ onAction: (action: Action) => void
+}
+
+/** Renders exactly the legal-action set for the current betting state — illegal actions are never representable. */
+export const BettingControls = ({ betting, onAction }: BettingControlsProps): ReactElement => {
+ const actions = legalActions(betting)
+ return (
+
+ {actions.map((action) => (
+ {
+ onAction(action)
+ }}
+ >
+ {actionLabel(action)}
+
+ ))}
+
+ )
+}
diff --git a/poker-stakes/src/ui/BuyInScreen.test.tsx b/poker-stakes/src/ui/BuyInScreen.test.tsx
new file mode 100644
index 0000000..0a8540b
--- /dev/null
+++ b/poker-stakes/src/ui/BuyInScreen.test.tsx
@@ -0,0 +1,139 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import type { DebtLoad } from '../stakes/storage'
+import type { SettledDebt } from '../stakes/types'
+import { BuyInScreen } from './BuyInScreen'
+
+const debt = (overrides: Partial = {}): SettledDebt => ({
+ id: 'd1',
+ taskTypeId: 'pushups',
+ owedUnits: 100,
+ reason: 'cashOut',
+ settledAt: 1,
+ paidUnits: 0,
+ ...overrides,
+})
+
+describe('BuyInScreen', () => {
+ it('renders the buy-in form when there is no outstanding debt', () => {
+ render( )
+ expect(screen.getByText('Sit down')).toBeInTheDocument()
+ expect(screen.queryByText(/outstanding debt/)).not.toBeInTheDocument()
+ })
+
+ it('submits the selected task type and chip count', async () => {
+ const user = userEvent.setup()
+ const onBuyIn = vi.fn()
+ render( )
+ const chipInput = screen.getByLabelText('Chip count')
+ await user.clear(chipInput)
+ await user.type(chipInput, '250')
+ await user.click(screen.getByText('Sit down'))
+ expect(onBuyIn).toHaveBeenCalledWith({ taskTypeId: 'pushups', chips: 250 })
+ })
+
+ // Passes because native `min={1}` validation aborts the submit, NOT because the JS guard ran —
+ // the cleared-field test below is what actually exercises the guard. Keep both.
+ it('does not submit a non-positive or fractional chip count', async () => {
+ const user = userEvent.setup()
+ const onBuyIn = vi.fn()
+ render( )
+ const chipInput = screen.getByLabelText('Chip count')
+ await user.clear(chipInput)
+ await user.type(chipInput, '0')
+ await user.click(screen.getByText('Sit down'))
+ expect(onBuyIn).not.toHaveBeenCalled()
+ })
+
+ // An empty field is the only non-positive value that actually reaches the JS guard: the
+ // browser's native `min={1}` constraint aborts submission for a typed "-5"/"0" before the
+ // handler fires, whereas an empty input is valid HTML-wise and parses to `Number('') === 0`.
+ it('does not submit when the chip count is cleared to empty', async () => {
+ const user = userEvent.setup()
+ const onBuyIn = vi.fn()
+ render( )
+ await user.clear(screen.getByLabelText('Chip count'))
+ await user.click(screen.getByText('Sit down'))
+ expect(onBuyIn).not.toHaveBeenCalled()
+ })
+
+ it('buys in with the task type picked from the select', async () => {
+ const user = userEvent.setup()
+ const onBuyIn = vi.fn()
+ render( )
+ await user.selectOptions(screen.getByLabelText('Task type'), 'leetcode')
+ await user.click(screen.getByText('Sit down'))
+ expect(onBuyIn).toHaveBeenCalledWith({ taskTypeId: 'leetcode', chips: 100 })
+ })
+
+ it('replaces the buy-in form with outstanding debts when a debt is unpaid (hard gate)', () => {
+ const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 100, paidUnits: 40 })] }
+ render( )
+ expect(screen.queryByText('Sit down')).not.toBeInTheDocument()
+ expect(screen.getByText(/outstanding debt/)).toBeInTheDocument()
+ expect(screen.getByText('Pushups: 40 / 100 done')).toBeInTheDocument()
+ })
+
+ it('does not list a fully paid debt among the outstanding ones', () => {
+ const debtLoad: DebtLoad = {
+ status: 'ok',
+ debts: [debt({ id: 'paid', owedUnits: 50, paidUnits: 50 }), debt({ id: 'owed', owedUnits: 100, paidUnits: 0 })],
+ }
+ render( )
+ expect(screen.queryByText('Pushups: 50 / 50 done')).not.toBeInTheDocument()
+ expect(screen.getByText('Pushups: 0 / 100 done')).toBeInTheDocument()
+ })
+
+ it('a doubled-up debt (owedUnits 0) does not block a new buy-in', () => {
+ const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 0, paidUnits: 0 })] }
+ render( )
+ expect(screen.getByText('Sit down')).toBeInTheDocument()
+ })
+
+ it('logs progress against an outstanding debt via the log-progress control', async () => {
+ const user = userEvent.setup()
+ const onLogDebtProgress = vi.fn()
+ const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 100, paidUnits: 40 })] }
+ render( )
+ const input = screen.getByLabelText('Log units done for Pushups')
+ await user.type(input, '20')
+ await user.click(screen.getByText('Log progress'))
+ expect(onLogDebtProgress).toHaveBeenCalledWith('d1', 20)
+ })
+
+ // Passes because native `min={1}` validation aborts the submit, NOT because the JS guard ran —
+ // the empty-field test below is what actually exercises the guard. Keep both.
+ it('does not log a non-positive or fractional progress amount', async () => {
+ const user = userEvent.setup()
+ const onLogDebtProgress = vi.fn()
+ const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 100, paidUnits: 40 })] }
+ render( )
+ const input = screen.getByLabelText('Log units done for Pushups')
+ await user.type(input, '-5')
+ await user.click(screen.getByText('Log progress'))
+ expect(onLogDebtProgress).not.toHaveBeenCalled()
+ })
+
+ // Same reasoning as the chip-count empty case: the empty field is what reaches the guard.
+ it('does not log progress when the units field is left empty', async () => {
+ const user = userEvent.setup()
+ const onLogDebtProgress = vi.fn()
+ const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 100, paidUnits: 40 })] }
+ render( )
+ await user.click(screen.getByText('Log progress'))
+ expect(onLogDebtProgress).not.toHaveBeenCalled()
+ })
+
+ it('blocks with a fail-closed message when storage was unreadable', () => {
+ render( )
+ expect(screen.queryByText('Sit down')).not.toBeInTheDocument()
+ expect(screen.getByText(/could not be read/)).toBeInTheDocument()
+ })
+
+ it('falls back to the raw taskTypeId label if it is not in the fixed list', () => {
+ const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ taskTypeId: 'unknown-type', owedUnits: 10, paidUnits: 0 })] }
+ render( )
+ expect(screen.getByText('unknown-type: 0 / 10 done')).toBeInTheDocument()
+ })
+})
diff --git a/poker-stakes/src/ui/BuyInScreen.tsx b/poker-stakes/src/ui/BuyInScreen.tsx
new file mode 100644
index 0000000..74b929f
--- /dev/null
+++ b/poker-stakes/src/ui/BuyInScreen.tsx
@@ -0,0 +1,91 @@
+import { useState } from 'react'
+import type { ChangeEvent, ReactElement, SubmitEvent } from 'react'
+import { TASK_TYPES } from '../stakes/taskTypes'
+import type { BuyIn } from '../stakes/types'
+import type { DebtLoad } from '../stakes/storage'
+import { OutstandingDebtRow } from './OutstandingDebtRow'
+import { hasOutstandingDebt } from './useGame'
+
+interface BuyInScreenProps {
+ debtLoad: DebtLoad
+ onBuyIn: (buyIn: BuyIn) => void
+ onLogDebtProgress: (debtId: string, units: number) => void
+}
+
+/**
+ * Hard gate, not a soft warning: while any debt is outstanding (or storage is unreadable, per the
+ * fail-closed rule), the buy-in form is replaced entirely by the outstanding-debt list and a
+ * log-progress control — matches the same gate `useGame.buyIn` enforces internally.
+ */
+export const BuyInScreen = ({ debtLoad, onBuyIn, onLogDebtProgress }: BuyInScreenProps): ReactElement => {
+ const [taskTypeId, setTaskTypeId] = useState(TASK_TYPES[0].id)
+ const [chips, setChips] = useState('100')
+
+ if (debtLoad.status === 'unreadable') {
+ return (
+
+
Your debt history could not be read. Buying in is blocked until this is resolved.
+
+ )
+ }
+
+ if (hasOutstandingDebt(debtLoad)) {
+ const outstanding = debtLoad.debts.filter((d) => d.paidUnits < d.owedUnits)
+ return (
+
+
You have outstanding debt. Pay it off before buying into a new session.
+
+ {outstanding.map((debt) => (
+
+ ))}
+
+
+ )
+ }
+
+ const submit = (event: SubmitEvent): void => {
+ event.preventDefault()
+ const parsedChips = Number(chips)
+ if (!Number.isInteger(parsedChips) || parsedChips <= 0) {
+ return
+ }
+ onBuyIn({ taskTypeId, chips: parsedChips })
+ }
+
+ return (
+
+ )
+}
diff --git a/poker-stakes/src/ui/Card.test.tsx b/poker-stakes/src/ui/Card.test.tsx
new file mode 100644
index 0000000..8ec223b
--- /dev/null
+++ b/poker-stakes/src/ui/Card.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import type { Card as CardModel } from '../engine/types'
+import { Card, CardBack } from './Card'
+
+describe('Card', () => {
+ it('renders a black-suit card without the red modifier', () => {
+ const card: CardModel = { rank: 14, suit: 'spades' }
+ render( )
+ const el = screen.getByLabelText('A of spades')
+ expect(el.className).not.toContain('card--red')
+ expect(el.textContent).toBe('A♠')
+ })
+
+ it('renders a red-suit card with the red modifier', () => {
+ const card: CardModel = { rank: 10, suit: 'hearts' }
+ render( )
+ const el = screen.getByLabelText('10 of hearts')
+ expect(el.className).toContain('card--red')
+ expect(el.textContent).toBe('10♥')
+ })
+
+ it('renders every rank label correctly', () => {
+ const ranks: { rank: CardModel['rank']; label: string }[] = [
+ { rank: 2, label: '2' },
+ { rank: 9, label: '9' },
+ { rank: 10, label: '10' },
+ { rank: 11, label: 'J' },
+ { rank: 12, label: 'Q' },
+ { rank: 13, label: 'K' },
+ { rank: 14, label: 'A' },
+ ]
+ for (const { rank, label } of ranks) {
+ const { unmount } = render( )
+ expect(screen.getByText(`${label}♣`)).toBeInTheDocument()
+ unmount()
+ }
+ })
+})
+
+describe('CardBack', () => {
+ it('renders a face-down placeholder', () => {
+ render( )
+ expect(screen.getByLabelText('face-down card')).toBeInTheDocument()
+ })
+})
diff --git a/poker-stakes/src/ui/Card.tsx b/poker-stakes/src/ui/Card.tsx
new file mode 100644
index 0000000..a2e6dfb
--- /dev/null
+++ b/poker-stakes/src/ui/Card.tsx
@@ -0,0 +1,47 @@
+import type { ReactElement } from 'react'
+import type { Card as CardModel } from '../engine/types'
+
+const RANK_LABELS: Record = {
+ 2: '2',
+ 3: '3',
+ 4: '4',
+ 5: '5',
+ 6: '6',
+ 7: '7',
+ 8: '8',
+ 9: '9',
+ 10: '10',
+ 11: 'J',
+ 12: 'Q',
+ 13: 'K',
+ 14: 'A',
+}
+
+const SUIT_SYMBOLS: Record = {
+ clubs: '♣',
+ diamonds: '♦',
+ hearts: '♥',
+ spades: '♠',
+}
+
+const RED_SUITS: ReadonlySet = new Set(['diamonds', 'hearts'])
+
+interface CardProps {
+ card: CardModel
+}
+
+export const Card = ({ card }: CardProps): ReactElement => {
+ const isRed = RED_SUITS.has(card.suit)
+ return (
+
+ {RANK_LABELS[card.rank]}
+ {SUIT_SYMBOLS[card.suit]}
+
+ )
+}
+
+export const CardBack = (): ReactElement => (
+
+ ?
+
+)
diff --git a/poker-stakes/src/ui/CashOutPanel.test.tsx b/poker-stakes/src/ui/CashOutPanel.test.tsx
new file mode 100644
index 0000000..c0f5146
--- /dev/null
+++ b/poker-stakes/src/ui/CashOutPanel.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import type { HandStep } from '../engine/handFlow'
+import type { SessionState } from '../engine/session'
+import type { BuyIn } from '../stakes/types'
+import type { HandPhase } from './useGame'
+import { CashOutPanel } from './CashOutPanel'
+
+const session = (playerStack: number): SessionState => ({
+ stacks: { player: playerStack, opponent: 200 },
+ buttonSeat: 'player',
+ handsPlayed: 1,
+ outcome: null,
+})
+
+const buyIn: BuyIn = { taskTypeId: 'pushups', chips: 100 }
+
+describe('CashOutPanel', () => {
+ it('renders nothing while a hand is in progress (mirrors the mid-hand cashOut refusal)', () => {
+ const awaitingPhase: HandPhase = { kind: 'awaiting', step: {} as HandStep }
+ const { container } = render(
+ ,
+ )
+ expect(container.textContent).toBe('')
+ })
+
+ it('renders nothing when handPhase is null (no live session yet)', () => {
+ const { container } = render( )
+ expect(container.textContent).toBe('')
+ })
+
+ it('shows a zero owed preview when the player has doubled their buy-in', () => {
+ const phase: HandPhase = { kind: 'betweenHands', lastResult: null }
+ render( )
+ expect(screen.getByText(/owe 0 units/)).toBeInTheDocument()
+ })
+
+ it('shows the correct owed preview for a partial loss', () => {
+ const phase: HandPhase = { kind: 'betweenHands', lastResult: null }
+ render( )
+ // owed = max(0, 2*100 - 50) = 150
+ expect(screen.getByText(/owe 150 units/)).toBeInTheDocument()
+ })
+
+ it('calls onCashOut when the leave-table button is clicked', async () => {
+ const user = userEvent.setup()
+ const onCashOut = vi.fn()
+ const phase: HandPhase = { kind: 'betweenHands', lastResult: null }
+ render( )
+ await user.click(screen.getByText('Leave table'))
+ expect(onCashOut).toHaveBeenCalledOnce()
+ })
+})
diff --git a/poker-stakes/src/ui/CashOutPanel.tsx b/poker-stakes/src/ui/CashOutPanel.tsx
new file mode 100644
index 0000000..598adcd
--- /dev/null
+++ b/poker-stakes/src/ui/CashOutPanel.tsx
@@ -0,0 +1,33 @@
+import type { ReactElement } from 'react'
+import type { SessionState } from '../engine/session'
+import type { BuyIn } from '../stakes/types'
+import type { HandPhase } from './useGame'
+
+interface CashOutPanelProps {
+ session: SessionState
+ buyIn: BuyIn
+ handPhase: HandPhase | null
+ onCashOut: () => void
+}
+
+/**
+ * Visible only between hands — mid-hand `session.stacks` is a stale pre-hand snapshot, and
+ * `useGame.cashOut` itself refuses mid-hand (the settlement-exploit fix), so this mirrors that
+ * restriction rather than showing a button that would silently do nothing when clicked.
+ */
+export const CashOutPanel = ({ session, buyIn, handPhase, onCashOut }: CashOutPanelProps): ReactElement | null => {
+ if (handPhase?.kind !== 'betweenHands') {
+ return null
+ }
+ const owedIfCashedOutNow = Math.max(0, 2 * buyIn.chips - session.stacks.player)
+ return (
+
+
+ Current stack: {session.stacks.player} chips. Cashing out now would owe {owedIfCashedOutNow} units.
+
+
+ Leave table
+
+
+ )
+}
diff --git a/poker-stakes/src/ui/DebtHistoryPanel.test.tsx b/poker-stakes/src/ui/DebtHistoryPanel.test.tsx
new file mode 100644
index 0000000..32089c8
--- /dev/null
+++ b/poker-stakes/src/ui/DebtHistoryPanel.test.tsx
@@ -0,0 +1,74 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import type { DebtLoad } from '../stakes/storage'
+import type { SettledDebt } from '../stakes/types'
+import { DebtHistoryPanel } from './DebtHistoryPanel'
+
+const debt = (overrides: Partial = {}): SettledDebt => ({
+ id: 'd1',
+ taskTypeId: 'pushups',
+ owedUnits: 100,
+ reason: 'cashOut',
+ settledAt: 1,
+ paidUnits: 0,
+ ...overrides,
+})
+
+describe('DebtHistoryPanel', () => {
+ it('says so when nothing has been settled yet', () => {
+ render( )
+ expect(screen.getByText('No debts settled yet.')).toBeInTheDocument()
+ })
+
+ it('reports unreadable storage instead of an empty ledger (fail-closed)', () => {
+ render( )
+ expect(screen.getByText('Your debt history could not be read.')).toBeInTheDocument()
+ expect(screen.queryByText('No debts settled yet.')).not.toBeInTheDocument()
+ })
+
+ it('gives an outstanding debt the log-progress control', async () => {
+ const user = userEvent.setup()
+ const onLogDebtProgress = vi.fn()
+ const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 100, paidUnits: 40 })] }
+ render( )
+ expect(screen.getByText('Pushups: 40 / 100 done')).toBeInTheDocument()
+ await user.type(screen.getByLabelText('Log units done for Pushups'), '10')
+ await user.click(screen.getByText('Log progress'))
+ expect(onLogDebtProgress).toHaveBeenCalledWith('d1', 10)
+ })
+
+ it('renders a cleared debt as read-only history with its reason', () => {
+ const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 50, paidUnits: 50, reason: 'bust' })] }
+ render( )
+ expect(screen.getByText('Pushups: 50 owed (bust) — cleared')).toBeInTheDocument()
+ expect(screen.queryByText('Log progress')).not.toBeInTheDocument()
+ })
+
+ it('labels a cash-out reason distinctly from a bust', () => {
+ const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 50, paidUnits: 50, reason: 'cashOut' })] }
+ render( )
+ expect(screen.getByText('Pushups: 50 owed (cashed out) — cleared')).toBeInTheDocument()
+ })
+
+ // A doubled-up session settles to owedUnits 0, which is already cleared at settle time.
+ it('treats a zero-owed debt as cleared', () => {
+ const debtLoad: DebtLoad = { status: 'ok', debts: [debt({ owedUnits: 0, paidUnits: 0 })] }
+ render( )
+ expect(screen.getByText('Pushups: 0 owed (cashed out) — cleared')).toBeInTheDocument()
+ })
+
+ it('lists debts newest first regardless of stored order', () => {
+ const debtLoad: DebtLoad = {
+ status: 'ok',
+ debts: [
+ debt({ id: 'old', taskTypeId: 'anki', owedUnits: 10, paidUnits: 10, settledAt: 100 }),
+ debt({ id: 'new', taskTypeId: 'leetcode', owedUnits: 20, paidUnits: 20, settledAt: 900 }),
+ ],
+ }
+ render( )
+ const rows = screen.getAllByRole('listitem').map((li) => li.textContent)
+ expect(rows[0]).toContain('LeetCode problems')
+ expect(rows[1]).toContain('Anki reviews')
+ })
+})
diff --git a/poker-stakes/src/ui/DebtHistoryPanel.tsx b/poker-stakes/src/ui/DebtHistoryPanel.tsx
new file mode 100644
index 0000000..ac4ac5f
--- /dev/null
+++ b/poker-stakes/src/ui/DebtHistoryPanel.tsx
@@ -0,0 +1,63 @@
+import type { ReactElement } from 'react'
+import type { DebtLoad } from '../stakes/storage'
+import type { SettledDebt } from '../stakes/types'
+import { OutstandingDebtRow } from './OutstandingDebtRow'
+import { taskLabel } from './taskLabel'
+
+interface DebtHistoryPanelProps {
+ debtLoad: DebtLoad
+ onLogDebtProgress: (debtId: string, units: number) => void
+}
+
+const reasonLabel = (reason: SettledDebt['reason']): string => (reason === 'bust' ? 'bust' : 'cashed out')
+
+/** Cleared debts are read-only history — there is no control to un-pay one. */
+const ClearedDebtRow = ({ debt }: { debt: SettledDebt }): ReactElement => (
+
+
+ {taskLabel(debt.taskTypeId)}: {debt.owedUnits} owed ({reasonLabel(debt.reason)}) — cleared
+
+
+)
+
+/**
+ * The durable ledger, separate from any single session: every `SettledDebt` ever recorded, newest
+ * first. Outstanding entries carry the same log-progress control `BuyInScreen`'s gate shows (the
+ * shared `OutstandingDebtRow`), so progress can be logged from either place with identical rules.
+ */
+export const DebtHistoryPanel = ({ debtLoad, onLogDebtProgress }: DebtHistoryPanelProps): ReactElement => {
+ if (debtLoad.status === 'unreadable') {
+ return (
+
+
Debt history
+
Your debt history could not be read.
+
+ )
+ }
+
+ if (debtLoad.debts.length === 0) {
+ return (
+
+
Debt history
+
No debts settled yet.
+
+ )
+ }
+
+ const newestFirst = [...debtLoad.debts].sort((a, b) => b.settledAt - a.settledAt)
+
+ return (
+
+
Debt history
+
+ {newestFirst.map((debt) =>
+ debt.paidUnits < debt.owedUnits ? (
+
+ ) : (
+
+ ),
+ )}
+
+
+ )
+}
diff --git a/poker-stakes/src/ui/HandLog.test.tsx b/poker-stakes/src/ui/HandLog.test.tsx
new file mode 100644
index 0000000..e263dd8
--- /dev/null
+++ b/poker-stakes/src/ui/HandLog.test.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import type { HandResult } from '../engine/handFlow'
+import { HandLog } from './HandLog'
+
+const result = (overrides: Partial = {}): HandResult => ({
+ winner: 'player',
+ potAwarded: 40,
+ wentToShowdown: true,
+ finalStacks: { player: 240, opponent: 160 },
+ loserSeat: 'opponent',
+ board: [],
+ revealedHoleCards: null,
+ ...overrides,
+})
+
+describe('HandLog', () => {
+ it('renders nothing before any hand has completed', () => {
+ const { container } = render( )
+ expect(container.textContent).toBe('')
+ })
+
+ it('summarizes a player win at showdown', () => {
+ render( )
+ expect(screen.getByText('You won at showdown — 40 chips.')).toBeInTheDocument()
+ })
+
+ it('summarizes an opponent win by fold', () => {
+ render( )
+ expect(screen.getByText('Opponent won by fold — 15 chips.')).toBeInTheDocument()
+ })
+
+ it('summarizes a split pot', () => {
+ render( )
+ expect(screen.getByText('Split pot at showdown — 60 chips divided evenly.')).toBeInTheDocument()
+ })
+})
diff --git a/poker-stakes/src/ui/HandLog.tsx b/poker-stakes/src/ui/HandLog.tsx
new file mode 100644
index 0000000..aaf9a8f
--- /dev/null
+++ b/poker-stakes/src/ui/HandLog.tsx
@@ -0,0 +1,24 @@
+import type { ReactElement } from 'react'
+import type { HandResult } from '../engine/handFlow'
+
+interface HandLogProps {
+ lastResult: HandResult | null
+}
+
+const summarize = (result: HandResult): string => {
+ const outcome = result.wentToShowdown ? 'at showdown' : 'by fold'
+ const potAwarded = String(result.potAwarded)
+ if (result.winner === 'split') {
+ return `Split pot ${outcome} — ${potAwarded} chips divided evenly.`
+ }
+ const winnerLabel = result.winner === 'player' ? 'You won' : 'Opponent won'
+ return `${winnerLabel} ${outcome} — ${potAwarded} chips.`
+}
+
+/** Shows the most recently completed hand's outcome — nothing until the first hand resolves. */
+export const HandLog = ({ lastResult }: HandLogProps): ReactElement | null => {
+ if (lastResult === null) {
+ return null
+ }
+ return {summarize(lastResult)}
+}
diff --git a/poker-stakes/src/ui/OutstandingDebtRow.test.tsx b/poker-stakes/src/ui/OutstandingDebtRow.test.tsx
new file mode 100644
index 0000000..b8d297f
--- /dev/null
+++ b/poker-stakes/src/ui/OutstandingDebtRow.test.tsx
@@ -0,0 +1,64 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import type { SettledDebt } from '../stakes/types'
+import { OutstandingDebtRow } from './OutstandingDebtRow'
+
+const debt = (overrides: Partial = {}): SettledDebt => ({
+ id: 'd1',
+ taskTypeId: 'pushups',
+ owedUnits: 100,
+ reason: 'cashOut',
+ settledAt: 1,
+ paidUnits: 40,
+ ...overrides,
+})
+
+const renderRow = (overrides: Partial = {}): ReturnType => {
+ const onLogDebtProgress = vi.fn()
+ render(
+ ,
+ )
+ return onLogDebtProgress
+}
+
+describe('OutstandingDebtRow', () => {
+ it('shows progress against the owed amount', () => {
+ renderRow()
+ expect(screen.getByText('Pushups: 40 / 100 done')).toBeInTheDocument()
+ })
+
+ it('logs a positive integer of units and clears the field afterwards', async () => {
+ const user = userEvent.setup()
+ const onLogDebtProgress = renderRow()
+ const input = screen.getByLabelText('Log units done for Pushups')
+ await user.type(input, '20')
+ await user.click(screen.getByText('Log progress'))
+ expect(onLogDebtProgress).toHaveBeenCalledWith('d1', 20)
+ expect(input).toHaveValue(null)
+ })
+
+ // Empty is the only non-positive value that reaches the JS guard — native `min={1}` validation
+ // aborts submission for a typed "-5" or "0" before the handler runs.
+ it('does not log when the units field is empty', async () => {
+ const user = userEvent.setup()
+ const onLogDebtProgress = renderRow()
+ await user.click(screen.getByText('Log progress'))
+ expect(onLogDebtProgress).not.toHaveBeenCalled()
+ })
+
+ it('does not log a fractional amount', async () => {
+ const user = userEvent.setup()
+ const onLogDebtProgress = renderRow()
+ await user.type(screen.getByLabelText('Log units done for Pushups'), '2.5')
+ await user.click(screen.getByText('Log progress'))
+ expect(onLogDebtProgress).not.toHaveBeenCalled()
+ })
+
+ it('labels the input with the raw id when the task type is unknown', () => {
+ renderRow({ taskTypeId: 'unknown-type' })
+ expect(screen.getByLabelText('Log units done for unknown-type')).toBeInTheDocument()
+ })
+})
diff --git a/poker-stakes/src/ui/OutstandingDebtRow.tsx b/poker-stakes/src/ui/OutstandingDebtRow.tsx
new file mode 100644
index 0000000..8f4d551
--- /dev/null
+++ b/poker-stakes/src/ui/OutstandingDebtRow.tsx
@@ -0,0 +1,50 @@
+import { useState } from 'react'
+import type { ChangeEvent, ReactElement, SubmitEvent } from 'react'
+import type { SettledDebt } from '../stakes/types'
+import { taskLabel } from './taskLabel'
+
+interface OutstandingDebtRowProps {
+ debt: SettledDebt
+ onLogDebtProgress: (debtId: string, units: number) => void
+}
+
+/**
+ * Shared by `BuyInScreen` (the hard gate) and `DebtHistoryPanel` (the ledger) so the two can never
+ * drift apart — the plan's "log N units done" control is the only way `paidUnits` advances, and
+ * there is deliberately no mark-paid checkbox to short-circuit it.
+ */
+export const OutstandingDebtRow = ({ debt, onLogDebtProgress }: OutstandingDebtRowProps): ReactElement => {
+ const [units, setUnits] = useState('')
+
+ const submit = (event: SubmitEvent): void => {
+ event.preventDefault()
+ const parsed = Number(units)
+ if (!Number.isInteger(parsed) || parsed <= 0) {
+ return
+ }
+ onLogDebtProgress(debt.id, parsed)
+ setUnits('')
+ }
+
+ return (
+
+
+ {taskLabel(debt.taskTypeId)}: {debt.paidUnits} / {debt.owedUnits} done
+
+
+
+ )
+}
diff --git a/poker-stakes/src/ui/Table.test.tsx b/poker-stakes/src/ui/Table.test.tsx
new file mode 100644
index 0000000..c780e87
--- /dev/null
+++ b/poker-stakes/src/ui/Table.test.tsx
@@ -0,0 +1,115 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { startInteractiveHand } from '../engine/interactiveHand'
+import { createRng } from '../engine/rng'
+import type { SessionState } from '../engine/session'
+import type { HandPhase } from './useGame'
+import { Table } from './Table'
+
+const session: SessionState = {
+ stacks: { player: 200, opponent: 200 },
+ buttonSeat: 'player',
+ handsPlayed: 0,
+ outcome: null,
+}
+
+describe('Table', () => {
+ it('shows the player their own hole cards and hides the opponent behind card backs mid-hand', () => {
+ const hand = startInteractiveHand(
+ createRng(1),
+ createRng(1 ^ 0x9e3779b9),
+ { buttonSeat: 'player', bigBlind: 10, smallBlind: 5, stacks: { player: 200, opponent: 200 } },
+ 1,
+ )
+ if (hand.status.kind !== 'awaiting') {
+ throw new Error('expected an awaiting hand for this seed')
+ }
+ const handPhase: HandPhase = { kind: 'awaiting', step: hand.status.step }
+ render()
+ expect(screen.getAllByLabelText(/face-down card/)).toHaveLength(2)
+ // Two own hole cards render as real Card elements, not face-down.
+ const allCards = screen.getAllByLabelText(/ of /)
+ expect(allCards.length).toBeGreaterThanOrEqual(2)
+ })
+
+ it('hides the opponent when betweenHands after a fold (no reveal)', () => {
+ const handPhase: HandPhase = {
+ kind: 'betweenHands',
+ lastResult: {
+ winner: 'opponent',
+ potAwarded: 15,
+ wentToShowdown: false,
+ finalStacks: { player: 190, opponent: 210 },
+ loserSeat: 'player',
+ board: [],
+ revealedHoleCards: null,
+ },
+ }
+ render()
+ expect(screen.queryByLabelText(/face-down card/)).not.toBeInTheDocument()
+ expect(screen.queryByLabelText(/ of /)).not.toBeInTheDocument()
+ })
+
+ it('reveals both hole cards at a showdown result while betweenHands', () => {
+ const handPhase: HandPhase = {
+ kind: 'betweenHands',
+ lastResult: {
+ winner: 'player',
+ potAwarded: 40,
+ wentToShowdown: true,
+ finalStacks: { player: 220, opponent: 180 },
+ loserSeat: 'opponent',
+ board: [
+ { rank: 2, suit: 'clubs' },
+ { rank: 5, suit: 'diamonds' },
+ { rank: 9, suit: 'hearts' },
+ ],
+ revealedHoleCards: {
+ player: [
+ { rank: 14, suit: 'spades' },
+ { rank: 14, suit: 'clubs' },
+ ],
+ opponent: [
+ { rank: 3, suit: 'hearts' },
+ { rank: 4, suit: 'spades' },
+ ],
+ },
+ },
+ }
+ render()
+ // The opponent's revealed hand.
+ expect(screen.getByLabelText('3 of hearts')).toBeInTheDocument()
+ expect(screen.getByLabelText('4 of spades')).toBeInTheDocument()
+ // ...and your OWN hand, which must stay visible after the hand resolves — otherwise a
+ // showdown shows the opponent's cards and the board while your seat sits empty and you
+ // cannot see what you won or lost with. This is what "both" in the test name means.
+ expect(screen.getByLabelText('A of spades')).toBeInTheDocument()
+ expect(screen.getByLabelText('A of clubs')).toBeInTheDocument()
+ // Board cards render too.
+ expect(screen.getByLabelText('9 of hearts')).toBeInTheDocument()
+ })
+
+ it('leaves your seat empty after a fold, since no hand is revealed then', () => {
+ const handPhase: HandPhase = {
+ kind: 'betweenHands',
+ lastResult: {
+ winner: 'opponent',
+ potAwarded: 15,
+ wentToShowdown: false,
+ finalStacks: { player: 190, opponent: 210 },
+ loserSeat: 'player',
+ board: [],
+ revealedHoleCards: null,
+ },
+ }
+ render()
+ expect(screen.queryByLabelText(/ of /)).not.toBeInTheDocument()
+ })
+
+ it('reads stacks and pot=0 from session when handPhase is null (pre-first-hand transient)', () => {
+ render()
+ expect(screen.getByText('Pot: 0')).toBeInTheDocument()
+ expect(screen.getByText('You — 200 chips')).toBeInTheDocument()
+ expect(screen.getByText('Opponent — 200 chips')).toBeInTheDocument()
+ })
+})
diff --git a/poker-stakes/src/ui/Table.tsx b/poker-stakes/src/ui/Table.tsx
new file mode 100644
index 0000000..8907e15
--- /dev/null
+++ b/poker-stakes/src/ui/Table.tsx
@@ -0,0 +1,66 @@
+import type { ReactElement } from 'react'
+import { potFromBetting } from '../engine/handFlow'
+import type { SessionState } from '../engine/session'
+import type { Card as CardModel } from '../engine/types'
+import type { HandPhase } from './useGame'
+import { Card, CardBack } from './Card'
+
+interface TableProps {
+ session: SessionState
+ handPhase: HandPhase | null
+}
+
+/** Stable per-hand key: a hand never contains a duplicate card, so rank+suit uniquely identifies one. */
+const cardKey = (card: CardModel): string => `${String(card.rank)}-${card.suit}`
+
+/**
+ * Reads stacks/pot from the live source of truth for the current moment: `handPhase.step.betting`
+ * mid-hand (which reflects THIS hand's blinds/bets — `session.stacks` is still the pre-hand
+ * snapshot then), and `session.stacks` between hands (pot is 0, no betting state exists).
+ */
+export const Table = ({ session, handPhase }: TableProps): ReactElement => {
+ const isAwaiting = handPhase?.kind === 'awaiting'
+ const betting = isAwaiting ? handPhase.step.betting : null
+ const stacks = betting === null ? session.stacks : { player: betting.seats.player.stack, opponent: betting.seats.opponent.stack }
+ const pot = betting === null ? 0 : potFromBetting(betting)
+ const board = isAwaiting ? handPhase.step.board : (handPhase?.kind === 'betweenHands' ? handPhase.lastResult?.board : undefined) ?? []
+ const revealed = handPhase?.kind === 'betweenHands' ? (handPhase.lastResult?.revealedHoleCards ?? null) : null
+ const revealedOpponentCards = revealed?.opponent ?? null
+ // Your own hand stays visible after the hand resolves, not just while a decision is pending —
+ // otherwise a showdown shows the opponent's cards and the board while your seat sits empty, and
+ // you cannot see what you actually won or lost with.
+ const playerHoleCards = isAwaiting ? handPhase.step.ownHoleCards : (revealed?.player ?? null)
+
+ return (
+
+
+
Opponent — {stacks.opponent} chips
+
+ {revealedOpponentCards !== null
+ ? revealedOpponentCards.map((card) => )
+ : isAwaiting && (
+ <>
+
+
+ >
+ )}
+
+
+
+
Pot: {pot}
+
+
+ {board.map((card) => (
+
+ ))}
+
+
+
+
+ {playerHoleCards?.map((card) => )}
+
+
You — {stacks.player} chips
+
+
+ )
+}
diff --git a/poker-stakes/src/ui/gameState.ts b/poker-stakes/src/ui/gameState.ts
new file mode 100644
index 0000000..a0e6905
--- /dev/null
+++ b/poker-stakes/src/ui/gameState.ts
@@ -0,0 +1,63 @@
+import type { HandResult, HandStep } from '../engine/handFlow'
+import type { SessionState } from '../engine/session'
+import type { DebtLoad } from '../stakes/storage'
+import type { BuyIn, SettledDebt } from '../stakes/types'
+/**
+ * Only meaningful while `session !== null`. `betweenHands` is a real, UI-visible pause point — no
+ * hand is in progress, `session.stacks` is trustworthy, and `cashOut` is only ever legal here. A
+ * hand result stays visible (`lastResult`) until the next hand actually starts, so HandLog has
+ * something to render. This is deliberately NOT auto-advanced past by `submitPlayerAction` — see
+ * `dealHand` below for why cash-out mid-hand would be a settlement exploit if it were.
+ */
+export type HandPhase = { kind: 'awaiting'; step: HandStep } | { kind: 'betweenHands'; lastResult: HandResult | null }
+
+export interface GameState {
+ debtLoad: DebtLoad
+ buyIn: BuyIn | null
+ session: SessionState | null
+ handPhase: HandPhase | null
+}
+
+type GameAction =
+ | { type: 'buyIn'; buyIn: BuyIn; session: SessionState }
+ | { type: 'handPhaseChanged'; session: SessionState; handPhase: HandPhase }
+ | { type: 'cashedOut'; session: SessionState }
+ | { type: 'debtSettled'; debts: SettledDebt[] }
+ | { type: 'debtProgressLogged'; debts: SettledDebt[] }
+
+export const initialGameState: GameState = {
+ debtLoad: { status: 'ok', debts: [] },
+ buyIn: null,
+ session: null,
+ handPhase: null,
+}
+
+/**
+ * Pure state transitions. All randomness/ids/clock reads happen in the hook, never in here.
+ * `GameAction` is exhaustively handled — TS errors on a missing case instead of a silent
+ * fallthrough, which is why there is no `default` branch.
+ */
+export const gameReducer = (state: GameState, action: GameAction): GameState => {
+ switch (action.type) {
+ case 'buyIn':
+ return { ...state, buyIn: action.buyIn, session: action.session, handPhase: null }
+ case 'handPhaseChanged':
+ return { ...state, session: action.session, handPhase: action.handPhase }
+ case 'cashedOut':
+ return { ...state, session: action.session }
+ case 'debtSettled':
+ return {
+ ...state,
+ debtLoad: { status: 'ok', debts: action.debts },
+ buyIn: null,
+ session: null,
+ handPhase: null,
+ }
+ case 'debtProgressLogged':
+ return { ...state, debtLoad: { status: 'ok', debts: action.debts } }
+ }
+}
+
+/** An unpaid debt (owedUnits not yet fully paid) blocks starting a new session — a hard gate. */
+export const hasOutstandingDebt = (debtLoad: DebtLoad): boolean =>
+ debtLoad.status === 'unreadable' || debtLoad.debts.some((d) => d.paidUnits < d.owedUnits)
diff --git a/poker-stakes/src/ui/taskLabel.test.ts b/poker-stakes/src/ui/taskLabel.test.ts
new file mode 100644
index 0000000..120a7ef
--- /dev/null
+++ b/poker-stakes/src/ui/taskLabel.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import { taskLabel } from './taskLabel'
+
+describe('taskLabel', () => {
+ it('maps a known task type id to its label', () => {
+ expect(taskLabel('pushups')).toBe('Pushups')
+ })
+
+ it('falls back to the raw id for an unknown task type', () => {
+ expect(taskLabel('unknown-type')).toBe('unknown-type')
+ })
+})
diff --git a/poker-stakes/src/ui/taskLabel.ts b/poker-stakes/src/ui/taskLabel.ts
new file mode 100644
index 0000000..d4319ce
--- /dev/null
+++ b/poker-stakes/src/ui/taskLabel.ts
@@ -0,0 +1,5 @@
+import { TASK_TYPES } from '../stakes/taskTypes'
+
+/** Falls back to the raw id so a debt recorded under a since-removed task type still renders. */
+export const taskLabel = (taskTypeId: string): string =>
+ TASK_TYPES.find((t) => t.id === taskTypeId)?.label ?? taskTypeId
diff --git a/poker-stakes/src/ui/useGame.debt.test.tsx b/poker-stakes/src/ui/useGame.debt.test.tsx
new file mode 100644
index 0000000..6fa4b7a
--- /dev/null
+++ b/poker-stakes/src/ui/useGame.debt.test.tsx
@@ -0,0 +1,156 @@
+import { act, renderHook } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { hasOutstandingDebt, useGame } from './useGame'
+import { makeStorage, wrapper } from '../test/useGameFixtures'
+
+describe('useGame: debt settlement and persistence', () => {
+ it('a zero-player-decision hand that the all-in player survives lands in betweenHands, not settled', () => {
+ // Seed 1, 4-chip buy-in, always folding when a decision exists: by hand 3 (handsPlayed=2) the
+ // player is down to 1 chip and posts it as the small blind — all-in, zero decision points.
+ // The all-in player wins/ties that hand and ends above 0, so the session must survive into
+ // `betweenHands` rather than settling — the sibling branch to the bust case above.
+ const storage = makeStorage()
+ const { result } = renderHook(() => useGame(storage, 1), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 4 })
+ })
+ let guard = 0
+ while (result.current.state.session !== null && result.current.state.session.handsPlayed < 3 && guard < 10) {
+ const phase = result.current.state.handPhase
+ if (phase?.kind === 'betweenHands') {
+ act(() => {
+ result.current.dealHand()
+ })
+ } else if (phase?.kind === 'awaiting') {
+ act(() => {
+ result.current.submitPlayerAction({ type: 'fold' })
+ })
+ } else {
+ break
+ }
+ guard += 1
+ }
+ expect(guard).toBeLessThan(10)
+ expect(result.current.state.session).not.toBeNull()
+ expect(result.current.state.session?.handsPlayed).toBe(3)
+ expect(result.current.state.handPhase?.kind).toBe('betweenHands')
+ expect(result.current.state.session?.stacks.player).toBeGreaterThan(0)
+ })
+
+ it('logDebtProgress with an unknown debt id leaves the debt list unchanged', () => {
+ const storage = makeStorage()
+ const { result } = renderHook(() => useGame(storage, 1), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
+ })
+ act(() => {
+ result.current.submitPlayerAction({ type: 'fold' })
+ })
+ act(() => {
+ result.current.cashOut()
+ })
+ const before = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
+ act(() => {
+ result.current.logDebtProgress('no-such-id', 10)
+ })
+ const after = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
+ expect(after).toEqual(before)
+ })
+
+ it('cashing out between hands (never played a hand to showdown) settles a debt equal to the full buy-in', () => {
+ const storage = makeStorage()
+ const { result } = renderHook(() => useGame(storage, 1), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
+ })
+ act(() => {
+ result.current.submitPlayerAction({ type: 'fold' })
+ })
+ expect(result.current.state.handPhase?.kind).toBe('betweenHands')
+ act(() => {
+ result.current.cashOut()
+ })
+ expect(result.current.state.session).toBeNull()
+ expect(result.current.state.debtLoad.status).toBe('ok')
+ const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
+ expect(debts).toHaveLength(1)
+ expect(debts[0]?.reason).toBe('cashOut')
+ })
+
+ it('an unpaid debt persists to storage and blocks a subsequent load', () => {
+ const storage = makeStorage()
+ const { result } = renderHook(() => useGame(storage, 1), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
+ })
+ act(() => {
+ result.current.submitPlayerAction({ type: 'fold' })
+ })
+ act(() => {
+ result.current.cashOut()
+ })
+ const { result: second } = renderHook(() => useGame(storage, 2), { wrapper })
+ expect(hasOutstandingDebt(second.current.state.debtLoad)).toBe(true)
+ })
+
+ it('logDebtProgress clamps paidUnits to owedUnits and persists', () => {
+ const storage = makeStorage()
+ const { result } = renderHook(() => useGame(storage, 1), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
+ })
+ act(() => {
+ result.current.submitPlayerAction({ type: 'fold' })
+ })
+ act(() => {
+ result.current.cashOut()
+ })
+ const debtId = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts[0]?.id : undefined
+ if (debtId === undefined) {
+ throw new Error('expected a settled debt')
+ }
+ act(() => {
+ result.current.logDebtProgress(debtId, 500)
+ })
+ const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
+ expect(debts[0]?.paidUnits).toBe(debts[0]?.owedUnits)
+
+ const { result: reloaded } = renderHook(() => useGame(storage, 3), { wrapper })
+ expect(hasOutstandingDebt(reloaded.current.state.debtLoad)).toBe(false)
+ })
+
+ it('bust ends the session with a debt of exactly 2×buyIn', () => {
+ const storage = makeStorage()
+ // Seed 4 at a 20-chip buy-in, always checking/calling as the player, deterministically busts
+ // the player within a handful of hands (found by probing seeds 1-10; every other seed either
+ // busts with the same owedUnits=40 result or ends in the opponent-bust/cashOut branch covered
+ // above). Deals explicitly via dealHand() between hands, matching the new no-auto-continue API.
+ const { result } = renderHook(() => useGame(storage, 4), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 20 })
+ })
+ let guard = 0
+ while (result.current.state.session !== null && guard < 20) {
+ const phase = result.current.state.handPhase
+ if (phase?.kind === 'betweenHands') {
+ act(() => {
+ result.current.dealHand()
+ })
+ } else if (phase?.kind === 'awaiting') {
+ const { betting } = phase.step
+ const toCall = betting.betToCall - betting.seats.player.committed
+ act(() => {
+ result.current.submitPlayerAction(toCall > 0 ? { type: 'call' } : { type: 'check' })
+ })
+ } else {
+ break
+ }
+ guard += 1
+ }
+ expect(guard).toBeLessThan(20)
+ expect(result.current.state.session).toBeNull()
+ const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
+ expect(debts).toHaveLength(1)
+ expect(debts[0]).toMatchObject({ owedUnits: 40, reason: 'bust' })
+ })
+})
diff --git a/poker-stakes/src/ui/useGame.reducer.test.tsx b/poker-stakes/src/ui/useGame.reducer.test.tsx
new file mode 100644
index 0000000..4f49806
--- /dev/null
+++ b/poker-stakes/src/ui/useGame.reducer.test.tsx
@@ -0,0 +1,75 @@
+import { describe, expect, it } from 'vitest'
+import { gameReducer, hasOutstandingDebt } from './useGame'
+import type { GameState } from './useGame'
+import { debt, initialState } from '../test/useGameFixtures'
+
+describe('hasOutstandingDebt', () => {
+ it('blocks when storage was unreadable', () => {
+ expect(hasOutstandingDebt({ status: 'unreadable' })).toBe(true)
+ })
+
+ it('does not block an empty debt list', () => {
+ expect(hasOutstandingDebt({ status: 'ok', debts: [] })).toBe(false)
+ })
+
+ it('does not block a fully paid debt', () => {
+ expect(hasOutstandingDebt({ status: 'ok', debts: [debt({ owedUnits: 50, paidUnits: 50 })] })).toBe(false)
+ })
+
+ it('does not block a debt that owes zero units (doubled-up cash-out)', () => {
+ expect(hasOutstandingDebt({ status: 'ok', debts: [debt({ owedUnits: 0, paidUnits: 0 })] })).toBe(false)
+ })
+
+ it('blocks a partially paid debt', () => {
+ expect(hasOutstandingDebt({ status: 'ok', debts: [debt({ owedUnits: 100, paidUnits: 40 })] })).toBe(true)
+ })
+})
+
+describe('gameReducer', () => {
+ it('buyIn sets buyIn/session and clears handPhase', () => {
+ const session: GameState['session'] = {
+ stacks: { player: 100, opponent: 100 },
+ buttonSeat: 'player',
+ handsPlayed: 0,
+ outcome: null,
+ }
+ const next = gameReducer(initialState, {
+ type: 'buyIn',
+ buyIn: { taskTypeId: 'pushups', chips: 100 },
+ session,
+ })
+ expect(next).toEqual({
+ debtLoad: { status: 'ok', debts: [] },
+ buyIn: { taskTypeId: 'pushups', chips: 100 },
+ session,
+ handPhase: null,
+ })
+ })
+
+ it('debtSettled clears the live session and resets to the buy-in gate', () => {
+ const mid: GameState = {
+ ...initialState,
+ buyIn: { taskTypeId: 'pushups', chips: 100 },
+ session: {
+ stacks: { player: 0, opponent: 200 },
+ buttonSeat: 'player',
+ handsPlayed: 3,
+ outcome: { reason: 'bust', finalChips: 0 },
+ },
+ handPhase: { kind: 'betweenHands', lastResult: null },
+ }
+ const settled = [debt({ owedUnits: 200 })]
+ const next = gameReducer(mid, { type: 'debtSettled', debts: settled })
+ expect(next).toEqual({
+ debtLoad: { status: 'ok', debts: settled },
+ buyIn: null,
+ session: null,
+ handPhase: null,
+ })
+ })
+
+ it('debtProgressLogged updates debtLoad only', () => {
+ const next = gameReducer(initialState, { type: 'debtProgressLogged', debts: [debt({ paidUnits: 20 })] })
+ expect(next.debtLoad).toEqual({ status: 'ok', debts: [debt({ paidUnits: 20 })] })
+ })
+})
diff --git a/poker-stakes/src/ui/useGame.session.test.tsx b/poker-stakes/src/ui/useGame.session.test.tsx
new file mode 100644
index 0000000..313d067
--- /dev/null
+++ b/poker-stakes/src/ui/useGame.session.test.tsx
@@ -0,0 +1,219 @@
+import { act, renderHook } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { hasOutstandingDebt, useGame } from './useGame'
+import { makeStorage, unreadableStorage, wrapper } from '../test/useGameFixtures'
+
+describe('useGame', () => {
+ it('starts with no debt and no session, blocked=false', () => {
+ const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper })
+ expect(hasOutstandingDebt(result.current.state.debtLoad)).toBe(false)
+ expect(result.current.state.session).toBeNull()
+ })
+
+ it('starts blocked when storage is unreadable, fail-closed', () => {
+ const { result } = renderHook(() => useGame(unreadableStorage(), 1), { wrapper })
+ expect(result.current.state.debtLoad).toEqual({ status: 'unreadable' })
+ expect(hasOutstandingDebt(result.current.state.debtLoad)).toBe(true)
+ })
+
+ it('buyIn is a no-op when storage is unreadable — the hard gate itself blocks, not just the UI', () => {
+ // Regression: an earlier version derived the gate's debt list via a helper that collapsed
+ // `{status: 'unreadable'}` to `[]`, which made `hasOutstandingDebt` see an empty (non-blocking)
+ // list and let a session start despite fail-closed storage. The hook's `buyIn` must itself
+ // refuse, not rely on a UI component to have already checked `state.debtLoad`.
+ const { result } = renderHook(() => useGame(unreadableStorage(), 1), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
+ })
+ expect(result.current.state.session).toBeNull()
+ })
+
+ it('buy-in starts a session and pauses on the player seat (StrictMode double-invoke safe)', () => {
+ const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
+ })
+ expect(result.current.state.session).not.toBeNull()
+ expect(result.current.state.session?.handsPlayed).toBe(0)
+ expect(result.current.state.handPhase?.kind).toBe('awaiting')
+ // If StrictMode's double-invoke perturbed the generator, stacks would already be inconsistent.
+ const stacks = result.current.state.session?.stacks
+ expect(stacks?.player).toBeGreaterThan(0)
+ expect(stacks?.opponent).toBeGreaterThan(0)
+ })
+
+ it('buyIn is a no-op while a debt is outstanding (hard gate, not just BuyInScreen UI)', () => {
+ const storage = makeStorage()
+ const { result } = renderHook(() => useGame(storage, 1), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
+ })
+ // Cash out immediately: `cashOut` requires `betweenHands`, but hand 1 just started as
+ // `awaiting`. Fold to reach `betweenHands` deterministically without depending on a specific
+ // showdown outcome.
+ act(() => {
+ result.current.submitPlayerAction({ type: 'fold' })
+ })
+ expect(result.current.state.handPhase?.kind).toBe('betweenHands')
+ act(() => {
+ result.current.cashOut()
+ })
+ expect(result.current.state.session).toBeNull()
+ const debtsBefore = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
+ expect(debtsBefore).toHaveLength(1)
+
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 50 })
+ })
+ expect(result.current.state.session).toBeNull()
+ const debtsAfter = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
+ expect(debtsAfter).toHaveLength(1)
+ })
+
+ it('buyIn rejects a non-positive or fractional chip count (chips are integers throughout)', () => {
+ const storage = makeStorage()
+ const { result } = renderHook(() => useGame(storage, 1), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 0 })
+ })
+ expect(result.current.state.session).toBeNull()
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: -5 })
+ })
+ expect(result.current.state.session).toBeNull()
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 2.5 })
+ })
+ expect(result.current.state.session).toBeNull()
+ })
+
+ it('submitting a second action right after a hand resolves into betweenHands does not throw', () => {
+ // Regression: handRef used to only be cleared on settle, not on the betweenHands path, so a
+ // late/duplicate submitPlayerAction call after a hand resolved without ending the session hit
+ // `hand.submit()` on an already-complete InteractiveHand and threw out of the event handler.
+ const storage = makeStorage()
+ const { result } = renderHook(() => useGame(storage, 1), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
+ })
+ act(() => {
+ result.current.submitPlayerAction({ type: 'fold' })
+ })
+ expect(result.current.state.handPhase?.kind).toBe('betweenHands')
+ expect(() => {
+ act(() => {
+ result.current.submitPlayerAction({ type: 'fold' })
+ })
+ }).not.toThrow()
+ // Still safely between hands — the stale submit was a no-op, not a state corruption.
+ expect(result.current.state.handPhase?.kind).toBe('betweenHands')
+ })
+
+ it('cashOut is a no-op when no session is live', () => {
+ const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper })
+ act(() => {
+ result.current.cashOut()
+ })
+ expect(result.current.state.session).toBeNull()
+ expect(result.current.state.debtLoad).toEqual({ status: 'ok', debts: [] })
+ })
+
+ it('cashOut is a no-op mid-hand (only legal between hands, per the settlement-exploit fix)', () => {
+ // Buying in leaves a live hand `awaiting` a player decision. Cashing out here must be a
+ // no-op: `session.stacks` predates this hand's blinds/bets, so settling against it would let
+ // a player see the deal, cash out at the pre-hand stack, and have posted chips vanish.
+ const storage = makeStorage()
+ const { result } = renderHook(() => useGame(storage, 1), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
+ })
+ expect(result.current.state.handPhase?.kind).toBe('awaiting')
+ act(() => {
+ result.current.cashOut()
+ })
+ expect(result.current.state.session).not.toBeNull()
+ expect(result.current.state.debtLoad).toEqual({ status: 'ok', debts: [] })
+ })
+
+ it('a hand that reaches showdown lands in betweenHands, not auto-dealing the next one', () => {
+ // Seed 1 at a 200-chip buy-in, always checking/calling: 4 submits carry the first hand to
+ // showdown. The session must land in `betweenHands` (handsPlayed incremented, no live hand)
+ // rather than silently starting hand 2 — that pause is what makes cashOut safe.
+ const storage = makeStorage()
+ const { result } = renderHook(() => useGame(storage, 1), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
+ })
+ for (let i = 0; i < 4; i += 1) {
+ const phase = result.current.state.handPhase
+ if (phase?.kind !== 'awaiting') {
+ break
+ }
+ const { betting } = phase.step
+ const toCall = betting.betToCall - betting.seats.player.committed
+ act(() => {
+ result.current.submitPlayerAction(toCall > 0 ? { type: 'call' } : { type: 'check' })
+ })
+ }
+ expect(result.current.state.session?.handsPlayed).toBe(1)
+ expect(result.current.state.session?.outcome).toBeNull()
+ const phase = result.current.state.handPhase
+ expect(phase).toMatchObject({ kind: 'betweenHands' })
+ expect(phase?.kind === 'betweenHands' ? phase.lastResult : undefined).not.toBeNull()
+ })
+
+ it('dealHand starts the next hand after betweenHands', () => {
+ const storage = makeStorage()
+ const { result } = renderHook(() => useGame(storage, 1), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 200 })
+ })
+ act(() => {
+ result.current.submitPlayerAction({ type: 'fold' })
+ })
+ expect(result.current.state.handPhase?.kind).toBe('betweenHands')
+ act(() => {
+ result.current.dealHand()
+ })
+ expect(result.current.state.handPhase?.kind).toBe('awaiting')
+ expect(result.current.state.session?.handsPlayed).toBe(1)
+ })
+
+ it('dealHand is a no-op before any buy-in', () => {
+ const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper })
+ act(() => {
+ result.current.dealHand()
+ })
+ expect(result.current.state.session).toBeNull()
+ expect(result.current.state.handPhase).toBeNull()
+ })
+
+ it('submitPlayerAction is a no-op before any buy-in (no live hand to advance)', () => {
+ const { result } = renderHook(() => useGame(makeStorage(), 1), { wrapper })
+ act(() => {
+ result.current.submitPlayerAction({ type: 'fold' })
+ })
+ expect(result.current.state.session).toBeNull()
+ expect(result.current.state.handPhase).toBeNull()
+ })
+
+ it('a zero-player-decision hand (all-in from the blind post) settles instead of soft-locking', () => {
+ // Buy-in of 1 chip: deriveBlindSize floors BB at 2, SB at 1 — the button posts their entire
+ // 1-chip stack as the small blind, an immediate all-in with zero player decision points.
+ // `dealHand` must fold that hand's result into the session and settle rather than leaving a
+ // live, un-settled session with no pending decision (the soft-lock this session's advisor
+ // review caught: `initialBettingState` used to reset `allIn: false` on every street).
+ // Seed 1 deterministically busts the player on this exact first hand.
+ const storage = makeStorage()
+ const { result } = renderHook(() => useGame(storage, 1), { wrapper })
+ act(() => {
+ result.current.buyIn({ taskTypeId: 'pushups', chips: 1 })
+ })
+ expect(result.current.state.session).toBeNull()
+ expect(result.current.state.handPhase).toBeNull()
+ const debts = result.current.state.debtLoad.status === 'ok' ? result.current.state.debtLoad.debts : []
+ expect(debts).toHaveLength(1)
+ expect(debts[0]).toMatchObject({ owedUnits: 2, reason: 'bust' })
+ })
+
+})
diff --git a/poker-stakes/src/ui/useGame.ts b/poker-stakes/src/ui/useGame.ts
new file mode 100644
index 0000000..56532f1
--- /dev/null
+++ b/poker-stakes/src/ui/useGame.ts
@@ -0,0 +1,212 @@
+import { useCallback, useReducer, useRef } from 'react'
+import type { Action as PokerAction } from '../engine/bettingRound'
+import type { InteractiveHand } from '../engine/interactiveHand'
+import { startInteractiveHand } from '../engine/interactiveHand'
+import { createRng } from '../engine/rng'
+import type { Rng } from '../engine/rng'
+import { applyHandResult, cashOutSession, deriveBlindSize, startSession } from '../engine/session'
+import type { SessionState } from '../engine/session'
+import { settleSession } from '../stakes/settlement'
+import { loadDebts, saveDebts } from '../stakes/storage'
+import type { DebtLoad } from '../stakes/storage'
+import type { BuyIn, SettledDebt } from '../stakes/types'
+
+/** XORed into the deal seed for the opponent's independent policy RNG, per the spec's seam. */
+const POLICY_SEED_SALT = 0x9e3779b9
+
+const OPPONENT_ITERATIONS = 200
+import { gameReducer, hasOutstandingDebt, initialGameState } from './gameState'
+import type { GameState } from './gameState'
+
+export type { GameState, HandPhase } from './gameState'
+export { gameReducer, hasOutstandingDebt } from './gameState'
+
+export interface UseGameResult {
+ state: GameState
+ buyIn: (buyIn: BuyIn) => void
+ dealHand: () => void
+ submitPlayerAction: (action: PokerAction) => void
+ cashOut: () => void
+ logDebtProgress: (debtId: string, units: number) => void
+}
+
+/** Computed once via lazy init on both `useReducer` and `useRef` below — pure given `storage`. */
+const readInitialDebts = (debtLoad: DebtLoad): SettledDebt[] => (debtLoad.status === 'ok' ? debtLoad.debts : [])
+
+export const useGame = (storage: Storage, dealSeed: number): UseGameResult => {
+ const [state, dispatch] = useReducer(gameReducer, initialGameState, (): GameState => ({
+ ...initialGameState,
+ debtLoad: loadDebts(storage),
+ }))
+
+ const handRef = useRef(null)
+ const dealRngRef = useRef(createRng(dealSeed))
+ const policyRngRef = useRef(createRng(dealSeed ^ POLICY_SEED_SALT))
+ const sessionRef = useRef(null)
+ const buyInRef = useRef(null)
+ // Authoritative debt list, independent of render state, so an unreadable-storage snapshot in
+ // `state.debtLoad` can never cause a settle to silently drop existing debts (fail-open bug).
+ // `useRef`'s initial-value argument is evaluated on every render (only the first is kept), so
+ // this and `storageUnreadableRef` below both see the same `loadDebts(storage)` read `state`'s
+ // own lazy initializer used — no separate read, no risk of the two disagreeing.
+ const debtsRef = useRef(readInitialDebts(loadDebts(storage)))
+ // Sticky: unreadable at load blocks buyIn for the lifetime of this hook instance, even though
+ // `debtsRef` itself only ever holds `SettledDebt[]` (never a nullable/unreadable marker) — this
+ // is what lets `buyIn`'s gate see 'unreadable' without a helper that collapses it to `[]` first
+ // (the exact bug this session's review caught: silently unblocking the gate).
+ const storageUnreadableRef = useRef(loadDebts(storage).status !== 'ok')
+
+ const persistDebts = useCallback(
+ (debts: SettledDebt[]): SettledDebt[] => {
+ debtsRef.current = debts
+ saveDebts(storage, debts)
+ return debts
+ },
+ [storage],
+ )
+
+ const settleAndPersist = useCallback(
+ // Caller (dealHand / cashOut) guarantees `session.outcome` is non-null and `buyInSnapshot` is
+ // the live buy-in; not re-guarded here.
+ (session: SessionState & { outcome: NonNullable }, buyInSnapshot: BuyIn): void => {
+ const debt = settleSession(buyInSnapshot, session.outcome, crypto.randomUUID(), Date.now())
+ const nextDebts = persistDebts([...debtsRef.current, debt])
+ handRef.current = null
+ buyInRef.current = null
+ sessionRef.current = null
+ dispatch({ type: 'debtSettled', debts: nextDebts })
+ },
+ [persistDebts],
+ )
+
+ /**
+ * Deals one hand. In the rare case a seat is already all-in from posting a blind (stack ≤
+ * blind size), the hand can complete with ZERO player decision points — `startInteractiveHand`
+ * resolves every opponent turn internally and returns already `complete`. That result is folded
+ * into the session immediately (not left for a UI round-trip that would never come), and either
+ * settles the session or lands in `betweenHands` so the caller can deal again.
+ */
+ const dealHand = useCallback((): void => {
+ const session = sessionRef.current
+ const buyInSnapshot = buyInRef.current
+ // Reachable only if called with no live session (before any buy-in, or after settlement) —
+ // the UI only exposes this action while `handPhase.kind === 'betweenHands'`.
+ if (session === null || buyInSnapshot === null) {
+ return
+ }
+ const { bigBlind, smallBlind } = deriveBlindSize(buyInSnapshot.chips)
+ const hand = startInteractiveHand(
+ dealRngRef.current,
+ policyRngRef.current,
+ { buttonSeat: session.buttonSeat, bigBlind, smallBlind, stacks: session.stacks },
+ OPPONENT_ITERATIONS,
+ )
+ const status = hand.status
+ if (status.kind === 'awaiting') {
+ handRef.current = hand
+ dispatch({ type: 'handPhaseChanged', session, handPhase: { kind: 'awaiting', step: status.step } })
+ return
+ }
+ const nextSession = applyHandResult(session, status.result)
+ sessionRef.current = nextSession
+ if (nextSession.outcome !== null) {
+ settleAndPersist({ ...nextSession, outcome: nextSession.outcome }, buyInSnapshot)
+ return
+ }
+ dispatch({
+ type: 'handPhaseChanged',
+ session: nextSession,
+ handPhase: { kind: 'betweenHands', lastResult: status.result },
+ })
+ }, [settleAndPersist])
+
+ const buyIn = useCallback(
+ (nextBuyIn: BuyIn): void => {
+ // Hard gate: an outstanding debt blocks starting a new session, per the confirmed stakes
+ // model — never just a UI-level warning. Chips are integers throughout (per the plan), so a
+ // non-positive or fractional buy-in is rejected here too, not just via an input `min`/`step`.
+ if (
+ storageUnreadableRef.current ||
+ hasOutstandingDebt({ status: 'ok', debts: debtsRef.current }) ||
+ !Number.isInteger(nextBuyIn.chips) ||
+ nextBuyIn.chips <= 0
+ ) {
+ return
+ }
+ const session = startSession({ buyInChips: nextBuyIn.chips })
+ buyInRef.current = nextBuyIn
+ sessionRef.current = session
+ dispatch({ type: 'buyIn', buyIn: nextBuyIn, session })
+ dealHand()
+ },
+ [dealHand],
+ )
+
+ const submitPlayerAction = useCallback(
+ (action: PokerAction): void => {
+ const hand = handRef.current
+ const session = sessionRef.current
+ const buyInSnapshot = buyInRef.current
+ // Reachable only via a submit before any buy-in — `hand.submit` itself throws if no
+ // decision is pending, which per this session's review is otherwise unreachable: `dealHand`
+ // never leaves `handRef` holding an already-`complete` hand.
+ if (hand === null || session === null || buyInSnapshot === null) {
+ return
+ }
+ hand.submit(action)
+ const status = hand.status
+ if (status.kind === 'awaiting') {
+ dispatch({ type: 'handPhaseChanged', session, handPhase: { kind: 'awaiting', step: status.step } })
+ return
+ }
+ // Hand resolved. Land in `betweenHands` — do NOT auto-deal the next hand: `session.stacks`
+ // must reflect a just-completed hand so `cashOut` (only legal in `betweenHands`) settles
+ // against real chips, and mid-hand cash-out would let a player see a bad flop, cash out at
+ // the PRE-hand stack, and have the posted blinds/bets vanish with the discarded generator.
+ handRef.current = null
+ const nextSession = applyHandResult(session, status.result)
+ sessionRef.current = nextSession
+ if (nextSession.outcome !== null) {
+ settleAndPersist({ ...nextSession, outcome: nextSession.outcome }, buyInSnapshot)
+ return
+ }
+ dispatch({
+ type: 'handPhaseChanged',
+ session: nextSession,
+ handPhase: { kind: 'betweenHands', lastResult: status.result },
+ })
+ },
+ [settleAndPersist],
+ )
+
+ const cashOut = useCallback((): void => {
+ const session = sessionRef.current
+ const buyInSnapshot = buyInRef.current
+ // Only legal between hands: `handRef.current !== null` means a hand is in progress, and
+ // mid-hand `session.stacks` predates that hand's blinds/bets — cashing out then would settle
+ // against a stale stack and let a player see the deal before deciding to cash out. handRef is
+ // read (a synchronous ref) rather than `state.handPhase` (React state) because this guard is
+ // security-critical: it must reflect what actually happened, not what has rendered yet.
+ if (session?.outcome !== null || buyInSnapshot === null || handRef.current !== null) {
+ return
+ }
+ const nextSession = cashOutSession(session)
+ sessionRef.current = nextSession
+ dispatch({ type: 'cashedOut', session: nextSession })
+ // cashOutSession always sets outcome, so this cast just restates that fact for the type.
+ settleAndPersist(nextSession as SessionState & { outcome: NonNullable }, buyInSnapshot)
+ }, [settleAndPersist])
+
+ const logDebtProgress = useCallback(
+ (debtId: string, units: number): void => {
+ const nextDebts = debtsRef.current.map((d) =>
+ d.id === debtId ? { ...d, paidUnits: Math.min(d.owedUnits, d.paidUnits + units) } : d,
+ )
+ persistDebts(nextDebts)
+ dispatch({ type: 'debtProgressLogged', debts: nextDebts })
+ },
+ [persistDebts],
+ )
+
+ return { state, buyIn, dealHand, submitPlayerAction, cashOut, logDebtProgress }
+}
diff --git a/poker-stakes/tsconfig.json b/poker-stakes/tsconfig.json
new file mode 100644
index 0000000..7bf5ff4
--- /dev/null
+++ b/poker-stakes/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2023",
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "noImplicitOverride": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "useDefineForClassFields": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/poker-stakes/vite.config.ts b/poker-stakes/vite.config.ts
new file mode 100644
index 0000000..f2279be
--- /dev/null
+++ b/poker-stakes/vite.config.ts
@@ -0,0 +1,18 @@
+///
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./src/test/setup.ts'],
+ coverage: {
+ provider: 'v8',
+ include: ['src/**/*.{ts,tsx}'],
+ exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**'],
+ thresholds: { lines: 100, functions: 100, branches: 100, statements: 100 },
+ reporter: ['text', 'json-summary'],
+ },
+ },
+})