@yipe/dice 0.9.0 → 0.11.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 (41) hide show
  1. package/dist/builder/ac.d.ts.map +1 -1
  2. package/dist/builder/ast.d.ts +32 -2
  3. package/dist/builder/ast.d.ts.map +1 -1
  4. package/dist/builder/attack.d.ts +19 -0
  5. package/dist/builder/attack.d.ts.map +1 -1
  6. package/dist/builder/dc.d.ts.map +1 -1
  7. package/dist/builder/example.d.ts +4 -4
  8. package/dist/builder/example.d.ts.map +1 -1
  9. package/dist/builder/index.cjs +614 -121
  10. package/dist/builder/index.cjs.map +1 -1
  11. package/dist/builder/index.js +614 -122
  12. package/dist/builder/index.js.map +1 -1
  13. package/dist/builder/nodes.d.ts +1 -0
  14. package/dist/builder/nodes.d.ts.map +1 -1
  15. package/dist/builder/roll.d.ts +13 -1
  16. package/dist/builder/roll.d.ts.map +1 -1
  17. package/dist/builder/save.d.ts.map +1 -1
  18. package/dist/builder/types.d.ts +1 -0
  19. package/dist/builder/types.d.ts.map +1 -1
  20. package/dist/common/bounce.d.ts +45 -0
  21. package/dist/common/bounce.d.ts.map +1 -1
  22. package/dist/common/types.d.ts +33 -0
  23. package/dist/common/types.d.ts.map +1 -1
  24. package/dist/index.cjs +235 -15
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.js +232 -16
  27. package/dist/index.js.map +1 -1
  28. package/dist/parser/dice.d.ts +14 -0
  29. package/dist/parser/dice.d.ts.map +1 -1
  30. package/dist/pmf/pmf.d.ts +22 -4
  31. package/dist/pmf/pmf.d.ts.map +1 -1
  32. package/dist/turn/plan.d.ts +29 -6
  33. package/dist/turn/plan.d.ts.map +1 -1
  34. package/dist/turn/state.d.ts +16 -8
  35. package/dist/turn/state.d.ts.map +1 -1
  36. package/dist/turn/turn.d.ts +35 -0
  37. package/dist/turn/turn.d.ts.map +1 -1
  38. package/dist/turn/types.d.ts +32 -10
  39. package/dist/turn/types.d.ts.map +1 -1
  40. package/package.json +2 -2
  41. package/CHANGELOG.md +0 -456
@@ -1476,7 +1476,7 @@ var _PMF = class _PMF {
1476
1476
  const id = this.identifier;
1477
1477
  let key = `${id}`;
1478
1478
  for (let i = 1; i < n; i++) key += `+${id}`;
1479
- return `${key}@${eps}`;
1479
+ return `${key}@${eps}|${this.fingerprint()}`;
1480
1480
  }
1481
1481
  /**
1482
1482
  * Efficiently computes this PMF convolved with itself `n` times.
@@ -1788,6 +1788,30 @@ var _PMF = class _PMF {
1788
1788
  `freq(${this.identifier},${freq})`
1789
1789
  );
1790
1790
  }
1791
+ /**
1792
+ * Splits this PMF into two complementary PMFs by an arbitrary per-damage-value factor in
1793
+ * `[0, 1]` — bin `d`'s mass, `count`, and `attr` split `factor(d)` / `1 - factor(d)` between the
1794
+ * two results (via the same proportional scaling {@link applyHitFrequency} uses, `scaleBin`), so
1795
+ * `a.add(b)` recovers this PMF exactly and both halves stay chart-attributable. Unlike
1796
+ * {@link applyHitFrequency}, mass is NOT redistributed to a miss bin at 0 — each bin stays at its
1797
+ * own damage value in whichever half it lands in. `factor` outside `[0, 1]` is clamped.
1798
+ *
1799
+ * Built for `dice-match` trigger slicing: splitting a hit/crit sub-PMF into "matched" and
1800
+ * "did not match" halves by the exact per-damage-value match probability.
1801
+ */
1802
+ splitByFactor(factor) {
1803
+ const a = /* @__PURE__ */ new Map();
1804
+ const b = /* @__PURE__ */ new Map();
1805
+ for (const [damage, bin] of this.map) {
1806
+ const f = Math.min(1, Math.max(0, factor(damage)));
1807
+ if (f > 0) a.set(damage, _PMF.scaleBin(bin, f));
1808
+ if (f < 1) b.set(damage, _PMF.scaleBin(bin, 1 - f));
1809
+ }
1810
+ return [
1811
+ new _PMF(a, this.epsilon, false, `split+(${this.identifier})`),
1812
+ new _PMF(b, this.epsilon, false, `split-(${this.identifier})`)
1813
+ ];
1814
+ }
1791
1815
  scaleMass(factor) {
1792
1816
  if (factor === 1) return this;
1793
1817
  const scaledMap = /* @__PURE__ */ new Map();
@@ -1827,16 +1851,27 @@ var _PMF = class _PMF {
1827
1851
  return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${p1.fingerprint()}|${p2.fingerprint()}`;
1828
1852
  }
1829
1853
  /**
1830
- * A small content fingerprint (mass + bin count + face sum) so convolution
1831
- * cache keys change if the underlying numbers do. Memoized because a PMF is
1832
- * immutable once constructed — this avoids re-summing every key on each
1833
- * convolve() call (including cache hits).
1854
+ * A content fingerprint of every bin (probability, per-label `count`, per-label `attr`) plus
1855
+ * the `normalized` flag, so convolution/power cache keys change whenever the underlying
1856
+ * numbers do. Mass/bin-count/face-sum alone are not content-unique: `mapDamage` variants can
1857
+ * keep the same identifier, support, mass, and face sum while differing in per-bin
1858
+ * probabilities or in the `count`/`attr` channels `convolve()`/`power()` actually propagate --
1859
+ * that previously let `power()` return one PMF's cached result for a different PMF. Memoized
1860
+ * because a PMF is immutable once constructed -- this avoids re-deriving the key on every
1861
+ * convolve()/power() call (including cache hits). Bin order is sorted by damage value (and
1862
+ * label keys sorted within each bin) so two equal-content PMFs built via different code paths
1863
+ * fingerprint identically regardless of Map insertion order.
1834
1864
  */
1835
1865
  fingerprint() {
1836
1866
  if (this._fingerprint === void 0) {
1837
- let faceSum = 0;
1838
- for (const k of this.map.keys()) faceSum += k;
1839
- this._fingerprint = `${this.mass().toFixed(12)}|${this.map.size}|${faceSum}`;
1867
+ const bins = [...this.map.entries()].sort((a, b) => a[0] - b[0]);
1868
+ const parts = [];
1869
+ for (const [damageValue, bin] of bins) {
1870
+ const countStr = Object.keys(bin.count).sort().map((k) => `${k}:${bin.count[k]}`).join(",");
1871
+ const attrStr = bin.attr ? Object.keys(bin.attr).sort().map((k) => `${k}:${bin.attr[k]}`).join(",") : "";
1872
+ parts.push(`${damageValue}:${bin.p}[${countStr}]{${attrStr}}`);
1873
+ }
1874
+ this._fingerprint = `${this.normalized ? 1 : 0}|${parts.join(";")}`;
1840
1875
  }
1841
1876
  return this._fingerprint;
1842
1877
  }
@@ -2068,11 +2103,13 @@ var _PMF = class _PMF {
2068
2103
  /** Quantile / inverse CDF for p in [0,1]. Returns smallest x with CDF ≥ p. */
2069
2104
  quantile(p) {
2070
2105
  if (this.map.size === 0) return 0;
2106
+ const totalMass = this.mass();
2107
+ if (totalMass <= 0) return 0;
2071
2108
  const s = this.support().sort((a, b) => a - b);
2072
2109
  let acc = 0;
2073
2110
  for (const x of s) {
2074
2111
  acc += this.pAt(x);
2075
- if (acc >= p) return x;
2112
+ if (acc / totalMass >= p) return x;
2076
2113
  }
2077
2114
  return s[s.length - 1];
2078
2115
  }
@@ -2461,6 +2498,84 @@ var _PMF = class _PMF {
2461
2498
  _PMF.__anonIdCounter = 1;
2462
2499
  var PMF = _PMF;
2463
2500
 
2501
+ // src/common/bounce.ts
2502
+ function faceWeights(faces, minimum = 0, reroll = 0) {
2503
+ const f = Math.max(0, Math.floor(faces));
2504
+ if (f <= 0) return [];
2505
+ let weights = new Array(f).fill(1 / f);
2506
+ const r = Math.max(0, Math.min(Math.floor(reroll), f));
2507
+ if (r > 0) {
2508
+ const rerollMass = r / f;
2509
+ const uniformReroll = rerollMass / f;
2510
+ weights = weights.map((_, i) => (i < r ? 0 : 1 / f) + uniformReroll);
2511
+ }
2512
+ const minV = Math.max(0, Math.floor(minimum));
2513
+ if (minV > 1) {
2514
+ const collapsed = new Array(f).fill(0);
2515
+ for (let v = 1; v <= f; v++) {
2516
+ const target = Math.min(f, Math.max(v, minV));
2517
+ collapsed[target - 1] += weights[v - 1];
2518
+ }
2519
+ weights = collapsed;
2520
+ }
2521
+ return weights;
2522
+ }
2523
+ function diceSumDistribution(dice, weights) {
2524
+ let dist = /* @__PURE__ */ new Map([[0, 1]]);
2525
+ for (let die = 0; die < dice; die++) {
2526
+ const next = /* @__PURE__ */ new Map();
2527
+ for (const [sum, mass] of dist) {
2528
+ for (let face = 1; face <= weights.length; face++) {
2529
+ const w = weights[face - 1] ?? 0;
2530
+ if (w <= 0) continue;
2531
+ const s = sum + face;
2532
+ next.set(s, (next.get(s) ?? 0) + mass * w);
2533
+ }
2534
+ }
2535
+ dist = next;
2536
+ }
2537
+ return dist;
2538
+ }
2539
+ function sumAllDistinctDistribution(dice, weights) {
2540
+ const faceCount = weights.length;
2541
+ let dp = /* @__PURE__ */ new Map([[0, /* @__PURE__ */ new Map([[0, 1]])]]);
2542
+ for (let face = 1; face <= faceCount; face++) {
2543
+ const w = weights[face - 1] ?? 0;
2544
+ const next = /* @__PURE__ */ new Map();
2545
+ for (const [count, sumMap] of dp) next.set(count, new Map(sumMap));
2546
+ if (w > 0) {
2547
+ for (const [count, sumMap] of dp) {
2548
+ const nextCount = count + 1;
2549
+ if (nextCount > dice) continue;
2550
+ const target = next.get(nextCount) ?? /* @__PURE__ */ new Map();
2551
+ for (const [sum, mass] of sumMap) {
2552
+ const s = sum + face;
2553
+ target.set(s, (target.get(s) ?? 0) + mass * w);
2554
+ }
2555
+ next.set(nextCount, target);
2556
+ }
2557
+ }
2558
+ dp = next;
2559
+ }
2560
+ let factorial = 1;
2561
+ for (let i = 2; i <= dice; i++) factorial *= i;
2562
+ const chosen = dp.get(dice) ?? /* @__PURE__ */ new Map();
2563
+ const result = /* @__PURE__ */ new Map();
2564
+ for (const [sum, mass] of chosen) result.set(sum, mass * factorial);
2565
+ return result;
2566
+ }
2567
+ function jointSumAndMatch(dice, weights) {
2568
+ if (dice <= 1) return /* @__PURE__ */ new Map();
2569
+ const total = diceSumDistribution(dice, weights);
2570
+ const distinct = sumAllDistinctDistribution(dice, weights);
2571
+ const result = /* @__PURE__ */ new Map();
2572
+ for (const [sum, mass] of total) {
2573
+ const matchMass = Math.max(0, mass - (distinct.get(sum) ?? 0));
2574
+ if (matchMass > 0) result.set(sum, matchMass);
2575
+ }
2576
+ return result;
2577
+ }
2578
+
2464
2579
  // src/pmf/mixture.ts
2465
2580
  var Mixture = class _Mixture {
2466
2581
  constructor(eps = EPS) {
@@ -3051,6 +3166,12 @@ function combineDiceWithNormalization(dice, normValue, outcomeType, currentNorm,
3051
3166
  finalResult = finalResult.combine(dice);
3052
3167
  return { newNorm: currentNorm * normValue, updatedResult: finalResult };
3053
3168
  }
3169
+ function subtractCounts(a, b) {
3170
+ const result = new Dice();
3171
+ for (const [key, value] of a.getFaceEntries()) result.increment(key, value);
3172
+ for (const [key, value] of b.getFaceEntries()) result.increment(key, -value);
3173
+ return result;
3174
+ }
3054
3175
  function parseExpression(arr, n) {
3055
3176
  const result = (() => {
3056
3177
  const res = parseArgument(arr, n);
@@ -3058,8 +3179,29 @@ function parseExpression(arr, n) {
3058
3179
  })();
3059
3180
  let op = parseOperation(arr);
3060
3181
  let finalResult = result;
3182
+ let baseDieMeta = result.privateData?.checkDie && !result.privateData.checkDie.rerollOne ? result.privateData.checkDie : void 0;
3183
+ let bonusOnly = Dice.scalar(0);
3061
3184
  while (op != null) {
3062
3185
  const arg = !op.unary ? parseArgument(arr, n) : finalResult;
3186
+ let acAlreadyApplied = false;
3187
+ if (baseDieMeta) {
3188
+ if (op === Dice.prototype.addNonZero) {
3189
+ bonusOnly = bonusOnly.add(arg);
3190
+ } else if (op === Dice.prototype.subtract) {
3191
+ bonusOnly = bonusOnly.subtract(arg);
3192
+ } else if (op === Dice.prototype.ac && typeof arg === "number") {
3193
+ const natMaxSlice = bonusOnly.add(baseDieMeta.sides);
3194
+ const restSlice = subtractCounts(finalResult, natMaxSlice);
3195
+ const gatedNatMaxSlice = natMaxSlice.ac(arg);
3196
+ finalResult = restSlice.ac(arg).combine(gatedNatMaxSlice);
3197
+ finalResult.privateData.checkDie = baseDieMeta;
3198
+ finalResult.privateData.natMaxCritSlice = gatedNatMaxSlice;
3199
+ acAlreadyApplied = true;
3200
+ baseDieMeta = void 0;
3201
+ } else {
3202
+ baseDieMeta = void 0;
3203
+ }
3204
+ }
3063
3205
  let crit;
3064
3206
  let critNorm = 1;
3065
3207
  if (arr[0] === "x" || arr[0] === "c") {
@@ -3070,11 +3212,17 @@ function parseExpression(arr, n) {
3070
3212
  assertToken(arr, "i");
3071
3213
  assertToken(arr, "t");
3072
3214
  const count = isXcrit ? parseNumber(arr, n) : 1;
3073
- crit = new Dice();
3074
- for (let i = 0; i < count; i++) {
3075
- const max = finalResult.maxFace();
3076
- crit.setFace(max, finalResult.get(max));
3077
- finalResult = finalResult.deleteFace(max);
3215
+ const trackedCritSlice = finalResult.privateData?.natMaxCritSlice;
3216
+ if (count === 1 && trackedCritSlice) {
3217
+ crit = trackedCritSlice;
3218
+ finalResult = subtractCounts(finalResult, trackedCritSlice);
3219
+ } else {
3220
+ crit = new Dice();
3221
+ for (let i = 0; i < count; i++) {
3222
+ const max = finalResult.maxFace();
3223
+ crit.setFace(max, finalResult.get(max));
3224
+ finalResult = finalResult.deleteFace(max);
3225
+ }
3078
3226
  }
3079
3227
  critNorm = crit.total();
3080
3228
  crit = op.call(crit, parseBinaryArgument(arg, arr, n));
@@ -3125,7 +3273,9 @@ function parseExpression(arr, n) {
3125
3273
  missNorm = miss && missNorm ? miss.total() / missNorm : 1;
3126
3274
  }
3127
3275
  let norm = finalResult.total();
3128
- finalResult = op.call(finalResult, arg);
3276
+ if (!acAlreadyApplied) {
3277
+ finalResult = op.call(finalResult, arg);
3278
+ }
3129
3279
  norm = norm ? finalResult.total() / norm : 1;
3130
3280
  if (crit) {
3131
3281
  const result2 = combineDiceWithNormalization(
@@ -3316,6 +3466,7 @@ function parseDice(s, n) {
3316
3466
  if (rerollOne) {
3317
3467
  result = result.reroll(1);
3318
3468
  }
3469
+ result.privateData.checkDie = { sides, rerollOne };
3319
3470
  return result;
3320
3471
  }
3321
3472
  function peek(arr, expected) {
@@ -3507,6 +3658,12 @@ function d20PMF(rerollOne) {
3507
3658
  }
3508
3659
 
3509
3660
  // src/builder/roll.ts
3661
+ function validateScaleInt(scale) {
3662
+ const scaleInt = Math.floor(scale);
3663
+ if (scaleInt !== scale) throw new Error("Scale must be an integer");
3664
+ if (scaleInt <= 0) throw new Error("Scale must be > 0");
3665
+ return scaleInt;
3666
+ }
3510
3667
  var rollPMFCache = new LRUCache(4e3);
3511
3668
  function clearRollCache() {
3512
3669
  rollPMFCache.clear();
@@ -3517,16 +3674,17 @@ var defaultConfig = {
3517
3674
  modifier: 0,
3518
3675
  reroll: 0,
3519
3676
  explode: 0,
3677
+ explodePoolBudget: 0,
3520
3678
  minimum: 0,
3521
3679
  bestOf: 0,
3522
3680
  keep: void 0,
3523
3681
  rollType: "flat"
3524
3682
  };
3525
3683
  var rollConfigsEqual = (a, b) => {
3526
- return a.count === b.count && a.sides === b.sides && a.modifier === b.modifier && a.reroll === b.reroll && a.explode === b.explode && a.minimum === b.minimum && a.bestOf === b.bestOf && a.keep === b.keep && a.rollType === b.rollType;
3684
+ return a.count === b.count && a.sides === b.sides && a.modifier === b.modifier && a.reroll === b.reroll && a.explode === b.explode && a.explodePoolBudget === b.explodePoolBudget && a.minimum === b.minimum && a.bestOf === b.bestOf && a.keep === b.keep && a.rollType === b.rollType;
3527
3685
  };
3528
3686
  var configComplexityScore = (config) => {
3529
- return (config.reroll > 0 ? 1 : 0) + (config.explode > 0 ? 1 : 0) + (config.minimum > 0 ? 1 : 0) + (config.bestOf > 0 ? 1 : 0) + (config.keep !== void 0 ? 1 : 0) + (config.rollType !== "flat" ? 1 : 0);
3687
+ return (config.reroll > 0 ? 1 : 0) + (config.explode > 0 ? 1 : 0) + (config.explodePoolBudget > 0 ? 1 : 0) + (config.minimum > 0 ? 1 : 0) + (config.bestOf > 0 ? 1 : 0) + (config.keep !== void 0 ? 1 : 0) + (config.rollType !== "flat" ? 1 : 0);
3530
3688
  };
3531
3689
  var RollBuilder = class _RollBuilder {
3532
3690
  constructor(countOrConfigs = 1) {
@@ -3708,11 +3866,37 @@ var RollBuilder = class _RollBuilder {
3708
3866
  if (count === void 0) return this;
3709
3867
  if (count === 0) return this;
3710
3868
  if (count < 0) throw new Error("Explode count must be >= 0");
3869
+ if (this.lastConfig.explodePoolBudget > 0) {
3870
+ throw new Error(
3871
+ "Cannot set explode() on a config that already has a pool-wide explodePool() budget \u2014 the two exploding-dice semantics (per-die vs pool-wide) are mutually exclusive on one config."
3872
+ );
3873
+ }
3711
3874
  const newConfigs = this.getSubRollConfigs();
3712
3875
  newConfigs[newConfigs.length - 1].explode = count;
3713
3876
  return this.create(newConfigs);
3714
3877
  }
3715
- /** Apply per-die minimum value (min > 0). */
3878
+ /**
3879
+ * Set a pool-wide exploding-dice budget: at most `budget` extra dice may be added across the
3880
+ * WHOLE pool (shared), as opposed to {@link explode}'s per-die cap (`n` dice each individually
3881
+ * allowed up to `explode(k)` extra dice). `budget` must be a finite non-negative integer —
3882
+ * unlike `explode()`, `Infinity` is not accepted (it would make the pool-wide DP non-terminating).
3883
+ */
3884
+ explodePool(budget) {
3885
+ if (isNaN(budget)) throw new Error("Invalid NaN value for explodePool budget");
3886
+ if (!Number.isFinite(budget)) throw new Error("explodePool budget must be finite");
3887
+ if (budget < 0) throw new Error("explodePool budget must be >= 0");
3888
+ if (budget === 0) return this;
3889
+ if (this.lastConfig.explode > 0) {
3890
+ throw new Error(
3891
+ "Cannot set explodePool() on a config that already has a per-die explode() cap \u2014 the two exploding-dice semantics (per-die vs pool-wide) are mutually exclusive on one config."
3892
+ );
3893
+ }
3894
+ const newConfigs = this.getSubRollConfigs();
3895
+ newConfigs[newConfigs.length - 1].explodePoolBudget = Math.floor(budget);
3896
+ return this.create(newConfigs);
3897
+ }
3898
+ /** Apply per-die minimum value (floors each die roll at `val`, e.g. `minimum(3)` treats a 1 or
3899
+ * 2 as a 3 -- the 2024 Great Weapon Fighting style). */
3716
3900
  minimum(val) {
3717
3901
  if (val !== void 0 && isNaN(val))
3718
3902
  throw new Error("Invalid NaN value for minimum");
@@ -3720,7 +3904,7 @@ var RollBuilder = class _RollBuilder {
3720
3904
  if (val === 0) return this;
3721
3905
  if (val < 0) throw new Error("Minimum value must be >= 0");
3722
3906
  const newConfigs = this.getSubRollConfigs();
3723
- newConfigs[newConfigs.length - 1].minimum = val + 1;
3907
+ newConfigs[newConfigs.length - 1].minimum = val;
3724
3908
  return this.create(newConfigs);
3725
3909
  }
3726
3910
  bestOf(count) {
@@ -3821,9 +4005,7 @@ var RollBuilder = class _RollBuilder {
3821
4005
  return this.create(configs);
3822
4006
  }
3823
4007
  scaleDice(scale) {
3824
- const scaleInt = Math.floor(scale);
3825
- if (scaleInt !== scale) throw new Error("Scale must be an integer");
3826
- if (scaleInt <= 0) throw new Error("Scale must be > 0");
4008
+ const scaleInt = validateScaleInt(scale);
3827
4009
  const newConfigs = this.getSubRollConfigs().map((config) => {
3828
4010
  if (!config.sides || config.sides <= 0) return config;
3829
4011
  return { ...config, count: config.count * scaleInt };
@@ -3946,12 +4128,21 @@ var RollBuilder = class _RollBuilder {
3946
4128
  }
3947
4129
  configToSingleExpressionWithoutModifier(config, isRootDie) {
3948
4130
  if (!config.sides || config.sides <= 0) return "";
4131
+ if (config.explode && Number.isFinite(config.explode) && config.explode > 0) {
4132
+ throw new Error(
4133
+ `toExpression() cannot represent an exploding die (d${config.sides} explode(${config.explode})): the string grammar has no explode syntax. Use the builder's own PMF (.toPMF()/.pmf) instead of round-tripping through toExpression()/parse().`
4134
+ );
4135
+ }
4136
+ if (config.explodePoolBudget && config.explodePoolBudget > 0) {
4137
+ throw new Error(
4138
+ `toExpression() cannot represent a pool-wide exploding-dice budget (d${config.sides} explodePool(${config.explodePoolBudget})): the string grammar has no explode syntax. Use the builder's own PMF (.toPMF()/.pmf) instead of round-tripping through toExpression()/parse().`
4139
+ );
4140
+ }
3949
4141
  let baseDie = `d${config.sides}`;
4142
+ const rerollClause = config.reroll > 0 ? config.reroll === 1 ? " reroll 1" : ` reroll d${config.reroll}` : "";
3950
4143
  if (config.reroll > 0) {
3951
- if (config.minimum > 0 && config.explode > 0) ; else if (config.minimum > 0) {
3952
- for (let i = config.reroll; i >= 1; i--) baseDie += ` reroll ${i}`;
3953
- } else {
3954
- for (let i = 1; i <= config.reroll; i++) baseDie += ` reroll ${i}`;
4144
+ if (config.minimum > 0 && config.explode > 0) ; else {
4145
+ baseDie += rerollClause;
3955
4146
  }
3956
4147
  }
3957
4148
  if (config.minimum > 0) {
@@ -3961,9 +4152,7 @@ var RollBuilder = class _RollBuilder {
3961
4152
  baseDie = `${config.minimum}>${baseDie}`;
3962
4153
  }
3963
4154
  if (config.reroll > 0 && config.explode > 0) {
3964
- for (let i = 1; i <= config.reroll; i++) {
3965
- baseDie += ` reroll ${i}`;
3966
- }
4155
+ baseDie += rerollClause;
3967
4156
  }
3968
4157
  }
3969
4158
  if (baseDie === "d20 reroll 1" && config.minimum <= 1) baseDie = "hd20";
@@ -3981,10 +4170,14 @@ var RollBuilder = class _RollBuilder {
3981
4170
  case "flat":
3982
4171
  if (config.keep) {
3983
4172
  const mode = config.keep.mode === "highest" ? "kh" : "kl";
4173
+ const baseCount = Math.max(1, Math.floor(Math.abs(config.count || 1)));
4174
+ const trials = Math.max(1, Math.floor(config.keep.total));
4175
+ const isMaxOfShape = config.keep.count === 1 && config.keep.mode === "highest";
4176
+ const innerCount = trials === baseCount && !isMaxOfShape ? 1 : baseCount;
3984
4177
  const baseDieExpression = this.configToSingleExpressionWithoutModifier(
3985
4178
  {
3986
4179
  ...config,
3987
- count: config.count,
4180
+ count: innerCount,
3988
4181
  modifier: 0,
3989
4182
  rollType: "flat",
3990
4183
  keep: void 0
@@ -4016,7 +4209,19 @@ var RollBuilder = class _RollBuilder {
4016
4209
  }
4017
4210
  }
4018
4211
  if (config.bestOf && config.count && config.bestOf < config.count) {
4019
- mainExpression += `kh${config.bestOf}`;
4212
+ const pool = Math.max(1, Math.floor(Math.abs(config.count)));
4213
+ const baseDieExpression = this.configToSingleExpressionWithoutModifier(
4214
+ {
4215
+ ...config,
4216
+ count: 1,
4217
+ modifier: 0,
4218
+ bestOf: 0,
4219
+ keep: void 0,
4220
+ rollType: "flat"
4221
+ },
4222
+ false
4223
+ );
4224
+ mainExpression = `${pool}kh${Math.floor(config.bestOf)}(${baseDieExpression})`;
4020
4225
  }
4021
4226
  break;
4022
4227
  }
@@ -4119,6 +4324,12 @@ var HalfRollBuilder = class _HalfRollBuilder extends RollBuilder {
4119
4324
  toPMF(eps = 0) {
4120
4325
  return pmfFromRollBuilder(this, eps);
4121
4326
  }
4327
+ // Scale the dice, keep the same // 2 (half) transform applied on top -- delegating to the
4328
+ // base class's `create()`-based scaleDice would drop the halving entirely, e.g. a doubled-dice
4329
+ // crit on a resisted hit payload silently losing the resistance.
4330
+ scaleDice(scale) {
4331
+ return new _HalfRollBuilder(this.innerRoll.scaleDice(scale));
4332
+ }
4122
4333
  copy() {
4123
4334
  return new _HalfRollBuilder(this.innerRoll.copy());
4124
4335
  }
@@ -4145,9 +4356,16 @@ var ScaleRollBuilder = class _ScaleRollBuilder extends RollBuilder {
4145
4356
  }
4146
4357
  toExpression() {
4147
4358
  const inner = this.innerRoll.toExpression();
4148
- if (this.denominator === 1) return `${this.numerator} * (${inner})`;
4149
- if (this.numerator === 1) return `(${inner}) // ${this.denominator}`;
4150
- return `(${inner}) * ${this.numerator} // ${this.denominator}`;
4359
+ const denominator = this.denominator === 0 ? 1 : this.denominator;
4360
+ if (denominator === 1) return `${this.numerator} ** (${inner})`;
4361
+ if (this.rounding === "round") {
4362
+ throw new Error(
4363
+ `toExpression() cannot represent scaleResult(${this.numerator}, ${this.denominator}, "round"): the string grammar has only floor (//) and ceil (/) division. Use the builder's own PMF (.toPMF()/.pmf) instead.`
4364
+ );
4365
+ }
4366
+ const div = this.rounding === "ceil" ? "/" : "//";
4367
+ if (this.numerator === 1) return `(${inner}) ${div} ${denominator}`;
4368
+ return `(${inner}) ** ${this.numerator} ${div} ${denominator}`;
4151
4369
  }
4152
4370
  toAST() {
4153
4371
  return {
@@ -4161,6 +4379,17 @@ var ScaleRollBuilder = class _ScaleRollBuilder extends RollBuilder {
4161
4379
  toPMF(eps = 0) {
4162
4380
  return pmfFromRollBuilder(this, eps);
4163
4381
  }
4382
+ // Scale the dice, keep the same numerator/denominator/rounding transform applied on top --
4383
+ // delegating to the base class's `create()`-based scaleDice would drop the scale entirely,
4384
+ // e.g. a doubled-dice crit on a vulnerable hit payload silently losing the vulnerability.
4385
+ scaleDice(scale) {
4386
+ return new _ScaleRollBuilder(
4387
+ this.innerRoll.scaleDice(scale),
4388
+ this.numerator,
4389
+ this.denominator,
4390
+ this.rounding
4391
+ );
4392
+ }
4164
4393
  copy() {
4165
4394
  return new _ScaleRollBuilder(
4166
4395
  this.innerRoll.copy(),
@@ -4233,6 +4462,18 @@ var MaxOfRollBuilder = class _MaxOfRollBuilder extends RollBuilder {
4233
4462
  toPMF(eps = 0) {
4234
4463
  return pmfFromRollBuilder(this, eps);
4235
4464
  }
4465
+ // Scale the dice INSIDE each trial (e.g. maxOf(2, 1d12) -> maxOf(2, 2d12)), keeping the same
4466
+ // trial count -- delegating to the base class's `create()`-based scaleDice would collapse
4467
+ // straight to plain dice, losing the "take the highest of N trials" semantics entirely.
4468
+ scaleDice(scale) {
4469
+ const scaleInt = validateScaleInt(scale);
4470
+ return new _MaxOfRollBuilder(
4471
+ this.innerRoll.scaleDice(scaleInt),
4472
+ this.count,
4473
+ this.diceCount ? this.diceCount * scaleInt : void 0,
4474
+ this.diceSides
4475
+ );
4476
+ }
4236
4477
  copy() {
4237
4478
  return new _MaxOfRollBuilder(this.innerRoll.copy(), this.count);
4238
4479
  }
@@ -4279,9 +4520,7 @@ var AlwaysHitBuilder = class _AlwaysHitBuilder extends RollBuilder {
4279
4520
  return new RollBuilder(configs).toExpression();
4280
4521
  }
4281
4522
  toPMF() {
4282
- const rollType = this.rollType;
4283
- const rerollOne = this.baseReroll > 0;
4284
- return d20RollPMF(rollType, rerollOne);
4523
+ return resolveRootD20(this);
4285
4524
  }
4286
4525
  copy() {
4287
4526
  const baseCopy = new RollBuilder(this.getSubRollConfigs());
@@ -4329,9 +4568,7 @@ var AlwaysCritBuilder = class _AlwaysCritBuilder extends RollBuilder {
4329
4568
  return new RollBuilder(configs).toExpression();
4330
4569
  }
4331
4570
  toPMF() {
4332
- const rollType = this.rollType;
4333
- const rerollOne = this.baseReroll > 0;
4334
- return d20RollPMF(rollType, rerollOne);
4571
+ return resolveRootD20(this);
4335
4572
  }
4336
4573
  copy() {
4337
4574
  const baseCopy = new RollBuilder(this.getSubRollConfigs());
@@ -4399,6 +4636,9 @@ var PooledRollBuilder = class _PooledRollBuilder extends RollBuilder {
4399
4636
  explode(_count = Infinity) {
4400
4637
  throw new Error("Cannot set explode on a pooled roll.");
4401
4638
  }
4639
+ explodePool(_budget) {
4640
+ throw new Error("Cannot set explodePool on a pooled roll.");
4641
+ }
4402
4642
  minimum(_val) {
4403
4643
  throw new Error("Cannot set minimum on a pooled roll.");
4404
4644
  }
@@ -4521,6 +4761,17 @@ var CompositeSumRollBuilder = class _CompositeSumRollBuilder extends RollBuilder
4521
4761
  toPMF(eps = 0) {
4522
4762
  return pmfFromRollBuilder(this, eps);
4523
4763
  }
4764
+ // Scaling a composite (mixed damage types, e.g. base + resisted) must scale each PART's own
4765
+ // dice while preserving its own half/scale wrapper -- delegating to the base class's
4766
+ // `create()`-based scaleDice would lose every part's transform, collapsing straight to plain
4767
+ // dice. This is what auto-crit doubling (attack.ts's `hitEffect.copy().doubleDice()`) relies
4768
+ // on for a mixed-resistance hit payload.
4769
+ scaleDice(scale) {
4770
+ validateScaleInt(scale);
4771
+ return new _CompositeSumRollBuilder(
4772
+ this.parts.map((p) => p.scaleDice(scale))
4773
+ );
4774
+ }
4524
4775
  copy() {
4525
4776
  return new _CompositeSumRollBuilder(this.parts.map((p) => p.copy()));
4526
4777
  }
@@ -4598,6 +4849,15 @@ var builderPMFCache = new LRUCache(1e3);
4598
4849
  // src/builder/ast.ts
4599
4850
  var defaultEps = 0;
4600
4851
  var singleDiePMFCache = new LRUCache(1e3);
4852
+ function dieNodeFromConfig(cfg) {
4853
+ return {
4854
+ type: "die",
4855
+ sides: cfg.sides,
4856
+ reroll: cfg.reroll > 0 ? cfg.reroll : void 0,
4857
+ minimum: cfg.minimum > 0 ? cfg.minimum : void 0,
4858
+ explode: cfg.explode && Number.isFinite(cfg.explode) && cfg.explode > 0 ? cfg.explode : void 0
4859
+ };
4860
+ }
4601
4861
  function astFromRollConfigs(configs) {
4602
4862
  if (!configs || configs.length === 0) return void 0;
4603
4863
  const children = [];
@@ -4607,13 +4867,9 @@ function astFromRollConfigs(configs) {
4607
4867
  const count = Math.abs(cfg.count || 0);
4608
4868
  constantSum += cfg.modifier || 0;
4609
4869
  if ((cfg.sides || 0) <= 0) continue;
4610
- const die = {
4611
- type: "die",
4612
- sides: cfg.sides,
4613
- reroll: cfg.reroll > 0 ? cfg.reroll : void 0,
4614
- minimum: cfg.minimum > 0 ? cfg.minimum : void 0,
4615
- explode: cfg.explode && Number.isFinite(cfg.explode) && cfg.explode > 0 ? cfg.explode : void 0
4616
- };
4870
+ const isSynthesizedBestOf = !cfg.keep && cfg.bestOf > 0 && cfg.bestOf < count;
4871
+ const effectiveKeep = isSynthesizedBestOf ? { total: count, count: Math.floor(cfg.bestOf), mode: "highest" } : cfg.keep;
4872
+ const die = dieNodeFromConfig(cfg);
4617
4873
  let node = die;
4618
4874
  let appliedRollType = false;
4619
4875
  if (cfg.rollType && cfg.rollType !== "flat") {
@@ -4631,11 +4887,16 @@ function astFromRollConfigs(configs) {
4631
4887
  }
4632
4888
  appliedRollType = true;
4633
4889
  }
4634
- if (cfg.rollType === "flat" && cfg.keep && cfg.keep.total > 0) {
4890
+ if (cfg.rollType === "flat" && effectiveKeep && effectiveKeep.total > 0) {
4891
+ if (cfg.explodePoolBudget > 0) {
4892
+ throw new Error(
4893
+ "explodePool() cannot be combined with keep()/bestOf() on the same config \u2014 the match/keep pool is ambiguous once dice can be added mid-resolution. Use explodePool() on a plain (non-keep) pool."
4894
+ );
4895
+ }
4635
4896
  const baseCount = Math.max(1, Math.floor(Math.abs(count || 1)));
4636
- const trials = Math.max(1, Math.floor(cfg.keep.total));
4637
- const k = Math.max(0, Math.floor(cfg.keep.count));
4638
- if (k === 1 && cfg.keep.mode === "highest") {
4897
+ const trials = Math.max(1, Math.floor(effectiveKeep.total));
4898
+ const k = Math.max(0, Math.floor(effectiveKeep.count));
4899
+ if (k === 1 && effectiveKeep.mode === "highest" && !isSynthesizedBestOf) {
4639
4900
  const perTrial = {
4640
4901
  type: "sum",
4641
4902
  count: baseCount,
@@ -4654,7 +4915,7 @@ function astFromRollConfigs(configs) {
4654
4915
  const base = { type: "sum", count: trials, child: node };
4655
4916
  node = {
4656
4917
  type: "keep",
4657
- mode: cfg.keep.mode,
4918
+ mode: effectiveKeep.mode,
4658
4919
  count: k,
4659
4920
  child: base
4660
4921
  };
@@ -4674,7 +4935,7 @@ function astFromRollConfigs(configs) {
4674
4935
  };
4675
4936
  node = {
4676
4937
  type: "keep",
4677
- mode: cfg.keep.mode,
4938
+ mode: effectiveKeep.mode,
4678
4939
  count: k,
4679
4940
  child: trialPool
4680
4941
  };
@@ -4682,7 +4943,17 @@ function astFromRollConfigs(configs) {
4682
4943
  }
4683
4944
  } else {
4684
4945
  const c = appliedRollType ? 1 : Math.max(1, count || 1);
4685
- node = { type: "sum", count: c, child: node };
4946
+ if (cfg.explodePoolBudget > 0 && appliedRollType) {
4947
+ throw new Error(
4948
+ "explodePool() cannot be combined with advantage/disadvantage/elven-accuracy on the same config \u2014 pool-wide explosion is for damage dice pools, not d20 rolls."
4949
+ );
4950
+ }
4951
+ node = {
4952
+ type: "sum",
4953
+ count: c,
4954
+ child: node,
4955
+ explodePoolBudget: cfg.explodePoolBudget > 0 ? cfg.explodePoolBudget : void 0
4956
+ };
4686
4957
  }
4687
4958
  children.push({ node, sign });
4688
4959
  }
@@ -4713,6 +4984,13 @@ function resolve(node, eps = defaultEps) {
4713
4984
  const base = resolve(node.child, eps);
4714
4985
  const n = Math.max(0, Math.floor(node.count));
4715
4986
  if (n === 0) return PMF.delta(0, eps);
4987
+ if (node.explodePoolBudget && Number.isFinite(node.explodePoolBudget) && node.explodePoolBudget > 0) {
4988
+ const die = findDie(node.child);
4989
+ if (!die) {
4990
+ throw new Error("explodePool() requires the pool's child to be a plain die.");
4991
+ }
4992
+ return resolveExplodingPool(base, die.sides, n, Math.floor(node.explodePoolBudget), eps);
4993
+ }
4716
4994
  if (n === 1) return base;
4717
4995
  return base.power(n, eps);
4718
4996
  }
@@ -4748,8 +5026,8 @@ function resolve(node, eps = defaultEps) {
4748
5026
  }
4749
5027
  case "d20Roll": {
4750
5028
  const childDie = findDie(node.child);
4751
- const rerollOne = !!childDie && (childDie.reroll || 0) >= 1;
4752
- return d20RollPMF(node.rollType, rerollOne);
5029
+ if (!childDie) return d20RollPMF(node.rollType, false);
5030
+ return resolveD20Roll(childDie, node.rollType);
4753
5031
  }
4754
5032
  case "half": {
4755
5033
  const childPMF = resolve(node.child, eps);
@@ -4775,6 +5053,37 @@ function pmfFromRollBuilder(rb, eps = defaultEps) {
4775
5053
  const ast = rb.toAST();
4776
5054
  return resolve(ast, eps);
4777
5055
  }
5056
+ var d20RollLiftCache = new LRUCache(500);
5057
+ function resolveD20Roll(die, rollType) {
5058
+ const base = resolveSingleDie(die, defaultEps);
5059
+ const type = rollType || "flat";
5060
+ if (type === "flat") return base;
5061
+ const cacheKey = `${getASTSignature(die)}|${type}`;
5062
+ const cached = d20RollLiftCache.get(cacheKey);
5063
+ if (cached) return cached;
5064
+ const support = [...base.support()].sort((a, b) => a - b);
5065
+ const out = /* @__PURE__ */ new Map();
5066
+ let cum = 0;
5067
+ let prevLifted = 0;
5068
+ for (const k of support) {
5069
+ cum += base.pAt(k);
5070
+ const curLifted = type === "advantage" ? cum * cum : type === "elven accuracy" ? cum * cum * cum : 1 - (1 - cum) * (1 - cum);
5071
+ const pk = curLifted - prevLifted;
5072
+ if (pk > 0) out.set(k, pk);
5073
+ prevLifted = curLifted;
5074
+ }
5075
+ const result = PMF.fromMap(out, defaultEps);
5076
+ d20RollLiftCache.set(cacheKey, result);
5077
+ return result;
5078
+ }
5079
+ function resolveRootD20(check) {
5080
+ const rootConfig = check.getRootDieConfig();
5081
+ const rollType = check.rollType;
5082
+ if (!rootConfig || !(rootConfig.sides > 0)) {
5083
+ return d20RollPMF(rollType, check.baseReroll > 0);
5084
+ }
5085
+ return resolveD20Roll(dieNodeFromConfig(rootConfig), rollType);
5086
+ }
4778
5087
  function resolveSingleDie(die, eps = defaultEps) {
4779
5088
  const signature = getASTSignature(die);
4780
5089
  const cacheKey = `${signature}_${eps}`;
@@ -4808,25 +5117,42 @@ function resolveSingleDie(die, eps = defaultEps) {
4808
5117
  for (const v of pmf.support()) {
4809
5118
  if (v !== maxFace) nonMax.set(v, pmf.pAt(v));
4810
5119
  }
4811
- let nonMaxPMF = PMF.fromMap(nonMax, eps);
4812
- if (Math.abs(nonMaxPMF.mass() - (1 - pMax)) > eps) {
4813
- nonMaxPMF = nonMaxPMF.scaleMass(1 - pMax);
5120
+ const nonMaxPMF = PMF.fromMap(nonMax, eps);
5121
+ let chain = pmf;
5122
+ for (let remaining = 1; remaining <= times - 1; remaining++) {
5123
+ chain = PMF.branch(chain.mapDamage((v) => v + maxFace), nonMaxPMF, pMax);
4814
5124
  }
4815
- let tail = PMF.delta(0, eps);
4816
- const addOnce = pmf;
4817
- for (let t = 1; t <= times; t++) {
4818
- tail = tail.convolve(addOnce, eps);
4819
- }
4820
- const exploded = PMF.branch(
4821
- tail.mapDamage((v) => v + maxFace),
4822
- nonMaxPMF,
4823
- pMax
4824
- );
5125
+ const exploded = PMF.branch(chain.mapDamage((v) => v + maxFace), nonMaxPMF, pMax);
4825
5126
  pmf = exploded;
4826
5127
  }
4827
5128
  singleDiePMFCache.set(cacheKey, pmf);
4828
5129
  return pmf;
4829
5130
  }
5131
+ function resolveExplodingPool(diePMF, maxFace, count, budget, eps) {
5132
+ const pMax = diePMF.pAt(maxFace);
5133
+ const nonMax = /* @__PURE__ */ new Map();
5134
+ for (const v of diePMF.support()) {
5135
+ if (v !== maxFace) nonMax.set(v, diePMF.pAt(v));
5136
+ }
5137
+ if (nonMax.size === 0) {
5138
+ return PMF.delta((count + budget) * maxFace, eps);
5139
+ }
5140
+ const nonMaxPMF = PMF.fromMap(nonMax, eps);
5141
+ const memo = /* @__PURE__ */ new Map();
5142
+ const f = (p, b) => {
5143
+ if (p === 0) return PMF.delta(0, eps);
5144
+ if (b === 0) return diePMF.power(p, eps);
5145
+ const key = `${p},${b}`;
5146
+ const cached = memo.get(key);
5147
+ if (cached) return cached;
5148
+ const maxBranch = f(p, b - 1).mapDamage((v) => v + maxFace);
5149
+ const nonMaxBranch = nonMaxPMF.convolve(f(p - 1, b), eps);
5150
+ const result = PMF.branch(maxBranch, nonMaxBranch, pMax);
5151
+ memo.set(key, result);
5152
+ return result;
5153
+ };
5154
+ return f(count, budget);
5155
+ }
4830
5156
  function findDie(node) {
4831
5157
  switch (node.type) {
4832
5158
  case "die":
@@ -5011,7 +5337,7 @@ function getASTSignature(node) {
5011
5337
  return `d{${parts.join(",")}}`;
5012
5338
  }
5013
5339
  case "sum":
5014
- return `sum{c:${node.count},ch:${getASTSignature(node.child)}}`;
5340
+ return `sum{c:${node.count},b:${node.explodePoolBudget || 0},ch:${getASTSignature(node.child)}}`;
5015
5341
  case "d20Roll":
5016
5342
  return `d20{t:${node.rollType},ch:${getASTSignature(node.child)}}`;
5017
5343
  case "keep":
@@ -5120,10 +5446,8 @@ var AttackBuilder = class _AttackBuilder {
5120
5446
  return `${checkPart} * ${effectPart}`;
5121
5447
  }
5122
5448
  resolveProbabilities(check, eps = 0) {
5123
- const rollType = check.rollType;
5124
- const rerollOne = check.baseReroll > 0;
5125
5449
  const critThreshold = check.critThreshold;
5126
- const d202 = d20RollPMF(rollType, rerollOne);
5450
+ const d202 = resolveRootD20(check);
5127
5451
  if (check instanceof AlwaysCritBuilder) {
5128
5452
  if (check.fromAlwaysHit) {
5129
5453
  return { pSuccess: 1, pHit: 0, pCrit: 1, pMiss: 0 };
@@ -5173,13 +5497,17 @@ var AttackBuilder = class _AttackBuilder {
5173
5497
  pmiss += pr;
5174
5498
  continue;
5175
5499
  }
5176
- if (r >= critThreshold) {
5500
+ if (r === 20) {
5177
5501
  pcrit += pr;
5178
5502
  continue;
5179
5503
  }
5180
5504
  const need = ac - staticMod - r;
5181
5505
  const pBonusHit = bonusPMF.tailProbGE(need);
5182
- phit += pr * pBonusHit;
5506
+ if (r >= critThreshold) {
5507
+ pcrit += pr * pBonusHit;
5508
+ } else {
5509
+ phit += pr * pBonusHit;
5510
+ }
5183
5511
  pmiss += pr * (1 - pBonusHit);
5184
5512
  }
5185
5513
  const psuccess = phit + pcrit;
@@ -5230,6 +5558,55 @@ var AttackBuilder = class _AttackBuilder {
5230
5558
  weights: { hit: phit, crit: pcrit, miss: pmiss }
5231
5559
  };
5232
5560
  }
5561
+ /**
5562
+ * For `dice-match` trigger slicing (`turn/types.ts`'s `HasDiceMatchInfo`): the exact
5563
+ * per-damage-value match probability for the hit and crit branches, or `null` per branch when no
5564
+ * descriptor is available — a string-parsed effect, a wrapped transform whose PMF isn't fully
5565
+ * captured by its `RollConfig`s (half/scale/maxOf/pooled — `cacheKey()` returns `null` for
5566
+ * exactly these), a `keep`/`bestOf` pool (ambiguous "the dice" under crit doubling), a
5567
+ * pool-wide exploding budget (composition with match not yet threaded through here), a
5568
+ * multi-die-type pool, a single die (can never match), or (crit) `noCrit()`.
5569
+ *
5570
+ * The crit branch is built from the SAME crit-effect selection `resolve()` uses (an explicit
5571
+ * `onCrit` roll, or the hit dice auto-doubled via `copy().doubleDice()`), so its descriptor
5572
+ * reflects the crit branch's REAL doubled pool, not the hit pool re-used blindly.
5573
+ */
5574
+ diceMatchInfo(_eps = EPS) {
5575
+ const hit = this.matchInfoForEffect(this.hitEffect);
5576
+ let critEffect;
5577
+ if (this.critEffect === null) {
5578
+ critEffect = void 0;
5579
+ } else if (this.critEffect) {
5580
+ critEffect = this.critEffect;
5581
+ } else if (this.hitEffect instanceof ParsedRollBuilder) {
5582
+ critEffect = void 0;
5583
+ } else {
5584
+ critEffect = this.hitEffect?.copy().doubleDice();
5585
+ }
5586
+ const crit = this.matchInfoForEffect(critEffect);
5587
+ return { hit, crit };
5588
+ }
5589
+ matchInfoForEffect(effect) {
5590
+ if (!effect || effect instanceof ParsedRollBuilder) return null;
5591
+ if (effect.cacheKey() === null) return null;
5592
+ const diceConfigs = effect.getSubRollConfigs().filter((c) => c.sides > 0);
5593
+ if (diceConfigs.length !== 1) return null;
5594
+ const config = diceConfigs[0];
5595
+ if (config.keep || config.bestOf > 0) return null;
5596
+ if (config.explodePoolBudget > 0) return null;
5597
+ if (config.count <= 1) return { matchProbabilityByDamage: /* @__PURE__ */ new Map() };
5598
+ const weights = faceWeights(config.sides, config.minimum, config.reroll);
5599
+ const totalDist = diceSumDistribution(config.count, weights);
5600
+ const joint = jointSumAndMatch(config.count, weights);
5601
+ const modifier = effect.modifier;
5602
+ const matchProbabilityByDamage = /* @__PURE__ */ new Map();
5603
+ for (const [sum, totalMass] of totalDist) {
5604
+ if (totalMass <= 0) continue;
5605
+ const matchMass = joint.get(sum) ?? 0;
5606
+ matchProbabilityByDamage.set(sum + modifier, matchMass / totalMass);
5607
+ }
5608
+ return { matchProbabilityByDamage };
5609
+ }
5233
5610
  /**
5234
5611
  * A cheap, complete key for this attack's resolved PMF, or `null` when it can't be cached soundly (an
5235
5612
  * effect whose PMF isn't captured by its {@link RollConfig}s — see {@link RollBuilder.cacheKey}). Composed
@@ -5327,9 +5704,7 @@ var ACBuilder = class _ACBuilder extends RollBuilder {
5327
5704
  }
5328
5705
  toPMF(eps = 0) {
5329
5706
  const ac = this.attackConfig.ac;
5330
- const rollType = this.rollType;
5331
- const rerollOne = this.baseReroll > 0;
5332
- const d202 = d20RollPMF(rollType, rerollOne);
5707
+ const d202 = resolveRootD20(this);
5333
5708
  const staticMod = this.modifier;
5334
5709
  const bonusPMFs = this.getBonusDicePMFs(this, eps);
5335
5710
  const parts = [d202, ...bonusPMFs];
@@ -5439,21 +5814,17 @@ var SaveBuilder = class _SaveBuilder {
5439
5814
  function resolveProbabilities(check) {
5440
5815
  const saveBonus = check.modifier;
5441
5816
  const dc = check.saveDC;
5442
- const d20Type = check.rollType;
5443
- const baseReroll = check.baseReroll;
5444
- const die = d20RollPMF(d20Type, baseReroll > 0);
5817
+ const eps = 0;
5818
+ const die = resolveRootD20(check);
5445
5819
  const faceP = /* @__PURE__ */ new Map();
5446
5820
  for (const [r, bin] of die) {
5447
5821
  const pr = bin.p;
5448
5822
  if (pr > 0) faceP.set(r, pr);
5449
5823
  }
5450
- const eps = 0;
5451
5824
  const bonusDicePMFs = check.getBonusDicePMFs(check, eps);
5452
5825
  const bonusPMF = bonusDicePMFs.length > 0 ? PMF.convolveMany(bonusDicePMFs, eps) : PMF.zero(eps);
5453
5826
  let pSuccess = 0;
5454
- for (let r = 1; r <= 20; r++) {
5455
- const pr = faceP.get(r);
5456
- if (!pr) continue;
5827
+ for (const [r, pr] of faceP) {
5457
5828
  const need = dc - saveBonus - r;
5458
5829
  pSuccess += pr * bonusPMF.tailProbGE(need);
5459
5830
  }
@@ -5523,9 +5894,7 @@ var DCBuilder = class _DCBuilder extends RollBuilder {
5523
5894
  if (cached) return cached;
5524
5895
  }
5525
5896
  const saveDC = this.saveDC;
5526
- const rollType = this.rollType;
5527
- const rerollOne = this.baseReroll > 0;
5528
- const d202 = d20RollPMF(rollType, rerollOne);
5897
+ const d202 = resolveRootD20(this);
5529
5898
  const staticMod = this.modifier;
5530
5899
  const bonusDicePMFs = this.getBonusDiceConfigs().map(
5531
5900
  (cfg) => pmfFromRollBuilder(RollBuilder.fromConfigs([cfg]), eps)
@@ -5562,14 +5931,15 @@ var TurnSpecError = class extends Error {
5562
5931
  this.name = "TurnSpecError";
5563
5932
  }
5564
5933
  };
5565
- var MAX_TRIGGER_GROUPS = 4;
5934
+ var MAX_TRIGGER_GROUPS = 9;
5566
5935
 
5567
5936
  // src/turn/plan.ts
5568
- var IS_HIT_TRIGGER = {
5937
+ var READS_GROUP = {
5569
5938
  "first-hit": true,
5570
5939
  "any-crit": true,
5571
5940
  "any-miss": true,
5572
- "every-hit": true
5941
+ "every-hit": true,
5942
+ "dice-match": true
5573
5943
  };
5574
5944
  function toPMF(damage, eps, id = "") {
5575
5945
  const parts = Array.isArray(damage) ? damage : [damage];
@@ -5613,20 +5983,49 @@ function critPMF(rider, base, eps) {
5613
5983
  if (doubled.length === 0) return base;
5614
5984
  return PMF.convolveMany(doubled, eps);
5615
5985
  }
5616
- function sliceSource(pmf) {
5986
+ function diceMatchInfoOf(source, eps) {
5987
+ const capable = source;
5988
+ if (typeof capable.diceMatchInfo === "function") {
5989
+ return capable.diceMatchInfo(eps);
5990
+ }
5991
+ return { hit: null, crit: null };
5992
+ }
5993
+ function sliceSource(pmf, matchInfo) {
5617
5994
  const labels = pmf.outcomes();
5618
5995
  if (!labels.includes("hit") && !labels.includes("crit")) return null;
5619
5996
  const missParts = ["missNone", "missDamage"].filter((label) => labels.includes(label)).map((label) => pmf.filterOutcome(label));
5620
- return {
5621
- hit: labels.includes("hit") ? pmf.filterOutcome("hit") : PMF.emptyMass(),
5622
- crit: labels.includes("crit") ? pmf.filterOutcome("crit") : PMF.emptyMass(),
5623
- miss: missParts.length ? missParts.reduce((all, part) => all.add(part)) : PMF.emptyMass()
5624
- };
5997
+ const hit = labels.includes("hit") ? pmf.filterOutcome("hit") : PMF.emptyMass();
5998
+ const crit = labels.includes("crit") ? pmf.filterOutcome("crit") : PMF.emptyMass();
5999
+ const miss = missParts.length ? missParts.reduce((all, part) => all.add(part)) : PMF.emptyMass();
6000
+ let hitMatch = null;
6001
+ let hitNoMatch = null;
6002
+ let critMatch = null;
6003
+ let critNoMatch = null;
6004
+ if (matchInfo?.hit) {
6005
+ const info = matchInfo.hit;
6006
+ const [m, nm] = hit.splitByFactor((d2) => info.matchProbabilityByDamage.get(d2) ?? 0);
6007
+ hitMatch = m;
6008
+ hitNoMatch = nm;
6009
+ }
6010
+ if (matchInfo?.crit) {
6011
+ const info = matchInfo.crit;
6012
+ const [m, nm] = crit.splitByFactor((d2) => info.matchProbabilityByDamage.get(d2) ?? 0);
6013
+ critMatch = m;
6014
+ critNoMatch = nm;
6015
+ }
6016
+ return { hit, crit, miss, hitMatch, hitNoMatch, critMatch, critNoMatch };
5625
6017
  }
5626
6018
  function buildPlan(spec, eps = EPS) {
5627
6019
  const fail = (code, id, message) => {
5628
6020
  throw new TurnSpecError(code, id, message);
5629
6021
  };
6022
+ const riders = spec.riders ?? [];
6023
+ const matchNeededSourceIds = /* @__PURE__ */ new Set();
6024
+ for (const rider of riders) {
6025
+ if (rider.on === "dice-match") {
6026
+ for (const sourceId of rider.of) matchNeededSourceIds.add(sourceId);
6027
+ }
6028
+ }
5630
6029
  const attackIds = [];
5631
6030
  const attackPMFs = [];
5632
6031
  const attackSlices = [];
@@ -5636,11 +6035,11 @@ function buildPlan(spec, eps = EPS) {
5636
6035
  const id = hasWrapper ? named.id : `attack ${index + 1}`;
5637
6036
  const source = hasWrapper ? named.source : entry;
5638
6037
  const pmf = toPMF(source, eps, id);
6038
+ const matchInfo = matchNeededSourceIds.has(id) ? diceMatchInfoOf(source, eps) : null;
5639
6039
  attackIds.push(id);
5640
6040
  attackPMFs.push(pmf);
5641
- attackSlices.push(sliceSource(pmf));
6041
+ attackSlices.push(sliceSource(pmf, matchInfo));
5642
6042
  });
5643
- const riders = spec.riders ?? [];
5644
6043
  const riderIds = riders.map((rider, index) => rider.id ?? `rider ${index + 1}`);
5645
6044
  const seen = /* @__PURE__ */ new Set();
5646
6045
  for (const id of [...attackIds, ...riderIds]) {
@@ -5649,6 +6048,17 @@ function buildPlan(spec, eps = EPS) {
5649
6048
  }
5650
6049
  const attackIndexById = new Map(attackIds.map((id, index) => [id, index]));
5651
6050
  const riderIndexById = new Map(riderIds.map((id, index) => [id, index]));
6051
+ const checkMatchable = (riderId, sourceId, slices) => {
6052
+ const missingHit = slices.hit.mass() > 0 && slices.hitMatch === null;
6053
+ const missingCrit = slices.crit.mass() > 0 && slices.critMatch === null;
6054
+ if (missingHit || missingCrit) {
6055
+ fail(
6056
+ "no-dice-descriptor",
6057
+ sourceId,
6058
+ `Rider "${riderId}" reads "${sourceId}" for "dice-match", but "${sourceId}" has no dice descriptor to match against \u2014 a bare PMF, a string-parsed expression, or a keep()/bestOf() pool (ambiguous "the dice" under crit doubling) cannot be matched.`
6059
+ );
6060
+ }
6061
+ };
5652
6062
  const sourceIdsByRider = riders.map((rider, index) => {
5653
6063
  const id = riderIds[index];
5654
6064
  if (rider.on === "not-fired") {
@@ -5673,7 +6083,7 @@ function buildPlan(spec, eps = EPS) {
5673
6083
  }
5674
6084
  return [target];
5675
6085
  }
5676
- const of = [...new Set(rider.of ?? attackIds)];
6086
+ const of = [...new Set(rider.on === "dice-match" ? rider.of : rider.of ?? attackIds)];
5677
6087
  if (of.length === 0) {
5678
6088
  fail("unknown-id", id, `Rider "${id}" has no sources.`);
5679
6089
  }
@@ -5697,8 +6107,11 @@ function buildPlan(spec, eps = EPS) {
5697
6107
  `Rider "${id}" triggers on "${sourceId}", an every-hit rider. Those are folded into their own sources rather than resolved separately, so they cannot be triggered on \u2014 point at the attacks instead.`
5698
6108
  );
5699
6109
  }
6110
+ const damageSource = isAttack ? void 0 : riders[riderIndex].damage;
6111
+ const singleDamageSource = damageSource !== void 0 && !Array.isArray(damageSource) ? damageSource : void 0;
5700
6112
  const slices = isAttack ? attackSlices[attackIndexById.get(sourceId)] : sliceSource(
5701
- toPMF(riders[riderIndex].damage, eps, sourceId)
6113
+ toPMF(damageSource, eps, sourceId),
6114
+ matchNeededSourceIds.has(sourceId) && singleDamageSource !== void 0 ? diceMatchInfoOf(singleDamageSource, eps) : null
5702
6115
  );
5703
6116
  if (!slices) {
5704
6117
  fail(
@@ -5706,6 +6119,8 @@ function buildPlan(spec, eps = EPS) {
5706
6119
  sourceId,
5707
6120
  `Rider "${id}" triggers on "${sourceId}", which has no hit/crit outcomes.`
5708
6121
  );
6122
+ } else if (rider.on === "dice-match") {
6123
+ checkMatchable(id, sourceId, slices);
5709
6124
  }
5710
6125
  }
5711
6126
  return [...of];
@@ -5751,7 +6166,7 @@ function buildPlan(spec, eps = EPS) {
5751
6166
  const perHitGroups = /* @__PURE__ */ new Map();
5752
6167
  for (const index of order) {
5753
6168
  const rider = riders[index];
5754
- if (!IS_HIT_TRIGGER[rider.on]) continue;
6169
+ if (!READS_GROUP[rider.on]) continue;
5755
6170
  const group = groupOf(sourceIdsByRider[index]);
5756
6171
  readsByRider.set(index, group);
5757
6172
  if (rider.on === "every-hit") perHitGroups.set(riderIds[index], group);
@@ -5781,21 +6196,34 @@ function buildPlan(spec, eps = EPS) {
5781
6196
  if (!payloads) return slices;
5782
6197
  let hit = slices.hit;
5783
6198
  let crit = slices.crit;
6199
+ let hitMatch = slices.hitMatch;
6200
+ let hitNoMatch = slices.hitNoMatch;
6201
+ let critMatch = slices.critMatch;
6202
+ let critNoMatch = slices.critNoMatch;
5784
6203
  for (const payload of payloads) {
5785
6204
  hit = hit.convolve(payload.hit, eps, true);
5786
6205
  crit = crit.convolve(payload.crit, eps, true);
6206
+ if (hitMatch) hitMatch = hitMatch.convolve(payload.hit, eps, true);
6207
+ if (hitNoMatch) hitNoMatch = hitNoMatch.convolve(payload.hit, eps, true);
6208
+ if (critMatch) critMatch = critMatch.convolve(payload.crit, eps, true);
6209
+ if (critNoMatch) critNoMatch = critNoMatch.convolve(payload.crit, eps, true);
5787
6210
  }
5788
- return { hit, crit, miss: slices.miss };
6211
+ return { hit, crit, miss: slices.miss, hitMatch, hitNoMatch, critMatch, critNoMatch };
6212
+ };
6213
+ const emptySlices = {
6214
+ hit: PMF.emptyMass(),
6215
+ crit: PMF.emptyMass(),
6216
+ miss: PMF.emptyMass(),
6217
+ hitMatch: null,
6218
+ hitNoMatch: null,
6219
+ critMatch: null,
6220
+ critNoMatch: null
5789
6221
  };
5790
6222
  const steps = attackIds.map((id, index) => ({
5791
6223
  id,
5792
6224
  trigger: null,
5793
6225
  slices: withPerHit(
5794
- attackSlices[index] ?? {
5795
- hit: attackPMFs[index],
5796
- crit: PMF.emptyMass(),
5797
- miss: PMF.emptyMass()
5798
- },
6226
+ attackSlices[index] ?? { ...emptySlices, hit: attackPMFs[index] },
5799
6227
  id
5800
6228
  ),
5801
6229
  damage: null,
@@ -5810,7 +6238,9 @@ function buildPlan(spec, eps = EPS) {
5810
6238
  if (rider.on === "every-hit") continue;
5811
6239
  const id = riderIds[index];
5812
6240
  const hit = toPMF(rider.damage, eps, id);
5813
- const slices = sliceSource(hit);
6241
+ const singleRiderDamage = !Array.isArray(rider.damage) ? rider.damage : void 0;
6242
+ const matchInfo = matchNeededSourceIds.has(id) && singleRiderDamage !== void 0 ? diceMatchInfoOf(singleRiderDamage, eps) : null;
6243
+ const slices = sliceSource(hit, matchInfo);
5814
6244
  if (slices && rider.critDamage !== void 0) {
5815
6245
  fail(
5816
6246
  "unused-crit-damage",
@@ -5832,6 +6262,15 @@ function buildPlan(spec, eps = EPS) {
5832
6262
  stepIndexByRider.set(index, steps.length - 1);
5833
6263
  riderSteps.set(id, steps.length - 1);
5834
6264
  }
6265
+ const groupLastReadStep = new Array(groupSources.length).fill(-1);
6266
+ steps.forEach((step, stepIndex) => {
6267
+ if (step.reads !== -1) {
6268
+ groupLastReadStep[step.reads] = Math.max(groupLastReadStep[step.reads], stepIndex);
6269
+ }
6270
+ });
6271
+ for (const group of perHitGroups.values()) {
6272
+ groupLastReadStep[group] = steps.length;
6273
+ }
5835
6274
  return {
5836
6275
  steps,
5837
6276
  groupCount: groupSources.length,
@@ -5839,7 +6278,8 @@ function buildPlan(spec, eps = EPS) {
5839
6278
  attackIds,
5840
6279
  riderIds,
5841
6280
  riderSteps,
5842
- perHitGroups
6281
+ perHitGroups,
6282
+ groupLastReadStep
5843
6283
  };
5844
6284
  }
5845
6285
 
@@ -5849,18 +6289,36 @@ var FIRST_HIT = 1;
5849
6289
  var FIRST_CRIT = 2;
5850
6290
  var CRIT_BIT = 2;
5851
6291
  var MISS_BIT = 1;
6292
+ var MATCH_BIT = 16;
5852
6293
  var START_CODE = FIRST_NONE << 2;
5853
- function advance(code, outcome) {
6294
+ function advance(code, outcome, matched = false) {
5854
6295
  if (outcome === "miss") return code | MISS_BIT;
5855
- const first = code >> 2;
6296
+ const first = code >> 2 & 3;
5856
6297
  const withCrit = outcome === "crit" ? code | CRIT_BIT : code;
5857
- if (first !== FIRST_NONE) return withCrit;
6298
+ const withMatch = matched ? withCrit | MATCH_BIT : withCrit;
6299
+ if (first !== FIRST_NONE) return withMatch;
5858
6300
  const nextFirst = outcome === "crit" ? FIRST_CRIT : FIRST_HIT;
5859
- return nextFirst << 2 | withCrit & (CRIT_BIT | MISS_BIT);
6301
+ return nextFirst << 2 | withMatch & (MATCH_BIT | CRIT_BIT | MISS_BIT);
5860
6302
  }
5861
6303
 
5862
6304
  // src/turn/turn.ts
5863
- var OUTCOMES = ["hit", "crit", "miss"];
6305
+ function stepDraws(slices) {
6306
+ const draws = [];
6307
+ if (slices.hitMatch) {
6308
+ draws.push({ outcome: "hit", matched: true, slice: slices.hitMatch });
6309
+ draws.push({ outcome: "hit", matched: false, slice: slices.hitNoMatch });
6310
+ } else {
6311
+ draws.push({ outcome: "hit", matched: false, slice: slices.hit });
6312
+ }
6313
+ if (slices.critMatch) {
6314
+ draws.push({ outcome: "crit", matched: true, slice: slices.critMatch });
6315
+ draws.push({ outcome: "crit", matched: false, slice: slices.critNoMatch });
6316
+ } else {
6317
+ draws.push({ outcome: "crit", matched: false, slice: slices.crit });
6318
+ }
6319
+ draws.push({ outcome: "miss", matched: false, slice: slices.miss });
6320
+ return draws;
6321
+ }
5864
6322
  function fireMode(step, codes, firedByStep) {
5865
6323
  const trigger = step.trigger;
5866
6324
  if (!trigger) return "hit";
@@ -5868,7 +6326,7 @@ function fireMode(step, codes, firedByStep) {
5868
6326
  return firedByStep[step.negates] === null ? "hit" : null;
5869
6327
  }
5870
6328
  const code = codes[step.reads];
5871
- const first = code >> 2;
6329
+ const first = code >> 2 & 3;
5872
6330
  switch (trigger.on) {
5873
6331
  case "first-hit":
5874
6332
  if (first === FIRST_NONE) return null;
@@ -5877,6 +6335,8 @@ function fireMode(step, codes, firedByStep) {
5877
6335
  return (code & CRIT_BIT) !== 0 ? "crit" : null;
5878
6336
  case "any-miss":
5879
6337
  return (code & MISS_BIT) !== 0 ? "hit" : null;
6338
+ case "dice-match":
6339
+ return (code & MATCH_BIT) !== 0 ? "hit" : null;
5880
6340
  default:
5881
6341
  return null;
5882
6342
  }
@@ -6004,6 +6464,20 @@ var Turn = class _Turn {
6004
6464
  riders.push({ ...options, damage, on: "not-fired", of: target });
6005
6465
  return new _Turn(this.declaredAttacks, riders, this.eps);
6006
6466
  }
6467
+ /**
6468
+ * Fires once if any of `of`'s named sources' own damage dice matched (showed a
6469
+ * duplicate value) on hit or crit — Chromatic Orb's bounce. Unlike the other
6470
+ * `onX` triggers, `of` is required: "the dice matched" has no coherent meaning
6471
+ * defaulted across every declared attack. Each named source must expose a
6472
+ * dice-match descriptor (an `AttackBuilder`-shaped source does); naming one
6473
+ * that doesn't is a `TurnSpecError("no-dice-descriptor", ...)`.
6474
+ *
6475
+ * Most callers want {@link bounce} instead of calling this directly — it
6476
+ * builds the whole depth-capped chain of attack-shaped riders.
6477
+ */
6478
+ onDiceMatch(of, damage, options = {}) {
6479
+ return this.rider({ ...options, damage, on: "dice-match", of });
6480
+ }
6007
6481
  /**
6008
6482
  * The exact joint distribution: mass 1, outcome-labelled. Resolved once and
6009
6483
  * cached.
@@ -6076,8 +6550,14 @@ var Turn = class _Turn {
6076
6550
  let states = /* @__PURE__ */ new Map([[String.fromCharCode(), start]]);
6077
6551
  plan.steps.forEach((step, stepIndex) => {
6078
6552
  const next = /* @__PURE__ */ new Map();
6553
+ const liveGroups = [];
6554
+ for (let g = 0; g < plan.groupCount; g++) {
6555
+ if (plan.groupLastReadStep[g] > stepIndex) liveGroups.push(g);
6556
+ }
6079
6557
  const merge = (state) => {
6080
- const key = String.fromCharCode(...state.codes) + "" + state.fired.map((mode) => mode === null ? "-" : "+").join("");
6558
+ let codesKey = "";
6559
+ for (const g of liveGroups) codesKey += String.fromCharCode(state.codes[g]);
6560
+ const key = codesKey + "" + state.fired.map((mode) => mode === null ? "-" : "+").join("");
6081
6561
  const existing = next.get(key);
6082
6562
  if (existing) existing.pmf = existing.pmf.add(state.pmf);
6083
6563
  else next.set(key, state);
@@ -6103,13 +6583,12 @@ var Turn = class _Turn {
6103
6583
  });
6104
6584
  continue;
6105
6585
  }
6106
- for (const outcome of OUTCOMES) {
6107
- const slice = step.slices[outcome];
6586
+ for (const { outcome, matched, slice } of stepDraws(step.slices)) {
6108
6587
  const sliceMass = slice.mass();
6109
6588
  if (sliceMass <= eps) continue;
6110
6589
  const codes = [...state.codes];
6111
6590
  for (const group of step.updates) {
6112
- codes[group] = advance(codes[group], outcome);
6591
+ codes[group] = advance(codes[group], outcome, matched);
6113
6592
  }
6114
6593
  merge({
6115
6594
  codes,
@@ -6133,7 +6612,7 @@ var Turn = class _Turn {
6133
6612
  }
6134
6613
  }
6135
6614
  for (const [id, group] of plan.perHitGroups) {
6136
- if (state.codes[group] >> 2 !== FIRST_NONE) {
6615
+ if ((state.codes[group] >> 2 & 3) !== FIRST_NONE) {
6137
6616
  fireMass.set(id, fireMass.get(id) + mass);
6138
6617
  }
6139
6618
  }
@@ -6157,6 +6636,19 @@ function turn(attacks = [], eps = EPS) {
6157
6636
  eps
6158
6637
  );
6159
6638
  }
6639
+ function bounce({ source, max }) {
6640
+ if (!Number.isInteger(max) || max < 0) {
6641
+ throw new RangeError(`bounce({ max }) needs a non-negative integer, got ${max}.`);
6642
+ }
6643
+ let result = turn(source);
6644
+ let previousId = result.attackIds[0];
6645
+ for (let i = 0; i < max; i++) {
6646
+ const riderId = `bounce ${i + 1}`;
6647
+ result = result.onDiceMatch([previousId], source, { id: riderId });
6648
+ previousId = riderId;
6649
+ }
6650
+ return result;
6651
+ }
6160
6652
 
6161
6653
  exports.ACBuilder = ACBuilder;
6162
6654
  exports.AlwaysCritBuilder = AlwaysCritBuilder;
@@ -6173,6 +6665,7 @@ exports.SaveBuilder = SaveBuilder;
6173
6665
  exports.ScaleRollBuilder = ScaleRollBuilder;
6174
6666
  exports.Turn = Turn;
6175
6667
  exports.TurnSpecError = TurnSpecError;
6668
+ exports.bounce = bounce;
6176
6669
  exports.builderPMFCache = builderPMFCache;
6177
6670
  exports.clearAttackCache = clearAttackCache;
6178
6671
  exports.clearDCCache = clearDCCache;