roll-parser 3.0.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 (88) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/MIGRATION.md +293 -4
  3. package/README.md +246 -55
  4. package/dist/cli/format.d.ts.map +1 -1
  5. package/dist/cli/format.js +8 -7
  6. package/dist/cli/format.js.map +1 -1
  7. package/dist/evaluator/die.d.ts +2 -1
  8. package/dist/evaluator/die.d.ts.map +1 -1
  9. package/dist/evaluator/die.js.map +1 -1
  10. package/dist/evaluator/env.d.ts +57 -0
  11. package/dist/evaluator/env.d.ts.map +1 -1
  12. package/dist/evaluator/env.js.map +1 -1
  13. package/dist/evaluator/evaluator.d.ts.map +1 -1
  14. package/dist/evaluator/evaluator.js +104 -56
  15. package/dist/evaluator/evaluator.js.map +1 -1
  16. package/dist/evaluator/modifiers/crit-threshold.d.ts +43 -5
  17. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  18. package/dist/evaluator/modifiers/crit-threshold.js +24 -11
  19. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -1
  20. package/dist/evaluator/modifiers/die-bound.d.ts +5 -2
  21. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -1
  22. package/dist/evaluator/modifiers/die-bound.js +6 -3
  23. package/dist/evaluator/modifiers/die-bound.js.map +1 -1
  24. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  25. package/dist/evaluator/modifiers/explode.js +13 -5
  26. package/dist/evaluator/modifiers/explode.js.map +1 -1
  27. package/dist/evaluator/modifiers/flags.d.ts +17 -0
  28. package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
  29. package/dist/evaluator/modifiers/flags.js +4 -4
  30. package/dist/evaluator/modifiers/flags.js.map +1 -1
  31. package/dist/evaluator/modifiers/keep-drop.d.ts +5 -5
  32. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  33. package/dist/evaluator/modifiers/keep-drop.js +7 -7
  34. package/dist/evaluator/modifiers/keep-drop.js.map +1 -1
  35. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  36. package/dist/evaluator/modifiers/reroll.js +16 -10
  37. package/dist/evaluator/modifiers/reroll.js.map +1 -1
  38. package/dist/evaluator/modifiers/sort.d.ts +1 -1
  39. package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/sort.js +3 -3
  41. package/dist/evaluator/modifiers/sort.js.map +1 -1
  42. package/dist/evaluator/modifiers/success-count.d.ts +6 -2
  43. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  44. package/dist/evaluator/modifiers/success-count.js +9 -5
  45. package/dist/evaluator/modifiers/success-count.js.map +1 -1
  46. package/dist/notation.d.ts +13 -0
  47. package/dist/notation.d.ts.map +1 -0
  48. package/dist/notation.js +8 -0
  49. package/dist/notation.js.map +1 -0
  50. package/dist/parser/ast.d.ts +12 -7
  51. package/dist/parser/ast.d.ts.map +1 -1
  52. package/dist/parser/ast.js.map +1 -1
  53. package/dist/parser/guards.d.ts +38 -0
  54. package/dist/parser/guards.d.ts.map +1 -1
  55. package/dist/parser/guards.js +51 -0
  56. package/dist/parser/guards.js.map +1 -1
  57. package/dist/parser/parser.d.ts.map +1 -1
  58. package/dist/parser/parser.js +26 -2
  59. package/dist/parser/parser.js.map +1 -1
  60. package/dist/render.d.ts +95 -0
  61. package/dist/render.d.ts.map +1 -0
  62. package/dist/render.js +232 -0
  63. package/dist/render.js.map +1 -0
  64. package/dist/types.d.ts +64 -12
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js.map +1 -1
  67. package/dist/version.d.ts +1 -1
  68. package/dist/version.js +1 -1
  69. package/package.json +14 -4
  70. package/src/cli/format.ts +15 -23
  71. package/src/evaluator/die.ts +2 -1
  72. package/src/evaluator/env.ts +58 -0
  73. package/src/evaluator/evaluator.ts +192 -70
  74. package/src/evaluator/modifiers/crit-threshold.ts +81 -15
  75. package/src/evaluator/modifiers/die-bound.ts +13 -4
  76. package/src/evaluator/modifiers/explode.ts +21 -6
  77. package/src/evaluator/modifiers/flags.ts +21 -4
  78. package/src/evaluator/modifiers/keep-drop.ts +10 -8
  79. package/src/evaluator/modifiers/reroll.ts +20 -11
  80. package/src/evaluator/modifiers/sort.ts +9 -3
  81. package/src/evaluator/modifiers/success-count.ts +27 -5
  82. package/src/notation.ts +24 -0
  83. package/src/parser/ast.ts +12 -7
  84. package/src/parser/guards.ts +92 -1
  85. package/src/parser/parser.ts +56 -1
  86. package/src/render.ts +394 -0
  87. package/src/types.ts +64 -12
  88. 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,
@@ -55,9 +56,12 @@ import {
55
56
  } from './modifiers/explode.js';
56
57
  import {
57
58
  isVersusDc,
59
+ META_MERGE_FLAGS,
58
60
  rewriteFlags,
59
61
  SELECTION_AND_TALLY_FLAGS,
60
62
  SELECTION_FLAGS,
63
+ stripFlags,
64
+ TALLY_FLAGS,
61
65
  } from './modifiers/flags.js';
62
66
  import { markDroppedIndices, sumKeptDice } from './modifiers/keep-drop.js';
63
67
  import {
@@ -263,6 +267,10 @@ function renderDie(result: number, modifiers: readonly DieModifier[]): string {
263
267
  * `sumKeptDice`; `'meta'` lets renderers hide them and lets callers
264
268
  * distinguish them from ordinary pool dice.
265
269
  *
270
+ * `'meta'` is stripped before being re-added: meta operands nest
271
+ * (`((1d2)d4)d6`), so the innermost dice pass through here once per level and
272
+ * an append-only rewrite would leave them carrying the tag once per level.
273
+ *
266
274
  * `'success'`/`'failure'` tags are stripped here as defense-in-depth against
267
275
  * a SuccessCount leaking into a meta sub-expression (parser rejects all such
268
276
  * wrappings; this strip ensures a future parse regression cannot leak tags
@@ -279,7 +287,7 @@ export function mergeMetaRolls(parent: EvalContext, source: EvalContext): void {
279
287
  for (const die of source.rolls) {
280
288
  parent.rolls.push({
281
289
  ...die,
282
- modifiers: rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'meta', 'dropped'),
290
+ modifiers: rewriteFlags(die.modifiers, META_MERGE_FLAGS, 'meta', 'dropped'),
283
291
  });
284
292
  }
285
293
  }
@@ -475,11 +483,31 @@ function evalMetaOperand(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv
475
483
  }
476
484
 
477
485
  const metaCtx = createContext();
478
- 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;
479
488
  mergeMetaRolls(ctx, metaCtx);
480
489
  return value;
481
490
  }
482
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
+
483
511
  /** Rejects dice counts that cannot address a pool. */
484
512
  function requireDiceCount(count: number, nodeType: 'Dice' | 'FateDice'): void {
485
513
  if (!Number.isInteger(count) || count < 0) {
@@ -952,11 +980,15 @@ function flattenKeepDropChain(
952
980
  * selects against the unmodified pool: `markDroppedIndices` reads results and
953
981
  * writes only into `droppedMask`, so no spec can observe another's outcome.
954
982
  */
955
- function mergeDropSets(baseDice: DieResult[], specs: KeepDropChainEntry[]): DieResult[] {
983
+ function mergeDropSets(
984
+ baseDice: DieResult[],
985
+ specs: KeepDropChainEntry[],
986
+ hasVersusDc: boolean,
987
+ ): DieResult[] {
956
988
  const droppedMask = new Uint8Array(baseDice.length);
957
989
 
958
990
  for (const spec of specs) {
959
- markDroppedIndices(baseDice, spec.count, spec.kind, spec.selector, droppedMask);
991
+ markDroppedIndices(baseDice, spec.count, spec.kind, spec.selector, droppedMask, hasVersusDc);
960
992
  }
961
993
 
962
994
  for (let index = 0; index < baseDice.length; index++) {
@@ -1019,10 +1051,11 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
1019
1051
 
1020
1052
  const code = formatExplodeCode(node.variant, node.threshold, thresholdValue);
1021
1053
 
1022
- const buildPart = (total: number): RollPart => {
1054
+ const buildPart = (total: number, rolls: DieResult[]): RollPart => {
1023
1055
  const part: RollPart = {
1024
1056
  type: 'explode',
1025
1057
  variant: node.variant,
1058
+ rolls,
1026
1059
  target: target.part,
1027
1060
  total,
1028
1061
  ...partSpan(node),
@@ -1037,7 +1070,7 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
1037
1070
  if (targetCtx.rolls.length === 0) {
1038
1071
  ctx.expressionParts.push(`${targetExpr}${code}`);
1039
1072
  ctx.renderedParts.push(`${targetExpr}${code}`);
1040
- return { total: target.total, part: buildPart(target.total) };
1073
+ return { total: target.total, part: buildPart(target.total, targetCtx.rolls) };
1041
1074
  }
1042
1075
 
1043
1076
  const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
@@ -1050,18 +1083,18 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
1050
1083
  // are self-evident, explosion origin is otherwise invisible.
1051
1084
  ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
1052
1085
 
1053
- const total = sumKeptDice(expanded);
1054
- return { total, part: buildPart(total) };
1086
+ const total = sumKeptDice(expanded, env.hasVersusDc);
1087
+ return { total, part: buildPart(total, expanded) };
1055
1088
  }
1056
1089
 
1057
1090
  function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1058
1091
  const targetCtx = createContext();
1059
1092
  const target = evalNode(node.target, rng, targetCtx, env);
1060
- const targetExpr = targetCtx.expressionParts.join('');
1061
1093
 
1062
1094
  const thresholdValue = evalMetaOperand(node.condition.value, rng, ctx, env);
1063
1095
 
1064
1096
  const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
1097
+ const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
1065
1098
  const condition: ResolvedComparePoint = {
1066
1099
  operator: node.condition.operator,
1067
1100
  value: thresholdValue,
@@ -1069,15 +1102,16 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1069
1102
 
1070
1103
  // No-op when the target produced no dice (e.g., `(1+2)r<5`).
1071
1104
  if (targetCtx.rolls.length === 0) {
1072
- ctx.expressionParts.push(`${targetExpr}${code}`);
1073
- ctx.renderedParts.push(`${targetExpr}${code}`);
1074
- const total = sumKeptDice(targetCtx.rolls);
1105
+ ctx.expressionParts.push(modifierExpr);
1106
+ ctx.renderedParts.push(modifierExpr);
1107
+ const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
1075
1108
  return {
1076
1109
  total,
1077
1110
  part: {
1078
1111
  type: 'reroll',
1079
1112
  once: node.once,
1080
1113
  condition,
1114
+ rolls: targetCtx.rolls,
1081
1115
  target: target.part,
1082
1116
  total,
1083
1117
  ...partSpan(node),
@@ -1090,16 +1124,17 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1090
1124
  : applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
1091
1125
 
1092
1126
  appendAll(ctx.rolls, pool);
1093
- ctx.expressionParts.push(`${targetExpr}${code}`);
1094
- ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
1127
+ ctx.expressionParts.push(modifierExpr);
1128
+ ctx.renderedParts.push(`${modifierExpr}${renderDice(pool)}`);
1095
1129
 
1096
- const total = sumKeptDice(pool);
1130
+ const total = sumKeptDice(pool, env.hasVersusDc);
1097
1131
  return {
1098
1132
  total,
1099
1133
  part: {
1100
1134
  type: 'reroll',
1101
1135
  once: node.once,
1102
1136
  condition,
1137
+ rolls: pool,
1103
1138
  target: target.part,
1104
1139
  total,
1105
1140
  ...partSpan(node),
@@ -1126,22 +1161,22 @@ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalE
1126
1161
  );
1127
1162
  }
1128
1163
 
1129
- applyDieBound(targetCtx.rolls, node.bound, boundValue);
1164
+ applyDieBound(targetCtx.rolls, node.bound, boundValue, env.hasVersusDc);
1130
1165
 
1131
1166
  appendAll(ctx.rolls, targetCtx.rolls);
1132
1167
  // ! No `propagateMetadata` here: clamping re-sums, so a propagated `degree`
1133
1168
  // ! would have been resolved against a total this node just replaced.
1134
1169
  // ! See the rule on `propagateMetadata`.
1135
1170
 
1136
- const targetExpr = targetCtx.expressionParts.join('');
1137
1171
  // Negative bounds render parenthesized so `result.expression` re-parses
1138
1172
  // (`4d6min-2` is a syntax error; `4d6min(-2)` is not).
1139
1173
  const code = boundValue < 0 ? `${node.bound}(${boundValue})` : `${node.bound}${boundValue}`;
1174
+ const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
1140
1175
 
1141
- ctx.expressionParts.push(`${targetExpr}${code}`);
1142
- ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
1176
+ ctx.expressionParts.push(modifierExpr);
1177
+ ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
1143
1178
 
1144
- const total = sumKeptDice(targetCtx.rolls);
1179
+ const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
1145
1180
  return {
1146
1181
  total,
1147
1182
  part: {
@@ -1175,16 +1210,16 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
1175
1210
  const targetCtx = createContext();
1176
1211
  const target = evalNode(node.target, rng, targetCtx, env);
1177
1212
 
1178
- const sortedRolls = sortDice(targetCtx.rolls, node.order);
1213
+ const sortedRolls = sortDice(targetCtx.rolls, node.order, env.hasVersusDc);
1179
1214
 
1180
1215
  appendAll(ctx.rolls, sortedRolls);
1181
1216
  propagateMetadata(ctx, targetCtx.versusMetadata);
1182
1217
 
1183
1218
  const code = node.order === 'ascending' ? 's' : 'sd';
1184
- const targetExpr = targetCtx.expressionParts.join('');
1219
+ const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
1185
1220
 
1186
- ctx.expressionParts.push(`${targetExpr}${code}`);
1187
- ctx.renderedParts.push(`${targetExpr}${code}${renderDice(sortedRolls)}`);
1221
+ ctx.expressionParts.push(modifierExpr);
1222
+ ctx.renderedParts.push(`${modifierExpr}${renderDice(sortedRolls)}`);
1188
1223
 
1189
1224
  return {
1190
1225
  total: target.total,
@@ -1210,8 +1245,12 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
1210
1245
  * crit criteria and `cf` thresholds replace only the fumble criteria. When a
1211
1246
  * side has no explicit threshold, the `'default'` rule applies — so
1212
1247
  * `1d20cf<3` keeps the default nat-20 crit. Bare `cs`/`cf` uses the
1213
- * `'default'` sentinel resolved per-die to `result === sides` or
1214
- * `result === 1`.
1248
+ * `'default'` sentinel, resolved per-die against the natural face
1249
+ * (`initialResult ?? result`) rather than the possibly-rewritten `result`.
1250
+ *
1251
+ * The resolved rule is recorded per die, so an enclosing explode or reroll
1252
+ * judges the dice it mints by it too — `1d6cs<2!` no longer reports the
1253
+ * default-rule crit the user overrode.
1215
1254
  *
1216
1255
  * Renders `<targetExpr><codes>[<dice>]`, mirroring `evalSort`/`evalExplode`.
1217
1256
  */
@@ -1233,19 +1272,18 @@ function evalCritThreshold(
1233
1272
  successResolved.length > 0 ? successResolved : ['default'];
1234
1273
  const failApplied: ResolvedCritThreshold[] = failResolved.length > 0 ? failResolved : ['default'];
1235
1274
 
1236
- applyCritThresholds(targetCtx.rolls, successApplied, failApplied);
1275
+ applyCritThresholds(targetCtx.rolls, successApplied, failApplied, env);
1237
1276
 
1238
1277
  appendAll(ctx.rolls, targetCtx.rolls);
1239
1278
  propagateMetadata(ctx, targetCtx.versusMetadata);
1240
1279
 
1241
- const targetExpr = targetCtx.expressionParts.join('');
1242
- const codes = [
1280
+ const modifierExpr = [
1243
1281
  ...successResolved.map((t) => (t === 'default' ? 'cs' : `cs${t.operator}${t.value}`)),
1244
1282
  ...failResolved.map((t) => (t === 'default' ? 'cf' : `cf${t.operator}${t.value}`)),
1245
- ].join('');
1283
+ ].reduce(joinModifierCode, targetCtx.expressionParts.join(''));
1246
1284
 
1247
- ctx.expressionParts.push(`${targetExpr}${codes}`);
1248
- ctx.renderedParts.push(`${targetExpr}${codes}${renderDice(targetCtx.rolls)}`);
1285
+ ctx.expressionParts.push(modifierExpr);
1286
+ ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
1249
1287
 
1250
1288
  return {
1251
1289
  total: target.total,
@@ -1296,16 +1334,16 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
1296
1334
  const targetCtx = createContext();
1297
1335
  const target = evalNode(baseTarget, rng, targetCtx, env);
1298
1336
 
1299
- const mergedDice = mergeDropSets(targetCtx.rolls, specs);
1337
+ const mergedDice = mergeDropSets(targetCtx.rolls, specs, env.hasVersusDc);
1300
1338
 
1301
1339
  appendAll(ctx.rolls, mergedDice);
1302
1340
 
1303
- const total = sumKeptDice(mergedDice);
1341
+ const total = sumKeptDice(mergedDice, env.hasVersusDc);
1304
1342
 
1305
1343
  const targetExpr = targetCtx.expressionParts.join('');
1306
1344
  const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
1307
1345
 
1308
- ctx.expressionParts.push(`${targetExpr}${keepDropCodes}`);
1346
+ ctx.expressionParts.push(joinModifierCode(targetExpr, keepDropCodes));
1309
1347
  ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
1310
1348
 
1311
1349
  return {
@@ -1328,10 +1366,17 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
1328
1366
  * success highlights inside a dropped span.
1329
1367
  */
1330
1368
  function stripInnerMarkers(rendered: string): string {
1331
- return rendered
1332
- .replace(/\*\*(-?\d+)\*\*/g, '$1')
1333
- .replace(/__(-?\d+)__/g, '$1')
1334
- .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');
1335
1380
  }
1336
1381
 
1337
1382
  /**
@@ -1358,10 +1403,16 @@ function evalGroupKeepDrop(
1358
1403
  expr: string;
1359
1404
  rendered: string;
1360
1405
  versusMetadata: EvalContext['versusMetadata'];
1406
+ scoredSuccesses: number;
1407
+ scoredFailures: number;
1361
1408
  };
1362
1409
 
1363
1410
  const subRolls: SubRoll[] = group.expressions.map((expr) => {
1364
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;
1365
1416
  const sub = evalNode(expr, rng, subCtx, env);
1366
1417
  return {
1367
1418
  subtotal: sub.total,
@@ -1370,6 +1421,8 @@ function evalGroupKeepDrop(
1370
1421
  expr: subCtx.expressionParts.join(''),
1371
1422
  rendered: subCtx.renderedParts.join(''),
1372
1423
  versusMetadata: subCtx.versusMetadata,
1424
+ scoredSuccesses: env.subtotalSuccesses - successTally,
1425
+ scoredFailures: env.subtotalFailures - failureTally,
1373
1426
  };
1374
1427
  });
1375
1428
 
@@ -1383,7 +1436,7 @@ function evalGroupKeepDrop(
1383
1436
  fumble: false,
1384
1437
  }));
1385
1438
 
1386
- const mergedSynthetic = mergeDropSets(syntheticDice, specs);
1439
+ const mergedSynthetic = mergeDropSets(syntheticDice, specs, env.hasVersusDc);
1387
1440
 
1388
1441
  const outerRendered: string[] = [];
1389
1442
  const keptIndices: number[] = [];
@@ -1402,6 +1455,10 @@ function evalGroupKeepDrop(
1402
1455
  for (const die of sub.rolls) {
1403
1456
  die.modifiers = rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'dropped');
1404
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;
1405
1462
  appendAll(ctx.rolls, sub.rolls);
1406
1463
  outerRendered.push(`~~${stripInnerMarkers(sub.rendered)}~~`);
1407
1464
  } else {
@@ -1489,14 +1546,24 @@ function evalSuccessCount(
1489
1546
  ctx: EvalContext,
1490
1547
  env: EvalEnv,
1491
1548
  ): EvalResult {
1492
- // Flag tracks syntactic presence of success-count notation, not pool size —
1493
- // set before any early return so empty pools still populate successes/failures.
1494
- 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;
1495
1554
 
1496
1555
  const targetCtx = createContext();
1497
1556
  const target = evalNode(node.target, rng, targetCtx, env);
1498
1557
  const targetExpr = targetCtx.expressionParts.join('');
1499
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
+
1500
1567
  const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
1501
1568
  const failValue =
1502
1569
  node.failThreshold != null
@@ -1514,6 +1581,7 @@ function evalSuccessCount(
1514
1581
  const part: RollPart = {
1515
1582
  type: 'successCount',
1516
1583
  threshold: { operator: node.threshold.operator, value: thresholdValue },
1584
+ rolls: targetCtx.rolls,
1517
1585
  target: target.part,
1518
1586
  successes,
1519
1587
  failures,
@@ -1526,26 +1594,66 @@ function evalSuccessCount(
1526
1594
  return part;
1527
1595
  };
1528
1596
 
1529
- // No-op when the target produced no dice (`0d6>=4`); `containsDicePool`
1530
- // should already reject dice-less targets at parse time. `target.total` is 0
1531
- // for an empty pool, so `total === successes - failures` still holds.
1532
- 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) {
1533
1627
  ctx.expressionParts.push(`${targetExpr}${code}`);
1534
1628
  ctx.renderedParts.push(`${targetExpr}${code}`);
1535
- return { total: target.total, part: buildPart(target.total, 0, 0) };
1629
+ return { total: 0, part: buildPart(0, 0, 0) };
1536
1630
  }
1537
1631
 
1538
1632
  const result = countSuccesses(
1539
- targetCtx.rolls,
1633
+ pool,
1540
1634
  { operator: node.threshold.operator, value: thresholdValue },
1541
1635
  failValue != null && node.failThreshold != null
1542
1636
  ? { operator: node.failThreshold.operator, value: failValue }
1543
1637
  : undefined,
1638
+ !bySubtotal && env.hasVersusDc,
1639
+ !bySubtotal && poolAlreadyCounted,
1544
1640
  );
1545
1641
 
1642
+ if (bySubtotal) {
1643
+ env.subtotalSuccesses = successTallyBefore + result.successes;
1644
+ env.subtotalFailures = failureTallyBefore + result.failures;
1645
+ }
1646
+
1546
1647
  appendAll(ctx.rolls, targetCtx.rolls);
1547
1648
  ctx.expressionParts.push(`${targetExpr}${code}`);
1548
- 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
+ );
1549
1657
 
1550
1658
  return {
1551
1659
  total: result.total,
@@ -1563,22 +1671,18 @@ function evalSuccessCount(
1563
1671
  * `undefined`.
1564
1672
  *
1565
1673
  * Excludes dropped (`kh`/`kl`/`dh`/`dl`/`r`/`ro`) dice — these aren't the
1566
- * final kept result. Explosion continuation dice (appended by standard/
1567
- * penetrating explode, tagged `'exploded'` with no `initialResult`) are not
1568
- * primaries either `1d20! vs DC` keeps the natural from the original d20.
1569
- * Compound explode accumulates into the original die and sets
1570
- * `initialResult`, so it stays a primary and the raw first face is used.
1571
- * Multiple primary kept d20s (e.g., `1d20+1d20`) yield `undefined` so no
1572
- * 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.
1573
1679
  */
1574
- function extractNatural(rolls: DieResult[]): number | undefined {
1680
+ function extractNatural(rolls: DieResult[], env: EvalEnv): number | undefined {
1575
1681
  // Rerolled intermediates are always stamped `['rerolled', 'dropped']`
1576
1682
  // (see `modifiers/reroll.ts`), so filtering by `'dropped'` covers them.
1683
+ const appended = env.explosionDice;
1577
1684
  const primaries = rolls.filter(
1578
- (d) =>
1579
- d.sides === 20 &&
1580
- !d.modifiers.includes('dropped') &&
1581
- !(d.modifiers.includes('exploded') && d.initialResult == null),
1685
+ (d) => d.sides === 20 && !d.modifiers.includes('dropped') && !appended?.has(d),
1582
1686
  );
1583
1687
  if (primaries.length !== 1) return undefined;
1584
1688
  const die = primaries[0];
@@ -1624,19 +1728,26 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1624
1728
  try {
1625
1729
  const rollCtx = createContext();
1626
1730
  const rollResult = evalNode(node.roll, rng, rollCtx, env);
1627
- const natural = extractNatural(rollCtx.rolls);
1731
+ const natural = extractNatural(rollCtx.rolls, env);
1628
1732
 
1629
1733
  const dcCtx = createContext();
1630
- 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);
1631
1738
 
1632
1739
  const degree = calculateDegree(rollResult.total, dcResult.total, natural);
1633
1740
 
1634
1741
  appendAll(ctx.rolls, rollCtx.rolls);
1635
1742
  // ! Tag before merging: past this point the DC dice are indistinguishable
1636
1743
  // ! from the roll side, and every pool modifier walks the merged array.
1744
+ // ! Bare push, deliberately: `insideVersus` above rejects every nesting, so
1745
+ // ! each die reaches exactly one `dcCtx` and cannot be tagged twice.
1637
1746
  for (const die of dcCtx.rolls) {
1638
1747
  die.modifiers.push('dc');
1639
1748
  }
1749
+ // Arms the exclusion checks every enclosing pool operation skips by default.
1750
+ if (dcCtx.rolls.length > 0) env.hasVersusDc = true;
1640
1751
  appendAll(ctx.rolls, dcCtx.rolls);
1641
1752
 
1642
1753
  const rollExpr = rollCtx.expressionParts.join('');
@@ -1729,7 +1840,12 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
1729
1840
  maxRerollIterations,
1730
1841
  totalDiceRolled: 0,
1731
1842
  hasSuccessCount: false,
1843
+ subtotalSuccesses: 0,
1844
+ subtotalFailures: 0,
1732
1845
  insideVersus: false,
1846
+ hasVersusDc: false,
1847
+ critRules: undefined,
1848
+ explosionDice: undefined,
1733
1849
  context,
1734
1850
  onMissingVariable,
1735
1851
  };
@@ -1761,21 +1877,27 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
1761
1877
  rendered,
1762
1878
  rolls: ctx.rolls,
1763
1879
  parts: part,
1764
- ...(env.hasSuccessCount ? countTaggedDice(ctx.rolls) : {}),
1880
+ ...(env.hasSuccessCount ? countTaggedDice(ctx.rolls, env) : {}),
1765
1881
  ...(versus ? { degree: versus.degree } : {}),
1766
1882
  ...(versus?.natural != null ? { natural: versus.natural } : {}),
1767
1883
  };
1768
1884
  }
1769
1885
 
1770
- /** Tallies the `'success'` / `'failure'` tags across a whole roll. */
1771
- function countTaggedDice(rolls: DieResult[]): { successes: number; failures: number } {
1772
- let successes = 0;
1773
- let failures = 0;
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
+ */
1890
+ function countTaggedDice(
1891
+ rolls: DieResult[],
1892
+ env: EvalEnv,
1893
+ ): { successes: number; failures: number } {
1894
+ let successes = env.subtotalSuccesses;
1895
+ let failures = env.subtotalFailures;
1774
1896
 
1775
1897
  for (const die of rolls) {
1776
1898
  // A success-count inside the DC sub-expression tags its own dice before
1777
1899
  // `evalVersus` marks them `'dc'`, so they arrive here already tagged.
1778
- if (isVersusDc(die)) continue;
1900
+ if (env.hasVersusDc && isVersusDc(die)) continue;
1779
1901
  if (die.modifiers.includes('success')) successes += 1;
1780
1902
  else if (die.modifiers.includes('failure')) failures += 1;
1781
1903
  }