roll-parser 3.1.0 → 3.2.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/MIGRATION.md +225 -8
  3. package/README.md +185 -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 +80 -40
  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 +4 -4
  47. package/src/evaluator/env.ts +27 -1
  48. package/src/evaluator/evaluator.ts +148 -53
  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 {
@@ -480,11 +483,31 @@ function evalMetaOperand(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv
480
483
  }
481
484
 
482
485
  const metaCtx = createContext();
483
- const value = evalNode(node, rng, metaCtx, env).total;
486
+ // The tally counterpart of the `TALLY_FLAGS` strip in `mergeMetaRolls`.
487
+ const value = evalDiscardingSubtotals(node, rng, metaCtx, env).total;
484
488
  mergeMetaRolls(ctx, metaCtx);
485
489
  return value;
486
490
  }
487
491
 
492
+ /**
493
+ * Evaluates a node whose result is consumed as a scalar — a meta operand or a
494
+ * `vs` DC — rolling back any subtotal verdicts it scored so they never reach
495
+ * the top-level tally.
496
+ */
497
+ function evalDiscardingSubtotals(
498
+ node: ASTNode,
499
+ rng: RNG,
500
+ ctx: EvalContext,
501
+ env: EvalEnv,
502
+ ): EvalResult {
503
+ const successTally = env.subtotalSuccesses;
504
+ const failureTally = env.subtotalFailures;
505
+ const result = evalNode(node, rng, ctx, env);
506
+ env.subtotalSuccesses = successTally;
507
+ env.subtotalFailures = failureTally;
508
+ return result;
509
+ }
510
+
488
511
  /** Rejects dice counts that cannot address a pool. */
489
512
  function requireDiceCount(count: number, nodeType: 'Dice' | 'FateDice'): void {
490
513
  if (!Number.isInteger(count) || count < 0) {
@@ -1067,11 +1090,11 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
1067
1090
  function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1068
1091
  const targetCtx = createContext();
1069
1092
  const target = evalNode(node.target, rng, targetCtx, env);
1070
- const targetExpr = targetCtx.expressionParts.join('');
1071
1093
 
1072
1094
  const thresholdValue = evalMetaOperand(node.condition.value, rng, ctx, env);
1073
1095
 
1074
1096
  const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
1097
+ const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
1075
1098
  const condition: ResolvedComparePoint = {
1076
1099
  operator: node.condition.operator,
1077
1100
  value: thresholdValue,
@@ -1079,8 +1102,8 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1079
1102
 
1080
1103
  // No-op when the target produced no dice (e.g., `(1+2)r<5`).
1081
1104
  if (targetCtx.rolls.length === 0) {
1082
- ctx.expressionParts.push(`${targetExpr}${code}`);
1083
- ctx.renderedParts.push(`${targetExpr}${code}`);
1105
+ ctx.expressionParts.push(modifierExpr);
1106
+ ctx.renderedParts.push(modifierExpr);
1084
1107
  const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
1085
1108
  return {
1086
1109
  total,
@@ -1101,8 +1124,8 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1101
1124
  : applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
1102
1125
 
1103
1126
  appendAll(ctx.rolls, pool);
1104
- ctx.expressionParts.push(`${targetExpr}${code}`);
1105
- ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
1127
+ ctx.expressionParts.push(modifierExpr);
1128
+ ctx.renderedParts.push(`${modifierExpr}${renderDice(pool)}`);
1106
1129
 
1107
1130
  const total = sumKeptDice(pool, env.hasVersusDc);
1108
1131
  return {
@@ -1145,13 +1168,13 @@ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalE
1145
1168
  // ! would have been resolved against a total this node just replaced.
1146
1169
  // ! See the rule on `propagateMetadata`.
1147
1170
 
1148
- const targetExpr = targetCtx.expressionParts.join('');
1149
1171
  // Negative bounds render parenthesized so `result.expression` re-parses
1150
1172
  // (`4d6min-2` is a syntax error; `4d6min(-2)` is not).
1151
1173
  const code = boundValue < 0 ? `${node.bound}(${boundValue})` : `${node.bound}${boundValue}`;
1174
+ const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
1152
1175
 
1153
- ctx.expressionParts.push(`${targetExpr}${code}`);
1154
- ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
1176
+ ctx.expressionParts.push(modifierExpr);
1177
+ ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
1155
1178
 
1156
1179
  const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
1157
1180
  return {
@@ -1193,10 +1216,10 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
1193
1216
  propagateMetadata(ctx, targetCtx.versusMetadata);
1194
1217
 
1195
1218
  const code = node.order === 'ascending' ? 's' : 'sd';
1196
- const targetExpr = targetCtx.expressionParts.join('');
1219
+ const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
1197
1220
 
1198
- ctx.expressionParts.push(`${targetExpr}${code}`);
1199
- ctx.renderedParts.push(`${targetExpr}${code}${renderDice(sortedRolls)}`);
1221
+ ctx.expressionParts.push(modifierExpr);
1222
+ ctx.renderedParts.push(`${modifierExpr}${renderDice(sortedRolls)}`);
1200
1223
 
1201
1224
  return {
1202
1225
  total: target.total,
@@ -1254,14 +1277,13 @@ function evalCritThreshold(
1254
1277
  appendAll(ctx.rolls, targetCtx.rolls);
1255
1278
  propagateMetadata(ctx, targetCtx.versusMetadata);
1256
1279
 
1257
- const targetExpr = targetCtx.expressionParts.join('');
1258
- const codes = [
1280
+ const modifierExpr = [
1259
1281
  ...successResolved.map((t) => (t === 'default' ? 'cs' : `cs${t.operator}${t.value}`)),
1260
1282
  ...failResolved.map((t) => (t === 'default' ? 'cf' : `cf${t.operator}${t.value}`)),
1261
- ].join('');
1283
+ ].reduce(joinModifierCode, targetCtx.expressionParts.join(''));
1262
1284
 
1263
- ctx.expressionParts.push(`${targetExpr}${codes}`);
1264
- ctx.renderedParts.push(`${targetExpr}${codes}${renderDice(targetCtx.rolls)}`);
1285
+ ctx.expressionParts.push(modifierExpr);
1286
+ ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
1265
1287
 
1266
1288
  return {
1267
1289
  total: target.total,
@@ -1321,7 +1343,7 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
1321
1343
  const targetExpr = targetCtx.expressionParts.join('');
1322
1344
  const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
1323
1345
 
1324
- ctx.expressionParts.push(`${targetExpr}${keepDropCodes}`);
1346
+ ctx.expressionParts.push(joinModifierCode(targetExpr, keepDropCodes));
1325
1347
  ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
1326
1348
 
1327
1349
  return {
@@ -1344,10 +1366,17 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
1344
1366
  * success highlights inside a dropped span.
1345
1367
  */
1346
1368
  function stripInnerMarkers(rendered: string): string {
1347
- return rendered
1348
- .replace(/\*\*(-?\d+)\*\*/g, '$1')
1349
- .replace(/__(-?\d+)__/g, '$1')
1350
- .replace(/~~(-?\d+)~~/g, '$1');
1369
+ return stripTallyMarkers(rendered).replace(/~~(-?\d+)~~/g, '$1');
1370
+ }
1371
+
1372
+ /**
1373
+ * Strips success (`**`) and failure (`__`) markers from an already-rendered
1374
+ * sub-roll, leaving dropped dice struck. Pairs with a `TALLY_FLAGS` strip: the
1375
+ * tags and the text they produced have to go together, or `renderBreakdown`
1376
+ * stops reproducing `rendered`.
1377
+ */
1378
+ function stripTallyMarkers(rendered: string): string {
1379
+ return rendered.replace(/\*\*(-?\d+)\*\*/g, '$1').replace(/__(-?\d+)__/g, '$1');
1351
1380
  }
1352
1381
 
1353
1382
  /**
@@ -1374,10 +1403,16 @@ function evalGroupKeepDrop(
1374
1403
  expr: string;
1375
1404
  rendered: string;
1376
1405
  versusMetadata: EvalContext['versusMetadata'];
1406
+ scoredSuccesses: number;
1407
+ scoredFailures: number;
1377
1408
  };
1378
1409
 
1379
1410
  const subRolls: SubRoll[] = group.expressions.map((expr) => {
1380
1411
  const subCtx = createContext();
1412
+ // What a subtotal count inside this sub-roll scored, so a drop can take it
1413
+ // back — the tally counterpart of the `TALLY_FLAGS` rewrite below.
1414
+ const successTally = env.subtotalSuccesses;
1415
+ const failureTally = env.subtotalFailures;
1381
1416
  const sub = evalNode(expr, rng, subCtx, env);
1382
1417
  return {
1383
1418
  subtotal: sub.total,
@@ -1386,6 +1421,8 @@ function evalGroupKeepDrop(
1386
1421
  expr: subCtx.expressionParts.join(''),
1387
1422
  rendered: subCtx.renderedParts.join(''),
1388
1423
  versusMetadata: subCtx.versusMetadata,
1424
+ scoredSuccesses: env.subtotalSuccesses - successTally,
1425
+ scoredFailures: env.subtotalFailures - failureTally,
1389
1426
  };
1390
1427
  });
1391
1428
 
@@ -1418,6 +1455,10 @@ function evalGroupKeepDrop(
1418
1455
  for (const die of sub.rolls) {
1419
1456
  die.modifiers = rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'dropped');
1420
1457
  }
1458
+ // A count on subtotals left no tag for the rewrite above to strip, so its
1459
+ // verdicts come back from the env tally instead.
1460
+ env.subtotalSuccesses -= sub.scoredSuccesses;
1461
+ env.subtotalFailures -= sub.scoredFailures;
1421
1462
  appendAll(ctx.rolls, sub.rolls);
1422
1463
  outerRendered.push(`~~${stripInnerMarkers(sub.rendered)}~~`);
1423
1464
  } else {
@@ -1505,14 +1546,24 @@ function evalSuccessCount(
1505
1546
  ctx: EvalContext,
1506
1547
  env: EvalEnv,
1507
1548
  ): 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;
1549
+ // Rolled back below, so a subtotal count nested in the target
1550
+ // (`{{2d6, 2d6}>=10, 1d8}>=1`) is not reported alongside the subtotal this
1551
+ // pass re-scores it into.
1552
+ const successTallyBefore = env.subtotalSuccesses;
1553
+ const failureTallyBefore = env.subtotalFailures;
1511
1554
 
1512
1555
  const targetCtx = createContext();
1513
1556
  const target = evalNode(node.target, rng, targetCtx, env);
1514
1557
  const targetExpr = targetCtx.expressionParts.join('');
1515
1558
 
1559
+ // True once any count has run: an inner one that tagged this very pool (only
1560
+ // a group can arrange that — `{4d6>=5}<=2f5`), or an unrelated earlier one,
1561
+ // which costs a redundant strip over dice nothing tagged. The flag otherwise
1562
+ // tracks syntactic presence of success-count notation, not pool size — set
1563
+ // before any early return so empty pools still populate successes/failures.
1564
+ const poolAlreadyCounted = env.hasSuccessCount;
1565
+ env.hasSuccessCount = true;
1566
+
1516
1567
  const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
1517
1568
  const failValue =
1518
1569
  node.failThreshold != null
@@ -1543,27 +1594,66 @@ function evalSuccessCount(
1543
1594
  return part;
1544
1595
  };
1545
1596
 
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) {
1597
+ // Multi-sub-roll group: the units are sub-roll subtotals, not dice. The
1598
+ // `sides = 0` synthetics are `evalGroupKeepDrop`'s sentinel and never reach
1599
+ // `ctx.rolls`. Only a direct group target arrives here the parser refuses
1600
+ // every form that would reach the count with the subtotals already gone.
1601
+ const bySubtotal = node.target.type === 'Group' && node.target.expressions.length >= 2;
1602
+ const pool: DieResult[] = bySubtotal
1603
+ ? (target.part as Extract<RollPart, { type: 'group' }>).parts.map((sub) => ({
1604
+ sides: 0,
1605
+ result: sub.total,
1606
+ modifiers: [],
1607
+ critical: false,
1608
+ fumble: false,
1609
+ }))
1610
+ : targetCtx.rolls;
1611
+
1612
+ // ! Releases every die an inner count tagged, `vs` DC dice included, though
1613
+ // ! `countSuccesses` spares those. The marker strip below reads rendered text
1614
+ // ! and cannot tell a DC die apart, so sparing one here leaves a tag whose
1615
+ // ! `**` is already gone and `renderBreakdown` stops reproducing `rendered`.
1616
+ if (bySubtotal && poolAlreadyCounted) {
1617
+ for (const die of targetCtx.rolls) {
1618
+ die.modifiers = stripFlags(die.modifiers, TALLY_FLAGS);
1619
+ }
1620
+ }
1621
+
1622
+ // An empty pool (`0d6>=4`) scores zero of both, so its total is 0 — never
1623
+ // `target.total`, which would break `total === successes - failures`.
1624
+ // Reachable only through a zero-count pool — a target holding no dice node at
1625
+ // all is rejected at parse time.
1626
+ if (pool.length === 0) {
1550
1627
  ctx.expressionParts.push(`${targetExpr}${code}`);
1551
1628
  ctx.renderedParts.push(`${targetExpr}${code}`);
1552
- return { total: target.total, part: buildPart(target.total, 0, 0) };
1629
+ return { total: 0, part: buildPart(0, 0, 0) };
1553
1630
  }
1554
1631
 
1555
1632
  const result = countSuccesses(
1556
- targetCtx.rolls,
1633
+ pool,
1557
1634
  { operator: node.threshold.operator, value: thresholdValue },
1558
1635
  failValue != null && node.failThreshold != null
1559
1636
  ? { operator: node.failThreshold.operator, value: failValue }
1560
1637
  : undefined,
1561
- env.hasVersusDc,
1638
+ !bySubtotal && env.hasVersusDc,
1639
+ !bySubtotal && poolAlreadyCounted,
1562
1640
  );
1563
1641
 
1642
+ if (bySubtotal) {
1643
+ env.subtotalSuccesses = successTallyBefore + result.successes;
1644
+ env.subtotalFailures = failureTallyBefore + result.failures;
1645
+ }
1646
+
1564
1647
  appendAll(ctx.rolls, targetCtx.rolls);
1565
1648
  ctx.expressionParts.push(`${targetExpr}${code}`);
1566
- ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
1649
+ // A subtotal count renders through the group — its sub-rolls carry their own
1650
+ // brackets, and one flat bracket would spell out the units it never used. The
1651
+ // strip pairs with the tag release above: markers and tags go together.
1652
+ ctx.renderedParts.push(
1653
+ bySubtotal
1654
+ ? `${stripTallyMarkers(targetCtx.renderedParts.join(''))}${code}`
1655
+ : `${targetExpr}${code}${renderDice(targetCtx.rolls)}`,
1656
+ );
1567
1657
 
1568
1658
  return {
1569
1659
  total: result.total,
@@ -1581,22 +1671,18 @@ function evalSuccessCount(
1581
1671
  * `undefined`.
1582
1672
  *
1583
1673
  * 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.
1674
+ * final kept result and the continuation dice `env.explosionDice` marks, so
1675
+ * `1d20! vs DC` keeps the natural from the original d20. A compound explode
1676
+ * accumulates into that original instead of appending, so it stays a primary
1677
+ * and its raw first face is used. Multiple primary kept d20s (e.g.,
1678
+ * `1d20+1d20`) yield `undefined` so no ambiguous upgrade/downgrade is applied.
1591
1679
  */
1592
- function extractNatural(rolls: DieResult[]): number | undefined {
1680
+ function extractNatural(rolls: DieResult[], env: EvalEnv): number | undefined {
1593
1681
  // Rerolled intermediates are always stamped `['rerolled', 'dropped']`
1594
1682
  // (see `modifiers/reroll.ts`), so filtering by `'dropped'` covers them.
1683
+ const appended = env.explosionDice;
1595
1684
  const primaries = rolls.filter(
1596
- (d) =>
1597
- d.sides === 20 &&
1598
- !d.modifiers.includes('dropped') &&
1599
- !(d.modifiers.includes('exploded') && d.initialResult == null),
1685
+ (d) => d.sides === 20 && !d.modifiers.includes('dropped') && !appended?.has(d),
1600
1686
  );
1601
1687
  if (primaries.length !== 1) return undefined;
1602
1688
  const die = primaries[0];
@@ -1642,10 +1728,13 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1642
1728
  try {
1643
1729
  const rollCtx = createContext();
1644
1730
  const rollResult = evalNode(node.roll, rng, rollCtx, env);
1645
- const natural = extractNatural(rollCtx.rolls);
1731
+ const natural = extractNatural(rollCtx.rolls, env);
1646
1732
 
1647
1733
  const dcCtx = createContext();
1648
- const dcResult = evalNode(node.dc, rng, dcCtx, env);
1734
+ // A subtotal count on the DC side (`1d20 vs {{2d6, 2d6}>=10}`) is rolled
1735
+ // back for the same reason `countTaggedDice` skips DC dice: no pool pass
1736
+ // may tally that side.
1737
+ const dcResult = evalDiscardingSubtotals(node.dc, rng, dcCtx, env);
1649
1738
 
1650
1739
  const degree = calculateDegree(rollResult.total, dcResult.total, natural);
1651
1740
 
@@ -1751,9 +1840,12 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
1751
1840
  maxRerollIterations,
1752
1841
  totalDiceRolled: 0,
1753
1842
  hasSuccessCount: false,
1843
+ subtotalSuccesses: 0,
1844
+ subtotalFailures: 0,
1754
1845
  insideVersus: false,
1755
1846
  hasVersusDc: false,
1756
1847
  critRules: undefined,
1848
+ explosionDice: undefined,
1757
1849
  context,
1758
1850
  onMissingVariable,
1759
1851
  };
@@ -1785,24 +1877,27 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
1785
1877
  rendered,
1786
1878
  rolls: ctx.rolls,
1787
1879
  parts: part,
1788
- ...(env.hasSuccessCount ? countTaggedDice(ctx.rolls, env.hasVersusDc) : {}),
1880
+ ...(env.hasSuccessCount ? countTaggedDice(ctx.rolls, env) : {}),
1789
1881
  ...(versus ? { degree: versus.degree } : {}),
1790
1882
  ...(versus?.natural != null ? { natural: versus.natural } : {}),
1791
1883
  };
1792
1884
  }
1793
1885
 
1794
- /** Tallies the `'success'` / `'failure'` tags across a whole roll. */
1886
+ /**
1887
+ * Tallies the `'success'` / `'failure'` tags across a whole roll, on top of
1888
+ * what a group count scored on subtotals — those carry no tag to find.
1889
+ */
1795
1890
  function countTaggedDice(
1796
1891
  rolls: DieResult[],
1797
- hasVersusDc: boolean,
1892
+ env: EvalEnv,
1798
1893
  ): { successes: number; failures: number } {
1799
- let successes = 0;
1800
- let failures = 0;
1894
+ let successes = env.subtotalSuccesses;
1895
+ let failures = env.subtotalFailures;
1801
1896
 
1802
1897
  for (const die of rolls) {
1803
1898
  // A success-count inside the DC sub-expression tags its own dice before
1804
1899
  // `evalVersus` marks them `'dc'`, so they arrive here already tagged.
1805
- if (hasVersusDc && isVersusDc(die)) continue;
1900
+ if (env.hasVersusDc && isVersusDc(die)) continue;
1806
1901
  if (die.modifiers.includes('success')) successes += 1;
1807
1902
  else if (die.modifiers.includes('failure')) failures += 1;
1808
1903
  }
@@ -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'];
@@ -134,12 +134,10 @@ function markSingleExtreme(
134
134
  }
135
135
 
136
136
  /**
137
- * Calculates total from dice, excluding dropped dice.
137
+ * Sums the dice a keep/drop pass left standing.
138
138
  *
139
- * @param dice - Array of die results
140
139
  * @param hasVersusDc - Shared env flag; skips the DC exclusion when no `vs` has
141
140
  * tagged anything
142
- * @returns Sum of non-dropped dice
143
141
  */
144
142
  export function sumKeptDice(dice: DieResult[], hasVersusDc: boolean): number {
145
143
  let total = 0;
@@ -37,7 +37,7 @@ export function sortDice(
37
37
  : (a: DieResult, b: DieResult) => b.result - a.result;
38
38
 
39
39
  // Scan before allocating: the `filter` this replaced built a throwaway array
40
- // on every sort to serve a case only a `vs` can produce (#281).
40
+ // on every sort to serve a case only a `vs` can produce.
41
41
  if (!hasVersusDc || !dice.some(isVersusDc)) return [...dice].sort(cmp);
42
42
 
43
43
  // Sort only the pool members, then lay them back into the slots they came