@yipe/dice 0.9.0 → 0.11.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.
Files changed (41) hide show
  1. package/dist/builder/ac.d.ts.map +1 -1
  2. package/dist/builder/ast.d.ts +32 -2
  3. package/dist/builder/ast.d.ts.map +1 -1
  4. package/dist/builder/attack.d.ts +19 -0
  5. package/dist/builder/attack.d.ts.map +1 -1
  6. package/dist/builder/dc.d.ts.map +1 -1
  7. package/dist/builder/example.d.ts +4 -4
  8. package/dist/builder/example.d.ts.map +1 -1
  9. package/dist/builder/index.cjs +614 -121
  10. package/dist/builder/index.cjs.map +1 -1
  11. package/dist/builder/index.js +614 -122
  12. package/dist/builder/index.js.map +1 -1
  13. package/dist/builder/nodes.d.ts +1 -0
  14. package/dist/builder/nodes.d.ts.map +1 -1
  15. package/dist/builder/roll.d.ts +13 -1
  16. package/dist/builder/roll.d.ts.map +1 -1
  17. package/dist/builder/save.d.ts.map +1 -1
  18. package/dist/builder/types.d.ts +1 -0
  19. package/dist/builder/types.d.ts.map +1 -1
  20. package/dist/common/bounce.d.ts +45 -0
  21. package/dist/common/bounce.d.ts.map +1 -1
  22. package/dist/common/types.d.ts +33 -0
  23. package/dist/common/types.d.ts.map +1 -1
  24. package/dist/index.cjs +235 -15
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.js +232 -16
  27. package/dist/index.js.map +1 -1
  28. package/dist/parser/dice.d.ts +14 -0
  29. package/dist/parser/dice.d.ts.map +1 -1
  30. package/dist/pmf/pmf.d.ts +22 -4
  31. package/dist/pmf/pmf.d.ts.map +1 -1
  32. package/dist/turn/plan.d.ts +29 -6
  33. package/dist/turn/plan.d.ts.map +1 -1
  34. package/dist/turn/state.d.ts +16 -8
  35. package/dist/turn/state.d.ts.map +1 -1
  36. package/dist/turn/turn.d.ts +35 -0
  37. package/dist/turn/turn.d.ts.map +1 -1
  38. package/dist/turn/types.d.ts +32 -10
  39. package/dist/turn/types.d.ts.map +1 -1
  40. package/package.json +2 -2
  41. package/CHANGELOG.md +0 -456
package/CHANGELOG.md DELETED
@@ -1,456 +0,0 @@
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).