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