@yipe/dice 0.11.0 → 0.12.1
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/README.md +282 -8
- package/dist/builder/ac.d.ts +44 -1
- package/dist/builder/ac.d.ts.map +1 -1
- package/dist/builder/arguments.d.ts +6 -0
- package/dist/builder/arguments.d.ts.map +1 -0
- package/dist/builder/ast.d.ts +36 -7
- package/dist/builder/ast.d.ts.map +1 -1
- package/dist/builder/attack.d.ts +88 -5
- package/dist/builder/attack.d.ts.map +1 -1
- package/dist/builder/dc.d.ts +13 -0
- package/dist/builder/dc.d.ts.map +1 -1
- package/dist/builder/example.d.ts +11 -15
- package/dist/builder/example.d.ts.map +1 -1
- package/dist/builder/expression.d.ts +72 -0
- package/dist/builder/expression.d.ts.map +1 -0
- package/dist/builder/factory.d.ts +2 -3
- package/dist/builder/factory.d.ts.map +1 -1
- package/dist/builder/index.cjs +3657 -1373
- package/dist/builder/index.cjs.map +1 -1
- package/dist/builder/index.js +3650 -1374
- package/dist/builder/index.js.map +1 -1
- package/dist/builder/nodes.d.ts +7 -1
- package/dist/builder/nodes.d.ts.map +1 -1
- package/dist/builder/prob.d.ts +9 -0
- package/dist/builder/prob.d.ts.map +1 -1
- package/dist/builder/roll.d.ts +151 -21
- package/dist/builder/roll.d.ts.map +1 -1
- package/dist/builder/save.d.ts +6 -1
- package/dist/builder/save.d.ts.map +1 -1
- package/dist/builder/types.d.ts +18 -0
- package/dist/builder/types.d.ts.map +1 -1
- package/dist/common/bounce.d.ts +24 -11
- package/dist/common/bounce.d.ts.map +1 -1
- package/dist/common/lru-cache.d.ts +29 -1
- package/dist/common/lru-cache.d.ts.map +1 -1
- package/dist/index.cjs +1219 -421
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1219 -421
- package/dist/index.js.map +1 -1
- package/dist/parser/dice.d.ts +59 -16
- package/dist/parser/dice.d.ts.map +1 -1
- package/dist/parser/parser.d.ts +1 -5
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/rollType.d.ts +4 -4
- package/dist/parser/scaleDice.d.ts +14 -0
- package/dist/parser/scaleDice.d.ts.map +1 -0
- package/dist/pmf/mixture.d.ts +17 -3
- package/dist/pmf/mixture.d.ts.map +1 -1
- package/dist/pmf/pmf.d.ts +118 -33
- package/dist/pmf/pmf.d.ts.map +1 -1
- package/dist/pmf/query.d.ts +25 -12
- package/dist/pmf/query.d.ts.map +1 -1
- package/dist/turn/effects.d.ts +114 -0
- package/dist/turn/effects.d.ts.map +1 -0
- package/dist/turn/index.d.ts +3 -1
- package/dist/turn/index.d.ts.map +1 -1
- package/dist/turn/plan.d.ts +101 -21
- package/dist/turn/plan.d.ts.map +1 -1
- package/dist/turn/state.d.ts +14 -8
- package/dist/turn/state.d.ts.map +1 -1
- package/dist/turn/turn.d.ts +127 -26
- package/dist/turn/turn.d.ts.map +1 -1
- package/dist/turn/types.d.ts +154 -17
- package/dist/turn/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -56,19 +56,28 @@ function calculateBounceOdds(diceCount, dieFaces, options) {
|
|
|
56
56
|
const minimumDieRoll = options?.minimumDieRoll ?? 0;
|
|
57
57
|
const rerollDamageDice = options?.rerollDamageDice ?? 0;
|
|
58
58
|
const pMatchFirst = pMatch(diceCount, dieFaces, minimumDieRoll);
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
59
|
+
const rerollLimit = Math.min(Math.floor(rerollDamageDice), diceCount);
|
|
60
|
+
if (rerollLimit <= 0 || pMatchFirst >= 1) return pMatchFirst;
|
|
61
|
+
const collapsed = minimumDieRoll >= 2;
|
|
62
|
+
const lightCount = collapsed ? dieFaces - minimumDieRoll : dieFaces;
|
|
63
|
+
const heavyWeight = collapsed ? minimumDieRoll / dieFaces : 0;
|
|
64
|
+
const pMissAfter = (rerolled, keptLight, heavyKept) => pAllDistinct(rerolled, dieFaces, lightCount - keptLight, heavyKept ? 0 : heavyWeight);
|
|
65
|
+
let missLightOnly = 1;
|
|
66
|
+
let missWithHeavy = 1;
|
|
67
|
+
for (let rerolled = 1; rerolled <= rerollLimit; rerolled++) {
|
|
68
|
+
const allLightKept = pMissAfter(rerolled, diceCount - rerolled, false);
|
|
69
|
+
missLightOnly = Math.min(missLightOnly, allLightKept);
|
|
70
|
+
missWithHeavy = Math.min(missWithHeavy, allLightKept);
|
|
71
|
+
if (rerolled < diceCount) {
|
|
72
|
+
missWithHeavy = Math.min(missWithHeavy, pMissAfter(rerolled, diceCount - 1 - rerolled, true));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const pLightOnly = pAllDistinct(diceCount, dieFaces, lightCount, 0);
|
|
76
|
+
const pWithHeavy = diceCount * heavyWeight * pAllDistinct(diceCount - 1, dieFaces, lightCount, 0);
|
|
77
|
+
return Math.min(
|
|
68
78
|
1,
|
|
69
|
-
|
|
79
|
+
pMatchFirst + pLightOnly * (1 - missLightOnly) + pWithHeavy * (1 - missWithHeavy)
|
|
70
80
|
);
|
|
71
|
-
return Math.min(1, pMatchFirst + pNoMatchFirst * pMatchAfterReroll);
|
|
72
81
|
}
|
|
73
82
|
function diceSumDistribution(dice, weights) {
|
|
74
83
|
let dist = /* @__PURE__ */ new Map([[0, 1]]);
|
|
@@ -118,8 +127,15 @@ function jointSumAndMatch(dice, weights) {
|
|
|
118
127
|
if (dice <= 1) return /* @__PURE__ */ new Map();
|
|
119
128
|
const total = diceSumDistribution(dice, weights);
|
|
120
129
|
const distinct = sumAllDistinctDistribution(dice, weights);
|
|
130
|
+
const rest = diceSumDistribution(dice - 2, weights);
|
|
131
|
+
const matchable = /* @__PURE__ */ new Set();
|
|
132
|
+
weights.forEach((weight, index) => {
|
|
133
|
+
if (weight <= 0) return;
|
|
134
|
+
for (const sum of rest.keys()) matchable.add(2 * (index + 1) + sum);
|
|
135
|
+
});
|
|
121
136
|
const result = /* @__PURE__ */ new Map();
|
|
122
137
|
for (const [sum, mass] of total) {
|
|
138
|
+
if (!matchable.has(sum)) continue;
|
|
123
139
|
const matchMass = Math.max(0, mass - (distinct.get(sum) ?? 0));
|
|
124
140
|
if (matchMass > 0) result.set(sum, matchMass);
|
|
125
141
|
}
|
|
@@ -173,22 +189,26 @@ function explodingPoolMkDistribution(pMax, count, budget) {
|
|
|
173
189
|
};
|
|
174
190
|
return f(count, budget);
|
|
175
191
|
}
|
|
176
|
-
function
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
192
|
+
function allDistinctProbability(count, weights) {
|
|
193
|
+
const symmetric = new Array(count + 1).fill(0);
|
|
194
|
+
symmetric[0] = 1;
|
|
195
|
+
for (const weight of weights) {
|
|
196
|
+
if (weight <= 0) continue;
|
|
197
|
+
for (let chosen = count; chosen >= 1; chosen--) symmetric[chosen] += symmetric[chosen - 1] * weight;
|
|
198
|
+
}
|
|
199
|
+
let factorial = 1;
|
|
200
|
+
for (let i = 2; i <= count; i++) factorial *= i;
|
|
201
|
+
return factorial * symmetric[count];
|
|
202
|
+
}
|
|
203
|
+
function explodingPoolMatchProbability(weights, count, budget) {
|
|
204
|
+
if (count <= 0 || weights.length === 0) return 0;
|
|
205
|
+
const pMax = weights[weights.length - 1];
|
|
206
|
+
const nonMax = pMax < 1 ? weights.slice(0, -1).map((weight) => weight / (1 - pMax)) : [];
|
|
180
207
|
let pMatchTotal = 0;
|
|
181
|
-
for (const [mk, weight] of
|
|
208
|
+
for (const [mk, weight] of explodingPoolMkDistribution(pMax, count, budget)) {
|
|
182
209
|
const [m, k] = mk.split(",").map(Number);
|
|
183
|
-
if (m >= 2)
|
|
184
|
-
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
let pAllDistinctAmongNonMax = 1;
|
|
188
|
-
for (let i = 0; i < k; i++) {
|
|
189
|
-
pAllDistinctAmongNonMax *= (nonMaxFaces - i) / nonMaxFaces;
|
|
190
|
-
}
|
|
191
|
-
pMatchTotal += weight * (1 - Math.max(0, pAllDistinctAmongNonMax));
|
|
210
|
+
if (m >= 2) pMatchTotal += weight;
|
|
211
|
+
else if (k >= 2) pMatchTotal += weight * (1 - allDistinctProbability(k, nonMax));
|
|
192
212
|
}
|
|
193
213
|
return Math.min(1, Math.max(0, pMatchTotal));
|
|
194
214
|
}
|
|
@@ -205,12 +225,40 @@ var DiceParseError = class _DiceParseError extends Error {
|
|
|
205
225
|
};
|
|
206
226
|
|
|
207
227
|
// src/common/lru-cache.ts
|
|
228
|
+
var cachingEnabled = true;
|
|
229
|
+
var cacheGeneration = 0;
|
|
230
|
+
function setCachingEnabled(enabled) {
|
|
231
|
+
cachingEnabled = enabled;
|
|
232
|
+
if (!enabled) cacheGeneration++;
|
|
233
|
+
}
|
|
234
|
+
function getCachingEnabled() {
|
|
235
|
+
return cachingEnabled;
|
|
236
|
+
}
|
|
208
237
|
var LRUCache = class {
|
|
209
|
-
|
|
238
|
+
/**
|
|
239
|
+
* @param maxSize Entries kept before the least recently used is evicted. A capacity of 0 or
|
|
240
|
+
* less (or NaN) makes the cache store nothing.
|
|
241
|
+
*/
|
|
242
|
+
constructor(maxSize = 1e3, options = {}) {
|
|
210
243
|
this.maxSize = maxSize;
|
|
211
244
|
this.cache = /* @__PURE__ */ new Map();
|
|
245
|
+
this.generation = cacheGeneration;
|
|
246
|
+
this.onInsert = options.onInsert;
|
|
247
|
+
this.followsCachingToggle = options.followsCachingToggle ?? false;
|
|
248
|
+
}
|
|
249
|
+
/** Drops the entries of a toggle-following cache when caching was turned off since last use. */
|
|
250
|
+
sync() {
|
|
251
|
+
if (this.followsCachingToggle && this.generation !== cacheGeneration) {
|
|
252
|
+
this.cache.clear();
|
|
253
|
+
this.generation = cacheGeneration;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
get storing() {
|
|
257
|
+
this.sync();
|
|
258
|
+
return this.maxSize > 0 && (cachingEnabled || !this.followsCachingToggle);
|
|
212
259
|
}
|
|
213
260
|
get(key) {
|
|
261
|
+
if (!this.storing) return void 0;
|
|
214
262
|
const value = this.cache.get(key);
|
|
215
263
|
if (value === void 0) return void 0;
|
|
216
264
|
this.cache.delete(key);
|
|
@@ -221,11 +269,13 @@ var LRUCache = class {
|
|
|
221
269
|
this.cache.delete(key);
|
|
222
270
|
}
|
|
223
271
|
set(key, value) {
|
|
224
|
-
if (this.
|
|
272
|
+
if (!this.storing) return this;
|
|
273
|
+
this.onInsert?.(value);
|
|
274
|
+
this.cache.delete(key);
|
|
275
|
+
if (this.cache.size >= this.maxSize) {
|
|
225
276
|
const oldestKey = this.cache.keys().next().value;
|
|
226
277
|
this.cache.delete(oldestKey);
|
|
227
278
|
}
|
|
228
|
-
this.cache.delete(key);
|
|
229
279
|
this.cache.set(key, value);
|
|
230
280
|
return this;
|
|
231
281
|
}
|
|
@@ -233,15 +283,18 @@ var LRUCache = class {
|
|
|
233
283
|
this.cache.clear();
|
|
234
284
|
}
|
|
235
285
|
get size() {
|
|
286
|
+
this.sync();
|
|
236
287
|
return this.cache.size;
|
|
237
288
|
}
|
|
238
289
|
has(key) {
|
|
239
|
-
return this.cache.has(key);
|
|
290
|
+
return this.storing && this.cache.has(key);
|
|
240
291
|
}
|
|
241
292
|
keys() {
|
|
293
|
+
this.sync();
|
|
242
294
|
return this.cache.keys();
|
|
243
295
|
}
|
|
244
296
|
values() {
|
|
297
|
+
this.sync();
|
|
245
298
|
return this.cache.values();
|
|
246
299
|
}
|
|
247
300
|
};
|
|
@@ -510,33 +563,14 @@ var _DiceQuery = class _DiceQuery {
|
|
|
510
563
|
return probabilitySum;
|
|
511
564
|
}
|
|
512
565
|
/**
|
|
513
|
-
* Returns damage values at specific percentiles
|
|
566
|
+
* Returns damage values at specific percentiles: for each p, the smallest damage x with
|
|
567
|
+
* P(total ≤ x) ≥ p, exact at CDF boundaries (see {@link PMF.quantile}).
|
|
514
568
|
*
|
|
515
569
|
* Example: `query.percentiles([0.25, 0.5, 0.75])` → [8, 12, 18]
|
|
516
570
|
* Use case: "What are my 25th, 50th, and 75th percentile damage values?"
|
|
517
571
|
*/
|
|
518
572
|
percentiles(percentileValues) {
|
|
519
|
-
|
|
520
|
-
if (sortedDamageValues.length === 0) return percentileValues.map(() => 0);
|
|
521
|
-
const cumulativeProbabilities = [];
|
|
522
|
-
let runningProbabilitySum = 0;
|
|
523
|
-
for (const damageValue of sortedDamageValues) {
|
|
524
|
-
runningProbabilitySum += this.combined.map.get(damageValue).p;
|
|
525
|
-
cumulativeProbabilities.push(runningProbabilitySum);
|
|
526
|
-
}
|
|
527
|
-
return percentileValues.map((targetPercentile) => {
|
|
528
|
-
let leftBound = 0;
|
|
529
|
-
let rightBound = cumulativeProbabilities.length - 1;
|
|
530
|
-
while (leftBound <= rightBound) {
|
|
531
|
-
const middleIndex = Math.floor((leftBound + rightBound) / 2);
|
|
532
|
-
if (cumulativeProbabilities[middleIndex] >= targetPercentile) {
|
|
533
|
-
rightBound = middleIndex - 1;
|
|
534
|
-
} else {
|
|
535
|
-
leftBound = middleIndex + 1;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
return leftBound < sortedDamageValues.length ? sortedDamageValues[leftBound] : sortedDamageValues[sortedDamageValues.length - 1];
|
|
539
|
-
});
|
|
573
|
+
return percentileValues.map((p) => this.combined.quantile(p));
|
|
540
574
|
}
|
|
541
575
|
/**
|
|
542
576
|
* Returns the minimum possible damage.
|
|
@@ -619,16 +653,14 @@ var _DiceQuery = class _DiceQuery {
|
|
|
619
653
|
* Note:
|
|
620
654
|
*
|
|
621
655
|
* - You have to pass in an array of labels to avoid double-counting if you are
|
|
622
|
-
* using multiple labels. You cannot just add them.
|
|
656
|
+
* using multiple labels. You cannot just add them. A label listed twice counts once.
|
|
623
657
|
*/
|
|
624
658
|
probAtLeastOne(labels) {
|
|
625
|
-
|
|
626
|
-
labels = [labels];
|
|
627
|
-
}
|
|
659
|
+
const distinctLabels = typeof labels === "string" ? [labels] : [...new Set(labels)];
|
|
628
660
|
let productOfNonOccurrence = 1;
|
|
629
661
|
for (let diceIndex = 0; diceIndex < this.singles.length; diceIndex++) {
|
|
630
662
|
let combinedProbability = 0;
|
|
631
|
-
for (const label of
|
|
663
|
+
for (const label of distinctLabels) {
|
|
632
664
|
combinedProbability += this.singleProb(diceIndex, label);
|
|
633
665
|
}
|
|
634
666
|
if (combinedProbability < 0) combinedProbability = 0;
|
|
@@ -684,10 +716,11 @@ var _DiceQuery = class _DiceQuery {
|
|
|
684
716
|
* - "How likely am I to get exactly 2 successes out of 3 attacks?"
|
|
685
717
|
* - "What's the probability that exactly half my attacks succeed?"
|
|
686
718
|
*
|
|
687
|
-
* Note: For arrays, an attack counts as a "success" if it has any of the specified labels
|
|
688
|
-
*
|
|
719
|
+
* Note: For arrays, an attack counts as a "success" if it has any of the specified labels,
|
|
720
|
+
* as in probAtLeastK and probAtMostK. A negative k has probability 0.
|
|
689
721
|
*/
|
|
690
722
|
probExactlyK(labels, k) {
|
|
723
|
+
if (k < 0) return 0;
|
|
691
724
|
if (typeof labels === "string") {
|
|
692
725
|
const probabilityArray = this.computeBinomialProbabilities(labels, k);
|
|
693
726
|
return probabilityArray[k];
|
|
@@ -745,16 +778,24 @@ var _DiceQuery = class _DiceQuery {
|
|
|
745
778
|
* - "How much damage do I expect from successful attacks?"
|
|
746
779
|
* - "What's the damage contribution from critical hits specifically?"
|
|
747
780
|
* - "How much damage comes from miss effects (like save-for-half spells)?"
|
|
781
|
+
*
|
|
782
|
+
* A single whose mass is not 1 is normalized exactly as {@link mean} normalizes it, so the
|
|
783
|
+
* contributions of labels that cover every outcome add up to `mean()`. A label listed twice
|
|
784
|
+
* counts once.
|
|
748
785
|
*/
|
|
749
786
|
expectedDamageFrom(labels) {
|
|
750
|
-
const wanted = Array.isArray(labels) ? labels : [labels];
|
|
787
|
+
const wanted = Array.isArray(labels) ? [...new Set(labels)] : [labels];
|
|
751
788
|
let total = 0;
|
|
752
789
|
for (const single of this.singles) {
|
|
790
|
+
const mass = single.mass();
|
|
791
|
+
if (mass <= 0) continue;
|
|
792
|
+
let contribution = 0;
|
|
753
793
|
for (const [dmg, bin] of single) {
|
|
754
794
|
let p = 0;
|
|
755
795
|
for (const label of wanted) p += bin.count[label] ?? 0;
|
|
756
|
-
|
|
796
|
+
contribution += dmg * p;
|
|
757
797
|
}
|
|
798
|
+
total += Math.abs(mass - 1) <= this._eps ? contribution : contribution / mass;
|
|
758
799
|
}
|
|
759
800
|
return total;
|
|
760
801
|
}
|
|
@@ -921,10 +962,15 @@ var _DiceQuery = class _DiceQuery {
|
|
|
921
962
|
return this.probAtLeastOne(labels);
|
|
922
963
|
}
|
|
923
964
|
/**
|
|
924
|
-
* Returns the probability
|
|
965
|
+
* Returns the probability that at least one attack misses (either kind of miss:
|
|
966
|
+
* `missNone` or `missDamage`). For a single attack this is its miss chance.
|
|
967
|
+
*
|
|
968
|
+
* Example: `query.missChance()` → 0.45
|
|
969
|
+
* Use case: "What's the chance I miss at least once this turn?"
|
|
925
970
|
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
971
|
+
* For the chance that every attack misses, use
|
|
972
|
+
* `probExactlyK(["missNone", "missDamage"], n)` with n attacks, or
|
|
973
|
+
* `probAtMostK(["hit", "crit"], 0)`.
|
|
928
974
|
*/
|
|
929
975
|
missChance() {
|
|
930
976
|
return this.probabilityOf(["missDamage", "missNone"]);
|
|
@@ -1063,16 +1109,13 @@ var _DiceQuery = class _DiceQuery {
|
|
|
1063
1109
|
data: ccdfData
|
|
1064
1110
|
};
|
|
1065
1111
|
}
|
|
1066
|
-
/*
|
|
1067
|
-
Statistics snapshot of the query.
|
|
1068
|
-
*/
|
|
1069
1112
|
/** Probability of doing strictly more than threshold damage (default >0). */
|
|
1070
1113
|
probDamageGreaterThan(threshold = 0) {
|
|
1071
1114
|
let acc = 0;
|
|
1072
1115
|
for (const [x, bin] of this.combined.map) if (x > threshold) acc += bin.p;
|
|
1073
1116
|
return acc;
|
|
1074
1117
|
}
|
|
1075
|
-
/** All outcome keys
|
|
1118
|
+
/** All outcome keys present in the PMF, ordered by `order` when given. */
|
|
1076
1119
|
outcomeKeys(order) {
|
|
1077
1120
|
const found = /* @__PURE__ */ new Set();
|
|
1078
1121
|
for (const [, bin] of this.combined.map) {
|
|
@@ -1240,17 +1283,10 @@ var _DiceQuery = class _DiceQuery {
|
|
|
1240
1283
|
const averageDPR = this.mean();
|
|
1241
1284
|
let damageChance = 0;
|
|
1242
1285
|
for (const [x, bin] of this.combined.map) if (x > 0) damageChance += bin.p;
|
|
1243
|
-
const { support, data } = this.toCDFSeries(false);
|
|
1244
|
-
const quantile = (p) => {
|
|
1245
|
-
if (support.length === 0) return 0;
|
|
1246
|
-
for (let i = 0; i < support.length; i++)
|
|
1247
|
-
if (data[i] >= p) return support[i];
|
|
1248
|
-
return support[support.length - 1];
|
|
1249
|
-
};
|
|
1250
1286
|
const percentiles = {
|
|
1251
|
-
p25: quantile(0.25),
|
|
1252
|
-
p50: quantile(0.5),
|
|
1253
|
-
p75: quantile(0.75)
|
|
1287
|
+
p25: this.combined.quantile(0.25),
|
|
1288
|
+
p50: this.combined.quantile(0.5),
|
|
1289
|
+
p75: this.combined.quantile(0.75)
|
|
1254
1290
|
};
|
|
1255
1291
|
return { averageDPR, damageChance, percentiles, outcomes: outcomeMap };
|
|
1256
1292
|
}
|
|
@@ -1330,20 +1366,23 @@ var _DiceQuery = class _DiceQuery {
|
|
|
1330
1366
|
return new _DiceQuery([this.combined.mapDamage(damageTransformFunction)]);
|
|
1331
1367
|
}
|
|
1332
1368
|
/**
|
|
1333
|
-
* Returns a new DiceQuery with damage values scaled by
|
|
1334
|
-
* Convenient wrapper around mapDamage for multiplicative scaling
|
|
1369
|
+
* Returns a new DiceQuery with damage values scaled by `factor / denominator`.
|
|
1370
|
+
* Convenient wrapper around mapDamage for multiplicative scaling; see
|
|
1371
|
+
* {@link PMF.scaleDamage} for the rounding rules.
|
|
1335
1372
|
*
|
|
1336
|
-
* @param factor Scaling factor for damage values
|
|
1373
|
+
* @param factor Scaling factor for damage values (the numerator of a ratio)
|
|
1337
1374
|
* @param rounding Rounding method: "floor" (default), "round", or "ceil"
|
|
1375
|
+
* @param denominator Divisor; integer `factor` and `denominator` round exactly
|
|
1338
1376
|
* @returns New DiceQuery with scaled damage values
|
|
1339
1377
|
*
|
|
1340
1378
|
* @example
|
|
1341
1379
|
* const baseAttack = parse("2d6 + 3");
|
|
1342
1380
|
* const doubled = baseAttack.scaleDamage(2); // Double damage
|
|
1343
1381
|
* const halfDamage = baseAttack.scaleDamage(0.5, "round"); // Half damage, rounded
|
|
1382
|
+
* const sevenTenths = baseAttack.scaleDamage(7, "floor", 10); // floor(7v/10), exactly
|
|
1344
1383
|
*/
|
|
1345
|
-
scaleDamage(factor, rounding = "floor") {
|
|
1346
|
-
return new _DiceQuery([this.combined.scaleDamage(factor, rounding)]);
|
|
1384
|
+
scaleDamage(factor, rounding = "floor", denominator = 1) {
|
|
1385
|
+
return new _DiceQuery([this.combined.scaleDamage(factor, rounding, denominator)]);
|
|
1347
1386
|
}
|
|
1348
1387
|
/**
|
|
1349
1388
|
* Returns a new DiceQuery combining this query with another via convolution.
|
|
@@ -1424,14 +1463,69 @@ _DiceQuery.DEFAULT_OUTCOMES = [
|
|
|
1424
1463
|
"missNone"
|
|
1425
1464
|
];
|
|
1426
1465
|
var DiceQuery = _DiceQuery;
|
|
1427
|
-
|
|
1466
|
+
|
|
1467
|
+
// src/pmf/pmf.ts
|
|
1468
|
+
var sharedPMFCacheOptions = {
|
|
1469
|
+
onInsert: (pmf) => pmf.freeze(),
|
|
1470
|
+
followsCachingToggle: true
|
|
1471
|
+
};
|
|
1472
|
+
var pmfCache = new LRUCache(1e3, sharedPMFCacheOptions);
|
|
1473
|
+
var QUANTILE_RELATIVE_SLACK = 1e-12;
|
|
1474
|
+
var FrozenBinMap = class extends Map {
|
|
1475
|
+
constructor(source) {
|
|
1476
|
+
super();
|
|
1477
|
+
for (const [value, bin] of source) super.set(value, bin);
|
|
1478
|
+
}
|
|
1479
|
+
set(value) {
|
|
1480
|
+
throw new TypeError(`Cannot set damage value ${value}: this PMF is frozen (shared through a cache)`);
|
|
1481
|
+
}
|
|
1482
|
+
delete(value) {
|
|
1483
|
+
throw new TypeError(`Cannot delete damage value ${value}: this PMF is frozen (shared through a cache)`);
|
|
1484
|
+
}
|
|
1485
|
+
clear() {
|
|
1486
|
+
throw new TypeError("Cannot clear the map: this PMF is frozen (shared through a cache)");
|
|
1487
|
+
}
|
|
1488
|
+
};
|
|
1428
1489
|
var _PMF = class _PMF {
|
|
1490
|
+
/**
|
|
1491
|
+
* @param map Damage value → bin. Typed read-only: a PMF is immutable once built. A PMF returned
|
|
1492
|
+
* from the library's caches is frozen: its bins, its map and the `map` property itself reject
|
|
1493
|
+
* writes (see {@link freeze}).
|
|
1494
|
+
*/
|
|
1429
1495
|
constructor(map = /* @__PURE__ */ new Map(), epsilon = EPS, normalized = false, identifier = `anon#${_PMF.__anonIdCounter++}`, _preservedProvenance = true) {
|
|
1430
1496
|
this.map = map;
|
|
1431
1497
|
this.epsilon = epsilon;
|
|
1432
1498
|
this.normalized = normalized;
|
|
1433
1499
|
this.identifier = identifier;
|
|
1434
1500
|
this._preservedProvenance = _preservedProvenance;
|
|
1501
|
+
this._frozen = false;
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* A PMF cache that freezes every stored PMF and follows `setCachingEnabled`. The library's
|
|
1505
|
+
* own caches are built with this.
|
|
1506
|
+
*/
|
|
1507
|
+
static createCache(maxSize) {
|
|
1508
|
+
return new LRUCache(maxSize, sharedPMFCacheOptions);
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Freezes this PMF so it can be shared through a cache. Every bin is deep-frozen, including its
|
|
1512
|
+
* `count` and `attr` maps, so writing to one throws a `TypeError`. The map is replaced by a copy
|
|
1513
|
+
* whose `set`/`delete`/`clear` throw a `TypeError` (the map this PMF was built with stays the
|
|
1514
|
+
* caller's), and the `map` property becomes read-only, so assigning it throws too.
|
|
1515
|
+
* Returns this PMF.
|
|
1516
|
+
*/
|
|
1517
|
+
freeze() {
|
|
1518
|
+
if (this._frozen) return this;
|
|
1519
|
+
const map = new FrozenBinMap(this.map);
|
|
1520
|
+
for (const bin of map.values()) {
|
|
1521
|
+
Object.freeze(bin.count);
|
|
1522
|
+
if (bin.attr) Object.freeze(bin.attr);
|
|
1523
|
+
Object.freeze(bin);
|
|
1524
|
+
}
|
|
1525
|
+
Object.freeze(map);
|
|
1526
|
+
Object.defineProperty(this, "map", { value: map, writable: false, enumerable: true, configurable: false });
|
|
1527
|
+
this._frozen = true;
|
|
1528
|
+
return this;
|
|
1435
1529
|
}
|
|
1436
1530
|
static empty(epsilon = EPS, identifier = "empty") {
|
|
1437
1531
|
return new _PMF(/* @__PURE__ */ new Map(), epsilon, false, identifier);
|
|
@@ -1692,18 +1786,10 @@ var _PMF = class _PMF {
|
|
|
1692
1786
|
}
|
|
1693
1787
|
return acc ?? _PMF.emptyMass();
|
|
1694
1788
|
}
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
setPreservedProvenance(preserved) {
|
|
1700
|
-
if (!this._preservedProvenance && preserved) {
|
|
1701
|
-
throw new Error(
|
|
1702
|
-
"Preserved provenance is already set to false, cannot fix that"
|
|
1703
|
-
);
|
|
1704
|
-
}
|
|
1705
|
-
this._preservedProvenance = preserved;
|
|
1706
|
-
}
|
|
1789
|
+
/**
|
|
1790
|
+
* False for a PMF produced by {@link power}, which folds independent attacks into one
|
|
1791
|
+
* distribution and cannot say which attack produced which label.
|
|
1792
|
+
*/
|
|
1707
1793
|
preservedProvenance() {
|
|
1708
1794
|
return this._preservedProvenance;
|
|
1709
1795
|
}
|
|
@@ -1714,14 +1800,12 @@ var _PMF = class _PMF {
|
|
|
1714
1800
|
return `${key}@${eps}|${this.fingerprint()}`;
|
|
1715
1801
|
}
|
|
1716
1802
|
/**
|
|
1717
|
-
*
|
|
1718
|
-
*
|
|
1719
|
-
*
|
|
1720
|
-
*
|
|
1721
|
-
*
|
|
1722
|
-
*
|
|
1723
|
-
* This is ONLY SAFE if you are trying to calculate masses.
|
|
1724
|
-
* If you want to query any atLeast probabilities, you should use the DiceQuery class instead without power().
|
|
1803
|
+
* Convolves this PMF with itself `n` times, by exponentiation by squaring. `n` must be a
|
|
1804
|
+
* positive integer.
|
|
1805
|
+
*
|
|
1806
|
+
* NOTE: this folds `n` independent, identical attacks into one PMF, so it loses data
|
|
1807
|
+
* provenance. It is only safe when computing masses; for `atLeast`-style queries use a
|
|
1808
|
+
* `DiceQuery` instead of `power()`.
|
|
1725
1809
|
*/
|
|
1726
1810
|
power(n, eps = this.epsilon) {
|
|
1727
1811
|
if (!Number.isInteger(n) || n <= 0) {
|
|
@@ -1730,10 +1814,8 @@ var _PMF = class _PMF {
|
|
|
1730
1814
|
if (n === 1) return this;
|
|
1731
1815
|
const epsilon = eps ?? this.epsilon;
|
|
1732
1816
|
const key = this.getPowerCacheKey(n, epsilon);
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
if (cached) return cached;
|
|
1736
|
-
}
|
|
1817
|
+
const cached = pmfCache.get(key);
|
|
1818
|
+
if (cached) return cached;
|
|
1737
1819
|
let base = this.normalized ? this : this.normalize();
|
|
1738
1820
|
let result = base;
|
|
1739
1821
|
let exp = n - 1;
|
|
@@ -1746,11 +1828,15 @@ var _PMF = class _PMF {
|
|
|
1746
1828
|
base = base.convolve(base, epsilon);
|
|
1747
1829
|
}
|
|
1748
1830
|
}
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1831
|
+
const folded = new _PMF(
|
|
1832
|
+
result.map,
|
|
1833
|
+
result.epsilon,
|
|
1834
|
+
result.normalized,
|
|
1835
|
+
result.identifier,
|
|
1836
|
+
false
|
|
1837
|
+
);
|
|
1838
|
+
pmfCache.set(key, folded);
|
|
1839
|
+
return folded;
|
|
1754
1840
|
}
|
|
1755
1841
|
/*
|
|
1756
1842
|
* Helper for chaining multiple identical attacks
|
|
@@ -1869,8 +1955,11 @@ var _PMF = class _PMF {
|
|
|
1869
1955
|
return this._max;
|
|
1870
1956
|
}
|
|
1871
1957
|
/**
|
|
1872
|
-
* Returns the expected (mean) damage value.
|
|
1873
|
-
*
|
|
1958
|
+
* Returns the expected (mean) damage value, Σ v·p. Cached.
|
|
1959
|
+
*
|
|
1960
|
+
* On a PMF whose mass is not 1 this is the partial expectation (the slice's contribution to
|
|
1961
|
+
* the whole distribution's mean), not the conditional mean; `DiceQuery.mean()` divides by the
|
|
1962
|
+
* mass instead.
|
|
1874
1963
|
*/
|
|
1875
1964
|
mean() {
|
|
1876
1965
|
if (this._mean === void 0) {
|
|
@@ -1883,18 +1972,25 @@ var _PMF = class _PMF {
|
|
|
1883
1972
|
return this._mean;
|
|
1884
1973
|
}
|
|
1885
1974
|
/**
|
|
1886
|
-
* Returns the variance of the damage distribution.
|
|
1887
|
-
*
|
|
1975
|
+
* Returns the variance of the damage distribution. Cached.
|
|
1976
|
+
*
|
|
1977
|
+
* On a PMF whose mass is not 1 (a slice such as {@link filterOutcome}'s output) this is the
|
|
1978
|
+
* variance of the distribution conditioned on the slice, Σ (v − μ)²·p / m with μ = Σ v·p / m
|
|
1979
|
+
* and m = {@link mass}, the same quantity `DiceQuery.variance()` reports for that PMF. Note
|
|
1980
|
+
* that {@link mean} is not conditioned: it stays the partial expectation Σ v·p, so slices add
|
|
1981
|
+
* up. Within 1e-12 of unit mass (or at zero or negative mass) no conditioning is applied.
|
|
1888
1982
|
*/
|
|
1889
1983
|
variance() {
|
|
1890
1984
|
if (this._variance === void 0) {
|
|
1891
|
-
const
|
|
1985
|
+
const mass = this.mass();
|
|
1986
|
+
const conditional = mass > 0 && Math.abs(mass - 1) > EPS;
|
|
1987
|
+
const meanValue = conditional ? this.mean() / mass : this.mean();
|
|
1892
1988
|
let varianceSum = 0;
|
|
1893
1989
|
for (const [damageValue, probabilityBin] of this.map) {
|
|
1894
1990
|
const deviationFromMean = damageValue - meanValue;
|
|
1895
1991
|
varianceSum += deviationFromMean * deviationFromMean * probabilityBin.p;
|
|
1896
1992
|
}
|
|
1897
|
-
this._variance = varianceSum;
|
|
1993
|
+
this._variance = conditional ? varianceSum / mass : varianceSum;
|
|
1898
1994
|
}
|
|
1899
1995
|
return this._variance;
|
|
1900
1996
|
}
|
|
@@ -1954,11 +2050,9 @@ var _PMF = class _PMF {
|
|
|
1954
2050
|
return this.addScaled(other, 1);
|
|
1955
2051
|
}
|
|
1956
2052
|
/**
|
|
1957
|
-
* Returns a new PMF with
|
|
1958
|
-
*
|
|
1959
|
-
*
|
|
1960
|
-
* able to model "I can probably have this opportunity attack 40% of rounds"
|
|
1961
|
-
* Example: `pmf.addScaled(critBranch, 0.05)` → PMF including 5% crit outcomes
|
|
2053
|
+
* Returns a new PMF with `branch` added to this one, scaled by `probability` before merging —
|
|
2054
|
+
* the primitive for conditional effects. Example: `pmf.addScaled(critBranch, 0.05)` → a PMF
|
|
2055
|
+
* including a 5% crit slice.
|
|
1962
2056
|
*/
|
|
1963
2057
|
addScaled(branch, probability) {
|
|
1964
2058
|
if (probability === 0) return this;
|
|
@@ -1981,40 +2075,42 @@ var _PMF = class _PMF {
|
|
|
1981
2075
|
);
|
|
1982
2076
|
}
|
|
1983
2077
|
/**
|
|
1984
|
-
*
|
|
1985
|
-
*
|
|
1986
|
-
* sub-one AoE target fraction.
|
|
2078
|
+
* Bernoulli thinning: the effect this PMF describes happens with probability
|
|
2079
|
+
* `frequency` and otherwise deals nothing — a conditional attack, an on-hit
|
|
2080
|
+
* rider, or a sub-one AoE target fraction. The result is
|
|
2081
|
+
* `frequency · X + (1 − frequency) · δ0`.
|
|
1987
2082
|
*
|
|
1988
|
-
* Every
|
|
1989
|
-
* per-label `count
|
|
1990
|
-
*
|
|
1991
|
-
*
|
|
2083
|
+
* Every bin (negative damage included) keeps `frequency` of its probability
|
|
2084
|
+
* mass, per-label `count` and per-label `attr`; the zero bin keeps its labels
|
|
2085
|
+
* at that share too. The freed mass, `(1 − frequency) · mass()`, is added to
|
|
2086
|
+
* the damage-0 bin under the canonical `missNone` outcome, so the total mass
|
|
2087
|
+
* is unchanged, also for a slice whose mass is not 1.
|
|
1992
2088
|
*
|
|
1993
2089
|
* Unlike a bare {@link scaleMass} or {@link mapDamage}, this keeps damage
|
|
1994
2090
|
* attribution (`attr`) intact, so a frequency-scaled PMF still renders
|
|
1995
2091
|
* correctly in the damage-attribution charts.
|
|
1996
2092
|
*
|
|
1997
2093
|
* `frequency >= 1` (or non-finite) returns this PMF unchanged; `frequency <= 0`
|
|
1998
|
-
*
|
|
1999
|
-
* encoded at damage value 0.
|
|
2094
|
+
* leaves only the damage-0 bin, holding all of the mass as `missNone`.
|
|
2000
2095
|
*
|
|
2001
2096
|
* @param frequency Probability in [0, 1] that the effect occurs.
|
|
2002
2097
|
*/
|
|
2003
2098
|
applyHitFrequency(frequency) {
|
|
2004
2099
|
if (!Number.isFinite(frequency) || frequency >= 1) return this;
|
|
2005
2100
|
const freq = Math.max(0, frequency);
|
|
2006
|
-
const
|
|
2007
|
-
const pHit = 1 - pMiss;
|
|
2008
|
-
const newMissMass = pMiss + (1 - freq) * pHit;
|
|
2101
|
+
const freedMass = (1 - freq) * this.mass();
|
|
2009
2102
|
const newMap = /* @__PURE__ */ new Map();
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
}
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2103
|
+
if (freq > 0) {
|
|
2104
|
+
for (const [damage, bin] of this.map) {
|
|
2105
|
+
newMap.set(damage, _PMF.scaleBin(bin, freq));
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
if (freedMass > 0) {
|
|
2109
|
+
_PMF.mergeInto(newMap, 0, {
|
|
2110
|
+
p: freedMass,
|
|
2111
|
+
count: { [MISS_NONE_OUTCOME]: freedMass },
|
|
2112
|
+
attr: {}
|
|
2113
|
+
});
|
|
2018
2114
|
}
|
|
2019
2115
|
return new _PMF(
|
|
2020
2116
|
newMap,
|
|
@@ -2047,6 +2143,47 @@ var _PMF = class _PMF {
|
|
|
2047
2143
|
new _PMF(b, this.epsilon, false, `split-(${this.identifier})`)
|
|
2048
2144
|
];
|
|
2049
2145
|
}
|
|
2146
|
+
/**
|
|
2147
|
+
* Max of two i.i.d. copies of this PMF's distribution: normalize, square
|
|
2148
|
+
* the CDF, then restore the original mass. The engine's damage-reroll
|
|
2149
|
+
* substitution (`onFirstHit(keepBestDamage())`) applies this to a landing
|
|
2150
|
+
* attack's base payload slice — "roll it again, keep the better total".
|
|
2151
|
+
*
|
|
2152
|
+
* PRESERVES outcome labels and attribution. A max, unlike a sum, is
|
|
2153
|
+
* literally one of the two draws: the value that wins was drawn from this
|
|
2154
|
+
* same distribution, so its `count`/`attr` composition is unchanged in
|
|
2155
|
+
* *proportion* — only that bin's total mass is recomputed (via the
|
|
2156
|
+
* squared-CDF step) and every label is rescaled by the same factor. This
|
|
2157
|
+
* is why {@link power}'s documented provenance loss does not apply here:
|
|
2158
|
+
* for a sum, one output value arises from many `(x1, x2)` pairs with
|
|
2159
|
+
* different attribution mixes, so provenance is genuinely ambiguous; for a
|
|
2160
|
+
* max, there is exactly one realized draw per bin. A naive rebuild through
|
|
2161
|
+
* {@link fromMap} would silently discard both `count` and `attr`.
|
|
2162
|
+
*
|
|
2163
|
+
* Works on a non-unit-mass slice (e.g. {@link filterOutcome}'s output):
|
|
2164
|
+
* the CDF is squared on the NORMALIZED distribution, then the result is
|
|
2165
|
+
* rescaled back to this PMF's original total mass, not to 1.
|
|
2166
|
+
*/
|
|
2167
|
+
maxOfTwo() {
|
|
2168
|
+
const totalMass = this.mass();
|
|
2169
|
+
if (totalMass <= 0) return this;
|
|
2170
|
+
const resultMap = /* @__PURE__ */ new Map();
|
|
2171
|
+
let cdf = 0;
|
|
2172
|
+
for (const damage of this.support()) {
|
|
2173
|
+
const bin = this.map.get(damage);
|
|
2174
|
+
const normalizedP = bin.p / totalMass;
|
|
2175
|
+
if (normalizedP <= 0) continue;
|
|
2176
|
+
const prevCdf = cdf;
|
|
2177
|
+
cdf += normalizedP;
|
|
2178
|
+
resultMap.set(damage, _PMF.scaleBin(bin, cdf + prevCdf));
|
|
2179
|
+
}
|
|
2180
|
+
return new _PMF(
|
|
2181
|
+
resultMap,
|
|
2182
|
+
this.epsilon,
|
|
2183
|
+
this.normalized,
|
|
2184
|
+
`maxOfTwo(${this.identifier})`
|
|
2185
|
+
);
|
|
2186
|
+
}
|
|
2050
2187
|
scaleMass(factor) {
|
|
2051
2188
|
if (factor === 1) return this;
|
|
2052
2189
|
const scaledMap = /* @__PURE__ */ new Map();
|
|
@@ -2077,9 +2214,37 @@ var _PMF = class _PMF {
|
|
|
2077
2214
|
`map(${this.identifier})`
|
|
2078
2215
|
);
|
|
2079
2216
|
}
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2217
|
+
/**
|
|
2218
|
+
* Multiplies every damage value by `factor / denominator` and rounds: `floor`
|
|
2219
|
+
* (toward −∞, the default), `ceil` (toward +∞) or `round` (to nearest, halves
|
|
2220
|
+
* toward +∞). Values that land on the same result merge, labels included.
|
|
2221
|
+
*
|
|
2222
|
+
* With an integer `factor` and `denominator` the rounding is exact: `v·factor`
|
|
2223
|
+
* is an exact integer, and one division of two integers below 2^53 lands on
|
|
2224
|
+
* the correct side of every integer. Pass a ratio that way, e.g.
|
|
2225
|
+
* `scaleDamage(9, "ceil", 7)`; `scaleDamage(9 / 7, "ceil")` rounds the factor
|
|
2226
|
+
* to a double first, so 21·(9/7) = 27.000000000000004 would round up to 28.
|
|
2227
|
+
*/
|
|
2228
|
+
scaleDamage(factor, rounding = "floor", denominator = 1) {
|
|
2229
|
+
if (!Number.isFinite(factor)) {
|
|
2230
|
+
throw new RangeError(`scaleDamage() factor must be finite, got ${factor}`);
|
|
2231
|
+
}
|
|
2232
|
+
if (!Number.isFinite(denominator) || denominator === 0) {
|
|
2233
|
+
throw new RangeError(`scaleDamage() denominator must be finite and non-zero, got ${denominator}`);
|
|
2234
|
+
}
|
|
2235
|
+
const exact = Number.isInteger(factor) && Number.isInteger(denominator);
|
|
2236
|
+
return this.mapDamage((damageValue) => {
|
|
2237
|
+
if (exact && Number.isInteger(damageValue)) {
|
|
2238
|
+
const numerator = damageValue * factor;
|
|
2239
|
+
if (rounding === "round") {
|
|
2240
|
+
return Math.floor((2 * numerator + denominator) / (2 * denominator));
|
|
2241
|
+
}
|
|
2242
|
+
const quotient = numerator / denominator;
|
|
2243
|
+
return rounding === "ceil" ? Math.ceil(quotient) : Math.floor(quotient);
|
|
2244
|
+
}
|
|
2245
|
+
const scaled = damageValue * factor / denominator;
|
|
2246
|
+
return rounding === "round" ? Math.round(scaled) : rounding === "ceil" ? Math.ceil(scaled) : Math.floor(scaled);
|
|
2247
|
+
});
|
|
2083
2248
|
}
|
|
2084
2249
|
getPMFCombineCacheKey(p1, p2, eps, raw) {
|
|
2085
2250
|
const [id1, id2] = [p1.identifier, p2.identifier].sort();
|
|
@@ -2095,16 +2260,16 @@ var _PMF = class _PMF {
|
|
|
2095
2260
|
* because a PMF is immutable once constructed -- this avoids re-deriving the key on every
|
|
2096
2261
|
* convolve()/power() call (including cache hits). Bin order is sorted by damage value (and
|
|
2097
2262
|
* label keys sorted within each bin) so two equal-content PMFs built via different code paths
|
|
2098
|
-
* fingerprint identically regardless of Map insertion order.
|
|
2263
|
+
* fingerprint identically regardless of Map insertion order. Label keys are JSON-encoded, so a
|
|
2264
|
+
* label containing the separators cannot make two different bins read the same.
|
|
2099
2265
|
*/
|
|
2100
2266
|
fingerprint() {
|
|
2101
2267
|
if (this._fingerprint === void 0) {
|
|
2268
|
+
const labels = (m) => m === void 0 ? "" : Object.keys(m).sort().map((k) => `${JSON.stringify(k)}:${m[k]}`).join(",");
|
|
2102
2269
|
const bins = [...this.map.entries()].sort((a, b) => a[0] - b[0]);
|
|
2103
2270
|
const parts = [];
|
|
2104
2271
|
for (const [damageValue, bin] of bins) {
|
|
2105
|
-
|
|
2106
|
-
const attrStr = bin.attr ? Object.keys(bin.attr).sort().map((k) => `${k}:${bin.attr[k]}`).join(",") : "";
|
|
2107
|
-
parts.push(`${damageValue}:${bin.p}[${countStr}]{${attrStr}}`);
|
|
2272
|
+
parts.push(`${damageValue}:${bin.p}[${labels(bin.count)}]{${labels(bin.attr)}}`);
|
|
2108
2273
|
}
|
|
2109
2274
|
this._fingerprint = `${this.normalized ? 1 : 0}|${parts.join(";")}`;
|
|
2110
2275
|
}
|
|
@@ -2117,15 +2282,21 @@ var _PMF = class _PMF {
|
|
|
2117
2282
|
const B0 = norm(other);
|
|
2118
2283
|
const [A, B] = A0.identifier <= B0.identifier ? [A0, B0] : [B0, A0];
|
|
2119
2284
|
const cacheKey = this.getPMFCombineCacheKey(A, B, epsilon, raw);
|
|
2120
|
-
const cached = pmfCache
|
|
2285
|
+
const cached = pmfCache.get(cacheKey);
|
|
2121
2286
|
if (cached) return cached;
|
|
2287
|
+
const labelEntries = (m) => m === void 0 ? void 0 : Object.entries(m);
|
|
2288
|
+
const bEntries = [...B.map].map(([bVal, bBin]) => ({
|
|
2289
|
+
bVal,
|
|
2290
|
+
bp: bBin.p,
|
|
2291
|
+
count: labelEntries(bBin.count),
|
|
2292
|
+
attr: labelEntries(bBin.attr)
|
|
2293
|
+
}));
|
|
2122
2294
|
const combinedMap = /* @__PURE__ */ new Map();
|
|
2123
2295
|
for (const [aVal, aBin] of A.map) {
|
|
2124
2296
|
const ap = aBin.p;
|
|
2125
|
-
const aCount = aBin.count;
|
|
2126
|
-
const aAttr = aBin.attr;
|
|
2127
|
-
for (const
|
|
2128
|
-
const bp = bBin.p;
|
|
2297
|
+
const aCount = labelEntries(aBin.count);
|
|
2298
|
+
const aAttr = labelEntries(aBin.attr);
|
|
2299
|
+
for (const { bVal, bp, count: bCount, attr: bAttr } of bEntries) {
|
|
2129
2300
|
const dmg = aVal + bVal;
|
|
2130
2301
|
let dest = combinedMap.get(dmg);
|
|
2131
2302
|
if (dest === void 0) {
|
|
@@ -2134,20 +2305,16 @@ var _PMF = class _PMF {
|
|
|
2134
2305
|
}
|
|
2135
2306
|
dest.p += ap * bp;
|
|
2136
2307
|
const dc = dest.count;
|
|
2137
|
-
for (const k
|
|
2138
|
-
for (const k
|
|
2139
|
-
|
|
2140
|
-
if (aAttr || bBin.attr) {
|
|
2308
|
+
for (const [k, v] of aCount) dc[k] = (dc[k] || 0) + v * bp;
|
|
2309
|
+
for (const [k, v] of bCount) dc[k] = (dc[k] || 0) + v * ap;
|
|
2310
|
+
if (aAttr || bAttr) {
|
|
2141
2311
|
let da = dest.attr;
|
|
2142
2312
|
if (da === void 0) {
|
|
2143
2313
|
da = {};
|
|
2144
2314
|
dest.attr = da;
|
|
2145
2315
|
}
|
|
2146
|
-
if (aAttr)
|
|
2147
|
-
|
|
2148
|
-
if (bBin.attr)
|
|
2149
|
-
for (const k in bBin.attr)
|
|
2150
|
-
da[k] = (da[k] || 0) + bBin.attr[k] * ap;
|
|
2316
|
+
if (aAttr) for (const [k, v] of aAttr) da[k] = (da[k] || 0) + v * bp;
|
|
2317
|
+
if (bAttr) for (const [k, v] of bAttr) da[k] = (da[k] || 0) + v * ap;
|
|
2151
2318
|
}
|
|
2152
2319
|
}
|
|
2153
2320
|
}
|
|
@@ -2164,10 +2331,10 @@ var _PMF = class _PMF {
|
|
|
2164
2331
|
}
|
|
2165
2332
|
if (!raw && mGot !== 0 && Math.abs(result.mass() - 1) > epsilon)
|
|
2166
2333
|
result = result.normalize();
|
|
2167
|
-
pmfCache
|
|
2334
|
+
pmfCache.set(cacheKey, result);
|
|
2168
2335
|
return result;
|
|
2169
2336
|
}
|
|
2170
|
-
//
|
|
2337
|
+
// Convolve without renormalizing (raw = true), for callers that combine raw counts.
|
|
2171
2338
|
combineRaw(other, eps) {
|
|
2172
2339
|
return this.convolve(other, eps, true);
|
|
2173
2340
|
}
|
|
@@ -2335,18 +2502,61 @@ var _PMF = class _PMF {
|
|
|
2335
2502
|
for (const [val, bin] of this.map) if (val <= x) acc += bin.p;
|
|
2336
2503
|
return acc;
|
|
2337
2504
|
}
|
|
2338
|
-
/**
|
|
2505
|
+
/**
|
|
2506
|
+
* Quantile / inverse CDF: the smallest support value x with P(X ≤ x) ≥ p·mass().
|
|
2507
|
+
* `p <= 0` gives the smallest support value; `p >= 1` or NaN gives the largest.
|
|
2508
|
+
*
|
|
2509
|
+
* A float running sum can land an ulp short of a CDF that is exactly p (a d20's CDF(10) sums
|
|
2510
|
+
* to 0.49999999999999994), so the comparison allows a relative slack of a few ulps per bin.
|
|
2511
|
+
* Below the median it compares the CDF summed from the low end; above it, the mass strictly
|
|
2512
|
+
* above x summed from the high end. A sum of non-negative terms is accurate relative to its
|
|
2513
|
+
* own size, so the tail that decides the answer is never swamped by the rest of the mass.
|
|
2514
|
+
*/
|
|
2339
2515
|
quantile(p) {
|
|
2340
|
-
|
|
2341
|
-
const
|
|
2342
|
-
if (
|
|
2343
|
-
const
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2516
|
+
const { values, below, above } = this.cumulative();
|
|
2517
|
+
const n = values.length;
|
|
2518
|
+
if (n === 0) return 0;
|
|
2519
|
+
const total = below[n - 1];
|
|
2520
|
+
if (!(total > 0)) return 0;
|
|
2521
|
+
if (p <= 0) return values[0];
|
|
2522
|
+
if (p >= 1) return values[n - 1];
|
|
2523
|
+
const slack = Math.max(QUANTILE_RELATIVE_SLACK, 4 * n * Number.EPSILON);
|
|
2524
|
+
const lowTarget = p * total * (1 - slack);
|
|
2525
|
+
const highTarget = (1 - p) * total * (1 + slack);
|
|
2526
|
+
const lowSide = p <= 0.5;
|
|
2527
|
+
let lo = 0;
|
|
2528
|
+
let hi = n;
|
|
2529
|
+
while (lo < hi) {
|
|
2530
|
+
const mid = lo + hi >>> 1;
|
|
2531
|
+
const reached = lowSide ? below[mid] >= lowTarget : above[mid] <= highTarget;
|
|
2532
|
+
if (reached) hi = mid;
|
|
2533
|
+
else lo = mid + 1;
|
|
2534
|
+
}
|
|
2535
|
+
return values[Math.min(lo, n - 1)];
|
|
2536
|
+
}
|
|
2537
|
+
/**
|
|
2538
|
+
* Sorted support with `below[i]` = Σ p over values ≤ values[i], summed from the low end,
|
|
2539
|
+
* and `above[i]` = Σ p over values > values[i], summed from the high end. Cached.
|
|
2540
|
+
*/
|
|
2541
|
+
cumulative() {
|
|
2542
|
+
if (this._cumulative === void 0) {
|
|
2543
|
+
const values = this.support();
|
|
2544
|
+
const n = values.length;
|
|
2545
|
+
const below = new Array(n);
|
|
2546
|
+
const above = new Array(n);
|
|
2547
|
+
let sum = 0;
|
|
2548
|
+
for (let i = 0; i < n; i++) {
|
|
2549
|
+
sum += this.map.get(values[i]).p;
|
|
2550
|
+
below[i] = sum;
|
|
2551
|
+
}
|
|
2552
|
+
sum = 0;
|
|
2553
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
2554
|
+
above[i] = sum;
|
|
2555
|
+
sum += this.map.get(values[i]).p;
|
|
2556
|
+
}
|
|
2557
|
+
this._cumulative = { values, below, above };
|
|
2348
2558
|
}
|
|
2349
|
-
return
|
|
2559
|
+
return this._cumulative;
|
|
2350
2560
|
}
|
|
2351
2561
|
/** Get outcome probability at specific damage value. */
|
|
2352
2562
|
outcomeAt(damage, outcome) {
|
|
@@ -2666,35 +2876,33 @@ var _PMF = class _PMF {
|
|
|
2666
2876
|
const pNone = 1 - pSpecificSuccess - pGeneralSuccess;
|
|
2667
2877
|
return { pSpecificSuccess, pGeneralSuccess, pNone, pAny };
|
|
2668
2878
|
}
|
|
2879
|
+
/**
|
|
2880
|
+
* Maps every damage value through `f`, then optionally rounds it (`rounding`, default
|
|
2881
|
+
* `"none"`). Values that land on the same result merge their probability and, unless
|
|
2882
|
+
* `preserveCounts` is false, their per-label `count`. Damage attribution (`attr`) is dropped,
|
|
2883
|
+
* because it is tied to the old values. Nothing is pruned and the mass is unchanged.
|
|
2884
|
+
*
|
|
2885
|
+
* @param eps Epsilon carried by the result.
|
|
2886
|
+
* @throws Error when a mapped value is not a finite integer.
|
|
2887
|
+
*/
|
|
2669
2888
|
mapValues(f, eps = EPS, opts) {
|
|
2670
2889
|
const rounding = opts?.rounding ?? "none";
|
|
2671
2890
|
const preserveCounts = opts?.preserveCounts ?? true;
|
|
2672
2891
|
const round = (x) => rounding === "floor" ? Math.floor(x) : rounding === "ceil" ? Math.ceil(x) : rounding === "round" ? Math.round(x) : x;
|
|
2673
|
-
const
|
|
2674
|
-
const counts = /* @__PURE__ */ new Map();
|
|
2892
|
+
const merged = /* @__PURE__ */ new Map();
|
|
2675
2893
|
for (const [v, bin] of this) {
|
|
2676
|
-
if (
|
|
2894
|
+
if (bin.p === 0) continue;
|
|
2677
2895
|
const u = round(f(v));
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
const src = bin.count;
|
|
2681
|
-
if (src) {
|
|
2682
|
-
const dest = counts.get(u) ?? {};
|
|
2683
|
-
for (const k in src) {
|
|
2684
|
-
dest[k] = (dest[k] ?? 0) + src[k];
|
|
2685
|
-
}
|
|
2686
|
-
counts.set(u, dest);
|
|
2687
|
-
}
|
|
2896
|
+
if (!Number.isInteger(u)) {
|
|
2897
|
+
throw new Error(`mapValues: ${v} maps to ${u}, not a finite integer`);
|
|
2688
2898
|
}
|
|
2899
|
+
_PMF.mergeInto(merged, u, {
|
|
2900
|
+
p: bin.p,
|
|
2901
|
+
count: preserveCounts ? bin.count : {}
|
|
2902
|
+
});
|
|
2689
2903
|
}
|
|
2690
|
-
const
|
|
2691
|
-
|
|
2692
|
-
internal.set(u, { p, count: counts.get(u) ?? {} });
|
|
2693
|
-
}
|
|
2694
|
-
return _PMF.fromMap(
|
|
2695
|
-
new Map(Array.from(internal, ([u, b]) => [u, b.p])),
|
|
2696
|
-
eps
|
|
2697
|
-
);
|
|
2904
|
+
const sorted = new Map([...merged.entries()].sort((a, b) => a[0] - b[0]));
|
|
2905
|
+
return new _PMF(sorted, eps, this.normalized, `mapValues(${this.identifier})`);
|
|
2698
2906
|
}
|
|
2699
2907
|
static fromMap(m, eps = EPS, { requireIntegerValues = true } = {}) {
|
|
2700
2908
|
const filtered = [];
|
|
@@ -2791,7 +2999,7 @@ var Dice = class _Dice {
|
|
|
2791
2999
|
if (totalCount === 0) return 0;
|
|
2792
3000
|
return expectedDamage / totalCount;
|
|
2793
3001
|
}
|
|
2794
|
-
//
|
|
3002
|
+
// Public (no modifier) for direct test access.
|
|
2795
3003
|
calculateHitDistribution() {
|
|
2796
3004
|
const hitValues = {};
|
|
2797
3005
|
const subtractedOutcomes = [
|
|
@@ -2812,7 +3020,7 @@ var Dice = class _Dice {
|
|
|
2812
3020
|
}
|
|
2813
3021
|
}
|
|
2814
3022
|
if (numFace === 0) {
|
|
2815
|
-
hitCount = 0;
|
|
3023
|
+
hitCount = this.outcomeData.hit?.[0] ?? 0;
|
|
2816
3024
|
}
|
|
2817
3025
|
if (hitCount < 0) {
|
|
2818
3026
|
hitCount = 0;
|
|
@@ -2854,18 +3062,6 @@ var Dice = class _Dice {
|
|
|
2854
3062
|
}
|
|
2855
3063
|
return result;
|
|
2856
3064
|
}
|
|
2857
|
-
removeFaces(facesToRemove) {
|
|
2858
|
-
const result = new _Dice();
|
|
2859
|
-
for (const [key, value] of Object.entries(this.faces)) {
|
|
2860
|
-
const numKey = Number(key);
|
|
2861
|
-
if (!facesToRemove.includes(numKey)) {
|
|
2862
|
-
result.faces[numKey] = value;
|
|
2863
|
-
}
|
|
2864
|
-
}
|
|
2865
|
-
result.privateData = { ...this.privateData };
|
|
2866
|
-
result.outcomeData = { ...this.outcomeData };
|
|
2867
|
-
return result;
|
|
2868
|
-
}
|
|
2869
3065
|
// PUBLIC FUNCTIONS
|
|
2870
3066
|
getFaceEntries() {
|
|
2871
3067
|
return Object.entries(this.faces).map(([k, v]) => [Number(k), v]);
|
|
@@ -2911,13 +3107,20 @@ var Dice = class _Dice {
|
|
|
2911
3107
|
const current = this.faces[face] || 0;
|
|
2912
3108
|
this.faces[face] = current + count;
|
|
2913
3109
|
}
|
|
3110
|
+
/** Scales every face count, and every outcome's counts with it, so each outcome keeps its share. */
|
|
2914
3111
|
normalize(scalar) {
|
|
2915
3112
|
const result = new _Dice();
|
|
2916
3113
|
for (const [face, count] of Object.entries(this.faces)) {
|
|
2917
3114
|
result.faces[Number(face)] = count * scalar;
|
|
2918
3115
|
}
|
|
2919
3116
|
result.privateData = { ...this.privateData };
|
|
2920
|
-
|
|
3117
|
+
for (const [key, distribution] of Object.entries(this.outcomeData)) {
|
|
3118
|
+
const scaled = {};
|
|
3119
|
+
for (const [face, count] of Object.entries(distribution)) {
|
|
3120
|
+
scaled[Number(face)] = count * scalar;
|
|
3121
|
+
}
|
|
3122
|
+
result.outcomeData[key] = scaled;
|
|
3123
|
+
}
|
|
2921
3124
|
return result;
|
|
2922
3125
|
}
|
|
2923
3126
|
// OPERATIONS
|
|
@@ -2955,10 +3158,18 @@ var Dice = class _Dice {
|
|
|
2955
3158
|
return this.binaryOp(other, (a, b) => a / b);
|
|
2956
3159
|
}
|
|
2957
3160
|
divideRoundUp(other) {
|
|
2958
|
-
|
|
3161
|
+
this.assertNonZeroDivisor(other);
|
|
3162
|
+
return this.binaryOp(other, (a, b) => b === 0 ? 0 : Math.ceil(a / b));
|
|
2959
3163
|
}
|
|
2960
3164
|
divideRoundDown(other) {
|
|
2961
|
-
|
|
3165
|
+
this.assertNonZeroDivisor(other);
|
|
3166
|
+
return this.binaryOp(other, (a, b) => b === 0 ? 0 : Math.floor(a / b));
|
|
3167
|
+
}
|
|
3168
|
+
/** A divisor that can be 0 has no quotient there. A 0 face with no weight is never rolled, so it passes. */
|
|
3169
|
+
assertNonZeroDivisor(other) {
|
|
3170
|
+
if (typeof other === "number" ? other === 0 : other.get(0) > 0) {
|
|
3171
|
+
throw new DiceParseError("Division by zero: the divisor can be 0");
|
|
3172
|
+
}
|
|
2962
3173
|
}
|
|
2963
3174
|
and(other) {
|
|
2964
3175
|
return this.binaryOp(other, (a, b) => a && b ? 1 : 0);
|
|
@@ -2978,9 +3189,23 @@ var Dice = class _Dice {
|
|
|
2978
3189
|
result.privateData.isDCCheck = true;
|
|
2979
3190
|
return result;
|
|
2980
3191
|
}
|
|
3192
|
+
/**
|
|
3193
|
+
* An attack check: a total that meets the target lands at its own value, and a miss is 0. A total
|
|
3194
|
+
* of exactly 0 that meets the target (a target of 0 or less) lands at 0 too, where the misses sit:
|
|
3195
|
+
* its count is recorded under `hit` at 0, like a payload's 0-damage hit, so the ops after the
|
|
3196
|
+
* check tell it from a miss.
|
|
3197
|
+
*/
|
|
2981
3198
|
ac(other) {
|
|
2982
3199
|
const acCheck = (a, b) => a >= b ? a : 0;
|
|
2983
|
-
|
|
3200
|
+
const result = this.checkTarget(other, acCheck);
|
|
3201
|
+
const zero = this.get(0);
|
|
3202
|
+
if (zero > 0) {
|
|
3203
|
+
let met = 0;
|
|
3204
|
+
if (typeof other === "number") met = other <= 0 ? 1 : 0;
|
|
3205
|
+
else for (const [target, count] of other.getFaceEntries()) if (target <= 0) met += count;
|
|
3206
|
+
if (met > 0) result.setOutcomeDistribution("hit", { 0: zero * met });
|
|
3207
|
+
}
|
|
3208
|
+
return result;
|
|
2984
3209
|
}
|
|
2985
3210
|
deleteFace(face) {
|
|
2986
3211
|
const result = new _Dice();
|
|
@@ -2994,18 +3219,19 @@ var Dice = class _Dice {
|
|
|
2994
3219
|
result.outcomeData = { ...this.outcomeData };
|
|
2995
3220
|
return result;
|
|
2996
3221
|
}
|
|
3222
|
+
/**
|
|
3223
|
+
* Roll once and, on a result in `toReroll`'s faces, roll again and keep the second roll. Each
|
|
3224
|
+
* result keeps its own weight: with T the total count and c_R the count on the rerolled faces,
|
|
3225
|
+
* face v's new count is c_v·(T·[v∉R] + c_R), i.e. p′(v) = p(v)·[v∉R] + P(R)·p(v).
|
|
3226
|
+
*/
|
|
2997
3227
|
reroll(toReroll) {
|
|
2998
|
-
const
|
|
2999
|
-
const
|
|
3000
|
-
|
|
3001
|
-
const
|
|
3002
|
-
|
|
3003
|
-
for (const face of this.
|
|
3004
|
-
|
|
3005
|
-
result = result.combine(removed);
|
|
3006
|
-
if (wasRerolled) {
|
|
3007
|
-
result = result.combine(this);
|
|
3008
|
-
}
|
|
3228
|
+
const rerolled = new Set(typeof toReroll === "number" ? [toReroll] : toReroll.keys());
|
|
3229
|
+
const total = this.total();
|
|
3230
|
+
let rerolledCount = 0;
|
|
3231
|
+
for (const [face, count] of this.getFaceEntries()) if (rerolled.has(face)) rerolledCount += count;
|
|
3232
|
+
const result = new _Dice();
|
|
3233
|
+
for (const [face, count] of this.getFaceEntries()) {
|
|
3234
|
+
result.increment(face, count * ((rerolled.has(face) ? 0 : total) + rerolledCount));
|
|
3009
3235
|
}
|
|
3010
3236
|
return result;
|
|
3011
3237
|
}
|
|
@@ -3068,6 +3294,7 @@ var Dice = class _Dice {
|
|
|
3068
3294
|
const critDistro = this.getOutcomeDistribution("crit") || {};
|
|
3069
3295
|
const missDistro = this.getOutcomeDistribution("missDamage") || {};
|
|
3070
3296
|
const saveDistro = this.getOutcomeDistribution("saveHalf") || {};
|
|
3297
|
+
const saveFailDistro = this.getOutcomeDistribution("saveFail") || {};
|
|
3071
3298
|
const pcDistro = this.getOutcomeDistribution("pc") || {};
|
|
3072
3299
|
const isSaveHalf = Object.keys(saveDistro).length > 0;
|
|
3073
3300
|
const isDCCheck = this.privateData.isDCCheck === true;
|
|
@@ -3111,15 +3338,15 @@ var Dice = class _Dice {
|
|
|
3111
3338
|
if (saveDistro[face]) {
|
|
3112
3339
|
const c = clampNonNeg(saveDistro[face] / total);
|
|
3113
3340
|
if (c > 0) {
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3341
|
+
count.saveHalf = c;
|
|
3342
|
+
attr.saveHalf = clampNonNeg(face * saveDistro[face] / total);
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
if (saveFailDistro[face]) {
|
|
3346
|
+
const c = clampNonNeg(saveFailDistro[face] / total);
|
|
3347
|
+
if (c > 0) {
|
|
3348
|
+
count.saveFail = (count.saveFail ?? 0) + c;
|
|
3349
|
+
attr.saveFail = clampNonNeg((attr.saveFail ?? 0) + face * saveFailDistro[face] / total);
|
|
3123
3350
|
}
|
|
3124
3351
|
}
|
|
3125
3352
|
if (pcDistro[face]) {
|
|
@@ -3129,8 +3356,8 @@ var Dice = class _Dice {
|
|
|
3129
3356
|
attr.pc = clampNonNeg(face * pcDistro[face] / total);
|
|
3130
3357
|
}
|
|
3131
3358
|
}
|
|
3132
|
-
if (!
|
|
3133
|
-
const distroCountRaw = (hitDistro[face] || 0) + (critDistro[face] || 0) + (missDistro[face] || 0) + (saveDistro[face] || 0) + (pcDistro[face] || 0);
|
|
3359
|
+
if (!isDCCheck) {
|
|
3360
|
+
const distroCountRaw = (hitDistro[face] || 0) + (critDistro[face] || 0) + (missDistro[face] || 0) + (saveDistro[face] || 0) + (saveFailDistro[face] || 0) + (pcDistro[face] || 0);
|
|
3134
3361
|
const unaccountedCount = clampNonNeg(faceCount - distroCountRaw);
|
|
3135
3362
|
if (unaccountedCount > 0) {
|
|
3136
3363
|
const frac = clampNonNeg(unaccountedCount / total);
|
|
@@ -3150,25 +3377,238 @@ var Dice = class _Dice {
|
|
|
3150
3377
|
}
|
|
3151
3378
|
};
|
|
3152
3379
|
|
|
3380
|
+
// src/parser/scaleDice.ts
|
|
3381
|
+
var isDigitOrN = (c) => c !== void 0 && (c >= "0" && c <= "9" || c === "n");
|
|
3382
|
+
var isPerDieOp = (op) => op === "reroll" || op === ">" || op === "<" || op === "!";
|
|
3383
|
+
var UndoubleableExpressionError = class extends Error {
|
|
3384
|
+
};
|
|
3385
|
+
var AmbiguousCritDoublingError = class extends Error {
|
|
3386
|
+
};
|
|
3387
|
+
var DiceTermReader = class {
|
|
3388
|
+
constructor(s, expression) {
|
|
3389
|
+
this.s = s;
|
|
3390
|
+
this.expression = expression;
|
|
3391
|
+
this.pos = 0;
|
|
3392
|
+
}
|
|
3393
|
+
read() {
|
|
3394
|
+
const expr = this.expr();
|
|
3395
|
+
if (this.pos !== this.s.length) this.fail(`unexpected '${this.s[this.pos]}'`);
|
|
3396
|
+
return expr;
|
|
3397
|
+
}
|
|
3398
|
+
fail(reason) {
|
|
3399
|
+
throw new UndoubleableExpressionError(`Cannot double the dice of "${this.expression}": ${reason}.`);
|
|
3400
|
+
}
|
|
3401
|
+
expr() {
|
|
3402
|
+
const first = this.chain();
|
|
3403
|
+
const rest = [];
|
|
3404
|
+
for (let op = this.operation(); op !== void 0; op = this.operation()) {
|
|
3405
|
+
if (op === "ac") {
|
|
3406
|
+
this.fail("it contains an attack check (a d20 roll against an AC), so it is not a damage expression");
|
|
3407
|
+
}
|
|
3408
|
+
if (op === "dc") {
|
|
3409
|
+
this.fail("it contains a saving throw check (a d20 roll against a DC), so it is not a damage expression");
|
|
3410
|
+
}
|
|
3411
|
+
const arg = op === "!" ? void 0 : this.chain();
|
|
3412
|
+
const c = this.s[this.pos];
|
|
3413
|
+
if (c === "x" || c === "c" || c === "s" || c === "m" || this.s.startsWith("pc", this.pos)) {
|
|
3414
|
+
this.fail("it contains a check-outcome clause (crit/save/pc/miss), so it is not a damage expression");
|
|
3415
|
+
}
|
|
3416
|
+
rest.push({ op, arg, end: this.pos });
|
|
3417
|
+
}
|
|
3418
|
+
return { first, rest };
|
|
3419
|
+
}
|
|
3420
|
+
chain() {
|
|
3421
|
+
const start = this.pos;
|
|
3422
|
+
const atoms = [];
|
|
3423
|
+
for (let atom = this.atom(); atom !== void 0; atom = this.atom()) atoms.push(atom);
|
|
3424
|
+
return { start, atoms };
|
|
3425
|
+
}
|
|
3426
|
+
atom() {
|
|
3427
|
+
const start = this.pos;
|
|
3428
|
+
const c = this.s[start];
|
|
3429
|
+
if (c === "(") {
|
|
3430
|
+
this.pos++;
|
|
3431
|
+
const expr = this.expr();
|
|
3432
|
+
if (this.s[this.pos] !== ")") this.fail("unbalanced parentheses");
|
|
3433
|
+
this.pos++;
|
|
3434
|
+
return { kind: "group", start, end: this.pos, expr };
|
|
3435
|
+
}
|
|
3436
|
+
if (c === "h" && this.s[start + 1] === "d" && isDigitOrN(this.s[start + 2])) {
|
|
3437
|
+
this.pos += 2;
|
|
3438
|
+
this.number();
|
|
3439
|
+
return { kind: "die", start, end: this.pos };
|
|
3440
|
+
}
|
|
3441
|
+
if (c === "d" && isDigitOrN(this.s[start + 1])) {
|
|
3442
|
+
this.pos += 1;
|
|
3443
|
+
this.number();
|
|
3444
|
+
return { kind: "die", start, end: this.pos };
|
|
3445
|
+
}
|
|
3446
|
+
if (c === "k") {
|
|
3447
|
+
const mode = this.s[start + 1];
|
|
3448
|
+
if (mode !== "h" && mode !== "l") this.fail("'k' must be followed by 'h' or 'l'");
|
|
3449
|
+
this.pos += 2;
|
|
3450
|
+
const kept = this.number();
|
|
3451
|
+
const inner = this.atom();
|
|
3452
|
+
if (inner === void 0) this.fail("a keep needs dice after it");
|
|
3453
|
+
return { kind: "keep", start, end: this.pos, inner, mode, kept };
|
|
3454
|
+
}
|
|
3455
|
+
if (isDigitOrN(c)) {
|
|
3456
|
+
const value = this.number();
|
|
3457
|
+
return { kind: "number", start, end: this.pos, value };
|
|
3458
|
+
}
|
|
3459
|
+
return void 0;
|
|
3460
|
+
}
|
|
3461
|
+
number() {
|
|
3462
|
+
let digits = "";
|
|
3463
|
+
while (isDigitOrN(this.s[this.pos])) {
|
|
3464
|
+
const ch = this.s[this.pos++];
|
|
3465
|
+
digits += ch === "n" ? "0" : ch;
|
|
3466
|
+
}
|
|
3467
|
+
if (digits.length === 0) this.fail(`expected a number at '${this.s[this.pos]}'`);
|
|
3468
|
+
return parseInt(digits, 10);
|
|
3469
|
+
}
|
|
3470
|
+
operation() {
|
|
3471
|
+
const rest = this.s.slice(this.pos);
|
|
3472
|
+
const op = ["reroll", "**", "//", "~+", "ac", "dc", "!", ">", "<", "+", "-", "&", "*", "/", "="].find(
|
|
3473
|
+
(token) => rest.startsWith(token)
|
|
3474
|
+
);
|
|
3475
|
+
if (op !== void 0) this.pos += op.length;
|
|
3476
|
+
return op;
|
|
3477
|
+
}
|
|
3478
|
+
};
|
|
3479
|
+
function atomHasDice(atom) {
|
|
3480
|
+
switch (atom.kind) {
|
|
3481
|
+
case "die":
|
|
3482
|
+
return true;
|
|
3483
|
+
case "number":
|
|
3484
|
+
return false;
|
|
3485
|
+
case "keep":
|
|
3486
|
+
return atomHasDice(atom.inner);
|
|
3487
|
+
case "group":
|
|
3488
|
+
return chainHasDice(atom.expr.first) || atom.expr.rest.some(({ op, arg }) => op !== "reroll" && arg !== void 0 && chainHasDice(arg));
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
var chainHasDice = (chain) => chain.atoms.some(atomHasDice);
|
|
3492
|
+
var isSingleDieAtom = (atom) => atom.kind === "die" || atom.kind === "group" && isSingleDieExpr(atom.expr, atom.expr.rest.length);
|
|
3493
|
+
function isSingleDieExpr(expr, opCount) {
|
|
3494
|
+
const operands = [expr.first];
|
|
3495
|
+
for (const { op, arg } of expr.rest.slice(0, opCount)) {
|
|
3496
|
+
if (!isPerDieOp(op)) return false;
|
|
3497
|
+
if (op !== "reroll" && arg !== void 0) operands.push(arg);
|
|
3498
|
+
}
|
|
3499
|
+
let dice = 0;
|
|
3500
|
+
for (const chain of operands) {
|
|
3501
|
+
if (chain.atoms.length === 1 && isSingleDieAtom(chain.atoms[0])) dice++;
|
|
3502
|
+
else if (chainHasDice(chain)) return false;
|
|
3503
|
+
}
|
|
3504
|
+
return dice === 1;
|
|
3505
|
+
}
|
|
3506
|
+
function scaleParsedDice(expression, scale) {
|
|
3507
|
+
let cleaned = "";
|
|
3508
|
+
const original = [];
|
|
3509
|
+
for (let i = 0; i < expression.length; i++) {
|
|
3510
|
+
if (expression[i] === " ") continue;
|
|
3511
|
+
cleaned += expression[i].toLowerCase();
|
|
3512
|
+
original.push(i);
|
|
3513
|
+
}
|
|
3514
|
+
const root = new DiceTermReader(cleaned, expression).read();
|
|
3515
|
+
const edits = [];
|
|
3516
|
+
const span = (start, end) => ({ from: original[start], to: original[end - 1] + 1 });
|
|
3517
|
+
const wrap = (start, end, open) => {
|
|
3518
|
+
const { from, to } = span(start, end);
|
|
3519
|
+
edits.push({ from, to: from, text: open }, { from: to, to, text: ")" });
|
|
3520
|
+
};
|
|
3521
|
+
const source = (start, end) => {
|
|
3522
|
+
const { from, to } = span(start, end);
|
|
3523
|
+
return expression.slice(from, to);
|
|
3524
|
+
};
|
|
3525
|
+
const ambiguous = [];
|
|
3526
|
+
const keepReading = 'has no single doubled meaning (only keep-highest-of-1, "roll it N times, keep the best", doubles its dice inside each trial)';
|
|
3527
|
+
const noteKeep = (keep, trials) => {
|
|
3528
|
+
if (keep.mode === "h" && keep.kept === 1 || !atomHasDice(keep.inner)) return;
|
|
3529
|
+
ambiguous.push(`the keep \`${source(trials?.kind === "number" ? trials.start : keep.start, keep.inner.start)}\` ${keepReading}`);
|
|
3530
|
+
};
|
|
3531
|
+
const scaleKept = (inner) => {
|
|
3532
|
+
if (isSingleDieAtom(inner)) wrap(inner.start, inner.end, `(${scale}`);
|
|
3533
|
+
else if (inner.kind === "group") scaleExpr(inner.expr);
|
|
3534
|
+
else if (inner.kind === "keep") {
|
|
3535
|
+
noteKeep(inner);
|
|
3536
|
+
scaleKept(inner.inner);
|
|
3537
|
+
}
|
|
3538
|
+
};
|
|
3539
|
+
const scaleChain = (chain) => {
|
|
3540
|
+
const { atoms } = chain;
|
|
3541
|
+
if (atoms.length === 0) return;
|
|
3542
|
+
const last = atoms[atoms.length - 1];
|
|
3543
|
+
const counts = atoms.slice(0, -1);
|
|
3544
|
+
if (counts.some(atomHasDice)) {
|
|
3545
|
+
throw new UndoubleableExpressionError(
|
|
3546
|
+
`Cannot double the dice of "${expression}": a dice-valued repeat count (like d4d6) has no single dice term to double.`
|
|
3547
|
+
);
|
|
3548
|
+
}
|
|
3549
|
+
if (last.kind === "keep") {
|
|
3550
|
+
noteKeep(last, counts[counts.length - 1]);
|
|
3551
|
+
scaleKept(last.inner);
|
|
3552
|
+
} else if (isSingleDieAtom(last)) {
|
|
3553
|
+
const count = counts[counts.length - 1];
|
|
3554
|
+
if (count?.kind === "number") {
|
|
3555
|
+
edits.push({ ...span(count.start, count.end), text: String(count.value * scale) });
|
|
3556
|
+
} else {
|
|
3557
|
+
const at = original[last.start];
|
|
3558
|
+
edits.push({ from: at, to: at, text: String(scale) });
|
|
3559
|
+
}
|
|
3560
|
+
} else if (last.kind === "group") {
|
|
3561
|
+
scaleExpr(last.expr);
|
|
3562
|
+
}
|
|
3563
|
+
};
|
|
3564
|
+
function scaleExpr(expr) {
|
|
3565
|
+
let unitOps = 0;
|
|
3566
|
+
for (let i = 1; i <= expr.rest.length && isPerDieOp(expr.rest[i - 1].op); i++) {
|
|
3567
|
+
if (isSingleDieExpr(expr, i)) unitOps = i;
|
|
3568
|
+
}
|
|
3569
|
+
if (unitOps > 0) wrap(expr.first.start, expr.rest[unitOps - 1].end, `${scale}(`);
|
|
3570
|
+
else scaleChain(expr.first);
|
|
3571
|
+
let leftHasDice = unitOps > 0 || chainHasDice(expr.first);
|
|
3572
|
+
for (const { op, arg, end } of expr.rest.slice(unitOps)) {
|
|
3573
|
+
if (op === "reroll" || arg === void 0) continue;
|
|
3574
|
+
const argHasDice = chainHasDice(arg);
|
|
3575
|
+
if (op === "<" && leftHasDice && argHasDice) {
|
|
3576
|
+
ambiguous.push(`the lower of two dice terms \`${source(expr.first.start, end)}\` ${keepReading}`);
|
|
3577
|
+
}
|
|
3578
|
+
if (op === "&" && (leftHasDice || argHasDice)) {
|
|
3579
|
+
ambiguous.push(
|
|
3580
|
+
`the mix \`${source(expr.first.start, end)}\` has no single doubled meaning (an \`&\` weights each side by its count of outcomes, so doubling a side's dice also changes its share of the mix)`
|
|
3581
|
+
);
|
|
3582
|
+
}
|
|
3583
|
+
leftHasDice || (leftHasDice = argHasDice);
|
|
3584
|
+
scaleChain(arg);
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
scaleExpr(root);
|
|
3588
|
+
if (ambiguous.length > 0) {
|
|
3589
|
+
throw new AmbiguousCritDoublingError(
|
|
3590
|
+
`Cannot double the dice of "${expression}" on a crit: ${ambiguous[0]}. Give the crit explicitly: onCrit(...) on an attack, critDamage on a rider, or a crit (...) clause.`
|
|
3591
|
+
);
|
|
3592
|
+
}
|
|
3593
|
+
let result = expression;
|
|
3594
|
+
for (const { from, to, text } of edits.sort((a, b) => b.from - a.from)) {
|
|
3595
|
+
result = result.slice(0, from) + text + result.slice(to);
|
|
3596
|
+
}
|
|
3597
|
+
return result;
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3153
3600
|
// src/parser/parser.ts
|
|
3154
3601
|
var MAX_DIE_SIDES = 1e6;
|
|
3155
3602
|
var MAX_DICE_COUNT = 1e4;
|
|
3156
|
-
var
|
|
3157
|
-
var
|
|
3158
|
-
var
|
|
3159
|
-
function setCachingEnabled(enabled) {
|
|
3160
|
-
cachingEnabled = enabled;
|
|
3161
|
-
if (!enabled) clearParserCache();
|
|
3162
|
-
}
|
|
3163
|
-
function getCachingEnabled() {
|
|
3164
|
-
return cachingEnabled;
|
|
3165
|
-
}
|
|
3603
|
+
var MAX_KEEP_WORK = 1e8;
|
|
3604
|
+
var MAX_EXACT_COUNT = Number.MAX_SAFE_INTEGER;
|
|
3605
|
+
var parseCache = PMF.createCache(1e3);
|
|
3166
3606
|
function clearParserCache() {
|
|
3167
3607
|
parseCache.clear();
|
|
3168
3608
|
}
|
|
3169
3609
|
function parse(expression, n = 0) {
|
|
3170
3610
|
const cleaned = expression.replace(/ /g, "").toLowerCase();
|
|
3171
|
-
if (
|
|
3611
|
+
if (getCachingEnabled()) {
|
|
3172
3612
|
const cacheKey = `${cleaned}:${n}`;
|
|
3173
3613
|
const cached = parseCache.get(cacheKey);
|
|
3174
3614
|
if (cached) return cached;
|
|
@@ -3191,8 +3631,21 @@ function parse(expression, n = 0) {
|
|
|
3191
3631
|
{ expression }
|
|
3192
3632
|
);
|
|
3193
3633
|
}
|
|
3634
|
+
const total = result.total();
|
|
3635
|
+
if (total === 0) {
|
|
3636
|
+
throw new DiceParseError(
|
|
3637
|
+
`Cannot parse dice expression [${expression}]: it has no outcomes (a d0 has no faces; it is only a reroll set, as in \`reroll d0\`)`,
|
|
3638
|
+
{ expression }
|
|
3639
|
+
);
|
|
3640
|
+
}
|
|
3641
|
+
if (!Number.isFinite(total)) {
|
|
3642
|
+
throw new DiceParseError(
|
|
3643
|
+
`Cannot parse dice expression [${expression}]: its outcome counts overflow (too many dice combined to count exactly)`,
|
|
3644
|
+
{ expression }
|
|
3645
|
+
);
|
|
3646
|
+
}
|
|
3194
3647
|
const resultPMF = result.toPMF(-1);
|
|
3195
|
-
if (
|
|
3648
|
+
if (getCachingEnabled()) {
|
|
3196
3649
|
const cacheKey = `${cleaned}:${n}`;
|
|
3197
3650
|
parseCache.set(cacheKey, resultPMF);
|
|
3198
3651
|
}
|
|
@@ -3211,61 +3664,75 @@ function subtractCounts(a, b) {
|
|
|
3211
3664
|
for (const [key, value] of b.getFaceEntries()) result.increment(key, -value);
|
|
3212
3665
|
return result;
|
|
3213
3666
|
}
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3667
|
+
var HIT_ONLY_OPS = /* @__PURE__ */ new Set([
|
|
3668
|
+
Dice.prototype.addNonZero,
|
|
3669
|
+
Dice.prototype.conditionalApply,
|
|
3670
|
+
Dice.prototype.multiply,
|
|
3671
|
+
Dice.prototype.divideRoundUp,
|
|
3672
|
+
Dice.prototype.divideRoundDown
|
|
3673
|
+
]);
|
|
3674
|
+
var GATE_OPS = /* @__PURE__ */ new Set([Dice.prototype.ac, Dice.prototype.dc]);
|
|
3675
|
+
function lastGateAt(arr) {
|
|
3676
|
+
let depth = 0;
|
|
3677
|
+
let at;
|
|
3678
|
+
for (let i = 0; i < arr.length - 1; i++) {
|
|
3679
|
+
const c = arr[i];
|
|
3680
|
+
if (c === "(") depth++;
|
|
3681
|
+
else if (c === ")") {
|
|
3682
|
+
if (depth === 0) break;
|
|
3683
|
+
depth--;
|
|
3684
|
+
} else if (depth === 0 && (c === "a" || c === "d") && arr[i + 1] === "c") {
|
|
3685
|
+
at = arr.length - i;
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
return at;
|
|
3689
|
+
}
|
|
3690
|
+
function parseExpression(arr, n, inCheck = false) {
|
|
3691
|
+
const gate = lastGateAt(arr);
|
|
3692
|
+
const buildsCheck = () => inCheck || gate !== void 0 && arr.length > gate;
|
|
3693
|
+
const readOperation = () => {
|
|
3694
|
+
const checkTerm = buildsCheck();
|
|
3695
|
+
const parsed = parseOperation(arr);
|
|
3696
|
+
return parsed === Dice.prototype.addNonZero && checkTerm ? Dice.prototype.add : parsed;
|
|
3697
|
+
};
|
|
3698
|
+
const first = parseArgument(arr, n, buildsCheck());
|
|
3699
|
+
let finalResult = typeof first === "number" ? Dice.scalar(first) : first;
|
|
3700
|
+
if (typeof first === "number") finalResult.privateData.noDie = true;
|
|
3701
|
+
let opText = finalResult.privateData.implicitCrit ? arr.join("") : void 0;
|
|
3702
|
+
let op = readOperation();
|
|
3223
3703
|
while (op != null) {
|
|
3224
|
-
const
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
const natMaxSlice = bonusOnly.add(baseDieMeta.sides);
|
|
3233
|
-
const restSlice = subtractCounts(finalResult, natMaxSlice);
|
|
3234
|
-
const gatedNatMaxSlice = natMaxSlice.ac(arg);
|
|
3235
|
-
finalResult = restSlice.ac(arg).combine(gatedNatMaxSlice);
|
|
3236
|
-
finalResult.privateData.checkDie = baseDieMeta;
|
|
3237
|
-
finalResult.privateData.natMaxCritSlice = gatedNatMaxSlice;
|
|
3238
|
-
acAlreadyApplied = true;
|
|
3239
|
-
baseDieMeta = void 0;
|
|
3240
|
-
} else {
|
|
3241
|
-
baseDieMeta = void 0;
|
|
3242
|
-
}
|
|
3243
|
-
}
|
|
3704
|
+
const pending = op === Dice.prototype.conditionalApply && finalResult.privateData.isACCheck ? arr.join("") : void 0;
|
|
3705
|
+
const arg = !op.unary ? parseArgument(arr, n, buildsCheck()) : finalResult;
|
|
3706
|
+
const hitText = pending?.slice(0, pending.length - arr.length);
|
|
3707
|
+
const termText = opText?.slice(0, opText.length - arr.length);
|
|
3708
|
+
const before = finalResult;
|
|
3709
|
+
const attack = isAttack(finalResult);
|
|
3710
|
+
const acCheck = finalResult.privateData.isACCheck === true;
|
|
3711
|
+
if (op === Dice.prototype.combine) assertMixable(before, arg, arr);
|
|
3244
3712
|
let crit;
|
|
3245
3713
|
let critNorm = 1;
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3714
|
+
const critClause = arr[0] === "x" || arr[0] === "c";
|
|
3715
|
+
const implicitCrit = !critClause && hitText !== void 0 && !finalResult.privateData.noDie;
|
|
3716
|
+
if (critClause || implicitCrit) {
|
|
3717
|
+
let count = 1;
|
|
3718
|
+
if (critClause) {
|
|
3719
|
+
const isXcrit = arr[0] === "x";
|
|
3720
|
+
if (isXcrit) assertToken(arr, "x");
|
|
3721
|
+
assertToken(arr, "c");
|
|
3722
|
+
assertToken(arr, "r");
|
|
3723
|
+
assertToken(arr, "i");
|
|
3724
|
+
assertToken(arr, "t");
|
|
3725
|
+
if (isXcrit) count = parseNumber(arr, n);
|
|
3726
|
+
}
|
|
3727
|
+
if (finalResult.privateData.noDie) {
|
|
3728
|
+
parseBinaryArgument(arg, arr, n);
|
|
3258
3729
|
} else {
|
|
3259
|
-
crit =
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
}
|
|
3730
|
+
({ crit, rest: finalResult } = splitCrit(finalResult, count));
|
|
3731
|
+
critNorm = crit.total();
|
|
3732
|
+
const critArg = critClause ? parseBinaryArgument(arg, arr, n) : critPayload(hitText, n);
|
|
3733
|
+
crit = HIT_ONLY_OPS.has(op) ? applyToLanded(crit, op, critArg, crit.get(0), acCheck) : op.call(crit, critArg);
|
|
3734
|
+
critNorm = crit && critNorm ? crit.total() / critNorm : 1;
|
|
3265
3735
|
}
|
|
3266
|
-
critNorm = crit.total();
|
|
3267
|
-
crit = op.call(crit, parseBinaryArgument(arg, arr, n));
|
|
3268
|
-
critNorm = crit && critNorm ? crit.total() / critNorm : 1;
|
|
3269
3736
|
}
|
|
3270
3737
|
let save;
|
|
3271
3738
|
let saveNorm = 1;
|
|
@@ -3274,12 +3741,10 @@ function parseExpression(arr, n) {
|
|
|
3274
3741
|
assertToken(arr, "a");
|
|
3275
3742
|
assertToken(arr, "v");
|
|
3276
3743
|
assertToken(arr, "e");
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
finalResult = finalResult.deleteFace(min);
|
|
3282
|
-
save = op.call(save, parseBinaryArgument(arg, arr, n));
|
|
3744
|
+
const { miss: missed, rest } = splitMiss(finalResult, attack);
|
|
3745
|
+
finalResult = rest;
|
|
3746
|
+
saveNorm = missed.total();
|
|
3747
|
+
save = op.call(missed, parseBinaryArgument(arg, arr, n));
|
|
3283
3748
|
saveNorm = save && saveNorm ? save.total() / saveNorm : 1;
|
|
3284
3749
|
}
|
|
3285
3750
|
let pc;
|
|
@@ -3287,12 +3752,10 @@ function parseExpression(arr, n) {
|
|
|
3287
3752
|
if (arr.length >= 2 && arr[0] === "p" && arr[1] === "c") {
|
|
3288
3753
|
assertToken(arr, "p");
|
|
3289
3754
|
assertToken(arr, "c");
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
finalResult = finalResult.deleteFace(min);
|
|
3295
|
-
pc = op.call(pc, parseBinaryArgument(arg, arr, n)).divideRoundDown(2);
|
|
3755
|
+
const { miss: missed, rest } = splitMiss(finalResult, attack);
|
|
3756
|
+
finalResult = rest;
|
|
3757
|
+
const missBefore = missed.total();
|
|
3758
|
+
pc = op.call(missed, parseBinaryArgument(arg, arr, n)).divideRoundDown(2);
|
|
3296
3759
|
const missAfter = pc ? pc.total() : 0;
|
|
3297
3760
|
pcNorm = missBefore ? missAfter / missBefore : 1;
|
|
3298
3761
|
}
|
|
@@ -3303,118 +3766,416 @@ function parseExpression(arr, n) {
|
|
|
3303
3766
|
assertToken(arr, "i");
|
|
3304
3767
|
assertToken(arr, "s");
|
|
3305
3768
|
assertToken(arr, "s");
|
|
3306
|
-
miss =
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
finalResult = finalResult.deleteFace(min);
|
|
3311
|
-
miss = op.call(miss, parseBinaryArgument(arg, arr, n));
|
|
3769
|
+
const { miss: missed, rest } = splitMiss(finalResult, attack);
|
|
3770
|
+
finalResult = rest;
|
|
3771
|
+
missNorm = missed.total();
|
|
3772
|
+
miss = op.call(missed, parseBinaryArgument(arg, arr, n));
|
|
3312
3773
|
missNorm = miss && missNorm ? miss.total() / missNorm : 1;
|
|
3313
3774
|
}
|
|
3314
3775
|
let norm = finalResult.total();
|
|
3315
|
-
|
|
3776
|
+
const clause = crit !== void 0 || save !== void 0 || pc !== void 0 || miss !== void 0;
|
|
3777
|
+
const labelled = hasOutcomeLabels(finalResult);
|
|
3778
|
+
const operand = finalResult;
|
|
3779
|
+
if (!clause && labelled && HIT_ONLY_OPS.has(op)) {
|
|
3780
|
+
finalResult = applyByOutcome(finalResult, op, arg, termText, n);
|
|
3781
|
+
} else if (HIT_ONLY_OPS.has(op)) {
|
|
3782
|
+
finalResult = applyToLanded(finalResult, op, arg, landedAtZero(finalResult), acCheck);
|
|
3783
|
+
} else {
|
|
3316
3784
|
finalResult = op.call(finalResult, arg);
|
|
3317
3785
|
}
|
|
3786
|
+
if (operand.privateData.isDCCheck && HIT_ONLY_OPS.has(op)) {
|
|
3787
|
+
finalResult.setOutcomeDistribution("saveFail", op.call(operand.deleteFace(0), arg).getFaceMap());
|
|
3788
|
+
}
|
|
3789
|
+
if (attack && HIT_ONLY_OPS.has(op)) {
|
|
3790
|
+
finalResult.privateData.attackPayload = true;
|
|
3791
|
+
const landed = landedHitsAtZero(operand, op, arg, acCheck);
|
|
3792
|
+
if (landed > 0) finalResult.setOutcomeDistribution("hit", { 0: landed });
|
|
3793
|
+
} else if (op === Dice.prototype.combine && typeof arg !== "number") {
|
|
3794
|
+
if (arg.privateData.attackPayload) finalResult.privateData.attackPayload = true;
|
|
3795
|
+
const landed = landedAtZero(operand) + landedAtZero(arg);
|
|
3796
|
+
if (landed > 0) finalResult.setOutcomeDistribution("hit", { 0: landed });
|
|
3797
|
+
}
|
|
3798
|
+
const gated = op === Dice.prototype.combine && typeof arg !== "number" && arg.privateData.isACCheck;
|
|
3799
|
+
if (op === Dice.prototype.ac || gated) finalResult.privateData.isACCheck = true;
|
|
3800
|
+
followNaturalRoll(before, op, arg, finalResult, clause);
|
|
3318
3801
|
norm = norm ? finalResult.total() / norm : 1;
|
|
3319
3802
|
if (crit) {
|
|
3320
|
-
const
|
|
3803
|
+
const result = combineDiceWithNormalization(
|
|
3321
3804
|
crit,
|
|
3322
3805
|
critNorm,
|
|
3323
3806
|
"crit",
|
|
3324
3807
|
norm,
|
|
3325
3808
|
finalResult
|
|
3326
3809
|
);
|
|
3327
|
-
norm =
|
|
3328
|
-
finalResult =
|
|
3810
|
+
norm = result.newNorm;
|
|
3811
|
+
finalResult = result.updatedResult;
|
|
3329
3812
|
}
|
|
3330
3813
|
if (save) {
|
|
3331
|
-
const
|
|
3814
|
+
const result = combineDiceWithNormalization(
|
|
3332
3815
|
save,
|
|
3333
3816
|
saveNorm,
|
|
3334
3817
|
"saveHalf",
|
|
3335
3818
|
norm,
|
|
3336
3819
|
finalResult
|
|
3337
3820
|
);
|
|
3338
|
-
norm =
|
|
3339
|
-
finalResult =
|
|
3821
|
+
norm = result.newNorm;
|
|
3822
|
+
finalResult = result.updatedResult;
|
|
3340
3823
|
}
|
|
3341
3824
|
if (miss) {
|
|
3342
|
-
const
|
|
3825
|
+
const result = combineDiceWithNormalization(
|
|
3343
3826
|
miss,
|
|
3344
3827
|
missNorm,
|
|
3345
3828
|
"missDamage",
|
|
3346
3829
|
norm,
|
|
3347
3830
|
finalResult
|
|
3348
3831
|
);
|
|
3349
|
-
norm =
|
|
3350
|
-
finalResult =
|
|
3832
|
+
norm = result.newNorm;
|
|
3833
|
+
finalResult = result.updatedResult;
|
|
3351
3834
|
}
|
|
3352
3835
|
if (pc) {
|
|
3353
|
-
const
|
|
3836
|
+
const result = combineDiceWithNormalization(
|
|
3354
3837
|
pc,
|
|
3355
3838
|
pcNorm,
|
|
3356
3839
|
"pc",
|
|
3357
3840
|
norm,
|
|
3358
3841
|
finalResult
|
|
3359
3842
|
);
|
|
3360
|
-
norm =
|
|
3361
|
-
finalResult =
|
|
3843
|
+
norm = result.newNorm;
|
|
3844
|
+
finalResult = result.updatedResult;
|
|
3362
3845
|
}
|
|
3363
|
-
|
|
3846
|
+
if (implicitCrit) finalResult.privateData.implicitCrit = { payload: hitText };
|
|
3847
|
+
opText = finalResult.privateData.implicitCrit ? arr.join("") : void 0;
|
|
3848
|
+
op = readOperation();
|
|
3364
3849
|
}
|
|
3365
3850
|
return finalResult;
|
|
3366
3851
|
}
|
|
3367
|
-
function
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3852
|
+
function naturalSides(value) {
|
|
3853
|
+
if (typeof value === "number") return 0;
|
|
3854
|
+
const { critTrack, untrackedSides } = value.privateData;
|
|
3855
|
+
return critTrack ? critTrack.sides : untrackedSides ?? 0;
|
|
3856
|
+
}
|
|
3857
|
+
function outranks(sides, other) {
|
|
3858
|
+
if (sides === other) return false;
|
|
3859
|
+
if (sides === 20 || other === 20) return sides === 20;
|
|
3860
|
+
return sides > other;
|
|
3861
|
+
}
|
|
3862
|
+
function bareTrack(die) {
|
|
3863
|
+
return {
|
|
3864
|
+
sides: die.maxFace(),
|
|
3865
|
+
bare: true,
|
|
3866
|
+
slice: (face) => {
|
|
3867
|
+
const slice = new Dice();
|
|
3868
|
+
const weight = die.get(face);
|
|
3869
|
+
if (weight) slice.setFace(face, weight);
|
|
3870
|
+
return slice;
|
|
3871
|
+
}
|
|
3872
|
+
};
|
|
3873
|
+
}
|
|
3874
|
+
function asValue(value) {
|
|
3875
|
+
if (typeof value !== "number") return value;
|
|
3876
|
+
const scalar = Dice.scalar(value);
|
|
3877
|
+
scalar.privateData.noDie = true;
|
|
3878
|
+
return scalar;
|
|
3879
|
+
}
|
|
3880
|
+
function branchesOf(value) {
|
|
3881
|
+
return typeof value === "number" ? [asValue(value)] : value.privateData.branches ?? [value];
|
|
3882
|
+
}
|
|
3883
|
+
var hasOutcomeLabels = (value) => typeof value !== "number" && Object.keys(value.getFullOutcomeDistribution()).some((label) => label !== "hit");
|
|
3884
|
+
var isSave = (value) => typeof value !== "number" && value.privateData.isDCCheck === true;
|
|
3885
|
+
var isAttack = (value) => value.privateData.isDCCheck !== true && (value.privateData.isACCheck === true || value.privateData.attackPayload === true);
|
|
3886
|
+
var landedAtZero = (value) => typeof value === "number" ? 0 : value.getOutcomeCount("hit", 0);
|
|
3887
|
+
function applyToLanded(value, op, arg, landed, gate) {
|
|
3888
|
+
const lands = op === Dice.prototype.addNonZero || gate && op === Dice.prototype.conditionalApply;
|
|
3889
|
+
if (!(landed > 0) || !lands) return op.call(value, arg);
|
|
3890
|
+
const misses = value.deleteFace(0);
|
|
3891
|
+
const missed = value.get(0) - landed;
|
|
3892
|
+
if (missed > 0) misses.setFace(0, missed);
|
|
3893
|
+
const result = op.call(misses, arg);
|
|
3894
|
+
result.combineInPlace(asValue(arg).normalize(landed));
|
|
3895
|
+
return result;
|
|
3896
|
+
}
|
|
3897
|
+
function applyToAttack(value, op, arg, gate) {
|
|
3898
|
+
const result = applyToLanded(value, op, arg, landedAtZero(value), gate);
|
|
3899
|
+
result.privateData.attackPayload = true;
|
|
3900
|
+
const landed = landedHitsAtZero(value, op, arg, gate);
|
|
3901
|
+
if (landed > 0) result.setOutcomeDistribution("hit", { 0: landed });
|
|
3902
|
+
return result;
|
|
3903
|
+
}
|
|
3904
|
+
function splitMiss(check, attack) {
|
|
3905
|
+
const face = attack ? 0 : check.minFace();
|
|
3906
|
+
const landed = face === 0 ? landedAtZero(check) : 0;
|
|
3907
|
+
const miss = new Dice();
|
|
3908
|
+
miss.increment(face > 0 ? face : 1, check.get(face) - landed);
|
|
3909
|
+
const rest = check.deleteFace(face);
|
|
3910
|
+
if (landed > 0) rest.setFace(0, landed);
|
|
3911
|
+
return { miss, rest };
|
|
3912
|
+
}
|
|
3913
|
+
function assertMixable(left, right, rest) {
|
|
3914
|
+
if (hasOutcomeLabels(left) || hasOutcomeLabels(right)) {
|
|
3915
|
+
throw new Error(
|
|
3916
|
+
"an `&` mix of an attack already split into crit, miss or save outcomes has no single reading: mix the checks before the payload, like `((d20 AC 10) & (d20 AC 15)) * (1d6)`"
|
|
3917
|
+
);
|
|
3918
|
+
}
|
|
3919
|
+
if (isSave(left) !== isSave(right)) {
|
|
3920
|
+
throw new Error("an `&` mix of a saving throw (DC) with anything but another saving throw has no single set of outcomes");
|
|
3921
|
+
}
|
|
3922
|
+
const [next, after] = rest;
|
|
3923
|
+
if (next === "x" || next === "c" || next === "s" || next === "m" || next === "p" && after === "c") {
|
|
3924
|
+
throw new Error("a crit, save, pc or miss clause on an `&` mix has no single reading: put it after the payload's `*`");
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
function followNaturalRoll(before, op, arg, after, clause) {
|
|
3928
|
+
const data = after.privateData;
|
|
3929
|
+
delete data.critTrack;
|
|
3930
|
+
delete data.untrackedSides;
|
|
3931
|
+
delete data.noDie;
|
|
3932
|
+
delete data.branches;
|
|
3933
|
+
const gate = GATE_OPS.has(op);
|
|
3934
|
+
if (!clause && before.privateData.noDie && (gate || typeof arg === "number" || arg.privateData.noDie)) {
|
|
3935
|
+
data.noDie = true;
|
|
3936
|
+
}
|
|
3937
|
+
const mixed = before.privateData.branches !== void 0 || !gate && typeof arg !== "number" && arg.privateData.branches !== void 0;
|
|
3938
|
+
if (!clause && op === Dice.prototype.combine) {
|
|
3939
|
+
data.branches = [...branchesOf(before), ...branchesOf(arg)];
|
|
3940
|
+
} else if (!clause && mixed && op !== Dice.prototype.reroll) {
|
|
3941
|
+
const advantage = op === Dice.prototype.advantage;
|
|
3942
|
+
const pairOp = advantage ? Dice.prototype.max : op;
|
|
3943
|
+
const lefts = branchesOf(before);
|
|
3944
|
+
const rights = advantage ? lefts : gate ? [asValue(arg)] : branchesOf(arg);
|
|
3945
|
+
data.branches = lefts.flatMap(
|
|
3946
|
+
(left) => rights.map((right) => {
|
|
3947
|
+
const part = HIT_ONLY_OPS.has(pairOp) && isAttack(left) ? applyToAttack(left, pairOp, right, left.privateData.isACCheck === true) : pairOp.call(left, right);
|
|
3948
|
+
followNaturalRoll(left, pairOp, right, part, false);
|
|
3949
|
+
return part;
|
|
3950
|
+
})
|
|
3951
|
+
);
|
|
3952
|
+
}
|
|
3953
|
+
if (data.branches) {
|
|
3954
|
+
mixNaturalRolls(after, data.branches);
|
|
3955
|
+
return;
|
|
3956
|
+
}
|
|
3957
|
+
const track = before.privateData.critTrack;
|
|
3958
|
+
const argTrack = typeof arg === "number" || gate ? void 0 : arg.privateData.critTrack;
|
|
3959
|
+
const sides = naturalSides(before);
|
|
3960
|
+
const argSides = gate ? 0 : naturalSides(arg);
|
|
3961
|
+
const extremum = op === Dice.prototype.max || op === Dice.prototype.min;
|
|
3962
|
+
const own = track !== void 0 && outranks(sides, argSides);
|
|
3963
|
+
const followed = own ? track : argTrack && outranks(argSides, sides) ? argTrack : void 0;
|
|
3964
|
+
if (clause) ; else if (op === Dice.prototype.advantage || op === Dice.prototype.reroll) {
|
|
3965
|
+
if (track?.bare) data.critTrack = bareTrack(after);
|
|
3966
|
+
} else if (extremum && track?.bare && argTrack?.bare && sides === argSides) {
|
|
3967
|
+
data.critTrack = bareTrack(after);
|
|
3968
|
+
} else if (followed) {
|
|
3969
|
+
const attack = HIT_ONLY_OPS.has(op) && isAttack(before);
|
|
3970
|
+
const gated = before.privateData.isACCheck === true;
|
|
3971
|
+
const call = (value, other) => attack ? applyToAttack(value, op, other, gated) : op.call(value, other);
|
|
3972
|
+
const apply = own ? (value) => call(value, arg) : (value) => call(before, value);
|
|
3973
|
+
const step = extremum ? (slice) => keptPart(slice, apply) : apply;
|
|
3974
|
+
data.critTrack = { sides: followed.sides, bare: false, slice: (face) => step(followed.slice(face)) };
|
|
3975
|
+
}
|
|
3976
|
+
if (!data.critTrack) {
|
|
3977
|
+
const top = outranks(argSides, sides) ? argSides : sides;
|
|
3978
|
+
if (top > 0) data.untrackedSides = top;
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3981
|
+
function mixNaturalRolls(after, branches) {
|
|
3982
|
+
const sides = branches.reduce((top, branch) => outranks(naturalSides(branch), top) ? naturalSides(branch) : top, 0);
|
|
3983
|
+
if (sides === 0) return;
|
|
3984
|
+
const tracks = [];
|
|
3985
|
+
for (const branch of branches) {
|
|
3986
|
+
if (naturalSides(branch) !== sides) continue;
|
|
3987
|
+
const track = branch.privateData.critTrack;
|
|
3988
|
+
if (!track) {
|
|
3989
|
+
after.privateData.untrackedSides = sides;
|
|
3990
|
+
return;
|
|
3991
|
+
}
|
|
3992
|
+
tracks.push(track);
|
|
3993
|
+
}
|
|
3994
|
+
after.privateData.critTrack = {
|
|
3995
|
+
sides,
|
|
3996
|
+
bare: tracks.length === branches.length && tracks.every((track) => track.bare),
|
|
3997
|
+
slice: (face) => {
|
|
3998
|
+
const slice = new Dice();
|
|
3999
|
+
let landed = 0;
|
|
4000
|
+
for (const track of tracks) {
|
|
4001
|
+
const part = track.slice(face);
|
|
4002
|
+
slice.combineInPlace(part);
|
|
4003
|
+
landed += landedAtZero(part);
|
|
4004
|
+
}
|
|
4005
|
+
if (landed > 0) slice.setOutcomeDistribution("hit", { 0: landed });
|
|
4006
|
+
return slice;
|
|
4007
|
+
}
|
|
4008
|
+
};
|
|
4009
|
+
}
|
|
4010
|
+
function keptPart(slice, apply) {
|
|
4011
|
+
const result = new Dice();
|
|
4012
|
+
for (const [value, count] of slice.getFaceEntries()) {
|
|
4013
|
+
const face = new Dice();
|
|
4014
|
+
face.setFace(value, count);
|
|
4015
|
+
const kept = apply(face).get(value);
|
|
4016
|
+
if (kept) result.increment(value, kept);
|
|
4017
|
+
}
|
|
4018
|
+
return result;
|
|
4019
|
+
}
|
|
4020
|
+
function splitCrit(check, count) {
|
|
4021
|
+
if (count === 0) {
|
|
4022
|
+
const none = new Dice();
|
|
4023
|
+
const rest2 = subtractCounts(check, none);
|
|
4024
|
+
if (landedAtZero(check) > 0) rest2.setOutcomeDistribution("hit", { 0: landedAtZero(check) });
|
|
4025
|
+
return { crit: none, rest: rest2 };
|
|
4026
|
+
}
|
|
4027
|
+
const track = check.privateData.critTrack;
|
|
4028
|
+
if (!track) {
|
|
4029
|
+
throw new Error(
|
|
4030
|
+
"crit rate cannot be computed exactly for this attack check: its natural roll is not one die (a dice-valued check like 2d20 or 2kh2d20, two d20s like d20 + d20, or advantage over a total). Build the attack with the builder API instead."
|
|
4031
|
+
);
|
|
4032
|
+
}
|
|
4033
|
+
const { sides } = track;
|
|
4034
|
+
if (count > sides) {
|
|
4035
|
+
throw new Error(`xcrit${count} is wider than the d${sides} it reads its natural roll from`);
|
|
4036
|
+
}
|
|
4037
|
+
let crit = new Dice();
|
|
4038
|
+
let landed = 0;
|
|
4039
|
+
for (let face = sides; face > sides - count; face--) {
|
|
4040
|
+
const slice = track.slice(face);
|
|
4041
|
+
crit.combineInPlace(slice);
|
|
4042
|
+
landed += landedAtZero(slice);
|
|
4043
|
+
}
|
|
4044
|
+
const rest = subtractCounts(check, crit);
|
|
4045
|
+
const missed = crit.get(0) - landed;
|
|
4046
|
+
if (missed) {
|
|
4047
|
+
crit = crit.deleteFace(0);
|
|
4048
|
+
if (landed > 0) crit.setFace(0, landed);
|
|
4049
|
+
rest.increment(0, missed);
|
|
4050
|
+
}
|
|
4051
|
+
const restLanded = landedAtZero(check) - landed;
|
|
4052
|
+
if (restLanded > 0) rest.setOutcomeDistribution("hit", { 0: restLanded });
|
|
4053
|
+
return { crit, rest };
|
|
4054
|
+
}
|
|
4055
|
+
function critPayload(text, n) {
|
|
4056
|
+
let doubled = text;
|
|
4057
|
+
try {
|
|
4058
|
+
doubled = scaleParsedDice(text.replace(/n/g, String(n)), 2);
|
|
4059
|
+
} catch (error) {
|
|
4060
|
+
if (!(error instanceof UndoubleableExpressionError)) throw error;
|
|
4061
|
+
}
|
|
4062
|
+
const chars = [...doubled];
|
|
4063
|
+
const payload = parseExpression(chars, n);
|
|
4064
|
+
if (chars.length > 0) {
|
|
4065
|
+
throw new Error(`Unexpected token '${chars[0]}' in the crit payload '${doubled}'`);
|
|
4066
|
+
}
|
|
4067
|
+
return payload;
|
|
4068
|
+
}
|
|
4069
|
+
var PAYLOAD_OUTCOMES = {
|
|
4070
|
+
crit: true,
|
|
4071
|
+
missDamage: true,
|
|
4072
|
+
pc: true,
|
|
4073
|
+
saveFail: true,
|
|
4074
|
+
saveHalf: true
|
|
4075
|
+
};
|
|
4076
|
+
function applyByOutcome(labelled, op, arg, termText, n) {
|
|
4077
|
+
const implicit = labelled.privateData.implicitCrit;
|
|
4078
|
+
const argTotal = typeof arg === "number" ? 1 : arg.total();
|
|
4079
|
+
const result = new Dice();
|
|
4080
|
+
let rest = labelled;
|
|
4081
|
+
let payload;
|
|
4082
|
+
for (const [label, distribution] of Object.entries(labelled.getFullOutcomeDistribution())) {
|
|
4083
|
+
if (label === "hit" || distribution === void 0) continue;
|
|
4084
|
+
const part = new Dice();
|
|
4085
|
+
for (const [face, count] of Object.entries(distribution)) part.increment(Number(face), count);
|
|
4086
|
+
rest = subtractCounts(rest, part);
|
|
4087
|
+
let applied;
|
|
4088
|
+
if (label === "crit" && implicit && termText !== void 0) {
|
|
4089
|
+
payload = implicit.payload + (op === Dice.prototype.addNonZero ? "~" : "") + termText;
|
|
4090
|
+
const doubled = critPayload(payload, n);
|
|
4091
|
+
applied = doubled.normalize(part.total() * argTotal / doubled.total());
|
|
4092
|
+
} else if (PAYLOAD_OUTCOMES[label] === true) {
|
|
4093
|
+
applied = applyToLanded(part, op, arg, part.get(0), false);
|
|
4094
|
+
} else {
|
|
4095
|
+
applied = op.call(part, arg);
|
|
4096
|
+
}
|
|
4097
|
+
result.combineInPlace(applied);
|
|
4098
|
+
result.setOutcomeDistribution(label, applied.getFaceMap());
|
|
4099
|
+
}
|
|
4100
|
+
result.combineInPlace(applyToLanded(rest, op, arg, landedAtZero(labelled), false));
|
|
4101
|
+
if (labelled.privateData.isDCCheck) result.privateData.isDCCheck = true;
|
|
4102
|
+
if (payload !== void 0) result.privateData.implicitCrit = { payload };
|
|
4103
|
+
return result;
|
|
4104
|
+
}
|
|
4105
|
+
function landedHitsAtZero(operand, op, arg, gate) {
|
|
4106
|
+
let landed = 0;
|
|
4107
|
+
for (const [face, count] of Object.entries(operand.calculateHitDistribution())) {
|
|
4108
|
+
if (!(count > 0)) continue;
|
|
4109
|
+
const hit = new Dice();
|
|
4110
|
+
hit.setFace(Number(face), count);
|
|
4111
|
+
landed += applyToLanded(hit, op, arg, Number(face) === 0 ? count : 0, gate).get(0);
|
|
4112
|
+
}
|
|
4113
|
+
return landed;
|
|
4114
|
+
}
|
|
4115
|
+
function parseArgument(s, n, inCheck = false) {
|
|
4116
|
+
if (s[0] === "-") {
|
|
4117
|
+
s.shift();
|
|
4118
|
+
const operand = parseArgument(s, n, inCheck);
|
|
4119
|
+
if (typeof operand === "number") return 0 - operand;
|
|
4120
|
+
const zero = asValue(0);
|
|
4121
|
+
const negated = zero.subtract(operand);
|
|
4122
|
+
followNaturalRoll(zero, Dice.prototype.subtract, operand, negated, false);
|
|
4123
|
+
return negated;
|
|
4124
|
+
}
|
|
4125
|
+
let result = parseArgumentInternal(s, n, inCheck);
|
|
4126
|
+
if (result === void 0) {
|
|
4127
|
+
const at = s.length === 0 ? "the end of the expression" : `'${s.slice(0, 20).join("")}'`;
|
|
4128
|
+
throw new Error(`Expected a number, a die, a keep or '(' at ${at}`);
|
|
4129
|
+
}
|
|
4130
|
+
for (let next = parseArgumentInternal(s, n, inCheck); next !== void 0; next = parseArgumentInternal(s, n, inCheck)) {
|
|
3372
4131
|
result = multiplyDiceByDice(result, next);
|
|
3373
4132
|
}
|
|
3374
4133
|
return result;
|
|
3375
4134
|
}
|
|
3376
4135
|
function multiplyDiceByDice(d1, d2) {
|
|
4136
|
+
const noDie = (typeof d1 === "number" || d1.privateData.noDie) && (typeof d2 === "number" || d2.privateData.noDie);
|
|
3377
4137
|
if (typeof d1 === "number") d1 = Dice.scalar(d1);
|
|
3378
4138
|
if (typeof d2 === "number") d2 = Dice.scalar(d2);
|
|
3379
4139
|
const result = new Dice();
|
|
3380
4140
|
const faces = /* @__PURE__ */ new Map();
|
|
3381
|
-
let
|
|
4141
|
+
let common = 1;
|
|
4142
|
+
const { keep } = d2.privateData;
|
|
3382
4143
|
for (const key of d1.keys()) {
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
continue;
|
|
3386
|
-
}
|
|
3387
|
-
if (d2.privateData.keep) {
|
|
3388
|
-
const faceCount = d2.keys().length;
|
|
3389
|
-
if (Math.pow(faceCount, key) > MAX_KEEP_OUTCOMES) {
|
|
3390
|
-
throw new DiceParseError(
|
|
3391
|
-
`Keep enumeration of ${faceCount}^${key} outcomes exceeds the maximum of ${MAX_KEEP_OUTCOMES}`
|
|
3392
|
-
);
|
|
3393
|
-
}
|
|
3394
|
-
const repeat = Array(key).fill(d2);
|
|
3395
|
-
face = opDice(repeat, d2.privateData.keep);
|
|
3396
|
-
} else {
|
|
3397
|
-
face = multiplyDice(key, d2);
|
|
3398
|
-
}
|
|
3399
|
-
normalizationFactor *= face.total();
|
|
4144
|
+
const face = keep ? keepDice(d2, key, keep) : multiplyDice(key, d2);
|
|
4145
|
+
common *= face.total();
|
|
3400
4146
|
faces.set(key, face);
|
|
3401
4147
|
}
|
|
4148
|
+
const exact = common <= MAX_EXACT_COUNT;
|
|
3402
4149
|
for (const [k, face] of faces) {
|
|
3403
4150
|
const count = d1.get(k);
|
|
3404
|
-
result.combineInPlace(
|
|
3405
|
-
face.normalize(count * normalizationFactor / face.total())
|
|
3406
|
-
);
|
|
4151
|
+
result.combineInPlace(face.normalize((exact ? common : 1) * count / face.total()));
|
|
3407
4152
|
}
|
|
3408
4153
|
result.privateData.except = {};
|
|
4154
|
+
const [only, ...more] = d1.keys();
|
|
4155
|
+
const { critTrack } = d2.privateData;
|
|
4156
|
+
const rolls = keep === void 0 ? only : Math.min(only, keep.kept);
|
|
4157
|
+
const sides = outranks(naturalSides(d2), naturalSides(d1)) ? naturalSides(d2) : naturalSides(d1);
|
|
4158
|
+
if (rolls === 1 && more.length === 0 && critTrack?.bare) {
|
|
4159
|
+
result.privateData.critTrack = bareTrack(result);
|
|
4160
|
+
} else if (sides > 0) {
|
|
4161
|
+
result.privateData.untrackedSides = sides;
|
|
4162
|
+
}
|
|
4163
|
+
if (noDie) result.privateData.noDie = true;
|
|
3409
4164
|
return result;
|
|
3410
4165
|
}
|
|
3411
|
-
function
|
|
4166
|
+
function assertRepeatCount(n) {
|
|
4167
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
4168
|
+
throw new DiceParseError(`A repeat count must be a whole number of 0 or more; this one can be ${n}`);
|
|
4169
|
+
}
|
|
3412
4170
|
if (n > MAX_DICE_COUNT) {
|
|
3413
4171
|
throw new DiceParseError(
|
|
3414
4172
|
`Dice count ${n} exceeds the maximum of ${MAX_DICE_COUNT}`
|
|
3415
4173
|
);
|
|
3416
4174
|
}
|
|
3417
|
-
|
|
4175
|
+
}
|
|
4176
|
+
function multiplyDice(n, d) {
|
|
4177
|
+
assertRepeatCount(n);
|
|
4178
|
+
if (n === 0) return Dice.scalar(0);
|
|
3418
4179
|
if (n === 1) return d;
|
|
3419
4180
|
const half = Math.floor(n / 2);
|
|
3420
4181
|
let result = multiplyDice(half, d);
|
|
@@ -3422,43 +4183,70 @@ function multiplyDice(n, d) {
|
|
|
3422
4183
|
if (n % 2 === 1) {
|
|
3423
4184
|
result = result.add(d);
|
|
3424
4185
|
}
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
function opDice(diceList, keepFn) {
|
|
3428
|
-
return opDiceInternal(diceList, new Dice(), 0, [], 1, keepFn);
|
|
4186
|
+
const total = result.total();
|
|
4187
|
+
return total > MAX_EXACT_COUNT ? result.normalize(1 / total) : result;
|
|
3429
4188
|
}
|
|
3430
|
-
function
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
const
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
values,
|
|
3442
|
-
weight * currentDice.get(face),
|
|
3443
|
-
combineFn
|
|
4189
|
+
function keepDice(die, count, { kept, lowest }) {
|
|
4190
|
+
assertRepeatCount(count);
|
|
4191
|
+
if (kept >= count) return multiplyDice(count, die);
|
|
4192
|
+
if (kept <= 0) return Dice.scalar(0);
|
|
4193
|
+
const faces = die.getFaceEntries().filter(([, weight]) => weight > 0).sort(([a], [b]) => lowest ? a - b : b - a);
|
|
4194
|
+
if (faces.length === 0) return new Dice();
|
|
4195
|
+
const span = Math.abs(faces[faces.length - 1][0] - faces[0][0]);
|
|
4196
|
+
const work = faces.length * kept * kept * (kept * span + 1);
|
|
4197
|
+
if (work > MAX_KEEP_WORK) {
|
|
4198
|
+
throw new DiceParseError(
|
|
4199
|
+
`Keep of ${kept} of ${count} copies of a ${faces.length}-face roll exceeds the maximum work of ${MAX_KEEP_WORK}`
|
|
3444
4200
|
);
|
|
3445
|
-
values.pop();
|
|
3446
4201
|
}
|
|
4202
|
+
const tails = new Array(faces.length);
|
|
4203
|
+
for (let i = faces.length - 1, tail = 0; i >= 0; i--) tails[i] = tail += faces[i][1];
|
|
4204
|
+
const addTo = (map, key, p) => {
|
|
4205
|
+
map.set(key, (map.get(key) ?? 0) + p);
|
|
4206
|
+
};
|
|
4207
|
+
let states = Array.from({ length: kept }, () => /* @__PURE__ */ new Map());
|
|
4208
|
+
states[0].set(0, 1);
|
|
4209
|
+
const done = /* @__PURE__ */ new Map();
|
|
4210
|
+
faces.forEach(([value, weight], i) => {
|
|
4211
|
+
const q = weight / tails[i];
|
|
4212
|
+
const next = Array.from({ length: kept }, () => /* @__PURE__ */ new Map());
|
|
4213
|
+
states.forEach((sums, placed) => {
|
|
4214
|
+
if (sums.size === 0) return;
|
|
4215
|
+
const left = count - placed;
|
|
4216
|
+
const need = kept - placed;
|
|
4217
|
+
const few = [];
|
|
4218
|
+
let logChoose = 0;
|
|
4219
|
+
for (let c = 0; c < need; c++) {
|
|
4220
|
+
if (c > 0) logChoose += Math.log((left - c + 1) / c);
|
|
4221
|
+
few.push(q >= 1 ? 0 : Math.exp(logChoose + c * Math.log(q) + (left - c) * Math.log1p(-q)));
|
|
4222
|
+
}
|
|
4223
|
+
const enough = Math.max(0, 1 - few.reduce((total, p) => total + p, 0));
|
|
4224
|
+
for (const [sum, p] of sums) {
|
|
4225
|
+
few.forEach((pc, c) => {
|
|
4226
|
+
if (pc > 0) addTo(next[placed + c], sum + c * value, p * pc);
|
|
4227
|
+
});
|
|
4228
|
+
if (enough > 0) addTo(done, sum + need * value, p * enough);
|
|
4229
|
+
}
|
|
4230
|
+
});
|
|
4231
|
+
states = next;
|
|
4232
|
+
});
|
|
4233
|
+
const result = new Dice();
|
|
4234
|
+
for (const [sum, p] of done) result.increment(sum, p);
|
|
3447
4235
|
return result;
|
|
3448
4236
|
}
|
|
3449
|
-
function parseArgumentInternal(s, n) {
|
|
4237
|
+
function parseArgumentInternal(s, n, inCheck = false) {
|
|
3450
4238
|
if (s.length === 0) return;
|
|
3451
4239
|
const c = s[0];
|
|
3452
4240
|
switch (c) {
|
|
3453
4241
|
case "(":
|
|
3454
4242
|
s.shift();
|
|
3455
|
-
return assertToken(s, ")", parseExpression(s, n));
|
|
4243
|
+
return assertToken(s, ")", parseExpression(s, n, inCheck));
|
|
3456
4244
|
case "h":
|
|
3457
4245
|
case "d":
|
|
3458
4246
|
return parseDice(s, n);
|
|
3459
4247
|
case "k":
|
|
3460
4248
|
assertToken(s, "k");
|
|
3461
|
-
return parseKeep(s, n);
|
|
4249
|
+
return parseKeep(s, n, inCheck);
|
|
3462
4250
|
case "n":
|
|
3463
4251
|
return parseNumber(s, n);
|
|
3464
4252
|
default:
|
|
@@ -3502,10 +4290,11 @@ function parseDice(s, n) {
|
|
|
3502
4290
|
);
|
|
3503
4291
|
}
|
|
3504
4292
|
let result = new Dice(sides);
|
|
4293
|
+
if (sides === 0) return result;
|
|
3505
4294
|
if (rerollOne) {
|
|
3506
4295
|
result = result.reroll(1);
|
|
3507
4296
|
}
|
|
3508
|
-
result.privateData.
|
|
4297
|
+
result.privateData.critTrack = bareTrack(result);
|
|
3509
4298
|
return result;
|
|
3510
4299
|
}
|
|
3511
4300
|
function peek(arr, expected) {
|
|
@@ -3533,31 +4322,24 @@ function parseNumber(s, n) {
|
|
|
3533
4322
|
function isDigit(c) {
|
|
3534
4323
|
return c >= "0" && c <= "9";
|
|
3535
4324
|
}
|
|
3536
|
-
function parseKeep(s, n) {
|
|
3537
|
-
let
|
|
4325
|
+
function parseKeep(s, n, inCheck) {
|
|
4326
|
+
let lowest = false;
|
|
3538
4327
|
if (peek(s, "l")) {
|
|
3539
4328
|
assertToken(s, "l");
|
|
3540
|
-
|
|
4329
|
+
lowest = true;
|
|
3541
4330
|
} else if (peek(s, "h")) {
|
|
3542
4331
|
assertToken(s, "h");
|
|
3543
|
-
keepLowest = false;
|
|
3544
4332
|
} else {
|
|
3545
4333
|
return;
|
|
3546
4334
|
}
|
|
3547
|
-
const
|
|
3548
|
-
const result = parseArgumentInternal(s, n);
|
|
4335
|
+
const kept = parseNumber(s, n);
|
|
4336
|
+
const result = parseArgumentInternal(s, n, inCheck);
|
|
3549
4337
|
if (result instanceof Dice) {
|
|
3550
|
-
result.privateData.keep =
|
|
4338
|
+
result.privateData.keep = { kept, lowest };
|
|
3551
4339
|
return result;
|
|
3552
4340
|
}
|
|
3553
4341
|
throw new Error("Expected Dice after keep modifier");
|
|
3554
4342
|
}
|
|
3555
|
-
function keepN(n, low) {
|
|
3556
|
-
return (values) => {
|
|
3557
|
-
const sorted = [...values].sort((a, b) => low ? a - b : b - a);
|
|
3558
|
-
return sorted.slice(0, n).reduce((sum, val) => sum + val, 0);
|
|
3559
|
-
};
|
|
3560
|
-
}
|
|
3561
4343
|
function parseOperation(s) {
|
|
3562
4344
|
switch (s[0]) {
|
|
3563
4345
|
case ")":
|
|
@@ -3700,7 +4482,7 @@ var Mixture = class _Mixture {
|
|
|
3700
4482
|
}
|
|
3701
4483
|
/**
|
|
3702
4484
|
* Add a labeled component with a mixture weight.
|
|
3703
|
-
* Weight can be any positive finite number
|
|
4485
|
+
* Weight can be any positive finite number; only the ratios between weights matter.
|
|
3704
4486
|
*/
|
|
3705
4487
|
add(label, pmf, weight = 1) {
|
|
3706
4488
|
if (!Number.isFinite(weight) || weight <= 0) return this;
|
|
@@ -3708,7 +4490,7 @@ var Mixture = class _Mixture {
|
|
|
3708
4490
|
const p = bin.p;
|
|
3709
4491
|
if (p <= 0) continue;
|
|
3710
4492
|
const add = weight * p;
|
|
3711
|
-
if (!Number.isFinite(add) ||
|
|
4493
|
+
if (!Number.isFinite(add) || add <= 0) continue;
|
|
3712
4494
|
this.totals.set(v, (this.totals.get(v) ?? 0) + add);
|
|
3713
4495
|
const bag = this.labelMass.get(v) ?? {};
|
|
3714
4496
|
bag[label] = (bag[label] ?? 0) + add;
|
|
@@ -3716,27 +4498,35 @@ var Mixture = class _Mixture {
|
|
|
3716
4498
|
}
|
|
3717
4499
|
return this;
|
|
3718
4500
|
}
|
|
4501
|
+
/**
|
|
4502
|
+
* The normalized mixture. Each bin's `p` and per-label `count` are its raw mass divided by
|
|
4503
|
+
* the grand total, so labels sum to `p` whatever the weights summed to. Outcomes below
|
|
4504
|
+
* `eps` of the total (the pruning `eps` given to the constructor) are dropped first.
|
|
4505
|
+
*
|
|
4506
|
+
* @param eps Epsilon carried by the built PMF.
|
|
4507
|
+
*/
|
|
3719
4508
|
buildPMF(eps = EPS) {
|
|
3720
|
-
|
|
3721
|
-
let c = 0;
|
|
3722
|
-
for (const m of this.totals.values()) {
|
|
3723
|
-
const y = m - c;
|
|
3724
|
-
const t = grand + y;
|
|
3725
|
-
c = t - grand - y;
|
|
3726
|
-
grand = t;
|
|
3727
|
-
}
|
|
4509
|
+
const grand = kahanSum(this.totals.values());
|
|
3728
4510
|
if (!(grand > 0)) throw new Error("Mixture: zero total mass");
|
|
4511
|
+
const threshold = this.eps * grand;
|
|
4512
|
+
const kept = [...this.totals].filter(([, m]) => m > 0 && m >= threshold);
|
|
4513
|
+
if (kept.length === 0) {
|
|
4514
|
+
throw new Error(`Mixture: pruning at eps ${this.eps} removed every outcome`);
|
|
4515
|
+
}
|
|
4516
|
+
const keptTotal = kept.length === this.totals.size ? grand : kahanSum(kept.map(([, m]) => m));
|
|
3729
4517
|
const internal = /* @__PURE__ */ new Map();
|
|
3730
|
-
for (const [v, m] of
|
|
3731
|
-
|
|
3732
|
-
const
|
|
3733
|
-
|
|
4518
|
+
for (const [v, m] of kept) {
|
|
4519
|
+
const count = {};
|
|
4520
|
+
const bag = this.labelMass.get(v) ?? {};
|
|
4521
|
+
for (const label in bag) count[label] = bag[label] / keptTotal;
|
|
4522
|
+
internal.set(v, { p: m / keptTotal, count });
|
|
3734
4523
|
}
|
|
3735
4524
|
return new PMF(internal, eps);
|
|
3736
4525
|
}
|
|
3737
4526
|
/**
|
|
3738
4527
|
* Produce normalized *per-label* PMFs (labels independent).
|
|
3739
|
-
* These are unlabeled PMFs built from the raw mass of that label alone
|
|
4528
|
+
* These are unlabeled PMFs built from the raw mass of that label alone; values below `eps`
|
|
4529
|
+
* of the label's own mass are pruned.
|
|
3740
4530
|
*/
|
|
3741
4531
|
byOutcome() {
|
|
3742
4532
|
const labels = /* @__PURE__ */ new Set();
|
|
@@ -3745,12 +4535,16 @@ var Mixture = class _Mixture {
|
|
|
3745
4535
|
}
|
|
3746
4536
|
const out = {};
|
|
3747
4537
|
for (const label of labels) {
|
|
4538
|
+
const labelTotal = kahanSum(
|
|
4539
|
+
[...this.labelMass.values()].map((bag) => bag[label] ?? 0)
|
|
4540
|
+
);
|
|
4541
|
+
if (!(labelTotal > 0)) continue;
|
|
3748
4542
|
const m = /* @__PURE__ */ new Map();
|
|
3749
4543
|
for (const [v, bag] of this.labelMass) {
|
|
3750
4544
|
const w = bag[label];
|
|
3751
|
-
if (w
|
|
4545
|
+
if (w) m.set(v, w / labelTotal);
|
|
3752
4546
|
}
|
|
3753
|
-
|
|
4547
|
+
out[label] = PMF.fromMap(m, this.eps);
|
|
3754
4548
|
}
|
|
3755
4549
|
return out;
|
|
3756
4550
|
}
|
|
@@ -3766,14 +4560,7 @@ var Mixture = class _Mixture {
|
|
|
3766
4560
|
res[lab] = (res[lab] ?? 0) + w;
|
|
3767
4561
|
}
|
|
3768
4562
|
}
|
|
3769
|
-
|
|
3770
|
-
let c = 0;
|
|
3771
|
-
for (const v of Object.values(res)) {
|
|
3772
|
-
const y = v - c;
|
|
3773
|
-
const t = total + y;
|
|
3774
|
-
c = t - total - y;
|
|
3775
|
-
total = t;
|
|
3776
|
-
}
|
|
4563
|
+
const total = kahanSum(Object.values(res));
|
|
3777
4564
|
if (total > 0) {
|
|
3778
4565
|
for (const k in res) res[k] = res[k] / total;
|
|
3779
4566
|
}
|
|
@@ -3789,9 +4576,20 @@ var Mixture = class _Mixture {
|
|
|
3789
4576
|
static mix(items, eps = EPS) {
|
|
3790
4577
|
const mix = new _Mixture(eps);
|
|
3791
4578
|
for (const [lab, pmf, w] of items) mix.add(lab, pmf, w);
|
|
3792
|
-
return mix.buildPMF();
|
|
4579
|
+
return mix.buildPMF(eps);
|
|
3793
4580
|
}
|
|
3794
4581
|
};
|
|
4582
|
+
function kahanSum(values) {
|
|
4583
|
+
let sum = 0;
|
|
4584
|
+
let c = 0;
|
|
4585
|
+
for (const v of values) {
|
|
4586
|
+
const y = v - c;
|
|
4587
|
+
const t = sum + y;
|
|
4588
|
+
c = t - sum - y;
|
|
4589
|
+
sum = t;
|
|
4590
|
+
}
|
|
4591
|
+
return sum;
|
|
4592
|
+
}
|
|
3795
4593
|
|
|
3796
4594
|
exports.ALL_OUTCOME_TYPES = ALL_OUTCOME_TYPES;
|
|
3797
4595
|
exports.DiceParseError = DiceParseError;
|