roll-parser 3.0.0 → 3.2.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 (88) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/MIGRATION.md +293 -4
  3. package/README.md +246 -55
  4. package/dist/cli/format.d.ts.map +1 -1
  5. package/dist/cli/format.js +8 -7
  6. package/dist/cli/format.js.map +1 -1
  7. package/dist/evaluator/die.d.ts +2 -1
  8. package/dist/evaluator/die.d.ts.map +1 -1
  9. package/dist/evaluator/die.js.map +1 -1
  10. package/dist/evaluator/env.d.ts +57 -0
  11. package/dist/evaluator/env.d.ts.map +1 -1
  12. package/dist/evaluator/env.js.map +1 -1
  13. package/dist/evaluator/evaluator.d.ts.map +1 -1
  14. package/dist/evaluator/evaluator.js +104 -56
  15. package/dist/evaluator/evaluator.js.map +1 -1
  16. package/dist/evaluator/modifiers/crit-threshold.d.ts +43 -5
  17. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  18. package/dist/evaluator/modifiers/crit-threshold.js +24 -11
  19. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -1
  20. package/dist/evaluator/modifiers/die-bound.d.ts +5 -2
  21. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -1
  22. package/dist/evaluator/modifiers/die-bound.js +6 -3
  23. package/dist/evaluator/modifiers/die-bound.js.map +1 -1
  24. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  25. package/dist/evaluator/modifiers/explode.js +13 -5
  26. package/dist/evaluator/modifiers/explode.js.map +1 -1
  27. package/dist/evaluator/modifiers/flags.d.ts +17 -0
  28. package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
  29. package/dist/evaluator/modifiers/flags.js +4 -4
  30. package/dist/evaluator/modifiers/flags.js.map +1 -1
  31. package/dist/evaluator/modifiers/keep-drop.d.ts +5 -5
  32. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  33. package/dist/evaluator/modifiers/keep-drop.js +7 -7
  34. package/dist/evaluator/modifiers/keep-drop.js.map +1 -1
  35. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  36. package/dist/evaluator/modifiers/reroll.js +16 -10
  37. package/dist/evaluator/modifiers/reroll.js.map +1 -1
  38. package/dist/evaluator/modifiers/sort.d.ts +1 -1
  39. package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/sort.js +3 -3
  41. package/dist/evaluator/modifiers/sort.js.map +1 -1
  42. package/dist/evaluator/modifiers/success-count.d.ts +6 -2
  43. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  44. package/dist/evaluator/modifiers/success-count.js +9 -5
  45. package/dist/evaluator/modifiers/success-count.js.map +1 -1
  46. package/dist/notation.d.ts +13 -0
  47. package/dist/notation.d.ts.map +1 -0
  48. package/dist/notation.js +8 -0
  49. package/dist/notation.js.map +1 -0
  50. package/dist/parser/ast.d.ts +12 -7
  51. package/dist/parser/ast.d.ts.map +1 -1
  52. package/dist/parser/ast.js.map +1 -1
  53. package/dist/parser/guards.d.ts +38 -0
  54. package/dist/parser/guards.d.ts.map +1 -1
  55. package/dist/parser/guards.js +51 -0
  56. package/dist/parser/guards.js.map +1 -1
  57. package/dist/parser/parser.d.ts.map +1 -1
  58. package/dist/parser/parser.js +26 -2
  59. package/dist/parser/parser.js.map +1 -1
  60. package/dist/render.d.ts +95 -0
  61. package/dist/render.d.ts.map +1 -0
  62. package/dist/render.js +232 -0
  63. package/dist/render.js.map +1 -0
  64. package/dist/types.d.ts +64 -12
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js.map +1 -1
  67. package/dist/version.d.ts +1 -1
  68. package/dist/version.js +1 -1
  69. package/package.json +14 -4
  70. package/src/cli/format.ts +15 -23
  71. package/src/evaluator/die.ts +2 -1
  72. package/src/evaluator/env.ts +58 -0
  73. package/src/evaluator/evaluator.ts +192 -70
  74. package/src/evaluator/modifiers/crit-threshold.ts +81 -15
  75. package/src/evaluator/modifiers/die-bound.ts +13 -4
  76. package/src/evaluator/modifiers/explode.ts +21 -6
  77. package/src/evaluator/modifiers/flags.ts +21 -4
  78. package/src/evaluator/modifiers/keep-drop.ts +10 -8
  79. package/src/evaluator/modifiers/reroll.ts +20 -11
  80. package/src/evaluator/modifiers/sort.ts +9 -3
  81. package/src/evaluator/modifiers/success-count.ts +27 -5
  82. package/src/notation.ts +24 -0
  83. package/src/parser/ast.ts +12 -7
  84. package/src/parser/guards.ts +92 -1
  85. package/src/parser/parser.ts +56 -1
  86. package/src/render.ts +394 -0
  87. package/src/types.ts +64 -12
  88. package/src/version.ts +1 -1
@@ -9,6 +9,31 @@
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 every modifier that overwrites
15
+ * `result` while recording the face it replaced — compound explode,
16
+ * penetrating explode, and `minN`/`maxN`. An explicit threshold (`cs>4`,
17
+ * `cf<=2`) reads the die's current `result`: it is a predicate over the die's
18
+ * value, and postfix modifiers are order-sensitive by design, so `4d6min5cs>4`
19
+ * is meant to see the clamped faces. The two can therefore disagree on one
20
+ * die — `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
+ * The rule is recorded per die on `env.critRules`, so dice that explode and
30
+ * reroll mint *after* the crit node has run inherit it from the die they
31
+ * descended from. `cs`/`cf` therefore covers the whole pool wherever it sits
32
+ * in the postfix chain — `1d6cs<2!` and `1d6!cs<2` agree. Two things stay
33
+ * outside that: compound explode, which mints no die and so keeps the flags
34
+ * its accumulated `result` had when the crit node ran, and `minN`/`maxN`,
35
+ * which rewrites `result` under an explicit threshold's feet.
36
+ *
12
37
  * Display-only: does not alter `total`, explosion triggers, success
13
38
  * counting, or any other modifier flag. Dropped dice still participate —
14
39
  * their `critical`/`fumble` metadata reflects what they rolled, not
@@ -18,39 +43,80 @@
18
43
  */
19
44
 
20
45
  import type { DieResult, ResolvedCritThreshold } from '../../types.js';
46
+ import type { CritRule, EvalEnv } from '../env.js';
21
47
  import { matchesCondition } from './compare.js';
22
48
  import { isVersusDc } from './flags.js';
23
49
 
24
50
  /**
25
- * Applies success/fail threshold arrays to a dice pool, overriding each
26
- * die's `critical` and `fumble` flags in place. A die matches `'default'`
27
- * on the success side when `result === sides && sides > 1`, and on the
28
- * fail side when `result === 1`. Meta dice are skipped.
51
+ * Applies success/fail threshold arrays to a dice pool, overriding each die's
52
+ * `critical` and `fumble` flags in place. Meta and DC dice are skipped.
53
+ *
54
+ * Also records the rule against every die it touched, so later explode and
55
+ * reroll dice can inherit it via {@link inheritCritRule}.
29
56
  */
30
57
  export function applyCritThresholds(
31
58
  dice: DieResult[],
32
59
  successThresholds: ResolvedCritThreshold[],
33
60
  failThresholds: ResolvedCritThreshold[],
61
+ env: EvalEnv,
34
62
  ): void {
63
+ const rule: CritRule = { success: successThresholds, fail: failThresholds };
64
+ env.critRules ??= new WeakMap();
65
+ const rules = env.critRules;
66
+
35
67
  for (const die of dice) {
36
- if (die.modifiers.includes('meta') || isVersusDc(die)) continue;
68
+ if (die.modifiers.includes('meta')) continue;
69
+ if (env.hasVersusDc && isVersusDc(die)) continue;
37
70
 
38
- die.critical = successThresholds.some((t) => matchesCrit(t, die));
39
- die.fumble = failThresholds.some((t) => matchesFumble(t, die));
71
+ applyCritRule(die, rule, die.initialResult ?? die.result);
72
+ rules.set(die, rule);
40
73
  }
41
74
  }
42
75
 
43
- function matchesCrit(threshold: ResolvedCritThreshold, die: DieResult): boolean {
44
- if (threshold === 'default') {
45
- return die.result === die.sides && die.sides > 1;
46
- }
47
- return matchesCondition(die.result, threshold.operator, threshold.value);
76
+ /**
77
+ * Judges one die by a recorded rule, overwriting both flags. A side always
78
+ * carries at least `'default'` `evalCritThreshold` fills the side the user
79
+ * left out — so neither flag is silently left untouched.
80
+ */
81
+ function applyCritRule(die: DieResult, rule: CritRule, natural: number): void {
82
+ die.critical = rule.success.some((t) => matchesThreshold(t, die, natural, die.sides));
83
+ die.fumble = rule.fail.some((t) => matchesThreshold(t, die, natural, 1));
48
84
  }
49
85
 
50
- function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult): boolean {
86
+ /**
87
+ * Passes the crit rule recorded for `parent` down to a die minted from it,
88
+ * judging the child by that rule and recording it so a further explode or
89
+ * reroll inherits it in turn. No-op when no `cs`/`cf` governs `parent`, which
90
+ * leaves the `createDieResult` default rule in place.
91
+ *
92
+ * ! Call this only once the child's final `result` and `initialResult` are
93
+ * ! stored — an explicit threshold is a predicate over `result`, so a
94
+ * ! penetrating die is judged by its decremented value, matching `1d6!pcs<2`.
95
+ */
96
+ export function inheritCritRule(env: EvalEnv, parent: DieResult, child: DieResult): void {
97
+ const rules = env.critRules;
98
+ if (rules === undefined) return;
99
+
100
+ const rule = rules.get(parent);
101
+ if (rule === undefined) return;
102
+
103
+ applyCritRule(child, rule, child.initialResult ?? child.result);
104
+ rules.set(child, rule);
105
+ }
106
+
107
+ /**
108
+ * `defaultFace` is the face the `'default'` sentinel looks for — `die.sides`
109
+ * on the success side, `1` on the fail side. The `sides > 1` guard mirrors
110
+ * `createDieResult`: a d1 always rolls 1, so it is neither.
111
+ */
112
+ function matchesThreshold(
113
+ threshold: ResolvedCritThreshold,
114
+ die: DieResult,
115
+ natural: number,
116
+ defaultFace: number,
117
+ ): boolean {
51
118
  if (threshold === 'default') {
52
- // Mirrors `createDieResult` a d1 always rolls 1, never a fumble.
53
- return die.result === 1 && die.sides > 1;
119
+ return natural === defaultFace && die.sides > 1;
54
120
  }
55
121
  return matchesCondition(die.result, threshold.operator, threshold.value);
56
122
  }
@@ -6,10 +6,13 @@
6
6
  * `initialResult` (first writer wins, so a compound-explode accumulation
7
7
  * that was clamped afterwards still reports its original first roll), and
8
8
  * the die is tagged `'min'` / `'max'` so parts consumers can tell a clamped
9
- * value from a natural one.
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'`.
10
11
  *
11
12
  * `critical` / `fumble` keep reflecting the natural face — a clamped 1 is
12
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.
13
16
  * Meta dice (rolled to compute counts/sides/modifier args) are skipped.
14
17
  * Dropped dice are clamped too — they are excluded from totals anyway, and
15
18
  * clamping them keeps the rendered pool consistent.
@@ -25,15 +28,21 @@ import { isVersusDc } from './flags.js';
25
28
  * `bound: 'min'` lifts lower results up to `value`; `'max'` caps higher
26
29
  * results down to it. Untouched dice keep their tags.
27
30
  */
28
- export function applyDieBound(dice: DieResult[], bound: 'min' | 'max', value: number): void {
31
+ export function applyDieBound(
32
+ dice: DieResult[],
33
+ bound: 'min' | 'max',
34
+ value: number,
35
+ hasVersusDc: boolean,
36
+ ): void {
29
37
  for (const die of dice) {
30
- if (die.modifiers.includes('meta') || isVersusDc(die)) continue;
38
+ if (die.modifiers.includes('meta')) continue;
39
+ if (hasVersusDc && isVersusDc(die)) continue;
31
40
 
32
41
  const clamped = bound === 'min' ? Math.max(die.result, value) : Math.min(die.result, value);
33
42
  if (clamped === die.result) continue;
34
43
 
35
44
  die.initialResult ??= die.result;
36
45
  die.result = clamped;
37
- die.modifiers.push(bound);
46
+ if (!die.modifiers.includes(bound)) die.modifiers.push(bound);
38
47
  }
39
48
  }
@@ -13,6 +13,7 @@ import type { RNG } from '../../rng/types.js';
13
13
  import type { CompareOp, DieResult } from '../../types.js';
14
14
  import { createDieResult } from '../die.js';
15
15
  import { chargeDie, type EvalEnv } from '../env.js';
16
+ import { inheritCritRule } from './crit-threshold.js';
16
17
  import { isVersusDc } from './flags.js';
17
18
 
18
19
  /**
@@ -84,8 +85,8 @@ function explodeLimitError(maxIterations: number): EvaluatorError {
84
85
  * never fire during normal flow. Keeping it ensures `rng.nextInt(1, 0)`
85
86
  * can never be reached if a future AST path slips past the parser gate.
86
87
  */
87
- function canExplode(die: DieResult): boolean {
88
- if (isVersusDc(die)) return false;
88
+ function canExplode(die: DieResult, hasVersusDc: boolean): boolean {
89
+ if (hasVersusDc && isVersusDc(die)) return false;
89
90
  if (die.modifiers.includes('dropped')) return false;
90
91
  if (die.sides < 1) return false;
91
92
  return true;
@@ -97,8 +98,14 @@ function canExplode(die: DieResult): boolean {
97
98
  * the value recorded on the appended die, which is the only thing standard
98
99
  * and penetrating explosions disagree about.
99
100
  *
101
+ * A die whose stored value differs from its raw roll records the raw face in
102
+ * `initialResult`, so `initialResult ?? result` recovers what was rolled.
103
+ *
100
104
  * `critical`/`fumble` are likewise derived from the raw roll — a penetrating
101
105
  * die that rolled its max face is still a crit even though it stores one less.
106
+ * A `cs`/`cf` rule keeps that: it is applied after `storeResult`, so an
107
+ * explicit threshold reads the stored value while the `'default'` sentinel
108
+ * reads `initialResult`.
102
109
  */
103
110
  function applyAppendingExplode(
104
111
  pool: DieResult[],
@@ -111,7 +118,7 @@ function applyAppendingExplode(
111
118
 
112
119
  for (const original of pool) {
113
120
  result.push(original);
114
- if (!canExplode(original)) continue;
121
+ if (!canExplode(original, env.hasVersusDc)) continue;
115
122
 
116
123
  const sides = original.sides;
117
124
  let lastRaw = original.result;
@@ -124,6 +131,13 @@ function applyAppendingExplode(
124
131
  const raw = rollExplosion(sides, rng, env);
125
132
  const die = createDieResult(sides, raw, ['exploded', 'kept']);
126
133
  die.result = storeResult(raw);
134
+ if (die.result !== raw) die.initialResult = raw;
135
+ // Only `extractNatural` reads this, and only inside a `vs`.
136
+ if (env.insideVersus) {
137
+ env.explosionDice ??= new WeakSet();
138
+ env.explosionDice.add(die);
139
+ }
140
+ inheritCritRule(env, original, die);
127
141
  result.push(die);
128
142
  lastRaw = raw;
129
143
  iterations += 1;
@@ -172,7 +186,7 @@ export function applyCompoundExplode(
172
186
  env: EvalEnv,
173
187
  ): DieResult[] {
174
188
  for (const original of pool) {
175
- if (!canExplode(original)) continue;
189
+ if (!canExplode(original, env.hasVersusDc)) continue;
176
190
 
177
191
  const sides = original.sides;
178
192
  let accumulated = original.result;
@@ -194,8 +208,9 @@ export function applyCompoundExplode(
194
208
  if (!exploded) continue;
195
209
 
196
210
  // `critical` and `fumble` keep referring to the original triggering
197
- // roll — after compounding the `result` is a sum.
198
- original.initialResult = original.result;
211
+ // roll — after compounding the `result` is a sum. First writer wins, so
212
+ // a preceding `minN`/`maxN` clamp keeps its record of the raw face.
213
+ original.initialResult ??= original.result;
199
214
  original.result = accumulated;
200
215
  if (!original.modifiers.includes('exploded')) {
201
216
  original.modifiers = [...original.modifiers, 'exploded'];
@@ -20,6 +20,10 @@ import type { DieModifier, DieResult } from '../../types.js';
20
20
  * operates on. Nothing may sum, select, clamp, reroll, explode, or tally them
21
21
  * — a `d10` on the DC side must never come back clamped to 20, and
22
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
26
+ * ! tag; the flag is `false` until a `vs` has actually tagged something.
23
27
  */
24
28
  export function isVersusDc(die: DieResult): boolean {
25
29
  return die.modifiers.includes('dc');
@@ -28,18 +32,31 @@ export function isVersusDc(die: DieResult): boolean {
28
32
  /** Kept/dropped selection flags — rebuilt by every keep/drop pass. */
29
33
  export const SELECTION_FLAGS: readonly DieModifier[] = ['kept', 'dropped'];
30
34
 
35
+ /**
36
+ * Success-count tally flags — rebuilt by every counting pass. A group lets a
37
+ * second count reach a pool the first one already tagged (`{4d6>=5}<=2f5`);
38
+ * stripping these first is what keeps the outermost count the only one the
39
+ * tags describe.
40
+ */
41
+ export const TALLY_FLAGS: readonly DieModifier[] = ['success', 'failure'];
42
+
31
43
  /**
32
44
  * Selection flags plus the success-count tally flags. Stripped when a die
33
45
  * leaves the pool that tagged it (meta sub-expressions, dropped group
34
46
  * sub-rolls) so the top-level successes/failures scan cannot count it.
35
47
  */
36
48
  export const SELECTION_AND_TALLY_FLAGS: readonly DieModifier[] = [
37
- 'kept',
38
- 'dropped',
39
- 'success',
40
- 'failure',
49
+ ...SELECTION_FLAGS,
50
+ ...TALLY_FLAGS,
41
51
  ];
42
52
 
53
+ /**
54
+ * {@link SELECTION_AND_TALLY_FLAGS} plus `'meta'`, for a meta context merging
55
+ * into a parent. Meta operands nest (`((1d2)d4)d6`), so a die passes through
56
+ * the merge once per level and the tag must be rebuilt, not appended.
57
+ */
58
+ export const META_MERGE_FLAGS: readonly DieModifier[] = [...SELECTION_AND_TALLY_FLAGS, 'meta'];
59
+
43
60
  /** Selection flags plus `rerolled` — reassigned on every reroll pass. */
44
61
  export const REROLL_SLOT_FLAGS: readonly DieModifier[] = ['kept', 'dropped', 'rerolled'];
45
62
 
@@ -29,9 +29,10 @@ export function markDroppedIndices(
29
29
  kind: KeepDropSpec['kind'],
30
30
  selector: KeepDropSpec['selector'],
31
31
  droppedMask: Uint8Array,
32
+ hasVersusDc: boolean,
32
33
  ): void {
33
34
  if (count === 1) {
34
- markSingleExtreme(dice, kind, selector, droppedMask);
35
+ markSingleExtreme(dice, kind, selector, droppedMask, hasVersusDc);
35
36
  return;
36
37
  }
37
38
 
@@ -40,7 +41,7 @@ export function markDroppedIndices(
40
41
  for (let index = 0; index < dice.length; index++) {
41
42
  const die = dice[index];
42
43
  if (die == null) continue;
43
- if (isVersusDc(die)) continue;
44
+ if (hasVersusDc && isVersusDc(die)) continue;
44
45
 
45
46
  if (die.modifiers.includes('dropped')) {
46
47
  droppedMask[index] = 1;
@@ -91,6 +92,7 @@ function markSingleExtreme(
91
92
  kind: KeepDropSpec['kind'],
92
93
  selector: KeepDropSpec['selector'],
93
94
  droppedMask: Uint8Array,
95
+ hasVersusDc: boolean,
94
96
  ): void {
95
97
  const isKeep = kind === 'keep';
96
98
  const wantHighest = selector === 'highest';
@@ -101,7 +103,7 @@ function markSingleExtreme(
101
103
  for (let index = 0; index < dice.length; index++) {
102
104
  const die = dice[index];
103
105
  if (die == null) continue;
104
- if (isVersusDc(die)) continue;
106
+ if (hasVersusDc && isVersusDc(die)) continue;
105
107
 
106
108
  if (die.modifiers.includes('dropped')) {
107
109
  droppedMask[index] = 1;
@@ -132,16 +134,16 @@ function markSingleExtreme(
132
134
  }
133
135
 
134
136
  /**
135
- * Calculates total from dice, excluding dropped dice.
137
+ * Sums the dice a keep/drop pass left standing.
136
138
  *
137
- * @param dice - Array of die results
138
- * @returns Sum of non-dropped dice
139
+ * @param hasVersusDc - Shared env flag; skips the DC exclusion when no `vs` has
140
+ * tagged anything
139
141
  */
140
- export function sumKeptDice(dice: DieResult[]): number {
142
+ export function sumKeptDice(dice: DieResult[], hasVersusDc: boolean): number {
141
143
  let total = 0;
142
144
 
143
145
  for (const die of dice) {
144
- if (isVersusDc(die)) continue;
146
+ if (hasVersusDc && isVersusDc(die)) continue;
145
147
  if (!die.modifiers.includes('dropped')) total += die.result;
146
148
  }
147
149
 
@@ -15,6 +15,7 @@ import type { CompareOp, DieResult } from '../../types.js';
15
15
  import { createDieResult, createFateDieResult } from '../die.js';
16
16
  import { chargeDie, type EvalEnv } from '../env.js';
17
17
  import { matchesCondition } from './compare.js';
18
+ import { inheritCritRule } from './crit-threshold.js';
18
19
  import { isVersusDc, REROLL_SLOT_FLAGS, rewriteFlags } from './flags.js';
19
20
 
20
21
  /**
@@ -31,15 +32,22 @@ import { isVersusDc, REROLL_SLOT_FLAGS, rewriteFlags } from './flags.js';
31
32
  export const DEFAULT_MAX_REROLL_ITERATIONS = 1_000;
32
33
 
33
34
  /**
34
- * Rolls a replacement die for the given sides, charging it against the global
35
- * 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.
36
39
  */
37
- function rollReplacement(sides: number, rng: RNG, env: EvalEnv): DieResult {
40
+ function rollReplacement(parent: DieResult, rng: RNG, env: EvalEnv): DieResult {
38
41
  chargeDie(env, 'Reroll');
39
42
 
40
- if (sides === 0) return createFateDieResult(rng.nextInt(-1, 1), []);
43
+ const sides = parent.sides;
44
+ const die =
45
+ sides === 0
46
+ ? createFateDieResult(rng.nextInt(-1, 1), [])
47
+ : createDieResult(sides, rng.nextInt(1, sides), []);
41
48
 
42
- return createDieResult(sides, rng.nextInt(1, sides), []);
49
+ inheritCritRule(env, parent, die);
50
+ return die;
43
51
  }
44
52
 
45
53
  function rerollLimitError(maxIterations: number): EvaluatorError {
@@ -54,8 +62,9 @@ function rerollLimitError(maxIterations: number): EvaluatorError {
54
62
  * True for dice eligible to start rerolling. Dropped dice (from a preceding
55
63
  * keep/drop modifier) are left alone.
56
64
  */
57
- function canReroll(die: DieResult): boolean {
58
- return !die.modifiers.includes('dropped') && !isVersusDc(die);
65
+ function canReroll(die: DieResult, hasVersusDc: boolean): boolean {
66
+ if (hasVersusDc && isVersusDc(die)) return false;
67
+ return !die.modifiers.includes('dropped');
59
68
  }
60
69
 
61
70
  /**
@@ -76,7 +85,7 @@ export function applyRecursiveReroll(
76
85
  const result: DieResult[] = [];
77
86
 
78
87
  for (const original of pool) {
79
- if (!canReroll(original)) {
88
+ if (!canReroll(original, env.hasVersusDc)) {
80
89
  result.push(original);
81
90
  continue;
82
91
  }
@@ -94,7 +103,7 @@ export function applyRecursiveReroll(
94
103
  current.modifiers = rewriteFlags(current.modifiers, REROLL_SLOT_FLAGS, 'rerolled', 'dropped');
95
104
  result.push(current);
96
105
 
97
- current = rollReplacement(current.sides, rng, env);
106
+ current = rollReplacement(current, rng, env);
98
107
  iterations += 1;
99
108
  }
100
109
 
@@ -120,7 +129,7 @@ export function applyRerollOnce(
120
129
  const result: DieResult[] = [];
121
130
 
122
131
  for (const original of pool) {
123
- if (!canReroll(original)) {
132
+ if (!canReroll(original, env.hasVersusDc)) {
124
133
  result.push(original);
125
134
  continue;
126
135
  }
@@ -135,7 +144,7 @@ export function applyRerollOnce(
135
144
  original.modifiers = rewriteFlags(original.modifiers, REROLL_SLOT_FLAGS, 'rerolled', 'dropped');
136
145
  result.push(original);
137
146
 
138
- const replacement = rollReplacement(original.sides, rng, env);
147
+ const replacement = rollReplacement(original, rng, env);
139
148
  replacement.modifiers = rewriteFlags(replacement.modifiers, REROLL_SLOT_FLAGS, 'kept');
140
149
  result.push(replacement);
141
150
  }
@@ -26,17 +26,23 @@ import { isVersusDc } from './flags.js';
26
26
  * Relies on `Array.prototype.sort` being stable — equal-valued dice retain
27
27
  * their original insertion order.
28
28
  */
29
- 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[] {
30
34
  const cmp =
31
35
  order === 'ascending'
32
36
  ? (a: DieResult, b: DieResult) => a.result - b.result
33
37
  : (a: DieResult, b: DieResult) => b.result - a.result;
34
38
 
35
- const sortable = dice.filter((die) => !isVersusDc(die));
36
- if (sortable.length === dice.length) return [...dice].sort(cmp);
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.
41
+ if (!hasVersusDc || !dice.some(isVersusDc)) return [...dice].sort(cmp);
37
42
 
38
43
  // Sort only the pool members, then lay them back into the slots they came
39
44
  // from, leaving every DC die exactly where it was.
45
+ const sortable = dice.filter((die) => !isVersusDc(die));
40
46
  sortable.sort(cmp);
41
47
  let next = 0;
42
48
  return dice.map((die) => (isVersusDc(die) ? die : (sortable[next++] as DieResult)));
@@ -10,14 +10,18 @@
10
10
  * excluded from counting and are never tagged.
11
11
  *
12
12
  * Mutates the input pool in place to add `'success'` / `'failure'` modifier
13
- * flags — mirrors the mutation pattern of explode and reroll modifiers.
13
+ * flags — mirrors the mutation pattern of explode and reroll modifiers. The
14
+ * tags are rebuilt, not appended: a group counted after its members
15
+ * (`{4d6>=5}<=2f5`) runs this pass twice over the same dice, and the outermost
16
+ * pass is the one whose arithmetic `RollResult.successes` / `failures` and the
17
+ * rendered markers report.
14
18
  *
15
19
  * @module evaluator/modifiers/success-count
16
20
  */
17
21
 
18
22
  import type { DieResult, ResolvedComparePoint } from '../../types.js';
19
23
  import { matchesCondition } from './compare.js';
20
- import { isVersusDc } from './flags.js';
24
+ import { isVersusDc, stripFlags, TALLY_FLAGS } from './flags.js';
21
25
 
22
26
  export type SuccessCountResult = {
23
27
  total: number;
@@ -28,13 +32,31 @@ export type SuccessCountResult = {
28
32
  export function countSuccesses(
29
33
  dice: DieResult[],
30
34
  threshold: ResolvedComparePoint,
31
- failThreshold?: ResolvedComparePoint,
35
+ failThreshold: ResolvedComparePoint | undefined,
36
+ hasVersusDc: boolean,
37
+ poolAlreadyCounted: boolean,
32
38
  ): SuccessCountResult {
33
39
  let successes = 0;
34
40
  let failures = 0;
35
41
 
36
- for (const die of dice) {
37
- if (isVersusDc(die)) continue;
42
+ // ! Excluded up front, never inside the loop below. Even short-circuited on a
43
+ // ! false flag, a `hasVersusDc && isVersusDc(die)` guard in this loop costs
44
+ // ! ~9% on `10d10>=6f1` — measured, not assumed. Filtering keeps the
45
+ // ! hot body identical to the pre-exclusion one and pays an allocation only
46
+ // ! on the `vs` path. The dice are the same objects either way, so the
47
+ // ! `'success'` / `'failure'` tags written below still land on the pool.
48
+ const pool = hasVersusDc ? dice.filter((die) => !isVersusDc(die)) : dice;
49
+
50
+ // ! Dropped dice are stripped too, not just the ones re-tagged below. This
51
+ // ! pass skips them, so an inner count's tag would otherwise survive into the
52
+ // ! top-level successes/failures scan and break `total === successes - failures`.
53
+ if (poolAlreadyCounted) {
54
+ for (const die of pool) {
55
+ die.modifiers = stripFlags(die.modifiers, TALLY_FLAGS);
56
+ }
57
+ }
58
+
59
+ for (const die of pool) {
38
60
  if (die.modifiers.includes('dropped')) continue;
39
61
 
40
62
  if (matchesCondition(die.result, threshold.operator, threshold.value)) {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Notation serialization shared by the two breakdown builders — the
3
+ * evaluator's `expression`/`rendered` strings and `render.ts`.
4
+ *
5
+ * @module notation
6
+ */
7
+
8
+ // ! `cs`, `cf`, `s`, and `sd` are the only codes ending in a letter the lexer
9
+ // ! scans as an identifier — maximal munch then swallows what follows, so
10
+ // ! `cs` + `cf` re-lexes as `cscf`. `4dF` and `!p` are their own tokens.
11
+ const BARE_MODIFIER_END = /(?:cs|cf|sd|s)$/;
12
+
13
+ const STARTS_WITH_LETTER = /^[A-Za-z]/;
14
+
15
+ /**
16
+ * Appends a modifier code to the expression built so far, separating the two
17
+ * with a space when concatenating them would re-lex as a single identifier.
18
+ * The space is the same separator the input used to make them parse.
19
+ */
20
+ export function joinModifierCode(expression: string, code: string): string {
21
+ return BARE_MODIFIER_END.test(expression) && STARTS_WITH_LETTER.test(code)
22
+ ? `${expression} ${code}`
23
+ : `${expression}${code}`;
24
+ }
package/src/parser/ast.ts CHANGED
@@ -61,8 +61,11 @@ export type DiceNode = NodeSpan & {
61
61
  * Fate/Fudge dice node (`dF`).
62
62
  * Each die produces a result in {-1, 0, +1}. No configurable sides.
63
63
  *
64
- * Fate dice carry `sides: 0` as a sentinel in their `DieResult`, and are
65
- * never `critical` or `fumble`there is no maximum face to hit.
64
+ * Fate dice carry `sides: 0` as a sentinel in their `DieResult`, so the
65
+ * default `critical`/`fumble` rule never fires it is guarded on `sides > 1`,
66
+ * which also stops a `+1` face from reading as a fumbled 1. An explicit
67
+ * threshold still applies: `4dFcs>0` and `4dFcf=-1` set the flags, while the
68
+ * bare `cs`/`cf` forms are rejected at parse time.
66
69
  *
67
70
  * @category AST
68
71
  */
@@ -314,9 +317,10 @@ export type SortNode = NodeSpan & {
314
317
  };
315
318
 
316
319
  /**
317
- * Sentinel for bare `cs` / `cf` without a ComparePoint. Resolved to
318
- * `result === sides` (for critical) or `result === 1` (for fumble) at
319
- * evaluation time, using each die's own `sides`.
320
+ * Sentinel for bare `cs` / `cf` without a ComparePoint. Resolved at
321
+ * evaluation time against each die's own `sides` and its natural face
322
+ * (`initialResult ?? result`): critical when that face equals `sides`,
323
+ * fumble when it equals 1.
320
324
  *
321
325
  * @category AST
322
326
  */
@@ -325,8 +329,9 @@ export type CritThreshold = ComparePoint | 'default';
325
329
  /**
326
330
  * Critical threshold modifier node (`cs`, `cf`).
327
331
  *
328
- * Overrides the default `critical`/`fumble` flag logic for the dice
329
- * produced by `target`. Bare `cs`/`cf` uses the `'default'` sentinel
332
+ * Overrides the default `critical`/`fumble` flag logic for `target`'s dice
333
+ * pool including dice an enclosing `!` or `r` adds to it afterwards, which
334
+ * `target` never produced. Bare `cs`/`cf` uses the `'default'` sentinel
330
335
  * (max face / 1). Custom thresholds accept any ComparePoint. Chaining
331
336
  * collapses into a single node — `1d20cs=20cs=1cf>18` has two success
332
337
  * and one fail threshold. Display-only: does not change `total`,