roll-parser 3.0.0-beta.0 → 3.0.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 (159) hide show
  1. package/CHANGELOG.md +107 -9
  2. package/MIGRATION.md +147 -0
  3. package/README.md +960 -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 +17 -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 +26 -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 +58 -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 +906 -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 +0 -1
  39. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/crit-threshold.js +23 -0
  41. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  42. package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
  43. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.js +14 -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 +103 -0
  49. package/dist/evaluator/modifiers/explode.js.map +1 -0
  50. package/dist/evaluator/modifiers/flags.d.ts +37 -0
  51. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  52. package/dist/evaluator/modifiers/flags.js +18 -0
  53. package/dist/evaluator/modifiers/flags.js.map +1 -0
  54. package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
  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 +62 -0
  61. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  62. package/dist/evaluator/modifiers/sort.d.ts +4 -0
  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 +2 -6
  67. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  68. package/dist/evaluator/modifiers/success-count.js +24 -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 +314 -160
  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/rng/mock.d.ts +73 -12
  95. package/dist/rng/mock.d.ts.map +1 -1
  96. package/dist/rng/mock.js +30 -0
  97. package/dist/rng/mock.js.map +1 -0
  98. package/dist/rng/seeded.d.ts +141 -9
  99. package/dist/rng/seeded.d.ts.map +1 -1
  100. package/dist/rng/seeded.js +138 -0
  101. package/dist/rng/seeded.js.map +1 -0
  102. package/dist/rng/types.d.ts +57 -0
  103. package/dist/rng/types.d.ts.map +1 -1
  104. package/dist/rng/types.js +2 -0
  105. package/dist/rng/types.js.map +1 -0
  106. package/dist/roll.d.ts +58 -28
  107. package/dist/roll.d.ts.map +1 -1
  108. package/dist/roll.js +8 -0
  109. package/dist/roll.js.map +1 -0
  110. package/dist/testing.d.ts +5 -4
  111. package/dist/testing.d.ts.map +1 -1
  112. package/dist/testing.js +2 -41
  113. package/dist/testing.js.map +1 -11
  114. package/dist/types.d.ts +306 -44
  115. package/dist/types.d.ts.map +1 -1
  116. package/dist/types.js +8 -0
  117. package/dist/types.js.map +1 -0
  118. package/dist/version.d.ts +2 -0
  119. package/dist/version.d.ts.map +1 -0
  120. package/dist/version.js +2 -0
  121. package/dist/version.js.map +1 -0
  122. package/package.json +83 -34
  123. package/src/cli/args.ts +66 -10
  124. package/src/cli/format.ts +22 -3
  125. package/src/cli/index.ts +27 -84
  126. package/src/cli/main.ts +129 -0
  127. package/src/errors.ts +480 -27
  128. package/src/evaluator/die.ts +50 -0
  129. package/src/evaluator/env.ts +73 -0
  130. package/src/evaluator/evaluator.ts +653 -421
  131. package/src/evaluator/modifiers/crit-threshold.ts +3 -6
  132. package/src/evaluator/modifiers/die-bound.ts +39 -0
  133. package/src/evaluator/modifiers/explode.ts +60 -58
  134. package/src/evaluator/modifiers/flags.ts +61 -0
  135. package/src/evaluator/modifiers/keep-drop.ts +124 -126
  136. package/src/evaluator/modifiers/reroll.ts +28 -49
  137. package/src/evaluator/modifiers/sort.ts +14 -1
  138. package/src/evaluator/modifiers/success-count.ts +5 -8
  139. package/src/index.ts +56 -35
  140. package/src/lexer/lexer.ts +107 -34
  141. package/src/lexer/tokens.ts +31 -6
  142. package/src/parser/ast.ts +323 -341
  143. package/src/parser/guards.ts +248 -0
  144. package/src/parser/parser.ts +419 -242
  145. package/src/rng/mock.ts +74 -13
  146. package/src/rng/seeded.ts +299 -64
  147. package/src/rng/types.ts +57 -0
  148. package/src/roll.ts +64 -47
  149. package/src/testing.ts +5 -9
  150. package/src/types.ts +310 -43
  151. package/src/version.ts +2 -0
  152. package/dist/cli.js +0 -2608
  153. package/dist/cli.js.map +0 -28
  154. package/dist/evaluator/index.d.ts +0 -8
  155. package/dist/evaluator/index.d.ts.map +0 -1
  156. package/dist/rng/index.d.ts +0 -8
  157. package/dist/rng/index.d.ts.map +0 -1
  158. package/src/evaluator/index.ts +0 -14
  159. package/src/rng/index.ts +0 -8
@@ -19,10 +19,7 @@
19
19
 
20
20
  import type { DieResult, ResolvedCritThreshold } from '../../types.js';
21
21
  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';
22
+ import { isVersusDc } from './flags.js';
26
23
 
27
24
  /**
28
25
  * Applies success/fail threshold arrays to a dice pool, overriding each
@@ -36,7 +33,7 @@ export function applyCritThresholds(
36
33
  failThresholds: ResolvedCritThreshold[],
37
34
  ): void {
38
35
  for (const die of dice) {
39
- if (die.modifiers.includes('meta')) continue;
36
+ if (die.modifiers.includes('meta') || isVersusDc(die)) continue;
40
37
 
41
38
  die.critical = successThresholds.some((t) => matchesCrit(t, die));
42
39
  die.fumble = failThresholds.some((t) => matchesFumble(t, die));
@@ -52,7 +49,7 @@ function matchesCrit(threshold: ResolvedCritThreshold, die: DieResult): boolean
52
49
 
53
50
  function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult): boolean {
54
51
  if (threshold === 'default') {
55
- // ? Mirrors `createDieResult` — a d1 always rolls 1, never a fumble.
52
+ // Mirrors `createDieResult` — a d1 always rolls 1, never a fumble.
56
53
  return die.result === 1 && die.sides > 1;
57
54
  }
58
55
  return matchesCondition(die.result, threshold.operator, threshold.value);
@@ -0,0 +1,39 @@
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.
10
+ *
11
+ * `critical` / `fumble` keep reflecting the natural face — a clamped 1 is
12
+ * still a fumble, matching the raw-face crit semantics used everywhere else.
13
+ * Meta dice (rolled to compute counts/sides/modifier args) are skipped.
14
+ * Dropped dice are clamped too — they are excluded from totals anyway, and
15
+ * clamping them keeps the rendered pool consistent.
16
+ *
17
+ * @module evaluator/modifiers/die-bound
18
+ */
19
+
20
+ import type { DieResult } from '../../types.js';
21
+ import { isVersusDc } from './flags.js';
22
+
23
+ /**
24
+ * Clamps every non-meta die in `dice` against `value`, in place.
25
+ * `bound: 'min'` lifts lower results up to `value`; `'max'` caps higher
26
+ * results down to it. Untouched dice keep their tags.
27
+ */
28
+ export function applyDieBound(dice: DieResult[], bound: 'min' | 'max', value: number): void {
29
+ for (const die of dice) {
30
+ if (die.modifiers.includes('meta') || isVersusDc(die)) continue;
31
+
32
+ const clamped = bound === 'min' ? Math.max(die.result, value) : Math.min(die.result, value);
33
+ if (clamped === die.result) continue;
34
+
35
+ die.initialResult ??= die.result;
36
+ die.result = clamped;
37
+ die.modifiers.push(bound);
38
+ }
39
+ }
@@ -8,12 +8,22 @@
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 { isVersusDc } from './flags.js';
15
17
 
16
- /** Default maximum explosion iterations per die. */
18
+ /**
19
+ * Default value of `EvaluationOptions.maxExplodeIterations`: how many times a
20
+ * single die may explode before `EXPLODE_LIMIT_EXCEEDED` is thrown.
21
+ *
22
+ * Per die, not per pool — it bounds notations like `1d1!`, where the
23
+ * explosion condition can never stop being true.
24
+ *
25
+ * @category Limits
26
+ */
17
27
  export const DEFAULT_MAX_EXPLODE_ITERATIONS = 1_000;
18
28
 
19
29
  /**
@@ -52,14 +62,7 @@ export function buildShouldExplode(
52
62
  * Rolls one explosion die, charging it against the global dice limit.
53
63
  */
54
64
  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;
65
+ chargeDie(env, 'Explode');
63
66
  return rng.nextInt(1, sides);
64
67
  }
65
68
 
@@ -82,20 +85,27 @@ function explodeLimitError(maxIterations: number): EvaluatorError {
82
85
  * can never be reached if a future AST path slips past the parser gate.
83
86
  */
84
87
  function canExplode(die: DieResult): boolean {
88
+ if (isVersusDc(die)) return false;
85
89
  if (die.modifiers.includes('dropped')) return false;
86
90
  if (die.sides < 1) return false;
87
91
  return true;
88
92
  }
89
93
 
90
94
  /**
91
- * Standard explode: append each new die to the pool. The original die keeps
92
- * its modifiers untouched; new dice carry `['exploded', 'kept']`.
95
+ * Shared implementation of the two appending explode variants. The explosion
96
+ * predicate always runs on the RAW roll; `storeResult` maps that raw roll to
97
+ * the value recorded on the appended die, which is the only thing standard
98
+ * and penetrating explosions disagree about.
99
+ *
100
+ * `critical`/`fumble` are likewise derived from the raw roll — a penetrating
101
+ * die that rolled its max face is still a crit even though it stores one less.
93
102
  */
94
- export function applyStandardExplode(
103
+ function applyAppendingExplode(
95
104
  pool: DieResult[],
96
105
  shouldExplode: ShouldExplode,
97
106
  rng: RNG,
98
107
  env: EvalEnv,
108
+ storeResult: (raw: number) => number,
99
109
  ): DieResult[] {
100
110
  const result: DieResult[] = [];
101
111
 
@@ -104,22 +114,18 @@ export function applyStandardExplode(
104
114
  if (!canExplode(original)) continue;
105
115
 
106
116
  const sides = original.sides;
107
- let last = original.result;
117
+ let lastRaw = original.result;
108
118
  let iterations = 0;
109
119
 
110
- while (shouldExplode(last, sides)) {
120
+ while (shouldExplode(lastRaw, sides)) {
111
121
  if (iterations >= env.maxExplodeIterations) {
112
122
  throw explodeLimitError(env.maxExplodeIterations);
113
123
  }
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;
124
+ const raw = rollExplosion(sides, rng, env);
125
+ const die = createDieResult(sides, raw, ['exploded', 'kept']);
126
+ die.result = storeResult(raw);
127
+ result.push(die);
128
+ lastRaw = raw;
123
129
  iterations += 1;
124
130
  }
125
131
  }
@@ -127,6 +133,29 @@ export function applyStandardExplode(
127
133
  return result;
128
134
  }
129
135
 
136
+ /** Records the raw roll unchanged — standard explode. */
137
+ function keepRaw(raw: number): number {
138
+ return raw;
139
+ }
140
+
141
+ /** Records one less than the raw roll — penetrating explode. */
142
+ function penetratingPenalty(raw: number): number {
143
+ return raw - 1;
144
+ }
145
+
146
+ /**
147
+ * Standard explode: append each new die to the pool. The original die keeps
148
+ * its modifiers untouched; new dice carry `['exploded', 'kept']`.
149
+ */
150
+ export function applyStandardExplode(
151
+ pool: DieResult[],
152
+ shouldExplode: ShouldExplode,
153
+ rng: RNG,
154
+ env: EvalEnv,
155
+ ): DieResult[] {
156
+ return applyAppendingExplode(pool, shouldExplode, rng, env, keepRaw);
157
+ }
158
+
130
159
  /**
131
160
  * Compound explode: pool length stays the same. Each original die's `result`
132
161
  * accumulates every explosion roll, and gains the `'exploded'` modifier once
@@ -164,8 +193,8 @@ export function applyCompoundExplode(
164
193
 
165
194
  if (!exploded) continue;
166
195
 
167
- // ? `critical` and `fumble` keep referring to the original triggering
168
- // roll — after compounding the `result` is a sum.
196
+ // `critical` and `fumble` keep referring to the original triggering
197
+ // roll — after compounding the `result` is a sum.
169
198
  original.initialResult = original.result;
170
199
  original.result = accumulated;
171
200
  if (!original.modifiers.includes('exploded')) {
@@ -179,7 +208,8 @@ export function applyCompoundExplode(
179
208
  /**
180
209
  * Penetrating explode: like standard, but each appended die stores
181
210
  * `result = rawRoll - 1`. The explosion predicate runs on the RAW roll
182
- * (pre-decrement), matching rpg-dice-roller convention.
211
+ * (pre-decrement), so a max face still chains even though its stored value
212
+ * is one lower.
183
213
  */
184
214
  export function applyPenetratingExplode(
185
215
  pool: DieResult[],
@@ -187,33 +217,5 @@ export function applyPenetratingExplode(
187
217
  rng: RNG,
188
218
  env: EvalEnv,
189
219
  ): 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;
220
+ return applyAppendingExplode(pool, shouldExplode, rng, env, penetratingPenalty);
219
221
  }
@@ -0,0 +1,61 @@
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
+ export function isVersusDc(die: DieResult): boolean {
25
+ return die.modifiers.includes('dc');
26
+ }
27
+
28
+ /** Kept/dropped selection flags — rebuilt by every keep/drop pass. */
29
+ export const SELECTION_FLAGS: readonly DieModifier[] = ['kept', 'dropped'];
30
+
31
+ /**
32
+ * Selection flags plus the success-count tally flags. Stripped when a die
33
+ * leaves the pool that tagged it (meta sub-expressions, dropped group
34
+ * sub-rolls) so the top-level successes/failures scan cannot count it.
35
+ */
36
+ export const SELECTION_AND_TALLY_FLAGS: readonly DieModifier[] = [
37
+ 'kept',
38
+ 'dropped',
39
+ 'success',
40
+ 'failure',
41
+ ];
42
+
43
+ /** Selection flags plus `rerolled` — reassigned on every reroll pass. */
44
+ export const REROLL_SLOT_FLAGS: readonly DieModifier[] = ['kept', 'dropped', 'rerolled'];
45
+
46
+ /** Returns `modifiers` with every flag in `excluded` removed. */
47
+ export function stripFlags(
48
+ modifiers: readonly DieModifier[],
49
+ excluded: readonly DieModifier[],
50
+ ): DieModifier[] {
51
+ return modifiers.filter((modifier) => !excluded.includes(modifier));
52
+ }
53
+
54
+ /** Returns `modifiers` with `excluded` removed and `added` appended, in order. */
55
+ export function rewriteFlags(
56
+ modifiers: readonly DieModifier[],
57
+ excluded: readonly DieModifier[],
58
+ ...added: DieModifier[]
59
+ ): DieModifier[] {
60
+ return [...stripFlags(modifiers, excluded), ...added];
61
+ }
@@ -4,138 +4,131 @@
4
4
  * @module evaluator/modifiers/keep-drop
5
5
  */
6
6
 
7
- import type { DieResult } from '../../types.js';
7
+ import type { DieResult, KeepDropSpec } from '../../types.js';
8
+ import { isVersusDc } from './flags.js';
8
9
 
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
- }
10
+ /** One selectable die: its rolled value and its slot in the original pool. */
11
+ type EligibleDie = { result: number; index: number };
23
12
 
24
13
  /**
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.
14
+ * Records into `droppedMask` every pool slot that `kind` / `selector` /
15
+ * `count` drops.
76
16
  *
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.
17
+ * Nothing is cloned and no flag is written. The caller owns the merge, so a
18
+ * chain like `4d6kh3dl1` runs one pass per spec over the same mask and then
19
+ * rewrites each die's slot flags exactly once — where the previous
20
+ * clone-per-spec appliers rebuilt the whole pool for every spec only to have
21
+ * their flags read back and discarded.
22
+ *
23
+ * Dice already carrying `'dropped'` (reroll intermediates, meta dice, a
24
+ * preceding chain's losers) are ineligible for selection and stay dropped.
112
25
  */
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');
26
+ export function markDroppedIndices(
27
+ dice: DieResult[],
28
+ count: number,
29
+ kind: KeepDropSpec['kind'],
30
+ selector: KeepDropSpec['selector'],
31
+ droppedMask: Uint8Array,
32
+ ): void {
33
+ if (count === 1) {
34
+ markSingleExtreme(dice, kind, selector, droppedMask);
35
+ return;
36
+ }
37
+
38
+ const eligible: EligibleDie[] = [];
39
+
40
+ for (let index = 0; index < dice.length; index++) {
41
+ const die = dice[index];
42
+ if (die == null) continue;
43
+ if (isVersusDc(die)) continue;
44
+
45
+ if (die.modifiers.includes('dropped')) {
46
+ droppedMask[index] = 1;
47
+ continue;
48
+ }
49
+
50
+ eligible.push({ result: die.result, index });
51
+ }
52
+
53
+ const isKeep = kind === 'keep';
54
+
55
+ // Keep-everything: a keep covering the whole eligible pool, or a zero drop.
56
+ if (isKeep ? count >= eligible.length : count <= 0) return;
57
+
58
+ // Drop-everything: a zero keep, or a drop covering the whole eligible pool.
59
+ if (isKeep ? count <= 0 : count >= eligible.length) {
60
+ for (const item of eligible) {
61
+ droppedMask[item.index] = 1;
62
+ }
63
+ return;
64
+ }
65
+
66
+ // Stable sort — ties resolve by original pool order.
67
+ eligible.sort(
68
+ selector === 'highest' ? (a, b) => b.result - a.result : (a, b) => a.result - b.result,
69
+ );
70
+
71
+ // The sort puts the acted-on dice first: `keep` drops everything past
72
+ // `count`, `drop` drops the selection itself.
73
+ const start = isKeep ? count : 0;
74
+ const end = isKeep ? eligible.length : count;
75
+
76
+ for (let i = start; i < end; i++) {
77
+ const item = eligible[i];
78
+ if (item != null) droppedMask[item.index] = 1;
79
+ }
123
80
  }
124
81
 
125
82
  /**
126
- * Applies drop lowest modifier - drops the N lowest eligible dice, keeps
127
- * the rest. Dice already marked `'dropped'` are left unchanged.
83
+ * `count === 1` fast path: a single linear scan replaces the wrapper array
84
+ * and comparator sort `2d20kh1` (advantage) and `4d6dl1` are the most
85
+ * common notations. Strict comparison preserves the stable sort's
86
+ * first-occurrence tie-break, and bits are only ever set, never cleared, so
87
+ * a shared mask keeps every previous spec's drops.
128
88
  */
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');
89
+ function markSingleExtreme(
90
+ dice: DieResult[],
91
+ kind: KeepDropSpec['kind'],
92
+ selector: KeepDropSpec['selector'],
93
+ droppedMask: Uint8Array,
94
+ ): void {
95
+ const isKeep = kind === 'keep';
96
+ const wantHighest = selector === 'highest';
97
+
98
+ let extremeIndex = -1;
99
+ let extremeResult = 0;
100
+
101
+ for (let index = 0; index < dice.length; index++) {
102
+ const die = dice[index];
103
+ if (die == null) continue;
104
+ if (isVersusDc(die)) continue;
105
+
106
+ if (die.modifiers.includes('dropped')) {
107
+ droppedMask[index] = 1;
108
+ continue;
109
+ }
110
+
111
+ const { result } = die;
112
+
113
+ if (extremeIndex === -1) {
114
+ extremeIndex = index;
115
+ extremeResult = result;
116
+ continue;
117
+ }
118
+
119
+ if (wantHighest ? result > extremeResult : result < extremeResult) {
120
+ // A keep drops the dethroned extreme; a drop keeps everything else.
121
+ if (isKeep) droppedMask[extremeIndex] = 1;
122
+ extremeIndex = index;
123
+ extremeResult = result;
124
+ } else if (isKeep) {
125
+ droppedMask[index] = 1;
126
+ }
127
+ }
128
+
129
+ // Keeping 1 of ≤1 eligible dice drops nothing; dropping 1 of ≥1 drops the
130
+ // extreme — both match the general path's whole-pool guards.
131
+ if (!isKeep && extremeIndex !== -1) droppedMask[extremeIndex] = 1;
139
132
  }
140
133
 
141
134
  /**
@@ -145,7 +138,12 @@ export function applyDropLowest(dice: DieResult[], count: number): DieResult[] {
145
138
  * @returns Sum of non-dropped dice
146
139
  */
147
140
  export function sumKeptDice(dice: DieResult[]): number {
148
- return dice
149
- .filter((die) => !die.modifiers.includes('dropped'))
150
- .reduce((sum, die) => sum + die.result, 0);
141
+ let total = 0;
142
+
143
+ for (const die of dice) {
144
+ if (isVersusDc(die)) continue;
145
+ if (!die.modifiers.includes('dropped')) total += die.result;
146
+ }
147
+
148
+ return total;
151
149
  }