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 +21 -0
- package/README.md +323 -0
- package/dist/chunk-JIYQHWUA.js +243 -0
- package/dist/chunk-OEMS7CZP.cjs +251 -0
- package/dist/index.cjs +155 -0
- package/dist/index.d.cts +22 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +140 -0
- package/dist/react.cjs +50 -0
- package/dist/react.d.cts +22 -0
- package/dist/react.d.ts +22 -0
- package/dist/react.js +47 -0
- package/dist/render.cjs +2515 -0
- package/dist/render.d.cts +75 -0
- package/dist/render.d.ts +75 -0
- package/dist/render.js +2498 -0
- package/dist/types-CnzvkHmJ.d.cts +88 -0
- package/dist/types-CnzvkHmJ.d.ts +88 -0
- package/package.json +120 -0
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 };
|