pollyroll 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Irakli Iremashvili
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,323 @@
1
+ # pollyroll
2
+
3
+ Lightweight 3D dice for the web with zero runtime dependencies. It has a WebGL2 renderer and a
4
+ deterministic physics engine, and rolls are result-first, so it works for multiplayer.
5
+
6
+ - **Result-first.** Values are drawn before any physics runs. The simulation only animates the dice,
7
+ and a symmetry remap turns each settled die so its top face shows the value already chosen.
8
+ - **Shared rolls.** A roll is a plain JSON `RollEvent`. Every client that plays the same event shows
9
+ the same values. Clients whose trays have the same shape also see the same motion.
10
+ - **Small.** About 3 kB for the DOM-free core, about 18.4 kB for the renderer, physics, geometry, and
11
+ shaders, and under 1 kB for the React binding (min+gzip). It ships no asset files: geometry,
12
+ labels, patterns, and lighting are all generated in code.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ npm install pollyroll
18
+ ```
19
+
20
+ `react` (≥ 18) is an optional peer dependency, needed only for `pollyroll/react`.
21
+
22
+ ## Entry points
23
+
24
+ | Import | Contents | Runs in |
25
+ | ------------------ | ------------------------------------------------------------------ | -------------- |
26
+ | `pollyroll` | Notation parser, `createRoll`, `evaluate`, `redact`, `isRollEvent` | Node, browsers |
27
+ | `pollyroll/render` | `createDiceTray`, skins and presets | Browsers |
28
+ | `pollyroll/react` | `useDiceTray` hook and `<DiceTray />` overlay | React |
29
+
30
+ ## Quick start
31
+
32
+ ```ts
33
+ import { createRoll } from 'pollyroll';
34
+ import { createDiceTray } from 'pollyroll/render';
35
+
36
+ const tray = createDiceTray(document.getElementById('table')!); // overlay canvas inside the element
37
+ const event = createRoll('2d20kh1+5');
38
+ const summary = await tray.playRoll(event); // resolves once every die has settled
39
+ console.log(summary.total);
40
+ ```
41
+
42
+ If the target is an `HTMLElement`, the tray adds a transparent canvas inside it. The canvas is
43
+ absolutely positioned and ignores pointer events, so give the element `position: relative` (or any
44
+ positioning). You can also pass an existing `<canvas>`.
45
+
46
+ ## Core (`pollyroll`)
47
+
48
+ ```ts
49
+ parse(notation: string): RollAst; // throws PollyrollSyntaxError { message, index }
50
+ createRoll(notation, opts?): RollEvent; // opts: { rng?, seed?, skin?, rollerId?, audience? }
51
+ evaluate(event: RollEvent): RollSummary; // { total | null, modifier, groups: [{ die, dice, kept, dropped, subtotal }] }
52
+ redact(event: RollEvent): RollEvent; // copy with every value set to null
53
+ isRollEvent(input: unknown): input is RollEvent; // validates untrusted input, including bounds
54
+ ```
55
+
56
+ ### Notation
57
+
58
+ Notation is case-insensitive and ignores whitespace.
59
+
60
+ | Syntax | Meaning |
61
+ | ----------------------- | --------------------------------------------------------------- |
62
+ | `NdX` | N dice (1–100) of X ∈ 4, 6, 8, 10, 12, 20, 100; N defaults to 1 |
63
+ | `d%`, `dF` | d100; Fudge die (−1, 0, +1) |
64
+ | `+`, `-`, integers | Add or subtract terms and constants |
65
+ | `khN` `klN` `dhN` `dlN` | Keep or drop the highest or lowest N (N defaults to 1) |
66
+ | `kN`, `dN` | `khN`, `dlN` (N defaults to 1) |
67
+ | `!` | Explode on the maximum, up to 10 extra waves |
68
+ | `x` | `!` |
69
+ | `adv`, `dis` | `2d20kh1`, `2d20kl1` |
70
+
71
+ Limits are at most 20 terms, at most 200 dice before explosions, and inputs of at most 256
72
+ characters. Examples: `2d20kh1+5`, `4d6dl1`, `3d6!`, `adv+3`, `1d%`, `4dF`.
73
+
74
+ ### `RollEvent` (wire format, `v: 1`)
75
+
76
+ ```ts
77
+ interface RollEvent {
78
+ v: 1;
79
+ id: string; // crypto.randomUUID(); receivers dedupe on it
80
+ notation: string; // as entered
81
+ dice: Array<{ type: DieType; value: number | null; group: number; wave: number }>;
82
+ modifier: number; // sum of constant terms
83
+ seed: string; // 32 hex chars; derives every throw parameter
84
+ skin?: SkinRef;
85
+ rollerId?: string;
86
+ audience?: 'all' | 'dm' | string[];
87
+ createdAt: number;
88
+ }
89
+ ```
90
+
91
+ In each summary group, `dice` lists the group's values in event order, and `kept` and `dropped` are
92
+ **indexes** into that `dice` array. For example, `2d20kh1` with `[7, 15]` gives
93
+ `{ dice: [7, 15], kept: [1], dropped: [0], subtotal: 15 }`. Keep/drop ranks every die of the group,
94
+ explosion dice included, and ties go to the lower index.
95
+
96
+ The event never carries totals or kept/dropped dice; `evaluate(event)` derives them. `group` is the
97
+ index of the dice term (constants are not counted). `wave` is 0 for the initial throw and 1 or more
98
+ for explosions.
99
+
100
+ ## Renderer (`pollyroll/render`)
101
+
102
+ ```ts
103
+ const tray = createDiceTray(target, {
104
+ skin: 'classic', // preset name or a Skin object
105
+ labelFont: 'system-ui',
106
+ dieScale: 2, // maximum die size; large rolls shrink to fit
107
+ shadows: true,
108
+ maxDpr: 2,
109
+ reducedMotion: 'auto', // 'auto' follows prefers-reduced-motion; 'always' | 'never'
110
+ fadeAfterMs: null, // number: fade the dice out after they settle
111
+ labels: { d6: ['⚀', '⚁', '⚂', '⚃', '⚄', '⚅'] }, // optional custom labels per die
112
+ });
113
+
114
+ tray.playRoll(event); // Promise<RollSummary>; a new roll replaces the previous dice
115
+ tray.setSkin('obsidian');
116
+ tray.setDieScale(1.5); // size for the next roll; dice on screen keep theirs
117
+ tray.clear();
118
+ tray.resize(); // also called automatically through ResizeObserver
119
+ tray.dispose(); // releases every GPU resource and listener
120
+ tray.supported; // false when WebGL2 is unavailable
121
+ ```
122
+
123
+ Custom labels (`labels`) replace the stock text but keep the value mapping. Each array follows the
124
+ die's natural order:
125
+
126
+ - `d4` to `d20`: `'1'…'N'`.
127
+ - `d10`, `d100ones`: digits `'0'…'9'`.
128
+ - `d100tens`: `'00'…'90'`.
129
+ - `dF`: three entries for −1, blank, and +1.
130
+
131
+ The label font is the skin's `font` if it has one, otherwise `labelFont`. If the browser can't parse that font, the label falls back to `labelFont`, then `system-ui`.
132
+
133
+ - **No WebGL2:** `playRoll` resolves immediately with the summary and draws nothing.
134
+ - **Reduced motion:** the tray draws the settled dice without animating them and resolves
135
+ immediately.
136
+ - **Die size:** `dieScale` (default 2) is the largest size a die is drawn at. A roll with many dice
137
+ shrinks in 0.05 steps until its dice fit the tray, so a single d20 shows at full size while 20d6
138
+ stays readable without overlapping. The size sets the walls as well as the drawing, so both sides
139
+ of a shared roll need the same `dieScale` and tray aspect to see identical motion.
140
+ - **Superseded rolls:** if a roll is replaced, cleared, or disposed, its promise still resolves with
141
+ that roll's own summary. It never rejects in these cases.
142
+ - **Dice limit:** at most 30 dice are animated per tray (a d100 counts as two). Dice beyond that
143
+ still count in the summary.
144
+ - **Device pixel ratio:** capped at `maxDpr`. The frame loop stops once the dice settle, so an idle
145
+ tray costs nothing.
146
+
147
+ ### Skins
148
+
149
+ ```ts
150
+ import {
151
+ classic,
152
+ obsidian,
153
+ brass,
154
+ oak,
155
+ sapphire,
156
+ ruby,
157
+ emerald,
158
+ amethyst,
159
+ topaz,
160
+ aquamarine,
161
+ smoke,
162
+ defineSkin,
163
+ registerSkin,
164
+ } from 'pollyroll/render';
165
+
166
+ registerSkin('table-red', defineSkin('ruby', { labelColor: '#fff' }));
167
+ tray.setSkin('table-red');
168
+
169
+ tray.setSkin({
170
+ material: 'plastic', // 'plastic' | 'metal' | 'wood' | 'glass' | 'stone' | 'gem' | MaterialParams
171
+ color: ['#204080', '#a0c0ff'], // solid or two-tone
172
+ labelColor: '#ffffff',
173
+ labelStyle: 'engraved', // 'engraved' | 'printed' | 'embossed'
174
+ pattern: 'marble', // 'none' | 'gradient' | 'speckle' | 'marble' | 'wood' | 'swirl' | { glsl }
175
+ });
176
+ ```
177
+
178
+ A custom pattern is a GLSL ES 3.00 function, `vec3 pattern(vec3 p, vec3 n, vec3 a, vec3 b)`, that
179
+ works in object space and returns the base color. If it fails to compile, `setSkin` throws
180
+ `PollyrollShaderError`, and its `log` holds the compiler output. For safety, `isRollEvent` rejects
181
+ events whose inline skin carries custom GLSL. Register custom-pattern skins locally and send only
182
+ their names.
183
+
184
+ Presets: `classic`, `obsidian`, `brass`, `oak`, `sapphire` (glass), `ruby` (gem), `emerald`
185
+ (glass), `amethyst` (gem), `topaz` (gem), `aquamarine` (clear glass), and `smoke` (smoky glass).
186
+
187
+ `MaterialParams` takes `metalness`, `roughness`, and optional `clearcoat`, `transmission`, `tint`,
188
+ and `sparkle`, all in [0, 1]. `transmission` above 0 makes the die see-through (higher is clearer),
189
+ `tint` sets how deep the body colour gets along the view path, and `sparkle` adds gem glints.
190
+ `'glass'` is `transmission: 0.8, tint: 0.6`; `'gem'` is `transmission: 0.4, tint: 0.9, sparkle: 1`.
191
+ `materialPresets` (from `pollyroll/render`) gives the full params of every named material, a
192
+ starting point for custom ones: `{ ...materialPresets.glass, tint: 0.3 }`.
193
+
194
+ ```ts
195
+ tray.setSkin({
196
+ material: {
197
+ metalness: 0,
198
+ roughness: 0.06,
199
+ clearcoat: 1,
200
+ transmission: 0.7,
201
+ tint: 0.8,
202
+ sparkle: 0.5,
203
+ },
204
+ color: ['#0b5d3b', '#18a86b'],
205
+ labelColor: '#ffffff',
206
+ labelStyle: 'printed',
207
+ });
208
+ ```
209
+
210
+ ## React (`pollyroll/react`)
211
+
212
+ ```tsx
213
+ import { useRef } from 'react';
214
+ import { createRoll } from 'pollyroll';
215
+ import { DiceTray } from 'pollyroll/react';
216
+ import type { DiceTray as Tray } from 'pollyroll/render';
217
+
218
+ function Table() {
219
+ const tray = useRef<Tray | null>(null);
220
+ return (
221
+ <div style={{ position: 'relative', height: 480 }}>
222
+ <DiceTray trayRef={tray} skin="oak" />
223
+ <button onClick={() => tray.current?.playRoll(createRoll('1d20'))}>Roll</button>
224
+ </div>
225
+ );
226
+ }
227
+ ```
228
+
229
+ `useDiceTray(ref, opts)` creates a tray on `ref.current` after mount and disposes it exactly once on
230
+ unmount, including under StrictMode. Options are read when the tray is created. Only `skin` updates
231
+ afterwards, and object skins are compared by value.
232
+
233
+ ## Multiplayer
234
+
235
+ The library never touches the network. Send the event over whatever channel you already have:
236
+
237
+ ```ts
238
+ // roller
239
+ const event = createRoll('1d20+4', { rollerId: me.id });
240
+ // redact() nulls every value and drops explosion dice, so a hidden roll reveals nothing
241
+ tray.playRoll(event);
242
+ channel.send({ kind: 'dice', event: hidden ? redact(event) : event });
243
+
244
+ // receivers
245
+ channel.on('dice', ({ event }) => {
246
+ if (!isRollEvent(event) || seen.has(event.id)) return;
247
+ seen.add(event.id);
248
+ tray.playRoll(event); // redacted: blank labels, total null, explosion dice removed
249
+ });
250
+ ```
251
+
252
+ The throw is derived from `seed`, so receivers replay the same tumble. The values come from the
253
+ event, so every receiver shows the same result. Motion matches exactly when both trays have the
254
+ same quantized aspect ratio and `dieScale`.
255
+
256
+ ## Determinism
257
+
258
+ The physics step uses only `+ − * /` and `Math.sqrt`, with a fixed 1/120 s step and fixed iteration
259
+ order. The geometry and the tray bounds use algebraic constants instead of trigonometry. CI checks
260
+ that Chromium, Firefox, and WebKit compute the same trajectory hash as Node.
261
+
262
+ ## Known limits
263
+
264
+ These describe 0.1.0 as built. Each one is a deliberate trade-off or a measured edge case, not a
265
+ hidden bug.
266
+
267
+ **Physics and layout**
268
+
269
+ - **Dice collide as spheres with each other.** Contact with the floor and walls uses the exact
270
+ shape, but die-to-die contact uses bounding spheres. Dice therefore stop a little apart and never
271
+ stack or lean on each other's faces.
272
+ - **Large rolls get smaller dice.** Auto-fit shrinks a roll until its dice fit the tray (5 square
273
+ die-units per die). With many dice in a small canvas the dice get small, down to a floor of
274
+ 0.5 × `dieScale`. Dice beyond 30 animated bodies are counted in the summary but not shown; a d100
275
+ counts as two bodies.
276
+ - **30-dice rolls can overlap.** About 3–5 % of 30-dice rolls end with two dice touching. Rolls of
277
+ 4, 9 and 20 dice showed no overlap in 150 test rolls each.
278
+ - **The final settle skips collision checks.** For the last 0.2 s, a die that rested tilted or too
279
+ close to a neighbour is moved flat and apart without collision checks. In crowded rolls it can
280
+ briefly pass through a neighbour.
281
+ - **Motion matches only between same-shape trays.** Two clients see identical motion when their
282
+ trays have the same aspect ratio and the same `dieScale`. The values always match, because the
283
+ remap runs on each client.
284
+ - **Explosion waves add time.** Each wave is thrown after the previous one settles, so long
285
+ explosion chains (up to 10 waves) take several seconds.
286
+
287
+ **Rendering**
288
+
289
+ - **Camera:** the camera looks straight down (orthographic). There is no option for an angled view,
290
+ and dice in the air do not grow as they approach; the shadow offset carries the height cue.
291
+ - **Small labels:** d4 and d20 labels are the smallest because of their face shapes. Raise
292
+ `dieScale` if they are hard to read.
293
+ - **No images:** there are no image textures, and lighting is analytic.
294
+ - **Shadows:** these are soft blobs, not shadow maps.
295
+ - **What has been tested:** screenshot baselines come from SwiftShader in headless Chromium. Real
296
+ GPUs, mobile devices, and the 60 fps target under CPU throttling have not been measured yet.
297
+ - **WebGL2 is required:** without it, `playRoll` resolves with the summary and draws nothing.
298
+
299
+ **Notation and wire format**
300
+
301
+ - **Not supported:** rerolls (`r1`), compounding explosions, success counting (`>=5`), crit ranges,
302
+ parentheses, `*` and `/`, and dice other than d4, d6, d8, d10, d12, d20, d100 and dF.
303
+ - **Aliases:** `kN`, `dN` and `x` are accepted. An event that uses them is rejected by
304
+ `isRollEvent` in any build that predates the aliases.
305
+ - **Custom GLSL stays local:** `isRollEvent` rejects inline custom GLSL, so share custom-pattern
306
+ skins by registered name.
307
+
308
+ ## Development
309
+
310
+ ```sh
311
+ pnpm install
312
+ pnpm dev # demo playground
313
+ pnpm verify # lint, format, build, typecheck, tests with coverage, size budgets
314
+ pnpm e2e # Playwright (Chromium, Firefox, WebKit)
315
+ pnpm bench # physics benchmark
316
+ ```
317
+
318
+ The build specification is in [docs/PLAN.md](docs/PLAN.md) and the decision log in
319
+ [docs/DECISIONS.md](docs/DECISIONS.md).
320
+
321
+ ## License
322
+
323
+ MIT
@@ -0,0 +1,243 @@
1
+ // src/core/notation.ts
2
+ var PollyrollSyntaxError = class extends Error {
3
+ index;
4
+ constructor(message, index) {
5
+ super(message);
6
+ this.name = "PollyrollSyntaxError";
7
+ this.index = index;
8
+ }
9
+ };
10
+ var MAX_LENGTH = 256;
11
+ var MAX_TERMS = 20;
12
+ var MAX_COUNT = 100;
13
+ var MAX_DICE = 200;
14
+ var MAX_CONSTANT = 999999;
15
+ var WHITESPACE = /\s/;
16
+ var SIZES = {
17
+ "4": "d4",
18
+ "6": "d6",
19
+ "8": "d8",
20
+ "10": "d10",
21
+ "12": "d12",
22
+ "20": "d20",
23
+ "100": "d100"
24
+ };
25
+ var isDigit = (c) => c >= "0" && c <= "9";
26
+ function parse(notation) {
27
+ if (typeof notation !== "string") {
28
+ throw new PollyrollSyntaxError("Notation must be a string", 0);
29
+ }
30
+ if (notation.length > MAX_LENGTH) {
31
+ throw new PollyrollSyntaxError("Notation is longer than 256 characters", MAX_LENGTH);
32
+ }
33
+ const chars = [];
34
+ const origin = [];
35
+ for (let i = 0; i < notation.length; i++) {
36
+ const c = notation.charAt(i);
37
+ if (WHITESPACE.test(c)) continue;
38
+ chars.push(c >= "A" && c <= "Z" ? c.toLowerCase() : c);
39
+ origin.push(i);
40
+ }
41
+ if (chars.length === 0) {
42
+ throw new PollyrollSyntaxError("Notation is empty", 0);
43
+ }
44
+ let pos = 0;
45
+ const peek = (offset = 0) => chars[pos + offset] ?? "";
46
+ const word2 = (w) => chars.slice(pos, pos + w.length).join("") === w;
47
+ const fail = (message, at) => new PollyrollSyntaxError(message, origin[at] ?? notation.length);
48
+ const unexpected = () => {
49
+ const at = origin[pos];
50
+ return at === void 0 ? fail("Unexpected end of notation", pos) : fail(`Unexpected character "${notation.charAt(at)}"`, pos);
51
+ };
52
+ const digits = () => {
53
+ let s = "";
54
+ while (isDigit(peek())) {
55
+ s += peek();
56
+ pos++;
57
+ }
58
+ return s;
59
+ };
60
+ const parseDie = () => {
61
+ const c = peek();
62
+ if (c === "%" || c === "f") {
63
+ pos++;
64
+ return c === "%" ? "d100" : "dF";
65
+ }
66
+ if (!isDigit(c)) throw unexpected();
67
+ const sizeAt = pos;
68
+ const size = digits();
69
+ const die = SIZES[size];
70
+ if (!die) throw fail(`Unsupported die size d${size}`, sizeAt);
71
+ return die;
72
+ };
73
+ const parseTerm = (sign2) => {
74
+ if (word2("adv") || word2("dis")) {
75
+ const mode = peek() === "a" ? "kh" : "kl";
76
+ pos += 3;
77
+ return { kind: "dice", sign: sign2, count: 2, die: "d20", keep: { mode, n: 1 }, explode: false };
78
+ }
79
+ let count = 1;
80
+ if (isDigit(peek())) {
81
+ const countAt = pos;
82
+ count = Number(digits());
83
+ if (word2("adv") || word2("dis")) throw fail("adv and dis take no dice count", pos);
84
+ if (peek() !== "d") {
85
+ if (count > MAX_CONSTANT) throw fail("Constant is larger than 999999", countAt);
86
+ return { kind: "constant", sign: sign2, value: count };
87
+ }
88
+ if (count < 1 || count > MAX_COUNT) {
89
+ throw fail("Dice count must be between 1 and 100", countAt);
90
+ }
91
+ } else if (peek() !== "d") {
92
+ throw unexpected();
93
+ }
94
+ pos++;
95
+ const die = parseDie();
96
+ let keep = null;
97
+ let explode = false;
98
+ for (; ; ) {
99
+ const c = peek();
100
+ const next = peek(1);
101
+ if (c === "!" || c === "x") {
102
+ if (explode) throw fail("A term can explode only once", pos);
103
+ if (die === "dF") throw fail("Fudge dice cannot explode", pos);
104
+ explode = true;
105
+ pos++;
106
+ } else if (c === "k" || c === "d") {
107
+ if (keep) throw fail("A term can have only one keep or drop", pos);
108
+ const side = next === "h" || next === "l" ? next : c === "k" ? "h" : "l";
109
+ const mode = c === "k" ? side === "h" ? "kh" : "kl" : side === "h" ? "dh" : "dl";
110
+ let nAt = pos;
111
+ pos += next === side ? 2 : 1;
112
+ let n = 1;
113
+ if (isDigit(peek())) {
114
+ nAt = pos;
115
+ n = Number(digits());
116
+ }
117
+ if (n < 1 || n > count)
118
+ throw fail(`Keep or drop count must be between 1 and ${count}`, nAt);
119
+ keep = { mode, n };
120
+ } else {
121
+ break;
122
+ }
123
+ }
124
+ return { kind: "dice", sign: sign2, count, die, keep, explode };
125
+ };
126
+ const terms = [];
127
+ let total = 0;
128
+ let sign = 1;
129
+ if (peek() === "+" || peek() === "-") {
130
+ sign = peek() === "-" ? -1 : 1;
131
+ pos++;
132
+ }
133
+ for (; ; ) {
134
+ const start = pos;
135
+ const term = parseTerm(sign);
136
+ terms.push(term);
137
+ if (terms.length > MAX_TERMS) throw fail("Notation has more than 20 terms", start);
138
+ if (term.kind === "dice") {
139
+ total += term.count;
140
+ if (total > MAX_DICE) throw fail("Notation has more than 200 dice", start);
141
+ }
142
+ const c = peek();
143
+ if (c === "") break;
144
+ if (c !== "+" && c !== "-") throw unexpected();
145
+ sign = c === "-" ? -1 : 1;
146
+ pos++;
147
+ }
148
+ if (total === 0) {
149
+ throw new PollyrollSyntaxError("Notation has no dice", 0);
150
+ }
151
+ return { terms };
152
+ }
153
+
154
+ // src/core/evaluate.ts
155
+ var ascending = (list) => list.map(([, i]) => i).sort((a, b) => a - b);
156
+ function evaluate(event) {
157
+ const buckets = parse(event.notation).terms.filter((t) => t.kind === "dice").map((term) => ({ term, dice: [] }));
158
+ for (const die of event.dice) {
159
+ const bucket = buckets[die.group];
160
+ if (!bucket) throw new TypeError(`die group ${die.group} is out of range`);
161
+ bucket.dice.push(die.value);
162
+ }
163
+ let total = event.modifier;
164
+ const groups = buckets.map(({ term, dice }) => {
165
+ const pairs = [];
166
+ dice.forEach((v, i) => {
167
+ if (v !== null) pairs.push([v, i]);
168
+ });
169
+ if (pairs.length < dice.length) {
170
+ total = null;
171
+ return { die: term.die, dice, kept: [], dropped: [], subtotal: null };
172
+ }
173
+ let keep = pairs;
174
+ let drop = [];
175
+ if (term.keep) {
176
+ const { mode, n } = term.keep;
177
+ const dir = mode === "kh" || mode === "dh" ? -1 : 1;
178
+ const ranked = pairs.slice().sort(([va, a], [vb, b]) => dir * (va - vb) || a - b);
179
+ [keep, drop] = mode[0] === "k" ? [ranked.slice(0, n), ranked.slice(n)] : [ranked.slice(n), ranked.slice(0, n)];
180
+ }
181
+ let subtotal = 0;
182
+ for (const [v] of keep) subtotal += term.sign * v;
183
+ if (total !== null) total += subtotal;
184
+ return { die: term.die, dice, kept: ascending(keep), dropped: ascending(drop), subtotal };
185
+ });
186
+ return { total, modifier: event.modifier, groups };
187
+ }
188
+
189
+ // src/core/rng.ts
190
+ var TWO_32 = 2 ** 32;
191
+ var SEED_PATTERN = /^[0-9a-f]{32}$/;
192
+ var word = new Uint32Array(1);
193
+ function rejectionInt(max, draw) {
194
+ if (!Number.isInteger(max) || max < 1 || max > TWO_32) {
195
+ throw new RangeError(`maxExclusive must be an integer in [1, 2^32], got ${max}`);
196
+ }
197
+ const limit = TWO_32 - TWO_32 % max;
198
+ let u = draw();
199
+ while (u >= limit) u = draw();
200
+ return u % max;
201
+ }
202
+ function cryptoInt(maxExclusive) {
203
+ return rejectionInt(maxExclusive, () => {
204
+ let u = 0;
205
+ for (const w of globalThis.crypto.getRandomValues(word)) u = w;
206
+ return u;
207
+ });
208
+ }
209
+ function generateSeed() {
210
+ const words = globalThis.crypto.getRandomValues(new Uint32Array(4));
211
+ let seed = "";
212
+ for (const w of words) seed += w.toString(16).padStart(8, "0");
213
+ return seed;
214
+ }
215
+ function isSeed(value) {
216
+ return typeof value === "string" && SEED_PATTERN.test(value);
217
+ }
218
+ function createSeedRng(seed) {
219
+ if (!isSeed(seed)) throw new TypeError("seed must be 32 lowercase hex characters");
220
+ const part = (i) => parseInt(seed.slice(i * 8, i * 8 + 8), 16) | 0;
221
+ let a = part(0);
222
+ let b = part(1);
223
+ let c = part(2);
224
+ let d = part(3);
225
+ const u32 = () => {
226
+ let t = a + b | 0;
227
+ a = b ^ b >>> 9;
228
+ b = c + (c << 3) | 0;
229
+ c = c << 21 | c >>> 11;
230
+ d = d + 1 | 0;
231
+ t = t + d | 0;
232
+ c = c + t | 0;
233
+ return t >>> 0;
234
+ };
235
+ for (let i = 0; i < 12; i++) u32();
236
+ return {
237
+ u32,
238
+ float: () => u32() / TWO_32,
239
+ int: (maxExclusive) => rejectionInt(maxExclusive, u32)
240
+ };
241
+ }
242
+
243
+ export { PollyrollSyntaxError, createSeedRng, cryptoInt, evaluate, generateSeed, isSeed, parse };