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