@yipe/dice 0.8.1 → 0.9.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/CHANGELOG.md +456 -0
- package/README.md +169 -19
- package/dist/builder/index.cjs +668 -6
- package/dist/builder/index.cjs.map +1 -1
- package/dist/builder/index.d.ts +1 -0
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +665 -7
- package/dist/builder/index.js.map +1 -1
- package/dist/index.cjs +114 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +113 -1
- package/dist/index.js.map +1 -1
- package/dist/parser/rollType.d.ts +58 -0
- package/dist/parser/rollType.d.ts.map +1 -0
- package/dist/pmf/query.d.ts +26 -0
- package/dist/pmf/query.d.ts.map +1 -1
- package/dist/turn/index.d.ts +3 -0
- package/dist/turn/index.d.ts.map +1 -0
- package/dist/turn/plan.d.ts +55 -0
- package/dist/turn/plan.d.ts.map +1 -0
- package/dist/turn/state.d.ts +23 -0
- package/dist/turn/state.d.ts.map +1 -0
- package/dist/turn/turn.d.ts +149 -0
- package/dist/turn/turn.d.ts.map +1 -0
- package/dist/turn/types.d.ts +99 -0
- package/dist/turn/types.d.ts.map +1 -0
- package/package.json +2 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.9.0]
|
|
9
|
+
|
|
10
|
+
Adds `Turn`: attacks plus conditional damage riders, resolved to one exact joint
|
|
11
|
+
distribution. This is the roadmap's `Turn` / `DamageRider` item.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`turn()` / `Turn`** (`@yipe/dice/builder`). Declare attacks, then chain riders
|
|
16
|
+
in the same `onX` vocabulary the builders already use: `onFirstHit` (Sneak
|
|
17
|
+
Attack), `onAnyCrit` (Divine Smite), `onAnyMiss` (Unerring Accuracy, Lucky),
|
|
18
|
+
`onEveryHit` (Hunter's Mark, Hex, Rage), and `otherwise` for the branch where
|
|
19
|
+
the preceding rider did not fire ("flurry of blows if I didn't smite").
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const dagger = d20.plus(8).ac(16).onHit(d4.plus(4));
|
|
23
|
+
const unarmed = d20.plus(8).ac(16).onHit(d6.plus(4));
|
|
24
|
+
|
|
25
|
+
const rogue = turn([dagger, dagger]).onFirstHit(roll(3, d6));
|
|
26
|
+
rogue.mean(); // 18.6225
|
|
27
|
+
rogue.pmf.pAt(0); // 0.1225
|
|
28
|
+
|
|
29
|
+
const goliath = turn([dagger, dagger])
|
|
30
|
+
.onFirstHit(roll(3, d6))
|
|
31
|
+
.onAnyCrit(roll(2, d8), { id: "smite" })
|
|
32
|
+
.otherwise([unarmed, unarmed])
|
|
33
|
+
.onEveryHit(d6);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Each method takes an optional `{ id, of, critDamage }`, where `of` selects which
|
|
37
|
+
attacks the rider watches and defaults to all of them. All of them are sugar
|
|
38
|
+
over `rider({ damage, on, of })`, which takes the trigger as plain data.
|
|
39
|
+
|
|
40
|
+
Motivation: the pattern the README used to recommend — build a rider PMF with
|
|
41
|
+
`firstSuccessSplit` + `PMF.exclusive`, then convolve it alongside the attacks —
|
|
42
|
+
is only correct in the mean. A rider is perfectly correlated with the attacks
|
|
43
|
+
that trigger it, so treating it as an independent single corrupts the
|
|
44
|
+
distribution. For two `d20+8 AC 16 → 1d4+4` daggers plus `3d6` Sneak Attack it
|
|
45
|
+
reports P(0 damage) = 0.015 against a true 0.1225, and a standard deviation of
|
|
46
|
+
7.18 against 8.89. Because the means agreed, DPR checks never caught it while
|
|
47
|
+
every distribution chart and percentile was wrong. `Turn` owns the sources and
|
|
48
|
+
walks the joint outcome space instead, carrying one packed byte of state per
|
|
49
|
+
trigger group.
|
|
50
|
+
|
|
51
|
+
Riders sharing a trigger resolve jointly, so Sneak Attack and Fire's Burn fire
|
|
52
|
+
together or not at all, and a `not-fired` rider is the other branch of the same
|
|
53
|
+
decision rather than an independent event — mutually exclusive riders can never
|
|
54
|
+
both land. Riders may be attacks themselves, and may be sources for other riders.
|
|
55
|
+
|
|
56
|
+
- **`tryParse(expression)`** — `parse` without the throw, returning an empty PMF
|
|
57
|
+
for junk. It also accepts a *signed* integer, which the grammar rejects:
|
|
58
|
+
`parse("7")` already returns a delta, but `parse("-3")` throws, and a
|
|
59
|
+
half-typed damage field is a bare signed number often enough to matter. Every
|
|
60
|
+
consumer had written this try/catch; dprcalc's version fell back to
|
|
61
|
+
`1d1 + (n - 1)`, which the parser then rejected for a negative `n`. This one
|
|
62
|
+
builds the delta directly, and refuses values outside the safe-integer range
|
|
63
|
+
rather than quietly rounding them.
|
|
64
|
+
|
|
65
|
+
- **`withRollType(expression, rollType)`** — rewrite an expression's attack rolls
|
|
66
|
+
between flat / advantage / disadvantage / elven accuracy, leaving the damage,
|
|
67
|
+
crit and miss clauses alone. Each `d20` is resolved against the nearest
|
|
68
|
+
enclosing check, so nesting works and an expression with several attacks is
|
|
69
|
+
fully converted: `AC` is an attack roll, `DC` is the target's saving throw,
|
|
70
|
+
which the attacker's advantage does not affect. Saves and pure damage come back
|
|
71
|
+
unchanged, so this is safe to map over a mixed list. A halfling-luck `h` prefix
|
|
72
|
+
is preserved.
|
|
73
|
+
|
|
74
|
+
dprcalc was doing this by round-tripping through its own `AttackModel` parser
|
|
75
|
+
and re-serializing, 47 lines deep, because there was no way to say "same attack,
|
|
76
|
+
with advantage" to the library.
|
|
77
|
+
|
|
78
|
+
- **`DiceQuery.outcomeStats(outcomes?)`** — per-outcome `atLeastOneProbability`,
|
|
79
|
+
`allProbability` and `damageRange`, with the range summed over the singles that
|
|
80
|
+
can produce the outcome rather than read off the combined PMF. `snapshot()`
|
|
81
|
+
takes its range from the combined `count`, which the convolution accumulates as
|
|
82
|
+
an expected count, so its `avg` is size-biased for two or more attacks (its own
|
|
83
|
+
doc comment says so). `outcomeStats` is linear in the attack count by
|
|
84
|
+
construction, and the two agree for a single attack.
|
|
85
|
+
|
|
86
|
+
- **`Turn.from(spec)`** for plain-data construction from
|
|
87
|
+
`{ attacks: [{ id, source }], riders: [{ id, damage, on, of }] }`, validated up
|
|
88
|
+
front with a typed `TurnSpecError.code` (`unknown-id`, `duplicate-id`,
|
|
89
|
+
`self-reference`, `cycle`, `not-an-attack`, `too-many-groups`), so a consumer UI
|
|
90
|
+
can map errors to field states rather than reimplementing the checks. `Trigger`
|
|
91
|
+
is JSON-safe and meant to be persisted verbatim.
|
|
92
|
+
|
|
93
|
+
- **`Turn.toQuery()`**, named to match `RollBuilder`/`AttackBuilder`/`SaveBuilder`,
|
|
94
|
+
alongside a `pmf` getter as those have. There is no `toPMF(eps)`: a turn's
|
|
95
|
+
epsilon is fixed at construction, where its plan is validated and its sources
|
|
96
|
+
resolved.
|
|
97
|
+
|
|
98
|
+
- **`Turn.attacks(count, source)`** for Extra Attack, mirroring `roll(count, die)`,
|
|
99
|
+
so a Fighter's four swings do not have to be spelled out as
|
|
100
|
+
`turn([sword, sword, sword, sword])`. `turn()` also takes a bare source now, so
|
|
101
|
+
a one-attack turn needs no brackets.
|
|
102
|
+
|
|
103
|
+
- `TurnSpecError` code **`unused-crit-damage`**, for a `critDamage` passed to a
|
|
104
|
+
rider that rolls its own attack. Such a rider crits on its own terms — Great
|
|
105
|
+
Weapon Master's bonus swing does not deal doubled dice because the attack that
|
|
106
|
+
triggered it crit — so there was nothing for the value to mean and it was
|
|
107
|
+
being dropped in silence.
|
|
108
|
+
|
|
109
|
+
- **`Turn.attackIds` / `Turn.riderIds`** in declaration order, including the
|
|
110
|
+
`attack 1` / `rider 2` defaults, so a caller can discover the names that `of`
|
|
111
|
+
and `fireProbability` accept instead of having to have supplied them all.
|
|
112
|
+
|
|
113
|
+
- Every construction path — `Turn.from`, `attack()`, `rider()` and the `onX`
|
|
114
|
+
methods — validates immediately, so a bad `of` throws at the call that
|
|
115
|
+
introduced it rather than later at `pmf` access. Measured at 0.63ms for the
|
|
116
|
+
six validations in the full goliath chain, with an unchanged 26-AC sweep.
|
|
117
|
+
|
|
118
|
+
- **`Turn.fireProbability(id)`** — P(a rider fired), which the walk already knows. For
|
|
119
|
+
`every-hit` riders it reports P(at least one source hit).
|
|
120
|
+
|
|
121
|
+
- `examples/turn-examples.ts` and `yarn example turn`.
|
|
122
|
+
|
|
123
|
+
### Changed
|
|
124
|
+
|
|
125
|
+
- **`DiceQuery.combinedWithAttribution()` now honours an explicitly provided
|
|
126
|
+
`combined` distribution** instead of re-convolving `singles`. A provided
|
|
127
|
+
combined is not necessarily the independent product of the singles — a `Turn`'s
|
|
128
|
+
is strictly narrower — and re-convolving discarded it, dropping every rider's
|
|
129
|
+
damage from attribution charts. Queries that provide no combined are unaffected.
|
|
130
|
+
|
|
131
|
+
### Removed
|
|
132
|
+
|
|
133
|
+
- `examples/sneak-attack-examples.ts` (~800 lines, six hand-rolled variants of the
|
|
134
|
+
same turn). It only existed because there was no primitive for conditional
|
|
135
|
+
riders. Its state-machine variant is the ancestor of `Turn`'s walk, and its
|
|
136
|
+
agreement checks are now `tests/turn-exactness.test.ts`, which compares `Turn`
|
|
137
|
+
against a brute-force enumeration of every attack-outcome sequence.
|
|
138
|
+
|
|
139
|
+
## [0.8.1]
|
|
140
|
+
|
|
141
|
+
Extends the resolved-PMF cache to every builder kind, so no consumer has to
|
|
142
|
+
key its own cache by the AST-walking `toExpression()`.
|
|
143
|
+
|
|
144
|
+
### Changed
|
|
145
|
+
|
|
146
|
+
- **`RollBuilder.toPMF`, `DCBuilder.toPMF` and `SaveBuilder.toPMF` now cache their
|
|
147
|
+
resolved PMFs**, joining `AttackBuilder` (0.8.0). All four key by a cheap
|
|
148
|
+
serialization of their `RollConfig`s and return `null` — resolving uncached —
|
|
149
|
+
whenever a transform's PMF is not captured by those configs, so a conservative
|
|
150
|
+
miss is always preferred to a wrong hit.
|
|
151
|
+
- `RollBuilder` reuses the `cacheKey()` that already existed for
|
|
152
|
+
`AttackBuilder`'s benefit. Subclasses overriding `toPMF`
|
|
153
|
+
(`Half`/`Scale`/`MaxOf`/`Composite`) return a `null` key and stay uncached.
|
|
154
|
+
- `DCBuilder` gains a `cacheKey()` extending the base with the save DC.
|
|
155
|
+
- `SaveBuilder` gains a `cacheKey()` over the check, the failure effect and the
|
|
156
|
+
save outcome.
|
|
157
|
+
|
|
158
|
+
Motivation: profiling a dprcalc DPR plan put `toExpression()` at ~18% of self
|
|
159
|
+
time — the largest single cost — because only attacks had an internal cache, so
|
|
160
|
+
the consumer keyed its own by the expression string. Save-based builds have no
|
|
161
|
+
such fallback: removing that consumer cache without this change regressed a
|
|
162
|
+
Swords Bard by 15% and an Evoker Wizard by 11%. With it, the consumer layer can
|
|
163
|
+
be deleted outright (measured −1% to −4% across five character canaries).
|
|
164
|
+
|
|
165
|
+
### Added
|
|
166
|
+
|
|
167
|
+
- `clearRollCache()`, `clearDCCache()` and `clearSaveCache()` test/bench seams,
|
|
168
|
+
mirroring `clearAttackCache()`.
|
|
169
|
+
|
|
170
|
+
## [0.8.0]
|
|
171
|
+
|
|
172
|
+
### Added
|
|
173
|
+
|
|
174
|
+
- **`AttackBuilder.toPMF` caches its resolved PMF**, keyed by a cheap
|
|
175
|
+
serialization of the check + effect `RollConfig`s rather than the AST-walking
|
|
176
|
+
`toExpression()`. A DPR sweep resolves the same attack thousands of times
|
|
177
|
+
(~99.9% repeats measured in dprcalc). `clearAttackCache()` is the test seam.
|
|
178
|
+
|
|
179
|
+
## [0.7.0]
|
|
180
|
+
|
|
181
|
+
Moves the full stacked damage-attribution chart pipeline into the library, so
|
|
182
|
+
consumers own no dice-or-probability logic for that chart.
|
|
183
|
+
|
|
184
|
+
### Added
|
|
185
|
+
|
|
186
|
+
- **`PMF.damageAttributionChartModel(options?)`** (and a `DiceQuery` convenience)
|
|
187
|
+
returns the complete numeric model for the stacked damage-attribution chart:
|
|
188
|
+
bucket `labels`/`binRanges`, discovered `outcomes` in stack order, per-outcome
|
|
189
|
+
per-bucket `series` (bar-height mass) and `shares` (conditional tooltip share,
|
|
190
|
+
with an `epsilon` divide-by-~0 guard), per-bucket `totals`, reversed-convention
|
|
191
|
+
CCDF `percentiles`, and the `mean`. Options: `maxBuckets` (coarsen wide
|
|
192
|
+
distributions — split-first-then-bin, so sub-`binSize` damage is never folded
|
|
193
|
+
into the miss credit), `stackOrder`, `epsilon`. Exported type
|
|
194
|
+
`DamageAttributionChartModel`.
|
|
195
|
+
|
|
196
|
+
### Removed
|
|
197
|
+
|
|
198
|
+
- **BREAKING:** removed the superseded, unused `DiceQuery` chart-series methods
|
|
199
|
+
`toAttributionChartSeries`, `toDamageAttributionChartSeries`, and
|
|
200
|
+
`toOutcomeAttributionChartSeries`. Use `attributionByValue()` for the raw split
|
|
201
|
+
or `damageAttributionChartModel()` for the full chart model.
|
|
202
|
+
|
|
203
|
+
## [0.6.0]
|
|
204
|
+
|
|
205
|
+
Toolchain release: migrates the build to **TypeScript 7.0** (the Go-native
|
|
206
|
+
compiler). No library API or runtime behavior changes — the compiler port is
|
|
207
|
+
behavior-preserving, and consumer type resolution is verified equivalent across
|
|
208
|
+
`node16`, `nodenext`, and `bundler`.
|
|
209
|
+
|
|
210
|
+
### Changed
|
|
211
|
+
|
|
212
|
+
- **Migrated to TypeScript 7.0.2** (native Go `tsc`). Type-checking and
|
|
213
|
+
declaration emit are ~5× faster on this codebase.
|
|
214
|
+
- Declarations are now emitted by the native `tsc` (tsup handles JS bundling
|
|
215
|
+
only; its `rollup-plugin-dts` path does not support the TS 7 compiler API). A
|
|
216
|
+
small post-build step adds explicit `.js` extensions to relative specifiers so
|
|
217
|
+
the output resolves under `node16`/`nodenext`/`bundler`. The published `.d.ts`
|
|
218
|
+
changes from a single bundled file to a mirrored tree; **named exports and
|
|
219
|
+
type resolution are unchanged**.
|
|
220
|
+
- Bumped the dev toolchain: Yarn 4.17.1, plus latest `@types/node`,
|
|
221
|
+
`typescript-eslint`, `eslint`, `tsx`, and `vitest`. Added the
|
|
222
|
+
`@typescript/typescript6` bridge so `typescript-eslint` (which does not yet
|
|
223
|
+
support TS 7) continues to lint against the 6.0 API while `tsc` runs on 7.0.
|
|
224
|
+
|
|
225
|
+
## [0.5.0]
|
|
226
|
+
|
|
227
|
+
Pushes damage-attribution / provenance and D&D-probability logic that the
|
|
228
|
+
consuming app (dprcalc) had hand-rolled over PMF internals down into the
|
|
229
|
+
library, so the provenance model and dice math stay owned here, and adds a
|
|
230
|
+
**composable scale node** so a scaled/rounded sub-roll can nest inside a larger
|
|
231
|
+
damage payload (per-damage-type resistance / immunity / vulnerability). All
|
|
232
|
+
additive except the Elemental-Adept bounce fix noted below.
|
|
233
|
+
|
|
234
|
+
### Added
|
|
235
|
+
|
|
236
|
+
- **`RollBuilder.scaleResult(numerator, denominator = 1, rounding = 'floor')`** —
|
|
237
|
+
wraps a builder in a composable `scale` AST node that scales its resolved PMF
|
|
238
|
+
by `numerator / denominator` with the given rounding. Unlike the old
|
|
239
|
+
`.half()` wrapper, a scaled builder composes: it survives `sumRolls(...)`
|
|
240
|
+
instead of being dropped on a flat-config merge, so a per-type resisted or
|
|
241
|
+
doubled sub-roll keeps its own scaling inside a larger hit/crit payload. The
|
|
242
|
+
rendered expression reflects it — `denominator === 1 → "N * (child)"`,
|
|
243
|
+
`numerator === 1 → "(child) // D"`, general → `"(child) * N // D"`. `.half()`
|
|
244
|
+
is now `scaleResult(1, 2, 'floor')`.
|
|
245
|
+
- **`sumRolls(parts: RollBuilder[])`** — additive factory whose `toAST()` is an
|
|
246
|
+
`add` node over each part's AST, letting scaled and plain children sit side by
|
|
247
|
+
side without the flat `.plus()` merge collapsing them. `toExpression()` joins
|
|
248
|
+
the parts with ` + ` and `toPMF()` convolves them.
|
|
249
|
+
- **`PMF.applyHitFrequency(frequency)`** — provenance-preserving mass
|
|
250
|
+
redistribution for effects that only occur with some probability (conditional
|
|
251
|
+
attacks, on-hit riders, sub-one AoE fractions): scales every hit bin (damage
|
|
252
|
+
> 0) by `frequency` and moves the freed mass into a `missNone` bin. Unlike a
|
|
253
|
+
bare `scaleMass`/`mapDamage`, it scales per-label `count` **and** `attr`, so a
|
|
254
|
+
frequency-scaled PMF still renders correctly in the damage-attribution charts.
|
|
255
|
+
Replaces the app's hand-rolled `applyFrequencyToPMF`, which dropped `attr`.
|
|
256
|
+
- **`PMF.missNone(epsilon?)`** / **`MISS_NONE_OUTCOME`** — canonical "clean miss"
|
|
257
|
+
delta (point mass at 0 tagged with the `missNone` `OutcomeType`, distinct from
|
|
258
|
+
`PMF.zero`'s builder-side `miss` label), and the label as a single source of
|
|
259
|
+
truth.
|
|
260
|
+
- **`PMF.hitProbability()` / `PMF.missProbability()`** — the `1 - P(0)` idiom
|
|
261
|
+
(miss encoded at damage 0), centralized.
|
|
262
|
+
- **`PMF.rebin(maxBuckets)`** — coarsen a wide distribution into ≤ N contiguous
|
|
263
|
+
equal-width buckets, aggregating `count`/`attr` provenance. For charting wide
|
|
264
|
+
distributions, not DPR math.
|
|
265
|
+
- **`PMF.attributionByValue()` / `DiceQuery.attributionByValue()`** — split each
|
|
266
|
+
damage value's probability mass across outcome labels (by `attr` for
|
|
267
|
+
damage-bearing bins, by `count` for the clean-miss bin), returning per-label
|
|
268
|
+
`value → mass` series. The provenance core of the stacked attribution chart.
|
|
269
|
+
- **`DiceQuery.countSinglesWith(label)`** — how many independent single PMFs can
|
|
270
|
+
produce a given outcome label.
|
|
271
|
+
- **`ALL_OUTCOME_TYPES`**, **`OUTCOME_DISPLAY_ORDER`**, **`sortOutcomes()`** —
|
|
272
|
+
canonical `OutcomeType` enumeration + stack / display orderings, replacing
|
|
273
|
+
per-consumer outcome tables.
|
|
274
|
+
- **`critProbability(critRange, rollType)`** and **`RollType`** (now exported
|
|
275
|
+
from the package root as well as `@yipe/dice/builder`) — advantage-aware
|
|
276
|
+
P(crit) for a given crit window.
|
|
277
|
+
- **`calculateBounceOdds(diceCount, dieFaces, options?)`** and
|
|
278
|
+
**`BounceOddsOptions`** — the "birthday problem" for bouncing damage dice
|
|
279
|
+
(Chromatic Orb), honoring Elemental Adept and Empowered Spell. Moved out of the
|
|
280
|
+
app; the base and Elemental-Adept cases are now computed **exactly** (verified
|
|
281
|
+
against brute-force enumeration in `tests/bounce.test.ts`).
|
|
282
|
+
|
|
283
|
+
### Fixed
|
|
284
|
+
|
|
285
|
+
- **`calculateBounceOdds` Elemental Adept was approximate.** The former
|
|
286
|
+
hand-derived adjustment factor drifted from the exact value by up to ~3.5%
|
|
287
|
+
(e.g. 3×d8, min-roll 3: 0.4965 → 0.5313). The Elemental-Adept branch now uses
|
|
288
|
+
an exact elementary-symmetric-polynomial computation. Consumers relying on the
|
|
289
|
+
old numbers for bouncing spells with Elemental Adept will see small DPR shifts.
|
|
290
|
+
- **`calculateBounceOdds` Empowered Spell returned certainty when rerolling all
|
|
291
|
+
dice.** When `rerollDamageDice >= diceCount` (no dice kept), the model claimed
|
|
292
|
+
a guaranteed match (1.0) instead of treating the reroll as a second
|
|
293
|
+
independent roll. It now correctly yields `1 - (1 - pMatch)^2` in that case
|
|
294
|
+
(e.g. 3×d8 reroll-all: 1.0 → 0.5693).
|
|
295
|
+
|
|
296
|
+
## [0.3.0]
|
|
297
|
+
|
|
298
|
+
### Fixed (mathematical correctness)
|
|
299
|
+
|
|
300
|
+
Every fix is verified against an independent brute-force enumeration (see
|
|
301
|
+
`tests/math-correctness.test.ts`).
|
|
302
|
+
|
|
303
|
+
- **`DiceQuery.probabilityOf(label)` over-counted.** It summed the full `bin.p`
|
|
304
|
+
of every combined bin that merely *contained* a label, but bins hold multiple
|
|
305
|
+
mutually-exclusive outcomes — so `probabilityOf('crit')` returned 0.49 where
|
|
306
|
+
the true P(crit)=0.05. It now returns the correct Poisson-binomial marginal
|
|
307
|
+
(= `probAtLeastOne`). `missChance()` is fixed by the same change.
|
|
308
|
+
- **`DiceQuery.probExactlyK([labels], k)` array-path** delegated to the buggy
|
|
309
|
+
`probabilityOf`, disagreeing with the (correct) single-label string path; both
|
|
310
|
+
now match the true binomial.
|
|
311
|
+
- **`DiceQuery.variance()/stddev()`** used the unstable `E[X²]−E[X]²` form and
|
|
312
|
+
lost all precision under a large constant damage offset (`1d6 + 1e8` gave
|
|
313
|
+
variance 2 instead of 35/12). Now uses the centered, additive-per-single form.
|
|
314
|
+
- **`DiceQuery.mean()/variance()`** now stay consistent with an explicitly
|
|
315
|
+
supplied `combined` that diverges from `convolve(singles)`.
|
|
316
|
+
- **`PMF.convolve()` produced `NaN`** for a zero-mass operand (divide-by-zero in
|
|
317
|
+
the mass rescale), silently poisoning `DiceQuery.combined`. A zero-mass
|
|
318
|
+
convolution now correctly yields mass 0.
|
|
319
|
+
- **`probAtLeastOne` is now mass-invariant** (per-attack probability divided by
|
|
320
|
+
the single's mass) and clamped to `[0,1]` (was returning `1.0000000002`).
|
|
321
|
+
- **`PMF.firstSuccessWeights`** throws on `pSpecial > pSuccess` instead of
|
|
322
|
+
returning out-of-range probabilities.
|
|
323
|
+
- **Parser `hd6`/`hd20` (reroll-one)** used a weighted union giving
|
|
324
|
+
`P(1)=1/(2s−1)`; now uses `reroll(1)` for the correct `P(1)=1/s²`. The parser
|
|
325
|
+
`hd` distribution now matches the builder's `reroll(1)` exactly (the
|
|
326
|
+
previously loosened tests are tightened).
|
|
327
|
+
- **`DiceQuery.snapshot()` outcome probabilities** (`atLeastOneProbability`,
|
|
328
|
+
`allProbability`) were aggregated as expected counts and could exceed 1 for
|
|
329
|
+
multi-attack queries. They now use the correct Poisson-binomial marginals
|
|
330
|
+
(P(≥1) and P(all)) and are always in [0,1]. (`damageRange.avg` remains a
|
|
331
|
+
size-biased mean for N≥2 — see Known limitations.)
|
|
332
|
+
- **Parser save-for-half mislabeled outcomes** on odd/constant damage (e.g.
|
|
333
|
+
`(d20 DC 15) * (3) save half`): the brittle "2×half ∈ hit" detection
|
|
334
|
+
false-negatived, tagging the success mass as `saveFail` and the failure mass
|
|
335
|
+
as `hit`. Detection is now deterministic (the presence of a save distribution),
|
|
336
|
+
so `saveHalf`/`saveFail` are always labeled correctly.
|
|
337
|
+
- **`PMF.compact()` corrupted PMFs that shared bin objects.** It deleted
|
|
338
|
+
sub-epsilon `count`/`attr` entries *in place* and reused that same bin
|
|
339
|
+
reference in the compacted map. Because bins are shared by reference across
|
|
340
|
+
PMFs (the `branch()` / `addScaled()` / `scaleMass()` fast paths can carry
|
|
341
|
+
another PMF's bin objects), this silently mutated the source PMF — and the
|
|
342
|
+
receiver's own bins. `compact()` now clones each surviving bin before pruning;
|
|
343
|
+
the compacted result is unchanged.
|
|
344
|
+
|
|
345
|
+
### Security / hardening
|
|
346
|
+
|
|
347
|
+
- **Parser resource-exhaustion guards.** Adversarial expressions are rejected
|
|
348
|
+
with a `DiceParseError` instead of exhausting CPU/memory: a die over 1,000,000
|
|
349
|
+
faces, a dice count over 10,000, a keep whose `faces^count` enumeration would
|
|
350
|
+
exceed 1,000,000 outcomes, and a binary operation whose `faces₁ × faces₂` work
|
|
351
|
+
would exceed 100,000,000 face pairs. The last closes a gap the per-operand
|
|
352
|
+
caps missed — two individually-legal large dice (e.g. `d100000 + d100000`,
|
|
353
|
+
~10¹⁰ operations) previously hung for tens of seconds. All legitimate
|
|
354
|
+
expressions, including `d100000`, still parse.
|
|
355
|
+
|
|
356
|
+
### Known limitations (documented; recommend maintainer review)
|
|
357
|
+
|
|
358
|
+
These are real but require API/architecture decisions, so they are documented
|
|
359
|
+
and pinned by tests rather than changed blindly:
|
|
360
|
+
|
|
361
|
+
- **Parser crit probability with bonus to-hit dice is wrong.** With bonus dice in
|
|
362
|
+
the to-hit (e.g. Bless, `d20 + 5 + 1d4`), the string parser collapses crit to
|
|
363
|
+
`1/(20·∏bonusSides)` (and the DPR is off by a few %), because the natural-20
|
|
364
|
+
slice can't be separated after the bonus dice are convolved. **The builder API
|
|
365
|
+
computes it correctly** — use `d20.plus(..).plus(bonusDie).ac(..).onCrit(..)`.
|
|
366
|
+
- **Multi-attack conditional damage `avg` is size-biased.** The `avg` returned by
|
|
367
|
+
`damageStatsFrom()` (single label), `outcomeDamageRanges()` and
|
|
368
|
+
`snapshot().damageRange` aggregates the combined PMF's `count` (an *expected
|
|
369
|
+
count* for N≥2 attacks), so it is the size-biased mean E[dmg·#label]/E[#label]
|
|
370
|
+
rather than a clean conditional expectation. It is correct for a single attack.
|
|
371
|
+
(The associated *probabilities* are now correct — see Fixed.)
|
|
372
|
+
- **`PMF.mixN`/`gate`/`branch` build O(2ⁿ) identifier strings**, which can blow up
|
|
373
|
+
(multi-MB, eventual `RangeError`) for very deep (≈20+) gate chains. Prefer
|
|
374
|
+
`PMF.exclusive`/`PMF.mix` for large mixtures.
|
|
375
|
+
|
|
376
|
+
### Breaking
|
|
377
|
+
|
|
378
|
+
- **`PMF.toJSON()` now returns a plain object** (`{ bins, normalized, identifier }`)
|
|
379
|
+
instead of a JSON string, following the standard `toJSON` contract. This means
|
|
380
|
+
`JSON.stringify(pmf)` no longer double-encodes. If you relied on the old string
|
|
381
|
+
return, call the new `PMF.toJSONString()` instead.
|
|
382
|
+
- **`DiceQuery.firstSuccessSplit()` is typed as `OutcomeType | OutcomeType[]`**
|
|
383
|
+
(previously `string | string[]`). Only affects callers passing arbitrary strings;
|
|
384
|
+
valid outcome labels are unchanged.
|
|
385
|
+
|
|
386
|
+
### Added
|
|
387
|
+
|
|
388
|
+
- **`DiceParseError`** — `parse()` now throws this typed error (a subclass of
|
|
389
|
+
`Error`) instead of a plain `Error`. Existing `try/catch` and message checks keep
|
|
390
|
+
working; you can now narrow with `instanceof DiceParseError` and read
|
|
391
|
+
`error.expression` / `error.cause`.
|
|
392
|
+
- **`PMF.toJSONString()`** — returns the JSON string form (the previous
|
|
393
|
+
`toJSON()` behavior).
|
|
394
|
+
- **`DiceQuery.stdev()`** — alias of `stddev()`, matching `PMF.stdev()`.
|
|
395
|
+
- **`PMF.hasAttribution()`** — O(1) check for whether a PMF already carries
|
|
396
|
+
damage-attribution metadata.
|
|
397
|
+
|
|
398
|
+
### Performance
|
|
399
|
+
|
|
400
|
+
- **`DiceQuery.mean()` / `variance()` / `stddev()` use moment additivity**
|
|
401
|
+
(`E[ΣX]=ΣE[X]`, `Var[ΣX]=ΣVar[X]`) computed directly from the single PMFs.
|
|
402
|
+
- **`DiceQuery.combined` is now built lazily** (on first access) instead of in
|
|
403
|
+
the constructor. Combined with the above, a query used only for DPR / mean /
|
|
404
|
+
variance never performs the N-way convolution — multi-attack stats-only
|
|
405
|
+
queries are ~10000× faster (e.g. ~17 ms → ~0.001 ms for a heavy 4-attack
|
|
406
|
+
expression). The materialized `combined` distribution is unchanged; mean and
|
|
407
|
+
variance may differ from the previous convolution-based values by at most a
|
|
408
|
+
few ULP (well within the library's tolerances).
|
|
409
|
+
- **`DiceQuery.combinedWithAttribution()` reuses `combined`** when every single
|
|
410
|
+
already carries attribution (as parser-generated PMFs do), avoiding a
|
|
411
|
+
redundant convolution pass. Result is bit-for-bit identical.
|
|
412
|
+
- **`PMF.convolve()` inner loop accumulates directly into destination bins**
|
|
413
|
+
instead of allocating a temporary bin per term and merging — ~1.6× faster
|
|
414
|
+
convolution (the cost of building the combined distribution for charts). The
|
|
415
|
+
probability channel is bit-identical; per-label `count`/`attr` provenance may
|
|
416
|
+
re-associate by at most a few ULP (≤1e-14 even at 16 attacks, ~100× below the
|
|
417
|
+
eps pruning threshold).
|
|
418
|
+
- **Convolution cache-key fingerprint is memoized** on each (immutable) PMF
|
|
419
|
+
instead of re-summing every bin key on every `convolve()` call — ~36% faster
|
|
420
|
+
on warm cache hits. Bit-identical (`PMF.fingerprint()` returns the same string).
|
|
421
|
+
- **`Dice.calculateHitDistribution()` no longer clones outcome distributions per
|
|
422
|
+
face** — it reads the stored maps once instead of `O(faces × outcomes)` clones,
|
|
423
|
+
~10% faster cold parsing of wide-support expressions. Bit-identical.
|
|
424
|
+
- **`DiceQuery.toStackedChartData()` drops a dead `O(N×L)` precomputation pass**
|
|
425
|
+
whose result was discarded — ~2× faster. Bit-identical.
|
|
426
|
+
- Minor bit-identical cleanups on the parse path (`Dice.toPMF` iterates the
|
|
427
|
+
internal face map directly; `multiplyDiceByDice` uses a `Map`).
|
|
428
|
+
- **`DiceQuery` count queries (`probExactlyK` / `probAtLeastK` / `probAtMostK`,
|
|
429
|
+
array-label paths)** compute each attack's success probability and the binomial
|
|
430
|
+
DP once instead of rebuilding a query per requested count (~3× on the looped
|
|
431
|
+
variants).
|
|
432
|
+
- **`PMF.branch()` assembles its Bernoulli mixture in a single pass** rather than
|
|
433
|
+
chaining two `addScaled` calls (which copied the failure branch's bins twice).
|
|
434
|
+
- **`keepSumPMF` packs its DP state into a single integer key** instead of a
|
|
435
|
+
`"used|r"` string parsed on every transition.
|
|
436
|
+
- **`computeMaxOfPMF` walks the support once with a running CDF** for large pools,
|
|
437
|
+
reducing the max-of computation from O(N²) to O(N).
|
|
438
|
+
- **`Dice.reroll()` uses a `Set` for membership** and **`Dice.binaryOp()` hoists
|
|
439
|
+
the inner die's face list** out of its loop.
|
|
440
|
+
|
|
441
|
+
All of the above were verified bit-for-bit identical (probabilities, counts,
|
|
442
|
+
means, variance) across the full expression corpus.
|
|
443
|
+
|
|
444
|
+
### Changed
|
|
445
|
+
|
|
446
|
+
- Removed the stale `package-lock.json` (the project uses Yarn 4) and dropped the
|
|
447
|
+
unused `ts-node` / `tsconfig-paths` dev dependencies.
|
|
448
|
+
- Removed `console.error` calls from the parser so the library no longer writes to
|
|
449
|
+
a consumer's console.
|
|
450
|
+
- Internal refactors with no behavioral change: deduplicated `Bin` clone/scale
|
|
451
|
+
logic in `PMF`, removed dead code and impossible iterator branches, and tightened
|
|
452
|
+
internal `any` usage.
|
|
453
|
+
- `Dice.outcomeData` is typed `Partial<Record<OutcomeType, …>>` (dropping an
|
|
454
|
+
unsound `as Record<…>` cast); `getFullOutcomeDistribution()`'s return type
|
|
455
|
+
matches. Type-only change; runtime output is unchanged.
|
|
456
|
+
- Added a `yarn format` script (ESLint autofix).
|
package/README.md
CHANGED
|
@@ -121,6 +121,11 @@ src/
|
|
|
121
121
|
├── parser/ # String-based dice expression parser
|
|
122
122
|
│ ├── parser.ts # Main parser implementation
|
|
123
123
|
│ └── dice.ts # Dice class (legacy parser representation)
|
|
124
|
+
├── turn/ # Turns: attacks + conditional damage riders
|
|
125
|
+
│ ├── types.ts # Trigger, Rider, TurnSpec, TurnSpecError
|
|
126
|
+
│ ├── plan.ts # Spec validation and step/group resolution
|
|
127
|
+
│ ├── state.ts # Packed per-group trigger state
|
|
128
|
+
│ └── turn.ts # Turn class - exact joint distribution
|
|
124
129
|
├── pmf/ # Probability Mass Function core
|
|
125
130
|
│ ├── pmf.ts # PMF class - core data structure
|
|
126
131
|
│ ├── query.ts # DiceQuery - analysis interface
|
|
@@ -329,27 +334,172 @@ try {
|
|
|
329
334
|
}
|
|
330
335
|
```
|
|
331
336
|
|
|
332
|
-
|
|
337
|
+
For UI code that parses on every keystroke, `tryParse()` returns an empty PMF
|
|
338
|
+
instead of throwing, and accepts a bare integer — which the grammar rejects, but
|
|
339
|
+
a half-typed damage field is one for a keystroke or two:
|
|
333
340
|
|
|
334
|
-
|
|
341
|
+
```ts
|
|
342
|
+
import { tryParse } from "@yipe/dice";
|
|
343
|
+
|
|
344
|
+
tryParse("1d6 + 2").mean(); // 5.5
|
|
345
|
+
tryParse("-3").mean(); // -3 — signed integers, which the grammar rejects
|
|
346
|
+
tryParse("0x10").mass(); // 0 — decimal only
|
|
347
|
+
tryParse("1d").mass(); // 0 — empty PMF
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
The failure value has **mass 0**, not a distribution, and convolving it collapses
|
|
351
|
+
the whole result to mass 0 — check `mass()` or skip empties when combining
|
|
352
|
+
several expressions. Where a bad expression should surface rather than be
|
|
353
|
+
absorbed, use `parse()` and handle `DiceParseError`.
|
|
354
|
+
|
|
355
|
+
### Roll Types
|
|
356
|
+
|
|
357
|
+
`withRollType()` rewrites an expression's attack roll, leaving the damage, crit
|
|
358
|
+
and miss clauses alone — the usual way to chart one attack across advantage
|
|
359
|
+
states:
|
|
335
360
|
|
|
336
361
|
```ts
|
|
337
|
-
import {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
362
|
+
import { withRollType } from "@yipe/dice";
|
|
363
|
+
|
|
364
|
+
const attack = "(d20 + 8 AC 16) * (1d4 + 4) crit (2d4 + 4)";
|
|
365
|
+
|
|
366
|
+
withRollType(attack, "advantage"); // "(d20 > d20 + 8 AC 16) * (1d4 + 4) crit (2d4 + 4)"
|
|
367
|
+
withRollType(attack, "elven accuracy"); // "(d20 > d20 > d20 + 8 AC 16) * ..."
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Every attack roll is rewritten, so an expression holding several attacks is fully
|
|
371
|
+
converted, nesting and all. Each `d20` is resolved against the nearest enclosing
|
|
372
|
+
check: `AC` is an attack roll, while `DC` is the *target's* saving throw, which
|
|
373
|
+
the attacker's advantage does not affect. Saves therefore come back unchanged, as
|
|
374
|
+
does anything with no check at all, which makes this safe to map over a mixed
|
|
375
|
+
list. A halfling-luck `h` prefix is preserved.
|
|
376
|
+
|
|
377
|
+
### Damage Riders (Sneak Attack, Smite, Hunter's Mark)
|
|
378
|
+
|
|
379
|
+
Most of what makes 5e damage interesting is conditional: Sneak Attack needs *a* dagger to land,
|
|
380
|
+
Divine Smite wants a crit, a flurry of blows only happens if you didn't smite. A **`turn()`** is
|
|
381
|
+
attacks plus riders that fire based on what those attacks did.
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
import { turn, d20, d4, d6, roll } from "@yipe/dice/builder";
|
|
385
|
+
|
|
386
|
+
const dagger = d20.plus(8).ac(16).onHit(d4.plus(4));
|
|
387
|
+
|
|
388
|
+
const rogue = turn([dagger, dagger]).onFirstHit(roll(3, d6));
|
|
389
|
+
|
|
390
|
+
rogue.mean(); // 18.6225
|
|
391
|
+
rogue.pmf.pAt(0); // 0.1225 — chance the whole turn whiffs
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
That is the whole API for the common case. A `Turn` resolves the **exact joint distribution**: a
|
|
395
|
+
rider is correlated with the attacks that trigger it, so building one as a separate PMF and
|
|
396
|
+
convolving it in gets the right mean but the wrong shape — the example above would report a whiff
|
|
397
|
+
chance of 0.015 instead of 0.1225.
|
|
398
|
+
|
|
399
|
+
| method | fires | example |
|
|
400
|
+
|---|---|---|
|
|
401
|
+
| `onFirstHit` | once, on the first attack that lands — doubled if it crit | Sneak Attack |
|
|
402
|
+
| `onAnyCrit` | once, if any attack crit | Divine Smite |
|
|
403
|
+
| `onAnyMiss` | once, if any attack missed | Unerring Accuracy, Lucky |
|
|
404
|
+
| `onEveryHit` | once per attack that lands | Hunter's Mark, Hex, Rage |
|
|
405
|
+
| `otherwise` | when the rider before it did *not* | flurry of blows if you didn't smite |
|
|
406
|
+
|
|
407
|
+
#### Extra Attack
|
|
408
|
+
|
|
409
|
+
`attacks(count, source)` mirrors `roll(count, die)`, so the Fighter's four — or eight, with Action
|
|
410
|
+
Surge — stays one line:
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
const sword = d20.plus(9).ac(16).onHit(d6.plus(5));
|
|
414
|
+
|
|
415
|
+
turn().attacks(4, sword).onEveryHit(d6).mean(); // 35.0 — hunter's mark on each hit
|
|
416
|
+
turn().attacks(8, sword).onEveryHit(d6).mean(); // 70.0 — action surge
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
#### A rider can be anything that makes damage
|
|
420
|
+
|
|
421
|
+
Riders take the same builders attacks do, so "extra damage" and "an extra attack" are the same call.
|
|
422
|
+
A whole attack, a list of attacks, a flat bonus, or a saving throw all work:
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
const dagger = d20.plus(8).ac(16).onHit(d4.plus(4));
|
|
426
|
+
const sword = d20.plus(9).ac(16).onHit(d6.plus(5));
|
|
427
|
+
const greatsword = d20.plus(9).ac(16).onHit(roll(2, d6).plus(5));
|
|
428
|
+
const unarmed = d20.plus(8).ac(16).onHit(d6.plus(4));
|
|
429
|
+
const poison = d20.dc(13).onSaveFailure(roll(3, d6)).saveHalf();
|
|
430
|
+
|
|
431
|
+
turn([greatsword, greatsword]).onAnyCrit(greatsword); // Great Weapon Master's bonus attack
|
|
432
|
+
turn([dagger]).onFirstHit(poison); // hit, then the target saves
|
|
433
|
+
turn([sword, sword]).onEveryHit(flat(2)); // Rage
|
|
434
|
+
turn([dagger, dagger]).onAnyCrit(roll(4, d8)).otherwise([unarmed, unarmed]); // smite, or flurry
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
#### The hard build
|
|
438
|
+
|
|
439
|
+
A goliath rogue/monk/paladin, every trigger at once:
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
const goliath = turn([dagger, dagger])
|
|
443
|
+
.onFirstHit(roll(3, d6)) // sneak attack
|
|
444
|
+
.onFirstHit(d10) // fire's burn
|
|
445
|
+
.onAnyCrit(roll(2, d8)) // divine smite
|
|
446
|
+
.otherwise([unarmed, unarmed]) // flurry of blows, if the smite didn't happen
|
|
447
|
+
.onEveryHit(d6); // hunter's mark
|
|
448
|
+
|
|
449
|
+
goliath.mean(); // 39.5903
|
|
450
|
+
goliath.toQuery().damageAttributionChartModel();
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Two things that would be easy to get wrong are handled for you. Riders sharing a trigger resolve
|
|
454
|
+
**jointly** — sneak attack and fire's burn fire together or not at all, which shows up in the spread
|
|
455
|
+
even though it never moves the mean. And `otherwise()` binds to the rider immediately before it, so
|
|
456
|
+
the smite and the flurry are two branches of one decision and can never both land.
|
|
457
|
+
|
|
458
|
+
#### Asking questions
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
const t = turn([dagger, dagger]).onFirstHit(roll(3, d6));
|
|
462
|
+
|
|
463
|
+
t.mean(); // 18.6225
|
|
464
|
+
t.pmf.pAt(0); // 0.1225 — P(whiff)
|
|
465
|
+
t.pmf.stdev(); // 8.8860
|
|
466
|
+
t.toQuery().probTotalAtLeast(20); // 0.5062 — P(20+ damage)
|
|
467
|
+
t.toQuery().percentiles([0.25, 0.5, 0.75]); // [15, 20, 24]
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
#### Ids, errors, and plain data
|
|
471
|
+
|
|
472
|
+
Nothing above needs an `id`: attacks and riders get `attack 1`, `rider 2`, … in declaration order,
|
|
473
|
+
and `otherwise()` finds its own target. Name a rider when you want to ask about it afterwards:
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
const paladin = turn([dagger, dagger]).onAnyCrit(roll(2, d8), { id: "smite" });
|
|
477
|
+
|
|
478
|
+
paladin.fireProbability("smite"); // 0.0975
|
|
479
|
+
paladin.attackIds; // ["attack 1", "attack 2"]
|
|
480
|
+
paladin.riderIds; // ["smite"]
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
Every construction path validates immediately and throws a `TurnSpecError` whose `code` —
|
|
484
|
+
`unknown-id`, `cycle`, `not-an-attack`, `duplicate-id`, `self-reference`, `unused-crit-damage`,
|
|
485
|
+
`too-many-groups` — maps
|
|
486
|
+
straight onto a UI field state. A bad `of` fails at the call that introduced it, not later at
|
|
487
|
+
`.mean()`.
|
|
488
|
+
|
|
489
|
+
Each `onX` method takes an optional `{ id, of, critDamage }`, where `of` picks which attacks the
|
|
490
|
+
rider watches and defaults to all of them. All of them are sugar over `rider()`, which takes the
|
|
491
|
+
trigger as plain data — and `Trigger` is JSON-safe, so a UI can persist one and hand it straight
|
|
492
|
+
back:
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
import { Turn, d20, d4, d6, roll } from "@yipe/dice/builder";
|
|
496
|
+
|
|
497
|
+
const dagger = d20.plus(8).ac(16).onHit(d4.plus(4));
|
|
351
498
|
|
|
352
|
-
|
|
499
|
+
const fromUI = Turn.from({
|
|
500
|
+
attacks: [{ id: "dagger 1", source: dagger }, { id: "dagger 2", source: dagger }],
|
|
501
|
+
riders: [{ id: "sneak", damage: roll(3, d6), on: "first-hit" }],
|
|
502
|
+
});
|
|
353
503
|
```
|
|
354
504
|
|
|
355
505
|
### Statistics and Charts
|
|
@@ -386,7 +536,7 @@ This repository includes example scripts:
|
|
|
386
536
|
```bash
|
|
387
537
|
yarn example basic
|
|
388
538
|
yarn example stats
|
|
389
|
-
yarn example
|
|
539
|
+
yarn example turn
|
|
390
540
|
yarn example misc
|
|
391
541
|
```
|
|
392
542
|
|
|
@@ -467,7 +617,7 @@ This enables rich statistics like "how much damage comes from crits vs hits".
|
|
|
467
617
|
## 🧱 Roadmap
|
|
468
618
|
|
|
469
619
|
- [ ] Create a **web playground** with live examples
|
|
470
|
-
- [
|
|
620
|
+
- [x] Higher-level `Turn` API for conditional damage riders (0.9.0)
|
|
471
621
|
- [ ] Add more comprehensive 5e rule examples
|
|
472
622
|
- [ ] Performance improvements for DPR-only calculations
|
|
473
623
|
- [ ] Multi-round and sustained vs nova simulations
|