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