roll-parser 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/MIGRATION.md +225 -8
  3. package/README.md +188 -60
  4. package/dist/evaluator/env.d.ts +27 -1
  5. package/dist/evaluator/env.d.ts.map +1 -1
  6. package/dist/evaluator/env.js.map +1 -1
  7. package/dist/evaluator/evaluator.d.ts.map +1 -1
  8. package/dist/evaluator/evaluator.js +89 -43
  9. package/dist/evaluator/evaluator.js.map +1 -1
  10. package/dist/evaluator/modifiers/crit-threshold.d.ts +13 -28
  11. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  12. package/dist/evaluator/modifiers/crit-threshold.js +6 -12
  13. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -1
  14. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  15. package/dist/evaluator/modifiers/explode.js +7 -1
  16. package/dist/evaluator/modifiers/explode.js.map +1 -1
  17. package/dist/evaluator/modifiers/flags.d.ts +9 -2
  18. package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
  19. package/dist/evaluator/modifiers/flags.js +4 -11
  20. package/dist/evaluator/modifiers/flags.js.map +1 -1
  21. package/dist/evaluator/modifiers/keep-drop.d.ts +1 -3
  22. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  23. package/dist/evaluator/modifiers/keep-drop.js.map +1 -1
  24. package/dist/evaluator/modifiers/success-count.d.ts +6 -5
  25. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  26. package/dist/evaluator/modifiers/success-count.js +9 -6
  27. package/dist/evaluator/modifiers/success-count.js.map +1 -1
  28. package/dist/notation.d.ts +13 -0
  29. package/dist/notation.d.ts.map +1 -0
  30. package/dist/notation.js +8 -0
  31. package/dist/notation.js.map +1 -0
  32. package/dist/parser/guards.d.ts +38 -0
  33. package/dist/parser/guards.d.ts.map +1 -1
  34. package/dist/parser/guards.js +51 -0
  35. package/dist/parser/guards.js.map +1 -1
  36. package/dist/parser/parser.d.ts.map +1 -1
  37. package/dist/parser/parser.js +26 -2
  38. package/dist/parser/parser.js.map +1 -1
  39. package/dist/render.d.ts.map +1 -1
  40. package/dist/render.js +18 -13
  41. package/dist/render.js.map +1 -1
  42. package/dist/types.d.ts +15 -3
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/package.json +5 -5
  47. package/src/evaluator/env.ts +27 -1
  48. package/src/evaluator/evaluator.ts +179 -56
  49. package/src/evaluator/modifiers/crit-threshold.ts +29 -48
  50. package/src/evaluator/modifiers/explode.ts +13 -4
  51. package/src/evaluator/modifiers/flags.ts +13 -13
  52. package/src/evaluator/modifiers/keep-drop.ts +1 -3
  53. package/src/evaluator/modifiers/sort.ts +1 -1
  54. package/src/evaluator/modifiers/success-count.ts +19 -12
  55. package/src/notation.ts +24 -0
  56. package/src/parser/guards.ts +92 -1
  57. package/src/parser/parser.ts +56 -1
  58. package/src/render.ts +24 -22
  59. package/src/types.ts +15 -3
  60. package/src/version.ts +1 -1
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { describeValue, EvaluatorError, RollParserError, stampEvaluatorSpan } from '../errors.js';
8
+ import { joinModifierCode } from '../notation.js';
8
9
  import type {
9
10
  ASTNode,
10
11
  BinaryOpNode,
@@ -59,6 +60,8 @@ import {
59
60
  rewriteFlags,
60
61
  SELECTION_AND_TALLY_FLAGS,
61
62
  SELECTION_FLAGS,
63
+ stripFlags,
64
+ TALLY_FLAGS,
62
65
  } from './modifiers/flags.js';
63
66
  import { markDroppedIndices, sumKeptDice } from './modifiers/keep-drop.js';
64
67
  import {
@@ -204,6 +207,26 @@ function appendAll<T>(target: T[], source: readonly T[]): void {
204
207
  }
205
208
  }
206
209
 
210
+ /**
211
+ * Message for an operand that has to be a whole number — dice count, dice
212
+ * sides, keep/drop count. A fractional value there is almost always an
213
+ * unrounded division (`5d(20/3)`, `4d6kh(3/2)`), and the fix is a function the
214
+ * notation already carries, so the message names it rather than leaving the
215
+ * author to find it. Only finite positive values get the hint: no rounding
216
+ * rescues a negative count or a `NaN`.
217
+ *
218
+ * The value prints in full, repeating decimals and all. Shortening it to a
219
+ * fixed width read better on `20/3` but rounded `1.00001` to `1` — naming a
220
+ * side count the evaluator accepts, which reads as a bug in the library rather
221
+ * than in the notation. Exactness is worth more than the digits it costs.
222
+ */
223
+ function invalidIntegerOperand(label: string, value: number): string {
224
+ if (Number.isInteger(value) || !Number.isFinite(value) || value <= 0) {
225
+ return `${label}: ${value}`;
226
+ }
227
+ return `${label}: ${value} (use 'floor', 'ceil', or 'round')`;
228
+ }
229
+
207
230
  /** Drops the internal notation `code`, leaving the public `KeepDropSpec` shape. */
208
231
  function toPublicSpecs(specs: KeepDropChainEntry[]): KeepDropSpec[] {
209
232
  return specs.map(({ code: _code, ...spec }) => spec);
@@ -480,15 +503,39 @@ function evalMetaOperand(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv
480
503
  }
481
504
 
482
505
  const metaCtx = createContext();
483
- const value = evalNode(node, rng, metaCtx, env).total;
506
+ // The tally counterpart of the `TALLY_FLAGS` strip in `mergeMetaRolls`.
507
+ const value = evalDiscardingSubtotals(node, rng, metaCtx, env).total;
484
508
  mergeMetaRolls(ctx, metaCtx);
485
509
  return value;
486
510
  }
487
511
 
512
+ /**
513
+ * Evaluates a node whose result is consumed as a scalar — a meta operand or a
514
+ * `vs` DC — rolling back any subtotal verdicts it scored so they never reach
515
+ * the top-level tally.
516
+ */
517
+ function evalDiscardingSubtotals(
518
+ node: ASTNode,
519
+ rng: RNG,
520
+ ctx: EvalContext,
521
+ env: EvalEnv,
522
+ ): EvalResult {
523
+ const successTally = env.subtotalSuccesses;
524
+ const failureTally = env.subtotalFailures;
525
+ const result = evalNode(node, rng, ctx, env);
526
+ env.subtotalSuccesses = successTally;
527
+ env.subtotalFailures = failureTally;
528
+ return result;
529
+ }
530
+
488
531
  /** Rejects dice counts that cannot address a pool. */
489
532
  function requireDiceCount(count: number, nodeType: 'Dice' | 'FateDice'): void {
490
533
  if (!Number.isInteger(count) || count < 0) {
491
- throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', nodeType);
534
+ throw new EvaluatorError(
535
+ invalidIntegerOperand('Invalid dice count', count),
536
+ 'INVALID_DICE_COUNT',
537
+ nodeType,
538
+ );
492
539
  }
493
540
  }
494
541
 
@@ -541,7 +588,11 @@ function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
541
588
 
542
589
  requireDiceCount(count, 'Dice');
543
590
  if (!Number.isInteger(sides) || sides < 1) {
544
- throw new EvaluatorError(`Invalid dice sides: ${sides}`, 'INVALID_DICE_SIDES', 'Dice');
591
+ throw new EvaluatorError(
592
+ invalidIntegerOperand('Invalid dice sides', sides),
593
+ 'INVALID_DICE_SIDES',
594
+ 'Dice',
595
+ );
545
596
  }
546
597
  if (sides > MAX_DICE_SIDES) {
547
598
  throw new EvaluatorError(
@@ -929,7 +980,7 @@ function flattenKeepDropChain(
929
980
 
930
981
  if (!Number.isInteger(modCount) || modCount < 0) {
931
982
  throw new EvaluatorError(
932
- `Invalid keep/drop count: ${modCount}`,
983
+ invalidIntegerOperand('Invalid keep/drop count', modCount),
933
984
  'INVALID_KEEP_DROP_COUNT',
934
985
  'KeepDrop',
935
986
  );
@@ -1067,11 +1118,11 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
1067
1118
  function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1068
1119
  const targetCtx = createContext();
1069
1120
  const target = evalNode(node.target, rng, targetCtx, env);
1070
- const targetExpr = targetCtx.expressionParts.join('');
1071
1121
 
1072
1122
  const thresholdValue = evalMetaOperand(node.condition.value, rng, ctx, env);
1073
1123
 
1074
1124
  const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
1125
+ const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
1075
1126
  const condition: ResolvedComparePoint = {
1076
1127
  operator: node.condition.operator,
1077
1128
  value: thresholdValue,
@@ -1079,8 +1130,8 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1079
1130
 
1080
1131
  // No-op when the target produced no dice (e.g., `(1+2)r<5`).
1081
1132
  if (targetCtx.rolls.length === 0) {
1082
- ctx.expressionParts.push(`${targetExpr}${code}`);
1083
- ctx.renderedParts.push(`${targetExpr}${code}`);
1133
+ ctx.expressionParts.push(modifierExpr);
1134
+ ctx.renderedParts.push(modifierExpr);
1084
1135
  const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
1085
1136
  return {
1086
1137
  total,
@@ -1101,8 +1152,8 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1101
1152
  : applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
1102
1153
 
1103
1154
  appendAll(ctx.rolls, pool);
1104
- ctx.expressionParts.push(`${targetExpr}${code}`);
1105
- ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
1155
+ ctx.expressionParts.push(modifierExpr);
1156
+ ctx.renderedParts.push(`${modifierExpr}${renderDice(pool)}`);
1106
1157
 
1107
1158
  const total = sumKeptDice(pool, env.hasVersusDc);
1108
1159
  return {
@@ -1145,13 +1196,13 @@ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalE
1145
1196
  // ! would have been resolved against a total this node just replaced.
1146
1197
  // ! See the rule on `propagateMetadata`.
1147
1198
 
1148
- const targetExpr = targetCtx.expressionParts.join('');
1149
1199
  // Negative bounds render parenthesized so `result.expression` re-parses
1150
1200
  // (`4d6min-2` is a syntax error; `4d6min(-2)` is not).
1151
1201
  const code = boundValue < 0 ? `${node.bound}(${boundValue})` : `${node.bound}${boundValue}`;
1202
+ const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
1152
1203
 
1153
- ctx.expressionParts.push(`${targetExpr}${code}`);
1154
- ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
1204
+ ctx.expressionParts.push(modifierExpr);
1205
+ ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
1155
1206
 
1156
1207
  const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
1157
1208
  return {
@@ -1193,10 +1244,10 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
1193
1244
  propagateMetadata(ctx, targetCtx.versusMetadata);
1194
1245
 
1195
1246
  const code = node.order === 'ascending' ? 's' : 'sd';
1196
- const targetExpr = targetCtx.expressionParts.join('');
1247
+ const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
1197
1248
 
1198
- ctx.expressionParts.push(`${targetExpr}${code}`);
1199
- ctx.renderedParts.push(`${targetExpr}${code}${renderDice(sortedRolls)}`);
1249
+ ctx.expressionParts.push(modifierExpr);
1250
+ ctx.renderedParts.push(`${modifierExpr}${renderDice(sortedRolls)}`);
1200
1251
 
1201
1252
  return {
1202
1253
  total: target.total,
@@ -1254,14 +1305,13 @@ function evalCritThreshold(
1254
1305
  appendAll(ctx.rolls, targetCtx.rolls);
1255
1306
  propagateMetadata(ctx, targetCtx.versusMetadata);
1256
1307
 
1257
- const targetExpr = targetCtx.expressionParts.join('');
1258
- const codes = [
1308
+ const modifierExpr = [
1259
1309
  ...successResolved.map((t) => (t === 'default' ? 'cs' : `cs${t.operator}${t.value}`)),
1260
1310
  ...failResolved.map((t) => (t === 'default' ? 'cf' : `cf${t.operator}${t.value}`)),
1261
- ].join('');
1311
+ ].reduce(joinModifierCode, targetCtx.expressionParts.join(''));
1262
1312
 
1263
- ctx.expressionParts.push(`${targetExpr}${codes}`);
1264
- ctx.renderedParts.push(`${targetExpr}${codes}${renderDice(targetCtx.rolls)}`);
1313
+ ctx.expressionParts.push(modifierExpr);
1314
+ ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
1265
1315
 
1266
1316
  return {
1267
1317
  total: target.total,
@@ -1321,7 +1371,7 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
1321
1371
  const targetExpr = targetCtx.expressionParts.join('');
1322
1372
  const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
1323
1373
 
1324
- ctx.expressionParts.push(`${targetExpr}${keepDropCodes}`);
1374
+ ctx.expressionParts.push(joinModifierCode(targetExpr, keepDropCodes));
1325
1375
  ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
1326
1376
 
1327
1377
  return {
@@ -1344,10 +1394,17 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
1344
1394
  * success highlights inside a dropped span.
1345
1395
  */
1346
1396
  function stripInnerMarkers(rendered: string): string {
1347
- return rendered
1348
- .replace(/\*\*(-?\d+)\*\*/g, '$1')
1349
- .replace(/__(-?\d+)__/g, '$1')
1350
- .replace(/~~(-?\d+)~~/g, '$1');
1397
+ return stripTallyMarkers(rendered).replace(/~~(-?\d+)~~/g, '$1');
1398
+ }
1399
+
1400
+ /**
1401
+ * Strips success (`**`) and failure (`__`) markers from an already-rendered
1402
+ * sub-roll, leaving dropped dice struck. Pairs with a `TALLY_FLAGS` strip: the
1403
+ * tags and the text they produced have to go together, or `renderBreakdown`
1404
+ * stops reproducing `rendered`.
1405
+ */
1406
+ function stripTallyMarkers(rendered: string): string {
1407
+ return rendered.replace(/\*\*(-?\d+)\*\*/g, '$1').replace(/__(-?\d+)__/g, '$1');
1351
1408
  }
1352
1409
 
1353
1410
  /**
@@ -1374,10 +1431,16 @@ function evalGroupKeepDrop(
1374
1431
  expr: string;
1375
1432
  rendered: string;
1376
1433
  versusMetadata: EvalContext['versusMetadata'];
1434
+ scoredSuccesses: number;
1435
+ scoredFailures: number;
1377
1436
  };
1378
1437
 
1379
1438
  const subRolls: SubRoll[] = group.expressions.map((expr) => {
1380
1439
  const subCtx = createContext();
1440
+ // What a subtotal count inside this sub-roll scored, so a drop can take it
1441
+ // back — the tally counterpart of the `TALLY_FLAGS` rewrite below.
1442
+ const successTally = env.subtotalSuccesses;
1443
+ const failureTally = env.subtotalFailures;
1381
1444
  const sub = evalNode(expr, rng, subCtx, env);
1382
1445
  return {
1383
1446
  subtotal: sub.total,
@@ -1386,6 +1449,8 @@ function evalGroupKeepDrop(
1386
1449
  expr: subCtx.expressionParts.join(''),
1387
1450
  rendered: subCtx.renderedParts.join(''),
1388
1451
  versusMetadata: subCtx.versusMetadata,
1452
+ scoredSuccesses: env.subtotalSuccesses - successTally,
1453
+ scoredFailures: env.subtotalFailures - failureTally,
1389
1454
  };
1390
1455
  });
1391
1456
 
@@ -1418,6 +1483,10 @@ function evalGroupKeepDrop(
1418
1483
  for (const die of sub.rolls) {
1419
1484
  die.modifiers = rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'dropped');
1420
1485
  }
1486
+ // A count on subtotals left no tag for the rewrite above to strip, so its
1487
+ // verdicts come back from the env tally instead.
1488
+ env.subtotalSuccesses -= sub.scoredSuccesses;
1489
+ env.subtotalFailures -= sub.scoredFailures;
1421
1490
  appendAll(ctx.rolls, sub.rolls);
1422
1491
  outerRendered.push(`~~${stripInnerMarkers(sub.rendered)}~~`);
1423
1492
  } else {
@@ -1505,14 +1574,24 @@ function evalSuccessCount(
1505
1574
  ctx: EvalContext,
1506
1575
  env: EvalEnv,
1507
1576
  ): EvalResult {
1508
- // Flag tracks syntactic presence of success-count notation, not pool size —
1509
- // set before any early return so empty pools still populate successes/failures.
1510
- env.hasSuccessCount = true;
1577
+ // Rolled back below, so a subtotal count nested in the target
1578
+ // (`{{2d6, 2d6}>=10, 1d8}>=1`) is not reported alongside the subtotal this
1579
+ // pass re-scores it into.
1580
+ const successTallyBefore = env.subtotalSuccesses;
1581
+ const failureTallyBefore = env.subtotalFailures;
1511
1582
 
1512
1583
  const targetCtx = createContext();
1513
1584
  const target = evalNode(node.target, rng, targetCtx, env);
1514
1585
  const targetExpr = targetCtx.expressionParts.join('');
1515
1586
 
1587
+ // True once any count has run: an inner one that tagged this very pool (only
1588
+ // a group can arrange that — `{4d6>=5}<=2f5`), or an unrelated earlier one,
1589
+ // which costs a redundant strip over dice nothing tagged. The flag otherwise
1590
+ // tracks syntactic presence of success-count notation, not pool size — set
1591
+ // before any early return so empty pools still populate successes/failures.
1592
+ const poolAlreadyCounted = env.hasSuccessCount;
1593
+ env.hasSuccessCount = true;
1594
+
1516
1595
  const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
1517
1596
  const failValue =
1518
1597
  node.failThreshold != null
@@ -1543,27 +1622,66 @@ function evalSuccessCount(
1543
1622
  return part;
1544
1623
  };
1545
1624
 
1546
- // No-op when the target produced no dice (`0d6>=4`); `containsDicePool`
1547
- // should already reject dice-less targets at parse time. `target.total` is 0
1548
- // for an empty pool, so `total === successes - failures` still holds.
1549
- if (targetCtx.rolls.length === 0) {
1625
+ // Multi-sub-roll group: the units are sub-roll subtotals, not dice. The
1626
+ // `sides = 0` synthetics are `evalGroupKeepDrop`'s sentinel and never reach
1627
+ // `ctx.rolls`. Only a direct group target arrives here the parser refuses
1628
+ // every form that would reach the count with the subtotals already gone.
1629
+ const bySubtotal = node.target.type === 'Group' && node.target.expressions.length >= 2;
1630
+ const pool: DieResult[] = bySubtotal
1631
+ ? (target.part as Extract<RollPart, { type: 'group' }>).parts.map((sub) => ({
1632
+ sides: 0,
1633
+ result: sub.total,
1634
+ modifiers: [],
1635
+ critical: false,
1636
+ fumble: false,
1637
+ }))
1638
+ : targetCtx.rolls;
1639
+
1640
+ // ! Releases every die an inner count tagged, `vs` DC dice included, though
1641
+ // ! `countSuccesses` spares those. The marker strip below reads rendered text
1642
+ // ! and cannot tell a DC die apart, so sparing one here leaves a tag whose
1643
+ // ! `**` is already gone and `renderBreakdown` stops reproducing `rendered`.
1644
+ if (bySubtotal && poolAlreadyCounted) {
1645
+ for (const die of targetCtx.rolls) {
1646
+ die.modifiers = stripFlags(die.modifiers, TALLY_FLAGS);
1647
+ }
1648
+ }
1649
+
1650
+ // An empty pool (`0d6>=4`) scores zero of both, so its total is 0 — never
1651
+ // `target.total`, which would break `total === successes - failures`.
1652
+ // Reachable only through a zero-count pool — a target holding no dice node at
1653
+ // all is rejected at parse time.
1654
+ if (pool.length === 0) {
1550
1655
  ctx.expressionParts.push(`${targetExpr}${code}`);
1551
1656
  ctx.renderedParts.push(`${targetExpr}${code}`);
1552
- return { total: target.total, part: buildPart(target.total, 0, 0) };
1657
+ return { total: 0, part: buildPart(0, 0, 0) };
1553
1658
  }
1554
1659
 
1555
1660
  const result = countSuccesses(
1556
- targetCtx.rolls,
1661
+ pool,
1557
1662
  { operator: node.threshold.operator, value: thresholdValue },
1558
1663
  failValue != null && node.failThreshold != null
1559
1664
  ? { operator: node.failThreshold.operator, value: failValue }
1560
1665
  : undefined,
1561
- env.hasVersusDc,
1666
+ !bySubtotal && env.hasVersusDc,
1667
+ !bySubtotal && poolAlreadyCounted,
1562
1668
  );
1563
1669
 
1670
+ if (bySubtotal) {
1671
+ env.subtotalSuccesses = successTallyBefore + result.successes;
1672
+ env.subtotalFailures = failureTallyBefore + result.failures;
1673
+ }
1674
+
1564
1675
  appendAll(ctx.rolls, targetCtx.rolls);
1565
1676
  ctx.expressionParts.push(`${targetExpr}${code}`);
1566
- ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
1677
+ // A subtotal count renders through the group — its sub-rolls carry their own
1678
+ // brackets, and one flat bracket would spell out the units it never used. The
1679
+ // strip pairs with the tag release above: markers and tags go together.
1680
+ ctx.renderedParts.push(
1681
+ bySubtotal
1682
+ ? `${stripTallyMarkers(targetCtx.renderedParts.join(''))}${code}`
1683
+ : `${targetExpr}${code}${renderDice(targetCtx.rolls)}`,
1684
+ );
1567
1685
 
1568
1686
  return {
1569
1687
  total: result.total,
@@ -1581,22 +1699,18 @@ function evalSuccessCount(
1581
1699
  * `undefined`.
1582
1700
  *
1583
1701
  * Excludes dropped (`kh`/`kl`/`dh`/`dl`/`r`/`ro`) dice — these aren't the
1584
- * final kept result. Explosion continuation dice (appended by standard/
1585
- * penetrating explode, tagged `'exploded'` with no `initialResult`) are not
1586
- * primaries either `1d20! vs DC` keeps the natural from the original d20.
1587
- * Compound explode accumulates into the original die and sets
1588
- * `initialResult`, so it stays a primary and the raw first face is used.
1589
- * Multiple primary kept d20s (e.g., `1d20+1d20`) yield `undefined` so no
1590
- * ambiguous upgrade/downgrade is applied.
1702
+ * final kept result and the continuation dice `env.explosionDice` marks, so
1703
+ * `1d20! vs DC` keeps the natural from the original d20. A compound explode
1704
+ * accumulates into that original instead of appending, so it stays a primary
1705
+ * and its raw first face is used. Multiple primary kept d20s (e.g.,
1706
+ * `1d20+1d20`) yield `undefined` so no ambiguous upgrade/downgrade is applied.
1591
1707
  */
1592
- function extractNatural(rolls: DieResult[]): number | undefined {
1708
+ function extractNatural(rolls: DieResult[], env: EvalEnv): number | undefined {
1593
1709
  // Rerolled intermediates are always stamped `['rerolled', 'dropped']`
1594
1710
  // (see `modifiers/reroll.ts`), so filtering by `'dropped'` covers them.
1711
+ const appended = env.explosionDice;
1595
1712
  const primaries = rolls.filter(
1596
- (d) =>
1597
- d.sides === 20 &&
1598
- !d.modifiers.includes('dropped') &&
1599
- !(d.modifiers.includes('exploded') && d.initialResult == null),
1713
+ (d) => d.sides === 20 && !d.modifiers.includes('dropped') && !appended?.has(d),
1600
1714
  );
1601
1715
  if (primaries.length !== 1) return undefined;
1602
1716
  const die = primaries[0];
@@ -1642,10 +1756,13 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1642
1756
  try {
1643
1757
  const rollCtx = createContext();
1644
1758
  const rollResult = evalNode(node.roll, rng, rollCtx, env);
1645
- const natural = extractNatural(rollCtx.rolls);
1759
+ const natural = extractNatural(rollCtx.rolls, env);
1646
1760
 
1647
1761
  const dcCtx = createContext();
1648
- const dcResult = evalNode(node.dc, rng, dcCtx, env);
1762
+ // A subtotal count on the DC side (`1d20 vs {{2d6, 2d6}>=10}`) is rolled
1763
+ // back for the same reason `countTaggedDice` skips DC dice: no pool pass
1764
+ // may tally that side.
1765
+ const dcResult = evalDiscardingSubtotals(node.dc, rng, dcCtx, env);
1649
1766
 
1650
1767
  const degree = calculateDegree(rollResult.total, dcResult.total, natural);
1651
1768
 
@@ -1751,9 +1868,12 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
1751
1868
  maxRerollIterations,
1752
1869
  totalDiceRolled: 0,
1753
1870
  hasSuccessCount: false,
1871
+ subtotalSuccesses: 0,
1872
+ subtotalFailures: 0,
1754
1873
  insideVersus: false,
1755
1874
  hasVersusDc: false,
1756
1875
  critRules: undefined,
1876
+ explosionDice: undefined,
1757
1877
  context,
1758
1878
  onMissingVariable,
1759
1879
  };
@@ -1785,24 +1905,27 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
1785
1905
  rendered,
1786
1906
  rolls: ctx.rolls,
1787
1907
  parts: part,
1788
- ...(env.hasSuccessCount ? countTaggedDice(ctx.rolls, env.hasVersusDc) : {}),
1908
+ ...(env.hasSuccessCount ? countTaggedDice(ctx.rolls, env) : {}),
1789
1909
  ...(versus ? { degree: versus.degree } : {}),
1790
1910
  ...(versus?.natural != null ? { natural: versus.natural } : {}),
1791
1911
  };
1792
1912
  }
1793
1913
 
1794
- /** Tallies the `'success'` / `'failure'` tags across a whole roll. */
1914
+ /**
1915
+ * Tallies the `'success'` / `'failure'` tags across a whole roll, on top of
1916
+ * what a group count scored on subtotals — those carry no tag to find.
1917
+ */
1795
1918
  function countTaggedDice(
1796
1919
  rolls: DieResult[],
1797
- hasVersusDc: boolean,
1920
+ env: EvalEnv,
1798
1921
  ): { successes: number; failures: number } {
1799
- let successes = 0;
1800
- let failures = 0;
1922
+ let successes = env.subtotalSuccesses;
1923
+ let failures = env.subtotalFailures;
1801
1924
 
1802
1925
  for (const die of rolls) {
1803
1926
  // A success-count inside the DC sub-expression tags its own dice before
1804
1927
  // `evalVersus` marks them `'dc'`, so they arrive here already tagged.
1805
- if (hasVersusDc && isVersusDc(die)) continue;
1928
+ if (env.hasVersusDc && isVersusDc(die)) continue;
1806
1929
  if (die.modifiers.includes('success')) successes += 1;
1807
1930
  else if (die.modifiers.includes('failure')) failures += 1;
1808
1931
  }
@@ -11,13 +11,13 @@
11
11
  *
12
12
  * The two threshold kinds deliberately read different values. `'default'`
13
13
  * reads `initialResult ?? result`, the same source as the versus `natural`,
14
- * so "rolled the maximum face" survives the two modifiers that overwrite
15
- * `result` while recording the face they replaced — compound explode and
16
- * `minN`/`maxN`. An explicit threshold (`cs>4`, `cf<=2`) reads the die's
17
- * current `result`: it is a predicate over the die's value, and postfix
18
- * modifiers are order-sensitive by design, so `4d6min5cs>4` is meant to
19
- * see the clamped faces. The two can therefore disagree on one die —
20
- * `4d6min5cs>4` flags a clamped natural 1 as both critical and fumble.
14
+ * so "rolled the maximum face" survives every modifier that overwrites
15
+ * `result` while recording the face it replaced — compound explode,
16
+ * penetrating explode, and `minN`/`maxN`. An explicit threshold (`cs>4`,
17
+ * `cf<=2`) reads the die's current `result`: it is a predicate over the die's
18
+ * value, and postfix modifiers are order-sensitive by design, so `4d6min5cs>4`
19
+ * is meant to see the clamped faces. The two can therefore disagree on one
20
+ * die — `4d6min5cs>4` flags a clamped natural 1 as both critical and fumble.
21
21
  *
22
22
  * They also diverge on Fate dice. `'default'` carries a `sides > 1` guard, so
23
23
  * it never fires on a `sides = 0` pool; an explicit threshold has none and
@@ -26,15 +26,6 @@
26
26
  * `cs`/`cf` forms instead, since they would resolve to `'default'` and
27
27
  * silently do nothing.
28
28
  *
29
- * ! Penetrating explode is not covered: it stores `raw - 1` in `result`
30
- * ! without recording `initialResult`, so `1d6!pcs` still judges a natural
31
- * ! 6 by its decremented 5. An *inherited* rule is handed the raw roll
32
- * ! instead, which keeps `1d6cf>5!p` agreeing with `1d6!p` on the side the
33
- * ! user never overrode — at the cost of `1d6cf>5!p` and `1d6!pcf>5`
34
- * ! disagreeing on it. Recording `initialResult` here would settle both, but
35
- * ! `extractNatural` reads that field to tell an appended explosion die from
36
- * ! a compounded one, and would start counting these as versus primaries.
37
- *
38
29
  * The rule is recorded per die on `env.critRules`, so dice that explode and
39
30
  * reroll mint *after* the crit node has run inherit it from the die they
40
31
  * descended from. `cs`/`cf` therefore covers the whole pool wherever it sits
@@ -57,11 +48,8 @@ import { matchesCondition } from './compare.js';
57
48
  import { isVersusDc } from './flags.js';
58
49
 
59
50
  /**
60
- * Applies success/fail threshold arrays to a dice pool, overriding each
61
- * die's `critical` and `fumble` flags in place. Writing `natural` for
62
- * `initialResult ?? result`, a die matches `'default'` on the success side
63
- * when `natural === sides && sides > 1`, and on the fail side when
64
- * `natural === 1 && sides > 1`. Meta dice are skipped.
51
+ * Applies success/fail threshold arrays to a dice pool, overriding each die's
52
+ * `critical` and `fumble` flags in place. Meta and DC dice are skipped.
65
53
  *
66
54
  * Also records the rule against every die it touched, so later explode and
67
55
  * reroll dice can inherit it via {@link inheritCritRule}.
@@ -88,12 +76,11 @@ export function applyCritThresholds(
88
76
  /**
89
77
  * Judges one die by a recorded rule, overwriting both flags. A side always
90
78
  * carries at least `'default'` — `evalCritThreshold` fills the side the user
91
- * left out — so neither flag is silently left untouched. `natural` is the face
92
- * the `'default'` sentinel reads; an explicit threshold always reads `result`.
79
+ * left out — so neither flag is silently left untouched.
93
80
  */
94
81
  function applyCritRule(die: DieResult, rule: CritRule, natural: number): void {
95
- die.critical = rule.success.some((t) => matchesCrit(t, die, natural));
96
- die.fumble = rule.fail.some((t) => matchesFumble(t, die, natural));
82
+ die.critical = rule.success.some((t) => matchesThreshold(t, die, natural, die.sides));
83
+ die.fumble = rule.fail.some((t) => matchesThreshold(t, die, natural, 1));
97
84
  }
98
85
 
99
86
  /**
@@ -102,40 +89,34 @@ function applyCritRule(die: DieResult, rule: CritRule, natural: number): void {
102
89
  * reroll inherits it in turn. No-op when no `cs`/`cf` governs `parent`, which
103
90
  * leaves the `createDieResult` default rule in place.
104
91
  *
105
- * `natural` is the face the `'default'` sentinel reads, defaulting to the
106
- * child's own. Penetrating explode passes its raw roll see the module note.
107
- *
108
- * ! Call this only once the child's final `result` is stored — an explicit
109
- * ! threshold is a predicate over `result`, so a penetrating die is judged by
110
- * ! its decremented value, matching `1d6!pcs<2`.
92
+ * ! Call this only once the child's final `result` and `initialResult` are
93
+ * ! stored an explicit threshold is a predicate over `result`, so a
94
+ * ! penetrating die is judged by its decremented value, matching `1d6!pcs<2`.
111
95
  */
112
- export function inheritCritRule(
113
- env: EvalEnv,
114
- parent: DieResult,
115
- child: DieResult,
116
- natural = child.initialResult ?? child.result,
117
- ): void {
96
+ export function inheritCritRule(env: EvalEnv, parent: DieResult, child: DieResult): void {
118
97
  const rules = env.critRules;
119
98
  if (rules === undefined) return;
120
99
 
121
100
  const rule = rules.get(parent);
122
101
  if (rule === undefined) return;
123
102
 
124
- applyCritRule(child, rule, natural);
103
+ applyCritRule(child, rule, child.initialResult ?? child.result);
125
104
  rules.set(child, rule);
126
105
  }
127
106
 
128
- function matchesCrit(threshold: ResolvedCritThreshold, die: DieResult, natural: number): boolean {
129
- if (threshold === 'default') {
130
- return natural === die.sides && die.sides > 1;
131
- }
132
- return matchesCondition(die.result, threshold.operator, threshold.value);
133
- }
134
-
135
- function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult, natural: number): boolean {
107
+ /**
108
+ * `defaultFace` is the face the `'default'` sentinel looks for — `die.sides`
109
+ * on the success side, `1` on the fail side. The `sides > 1` guard mirrors
110
+ * `createDieResult`: a d1 always rolls 1, so it is neither.
111
+ */
112
+ function matchesThreshold(
113
+ threshold: ResolvedCritThreshold,
114
+ die: DieResult,
115
+ natural: number,
116
+ defaultFace: number,
117
+ ): boolean {
136
118
  if (threshold === 'default') {
137
- // Mirrors `createDieResult` a d1 always rolls 1, never a fumble.
138
- return natural === 1 && die.sides > 1;
119
+ return natural === defaultFace && die.sides > 1;
139
120
  }
140
121
  return matchesCondition(die.result, threshold.operator, threshold.value);
141
122
  }
@@ -98,11 +98,14 @@ function canExplode(die: DieResult, hasVersusDc: boolean): boolean {
98
98
  * the value recorded on the appended die, which is the only thing standard
99
99
  * and penetrating explosions disagree about.
100
100
  *
101
+ * A die whose stored value differs from its raw roll records the raw face in
102
+ * `initialResult`, so `initialResult ?? result` recovers what was rolled.
103
+ *
101
104
  * `critical`/`fumble` are likewise derived from the raw roll — a penetrating
102
105
  * die that rolled its max face is still a crit even though it stores one less.
103
- * An inherited `cs`/`cf` keeps that: it is applied after `storeResult`, so an
104
- * explicit threshold reads the stored value, while the raw roll is handed over
105
- * for the `'default'` sentinel to read.
106
+ * A `cs`/`cf` rule keeps that: it is applied after `storeResult`, so an
107
+ * explicit threshold reads the stored value while the `'default'` sentinel
108
+ * reads `initialResult`.
106
109
  */
107
110
  function applyAppendingExplode(
108
111
  pool: DieResult[],
@@ -128,7 +131,13 @@ function applyAppendingExplode(
128
131
  const raw = rollExplosion(sides, rng, env);
129
132
  const die = createDieResult(sides, raw, ['exploded', 'kept']);
130
133
  die.result = storeResult(raw);
131
- inheritCritRule(env, original, die, raw);
134
+ if (die.result !== raw) die.initialResult = raw;
135
+ // Only `extractNatural` reads this, and only inside a `vs`.
136
+ if (env.insideVersus) {
137
+ env.explosionDice ??= new WeakSet();
138
+ env.explosionDice.add(die);
139
+ }
140
+ inheritCritRule(env, original, die);
132
141
  result.push(die);
133
142
  lastRaw = raw;
134
143
  iterations += 1;
@@ -22,8 +22,8 @@ import type { DieModifier, DieResult } from '../../types.js';
22
22
  * `{1d20 vs 2d10, 1d4}>=5` must not count the DC faces as successes.
23
23
  *
24
24
  * ! Call this behind the shared `hasVersusDc` env flag, never bare. Unguarded
25
- * ! inside a per-die loop it cost 11-38% on notation that cannot carry the tag
26
- * ! (#281); the flag is `false` until a `vs` has actually tagged something.
25
+ * ! inside a per-die loop it cost 11-38% on notation that cannot carry the
26
+ * ! tag; the flag is `false` until a `vs` has actually tagged something.
27
27
  */
28
28
  export function isVersusDc(die: DieResult): boolean {
29
29
  return die.modifiers.includes('dc');
@@ -32,16 +32,22 @@ export function isVersusDc(die: DieResult): boolean {
32
32
  /** Kept/dropped selection flags — rebuilt by every keep/drop pass. */
33
33
  export const SELECTION_FLAGS: readonly DieModifier[] = ['kept', 'dropped'];
34
34
 
35
+ /**
36
+ * Success-count tally flags — rebuilt by every counting pass. A group lets a
37
+ * second count reach a pool the first one already tagged (`{4d6>=5}<=2f5`);
38
+ * stripping these first is what keeps the outermost count the only one the
39
+ * tags describe.
40
+ */
41
+ export const TALLY_FLAGS: readonly DieModifier[] = ['success', 'failure'];
42
+
35
43
  /**
36
44
  * Selection flags plus the success-count tally flags. Stripped when a die
37
45
  * leaves the pool that tagged it (meta sub-expressions, dropped group
38
46
  * sub-rolls) so the top-level successes/failures scan cannot count it.
39
47
  */
40
48
  export const SELECTION_AND_TALLY_FLAGS: readonly DieModifier[] = [
41
- 'kept',
42
- 'dropped',
43
- 'success',
44
- 'failure',
49
+ ...SELECTION_FLAGS,
50
+ ...TALLY_FLAGS,
45
51
  ];
46
52
 
47
53
  /**
@@ -49,13 +55,7 @@ export const SELECTION_AND_TALLY_FLAGS: readonly DieModifier[] = [
49
55
  * into a parent. Meta operands nest (`((1d2)d4)d6`), so a die passes through
50
56
  * the merge once per level and the tag must be rebuilt, not appended.
51
57
  */
52
- export const META_MERGE_FLAGS: readonly DieModifier[] = [
53
- 'kept',
54
- 'dropped',
55
- 'success',
56
- 'failure',
57
- 'meta',
58
- ];
58
+ export const META_MERGE_FLAGS: readonly DieModifier[] = [...SELECTION_AND_TALLY_FLAGS, 'meta'];
59
59
 
60
60
  /** Selection flags plus `rerolled` — reassigned on every reroll pass. */
61
61
  export const REROLL_SLOT_FLAGS: readonly DieModifier[] = ['kept', 'dropped', 'rerolled'];