@yipe/dice 0.2.23 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/worktrees/amazing-matsumoto-27220c/LICENSE +21 -0
- package/.claude/worktrees/amazing-matsumoto-27220c/README.md +518 -0
- package/.claude/worktrees/vibrant-lovelace-0cc9e7/LICENSE +21 -0
- package/.claude/worktrees/vibrant-lovelace-0cc9e7/README.md +518 -0
- package/.claude/worktrees/wizardly-mclean-e375de/LICENSE +21 -0
- package/.claude/worktrees/wizardly-mclean-e375de/README.md +518 -0
- package/CHANGELOG.md +224 -0
- package/README.md +17 -0
- package/dist/builder/index.cjs +555 -295
- package/dist/builder/index.cjs.map +1 -1
- package/dist/builder/index.d.cts +9 -10
- package/dist/builder/index.d.ts +9 -10
- package/dist/builder/index.js +555 -295
- package/dist/builder/index.js.map +1 -1
- package/dist/index.cjs +619 -256
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +61 -3
- package/dist/index.d.ts +61 -3
- package/dist/index.js +613 -257
- package/dist/index.js.map +1 -1
- package/dist/{pmf-BC1poIqF.d.cts → pmf-D5VRghZI.d.cts} +204 -10
- package/dist/{pmf-BC1poIqF.d.ts → pmf-D5VRghZI.d.ts} +204 -10
- package/package.json +12 -14
package/dist/index.js
CHANGED
|
@@ -1,12 +1,69 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// src/common/bounce.ts
|
|
2
|
+
function binom(n, k) {
|
|
3
|
+
if (k < 0 || k > n) return 0;
|
|
4
|
+
let result = 1;
|
|
5
|
+
for (let i = 0; i < k; i++) result = result * (n - i) / (i + 1);
|
|
6
|
+
return result;
|
|
7
|
+
}
|
|
8
|
+
function pAllDistinct(dice, faces, uniformCount, heavyWeight) {
|
|
9
|
+
const light = 1 / faces;
|
|
10
|
+
const eK = binom(uniformCount, dice) * Math.pow(light, dice) + heavyWeight * binom(uniformCount, dice - 1) * Math.pow(light, dice - 1);
|
|
11
|
+
let kFactorial = 1;
|
|
12
|
+
for (let i = 2; i <= dice; i++) kFactorial *= i;
|
|
13
|
+
return kFactorial * eK;
|
|
14
|
+
}
|
|
15
|
+
function pMatch(dice, faces, minimumDieRoll) {
|
|
16
|
+
if (dice <= 1) return 0;
|
|
17
|
+
if (dice > faces) return 1;
|
|
18
|
+
if (minimumDieRoll >= 2) {
|
|
19
|
+
const uniformCount = faces - minimumDieRoll;
|
|
20
|
+
const effectiveValues = uniformCount + 1;
|
|
21
|
+
if (dice > effectiveValues) return 1;
|
|
22
|
+
const heavyWeight = minimumDieRoll / faces;
|
|
23
|
+
const distinct = pAllDistinct(dice, faces, uniformCount, heavyWeight);
|
|
24
|
+
return Math.min(1, Math.max(0, 1 - distinct));
|
|
25
|
+
}
|
|
26
|
+
let pDistinct = 1;
|
|
27
|
+
for (let i = 0; i < dice; i++) pDistinct *= (faces - i) / faces;
|
|
28
|
+
return 1 - pDistinct;
|
|
29
|
+
}
|
|
30
|
+
function calculateBounceOdds(diceCount, dieFaces, options) {
|
|
31
|
+
if (diceCount <= 1) return 0;
|
|
32
|
+
if (diceCount > dieFaces) return 1;
|
|
33
|
+
const minimumDieRoll = options?.minimumDieRoll ?? 0;
|
|
34
|
+
const rerollDamageDice = options?.rerollDamageDice ?? 0;
|
|
35
|
+
const pMatchFirst = pMatch(diceCount, dieFaces, minimumDieRoll);
|
|
36
|
+
const rerollCount = Math.min(rerollDamageDice, diceCount);
|
|
37
|
+
if (rerollCount <= 0) return pMatchFirst;
|
|
38
|
+
const pNoMatchFirst = 1 - pMatchFirst;
|
|
39
|
+
const keptDice = diceCount - rerollCount;
|
|
40
|
+
const effectiveFaces = minimumDieRoll >= 2 ? dieFaces - (minimumDieRoll - 1) : dieFaces;
|
|
41
|
+
const pRerollDieMissesAll = keptDice > 0 ? Math.pow((effectiveFaces - keptDice) / effectiveFaces, rerollCount) : 1;
|
|
42
|
+
const pAtLeastOneRerollMatches = 1 - pRerollDieMissesAll;
|
|
43
|
+
const pRerolledMatch = rerollCount >= 2 ? pMatch(rerollCount, dieFaces, minimumDieRoll) : 0;
|
|
44
|
+
const pMatchAfterReroll = Math.min(
|
|
45
|
+
1,
|
|
46
|
+
pAtLeastOneRerollMatches + pRerolledMatch * (1 - pAtLeastOneRerollMatches)
|
|
47
|
+
);
|
|
48
|
+
return Math.min(1, pMatchFirst + pNoMatchFirst * pMatchAfterReroll);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/common/errors.ts
|
|
52
|
+
var DiceParseError = class _DiceParseError extends Error {
|
|
53
|
+
constructor(message, options) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "DiceParseError";
|
|
56
|
+
this.expression = options?.expression;
|
|
57
|
+
this.cause = options?.cause;
|
|
58
|
+
Object.setPrototypeOf(this, _DiceParseError.prototype);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
4
61
|
|
|
5
62
|
// src/common/lru-cache.ts
|
|
6
63
|
var LRUCache = class {
|
|
7
64
|
constructor(maxSize = 1e3) {
|
|
8
65
|
this.maxSize = maxSize;
|
|
9
|
-
|
|
66
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
10
67
|
}
|
|
11
68
|
get(key) {
|
|
12
69
|
const value = this.cache.get(key);
|
|
@@ -46,6 +103,50 @@ var LRUCache = class {
|
|
|
46
103
|
|
|
47
104
|
// src/common/types.ts
|
|
48
105
|
var EPS = 1e-12;
|
|
106
|
+
function critProbability(critRange, rollType = "flat") {
|
|
107
|
+
const base = critRange / 20;
|
|
108
|
+
switch (rollType) {
|
|
109
|
+
case "advantage":
|
|
110
|
+
return 1 - (1 - base) ** 2;
|
|
111
|
+
case "elven accuracy":
|
|
112
|
+
return 1 - (1 - base) ** 3;
|
|
113
|
+
case "disadvantage":
|
|
114
|
+
return base ** 2;
|
|
115
|
+
case "flat":
|
|
116
|
+
default:
|
|
117
|
+
return base;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
var MISS_NONE_OUTCOME = "missNone";
|
|
121
|
+
var ALL_OUTCOME_TYPES = [
|
|
122
|
+
"missNone",
|
|
123
|
+
"missDamage",
|
|
124
|
+
"saveFail",
|
|
125
|
+
"saveHalf",
|
|
126
|
+
"pc",
|
|
127
|
+
"hit",
|
|
128
|
+
"crit"
|
|
129
|
+
];
|
|
130
|
+
var OUTCOME_DISPLAY_ORDER = [
|
|
131
|
+
"crit",
|
|
132
|
+
"hit",
|
|
133
|
+
"missDamage",
|
|
134
|
+
"saveHalf",
|
|
135
|
+
"saveFail",
|
|
136
|
+
"pc",
|
|
137
|
+
"missNone"
|
|
138
|
+
];
|
|
139
|
+
function sortOutcomes(outcomes, order = ALL_OUTCOME_TYPES) {
|
|
140
|
+
const rank = new Map(order.map((o, i) => [o, i]));
|
|
141
|
+
return [...outcomes].sort((a, b) => {
|
|
142
|
+
const ra = rank.get(a);
|
|
143
|
+
const rb = rank.get(b);
|
|
144
|
+
if (ra !== void 0 && rb !== void 0) return ra - rb;
|
|
145
|
+
if (ra !== void 0) return -1;
|
|
146
|
+
if (rb !== void 0) return 1;
|
|
147
|
+
return a.localeCompare(b);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
49
150
|
var onAnyHit = ["hit", "crit"];
|
|
50
151
|
var onCritOnly = ["crit"];
|
|
51
152
|
var onHitOnly = ["hit"];
|
|
@@ -58,15 +159,30 @@ var onPotentCantripOnly = ["pc"];
|
|
|
58
159
|
// src/pmf/query.ts
|
|
59
160
|
var _DiceQuery = class _DiceQuery {
|
|
60
161
|
constructor(singles, combined, eps = EPS) {
|
|
61
|
-
__publicField(this, "singles");
|
|
62
|
-
__publicField(this, "combined");
|
|
63
|
-
__publicField(this, "_combinedWithAttr");
|
|
64
162
|
this.singles = Array.isArray(singles) ? singles : [singles];
|
|
65
163
|
if (this.singles.some((s) => s === void 0)) {
|
|
66
164
|
throw new Error("DiceQuery contains undefined singles");
|
|
67
165
|
}
|
|
68
|
-
|
|
69
|
-
this.
|
|
166
|
+
this._eps = eps;
|
|
167
|
+
this._combinedProvided = combined !== void 0;
|
|
168
|
+
if (combined !== void 0) {
|
|
169
|
+
this._combined = Math.abs(combined.mass() - 1) <= eps ? combined : combined.normalize();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The combined damage distribution of all single PMFs (their convolution),
|
|
174
|
+
* normalized to total probability 1.
|
|
175
|
+
*
|
|
176
|
+
* Computed lazily on first access and cached. Queries that only need
|
|
177
|
+
* additive statistics — {@link DiceQuery.mean}, {@link DiceQuery.variance},
|
|
178
|
+
* {@link DiceQuery.stddev} — never trigger this convolution.
|
|
179
|
+
*/
|
|
180
|
+
get combined() {
|
|
181
|
+
if (this._combined === void 0) {
|
|
182
|
+
const c = PMF.convolveMany(this.singles);
|
|
183
|
+
this._combined = Math.abs(c.mass() - 1) <= this._eps ? c : c.normalize();
|
|
184
|
+
}
|
|
185
|
+
return this._combined;
|
|
70
186
|
}
|
|
71
187
|
/**
|
|
72
188
|
* Returns a new PMF with damage attribution metadata populated.
|
|
@@ -91,12 +207,39 @@ var _DiceQuery = class _DiceQuery {
|
|
|
91
207
|
if (this._combinedWithAttr) {
|
|
92
208
|
return this._combinedWithAttr;
|
|
93
209
|
}
|
|
210
|
+
if (this.singles.every((pmf) => pmf.hasAttribution())) {
|
|
211
|
+
this._combinedWithAttr = this.combined;
|
|
212
|
+
return this._combinedWithAttr;
|
|
213
|
+
}
|
|
94
214
|
const singlesWithAttr = this.singles.map((pmf) => pmf.withAttribution());
|
|
95
215
|
const combined = PMF.convolveMany(singlesWithAttr, this.combined.epsilon);
|
|
96
216
|
const normalized = Math.abs(combined.mass() - 1) <= this.combined.epsilon ? combined : combined.normalize();
|
|
97
217
|
this._combinedWithAttr = normalized;
|
|
98
218
|
return normalized;
|
|
99
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Per-label `damage value → probability mass` series for the combined,
|
|
222
|
+
* attribution-carrying distribution — the provenance core of the stacked
|
|
223
|
+
* damage-attribution chart. Convenience for
|
|
224
|
+
* `combinedWithAttribution().attributionByValue()`; see
|
|
225
|
+
* {@link PMF.attributionByValue}.
|
|
226
|
+
*/
|
|
227
|
+
attributionByValue() {
|
|
228
|
+
return this.combinedWithAttribution().attributionByValue();
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* How many of the independent single PMFs can produce the given outcome
|
|
232
|
+
* label. Useful for "all of them succeeded" style probabilities where the
|
|
233
|
+
* exponent is the number of contributing attacks (see
|
|
234
|
+
* {@link DiceQuery.probExactlyK}).
|
|
235
|
+
*/
|
|
236
|
+
countSinglesWith(label) {
|
|
237
|
+
let count = 0;
|
|
238
|
+
for (const single of this.singles) {
|
|
239
|
+
if (single.hasOutcome(label)) count++;
|
|
240
|
+
}
|
|
241
|
+
return count;
|
|
242
|
+
}
|
|
100
243
|
/**
|
|
101
244
|
* Returns the expected damage across all possible outcomes.
|
|
102
245
|
*
|
|
@@ -104,11 +247,18 @@ var _DiceQuery = class _DiceQuery {
|
|
|
104
247
|
* Use case: "What's my average damage per round?"
|
|
105
248
|
*/
|
|
106
249
|
mean() {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
250
|
+
if (this._combinedProvided) {
|
|
251
|
+
let m = 0;
|
|
252
|
+
for (const [damageValue, bin] of this.combined) m += damageValue * bin.p;
|
|
253
|
+
return m;
|
|
254
|
+
}
|
|
255
|
+
let totalMean = 0;
|
|
256
|
+
for (const single of this.singles) {
|
|
257
|
+
const mass = single.mass();
|
|
258
|
+
if (mass <= 0) continue;
|
|
259
|
+
totalMean += Math.abs(mass - 1) <= this._eps ? single.mean() : single.mean() / mass;
|
|
110
260
|
}
|
|
111
|
-
return
|
|
261
|
+
return totalMean;
|
|
112
262
|
}
|
|
113
263
|
/**
|
|
114
264
|
* Returns the variance of the damage distribution.
|
|
@@ -118,13 +268,33 @@ var _DiceQuery = class _DiceQuery {
|
|
|
118
268
|
* High variance means higher risk/reward. Lower variance means more consistent damage.
|
|
119
269
|
*/
|
|
120
270
|
variance() {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
271
|
+
if (this._combinedProvided) {
|
|
272
|
+
const mu = this.mean();
|
|
273
|
+
let v = 0;
|
|
274
|
+
for (const [damageValue, bin] of this.combined) {
|
|
275
|
+
const dev = damageValue - mu;
|
|
276
|
+
v += dev * dev * bin.p;
|
|
277
|
+
}
|
|
278
|
+
return v;
|
|
126
279
|
}
|
|
127
|
-
|
|
280
|
+
let totalVariance = 0;
|
|
281
|
+
for (const single of this.singles) {
|
|
282
|
+
const mass = single.mass();
|
|
283
|
+
if (mass <= 0) continue;
|
|
284
|
+
if (Math.abs(mass - 1) <= this._eps) {
|
|
285
|
+
totalVariance += single.variance();
|
|
286
|
+
} else {
|
|
287
|
+
let mu = 0;
|
|
288
|
+
for (const [d, b] of single) mu += d * (b.p / mass);
|
|
289
|
+
let v = 0;
|
|
290
|
+
for (const [d, b] of single) {
|
|
291
|
+
const dev = d - mu;
|
|
292
|
+
v += dev * dev * (b.p / mass);
|
|
293
|
+
}
|
|
294
|
+
totalVariance += v;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return totalVariance;
|
|
128
298
|
}
|
|
129
299
|
/**
|
|
130
300
|
* Returns the standard deviation of the damage distribution.
|
|
@@ -136,6 +306,10 @@ var _DiceQuery = class _DiceQuery {
|
|
|
136
306
|
stddev() {
|
|
137
307
|
return Math.sqrt(this.variance());
|
|
138
308
|
}
|
|
309
|
+
/** Alias of {@link DiceQuery.stddev}, matching {@link PMF.stdev}. */
|
|
310
|
+
stdev() {
|
|
311
|
+
return this.stddev();
|
|
312
|
+
}
|
|
139
313
|
/**
|
|
140
314
|
* Returns the Cumulative Distribution Function.
|
|
141
315
|
*/
|
|
@@ -226,20 +400,49 @@ var _DiceQuery = class _DiceQuery {
|
|
|
226
400
|
return this.combined.max();
|
|
227
401
|
}
|
|
228
402
|
singleProb(diceIndex, label) {
|
|
403
|
+
const single = this.singles[diceIndex];
|
|
229
404
|
let probabilitySum = 0;
|
|
230
|
-
for (const [, probabilityBin] of
|
|
405
|
+
for (const [, probabilityBin] of single) {
|
|
231
406
|
probabilitySum += probabilityBin.count[label] || 0;
|
|
232
407
|
}
|
|
233
|
-
|
|
408
|
+
const mass = single.mass();
|
|
409
|
+
return mass > 0 ? probabilitySum / mass : 0;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Full count distribution [P(0), P(1), …, P(n)] for "an attack succeeds if it
|
|
413
|
+
* carries ANY of `labels`", over the n independent singles.
|
|
414
|
+
*
|
|
415
|
+
* Each single's per-event success probability is the Poisson-binomial
|
|
416
|
+
* marginal P(≥1 of labels) from {@link probabilityOf} (i.e. probAtLeastOne),
|
|
417
|
+
* computed exactly once. The binomial DP then runs once to produce the whole
|
|
418
|
+
* distribution, so the array-label paths of probExactlyK / probAtLeastK /
|
|
419
|
+
* probAtMostK can slice or sum from it instead of rebuilding a DiceQuery and
|
|
420
|
+
* re-running the DP per requested k.
|
|
421
|
+
*/
|
|
422
|
+
countDistribution(labels) {
|
|
423
|
+
const n = this.singles.length;
|
|
424
|
+
const successProbabilities = this.singles.map(
|
|
425
|
+
(single) => new _DiceQuery([single]).probabilityOf(labels)
|
|
426
|
+
);
|
|
427
|
+
const dist = new Array(n + 1).fill(0);
|
|
428
|
+
dist[0] = 1;
|
|
429
|
+
for (const successProb of successProbabilities) {
|
|
430
|
+
for (let outcomeCount = n; outcomeCount >= 1; outcomeCount--) {
|
|
431
|
+
dist[outcomeCount] = dist[outcomeCount] * (1 - successProb) + dist[outcomeCount - 1] * successProb;
|
|
432
|
+
}
|
|
433
|
+
dist[0] *= 1 - successProb;
|
|
434
|
+
}
|
|
435
|
+
return dist;
|
|
234
436
|
}
|
|
235
437
|
probAtLeastK(labels, k) {
|
|
236
438
|
const L = Array.isArray(labels) ? [...new Set(labels)] : [labels];
|
|
237
439
|
const n = this.singles.length;
|
|
238
440
|
if (k <= 0) return 1;
|
|
239
441
|
if (k > n) return 0;
|
|
442
|
+
const dist = this.countDistribution(L);
|
|
240
443
|
let tail = 0;
|
|
241
444
|
for (let i = k; i <= n; i++) {
|
|
242
|
-
tail +=
|
|
445
|
+
tail += dist[i];
|
|
243
446
|
}
|
|
244
447
|
if (tail < 0) return 0;
|
|
245
448
|
if (tail > 1) return 1;
|
|
@@ -271,9 +474,12 @@ var _DiceQuery = class _DiceQuery {
|
|
|
271
474
|
for (const label of labels) {
|
|
272
475
|
combinedProbability += this.singleProb(diceIndex, label);
|
|
273
476
|
}
|
|
477
|
+
if (combinedProbability < 0) combinedProbability = 0;
|
|
478
|
+
else if (combinedProbability > 1) combinedProbability = 1;
|
|
274
479
|
productOfNonOccurrence *= 1 - combinedProbability;
|
|
275
480
|
}
|
|
276
|
-
|
|
481
|
+
const result = 1 - productOfNonOccurrence;
|
|
482
|
+
return result < 0 ? 0 : result > 1 ? 1 : result;
|
|
277
483
|
}
|
|
278
484
|
/**
|
|
279
485
|
* Computes binomial probabilities for exactly 0, 1, 2, ..., maxK occurrences of a label.
|
|
@@ -314,7 +520,7 @@ var _DiceQuery = class _DiceQuery {
|
|
|
314
520
|
* Array examples:
|
|
315
521
|
* - probExactlyK(['hit', 'crit'], 2) = probability exactly 2 attacks succeed
|
|
316
522
|
* - probExactlyK(['hit', 'crit'], 1) = probability exactly 1 attack succeeds
|
|
317
|
-
* - probExactlyK(['
|
|
523
|
+
* - probExactlyK(['missDamage', 'missNone'], 0) = probability no attacks miss
|
|
318
524
|
*
|
|
319
525
|
* Use cases:
|
|
320
526
|
* - "What's the chance exactly one of my attacks hits?"
|
|
@@ -329,19 +535,8 @@ var _DiceQuery = class _DiceQuery {
|
|
|
329
535
|
const probabilityArray = this.computeBinomialProbabilities(labels, k);
|
|
330
536
|
return probabilityArray[k];
|
|
331
537
|
}
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
return singleQuery.probabilityOf(labels);
|
|
335
|
-
});
|
|
336
|
-
const binomialProbs = new Array(k + 1).fill(0);
|
|
337
|
-
binomialProbs[0] = 1;
|
|
338
|
-
for (const successProb of successProbabilities) {
|
|
339
|
-
for (let outcomeCount = k; outcomeCount >= 1; outcomeCount--) {
|
|
340
|
-
binomialProbs[outcomeCount] = binomialProbs[outcomeCount] * (1 - successProb) + binomialProbs[outcomeCount - 1] * successProb;
|
|
341
|
-
}
|
|
342
|
-
binomialProbs[0] *= 1 - successProb;
|
|
343
|
-
}
|
|
344
|
-
return binomialProbs[k];
|
|
538
|
+
const dist = this.countDistribution(labels);
|
|
539
|
+
return k >= 0 && k < dist.length ? dist[k] : 0;
|
|
345
540
|
}
|
|
346
541
|
/**
|
|
347
542
|
* Returns the probability that AT MOST K attacks result in the specified outcome(s).
|
|
@@ -349,7 +544,7 @@ var _DiceQuery = class _DiceQuery {
|
|
|
349
544
|
* Single label examples:
|
|
350
545
|
* - probAtMostK('hit', 1) = probability 0 or 1 attacks hit (at most 1)
|
|
351
546
|
* - probAtMostK('crit', 0) = probability no attacks crit
|
|
352
|
-
* - probAtMostK('
|
|
547
|
+
* - probAtMostK('missDamage', 2) = probability at most 2 attacks miss
|
|
353
548
|
*
|
|
354
549
|
* Array examples:
|
|
355
550
|
* - probAtMostK(['hit', 'crit'], 1) = probability at most 1 attack succeeds
|
|
@@ -370,9 +565,11 @@ var _DiceQuery = class _DiceQuery {
|
|
|
370
565
|
}
|
|
371
566
|
return cumulativeSum2;
|
|
372
567
|
}
|
|
568
|
+
const dist = this.countDistribution(labels);
|
|
569
|
+
const upper = Math.min(k, dist.length - 1);
|
|
373
570
|
let cumulativeSum = 0;
|
|
374
|
-
for (let outcomeCount = 0; outcomeCount <=
|
|
375
|
-
cumulativeSum +=
|
|
571
|
+
for (let outcomeCount = 0; outcomeCount <= upper; outcomeCount++) {
|
|
572
|
+
cumulativeSum += dist[outcomeCount];
|
|
376
573
|
}
|
|
377
574
|
return cumulativeSum;
|
|
378
575
|
}
|
|
@@ -418,7 +615,7 @@ var _DiceQuery = class _DiceQuery {
|
|
|
418
615
|
*
|
|
419
616
|
* Array examples:
|
|
420
617
|
* - damageStatsFrom(['hit', 'crit']) = damage range when at least one attack succeeds
|
|
421
|
-
* - damageStatsFrom(['
|
|
618
|
+
* - damageStatsFrom(['missDamage', 'missNone']) = damage range when at least one attack misses
|
|
422
619
|
*
|
|
423
620
|
* Tactical Use Cases:
|
|
424
621
|
* - "Given that I don't completely whiff (99% of turns), what damage should I expect?"
|
|
@@ -436,6 +633,12 @@ var _DiceQuery = class _DiceQuery {
|
|
|
436
633
|
* This includes mixed scenarios (2 hits + 1 crit, 3 hits + 1 miss, etc.) which
|
|
437
634
|
* occur far more frequently than pure scenarios. For pure scenarios, use combinedDamageStats.
|
|
438
635
|
*
|
|
636
|
+
* KNOWN LIMITATION (multi-attack, single label): the returned `count` is an
|
|
637
|
+
* EXPECTED COUNT (E[#label], so > 1 for N≥2 attacks, not a probability), and
|
|
638
|
+
* `avg` is the size-biased conditional mean E[dmg·#label]/E[#label] rather than
|
|
639
|
+
* E[dmg | the label occurs]. For a single attack both are the plain
|
|
640
|
+
* conditional figures. Use {@link probAtLeastOne} for the scenario probability.
|
|
641
|
+
*
|
|
439
642
|
* @example
|
|
440
643
|
* // High-level tactical planning
|
|
441
644
|
* const successStats = query.damageStatsFrom('hit')
|
|
@@ -540,7 +743,8 @@ var _DiceQuery = class _DiceQuery {
|
|
|
540
743
|
};
|
|
541
744
|
}
|
|
542
745
|
/**
|
|
543
|
-
* Returns the probability that
|
|
746
|
+
* Returns the probability that at least one attack carries ANY of the
|
|
747
|
+
* specified labels (the marginal P(≥1) across the independent attacks).
|
|
544
748
|
*
|
|
545
749
|
* Examples:
|
|
546
750
|
* - `query.probabilityOf('hit')` → 0.88 (probability at least one hit occurs)
|
|
@@ -549,25 +753,15 @@ var _DiceQuery = class _DiceQuery {
|
|
|
549
753
|
* Use cases:
|
|
550
754
|
* - "What's the chance my resolution includes a success label?"
|
|
551
755
|
* - "How likely am I to get any hits or crits across all attacks?"
|
|
756
|
+
*
|
|
757
|
+
* Note: this must NOT be computed by summing `combined` bin probabilities. A
|
|
758
|
+
* single combined damage total is reachable by many outcome combinations and
|
|
759
|
+
* a bin can hold several labels at once, so summing `bin.p` over bins that
|
|
760
|
+
* contain a label over-counts. The correct marginal is the Poisson-binomial
|
|
761
|
+
* complement over the per-attack probabilities, i.e. {@link probAtLeastOne}.
|
|
552
762
|
*/
|
|
553
763
|
probabilityOf(labels) {
|
|
554
|
-
|
|
555
|
-
labels = [labels];
|
|
556
|
-
}
|
|
557
|
-
let totalProbability = 0;
|
|
558
|
-
for (const [, probabilityBin] of this.combined) {
|
|
559
|
-
let binHasAnyLabel = false;
|
|
560
|
-
for (const label of labels) {
|
|
561
|
-
if (probabilityBin.count[label] && probabilityBin.count[label] > 0) {
|
|
562
|
-
binHasAnyLabel = true;
|
|
563
|
-
break;
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
if (binHasAnyLabel) {
|
|
567
|
-
totalProbability += probabilityBin.p;
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
return totalProbability;
|
|
764
|
+
return this.probAtLeastOne(labels);
|
|
571
765
|
}
|
|
572
766
|
/**
|
|
573
767
|
* Returns the probability of missing (any type of miss).
|
|
@@ -627,10 +821,6 @@ var _DiceQuery = class _DiceQuery {
|
|
|
627
821
|
*/
|
|
628
822
|
toStackedChartData(labels = [], epsilon = EPS) {
|
|
629
823
|
const damageValues = this.combined.support();
|
|
630
|
-
damageValues.map((dmg) => {
|
|
631
|
-
const bin = this.combined.map.get(dmg);
|
|
632
|
-
return labels.reduce((sum, lab) => sum + (bin.count[lab] || 0), 0);
|
|
633
|
-
});
|
|
634
824
|
const datasets = labels.map((outcomeLabel) => ({
|
|
635
825
|
label: outcomeLabel,
|
|
636
826
|
data: damageValues.map((dmg) => {
|
|
@@ -1067,6 +1257,16 @@ var _DiceQuery = class _DiceQuery {
|
|
|
1067
1257
|
* Snapshot of the distribution in the exact shape the UI consumes.
|
|
1068
1258
|
* - outcome probabilities are "at least one" (and equal to "all" for a single PMF)
|
|
1069
1259
|
* - damageRange is conditional on the outcome occurring
|
|
1260
|
+
*
|
|
1261
|
+
* The outcome probabilities use the correct Poisson-binomial marginals
|
|
1262
|
+
* (`atLeastOneProbability` = P(≥1 attack has it), `allProbability` = P(all do)),
|
|
1263
|
+
* so they are always valid probabilities in [0,1].
|
|
1264
|
+
*
|
|
1265
|
+
* KNOWN LIMITATION (multi-attack): `damageRange.avg` is still aggregated from
|
|
1266
|
+
* the combined PMF's `count`, which the convolution accumulates as an EXPECTED
|
|
1267
|
+
* COUNT, so for N≥2 attacks it is the size-biased mean E[dmg·#label]/E[#label]
|
|
1268
|
+
* rather than a clean conditional expectation. It is correct for a single
|
|
1269
|
+
* attack.
|
|
1070
1270
|
*/
|
|
1071
1271
|
snapshot(order) {
|
|
1072
1272
|
const discovered = /* @__PURE__ */ new Set();
|
|
@@ -1088,10 +1288,8 @@ var _DiceQuery = class _DiceQuery {
|
|
|
1088
1288
|
);
|
|
1089
1289
|
}
|
|
1090
1290
|
const rows = this.toLabeledTable(outcomes);
|
|
1091
|
-
const totals = /* @__PURE__ */ new Map();
|
|
1092
1291
|
const rangeAcc = /* @__PURE__ */ new Map();
|
|
1093
1292
|
for (const ot of outcomes) {
|
|
1094
|
-
totals.set(ot, 0);
|
|
1095
1293
|
rangeAcc.set(ot, { sum: 0, mass: 0 });
|
|
1096
1294
|
}
|
|
1097
1295
|
for (const row of rows) {
|
|
@@ -1099,7 +1297,6 @@ var _DiceQuery = class _DiceQuery {
|
|
|
1099
1297
|
for (const ot of outcomes) {
|
|
1100
1298
|
const p = row[ot] || 0;
|
|
1101
1299
|
if (p <= 0) continue;
|
|
1102
|
-
totals.set(ot, (totals.get(ot) || 0) + p);
|
|
1103
1300
|
const r = rangeAcc.get(ot);
|
|
1104
1301
|
r.sum += dmg * p;
|
|
1105
1302
|
r.mass += p;
|
|
@@ -1107,15 +1304,14 @@ var _DiceQuery = class _DiceQuery {
|
|
|
1107
1304
|
if (r.max === void 0 || dmg > r.max) r.max = dmg;
|
|
1108
1305
|
}
|
|
1109
1306
|
}
|
|
1307
|
+
const n = this.singles.length;
|
|
1110
1308
|
const outcomeMap = /* @__PURE__ */ new Map();
|
|
1111
1309
|
for (const ot of outcomes) {
|
|
1112
|
-
const total = totals.get(ot) || 0;
|
|
1113
1310
|
const r = rangeAcc.get(ot);
|
|
1114
1311
|
const avg = r.mass > 0 ? r.sum / r.mass : 0;
|
|
1115
1312
|
outcomeMap.set(ot, {
|
|
1116
|
-
atLeastOneProbability:
|
|
1117
|
-
allProbability:
|
|
1118
|
-
// single aggregate PMF: same value
|
|
1313
|
+
atLeastOneProbability: this.probAtLeastOne(ot),
|
|
1314
|
+
allProbability: this.probAtLeastK(ot, n),
|
|
1119
1315
|
damageRange: { min: r.min ?? 0, avg, max: r.max ?? 0 }
|
|
1120
1316
|
});
|
|
1121
1317
|
}
|
|
@@ -1300,11 +1496,11 @@ var _DiceQuery = class _DiceQuery {
|
|
|
1300
1496
|
return [a, b, any, none];
|
|
1301
1497
|
}
|
|
1302
1498
|
};
|
|
1303
|
-
|
|
1499
|
+
_DiceQuery.DEFAULT_OUTCOMES = [
|
|
1304
1500
|
"hit",
|
|
1305
1501
|
"crit",
|
|
1306
1502
|
"missNone"
|
|
1307
|
-
]
|
|
1503
|
+
];
|
|
1308
1504
|
var DiceQuery = _DiceQuery;
|
|
1309
1505
|
var pmfCache = new LRUCache(1e3);
|
|
1310
1506
|
var _PMF = class _PMF {
|
|
@@ -1314,14 +1510,6 @@ var _PMF = class _PMF {
|
|
|
1314
1510
|
this.normalized = normalized;
|
|
1315
1511
|
this.identifier = identifier;
|
|
1316
1512
|
this._preservedProvenance = _preservedProvenance;
|
|
1317
|
-
// Cached computed values
|
|
1318
|
-
__publicField(this, "_support");
|
|
1319
|
-
__publicField(this, "_min");
|
|
1320
|
-
__publicField(this, "_max");
|
|
1321
|
-
__publicField(this, "_totalMass");
|
|
1322
|
-
__publicField(this, "_mean");
|
|
1323
|
-
__publicField(this, "_variance");
|
|
1324
|
-
__publicField(this, "_stdev");
|
|
1325
1513
|
}
|
|
1326
1514
|
static empty(epsilon = EPS, identifier = "empty") {
|
|
1327
1515
|
return new _PMF(/* @__PURE__ */ new Map(), epsilon, false, identifier);
|
|
@@ -1335,6 +1523,20 @@ var _PMF = class _PMF {
|
|
|
1335
1523
|
static delta(value, epsilon = EPS) {
|
|
1336
1524
|
return _PMF.fromMap(/* @__PURE__ */ new Map([[value, 1]]), epsilon);
|
|
1337
1525
|
}
|
|
1526
|
+
/**
|
|
1527
|
+
* Point mass at damage 0 tagged with the canonical `missNone` outcome.
|
|
1528
|
+
*
|
|
1529
|
+
* Differs from {@link PMF.zero}, which labels its zero bin `miss` — the
|
|
1530
|
+
* builder's attack-resolution vocabulary. This uses the `missNone`
|
|
1531
|
+
* {@link OutcomeType} that the attribution charts and outcome stats key on,
|
|
1532
|
+
* so it is the correct "clean miss / no damage" delta for provenance-aware
|
|
1533
|
+
* mixtures feeding those consumers.
|
|
1534
|
+
*/
|
|
1535
|
+
static missNone(epsilon = EPS) {
|
|
1536
|
+
const m = /* @__PURE__ */ new Map();
|
|
1537
|
+
m.set(0, { p: 1, count: { [MISS_NONE_OUTCOME]: 1 }, attr: {} });
|
|
1538
|
+
return new _PMF(m, epsilon, false, "missNone");
|
|
1539
|
+
}
|
|
1338
1540
|
// This creates a single bin at value 0, but with weight 0.
|
|
1339
1541
|
static emptyMass() {
|
|
1340
1542
|
return _PMF.zero().scaleMass(0);
|
|
@@ -1361,8 +1563,14 @@ var _PMF = class _PMF {
|
|
|
1361
1563
|
if (p === 1) return successPMF.scaleMass(1);
|
|
1362
1564
|
const eps = successPMF.epsilon ?? failurePMF.epsilon;
|
|
1363
1565
|
const id = `branch(${failurePMF.identifier}*${q.toFixed(6)} + ${successPMF.identifier}*${p.toFixed(6)})`;
|
|
1364
|
-
const
|
|
1365
|
-
|
|
1566
|
+
const resultMap = /* @__PURE__ */ new Map();
|
|
1567
|
+
for (const [damageValue, bin] of failurePMF.map) {
|
|
1568
|
+
_PMF.mergeInto(resultMap, damageValue, _PMF.scaleBin(bin, q));
|
|
1569
|
+
}
|
|
1570
|
+
for (const [damageValue, bin] of successPMF.map) {
|
|
1571
|
+
_PMF.mergeInto(resultMap, damageValue, _PMF.scaleBin(bin, p));
|
|
1572
|
+
}
|
|
1573
|
+
return new _PMF(resultMap, eps, false, id);
|
|
1366
1574
|
}
|
|
1367
1575
|
/**
|
|
1368
1576
|
* withProbability()
|
|
@@ -1410,8 +1618,8 @@ var _PMF = class _PMF {
|
|
|
1410
1618
|
* @param fallback PMF to apply when this PMF is *not* selected.
|
|
1411
1619
|
* @returns A new PMF representing the weighted mixture of this PMF and the fallback.
|
|
1412
1620
|
*/
|
|
1413
|
-
gate(p,
|
|
1414
|
-
return _PMF.branch(this,
|
|
1621
|
+
gate(p, fallback) {
|
|
1622
|
+
return _PMF.branch(this, fallback, p);
|
|
1415
1623
|
}
|
|
1416
1624
|
/**
|
|
1417
1625
|
* PMF.exclusive()
|
|
@@ -1494,13 +1702,23 @@ var _PMF = class _PMF {
|
|
|
1494
1702
|
*
|
|
1495
1703
|
* @returns New PMF with attr field populated in each bin
|
|
1496
1704
|
*/
|
|
1497
|
-
|
|
1705
|
+
/**
|
|
1706
|
+
* Returns true if this PMF already carries damage attribution metadata.
|
|
1707
|
+
*
|
|
1708
|
+
* Only the first positive-damage bin is inspected (parser-generated PMFs
|
|
1709
|
+
* populate `attr` uniformly), so this is O(1) in practice.
|
|
1710
|
+
*/
|
|
1711
|
+
hasAttribution() {
|
|
1498
1712
|
for (const [damage, bin] of this.map) {
|
|
1499
1713
|
if (damage !== 0 && bin.attr && Object.keys(bin.attr).length > 0) {
|
|
1500
|
-
return
|
|
1714
|
+
return true;
|
|
1501
1715
|
}
|
|
1502
1716
|
if (damage > 0) break;
|
|
1503
1717
|
}
|
|
1718
|
+
return false;
|
|
1719
|
+
}
|
|
1720
|
+
withAttribution() {
|
|
1721
|
+
if (this.hasAttribution()) return this;
|
|
1504
1722
|
const newMap = /* @__PURE__ */ new Map();
|
|
1505
1723
|
for (const [damage, bin] of this.map) {
|
|
1506
1724
|
const attr = {};
|
|
@@ -1617,7 +1835,7 @@ var _PMF = class _PMF {
|
|
|
1617
1835
|
*/
|
|
1618
1836
|
replicate(n) {
|
|
1619
1837
|
if (!Number.isInteger(n) || n <= 0) {
|
|
1620
|
-
throw new Error("
|
|
1838
|
+
throw new Error("replicate(n): n must be a positive integer");
|
|
1621
1839
|
}
|
|
1622
1840
|
if (n === 1) return [this];
|
|
1623
1841
|
return Array.from({ length: n }, () => this);
|
|
@@ -1649,7 +1867,6 @@ var _PMF = class _PMF {
|
|
|
1649
1867
|
if (normalizationFactor === 0) return this;
|
|
1650
1868
|
const normalizedMap = /* @__PURE__ */ new Map();
|
|
1651
1869
|
for (const [damageValue, probabilityBin] of this.map) {
|
|
1652
|
-
const normalizedProbability = probabilityBin.p / normalizationFactor;
|
|
1653
1870
|
const normalizedCount = {};
|
|
1654
1871
|
for (const labelKey in probabilityBin.count) {
|
|
1655
1872
|
normalizedCount[labelKey] = probabilityBin.count[labelKey] / normalizationFactor;
|
|
@@ -1662,7 +1879,7 @@ var _PMF = class _PMF {
|
|
|
1662
1879
|
}
|
|
1663
1880
|
}
|
|
1664
1881
|
normalizedMap.set(damageValue, {
|
|
1665
|
-
p:
|
|
1882
|
+
p: probabilityBin.p / normalizationFactor,
|
|
1666
1883
|
count: normalizedCount,
|
|
1667
1884
|
attr: normalizedAttributes
|
|
1668
1885
|
});
|
|
@@ -1685,22 +1902,23 @@ var _PMF = class _PMF {
|
|
|
1685
1902
|
for (const [damageValue, probabilityBin] of this.map) {
|
|
1686
1903
|
const shouldKeep = probabilityBin.p >= eps || keepFinalBin && damageValue === maxKey;
|
|
1687
1904
|
if (!shouldKeep) continue;
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1905
|
+
const cleanedBin = _PMF.cloneBin(probabilityBin);
|
|
1906
|
+
for (const labelKey in cleanedBin.count) {
|
|
1907
|
+
if (Math.abs(cleanedBin.count[labelKey] || 0) < eps) {
|
|
1908
|
+
delete cleanedBin.count[labelKey];
|
|
1691
1909
|
}
|
|
1692
1910
|
}
|
|
1693
|
-
if (
|
|
1694
|
-
for (const labelKey in
|
|
1695
|
-
if (Math.abs(
|
|
1696
|
-
delete
|
|
1911
|
+
if (cleanedBin.attr) {
|
|
1912
|
+
for (const labelKey in cleanedBin.attr) {
|
|
1913
|
+
if (Math.abs(cleanedBin.attr[labelKey] || 0) < eps) {
|
|
1914
|
+
delete cleanedBin.attr[labelKey];
|
|
1697
1915
|
}
|
|
1698
1916
|
}
|
|
1699
|
-
if (Object.keys(
|
|
1700
|
-
|
|
1917
|
+
if (Object.keys(cleanedBin.attr).length === 0) {
|
|
1918
|
+
cleanedBin.attr = void 0;
|
|
1701
1919
|
}
|
|
1702
1920
|
}
|
|
1703
|
-
compactedMap.set(damageValue,
|
|
1921
|
+
compactedMap.set(damageValue, cleanedBin);
|
|
1704
1922
|
}
|
|
1705
1923
|
return new _PMF(compactedMap, eps, this.normalized, this.identifier);
|
|
1706
1924
|
}
|
|
@@ -1767,14 +1985,33 @@ var _PMF = class _PMF {
|
|
|
1767
1985
|
}
|
|
1768
1986
|
return this._stdev;
|
|
1769
1987
|
}
|
|
1988
|
+
/** Deep-copies a Bin, cloning its count and (optional) attr maps. */
|
|
1989
|
+
static cloneBin(bin) {
|
|
1990
|
+
return {
|
|
1991
|
+
p: bin.p,
|
|
1992
|
+
count: { ...bin.count },
|
|
1993
|
+
attr: bin.attr ? { ...bin.attr } : void 0
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
/** Returns a new Bin with p, count, and attr all multiplied by `factor`. */
|
|
1997
|
+
static scaleBin(bin, factor) {
|
|
1998
|
+
const count = {};
|
|
1999
|
+
for (const k in bin.count) {
|
|
2000
|
+
count[k] = bin.count[k] * factor;
|
|
2001
|
+
}
|
|
2002
|
+
let attr;
|
|
2003
|
+
if (bin.attr) {
|
|
2004
|
+
attr = {};
|
|
2005
|
+
for (const k in bin.attr) {
|
|
2006
|
+
attr[k] = bin.attr[k] * factor;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
return { p: bin.p * factor, count, attr };
|
|
2010
|
+
}
|
|
1770
2011
|
static mergeInto(destinationMap, damageValue, binToAdd) {
|
|
1771
2012
|
const existingBin = destinationMap.get(damageValue);
|
|
1772
2013
|
if (!existingBin) {
|
|
1773
|
-
destinationMap.set(damageValue,
|
|
1774
|
-
p: binToAdd.p,
|
|
1775
|
-
count: { ...binToAdd.count },
|
|
1776
|
-
attr: binToAdd.attr ? { ...binToAdd.attr } : void 0
|
|
1777
|
-
});
|
|
2014
|
+
destinationMap.set(damageValue, _PMF.cloneBin(binToAdd));
|
|
1778
2015
|
return;
|
|
1779
2016
|
}
|
|
1780
2017
|
existingBin.p += binToAdd.p;
|
|
@@ -1805,29 +2042,14 @@ var _PMF = class _PMF {
|
|
|
1805
2042
|
if (probability === 0) return this;
|
|
1806
2043
|
const resultMap = /* @__PURE__ */ new Map();
|
|
1807
2044
|
for (const [dmg, bin] of this.map) {
|
|
1808
|
-
resultMap.set(dmg,
|
|
1809
|
-
p: bin.p,
|
|
1810
|
-
count: { ...bin.count },
|
|
1811
|
-
attr: bin.attr ? { ...bin.attr } : void 0
|
|
1812
|
-
});
|
|
2045
|
+
resultMap.set(dmg, _PMF.cloneBin(bin));
|
|
1813
2046
|
}
|
|
1814
2047
|
for (const [damageValue, probabilityBin] of branch.map) {
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
if (probabilityBin.attr) {
|
|
1821
|
-
scaledAttributes = {};
|
|
1822
|
-
for (const k in probabilityBin.attr) {
|
|
1823
|
-
scaledAttributes[k] = probability * probabilityBin.attr[k];
|
|
1824
|
-
}
|
|
1825
|
-
}
|
|
1826
|
-
_PMF.mergeInto(resultMap, damageValue, {
|
|
1827
|
-
p: probability * probabilityBin.p,
|
|
1828
|
-
count: scaledCount,
|
|
1829
|
-
attr: scaledAttributes
|
|
1830
|
-
});
|
|
2048
|
+
_PMF.mergeInto(
|
|
2049
|
+
resultMap,
|
|
2050
|
+
damageValue,
|
|
2051
|
+
_PMF.scaleBin(probabilityBin, probability)
|
|
2052
|
+
);
|
|
1831
2053
|
}
|
|
1832
2054
|
return new _PMF(
|
|
1833
2055
|
resultMap,
|
|
@@ -1836,26 +2058,54 @@ var _PMF = class _PMF {
|
|
|
1836
2058
|
`${this.identifier}+scaled(${branch.identifier},${probability})`
|
|
1837
2059
|
);
|
|
1838
2060
|
}
|
|
2061
|
+
/**
|
|
2062
|
+
* Redistributes probability mass to model an effect that only occurs with
|
|
2063
|
+
* probability `frequency` — a conditional attack, an on-hit rider, or a
|
|
2064
|
+
* sub-one AoE target fraction.
|
|
2065
|
+
*
|
|
2066
|
+
* Every hit outcome (damage > 0) is scaled by `frequency` — probability mass,
|
|
2067
|
+
* per-label `count`, AND per-label `attr` — and the freed mass is moved into
|
|
2068
|
+
* the miss bin at damage 0, tagged with the canonical `missNone` outcome.
|
|
2069
|
+
* Total probability mass is preserved.
|
|
2070
|
+
*
|
|
2071
|
+
* Unlike a bare {@link scaleMass} or {@link mapDamage}, this keeps damage
|
|
2072
|
+
* attribution (`attr`) intact, so a frequency-scaled PMF still renders
|
|
2073
|
+
* correctly in the damage-attribution charts.
|
|
2074
|
+
*
|
|
2075
|
+
* `frequency >= 1` (or non-finite) returns this PMF unchanged; `frequency <= 0`
|
|
2076
|
+
* collapses all mass into the miss bin. The miss outcome is assumed to be
|
|
2077
|
+
* encoded at damage value 0.
|
|
2078
|
+
*
|
|
2079
|
+
* @param frequency Probability in [0, 1] that the effect occurs.
|
|
2080
|
+
*/
|
|
2081
|
+
applyHitFrequency(frequency) {
|
|
2082
|
+
if (!Number.isFinite(frequency) || frequency >= 1) return this;
|
|
2083
|
+
const freq = Math.max(0, frequency);
|
|
2084
|
+
const pMiss = this.pAt(0);
|
|
2085
|
+
const pHit = 1 - pMiss;
|
|
2086
|
+
const newMissMass = pMiss + (1 - freq) * pHit;
|
|
2087
|
+
const newMap = /* @__PURE__ */ new Map();
|
|
2088
|
+
newMap.set(0, {
|
|
2089
|
+
p: newMissMass,
|
|
2090
|
+
count: { [MISS_NONE_OUTCOME]: newMissMass },
|
|
2091
|
+
attr: {}
|
|
2092
|
+
});
|
|
2093
|
+
for (const [damage, bin] of this.map) {
|
|
2094
|
+
if (damage <= 0) continue;
|
|
2095
|
+
newMap.set(damage, _PMF.scaleBin(bin, freq));
|
|
2096
|
+
}
|
|
2097
|
+
return new _PMF(
|
|
2098
|
+
newMap,
|
|
2099
|
+
this.epsilon,
|
|
2100
|
+
false,
|
|
2101
|
+
`freq(${this.identifier},${freq})`
|
|
2102
|
+
);
|
|
2103
|
+
}
|
|
1839
2104
|
scaleMass(factor) {
|
|
1840
2105
|
if (factor === 1) return this;
|
|
1841
2106
|
const scaledMap = /* @__PURE__ */ new Map();
|
|
1842
2107
|
for (const [damageValue, probabilityBin] of this.map) {
|
|
1843
|
-
|
|
1844
|
-
for (const labelKey in probabilityBin.count) {
|
|
1845
|
-
scaledCount[labelKey] = probabilityBin.count[labelKey] * factor;
|
|
1846
|
-
}
|
|
1847
|
-
let scaledAttributes;
|
|
1848
|
-
if (probabilityBin.attr) {
|
|
1849
|
-
scaledAttributes = {};
|
|
1850
|
-
for (const labelKey in probabilityBin.attr) {
|
|
1851
|
-
scaledAttributes[labelKey] = probabilityBin.attr[labelKey] * factor;
|
|
1852
|
-
}
|
|
1853
|
-
}
|
|
1854
|
-
scaledMap.set(damageValue, {
|
|
1855
|
-
p: probabilityBin.p * factor,
|
|
1856
|
-
count: scaledCount,
|
|
1857
|
-
attr: scaledAttributes
|
|
1858
|
-
});
|
|
2108
|
+
scaledMap.set(damageValue, _PMF.scaleBin(probabilityBin, factor));
|
|
1859
2109
|
}
|
|
1860
2110
|
return new _PMF(
|
|
1861
2111
|
scaledMap,
|
|
@@ -1868,11 +2118,11 @@ var _PMF = class _PMF {
|
|
|
1868
2118
|
const transformedMap = /* @__PURE__ */ new Map();
|
|
1869
2119
|
for (const [originalDamage, probabilityBin] of this.map) {
|
|
1870
2120
|
const transformedDamage = damageTransformFunction(originalDamage);
|
|
1871
|
-
_PMF.mergeInto(
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
2121
|
+
_PMF.mergeInto(
|
|
2122
|
+
transformedMap,
|
|
2123
|
+
transformedDamage,
|
|
2124
|
+
_PMF.cloneBin(probabilityBin)
|
|
2125
|
+
);
|
|
1876
2126
|
}
|
|
1877
2127
|
return new _PMF(
|
|
1878
2128
|
transformedMap,
|
|
@@ -1887,14 +2137,21 @@ var _PMF = class _PMF {
|
|
|
1887
2137
|
}
|
|
1888
2138
|
getPMFCombineCacheKey(p1, p2, eps, raw) {
|
|
1889
2139
|
const [id1, id2] = [p1.identifier, p2.identifier].sort();
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
2140
|
+
return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${p1.fingerprint()}|${p2.fingerprint()}`;
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* A small content fingerprint (mass + bin count + face sum) so convolution
|
|
2144
|
+
* cache keys change if the underlying numbers do. Memoized because a PMF is
|
|
2145
|
+
* immutable once constructed — this avoids re-summing every key on each
|
|
2146
|
+
* convolve() call (including cache hits).
|
|
2147
|
+
*/
|
|
2148
|
+
fingerprint() {
|
|
2149
|
+
if (this._fingerprint === void 0) {
|
|
1893
2150
|
let faceSum = 0;
|
|
1894
|
-
for (const k of
|
|
1895
|
-
|
|
1896
|
-
}
|
|
1897
|
-
return
|
|
2151
|
+
for (const k of this.map.keys()) faceSum += k;
|
|
2152
|
+
this._fingerprint = `${this.mass().toFixed(12)}|${this.map.size}|${faceSum}`;
|
|
2153
|
+
}
|
|
2154
|
+
return this._fingerprint;
|
|
1898
2155
|
}
|
|
1899
2156
|
convolve(other, eps, raw = false) {
|
|
1900
2157
|
const epsilon = eps ?? this.epsilon;
|
|
@@ -1907,25 +2164,34 @@ var _PMF = class _PMF {
|
|
|
1907
2164
|
if (cached) return cached;
|
|
1908
2165
|
const combinedMap = /* @__PURE__ */ new Map();
|
|
1909
2166
|
for (const [aVal, aBin] of A.map) {
|
|
2167
|
+
const ap = aBin.p;
|
|
2168
|
+
const aCount = aBin.count;
|
|
2169
|
+
const aAttr = aBin.attr;
|
|
1910
2170
|
for (const [bVal, bBin] of B.map) {
|
|
1911
|
-
const
|
|
2171
|
+
const bp = bBin.p;
|
|
1912
2172
|
const dmg = aVal + bVal;
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
2173
|
+
let dest = combinedMap.get(dmg);
|
|
2174
|
+
if (dest === void 0) {
|
|
2175
|
+
dest = { p: 0, count: {} };
|
|
2176
|
+
combinedMap.set(dmg, dest);
|
|
2177
|
+
}
|
|
2178
|
+
dest.p += ap * bp;
|
|
2179
|
+
const dc = dest.count;
|
|
2180
|
+
for (const k in aCount) dc[k] = (dc[k] || 0) + aCount[k] * bp;
|
|
1916
2181
|
for (const k in bBin.count)
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
2182
|
+
dc[k] = (dc[k] || 0) + bBin.count[k] * ap;
|
|
2183
|
+
if (aAttr || bBin.attr) {
|
|
2184
|
+
let da = dest.attr;
|
|
2185
|
+
if (da === void 0) {
|
|
2186
|
+
da = {};
|
|
2187
|
+
dest.attr = da;
|
|
2188
|
+
}
|
|
2189
|
+
if (aAttr)
|
|
2190
|
+
for (const k in aAttr) da[k] = (da[k] || 0) + aAttr[k] * bp;
|
|
1924
2191
|
if (bBin.attr)
|
|
1925
2192
|
for (const k in bBin.attr)
|
|
1926
|
-
|
|
2193
|
+
da[k] = (da[k] || 0) + bBin.attr[k] * ap;
|
|
1927
2194
|
}
|
|
1928
|
-
_PMF.mergeInto(combinedMap, dmg, { p, count, attr });
|
|
1929
2195
|
}
|
|
1930
2196
|
}
|
|
1931
2197
|
let result = new _PMF(
|
|
@@ -1936,10 +2202,10 @@ var _PMF = class _PMF {
|
|
|
1936
2202
|
);
|
|
1937
2203
|
const mExp = (raw ? A.mass() : 1) * (raw ? B.mass() : 1);
|
|
1938
2204
|
const mGot = result.mass();
|
|
1939
|
-
if (mExp !== 0 && Math.abs(mGot - mExp) > epsilon) {
|
|
2205
|
+
if (mExp !== 0 && mGot !== 0 && Math.abs(mGot - mExp) > epsilon) {
|
|
1940
2206
|
result = result.scaleMass(mExp / mGot);
|
|
1941
2207
|
}
|
|
1942
|
-
if (!raw && Math.abs(result.mass() - 1) > epsilon)
|
|
2208
|
+
if (!raw && mGot !== 0 && Math.abs(result.mass() - 1) > epsilon)
|
|
1943
2209
|
result = result.normalize();
|
|
1944
2210
|
pmfCache?.set(cacheKey, result);
|
|
1945
2211
|
return result;
|
|
@@ -1948,27 +2214,6 @@ var _PMF = class _PMF {
|
|
|
1948
2214
|
combineRaw(other, eps) {
|
|
1949
2215
|
return this.convolve(other, eps, true);
|
|
1950
2216
|
}
|
|
1951
|
-
// Collapse repeated identical PMFs using power() and return a sorted list
|
|
1952
|
-
// Temporarily disabled for now. This was used for a performance optimization, but can lose data provenance.
|
|
1953
|
-
// private static collapseIdentical(pmfList: PMF[], eps: number): PMF[] {
|
|
1954
|
-
// const grouped = new Map<string, { pmf: PMF; count: number }>();
|
|
1955
|
-
// for (const pmf of pmfList) {
|
|
1956
|
-
// const id = pmf.identifier;
|
|
1957
|
-
// const g = grouped.get(id);
|
|
1958
|
-
// if (g) g.count++;
|
|
1959
|
-
// else grouped.set(id, { pmf, count: 1 });
|
|
1960
|
-
// }
|
|
1961
|
-
// if (grouped.size >= pmfList.length) return pmfList;
|
|
1962
|
-
// const collapsed: PMF[] = [];
|
|
1963
|
-
// for (const { pmf, count } of grouped.values()) {
|
|
1964
|
-
// collapsed.push(count > 1 ? pmf.power(count, eps) : pmf);
|
|
1965
|
-
// }
|
|
1966
|
-
// // Stable order for better cache locality
|
|
1967
|
-
// collapsed.sort((a, b) =>
|
|
1968
|
-
// a.identifier < b.identifier ? -1 : a.identifier > b.identifier ? 1 : 0
|
|
1969
|
-
// );
|
|
1970
|
-
// return collapsed;
|
|
1971
|
-
// }
|
|
1972
2217
|
// Reduce a list of PMFs by left-folding convolve() with the given eps
|
|
1973
2218
|
static reduceConvolveLeft(pmfList, eps) {
|
|
1974
2219
|
let result = pmfList[0];
|
|
@@ -1992,12 +2237,23 @@ var _PMF = class _PMF {
|
|
|
1992
2237
|
if (pmfList.length === 1) return pmfList[0];
|
|
1993
2238
|
return _PMF.reduceConvolveLeft(pmfList, eps);
|
|
1994
2239
|
}
|
|
2240
|
+
/**
|
|
2241
|
+
* Returns a plain, JSON-serializable representation of this PMF.
|
|
2242
|
+
*
|
|
2243
|
+
* Follows the standard `toJSON` contract, so `JSON.stringify(pmf)` produces
|
|
2244
|
+
* the expected output (no double-encoding). Use {@link PMF.fromJSON} to
|
|
2245
|
+
* reconstruct, or {@link PMF.toJSONString} if you need the string directly.
|
|
2246
|
+
*/
|
|
1995
2247
|
toJSON() {
|
|
1996
|
-
return
|
|
2248
|
+
return {
|
|
1997
2249
|
bins: [...this.map.entries()],
|
|
1998
2250
|
normalized: this.normalized,
|
|
1999
2251
|
identifier: this.identifier
|
|
2000
|
-
}
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
/** Serializes this PMF to a JSON string (equivalent to `JSON.stringify(pmf)`). */
|
|
2255
|
+
toJSONString() {
|
|
2256
|
+
return JSON.stringify(this);
|
|
2001
2257
|
}
|
|
2002
2258
|
static fromJSON(jsonData) {
|
|
2003
2259
|
return new _PMF(
|
|
@@ -2068,11 +2324,43 @@ var _PMF = class _PMF {
|
|
|
2068
2324
|
}
|
|
2069
2325
|
return new _PMF(prunedMap, epsRel, false, `prune(${this.identifier})`);
|
|
2070
2326
|
}
|
|
2071
|
-
/** NEW - REVIEW IF THESE ARE USEFUL OR DUPLCIATIVE? */
|
|
2072
2327
|
/** Probability mass at exactly x. */
|
|
2073
2328
|
pAt(x) {
|
|
2074
2329
|
return this.map.get(x)?.p ?? 0;
|
|
2075
2330
|
}
|
|
2331
|
+
/**
|
|
2332
|
+
* P(any damage) — the mass on all non-zero outcomes, i.e. `1 - P(0)`.
|
|
2333
|
+
* Assumes a miss is encoded as the damage-0 bin (the convention used across
|
|
2334
|
+
* attack/save PMFs). The dual of {@link missProbability}.
|
|
2335
|
+
*/
|
|
2336
|
+
hitProbability() {
|
|
2337
|
+
return 1 - this.pAt(0);
|
|
2338
|
+
}
|
|
2339
|
+
/** P(no damage) — the mass at damage 0. The dual of {@link hitProbability}. */
|
|
2340
|
+
missProbability() {
|
|
2341
|
+
return this.pAt(0);
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Coarsen the distribution into at most `maxBuckets` contiguous, equal-width
|
|
2345
|
+
* damage buckets, aggregating probability mass (and `count`/`attr`
|
|
2346
|
+
* provenance) into each bucket's start value. Returns this PMF unchanged when
|
|
2347
|
+
* its integer support already fits within `maxBuckets`.
|
|
2348
|
+
*
|
|
2349
|
+
* This is a lossy display/downsampling transform (bucket start replaces the
|
|
2350
|
+
* exact damage value) — use it for charting wide distributions, not for DPR
|
|
2351
|
+
* math.
|
|
2352
|
+
*/
|
|
2353
|
+
rebin(maxBuckets) {
|
|
2354
|
+
if (!(maxBuckets > 0)) return this;
|
|
2355
|
+
const support = this.support();
|
|
2356
|
+
if (support.length === 0) return this;
|
|
2357
|
+
const min = support[0];
|
|
2358
|
+
const max = support[support.length - 1];
|
|
2359
|
+
const range = max - min;
|
|
2360
|
+
if (range + 1 <= maxBuckets) return this;
|
|
2361
|
+
const binSize = Math.ceil((range + 1) / maxBuckets);
|
|
2362
|
+
return this.mapDamage((d) => min + Math.floor((d - min) / binSize) * binSize);
|
|
2363
|
+
}
|
|
2076
2364
|
/** Dense integer support from min..max (inclusive).
|
|
2077
2365
|
* Useful for showing empty bars in charts.
|
|
2078
2366
|
*/
|
|
@@ -2148,11 +2436,60 @@ var _PMF = class _PMF {
|
|
|
2148
2436
|
}
|
|
2149
2437
|
return false;
|
|
2150
2438
|
}
|
|
2439
|
+
/**
|
|
2440
|
+
* Split each damage value's probability mass across outcome labels, returning
|
|
2441
|
+
* per-label maps of `damage value → probability mass attributable to that
|
|
2442
|
+
* label`. Summing over labels at a given value recovers that value's `p`.
|
|
2443
|
+
*
|
|
2444
|
+
* Damage-bearing bins are split by `attr` weight (the share of damage each
|
|
2445
|
+
* outcome contributed); the clean-miss bin at 0 is split by `count` weight
|
|
2446
|
+
* (there is no damage to attribute). Attribution is computed on demand via
|
|
2447
|
+
* {@link withAttribution} when absent, so builder-generated PMFs work too.
|
|
2448
|
+
*
|
|
2449
|
+
* This is the provenance core of the stacked damage-attribution chart — the
|
|
2450
|
+
* caller only maps these series into its rendering format (colors, binning,
|
|
2451
|
+
* axis labels).
|
|
2452
|
+
*/
|
|
2453
|
+
attributionByValue() {
|
|
2454
|
+
const src = this.hasAttribution() ? this : this.withAttribution();
|
|
2455
|
+
const result = /* @__PURE__ */ new Map();
|
|
2456
|
+
const add = (label, damage, mass) => {
|
|
2457
|
+
if (!(mass > 0)) return;
|
|
2458
|
+
let series = result.get(label);
|
|
2459
|
+
if (!series) {
|
|
2460
|
+
series = /* @__PURE__ */ new Map();
|
|
2461
|
+
result.set(label, series);
|
|
2462
|
+
}
|
|
2463
|
+
series.set(damage, (series.get(damage) ?? 0) + mass);
|
|
2464
|
+
};
|
|
2465
|
+
for (const [damage, bin] of src.map) {
|
|
2466
|
+
const p = bin.p || 0;
|
|
2467
|
+
if (p <= 0) continue;
|
|
2468
|
+
const isMissBin = damage === 0;
|
|
2469
|
+
if (isMissBin) {
|
|
2470
|
+
let totalCount = 0;
|
|
2471
|
+
for (const k in bin.count) totalCount += bin.count[k] || 0;
|
|
2472
|
+
if (totalCount > 0) {
|
|
2473
|
+
const c = bin.count[MISS_NONE_OUTCOME] || 0;
|
|
2474
|
+
add(MISS_NONE_OUTCOME, damage, c / totalCount * p);
|
|
2475
|
+
}
|
|
2476
|
+
continue;
|
|
2477
|
+
}
|
|
2478
|
+
let totalAttr = 0;
|
|
2479
|
+
if (bin.attr) for (const k in bin.attr) totalAttr += bin.attr[k] || 0;
|
|
2480
|
+
if (bin.attr && totalAttr > 0) {
|
|
2481
|
+
for (const k in bin.attr) {
|
|
2482
|
+
if (k === MISS_NONE_OUTCOME) continue;
|
|
2483
|
+
add(k, damage, (bin.attr[k] || 0) / totalAttr * p);
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
return result;
|
|
2488
|
+
}
|
|
2151
2489
|
tailProbGE(t) {
|
|
2152
2490
|
let s = 0;
|
|
2153
|
-
for (const [x,
|
|
2154
|
-
|
|
2155
|
-
if (p > 0 && x >= t) s += p;
|
|
2491
|
+
for (const [x, bin] of this) {
|
|
2492
|
+
if (bin.p > 0 && x >= t) s += bin.p;
|
|
2156
2493
|
}
|
|
2157
2494
|
return s;
|
|
2158
2495
|
}
|
|
@@ -2213,6 +2550,11 @@ var _PMF = class _PMF {
|
|
|
2213
2550
|
* - pAny: Probability that at least one success occurred
|
|
2214
2551
|
*/
|
|
2215
2552
|
static firstSuccessWeights(pSuccess, pSpecial, n) {
|
|
2553
|
+
if (!Number.isFinite(pSuccess) || !Number.isFinite(pSpecial) || pSuccess < 0 || pSuccess > 1 || pSpecial < 0 || pSpecial - pSuccess > EPS) {
|
|
2554
|
+
throw new Error(
|
|
2555
|
+
`firstSuccessWeights: require 0 <= pSpecial <= pSuccess <= 1 (got pSuccess=${pSuccess}, pSpecial=${pSpecial})`
|
|
2556
|
+
);
|
|
2557
|
+
}
|
|
2216
2558
|
const pFail = 1 - pSuccess;
|
|
2217
2559
|
const pFailAll = Math.pow(pFail, n);
|
|
2218
2560
|
const pAny = 1 - pFailAll;
|
|
@@ -2228,13 +2570,12 @@ var _PMF = class _PMF {
|
|
|
2228
2570
|
const round = (x) => rounding === "floor" ? Math.floor(x) : rounding === "ceil" ? Math.ceil(x) : rounding === "round" ? Math.round(x) : x;
|
|
2229
2571
|
const probs = /* @__PURE__ */ new Map();
|
|
2230
2572
|
const counts = /* @__PURE__ */ new Map();
|
|
2231
|
-
for (const [v,
|
|
2232
|
-
if (Math.abs(
|
|
2573
|
+
for (const [v, bin] of this) {
|
|
2574
|
+
if (Math.abs(bin.p) < eps) continue;
|
|
2233
2575
|
const u = round(f(v));
|
|
2234
|
-
probs.set(u, (probs.get(u) ?? 0) +
|
|
2576
|
+
probs.set(u, (probs.get(u) ?? 0) + bin.p);
|
|
2235
2577
|
if (preserveCounts) {
|
|
2236
|
-
const
|
|
2237
|
-
const src = typeof rec2 === "number" ? void 0 : rec2?.count;
|
|
2578
|
+
const src = bin.count;
|
|
2238
2579
|
if (src) {
|
|
2239
2580
|
const dest = counts.get(u) ?? {};
|
|
2240
2581
|
for (const k in src) {
|
|
@@ -2287,17 +2628,20 @@ var _PMF = class _PMF {
|
|
|
2287
2628
|
}
|
|
2288
2629
|
};
|
|
2289
2630
|
// Unique ID generator for anonymous PMFs to avoid cache key collisions
|
|
2290
|
-
|
|
2631
|
+
_PMF.__anonIdCounter = 1;
|
|
2291
2632
|
var PMF = _PMF;
|
|
2292
2633
|
|
|
2293
2634
|
// src/parser/dice.ts
|
|
2635
|
+
var MAX_BINARY_OUTCOMES = 1e8;
|
|
2294
2636
|
var Dice = class _Dice {
|
|
2295
2637
|
constructor(x = 0) {
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2638
|
+
this.faces = {};
|
|
2639
|
+
this.privateData = {};
|
|
2640
|
+
// Partial: the object starts empty and gains keys as outcomes are recorded,
|
|
2641
|
+
// so the type must not claim every OutcomeType is present. (Previously typed
|
|
2642
|
+
// as a full Record via an `as` cast, which lied about missing keys.)
|
|
2643
|
+
this.outcomeData = {};
|
|
2644
|
+
this.hasHitDistributionCalculated = false;
|
|
2301
2645
|
if (x <= 0) return;
|
|
2302
2646
|
for (let i = 1; i <= x; i++) {
|
|
2303
2647
|
this.faces[i] = 1;
|
|
@@ -2348,27 +2692,27 @@ var Dice = class _Dice {
|
|
|
2348
2692
|
// TODO this can be private later if we change how testing works
|
|
2349
2693
|
calculateHitDistribution() {
|
|
2350
2694
|
const hitValues = {};
|
|
2695
|
+
const subtractedOutcomes = [
|
|
2696
|
+
this.outcomeData.crit,
|
|
2697
|
+
this.outcomeData.missNone,
|
|
2698
|
+
this.outcomeData.missDamage,
|
|
2699
|
+
this.outcomeData.saveHalf,
|
|
2700
|
+
this.outcomeData.saveFail,
|
|
2701
|
+
this.outcomeData.pc
|
|
2702
|
+
];
|
|
2351
2703
|
for (const [face, totalCount] of Object.entries(this.faces)) {
|
|
2352
2704
|
const numFace = Number(face);
|
|
2353
2705
|
let hitCount = totalCount;
|
|
2354
|
-
for (const
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
"saveHalf",
|
|
2359
|
-
"saveFail",
|
|
2360
|
-
"pc"
|
|
2361
|
-
]) {
|
|
2362
|
-
const distribution = this.getOutcomeDistribution(outcomeType);
|
|
2363
|
-
if (distribution && distribution[numFace]) {
|
|
2364
|
-
hitCount -= distribution[numFace];
|
|
2706
|
+
for (const distribution of subtractedOutcomes) {
|
|
2707
|
+
const outcomeCount = distribution?.[numFace];
|
|
2708
|
+
if (outcomeCount) {
|
|
2709
|
+
hitCount -= outcomeCount;
|
|
2365
2710
|
}
|
|
2366
2711
|
}
|
|
2367
2712
|
if (numFace === 0) {
|
|
2368
2713
|
hitCount = 0;
|
|
2369
2714
|
}
|
|
2370
2715
|
if (hitCount < 0) {
|
|
2371
|
-
console.error("hitCount is <=0?", face, totalCount, hitCount);
|
|
2372
2716
|
hitCount = 0;
|
|
2373
2717
|
}
|
|
2374
2718
|
hitValues[numFace] = hitCount;
|
|
@@ -2387,13 +2731,18 @@ var Dice = class _Dice {
|
|
|
2387
2731
|
const result = diceConstructor ? diceConstructor() : new _Dice();
|
|
2388
2732
|
const isScalar = typeof other === "number";
|
|
2389
2733
|
const keys1 = this.keys();
|
|
2734
|
+
const keys2 = isScalar ? [] : other.keys();
|
|
2735
|
+
if (!isScalar && keys1.length * keys2.length > MAX_BINARY_OUTCOMES) {
|
|
2736
|
+
throw new DiceParseError(
|
|
2737
|
+
`Dice operation over ${keys1.length}\xD7${keys2.length} face pairs exceeds the maximum of ${MAX_BINARY_OUTCOMES}`
|
|
2738
|
+
);
|
|
2739
|
+
}
|
|
2390
2740
|
for (const key1 of keys1) {
|
|
2391
2741
|
const value1 = this.faces[key1];
|
|
2392
2742
|
if (isScalar) {
|
|
2393
2743
|
const resultKey = op(key1, other);
|
|
2394
2744
|
result.increment(resultKey, value1);
|
|
2395
2745
|
} else {
|
|
2396
|
-
const keys2 = other.keys();
|
|
2397
2746
|
for (const key2 of keys2) {
|
|
2398
2747
|
const value2 = other.faces[key2];
|
|
2399
2748
|
const resultKey = op(key1, key2);
|
|
@@ -2415,7 +2764,7 @@ var Dice = class _Dice {
|
|
|
2415
2764
|
result.outcomeData = { ...this.outcomeData };
|
|
2416
2765
|
return result;
|
|
2417
2766
|
}
|
|
2418
|
-
// PUBLIC
|
|
2767
|
+
// PUBLIC FUNCTIONS
|
|
2419
2768
|
getFaceEntries() {
|
|
2420
2769
|
return Object.entries(this.faces).map(([k, v]) => [Number(k), v]);
|
|
2421
2770
|
}
|
|
@@ -2545,10 +2894,12 @@ var Dice = class _Dice {
|
|
|
2545
2894
|
}
|
|
2546
2895
|
reroll(toReroll) {
|
|
2547
2896
|
const rerollDice = typeof toReroll === "number" ? _Dice.scalar(toReroll) : toReroll;
|
|
2548
|
-
const
|
|
2897
|
+
const rerollKeys = rerollDice.keys();
|
|
2898
|
+
const rerollSet = new Set(rerollKeys);
|
|
2899
|
+
const removed = this.removeFaces(rerollKeys);
|
|
2549
2900
|
let result = new _Dice();
|
|
2550
2901
|
for (const face of this.keys()) {
|
|
2551
|
-
const wasRerolled =
|
|
2902
|
+
const wasRerolled = rerollSet.has(face);
|
|
2552
2903
|
result = result.combine(removed);
|
|
2553
2904
|
if (wasRerolled) {
|
|
2554
2905
|
result = result.combine(this);
|
|
@@ -2616,17 +2967,10 @@ var Dice = class _Dice {
|
|
|
2616
2967
|
const missDistro = this.getOutcomeDistribution("missDamage") || {};
|
|
2617
2968
|
const saveDistro = this.getOutcomeDistribution("saveHalf") || {};
|
|
2618
2969
|
const pcDistro = this.getOutcomeDistribution("pc") || {};
|
|
2619
|
-
|
|
2620
|
-
for (const halfDamage of Object.keys(saveDistro).map(Number)) {
|
|
2621
|
-
const fullDamage = halfDamage * 2;
|
|
2622
|
-
if (fullDamage > 0 && hitDistro[fullDamage]) {
|
|
2623
|
-
isSaveHalf = true;
|
|
2624
|
-
break;
|
|
2625
|
-
}
|
|
2626
|
-
}
|
|
2970
|
+
const isSaveHalf = Object.keys(saveDistro).length > 0;
|
|
2627
2971
|
const isDCCheck = this.privateData.isDCCheck === true;
|
|
2628
2972
|
const clampNonNeg = (x) => x < 0 && x > -1e-15 ? 0 : x;
|
|
2629
|
-
for (const [faceStr, faceCountRaw] of Object.entries(this.
|
|
2973
|
+
for (const [faceStr, faceCountRaw] of Object.entries(this.faces)) {
|
|
2630
2974
|
const face = Number(faceStr);
|
|
2631
2975
|
const faceCount = Number(faceCountRaw);
|
|
2632
2976
|
if (faceCount <= 0) continue;
|
|
@@ -2700,14 +3044,14 @@ var Dice = class _Dice {
|
|
|
2700
3044
|
map.set(face, bin);
|
|
2701
3045
|
}
|
|
2702
3046
|
const identifier = this.identifier || "ERROR";
|
|
2703
|
-
if (identifier === "ERROR") {
|
|
2704
|
-
console.error("Dice identifier is undefined", this);
|
|
2705
|
-
}
|
|
2706
3047
|
return new PMF(map, numEpsilon, true, identifier).compact(numEpsilon, true);
|
|
2707
3048
|
}
|
|
2708
3049
|
};
|
|
2709
3050
|
|
|
2710
3051
|
// src/parser/parser.ts
|
|
3052
|
+
var MAX_DIE_SIDES = 1e6;
|
|
3053
|
+
var MAX_DICE_COUNT = 1e4;
|
|
3054
|
+
var MAX_KEEP_OUTCOMES = 1e6;
|
|
2711
3055
|
var parseCache = new LRUCache(1e3);
|
|
2712
3056
|
var cachingEnabled = true;
|
|
2713
3057
|
function setCachingEnabled(enabled) {
|
|
@@ -2728,20 +3072,21 @@ function parse(expression, n = 0) {
|
|
|
2728
3072
|
if (cached) return cached;
|
|
2729
3073
|
}
|
|
2730
3074
|
const chars = [...cleaned];
|
|
2731
|
-
let result
|
|
3075
|
+
let result;
|
|
2732
3076
|
try {
|
|
2733
3077
|
result = parseExpression(chars, n);
|
|
2734
3078
|
} catch (error) {
|
|
2735
|
-
throw new
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
result.identifier = cleaned;
|
|
2740
|
-
} catch {
|
|
3079
|
+
throw new DiceParseError(
|
|
3080
|
+
`Cannot parse dice expression [${expression}]: ${error}`,
|
|
3081
|
+
{ expression, cause: error }
|
|
3082
|
+
);
|
|
2741
3083
|
}
|
|
3084
|
+
result.privateData = result.privateData || {};
|
|
3085
|
+
result.identifier = cleaned;
|
|
2742
3086
|
if (chars.length > 0) {
|
|
2743
|
-
throw new
|
|
2744
|
-
`Unexpected token: '${chars[0]}' from expression: '${expression}'
|
|
3087
|
+
throw new DiceParseError(
|
|
3088
|
+
`Unexpected token: '${chars[0]}' from expression: '${expression}'`,
|
|
3089
|
+
{ expression }
|
|
2745
3090
|
);
|
|
2746
3091
|
}
|
|
2747
3092
|
const resultPMF = result.toPMF(-1);
|
|
@@ -2895,7 +3240,7 @@ function multiplyDiceByDice(d1, d2) {
|
|
|
2895
3240
|
if (typeof d1 === "number") d1 = Dice.scalar(d1);
|
|
2896
3241
|
if (typeof d2 === "number") d2 = Dice.scalar(d2);
|
|
2897
3242
|
const result = new Dice();
|
|
2898
|
-
const faces =
|
|
3243
|
+
const faces = /* @__PURE__ */ new Map();
|
|
2899
3244
|
let normalizationFactor = 1;
|
|
2900
3245
|
for (const key of d1.keys()) {
|
|
2901
3246
|
let face;
|
|
@@ -2903,17 +3248,21 @@ function multiplyDiceByDice(d1, d2) {
|
|
|
2903
3248
|
continue;
|
|
2904
3249
|
}
|
|
2905
3250
|
if (d2.privateData.keep) {
|
|
3251
|
+
const faceCount = d2.keys().length;
|
|
3252
|
+
if (Math.pow(faceCount, key) > MAX_KEEP_OUTCOMES) {
|
|
3253
|
+
throw new DiceParseError(
|
|
3254
|
+
`Keep enumeration of ${faceCount}^${key} outcomes exceeds the maximum of ${MAX_KEEP_OUTCOMES}`
|
|
3255
|
+
);
|
|
3256
|
+
}
|
|
2906
3257
|
const repeat = Array(key).fill(d2);
|
|
2907
3258
|
face = opDice(repeat, d2.privateData.keep);
|
|
2908
3259
|
} else {
|
|
2909
3260
|
face = multiplyDice(key, d2);
|
|
2910
3261
|
}
|
|
2911
3262
|
normalizationFactor *= face.total();
|
|
2912
|
-
faces
|
|
3263
|
+
faces.set(key, face);
|
|
2913
3264
|
}
|
|
2914
|
-
for (const
|
|
2915
|
-
const k = parseFloat(key);
|
|
2916
|
-
const face = faces[k];
|
|
3265
|
+
for (const [k, face] of faces) {
|
|
2917
3266
|
const count = d1.get(k);
|
|
2918
3267
|
result.combineInPlace(
|
|
2919
3268
|
face.normalize(count * normalizationFactor / face.total())
|
|
@@ -2923,6 +3272,11 @@ function multiplyDiceByDice(d1, d2) {
|
|
|
2923
3272
|
return result;
|
|
2924
3273
|
}
|
|
2925
3274
|
function multiplyDice(n, d) {
|
|
3275
|
+
if (n > MAX_DICE_COUNT) {
|
|
3276
|
+
throw new DiceParseError(
|
|
3277
|
+
`Dice count ${n} exceeds the maximum of ${MAX_DICE_COUNT}`
|
|
3278
|
+
);
|
|
3279
|
+
}
|
|
2926
3280
|
if (n === 0) return new Dice(0);
|
|
2927
3281
|
if (n === 1) return d;
|
|
2928
3282
|
const half = Math.floor(n / 2);
|
|
@@ -3005,9 +3359,14 @@ function parseDice(s, n) {
|
|
|
3005
3359
|
return;
|
|
3006
3360
|
}
|
|
3007
3361
|
const sides = parseNumber(s, n);
|
|
3362
|
+
if (sides > MAX_DIE_SIDES) {
|
|
3363
|
+
throw new DiceParseError(
|
|
3364
|
+
`Die size ${sides} exceeds the maximum of ${MAX_DIE_SIDES}`
|
|
3365
|
+
);
|
|
3366
|
+
}
|
|
3008
3367
|
let result = new Dice(sides);
|
|
3009
3368
|
if (rerollOne) {
|
|
3010
|
-
result = result.
|
|
3369
|
+
result = result.reroll(1);
|
|
3011
3370
|
}
|
|
3012
3371
|
return result;
|
|
3013
3372
|
}
|
|
@@ -3122,11 +3481,9 @@ function parseOperation(s) {
|
|
|
3122
3481
|
// src/pmf/mixture.ts
|
|
3123
3482
|
var Mixture = class _Mixture {
|
|
3124
3483
|
constructor(eps = EPS) {
|
|
3125
|
-
|
|
3484
|
+
this.totals = /* @__PURE__ */ new Map();
|
|
3126
3485
|
// raw mass per outcome (pre-normalization)
|
|
3127
|
-
|
|
3128
|
-
// raw mass per outcome per label
|
|
3129
|
-
__publicField(this, "eps");
|
|
3486
|
+
this.labelMass = /* @__PURE__ */ new Map();
|
|
3130
3487
|
this.eps = Number.isFinite(eps) ? eps : EPS;
|
|
3131
3488
|
}
|
|
3132
3489
|
/** Remove all accumulated state. */
|
|
@@ -3150,9 +3507,8 @@ var Mixture = class _Mixture {
|
|
|
3150
3507
|
*/
|
|
3151
3508
|
add(label, pmf, weight = 1) {
|
|
3152
3509
|
if (!Number.isFinite(weight) || weight <= 0) return this;
|
|
3153
|
-
for (const [v,
|
|
3154
|
-
const
|
|
3155
|
-
const p = isNumber ? binOrNumber : binOrNumber?.p ?? 0;
|
|
3510
|
+
for (const [v, bin] of pmf) {
|
|
3511
|
+
const p = bin.p;
|
|
3156
3512
|
if (p <= 0) continue;
|
|
3157
3513
|
const add = weight * p;
|
|
3158
3514
|
if (!Number.isFinite(add) || Math.abs(add) < this.eps) continue;
|
|
@@ -3240,6 +3596,6 @@ var Mixture = class _Mixture {
|
|
|
3240
3596
|
}
|
|
3241
3597
|
};
|
|
3242
3598
|
|
|
3243
|
-
export { DiceQuery, EPS, LRUCache, Mixture, PMF, clearParserCache, getCachingEnabled, onAnyHit, onCritOnly, onHitOnly, onMissDamageOnly, onMissOnly, onPotentCantripOnly, onSaveFailOnly, onSaveHalfOnly, parse, pmfCache, setCachingEnabled };
|
|
3599
|
+
export { ALL_OUTCOME_TYPES, DiceParseError, DiceQuery, EPS, LRUCache, MISS_NONE_OUTCOME, Mixture, OUTCOME_DISPLAY_ORDER, PMF, calculateBounceOdds, clearParserCache, critProbability, getCachingEnabled, onAnyHit, onCritOnly, onHitOnly, onMissDamageOnly, onMissOnly, onPotentCantripOnly, onSaveFailOnly, onSaveHalfOnly, parse, pmfCache, setCachingEnabled, sortOutcomes };
|
|
3244
3600
|
//# sourceMappingURL=index.js.map
|
|
3245
3601
|
//# sourceMappingURL=index.js.map
|