roll-parser 2.3.2 → 3.0.0-alpha.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 (101) hide show
  1. package/README.md +47 -178
  2. package/dist/cli/args.d.ts +35 -0
  3. package/dist/cli/args.d.ts.map +1 -0
  4. package/dist/cli/format.d.ts +20 -0
  5. package/dist/cli/format.d.ts.map +1 -0
  6. package/dist/cli/index.d.ts +8 -0
  7. package/dist/cli/index.d.ts.map +1 -0
  8. package/dist/cli.js +1775 -0
  9. package/dist/errors.d.ts +39 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/evaluator/evaluator.d.ts +65 -0
  12. package/dist/evaluator/evaluator.d.ts.map +1 -0
  13. package/dist/evaluator/index.d.ts +8 -0
  14. package/dist/evaluator/index.d.ts.map +1 -0
  15. package/dist/evaluator/modifiers/compare.d.ts +15 -0
  16. package/dist/evaluator/modifiers/compare.d.ts.map +1 -0
  17. package/dist/evaluator/modifiers/explode.d.ts +44 -0
  18. package/dist/evaluator/modifiers/explode.d.ts.map +1 -0
  19. package/dist/evaluator/modifiers/keep-drop.d.ts +44 -0
  20. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -0
  21. package/dist/evaluator/modifiers/reroll.d.ts +33 -0
  22. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -0
  23. package/dist/evaluator/modifiers/success-count.d.ts +28 -0
  24. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -0
  25. package/dist/index.d.ts +20 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +1723 -0
  28. package/dist/index.mjs +1724 -0
  29. package/dist/lexer/lexer.d.ts +75 -0
  30. package/dist/lexer/lexer.d.ts.map +1 -0
  31. package/dist/lexer/tokens.d.ts +87 -0
  32. package/dist/lexer/tokens.d.ts.map +1 -0
  33. package/dist/parser/ast.d.ts +183 -0
  34. package/dist/parser/ast.d.ts.map +1 -0
  35. package/dist/parser/parser.d.ts +102 -0
  36. package/dist/parser/parser.d.ts.map +1 -0
  37. package/dist/rng/index.d.ts +8 -0
  38. package/dist/rng/index.d.ts.map +1 -0
  39. package/dist/rng/mock.d.ts +36 -0
  40. package/dist/rng/mock.d.ts.map +1 -0
  41. package/dist/rng/seeded.d.ts +33 -0
  42. package/dist/rng/seeded.d.ts.map +1 -0
  43. package/dist/rng/types.d.ts +24 -0
  44. package/dist/rng/types.d.ts.map +1 -0
  45. package/dist/roll.d.ts +47 -0
  46. package/dist/roll.d.ts.map +1 -0
  47. package/dist/testing.d.ts +11 -0
  48. package/dist/testing.d.ts.map +1 -0
  49. package/dist/testing.js +38 -0
  50. package/dist/testing.mjs +39 -0
  51. package/dist/types.d.ts +105 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/package.json +62 -43
  54. package/src/cli/args.ts +72 -0
  55. package/src/cli/format.ts +45 -0
  56. package/src/cli/index.ts +75 -0
  57. package/src/errors.ts +78 -0
  58. package/src/evaluator/evaluator.ts +837 -0
  59. package/src/evaluator/index.ts +14 -0
  60. package/src/evaluator/modifiers/compare.ts +29 -0
  61. package/src/evaluator/modifiers/explode.ts +215 -0
  62. package/src/evaluator/modifiers/keep-drop.ts +151 -0
  63. package/src/evaluator/modifiers/reroll.ts +172 -0
  64. package/src/evaluator/modifiers/success-count.ts +59 -0
  65. package/src/index.ts +72 -0
  66. package/src/lexer/lexer.ts +281 -0
  67. package/src/lexer/tokens.ts +147 -0
  68. package/src/parser/ast.ts +261 -0
  69. package/src/parser/parser.ts +716 -0
  70. package/src/rng/index.ts +8 -0
  71. package/src/rng/mock.ts +65 -0
  72. package/src/rng/seeded.ts +127 -0
  73. package/src/rng/types.ts +25 -0
  74. package/src/roll.ts +66 -0
  75. package/src/testing.ts +16 -0
  76. package/src/types.ts +112 -0
  77. package/bin/help.txt +0 -19
  78. package/bin/roll-parser.js +0 -23
  79. package/dist/roll-parser.js +0 -697
  80. package/dist/roll-parser.min.js +0 -1
  81. package/index.js +0 -24
  82. package/src/complex/parse.js +0 -37
  83. package/src/complex/parseAndRoll.js +0 -21
  84. package/src/complex/parseAndRollClassic.js +0 -20
  85. package/src/complex/parseAndRollSimple.js +0 -19
  86. package/src/complex/parseAndRollWod.js +0 -20
  87. package/src/complex/parseClassicRoll.js +0 -22
  88. package/src/complex/parseSimpleRoll.js +0 -21
  89. package/src/complex/parseWodRoll.js +0 -21
  90. package/src/converter.js +0 -45
  91. package/src/grammar.js +0 -14
  92. package/src/mapper.js +0 -50
  93. package/src/normalizer.js +0 -51
  94. package/src/object/Result.js +0 -23
  95. package/src/object/Roll.js +0 -26
  96. package/src/object/Type.js +0 -5
  97. package/src/object/WodRoll.js +0 -29
  98. package/src/parser.js +0 -43
  99. package/src/random.js +0 -22
  100. package/src/roller.js +0 -106
  101. package/src/stringifier.js +0 -33
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Evaluator module - AST to roll result transformation.
3
+ *
4
+ * @module evaluator
5
+ */
6
+
7
+ export { DEFAULT_MAX_DICE, evaluate, EvaluatorError } from './evaluator';
8
+ export {
9
+ applyDropHighest,
10
+ applyDropLowest,
11
+ applyKeepHighest,
12
+ applyKeepLowest,
13
+ sumKeptDice,
14
+ } from './modifiers/keep-drop';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared comparison helper for dice-pool modifiers.
3
+ *
4
+ * Both reroll and success-counting modifiers compare a die result against a
5
+ * fixed threshold. Extracting the comparison keeps those modules free of
6
+ * duplicated logic.
7
+ *
8
+ * @module evaluator/modifiers/compare
9
+ */
10
+
11
+ import type { CompareOp } from '../../types';
12
+
13
+ /**
14
+ * Compares a die result against a fixed comparison threshold.
15
+ */
16
+ export function matchesCondition(result: number, operator: CompareOp, value: number): boolean {
17
+ switch (operator) {
18
+ case '>':
19
+ return result > value;
20
+ case '>=':
21
+ return result >= value;
22
+ case '<':
23
+ return result < value;
24
+ case '<=':
25
+ return result <= value;
26
+ case '=':
27
+ return result === value;
28
+ }
29
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Exploding dice modifier implementations.
3
+ *
4
+ * Standard (`!`): re-roll on match, append each new die to the pool.
5
+ * Compound (`!!`): re-roll on match, accumulate into the original die's `result`.
6
+ * Penetrating (`!p`): re-roll on match, append with `result = rawRoll - 1`.
7
+ *
8
+ * @module evaluator/modifiers/explode
9
+ */
10
+
11
+ import type { RNG } from '../../rng/types';
12
+ import type { CompareOp } from '../../types';
13
+ import type { DieResult } from '../../types';
14
+ import { EvaluatorError, type EvalEnv } from '../evaluator';
15
+
16
+ /** Default maximum explosion iterations per die. */
17
+ export const DEFAULT_MAX_EXPLODE_ITERATIONS = 1_000;
18
+
19
+ /**
20
+ * Predicate deciding whether a die with the given latest raw roll continues
21
+ * to explode. `sides` is passed separately because the default predicate
22
+ * ("explode on max face") uses `result === sides`.
23
+ */
24
+ export type ShouldExplode = (rawResult: number, sides: number) => boolean;
25
+
26
+ /**
27
+ * Builds an explosion predicate from an optional ComparePoint. When no
28
+ * threshold is given, explode when the raw result equals the die's max face.
29
+ */
30
+ export function buildShouldExplode(
31
+ operator: CompareOp | undefined,
32
+ thresholdValue: number | undefined,
33
+ ): ShouldExplode {
34
+ if (operator == null || thresholdValue == null) {
35
+ return (result, sides) => result === sides;
36
+ }
37
+ switch (operator) {
38
+ case '>':
39
+ return (result) => result > thresholdValue;
40
+ case '>=':
41
+ return (result) => result >= thresholdValue;
42
+ case '<':
43
+ return (result) => result < thresholdValue;
44
+ case '<=':
45
+ return (result) => result <= thresholdValue;
46
+ case '=':
47
+ return (result) => result === thresholdValue;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Rolls one explosion die, charging it against the global dice limit.
53
+ */
54
+ 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;
63
+ return rng.nextInt(1, sides);
64
+ }
65
+
66
+ function explodeLimitError(maxIterations: number): EvaluatorError {
67
+ return new EvaluatorError(
68
+ `Explode iteration limit of ${maxIterations} exceeded`,
69
+ 'EXPLODE_LIMIT_EXCEEDED',
70
+ 'Explode',
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Returns true when the die is eligible to start exploding: it must not
76
+ * already be dropped by a prior modifier, and its `sides` must be rollable.
77
+ *
78
+ * Fate dice (sides = 0) are skipped defensively — `rng.nextInt(1, 0)` is
79
+ * invalid. Fate + explode is out of scope for the current feature set.
80
+ */
81
+ function canExplode(die: DieResult): boolean {
82
+ if (die.modifiers.includes('dropped')) return false;
83
+ if (die.sides < 1) return false;
84
+ return true;
85
+ }
86
+
87
+ /**
88
+ * Standard explode: append each new die to the pool. The original die keeps
89
+ * its modifiers untouched; new dice carry `['exploded', 'kept']`.
90
+ */
91
+ export function applyStandardExplode(
92
+ pool: DieResult[],
93
+ shouldExplode: ShouldExplode,
94
+ rng: RNG,
95
+ env: EvalEnv,
96
+ ): DieResult[] {
97
+ const result: DieResult[] = [];
98
+
99
+ for (const original of pool) {
100
+ result.push(original);
101
+ if (!canExplode(original)) continue;
102
+
103
+ const sides = original.sides;
104
+ let last = original.result;
105
+ let iterations = 0;
106
+
107
+ while (shouldExplode(last, sides)) {
108
+ if (iterations >= env.maxExplodeIterations) {
109
+ throw explodeLimitError(env.maxExplodeIterations);
110
+ }
111
+ const next = rollExplosion(sides, rng, env);
112
+ result.push({
113
+ sides,
114
+ result: next,
115
+ modifiers: ['exploded', 'kept'],
116
+ critical: next === sides && sides > 1,
117
+ fumble: next === 1,
118
+ });
119
+ last = next;
120
+ iterations += 1;
121
+ }
122
+ }
123
+
124
+ return result;
125
+ }
126
+
127
+ /**
128
+ * Compound explode: pool length stays the same. Each original die's `result`
129
+ * accumulates every explosion roll, and gains the `'exploded'` modifier once
130
+ * it actually exploded at least once.
131
+ */
132
+ export function applyCompoundExplode(
133
+ pool: DieResult[],
134
+ shouldExplode: ShouldExplode,
135
+ rng: RNG,
136
+ env: EvalEnv,
137
+ ): DieResult[] {
138
+ return pool.map((original) => {
139
+ if (!canExplode(original)) return original;
140
+
141
+ const sides = original.sides;
142
+ let accumulated = original.result;
143
+ let last = original.result;
144
+ let iterations = 0;
145
+ let exploded = false;
146
+
147
+ while (shouldExplode(last, sides)) {
148
+ if (iterations >= env.maxExplodeIterations) {
149
+ throw explodeLimitError(env.maxExplodeIterations);
150
+ }
151
+ const next = rollExplosion(sides, rng, env);
152
+ accumulated += next;
153
+ last = next;
154
+ exploded = true;
155
+ iterations += 1;
156
+ }
157
+
158
+ if (!exploded) return original;
159
+
160
+ return {
161
+ sides,
162
+ result: accumulated,
163
+ modifiers: original.modifiers.includes('exploded')
164
+ ? original.modifiers
165
+ : [...original.modifiers, 'exploded'],
166
+ // ? `critical` and `fumble` refer to the original triggering roll, but
167
+ // after compounding the `result` is a sum. Mark critical if the
168
+ // original roll was max; fumble stays as-is (can't accumulate down).
169
+ critical: original.critical,
170
+ fumble: original.fumble,
171
+ };
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Penetrating explode: like standard, but each appended die stores
177
+ * `result = rawRoll - 1`. The explosion predicate runs on the RAW roll
178
+ * (pre-decrement), matching rpg-dice-roller convention.
179
+ */
180
+ export function applyPenetratingExplode(
181
+ pool: DieResult[],
182
+ shouldExplode: ShouldExplode,
183
+ rng: RNG,
184
+ env: EvalEnv,
185
+ ): DieResult[] {
186
+ const result: DieResult[] = [];
187
+
188
+ for (const original of pool) {
189
+ result.push(original);
190
+ if (!canExplode(original)) continue;
191
+
192
+ const sides = original.sides;
193
+ let lastRaw = original.result;
194
+ let iterations = 0;
195
+
196
+ while (shouldExplode(lastRaw, sides)) {
197
+ if (iterations >= env.maxExplodeIterations) {
198
+ throw explodeLimitError(env.maxExplodeIterations);
199
+ }
200
+ const raw = rollExplosion(sides, rng, env);
201
+ const stored = raw - 1;
202
+ result.push({
203
+ sides,
204
+ result: stored,
205
+ modifiers: ['exploded', 'kept'],
206
+ critical: raw === sides && sides > 1,
207
+ fumble: raw === 1,
208
+ });
209
+ lastRaw = raw;
210
+ iterations += 1;
211
+ }
212
+ }
213
+
214
+ return result;
215
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Keep/drop modifier implementations for dice pools.
3
+ *
4
+ * @module evaluator/modifiers/keep-drop
5
+ */
6
+
7
+ import type { DieResult } from '../../types';
8
+
9
+ /**
10
+ * Marks all eligible dice as `'kept'`. Dice that already carry `'dropped'`
11
+ * (e.g., intermediate rerolls, or the loser of a prior modifier) are left
12
+ * untouched so those drops cannot be silently revived.
13
+ */
14
+ export function markAllKept(dice: DieResult[]): DieResult[] {
15
+ return dice.map((die) => {
16
+ if (die.modifiers.includes('dropped')) return die;
17
+ return {
18
+ ...die,
19
+ modifiers: die.modifiers.includes('kept') ? die.modifiers : [...die.modifiers, 'kept'],
20
+ };
21
+ });
22
+ }
23
+
24
+ /**
25
+ * Returns indexed dice that are eligible for keep/drop selection — dice that
26
+ * have not already been dropped by a preceding modifier (e.g. reroll's
27
+ * intermediate dice carry `'dropped'` and must be ignored here).
28
+ */
29
+ function eligibleIndexed(dice: DieResult[]): { die: DieResult; index: number }[] {
30
+ return dice
31
+ .map((die, index) => ({ die, index }))
32
+ .filter(({ die }) => !die.modifiers.includes('dropped'));
33
+ }
34
+
35
+ /**
36
+ * Rebuilds a die's slot flags (`kept` / `dropped`) from the selection set.
37
+ * Pre-dropped dice are returned unchanged so their state is preserved.
38
+ */
39
+ function applySelection(
40
+ dice: DieResult[],
41
+ selectionIndices: Set<number>,
42
+ selectionMarker: 'kept' | 'dropped',
43
+ ): DieResult[] {
44
+ const otherMarker = selectionMarker === 'kept' ? 'dropped' : 'kept';
45
+
46
+ return dice.map((die, index) => {
47
+ if (die.modifiers.includes('dropped')) return die;
48
+
49
+ const isSelected = selectionIndices.has(index);
50
+ const marker = isSelected ? selectionMarker : otherMarker;
51
+
52
+ return {
53
+ ...die,
54
+ modifiers: [...die.modifiers.filter((m) => m !== 'kept' && m !== 'dropped'), marker],
55
+ };
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Drops every non-already-dropped die. Used by keep-N when N <= 0 and by
61
+ * drop-N when N >= eligible.length.
62
+ */
63
+ function markAllDropped(dice: DieResult[]): DieResult[] {
64
+ return dice.map((die) => {
65
+ if (die.modifiers.includes('dropped')) return die;
66
+ return {
67
+ ...die,
68
+ modifiers: [...die.modifiers.filter((m) => m !== 'kept'), 'dropped'],
69
+ };
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Applies keep highest modifier - keeps the N highest eligible dice, marks
75
+ * others as dropped. Dice already marked `'dropped'` are left unchanged.
76
+ *
77
+ * @param dice - Array of die results
78
+ * @param count - Number of dice to keep
79
+ * @returns New array with appropriate modifiers applied
80
+ */
81
+ export function applyKeepHighest(dice: DieResult[], count: number): DieResult[] {
82
+ const eligible = eligibleIndexed(dice);
83
+
84
+ if (count >= eligible.length) return markAllKept(dice);
85
+ if (count <= 0) return markAllDropped(dice);
86
+
87
+ const sorted = [...eligible].sort((a, b) => b.die.result - a.die.result);
88
+ const keptIndices = new Set(sorted.slice(0, count).map((item) => item.index));
89
+
90
+ return applySelection(dice, keptIndices, 'kept');
91
+ }
92
+
93
+ /**
94
+ * Applies keep lowest modifier - keeps the N lowest eligible dice, marks
95
+ * others as dropped. Dice already marked `'dropped'` are left unchanged.
96
+ */
97
+ export function applyKeepLowest(dice: DieResult[], count: number): DieResult[] {
98
+ const eligible = eligibleIndexed(dice);
99
+
100
+ if (count >= eligible.length) return markAllKept(dice);
101
+ if (count <= 0) return markAllDropped(dice);
102
+
103
+ const sorted = [...eligible].sort((a, b) => a.die.result - b.die.result);
104
+ const keptIndices = new Set(sorted.slice(0, count).map((item) => item.index));
105
+
106
+ return applySelection(dice, keptIndices, 'kept');
107
+ }
108
+
109
+ /**
110
+ * Applies drop highest modifier - drops the N highest eligible dice, keeps
111
+ * the rest. Dice already marked `'dropped'` are left unchanged.
112
+ */
113
+ export function applyDropHighest(dice: DieResult[], count: number): DieResult[] {
114
+ const eligible = eligibleIndexed(dice);
115
+
116
+ if (count <= 0) return markAllKept(dice);
117
+ if (count >= eligible.length) return markAllDropped(dice);
118
+
119
+ const sorted = [...eligible].sort((a, b) => b.die.result - a.die.result);
120
+ const droppedIndices = new Set(sorted.slice(0, count).map((item) => item.index));
121
+
122
+ return applySelection(dice, droppedIndices, 'dropped');
123
+ }
124
+
125
+ /**
126
+ * Applies drop lowest modifier - drops the N lowest eligible dice, keeps
127
+ * the rest. Dice already marked `'dropped'` are left unchanged.
128
+ */
129
+ export function applyDropLowest(dice: DieResult[], count: number): DieResult[] {
130
+ const eligible = eligibleIndexed(dice);
131
+
132
+ if (count <= 0) return markAllKept(dice);
133
+ if (count >= eligible.length) return markAllDropped(dice);
134
+
135
+ const sorted = [...eligible].sort((a, b) => a.die.result - b.die.result);
136
+ const droppedIndices = new Set(sorted.slice(0, count).map((item) => item.index));
137
+
138
+ return applySelection(dice, droppedIndices, 'dropped');
139
+ }
140
+
141
+ /**
142
+ * Calculates total from dice, excluding dropped dice.
143
+ *
144
+ * @param dice - Array of die results
145
+ * @returns Sum of non-dropped dice
146
+ */
147
+ export function sumKeptDice(dice: DieResult[]): number {
148
+ return dice
149
+ .filter((die) => !die.modifiers.includes('dropped'))
150
+ .reduce((sum, die) => sum + die.result, 0);
151
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Reroll modifier implementations.
3
+ *
4
+ * Recursive (`r`): re-roll matching dice until the condition no longer holds,
5
+ * or the per-die iteration limit is reached.
6
+ * Reroll-once (`ro`): re-roll matching dice exactly once, keeping the second
7
+ * result regardless of whether it matches.
8
+ *
9
+ * @module evaluator/modifiers/reroll
10
+ */
11
+
12
+ import type { RNG } from '../../rng/types';
13
+ import type { CompareOp, DieResult } from '../../types';
14
+ import { EvaluatorError, type EvalEnv } from '../evaluator';
15
+ import { matchesCondition } from './compare';
16
+
17
+ /** Default maximum reroll iterations per die. */
18
+ export const DEFAULT_MAX_REROLL_ITERATIONS = 1_000;
19
+
20
+ export { matchesCondition };
21
+
22
+ /**
23
+ * Rolls a replacement die for the given sides, charging it against the global
24
+ * dice limit. Fate dice (sides === 0) re-roll on the {-1, 0, +1} range.
25
+ */
26
+ function rollReplacement(sides: number, rng: RNG, env: EvalEnv): DieResult {
27
+ if (env.totalDiceRolled + 1 > env.maxDice) {
28
+ throw new EvaluatorError(
29
+ `Total dice count ${env.totalDiceRolled + 1} exceeds limit of ${env.maxDice}`,
30
+ 'DICE_LIMIT_EXCEEDED',
31
+ 'Reroll',
32
+ );
33
+ }
34
+ env.totalDiceRolled += 1;
35
+
36
+ if (sides === 0) {
37
+ const result = rng.nextInt(-1, 1);
38
+ return {
39
+ sides: 0,
40
+ result,
41
+ modifiers: [],
42
+ critical: false,
43
+ fumble: false,
44
+ };
45
+ }
46
+
47
+ const result = rng.nextInt(1, sides);
48
+ return {
49
+ sides,
50
+ result,
51
+ modifiers: [],
52
+ critical: result === sides && sides > 1,
53
+ fumble: result === 1,
54
+ };
55
+ }
56
+
57
+ function rerollLimitError(maxIterations: number): EvaluatorError {
58
+ return new EvaluatorError(
59
+ `Reroll iteration limit of ${maxIterations} exceeded`,
60
+ 'REROLL_LIMIT_EXCEEDED',
61
+ 'Reroll',
62
+ );
63
+ }
64
+
65
+ /**
66
+ * True for dice eligible to start rerolling. Dropped dice (from a preceding
67
+ * keep/drop modifier) are left alone.
68
+ */
69
+ function canReroll(die: DieResult): boolean {
70
+ return !die.modifiers.includes('dropped');
71
+ }
72
+
73
+ /**
74
+ * Returns the die's modifiers with any "slot" flags removed. Slot flags
75
+ * (`kept`, `dropped`, `rerolled`) are controlled by the surrounding logic
76
+ * and should be reassigned each pass.
77
+ */
78
+ function stripSlotFlags(modifiers: DieResult['modifiers']): DieResult['modifiers'] {
79
+ return modifiers.filter((m) => m !== 'kept' && m !== 'dropped' && m !== 'rerolled');
80
+ }
81
+
82
+ /**
83
+ * Applies recursive reroll: re-roll each matching die until it no longer
84
+ * matches or the per-die iteration limit is reached. Intermediate dice are
85
+ * appended to the output pool with `['rerolled', 'dropped']` so they:
86
+ * 1. Render as strikethrough (via `renderDice`'s `'dropped'` check).
87
+ * 2. Are excluded from `sumKeptDice`.
88
+ * 3. Are ignored by subsequent keep/drop modifiers.
89
+ */
90
+ export function applyRecursiveReroll(
91
+ pool: DieResult[],
92
+ operator: CompareOp,
93
+ value: number,
94
+ rng: RNG,
95
+ env: EvalEnv,
96
+ ): DieResult[] {
97
+ const result: DieResult[] = [];
98
+
99
+ for (const original of pool) {
100
+ if (!canReroll(original)) {
101
+ result.push(original);
102
+ continue;
103
+ }
104
+
105
+ let current = original;
106
+ let iterations = 0;
107
+
108
+ while (matchesCondition(current.result, operator, value)) {
109
+ if (iterations >= env.maxRerollIterations) {
110
+ throw rerollLimitError(env.maxRerollIterations);
111
+ }
112
+
113
+ result.push({
114
+ ...current,
115
+ modifiers: [...stripSlotFlags(current.modifiers), 'rerolled', 'dropped'],
116
+ });
117
+
118
+ current = rollReplacement(current.sides, rng, env);
119
+ iterations += 1;
120
+ }
121
+
122
+ result.push({
123
+ ...current,
124
+ modifiers: [...stripSlotFlags(current.modifiers), 'kept'],
125
+ });
126
+ }
127
+
128
+ return result;
129
+ }
130
+
131
+ /**
132
+ * Applies reroll-once: re-roll each matching die exactly once, keeping the
133
+ * second result regardless of whether it matches. Non-matching dice pass
134
+ * through with the `'kept'` slot flag.
135
+ */
136
+ export function applyRerollOnce(
137
+ pool: DieResult[],
138
+ operator: CompareOp,
139
+ value: number,
140
+ rng: RNG,
141
+ env: EvalEnv,
142
+ ): DieResult[] {
143
+ const result: DieResult[] = [];
144
+
145
+ for (const original of pool) {
146
+ if (!canReroll(original)) {
147
+ result.push(original);
148
+ continue;
149
+ }
150
+
151
+ if (!matchesCondition(original.result, operator, value)) {
152
+ result.push({
153
+ ...original,
154
+ modifiers: [...stripSlotFlags(original.modifiers), 'kept'],
155
+ });
156
+ continue;
157
+ }
158
+
159
+ result.push({
160
+ ...original,
161
+ modifiers: [...stripSlotFlags(original.modifiers), 'rerolled', 'dropped'],
162
+ });
163
+
164
+ const replacement = rollReplacement(original.sides, rng, env);
165
+ result.push({
166
+ ...replacement,
167
+ modifiers: [...stripSlotFlags(replacement.modifiers), 'kept'],
168
+ });
169
+ }
170
+
171
+ return result;
172
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Success counting modifier.
3
+ *
4
+ * Transforms a dice pool into a signed success count: each die matching the
5
+ * success `threshold` contributes +1, each die matching the optional
6
+ * `failThreshold` contributes -1. When a single die matches both thresholds,
7
+ * success wins (checked first).
8
+ *
9
+ * Dropped dice (from a preceding keep/drop or intermediate reroll) are
10
+ * excluded from counting and are never tagged.
11
+ *
12
+ * Mutates the input pool in place to add `'success'` / `'failure'` modifier
13
+ * flags — mirrors the mutation pattern of explode and reroll modifiers.
14
+ *
15
+ * @module evaluator/modifiers/success-count
16
+ */
17
+
18
+ import type { CompareOp, DieResult } from '../../types';
19
+ import { matchesCondition } from './compare';
20
+
21
+ export type ResolvedThreshold = {
22
+ operator: CompareOp;
23
+ value: number;
24
+ };
25
+
26
+ export type SuccessCountResult = {
27
+ total: number;
28
+ successes: number;
29
+ failures: number;
30
+ };
31
+
32
+ export function countSuccesses(
33
+ dice: DieResult[],
34
+ threshold: ResolvedThreshold,
35
+ failThreshold?: ResolvedThreshold,
36
+ ): SuccessCountResult {
37
+ let successes = 0;
38
+ let failures = 0;
39
+
40
+ for (const die of dice) {
41
+ if (die.modifiers.includes('dropped')) continue;
42
+
43
+ if (matchesCondition(die.result, threshold.operator, threshold.value)) {
44
+ die.modifiers.push('success');
45
+ successes += 1;
46
+ continue;
47
+ }
48
+
49
+ if (
50
+ failThreshold != null &&
51
+ matchesCondition(die.result, failThreshold.operator, failThreshold.value)
52
+ ) {
53
+ die.modifiers.push('failure');
54
+ failures += 1;
55
+ }
56
+ }
57
+
58
+ return { total: successes - failures, successes, failures };
59
+ }