roll-parser 3.3.0 → 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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.3.1] - 2026-08-20
11
+
12
+ ### Changed
13
+
14
+ - performance: the evaluator does less work per node and per die. `EvalContext`'s `expression` and `rendered` fragment arrays are single strings carried on `EvalResult`, so no branch can omit one; `rewriteFlags` is one loop instead of a closure, a filter array and a spread; a modifier's target context no longer renders the pool it discards, with every pool render routed through `renderedPool`; and `renderGroup` looks kept indices up in a `Set` rather than an array. No notation evaluates differently and no public type changed ([#336](https://github.com/edloidas/roll-parser/issues/336))
15
+
16
+ ### Documentation
17
+
18
+ - `package.json` metadata is tuned for npm search: the `description` leads with queryable nouns and names the systems, `roller`, `die`, `notation`, `pathfinder` and the space-separated `dice notation` join the keywords, and seven keywords that ranked outside the top 100 for their own chip are gone. `README.md` gains a `min+brotli ≤13.5 kB` badge linking to the size breakdown ([#333](https://github.com/edloidas/roll-parser/issues/333))
19
+
10
20
  ## [3.3.0] - 2026-08-17
11
21
 
12
22
  Error-message work throughout: nine parse and lex errors now name the rule they
@@ -295,7 +305,8 @@ Dice mechanics (Stage 2):
295
305
  - Dice count safety limit via `maxDice` option (default 10,000), enforced across the whole expression to prevent DoS via additive groups like `5000d6+5000d6` ([#19](https://github.com/edloidas/roll-parser/issues/19))
296
306
  - Parser and evaluator correctness: duplicate `kept` modifier entries, implicit modifier count defaulting to 1 (`4d6kh` → `4d6kh1`), `critical` flag suppression when `sides === 1`, negative `--seed` CLI values ([#21](https://github.com/edloidas/roll-parser/issues/21))
297
307
 
298
- [Unreleased]: https://github.com/edloidas/roll-parser/compare/v3.3.0...HEAD
308
+ [Unreleased]: https://github.com/edloidas/roll-parser/compare/v3.3.1...HEAD
309
+ [3.3.1]: https://github.com/edloidas/roll-parser/releases/tag/v3.3.1
299
310
  [3.3.0]: https://github.com/edloidas/roll-parser/releases/tag/v3.3.0
300
311
  [3.2.2]: https://github.com/edloidas/roll-parser/releases/tag/v3.2.2
301
312
  [3.2.1]: https://github.com/edloidas/roll-parser/releases/tag/v3.2.1
package/README.md CHANGED
@@ -13,6 +13,7 @@ Dice notation for tabletop RPGs, rolled into structured results.
13
13
  <a href="https://github.com/edloidas/roll-parser/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/edloidas/roll-parser/ci.yml?branch=master&label=CI" alt="CI status"></a>
14
14
  <a href="https://github.com/edloidas/roll-parser/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/roll-parser?color=blue" alt="MIT license"></a>
15
15
  <a href="https://nodejs.org/"><img src="https://img.shields.io/node/v/roll-parser?color=5fa04e" alt="Node.js >= 22.12"></a>
16
+ <a href="https://github.com/edloidas/roll-parser#why-roll-parser"><img src="https://img.shields.io/badge/min%2Bbrotli-%E2%89%A413.5%20kB-8b5cf6" alt="min+brotli ≤13.5 kB"></a>
16
17
  </p>
17
18
 
18
19
  <p align="center">
@@ -51,11 +52,11 @@ roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) }).total; // 14, every run
51
52
 
52
53
  ## Why roll-parser
53
54
 
54
- - **Complete notation.** Keep/drop, three flavours of exploding dice, rerolls,
55
- min/max clamps, success pools, crit thresholds, sorting, grouped rolls, PF2e
56
- degrees of success, math functions, variables, computed dice — enough for D&D 5e,
57
- Pathfinder, World of Darkness, Shadowrun, Fate, Savage Worlds, and Call of
58
- Cthulhu without escape hatches. Spelled the way
55
+ - **Complete notation.** A dice roller that takes keep/drop, three flavours of
56
+ exploding dice, rerolls, min/max clamps, success pools, crit thresholds,
57
+ sorting, grouped rolls, PF2e degrees of success, math functions, variables,
58
+ computed dice — enough for D&D 5e, Pathfinder, World of Darkness, Shadowrun,
59
+ Fate, Savage Worlds, and Call of Cthulhu without escape hatches. Spelled the way
59
60
  [Roll20](https://help.roll20.net/hc/en-us/articles/360037773133-Dice-Reference)
60
61
  spells it wherever the two overlap, so the notation your table already uses
61
62
  keeps working.
@@ -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;AAmoDF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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) {
@@ -760,7 +786,7 @@ function evalSuccessCount(node, rng, ctx, env) {
760
786
  const failureTallyBefore = env.subtotalFailures;
761
787
  const targetCtx = createContext();
762
788
  const target = evalNode(node.target, rng, targetCtx, env);
763
- const targetExpr = targetCtx.expressionParts.join('');
789
+ const targetExpr = target.expression;
764
790
  const poolAlreadyCounted = env.hasSuccessCount;
765
791
  env.hasSuccessCount = true;
766
792
  const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
@@ -803,9 +829,12 @@ function evalSuccessCount(node, rng, ctx, env) {
803
829
  }
804
830
  }
805
831
  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) };
832
+ return {
833
+ total: 0,
834
+ part: buildPart(0, 0, 0),
835
+ expression: `${targetExpr}${code}`,
836
+ rendered: `${targetExpr}${code}`,
837
+ };
809
838
  }
810
839
  const result = countSuccesses(pool, { operator: node.threshold.operator, value: thresholdValue }, failValue != null && node.failThreshold != null
811
840
  ? { operator: node.failThreshold.operator, value: failValue }
@@ -815,13 +844,13 @@ function evalSuccessCount(node, rng, ctx, env) {
815
844
  env.subtotalFailures = failureTallyBefore + result.failures;
816
845
  }
817
846
  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
847
  return {
823
848
  total: result.total,
824
849
  part: buildPart(result.total, result.successes, result.failures),
850
+ expression: `${targetExpr}${code}`,
851
+ rendered: bySubtotal
852
+ ? `${stripTallyMarkers(target.rendered)}${code}`
853
+ : renderedPool(ctx, `${targetExpr}${code}`, targetCtx.rolls),
825
854
  };
826
855
  }
827
856
  function extractNatural(rolls, env) {
@@ -879,12 +908,6 @@ function evalVersus(node, rng, ctx, env) {
879
908
  if (dcCtx.rolls.length > 0)
880
909
  env.hasVersusDc = true;
881
910
  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
911
  ctx.versusMetadata = { degree, natural, dcTotal: dcResult.total };
889
912
  return {
890
913
  total: rollResult.total,
@@ -896,6 +919,8 @@ function evalVersus(node, rng, ctx, env) {
896
919
  total: rollResult.total,
897
920
  ...partSpan(node),
898
921
  },
922
+ expression: `${rollResult.expression} vs ${dcResult.expression}`,
923
+ rendered: `${rollResult.rendered} vs ${dcResult.rendered}`,
899
924
  };
900
925
  }
901
926
  finally {
@@ -924,13 +949,12 @@ export function evaluate(ast, rng, options = {}) {
924
949
  onMissingVariable,
925
950
  };
926
951
  const ctx = createContext();
927
- const { total, part } = evalNode(ast, rng, ctx, env);
952
+ const { total, part, expression, rendered: renderedBody } = evalNode(ast, rng, ctx, env);
928
953
  if (!Number.isFinite(total)) {
929
954
  throw new EvaluatorError(`Result is not a finite number: ${total}`, 'NON_FINITE_RESULT', ast.type);
930
955
  }
931
- const expression = ctx.expressionParts.join('');
932
956
  const trailing = ctx.versusMetadata ? degreeLabel(ctx.versusMetadata.degree) : String(total);
933
- const rendered = `${ctx.renderedParts.join('')} = ${trailing}`;
957
+ const rendered = `${renderedBody} = ${trailing}`;
934
958
  const versus = ctx.versusMetadata;
935
959
  return {
936
960
  total,