@yipe/dice 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3447 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
+
5
+ // src/common/lru-cache.ts
6
+ var LRUCache = class {
7
+ constructor(maxSize = 1e3) {
8
+ this.maxSize = maxSize;
9
+ __publicField(this, "cache", /* @__PURE__ */ new Map());
10
+ }
11
+ get(key) {
12
+ const value = this.cache.get(key);
13
+ if (value === void 0) return void 0;
14
+ this.cache.delete(key);
15
+ this.cache.set(key, value);
16
+ return value;
17
+ }
18
+ delete(key) {
19
+ this.cache.delete(key);
20
+ }
21
+ set(key, value) {
22
+ if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
23
+ const oldestKey = this.cache.keys().next().value;
24
+ this.cache.delete(oldestKey);
25
+ }
26
+ this.cache.delete(key);
27
+ this.cache.set(key, value);
28
+ return this;
29
+ }
30
+ clear() {
31
+ this.cache.clear();
32
+ }
33
+ get size() {
34
+ return this.cache.size;
35
+ }
36
+ has(key) {
37
+ return this.cache.has(key);
38
+ }
39
+ keys() {
40
+ return this.cache.keys();
41
+ }
42
+ values() {
43
+ return this.cache.values();
44
+ }
45
+ };
46
+
47
+ // src/common/types.ts
48
+ var EPS = 1e-12;
49
+
50
+ // src/pmf/query.ts
51
+ var _DiceQuery = class _DiceQuery {
52
+ constructor(singles, combined, eps = EPS) {
53
+ __publicField(this, "singles");
54
+ __publicField(this, "combined");
55
+ this.singles = Array.isArray(singles) ? singles : [singles];
56
+ if (this.singles.some((s) => s === void 0)) {
57
+ throw new Error("DiceQuery contains undefined singles");
58
+ }
59
+ const c = combined ?? PMF.convolveMany(this.singles);
60
+ this.combined = Math.abs(c.mass() - 1) <= eps ? c : c.normalize();
61
+ }
62
+ /**
63
+ * Returns the expected damage across all possible outcomes.
64
+ *
65
+ * Example: `query.mean()` → 12.5
66
+ * Use case: "What's my average damage per round?"
67
+ */
68
+ mean() {
69
+ let totalSum = 0;
70
+ for (const [damageValue, probabilityBin] of this.combined) {
71
+ totalSum += damageValue * probabilityBin.p;
72
+ }
73
+ return totalSum;
74
+ }
75
+ /**
76
+ * Returns the variance of the damage distribution.
77
+ *
78
+ * Example: `query.variance()` → 45.2
79
+ * Use case: "How much does my damage vary from the average?"
80
+ * High variance means higher risk/reward. Lower variance means more consistent damage.
81
+ */
82
+ variance() {
83
+ const meanValue = this.mean();
84
+ let varianceSum = 0;
85
+ for (const [damageValue, probabilityBin] of this.combined) {
86
+ const deviationFromMean = damageValue - meanValue;
87
+ varianceSum += deviationFromMean * deviationFromMean * probabilityBin.p;
88
+ }
89
+ return varianceSum;
90
+ }
91
+ /**
92
+ * Returns the standard deviation of the damage distribution.
93
+ *
94
+ * Example: `query.stdev()` → 6.7
95
+ * Use case: "What's the typical spread around my average damage?"
96
+ * Used to determine how consistent the damage is.
97
+ */
98
+ stddev() {
99
+ return Math.sqrt(this.variance());
100
+ }
101
+ /**
102
+ * Returns the Cumulative Distribution Function.
103
+ */
104
+ cdf(x) {
105
+ return this.probTotalAtMost(x);
106
+ }
107
+ /**
108
+ * Returns the probability of dealing X damage or less.
109
+ * In statistics, this is called the cumulative distribution function (CDF).
110
+ * Example: `query.cdf(20)` → 0.75
111
+ * Use case: "What's the chance I deal 20 damage or less?"
112
+ */
113
+ probTotalAtMost(x) {
114
+ let cumulativeProbability = 0;
115
+ for (const [damageValue, probabilityBin] of this.combined) {
116
+ if (damageValue <= x) {
117
+ cumulativeProbability += probabilityBin.p;
118
+ }
119
+ }
120
+ return cumulativeProbability;
121
+ }
122
+ /**
123
+ * Returns the Complementary Cumulative Distribution Function.
124
+ */
125
+ ccdf(x) {
126
+ return this.probTotalAtLeast(x);
127
+ }
128
+ /**
129
+ * Returns the probability of dealing at least X damage.
130
+ *
131
+ * Example: `query.probTotalAtLeast(25)` → 0.35
132
+ * Use case: "What's the chance I deal at least 25 damage to finish the enemy?"
133
+ */
134
+ probTotalAtLeast(threshold) {
135
+ let probabilitySum = 0;
136
+ for (const [damageValue, probabilityBin] of this.combined) {
137
+ if (damageValue >= threshold) {
138
+ probabilitySum += probabilityBin.p;
139
+ }
140
+ }
141
+ return probabilitySum;
142
+ }
143
+ /**
144
+ * Returns damage values at specific percentiles.
145
+ *
146
+ * Example: `query.percentiles([0.25, 0.5, 0.75])` → [8, 12, 18]
147
+ * Use case: "What are my 25th, 50th, and 75th percentile damage values?"
148
+ */
149
+ percentiles(percentileValues) {
150
+ const sortedDamageValues = this.combined.support();
151
+ if (sortedDamageValues.length === 0) return percentileValues.map(() => 0);
152
+ const cumulativeProbabilities = [];
153
+ let runningProbabilitySum = 0;
154
+ for (const damageValue of sortedDamageValues) {
155
+ runningProbabilitySum += this.combined.map.get(damageValue).p;
156
+ cumulativeProbabilities.push(runningProbabilitySum);
157
+ }
158
+ return percentileValues.map((targetPercentile) => {
159
+ let leftBound = 0;
160
+ let rightBound = cumulativeProbabilities.length - 1;
161
+ while (leftBound <= rightBound) {
162
+ const middleIndex = Math.floor((leftBound + rightBound) / 2);
163
+ if (cumulativeProbabilities[middleIndex] >= targetPercentile) {
164
+ rightBound = middleIndex - 1;
165
+ } else {
166
+ leftBound = middleIndex + 1;
167
+ }
168
+ }
169
+ return leftBound < sortedDamageValues.length ? sortedDamageValues[leftBound] : sortedDamageValues[sortedDamageValues.length - 1];
170
+ });
171
+ }
172
+ /**
173
+ * Returns the minimum possible damage.
174
+ *
175
+ * Example: `query.min()` → 0
176
+ * Use case: "What's the worst-case damage if everything misses?"
177
+ */
178
+ min() {
179
+ return this.combined.min();
180
+ }
181
+ /**
182
+ * Returns the maximum possible damage.
183
+ *
184
+ * Example: `query.max()` → 56
185
+ * Use case: "What's the best-case damage if everything crits and rolls max?"
186
+ */
187
+ max() {
188
+ return this.combined.max();
189
+ }
190
+ singleProb(diceIndex, label) {
191
+ let probabilitySum = 0;
192
+ for (const [, probabilityBin] of this.singles[diceIndex]) {
193
+ probabilitySum += probabilityBin.count[label] || 0;
194
+ }
195
+ return probabilitySum;
196
+ }
197
+ probAtLeastK(labels, k) {
198
+ const L = Array.isArray(labels) ? [...new Set(labels)] : [labels];
199
+ const n = this.singles.length;
200
+ if (k <= 0) return 1;
201
+ if (k > n) return 0;
202
+ let tail = 0;
203
+ for (let i = k; i <= n; i++) {
204
+ tail += this.probExactlyK(L, i);
205
+ }
206
+ if (tail < 0) return 0;
207
+ if (tail > 1) return 1;
208
+ return tail;
209
+ }
210
+ /**
211
+ * Returns the probability that at least one attack has the specified outcome(s).
212
+ * - This is the complement of probAtMostK(labels, 0)
213
+ *
214
+ * Examples:
215
+ * - `query.probAtLeastOne('hit')` → 0.88 (88% chance at least one attack hits)
216
+ * - `query.probAtLeastOne(['hit', 'crit'])` → 0.96 (96% chance at least one succeeds)
217
+ *
218
+ * Use cases:
219
+ * - "What's the chance at least one of my attacks connects?"
220
+ *
221
+ * Note:
222
+ *
223
+ * - You have to pass in an array of labels to avoid double-counting if you are
224
+ * using multiple labels. You cannot just add them.
225
+ */
226
+ probAtLeastOne(labels) {
227
+ if (typeof labels === "string") {
228
+ labels = [labels];
229
+ }
230
+ let productOfNonOccurrence = 1;
231
+ for (let diceIndex = 0; diceIndex < this.singles.length; diceIndex++) {
232
+ let combinedProbability = 0;
233
+ for (const label of labels) {
234
+ combinedProbability += this.singleProb(diceIndex, label);
235
+ }
236
+ productOfNonOccurrence *= 1 - combinedProbability;
237
+ }
238
+ return 1 - productOfNonOccurrence;
239
+ }
240
+ /**
241
+ * Computes binomial probabilities for exactly 0, 1, 2, ..., maxK occurrences of a label.
242
+ *
243
+ * Uses dynamic programming to efficiently calculate the probability distribution
244
+ * of how many attacks will have the specified outcome, accounting for different
245
+ * success probabilities across individual attacks.
246
+ *
247
+ * Example: For 3 attacks with 50% hit chance each, returns:
248
+ * [0.125, 0.375, 0.375, 0.125] = [P(0 hits), P(1 hit), P(2 hits), P(3 hits)]
249
+ *
250
+ * @param label - The outcome type to count
251
+ * @param maxK - Maximum number of occurrences to calculate (usually number of attacks)
252
+ * @returns Array where index K contains P(exactly K attacks have the label)
253
+ */
254
+ computeBinomialProbabilities(label, maxK) {
255
+ const individualProbabilities = this.singles.map(
256
+ (_, diceIndex) => this.singleProb(diceIndex, label)
257
+ );
258
+ const binomialProbs = new Array(maxK + 1).fill(0);
259
+ binomialProbs[0] = 1;
260
+ for (const singleProbability of individualProbabilities) {
261
+ for (let outcomeCount = maxK; outcomeCount >= 1; outcomeCount--) {
262
+ binomialProbs[outcomeCount] = binomialProbs[outcomeCount] * (1 - singleProbability) + binomialProbs[outcomeCount - 1] * singleProbability;
263
+ }
264
+ binomialProbs[0] *= 1 - singleProbability;
265
+ }
266
+ return binomialProbs;
267
+ }
268
+ /**
269
+ * Returns the probability that exactly K attacks result in the specified outcome(s).
270
+ *
271
+ * Single label examples:
272
+ * - probExactlyK('hit', 2) = probability exactly 2 attacks hit
273
+ * - probExactlyK('crit', 1) = probability exactly 1 attack crits
274
+ * - probExactlyK('crit', 0) = probability no attacks crit
275
+ *
276
+ * Array examples:
277
+ * - probExactlyK(['hit', 'crit'], 2) = probability exactly 2 attacks succeed
278
+ * - probExactlyK(['hit', 'crit'], 1) = probability exactly 1 attack succeeds
279
+ * - probExactlyK(['miss', 'missNone'], 0) = probability no attacks miss
280
+ *
281
+ * Use cases:
282
+ * - "What's the chance exactly one of my attacks hits?"
283
+ * - "How likely am I to get exactly 2 successes out of 3 attacks?"
284
+ * - "What's the probability that exactly half my attacks succeed?"
285
+ *
286
+ * Note: For arrays, an attack counts as a "success" if it has any of the specified labels.
287
+ * This is different from probAtMostK, which counts an attack as a "success" if it has ALL of the specified labels.
288
+ */
289
+ probExactlyK(labels, k) {
290
+ if (typeof labels === "string") {
291
+ const probabilityArray = this.computeBinomialProbabilities(labels, k);
292
+ return probabilityArray[k];
293
+ }
294
+ const successProbabilities = this.singles.map((single) => {
295
+ const singleQuery = new _DiceQuery([single]);
296
+ return singleQuery.probabilityOf(labels);
297
+ });
298
+ const binomialProbs = new Array(k + 1).fill(0);
299
+ binomialProbs[0] = 1;
300
+ for (const successProb of successProbabilities) {
301
+ for (let outcomeCount = k; outcomeCount >= 1; outcomeCount--) {
302
+ binomialProbs[outcomeCount] = binomialProbs[outcomeCount] * (1 - successProb) + binomialProbs[outcomeCount - 1] * successProb;
303
+ }
304
+ binomialProbs[0] *= 1 - successProb;
305
+ }
306
+ return binomialProbs[k];
307
+ }
308
+ /**
309
+ * Returns the probability that AT MOST K attacks result in the specified outcome(s).
310
+ *
311
+ * Single label examples:
312
+ * - probAtMostK('hit', 1) = probability 0 or 1 attacks hit (at most 1)
313
+ * - probAtMostK('crit', 0) = probability no attacks crit
314
+ * - probAtMostK('miss', 2) = probability at most 2 attacks miss
315
+ *
316
+ * Array examples:
317
+ * - probAtMostK(['hit', 'crit'], 1) = probability at most 1 attack succeeds
318
+ * - probAtMostK(['hit', 'crit'], 0) = probability no attacks succeed (all miss)
319
+ *
320
+ * Use cases:
321
+ * - "What's the chance that at most one attack hits?" (rest miss)
322
+ * - "How likely am I to have mostly failures?" (at most 1 success)
323
+ * - "What's the probability of a really bad turn?" (at most 0 successes)
324
+ *
325
+ */
326
+ probAtMostK(labels, k) {
327
+ if (typeof labels === "string") {
328
+ const probabilityArray = this.computeBinomialProbabilities(labels, k);
329
+ let cumulativeSum2 = 0;
330
+ for (let outcomeCount = 0; outcomeCount <= k; outcomeCount++) {
331
+ cumulativeSum2 += probabilityArray[outcomeCount];
332
+ }
333
+ return cumulativeSum2;
334
+ }
335
+ let cumulativeSum = 0;
336
+ for (let outcomeCount = 0; outcomeCount <= k; outcomeCount++) {
337
+ cumulativeSum += this.probExactlyK(labels, outcomeCount);
338
+ }
339
+ return cumulativeSum;
340
+ }
341
+ /**
342
+ * Returns the expected damage attributed to specific outcome types.
343
+ *
344
+ * Single label examples:
345
+ * - expectedDamageFrom('hit') = expected damage from hit components
346
+ * - expectedDamageFrom('crit') = expected damage from crit components
347
+ *
348
+ * Array examples:
349
+ * - expectedDamageFrom(['hit', 'crit']) = expected damage from any success
350
+ * - expectedDamageFrom(['missDamage', 'missNone']) = expected damage from misses
351
+ *
352
+ * Use cases:
353
+ * - "How much damage do I expect from successful attacks?"
354
+ * - "What's the damage contribution from critical hits specifically?"
355
+ * - "How much damage comes from miss effects (like save-for-half spells)?"
356
+ */
357
+ expectedDamageFrom(labels) {
358
+ const wanted = Array.isArray(labels) ? labels : [labels];
359
+ let total = 0;
360
+ for (const single of this.singles) {
361
+ for (const [dmg, bin] of single) {
362
+ let p = 0;
363
+ for (const label of wanted) p += bin.count[label] ?? 0;
364
+ total += dmg * p;
365
+ }
366
+ }
367
+ return total;
368
+ }
369
+ /**
370
+ * Returns damage statistics for scenarios where AT LEAST ONE attack results in
371
+ * the specified outcome(s).
372
+ *
373
+ * This method answers "What happens when things go reasonably well?" rather than
374
+ * "What's the theoretical maximum?" It includes mixed scenarios which are more
375
+ * common and tactically relevant than pure scenarios.
376
+ *
377
+ * Single label examples:
378
+ * - damageStatsFrom('hit') = damage range when at least one attack hits
379
+ * - damageStatsFrom('crit') = damage range when at least one attack crits
380
+ *
381
+ * Array examples:
382
+ * - damageStatsFrom(['hit', 'crit']) = damage range when at least one attack succeeds
383
+ * - damageStatsFrom(['miss', 'missNone']) = damage range when at least one attack misses
384
+ *
385
+ * Tactical Use Cases:
386
+ * - "Given that I don't completely whiff (99% of turns), what damage should I expect?"
387
+ * - "When planning to kill a 60 HP enemy, what's my damage range on successful turns?"
388
+ * - "Should I use this risky spell if it has good damage when it works?"
389
+ * - "What's my damage potential when something goes right?" (vs pure failure)
390
+ *
391
+ * Combat Planning Examples:
392
+ * - 4 attacks with 90% hit chance: "96% of the time you'll do 25-150 damage, avg 52"
393
+ * (Much more useful than "You average 50 damage including complete misses")
394
+ * - Risk assessment: "80% of successful turns do 40-80 damage, but 20% do 80-150"
395
+ * - Resource management: "If I hit anything, I'll likely finish this enemy"
396
+ *
397
+ * Statistical Note:
398
+ * This includes mixed scenarios (2 hits + 1 crit, 3 hits + 1 miss, etc.) which
399
+ * occur far more frequently than pure scenarios. For pure scenarios, use combinedDamageStats.
400
+ *
401
+ * @example
402
+ * // High-level tactical planning
403
+ * const successStats = query.damageStatsFrom('hit')
404
+ * const successChance = query.probAtLeastOne('hit')
405
+ * console.log(`${(successChance*100).toFixed(1)}% chance to do ${successStats.min}-${successStats.max} damage`)
406
+ */
407
+ damageStatsFrom(labels) {
408
+ const labelArray = typeof labels === "string" ? [labels] : labels;
409
+ let minDamage = Infinity;
410
+ let maxDamage = -Infinity;
411
+ let totalDamage = 0;
412
+ let totalCount = 0;
413
+ for (const [damage, probabilityBin] of this.combined) {
414
+ let binHasAnyLabel = false;
415
+ let binContribution = 0;
416
+ for (const label of labelArray) {
417
+ const count = probabilityBin.count[label];
418
+ if (count && count > 0) {
419
+ binHasAnyLabel = true;
420
+ binContribution += count;
421
+ }
422
+ }
423
+ if (damage > 0 && binHasAnyLabel) {
424
+ minDamage = Math.min(minDamage, damage);
425
+ maxDamage = Math.max(maxDamage, damage);
426
+ const weightToUse = labelArray.length === 1 ? binContribution : probabilityBin.p;
427
+ totalDamage += damage * weightToUse;
428
+ totalCount += weightToUse;
429
+ }
430
+ }
431
+ return {
432
+ min: minDamage === Infinity ? 0 : minDamage,
433
+ max: maxDamage === -Infinity ? 0 : maxDamage,
434
+ avg: totalCount > 0 ? totalDamage / totalCount : 0,
435
+ count: totalCount
436
+ };
437
+ }
438
+ /**
439
+ * Returns damage statistics for scenarios where ALL attacks result in the specified
440
+ * outcome, calculated by leveraging the pure partition of singles.
441
+ *
442
+ * This method answers "What's the theoretical best/worst case?" and "What are the
443
+ * clean mathematical boundaries?" It provides pure scenarios without mixing outcomes.
444
+ *
445
+ * Examples:
446
+ * - combinedDamageStats('hit') = damage range when all attacks hit (none crit, none miss)
447
+ * - combinedDamageStats('crit') = damage range when all attacks crit (none just hit)
448
+ *
449
+ * UI and Display Use Cases:
450
+ * - Statistics panels showing "MAX Hit Damage" (users expect pure hits, not mixed)
451
+ * - "Best case scenario" vs "worst case scenario" analysis
452
+ * - Mathematical verification: "Does our hit damage calculation match manual math?"
453
+ * - Clean damage type attribution: "How much comes from base hits vs crits?"
454
+ *
455
+ * Design and Balance Use Cases:
456
+ * - Game designers: "What's the damage ceiling if someone gets lucky?"
457
+ * - Character optimization: "What's my absolute maximum potential?"
458
+ * - Ability comparison: "Which build has higher crit ceiling?"
459
+ * - Minimum guaranteed damage: "What's the worst I can do if everything hits?"
460
+ *
461
+ * Mathematical Use Cases:
462
+ * - Validating complex calculations against simple manual math
463
+ * - Understanding damage component contributions in isolation
464
+ * - Separating luck (crit variance) from consistency (hit variance)
465
+ * - Building intuition about damage sources
466
+ *
467
+ * When to Use This vs damageStatsFrom():
468
+ * - Use THIS for: UI max/min displays, theoretical limits, clean comparisons
469
+ * - Use damageStatsFrom() for: tactical planning, realistic expectations, mixed scenarios
470
+ *
471
+ * Statistical Note:
472
+ * Pure scenarios (all hits, all crits) are rare but represent clear mathematical
473
+ * boundaries. These stats help understand the "shape" of your damage potential.
474
+ *
475
+ * @example
476
+ * // UI display logic
477
+ * const pureHitMax = query.combinedDamageStats('hit').max // Clean "MAX Hit Damage: 90"
478
+ * const pureCritMax = query.combinedDamageStats('crit').max // Clean "MAX Crit Damage: 168"
479
+ *
480
+ * // vs tactical planning (use damageStatsFrom instead)
481
+ * const realisticRange = query.damageStatsFrom('hit') // Includes mixed scenarios
482
+ */
483
+ combinedDamageStats(targetLabel) {
484
+ const singleStats = this.singles.map(
485
+ (single) => new _DiceQuery([single]).damageStatsFrom(targetLabel)
486
+ );
487
+ if (singleStats.some((stats) => stats.count === 0)) {
488
+ return { min: 0, max: 0, avg: 0, count: 0 };
489
+ }
490
+ const combinedMin = singleStats.reduce((sum, stats) => sum + stats.min, 0);
491
+ const combinedMax = singleStats.reduce((sum, stats) => sum + stats.max, 0);
492
+ const combinedAvg = singleStats.reduce((sum, stats) => sum + stats.avg, 0);
493
+ const combinedProb = singleStats.reduce(
494
+ (product, stats) => product * stats.count,
495
+ 1
496
+ );
497
+ return {
498
+ min: combinedMin,
499
+ max: combinedMax,
500
+ avg: combinedAvg,
501
+ count: combinedProb
502
+ };
503
+ }
504
+ /**
505
+ * Returns the probability that a result includes ANY of the specified labels.
506
+ *
507
+ * Examples:
508
+ * - `query.probabilityOf('hit')` → 0.88 (probability at least one hit occurs)
509
+ * - `query.probabilityOf(['hit', 'crit'])` → 0.96 (probability of any success)
510
+ *
511
+ * Use cases:
512
+ * - "What's the chance my resolution includes a success label?"
513
+ * - "How likely am I to get any hits or crits across all attacks?"
514
+ */
515
+ probabilityOf(labels) {
516
+ if (typeof labels === "string") {
517
+ labels = [labels];
518
+ }
519
+ let totalProbability = 0;
520
+ for (const [, probabilityBin] of this.combined) {
521
+ let binHasAnyLabel = false;
522
+ for (const label of labels) {
523
+ if (probabilityBin.count[label] && probabilityBin.count[label] > 0) {
524
+ binHasAnyLabel = true;
525
+ break;
526
+ }
527
+ }
528
+ if (binHasAnyLabel) {
529
+ totalProbability += probabilityBin.p;
530
+ }
531
+ }
532
+ return totalProbability;
533
+ }
534
+ /**
535
+ * Returns the probability of missing (any type of miss).
536
+ *
537
+ * Example: `query.missChance()` → 0.04
538
+ * Use case: "What's the chance I miss completely this turn?"
539
+ */
540
+ missChance() {
541
+ return this.probabilityOf(["missDamage", "missNone"]);
542
+ }
543
+ /**
544
+ * Returns data formatted for plotting damage probability distribution.
545
+ *
546
+ * Example: `query.toChartSeries()` → [{x: 0, y: 0.04}, {x: 6, y: 0.1}, ...]
547
+ * Use case: "I want to visualize my damage distribution in a chart."
548
+ */
549
+ toChartSeries() {
550
+ return this.combined.support().map((damageValue) => ({
551
+ x: damageValue,
552
+ y: this.combined.map.get(damageValue).p
553
+ }));
554
+ }
555
+ /**
556
+ * Returns tabular data showing damage values and their probability breakdowns.
557
+ *
558
+ * Example: `query.toLabeledTable(['hit', 'crit'])` →
559
+ * [{damage: 6, total: 0.01, hit: 0.008, crit: 0}, ...]
560
+ *
561
+ * Use case: "I want to see exactly how hit/crit probabilities contribute to each damage value."
562
+ */
563
+ toLabeledTable(labels = []) {
564
+ return this.combined.support().map((damageValue) => {
565
+ const probabilityBin = this.combined.map.get(damageValue);
566
+ const tableRow = {
567
+ damage: damageValue,
568
+ total: probabilityBin.p
569
+ };
570
+ for (const outcomeLabel of labels) {
571
+ tableRow[outcomeLabel] = probabilityBin.count[outcomeLabel] || 0;
572
+ }
573
+ return tableRow;
574
+ });
575
+ }
576
+ /**
577
+ * Returns data for stacked charts with unconditional per-label probability mass per damage.
578
+ *
579
+ * - Each dataset value equals the unconditional probability mass for that label at that damage
580
+ * (i.e., `bin.count[label]`).
581
+ * - Column sums may be less than the total probability `bin.p` when you omit labels or when
582
+ * there is unlabeled mass. Include all relevant outcome labels if you need the sum to match.
583
+ * - This behavior matches tests that expect raw per-label mass (not proportional scaling).
584
+ * - NOTE: This implementation may break dprcalc.com chart binning at large n, need to test it more.
585
+ *
586
+ * @example
587
+ * query.toStackedChartData(['hit', 'crit'])
588
+ * // → {labels: [0, 6, 12, ...], datasets: [{label: 'hit', data: [0, 0.03, ...]}, ...]}
589
+ */
590
+ toStackedChartData(labels = [], epsilon = EPS) {
591
+ const damageValues = this.combined.support();
592
+ damageValues.map((dmg) => {
593
+ const bin = this.combined.map.get(dmg);
594
+ return labels.reduce((sum, lab) => sum + (bin.count[lab] || 0), 0);
595
+ });
596
+ const datasets = labels.map((outcomeLabel) => ({
597
+ label: outcomeLabel,
598
+ data: damageValues.map((dmg) => {
599
+ const bin = this.combined.map.get(dmg);
600
+ const v = bin ? bin.count[outcomeLabel] || 0 : 0;
601
+ return v <= epsilon ? 0 : v;
602
+ })
603
+ }));
604
+ return { labels: damageValues, datasets };
605
+ }
606
+ /**
607
+ * Returns pure mathematical data for attribution charts showing outcome contributions.
608
+ *
609
+ * Automatically discovers all outcome types present in the PMF, applies filtering rules,
610
+ * and returns proportional data suitable for stacked visualization.
611
+ *
612
+ * @param options Configuration options
613
+ * @param options.stackOrder Preferred order for outcome types (unknowns placed at end)
614
+ * @param options.filterRules Function to determine if outcome should be included for a given damage value
615
+ * @param options.asPercentages Whether to return percentages (0-100) or probabilities (0-1)
616
+ * @returns Pure data structure with support, outcomes, and proportional data
617
+ *
618
+ * @example
619
+ * query.toAttributionChartSeries()
620
+ * // → {support: [0, 6, 12], outcomes: ['hit', 'crit'], data: {hit: [5.2, 8.1, ...], crit: [0, 2.3, ...]}}
621
+ */
622
+ toAttributionChartSeries(options = {}) {
623
+ const {
624
+ stackOrder = [
625
+ "missNone",
626
+ "missDamage",
627
+ "saveFail",
628
+ "saveHalf",
629
+ "pc",
630
+ "hit",
631
+ "crit"
632
+ ],
633
+ filterRules = (outcome, damage) => !(outcome === "missNone" && damage !== 0),
634
+ asPercentages = true
635
+ } = options;
636
+ const originalSupport = this.combined.support();
637
+ if (originalSupport.length === 0) {
638
+ return { support: [], outcomes: [], data: {} };
639
+ }
640
+ const minDamage = Math.min(...originalSupport);
641
+ const maxDamage = Math.max(...originalSupport);
642
+ const support = Array.from(
643
+ { length: maxDamage - minDamage + 1 },
644
+ (_, i) => minDamage + i
645
+ );
646
+ const allOutcomeTypes = /* @__PURE__ */ new Set();
647
+ for (const [, bin] of this.combined.map) {
648
+ for (const outcomeType in bin.count) {
649
+ if (bin.count[outcomeType] && bin.count[outcomeType] > 0) {
650
+ allOutcomeTypes.add(outcomeType);
651
+ }
652
+ }
653
+ }
654
+ const existingOutcomes = Array.from(allOutcomeTypes).sort((a, b) => {
655
+ const indexA = stackOrder.indexOf(a);
656
+ const indexB = stackOrder.indexOf(b);
657
+ if (indexA >= 0 && indexB >= 0) return indexA - indexB;
658
+ if (indexA >= 0) return -1;
659
+ if (indexB >= 0) return 1;
660
+ return a.localeCompare(b);
661
+ });
662
+ if (existingOutcomes.length === 0) {
663
+ return { support, outcomes: [], data: {} };
664
+ }
665
+ const data = {};
666
+ for (const outcome of existingOutcomes) {
667
+ data[outcome] = support.map((damage) => {
668
+ const bin = this.combined.map.get(damage);
669
+ if (!bin) return 0;
670
+ if (!filterRules(outcome, damage)) {
671
+ return 0;
672
+ }
673
+ const outcomeCount = bin.count[outcome] || 0;
674
+ let totalChartableCount = 0;
675
+ for (const [outcomeName, count] of Object.entries(bin.count)) {
676
+ if (filterRules(outcomeName, damage)) {
677
+ totalChartableCount += count || 0;
678
+ }
679
+ }
680
+ if (totalChartableCount === 0) return 0;
681
+ const outcomeFraction = outcomeCount / totalChartableCount;
682
+ const outcomeProbability = bin.p * outcomeFraction;
683
+ return asPercentages ? outcomeProbability * 100 : outcomeProbability;
684
+ });
685
+ }
686
+ return {
687
+ support,
688
+ outcomes: existingOutcomes,
689
+ data
690
+ };
691
+ }
692
+ /**
693
+ * Returns pure mathematical data for damage attribution charts showing damage contribution
694
+ * from each outcome type at each damage value.
695
+ *
696
+ * Similar to toAttributionChartSeries() but uses bin.attr (damage attribution) instead of
697
+ * bin.count (probability attribution).
698
+ *
699
+ * @param options Configuration options
700
+ * @param options.stackOrder Preferred order for outcome types (unknowns placed at end)
701
+ * @param options.filterRules Function to determine if outcome should be included for a given damage value
702
+ * @param options.asPercentages Whether to return percentages (0-100) or raw damage values (0+)
703
+ * @returns Pure data structure with support, outcomes, and damage attribution data
704
+ *
705
+ * @example
706
+ * query.toDamageAttributionChartSeries()
707
+ * // → {support: [0, 6, 12], outcomes: ['hit', 'crit'], data: {hit: [3.2, 5.1, ...], crit: [0, 1.8, ...]}}
708
+ */
709
+ toDamageAttributionChartSeries(options = {}) {
710
+ const {
711
+ stackOrder = [
712
+ "missNone",
713
+ "missDamage",
714
+ "saveFail",
715
+ "saveHalf",
716
+ "pc",
717
+ "hit",
718
+ "crit"
719
+ ],
720
+ filterRules = (outcome, damage) => !(outcome === "missNone" && damage !== 0),
721
+ asPercentages = true
722
+ } = options;
723
+ const originalSupport = this.combined.support();
724
+ if (originalSupport.length === 0) {
725
+ return { support: [], outcomes: [], data: {} };
726
+ }
727
+ const minDamage = Math.min(...originalSupport);
728
+ const maxDamage = Math.max(...originalSupport);
729
+ const support = Array.from(
730
+ { length: maxDamage - minDamage + 1 },
731
+ (_, i) => minDamage + i
732
+ );
733
+ const allOutcomeTypes = /* @__PURE__ */ new Set();
734
+ for (const [, bin] of this.combined.map) {
735
+ if (bin.attr) {
736
+ for (const outcomeType in bin.attr) {
737
+ if (bin.attr[outcomeType] && bin.attr[outcomeType] > 0) {
738
+ allOutcomeTypes.add(outcomeType);
739
+ }
740
+ }
741
+ }
742
+ }
743
+ const existingOutcomes = Array.from(allOutcomeTypes).sort((a, b) => {
744
+ const indexA = stackOrder.indexOf(a);
745
+ const indexB = stackOrder.indexOf(b);
746
+ if (indexA >= 0 && indexB >= 0) return indexA - indexB;
747
+ if (indexA >= 0) return -1;
748
+ if (indexB >= 0) return 1;
749
+ return a.localeCompare(b);
750
+ });
751
+ if (existingOutcomes.length === 0) {
752
+ return { support, outcomes: [], data: {} };
753
+ }
754
+ const data = {};
755
+ for (const outcome of existingOutcomes) {
756
+ data[outcome] = support.map((damage) => {
757
+ const bin = this.combined.map.get(damage);
758
+ if (!bin || !bin.attr) return 0;
759
+ if (!filterRules(outcome, damage)) {
760
+ return 0;
761
+ }
762
+ const outcomeDamageAttribution = bin.attr[outcome] || 0;
763
+ if (asPercentages) {
764
+ let totalDamageAttribution = 0;
765
+ for (const [outcomeName, damageAttr] of Object.entries(bin.attr)) {
766
+ if (filterRules(outcomeName, damage)) {
767
+ totalDamageAttribution += damageAttr || 0;
768
+ }
769
+ }
770
+ if (totalDamageAttribution === 0) return 0;
771
+ const damagePercentage = outcomeDamageAttribution / totalDamageAttribution * 100;
772
+ return damagePercentage * bin.p * 100;
773
+ } else {
774
+ return outcomeDamageAttribution;
775
+ }
776
+ });
777
+ }
778
+ return {
779
+ support,
780
+ outcomes: existingOutcomes,
781
+ data
782
+ };
783
+ }
784
+ /**
785
+ * Returns pure mathematical data for outcome attribution charts showing which
786
+ * attack outcome combinations can produce each damage value.
787
+ *
788
+ * Unlike toDamageAttributionChartSeries() which tracks damage sources, this tracks
789
+ * outcome combinations - answering "what attack outcomes produced this damage?"
790
+ *
791
+ * @param options Configuration options
792
+ * @param options.stackOrder Preferred order for outcome types (unknowns placed at end)
793
+ * @param options.filterRules Function to determine if outcome should be included for a given damage value
794
+ * @param options.asPercentages Whether to return percentages (0-100) or probabilities (0-1)
795
+ * @returns Pure data structure with support, outcomes, and outcome combination probabilities
796
+ *
797
+ * @example
798
+ * query.toOutcomeAttributionChartSeries()
799
+ * // → {support: [0, 6, 12], outcomes: ['all_miss', 'mixed', 'all_hit'], data: {all_miss: [15, 0, 0], mixed: [60, 80, 20], all_hit: [25, 20, 80]}}
800
+ */
801
+ toOutcomeAttributionChartSeries(options = {}) {
802
+ const {
803
+ stackOrder = [
804
+ "missNone",
805
+ "missDamage",
806
+ "saveFail",
807
+ "saveHalf",
808
+ "pc",
809
+ "hit",
810
+ "crit"
811
+ ],
812
+ filterRules = (outcome, damage) => !(outcome === "missNone" && damage !== 0),
813
+ asPercentages = true
814
+ } = options;
815
+ const originalSupport = this.combined.support();
816
+ if (originalSupport.length === 0) {
817
+ return { support: [], outcomes: [], data: {} };
818
+ }
819
+ const minDamage = Math.min(...originalSupport);
820
+ const maxDamage = Math.max(...originalSupport);
821
+ const support = Array.from(
822
+ { length: maxDamage - minDamage + 1 },
823
+ (_, i) => minDamage + i
824
+ );
825
+ const allOutcomeTypes = /* @__PURE__ */ new Set();
826
+ for (const [, bin] of this.combined.map) {
827
+ for (const outcomeType in bin.count) {
828
+ if (bin.count[outcomeType] && bin.count[outcomeType] > 0) {
829
+ allOutcomeTypes.add(outcomeType);
830
+ }
831
+ }
832
+ }
833
+ const existingOutcomes = Array.from(allOutcomeTypes).sort((a, b) => {
834
+ const indexA = stackOrder.indexOf(a);
835
+ const indexB = stackOrder.indexOf(b);
836
+ if (indexA >= 0 && indexB >= 0) return indexA - indexB;
837
+ if (indexA >= 0) return -1;
838
+ if (indexB >= 0) return 1;
839
+ return a.localeCompare(b);
840
+ });
841
+ if (existingOutcomes.length === 0) {
842
+ return { support, outcomes: [], data: {} };
843
+ }
844
+ const data = {};
845
+ for (const outcome of existingOutcomes) {
846
+ data[outcome] = support.map((damage) => {
847
+ const bin = this.combined.map.get(damage);
848
+ if (!bin) return 0;
849
+ if (!filterRules(outcome, damage)) {
850
+ return 0;
851
+ }
852
+ if (outcome === "missNone") {
853
+ const outcomeCount = bin.count[outcome] || 0;
854
+ if (outcomeCount === 0) return 0;
855
+ if (asPercentages) {
856
+ let totalChartableCount = 0;
857
+ for (const [outcomeName, count] of Object.entries(bin.count)) {
858
+ if (filterRules(outcomeName, damage)) {
859
+ totalChartableCount += count || 0;
860
+ }
861
+ }
862
+ if (totalChartableCount === 0) return 0;
863
+ const outcomeFraction = outcomeCount / totalChartableCount;
864
+ return outcomeFraction * bin.p * 100;
865
+ } else {
866
+ return outcomeCount;
867
+ }
868
+ }
869
+ if (!bin.attr) return 0;
870
+ const outcomeDamageContribution = bin.attr[outcome] || 0;
871
+ if (asPercentages) {
872
+ let totalDamageAttribution = 0;
873
+ for (const [, damageAttr] of Object.entries(bin.attr)) {
874
+ totalDamageAttribution += damageAttr || 0;
875
+ }
876
+ if (totalDamageAttribution === 0) return 0;
877
+ const outcomeFraction = outcomeDamageContribution / totalDamageAttribution;
878
+ return outcomeFraction * bin.p * 100;
879
+ } else {
880
+ return outcomeDamageContribution;
881
+ }
882
+ });
883
+ }
884
+ return {
885
+ support,
886
+ outcomes: existingOutcomes,
887
+ data
888
+ };
889
+ }
890
+ /**
891
+ * Returns pure mathematical data for cumulative distribution function (CDF).
892
+ * Shows P(X ≤ x) - the probability of getting at most x damage.
893
+ *
894
+ * @param asPercentages Whether to return percentages (0-100) or probabilities (0-1)
895
+ * @returns Pure data structure with support and cumulative probabilities
896
+ *
897
+ * @example
898
+ * query.toCDFSeries()
899
+ * // → {support: [0, 6, 12], data: [5.2, 18.3, 45.1]}
900
+ */
901
+ toCDFSeries(asPercentages = true) {
902
+ const originalSupport = this.combined.support();
903
+ if (originalSupport.length === 0) {
904
+ return { support: [], data: [] };
905
+ }
906
+ const minDamage = Math.min(...originalSupport);
907
+ const maxDamage = Math.max(...originalSupport);
908
+ const support = Array.from(
909
+ { length: maxDamage - minDamage + 1 },
910
+ (_, i) => minDamage + i
911
+ );
912
+ let cumulativeProbability = 0;
913
+ const cdfData = [];
914
+ for (const damage of support) {
915
+ const bin = this.combined.map.get(damage);
916
+ if (bin) {
917
+ cumulativeProbability += bin.p;
918
+ }
919
+ cdfData.push(
920
+ asPercentages ? cumulativeProbability * 100 : cumulativeProbability
921
+ );
922
+ }
923
+ return {
924
+ support,
925
+ data: cdfData
926
+ };
927
+ }
928
+ /**
929
+ * Returns pure mathematical data for complementary cumulative distribution function (CCDF).
930
+ * Shows P(X ≥ x) - the probability of getting at least x damage.
931
+ *
932
+ * @param asPercentages Whether to return percentages (0-100) or probabilities (0-1)
933
+ * @returns Pure data structure with support and complementary cumulative probabilities
934
+ *
935
+ * @example
936
+ * query.toCCDFSeries()
937
+ * // → {support: [0, 6, 12], data: [100, 94.8, 81.7]}
938
+ */
939
+ toCCDFSeries(asPercentages = true) {
940
+ const originalSupport = this.combined.support();
941
+ if (originalSupport.length === 0) {
942
+ return { support: [], data: [] };
943
+ }
944
+ const minDamage = Math.min(...originalSupport);
945
+ const maxDamage = Math.max(...originalSupport);
946
+ const support = Array.from(
947
+ { length: maxDamage - minDamage + 1 },
948
+ (_, i) => minDamage + i
949
+ );
950
+ let cumulativeProbability = 0;
951
+ const ccdfData = [];
952
+ for (const damage of support) {
953
+ const ccdf = 1 - cumulativeProbability;
954
+ ccdfData.push(asPercentages ? ccdf * 100 : ccdf);
955
+ const bin = this.combined.map.get(damage);
956
+ if (bin) {
957
+ cumulativeProbability += bin.p;
958
+ }
959
+ }
960
+ return {
961
+ support,
962
+ data: ccdfData
963
+ };
964
+ }
965
+ /*
966
+ Statistics snapshot of the query.
967
+ */
968
+ /** Probability of doing strictly more than threshold damage (default >0). */
969
+ probDamageGreaterThan(threshold = 0) {
970
+ let acc = 0;
971
+ for (const [x, bin] of this.combined.map) if (x > threshold) acc += bin.p;
972
+ return acc;
973
+ }
974
+ /** All outcome keys actually present (typed & ordered if you pass an order). */
975
+ outcomeKeys(order) {
976
+ const found = /* @__PURE__ */ new Set();
977
+ for (const [, bin] of this.combined.map) {
978
+ for (const k in bin.count)
979
+ if (bin.count[k] && bin.count[k] > 0) found.add(k);
980
+ }
981
+ if (found.size === 0)
982
+ ["hit", "crit", "missNone"].forEach((k) => found.add(k));
983
+ const keys = Array.from(found).filter(
984
+ (k) => order?.includes(k) ?? true
985
+ );
986
+ if (order && order.length)
987
+ keys.sort((a, b) => order.indexOf(a) + 999 - (order.indexOf(b) + 999));
988
+ return keys;
989
+ }
990
+ /** Total probability per outcome across the PMF. */
991
+ outcomeTotals(outcomes = this.outcomeKeys()) {
992
+ const totals = /* @__PURE__ */ new Map();
993
+ outcomes.forEach((o) => totals.set(o, 0));
994
+ for (const [, row] of this.combined.map) {
995
+ for (const o of outcomes) {
996
+ const p = row.count[o] || 0;
997
+ totals.set(o, (totals.get(o) || 0) + p);
998
+ }
999
+ }
1000
+ return totals;
1001
+ }
1002
+ /** Conditional damage range per outcome (min/avg/max of X | outcome). */
1003
+ outcomeDamageRanges(outcomes = this.outcomeKeys()) {
1004
+ const table = this.toLabeledTable(outcomes);
1005
+ const ranges = /* @__PURE__ */ new Map();
1006
+ outcomes.forEach((o) => ranges.set(o, { sum: 0, mass: 0 }));
1007
+ for (const row of table) {
1008
+ const dmg = row.damage;
1009
+ for (const o of outcomes) {
1010
+ const p = row[o] || 0;
1011
+ if (p > 0) {
1012
+ const r = ranges.get(o);
1013
+ r.sum += dmg * p;
1014
+ r.mass += p;
1015
+ if (r.min === void 0 || dmg < r.min) r.min = dmg;
1016
+ if (r.max === void 0 || dmg > r.max) r.max = dmg;
1017
+ }
1018
+ }
1019
+ }
1020
+ const out = /* @__PURE__ */ new Map();
1021
+ for (const o of outcomes) {
1022
+ const r = ranges.get(o);
1023
+ const avg = r.mass > 0 ? r.sum / r.mass : 0;
1024
+ out.set(o, { min: r.min ?? 0, avg, max: r.max ?? 0 });
1025
+ }
1026
+ return out;
1027
+ }
1028
+ /**
1029
+ * Snapshot of the distribution in the exact shape the UI consumes.
1030
+ * - outcome probabilities are "at least one" (and equal to "all" for a single PMF)
1031
+ * - damageRange is conditional on the outcome occurring
1032
+ */
1033
+ snapshot(order) {
1034
+ const discovered = /* @__PURE__ */ new Set();
1035
+ for (const [, bin] of this.combined.map) {
1036
+ for (const k in bin.count) {
1037
+ if (bin.count[k] && bin.count[k] > 0) discovered.add(k);
1038
+ }
1039
+ }
1040
+ if (discovered.size === 0) {
1041
+ for (const k of _DiceQuery.DEFAULT_OUTCOMES) discovered.add(k);
1042
+ }
1043
+ let outcomes = Array.from(discovered);
1044
+ if (order && order.length) {
1045
+ const inOrder = new Set(order);
1046
+ outcomes = outcomes.filter((k) => inOrder.has(k));
1047
+ const rank = new Map(order.map((k, i) => [k, i]));
1048
+ outcomes.sort(
1049
+ (a, b) => (rank.get(a) ?? 999) - (rank.get(b) ?? 999)
1050
+ );
1051
+ }
1052
+ const rows = this.toLabeledTable(outcomes);
1053
+ const totals = /* @__PURE__ */ new Map();
1054
+ const rangeAcc = /* @__PURE__ */ new Map();
1055
+ for (const ot of outcomes) {
1056
+ totals.set(ot, 0);
1057
+ rangeAcc.set(ot, { sum: 0, mass: 0 });
1058
+ }
1059
+ for (const row of rows) {
1060
+ const dmg = row.damage;
1061
+ for (const ot of outcomes) {
1062
+ const p = row[ot] || 0;
1063
+ if (p <= 0) continue;
1064
+ totals.set(ot, (totals.get(ot) || 0) + p);
1065
+ const r = rangeAcc.get(ot);
1066
+ r.sum += dmg * p;
1067
+ r.mass += p;
1068
+ if (r.min === void 0 || dmg < r.min) r.min = dmg;
1069
+ if (r.max === void 0 || dmg > r.max) r.max = dmg;
1070
+ }
1071
+ }
1072
+ const outcomeMap = /* @__PURE__ */ new Map();
1073
+ for (const ot of outcomes) {
1074
+ const total = totals.get(ot) || 0;
1075
+ const r = rangeAcc.get(ot);
1076
+ const avg = r.mass > 0 ? r.sum / r.mass : 0;
1077
+ outcomeMap.set(ot, {
1078
+ atLeastOneProbability: total,
1079
+ allProbability: total,
1080
+ // single aggregate PMF: same value
1081
+ damageRange: { min: r.min ?? 0, avg, max: r.max ?? 0 }
1082
+ });
1083
+ }
1084
+ const averageDPR = this.mean();
1085
+ let damageChance = 0;
1086
+ for (const [x, bin] of this.combined.map) if (x > 0) damageChance += bin.p;
1087
+ const { support, data } = this.toCDFSeries(false);
1088
+ const quantile = (p) => {
1089
+ if (support.length === 0) return 0;
1090
+ for (let i = 0; i < support.length; i++)
1091
+ if (data[i] >= p) return support[i];
1092
+ return support[support.length - 1];
1093
+ };
1094
+ const percentiles = {
1095
+ p25: quantile(0.25),
1096
+ p50: quantile(0.5),
1097
+ p75: quantile(0.75)
1098
+ };
1099
+ return { averageDPR, damageChance, percentiles, outcomes: outcomeMap };
1100
+ }
1101
+ /**
1102
+ * PMF Transformation Methods
1103
+ *
1104
+ * These methods provide a fluent API for transforming dice queries by wrapping
1105
+ * the underlying PMF transformation methods. All operations work on the combined
1106
+ * PMF and return new DiceQuery instances.
1107
+ */
1108
+ /**
1109
+ * Returns a new DiceQuery with normalized probabilities (ensuring they sum to 1.0).
1110
+ *
1111
+ * @returns New DiceQuery with normalized combined PMF
1112
+ */
1113
+ normalize() {
1114
+ return new _DiceQuery([this.combined.normalize()]);
1115
+ }
1116
+ /**
1117
+ * Returns a new DiceQuery with low-probability outcomes removed.
1118
+ *
1119
+ * @param eps Minimum probability threshold (defaults to PMF epsilon)
1120
+ * @param keepFinalBin Whether to keep the highest damage bin regardless of probability
1121
+ * @returns New DiceQuery with compacted combined PMF
1122
+ */
1123
+ compact(eps, keepFinalBin) {
1124
+ return new _DiceQuery([this.combined.compact(eps, keepFinalBin)]);
1125
+ }
1126
+ /**
1127
+ * Returns a new DiceQuery with an additional scaled branch added.
1128
+ * Useful for conditional outcomes like "30% chance of opportunity attack".
1129
+ *
1130
+ * @param branch DiceQuery to add as a scaled branch
1131
+ * @param probability Probability of the branch occurring (0-1)
1132
+ * @returns New DiceQuery combining this query with the scaled branch
1133
+ *
1134
+ * @example
1135
+ * const baseAttack = parse("(d20 + 5 AC 15) * (2d6 + 3)");
1136
+ * const opportunityAttack = parse("(d20 + 5 AC 15) * (1d8 + 3)");
1137
+ * const withOpportunity = baseAttack.addScaled(opportunityAttack, 0.3);
1138
+ */
1139
+ addScaled(branch, probability) {
1140
+ return new _DiceQuery([
1141
+ this.combined.addScaled(branch.combined, probability)
1142
+ ]);
1143
+ }
1144
+ /**
1145
+ * Returns a new DiceQuery with all probabilities scaled by a factor.
1146
+ * Used for conditional scenarios where the entire outcome has reduced probability.
1147
+ *
1148
+ * @param factor Scaling factor for probabilities
1149
+ * @returns New DiceQuery with scaled probabilities
1150
+ *
1151
+ * @example
1152
+ * const fullAttack = parse("(d20 + 5 AC 15) * (2d6 + 3)");
1153
+ * const conditionalAttack = fullAttack.scaleMass(0.3); // 30% chance scenario
1154
+ */
1155
+ scaleMass(factor) {
1156
+ return new _DiceQuery([this.combined.scaleMass(factor)]);
1157
+ }
1158
+ totalMass() {
1159
+ return this.combined.mass();
1160
+ }
1161
+ /**
1162
+ * Returns a new DiceQuery with damage values transformed by a function.
1163
+ * Useful for applying modifiers, resistances, or other damage transformations.
1164
+ *
1165
+ * @param damageTransformFunction Function to transform each damage value
1166
+ * @returns New DiceQuery with transformed damage values
1167
+ *
1168
+ * @example
1169
+ * const baseAttack = parse("2d6 + 3");
1170
+ * const withResistance = baseAttack.mapDamage(dmg => Math.floor(dmg / 2)); // Half damage
1171
+ * const withBonus = baseAttack.mapDamage(dmg => dmg + 5); // +5 damage
1172
+ */
1173
+ mapDamage(damageTransformFunction) {
1174
+ return new _DiceQuery([this.combined.mapDamage(damageTransformFunction)]);
1175
+ }
1176
+ /**
1177
+ * Returns a new DiceQuery with damage values scaled by a factor.
1178
+ * Convenient wrapper around mapDamage for multiplicative scaling.
1179
+ *
1180
+ * @param factor Scaling factor for damage values
1181
+ * @param rounding Rounding method: "floor" (default), "round", or "ceil"
1182
+ * @returns New DiceQuery with scaled damage values
1183
+ *
1184
+ * @example
1185
+ * const baseAttack = parse("2d6 + 3");
1186
+ * const doubled = baseAttack.scaleDamage(2); // Double damage
1187
+ * const halfDamage = baseAttack.scaleDamage(0.5, "round"); // Half damage, rounded
1188
+ */
1189
+ scaleDamage(factor, rounding = "floor") {
1190
+ return new _DiceQuery([this.combined.scaleDamage(factor, rounding)]);
1191
+ }
1192
+ /**
1193
+ * Returns a new DiceQuery combining this query with another via convolution.
1194
+ * Equivalent to rolling both queries independently and adding results.
1195
+ * It is important to use this rather than combing()ing the PMFs directly!
1196
+ * This method maintains the provenance of the PMFs which is needed for damage attribution.
1197
+ * Combining the .combined PMFs directly is still valid for DPR calculations but
1198
+ * is not statistically sound for queries.
1199
+ *
1200
+ * @param other DiceQuery to combine with
1201
+ * @param eps Optional epsilon for precision control
1202
+ * @returns New DiceQuery representing the combined outcome
1203
+ *
1204
+ * @example
1205
+ * const mainAttack = parse("(d20 + 5 AC 15) * (2d6 + 3)");
1206
+ * const bonusAttack = parse("(d20 + 3 AC 15) * (1d6 + 1)");
1207
+ * const bothAttacks = mainAttack.convolve(bonusAttack);
1208
+ */
1209
+ convolve(other) {
1210
+ const singles = [...this.singles, ...other.singles];
1211
+ return new _DiceQuery(singles);
1212
+ }
1213
+ /**
1214
+ * First-success split over an ordered list of DISTINCT single-swing PMFs.
1215
+ * Each PMF may have different success/subset probabilities (from labels).
1216
+ *
1217
+ * successOutcome: e.g., ["success"] or ["hit", "crit"]
1218
+ * subsetOutcome: e.g., ["subset"] or ["crit"] where subset ⊆ success
1219
+ *
1220
+ * Returns tuple: [pFirstNonSubset, pFirstSubset, pAnySuccess, pNone]
1221
+ */
1222
+ firstSuccessSplit(successOutcome, subsetOutcome, eps = EPS) {
1223
+ const pmfs = this.singles;
1224
+ if (!pmfs.length) {
1225
+ throw new Error("firstSuccessSplitFromPMFs: pmfs must be non-empty");
1226
+ }
1227
+ const toArr = (x) => Array.isArray(x) ? x : [x];
1228
+ const clamp01 = (x) => Math.max(0, Math.min(1, x));
1229
+ const tol = Math.max(eps, 8 * Number.EPSILON);
1230
+ const per = pmfs.map((pmf) => {
1231
+ const dq = new _DiceQuery([pmf]);
1232
+ const pS = dq.probAtLeastOne(toArr(successOutcome));
1233
+ const pB = dq.probAtLeastOne(toArr(subsetOutcome));
1234
+ if (pB - pS > eps) {
1235
+ throw new Error(
1236
+ "firstSuccessSplitFromPMFs: P(subset) > P(success) for an event. Ensure subset \u2286 success."
1237
+ );
1238
+ }
1239
+ return { pS, pB };
1240
+ });
1241
+ let missSoFar = 1;
1242
+ let pFirstSubset = 0;
1243
+ let pFirstNonSubset = 0;
1244
+ let pNone = 1;
1245
+ for (const { pS, pB } of per) {
1246
+ pFirstSubset += missSoFar * pB;
1247
+ pFirstNonSubset += missSoFar * (pS - pB);
1248
+ const miss = 1 - pS;
1249
+ missSoFar *= miss;
1250
+ pNone *= miss;
1251
+ }
1252
+ const pAny = 1 - pNone;
1253
+ const a = clamp01(pFirstNonSubset);
1254
+ const b = clamp01(pFirstSubset);
1255
+ const any = clamp01(pAny);
1256
+ const none = clamp01(pNone);
1257
+ if (Math.abs(a + b - any) > tol * Math.max(1, any)) {
1258
+ throw new Error(
1259
+ `firstSuccessSplitFromPMFs: parts do not sum to pAny. got a+b=${a + b}, pAny=${any}`
1260
+ );
1261
+ }
1262
+ return [a, b, any, none];
1263
+ }
1264
+ };
1265
+ __publicField(_DiceQuery, "DEFAULT_OUTCOMES", [
1266
+ "hit",
1267
+ "crit",
1268
+ "missNone"
1269
+ ]);
1270
+ var DiceQuery = _DiceQuery;
1271
+ var pmfCache = new LRUCache(1e3);
1272
+ var _PMF = class _PMF {
1273
+ constructor(map = /* @__PURE__ */ new Map(), epsilon = EPS, normalized = false, identifier = `anon#${_PMF.__anonIdCounter++}`, _preservedProvenance = true) {
1274
+ this.map = map;
1275
+ this.epsilon = epsilon;
1276
+ this.normalized = normalized;
1277
+ this.identifier = identifier;
1278
+ this._preservedProvenance = _preservedProvenance;
1279
+ // Cached computed values
1280
+ __publicField(this, "_support");
1281
+ __publicField(this, "_min");
1282
+ __publicField(this, "_max");
1283
+ __publicField(this, "_totalMass");
1284
+ __publicField(this, "_mean");
1285
+ __publicField(this, "_variance");
1286
+ __publicField(this, "_stdev");
1287
+ }
1288
+ static empty(epsilon = EPS, identifier = "empty") {
1289
+ return new _PMF(/* @__PURE__ */ new Map(), epsilon, false, identifier);
1290
+ }
1291
+ // This has a single bin at value 0, mass of 1
1292
+ static zero(epsilon = EPS) {
1293
+ const m = /* @__PURE__ */ new Map();
1294
+ m.set(0, { p: 1, count: { miss: 1 }, attr: {} });
1295
+ return new _PMF(m, epsilon, false, "zero");
1296
+ }
1297
+ static delta(value, epsilon = EPS) {
1298
+ return _PMF.fromMap(/* @__PURE__ */ new Map([[value, 1]]), epsilon);
1299
+ }
1300
+ // This creates a single bin at value 0, but with weight 0.
1301
+ static emptyMass() {
1302
+ return _PMF.zero().scaleMass(0);
1303
+ }
1304
+ // Makes PMF iterable over [damage, bin] pairs.
1305
+ [Symbol.iterator]() {
1306
+ return this.map[Symbol.iterator]();
1307
+ }
1308
+ static clearCache() {
1309
+ pmfCache.clear();
1310
+ }
1311
+ /**
1312
+ * Creates a conditional PMF from two branches (success and failure) and a probability.
1313
+ * This is the core logic for modeling any probabilistic event where there are two
1314
+ * distinct outcomes.
1315
+ */
1316
+ static branch(successPMF, failurePMF, successProbability) {
1317
+ let p = successProbability;
1318
+ if (!Number.isFinite(p)) p = 0;
1319
+ if (p < 0) p = 0;
1320
+ if (p > 1) p = 1;
1321
+ const q = 1 - p;
1322
+ if (p === 0) return failurePMF.scaleMass(1);
1323
+ if (p === 1) return successPMF.scaleMass(1);
1324
+ const eps = successPMF.epsilon ?? failurePMF.epsilon;
1325
+ const id = `branch(${failurePMF.identifier}*${q.toFixed(6)} + ${successPMF.identifier}*${p.toFixed(6)})`;
1326
+ const out = _PMF.empty(eps, id).addScaled(failurePMF, q).addScaled(successPMF, p);
1327
+ return out;
1328
+ }
1329
+ /**
1330
+ * withProbability()
1331
+ *
1332
+ * A convenience wrapper around branch() for the common case where the "failure" branch is always zero().
1333
+ *
1334
+ * Think of this as a shortcut for:
1335
+ * pmf.gate(p, PMF.zero())
1336
+ *
1337
+ * Use this to model a *single* Bernoulli event — an outcome that either happens or doesn't,
1338
+ * like an opportunity attack that occurs with probability p, or a single attack that either hits or misses.
1339
+ *
1340
+ * This is **not** for combining multiple independent attacks or mutually exclusive multi-outcome scenarios.
1341
+ * - For multiple independent swings, use DiceQuery with separate PMFs for each attack.
1342
+ * - For modeling "first success" logic across multiple attacks (like Sneak Attack or Smite)
1343
+ * use query.firstSuccessSplit() to get the exact probabilities.
1344
+ * - For scenarios with several mutually exclusive outcomes (like crit vs hit vs none), use PMF.exclusive().
1345
+ *
1346
+ */
1347
+ static withProbability(successPMF, probability) {
1348
+ return _PMF.branch(successPMF, _PMF.zero(), probability);
1349
+ }
1350
+ /**
1351
+ * gate()
1352
+ *
1353
+ * A conditional wrapper around branch() that applies this PMF with probability `p`,
1354
+ * and applies a provided fallback PMF otherwise.
1355
+ *
1356
+ * This is useful for modeling a binary choice between two outcomes:
1357
+ * - The "success" outcome (this PMF) happens with probability `p`.
1358
+ * - The "failure" outcome (fallback PMF) happens with probability `1 - p`.
1359
+ *
1360
+ * Examples:
1361
+ * - 25% chance to include an opportunity attack, otherwise nothing:
1362
+ * attackPMF.gate(0.25, PMF.zero())
1363
+ *
1364
+ * - 50% chance to deal fireball damage, otherwise cone of cold damage:
1365
+ * fireballPMF.gate(0.5, coneOfColdPMF)
1366
+ *
1367
+ * Relationship to other helpers:
1368
+ * - **withProbability()** is a shortcut for the common case where the fallback is `PMF.zero()`.
1369
+ * - **exclusive()** is for three or more mutually exclusive outcomes (e.g., crit vs hit vs none).
1370
+ *
1371
+ * @param p Probability of applying this PMF (between 0 and 1).
1372
+ * @param fallback PMF to apply when this PMF is *not* selected.
1373
+ * @returns A new PMF representing the weighted mixture of this PMF and the fallback.
1374
+ */
1375
+ gate(p, zero) {
1376
+ return _PMF.branch(this, zero, p);
1377
+ }
1378
+ /**
1379
+ * PMF.exclusive()
1380
+ *
1381
+ * Builds a single PMF from a set of mutually exclusive weighted outcomes.
1382
+ * Exactly one of the provided options will occur.
1383
+ *
1384
+ * Each option has:
1385
+ * - A PMF representing its outcome (e.g., damage dice).
1386
+ * - A weight representing its probability of being selected.
1387
+ *
1388
+ * This is useful for modeling situations like:
1389
+ * - Sneak Attack: 6d6 if first hit is a crit, 3d6 if first hit is non-crit, 0 otherwise.
1390
+ * - Multiple exclusive spells: Fireball vs Cone of Cold vs nothing.
1391
+ *
1392
+ * Notes:
1393
+ * - If the total weight is less than 1, a zero PMF will automatically be added to
1394
+ * make up the remaining probability.
1395
+ * - Throws an error if the total weight is greater than 1 (invalid probability sum).
1396
+ *
1397
+ * @param options Array of `{ pmf, weight }` objects, each representing an outcome and its probability.
1398
+ * @param label Optional name for debugging or charting.
1399
+ * @param eps Optional tolerance for floating point rounding
1400
+ * @returns A single PMF representing the exclusive mixture of all options.
1401
+ */
1402
+ static exclusive(options, eps = EPS) {
1403
+ const items = options.map(
1404
+ (o) => Array.isArray(o) ? { pmf: o[0], weight: o[1] } : o
1405
+ );
1406
+ const totalWeight = items.reduce((s, { weight }) => s + weight, 0);
1407
+ if (totalWeight - 1 > eps) {
1408
+ throw new Error(
1409
+ `PMF.exclusive: total weight ${totalWeight.toFixed(6)} exceeds 1.`
1410
+ );
1411
+ }
1412
+ let out = items.reduce(
1413
+ (acc, { pmf, weight }) => weight > 0 ? acc.addScaled(pmf, weight) : acc,
1414
+ _PMF.empty(eps)
1415
+ );
1416
+ const leftover = 1 - totalWeight;
1417
+ if (leftover > eps) {
1418
+ out = out.addScaled(_PMF.zero(), leftover);
1419
+ }
1420
+ return out;
1421
+ }
1422
+ /**
1423
+ * General-purpose N-way mixture.
1424
+ * weights: Array of [weight, PMF].
1425
+ *
1426
+ * Example: PMF.mixN([
1427
+ * [pMiss, zero],
1428
+ * [pHit, hitPMF],
1429
+ * [pCrit, critPMF],
1430
+ * ]);
1431
+ */
1432
+ static mixN(weights, eps = EPS) {
1433
+ const filtered = weights.filter(([w]) => w > eps);
1434
+ if (filtered.length === 0) {
1435
+ return _PMF.emptyMass();
1436
+ }
1437
+ let acc = null;
1438
+ let sum = 0;
1439
+ for (const [w, pmf] of filtered) {
1440
+ if (acc === null) {
1441
+ acc = pmf;
1442
+ sum = w;
1443
+ } else {
1444
+ const q = w / (sum + w);
1445
+ acc = _PMF.branch(pmf, acc, q);
1446
+ sum += w;
1447
+ }
1448
+ }
1449
+ return acc ?? _PMF.emptyMass();
1450
+ }
1451
+ // This is a convenience method for when we use power
1452
+ // TODO: It can be smarter in the future, and we can also add it to query
1453
+ // That way statistics operations on invalid PMFs can throw an error
1454
+ // TODO… how can we detect if manually merging two queries' combined PMFs, as that loses provenance?
1455
+ setPreservedProvenance(preserved) {
1456
+ if (!this._preservedProvenance && preserved) {
1457
+ throw new Error(
1458
+ "Preserved provenance is already set to false, cannot fix that"
1459
+ );
1460
+ }
1461
+ this._preservedProvenance = preserved;
1462
+ }
1463
+ preservedProvenance() {
1464
+ return this._preservedProvenance;
1465
+ }
1466
+ getPowerCacheKey(n, eps) {
1467
+ const id = this.identifier;
1468
+ let key = `${id}`;
1469
+ for (let i = 1; i < n; i++) key += `+${id}`;
1470
+ return `${key}@${eps}`;
1471
+ }
1472
+ /**
1473
+ * Efficiently computes this PMF convolved with itself `n` times.
1474
+ * Uses exponentiation by squaring to reduce total convolutions.
1475
+ * n must be a positive integer.
1476
+ * *
1477
+ * * NOTE: This folds multiple independent attacks into a single PMF.
1478
+ * As a result, The power() method causes a loss of data provenance.
1479
+ * This is ONLY SAFE if you are trying to calculate masses.
1480
+ * If you want to query any atLeast probabilities, you should use the DiceQuery class instead without power().
1481
+ */
1482
+ power(n, eps = this.epsilon) {
1483
+ if (!Number.isInteger(n) || n <= 0) {
1484
+ throw new Error("power(n): n must be a positive integer");
1485
+ }
1486
+ if (n === 1) return this;
1487
+ const epsilon = eps ?? this.epsilon;
1488
+ const key = this.getPowerCacheKey(n, epsilon);
1489
+ {
1490
+ const cached = pmfCache?.get(key);
1491
+ if (cached) return cached;
1492
+ }
1493
+ let base = this.normalized ? this : this.normalize();
1494
+ let result = base;
1495
+ let exp = n - 1;
1496
+ while (exp > 0) {
1497
+ if (exp & 1) {
1498
+ result = result.convolve(base, epsilon);
1499
+ }
1500
+ exp >>= 1;
1501
+ if (exp > 0) {
1502
+ base = base.convolve(base, epsilon);
1503
+ }
1504
+ }
1505
+ result.setPreservedProvenance(false);
1506
+ {
1507
+ pmfCache?.set(key, result);
1508
+ }
1509
+ return result;
1510
+ }
1511
+ /*
1512
+ * Helper for chaining multiple identical attacks
1513
+ */
1514
+ replicate(n) {
1515
+ if (!Number.isInteger(n) || n <= 0) {
1516
+ throw new Error("combineN(n): n must be a positive integer");
1517
+ }
1518
+ if (n === 1) return [this];
1519
+ return Array.from({ length: n }, () => this);
1520
+ }
1521
+ mass() {
1522
+ if (this._totalMass === void 0) {
1523
+ let totalProbabilityMass = 0;
1524
+ for (const { p } of this.map.values()) {
1525
+ totalProbabilityMass += p;
1526
+ }
1527
+ this._totalMass = totalProbabilityMass;
1528
+ }
1529
+ return this._totalMass;
1530
+ }
1531
+ outcomeMass(outcome) {
1532
+ let totalProbabilityMass = 0;
1533
+ for (const { p, count } of this.map.values()) {
1534
+ totalProbabilityMass += p * (count[outcome] ?? 0);
1535
+ }
1536
+ return totalProbabilityMass;
1537
+ }
1538
+ // Helper for testing
1539
+ faceTotal() {
1540
+ return [...this.map.keys()].reduce((sum, key) => sum + key, 0);
1541
+ }
1542
+ normalize() {
1543
+ if (this.normalized) return this;
1544
+ const normalizationFactor = this.mass();
1545
+ if (normalizationFactor === 0) return this;
1546
+ const normalizedMap = /* @__PURE__ */ new Map();
1547
+ for (const [damageValue, probabilityBin] of this.map) {
1548
+ const normalizedProbability = probabilityBin.p / normalizationFactor;
1549
+ const normalizedCount = {};
1550
+ for (const labelKey in probabilityBin.count) {
1551
+ normalizedCount[labelKey] = probabilityBin.count[labelKey] / normalizationFactor;
1552
+ }
1553
+ let normalizedAttributes;
1554
+ if (probabilityBin.attr) {
1555
+ normalizedAttributes = {};
1556
+ for (const labelKey in probabilityBin.attr) {
1557
+ normalizedAttributes[labelKey] = probabilityBin.attr[labelKey] / normalizationFactor;
1558
+ }
1559
+ }
1560
+ normalizedMap.set(damageValue, {
1561
+ p: normalizedProbability,
1562
+ count: normalizedCount,
1563
+ attr: normalizedAttributes
1564
+ });
1565
+ }
1566
+ return new _PMF(normalizedMap, this.epsilon, true, this.identifier);
1567
+ }
1568
+ /**
1569
+ * Returns a copy with negligible probabilities removed (p < eps).
1570
+ * If keepFinalBin is true, the bin with the largest key is always kept,
1571
+ * even if its probability is below eps. count/attr submaps are still cleaned.
1572
+ */
1573
+ compact(eps = this.epsilon, keepFinalBin = false) {
1574
+ let maxKey = -Infinity;
1575
+ if (keepFinalBin) {
1576
+ for (const key of this.map.keys()) {
1577
+ if (key > maxKey) maxKey = key;
1578
+ }
1579
+ }
1580
+ const compactedMap = /* @__PURE__ */ new Map();
1581
+ for (const [damageValue, probabilityBin] of this.map) {
1582
+ const shouldKeep = probabilityBin.p >= eps || keepFinalBin && damageValue === maxKey;
1583
+ if (!shouldKeep) continue;
1584
+ for (const labelKey in probabilityBin.count) {
1585
+ if (Math.abs(probabilityBin.count[labelKey] || 0) < eps) {
1586
+ delete probabilityBin.count[labelKey];
1587
+ }
1588
+ }
1589
+ if (probabilityBin.attr) {
1590
+ for (const labelKey in probabilityBin.attr) {
1591
+ if (Math.abs(probabilityBin.attr[labelKey] || 0) < eps) {
1592
+ delete probabilityBin.attr[labelKey];
1593
+ }
1594
+ }
1595
+ if (Object.keys(probabilityBin.attr).length === 0) {
1596
+ probabilityBin.attr = void 0;
1597
+ }
1598
+ }
1599
+ compactedMap.set(damageValue, probabilityBin);
1600
+ }
1601
+ return new _PMF(compactedMap, eps, this.normalized, this.identifier);
1602
+ }
1603
+ // Note: The "support" of a PMF is the set of all non-zero probability outcomes.
1604
+ // This returns all damage values with non-zero probability, sorted ascending.
1605
+ support() {
1606
+ if (this._support === void 0) {
1607
+ this._support = [...this.map.keys()].sort((a, b) => a - b);
1608
+ }
1609
+ return this._support;
1610
+ }
1611
+ // Minimum possible damage value.
1612
+ min() {
1613
+ if (this._min === void 0) {
1614
+ const support = this.support();
1615
+ this._min = support.length > 0 ? support[0] : 0;
1616
+ }
1617
+ return this._min;
1618
+ }
1619
+ // Maximum possible damage value.
1620
+ max() {
1621
+ if (this._max === void 0) {
1622
+ const support = this.support();
1623
+ this._max = support.length > 0 ? support[support.length - 1] : 0;
1624
+ }
1625
+ return this._max;
1626
+ }
1627
+ /**
1628
+ * Returns the expected (mean) damage value.
1629
+ * Cached for performance since this requires iterating through all bins.
1630
+ */
1631
+ mean() {
1632
+ if (this._mean === void 0) {
1633
+ let totalSum = 0;
1634
+ for (const [damageValue, probabilityBin] of this.map) {
1635
+ totalSum += damageValue * probabilityBin.p;
1636
+ }
1637
+ this._mean = totalSum;
1638
+ }
1639
+ return this._mean;
1640
+ }
1641
+ /**
1642
+ * Returns the variance of the damage distribution.
1643
+ * Cached for performance since this requires mean calculation plus iteration.
1644
+ */
1645
+ variance() {
1646
+ if (this._variance === void 0) {
1647
+ const meanValue = this.mean();
1648
+ let varianceSum = 0;
1649
+ for (const [damageValue, probabilityBin] of this.map) {
1650
+ const deviationFromMean = damageValue - meanValue;
1651
+ varianceSum += deviationFromMean * deviationFromMean * probabilityBin.p;
1652
+ }
1653
+ this._variance = varianceSum;
1654
+ }
1655
+ return this._variance;
1656
+ }
1657
+ /**
1658
+ * Returns the standard deviation of the damage distribution.
1659
+ */
1660
+ stdev() {
1661
+ if (this._stdev === void 0) {
1662
+ this._stdev = Math.sqrt(this.variance());
1663
+ }
1664
+ return this._stdev;
1665
+ }
1666
+ static mergeInto(destinationMap, damageValue, binToAdd) {
1667
+ const existingBin = destinationMap.get(damageValue);
1668
+ if (!existingBin) {
1669
+ destinationMap.set(damageValue, {
1670
+ p: binToAdd.p,
1671
+ count: { ...binToAdd.count },
1672
+ attr: binToAdd.attr ? { ...binToAdd.attr } : void 0
1673
+ });
1674
+ return;
1675
+ }
1676
+ existingBin.p += binToAdd.p;
1677
+ for (const labelKey in binToAdd.count) {
1678
+ existingBin.count[labelKey] = (existingBin.count[labelKey] || 0) + binToAdd.count[labelKey];
1679
+ }
1680
+ if (binToAdd.attr) {
1681
+ if (!existingBin.attr) {
1682
+ existingBin.attr = {};
1683
+ }
1684
+ for (const labelKey in binToAdd.attr) {
1685
+ existingBin.attr[labelKey] = (existingBin.attr[labelKey] || 0) + binToAdd.attr[labelKey];
1686
+ }
1687
+ }
1688
+ }
1689
+ // Convenience method
1690
+ add(other) {
1691
+ return this.addScaled(other, 1);
1692
+ }
1693
+ /**
1694
+ * Returns a new PMF with a scaled branch added to this one.
1695
+ * The branch PMF is scaled by the given probability before merging
1696
+ * This will be very useful for conditional effects and for being
1697
+ * able to model "I can probably have this opportunity attack 40% of rounds"
1698
+ * Example: `pmf.addScaled(critBranch, 0.05)` → PMF including 5% crit outcomes
1699
+ */
1700
+ addScaled(branch, probability) {
1701
+ if (probability === 0) return this;
1702
+ const resultMap = /* @__PURE__ */ new Map();
1703
+ for (const [dmg, bin] of this.map) {
1704
+ resultMap.set(dmg, {
1705
+ p: bin.p,
1706
+ count: { ...bin.count },
1707
+ attr: bin.attr ? { ...bin.attr } : void 0
1708
+ });
1709
+ }
1710
+ for (const [damageValue, probabilityBin] of branch.map) {
1711
+ const scaledCount = {};
1712
+ for (const k in probabilityBin.count) {
1713
+ scaledCount[k] = probability * probabilityBin.count[k];
1714
+ }
1715
+ let scaledAttributes;
1716
+ if (probabilityBin.attr) {
1717
+ scaledAttributes = {};
1718
+ for (const k in probabilityBin.attr) {
1719
+ scaledAttributes[k] = probability * probabilityBin.attr[k];
1720
+ }
1721
+ }
1722
+ _PMF.mergeInto(resultMap, damageValue, {
1723
+ p: probability * probabilityBin.p,
1724
+ count: scaledCount,
1725
+ attr: scaledAttributes
1726
+ });
1727
+ }
1728
+ return new _PMF(
1729
+ resultMap,
1730
+ this.epsilon,
1731
+ false,
1732
+ `${this.identifier}+scaled(${branch.identifier},${probability})`
1733
+ );
1734
+ }
1735
+ scaleMass(factor) {
1736
+ if (factor === 1) return this;
1737
+ const scaledMap = /* @__PURE__ */ new Map();
1738
+ for (const [damageValue, probabilityBin] of this.map) {
1739
+ const scaledCount = {};
1740
+ for (const labelKey in probabilityBin.count) {
1741
+ scaledCount[labelKey] = probabilityBin.count[labelKey] * factor;
1742
+ }
1743
+ let scaledAttributes;
1744
+ if (probabilityBin.attr) {
1745
+ scaledAttributes = {};
1746
+ for (const labelKey in probabilityBin.attr) {
1747
+ scaledAttributes[labelKey] = probabilityBin.attr[labelKey] * factor;
1748
+ }
1749
+ }
1750
+ scaledMap.set(damageValue, {
1751
+ p: probabilityBin.p * factor,
1752
+ count: scaledCount,
1753
+ attr: scaledAttributes
1754
+ });
1755
+ }
1756
+ return new _PMF(
1757
+ scaledMap,
1758
+ this.epsilon,
1759
+ false,
1760
+ `scale(${this.identifier},${factor})`
1761
+ );
1762
+ }
1763
+ mapDamage(damageTransformFunction) {
1764
+ const transformedMap = /* @__PURE__ */ new Map();
1765
+ for (const [originalDamage, probabilityBin] of this.map) {
1766
+ const transformedDamage = damageTransformFunction(originalDamage);
1767
+ _PMF.mergeInto(transformedMap, transformedDamage, {
1768
+ p: probabilityBin.p,
1769
+ count: { ...probabilityBin.count },
1770
+ attr: probabilityBin.attr ? { ...probabilityBin.attr } : void 0
1771
+ });
1772
+ }
1773
+ return new _PMF(
1774
+ transformedMap,
1775
+ this.epsilon,
1776
+ this.normalized,
1777
+ `map(${this.identifier})`
1778
+ );
1779
+ }
1780
+ scaleDamage(factor, rounding = "floor") {
1781
+ const roundFunction = rounding === "round" ? Math.round : rounding === "ceil" ? Math.ceil : Math.floor;
1782
+ return this.mapDamage((damageValue) => roundFunction(damageValue * factor));
1783
+ }
1784
+ getPMFCombineCacheKey(p1, p2, eps, raw) {
1785
+ const [id1, id2] = [p1.identifier, p2.identifier].sort();
1786
+ const fp = (x) => {
1787
+ const m = x.mass().toFixed(12);
1788
+ const n = x.map.size;
1789
+ let faceSum = 0;
1790
+ for (const k of x.map.keys()) faceSum += k;
1791
+ return `${m}|${n}|${faceSum}`;
1792
+ };
1793
+ return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${fp(p1)}|${fp(p2)}`;
1794
+ }
1795
+ convolve(other, eps, raw = false) {
1796
+ const epsilon = eps ?? this.epsilon;
1797
+ const norm = (x) => raw ? x : Math.abs(x.mass() - 1) <= epsilon ? x : x.normalize();
1798
+ const A0 = norm(this);
1799
+ const B0 = norm(other);
1800
+ const [A, B] = A0.identifier <= B0.identifier ? [A0, B0] : [B0, A0];
1801
+ const cacheKey = this.getPMFCombineCacheKey(A, B, epsilon, raw);
1802
+ const cached = pmfCache?.get(cacheKey);
1803
+ if (cached) return cached;
1804
+ const combinedMap = /* @__PURE__ */ new Map();
1805
+ for (const [aVal, aBin] of A.map) {
1806
+ for (const [bVal, bBin] of B.map) {
1807
+ const p = aBin.p * bBin.p;
1808
+ const dmg = aVal + bVal;
1809
+ const count = {};
1810
+ for (const k in aBin.count)
1811
+ count[k] = (count[k] || 0) + aBin.count[k] * bBin.p;
1812
+ for (const k in bBin.count)
1813
+ count[k] = (count[k] || 0) + bBin.count[k] * aBin.p;
1814
+ let attr;
1815
+ if (aBin.attr || bBin.attr) {
1816
+ attr = {};
1817
+ if (aBin.attr)
1818
+ for (const k in aBin.attr)
1819
+ attr[k] = (attr[k] || 0) + aBin.attr[k] * bBin.p;
1820
+ if (bBin.attr)
1821
+ for (const k in bBin.attr)
1822
+ attr[k] = (attr[k] || 0) + bBin.attr[k] * aBin.p;
1823
+ }
1824
+ _PMF.mergeInto(combinedMap, dmg, { p, count, attr });
1825
+ }
1826
+ }
1827
+ let result = new _PMF(
1828
+ combinedMap,
1829
+ epsilon,
1830
+ !raw,
1831
+ `${A.identifier}${raw ? "*" : "+"}${B.identifier}`
1832
+ );
1833
+ const mExp = (raw ? A.mass() : 1) * (raw ? B.mass() : 1);
1834
+ const mGot = result.mass();
1835
+ if (mExp !== 0 && Math.abs(mGot - mExp) > epsilon) {
1836
+ result = result.scaleMass(mExp / mGot);
1837
+ }
1838
+ if (!raw && Math.abs(result.mass() - 1) > epsilon)
1839
+ result = result.normalize();
1840
+ pmfCache?.set(cacheKey, result);
1841
+ return result;
1842
+ }
1843
+ // 3) Nice wrapper so you can call pmf.combineRaw(other)
1844
+ combineRaw(other, eps) {
1845
+ return this.convolve(other, eps, true);
1846
+ }
1847
+ // Collapse repeated identical PMFs using power() and return a sorted list
1848
+ // Temporarily disabled for now. This was used for a performance optimization, but can lose data provenance.
1849
+ // private static collapseIdentical(pmfList: PMF[], eps: number): PMF[] {
1850
+ // const grouped = new Map<string, { pmf: PMF; count: number }>();
1851
+ // for (const pmf of pmfList) {
1852
+ // const id = pmf.identifier;
1853
+ // const g = grouped.get(id);
1854
+ // if (g) g.count++;
1855
+ // else grouped.set(id, { pmf, count: 1 });
1856
+ // }
1857
+ // if (grouped.size >= pmfList.length) return pmfList;
1858
+ // const collapsed: PMF[] = [];
1859
+ // for (const { pmf, count } of grouped.values()) {
1860
+ // collapsed.push(count > 1 ? pmf.power(count, eps) : pmf);
1861
+ // }
1862
+ // // Stable order for better cache locality
1863
+ // collapsed.sort((a, b) =>
1864
+ // a.identifier < b.identifier ? -1 : a.identifier > b.identifier ? 1 : 0
1865
+ // );
1866
+ // return collapsed;
1867
+ // }
1868
+ // Reduce a list of PMFs by left-folding convolve() with the given eps
1869
+ static reduceConvolveLeft(pmfList, eps) {
1870
+ let result = pmfList[0];
1871
+ for (let i = 1; i < pmfList.length; i++) {
1872
+ result = result.convolve(pmfList[i], eps);
1873
+ }
1874
+ return result;
1875
+ }
1876
+ /**
1877
+ * Convolves multiple PMFs using linear convolution with automatic caching.
1878
+ * Uses a left-to-right accumulation approach for maximum cache reuse.
1879
+ * Each convolve() call automatically uses the convolution cache for performance.
1880
+ *
1881
+ * This linear approach provides better cache hits than pairwise because:
1882
+ * - Intermediate results are more predictable and stable
1883
+ * - Similar PMF lists share common prefixes (A+B, (A+B)+C, etc.)
1884
+ * - Order-independent cache keys work better with consistent build patterns
1885
+ */
1886
+ static convolveMany(pmfList, eps = EPS) {
1887
+ if (pmfList.length === 0) return _PMF.empty(eps);
1888
+ if (pmfList.length === 1) return pmfList[0];
1889
+ return _PMF.reduceConvolveLeft(pmfList, eps);
1890
+ }
1891
+ toJSON() {
1892
+ return JSON.stringify({
1893
+ bins: [...this.map.entries()],
1894
+ normalized: this.normalized,
1895
+ identifier: this.identifier
1896
+ });
1897
+ }
1898
+ static fromJSON(jsonData) {
1899
+ return new _PMF(
1900
+ new Map(jsonData.bins),
1901
+ EPS,
1902
+ !!jsonData.normalized,
1903
+ jsonData.identifier || "fromJSON"
1904
+ );
1905
+ }
1906
+ /**
1907
+ * Relative pruning with optional top-K floor.
1908
+ * Keeps bins with p >= epsRel * peak, always keeps min and max damage,
1909
+ * optionally guarantees at least `minBins` survivors by adding top-K.
1910
+ * Returns a new, non-normalized PMF.
1911
+ */
1912
+ prune(epsRel, minBins = 0) {
1913
+ const size = this.map.size;
1914
+ if (size === 0) return this;
1915
+ let peak = 0;
1916
+ let minDamage = Number.POSITIVE_INFINITY;
1917
+ let maxDamage = Number.NEGATIVE_INFINITY;
1918
+ for (const [dmg, bin] of this.map) {
1919
+ if (bin.p > peak) peak = bin.p;
1920
+ if (dmg < minDamage) minDamage = dmg;
1921
+ if (dmg > maxDamage) maxDamage = dmg;
1922
+ }
1923
+ if (peak === 0)
1924
+ return new _PMF(new Map(this.map), epsRel, false, this.identifier);
1925
+ const thresh = epsRel * peak;
1926
+ const entries = [...this.map.entries()];
1927
+ const survivorsByDmg = /* @__PURE__ */ new Map();
1928
+ const protect = (d2) => {
1929
+ const b = this.map.get(d2);
1930
+ if (b) survivorsByDmg.set(d2, b);
1931
+ };
1932
+ protect(minDamage);
1933
+ if (maxDamage !== minDamage) protect(maxDamage);
1934
+ for (const [dmg, bin] of entries) {
1935
+ if (bin.p >= thresh) survivorsByDmg.set(dmg, bin);
1936
+ }
1937
+ if (minBins > 0 && survivorsByDmg.size < minBins) {
1938
+ entries.sort((a, b) => b[1].p - a[1].p);
1939
+ for (const [dmg, bin] of entries) {
1940
+ if (!survivorsByDmg.has(dmg)) {
1941
+ survivorsByDmg.set(dmg, bin);
1942
+ if (survivorsByDmg.size >= minBins) break;
1943
+ }
1944
+ }
1945
+ }
1946
+ const prunedMap = /* @__PURE__ */ new Map();
1947
+ for (const [dmg, bin] of survivorsByDmg) {
1948
+ const newCount = {};
1949
+ for (const k in bin.count) {
1950
+ const v = bin.count[k];
1951
+ if (Math.abs(v) >= thresh) newCount[k] = v;
1952
+ }
1953
+ let newAttr;
1954
+ if (bin.attr) {
1955
+ for (const k in bin.attr) {
1956
+ const v = bin.attr[k];
1957
+ if (Math.abs(v) >= thresh) {
1958
+ if (!newAttr) newAttr = {};
1959
+ newAttr[k] = v;
1960
+ }
1961
+ }
1962
+ }
1963
+ prunedMap.set(dmg, { p: bin.p, count: newCount, attr: newAttr });
1964
+ }
1965
+ return new _PMF(prunedMap, epsRel, false, `prune(${this.identifier})`);
1966
+ }
1967
+ /** NEW - REVIEW IF THESE ARE USEFUL OR DUPLCIATIVE? */
1968
+ /** Probability mass at exactly x. */
1969
+ pAt(x) {
1970
+ return this.map.get(x)?.p ?? 0;
1971
+ }
1972
+ /** Dense integer support from min..max (inclusive).
1973
+ * Useful for showing empty bars in charts.
1974
+ */
1975
+ denseSupport() {
1976
+ const s = this.support();
1977
+ if (s.length === 0) return [];
1978
+ const lo = Math.min(...s), hi = Math.max(...s);
1979
+ return Array.from({ length: hi - lo + 1 }, (_, i) => lo + i).sort(
1980
+ (a, b) => a - b
1981
+ );
1982
+ }
1983
+ /** CDF at x: P(X ≤ x). */
1984
+ cdfAt(x) {
1985
+ let acc = 0;
1986
+ for (const [val, bin] of this.map) if (val <= x) acc += bin.p;
1987
+ return acc;
1988
+ }
1989
+ /** Quantile / inverse CDF for p in [0,1]. Returns smallest x with CDF ≥ p. */
1990
+ quantile(p) {
1991
+ if (this.map.size === 0) return 0;
1992
+ const s = this.support().sort((a, b) => a - b);
1993
+ let acc = 0;
1994
+ for (const x of s) {
1995
+ acc += this.pAt(x);
1996
+ if (acc >= p) return x;
1997
+ }
1998
+ return s[s.length - 1];
1999
+ }
2000
+ /** Get outcome probability at specific damage value. */
2001
+ outcomeAt(damage, outcome) {
2002
+ return this.map.get(damage)?.count[outcome] ?? 0;
2003
+ }
2004
+ /** Get all outcome types present in this PMF. */
2005
+ outcomes() {
2006
+ const outcomeSet = /* @__PURE__ */ new Set();
2007
+ for (const [, bin] of this.map) {
2008
+ for (const outcome in bin.count) {
2009
+ if (bin.count[outcome] > 0) {
2010
+ outcomeSet.add(outcome);
2011
+ }
2012
+ }
2013
+ }
2014
+ return Array.from(outcomeSet).sort();
2015
+ }
2016
+ /** Get total probability of an outcome across all damage values. */
2017
+ outcomeProbability(outcome) {
2018
+ let total = 0;
2019
+ for (const [, bin] of this.map) {
2020
+ total += bin.count[outcome] ?? 0;
2021
+ }
2022
+ return total;
2023
+ }
2024
+ /** Get damage attribution for an outcome at specific damage value. */
2025
+ outcomeAttributionAt(damage, outcome) {
2026
+ return this.map.get(damage)?.attr?.[outcome] ?? 0;
2027
+ }
2028
+ /** Get all outcome data at specific damage value. */
2029
+ binAt(damage) {
2030
+ const bin = this.map.get(damage);
2031
+ if (!bin) return null;
2032
+ return {
2033
+ p: bin.p,
2034
+ count: { ...bin.count },
2035
+ attr: bin.attr ? { ...bin.attr } : void 0
2036
+ };
2037
+ }
2038
+ /** Check if outcome exists in this PMF. */
2039
+ hasOutcome(outcome) {
2040
+ for (const [, bin] of this.map) {
2041
+ if ((bin.count[outcome] ?? 0) > 0) {
2042
+ return true;
2043
+ }
2044
+ }
2045
+ return false;
2046
+ }
2047
+ tailProbGE(t) {
2048
+ let s = 0;
2049
+ for (const [x, rec] of this) {
2050
+ const p = typeof rec === "number" ? rec : rec.p;
2051
+ if (p > 0 && x >= t) s += p;
2052
+ }
2053
+ return s;
2054
+ }
2055
+ tailProbGT(t) {
2056
+ let s = 0;
2057
+ for (const [x, rec] of this) {
2058
+ if (x > t) s += rec.p;
2059
+ }
2060
+ return s;
2061
+ }
2062
+ /**
2063
+ * Returns a new PMF containing only bins where the specified outcome has non-zero probability.
2064
+ * This creates a marginal distribution for the given outcome type, with probabilities
2065
+ * scaled to represent the unconditional mass attributable to that outcome.
2066
+ */
2067
+ filterOutcome(outcome) {
2068
+ const filteredMap = /* @__PURE__ */ new Map();
2069
+ for (const [damageValue, bin] of this.map) {
2070
+ const outcomeCount = bin.count[outcome] ?? 0;
2071
+ const totalCount = Object.values(bin.count ?? {}).reduce(
2072
+ (a, b) => (a ?? 0) + (b ?? 0),
2073
+ 0
2074
+ );
2075
+ if (outcomeCount > 0 && totalCount !== void 0 && totalCount > 0) {
2076
+ const proportion = outcomeCount / totalCount;
2077
+ const newP = bin.p * proportion;
2078
+ const newCount = { [outcome]: outcomeCount };
2079
+ let newAttr;
2080
+ if (bin.attr && bin.attr[outcome] !== void 0) {
2081
+ newAttr = { [outcome]: bin.attr[outcome] * proportion };
2082
+ }
2083
+ filteredMap.set(damageValue, {
2084
+ p: newP,
2085
+ count: newCount,
2086
+ attr: newAttr
2087
+ });
2088
+ }
2089
+ }
2090
+ return new _PMF(
2091
+ filteredMap,
2092
+ this.epsilon,
2093
+ false,
2094
+ // don't normalize by default
2095
+ `filter(${this.identifier},${outcome})`
2096
+ );
2097
+ }
2098
+ /**
2099
+ * Calculates probabilities for first-success outcomes across n independent attempts.
2100
+ *
2101
+ * @param pSuccess - Total probability of any success on a single attempt.
2102
+ * @param pSpecial - Probability of a specific subset of successes (e.g., critical success).
2103
+ * @param n - Number of independent attempts.
2104
+ *
2105
+ * Returns:
2106
+ * - pSpecificSuccess: Probability that the first success was of the "special" type
2107
+ * - pGeneralSuccess: Probability that the first success was of the non-special type
2108
+ * - pNone: Probability that no successes occurred
2109
+ * - pAny: Probability that at least one success occurred
2110
+ */
2111
+ static firstSuccessWeights(pSuccess, pSpecial, n) {
2112
+ const pFail = 1 - pSuccess;
2113
+ const pFailAll = Math.pow(pFail, n);
2114
+ const pAny = 1 - pFailAll;
2115
+ const denom = pSuccess === 0 ? 1 : pSuccess;
2116
+ const pSpecificSuccess = pSpecial * pAny / denom;
2117
+ const pGeneralSuccess = (pSuccess - pSpecial) * pAny / denom;
2118
+ const pNone = 1 - pSpecificSuccess - pGeneralSuccess;
2119
+ return { pSpecificSuccess, pGeneralSuccess, pNone, pAny };
2120
+ }
2121
+ mapValues(f, eps = EPS, opts) {
2122
+ const rounding = opts?.rounding ?? "none";
2123
+ const preserveCounts = opts?.preserveCounts ?? true;
2124
+ const round = (x) => rounding === "floor" ? Math.floor(x) : rounding === "ceil" ? Math.ceil(x) : rounding === "round" ? Math.round(x) : x;
2125
+ const probs = /* @__PURE__ */ new Map();
2126
+ const counts = /* @__PURE__ */ new Map();
2127
+ for (const [v, rec] of this) {
2128
+ if (Math.abs(rec.p) < eps) continue;
2129
+ const u = round(f(v));
2130
+ probs.set(u, (probs.get(u) ?? 0) + rec.p);
2131
+ if (preserveCounts) {
2132
+ const rec2 = this.map.get(v);
2133
+ const src = typeof rec2 === "number" ? void 0 : rec2?.count;
2134
+ if (src) {
2135
+ const dest = counts.get(u) ?? {};
2136
+ for (const k in src) {
2137
+ dest[k] = (dest[k] ?? 0) + src[k];
2138
+ }
2139
+ counts.set(u, dest);
2140
+ }
2141
+ }
2142
+ }
2143
+ const internal = /* @__PURE__ */ new Map();
2144
+ for (const [u, p] of probs) {
2145
+ internal.set(u, { p, count: counts.get(u) ?? {} });
2146
+ }
2147
+ return _PMF.fromMap(
2148
+ new Map(Array.from(internal, ([u, b]) => [u, b.p])),
2149
+ eps
2150
+ );
2151
+ }
2152
+ static fromMap(m, eps = EPS, { requireIntegerValues = true } = {}) {
2153
+ const filtered = [];
2154
+ for (const [v, p] of m) {
2155
+ if (!Number.isFinite(v) || !Number.isFinite(p)) continue;
2156
+ if (p <= 0 || Math.abs(p) < eps) continue;
2157
+ if (requireIntegerValues && !Number.isInteger(v)) {
2158
+ throw new Error(`fromMap: non-integer outcome ${v}`);
2159
+ }
2160
+ filtered.push([v, p]);
2161
+ }
2162
+ if (filtered.length === 0) {
2163
+ throw new Error("fromMap: empty or invalid input map");
2164
+ }
2165
+ let sum = 0;
2166
+ let c = 0;
2167
+ for (const [, p] of filtered) {
2168
+ const y = p - c;
2169
+ const t = sum + y;
2170
+ c = t - sum - y;
2171
+ sum = t;
2172
+ }
2173
+ if (sum <= 0) throw new Error("pmfFromMap: probabilities sum to 0");
2174
+ filtered.sort((a, b) => a[0] - b[0]);
2175
+ const internal = /* @__PURE__ */ new Map();
2176
+ for (const [v, p] of filtered) {
2177
+ internal.set(v, { p: p / sum, count: {} });
2178
+ }
2179
+ return new _PMF(internal, eps);
2180
+ }
2181
+ query() {
2182
+ return new DiceQuery(this);
2183
+ }
2184
+ };
2185
+ // Unique ID generator for anonymous PMFs to avoid cache key collisions
2186
+ __publicField(_PMF, "__anonIdCounter", 1);
2187
+ var PMF = _PMF;
2188
+
2189
+ // src/pmf/mixture.ts
2190
+ var Mixture = class _Mixture {
2191
+ constructor(eps = EPS) {
2192
+ __publicField(this, "totals", /* @__PURE__ */ new Map());
2193
+ // raw mass per outcome (pre-normalization)
2194
+ __publicField(this, "labelMass", /* @__PURE__ */ new Map());
2195
+ // raw mass per outcome per label
2196
+ __publicField(this, "eps");
2197
+ this.eps = Number.isFinite(eps) ? eps : EPS;
2198
+ }
2199
+ /** Remove all accumulated state. */
2200
+ clear() {
2201
+ this.totals.clear();
2202
+ this.labelMass.clear();
2203
+ return this;
2204
+ }
2205
+ /** Number of distinct outcome values currently accumulated. */
2206
+ size() {
2207
+ return this.totals.size;
2208
+ }
2209
+ /** Whether a label was ever added. */
2210
+ hasLabel(label) {
2211
+ for (const bag of this.labelMass.values()) if (bag[label]) return true;
2212
+ return false;
2213
+ }
2214
+ /**
2215
+ * Add a labeled component with a mixture weight.
2216
+ * Weight can be any positive finite number. Very small contributions are pruned by eps.
2217
+ */
2218
+ add(label, pmf, weight = 1) {
2219
+ if (!Number.isFinite(weight) || weight <= 0) return this;
2220
+ for (const [v, binOrNumber] of pmf) {
2221
+ const isNumber = typeof binOrNumber === "number";
2222
+ const p = isNumber ? binOrNumber : binOrNumber?.p ?? 0;
2223
+ if (p <= 0) continue;
2224
+ const add = weight * p;
2225
+ if (!Number.isFinite(add) || Math.abs(add) < this.eps) continue;
2226
+ this.totals.set(v, (this.totals.get(v) ?? 0) + add);
2227
+ const bag = this.labelMass.get(v) ?? {};
2228
+ bag[label] = (bag[label] ?? 0) + add;
2229
+ this.labelMass.set(v, bag);
2230
+ }
2231
+ return this;
2232
+ }
2233
+ buildPMF(eps = EPS) {
2234
+ let grand = 0;
2235
+ let c = 0;
2236
+ for (const m of this.totals.values()) {
2237
+ const y = m - c;
2238
+ const t = grand + y;
2239
+ c = t - grand - y;
2240
+ grand = t;
2241
+ }
2242
+ if (!(grand > 0)) throw new Error("Mixture: zero total mass");
2243
+ const internal = /* @__PURE__ */ new Map();
2244
+ for (const [v, m] of this.totals) {
2245
+ if (m <= 0 || Math.abs(m) < this.eps) continue;
2246
+ const count = this.labelMass.get(v) ?? {};
2247
+ internal.set(v, { p: m / grand, count });
2248
+ }
2249
+ return new PMF(internal, eps);
2250
+ }
2251
+ /**
2252
+ * Produce normalized *per-label* PMFs (labels independent).
2253
+ * These are unlabeled PMFs built from the raw mass of that label alone.
2254
+ */
2255
+ byOutcome() {
2256
+ const labels = /* @__PURE__ */ new Set();
2257
+ for (const bag of this.labelMass.values()) {
2258
+ for (const k of Object.keys(bag)) labels.add(k);
2259
+ }
2260
+ const out = {};
2261
+ for (const label of labels) {
2262
+ const m = /* @__PURE__ */ new Map();
2263
+ for (const [v, bag] of this.labelMass) {
2264
+ const w = bag[label];
2265
+ if (w && Math.abs(w) >= this.eps) m.set(v, w);
2266
+ }
2267
+ if (m.size > 0) out[label] = PMF.fromMap(m, this.eps);
2268
+ }
2269
+ return out;
2270
+ }
2271
+ /**
2272
+ * Mixture weights per label, normalized to sum to 1 over labels that appeared.
2273
+ * Uses raw mass before per-outcome normalization.
2274
+ */
2275
+ weights() {
2276
+ const res = {};
2277
+ for (const [, bag] of this.labelMass) {
2278
+ for (const [lab, w] of Object.entries(bag)) {
2279
+ if (!Number.isFinite(w) || w <= 0) continue;
2280
+ res[lab] = (res[lab] ?? 0) + w;
2281
+ }
2282
+ }
2283
+ let total = 0;
2284
+ let c = 0;
2285
+ for (const v of Object.values(res)) {
2286
+ const y = v - c;
2287
+ const t = total + y;
2288
+ c = t - total - y;
2289
+ total = t;
2290
+ }
2291
+ if (total > 0) {
2292
+ for (const k in res) res[k] = res[k] / total;
2293
+ }
2294
+ return res;
2295
+ }
2296
+ toJSON() {
2297
+ return {
2298
+ totals: Array.from(this.totals.entries()).sort((a, b) => a[0] - b[0]),
2299
+ labels: Array.from(this.labelMass.entries()).sort((a, b) => a[0] - b[0]),
2300
+ eps: this.eps
2301
+ };
2302
+ }
2303
+ static mix(items, eps = EPS) {
2304
+ const mix = new _Mixture(eps);
2305
+ for (const [lab, pmf, w] of items) mix.add(lab, pmf, w);
2306
+ return mix.buildPMF();
2307
+ }
2308
+ };
2309
+
2310
+ // src/builder/prob.ts
2311
+ function d20PmfFromCdf(cdfPow, eps = EPS) {
2312
+ const out = /* @__PURE__ */ new Map();
2313
+ let prev = 0;
2314
+ for (let k = 1; k <= 20; k++) {
2315
+ const cur = cdfPow(k);
2316
+ const pk = cur - prev;
2317
+ if (pk > 0) {
2318
+ out.set(k, pk);
2319
+ }
2320
+ prev = cur;
2321
+ }
2322
+ return PMF.fromMap(out, eps);
2323
+ }
2324
+
2325
+ // src/builder/d20.ts
2326
+ var cacheKeyMap = {
2327
+ "flat-flat": "d20",
2328
+ "flat-reroll": "hd20",
2329
+ "advantage-flat": "d20 > d20",
2330
+ "advantage-reroll": "hd20 > hd20",
2331
+ "disadvantage-flat": "d20 < d20",
2332
+ "disadvantage-reroll": "hd20 < hd20",
2333
+ "elven accuracy-flat": "d20 > d20 > d20",
2334
+ "elven accuracy-reroll": "hd20 > hd20 > hd20"
2335
+ };
2336
+ function d20RollPMF(rollType, rerollOne = false) {
2337
+ rollType = rollType || "flat";
2338
+ const cacheKeyLookup = `${rollType}-${rerollOne ? "reroll" : "flat"}`;
2339
+ const cacheKey = cacheKeyMap[cacheKeyLookup];
2340
+ if (!cacheKey) {
2341
+ throw new Error(`Invalid roll type: ${rollType}`);
2342
+ }
2343
+ const cached = pmfCache.get(cacheKey);
2344
+ if (cached) return cached;
2345
+ const base = d20PMF(rerollOne);
2346
+ if (!rollType || rollType === "flat") {
2347
+ pmfCache.set(cacheKey, base);
2348
+ return base;
2349
+ }
2350
+ const p = new Array(21).fill(0);
2351
+ for (const [r, rec] of base) {
2352
+ const pr = typeof rec === "number" ? rec : rec.p;
2353
+ if (r >= 1 && r <= 20) p[r] = pr;
2354
+ }
2355
+ const F = new Array(21).fill(0);
2356
+ for (let k = 1; k <= 20; k++) F[k] = F[k - 1] + p[k];
2357
+ const eps = 0;
2358
+ let result = base;
2359
+ if (rollType === "advantage") {
2360
+ result = d20PmfFromCdf((k) => Math.pow(F[k], 2), eps);
2361
+ } else if (rollType === "elven accuracy") {
2362
+ result = d20PmfFromCdf((k) => Math.pow(F[k], 3), eps);
2363
+ } else if (rollType === "disadvantage") {
2364
+ result = d20PmfFromCdf((k) => 1 - Math.pow(1 - F[k], 2), eps);
2365
+ }
2366
+ pmfCache.set(cacheKey, result);
2367
+ return result;
2368
+ }
2369
+ function d20PMF(rerollOne) {
2370
+ const cacheKey = `flat-${rerollOne ? "reroll" : "flat"}`;
2371
+ const cached = pmfCache.get(cacheKey);
2372
+ if (cached) return cached;
2373
+ const m = /* @__PURE__ */ new Map();
2374
+ const base = 1 / 20;
2375
+ const rerollShare = base * base;
2376
+ if (!rerollOne) {
2377
+ for (let r = 1; r <= 20; r++) {
2378
+ m.set(r, base);
2379
+ }
2380
+ } else {
2381
+ for (let r = 1; r <= 20; r++) {
2382
+ m.set(r, (r === 1 ? 0 : base) + rerollShare);
2383
+ }
2384
+ }
2385
+ const result = PMF.fromMap(m, EPS);
2386
+ pmfCache.set(cacheKey, result);
2387
+ return result;
2388
+ }
2389
+
2390
+ // src/builder/ast.ts
2391
+ var defaultEps = 0;
2392
+ var singleDiePMFCache = new LRUCache(1e3);
2393
+ function astFromRollConfigs(configs) {
2394
+ if (!configs || configs.length === 0) return void 0;
2395
+ const children = [];
2396
+ let constantSum = 0;
2397
+ for (const cfg of configs) {
2398
+ const sign = cfg.isSubtraction || cfg.count < 0 ? -1 : 1;
2399
+ const count = Math.abs(cfg.count || 0);
2400
+ constantSum += cfg.modifier || 0;
2401
+ if ((cfg.sides || 0) <= 0) continue;
2402
+ const die = {
2403
+ type: "die",
2404
+ sides: cfg.sides,
2405
+ reroll: cfg.reroll > 0 ? cfg.reroll : void 0,
2406
+ minimum: cfg.minimum > 0 ? cfg.minimum : void 0,
2407
+ explode: cfg.explode && Number.isFinite(cfg.explode) && cfg.explode > 0 ? cfg.explode : void 0
2408
+ };
2409
+ let node = die;
2410
+ let appliedRollType = false;
2411
+ if (cfg.rollType && cfg.rollType !== "flat") {
2412
+ if (cfg.sides === 20) {
2413
+ node = {
2414
+ type: "d20Roll",
2415
+ rollType: cfg.rollType,
2416
+ child: node
2417
+ };
2418
+ } else {
2419
+ const n = cfg.rollType === "elven accuracy" ? 3 : 2;
2420
+ const mode = cfg.rollType === "disadvantage" ? "lowest" : "highest";
2421
+ const base = { type: "sum", count: n, child: node };
2422
+ node = { type: "keep", mode, count: 1, child: base };
2423
+ }
2424
+ appliedRollType = true;
2425
+ }
2426
+ if (cfg.rollType === "flat" && cfg.keep && cfg.keep.total > 0) {
2427
+ const base = { type: "sum", count: cfg.keep.total, child: node };
2428
+ node = {
2429
+ type: "keep",
2430
+ mode: cfg.keep.mode,
2431
+ count: cfg.keep.count,
2432
+ child: base
2433
+ };
2434
+ } else {
2435
+ const c = appliedRollType ? 1 : Math.max(1, count || 1);
2436
+ node = { type: "sum", count: c, child: node };
2437
+ }
2438
+ children.push({ node, sign });
2439
+ }
2440
+ if (children.length === 0) {
2441
+ return { type: "constant", value: constantSum };
2442
+ }
2443
+ const add = { type: "add", children };
2444
+ if (constantSum !== 0)
2445
+ add.children.push({
2446
+ node: { type: "constant", value: constantSum },
2447
+ sign: 1
2448
+ });
2449
+ return add;
2450
+ }
2451
+ function resolve(node, eps = defaultEps) {
2452
+ const signature = getASTSignature(node);
2453
+ const cacheKey = `${signature}_${eps}`;
2454
+ const cached = builderPMFCache.get(cacheKey);
2455
+ if (cached) return cached;
2456
+ const result = (() => {
2457
+ switch (node.type) {
2458
+ case "constant":
2459
+ return PMF.delta(node.value, eps);
2460
+ case "die": {
2461
+ return resolveSingleDie(node, eps);
2462
+ }
2463
+ case "sum": {
2464
+ const base = resolve(node.child, eps);
2465
+ const n = Math.max(0, Math.floor(node.count));
2466
+ if (n === 0) return PMF.delta(0, eps);
2467
+ if (n === 1) return base;
2468
+ return base.power(n, eps);
2469
+ }
2470
+ case "add": {
2471
+ let shift = 0;
2472
+ const parts = [];
2473
+ for (const c of node.children) {
2474
+ if (c.node.type === "constant") {
2475
+ shift += c.sign * c.node.value;
2476
+ } else {
2477
+ const p = resolve(c.node, eps);
2478
+ parts.push(c.sign === 1 ? p : p.mapDamage((v) => -v));
2479
+ }
2480
+ }
2481
+ if (parts.length === 0) return PMF.delta(shift, eps);
2482
+ let res = parts.length === 1 ? parts[0] : PMF.convolveMany(parts, eps);
2483
+ if (shift !== 0) res = res.mapDamage((v) => v + shift);
2484
+ return res;
2485
+ }
2486
+ case "keep": {
2487
+ const total = getTotalCount(node);
2488
+ const die = findDie(node.child.child);
2489
+ const perDie = die ? resolveSingleDie(die, eps) : PMF.delta(0, eps);
2490
+ const keepCount = Math.max(0, Math.min(node.count, total));
2491
+ if (keepCount === 0 || total === 0) return PMF.delta(0, eps);
2492
+ return keepSumPMF(
2493
+ perDie,
2494
+ total,
2495
+ keepCount,
2496
+ node.mode === "highest",
2497
+ eps
2498
+ );
2499
+ }
2500
+ case "d20Roll": {
2501
+ const childDie = findDie(node.child);
2502
+ const rerollOne = !!childDie && (childDie.reroll || 0) >= 1;
2503
+ return d20RollPMF(node.rollType, rerollOne);
2504
+ }
2505
+ }
2506
+ })();
2507
+ builderPMFCache.set(cacheKey, result);
2508
+ return result;
2509
+ }
2510
+ function pmfFromRollBuilder(rb, eps = defaultEps) {
2511
+ const configs = rb.getSubRollConfigs?.() ?? [];
2512
+ const ast = astFromRollConfigs(configs) || { type: "constant", value: 0 };
2513
+ return resolve(ast, eps);
2514
+ }
2515
+ function resolveSingleDie(die, eps = defaultEps) {
2516
+ const signature = getASTSignature(die);
2517
+ const cacheKey = `${signature}_${eps}`;
2518
+ const cached = singleDiePMFCache.get(cacheKey);
2519
+ if (cached) return cached;
2520
+ const s = Math.max(0, Math.floor(die.sides));
2521
+ if (s <= 0) return PMF.delta(0, eps);
2522
+ let probs = /* @__PURE__ */ new Map();
2523
+ for (let v = 1; v <= s; v++) probs.set(v, 1 / s);
2524
+ const r = Math.max(0, Math.floor(die.reroll || 0));
2525
+ if (r > 0) {
2526
+ const k = Math.min(r, s);
2527
+ const rerollMass = k / s;
2528
+ const uniformReroll = rerollMass / s;
2529
+ const next = /* @__PURE__ */ new Map();
2530
+ for (let v = 1; v <= s; v++) {
2531
+ const keep = v <= k ? 0 : 1 / s;
2532
+ next.set(v, keep + uniformReroll);
2533
+ }
2534
+ probs = next;
2535
+ }
2536
+ let pmf = PMF.fromMap(new Map(probs), eps);
2537
+ const minV = Math.max(0, Math.floor(die.minimum || 0));
2538
+ if (minV > 0) pmf = pmf.mapDamage((v) => Math.max(v, minV));
2539
+ const explode = die.explode;
2540
+ if (explode && Number.isFinite(explode) && explode > 0) {
2541
+ const times = Math.floor(explode);
2542
+ const maxFace = s;
2543
+ const nonMax = /* @__PURE__ */ new Map();
2544
+ const pMax = pmf.pAt(maxFace);
2545
+ for (const v of pmf.support()) {
2546
+ if (v !== maxFace) nonMax.set(v, pmf.pAt(v));
2547
+ }
2548
+ let nonMaxPMF = PMF.fromMap(nonMax, eps);
2549
+ if (Math.abs(nonMaxPMF.mass() - (1 - pMax)) > eps) {
2550
+ nonMaxPMF = nonMaxPMF.scaleMass(1 - pMax);
2551
+ }
2552
+ let tail = PMF.delta(0, eps);
2553
+ const addOnce = pmf;
2554
+ for (let t = 1; t <= times; t++) {
2555
+ tail = tail.convolve(addOnce, eps);
2556
+ }
2557
+ const exploded = PMF.branch(
2558
+ tail.mapDamage((v) => v + maxFace),
2559
+ nonMaxPMF,
2560
+ pMax
2561
+ );
2562
+ pmf = exploded;
2563
+ }
2564
+ singleDiePMFCache.set(cacheKey, pmf);
2565
+ return pmf;
2566
+ }
2567
+ function findDie(node) {
2568
+ switch (node.type) {
2569
+ case "die":
2570
+ return node;
2571
+ case "constant":
2572
+ return void 0;
2573
+ case "sum":
2574
+ case "d20Roll":
2575
+ return findDie(node.child);
2576
+ case "keep":
2577
+ return findDie(node.child.child);
2578
+ case "add":
2579
+ for (const c of node.children) {
2580
+ const d2 = findDie(c.node);
2581
+ if (d2) return d2;
2582
+ }
2583
+ return void 0;
2584
+ }
2585
+ }
2586
+ function getTotalCount(node) {
2587
+ let cur = node.child;
2588
+ while (cur.type === "keep") cur = cur.child;
2589
+ return cur.type === "sum" ? Math.max(0, Math.floor(cur.count)) : 0;
2590
+ }
2591
+ function keepSumPMF(single, total, keep, highest, eps = defaultEps) {
2592
+ if (keep >= total) return single.power(total, eps);
2593
+ if (keep <= 0) return PMF.delta(0, eps);
2594
+ const sortedSupport = [...single.support()].sort((a, b) => a - b);
2595
+ const pmfSig = sortedSupport.map((val) => `${val}:${single.pAt(val).toPrecision(6)}`).join(",");
2596
+ const cacheKey = `keep|${pmfSig}|t:${total}|k:${keep}|h:${highest ? 1 : 0}|e:${eps}`;
2597
+ const cached = builderPMFCache.get(cacheKey);
2598
+ if (cached) return cached;
2599
+ const support = single.support();
2600
+ const probs = support.map((v) => ({ v, p: single.pAt(v) }));
2601
+ const out = /* @__PURE__ */ new Map();
2602
+ function dfs(i, accValues, weight) {
2603
+ if (i === total) {
2604
+ const sorted = [...accValues].sort((a, b) => highest ? b - a : a - b);
2605
+ let sum = 0;
2606
+ for (let j = 0; j < keep; j++) sum += sorted[j];
2607
+ out.set(sum, (out.get(sum) || 0) + weight);
2608
+ return;
2609
+ }
2610
+ for (const { v, p } of probs) {
2611
+ if (p <= 0) continue;
2612
+ dfs(i + 1, [...accValues, v], weight * p);
2613
+ }
2614
+ }
2615
+ dfs(0, [], 1);
2616
+ const result = PMF.fromMap(out, eps);
2617
+ builderPMFCache.set(cacheKey, result);
2618
+ return result;
2619
+ }
2620
+ function getASTSignature(node) {
2621
+ switch (node.type) {
2622
+ case "constant":
2623
+ return `c:${node.value}`;
2624
+ case "die": {
2625
+ const parts = [];
2626
+ parts.push(`s:${node.sides}`);
2627
+ if (node.reroll) parts.push(`r:${node.reroll}`);
2628
+ if (node.minimum) parts.push(`m:${node.minimum}`);
2629
+ if (node.explode) parts.push(`e:${node.explode}`);
2630
+ return `d{${parts.join(",")}}`;
2631
+ }
2632
+ case "sum":
2633
+ return `sum{c:${node.count},ch:${getASTSignature(node.child)}}`;
2634
+ case "d20Roll":
2635
+ return `d20{t:${node.rollType},ch:${getASTSignature(node.child)}}`;
2636
+ case "keep":
2637
+ return `keep{c:${node.count},m:${node.mode},ch:${getASTSignature(
2638
+ node.child
2639
+ )}}`;
2640
+ case "add": {
2641
+ let constantValue = 0;
2642
+ const otherChildrenSigs = [];
2643
+ for (const c of node.children) {
2644
+ if (c.node.type === "constant") {
2645
+ constantValue += c.sign * c.node.value;
2646
+ } else {
2647
+ otherChildrenSigs.push(
2648
+ `${c.sign === -1 ? "-" : "+"}${getASTSignature(c.node)}`
2649
+ );
2650
+ }
2651
+ }
2652
+ if (constantValue !== 0) {
2653
+ otherChildrenSigs.push(
2654
+ constantValue > 0 ? `+c:${constantValue}` : `-c:${-constantValue}`
2655
+ );
2656
+ }
2657
+ otherChildrenSigs.sort();
2658
+ return `add[${otherChildrenSigs.join("")}]`;
2659
+ }
2660
+ }
2661
+ }
2662
+
2663
+ // src/builder/roll.ts
2664
+ var defaultConfig = {
2665
+ count: 1,
2666
+ sides: 0,
2667
+ modifier: 0,
2668
+ reroll: 0,
2669
+ explode: 0,
2670
+ minimum: 0,
2671
+ bestOf: 0,
2672
+ keep: void 0,
2673
+ rollType: "flat"
2674
+ };
2675
+ var RollBuilder = class _RollBuilder {
2676
+ constructor(countOrConfigs = 1) {
2677
+ __publicField(this, "subRollConfigs");
2678
+ // --- Dice Shortcut Methods ---
2679
+ __publicField(this, "d4", () => this.d(4));
2680
+ __publicField(this, "d6", () => this.d(6));
2681
+ __publicField(this, "d8", () => this.d(8));
2682
+ __publicField(this, "d10", () => this.d(10));
2683
+ __publicField(this, "d12", () => this.d(12));
2684
+ __publicField(this, "d20", () => this.d(20));
2685
+ __publicField(this, "d100", () => this.d(100));
2686
+ if (typeof countOrConfigs === "number") {
2687
+ const count = countOrConfigs;
2688
+ if (isNaN(count)) throw new Error("Invalid NaN value for count");
2689
+ this.subRollConfigs = [
2690
+ { ...defaultConfig, count, isSubtraction: count < 0 }
2691
+ ];
2692
+ } else {
2693
+ this.subRollConfigs = countOrConfigs.map((c) => ({ ...c }));
2694
+ }
2695
+ }
2696
+ get lastConfig() {
2697
+ return this.subRollConfigs[this.subRollConfigs.length - 1];
2698
+ }
2699
+ getSubRollConfigs() {
2700
+ return this.subRollConfigs.map((c) => ({ ...c }));
2701
+ }
2702
+ // for testing
2703
+ static fromConfig(config) {
2704
+ return new _RollBuilder([{ ...defaultConfig, ...config }]);
2705
+ }
2706
+ static fromConfigs(configs) {
2707
+ return new _RollBuilder(
2708
+ configs.map((config) => ({ ...defaultConfig, ...config }))
2709
+ );
2710
+ }
2711
+ static fromArgs(...args) {
2712
+ if (args.length === 1) {
2713
+ const arg = args[0];
2714
+ if (typeof arg === "number") {
2715
+ if (isNaN(arg)) throw new Error("Invalid NaN value for argument");
2716
+ return new _RollBuilder(0).plus(arg);
2717
+ }
2718
+ if (arg instanceof _RollBuilder) {
2719
+ return arg;
2720
+ }
2721
+ }
2722
+ if (args.length === 2 || args.length === 3) {
2723
+ const [count, sidesOrDie, modifier] = args;
2724
+ if (typeof count !== "number") {
2725
+ throw new Error("First argument must be a number for multi-arg call");
2726
+ }
2727
+ if (isNaN(count)) throw new Error("Invalid NaN value for count argument");
2728
+ if (sidesOrDie instanceof _RollBuilder) {
2729
+ const subRollConfigs = sidesOrDie.getSubRollConfigs();
2730
+ if (subRollConfigs.length === 0) {
2731
+ const result = new _RollBuilder(0);
2732
+ return modifier !== void 0 ? result.plus(modifier) : result;
2733
+ }
2734
+ const absCount = Math.abs(count);
2735
+ const newConfigs = subRollConfigs.map((config) => ({
2736
+ ...config,
2737
+ count: config.count * absCount,
2738
+ modifier: config.modifier * absCount
2739
+ }));
2740
+ let resultBuilder = new _RollBuilder(newConfigs);
2741
+ if (count < 0) {
2742
+ const negatedConfigs = resultBuilder.getSubRollConfigs().map((c) => ({ ...c, isSubtraction: !c.isSubtraction }));
2743
+ resultBuilder = new _RollBuilder(negatedConfigs);
2744
+ }
2745
+ return modifier !== void 0 ? resultBuilder.plus(modifier) : resultBuilder;
2746
+ } else if (typeof sidesOrDie === "number" || sidesOrDie === void 0) {
2747
+ if (typeof sidesOrDie === "number" && isNaN(sidesOrDie))
2748
+ throw new Error("Invalid NaN value for sides argument");
2749
+ let builder = new _RollBuilder(count);
2750
+ if (sidesOrDie && sidesOrDie > 0) {
2751
+ builder = builder.d(sidesOrDie);
2752
+ }
2753
+ return modifier !== void 0 ? builder.plus(modifier) : builder;
2754
+ }
2755
+ }
2756
+ throw new Error(`Invalid arguments passed: ${args.join(", ")}`);
2757
+ }
2758
+ // --- Core Dice Methods ---
2759
+ d(sides) {
2760
+ if (sides !== void 0 && isNaN(sides))
2761
+ throw new Error("Invalid NaN value for sides");
2762
+ if (sides === void 0) return this;
2763
+ if (this.lastConfig.sides && this.lastConfig.sides > 0) {
2764
+ throw new Error("Cannot add a die after adding a die");
2765
+ }
2766
+ if (sides === 0) return this;
2767
+ const newConfigs = this.getSubRollConfigs();
2768
+ newConfigs[newConfigs.length - 1].sides = sides;
2769
+ return new this.constructor(newConfigs);
2770
+ }
2771
+ plus(modOrRoll, die) {
2772
+ if (typeof modOrRoll === "number" && isNaN(modOrRoll))
2773
+ throw new Error("Invalid NaN value for modOrRoll");
2774
+ if (die instanceof _RollBuilder && typeof modOrRoll === "number") {
2775
+ const count = modOrRoll;
2776
+ const subRollConfigs = die.getSubRollConfigs();
2777
+ if (subRollConfigs.length === 0) return this;
2778
+ const absCount = Math.abs(count);
2779
+ const newConfigs = subRollConfigs.map((config) => ({
2780
+ ...config,
2781
+ count: config.count * absCount,
2782
+ modifier: config.modifier * absCount
2783
+ }));
2784
+ let rollToAdd = new _RollBuilder(newConfigs);
2785
+ if (count < 0) {
2786
+ const negatedConfigs = rollToAdd.getSubRollConfigs().map((c) => ({ ...c, isSubtraction: !c.isSubtraction }));
2787
+ rollToAdd = new _RollBuilder(negatedConfigs);
2788
+ }
2789
+ return this.add(rollToAdd);
2790
+ }
2791
+ if (die !== void 0) {
2792
+ throw new Error("Invalid arguments to plus()");
2793
+ }
2794
+ if (modOrRoll === void 0) return this;
2795
+ if (typeof modOrRoll === "number") {
2796
+ if (modOrRoll === 0) return this;
2797
+ const newConfigs = this.getSubRollConfigs();
2798
+ newConfigs[newConfigs.length - 1].modifier += modOrRoll;
2799
+ return new this.constructor(newConfigs);
2800
+ }
2801
+ return this.add(modOrRoll);
2802
+ }
2803
+ minus(modOrRoll, die) {
2804
+ const isNumber = typeof modOrRoll === "number";
2805
+ const dieIsRoll = die instanceof _RollBuilder;
2806
+ if (dieIsRoll && isNumber) return this.plus(-modOrRoll, die);
2807
+ if (die !== void 0) throw new Error("Invalid arguments to minus()");
2808
+ if (modOrRoll === void 0) return this;
2809
+ return isNumber ? this.plus(-modOrRoll) : this.plus(-1, modOrRoll);
2810
+ }
2811
+ /** Apply one-pass reroll threshold (k): reroll faces 1..k once, must keep. */
2812
+ reroll(value) {
2813
+ if (isNaN(value)) throw new Error("Invalid NaN value for reroll");
2814
+ const newConfigs = this.getSubRollConfigs();
2815
+ newConfigs[newConfigs.length - 1].reroll = value;
2816
+ return new this.constructor(newConfigs);
2817
+ }
2818
+ /** Set finite explode count for max-face explosions (Infinity allowed). */
2819
+ explode(count = Infinity) {
2820
+ if (count !== void 0 && isNaN(count))
2821
+ throw new Error("Invalid NaN value for explode count");
2822
+ if (count === void 0) return this;
2823
+ if (count === 0) return this;
2824
+ if (count < 0) throw new Error("Explode count must be >= 0");
2825
+ const newConfigs = this.getSubRollConfigs();
2826
+ newConfigs[newConfigs.length - 1].explode = count;
2827
+ return new this.constructor(newConfigs);
2828
+ }
2829
+ /** Apply per-die minimum value (min > 0). */
2830
+ minimum(val) {
2831
+ if (val !== void 0 && isNaN(val))
2832
+ throw new Error("Invalid NaN value for minimum");
2833
+ if (val === void 0) return this;
2834
+ if (val === 0) return this;
2835
+ if (val < 0) throw new Error("Minimum value must be >= 0");
2836
+ const newConfigs = this.getSubRollConfigs();
2837
+ newConfigs[newConfigs.length - 1].minimum = val + 1;
2838
+ return new this.constructor(newConfigs);
2839
+ }
2840
+ bestOf(count) {
2841
+ if (count !== void 0 && isNaN(count))
2842
+ throw new Error("Invalid NaN value for bestOf count");
2843
+ if (count === void 0) return this;
2844
+ if (count <= 0) throw new Error("Best of count must be > 0");
2845
+ const newConfigs = this.getSubRollConfigs();
2846
+ newConfigs[newConfigs.length - 1].bestOf = count;
2847
+ return new this.constructor(newConfigs);
2848
+ }
2849
+ keepHighest(total, count) {
2850
+ if (isNaN(total) || isNaN(count))
2851
+ throw new Error("Invalid NaN value for keepHighest");
2852
+ const newConfigs = this.getSubRollConfigs();
2853
+ newConfigs[newConfigs.length - 1].keep = { total, count, mode: "highest" };
2854
+ return new this.constructor(newConfigs);
2855
+ }
2856
+ keepLowest(total, count) {
2857
+ if (isNaN(total) || isNaN(count))
2858
+ throw new Error("Invalid NaN value for keepLowest");
2859
+ const newConfigs = this.getSubRollConfigs();
2860
+ newConfigs[newConfigs.length - 1].keep = { total, count, mode: "lowest" };
2861
+ return new this.constructor(newConfigs);
2862
+ }
2863
+ withAdvantage() {
2864
+ const newConfigs = this.getSubRollConfigs();
2865
+ newConfigs[newConfigs.length - 1].rollType = "advantage";
2866
+ return new this.constructor(newConfigs);
2867
+ }
2868
+ withDisadvantage() {
2869
+ const configs = this.getSubRollConfigs();
2870
+ configs[configs.length - 1].rollType = "disadvantage";
2871
+ return new this.constructor(configs);
2872
+ }
2873
+ add(anotherRoll) {
2874
+ if (anotherRoll === void 0) return this;
2875
+ const configs = [...this.subRollConfigs, ...anotherRoll.subRollConfigs];
2876
+ return new this.constructor(configs);
2877
+ }
2878
+ withBonus(anotherRoll) {
2879
+ const configs = [...this.subRollConfigs, ...anotherRoll.subRollConfigs];
2880
+ return new this.constructor(configs);
2881
+ }
2882
+ addRoll(count = 1) {
2883
+ if (isNaN(count)) throw new Error("Invalid NaN value for count");
2884
+ const configs = [
2885
+ ...this.subRollConfigs,
2886
+ {
2887
+ ...defaultConfig,
2888
+ count,
2889
+ isSubtraction: count < 0
2890
+ }
2891
+ ];
2892
+ return new this.constructor(configs);
2893
+ }
2894
+ doubleDice() {
2895
+ const newConfigs = this.getSubRollConfigs().map((config) => {
2896
+ if (!config.sides || config.sides <= 0) return config;
2897
+ return { ...config, count: config.count * 2 };
2898
+ });
2899
+ return new this.constructor(newConfigs);
2900
+ }
2901
+ copy() {
2902
+ return new this.constructor(this.getSubRollConfigs());
2903
+ }
2904
+ ac(targetAC) {
2905
+ if (isNaN(targetAC)) throw new Error("Invalid NaN value for targetAC");
2906
+ return new ACBuilder(this, targetAC);
2907
+ }
2908
+ withElvenAccuracy() {
2909
+ const newConfigs = this.getSubRollConfigs();
2910
+ newConfigs[newConfigs.length - 1].rollType = "elven accuracy";
2911
+ return new this.constructor(newConfigs);
2912
+ }
2913
+ dc(saveDC) {
2914
+ if (isNaN(saveDC)) throw new Error("Invalid NaN value for saveDC");
2915
+ return new DCBuilder(this).dc(saveDC);
2916
+ }
2917
+ toExpression() {
2918
+ const diceConfigs = this.subRollConfigs.filter(
2919
+ (config) => config.sides && config.sides > 0
2920
+ );
2921
+ const totalModifier = this.subRollConfigs.reduce(
2922
+ (sum, config) => sum + config.modifier,
2923
+ 0
2924
+ );
2925
+ if (diceConfigs.length === 0) return totalModifier.toString();
2926
+ const rootConfig = this.getRootDieConfig();
2927
+ const diceExpressions = diceConfigs.map(
2928
+ (config) => this.configToSingleExpressionWithoutModifier(
2929
+ config,
2930
+ config === rootConfig
2931
+ )
2932
+ );
2933
+ let result = "";
2934
+ for (let i = 0; i < diceExpressions.length; i++) {
2935
+ const config = diceConfigs[i];
2936
+ const expression = diceExpressions[i];
2937
+ if (i === 0) {
2938
+ result = (config.isSubtraction ? "-" : "") + expression;
2939
+ } else {
2940
+ const operator = config.isSubtraction ? " - " : " + ";
2941
+ result += operator + expression;
2942
+ }
2943
+ }
2944
+ if (totalModifier > 0) result += ` + ${totalModifier}`;
2945
+ else if (totalModifier < 0) result += ` - ${Math.abs(totalModifier)}`;
2946
+ return result.replace(/\+ -/g, "-");
2947
+ }
2948
+ toPMF(eps = 0) {
2949
+ return pmfFromRollBuilder(this, eps);
2950
+ }
2951
+ toQuery(eps = 0) {
2952
+ return this.toPMF(eps).query();
2953
+ }
2954
+ toAST() {
2955
+ const configs = this.getSubRollConfigs();
2956
+ return astFromRollConfigs(configs) || { type: "constant", value: 0 };
2957
+ }
2958
+ configToSingleExpressionWithoutModifier(config, isRootDie) {
2959
+ if (!config.sides || config.sides <= 0) return "";
2960
+ let baseDie = `d${config.sides}`;
2961
+ if (config.reroll > 0) {
2962
+ if (config.minimum > 0 && config.explode > 0) ; else if (config.minimum > 0) {
2963
+ for (let i = config.reroll; i >= 1; i--) baseDie += ` reroll ${i}`;
2964
+ } else {
2965
+ for (let i = 1; i <= config.reroll; i++) baseDie += ` reroll ${i}`;
2966
+ }
2967
+ }
2968
+ if (config.minimum > 0) {
2969
+ baseDie = `${config.minimum}>${baseDie}`;
2970
+ if (config.reroll > 0 && config.explode > 0) {
2971
+ for (let i = 1; i <= config.reroll; i++) {
2972
+ baseDie += ` reroll ${i}`;
2973
+ }
2974
+ }
2975
+ }
2976
+ if (baseDie === "d20 reroll 1" && config.minimum <= 1) baseDie = "hd20";
2977
+ let mainExpression = "";
2978
+ switch (config.rollType) {
2979
+ case "advantage":
2980
+ mainExpression = `${baseDie} > ${baseDie}`;
2981
+ break;
2982
+ case "disadvantage":
2983
+ mainExpression = `${baseDie} < ${baseDie}`;
2984
+ break;
2985
+ case "elven accuracy":
2986
+ mainExpression = `${baseDie} > ${baseDie} > ${baseDie}`;
2987
+ break;
2988
+ case "flat":
2989
+ if (config.keep) {
2990
+ const mode = config.keep.mode === "highest" ? "kh" : "kl";
2991
+ mainExpression = `${config.keep.total}${mode}${config.keep.count}(${baseDie})`;
2992
+ } else {
2993
+ const isComplex = baseDie.length > `d${config.sides}`.length;
2994
+ const isHalflingShorthand = baseDie === "hd20";
2995
+ const isD20Shorthand = baseDie === "d20" && isRootDie;
2996
+ const hasMinimum = baseDie.startsWith("(") && baseDie.includes(">");
2997
+ const hasReroll = baseDie.includes("reroll");
2998
+ const effectiveCount = config.isSubtraction ? Math.abs(config.count) : config.count < 0 ? 1 : Math.abs(config.count);
2999
+ if (effectiveCount > 1) {
3000
+ const shouldAddParentheses = isComplex && (// Complex single roll: explode + reroll
3001
+ !hasMinimum && !hasReroll || // Simple complex case
3002
+ hasReroll && !hasMinimum);
3003
+ mainExpression = shouldAddParentheses ? `${effectiveCount}(${baseDie})` : `${effectiveCount}${baseDie}`;
3004
+ } else if (effectiveCount === 1) {
3005
+ if (config.isSubtraction) {
3006
+ mainExpression = `1${baseDie}`;
3007
+ } else if (isComplex || isHalflingShorthand || isD20Shorthand || config.count < 0) {
3008
+ mainExpression = baseDie;
3009
+ } else {
3010
+ mainExpression = `1${baseDie}`;
3011
+ }
3012
+ } else {
3013
+ mainExpression = baseDie;
3014
+ }
3015
+ }
3016
+ if (config.bestOf && config.count && config.bestOf < config.count) {
3017
+ mainExpression += `kh${config.bestOf}`;
3018
+ }
3019
+ break;
3020
+ }
3021
+ return mainExpression;
3022
+ }
3023
+ getRootDieConfig() {
3024
+ const configs = this.subRollConfigs;
3025
+ return configs.find((config) => config.sides > 0) || configs[0];
3026
+ }
3027
+ getAllDieConfigs() {
3028
+ return this.getSubRollConfigs();
3029
+ }
3030
+ getBonusDiceConfigs() {
3031
+ const allConfigs = this.subRollConfigs;
3032
+ const rootConfig = allConfigs.find((config) => config.sides > 0) || allConfigs[0];
3033
+ if (!rootConfig) return [];
3034
+ return allConfigs.filter((config) => config.sides > 0).filter((config) => config !== rootConfig);
3035
+ }
3036
+ getBonusDicePMFs(check, eps = 0) {
3037
+ return check.getBonusDiceConfigs().map(
3038
+ (config) => pmfFromRollBuilder(_RollBuilder.fromConfigs([config]), eps)
3039
+ );
3040
+ }
3041
+ get modifier() {
3042
+ return this.subRollConfigs.reduce(
3043
+ (sum, config) => sum + config.modifier,
3044
+ 0
3045
+ );
3046
+ }
3047
+ get rollType() {
3048
+ const rootConfig = this.getRootDieConfig();
3049
+ return rootConfig?.rollType || "flat";
3050
+ }
3051
+ get baseReroll() {
3052
+ const rootConfig = this.getRootDieConfig();
3053
+ return rootConfig?.reroll || 0;
3054
+ }
3055
+ };
3056
+
3057
+ // src/builder/attack.ts
3058
+ var AttackBuilder = class _AttackBuilder {
3059
+ constructor(check, hitEffect, critEffect, missEffect) {
3060
+ this.check = check;
3061
+ this.hitEffect = hitEffect;
3062
+ this.critEffect = critEffect;
3063
+ this.missEffect = missEffect;
3064
+ }
3065
+ onCrit(...args) {
3066
+ const damageRoll = RollBuilder.fromArgs(...args);
3067
+ return new _AttackBuilder(
3068
+ this.check,
3069
+ this.hitEffect,
3070
+ damageRoll,
3071
+ this.missEffect
3072
+ );
3073
+ }
3074
+ onMiss(...args) {
3075
+ const damageRoll = RollBuilder.fromArgs(...args);
3076
+ return new _AttackBuilder(
3077
+ this.check,
3078
+ this.hitEffect,
3079
+ this.critEffect,
3080
+ damageRoll
3081
+ );
3082
+ }
3083
+ noCrit() {
3084
+ return new _AttackBuilder(this.check, this.hitEffect, null, this.missEffect);
3085
+ }
3086
+ // Legacy expressions
3087
+ toExpression() {
3088
+ const checkPart = this.check.toExpression();
3089
+ let effectPart = "";
3090
+ if (this.hitEffect) {
3091
+ effectPart = `(${this.hitEffect.toExpression()})`;
3092
+ let crit = void 0;
3093
+ if (this.critEffect !== null) {
3094
+ crit = this.critEffect ?? this.hitEffect.copy().doubleDice();
3095
+ const critThreshold = this.check.critThreshold;
3096
+ if (critThreshold < 15 || critThreshold > 20) {
3097
+ throw new Error(
3098
+ `Invalid crit threshold: ${critThreshold}. Must be between 15 and 20.`
3099
+ );
3100
+ }
3101
+ if (critThreshold === 20) {
3102
+ effectPart += ` crit (${crit.toExpression()})`;
3103
+ } else {
3104
+ const xcritNumber = 21 - critThreshold;
3105
+ effectPart += ` xcrit${xcritNumber} (${crit.toExpression()})`;
3106
+ }
3107
+ }
3108
+ if (this.missEffect) {
3109
+ effectPart += ` miss (${this.missEffect.toExpression()})`;
3110
+ }
3111
+ }
3112
+ return `${checkPart} * ${effectPart}`;
3113
+ }
3114
+ resolveProbabilities(check, eps = 0) {
3115
+ const ac = check.attackConfig.ac;
3116
+ const rollType = check.rollType;
3117
+ const rerollOne = check.baseReroll > 0;
3118
+ const critThreshold = check.critThreshold;
3119
+ const d202 = d20RollPMF(rollType, rerollOne);
3120
+ const staticMod = this.check.modifier;
3121
+ const bonusDicePMFs = this.check.getBonusDicePMFs(this.check, eps);
3122
+ const bonusPMF = bonusDicePMFs.length ? PMF.convolveMany(bonusDicePMFs, eps) : PMF.delta(0, eps);
3123
+ let pcrit = 0;
3124
+ let phit = 0;
3125
+ let pmiss = 0;
3126
+ for (const [r, rec] of d202) {
3127
+ const pr = typeof rec === "number" ? rec : rec.p;
3128
+ if (pr <= 0) continue;
3129
+ if (r >= critThreshold) {
3130
+ pcrit += pr;
3131
+ continue;
3132
+ }
3133
+ if (r === 1) {
3134
+ pmiss += pr;
3135
+ continue;
3136
+ }
3137
+ const need = ac - staticMod - r;
3138
+ const pBonusHit = bonusPMF.tailProbGE(need);
3139
+ phit += pr * pBonusHit;
3140
+ pmiss += pr * (1 - pBonusHit);
3141
+ }
3142
+ const psuccess = phit + pcrit;
3143
+ return { pSuccess: psuccess, pHit: phit, pCrit: pcrit, pMiss: pmiss };
3144
+ }
3145
+ resolve(eps = EPS) {
3146
+ const {
3147
+ pHit,
3148
+ pCrit,
3149
+ pMiss: pmiss
3150
+ } = this.resolveProbabilities(this.check, eps);
3151
+ const hitPMF = this.hitEffect ? pmfFromRollBuilder(this.hitEffect, eps) : PMF.delta(0, eps);
3152
+ let critPMF = null;
3153
+ let phit = pHit;
3154
+ let pcrit = pCrit;
3155
+ if (this.critEffect === null) {
3156
+ critPMF = null;
3157
+ phit += pcrit;
3158
+ pcrit = 0;
3159
+ } else {
3160
+ const critBuilder = this.critEffect ?? this.hitEffect?.copy().doubleDice();
3161
+ critPMF = critBuilder ? pmfFromRollBuilder(critBuilder, eps) : PMF.delta(0, eps);
3162
+ }
3163
+ const missPMF = this.missEffect ? pmfFromRollBuilder(this.missEffect, eps) : PMF.delta(0, eps);
3164
+ const mix = new Mixture(eps);
3165
+ if (phit > 0) mix.add("hit", hitPMF, phit);
3166
+ if (critPMF && pcrit > 0) mix.add("crit", critPMF, pcrit);
3167
+ if (pmiss > 0)
3168
+ mix.add(this.missEffect ? "missDamage" : "missNone", missPMF, pmiss);
3169
+ return {
3170
+ pmf: mix.buildPMF(eps) ?? PMF.delta(0, eps),
3171
+ check: this.check.toPMF(eps) ?? PMF.delta(0, eps),
3172
+ hit: hitPMF ?? PMF.delta(0, eps),
3173
+ crit: critPMF ?? PMF.delta(0, eps),
3174
+ miss: missPMF ?? PMF.delta(0, eps),
3175
+ weights: { hit: phit, crit: pcrit, miss: pmiss }
3176
+ };
3177
+ }
3178
+ // By default, create PMF with no pruning
3179
+ toPMF(eps = 0) {
3180
+ return this.resolve(eps).pmf;
3181
+ }
3182
+ // By default, create query on PMF with no pruning
3183
+ toQuery(eps = 0) {
3184
+ return this.toPMF(eps).query();
3185
+ }
3186
+ };
3187
+
3188
+ // src/builder/ac.ts
3189
+ var ACBuilder = class _ACBuilder extends RollBuilder {
3190
+ constructor(baseRoll, ac, attackConfig) {
3191
+ super(baseRoll.getSubRollConfigs());
3192
+ __publicField(this, "attackConfig");
3193
+ if (attackConfig) {
3194
+ this.attackConfig = { ...attackConfig, ac };
3195
+ } else {
3196
+ this.attackConfig = { ac, critThreshold: 20 };
3197
+ }
3198
+ }
3199
+ onHit(...args) {
3200
+ const damageRoll = RollBuilder.fromArgs(...args);
3201
+ return new AttackBuilder(this, damageRoll);
3202
+ }
3203
+ dc(_) {
3204
+ throw new Error(
3205
+ "Cannot use dc() on an AttackRollBuilder. Use ac() for attack rolls instead."
3206
+ );
3207
+ }
3208
+ get critThreshold() {
3209
+ return this.attackConfig.critThreshold;
3210
+ }
3211
+ // TODO - move this to AC Builder… or if we create a DC builder that has critOn, throw an error?
3212
+ critOn(threshold) {
3213
+ const newConfig = {
3214
+ ...this.attackConfig,
3215
+ critThreshold: threshold
3216
+ };
3217
+ return new _ACBuilder(this, this.attackConfig.ac, newConfig);
3218
+ }
3219
+ // Legacy expressions
3220
+ toExpression() {
3221
+ const configs = this.getSubRollConfigs();
3222
+ const expression = new RollBuilder(configs).toExpression();
3223
+ return this.attackConfig.ac ? `(${expression} AC ${this.attackConfig.ac})` : expression;
3224
+ }
3225
+ toPMF(eps = 0) {
3226
+ const ac = this.attackConfig.ac;
3227
+ const rollType = this.rollType;
3228
+ const rerollOne = this.baseReroll > 0;
3229
+ const d202 = d20RollPMF(rollType, rerollOne);
3230
+ const staticMod = this.modifier;
3231
+ const bonusPMFs = this.getBonusDicePMFs(this, eps);
3232
+ const parts = [d202, ...bonusPMFs];
3233
+ let attackRollPMF = parts.length === 1 ? d202 : PMF.convolveMany(parts, eps);
3234
+ if (staticMod !== 0)
3235
+ attackRollPMF = attackRollPMF.mapDamage(
3236
+ (rollValue) => rollValue + staticMod
3237
+ );
3238
+ const out = /* @__PURE__ */ new Map();
3239
+ for (const rollValue of attackRollPMF.support()) {
3240
+ const p = attackRollPMF.pAt(rollValue);
3241
+ const key = rollValue >= ac ? rollValue : 0;
3242
+ out.set(key, (out.get(key) || 0) + p);
3243
+ }
3244
+ return PMF.fromMap(out, eps);
3245
+ }
3246
+ copy() {
3247
+ const baseCopy = new RollBuilder(this.getSubRollConfigs());
3248
+ const newConfig = {
3249
+ ac: this.attackConfig.ac,
3250
+ critThreshold: this.attackConfig.critThreshold
3251
+ };
3252
+ return new _ACBuilder(baseCopy, newConfig.ac, newConfig);
3253
+ }
3254
+ };
3255
+
3256
+ // src/builder/save.ts
3257
+ var SaveBuilder = class _SaveBuilder {
3258
+ constructor(check, failureEffect, saveOutcome = "normal") {
3259
+ this.check = check;
3260
+ this.failureEffect = failureEffect;
3261
+ this.saveOutcome = saveOutcome;
3262
+ }
3263
+ saveHalf() {
3264
+ return new _SaveBuilder(this.check, this.failureEffect, "half");
3265
+ }
3266
+ toExpression() {
3267
+ const checkPart = this.check.toExpression();
3268
+ if (!this.failureEffect) return checkPart;
3269
+ const failureEffectPart = this.failureEffect.toExpression();
3270
+ const result = `${checkPart} * (${failureEffectPart})`;
3271
+ return this.saveOutcome === "half" ? `${result} save half` : result;
3272
+ }
3273
+ resolve(eps = EPS) {
3274
+ const { pSuccess: psuccess = 0, pFail: pfail = 1 } = resolveProbabilities(
3275
+ this.check
3276
+ );
3277
+ const failPMF = this.failureEffect ? pmfFromRollBuilder(this.failureEffect) : PMF.delta(0);
3278
+ const onSuccess = this.saveOutcome ?? "half";
3279
+ let successPMF = PMF.delta(0, eps);
3280
+ if (onSuccess === "half") successPMF = failPMF.scaleDamage(0.5, "floor");
3281
+ const successLabel = onSuccess === "normal" ? "missNone" : "saveHalf";
3282
+ const failLabel = "saveFail";
3283
+ const baseMix = new Mixture(eps);
3284
+ const mixture = baseMix.add(successLabel, successPMF, psuccess).add(failLabel, failPMF, pfail);
3285
+ return {
3286
+ pmf: mixture.buildPMF(eps) ?? PMF.delta(0, eps),
3287
+ check: PMF.exclusive([[PMF.delta(1), psuccess]], eps) ?? PMF.delta(0, eps),
3288
+ saveFail: failPMF ?? PMF.delta(0, eps),
3289
+ saveSuccess: successPMF ?? PMF.delta(0, eps),
3290
+ weights: { success: psuccess, fail: pfail }
3291
+ };
3292
+ }
3293
+ // By default, create PMF with no pruning
3294
+ toPMF(eps = 0) {
3295
+ return this.resolve(eps).pmf;
3296
+ }
3297
+ // By default, create query on PMF with no pruning
3298
+ toQuery(eps = 0) {
3299
+ return this.toPMF(eps).query();
3300
+ }
3301
+ };
3302
+ function resolveProbabilities(check) {
3303
+ const saveBonus = check.modifier;
3304
+ const dc = check.saveDC;
3305
+ const d20Type = check.rollType;
3306
+ const baseReroll = check.baseReroll;
3307
+ const die = d20RollPMF(d20Type, baseReroll > 0);
3308
+ const faceP = /* @__PURE__ */ new Map();
3309
+ for (const [r, rec] of die) {
3310
+ const pr = typeof rec === "number" ? rec : rec.p;
3311
+ if (pr > 0) faceP.set(r, pr);
3312
+ }
3313
+ const eps = 0;
3314
+ const bonusDicePMFs = check.getBonusDicePMFs(check, eps);
3315
+ const bonusPMF = bonusDicePMFs.length > 0 ? PMF.convolveMany(bonusDicePMFs, eps) : PMF.zero(eps);
3316
+ let pSuccess = 0;
3317
+ for (let r = 1; r <= 20; r++) {
3318
+ const pr = faceP.get(r);
3319
+ if (!pr) continue;
3320
+ const need = dc - saveBonus - r;
3321
+ pSuccess += pr * bonusPMF.tailProbGE(need);
3322
+ }
3323
+ const pFail = Math.max(0, 1 - pSuccess);
3324
+ return { pSuccess, pFail };
3325
+ }
3326
+
3327
+ // src/builder/dc.ts
3328
+ var DCBuilder = class _DCBuilder extends RollBuilder {
3329
+ constructor(baseRoll, saveConfig) {
3330
+ super(baseRoll.getSubRollConfigs());
3331
+ __publicField(this, "saveConfig");
3332
+ this.saveConfig = saveConfig ? { ...saveConfig } : { dc: 10 };
3333
+ }
3334
+ dc(saveDC) {
3335
+ if (this.rollType && this.rollType === "elven accuracy") {
3336
+ throw new Error(
3337
+ "Cannot use dc() on an AttackRollBuilder. Use ac() for attack rolls instead."
3338
+ );
3339
+ }
3340
+ return new _DCBuilder(this, { dc: saveDC });
3341
+ }
3342
+ get saveDC() {
3343
+ return this.saveConfig.dc;
3344
+ }
3345
+ add(anotherRoll) {
3346
+ const newBuilder = super.add(anotherRoll);
3347
+ return new _DCBuilder(newBuilder, this.saveConfig);
3348
+ }
3349
+ addRoll(count) {
3350
+ const newBuilder = super.addRoll(count);
3351
+ return new _DCBuilder(newBuilder, this.saveConfig);
3352
+ }
3353
+ onSaveFailure(...args) {
3354
+ const damageRoll = RollBuilder.fromArgs(...args);
3355
+ return new SaveBuilder(this, damageRoll);
3356
+ }
3357
+ withElvenAccuracy() {
3358
+ throw new Error(
3359
+ "Elven Accuracy cannot be used with saving throws (DC checks). It is only valid for attack rolls (AC checks)."
3360
+ );
3361
+ }
3362
+ // Legacy expressions
3363
+ toExpression() {
3364
+ const subConfigs = this.getSubRollConfigs();
3365
+ const allConfigs = [...subConfigs];
3366
+ const expression = new RollBuilder(allConfigs).toExpression();
3367
+ return `(${expression} DC ${this.saveConfig.dc})`;
3368
+ }
3369
+ toPMF(eps = 0) {
3370
+ const saveDC = this.saveDC;
3371
+ const rollType = this.rollType;
3372
+ const rerollOne = this.baseReroll > 0;
3373
+ const d202 = d20RollPMF(rollType, rerollOne);
3374
+ const staticMod = this.modifier;
3375
+ const bonusDicePMFs = this.getBonusDiceConfigs().map(
3376
+ (cfg) => pmfFromRollBuilder(RollBuilder.fromConfigs([cfg]), eps)
3377
+ );
3378
+ const bonusPMF = bonusDicePMFs.length ? PMF.convolveMany(bonusDicePMFs, eps) : PMF.delta(0, eps);
3379
+ let psuccess = 0;
3380
+ for (const [r, rec] of d202) {
3381
+ const pr = typeof rec === "number" ? rec : rec.p;
3382
+ if (pr <= 0) continue;
3383
+ const need = saveDC - staticMod - r;
3384
+ psuccess += pr * bonusPMF.tailProbGE(need);
3385
+ }
3386
+ const pfail = Math.max(0, 1 - psuccess);
3387
+ const m = /* @__PURE__ */ new Map([
3388
+ [0, psuccess > 0 ? psuccess : 0],
3389
+ [1, pfail > 0 ? pfail : 0]
3390
+ ]);
3391
+ return PMF.fromMap(m, eps);
3392
+ }
3393
+ };
3394
+
3395
+ // src/builder/factory.ts
3396
+ var rollFn = (count, sidesOrDie, modifier) => {
3397
+ if (sidesOrDie instanceof RollBuilder) {
3398
+ const subRollConfigs = sidesOrDie.getSubRollConfigs();
3399
+ if (subRollConfigs.length === 0) return new RollBuilder(0).plus(modifier);
3400
+ const absCount = Math.abs(count);
3401
+ const newConfigs = subRollConfigs.map((config) => ({
3402
+ ...config,
3403
+ count: config.count * absCount,
3404
+ modifier: config.modifier * absCount
3405
+ }));
3406
+ let resultBuilder = new RollBuilder(newConfigs);
3407
+ if (count < 0) {
3408
+ const negatedConfigs = resultBuilder.getSubRollConfigs().map((c) => ({ ...c, isSubtraction: !c.isSubtraction }));
3409
+ resultBuilder = new RollBuilder(negatedConfigs);
3410
+ }
3411
+ return resultBuilder.plus(modifier);
3412
+ } else {
3413
+ let builder = new RollBuilder(count);
3414
+ if (sidesOrDie && sidesOrDie > 0) {
3415
+ builder = builder.d(sidesOrDie);
3416
+ }
3417
+ return builder.plus(modifier);
3418
+ }
3419
+ };
3420
+ rollFn.d = (sides) => new RollBuilder(1).d(sides);
3421
+ rollFn.hd20 = () => new RollBuilder(1).d20().reroll(1);
3422
+ rollFn.d4 = () => new RollBuilder(1).d4();
3423
+ rollFn.d6 = () => new RollBuilder(1).d6();
3424
+ rollFn.d8 = () => new RollBuilder(1).d8();
3425
+ rollFn.d10 = () => new RollBuilder(1).d10();
3426
+ rollFn.d12 = () => new RollBuilder(1).d12();
3427
+ rollFn.d20 = () => new RollBuilder(1).d20();
3428
+ rollFn.d100 = () => new RollBuilder(1).d100();
3429
+ rollFn.flat = (n) => new RollBuilder(0).plus(n);
3430
+ function d(sides) {
3431
+ return new RollBuilder(1).d(sides);
3432
+ }
3433
+ var d4 = new RollBuilder(1).d4();
3434
+ var d6 = new RollBuilder(1).d6();
3435
+ var d8 = new RollBuilder(1).d8();
3436
+ var d10 = new RollBuilder(1).d10();
3437
+ var d12 = new RollBuilder(1).d12();
3438
+ var d20 = new RollBuilder(1).d20();
3439
+ var hd20 = new RollBuilder(1).d20().reroll(1);
3440
+ var d100 = new RollBuilder(1).d100();
3441
+ var flat = (n) => new RollBuilder(0).plus(n);
3442
+ var roll = rollFn;
3443
+ var builderPMFCache = new LRUCache(1e3);
3444
+
3445
+ export { ACBuilder, DCBuilder, RollBuilder, SaveBuilder, builderPMFCache, d, d10, d100, d12, d20, d4, d6, d8, defaultConfig, flat, hd20, roll };
3446
+ //# sourceMappingURL=index.js.map
3447
+ //# sourceMappingURL=index.js.map