roll-parser 3.1.0 → 3.2.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/MIGRATION.md +225 -8
  3. package/README.md +188 -60
  4. package/dist/evaluator/env.d.ts +27 -1
  5. package/dist/evaluator/env.d.ts.map +1 -1
  6. package/dist/evaluator/env.js.map +1 -1
  7. package/dist/evaluator/evaluator.d.ts.map +1 -1
  8. package/dist/evaluator/evaluator.js +89 -43
  9. package/dist/evaluator/evaluator.js.map +1 -1
  10. package/dist/evaluator/modifiers/crit-threshold.d.ts +13 -28
  11. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  12. package/dist/evaluator/modifiers/crit-threshold.js +6 -12
  13. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -1
  14. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  15. package/dist/evaluator/modifiers/explode.js +7 -1
  16. package/dist/evaluator/modifiers/explode.js.map +1 -1
  17. package/dist/evaluator/modifiers/flags.d.ts +9 -2
  18. package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
  19. package/dist/evaluator/modifiers/flags.js +4 -11
  20. package/dist/evaluator/modifiers/flags.js.map +1 -1
  21. package/dist/evaluator/modifiers/keep-drop.d.ts +1 -3
  22. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  23. package/dist/evaluator/modifiers/keep-drop.js.map +1 -1
  24. package/dist/evaluator/modifiers/success-count.d.ts +6 -5
  25. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  26. package/dist/evaluator/modifiers/success-count.js +9 -6
  27. package/dist/evaluator/modifiers/success-count.js.map +1 -1
  28. package/dist/notation.d.ts +13 -0
  29. package/dist/notation.d.ts.map +1 -0
  30. package/dist/notation.js +8 -0
  31. package/dist/notation.js.map +1 -0
  32. package/dist/parser/guards.d.ts +38 -0
  33. package/dist/parser/guards.d.ts.map +1 -1
  34. package/dist/parser/guards.js +51 -0
  35. package/dist/parser/guards.js.map +1 -1
  36. package/dist/parser/parser.d.ts.map +1 -1
  37. package/dist/parser/parser.js +26 -2
  38. package/dist/parser/parser.js.map +1 -1
  39. package/dist/render.d.ts.map +1 -1
  40. package/dist/render.js +18 -13
  41. package/dist/render.js.map +1 -1
  42. package/dist/types.d.ts +15 -3
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/package.json +5 -5
  47. package/src/evaluator/env.ts +27 -1
  48. package/src/evaluator/evaluator.ts +179 -56
  49. package/src/evaluator/modifiers/crit-threshold.ts +29 -48
  50. package/src/evaluator/modifiers/explode.ts +13 -4
  51. package/src/evaluator/modifiers/flags.ts +13 -13
  52. package/src/evaluator/modifiers/keep-drop.ts +1 -3
  53. package/src/evaluator/modifiers/sort.ts +1 -1
  54. package/src/evaluator/modifiers/success-count.ts +19 -12
  55. package/src/notation.ts +24 -0
  56. package/src/parser/guards.ts +92 -1
  57. package/src/parser/parser.ts +56 -1
  58. package/src/render.ts +24 -22
  59. package/src/types.ts +15 -3
  60. package/src/version.ts +1 -1
@@ -134,12 +134,10 @@ function markSingleExtreme(
134
134
  }
135
135
 
136
136
  /**
137
- * Calculates total from dice, excluding dropped dice.
137
+ * Sums the dice a keep/drop pass left standing.
138
138
  *
139
- * @param dice - Array of die results
140
139
  * @param hasVersusDc - Shared env flag; skips the DC exclusion when no `vs` has
141
140
  * tagged anything
142
- * @returns Sum of non-dropped dice
143
141
  */
144
142
  export function sumKeptDice(dice: DieResult[], hasVersusDc: boolean): number {
145
143
  let total = 0;
@@ -37,7 +37,7 @@ export function sortDice(
37
37
  : (a: DieResult, b: DieResult) => b.result - a.result;
38
38
 
39
39
  // Scan before allocating: the `filter` this replaced built a throwaway array
40
- // on every sort to serve a case only a `vs` can produce (#281).
40
+ // on every sort to serve a case only a `vs` can produce.
41
41
  if (!hasVersusDc || !dice.some(isVersusDc)) return [...dice].sort(cmp);
42
42
 
43
43
  // Sort only the pool members, then lay them back into the slots they came
@@ -10,17 +10,18 @@
10
10
  * excluded from counting and are never tagged.
11
11
  *
12
12
  * Mutates the input pool in place to add `'success'` / `'failure'` modifier
13
- * flags — mirrors the mutation pattern of explode and reroll modifiers. Each
14
- * tag is written at most once per die: a group counted after its members
15
- * (`{4d6>=5}>=1`) runs this pass twice over the same dice, and the parse-time
16
- * reject that blocks a direct `4d6>=5>=4` does not reach through a group.
13
+ * flags — mirrors the mutation pattern of explode and reroll modifiers. The
14
+ * tags are rebuilt, not appended: a group counted after its members
15
+ * (`{4d6>=5}<=2f5`) runs this pass twice over the same dice, and the outermost
16
+ * pass is the one whose arithmetic `RollResult.successes` / `failures` and the
17
+ * rendered markers report.
17
18
  *
18
19
  * @module evaluator/modifiers/success-count
19
20
  */
20
21
 
21
22
  import type { DieResult, ResolvedComparePoint } from '../../types.js';
22
23
  import { matchesCondition } from './compare.js';
23
- import { isVersusDc } from './flags.js';
24
+ import { isVersusDc, stripFlags, TALLY_FLAGS } from './flags.js';
24
25
 
25
26
  export type SuccessCountResult = {
26
27
  total: number;
@@ -33,27 +34,33 @@ export function countSuccesses(
33
34
  threshold: ResolvedComparePoint,
34
35
  failThreshold: ResolvedComparePoint | undefined,
35
36
  hasVersusDc: boolean,
37
+ poolAlreadyCounted: boolean,
36
38
  ): SuccessCountResult {
37
39
  let successes = 0;
38
40
  let failures = 0;
39
41
 
40
42
  // ! Excluded up front, never inside the loop below. Even short-circuited on a
41
43
  // ! false flag, a `hasVersusDc && isVersusDc(die)` guard in this loop costs
42
- // ! ~9% on `10d10>=6f1` — measured, not assumed (#281). Filtering keeps the
44
+ // ! ~9% on `10d10>=6f1` — measured, not assumed. Filtering keeps the
43
45
  // ! hot body identical to the pre-exclusion one and pays an allocation only
44
46
  // ! on the `vs` path. The dice are the same objects either way, so the
45
47
  // ! `'success'` / `'failure'` tags written below still land on the pool.
46
48
  const pool = hasVersusDc ? dice.filter((die) => !isVersusDc(die)) : dice;
47
49
 
48
- // ! The guards below only stop the *same* tag being written twice. A nested
49
- // ! count with a different threshold still appends to the first pass's tags,
50
- // ! so a die can end up both `'success'` and `'failure'`, and the returned
51
- // ! counts then disagree with the tags in `rolls`.
50
+ // ! Dropped dice are stripped too, not just the ones re-tagged below. This
51
+ // ! pass skips them, so an inner count's tag would otherwise survive into the
52
+ // ! top-level successes/failures scan and break `total === successes - failures`.
53
+ if (poolAlreadyCounted) {
54
+ for (const die of pool) {
55
+ die.modifiers = stripFlags(die.modifiers, TALLY_FLAGS);
56
+ }
57
+ }
58
+
52
59
  for (const die of pool) {
53
60
  if (die.modifiers.includes('dropped')) continue;
54
61
 
55
62
  if (matchesCondition(die.result, threshold.operator, threshold.value)) {
56
- if (!die.modifiers.includes('success')) die.modifiers.push('success');
63
+ die.modifiers.push('success');
57
64
  successes += 1;
58
65
  continue;
59
66
  }
@@ -62,7 +69,7 @@ export function countSuccesses(
62
69
  failThreshold != null &&
63
70
  matchesCondition(die.result, failThreshold.operator, failThreshold.value)
64
71
  ) {
65
- if (!die.modifiers.includes('failure')) die.modifiers.push('failure');
72
+ die.modifiers.push('failure');
66
73
  failures += 1;
67
74
  }
68
75
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Notation serialization shared by the two breakdown builders — the
3
+ * evaluator's `expression`/`rendered` strings and `render.ts`.
4
+ *
5
+ * @module notation
6
+ */
7
+
8
+ // ! `cs`, `cf`, `s`, and `sd` are the only codes ending in a letter the lexer
9
+ // ! scans as an identifier — maximal munch then swallows what follows, so
10
+ // ! `cs` + `cf` re-lexes as `cscf`. `4dF` and `!p` are their own tokens.
11
+ const BARE_MODIFIER_END = /(?:cs|cf|sd|s)$/;
12
+
13
+ const STARTS_WITH_LETTER = /^[A-Za-z]/;
14
+
15
+ /**
16
+ * Appends a modifier code to the expression built so far, separating the two
17
+ * with a space when concatenating them would re-lex as a single identifier.
18
+ * The space is the same separator the input used to make them parse.
19
+ */
20
+ export function joinModifierCode(expression: string, code: string): string {
21
+ return BARE_MODIFIER_END.test(expression) && STARTS_WITH_LETTER.test(code)
22
+ ? `${expression} ${code}`
23
+ : `${expression}${code}`;
24
+ }
@@ -61,7 +61,7 @@ export function unwrapAllTransparent(node: ASTNode): ASTNode {
61
61
  * Returns `true` when `node` or any descendant satisfies `isHit`. Recurses
62
62
  * directly through the entire node vocabulary: arithmetic operands,
63
63
  * modifier-chain targets, `Versus` sides, function arguments, and group
64
- * sub-expressions. This is the shared driver behind the four deep walkers
64
+ * sub-expressions. This is the shared driver behind the deep walkers
65
65
  * below. The shallow walkers (`containsDicePool`, `containsFatePool`) stay
66
66
  * hand-written because their rejection semantics deliberately stop at
67
67
  * arithmetic boundaries.
@@ -112,6 +112,9 @@ const isDicePoolHit = (current: ASTNode): boolean =>
112
112
 
113
113
  const isFateDiceHit = (current: ASTNode): boolean => current.type === 'FateDice';
114
114
 
115
+ const isDiceHit = (current: ASTNode): boolean =>
116
+ current.type === 'Dice' || current.type === 'FateDice';
117
+
115
118
  const isMultiSubGroupHit = (current: ASTNode): boolean =>
116
119
  current.type === 'Group' && current.expressions.length >= 2;
117
120
 
@@ -168,6 +171,94 @@ export function deepContainsDicePool(node: ASTNode): boolean {
168
171
  return someDescendant(node, isDicePoolHit);
169
172
  }
170
173
 
174
+ /**
175
+ * Returns `true` when this node's total provably equals the sum of its kept
176
+ * dice faces: a pool, a chain of pool modifiers over one, or an addition of
177
+ * those. A multi-sub-roll `Group` qualifies when every sub-roll does, since its
178
+ * total is their sum and `evalGroupKeepDrop` re-flags dropped sub-rolls' dice.
179
+ *
180
+ * Guards the single-sub-roll `Group` keep/drop target, whose flat-pool path
181
+ * totals `sumKeptDice`. Anything else in there — a literal, a subtracted or
182
+ * scaled dice term, a function call, a success count — contributes to the
183
+ * group's own total but not to that face sum, so `{2d6+3}kh2` would lose the
184
+ * `+3` and `{2d6-1d4}kh3` would flip the `1d4` from `-3` to `+3` while dropping
185
+ * nothing.
186
+ */
187
+ export function sumsToKeptFaces(node: ASTNode): boolean {
188
+ switch (node.type) {
189
+ case 'Dice':
190
+ case 'FateDice':
191
+ return true;
192
+ case 'KeepDrop':
193
+ case 'Explode':
194
+ case 'Reroll':
195
+ case 'DieBound':
196
+ case 'Sort':
197
+ case 'CritThreshold':
198
+ return sumsToKeptFaces(node.target);
199
+ case 'Grouped':
200
+ return sumsToKeptFaces(node.expression);
201
+ case 'Group':
202
+ return node.expressions.every(sumsToKeptFaces);
203
+ case 'BinaryOp':
204
+ return node.operator === '+' && sumsToKeptFaces(node.left) && sumsToKeptFaces(node.right);
205
+ default:
206
+ return false;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Returns `true` when comparing this node's dice one face at a time is faithful
212
+ * to what it totals. Shaped like `sumsToKeptFaces` but answering a different
213
+ * question, so the two differ on three node types:
214
+ *
215
+ * - `SuccessCount` passes — a nested count re-scores the very same faces.
216
+ * - `Versus` looks only at its roll side — no pool pass ever counts a DC.
217
+ * - a multi-sub-roll `Group` fails everywhere, because its units are subtotals.
218
+ * `sumsToKeptFaces` accepts one, a sum of subtotals still being a face sum.
219
+ *
220
+ * Guards the flat success-count path, whose units are individual dice. The
221
+ * subtotal path takes a direct multi-sub-roll `Group` before this is consulted.
222
+ */
223
+ export function countsPerDie(node: ASTNode): boolean {
224
+ switch (node.type) {
225
+ case 'Dice':
226
+ case 'FateDice':
227
+ return true;
228
+ case 'KeepDrop':
229
+ case 'Explode':
230
+ case 'Reroll':
231
+ case 'DieBound':
232
+ case 'Sort':
233
+ case 'CritThreshold':
234
+ case 'SuccessCount':
235
+ return countsPerDie(node.target);
236
+ case 'Versus':
237
+ return countsPerDie(node.roll);
238
+ case 'Grouped':
239
+ return countsPerDie(node.expression);
240
+ case 'Group':
241
+ return node.expressions.length === 1 && node.expressions.every(countsPerDie);
242
+ case 'BinaryOp':
243
+ return node.operator === '+' && countsPerDie(node.left) && countsPerDie(node.right);
244
+ default:
245
+ return false;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Deep-walks a node to find any descendant `Dice` or `FateDice`. Unlike
251
+ * `deepContainsDicePool`, a multi-sub-roll `Group` is not a hit in its own
252
+ * right — only real dice are.
253
+ *
254
+ * Used by the success-count parser guard, which tallies individual dice rather
255
+ * than subtotals: a literal-only group satisfies `containsDicePool` yet rolls
256
+ * nothing to tally.
257
+ */
258
+ export function containsDice(node: ASTNode): boolean {
259
+ return someDescendant(node, isDiceHit);
260
+ }
261
+
171
262
  /**
172
263
  * Returns `true` if the pool this node resolves to is (or wraps) a `FateDice`
173
264
  * pool. Walks through chained pool modifiers (`KeepDrop` / `Explode` /
@@ -32,11 +32,14 @@ import type {
32
32
  } from './ast.js';
33
33
  import { isCritThreshold, isSuccessCount } from './ast.js';
34
34
  import {
35
+ containsDice,
35
36
  containsDicePool,
36
37
  containsFatePool,
37
38
  containsMultiSubGroup,
38
39
  containsVersus,
40
+ countsPerDie,
39
41
  deepContainsDicePool,
42
+ sumsToKeptFaces,
40
43
  unwrapAllTransparent,
41
44
  unwrapGrouped,
42
45
  } from './guards.js';
@@ -767,6 +770,39 @@ export class Parser {
767
770
  );
768
771
  }
769
772
 
773
+ // ! A single-sub-roll Group is the flat-pool escape hatch, so `containsDicePool`
774
+ // ! deep-walks the arithmetic that `(1d6+5)kh1` rejects outright — but the flat
775
+ // ! path totals `sumKeptDice`, faces only. `{2d6+3}kh2` and `{2d6-1d4}kh3` are
776
+ // ! exactly the drop that reject exists to prevent.
777
+ const base = unwrapAllTransparent(target);
778
+ if (base.type === 'Group' && base.expressions.length === 1) {
779
+ const inner = base.expressions[0];
780
+ if (inner != null) {
781
+ // Single-sub Groups hide a count from the shallow reject at the top of this
782
+ // method, so peel them to any depth and `{4d6>=5}kh1`, `{{4d6>=5}}kh1`, and
783
+ // `(4d6>=5)kh1` all report one code. Peeling only picks which error the
784
+ // caller sees — `sumsToKeptFaces` below refuses a count either way.
785
+ let innermost = inner;
786
+ while (true) {
787
+ const peeled = unwrapGrouped(innermost);
788
+ if (peeled.type !== 'Group' || peeled.expressions.length !== 1) break;
789
+ const next = peeled.expressions[0];
790
+ if (next == null) break;
791
+ innermost = next;
792
+ }
793
+ this.rejectSuccessCountTarget(innermost, token);
794
+
795
+ if (!sumsToKeptFaces(inner)) {
796
+ throw new ParseError(
797
+ `Keep/drop on a single-sub-roll group requires added dice terms only`,
798
+ 'INVALID_KEEP_DROP_TARGET',
799
+ token.position,
800
+ token,
801
+ );
802
+ }
803
+ }
804
+ }
805
+
770
806
  const kind =
771
807
  token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
772
808
 
@@ -1087,7 +1123,12 @@ export class Parser {
1087
1123
  // Success counting reads a raw pool, so arithmetic or composition wrappers
1088
1124
  // (`1>=3`, `(1+2)>=3`, `(1d6*2)>=10`, `(1d20 vs 15)>=1`) would be silently
1089
1125
  // ignored.
1090
- if (!containsDicePool(target)) {
1126
+ //
1127
+ // ! Both checks are load-bearing. `containsDicePool` rejects those wrappers
1128
+ // ! despite the dice; `containsDice` rejects a group holding none
1129
+ // ! (`{3, 5, 7}>=4`), which the multi-sub-roll rule accepts — that rule is
1130
+ // ! written for keep/drop, whose units are subtotals, not dice.
1131
+ if (!containsDicePool(target) || !containsDice(target)) {
1091
1132
  throw new ParseError(
1092
1133
  `Success counting requires a dice pool target`,
1093
1134
  'INVALID_SUCCESS_COUNT_TARGET',
@@ -1096,6 +1137,20 @@ export class Parser {
1096
1137
  );
1097
1138
  }
1098
1139
 
1140
+ // ! A multi-sub-roll group is counted by subtotal, and only `evalSuccessCount`
1141
+ // ! knows how — it needs the group as its direct target. Reached any other way
1142
+ // ! (`{{2d6, 2d8}}>=4`, `({2d6, 2d8})>=4`, `{2d6, 2d8}kh1>=4`) the subtotals are
1143
+ // ! gone by the time the count runs, leaving loose faces to compare.
1144
+ const isSubtotalGroup = target.type === 'Group' && target.expressions.length >= 2;
1145
+ if (!isSubtotalGroup && !countsPerDie(target)) {
1146
+ throw new ParseError(
1147
+ `Success counting requires a target whose dice can be counted one face at a time`,
1148
+ 'INVALID_SUCCESS_COUNT_TARGET',
1149
+ token.position,
1150
+ token,
1151
+ );
1152
+ }
1153
+
1099
1154
  const operator = this.getCompareOp(token);
1100
1155
  // Threshold binds at `BP.DICE_LEFT` — see `parseComparePoint` TSDoc.
1101
1156
  const value = this.parseExpression(BP.DICE_LEFT);
package/src/render.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * @module render
10
10
  */
11
11
 
12
+ import { joinModifierCode } from './notation.js';
12
13
  import type {
13
14
  DieResult,
14
15
  KeepDropSpec,
@@ -122,7 +123,6 @@ function failCode(point: ResolvedComparePoint): string {
122
123
  function expr(part: RollPart): string {
123
124
  switch (part.type) {
124
125
  case 'literal':
125
- return String(part.value);
126
126
  case 'variable':
127
127
  return String(part.value);
128
128
  case 'dice':
@@ -140,17 +140,17 @@ function expr(part: RollPart): string {
140
140
  case 'functionCall':
141
141
  return `${part.name}(${part.args.map(expr).join(', ')})`;
142
142
  case 'keepDrop':
143
- return `${expr(part.target)}${part.specs.map(keepDropCode).join('')}`;
143
+ return joinModifierCode(expr(part.target), part.specs.map(keepDropCode).join(''));
144
144
  case 'explode':
145
145
  return `${expr(part.target)}${explodeCode(part)}`;
146
146
  case 'reroll':
147
- return `${expr(part.target)}${part.once ? 'ro' : 'r'}${comparePointCode(part.condition)}`;
147
+ return joinModifierCode(expr(part.target), rerollCode(part));
148
148
  case 'dieBound':
149
- return `${expr(part.target)}${dieBoundCode(part)}`;
149
+ return joinModifierCode(expr(part.target), dieBoundCode(part));
150
150
  case 'sort':
151
- return `${expr(part.target)}${part.order === 'ascending' ? 's' : 'sd'}`;
151
+ return joinModifierCode(expr(part.target), part.order === 'ascending' ? 's' : 'sd');
152
152
  case 'critThreshold':
153
- return `${expr(part.target)}${critThresholdCode(part)}`;
153
+ return joinModifierCode(expr(part.target), critThresholdCode(part));
154
154
  case 'successCount':
155
155
  return `${expr(part.target)}${successCountCode(part)}`;
156
156
  case 'versus':
@@ -163,6 +163,10 @@ function explodeCode(part: Extract<RollPart, { type: 'explode' }>): string {
163
163
  return part.threshold == null ? marker : `${marker}${comparePointCode(part.threshold)}`;
164
164
  }
165
165
 
166
+ function rerollCode(part: Extract<RollPart, { type: 'reroll' }>): string {
167
+ return `${part.once ? 'ro' : 'r'}${comparePointCode(part.condition)}`;
168
+ }
169
+
166
170
  /** Negative bounds are parenthesized so the expression re-parses. */
167
171
  function dieBoundCode(part: Extract<RollPart, { type: 'dieBound' }>): string {
168
172
  return part.value < 0 ? `${part.bound}(${part.value})` : `${part.bound}${part.value}`;
@@ -172,7 +176,7 @@ function critThresholdCode(part: Extract<RollPart, { type: 'critThreshold' }>):
172
176
  return [
173
177
  ...part.successThresholds.map((threshold) => critCode('cs', threshold)),
174
178
  ...part.failThresholds.map((threshold) => critCode('cf', threshold)),
175
- ].join('');
179
+ ].reduce(joinModifierCode, '');
176
180
  }
177
181
 
178
182
  function successCountCode(part: Extract<RollPart, { type: 'successCount' }>): string {
@@ -235,9 +239,8 @@ function poolOf(part: RollPart): DieResult[] {
235
239
  }
236
240
 
237
241
  /**
238
- * Marks one die. `plain` suppresses the three state marks for dice inside a
239
- * dropped sub-roll, where the group wrapper already carries the verdict —
240
- * crit and fumble survive, since they describe the face, not the selection.
242
+ * Marks one die. `plain` suppresses the three state marks inside a dropped
243
+ * sub-roll the rule {@link DieMarks.droppedGroup} documents.
241
244
  */
242
245
  function markDie(die: DieResult, marks: DieMarks, plain: boolean): string {
243
246
  let text = String(die.result);
@@ -279,7 +282,7 @@ function renderModifier(
279
282
  plain: boolean,
280
283
  ): string {
281
284
  const pool = dice.length === 0 ? '' : renderPool(dice, marks, plain);
282
- return `${expr(target)}${code}${pool}`;
285
+ return `${joinModifierCode(expr(target), code)}${pool}`;
283
286
  }
284
287
 
285
288
  function renderPart(part: RollPart, marks: DieMarks, plain: boolean): string {
@@ -316,21 +319,20 @@ function renderPart(part: RollPart, marks: DieMarks, plain: boolean): string {
316
319
  case 'explode':
317
320
  return renderModifier(part.target, explodeCode(part), part.rolls, marks, plain);
318
321
  case 'reroll':
319
- return renderModifier(
320
- part.target,
321
- `${part.once ? 'ro' : 'r'}${comparePointCode(part.condition)}`,
322
- part.rolls,
323
- marks,
324
- plain,
325
- );
322
+ return renderModifier(part.target, rerollCode(part), part.rolls, marks, plain);
326
323
  case 'successCount':
327
- return renderModifier(part.target, successCountCode(part), part.rolls, marks, plain);
324
+ // Subtotal counting renders through the group, which shows each sub-roll's
325
+ // own dice; only the flat form collapses into one bracket.
326
+ return part.target.type === 'group' && part.target.parts.length >= 2
327
+ ? `${renderPart(part.target, marks, plain)}${successCountCode(part)}`
328
+ : renderModifier(part.target, successCountCode(part), part.rolls, marks, plain);
329
+ // These three render as their own normalized expression plus a pool.
328
330
  case 'dieBound':
329
- return `${expr(part.target)}${dieBoundCode(part)}${renderPool(poolOf(part.target), marks, plain)}`;
331
+ return `${expr(part)}${renderPool(poolOf(part.target), marks, plain)}`;
330
332
  case 'sort':
331
- return `${expr(part.target)}${part.order === 'ascending' ? 's' : 'sd'}${renderPool(part.rolls, marks, plain)}`;
333
+ return `${expr(part)}${renderPool(part.rolls, marks, plain)}`;
332
334
  case 'critThreshold':
333
- return `${expr(part.target)}${critThresholdCode(part)}${renderPool(poolOf(part.target), marks, plain)}`;
335
+ return `${expr(part)}${renderPool(poolOf(part.target), marks, plain)}`;
334
336
  }
335
337
  }
336
338
 
package/src/types.ts CHANGED
@@ -165,8 +165,9 @@ export type DieResult = {
165
165
  /** The rolled value */
166
166
  result: number;
167
167
  /**
168
- * Raw first roll before any mutation (e.g., compound-explode accumulation).
169
- * Only populated when `result` has been overwritten with a computed value.
168
+ * Raw first roll before any mutation compound-explode accumulation, the
169
+ * `!p` decrement, or a `minN`/`maxN` clamp. Only populated when `result` has
170
+ * been overwritten with a computed value.
170
171
  * Consumers that need the original face (nat-20 / nat-1 detection) should
171
172
  * read `initialResult ?? result`.
172
173
  */
@@ -234,7 +235,10 @@ export type RollPartBase = {
234
235
  * - `literal.total === value` and `variable.total === value`.
235
236
  * - Each part's `rolls[]` shares `DieResult` references with
236
237
  * `RollResult.rolls[]`; both reflect post-evaluation state (explode
237
- * accumulation, reroll flags, keep/drop flags). No deep clone.
238
+ * accumulation, reroll flags, keep/drop flags, success/failure tags). No deep
239
+ * clone. A part's own numbers record what that part computed, so an inner
240
+ * `successCount` re-scored by an outer one (`{4d6>=5}<=2f5`) keeps its
241
+ * tally while the dice it shares show the outer count's tags.
238
242
  *
239
243
  * Meta-expression sub-trees (`4d6kh(1d2)`, `(1+1)d6` counts/sides, computed
240
244
  * thresholds) are not surfaced as nested parts — their resolved numbers
@@ -449,6 +453,14 @@ export type RollResult = Readonly<{
449
453
  * arithmetic on top of a success count (e.g. `{5d6>=5}+2`) affects `total`
450
454
  * but not `successes`. Success counts are terminal, so the group braces are
451
455
  * required: `5d6>=5 * 2` is an `INVALID_SUCCESS_COUNT_TARGET` parse error.
456
+ *
457
+ * Braces also let a second count re-count the same pool (`{4d6>=5}<=2f5`).
458
+ * The outermost count owns the outcome: it re-scores the pool against its own
459
+ * thresholds, so no die is ever both a success and a failure, and dice it
460
+ * does not count come out untagged rather than keeping the inner count's
461
+ * tags. The DC side of a `vs` is never tallied here; it keeps whatever tags
462
+ * an inner count gave it, unless the outer count scores subtotals rather
463
+ * than dice, which leaves every die untagged.
452
464
  */
453
465
  successes?: number;
454
466
  /**
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by `bun run generate:version` from package.json — do not edit.
2
- export const version = '3.1.0';
2
+ export const version = '3.2.1';