roll-parser 2.3.2 → 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 +175 -170
- package/dist/cli/args.d.ts +35 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/format.d.ts +20 -0
- package/dist/cli/format.d.ts.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli.js +2608 -0
- package/dist/cli.js.map +28 -0
- package/dist/errors.d.ts +42 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/evaluator/evaluator.d.ts +121 -0
- package/dist/evaluator/evaluator.d.ts.map +1 -0
- package/dist/evaluator/index.d.ts +8 -0
- package/dist/evaluator/index.d.ts.map +1 -0
- package/dist/evaluator/modifiers/compare.d.ts +15 -0
- package/dist/evaluator/modifiers/compare.d.ts.map +1 -0
- 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 +48 -0
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts +44 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -0
- package/dist/evaluator/modifiers/reroll.d.ts +33 -0
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -0
- 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 +28 -0
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2560 -0
- package/dist/index.js.map +26 -0
- package/dist/lexer/lexer.d.ts +90 -0
- package/dist/lexer/lexer.d.ts.map +1 -0
- package/dist/lexer/tokens.d.ts +107 -0
- package/dist/lexer/tokens.d.ts.map +1 -0
- package/dist/parser/ast.d.ts +368 -0
- package/dist/parser/ast.d.ts.map +1 -0
- package/dist/parser/parser.d.ts +145 -0
- package/dist/parser/parser.d.ts.map +1 -0
- package/dist/rng/index.d.ts +8 -0
- package/dist/rng/index.d.ts.map +1 -0
- package/dist/rng/mock.d.ts +36 -0
- package/dist/rng/mock.d.ts.map +1 -0
- package/dist/rng/seeded.d.ts +40 -0
- package/dist/rng/seeded.d.ts.map +1 -0
- package/dist/rng/types.d.ts +24 -0
- package/dist/rng/types.d.ts.map +1 -0
- package/dist/roll.d.ts +51 -0
- package/dist/roll.d.ts.map +1 -0
- package/dist/testing.d.ts +11 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +41 -0
- package/dist/testing.js.map +11 -0
- package/dist/types.d.ts +246 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +66 -43
- package/src/cli/args.ts +73 -0
- package/src/cli/format.ts +49 -0
- package/src/cli/index.ts +92 -0
- package/src/errors.ts +90 -0
- package/src/evaluator/evaluator.ts +1552 -0
- package/src/evaluator/index.ts +14 -0
- package/src/evaluator/modifiers/compare.ts +29 -0
- package/src/evaluator/modifiers/crit-threshold.ts +59 -0
- package/src/evaluator/modifiers/explode.ts +219 -0
- package/src/evaluator/modifiers/keep-drop.ts +151 -0
- package/src/evaluator/modifiers/reroll.ts +165 -0
- package/src/evaluator/modifiers/sort.ts +30 -0
- package/src/evaluator/modifiers/success-count.ts +59 -0
- package/src/index.ts +90 -0
- package/src/lexer/lexer.ts +374 -0
- package/src/lexer/tokens.ts +187 -0
- package/src/parser/ast.ts +628 -0
- package/src/parser/parser.ts +1239 -0
- package/src/rng/index.ts +8 -0
- package/src/rng/mock.ts +65 -0
- package/src/rng/seeded.ts +157 -0
- package/src/rng/types.ts +25 -0
- package/src/roll.ts +74 -0
- package/src/testing.ts +16 -0
- package/src/types.ts +237 -0
- package/bin/help.txt +0 -19
- package/bin/roll-parser.js +0 -23
- package/dist/roll-parser.js +0 -697
- package/dist/roll-parser.min.js +0 -1
- package/index.js +0 -24
- package/src/complex/parse.js +0 -37
- package/src/complex/parseAndRoll.js +0 -21
- package/src/complex/parseAndRollClassic.js +0 -20
- package/src/complex/parseAndRollSimple.js +0 -19
- package/src/complex/parseAndRollWod.js +0 -20
- package/src/complex/parseClassicRoll.js +0 -22
- package/src/complex/parseSimpleRoll.js +0 -21
- package/src/complex/parseWodRoll.js +0 -21
- package/src/converter.js +0 -45
- package/src/grammar.js +0 -14
- package/src/mapper.js +0 -50
- package/src/normalizer.js +0 -51
- package/src/object/Result.js +0 -23
- package/src/object/Roll.js +0 -26
- package/src/object/Type.js +0 -5
- package/src/object/WodRoll.js +0 -29
- package/src/parser.js +0 -43
- package/src/random.js +0 -22
- package/src/roller.js +0 -106
- package/src/stringifier.js +0 -33
|
@@ -0,0 +1,1239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pratt parser for dice notation.
|
|
3
|
+
*
|
|
4
|
+
* @module parser/parser
|
|
5
|
+
*/
|
|
6
|
+
|
|
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
|
+
import type {
|
|
13
|
+
ASTNode,
|
|
14
|
+
BinaryOpNode,
|
|
15
|
+
CritThreshold,
|
|
16
|
+
CritThresholdNode,
|
|
17
|
+
DiceNode,
|
|
18
|
+
ExplodeNode,
|
|
19
|
+
FateDiceNode,
|
|
20
|
+
FunctionCallNode,
|
|
21
|
+
GroupedNode,
|
|
22
|
+
GroupNode,
|
|
23
|
+
LiteralNode,
|
|
24
|
+
ModifierNode,
|
|
25
|
+
RerollNode,
|
|
26
|
+
SortNode,
|
|
27
|
+
SuccessCountNode,
|
|
28
|
+
UnaryOpNode,
|
|
29
|
+
VariableNode,
|
|
30
|
+
VersusNode,
|
|
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';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Error thrown when the parser encounters invalid syntax.
|
|
45
|
+
*/
|
|
46
|
+
export class ParseError extends RollParserError {
|
|
47
|
+
readonly position: number;
|
|
48
|
+
readonly token: Token | undefined;
|
|
49
|
+
|
|
50
|
+
constructor(message: string, code: RollParserErrorCode, position: number, token?: Token) {
|
|
51
|
+
super(`${message} at position ${position}`, code);
|
|
52
|
+
this.name = 'ParseError';
|
|
53
|
+
this.position = position;
|
|
54
|
+
this.token = token ?? undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Binding power constants for operators.
|
|
60
|
+
* Higher values bind tighter. Right < Left for right-associativity.
|
|
61
|
+
*
|
|
62
|
+
* Precedence order (lowest to highest):
|
|
63
|
+
* - Versus (`vs`): 2-3 (lowest — full expressions on both sides)
|
|
64
|
+
* - Addition/subtraction: 10
|
|
65
|
+
* - Multiplication/division/modulo: 20
|
|
66
|
+
* - Unary minus: 25 (binds to complete dice expr: -1d4 = -(1d4))
|
|
67
|
+
* - Power: 30-31
|
|
68
|
+
* - Modifiers (postfix): 35 (must be < DICE_RIGHT to bind to complete dice expr)
|
|
69
|
+
* - Dice: 40-41
|
|
70
|
+
*/
|
|
71
|
+
const BP = {
|
|
72
|
+
// Versus (left-associative, lowest precedence — `1d20+10 vs 25+10` = `(1d20+10) vs (25+10)`)
|
|
73
|
+
VS_LEFT: 2,
|
|
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,
|
|
79
|
+
// Addition/subtraction (left-associative)
|
|
80
|
+
ADD_LEFT: 10,
|
|
81
|
+
ADD_RIGHT: 11,
|
|
82
|
+
// Multiplication/division/modulo (left-associative)
|
|
83
|
+
MUL_LEFT: 20,
|
|
84
|
+
MUL_RIGHT: 21,
|
|
85
|
+
// Unary minus: between mul and power so -1d4 = -(1d4) not (-1)d4
|
|
86
|
+
UNARY: 25,
|
|
87
|
+
// Power (right-associative: left > right)
|
|
88
|
+
POW_LEFT: 31,
|
|
89
|
+
POW_RIGHT: 30,
|
|
90
|
+
// Postfix modifiers: must be < DICE_RIGHT so they bind to complete dice expr
|
|
91
|
+
MODIFIER: 35,
|
|
92
|
+
// Dice operator (highest math precedence)
|
|
93
|
+
DICE_LEFT: 40,
|
|
94
|
+
DICE_RIGHT: 41,
|
|
95
|
+
} as const;
|
|
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
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Arity table for math functions. `min` and `max` are inclusive.
|
|
117
|
+
* `POSITIVE_INFINITY` means unbounded (variadic).
|
|
118
|
+
*/
|
|
119
|
+
const FUNCTION_ARITY: Record<string, { min: number; max: number }> = {
|
|
120
|
+
floor: { min: 1, max: 1 },
|
|
121
|
+
ceil: { min: 1, max: 1 },
|
|
122
|
+
round: { min: 1, max: 1 },
|
|
123
|
+
abs: { min: 1, max: 1 },
|
|
124
|
+
max: { min: 2, max: Number.POSITIVE_INFINITY },
|
|
125
|
+
min: { min: 2, max: Number.POSITIVE_INFINITY },
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Pratt parser for dice notation.
|
|
130
|
+
*
|
|
131
|
+
* Uses binding power (precedence) to handle operator associativity and
|
|
132
|
+
* precedence without left recursion issues.
|
|
133
|
+
*/
|
|
134
|
+
export class Parser {
|
|
135
|
+
private readonly tokens: Token[];
|
|
136
|
+
private pos = 0;
|
|
137
|
+
private depth = 0;
|
|
138
|
+
|
|
139
|
+
constructor(tokens: Token[]) {
|
|
140
|
+
this.tokens = tokens;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Parse the token stream into an AST.
|
|
145
|
+
*/
|
|
146
|
+
parse(): ASTNode {
|
|
147
|
+
if (this.peek().type === TokenType.EOF) {
|
|
148
|
+
throw new ParseError('Unexpected end of input', 'UNEXPECTED_END', this.peek().position);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const ast = this.parseExpression(0);
|
|
152
|
+
|
|
153
|
+
// Ensure we consumed all tokens
|
|
154
|
+
if (this.peek().type !== TokenType.EOF) {
|
|
155
|
+
const token = this.peek();
|
|
156
|
+
throw new ParseError(
|
|
157
|
+
`Unexpected token '${token.value}'`,
|
|
158
|
+
'UNEXPECTED_TOKEN',
|
|
159
|
+
token.position,
|
|
160
|
+
token,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return ast;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Parse an expression with minimum binding power.
|
|
169
|
+
*/
|
|
170
|
+
private parseExpression(minBp: number): ASTNode {
|
|
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
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
let left = this.parseNud();
|
|
183
|
+
|
|
184
|
+
while (this.hasTokens()) {
|
|
185
|
+
const token = this.peek();
|
|
186
|
+
const leftBp = this.getLeftBp(token);
|
|
187
|
+
|
|
188
|
+
if (leftBp < minBp) break;
|
|
189
|
+
|
|
190
|
+
this.advance();
|
|
191
|
+
left = this.parseLed(left, token);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return left;
|
|
195
|
+
} finally {
|
|
196
|
+
this.depth -= 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* NUD - Null Denotation.
|
|
202
|
+
* Handles tokens that appear at the start of an expression (prefix position).
|
|
203
|
+
*/
|
|
204
|
+
private parseNud(): ASTNode {
|
|
205
|
+
const token = this.advance();
|
|
206
|
+
|
|
207
|
+
switch (token.type) {
|
|
208
|
+
case TokenType.NUMBER:
|
|
209
|
+
return this.parseLiteral(token);
|
|
210
|
+
|
|
211
|
+
case TokenType.MINUS:
|
|
212
|
+
return this.parseUnaryMinus(token);
|
|
213
|
+
|
|
214
|
+
case TokenType.DICE:
|
|
215
|
+
return this.parsePrefixDice(token);
|
|
216
|
+
|
|
217
|
+
case TokenType.DICE_PERCENT:
|
|
218
|
+
return this.parsePrefixDicePercent(token);
|
|
219
|
+
|
|
220
|
+
case TokenType.DICE_FATE:
|
|
221
|
+
return this.parsePrefixFateDice(token);
|
|
222
|
+
|
|
223
|
+
case TokenType.LPAREN:
|
|
224
|
+
return this.parseGrouped(token);
|
|
225
|
+
|
|
226
|
+
case TokenType.LBRACE:
|
|
227
|
+
return this.parseGroup(token);
|
|
228
|
+
|
|
229
|
+
case TokenType.FUNCTION:
|
|
230
|
+
return this.parseFunctionCall(token);
|
|
231
|
+
|
|
232
|
+
case TokenType.AT:
|
|
233
|
+
return this.parseVariable(token);
|
|
234
|
+
|
|
235
|
+
case TokenType.EOF:
|
|
236
|
+
throw new ParseError('Unexpected end of input', 'UNEXPECTED_END', token.position);
|
|
237
|
+
|
|
238
|
+
default:
|
|
239
|
+
throw new ParseError(
|
|
240
|
+
`Unexpected token '${token.value}'`,
|
|
241
|
+
'UNEXPECTED_TOKEN',
|
|
242
|
+
token.position,
|
|
243
|
+
token,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* LED - Left Denotation.
|
|
250
|
+
* Handles tokens that appear between expressions (infix/postfix position).
|
|
251
|
+
*/
|
|
252
|
+
private parseLed(left: ASTNode, token: Token): ASTNode {
|
|
253
|
+
switch (token.type) {
|
|
254
|
+
case TokenType.DICE:
|
|
255
|
+
return this.parseInfixDice(left, token);
|
|
256
|
+
|
|
257
|
+
case TokenType.DICE_PERCENT:
|
|
258
|
+
return this.parseInfixDicePercent(left, token);
|
|
259
|
+
|
|
260
|
+
case TokenType.DICE_FATE:
|
|
261
|
+
return this.parseInfixFateDice(left, token);
|
|
262
|
+
|
|
263
|
+
case TokenType.PLUS:
|
|
264
|
+
case TokenType.MINUS:
|
|
265
|
+
case TokenType.MULTIPLY:
|
|
266
|
+
case TokenType.DIVIDE:
|
|
267
|
+
case TokenType.MODULO:
|
|
268
|
+
case TokenType.POWER:
|
|
269
|
+
return this.parseBinaryOp(left, token);
|
|
270
|
+
|
|
271
|
+
case TokenType.KEEP_HIGH:
|
|
272
|
+
case TokenType.KEEP_LOW:
|
|
273
|
+
case TokenType.DROP_HIGH:
|
|
274
|
+
case TokenType.DROP_LOW:
|
|
275
|
+
return this.parseModifier(left, token);
|
|
276
|
+
|
|
277
|
+
case TokenType.EXPLODE:
|
|
278
|
+
case TokenType.EXPLODE_COMPOUND:
|
|
279
|
+
case TokenType.EXPLODE_PENETRATING:
|
|
280
|
+
return this.parseExplode(left, token);
|
|
281
|
+
|
|
282
|
+
case TokenType.REROLL:
|
|
283
|
+
case TokenType.REROLL_ONCE:
|
|
284
|
+
return this.parseReroll(left, token);
|
|
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
|
+
|
|
294
|
+
case TokenType.GREATER:
|
|
295
|
+
case TokenType.GREATER_EQUAL:
|
|
296
|
+
case TokenType.LESS:
|
|
297
|
+
case TokenType.LESS_EQUAL:
|
|
298
|
+
case TokenType.EQUAL:
|
|
299
|
+
return this.parseSuccessCount(left, token);
|
|
300
|
+
|
|
301
|
+
case TokenType.VS:
|
|
302
|
+
return this.parseVersus(left, token);
|
|
303
|
+
|
|
304
|
+
default:
|
|
305
|
+
throw new ParseError(
|
|
306
|
+
`Unexpected infix token '${token.value}'`,
|
|
307
|
+
'UNEXPECTED_TOKEN',
|
|
308
|
+
token.position,
|
|
309
|
+
token,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// * Node parsers
|
|
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
|
+
|
|
324
|
+
private parseLiteral(token: Token): LiteralNode {
|
|
325
|
+
return {
|
|
326
|
+
type: 'Literal',
|
|
327
|
+
value: Number.parseFloat(token.value),
|
|
328
|
+
start: token.position,
|
|
329
|
+
end: token.end,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private parseUnaryMinus(token: Token): UnaryOpNode {
|
|
334
|
+
const operand = this.parseExpression(BP.UNARY);
|
|
335
|
+
this.rejectSuccessCountTarget(operand, token);
|
|
336
|
+
return {
|
|
337
|
+
type: 'UnaryOp',
|
|
338
|
+
operator: '-',
|
|
339
|
+
operand,
|
|
340
|
+
start: token.position,
|
|
341
|
+
end: operand.end ?? token.end,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private parsePrefixDice(token: Token): DiceNode {
|
|
346
|
+
// d20 → Dice(1, 20)
|
|
347
|
+
const sides = this.parseExpression(BP.DICE_RIGHT);
|
|
348
|
+
this.rejectSuccessCountTarget(sides, token);
|
|
349
|
+
this.rejectVersusTarget(sides, token);
|
|
350
|
+
return {
|
|
351
|
+
type: 'Dice',
|
|
352
|
+
count: Parser.syntheticLiteral(1, token),
|
|
353
|
+
sides,
|
|
354
|
+
start: token.position,
|
|
355
|
+
end: sides.end ?? token.end,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private parseInfixDice(left: ASTNode, token: Token): DiceNode {
|
|
360
|
+
// 4d6 → Dice(4, 6)
|
|
361
|
+
this.rejectSuccessCountTarget(left, token);
|
|
362
|
+
this.rejectVersusTarget(left, token);
|
|
363
|
+
this.rejectBareDiceChain(left, token);
|
|
364
|
+
const sides = this.parseExpression(BP.DICE_RIGHT);
|
|
365
|
+
this.rejectSuccessCountTarget(sides, token);
|
|
366
|
+
this.rejectVersusTarget(sides, token);
|
|
367
|
+
return {
|
|
368
|
+
type: 'Dice',
|
|
369
|
+
count: left,
|
|
370
|
+
sides,
|
|
371
|
+
start: left.start ?? token.position,
|
|
372
|
+
end: sides.end ?? token.end,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private parsePrefixDicePercent(token: Token): DiceNode {
|
|
377
|
+
// d% → Dice(1, 100)
|
|
378
|
+
return {
|
|
379
|
+
type: 'Dice',
|
|
380
|
+
count: Parser.syntheticLiteral(1, token),
|
|
381
|
+
sides: Parser.syntheticLiteral(100, token),
|
|
382
|
+
start: token.position,
|
|
383
|
+
end: token.end,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private parseInfixDicePercent(left: ASTNode, token: Token): DiceNode {
|
|
388
|
+
// 2d% → Dice(2, 100)
|
|
389
|
+
this.rejectSuccessCountTarget(left, token);
|
|
390
|
+
this.rejectVersusTarget(left, token);
|
|
391
|
+
this.rejectBareDiceChain(left, token);
|
|
392
|
+
return {
|
|
393
|
+
type: 'Dice',
|
|
394
|
+
count: left,
|
|
395
|
+
sides: Parser.syntheticLiteral(100, token),
|
|
396
|
+
start: left.start ?? token.position,
|
|
397
|
+
end: token.end,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private parsePrefixFateDice(token: Token): FateDiceNode {
|
|
402
|
+
// dF → FateDice(1)
|
|
403
|
+
return {
|
|
404
|
+
type: 'FateDice',
|
|
405
|
+
count: Parser.syntheticLiteral(1, token),
|
|
406
|
+
start: token.position,
|
|
407
|
+
end: token.end,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private parseInfixFateDice(left: ASTNode, token: Token): FateDiceNode {
|
|
412
|
+
// 4dF → FateDice(4). Unlike parseInfixDice, there is no sides sub-parse,
|
|
413
|
+
// so modifiers (`kh`, `dl`, …) naturally bind at the outer Pratt loop
|
|
414
|
+
// without BP competition against a right-operand.
|
|
415
|
+
this.rejectSuccessCountTarget(left, token);
|
|
416
|
+
this.rejectVersusTarget(left, token);
|
|
417
|
+
this.rejectBareDiceChain(left, token);
|
|
418
|
+
return {
|
|
419
|
+
type: 'FateDice',
|
|
420
|
+
count: left,
|
|
421
|
+
start: left.start ?? token.position,
|
|
422
|
+
end: token.end,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
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 };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private parseFunctionCall(token: Token): FunctionCallNode {
|
|
465
|
+
// `FUNCTION` has BP = -1 so callers stop here; `COMMA` and `RPAREN` also
|
|
466
|
+
// terminate inner `parseExpression(0)` calls, so argument boundaries are
|
|
467
|
+
// natural.
|
|
468
|
+
this.expect(TokenType.LPAREN);
|
|
469
|
+
|
|
470
|
+
const args: ASTNode[] = [];
|
|
471
|
+
if (this.peek().type !== TokenType.RPAREN) {
|
|
472
|
+
const first = this.parseExpression(0);
|
|
473
|
+
this.rejectSuccessCountTarget(first, token);
|
|
474
|
+
args.push(first);
|
|
475
|
+
while (this.peek().type === TokenType.COMMA) {
|
|
476
|
+
this.advance();
|
|
477
|
+
const next = this.parseExpression(0);
|
|
478
|
+
this.rejectSuccessCountTarget(next, token);
|
|
479
|
+
args.push(next);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const close = this.expect(TokenType.RPAREN);
|
|
484
|
+
|
|
485
|
+
const arity = FUNCTION_ARITY[token.value];
|
|
486
|
+
if (arity === undefined) {
|
|
487
|
+
// ? Unreachable in practice: lexer only emits FUNCTION for registered
|
|
488
|
+
// names. Kept defensive to keep parser/evaluator error-code contract
|
|
489
|
+
// symmetrical.
|
|
490
|
+
throw new ParseError(
|
|
491
|
+
`Unknown function '${token.value}'`,
|
|
492
|
+
'UNKNOWN_FUNCTION',
|
|
493
|
+
token.position,
|
|
494
|
+
token,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (args.length < arity.min || args.length > arity.max) {
|
|
499
|
+
const expected =
|
|
500
|
+
arity.max === Number.POSITIVE_INFINITY
|
|
501
|
+
? `at least ${arity.min}`
|
|
502
|
+
: arity.min === arity.max
|
|
503
|
+
? `${arity.min}`
|
|
504
|
+
: `${arity.min}–${arity.max}`;
|
|
505
|
+
throw new ParseError(
|
|
506
|
+
`Function '${token.value}' expects ${expected} argument${arity.min === 1 && arity.max === 1 ? '' : 's'}, got ${args.length}`,
|
|
507
|
+
'INVALID_FUNCTION_ARITY',
|
|
508
|
+
token.position,
|
|
509
|
+
token,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
type: 'FunctionCall',
|
|
515
|
+
name: token.value,
|
|
516
|
+
args,
|
|
517
|
+
start: token.position,
|
|
518
|
+
end: close.end,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private parseBinaryOp(left: ASTNode, token: Token): BinaryOpNode {
|
|
523
|
+
this.rejectSuccessCountTarget(left, token);
|
|
524
|
+
|
|
525
|
+
const operator = this.getOperatorSymbol(token);
|
|
526
|
+
const rightBp = this.getRightBp(token);
|
|
527
|
+
const right = this.parseExpression(rightBp);
|
|
528
|
+
|
|
529
|
+
this.rejectSuccessCountTarget(right, token);
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
type: 'BinaryOp',
|
|
533
|
+
operator,
|
|
534
|
+
left,
|
|
535
|
+
right,
|
|
536
|
+
start: left.start ?? token.position,
|
|
537
|
+
end: right.end ?? token.end,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
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
|
+
|
|
569
|
+
private rejectSuccessCountTarget(target: ASTNode, token: Token): void {
|
|
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)) {
|
|
576
|
+
throw new ParseError(
|
|
577
|
+
`Cannot apply modifier after success counting`,
|
|
578
|
+
'INVALID_SUCCESS_COUNT_TARGET',
|
|
579
|
+
token.position,
|
|
580
|
+
token,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
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
|
+
|
|
660
|
+
private parseModifier(target: ASTNode, token: Token): ModifierNode {
|
|
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
|
+
}
|
|
681
|
+
|
|
682
|
+
const modifier =
|
|
683
|
+
token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
|
|
684
|
+
|
|
685
|
+
const selector =
|
|
686
|
+
token.type === TokenType.KEEP_HIGH || token.type === TokenType.DROP_HIGH
|
|
687
|
+
? 'highest'
|
|
688
|
+
: 'lowest';
|
|
689
|
+
|
|
690
|
+
// Default to 1 when no explicit count follows the modifier (e.g., 4d6kh → 4d6kh1)
|
|
691
|
+
const nextToken = this.peek().type;
|
|
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);
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
type: 'Modifier',
|
|
704
|
+
modifier,
|
|
705
|
+
selector,
|
|
706
|
+
count,
|
|
707
|
+
target,
|
|
708
|
+
start: target.start ?? token.position,
|
|
709
|
+
end: hasExplicitCount ? (count.end ?? token.end) : token.end,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private parseExplode(target: ASTNode, token: Token): ExplodeNode {
|
|
714
|
+
this.rejectSuccessCountTarget(target, token);
|
|
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
|
+
|
|
744
|
+
// ? Reject nested explodes (e.g., `1d6!!!`) — a second explode token atop
|
|
745
|
+
// an ExplodeNode has no meaningful semantics and is rejected per spec.
|
|
746
|
+
if (target.type === 'Explode') {
|
|
747
|
+
throw new ParseError(
|
|
748
|
+
`Cannot chain explode modifiers`,
|
|
749
|
+
'INVALID_EXPLODE_TARGET',
|
|
750
|
+
token.position,
|
|
751
|
+
token,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const variant: ExplodeNode['variant'] =
|
|
756
|
+
token.type === TokenType.EXPLODE
|
|
757
|
+
? 'standard'
|
|
758
|
+
: token.type === TokenType.EXPLODE_COMPOUND
|
|
759
|
+
? 'compound'
|
|
760
|
+
: 'penetrating';
|
|
761
|
+
|
|
762
|
+
const node: ExplodeNode = {
|
|
763
|
+
type: 'Explode',
|
|
764
|
+
variant,
|
|
765
|
+
target,
|
|
766
|
+
start: target.start ?? token.position,
|
|
767
|
+
end: token.end,
|
|
768
|
+
};
|
|
769
|
+
if (this.isComparePointAhead()) {
|
|
770
|
+
node.threshold = this.parseComparePoint();
|
|
771
|
+
node.end = node.threshold.value.end ?? token.end;
|
|
772
|
+
}
|
|
773
|
+
return node;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private parseReroll(target: ASTNode, token: Token): RerollNode {
|
|
777
|
+
this.rejectSuccessCountTarget(target, token);
|
|
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
|
+
|
|
794
|
+
// A reroll token must be followed by a comparison — bare `r` / `ro` is invalid.
|
|
795
|
+
if (!this.isComparePointAhead()) {
|
|
796
|
+
throw new ParseError(
|
|
797
|
+
`Expected comparison operator after '${token.value}'`,
|
|
798
|
+
'EXPECTED_TOKEN',
|
|
799
|
+
token.position,
|
|
800
|
+
token,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const once = token.type === TokenType.REROLL_ONCE;
|
|
805
|
+
const condition = this.parseComparePoint();
|
|
806
|
+
|
|
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
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
private parseSuccessCount(target: ASTNode, token: Token): SuccessCountNode {
|
|
945
|
+
// Success counting is terminal: chaining (`>=5>=3`) has no semantics.
|
|
946
|
+
this.rejectSuccessCountTarget(target, token);
|
|
947
|
+
|
|
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)) {
|
|
952
|
+
throw new ParseError(
|
|
953
|
+
`Success counting requires a dice pool target`,
|
|
954
|
+
'INVALID_SUCCESS_COUNT_TARGET',
|
|
955
|
+
token.position,
|
|
956
|
+
token,
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const operator = this.getCompareOp(token);
|
|
961
|
+
// ? Threshold binding: `BP.DICE_LEFT` — see `parseComparePoint` JSDoc.
|
|
962
|
+
const value = this.parseExpression(BP.DICE_LEFT);
|
|
963
|
+
this.rejectSuccessCountTarget(value, token);
|
|
964
|
+
this.rejectVersusTarget(value, token);
|
|
965
|
+
const node: SuccessCountNode = {
|
|
966
|
+
type: 'SuccessCount',
|
|
967
|
+
target,
|
|
968
|
+
threshold: { operator, value },
|
|
969
|
+
start: target.start ?? token.position,
|
|
970
|
+
end: value.end ?? token.end,
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
if (this.peek().type === TokenType.FAIL) {
|
|
974
|
+
this.advance();
|
|
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;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
return node;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
private parseVersus(left: ASTNode, token: Token): VersusNode {
|
|
991
|
+
this.rejectSuccessCountTarget(left, token);
|
|
992
|
+
|
|
993
|
+
// ? Chained `a vs b vs c` has no semantics — a degree is a scalar, not a
|
|
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') {
|
|
1002
|
+
throw new ParseError('Cannot chain versus operators', 'NESTED_VERSUS', token.position, token);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const dc = this.parseExpression(BP.VS_RIGHT);
|
|
1006
|
+
this.rejectSuccessCountTarget(dc, token);
|
|
1007
|
+
|
|
1008
|
+
return {
|
|
1009
|
+
type: 'Versus',
|
|
1010
|
+
roll: left,
|
|
1011
|
+
dc,
|
|
1012
|
+
start: left.start ?? token.position,
|
|
1013
|
+
end: dc.end ?? token.end,
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// * Compare point utilities
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Checks whether the next token is a comparison operator.
|
|
1021
|
+
*/
|
|
1022
|
+
isComparePointAhead(): boolean {
|
|
1023
|
+
const type = this.peek().type;
|
|
1024
|
+
return (
|
|
1025
|
+
type === TokenType.GREATER ||
|
|
1026
|
+
type === TokenType.GREATER_EQUAL ||
|
|
1027
|
+
type === TokenType.LESS ||
|
|
1028
|
+
type === TokenType.LESS_EQUAL ||
|
|
1029
|
+
type === TokenType.EQUAL
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Parses a comparison operator followed by a value expression.
|
|
1035
|
+
* Called by modifier parsers (explode, reroll, success counting).
|
|
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
|
+
*
|
|
1044
|
+
* @returns A ComparePoint with the operator and value AST node
|
|
1045
|
+
* @throws {ParseError} If the next token is not a comparison operator
|
|
1046
|
+
*/
|
|
1047
|
+
parseComparePoint(): ComparePoint {
|
|
1048
|
+
const token = this.peek();
|
|
1049
|
+
const operator = this.getCompareOp(token);
|
|
1050
|
+
|
|
1051
|
+
this.advance();
|
|
1052
|
+
|
|
1053
|
+
const value = this.parseExpression(BP.DICE_LEFT);
|
|
1054
|
+
this.rejectSuccessCountTarget(value, token);
|
|
1055
|
+
this.rejectVersusTarget(value, token);
|
|
1056
|
+
|
|
1057
|
+
return { operator, value };
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
private getCompareOp(token: Token): CompareOp {
|
|
1061
|
+
switch (token.type) {
|
|
1062
|
+
case TokenType.GREATER:
|
|
1063
|
+
return '>';
|
|
1064
|
+
case TokenType.GREATER_EQUAL:
|
|
1065
|
+
return '>=';
|
|
1066
|
+
case TokenType.LESS:
|
|
1067
|
+
return '<';
|
|
1068
|
+
case TokenType.LESS_EQUAL:
|
|
1069
|
+
return '<=';
|
|
1070
|
+
case TokenType.EQUAL:
|
|
1071
|
+
return '=';
|
|
1072
|
+
default:
|
|
1073
|
+
throw new ParseError(
|
|
1074
|
+
`Expected comparison operator but got '${token.value}'`,
|
|
1075
|
+
'EXPECTED_TOKEN',
|
|
1076
|
+
token.position,
|
|
1077
|
+
token,
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// * Helpers
|
|
1083
|
+
|
|
1084
|
+
private getOperatorSymbol(token: Token): '+' | '-' | '*' | '/' | '%' | '**' {
|
|
1085
|
+
switch (token.type) {
|
|
1086
|
+
case TokenType.PLUS:
|
|
1087
|
+
return '+';
|
|
1088
|
+
case TokenType.MINUS:
|
|
1089
|
+
return '-';
|
|
1090
|
+
case TokenType.MULTIPLY:
|
|
1091
|
+
return '*';
|
|
1092
|
+
case TokenType.DIVIDE:
|
|
1093
|
+
return '/';
|
|
1094
|
+
case TokenType.MODULO:
|
|
1095
|
+
return '%';
|
|
1096
|
+
case TokenType.POWER:
|
|
1097
|
+
return '**';
|
|
1098
|
+
default:
|
|
1099
|
+
throw new ParseError(
|
|
1100
|
+
`Unknown operator '${token.value}'`,
|
|
1101
|
+
'UNEXPECTED_TOKEN',
|
|
1102
|
+
token.position,
|
|
1103
|
+
token,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
private getLeftBp(token: Token): number {
|
|
1109
|
+
switch (token.type) {
|
|
1110
|
+
case TokenType.VS:
|
|
1111
|
+
return BP.VS_LEFT;
|
|
1112
|
+
case TokenType.PLUS:
|
|
1113
|
+
case TokenType.MINUS:
|
|
1114
|
+
return BP.ADD_LEFT;
|
|
1115
|
+
case TokenType.MULTIPLY:
|
|
1116
|
+
case TokenType.DIVIDE:
|
|
1117
|
+
case TokenType.MODULO:
|
|
1118
|
+
return BP.MUL_LEFT;
|
|
1119
|
+
case TokenType.POWER:
|
|
1120
|
+
return BP.POW_LEFT;
|
|
1121
|
+
case TokenType.DICE:
|
|
1122
|
+
case TokenType.DICE_PERCENT:
|
|
1123
|
+
case TokenType.DICE_FATE:
|
|
1124
|
+
return BP.DICE_LEFT;
|
|
1125
|
+
case TokenType.KEEP_HIGH:
|
|
1126
|
+
case TokenType.KEEP_LOW:
|
|
1127
|
+
case TokenType.DROP_HIGH:
|
|
1128
|
+
case TokenType.DROP_LOW:
|
|
1129
|
+
case TokenType.EXPLODE:
|
|
1130
|
+
case TokenType.EXPLODE_COMPOUND:
|
|
1131
|
+
case TokenType.EXPLODE_PENETRATING:
|
|
1132
|
+
case TokenType.REROLL:
|
|
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;
|
|
1139
|
+
// Comparison operators act as LED-dispatched success-count modifiers
|
|
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.
|
|
1145
|
+
case TokenType.GREATER:
|
|
1146
|
+
case TokenType.GREATER_EQUAL:
|
|
1147
|
+
case TokenType.LESS:
|
|
1148
|
+
case TokenType.LESS_EQUAL:
|
|
1149
|
+
case TokenType.EQUAL:
|
|
1150
|
+
return BP.COMPARE;
|
|
1151
|
+
case TokenType.RPAREN:
|
|
1152
|
+
case TokenType.EOF:
|
|
1153
|
+
// Punctuation and keywords that terminate expressions
|
|
1154
|
+
case TokenType.COMMA:
|
|
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:
|
|
1161
|
+
return -1;
|
|
1162
|
+
default:
|
|
1163
|
+
return 0;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
private getRightBp(token: Token): number {
|
|
1168
|
+
switch (token.type) {
|
|
1169
|
+
case TokenType.VS:
|
|
1170
|
+
return BP.VS_RIGHT;
|
|
1171
|
+
case TokenType.PLUS:
|
|
1172
|
+
case TokenType.MINUS:
|
|
1173
|
+
return BP.ADD_RIGHT;
|
|
1174
|
+
case TokenType.MULTIPLY:
|
|
1175
|
+
case TokenType.DIVIDE:
|
|
1176
|
+
case TokenType.MODULO:
|
|
1177
|
+
return BP.MUL_RIGHT;
|
|
1178
|
+
case TokenType.POWER:
|
|
1179
|
+
return BP.POW_RIGHT;
|
|
1180
|
+
case TokenType.DICE:
|
|
1181
|
+
return BP.DICE_RIGHT;
|
|
1182
|
+
default:
|
|
1183
|
+
return 0;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
private peek(): Token {
|
|
1188
|
+
return (
|
|
1189
|
+
this.tokens[this.pos] ?? { type: TokenType.EOF, value: '', position: this.pos, end: this.pos }
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
private advance(): Token {
|
|
1194
|
+
const token = this.peek();
|
|
1195
|
+
this.pos++;
|
|
1196
|
+
return token;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
private expect(type: TokenType): Token {
|
|
1200
|
+
const token = this.peek();
|
|
1201
|
+
if (token.type !== type) {
|
|
1202
|
+
const expected = TOKEN_DISPLAY[type] ?? TokenType[type];
|
|
1203
|
+
const got = token.type === TokenType.EOF ? 'end of input' : `'${token.value}'`;
|
|
1204
|
+
throw new ParseError(
|
|
1205
|
+
`Expected ${expected} but got ${got}`,
|
|
1206
|
+
'EXPECTED_TOKEN',
|
|
1207
|
+
token.position,
|
|
1208
|
+
token,
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
return this.advance();
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
private hasTokens(): boolean {
|
|
1215
|
+
return this.peek().type !== TokenType.EOF;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Parse a dice notation string into an AST.
|
|
1221
|
+
*
|
|
1222
|
+
* @param notation - The dice notation to parse
|
|
1223
|
+
* @returns The root AST node
|
|
1224
|
+
* @throws {LexerError} If the input contains invalid characters
|
|
1225
|
+
* @throws {ParseError} If the input has invalid syntax
|
|
1226
|
+
*
|
|
1227
|
+
* @example
|
|
1228
|
+
* ```typescript
|
|
1229
|
+
* const ast = parse('2d6+3');
|
|
1230
|
+
* // { type: 'BinaryOp', operator: '+',
|
|
1231
|
+
* // left: { type: 'Dice', count: 2, sides: 6 },
|
|
1232
|
+
* // right: { type: 'Literal', value: 3 } }
|
|
1233
|
+
* ```
|
|
1234
|
+
*/
|
|
1235
|
+
export function parse(notation: string): ASTNode {
|
|
1236
|
+
const tokens = lex(notation);
|
|
1237
|
+
const parser = new Parser(tokens);
|
|
1238
|
+
return parser.parse();
|
|
1239
|
+
}
|