@yipe/dice 0.9.0 → 0.10.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.
@@ -1474,7 +1474,7 @@ var _PMF = class _PMF {
1474
1474
  const id = this.identifier;
1475
1475
  let key = `${id}`;
1476
1476
  for (let i = 1; i < n; i++) key += `+${id}`;
1477
- return `${key}@${eps}`;
1477
+ return `${key}@${eps}|${this.fingerprint()}`;
1478
1478
  }
1479
1479
  /**
1480
1480
  * Efficiently computes this PMF convolved with itself `n` times.
@@ -1825,16 +1825,27 @@ var _PMF = class _PMF {
1825
1825
  return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${p1.fingerprint()}|${p2.fingerprint()}`;
1826
1826
  }
1827
1827
  /**
1828
- * A small content fingerprint (mass + bin count + face sum) so convolution
1829
- * cache keys change if the underlying numbers do. Memoized because a PMF is
1830
- * immutable once constructed — this avoids re-summing every key on each
1831
- * convolve() call (including cache hits).
1828
+ * A content fingerprint of every bin (probability, per-label `count`, per-label `attr`) plus
1829
+ * the `normalized` flag, so convolution/power cache keys change whenever the underlying
1830
+ * numbers do. Mass/bin-count/face-sum alone are not content-unique: `mapDamage` variants can
1831
+ * keep the same identifier, support, mass, and face sum while differing in per-bin
1832
+ * probabilities or in the `count`/`attr` channels `convolve()`/`power()` actually propagate --
1833
+ * that previously let `power()` return one PMF's cached result for a different PMF. Memoized
1834
+ * because a PMF is immutable once constructed -- this avoids re-deriving the key on every
1835
+ * convolve()/power() call (including cache hits). Bin order is sorted by damage value (and
1836
+ * label keys sorted within each bin) so two equal-content PMFs built via different code paths
1837
+ * fingerprint identically regardless of Map insertion order.
1832
1838
  */
1833
1839
  fingerprint() {
1834
1840
  if (this._fingerprint === void 0) {
1835
- let faceSum = 0;
1836
- for (const k of this.map.keys()) faceSum += k;
1837
- this._fingerprint = `${this.mass().toFixed(12)}|${this.map.size}|${faceSum}`;
1841
+ const bins = [...this.map.entries()].sort((a, b) => a[0] - b[0]);
1842
+ const parts = [];
1843
+ for (const [damageValue, bin] of bins) {
1844
+ const countStr = Object.keys(bin.count).sort().map((k) => `${k}:${bin.count[k]}`).join(",");
1845
+ const attrStr = bin.attr ? Object.keys(bin.attr).sort().map((k) => `${k}:${bin.attr[k]}`).join(",") : "";
1846
+ parts.push(`${damageValue}:${bin.p}[${countStr}]{${attrStr}}`);
1847
+ }
1848
+ this._fingerprint = `${this.normalized ? 1 : 0}|${parts.join(";")}`;
1838
1849
  }
1839
1850
  return this._fingerprint;
1840
1851
  }
@@ -2066,11 +2077,13 @@ var _PMF = class _PMF {
2066
2077
  /** Quantile / inverse CDF for p in [0,1]. Returns smallest x with CDF ≥ p. */
2067
2078
  quantile(p) {
2068
2079
  if (this.map.size === 0) return 0;
2080
+ const totalMass = this.mass();
2081
+ if (totalMass <= 0) return 0;
2069
2082
  const s = this.support().sort((a, b) => a - b);
2070
2083
  let acc = 0;
2071
2084
  for (const x of s) {
2072
2085
  acc += this.pAt(x);
2073
- if (acc >= p) return x;
2086
+ if (acc / totalMass >= p) return x;
2074
2087
  }
2075
2088
  return s[s.length - 1];
2076
2089
  }
@@ -3049,6 +3062,12 @@ function combineDiceWithNormalization(dice, normValue, outcomeType, currentNorm,
3049
3062
  finalResult = finalResult.combine(dice);
3050
3063
  return { newNorm: currentNorm * normValue, updatedResult: finalResult };
3051
3064
  }
3065
+ function subtractCounts(a, b) {
3066
+ const result = new Dice();
3067
+ for (const [key, value] of a.getFaceEntries()) result.increment(key, value);
3068
+ for (const [key, value] of b.getFaceEntries()) result.increment(key, -value);
3069
+ return result;
3070
+ }
3052
3071
  function parseExpression(arr, n) {
3053
3072
  const result = (() => {
3054
3073
  const res = parseArgument(arr, n);
@@ -3056,8 +3075,29 @@ function parseExpression(arr, n) {
3056
3075
  })();
3057
3076
  let op = parseOperation(arr);
3058
3077
  let finalResult = result;
3078
+ let baseDieMeta = result.privateData?.checkDie && !result.privateData.checkDie.rerollOne ? result.privateData.checkDie : void 0;
3079
+ let bonusOnly = Dice.scalar(0);
3059
3080
  while (op != null) {
3060
3081
  const arg = !op.unary ? parseArgument(arr, n) : finalResult;
3082
+ let acAlreadyApplied = false;
3083
+ if (baseDieMeta) {
3084
+ if (op === Dice.prototype.addNonZero) {
3085
+ bonusOnly = bonusOnly.add(arg);
3086
+ } else if (op === Dice.prototype.subtract) {
3087
+ bonusOnly = bonusOnly.subtract(arg);
3088
+ } else if (op === Dice.prototype.ac && typeof arg === "number") {
3089
+ const natMaxSlice = bonusOnly.add(baseDieMeta.sides);
3090
+ const restSlice = subtractCounts(finalResult, natMaxSlice);
3091
+ const gatedNatMaxSlice = natMaxSlice.ac(arg);
3092
+ finalResult = restSlice.ac(arg).combine(gatedNatMaxSlice);
3093
+ finalResult.privateData.checkDie = baseDieMeta;
3094
+ finalResult.privateData.natMaxCritSlice = gatedNatMaxSlice;
3095
+ acAlreadyApplied = true;
3096
+ baseDieMeta = void 0;
3097
+ } else {
3098
+ baseDieMeta = void 0;
3099
+ }
3100
+ }
3061
3101
  let crit;
3062
3102
  let critNorm = 1;
3063
3103
  if (arr[0] === "x" || arr[0] === "c") {
@@ -3068,11 +3108,17 @@ function parseExpression(arr, n) {
3068
3108
  assertToken(arr, "i");
3069
3109
  assertToken(arr, "t");
3070
3110
  const count = isXcrit ? parseNumber(arr, n) : 1;
3071
- crit = new Dice();
3072
- for (let i = 0; i < count; i++) {
3073
- const max = finalResult.maxFace();
3074
- crit.setFace(max, finalResult.get(max));
3075
- finalResult = finalResult.deleteFace(max);
3111
+ const trackedCritSlice = finalResult.privateData?.natMaxCritSlice;
3112
+ if (count === 1 && trackedCritSlice) {
3113
+ crit = trackedCritSlice;
3114
+ finalResult = subtractCounts(finalResult, trackedCritSlice);
3115
+ } else {
3116
+ crit = new Dice();
3117
+ for (let i = 0; i < count; i++) {
3118
+ const max = finalResult.maxFace();
3119
+ crit.setFace(max, finalResult.get(max));
3120
+ finalResult = finalResult.deleteFace(max);
3121
+ }
3076
3122
  }
3077
3123
  critNorm = crit.total();
3078
3124
  crit = op.call(crit, parseBinaryArgument(arg, arr, n));
@@ -3123,7 +3169,9 @@ function parseExpression(arr, n) {
3123
3169
  missNorm = miss && missNorm ? miss.total() / missNorm : 1;
3124
3170
  }
3125
3171
  let norm = finalResult.total();
3126
- finalResult = op.call(finalResult, arg);
3172
+ if (!acAlreadyApplied) {
3173
+ finalResult = op.call(finalResult, arg);
3174
+ }
3127
3175
  norm = norm ? finalResult.total() / norm : 1;
3128
3176
  if (crit) {
3129
3177
  const result2 = combineDiceWithNormalization(
@@ -3314,6 +3362,7 @@ function parseDice(s, n) {
3314
3362
  if (rerollOne) {
3315
3363
  result = result.reroll(1);
3316
3364
  }
3365
+ result.privateData.checkDie = { sides, rerollOne };
3317
3366
  return result;
3318
3367
  }
3319
3368
  function peek(arr, expected) {
@@ -3505,6 +3554,12 @@ function d20PMF(rerollOne) {
3505
3554
  }
3506
3555
 
3507
3556
  // src/builder/roll.ts
3557
+ function validateScaleInt(scale) {
3558
+ const scaleInt = Math.floor(scale);
3559
+ if (scaleInt !== scale) throw new Error("Scale must be an integer");
3560
+ if (scaleInt <= 0) throw new Error("Scale must be > 0");
3561
+ return scaleInt;
3562
+ }
3508
3563
  var rollPMFCache = new LRUCache(4e3);
3509
3564
  function clearRollCache() {
3510
3565
  rollPMFCache.clear();
@@ -3710,7 +3765,8 @@ var RollBuilder = class _RollBuilder {
3710
3765
  newConfigs[newConfigs.length - 1].explode = count;
3711
3766
  return this.create(newConfigs);
3712
3767
  }
3713
- /** Apply per-die minimum value (min > 0). */
3768
+ /** Apply per-die minimum value (floors each die roll at `val`, e.g. `minimum(3)` treats a 1 or
3769
+ * 2 as a 3 -- the 2024 Great Weapon Fighting style). */
3714
3770
  minimum(val) {
3715
3771
  if (val !== void 0 && isNaN(val))
3716
3772
  throw new Error("Invalid NaN value for minimum");
@@ -3718,7 +3774,7 @@ var RollBuilder = class _RollBuilder {
3718
3774
  if (val === 0) return this;
3719
3775
  if (val < 0) throw new Error("Minimum value must be >= 0");
3720
3776
  const newConfigs = this.getSubRollConfigs();
3721
- newConfigs[newConfigs.length - 1].minimum = val + 1;
3777
+ newConfigs[newConfigs.length - 1].minimum = val;
3722
3778
  return this.create(newConfigs);
3723
3779
  }
3724
3780
  bestOf(count) {
@@ -3819,9 +3875,7 @@ var RollBuilder = class _RollBuilder {
3819
3875
  return this.create(configs);
3820
3876
  }
3821
3877
  scaleDice(scale) {
3822
- const scaleInt = Math.floor(scale);
3823
- if (scaleInt !== scale) throw new Error("Scale must be an integer");
3824
- if (scaleInt <= 0) throw new Error("Scale must be > 0");
3878
+ const scaleInt = validateScaleInt(scale);
3825
3879
  const newConfigs = this.getSubRollConfigs().map((config) => {
3826
3880
  if (!config.sides || config.sides <= 0) return config;
3827
3881
  return { ...config, count: config.count * scaleInt };
@@ -3944,12 +3998,16 @@ var RollBuilder = class _RollBuilder {
3944
3998
  }
3945
3999
  configToSingleExpressionWithoutModifier(config, isRootDie) {
3946
4000
  if (!config.sides || config.sides <= 0) return "";
4001
+ if (config.explode && Number.isFinite(config.explode) && config.explode > 0) {
4002
+ throw new Error(
4003
+ `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().`
4004
+ );
4005
+ }
3947
4006
  let baseDie = `d${config.sides}`;
4007
+ const rerollClause = config.reroll > 0 ? config.reroll === 1 ? " reroll 1" : ` reroll d${config.reroll}` : "";
3948
4008
  if (config.reroll > 0) {
3949
- if (config.minimum > 0 && config.explode > 0) ; else if (config.minimum > 0) {
3950
- for (let i = config.reroll; i >= 1; i--) baseDie += ` reroll ${i}`;
3951
- } else {
3952
- for (let i = 1; i <= config.reroll; i++) baseDie += ` reroll ${i}`;
4009
+ if (config.minimum > 0 && config.explode > 0) ; else {
4010
+ baseDie += rerollClause;
3953
4011
  }
3954
4012
  }
3955
4013
  if (config.minimum > 0) {
@@ -3959,9 +4017,7 @@ var RollBuilder = class _RollBuilder {
3959
4017
  baseDie = `${config.minimum}>${baseDie}`;
3960
4018
  }
3961
4019
  if (config.reroll > 0 && config.explode > 0) {
3962
- for (let i = 1; i <= config.reroll; i++) {
3963
- baseDie += ` reroll ${i}`;
3964
- }
4020
+ baseDie += rerollClause;
3965
4021
  }
3966
4022
  }
3967
4023
  if (baseDie === "d20 reroll 1" && config.minimum <= 1) baseDie = "hd20";
@@ -3979,10 +4035,14 @@ var RollBuilder = class _RollBuilder {
3979
4035
  case "flat":
3980
4036
  if (config.keep) {
3981
4037
  const mode = config.keep.mode === "highest" ? "kh" : "kl";
4038
+ const baseCount = Math.max(1, Math.floor(Math.abs(config.count || 1)));
4039
+ const trials = Math.max(1, Math.floor(config.keep.total));
4040
+ const isMaxOfShape = config.keep.count === 1 && config.keep.mode === "highest";
4041
+ const innerCount = trials === baseCount && !isMaxOfShape ? 1 : baseCount;
3982
4042
  const baseDieExpression = this.configToSingleExpressionWithoutModifier(
3983
4043
  {
3984
4044
  ...config,
3985
- count: config.count,
4045
+ count: innerCount,
3986
4046
  modifier: 0,
3987
4047
  rollType: "flat",
3988
4048
  keep: void 0
@@ -4014,7 +4074,19 @@ var RollBuilder = class _RollBuilder {
4014
4074
  }
4015
4075
  }
4016
4076
  if (config.bestOf && config.count && config.bestOf < config.count) {
4017
- mainExpression += `kh${config.bestOf}`;
4077
+ const pool = Math.max(1, Math.floor(Math.abs(config.count)));
4078
+ const baseDieExpression = this.configToSingleExpressionWithoutModifier(
4079
+ {
4080
+ ...config,
4081
+ count: 1,
4082
+ modifier: 0,
4083
+ bestOf: 0,
4084
+ keep: void 0,
4085
+ rollType: "flat"
4086
+ },
4087
+ false
4088
+ );
4089
+ mainExpression = `${pool}kh${Math.floor(config.bestOf)}(${baseDieExpression})`;
4018
4090
  }
4019
4091
  break;
4020
4092
  }
@@ -4117,6 +4189,12 @@ var HalfRollBuilder = class _HalfRollBuilder extends RollBuilder {
4117
4189
  toPMF(eps = 0) {
4118
4190
  return pmfFromRollBuilder(this, eps);
4119
4191
  }
4192
+ // Scale the dice, keep the same // 2 (half) transform applied on top -- delegating to the
4193
+ // base class's `create()`-based scaleDice would drop the halving entirely, e.g. a doubled-dice
4194
+ // crit on a resisted hit payload silently losing the resistance.
4195
+ scaleDice(scale) {
4196
+ return new _HalfRollBuilder(this.innerRoll.scaleDice(scale));
4197
+ }
4120
4198
  copy() {
4121
4199
  return new _HalfRollBuilder(this.innerRoll.copy());
4122
4200
  }
@@ -4143,9 +4221,16 @@ var ScaleRollBuilder = class _ScaleRollBuilder extends RollBuilder {
4143
4221
  }
4144
4222
  toExpression() {
4145
4223
  const inner = this.innerRoll.toExpression();
4146
- if (this.denominator === 1) return `${this.numerator} * (${inner})`;
4147
- if (this.numerator === 1) return `(${inner}) // ${this.denominator}`;
4148
- return `(${inner}) * ${this.numerator} // ${this.denominator}`;
4224
+ const denominator = this.denominator === 0 ? 1 : this.denominator;
4225
+ if (denominator === 1) return `${this.numerator} ** (${inner})`;
4226
+ if (this.rounding === "round") {
4227
+ throw new Error(
4228
+ `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.`
4229
+ );
4230
+ }
4231
+ const div = this.rounding === "ceil" ? "/" : "//";
4232
+ if (this.numerator === 1) return `(${inner}) ${div} ${denominator}`;
4233
+ return `(${inner}) ** ${this.numerator} ${div} ${denominator}`;
4149
4234
  }
4150
4235
  toAST() {
4151
4236
  return {
@@ -4159,6 +4244,17 @@ var ScaleRollBuilder = class _ScaleRollBuilder extends RollBuilder {
4159
4244
  toPMF(eps = 0) {
4160
4245
  return pmfFromRollBuilder(this, eps);
4161
4246
  }
4247
+ // Scale the dice, keep the same numerator/denominator/rounding transform applied on top --
4248
+ // delegating to the base class's `create()`-based scaleDice would drop the scale entirely,
4249
+ // e.g. a doubled-dice crit on a vulnerable hit payload silently losing the vulnerability.
4250
+ scaleDice(scale) {
4251
+ return new _ScaleRollBuilder(
4252
+ this.innerRoll.scaleDice(scale),
4253
+ this.numerator,
4254
+ this.denominator,
4255
+ this.rounding
4256
+ );
4257
+ }
4162
4258
  copy() {
4163
4259
  return new _ScaleRollBuilder(
4164
4260
  this.innerRoll.copy(),
@@ -4231,6 +4327,18 @@ var MaxOfRollBuilder = class _MaxOfRollBuilder extends RollBuilder {
4231
4327
  toPMF(eps = 0) {
4232
4328
  return pmfFromRollBuilder(this, eps);
4233
4329
  }
4330
+ // Scale the dice INSIDE each trial (e.g. maxOf(2, 1d12) -> maxOf(2, 2d12)), keeping the same
4331
+ // trial count -- delegating to the base class's `create()`-based scaleDice would collapse
4332
+ // straight to plain dice, losing the "take the highest of N trials" semantics entirely.
4333
+ scaleDice(scale) {
4334
+ const scaleInt = validateScaleInt(scale);
4335
+ return new _MaxOfRollBuilder(
4336
+ this.innerRoll.scaleDice(scaleInt),
4337
+ this.count,
4338
+ this.diceCount ? this.diceCount * scaleInt : void 0,
4339
+ this.diceSides
4340
+ );
4341
+ }
4234
4342
  copy() {
4235
4343
  return new _MaxOfRollBuilder(this.innerRoll.copy(), this.count);
4236
4344
  }
@@ -4277,9 +4385,7 @@ var AlwaysHitBuilder = class _AlwaysHitBuilder extends RollBuilder {
4277
4385
  return new RollBuilder(configs).toExpression();
4278
4386
  }
4279
4387
  toPMF() {
4280
- const rollType = this.rollType;
4281
- const rerollOne = this.baseReroll > 0;
4282
- return d20RollPMF(rollType, rerollOne);
4388
+ return resolveRootD20(this);
4283
4389
  }
4284
4390
  copy() {
4285
4391
  const baseCopy = new RollBuilder(this.getSubRollConfigs());
@@ -4327,9 +4433,7 @@ var AlwaysCritBuilder = class _AlwaysCritBuilder extends RollBuilder {
4327
4433
  return new RollBuilder(configs).toExpression();
4328
4434
  }
4329
4435
  toPMF() {
4330
- const rollType = this.rollType;
4331
- const rerollOne = this.baseReroll > 0;
4332
- return d20RollPMF(rollType, rerollOne);
4436
+ return resolveRootD20(this);
4333
4437
  }
4334
4438
  copy() {
4335
4439
  const baseCopy = new RollBuilder(this.getSubRollConfigs());
@@ -4519,6 +4623,17 @@ var CompositeSumRollBuilder = class _CompositeSumRollBuilder extends RollBuilder
4519
4623
  toPMF(eps = 0) {
4520
4624
  return pmfFromRollBuilder(this, eps);
4521
4625
  }
4626
+ // Scaling a composite (mixed damage types, e.g. base + resisted) must scale each PART's own
4627
+ // dice while preserving its own half/scale wrapper -- delegating to the base class's
4628
+ // `create()`-based scaleDice would lose every part's transform, collapsing straight to plain
4629
+ // dice. This is what auto-crit doubling (attack.ts's `hitEffect.copy().doubleDice()`) relies
4630
+ // on for a mixed-resistance hit payload.
4631
+ scaleDice(scale) {
4632
+ validateScaleInt(scale);
4633
+ return new _CompositeSumRollBuilder(
4634
+ this.parts.map((p) => p.scaleDice(scale))
4635
+ );
4636
+ }
4522
4637
  copy() {
4523
4638
  return new _CompositeSumRollBuilder(this.parts.map((p) => p.copy()));
4524
4639
  }
@@ -4596,6 +4711,15 @@ var builderPMFCache = new LRUCache(1e3);
4596
4711
  // src/builder/ast.ts
4597
4712
  var defaultEps = 0;
4598
4713
  var singleDiePMFCache = new LRUCache(1e3);
4714
+ function dieNodeFromConfig(cfg) {
4715
+ return {
4716
+ type: "die",
4717
+ sides: cfg.sides,
4718
+ reroll: cfg.reroll > 0 ? cfg.reroll : void 0,
4719
+ minimum: cfg.minimum > 0 ? cfg.minimum : void 0,
4720
+ explode: cfg.explode && Number.isFinite(cfg.explode) && cfg.explode > 0 ? cfg.explode : void 0
4721
+ };
4722
+ }
4599
4723
  function astFromRollConfigs(configs) {
4600
4724
  if (!configs || configs.length === 0) return void 0;
4601
4725
  const children = [];
@@ -4605,13 +4729,9 @@ function astFromRollConfigs(configs) {
4605
4729
  const count = Math.abs(cfg.count || 0);
4606
4730
  constantSum += cfg.modifier || 0;
4607
4731
  if ((cfg.sides || 0) <= 0) continue;
4608
- const die = {
4609
- type: "die",
4610
- sides: cfg.sides,
4611
- reroll: cfg.reroll > 0 ? cfg.reroll : void 0,
4612
- minimum: cfg.minimum > 0 ? cfg.minimum : void 0,
4613
- explode: cfg.explode && Number.isFinite(cfg.explode) && cfg.explode > 0 ? cfg.explode : void 0
4614
- };
4732
+ const isSynthesizedBestOf = !cfg.keep && cfg.bestOf > 0 && cfg.bestOf < count;
4733
+ const effectiveKeep = isSynthesizedBestOf ? { total: count, count: Math.floor(cfg.bestOf), mode: "highest" } : cfg.keep;
4734
+ const die = dieNodeFromConfig(cfg);
4615
4735
  let node = die;
4616
4736
  let appliedRollType = false;
4617
4737
  if (cfg.rollType && cfg.rollType !== "flat") {
@@ -4629,11 +4749,11 @@ function astFromRollConfigs(configs) {
4629
4749
  }
4630
4750
  appliedRollType = true;
4631
4751
  }
4632
- if (cfg.rollType === "flat" && cfg.keep && cfg.keep.total > 0) {
4752
+ if (cfg.rollType === "flat" && effectiveKeep && effectiveKeep.total > 0) {
4633
4753
  const baseCount = Math.max(1, Math.floor(Math.abs(count || 1)));
4634
- const trials = Math.max(1, Math.floor(cfg.keep.total));
4635
- const k = Math.max(0, Math.floor(cfg.keep.count));
4636
- if (k === 1 && cfg.keep.mode === "highest") {
4754
+ const trials = Math.max(1, Math.floor(effectiveKeep.total));
4755
+ const k = Math.max(0, Math.floor(effectiveKeep.count));
4756
+ if (k === 1 && effectiveKeep.mode === "highest" && !isSynthesizedBestOf) {
4637
4757
  const perTrial = {
4638
4758
  type: "sum",
4639
4759
  count: baseCount,
@@ -4652,7 +4772,7 @@ function astFromRollConfigs(configs) {
4652
4772
  const base = { type: "sum", count: trials, child: node };
4653
4773
  node = {
4654
4774
  type: "keep",
4655
- mode: cfg.keep.mode,
4775
+ mode: effectiveKeep.mode,
4656
4776
  count: k,
4657
4777
  child: base
4658
4778
  };
@@ -4672,7 +4792,7 @@ function astFromRollConfigs(configs) {
4672
4792
  };
4673
4793
  node = {
4674
4794
  type: "keep",
4675
- mode: cfg.keep.mode,
4795
+ mode: effectiveKeep.mode,
4676
4796
  count: k,
4677
4797
  child: trialPool
4678
4798
  };
@@ -4746,8 +4866,8 @@ function resolve(node, eps = defaultEps) {
4746
4866
  }
4747
4867
  case "d20Roll": {
4748
4868
  const childDie = findDie(node.child);
4749
- const rerollOne = !!childDie && (childDie.reroll || 0) >= 1;
4750
- return d20RollPMF(node.rollType, rerollOne);
4869
+ if (!childDie) return d20RollPMF(node.rollType, false);
4870
+ return resolveD20Roll(childDie, node.rollType);
4751
4871
  }
4752
4872
  case "half": {
4753
4873
  const childPMF = resolve(node.child, eps);
@@ -4773,6 +4893,37 @@ function pmfFromRollBuilder(rb, eps = defaultEps) {
4773
4893
  const ast = rb.toAST();
4774
4894
  return resolve(ast, eps);
4775
4895
  }
4896
+ var d20RollLiftCache = new LRUCache(500);
4897
+ function resolveD20Roll(die, rollType) {
4898
+ const base = resolveSingleDie(die, defaultEps);
4899
+ const type = rollType || "flat";
4900
+ if (type === "flat") return base;
4901
+ const cacheKey = `${getASTSignature(die)}|${type}`;
4902
+ const cached = d20RollLiftCache.get(cacheKey);
4903
+ if (cached) return cached;
4904
+ const support = [...base.support()].sort((a, b) => a - b);
4905
+ const out = /* @__PURE__ */ new Map();
4906
+ let cum = 0;
4907
+ let prevLifted = 0;
4908
+ for (const k of support) {
4909
+ cum += base.pAt(k);
4910
+ const curLifted = type === "advantage" ? cum * cum : type === "elven accuracy" ? cum * cum * cum : 1 - (1 - cum) * (1 - cum);
4911
+ const pk = curLifted - prevLifted;
4912
+ if (pk > 0) out.set(k, pk);
4913
+ prevLifted = curLifted;
4914
+ }
4915
+ const result = PMF.fromMap(out, defaultEps);
4916
+ d20RollLiftCache.set(cacheKey, result);
4917
+ return result;
4918
+ }
4919
+ function resolveRootD20(check) {
4920
+ const rootConfig = check.getRootDieConfig();
4921
+ const rollType = check.rollType;
4922
+ if (!rootConfig || !(rootConfig.sides > 0)) {
4923
+ return d20RollPMF(rollType, check.baseReroll > 0);
4924
+ }
4925
+ return resolveD20Roll(dieNodeFromConfig(rootConfig), rollType);
4926
+ }
4776
4927
  function resolveSingleDie(die, eps = defaultEps) {
4777
4928
  const signature = getASTSignature(die);
4778
4929
  const cacheKey = `${signature}_${eps}`;
@@ -4806,20 +4957,12 @@ function resolveSingleDie(die, eps = defaultEps) {
4806
4957
  for (const v of pmf.support()) {
4807
4958
  if (v !== maxFace) nonMax.set(v, pmf.pAt(v));
4808
4959
  }
4809
- let nonMaxPMF = PMF.fromMap(nonMax, eps);
4810
- if (Math.abs(nonMaxPMF.mass() - (1 - pMax)) > eps) {
4811
- nonMaxPMF = nonMaxPMF.scaleMass(1 - pMax);
4812
- }
4813
- let tail = PMF.delta(0, eps);
4814
- const addOnce = pmf;
4815
- for (let t = 1; t <= times; t++) {
4816
- tail = tail.convolve(addOnce, eps);
4960
+ const nonMaxPMF = PMF.fromMap(nonMax, eps);
4961
+ let chain = pmf;
4962
+ for (let remaining = 1; remaining <= times - 1; remaining++) {
4963
+ chain = PMF.branch(chain.mapDamage((v) => v + maxFace), nonMaxPMF, pMax);
4817
4964
  }
4818
- const exploded = PMF.branch(
4819
- tail.mapDamage((v) => v + maxFace),
4820
- nonMaxPMF,
4821
- pMax
4822
- );
4965
+ const exploded = PMF.branch(chain.mapDamage((v) => v + maxFace), nonMaxPMF, pMax);
4823
4966
  pmf = exploded;
4824
4967
  }
4825
4968
  singleDiePMFCache.set(cacheKey, pmf);
@@ -5118,10 +5261,8 @@ var AttackBuilder = class _AttackBuilder {
5118
5261
  return `${checkPart} * ${effectPart}`;
5119
5262
  }
5120
5263
  resolveProbabilities(check, eps = 0) {
5121
- const rollType = check.rollType;
5122
- const rerollOne = check.baseReroll > 0;
5123
5264
  const critThreshold = check.critThreshold;
5124
- const d202 = d20RollPMF(rollType, rerollOne);
5265
+ const d202 = resolveRootD20(check);
5125
5266
  if (check instanceof AlwaysCritBuilder) {
5126
5267
  if (check.fromAlwaysHit) {
5127
5268
  return { pSuccess: 1, pHit: 0, pCrit: 1, pMiss: 0 };
@@ -5171,13 +5312,17 @@ var AttackBuilder = class _AttackBuilder {
5171
5312
  pmiss += pr;
5172
5313
  continue;
5173
5314
  }
5174
- if (r >= critThreshold) {
5315
+ if (r === 20) {
5175
5316
  pcrit += pr;
5176
5317
  continue;
5177
5318
  }
5178
5319
  const need = ac - staticMod - r;
5179
5320
  const pBonusHit = bonusPMF.tailProbGE(need);
5180
- phit += pr * pBonusHit;
5321
+ if (r >= critThreshold) {
5322
+ pcrit += pr * pBonusHit;
5323
+ } else {
5324
+ phit += pr * pBonusHit;
5325
+ }
5181
5326
  pmiss += pr * (1 - pBonusHit);
5182
5327
  }
5183
5328
  const psuccess = phit + pcrit;
@@ -5325,9 +5470,7 @@ var ACBuilder = class _ACBuilder extends RollBuilder {
5325
5470
  }
5326
5471
  toPMF(eps = 0) {
5327
5472
  const ac = this.attackConfig.ac;
5328
- const rollType = this.rollType;
5329
- const rerollOne = this.baseReroll > 0;
5330
- const d202 = d20RollPMF(rollType, rerollOne);
5473
+ const d202 = resolveRootD20(this);
5331
5474
  const staticMod = this.modifier;
5332
5475
  const bonusPMFs = this.getBonusDicePMFs(this, eps);
5333
5476
  const parts = [d202, ...bonusPMFs];
@@ -5437,21 +5580,17 @@ var SaveBuilder = class _SaveBuilder {
5437
5580
  function resolveProbabilities(check) {
5438
5581
  const saveBonus = check.modifier;
5439
5582
  const dc = check.saveDC;
5440
- const d20Type = check.rollType;
5441
- const baseReroll = check.baseReroll;
5442
- const die = d20RollPMF(d20Type, baseReroll > 0);
5583
+ const eps = 0;
5584
+ const die = resolveRootD20(check);
5443
5585
  const faceP = /* @__PURE__ */ new Map();
5444
5586
  for (const [r, bin] of die) {
5445
5587
  const pr = bin.p;
5446
5588
  if (pr > 0) faceP.set(r, pr);
5447
5589
  }
5448
- const eps = 0;
5449
5590
  const bonusDicePMFs = check.getBonusDicePMFs(check, eps);
5450
5591
  const bonusPMF = bonusDicePMFs.length > 0 ? PMF.convolveMany(bonusDicePMFs, eps) : PMF.zero(eps);
5451
5592
  let pSuccess = 0;
5452
- for (let r = 1; r <= 20; r++) {
5453
- const pr = faceP.get(r);
5454
- if (!pr) continue;
5593
+ for (const [r, pr] of faceP) {
5455
5594
  const need = dc - saveBonus - r;
5456
5595
  pSuccess += pr * bonusPMF.tailProbGE(need);
5457
5596
  }
@@ -5521,9 +5660,7 @@ var DCBuilder = class _DCBuilder extends RollBuilder {
5521
5660
  if (cached) return cached;
5522
5661
  }
5523
5662
  const saveDC = this.saveDC;
5524
- const rollType = this.rollType;
5525
- const rerollOne = this.baseReroll > 0;
5526
- const d202 = d20RollPMF(rollType, rerollOne);
5663
+ const d202 = resolveRootD20(this);
5527
5664
  const staticMod = this.modifier;
5528
5665
  const bonusDicePMFs = this.getBonusDiceConfigs().map(
5529
5666
  (cfg) => pmfFromRollBuilder(RollBuilder.fromConfigs([cfg]), eps)