@yipe/dice 0.3.0 → 0.4.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,224 @@
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.4.0]
9
+
10
+ Pushes damage-attribution / provenance and D&D-probability logic that the
11
+ consuming app (dprcalc) had hand-rolled over PMF internals down into the
12
+ library, so the provenance model and dice math stay owned here. All additive
13
+ except the Elemental-Adept bounce fix noted below.
14
+
15
+ ### Added
16
+
17
+ - **`PMF.applyHitFrequency(frequency)`** — provenance-preserving mass
18
+ redistribution for effects that only occur with some probability (conditional
19
+ attacks, on-hit riders, sub-one AoE fractions): scales every hit bin (damage
20
+ > 0) by `frequency` and moves the freed mass into a `missNone` bin. Unlike a
21
+ bare `scaleMass`/`mapDamage`, it scales per-label `count` **and** `attr`, so a
22
+ frequency-scaled PMF still renders correctly in the damage-attribution charts.
23
+ Replaces the app's hand-rolled `applyFrequencyToPMF`, which dropped `attr`.
24
+ - **`PMF.missNone(epsilon?)`** / **`MISS_NONE_OUTCOME`** — canonical "clean miss"
25
+ delta (point mass at 0 tagged with the `missNone` `OutcomeType`, distinct from
26
+ `PMF.zero`'s builder-side `miss` label), and the label as a single source of
27
+ truth.
28
+ - **`PMF.hitProbability()` / `PMF.missProbability()`** — the `1 - P(0)` idiom
29
+ (miss encoded at damage 0), centralized.
30
+ - **`PMF.rebin(maxBuckets)`** — coarsen a wide distribution into ≤ N contiguous
31
+ equal-width buckets, aggregating `count`/`attr` provenance. For charting wide
32
+ distributions, not DPR math.
33
+ - **`PMF.attributionByValue()` / `DiceQuery.attributionByValue()`** — split each
34
+ damage value's probability mass across outcome labels (by `attr` for
35
+ damage-bearing bins, by `count` for the clean-miss bin), returning per-label
36
+ `value → mass` series. The provenance core of the stacked attribution chart.
37
+ - **`DiceQuery.countSinglesWith(label)`** — how many independent single PMFs can
38
+ produce a given outcome label.
39
+ - **`ALL_OUTCOME_TYPES`**, **`OUTCOME_DISPLAY_ORDER`**, **`sortOutcomes()`** —
40
+ canonical `OutcomeType` enumeration + stack / display orderings, replacing
41
+ per-consumer outcome tables.
42
+ - **`critProbability(critRange, rollType)`** and **`RollType`** (now exported
43
+ from the package root as well as `@yipe/dice/builder`) — advantage-aware
44
+ P(crit) for a given crit window.
45
+ - **`calculateBounceOdds(diceCount, dieFaces, options?)`** and
46
+ **`BounceOddsOptions`** — the "birthday problem" for bouncing damage dice
47
+ (Chromatic Orb), honoring Elemental Adept and Empowered Spell. Moved out of the
48
+ app; the base and Elemental-Adept cases are now computed **exactly** (verified
49
+ against brute-force enumeration in `tests/bounce.test.ts`).
50
+
51
+ ### Fixed
52
+
53
+ - **`calculateBounceOdds` Elemental Adept was approximate.** The former
54
+ hand-derived adjustment factor drifted from the exact value by up to ~3.5%
55
+ (e.g. 3×d8, min-roll 3: 0.4965 → 0.5313). The Elemental-Adept branch now uses
56
+ an exact elementary-symmetric-polynomial computation. Consumers relying on the
57
+ old numbers for bouncing spells with Elemental Adept will see small DPR shifts.
58
+ - **`calculateBounceOdds` Empowered Spell returned certainty when rerolling all
59
+ dice.** When `rerollDamageDice >= diceCount` (no dice kept), the model claimed
60
+ a guaranteed match (1.0) instead of treating the reroll as a second
61
+ independent roll. It now correctly yields `1 - (1 - pMatch)^2` in that case
62
+ (e.g. 3×d8 reroll-all: 1.0 → 0.5693).
63
+
64
+ ## [0.3.0]
65
+
66
+ ### Fixed (mathematical correctness)
67
+
68
+ Every fix is verified against an independent brute-force enumeration (see
69
+ `tests/math-correctness.test.ts`).
70
+
71
+ - **`DiceQuery.probabilityOf(label)` over-counted.** It summed the full `bin.p`
72
+ of every combined bin that merely *contained* a label, but bins hold multiple
73
+ mutually-exclusive outcomes — so `probabilityOf('crit')` returned 0.49 where
74
+ the true P(crit)=0.05. It now returns the correct Poisson-binomial marginal
75
+ (= `probAtLeastOne`). `missChance()` is fixed by the same change.
76
+ - **`DiceQuery.probExactlyK([labels], k)` array-path** delegated to the buggy
77
+ `probabilityOf`, disagreeing with the (correct) single-label string path; both
78
+ now match the true binomial.
79
+ - **`DiceQuery.variance()/stddev()`** used the unstable `E[X²]−E[X]²` form and
80
+ lost all precision under a large constant damage offset (`1d6 + 1e8` gave
81
+ variance 2 instead of 35/12). Now uses the centered, additive-per-single form.
82
+ - **`DiceQuery.mean()/variance()`** now stay consistent with an explicitly
83
+ supplied `combined` that diverges from `convolve(singles)`.
84
+ - **`PMF.convolve()` produced `NaN`** for a zero-mass operand (divide-by-zero in
85
+ the mass rescale), silently poisoning `DiceQuery.combined`. A zero-mass
86
+ convolution now correctly yields mass 0.
87
+ - **`probAtLeastOne` is now mass-invariant** (per-attack probability divided by
88
+ the single's mass) and clamped to `[0,1]` (was returning `1.0000000002`).
89
+ - **`PMF.firstSuccessWeights`** throws on `pSpecial > pSuccess` instead of
90
+ returning out-of-range probabilities.
91
+ - **Parser `hd6`/`hd20` (reroll-one)** used a weighted union giving
92
+ `P(1)=1/(2s−1)`; now uses `reroll(1)` for the correct `P(1)=1/s²`. The parser
93
+ `hd` distribution now matches the builder's `reroll(1)` exactly (the
94
+ previously loosened tests are tightened).
95
+ - **`DiceQuery.snapshot()` outcome probabilities** (`atLeastOneProbability`,
96
+ `allProbability`) were aggregated as expected counts and could exceed 1 for
97
+ multi-attack queries. They now use the correct Poisson-binomial marginals
98
+ (P(≥1) and P(all)) and are always in [0,1]. (`damageRange.avg` remains a
99
+ size-biased mean for N≥2 — see Known limitations.)
100
+ - **Parser save-for-half mislabeled outcomes** on odd/constant damage (e.g.
101
+ `(d20 DC 15) * (3) save half`): the brittle "2×half ∈ hit" detection
102
+ false-negatived, tagging the success mass as `saveFail` and the failure mass
103
+ as `hit`. Detection is now deterministic (the presence of a save distribution),
104
+ so `saveHalf`/`saveFail` are always labeled correctly.
105
+ - **`PMF.compact()` corrupted PMFs that shared bin objects.** It deleted
106
+ sub-epsilon `count`/`attr` entries *in place* and reused that same bin
107
+ reference in the compacted map. Because bins are shared by reference across
108
+ PMFs (the `branch()` / `addScaled()` / `scaleMass()` fast paths can carry
109
+ another PMF's bin objects), this silently mutated the source PMF — and the
110
+ receiver's own bins. `compact()` now clones each surviving bin before pruning;
111
+ the compacted result is unchanged.
112
+
113
+ ### Security / hardening
114
+
115
+ - **Parser resource-exhaustion guards.** Adversarial expressions are rejected
116
+ with a `DiceParseError` instead of exhausting CPU/memory: a die over 1,000,000
117
+ faces, a dice count over 10,000, a keep whose `faces^count` enumeration would
118
+ exceed 1,000,000 outcomes, and a binary operation whose `faces₁ × faces₂` work
119
+ would exceed 100,000,000 face pairs. The last closes a gap the per-operand
120
+ caps missed — two individually-legal large dice (e.g. `d100000 + d100000`,
121
+ ~10¹⁰ operations) previously hung for tens of seconds. All legitimate
122
+ expressions, including `d100000`, still parse.
123
+
124
+ ### Known limitations (documented; recommend maintainer review)
125
+
126
+ These are real but require API/architecture decisions, so they are documented
127
+ and pinned by tests rather than changed blindly:
128
+
129
+ - **Parser crit probability with bonus to-hit dice is wrong.** With bonus dice in
130
+ the to-hit (e.g. Bless, `d20 + 5 + 1d4`), the string parser collapses crit to
131
+ `1/(20·∏bonusSides)` (and the DPR is off by a few %), because the natural-20
132
+ slice can't be separated after the bonus dice are convolved. **The builder API
133
+ computes it correctly** — use `d20.plus(..).plus(bonusDie).ac(..).onCrit(..)`.
134
+ - **Multi-attack conditional damage `avg` is size-biased.** The `avg` returned by
135
+ `damageStatsFrom()` (single label), `outcomeDamageRanges()` and
136
+ `snapshot().damageRange` aggregates the combined PMF's `count` (an *expected
137
+ count* for N≥2 attacks), so it is the size-biased mean E[dmg·#label]/E[#label]
138
+ rather than a clean conditional expectation. It is correct for a single attack.
139
+ (The associated *probabilities* are now correct — see Fixed.)
140
+ - **`PMF.mixN`/`gate`/`branch` build O(2ⁿ) identifier strings**, which can blow up
141
+ (multi-MB, eventual `RangeError`) for very deep (≈20+) gate chains. Prefer
142
+ `PMF.exclusive`/`PMF.mix` for large mixtures.
143
+
144
+ ### Breaking
145
+
146
+ - **`PMF.toJSON()` now returns a plain object** (`{ bins, normalized, identifier }`)
147
+ instead of a JSON string, following the standard `toJSON` contract. This means
148
+ `JSON.stringify(pmf)` no longer double-encodes. If you relied on the old string
149
+ return, call the new `PMF.toJSONString()` instead.
150
+ - **`DiceQuery.firstSuccessSplit()` is typed as `OutcomeType | OutcomeType[]`**
151
+ (previously `string | string[]`). Only affects callers passing arbitrary strings;
152
+ valid outcome labels are unchanged.
153
+
154
+ ### Added
155
+
156
+ - **`DiceParseError`** — `parse()` now throws this typed error (a subclass of
157
+ `Error`) instead of a plain `Error`. Existing `try/catch` and message checks keep
158
+ working; you can now narrow with `instanceof DiceParseError` and read
159
+ `error.expression` / `error.cause`.
160
+ - **`PMF.toJSONString()`** — returns the JSON string form (the previous
161
+ `toJSON()` behavior).
162
+ - **`DiceQuery.stdev()`** — alias of `stddev()`, matching `PMF.stdev()`.
163
+ - **`PMF.hasAttribution()`** — O(1) check for whether a PMF already carries
164
+ damage-attribution metadata.
165
+
166
+ ### Performance
167
+
168
+ - **`DiceQuery.mean()` / `variance()` / `stddev()` use moment additivity**
169
+ (`E[ΣX]=ΣE[X]`, `Var[ΣX]=ΣVar[X]`) computed directly from the single PMFs.
170
+ - **`DiceQuery.combined` is now built lazily** (on first access) instead of in
171
+ the constructor. Combined with the above, a query used only for DPR / mean /
172
+ variance never performs the N-way convolution — multi-attack stats-only
173
+ queries are ~10000× faster (e.g. ~17 ms → ~0.001 ms for a heavy 4-attack
174
+ expression). The materialized `combined` distribution is unchanged; mean and
175
+ variance may differ from the previous convolution-based values by at most a
176
+ few ULP (well within the library's tolerances).
177
+ - **`DiceQuery.combinedWithAttribution()` reuses `combined`** when every single
178
+ already carries attribution (as parser-generated PMFs do), avoiding a
179
+ redundant convolution pass. Result is bit-for-bit identical.
180
+ - **`PMF.convolve()` inner loop accumulates directly into destination bins**
181
+ instead of allocating a temporary bin per term and merging — ~1.6× faster
182
+ convolution (the cost of building the combined distribution for charts). The
183
+ probability channel is bit-identical; per-label `count`/`attr` provenance may
184
+ re-associate by at most a few ULP (≤1e-14 even at 16 attacks, ~100× below the
185
+ eps pruning threshold).
186
+ - **Convolution cache-key fingerprint is memoized** on each (immutable) PMF
187
+ instead of re-summing every bin key on every `convolve()` call — ~36% faster
188
+ on warm cache hits. Bit-identical (`PMF.fingerprint()` returns the same string).
189
+ - **`Dice.calculateHitDistribution()` no longer clones outcome distributions per
190
+ face** — it reads the stored maps once instead of `O(faces × outcomes)` clones,
191
+ ~10% faster cold parsing of wide-support expressions. Bit-identical.
192
+ - **`DiceQuery.toStackedChartData()` drops a dead `O(N×L)` precomputation pass**
193
+ whose result was discarded — ~2× faster. Bit-identical.
194
+ - Minor bit-identical cleanups on the parse path (`Dice.toPMF` iterates the
195
+ internal face map directly; `multiplyDiceByDice` uses a `Map`).
196
+ - **`DiceQuery` count queries (`probExactlyK` / `probAtLeastK` / `probAtMostK`,
197
+ array-label paths)** compute each attack's success probability and the binomial
198
+ DP once instead of rebuilding a query per requested count (~3× on the looped
199
+ variants).
200
+ - **`PMF.branch()` assembles its Bernoulli mixture in a single pass** rather than
201
+ chaining two `addScaled` calls (which copied the failure branch's bins twice).
202
+ - **`keepSumPMF` packs its DP state into a single integer key** instead of a
203
+ `"used|r"` string parsed on every transition.
204
+ - **`computeMaxOfPMF` walks the support once with a running CDF** for large pools,
205
+ reducing the max-of computation from O(N²) to O(N).
206
+ - **`Dice.reroll()` uses a `Set` for membership** and **`Dice.binaryOp()` hoists
207
+ the inner die's face list** out of its loop.
208
+
209
+ All of the above were verified bit-for-bit identical (probabilities, counts,
210
+ means, variance) across the full expression corpus.
211
+
212
+ ### Changed
213
+
214
+ - Removed the stale `package-lock.json` (the project uses Yarn 4) and dropped the
215
+ unused `ts-node` / `tsconfig-paths` dev dependencies.
216
+ - Removed `console.error` calls from the parser so the library no longer writes to
217
+ a consumer's console.
218
+ - Internal refactors with no behavioral change: deduplicated `Bin` clone/scale
219
+ logic in `PMF`, removed dead code and impossible iterator branches, and tightened
220
+ internal `any` usage.
221
+ - `Dice.outcomeData` is typed `Partial<Record<OutcomeType, …>>` (dropping an
222
+ unsound `as Record<…>` cast); `getFullOutcomeDistribution()`'s return type
223
+ matches. Type-only change; runtime output is unchanged.
224
+ - Added a `yarn format` script (ESLint autofix).
@@ -44,6 +44,7 @@ var LRUCache = class {
44
44
 
45
45
  // src/common/types.ts
46
46
  var EPS = 1e-12;
47
+ var MISS_NONE_OUTCOME = "missNone";
47
48
 
48
49
  // src/pmf/query.ts
49
50
  var _DiceQuery = class _DiceQuery {
@@ -106,6 +107,29 @@ var _DiceQuery = class _DiceQuery {
106
107
  this._combinedWithAttr = normalized;
107
108
  return normalized;
108
109
  }
110
+ /**
111
+ * Per-label `damage value → probability mass` series for the combined,
112
+ * attribution-carrying distribution — the provenance core of the stacked
113
+ * damage-attribution chart. Convenience for
114
+ * `combinedWithAttribution().attributionByValue()`; see
115
+ * {@link PMF.attributionByValue}.
116
+ */
117
+ attributionByValue() {
118
+ return this.combinedWithAttribution().attributionByValue();
119
+ }
120
+ /**
121
+ * How many of the independent single PMFs can produce the given outcome
122
+ * label. Useful for "all of them succeeded" style probabilities where the
123
+ * exponent is the number of contributing attacks (see
124
+ * {@link DiceQuery.probExactlyK}).
125
+ */
126
+ countSinglesWith(label) {
127
+ let count = 0;
128
+ for (const single of this.singles) {
129
+ if (single.hasOutcome(label)) count++;
130
+ }
131
+ return count;
132
+ }
109
133
  /**
110
134
  * Returns the expected damage across all possible outcomes.
111
135
  *
@@ -1389,6 +1413,20 @@ var _PMF = class _PMF {
1389
1413
  static delta(value, epsilon = EPS) {
1390
1414
  return _PMF.fromMap(/* @__PURE__ */ new Map([[value, 1]]), epsilon);
1391
1415
  }
1416
+ /**
1417
+ * Point mass at damage 0 tagged with the canonical `missNone` outcome.
1418
+ *
1419
+ * Differs from {@link PMF.zero}, which labels its zero bin `miss` — the
1420
+ * builder's attack-resolution vocabulary. This uses the `missNone`
1421
+ * {@link OutcomeType} that the attribution charts and outcome stats key on,
1422
+ * so it is the correct "clean miss / no damage" delta for provenance-aware
1423
+ * mixtures feeding those consumers.
1424
+ */
1425
+ static missNone(epsilon = EPS) {
1426
+ const m = /* @__PURE__ */ new Map();
1427
+ m.set(0, { p: 1, count: { [MISS_NONE_OUTCOME]: 1 }, attr: {} });
1428
+ return new _PMF(m, epsilon, false, "missNone");
1429
+ }
1392
1430
  // This creates a single bin at value 0, but with weight 0.
1393
1431
  static emptyMass() {
1394
1432
  return _PMF.zero().scaleMass(0);
@@ -1910,6 +1948,49 @@ var _PMF = class _PMF {
1910
1948
  `${this.identifier}+scaled(${branch.identifier},${probability})`
1911
1949
  );
1912
1950
  }
1951
+ /**
1952
+ * Redistributes probability mass to model an effect that only occurs with
1953
+ * probability `frequency` — a conditional attack, an on-hit rider, or a
1954
+ * sub-one AoE target fraction.
1955
+ *
1956
+ * Every hit outcome (damage > 0) is scaled by `frequency` — probability mass,
1957
+ * per-label `count`, AND per-label `attr` — and the freed mass is moved into
1958
+ * the miss bin at damage 0, tagged with the canonical `missNone` outcome.
1959
+ * Total probability mass is preserved.
1960
+ *
1961
+ * Unlike a bare {@link scaleMass} or {@link mapDamage}, this keeps damage
1962
+ * attribution (`attr`) intact, so a frequency-scaled PMF still renders
1963
+ * correctly in the damage-attribution charts.
1964
+ *
1965
+ * `frequency >= 1` (or non-finite) returns this PMF unchanged; `frequency <= 0`
1966
+ * collapses all mass into the miss bin. The miss outcome is assumed to be
1967
+ * encoded at damage value 0.
1968
+ *
1969
+ * @param frequency Probability in [0, 1] that the effect occurs.
1970
+ */
1971
+ applyHitFrequency(frequency) {
1972
+ if (!Number.isFinite(frequency) || frequency >= 1) return this;
1973
+ const freq = Math.max(0, frequency);
1974
+ const pMiss = this.pAt(0);
1975
+ const pHit = 1 - pMiss;
1976
+ const newMissMass = pMiss + (1 - freq) * pHit;
1977
+ const newMap = /* @__PURE__ */ new Map();
1978
+ newMap.set(0, {
1979
+ p: newMissMass,
1980
+ count: { [MISS_NONE_OUTCOME]: newMissMass },
1981
+ attr: {}
1982
+ });
1983
+ for (const [damage, bin] of this.map) {
1984
+ if (damage <= 0) continue;
1985
+ newMap.set(damage, _PMF.scaleBin(bin, freq));
1986
+ }
1987
+ return new _PMF(
1988
+ newMap,
1989
+ this.epsilon,
1990
+ false,
1991
+ `freq(${this.identifier},${freq})`
1992
+ );
1993
+ }
1913
1994
  scaleMass(factor) {
1914
1995
  if (factor === 1) return this;
1915
1996
  const scaledMap = /* @__PURE__ */ new Map();
@@ -2137,6 +2218,39 @@ var _PMF = class _PMF {
2137
2218
  pAt(x) {
2138
2219
  return this.map.get(x)?.p ?? 0;
2139
2220
  }
2221
+ /**
2222
+ * P(any damage) — the mass on all non-zero outcomes, i.e. `1 - P(0)`.
2223
+ * Assumes a miss is encoded as the damage-0 bin (the convention used across
2224
+ * attack/save PMFs). The dual of {@link missProbability}.
2225
+ */
2226
+ hitProbability() {
2227
+ return 1 - this.pAt(0);
2228
+ }
2229
+ /** P(no damage) — the mass at damage 0. The dual of {@link hitProbability}. */
2230
+ missProbability() {
2231
+ return this.pAt(0);
2232
+ }
2233
+ /**
2234
+ * Coarsen the distribution into at most `maxBuckets` contiguous, equal-width
2235
+ * damage buckets, aggregating probability mass (and `count`/`attr`
2236
+ * provenance) into each bucket's start value. Returns this PMF unchanged when
2237
+ * its integer support already fits within `maxBuckets`.
2238
+ *
2239
+ * This is a lossy display/downsampling transform (bucket start replaces the
2240
+ * exact damage value) — use it for charting wide distributions, not for DPR
2241
+ * math.
2242
+ */
2243
+ rebin(maxBuckets) {
2244
+ if (!(maxBuckets > 0)) return this;
2245
+ const support = this.support();
2246
+ if (support.length === 0) return this;
2247
+ const min = support[0];
2248
+ const max = support[support.length - 1];
2249
+ const range = max - min;
2250
+ if (range + 1 <= maxBuckets) return this;
2251
+ const binSize = Math.ceil((range + 1) / maxBuckets);
2252
+ return this.mapDamage((d2) => min + Math.floor((d2 - min) / binSize) * binSize);
2253
+ }
2140
2254
  /** Dense integer support from min..max (inclusive).
2141
2255
  * Useful for showing empty bars in charts.
2142
2256
  */
@@ -2212,6 +2326,56 @@ var _PMF = class _PMF {
2212
2326
  }
2213
2327
  return false;
2214
2328
  }
2329
+ /**
2330
+ * Split each damage value's probability mass across outcome labels, returning
2331
+ * per-label maps of `damage value → probability mass attributable to that
2332
+ * label`. Summing over labels at a given value recovers that value's `p`.
2333
+ *
2334
+ * Damage-bearing bins are split by `attr` weight (the share of damage each
2335
+ * outcome contributed); the clean-miss bin at 0 is split by `count` weight
2336
+ * (there is no damage to attribute). Attribution is computed on demand via
2337
+ * {@link withAttribution} when absent, so builder-generated PMFs work too.
2338
+ *
2339
+ * This is the provenance core of the stacked damage-attribution chart — the
2340
+ * caller only maps these series into its rendering format (colors, binning,
2341
+ * axis labels).
2342
+ */
2343
+ attributionByValue() {
2344
+ const src = this.hasAttribution() ? this : this.withAttribution();
2345
+ const result = /* @__PURE__ */ new Map();
2346
+ const add = (label, damage, mass) => {
2347
+ if (!(mass > 0)) return;
2348
+ let series = result.get(label);
2349
+ if (!series) {
2350
+ series = /* @__PURE__ */ new Map();
2351
+ result.set(label, series);
2352
+ }
2353
+ series.set(damage, (series.get(damage) ?? 0) + mass);
2354
+ };
2355
+ for (const [damage, bin] of src.map) {
2356
+ const p = bin.p || 0;
2357
+ if (p <= 0) continue;
2358
+ const isMissBin = damage === 0;
2359
+ if (isMissBin) {
2360
+ let totalCount = 0;
2361
+ for (const k in bin.count) totalCount += bin.count[k] || 0;
2362
+ if (totalCount > 0) {
2363
+ const c = bin.count[MISS_NONE_OUTCOME] || 0;
2364
+ add(MISS_NONE_OUTCOME, damage, c / totalCount * p);
2365
+ }
2366
+ continue;
2367
+ }
2368
+ let totalAttr = 0;
2369
+ if (bin.attr) for (const k in bin.attr) totalAttr += bin.attr[k] || 0;
2370
+ if (bin.attr && totalAttr > 0) {
2371
+ for (const k in bin.attr) {
2372
+ if (k === MISS_NONE_OUTCOME) continue;
2373
+ add(k, damage, (bin.attr[k] || 0) / totalAttr * p);
2374
+ }
2375
+ }
2376
+ }
2377
+ return result;
2378
+ }
2215
2379
  tailProbGE(t) {
2216
2380
  let s = 0;
2217
2381
  for (const [x, bin] of this) {