test: add Vitest + 30 dice-engine tests

- Install vitest and @vitest/coverage-v8
- Add test scripts to package.json
- Create vitest.config.ts
- Add 30 comprehensive tests for parseDiceNotation, rollDice,
  rollAdvantage, buildNotation, isAdvancedNotation
- Fix isAdvancedNotation regex to detect ro (reroll once)
This commit is contained in:
emil
2026-05-10 21:12:08 +03:00
parent 3750cd2cc6
commit bb62d7310e
5 changed files with 903 additions and 57 deletions
+510 -22
View File
@@ -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",
+8 -1
View File
@@ -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"
}
}
+286
View File
@@ -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<typeof vi.spyOn>;
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<typeof vi.spyOn>;
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);
});
});
+90 -34
View File
@@ -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<number>;
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<N, ro<N
let reroll: Reroll = { active: false, values: new Set(), once: false, operator: 'eq' };
let reroll: Reroll = {
active: false,
values: new Set(),
once: false,
operator: "eq",
};
const rrMatch = remaining.match(/^(ro|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);
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});