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