@yipe/dice 0.8.1 → 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.
Files changed (42) hide show
  1. package/README.md +169 -19
  2. package/dist/builder/ac.d.ts.map +1 -1
  3. package/dist/builder/ast.d.ts +32 -2
  4. package/dist/builder/ast.d.ts.map +1 -1
  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 +889 -90
  10. package/dist/builder/index.cjs.map +1 -1
  11. package/dist/builder/index.d.ts +1 -0
  12. package/dist/builder/index.d.ts.map +1 -1
  13. package/dist/builder/index.js +886 -91
  14. package/dist/builder/index.js.map +1 -1
  15. package/dist/builder/roll.d.ts +5 -1
  16. package/dist/builder/roll.d.ts.map +1 -1
  17. package/dist/builder/save.d.ts.map +1 -1
  18. package/dist/index.cjs +178 -15
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +177 -16
  23. package/dist/index.js.map +1 -1
  24. package/dist/parser/dice.d.ts +14 -0
  25. package/dist/parser/dice.d.ts.map +1 -1
  26. package/dist/parser/rollType.d.ts +58 -0
  27. package/dist/parser/rollType.d.ts.map +1 -0
  28. package/dist/pmf/pmf.d.ts +10 -4
  29. package/dist/pmf/pmf.d.ts.map +1 -1
  30. package/dist/pmf/query.d.ts +26 -0
  31. package/dist/pmf/query.d.ts.map +1 -1
  32. package/dist/turn/index.d.ts +3 -0
  33. package/dist/turn/index.d.ts.map +1 -0
  34. package/dist/turn/plan.d.ts +55 -0
  35. package/dist/turn/plan.d.ts.map +1 -0
  36. package/dist/turn/state.d.ts +23 -0
  37. package/dist/turn/state.d.ts.map +1 -0
  38. package/dist/turn/turn.d.ts +149 -0
  39. package/dist/turn/turn.d.ts.map +1 -0
  40. package/dist/turn/types.d.ts +99 -0
  41. package/dist/turn/types.d.ts.map +1 -0
  42. package/package.json +1 -1
@@ -115,6 +115,10 @@ var _DiceQuery = class _DiceQuery {
115
115
  if (this._combinedWithAttr) {
116
116
  return this._combinedWithAttr;
117
117
  }
118
+ if (this._combinedProvided) {
119
+ this._combinedWithAttr = this.combined.withAttribution();
120
+ return this._combinedWithAttr;
121
+ }
118
122
  if (this.singles.every((pmf) => pmf.hasAttribution())) {
119
123
  this._combinedWithAttr = this.combined;
120
124
  return this._combinedWithAttr;
@@ -885,6 +889,55 @@ var _DiceQuery = class _DiceQuery {
885
889
  }
886
890
  return out;
887
891
  }
892
+ /**
893
+ * Per-outcome probabilities and damage ranges, aggregated over the individual
894
+ * singles rather than read off the combined distribution.
895
+ *
896
+ * `damageRange` is the sum, over every single that can produce the outcome, of
897
+ * that single's own conditional damage range: "what this outcome contributes
898
+ * across the whole turn when every attack that can produce it does". Linear in
899
+ * the number of attacks by construction.
900
+ *
901
+ * Prefer this over {@link DiceQuery.snapshot} for a multi-attack query.
902
+ * `snapshot` reads `damageRange` off the combined PMF's `count`, which the
903
+ * convolution accumulates as an expected count, so its `avg` is size-biased
904
+ * for N≥2 (its own doc comment says so). The two agree for a single attack.
905
+ *
906
+ * Only outcomes that actually occur appear in the result.
907
+ *
908
+ * Like every `singles`-based helper on this class, it describes the singles
909
+ * and not an explicitly provided `combined`. `Turn.toQuery()` supplies one whose
910
+ * distribution also contains rider attacks that are absent from `singles`
911
+ * (an `otherwise([unarmed, unarmed])` flurry, say), so those attacks do not
912
+ * appear here. For rider-inclusive figures read the combined distribution
913
+ * directly: {@link DiceQuery.outcomeTotals}, {@link DiceQuery.outcomeDamageRanges}.
914
+ *
915
+ * @param outcomes Which outcomes to consider; defaults to every canonical one.
916
+ */
917
+ outcomeStats(outcomes = ALL_OUTCOME_TYPES) {
918
+ const stats = /* @__PURE__ */ new Map();
919
+ const perSingle = this.singles.map((pmf) => new _DiceQuery([pmf], void 0, this._eps));
920
+ for (const outcome of outcomes) {
921
+ const atLeastOneProbability = this.probAtLeastOne(outcome);
922
+ if (atLeastOneProbability <= 0) continue;
923
+ const damageRange = { min: 0, avg: 0, max: 0 };
924
+ let contributors = 0;
925
+ for (const single of perSingle) {
926
+ if (single.probAtLeastOne(outcome) <= 0) continue;
927
+ contributors++;
928
+ const stat = single.damageStatsFrom(outcome);
929
+ damageRange.min += stat.min;
930
+ damageRange.avg += stat.avg;
931
+ damageRange.max += stat.max;
932
+ }
933
+ stats.set(outcome, {
934
+ atLeastOneProbability,
935
+ allProbability: contributors > 0 ? this.probExactlyK(outcome, contributors) : 0,
936
+ damageRange
937
+ });
938
+ }
939
+ return stats;
940
+ }
888
941
  /**
889
942
  * Snapshot of the distribution in the exact shape the UI consumes.
890
943
  * - outcome probabilities are "at least one" (and equal to "all" for a single PMF)
@@ -1421,7 +1474,7 @@ var _PMF = class _PMF {
1421
1474
  const id = this.identifier;
1422
1475
  let key = `${id}`;
1423
1476
  for (let i = 1; i < n; i++) key += `+${id}`;
1424
- return `${key}@${eps}`;
1477
+ return `${key}@${eps}|${this.fingerprint()}`;
1425
1478
  }
1426
1479
  /**
1427
1480
  * Efficiently computes this PMF convolved with itself `n` times.
@@ -1772,16 +1825,27 @@ var _PMF = class _PMF {
1772
1825
  return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${p1.fingerprint()}|${p2.fingerprint()}`;
1773
1826
  }
1774
1827
  /**
1775
- * A small content fingerprint (mass + bin count + face sum) so convolution
1776
- * cache keys change if the underlying numbers do. Memoized because a PMF is
1777
- * immutable once constructed — this avoids re-summing every key on each
1778
- * 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.
1779
1838
  */
1780
1839
  fingerprint() {
1781
1840
  if (this._fingerprint === void 0) {
1782
- let faceSum = 0;
1783
- for (const k of this.map.keys()) faceSum += k;
1784
- 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(";")}`;
1785
1849
  }
1786
1850
  return this._fingerprint;
1787
1851
  }
@@ -2013,11 +2077,13 @@ var _PMF = class _PMF {
2013
2077
  /** Quantile / inverse CDF for p in [0,1]. Returns smallest x with CDF ≥ p. */
2014
2078
  quantile(p) {
2015
2079
  if (this.map.size === 0) return 0;
2080
+ const totalMass = this.mass();
2081
+ if (totalMass <= 0) return 0;
2016
2082
  const s = this.support().sort((a, b) => a - b);
2017
2083
  let acc = 0;
2018
2084
  for (const x of s) {
2019
2085
  acc += this.pAt(x);
2020
- if (acc >= p) return x;
2086
+ if (acc / totalMass >= p) return x;
2021
2087
  }
2022
2088
  return s[s.length - 1];
2023
2089
  }
@@ -2996,6 +3062,12 @@ function combineDiceWithNormalization(dice, normValue, outcomeType, currentNorm,
2996
3062
  finalResult = finalResult.combine(dice);
2997
3063
  return { newNorm: currentNorm * normValue, updatedResult: finalResult };
2998
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
+ }
2999
3071
  function parseExpression(arr, n) {
3000
3072
  const result = (() => {
3001
3073
  const res = parseArgument(arr, n);
@@ -3003,8 +3075,29 @@ function parseExpression(arr, n) {
3003
3075
  })();
3004
3076
  let op = parseOperation(arr);
3005
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);
3006
3080
  while (op != null) {
3007
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
+ }
3008
3101
  let crit;
3009
3102
  let critNorm = 1;
3010
3103
  if (arr[0] === "x" || arr[0] === "c") {
@@ -3015,11 +3108,17 @@ function parseExpression(arr, n) {
3015
3108
  assertToken(arr, "i");
3016
3109
  assertToken(arr, "t");
3017
3110
  const count = isXcrit ? parseNumber(arr, n) : 1;
3018
- crit = new Dice();
3019
- for (let i = 0; i < count; i++) {
3020
- const max = finalResult.maxFace();
3021
- crit.setFace(max, finalResult.get(max));
3022
- 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
+ }
3023
3122
  }
3024
3123
  critNorm = crit.total();
3025
3124
  crit = op.call(crit, parseBinaryArgument(arg, arr, n));
@@ -3070,7 +3169,9 @@ function parseExpression(arr, n) {
3070
3169
  missNorm = miss && missNorm ? miss.total() / missNorm : 1;
3071
3170
  }
3072
3171
  let norm = finalResult.total();
3073
- finalResult = op.call(finalResult, arg);
3172
+ if (!acAlreadyApplied) {
3173
+ finalResult = op.call(finalResult, arg);
3174
+ }
3074
3175
  norm = norm ? finalResult.total() / norm : 1;
3075
3176
  if (crit) {
3076
3177
  const result2 = combineDiceWithNormalization(
@@ -3261,6 +3362,7 @@ function parseDice(s, n) {
3261
3362
  if (rerollOne) {
3262
3363
  result = result.reroll(1);
3263
3364
  }
3365
+ result.privateData.checkDie = { sides, rerollOne };
3264
3366
  return result;
3265
3367
  }
3266
3368
  function peek(arr, expected) {
@@ -3452,6 +3554,12 @@ function d20PMF(rerollOne) {
3452
3554
  }
3453
3555
 
3454
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
+ }
3455
3563
  var rollPMFCache = new LRUCache(4e3);
3456
3564
  function clearRollCache() {
3457
3565
  rollPMFCache.clear();
@@ -3657,7 +3765,8 @@ var RollBuilder = class _RollBuilder {
3657
3765
  newConfigs[newConfigs.length - 1].explode = count;
3658
3766
  return this.create(newConfigs);
3659
3767
  }
3660
- /** 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). */
3661
3770
  minimum(val) {
3662
3771
  if (val !== void 0 && isNaN(val))
3663
3772
  throw new Error("Invalid NaN value for minimum");
@@ -3665,7 +3774,7 @@ var RollBuilder = class _RollBuilder {
3665
3774
  if (val === 0) return this;
3666
3775
  if (val < 0) throw new Error("Minimum value must be >= 0");
3667
3776
  const newConfigs = this.getSubRollConfigs();
3668
- newConfigs[newConfigs.length - 1].minimum = val + 1;
3777
+ newConfigs[newConfigs.length - 1].minimum = val;
3669
3778
  return this.create(newConfigs);
3670
3779
  }
3671
3780
  bestOf(count) {
@@ -3766,9 +3875,7 @@ var RollBuilder = class _RollBuilder {
3766
3875
  return this.create(configs);
3767
3876
  }
3768
3877
  scaleDice(scale) {
3769
- const scaleInt = Math.floor(scale);
3770
- if (scaleInt !== scale) throw new Error("Scale must be an integer");
3771
- if (scaleInt <= 0) throw new Error("Scale must be > 0");
3878
+ const scaleInt = validateScaleInt(scale);
3772
3879
  const newConfigs = this.getSubRollConfigs().map((config) => {
3773
3880
  if (!config.sides || config.sides <= 0) return config;
3774
3881
  return { ...config, count: config.count * scaleInt };
@@ -3891,12 +3998,16 @@ var RollBuilder = class _RollBuilder {
3891
3998
  }
3892
3999
  configToSingleExpressionWithoutModifier(config, isRootDie) {
3893
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
+ }
3894
4006
  let baseDie = `d${config.sides}`;
4007
+ const rerollClause = config.reroll > 0 ? config.reroll === 1 ? " reroll 1" : ` reroll d${config.reroll}` : "";
3895
4008
  if (config.reroll > 0) {
3896
- if (config.minimum > 0 && config.explode > 0) ; else if (config.minimum > 0) {
3897
- for (let i = config.reroll; i >= 1; i--) baseDie += ` reroll ${i}`;
3898
- } else {
3899
- for (let i = 1; i <= config.reroll; i++) baseDie += ` reroll ${i}`;
4009
+ if (config.minimum > 0 && config.explode > 0) ; else {
4010
+ baseDie += rerollClause;
3900
4011
  }
3901
4012
  }
3902
4013
  if (config.minimum > 0) {
@@ -3906,9 +4017,7 @@ var RollBuilder = class _RollBuilder {
3906
4017
  baseDie = `${config.minimum}>${baseDie}`;
3907
4018
  }
3908
4019
  if (config.reroll > 0 && config.explode > 0) {
3909
- for (let i = 1; i <= config.reroll; i++) {
3910
- baseDie += ` reroll ${i}`;
3911
- }
4020
+ baseDie += rerollClause;
3912
4021
  }
3913
4022
  }
3914
4023
  if (baseDie === "d20 reroll 1" && config.minimum <= 1) baseDie = "hd20";
@@ -3926,10 +4035,14 @@ var RollBuilder = class _RollBuilder {
3926
4035
  case "flat":
3927
4036
  if (config.keep) {
3928
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;
3929
4042
  const baseDieExpression = this.configToSingleExpressionWithoutModifier(
3930
4043
  {
3931
4044
  ...config,
3932
- count: config.count,
4045
+ count: innerCount,
3933
4046
  modifier: 0,
3934
4047
  rollType: "flat",
3935
4048
  keep: void 0
@@ -3961,7 +4074,19 @@ var RollBuilder = class _RollBuilder {
3961
4074
  }
3962
4075
  }
3963
4076
  if (config.bestOf && config.count && config.bestOf < config.count) {
3964
- 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})`;
3965
4090
  }
3966
4091
  break;
3967
4092
  }
@@ -4064,6 +4189,12 @@ var HalfRollBuilder = class _HalfRollBuilder extends RollBuilder {
4064
4189
  toPMF(eps = 0) {
4065
4190
  return pmfFromRollBuilder(this, eps);
4066
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
+ }
4067
4198
  copy() {
4068
4199
  return new _HalfRollBuilder(this.innerRoll.copy());
4069
4200
  }
@@ -4090,9 +4221,16 @@ var ScaleRollBuilder = class _ScaleRollBuilder extends RollBuilder {
4090
4221
  }
4091
4222
  toExpression() {
4092
4223
  const inner = this.innerRoll.toExpression();
4093
- if (this.denominator === 1) return `${this.numerator} * (${inner})`;
4094
- if (this.numerator === 1) return `(${inner}) // ${this.denominator}`;
4095
- 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}`;
4096
4234
  }
4097
4235
  toAST() {
4098
4236
  return {
@@ -4106,6 +4244,17 @@ var ScaleRollBuilder = class _ScaleRollBuilder extends RollBuilder {
4106
4244
  toPMF(eps = 0) {
4107
4245
  return pmfFromRollBuilder(this, eps);
4108
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
+ }
4109
4258
  copy() {
4110
4259
  return new _ScaleRollBuilder(
4111
4260
  this.innerRoll.copy(),
@@ -4178,6 +4327,18 @@ var MaxOfRollBuilder = class _MaxOfRollBuilder extends RollBuilder {
4178
4327
  toPMF(eps = 0) {
4179
4328
  return pmfFromRollBuilder(this, eps);
4180
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
+ }
4181
4342
  copy() {
4182
4343
  return new _MaxOfRollBuilder(this.innerRoll.copy(), this.count);
4183
4344
  }
@@ -4224,9 +4385,7 @@ var AlwaysHitBuilder = class _AlwaysHitBuilder extends RollBuilder {
4224
4385
  return new RollBuilder(configs).toExpression();
4225
4386
  }
4226
4387
  toPMF() {
4227
- const rollType = this.rollType;
4228
- const rerollOne = this.baseReroll > 0;
4229
- return d20RollPMF(rollType, rerollOne);
4388
+ return resolveRootD20(this);
4230
4389
  }
4231
4390
  copy() {
4232
4391
  const baseCopy = new RollBuilder(this.getSubRollConfigs());
@@ -4274,9 +4433,7 @@ var AlwaysCritBuilder = class _AlwaysCritBuilder extends RollBuilder {
4274
4433
  return new RollBuilder(configs).toExpression();
4275
4434
  }
4276
4435
  toPMF() {
4277
- const rollType = this.rollType;
4278
- const rerollOne = this.baseReroll > 0;
4279
- return d20RollPMF(rollType, rerollOne);
4436
+ return resolveRootD20(this);
4280
4437
  }
4281
4438
  copy() {
4282
4439
  const baseCopy = new RollBuilder(this.getSubRollConfigs());
@@ -4466,6 +4623,17 @@ var CompositeSumRollBuilder = class _CompositeSumRollBuilder extends RollBuilder
4466
4623
  toPMF(eps = 0) {
4467
4624
  return pmfFromRollBuilder(this, eps);
4468
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
+ }
4469
4637
  copy() {
4470
4638
  return new _CompositeSumRollBuilder(this.parts.map((p) => p.copy()));
4471
4639
  }
@@ -4543,6 +4711,15 @@ var builderPMFCache = new LRUCache(1e3);
4543
4711
  // src/builder/ast.ts
4544
4712
  var defaultEps = 0;
4545
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
+ }
4546
4723
  function astFromRollConfigs(configs) {
4547
4724
  if (!configs || configs.length === 0) return void 0;
4548
4725
  const children = [];
@@ -4552,13 +4729,9 @@ function astFromRollConfigs(configs) {
4552
4729
  const count = Math.abs(cfg.count || 0);
4553
4730
  constantSum += cfg.modifier || 0;
4554
4731
  if ((cfg.sides || 0) <= 0) continue;
4555
- const die = {
4556
- type: "die",
4557
- sides: cfg.sides,
4558
- reroll: cfg.reroll > 0 ? cfg.reroll : void 0,
4559
- minimum: cfg.minimum > 0 ? cfg.minimum : void 0,
4560
- explode: cfg.explode && Number.isFinite(cfg.explode) && cfg.explode > 0 ? cfg.explode : void 0
4561
- };
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);
4562
4735
  let node = die;
4563
4736
  let appliedRollType = false;
4564
4737
  if (cfg.rollType && cfg.rollType !== "flat") {
@@ -4576,11 +4749,11 @@ function astFromRollConfigs(configs) {
4576
4749
  }
4577
4750
  appliedRollType = true;
4578
4751
  }
4579
- if (cfg.rollType === "flat" && cfg.keep && cfg.keep.total > 0) {
4752
+ if (cfg.rollType === "flat" && effectiveKeep && effectiveKeep.total > 0) {
4580
4753
  const baseCount = Math.max(1, Math.floor(Math.abs(count || 1)));
4581
- const trials = Math.max(1, Math.floor(cfg.keep.total));
4582
- const k = Math.max(0, Math.floor(cfg.keep.count));
4583
- 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) {
4584
4757
  const perTrial = {
4585
4758
  type: "sum",
4586
4759
  count: baseCount,
@@ -4599,7 +4772,7 @@ function astFromRollConfigs(configs) {
4599
4772
  const base = { type: "sum", count: trials, child: node };
4600
4773
  node = {
4601
4774
  type: "keep",
4602
- mode: cfg.keep.mode,
4775
+ mode: effectiveKeep.mode,
4603
4776
  count: k,
4604
4777
  child: base
4605
4778
  };
@@ -4619,7 +4792,7 @@ function astFromRollConfigs(configs) {
4619
4792
  };
4620
4793
  node = {
4621
4794
  type: "keep",
4622
- mode: cfg.keep.mode,
4795
+ mode: effectiveKeep.mode,
4623
4796
  count: k,
4624
4797
  child: trialPool
4625
4798
  };
@@ -4693,8 +4866,8 @@ function resolve(node, eps = defaultEps) {
4693
4866
  }
4694
4867
  case "d20Roll": {
4695
4868
  const childDie = findDie(node.child);
4696
- const rerollOne = !!childDie && (childDie.reroll || 0) >= 1;
4697
- return d20RollPMF(node.rollType, rerollOne);
4869
+ if (!childDie) return d20RollPMF(node.rollType, false);
4870
+ return resolveD20Roll(childDie, node.rollType);
4698
4871
  }
4699
4872
  case "half": {
4700
4873
  const childPMF = resolve(node.child, eps);
@@ -4720,6 +4893,37 @@ function pmfFromRollBuilder(rb, eps = defaultEps) {
4720
4893
  const ast = rb.toAST();
4721
4894
  return resolve(ast, eps);
4722
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
+ }
4723
4927
  function resolveSingleDie(die, eps = defaultEps) {
4724
4928
  const signature = getASTSignature(die);
4725
4929
  const cacheKey = `${signature}_${eps}`;
@@ -4753,20 +4957,12 @@ function resolveSingleDie(die, eps = defaultEps) {
4753
4957
  for (const v of pmf.support()) {
4754
4958
  if (v !== maxFace) nonMax.set(v, pmf.pAt(v));
4755
4959
  }
4756
- let nonMaxPMF = PMF.fromMap(nonMax, eps);
4757
- if (Math.abs(nonMaxPMF.mass() - (1 - pMax)) > eps) {
4758
- nonMaxPMF = nonMaxPMF.scaleMass(1 - pMax);
4759
- }
4760
- let tail = PMF.delta(0, eps);
4761
- const addOnce = pmf;
4762
- for (let t = 1; t <= times; t++) {
4763
- 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);
4764
4964
  }
4765
- const exploded = PMF.branch(
4766
- tail.mapDamage((v) => v + maxFace),
4767
- nonMaxPMF,
4768
- pMax
4769
- );
4965
+ const exploded = PMF.branch(chain.mapDamage((v) => v + maxFace), nonMaxPMF, pMax);
4770
4966
  pmf = exploded;
4771
4967
  }
4772
4968
  singleDiePMFCache.set(cacheKey, pmf);
@@ -5065,10 +5261,8 @@ var AttackBuilder = class _AttackBuilder {
5065
5261
  return `${checkPart} * ${effectPart}`;
5066
5262
  }
5067
5263
  resolveProbabilities(check, eps = 0) {
5068
- const rollType = check.rollType;
5069
- const rerollOne = check.baseReroll > 0;
5070
5264
  const critThreshold = check.critThreshold;
5071
- const d202 = d20RollPMF(rollType, rerollOne);
5265
+ const d202 = resolveRootD20(check);
5072
5266
  if (check instanceof AlwaysCritBuilder) {
5073
5267
  if (check.fromAlwaysHit) {
5074
5268
  return { pSuccess: 1, pHit: 0, pCrit: 1, pMiss: 0 };
@@ -5118,13 +5312,17 @@ var AttackBuilder = class _AttackBuilder {
5118
5312
  pmiss += pr;
5119
5313
  continue;
5120
5314
  }
5121
- if (r >= critThreshold) {
5315
+ if (r === 20) {
5122
5316
  pcrit += pr;
5123
5317
  continue;
5124
5318
  }
5125
5319
  const need = ac - staticMod - r;
5126
5320
  const pBonusHit = bonusPMF.tailProbGE(need);
5127
- phit += pr * pBonusHit;
5321
+ if (r >= critThreshold) {
5322
+ pcrit += pr * pBonusHit;
5323
+ } else {
5324
+ phit += pr * pBonusHit;
5325
+ }
5128
5326
  pmiss += pr * (1 - pBonusHit);
5129
5327
  }
5130
5328
  const psuccess = phit + pcrit;
@@ -5137,11 +5335,11 @@ var AttackBuilder = class _AttackBuilder {
5137
5335
  pMiss: pmiss
5138
5336
  } = this.resolveProbabilities(this.check, eps);
5139
5337
  const hitPMF = this.hitEffect ? this.hitEffect instanceof ParsedRollBuilder ? this.hitEffect.toPMF(eps) : pmfFromRollBuilder(this.hitEffect, eps) : PMF.delta(0, eps);
5140
- let critPMF = null;
5338
+ let critPMF2 = null;
5141
5339
  let phit = pHit;
5142
5340
  let pcrit = pCrit;
5143
5341
  if (this.critEffect === null) {
5144
- critPMF = null;
5342
+ critPMF2 = null;
5145
5343
  phit += pcrit;
5146
5344
  pcrit = 0;
5147
5345
  } else {
@@ -5149,7 +5347,7 @@ var AttackBuilder = class _AttackBuilder {
5149
5347
  if (this.critEffect) {
5150
5348
  critBuilder = this.critEffect;
5151
5349
  } else if (this.hitEffect instanceof ParsedRollBuilder) {
5152
- critPMF = null;
5350
+ critPMF2 = null;
5153
5351
  phit += pcrit;
5154
5352
  pcrit = 0;
5155
5353
  critBuilder = void 0;
@@ -5157,20 +5355,20 @@ var AttackBuilder = class _AttackBuilder {
5157
5355
  critBuilder = this.hitEffect?.copy().doubleDice();
5158
5356
  }
5159
5357
  if (critBuilder) {
5160
- critPMF = critBuilder instanceof ParsedRollBuilder ? critBuilder.toPMF(eps) : pmfFromRollBuilder(critBuilder, eps);
5358
+ critPMF2 = critBuilder instanceof ParsedRollBuilder ? critBuilder.toPMF(eps) : pmfFromRollBuilder(critBuilder, eps);
5161
5359
  }
5162
5360
  }
5163
5361
  const missPMF = this.missEffect ? this.missEffect instanceof ParsedRollBuilder ? this.missEffect.toPMF(eps) : pmfFromRollBuilder(this.missEffect, eps) : PMF.delta(0, eps);
5164
5362
  const mix = new Mixture(eps);
5165
5363
  if (phit > 0) mix.add("hit", hitPMF, phit);
5166
- if (critPMF && pcrit > 0) mix.add("crit", critPMF, pcrit);
5364
+ if (critPMF2 && pcrit > 0) mix.add("crit", critPMF2, pcrit);
5167
5365
  if (pmiss > 0)
5168
5366
  mix.add(this.missEffect ? "missDamage" : "missNone", missPMF, pmiss);
5169
5367
  return {
5170
5368
  pmf: mix.buildPMF(eps) ?? PMF.delta(0, eps),
5171
5369
  check: this.check.toPMF(eps) ?? PMF.delta(0, eps),
5172
5370
  hit: hitPMF ?? PMF.delta(0, eps),
5173
- crit: critPMF ?? PMF.delta(0, eps),
5371
+ crit: critPMF2 ?? PMF.delta(0, eps),
5174
5372
  miss: missPMF ?? PMF.delta(0, eps),
5175
5373
  weights: { hit: phit, crit: pcrit, miss: pmiss }
5176
5374
  };
@@ -5272,9 +5470,7 @@ var ACBuilder = class _ACBuilder extends RollBuilder {
5272
5470
  }
5273
5471
  toPMF(eps = 0) {
5274
5472
  const ac = this.attackConfig.ac;
5275
- const rollType = this.rollType;
5276
- const rerollOne = this.baseReroll > 0;
5277
- const d202 = d20RollPMF(rollType, rerollOne);
5473
+ const d202 = resolveRootD20(this);
5278
5474
  const staticMod = this.modifier;
5279
5475
  const bonusPMFs = this.getBonusDicePMFs(this, eps);
5280
5476
  const parts = [d202, ...bonusPMFs];
@@ -5384,21 +5580,17 @@ var SaveBuilder = class _SaveBuilder {
5384
5580
  function resolveProbabilities(check) {
5385
5581
  const saveBonus = check.modifier;
5386
5582
  const dc = check.saveDC;
5387
- const d20Type = check.rollType;
5388
- const baseReroll = check.baseReroll;
5389
- const die = d20RollPMF(d20Type, baseReroll > 0);
5583
+ const eps = 0;
5584
+ const die = resolveRootD20(check);
5390
5585
  const faceP = /* @__PURE__ */ new Map();
5391
5586
  for (const [r, bin] of die) {
5392
5587
  const pr = bin.p;
5393
5588
  if (pr > 0) faceP.set(r, pr);
5394
5589
  }
5395
- const eps = 0;
5396
5590
  const bonusDicePMFs = check.getBonusDicePMFs(check, eps);
5397
5591
  const bonusPMF = bonusDicePMFs.length > 0 ? PMF.convolveMany(bonusDicePMFs, eps) : PMF.zero(eps);
5398
5592
  let pSuccess = 0;
5399
- for (let r = 1; r <= 20; r++) {
5400
- const pr = faceP.get(r);
5401
- if (!pr) continue;
5593
+ for (const [r, pr] of faceP) {
5402
5594
  const need = dc - saveBonus - r;
5403
5595
  pSuccess += pr * bonusPMF.tailProbGE(need);
5404
5596
  }
@@ -5468,9 +5660,7 @@ var DCBuilder = class _DCBuilder extends RollBuilder {
5468
5660
  if (cached) return cached;
5469
5661
  }
5470
5662
  const saveDC = this.saveDC;
5471
- const rollType = this.rollType;
5472
- const rerollOne = this.baseReroll > 0;
5473
- const d202 = d20RollPMF(rollType, rerollOne);
5663
+ const d202 = resolveRootD20(this);
5474
5664
  const staticMod = this.modifier;
5475
5665
  const bonusDicePMFs = this.getBonusDiceConfigs().map(
5476
5666
  (cfg) => pmfFromRollBuilder(RollBuilder.fromConfigs([cfg]), eps)
@@ -5498,6 +5688,611 @@ RollBuilder.prototype.dc = function(saveDC) {
5498
5688
  return new DCBuilder(this).dc(saveDC);
5499
5689
  };
5500
5690
 
5501
- export { ACBuilder, AlwaysCritBuilder, AlwaysHitBuilder, AttackBuilder, DCBuilder, HalfRollBuilder, MaxOfRollBuilder, ParsedRollBuilder, PooledRollBuilder, RollBuilder, SaveBuilder, ScaleRollBuilder, builderPMFCache, clearAttackCache, clearDCCache, clearRollCache, clearSaveCache, d, d10, d100, d12, d20, d4, d6, d8, defaultConfig, flat, hd20, roll, sumRolls };
5691
+ // src/turn/types.ts
5692
+ var TurnSpecError = class extends Error {
5693
+ constructor(code, id, message) {
5694
+ super(message);
5695
+ this.code = code;
5696
+ this.id = id;
5697
+ this.name = "TurnSpecError";
5698
+ }
5699
+ };
5700
+ var MAX_TRIGGER_GROUPS = 4;
5701
+
5702
+ // src/turn/plan.ts
5703
+ var IS_HIT_TRIGGER = {
5704
+ "first-hit": true,
5705
+ "any-crit": true,
5706
+ "any-miss": true,
5707
+ "every-hit": true
5708
+ };
5709
+ function toPMF(damage, eps, id = "") {
5710
+ const parts = Array.isArray(damage) ? damage : [damage];
5711
+ if (parts.length === 0) return PMF.delta(0, eps);
5712
+ const pmfs = parts.map((part) => {
5713
+ if (part instanceof PMF) return part;
5714
+ if (typeof part.toPMF !== "function") {
5715
+ throw new TurnSpecError(
5716
+ "not-an-attack",
5717
+ id,
5718
+ `"${id}" is neither a PMF nor a builder with toPMF().`
5719
+ );
5720
+ }
5721
+ const resolved = part.toPMF(eps);
5722
+ if (!(resolved instanceof PMF)) {
5723
+ throw new TurnSpecError(
5724
+ "not-an-attack",
5725
+ id,
5726
+ `"${id}" has a toPMF() that did not return a PMF.`
5727
+ );
5728
+ }
5729
+ return resolved;
5730
+ });
5731
+ return PMF.convolveMany(pmfs, eps);
5732
+ }
5733
+ function critPMF(rider, base, eps) {
5734
+ if (rider.critDamage !== void 0) return toPMF(rider.critDamage, eps);
5735
+ const parts = Array.isArray(rider.damage) ? rider.damage : [rider.damage];
5736
+ const doubled = [];
5737
+ for (const part of parts) {
5738
+ const doublable = part;
5739
+ if (part instanceof PMF || typeof doublable.doubleDice !== "function") {
5740
+ return base;
5741
+ }
5742
+ try {
5743
+ doubled.push(toPMF(doublable.doubleDice(), eps));
5744
+ } catch {
5745
+ return base;
5746
+ }
5747
+ }
5748
+ if (doubled.length === 0) return base;
5749
+ return PMF.convolveMany(doubled, eps);
5750
+ }
5751
+ function sliceSource(pmf) {
5752
+ const labels = pmf.outcomes();
5753
+ if (!labels.includes("hit") && !labels.includes("crit")) return null;
5754
+ const missParts = ["missNone", "missDamage"].filter((label) => labels.includes(label)).map((label) => pmf.filterOutcome(label));
5755
+ return {
5756
+ hit: labels.includes("hit") ? pmf.filterOutcome("hit") : PMF.emptyMass(),
5757
+ crit: labels.includes("crit") ? pmf.filterOutcome("crit") : PMF.emptyMass(),
5758
+ miss: missParts.length ? missParts.reduce((all, part) => all.add(part)) : PMF.emptyMass()
5759
+ };
5760
+ }
5761
+ function buildPlan(spec, eps = EPS) {
5762
+ const fail = (code, id, message) => {
5763
+ throw new TurnSpecError(code, id, message);
5764
+ };
5765
+ const attackIds = [];
5766
+ const attackPMFs = [];
5767
+ const attackSlices = [];
5768
+ spec.attacks.forEach((entry, index) => {
5769
+ const named = entry;
5770
+ const hasWrapper = typeof named.id === "string" && named.source !== void 0;
5771
+ const id = hasWrapper ? named.id : `attack ${index + 1}`;
5772
+ const source = hasWrapper ? named.source : entry;
5773
+ const pmf = toPMF(source, eps, id);
5774
+ attackIds.push(id);
5775
+ attackPMFs.push(pmf);
5776
+ attackSlices.push(sliceSource(pmf));
5777
+ });
5778
+ const riders = spec.riders ?? [];
5779
+ const riderIds = riders.map((rider, index) => rider.id ?? `rider ${index + 1}`);
5780
+ const seen = /* @__PURE__ */ new Set();
5781
+ for (const id of [...attackIds, ...riderIds]) {
5782
+ if (seen.has(id)) fail("duplicate-id", id, `Duplicate id "${id}".`);
5783
+ seen.add(id);
5784
+ }
5785
+ const attackIndexById = new Map(attackIds.map((id, index) => [id, index]));
5786
+ const riderIndexById = new Map(riderIds.map((id, index) => [id, index]));
5787
+ const sourceIdsByRider = riders.map((rider, index) => {
5788
+ const id = riderIds[index];
5789
+ if (rider.on === "not-fired") {
5790
+ const target = rider.of;
5791
+ if (target === id) {
5792
+ fail("self-reference", id, `Rider "${id}" cannot depend on itself.`);
5793
+ }
5794
+ const targetIndex = riderIndexById.get(target);
5795
+ if (targetIndex === void 0) {
5796
+ fail(
5797
+ "unknown-id",
5798
+ target,
5799
+ `Rider "${id}" negates "${target}", which is not a rider in this turn.`
5800
+ );
5801
+ }
5802
+ if (riders[targetIndex].on === "every-hit") {
5803
+ fail(
5804
+ "not-an-attack",
5805
+ target,
5806
+ `Rider "${id}" negates "${target}", an every-hit rider, which can fire more than once and so has no single "did not fire" branch.`
5807
+ );
5808
+ }
5809
+ return [target];
5810
+ }
5811
+ const of = [...new Set(rider.of ?? attackIds)];
5812
+ if (of.length === 0) {
5813
+ fail("unknown-id", id, `Rider "${id}" has no sources.`);
5814
+ }
5815
+ for (const sourceId of of) {
5816
+ if (sourceId === id) {
5817
+ fail("self-reference", id, `Rider "${id}" cannot depend on itself.`);
5818
+ }
5819
+ const riderIndex = riderIndexById.get(sourceId);
5820
+ const isAttack = attackIndexById.has(sourceId);
5821
+ if (!isAttack && riderIndex === void 0) {
5822
+ fail(
5823
+ "unknown-id",
5824
+ sourceId,
5825
+ `Rider "${id}" depends on "${sourceId}", which is not in this turn.`
5826
+ );
5827
+ }
5828
+ if (riderIndex !== void 0 && riders[riderIndex].on === "every-hit") {
5829
+ fail(
5830
+ "not-an-attack",
5831
+ sourceId,
5832
+ `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.`
5833
+ );
5834
+ }
5835
+ const slices = isAttack ? attackSlices[attackIndexById.get(sourceId)] : sliceSource(
5836
+ toPMF(riders[riderIndex].damage, eps, sourceId)
5837
+ );
5838
+ if (!slices) {
5839
+ fail(
5840
+ "not-an-attack",
5841
+ sourceId,
5842
+ `Rider "${id}" triggers on "${sourceId}", which has no hit/crit outcomes.`
5843
+ );
5844
+ }
5845
+ }
5846
+ return [...of];
5847
+ });
5848
+ const order = [];
5849
+ const visiting = /* @__PURE__ */ new Set();
5850
+ const done = /* @__PURE__ */ new Set();
5851
+ const visit = (index) => {
5852
+ if (done.has(index)) return;
5853
+ const id = riderIds[index];
5854
+ if (visiting.has(index)) {
5855
+ fail("cycle", id, `Rider "${id}" is part of a dependency cycle.`);
5856
+ }
5857
+ visiting.add(index);
5858
+ for (const sourceId of sourceIdsByRider[index]) {
5859
+ const dependency = riderIndexById.get(sourceId);
5860
+ if (dependency !== void 0) visit(dependency);
5861
+ }
5862
+ visiting.delete(index);
5863
+ done.add(index);
5864
+ order.push(index);
5865
+ };
5866
+ riders.forEach((_, index) => visit(index));
5867
+ const groupIndexByKey = /* @__PURE__ */ new Map();
5868
+ const groupSources = [];
5869
+ const groupOf = (sourceIds) => {
5870
+ const key = JSON.stringify([...sourceIds].sort());
5871
+ const existing = groupIndexByKey.get(key);
5872
+ if (existing !== void 0) return existing;
5873
+ if (groupSources.length >= MAX_TRIGGER_GROUPS) {
5874
+ fail(
5875
+ "too-many-groups",
5876
+ key,
5877
+ `A turn may track at most ${MAX_TRIGGER_GROUPS} distinct trigger source sets.`
5878
+ );
5879
+ }
5880
+ const index = groupSources.length;
5881
+ groupIndexByKey.set(key, index);
5882
+ groupSources.push([...sourceIds]);
5883
+ return index;
5884
+ };
5885
+ const readsByRider = /* @__PURE__ */ new Map();
5886
+ const perHitGroups = /* @__PURE__ */ new Map();
5887
+ for (const index of order) {
5888
+ const rider = riders[index];
5889
+ if (!IS_HIT_TRIGGER[rider.on]) continue;
5890
+ const group = groupOf(sourceIdsByRider[index]);
5891
+ readsByRider.set(index, group);
5892
+ if (rider.on === "every-hit") perHitGroups.set(riderIds[index], group);
5893
+ }
5894
+ const perHitBySource = /* @__PURE__ */ new Map();
5895
+ for (const index of order) {
5896
+ const rider = riders[index];
5897
+ if (rider.on !== "every-hit") continue;
5898
+ const hit = toPMF(rider.damage, eps, riderIds[index]);
5899
+ const payload = { hit, crit: critPMF(rider, hit, eps) };
5900
+ for (const sourceId of sourceIdsByRider[index]) {
5901
+ const existing = perHitBySource.get(sourceId);
5902
+ if (existing) existing.push(payload);
5903
+ else perHitBySource.set(sourceId, [payload]);
5904
+ }
5905
+ }
5906
+ const updatesById = /* @__PURE__ */ new Map();
5907
+ groupSources.forEach((sourceIds, groupIndex) => {
5908
+ for (const sourceId of sourceIds) {
5909
+ const existing = updatesById.get(sourceId);
5910
+ if (existing) existing.push(groupIndex);
5911
+ else updatesById.set(sourceId, [groupIndex]);
5912
+ }
5913
+ });
5914
+ const withPerHit = (slices, id) => {
5915
+ const payloads = perHitBySource.get(id);
5916
+ if (!payloads) return slices;
5917
+ let hit = slices.hit;
5918
+ let crit = slices.crit;
5919
+ for (const payload of payloads) {
5920
+ hit = hit.convolve(payload.hit, eps, true);
5921
+ crit = crit.convolve(payload.crit, eps, true);
5922
+ }
5923
+ return { hit, crit, miss: slices.miss };
5924
+ };
5925
+ const steps = attackIds.map((id, index) => ({
5926
+ id,
5927
+ trigger: null,
5928
+ slices: withPerHit(
5929
+ attackSlices[index] ?? {
5930
+ hit: attackPMFs[index],
5931
+ crit: PMF.emptyMass(),
5932
+ miss: PMF.emptyMass()
5933
+ },
5934
+ id
5935
+ ),
5936
+ damage: null,
5937
+ updates: updatesById.get(id) ?? [],
5938
+ reads: -1,
5939
+ negates: -1
5940
+ }));
5941
+ const stepIndexByRider = /* @__PURE__ */ new Map();
5942
+ const riderSteps = /* @__PURE__ */ new Map();
5943
+ for (const index of order) {
5944
+ const rider = riders[index];
5945
+ if (rider.on === "every-hit") continue;
5946
+ const id = riderIds[index];
5947
+ const hit = toPMF(rider.damage, eps, id);
5948
+ const slices = sliceSource(hit);
5949
+ if (slices && rider.critDamage !== void 0) {
5950
+ fail(
5951
+ "unused-crit-damage",
5952
+ id,
5953
+ `Rider "${id}" rolls its own attack, so its critDamage would never be used. Remove it, or pass plain damage dice instead.`
5954
+ );
5955
+ }
5956
+ const negatedRider = rider.on === "not-fired" ? riderIndexById.get(rider.of) : void 0;
5957
+ const step = {
5958
+ id,
5959
+ trigger: rider,
5960
+ slices: slices ? withPerHit(slices, id) : null,
5961
+ damage: slices ? null : { hit, crit: critPMF(rider, hit, eps) },
5962
+ updates: updatesById.get(id) ?? [],
5963
+ reads: readsByRider.get(index) ?? -1,
5964
+ negates: negatedRider === void 0 ? -1 : stepIndexByRider.get(negatedRider)
5965
+ };
5966
+ steps.push(step);
5967
+ stepIndexByRider.set(index, steps.length - 1);
5968
+ riderSteps.set(id, steps.length - 1);
5969
+ }
5970
+ return {
5971
+ steps,
5972
+ groupCount: groupSources.length,
5973
+ attackPMFs,
5974
+ attackIds,
5975
+ riderIds,
5976
+ riderSteps,
5977
+ perHitGroups
5978
+ };
5979
+ }
5980
+
5981
+ // src/turn/state.ts
5982
+ var FIRST_NONE = 0;
5983
+ var FIRST_HIT = 1;
5984
+ var FIRST_CRIT = 2;
5985
+ var CRIT_BIT = 2;
5986
+ var MISS_BIT = 1;
5987
+ var START_CODE = FIRST_NONE << 2;
5988
+ function advance(code, outcome) {
5989
+ if (outcome === "miss") return code | MISS_BIT;
5990
+ const first = code >> 2;
5991
+ const withCrit = outcome === "crit" ? code | CRIT_BIT : code;
5992
+ if (first !== FIRST_NONE) return withCrit;
5993
+ const nextFirst = outcome === "crit" ? FIRST_CRIT : FIRST_HIT;
5994
+ return nextFirst << 2 | withCrit & (CRIT_BIT | MISS_BIT);
5995
+ }
5996
+
5997
+ // src/turn/turn.ts
5998
+ var OUTCOMES = ["hit", "crit", "miss"];
5999
+ function fireMode(step, codes, firedByStep) {
6000
+ const trigger = step.trigger;
6001
+ if (!trigger) return "hit";
6002
+ if (trigger.on === "not-fired") {
6003
+ return firedByStep[step.negates] === null ? "hit" : null;
6004
+ }
6005
+ const code = codes[step.reads];
6006
+ const first = code >> 2;
6007
+ switch (trigger.on) {
6008
+ case "first-hit":
6009
+ if (first === FIRST_NONE) return null;
6010
+ return first === FIRST_CRIT ? "crit" : "hit";
6011
+ case "any-crit":
6012
+ return (code & CRIT_BIT) !== 0 ? "crit" : null;
6013
+ case "any-miss":
6014
+ return (code & MISS_BIT) !== 0 ? "hit" : null;
6015
+ default:
6016
+ return null;
6017
+ }
6018
+ }
6019
+ var Turn = class _Turn {
6020
+ constructor(attacks, riders, eps) {
6021
+ this.declaredAttacks = [...attacks];
6022
+ this.riders = [...riders];
6023
+ this.eps = eps;
6024
+ this.plan = buildPlan({ attacks: this.declaredAttacks, riders: this.riders }, eps);
6025
+ }
6026
+ /**
6027
+ * Builds a turn from plain data, throwing {@link TurnSpecError} if it is
6028
+ * malformed. Use this from a UI, where `error.code` maps to the field state to
6029
+ * show.
6030
+ */
6031
+ static from(spec, eps = EPS) {
6032
+ return new _Turn(spec.attacks, spec.riders ?? [], eps);
6033
+ }
6034
+ /**
6035
+ * Appends an attack, throwing {@link TurnSpecError} if that makes the turn
6036
+ * invalid.
6037
+ *
6038
+ * A rider with no explicit `of` watches every declared attack *including ones
6039
+ * appended after it*, because `of` is resolved when the plan is built rather
6040
+ * than when the rider is added. Pass an explicit `of` to pin a rider to the
6041
+ * attacks it already saw. One attack must exist before a rider with a default
6042
+ * `of` is added, or the build fails `unknown-id`.
6043
+ */
6044
+ attack(source, id) {
6045
+ const entry = id === void 0 ? source : { id, source };
6046
+ return new _Turn([...this.declaredAttacks, entry], this.riders, this.eps);
6047
+ }
6048
+ /**
6049
+ * Appends `count` copies of the same attack — the Extra Attack case, which is
6050
+ * most of 5e. Argument order mirrors `roll(count, die)`.
6051
+ *
6052
+ * ```ts
6053
+ * turn().attacks(4, greatsword).onEveryHit(d6); // fighter 20 + hunter's mark
6054
+ * ```
6055
+ *
6056
+ * @throws {RangeError} if `count` is not a positive integer.
6057
+ */
6058
+ attacks(count, source) {
6059
+ if (!Number.isInteger(count) || count < 1) {
6060
+ throw new RangeError(
6061
+ `attacks(count) needs a positive integer, got ${count}.`
6062
+ );
6063
+ }
6064
+ const added = new Array(count).fill(source);
6065
+ return new _Turn([...this.declaredAttacks, ...added], this.riders, this.eps);
6066
+ }
6067
+ /**
6068
+ * Appends a rider, throwing {@link TurnSpecError} if that makes the turn
6069
+ * invalid. The `onX` methods below are the readable way to call this.
6070
+ */
6071
+ rider(rider) {
6072
+ return new _Turn(this.declaredAttacks, [...this.riders, rider], this.eps);
6073
+ }
6074
+ /**
6075
+ * Fires once, on the first source that lands, in that source's mode — so a
6076
+ * crit on the first landing attack doubles the rider's dice. Sneak Attack.
6077
+ */
6078
+ onFirstHit(damage, options = {}) {
6079
+ return this.rider({ ...options, damage, on: "first-hit" });
6080
+ }
6081
+ /**
6082
+ * Fires once if any source crit, always in crit mode. Divine Smite: nothing is
6083
+ * lost by holding it for a crit, so this is "any", not "first".
6084
+ */
6085
+ onAnyCrit(damage, options = {}) {
6086
+ return this.rider({ ...options, damage, on: "any-crit" });
6087
+ }
6088
+ /**
6089
+ * Fires once if any source missed. The reroll gate: a reroll is a fresh attack,
6090
+ * so pass one as the damage. Kensei's Unerring Accuracy, Lucky.
6091
+ */
6092
+ onAnyMiss(damage, options = {}) {
6093
+ return this.rider({ ...options, damage, on: "any-miss" });
6094
+ }
6095
+ /**
6096
+ * Fires once per source that lands, in that hit's mode — so it can fire several
6097
+ * times in a turn. Hunter's Mark, Hex, Rage.
6098
+ */
6099
+ onEveryHit(damage, options = {}) {
6100
+ return this.rider({ ...options, damage, on: "every-hit" });
6101
+ }
6102
+ /**
6103
+ * Damage for the turns where the rider added just before this one did *not*
6104
+ * fire: "flurry of blows if I didn't smite".
6105
+ *
6106
+ * ```ts
6107
+ * turn([dagger, dagger])
6108
+ * .onAnyCrit(roll(2, d8)) // smite
6109
+ * .otherwise([flurry, flurry]) // ... or two more attacks
6110
+ * ```
6111
+ *
6112
+ * Always binds to the *immediately* preceding rider, so the two are branches of
6113
+ * one decision and can never both land. Note that chaining it therefore
6114
+ * alternates rather than laddering: `a.otherwise(b).otherwise(c)` makes `c`
6115
+ * fire whenever `b` did not, which is exactly when `a` did. For a genuine
6116
+ * three-way priority chain, name the riders and use explicit `not-fired`
6117
+ * triggers against the right one.
6118
+ */
6119
+ otherwise(damage, options = {}) {
6120
+ const index = this.riders.length - 1;
6121
+ if (index < 0) {
6122
+ throw new TurnSpecError(
6123
+ "unknown-id",
6124
+ "",
6125
+ "otherwise() needs a preceding rider to negate."
6126
+ );
6127
+ }
6128
+ const previous = this.riders[index];
6129
+ if (previous.on === "every-hit") {
6130
+ throw new TurnSpecError(
6131
+ "not-an-attack",
6132
+ previous.id ?? `rider ${index + 1}`,
6133
+ "otherwise() cannot negate an every-hit rider: it can fire more than once."
6134
+ );
6135
+ }
6136
+ const target = previous.id ?? `rider ${index + 1}`;
6137
+ const riders = [...this.riders];
6138
+ riders[index] = { ...previous, id: target };
6139
+ riders.push({ ...options, damage, on: "not-fired", of: target });
6140
+ return new _Turn(this.declaredAttacks, riders, this.eps);
6141
+ }
6142
+ /**
6143
+ * The exact joint distribution: mass 1, outcome-labelled. Resolved once and
6144
+ * cached.
6145
+ *
6146
+ * There is no `toPMF(eps)` to match the builders: a turn's epsilon is fixed
6147
+ * when it is constructed, because the plan is validated and its sources are
6148
+ * resolved at that point.
6149
+ */
6150
+ get pmf() {
6151
+ return this.resolve().pmf;
6152
+ }
6153
+ /** Mean damage for the turn. */
6154
+ mean() {
6155
+ return this.pmf.mean();
6156
+ }
6157
+ /**
6158
+ * A query whose `singles` are the **declared attacks** and whose combined
6159
+ * distribution is the exact turn PMF.
6160
+ *
6161
+ * Riders are inside the combined PMF, not in `singles`, so singles-based
6162
+ * helpers (`probAtLeastOne`, `countSinglesWith`, `outcomeStats`) describe the
6163
+ * attacks only. Read rider-inclusive statistics off the combined PMF —
6164
+ * `outcomeTotals`, `outcomeDamageRanges`, `damageAttributionChartModel`.
6165
+ */
6166
+ toQuery() {
6167
+ return new DiceQuery([...this.plan.attackPMFs], this.pmf, this.eps);
6168
+ }
6169
+ /**
6170
+ * Attack ids in declaration order, including the `attack 1`, `attack 2`, …
6171
+ * defaults given to bare sources. These are the names `of` accepts.
6172
+ */
6173
+ get attackIds() {
6174
+ return this.plan.attackIds;
6175
+ }
6176
+ /**
6177
+ * Rider ids in declaration order, including the `rider 1`, `rider 2`, …
6178
+ * defaults. These are the names {@link Turn.fireProbability} accepts.
6179
+ */
6180
+ get riderIds() {
6181
+ return this.plan.riderIds;
6182
+ }
6183
+ /**
6184
+ * P(this rider fired). For an `every-hit` rider it is P(at least one source
6185
+ * hit), since that rider can fire more than once in a turn.
6186
+ *
6187
+ * @throws {TurnSpecError} `unknown-id` if `id` is not a rider — attack ids
6188
+ * included, since attacks always happen and have no firing probability.
6189
+ */
6190
+ fireProbability(id) {
6191
+ const mass = this.resolve().fireMass.get(id);
6192
+ if (mass === void 0) {
6193
+ throw new TurnSpecError(
6194
+ "unknown-id",
6195
+ id,
6196
+ `"${id}" is not a rider in this turn. Riders: ${this.plan.riderIds.map((each) => `"${each}"`).join(", ")}.`
6197
+ );
6198
+ }
6199
+ return mass;
6200
+ }
6201
+ resolve() {
6202
+ if (this.resolved) return this.resolved;
6203
+ const plan = this.plan;
6204
+ const eps = this.eps;
6205
+ const width = plan.groupCount;
6206
+ const start = {
6207
+ codes: new Array(width).fill(START_CODE),
6208
+ pmf: PMF.delta(0, eps),
6209
+ fired: new Array(plan.steps.length).fill(null)
6210
+ };
6211
+ let states = /* @__PURE__ */ new Map([[String.fromCharCode(), start]]);
6212
+ plan.steps.forEach((step, stepIndex) => {
6213
+ const next = /* @__PURE__ */ new Map();
6214
+ const merge = (state) => {
6215
+ const key = String.fromCharCode(...state.codes) + "" + state.fired.map((mode) => mode === null ? "-" : "+").join("");
6216
+ const existing = next.get(key);
6217
+ if (existing) existing.pmf = existing.pmf.add(state.pmf);
6218
+ else next.set(key, state);
6219
+ };
6220
+ for (const state of states.values()) {
6221
+ const mode = fireMode(step, state.codes, state.fired);
6222
+ const fired = [...state.fired];
6223
+ fired[stepIndex] = mode;
6224
+ if (mode === null) {
6225
+ merge({ codes: state.codes, pmf: state.pmf, fired });
6226
+ continue;
6227
+ }
6228
+ if (!step.slices) {
6229
+ const payload = step.damage;
6230
+ merge({
6231
+ codes: state.codes,
6232
+ pmf: state.pmf.convolve(
6233
+ mode === "crit" ? payload.crit : payload.hit,
6234
+ eps,
6235
+ true
6236
+ ),
6237
+ fired
6238
+ });
6239
+ continue;
6240
+ }
6241
+ for (const outcome of OUTCOMES) {
6242
+ const slice = step.slices[outcome];
6243
+ const sliceMass = slice.mass();
6244
+ if (sliceMass <= eps) continue;
6245
+ const codes = [...state.codes];
6246
+ for (const group of step.updates) {
6247
+ codes[group] = advance(codes[group], outcome);
6248
+ }
6249
+ merge({
6250
+ codes,
6251
+ pmf: state.pmf.convolve(slice, eps, true),
6252
+ fired
6253
+ });
6254
+ }
6255
+ }
6256
+ states = next;
6257
+ });
6258
+ const fireMass = /* @__PURE__ */ new Map();
6259
+ for (const id of plan.riderSteps.keys()) fireMass.set(id, 0);
6260
+ for (const id of plan.perHitGroups.keys()) fireMass.set(id, 0);
6261
+ let total;
6262
+ for (const state of states.values()) {
6263
+ total = total ? total.add(state.pmf) : state.pmf;
6264
+ const mass = state.pmf.mass();
6265
+ for (const [id, stepIndex] of plan.riderSteps) {
6266
+ if (state.fired[stepIndex] !== null) {
6267
+ fireMass.set(id, fireMass.get(id) + mass);
6268
+ }
6269
+ }
6270
+ for (const [id, group] of plan.perHitGroups) {
6271
+ if (state.codes[group] >> 2 !== FIRST_NONE) {
6272
+ fireMass.set(id, fireMass.get(id) + mass);
6273
+ }
6274
+ }
6275
+ }
6276
+ const pmf = total ?? PMF.delta(0, eps);
6277
+ const totalMass = pmf.mass();
6278
+ const needsNormalizing = Math.abs(totalMass - 1) > eps && totalMass > 0;
6279
+ if (needsNormalizing) {
6280
+ for (const [id, mass] of fireMass) fireMass.set(id, mass / totalMass);
6281
+ }
6282
+ this.resolved = {
6283
+ pmf: needsNormalizing ? pmf.normalize() : pmf,
6284
+ fireMass
6285
+ };
6286
+ return this.resolved;
6287
+ }
6288
+ };
6289
+ function turn(attacks = [], eps = EPS) {
6290
+ return Turn.from(
6291
+ { attacks: Array.isArray(attacks) ? attacks : [attacks] },
6292
+ eps
6293
+ );
6294
+ }
6295
+
6296
+ export { ACBuilder, AlwaysCritBuilder, AlwaysHitBuilder, AttackBuilder, DCBuilder, HalfRollBuilder, MAX_TRIGGER_GROUPS, MaxOfRollBuilder, ParsedRollBuilder, PooledRollBuilder, RollBuilder, SaveBuilder, ScaleRollBuilder, Turn, TurnSpecError, builderPMFCache, clearAttackCache, clearDCCache, clearRollCache, clearSaveCache, d, d10, d100, d12, d20, d4, d6, d8, defaultConfig, flat, hd20, roll, sumRolls, turn };
5502
6297
  //# sourceMappingURL=index.js.map
5503
6298
  //# sourceMappingURL=index.js.map