@yipe/dice 0.1.0

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