@yipe/dice 0.6.0 → 0.8.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.
@@ -45,6 +45,26 @@ var LRUCache = class {
45
45
  // src/common/types.ts
46
46
  var EPS = 1e-12;
47
47
  var MISS_NONE_OUTCOME = "missNone";
48
+ var ALL_OUTCOME_TYPES = [
49
+ "missNone",
50
+ "missDamage",
51
+ "saveFail",
52
+ "saveHalf",
53
+ "pc",
54
+ "hit",
55
+ "crit"
56
+ ];
57
+ function sortOutcomes(outcomes, order = ALL_OUTCOME_TYPES) {
58
+ const rank = new Map(order.map((o, i) => [o, i]));
59
+ return [...outcomes].sort((a, b) => {
60
+ const ra = rank.get(a);
61
+ const rb = rank.get(b);
62
+ if (ra !== void 0 && rb !== void 0) return ra - rb;
63
+ if (ra !== void 0) return -1;
64
+ if (rb !== void 0) return 1;
65
+ return a.localeCompare(b);
66
+ });
67
+ }
48
68
 
49
69
  // src/pmf/query.ts
50
70
  var _DiceQuery = class _DiceQuery {
@@ -91,7 +111,7 @@ var _DiceQuery = class _DiceQuery {
91
111
  * const attack = d20.plus(5).ac(15).onHit(d(2,6).plus(3)).onCrit(d(2,6))
92
112
  * const query = attack.toQuery()
93
113
  * const pmf = query.combinedWithAttribution()
94
- * // Now pmf can be used with toDamageAttributionChartSeries()
114
+ * // Now pmf can be used with attributionByValue() / damageAttributionChartModel()
95
115
  */
96
116
  combinedWithAttribution() {
97
117
  if (this._combinedWithAttr) {
@@ -117,6 +137,14 @@ var _DiceQuery = class _DiceQuery {
117
137
  attributionByValue() {
118
138
  return this.combinedWithAttribution().attributionByValue();
119
139
  }
140
+ /**
141
+ * Full numeric model for the stacked damage-attribution chart. Convenience for
142
+ * `combinedWithAttribution().damageAttributionChartModel(options)`; see
143
+ * {@link PMF.damageAttributionChartModel}.
144
+ */
145
+ damageAttributionChartModel(options) {
146
+ return this.combinedWithAttribution().damageAttributionChartModel(options);
147
+ }
120
148
  /**
121
149
  * How many of the independent single PMFs can produce the given outcome
122
150
  * label. Useful for "all of them succeeded" style probabilities where the
@@ -721,290 +749,6 @@ var _DiceQuery = class _DiceQuery {
721
749
  }));
722
750
  return { labels: damageValues, datasets };
723
751
  }
724
- /**
725
- * Returns pure mathematical data for attribution charts showing outcome contributions.
726
- *
727
- * Automatically discovers all outcome types present in the PMF, applies filtering rules,
728
- * and returns proportional data suitable for stacked visualization.
729
- *
730
- * @param options Configuration options
731
- * @param options.stackOrder Preferred order for outcome types (unknowns placed at end)
732
- * @param options.filterRules Function to determine if outcome should be included for a given damage value
733
- * @param options.asPercentages Whether to return percentages (0-100) or probabilities (0-1)
734
- * @returns Pure data structure with support, outcomes, and proportional data
735
- *
736
- * @example
737
- * query.toAttributionChartSeries()
738
- * // → {support: [0, 6, 12], outcomes: ['hit', 'crit'], data: {hit: [5.2, 8.1, ...], crit: [0, 2.3, ...]}}
739
- */
740
- toAttributionChartSeries(options = {}) {
741
- const {
742
- stackOrder = [
743
- "missNone",
744
- "missDamage",
745
- "saveFail",
746
- "saveHalf",
747
- "pc",
748
- "hit",
749
- "crit"
750
- ],
751
- filterRules = (outcome, damage) => !(outcome === "missNone" && damage !== 0),
752
- asPercentages = true
753
- } = options;
754
- const originalSupport = this.combined.support();
755
- if (originalSupport.length === 0) {
756
- return { support: [], outcomes: [], data: {} };
757
- }
758
- const minDamage = Math.min(...originalSupport);
759
- const maxDamage = Math.max(...originalSupport);
760
- const support = Array.from(
761
- { length: maxDamage - minDamage + 1 },
762
- (_, i) => minDamage + i
763
- );
764
- const allOutcomeTypes = /* @__PURE__ */ new Set();
765
- for (const [, bin] of this.combined.map) {
766
- for (const outcomeType in bin.count) {
767
- if (bin.count[outcomeType] && bin.count[outcomeType] > 0) {
768
- allOutcomeTypes.add(outcomeType);
769
- }
770
- }
771
- }
772
- const existingOutcomes = Array.from(allOutcomeTypes).sort((a, b) => {
773
- const indexA = stackOrder.indexOf(a);
774
- const indexB = stackOrder.indexOf(b);
775
- if (indexA >= 0 && indexB >= 0) return indexA - indexB;
776
- if (indexA >= 0) return -1;
777
- if (indexB >= 0) return 1;
778
- return a.localeCompare(b);
779
- });
780
- if (existingOutcomes.length === 0) {
781
- return { support, outcomes: [], data: {} };
782
- }
783
- const data = {};
784
- for (const outcome of existingOutcomes) {
785
- data[outcome] = support.map((damage) => {
786
- const bin = this.combined.map.get(damage);
787
- if (!bin) return 0;
788
- if (!filterRules(outcome, damage)) {
789
- return 0;
790
- }
791
- const outcomeCount = bin.count[outcome] || 0;
792
- let totalChartableCount = 0;
793
- for (const [outcomeName, count] of Object.entries(bin.count)) {
794
- if (filterRules(outcomeName, damage)) {
795
- totalChartableCount += count || 0;
796
- }
797
- }
798
- if (totalChartableCount === 0) return 0;
799
- const outcomeFraction = outcomeCount / totalChartableCount;
800
- const outcomeProbability = bin.p * outcomeFraction;
801
- return asPercentages ? outcomeProbability * 100 : outcomeProbability;
802
- });
803
- }
804
- return {
805
- support,
806
- outcomes: existingOutcomes,
807
- data
808
- };
809
- }
810
- /**
811
- * Returns pure mathematical data for damage attribution charts showing damage contribution
812
- * from each outcome type at each damage value.
813
- *
814
- * Similar to toAttributionChartSeries() but uses bin.attr (damage attribution) instead of
815
- * bin.count (probability attribution).
816
- *
817
- * @param options Configuration options
818
- * @param options.stackOrder Preferred order for outcome types (unknowns placed at end)
819
- * @param options.filterRules Function to determine if outcome should be included for a given damage value
820
- * @param options.asPercentages Whether to return percentages (0-100) or raw damage values (0+)
821
- * @returns Pure data structure with support, outcomes, and damage attribution data
822
- *
823
- * @example
824
- * query.toDamageAttributionChartSeries()
825
- * // → {support: [0, 6, 12], outcomes: ['hit', 'crit'], data: {hit: [3.2, 5.1, ...], crit: [0, 1.8, ...]}}
826
- */
827
- toDamageAttributionChartSeries(options = {}) {
828
- const {
829
- stackOrder = [
830
- "missNone",
831
- "missDamage",
832
- "saveFail",
833
- "saveHalf",
834
- "pc",
835
- "hit",
836
- "crit"
837
- ],
838
- filterRules = (outcome, damage) => !(outcome === "missNone" && damage !== 0),
839
- asPercentages = true
840
- } = options;
841
- const originalSupport = this.combined.support();
842
- if (originalSupport.length === 0) {
843
- return { support: [], outcomes: [], data: {} };
844
- }
845
- const minDamage = Math.min(...originalSupport);
846
- const maxDamage = Math.max(...originalSupport);
847
- const support = Array.from(
848
- { length: maxDamage - minDamage + 1 },
849
- (_, i) => minDamage + i
850
- );
851
- const allOutcomeTypes = /* @__PURE__ */ new Set();
852
- for (const [, bin] of this.combined.map) {
853
- if (bin.attr) {
854
- for (const outcomeType in bin.attr) {
855
- if (bin.attr[outcomeType] && bin.attr[outcomeType] > 0) {
856
- allOutcomeTypes.add(outcomeType);
857
- }
858
- }
859
- }
860
- }
861
- const existingOutcomes = Array.from(allOutcomeTypes).sort((a, b) => {
862
- const indexA = stackOrder.indexOf(a);
863
- const indexB = stackOrder.indexOf(b);
864
- if (indexA >= 0 && indexB >= 0) return indexA - indexB;
865
- if (indexA >= 0) return -1;
866
- if (indexB >= 0) return 1;
867
- return a.localeCompare(b);
868
- });
869
- if (existingOutcomes.length === 0) {
870
- return { support, outcomes: [], data: {} };
871
- }
872
- const data = {};
873
- for (const outcome of existingOutcomes) {
874
- data[outcome] = support.map((damage) => {
875
- const bin = this.combined.map.get(damage);
876
- if (!bin || !bin.attr) return 0;
877
- if (!filterRules(outcome, damage)) {
878
- return 0;
879
- }
880
- const outcomeDamageAttribution = bin.attr[outcome] || 0;
881
- if (asPercentages) {
882
- let totalDamageAttribution = 0;
883
- for (const [outcomeName, damageAttr] of Object.entries(bin.attr)) {
884
- if (filterRules(outcomeName, damage)) {
885
- totalDamageAttribution += damageAttr || 0;
886
- }
887
- }
888
- if (totalDamageAttribution === 0) return 0;
889
- const damagePercentage = outcomeDamageAttribution / totalDamageAttribution * 100;
890
- return damagePercentage * bin.p * 100;
891
- } else {
892
- return outcomeDamageAttribution;
893
- }
894
- });
895
- }
896
- return {
897
- support,
898
- outcomes: existingOutcomes,
899
- data
900
- };
901
- }
902
- /**
903
- * Returns pure mathematical data for outcome attribution charts showing which
904
- * attack outcome combinations can produce each damage value.
905
- *
906
- * Unlike toDamageAttributionChartSeries() which tracks damage sources, this tracks
907
- * outcome combinations - answering "what attack outcomes produced this damage?"
908
- *
909
- * @param options Configuration options
910
- * @param options.stackOrder Preferred order for outcome types (unknowns placed at end)
911
- * @param options.filterRules Function to determine if outcome should be included for a given damage value
912
- * @param options.asPercentages Whether to return percentages (0-100) or probabilities (0-1)
913
- * @returns Pure data structure with support, outcomes, and outcome combination probabilities
914
- *
915
- * @example
916
- * query.toOutcomeAttributionChartSeries()
917
- * // → {support: [0, 6, 12], outcomes: ['all_miss', 'mixed', 'all_hit'], data: {all_miss: [15, 0, 0], mixed: [60, 80, 20], all_hit: [25, 20, 80]}}
918
- */
919
- toOutcomeAttributionChartSeries(options = {}) {
920
- const {
921
- stackOrder = [
922
- "missNone",
923
- "missDamage",
924
- "saveFail",
925
- "saveHalf",
926
- "pc",
927
- "hit",
928
- "crit"
929
- ],
930
- filterRules = (outcome, damage) => !(outcome === "missNone" && damage !== 0),
931
- asPercentages = true
932
- } = options;
933
- const originalSupport = this.combined.support();
934
- if (originalSupport.length === 0) {
935
- return { support: [], outcomes: [], data: {} };
936
- }
937
- const minDamage = Math.min(...originalSupport);
938
- const maxDamage = Math.max(...originalSupport);
939
- const support = Array.from(
940
- { length: maxDamage - minDamage + 1 },
941
- (_, i) => minDamage + i
942
- );
943
- const allOutcomeTypes = /* @__PURE__ */ new Set();
944
- for (const [, bin] of this.combined.map) {
945
- for (const outcomeType in bin.count) {
946
- if (bin.count[outcomeType] && bin.count[outcomeType] > 0) {
947
- allOutcomeTypes.add(outcomeType);
948
- }
949
- }
950
- }
951
- const existingOutcomes = Array.from(allOutcomeTypes).sort((a, b) => {
952
- const indexA = stackOrder.indexOf(a);
953
- const indexB = stackOrder.indexOf(b);
954
- if (indexA >= 0 && indexB >= 0) return indexA - indexB;
955
- if (indexA >= 0) return -1;
956
- if (indexB >= 0) return 1;
957
- return a.localeCompare(b);
958
- });
959
- if (existingOutcomes.length === 0) {
960
- return { support, outcomes: [], data: {} };
961
- }
962
- const data = {};
963
- for (const outcome of existingOutcomes) {
964
- data[outcome] = support.map((damage) => {
965
- const bin = this.combined.map.get(damage);
966
- if (!bin) return 0;
967
- if (!filterRules(outcome, damage)) {
968
- return 0;
969
- }
970
- if (outcome === "missNone") {
971
- const outcomeCount = bin.count[outcome] || 0;
972
- if (outcomeCount === 0) return 0;
973
- if (asPercentages) {
974
- let totalChartableCount = 0;
975
- for (const [outcomeName, count] of Object.entries(bin.count)) {
976
- if (filterRules(outcomeName, damage)) {
977
- totalChartableCount += count || 0;
978
- }
979
- }
980
- if (totalChartableCount === 0) return 0;
981
- const outcomeFraction = outcomeCount / totalChartableCount;
982
- return outcomeFraction * bin.p * 100;
983
- } else {
984
- return outcomeCount;
985
- }
986
- }
987
- if (!bin.attr) return 0;
988
- const outcomeDamageContribution = bin.attr[outcome] || 0;
989
- if (asPercentages) {
990
- let totalDamageAttribution = 0;
991
- for (const [, damageAttr] of Object.entries(bin.attr)) {
992
- totalDamageAttribution += damageAttr || 0;
993
- }
994
- if (totalDamageAttribution === 0) return 0;
995
- const outcomeFraction = outcomeDamageContribution / totalDamageAttribution;
996
- return outcomeFraction * bin.p * 100;
997
- } else {
998
- return outcomeDamageContribution;
999
- }
1000
- });
1001
- }
1002
- return {
1003
- support,
1004
- outcomes: existingOutcomes,
1005
- data
1006
- };
1007
- }
1008
752
  /**
1009
753
  * Returns pure mathematical data for cumulative distribution function (CDF).
1010
754
  * Shows P(X ≤ x) - the probability of getting at most x damage.
@@ -2376,6 +2120,149 @@ var _PMF = class _PMF {
2376
2120
  }
2377
2121
  return result;
2378
2122
  }
2123
+ /**
2124
+ * Reversed-convention CCDF percentile markers used by the attribution chart:
2125
+ * for each target probability t, the largest damage x still reached with
2126
+ * P(X ≥ x) > t%, falling back to the smallest/largest support value at the
2127
+ * edges. Ported verbatim from the app so `p80/p50/p20` keep their intentional
2128
+ * reversed meaning (p80 is the low-damage end). Computed on the full, un-binned
2129
+ * support. Assumes a non-empty map.
2130
+ */
2131
+ attributionPercentiles() {
2132
+ const sortedKeys = [...this.map.keys()].sort((a, b) => a - b);
2133
+ const sparseCCDF = [];
2134
+ let cumulativeP = 0;
2135
+ for (let i = sortedKeys.length - 1; i >= 0; i--) {
2136
+ const key = sortedKeys[i];
2137
+ const bin = this.map.get(key);
2138
+ if (!bin) continue;
2139
+ cumulativeP += bin.p;
2140
+ sparseCCDF.unshift({ x: key, y: cumulativeP * 100 });
2141
+ }
2142
+ const findDamageAtProbability = (targetProb) => {
2143
+ for (let i = 0; i < sparseCCDF.length; i++) {
2144
+ if (sparseCCDF[i].y <= targetProb) {
2145
+ return i > 0 ? sparseCCDF[i - 1].x : sparseCCDF[i].x;
2146
+ }
2147
+ }
2148
+ return sparseCCDF[sparseCCDF.length - 1].x;
2149
+ };
2150
+ return {
2151
+ p80: findDamageAtProbability(80),
2152
+ p50: findDamageAtProbability(50),
2153
+ p20: findDamageAtProbability(20)
2154
+ };
2155
+ }
2156
+ /**
2157
+ * Full numeric model for the stacked damage-attribution chart — bar-height
2158
+ * masses, tooltip shares, bucket labels/ranges, percentile markers, and the
2159
+ * mean. The caller only maps these into a rendering format (colors, labels,
2160
+ * axis units); all of the dice-and-probability logic lives here.
2161
+ *
2162
+ * Built split-first-then-bin: the attribution split ({@link attributionByValue})
2163
+ * runs on the un-binned distribution, then the resulting series are coarsened.
2164
+ * {@link rebin} is deliberately *not* used — rebinning first would fold any
2165
+ * sub-`binSize` damage into the damage-0 bucket, which the split then mistakes
2166
+ * for a clean miss and drops.
2167
+ *
2168
+ * @param options.maxBuckets Coarsen to at most this many equal-width buckets
2169
+ * when the integer support is wider (`range > maxBuckets`); omit for a dense,
2170
+ * per-integer model.
2171
+ * @param options.stackOrder Preferred outcome order (defaults to
2172
+ * {@link ALL_OUTCOME_TYPES}); labels outside it sort alphabetically after.
2173
+ * @param options.epsilon Bucket-total floor below which a `shares` entry is 0
2174
+ * (divide-by-~0 guard). Defaults to 1e-9.
2175
+ */
2176
+ damageAttributionChartModel(options = {}) {
2177
+ const { maxBuckets, stackOrder = ALL_OUTCOME_TYPES, epsilon = 1e-9 } = options;
2178
+ const empty = {
2179
+ labels: [],
2180
+ outcomes: [],
2181
+ series: /* @__PURE__ */ new Map(),
2182
+ shares: /* @__PURE__ */ new Map(),
2183
+ totals: [],
2184
+ percentiles: { p80: 0, p50: 0, p20: 0 },
2185
+ mean: 0
2186
+ };
2187
+ if (this.map.size === 0) return empty;
2188
+ const split = this.attributionByValue();
2189
+ const discovered = /* @__PURE__ */ new Set();
2190
+ for (const [, bin] of this.map) {
2191
+ for (const k in bin.count) discovered.add(k);
2192
+ if (bin.attr) for (const k in bin.attr) discovered.add(k);
2193
+ }
2194
+ const outcomes = sortOutcomes([...discovered], stackOrder);
2195
+ const hasAttribution = outcomes.length > 0;
2196
+ let min = Infinity;
2197
+ let max = -Infinity;
2198
+ const widen = (d2) => {
2199
+ if (d2 < min) min = d2;
2200
+ if (d2 > max) max = d2;
2201
+ };
2202
+ if (hasAttribution) {
2203
+ for (const s of split.values())
2204
+ for (const [d2, m] of s) if (m > 0) widen(d2);
2205
+ } else {
2206
+ for (const [d2, bin] of this.map) if ((bin.p || 0) > 0) widen(d2);
2207
+ }
2208
+ if (max < min) return empty;
2209
+ const range = max - min;
2210
+ const binned = maxBuckets !== void 0 && maxBuckets > 0 && range > maxBuckets;
2211
+ const binSize = binned ? Math.ceil((range + 1) / maxBuckets) : 1;
2212
+ const numBins = binned ? Math.ceil((range + 1) / binSize) : range + 1;
2213
+ const bucketOf = (d2) => Math.floor((d2 - min) / binSize);
2214
+ const labels = [];
2215
+ const binRanges = binned ? [] : void 0;
2216
+ for (let i = 0; i < numBins; i++) {
2217
+ const start = min + i * binSize;
2218
+ labels.push(start);
2219
+ if (binRanges)
2220
+ binRanges.push({ start, end: Math.min(start + binSize - 1, max) });
2221
+ }
2222
+ const series = /* @__PURE__ */ new Map();
2223
+ for (const outcome of outcomes) {
2224
+ const arr = new Array(numBins).fill(0);
2225
+ const s = split.get(outcome);
2226
+ if (s) {
2227
+ for (const [d2, m] of s) {
2228
+ const b = bucketOf(d2);
2229
+ if (b >= 0 && b < numBins) arr[b] += m;
2230
+ }
2231
+ }
2232
+ series.set(outcome, arr);
2233
+ }
2234
+ const totals = new Array(numBins).fill(0);
2235
+ if (hasAttribution) {
2236
+ for (const arr of series.values())
2237
+ for (let i = 0; i < numBins; i++) totals[i] += arr[i];
2238
+ } else {
2239
+ for (const [d2, bin] of this.map) {
2240
+ const p = bin.p || 0;
2241
+ if (p <= 0) continue;
2242
+ const b = bucketOf(d2);
2243
+ if (b >= 0 && b < numBins) totals[b] += p;
2244
+ }
2245
+ }
2246
+ const shares = /* @__PURE__ */ new Map();
2247
+ for (const outcome of outcomes) {
2248
+ const arr = series.get(outcome);
2249
+ const sh = new Array(numBins).fill(0);
2250
+ for (let i = 0; i < numBins; i++) {
2251
+ sh[i] = totals[i] > epsilon ? arr[i] / totals[i] : 0;
2252
+ }
2253
+ shares.set(outcome, sh);
2254
+ }
2255
+ return {
2256
+ labels,
2257
+ binRanges,
2258
+ outcomes,
2259
+ series,
2260
+ shares,
2261
+ totals,
2262
+ percentiles: this.attributionPercentiles(),
2263
+ mean: this.mean()
2264
+ };
2265
+ }
2379
2266
  tailProbGE(t) {
2380
2267
  let s = 0;
2381
2268
  for (const [x, bin] of this) {
@@ -3616,6 +3503,17 @@ var RollBuilder = class _RollBuilder {
3616
3503
  getSubRollConfigs() {
3617
3504
  return this.subRollConfigs.map((c) => ({ ...c }));
3618
3505
  }
3506
+ /**
3507
+ * A cheap, stable string that FULLY identifies this builder's PMF — used by {@link AttackBuilder.toPMF}
3508
+ * to cache resolved attack PMFs across rebuilds without walking the AST via {@link toExpression}. A plain
3509
+ * roll is fully determined by its {@link RollConfig} array (count/sides/modifier/reroll/explode/minimum/
3510
+ * bestOf/keep/rollType/isSubtraction), so serializing that is sound. Subclasses whose PMF depends on
3511
+ * hidden state NOT captured by `subRollConfigs` (half/scale/max/parsed/pooled/composite transforms) return
3512
+ * `null` to opt OUT of caching — a conservative miss is always safe; a wrong key would corrupt DPR.
3513
+ */
3514
+ cacheKey() {
3515
+ return JSON.stringify(this.subRollConfigs);
3516
+ }
3619
3517
  // for testing
3620
3518
  static fromConfig(config) {
3621
3519
  return new _RollBuilder([{ ...defaultConfig, ...config }]);
@@ -4124,6 +4022,9 @@ var HalfRollBuilder = class _HalfRollBuilder extends RollBuilder {
4124
4022
  hasHiddenState() {
4125
4023
  return this.innerRoll.hasHiddenState();
4126
4024
  }
4025
+ cacheKey() {
4026
+ return null;
4027
+ }
4127
4028
  // No need to override create if we don't expose RollBuilder methods that use it,
4128
4029
  // but HalfRollBuilder extends RollBuilder so it does.
4129
4030
  // However, HalfRollBuilder seems to just wrap another roll.
@@ -4168,6 +4069,9 @@ var ScaleRollBuilder = class _ScaleRollBuilder extends RollBuilder {
4168
4069
  hasHiddenState() {
4169
4070
  return this.innerRoll.hasHiddenState();
4170
4071
  }
4072
+ cacheKey() {
4073
+ return null;
4074
+ }
4171
4075
  get lastConfig() {
4172
4076
  return this.innerRoll.lastConfig;
4173
4077
  }
@@ -4212,6 +4116,9 @@ var MaxOfRollBuilder = class _MaxOfRollBuilder extends RollBuilder {
4212
4116
  hasHiddenState() {
4213
4117
  return this.innerRoll.hasHiddenState();
4214
4118
  }
4119
+ cacheKey() {
4120
+ return null;
4121
+ }
4215
4122
  get lastConfig() {
4216
4123
  return this.innerRoll.lastConfig;
4217
4124
  }
@@ -4289,6 +4196,10 @@ var AlwaysHitBuilder = class _AlwaysHitBuilder extends RollBuilder {
4289
4196
  get critThreshold() {
4290
4197
  return this.attackConfig.critThreshold;
4291
4198
  }
4199
+ cacheKey() {
4200
+ const base = super.cacheKey();
4201
+ return base === null ? null : `H|${this.attackConfig.critThreshold}|${base}`;
4202
+ }
4292
4203
  // TODO - move this to AC Builder… or if we create a DC builder that has critOn, throw an error?
4293
4204
  critOn(critThreshold) {
4294
4205
  const newConfig = { critThreshold };
@@ -4339,6 +4250,10 @@ var AlwaysCritBuilder = class _AlwaysCritBuilder extends RollBuilder {
4339
4250
  get critThreshold() {
4340
4251
  return this.attackConfig.critThreshold;
4341
4252
  }
4253
+ cacheKey() {
4254
+ const base = super.cacheKey();
4255
+ return base === null ? null : `C|${this.fromAlwaysHit ? 1 : 0}|${this.attackConfig.critThreshold}|${this.attackConfig.ac ?? ""}|${base}`;
4256
+ }
4342
4257
  critOn(critThreshold) {
4343
4258
  const newConfig = { critThreshold, ac: this.attackConfig.ac };
4344
4259
  return new _AlwaysCritBuilder(this, newConfig, this.fromAlwaysHit);
@@ -4369,6 +4284,9 @@ var ParsedRollBuilder = class _ParsedRollBuilder extends RollBuilder {
4369
4284
  hasHiddenState() {
4370
4285
  return true;
4371
4286
  }
4287
+ cacheKey() {
4288
+ return null;
4289
+ }
4372
4290
  create(configs) {
4373
4291
  return new RollBuilder(configs);
4374
4292
  }
@@ -4404,6 +4322,9 @@ var PooledRollBuilder = class _PooledRollBuilder extends RollBuilder {
4404
4322
  hasHiddenState() {
4405
4323
  return true;
4406
4324
  }
4325
+ cacheKey() {
4326
+ return null;
4327
+ }
4407
4328
  d(_sides) {
4408
4329
  throw new Error("Cannot add dice to a pooled roll. The pool is finalized.");
4409
4330
  }
@@ -4507,6 +4428,9 @@ var CompositeSumRollBuilder = class _CompositeSumRollBuilder extends RollBuilder
4507
4428
  hasHiddenState() {
4508
4429
  return true;
4509
4430
  }
4431
+ cacheKey() {
4432
+ return null;
4433
+ }
4510
4434
  getSubRollConfigs() {
4511
4435
  return [];
4512
4436
  }
@@ -5059,6 +4983,10 @@ function getASTSignature(node) {
5059
4983
  }
5060
4984
 
5061
4985
  // src/builder/attack.ts
4986
+ var attackPMFCache = new LRUCache(4e3);
4987
+ function clearAttackCache() {
4988
+ attackPMFCache.clear();
4989
+ }
5062
4990
  var AttackBuilder = class _AttackBuilder {
5063
4991
  constructor(check, hitEffect, critEffect, missEffect) {
5064
4992
  this.check = check;
@@ -5237,9 +5165,46 @@ var AttackBuilder = class _AttackBuilder {
5237
5165
  weights: { hit: phit, crit: pcrit, miss: pmiss }
5238
5166
  };
5239
5167
  }
5240
- // By default, create PMF with no pruning
5168
+ /**
5169
+ * A cheap, complete key for this attack's resolved PMF, or `null` when it can't be cached soundly (an
5170
+ * effect whose PMF isn't captured by its {@link RollConfig}s — see {@link RollBuilder.cacheKey}). Composed
5171
+ * from the check + hit/crit/miss effect keys + `eps`. `critEffect === null` (noCrit) and `undefined`
5172
+ * (auto-double the hit dice) are distinct crit states, encoded separately.
5173
+ */
5174
+ cacheKey(eps) {
5175
+ const checkKey = this.check.cacheKey();
5176
+ if (checkKey === null) return null;
5177
+ let hitKey = "";
5178
+ if (this.hitEffect) {
5179
+ const k = this.hitEffect.cacheKey();
5180
+ if (k === null) return null;
5181
+ hitKey = k;
5182
+ }
5183
+ let critKey;
5184
+ if (this.critEffect === null) critKey = "n";
5185
+ else if (this.critEffect === void 0) critKey = "a";
5186
+ else {
5187
+ const k = this.critEffect.cacheKey();
5188
+ if (k === null) return null;
5189
+ critKey = k;
5190
+ }
5191
+ let missKey = "";
5192
+ if (this.missEffect) {
5193
+ const k = this.missEffect.cacheKey();
5194
+ if (k === null) return null;
5195
+ missKey = k;
5196
+ }
5197
+ return `${checkKey}*H${hitKey}*C${critKey}*M${missKey}*e${eps}`;
5198
+ }
5199
+ // By default, create PMF with no pruning. Cached by the cheap config key across identical rebuilds.
5241
5200
  toPMF(eps = 0) {
5242
- return this.resolve(eps).pmf;
5201
+ const key = this.cacheKey(eps);
5202
+ if (key === null) return this.resolve(eps).pmf;
5203
+ const cached = attackPMFCache.get(key);
5204
+ if (cached) return cached;
5205
+ const pmf = this.resolve(eps).pmf;
5206
+ attackPMFCache.set(key, pmf);
5207
+ return pmf;
5243
5208
  }
5244
5209
  get pmf() {
5245
5210
  return this.toPMF();
@@ -5267,6 +5232,10 @@ var ACBuilder = class _ACBuilder extends RollBuilder {
5267
5232
  get critThreshold() {
5268
5233
  return this.attackConfig.critThreshold;
5269
5234
  }
5235
+ cacheKey() {
5236
+ const base = super.cacheKey();
5237
+ return base === null ? null : `A|${this.attackConfig.ac}|${this.attackConfig.critThreshold}|${base}`;
5238
+ }
5270
5239
  // TODO - move this to AC Builder… or if we create a DC builder that has critOn, throw an error?
5271
5240
  critOn(threshold) {
5272
5241
  const newConfig = {
@@ -5484,6 +5453,7 @@ exports.RollBuilder = RollBuilder;
5484
5453
  exports.SaveBuilder = SaveBuilder;
5485
5454
  exports.ScaleRollBuilder = ScaleRollBuilder;
5486
5455
  exports.builderPMFCache = builderPMFCache;
5456
+ exports.clearAttackCache = clearAttackCache;
5487
5457
  exports.d = d;
5488
5458
  exports.d10 = d10;
5489
5459
  exports.d100 = d100;