@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.
- package/dist/builder/ac.d.ts +1 -0
- package/dist/builder/ac.d.ts.map +1 -1
- package/dist/builder/attack.d.ts +9 -0
- package/dist/builder/attack.d.ts.map +1 -1
- package/dist/builder/index.cjs +257 -287
- package/dist/builder/index.cjs.map +1 -1
- package/dist/builder/index.js +257 -288
- package/dist/builder/index.js.map +1 -1
- package/dist/builder/roll.d.ts +16 -0
- package/dist/builder/roll.d.ts.map +1 -1
- package/dist/index.cjs +152 -285
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +152 -285
- package/dist/index.js.map +1 -1
- package/dist/pmf/pmf.d.ts +69 -0
- package/dist/pmf/pmf.d.ts.map +1 -1
- package/dist/pmf/query.d.ts +12 -84
- package/dist/pmf/query.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/builder/index.js
CHANGED
|
@@ -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
|
|
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) {
|
|
@@ -3614,6 +3501,17 @@ var RollBuilder = class _RollBuilder {
|
|
|
3614
3501
|
getSubRollConfigs() {
|
|
3615
3502
|
return this.subRollConfigs.map((c) => ({ ...c }));
|
|
3616
3503
|
}
|
|
3504
|
+
/**
|
|
3505
|
+
* A cheap, stable string that FULLY identifies this builder's PMF — used by {@link AttackBuilder.toPMF}
|
|
3506
|
+
* to cache resolved attack PMFs across rebuilds without walking the AST via {@link toExpression}. A plain
|
|
3507
|
+
* roll is fully determined by its {@link RollConfig} array (count/sides/modifier/reroll/explode/minimum/
|
|
3508
|
+
* bestOf/keep/rollType/isSubtraction), so serializing that is sound. Subclasses whose PMF depends on
|
|
3509
|
+
* hidden state NOT captured by `subRollConfigs` (half/scale/max/parsed/pooled/composite transforms) return
|
|
3510
|
+
* `null` to opt OUT of caching — a conservative miss is always safe; a wrong key would corrupt DPR.
|
|
3511
|
+
*/
|
|
3512
|
+
cacheKey() {
|
|
3513
|
+
return JSON.stringify(this.subRollConfigs);
|
|
3514
|
+
}
|
|
3617
3515
|
// for testing
|
|
3618
3516
|
static fromConfig(config) {
|
|
3619
3517
|
return new _RollBuilder([{ ...defaultConfig, ...config }]);
|
|
@@ -4122,6 +4020,9 @@ var HalfRollBuilder = class _HalfRollBuilder extends RollBuilder {
|
|
|
4122
4020
|
hasHiddenState() {
|
|
4123
4021
|
return this.innerRoll.hasHiddenState();
|
|
4124
4022
|
}
|
|
4023
|
+
cacheKey() {
|
|
4024
|
+
return null;
|
|
4025
|
+
}
|
|
4125
4026
|
// No need to override create if we don't expose RollBuilder methods that use it,
|
|
4126
4027
|
// but HalfRollBuilder extends RollBuilder so it does.
|
|
4127
4028
|
// However, HalfRollBuilder seems to just wrap another roll.
|
|
@@ -4166,6 +4067,9 @@ var ScaleRollBuilder = class _ScaleRollBuilder extends RollBuilder {
|
|
|
4166
4067
|
hasHiddenState() {
|
|
4167
4068
|
return this.innerRoll.hasHiddenState();
|
|
4168
4069
|
}
|
|
4070
|
+
cacheKey() {
|
|
4071
|
+
return null;
|
|
4072
|
+
}
|
|
4169
4073
|
get lastConfig() {
|
|
4170
4074
|
return this.innerRoll.lastConfig;
|
|
4171
4075
|
}
|
|
@@ -4210,6 +4114,9 @@ var MaxOfRollBuilder = class _MaxOfRollBuilder extends RollBuilder {
|
|
|
4210
4114
|
hasHiddenState() {
|
|
4211
4115
|
return this.innerRoll.hasHiddenState();
|
|
4212
4116
|
}
|
|
4117
|
+
cacheKey() {
|
|
4118
|
+
return null;
|
|
4119
|
+
}
|
|
4213
4120
|
get lastConfig() {
|
|
4214
4121
|
return this.innerRoll.lastConfig;
|
|
4215
4122
|
}
|
|
@@ -4287,6 +4194,10 @@ var AlwaysHitBuilder = class _AlwaysHitBuilder extends RollBuilder {
|
|
|
4287
4194
|
get critThreshold() {
|
|
4288
4195
|
return this.attackConfig.critThreshold;
|
|
4289
4196
|
}
|
|
4197
|
+
cacheKey() {
|
|
4198
|
+
const base = super.cacheKey();
|
|
4199
|
+
return base === null ? null : `H|${this.attackConfig.critThreshold}|${base}`;
|
|
4200
|
+
}
|
|
4290
4201
|
// TODO - move this to AC Builder… or if we create a DC builder that has critOn, throw an error?
|
|
4291
4202
|
critOn(critThreshold) {
|
|
4292
4203
|
const newConfig = { critThreshold };
|
|
@@ -4337,6 +4248,10 @@ var AlwaysCritBuilder = class _AlwaysCritBuilder extends RollBuilder {
|
|
|
4337
4248
|
get critThreshold() {
|
|
4338
4249
|
return this.attackConfig.critThreshold;
|
|
4339
4250
|
}
|
|
4251
|
+
cacheKey() {
|
|
4252
|
+
const base = super.cacheKey();
|
|
4253
|
+
return base === null ? null : `C|${this.fromAlwaysHit ? 1 : 0}|${this.attackConfig.critThreshold}|${this.attackConfig.ac ?? ""}|${base}`;
|
|
4254
|
+
}
|
|
4340
4255
|
critOn(critThreshold) {
|
|
4341
4256
|
const newConfig = { critThreshold, ac: this.attackConfig.ac };
|
|
4342
4257
|
return new _AlwaysCritBuilder(this, newConfig, this.fromAlwaysHit);
|
|
@@ -4367,6 +4282,9 @@ var ParsedRollBuilder = class _ParsedRollBuilder extends RollBuilder {
|
|
|
4367
4282
|
hasHiddenState() {
|
|
4368
4283
|
return true;
|
|
4369
4284
|
}
|
|
4285
|
+
cacheKey() {
|
|
4286
|
+
return null;
|
|
4287
|
+
}
|
|
4370
4288
|
create(configs) {
|
|
4371
4289
|
return new RollBuilder(configs);
|
|
4372
4290
|
}
|
|
@@ -4402,6 +4320,9 @@ var PooledRollBuilder = class _PooledRollBuilder extends RollBuilder {
|
|
|
4402
4320
|
hasHiddenState() {
|
|
4403
4321
|
return true;
|
|
4404
4322
|
}
|
|
4323
|
+
cacheKey() {
|
|
4324
|
+
return null;
|
|
4325
|
+
}
|
|
4405
4326
|
d(_sides) {
|
|
4406
4327
|
throw new Error("Cannot add dice to a pooled roll. The pool is finalized.");
|
|
4407
4328
|
}
|
|
@@ -4505,6 +4426,9 @@ var CompositeSumRollBuilder = class _CompositeSumRollBuilder extends RollBuilder
|
|
|
4505
4426
|
hasHiddenState() {
|
|
4506
4427
|
return true;
|
|
4507
4428
|
}
|
|
4429
|
+
cacheKey() {
|
|
4430
|
+
return null;
|
|
4431
|
+
}
|
|
4508
4432
|
getSubRollConfigs() {
|
|
4509
4433
|
return [];
|
|
4510
4434
|
}
|
|
@@ -5057,6 +4981,10 @@ function getASTSignature(node) {
|
|
|
5057
4981
|
}
|
|
5058
4982
|
|
|
5059
4983
|
// src/builder/attack.ts
|
|
4984
|
+
var attackPMFCache = new LRUCache(4e3);
|
|
4985
|
+
function clearAttackCache() {
|
|
4986
|
+
attackPMFCache.clear();
|
|
4987
|
+
}
|
|
5060
4988
|
var AttackBuilder = class _AttackBuilder {
|
|
5061
4989
|
constructor(check, hitEffect, critEffect, missEffect) {
|
|
5062
4990
|
this.check = check;
|
|
@@ -5235,9 +5163,46 @@ var AttackBuilder = class _AttackBuilder {
|
|
|
5235
5163
|
weights: { hit: phit, crit: pcrit, miss: pmiss }
|
|
5236
5164
|
};
|
|
5237
5165
|
}
|
|
5238
|
-
|
|
5166
|
+
/**
|
|
5167
|
+
* A cheap, complete key for this attack's resolved PMF, or `null` when it can't be cached soundly (an
|
|
5168
|
+
* effect whose PMF isn't captured by its {@link RollConfig}s — see {@link RollBuilder.cacheKey}). Composed
|
|
5169
|
+
* from the check + hit/crit/miss effect keys + `eps`. `critEffect === null` (noCrit) and `undefined`
|
|
5170
|
+
* (auto-double the hit dice) are distinct crit states, encoded separately.
|
|
5171
|
+
*/
|
|
5172
|
+
cacheKey(eps) {
|
|
5173
|
+
const checkKey = this.check.cacheKey();
|
|
5174
|
+
if (checkKey === null) return null;
|
|
5175
|
+
let hitKey = "";
|
|
5176
|
+
if (this.hitEffect) {
|
|
5177
|
+
const k = this.hitEffect.cacheKey();
|
|
5178
|
+
if (k === null) return null;
|
|
5179
|
+
hitKey = k;
|
|
5180
|
+
}
|
|
5181
|
+
let critKey;
|
|
5182
|
+
if (this.critEffect === null) critKey = "n";
|
|
5183
|
+
else if (this.critEffect === void 0) critKey = "a";
|
|
5184
|
+
else {
|
|
5185
|
+
const k = this.critEffect.cacheKey();
|
|
5186
|
+
if (k === null) return null;
|
|
5187
|
+
critKey = k;
|
|
5188
|
+
}
|
|
5189
|
+
let missKey = "";
|
|
5190
|
+
if (this.missEffect) {
|
|
5191
|
+
const k = this.missEffect.cacheKey();
|
|
5192
|
+
if (k === null) return null;
|
|
5193
|
+
missKey = k;
|
|
5194
|
+
}
|
|
5195
|
+
return `${checkKey}*H${hitKey}*C${critKey}*M${missKey}*e${eps}`;
|
|
5196
|
+
}
|
|
5197
|
+
// By default, create PMF with no pruning. Cached by the cheap config key across identical rebuilds.
|
|
5239
5198
|
toPMF(eps = 0) {
|
|
5240
|
-
|
|
5199
|
+
const key = this.cacheKey(eps);
|
|
5200
|
+
if (key === null) return this.resolve(eps).pmf;
|
|
5201
|
+
const cached = attackPMFCache.get(key);
|
|
5202
|
+
if (cached) return cached;
|
|
5203
|
+
const pmf = this.resolve(eps).pmf;
|
|
5204
|
+
attackPMFCache.set(key, pmf);
|
|
5205
|
+
return pmf;
|
|
5241
5206
|
}
|
|
5242
5207
|
get pmf() {
|
|
5243
5208
|
return this.toPMF();
|
|
@@ -5265,6 +5230,10 @@ var ACBuilder = class _ACBuilder extends RollBuilder {
|
|
|
5265
5230
|
get critThreshold() {
|
|
5266
5231
|
return this.attackConfig.critThreshold;
|
|
5267
5232
|
}
|
|
5233
|
+
cacheKey() {
|
|
5234
|
+
const base = super.cacheKey();
|
|
5235
|
+
return base === null ? null : `A|${this.attackConfig.ac}|${this.attackConfig.critThreshold}|${base}`;
|
|
5236
|
+
}
|
|
5268
5237
|
// TODO - move this to AC Builder… or if we create a DC builder that has critOn, throw an error?
|
|
5269
5238
|
critOn(threshold) {
|
|
5270
5239
|
const newConfig = {
|
|
@@ -5469,6 +5438,6 @@ RollBuilder.prototype.dc = function(saveDC) {
|
|
|
5469
5438
|
return new DCBuilder(this).dc(saveDC);
|
|
5470
5439
|
};
|
|
5471
5440
|
|
|
5472
|
-
export { ACBuilder, AlwaysCritBuilder, AlwaysHitBuilder, AttackBuilder, DCBuilder, HalfRollBuilder, MaxOfRollBuilder, ParsedRollBuilder, PooledRollBuilder, RollBuilder, SaveBuilder, ScaleRollBuilder, builderPMFCache, d, d10, d100, d12, d20, d4, d6, d8, defaultConfig, flat, hd20, roll, sumRolls };
|
|
5441
|
+
export { ACBuilder, AlwaysCritBuilder, AlwaysHitBuilder, AttackBuilder, DCBuilder, HalfRollBuilder, MaxOfRollBuilder, ParsedRollBuilder, PooledRollBuilder, RollBuilder, SaveBuilder, ScaleRollBuilder, builderPMFCache, clearAttackCache, d, d10, d100, d12, d20, d4, d6, d8, defaultConfig, flat, hd20, roll, sumRolls };
|
|
5473
5442
|
//# sourceMappingURL=index.js.map
|
|
5474
5443
|
//# sourceMappingURL=index.js.map
|