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
@@ -4,148 +4,150 @@
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
+ hasVersusDc: boolean,
33
+ ): void {
34
+ if (count === 1) {
35
+ markSingleExtreme(dice, kind, selector, droppedMask, hasVersusDc);
36
+ return;
37
+ }
38
+
39
+ const eligible: EligibleDie[] = [];
40
+
41
+ for (let index = 0; index < dice.length; index++) {
42
+ const die = dice[index];
43
+ if (die == null) continue;
44
+ if (hasVersusDc && isVersusDc(die)) continue;
45
+
46
+ if (die.modifiers.includes('dropped')) {
47
+ droppedMask[index] = 1;
48
+ continue;
49
+ }
50
+
51
+ eligible.push({ result: die.result, index });
52
+ }
53
+
54
+ const isKeep = kind === 'keep';
55
+
56
+ // Keep-everything: a keep covering the whole eligible pool, or a zero drop.
57
+ if (isKeep ? count >= eligible.length : count <= 0) return;
58
+
59
+ // Drop-everything: a zero keep, or a drop covering the whole eligible pool.
60
+ if (isKeep ? count <= 0 : count >= eligible.length) {
61
+ for (const item of eligible) {
62
+ droppedMask[item.index] = 1;
63
+ }
64
+ return;
65
+ }
66
+
67
+ // Stable sort — ties resolve by original pool order.
68
+ eligible.sort(
69
+ selector === 'highest' ? (a, b) => b.result - a.result : (a, b) => a.result - b.result,
70
+ );
71
+
72
+ // The sort puts the acted-on dice first: `keep` drops everything past
73
+ // `count`, `drop` drops the selection itself.
74
+ const start = isKeep ? count : 0;
75
+ const end = isKeep ? eligible.length : count;
76
+
77
+ for (let i = start; i < end; i++) {
78
+ const item = eligible[i];
79
+ if (item != null) droppedMask[item.index] = 1;
80
+ }
123
81
  }
124
82
 
125
83
  /**
126
- * Applies drop lowest modifier - drops the N lowest eligible dice, keeps
127
- * the rest. Dice already marked `'dropped'` are left unchanged.
84
+ * `count === 1` fast path: a single linear scan replaces the wrapper array
85
+ * and comparator sort `2d20kh1` (advantage) and `4d6dl1` are the most
86
+ * common notations. Strict comparison preserves the stable sort's
87
+ * first-occurrence tie-break, and bits are only ever set, never cleared, so
88
+ * a shared mask keeps every previous spec's drops.
128
89
  */
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');
90
+ function markSingleExtreme(
91
+ dice: DieResult[],
92
+ kind: KeepDropSpec['kind'],
93
+ selector: KeepDropSpec['selector'],
94
+ droppedMask: Uint8Array,
95
+ hasVersusDc: boolean,
96
+ ): void {
97
+ const isKeep = kind === 'keep';
98
+ const wantHighest = selector === 'highest';
99
+
100
+ let extremeIndex = -1;
101
+ let extremeResult = 0;
102
+
103
+ for (let index = 0; index < dice.length; index++) {
104
+ const die = dice[index];
105
+ if (die == null) continue;
106
+ if (hasVersusDc && isVersusDc(die)) continue;
107
+
108
+ if (die.modifiers.includes('dropped')) {
109
+ droppedMask[index] = 1;
110
+ continue;
111
+ }
112
+
113
+ const { result } = die;
114
+
115
+ if (extremeIndex === -1) {
116
+ extremeIndex = index;
117
+ extremeResult = result;
118
+ continue;
119
+ }
120
+
121
+ if (wantHighest ? result > extremeResult : result < extremeResult) {
122
+ // A keep drops the dethroned extreme; a drop keeps everything else.
123
+ if (isKeep) droppedMask[extremeIndex] = 1;
124
+ extremeIndex = index;
125
+ extremeResult = result;
126
+ } else if (isKeep) {
127
+ droppedMask[index] = 1;
128
+ }
129
+ }
130
+
131
+ // Keeping 1 of ≤1 eligible dice drops nothing; dropping 1 of ≥1 drops the
132
+ // extreme — both match the general path's whole-pool guards.
133
+ if (!isKeep && extremeIndex !== -1) droppedMask[extremeIndex] = 1;
139
134
  }
140
135
 
141
136
  /**
142
137
  * Calculates total from dice, excluding dropped dice.
143
138
  *
144
139
  * @param dice - Array of die results
140
+ * @param hasVersusDc - Shared env flag; skips the DC exclusion when no `vs` has
141
+ * tagged anything
145
142
  * @returns Sum of non-dropped dice
146
143
  */
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);
144
+ export function sumKeptDice(dice: DieResult[], hasVersusDc: boolean): number {
145
+ let total = 0;
146
+
147
+ for (const die of dice) {
148
+ if (hasVersusDc && isVersusDc(die)) continue;
149
+ if (!die.modifiers.includes('dropped')) total += die.result;
150
+ }
151
+
152
+ return total;
151
153
  }
@@ -9,49 +9,45 @@
9
9
  * @module evaluator/modifiers/reroll
10
10
  */
11
11
 
12
+ import { EvaluatorError } from '../../errors.js';
12
13
  import type { RNG } from '../../rng/types.js';
13
14
  import type { CompareOp, DieResult } from '../../types.js';
14
- import { EvaluatorError, type EvalEnv } from '../evaluator.js';
15
+ import { createDieResult, createFateDieResult } from '../die.js';
16
+ import { chargeDie, type EvalEnv } from '../env.js';
15
17
  import { matchesCondition } from './compare.js';
18
+ import { inheritCritRule } from './crit-threshold.js';
19
+ import { isVersusDc, REROLL_SLOT_FLAGS, rewriteFlags } from './flags.js';
16
20
 
17
- /** Default maximum reroll iterations per die. */
21
+ /**
22
+ * Default value of `EvaluationOptions.maxRerollIterations`: how many times a
23
+ * single die may be re-rolled by `r` before `REROLL_LIMIT_EXCEEDED` is
24
+ * thrown.
25
+ *
26
+ * Per die, not per pool — it bounds notations like `1d1r<2`, whose reroll
27
+ * condition can never stop matching. `ro` re-rolls exactly once and is
28
+ * unaffected.
29
+ *
30
+ * @category Limits
31
+ */
18
32
  export const DEFAULT_MAX_REROLL_ITERATIONS = 1_000;
19
33
 
20
- export { matchesCondition };
21
-
22
34
  /**
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.
35
+ * Rolls a replacement for `parent`, charging it against the global dice limit.
36
+ * Fate dice (sides === 0) re-roll on the {-1, 0, +1} range. Any `cs`/`cf`
37
+ * governing `parent` carries over, so a replacement is judged by the rule the
38
+ * user declared rather than the built-in default.
25
39
  */
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
- }
40
+ function rollReplacement(parent: DieResult, rng: RNG, env: EvalEnv): DieResult {
41
+ chargeDie(env, 'Reroll');
42
+
43
+ const sides = parent.sides;
44
+ const die =
45
+ sides === 0
46
+ ? createFateDieResult(rng.nextInt(-1, 1), [])
47
+ : createDieResult(sides, rng.nextInt(1, sides), []);
46
48
 
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 && sides > 1,
54
- };
49
+ inheritCritRule(env, parent, die);
50
+ return die;
55
51
  }
56
52
 
57
53
  function rerollLimitError(maxIterations: number): EvaluatorError {
@@ -66,19 +62,11 @@ function rerollLimitError(maxIterations: number): EvaluatorError {
66
62
  * True for dice eligible to start rerolling. Dropped dice (from a preceding
67
63
  * keep/drop modifier) are left alone.
68
64
  */
69
- function canReroll(die: DieResult): boolean {
65
+ function canReroll(die: DieResult, hasVersusDc: boolean): boolean {
66
+ if (hasVersusDc && isVersusDc(die)) return false;
70
67
  return !die.modifiers.includes('dropped');
71
68
  }
72
69
 
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
70
  /**
83
71
  * Applies recursive reroll: re-roll each matching die until it no longer
84
72
  * matches or the per-die iteration limit is reached. Intermediate dice are
@@ -97,7 +85,7 @@ export function applyRecursiveReroll(
97
85
  const result: DieResult[] = [];
98
86
 
99
87
  for (const original of pool) {
100
- if (!canReroll(original)) {
88
+ if (!canReroll(original, env.hasVersusDc)) {
101
89
  result.push(original);
102
90
  continue;
103
91
  }
@@ -105,21 +93,21 @@ export function applyRecursiveReroll(
105
93
  let current = original;
106
94
  let iterations = 0;
107
95
 
108
- // ? Mutate flags in place — the same DieResult objects are shared with
109
- // the RollPart tree, and both views must reflect reroll state.
96
+ // Mutate flags in place — the same DieResult objects are shared with
97
+ // the RollPart tree, and both views must reflect reroll state.
110
98
  while (matchesCondition(current.result, operator, value)) {
111
99
  if (iterations >= env.maxRerollIterations) {
112
100
  throw rerollLimitError(env.maxRerollIterations);
113
101
  }
114
102
 
115
- current.modifiers = [...stripSlotFlags(current.modifiers), 'rerolled', 'dropped'];
103
+ current.modifiers = rewriteFlags(current.modifiers, REROLL_SLOT_FLAGS, 'rerolled', 'dropped');
116
104
  result.push(current);
117
105
 
118
- current = rollReplacement(current.sides, rng, env);
106
+ current = rollReplacement(current, rng, env);
119
107
  iterations += 1;
120
108
  }
121
109
 
122
- current.modifiers = [...stripSlotFlags(current.modifiers), 'kept'];
110
+ current.modifiers = rewriteFlags(current.modifiers, REROLL_SLOT_FLAGS, 'kept');
123
111
  result.push(current);
124
112
  }
125
113
 
@@ -141,23 +129,23 @@ export function applyRerollOnce(
141
129
  const result: DieResult[] = [];
142
130
 
143
131
  for (const original of pool) {
144
- if (!canReroll(original)) {
132
+ if (!canReroll(original, env.hasVersusDc)) {
145
133
  result.push(original);
146
134
  continue;
147
135
  }
148
136
 
149
- // ? Mutate flags in place — see `applyRecursiveReroll`.
137
+ // Mutate flags in place — see `applyRecursiveReroll`.
150
138
  if (!matchesCondition(original.result, operator, value)) {
151
- original.modifiers = [...stripSlotFlags(original.modifiers), 'kept'];
139
+ original.modifiers = rewriteFlags(original.modifiers, REROLL_SLOT_FLAGS, 'kept');
152
140
  result.push(original);
153
141
  continue;
154
142
  }
155
143
 
156
- original.modifiers = [...stripSlotFlags(original.modifiers), 'rerolled', 'dropped'];
144
+ original.modifiers = rewriteFlags(original.modifiers, REROLL_SLOT_FLAGS, 'rerolled', 'dropped');
157
145
  result.push(original);
158
146
 
159
- const replacement = rollReplacement(original.sides, rng, env);
160
- replacement.modifiers = [...stripSlotFlags(replacement.modifiers), 'kept'];
147
+ const replacement = rollReplacement(original, rng, env);
148
+ replacement.modifiers = rewriteFlags(replacement.modifiers, REROLL_SLOT_FLAGS, 'kept');
161
149
  result.push(replacement);
162
150
  }
163
151
 
@@ -7,10 +7,15 @@
7
7
  * Dropped dice stay in the pool and sort alongside kept dice so readers can
8
8
  * see where the dropped value landed in the ordered sequence.
9
9
  *
10
+ * DC dice are the exception: they hold their positions rather than sorting,
11
+ * so a `vs` comparison's DC faces never appear shuffled into the roll-side
12
+ * pool they are not a member of.
13
+ *
10
14
  * @module evaluator/modifiers/sort
11
15
  */
12
16
 
13
17
  import type { DieResult } from '../../types.js';
18
+ import { isVersusDc } from './flags.js';
14
19
 
15
20
  /**
16
21
  * Returns a sorted copy of `dice` in the given order, preserving every die's
@@ -21,10 +26,24 @@ import type { DieResult } from '../../types.js';
21
26
  * Relies on `Array.prototype.sort` being stable — equal-valued dice retain
22
27
  * their original insertion order.
23
28
  */
24
- export function sortDice(dice: DieResult[], order: 'ascending' | 'descending'): DieResult[] {
29
+ export function sortDice(
30
+ dice: DieResult[],
31
+ order: 'ascending' | 'descending',
32
+ hasVersusDc: boolean,
33
+ ): DieResult[] {
25
34
  const cmp =
26
35
  order === 'ascending'
27
36
  ? (a: DieResult, b: DieResult) => a.result - b.result
28
37
  : (a: DieResult, b: DieResult) => b.result - a.result;
29
- return [...dice].sort(cmp);
38
+
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).
41
+ if (!hasVersusDc || !dice.some(isVersusDc)) return [...dice].sort(cmp);
42
+
43
+ // Sort only the pool members, then lay them back into the slots they came
44
+ // from, leaving every DC die exactly where it was.
45
+ const sortable = dice.filter((die) => !isVersusDc(die));
46
+ sortable.sort(cmp);
47
+ let next = 0;
48
+ return dice.map((die) => (isVersusDc(die) ? die : (sortable[next++] as DieResult)));
30
49
  }
@@ -10,18 +10,17 @@
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.
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.
14
17
  *
15
18
  * @module evaluator/modifiers/success-count
16
19
  */
17
20
 
18
- import type { CompareOp, DieResult } from '../../types.js';
21
+ import type { DieResult, ResolvedComparePoint } from '../../types.js';
19
22
  import { matchesCondition } from './compare.js';
20
-
21
- export type ResolvedThreshold = {
22
- operator: CompareOp;
23
- value: number;
24
- };
23
+ import { isVersusDc } from './flags.js';
25
24
 
26
25
  export type SuccessCountResult = {
27
26
  total: number;
@@ -31,17 +30,30 @@ export type SuccessCountResult = {
31
30
 
32
31
  export function countSuccesses(
33
32
  dice: DieResult[],
34
- threshold: ResolvedThreshold,
35
- failThreshold?: ResolvedThreshold,
33
+ threshold: ResolvedComparePoint,
34
+ failThreshold: ResolvedComparePoint | undefined,
35
+ hasVersusDc: boolean,
36
36
  ): SuccessCountResult {
37
37
  let successes = 0;
38
38
  let failures = 0;
39
39
 
40
- for (const die of dice) {
40
+ // ! Excluded up front, never inside the loop below. Even short-circuited on a
41
+ // ! false flag, a `hasVersusDc && isVersusDc(die)` guard in this loop costs
42
+ // ! ~9% on `10d10>=6f1` — measured, not assumed (#281). Filtering keeps the
43
+ // ! hot body identical to the pre-exclusion one and pays an allocation only
44
+ // ! on the `vs` path. The dice are the same objects either way, so the
45
+ // ! `'success'` / `'failure'` tags written below still land on the pool.
46
+ const pool = hasVersusDc ? dice.filter((die) => !isVersusDc(die)) : dice;
47
+
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`.
52
+ for (const die of pool) {
41
53
  if (die.modifiers.includes('dropped')) continue;
42
54
 
43
55
  if (matchesCondition(die.result, threshold.operator, threshold.value)) {
44
- die.modifiers.push('success');
56
+ if (!die.modifiers.includes('success')) die.modifiers.push('success');
45
57
  successes += 1;
46
58
  continue;
47
59
  }
@@ -50,7 +62,7 @@ export function countSuccesses(
50
62
  failThreshold != null &&
51
63
  matchesCondition(die.result, failThreshold.operator, failThreshold.value)
52
64
  ) {
53
- die.modifiers.push('failure');
65
+ if (!die.modifiers.includes('failure')) die.modifiers.push('failure');
54
66
  failures += 1;
55
67
  }
56
68
  }