roll-parser 3.2.2 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -132,7 +132,8 @@ export { DEFAULT_MAX_EXPLODE_ITERATIONS, DEFAULT_MAX_REROLL_ITERATIONS };
132
132
  //
133
133
 
134
134
  /**
135
- * Per-branch mutable accumulator for tracking rolls and output during recursion.
135
+ * Per-branch accumulator for what flows up a recursion unchanged or merged. A
136
+ * field a parent reformats instead belongs on {@link EvalResult}.
136
137
  *
137
138
  * Module-level export, deliberately absent from `src/index.ts` — the package
138
139
  * surface never mentions it. See {@link mergeMetaRolls} for why the export
@@ -140,8 +141,19 @@ export { DEFAULT_MAX_EXPLODE_ITERATIONS, DEFAULT_MAX_REROLL_ITERATIONS };
140
141
  */
141
142
  export type EvalContext = {
142
143
  rolls: DieResult[];
143
- expressionParts: string[];
144
- renderedParts: string[];
144
+ /**
145
+ * Set by a parent that discards its child's `rendered`. Every pool modifier
146
+ * does: it rebuilds the breakdown from the target's `expression` plus a
147
+ * `renderDice` pass of its own.
148
+ *
149
+ * ! In a chain each modifier's own context is the next one out's discarded
150
+ * ! target, so a render site that skips the check leaves every level above the
151
+ * ! innermost still rendering into the void.
152
+ *
153
+ * ! Does not propagate into nested contexts — `(4d6)kh1` evaluates into a
154
+ * ! fresh one, so only a modifier's direct dice-pool target is spared.
155
+ */
156
+ suppressRender?: boolean;
145
157
  /**
146
158
  * Populated by `evalVersus` with the resolved degree and natural value.
147
159
  * `evaluate()` reads this from the top-level ctx to surface `degree` and
@@ -156,12 +168,28 @@ export type EvalContext = {
156
168
  };
157
169
 
158
170
  /**
159
- * Creates an empty per-branch accumulator. Every sub-evaluation runs in a
160
- * fresh context so its rolls and rendered fragments can be merged back on the
161
- * parent's terms.
171
+ * Creates an empty per-branch accumulator. Every sub-evaluation runs in a fresh
172
+ * context so its rolls merge back on the parent's terms.
162
173
  */
163
174
  function createContext(): EvalContext {
164
- return { rolls: [], expressionParts: [], renderedParts: [] };
175
+ return { rolls: [] };
176
+ }
177
+
178
+ /**
179
+ * Creates the context a postfix pool modifier evaluates its target into.
180
+ *
181
+ * ! Not for `evalSuccessCount`'s subtotal branch or `evalGroupKeepDrop`. Both
182
+ * ! read their child's `rendered`, and both must keep using `createContext`.
183
+ */
184
+ function createDiscardedRenderContext(): EvalContext {
185
+ const ctx = createContext();
186
+ ctx.suppressRender = true;
187
+ return ctx;
188
+ }
189
+
190
+ /** Every pool render routes through here, including ones that look unreachable. */
191
+ function renderedPool(ctx: EvalContext, prefix: string, dice: DieResult[]): string {
192
+ return ctx.suppressRender ? '' : `${prefix}${renderDice(dice)}`;
165
193
  }
166
194
 
167
195
  /**
@@ -171,13 +199,15 @@ function createContext(): EvalContext {
171
199
  type KeepDropChainEntry = KeepDropSpec & { code: string };
172
200
 
173
201
  /**
174
- * Every branch returns its numeric total AND the `RollPart` it contributes
175
- * to the structured breakdown TypeScript exhaustiveness guarantees no
176
- * branch can forget to produce a part.
202
+ * What a branch produces for its parent to reformat TypeScript exhaustiveness
203
+ * guarantees none of the four can be forgotten. `expression` and `rendered` carry
204
+ * this branch's share of the same fields on `RollResult`.
177
205
  */
178
206
  type EvalResult = {
179
207
  total: number;
180
208
  part: RollPart;
209
+ expression: string;
210
+ rendered: string;
181
211
  };
182
212
 
183
213
  //
@@ -338,7 +368,7 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eval
338
368
  function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
339
369
  switch (node.type) {
340
370
  case 'Literal':
341
- return evalLiteral(node, ctx);
371
+ return evalLiteral(node);
342
372
 
343
373
  case 'Dice':
344
374
  return evalDice(node, rng, ctx, env);
@@ -386,7 +416,7 @@ function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
386
416
  return evalCritThreshold(node, rng, ctx, env);
387
417
 
388
418
  case 'Variable':
389
- return evalVariable(node, ctx, env);
419
+ return evalVariable(node, env);
390
420
 
391
421
  default: {
392
422
  const exhaustive: never = node;
@@ -403,11 +433,15 @@ function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
403
433
  // * Leaf nodes
404
434
  //
405
435
 
406
- function evalLiteral(node: LiteralNode, ctx: EvalContext): EvalResult {
436
+ function evalLiteral(node: LiteralNode): EvalResult {
407
437
  const { value } = node;
408
- ctx.expressionParts.push(String(value));
409
- ctx.renderedParts.push(String(value));
410
- return { total: value, part: { type: 'literal', value, total: value, ...partSpan(node) } };
438
+ const text = String(value);
439
+ return {
440
+ total: value,
441
+ part: { type: 'literal', value, total: value, ...partSpan(node) },
442
+ expression: text,
443
+ rendered: text,
444
+ };
411
445
  }
412
446
 
413
447
  /**
@@ -429,7 +463,7 @@ function variableNeedsBraces(name: string): boolean {
429
463
  * while `rendered` keeps the original `@name` (or `@{name}`) annotated with
430
464
  * the resolved value in brackets so readers can attribute the number.
431
465
  */
432
- function evalVariable(node: VariableNode, ctx: EvalContext, env: EvalEnv): EvalResult {
466
+ function evalVariable(node: VariableNode, env: EvalEnv): EvalResult {
433
467
  const present = Object.hasOwn(env.context, node.name);
434
468
  if (!present) {
435
469
  if (env.onMissingVariable === 'throw') {
@@ -440,11 +474,11 @@ function evalVariable(node: VariableNode, ctx: EvalContext, env: EvalEnv): EvalR
440
474
  );
441
475
  }
442
476
  const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
443
- ctx.expressionParts.push('0');
444
- ctx.renderedParts.push(`${display}[0]`);
445
477
  return {
446
478
  total: 0,
447
479
  part: { type: 'variable', name: node.name, value: 0, total: 0, ...partSpan(node) },
480
+ expression: '0',
481
+ rendered: `${display}[0]`,
448
482
  };
449
483
  }
450
484
 
@@ -457,11 +491,11 @@ function evalVariable(node: VariableNode, ctx: EvalContext, env: EvalEnv): EvalR
457
491
  );
458
492
  }
459
493
  const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
460
- ctx.expressionParts.push(String(value));
461
- ctx.renderedParts.push(`${display}[${value}]`);
462
494
  return {
463
495
  total: value,
464
496
  part: { type: 'variable', name: node.name, value, total: value, ...partSpan(node) },
497
+ expression: String(value),
498
+ rendered: `${display}[${value}]`,
465
499
  };
466
500
  }
467
501
 
@@ -556,7 +590,7 @@ function rollPool(
556
590
  notation: string,
557
591
  rollDie: () => DieResult,
558
592
  ctx: EvalContext,
559
- ): { total: number; rolls: DieResult[] } {
593
+ ): { total: number; rolls: DieResult[]; expression: string; rendered: string } {
560
594
  const dice: DieResult[] = [];
561
595
  let total = 0;
562
596
 
@@ -568,10 +602,13 @@ function rollPool(
568
602
 
569
603
  appendAll(ctx.rolls, dice);
570
604
 
571
- ctx.expressionParts.push(notation);
572
- ctx.renderedParts.push(`${notation}${renderDice(dice)}`);
573
-
574
- return { total, rolls: dice };
605
+ return {
606
+ total,
607
+ rolls: dice,
608
+ expression: notation,
609
+ // Empty rather than notation-only, so a stray reader fails visibly.
610
+ rendered: ctx.suppressRender ? '' : `${notation}${renderDice(dice)}`,
611
+ };
575
612
  }
576
613
 
577
614
  /**
@@ -604,7 +641,7 @@ function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
604
641
 
605
642
  chargeDice(env, count, 'Dice');
606
643
 
607
- const { total, rolls } = rollPool(
644
+ const { total, rolls, expression, rendered } = rollPool(
608
645
  count,
609
646
  `${count}d${sides}`,
610
647
  // Fresh `['kept']` per die — `success-count` appends tally flags in
@@ -613,7 +650,12 @@ function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
613
650
  ctx,
614
651
  );
615
652
 
616
- return { total, part: { type: 'dice', count, sides, rolls, total, ...partSpan(node) } };
653
+ return {
654
+ total,
655
+ part: { type: 'dice', count, sides, rolls, total, ...partSpan(node) },
656
+ expression,
657
+ rendered,
658
+ };
617
659
  }
618
660
 
619
661
  function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
@@ -622,14 +664,19 @@ function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalE
622
664
  requireDiceCount(count, 'FateDice');
623
665
  chargeDice(env, count, 'FateDice');
624
666
 
625
- const { total, rolls } = rollPool(
667
+ const { total, rolls, expression, rendered } = rollPool(
626
668
  count,
627
669
  `${count}dF`,
628
670
  () => createFateDieResult(rng.nextInt(-1, 1), ['kept']),
629
671
  ctx,
630
672
  );
631
673
 
632
- return { total, part: { type: 'fateDice', count, rolls, total, ...partSpan(node) } };
674
+ return {
675
+ total,
676
+ part: { type: 'fateDice', count, rolls, total, ...partSpan(node) },
677
+ expression,
678
+ rendered,
679
+ };
633
680
  }
634
681
 
635
682
  //
@@ -680,9 +727,6 @@ function propagateMetadata(parent: EvalContext, metadata: EvalContext['versusMet
680
727
  /**
681
728
  * Merges a child sub-context back into its parent. Copies `rolls` and
682
729
  * delegates `versusMetadata` propagation to `propagateMetadata`.
683
- *
684
- * Does not merge `expressionParts` / `renderedParts` — each wrapper formats
685
- * those with its own operator/function syntax.
686
730
  */
687
731
  function mergeContext(parent: EvalContext, child: EvalContext): void {
688
732
  appendAll(parent.rolls, child.rolls);
@@ -703,14 +747,6 @@ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalE
703
747
  mergeContext(ctx, leftCtx);
704
748
  mergeContext(ctx, rightCtx);
705
749
 
706
- const leftExpr = leftCtx.expressionParts.join('');
707
- const rightExpr = rightCtx.expressionParts.join('');
708
- const leftRendered = leftCtx.renderedParts.join('');
709
- const rightRendered = rightCtx.renderedParts.join('');
710
-
711
- ctx.expressionParts.push(`${leftExpr} ${node.operator} ${rightExpr}`);
712
- ctx.renderedParts.push(`${leftRendered} ${node.operator} ${rightRendered}`);
713
-
714
750
  const total = applyBinaryOperator(node.operator, left.total, right.total);
715
751
 
716
752
  return {
@@ -723,6 +759,8 @@ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalE
723
759
  total,
724
760
  ...partSpan(node),
725
761
  },
762
+ expression: `${left.expression} ${node.operator} ${right.expression}`,
763
+ rendered: `${left.rendered} ${node.operator} ${right.rendered}`,
726
764
  };
727
765
  }
728
766
 
@@ -763,17 +801,13 @@ function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv
763
801
 
764
802
  mergeContext(ctx, innerCtx);
765
803
 
766
- const innerExpr = innerCtx.expressionParts.join('');
767
- const innerRendered = innerCtx.renderedParts.join('');
768
-
769
- ctx.expressionParts.push(`-${innerExpr}`);
770
- ctx.renderedParts.push(`-${innerRendered}`);
771
-
772
804
  const total = -inner.total;
773
805
 
774
806
  return {
775
807
  total,
776
808
  part: { type: 'unaryOp', operator: '-', operand: inner.part, total, ...partSpan(node) },
809
+ expression: `-${inner.expression}`,
810
+ rendered: `-${inner.rendered}`,
777
811
  };
778
812
  }
779
813
 
@@ -787,12 +821,11 @@ function evalGrouped(node: GroupedNode, rng: RNG, ctx: EvalContext, env: EvalEnv
787
821
 
788
822
  mergeContext(ctx, innerCtx);
789
823
 
790
- ctx.expressionParts.push(`(${innerCtx.expressionParts.join('')})`);
791
- ctx.renderedParts.push(`(${innerCtx.renderedParts.join('')})`);
792
-
793
824
  return {
794
825
  total: inner.total,
795
826
  part: { type: 'grouped', inner: inner.part, total: inner.total, ...partSpan(node) },
827
+ expression: `(${inner.expression})`,
828
+ rendered: `(${inner.rendered})`,
796
829
  };
797
830
  }
798
831
 
@@ -816,18 +849,20 @@ function evalGroup(node: GroupNode, rng: RNG, ctx: EvalContext, env: EvalEnv): E
816
849
  const subCtx = createContext();
817
850
  const sub = evalNode(expr, rng, subCtx, env);
818
851
  mergeContext(ctx, subCtx);
819
- subExprs.push(subCtx.expressionParts.join(''));
820
- subRendered.push(subCtx.renderedParts.join(''));
852
+ subExprs.push(sub.expression);
853
+ subRendered.push(sub.rendered);
821
854
  subParts.push(sub.part);
822
855
  total += sub.total;
823
856
  }
824
857
 
825
- ctx.expressionParts.push(`{${subExprs.join(', ')}}`);
826
- ctx.renderedParts.push(`{${subRendered.join(', ')}}`);
827
-
828
858
  // No `keptIndices` — bare groups (and single-sub passthroughs) perform
829
859
  // no sub-roll selection; only `evalGroupKeepDrop` sets it.
830
- return { total, part: { type: 'group', parts: subParts, total, ...partSpan(node) } };
860
+ return {
861
+ total,
862
+ part: { type: 'group', parts: subParts, total, ...partSpan(node) },
863
+ expression: `{${subExprs.join(', ')}}`,
864
+ rendered: `{${subRendered.join(', ')}}`,
865
+ };
831
866
  }
832
867
 
833
868
  function evalFunctionCall(
@@ -849,12 +884,6 @@ function evalFunctionCall(
849
884
  mergeContext(ctx, argCtx);
850
885
  }
851
886
 
852
- const argExprs = argCtxs.map((c) => c.expressionParts.join(''));
853
- const argRendereds = argCtxs.map((c) => c.renderedParts.join(''));
854
-
855
- ctx.expressionParts.push(`${node.name}(${argExprs.join(', ')})`);
856
- ctx.renderedParts.push(`${node.name}(${argRendereds.join(', ')})`);
857
-
858
887
  const total = applyFunction(
859
888
  node.name,
860
889
  argResults.map((r) => r.total),
@@ -869,6 +898,8 @@ function evalFunctionCall(
869
898
  total,
870
899
  ...partSpan(node),
871
900
  },
901
+ expression: `${node.name}(${argResults.map((r) => r.expression).join(', ')})`,
902
+ rendered: `${node.name}(${argResults.map((r) => r.rendered).join(', ')})`,
872
903
  };
873
904
  }
874
905
 
@@ -1068,9 +1099,9 @@ function formatExplodeCode(
1068
1099
  }
1069
1100
 
1070
1101
  function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1071
- const targetCtx = createContext();
1102
+ const targetCtx = createDiscardedRenderContext();
1072
1103
  const target = evalNode(node.target, rng, targetCtx, env);
1073
- const targetExpr = targetCtx.expressionParts.join('');
1104
+ const targetExpr = target.expression;
1074
1105
 
1075
1106
  let thresholdValue: number | undefined;
1076
1107
  if (node.threshold != null) {
@@ -1096,9 +1127,12 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
1096
1127
 
1097
1128
  // No-op when the target produced no dice (e.g., `(1+2)!`).
1098
1129
  if (targetCtx.rolls.length === 0) {
1099
- ctx.expressionParts.push(`${targetExpr}${code}`);
1100
- ctx.renderedParts.push(`${targetExpr}${code}`);
1101
- return { total: target.total, part: buildPart(target.total, targetCtx.rolls) };
1130
+ return {
1131
+ total: target.total,
1132
+ part: buildPart(target.total, targetCtx.rolls),
1133
+ expression: `${targetExpr}${code}`,
1134
+ rendered: ctx.suppressRender ? '' : `${targetExpr}${code}`,
1135
+ };
1102
1136
  }
1103
1137
 
1104
1138
  const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
@@ -1106,23 +1140,25 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
1106
1140
  const expanded = EXPLODE_APPLIERS[node.variant](targetCtx.rolls, shouldExplode, rng, env);
1107
1141
 
1108
1142
  appendAll(ctx.rolls, expanded);
1109
- ctx.expressionParts.push(`${targetExpr}${code}`);
1110
- // Rendered form carries the explode code — unlike kh/dl, whose dropped dice
1111
- // are self-evident, explosion origin is otherwise invisible.
1112
- ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
1113
-
1114
1143
  const total = sumKeptDice(expanded, env.hasVersusDc);
1115
- return { total, part: buildPart(total, expanded) };
1144
+ return {
1145
+ total,
1146
+ part: buildPart(total, expanded),
1147
+ expression: `${targetExpr}${code}`,
1148
+ // Rendered form carries the explode code — unlike kh/dl, whose dropped dice
1149
+ // are self-evident, explosion origin is otherwise invisible.
1150
+ rendered: renderedPool(ctx, `${targetExpr}${code}`, expanded),
1151
+ };
1116
1152
  }
1117
1153
 
1118
1154
  function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1119
- const targetCtx = createContext();
1155
+ const targetCtx = createDiscardedRenderContext();
1120
1156
  const target = evalNode(node.target, rng, targetCtx, env);
1121
1157
 
1122
1158
  const thresholdValue = evalMetaOperand(node.condition.value, rng, ctx, env);
1123
1159
 
1124
1160
  const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
1125
- const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
1161
+ const modifierExpr = joinModifierCode(target.expression, code);
1126
1162
  const condition: ResolvedComparePoint = {
1127
1163
  operator: node.condition.operator,
1128
1164
  value: thresholdValue,
@@ -1130,8 +1166,6 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1130
1166
 
1131
1167
  // No-op when the target produced no dice (e.g., `(1+2)r<5`).
1132
1168
  if (targetCtx.rolls.length === 0) {
1133
- ctx.expressionParts.push(modifierExpr);
1134
- ctx.renderedParts.push(modifierExpr);
1135
1169
  const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
1136
1170
  return {
1137
1171
  total,
@@ -1144,6 +1178,8 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1144
1178
  total,
1145
1179
  ...partSpan(node),
1146
1180
  },
1181
+ expression: modifierExpr,
1182
+ rendered: ctx.suppressRender ? '' : modifierExpr,
1147
1183
  };
1148
1184
  }
1149
1185
 
@@ -1152,9 +1188,6 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1152
1188
  : applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
1153
1189
 
1154
1190
  appendAll(ctx.rolls, pool);
1155
- ctx.expressionParts.push(modifierExpr);
1156
- ctx.renderedParts.push(`${modifierExpr}${renderDice(pool)}`);
1157
-
1158
1191
  const total = sumKeptDice(pool, env.hasVersusDc);
1159
1192
  return {
1160
1193
  total,
@@ -1167,6 +1200,8 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1167
1200
  total,
1168
1201
  ...partSpan(node),
1169
1202
  },
1203
+ expression: modifierExpr,
1204
+ rendered: renderedPool(ctx, modifierExpr, pool),
1170
1205
  };
1171
1206
  }
1172
1207
 
@@ -1177,7 +1212,7 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1177
1212
  * and the total re-summed, since clamping changes kept-die values.
1178
1213
  */
1179
1214
  function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1180
- const targetCtx = createContext();
1215
+ const targetCtx = createDiscardedRenderContext();
1181
1216
  const target = evalNode(node.target, rng, targetCtx, env);
1182
1217
 
1183
1218
  const boundValue = evalMetaOperand(node.value, rng, ctx, env);
@@ -1199,13 +1234,12 @@ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalE
1199
1234
  // Negative bounds render parenthesized so `result.expression` re-parses
1200
1235
  // (`4d6min-2` is a syntax error; `4d6min(-2)` is not).
1201
1236
  const code = boundValue < 0 ? `${node.bound}(${boundValue})` : `${node.bound}${boundValue}`;
1202
- const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
1203
-
1204
- ctx.expressionParts.push(modifierExpr);
1205
- ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
1237
+ const modifierExpr = joinModifierCode(target.expression, code);
1206
1238
 
1207
1239
  const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
1208
1240
  return {
1241
+ expression: modifierExpr,
1242
+ rendered: renderedPool(ctx, modifierExpr, targetCtx.rolls),
1209
1243
  total,
1210
1244
  part: {
1211
1245
  type: 'dieBound',
@@ -1235,7 +1269,7 @@ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalE
1235
1269
  * per-sub-roll sorting (Stage 3 spec §3 "Group interaction") is implemented.
1236
1270
  */
1237
1271
  function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
1238
- const targetCtx = createContext();
1272
+ const targetCtx = createDiscardedRenderContext();
1239
1273
  const target = evalNode(node.target, rng, targetCtx, env);
1240
1274
 
1241
1275
  const sortedRolls = sortDice(targetCtx.rolls, node.order, env.hasVersusDc);
@@ -1244,12 +1278,11 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
1244
1278
  propagateMetadata(ctx, targetCtx.versusMetadata);
1245
1279
 
1246
1280
  const code = node.order === 'ascending' ? 's' : 'sd';
1247
- const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
1248
-
1249
- ctx.expressionParts.push(modifierExpr);
1250
- ctx.renderedParts.push(`${modifierExpr}${renderDice(sortedRolls)}`);
1281
+ const modifierExpr = joinModifierCode(target.expression, code);
1251
1282
 
1252
1283
  return {
1284
+ expression: modifierExpr,
1285
+ rendered: renderedPool(ctx, modifierExpr, sortedRolls),
1253
1286
  total: target.total,
1254
1287
  part: {
1255
1288
  type: 'sort',
@@ -1288,7 +1321,7 @@ function evalCritThreshold(
1288
1321
  ctx: EvalContext,
1289
1322
  env: EvalEnv,
1290
1323
  ): EvalResult {
1291
- const targetCtx = createContext();
1324
+ const targetCtx = createDiscardedRenderContext();
1292
1325
  const target = evalNode(node.target, rng, targetCtx, env);
1293
1326
 
1294
1327
  const successResolved = node.successThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
@@ -1308,12 +1341,11 @@ function evalCritThreshold(
1308
1341
  const modifierExpr = [
1309
1342
  ...successResolved.map((t) => (t === 'default' ? 'cs' : `cs${t.operator}${t.value}`)),
1310
1343
  ...failResolved.map((t) => (t === 'default' ? 'cf' : `cf${t.operator}${t.value}`)),
1311
- ].reduce(joinModifierCode, targetCtx.expressionParts.join(''));
1312
-
1313
- ctx.expressionParts.push(modifierExpr);
1314
- ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
1344
+ ].reduce(joinModifierCode, target.expression);
1315
1345
 
1316
1346
  return {
1347
+ expression: modifierExpr,
1348
+ rendered: renderedPool(ctx, modifierExpr, targetCtx.rolls),
1317
1349
  total: target.total,
1318
1350
  part: {
1319
1351
  type: 'critThreshold',
@@ -1359,7 +1391,7 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
1359
1391
  return evalGroupKeepDrop(node, baseTarget, specs, rng, ctx, env);
1360
1392
  }
1361
1393
 
1362
- const targetCtx = createContext();
1394
+ const targetCtx = createDiscardedRenderContext();
1363
1395
  const target = evalNode(baseTarget, rng, targetCtx, env);
1364
1396
 
1365
1397
  const mergedDice = mergeDropSets(targetCtx.rolls, specs, env.hasVersusDc);
@@ -1368,12 +1400,9 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
1368
1400
 
1369
1401
  const total = sumKeptDice(mergedDice, env.hasVersusDc);
1370
1402
 
1371
- const targetExpr = targetCtx.expressionParts.join('');
1403
+ const targetExpr = target.expression;
1372
1404
  const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
1373
1405
 
1374
- ctx.expressionParts.push(joinModifierCode(targetExpr, keepDropCodes));
1375
- ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
1376
-
1377
1406
  return {
1378
1407
  total,
1379
1408
  part: {
@@ -1383,6 +1412,8 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
1383
1412
  total,
1384
1413
  ...partSpan(node),
1385
1414
  },
1415
+ expression: joinModifierCode(targetExpr, keepDropCodes),
1416
+ rendered: renderedPool(ctx, targetExpr, mergedDice),
1386
1417
  };
1387
1418
  }
1388
1419
 
@@ -1446,8 +1477,8 @@ function evalGroupKeepDrop(
1446
1477
  subtotal: sub.total,
1447
1478
  part: sub.part,
1448
1479
  rolls: subCtx.rolls,
1449
- expr: subCtx.expressionParts.join(''),
1450
- rendered: subCtx.renderedParts.join(''),
1480
+ expr: sub.expression,
1481
+ rendered: sub.rendered,
1451
1482
  versusMetadata: subCtx.versusMetadata,
1452
1483
  scoredSuccesses: env.subtotalSuccesses - successTally,
1453
1484
  scoredFailures: env.subtotalFailures - failureTally,
@@ -1507,11 +1538,6 @@ function evalGroupKeepDrop(
1507
1538
  const subExprStrs = subRolls.map((s) => s.expr);
1508
1539
  const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
1509
1540
 
1510
- ctx.expressionParts.push(`{${subExprStrs.join(', ')}}${keepDropCodes}`);
1511
- // Keep/drop codes live in `expressionParts` only — the per-sub strikethrough
1512
- // already shows which sub-rolls were kept.
1513
- ctx.renderedParts.push(`{${outerRendered.join(', ')}}`);
1514
-
1515
1541
  // `keptIndices` sits on the inner `group` part even though the outer modifier
1516
1542
  // computed it — it describes sub-roll selection. Dropped sub-rolls keep their
1517
1543
  // complete parts; consumers filter by `keptIndices`.
@@ -1532,6 +1558,10 @@ function evalGroupKeepDrop(
1532
1558
  total,
1533
1559
  ...partSpan(node),
1534
1560
  },
1561
+ expression: `{${subExprStrs.join(', ')}}${keepDropCodes}`,
1562
+ // Keep/drop codes live in `expression` only — the per-sub strikethrough
1563
+ // already shows which sub-rolls were kept.
1564
+ rendered: `{${outerRendered.join(', ')}}`,
1535
1565
  };
1536
1566
  }
1537
1567
 
@@ -1582,7 +1612,7 @@ function evalSuccessCount(
1582
1612
 
1583
1613
  const targetCtx = createContext();
1584
1614
  const target = evalNode(node.target, rng, targetCtx, env);
1585
- const targetExpr = targetCtx.expressionParts.join('');
1615
+ const targetExpr = target.expression;
1586
1616
 
1587
1617
  // True once any count has run: an inner one that tagged this very pool (only
1588
1618
  // a group can arrange that — `{4d6>=5}<=2f5`), or an unrelated earlier one,
@@ -1652,9 +1682,12 @@ function evalSuccessCount(
1652
1682
  // Reachable only through a zero-count pool — a target holding no dice node at
1653
1683
  // all is rejected at parse time.
1654
1684
  if (pool.length === 0) {
1655
- ctx.expressionParts.push(`${targetExpr}${code}`);
1656
- ctx.renderedParts.push(`${targetExpr}${code}`);
1657
- return { total: 0, part: buildPart(0, 0, 0) };
1685
+ return {
1686
+ total: 0,
1687
+ part: buildPart(0, 0, 0),
1688
+ expression: `${targetExpr}${code}`,
1689
+ rendered: `${targetExpr}${code}`,
1690
+ };
1658
1691
  }
1659
1692
 
1660
1693
  const result = countSuccesses(
@@ -1673,19 +1706,16 @@ function evalSuccessCount(
1673
1706
  }
1674
1707
 
1675
1708
  appendAll(ctx.rolls, targetCtx.rolls);
1676
- ctx.expressionParts.push(`${targetExpr}${code}`);
1677
- // A subtotal count renders through the group — its sub-rolls carry their own
1678
- // brackets, and one flat bracket would spell out the units it never used. The
1679
- // strip pairs with the tag release above: markers and tags go together.
1680
- ctx.renderedParts.push(
1681
- bySubtotal
1682
- ? `${stripTallyMarkers(targetCtx.renderedParts.join(''))}${code}`
1683
- : `${targetExpr}${code}${renderDice(targetCtx.rolls)}`,
1684
- );
1685
-
1686
1709
  return {
1687
1710
  total: result.total,
1688
1711
  part: buildPart(result.total, result.successes, result.failures),
1712
+ expression: `${targetExpr}${code}`,
1713
+ // A subtotal count renders through the group — its sub-rolls carry their own
1714
+ // brackets, and one flat bracket would spell out the units it never used. The
1715
+ // strip pairs with the tag release above: markers and tags go together.
1716
+ rendered: bySubtotal
1717
+ ? `${stripTallyMarkers(target.rendered)}${code}`
1718
+ : renderedPool(ctx, `${targetExpr}${code}`, targetCtx.rolls),
1689
1719
  };
1690
1720
  }
1691
1721
 
@@ -1778,13 +1808,6 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1778
1808
  if (dcCtx.rolls.length > 0) env.hasVersusDc = true;
1779
1809
  appendAll(ctx.rolls, dcCtx.rolls);
1780
1810
 
1781
- const rollExpr = rollCtx.expressionParts.join('');
1782
- const dcExpr = dcCtx.expressionParts.join('');
1783
- const rollRendered = rollCtx.renderedParts.join('');
1784
- const dcRendered = dcCtx.renderedParts.join('');
1785
-
1786
- ctx.expressionParts.push(`${rollExpr} vs ${dcExpr}`);
1787
- ctx.renderedParts.push(`${rollRendered} vs ${dcRendered}`);
1788
1811
  ctx.versusMetadata = { degree, natural, dcTotal: dcResult.total };
1789
1812
 
1790
1813
  return {
@@ -1797,6 +1820,8 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
1797
1820
  total: rollResult.total,
1798
1821
  ...partSpan(node),
1799
1822
  },
1823
+ expression: `${rollResult.expression} vs ${dcResult.expression}`,
1824
+ rendered: `${rollResult.rendered} vs ${dcResult.rendered}`,
1800
1825
  };
1801
1826
  } finally {
1802
1827
  env.insideVersus = false;
@@ -1879,7 +1904,7 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
1879
1904
  };
1880
1905
  const ctx = createContext();
1881
1906
 
1882
- const { total, part } = evalNode(ast, rng, ctx, env);
1907
+ const { total, part, expression, rendered: renderedBody } = evalNode(ast, rng, ctx, env);
1883
1908
 
1884
1909
  if (!Number.isFinite(total)) {
1885
1910
  throw new EvaluatorError(
@@ -1889,11 +1914,10 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
1889
1914
  );
1890
1915
  }
1891
1916
 
1892
- const expression = ctx.expressionParts.join('');
1893
1917
  // Versus replaces the numeric total with the degree label in the rendered
1894
1918
  // form; `RollResult.total` remains the numeric roll total.
1895
1919
  const trailing = ctx.versusMetadata ? degreeLabel(ctx.versusMetadata.degree) : String(total);
1896
- const rendered = `${ctx.renderedParts.join('')} = ${trailing}`;
1920
+ const rendered = `${renderedBody} = ${trailing}`;
1897
1921
 
1898
1922
  // `RollResult` is `Readonly` — optional fields fold in via conditional spreads.
1899
1923
  const versus = ctx.versusMetadata;