@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.
package/dist/index.js CHANGED
@@ -1,12 +1,19 @@
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);
1
+ // src/common/errors.ts
2
+ var DiceParseError = class _DiceParseError extends Error {
3
+ constructor(message, options) {
4
+ super(message);
5
+ this.name = "DiceParseError";
6
+ this.expression = options?.expression;
7
+ this.cause = options?.cause;
8
+ Object.setPrototypeOf(this, _DiceParseError.prototype);
9
+ }
10
+ };
4
11
 
5
12
  // src/common/lru-cache.ts
6
13
  var LRUCache = class {
7
14
  constructor(maxSize = 1e3) {
8
15
  this.maxSize = maxSize;
9
- __publicField(this, "cache", /* @__PURE__ */ new Map());
16
+ this.cache = /* @__PURE__ */ new Map();
10
17
  }
11
18
  get(key) {
12
19
  const value = this.cache.get(key);
@@ -58,15 +65,30 @@ var onPotentCantripOnly = ["pc"];
58
65
  // src/pmf/query.ts
59
66
  var _DiceQuery = class _DiceQuery {
60
67
  constructor(singles, combined, eps = EPS) {
61
- __publicField(this, "singles");
62
- __publicField(this, "combined");
63
- __publicField(this, "_combinedWithAttr");
64
68
  this.singles = Array.isArray(singles) ? singles : [singles];
65
69
  if (this.singles.some((s) => s === void 0)) {
66
70
  throw new Error("DiceQuery contains undefined singles");
67
71
  }
68
- const c = combined ?? PMF.convolveMany(this.singles);
69
- this.combined = Math.abs(c.mass() - 1) <= eps ? c : c.normalize();
72
+ this._eps = eps;
73
+ this._combinedProvided = combined !== void 0;
74
+ if (combined !== void 0) {
75
+ this._combined = Math.abs(combined.mass() - 1) <= eps ? combined : combined.normalize();
76
+ }
77
+ }
78
+ /**
79
+ * The combined damage distribution of all single PMFs (their convolution),
80
+ * normalized to total probability 1.
81
+ *
82
+ * Computed lazily on first access and cached. Queries that only need
83
+ * additive statistics — {@link DiceQuery.mean}, {@link DiceQuery.variance},
84
+ * {@link DiceQuery.stddev} — never trigger this convolution.
85
+ */
86
+ get combined() {
87
+ if (this._combined === void 0) {
88
+ const c = PMF.convolveMany(this.singles);
89
+ this._combined = Math.abs(c.mass() - 1) <= this._eps ? c : c.normalize();
90
+ }
91
+ return this._combined;
70
92
  }
71
93
  /**
72
94
  * Returns a new PMF with damage attribution metadata populated.
@@ -91,6 +113,10 @@ var _DiceQuery = class _DiceQuery {
91
113
  if (this._combinedWithAttr) {
92
114
  return this._combinedWithAttr;
93
115
  }
116
+ if (this.singles.every((pmf) => pmf.hasAttribution())) {
117
+ this._combinedWithAttr = this.combined;
118
+ return this._combinedWithAttr;
119
+ }
94
120
  const singlesWithAttr = this.singles.map((pmf) => pmf.withAttribution());
95
121
  const combined = PMF.convolveMany(singlesWithAttr, this.combined.epsilon);
96
122
  const normalized = Math.abs(combined.mass() - 1) <= this.combined.epsilon ? combined : combined.normalize();
@@ -104,11 +130,18 @@ var _DiceQuery = class _DiceQuery {
104
130
  * Use case: "What's my average damage per round?"
105
131
  */
106
132
  mean() {
107
- let totalSum = 0;
108
- for (const [damageValue, probabilityBin] of this.combined) {
109
- totalSum += damageValue * probabilityBin.p;
133
+ if (this._combinedProvided) {
134
+ let m = 0;
135
+ for (const [damageValue, bin] of this.combined) m += damageValue * bin.p;
136
+ return m;
137
+ }
138
+ let totalMean = 0;
139
+ for (const single of this.singles) {
140
+ const mass = single.mass();
141
+ if (mass <= 0) continue;
142
+ totalMean += Math.abs(mass - 1) <= this._eps ? single.mean() : single.mean() / mass;
110
143
  }
111
- return totalSum;
144
+ return totalMean;
112
145
  }
113
146
  /**
114
147
  * Returns the variance of the damage distribution.
@@ -118,13 +151,33 @@ var _DiceQuery = class _DiceQuery {
118
151
  * High variance means higher risk/reward. Lower variance means more consistent damage.
119
152
  */
120
153
  variance() {
121
- const meanValue = this.mean();
122
- let varianceSum = 0;
123
- for (const [damageValue, probabilityBin] of this.combined) {
124
- const deviationFromMean = damageValue - meanValue;
125
- varianceSum += deviationFromMean * deviationFromMean * probabilityBin.p;
154
+ if (this._combinedProvided) {
155
+ const mu = this.mean();
156
+ let v = 0;
157
+ for (const [damageValue, bin] of this.combined) {
158
+ const dev = damageValue - mu;
159
+ v += dev * dev * bin.p;
160
+ }
161
+ return v;
162
+ }
163
+ let totalVariance = 0;
164
+ for (const single of this.singles) {
165
+ const mass = single.mass();
166
+ if (mass <= 0) continue;
167
+ if (Math.abs(mass - 1) <= this._eps) {
168
+ totalVariance += single.variance();
169
+ } else {
170
+ let mu = 0;
171
+ for (const [d, b] of single) mu += d * (b.p / mass);
172
+ let v = 0;
173
+ for (const [d, b] of single) {
174
+ const dev = d - mu;
175
+ v += dev * dev * (b.p / mass);
176
+ }
177
+ totalVariance += v;
178
+ }
126
179
  }
127
- return varianceSum;
180
+ return totalVariance;
128
181
  }
129
182
  /**
130
183
  * Returns the standard deviation of the damage distribution.
@@ -136,6 +189,10 @@ var _DiceQuery = class _DiceQuery {
136
189
  stddev() {
137
190
  return Math.sqrt(this.variance());
138
191
  }
192
+ /** Alias of {@link DiceQuery.stddev}, matching {@link PMF.stdev}. */
193
+ stdev() {
194
+ return this.stddev();
195
+ }
139
196
  /**
140
197
  * Returns the Cumulative Distribution Function.
141
198
  */
@@ -226,20 +283,49 @@ var _DiceQuery = class _DiceQuery {
226
283
  return this.combined.max();
227
284
  }
228
285
  singleProb(diceIndex, label) {
286
+ const single = this.singles[diceIndex];
229
287
  let probabilitySum = 0;
230
- for (const [, probabilityBin] of this.singles[diceIndex]) {
288
+ for (const [, probabilityBin] of single) {
231
289
  probabilitySum += probabilityBin.count[label] || 0;
232
290
  }
233
- return probabilitySum;
291
+ const mass = single.mass();
292
+ return mass > 0 ? probabilitySum / mass : 0;
293
+ }
294
+ /**
295
+ * Full count distribution [P(0), P(1), …, P(n)] for "an attack succeeds if it
296
+ * carries ANY of `labels`", over the n independent singles.
297
+ *
298
+ * Each single's per-event success probability is the Poisson-binomial
299
+ * marginal P(≥1 of labels) from {@link probabilityOf} (i.e. probAtLeastOne),
300
+ * computed exactly once. The binomial DP then runs once to produce the whole
301
+ * distribution, so the array-label paths of probExactlyK / probAtLeastK /
302
+ * probAtMostK can slice or sum from it instead of rebuilding a DiceQuery and
303
+ * re-running the DP per requested k.
304
+ */
305
+ countDistribution(labels) {
306
+ const n = this.singles.length;
307
+ const successProbabilities = this.singles.map(
308
+ (single) => new _DiceQuery([single]).probabilityOf(labels)
309
+ );
310
+ const dist = new Array(n + 1).fill(0);
311
+ dist[0] = 1;
312
+ for (const successProb of successProbabilities) {
313
+ for (let outcomeCount = n; outcomeCount >= 1; outcomeCount--) {
314
+ dist[outcomeCount] = dist[outcomeCount] * (1 - successProb) + dist[outcomeCount - 1] * successProb;
315
+ }
316
+ dist[0] *= 1 - successProb;
317
+ }
318
+ return dist;
234
319
  }
235
320
  probAtLeastK(labels, k) {
236
321
  const L = Array.isArray(labels) ? [...new Set(labels)] : [labels];
237
322
  const n = this.singles.length;
238
323
  if (k <= 0) return 1;
239
324
  if (k > n) return 0;
325
+ const dist = this.countDistribution(L);
240
326
  let tail = 0;
241
327
  for (let i = k; i <= n; i++) {
242
- tail += this.probExactlyK(L, i);
328
+ tail += dist[i];
243
329
  }
244
330
  if (tail < 0) return 0;
245
331
  if (tail > 1) return 1;
@@ -271,9 +357,12 @@ var _DiceQuery = class _DiceQuery {
271
357
  for (const label of labels) {
272
358
  combinedProbability += this.singleProb(diceIndex, label);
273
359
  }
360
+ if (combinedProbability < 0) combinedProbability = 0;
361
+ else if (combinedProbability > 1) combinedProbability = 1;
274
362
  productOfNonOccurrence *= 1 - combinedProbability;
275
363
  }
276
- return 1 - productOfNonOccurrence;
364
+ const result = 1 - productOfNonOccurrence;
365
+ return result < 0 ? 0 : result > 1 ? 1 : result;
277
366
  }
278
367
  /**
279
368
  * Computes binomial probabilities for exactly 0, 1, 2, ..., maxK occurrences of a label.
@@ -314,7 +403,7 @@ var _DiceQuery = class _DiceQuery {
314
403
  * Array examples:
315
404
  * - probExactlyK(['hit', 'crit'], 2) = probability exactly 2 attacks succeed
316
405
  * - probExactlyK(['hit', 'crit'], 1) = probability exactly 1 attack succeeds
317
- * - probExactlyK(['miss', 'missNone'], 0) = probability no attacks miss
406
+ * - probExactlyK(['missDamage', 'missNone'], 0) = probability no attacks miss
318
407
  *
319
408
  * Use cases:
320
409
  * - "What's the chance exactly one of my attacks hits?"
@@ -329,19 +418,8 @@ var _DiceQuery = class _DiceQuery {
329
418
  const probabilityArray = this.computeBinomialProbabilities(labels, k);
330
419
  return probabilityArray[k];
331
420
  }
332
- const successProbabilities = this.singles.map((single) => {
333
- const singleQuery = new _DiceQuery([single]);
334
- return singleQuery.probabilityOf(labels);
335
- });
336
- const binomialProbs = new Array(k + 1).fill(0);
337
- binomialProbs[0] = 1;
338
- for (const successProb of successProbabilities) {
339
- for (let outcomeCount = k; outcomeCount >= 1; outcomeCount--) {
340
- binomialProbs[outcomeCount] = binomialProbs[outcomeCount] * (1 - successProb) + binomialProbs[outcomeCount - 1] * successProb;
341
- }
342
- binomialProbs[0] *= 1 - successProb;
343
- }
344
- return binomialProbs[k];
421
+ const dist = this.countDistribution(labels);
422
+ return k >= 0 && k < dist.length ? dist[k] : 0;
345
423
  }
346
424
  /**
347
425
  * Returns the probability that AT MOST K attacks result in the specified outcome(s).
@@ -349,7 +427,7 @@ var _DiceQuery = class _DiceQuery {
349
427
  * Single label examples:
350
428
  * - probAtMostK('hit', 1) = probability 0 or 1 attacks hit (at most 1)
351
429
  * - probAtMostK('crit', 0) = probability no attacks crit
352
- * - probAtMostK('miss', 2) = probability at most 2 attacks miss
430
+ * - probAtMostK('missDamage', 2) = probability at most 2 attacks miss
353
431
  *
354
432
  * Array examples:
355
433
  * - probAtMostK(['hit', 'crit'], 1) = probability at most 1 attack succeeds
@@ -370,9 +448,11 @@ var _DiceQuery = class _DiceQuery {
370
448
  }
371
449
  return cumulativeSum2;
372
450
  }
451
+ const dist = this.countDistribution(labels);
452
+ const upper = Math.min(k, dist.length - 1);
373
453
  let cumulativeSum = 0;
374
- for (let outcomeCount = 0; outcomeCount <= k; outcomeCount++) {
375
- cumulativeSum += this.probExactlyK(labels, outcomeCount);
454
+ for (let outcomeCount = 0; outcomeCount <= upper; outcomeCount++) {
455
+ cumulativeSum += dist[outcomeCount];
376
456
  }
377
457
  return cumulativeSum;
378
458
  }
@@ -418,7 +498,7 @@ var _DiceQuery = class _DiceQuery {
418
498
  *
419
499
  * Array examples:
420
500
  * - damageStatsFrom(['hit', 'crit']) = damage range when at least one attack succeeds
421
- * - damageStatsFrom(['miss', 'missNone']) = damage range when at least one attack misses
501
+ * - damageStatsFrom(['missDamage', 'missNone']) = damage range when at least one attack misses
422
502
  *
423
503
  * Tactical Use Cases:
424
504
  * - "Given that I don't completely whiff (99% of turns), what damage should I expect?"
@@ -436,6 +516,12 @@ var _DiceQuery = class _DiceQuery {
436
516
  * This includes mixed scenarios (2 hits + 1 crit, 3 hits + 1 miss, etc.) which
437
517
  * occur far more frequently than pure scenarios. For pure scenarios, use combinedDamageStats.
438
518
  *
519
+ * KNOWN LIMITATION (multi-attack, single label): the returned `count` is an
520
+ * EXPECTED COUNT (E[#label], so > 1 for N≥2 attacks, not a probability), and
521
+ * `avg` is the size-biased conditional mean E[dmg·#label]/E[#label] rather than
522
+ * E[dmg | the label occurs]. For a single attack both are the plain
523
+ * conditional figures. Use {@link probAtLeastOne} for the scenario probability.
524
+ *
439
525
  * @example
440
526
  * // High-level tactical planning
441
527
  * const successStats = query.damageStatsFrom('hit')
@@ -540,7 +626,8 @@ var _DiceQuery = class _DiceQuery {
540
626
  };
541
627
  }
542
628
  /**
543
- * Returns the probability that a result includes ANY of the specified labels.
629
+ * Returns the probability that at least one attack carries ANY of the
630
+ * specified labels (the marginal P(≥1) across the independent attacks).
544
631
  *
545
632
  * Examples:
546
633
  * - `query.probabilityOf('hit')` → 0.88 (probability at least one hit occurs)
@@ -549,25 +636,15 @@ var _DiceQuery = class _DiceQuery {
549
636
  * Use cases:
550
637
  * - "What's the chance my resolution includes a success label?"
551
638
  * - "How likely am I to get any hits or crits across all attacks?"
639
+ *
640
+ * Note: this must NOT be computed by summing `combined` bin probabilities. A
641
+ * single combined damage total is reachable by many outcome combinations and
642
+ * a bin can hold several labels at once, so summing `bin.p` over bins that
643
+ * contain a label over-counts. The correct marginal is the Poisson-binomial
644
+ * complement over the per-attack probabilities, i.e. {@link probAtLeastOne}.
552
645
  */
553
646
  probabilityOf(labels) {
554
- if (typeof labels === "string") {
555
- labels = [labels];
556
- }
557
- let totalProbability = 0;
558
- for (const [, probabilityBin] of this.combined) {
559
- let binHasAnyLabel = false;
560
- for (const label of labels) {
561
- if (probabilityBin.count[label] && probabilityBin.count[label] > 0) {
562
- binHasAnyLabel = true;
563
- break;
564
- }
565
- }
566
- if (binHasAnyLabel) {
567
- totalProbability += probabilityBin.p;
568
- }
569
- }
570
- return totalProbability;
647
+ return this.probAtLeastOne(labels);
571
648
  }
572
649
  /**
573
650
  * Returns the probability of missing (any type of miss).
@@ -627,10 +704,6 @@ var _DiceQuery = class _DiceQuery {
627
704
  */
628
705
  toStackedChartData(labels = [], epsilon = EPS) {
629
706
  const damageValues = this.combined.support();
630
- damageValues.map((dmg) => {
631
- const bin = this.combined.map.get(dmg);
632
- return labels.reduce((sum, lab) => sum + (bin.count[lab] || 0), 0);
633
- });
634
707
  const datasets = labels.map((outcomeLabel) => ({
635
708
  label: outcomeLabel,
636
709
  data: damageValues.map((dmg) => {
@@ -1067,6 +1140,16 @@ var _DiceQuery = class _DiceQuery {
1067
1140
  * Snapshot of the distribution in the exact shape the UI consumes.
1068
1141
  * - outcome probabilities are "at least one" (and equal to "all" for a single PMF)
1069
1142
  * - damageRange is conditional on the outcome occurring
1143
+ *
1144
+ * The outcome probabilities use the correct Poisson-binomial marginals
1145
+ * (`atLeastOneProbability` = P(≥1 attack has it), `allProbability` = P(all do)),
1146
+ * so they are always valid probabilities in [0,1].
1147
+ *
1148
+ * KNOWN LIMITATION (multi-attack): `damageRange.avg` is still aggregated from
1149
+ * the combined PMF's `count`, which the convolution accumulates as an EXPECTED
1150
+ * COUNT, so for N≥2 attacks it is the size-biased mean E[dmg·#label]/E[#label]
1151
+ * rather than a clean conditional expectation. It is correct for a single
1152
+ * attack.
1070
1153
  */
1071
1154
  snapshot(order) {
1072
1155
  const discovered = /* @__PURE__ */ new Set();
@@ -1088,10 +1171,8 @@ var _DiceQuery = class _DiceQuery {
1088
1171
  );
1089
1172
  }
1090
1173
  const rows = this.toLabeledTable(outcomes);
1091
- const totals = /* @__PURE__ */ new Map();
1092
1174
  const rangeAcc = /* @__PURE__ */ new Map();
1093
1175
  for (const ot of outcomes) {
1094
- totals.set(ot, 0);
1095
1176
  rangeAcc.set(ot, { sum: 0, mass: 0 });
1096
1177
  }
1097
1178
  for (const row of rows) {
@@ -1099,7 +1180,6 @@ var _DiceQuery = class _DiceQuery {
1099
1180
  for (const ot of outcomes) {
1100
1181
  const p = row[ot] || 0;
1101
1182
  if (p <= 0) continue;
1102
- totals.set(ot, (totals.get(ot) || 0) + p);
1103
1183
  const r = rangeAcc.get(ot);
1104
1184
  r.sum += dmg * p;
1105
1185
  r.mass += p;
@@ -1107,15 +1187,14 @@ var _DiceQuery = class _DiceQuery {
1107
1187
  if (r.max === void 0 || dmg > r.max) r.max = dmg;
1108
1188
  }
1109
1189
  }
1190
+ const n = this.singles.length;
1110
1191
  const outcomeMap = /* @__PURE__ */ new Map();
1111
1192
  for (const ot of outcomes) {
1112
- const total = totals.get(ot) || 0;
1113
1193
  const r = rangeAcc.get(ot);
1114
1194
  const avg = r.mass > 0 ? r.sum / r.mass : 0;
1115
1195
  outcomeMap.set(ot, {
1116
- atLeastOneProbability: total,
1117
- allProbability: total,
1118
- // single aggregate PMF: same value
1196
+ atLeastOneProbability: this.probAtLeastOne(ot),
1197
+ allProbability: this.probAtLeastK(ot, n),
1119
1198
  damageRange: { min: r.min ?? 0, avg, max: r.max ?? 0 }
1120
1199
  });
1121
1200
  }
@@ -1300,11 +1379,11 @@ var _DiceQuery = class _DiceQuery {
1300
1379
  return [a, b, any, none];
1301
1380
  }
1302
1381
  };
1303
- __publicField(_DiceQuery, "DEFAULT_OUTCOMES", [
1382
+ _DiceQuery.DEFAULT_OUTCOMES = [
1304
1383
  "hit",
1305
1384
  "crit",
1306
1385
  "missNone"
1307
- ]);
1386
+ ];
1308
1387
  var DiceQuery = _DiceQuery;
1309
1388
  var pmfCache = new LRUCache(1e3);
1310
1389
  var _PMF = class _PMF {
@@ -1314,14 +1393,6 @@ var _PMF = class _PMF {
1314
1393
  this.normalized = normalized;
1315
1394
  this.identifier = identifier;
1316
1395
  this._preservedProvenance = _preservedProvenance;
1317
- // Cached computed values
1318
- __publicField(this, "_support");
1319
- __publicField(this, "_min");
1320
- __publicField(this, "_max");
1321
- __publicField(this, "_totalMass");
1322
- __publicField(this, "_mean");
1323
- __publicField(this, "_variance");
1324
- __publicField(this, "_stdev");
1325
1396
  }
1326
1397
  static empty(epsilon = EPS, identifier = "empty") {
1327
1398
  return new _PMF(/* @__PURE__ */ new Map(), epsilon, false, identifier);
@@ -1361,8 +1432,14 @@ var _PMF = class _PMF {
1361
1432
  if (p === 1) return successPMF.scaleMass(1);
1362
1433
  const eps = successPMF.epsilon ?? failurePMF.epsilon;
1363
1434
  const id = `branch(${failurePMF.identifier}*${q.toFixed(6)} + ${successPMF.identifier}*${p.toFixed(6)})`;
1364
- const out = _PMF.empty(eps, id).addScaled(failurePMF, q).addScaled(successPMF, p);
1365
- return out;
1435
+ const resultMap = /* @__PURE__ */ new Map();
1436
+ for (const [damageValue, bin] of failurePMF.map) {
1437
+ _PMF.mergeInto(resultMap, damageValue, _PMF.scaleBin(bin, q));
1438
+ }
1439
+ for (const [damageValue, bin] of successPMF.map) {
1440
+ _PMF.mergeInto(resultMap, damageValue, _PMF.scaleBin(bin, p));
1441
+ }
1442
+ return new _PMF(resultMap, eps, false, id);
1366
1443
  }
1367
1444
  /**
1368
1445
  * withProbability()
@@ -1410,8 +1487,8 @@ var _PMF = class _PMF {
1410
1487
  * @param fallback PMF to apply when this PMF is *not* selected.
1411
1488
  * @returns A new PMF representing the weighted mixture of this PMF and the fallback.
1412
1489
  */
1413
- gate(p, zero) {
1414
- return _PMF.branch(this, zero, p);
1490
+ gate(p, fallback) {
1491
+ return _PMF.branch(this, fallback, p);
1415
1492
  }
1416
1493
  /**
1417
1494
  * PMF.exclusive()
@@ -1494,13 +1571,23 @@ var _PMF = class _PMF {
1494
1571
  *
1495
1572
  * @returns New PMF with attr field populated in each bin
1496
1573
  */
1497
- withAttribution() {
1574
+ /**
1575
+ * Returns true if this PMF already carries damage attribution metadata.
1576
+ *
1577
+ * Only the first positive-damage bin is inspected (parser-generated PMFs
1578
+ * populate `attr` uniformly), so this is O(1) in practice.
1579
+ */
1580
+ hasAttribution() {
1498
1581
  for (const [damage, bin] of this.map) {
1499
1582
  if (damage !== 0 && bin.attr && Object.keys(bin.attr).length > 0) {
1500
- return this;
1583
+ return true;
1501
1584
  }
1502
1585
  if (damage > 0) break;
1503
1586
  }
1587
+ return false;
1588
+ }
1589
+ withAttribution() {
1590
+ if (this.hasAttribution()) return this;
1504
1591
  const newMap = /* @__PURE__ */ new Map();
1505
1592
  for (const [damage, bin] of this.map) {
1506
1593
  const attr = {};
@@ -1617,7 +1704,7 @@ var _PMF = class _PMF {
1617
1704
  */
1618
1705
  replicate(n) {
1619
1706
  if (!Number.isInteger(n) || n <= 0) {
1620
- throw new Error("combineN(n): n must be a positive integer");
1707
+ throw new Error("replicate(n): n must be a positive integer");
1621
1708
  }
1622
1709
  if (n === 1) return [this];
1623
1710
  return Array.from({ length: n }, () => this);
@@ -1649,7 +1736,6 @@ var _PMF = class _PMF {
1649
1736
  if (normalizationFactor === 0) return this;
1650
1737
  const normalizedMap = /* @__PURE__ */ new Map();
1651
1738
  for (const [damageValue, probabilityBin] of this.map) {
1652
- const normalizedProbability = probabilityBin.p / normalizationFactor;
1653
1739
  const normalizedCount = {};
1654
1740
  for (const labelKey in probabilityBin.count) {
1655
1741
  normalizedCount[labelKey] = probabilityBin.count[labelKey] / normalizationFactor;
@@ -1662,7 +1748,7 @@ var _PMF = class _PMF {
1662
1748
  }
1663
1749
  }
1664
1750
  normalizedMap.set(damageValue, {
1665
- p: normalizedProbability,
1751
+ p: probabilityBin.p / normalizationFactor,
1666
1752
  count: normalizedCount,
1667
1753
  attr: normalizedAttributes
1668
1754
  });
@@ -1685,22 +1771,23 @@ var _PMF = class _PMF {
1685
1771
  for (const [damageValue, probabilityBin] of this.map) {
1686
1772
  const shouldKeep = probabilityBin.p >= eps || keepFinalBin && damageValue === maxKey;
1687
1773
  if (!shouldKeep) continue;
1688
- for (const labelKey in probabilityBin.count) {
1689
- if (Math.abs(probabilityBin.count[labelKey] || 0) < eps) {
1690
- delete probabilityBin.count[labelKey];
1774
+ const cleanedBin = _PMF.cloneBin(probabilityBin);
1775
+ for (const labelKey in cleanedBin.count) {
1776
+ if (Math.abs(cleanedBin.count[labelKey] || 0) < eps) {
1777
+ delete cleanedBin.count[labelKey];
1691
1778
  }
1692
1779
  }
1693
- if (probabilityBin.attr) {
1694
- for (const labelKey in probabilityBin.attr) {
1695
- if (Math.abs(probabilityBin.attr[labelKey] || 0) < eps) {
1696
- delete probabilityBin.attr[labelKey];
1780
+ if (cleanedBin.attr) {
1781
+ for (const labelKey in cleanedBin.attr) {
1782
+ if (Math.abs(cleanedBin.attr[labelKey] || 0) < eps) {
1783
+ delete cleanedBin.attr[labelKey];
1697
1784
  }
1698
1785
  }
1699
- if (Object.keys(probabilityBin.attr).length === 0) {
1700
- probabilityBin.attr = void 0;
1786
+ if (Object.keys(cleanedBin.attr).length === 0) {
1787
+ cleanedBin.attr = void 0;
1701
1788
  }
1702
1789
  }
1703
- compactedMap.set(damageValue, probabilityBin);
1790
+ compactedMap.set(damageValue, cleanedBin);
1704
1791
  }
1705
1792
  return new _PMF(compactedMap, eps, this.normalized, this.identifier);
1706
1793
  }
@@ -1767,14 +1854,33 @@ var _PMF = class _PMF {
1767
1854
  }
1768
1855
  return this._stdev;
1769
1856
  }
1857
+ /** Deep-copies a Bin, cloning its count and (optional) attr maps. */
1858
+ static cloneBin(bin) {
1859
+ return {
1860
+ p: bin.p,
1861
+ count: { ...bin.count },
1862
+ attr: bin.attr ? { ...bin.attr } : void 0
1863
+ };
1864
+ }
1865
+ /** Returns a new Bin with p, count, and attr all multiplied by `factor`. */
1866
+ static scaleBin(bin, factor) {
1867
+ const count = {};
1868
+ for (const k in bin.count) {
1869
+ count[k] = bin.count[k] * factor;
1870
+ }
1871
+ let attr;
1872
+ if (bin.attr) {
1873
+ attr = {};
1874
+ for (const k in bin.attr) {
1875
+ attr[k] = bin.attr[k] * factor;
1876
+ }
1877
+ }
1878
+ return { p: bin.p * factor, count, attr };
1879
+ }
1770
1880
  static mergeInto(destinationMap, damageValue, binToAdd) {
1771
1881
  const existingBin = destinationMap.get(damageValue);
1772
1882
  if (!existingBin) {
1773
- destinationMap.set(damageValue, {
1774
- p: binToAdd.p,
1775
- count: { ...binToAdd.count },
1776
- attr: binToAdd.attr ? { ...binToAdd.attr } : void 0
1777
- });
1883
+ destinationMap.set(damageValue, _PMF.cloneBin(binToAdd));
1778
1884
  return;
1779
1885
  }
1780
1886
  existingBin.p += binToAdd.p;
@@ -1805,29 +1911,14 @@ var _PMF = class _PMF {
1805
1911
  if (probability === 0) return this;
1806
1912
  const resultMap = /* @__PURE__ */ new Map();
1807
1913
  for (const [dmg, bin] of this.map) {
1808
- resultMap.set(dmg, {
1809
- p: bin.p,
1810
- count: { ...bin.count },
1811
- attr: bin.attr ? { ...bin.attr } : void 0
1812
- });
1914
+ resultMap.set(dmg, _PMF.cloneBin(bin));
1813
1915
  }
1814
1916
  for (const [damageValue, probabilityBin] of branch.map) {
1815
- const scaledCount = {};
1816
- for (const k in probabilityBin.count) {
1817
- scaledCount[k] = probability * probabilityBin.count[k];
1818
- }
1819
- let scaledAttributes;
1820
- if (probabilityBin.attr) {
1821
- scaledAttributes = {};
1822
- for (const k in probabilityBin.attr) {
1823
- scaledAttributes[k] = probability * probabilityBin.attr[k];
1824
- }
1825
- }
1826
- _PMF.mergeInto(resultMap, damageValue, {
1827
- p: probability * probabilityBin.p,
1828
- count: scaledCount,
1829
- attr: scaledAttributes
1830
- });
1917
+ _PMF.mergeInto(
1918
+ resultMap,
1919
+ damageValue,
1920
+ _PMF.scaleBin(probabilityBin, probability)
1921
+ );
1831
1922
  }
1832
1923
  return new _PMF(
1833
1924
  resultMap,
@@ -1840,22 +1931,7 @@ var _PMF = class _PMF {
1840
1931
  if (factor === 1) return this;
1841
1932
  const scaledMap = /* @__PURE__ */ new Map();
1842
1933
  for (const [damageValue, probabilityBin] of this.map) {
1843
- const scaledCount = {};
1844
- for (const labelKey in probabilityBin.count) {
1845
- scaledCount[labelKey] = probabilityBin.count[labelKey] * factor;
1846
- }
1847
- let scaledAttributes;
1848
- if (probabilityBin.attr) {
1849
- scaledAttributes = {};
1850
- for (const labelKey in probabilityBin.attr) {
1851
- scaledAttributes[labelKey] = probabilityBin.attr[labelKey] * factor;
1852
- }
1853
- }
1854
- scaledMap.set(damageValue, {
1855
- p: probabilityBin.p * factor,
1856
- count: scaledCount,
1857
- attr: scaledAttributes
1858
- });
1934
+ scaledMap.set(damageValue, _PMF.scaleBin(probabilityBin, factor));
1859
1935
  }
1860
1936
  return new _PMF(
1861
1937
  scaledMap,
@@ -1868,11 +1944,11 @@ var _PMF = class _PMF {
1868
1944
  const transformedMap = /* @__PURE__ */ new Map();
1869
1945
  for (const [originalDamage, probabilityBin] of this.map) {
1870
1946
  const transformedDamage = damageTransformFunction(originalDamage);
1871
- _PMF.mergeInto(transformedMap, transformedDamage, {
1872
- p: probabilityBin.p,
1873
- count: { ...probabilityBin.count },
1874
- attr: probabilityBin.attr ? { ...probabilityBin.attr } : void 0
1875
- });
1947
+ _PMF.mergeInto(
1948
+ transformedMap,
1949
+ transformedDamage,
1950
+ _PMF.cloneBin(probabilityBin)
1951
+ );
1876
1952
  }
1877
1953
  return new _PMF(
1878
1954
  transformedMap,
@@ -1887,14 +1963,21 @@ var _PMF = class _PMF {
1887
1963
  }
1888
1964
  getPMFCombineCacheKey(p1, p2, eps, raw) {
1889
1965
  const [id1, id2] = [p1.identifier, p2.identifier].sort();
1890
- const fp = (x) => {
1891
- const m = x.mass().toFixed(12);
1892
- const n = x.map.size;
1966
+ return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${p1.fingerprint()}|${p2.fingerprint()}`;
1967
+ }
1968
+ /**
1969
+ * A small content fingerprint (mass + bin count + face sum) so convolution
1970
+ * cache keys change if the underlying numbers do. Memoized because a PMF is
1971
+ * immutable once constructed — this avoids re-summing every key on each
1972
+ * convolve() call (including cache hits).
1973
+ */
1974
+ fingerprint() {
1975
+ if (this._fingerprint === void 0) {
1893
1976
  let faceSum = 0;
1894
- for (const k of x.map.keys()) faceSum += k;
1895
- return `${m}|${n}|${faceSum}`;
1896
- };
1897
- return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${fp(p1)}|${fp(p2)}`;
1977
+ for (const k of this.map.keys()) faceSum += k;
1978
+ this._fingerprint = `${this.mass().toFixed(12)}|${this.map.size}|${faceSum}`;
1979
+ }
1980
+ return this._fingerprint;
1898
1981
  }
1899
1982
  convolve(other, eps, raw = false) {
1900
1983
  const epsilon = eps ?? this.epsilon;
@@ -1907,25 +1990,34 @@ var _PMF = class _PMF {
1907
1990
  if (cached) return cached;
1908
1991
  const combinedMap = /* @__PURE__ */ new Map();
1909
1992
  for (const [aVal, aBin] of A.map) {
1993
+ const ap = aBin.p;
1994
+ const aCount = aBin.count;
1995
+ const aAttr = aBin.attr;
1910
1996
  for (const [bVal, bBin] of B.map) {
1911
- const p = aBin.p * bBin.p;
1997
+ const bp = bBin.p;
1912
1998
  const dmg = aVal + bVal;
1913
- const count = {};
1914
- for (const k in aBin.count)
1915
- count[k] = (count[k] || 0) + aBin.count[k] * bBin.p;
1999
+ let dest = combinedMap.get(dmg);
2000
+ if (dest === void 0) {
2001
+ dest = { p: 0, count: {} };
2002
+ combinedMap.set(dmg, dest);
2003
+ }
2004
+ dest.p += ap * bp;
2005
+ const dc = dest.count;
2006
+ for (const k in aCount) dc[k] = (dc[k] || 0) + aCount[k] * bp;
1916
2007
  for (const k in bBin.count)
1917
- count[k] = (count[k] || 0) + bBin.count[k] * aBin.p;
1918
- let attr;
1919
- if (aBin.attr || bBin.attr) {
1920
- attr = {};
1921
- if (aBin.attr)
1922
- for (const k in aBin.attr)
1923
- attr[k] = (attr[k] || 0) + aBin.attr[k] * bBin.p;
2008
+ dc[k] = (dc[k] || 0) + bBin.count[k] * ap;
2009
+ if (aAttr || bBin.attr) {
2010
+ let da = dest.attr;
2011
+ if (da === void 0) {
2012
+ da = {};
2013
+ dest.attr = da;
2014
+ }
2015
+ if (aAttr)
2016
+ for (const k in aAttr) da[k] = (da[k] || 0) + aAttr[k] * bp;
1924
2017
  if (bBin.attr)
1925
2018
  for (const k in bBin.attr)
1926
- attr[k] = (attr[k] || 0) + bBin.attr[k] * aBin.p;
2019
+ da[k] = (da[k] || 0) + bBin.attr[k] * ap;
1927
2020
  }
1928
- _PMF.mergeInto(combinedMap, dmg, { p, count, attr });
1929
2021
  }
1930
2022
  }
1931
2023
  let result = new _PMF(
@@ -1936,10 +2028,10 @@ var _PMF = class _PMF {
1936
2028
  );
1937
2029
  const mExp = (raw ? A.mass() : 1) * (raw ? B.mass() : 1);
1938
2030
  const mGot = result.mass();
1939
- if (mExp !== 0 && Math.abs(mGot - mExp) > epsilon) {
2031
+ if (mExp !== 0 && mGot !== 0 && Math.abs(mGot - mExp) > epsilon) {
1940
2032
  result = result.scaleMass(mExp / mGot);
1941
2033
  }
1942
- if (!raw && Math.abs(result.mass() - 1) > epsilon)
2034
+ if (!raw && mGot !== 0 && Math.abs(result.mass() - 1) > epsilon)
1943
2035
  result = result.normalize();
1944
2036
  pmfCache?.set(cacheKey, result);
1945
2037
  return result;
@@ -1948,27 +2040,6 @@ var _PMF = class _PMF {
1948
2040
  combineRaw(other, eps) {
1949
2041
  return this.convolve(other, eps, true);
1950
2042
  }
1951
- // Collapse repeated identical PMFs using power() and return a sorted list
1952
- // Temporarily disabled for now. This was used for a performance optimization, but can lose data provenance.
1953
- // private static collapseIdentical(pmfList: PMF[], eps: number): PMF[] {
1954
- // const grouped = new Map<string, { pmf: PMF; count: number }>();
1955
- // for (const pmf of pmfList) {
1956
- // const id = pmf.identifier;
1957
- // const g = grouped.get(id);
1958
- // if (g) g.count++;
1959
- // else grouped.set(id, { pmf, count: 1 });
1960
- // }
1961
- // if (grouped.size >= pmfList.length) return pmfList;
1962
- // const collapsed: PMF[] = [];
1963
- // for (const { pmf, count } of grouped.values()) {
1964
- // collapsed.push(count > 1 ? pmf.power(count, eps) : pmf);
1965
- // }
1966
- // // Stable order for better cache locality
1967
- // collapsed.sort((a, b) =>
1968
- // a.identifier < b.identifier ? -1 : a.identifier > b.identifier ? 1 : 0
1969
- // );
1970
- // return collapsed;
1971
- // }
1972
2043
  // Reduce a list of PMFs by left-folding convolve() with the given eps
1973
2044
  static reduceConvolveLeft(pmfList, eps) {
1974
2045
  let result = pmfList[0];
@@ -1992,12 +2063,23 @@ var _PMF = class _PMF {
1992
2063
  if (pmfList.length === 1) return pmfList[0];
1993
2064
  return _PMF.reduceConvolveLeft(pmfList, eps);
1994
2065
  }
2066
+ /**
2067
+ * Returns a plain, JSON-serializable representation of this PMF.
2068
+ *
2069
+ * Follows the standard `toJSON` contract, so `JSON.stringify(pmf)` produces
2070
+ * the expected output (no double-encoding). Use {@link PMF.fromJSON} to
2071
+ * reconstruct, or {@link PMF.toJSONString} if you need the string directly.
2072
+ */
1995
2073
  toJSON() {
1996
- return JSON.stringify({
2074
+ return {
1997
2075
  bins: [...this.map.entries()],
1998
2076
  normalized: this.normalized,
1999
2077
  identifier: this.identifier
2000
- });
2078
+ };
2079
+ }
2080
+ /** Serializes this PMF to a JSON string (equivalent to `JSON.stringify(pmf)`). */
2081
+ toJSONString() {
2082
+ return JSON.stringify(this);
2001
2083
  }
2002
2084
  static fromJSON(jsonData) {
2003
2085
  return new _PMF(
@@ -2068,7 +2150,6 @@ var _PMF = class _PMF {
2068
2150
  }
2069
2151
  return new _PMF(prunedMap, epsRel, false, `prune(${this.identifier})`);
2070
2152
  }
2071
- /** NEW - REVIEW IF THESE ARE USEFUL OR DUPLCIATIVE? */
2072
2153
  /** Probability mass at exactly x. */
2073
2154
  pAt(x) {
2074
2155
  return this.map.get(x)?.p ?? 0;
@@ -2150,9 +2231,8 @@ var _PMF = class _PMF {
2150
2231
  }
2151
2232
  tailProbGE(t) {
2152
2233
  let s = 0;
2153
- for (const [x, rec] of this) {
2154
- const p = typeof rec === "number" ? rec : rec.p;
2155
- if (p > 0 && x >= t) s += p;
2234
+ for (const [x, bin] of this) {
2235
+ if (bin.p > 0 && x >= t) s += bin.p;
2156
2236
  }
2157
2237
  return s;
2158
2238
  }
@@ -2213,6 +2293,11 @@ var _PMF = class _PMF {
2213
2293
  * - pAny: Probability that at least one success occurred
2214
2294
  */
2215
2295
  static firstSuccessWeights(pSuccess, pSpecial, n) {
2296
+ if (!Number.isFinite(pSuccess) || !Number.isFinite(pSpecial) || pSuccess < 0 || pSuccess > 1 || pSpecial < 0 || pSpecial - pSuccess > EPS) {
2297
+ throw new Error(
2298
+ `firstSuccessWeights: require 0 <= pSpecial <= pSuccess <= 1 (got pSuccess=${pSuccess}, pSpecial=${pSpecial})`
2299
+ );
2300
+ }
2216
2301
  const pFail = 1 - pSuccess;
2217
2302
  const pFailAll = Math.pow(pFail, n);
2218
2303
  const pAny = 1 - pFailAll;
@@ -2228,13 +2313,12 @@ var _PMF = class _PMF {
2228
2313
  const round = (x) => rounding === "floor" ? Math.floor(x) : rounding === "ceil" ? Math.ceil(x) : rounding === "round" ? Math.round(x) : x;
2229
2314
  const probs = /* @__PURE__ */ new Map();
2230
2315
  const counts = /* @__PURE__ */ new Map();
2231
- for (const [v, rec] of this) {
2232
- if (Math.abs(rec.p) < eps) continue;
2316
+ for (const [v, bin] of this) {
2317
+ if (Math.abs(bin.p) < eps) continue;
2233
2318
  const u = round(f(v));
2234
- probs.set(u, (probs.get(u) ?? 0) + rec.p);
2319
+ probs.set(u, (probs.get(u) ?? 0) + bin.p);
2235
2320
  if (preserveCounts) {
2236
- const rec2 = this.map.get(v);
2237
- const src = typeof rec2 === "number" ? void 0 : rec2?.count;
2321
+ const src = bin.count;
2238
2322
  if (src) {
2239
2323
  const dest = counts.get(u) ?? {};
2240
2324
  for (const k in src) {
@@ -2287,17 +2371,20 @@ var _PMF = class _PMF {
2287
2371
  }
2288
2372
  };
2289
2373
  // Unique ID generator for anonymous PMFs to avoid cache key collisions
2290
- __publicField(_PMF, "__anonIdCounter", 1);
2374
+ _PMF.__anonIdCounter = 1;
2291
2375
  var PMF = _PMF;
2292
2376
 
2293
2377
  // src/parser/dice.ts
2378
+ var MAX_BINARY_OUTCOMES = 1e8;
2294
2379
  var Dice = class _Dice {
2295
2380
  constructor(x = 0) {
2296
- __publicField(this, "faces", {});
2297
- __publicField(this, "privateData", {});
2298
- __publicField(this, "outcomeData", {});
2299
- __publicField(this, "hasHitDistributionCalculated", false);
2300
- __publicField(this, "identifier");
2381
+ this.faces = {};
2382
+ this.privateData = {};
2383
+ // Partial: the object starts empty and gains keys as outcomes are recorded,
2384
+ // so the type must not claim every OutcomeType is present. (Previously typed
2385
+ // as a full Record via an `as` cast, which lied about missing keys.)
2386
+ this.outcomeData = {};
2387
+ this.hasHitDistributionCalculated = false;
2301
2388
  if (x <= 0) return;
2302
2389
  for (let i = 1; i <= x; i++) {
2303
2390
  this.faces[i] = 1;
@@ -2348,27 +2435,27 @@ var Dice = class _Dice {
2348
2435
  // TODO this can be private later if we change how testing works
2349
2436
  calculateHitDistribution() {
2350
2437
  const hitValues = {};
2438
+ const subtractedOutcomes = [
2439
+ this.outcomeData.crit,
2440
+ this.outcomeData.missNone,
2441
+ this.outcomeData.missDamage,
2442
+ this.outcomeData.saveHalf,
2443
+ this.outcomeData.saveFail,
2444
+ this.outcomeData.pc
2445
+ ];
2351
2446
  for (const [face, totalCount] of Object.entries(this.faces)) {
2352
2447
  const numFace = Number(face);
2353
2448
  let hitCount = totalCount;
2354
- for (const outcomeType of [
2355
- "crit",
2356
- "missNone",
2357
- "missDamage",
2358
- "saveHalf",
2359
- "saveFail",
2360
- "pc"
2361
- ]) {
2362
- const distribution = this.getOutcomeDistribution(outcomeType);
2363
- if (distribution && distribution[numFace]) {
2364
- hitCount -= distribution[numFace];
2449
+ for (const distribution of subtractedOutcomes) {
2450
+ const outcomeCount = distribution?.[numFace];
2451
+ if (outcomeCount) {
2452
+ hitCount -= outcomeCount;
2365
2453
  }
2366
2454
  }
2367
2455
  if (numFace === 0) {
2368
2456
  hitCount = 0;
2369
2457
  }
2370
2458
  if (hitCount < 0) {
2371
- console.error("hitCount is <=0?", face, totalCount, hitCount);
2372
2459
  hitCount = 0;
2373
2460
  }
2374
2461
  hitValues[numFace] = hitCount;
@@ -2387,13 +2474,18 @@ var Dice = class _Dice {
2387
2474
  const result = diceConstructor ? diceConstructor() : new _Dice();
2388
2475
  const isScalar = typeof other === "number";
2389
2476
  const keys1 = this.keys();
2477
+ const keys2 = isScalar ? [] : other.keys();
2478
+ if (!isScalar && keys1.length * keys2.length > MAX_BINARY_OUTCOMES) {
2479
+ throw new DiceParseError(
2480
+ `Dice operation over ${keys1.length}\xD7${keys2.length} face pairs exceeds the maximum of ${MAX_BINARY_OUTCOMES}`
2481
+ );
2482
+ }
2390
2483
  for (const key1 of keys1) {
2391
2484
  const value1 = this.faces[key1];
2392
2485
  if (isScalar) {
2393
2486
  const resultKey = op(key1, other);
2394
2487
  result.increment(resultKey, value1);
2395
2488
  } else {
2396
- const keys2 = other.keys();
2397
2489
  for (const key2 of keys2) {
2398
2490
  const value2 = other.faces[key2];
2399
2491
  const resultKey = op(key1, key2);
@@ -2415,7 +2507,7 @@ var Dice = class _Dice {
2415
2507
  result.outcomeData = { ...this.outcomeData };
2416
2508
  return result;
2417
2509
  }
2418
- // PUBLIC FUNTIONS
2510
+ // PUBLIC FUNCTIONS
2419
2511
  getFaceEntries() {
2420
2512
  return Object.entries(this.faces).map(([k, v]) => [Number(k), v]);
2421
2513
  }
@@ -2545,10 +2637,12 @@ var Dice = class _Dice {
2545
2637
  }
2546
2638
  reroll(toReroll) {
2547
2639
  const rerollDice = typeof toReroll === "number" ? _Dice.scalar(toReroll) : toReroll;
2548
- const removed = this.removeFaces(rerollDice.keys());
2640
+ const rerollKeys = rerollDice.keys();
2641
+ const rerollSet = new Set(rerollKeys);
2642
+ const removed = this.removeFaces(rerollKeys);
2549
2643
  let result = new _Dice();
2550
2644
  for (const face of this.keys()) {
2551
- const wasRerolled = rerollDice.keys().includes(face);
2645
+ const wasRerolled = rerollSet.has(face);
2552
2646
  result = result.combine(removed);
2553
2647
  if (wasRerolled) {
2554
2648
  result = result.combine(this);
@@ -2616,17 +2710,10 @@ var Dice = class _Dice {
2616
2710
  const missDistro = this.getOutcomeDistribution("missDamage") || {};
2617
2711
  const saveDistro = this.getOutcomeDistribution("saveHalf") || {};
2618
2712
  const pcDistro = this.getOutcomeDistribution("pc") || {};
2619
- let isSaveHalf = false;
2620
- for (const halfDamage of Object.keys(saveDistro).map(Number)) {
2621
- const fullDamage = halfDamage * 2;
2622
- if (fullDamage > 0 && hitDistro[fullDamage]) {
2623
- isSaveHalf = true;
2624
- break;
2625
- }
2626
- }
2713
+ const isSaveHalf = Object.keys(saveDistro).length > 0;
2627
2714
  const isDCCheck = this.privateData.isDCCheck === true;
2628
2715
  const clampNonNeg = (x) => x < 0 && x > -1e-15 ? 0 : x;
2629
- for (const [faceStr, faceCountRaw] of Object.entries(this.getFaceMap())) {
2716
+ for (const [faceStr, faceCountRaw] of Object.entries(this.faces)) {
2630
2717
  const face = Number(faceStr);
2631
2718
  const faceCount = Number(faceCountRaw);
2632
2719
  if (faceCount <= 0) continue;
@@ -2700,14 +2787,14 @@ var Dice = class _Dice {
2700
2787
  map.set(face, bin);
2701
2788
  }
2702
2789
  const identifier = this.identifier || "ERROR";
2703
- if (identifier === "ERROR") {
2704
- console.error("Dice identifier is undefined", this);
2705
- }
2706
2790
  return new PMF(map, numEpsilon, true, identifier).compact(numEpsilon, true);
2707
2791
  }
2708
2792
  };
2709
2793
 
2710
2794
  // src/parser/parser.ts
2795
+ var MAX_DIE_SIDES = 1e6;
2796
+ var MAX_DICE_COUNT = 1e4;
2797
+ var MAX_KEEP_OUTCOMES = 1e6;
2711
2798
  var parseCache = new LRUCache(1e3);
2712
2799
  var cachingEnabled = true;
2713
2800
  function setCachingEnabled(enabled) {
@@ -2728,20 +2815,21 @@ function parse(expression, n = 0) {
2728
2815
  if (cached) return cached;
2729
2816
  }
2730
2817
  const chars = [...cleaned];
2731
- let result = void 0;
2818
+ let result;
2732
2819
  try {
2733
2820
  result = parseExpression(chars, n);
2734
2821
  } catch (error) {
2735
- throw new Error(`Cannot parse dice expression [${expression}]: ${error}`);
2736
- }
2737
- try {
2738
- result.privateData = result.privateData || {};
2739
- result.identifier = cleaned;
2740
- } catch {
2822
+ throw new DiceParseError(
2823
+ `Cannot parse dice expression [${expression}]: ${error}`,
2824
+ { expression, cause: error }
2825
+ );
2741
2826
  }
2827
+ result.privateData = result.privateData || {};
2828
+ result.identifier = cleaned;
2742
2829
  if (chars.length > 0) {
2743
- throw new Error(
2744
- `Unexpected token: '${chars[0]}' from expression: '${expression}'`
2830
+ throw new DiceParseError(
2831
+ `Unexpected token: '${chars[0]}' from expression: '${expression}'`,
2832
+ { expression }
2745
2833
  );
2746
2834
  }
2747
2835
  const resultPMF = result.toPMF(-1);
@@ -2895,7 +2983,7 @@ function multiplyDiceByDice(d1, d2) {
2895
2983
  if (typeof d1 === "number") d1 = Dice.scalar(d1);
2896
2984
  if (typeof d2 === "number") d2 = Dice.scalar(d2);
2897
2985
  const result = new Dice();
2898
- const faces = {};
2986
+ const faces = /* @__PURE__ */ new Map();
2899
2987
  let normalizationFactor = 1;
2900
2988
  for (const key of d1.keys()) {
2901
2989
  let face;
@@ -2903,17 +2991,21 @@ function multiplyDiceByDice(d1, d2) {
2903
2991
  continue;
2904
2992
  }
2905
2993
  if (d2.privateData.keep) {
2994
+ const faceCount = d2.keys().length;
2995
+ if (Math.pow(faceCount, key) > MAX_KEEP_OUTCOMES) {
2996
+ throw new DiceParseError(
2997
+ `Keep enumeration of ${faceCount}^${key} outcomes exceeds the maximum of ${MAX_KEEP_OUTCOMES}`
2998
+ );
2999
+ }
2906
3000
  const repeat = Array(key).fill(d2);
2907
3001
  face = opDice(repeat, d2.privateData.keep);
2908
3002
  } else {
2909
3003
  face = multiplyDice(key, d2);
2910
3004
  }
2911
3005
  normalizationFactor *= face.total();
2912
- faces[key] = face;
3006
+ faces.set(key, face);
2913
3007
  }
2914
- for (const key of Object.keys(faces)) {
2915
- const k = parseFloat(key);
2916
- const face = faces[k];
3008
+ for (const [k, face] of faces) {
2917
3009
  const count = d1.get(k);
2918
3010
  result.combineInPlace(
2919
3011
  face.normalize(count * normalizationFactor / face.total())
@@ -2923,6 +3015,11 @@ function multiplyDiceByDice(d1, d2) {
2923
3015
  return result;
2924
3016
  }
2925
3017
  function multiplyDice(n, d) {
3018
+ if (n > MAX_DICE_COUNT) {
3019
+ throw new DiceParseError(
3020
+ `Dice count ${n} exceeds the maximum of ${MAX_DICE_COUNT}`
3021
+ );
3022
+ }
2926
3023
  if (n === 0) return new Dice(0);
2927
3024
  if (n === 1) return d;
2928
3025
  const half = Math.floor(n / 2);
@@ -3005,9 +3102,14 @@ function parseDice(s, n) {
3005
3102
  return;
3006
3103
  }
3007
3104
  const sides = parseNumber(s, n);
3105
+ if (sides > MAX_DIE_SIDES) {
3106
+ throw new DiceParseError(
3107
+ `Die size ${sides} exceeds the maximum of ${MAX_DIE_SIDES}`
3108
+ );
3109
+ }
3008
3110
  let result = new Dice(sides);
3009
3111
  if (rerollOne) {
3010
- result = result.deleteFace(1).combine(result);
3112
+ result = result.reroll(1);
3011
3113
  }
3012
3114
  return result;
3013
3115
  }
@@ -3122,11 +3224,9 @@ function parseOperation(s) {
3122
3224
  // src/pmf/mixture.ts
3123
3225
  var Mixture = class _Mixture {
3124
3226
  constructor(eps = EPS) {
3125
- __publicField(this, "totals", /* @__PURE__ */ new Map());
3227
+ this.totals = /* @__PURE__ */ new Map();
3126
3228
  // raw mass per outcome (pre-normalization)
3127
- __publicField(this, "labelMass", /* @__PURE__ */ new Map());
3128
- // raw mass per outcome per label
3129
- __publicField(this, "eps");
3229
+ this.labelMass = /* @__PURE__ */ new Map();
3130
3230
  this.eps = Number.isFinite(eps) ? eps : EPS;
3131
3231
  }
3132
3232
  /** Remove all accumulated state. */
@@ -3150,9 +3250,8 @@ var Mixture = class _Mixture {
3150
3250
  */
3151
3251
  add(label, pmf, weight = 1) {
3152
3252
  if (!Number.isFinite(weight) || weight <= 0) return this;
3153
- for (const [v, binOrNumber] of pmf) {
3154
- const isNumber = typeof binOrNumber === "number";
3155
- const p = isNumber ? binOrNumber : binOrNumber?.p ?? 0;
3253
+ for (const [v, bin] of pmf) {
3254
+ const p = bin.p;
3156
3255
  if (p <= 0) continue;
3157
3256
  const add = weight * p;
3158
3257
  if (!Number.isFinite(add) || Math.abs(add) < this.eps) continue;
@@ -3240,6 +3339,6 @@ var Mixture = class _Mixture {
3240
3339
  }
3241
3340
  };
3242
3341
 
3243
- export { DiceQuery, EPS, LRUCache, Mixture, PMF, clearParserCache, getCachingEnabled, onAnyHit, onCritOnly, onHitOnly, onMissDamageOnly, onMissOnly, onPotentCantripOnly, onSaveFailOnly, onSaveHalfOnly, parse, pmfCache, setCachingEnabled };
3342
+ export { DiceParseError, DiceQuery, EPS, LRUCache, Mixture, PMF, clearParserCache, getCachingEnabled, onAnyHit, onCritOnly, onHitOnly, onMissDamageOnly, onMissOnly, onPotentCantripOnly, onSaveFailOnly, onSaveHalfOnly, parse, pmfCache, setCachingEnabled };
3244
3343
  //# sourceMappingURL=index.js.map
3245
3344
  //# sourceMappingURL=index.js.map