roll-parser 3.3.0 → 3.4.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 (52) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +60 -32
  3. package/dist/cli/args.d.ts +11 -1
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +67 -42
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/cli/format.d.ts +8 -1
  8. package/dist/cli/format.d.ts.map +1 -1
  9. package/dist/cli/format.js +4 -3
  10. package/dist/cli/format.js.map +1 -1
  11. package/dist/cli/main.d.ts +3 -3
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/main.js +50 -19
  14. package/dist/cli/main.js.map +1 -1
  15. package/dist/evaluator/evaluator.d.ts +15 -3
  16. package/dist/evaluator/evaluator.d.ts.map +1 -1
  17. package/dist/evaluator/evaluator.js +134 -105
  18. package/dist/evaluator/evaluator.js.map +1 -1
  19. package/dist/evaluator/modifiers/flags.d.ts +7 -1
  20. package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
  21. package/dist/evaluator/modifiers/flags.js +9 -1
  22. package/dist/evaluator/modifiers/flags.js.map +1 -1
  23. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  24. package/dist/evaluator/modifiers/keep-drop.js +19 -16
  25. package/dist/evaluator/modifiers/keep-drop.js.map +1 -1
  26. package/dist/lexer/lexer.d.ts +5 -0
  27. package/dist/lexer/lexer.d.ts.map +1 -1
  28. package/dist/lexer/lexer.js +3 -0
  29. package/dist/lexer/lexer.js.map +1 -1
  30. package/dist/parser/parser.d.ts +1 -0
  31. package/dist/parser/parser.d.ts.map +1 -1
  32. package/dist/parser/parser.js +23 -20
  33. package/dist/parser/parser.js.map +1 -1
  34. package/dist/render.d.ts.map +1 -1
  35. package/dist/render.js +2 -1
  36. package/dist/render.js.map +1 -1
  37. package/dist/rng/seeded.d.ts.map +1 -1
  38. package/dist/rng/seeded.js.map +1 -1
  39. package/dist/version.d.ts +1 -1
  40. package/dist/version.js +1 -1
  41. package/package.json +13 -14
  42. package/src/cli/args.ts +112 -40
  43. package/src/cli/format.ts +11 -3
  44. package/src/cli/main.ts +97 -22
  45. package/src/evaluator/evaluator.ts +189 -150
  46. package/src/evaluator/modifiers/flags.ts +18 -2
  47. package/src/evaluator/modifiers/keep-drop.ts +42 -16
  48. package/src/lexer/lexer.ts +8 -0
  49. package/src/parser/parser.ts +36 -32
  50. package/src/render.ts +2 -1
  51. package/src/rng/seeded.ts +4 -0
  52. package/src/version.ts +1 -1
@@ -24,7 +24,8 @@ export { EvaluatorError };
24
24
  export declare const DEFAULT_MAX_DICE = 10000;
25
25
  export { DEFAULT_MAX_EXPLODE_ITERATIONS, DEFAULT_MAX_REROLL_ITERATIONS };
26
26
  /**
27
- * Per-branch mutable accumulator for tracking rolls and output during recursion.
27
+ * Per-branch accumulator for what flows up a recursion unchanged or merged. A
28
+ * field a parent reformats instead belongs on {@link EvalResult}.
28
29
  *
29
30
  * Module-level export, deliberately absent from `src/index.ts` — the package
30
31
  * surface never mentions it. See {@link mergeMetaRolls} for why the export
@@ -32,8 +33,19 @@ export { DEFAULT_MAX_EXPLODE_ITERATIONS, DEFAULT_MAX_REROLL_ITERATIONS };
32
33
  */
33
34
  export type EvalContext = {
34
35
  rolls: DieResult[];
35
- expressionParts: string[];
36
- renderedParts: string[];
36
+ /**
37
+ * Set by a parent that discards its child's `rendered`. Every pool modifier
38
+ * does: it rebuilds the breakdown from the target's `expression` plus a
39
+ * `renderDice` pass of its own.
40
+ *
41
+ * ! In a chain each modifier's own context is the next one out's discarded
42
+ * ! target, so a render site that skips the check leaves every level above the
43
+ * ! innermost still rendering into the void.
44
+ *
45
+ * ! Does not propagate into nested contexts — `(4d6)kh1` evaluates into a
46
+ * ! fresh one, so only a modifier's direct dice-pool target is spared.
47
+ */
48
+ suppressRender?: boolean;
37
49
  /**
38
50
  * Populated by `evalVersus` with the resolved degree and natural value.
39
51
  * `evaluate()` reads this from the top-level ctx to surface `degree` and
@@ -1 +1 @@
1
- {"version":3,"file":"evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluator/evaluator.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAiB,cAAc,EAAuC,MAAM,cAAc,CAAC;AAElG,OAAO,KAAK,EACV,OAAO,EAmBR,MAAM,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,KAAK,EAIV,SAAS,EACT,eAAe,EAKf,UAAU,EACX,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAK9C,OAAO,EAKL,8BAA8B,EAC/B,MAAM,wBAAwB,CAAC;AAWhC,OAAO,EAGL,6BAA6B,EAC9B,MAAM,uBAAuB,CAAC;AAM/B,OAAO,EAAE,cAAc,EAAE,CAAC;AAM1B;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,QAAS,CAAC;AAmCvC,OAAO,EAAE,8BAA8B,EAAE,6BAA6B,EAAE,CAAC;AAMzE;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB;;;;;OAKG;IACH,cAAc,CAAC,EAAE;QACf,MAAM,EAAE,eAAe,CAAC;QACxB,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;QAC5B,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;CACH,CAAC;AAsnDF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,OAAO,GAAE,eAAoB,GAAG,UAAU,CAiE1F"}
1
+ {"version":3,"file":"evaluator.d.ts","sourceRoot":"","sources":["../../src/evaluator/evaluator.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAiB,cAAc,EAAuC,MAAM,cAAc,CAAC;AAElG,OAAO,KAAK,EACV,OAAO,EAmBR,MAAM,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,KAAK,EAIV,SAAS,EACT,eAAe,EAKf,UAAU,EACX,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAK9C,OAAO,EAKL,8BAA8B,EAC/B,MAAM,wBAAwB,CAAC;AAWhC,OAAO,EAGL,6BAA6B,EAC9B,MAAM,uBAAuB,CAAC;AAM/B,OAAO,EAAE,cAAc,EAAE,CAAC;AAM1B;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,QAAS,CAAC;AAmCvC,OAAO,EAAE,8BAA8B,EAAE,6BAA6B,EAAE,CAAC;AAMzE;;;;;;;GAOG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB;;;;;;;;;;;OAWG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;OAKG;IACH,cAAc,CAAC,EAAE;QACf,MAAM,EAAE,eAAe,CAAC;QACxB,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;QAC5B,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;CACH,CAAC;AAkpDF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,OAAO,GAAE,eAAoB,GAAG,UAAU,CAgE1F"}
@@ -26,7 +26,15 @@ function resolveLimit(value, option, fallback, min) {
26
26
  }
27
27
  export { DEFAULT_MAX_EXPLODE_ITERATIONS, DEFAULT_MAX_REROLL_ITERATIONS };
28
28
  function createContext() {
29
- return { rolls: [], expressionParts: [], renderedParts: [] };
29
+ return { rolls: [] };
30
+ }
31
+ function createDiscardedRenderContext() {
32
+ const ctx = createContext();
33
+ ctx.suppressRender = true;
34
+ return ctx;
35
+ }
36
+ function renderedPool(ctx, prefix, dice) {
37
+ return ctx.suppressRender ? '' : `${prefix}${renderDice(dice)}`;
30
38
  }
31
39
  function partSpan(node) {
32
40
  if (node.start == null || node.end == null)
@@ -97,7 +105,7 @@ function evalNode(node, rng, ctx, env) {
97
105
  function evalNodeInner(node, rng, ctx, env) {
98
106
  switch (node.type) {
99
107
  case 'Literal':
100
- return evalLiteral(node, ctx);
108
+ return evalLiteral(node);
101
109
  case 'Dice':
102
110
  return evalDice(node, rng, ctx, env);
103
111
  case 'FateDice':
@@ -129,34 +137,38 @@ function evalNodeInner(node, rng, ctx, env) {
129
137
  case 'CritThreshold':
130
138
  return evalCritThreshold(node, rng, ctx, env);
131
139
  case 'Variable':
132
- return evalVariable(node, ctx, env);
140
+ return evalVariable(node, env);
133
141
  default: {
134
142
  const exhaustive = node;
135
143
  throw new EvaluatorError(`Unknown node type: ${exhaustive.type}`, 'UNKNOWN_NODE_TYPE', exhaustive.type);
136
144
  }
137
145
  }
138
146
  }
139
- function evalLiteral(node, ctx) {
147
+ function evalLiteral(node) {
140
148
  const { value } = node;
141
- ctx.expressionParts.push(String(value));
142
- ctx.renderedParts.push(String(value));
143
- return { total: value, part: { type: 'literal', value, total: value, ...partSpan(node) } };
149
+ const text = String(value);
150
+ return {
151
+ total: value,
152
+ part: { type: 'literal', value, total: value, ...partSpan(node) },
153
+ expression: text,
154
+ rendered: text,
155
+ };
144
156
  }
145
157
  function variableNeedsBraces(name) {
146
158
  return !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
147
159
  }
148
- function evalVariable(node, ctx, env) {
160
+ function evalVariable(node, env) {
149
161
  const present = Object.hasOwn(env.context, node.name);
150
162
  if (!present) {
151
163
  if (env.onMissingVariable === 'throw') {
152
164
  throw new EvaluatorError(`Undefined variable: ${node.name}`, 'UNDEFINED_VARIABLE', 'Variable');
153
165
  }
154
166
  const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
155
- ctx.expressionParts.push('0');
156
- ctx.renderedParts.push(`${display}[0]`);
157
167
  return {
158
168
  total: 0,
159
169
  part: { type: 'variable', name: node.name, value: 0, total: 0, ...partSpan(node) },
170
+ expression: '0',
171
+ rendered: `${display}[0]`,
160
172
  };
161
173
  }
162
174
  const value = env.context[node.name];
@@ -164,11 +176,11 @@ function evalVariable(node, ctx, env) {
164
176
  throw new EvaluatorError(`Invalid variable value: ${node.name} = ${value}`, 'INVALID_VARIABLE_VALUE', 'Variable');
165
177
  }
166
178
  const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
167
- ctx.expressionParts.push(String(value));
168
- ctx.renderedParts.push(`${display}[${value}]`);
169
179
  return {
170
180
  total: value,
171
181
  part: { type: 'variable', name: node.name, value, total: value, ...partSpan(node) },
182
+ expression: String(value),
183
+ rendered: `${display}[${value}]`,
172
184
  };
173
185
  }
174
186
  function evalMetaOperand(node, rng, ctx, env) {
@@ -207,9 +219,12 @@ function rollPool(count, notation, rollDie, ctx) {
207
219
  total += die.result;
208
220
  }
209
221
  appendAll(ctx.rolls, dice);
210
- ctx.expressionParts.push(notation);
211
- ctx.renderedParts.push(`${notation}${renderDice(dice)}`);
212
- return { total, rolls: dice };
222
+ return {
223
+ total,
224
+ rolls: dice,
225
+ expression: notation,
226
+ rendered: ctx.suppressRender ? '' : `${notation}${renderDice(dice)}`,
227
+ };
213
228
  }
214
229
  function evalDice(node, rng, ctx, env) {
215
230
  const count = evalMetaOperand(node.count, rng, ctx, env);
@@ -222,15 +237,25 @@ function evalDice(node, rng, ctx, env) {
222
237
  throw new EvaluatorError(`Dice sides ${sides} exceeds maximum of ${MAX_DICE_SIDES}`, 'INVALID_DICE_SIDES', 'Dice');
223
238
  }
224
239
  chargeDice(env, count, 'Dice');
225
- const { total, rolls } = rollPool(count, `${count}d${sides}`, () => createDieResult(sides, rng.nextInt(1, sides), ['kept']), ctx);
226
- return { total, part: { type: 'dice', count, sides, rolls, total, ...partSpan(node) } };
240
+ const { total, rolls, expression, rendered } = rollPool(count, `${count}d${sides}`, () => createDieResult(sides, rng.nextInt(1, sides), ['kept']), ctx);
241
+ return {
242
+ total,
243
+ part: { type: 'dice', count, sides, rolls, total, ...partSpan(node) },
244
+ expression,
245
+ rendered,
246
+ };
227
247
  }
228
248
  function evalFateDice(node, rng, ctx, env) {
229
249
  const count = evalMetaOperand(node.count, rng, ctx, env);
230
250
  requireDiceCount(count, 'FateDice');
231
251
  chargeDice(env, count, 'FateDice');
232
- const { total, rolls } = rollPool(count, `${count}dF`, () => createFateDieResult(rng.nextInt(-1, 1), ['kept']), ctx);
233
- return { total, part: { type: 'fateDice', count, rolls, total, ...partSpan(node) } };
252
+ const { total, rolls, expression, rendered } = rollPool(count, `${count}dF`, () => createFateDieResult(rng.nextInt(-1, 1), ['kept']), ctx);
253
+ return {
254
+ total,
255
+ part: { type: 'fateDice', count, rolls, total, ...partSpan(node) },
256
+ expression,
257
+ rendered,
258
+ };
234
259
  }
235
260
  function propagateMetadata(parent, metadata) {
236
261
  if (!metadata)
@@ -251,12 +276,6 @@ function evalBinaryOp(node, rng, ctx, env) {
251
276
  const right = evalNode(node.right, rng, rightCtx, env);
252
277
  mergeContext(ctx, leftCtx);
253
278
  mergeContext(ctx, rightCtx);
254
- const leftExpr = leftCtx.expressionParts.join('');
255
- const rightExpr = rightCtx.expressionParts.join('');
256
- const leftRendered = leftCtx.renderedParts.join('');
257
- const rightRendered = rightCtx.renderedParts.join('');
258
- ctx.expressionParts.push(`${leftExpr} ${node.operator} ${rightExpr}`);
259
- ctx.renderedParts.push(`${leftRendered} ${node.operator} ${rightRendered}`);
260
279
  const total = applyBinaryOperator(node.operator, left.total, right.total);
261
280
  return {
262
281
  total,
@@ -268,6 +287,8 @@ function evalBinaryOp(node, rng, ctx, env) {
268
287
  total,
269
288
  ...partSpan(node),
270
289
  },
290
+ expression: `${left.expression} ${node.operator} ${right.expression}`,
291
+ rendered: `${left.rendered} ${node.operator} ${right.rendered}`,
271
292
  };
272
293
  }
273
294
  function applyBinaryOperator(operator, left, right) {
@@ -300,25 +321,23 @@ function evalUnaryOp(node, rng, ctx, env) {
300
321
  const innerCtx = createContext();
301
322
  const inner = evalNode(node.operand, rng, innerCtx, env);
302
323
  mergeContext(ctx, innerCtx);
303
- const innerExpr = innerCtx.expressionParts.join('');
304
- const innerRendered = innerCtx.renderedParts.join('');
305
- ctx.expressionParts.push(`-${innerExpr}`);
306
- ctx.renderedParts.push(`-${innerRendered}`);
307
324
  const total = -inner.total;
308
325
  return {
309
326
  total,
310
327
  part: { type: 'unaryOp', operator: '-', operand: inner.part, total, ...partSpan(node) },
328
+ expression: `-${inner.expression}`,
329
+ rendered: `-${inner.rendered}`,
311
330
  };
312
331
  }
313
332
  function evalGrouped(node, rng, ctx, env) {
314
333
  const innerCtx = createContext();
315
334
  const inner = evalNode(node.expression, rng, innerCtx, env);
316
335
  mergeContext(ctx, innerCtx);
317
- ctx.expressionParts.push(`(${innerCtx.expressionParts.join('')})`);
318
- ctx.renderedParts.push(`(${innerCtx.renderedParts.join('')})`);
319
336
  return {
320
337
  total: inner.total,
321
338
  part: { type: 'grouped', inner: inner.part, total: inner.total, ...partSpan(node) },
339
+ expression: `(${inner.expression})`,
340
+ rendered: `(${inner.rendered})`,
322
341
  };
323
342
  }
324
343
  function evalGroup(node, rng, ctx, env) {
@@ -330,14 +349,17 @@ function evalGroup(node, rng, ctx, env) {
330
349
  const subCtx = createContext();
331
350
  const sub = evalNode(expr, rng, subCtx, env);
332
351
  mergeContext(ctx, subCtx);
333
- subExprs.push(subCtx.expressionParts.join(''));
334
- subRendered.push(subCtx.renderedParts.join(''));
352
+ subExprs.push(sub.expression);
353
+ subRendered.push(sub.rendered);
335
354
  subParts.push(sub.part);
336
355
  total += sub.total;
337
356
  }
338
- ctx.expressionParts.push(`{${subExprs.join(', ')}}`);
339
- ctx.renderedParts.push(`{${subRendered.join(', ')}}`);
340
- return { total, part: { type: 'group', parts: subParts, total, ...partSpan(node) } };
357
+ return {
358
+ total,
359
+ part: { type: 'group', parts: subParts, total, ...partSpan(node) },
360
+ expression: `{${subExprs.join(', ')}}`,
361
+ rendered: `{${subRendered.join(', ')}}`,
362
+ };
341
363
  }
342
364
  function evalFunctionCall(node, rng, ctx, env) {
343
365
  const argCtxs = [];
@@ -350,10 +372,6 @@ function evalFunctionCall(node, rng, ctx, env) {
350
372
  for (const argCtx of argCtxs) {
351
373
  mergeContext(ctx, argCtx);
352
374
  }
353
- const argExprs = argCtxs.map((c) => c.expressionParts.join(''));
354
- const argRendereds = argCtxs.map((c) => c.renderedParts.join(''));
355
- ctx.expressionParts.push(`${node.name}(${argExprs.join(', ')})`);
356
- ctx.renderedParts.push(`${node.name}(${argRendereds.join(', ')})`);
357
375
  const total = applyFunction(node.name, argResults.map((r) => r.total));
358
376
  return {
359
377
  total,
@@ -364,6 +382,8 @@ function evalFunctionCall(node, rng, ctx, env) {
364
382
  total,
365
383
  ...partSpan(node),
366
384
  },
385
+ expression: `${node.name}(${argResults.map((r) => r.expression).join(', ')})`,
386
+ rendered: `${node.name}(${argResults.map((r) => r.rendered).join(', ')})`,
367
387
  };
368
388
  }
369
389
  function applyFunction(name, values) {
@@ -474,9 +494,9 @@ function formatExplodeCode(variant, threshold, thresholdValue) {
474
494
  return `${marker}${threshold.operator}${thresholdValue}`;
475
495
  }
476
496
  function evalExplode(node, rng, ctx, env) {
477
- const targetCtx = createContext();
497
+ const targetCtx = createDiscardedRenderContext();
478
498
  const target = evalNode(node.target, rng, targetCtx, env);
479
- const targetExpr = targetCtx.expressionParts.join('');
499
+ const targetExpr = target.expression;
480
500
  let thresholdValue;
481
501
  if (node.threshold != null) {
482
502
  thresholdValue = evalMetaOperand(node.threshold.value, rng, ctx, env);
@@ -497,31 +517,35 @@ function evalExplode(node, rng, ctx, env) {
497
517
  return part;
498
518
  };
499
519
  if (targetCtx.rolls.length === 0) {
500
- ctx.expressionParts.push(`${targetExpr}${code}`);
501
- ctx.renderedParts.push(`${targetExpr}${code}`);
502
- return { total: target.total, part: buildPart(target.total, targetCtx.rolls) };
520
+ return {
521
+ total: target.total,
522
+ part: buildPart(target.total, targetCtx.rolls),
523
+ expression: `${targetExpr}${code}`,
524
+ rendered: ctx.suppressRender ? '' : `${targetExpr}${code}`,
525
+ };
503
526
  }
504
527
  const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
505
528
  const expanded = EXPLODE_APPLIERS[node.variant](targetCtx.rolls, shouldExplode, rng, env);
506
529
  appendAll(ctx.rolls, expanded);
507
- ctx.expressionParts.push(`${targetExpr}${code}`);
508
- ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
509
530
  const total = sumKeptDice(expanded, env.hasVersusDc);
510
- return { total, part: buildPart(total, expanded) };
531
+ return {
532
+ total,
533
+ part: buildPart(total, expanded),
534
+ expression: `${targetExpr}${code}`,
535
+ rendered: renderedPool(ctx, `${targetExpr}${code}`, expanded),
536
+ };
511
537
  }
512
538
  function evalReroll(node, rng, ctx, env) {
513
- const targetCtx = createContext();
539
+ const targetCtx = createDiscardedRenderContext();
514
540
  const target = evalNode(node.target, rng, targetCtx, env);
515
541
  const thresholdValue = evalMetaOperand(node.condition.value, rng, ctx, env);
516
542
  const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
517
- const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
543
+ const modifierExpr = joinModifierCode(target.expression, code);
518
544
  const condition = {
519
545
  operator: node.condition.operator,
520
546
  value: thresholdValue,
521
547
  };
522
548
  if (targetCtx.rolls.length === 0) {
523
- ctx.expressionParts.push(modifierExpr);
524
- ctx.renderedParts.push(modifierExpr);
525
549
  const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
526
550
  return {
527
551
  total,
@@ -534,14 +558,14 @@ function evalReroll(node, rng, ctx, env) {
534
558
  total,
535
559
  ...partSpan(node),
536
560
  },
561
+ expression: modifierExpr,
562
+ rendered: ctx.suppressRender ? '' : modifierExpr,
537
563
  };
538
564
  }
539
565
  const pool = node.once
540
566
  ? applyRerollOnce(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env)
541
567
  : applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
542
568
  appendAll(ctx.rolls, pool);
543
- ctx.expressionParts.push(modifierExpr);
544
- ctx.renderedParts.push(`${modifierExpr}${renderDice(pool)}`);
545
569
  const total = sumKeptDice(pool, env.hasVersusDc);
546
570
  return {
547
571
  total,
@@ -554,10 +578,12 @@ function evalReroll(node, rng, ctx, env) {
554
578
  total,
555
579
  ...partSpan(node),
556
580
  },
581
+ expression: modifierExpr,
582
+ rendered: renderedPool(ctx, modifierExpr, pool),
557
583
  };
558
584
  }
559
585
  function evalDieBound(node, rng, ctx, env) {
560
- const targetCtx = createContext();
586
+ const targetCtx = createDiscardedRenderContext();
561
587
  const target = evalNode(node.target, rng, targetCtx, env);
562
588
  const boundValue = evalMetaOperand(node.value, rng, ctx, env);
563
589
  if (!Number.isFinite(boundValue)) {
@@ -566,11 +592,11 @@ function evalDieBound(node, rng, ctx, env) {
566
592
  applyDieBound(targetCtx.rolls, node.bound, boundValue, env.hasVersusDc);
567
593
  appendAll(ctx.rolls, targetCtx.rolls);
568
594
  const code = boundValue < 0 ? `${node.bound}(${boundValue})` : `${node.bound}${boundValue}`;
569
- const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
570
- ctx.expressionParts.push(modifierExpr);
571
- ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
595
+ const modifierExpr = joinModifierCode(target.expression, code);
572
596
  const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
573
597
  return {
598
+ expression: modifierExpr,
599
+ rendered: renderedPool(ctx, modifierExpr, targetCtx.rolls),
574
600
  total,
575
601
  part: {
576
602
  type: 'dieBound',
@@ -583,16 +609,16 @@ function evalDieBound(node, rng, ctx, env) {
583
609
  };
584
610
  }
585
611
  function evalSort(node, rng, ctx, env) {
586
- const targetCtx = createContext();
612
+ const targetCtx = createDiscardedRenderContext();
587
613
  const target = evalNode(node.target, rng, targetCtx, env);
588
614
  const sortedRolls = sortDice(targetCtx.rolls, node.order, env.hasVersusDc);
589
615
  appendAll(ctx.rolls, sortedRolls);
590
616
  propagateMetadata(ctx, targetCtx.versusMetadata);
591
617
  const code = node.order === 'ascending' ? 's' : 'sd';
592
- const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
593
- ctx.expressionParts.push(modifierExpr);
594
- ctx.renderedParts.push(`${modifierExpr}${renderDice(sortedRolls)}`);
618
+ const modifierExpr = joinModifierCode(target.expression, code);
595
619
  return {
620
+ expression: modifierExpr,
621
+ rendered: renderedPool(ctx, modifierExpr, sortedRolls),
596
622
  total: target.total,
597
623
  part: {
598
624
  type: 'sort',
@@ -605,7 +631,7 @@ function evalSort(node, rng, ctx, env) {
605
631
  };
606
632
  }
607
633
  function evalCritThreshold(node, rng, ctx, env) {
608
- const targetCtx = createContext();
634
+ const targetCtx = createDiscardedRenderContext();
609
635
  const target = evalNode(node.target, rng, targetCtx, env);
610
636
  const successResolved = node.successThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
611
637
  const failResolved = node.failThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
@@ -617,10 +643,10 @@ function evalCritThreshold(node, rng, ctx, env) {
617
643
  const modifierExpr = [
618
644
  ...successResolved.map((t) => (t === 'default' ? 'cs' : `cs${t.operator}${t.value}`)),
619
645
  ...failResolved.map((t) => (t === 'default' ? 'cf' : `cf${t.operator}${t.value}`)),
620
- ].reduce(joinModifierCode, targetCtx.expressionParts.join(''));
621
- ctx.expressionParts.push(modifierExpr);
622
- ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
646
+ ].reduce(joinModifierCode, target.expression);
623
647
  return {
648
+ expression: modifierExpr,
649
+ rendered: renderedPool(ctx, modifierExpr, targetCtx.rolls),
624
650
  total: target.total,
625
651
  part: {
626
652
  type: 'critThreshold',
@@ -646,15 +672,13 @@ function evalKeepDrop(node, rng, ctx, env) {
646
672
  if (baseTarget.type === 'Group' && baseTarget.expressions.length >= 2) {
647
673
  return evalGroupKeepDrop(node, baseTarget, specs, rng, ctx, env);
648
674
  }
649
- const targetCtx = createContext();
675
+ const targetCtx = createDiscardedRenderContext();
650
676
  const target = evalNode(baseTarget, rng, targetCtx, env);
651
677
  const mergedDice = mergeDropSets(targetCtx.rolls, specs, env.hasVersusDc);
652
678
  appendAll(ctx.rolls, mergedDice);
653
679
  const total = sumKeptDice(mergedDice, env.hasVersusDc);
654
- const targetExpr = targetCtx.expressionParts.join('');
680
+ const targetExpr = target.expression;
655
681
  const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
656
- ctx.expressionParts.push(joinModifierCode(targetExpr, keepDropCodes));
657
- ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
658
682
  return {
659
683
  total,
660
684
  part: {
@@ -664,6 +688,8 @@ function evalKeepDrop(node, rng, ctx, env) {
664
688
  total,
665
689
  ...partSpan(node),
666
690
  },
691
+ expression: joinModifierCode(targetExpr, keepDropCodes),
692
+ rendered: renderedPool(ctx, targetExpr, mergedDice),
667
693
  };
668
694
  }
669
695
  function stripInnerMarkers(rendered) {
@@ -682,8 +708,8 @@ function evalGroupKeepDrop(node, group, specs, rng, ctx, env) {
682
708
  subtotal: sub.total,
683
709
  part: sub.part,
684
710
  rolls: subCtx.rolls,
685
- expr: subCtx.expressionParts.join(''),
686
- rendered: subCtx.renderedParts.join(''),
711
+ expr: sub.expression,
712
+ rendered: sub.rendered,
687
713
  versusMetadata: subCtx.versusMetadata,
688
714
  scoredSuccesses: env.subtotalSuccesses - successTally,
689
715
  scoredFailures: env.subtotalFailures - failureTally,
@@ -725,8 +751,6 @@ function evalGroupKeepDrop(node, group, specs, rng, ctx, env) {
725
751
  }
726
752
  const subExprStrs = subRolls.map((s) => s.expr);
727
753
  const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
728
- ctx.expressionParts.push(`{${subExprStrs.join(', ')}}${keepDropCodes}`);
729
- ctx.renderedParts.push(`{${outerRendered.join(', ')}}`);
730
754
  const groupPart = {
731
755
  type: 'group',
732
756
  parts: subRolls.map((s) => s.part),
@@ -743,6 +767,8 @@ function evalGroupKeepDrop(node, group, specs, rng, ctx, env) {
743
767
  total,
744
768
  ...partSpan(node),
745
769
  },
770
+ expression: `{${subExprStrs.join(', ')}}${keepDropCodes}`,
771
+ rendered: `{${outerRendered.join(', ')}}`,
746
772
  };
747
773
  }
748
774
  function resolveThreshold(value, rng, ctx, env, role) {
@@ -755,12 +781,28 @@ function resolveThreshold(value, rng, ctx, env, role) {
755
781
  function formatFailCode(operator, value) {
756
782
  return operator === '=' ? `f${value}` : `f${operator}${value}`;
757
783
  }
784
+ function clearTallyFlags(rolls) {
785
+ for (const die of rolls) {
786
+ die.modifiers = stripFlags(die.modifiers, TALLY_FLAGS);
787
+ }
788
+ }
789
+ function countablePool(target, targetCtx, bySubtotal) {
790
+ if (!bySubtotal)
791
+ return targetCtx.rolls;
792
+ return target.part.parts.map((sub) => ({
793
+ sides: 0,
794
+ result: sub.total,
795
+ modifiers: [],
796
+ critical: false,
797
+ fumble: false,
798
+ }));
799
+ }
758
800
  function evalSuccessCount(node, rng, ctx, env) {
759
801
  const successTallyBefore = env.subtotalSuccesses;
760
802
  const failureTallyBefore = env.subtotalFailures;
761
803
  const targetCtx = createContext();
762
804
  const target = evalNode(node.target, rng, targetCtx, env);
763
- const targetExpr = targetCtx.expressionParts.join('');
805
+ const targetExpr = target.expression;
764
806
  const poolAlreadyCounted = env.hasSuccessCount;
765
807
  env.hasSuccessCount = true;
766
808
  const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
@@ -788,24 +830,16 @@ function evalSuccessCount(node, rng, ctx, env) {
788
830
  return part;
789
831
  };
790
832
  const bySubtotal = node.target.type === 'Group' && node.target.expressions.length >= 2;
791
- const pool = bySubtotal
792
- ? target.part.parts.map((sub) => ({
793
- sides: 0,
794
- result: sub.total,
795
- modifiers: [],
796
- critical: false,
797
- fumble: false,
798
- }))
799
- : targetCtx.rolls;
800
- if (bySubtotal && poolAlreadyCounted) {
801
- for (const die of targetCtx.rolls) {
802
- die.modifiers = stripFlags(die.modifiers, TALLY_FLAGS);
803
- }
804
- }
833
+ const pool = countablePool(target, targetCtx, bySubtotal);
834
+ if (bySubtotal && poolAlreadyCounted)
835
+ clearTallyFlags(targetCtx.rolls);
805
836
  if (pool.length === 0) {
806
- ctx.expressionParts.push(`${targetExpr}${code}`);
807
- ctx.renderedParts.push(`${targetExpr}${code}`);
808
- return { total: 0, part: buildPart(0, 0, 0) };
837
+ return {
838
+ total: 0,
839
+ part: buildPart(0, 0, 0),
840
+ expression: `${targetExpr}${code}`,
841
+ rendered: `${targetExpr}${code}`,
842
+ };
809
843
  }
810
844
  const result = countSuccesses(pool, { operator: node.threshold.operator, value: thresholdValue }, failValue != null && node.failThreshold != null
811
845
  ? { operator: node.failThreshold.operator, value: failValue }
@@ -815,13 +849,13 @@ function evalSuccessCount(node, rng, ctx, env) {
815
849
  env.subtotalFailures = failureTallyBefore + result.failures;
816
850
  }
817
851
  appendAll(ctx.rolls, targetCtx.rolls);
818
- ctx.expressionParts.push(`${targetExpr}${code}`);
819
- ctx.renderedParts.push(bySubtotal
820
- ? `${stripTallyMarkers(targetCtx.renderedParts.join(''))}${code}`
821
- : `${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
822
852
  return {
823
853
  total: result.total,
824
854
  part: buildPart(result.total, result.successes, result.failures),
855
+ expression: `${targetExpr}${code}`,
856
+ rendered: bySubtotal
857
+ ? `${stripTallyMarkers(target.rendered)}${code}`
858
+ : renderedPool(ctx, `${targetExpr}${code}`, targetCtx.rolls),
825
859
  };
826
860
  }
827
861
  function extractNatural(rolls, env) {
@@ -879,12 +913,6 @@ function evalVersus(node, rng, ctx, env) {
879
913
  if (dcCtx.rolls.length > 0)
880
914
  env.hasVersusDc = true;
881
915
  appendAll(ctx.rolls, dcCtx.rolls);
882
- const rollExpr = rollCtx.expressionParts.join('');
883
- const dcExpr = dcCtx.expressionParts.join('');
884
- const rollRendered = rollCtx.renderedParts.join('');
885
- const dcRendered = dcCtx.renderedParts.join('');
886
- ctx.expressionParts.push(`${rollExpr} vs ${dcExpr}`);
887
- ctx.renderedParts.push(`${rollRendered} vs ${dcRendered}`);
888
916
  ctx.versusMetadata = { degree, natural, dcTotal: dcResult.total };
889
917
  return {
890
918
  total: rollResult.total,
@@ -896,6 +924,8 @@ function evalVersus(node, rng, ctx, env) {
896
924
  total: rollResult.total,
897
925
  ...partSpan(node),
898
926
  },
927
+ expression: `${rollResult.expression} vs ${dcResult.expression}`,
928
+ rendered: `${rollResult.rendered} vs ${dcResult.rendered}`,
899
929
  };
900
930
  }
901
931
  finally {
@@ -924,13 +954,12 @@ export function evaluate(ast, rng, options = {}) {
924
954
  onMissingVariable,
925
955
  };
926
956
  const ctx = createContext();
927
- const { total, part } = evalNode(ast, rng, ctx, env);
957
+ const { total, part, expression, rendered: renderedBody } = evalNode(ast, rng, ctx, env);
928
958
  if (!Number.isFinite(total)) {
929
959
  throw new EvaluatorError(`Result is not a finite number: ${total}`, 'NON_FINITE_RESULT', ast.type);
930
960
  }
931
- const expression = ctx.expressionParts.join('');
932
961
  const trailing = ctx.versusMetadata ? degreeLabel(ctx.versusMetadata.degree) : String(total);
933
- const rendered = `${ctx.renderedParts.join('')} = ${trailing}`;
962
+ const rendered = `${renderedBody} = ${trailing}`;
934
963
  const versus = ctx.versusMetadata;
935
964
  return {
936
965
  total,