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