@yipe/dice 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +278 -8
  2. package/dist/builder/ac.d.ts +44 -1
  3. package/dist/builder/ac.d.ts.map +1 -1
  4. package/dist/builder/arguments.d.ts +6 -0
  5. package/dist/builder/arguments.d.ts.map +1 -0
  6. package/dist/builder/ast.d.ts +36 -7
  7. package/dist/builder/ast.d.ts.map +1 -1
  8. package/dist/builder/attack.d.ts +104 -2
  9. package/dist/builder/attack.d.ts.map +1 -1
  10. package/dist/builder/dc.d.ts +13 -0
  11. package/dist/builder/dc.d.ts.map +1 -1
  12. package/dist/builder/example.d.ts +11 -15
  13. package/dist/builder/example.d.ts.map +1 -1
  14. package/dist/builder/expression.d.ts +72 -0
  15. package/dist/builder/expression.d.ts.map +1 -0
  16. package/dist/builder/factory.d.ts +2 -3
  17. package/dist/builder/factory.d.ts.map +1 -1
  18. package/dist/builder/index.cjs +3844 -1273
  19. package/dist/builder/index.cjs.map +1 -1
  20. package/dist/builder/index.js +3836 -1274
  21. package/dist/builder/index.js.map +1 -1
  22. package/dist/builder/nodes.d.ts +8 -1
  23. package/dist/builder/nodes.d.ts.map +1 -1
  24. package/dist/builder/prob.d.ts +9 -0
  25. package/dist/builder/prob.d.ts.map +1 -1
  26. package/dist/builder/roll.d.ts +157 -19
  27. package/dist/builder/roll.d.ts.map +1 -1
  28. package/dist/builder/save.d.ts +6 -1
  29. package/dist/builder/save.d.ts.map +1 -1
  30. package/dist/builder/types.d.ts +19 -0
  31. package/dist/builder/types.d.ts.map +1 -1
  32. package/dist/common/bounce.d.ts +63 -5
  33. package/dist/common/bounce.d.ts.map +1 -1
  34. package/dist/common/lru-cache.d.ts +29 -1
  35. package/dist/common/lru-cache.d.ts.map +1 -1
  36. package/dist/common/types.d.ts +33 -0
  37. package/dist/common/types.d.ts.map +1 -1
  38. package/dist/index.cjs +1288 -388
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.js +1285 -389
  41. package/dist/index.js.map +1 -1
  42. package/dist/parser/dice.d.ts +52 -16
  43. package/dist/parser/dice.d.ts.map +1 -1
  44. package/dist/parser/parser.d.ts +1 -5
  45. package/dist/parser/parser.d.ts.map +1 -1
  46. package/dist/parser/rollType.d.ts +4 -4
  47. package/dist/parser/scaleDice.d.ts +14 -0
  48. package/dist/parser/scaleDice.d.ts.map +1 -0
  49. package/dist/pmf/mixture.d.ts +17 -3
  50. package/dist/pmf/mixture.d.ts.map +1 -1
  51. package/dist/pmf/pmf.d.ts +130 -33
  52. package/dist/pmf/pmf.d.ts.map +1 -1
  53. package/dist/pmf/query.d.ts +25 -12
  54. package/dist/pmf/query.d.ts.map +1 -1
  55. package/dist/turn/effects.d.ts +114 -0
  56. package/dist/turn/effects.d.ts.map +1 -0
  57. package/dist/turn/index.d.ts +3 -1
  58. package/dist/turn/index.d.ts.map +1 -1
  59. package/dist/turn/plan.d.ts +115 -12
  60. package/dist/turn/plan.d.ts.map +1 -1
  61. package/dist/turn/state.d.ts +22 -8
  62. package/dist/turn/state.d.ts.map +1 -1
  63. package/dist/turn/turn.d.ts +162 -26
  64. package/dist/turn/turn.d.ts.map +1 -1
  65. package/dist/turn/types.d.ts +180 -21
  66. package/dist/turn/types.d.ts.map +1 -1
  67. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -1,6 +1,27 @@
1
1
  'use strict';
2
2
 
3
3
  // src/common/bounce.ts
4
+ function faceWeights(faces, minimum = 0, reroll = 0) {
5
+ const f = Math.max(0, Math.floor(faces));
6
+ if (f <= 0) return [];
7
+ let weights = new Array(f).fill(1 / f);
8
+ const r = Math.max(0, Math.min(Math.floor(reroll), f));
9
+ if (r > 0) {
10
+ const rerollMass = r / f;
11
+ const uniformReroll = rerollMass / f;
12
+ weights = weights.map((_, i) => (i < r ? 0 : 1 / f) + uniformReroll);
13
+ }
14
+ const minV = Math.max(0, Math.floor(minimum));
15
+ if (minV > 1) {
16
+ const collapsed = new Array(f).fill(0);
17
+ for (let v = 1; v <= f; v++) {
18
+ const target = Math.min(f, Math.max(v, minV));
19
+ collapsed[target - 1] += weights[v - 1];
20
+ }
21
+ weights = collapsed;
22
+ }
23
+ return weights;
24
+ }
4
25
  function binom(n, k) {
5
26
  if (k < 0 || k > n) return 0;
6
27
  let result = 1;
@@ -35,19 +56,161 @@ function calculateBounceOdds(diceCount, dieFaces, options) {
35
56
  const minimumDieRoll = options?.minimumDieRoll ?? 0;
36
57
  const rerollDamageDice = options?.rerollDamageDice ?? 0;
37
58
  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(
59
+ const rerollLimit = Math.min(Math.floor(rerollDamageDice), diceCount);
60
+ if (rerollLimit <= 0 || pMatchFirst >= 1) return pMatchFirst;
61
+ const collapsed = minimumDieRoll >= 2;
62
+ const lightCount = collapsed ? dieFaces - minimumDieRoll : dieFaces;
63
+ const heavyWeight = collapsed ? minimumDieRoll / dieFaces : 0;
64
+ const pMissAfter = (rerolled, keptLight, heavyKept) => pAllDistinct(rerolled, dieFaces, lightCount - keptLight, heavyKept ? 0 : heavyWeight);
65
+ let missLightOnly = 1;
66
+ let missWithHeavy = 1;
67
+ for (let rerolled = 1; rerolled <= rerollLimit; rerolled++) {
68
+ const allLightKept = pMissAfter(rerolled, diceCount - rerolled, false);
69
+ missLightOnly = Math.min(missLightOnly, allLightKept);
70
+ missWithHeavy = Math.min(missWithHeavy, allLightKept);
71
+ if (rerolled < diceCount) {
72
+ missWithHeavy = Math.min(missWithHeavy, pMissAfter(rerolled, diceCount - 1 - rerolled, true));
73
+ }
74
+ }
75
+ const pLightOnly = pAllDistinct(diceCount, dieFaces, lightCount, 0);
76
+ const pWithHeavy = diceCount * heavyWeight * pAllDistinct(diceCount - 1, dieFaces, lightCount, 0);
77
+ return Math.min(
47
78
  1,
48
- pAtLeastOneRerollMatches + pRerolledMatch * (1 - pAtLeastOneRerollMatches)
79
+ pMatchFirst + pLightOnly * (1 - missLightOnly) + pWithHeavy * (1 - missWithHeavy)
49
80
  );
50
- return Math.min(1, pMatchFirst + pNoMatchFirst * pMatchAfterReroll);
81
+ }
82
+ function diceSumDistribution(dice, weights) {
83
+ let dist = /* @__PURE__ */ new Map([[0, 1]]);
84
+ for (let die = 0; die < dice; die++) {
85
+ const next = /* @__PURE__ */ new Map();
86
+ for (const [sum, mass] of dist) {
87
+ for (let face = 1; face <= weights.length; face++) {
88
+ const w = weights[face - 1] ?? 0;
89
+ if (w <= 0) continue;
90
+ const s = sum + face;
91
+ next.set(s, (next.get(s) ?? 0) + mass * w);
92
+ }
93
+ }
94
+ dist = next;
95
+ }
96
+ return dist;
97
+ }
98
+ function sumAllDistinctDistribution(dice, weights) {
99
+ const faceCount = weights.length;
100
+ let dp = /* @__PURE__ */ new Map([[0, /* @__PURE__ */ new Map([[0, 1]])]]);
101
+ for (let face = 1; face <= faceCount; face++) {
102
+ const w = weights[face - 1] ?? 0;
103
+ const next = /* @__PURE__ */ new Map();
104
+ for (const [count, sumMap] of dp) next.set(count, new Map(sumMap));
105
+ if (w > 0) {
106
+ for (const [count, sumMap] of dp) {
107
+ const nextCount = count + 1;
108
+ if (nextCount > dice) continue;
109
+ const target = next.get(nextCount) ?? /* @__PURE__ */ new Map();
110
+ for (const [sum, mass] of sumMap) {
111
+ const s = sum + face;
112
+ target.set(s, (target.get(s) ?? 0) + mass * w);
113
+ }
114
+ next.set(nextCount, target);
115
+ }
116
+ }
117
+ dp = next;
118
+ }
119
+ let factorial = 1;
120
+ for (let i = 2; i <= dice; i++) factorial *= i;
121
+ const chosen = dp.get(dice) ?? /* @__PURE__ */ new Map();
122
+ const result = /* @__PURE__ */ new Map();
123
+ for (const [sum, mass] of chosen) result.set(sum, mass * factorial);
124
+ return result;
125
+ }
126
+ function jointSumAndMatch(dice, weights) {
127
+ if (dice <= 1) return /* @__PURE__ */ new Map();
128
+ const total = diceSumDistribution(dice, weights);
129
+ const distinct = sumAllDistinctDistribution(dice, weights);
130
+ const rest = diceSumDistribution(dice - 2, weights);
131
+ const matchable = /* @__PURE__ */ new Set();
132
+ weights.forEach((weight, index) => {
133
+ if (weight <= 0) return;
134
+ for (const sum of rest.keys()) matchable.add(2 * (index + 1) + sum);
135
+ });
136
+ const result = /* @__PURE__ */ new Map();
137
+ for (const [sum, mass] of total) {
138
+ if (!matchable.has(sum)) continue;
139
+ const matchMass = Math.max(0, mass - (distinct.get(sum) ?? 0));
140
+ if (matchMass > 0) result.set(sum, matchMass);
141
+ }
142
+ return result;
143
+ }
144
+ function explodingPoolMkDistribution(pMax, count, budget) {
145
+ const binomial = (n, p) => {
146
+ const result = new Array(n + 1).fill(0);
147
+ result[0] = 1;
148
+ for (let trial = 0; trial < n; trial++) {
149
+ const next = new Array(n + 1).fill(0);
150
+ for (let successes = 0; successes <= trial; successes++) {
151
+ const mass = result[successes];
152
+ if (mass <= 0) continue;
153
+ next[successes] += mass * (1 - p);
154
+ next[successes + 1] += mass * p;
155
+ }
156
+ for (let i = 0; i <= n; i++) result[i] = next[i];
157
+ }
158
+ return result;
159
+ };
160
+ const memo = /* @__PURE__ */ new Map();
161
+ const f = (pending, remainingBudget) => {
162
+ if (pending === 0) return /* @__PURE__ */ new Map([["0,0", 1]]);
163
+ if (remainingBudget === 0) {
164
+ const binom2 = binomial(pending, pMax);
165
+ const result2 = /* @__PURE__ */ new Map();
166
+ for (let m = 0; m <= pending; m++) {
167
+ const mass = binom2[m];
168
+ if (mass > 0) result2.set(`${m},${pending - m}`, mass);
169
+ }
170
+ return result2;
171
+ }
172
+ const key = `${pending},${remainingBudget}`;
173
+ const cached = memo.get(key);
174
+ if (cached) return cached;
175
+ const result = /* @__PURE__ */ new Map();
176
+ const accumulate = (mk, mass) => {
177
+ result.set(mk, (result.get(mk) ?? 0) + mass);
178
+ };
179
+ for (const [mk, mass] of f(pending, remainingBudget - 1)) {
180
+ const [m, k] = mk.split(",").map(Number);
181
+ accumulate(`${m + 1},${k}`, mass * pMax);
182
+ }
183
+ for (const [mk, mass] of f(pending - 1, remainingBudget)) {
184
+ const [m, k] = mk.split(",").map(Number);
185
+ accumulate(`${m},${k + 1}`, mass * (1 - pMax));
186
+ }
187
+ memo.set(key, result);
188
+ return result;
189
+ };
190
+ return f(count, budget);
191
+ }
192
+ function allDistinctProbability(count, weights) {
193
+ const symmetric = new Array(count + 1).fill(0);
194
+ symmetric[0] = 1;
195
+ for (const weight of weights) {
196
+ if (weight <= 0) continue;
197
+ for (let chosen = count; chosen >= 1; chosen--) symmetric[chosen] += symmetric[chosen - 1] * weight;
198
+ }
199
+ let factorial = 1;
200
+ for (let i = 2; i <= count; i++) factorial *= i;
201
+ return factorial * symmetric[count];
202
+ }
203
+ function explodingPoolMatchProbability(weights, count, budget) {
204
+ if (count <= 0 || weights.length === 0) return 0;
205
+ const pMax = weights[weights.length - 1];
206
+ const nonMax = pMax < 1 ? weights.slice(0, -1).map((weight) => weight / (1 - pMax)) : [];
207
+ let pMatchTotal = 0;
208
+ for (const [mk, weight] of explodingPoolMkDistribution(pMax, count, budget)) {
209
+ const [m, k] = mk.split(",").map(Number);
210
+ if (m >= 2) pMatchTotal += weight;
211
+ else if (k >= 2) pMatchTotal += weight * (1 - allDistinctProbability(k, nonMax));
212
+ }
213
+ return Math.min(1, Math.max(0, pMatchTotal));
51
214
  }
52
215
 
53
216
  // src/common/errors.ts
@@ -62,12 +225,40 @@ var DiceParseError = class _DiceParseError extends Error {
62
225
  };
63
226
 
64
227
  // src/common/lru-cache.ts
228
+ var cachingEnabled = true;
229
+ var cacheGeneration = 0;
230
+ function setCachingEnabled(enabled) {
231
+ cachingEnabled = enabled;
232
+ if (!enabled) cacheGeneration++;
233
+ }
234
+ function getCachingEnabled() {
235
+ return cachingEnabled;
236
+ }
65
237
  var LRUCache = class {
66
- constructor(maxSize = 1e3) {
238
+ /**
239
+ * @param maxSize Entries kept before the least recently used is evicted. A capacity of 0 or
240
+ * less (or NaN) makes the cache store nothing.
241
+ */
242
+ constructor(maxSize = 1e3, options = {}) {
67
243
  this.maxSize = maxSize;
68
244
  this.cache = /* @__PURE__ */ new Map();
245
+ this.generation = cacheGeneration;
246
+ this.onInsert = options.onInsert;
247
+ this.followsCachingToggle = options.followsCachingToggle ?? false;
248
+ }
249
+ /** Drops the entries of a toggle-following cache when caching was turned off since last use. */
250
+ sync() {
251
+ if (this.followsCachingToggle && this.generation !== cacheGeneration) {
252
+ this.cache.clear();
253
+ this.generation = cacheGeneration;
254
+ }
255
+ }
256
+ get storing() {
257
+ this.sync();
258
+ return this.maxSize > 0 && (cachingEnabled || !this.followsCachingToggle);
69
259
  }
70
260
  get(key) {
261
+ if (!this.storing) return void 0;
71
262
  const value = this.cache.get(key);
72
263
  if (value === void 0) return void 0;
73
264
  this.cache.delete(key);
@@ -78,11 +269,13 @@ var LRUCache = class {
78
269
  this.cache.delete(key);
79
270
  }
80
271
  set(key, value) {
81
- if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
272
+ if (!this.storing) return this;
273
+ this.onInsert?.(value);
274
+ this.cache.delete(key);
275
+ if (this.cache.size >= this.maxSize) {
82
276
  const oldestKey = this.cache.keys().next().value;
83
277
  this.cache.delete(oldestKey);
84
278
  }
85
- this.cache.delete(key);
86
279
  this.cache.set(key, value);
87
280
  return this;
88
281
  }
@@ -90,15 +283,18 @@ var LRUCache = class {
90
283
  this.cache.clear();
91
284
  }
92
285
  get size() {
286
+ this.sync();
93
287
  return this.cache.size;
94
288
  }
95
289
  has(key) {
96
- return this.cache.has(key);
290
+ return this.storing && this.cache.has(key);
97
291
  }
98
292
  keys() {
293
+ this.sync();
99
294
  return this.cache.keys();
100
295
  }
101
296
  values() {
297
+ this.sync();
102
298
  return this.cache.values();
103
299
  }
104
300
  };
@@ -367,33 +563,14 @@ var _DiceQuery = class _DiceQuery {
367
563
  return probabilitySum;
368
564
  }
369
565
  /**
370
- * Returns damage values at specific percentiles.
566
+ * Returns damage values at specific percentiles: for each p, the smallest damage x with
567
+ * P(total ≤ x) ≥ p, exact at CDF boundaries (see {@link PMF.quantile}).
371
568
  *
372
569
  * Example: `query.percentiles([0.25, 0.5, 0.75])` → [8, 12, 18]
373
570
  * Use case: "What are my 25th, 50th, and 75th percentile damage values?"
374
571
  */
375
572
  percentiles(percentileValues) {
376
- const sortedDamageValues = this.combined.support();
377
- if (sortedDamageValues.length === 0) return percentileValues.map(() => 0);
378
- const cumulativeProbabilities = [];
379
- let runningProbabilitySum = 0;
380
- for (const damageValue of sortedDamageValues) {
381
- runningProbabilitySum += this.combined.map.get(damageValue).p;
382
- cumulativeProbabilities.push(runningProbabilitySum);
383
- }
384
- return percentileValues.map((targetPercentile) => {
385
- let leftBound = 0;
386
- let rightBound = cumulativeProbabilities.length - 1;
387
- while (leftBound <= rightBound) {
388
- const middleIndex = Math.floor((leftBound + rightBound) / 2);
389
- if (cumulativeProbabilities[middleIndex] >= targetPercentile) {
390
- rightBound = middleIndex - 1;
391
- } else {
392
- leftBound = middleIndex + 1;
393
- }
394
- }
395
- return leftBound < sortedDamageValues.length ? sortedDamageValues[leftBound] : sortedDamageValues[sortedDamageValues.length - 1];
396
- });
573
+ return percentileValues.map((p) => this.combined.quantile(p));
397
574
  }
398
575
  /**
399
576
  * Returns the minimum possible damage.
@@ -476,16 +653,14 @@ var _DiceQuery = class _DiceQuery {
476
653
  * Note:
477
654
  *
478
655
  * - You have to pass in an array of labels to avoid double-counting if you are
479
- * using multiple labels. You cannot just add them.
656
+ * using multiple labels. You cannot just add them. A label listed twice counts once.
480
657
  */
481
658
  probAtLeastOne(labels) {
482
- if (typeof labels === "string") {
483
- labels = [labels];
484
- }
659
+ const distinctLabels = typeof labels === "string" ? [labels] : [...new Set(labels)];
485
660
  let productOfNonOccurrence = 1;
486
661
  for (let diceIndex = 0; diceIndex < this.singles.length; diceIndex++) {
487
662
  let combinedProbability = 0;
488
- for (const label of labels) {
663
+ for (const label of distinctLabels) {
489
664
  combinedProbability += this.singleProb(diceIndex, label);
490
665
  }
491
666
  if (combinedProbability < 0) combinedProbability = 0;
@@ -541,10 +716,11 @@ var _DiceQuery = class _DiceQuery {
541
716
  * - "How likely am I to get exactly 2 successes out of 3 attacks?"
542
717
  * - "What's the probability that exactly half my attacks succeed?"
543
718
  *
544
- * Note: For arrays, an attack counts as a "success" if it has any of the specified labels.
545
- * This is different from probAtMostK, which counts an attack as a "success" if it has ALL of the specified labels.
719
+ * Note: For arrays, an attack counts as a "success" if it has any of the specified labels,
720
+ * as in probAtLeastK and probAtMostK. A negative k has probability 0.
546
721
  */
547
722
  probExactlyK(labels, k) {
723
+ if (k < 0) return 0;
548
724
  if (typeof labels === "string") {
549
725
  const probabilityArray = this.computeBinomialProbabilities(labels, k);
550
726
  return probabilityArray[k];
@@ -602,16 +778,24 @@ var _DiceQuery = class _DiceQuery {
602
778
  * - "How much damage do I expect from successful attacks?"
603
779
  * - "What's the damage contribution from critical hits specifically?"
604
780
  * - "How much damage comes from miss effects (like save-for-half spells)?"
781
+ *
782
+ * A single whose mass is not 1 is normalized exactly as {@link mean} normalizes it, so the
783
+ * contributions of labels that cover every outcome add up to `mean()`. A label listed twice
784
+ * counts once.
605
785
  */
606
786
  expectedDamageFrom(labels) {
607
- const wanted = Array.isArray(labels) ? labels : [labels];
787
+ const wanted = Array.isArray(labels) ? [...new Set(labels)] : [labels];
608
788
  let total = 0;
609
789
  for (const single of this.singles) {
790
+ const mass = single.mass();
791
+ if (mass <= 0) continue;
792
+ let contribution = 0;
610
793
  for (const [dmg, bin] of single) {
611
794
  let p = 0;
612
795
  for (const label of wanted) p += bin.count[label] ?? 0;
613
- total += dmg * p;
796
+ contribution += dmg * p;
614
797
  }
798
+ total += Math.abs(mass - 1) <= this._eps ? contribution : contribution / mass;
615
799
  }
616
800
  return total;
617
801
  }
@@ -778,10 +962,15 @@ var _DiceQuery = class _DiceQuery {
778
962
  return this.probAtLeastOne(labels);
779
963
  }
780
964
  /**
781
- * Returns the probability of missing (any type of miss).
965
+ * Returns the probability that at least one attack misses (either kind of miss:
966
+ * `missNone` or `missDamage`). For a single attack this is its miss chance.
967
+ *
968
+ * Example: `query.missChance()` → 0.45
969
+ * Use case: "What's the chance I miss at least once this turn?"
782
970
  *
783
- * Example: `query.missChance()` → 0.04
784
- * Use case: "What's the chance I miss completely this turn?"
971
+ * For the chance that every attack misses, use
972
+ * `probExactlyK(["missNone", "missDamage"], n)` with n attacks, or
973
+ * `probAtMostK(["hit", "crit"], 0)`.
785
974
  */
786
975
  missChance() {
787
976
  return this.probabilityOf(["missDamage", "missNone"]);
@@ -920,16 +1109,13 @@ var _DiceQuery = class _DiceQuery {
920
1109
  data: ccdfData
921
1110
  };
922
1111
  }
923
- /*
924
- Statistics snapshot of the query.
925
- */
926
1112
  /** Probability of doing strictly more than threshold damage (default >0). */
927
1113
  probDamageGreaterThan(threshold = 0) {
928
1114
  let acc = 0;
929
1115
  for (const [x, bin] of this.combined.map) if (x > threshold) acc += bin.p;
930
1116
  return acc;
931
1117
  }
932
- /** All outcome keys actually present (typed & ordered if you pass an order). */
1118
+ /** All outcome keys present in the PMF, ordered by `order` when given. */
933
1119
  outcomeKeys(order) {
934
1120
  const found = /* @__PURE__ */ new Set();
935
1121
  for (const [, bin] of this.combined.map) {
@@ -1097,17 +1283,10 @@ var _DiceQuery = class _DiceQuery {
1097
1283
  const averageDPR = this.mean();
1098
1284
  let damageChance = 0;
1099
1285
  for (const [x, bin] of this.combined.map) if (x > 0) damageChance += bin.p;
1100
- const { support, data } = this.toCDFSeries(false);
1101
- const quantile = (p) => {
1102
- if (support.length === 0) return 0;
1103
- for (let i = 0; i < support.length; i++)
1104
- if (data[i] >= p) return support[i];
1105
- return support[support.length - 1];
1106
- };
1107
1286
  const percentiles = {
1108
- p25: quantile(0.25),
1109
- p50: quantile(0.5),
1110
- p75: quantile(0.75)
1287
+ p25: this.combined.quantile(0.25),
1288
+ p50: this.combined.quantile(0.5),
1289
+ p75: this.combined.quantile(0.75)
1111
1290
  };
1112
1291
  return { averageDPR, damageChance, percentiles, outcomes: outcomeMap };
1113
1292
  }
@@ -1187,20 +1366,23 @@ var _DiceQuery = class _DiceQuery {
1187
1366
  return new _DiceQuery([this.combined.mapDamage(damageTransformFunction)]);
1188
1367
  }
1189
1368
  /**
1190
- * Returns a new DiceQuery with damage values scaled by a factor.
1191
- * Convenient wrapper around mapDamage for multiplicative scaling.
1369
+ * Returns a new DiceQuery with damage values scaled by `factor / denominator`.
1370
+ * Convenient wrapper around mapDamage for multiplicative scaling; see
1371
+ * {@link PMF.scaleDamage} for the rounding rules.
1192
1372
  *
1193
- * @param factor Scaling factor for damage values
1373
+ * @param factor Scaling factor for damage values (the numerator of a ratio)
1194
1374
  * @param rounding Rounding method: "floor" (default), "round", or "ceil"
1375
+ * @param denominator Divisor; integer `factor` and `denominator` round exactly
1195
1376
  * @returns New DiceQuery with scaled damage values
1196
1377
  *
1197
1378
  * @example
1198
1379
  * const baseAttack = parse("2d6 + 3");
1199
1380
  * const doubled = baseAttack.scaleDamage(2); // Double damage
1200
1381
  * const halfDamage = baseAttack.scaleDamage(0.5, "round"); // Half damage, rounded
1382
+ * const sevenTenths = baseAttack.scaleDamage(7, "floor", 10); // floor(7v/10), exactly
1201
1383
  */
1202
- scaleDamage(factor, rounding = "floor") {
1203
- return new _DiceQuery([this.combined.scaleDamage(factor, rounding)]);
1384
+ scaleDamage(factor, rounding = "floor", denominator = 1) {
1385
+ return new _DiceQuery([this.combined.scaleDamage(factor, rounding, denominator)]);
1204
1386
  }
1205
1387
  /**
1206
1388
  * Returns a new DiceQuery combining this query with another via convolution.
@@ -1281,14 +1463,69 @@ _DiceQuery.DEFAULT_OUTCOMES = [
1281
1463
  "missNone"
1282
1464
  ];
1283
1465
  var DiceQuery = _DiceQuery;
1284
- var pmfCache = new LRUCache(1e3);
1466
+
1467
+ // src/pmf/pmf.ts
1468
+ var sharedPMFCacheOptions = {
1469
+ onInsert: (pmf) => pmf.freeze(),
1470
+ followsCachingToggle: true
1471
+ };
1472
+ var pmfCache = new LRUCache(1e3, sharedPMFCacheOptions);
1473
+ var QUANTILE_RELATIVE_SLACK = 1e-12;
1474
+ var FrozenBinMap = class extends Map {
1475
+ constructor(source) {
1476
+ super();
1477
+ for (const [value, bin] of source) super.set(value, bin);
1478
+ }
1479
+ set(value) {
1480
+ throw new TypeError(`Cannot set damage value ${value}: this PMF is frozen (shared through a cache)`);
1481
+ }
1482
+ delete(value) {
1483
+ throw new TypeError(`Cannot delete damage value ${value}: this PMF is frozen (shared through a cache)`);
1484
+ }
1485
+ clear() {
1486
+ throw new TypeError("Cannot clear the map: this PMF is frozen (shared through a cache)");
1487
+ }
1488
+ };
1285
1489
  var _PMF = class _PMF {
1490
+ /**
1491
+ * @param map Damage value → bin. Typed read-only: a PMF is immutable once built. A PMF returned
1492
+ * from the library's caches is frozen: its bins, its map and the `map` property itself reject
1493
+ * writes (see {@link freeze}).
1494
+ */
1286
1495
  constructor(map = /* @__PURE__ */ new Map(), epsilon = EPS, normalized = false, identifier = `anon#${_PMF.__anonIdCounter++}`, _preservedProvenance = true) {
1287
1496
  this.map = map;
1288
1497
  this.epsilon = epsilon;
1289
1498
  this.normalized = normalized;
1290
1499
  this.identifier = identifier;
1291
1500
  this._preservedProvenance = _preservedProvenance;
1501
+ this._frozen = false;
1502
+ }
1503
+ /**
1504
+ * A PMF cache that freezes every stored PMF and follows `setCachingEnabled`. The library's
1505
+ * own caches are built with this.
1506
+ */
1507
+ static createCache(maxSize) {
1508
+ return new LRUCache(maxSize, sharedPMFCacheOptions);
1509
+ }
1510
+ /**
1511
+ * Freezes this PMF so it can be shared through a cache. Every bin is deep-frozen, including its
1512
+ * `count` and `attr` maps, so writing to one throws a `TypeError`. The map is replaced by a copy
1513
+ * whose `set`/`delete`/`clear` throw a `TypeError` (the map this PMF was built with stays the
1514
+ * caller's), and the `map` property becomes read-only, so assigning it throws too.
1515
+ * Returns this PMF.
1516
+ */
1517
+ freeze() {
1518
+ if (this._frozen) return this;
1519
+ const map = new FrozenBinMap(this.map);
1520
+ for (const bin of map.values()) {
1521
+ Object.freeze(bin.count);
1522
+ if (bin.attr) Object.freeze(bin.attr);
1523
+ Object.freeze(bin);
1524
+ }
1525
+ Object.freeze(map);
1526
+ Object.defineProperty(this, "map", { value: map, writable: false, enumerable: true, configurable: false });
1527
+ this._frozen = true;
1528
+ return this;
1292
1529
  }
1293
1530
  static empty(epsilon = EPS, identifier = "empty") {
1294
1531
  return new _PMF(/* @__PURE__ */ new Map(), epsilon, false, identifier);
@@ -1549,18 +1786,10 @@ var _PMF = class _PMF {
1549
1786
  }
1550
1787
  return acc ?? _PMF.emptyMass();
1551
1788
  }
1552
- // This is a convenience method for when we use power
1553
- // TODO: It can be smarter in the future, and we can also add it to query
1554
- // That way statistics operations on invalid PMFs can throw an error
1555
- // TODO… how can we detect if manually merging two queries' combined PMFs, as that loses provenance?
1556
- setPreservedProvenance(preserved) {
1557
- if (!this._preservedProvenance && preserved) {
1558
- throw new Error(
1559
- "Preserved provenance is already set to false, cannot fix that"
1560
- );
1561
- }
1562
- this._preservedProvenance = preserved;
1563
- }
1789
+ /**
1790
+ * False for a PMF produced by {@link power}, which folds independent attacks into one
1791
+ * distribution and cannot say which attack produced which label.
1792
+ */
1564
1793
  preservedProvenance() {
1565
1794
  return this._preservedProvenance;
1566
1795
  }
@@ -1571,14 +1800,12 @@ var _PMF = class _PMF {
1571
1800
  return `${key}@${eps}|${this.fingerprint()}`;
1572
1801
  }
1573
1802
  /**
1574
- * Efficiently computes this PMF convolved with itself `n` times.
1575
- * Uses exponentiation by squaring to reduce total convolutions.
1576
- * n must be a positive integer.
1577
- * *
1578
- * * NOTE: This folds multiple independent attacks into a single PMF.
1579
- * As a result, The power() method causes a loss of data provenance.
1580
- * This is ONLY SAFE if you are trying to calculate masses.
1581
- * If you want to query any atLeast probabilities, you should use the DiceQuery class instead without power().
1803
+ * Convolves this PMF with itself `n` times, by exponentiation by squaring. `n` must be a
1804
+ * positive integer.
1805
+ *
1806
+ * NOTE: this folds `n` independent, identical attacks into one PMF, so it loses data
1807
+ * provenance. It is only safe when computing masses; for `atLeast`-style queries use a
1808
+ * `DiceQuery` instead of `power()`.
1582
1809
  */
1583
1810
  power(n, eps = this.epsilon) {
1584
1811
  if (!Number.isInteger(n) || n <= 0) {
@@ -1587,10 +1814,8 @@ var _PMF = class _PMF {
1587
1814
  if (n === 1) return this;
1588
1815
  const epsilon = eps ?? this.epsilon;
1589
1816
  const key = this.getPowerCacheKey(n, epsilon);
1590
- {
1591
- const cached = pmfCache?.get(key);
1592
- if (cached) return cached;
1593
- }
1817
+ const cached = pmfCache.get(key);
1818
+ if (cached) return cached;
1594
1819
  let base = this.normalized ? this : this.normalize();
1595
1820
  let result = base;
1596
1821
  let exp = n - 1;
@@ -1603,11 +1828,15 @@ var _PMF = class _PMF {
1603
1828
  base = base.convolve(base, epsilon);
1604
1829
  }
1605
1830
  }
1606
- result.setPreservedProvenance(false);
1607
- {
1608
- pmfCache?.set(key, result);
1609
- }
1610
- return result;
1831
+ const folded = new _PMF(
1832
+ result.map,
1833
+ result.epsilon,
1834
+ result.normalized,
1835
+ result.identifier,
1836
+ false
1837
+ );
1838
+ pmfCache.set(key, folded);
1839
+ return folded;
1611
1840
  }
1612
1841
  /*
1613
1842
  * Helper for chaining multiple identical attacks
@@ -1726,8 +1955,11 @@ var _PMF = class _PMF {
1726
1955
  return this._max;
1727
1956
  }
1728
1957
  /**
1729
- * Returns the expected (mean) damage value.
1730
- * Cached for performance since this requires iterating through all bins.
1958
+ * Returns the expected (mean) damage value, Σ v·p. Cached.
1959
+ *
1960
+ * On a PMF whose mass is not 1 this is the partial expectation (the slice's contribution to
1961
+ * the whole distribution's mean), not the conditional mean; `DiceQuery.mean()` divides by the
1962
+ * mass instead.
1731
1963
  */
1732
1964
  mean() {
1733
1965
  if (this._mean === void 0) {
@@ -1740,18 +1972,25 @@ var _PMF = class _PMF {
1740
1972
  return this._mean;
1741
1973
  }
1742
1974
  /**
1743
- * Returns the variance of the damage distribution.
1744
- * Cached for performance since this requires mean calculation plus iteration.
1975
+ * Returns the variance of the damage distribution. Cached.
1976
+ *
1977
+ * On a PMF whose mass is not 1 (a slice such as {@link filterOutcome}'s output) this is the
1978
+ * variance of the distribution conditioned on the slice, Σ (v − μ)²·p / m with μ = Σ v·p / m
1979
+ * and m = {@link mass}, the same quantity `DiceQuery.variance()` reports for that PMF. Note
1980
+ * that {@link mean} is not conditioned: it stays the partial expectation Σ v·p, so slices add
1981
+ * up. Within 1e-12 of unit mass (or at zero or negative mass) no conditioning is applied.
1745
1982
  */
1746
1983
  variance() {
1747
1984
  if (this._variance === void 0) {
1748
- const meanValue = this.mean();
1985
+ const mass = this.mass();
1986
+ const conditional = mass > 0 && Math.abs(mass - 1) > EPS;
1987
+ const meanValue = conditional ? this.mean() / mass : this.mean();
1749
1988
  let varianceSum = 0;
1750
1989
  for (const [damageValue, probabilityBin] of this.map) {
1751
1990
  const deviationFromMean = damageValue - meanValue;
1752
1991
  varianceSum += deviationFromMean * deviationFromMean * probabilityBin.p;
1753
1992
  }
1754
- this._variance = varianceSum;
1993
+ this._variance = conditional ? varianceSum / mass : varianceSum;
1755
1994
  }
1756
1995
  return this._variance;
1757
1996
  }
@@ -1811,11 +2050,9 @@ var _PMF = class _PMF {
1811
2050
  return this.addScaled(other, 1);
1812
2051
  }
1813
2052
  /**
1814
- * Returns a new PMF with a scaled branch added to this one.
1815
- * The branch PMF is scaled by the given probability before merging
1816
- * This will be very useful for conditional effects and for being
1817
- * able to model "I can probably have this opportunity attack 40% of rounds"
1818
- * Example: `pmf.addScaled(critBranch, 0.05)` → PMF including 5% crit outcomes
2053
+ * Returns a new PMF with `branch` added to this one, scaled by `probability` before merging —
2054
+ * the primitive for conditional effects. Example: `pmf.addScaled(critBranch, 0.05)` → a PMF
2055
+ * including a 5% crit slice.
1819
2056
  */
1820
2057
  addScaled(branch, probability) {
1821
2058
  if (probability === 0) return this;
@@ -1838,40 +2075,42 @@ var _PMF = class _PMF {
1838
2075
  );
1839
2076
  }
1840
2077
  /**
1841
- * Redistributes probability mass to model an effect that only occurs with
1842
- * probability `frequency` — a conditional attack, an on-hit rider, or a
1843
- * sub-one AoE target fraction.
2078
+ * Bernoulli thinning: the effect this PMF describes happens with probability
2079
+ * `frequency` and otherwise deals nothing — a conditional attack, an on-hit
2080
+ * rider, or a sub-one AoE target fraction. The result is
2081
+ * `frequency · X + (1 − frequency) · δ0`.
1844
2082
  *
1845
- * Every hit outcome (damage > 0) is scaled by `frequency` — probability mass,
1846
- * per-label `count`, AND per-label `attr` — and the freed mass is moved into
1847
- * the miss bin at damage 0, tagged with the canonical `missNone` outcome.
1848
- * Total probability mass is preserved.
2083
+ * Every bin (negative damage included) keeps `frequency` of its probability
2084
+ * mass, per-label `count` and per-label `attr`; the zero bin keeps its labels
2085
+ * at that share too. The freed mass, `(1 − frequency) · mass()`, is added to
2086
+ * the damage-0 bin under the canonical `missNone` outcome, so the total mass
2087
+ * is unchanged, also for a slice whose mass is not 1.
1849
2088
  *
1850
2089
  * Unlike a bare {@link scaleMass} or {@link mapDamage}, this keeps damage
1851
2090
  * attribution (`attr`) intact, so a frequency-scaled PMF still renders
1852
2091
  * correctly in the damage-attribution charts.
1853
2092
  *
1854
2093
  * `frequency >= 1` (or non-finite) returns this PMF unchanged; `frequency <= 0`
1855
- * collapses all mass into the miss bin. The miss outcome is assumed to be
1856
- * encoded at damage value 0.
2094
+ * leaves only the damage-0 bin, holding all of the mass as `missNone`.
1857
2095
  *
1858
2096
  * @param frequency Probability in [0, 1] that the effect occurs.
1859
2097
  */
1860
2098
  applyHitFrequency(frequency) {
1861
2099
  if (!Number.isFinite(frequency) || frequency >= 1) return this;
1862
2100
  const freq = Math.max(0, frequency);
1863
- const pMiss = this.pAt(0);
1864
- const pHit = 1 - pMiss;
1865
- const newMissMass = pMiss + (1 - freq) * pHit;
2101
+ const freedMass = (1 - freq) * this.mass();
1866
2102
  const newMap = /* @__PURE__ */ new Map();
1867
- newMap.set(0, {
1868
- p: newMissMass,
1869
- count: { [MISS_NONE_OUTCOME]: newMissMass },
1870
- attr: {}
1871
- });
1872
- for (const [damage, bin] of this.map) {
1873
- if (damage <= 0) continue;
1874
- newMap.set(damage, _PMF.scaleBin(bin, freq));
2103
+ if (freq > 0) {
2104
+ for (const [damage, bin] of this.map) {
2105
+ newMap.set(damage, _PMF.scaleBin(bin, freq));
2106
+ }
2107
+ }
2108
+ if (freedMass > 0) {
2109
+ _PMF.mergeInto(newMap, 0, {
2110
+ p: freedMass,
2111
+ count: { [MISS_NONE_OUTCOME]: freedMass },
2112
+ attr: {}
2113
+ });
1875
2114
  }
1876
2115
  return new _PMF(
1877
2116
  newMap,
@@ -1880,6 +2119,71 @@ var _PMF = class _PMF {
1880
2119
  `freq(${this.identifier},${freq})`
1881
2120
  );
1882
2121
  }
2122
+ /**
2123
+ * Splits this PMF into two complementary PMFs by an arbitrary per-damage-value factor in
2124
+ * `[0, 1]` — bin `d`'s mass, `count`, and `attr` split `factor(d)` / `1 - factor(d)` between the
2125
+ * two results (via the same proportional scaling {@link applyHitFrequency} uses, `scaleBin`), so
2126
+ * `a.add(b)` recovers this PMF exactly and both halves stay chart-attributable. Unlike
2127
+ * {@link applyHitFrequency}, mass is NOT redistributed to a miss bin at 0 — each bin stays at its
2128
+ * own damage value in whichever half it lands in. `factor` outside `[0, 1]` is clamped.
2129
+ *
2130
+ * Built for `dice-match` trigger slicing: splitting a hit/crit sub-PMF into "matched" and
2131
+ * "did not match" halves by the exact per-damage-value match probability.
2132
+ */
2133
+ splitByFactor(factor) {
2134
+ const a = /* @__PURE__ */ new Map();
2135
+ const b = /* @__PURE__ */ new Map();
2136
+ for (const [damage, bin] of this.map) {
2137
+ const f = Math.min(1, Math.max(0, factor(damage)));
2138
+ if (f > 0) a.set(damage, _PMF.scaleBin(bin, f));
2139
+ if (f < 1) b.set(damage, _PMF.scaleBin(bin, 1 - f));
2140
+ }
2141
+ return [
2142
+ new _PMF(a, this.epsilon, false, `split+(${this.identifier})`),
2143
+ new _PMF(b, this.epsilon, false, `split-(${this.identifier})`)
2144
+ ];
2145
+ }
2146
+ /**
2147
+ * Max of two i.i.d. copies of this PMF's distribution: normalize, square
2148
+ * the CDF, then restore the original mass. The engine's damage-reroll
2149
+ * substitution (`onFirstHit(keepBestDamage())`) applies this to a landing
2150
+ * attack's base payload slice — "roll it again, keep the better total".
2151
+ *
2152
+ * PRESERVES outcome labels and attribution. A max, unlike a sum, is
2153
+ * literally one of the two draws: the value that wins was drawn from this
2154
+ * same distribution, so its `count`/`attr` composition is unchanged in
2155
+ * *proportion* — only that bin's total mass is recomputed (via the
2156
+ * squared-CDF step) and every label is rescaled by the same factor. This
2157
+ * is why {@link power}'s documented provenance loss does not apply here:
2158
+ * for a sum, one output value arises from many `(x1, x2)` pairs with
2159
+ * different attribution mixes, so provenance is genuinely ambiguous; for a
2160
+ * max, there is exactly one realized draw per bin. A naive rebuild through
2161
+ * {@link fromMap} would silently discard both `count` and `attr`.
2162
+ *
2163
+ * Works on a non-unit-mass slice (e.g. {@link filterOutcome}'s output):
2164
+ * the CDF is squared on the NORMALIZED distribution, then the result is
2165
+ * rescaled back to this PMF's original total mass, not to 1.
2166
+ */
2167
+ maxOfTwo() {
2168
+ const totalMass = this.mass();
2169
+ if (totalMass <= 0) return this;
2170
+ const resultMap = /* @__PURE__ */ new Map();
2171
+ let cdf = 0;
2172
+ for (const damage of this.support()) {
2173
+ const bin = this.map.get(damage);
2174
+ const normalizedP = bin.p / totalMass;
2175
+ if (normalizedP <= 0) continue;
2176
+ const prevCdf = cdf;
2177
+ cdf += normalizedP;
2178
+ resultMap.set(damage, _PMF.scaleBin(bin, cdf + prevCdf));
2179
+ }
2180
+ return new _PMF(
2181
+ resultMap,
2182
+ this.epsilon,
2183
+ this.normalized,
2184
+ `maxOfTwo(${this.identifier})`
2185
+ );
2186
+ }
1883
2187
  scaleMass(factor) {
1884
2188
  if (factor === 1) return this;
1885
2189
  const scaledMap = /* @__PURE__ */ new Map();
@@ -1910,9 +2214,37 @@ var _PMF = class _PMF {
1910
2214
  `map(${this.identifier})`
1911
2215
  );
1912
2216
  }
1913
- scaleDamage(factor, rounding = "floor") {
1914
- const roundFunction = rounding === "round" ? Math.round : rounding === "ceil" ? Math.ceil : Math.floor;
1915
- return this.mapDamage((damageValue) => roundFunction(damageValue * factor));
2217
+ /**
2218
+ * Multiplies every damage value by `factor / denominator` and rounds: `floor`
2219
+ * (toward −∞, the default), `ceil` (toward +∞) or `round` (to nearest, halves
2220
+ * toward +∞). Values that land on the same result merge, labels included.
2221
+ *
2222
+ * With an integer `factor` and `denominator` the rounding is exact: `v·factor`
2223
+ * is an exact integer, and one division of two integers below 2^53 lands on
2224
+ * the correct side of every integer. Pass a ratio that way, e.g.
2225
+ * `scaleDamage(9, "ceil", 7)`; `scaleDamage(9 / 7, "ceil")` rounds the factor
2226
+ * to a double first, so 21·(9/7) = 27.000000000000004 would round up to 28.
2227
+ */
2228
+ scaleDamage(factor, rounding = "floor", denominator = 1) {
2229
+ if (!Number.isFinite(factor)) {
2230
+ throw new RangeError(`scaleDamage() factor must be finite, got ${factor}`);
2231
+ }
2232
+ if (!Number.isFinite(denominator) || denominator === 0) {
2233
+ throw new RangeError(`scaleDamage() denominator must be finite and non-zero, got ${denominator}`);
2234
+ }
2235
+ const exact = Number.isInteger(factor) && Number.isInteger(denominator);
2236
+ return this.mapDamage((damageValue) => {
2237
+ if (exact && Number.isInteger(damageValue)) {
2238
+ const numerator = damageValue * factor;
2239
+ if (rounding === "round") {
2240
+ return Math.floor((2 * numerator + denominator) / (2 * denominator));
2241
+ }
2242
+ const quotient = numerator / denominator;
2243
+ return rounding === "ceil" ? Math.ceil(quotient) : Math.floor(quotient);
2244
+ }
2245
+ const scaled = damageValue * factor / denominator;
2246
+ return rounding === "round" ? Math.round(scaled) : rounding === "ceil" ? Math.ceil(scaled) : Math.floor(scaled);
2247
+ });
1916
2248
  }
1917
2249
  getPMFCombineCacheKey(p1, p2, eps, raw) {
1918
2250
  const [id1, id2] = [p1.identifier, p2.identifier].sort();
@@ -1928,16 +2260,16 @@ var _PMF = class _PMF {
1928
2260
  * because a PMF is immutable once constructed -- this avoids re-deriving the key on every
1929
2261
  * convolve()/power() call (including cache hits). Bin order is sorted by damage value (and
1930
2262
  * label keys sorted within each bin) so two equal-content PMFs built via different code paths
1931
- * fingerprint identically regardless of Map insertion order.
2263
+ * fingerprint identically regardless of Map insertion order. Label keys are JSON-encoded, so a
2264
+ * label containing the separators cannot make two different bins read the same.
1932
2265
  */
1933
2266
  fingerprint() {
1934
2267
  if (this._fingerprint === void 0) {
2268
+ const labels = (m) => m === void 0 ? "" : Object.keys(m).sort().map((k) => `${JSON.stringify(k)}:${m[k]}`).join(",");
1935
2269
  const bins = [...this.map.entries()].sort((a, b) => a[0] - b[0]);
1936
2270
  const parts = [];
1937
2271
  for (const [damageValue, bin] of bins) {
1938
- const countStr = Object.keys(bin.count).sort().map((k) => `${k}:${bin.count[k]}`).join(",");
1939
- const attrStr = bin.attr ? Object.keys(bin.attr).sort().map((k) => `${k}:${bin.attr[k]}`).join(",") : "";
1940
- parts.push(`${damageValue}:${bin.p}[${countStr}]{${attrStr}}`);
2272
+ parts.push(`${damageValue}:${bin.p}[${labels(bin.count)}]{${labels(bin.attr)}}`);
1941
2273
  }
1942
2274
  this._fingerprint = `${this.normalized ? 1 : 0}|${parts.join(";")}`;
1943
2275
  }
@@ -1950,15 +2282,21 @@ var _PMF = class _PMF {
1950
2282
  const B0 = norm(other);
1951
2283
  const [A, B] = A0.identifier <= B0.identifier ? [A0, B0] : [B0, A0];
1952
2284
  const cacheKey = this.getPMFCombineCacheKey(A, B, epsilon, raw);
1953
- const cached = pmfCache?.get(cacheKey);
2285
+ const cached = pmfCache.get(cacheKey);
1954
2286
  if (cached) return cached;
2287
+ const labelEntries = (m) => m === void 0 ? void 0 : Object.entries(m);
2288
+ const bEntries = [...B.map].map(([bVal, bBin]) => ({
2289
+ bVal,
2290
+ bp: bBin.p,
2291
+ count: labelEntries(bBin.count),
2292
+ attr: labelEntries(bBin.attr)
2293
+ }));
1955
2294
  const combinedMap = /* @__PURE__ */ new Map();
1956
2295
  for (const [aVal, aBin] of A.map) {
1957
2296
  const ap = aBin.p;
1958
- const aCount = aBin.count;
1959
- const aAttr = aBin.attr;
1960
- for (const [bVal, bBin] of B.map) {
1961
- const bp = bBin.p;
2297
+ const aCount = labelEntries(aBin.count);
2298
+ const aAttr = labelEntries(aBin.attr);
2299
+ for (const { bVal, bp, count: bCount, attr: bAttr } of bEntries) {
1962
2300
  const dmg = aVal + bVal;
1963
2301
  let dest = combinedMap.get(dmg);
1964
2302
  if (dest === void 0) {
@@ -1967,20 +2305,16 @@ var _PMF = class _PMF {
1967
2305
  }
1968
2306
  dest.p += ap * bp;
1969
2307
  const dc = dest.count;
1970
- for (const k in aCount) dc[k] = (dc[k] || 0) + aCount[k] * bp;
1971
- for (const k in bBin.count)
1972
- dc[k] = (dc[k] || 0) + bBin.count[k] * ap;
1973
- if (aAttr || bBin.attr) {
2308
+ for (const [k, v] of aCount) dc[k] = (dc[k] || 0) + v * bp;
2309
+ for (const [k, v] of bCount) dc[k] = (dc[k] || 0) + v * ap;
2310
+ if (aAttr || bAttr) {
1974
2311
  let da = dest.attr;
1975
2312
  if (da === void 0) {
1976
2313
  da = {};
1977
2314
  dest.attr = da;
1978
2315
  }
1979
- if (aAttr)
1980
- for (const k in aAttr) da[k] = (da[k] || 0) + aAttr[k] * bp;
1981
- if (bBin.attr)
1982
- for (const k in bBin.attr)
1983
- da[k] = (da[k] || 0) + bBin.attr[k] * ap;
2316
+ if (aAttr) for (const [k, v] of aAttr) da[k] = (da[k] || 0) + v * bp;
2317
+ if (bAttr) for (const [k, v] of bAttr) da[k] = (da[k] || 0) + v * ap;
1984
2318
  }
1985
2319
  }
1986
2320
  }
@@ -1997,10 +2331,10 @@ var _PMF = class _PMF {
1997
2331
  }
1998
2332
  if (!raw && mGot !== 0 && Math.abs(result.mass() - 1) > epsilon)
1999
2333
  result = result.normalize();
2000
- pmfCache?.set(cacheKey, result);
2334
+ pmfCache.set(cacheKey, result);
2001
2335
  return result;
2002
2336
  }
2003
- // 3) Nice wrapper so you can call pmf.combineRaw(other)
2337
+ // Convolve without renormalizing (raw = true), for callers that combine raw counts.
2004
2338
  combineRaw(other, eps) {
2005
2339
  return this.convolve(other, eps, true);
2006
2340
  }
@@ -2168,18 +2502,61 @@ var _PMF = class _PMF {
2168
2502
  for (const [val, bin] of this.map) if (val <= x) acc += bin.p;
2169
2503
  return acc;
2170
2504
  }
2171
- /** Quantile / inverse CDF for p in [0,1]. Returns smallest x with CDF ≥ p. */
2505
+ /**
2506
+ * Quantile / inverse CDF: the smallest support value x with P(X ≤ x) ≥ p·mass().
2507
+ * `p <= 0` gives the smallest support value; `p >= 1` or NaN gives the largest.
2508
+ *
2509
+ * A float running sum can land an ulp short of a CDF that is exactly p (a d20's CDF(10) sums
2510
+ * to 0.49999999999999994), so the comparison allows a relative slack of a few ulps per bin.
2511
+ * Below the median it compares the CDF summed from the low end; above it, the mass strictly
2512
+ * above x summed from the high end. A sum of non-negative terms is accurate relative to its
2513
+ * own size, so the tail that decides the answer is never swamped by the rest of the mass.
2514
+ */
2172
2515
  quantile(p) {
2173
- if (this.map.size === 0) return 0;
2174
- const totalMass = this.mass();
2175
- if (totalMass <= 0) return 0;
2176
- const s = this.support().sort((a, b) => a - b);
2177
- let acc = 0;
2178
- for (const x of s) {
2179
- acc += this.pAt(x);
2180
- if (acc / totalMass >= p) return x;
2516
+ const { values, below, above } = this.cumulative();
2517
+ const n = values.length;
2518
+ if (n === 0) return 0;
2519
+ const total = below[n - 1];
2520
+ if (!(total > 0)) return 0;
2521
+ if (p <= 0) return values[0];
2522
+ if (p >= 1) return values[n - 1];
2523
+ const slack = Math.max(QUANTILE_RELATIVE_SLACK, 4 * n * Number.EPSILON);
2524
+ const lowTarget = p * total * (1 - slack);
2525
+ const highTarget = (1 - p) * total * (1 + slack);
2526
+ const lowSide = p <= 0.5;
2527
+ let lo = 0;
2528
+ let hi = n;
2529
+ while (lo < hi) {
2530
+ const mid = lo + hi >>> 1;
2531
+ const reached = lowSide ? below[mid] >= lowTarget : above[mid] <= highTarget;
2532
+ if (reached) hi = mid;
2533
+ else lo = mid + 1;
2534
+ }
2535
+ return values[Math.min(lo, n - 1)];
2536
+ }
2537
+ /**
2538
+ * Sorted support with `below[i]` = Σ p over values ≤ values[i], summed from the low end,
2539
+ * and `above[i]` = Σ p over values > values[i], summed from the high end. Cached.
2540
+ */
2541
+ cumulative() {
2542
+ if (this._cumulative === void 0) {
2543
+ const values = this.support();
2544
+ const n = values.length;
2545
+ const below = new Array(n);
2546
+ const above = new Array(n);
2547
+ let sum = 0;
2548
+ for (let i = 0; i < n; i++) {
2549
+ sum += this.map.get(values[i]).p;
2550
+ below[i] = sum;
2551
+ }
2552
+ sum = 0;
2553
+ for (let i = n - 1; i >= 0; i--) {
2554
+ above[i] = sum;
2555
+ sum += this.map.get(values[i]).p;
2556
+ }
2557
+ this._cumulative = { values, below, above };
2181
2558
  }
2182
- return s[s.length - 1];
2559
+ return this._cumulative;
2183
2560
  }
2184
2561
  /** Get outcome probability at specific damage value. */
2185
2562
  outcomeAt(damage, outcome) {
@@ -2499,35 +2876,33 @@ var _PMF = class _PMF {
2499
2876
  const pNone = 1 - pSpecificSuccess - pGeneralSuccess;
2500
2877
  return { pSpecificSuccess, pGeneralSuccess, pNone, pAny };
2501
2878
  }
2879
+ /**
2880
+ * Maps every damage value through `f`, then optionally rounds it (`rounding`, default
2881
+ * `"none"`). Values that land on the same result merge their probability and, unless
2882
+ * `preserveCounts` is false, their per-label `count`. Damage attribution (`attr`) is dropped,
2883
+ * because it is tied to the old values. Nothing is pruned and the mass is unchanged.
2884
+ *
2885
+ * @param eps Epsilon carried by the result.
2886
+ * @throws Error when a mapped value is not a finite integer.
2887
+ */
2502
2888
  mapValues(f, eps = EPS, opts) {
2503
2889
  const rounding = opts?.rounding ?? "none";
2504
2890
  const preserveCounts = opts?.preserveCounts ?? true;
2505
2891
  const round = (x) => rounding === "floor" ? Math.floor(x) : rounding === "ceil" ? Math.ceil(x) : rounding === "round" ? Math.round(x) : x;
2506
- const probs = /* @__PURE__ */ new Map();
2507
- const counts = /* @__PURE__ */ new Map();
2892
+ const merged = /* @__PURE__ */ new Map();
2508
2893
  for (const [v, bin] of this) {
2509
- if (Math.abs(bin.p) < eps) continue;
2894
+ if (bin.p === 0) continue;
2510
2895
  const u = round(f(v));
2511
- probs.set(u, (probs.get(u) ?? 0) + bin.p);
2512
- if (preserveCounts) {
2513
- const src = bin.count;
2514
- if (src) {
2515
- const dest = counts.get(u) ?? {};
2516
- for (const k in src) {
2517
- dest[k] = (dest[k] ?? 0) + src[k];
2518
- }
2519
- counts.set(u, dest);
2520
- }
2896
+ if (!Number.isInteger(u)) {
2897
+ throw new Error(`mapValues: ${v} maps to ${u}, not a finite integer`);
2521
2898
  }
2899
+ _PMF.mergeInto(merged, u, {
2900
+ p: bin.p,
2901
+ count: preserveCounts ? bin.count : {}
2902
+ });
2522
2903
  }
2523
- const internal = /* @__PURE__ */ new Map();
2524
- for (const [u, p] of probs) {
2525
- internal.set(u, { p, count: counts.get(u) ?? {} });
2526
- }
2527
- return _PMF.fromMap(
2528
- new Map(Array.from(internal, ([u, b]) => [u, b.p])),
2529
- eps
2530
- );
2904
+ const sorted = new Map([...merged.entries()].sort((a, b) => a[0] - b[0]));
2905
+ return new _PMF(sorted, eps, this.normalized, `mapValues(${this.identifier})`);
2531
2906
  }
2532
2907
  static fromMap(m, eps = EPS, { requireIntegerValues = true } = {}) {
2533
2908
  const filtered = [];
@@ -2624,7 +2999,7 @@ var Dice = class _Dice {
2624
2999
  if (totalCount === 0) return 0;
2625
3000
  return expectedDamage / totalCount;
2626
3001
  }
2627
- // TODO this can be private later if we change how testing works
3002
+ // Public (no modifier) for direct test access.
2628
3003
  calculateHitDistribution() {
2629
3004
  const hitValues = {};
2630
3005
  const subtractedOutcomes = [
@@ -2645,7 +3020,7 @@ var Dice = class _Dice {
2645
3020
  }
2646
3021
  }
2647
3022
  if (numFace === 0) {
2648
- hitCount = 0;
3023
+ hitCount = this.outcomeData.hit?.[0] ?? 0;
2649
3024
  }
2650
3025
  if (hitCount < 0) {
2651
3026
  hitCount = 0;
@@ -2687,18 +3062,6 @@ var Dice = class _Dice {
2687
3062
  }
2688
3063
  return result;
2689
3064
  }
2690
- removeFaces(facesToRemove) {
2691
- const result = new _Dice();
2692
- for (const [key, value] of Object.entries(this.faces)) {
2693
- const numKey = Number(key);
2694
- if (!facesToRemove.includes(numKey)) {
2695
- result.faces[numKey] = value;
2696
- }
2697
- }
2698
- result.privateData = { ...this.privateData };
2699
- result.outcomeData = { ...this.outcomeData };
2700
- return result;
2701
- }
2702
3065
  // PUBLIC FUNCTIONS
2703
3066
  getFaceEntries() {
2704
3067
  return Object.entries(this.faces).map(([k, v]) => [Number(k), v]);
@@ -2744,13 +3107,20 @@ var Dice = class _Dice {
2744
3107
  const current = this.faces[face] || 0;
2745
3108
  this.faces[face] = current + count;
2746
3109
  }
3110
+ /** Scales every face count, and every outcome's counts with it, so each outcome keeps its share. */
2747
3111
  normalize(scalar) {
2748
3112
  const result = new _Dice();
2749
3113
  for (const [face, count] of Object.entries(this.faces)) {
2750
3114
  result.faces[Number(face)] = count * scalar;
2751
3115
  }
2752
3116
  result.privateData = { ...this.privateData };
2753
- result.outcomeData = { ...this.outcomeData };
3117
+ for (const [key, distribution] of Object.entries(this.outcomeData)) {
3118
+ const scaled = {};
3119
+ for (const [face, count] of Object.entries(distribution)) {
3120
+ scaled[Number(face)] = count * scalar;
3121
+ }
3122
+ result.outcomeData[key] = scaled;
3123
+ }
2754
3124
  return result;
2755
3125
  }
2756
3126
  // OPERATIONS
@@ -2788,10 +3158,18 @@ var Dice = class _Dice {
2788
3158
  return this.binaryOp(other, (a, b) => a / b);
2789
3159
  }
2790
3160
  divideRoundUp(other) {
2791
- return this.binaryOp(other, (a, b) => Math.ceil(a / b));
3161
+ this.assertNonZeroDivisor(other);
3162
+ return this.binaryOp(other, (a, b) => b === 0 ? 0 : Math.ceil(a / b));
2792
3163
  }
2793
3164
  divideRoundDown(other) {
2794
- return this.binaryOp(other, (a, b) => Math.floor(a / b));
3165
+ this.assertNonZeroDivisor(other);
3166
+ return this.binaryOp(other, (a, b) => b === 0 ? 0 : Math.floor(a / b));
3167
+ }
3168
+ /** A divisor that can be 0 has no quotient there. A 0 face with no weight is never rolled, so it passes. */
3169
+ assertNonZeroDivisor(other) {
3170
+ if (typeof other === "number" ? other === 0 : other.get(0) > 0) {
3171
+ throw new DiceParseError("Division by zero: the divisor can be 0");
3172
+ }
2795
3173
  }
2796
3174
  and(other) {
2797
3175
  return this.binaryOp(other, (a, b) => a && b ? 1 : 0);
@@ -2827,18 +3205,19 @@ var Dice = class _Dice {
2827
3205
  result.outcomeData = { ...this.outcomeData };
2828
3206
  return result;
2829
3207
  }
3208
+ /**
3209
+ * Roll once and, on a result in `toReroll`'s faces, roll again and keep the second roll. Each
3210
+ * result keeps its own weight: with T the total count and c_R the count on the rerolled faces,
3211
+ * face v's new count is c_v·(T·[v∉R] + c_R), i.e. p′(v) = p(v)·[v∉R] + P(R)·p(v).
3212
+ */
2830
3213
  reroll(toReroll) {
2831
- const rerollDice = typeof toReroll === "number" ? _Dice.scalar(toReroll) : toReroll;
2832
- const rerollKeys = rerollDice.keys();
2833
- const rerollSet = new Set(rerollKeys);
2834
- const removed = this.removeFaces(rerollKeys);
2835
- let result = new _Dice();
2836
- for (const face of this.keys()) {
2837
- const wasRerolled = rerollSet.has(face);
2838
- result = result.combine(removed);
2839
- if (wasRerolled) {
2840
- result = result.combine(this);
2841
- }
3214
+ const rerolled = new Set(typeof toReroll === "number" ? [toReroll] : toReroll.keys());
3215
+ const total = this.total();
3216
+ let rerolledCount = 0;
3217
+ for (const [face, count] of this.getFaceEntries()) if (rerolled.has(face)) rerolledCount += count;
3218
+ const result = new _Dice();
3219
+ for (const [face, count] of this.getFaceEntries()) {
3220
+ result.increment(face, count * ((rerolled.has(face) ? 0 : total) + rerolledCount));
2842
3221
  }
2843
3222
  return result;
2844
3223
  }
@@ -2901,6 +3280,7 @@ var Dice = class _Dice {
2901
3280
  const critDistro = this.getOutcomeDistribution("crit") || {};
2902
3281
  const missDistro = this.getOutcomeDistribution("missDamage") || {};
2903
3282
  const saveDistro = this.getOutcomeDistribution("saveHalf") || {};
3283
+ const saveFailDistro = this.getOutcomeDistribution("saveFail") || {};
2904
3284
  const pcDistro = this.getOutcomeDistribution("pc") || {};
2905
3285
  const isSaveHalf = Object.keys(saveDistro).length > 0;
2906
3286
  const isDCCheck = this.privateData.isDCCheck === true;
@@ -2944,15 +3324,15 @@ var Dice = class _Dice {
2944
3324
  if (saveDistro[face]) {
2945
3325
  const c = clampNonNeg(saveDistro[face] / total);
2946
3326
  if (c > 0) {
2947
- if (isSaveHalf) {
2948
- count.saveHalf = c;
2949
- attr.saveHalf = clampNonNeg(face * saveDistro[face] / total);
2950
- } else {
2951
- count.saveFail = (count.saveFail ?? 0) + c;
2952
- attr.saveFail = clampNonNeg(
2953
- (attr.saveFail ?? 0) + face * saveDistro[face] / total
2954
- );
2955
- }
3327
+ count.saveHalf = c;
3328
+ attr.saveHalf = clampNonNeg(face * saveDistro[face] / total);
3329
+ }
3330
+ }
3331
+ if (saveFailDistro[face]) {
3332
+ const c = clampNonNeg(saveFailDistro[face] / total);
3333
+ if (c > 0) {
3334
+ count.saveFail = (count.saveFail ?? 0) + c;
3335
+ attr.saveFail = clampNonNeg((attr.saveFail ?? 0) + face * saveFailDistro[face] / total);
2956
3336
  }
2957
3337
  }
2958
3338
  if (pcDistro[face]) {
@@ -2962,8 +3342,8 @@ var Dice = class _Dice {
2962
3342
  attr.pc = clampNonNeg(face * pcDistro[face] / total);
2963
3343
  }
2964
3344
  }
2965
- if (!isSaveHalf && !isDCCheck) {
2966
- const distroCountRaw = (hitDistro[face] || 0) + (critDistro[face] || 0) + (missDistro[face] || 0) + (saveDistro[face] || 0) + (pcDistro[face] || 0);
3345
+ if (!isDCCheck) {
3346
+ const distroCountRaw = (hitDistro[face] || 0) + (critDistro[face] || 0) + (missDistro[face] || 0) + (saveDistro[face] || 0) + (saveFailDistro[face] || 0) + (pcDistro[face] || 0);
2967
3347
  const unaccountedCount = clampNonNeg(faceCount - distroCountRaw);
2968
3348
  if (unaccountedCount > 0) {
2969
3349
  const frac = clampNonNeg(unaccountedCount / total);
@@ -2983,25 +3363,238 @@ var Dice = class _Dice {
2983
3363
  }
2984
3364
  };
2985
3365
 
3366
+ // src/parser/scaleDice.ts
3367
+ var isDigitOrN = (c) => c !== void 0 && (c >= "0" && c <= "9" || c === "n");
3368
+ var isPerDieOp = (op) => op === "reroll" || op === ">" || op === "<" || op === "!";
3369
+ var UndoubleableExpressionError = class extends Error {
3370
+ };
3371
+ var AmbiguousCritDoublingError = class extends Error {
3372
+ };
3373
+ var DiceTermReader = class {
3374
+ constructor(s, expression) {
3375
+ this.s = s;
3376
+ this.expression = expression;
3377
+ this.pos = 0;
3378
+ }
3379
+ read() {
3380
+ const expr = this.expr();
3381
+ if (this.pos !== this.s.length) this.fail(`unexpected '${this.s[this.pos]}'`);
3382
+ return expr;
3383
+ }
3384
+ fail(reason) {
3385
+ throw new UndoubleableExpressionError(`Cannot double the dice of "${this.expression}": ${reason}.`);
3386
+ }
3387
+ expr() {
3388
+ const first = this.chain();
3389
+ const rest = [];
3390
+ for (let op = this.operation(); op !== void 0; op = this.operation()) {
3391
+ if (op === "ac") {
3392
+ this.fail("it contains an attack check (a d20 roll against an AC), so it is not a damage expression");
3393
+ }
3394
+ if (op === "dc") {
3395
+ this.fail("it contains a saving throw check (a d20 roll against a DC), so it is not a damage expression");
3396
+ }
3397
+ const arg = op === "!" ? void 0 : this.chain();
3398
+ const c = this.s[this.pos];
3399
+ if (c === "x" || c === "c" || c === "s" || c === "m" || this.s.startsWith("pc", this.pos)) {
3400
+ this.fail("it contains a check-outcome clause (crit/save/pc/miss), so it is not a damage expression");
3401
+ }
3402
+ rest.push({ op, arg, end: this.pos });
3403
+ }
3404
+ return { first, rest };
3405
+ }
3406
+ chain() {
3407
+ const start = this.pos;
3408
+ const atoms = [];
3409
+ for (let atom = this.atom(); atom !== void 0; atom = this.atom()) atoms.push(atom);
3410
+ return { start, atoms };
3411
+ }
3412
+ atom() {
3413
+ const start = this.pos;
3414
+ const c = this.s[start];
3415
+ if (c === "(") {
3416
+ this.pos++;
3417
+ const expr = this.expr();
3418
+ if (this.s[this.pos] !== ")") this.fail("unbalanced parentheses");
3419
+ this.pos++;
3420
+ return { kind: "group", start, end: this.pos, expr };
3421
+ }
3422
+ if (c === "h" && this.s[start + 1] === "d" && isDigitOrN(this.s[start + 2])) {
3423
+ this.pos += 2;
3424
+ this.number();
3425
+ return { kind: "die", start, end: this.pos };
3426
+ }
3427
+ if (c === "d" && isDigitOrN(this.s[start + 1])) {
3428
+ this.pos += 1;
3429
+ this.number();
3430
+ return { kind: "die", start, end: this.pos };
3431
+ }
3432
+ if (c === "k") {
3433
+ const mode = this.s[start + 1];
3434
+ if (mode !== "h" && mode !== "l") this.fail("'k' must be followed by 'h' or 'l'");
3435
+ this.pos += 2;
3436
+ const kept = this.number();
3437
+ const inner = this.atom();
3438
+ if (inner === void 0) this.fail("a keep needs dice after it");
3439
+ return { kind: "keep", start, end: this.pos, inner, mode, kept };
3440
+ }
3441
+ if (isDigitOrN(c)) {
3442
+ const value = this.number();
3443
+ return { kind: "number", start, end: this.pos, value };
3444
+ }
3445
+ return void 0;
3446
+ }
3447
+ number() {
3448
+ let digits = "";
3449
+ while (isDigitOrN(this.s[this.pos])) {
3450
+ const ch = this.s[this.pos++];
3451
+ digits += ch === "n" ? "0" : ch;
3452
+ }
3453
+ if (digits.length === 0) this.fail(`expected a number at '${this.s[this.pos]}'`);
3454
+ return parseInt(digits, 10);
3455
+ }
3456
+ operation() {
3457
+ const rest = this.s.slice(this.pos);
3458
+ const op = ["reroll", "**", "//", "~+", "ac", "dc", "!", ">", "<", "+", "-", "&", "*", "/", "="].find(
3459
+ (token) => rest.startsWith(token)
3460
+ );
3461
+ if (op !== void 0) this.pos += op.length;
3462
+ return op;
3463
+ }
3464
+ };
3465
+ function atomHasDice(atom) {
3466
+ switch (atom.kind) {
3467
+ case "die":
3468
+ return true;
3469
+ case "number":
3470
+ return false;
3471
+ case "keep":
3472
+ return atomHasDice(atom.inner);
3473
+ case "group":
3474
+ return chainHasDice(atom.expr.first) || atom.expr.rest.some(({ op, arg }) => op !== "reroll" && arg !== void 0 && chainHasDice(arg));
3475
+ }
3476
+ }
3477
+ var chainHasDice = (chain) => chain.atoms.some(atomHasDice);
3478
+ var isSingleDieAtom = (atom) => atom.kind === "die" || atom.kind === "group" && isSingleDieExpr(atom.expr, atom.expr.rest.length);
3479
+ function isSingleDieExpr(expr, opCount) {
3480
+ const operands = [expr.first];
3481
+ for (const { op, arg } of expr.rest.slice(0, opCount)) {
3482
+ if (!isPerDieOp(op)) return false;
3483
+ if (op !== "reroll" && arg !== void 0) operands.push(arg);
3484
+ }
3485
+ let dice = 0;
3486
+ for (const chain of operands) {
3487
+ if (chain.atoms.length === 1 && isSingleDieAtom(chain.atoms[0])) dice++;
3488
+ else if (chainHasDice(chain)) return false;
3489
+ }
3490
+ return dice === 1;
3491
+ }
3492
+ function scaleParsedDice(expression, scale) {
3493
+ let cleaned = "";
3494
+ const original = [];
3495
+ for (let i = 0; i < expression.length; i++) {
3496
+ if (expression[i] === " ") continue;
3497
+ cleaned += expression[i].toLowerCase();
3498
+ original.push(i);
3499
+ }
3500
+ const root = new DiceTermReader(cleaned, expression).read();
3501
+ const edits = [];
3502
+ const span = (start, end) => ({ from: original[start], to: original[end - 1] + 1 });
3503
+ const wrap = (start, end, open) => {
3504
+ const { from, to } = span(start, end);
3505
+ edits.push({ from, to: from, text: open }, { from: to, to, text: ")" });
3506
+ };
3507
+ const source = (start, end) => {
3508
+ const { from, to } = span(start, end);
3509
+ return expression.slice(from, to);
3510
+ };
3511
+ const ambiguous = [];
3512
+ const keepReading = 'has no single doubled meaning (only keep-highest-of-1, "roll it N times, keep the best", doubles its dice inside each trial)';
3513
+ const noteKeep = (keep, trials) => {
3514
+ if (keep.mode === "h" && keep.kept === 1 || !atomHasDice(keep.inner)) return;
3515
+ ambiguous.push(`the keep \`${source(trials?.kind === "number" ? trials.start : keep.start, keep.inner.start)}\` ${keepReading}`);
3516
+ };
3517
+ const scaleKept = (inner) => {
3518
+ if (isSingleDieAtom(inner)) wrap(inner.start, inner.end, `(${scale}`);
3519
+ else if (inner.kind === "group") scaleExpr(inner.expr);
3520
+ else if (inner.kind === "keep") {
3521
+ noteKeep(inner);
3522
+ scaleKept(inner.inner);
3523
+ }
3524
+ };
3525
+ const scaleChain = (chain) => {
3526
+ const { atoms } = chain;
3527
+ if (atoms.length === 0) return;
3528
+ const last = atoms[atoms.length - 1];
3529
+ const counts = atoms.slice(0, -1);
3530
+ if (counts.some(atomHasDice)) {
3531
+ throw new UndoubleableExpressionError(
3532
+ `Cannot double the dice of "${expression}": a dice-valued repeat count (like d4d6) has no single dice term to double.`
3533
+ );
3534
+ }
3535
+ if (last.kind === "keep") {
3536
+ noteKeep(last, counts[counts.length - 1]);
3537
+ scaleKept(last.inner);
3538
+ } else if (isSingleDieAtom(last)) {
3539
+ const count = counts[counts.length - 1];
3540
+ if (count?.kind === "number") {
3541
+ edits.push({ ...span(count.start, count.end), text: String(count.value * scale) });
3542
+ } else {
3543
+ const at = original[last.start];
3544
+ edits.push({ from: at, to: at, text: String(scale) });
3545
+ }
3546
+ } else if (last.kind === "group") {
3547
+ scaleExpr(last.expr);
3548
+ }
3549
+ };
3550
+ function scaleExpr(expr) {
3551
+ let unitOps = 0;
3552
+ for (let i = 1; i <= expr.rest.length && isPerDieOp(expr.rest[i - 1].op); i++) {
3553
+ if (isSingleDieExpr(expr, i)) unitOps = i;
3554
+ }
3555
+ if (unitOps > 0) wrap(expr.first.start, expr.rest[unitOps - 1].end, `${scale}(`);
3556
+ else scaleChain(expr.first);
3557
+ let leftHasDice = unitOps > 0 || chainHasDice(expr.first);
3558
+ for (const { op, arg, end } of expr.rest.slice(unitOps)) {
3559
+ if (op === "reroll" || arg === void 0) continue;
3560
+ const argHasDice = chainHasDice(arg);
3561
+ if (op === "<" && leftHasDice && argHasDice) {
3562
+ ambiguous.push(`the lower of two dice terms \`${source(expr.first.start, end)}\` ${keepReading}`);
3563
+ }
3564
+ if (op === "&" && (leftHasDice || argHasDice)) {
3565
+ ambiguous.push(
3566
+ `the mix \`${source(expr.first.start, end)}\` has no single doubled meaning (an \`&\` weights each side by its count of outcomes, so doubling a side's dice also changes its share of the mix)`
3567
+ );
3568
+ }
3569
+ leftHasDice || (leftHasDice = argHasDice);
3570
+ scaleChain(arg);
3571
+ }
3572
+ }
3573
+ scaleExpr(root);
3574
+ if (ambiguous.length > 0) {
3575
+ throw new AmbiguousCritDoublingError(
3576
+ `Cannot double the dice of "${expression}" on a crit: ${ambiguous[0]}. Give the crit explicitly: onCrit(...) on an attack, critDamage on a rider, or a crit (...) clause.`
3577
+ );
3578
+ }
3579
+ let result = expression;
3580
+ for (const { from, to, text } of edits.sort((a, b) => b.from - a.from)) {
3581
+ result = result.slice(0, from) + text + result.slice(to);
3582
+ }
3583
+ return result;
3584
+ }
3585
+
2986
3586
  // src/parser/parser.ts
2987
3587
  var MAX_DIE_SIDES = 1e6;
2988
3588
  var MAX_DICE_COUNT = 1e4;
2989
- var MAX_KEEP_OUTCOMES = 1e6;
2990
- var parseCache = new LRUCache(1e3);
2991
- var cachingEnabled = true;
2992
- function setCachingEnabled(enabled) {
2993
- cachingEnabled = enabled;
2994
- if (!enabled) clearParserCache();
2995
- }
2996
- function getCachingEnabled() {
2997
- return cachingEnabled;
2998
- }
3589
+ var MAX_KEEP_WORK = 1e8;
3590
+ var MAX_EXACT_COUNT = Number.MAX_SAFE_INTEGER;
3591
+ var parseCache = PMF.createCache(1e3);
2999
3592
  function clearParserCache() {
3000
3593
  parseCache.clear();
3001
3594
  }
3002
3595
  function parse(expression, n = 0) {
3003
3596
  const cleaned = expression.replace(/ /g, "").toLowerCase();
3004
- if (cachingEnabled) {
3597
+ if (getCachingEnabled()) {
3005
3598
  const cacheKey = `${cleaned}:${n}`;
3006
3599
  const cached = parseCache.get(cacheKey);
3007
3600
  if (cached) return cached;
@@ -3024,8 +3617,21 @@ function parse(expression, n = 0) {
3024
3617
  { expression }
3025
3618
  );
3026
3619
  }
3620
+ const total = result.total();
3621
+ if (total === 0) {
3622
+ throw new DiceParseError(
3623
+ `Cannot parse dice expression [${expression}]: it has no outcomes (a d0 has no faces; it is only a reroll set, as in \`reroll d0\`)`,
3624
+ { expression }
3625
+ );
3626
+ }
3627
+ if (!Number.isFinite(total)) {
3628
+ throw new DiceParseError(
3629
+ `Cannot parse dice expression [${expression}]: its outcome counts overflow (too many dice combined to count exactly)`,
3630
+ { expression }
3631
+ );
3632
+ }
3027
3633
  const resultPMF = result.toPMF(-1);
3028
- if (cachingEnabled) {
3634
+ if (getCachingEnabled()) {
3029
3635
  const cacheKey = `${cleaned}:${n}`;
3030
3636
  parseCache.set(cacheKey, resultPMF);
3031
3637
  }
@@ -3044,61 +3650,72 @@ function subtractCounts(a, b) {
3044
3650
  for (const [key, value] of b.getFaceEntries()) result.increment(key, -value);
3045
3651
  return result;
3046
3652
  }
3047
- function parseExpression(arr, n) {
3048
- const result = (() => {
3049
- const res = parseArgument(arr, n);
3050
- return typeof res === "number" ? Dice.scalar(res) : res;
3051
- })();
3052
- let op = parseOperation(arr);
3053
- let finalResult = result;
3054
- let baseDieMeta = result.privateData?.checkDie && !result.privateData.checkDie.rerollOne ? result.privateData.checkDie : void 0;
3055
- let bonusOnly = Dice.scalar(0);
3653
+ var HIT_ONLY_OPS = /* @__PURE__ */ new Set([
3654
+ Dice.prototype.addNonZero,
3655
+ Dice.prototype.conditionalApply,
3656
+ Dice.prototype.multiply,
3657
+ Dice.prototype.divideRoundUp,
3658
+ Dice.prototype.divideRoundDown
3659
+ ]);
3660
+ var GATE_OPS = /* @__PURE__ */ new Set([Dice.prototype.ac, Dice.prototype.dc]);
3661
+ function lastGateAt(arr) {
3662
+ let depth = 0;
3663
+ let at;
3664
+ for (let i = 0; i < arr.length - 1; i++) {
3665
+ const c = arr[i];
3666
+ if (c === "(") depth++;
3667
+ else if (c === ")") {
3668
+ if (depth === 0) break;
3669
+ depth--;
3670
+ } else if (depth === 0 && (c === "a" || c === "d") && arr[i + 1] === "c") {
3671
+ at = arr.length - i;
3672
+ }
3673
+ }
3674
+ return at;
3675
+ }
3676
+ function parseExpression(arr, n, inCheck = false) {
3677
+ const gate = lastGateAt(arr);
3678
+ const buildsCheck = () => inCheck || gate !== void 0 && arr.length > gate;
3679
+ const readOperation = () => {
3680
+ const checkTerm = buildsCheck();
3681
+ const parsed = parseOperation(arr);
3682
+ return parsed === Dice.prototype.addNonZero && checkTerm ? Dice.prototype.add : parsed;
3683
+ };
3684
+ const first = parseArgument(arr, n, buildsCheck());
3685
+ let finalResult = typeof first === "number" ? Dice.scalar(first) : first;
3686
+ if (typeof first === "number") finalResult.privateData.noDie = true;
3687
+ let opText = finalResult.privateData.implicitCrit ? arr.join("") : void 0;
3688
+ let op = readOperation();
3056
3689
  while (op != null) {
3057
- const arg = !op.unary ? parseArgument(arr, n) : finalResult;
3058
- let acAlreadyApplied = false;
3059
- if (baseDieMeta) {
3060
- if (op === Dice.prototype.addNonZero) {
3061
- bonusOnly = bonusOnly.add(arg);
3062
- } else if (op === Dice.prototype.subtract) {
3063
- bonusOnly = bonusOnly.subtract(arg);
3064
- } else if (op === Dice.prototype.ac && typeof arg === "number") {
3065
- const natMaxSlice = bonusOnly.add(baseDieMeta.sides);
3066
- const restSlice = subtractCounts(finalResult, natMaxSlice);
3067
- const gatedNatMaxSlice = natMaxSlice.ac(arg);
3068
- finalResult = restSlice.ac(arg).combine(gatedNatMaxSlice);
3069
- finalResult.privateData.checkDie = baseDieMeta;
3070
- finalResult.privateData.natMaxCritSlice = gatedNatMaxSlice;
3071
- acAlreadyApplied = true;
3072
- baseDieMeta = void 0;
3073
- } else {
3074
- baseDieMeta = void 0;
3075
- }
3076
- }
3690
+ const pending = op === Dice.prototype.conditionalApply && finalResult.privateData.isACCheck ? arr.join("") : void 0;
3691
+ const arg = !op.unary ? parseArgument(arr, n, buildsCheck()) : finalResult;
3692
+ const hitText = pending?.slice(0, pending.length - arr.length);
3693
+ const termText = opText?.slice(0, opText.length - arr.length);
3694
+ const before = finalResult;
3695
+ if (op === Dice.prototype.combine) assertMixable(before, arg, arr);
3077
3696
  let crit;
3078
3697
  let critNorm = 1;
3079
- if (arr[0] === "x" || arr[0] === "c") {
3080
- const isXcrit = arr[0] === "x";
3081
- if (isXcrit) assertToken(arr, "x");
3082
- assertToken(arr, "c");
3083
- assertToken(arr, "r");
3084
- assertToken(arr, "i");
3085
- assertToken(arr, "t");
3086
- const count = isXcrit ? parseNumber(arr, n) : 1;
3087
- const trackedCritSlice = finalResult.privateData?.natMaxCritSlice;
3088
- if (count === 1 && trackedCritSlice) {
3089
- crit = trackedCritSlice;
3090
- finalResult = subtractCounts(finalResult, trackedCritSlice);
3698
+ const critClause = arr[0] === "x" || arr[0] === "c";
3699
+ const implicitCrit = !critClause && hitText !== void 0 && !finalResult.privateData.noDie;
3700
+ if (critClause || implicitCrit) {
3701
+ let count = 1;
3702
+ if (critClause) {
3703
+ const isXcrit = arr[0] === "x";
3704
+ if (isXcrit) assertToken(arr, "x");
3705
+ assertToken(arr, "c");
3706
+ assertToken(arr, "r");
3707
+ assertToken(arr, "i");
3708
+ assertToken(arr, "t");
3709
+ if (isXcrit) count = parseNumber(arr, n);
3710
+ }
3711
+ if (finalResult.privateData.noDie) {
3712
+ parseBinaryArgument(arg, arr, n);
3091
3713
  } else {
3092
- crit = new Dice();
3093
- for (let i = 0; i < count; i++) {
3094
- const max = finalResult.maxFace();
3095
- crit.setFace(max, finalResult.get(max));
3096
- finalResult = finalResult.deleteFace(max);
3097
- }
3714
+ ({ crit, rest: finalResult } = splitCrit(finalResult, count));
3715
+ critNorm = crit.total();
3716
+ crit = op.call(crit, critClause ? parseBinaryArgument(arg, arr, n) : critPayload(hitText, n));
3717
+ critNorm = crit && critNorm ? crit.total() / critNorm : 1;
3098
3718
  }
3099
- critNorm = crit.total();
3100
- crit = op.call(crit, parseBinaryArgument(arg, arr, n));
3101
- critNorm = crit && critNorm ? crit.total() / critNorm : 1;
3102
3719
  }
3103
3720
  let save;
3104
3721
  let saveNorm = 1;
@@ -3145,109 +3762,351 @@ function parseExpression(arr, n) {
3145
3762
  missNorm = miss && missNorm ? miss.total() / missNorm : 1;
3146
3763
  }
3147
3764
  let norm = finalResult.total();
3148
- if (!acAlreadyApplied) {
3765
+ const clause = crit !== void 0 || save !== void 0 || pc !== void 0 || miss !== void 0;
3766
+ const labelled = hasOutcomeLabels(finalResult);
3767
+ const operand = finalResult;
3768
+ if (!clause && labelled && HIT_ONLY_OPS.has(op)) {
3769
+ finalResult = applyByOutcome(finalResult, op, arg, termText, n);
3770
+ } else {
3149
3771
  finalResult = op.call(finalResult, arg);
3150
3772
  }
3773
+ if (operand.privateData.isDCCheck && HIT_ONLY_OPS.has(op)) {
3774
+ finalResult.setOutcomeDistribution("saveFail", op.call(operand.deleteFace(0), arg).getFaceMap());
3775
+ }
3776
+ if (!operand.privateData.isDCCheck && HIT_ONLY_OPS.has(op) && (hitText !== void 0 || operand.privateData.attackPayload)) {
3777
+ finalResult.privateData.attackPayload = true;
3778
+ const landed = landedHitsAtZero(operand, op, arg);
3779
+ if (landed > 0) finalResult.setOutcomeDistribution("hit", { 0: landed });
3780
+ } else if (op === Dice.prototype.combine && typeof arg !== "number" && arg.privateData.attackPayload) {
3781
+ finalResult.privateData.attackPayload = true;
3782
+ const landed = operand.getOutcomeCount("hit", 0) + arg.getOutcomeCount("hit", 0);
3783
+ if (landed > 0) finalResult.setOutcomeDistribution("hit", { 0: landed });
3784
+ }
3785
+ const gated = op === Dice.prototype.combine && typeof arg !== "number" && arg.privateData.isACCheck;
3786
+ if (op === Dice.prototype.ac || gated) finalResult.privateData.isACCheck = true;
3787
+ followNaturalRoll(before, op, arg, finalResult, clause);
3151
3788
  norm = norm ? finalResult.total() / norm : 1;
3152
3789
  if (crit) {
3153
- const result2 = combineDiceWithNormalization(
3790
+ const result = combineDiceWithNormalization(
3154
3791
  crit,
3155
3792
  critNorm,
3156
3793
  "crit",
3157
3794
  norm,
3158
3795
  finalResult
3159
3796
  );
3160
- norm = result2.newNorm;
3161
- finalResult = result2.updatedResult;
3797
+ norm = result.newNorm;
3798
+ finalResult = result.updatedResult;
3162
3799
  }
3163
3800
  if (save) {
3164
- const result2 = combineDiceWithNormalization(
3801
+ const result = combineDiceWithNormalization(
3165
3802
  save,
3166
3803
  saveNorm,
3167
3804
  "saveHalf",
3168
3805
  norm,
3169
3806
  finalResult
3170
3807
  );
3171
- norm = result2.newNorm;
3172
- finalResult = result2.updatedResult;
3808
+ norm = result.newNorm;
3809
+ finalResult = result.updatedResult;
3173
3810
  }
3174
3811
  if (miss) {
3175
- const result2 = combineDiceWithNormalization(
3812
+ const result = combineDiceWithNormalization(
3176
3813
  miss,
3177
3814
  missNorm,
3178
3815
  "missDamage",
3179
3816
  norm,
3180
3817
  finalResult
3181
3818
  );
3182
- norm = result2.newNorm;
3183
- finalResult = result2.updatedResult;
3819
+ norm = result.newNorm;
3820
+ finalResult = result.updatedResult;
3184
3821
  }
3185
3822
  if (pc) {
3186
- const result2 = combineDiceWithNormalization(
3823
+ const result = combineDiceWithNormalization(
3187
3824
  pc,
3188
3825
  pcNorm,
3189
3826
  "pc",
3190
3827
  norm,
3191
3828
  finalResult
3192
3829
  );
3193
- norm = result2.newNorm;
3194
- finalResult = result2.updatedResult;
3830
+ norm = result.newNorm;
3831
+ finalResult = result.updatedResult;
3195
3832
  }
3196
- op = parseOperation(arr);
3833
+ if (implicitCrit) finalResult.privateData.implicitCrit = { payload: hitText };
3834
+ opText = finalResult.privateData.implicitCrit ? arr.join("") : void 0;
3835
+ op = readOperation();
3197
3836
  }
3198
3837
  return finalResult;
3199
3838
  }
3200
- function parseArgument(s, n) {
3201
- let result = parseArgumentInternal(s, n);
3202
- while (true) {
3203
- const next = parseArgumentInternal(s, n);
3204
- if (next === void 0) break;
3839
+ function naturalSides(value) {
3840
+ if (typeof value === "number") return 0;
3841
+ const { critTrack, untrackedSides } = value.privateData;
3842
+ return critTrack ? critTrack.sides : untrackedSides ?? 0;
3843
+ }
3844
+ function outranks(sides, other) {
3845
+ if (sides === other) return false;
3846
+ if (sides === 20 || other === 20) return sides === 20;
3847
+ return sides > other;
3848
+ }
3849
+ function bareTrack(die) {
3850
+ return {
3851
+ sides: die.maxFace(),
3852
+ bare: true,
3853
+ slice: (face) => {
3854
+ const slice = new Dice();
3855
+ const weight = die.get(face);
3856
+ if (weight) slice.setFace(face, weight);
3857
+ return slice;
3858
+ }
3859
+ };
3860
+ }
3861
+ function asValue(value) {
3862
+ if (typeof value !== "number") return value;
3863
+ const scalar = Dice.scalar(value);
3864
+ scalar.privateData.noDie = true;
3865
+ return scalar;
3866
+ }
3867
+ function branchesOf(value) {
3868
+ return typeof value === "number" ? [asValue(value)] : value.privateData.branches ?? [value];
3869
+ }
3870
+ var hasOutcomeLabels = (value) => typeof value !== "number" && Object.keys(value.getFullOutcomeDistribution()).some((label) => label !== "hit");
3871
+ var isSave = (value) => typeof value !== "number" && value.privateData.isDCCheck === true;
3872
+ function assertMixable(left, right, rest) {
3873
+ if (hasOutcomeLabels(left) || hasOutcomeLabels(right)) {
3874
+ throw new Error(
3875
+ "an `&` mix of an attack already split into crit, miss or save outcomes has no single reading: mix the checks before the payload, like `((d20 AC 10) & (d20 AC 15)) * (1d6)`"
3876
+ );
3877
+ }
3878
+ if (isSave(left) !== isSave(right)) {
3879
+ throw new Error("an `&` mix of a saving throw (DC) with anything but another saving throw has no single set of outcomes");
3880
+ }
3881
+ const [next, after] = rest;
3882
+ if (next === "x" || next === "c" || next === "s" || next === "m" || next === "p" && after === "c") {
3883
+ throw new Error("a crit, save, pc or miss clause on an `&` mix has no single reading: put it after the payload's `*`");
3884
+ }
3885
+ }
3886
+ function followNaturalRoll(before, op, arg, after, clause) {
3887
+ const data = after.privateData;
3888
+ delete data.critTrack;
3889
+ delete data.untrackedSides;
3890
+ delete data.noDie;
3891
+ delete data.branches;
3892
+ const gate = GATE_OPS.has(op);
3893
+ if (!clause && before.privateData.noDie && (gate || typeof arg === "number" || arg.privateData.noDie)) {
3894
+ data.noDie = true;
3895
+ }
3896
+ const mixed = before.privateData.branches !== void 0 || !gate && typeof arg !== "number" && arg.privateData.branches !== void 0;
3897
+ if (!clause && op === Dice.prototype.combine) {
3898
+ data.branches = [...branchesOf(before), ...branchesOf(arg)];
3899
+ } else if (!clause && mixed && op !== Dice.prototype.reroll) {
3900
+ const advantage = op === Dice.prototype.advantage;
3901
+ const pairOp = advantage ? Dice.prototype.max : op;
3902
+ const lefts = branchesOf(before);
3903
+ const rights = advantage ? lefts : gate ? [asValue(arg)] : branchesOf(arg);
3904
+ data.branches = lefts.flatMap(
3905
+ (left) => rights.map((right) => {
3906
+ const part = pairOp.call(left, right);
3907
+ followNaturalRoll(left, pairOp, right, part, false);
3908
+ return part;
3909
+ })
3910
+ );
3911
+ }
3912
+ if (data.branches) {
3913
+ mixNaturalRolls(after, data.branches);
3914
+ return;
3915
+ }
3916
+ const track = before.privateData.critTrack;
3917
+ const argTrack = typeof arg === "number" || gate ? void 0 : arg.privateData.critTrack;
3918
+ const sides = naturalSides(before);
3919
+ const argSides = gate ? 0 : naturalSides(arg);
3920
+ const extremum = op === Dice.prototype.max || op === Dice.prototype.min;
3921
+ const own = track !== void 0 && outranks(sides, argSides);
3922
+ const followed = own ? track : argTrack && outranks(argSides, sides) ? argTrack : void 0;
3923
+ if (clause) ; else if (op === Dice.prototype.advantage || op === Dice.prototype.reroll) {
3924
+ if (track?.bare) data.critTrack = bareTrack(after);
3925
+ } else if (extremum && track?.bare && argTrack?.bare && sides === argSides) {
3926
+ data.critTrack = bareTrack(after);
3927
+ } else if (followed) {
3928
+ const apply = own ? (value) => op.call(value, arg) : (value) => op.call(before, value);
3929
+ const step = extremum ? (slice) => keptPart(slice, apply) : apply;
3930
+ data.critTrack = { sides: followed.sides, bare: false, slice: (face) => step(followed.slice(face)) };
3931
+ }
3932
+ if (!data.critTrack) {
3933
+ const top = outranks(argSides, sides) ? argSides : sides;
3934
+ if (top > 0) data.untrackedSides = top;
3935
+ }
3936
+ }
3937
+ function mixNaturalRolls(after, branches) {
3938
+ const sides = branches.reduce((top, branch) => outranks(naturalSides(branch), top) ? naturalSides(branch) : top, 0);
3939
+ if (sides === 0) return;
3940
+ const tracks = [];
3941
+ for (const branch of branches) {
3942
+ if (naturalSides(branch) !== sides) continue;
3943
+ const track = branch.privateData.critTrack;
3944
+ if (!track) {
3945
+ after.privateData.untrackedSides = sides;
3946
+ return;
3947
+ }
3948
+ tracks.push(track);
3949
+ }
3950
+ after.privateData.critTrack = {
3951
+ sides,
3952
+ bare: tracks.length === branches.length && tracks.every((track) => track.bare),
3953
+ slice: (face) => {
3954
+ const slice = new Dice();
3955
+ for (const track of tracks) slice.combineInPlace(track.slice(face));
3956
+ return slice;
3957
+ }
3958
+ };
3959
+ }
3960
+ function keptPart(slice, apply) {
3961
+ const result = new Dice();
3962
+ for (const [value, count] of slice.getFaceEntries()) {
3963
+ const face = new Dice();
3964
+ face.setFace(value, count);
3965
+ const kept = apply(face).get(value);
3966
+ if (kept) result.increment(value, kept);
3967
+ }
3968
+ return result;
3969
+ }
3970
+ function splitCrit(check, count) {
3971
+ if (count === 0) {
3972
+ const none = new Dice();
3973
+ return { crit: none, rest: subtractCounts(check, none) };
3974
+ }
3975
+ const track = check.privateData.critTrack;
3976
+ if (!track) {
3977
+ throw new Error(
3978
+ "crit rate cannot be computed exactly for this attack check: its natural roll is not one die (a dice-valued check like 2d20 or 2kh2d20, two d20s like d20 + d20, or advantage over a total). Build the attack with the builder API instead."
3979
+ );
3980
+ }
3981
+ const { sides } = track;
3982
+ if (count > sides) {
3983
+ throw new Error(`xcrit${count} is wider than the d${sides} it reads its natural roll from`);
3984
+ }
3985
+ let crit = new Dice();
3986
+ for (let face = sides; face > sides - count; face--) crit.combineInPlace(track.slice(face));
3987
+ const rest = subtractCounts(check, crit);
3988
+ const missed = crit.get(0);
3989
+ if (missed) {
3990
+ crit = crit.deleteFace(0);
3991
+ rest.increment(0, missed);
3992
+ }
3993
+ return { crit, rest };
3994
+ }
3995
+ function critPayload(text, n) {
3996
+ let doubled = text;
3997
+ try {
3998
+ doubled = scaleParsedDice(text.replace(/n/g, String(n)), 2);
3999
+ } catch (error) {
4000
+ if (!(error instanceof UndoubleableExpressionError)) throw error;
4001
+ }
4002
+ const chars = [...doubled];
4003
+ const payload = parseExpression(chars, n);
4004
+ if (chars.length > 0) {
4005
+ throw new Error(`Unexpected token '${chars[0]}' in the crit payload '${doubled}'`);
4006
+ }
4007
+ return payload;
4008
+ }
4009
+ function applyByOutcome(labelled, op, arg, termText, n) {
4010
+ const implicit = labelled.privateData.implicitCrit;
4011
+ const argTotal = typeof arg === "number" ? 1 : arg.total();
4012
+ const result = new Dice();
4013
+ let rest = labelled;
4014
+ let payload;
4015
+ for (const [label, distribution] of Object.entries(labelled.getFullOutcomeDistribution())) {
4016
+ if (label === "hit" || distribution === void 0) continue;
4017
+ const part = new Dice();
4018
+ for (const [face, count] of Object.entries(distribution)) part.increment(Number(face), count);
4019
+ rest = subtractCounts(rest, part);
4020
+ let applied;
4021
+ if (label === "crit" && implicit && termText !== void 0) {
4022
+ payload = implicit.payload + termText;
4023
+ const doubled = critPayload(payload, n);
4024
+ applied = doubled.normalize(part.total() * argTotal / doubled.total());
4025
+ } else {
4026
+ applied = op.call(part, arg);
4027
+ }
4028
+ result.combineInPlace(applied);
4029
+ result.setOutcomeDistribution(label, applied.getFaceMap());
4030
+ }
4031
+ result.combineInPlace(op.call(rest, arg));
4032
+ if (labelled.privateData.isDCCheck) result.privateData.isDCCheck = true;
4033
+ if (payload !== void 0) result.privateData.implicitCrit = { payload };
4034
+ return result;
4035
+ }
4036
+ function landedHitsAtZero(operand, op, arg) {
4037
+ let landed = 0;
4038
+ for (const [face, count] of Object.entries(operand.calculateHitDistribution())) {
4039
+ if (!(count > 0)) continue;
4040
+ const hit = new Dice();
4041
+ hit.setFace(Number(face), count);
4042
+ landed += op.call(hit, arg).get(0);
4043
+ }
4044
+ return landed;
4045
+ }
4046
+ function parseArgument(s, n, inCheck = false) {
4047
+ if (s[0] === "-") {
4048
+ s.shift();
4049
+ const operand = parseArgument(s, n, inCheck);
4050
+ if (typeof operand === "number") return 0 - operand;
4051
+ const zero = asValue(0);
4052
+ const negated = zero.subtract(operand);
4053
+ followNaturalRoll(zero, Dice.prototype.subtract, operand, negated, false);
4054
+ return negated;
4055
+ }
4056
+ let result = parseArgumentInternal(s, n, inCheck);
4057
+ if (result === void 0) {
4058
+ const at = s.length === 0 ? "the end of the expression" : `'${s.slice(0, 20).join("")}'`;
4059
+ throw new Error(`Expected a number, a die, a keep or '(' at ${at}`);
4060
+ }
4061
+ for (let next = parseArgumentInternal(s, n, inCheck); next !== void 0; next = parseArgumentInternal(s, n, inCheck)) {
3205
4062
  result = multiplyDiceByDice(result, next);
3206
4063
  }
3207
4064
  return result;
3208
4065
  }
3209
4066
  function multiplyDiceByDice(d1, d2) {
4067
+ const noDie = (typeof d1 === "number" || d1.privateData.noDie) && (typeof d2 === "number" || d2.privateData.noDie);
3210
4068
  if (typeof d1 === "number") d1 = Dice.scalar(d1);
3211
4069
  if (typeof d2 === "number") d2 = Dice.scalar(d2);
3212
4070
  const result = new Dice();
3213
4071
  const faces = /* @__PURE__ */ new Map();
3214
- let normalizationFactor = 1;
4072
+ let common = 1;
4073
+ const { keep } = d2.privateData;
3215
4074
  for (const key of d1.keys()) {
3216
- let face;
3217
- if (typeof key !== "number") {
3218
- continue;
3219
- }
3220
- if (d2.privateData.keep) {
3221
- const faceCount = d2.keys().length;
3222
- if (Math.pow(faceCount, key) > MAX_KEEP_OUTCOMES) {
3223
- throw new DiceParseError(
3224
- `Keep enumeration of ${faceCount}^${key} outcomes exceeds the maximum of ${MAX_KEEP_OUTCOMES}`
3225
- );
3226
- }
3227
- const repeat = Array(key).fill(d2);
3228
- face = opDice(repeat, d2.privateData.keep);
3229
- } else {
3230
- face = multiplyDice(key, d2);
3231
- }
3232
- normalizationFactor *= face.total();
4075
+ const face = keep ? keepDice(d2, key, keep) : multiplyDice(key, d2);
4076
+ common *= face.total();
3233
4077
  faces.set(key, face);
3234
4078
  }
4079
+ const exact = common <= MAX_EXACT_COUNT;
3235
4080
  for (const [k, face] of faces) {
3236
4081
  const count = d1.get(k);
3237
- result.combineInPlace(
3238
- face.normalize(count * normalizationFactor / face.total())
3239
- );
4082
+ result.combineInPlace(face.normalize((exact ? common : 1) * count / face.total()));
3240
4083
  }
3241
4084
  result.privateData.except = {};
4085
+ const [only, ...more] = d1.keys();
4086
+ const { critTrack } = d2.privateData;
4087
+ const rolls = keep === void 0 ? only : Math.min(only, keep.kept);
4088
+ const sides = outranks(naturalSides(d2), naturalSides(d1)) ? naturalSides(d2) : naturalSides(d1);
4089
+ if (rolls === 1 && more.length === 0 && critTrack?.bare) {
4090
+ result.privateData.critTrack = bareTrack(result);
4091
+ } else if (sides > 0) {
4092
+ result.privateData.untrackedSides = sides;
4093
+ }
4094
+ if (noDie) result.privateData.noDie = true;
3242
4095
  return result;
3243
4096
  }
3244
- function multiplyDice(n, d) {
4097
+ function assertRepeatCount(n) {
4098
+ if (!Number.isInteger(n) || n < 0) {
4099
+ throw new DiceParseError(`A repeat count must be a whole number of 0 or more; this one can be ${n}`);
4100
+ }
3245
4101
  if (n > MAX_DICE_COUNT) {
3246
4102
  throw new DiceParseError(
3247
4103
  `Dice count ${n} exceeds the maximum of ${MAX_DICE_COUNT}`
3248
4104
  );
3249
4105
  }
3250
- if (n === 0) return new Dice(0);
4106
+ }
4107
+ function multiplyDice(n, d) {
4108
+ assertRepeatCount(n);
4109
+ if (n === 0) return Dice.scalar(0);
3251
4110
  if (n === 1) return d;
3252
4111
  const half = Math.floor(n / 2);
3253
4112
  let result = multiplyDice(half, d);
@@ -3255,43 +4114,70 @@ function multiplyDice(n, d) {
3255
4114
  if (n % 2 === 1) {
3256
4115
  result = result.add(d);
3257
4116
  }
3258
- return result;
3259
- }
3260
- function opDice(diceList, keepFn) {
3261
- return opDiceInternal(diceList, new Dice(), 0, [], 1, keepFn);
4117
+ const total = result.total();
4118
+ return total > MAX_EXACT_COUNT ? result.normalize(1 / total) : result;
3262
4119
  }
3263
- function opDiceInternal(diceList, result, index, values, weight, combineFn) {
3264
- if (index === diceList.length) {
3265
- return result.combine(Dice.scalar(combineFn(values)).normalize(weight));
3266
- }
3267
- const currentDice = diceList[index];
3268
- for (const face of currentDice.keys()) {
3269
- values.push(face);
3270
- result = opDiceInternal(
3271
- diceList,
3272
- result,
3273
- index + 1,
3274
- values,
3275
- weight * currentDice.get(face),
3276
- combineFn
4120
+ function keepDice(die, count, { kept, lowest }) {
4121
+ assertRepeatCount(count);
4122
+ if (kept >= count) return multiplyDice(count, die);
4123
+ if (kept <= 0) return Dice.scalar(0);
4124
+ const faces = die.getFaceEntries().filter(([, weight]) => weight > 0).sort(([a], [b]) => lowest ? a - b : b - a);
4125
+ if (faces.length === 0) return new Dice();
4126
+ const span = Math.abs(faces[faces.length - 1][0] - faces[0][0]);
4127
+ const work = faces.length * kept * kept * (kept * span + 1);
4128
+ if (work > MAX_KEEP_WORK) {
4129
+ throw new DiceParseError(
4130
+ `Keep of ${kept} of ${count} copies of a ${faces.length}-face roll exceeds the maximum work of ${MAX_KEEP_WORK}`
3277
4131
  );
3278
- values.pop();
3279
4132
  }
4133
+ const tails = new Array(faces.length);
4134
+ for (let i = faces.length - 1, tail = 0; i >= 0; i--) tails[i] = tail += faces[i][1];
4135
+ const addTo = (map, key, p) => {
4136
+ map.set(key, (map.get(key) ?? 0) + p);
4137
+ };
4138
+ let states = Array.from({ length: kept }, () => /* @__PURE__ */ new Map());
4139
+ states[0].set(0, 1);
4140
+ const done = /* @__PURE__ */ new Map();
4141
+ faces.forEach(([value, weight], i) => {
4142
+ const q = weight / tails[i];
4143
+ const next = Array.from({ length: kept }, () => /* @__PURE__ */ new Map());
4144
+ states.forEach((sums, placed) => {
4145
+ if (sums.size === 0) return;
4146
+ const left = count - placed;
4147
+ const need = kept - placed;
4148
+ const few = [];
4149
+ let logChoose = 0;
4150
+ for (let c = 0; c < need; c++) {
4151
+ if (c > 0) logChoose += Math.log((left - c + 1) / c);
4152
+ few.push(q >= 1 ? 0 : Math.exp(logChoose + c * Math.log(q) + (left - c) * Math.log1p(-q)));
4153
+ }
4154
+ const enough = Math.max(0, 1 - few.reduce((total, p) => total + p, 0));
4155
+ for (const [sum, p] of sums) {
4156
+ few.forEach((pc, c) => {
4157
+ if (pc > 0) addTo(next[placed + c], sum + c * value, p * pc);
4158
+ });
4159
+ if (enough > 0) addTo(done, sum + need * value, p * enough);
4160
+ }
4161
+ });
4162
+ states = next;
4163
+ });
4164
+ const result = new Dice();
4165
+ for (const [sum, p] of done) result.increment(sum, p);
3280
4166
  return result;
3281
4167
  }
3282
- function parseArgumentInternal(s, n) {
4168
+ function parseArgumentInternal(s, n, inCheck = false) {
3283
4169
  if (s.length === 0) return;
3284
4170
  const c = s[0];
3285
4171
  switch (c) {
3286
4172
  case "(":
3287
4173
  s.shift();
3288
- return assertToken(s, ")", parseExpression(s, n));
4174
+ return assertToken(s, ")", parseExpression(s, n, inCheck));
3289
4175
  case "h":
3290
4176
  case "d":
3291
4177
  return parseDice(s, n);
3292
4178
  case "k":
3293
4179
  assertToken(s, "k");
3294
- return parseKeep(s, n);
4180
+ return parseKeep(s, n, inCheck);
3295
4181
  case "n":
3296
4182
  return parseNumber(s, n);
3297
4183
  default:
@@ -3335,10 +4221,11 @@ function parseDice(s, n) {
3335
4221
  );
3336
4222
  }
3337
4223
  let result = new Dice(sides);
4224
+ if (sides === 0) return result;
3338
4225
  if (rerollOne) {
3339
4226
  result = result.reroll(1);
3340
4227
  }
3341
- result.privateData.checkDie = { sides, rerollOne };
4228
+ result.privateData.critTrack = bareTrack(result);
3342
4229
  return result;
3343
4230
  }
3344
4231
  function peek(arr, expected) {
@@ -3366,31 +4253,24 @@ function parseNumber(s, n) {
3366
4253
  function isDigit(c) {
3367
4254
  return c >= "0" && c <= "9";
3368
4255
  }
3369
- function parseKeep(s, n) {
3370
- let keepLowest = false;
4256
+ function parseKeep(s, n, inCheck) {
4257
+ let lowest = false;
3371
4258
  if (peek(s, "l")) {
3372
4259
  assertToken(s, "l");
3373
- keepLowest = true;
4260
+ lowest = true;
3374
4261
  } else if (peek(s, "h")) {
3375
4262
  assertToken(s, "h");
3376
- keepLowest = false;
3377
4263
  } else {
3378
4264
  return;
3379
4265
  }
3380
- const keepCount = parseNumber(s, n);
3381
- const result = parseArgumentInternal(s, n);
4266
+ const kept = parseNumber(s, n);
4267
+ const result = parseArgumentInternal(s, n, inCheck);
3382
4268
  if (result instanceof Dice) {
3383
- result.privateData.keep = keepN(keepCount, keepLowest);
4269
+ result.privateData.keep = { kept, lowest };
3384
4270
  return result;
3385
4271
  }
3386
4272
  throw new Error("Expected Dice after keep modifier");
3387
4273
  }
3388
- function keepN(n, low) {
3389
- return (values) => {
3390
- const sorted = [...values].sort((a, b) => low ? a - b : b - a);
3391
- return sorted.slice(0, n).reduce((sum, val) => sum + val, 0);
3392
- };
3393
- }
3394
4274
  function parseOperation(s) {
3395
4275
  switch (s[0]) {
3396
4276
  case ")":
@@ -3533,7 +4413,7 @@ var Mixture = class _Mixture {
3533
4413
  }
3534
4414
  /**
3535
4415
  * Add a labeled component with a mixture weight.
3536
- * Weight can be any positive finite number. Very small contributions are pruned by eps.
4416
+ * Weight can be any positive finite number; only the ratios between weights matter.
3537
4417
  */
3538
4418
  add(label, pmf, weight = 1) {
3539
4419
  if (!Number.isFinite(weight) || weight <= 0) return this;
@@ -3541,7 +4421,7 @@ var Mixture = class _Mixture {
3541
4421
  const p = bin.p;
3542
4422
  if (p <= 0) continue;
3543
4423
  const add = weight * p;
3544
- if (!Number.isFinite(add) || Math.abs(add) < this.eps) continue;
4424
+ if (!Number.isFinite(add) || add <= 0) continue;
3545
4425
  this.totals.set(v, (this.totals.get(v) ?? 0) + add);
3546
4426
  const bag = this.labelMass.get(v) ?? {};
3547
4427
  bag[label] = (bag[label] ?? 0) + add;
@@ -3549,27 +4429,35 @@ var Mixture = class _Mixture {
3549
4429
  }
3550
4430
  return this;
3551
4431
  }
4432
+ /**
4433
+ * The normalized mixture. Each bin's `p` and per-label `count` are its raw mass divided by
4434
+ * the grand total, so labels sum to `p` whatever the weights summed to. Outcomes below
4435
+ * `eps` of the total (the pruning `eps` given to the constructor) are dropped first.
4436
+ *
4437
+ * @param eps Epsilon carried by the built PMF.
4438
+ */
3552
4439
  buildPMF(eps = EPS) {
3553
- let grand = 0;
3554
- let c = 0;
3555
- for (const m of this.totals.values()) {
3556
- const y = m - c;
3557
- const t = grand + y;
3558
- c = t - grand - y;
3559
- grand = t;
3560
- }
4440
+ const grand = kahanSum(this.totals.values());
3561
4441
  if (!(grand > 0)) throw new Error("Mixture: zero total mass");
4442
+ const threshold = this.eps * grand;
4443
+ const kept = [...this.totals].filter(([, m]) => m > 0 && m >= threshold);
4444
+ if (kept.length === 0) {
4445
+ throw new Error(`Mixture: pruning at eps ${this.eps} removed every outcome`);
4446
+ }
4447
+ const keptTotal = kept.length === this.totals.size ? grand : kahanSum(kept.map(([, m]) => m));
3562
4448
  const internal = /* @__PURE__ */ new Map();
3563
- for (const [v, m] of this.totals) {
3564
- if (m <= 0 || Math.abs(m) < this.eps) continue;
3565
- const count = this.labelMass.get(v) ?? {};
3566
- internal.set(v, { p: m / grand, count });
4449
+ for (const [v, m] of kept) {
4450
+ const count = {};
4451
+ const bag = this.labelMass.get(v) ?? {};
4452
+ for (const label in bag) count[label] = bag[label] / keptTotal;
4453
+ internal.set(v, { p: m / keptTotal, count });
3567
4454
  }
3568
4455
  return new PMF(internal, eps);
3569
4456
  }
3570
4457
  /**
3571
4458
  * Produce normalized *per-label* PMFs (labels independent).
3572
- * These are unlabeled PMFs built from the raw mass of that label alone.
4459
+ * These are unlabeled PMFs built from the raw mass of that label alone; values below `eps`
4460
+ * of the label's own mass are pruned.
3573
4461
  */
3574
4462
  byOutcome() {
3575
4463
  const labels = /* @__PURE__ */ new Set();
@@ -3578,12 +4466,16 @@ var Mixture = class _Mixture {
3578
4466
  }
3579
4467
  const out = {};
3580
4468
  for (const label of labels) {
4469
+ const labelTotal = kahanSum(
4470
+ [...this.labelMass.values()].map((bag) => bag[label] ?? 0)
4471
+ );
4472
+ if (!(labelTotal > 0)) continue;
3581
4473
  const m = /* @__PURE__ */ new Map();
3582
4474
  for (const [v, bag] of this.labelMass) {
3583
4475
  const w = bag[label];
3584
- if (w && Math.abs(w) >= this.eps) m.set(v, w);
4476
+ if (w) m.set(v, w / labelTotal);
3585
4477
  }
3586
- if (m.size > 0) out[label] = PMF.fromMap(m, this.eps);
4478
+ out[label] = PMF.fromMap(m, this.eps);
3587
4479
  }
3588
4480
  return out;
3589
4481
  }
@@ -3599,14 +4491,7 @@ var Mixture = class _Mixture {
3599
4491
  res[lab] = (res[lab] ?? 0) + w;
3600
4492
  }
3601
4493
  }
3602
- let total = 0;
3603
- let c = 0;
3604
- for (const v of Object.values(res)) {
3605
- const y = v - c;
3606
- const t = total + y;
3607
- c = t - total - y;
3608
- total = t;
3609
- }
4494
+ const total = kahanSum(Object.values(res));
3610
4495
  if (total > 0) {
3611
4496
  for (const k in res) res[k] = res[k] / total;
3612
4497
  }
@@ -3622,9 +4507,20 @@ var Mixture = class _Mixture {
3622
4507
  static mix(items, eps = EPS) {
3623
4508
  const mix = new _Mixture(eps);
3624
4509
  for (const [lab, pmf, w] of items) mix.add(lab, pmf, w);
3625
- return mix.buildPMF();
4510
+ return mix.buildPMF(eps);
3626
4511
  }
3627
4512
  };
4513
+ function kahanSum(values) {
4514
+ let sum = 0;
4515
+ let c = 0;
4516
+ for (const v of values) {
4517
+ const y = v - c;
4518
+ const t = sum + y;
4519
+ c = t - sum - y;
4520
+ sum = t;
4521
+ }
4522
+ return sum;
4523
+ }
3628
4524
 
3629
4525
  exports.ALL_OUTCOME_TYPES = ALL_OUTCOME_TYPES;
3630
4526
  exports.DiceParseError = DiceParseError;
@@ -3638,7 +4534,11 @@ exports.PMF = PMF;
3638
4534
  exports.calculateBounceOdds = calculateBounceOdds;
3639
4535
  exports.clearParserCache = clearParserCache;
3640
4536
  exports.critProbability = critProbability;
4537
+ exports.diceSumDistribution = diceSumDistribution;
4538
+ exports.explodingPoolMatchProbability = explodingPoolMatchProbability;
4539
+ exports.faceWeights = faceWeights;
3641
4540
  exports.getCachingEnabled = getCachingEnabled;
4541
+ exports.jointSumAndMatch = jointSumAndMatch;
3642
4542
  exports.onAnyHit = onAnyHit;
3643
4543
  exports.onCritOnly = onCritOnly;
3644
4544
  exports.onHitOnly = onHitOnly;