roll-parser 3.3.1 → 3.4.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.
@@ -1598,6 +1598,37 @@ function formatFailCode(operator: CompareOp, value: number): string {
1598
1598
  return operator === '=' ? `f${value}` : `f${operator}${value}`;
1599
1599
  }
1600
1600
 
1601
+ /**
1602
+ * The units a success count scores: sub-roll subtotals for a multi-sub group,
1603
+ * the target's own dice otherwise.
1604
+ */
1605
+ // ! Releases every die an inner count tagged, `vs` DC dice included, though
1606
+ // ! `countSuccesses` spares those. The `stripTallyMarkers` pass below reads
1607
+ // ! rendered text and cannot tell a DC die apart, so sparing one here leaves a
1608
+ // ! tag whose `**` is already gone and `renderBreakdown` stops reproducing
1609
+ // ! `rendered`.
1610
+ function clearTallyFlags(rolls: DieResult[]): void {
1611
+ for (const die of rolls) {
1612
+ die.modifiers = stripFlags(die.modifiers, TALLY_FLAGS);
1613
+ }
1614
+ }
1615
+
1616
+ function countablePool(
1617
+ target: EvalResult,
1618
+ targetCtx: EvalContext,
1619
+ bySubtotal: boolean,
1620
+ ): DieResult[] {
1621
+ if (!bySubtotal) return targetCtx.rolls;
1622
+
1623
+ return (target.part as Extract<RollPart, { type: 'group' }>).parts.map((sub) => ({
1624
+ sides: 0,
1625
+ result: sub.total,
1626
+ modifiers: [],
1627
+ critical: false,
1628
+ fumble: false,
1629
+ }));
1630
+ }
1631
+
1601
1632
  function evalSuccessCount(
1602
1633
  node: SuccessCountNode,
1603
1634
  rng: RNG,
@@ -1657,25 +1688,9 @@ function evalSuccessCount(
1657
1688
  // `ctx.rolls`. Only a direct group target arrives here — the parser refuses
1658
1689
  // every form that would reach the count with the subtotals already gone.
1659
1690
  const bySubtotal = node.target.type === 'Group' && node.target.expressions.length >= 2;
1660
- const pool: DieResult[] = bySubtotal
1661
- ? (target.part as Extract<RollPart, { type: 'group' }>).parts.map((sub) => ({
1662
- sides: 0,
1663
- result: sub.total,
1664
- modifiers: [],
1665
- critical: false,
1666
- fumble: false,
1667
- }))
1668
- : targetCtx.rolls;
1669
-
1670
- // ! Releases every die an inner count tagged, `vs` DC dice included, though
1671
- // ! `countSuccesses` spares those. The marker strip below reads rendered text
1672
- // ! and cannot tell a DC die apart, so sparing one here leaves a tag whose
1673
- // ! `**` is already gone and `renderBreakdown` stops reproducing `rendered`.
1674
- if (bySubtotal && poolAlreadyCounted) {
1675
- for (const die of targetCtx.rolls) {
1676
- die.modifiers = stripFlags(die.modifiers, TALLY_FLAGS);
1677
- }
1678
- }
1691
+ const pool = countablePool(target, targetCtx, bySubtotal);
1692
+
1693
+ if (bySubtotal && poolAlreadyCounted) clearTallyFlags(targetCtx.rolls);
1679
1694
 
1680
1695
  // An empty pool (`0d6>=4`) scores zero of both, so its total is 0 — never
1681
1696
  // `target.total`, which would break `total === successes - failures`.
@@ -10,6 +10,29 @@ import { isVersusDc } from './flags.js';
10
10
  /** One selectable die: its rolled value and its slot in the original pool. */
11
11
  type EligibleDie = { result: number; index: number };
12
12
 
13
+ /**
14
+ * The die at pool slot `index` when it is eligible for keep/drop selection,
15
+ * `null` otherwise. Marks an ineligible-but-droppable die in `droppedMask` on
16
+ * the way past.
17
+ */
18
+ function selectOrMarkDropped(
19
+ dice: DieResult[],
20
+ index: number,
21
+ droppedMask: Uint8Array,
22
+ hasVersusDc: boolean,
23
+ ): DieResult | null {
24
+ const die = dice[index];
25
+ if (die == null) return null;
26
+ if (hasVersusDc && isVersusDc(die)) return null;
27
+
28
+ if (die.modifiers.includes('dropped')) {
29
+ droppedMask[index] = 1;
30
+ return null;
31
+ }
32
+
33
+ return die;
34
+ }
35
+
13
36
  /**
14
37
  * Records into `droppedMask` every pool slot that `kind` / `selector` /
15
38
  * `count` drops.
@@ -39,14 +62,8 @@ export function markDroppedIndices(
39
62
  const eligible: EligibleDie[] = [];
40
63
 
41
64
  for (let index = 0; index < dice.length; index++) {
42
- const die = dice[index];
65
+ const die = selectOrMarkDropped(dice, index, droppedMask, hasVersusDc);
43
66
  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
67
 
51
68
  eligible.push({ result: die.result, index });
52
69
  }
@@ -64,6 +81,20 @@ export function markDroppedIndices(
64
81
  return;
65
82
  }
66
83
 
84
+ dropBySelection(eligible, count, isKeep, selector, droppedMask);
85
+ }
86
+
87
+ /**
88
+ * Reached only once both whole-pool cases are ruled out, so the selected range
89
+ * is always a strict subset of `eligible`.
90
+ */
91
+ function dropBySelection(
92
+ eligible: EligibleDie[],
93
+ count: number,
94
+ isKeep: boolean,
95
+ selector: KeepDropSpec['selector'],
96
+ droppedMask: Uint8Array,
97
+ ): void {
67
98
  // Stable sort — ties resolve by original pool order.
68
99
  eligible.sort(
69
100
  selector === 'highest' ? (a, b) => b.result - a.result : (a, b) => a.result - b.result,
@@ -95,20 +126,15 @@ function markSingleExtreme(
95
126
  hasVersusDc: boolean,
96
127
  ): void {
97
128
  const isKeep = kind === 'keep';
98
- const wantHighest = selector === 'highest';
129
+ const isMoreExtreme =
130
+ selector === 'highest' ? (a: number, b: number) => a > b : (a: number, b: number) => a < b;
99
131
 
100
132
  let extremeIndex = -1;
101
133
  let extremeResult = 0;
102
134
 
103
135
  for (let index = 0; index < dice.length; index++) {
104
- const die = dice[index];
136
+ const die = selectOrMarkDropped(dice, index, droppedMask, hasVersusDc);
105
137
  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
138
 
113
139
  const { result } = die;
114
140
 
@@ -118,7 +144,7 @@ function markSingleExtreme(
118
144
  continue;
119
145
  }
120
146
 
121
- if (wantHighest ? result > extremeResult : result < extremeResult) {
147
+ if (isMoreExtreme(result, extremeResult)) {
122
148
  // A keep drops the dethroned extreme; a drop keeps everything else.
123
149
  if (isKeep) droppedMask[extremeIndex] = 1;
124
150
  extremeIndex = index;
@@ -215,6 +215,14 @@ export class Lexer {
215
215
  return this.scanAt();
216
216
  }
217
217
 
218
+ return this.scanOperator(char, startPos);
219
+ }
220
+
221
+ /**
222
+ * Single- and two-character operators. The last resort in `nextToken`'s
223
+ * dispatch, so an unrecognised character can only be a lexer error.
224
+ */
225
+ private scanOperator(char: string, startPos: number): Token {
218
226
  this.advance();
219
227
 
220
228
  switch (char) {
@@ -850,6 +850,41 @@ export class Parser {
850
850
  }
851
851
  }
852
852
 
853
+ // ! A single-sub-roll Group is the flat-pool escape hatch, so `containsDicePool`
854
+ // ! deep-walks the arithmetic that `(1d6+5)kh1` rejects outright — but the flat
855
+ // ! path totals `sumKeptDice`, faces only. `{2d6+3}kh2` and `{2d6-1d4}kh3` are
856
+ // ! exactly the drop that reject exists to prevent.
857
+ private rejectSingleSubGroupTarget(target: ASTNode, token: Token): void {
858
+ const base = unwrapAllTransparent(target);
859
+ if (base.type !== 'Group' || base.expressions.length !== 1) return;
860
+
861
+ const inner = base.expressions[0];
862
+ if (inner == null) return;
863
+
864
+ // Single-sub Groups hide a count from the shallow reject in `parseKeepDrop`,
865
+ // so peel them to any depth and `{4d6>=5}kh1`, `{{4d6>=5}}kh1`, and
866
+ // `(4d6>=5)kh1` all report one code. Peeling only picks which error the
867
+ // caller sees — `sumsToKeptFaces` below refuses a count either way.
868
+ let innermost = inner;
869
+ while (true) {
870
+ const peeled = unwrapGrouped(innermost);
871
+ if (peeled.type !== 'Group' || peeled.expressions.length !== 1) break;
872
+ const next = peeled.expressions[0];
873
+ if (next == null) break;
874
+ innermost = next;
875
+ }
876
+ this.rejectSuccessCountTarget(innermost, token);
877
+
878
+ if (!sumsToKeptFaces(inner)) {
879
+ throw new ParseError(
880
+ `Keep/drop on a single-sub-roll group requires added dice terms only`,
881
+ 'INVALID_KEEP_DROP_TARGET',
882
+ token.position,
883
+ token,
884
+ );
885
+ }
886
+ }
887
+
853
888
  private parseKeepDrop(target: ASTNode, token: Token): KeepDropNode {
854
889
  this.rejectSuccessCountTarget(target, token);
855
890
  // ! Keep/drop on a Versus target silently drops `degree`/`natural` metadata,
@@ -869,38 +904,7 @@ export class Parser {
869
904
  );
870
905
  }
871
906
 
872
- // ! A single-sub-roll Group is the flat-pool escape hatch, so `containsDicePool`
873
- // ! deep-walks the arithmetic that `(1d6+5)kh1` rejects outright — but the flat
874
- // ! path totals `sumKeptDice`, faces only. `{2d6+3}kh2` and `{2d6-1d4}kh3` are
875
- // ! exactly the drop that reject exists to prevent.
876
- const base = unwrapAllTransparent(target);
877
- if (base.type === 'Group' && base.expressions.length === 1) {
878
- const inner = base.expressions[0];
879
- if (inner != null) {
880
- // Single-sub Groups hide a count from the shallow reject at the top of this
881
- // method, so peel them to any depth and `{4d6>=5}kh1`, `{{4d6>=5}}kh1`, and
882
- // `(4d6>=5)kh1` all report one code. Peeling only picks which error the
883
- // caller sees — `sumsToKeptFaces` below refuses a count either way.
884
- let innermost = inner;
885
- while (true) {
886
- const peeled = unwrapGrouped(innermost);
887
- if (peeled.type !== 'Group' || peeled.expressions.length !== 1) break;
888
- const next = peeled.expressions[0];
889
- if (next == null) break;
890
- innermost = next;
891
- }
892
- this.rejectSuccessCountTarget(innermost, token);
893
-
894
- if (!sumsToKeptFaces(inner)) {
895
- throw new ParseError(
896
- `Keep/drop on a single-sub-roll group requires added dice terms only`,
897
- 'INVALID_KEEP_DROP_TARGET',
898
- token.position,
899
- token,
900
- );
901
- }
902
- }
903
- }
907
+ this.rejectSingleSubGroupTarget(target, token);
904
908
 
905
909
  const kind =
906
910
  token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by `bun run generate:version` from package.json — do not edit.
2
- export const version = '3.3.1';
2
+ export const version = '3.4.0';