@yipe/dice 0.2.23 → 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/dist/index.cjs CHANGED
@@ -1,14 +1,71 @@
1
1
  'use strict';
2
2
 
3
- var __defProp = Object.defineProperty;
4
- var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
5
- var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
3
+ // src/common/bounce.ts
4
+ function binom(n, k) {
5
+ if (k < 0 || k > n) return 0;
6
+ let result = 1;
7
+ for (let i = 0; i < k; i++) result = result * (n - i) / (i + 1);
8
+ return result;
9
+ }
10
+ function pAllDistinct(dice, faces, uniformCount, heavyWeight) {
11
+ const light = 1 / faces;
12
+ const eK = binom(uniformCount, dice) * Math.pow(light, dice) + heavyWeight * binom(uniformCount, dice - 1) * Math.pow(light, dice - 1);
13
+ let kFactorial = 1;
14
+ for (let i = 2; i <= dice; i++) kFactorial *= i;
15
+ return kFactorial * eK;
16
+ }
17
+ function pMatch(dice, faces, minimumDieRoll) {
18
+ if (dice <= 1) return 0;
19
+ if (dice > faces) return 1;
20
+ if (minimumDieRoll >= 2) {
21
+ const uniformCount = faces - minimumDieRoll;
22
+ const effectiveValues = uniformCount + 1;
23
+ if (dice > effectiveValues) return 1;
24
+ const heavyWeight = minimumDieRoll / faces;
25
+ const distinct = pAllDistinct(dice, faces, uniformCount, heavyWeight);
26
+ return Math.min(1, Math.max(0, 1 - distinct));
27
+ }
28
+ let pDistinct = 1;
29
+ for (let i = 0; i < dice; i++) pDistinct *= (faces - i) / faces;
30
+ return 1 - pDistinct;
31
+ }
32
+ function calculateBounceOdds(diceCount, dieFaces, options) {
33
+ if (diceCount <= 1) return 0;
34
+ if (diceCount > dieFaces) return 1;
35
+ const minimumDieRoll = options?.minimumDieRoll ?? 0;
36
+ const rerollDamageDice = options?.rerollDamageDice ?? 0;
37
+ const pMatchFirst = pMatch(diceCount, dieFaces, minimumDieRoll);
38
+ const rerollCount = Math.min(rerollDamageDice, diceCount);
39
+ if (rerollCount <= 0) return pMatchFirst;
40
+ const pNoMatchFirst = 1 - pMatchFirst;
41
+ const keptDice = diceCount - rerollCount;
42
+ const effectiveFaces = minimumDieRoll >= 2 ? dieFaces - (minimumDieRoll - 1) : dieFaces;
43
+ const pRerollDieMissesAll = keptDice > 0 ? Math.pow((effectiveFaces - keptDice) / effectiveFaces, rerollCount) : 1;
44
+ const pAtLeastOneRerollMatches = 1 - pRerollDieMissesAll;
45
+ const pRerolledMatch = rerollCount >= 2 ? pMatch(rerollCount, dieFaces, minimumDieRoll) : 0;
46
+ const pMatchAfterReroll = Math.min(
47
+ 1,
48
+ pAtLeastOneRerollMatches + pRerolledMatch * (1 - pAtLeastOneRerollMatches)
49
+ );
50
+ return Math.min(1, pMatchFirst + pNoMatchFirst * pMatchAfterReroll);
51
+ }
52
+
53
+ // src/common/errors.ts
54
+ var DiceParseError = class _DiceParseError extends Error {
55
+ constructor(message, options) {
56
+ super(message);
57
+ this.name = "DiceParseError";
58
+ this.expression = options?.expression;
59
+ this.cause = options?.cause;
60
+ Object.setPrototypeOf(this, _DiceParseError.prototype);
61
+ }
62
+ };
6
63
 
7
64
  // src/common/lru-cache.ts
8
65
  var LRUCache = class {
9
66
  constructor(maxSize = 1e3) {
10
67
  this.maxSize = maxSize;
11
- __publicField(this, "cache", /* @__PURE__ */ new Map());
68
+ this.cache = /* @__PURE__ */ new Map();
12
69
  }
13
70
  get(key) {
14
71
  const value = this.cache.get(key);
@@ -48,6 +105,50 @@ var LRUCache = class {
48
105
 
49
106
  // src/common/types.ts
50
107
  var EPS = 1e-12;
108
+ function critProbability(critRange, rollType = "flat") {
109
+ const base = critRange / 20;
110
+ switch (rollType) {
111
+ case "advantage":
112
+ return 1 - (1 - base) ** 2;
113
+ case "elven accuracy":
114
+ return 1 - (1 - base) ** 3;
115
+ case "disadvantage":
116
+ return base ** 2;
117
+ case "flat":
118
+ default:
119
+ return base;
120
+ }
121
+ }
122
+ var MISS_NONE_OUTCOME = "missNone";
123
+ var ALL_OUTCOME_TYPES = [
124
+ "missNone",
125
+ "missDamage",
126
+ "saveFail",
127
+ "saveHalf",
128
+ "pc",
129
+ "hit",
130
+ "crit"
131
+ ];
132
+ var OUTCOME_DISPLAY_ORDER = [
133
+ "crit",
134
+ "hit",
135
+ "missDamage",
136
+ "saveHalf",
137
+ "saveFail",
138
+ "pc",
139
+ "missNone"
140
+ ];
141
+ function sortOutcomes(outcomes, order = ALL_OUTCOME_TYPES) {
142
+ const rank = new Map(order.map((o, i) => [o, i]));
143
+ return [...outcomes].sort((a, b) => {
144
+ const ra = rank.get(a);
145
+ const rb = rank.get(b);
146
+ if (ra !== void 0 && rb !== void 0) return ra - rb;
147
+ if (ra !== void 0) return -1;
148
+ if (rb !== void 0) return 1;
149
+ return a.localeCompare(b);
150
+ });
151
+ }
51
152
  var onAnyHit = ["hit", "crit"];
52
153
  var onCritOnly = ["crit"];
53
154
  var onHitOnly = ["hit"];
@@ -60,15 +161,30 @@ var onPotentCantripOnly = ["pc"];
60
161
  // src/pmf/query.ts
61
162
  var _DiceQuery = class _DiceQuery {
62
163
  constructor(singles, combined, eps = EPS) {
63
- __publicField(this, "singles");
64
- __publicField(this, "combined");
65
- __publicField(this, "_combinedWithAttr");
66
164
  this.singles = Array.isArray(singles) ? singles : [singles];
67
165
  if (this.singles.some((s) => s === void 0)) {
68
166
  throw new Error("DiceQuery contains undefined singles");
69
167
  }
70
- const c = combined ?? PMF.convolveMany(this.singles);
71
- this.combined = Math.abs(c.mass() - 1) <= eps ? c : c.normalize();
168
+ this._eps = eps;
169
+ this._combinedProvided = combined !== void 0;
170
+ if (combined !== void 0) {
171
+ this._combined = Math.abs(combined.mass() - 1) <= eps ? combined : combined.normalize();
172
+ }
173
+ }
174
+ /**
175
+ * The combined damage distribution of all single PMFs (their convolution),
176
+ * normalized to total probability 1.
177
+ *
178
+ * Computed lazily on first access and cached. Queries that only need
179
+ * additive statistics — {@link DiceQuery.mean}, {@link DiceQuery.variance},
180
+ * {@link DiceQuery.stddev} — never trigger this convolution.
181
+ */
182
+ get combined() {
183
+ if (this._combined === void 0) {
184
+ const c = PMF.convolveMany(this.singles);
185
+ this._combined = Math.abs(c.mass() - 1) <= this._eps ? c : c.normalize();
186
+ }
187
+ return this._combined;
72
188
  }
73
189
  /**
74
190
  * Returns a new PMF with damage attribution metadata populated.
@@ -93,12 +209,39 @@ var _DiceQuery = class _DiceQuery {
93
209
  if (this._combinedWithAttr) {
94
210
  return this._combinedWithAttr;
95
211
  }
212
+ if (this.singles.every((pmf) => pmf.hasAttribution())) {
213
+ this._combinedWithAttr = this.combined;
214
+ return this._combinedWithAttr;
215
+ }
96
216
  const singlesWithAttr = this.singles.map((pmf) => pmf.withAttribution());
97
217
  const combined = PMF.convolveMany(singlesWithAttr, this.combined.epsilon);
98
218
  const normalized = Math.abs(combined.mass() - 1) <= this.combined.epsilon ? combined : combined.normalize();
99
219
  this._combinedWithAttr = normalized;
100
220
  return normalized;
101
221
  }
222
+ /**
223
+ * Per-label `damage value → probability mass` series for the combined,
224
+ * attribution-carrying distribution — the provenance core of the stacked
225
+ * damage-attribution chart. Convenience for
226
+ * `combinedWithAttribution().attributionByValue()`; see
227
+ * {@link PMF.attributionByValue}.
228
+ */
229
+ attributionByValue() {
230
+ return this.combinedWithAttribution().attributionByValue();
231
+ }
232
+ /**
233
+ * How many of the independent single PMFs can produce the given outcome
234
+ * label. Useful for "all of them succeeded" style probabilities where the
235
+ * exponent is the number of contributing attacks (see
236
+ * {@link DiceQuery.probExactlyK}).
237
+ */
238
+ countSinglesWith(label) {
239
+ let count = 0;
240
+ for (const single of this.singles) {
241
+ if (single.hasOutcome(label)) count++;
242
+ }
243
+ return count;
244
+ }
102
245
  /**
103
246
  * Returns the expected damage across all possible outcomes.
104
247
  *
@@ -106,11 +249,18 @@ var _DiceQuery = class _DiceQuery {
106
249
  * Use case: "What's my average damage per round?"
107
250
  */
108
251
  mean() {
109
- let totalSum = 0;
110
- for (const [damageValue, probabilityBin] of this.combined) {
111
- totalSum += damageValue * probabilityBin.p;
252
+ if (this._combinedProvided) {
253
+ let m = 0;
254
+ for (const [damageValue, bin] of this.combined) m += damageValue * bin.p;
255
+ return m;
256
+ }
257
+ let totalMean = 0;
258
+ for (const single of this.singles) {
259
+ const mass = single.mass();
260
+ if (mass <= 0) continue;
261
+ totalMean += Math.abs(mass - 1) <= this._eps ? single.mean() : single.mean() / mass;
112
262
  }
113
- return totalSum;
263
+ return totalMean;
114
264
  }
115
265
  /**
116
266
  * Returns the variance of the damage distribution.
@@ -120,13 +270,33 @@ var _DiceQuery = class _DiceQuery {
120
270
  * High variance means higher risk/reward. Lower variance means more consistent damage.
121
271
  */
122
272
  variance() {
123
- const meanValue = this.mean();
124
- let varianceSum = 0;
125
- for (const [damageValue, probabilityBin] of this.combined) {
126
- const deviationFromMean = damageValue - meanValue;
127
- varianceSum += deviationFromMean * deviationFromMean * probabilityBin.p;
273
+ if (this._combinedProvided) {
274
+ const mu = this.mean();
275
+ let v = 0;
276
+ for (const [damageValue, bin] of this.combined) {
277
+ const dev = damageValue - mu;
278
+ v += dev * dev * bin.p;
279
+ }
280
+ return v;
128
281
  }
129
- return varianceSum;
282
+ let totalVariance = 0;
283
+ for (const single of this.singles) {
284
+ const mass = single.mass();
285
+ if (mass <= 0) continue;
286
+ if (Math.abs(mass - 1) <= this._eps) {
287
+ totalVariance += single.variance();
288
+ } else {
289
+ let mu = 0;
290
+ for (const [d, b] of single) mu += d * (b.p / mass);
291
+ let v = 0;
292
+ for (const [d, b] of single) {
293
+ const dev = d - mu;
294
+ v += dev * dev * (b.p / mass);
295
+ }
296
+ totalVariance += v;
297
+ }
298
+ }
299
+ return totalVariance;
130
300
  }
131
301
  /**
132
302
  * Returns the standard deviation of the damage distribution.
@@ -138,6 +308,10 @@ var _DiceQuery = class _DiceQuery {
138
308
  stddev() {
139
309
  return Math.sqrt(this.variance());
140
310
  }
311
+ /** Alias of {@link DiceQuery.stddev}, matching {@link PMF.stdev}. */
312
+ stdev() {
313
+ return this.stddev();
314
+ }
141
315
  /**
142
316
  * Returns the Cumulative Distribution Function.
143
317
  */
@@ -228,20 +402,49 @@ var _DiceQuery = class _DiceQuery {
228
402
  return this.combined.max();
229
403
  }
230
404
  singleProb(diceIndex, label) {
405
+ const single = this.singles[diceIndex];
231
406
  let probabilitySum = 0;
232
- for (const [, probabilityBin] of this.singles[diceIndex]) {
407
+ for (const [, probabilityBin] of single) {
233
408
  probabilitySum += probabilityBin.count[label] || 0;
234
409
  }
235
- return probabilitySum;
410
+ const mass = single.mass();
411
+ return mass > 0 ? probabilitySum / mass : 0;
412
+ }
413
+ /**
414
+ * Full count distribution [P(0), P(1), …, P(n)] for "an attack succeeds if it
415
+ * carries ANY of `labels`", over the n independent singles.
416
+ *
417
+ * Each single's per-event success probability is the Poisson-binomial
418
+ * marginal P(≥1 of labels) from {@link probabilityOf} (i.e. probAtLeastOne),
419
+ * computed exactly once. The binomial DP then runs once to produce the whole
420
+ * distribution, so the array-label paths of probExactlyK / probAtLeastK /
421
+ * probAtMostK can slice or sum from it instead of rebuilding a DiceQuery and
422
+ * re-running the DP per requested k.
423
+ */
424
+ countDistribution(labels) {
425
+ const n = this.singles.length;
426
+ const successProbabilities = this.singles.map(
427
+ (single) => new _DiceQuery([single]).probabilityOf(labels)
428
+ );
429
+ const dist = new Array(n + 1).fill(0);
430
+ dist[0] = 1;
431
+ for (const successProb of successProbabilities) {
432
+ for (let outcomeCount = n; outcomeCount >= 1; outcomeCount--) {
433
+ dist[outcomeCount] = dist[outcomeCount] * (1 - successProb) + dist[outcomeCount - 1] * successProb;
434
+ }
435
+ dist[0] *= 1 - successProb;
436
+ }
437
+ return dist;
236
438
  }
237
439
  probAtLeastK(labels, k) {
238
440
  const L = Array.isArray(labels) ? [...new Set(labels)] : [labels];
239
441
  const n = this.singles.length;
240
442
  if (k <= 0) return 1;
241
443
  if (k > n) return 0;
444
+ const dist = this.countDistribution(L);
242
445
  let tail = 0;
243
446
  for (let i = k; i <= n; i++) {
244
- tail += this.probExactlyK(L, i);
447
+ tail += dist[i];
245
448
  }
246
449
  if (tail < 0) return 0;
247
450
  if (tail > 1) return 1;
@@ -273,9 +476,12 @@ var _DiceQuery = class _DiceQuery {
273
476
  for (const label of labels) {
274
477
  combinedProbability += this.singleProb(diceIndex, label);
275
478
  }
479
+ if (combinedProbability < 0) combinedProbability = 0;
480
+ else if (combinedProbability > 1) combinedProbability = 1;
276
481
  productOfNonOccurrence *= 1 - combinedProbability;
277
482
  }
278
- return 1 - productOfNonOccurrence;
483
+ const result = 1 - productOfNonOccurrence;
484
+ return result < 0 ? 0 : result > 1 ? 1 : result;
279
485
  }
280
486
  /**
281
487
  * Computes binomial probabilities for exactly 0, 1, 2, ..., maxK occurrences of a label.
@@ -316,7 +522,7 @@ var _DiceQuery = class _DiceQuery {
316
522
  * Array examples:
317
523
  * - probExactlyK(['hit', 'crit'], 2) = probability exactly 2 attacks succeed
318
524
  * - probExactlyK(['hit', 'crit'], 1) = probability exactly 1 attack succeeds
319
- * - probExactlyK(['miss', 'missNone'], 0) = probability no attacks miss
525
+ * - probExactlyK(['missDamage', 'missNone'], 0) = probability no attacks miss
320
526
  *
321
527
  * Use cases:
322
528
  * - "What's the chance exactly one of my attacks hits?"
@@ -331,19 +537,8 @@ var _DiceQuery = class _DiceQuery {
331
537
  const probabilityArray = this.computeBinomialProbabilities(labels, k);
332
538
  return probabilityArray[k];
333
539
  }
334
- const successProbabilities = this.singles.map((single) => {
335
- const singleQuery = new _DiceQuery([single]);
336
- return singleQuery.probabilityOf(labels);
337
- });
338
- const binomialProbs = new Array(k + 1).fill(0);
339
- binomialProbs[0] = 1;
340
- for (const successProb of successProbabilities) {
341
- for (let outcomeCount = k; outcomeCount >= 1; outcomeCount--) {
342
- binomialProbs[outcomeCount] = binomialProbs[outcomeCount] * (1 - successProb) + binomialProbs[outcomeCount - 1] * successProb;
343
- }
344
- binomialProbs[0] *= 1 - successProb;
345
- }
346
- return binomialProbs[k];
540
+ const dist = this.countDistribution(labels);
541
+ return k >= 0 && k < dist.length ? dist[k] : 0;
347
542
  }
348
543
  /**
349
544
  * Returns the probability that AT MOST K attacks result in the specified outcome(s).
@@ -351,7 +546,7 @@ var _DiceQuery = class _DiceQuery {
351
546
  * Single label examples:
352
547
  * - probAtMostK('hit', 1) = probability 0 or 1 attacks hit (at most 1)
353
548
  * - probAtMostK('crit', 0) = probability no attacks crit
354
- * - probAtMostK('miss', 2) = probability at most 2 attacks miss
549
+ * - probAtMostK('missDamage', 2) = probability at most 2 attacks miss
355
550
  *
356
551
  * Array examples:
357
552
  * - probAtMostK(['hit', 'crit'], 1) = probability at most 1 attack succeeds
@@ -372,9 +567,11 @@ var _DiceQuery = class _DiceQuery {
372
567
  }
373
568
  return cumulativeSum2;
374
569
  }
570
+ const dist = this.countDistribution(labels);
571
+ const upper = Math.min(k, dist.length - 1);
375
572
  let cumulativeSum = 0;
376
- for (let outcomeCount = 0; outcomeCount <= k; outcomeCount++) {
377
- cumulativeSum += this.probExactlyK(labels, outcomeCount);
573
+ for (let outcomeCount = 0; outcomeCount <= upper; outcomeCount++) {
574
+ cumulativeSum += dist[outcomeCount];
378
575
  }
379
576
  return cumulativeSum;
380
577
  }
@@ -420,7 +617,7 @@ var _DiceQuery = class _DiceQuery {
420
617
  *
421
618
  * Array examples:
422
619
  * - damageStatsFrom(['hit', 'crit']) = damage range when at least one attack succeeds
423
- * - damageStatsFrom(['miss', 'missNone']) = damage range when at least one attack misses
620
+ * - damageStatsFrom(['missDamage', 'missNone']) = damage range when at least one attack misses
424
621
  *
425
622
  * Tactical Use Cases:
426
623
  * - "Given that I don't completely whiff (99% of turns), what damage should I expect?"
@@ -438,6 +635,12 @@ var _DiceQuery = class _DiceQuery {
438
635
  * This includes mixed scenarios (2 hits + 1 crit, 3 hits + 1 miss, etc.) which
439
636
  * occur far more frequently than pure scenarios. For pure scenarios, use combinedDamageStats.
440
637
  *
638
+ * KNOWN LIMITATION (multi-attack, single label): the returned `count` is an
639
+ * EXPECTED COUNT (E[#label], so > 1 for N≥2 attacks, not a probability), and
640
+ * `avg` is the size-biased conditional mean E[dmg·#label]/E[#label] rather than
641
+ * E[dmg | the label occurs]. For a single attack both are the plain
642
+ * conditional figures. Use {@link probAtLeastOne} for the scenario probability.
643
+ *
441
644
  * @example
442
645
  * // High-level tactical planning
443
646
  * const successStats = query.damageStatsFrom('hit')
@@ -542,7 +745,8 @@ var _DiceQuery = class _DiceQuery {
542
745
  };
543
746
  }
544
747
  /**
545
- * Returns the probability that a result includes ANY of the specified labels.
748
+ * Returns the probability that at least one attack carries ANY of the
749
+ * specified labels (the marginal P(≥1) across the independent attacks).
546
750
  *
547
751
  * Examples:
548
752
  * - `query.probabilityOf('hit')` → 0.88 (probability at least one hit occurs)
@@ -551,25 +755,15 @@ var _DiceQuery = class _DiceQuery {
551
755
  * Use cases:
552
756
  * - "What's the chance my resolution includes a success label?"
553
757
  * - "How likely am I to get any hits or crits across all attacks?"
758
+ *
759
+ * Note: this must NOT be computed by summing `combined` bin probabilities. A
760
+ * single combined damage total is reachable by many outcome combinations and
761
+ * a bin can hold several labels at once, so summing `bin.p` over bins that
762
+ * contain a label over-counts. The correct marginal is the Poisson-binomial
763
+ * complement over the per-attack probabilities, i.e. {@link probAtLeastOne}.
554
764
  */
555
765
  probabilityOf(labels) {
556
- if (typeof labels === "string") {
557
- labels = [labels];
558
- }
559
- let totalProbability = 0;
560
- for (const [, probabilityBin] of this.combined) {
561
- let binHasAnyLabel = false;
562
- for (const label of labels) {
563
- if (probabilityBin.count[label] && probabilityBin.count[label] > 0) {
564
- binHasAnyLabel = true;
565
- break;
566
- }
567
- }
568
- if (binHasAnyLabel) {
569
- totalProbability += probabilityBin.p;
570
- }
571
- }
572
- return totalProbability;
766
+ return this.probAtLeastOne(labels);
573
767
  }
574
768
  /**
575
769
  * Returns the probability of missing (any type of miss).
@@ -629,10 +823,6 @@ var _DiceQuery = class _DiceQuery {
629
823
  */
630
824
  toStackedChartData(labels = [], epsilon = EPS) {
631
825
  const damageValues = this.combined.support();
632
- damageValues.map((dmg) => {
633
- const bin = this.combined.map.get(dmg);
634
- return labels.reduce((sum, lab) => sum + (bin.count[lab] || 0), 0);
635
- });
636
826
  const datasets = labels.map((outcomeLabel) => ({
637
827
  label: outcomeLabel,
638
828
  data: damageValues.map((dmg) => {
@@ -1069,6 +1259,16 @@ var _DiceQuery = class _DiceQuery {
1069
1259
  * Snapshot of the distribution in the exact shape the UI consumes.
1070
1260
  * - outcome probabilities are "at least one" (and equal to "all" for a single PMF)
1071
1261
  * - damageRange is conditional on the outcome occurring
1262
+ *
1263
+ * The outcome probabilities use the correct Poisson-binomial marginals
1264
+ * (`atLeastOneProbability` = P(≥1 attack has it), `allProbability` = P(all do)),
1265
+ * so they are always valid probabilities in [0,1].
1266
+ *
1267
+ * KNOWN LIMITATION (multi-attack): `damageRange.avg` is still aggregated from
1268
+ * the combined PMF's `count`, which the convolution accumulates as an EXPECTED
1269
+ * COUNT, so for N≥2 attacks it is the size-biased mean E[dmg·#label]/E[#label]
1270
+ * rather than a clean conditional expectation. It is correct for a single
1271
+ * attack.
1072
1272
  */
1073
1273
  snapshot(order) {
1074
1274
  const discovered = /* @__PURE__ */ new Set();
@@ -1090,10 +1290,8 @@ var _DiceQuery = class _DiceQuery {
1090
1290
  );
1091
1291
  }
1092
1292
  const rows = this.toLabeledTable(outcomes);
1093
- const totals = /* @__PURE__ */ new Map();
1094
1293
  const rangeAcc = /* @__PURE__ */ new Map();
1095
1294
  for (const ot of outcomes) {
1096
- totals.set(ot, 0);
1097
1295
  rangeAcc.set(ot, { sum: 0, mass: 0 });
1098
1296
  }
1099
1297
  for (const row of rows) {
@@ -1101,7 +1299,6 @@ var _DiceQuery = class _DiceQuery {
1101
1299
  for (const ot of outcomes) {
1102
1300
  const p = row[ot] || 0;
1103
1301
  if (p <= 0) continue;
1104
- totals.set(ot, (totals.get(ot) || 0) + p);
1105
1302
  const r = rangeAcc.get(ot);
1106
1303
  r.sum += dmg * p;
1107
1304
  r.mass += p;
@@ -1109,15 +1306,14 @@ var _DiceQuery = class _DiceQuery {
1109
1306
  if (r.max === void 0 || dmg > r.max) r.max = dmg;
1110
1307
  }
1111
1308
  }
1309
+ const n = this.singles.length;
1112
1310
  const outcomeMap = /* @__PURE__ */ new Map();
1113
1311
  for (const ot of outcomes) {
1114
- const total = totals.get(ot) || 0;
1115
1312
  const r = rangeAcc.get(ot);
1116
1313
  const avg = r.mass > 0 ? r.sum / r.mass : 0;
1117
1314
  outcomeMap.set(ot, {
1118
- atLeastOneProbability: total,
1119
- allProbability: total,
1120
- // single aggregate PMF: same value
1315
+ atLeastOneProbability: this.probAtLeastOne(ot),
1316
+ allProbability: this.probAtLeastK(ot, n),
1121
1317
  damageRange: { min: r.min ?? 0, avg, max: r.max ?? 0 }
1122
1318
  });
1123
1319
  }
@@ -1302,11 +1498,11 @@ var _DiceQuery = class _DiceQuery {
1302
1498
  return [a, b, any, none];
1303
1499
  }
1304
1500
  };
1305
- __publicField(_DiceQuery, "DEFAULT_OUTCOMES", [
1501
+ _DiceQuery.DEFAULT_OUTCOMES = [
1306
1502
  "hit",
1307
1503
  "crit",
1308
1504
  "missNone"
1309
- ]);
1505
+ ];
1310
1506
  var DiceQuery = _DiceQuery;
1311
1507
  var pmfCache = new LRUCache(1e3);
1312
1508
  var _PMF = class _PMF {
@@ -1316,14 +1512,6 @@ var _PMF = class _PMF {
1316
1512
  this.normalized = normalized;
1317
1513
  this.identifier = identifier;
1318
1514
  this._preservedProvenance = _preservedProvenance;
1319
- // Cached computed values
1320
- __publicField(this, "_support");
1321
- __publicField(this, "_min");
1322
- __publicField(this, "_max");
1323
- __publicField(this, "_totalMass");
1324
- __publicField(this, "_mean");
1325
- __publicField(this, "_variance");
1326
- __publicField(this, "_stdev");
1327
1515
  }
1328
1516
  static empty(epsilon = EPS, identifier = "empty") {
1329
1517
  return new _PMF(/* @__PURE__ */ new Map(), epsilon, false, identifier);
@@ -1337,6 +1525,20 @@ var _PMF = class _PMF {
1337
1525
  static delta(value, epsilon = EPS) {
1338
1526
  return _PMF.fromMap(/* @__PURE__ */ new Map([[value, 1]]), epsilon);
1339
1527
  }
1528
+ /**
1529
+ * Point mass at damage 0 tagged with the canonical `missNone` outcome.
1530
+ *
1531
+ * Differs from {@link PMF.zero}, which labels its zero bin `miss` — the
1532
+ * builder's attack-resolution vocabulary. This uses the `missNone`
1533
+ * {@link OutcomeType} that the attribution charts and outcome stats key on,
1534
+ * so it is the correct "clean miss / no damage" delta for provenance-aware
1535
+ * mixtures feeding those consumers.
1536
+ */
1537
+ static missNone(epsilon = EPS) {
1538
+ const m = /* @__PURE__ */ new Map();
1539
+ m.set(0, { p: 1, count: { [MISS_NONE_OUTCOME]: 1 }, attr: {} });
1540
+ return new _PMF(m, epsilon, false, "missNone");
1541
+ }
1340
1542
  // This creates a single bin at value 0, but with weight 0.
1341
1543
  static emptyMass() {
1342
1544
  return _PMF.zero().scaleMass(0);
@@ -1363,8 +1565,14 @@ var _PMF = class _PMF {
1363
1565
  if (p === 1) return successPMF.scaleMass(1);
1364
1566
  const eps = successPMF.epsilon ?? failurePMF.epsilon;
1365
1567
  const id = `branch(${failurePMF.identifier}*${q.toFixed(6)} + ${successPMF.identifier}*${p.toFixed(6)})`;
1366
- const out = _PMF.empty(eps, id).addScaled(failurePMF, q).addScaled(successPMF, p);
1367
- return out;
1568
+ const resultMap = /* @__PURE__ */ new Map();
1569
+ for (const [damageValue, bin] of failurePMF.map) {
1570
+ _PMF.mergeInto(resultMap, damageValue, _PMF.scaleBin(bin, q));
1571
+ }
1572
+ for (const [damageValue, bin] of successPMF.map) {
1573
+ _PMF.mergeInto(resultMap, damageValue, _PMF.scaleBin(bin, p));
1574
+ }
1575
+ return new _PMF(resultMap, eps, false, id);
1368
1576
  }
1369
1577
  /**
1370
1578
  * withProbability()
@@ -1412,8 +1620,8 @@ var _PMF = class _PMF {
1412
1620
  * @param fallback PMF to apply when this PMF is *not* selected.
1413
1621
  * @returns A new PMF representing the weighted mixture of this PMF and the fallback.
1414
1622
  */
1415
- gate(p, zero) {
1416
- return _PMF.branch(this, zero, p);
1623
+ gate(p, fallback) {
1624
+ return _PMF.branch(this, fallback, p);
1417
1625
  }
1418
1626
  /**
1419
1627
  * PMF.exclusive()
@@ -1496,13 +1704,23 @@ var _PMF = class _PMF {
1496
1704
  *
1497
1705
  * @returns New PMF with attr field populated in each bin
1498
1706
  */
1499
- withAttribution() {
1707
+ /**
1708
+ * Returns true if this PMF already carries damage attribution metadata.
1709
+ *
1710
+ * Only the first positive-damage bin is inspected (parser-generated PMFs
1711
+ * populate `attr` uniformly), so this is O(1) in practice.
1712
+ */
1713
+ hasAttribution() {
1500
1714
  for (const [damage, bin] of this.map) {
1501
1715
  if (damage !== 0 && bin.attr && Object.keys(bin.attr).length > 0) {
1502
- return this;
1716
+ return true;
1503
1717
  }
1504
1718
  if (damage > 0) break;
1505
1719
  }
1720
+ return false;
1721
+ }
1722
+ withAttribution() {
1723
+ if (this.hasAttribution()) return this;
1506
1724
  const newMap = /* @__PURE__ */ new Map();
1507
1725
  for (const [damage, bin] of this.map) {
1508
1726
  const attr = {};
@@ -1619,7 +1837,7 @@ var _PMF = class _PMF {
1619
1837
  */
1620
1838
  replicate(n) {
1621
1839
  if (!Number.isInteger(n) || n <= 0) {
1622
- throw new Error("combineN(n): n must be a positive integer");
1840
+ throw new Error("replicate(n): n must be a positive integer");
1623
1841
  }
1624
1842
  if (n === 1) return [this];
1625
1843
  return Array.from({ length: n }, () => this);
@@ -1651,7 +1869,6 @@ var _PMF = class _PMF {
1651
1869
  if (normalizationFactor === 0) return this;
1652
1870
  const normalizedMap = /* @__PURE__ */ new Map();
1653
1871
  for (const [damageValue, probabilityBin] of this.map) {
1654
- const normalizedProbability = probabilityBin.p / normalizationFactor;
1655
1872
  const normalizedCount = {};
1656
1873
  for (const labelKey in probabilityBin.count) {
1657
1874
  normalizedCount[labelKey] = probabilityBin.count[labelKey] / normalizationFactor;
@@ -1664,7 +1881,7 @@ var _PMF = class _PMF {
1664
1881
  }
1665
1882
  }
1666
1883
  normalizedMap.set(damageValue, {
1667
- p: normalizedProbability,
1884
+ p: probabilityBin.p / normalizationFactor,
1668
1885
  count: normalizedCount,
1669
1886
  attr: normalizedAttributes
1670
1887
  });
@@ -1687,22 +1904,23 @@ var _PMF = class _PMF {
1687
1904
  for (const [damageValue, probabilityBin] of this.map) {
1688
1905
  const shouldKeep = probabilityBin.p >= eps || keepFinalBin && damageValue === maxKey;
1689
1906
  if (!shouldKeep) continue;
1690
- for (const labelKey in probabilityBin.count) {
1691
- if (Math.abs(probabilityBin.count[labelKey] || 0) < eps) {
1692
- delete probabilityBin.count[labelKey];
1907
+ const cleanedBin = _PMF.cloneBin(probabilityBin);
1908
+ for (const labelKey in cleanedBin.count) {
1909
+ if (Math.abs(cleanedBin.count[labelKey] || 0) < eps) {
1910
+ delete cleanedBin.count[labelKey];
1693
1911
  }
1694
1912
  }
1695
- if (probabilityBin.attr) {
1696
- for (const labelKey in probabilityBin.attr) {
1697
- if (Math.abs(probabilityBin.attr[labelKey] || 0) < eps) {
1698
- delete probabilityBin.attr[labelKey];
1913
+ if (cleanedBin.attr) {
1914
+ for (const labelKey in cleanedBin.attr) {
1915
+ if (Math.abs(cleanedBin.attr[labelKey] || 0) < eps) {
1916
+ delete cleanedBin.attr[labelKey];
1699
1917
  }
1700
1918
  }
1701
- if (Object.keys(probabilityBin.attr).length === 0) {
1702
- probabilityBin.attr = void 0;
1919
+ if (Object.keys(cleanedBin.attr).length === 0) {
1920
+ cleanedBin.attr = void 0;
1703
1921
  }
1704
1922
  }
1705
- compactedMap.set(damageValue, probabilityBin);
1923
+ compactedMap.set(damageValue, cleanedBin);
1706
1924
  }
1707
1925
  return new _PMF(compactedMap, eps, this.normalized, this.identifier);
1708
1926
  }
@@ -1769,14 +1987,33 @@ var _PMF = class _PMF {
1769
1987
  }
1770
1988
  return this._stdev;
1771
1989
  }
1990
+ /** Deep-copies a Bin, cloning its count and (optional) attr maps. */
1991
+ static cloneBin(bin) {
1992
+ return {
1993
+ p: bin.p,
1994
+ count: { ...bin.count },
1995
+ attr: bin.attr ? { ...bin.attr } : void 0
1996
+ };
1997
+ }
1998
+ /** Returns a new Bin with p, count, and attr all multiplied by `factor`. */
1999
+ static scaleBin(bin, factor) {
2000
+ const count = {};
2001
+ for (const k in bin.count) {
2002
+ count[k] = bin.count[k] * factor;
2003
+ }
2004
+ let attr;
2005
+ if (bin.attr) {
2006
+ attr = {};
2007
+ for (const k in bin.attr) {
2008
+ attr[k] = bin.attr[k] * factor;
2009
+ }
2010
+ }
2011
+ return { p: bin.p * factor, count, attr };
2012
+ }
1772
2013
  static mergeInto(destinationMap, damageValue, binToAdd) {
1773
2014
  const existingBin = destinationMap.get(damageValue);
1774
2015
  if (!existingBin) {
1775
- destinationMap.set(damageValue, {
1776
- p: binToAdd.p,
1777
- count: { ...binToAdd.count },
1778
- attr: binToAdd.attr ? { ...binToAdd.attr } : void 0
1779
- });
2016
+ destinationMap.set(damageValue, _PMF.cloneBin(binToAdd));
1780
2017
  return;
1781
2018
  }
1782
2019
  existingBin.p += binToAdd.p;
@@ -1807,29 +2044,14 @@ var _PMF = class _PMF {
1807
2044
  if (probability === 0) return this;
1808
2045
  const resultMap = /* @__PURE__ */ new Map();
1809
2046
  for (const [dmg, bin] of this.map) {
1810
- resultMap.set(dmg, {
1811
- p: bin.p,
1812
- count: { ...bin.count },
1813
- attr: bin.attr ? { ...bin.attr } : void 0
1814
- });
2047
+ resultMap.set(dmg, _PMF.cloneBin(bin));
1815
2048
  }
1816
2049
  for (const [damageValue, probabilityBin] of branch.map) {
1817
- const scaledCount = {};
1818
- for (const k in probabilityBin.count) {
1819
- scaledCount[k] = probability * probabilityBin.count[k];
1820
- }
1821
- let scaledAttributes;
1822
- if (probabilityBin.attr) {
1823
- scaledAttributes = {};
1824
- for (const k in probabilityBin.attr) {
1825
- scaledAttributes[k] = probability * probabilityBin.attr[k];
1826
- }
1827
- }
1828
- _PMF.mergeInto(resultMap, damageValue, {
1829
- p: probability * probabilityBin.p,
1830
- count: scaledCount,
1831
- attr: scaledAttributes
1832
- });
2050
+ _PMF.mergeInto(
2051
+ resultMap,
2052
+ damageValue,
2053
+ _PMF.scaleBin(probabilityBin, probability)
2054
+ );
1833
2055
  }
1834
2056
  return new _PMF(
1835
2057
  resultMap,
@@ -1838,26 +2060,54 @@ var _PMF = class _PMF {
1838
2060
  `${this.identifier}+scaled(${branch.identifier},${probability})`
1839
2061
  );
1840
2062
  }
2063
+ /**
2064
+ * Redistributes probability mass to model an effect that only occurs with
2065
+ * probability `frequency` — a conditional attack, an on-hit rider, or a
2066
+ * sub-one AoE target fraction.
2067
+ *
2068
+ * Every hit outcome (damage > 0) is scaled by `frequency` — probability mass,
2069
+ * per-label `count`, AND per-label `attr` — and the freed mass is moved into
2070
+ * the miss bin at damage 0, tagged with the canonical `missNone` outcome.
2071
+ * Total probability mass is preserved.
2072
+ *
2073
+ * Unlike a bare {@link scaleMass} or {@link mapDamage}, this keeps damage
2074
+ * attribution (`attr`) intact, so a frequency-scaled PMF still renders
2075
+ * correctly in the damage-attribution charts.
2076
+ *
2077
+ * `frequency >= 1` (or non-finite) returns this PMF unchanged; `frequency <= 0`
2078
+ * collapses all mass into the miss bin. The miss outcome is assumed to be
2079
+ * encoded at damage value 0.
2080
+ *
2081
+ * @param frequency Probability in [0, 1] that the effect occurs.
2082
+ */
2083
+ applyHitFrequency(frequency) {
2084
+ if (!Number.isFinite(frequency) || frequency >= 1) return this;
2085
+ const freq = Math.max(0, frequency);
2086
+ const pMiss = this.pAt(0);
2087
+ const pHit = 1 - pMiss;
2088
+ const newMissMass = pMiss + (1 - freq) * pHit;
2089
+ const newMap = /* @__PURE__ */ new Map();
2090
+ newMap.set(0, {
2091
+ p: newMissMass,
2092
+ count: { [MISS_NONE_OUTCOME]: newMissMass },
2093
+ attr: {}
2094
+ });
2095
+ for (const [damage, bin] of this.map) {
2096
+ if (damage <= 0) continue;
2097
+ newMap.set(damage, _PMF.scaleBin(bin, freq));
2098
+ }
2099
+ return new _PMF(
2100
+ newMap,
2101
+ this.epsilon,
2102
+ false,
2103
+ `freq(${this.identifier},${freq})`
2104
+ );
2105
+ }
1841
2106
  scaleMass(factor) {
1842
2107
  if (factor === 1) return this;
1843
2108
  const scaledMap = /* @__PURE__ */ new Map();
1844
2109
  for (const [damageValue, probabilityBin] of this.map) {
1845
- const scaledCount = {};
1846
- for (const labelKey in probabilityBin.count) {
1847
- scaledCount[labelKey] = probabilityBin.count[labelKey] * factor;
1848
- }
1849
- let scaledAttributes;
1850
- if (probabilityBin.attr) {
1851
- scaledAttributes = {};
1852
- for (const labelKey in probabilityBin.attr) {
1853
- scaledAttributes[labelKey] = probabilityBin.attr[labelKey] * factor;
1854
- }
1855
- }
1856
- scaledMap.set(damageValue, {
1857
- p: probabilityBin.p * factor,
1858
- count: scaledCount,
1859
- attr: scaledAttributes
1860
- });
2110
+ scaledMap.set(damageValue, _PMF.scaleBin(probabilityBin, factor));
1861
2111
  }
1862
2112
  return new _PMF(
1863
2113
  scaledMap,
@@ -1870,11 +2120,11 @@ var _PMF = class _PMF {
1870
2120
  const transformedMap = /* @__PURE__ */ new Map();
1871
2121
  for (const [originalDamage, probabilityBin] of this.map) {
1872
2122
  const transformedDamage = damageTransformFunction(originalDamage);
1873
- _PMF.mergeInto(transformedMap, transformedDamage, {
1874
- p: probabilityBin.p,
1875
- count: { ...probabilityBin.count },
1876
- attr: probabilityBin.attr ? { ...probabilityBin.attr } : void 0
1877
- });
2123
+ _PMF.mergeInto(
2124
+ transformedMap,
2125
+ transformedDamage,
2126
+ _PMF.cloneBin(probabilityBin)
2127
+ );
1878
2128
  }
1879
2129
  return new _PMF(
1880
2130
  transformedMap,
@@ -1889,14 +2139,21 @@ var _PMF = class _PMF {
1889
2139
  }
1890
2140
  getPMFCombineCacheKey(p1, p2, eps, raw) {
1891
2141
  const [id1, id2] = [p1.identifier, p2.identifier].sort();
1892
- const fp = (x) => {
1893
- const m = x.mass().toFixed(12);
1894
- const n = x.map.size;
2142
+ return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${p1.fingerprint()}|${p2.fingerprint()}`;
2143
+ }
2144
+ /**
2145
+ * A small content fingerprint (mass + bin count + face sum) so convolution
2146
+ * cache keys change if the underlying numbers do. Memoized because a PMF is
2147
+ * immutable once constructed — this avoids re-summing every key on each
2148
+ * convolve() call (including cache hits).
2149
+ */
2150
+ fingerprint() {
2151
+ if (this._fingerprint === void 0) {
1895
2152
  let faceSum = 0;
1896
- for (const k of x.map.keys()) faceSum += k;
1897
- return `${m}|${n}|${faceSum}`;
1898
- };
1899
- return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${fp(p1)}|${fp(p2)}`;
2153
+ for (const k of this.map.keys()) faceSum += k;
2154
+ this._fingerprint = `${this.mass().toFixed(12)}|${this.map.size}|${faceSum}`;
2155
+ }
2156
+ return this._fingerprint;
1900
2157
  }
1901
2158
  convolve(other, eps, raw = false) {
1902
2159
  const epsilon = eps ?? this.epsilon;
@@ -1909,25 +2166,34 @@ var _PMF = class _PMF {
1909
2166
  if (cached) return cached;
1910
2167
  const combinedMap = /* @__PURE__ */ new Map();
1911
2168
  for (const [aVal, aBin] of A.map) {
2169
+ const ap = aBin.p;
2170
+ const aCount = aBin.count;
2171
+ const aAttr = aBin.attr;
1912
2172
  for (const [bVal, bBin] of B.map) {
1913
- const p = aBin.p * bBin.p;
2173
+ const bp = bBin.p;
1914
2174
  const dmg = aVal + bVal;
1915
- const count = {};
1916
- for (const k in aBin.count)
1917
- count[k] = (count[k] || 0) + aBin.count[k] * bBin.p;
2175
+ let dest = combinedMap.get(dmg);
2176
+ if (dest === void 0) {
2177
+ dest = { p: 0, count: {} };
2178
+ combinedMap.set(dmg, dest);
2179
+ }
2180
+ dest.p += ap * bp;
2181
+ const dc = dest.count;
2182
+ for (const k in aCount) dc[k] = (dc[k] || 0) + aCount[k] * bp;
1918
2183
  for (const k in bBin.count)
1919
- count[k] = (count[k] || 0) + bBin.count[k] * aBin.p;
1920
- let attr;
1921
- if (aBin.attr || bBin.attr) {
1922
- attr = {};
1923
- if (aBin.attr)
1924
- for (const k in aBin.attr)
1925
- attr[k] = (attr[k] || 0) + aBin.attr[k] * bBin.p;
2184
+ dc[k] = (dc[k] || 0) + bBin.count[k] * ap;
2185
+ if (aAttr || bBin.attr) {
2186
+ let da = dest.attr;
2187
+ if (da === void 0) {
2188
+ da = {};
2189
+ dest.attr = da;
2190
+ }
2191
+ if (aAttr)
2192
+ for (const k in aAttr) da[k] = (da[k] || 0) + aAttr[k] * bp;
1926
2193
  if (bBin.attr)
1927
2194
  for (const k in bBin.attr)
1928
- attr[k] = (attr[k] || 0) + bBin.attr[k] * aBin.p;
2195
+ da[k] = (da[k] || 0) + bBin.attr[k] * ap;
1929
2196
  }
1930
- _PMF.mergeInto(combinedMap, dmg, { p, count, attr });
1931
2197
  }
1932
2198
  }
1933
2199
  let result = new _PMF(
@@ -1938,10 +2204,10 @@ var _PMF = class _PMF {
1938
2204
  );
1939
2205
  const mExp = (raw ? A.mass() : 1) * (raw ? B.mass() : 1);
1940
2206
  const mGot = result.mass();
1941
- if (mExp !== 0 && Math.abs(mGot - mExp) > epsilon) {
2207
+ if (mExp !== 0 && mGot !== 0 && Math.abs(mGot - mExp) > epsilon) {
1942
2208
  result = result.scaleMass(mExp / mGot);
1943
2209
  }
1944
- if (!raw && Math.abs(result.mass() - 1) > epsilon)
2210
+ if (!raw && mGot !== 0 && Math.abs(result.mass() - 1) > epsilon)
1945
2211
  result = result.normalize();
1946
2212
  pmfCache?.set(cacheKey, result);
1947
2213
  return result;
@@ -1950,27 +2216,6 @@ var _PMF = class _PMF {
1950
2216
  combineRaw(other, eps) {
1951
2217
  return this.convolve(other, eps, true);
1952
2218
  }
1953
- // Collapse repeated identical PMFs using power() and return a sorted list
1954
- // Temporarily disabled for now. This was used for a performance optimization, but can lose data provenance.
1955
- // private static collapseIdentical(pmfList: PMF[], eps: number): PMF[] {
1956
- // const grouped = new Map<string, { pmf: PMF; count: number }>();
1957
- // for (const pmf of pmfList) {
1958
- // const id = pmf.identifier;
1959
- // const g = grouped.get(id);
1960
- // if (g) g.count++;
1961
- // else grouped.set(id, { pmf, count: 1 });
1962
- // }
1963
- // if (grouped.size >= pmfList.length) return pmfList;
1964
- // const collapsed: PMF[] = [];
1965
- // for (const { pmf, count } of grouped.values()) {
1966
- // collapsed.push(count > 1 ? pmf.power(count, eps) : pmf);
1967
- // }
1968
- // // Stable order for better cache locality
1969
- // collapsed.sort((a, b) =>
1970
- // a.identifier < b.identifier ? -1 : a.identifier > b.identifier ? 1 : 0
1971
- // );
1972
- // return collapsed;
1973
- // }
1974
2219
  // Reduce a list of PMFs by left-folding convolve() with the given eps
1975
2220
  static reduceConvolveLeft(pmfList, eps) {
1976
2221
  let result = pmfList[0];
@@ -1994,12 +2239,23 @@ var _PMF = class _PMF {
1994
2239
  if (pmfList.length === 1) return pmfList[0];
1995
2240
  return _PMF.reduceConvolveLeft(pmfList, eps);
1996
2241
  }
2242
+ /**
2243
+ * Returns a plain, JSON-serializable representation of this PMF.
2244
+ *
2245
+ * Follows the standard `toJSON` contract, so `JSON.stringify(pmf)` produces
2246
+ * the expected output (no double-encoding). Use {@link PMF.fromJSON} to
2247
+ * reconstruct, or {@link PMF.toJSONString} if you need the string directly.
2248
+ */
1997
2249
  toJSON() {
1998
- return JSON.stringify({
2250
+ return {
1999
2251
  bins: [...this.map.entries()],
2000
2252
  normalized: this.normalized,
2001
2253
  identifier: this.identifier
2002
- });
2254
+ };
2255
+ }
2256
+ /** Serializes this PMF to a JSON string (equivalent to `JSON.stringify(pmf)`). */
2257
+ toJSONString() {
2258
+ return JSON.stringify(this);
2003
2259
  }
2004
2260
  static fromJSON(jsonData) {
2005
2261
  return new _PMF(
@@ -2070,11 +2326,43 @@ var _PMF = class _PMF {
2070
2326
  }
2071
2327
  return new _PMF(prunedMap, epsRel, false, `prune(${this.identifier})`);
2072
2328
  }
2073
- /** NEW - REVIEW IF THESE ARE USEFUL OR DUPLCIATIVE? */
2074
2329
  /** Probability mass at exactly x. */
2075
2330
  pAt(x) {
2076
2331
  return this.map.get(x)?.p ?? 0;
2077
2332
  }
2333
+ /**
2334
+ * P(any damage) — the mass on all non-zero outcomes, i.e. `1 - P(0)`.
2335
+ * Assumes a miss is encoded as the damage-0 bin (the convention used across
2336
+ * attack/save PMFs). The dual of {@link missProbability}.
2337
+ */
2338
+ hitProbability() {
2339
+ return 1 - this.pAt(0);
2340
+ }
2341
+ /** P(no damage) — the mass at damage 0. The dual of {@link hitProbability}. */
2342
+ missProbability() {
2343
+ return this.pAt(0);
2344
+ }
2345
+ /**
2346
+ * Coarsen the distribution into at most `maxBuckets` contiguous, equal-width
2347
+ * damage buckets, aggregating probability mass (and `count`/`attr`
2348
+ * provenance) into each bucket's start value. Returns this PMF unchanged when
2349
+ * its integer support already fits within `maxBuckets`.
2350
+ *
2351
+ * This is a lossy display/downsampling transform (bucket start replaces the
2352
+ * exact damage value) — use it for charting wide distributions, not for DPR
2353
+ * math.
2354
+ */
2355
+ rebin(maxBuckets) {
2356
+ if (!(maxBuckets > 0)) return this;
2357
+ const support = this.support();
2358
+ if (support.length === 0) return this;
2359
+ const min = support[0];
2360
+ const max = support[support.length - 1];
2361
+ const range = max - min;
2362
+ if (range + 1 <= maxBuckets) return this;
2363
+ const binSize = Math.ceil((range + 1) / maxBuckets);
2364
+ return this.mapDamage((d) => min + Math.floor((d - min) / binSize) * binSize);
2365
+ }
2078
2366
  /** Dense integer support from min..max (inclusive).
2079
2367
  * Useful for showing empty bars in charts.
2080
2368
  */
@@ -2150,11 +2438,60 @@ var _PMF = class _PMF {
2150
2438
  }
2151
2439
  return false;
2152
2440
  }
2441
+ /**
2442
+ * Split each damage value's probability mass across outcome labels, returning
2443
+ * per-label maps of `damage value → probability mass attributable to that
2444
+ * label`. Summing over labels at a given value recovers that value's `p`.
2445
+ *
2446
+ * Damage-bearing bins are split by `attr` weight (the share of damage each
2447
+ * outcome contributed); the clean-miss bin at 0 is split by `count` weight
2448
+ * (there is no damage to attribute). Attribution is computed on demand via
2449
+ * {@link withAttribution} when absent, so builder-generated PMFs work too.
2450
+ *
2451
+ * This is the provenance core of the stacked damage-attribution chart — the
2452
+ * caller only maps these series into its rendering format (colors, binning,
2453
+ * axis labels).
2454
+ */
2455
+ attributionByValue() {
2456
+ const src = this.hasAttribution() ? this : this.withAttribution();
2457
+ const result = /* @__PURE__ */ new Map();
2458
+ const add = (label, damage, mass) => {
2459
+ if (!(mass > 0)) return;
2460
+ let series = result.get(label);
2461
+ if (!series) {
2462
+ series = /* @__PURE__ */ new Map();
2463
+ result.set(label, series);
2464
+ }
2465
+ series.set(damage, (series.get(damage) ?? 0) + mass);
2466
+ };
2467
+ for (const [damage, bin] of src.map) {
2468
+ const p = bin.p || 0;
2469
+ if (p <= 0) continue;
2470
+ const isMissBin = damage === 0;
2471
+ if (isMissBin) {
2472
+ let totalCount = 0;
2473
+ for (const k in bin.count) totalCount += bin.count[k] || 0;
2474
+ if (totalCount > 0) {
2475
+ const c = bin.count[MISS_NONE_OUTCOME] || 0;
2476
+ add(MISS_NONE_OUTCOME, damage, c / totalCount * p);
2477
+ }
2478
+ continue;
2479
+ }
2480
+ let totalAttr = 0;
2481
+ if (bin.attr) for (const k in bin.attr) totalAttr += bin.attr[k] || 0;
2482
+ if (bin.attr && totalAttr > 0) {
2483
+ for (const k in bin.attr) {
2484
+ if (k === MISS_NONE_OUTCOME) continue;
2485
+ add(k, damage, (bin.attr[k] || 0) / totalAttr * p);
2486
+ }
2487
+ }
2488
+ }
2489
+ return result;
2490
+ }
2153
2491
  tailProbGE(t) {
2154
2492
  let s = 0;
2155
- for (const [x, rec] of this) {
2156
- const p = typeof rec === "number" ? rec : rec.p;
2157
- if (p > 0 && x >= t) s += p;
2493
+ for (const [x, bin] of this) {
2494
+ if (bin.p > 0 && x >= t) s += bin.p;
2158
2495
  }
2159
2496
  return s;
2160
2497
  }
@@ -2215,6 +2552,11 @@ var _PMF = class _PMF {
2215
2552
  * - pAny: Probability that at least one success occurred
2216
2553
  */
2217
2554
  static firstSuccessWeights(pSuccess, pSpecial, n) {
2555
+ if (!Number.isFinite(pSuccess) || !Number.isFinite(pSpecial) || pSuccess < 0 || pSuccess > 1 || pSpecial < 0 || pSpecial - pSuccess > EPS) {
2556
+ throw new Error(
2557
+ `firstSuccessWeights: require 0 <= pSpecial <= pSuccess <= 1 (got pSuccess=${pSuccess}, pSpecial=${pSpecial})`
2558
+ );
2559
+ }
2218
2560
  const pFail = 1 - pSuccess;
2219
2561
  const pFailAll = Math.pow(pFail, n);
2220
2562
  const pAny = 1 - pFailAll;
@@ -2230,13 +2572,12 @@ var _PMF = class _PMF {
2230
2572
  const round = (x) => rounding === "floor" ? Math.floor(x) : rounding === "ceil" ? Math.ceil(x) : rounding === "round" ? Math.round(x) : x;
2231
2573
  const probs = /* @__PURE__ */ new Map();
2232
2574
  const counts = /* @__PURE__ */ new Map();
2233
- for (const [v, rec] of this) {
2234
- if (Math.abs(rec.p) < eps) continue;
2575
+ for (const [v, bin] of this) {
2576
+ if (Math.abs(bin.p) < eps) continue;
2235
2577
  const u = round(f(v));
2236
- probs.set(u, (probs.get(u) ?? 0) + rec.p);
2578
+ probs.set(u, (probs.get(u) ?? 0) + bin.p);
2237
2579
  if (preserveCounts) {
2238
- const rec2 = this.map.get(v);
2239
- const src = typeof rec2 === "number" ? void 0 : rec2?.count;
2580
+ const src = bin.count;
2240
2581
  if (src) {
2241
2582
  const dest = counts.get(u) ?? {};
2242
2583
  for (const k in src) {
@@ -2289,17 +2630,20 @@ var _PMF = class _PMF {
2289
2630
  }
2290
2631
  };
2291
2632
  // Unique ID generator for anonymous PMFs to avoid cache key collisions
2292
- __publicField(_PMF, "__anonIdCounter", 1);
2633
+ _PMF.__anonIdCounter = 1;
2293
2634
  var PMF = _PMF;
2294
2635
 
2295
2636
  // src/parser/dice.ts
2637
+ var MAX_BINARY_OUTCOMES = 1e8;
2296
2638
  var Dice = class _Dice {
2297
2639
  constructor(x = 0) {
2298
- __publicField(this, "faces", {});
2299
- __publicField(this, "privateData", {});
2300
- __publicField(this, "outcomeData", {});
2301
- __publicField(this, "hasHitDistributionCalculated", false);
2302
- __publicField(this, "identifier");
2640
+ this.faces = {};
2641
+ this.privateData = {};
2642
+ // Partial: the object starts empty and gains keys as outcomes are recorded,
2643
+ // so the type must not claim every OutcomeType is present. (Previously typed
2644
+ // as a full Record via an `as` cast, which lied about missing keys.)
2645
+ this.outcomeData = {};
2646
+ this.hasHitDistributionCalculated = false;
2303
2647
  if (x <= 0) return;
2304
2648
  for (let i = 1; i <= x; i++) {
2305
2649
  this.faces[i] = 1;
@@ -2350,27 +2694,27 @@ var Dice = class _Dice {
2350
2694
  // TODO this can be private later if we change how testing works
2351
2695
  calculateHitDistribution() {
2352
2696
  const hitValues = {};
2697
+ const subtractedOutcomes = [
2698
+ this.outcomeData.crit,
2699
+ this.outcomeData.missNone,
2700
+ this.outcomeData.missDamage,
2701
+ this.outcomeData.saveHalf,
2702
+ this.outcomeData.saveFail,
2703
+ this.outcomeData.pc
2704
+ ];
2353
2705
  for (const [face, totalCount] of Object.entries(this.faces)) {
2354
2706
  const numFace = Number(face);
2355
2707
  let hitCount = totalCount;
2356
- for (const outcomeType of [
2357
- "crit",
2358
- "missNone",
2359
- "missDamage",
2360
- "saveHalf",
2361
- "saveFail",
2362
- "pc"
2363
- ]) {
2364
- const distribution = this.getOutcomeDistribution(outcomeType);
2365
- if (distribution && distribution[numFace]) {
2366
- hitCount -= distribution[numFace];
2708
+ for (const distribution of subtractedOutcomes) {
2709
+ const outcomeCount = distribution?.[numFace];
2710
+ if (outcomeCount) {
2711
+ hitCount -= outcomeCount;
2367
2712
  }
2368
2713
  }
2369
2714
  if (numFace === 0) {
2370
2715
  hitCount = 0;
2371
2716
  }
2372
2717
  if (hitCount < 0) {
2373
- console.error("hitCount is <=0?", face, totalCount, hitCount);
2374
2718
  hitCount = 0;
2375
2719
  }
2376
2720
  hitValues[numFace] = hitCount;
@@ -2389,13 +2733,18 @@ var Dice = class _Dice {
2389
2733
  const result = diceConstructor ? diceConstructor() : new _Dice();
2390
2734
  const isScalar = typeof other === "number";
2391
2735
  const keys1 = this.keys();
2736
+ const keys2 = isScalar ? [] : other.keys();
2737
+ if (!isScalar && keys1.length * keys2.length > MAX_BINARY_OUTCOMES) {
2738
+ throw new DiceParseError(
2739
+ `Dice operation over ${keys1.length}\xD7${keys2.length} face pairs exceeds the maximum of ${MAX_BINARY_OUTCOMES}`
2740
+ );
2741
+ }
2392
2742
  for (const key1 of keys1) {
2393
2743
  const value1 = this.faces[key1];
2394
2744
  if (isScalar) {
2395
2745
  const resultKey = op(key1, other);
2396
2746
  result.increment(resultKey, value1);
2397
2747
  } else {
2398
- const keys2 = other.keys();
2399
2748
  for (const key2 of keys2) {
2400
2749
  const value2 = other.faces[key2];
2401
2750
  const resultKey = op(key1, key2);
@@ -2417,7 +2766,7 @@ var Dice = class _Dice {
2417
2766
  result.outcomeData = { ...this.outcomeData };
2418
2767
  return result;
2419
2768
  }
2420
- // PUBLIC FUNTIONS
2769
+ // PUBLIC FUNCTIONS
2421
2770
  getFaceEntries() {
2422
2771
  return Object.entries(this.faces).map(([k, v]) => [Number(k), v]);
2423
2772
  }
@@ -2547,10 +2896,12 @@ var Dice = class _Dice {
2547
2896
  }
2548
2897
  reroll(toReroll) {
2549
2898
  const rerollDice = typeof toReroll === "number" ? _Dice.scalar(toReroll) : toReroll;
2550
- const removed = this.removeFaces(rerollDice.keys());
2899
+ const rerollKeys = rerollDice.keys();
2900
+ const rerollSet = new Set(rerollKeys);
2901
+ const removed = this.removeFaces(rerollKeys);
2551
2902
  let result = new _Dice();
2552
2903
  for (const face of this.keys()) {
2553
- const wasRerolled = rerollDice.keys().includes(face);
2904
+ const wasRerolled = rerollSet.has(face);
2554
2905
  result = result.combine(removed);
2555
2906
  if (wasRerolled) {
2556
2907
  result = result.combine(this);
@@ -2618,17 +2969,10 @@ var Dice = class _Dice {
2618
2969
  const missDistro = this.getOutcomeDistribution("missDamage") || {};
2619
2970
  const saveDistro = this.getOutcomeDistribution("saveHalf") || {};
2620
2971
  const pcDistro = this.getOutcomeDistribution("pc") || {};
2621
- let isSaveHalf = false;
2622
- for (const halfDamage of Object.keys(saveDistro).map(Number)) {
2623
- const fullDamage = halfDamage * 2;
2624
- if (fullDamage > 0 && hitDistro[fullDamage]) {
2625
- isSaveHalf = true;
2626
- break;
2627
- }
2628
- }
2972
+ const isSaveHalf = Object.keys(saveDistro).length > 0;
2629
2973
  const isDCCheck = this.privateData.isDCCheck === true;
2630
2974
  const clampNonNeg = (x) => x < 0 && x > -1e-15 ? 0 : x;
2631
- for (const [faceStr, faceCountRaw] of Object.entries(this.getFaceMap())) {
2975
+ for (const [faceStr, faceCountRaw] of Object.entries(this.faces)) {
2632
2976
  const face = Number(faceStr);
2633
2977
  const faceCount = Number(faceCountRaw);
2634
2978
  if (faceCount <= 0) continue;
@@ -2702,14 +3046,14 @@ var Dice = class _Dice {
2702
3046
  map.set(face, bin);
2703
3047
  }
2704
3048
  const identifier = this.identifier || "ERROR";
2705
- if (identifier === "ERROR") {
2706
- console.error("Dice identifier is undefined", this);
2707
- }
2708
3049
  return new PMF(map, numEpsilon, true, identifier).compact(numEpsilon, true);
2709
3050
  }
2710
3051
  };
2711
3052
 
2712
3053
  // src/parser/parser.ts
3054
+ var MAX_DIE_SIDES = 1e6;
3055
+ var MAX_DICE_COUNT = 1e4;
3056
+ var MAX_KEEP_OUTCOMES = 1e6;
2713
3057
  var parseCache = new LRUCache(1e3);
2714
3058
  var cachingEnabled = true;
2715
3059
  function setCachingEnabled(enabled) {
@@ -2730,20 +3074,21 @@ function parse(expression, n = 0) {
2730
3074
  if (cached) return cached;
2731
3075
  }
2732
3076
  const chars = [...cleaned];
2733
- let result = void 0;
3077
+ let result;
2734
3078
  try {
2735
3079
  result = parseExpression(chars, n);
2736
3080
  } catch (error) {
2737
- throw new Error(`Cannot parse dice expression [${expression}]: ${error}`);
2738
- }
2739
- try {
2740
- result.privateData = result.privateData || {};
2741
- result.identifier = cleaned;
2742
- } catch {
3081
+ throw new DiceParseError(
3082
+ `Cannot parse dice expression [${expression}]: ${error}`,
3083
+ { expression, cause: error }
3084
+ );
2743
3085
  }
3086
+ result.privateData = result.privateData || {};
3087
+ result.identifier = cleaned;
2744
3088
  if (chars.length > 0) {
2745
- throw new Error(
2746
- `Unexpected token: '${chars[0]}' from expression: '${expression}'`
3089
+ throw new DiceParseError(
3090
+ `Unexpected token: '${chars[0]}' from expression: '${expression}'`,
3091
+ { expression }
2747
3092
  );
2748
3093
  }
2749
3094
  const resultPMF = result.toPMF(-1);
@@ -2897,7 +3242,7 @@ function multiplyDiceByDice(d1, d2) {
2897
3242
  if (typeof d1 === "number") d1 = Dice.scalar(d1);
2898
3243
  if (typeof d2 === "number") d2 = Dice.scalar(d2);
2899
3244
  const result = new Dice();
2900
- const faces = {};
3245
+ const faces = /* @__PURE__ */ new Map();
2901
3246
  let normalizationFactor = 1;
2902
3247
  for (const key of d1.keys()) {
2903
3248
  let face;
@@ -2905,17 +3250,21 @@ function multiplyDiceByDice(d1, d2) {
2905
3250
  continue;
2906
3251
  }
2907
3252
  if (d2.privateData.keep) {
3253
+ const faceCount = d2.keys().length;
3254
+ if (Math.pow(faceCount, key) > MAX_KEEP_OUTCOMES) {
3255
+ throw new DiceParseError(
3256
+ `Keep enumeration of ${faceCount}^${key} outcomes exceeds the maximum of ${MAX_KEEP_OUTCOMES}`
3257
+ );
3258
+ }
2908
3259
  const repeat = Array(key).fill(d2);
2909
3260
  face = opDice(repeat, d2.privateData.keep);
2910
3261
  } else {
2911
3262
  face = multiplyDice(key, d2);
2912
3263
  }
2913
3264
  normalizationFactor *= face.total();
2914
- faces[key] = face;
3265
+ faces.set(key, face);
2915
3266
  }
2916
- for (const key of Object.keys(faces)) {
2917
- const k = parseFloat(key);
2918
- const face = faces[k];
3267
+ for (const [k, face] of faces) {
2919
3268
  const count = d1.get(k);
2920
3269
  result.combineInPlace(
2921
3270
  face.normalize(count * normalizationFactor / face.total())
@@ -2925,6 +3274,11 @@ function multiplyDiceByDice(d1, d2) {
2925
3274
  return result;
2926
3275
  }
2927
3276
  function multiplyDice(n, d) {
3277
+ if (n > MAX_DICE_COUNT) {
3278
+ throw new DiceParseError(
3279
+ `Dice count ${n} exceeds the maximum of ${MAX_DICE_COUNT}`
3280
+ );
3281
+ }
2928
3282
  if (n === 0) return new Dice(0);
2929
3283
  if (n === 1) return d;
2930
3284
  const half = Math.floor(n / 2);
@@ -3007,9 +3361,14 @@ function parseDice(s, n) {
3007
3361
  return;
3008
3362
  }
3009
3363
  const sides = parseNumber(s, n);
3364
+ if (sides > MAX_DIE_SIDES) {
3365
+ throw new DiceParseError(
3366
+ `Die size ${sides} exceeds the maximum of ${MAX_DIE_SIDES}`
3367
+ );
3368
+ }
3010
3369
  let result = new Dice(sides);
3011
3370
  if (rerollOne) {
3012
- result = result.deleteFace(1).combine(result);
3371
+ result = result.reroll(1);
3013
3372
  }
3014
3373
  return result;
3015
3374
  }
@@ -3124,11 +3483,9 @@ function parseOperation(s) {
3124
3483
  // src/pmf/mixture.ts
3125
3484
  var Mixture = class _Mixture {
3126
3485
  constructor(eps = EPS) {
3127
- __publicField(this, "totals", /* @__PURE__ */ new Map());
3486
+ this.totals = /* @__PURE__ */ new Map();
3128
3487
  // raw mass per outcome (pre-normalization)
3129
- __publicField(this, "labelMass", /* @__PURE__ */ new Map());
3130
- // raw mass per outcome per label
3131
- __publicField(this, "eps");
3488
+ this.labelMass = /* @__PURE__ */ new Map();
3132
3489
  this.eps = Number.isFinite(eps) ? eps : EPS;
3133
3490
  }
3134
3491
  /** Remove all accumulated state. */
@@ -3152,9 +3509,8 @@ var Mixture = class _Mixture {
3152
3509
  */
3153
3510
  add(label, pmf, weight = 1) {
3154
3511
  if (!Number.isFinite(weight) || weight <= 0) return this;
3155
- for (const [v, binOrNumber] of pmf) {
3156
- const isNumber = typeof binOrNumber === "number";
3157
- const p = isNumber ? binOrNumber : binOrNumber?.p ?? 0;
3512
+ for (const [v, bin] of pmf) {
3513
+ const p = bin.p;
3158
3514
  if (p <= 0) continue;
3159
3515
  const add = weight * p;
3160
3516
  if (!Number.isFinite(add) || Math.abs(add) < this.eps) continue;
@@ -3242,12 +3598,18 @@ var Mixture = class _Mixture {
3242
3598
  }
3243
3599
  };
3244
3600
 
3601
+ exports.ALL_OUTCOME_TYPES = ALL_OUTCOME_TYPES;
3602
+ exports.DiceParseError = DiceParseError;
3245
3603
  exports.DiceQuery = DiceQuery;
3246
3604
  exports.EPS = EPS;
3247
3605
  exports.LRUCache = LRUCache;
3606
+ exports.MISS_NONE_OUTCOME = MISS_NONE_OUTCOME;
3248
3607
  exports.Mixture = Mixture;
3608
+ exports.OUTCOME_DISPLAY_ORDER = OUTCOME_DISPLAY_ORDER;
3249
3609
  exports.PMF = PMF;
3610
+ exports.calculateBounceOdds = calculateBounceOdds;
3250
3611
  exports.clearParserCache = clearParserCache;
3612
+ exports.critProbability = critProbability;
3251
3613
  exports.getCachingEnabled = getCachingEnabled;
3252
3614
  exports.onAnyHit = onAnyHit;
3253
3615
  exports.onCritOnly = onCritOnly;
@@ -3260,5 +3622,6 @@ exports.onSaveHalfOnly = onSaveHalfOnly;
3260
3622
  exports.parse = parse;
3261
3623
  exports.pmfCache = pmfCache;
3262
3624
  exports.setCachingEnabled = setCachingEnabled;
3625
+ exports.sortOutcomes = sortOutcomes;
3263
3626
  //# sourceMappingURL=index.cjs.map
3264
3627
  //# sourceMappingURL=index.cjs.map