@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 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
- ### Sneak Attack (Conditional Damage)
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
- Conditional damage ("once-per-turn damage riders") like Sneak Attack can be modeled easily:
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 { DiceQuery, PMF, roll } from "@yipe/dice";
338
-
339
- function sneakAttack() {
340
- const attackPMF = d20.plus(8).ac(16).onHit(d4.plus(4)).pmf;
341
- const sneakAttack = roll(3, d6);
342
- const attacks = new DiceQuery([attackPMF, attackPMF]);
343
- const [pHit, pCrit] = attacks.firstSuccessSplit(onAnyHit, onCritOnly);
344
- const sneakPMF = PMF.exclusive([
345
- [sneakAttack.pmf, pHit],
346
- [sneakAttack.doubleDice().pmf, pCrit],
347
- ]);
348
-
349
- return new DiceQuery([attackPMF, attackPMF, sneakPMF]);
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
- console.log("DPR with once-per-turn sneak attack: ", sneakAttack().mean());
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 sneakattack
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
- - [ ] Consider creating higher-level APIs: `Turn`, `Attack`, `DamageRider`
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