@yipe/dice 0.6.0 → 0.7.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.
@@ -43,6 +43,26 @@ var LRUCache = class {
43
43
  // src/common/types.ts
44
44
  var EPS = 1e-12;
45
45
  var MISS_NONE_OUTCOME = "missNone";
46
+ var ALL_OUTCOME_TYPES = [
47
+ "missNone",
48
+ "missDamage",
49
+ "saveFail",
50
+ "saveHalf",
51
+ "pc",
52
+ "hit",
53
+ "crit"
54
+ ];
55
+ function sortOutcomes(outcomes, order = ALL_OUTCOME_TYPES) {
56
+ const rank = new Map(order.map((o, i) => [o, i]));
57
+ return [...outcomes].sort((a, b) => {
58
+ const ra = rank.get(a);
59
+ const rb = rank.get(b);
60
+ if (ra !== void 0 && rb !== void 0) return ra - rb;
61
+ if (ra !== void 0) return -1;
62
+ if (rb !== void 0) return 1;
63
+ return a.localeCompare(b);
64
+ });
65
+ }
46
66
 
47
67
  // src/pmf/query.ts
48
68
  var _DiceQuery = class _DiceQuery {
@@ -89,7 +109,7 @@ var _DiceQuery = class _DiceQuery {
89
109
  * const attack = d20.plus(5).ac(15).onHit(d(2,6).plus(3)).onCrit(d(2,6))
90
110
  * const query = attack.toQuery()
91
111
  * const pmf = query.combinedWithAttribution()
92
- * // Now pmf can be used with toDamageAttributionChartSeries()
112
+ * // Now pmf can be used with attributionByValue() / damageAttributionChartModel()
93
113
  */
94
114
  combinedWithAttribution() {
95
115
  if (this._combinedWithAttr) {
@@ -115,6 +135,14 @@ var _DiceQuery = class _DiceQuery {
115
135
  attributionByValue() {
116
136
  return this.combinedWithAttribution().attributionByValue();
117
137
  }
138
+ /**
139
+ * Full numeric model for the stacked damage-attribution chart. Convenience for
140
+ * `combinedWithAttribution().damageAttributionChartModel(options)`; see
141
+ * {@link PMF.damageAttributionChartModel}.
142
+ */
143
+ damageAttributionChartModel(options) {
144
+ return this.combinedWithAttribution().damageAttributionChartModel(options);
145
+ }
118
146
  /**
119
147
  * How many of the independent single PMFs can produce the given outcome
120
148
  * label. Useful for "all of them succeeded" style probabilities where the
@@ -719,290 +747,6 @@ var _DiceQuery = class _DiceQuery {
719
747
  }));
720
748
  return { labels: damageValues, datasets };
721
749
  }
722
- /**
723
- * Returns pure mathematical data for attribution charts showing outcome contributions.
724
- *
725
- * Automatically discovers all outcome types present in the PMF, applies filtering rules,
726
- * and returns proportional data suitable for stacked visualization.
727
- *
728
- * @param options Configuration options
729
- * @param options.stackOrder Preferred order for outcome types (unknowns placed at end)
730
- * @param options.filterRules Function to determine if outcome should be included for a given damage value
731
- * @param options.asPercentages Whether to return percentages (0-100) or probabilities (0-1)
732
- * @returns Pure data structure with support, outcomes, and proportional data
733
- *
734
- * @example
735
- * query.toAttributionChartSeries()
736
- * // → {support: [0, 6, 12], outcomes: ['hit', 'crit'], data: {hit: [5.2, 8.1, ...], crit: [0, 2.3, ...]}}
737
- */
738
- toAttributionChartSeries(options = {}) {
739
- const {
740
- stackOrder = [
741
- "missNone",
742
- "missDamage",
743
- "saveFail",
744
- "saveHalf",
745
- "pc",
746
- "hit",
747
- "crit"
748
- ],
749
- filterRules = (outcome, damage) => !(outcome === "missNone" && damage !== 0),
750
- asPercentages = true
751
- } = options;
752
- const originalSupport = this.combined.support();
753
- if (originalSupport.length === 0) {
754
- return { support: [], outcomes: [], data: {} };
755
- }
756
- const minDamage = Math.min(...originalSupport);
757
- const maxDamage = Math.max(...originalSupport);
758
- const support = Array.from(
759
- { length: maxDamage - minDamage + 1 },
760
- (_, i) => minDamage + i
761
- );
762
- const allOutcomeTypes = /* @__PURE__ */ new Set();
763
- for (const [, bin] of this.combined.map) {
764
- for (const outcomeType in bin.count) {
765
- if (bin.count[outcomeType] && bin.count[outcomeType] > 0) {
766
- allOutcomeTypes.add(outcomeType);
767
- }
768
- }
769
- }
770
- const existingOutcomes = Array.from(allOutcomeTypes).sort((a, b) => {
771
- const indexA = stackOrder.indexOf(a);
772
- const indexB = stackOrder.indexOf(b);
773
- if (indexA >= 0 && indexB >= 0) return indexA - indexB;
774
- if (indexA >= 0) return -1;
775
- if (indexB >= 0) return 1;
776
- return a.localeCompare(b);
777
- });
778
- if (existingOutcomes.length === 0) {
779
- return { support, outcomes: [], data: {} };
780
- }
781
- const data = {};
782
- for (const outcome of existingOutcomes) {
783
- data[outcome] = support.map((damage) => {
784
- const bin = this.combined.map.get(damage);
785
- if (!bin) return 0;
786
- if (!filterRules(outcome, damage)) {
787
- return 0;
788
- }
789
- const outcomeCount = bin.count[outcome] || 0;
790
- let totalChartableCount = 0;
791
- for (const [outcomeName, count] of Object.entries(bin.count)) {
792
- if (filterRules(outcomeName, damage)) {
793
- totalChartableCount += count || 0;
794
- }
795
- }
796
- if (totalChartableCount === 0) return 0;
797
- const outcomeFraction = outcomeCount / totalChartableCount;
798
- const outcomeProbability = bin.p * outcomeFraction;
799
- return asPercentages ? outcomeProbability * 100 : outcomeProbability;
800
- });
801
- }
802
- return {
803
- support,
804
- outcomes: existingOutcomes,
805
- data
806
- };
807
- }
808
- /**
809
- * Returns pure mathematical data for damage attribution charts showing damage contribution
810
- * from each outcome type at each damage value.
811
- *
812
- * Similar to toAttributionChartSeries() but uses bin.attr (damage attribution) instead of
813
- * bin.count (probability attribution).
814
- *
815
- * @param options Configuration options
816
- * @param options.stackOrder Preferred order for outcome types (unknowns placed at end)
817
- * @param options.filterRules Function to determine if outcome should be included for a given damage value
818
- * @param options.asPercentages Whether to return percentages (0-100) or raw damage values (0+)
819
- * @returns Pure data structure with support, outcomes, and damage attribution data
820
- *
821
- * @example
822
- * query.toDamageAttributionChartSeries()
823
- * // → {support: [0, 6, 12], outcomes: ['hit', 'crit'], data: {hit: [3.2, 5.1, ...], crit: [0, 1.8, ...]}}
824
- */
825
- toDamageAttributionChartSeries(options = {}) {
826
- const {
827
- stackOrder = [
828
- "missNone",
829
- "missDamage",
830
- "saveFail",
831
- "saveHalf",
832
- "pc",
833
- "hit",
834
- "crit"
835
- ],
836
- filterRules = (outcome, damage) => !(outcome === "missNone" && damage !== 0),
837
- asPercentages = true
838
- } = options;
839
- const originalSupport = this.combined.support();
840
- if (originalSupport.length === 0) {
841
- return { support: [], outcomes: [], data: {} };
842
- }
843
- const minDamage = Math.min(...originalSupport);
844
- const maxDamage = Math.max(...originalSupport);
845
- const support = Array.from(
846
- { length: maxDamage - minDamage + 1 },
847
- (_, i) => minDamage + i
848
- );
849
- const allOutcomeTypes = /* @__PURE__ */ new Set();
850
- for (const [, bin] of this.combined.map) {
851
- if (bin.attr) {
852
- for (const outcomeType in bin.attr) {
853
- if (bin.attr[outcomeType] && bin.attr[outcomeType] > 0) {
854
- allOutcomeTypes.add(outcomeType);
855
- }
856
- }
857
- }
858
- }
859
- const existingOutcomes = Array.from(allOutcomeTypes).sort((a, b) => {
860
- const indexA = stackOrder.indexOf(a);
861
- const indexB = stackOrder.indexOf(b);
862
- if (indexA >= 0 && indexB >= 0) return indexA - indexB;
863
- if (indexA >= 0) return -1;
864
- if (indexB >= 0) return 1;
865
- return a.localeCompare(b);
866
- });
867
- if (existingOutcomes.length === 0) {
868
- return { support, outcomes: [], data: {} };
869
- }
870
- const data = {};
871
- for (const outcome of existingOutcomes) {
872
- data[outcome] = support.map((damage) => {
873
- const bin = this.combined.map.get(damage);
874
- if (!bin || !bin.attr) return 0;
875
- if (!filterRules(outcome, damage)) {
876
- return 0;
877
- }
878
- const outcomeDamageAttribution = bin.attr[outcome] || 0;
879
- if (asPercentages) {
880
- let totalDamageAttribution = 0;
881
- for (const [outcomeName, damageAttr] of Object.entries(bin.attr)) {
882
- if (filterRules(outcomeName, damage)) {
883
- totalDamageAttribution += damageAttr || 0;
884
- }
885
- }
886
- if (totalDamageAttribution === 0) return 0;
887
- const damagePercentage = outcomeDamageAttribution / totalDamageAttribution * 100;
888
- return damagePercentage * bin.p * 100;
889
- } else {
890
- return outcomeDamageAttribution;
891
- }
892
- });
893
- }
894
- return {
895
- support,
896
- outcomes: existingOutcomes,
897
- data
898
- };
899
- }
900
- /**
901
- * Returns pure mathematical data for outcome attribution charts showing which
902
- * attack outcome combinations can produce each damage value.
903
- *
904
- * Unlike toDamageAttributionChartSeries() which tracks damage sources, this tracks
905
- * outcome combinations - answering "what attack outcomes produced this damage?"
906
- *
907
- * @param options Configuration options
908
- * @param options.stackOrder Preferred order for outcome types (unknowns placed at end)
909
- * @param options.filterRules Function to determine if outcome should be included for a given damage value
910
- * @param options.asPercentages Whether to return percentages (0-100) or probabilities (0-1)
911
- * @returns Pure data structure with support, outcomes, and outcome combination probabilities
912
- *
913
- * @example
914
- * query.toOutcomeAttributionChartSeries()
915
- * // → {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]}}
916
- */
917
- toOutcomeAttributionChartSeries(options = {}) {
918
- const {
919
- stackOrder = [
920
- "missNone",
921
- "missDamage",
922
- "saveFail",
923
- "saveHalf",
924
- "pc",
925
- "hit",
926
- "crit"
927
- ],
928
- filterRules = (outcome, damage) => !(outcome === "missNone" && damage !== 0),
929
- asPercentages = true
930
- } = options;
931
- const originalSupport = this.combined.support();
932
- if (originalSupport.length === 0) {
933
- return { support: [], outcomes: [], data: {} };
934
- }
935
- const minDamage = Math.min(...originalSupport);
936
- const maxDamage = Math.max(...originalSupport);
937
- const support = Array.from(
938
- { length: maxDamage - minDamage + 1 },
939
- (_, i) => minDamage + i
940
- );
941
- const allOutcomeTypes = /* @__PURE__ */ new Set();
942
- for (const [, bin] of this.combined.map) {
943
- for (const outcomeType in bin.count) {
944
- if (bin.count[outcomeType] && bin.count[outcomeType] > 0) {
945
- allOutcomeTypes.add(outcomeType);
946
- }
947
- }
948
- }
949
- const existingOutcomes = Array.from(allOutcomeTypes).sort((a, b) => {
950
- const indexA = stackOrder.indexOf(a);
951
- const indexB = stackOrder.indexOf(b);
952
- if (indexA >= 0 && indexB >= 0) return indexA - indexB;
953
- if (indexA >= 0) return -1;
954
- if (indexB >= 0) return 1;
955
- return a.localeCompare(b);
956
- });
957
- if (existingOutcomes.length === 0) {
958
- return { support, outcomes: [], data: {} };
959
- }
960
- const data = {};
961
- for (const outcome of existingOutcomes) {
962
- data[outcome] = support.map((damage) => {
963
- const bin = this.combined.map.get(damage);
964
- if (!bin) return 0;
965
- if (!filterRules(outcome, damage)) {
966
- return 0;
967
- }
968
- if (outcome === "missNone") {
969
- const outcomeCount = bin.count[outcome] || 0;
970
- if (outcomeCount === 0) return 0;
971
- if (asPercentages) {
972
- let totalChartableCount = 0;
973
- for (const [outcomeName, count] of Object.entries(bin.count)) {
974
- if (filterRules(outcomeName, damage)) {
975
- totalChartableCount += count || 0;
976
- }
977
- }
978
- if (totalChartableCount === 0) return 0;
979
- const outcomeFraction = outcomeCount / totalChartableCount;
980
- return outcomeFraction * bin.p * 100;
981
- } else {
982
- return outcomeCount;
983
- }
984
- }
985
- if (!bin.attr) return 0;
986
- const outcomeDamageContribution = bin.attr[outcome] || 0;
987
- if (asPercentages) {
988
- let totalDamageAttribution = 0;
989
- for (const [, damageAttr] of Object.entries(bin.attr)) {
990
- totalDamageAttribution += damageAttr || 0;
991
- }
992
- if (totalDamageAttribution === 0) return 0;
993
- const outcomeFraction = outcomeDamageContribution / totalDamageAttribution;
994
- return outcomeFraction * bin.p * 100;
995
- } else {
996
- return outcomeDamageContribution;
997
- }
998
- });
999
- }
1000
- return {
1001
- support,
1002
- outcomes: existingOutcomes,
1003
- data
1004
- };
1005
- }
1006
750
  /**
1007
751
  * Returns pure mathematical data for cumulative distribution function (CDF).
1008
752
  * Shows P(X ≤ x) - the probability of getting at most x damage.
@@ -2374,6 +2118,149 @@ var _PMF = class _PMF {
2374
2118
  }
2375
2119
  return result;
2376
2120
  }
2121
+ /**
2122
+ * Reversed-convention CCDF percentile markers used by the attribution chart:
2123
+ * for each target probability t, the largest damage x still reached with
2124
+ * P(X ≥ x) > t%, falling back to the smallest/largest support value at the
2125
+ * edges. Ported verbatim from the app so `p80/p50/p20` keep their intentional
2126
+ * reversed meaning (p80 is the low-damage end). Computed on the full, un-binned
2127
+ * support. Assumes a non-empty map.
2128
+ */
2129
+ attributionPercentiles() {
2130
+ const sortedKeys = [...this.map.keys()].sort((a, b) => a - b);
2131
+ const sparseCCDF = [];
2132
+ let cumulativeP = 0;
2133
+ for (let i = sortedKeys.length - 1; i >= 0; i--) {
2134
+ const key = sortedKeys[i];
2135
+ const bin = this.map.get(key);
2136
+ if (!bin) continue;
2137
+ cumulativeP += bin.p;
2138
+ sparseCCDF.unshift({ x: key, y: cumulativeP * 100 });
2139
+ }
2140
+ const findDamageAtProbability = (targetProb) => {
2141
+ for (let i = 0; i < sparseCCDF.length; i++) {
2142
+ if (sparseCCDF[i].y <= targetProb) {
2143
+ return i > 0 ? sparseCCDF[i - 1].x : sparseCCDF[i].x;
2144
+ }
2145
+ }
2146
+ return sparseCCDF[sparseCCDF.length - 1].x;
2147
+ };
2148
+ return {
2149
+ p80: findDamageAtProbability(80),
2150
+ p50: findDamageAtProbability(50),
2151
+ p20: findDamageAtProbability(20)
2152
+ };
2153
+ }
2154
+ /**
2155
+ * Full numeric model for the stacked damage-attribution chart — bar-height
2156
+ * masses, tooltip shares, bucket labels/ranges, percentile markers, and the
2157
+ * mean. The caller only maps these into a rendering format (colors, labels,
2158
+ * axis units); all of the dice-and-probability logic lives here.
2159
+ *
2160
+ * Built split-first-then-bin: the attribution split ({@link attributionByValue})
2161
+ * runs on the un-binned distribution, then the resulting series are coarsened.
2162
+ * {@link rebin} is deliberately *not* used — rebinning first would fold any
2163
+ * sub-`binSize` damage into the damage-0 bucket, which the split then mistakes
2164
+ * for a clean miss and drops.
2165
+ *
2166
+ * @param options.maxBuckets Coarsen to at most this many equal-width buckets
2167
+ * when the integer support is wider (`range > maxBuckets`); omit for a dense,
2168
+ * per-integer model.
2169
+ * @param options.stackOrder Preferred outcome order (defaults to
2170
+ * {@link ALL_OUTCOME_TYPES}); labels outside it sort alphabetically after.
2171
+ * @param options.epsilon Bucket-total floor below which a `shares` entry is 0
2172
+ * (divide-by-~0 guard). Defaults to 1e-9.
2173
+ */
2174
+ damageAttributionChartModel(options = {}) {
2175
+ const { maxBuckets, stackOrder = ALL_OUTCOME_TYPES, epsilon = 1e-9 } = options;
2176
+ const empty = {
2177
+ labels: [],
2178
+ outcomes: [],
2179
+ series: /* @__PURE__ */ new Map(),
2180
+ shares: /* @__PURE__ */ new Map(),
2181
+ totals: [],
2182
+ percentiles: { p80: 0, p50: 0, p20: 0 },
2183
+ mean: 0
2184
+ };
2185
+ if (this.map.size === 0) return empty;
2186
+ const split = this.attributionByValue();
2187
+ const discovered = /* @__PURE__ */ new Set();
2188
+ for (const [, bin] of this.map) {
2189
+ for (const k in bin.count) discovered.add(k);
2190
+ if (bin.attr) for (const k in bin.attr) discovered.add(k);
2191
+ }
2192
+ const outcomes = sortOutcomes([...discovered], stackOrder);
2193
+ const hasAttribution = outcomes.length > 0;
2194
+ let min = Infinity;
2195
+ let max = -Infinity;
2196
+ const widen = (d2) => {
2197
+ if (d2 < min) min = d2;
2198
+ if (d2 > max) max = d2;
2199
+ };
2200
+ if (hasAttribution) {
2201
+ for (const s of split.values())
2202
+ for (const [d2, m] of s) if (m > 0) widen(d2);
2203
+ } else {
2204
+ for (const [d2, bin] of this.map) if ((bin.p || 0) > 0) widen(d2);
2205
+ }
2206
+ if (max < min) return empty;
2207
+ const range = max - min;
2208
+ const binned = maxBuckets !== void 0 && maxBuckets > 0 && range > maxBuckets;
2209
+ const binSize = binned ? Math.ceil((range + 1) / maxBuckets) : 1;
2210
+ const numBins = binned ? Math.ceil((range + 1) / binSize) : range + 1;
2211
+ const bucketOf = (d2) => Math.floor((d2 - min) / binSize);
2212
+ const labels = [];
2213
+ const binRanges = binned ? [] : void 0;
2214
+ for (let i = 0; i < numBins; i++) {
2215
+ const start = min + i * binSize;
2216
+ labels.push(start);
2217
+ if (binRanges)
2218
+ binRanges.push({ start, end: Math.min(start + binSize - 1, max) });
2219
+ }
2220
+ const series = /* @__PURE__ */ new Map();
2221
+ for (const outcome of outcomes) {
2222
+ const arr = new Array(numBins).fill(0);
2223
+ const s = split.get(outcome);
2224
+ if (s) {
2225
+ for (const [d2, m] of s) {
2226
+ const b = bucketOf(d2);
2227
+ if (b >= 0 && b < numBins) arr[b] += m;
2228
+ }
2229
+ }
2230
+ series.set(outcome, arr);
2231
+ }
2232
+ const totals = new Array(numBins).fill(0);
2233
+ if (hasAttribution) {
2234
+ for (const arr of series.values())
2235
+ for (let i = 0; i < numBins; i++) totals[i] += arr[i];
2236
+ } else {
2237
+ for (const [d2, bin] of this.map) {
2238
+ const p = bin.p || 0;
2239
+ if (p <= 0) continue;
2240
+ const b = bucketOf(d2);
2241
+ if (b >= 0 && b < numBins) totals[b] += p;
2242
+ }
2243
+ }
2244
+ const shares = /* @__PURE__ */ new Map();
2245
+ for (const outcome of outcomes) {
2246
+ const arr = series.get(outcome);
2247
+ const sh = new Array(numBins).fill(0);
2248
+ for (let i = 0; i < numBins; i++) {
2249
+ sh[i] = totals[i] > epsilon ? arr[i] / totals[i] : 0;
2250
+ }
2251
+ shares.set(outcome, sh);
2252
+ }
2253
+ return {
2254
+ labels,
2255
+ binRanges,
2256
+ outcomes,
2257
+ series,
2258
+ shares,
2259
+ totals,
2260
+ percentiles: this.attributionPercentiles(),
2261
+ mean: this.mean()
2262
+ };
2263
+ }
2377
2264
  tailProbGE(t) {
2378
2265
  let s = 0;
2379
2266
  for (const [x, bin] of this) {