roll-parser 3.2.2 → 3.3.1

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.
@@ -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
  }
@@ -41,6 +41,9 @@ export class LexerError extends RollParserError {
41
41
  * The offending text — a single character for `UNEXPECTED_CHARACTER` (the
42
42
  * whole code point, so astral symbols are not split into surrogates), or
43
43
  * the unrecognized word for `UNEXPECTED_IDENTIFIER`.
44
+ *
45
+ * Not guaranteed to appear in `message` — errors that name a rule rather than
46
+ * a character leave it out.
44
47
  */
45
48
  readonly character: string;
46
49
 
@@ -51,7 +54,7 @@ export class LexerError extends RollParserError {
51
54
  character: string,
52
55
  options?: ErrorOptions,
53
56
  ) {
54
- super(`${message}: '${character}'`, code, options);
57
+ super(message, code, options);
55
58
  this.name = 'LexerError';
56
59
  this.position = position;
57
60
  this.character = character;
@@ -71,8 +74,14 @@ const CHAR_UPPER_Z = 90;
71
74
  const CHAR_LOWER_A = 97;
72
75
  const CHAR_LOWER_Z = 122;
73
76
 
74
- /** Known identifier keywords mapped to their token types. */
75
- const IDENTIFIER_KEYWORDS: Record<string, TokenType> = {
77
+ /**
78
+ * Known identifier keywords mapped to their token types.
79
+ *
80
+ * Null-prototype, because every lookup here is keyed by arbitrary user input.
81
+ * Over a plain object literal `'constructor'` resolves to `Object`'s own, and
82
+ * the identifier leaves the lexer as a token whose `type` is a function.
83
+ */
84
+ const IDENTIFIER_KEYWORDS: Record<string, TokenType> = Object.assign(Object.create(null), {
76
85
  kh: TokenType.KEEP_HIGH,
77
86
  kl: TokenType.KEEP_LOW,
78
87
  k: TokenType.KEEP_HIGH,
@@ -96,20 +105,58 @@ const IDENTIFIER_KEYWORDS: Record<string, TokenType> = {
96
105
  sd: TokenType.SORT_DESC,
97
106
  cs: TokenType.CRIT_SUCCESS,
98
107
  cf: TokenType.CRIT_FAIL,
99
- };
108
+ } satisfies Record<string, TokenType>);
109
+
110
+ /**
111
+ * Dice-pool modifiers that maximal munch can merge, mapped to what re-separates
112
+ * them: a count for the ones that take an operand, a space for the ones that do
113
+ * not. Membership doubles as the filter — the keywords left out (`d`, `f`, `r`,
114
+ * `ro`, `vs`, the function names) have no split that parses.
115
+ */
116
+ // ! `min`/`max` share `TokenType.FUNCTION` with `floor`, so the separator
117
+ // ! cannot be derived from the token type. Null-prototype for the same reason
118
+ // ! as `IDENTIFIER_KEYWORDS` above.
119
+ const MODIFIER_SEPARATORS: Record<string, string> = Object.assign(Object.create(null), {
120
+ kh: '1',
121
+ kl: '1',
122
+ k: '1',
123
+ dh: '1',
124
+ dl: '1',
125
+ min: '1',
126
+ max: '1',
127
+ s: ' ',
128
+ sa: ' ',
129
+ sd: ' ',
130
+ cs: ' ',
131
+ cf: ' ',
132
+ } satisfies Record<string, string>);
133
+
134
+ /**
135
+ * Names a code point the message cannot render — a no-break space, zero-width
136
+ * space or byte-order mark shows as nothing between the quotes. Printable ASCII
137
+ * is legible there already and stays unadorned.
138
+ */
139
+ function nameCodePoint(codePoint: number | undefined): string {
140
+ if (codePoint == null || (codePoint > 0x20 && codePoint < 0x7f)) return '';
141
+ return ` (U+${codePoint.toString(16).toUpperCase().padStart(4, '0')})`;
142
+ }
100
143
 
101
144
  /**
102
- * Builds a hint for identifiers that start with a known keyword. Maximal
103
- * munch merges adjacent modifiers when the first has no count — `4d6khs`
104
- * lexes as one identifier `khs` instead of `kh` + `s`. Point the user at the
105
- * explicit-count (or whitespace) split.
145
+ * Builds a hint for identifiers that merged two modifiers. Maximal munch joins
146
+ * them when the first takes no count — `4d6khs` lexes as one identifier `khs`
147
+ * instead of `kh` + `s` so the fix is to re-separate them.
148
+ *
149
+ * Both halves must be separable: `flor` and `kx` start with a keyword but end
150
+ * in something no split can rescue, so they get no hint.
106
151
  */
107
152
  function buildIdentifierHint(identifier: string): string {
108
153
  for (let length = identifier.length - 1; length >= 1; length--) {
109
154
  const prefix = identifier.slice(0, length);
110
- if (IDENTIFIER_KEYWORDS[prefix] == null) continue;
155
+ const separator = MODIFIER_SEPARATORS[prefix];
156
+ if (separator == null) continue;
111
157
  const rest = identifier.slice(length);
112
- return ` (did you mean '${prefix}' followed by '${rest}'? separate modifiers with a count or space, e.g. '${prefix}1${rest}')`;
158
+ if (MODIFIER_SEPARATORS[rest] == null) continue;
159
+ return ` (did you mean '${prefix}' followed by '${rest}'? write it as '${prefix}${separator}${rest}')`;
113
160
  }
114
161
  return '';
115
162
  }
@@ -221,7 +268,12 @@ export class Lexer {
221
268
  // report the full code point instead of a lone surrogate ('�').
222
269
  const codePoint = this.input.codePointAt(startPos);
223
270
  const display = codePoint == null ? char : String.fromCodePoint(codePoint);
224
- throw new LexerError('Unexpected character', 'UNEXPECTED_CHARACTER', startPos, display);
271
+ throw new LexerError(
272
+ `Unexpected character${nameCodePoint(codePoint)}: '${display}'`,
273
+ 'UNEXPECTED_CHARACTER',
274
+ startPos,
275
+ display,
276
+ );
225
277
  }
226
278
  }
227
279
  }
@@ -294,7 +346,7 @@ export class Lexer {
294
346
  }
295
347
 
296
348
  throw new LexerError(
297
- `Unexpected identifier${buildIdentifierHint(lower)}`,
349
+ `Unexpected identifier${buildIdentifierHint(lower)}: '${lower}'`,
298
350
  'UNEXPECTED_IDENTIFIER',
299
351
  startPos,
300
352
  lower,
@@ -332,7 +384,15 @@ export class Lexer {
332
384
  } else {
333
385
  const nameStart = this.pos;
334
386
  if (this.isAtEnd() || !this.isIdentifierStart(this.peek())) {
335
- throw new LexerError('Empty @ variable name', 'UNEXPECTED_CHARACTER', startPos, '@');
387
+ // Reached by a missing name and by one starting with a digit or a
388
+ // non-ASCII letter alike, so the message names the rule rather than
389
+ // any one of those causes.
390
+ throw new LexerError(
391
+ `@ variable name must start with a letter or '_'`,
392
+ 'UNEXPECTED_CHARACTER',
393
+ startPos,
394
+ '@',
395
+ );
336
396
  }
337
397
  this.advance();
338
398
  while (!this.isAtEnd() && this.isIdentifierPart(this.peek())) {
@@ -172,6 +172,55 @@ const TOKEN_DISPLAY = {
172
172
  /** Token types that carry a display symbol and may be passed to `expect()`. */
173
173
  type ExpectableToken = keyof typeof TOKEN_DISPLAY;
174
174
 
175
+ /**
176
+ * True while a crit threshold carries only the bare `cs`/`cf` sentinel, so no
177
+ * comparison was consumed. Emptiness cannot answer this — bare `cs` fills the
178
+ * list with `'default'` rather than leaving it empty.
179
+ */
180
+ function isBareCritThreshold(node: CritThresholdNode): boolean {
181
+ const isDefault = (threshold: CritThreshold): boolean => threshold === 'default';
182
+ return node.successThresholds.every(isDefault) && node.failThresholds.every(isDefault);
183
+ }
184
+
185
+ /**
186
+ * Follows a node down to the one whose source text the next token abuts.
187
+ *
188
+ * In `1+2d6s1` the sort is already buried under the `+` by the time the stray
189
+ * `1` reaches LED position.
190
+ *
191
+ * ! Only these three descend. Every other node that holds a sub-expression
192
+ * ! closes it first — `(…)`, `{…}`, `f(…)`, and the parenthesized operand of
193
+ * ! `2d(…)` or `4d6kh(…)` all put a delimiter between the child and the stray
194
+ * ! token, so the child is not what that token follows.
195
+ */
196
+ function rightmostNode(node: ASTNode): ASTNode {
197
+ let current = node;
198
+ for (;;) {
199
+ switch (current.type) {
200
+ case 'BinaryOp':
201
+ current = current.right;
202
+ break;
203
+ case 'UnaryOp':
204
+ current = current.operand;
205
+ break;
206
+ case 'Versus':
207
+ current = current.dc;
208
+ break;
209
+ default:
210
+ return current;
211
+ }
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Names the operand a token was still waiting for when the notation ran out.
217
+ */
218
+ function expectedOperand(token: Token): string {
219
+ if (token.type === TokenType.DICE) return 'a side count';
220
+ if (token.type === TokenType.VS) return 'a difficulty class';
221
+ return 'a value';
222
+ }
223
+
175
224
  /**
176
225
  * Arity table for math functions. `min` and `max` are inclusive.
177
226
  * `POSITIVE_INFINITY` means unbounded (variadic).
@@ -220,11 +269,9 @@ export class Parser {
220
269
  * Parse the token stream into an AST.
221
270
  */
222
271
  parse(): ASTNode {
223
- if (this.peek().type === TokenType.EOF) {
224
- throw new ParseError('Unexpected end of input', 'UNEXPECTED_END', this.peek().position);
225
- }
226
-
227
- const ast = this.parseExpression(0);
272
+ // Nothing has demanded an operand yet, so an immediate EOF is an empty
273
+ // notation rather than a truncated one.
274
+ const ast = this.parseExpression(0, undefined);
228
275
 
229
276
  if (this.peek().type !== TokenType.EOF) {
230
277
  const token = this.peek();
@@ -241,14 +288,18 @@ export class Parser {
241
288
 
242
289
  /**
243
290
  * Parse an expression with minimum binding power.
291
+ *
292
+ * `pending` is the token that demanded this operand — the `d` of `1d`, the
293
+ * `<` of `2d6r<`. It exists so a truncated notation can name what it was
294
+ * waiting for; `undefined` only at the top of the input.
244
295
  */
245
- private parseExpression(minBp: number): ASTNode {
296
+ private parseExpression(minBp: number, pending: Token | undefined): ASTNode {
246
297
  const entryDepth = this.depth;
247
298
  this.depth += 1;
248
299
  this.guardDepth();
249
300
 
250
301
  try {
251
- let left = this.parseNud();
302
+ let left = this.parseNud(pending);
252
303
 
253
304
  while (this.hasTokens()) {
254
305
  const token = this.peek();
@@ -290,7 +341,7 @@ export class Parser {
290
341
  * NUD - Null Denotation.
291
342
  * Handles tokens that appear at the start of an expression (prefix position).
292
343
  */
293
- private parseNud(): ASTNode {
344
+ private parseNud(pending: Token | undefined): ASTNode {
294
345
  const token = this.advance();
295
346
 
296
347
  switch (token.type) {
@@ -322,7 +373,23 @@ export class Parser {
322
373
  return this.parseVariable(token);
323
374
 
324
375
  case TokenType.EOF:
325
- throw new ParseError('Unexpected end of input', 'UNEXPECTED_END', token.position);
376
+ throw new ParseError(
377
+ pending == null
378
+ ? 'Empty notation'
379
+ : `Expected ${expectedOperand(pending)} after '${pending.value}'`,
380
+ 'UNEXPECTED_END',
381
+ token.position,
382
+ );
383
+
384
+ // Reaching prefix position means no roll precedes the `vs`, so there is
385
+ // nothing to check against the DC.
386
+ case TokenType.VS:
387
+ throw new ParseError(
388
+ 'Versus needs a roll on its left',
389
+ 'UNEXPECTED_TOKEN',
390
+ token.position,
391
+ token,
392
+ );
326
393
 
327
394
  default:
328
395
  throw new ParseError(
@@ -398,13 +465,32 @@ export class Parser {
398
465
  case TokenType.FAIL:
399
466
  return Parser.throwOrphanFailThreshold(token);
400
467
 
401
- default:
468
+ default: {
469
+ // A bare number after a modifier that took none — `2d6s1`, `2d6!0`.
470
+ // ! Only while the modifier is still empty-handed: past `2d6!>1 2` the
471
+ // ! explode did take its comparison, and the stray number is an
472
+ // ! unrelated juxtaposition that keeps the generic message.
473
+ const trailing = rightmostNode(left);
474
+ if (
475
+ token.type === TokenType.NUMBER &&
476
+ (trailing.type === 'Sort' ||
477
+ (trailing.type === 'Explode' && trailing.threshold == null) ||
478
+ (trailing.type === 'CritThreshold' && isBareCritThreshold(trailing)))
479
+ ) {
480
+ throw new ParseError(
481
+ 'This modifier takes no count',
482
+ 'UNEXPECTED_TOKEN',
483
+ token.position,
484
+ token,
485
+ );
486
+ }
402
487
  throw new ParseError(
403
488
  `Unexpected infix token '${token.value}'`,
404
489
  'UNEXPECTED_TOKEN',
405
490
  token.position,
406
491
  token,
407
492
  );
493
+ }
408
494
  }
409
495
  }
410
496
 
@@ -430,7 +516,7 @@ export class Parser {
430
516
  }
431
517
 
432
518
  private parseUnaryMinus(token: Token): UnaryOpNode {
433
- const operand = this.parseExpression(BP.UNARY);
519
+ const operand = this.parseExpression(BP.UNARY, token);
434
520
  this.rejectSuccessCountTarget(operand, token);
435
521
  return {
436
522
  type: 'UnaryOp',
@@ -442,7 +528,7 @@ export class Parser {
442
528
  }
443
529
 
444
530
  private parsePrefixDice(token: Token): DiceNode {
445
- const sides = this.parseExpression(BP.DICE_RIGHT);
531
+ const sides = this.parseExpression(BP.DICE_RIGHT, token);
446
532
  this.rejectSuccessCountTarget(sides, token);
447
533
  this.rejectVersusMetaOperand(sides, token);
448
534
  return {
@@ -458,7 +544,7 @@ export class Parser {
458
544
  this.rejectSuccessCountTarget(left, token);
459
545
  this.rejectVersusMetaOperand(left, token);
460
546
  this.rejectBareDiceChain(left, token);
461
- const sides = this.parseExpression(BP.DICE_RIGHT);
547
+ const sides = this.parseExpression(BP.DICE_RIGHT, token);
462
548
  this.rejectSuccessCountTarget(sides, token);
463
549
  this.rejectVersusMetaOperand(sides, token);
464
550
  return {
@@ -517,7 +603,13 @@ export class Parser {
517
603
  }
518
604
 
519
605
  private parseGrouped(token: Token): GroupedNode {
520
- const expression = this.parseExpression(0);
606
+ // Mirrors `parseGroup`'s empty check; without it `)` reaches the prefix arm
607
+ // and reports itself, naming the closer rather than the empty construct.
608
+ if (this.peek().type === TokenType.RPAREN) {
609
+ throw new ParseError('Empty parentheses', 'UNEXPECTED_TOKEN', token.position, token);
610
+ }
611
+
612
+ const expression = this.parseExpression(0, token);
521
613
  const close = this.expect(TokenType.RPAREN);
522
614
  return { type: 'Grouped', expression, start: token.position, end: close.end };
523
615
  }
@@ -530,10 +622,10 @@ export class Parser {
530
622
  throw new ParseError('Empty group', 'UNEXPECTED_TOKEN', startToken.position, startToken);
531
623
  }
532
624
 
533
- const expressions: ASTNode[] = [this.parseExpression(0)];
625
+ const expressions: ASTNode[] = [this.parseExpression(0, startToken)];
534
626
  while (this.peek().type === TokenType.COMMA) {
535
- this.advance();
536
- expressions.push(this.parseExpression(0));
627
+ const comma = this.advance();
628
+ expressions.push(this.parseExpression(0, comma));
537
629
  }
538
630
 
539
631
  if (this.peek().type !== TokenType.RBRACE) {
@@ -557,16 +649,16 @@ export class Parser {
557
649
  private parseFunctionCall(token: Token): FunctionCallNode {
558
650
  // `FUNCTION`, `COMMA`, and `RPAREN` all sit at BP -1, so argument boundaries
559
651
  // fall out of the inner `parseExpression(0)` calls terminating on their own.
560
- this.expect(TokenType.LPAREN);
652
+ const open = this.expect(TokenType.LPAREN);
561
653
 
562
654
  const args: ASTNode[] = [];
563
655
  if (this.peek().type !== TokenType.RPAREN) {
564
- const first = this.parseExpression(0);
656
+ const first = this.parseExpression(0, open);
565
657
  this.rejectSuccessCountTarget(first, token);
566
658
  args.push(first);
567
659
  while (this.peek().type === TokenType.COMMA) {
568
- this.advance();
569
- const next = this.parseExpression(0);
660
+ const comma = this.advance();
661
+ const next = this.parseExpression(0, comma);
570
662
  this.rejectSuccessCountTarget(next, token);
571
663
  args.push(next);
572
664
  }
@@ -610,7 +702,7 @@ export class Parser {
610
702
 
611
703
  const operator = this.getOperatorSymbol(token);
612
704
  const rightBp = this.getRightBp(token);
613
- const right = this.parseExpression(rightBp);
705
+ const right = this.parseExpression(rightBp, token);
614
706
 
615
707
  this.rejectSuccessCountTarget(right, token);
616
708
 
@@ -659,8 +751,12 @@ export class Parser {
659
751
  // never hide inside one and widening the set would never match.
660
752
  const node = unwrapGrouped(target);
661
753
  if (isSuccessCount(node)) {
754
+ // ! Three shapes reach here and only one is a modifier — arithmetic
755
+ // ! (`5d6>=5+3`) and meta-operand reuse (`4d(1d6>=3)`) do too, so the
756
+ // ! message has to stay true of all three. **README.md → Pools and
757
+ // ! checks** carries the worked rewrites.
662
758
  throw new ParseError(
663
- `Cannot apply modifier after success counting`,
759
+ `Success counting is terminal: it cannot be part of a larger expression`,
664
760
  'INVALID_SUCCESS_COUNT_TARGET',
665
761
  token.position,
666
762
  token,
@@ -820,7 +916,7 @@ export class Parser {
820
916
  nextToken === TokenType.LPAREN ||
821
917
  nextToken === TokenType.AT;
822
918
  const count: ASTNode = hasExplicitCount
823
- ? this.parseExpression(BP.DICE_LEFT)
919
+ ? this.parseExpression(BP.DICE_LEFT, token)
824
920
  : Parser.syntheticLiteral(1, token);
825
921
  this.rejectSuccessCountTarget(count, token);
826
922
  this.rejectVersusMetaOperand(count, token);
@@ -982,7 +1078,7 @@ export class Parser {
982
1078
  );
983
1079
  }
984
1080
 
985
- const value = this.parseExpression(BP.DICE_LEFT);
1081
+ const value = this.parseExpression(BP.DICE_LEFT, token);
986
1082
  this.rejectSuccessCountTarget(value, token);
987
1083
  this.rejectVersusMetaOperand(value, token);
988
1084
 
@@ -1156,7 +1252,7 @@ export class Parser {
1156
1252
 
1157
1253
  const operator = this.getCompareOp(token);
1158
1254
  // Threshold binds at `BP.DICE_LEFT` — see `parseComparePoint` TSDoc.
1159
- const value = this.parseExpression(BP.DICE_LEFT);
1255
+ const value = this.parseExpression(BP.DICE_LEFT, token);
1160
1256
  this.rejectSuccessCountTarget(value, token);
1161
1257
  this.rejectVersusMetaOperand(value, token);
1162
1258
  const start = target.start ?? token.position;
@@ -1166,8 +1262,8 @@ export class Parser {
1166
1262
  return { type: 'SuccessCount', target, threshold: { operator, value }, start, end };
1167
1263
  }
1168
1264
 
1169
- this.advance();
1170
- const failThreshold = this.parseFailThreshold(token);
1265
+ const failToken = this.advance();
1266
+ const failThreshold = this.parseFailThreshold(token, failToken);
1171
1267
 
1172
1268
  return {
1173
1269
  type: 'SuccessCount',
@@ -1179,12 +1275,18 @@ export class Parser {
1179
1275
  };
1180
1276
  }
1181
1277
 
1182
- /** Parses the `f...` suffix of a success count. Bare `fN` means `f=N`. */
1183
- private parseFailThreshold(token: Token): ComparePoint {
1278
+ /**
1279
+ * Parses the `f...` suffix of a success count. Bare `fN` means `f=N`.
1280
+ *
1281
+ * `token` is the success-count comparison, which the reject calls report
1282
+ * against; `failToken` is the `f`, so a truncated `2d6>=4f` names it rather
1283
+ * than the comparison behind it.
1284
+ */
1285
+ private parseFailThreshold(token: Token, failToken: Token): ComparePoint {
1184
1286
  if (this.isComparePointAhead()) return this.parseComparePoint();
1185
1287
 
1186
1288
  // Same threshold binding as `parseComparePoint` (BP.DICE_LEFT).
1187
- const failValue = this.parseExpression(BP.DICE_LEFT);
1289
+ const failValue = this.parseExpression(BP.DICE_LEFT, failToken);
1188
1290
  this.rejectSuccessCountTarget(failValue, token);
1189
1291
  this.rejectVersusMetaOperand(failValue, token);
1190
1292
 
@@ -1226,7 +1328,7 @@ export class Parser {
1226
1328
  throw new ParseError('Cannot chain versus operators', 'NESTED_VERSUS', token.position, token);
1227
1329
  }
1228
1330
 
1229
- const dc = this.parseExpression(BP.VS_RIGHT);
1331
+ const dc = this.parseExpression(BP.VS_RIGHT, token);
1230
1332
  this.rejectSuccessCountTarget(dc, token);
1231
1333
 
1232
1334
  return {
@@ -1276,7 +1378,7 @@ export class Parser {
1276
1378
 
1277
1379
  this.advance();
1278
1380
 
1279
- const value = this.parseExpression(BP.DICE_LEFT);
1381
+ const value = this.parseExpression(BP.DICE_LEFT, token);
1280
1382
  this.rejectSuccessCountTarget(value, token);
1281
1383
  this.rejectVersusMetaOperand(value, token);
1282
1384
 
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.2.2';
2
+ export const version = '3.3.1';