roll-parser 3.0.0-alpha.0 → 3.0.0-beta.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.
- package/CHANGELOG.md +91 -0
- package/README.md +166 -30
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/format.d.ts +1 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli.js +1110 -277
- package/dist/cli.js.map +28 -0
- package/dist/errors.d.ts +7 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/evaluator/evaluator.d.ts +63 -7
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/index.d.ts +2 -2
- package/dist/evaluator/index.d.ts.map +1 -1
- package/dist/evaluator/modifiers/compare.d.ts +1 -1
- package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.d.ts +28 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
- package/dist/evaluator/modifiers/explode.d.ts +8 -4
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.d.ts +1 -1
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
- package/dist/evaluator/modifiers/reroll.d.ts +4 -4
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
- package/dist/evaluator/modifiers/sort.d.ts +23 -0
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
- package/dist/evaluator/modifiers/success-count.d.ts +1 -1
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/index.d.ts +13 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1112 -275
- package/dist/index.js.map +26 -0
- package/dist/lexer/lexer.d.ts +18 -3
- package/dist/lexer/lexer.d.ts.map +1 -1
- package/dist/lexer/tokens.d.ts +22 -2
- package/dist/lexer/tokens.d.ts.map +1 -1
- package/dist/parser/ast.d.ts +209 -24
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/parser.d.ts +48 -5
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/rng/index.d.ts +2 -2
- package/dist/rng/index.d.ts.map +1 -1
- package/dist/rng/mock.d.ts +1 -1
- package/dist/rng/mock.d.ts.map +1 -1
- package/dist/rng/seeded.d.ts +8 -1
- package/dist/rng/seeded.d.ts.map +1 -1
- package/dist/roll.d.ts +6 -2
- package/dist/roll.d.ts.map +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +3 -0
- package/dist/testing.js.map +11 -0
- package/dist/types.d.ts +143 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +22 -18
- package/src/cli/args.ts +2 -1
- package/src/cli/format.ts +8 -4
- package/src/cli/index.ts +22 -5
- package/src/errors.ts +15 -3
- package/src/evaluator/evaluator.ts +826 -111
- package/src/evaluator/index.ts +2 -2
- package/src/evaluator/modifiers/compare.ts +1 -1
- package/src/evaluator/modifiers/crit-threshold.ts +59 -0
- package/src/evaluator/modifiers/explode.ts +29 -25
- package/src/evaluator/modifiers/keep-drop.ts +1 -1
- package/src/evaluator/modifiers/reroll.ts +18 -25
- package/src/evaluator/modifiers/sort.ts +30 -0
- package/src/evaluator/modifiers/success-count.ts +2 -2
- package/src/index.ts +33 -15
- package/src/lexer/lexer.ts +101 -8
- package/src/lexer/tokens.ts +42 -2
- package/src/parser/ast.ts +397 -30
- package/src/parser/parser.ts +590 -67
- package/src/rng/index.ts +2 -2
- package/src/rng/mock.ts +1 -1
- package/src/rng/seeded.ts +31 -1
- package/src/roll.ts +14 -6
- package/src/testing.ts +1 -1
- package/src/types.ts +127 -2
- package/dist/index.mjs +0 -1724
- package/dist/testing.mjs +0 -39
package/src/parser/parser.ts
CHANGED
|
@@ -4,26 +4,41 @@
|
|
|
4
4
|
* @module parser/parser
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { RollParserErrorCode } from '../errors';
|
|
8
|
-
import { RollParserError } from '../errors';
|
|
9
|
-
import { lex } from '../lexer/lexer';
|
|
10
|
-
import { type Token, TokenType } from '../lexer/tokens';
|
|
11
|
-
import type { CompareOp, ComparePoint } from '../types';
|
|
7
|
+
import type { RollParserErrorCode } from '../errors.js';
|
|
8
|
+
import { RollParserError } from '../errors.js';
|
|
9
|
+
import { lex } from '../lexer/lexer.js';
|
|
10
|
+
import { type Token, TokenType } from '../lexer/tokens.js';
|
|
11
|
+
import type { CompareOp, ComparePoint } from '../types.js';
|
|
12
12
|
import type {
|
|
13
13
|
ASTNode,
|
|
14
14
|
BinaryOpNode,
|
|
15
|
+
CritThreshold,
|
|
16
|
+
CritThresholdNode,
|
|
15
17
|
DiceNode,
|
|
16
18
|
ExplodeNode,
|
|
17
19
|
FateDiceNode,
|
|
18
20
|
FunctionCallNode,
|
|
21
|
+
GroupedNode,
|
|
22
|
+
GroupNode,
|
|
19
23
|
LiteralNode,
|
|
20
24
|
ModifierNode,
|
|
21
25
|
RerollNode,
|
|
26
|
+
SortNode,
|
|
22
27
|
SuccessCountNode,
|
|
23
28
|
UnaryOpNode,
|
|
29
|
+
VariableNode,
|
|
24
30
|
VersusNode,
|
|
25
|
-
} from './ast';
|
|
26
|
-
import {
|
|
31
|
+
} from './ast.js';
|
|
32
|
+
import {
|
|
33
|
+
containsDicePool,
|
|
34
|
+
containsFatePool,
|
|
35
|
+
containsMultiSubGroup,
|
|
36
|
+
containsVersus,
|
|
37
|
+
deepContainsDicePool,
|
|
38
|
+
isCritThreshold,
|
|
39
|
+
isSuccessCount,
|
|
40
|
+
unwrapTransparent,
|
|
41
|
+
} from './ast.js';
|
|
27
42
|
|
|
28
43
|
/**
|
|
29
44
|
* Error thrown when the parser encounters invalid syntax.
|
|
@@ -57,6 +72,10 @@ const BP = {
|
|
|
57
72
|
// Versus (left-associative, lowest precedence — `1d20+10 vs 25+10` = `(1d20+10) vs (25+10)`)
|
|
58
73
|
VS_LEFT: 2,
|
|
59
74
|
VS_RIGHT: 3,
|
|
75
|
+
// Comparison operators used as the success-count LED. Must be below
|
|
76
|
+
// ADD/MUL so `XdY+N>T` parses as `(XdY+N)>T` and fails the pool-target
|
|
77
|
+
// guard with a clear error, instead of the `>` stealing `N` from the `+`.
|
|
78
|
+
COMPARE: 8,
|
|
60
79
|
// Addition/subtraction (left-associative)
|
|
61
80
|
ADD_LEFT: 10,
|
|
62
81
|
ADD_RIGHT: 11,
|
|
@@ -75,6 +94,24 @@ const BP = {
|
|
|
75
94
|
DICE_RIGHT: 41,
|
|
76
95
|
} as const;
|
|
77
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Maximum expression nesting depth. Far beyond any human-authored notation —
|
|
99
|
+
* exists so adversarial input like 20,000 nested parens throws a typed
|
|
100
|
+
* `ParseError` instead of an uncaught `RangeError` stack overflow (which
|
|
101
|
+
* would also break the `isRollParserError` contract). Bounding parse depth
|
|
102
|
+
* also bounds AST depth, protecting the recursive AST walkers and evaluator.
|
|
103
|
+
*/
|
|
104
|
+
const MAX_PARSE_DEPTH = 128;
|
|
105
|
+
|
|
106
|
+
/** Human-readable symbols for tokens named in `expect()` error messages. */
|
|
107
|
+
const TOKEN_DISPLAY: Partial<Record<TokenType, string>> = {
|
|
108
|
+
[TokenType.LPAREN]: `'('`,
|
|
109
|
+
[TokenType.RPAREN]: `')'`,
|
|
110
|
+
[TokenType.LBRACE]: `'{'`,
|
|
111
|
+
[TokenType.RBRACE]: `'}'`,
|
|
112
|
+
[TokenType.COMMA]: `','`,
|
|
113
|
+
};
|
|
114
|
+
|
|
78
115
|
/**
|
|
79
116
|
* Arity table for math functions. `min` and `max` are inclusive.
|
|
80
117
|
* `POSITIVE_INFINITY` means unbounded (variadic).
|
|
@@ -97,6 +134,7 @@ const FUNCTION_ARITY: Record<string, { min: number; max: number }> = {
|
|
|
97
134
|
export class Parser {
|
|
98
135
|
private readonly tokens: Token[];
|
|
99
136
|
private pos = 0;
|
|
137
|
+
private depth = 0;
|
|
100
138
|
|
|
101
139
|
constructor(tokens: Token[]) {
|
|
102
140
|
this.tokens = tokens;
|
|
@@ -130,19 +168,33 @@ export class Parser {
|
|
|
130
168
|
* Parse an expression with minimum binding power.
|
|
131
169
|
*/
|
|
132
170
|
private parseExpression(minBp: number): ASTNode {
|
|
133
|
-
|
|
171
|
+
this.depth += 1;
|
|
172
|
+
if (this.depth > MAX_PARSE_DEPTH) {
|
|
173
|
+
throw new ParseError(
|
|
174
|
+
`Expression nesting exceeds the maximum depth of ${MAX_PARSE_DEPTH}`,
|
|
175
|
+
'MAX_DEPTH_EXCEEDED',
|
|
176
|
+
this.peek().position,
|
|
177
|
+
this.peek(),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
134
180
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const leftBp = this.getLeftBp(token);
|
|
181
|
+
try {
|
|
182
|
+
let left = this.parseNud();
|
|
138
183
|
|
|
139
|
-
|
|
184
|
+
while (this.hasTokens()) {
|
|
185
|
+
const token = this.peek();
|
|
186
|
+
const leftBp = this.getLeftBp(token);
|
|
140
187
|
|
|
141
|
-
|
|
142
|
-
left = this.parseLed(left, token);
|
|
143
|
-
}
|
|
188
|
+
if (leftBp < minBp) break;
|
|
144
189
|
|
|
145
|
-
|
|
190
|
+
this.advance();
|
|
191
|
+
left = this.parseLed(left, token);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return left;
|
|
195
|
+
} finally {
|
|
196
|
+
this.depth -= 1;
|
|
197
|
+
}
|
|
146
198
|
}
|
|
147
199
|
|
|
148
200
|
/**
|
|
@@ -157,23 +209,29 @@ export class Parser {
|
|
|
157
209
|
return this.parseLiteral(token);
|
|
158
210
|
|
|
159
211
|
case TokenType.MINUS:
|
|
160
|
-
return this.parseUnaryMinus();
|
|
212
|
+
return this.parseUnaryMinus(token);
|
|
161
213
|
|
|
162
214
|
case TokenType.DICE:
|
|
163
|
-
return this.parsePrefixDice();
|
|
215
|
+
return this.parsePrefixDice(token);
|
|
164
216
|
|
|
165
217
|
case TokenType.DICE_PERCENT:
|
|
166
|
-
return this.parsePrefixDicePercent();
|
|
218
|
+
return this.parsePrefixDicePercent(token);
|
|
167
219
|
|
|
168
220
|
case TokenType.DICE_FATE:
|
|
169
|
-
return this.parsePrefixFateDice();
|
|
221
|
+
return this.parsePrefixFateDice(token);
|
|
170
222
|
|
|
171
223
|
case TokenType.LPAREN:
|
|
172
|
-
return this.parseGrouped();
|
|
224
|
+
return this.parseGrouped(token);
|
|
225
|
+
|
|
226
|
+
case TokenType.LBRACE:
|
|
227
|
+
return this.parseGroup(token);
|
|
173
228
|
|
|
174
229
|
case TokenType.FUNCTION:
|
|
175
230
|
return this.parseFunctionCall(token);
|
|
176
231
|
|
|
232
|
+
case TokenType.AT:
|
|
233
|
+
return this.parseVariable(token);
|
|
234
|
+
|
|
177
235
|
case TokenType.EOF:
|
|
178
236
|
throw new ParseError('Unexpected end of input', 'UNEXPECTED_END', token.position);
|
|
179
237
|
|
|
@@ -194,13 +252,13 @@ export class Parser {
|
|
|
194
252
|
private parseLed(left: ASTNode, token: Token): ASTNode {
|
|
195
253
|
switch (token.type) {
|
|
196
254
|
case TokenType.DICE:
|
|
197
|
-
return this.parseInfixDice(left);
|
|
255
|
+
return this.parseInfixDice(left, token);
|
|
198
256
|
|
|
199
257
|
case TokenType.DICE_PERCENT:
|
|
200
|
-
return this.parseInfixDicePercent(left);
|
|
258
|
+
return this.parseInfixDicePercent(left, token);
|
|
201
259
|
|
|
202
260
|
case TokenType.DICE_FATE:
|
|
203
|
-
return this.parseInfixFateDice(left);
|
|
261
|
+
return this.parseInfixFateDice(left, token);
|
|
204
262
|
|
|
205
263
|
case TokenType.PLUS:
|
|
206
264
|
case TokenType.MINUS:
|
|
@@ -225,6 +283,14 @@ export class Parser {
|
|
|
225
283
|
case TokenType.REROLL_ONCE:
|
|
226
284
|
return this.parseReroll(left, token);
|
|
227
285
|
|
|
286
|
+
case TokenType.SORT_ASC:
|
|
287
|
+
case TokenType.SORT_DESC:
|
|
288
|
+
return this.parseSort(left, token);
|
|
289
|
+
|
|
290
|
+
case TokenType.CRIT_SUCCESS:
|
|
291
|
+
case TokenType.CRIT_FAIL:
|
|
292
|
+
return this.parseCritThreshold(left, token);
|
|
293
|
+
|
|
228
294
|
case TokenType.GREATER:
|
|
229
295
|
case TokenType.GREATER_EQUAL:
|
|
230
296
|
case TokenType.LESS:
|
|
@@ -247,82 +313,152 @@ export class Parser {
|
|
|
247
313
|
|
|
248
314
|
// * Node parsers
|
|
249
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Zero-width span for synthetic nodes (implicit counts, `d%` sides) that
|
|
318
|
+
* have no source text of their own — anchored at the governing token.
|
|
319
|
+
*/
|
|
320
|
+
private static syntheticLiteral(value: number, token: Token): LiteralNode {
|
|
321
|
+
return { type: 'Literal', value, start: token.position, end: token.position };
|
|
322
|
+
}
|
|
323
|
+
|
|
250
324
|
private parseLiteral(token: Token): LiteralNode {
|
|
251
325
|
return {
|
|
252
326
|
type: 'Literal',
|
|
253
327
|
value: Number.parseFloat(token.value),
|
|
328
|
+
start: token.position,
|
|
329
|
+
end: token.end,
|
|
254
330
|
};
|
|
255
331
|
}
|
|
256
332
|
|
|
257
|
-
private parseUnaryMinus(): UnaryOpNode {
|
|
333
|
+
private parseUnaryMinus(token: Token): UnaryOpNode {
|
|
258
334
|
const operand = this.parseExpression(BP.UNARY);
|
|
335
|
+
this.rejectSuccessCountTarget(operand, token);
|
|
259
336
|
return {
|
|
260
337
|
type: 'UnaryOp',
|
|
261
338
|
operator: '-',
|
|
262
339
|
operand,
|
|
340
|
+
start: token.position,
|
|
341
|
+
end: operand.end ?? token.end,
|
|
263
342
|
};
|
|
264
343
|
}
|
|
265
344
|
|
|
266
|
-
private parsePrefixDice(): DiceNode {
|
|
345
|
+
private parsePrefixDice(token: Token): DiceNode {
|
|
267
346
|
// d20 → Dice(1, 20)
|
|
268
347
|
const sides = this.parseExpression(BP.DICE_RIGHT);
|
|
348
|
+
this.rejectSuccessCountTarget(sides, token);
|
|
349
|
+
this.rejectVersusTarget(sides, token);
|
|
269
350
|
return {
|
|
270
351
|
type: 'Dice',
|
|
271
|
-
count:
|
|
352
|
+
count: Parser.syntheticLiteral(1, token),
|
|
272
353
|
sides,
|
|
354
|
+
start: token.position,
|
|
355
|
+
end: sides.end ?? token.end,
|
|
273
356
|
};
|
|
274
357
|
}
|
|
275
358
|
|
|
276
|
-
private parseInfixDice(left: ASTNode): DiceNode {
|
|
359
|
+
private parseInfixDice(left: ASTNode, token: Token): DiceNode {
|
|
277
360
|
// 4d6 → Dice(4, 6)
|
|
361
|
+
this.rejectSuccessCountTarget(left, token);
|
|
362
|
+
this.rejectVersusTarget(left, token);
|
|
363
|
+
this.rejectBareDiceChain(left, token);
|
|
278
364
|
const sides = this.parseExpression(BP.DICE_RIGHT);
|
|
365
|
+
this.rejectSuccessCountTarget(sides, token);
|
|
366
|
+
this.rejectVersusTarget(sides, token);
|
|
279
367
|
return {
|
|
280
368
|
type: 'Dice',
|
|
281
369
|
count: left,
|
|
282
370
|
sides,
|
|
371
|
+
start: left.start ?? token.position,
|
|
372
|
+
end: sides.end ?? token.end,
|
|
283
373
|
};
|
|
284
374
|
}
|
|
285
375
|
|
|
286
|
-
private parsePrefixDicePercent(): DiceNode {
|
|
376
|
+
private parsePrefixDicePercent(token: Token): DiceNode {
|
|
287
377
|
// d% → Dice(1, 100)
|
|
288
378
|
return {
|
|
289
379
|
type: 'Dice',
|
|
290
|
-
count:
|
|
291
|
-
sides:
|
|
380
|
+
count: Parser.syntheticLiteral(1, token),
|
|
381
|
+
sides: Parser.syntheticLiteral(100, token),
|
|
382
|
+
start: token.position,
|
|
383
|
+
end: token.end,
|
|
292
384
|
};
|
|
293
385
|
}
|
|
294
386
|
|
|
295
|
-
private parseInfixDicePercent(left: ASTNode): DiceNode {
|
|
387
|
+
private parseInfixDicePercent(left: ASTNode, token: Token): DiceNode {
|
|
296
388
|
// 2d% → Dice(2, 100)
|
|
389
|
+
this.rejectSuccessCountTarget(left, token);
|
|
390
|
+
this.rejectVersusTarget(left, token);
|
|
391
|
+
this.rejectBareDiceChain(left, token);
|
|
297
392
|
return {
|
|
298
393
|
type: 'Dice',
|
|
299
394
|
count: left,
|
|
300
|
-
sides:
|
|
395
|
+
sides: Parser.syntheticLiteral(100, token),
|
|
396
|
+
start: left.start ?? token.position,
|
|
397
|
+
end: token.end,
|
|
301
398
|
};
|
|
302
399
|
}
|
|
303
400
|
|
|
304
|
-
private parsePrefixFateDice(): FateDiceNode {
|
|
401
|
+
private parsePrefixFateDice(token: Token): FateDiceNode {
|
|
305
402
|
// dF → FateDice(1)
|
|
306
403
|
return {
|
|
307
404
|
type: 'FateDice',
|
|
308
|
-
count:
|
|
405
|
+
count: Parser.syntheticLiteral(1, token),
|
|
406
|
+
start: token.position,
|
|
407
|
+
end: token.end,
|
|
309
408
|
};
|
|
310
409
|
}
|
|
311
410
|
|
|
312
|
-
private parseInfixFateDice(left: ASTNode): FateDiceNode {
|
|
411
|
+
private parseInfixFateDice(left: ASTNode, token: Token): FateDiceNode {
|
|
313
412
|
// 4dF → FateDice(4). Unlike parseInfixDice, there is no sides sub-parse,
|
|
314
413
|
// so modifiers (`kh`, `dl`, …) naturally bind at the outer Pratt loop
|
|
315
414
|
// without BP competition against a right-operand.
|
|
415
|
+
this.rejectSuccessCountTarget(left, token);
|
|
416
|
+
this.rejectVersusTarget(left, token);
|
|
417
|
+
this.rejectBareDiceChain(left, token);
|
|
316
418
|
return {
|
|
317
419
|
type: 'FateDice',
|
|
318
420
|
count: left,
|
|
421
|
+
start: left.start ?? token.position,
|
|
422
|
+
end: token.end,
|
|
319
423
|
};
|
|
320
424
|
}
|
|
321
425
|
|
|
322
|
-
private parseGrouped():
|
|
323
|
-
const
|
|
324
|
-
this.expect(TokenType.RPAREN);
|
|
325
|
-
return
|
|
426
|
+
private parseGrouped(token: Token): GroupedNode {
|
|
427
|
+
const expression = this.parseExpression(0);
|
|
428
|
+
const close = this.expect(TokenType.RPAREN);
|
|
429
|
+
return { type: 'Grouped', expression, start: token.position, end: close.end };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private parseGroup(startToken: Token): GroupNode {
|
|
433
|
+
// ? `LBRACE`/`RBRACE`/`COMMA` all have `getLeftBp === -1`, so inner
|
|
434
|
+
// `parseExpression(0)` calls terminate at the first `,` or `}` without
|
|
435
|
+
// competing with modifier/arithmetic BPs.
|
|
436
|
+
if (this.peek().type === TokenType.RBRACE) {
|
|
437
|
+
throw new ParseError('Empty group', 'UNEXPECTED_TOKEN', startToken.position, startToken);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const expressions: ASTNode[] = [this.parseExpression(0)];
|
|
441
|
+
while (this.peek().type === TokenType.COMMA) {
|
|
442
|
+
this.advance();
|
|
443
|
+
expressions.push(this.parseExpression(0));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (this.peek().type !== TokenType.RBRACE) {
|
|
447
|
+
const unterminated = this.peek();
|
|
448
|
+
throw new ParseError(
|
|
449
|
+
`Unterminated group: expected '}' or ','`,
|
|
450
|
+
'EXPECTED_TOKEN',
|
|
451
|
+
unterminated.position,
|
|
452
|
+
unterminated,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
const close = this.advance();
|
|
456
|
+
|
|
457
|
+
return { type: 'Group', expressions, start: startToken.position, end: close.end };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private parseVariable(token: Token): VariableNode {
|
|
461
|
+
return { type: 'Variable', name: token.value, start: token.position, end: token.end };
|
|
326
462
|
}
|
|
327
463
|
|
|
328
464
|
private parseFunctionCall(token: Token): FunctionCallNode {
|
|
@@ -333,14 +469,18 @@ export class Parser {
|
|
|
333
469
|
|
|
334
470
|
const args: ASTNode[] = [];
|
|
335
471
|
if (this.peek().type !== TokenType.RPAREN) {
|
|
336
|
-
|
|
472
|
+
const first = this.parseExpression(0);
|
|
473
|
+
this.rejectSuccessCountTarget(first, token);
|
|
474
|
+
args.push(first);
|
|
337
475
|
while (this.peek().type === TokenType.COMMA) {
|
|
338
476
|
this.advance();
|
|
339
|
-
|
|
477
|
+
const next = this.parseExpression(0);
|
|
478
|
+
this.rejectSuccessCountTarget(next, token);
|
|
479
|
+
args.push(next);
|
|
340
480
|
}
|
|
341
481
|
}
|
|
342
482
|
|
|
343
|
-
this.expect(TokenType.RPAREN);
|
|
483
|
+
const close = this.expect(TokenType.RPAREN);
|
|
344
484
|
|
|
345
485
|
const arity = FUNCTION_ARITY[token.value];
|
|
346
486
|
if (arity === undefined) {
|
|
@@ -370,24 +510,69 @@ export class Parser {
|
|
|
370
510
|
);
|
|
371
511
|
}
|
|
372
512
|
|
|
373
|
-
return {
|
|
513
|
+
return {
|
|
514
|
+
type: 'FunctionCall',
|
|
515
|
+
name: token.value,
|
|
516
|
+
args,
|
|
517
|
+
start: token.position,
|
|
518
|
+
end: close.end,
|
|
519
|
+
};
|
|
374
520
|
}
|
|
375
521
|
|
|
376
522
|
private parseBinaryOp(left: ASTNode, token: Token): BinaryOpNode {
|
|
523
|
+
this.rejectSuccessCountTarget(left, token);
|
|
524
|
+
|
|
377
525
|
const operator = this.getOperatorSymbol(token);
|
|
378
526
|
const rightBp = this.getRightBp(token);
|
|
379
527
|
const right = this.parseExpression(rightBp);
|
|
380
528
|
|
|
529
|
+
this.rejectSuccessCountTarget(right, token);
|
|
530
|
+
|
|
381
531
|
return {
|
|
382
532
|
type: 'BinaryOp',
|
|
383
533
|
operator,
|
|
384
534
|
left,
|
|
385
535
|
right,
|
|
536
|
+
start: left.start ?? token.position,
|
|
537
|
+
end: right.end ?? token.end,
|
|
386
538
|
};
|
|
387
539
|
}
|
|
388
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Rejects a dice token whose count operand is itself a bare (unparenthesized)
|
|
543
|
+
* dice expression. `4d6d1` would otherwise silently parse as `(4d6)d1` —
|
|
544
|
+
* roll 4d6, then use the result as a count of d1 dice — which is almost
|
|
545
|
+
* never intended: every major dice dialect reads `4d6d1` as "drop lowest 1".
|
|
546
|
+
* Both meanings stay reachable through explicit forms: `4d6dl1` to drop,
|
|
547
|
+
* `(4d6)d1` for nested dice.
|
|
548
|
+
*/
|
|
549
|
+
private rejectBareDiceChain(left: ASTNode, token: Token): void {
|
|
550
|
+
switch (left.type) {
|
|
551
|
+
case 'Dice':
|
|
552
|
+
case 'FateDice':
|
|
553
|
+
case 'Explode':
|
|
554
|
+
case 'Reroll':
|
|
555
|
+
case 'Modifier':
|
|
556
|
+
case 'Sort':
|
|
557
|
+
case 'CritThreshold':
|
|
558
|
+
throw new ParseError(
|
|
559
|
+
`Ambiguous dice chain: use 'dl'/'dh' to drop dice (4d6dl1) or parentheses for nested dice ((4d6)d1)`,
|
|
560
|
+
'AMBIGUOUS_DICE_CHAIN',
|
|
561
|
+
token.position,
|
|
562
|
+
token,
|
|
563
|
+
);
|
|
564
|
+
default:
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
389
569
|
private rejectSuccessCountTarget(target: ASTNode, token: Token): void {
|
|
390
|
-
|
|
570
|
+
// ? Narrow unwrap: only `Grouped`. A `SuccessCount` cannot live inside
|
|
571
|
+
// `Modifier`/`Sort`/`CritThreshold` because each of those parsers calls
|
|
572
|
+
// this same reject on their target before constructing the wrapper —
|
|
573
|
+
// so widening the set here would never match.
|
|
574
|
+
const node = unwrapTransparent(target, ['Grouped']);
|
|
575
|
+
if (isSuccessCount(node)) {
|
|
391
576
|
throw new ParseError(
|
|
392
577
|
`Cannot apply modifier after success counting`,
|
|
393
578
|
'INVALID_SUCCESS_COUNT_TARGET',
|
|
@@ -397,8 +582,102 @@ export class Parser {
|
|
|
397
582
|
}
|
|
398
583
|
}
|
|
399
584
|
|
|
585
|
+
/**
|
|
586
|
+
* Rejects `GroupNode` (or a wrapper-cloaked group) as the target of `token`.
|
|
587
|
+
* Explode, reroll, and crit-threshold wrap bare dice pools only — a group
|
|
588
|
+
* is a container of sub-expressions, so these modifiers have no defined
|
|
589
|
+
* semantics. Walks `Grouped`/`Modifier`/`Sort`/`CritThreshold` so wrappers
|
|
590
|
+
* cannot smuggle a group past the check (`{1d6}kh1cs>5`, `({1d6})!`,
|
|
591
|
+
* `{1d6}scs>5` all reject the same as `{1d6}!`/`{1d6}cs>5`).
|
|
592
|
+
*
|
|
593
|
+
* `singleSubRollPasses` opts the caller into the Stage 3 single-sub-roll
|
|
594
|
+
* passthrough rule (STAGE3.md "Group Semantics: Single vs Multi Sub-Roll"):
|
|
595
|
+
* a `Group` with one expression is the user's explicit flat-pool escape
|
|
596
|
+
* hatch and is equivalent to its unwrapped form. Currently only
|
|
597
|
+
* `parseCritThreshold` opts in — explode/reroll keep the strict reject so
|
|
598
|
+
* existing notation contracts don't shift.
|
|
599
|
+
*/
|
|
600
|
+
private rejectGroupTarget(
|
|
601
|
+
target: ASTNode,
|
|
602
|
+
token: Token,
|
|
603
|
+
action: string,
|
|
604
|
+
code: RollParserErrorCode,
|
|
605
|
+
singleSubRollPasses = false,
|
|
606
|
+
): void {
|
|
607
|
+
const node = unwrapTransparent(target, ['Grouped', 'Modifier', 'Sort', 'CritThreshold']);
|
|
608
|
+
if (node.type !== 'Group') return;
|
|
609
|
+
if (singleSubRollPasses && node.expressions.length === 1) {
|
|
610
|
+
// ! Deep-walk the inner sub-expression — `unwrapTransparent` only peels
|
|
611
|
+
// `Grouped`/`Modifier`/`Sort`/`CritThreshold`, so a multi-sub Group
|
|
612
|
+
// buried under arithmetic (`{{1d6,2d8}+0}cs>5`), function calls
|
|
613
|
+
// (`{abs({1d6,2d8})}cs>5`), or unary ops would otherwise revive the
|
|
614
|
+
// exact dropped-die-flag bug from #97.
|
|
615
|
+
const inner = node.expressions[0];
|
|
616
|
+
if (inner != null && containsMultiSubGroup(inner)) {
|
|
617
|
+
throw new ParseError(`Cannot ${action} a group`, code, token.position, token);
|
|
618
|
+
}
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
throw new ParseError(`Cannot ${action} a group`, code, token.position, token);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private rejectVersusTarget(target: ASTNode, token: Token): void {
|
|
625
|
+
// ? Versus produces a PF2e degree outcome — a terminal scalar, not a
|
|
626
|
+
// valid input to dice count/sides/thresholds. Symmetric with
|
|
627
|
+
// `rejectSuccessCountTarget`; wrappers like `floor(vs)+0` still
|
|
628
|
+
// propagate `versusMetadata` via `mergeContext`, so this only blocks
|
|
629
|
+
// `mergeMetaRolls` sites where metadata would silently vanish.
|
|
630
|
+
// ? Narrow unwrap: only `Grouped`. `Modifier`/`Sort`/`CritThreshold`
|
|
631
|
+
// cannot wrap a `Versus` because `containsDicePool` does not recurse
|
|
632
|
+
// into `Versus`, so each of those parsers rejects the wrap upstream.
|
|
633
|
+
const node = unwrapTransparent(target, ['Grouped']);
|
|
634
|
+
if (node.type === 'Versus') {
|
|
635
|
+
throw new ParseError(
|
|
636
|
+
`Versus cannot be used as a meta-expression`,
|
|
637
|
+
'NESTED_VERSUS',
|
|
638
|
+
token.position,
|
|
639
|
+
token,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
// ! Single-sub-roll `Group` passthrough is the new sibling route —
|
|
643
|
+
// `containsDicePool` recurses into Group's single sub-expression via
|
|
644
|
+
// `deepContainsDicePool`, which traverses Versus's `roll`/`dc`. Deep
|
|
645
|
+
// walk for any descendant Versus so `{1d20 vs 15}cs>18`,
|
|
646
|
+
// `{1+(1d20 vs 15)}cs>18`, and `4d6>={abs(1d20 vs 15)}` reject too.
|
|
647
|
+
if (node.type === 'Group' && node.expressions.length === 1) {
|
|
648
|
+
const inner = node.expressions[0];
|
|
649
|
+
if (inner != null && containsVersus(inner)) {
|
|
650
|
+
throw new ParseError(
|
|
651
|
+
`Versus cannot be used as a meta-expression`,
|
|
652
|
+
'NESTED_VERSUS',
|
|
653
|
+
token.position,
|
|
654
|
+
token,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
400
660
|
private parseModifier(target: ASTNode, token: Token): ModifierNode {
|
|
401
661
|
this.rejectSuccessCountTarget(target, token);
|
|
662
|
+
// ! Mirror `parseSort`/`parseCritThreshold` — keep/drop applied to a
|
|
663
|
+
// Versus target silently drops `degree`/`natural` metadata. Pre-existing
|
|
664
|
+
// for `(1d20 vs 15)kh1` (caught upstream by `containsDicePool` with a
|
|
665
|
+
// different error code), but the single-sub-roll Group passthrough makes
|
|
666
|
+
// `{1d20 vs 15}kh1` reachable past `containsDicePool` (Group's deep
|
|
667
|
+
// walk recurses into Versus's roll/dc), so the explicit reject is the
|
|
668
|
+
// only thing that closes the metadata-drop hole.
|
|
669
|
+
this.rejectVersusTarget(target, token);
|
|
670
|
+
|
|
671
|
+
// Keep/drop modifiers need a dice pool to select from. Wrapping arithmetic
|
|
672
|
+
// (e.g. `(1d6+5)kh1`, `4d6+2kh3`) would silently drop user math.
|
|
673
|
+
if (!containsDicePool(target)) {
|
|
674
|
+
throw new ParseError(
|
|
675
|
+
`Keep/drop modifiers require a dice pool target`,
|
|
676
|
+
'INVALID_MODIFIER_TARGET',
|
|
677
|
+
token.position,
|
|
678
|
+
token,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
402
681
|
|
|
403
682
|
const modifier =
|
|
404
683
|
token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
|
|
@@ -410,10 +689,15 @@ export class Parser {
|
|
|
410
689
|
|
|
411
690
|
// Default to 1 when no explicit count follows the modifier (e.g., 4d6kh → 4d6kh1)
|
|
412
691
|
const nextToken = this.peek().type;
|
|
413
|
-
const
|
|
414
|
-
nextToken === TokenType.NUMBER ||
|
|
415
|
-
|
|
416
|
-
|
|
692
|
+
const hasExplicitCount =
|
|
693
|
+
nextToken === TokenType.NUMBER ||
|
|
694
|
+
nextToken === TokenType.LPAREN ||
|
|
695
|
+
nextToken === TokenType.AT;
|
|
696
|
+
const count: ASTNode = hasExplicitCount
|
|
697
|
+
? this.parseExpression(BP.DICE_LEFT)
|
|
698
|
+
: Parser.syntheticLiteral(1, token);
|
|
699
|
+
this.rejectSuccessCountTarget(count, token);
|
|
700
|
+
this.rejectVersusTarget(count, token);
|
|
417
701
|
|
|
418
702
|
return {
|
|
419
703
|
type: 'Modifier',
|
|
@@ -421,12 +705,42 @@ export class Parser {
|
|
|
421
705
|
selector,
|
|
422
706
|
count,
|
|
423
707
|
target,
|
|
708
|
+
start: target.start ?? token.position,
|
|
709
|
+
end: hasExplicitCount ? (count.end ?? token.end) : token.end,
|
|
424
710
|
};
|
|
425
711
|
}
|
|
426
712
|
|
|
427
713
|
private parseExplode(target: ASTNode, token: Token): ExplodeNode {
|
|
428
714
|
this.rejectSuccessCountTarget(target, token);
|
|
429
715
|
|
|
716
|
+
// Groups have no explode semantics — reject outright. Must come before
|
|
717
|
+
// `containsDicePool`, which recurses into `Group` and would otherwise
|
|
718
|
+
// let `{4d6}!` slip through.
|
|
719
|
+
this.rejectGroupTarget(target, token, 'explode', 'INVALID_EXPLODE_TARGET');
|
|
720
|
+
|
|
721
|
+
// Explode needs a dice pool to explode on. Wrapping arithmetic (e.g.
|
|
722
|
+
// `(1d6+5)!`, `floor(1d6/2)!`) would silently drop user math.
|
|
723
|
+
if (!containsDicePool(target)) {
|
|
724
|
+
throw new ParseError(
|
|
725
|
+
`Explode modifier requires a dice pool target`,
|
|
726
|
+
'INVALID_EXPLODE_TARGET',
|
|
727
|
+
token.position,
|
|
728
|
+
token,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Fate dice (sides = 0) cannot explode — the symmetric -1/0/+1 range has
|
|
733
|
+
// no natural "max" trigger, so semantics are undefined. Reject at parse
|
|
734
|
+
// time rather than silently no-op in the evaluator.
|
|
735
|
+
if (containsFatePool(target)) {
|
|
736
|
+
throw new ParseError(
|
|
737
|
+
`Fate dice cannot explode`,
|
|
738
|
+
'INVALID_EXPLODE_TARGET',
|
|
739
|
+
token.position,
|
|
740
|
+
token,
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
430
744
|
// ? Reject nested explodes (e.g., `1d6!!!`) — a second explode token atop
|
|
431
745
|
// an ExplodeNode has no meaningful semantics and is rejected per spec.
|
|
432
746
|
if (target.type === 'Explode') {
|
|
@@ -445,9 +759,16 @@ export class Parser {
|
|
|
445
759
|
? 'compound'
|
|
446
760
|
: 'penetrating';
|
|
447
761
|
|
|
448
|
-
const node: ExplodeNode = {
|
|
762
|
+
const node: ExplodeNode = {
|
|
763
|
+
type: 'Explode',
|
|
764
|
+
variant,
|
|
765
|
+
target,
|
|
766
|
+
start: target.start ?? token.position,
|
|
767
|
+
end: token.end,
|
|
768
|
+
};
|
|
449
769
|
if (this.isComparePointAhead()) {
|
|
450
770
|
node.threshold = this.parseComparePoint();
|
|
771
|
+
node.end = node.threshold.value.end ?? token.end;
|
|
451
772
|
}
|
|
452
773
|
return node;
|
|
453
774
|
}
|
|
@@ -455,6 +776,21 @@ export class Parser {
|
|
|
455
776
|
private parseReroll(target: ASTNode, token: Token): RerollNode {
|
|
456
777
|
this.rejectSuccessCountTarget(target, token);
|
|
457
778
|
|
|
779
|
+
// Groups have no reroll semantics — reject outright. Must come before
|
|
780
|
+
// `containsDicePool`, which recurses into `Group`.
|
|
781
|
+
this.rejectGroupTarget(target, token, 'reroll', 'INVALID_REROLL_TARGET');
|
|
782
|
+
|
|
783
|
+
// Reroll needs a dice pool to inspect. Wrapping arithmetic (e.g.
|
|
784
|
+
// `(1d6+5)r<3`, `floor(1d6/2)ro<3`) would silently drop user math.
|
|
785
|
+
if (!containsDicePool(target)) {
|
|
786
|
+
throw new ParseError(
|
|
787
|
+
`Reroll modifier requires a dice pool target`,
|
|
788
|
+
'INVALID_REROLL_TARGET',
|
|
789
|
+
token.position,
|
|
790
|
+
token,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
458
794
|
// A reroll token must be followed by a comparison — bare `r` / `ro` is invalid.
|
|
459
795
|
if (!this.isComparePointAhead()) {
|
|
460
796
|
throw new ParseError(
|
|
@@ -468,18 +804,153 @@ export class Parser {
|
|
|
468
804
|
const once = token.type === TokenType.REROLL_ONCE;
|
|
469
805
|
const condition = this.parseComparePoint();
|
|
470
806
|
|
|
471
|
-
return {
|
|
807
|
+
return {
|
|
808
|
+
type: 'Reroll',
|
|
809
|
+
once,
|
|
810
|
+
condition,
|
|
811
|
+
target,
|
|
812
|
+
start: target.start ?? token.position,
|
|
813
|
+
end: condition.value.end ?? token.end,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private parseSort(target: ASTNode, token: Token): SortNode {
|
|
818
|
+
this.rejectSuccessCountTarget(target, token);
|
|
819
|
+
this.rejectVersusTarget(target, token);
|
|
820
|
+
|
|
821
|
+
// Sort is purely visual but still needs a dice pool to reorder —
|
|
822
|
+
// `5s`, `(1+2)s`, or `floor(5)s` have no dice to touch. Uses the deep
|
|
823
|
+
// guard so arithmetic-wrapped pools like `(1d6+2d8)s` are accepted per
|
|
824
|
+
// Stage 3 spec, while pure literals/arithmetic reject.
|
|
825
|
+
if (!deepContainsDicePool(target)) {
|
|
826
|
+
throw new ParseError(
|
|
827
|
+
`Sort modifier requires a dice pool target`,
|
|
828
|
+
'INVALID_SORT_TARGET',
|
|
829
|
+
token.position,
|
|
830
|
+
token,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ! Multi-sub-roll groups (`{a, b}s`, `({a, b})s`) need hierarchical
|
|
835
|
+
// sort per Stage 3 spec §3 (sort dice within each sub-roll, then sort
|
|
836
|
+
// sub-rolls by total) — `evalSort` only flat-sorts, so accepting the
|
|
837
|
+
// syntax would silently ship non-spec behaviour. Reject at parse time
|
|
838
|
+
// until the deferred Stage 4 implementation lands. Single-sub Groups
|
|
839
|
+
// keep passing through (the unwrap returns a `Group` with one
|
|
840
|
+
// expression, which is the user's flat-pool escape hatch).
|
|
841
|
+
const base = unwrapTransparent(target, ['Grouped', 'Modifier', 'Sort', 'CritThreshold']);
|
|
842
|
+
if (base.type === 'Group' && base.expressions.length >= 2) {
|
|
843
|
+
throw new ParseError(
|
|
844
|
+
`Sort modifier does not yet support multi-sub-roll groups`,
|
|
845
|
+
'INVALID_SORT_TARGET',
|
|
846
|
+
token.position,
|
|
847
|
+
token,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const order: SortNode['order'] = token.type === TokenType.SORT_ASC ? 'ascending' : 'descending';
|
|
852
|
+
|
|
853
|
+
// ? Chained sorts (`4d6ss`, `4d6sasd`) are allowed — sort is idempotent
|
|
854
|
+
// when repeated in the same direction; a later `sd` after `s` just
|
|
855
|
+
// overrides the order since both pass over the same pool.
|
|
856
|
+
return {
|
|
857
|
+
type: 'Sort',
|
|
858
|
+
order,
|
|
859
|
+
target,
|
|
860
|
+
start: target.start ?? token.position,
|
|
861
|
+
end: token.end,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
private parseCritThreshold(target: ASTNode, token: Token): CritThresholdNode {
|
|
866
|
+
this.rejectSuccessCountTarget(target, token);
|
|
867
|
+
this.rejectVersusTarget(target, token);
|
|
868
|
+
|
|
869
|
+
// Multi-sub-roll groups have no crit-threshold semantics per Stage 3 spec
|
|
870
|
+
// — a group there is a container of sub-roll subtotals, not a dice pool,
|
|
871
|
+
// and applying cs/cf would override `critical`/`fumble` on dropped
|
|
872
|
+
// sub-roll dice. Single-sub-roll groups pass through under the documented
|
|
873
|
+
// flat-pool rule (`{1d20}kh1cs>18` ≡ `(1d20)kh1cs>18`). Must run before
|
|
874
|
+
// `containsDicePool`, which recurses into `Group`.
|
|
875
|
+
this.rejectGroupTarget(
|
|
876
|
+
target,
|
|
877
|
+
token,
|
|
878
|
+
'apply crit threshold to',
|
|
879
|
+
'INVALID_CRIT_THRESHOLD_TARGET',
|
|
880
|
+
true,
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
// Shallow dice-pool check (bare dice only) — rejects `(1d6+2d8)cs>5`,
|
|
884
|
+
// `5cs`, `(1+2)cs`, `floor(5)cs`. Matches explode/reroll behavior.
|
|
885
|
+
// Note: if `target` already is a `CritThresholdNode`, `containsDicePool`
|
|
886
|
+
// recurses into its `target`, so chained `cs`/`cf` naturally pass.
|
|
887
|
+
if (!containsDicePool(target)) {
|
|
888
|
+
throw new ParseError(
|
|
889
|
+
`Crit threshold modifier requires a dice pool target`,
|
|
890
|
+
'INVALID_CRIT_THRESHOLD_TARGET',
|
|
891
|
+
token.position,
|
|
892
|
+
token,
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Bare `cs`/`cf` resolve to a per-die default that assumes max-side / 1
|
|
897
|
+
// semantics — incompatible with Fate dice (`{-1, 0, +1}`), where the bare
|
|
898
|
+
// fumble check would flip the best face (`+1`) into a fumble. Custom
|
|
899
|
+
// thresholds with explicit ComparePoints remain accepted. Mirrors the
|
|
900
|
+
// Fate-explosion rejection above.
|
|
901
|
+
if (!this.isComparePointAhead() && containsFatePool(target)) {
|
|
902
|
+
throw new ParseError(
|
|
903
|
+
`Bare cs/cf cannot apply to Fate dice`,
|
|
904
|
+
'INVALID_CRIT_THRESHOLD_TARGET',
|
|
905
|
+
token.position,
|
|
906
|
+
token,
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const threshold: CritThreshold = this.isComparePointAhead()
|
|
911
|
+
? this.parseComparePoint()
|
|
912
|
+
: 'default';
|
|
913
|
+
const end = threshold === 'default' ? token.end : (threshold.value.end ?? token.end);
|
|
914
|
+
|
|
915
|
+
// ? Unwrap parens so `(1d20cs>19)cs=1` chains into the inner node. Without
|
|
916
|
+
// unwrapping, the outer `cs` would create a second CritThresholdNode
|
|
917
|
+
// wrapping the Grouped — the collapse-into-single-node design decision
|
|
918
|
+
// from STAGE3.md §"CritThreshold Collects Multiple Thresholds" would
|
|
919
|
+
// be subtly broken for any user who parenthesizes the chain.
|
|
920
|
+
let chainTarget: ASTNode = target;
|
|
921
|
+
while (chainTarget.type === 'Grouped') {
|
|
922
|
+
chainTarget = chainTarget.expression;
|
|
923
|
+
}
|
|
924
|
+
if (isCritThreshold(chainTarget)) {
|
|
925
|
+
if (token.type === TokenType.CRIT_SUCCESS) {
|
|
926
|
+
chainTarget.successThresholds.push(threshold);
|
|
927
|
+
} else {
|
|
928
|
+
chainTarget.failThresholds.push(threshold);
|
|
929
|
+
}
|
|
930
|
+
chainTarget.end = end;
|
|
931
|
+
return chainTarget;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
return {
|
|
935
|
+
type: 'CritThreshold',
|
|
936
|
+
successThresholds: token.type === TokenType.CRIT_SUCCESS ? [threshold] : [],
|
|
937
|
+
failThresholds: token.type === TokenType.CRIT_FAIL ? [threshold] : [],
|
|
938
|
+
target,
|
|
939
|
+
start: target.start ?? token.position,
|
|
940
|
+
end,
|
|
941
|
+
};
|
|
472
942
|
}
|
|
473
943
|
|
|
474
944
|
private parseSuccessCount(target: ASTNode, token: Token): SuccessCountNode {
|
|
475
945
|
// Success counting is terminal: chaining (`>=5>=3`) has no semantics.
|
|
476
946
|
this.rejectSuccessCountTarget(target, token);
|
|
477
947
|
|
|
478
|
-
// Reject non-
|
|
479
|
-
// operates on a dice pool;
|
|
480
|
-
|
|
948
|
+
// Reject non-pool targets like `1>=3`, `(1+2)>=3`, `(1d6*2)>=10`, or
|
|
949
|
+
// `(1d20 vs 15)>=1`. Success counting operates on a raw dice pool; any
|
|
950
|
+
// arithmetic or composition wrapping would be silently ignored.
|
|
951
|
+
if (!containsDicePool(target)) {
|
|
481
952
|
throw new ParseError(
|
|
482
|
-
`Success counting requires a dice
|
|
953
|
+
`Success counting requires a dice pool target`,
|
|
483
954
|
'INVALID_SUCCESS_COUNT_TARGET',
|
|
484
955
|
token.position,
|
|
485
956
|
token,
|
|
@@ -487,33 +958,60 @@ export class Parser {
|
|
|
487
958
|
}
|
|
488
959
|
|
|
489
960
|
const operator = this.getCompareOp(token);
|
|
961
|
+
// ? Threshold binding: `BP.DICE_LEFT` — see `parseComparePoint` JSDoc.
|
|
490
962
|
const value = this.parseExpression(BP.DICE_LEFT);
|
|
963
|
+
this.rejectSuccessCountTarget(value, token);
|
|
964
|
+
this.rejectVersusTarget(value, token);
|
|
491
965
|
const node: SuccessCountNode = {
|
|
492
966
|
type: 'SuccessCount',
|
|
493
967
|
target,
|
|
494
968
|
threshold: { operator, value },
|
|
969
|
+
start: target.start ?? token.position,
|
|
970
|
+
end: value.end ?? token.end,
|
|
495
971
|
};
|
|
496
972
|
|
|
497
973
|
if (this.peek().type === TokenType.FAIL) {
|
|
498
974
|
this.advance();
|
|
499
|
-
|
|
500
|
-
|
|
975
|
+
if (this.isComparePointAhead()) {
|
|
976
|
+
node.failThreshold = this.parseComparePoint();
|
|
977
|
+
} else {
|
|
978
|
+
// ? Same threshold binding as above (BP.DICE_LEFT).
|
|
979
|
+
const failValue = this.parseExpression(BP.DICE_LEFT);
|
|
980
|
+
this.rejectSuccessCountTarget(failValue, token);
|
|
981
|
+
this.rejectVersusTarget(failValue, token);
|
|
982
|
+
node.failThreshold = { operator: '=', value: failValue };
|
|
983
|
+
}
|
|
984
|
+
node.end = node.failThreshold.value.end ?? node.end ?? token.end;
|
|
501
985
|
}
|
|
502
986
|
|
|
503
987
|
return node;
|
|
504
988
|
}
|
|
505
989
|
|
|
506
990
|
private parseVersus(left: ASTNode, token: Token): VersusNode {
|
|
991
|
+
this.rejectSuccessCountTarget(left, token);
|
|
992
|
+
|
|
507
993
|
// ? Chained `a vs b vs c` has no semantics — a degree is a scalar, not a
|
|
508
|
-
// comparable.
|
|
509
|
-
//
|
|
510
|
-
|
|
994
|
+
// comparable. Unwrap `Grouped` so `(a vs b) vs c` rejects at parse
|
|
995
|
+
// time like the bare form; paren-nested DC (`a vs (b vs c)`) is still
|
|
996
|
+
// caught by the evaluator via `mergeContext`.
|
|
997
|
+
let leftChain: ASTNode = left;
|
|
998
|
+
while (leftChain.type === 'Grouped') {
|
|
999
|
+
leftChain = leftChain.expression;
|
|
1000
|
+
}
|
|
1001
|
+
if (leftChain.type === 'Versus') {
|
|
511
1002
|
throw new ParseError('Cannot chain versus operators', 'NESTED_VERSUS', token.position, token);
|
|
512
1003
|
}
|
|
513
1004
|
|
|
514
1005
|
const dc = this.parseExpression(BP.VS_RIGHT);
|
|
1006
|
+
this.rejectSuccessCountTarget(dc, token);
|
|
515
1007
|
|
|
516
|
-
return {
|
|
1008
|
+
return {
|
|
1009
|
+
type: 'Versus',
|
|
1010
|
+
roll: left,
|
|
1011
|
+
dc,
|
|
1012
|
+
start: left.start ?? token.position,
|
|
1013
|
+
end: dc.end ?? token.end,
|
|
1014
|
+
};
|
|
517
1015
|
}
|
|
518
1016
|
|
|
519
1017
|
// * Compare point utilities
|
|
@@ -536,6 +1034,13 @@ export class Parser {
|
|
|
536
1034
|
* Parses a comparison operator followed by a value expression.
|
|
537
1035
|
* Called by modifier parsers (explode, reroll, success counting).
|
|
538
1036
|
*
|
|
1037
|
+
* The threshold value is parsed at `BP.DICE_LEFT`, which binds tighter than
|
|
1038
|
+
* arithmetic. This keeps the comparison bound to the dice pool on the left
|
|
1039
|
+
* rather than letting arithmetic to the right be consumed into the
|
|
1040
|
+
* threshold. As a consequence, `1d6>=5+2` parses as `(1d6>=5)+2` with
|
|
1041
|
+
* threshold `5` — not `7`. Computed thresholds require parens:
|
|
1042
|
+
* `1d6>=(5+2)`. Same binding applies to `parseSuccessCount` below.
|
|
1043
|
+
*
|
|
539
1044
|
* @returns A ComparePoint with the operator and value AST node
|
|
540
1045
|
* @throws {ParseError} If the next token is not a comparison operator
|
|
541
1046
|
*/
|
|
@@ -546,6 +1051,8 @@ export class Parser {
|
|
|
546
1051
|
this.advance();
|
|
547
1052
|
|
|
548
1053
|
const value = this.parseExpression(BP.DICE_LEFT);
|
|
1054
|
+
this.rejectSuccessCountTarget(value, token);
|
|
1055
|
+
this.rejectVersusTarget(value, token);
|
|
549
1056
|
|
|
550
1057
|
return { operator, value };
|
|
551
1058
|
}
|
|
@@ -624,20 +1131,33 @@ export class Parser {
|
|
|
624
1131
|
case TokenType.EXPLODE_PENETRATING:
|
|
625
1132
|
case TokenType.REROLL:
|
|
626
1133
|
case TokenType.REROLL_ONCE:
|
|
1134
|
+
case TokenType.SORT_ASC:
|
|
1135
|
+
case TokenType.SORT_DESC:
|
|
1136
|
+
case TokenType.CRIT_SUCCESS:
|
|
1137
|
+
case TokenType.CRIT_FAIL:
|
|
1138
|
+
return BP.MODIFIER;
|
|
627
1139
|
// Comparison operators act as LED-dispatched success-count modifiers
|
|
628
|
-
// at the Pratt level.
|
|
629
|
-
//
|
|
1140
|
+
// at the Pratt level. Sit below ADD/MUL so arithmetic on a dice pool
|
|
1141
|
+
// completes before success counting wraps it — `5d6+2>4` parses as
|
|
1142
|
+
// `(5d6+2)>4` and then cleanly fails the pool-target guard, rather
|
|
1143
|
+
// than the `>` stealing `2` from the `+`. Inside `parseComparePoint`
|
|
1144
|
+
// (called manually by explode/reroll) this BP is bypassed.
|
|
630
1145
|
case TokenType.GREATER:
|
|
631
1146
|
case TokenType.GREATER_EQUAL:
|
|
632
1147
|
case TokenType.LESS:
|
|
633
1148
|
case TokenType.LESS_EQUAL:
|
|
634
1149
|
case TokenType.EQUAL:
|
|
635
|
-
return BP.
|
|
1150
|
+
return BP.COMPARE;
|
|
636
1151
|
case TokenType.RPAREN:
|
|
637
1152
|
case TokenType.EOF:
|
|
638
1153
|
// Punctuation and keywords that terminate expressions
|
|
639
1154
|
case TokenType.COMMA:
|
|
640
1155
|
case TokenType.FUNCTION:
|
|
1156
|
+
// Group boundaries: `}` closes the current group (consumed inside
|
|
1157
|
+
// `parseGroup`), and a stray `{` after a complete expression is an
|
|
1158
|
+
// error. Both terminate the outer Pratt loop at -1.
|
|
1159
|
+
case TokenType.LBRACE:
|
|
1160
|
+
case TokenType.RBRACE:
|
|
641
1161
|
return -1;
|
|
642
1162
|
default:
|
|
643
1163
|
return 0;
|
|
@@ -665,7 +1185,9 @@ export class Parser {
|
|
|
665
1185
|
}
|
|
666
1186
|
|
|
667
1187
|
private peek(): Token {
|
|
668
|
-
return
|
|
1188
|
+
return (
|
|
1189
|
+
this.tokens[this.pos] ?? { type: TokenType.EOF, value: '', position: this.pos, end: this.pos }
|
|
1190
|
+
);
|
|
669
1191
|
}
|
|
670
1192
|
|
|
671
1193
|
private advance(): Token {
|
|
@@ -677,9 +1199,10 @@ export class Parser {
|
|
|
677
1199
|
private expect(type: TokenType): Token {
|
|
678
1200
|
const token = this.peek();
|
|
679
1201
|
if (token.type !== type) {
|
|
680
|
-
const expected = TokenType[type];
|
|
1202
|
+
const expected = TOKEN_DISPLAY[type] ?? TokenType[type];
|
|
1203
|
+
const got = token.type === TokenType.EOF ? 'end of input' : `'${token.value}'`;
|
|
681
1204
|
throw new ParseError(
|
|
682
|
-
`Expected ${expected} but got
|
|
1205
|
+
`Expected ${expected} but got ${got}`,
|
|
683
1206
|
'EXPECTED_TOKEN',
|
|
684
1207
|
token.position,
|
|
685
1208
|
token,
|