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.
- package/CHANGELOG.md +47 -1
- package/README.md +7 -6
- package/dist/evaluator/evaluator.d.ts +15 -3
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +115 -91
- package/dist/evaluator/evaluator.js.map +1 -1
- package/dist/evaluator/modifiers/flags.d.ts +7 -1
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
- package/dist/evaluator/modifiers/flags.js +9 -1
- package/dist/evaluator/modifiers/flags.js.map +1 -1
- package/dist/lexer/lexer.d.ts +3 -0
- package/dist/lexer/lexer.d.ts.map +1 -1
- package/dist/lexer/lexer.js +30 -8
- package/dist/lexer/lexer.js.map +1 -1
- package/dist/parser/parser.d.ts +11 -1
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +72 -31
- package/dist/parser/parser.js.map +1 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +2 -1
- package/dist/render.js.map +1 -1
- package/dist/rng/seeded.d.ts.map +1 -1
- package/dist/rng/seeded.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +10 -11
- package/src/evaluator/evaluator.ts +155 -131
- package/src/evaluator/modifiers/flags.ts +18 -2
- package/src/lexer/lexer.ts +73 -13
- package/src/parser/parser.ts +135 -33
- package/src/render.ts +2 -1
- package/src/rng/seeded.ts +4 -0
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
package/src/lexer/lexer.ts
CHANGED
|
@@ -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(
|
|
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
|
-
/**
|
|
75
|
-
|
|
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
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
|
|
155
|
+
const separator = MODIFIER_SEPARATORS[prefix];
|
|
156
|
+
if (separator == null) continue;
|
|
111
157
|
const rest = identifier.slice(length);
|
|
112
|
-
|
|
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(
|
|
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
|
-
|
|
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())) {
|
package/src/parser/parser.ts
CHANGED
|
@@ -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
|
-
|
|
224
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
/**
|
|
1183
|
-
|
|
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 (
|
|
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
|
+
export const version = '3.3.1';
|