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 ( +
+
+

Poker Stakes

+

Lose the hand, pay the price — in pushups, problems or reviews.

+
+ {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' && ( + <> + + + + )} + + + )} + + + ) +} 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) => ( + + ))} +
+ ) +} 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 ( +
+
+ + +
+
+ + ) => { + setChips(event.target.value) + }} + /> +
+ + + ) +} 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. +

+ +
+ ) +} 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 + +
    + ) => { + setUnits(event.target.value) + }} + aria-label={`Log units done for ${taskLabel(debt.taskTypeId)}`} + /> + + +
  • + ) +} 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'], + }, + }, +})