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