roll-parser 3.0.0-beta.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/CHANGELOG.md +142 -9
  2. package/MIGRATION.md +219 -0
  3. package/README.md +1026 -154
  4. package/dist/cli/args.d.ts +1 -0
  5. package/dist/cli/args.d.ts.map +1 -1
  6. package/dist/cli/args.js +81 -0
  7. package/dist/cli/args.js.map +1 -0
  8. package/dist/cli/format.d.ts +15 -3
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +18 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/index.d.ts +3 -0
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/index.js +14 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/main.d.ts +36 -0
  17. package/dist/cli/main.d.ts.map +1 -0
  18. package/dist/cli/main.js +83 -0
  19. package/dist/cli/main.js.map +1 -0
  20. package/dist/errors.d.ts +332 -17
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +141 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/evaluator/die.d.ts +27 -0
  25. package/dist/evaluator/die.d.ts.map +1 -0
  26. package/dist/evaluator/die.js +19 -0
  27. package/dist/evaluator/die.js.map +1 -0
  28. package/dist/evaluator/env.d.ts +89 -0
  29. package/dist/evaluator/env.d.ts.map +1 -0
  30. package/dist/evaluator/env.js +11 -0
  31. package/dist/evaluator/env.js.map +1 -0
  32. package/dist/evaluator/evaluator.d.ts +41 -75
  33. package/dist/evaluator/evaluator.d.ts.map +1 -1
  34. package/dist/evaluator/evaluator.js +914 -0
  35. package/dist/evaluator/evaluator.js.map +1 -0
  36. package/dist/evaluator/modifiers/compare.js +15 -0
  37. package/dist/evaluator/modifiers/compare.js.map +1 -0
  38. package/dist/evaluator/modifiers/crit-threshold.d.ts +57 -5
  39. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/crit-threshold.js +42 -0
  41. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  42. package/dist/evaluator/modifiers/die-bound.d.ts +29 -0
  43. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.js +17 -0
  45. package/dist/evaluator/modifiers/die-bound.js.map +1 -0
  46. package/dist/evaluator/modifiers/explode.d.ts +13 -5
  47. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  48. package/dist/evaluator/modifiers/explode.js +105 -0
  49. package/dist/evaluator/modifiers/explode.js.map +1 -0
  50. package/dist/evaluator/modifiers/flags.d.ts +47 -0
  51. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  52. package/dist/evaluator/modifiers/flags.js +25 -0
  53. package/dist/evaluator/modifiers/flags.js.map +1 -0
  54. package/dist/evaluator/modifiers/keep-drop.d.ts +15 -29
  55. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  56. package/dist/evaluator/modifiers/keep-drop.js +82 -0
  57. package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
  58. package/dist/evaluator/modifiers/reroll.d.ts +12 -4
  59. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  60. package/dist/evaluator/modifiers/reroll.js +68 -0
  61. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  62. package/dist/evaluator/modifiers/sort.d.ts +5 -1
  63. package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
  64. package/dist/evaluator/modifiers/sort.js +13 -0
  65. package/dist/evaluator/modifiers/sort.js.map +1 -0
  66. package/dist/evaluator/modifiers/success-count.d.ts +6 -7
  67. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  68. package/dist/evaluator/modifiers/success-count.js +25 -0
  69. package/dist/evaluator/modifiers/success-count.js.map +1 -0
  70. package/dist/index.d.ts +33 -11
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +12 -2560
  73. package/dist/index.js.map +1 -26
  74. package/dist/lexer/lexer.d.ts +50 -5
  75. package/dist/lexer/lexer.d.ts.map +1 -1
  76. package/dist/lexer/lexer.js +260 -0
  77. package/dist/lexer/lexer.js.map +1 -0
  78. package/dist/lexer/tokens.d.ts +31 -6
  79. package/dist/lexer/tokens.d.ts.map +1 -1
  80. package/dist/lexer/tokens.js +42 -0
  81. package/dist/lexer/tokens.js.map +1 -0
  82. package/dist/parser/ast.d.ts +324 -165
  83. package/dist/parser/ast.d.ts.map +1 -1
  84. package/dist/parser/ast.js +52 -0
  85. package/dist/parser/ast.js.map +1 -0
  86. package/dist/parser/guards.d.ts +106 -0
  87. package/dist/parser/guards.d.ts.map +1 -0
  88. package/dist/parser/guards.js +121 -0
  89. package/dist/parser/guards.js.map +1 -0
  90. package/dist/parser/parser.d.ts +118 -14
  91. package/dist/parser/parser.d.ts.map +1 -1
  92. package/dist/parser/parser.js +751 -0
  93. package/dist/parser/parser.js.map +1 -0
  94. package/dist/render.d.ts +95 -0
  95. package/dist/render.d.ts.map +1 -0
  96. package/dist/render.js +227 -0
  97. package/dist/render.js.map +1 -0
  98. package/dist/rng/mock.d.ts +73 -12
  99. package/dist/rng/mock.d.ts.map +1 -1
  100. package/dist/rng/mock.js +30 -0
  101. package/dist/rng/mock.js.map +1 -0
  102. package/dist/rng/seeded.d.ts +141 -9
  103. package/dist/rng/seeded.d.ts.map +1 -1
  104. package/dist/rng/seeded.js +138 -0
  105. package/dist/rng/seeded.js.map +1 -0
  106. package/dist/rng/types.d.ts +57 -0
  107. package/dist/rng/types.d.ts.map +1 -1
  108. package/dist/rng/types.js +2 -0
  109. package/dist/rng/types.js.map +1 -0
  110. package/dist/roll.d.ts +58 -28
  111. package/dist/roll.d.ts.map +1 -1
  112. package/dist/roll.js +8 -0
  113. package/dist/roll.js.map +1 -0
  114. package/dist/testing.d.ts +5 -4
  115. package/dist/testing.d.ts.map +1 -1
  116. package/dist/testing.js +2 -41
  117. package/dist/testing.js.map +1 -11
  118. package/dist/types.d.ts +349 -47
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/types.js +8 -0
  121. package/dist/types.js.map +1 -0
  122. package/dist/version.d.ts +2 -0
  123. package/dist/version.d.ts.map +1 -0
  124. package/dist/version.js +2 -0
  125. package/dist/version.js.map +1 -0
  126. package/package.json +93 -34
  127. package/src/cli/args.ts +66 -10
  128. package/src/cli/format.ts +37 -26
  129. package/src/cli/index.ts +27 -84
  130. package/src/cli/main.ts +129 -0
  131. package/src/errors.ts +480 -27
  132. package/src/evaluator/die.ts +51 -0
  133. package/src/evaluator/env.ts +105 -0
  134. package/src/evaluator/evaluator.ts +693 -434
  135. package/src/evaluator/modifiers/crit-threshold.ts +96 -14
  136. package/src/evaluator/modifiers/die-bound.ts +48 -0
  137. package/src/evaluator/modifiers/explode.ts +70 -62
  138. package/src/evaluator/modifiers/flags.ts +78 -0
  139. package/src/evaluator/modifiers/keep-drop.ts +129 -127
  140. package/src/evaluator/modifiers/reroll.ts +44 -56
  141. package/src/evaluator/modifiers/sort.ts +21 -2
  142. package/src/evaluator/modifiers/success-count.ts +24 -12
  143. package/src/index.ts +56 -35
  144. package/src/lexer/lexer.ts +107 -34
  145. package/src/lexer/tokens.ts +31 -6
  146. package/src/parser/ast.ts +333 -346
  147. package/src/parser/guards.ts +248 -0
  148. package/src/parser/parser.ts +419 -242
  149. package/src/render.ts +392 -0
  150. package/src/rng/mock.ts +74 -13
  151. package/src/rng/seeded.ts +299 -64
  152. package/src/rng/types.ts +57 -0
  153. package/src/roll.ts +64 -47
  154. package/src/testing.ts +5 -9
  155. package/src/types.ts +353 -46
  156. package/src/version.ts +2 -0
  157. package/dist/cli.js +0 -2608
  158. package/dist/cli.js.map +0 -28
  159. package/dist/evaluator/index.d.ts +0 -8
  160. package/dist/evaluator/index.d.ts.map +0 -1
  161. package/dist/rng/index.d.ts +0 -8
  162. package/dist/rng/index.d.ts.map +0 -1
  163. package/src/evaluator/index.ts +0 -14
  164. package/src/rng/index.ts +0 -8
@@ -9,6 +9,40 @@
9
9
  * Meta dice (rolled to compute counts/sides/modifier args) are skipped so
10
10
  * their bookkeeping stays untouched.
11
11
  *
12
+ * The two threshold kinds deliberately read different values. `'default'`
13
+ * reads `initialResult ?? result`, the same source as the versus `natural`,
14
+ * so "rolled the maximum face" survives the two modifiers that overwrite
15
+ * `result` while recording the face they replaced — compound explode and
16
+ * `minN`/`maxN`. An explicit threshold (`cs>4`, `cf<=2`) reads the die's
17
+ * current `result`: it is a predicate over the die's value, and postfix
18
+ * modifiers are order-sensitive by design, so `4d6min5cs>4` is meant to
19
+ * see the clamped faces. The two can therefore disagree on one die —
20
+ * `4d6min5cs>4` flags a clamped natural 1 as both critical and fumble.
21
+ *
22
+ * They also diverge on Fate dice. `'default'` carries a `sides > 1` guard, so
23
+ * it never fires on a `sides = 0` pool; an explicit threshold has none and
24
+ * compares the raw {-1, 0, +1} face, so `4dFcs>0` and `4dFcf=-1` do set the
25
+ * flags. The missing guard is deliberate — the parser rejects the bare
26
+ * `cs`/`cf` forms instead, since they would resolve to `'default'` and
27
+ * silently do nothing.
28
+ *
29
+ * ! Penetrating explode is not covered: it stores `raw - 1` in `result`
30
+ * ! without recording `initialResult`, so `1d6!pcs` still judges a natural
31
+ * ! 6 by its decremented 5. An *inherited* rule is handed the raw roll
32
+ * ! instead, which keeps `1d6cf>5!p` agreeing with `1d6!p` on the side the
33
+ * ! user never overrode — at the cost of `1d6cf>5!p` and `1d6!pcf>5`
34
+ * ! disagreeing on it. Recording `initialResult` here would settle both, but
35
+ * ! `extractNatural` reads that field to tell an appended explosion die from
36
+ * ! a compounded one, and would start counting these as versus primaries.
37
+ *
38
+ * The rule is recorded per die on `env.critRules`, so dice that explode and
39
+ * reroll mint *after* the crit node has run inherit it from the die they
40
+ * descended from. `cs`/`cf` therefore covers the whole pool wherever it sits
41
+ * in the postfix chain — `1d6cs<2!` and `1d6!cs<2` agree. Two things stay
42
+ * outside that: compound explode, which mints no die and so keeps the flags
43
+ * its accumulated `result` had when the crit node ran, and `minN`/`maxN`,
44
+ * which rewrites `result` under an explicit threshold's feet.
45
+ *
12
46
  * Display-only: does not alter `total`, explosion triggers, success
13
47
  * counting, or any other modifier flag. Dropped dice still participate —
14
48
  * their `critical`/`fumble` metadata reflects what they rolled, not
@@ -18,42 +52,90 @@
18
52
  */
19
53
 
20
54
  import type { DieResult, ResolvedCritThreshold } from '../../types.js';
55
+ import type { CritRule, EvalEnv } from '../env.js';
21
56
  import { matchesCondition } from './compare.js';
22
-
23
- // ? Re-exported for existing importers — the definition moved to types.ts so
24
- // the public RollPart union can reference it without a circular import.
25
- export type { ResolvedCritThreshold } from '../../types.js';
57
+ import { isVersusDc } from './flags.js';
26
58
 
27
59
  /**
28
60
  * Applies success/fail threshold arrays to a dice pool, overriding each
29
- * die's `critical` and `fumble` flags in place. A die matches `'default'`
30
- * on the success side when `result === sides && sides > 1`, and on the
31
- * fail side when `result === 1`. Meta dice are skipped.
61
+ * die's `critical` and `fumble` flags in place. Writing `natural` for
62
+ * `initialResult ?? result`, a die matches `'default'` on the success side
63
+ * when `natural === sides && sides > 1`, and on the fail side when
64
+ * `natural === 1 && sides > 1`. Meta dice are skipped.
65
+ *
66
+ * Also records the rule against every die it touched, so later explode and
67
+ * reroll dice can inherit it via {@link inheritCritRule}.
32
68
  */
33
69
  export function applyCritThresholds(
34
70
  dice: DieResult[],
35
71
  successThresholds: ResolvedCritThreshold[],
36
72
  failThresholds: ResolvedCritThreshold[],
73
+ env: EvalEnv,
37
74
  ): void {
75
+ const rule: CritRule = { success: successThresholds, fail: failThresholds };
76
+ env.critRules ??= new WeakMap();
77
+ const rules = env.critRules;
78
+
38
79
  for (const die of dice) {
39
80
  if (die.modifiers.includes('meta')) continue;
81
+ if (env.hasVersusDc && isVersusDc(die)) continue;
40
82
 
41
- die.critical = successThresholds.some((t) => matchesCrit(t, die));
42
- die.fumble = failThresholds.some((t) => matchesFumble(t, die));
83
+ applyCritRule(die, rule, die.initialResult ?? die.result);
84
+ rules.set(die, rule);
43
85
  }
44
86
  }
45
87
 
46
- function matchesCrit(threshold: ResolvedCritThreshold, die: DieResult): boolean {
88
+ /**
89
+ * Judges one die by a recorded rule, overwriting both flags. A side always
90
+ * carries at least `'default'` — `evalCritThreshold` fills the side the user
91
+ * left out — so neither flag is silently left untouched. `natural` is the face
92
+ * the `'default'` sentinel reads; an explicit threshold always reads `result`.
93
+ */
94
+ function applyCritRule(die: DieResult, rule: CritRule, natural: number): void {
95
+ die.critical = rule.success.some((t) => matchesCrit(t, die, natural));
96
+ die.fumble = rule.fail.some((t) => matchesFumble(t, die, natural));
97
+ }
98
+
99
+ /**
100
+ * Passes the crit rule recorded for `parent` down to a die minted from it,
101
+ * judging the child by that rule and recording it so a further explode or
102
+ * reroll inherits it in turn. No-op when no `cs`/`cf` governs `parent`, which
103
+ * leaves the `createDieResult` default rule in place.
104
+ *
105
+ * `natural` is the face the `'default'` sentinel reads, defaulting to the
106
+ * child's own. Penetrating explode passes its raw roll — see the module note.
107
+ *
108
+ * ! Call this only once the child's final `result` is stored — an explicit
109
+ * ! threshold is a predicate over `result`, so a penetrating die is judged by
110
+ * ! its decremented value, matching `1d6!pcs<2`.
111
+ */
112
+ export function inheritCritRule(
113
+ env: EvalEnv,
114
+ parent: DieResult,
115
+ child: DieResult,
116
+ natural = child.initialResult ?? child.result,
117
+ ): void {
118
+ const rules = env.critRules;
119
+ if (rules === undefined) return;
120
+
121
+ const rule = rules.get(parent);
122
+ if (rule === undefined) return;
123
+
124
+ applyCritRule(child, rule, natural);
125
+ rules.set(child, rule);
126
+ }
127
+
128
+ function matchesCrit(threshold: ResolvedCritThreshold, die: DieResult, natural: number): boolean {
47
129
  if (threshold === 'default') {
48
- return die.result === die.sides && die.sides > 1;
130
+ return natural === die.sides && die.sides > 1;
49
131
  }
50
132
  return matchesCondition(die.result, threshold.operator, threshold.value);
51
133
  }
52
134
 
53
- function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult): boolean {
135
+ function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult, natural: number): boolean {
54
136
  if (threshold === 'default') {
55
- // ? Mirrors `createDieResult` — a d1 always rolls 1, never a fumble.
56
- return die.result === 1 && die.sides > 1;
137
+ // Mirrors `createDieResult` — a d1 always rolls 1, never a fumble.
138
+ return natural === 1 && die.sides > 1;
57
139
  }
58
140
  return matchesCondition(die.result, threshold.operator, threshold.value);
59
141
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Per-die clamp modifier (`minN` / `maxN`).
3
+ *
4
+ * Rewrites each die's `result` toward the bound: `min` raises results below
5
+ * the bound, `max` lowers results above it. The raw face is preserved in
6
+ * `initialResult` (first writer wins, so a compound-explode accumulation
7
+ * that was clamped afterwards still reports its original first roll), and
8
+ * the die is tagged `'min'` / `'max'` so parts consumers can tell a clamped
9
+ * value from a natural one. The tag is written at most once, so a chain of
10
+ * same-kind bounds (`4d6min3min4`) leaves a die carrying a single `'min'`.
11
+ *
12
+ * `critical` / `fumble` keep reflecting the natural face — a clamped 1 is
13
+ * still a fumble, matching the raw-face crit semantics used everywhere else.
14
+ * A following bare `cs`/`cf` agrees; a following *explicit* threshold
15
+ * (`4d6min5cs>4`) is a predicate over the clamped value and overrides it.
16
+ * Meta dice (rolled to compute counts/sides/modifier args) are skipped.
17
+ * Dropped dice are clamped too — they are excluded from totals anyway, and
18
+ * clamping them keeps the rendered pool consistent.
19
+ *
20
+ * @module evaluator/modifiers/die-bound
21
+ */
22
+
23
+ import type { DieResult } from '../../types.js';
24
+ import { isVersusDc } from './flags.js';
25
+
26
+ /**
27
+ * Clamps every non-meta die in `dice` against `value`, in place.
28
+ * `bound: 'min'` lifts lower results up to `value`; `'max'` caps higher
29
+ * results down to it. Untouched dice keep their tags.
30
+ */
31
+ export function applyDieBound(
32
+ dice: DieResult[],
33
+ bound: 'min' | 'max',
34
+ value: number,
35
+ hasVersusDc: boolean,
36
+ ): void {
37
+ for (const die of dice) {
38
+ if (die.modifiers.includes('meta')) continue;
39
+ if (hasVersusDc && isVersusDc(die)) continue;
40
+
41
+ const clamped = bound === 'min' ? Math.max(die.result, value) : Math.min(die.result, value);
42
+ if (clamped === die.result) continue;
43
+
44
+ die.initialResult ??= die.result;
45
+ die.result = clamped;
46
+ if (!die.modifiers.includes(bound)) die.modifiers.push(bound);
47
+ }
48
+ }
@@ -8,12 +8,23 @@
8
8
  * @module evaluator/modifiers/explode
9
9
  */
10
10
 
11
+ import { EvaluatorError } from '../../errors.js';
11
12
  import type { RNG } from '../../rng/types.js';
12
- import type { CompareOp } from '../../types.js';
13
- import type { DieResult } from '../../types.js';
14
- import { EvaluatorError, type EvalEnv } from '../evaluator.js';
13
+ import type { CompareOp, DieResult } from '../../types.js';
14
+ import { createDieResult } from '../die.js';
15
+ import { chargeDie, type EvalEnv } from '../env.js';
16
+ import { inheritCritRule } from './crit-threshold.js';
17
+ import { isVersusDc } from './flags.js';
15
18
 
16
- /** Default maximum explosion iterations per die. */
19
+ /**
20
+ * Default value of `EvaluationOptions.maxExplodeIterations`: how many times a
21
+ * single die may explode before `EXPLODE_LIMIT_EXCEEDED` is thrown.
22
+ *
23
+ * Per die, not per pool — it bounds notations like `1d1!`, where the
24
+ * explosion condition can never stop being true.
25
+ *
26
+ * @category Limits
27
+ */
17
28
  export const DEFAULT_MAX_EXPLODE_ITERATIONS = 1_000;
18
29
 
19
30
  /**
@@ -52,14 +63,7 @@ export function buildShouldExplode(
52
63
  * Rolls one explosion die, charging it against the global dice limit.
53
64
  */
54
65
  function rollExplosion(sides: number, rng: RNG, env: EvalEnv): number {
55
- if (env.totalDiceRolled + 1 > env.maxDice) {
56
- throw new EvaluatorError(
57
- `Total dice count ${env.totalDiceRolled + 1} exceeds limit of ${env.maxDice}`,
58
- 'DICE_LIMIT_EXCEEDED',
59
- 'Explode',
60
- );
61
- }
62
- env.totalDiceRolled += 1;
66
+ chargeDie(env, 'Explode');
63
67
  return rng.nextInt(1, sides);
64
68
  }
65
69
 
@@ -81,45 +85,52 @@ function explodeLimitError(maxIterations: number): EvaluatorError {
81
85
  * never fire during normal flow. Keeping it ensures `rng.nextInt(1, 0)`
82
86
  * can never be reached if a future AST path slips past the parser gate.
83
87
  */
84
- function canExplode(die: DieResult): boolean {
88
+ function canExplode(die: DieResult, hasVersusDc: boolean): boolean {
89
+ if (hasVersusDc && isVersusDc(die)) return false;
85
90
  if (die.modifiers.includes('dropped')) return false;
86
91
  if (die.sides < 1) return false;
87
92
  return true;
88
93
  }
89
94
 
90
95
  /**
91
- * Standard explode: append each new die to the pool. The original die keeps
92
- * its modifiers untouched; new dice carry `['exploded', 'kept']`.
96
+ * Shared implementation of the two appending explode variants. The explosion
97
+ * predicate always runs on the RAW roll; `storeResult` maps that raw roll to
98
+ * the value recorded on the appended die, which is the only thing standard
99
+ * and penetrating explosions disagree about.
100
+ *
101
+ * `critical`/`fumble` are likewise derived from the raw roll — a penetrating
102
+ * die that rolled its max face is still a crit even though it stores one less.
103
+ * An inherited `cs`/`cf` keeps that: it is applied after `storeResult`, so an
104
+ * explicit threshold reads the stored value, while the raw roll is handed over
105
+ * for the `'default'` sentinel to read.
93
106
  */
94
- export function applyStandardExplode(
107
+ function applyAppendingExplode(
95
108
  pool: DieResult[],
96
109
  shouldExplode: ShouldExplode,
97
110
  rng: RNG,
98
111
  env: EvalEnv,
112
+ storeResult: (raw: number) => number,
99
113
  ): DieResult[] {
100
114
  const result: DieResult[] = [];
101
115
 
102
116
  for (const original of pool) {
103
117
  result.push(original);
104
- if (!canExplode(original)) continue;
118
+ if (!canExplode(original, env.hasVersusDc)) continue;
105
119
 
106
120
  const sides = original.sides;
107
- let last = original.result;
121
+ let lastRaw = original.result;
108
122
  let iterations = 0;
109
123
 
110
- while (shouldExplode(last, sides)) {
124
+ while (shouldExplode(lastRaw, sides)) {
111
125
  if (iterations >= env.maxExplodeIterations) {
112
126
  throw explodeLimitError(env.maxExplodeIterations);
113
127
  }
114
- const next = rollExplosion(sides, rng, env);
115
- result.push({
116
- sides,
117
- result: next,
118
- modifiers: ['exploded', 'kept'],
119
- critical: next === sides && sides > 1,
120
- fumble: next === 1 && sides > 1,
121
- });
122
- last = next;
128
+ const raw = rollExplosion(sides, rng, env);
129
+ const die = createDieResult(sides, raw, ['exploded', 'kept']);
130
+ die.result = storeResult(raw);
131
+ inheritCritRule(env, original, die, raw);
132
+ result.push(die);
133
+ lastRaw = raw;
123
134
  iterations += 1;
124
135
  }
125
136
  }
@@ -127,6 +138,29 @@ export function applyStandardExplode(
127
138
  return result;
128
139
  }
129
140
 
141
+ /** Records the raw roll unchanged — standard explode. */
142
+ function keepRaw(raw: number): number {
143
+ return raw;
144
+ }
145
+
146
+ /** Records one less than the raw roll — penetrating explode. */
147
+ function penetratingPenalty(raw: number): number {
148
+ return raw - 1;
149
+ }
150
+
151
+ /**
152
+ * Standard explode: append each new die to the pool. The original die keeps
153
+ * its modifiers untouched; new dice carry `['exploded', 'kept']`.
154
+ */
155
+ export function applyStandardExplode(
156
+ pool: DieResult[],
157
+ shouldExplode: ShouldExplode,
158
+ rng: RNG,
159
+ env: EvalEnv,
160
+ ): DieResult[] {
161
+ return applyAppendingExplode(pool, shouldExplode, rng, env, keepRaw);
162
+ }
163
+
130
164
  /**
131
165
  * Compound explode: pool length stays the same. Each original die's `result`
132
166
  * accumulates every explosion roll, and gains the `'exploded'` modifier once
@@ -143,7 +177,7 @@ export function applyCompoundExplode(
143
177
  env: EvalEnv,
144
178
  ): DieResult[] {
145
179
  for (const original of pool) {
146
- if (!canExplode(original)) continue;
180
+ if (!canExplode(original, env.hasVersusDc)) continue;
147
181
 
148
182
  const sides = original.sides;
149
183
  let accumulated = original.result;
@@ -164,9 +198,10 @@ export function applyCompoundExplode(
164
198
 
165
199
  if (!exploded) continue;
166
200
 
167
- // ? `critical` and `fumble` keep referring to the original triggering
168
- // roll — after compounding the `result` is a sum.
169
- original.initialResult = original.result;
201
+ // `critical` and `fumble` keep referring to the original triggering
202
+ // roll — after compounding the `result` is a sum. First writer wins, so
203
+ // a preceding `minN`/`maxN` clamp keeps its record of the raw face.
204
+ original.initialResult ??= original.result;
170
205
  original.result = accumulated;
171
206
  if (!original.modifiers.includes('exploded')) {
172
207
  original.modifiers = [...original.modifiers, 'exploded'];
@@ -179,7 +214,8 @@ export function applyCompoundExplode(
179
214
  /**
180
215
  * Penetrating explode: like standard, but each appended die stores
181
216
  * `result = rawRoll - 1`. The explosion predicate runs on the RAW roll
182
- * (pre-decrement), matching rpg-dice-roller convention.
217
+ * (pre-decrement), so a max face still chains even though its stored value
218
+ * is one lower.
183
219
  */
184
220
  export function applyPenetratingExplode(
185
221
  pool: DieResult[],
@@ -187,33 +223,5 @@ export function applyPenetratingExplode(
187
223
  rng: RNG,
188
224
  env: EvalEnv,
189
225
  ): DieResult[] {
190
- const result: DieResult[] = [];
191
-
192
- for (const original of pool) {
193
- result.push(original);
194
- if (!canExplode(original)) continue;
195
-
196
- const sides = original.sides;
197
- let lastRaw = original.result;
198
- let iterations = 0;
199
-
200
- while (shouldExplode(lastRaw, sides)) {
201
- if (iterations >= env.maxExplodeIterations) {
202
- throw explodeLimitError(env.maxExplodeIterations);
203
- }
204
- const raw = rollExplosion(sides, rng, env);
205
- const stored = raw - 1;
206
- result.push({
207
- sides,
208
- result: stored,
209
- modifiers: ['exploded', 'kept'],
210
- critical: raw === sides && sides > 1,
211
- fumble: raw === 1 && sides > 1,
212
- });
213
- lastRaw = raw;
214
- iterations += 1;
215
- }
216
- }
217
-
218
- return result;
226
+ return applyAppendingExplode(pool, shouldExplode, rng, env, penetratingPenalty);
219
227
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Shared slot-flag rewriting for `DieResult.modifiers`.
3
+ *
4
+ * "Slot" flags (`kept` / `dropped` / `rerolled`) and tally flags
5
+ * (`success` / `failure`) are owned by whichever pass is currently deciding a
6
+ * die's fate, so each pass rebuilds them rather than appending. Every rewrite
7
+ * site strips its own exclusion list and appends its own markers — the lists
8
+ * differ, the mechanics do not.
9
+ *
10
+ * @module evaluator/modifiers/flags
11
+ */
12
+
13
+ import type { DieModifier, DieResult } from '../../types.js';
14
+
15
+ /**
16
+ * True when a die belongs to the DC side of a `vs` comparison.
17
+ *
18
+ * DC dice share the roll side's `rolls` array so they still render
19
+ * (`1d20[3] vs 2d10[5, 6]`), but they are not part of the pool a modifier
20
+ * operates on. Nothing may sum, select, clamp, reroll, explode, or tally them
21
+ * — a `d10` on the DC side must never come back clamped to 20, and
22
+ * `{1d20 vs 2d10, 1d4}>=5` must not count the DC faces as successes.
23
+ *
24
+ * ! Call this behind the shared `hasVersusDc` env flag, never bare. Unguarded
25
+ * ! inside a per-die loop it cost 11-38% on notation that cannot carry the tag
26
+ * ! (#281); the flag is `false` until a `vs` has actually tagged something.
27
+ */
28
+ export function isVersusDc(die: DieResult): boolean {
29
+ return die.modifiers.includes('dc');
30
+ }
31
+
32
+ /** Kept/dropped selection flags — rebuilt by every keep/drop pass. */
33
+ export const SELECTION_FLAGS: readonly DieModifier[] = ['kept', 'dropped'];
34
+
35
+ /**
36
+ * Selection flags plus the success-count tally flags. Stripped when a die
37
+ * leaves the pool that tagged it (meta sub-expressions, dropped group
38
+ * sub-rolls) so the top-level successes/failures scan cannot count it.
39
+ */
40
+ export const SELECTION_AND_TALLY_FLAGS: readonly DieModifier[] = [
41
+ 'kept',
42
+ 'dropped',
43
+ 'success',
44
+ 'failure',
45
+ ];
46
+
47
+ /**
48
+ * {@link SELECTION_AND_TALLY_FLAGS} plus `'meta'`, for a meta context merging
49
+ * into a parent. Meta operands nest (`((1d2)d4)d6`), so a die passes through
50
+ * the merge once per level and the tag must be rebuilt, not appended.
51
+ */
52
+ export const META_MERGE_FLAGS: readonly DieModifier[] = [
53
+ 'kept',
54
+ 'dropped',
55
+ 'success',
56
+ 'failure',
57
+ 'meta',
58
+ ];
59
+
60
+ /** Selection flags plus `rerolled` — reassigned on every reroll pass. */
61
+ export const REROLL_SLOT_FLAGS: readonly DieModifier[] = ['kept', 'dropped', 'rerolled'];
62
+
63
+ /** Returns `modifiers` with every flag in `excluded` removed. */
64
+ export function stripFlags(
65
+ modifiers: readonly DieModifier[],
66
+ excluded: readonly DieModifier[],
67
+ ): DieModifier[] {
68
+ return modifiers.filter((modifier) => !excluded.includes(modifier));
69
+ }
70
+
71
+ /** Returns `modifiers` with `excluded` removed and `added` appended, in order. */
72
+ export function rewriteFlags(
73
+ modifiers: readonly DieModifier[],
74
+ excluded: readonly DieModifier[],
75
+ ...added: DieModifier[]
76
+ ): DieModifier[] {
77
+ return [...stripFlags(modifiers, excluded), ...added];
78
+ }