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