diff --git a/package-lock.json b/package-lock.json index c590151..5afcfd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,10 @@ "@tailwindcss/vite": "^4.0.0", "astro": "^4.16.0", "tailwindcss": "^4.0.0" + }, + "devDependencies": { + "@vitest/coverage-v8": "^4.1.5", + "vitest": "^4.1.5" } }, "node_modules/@astrojs/compiler": { @@ -363,6 +367,16 @@ "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/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -831,7 +845,6 @@ "version": "0.128.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", - "peer": true, "funding": { "url": "https://github.com/sponsors/Boshen" } @@ -847,7 +860,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -863,7 +875,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -879,7 +890,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -895,7 +905,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -911,7 +920,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -927,7 +935,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -943,7 +950,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -959,7 +965,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -975,7 +980,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -991,7 +995,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1007,7 +1010,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1023,7 +1025,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1036,7 +1037,6 @@ "wasm32" ], "optional": true, - "peer": true, "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", @@ -1057,7 +1057,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1073,7 +1072,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1081,8 +1079,7 @@ "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", - "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", - "peer": true + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==" }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", @@ -1472,6 +1469,13 @@ "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==" }, + "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/@tailwindcss/node": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", @@ -1760,6 +1764,17 @@ "@babel/types": "^7.28.2" } }, + "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/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -1773,6 +1788,13 @@ "@types/ms": "*" } }, + "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/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1835,6 +1857,162 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==" }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "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.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "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.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1941,6 +2119,35 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "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/astro": { "version": "4.16.19", "resolved": "https://registry.npmjs.org/astro/-/astro-4.16.19.tgz", @@ -2597,6 +2804,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -2952,6 +3169,16 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -2995,7 +3222,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "peer": true, "engines": { "node": ">=12.0.0" }, @@ -3146,6 +3372,16 @@ "js-yaml": "bin/js-yaml.js" } }, + "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/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -3468,6 +3704,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/istanbul-reports/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/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3873,6 +4155,22 @@ "source-map-js": "^1.2.0" } }, + "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/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -4726,6 +5024,17 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -4881,6 +5190,13 @@ "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", @@ -5252,7 +5568,6 @@ "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", - "peer": true, "dependencies": { "@oxc-project/types": "=0.128.0", "@rolldown/pluginutils": "1.0.0-rc.18" @@ -5437,6 +5752,13 @@ "@types/hast": "^3.0.4" } }, + "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/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -5509,6 +5831,20 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, + "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.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -5585,6 +5921,19 @@ "node": ">=0.10.0" } }, + "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/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -5602,6 +5951,13 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -5611,7 +5967,6 @@ "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "peer": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" @@ -5623,6 +5978,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5911,7 +6276,6 @@ "version": "8.0.11", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -5997,6 +6361,113 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "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.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "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/vitest/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -6025,6 +6496,23 @@ "node": ">=4" } }, + "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/widest-line": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", diff --git a/package.json b/package.json index 8414a65..c1293fb 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,19 @@ "scripts": { "dev": "astro dev", "build": "astro build", - "preview": "astro preview" + "preview": "astro preview", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@astrojs/sitemap": "^3.2.1", "@tailwindcss/vite": "^4.0.0", "astro": "^4.16.0", "tailwindcss": "^4.0.0" + }, + "devDependencies": { + "@vitest/coverage-v8": "^4.1.5", + "vitest": "^4.1.5" } } diff --git a/src/lib/dice-engine.test.ts b/src/lib/dice-engine.test.ts new file mode 100644 index 0000000..d78a886 --- /dev/null +++ b/src/lib/dice-engine.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + parseDiceNotation, + rollDice, + rollAdvantage, + buildNotation, + isAdvancedNotation, +} from "./dice-engine"; + +describe("parseDiceNotation", () => { + it('parses basic notation "d6"', () => { + const parsed = parseDiceNotation("d6"); + expect(parsed).not.toBeNull(); + expect(parsed!.count).toBe(1); + expect(parsed!.sides).toBe(6); + expect(parsed!.modifier).toBe(0); + expect(parsed!.keepDrop).toBeNull(); + expect(parsed!.explode.active).toBe(false); + expect(parsed!.reroll.active).toBe(false); + }); + + it('parses "2d20+3"', () => { + const parsed = parseDiceNotation("2d20+3"); + expect(parsed).not.toBeNull(); + expect(parsed!.count).toBe(2); + expect(parsed!.sides).toBe(20); + expect(parsed!.modifier).toBe(3); + }); + + it('parses "3d8-1"', () => { + const parsed = parseDiceNotation("3d8-1"); + expect(parsed!.count).toBe(3); + expect(parsed!.sides).toBe(8); + expect(parsed!.modifier).toBe(-1); + }); + + it('parses "4d6kh3"', () => { + const parsed = parseDiceNotation("4d6kh3"); + expect(parsed!.keepDrop).toEqual({ type: "kh", count: 3 }); + }); + + it('parses "5d6dl2"', () => { + const parsed = parseDiceNotation("5d6dl2"); + expect(parsed!.keepDrop).toEqual({ type: "dl", count: 2 }); + }); + + it('parses exploding "3d6!"', () => { + const parsed = parseDiceNotation("3d6!"); + expect(parsed!.explode.active).toBe(true); + expect(parsed!.explode.penetrating).toBe(false); + expect(parsed!.explode.threshold).toBe(6); + }); + + it('parses penetrating explode "2d10!p"', () => { + const parsed = parseDiceNotation("2d10!p"); + expect(parsed!.explode.active).toBe(true); + expect(parsed!.explode.penetrating).toBe(true); + }); + + it('parses explode with custom threshold "2d6!>4"', () => { + const parsed = parseDiceNotation("2d6!>4"); + expect(parsed!.explode.active).toBe(true); + expect(parsed!.explode.threshold).toBe(4); + }); + + it('parses reroll "2d6r1"', () => { + const parsed = parseDiceNotation("2d6r1"); + expect(parsed!.reroll.active).toBe(true); + expect(parsed!.reroll.once).toBe(false); + expect(parsed!.reroll.values).toContain(1); + }); + + it('parses reroll once "2d6ro1"', () => { + const parsed = parseDiceNotation("2d6ro1"); + expect(parsed!.reroll.active).toBe(true); + expect(parsed!.reroll.once).toBe(true); + }); + + it('parses reroll less-than "2d6r<3"', () => { + const parsed = parseDiceNotation("2d6r<3"); + expect(parsed!.reroll.active).toBe(true); + expect(parsed!.reroll.values).toContain(1); + expect(parsed!.reroll.values).toContain(2); + expect(parsed!.reroll.values).not.toContain(3); + }); + + it("ignores whitespace", () => { + const parsed = parseDiceNotation(" 2 d 20 + 5 "); + expect(parsed!.count).toBe(2); + expect(parsed!.sides).toBe(20); + expect(parsed!.modifier).toBe(5); + }); + + it("returns null for invalid notation", () => { + expect(parseDiceNotation("")).toBeNull(); + expect(parseDiceNotation("abc")).toBeNull(); + expect(parseDiceNotation("2d6+1+2")).toBeNull(); + expect(parseDiceNotation("0d6")).toBeNull(); + expect(parseDiceNotation("21d6")).toBeNull(); + expect(parseDiceNotation("2d0")).toBeNull(); + expect(parseDiceNotation("2d6kh0")).toBeNull(); + expect(parseDiceNotation("2d6kh2")).toBeNull(); + }); +}); + +describe("rollDice", () => { + let mathRandomSpy: ReturnType; + + afterEach(() => { + mathRandomSpy?.mockRestore(); + }); + + it("rolls basic dice", () => { + mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); + const parsed = parseDiceNotation("3d6")!; + const result = rollDice(parsed); + + expect(result.dice).toHaveLength(3); + expect(result.kept).toHaveLength(3); + expect(result.dropped).toHaveLength(0); + expect(result.total).toBe(12); // each die: floor(0.5*6)+1 = 4, 3*4 = 12 + }); + + it("applies modifier", () => { + mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); + const parsed = parseDiceNotation("1d20+5")!; + const result = rollDice(parsed); + + expect(result.total).toBe(16); // 11 + 5 + }); + + it("keeps highest rolls (kh)", () => { + mathRandomSpy = vi + .spyOn(Math, "random") + .mockReturnValueOnce(0.1) // 1 + .mockReturnValueOnce(0.9) // 6 + .mockReturnValueOnce(0.5); // 4 + + const parsed = parseDiceNotation("3d6kh2")!; + const result = rollDice(parsed); + + expect(result.kept).toHaveLength(2); + expect(result.dropped).toHaveLength(1); + expect(result.dropped[0].dropped).toBe(true); + expect(result.total).toBe(10); // 6 + 4 + }); + + it("drops highest rolls (dh)", () => { + mathRandomSpy = vi + .spyOn(Math, "random") + .mockReturnValueOnce(0.1) // 1 + .mockReturnValueOnce(0.9) // 6 + .mockReturnValueOnce(0.5); // 4 + + const parsed = parseDiceNotation("3d6dh1")!; + const result = rollDice(parsed); + + expect(result.kept).toHaveLength(2); + expect(result.dropped).toHaveLength(1); + expect(result.dropped[0].value).toBe(6); + expect(result.total).toBe(5); // 1 + 4 + }); + + it("handles exploding dice", () => { + mathRandomSpy = vi + .spyOn(Math, "random") + .mockReturnValueOnce(0.99) // first roll: 6 (max, triggers explode) + .mockReturnValueOnce(0.99) // explosion 1: 6 (triggers again) + .mockReturnValueOnce(0.1); // explosion 2: 1 (stops) + + const parsed = parseDiceNotation("1d6!")!; + const result = rollDice(parsed); + + expect(result.dice[0].exploded).toBe(true); + expect(result.dice[0].explosions).toEqual([6, 1]); + expect(result.total).toBe(13); // 6 + 6 + 1 + }); + + it("handles penetrating explode", () => { + mathRandomSpy = vi + .spyOn(Math, "random") + .mockReturnValueOnce(0.99) // first roll: 6 (explode) + .mockReturnValueOnce(0.99); // explosion: 6 -> penetrating -> 5, then 5 < 6 stops + + const parsed = parseDiceNotation("1d6!p")!; + const result = rollDice(parsed); + + expect(result.dice[0].explosions).toEqual([5]); + expect(result.total).toBe(11); // 6 + 5 + }); + + it("handles reroll", () => { + mathRandomSpy = vi + .spyOn(Math, "random") + .mockReturnValueOnce(0.0) // first roll: 1 (reroll) + .mockReturnValueOnce(0.5); // reroll: 4 + + const parsed = parseDiceNotation("1d6r1")!; + const result = rollDice(parsed); + + expect(result.dice[0].rerolledFrom).toBe(1); + expect(result.dice[0].value).toBe(4); + }); + + it("handles reroll once (ro)", () => { + mathRandomSpy = vi + .spyOn(Math, "random") + .mockReturnValueOnce(0.0) // first: 1 (reroll once) + .mockReturnValueOnce(0.0); // second: 1 (stays, no more rerolls) + + const parsed = parseDiceNotation("1d6ro1")!; + const result = rollDice(parsed); + + expect(result.dice[0].rerolledFrom).toBe(1); + expect(result.dice[0].value).toBe(1); + }); +}); + +describe("rollAdvantage", () => { + let mathRandomSpy: ReturnType; + + afterEach(() => { + mathRandomSpy?.mockRestore(); + }); + + it("returns highest for advantage", () => { + mathRandomSpy = vi + .spyOn(Math, "random") + .mockReturnValueOnce(0.1) // 3 + .mockReturnValueOnce(0.9); // 19 + + const result = rollAdvantage(20, 0, true); + expect(result.total).toBe(19); + expect(result.advantageRolls).toEqual([3, 19]); + }); + + it("returns lowest for disadvantage", () => { + mathRandomSpy = vi + .spyOn(Math, "random") + .mockReturnValueOnce(0.9) // 19 + .mockReturnValueOnce(0.1); // 3 + + const result = rollAdvantage(20, 2, false); + expect(result.total).toBe(5); // 3 + 2 + expect(result.advantageRolls).toEqual([19, 3]); + }); +}); + +describe("buildNotation", () => { + it("builds basic notation", () => { + expect(buildNotation(1, 6, 0)).toBe("1d6"); + expect(buildNotation(2, 20, 0)).toBe("2d20"); + }); + + it("adds positive modifier", () => { + expect(buildNotation(2, 6, 3)).toBe("2d6+3"); + }); + + it("adds negative modifier", () => { + expect(buildNotation(2, 6, -2)).toBe("2d6-2"); + }); +}); + +describe("isAdvancedNotation", () => { + it("returns false for basic notation", () => { + expect(isAdvancedNotation("2d6")).toBe(false); + expect(isAdvancedNotation("1d20+5")).toBe(false); + expect(isAdvancedNotation("3d8-1")).toBe(false); + }); + + it("returns true for keep/drop", () => { + expect(isAdvancedNotation("4d6kh3")).toBe(true); + expect(isAdvancedNotation("5d6dl2")).toBe(true); + }); + + it("returns true for explode", () => { + expect(isAdvancedNotation("3d6!")).toBe(true); + expect(isAdvancedNotation("2d10!p")).toBe(true); + }); + + it("returns true for reroll", () => { + expect(isAdvancedNotation("2d6r1")).toBe(true); + expect(isAdvancedNotation("2d6ro1")).toBe(true); + expect(isAdvancedNotation("2d6r<3")).toBe(true); + }); +}); diff --git a/src/lib/dice-engine.ts b/src/lib/dice-engine.ts index ade5f30..72d4076 100644 --- a/src/lib/dice-engine.ts +++ b/src/lib/dice-engine.ts @@ -2,7 +2,7 @@ // Supports: XdY±Z, kh/kl/dh/dl, ! exploding, r reroll, advantage/disadvantage // Caps: explode chain ≤100, reroll recursion ≤1000 -type KeepDrop = { type: 'kh' | 'kl' | 'dh' | 'dl'; count: number } | null; +type KeepDrop = { type: "kh" | "kl" | "dh" | "dl"; count: number } | null; type Explode = { active: boolean; @@ -14,7 +14,7 @@ type Reroll = { active: boolean; values: Set; once: boolean; // "ro" = once only; "r" = recursive - operator: 'eq' | 'lt' | 'gt'; + operator: "eq" | "lt" | "gt"; }; export interface Parsed { @@ -32,7 +32,7 @@ export interface DieResult { value: number; original: number; exploded: boolean; - explosions: number[]; // chained explosion rolls + explosions: number[]; // chained explosion rolls rerolledFrom: number | null; dropped: boolean; } @@ -52,7 +52,7 @@ const REROLL_CAP = 1000; /** Remove all whitespace and lowercase for parsing */ function normalize(raw: string): string { - return raw.replace(/\s+/g, '').toLowerCase(); + return raw.replace(/\s+/g, "").toLowerCase(); } /** @@ -67,7 +67,7 @@ export function parseDiceNotation(raw: string): Parsed | null { const main = s.match(/^(\d*)d(\d+)(.*)$/); if (!main) return null; - const count = main[1] === '' ? 1 : parseInt(main[1], 10); + const count = main[1] === "" ? 1 : parseInt(main[1], 10); const sides = parseInt(main[2], 10); const rest = main[3]; @@ -81,7 +81,10 @@ export function parseDiceNotation(raw: string): Parsed | null { if (kdMatch) { const countKD = parseInt(kdMatch[2], 10); if (countKD < 1 || countKD >= count) return null; - keepDrop = { type: kdMatch[1] as 'kh' | 'kl' | 'dh' | 'dl', count: countKD }; + keepDrop = { + type: kdMatch[1] as "kh" | "kl" | "dh" | "dl", + count: countKD, + }; remaining = remaining.slice(kdMatch[0].length); } @@ -90,8 +93,8 @@ export function parseDiceNotation(raw: string): Parsed | null { const expMatch = remaining.match(/^(!p|!)(>?)(\d*)/); if (expMatch) { explode.active = true; - explode.penetrating = expMatch[1] === '!p'; - if (expMatch[2] === '>' && expMatch[3]) { + explode.penetrating = expMatch[1] === "!p"; + if (expMatch[2] === ">" && expMatch[3]) { explode.threshold = parseInt(expMatch[3], 10); if (explode.threshold < 2 || explode.threshold > sides) return null; } else { @@ -101,21 +104,26 @@ export function parseDiceNotation(raw: string): Parsed | null { } // Reroll: rN, roN, r]?)(\d+)/); if (rrMatch) { reroll.active = true; - reroll.once = rrMatch[1] === 'ro'; - const op = rrMatch[2] as '' | '<' | '>'; + reroll.once = rrMatch[1] === "ro"; + const op = rrMatch[2] as "" | "<" | ">"; const val = parseInt(rrMatch[3], 10); - if (op === '<') { - reroll.operator = 'lt'; + if (op === "<") { + reroll.operator = "lt"; for (let i = 1; i < val && i < sides; i++) reroll.values.add(i); - } else if (op === '>') { - reroll.operator = 'gt'; + } else if (op === ">") { + reroll.operator = "gt"; for (let i = val + 1; i <= sides; i++) reroll.values.add(i); } else { - reroll.operator = 'eq'; + reroll.operator = "eq"; reroll.values.add(val); } remaining = remaining.slice(rrMatch[0].length); @@ -133,7 +141,16 @@ export function parseDiceNotation(raw: string): Parsed | null { // If anything remains unparsed, it's invalid if (remaining.length > 0) return null; - return { count, sides, modifier, keepDrop, explode, reroll, advantage: false, disadvantage: false }; + return { + count, + sides, + modifier, + keepDrop, + explode, + reroll, + advantage: false, + disadvantage: false, + }; } /** Roll a single die with optional reroll and explode logic */ @@ -194,34 +211,47 @@ export function rollDice(parsed: Parsed): RollResult { if (parsed.keepDrop) { const sorted = [...dice].map((d, i) => ({ die: d, idx: i })); - if (parsed.keepDrop.type === 'kh') { + if (parsed.keepDrop.type === "kh") { sorted.sort((a, b) => b.die.value - a.die.value); - const keepIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx)); + const keepIndices = new Set( + sorted.slice(0, parsed.keepDrop.count).map((x) => x.idx), + ); kept = dice.filter((_, i) => keepIndices.has(i)); dropped = dice.filter((_, i) => !keepIndices.has(i)); - } else if (parsed.keepDrop.type === 'kl') { + } else if (parsed.keepDrop.type === "kl") { sorted.sort((a, b) => a.die.value - b.die.value); - const keepIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx)); + const keepIndices = new Set( + sorted.slice(0, parsed.keepDrop.count).map((x) => x.idx), + ); kept = dice.filter((_, i) => keepIndices.has(i)); dropped = dice.filter((_, i) => !keepIndices.has(i)); - } else if (parsed.keepDrop.type === 'dh') { + } else if (parsed.keepDrop.type === "dh") { sorted.sort((a, b) => b.die.value - a.die.value); - const dropIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx)); + const dropIndices = new Set( + sorted.slice(0, parsed.keepDrop.count).map((x) => x.idx), + ); kept = dice.filter((_, i) => !dropIndices.has(i)); dropped = dice.filter((_, i) => dropIndices.has(i)); - } else if (parsed.keepDrop.type === 'dl') { + } else if (parsed.keepDrop.type === "dl") { sorted.sort((a, b) => a.die.value - b.die.value); - const dropIndices = new Set(sorted.slice(0, parsed.keepDrop.count).map(x => x.idx)); + const dropIndices = new Set( + sorted.slice(0, parsed.keepDrop.count).map((x) => x.idx), + ); kept = dice.filter((_, i) => !dropIndices.has(i)); dropped = dice.filter((_, i) => dropIndices.has(i)); } } - dropped.forEach(d => { d.dropped = true; }); + dropped.forEach((d) => { + d.dropped = true; + }); // Calculate total let total = kept.reduce((sum, d) => sum + d.value, 0); - total += kept.reduce((sum, d) => sum + d.explosions.reduce((s, v) => s + v, 0), 0); + total += kept.reduce( + (sum, d) => sum + d.explosions.reduce((s, v) => s + v, 0), + 0, + ); total += parsed.modifier; return { @@ -230,30 +260,56 @@ export function rollDice(parsed: Parsed): RollResult { dropped, modifier: parsed.modifier, total, - notation: '', // filled by caller + notation: "", // filled by caller advantageRolls: null, }; } /** Roll with advantage or disadvantage (2dX keep highest/lowest) */ -export function rollAdvantage(sides: number, modifier: number, advantage: boolean): RollResult { +export function rollAdvantage( + sides: number, + modifier: number, + advantage: boolean, +): RollResult { const r1 = Math.floor(Math.random() * sides) + 1; const r2 = Math.floor(Math.random() * sides) + 1; const keptVal = advantage ? Math.max(r1, r2) : Math.min(r1, r2); return { - dice: [{ value: keptVal, original: keptVal, exploded: false, explosions: [], rerolledFrom: null, dropped: false }], - kept: [{ value: keptVal, original: keptVal, exploded: false, explosions: [], rerolledFrom: null, dropped: false }], + dice: [ + { + value: keptVal, + original: keptVal, + exploded: false, + explosions: [], + rerolledFrom: null, + dropped: false, + }, + ], + kept: [ + { + value: keptVal, + original: keptVal, + exploded: false, + explosions: [], + rerolledFrom: null, + dropped: false, + }, + ], dropped: [], modifier, total: keptVal + modifier, - notation: '', + notation: "", advantageRolls: [r1, r2], }; } /** Build notation string from parsed object (basic only, for simple sync) */ -export function buildNotation(count: number, sides: number, modifier: number): string { +export function buildNotation( + count: number, + sides: number, + modifier: number, +): string { let s = `${count}d${sides}`; if (modifier > 0) s += `+${modifier}`; else if (modifier < 0) s += modifier; @@ -263,5 +319,5 @@ export function buildNotation(count: number, sides: number, modifier: number): s /** Check if notation contains advanced features beyond basic XdY±Z */ export function isAdvancedNotation(raw: string): boolean { const s = normalize(raw); - return /(kh|kl|dh|dl|!|[<>]?r\d)/.test(s); + return /(kh|kl|dh|dl|!|ro?[<>]?\d)/.test(s); } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7dd1325 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +});