roll-parser 3.0.0 → 3.1.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 (74) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/MIGRATION.md +73 -1
  3. package/README.md +85 -19
  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 +31 -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 +29 -21
  15. package/dist/evaluator/evaluator.js.map +1 -1
  16. package/dist/evaluator/modifiers/crit-threshold.d.ts +57 -4
  17. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  18. package/dist/evaluator/modifiers/crit-threshold.js +27 -8
  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 +7 -5
  26. package/dist/evaluator/modifiers/explode.js.map +1 -1
  27. package/dist/evaluator/modifiers/flags.d.ts +10 -0
  28. package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
  29. package/dist/evaluator/modifiers/flags.js +7 -0
  30. package/dist/evaluator/modifiers/flags.js.map +1 -1
  31. package/dist/evaluator/modifiers/keep-drop.d.ts +4 -2
  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 +5 -2
  43. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  44. package/dist/evaluator/modifiers/success-count.js +7 -6
  45. package/dist/evaluator/modifiers/success-count.js.map +1 -1
  46. package/dist/parser/ast.d.ts +12 -7
  47. package/dist/parser/ast.d.ts.map +1 -1
  48. package/dist/parser/ast.js.map +1 -1
  49. package/dist/render.d.ts +95 -0
  50. package/dist/render.d.ts.map +1 -0
  51. package/dist/render.js +227 -0
  52. package/dist/render.js.map +1 -0
  53. package/dist/types.d.ts +49 -9
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/types.js.map +1 -1
  56. package/dist/version.d.ts +1 -1
  57. package/dist/version.js +1 -1
  58. package/package.json +11 -1
  59. package/src/cli/format.ts +15 -23
  60. package/src/evaluator/die.ts +2 -1
  61. package/src/evaluator/env.ts +32 -0
  62. package/src/evaluator/evaluator.ts +48 -21
  63. package/src/evaluator/modifiers/crit-threshold.ts +95 -10
  64. package/src/evaluator/modifiers/die-bound.ts +13 -4
  65. package/src/evaluator/modifiers/explode.ts +12 -6
  66. package/src/evaluator/modifiers/flags.ts +17 -0
  67. package/src/evaluator/modifiers/keep-drop.ts +9 -5
  68. package/src/evaluator/modifiers/reroll.ts +20 -11
  69. package/src/evaluator/modifiers/sort.ts +9 -3
  70. package/src/evaluator/modifiers/success-count.ts +21 -6
  71. package/src/parser/ast.ts +12 -7
  72. package/src/render.ts +392 -0
  73. package/src/types.ts +49 -9
  74. package/src/version.ts +1 -1
@@ -55,6 +55,7 @@ import {
55
55
  } from './modifiers/explode.js';
56
56
  import {
57
57
  isVersusDc,
58
+ META_MERGE_FLAGS,
58
59
  rewriteFlags,
59
60
  SELECTION_AND_TALLY_FLAGS,
60
61
  SELECTION_FLAGS,
@@ -263,6 +264,10 @@ function renderDie(result: number, modifiers: readonly DieModifier[]): string {
263
264
  * `sumKeptDice`; `'meta'` lets renderers hide them and lets callers
264
265
  * distinguish them from ordinary pool dice.
265
266
  *
267
+ * `'meta'` is stripped before being re-added: meta operands nest
268
+ * (`((1d2)d4)d6`), so the innermost dice pass through here once per level and
269
+ * an append-only rewrite would leave them carrying the tag once per level.
270
+ *
266
271
  * `'success'`/`'failure'` tags are stripped here as defense-in-depth against
267
272
  * a SuccessCount leaking into a meta sub-expression (parser rejects all such
268
273
  * wrappings; this strip ensures a future parse regression cannot leak tags
@@ -279,7 +284,7 @@ export function mergeMetaRolls(parent: EvalContext, source: EvalContext): void {
279
284
  for (const die of source.rolls) {
280
285
  parent.rolls.push({
281
286
  ...die,
282
- modifiers: rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'meta', 'dropped'),
287
+ modifiers: rewriteFlags(die.modifiers, META_MERGE_FLAGS, 'meta', 'dropped'),
283
288
  });
284
289
  }
285
290
  }
@@ -952,11 +957,15 @@ function flattenKeepDropChain(
952
957
  * selects against the unmodified pool: `markDroppedIndices` reads results and
953
958
  * writes only into `droppedMask`, so no spec can observe another's outcome.
954
959
  */
955
- function mergeDropSets(baseDice: DieResult[], specs: KeepDropChainEntry[]): DieResult[] {
960
+ function mergeDropSets(
961
+ baseDice: DieResult[],
962
+ specs: KeepDropChainEntry[],
963
+ hasVersusDc: boolean,
964
+ ): DieResult[] {
956
965
  const droppedMask = new Uint8Array(baseDice.length);
957
966
 
958
967
  for (const spec of specs) {
959
- markDroppedIndices(baseDice, spec.count, spec.kind, spec.selector, droppedMask);
968
+ markDroppedIndices(baseDice, spec.count, spec.kind, spec.selector, droppedMask, hasVersusDc);
960
969
  }
961
970
 
962
971
  for (let index = 0; index < baseDice.length; index++) {
@@ -1019,10 +1028,11 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
1019
1028
 
1020
1029
  const code = formatExplodeCode(node.variant, node.threshold, thresholdValue);
1021
1030
 
1022
- const buildPart = (total: number): RollPart => {
1031
+ const buildPart = (total: number, rolls: DieResult[]): RollPart => {
1023
1032
  const part: RollPart = {
1024
1033
  type: 'explode',
1025
1034
  variant: node.variant,
1035
+ rolls,
1026
1036
  target: target.part,
1027
1037
  total,
1028
1038
  ...partSpan(node),
@@ -1037,7 +1047,7 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
1037
1047
  if (targetCtx.rolls.length === 0) {
1038
1048
  ctx.expressionParts.push(`${targetExpr}${code}`);
1039
1049
  ctx.renderedParts.push(`${targetExpr}${code}`);
1040
- return { total: target.total, part: buildPart(target.total) };
1050
+ return { total: target.total, part: buildPart(target.total, targetCtx.rolls) };
1041
1051
  }
1042
1052
 
1043
1053
  const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
@@ -1050,8 +1060,8 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
1050
1060
  // are self-evident, explosion origin is otherwise invisible.
1051
1061
  ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
1052
1062
 
1053
- const total = sumKeptDice(expanded);
1054
- return { total, part: buildPart(total) };
1063
+ const total = sumKeptDice(expanded, env.hasVersusDc);
1064
+ return { total, part: buildPart(total, expanded) };
1055
1065
  }
1056
1066
 
1057
1067
  function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
@@ -1071,13 +1081,14 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1071
1081
  if (targetCtx.rolls.length === 0) {
1072
1082
  ctx.expressionParts.push(`${targetExpr}${code}`);
1073
1083
  ctx.renderedParts.push(`${targetExpr}${code}`);
1074
- const total = sumKeptDice(targetCtx.rolls);
1084
+ const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
1075
1085
  return {
1076
1086
  total,
1077
1087
  part: {
1078
1088
  type: 'reroll',
1079
1089
  once: node.once,
1080
1090
  condition,
1091
+ rolls: targetCtx.rolls,
1081
1092
  target: target.part,
1082
1093
  total,
1083
1094
  ...partSpan(node),
@@ -1093,13 +1104,14 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1093
1104
  ctx.expressionParts.push(`${targetExpr}${code}`);
1094
1105
  ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
1095
1106
 
1096
- const total = sumKeptDice(pool);
1107
+ const total = sumKeptDice(pool, env.hasVersusDc);
1097
1108
  return {
1098
1109
  total,
1099
1110
  part: {
1100
1111
  type: 'reroll',
1101
1112
  once: node.once,
1102
1113
  condition,
1114
+ rolls: pool,
1103
1115
  target: target.part,
1104
1116
  total,
1105
1117
  ...partSpan(node),
@@ -1126,7 +1138,7 @@ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalE
1126
1138
  );
1127
1139
  }
1128
1140
 
1129
- applyDieBound(targetCtx.rolls, node.bound, boundValue);
1141
+ applyDieBound(targetCtx.rolls, node.bound, boundValue, env.hasVersusDc);
1130
1142
 
1131
1143
  appendAll(ctx.rolls, targetCtx.rolls);
1132
1144
  // ! No `propagateMetadata` here: clamping re-sums, so a propagated `degree`
@@ -1141,7 +1153,7 @@ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalE
1141
1153
  ctx.expressionParts.push(`${targetExpr}${code}`);
1142
1154
  ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
1143
1155
 
1144
- const total = sumKeptDice(targetCtx.rolls);
1156
+ const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
1145
1157
  return {
1146
1158
  total,
1147
1159
  part: {
@@ -1175,7 +1187,7 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
1175
1187
  const targetCtx = createContext();
1176
1188
  const target = evalNode(node.target, rng, targetCtx, env);
1177
1189
 
1178
- const sortedRolls = sortDice(targetCtx.rolls, node.order);
1190
+ const sortedRolls = sortDice(targetCtx.rolls, node.order, env.hasVersusDc);
1179
1191
 
1180
1192
  appendAll(ctx.rolls, sortedRolls);
1181
1193
  propagateMetadata(ctx, targetCtx.versusMetadata);
@@ -1210,8 +1222,12 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
1210
1222
  * crit criteria and `cf` thresholds replace only the fumble criteria. When a
1211
1223
  * side has no explicit threshold, the `'default'` rule applies — so
1212
1224
  * `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`.
1225
+ * `'default'` sentinel, resolved per-die against the natural face
1226
+ * (`initialResult ?? result`) rather than the possibly-rewritten `result`.
1227
+ *
1228
+ * The resolved rule is recorded per die, so an enclosing explode or reroll
1229
+ * judges the dice it mints by it too — `1d6cs<2!` no longer reports the
1230
+ * default-rule crit the user overrode.
1215
1231
  *
1216
1232
  * Renders `<targetExpr><codes>[<dice>]`, mirroring `evalSort`/`evalExplode`.
1217
1233
  */
@@ -1233,7 +1249,7 @@ function evalCritThreshold(
1233
1249
  successResolved.length > 0 ? successResolved : ['default'];
1234
1250
  const failApplied: ResolvedCritThreshold[] = failResolved.length > 0 ? failResolved : ['default'];
1235
1251
 
1236
- applyCritThresholds(targetCtx.rolls, successApplied, failApplied);
1252
+ applyCritThresholds(targetCtx.rolls, successApplied, failApplied, env);
1237
1253
 
1238
1254
  appendAll(ctx.rolls, targetCtx.rolls);
1239
1255
  propagateMetadata(ctx, targetCtx.versusMetadata);
@@ -1296,11 +1312,11 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
1296
1312
  const targetCtx = createContext();
1297
1313
  const target = evalNode(baseTarget, rng, targetCtx, env);
1298
1314
 
1299
- const mergedDice = mergeDropSets(targetCtx.rolls, specs);
1315
+ const mergedDice = mergeDropSets(targetCtx.rolls, specs, env.hasVersusDc);
1300
1316
 
1301
1317
  appendAll(ctx.rolls, mergedDice);
1302
1318
 
1303
- const total = sumKeptDice(mergedDice);
1319
+ const total = sumKeptDice(mergedDice, env.hasVersusDc);
1304
1320
 
1305
1321
  const targetExpr = targetCtx.expressionParts.join('');
1306
1322
  const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
@@ -1383,7 +1399,7 @@ function evalGroupKeepDrop(
1383
1399
  fumble: false,
1384
1400
  }));
1385
1401
 
1386
- const mergedSynthetic = mergeDropSets(syntheticDice, specs);
1402
+ const mergedSynthetic = mergeDropSets(syntheticDice, specs, env.hasVersusDc);
1387
1403
 
1388
1404
  const outerRendered: string[] = [];
1389
1405
  const keptIndices: number[] = [];
@@ -1514,6 +1530,7 @@ function evalSuccessCount(
1514
1530
  const part: RollPart = {
1515
1531
  type: 'successCount',
1516
1532
  threshold: { operator: node.threshold.operator, value: thresholdValue },
1533
+ rolls: targetCtx.rolls,
1517
1534
  target: target.part,
1518
1535
  successes,
1519
1536
  failures,
@@ -1541,6 +1558,7 @@ function evalSuccessCount(
1541
1558
  failValue != null && node.failThreshold != null
1542
1559
  ? { operator: node.failThreshold.operator, value: failValue }
1543
1560
  : undefined,
1561
+ env.hasVersusDc,
1544
1562
  );
1545
1563
 
1546
1564
  appendAll(ctx.rolls, targetCtx.rolls);
@@ -1634,9 +1652,13 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1634
1652
  appendAll(ctx.rolls, rollCtx.rolls);
1635
1653
  // ! Tag before merging: past this point the DC dice are indistinguishable
1636
1654
  // ! from the roll side, and every pool modifier walks the merged array.
1655
+ // ! Bare push, deliberately: `insideVersus` above rejects every nesting, so
1656
+ // ! each die reaches exactly one `dcCtx` and cannot be tagged twice.
1637
1657
  for (const die of dcCtx.rolls) {
1638
1658
  die.modifiers.push('dc');
1639
1659
  }
1660
+ // Arms the exclusion checks every enclosing pool operation skips by default.
1661
+ if (dcCtx.rolls.length > 0) env.hasVersusDc = true;
1640
1662
  appendAll(ctx.rolls, dcCtx.rolls);
1641
1663
 
1642
1664
  const rollExpr = rollCtx.expressionParts.join('');
@@ -1730,6 +1752,8 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
1730
1752
  totalDiceRolled: 0,
1731
1753
  hasSuccessCount: false,
1732
1754
  insideVersus: false,
1755
+ hasVersusDc: false,
1756
+ critRules: undefined,
1733
1757
  context,
1734
1758
  onMissingVariable,
1735
1759
  };
@@ -1761,21 +1785,24 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
1761
1785
  rendered,
1762
1786
  rolls: ctx.rolls,
1763
1787
  parts: part,
1764
- ...(env.hasSuccessCount ? countTaggedDice(ctx.rolls) : {}),
1788
+ ...(env.hasSuccessCount ? countTaggedDice(ctx.rolls, env.hasVersusDc) : {}),
1765
1789
  ...(versus ? { degree: versus.degree } : {}),
1766
1790
  ...(versus?.natural != null ? { natural: versus.natural } : {}),
1767
1791
  };
1768
1792
  }
1769
1793
 
1770
1794
  /** Tallies the `'success'` / `'failure'` tags across a whole roll. */
1771
- function countTaggedDice(rolls: DieResult[]): { successes: number; failures: number } {
1795
+ function countTaggedDice(
1796
+ rolls: DieResult[],
1797
+ hasVersusDc: boolean,
1798
+ ): { successes: number; failures: number } {
1772
1799
  let successes = 0;
1773
1800
  let failures = 0;
1774
1801
 
1775
1802
  for (const die of rolls) {
1776
1803
  // A success-count inside the DC sub-expression tags its own dice before
1777
1804
  // `evalVersus` marks them `'dc'`, so they arrive here already tagged.
1778
- if (isVersusDc(die)) continue;
1805
+ if (hasVersusDc && isVersusDc(die)) continue;
1779
1806
  if (die.modifiers.includes('success')) successes += 1;
1780
1807
  else if (die.modifiers.includes('failure')) failures += 1;
1781
1808
  }
@@ -9,6 +9,40 @@
9
9
  * Meta dice (rolled to compute counts/sides/modifier args) are skipped so
10
10
  * their bookkeeping stays untouched.
11
11
  *
12
+ * The two threshold kinds deliberately read different values. `'default'`
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.
21
+ *
22
+ * They also diverge on Fate dice. `'default'` carries a `sides > 1` guard, so
23
+ * it never fires on a `sides = 0` pool; an explicit threshold has none and
24
+ * compares the raw {-1, 0, +1} face, so `4dFcs>0` and `4dFcf=-1` do set the
25
+ * flags. The missing guard is deliberate — the parser rejects the bare
26
+ * `cs`/`cf` forms instead, since they would resolve to `'default'` and
27
+ * silently do nothing.
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
+ * The rule is recorded per die on `env.critRules`, so dice that explode and
39
+ * reroll mint *after* the crit node has run inherit it from the die they
40
+ * descended from. `cs`/`cf` therefore covers the whole pool wherever it sits
41
+ * in the postfix chain — `1d6cs<2!` and `1d6!cs<2` agree. Two things stay
42
+ * outside that: compound explode, which mints no die and so keeps the flags
43
+ * its accumulated `result` had when the crit node ran, and `minN`/`maxN`,
44
+ * which rewrites `result` under an explicit threshold's feet.
45
+ *
12
46
  * Display-only: does not alter `total`, explosion triggers, success
13
47
  * counting, or any other modifier flag. Dropped dice still participate —
14
48
  * their `critical`/`fumble` metadata reflects what they rolled, not
@@ -18,39 +52,90 @@
18
52
  */
19
53
 
20
54
  import type { DieResult, ResolvedCritThreshold } from '../../types.js';
55
+ import type { CritRule, EvalEnv } from '../env.js';
21
56
  import { matchesCondition } from './compare.js';
22
57
  import { isVersusDc } from './flags.js';
23
58
 
24
59
  /**
25
60
  * Applies success/fail threshold arrays to a dice pool, overriding each
26
- * die's `critical` and `fumble` flags in place. A die matches `'default'`
27
- * on the success side when `result === sides && sides > 1`, and on the
28
- * fail side when `result === 1`. Meta dice are skipped.
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.
65
+ *
66
+ * Also records the rule against every die it touched, so later explode and
67
+ * reroll dice can inherit it via {@link inheritCritRule}.
29
68
  */
30
69
  export function applyCritThresholds(
31
70
  dice: DieResult[],
32
71
  successThresholds: ResolvedCritThreshold[],
33
72
  failThresholds: ResolvedCritThreshold[],
73
+ env: EvalEnv,
34
74
  ): void {
75
+ const rule: CritRule = { success: successThresholds, fail: failThresholds };
76
+ env.critRules ??= new WeakMap();
77
+ const rules = env.critRules;
78
+
35
79
  for (const die of dice) {
36
- if (die.modifiers.includes('meta') || isVersusDc(die)) continue;
80
+ if (die.modifiers.includes('meta')) continue;
81
+ if (env.hasVersusDc && isVersusDc(die)) continue;
37
82
 
38
- die.critical = successThresholds.some((t) => matchesCrit(t, die));
39
- die.fumble = failThresholds.some((t) => matchesFumble(t, die));
83
+ applyCritRule(die, rule, die.initialResult ?? die.result);
84
+ rules.set(die, rule);
40
85
  }
41
86
  }
42
87
 
43
- function matchesCrit(threshold: ResolvedCritThreshold, die: DieResult): boolean {
88
+ /**
89
+ * Judges one die by a recorded rule, overwriting both flags. A side always
90
+ * 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`.
93
+ */
94
+ 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));
97
+ }
98
+
99
+ /**
100
+ * Passes the crit rule recorded for `parent` down to a die minted from it,
101
+ * judging the child by that rule and recording it so a further explode or
102
+ * reroll inherits it in turn. No-op when no `cs`/`cf` governs `parent`, which
103
+ * leaves the `createDieResult` default rule in place.
104
+ *
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`.
111
+ */
112
+ export function inheritCritRule(
113
+ env: EvalEnv,
114
+ parent: DieResult,
115
+ child: DieResult,
116
+ natural = child.initialResult ?? child.result,
117
+ ): void {
118
+ const rules = env.critRules;
119
+ if (rules === undefined) return;
120
+
121
+ const rule = rules.get(parent);
122
+ if (rule === undefined) return;
123
+
124
+ applyCritRule(child, rule, natural);
125
+ rules.set(child, rule);
126
+ }
127
+
128
+ function matchesCrit(threshold: ResolvedCritThreshold, die: DieResult, natural: number): boolean {
44
129
  if (threshold === 'default') {
45
- return die.result === die.sides && die.sides > 1;
130
+ return natural === die.sides && die.sides > 1;
46
131
  }
47
132
  return matchesCondition(die.result, threshold.operator, threshold.value);
48
133
  }
49
134
 
50
- function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult): boolean {
135
+ function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult, natural: number): boolean {
51
136
  if (threshold === 'default') {
52
137
  // Mirrors `createDieResult` — a d1 always rolls 1, never a fumble.
53
- return die.result === 1 && die.sides > 1;
138
+ return natural === 1 && die.sides > 1;
54
139
  }
55
140
  return matchesCondition(die.result, threshold.operator, threshold.value);
56
141
  }
@@ -6,10 +6,13 @@
6
6
  * `initialResult` (first writer wins, so a compound-explode accumulation
7
7
  * that was clamped afterwards still reports its original first roll), and
8
8
  * the die is tagged `'min'` / `'max'` so parts consumers can tell a clamped
9
- * value from a natural one.
9
+ * value from a natural one. The tag is written at most once, so a chain of
10
+ * same-kind bounds (`4d6min3min4`) leaves a die carrying a single `'min'`.
10
11
  *
11
12
  * `critical` / `fumble` keep reflecting the natural face — a clamped 1 is
12
13
  * still a fumble, matching the raw-face crit semantics used everywhere else.
14
+ * A following bare `cs`/`cf` agrees; a following *explicit* threshold
15
+ * (`4d6min5cs>4`) is a predicate over the clamped value and overrides it.
13
16
  * Meta dice (rolled to compute counts/sides/modifier args) are skipped.
14
17
  * Dropped dice are clamped too — they are excluded from totals anyway, and
15
18
  * clamping them keeps the rendered pool consistent.
@@ -25,15 +28,21 @@ import { isVersusDc } from './flags.js';
25
28
  * `bound: 'min'` lifts lower results up to `value`; `'max'` caps higher
26
29
  * results down to it. Untouched dice keep their tags.
27
30
  */
28
- export function applyDieBound(dice: DieResult[], bound: 'min' | 'max', value: number): void {
31
+ export function applyDieBound(
32
+ dice: DieResult[],
33
+ bound: 'min' | 'max',
34
+ value: number,
35
+ hasVersusDc: boolean,
36
+ ): void {
29
37
  for (const die of dice) {
30
- if (die.modifiers.includes('meta') || isVersusDc(die)) continue;
38
+ if (die.modifiers.includes('meta')) continue;
39
+ if (hasVersusDc && isVersusDc(die)) continue;
31
40
 
32
41
  const clamped = bound === 'min' ? Math.max(die.result, value) : Math.min(die.result, value);
33
42
  if (clamped === die.result) continue;
34
43
 
35
44
  die.initialResult ??= die.result;
36
45
  die.result = clamped;
37
- die.modifiers.push(bound);
46
+ if (!die.modifiers.includes(bound)) die.modifiers.push(bound);
38
47
  }
39
48
  }
@@ -13,6 +13,7 @@ import type { RNG } from '../../rng/types.js';
13
13
  import type { CompareOp, DieResult } from '../../types.js';
14
14
  import { createDieResult } from '../die.js';
15
15
  import { chargeDie, type EvalEnv } from '../env.js';
16
+ import { inheritCritRule } from './crit-threshold.js';
16
17
  import { isVersusDc } from './flags.js';
17
18
 
18
19
  /**
@@ -84,8 +85,8 @@ function explodeLimitError(maxIterations: number): EvaluatorError {
84
85
  * never fire during normal flow. Keeping it ensures `rng.nextInt(1, 0)`
85
86
  * can never be reached if a future AST path slips past the parser gate.
86
87
  */
87
- function canExplode(die: DieResult): boolean {
88
- if (isVersusDc(die)) return false;
88
+ function canExplode(die: DieResult, hasVersusDc: boolean): boolean {
89
+ if (hasVersusDc && isVersusDc(die)) return false;
89
90
  if (die.modifiers.includes('dropped')) return false;
90
91
  if (die.sides < 1) return false;
91
92
  return true;
@@ -99,6 +100,9 @@ function canExplode(die: DieResult): boolean {
99
100
  *
100
101
  * `critical`/`fumble` are likewise derived from the raw roll — a penetrating
101
102
  * 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.
102
106
  */
103
107
  function applyAppendingExplode(
104
108
  pool: DieResult[],
@@ -111,7 +115,7 @@ function applyAppendingExplode(
111
115
 
112
116
  for (const original of pool) {
113
117
  result.push(original);
114
- if (!canExplode(original)) continue;
118
+ if (!canExplode(original, env.hasVersusDc)) continue;
115
119
 
116
120
  const sides = original.sides;
117
121
  let lastRaw = original.result;
@@ -124,6 +128,7 @@ function applyAppendingExplode(
124
128
  const raw = rollExplosion(sides, rng, env);
125
129
  const die = createDieResult(sides, raw, ['exploded', 'kept']);
126
130
  die.result = storeResult(raw);
131
+ inheritCritRule(env, original, die, raw);
127
132
  result.push(die);
128
133
  lastRaw = raw;
129
134
  iterations += 1;
@@ -172,7 +177,7 @@ export function applyCompoundExplode(
172
177
  env: EvalEnv,
173
178
  ): DieResult[] {
174
179
  for (const original of pool) {
175
- if (!canExplode(original)) continue;
180
+ if (!canExplode(original, env.hasVersusDc)) continue;
176
181
 
177
182
  const sides = original.sides;
178
183
  let accumulated = original.result;
@@ -194,8 +199,9 @@ export function applyCompoundExplode(
194
199
  if (!exploded) continue;
195
200
 
196
201
  // `critical` and `fumble` keep referring to the original triggering
197
- // roll — after compounding the `result` is a sum.
198
- original.initialResult = original.result;
202
+ // roll — after compounding the `result` is a sum. First writer wins, so
203
+ // a preceding `minN`/`maxN` clamp keeps its record of the raw face.
204
+ original.initialResult ??= original.result;
199
205
  original.result = accumulated;
200
206
  if (!original.modifiers.includes('exploded')) {
201
207
  original.modifiers = [...original.modifiers, 'exploded'];
@@ -20,6 +20,10 @@ import type { DieModifier, DieResult } from '../../types.js';
20
20
  * operates on. Nothing may sum, select, clamp, reroll, explode, or tally them
21
21
  * — a `d10` on the DC side must never come back clamped to 20, and
22
22
  * `{1d20 vs 2d10, 1d4}>=5` must not count the DC faces as successes.
23
+ *
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.
23
27
  */
24
28
  export function isVersusDc(die: DieResult): boolean {
25
29
  return die.modifiers.includes('dc');
@@ -40,6 +44,19 @@ export const SELECTION_AND_TALLY_FLAGS: readonly DieModifier[] = [
40
44
  'failure',
41
45
  ];
42
46
 
47
+ /**
48
+ * {@link SELECTION_AND_TALLY_FLAGS} plus `'meta'`, for a meta context merging
49
+ * into a parent. Meta operands nest (`((1d2)d4)d6`), so a die passes through
50
+ * the merge once per level and the tag must be rebuilt, not appended.
51
+ */
52
+ export const META_MERGE_FLAGS: readonly DieModifier[] = [
53
+ 'kept',
54
+ 'dropped',
55
+ 'success',
56
+ 'failure',
57
+ 'meta',
58
+ ];
59
+
43
60
  /** Selection flags plus `rerolled` — reassigned on every reroll pass. */
44
61
  export const REROLL_SLOT_FLAGS: readonly DieModifier[] = ['kept', 'dropped', 'rerolled'];
45
62
 
@@ -29,9 +29,10 @@ export function markDroppedIndices(
29
29
  kind: KeepDropSpec['kind'],
30
30
  selector: KeepDropSpec['selector'],
31
31
  droppedMask: Uint8Array,
32
+ hasVersusDc: boolean,
32
33
  ): void {
33
34
  if (count === 1) {
34
- markSingleExtreme(dice, kind, selector, droppedMask);
35
+ markSingleExtreme(dice, kind, selector, droppedMask, hasVersusDc);
35
36
  return;
36
37
  }
37
38
 
@@ -40,7 +41,7 @@ export function markDroppedIndices(
40
41
  for (let index = 0; index < dice.length; index++) {
41
42
  const die = dice[index];
42
43
  if (die == null) continue;
43
- if (isVersusDc(die)) continue;
44
+ if (hasVersusDc && isVersusDc(die)) continue;
44
45
 
45
46
  if (die.modifiers.includes('dropped')) {
46
47
  droppedMask[index] = 1;
@@ -91,6 +92,7 @@ function markSingleExtreme(
91
92
  kind: KeepDropSpec['kind'],
92
93
  selector: KeepDropSpec['selector'],
93
94
  droppedMask: Uint8Array,
95
+ hasVersusDc: boolean,
94
96
  ): void {
95
97
  const isKeep = kind === 'keep';
96
98
  const wantHighest = selector === 'highest';
@@ -101,7 +103,7 @@ function markSingleExtreme(
101
103
  for (let index = 0; index < dice.length; index++) {
102
104
  const die = dice[index];
103
105
  if (die == null) continue;
104
- if (isVersusDc(die)) continue;
106
+ if (hasVersusDc && isVersusDc(die)) continue;
105
107
 
106
108
  if (die.modifiers.includes('dropped')) {
107
109
  droppedMask[index] = 1;
@@ -135,13 +137,15 @@ function markSingleExtreme(
135
137
  * Calculates total from dice, excluding dropped dice.
136
138
  *
137
139
  * @param dice - Array of die results
140
+ * @param hasVersusDc - Shared env flag; skips the DC exclusion when no `vs` has
141
+ * tagged anything
138
142
  * @returns Sum of non-dropped dice
139
143
  */
140
- export function sumKeptDice(dice: DieResult[]): number {
144
+ export function sumKeptDice(dice: DieResult[], hasVersusDc: boolean): number {
141
145
  let total = 0;
142
146
 
143
147
  for (const die of dice) {
144
- if (isVersusDc(die)) continue;
148
+ if (hasVersusDc && isVersusDc(die)) continue;
145
149
  if (!die.modifiers.includes('dropped')) total += die.result;
146
150
  }
147
151
 
@@ -15,6 +15,7 @@ import type { CompareOp, DieResult } from '../../types.js';
15
15
  import { createDieResult, createFateDieResult } from '../die.js';
16
16
  import { chargeDie, type EvalEnv } from '../env.js';
17
17
  import { matchesCondition } from './compare.js';
18
+ import { inheritCritRule } from './crit-threshold.js';
18
19
  import { isVersusDc, REROLL_SLOT_FLAGS, rewriteFlags } from './flags.js';
19
20
 
20
21
  /**
@@ -31,15 +32,22 @@ import { isVersusDc, REROLL_SLOT_FLAGS, rewriteFlags } from './flags.js';
31
32
  export const DEFAULT_MAX_REROLL_ITERATIONS = 1_000;
32
33
 
33
34
  /**
34
- * Rolls a replacement die for the given sides, charging it against the global
35
- * dice limit. Fate dice (sides === 0) re-roll on the {-1, 0, +1} range.
35
+ * Rolls a replacement for `parent`, charging it against the global dice limit.
36
+ * Fate dice (sides === 0) re-roll on the {-1, 0, +1} range. Any `cs`/`cf`
37
+ * governing `parent` carries over, so a replacement is judged by the rule the
38
+ * user declared rather than the built-in default.
36
39
  */
37
- function rollReplacement(sides: number, rng: RNG, env: EvalEnv): DieResult {
40
+ function rollReplacement(parent: DieResult, rng: RNG, env: EvalEnv): DieResult {
38
41
  chargeDie(env, 'Reroll');
39
42
 
40
- if (sides === 0) return createFateDieResult(rng.nextInt(-1, 1), []);
43
+ const sides = parent.sides;
44
+ const die =
45
+ sides === 0
46
+ ? createFateDieResult(rng.nextInt(-1, 1), [])
47
+ : createDieResult(sides, rng.nextInt(1, sides), []);
41
48
 
42
- return createDieResult(sides, rng.nextInt(1, sides), []);
49
+ inheritCritRule(env, parent, die);
50
+ return die;
43
51
  }
44
52
 
45
53
  function rerollLimitError(maxIterations: number): EvaluatorError {
@@ -54,8 +62,9 @@ function rerollLimitError(maxIterations: number): EvaluatorError {
54
62
  * True for dice eligible to start rerolling. Dropped dice (from a preceding
55
63
  * keep/drop modifier) are left alone.
56
64
  */
57
- function canReroll(die: DieResult): boolean {
58
- return !die.modifiers.includes('dropped') && !isVersusDc(die);
65
+ function canReroll(die: DieResult, hasVersusDc: boolean): boolean {
66
+ if (hasVersusDc && isVersusDc(die)) return false;
67
+ return !die.modifiers.includes('dropped');
59
68
  }
60
69
 
61
70
  /**
@@ -76,7 +85,7 @@ export function applyRecursiveReroll(
76
85
  const result: DieResult[] = [];
77
86
 
78
87
  for (const original of pool) {
79
- if (!canReroll(original)) {
88
+ if (!canReroll(original, env.hasVersusDc)) {
80
89
  result.push(original);
81
90
  continue;
82
91
  }
@@ -94,7 +103,7 @@ export function applyRecursiveReroll(
94
103
  current.modifiers = rewriteFlags(current.modifiers, REROLL_SLOT_FLAGS, 'rerolled', 'dropped');
95
104
  result.push(current);
96
105
 
97
- current = rollReplacement(current.sides, rng, env);
106
+ current = rollReplacement(current, rng, env);
98
107
  iterations += 1;
99
108
  }
100
109
 
@@ -120,7 +129,7 @@ export function applyRerollOnce(
120
129
  const result: DieResult[] = [];
121
130
 
122
131
  for (const original of pool) {
123
- if (!canReroll(original)) {
132
+ if (!canReroll(original, env.hasVersusDc)) {
124
133
  result.push(original);
125
134
  continue;
126
135
  }
@@ -135,7 +144,7 @@ export function applyRerollOnce(
135
144
  original.modifiers = rewriteFlags(original.modifiers, REROLL_SLOT_FLAGS, 'rerolled', 'dropped');
136
145
  result.push(original);
137
146
 
138
- const replacement = rollReplacement(original.sides, rng, env);
147
+ const replacement = rollReplacement(original, rng, env);
139
148
  replacement.modifiers = rewriteFlags(replacement.modifiers, REROLL_SLOT_FLAGS, 'kept');
140
149
  result.push(replacement);
141
150
  }