roll-parser 3.3.0 → 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +60 -32
  3. package/dist/cli/args.d.ts +11 -1
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +67 -42
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/cli/format.d.ts +8 -1
  8. package/dist/cli/format.d.ts.map +1 -1
  9. package/dist/cli/format.js +4 -3
  10. package/dist/cli/format.js.map +1 -1
  11. package/dist/cli/main.d.ts +3 -3
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/main.js +50 -19
  14. package/dist/cli/main.js.map +1 -1
  15. package/dist/evaluator/evaluator.d.ts +15 -3
  16. package/dist/evaluator/evaluator.d.ts.map +1 -1
  17. package/dist/evaluator/evaluator.js +134 -105
  18. package/dist/evaluator/evaluator.js.map +1 -1
  19. package/dist/evaluator/modifiers/flags.d.ts +7 -1
  20. package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
  21. package/dist/evaluator/modifiers/flags.js +9 -1
  22. package/dist/evaluator/modifiers/flags.js.map +1 -1
  23. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  24. package/dist/evaluator/modifiers/keep-drop.js +19 -16
  25. package/dist/evaluator/modifiers/keep-drop.js.map +1 -1
  26. package/dist/lexer/lexer.d.ts +5 -0
  27. package/dist/lexer/lexer.d.ts.map +1 -1
  28. package/dist/lexer/lexer.js +3 -0
  29. package/dist/lexer/lexer.js.map +1 -1
  30. package/dist/parser/parser.d.ts +1 -0
  31. package/dist/parser/parser.d.ts.map +1 -1
  32. package/dist/parser/parser.js +23 -20
  33. package/dist/parser/parser.js.map +1 -1
  34. package/dist/render.d.ts.map +1 -1
  35. package/dist/render.js +2 -1
  36. package/dist/render.js.map +1 -1
  37. package/dist/rng/seeded.d.ts.map +1 -1
  38. package/dist/rng/seeded.js.map +1 -1
  39. package/dist/version.d.ts +1 -1
  40. package/dist/version.js +1 -1
  41. package/package.json +13 -14
  42. package/src/cli/args.ts +112 -40
  43. package/src/cli/format.ts +11 -3
  44. package/src/cli/main.ts +97 -22
  45. package/src/evaluator/evaluator.ts +189 -150
  46. package/src/evaluator/modifiers/flags.ts +18 -2
  47. package/src/evaluator/modifiers/keep-drop.ts +42 -16
  48. package/src/lexer/lexer.ts +8 -0
  49. package/src/parser/parser.ts +36 -32
  50. package/src/render.ts +2 -1
  51. package/src/rng/seeded.ts +4 -0
  52. package/src/version.ts +1 -1
@@ -68,11 +68,27 @@ export function stripFlags(
68
68
  return modifiers.filter((modifier) => !excluded.includes(modifier));
69
69
  }
70
70
 
71
- /** Returns `modifiers` with `excluded` removed and `added` appended, in order. */
71
+ /**
72
+ * Returns `modifiers` with `excluded` removed and `added` appended, in order.
73
+ *
74
+ * ! Always a fresh array. `mergeMetaRolls` clones a die as
75
+ * ! `{ ...die, modifiers: rewriteFlags(...) }`, so returning the input when it
76
+ * ! already matches would leave clone and original sharing mutated flags.
77
+ */
72
78
  export function rewriteFlags(
73
79
  modifiers: readonly DieModifier[],
74
80
  excluded: readonly DieModifier[],
75
81
  ...added: DieModifier[]
76
82
  ): DieModifier[] {
77
- return [...stripFlags(modifiers, excluded), ...added];
83
+ // One array, not `stripFlags`'s filter plus a spread — this runs per die.
84
+ const rewritten: DieModifier[] = [];
85
+
86
+ for (const modifier of modifiers) {
87
+ if (!excluded.includes(modifier)) rewritten.push(modifier);
88
+ }
89
+ for (const flag of added) {
90
+ rewritten.push(flag);
91
+ }
92
+
93
+ return rewritten;
78
94
  }
@@ -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/render.ts CHANGED
@@ -342,8 +342,9 @@ function renderGroup(
342
342
  plain: boolean,
343
343
  ): string {
344
344
  const { keptIndices } = part;
345
+ const kept = keptIndices == null ? null : new Set(keptIndices);
345
346
  const subRolls = part.parts.map((sub, index) => {
346
- if (keptIndices == null || keptIndices.includes(index)) {
347
+ if (kept == null || kept.has(index)) {
347
348
  return renderPart(sub, marks, plain);
348
349
  }
349
350
  const inner = renderPart(sub, marks, true);
package/src/rng/seeded.ts CHANGED
@@ -246,6 +246,10 @@ export class SeededRNG implements RNG {
246
246
  */
247
247
  private toSeedString(seed?: string | number): string {
248
248
  // ! The library's one permitted `Math.random()` site — both draws stay here.
249
+ // ! Decimal double-to-string is a JIT fast path and other radixes are not, so
250
+ // ! every shorter encoding measured slower despite the shorter `hashSeed` loop.
251
+ // ! Only truncating each draw to a uint32 beats it, at 64 draw bits against the
252
+ // ! ~100 documented above. A faster auto-seed must skip the string, not shorten it.
249
253
  if (seed == null) return `${Date.now()}-${Math.random()}-${Math.random()}`;
250
254
  return String(seed);
251
255
  }
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.0';
2
+ export const version = '3.4.0';