@yipe/dice 0.2.23 → 0.3.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.
@@ -1,14 +1,10 @@
1
1
  'use strict';
2
2
 
3
- var __defProp = Object.defineProperty;
4
- var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
5
- var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
6
-
7
3
  // src/common/lru-cache.ts
8
4
  var LRUCache = class {
9
5
  constructor(maxSize = 1e3) {
10
6
  this.maxSize = maxSize;
11
- __publicField(this, "cache", /* @__PURE__ */ new Map());
7
+ this.cache = /* @__PURE__ */ new Map();
12
8
  }
13
9
  get(key) {
14
10
  const value = this.cache.get(key);
@@ -52,15 +48,30 @@ var EPS = 1e-12;
52
48
  // src/pmf/query.ts
53
49
  var _DiceQuery = class _DiceQuery {
54
50
  constructor(singles, combined, eps = EPS) {
55
- __publicField(this, "singles");
56
- __publicField(this, "combined");
57
- __publicField(this, "_combinedWithAttr");
58
51
  this.singles = Array.isArray(singles) ? singles : [singles];
59
52
  if (this.singles.some((s) => s === void 0)) {
60
53
  throw new Error("DiceQuery contains undefined singles");
61
54
  }
62
- const c = combined ?? PMF.convolveMany(this.singles);
63
- this.combined = Math.abs(c.mass() - 1) <= eps ? c : c.normalize();
55
+ this._eps = eps;
56
+ this._combinedProvided = combined !== void 0;
57
+ if (combined !== void 0) {
58
+ this._combined = Math.abs(combined.mass() - 1) <= eps ? combined : combined.normalize();
59
+ }
60
+ }
61
+ /**
62
+ * The combined damage distribution of all single PMFs (their convolution),
63
+ * normalized to total probability 1.
64
+ *
65
+ * Computed lazily on first access and cached. Queries that only need
66
+ * additive statistics — {@link DiceQuery.mean}, {@link DiceQuery.variance},
67
+ * {@link DiceQuery.stddev} — never trigger this convolution.
68
+ */
69
+ get combined() {
70
+ if (this._combined === void 0) {
71
+ const c = PMF.convolveMany(this.singles);
72
+ this._combined = Math.abs(c.mass() - 1) <= this._eps ? c : c.normalize();
73
+ }
74
+ return this._combined;
64
75
  }
65
76
  /**
66
77
  * Returns a new PMF with damage attribution metadata populated.
@@ -85,6 +96,10 @@ var _DiceQuery = class _DiceQuery {
85
96
  if (this._combinedWithAttr) {
86
97
  return this._combinedWithAttr;
87
98
  }
99
+ if (this.singles.every((pmf) => pmf.hasAttribution())) {
100
+ this._combinedWithAttr = this.combined;
101
+ return this._combinedWithAttr;
102
+ }
88
103
  const singlesWithAttr = this.singles.map((pmf) => pmf.withAttribution());
89
104
  const combined = PMF.convolveMany(singlesWithAttr, this.combined.epsilon);
90
105
  const normalized = Math.abs(combined.mass() - 1) <= this.combined.epsilon ? combined : combined.normalize();
@@ -98,11 +113,18 @@ var _DiceQuery = class _DiceQuery {
98
113
  * Use case: "What's my average damage per round?"
99
114
  */
100
115
  mean() {
101
- let totalSum = 0;
102
- for (const [damageValue, probabilityBin] of this.combined) {
103
- totalSum += damageValue * probabilityBin.p;
116
+ if (this._combinedProvided) {
117
+ let m = 0;
118
+ for (const [damageValue, bin] of this.combined) m += damageValue * bin.p;
119
+ return m;
104
120
  }
105
- return totalSum;
121
+ let totalMean = 0;
122
+ for (const single of this.singles) {
123
+ const mass = single.mass();
124
+ if (mass <= 0) continue;
125
+ totalMean += Math.abs(mass - 1) <= this._eps ? single.mean() : single.mean() / mass;
126
+ }
127
+ return totalMean;
106
128
  }
107
129
  /**
108
130
  * Returns the variance of the damage distribution.
@@ -112,13 +134,33 @@ var _DiceQuery = class _DiceQuery {
112
134
  * High variance means higher risk/reward. Lower variance means more consistent damage.
113
135
  */
114
136
  variance() {
115
- const meanValue = this.mean();
116
- let varianceSum = 0;
117
- for (const [damageValue, probabilityBin] of this.combined) {
118
- const deviationFromMean = damageValue - meanValue;
119
- varianceSum += deviationFromMean * deviationFromMean * probabilityBin.p;
137
+ if (this._combinedProvided) {
138
+ const mu = this.mean();
139
+ let v = 0;
140
+ for (const [damageValue, bin] of this.combined) {
141
+ const dev = damageValue - mu;
142
+ v += dev * dev * bin.p;
143
+ }
144
+ return v;
145
+ }
146
+ let totalVariance = 0;
147
+ for (const single of this.singles) {
148
+ const mass = single.mass();
149
+ if (mass <= 0) continue;
150
+ if (Math.abs(mass - 1) <= this._eps) {
151
+ totalVariance += single.variance();
152
+ } else {
153
+ let mu = 0;
154
+ for (const [d2, b] of single) mu += d2 * (b.p / mass);
155
+ let v = 0;
156
+ for (const [d2, b] of single) {
157
+ const dev = d2 - mu;
158
+ v += dev * dev * (b.p / mass);
159
+ }
160
+ totalVariance += v;
161
+ }
120
162
  }
121
- return varianceSum;
163
+ return totalVariance;
122
164
  }
123
165
  /**
124
166
  * Returns the standard deviation of the damage distribution.
@@ -130,6 +172,10 @@ var _DiceQuery = class _DiceQuery {
130
172
  stddev() {
131
173
  return Math.sqrt(this.variance());
132
174
  }
175
+ /** Alias of {@link DiceQuery.stddev}, matching {@link PMF.stdev}. */
176
+ stdev() {
177
+ return this.stddev();
178
+ }
133
179
  /**
134
180
  * Returns the Cumulative Distribution Function.
135
181
  */
@@ -220,20 +266,49 @@ var _DiceQuery = class _DiceQuery {
220
266
  return this.combined.max();
221
267
  }
222
268
  singleProb(diceIndex, label) {
269
+ const single = this.singles[diceIndex];
223
270
  let probabilitySum = 0;
224
- for (const [, probabilityBin] of this.singles[diceIndex]) {
271
+ for (const [, probabilityBin] of single) {
225
272
  probabilitySum += probabilityBin.count[label] || 0;
226
273
  }
227
- return probabilitySum;
274
+ const mass = single.mass();
275
+ return mass > 0 ? probabilitySum / mass : 0;
276
+ }
277
+ /**
278
+ * Full count distribution [P(0), P(1), …, P(n)] for "an attack succeeds if it
279
+ * carries ANY of `labels`", over the n independent singles.
280
+ *
281
+ * Each single's per-event success probability is the Poisson-binomial
282
+ * marginal P(≥1 of labels) from {@link probabilityOf} (i.e. probAtLeastOne),
283
+ * computed exactly once. The binomial DP then runs once to produce the whole
284
+ * distribution, so the array-label paths of probExactlyK / probAtLeastK /
285
+ * probAtMostK can slice or sum from it instead of rebuilding a DiceQuery and
286
+ * re-running the DP per requested k.
287
+ */
288
+ countDistribution(labels) {
289
+ const n = this.singles.length;
290
+ const successProbabilities = this.singles.map(
291
+ (single) => new _DiceQuery([single]).probabilityOf(labels)
292
+ );
293
+ const dist = new Array(n + 1).fill(0);
294
+ dist[0] = 1;
295
+ for (const successProb of successProbabilities) {
296
+ for (let outcomeCount = n; outcomeCount >= 1; outcomeCount--) {
297
+ dist[outcomeCount] = dist[outcomeCount] * (1 - successProb) + dist[outcomeCount - 1] * successProb;
298
+ }
299
+ dist[0] *= 1 - successProb;
300
+ }
301
+ return dist;
228
302
  }
229
303
  probAtLeastK(labels, k) {
230
304
  const L = Array.isArray(labels) ? [...new Set(labels)] : [labels];
231
305
  const n = this.singles.length;
232
306
  if (k <= 0) return 1;
233
307
  if (k > n) return 0;
308
+ const dist = this.countDistribution(L);
234
309
  let tail = 0;
235
310
  for (let i = k; i <= n; i++) {
236
- tail += this.probExactlyK(L, i);
311
+ tail += dist[i];
237
312
  }
238
313
  if (tail < 0) return 0;
239
314
  if (tail > 1) return 1;
@@ -265,9 +340,12 @@ var _DiceQuery = class _DiceQuery {
265
340
  for (const label of labels) {
266
341
  combinedProbability += this.singleProb(diceIndex, label);
267
342
  }
343
+ if (combinedProbability < 0) combinedProbability = 0;
344
+ else if (combinedProbability > 1) combinedProbability = 1;
268
345
  productOfNonOccurrence *= 1 - combinedProbability;
269
346
  }
270
- return 1 - productOfNonOccurrence;
347
+ const result = 1 - productOfNonOccurrence;
348
+ return result < 0 ? 0 : result > 1 ? 1 : result;
271
349
  }
272
350
  /**
273
351
  * Computes binomial probabilities for exactly 0, 1, 2, ..., maxK occurrences of a label.
@@ -308,7 +386,7 @@ var _DiceQuery = class _DiceQuery {
308
386
  * Array examples:
309
387
  * - probExactlyK(['hit', 'crit'], 2) = probability exactly 2 attacks succeed
310
388
  * - probExactlyK(['hit', 'crit'], 1) = probability exactly 1 attack succeeds
311
- * - probExactlyK(['miss', 'missNone'], 0) = probability no attacks miss
389
+ * - probExactlyK(['missDamage', 'missNone'], 0) = probability no attacks miss
312
390
  *
313
391
  * Use cases:
314
392
  * - "What's the chance exactly one of my attacks hits?"
@@ -323,19 +401,8 @@ var _DiceQuery = class _DiceQuery {
323
401
  const probabilityArray = this.computeBinomialProbabilities(labels, k);
324
402
  return probabilityArray[k];
325
403
  }
326
- const successProbabilities = this.singles.map((single) => {
327
- const singleQuery = new _DiceQuery([single]);
328
- return singleQuery.probabilityOf(labels);
329
- });
330
- const binomialProbs = new Array(k + 1).fill(0);
331
- binomialProbs[0] = 1;
332
- for (const successProb of successProbabilities) {
333
- for (let outcomeCount = k; outcomeCount >= 1; outcomeCount--) {
334
- binomialProbs[outcomeCount] = binomialProbs[outcomeCount] * (1 - successProb) + binomialProbs[outcomeCount - 1] * successProb;
335
- }
336
- binomialProbs[0] *= 1 - successProb;
337
- }
338
- return binomialProbs[k];
404
+ const dist = this.countDistribution(labels);
405
+ return k >= 0 && k < dist.length ? dist[k] : 0;
339
406
  }
340
407
  /**
341
408
  * Returns the probability that AT MOST K attacks result in the specified outcome(s).
@@ -343,7 +410,7 @@ var _DiceQuery = class _DiceQuery {
343
410
  * Single label examples:
344
411
  * - probAtMostK('hit', 1) = probability 0 or 1 attacks hit (at most 1)
345
412
  * - probAtMostK('crit', 0) = probability no attacks crit
346
- * - probAtMostK('miss', 2) = probability at most 2 attacks miss
413
+ * - probAtMostK('missDamage', 2) = probability at most 2 attacks miss
347
414
  *
348
415
  * Array examples:
349
416
  * - probAtMostK(['hit', 'crit'], 1) = probability at most 1 attack succeeds
@@ -364,9 +431,11 @@ var _DiceQuery = class _DiceQuery {
364
431
  }
365
432
  return cumulativeSum2;
366
433
  }
434
+ const dist = this.countDistribution(labels);
435
+ const upper = Math.min(k, dist.length - 1);
367
436
  let cumulativeSum = 0;
368
- for (let outcomeCount = 0; outcomeCount <= k; outcomeCount++) {
369
- cumulativeSum += this.probExactlyK(labels, outcomeCount);
437
+ for (let outcomeCount = 0; outcomeCount <= upper; outcomeCount++) {
438
+ cumulativeSum += dist[outcomeCount];
370
439
  }
371
440
  return cumulativeSum;
372
441
  }
@@ -412,7 +481,7 @@ var _DiceQuery = class _DiceQuery {
412
481
  *
413
482
  * Array examples:
414
483
  * - damageStatsFrom(['hit', 'crit']) = damage range when at least one attack succeeds
415
- * - damageStatsFrom(['miss', 'missNone']) = damage range when at least one attack misses
484
+ * - damageStatsFrom(['missDamage', 'missNone']) = damage range when at least one attack misses
416
485
  *
417
486
  * Tactical Use Cases:
418
487
  * - "Given that I don't completely whiff (99% of turns), what damage should I expect?"
@@ -430,6 +499,12 @@ var _DiceQuery = class _DiceQuery {
430
499
  * This includes mixed scenarios (2 hits + 1 crit, 3 hits + 1 miss, etc.) which
431
500
  * occur far more frequently than pure scenarios. For pure scenarios, use combinedDamageStats.
432
501
  *
502
+ * KNOWN LIMITATION (multi-attack, single label): the returned `count` is an
503
+ * EXPECTED COUNT (E[#label], so > 1 for N≥2 attacks, not a probability), and
504
+ * `avg` is the size-biased conditional mean E[dmg·#label]/E[#label] rather than
505
+ * E[dmg | the label occurs]. For a single attack both are the plain
506
+ * conditional figures. Use {@link probAtLeastOne} for the scenario probability.
507
+ *
433
508
  * @example
434
509
  * // High-level tactical planning
435
510
  * const successStats = query.damageStatsFrom('hit')
@@ -534,7 +609,8 @@ var _DiceQuery = class _DiceQuery {
534
609
  };
535
610
  }
536
611
  /**
537
- * Returns the probability that a result includes ANY of the specified labels.
612
+ * Returns the probability that at least one attack carries ANY of the
613
+ * specified labels (the marginal P(≥1) across the independent attacks).
538
614
  *
539
615
  * Examples:
540
616
  * - `query.probabilityOf('hit')` → 0.88 (probability at least one hit occurs)
@@ -543,25 +619,15 @@ var _DiceQuery = class _DiceQuery {
543
619
  * Use cases:
544
620
  * - "What's the chance my resolution includes a success label?"
545
621
  * - "How likely am I to get any hits or crits across all attacks?"
622
+ *
623
+ * Note: this must NOT be computed by summing `combined` bin probabilities. A
624
+ * single combined damage total is reachable by many outcome combinations and
625
+ * a bin can hold several labels at once, so summing `bin.p` over bins that
626
+ * contain a label over-counts. The correct marginal is the Poisson-binomial
627
+ * complement over the per-attack probabilities, i.e. {@link probAtLeastOne}.
546
628
  */
547
629
  probabilityOf(labels) {
548
- if (typeof labels === "string") {
549
- labels = [labels];
550
- }
551
- let totalProbability = 0;
552
- for (const [, probabilityBin] of this.combined) {
553
- let binHasAnyLabel = false;
554
- for (const label of labels) {
555
- if (probabilityBin.count[label] && probabilityBin.count[label] > 0) {
556
- binHasAnyLabel = true;
557
- break;
558
- }
559
- }
560
- if (binHasAnyLabel) {
561
- totalProbability += probabilityBin.p;
562
- }
563
- }
564
- return totalProbability;
630
+ return this.probAtLeastOne(labels);
565
631
  }
566
632
  /**
567
633
  * Returns the probability of missing (any type of miss).
@@ -621,10 +687,6 @@ var _DiceQuery = class _DiceQuery {
621
687
  */
622
688
  toStackedChartData(labels = [], epsilon = EPS) {
623
689
  const damageValues = this.combined.support();
624
- damageValues.map((dmg) => {
625
- const bin = this.combined.map.get(dmg);
626
- return labels.reduce((sum, lab) => sum + (bin.count[lab] || 0), 0);
627
- });
628
690
  const datasets = labels.map((outcomeLabel) => ({
629
691
  label: outcomeLabel,
630
692
  data: damageValues.map((dmg) => {
@@ -1061,6 +1123,16 @@ var _DiceQuery = class _DiceQuery {
1061
1123
  * Snapshot of the distribution in the exact shape the UI consumes.
1062
1124
  * - outcome probabilities are "at least one" (and equal to "all" for a single PMF)
1063
1125
  * - damageRange is conditional on the outcome occurring
1126
+ *
1127
+ * The outcome probabilities use the correct Poisson-binomial marginals
1128
+ * (`atLeastOneProbability` = P(≥1 attack has it), `allProbability` = P(all do)),
1129
+ * so they are always valid probabilities in [0,1].
1130
+ *
1131
+ * KNOWN LIMITATION (multi-attack): `damageRange.avg` is still aggregated from
1132
+ * the combined PMF's `count`, which the convolution accumulates as an EXPECTED
1133
+ * COUNT, so for N≥2 attacks it is the size-biased mean E[dmg·#label]/E[#label]
1134
+ * rather than a clean conditional expectation. It is correct for a single
1135
+ * attack.
1064
1136
  */
1065
1137
  snapshot(order) {
1066
1138
  const discovered = /* @__PURE__ */ new Set();
@@ -1082,10 +1154,8 @@ var _DiceQuery = class _DiceQuery {
1082
1154
  );
1083
1155
  }
1084
1156
  const rows = this.toLabeledTable(outcomes);
1085
- const totals = /* @__PURE__ */ new Map();
1086
1157
  const rangeAcc = /* @__PURE__ */ new Map();
1087
1158
  for (const ot of outcomes) {
1088
- totals.set(ot, 0);
1089
1159
  rangeAcc.set(ot, { sum: 0, mass: 0 });
1090
1160
  }
1091
1161
  for (const row of rows) {
@@ -1093,7 +1163,6 @@ var _DiceQuery = class _DiceQuery {
1093
1163
  for (const ot of outcomes) {
1094
1164
  const p = row[ot] || 0;
1095
1165
  if (p <= 0) continue;
1096
- totals.set(ot, (totals.get(ot) || 0) + p);
1097
1166
  const r = rangeAcc.get(ot);
1098
1167
  r.sum += dmg * p;
1099
1168
  r.mass += p;
@@ -1101,15 +1170,14 @@ var _DiceQuery = class _DiceQuery {
1101
1170
  if (r.max === void 0 || dmg > r.max) r.max = dmg;
1102
1171
  }
1103
1172
  }
1173
+ const n = this.singles.length;
1104
1174
  const outcomeMap = /* @__PURE__ */ new Map();
1105
1175
  for (const ot of outcomes) {
1106
- const total = totals.get(ot) || 0;
1107
1176
  const r = rangeAcc.get(ot);
1108
1177
  const avg = r.mass > 0 ? r.sum / r.mass : 0;
1109
1178
  outcomeMap.set(ot, {
1110
- atLeastOneProbability: total,
1111
- allProbability: total,
1112
- // single aggregate PMF: same value
1179
+ atLeastOneProbability: this.probAtLeastOne(ot),
1180
+ allProbability: this.probAtLeastK(ot, n),
1113
1181
  damageRange: { min: r.min ?? 0, avg, max: r.max ?? 0 }
1114
1182
  });
1115
1183
  }
@@ -1294,11 +1362,11 @@ var _DiceQuery = class _DiceQuery {
1294
1362
  return [a, b, any, none];
1295
1363
  }
1296
1364
  };
1297
- __publicField(_DiceQuery, "DEFAULT_OUTCOMES", [
1365
+ _DiceQuery.DEFAULT_OUTCOMES = [
1298
1366
  "hit",
1299
1367
  "crit",
1300
1368
  "missNone"
1301
- ]);
1369
+ ];
1302
1370
  var DiceQuery = _DiceQuery;
1303
1371
  var pmfCache = new LRUCache(1e3);
1304
1372
  var _PMF = class _PMF {
@@ -1308,14 +1376,6 @@ var _PMF = class _PMF {
1308
1376
  this.normalized = normalized;
1309
1377
  this.identifier = identifier;
1310
1378
  this._preservedProvenance = _preservedProvenance;
1311
- // Cached computed values
1312
- __publicField(this, "_support");
1313
- __publicField(this, "_min");
1314
- __publicField(this, "_max");
1315
- __publicField(this, "_totalMass");
1316
- __publicField(this, "_mean");
1317
- __publicField(this, "_variance");
1318
- __publicField(this, "_stdev");
1319
1379
  }
1320
1380
  static empty(epsilon = EPS, identifier = "empty") {
1321
1381
  return new _PMF(/* @__PURE__ */ new Map(), epsilon, false, identifier);
@@ -1355,8 +1415,14 @@ var _PMF = class _PMF {
1355
1415
  if (p === 1) return successPMF.scaleMass(1);
1356
1416
  const eps = successPMF.epsilon ?? failurePMF.epsilon;
1357
1417
  const id = `branch(${failurePMF.identifier}*${q.toFixed(6)} + ${successPMF.identifier}*${p.toFixed(6)})`;
1358
- const out = _PMF.empty(eps, id).addScaled(failurePMF, q).addScaled(successPMF, p);
1359
- return out;
1418
+ const resultMap = /* @__PURE__ */ new Map();
1419
+ for (const [damageValue, bin] of failurePMF.map) {
1420
+ _PMF.mergeInto(resultMap, damageValue, _PMF.scaleBin(bin, q));
1421
+ }
1422
+ for (const [damageValue, bin] of successPMF.map) {
1423
+ _PMF.mergeInto(resultMap, damageValue, _PMF.scaleBin(bin, p));
1424
+ }
1425
+ return new _PMF(resultMap, eps, false, id);
1360
1426
  }
1361
1427
  /**
1362
1428
  * withProbability()
@@ -1404,8 +1470,8 @@ var _PMF = class _PMF {
1404
1470
  * @param fallback PMF to apply when this PMF is *not* selected.
1405
1471
  * @returns A new PMF representing the weighted mixture of this PMF and the fallback.
1406
1472
  */
1407
- gate(p, zero) {
1408
- return _PMF.branch(this, zero, p);
1473
+ gate(p, fallback) {
1474
+ return _PMF.branch(this, fallback, p);
1409
1475
  }
1410
1476
  /**
1411
1477
  * PMF.exclusive()
@@ -1488,13 +1554,23 @@ var _PMF = class _PMF {
1488
1554
  *
1489
1555
  * @returns New PMF with attr field populated in each bin
1490
1556
  */
1491
- withAttribution() {
1557
+ /**
1558
+ * Returns true if this PMF already carries damage attribution metadata.
1559
+ *
1560
+ * Only the first positive-damage bin is inspected (parser-generated PMFs
1561
+ * populate `attr` uniformly), so this is O(1) in practice.
1562
+ */
1563
+ hasAttribution() {
1492
1564
  for (const [damage, bin] of this.map) {
1493
1565
  if (damage !== 0 && bin.attr && Object.keys(bin.attr).length > 0) {
1494
- return this;
1566
+ return true;
1495
1567
  }
1496
1568
  if (damage > 0) break;
1497
1569
  }
1570
+ return false;
1571
+ }
1572
+ withAttribution() {
1573
+ if (this.hasAttribution()) return this;
1498
1574
  const newMap = /* @__PURE__ */ new Map();
1499
1575
  for (const [damage, bin] of this.map) {
1500
1576
  const attr = {};
@@ -1611,7 +1687,7 @@ var _PMF = class _PMF {
1611
1687
  */
1612
1688
  replicate(n) {
1613
1689
  if (!Number.isInteger(n) || n <= 0) {
1614
- throw new Error("combineN(n): n must be a positive integer");
1690
+ throw new Error("replicate(n): n must be a positive integer");
1615
1691
  }
1616
1692
  if (n === 1) return [this];
1617
1693
  return Array.from({ length: n }, () => this);
@@ -1643,7 +1719,6 @@ var _PMF = class _PMF {
1643
1719
  if (normalizationFactor === 0) return this;
1644
1720
  const normalizedMap = /* @__PURE__ */ new Map();
1645
1721
  for (const [damageValue, probabilityBin] of this.map) {
1646
- const normalizedProbability = probabilityBin.p / normalizationFactor;
1647
1722
  const normalizedCount = {};
1648
1723
  for (const labelKey in probabilityBin.count) {
1649
1724
  normalizedCount[labelKey] = probabilityBin.count[labelKey] / normalizationFactor;
@@ -1656,7 +1731,7 @@ var _PMF = class _PMF {
1656
1731
  }
1657
1732
  }
1658
1733
  normalizedMap.set(damageValue, {
1659
- p: normalizedProbability,
1734
+ p: probabilityBin.p / normalizationFactor,
1660
1735
  count: normalizedCount,
1661
1736
  attr: normalizedAttributes
1662
1737
  });
@@ -1679,22 +1754,23 @@ var _PMF = class _PMF {
1679
1754
  for (const [damageValue, probabilityBin] of this.map) {
1680
1755
  const shouldKeep = probabilityBin.p >= eps || keepFinalBin && damageValue === maxKey;
1681
1756
  if (!shouldKeep) continue;
1682
- for (const labelKey in probabilityBin.count) {
1683
- if (Math.abs(probabilityBin.count[labelKey] || 0) < eps) {
1684
- delete probabilityBin.count[labelKey];
1757
+ const cleanedBin = _PMF.cloneBin(probabilityBin);
1758
+ for (const labelKey in cleanedBin.count) {
1759
+ if (Math.abs(cleanedBin.count[labelKey] || 0) < eps) {
1760
+ delete cleanedBin.count[labelKey];
1685
1761
  }
1686
1762
  }
1687
- if (probabilityBin.attr) {
1688
- for (const labelKey in probabilityBin.attr) {
1689
- if (Math.abs(probabilityBin.attr[labelKey] || 0) < eps) {
1690
- delete probabilityBin.attr[labelKey];
1763
+ if (cleanedBin.attr) {
1764
+ for (const labelKey in cleanedBin.attr) {
1765
+ if (Math.abs(cleanedBin.attr[labelKey] || 0) < eps) {
1766
+ delete cleanedBin.attr[labelKey];
1691
1767
  }
1692
1768
  }
1693
- if (Object.keys(probabilityBin.attr).length === 0) {
1694
- probabilityBin.attr = void 0;
1769
+ if (Object.keys(cleanedBin.attr).length === 0) {
1770
+ cleanedBin.attr = void 0;
1695
1771
  }
1696
1772
  }
1697
- compactedMap.set(damageValue, probabilityBin);
1773
+ compactedMap.set(damageValue, cleanedBin);
1698
1774
  }
1699
1775
  return new _PMF(compactedMap, eps, this.normalized, this.identifier);
1700
1776
  }
@@ -1761,14 +1837,33 @@ var _PMF = class _PMF {
1761
1837
  }
1762
1838
  return this._stdev;
1763
1839
  }
1840
+ /** Deep-copies a Bin, cloning its count and (optional) attr maps. */
1841
+ static cloneBin(bin) {
1842
+ return {
1843
+ p: bin.p,
1844
+ count: { ...bin.count },
1845
+ attr: bin.attr ? { ...bin.attr } : void 0
1846
+ };
1847
+ }
1848
+ /** Returns a new Bin with p, count, and attr all multiplied by `factor`. */
1849
+ static scaleBin(bin, factor) {
1850
+ const count = {};
1851
+ for (const k in bin.count) {
1852
+ count[k] = bin.count[k] * factor;
1853
+ }
1854
+ let attr;
1855
+ if (bin.attr) {
1856
+ attr = {};
1857
+ for (const k in bin.attr) {
1858
+ attr[k] = bin.attr[k] * factor;
1859
+ }
1860
+ }
1861
+ return { p: bin.p * factor, count, attr };
1862
+ }
1764
1863
  static mergeInto(destinationMap, damageValue, binToAdd) {
1765
1864
  const existingBin = destinationMap.get(damageValue);
1766
1865
  if (!existingBin) {
1767
- destinationMap.set(damageValue, {
1768
- p: binToAdd.p,
1769
- count: { ...binToAdd.count },
1770
- attr: binToAdd.attr ? { ...binToAdd.attr } : void 0
1771
- });
1866
+ destinationMap.set(damageValue, _PMF.cloneBin(binToAdd));
1772
1867
  return;
1773
1868
  }
1774
1869
  existingBin.p += binToAdd.p;
@@ -1799,29 +1894,14 @@ var _PMF = class _PMF {
1799
1894
  if (probability === 0) return this;
1800
1895
  const resultMap = /* @__PURE__ */ new Map();
1801
1896
  for (const [dmg, bin] of this.map) {
1802
- resultMap.set(dmg, {
1803
- p: bin.p,
1804
- count: { ...bin.count },
1805
- attr: bin.attr ? { ...bin.attr } : void 0
1806
- });
1897
+ resultMap.set(dmg, _PMF.cloneBin(bin));
1807
1898
  }
1808
1899
  for (const [damageValue, probabilityBin] of branch.map) {
1809
- const scaledCount = {};
1810
- for (const k in probabilityBin.count) {
1811
- scaledCount[k] = probability * probabilityBin.count[k];
1812
- }
1813
- let scaledAttributes;
1814
- if (probabilityBin.attr) {
1815
- scaledAttributes = {};
1816
- for (const k in probabilityBin.attr) {
1817
- scaledAttributes[k] = probability * probabilityBin.attr[k];
1818
- }
1819
- }
1820
- _PMF.mergeInto(resultMap, damageValue, {
1821
- p: probability * probabilityBin.p,
1822
- count: scaledCount,
1823
- attr: scaledAttributes
1824
- });
1900
+ _PMF.mergeInto(
1901
+ resultMap,
1902
+ damageValue,
1903
+ _PMF.scaleBin(probabilityBin, probability)
1904
+ );
1825
1905
  }
1826
1906
  return new _PMF(
1827
1907
  resultMap,
@@ -1834,22 +1914,7 @@ var _PMF = class _PMF {
1834
1914
  if (factor === 1) return this;
1835
1915
  const scaledMap = /* @__PURE__ */ new Map();
1836
1916
  for (const [damageValue, probabilityBin] of this.map) {
1837
- const scaledCount = {};
1838
- for (const labelKey in probabilityBin.count) {
1839
- scaledCount[labelKey] = probabilityBin.count[labelKey] * factor;
1840
- }
1841
- let scaledAttributes;
1842
- if (probabilityBin.attr) {
1843
- scaledAttributes = {};
1844
- for (const labelKey in probabilityBin.attr) {
1845
- scaledAttributes[labelKey] = probabilityBin.attr[labelKey] * factor;
1846
- }
1847
- }
1848
- scaledMap.set(damageValue, {
1849
- p: probabilityBin.p * factor,
1850
- count: scaledCount,
1851
- attr: scaledAttributes
1852
- });
1917
+ scaledMap.set(damageValue, _PMF.scaleBin(probabilityBin, factor));
1853
1918
  }
1854
1919
  return new _PMF(
1855
1920
  scaledMap,
@@ -1862,11 +1927,11 @@ var _PMF = class _PMF {
1862
1927
  const transformedMap = /* @__PURE__ */ new Map();
1863
1928
  for (const [originalDamage, probabilityBin] of this.map) {
1864
1929
  const transformedDamage = damageTransformFunction(originalDamage);
1865
- _PMF.mergeInto(transformedMap, transformedDamage, {
1866
- p: probabilityBin.p,
1867
- count: { ...probabilityBin.count },
1868
- attr: probabilityBin.attr ? { ...probabilityBin.attr } : void 0
1869
- });
1930
+ _PMF.mergeInto(
1931
+ transformedMap,
1932
+ transformedDamage,
1933
+ _PMF.cloneBin(probabilityBin)
1934
+ );
1870
1935
  }
1871
1936
  return new _PMF(
1872
1937
  transformedMap,
@@ -1881,14 +1946,21 @@ var _PMF = class _PMF {
1881
1946
  }
1882
1947
  getPMFCombineCacheKey(p1, p2, eps, raw) {
1883
1948
  const [id1, id2] = [p1.identifier, p2.identifier].sort();
1884
- const fp = (x) => {
1885
- const m = x.mass().toFixed(12);
1886
- const n = x.map.size;
1949
+ return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${p1.fingerprint()}|${p2.fingerprint()}`;
1950
+ }
1951
+ /**
1952
+ * A small content fingerprint (mass + bin count + face sum) so convolution
1953
+ * cache keys change if the underlying numbers do. Memoized because a PMF is
1954
+ * immutable once constructed — this avoids re-summing every key on each
1955
+ * convolve() call (including cache hits).
1956
+ */
1957
+ fingerprint() {
1958
+ if (this._fingerprint === void 0) {
1887
1959
  let faceSum = 0;
1888
- for (const k of x.map.keys()) faceSum += k;
1889
- return `${m}|${n}|${faceSum}`;
1890
- };
1891
- return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${fp(p1)}|${fp(p2)}`;
1960
+ for (const k of this.map.keys()) faceSum += k;
1961
+ this._fingerprint = `${this.mass().toFixed(12)}|${this.map.size}|${faceSum}`;
1962
+ }
1963
+ return this._fingerprint;
1892
1964
  }
1893
1965
  convolve(other, eps, raw = false) {
1894
1966
  const epsilon = eps ?? this.epsilon;
@@ -1901,25 +1973,34 @@ var _PMF = class _PMF {
1901
1973
  if (cached) return cached;
1902
1974
  const combinedMap = /* @__PURE__ */ new Map();
1903
1975
  for (const [aVal, aBin] of A.map) {
1976
+ const ap = aBin.p;
1977
+ const aCount = aBin.count;
1978
+ const aAttr = aBin.attr;
1904
1979
  for (const [bVal, bBin] of B.map) {
1905
- const p = aBin.p * bBin.p;
1980
+ const bp = bBin.p;
1906
1981
  const dmg = aVal + bVal;
1907
- const count = {};
1908
- for (const k in aBin.count)
1909
- count[k] = (count[k] || 0) + aBin.count[k] * bBin.p;
1982
+ let dest = combinedMap.get(dmg);
1983
+ if (dest === void 0) {
1984
+ dest = { p: 0, count: {} };
1985
+ combinedMap.set(dmg, dest);
1986
+ }
1987
+ dest.p += ap * bp;
1988
+ const dc = dest.count;
1989
+ for (const k in aCount) dc[k] = (dc[k] || 0) + aCount[k] * bp;
1910
1990
  for (const k in bBin.count)
1911
- count[k] = (count[k] || 0) + bBin.count[k] * aBin.p;
1912
- let attr;
1913
- if (aBin.attr || bBin.attr) {
1914
- attr = {};
1915
- if (aBin.attr)
1916
- for (const k in aBin.attr)
1917
- attr[k] = (attr[k] || 0) + aBin.attr[k] * bBin.p;
1991
+ dc[k] = (dc[k] || 0) + bBin.count[k] * ap;
1992
+ if (aAttr || bBin.attr) {
1993
+ let da = dest.attr;
1994
+ if (da === void 0) {
1995
+ da = {};
1996
+ dest.attr = da;
1997
+ }
1998
+ if (aAttr)
1999
+ for (const k in aAttr) da[k] = (da[k] || 0) + aAttr[k] * bp;
1918
2000
  if (bBin.attr)
1919
2001
  for (const k in bBin.attr)
1920
- attr[k] = (attr[k] || 0) + bBin.attr[k] * aBin.p;
2002
+ da[k] = (da[k] || 0) + bBin.attr[k] * ap;
1921
2003
  }
1922
- _PMF.mergeInto(combinedMap, dmg, { p, count, attr });
1923
2004
  }
1924
2005
  }
1925
2006
  let result = new _PMF(
@@ -1930,10 +2011,10 @@ var _PMF = class _PMF {
1930
2011
  );
1931
2012
  const mExp = (raw ? A.mass() : 1) * (raw ? B.mass() : 1);
1932
2013
  const mGot = result.mass();
1933
- if (mExp !== 0 && Math.abs(mGot - mExp) > epsilon) {
2014
+ if (mExp !== 0 && mGot !== 0 && Math.abs(mGot - mExp) > epsilon) {
1934
2015
  result = result.scaleMass(mExp / mGot);
1935
2016
  }
1936
- if (!raw && Math.abs(result.mass() - 1) > epsilon)
2017
+ if (!raw && mGot !== 0 && Math.abs(result.mass() - 1) > epsilon)
1937
2018
  result = result.normalize();
1938
2019
  pmfCache?.set(cacheKey, result);
1939
2020
  return result;
@@ -1942,27 +2023,6 @@ var _PMF = class _PMF {
1942
2023
  combineRaw(other, eps) {
1943
2024
  return this.convolve(other, eps, true);
1944
2025
  }
1945
- // Collapse repeated identical PMFs using power() and return a sorted list
1946
- // Temporarily disabled for now. This was used for a performance optimization, but can lose data provenance.
1947
- // private static collapseIdentical(pmfList: PMF[], eps: number): PMF[] {
1948
- // const grouped = new Map<string, { pmf: PMF; count: number }>();
1949
- // for (const pmf of pmfList) {
1950
- // const id = pmf.identifier;
1951
- // const g = grouped.get(id);
1952
- // if (g) g.count++;
1953
- // else grouped.set(id, { pmf, count: 1 });
1954
- // }
1955
- // if (grouped.size >= pmfList.length) return pmfList;
1956
- // const collapsed: PMF[] = [];
1957
- // for (const { pmf, count } of grouped.values()) {
1958
- // collapsed.push(count > 1 ? pmf.power(count, eps) : pmf);
1959
- // }
1960
- // // Stable order for better cache locality
1961
- // collapsed.sort((a, b) =>
1962
- // a.identifier < b.identifier ? -1 : a.identifier > b.identifier ? 1 : 0
1963
- // );
1964
- // return collapsed;
1965
- // }
1966
2026
  // Reduce a list of PMFs by left-folding convolve() with the given eps
1967
2027
  static reduceConvolveLeft(pmfList, eps) {
1968
2028
  let result = pmfList[0];
@@ -1986,12 +2046,23 @@ var _PMF = class _PMF {
1986
2046
  if (pmfList.length === 1) return pmfList[0];
1987
2047
  return _PMF.reduceConvolveLeft(pmfList, eps);
1988
2048
  }
2049
+ /**
2050
+ * Returns a plain, JSON-serializable representation of this PMF.
2051
+ *
2052
+ * Follows the standard `toJSON` contract, so `JSON.stringify(pmf)` produces
2053
+ * the expected output (no double-encoding). Use {@link PMF.fromJSON} to
2054
+ * reconstruct, or {@link PMF.toJSONString} if you need the string directly.
2055
+ */
1989
2056
  toJSON() {
1990
- return JSON.stringify({
2057
+ return {
1991
2058
  bins: [...this.map.entries()],
1992
2059
  normalized: this.normalized,
1993
2060
  identifier: this.identifier
1994
- });
2061
+ };
2062
+ }
2063
+ /** Serializes this PMF to a JSON string (equivalent to `JSON.stringify(pmf)`). */
2064
+ toJSONString() {
2065
+ return JSON.stringify(this);
1995
2066
  }
1996
2067
  static fromJSON(jsonData) {
1997
2068
  return new _PMF(
@@ -2062,7 +2133,6 @@ var _PMF = class _PMF {
2062
2133
  }
2063
2134
  return new _PMF(prunedMap, epsRel, false, `prune(${this.identifier})`);
2064
2135
  }
2065
- /** NEW - REVIEW IF THESE ARE USEFUL OR DUPLCIATIVE? */
2066
2136
  /** Probability mass at exactly x. */
2067
2137
  pAt(x) {
2068
2138
  return this.map.get(x)?.p ?? 0;
@@ -2144,9 +2214,8 @@ var _PMF = class _PMF {
2144
2214
  }
2145
2215
  tailProbGE(t) {
2146
2216
  let s = 0;
2147
- for (const [x, rec] of this) {
2148
- const p = typeof rec === "number" ? rec : rec.p;
2149
- if (p > 0 && x >= t) s += p;
2217
+ for (const [x, bin] of this) {
2218
+ if (bin.p > 0 && x >= t) s += bin.p;
2150
2219
  }
2151
2220
  return s;
2152
2221
  }
@@ -2207,6 +2276,11 @@ var _PMF = class _PMF {
2207
2276
  * - pAny: Probability that at least one success occurred
2208
2277
  */
2209
2278
  static firstSuccessWeights(pSuccess, pSpecial, n) {
2279
+ if (!Number.isFinite(pSuccess) || !Number.isFinite(pSpecial) || pSuccess < 0 || pSuccess > 1 || pSpecial < 0 || pSpecial - pSuccess > EPS) {
2280
+ throw new Error(
2281
+ `firstSuccessWeights: require 0 <= pSpecial <= pSuccess <= 1 (got pSuccess=${pSuccess}, pSpecial=${pSpecial})`
2282
+ );
2283
+ }
2210
2284
  const pFail = 1 - pSuccess;
2211
2285
  const pFailAll = Math.pow(pFail, n);
2212
2286
  const pAny = 1 - pFailAll;
@@ -2222,13 +2296,12 @@ var _PMF = class _PMF {
2222
2296
  const round = (x) => rounding === "floor" ? Math.floor(x) : rounding === "ceil" ? Math.ceil(x) : rounding === "round" ? Math.round(x) : x;
2223
2297
  const probs = /* @__PURE__ */ new Map();
2224
2298
  const counts = /* @__PURE__ */ new Map();
2225
- for (const [v, rec] of this) {
2226
- if (Math.abs(rec.p) < eps) continue;
2299
+ for (const [v, bin] of this) {
2300
+ if (Math.abs(bin.p) < eps) continue;
2227
2301
  const u = round(f(v));
2228
- probs.set(u, (probs.get(u) ?? 0) + rec.p);
2302
+ probs.set(u, (probs.get(u) ?? 0) + bin.p);
2229
2303
  if (preserveCounts) {
2230
- const rec2 = this.map.get(v);
2231
- const src = typeof rec2 === "number" ? void 0 : rec2?.count;
2304
+ const src = bin.count;
2232
2305
  if (src) {
2233
2306
  const dest = counts.get(u) ?? {};
2234
2307
  for (const k in src) {
@@ -2281,17 +2354,15 @@ var _PMF = class _PMF {
2281
2354
  }
2282
2355
  };
2283
2356
  // Unique ID generator for anonymous PMFs to avoid cache key collisions
2284
- __publicField(_PMF, "__anonIdCounter", 1);
2357
+ _PMF.__anonIdCounter = 1;
2285
2358
  var PMF = _PMF;
2286
2359
 
2287
2360
  // src/pmf/mixture.ts
2288
2361
  var Mixture = class _Mixture {
2289
2362
  constructor(eps = EPS) {
2290
- __publicField(this, "totals", /* @__PURE__ */ new Map());
2363
+ this.totals = /* @__PURE__ */ new Map();
2291
2364
  // raw mass per outcome (pre-normalization)
2292
- __publicField(this, "labelMass", /* @__PURE__ */ new Map());
2293
- // raw mass per outcome per label
2294
- __publicField(this, "eps");
2365
+ this.labelMass = /* @__PURE__ */ new Map();
2295
2366
  this.eps = Number.isFinite(eps) ? eps : EPS;
2296
2367
  }
2297
2368
  /** Remove all accumulated state. */
@@ -2315,9 +2386,8 @@ var Mixture = class _Mixture {
2315
2386
  */
2316
2387
  add(label, pmf, weight = 1) {
2317
2388
  if (!Number.isFinite(weight) || weight <= 0) return this;
2318
- for (const [v, binOrNumber] of pmf) {
2319
- const isNumber = typeof binOrNumber === "number";
2320
- const p = isNumber ? binOrNumber : binOrNumber?.p ?? 0;
2389
+ for (const [v, bin] of pmf) {
2390
+ const p = bin.p;
2321
2391
  if (p <= 0) continue;
2322
2392
  const add = weight * p;
2323
2393
  if (!Number.isFinite(add) || Math.abs(add) < this.eps) continue;
@@ -2405,14 +2475,28 @@ var Mixture = class _Mixture {
2405
2475
  }
2406
2476
  };
2407
2477
 
2478
+ // src/common/errors.ts
2479
+ var DiceParseError = class _DiceParseError extends Error {
2480
+ constructor(message, options) {
2481
+ super(message);
2482
+ this.name = "DiceParseError";
2483
+ this.expression = options?.expression;
2484
+ this.cause = options?.cause;
2485
+ Object.setPrototypeOf(this, _DiceParseError.prototype);
2486
+ }
2487
+ };
2488
+
2408
2489
  // src/parser/dice.ts
2490
+ var MAX_BINARY_OUTCOMES = 1e8;
2409
2491
  var Dice = class _Dice {
2410
2492
  constructor(x = 0) {
2411
- __publicField(this, "faces", {});
2412
- __publicField(this, "privateData", {});
2413
- __publicField(this, "outcomeData", {});
2414
- __publicField(this, "hasHitDistributionCalculated", false);
2415
- __publicField(this, "identifier");
2493
+ this.faces = {};
2494
+ this.privateData = {};
2495
+ // Partial: the object starts empty and gains keys as outcomes are recorded,
2496
+ // so the type must not claim every OutcomeType is present. (Previously typed
2497
+ // as a full Record via an `as` cast, which lied about missing keys.)
2498
+ this.outcomeData = {};
2499
+ this.hasHitDistributionCalculated = false;
2416
2500
  if (x <= 0) return;
2417
2501
  for (let i = 1; i <= x; i++) {
2418
2502
  this.faces[i] = 1;
@@ -2463,27 +2547,27 @@ var Dice = class _Dice {
2463
2547
  // TODO this can be private later if we change how testing works
2464
2548
  calculateHitDistribution() {
2465
2549
  const hitValues = {};
2550
+ const subtractedOutcomes = [
2551
+ this.outcomeData.crit,
2552
+ this.outcomeData.missNone,
2553
+ this.outcomeData.missDamage,
2554
+ this.outcomeData.saveHalf,
2555
+ this.outcomeData.saveFail,
2556
+ this.outcomeData.pc
2557
+ ];
2466
2558
  for (const [face, totalCount] of Object.entries(this.faces)) {
2467
2559
  const numFace = Number(face);
2468
2560
  let hitCount = totalCount;
2469
- for (const outcomeType of [
2470
- "crit",
2471
- "missNone",
2472
- "missDamage",
2473
- "saveHalf",
2474
- "saveFail",
2475
- "pc"
2476
- ]) {
2477
- const distribution = this.getOutcomeDistribution(outcomeType);
2478
- if (distribution && distribution[numFace]) {
2479
- hitCount -= distribution[numFace];
2561
+ for (const distribution of subtractedOutcomes) {
2562
+ const outcomeCount = distribution?.[numFace];
2563
+ if (outcomeCount) {
2564
+ hitCount -= outcomeCount;
2480
2565
  }
2481
2566
  }
2482
2567
  if (numFace === 0) {
2483
2568
  hitCount = 0;
2484
2569
  }
2485
2570
  if (hitCount < 0) {
2486
- console.error("hitCount is <=0?", face, totalCount, hitCount);
2487
2571
  hitCount = 0;
2488
2572
  }
2489
2573
  hitValues[numFace] = hitCount;
@@ -2502,13 +2586,18 @@ var Dice = class _Dice {
2502
2586
  const result = diceConstructor ? diceConstructor() : new _Dice();
2503
2587
  const isScalar = typeof other === "number";
2504
2588
  const keys1 = this.keys();
2589
+ const keys2 = isScalar ? [] : other.keys();
2590
+ if (!isScalar && keys1.length * keys2.length > MAX_BINARY_OUTCOMES) {
2591
+ throw new DiceParseError(
2592
+ `Dice operation over ${keys1.length}\xD7${keys2.length} face pairs exceeds the maximum of ${MAX_BINARY_OUTCOMES}`
2593
+ );
2594
+ }
2505
2595
  for (const key1 of keys1) {
2506
2596
  const value1 = this.faces[key1];
2507
2597
  if (isScalar) {
2508
2598
  const resultKey = op(key1, other);
2509
2599
  result.increment(resultKey, value1);
2510
2600
  } else {
2511
- const keys2 = other.keys();
2512
2601
  for (const key2 of keys2) {
2513
2602
  const value2 = other.faces[key2];
2514
2603
  const resultKey = op(key1, key2);
@@ -2530,7 +2619,7 @@ var Dice = class _Dice {
2530
2619
  result.outcomeData = { ...this.outcomeData };
2531
2620
  return result;
2532
2621
  }
2533
- // PUBLIC FUNTIONS
2622
+ // PUBLIC FUNCTIONS
2534
2623
  getFaceEntries() {
2535
2624
  return Object.entries(this.faces).map(([k, v]) => [Number(k), v]);
2536
2625
  }
@@ -2660,10 +2749,12 @@ var Dice = class _Dice {
2660
2749
  }
2661
2750
  reroll(toReroll) {
2662
2751
  const rerollDice = typeof toReroll === "number" ? _Dice.scalar(toReroll) : toReroll;
2663
- const removed = this.removeFaces(rerollDice.keys());
2752
+ const rerollKeys = rerollDice.keys();
2753
+ const rerollSet = new Set(rerollKeys);
2754
+ const removed = this.removeFaces(rerollKeys);
2664
2755
  let result = new _Dice();
2665
2756
  for (const face of this.keys()) {
2666
- const wasRerolled = rerollDice.keys().includes(face);
2757
+ const wasRerolled = rerollSet.has(face);
2667
2758
  result = result.combine(removed);
2668
2759
  if (wasRerolled) {
2669
2760
  result = result.combine(this);
@@ -2731,17 +2822,10 @@ var Dice = class _Dice {
2731
2822
  const missDistro = this.getOutcomeDistribution("missDamage") || {};
2732
2823
  const saveDistro = this.getOutcomeDistribution("saveHalf") || {};
2733
2824
  const pcDistro = this.getOutcomeDistribution("pc") || {};
2734
- let isSaveHalf = false;
2735
- for (const halfDamage of Object.keys(saveDistro).map(Number)) {
2736
- const fullDamage = halfDamage * 2;
2737
- if (fullDamage > 0 && hitDistro[fullDamage]) {
2738
- isSaveHalf = true;
2739
- break;
2740
- }
2741
- }
2825
+ const isSaveHalf = Object.keys(saveDistro).length > 0;
2742
2826
  const isDCCheck = this.privateData.isDCCheck === true;
2743
2827
  const clampNonNeg = (x) => x < 0 && x > -1e-15 ? 0 : x;
2744
- for (const [faceStr, faceCountRaw] of Object.entries(this.getFaceMap())) {
2828
+ for (const [faceStr, faceCountRaw] of Object.entries(this.faces)) {
2745
2829
  const face = Number(faceStr);
2746
2830
  const faceCount = Number(faceCountRaw);
2747
2831
  if (faceCount <= 0) continue;
@@ -2815,14 +2899,14 @@ var Dice = class _Dice {
2815
2899
  map.set(face, bin);
2816
2900
  }
2817
2901
  const identifier = this.identifier || "ERROR";
2818
- if (identifier === "ERROR") {
2819
- console.error("Dice identifier is undefined", this);
2820
- }
2821
2902
  return new PMF(map, numEpsilon, true, identifier).compact(numEpsilon, true);
2822
2903
  }
2823
2904
  };
2824
2905
 
2825
2906
  // src/parser/parser.ts
2907
+ var MAX_DIE_SIDES = 1e6;
2908
+ var MAX_DICE_COUNT = 1e4;
2909
+ var MAX_KEEP_OUTCOMES = 1e6;
2826
2910
  var parseCache = new LRUCache(1e3);
2827
2911
  function parse(expression, n = 0) {
2828
2912
  const cleaned = expression.replace(/ /g, "").toLowerCase();
@@ -2832,20 +2916,21 @@ function parse(expression, n = 0) {
2832
2916
  if (cached) return cached;
2833
2917
  }
2834
2918
  const chars = [...cleaned];
2835
- let result = void 0;
2919
+ let result;
2836
2920
  try {
2837
2921
  result = parseExpression(chars, n);
2838
2922
  } catch (error) {
2839
- throw new Error(`Cannot parse dice expression [${expression}]: ${error}`);
2840
- }
2841
- try {
2842
- result.privateData = result.privateData || {};
2843
- result.identifier = cleaned;
2844
- } catch {
2923
+ throw new DiceParseError(
2924
+ `Cannot parse dice expression [${expression}]: ${error}`,
2925
+ { expression, cause: error }
2926
+ );
2845
2927
  }
2928
+ result.privateData = result.privateData || {};
2929
+ result.identifier = cleaned;
2846
2930
  if (chars.length > 0) {
2847
- throw new Error(
2848
- `Unexpected token: '${chars[0]}' from expression: '${expression}'`
2931
+ throw new DiceParseError(
2932
+ `Unexpected token: '${chars[0]}' from expression: '${expression}'`,
2933
+ { expression }
2849
2934
  );
2850
2935
  }
2851
2936
  const resultPMF = result.toPMF(-1);
@@ -2999,7 +3084,7 @@ function multiplyDiceByDice(d1, d2) {
2999
3084
  if (typeof d1 === "number") d1 = Dice.scalar(d1);
3000
3085
  if (typeof d2 === "number") d2 = Dice.scalar(d2);
3001
3086
  const result = new Dice();
3002
- const faces = {};
3087
+ const faces = /* @__PURE__ */ new Map();
3003
3088
  let normalizationFactor = 1;
3004
3089
  for (const key of d1.keys()) {
3005
3090
  let face;
@@ -3007,17 +3092,21 @@ function multiplyDiceByDice(d1, d2) {
3007
3092
  continue;
3008
3093
  }
3009
3094
  if (d2.privateData.keep) {
3095
+ const faceCount = d2.keys().length;
3096
+ if (Math.pow(faceCount, key) > MAX_KEEP_OUTCOMES) {
3097
+ throw new DiceParseError(
3098
+ `Keep enumeration of ${faceCount}^${key} outcomes exceeds the maximum of ${MAX_KEEP_OUTCOMES}`
3099
+ );
3100
+ }
3010
3101
  const repeat = Array(key).fill(d2);
3011
3102
  face = opDice(repeat, d2.privateData.keep);
3012
3103
  } else {
3013
3104
  face = multiplyDice(key, d2);
3014
3105
  }
3015
3106
  normalizationFactor *= face.total();
3016
- faces[key] = face;
3107
+ faces.set(key, face);
3017
3108
  }
3018
- for (const key of Object.keys(faces)) {
3019
- const k = parseFloat(key);
3020
- const face = faces[k];
3109
+ for (const [k, face] of faces) {
3021
3110
  const count = d1.get(k);
3022
3111
  result.combineInPlace(
3023
3112
  face.normalize(count * normalizationFactor / face.total())
@@ -3027,6 +3116,11 @@ function multiplyDiceByDice(d1, d2) {
3027
3116
  return result;
3028
3117
  }
3029
3118
  function multiplyDice(n, d2) {
3119
+ if (n > MAX_DICE_COUNT) {
3120
+ throw new DiceParseError(
3121
+ `Dice count ${n} exceeds the maximum of ${MAX_DICE_COUNT}`
3122
+ );
3123
+ }
3030
3124
  if (n === 0) return new Dice(0);
3031
3125
  if (n === 1) return d2;
3032
3126
  const half = Math.floor(n / 2);
@@ -3109,9 +3203,14 @@ function parseDice(s, n) {
3109
3203
  return;
3110
3204
  }
3111
3205
  const sides = parseNumber(s, n);
3206
+ if (sides > MAX_DIE_SIDES) {
3207
+ throw new DiceParseError(
3208
+ `Die size ${sides} exceeds the maximum of ${MAX_DIE_SIDES}`
3209
+ );
3210
+ }
3112
3211
  let result = new Dice(sides);
3113
3212
  if (rerollOne) {
3114
- result = result.deleteFace(1).combine(result);
3213
+ result = result.reroll(1);
3115
3214
  }
3116
3215
  return result;
3117
3216
  }
@@ -3323,15 +3422,14 @@ var configComplexityScore = (config) => {
3323
3422
  };
3324
3423
  var RollBuilder = class _RollBuilder {
3325
3424
  constructor(countOrConfigs = 1) {
3326
- __publicField(this, "subRollConfigs");
3327
3425
  // --- Dice Shortcut Methods ---
3328
- __publicField(this, "d4", () => this.d(4));
3329
- __publicField(this, "d6", () => this.d(6));
3330
- __publicField(this, "d8", () => this.d(8));
3331
- __publicField(this, "d10", () => this.d(10));
3332
- __publicField(this, "d12", () => this.d(12));
3333
- __publicField(this, "d20", () => this.d(20));
3334
- __publicField(this, "d100", () => this.d(100));
3426
+ this.d4 = () => this.d(4);
3427
+ this.d6 = () => this.d(6);
3428
+ this.d8 = () => this.d(8);
3429
+ this.d10 = () => this.d(10);
3430
+ this.d12 = () => this.d(12);
3431
+ this.d20 = () => this.d(20);
3432
+ this.d100 = () => this.d(100);
3335
3433
  if (typeof countOrConfigs === "number") {
3336
3434
  const count = countOrConfigs;
3337
3435
  if (isNaN(count)) throw new Error("Invalid NaN value for count");
@@ -3838,10 +3936,10 @@ var RollBuilder = class _RollBuilder {
3838
3936
  }
3839
3937
  // These methods are implemented via prototype augmentation in ac.ts and dc.ts
3840
3938
  // They are declared here to provide proper TypeScript types
3841
- ac(targetAC) {
3939
+ ac(_targetAC) {
3842
3940
  throw new Error("ac() should be implemented via prototype augmentation");
3843
3941
  }
3844
- dc(saveDC) {
3942
+ dc(_saveDC) {
3845
3943
  throw new Error("dc() should be implemented via prototype augmentation");
3846
3944
  }
3847
3945
  };
@@ -3958,7 +4056,6 @@ var AlwaysHitBuilder = class _AlwaysHitBuilder extends RollBuilder {
3958
4056
  );
3959
4057
  }
3960
4058
  super(baseRoll.getSubRollConfigs());
3961
- __publicField(this, "attackConfig");
3962
4059
  if (attackConfig) {
3963
4060
  this.attackConfig = { ...attackConfig };
3964
4061
  } else {
@@ -4008,8 +4105,6 @@ var AlwaysCritBuilder = class _AlwaysCritBuilder extends RollBuilder {
4008
4105
  );
4009
4106
  }
4010
4107
  super(baseRoll.getSubRollConfigs());
4011
- __publicField(this, "attackConfig");
4012
- __publicField(this, "fromAlwaysHit");
4013
4108
  if (attackConfig) {
4014
4109
  this.attackConfig = { ...attackConfig };
4015
4110
  } else {
@@ -4051,8 +4146,6 @@ var AlwaysCritBuilder = class _AlwaysCritBuilder extends RollBuilder {
4051
4146
  var ParsedRollBuilder = class _ParsedRollBuilder extends RollBuilder {
4052
4147
  constructor(expression) {
4053
4148
  super([]);
4054
- __publicField(this, "cachedPMF");
4055
- __publicField(this, "originalExpression");
4056
4149
  this.originalExpression = expression;
4057
4150
  this.cachedPMF = parse(expression, 0);
4058
4151
  }
@@ -4302,7 +4395,11 @@ function astFromRollConfigs(configs) {
4302
4395
  if (trials === 1) {
4303
4396
  node = perTrial;
4304
4397
  } else {
4305
- node = { type: "maxOf", count: trials, child: perTrial };
4398
+ node = {
4399
+ type: "maxOf",
4400
+ count: trials,
4401
+ child: perTrial
4402
+ };
4306
4403
  }
4307
4404
  } else if (trials === baseCount) {
4308
4405
  const base = { type: "sum", count: trials, child: node };
@@ -4507,7 +4604,7 @@ function computeMaxOfPMF(pmf, count, eps = defaultEps) {
4507
4604
  const support = pmf.support();
4508
4605
  const out = /* @__PURE__ */ new Map();
4509
4606
  if (count <= 6 && support.length <= 20) {
4510
- let dfs = function(rollsLeft, currentMax, probability) {
4607
+ let dfs2 = function(rollsLeft, currentMax, probability) {
4511
4608
  if (rollsLeft === 0) {
4512
4609
  out.set(currentMax, (out.get(currentMax) || 0) + probability);
4513
4610
  return;
@@ -4516,17 +4613,18 @@ function computeMaxOfPMF(pmf, count, eps = defaultEps) {
4516
4613
  const p = pmf.pAt(value);
4517
4614
  if (p > 0) {
4518
4615
  const newMax = Math.max(currentMax, value);
4519
- dfs(rollsLeft - 1, newMax, probability * p);
4616
+ dfs2(rollsLeft - 1, newMax, probability * p);
4520
4617
  }
4521
4618
  }
4522
4619
  };
4523
- dfs(count, -Infinity, 1);
4620
+ dfs2(count, -Infinity, 1);
4524
4621
  } else {
4525
4622
  const sortedSupport = [...support].sort((a, b) => a - b);
4623
+ let runningCdf = 0;
4526
4624
  for (const value of sortedSupport) {
4527
- const cdfAtValue = pmf.cdfAt(value);
4528
- const cdfAtValueMinus1 = value > sortedSupport[0] ? pmf.cdfAt(value - 1) : 0;
4529
- const probMax = Math.pow(cdfAtValue, count) - Math.pow(cdfAtValueMinus1, count);
4625
+ const prevCdf = runningCdf;
4626
+ runningCdf += pmf.pAt(value);
4627
+ const probMax = Math.pow(runningCdf, count) - Math.pow(prevCdf, count);
4530
4628
  if (probMax > eps) {
4531
4629
  out.set(value, probMax);
4532
4630
  }
@@ -4553,7 +4651,8 @@ function keepSumPMF(single, total, keep, highest, eps = defaultEps) {
4553
4651
  }
4554
4652
  }
4555
4653
  let state = /* @__PURE__ */ new Map();
4556
- const keyOf = (used, r) => `${used}|${r}`;
4654
+ const stride = total + 1;
4655
+ const keyOf = (used, r) => used * stride + r;
4557
4656
  state.set(keyOf(0, total), /* @__PURE__ */ new Map([[0, 1]]));
4558
4657
  const valuesDesc = highest ? [...sortedSupport].sort((a, b) => b - a) : [...sortedSupport].sort((a, b) => a - b);
4559
4658
  const binomPMF = (r, p) => {
@@ -4602,9 +4701,8 @@ function keepSumPMF(single, total, keep, highest, eps = defaultEps) {
4602
4701
  const pCond = Math.min(1, p / q);
4603
4702
  const next = /* @__PURE__ */ new Map();
4604
4703
  for (const [k, m] of state) {
4605
- const [usedStr, rStr] = k.split("|");
4606
- const used = parseInt(usedStr, 10);
4607
- const r = parseInt(rStr, 10);
4704
+ const used = Math.floor(k / stride);
4705
+ const r = k - used * stride;
4608
4706
  if (r === 0) {
4609
4707
  const destKey = keyOf(used, 0);
4610
4708
  const dest = next.get(destKey) ?? /* @__PURE__ */ new Map();
@@ -4775,8 +4873,8 @@ var AttackBuilder = class _AttackBuilder {
4775
4873
  const bonusPMF2 = bonusDicePMFs2.length ? PMF.convolveMany(bonusDicePMFs2, eps) : PMF.delta(0, eps);
4776
4874
  let pcrit2 = 0;
4777
4875
  let pmiss2 = 0;
4778
- for (const [r, rec] of d202) {
4779
- const pr = typeof rec === "number" ? rec : rec.p;
4876
+ for (const [r, bin] of d202) {
4877
+ const pr = bin.p;
4780
4878
  if (pr <= 0) continue;
4781
4879
  if (r === 1) {
4782
4880
  pmiss2 += pr;
@@ -4791,8 +4889,8 @@ var AttackBuilder = class _AttackBuilder {
4791
4889
  }
4792
4890
  if (check instanceof AlwaysHitBuilder) {
4793
4891
  let pCrit = 0;
4794
- for (const [r, rec] of d202) {
4795
- const pr = typeof rec === "number" ? rec : rec.p;
4892
+ for (const [r, bin] of d202) {
4893
+ const pr = bin.p;
4796
4894
  if (pr <= 0) continue;
4797
4895
  if (r >= critThreshold) pCrit += pr;
4798
4896
  }
@@ -4807,8 +4905,8 @@ var AttackBuilder = class _AttackBuilder {
4807
4905
  let pcrit = 0;
4808
4906
  let phit = 0;
4809
4907
  let pmiss = 0;
4810
- for (const [r, rec] of d202) {
4811
- const pr = typeof rec === "number" ? rec : rec.p;
4908
+ for (const [r, bin] of d202) {
4909
+ const pr = bin.p;
4812
4910
  if (pr <= 0) continue;
4813
4911
  if (r === 1) {
4814
4912
  pmiss += pr;
@@ -4888,7 +4986,6 @@ var AttackBuilder = class _AttackBuilder {
4888
4986
  var ACBuilder = class _ACBuilder extends RollBuilder {
4889
4987
  constructor(baseRoll, ac, attackConfig) {
4890
4988
  super(baseRoll.getSubRollConfigs());
4891
- __publicField(this, "attackConfig");
4892
4989
  if (attackConfig) {
4893
4990
  this.attackConfig = { ...attackConfig, ac };
4894
4991
  } else {
@@ -5017,8 +5114,8 @@ function resolveProbabilities(check) {
5017
5114
  const baseReroll = check.baseReroll;
5018
5115
  const die = d20RollPMF(d20Type, baseReroll > 0);
5019
5116
  const faceP = /* @__PURE__ */ new Map();
5020
- for (const [r, rec] of die) {
5021
- const pr = typeof rec === "number" ? rec : rec.p;
5117
+ for (const [r, bin] of die) {
5118
+ const pr = bin.p;
5022
5119
  if (pr > 0) faceP.set(r, pr);
5023
5120
  }
5024
5121
  const eps = 0;
@@ -5039,7 +5136,6 @@ function resolveProbabilities(check) {
5039
5136
  var DCBuilder = class _DCBuilder extends RollBuilder {
5040
5137
  constructor(baseRoll, saveConfig) {
5041
5138
  super(baseRoll.getSubRollConfigs());
5042
- __publicField(this, "saveConfig");
5043
5139
  this.saveConfig = saveConfig ? { ...saveConfig } : { dc: 10 };
5044
5140
  }
5045
5141
  dc(saveDC) {
@@ -5088,8 +5184,8 @@ var DCBuilder = class _DCBuilder extends RollBuilder {
5088
5184
  );
5089
5185
  const bonusPMF = bonusDicePMFs.length ? PMF.convolveMany(bonusDicePMFs, eps) : PMF.delta(0, eps);
5090
5186
  let psuccess = 0;
5091
- for (const [r, rec] of d202) {
5092
- const pr = typeof rec === "number" ? rec : rec.p;
5187
+ for (const [r, bin] of d202) {
5188
+ const pr = bin.p;
5093
5189
  if (pr <= 0) continue;
5094
5190
  const need = saveDC - staticMod - r;
5095
5191
  psuccess += pr * bonusPMF.tailProbGE(need);