roll-parser 2.3.2 → 3.0.0-alpha.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/README.md +47 -178
- 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 +1775 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/evaluator/evaluator.d.ts +65 -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/explode.d.ts +44 -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/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 +1723 -0
- package/dist/index.mjs +1724 -0
- package/dist/lexer/lexer.d.ts +75 -0
- package/dist/lexer/lexer.d.ts.map +1 -0
- package/dist/lexer/tokens.d.ts +87 -0
- package/dist/lexer/tokens.d.ts.map +1 -0
- package/dist/parser/ast.d.ts +183 -0
- package/dist/parser/ast.d.ts.map +1 -0
- package/dist/parser/parser.d.ts +102 -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 +33 -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 +47 -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 +38 -0
- package/dist/testing.mjs +39 -0
- package/dist/types.d.ts +105 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +62 -43
- package/src/cli/args.ts +72 -0
- package/src/cli/format.ts +45 -0
- package/src/cli/index.ts +75 -0
- package/src/errors.ts +78 -0
- package/src/evaluator/evaluator.ts +837 -0
- package/src/evaluator/index.ts +14 -0
- package/src/evaluator/modifiers/compare.ts +29 -0
- package/src/evaluator/modifiers/explode.ts +215 -0
- package/src/evaluator/modifiers/keep-drop.ts +151 -0
- package/src/evaluator/modifiers/reroll.ts +172 -0
- package/src/evaluator/modifiers/success-count.ts +59 -0
- package/src/index.ts +72 -0
- package/src/lexer/lexer.ts +281 -0
- package/src/lexer/tokens.ts +147 -0
- package/src/parser/ast.ts +261 -0
- package/src/parser/parser.ts +716 -0
- package/src/rng/index.ts +8 -0
- package/src/rng/mock.ts +65 -0
- package/src/rng/seeded.ts +127 -0
- package/src/rng/types.ts +25 -0
- package/src/roll.ts +66 -0
- package/src/testing.ts +16 -0
- package/src/types.ts +112 -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,716 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pratt parser for dice notation.
|
|
3
|
+
*
|
|
4
|
+
* @module parser/parser
|
|
5
|
+
*/
|
|
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';
|
|
12
|
+
import type {
|
|
13
|
+
ASTNode,
|
|
14
|
+
BinaryOpNode,
|
|
15
|
+
DiceNode,
|
|
16
|
+
ExplodeNode,
|
|
17
|
+
FateDiceNode,
|
|
18
|
+
FunctionCallNode,
|
|
19
|
+
LiteralNode,
|
|
20
|
+
ModifierNode,
|
|
21
|
+
RerollNode,
|
|
22
|
+
SuccessCountNode,
|
|
23
|
+
UnaryOpNode,
|
|
24
|
+
VersusNode,
|
|
25
|
+
} from './ast';
|
|
26
|
+
import { containsDice, isSuccessCount } from './ast';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Error thrown when the parser encounters invalid syntax.
|
|
30
|
+
*/
|
|
31
|
+
export class ParseError extends RollParserError {
|
|
32
|
+
readonly position: number;
|
|
33
|
+
readonly token: Token | undefined;
|
|
34
|
+
|
|
35
|
+
constructor(message: string, code: RollParserErrorCode, position: number, token?: Token) {
|
|
36
|
+
super(`${message} at position ${position}`, code);
|
|
37
|
+
this.name = 'ParseError';
|
|
38
|
+
this.position = position;
|
|
39
|
+
this.token = token ?? undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Binding power constants for operators.
|
|
45
|
+
* Higher values bind tighter. Right < Left for right-associativity.
|
|
46
|
+
*
|
|
47
|
+
* Precedence order (lowest to highest):
|
|
48
|
+
* - Versus (`vs`): 2-3 (lowest — full expressions on both sides)
|
|
49
|
+
* - Addition/subtraction: 10
|
|
50
|
+
* - Multiplication/division/modulo: 20
|
|
51
|
+
* - Unary minus: 25 (binds to complete dice expr: -1d4 = -(1d4))
|
|
52
|
+
* - Power: 30-31
|
|
53
|
+
* - Modifiers (postfix): 35 (must be < DICE_RIGHT to bind to complete dice expr)
|
|
54
|
+
* - Dice: 40-41
|
|
55
|
+
*/
|
|
56
|
+
const BP = {
|
|
57
|
+
// Versus (left-associative, lowest precedence — `1d20+10 vs 25+10` = `(1d20+10) vs (25+10)`)
|
|
58
|
+
VS_LEFT: 2,
|
|
59
|
+
VS_RIGHT: 3,
|
|
60
|
+
// Addition/subtraction (left-associative)
|
|
61
|
+
ADD_LEFT: 10,
|
|
62
|
+
ADD_RIGHT: 11,
|
|
63
|
+
// Multiplication/division/modulo (left-associative)
|
|
64
|
+
MUL_LEFT: 20,
|
|
65
|
+
MUL_RIGHT: 21,
|
|
66
|
+
// Unary minus: between mul and power so -1d4 = -(1d4) not (-1)d4
|
|
67
|
+
UNARY: 25,
|
|
68
|
+
// Power (right-associative: left > right)
|
|
69
|
+
POW_LEFT: 31,
|
|
70
|
+
POW_RIGHT: 30,
|
|
71
|
+
// Postfix modifiers: must be < DICE_RIGHT so they bind to complete dice expr
|
|
72
|
+
MODIFIER: 35,
|
|
73
|
+
// Dice operator (highest math precedence)
|
|
74
|
+
DICE_LEFT: 40,
|
|
75
|
+
DICE_RIGHT: 41,
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Arity table for math functions. `min` and `max` are inclusive.
|
|
80
|
+
* `POSITIVE_INFINITY` means unbounded (variadic).
|
|
81
|
+
*/
|
|
82
|
+
const FUNCTION_ARITY: Record<string, { min: number; max: number }> = {
|
|
83
|
+
floor: { min: 1, max: 1 },
|
|
84
|
+
ceil: { min: 1, max: 1 },
|
|
85
|
+
round: { min: 1, max: 1 },
|
|
86
|
+
abs: { min: 1, max: 1 },
|
|
87
|
+
max: { min: 2, max: Number.POSITIVE_INFINITY },
|
|
88
|
+
min: { min: 2, max: Number.POSITIVE_INFINITY },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Pratt parser for dice notation.
|
|
93
|
+
*
|
|
94
|
+
* Uses binding power (precedence) to handle operator associativity and
|
|
95
|
+
* precedence without left recursion issues.
|
|
96
|
+
*/
|
|
97
|
+
export class Parser {
|
|
98
|
+
private readonly tokens: Token[];
|
|
99
|
+
private pos = 0;
|
|
100
|
+
|
|
101
|
+
constructor(tokens: Token[]) {
|
|
102
|
+
this.tokens = tokens;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse the token stream into an AST.
|
|
107
|
+
*/
|
|
108
|
+
parse(): ASTNode {
|
|
109
|
+
if (this.peek().type === TokenType.EOF) {
|
|
110
|
+
throw new ParseError('Unexpected end of input', 'UNEXPECTED_END', this.peek().position);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ast = this.parseExpression(0);
|
|
114
|
+
|
|
115
|
+
// Ensure we consumed all tokens
|
|
116
|
+
if (this.peek().type !== TokenType.EOF) {
|
|
117
|
+
const token = this.peek();
|
|
118
|
+
throw new ParseError(
|
|
119
|
+
`Unexpected token '${token.value}'`,
|
|
120
|
+
'UNEXPECTED_TOKEN',
|
|
121
|
+
token.position,
|
|
122
|
+
token,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return ast;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parse an expression with minimum binding power.
|
|
131
|
+
*/
|
|
132
|
+
private parseExpression(minBp: number): ASTNode {
|
|
133
|
+
let left = this.parseNud();
|
|
134
|
+
|
|
135
|
+
while (this.hasTokens()) {
|
|
136
|
+
const token = this.peek();
|
|
137
|
+
const leftBp = this.getLeftBp(token);
|
|
138
|
+
|
|
139
|
+
if (leftBp < minBp) break;
|
|
140
|
+
|
|
141
|
+
this.advance();
|
|
142
|
+
left = this.parseLed(left, token);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return left;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* NUD - Null Denotation.
|
|
150
|
+
* Handles tokens that appear at the start of an expression (prefix position).
|
|
151
|
+
*/
|
|
152
|
+
private parseNud(): ASTNode {
|
|
153
|
+
const token = this.advance();
|
|
154
|
+
|
|
155
|
+
switch (token.type) {
|
|
156
|
+
case TokenType.NUMBER:
|
|
157
|
+
return this.parseLiteral(token);
|
|
158
|
+
|
|
159
|
+
case TokenType.MINUS:
|
|
160
|
+
return this.parseUnaryMinus();
|
|
161
|
+
|
|
162
|
+
case TokenType.DICE:
|
|
163
|
+
return this.parsePrefixDice();
|
|
164
|
+
|
|
165
|
+
case TokenType.DICE_PERCENT:
|
|
166
|
+
return this.parsePrefixDicePercent();
|
|
167
|
+
|
|
168
|
+
case TokenType.DICE_FATE:
|
|
169
|
+
return this.parsePrefixFateDice();
|
|
170
|
+
|
|
171
|
+
case TokenType.LPAREN:
|
|
172
|
+
return this.parseGrouped();
|
|
173
|
+
|
|
174
|
+
case TokenType.FUNCTION:
|
|
175
|
+
return this.parseFunctionCall(token);
|
|
176
|
+
|
|
177
|
+
case TokenType.EOF:
|
|
178
|
+
throw new ParseError('Unexpected end of input', 'UNEXPECTED_END', token.position);
|
|
179
|
+
|
|
180
|
+
default:
|
|
181
|
+
throw new ParseError(
|
|
182
|
+
`Unexpected token '${token.value}'`,
|
|
183
|
+
'UNEXPECTED_TOKEN',
|
|
184
|
+
token.position,
|
|
185
|
+
token,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* LED - Left Denotation.
|
|
192
|
+
* Handles tokens that appear between expressions (infix/postfix position).
|
|
193
|
+
*/
|
|
194
|
+
private parseLed(left: ASTNode, token: Token): ASTNode {
|
|
195
|
+
switch (token.type) {
|
|
196
|
+
case TokenType.DICE:
|
|
197
|
+
return this.parseInfixDice(left);
|
|
198
|
+
|
|
199
|
+
case TokenType.DICE_PERCENT:
|
|
200
|
+
return this.parseInfixDicePercent(left);
|
|
201
|
+
|
|
202
|
+
case TokenType.DICE_FATE:
|
|
203
|
+
return this.parseInfixFateDice(left);
|
|
204
|
+
|
|
205
|
+
case TokenType.PLUS:
|
|
206
|
+
case TokenType.MINUS:
|
|
207
|
+
case TokenType.MULTIPLY:
|
|
208
|
+
case TokenType.DIVIDE:
|
|
209
|
+
case TokenType.MODULO:
|
|
210
|
+
case TokenType.POWER:
|
|
211
|
+
return this.parseBinaryOp(left, token);
|
|
212
|
+
|
|
213
|
+
case TokenType.KEEP_HIGH:
|
|
214
|
+
case TokenType.KEEP_LOW:
|
|
215
|
+
case TokenType.DROP_HIGH:
|
|
216
|
+
case TokenType.DROP_LOW:
|
|
217
|
+
return this.parseModifier(left, token);
|
|
218
|
+
|
|
219
|
+
case TokenType.EXPLODE:
|
|
220
|
+
case TokenType.EXPLODE_COMPOUND:
|
|
221
|
+
case TokenType.EXPLODE_PENETRATING:
|
|
222
|
+
return this.parseExplode(left, token);
|
|
223
|
+
|
|
224
|
+
case TokenType.REROLL:
|
|
225
|
+
case TokenType.REROLL_ONCE:
|
|
226
|
+
return this.parseReroll(left, token);
|
|
227
|
+
|
|
228
|
+
case TokenType.GREATER:
|
|
229
|
+
case TokenType.GREATER_EQUAL:
|
|
230
|
+
case TokenType.LESS:
|
|
231
|
+
case TokenType.LESS_EQUAL:
|
|
232
|
+
case TokenType.EQUAL:
|
|
233
|
+
return this.parseSuccessCount(left, token);
|
|
234
|
+
|
|
235
|
+
case TokenType.VS:
|
|
236
|
+
return this.parseVersus(left, token);
|
|
237
|
+
|
|
238
|
+
default:
|
|
239
|
+
throw new ParseError(
|
|
240
|
+
`Unexpected infix token '${token.value}'`,
|
|
241
|
+
'UNEXPECTED_TOKEN',
|
|
242
|
+
token.position,
|
|
243
|
+
token,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// * Node parsers
|
|
249
|
+
|
|
250
|
+
private parseLiteral(token: Token): LiteralNode {
|
|
251
|
+
return {
|
|
252
|
+
type: 'Literal',
|
|
253
|
+
value: Number.parseFloat(token.value),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private parseUnaryMinus(): UnaryOpNode {
|
|
258
|
+
const operand = this.parseExpression(BP.UNARY);
|
|
259
|
+
return {
|
|
260
|
+
type: 'UnaryOp',
|
|
261
|
+
operator: '-',
|
|
262
|
+
operand,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private parsePrefixDice(): DiceNode {
|
|
267
|
+
// d20 → Dice(1, 20)
|
|
268
|
+
const sides = this.parseExpression(BP.DICE_RIGHT);
|
|
269
|
+
return {
|
|
270
|
+
type: 'Dice',
|
|
271
|
+
count: { type: 'Literal', value: 1 },
|
|
272
|
+
sides,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private parseInfixDice(left: ASTNode): DiceNode {
|
|
277
|
+
// 4d6 → Dice(4, 6)
|
|
278
|
+
const sides = this.parseExpression(BP.DICE_RIGHT);
|
|
279
|
+
return {
|
|
280
|
+
type: 'Dice',
|
|
281
|
+
count: left,
|
|
282
|
+
sides,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private parsePrefixDicePercent(): DiceNode {
|
|
287
|
+
// d% → Dice(1, 100)
|
|
288
|
+
return {
|
|
289
|
+
type: 'Dice',
|
|
290
|
+
count: { type: 'Literal', value: 1 },
|
|
291
|
+
sides: { type: 'Literal', value: 100 },
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private parseInfixDicePercent(left: ASTNode): DiceNode {
|
|
296
|
+
// 2d% → Dice(2, 100)
|
|
297
|
+
return {
|
|
298
|
+
type: 'Dice',
|
|
299
|
+
count: left,
|
|
300
|
+
sides: { type: 'Literal', value: 100 },
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private parsePrefixFateDice(): FateDiceNode {
|
|
305
|
+
// dF → FateDice(1)
|
|
306
|
+
return {
|
|
307
|
+
type: 'FateDice',
|
|
308
|
+
count: { type: 'Literal', value: 1 },
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private parseInfixFateDice(left: ASTNode): FateDiceNode {
|
|
313
|
+
// 4dF → FateDice(4). Unlike parseInfixDice, there is no sides sub-parse,
|
|
314
|
+
// so modifiers (`kh`, `dl`, …) naturally bind at the outer Pratt loop
|
|
315
|
+
// without BP competition against a right-operand.
|
|
316
|
+
return {
|
|
317
|
+
type: 'FateDice',
|
|
318
|
+
count: left,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private parseGrouped(): ASTNode {
|
|
323
|
+
const expr = this.parseExpression(0);
|
|
324
|
+
this.expect(TokenType.RPAREN);
|
|
325
|
+
return expr;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private parseFunctionCall(token: Token): FunctionCallNode {
|
|
329
|
+
// `FUNCTION` has BP = -1 so callers stop here; `COMMA` and `RPAREN` also
|
|
330
|
+
// terminate inner `parseExpression(0)` calls, so argument boundaries are
|
|
331
|
+
// natural.
|
|
332
|
+
this.expect(TokenType.LPAREN);
|
|
333
|
+
|
|
334
|
+
const args: ASTNode[] = [];
|
|
335
|
+
if (this.peek().type !== TokenType.RPAREN) {
|
|
336
|
+
args.push(this.parseExpression(0));
|
|
337
|
+
while (this.peek().type === TokenType.COMMA) {
|
|
338
|
+
this.advance();
|
|
339
|
+
args.push(this.parseExpression(0));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
this.expect(TokenType.RPAREN);
|
|
344
|
+
|
|
345
|
+
const arity = FUNCTION_ARITY[token.value];
|
|
346
|
+
if (arity === undefined) {
|
|
347
|
+
// ? Unreachable in practice: lexer only emits FUNCTION for registered
|
|
348
|
+
// names. Kept defensive to keep parser/evaluator error-code contract
|
|
349
|
+
// symmetrical.
|
|
350
|
+
throw new ParseError(
|
|
351
|
+
`Unknown function '${token.value}'`,
|
|
352
|
+
'UNKNOWN_FUNCTION',
|
|
353
|
+
token.position,
|
|
354
|
+
token,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (args.length < arity.min || args.length > arity.max) {
|
|
359
|
+
const expected =
|
|
360
|
+
arity.max === Number.POSITIVE_INFINITY
|
|
361
|
+
? `at least ${arity.min}`
|
|
362
|
+
: arity.min === arity.max
|
|
363
|
+
? `${arity.min}`
|
|
364
|
+
: `${arity.min}–${arity.max}`;
|
|
365
|
+
throw new ParseError(
|
|
366
|
+
`Function '${token.value}' expects ${expected} argument${arity.min === 1 && arity.max === 1 ? '' : 's'}, got ${args.length}`,
|
|
367
|
+
'INVALID_FUNCTION_ARITY',
|
|
368
|
+
token.position,
|
|
369
|
+
token,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return { type: 'FunctionCall', name: token.value, args };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private parseBinaryOp(left: ASTNode, token: Token): BinaryOpNode {
|
|
377
|
+
const operator = this.getOperatorSymbol(token);
|
|
378
|
+
const rightBp = this.getRightBp(token);
|
|
379
|
+
const right = this.parseExpression(rightBp);
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
type: 'BinaryOp',
|
|
383
|
+
operator,
|
|
384
|
+
left,
|
|
385
|
+
right,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private rejectSuccessCountTarget(target: ASTNode, token: Token): void {
|
|
390
|
+
if (isSuccessCount(target)) {
|
|
391
|
+
throw new ParseError(
|
|
392
|
+
`Cannot apply modifier after success counting`,
|
|
393
|
+
'INVALID_SUCCESS_COUNT_TARGET',
|
|
394
|
+
token.position,
|
|
395
|
+
token,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private parseModifier(target: ASTNode, token: Token): ModifierNode {
|
|
401
|
+
this.rejectSuccessCountTarget(target, token);
|
|
402
|
+
|
|
403
|
+
const modifier =
|
|
404
|
+
token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
|
|
405
|
+
|
|
406
|
+
const selector =
|
|
407
|
+
token.type === TokenType.KEEP_HIGH || token.type === TokenType.DROP_HIGH
|
|
408
|
+
? 'highest'
|
|
409
|
+
: 'lowest';
|
|
410
|
+
|
|
411
|
+
// Default to 1 when no explicit count follows the modifier (e.g., 4d6kh → 4d6kh1)
|
|
412
|
+
const nextToken = this.peek().type;
|
|
413
|
+
const count: ASTNode =
|
|
414
|
+
nextToken === TokenType.NUMBER || nextToken === TokenType.LPAREN
|
|
415
|
+
? this.parseExpression(BP.DICE_LEFT)
|
|
416
|
+
: { type: 'Literal', value: 1 };
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
type: 'Modifier',
|
|
420
|
+
modifier,
|
|
421
|
+
selector,
|
|
422
|
+
count,
|
|
423
|
+
target,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private parseExplode(target: ASTNode, token: Token): ExplodeNode {
|
|
428
|
+
this.rejectSuccessCountTarget(target, token);
|
|
429
|
+
|
|
430
|
+
// ? Reject nested explodes (e.g., `1d6!!!`) — a second explode token atop
|
|
431
|
+
// an ExplodeNode has no meaningful semantics and is rejected per spec.
|
|
432
|
+
if (target.type === 'Explode') {
|
|
433
|
+
throw new ParseError(
|
|
434
|
+
`Cannot chain explode modifiers`,
|
|
435
|
+
'INVALID_EXPLODE_TARGET',
|
|
436
|
+
token.position,
|
|
437
|
+
token,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const variant: ExplodeNode['variant'] =
|
|
442
|
+
token.type === TokenType.EXPLODE
|
|
443
|
+
? 'standard'
|
|
444
|
+
: token.type === TokenType.EXPLODE_COMPOUND
|
|
445
|
+
? 'compound'
|
|
446
|
+
: 'penetrating';
|
|
447
|
+
|
|
448
|
+
const node: ExplodeNode = { type: 'Explode', variant, target };
|
|
449
|
+
if (this.isComparePointAhead()) {
|
|
450
|
+
node.threshold = this.parseComparePoint();
|
|
451
|
+
}
|
|
452
|
+
return node;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private parseReroll(target: ASTNode, token: Token): RerollNode {
|
|
456
|
+
this.rejectSuccessCountTarget(target, token);
|
|
457
|
+
|
|
458
|
+
// A reroll token must be followed by a comparison — bare `r` / `ro` is invalid.
|
|
459
|
+
if (!this.isComparePointAhead()) {
|
|
460
|
+
throw new ParseError(
|
|
461
|
+
`Expected comparison operator after '${token.value}'`,
|
|
462
|
+
'EXPECTED_TOKEN',
|
|
463
|
+
token.position,
|
|
464
|
+
token,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const once = token.type === TokenType.REROLL_ONCE;
|
|
469
|
+
const condition = this.parseComparePoint();
|
|
470
|
+
|
|
471
|
+
return { type: 'Reroll', once, condition, target };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private parseSuccessCount(target: ASTNode, token: Token): SuccessCountNode {
|
|
475
|
+
// Success counting is terminal: chaining (`>=5>=3`) has no semantics.
|
|
476
|
+
this.rejectSuccessCountTarget(target, token);
|
|
477
|
+
|
|
478
|
+
// Reject non-dice targets like `1>=3` or `(1+2)>=3`. Success counting
|
|
479
|
+
// operates on a dice pool; a sum has no pool to count.
|
|
480
|
+
if (!containsDice(target)) {
|
|
481
|
+
throw new ParseError(
|
|
482
|
+
`Success counting requires a dice expression`,
|
|
483
|
+
'INVALID_SUCCESS_COUNT_TARGET',
|
|
484
|
+
token.position,
|
|
485
|
+
token,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const operator = this.getCompareOp(token);
|
|
490
|
+
const value = this.parseExpression(BP.DICE_LEFT);
|
|
491
|
+
const node: SuccessCountNode = {
|
|
492
|
+
type: 'SuccessCount',
|
|
493
|
+
target,
|
|
494
|
+
threshold: { operator, value },
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
if (this.peek().type === TokenType.FAIL) {
|
|
498
|
+
this.advance();
|
|
499
|
+
const failValue = this.parseExpression(BP.DICE_LEFT);
|
|
500
|
+
node.failThreshold = { operator: '=', value: failValue };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return node;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private parseVersus(left: ASTNode, token: Token): VersusNode {
|
|
507
|
+
// ? Chained `a vs b vs c` has no semantics — a degree is a scalar, not a
|
|
508
|
+
// comparable. Parens (`a vs (b vs c)`) slip past this check and are
|
|
509
|
+
// caught by the evaluator via `EvalEnv.insideVersus`.
|
|
510
|
+
if (left.type === 'Versus') {
|
|
511
|
+
throw new ParseError('Cannot chain versus operators', 'NESTED_VERSUS', token.position, token);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const dc = this.parseExpression(BP.VS_RIGHT);
|
|
515
|
+
|
|
516
|
+
return { type: 'Versus', roll: left, dc };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// * Compare point utilities
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Checks whether the next token is a comparison operator.
|
|
523
|
+
*/
|
|
524
|
+
isComparePointAhead(): boolean {
|
|
525
|
+
const type = this.peek().type;
|
|
526
|
+
return (
|
|
527
|
+
type === TokenType.GREATER ||
|
|
528
|
+
type === TokenType.GREATER_EQUAL ||
|
|
529
|
+
type === TokenType.LESS ||
|
|
530
|
+
type === TokenType.LESS_EQUAL ||
|
|
531
|
+
type === TokenType.EQUAL
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Parses a comparison operator followed by a value expression.
|
|
537
|
+
* Called by modifier parsers (explode, reroll, success counting).
|
|
538
|
+
*
|
|
539
|
+
* @returns A ComparePoint with the operator and value AST node
|
|
540
|
+
* @throws {ParseError} If the next token is not a comparison operator
|
|
541
|
+
*/
|
|
542
|
+
parseComparePoint(): ComparePoint {
|
|
543
|
+
const token = this.peek();
|
|
544
|
+
const operator = this.getCompareOp(token);
|
|
545
|
+
|
|
546
|
+
this.advance();
|
|
547
|
+
|
|
548
|
+
const value = this.parseExpression(BP.DICE_LEFT);
|
|
549
|
+
|
|
550
|
+
return { operator, value };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private getCompareOp(token: Token): CompareOp {
|
|
554
|
+
switch (token.type) {
|
|
555
|
+
case TokenType.GREATER:
|
|
556
|
+
return '>';
|
|
557
|
+
case TokenType.GREATER_EQUAL:
|
|
558
|
+
return '>=';
|
|
559
|
+
case TokenType.LESS:
|
|
560
|
+
return '<';
|
|
561
|
+
case TokenType.LESS_EQUAL:
|
|
562
|
+
return '<=';
|
|
563
|
+
case TokenType.EQUAL:
|
|
564
|
+
return '=';
|
|
565
|
+
default:
|
|
566
|
+
throw new ParseError(
|
|
567
|
+
`Expected comparison operator but got '${token.value}'`,
|
|
568
|
+
'EXPECTED_TOKEN',
|
|
569
|
+
token.position,
|
|
570
|
+
token,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// * Helpers
|
|
576
|
+
|
|
577
|
+
private getOperatorSymbol(token: Token): '+' | '-' | '*' | '/' | '%' | '**' {
|
|
578
|
+
switch (token.type) {
|
|
579
|
+
case TokenType.PLUS:
|
|
580
|
+
return '+';
|
|
581
|
+
case TokenType.MINUS:
|
|
582
|
+
return '-';
|
|
583
|
+
case TokenType.MULTIPLY:
|
|
584
|
+
return '*';
|
|
585
|
+
case TokenType.DIVIDE:
|
|
586
|
+
return '/';
|
|
587
|
+
case TokenType.MODULO:
|
|
588
|
+
return '%';
|
|
589
|
+
case TokenType.POWER:
|
|
590
|
+
return '**';
|
|
591
|
+
default:
|
|
592
|
+
throw new ParseError(
|
|
593
|
+
`Unknown operator '${token.value}'`,
|
|
594
|
+
'UNEXPECTED_TOKEN',
|
|
595
|
+
token.position,
|
|
596
|
+
token,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private getLeftBp(token: Token): number {
|
|
602
|
+
switch (token.type) {
|
|
603
|
+
case TokenType.VS:
|
|
604
|
+
return BP.VS_LEFT;
|
|
605
|
+
case TokenType.PLUS:
|
|
606
|
+
case TokenType.MINUS:
|
|
607
|
+
return BP.ADD_LEFT;
|
|
608
|
+
case TokenType.MULTIPLY:
|
|
609
|
+
case TokenType.DIVIDE:
|
|
610
|
+
case TokenType.MODULO:
|
|
611
|
+
return BP.MUL_LEFT;
|
|
612
|
+
case TokenType.POWER:
|
|
613
|
+
return BP.POW_LEFT;
|
|
614
|
+
case TokenType.DICE:
|
|
615
|
+
case TokenType.DICE_PERCENT:
|
|
616
|
+
case TokenType.DICE_FATE:
|
|
617
|
+
return BP.DICE_LEFT;
|
|
618
|
+
case TokenType.KEEP_HIGH:
|
|
619
|
+
case TokenType.KEEP_LOW:
|
|
620
|
+
case TokenType.DROP_HIGH:
|
|
621
|
+
case TokenType.DROP_LOW:
|
|
622
|
+
case TokenType.EXPLODE:
|
|
623
|
+
case TokenType.EXPLODE_COMPOUND:
|
|
624
|
+
case TokenType.EXPLODE_PENETRATING:
|
|
625
|
+
case TokenType.REROLL:
|
|
626
|
+
case TokenType.REROLL_ONCE:
|
|
627
|
+
// Comparison operators act as LED-dispatched success-count modifiers
|
|
628
|
+
// at the Pratt level. Inside `parseComparePoint` (called manually by
|
|
629
|
+
// explode/reroll) they are consumed directly and this BP is bypassed.
|
|
630
|
+
case TokenType.GREATER:
|
|
631
|
+
case TokenType.GREATER_EQUAL:
|
|
632
|
+
case TokenType.LESS:
|
|
633
|
+
case TokenType.LESS_EQUAL:
|
|
634
|
+
case TokenType.EQUAL:
|
|
635
|
+
return BP.MODIFIER;
|
|
636
|
+
case TokenType.RPAREN:
|
|
637
|
+
case TokenType.EOF:
|
|
638
|
+
// Punctuation and keywords that terminate expressions
|
|
639
|
+
case TokenType.COMMA:
|
|
640
|
+
case TokenType.FUNCTION:
|
|
641
|
+
return -1;
|
|
642
|
+
default:
|
|
643
|
+
return 0;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private getRightBp(token: Token): number {
|
|
648
|
+
switch (token.type) {
|
|
649
|
+
case TokenType.VS:
|
|
650
|
+
return BP.VS_RIGHT;
|
|
651
|
+
case TokenType.PLUS:
|
|
652
|
+
case TokenType.MINUS:
|
|
653
|
+
return BP.ADD_RIGHT;
|
|
654
|
+
case TokenType.MULTIPLY:
|
|
655
|
+
case TokenType.DIVIDE:
|
|
656
|
+
case TokenType.MODULO:
|
|
657
|
+
return BP.MUL_RIGHT;
|
|
658
|
+
case TokenType.POWER:
|
|
659
|
+
return BP.POW_RIGHT;
|
|
660
|
+
case TokenType.DICE:
|
|
661
|
+
return BP.DICE_RIGHT;
|
|
662
|
+
default:
|
|
663
|
+
return 0;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private peek(): Token {
|
|
668
|
+
return this.tokens[this.pos] ?? { type: TokenType.EOF, value: '', position: this.pos };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private advance(): Token {
|
|
672
|
+
const token = this.peek();
|
|
673
|
+
this.pos++;
|
|
674
|
+
return token;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private expect(type: TokenType): Token {
|
|
678
|
+
const token = this.peek();
|
|
679
|
+
if (token.type !== type) {
|
|
680
|
+
const expected = TokenType[type];
|
|
681
|
+
throw new ParseError(
|
|
682
|
+
`Expected ${expected} but got '${token.value}'`,
|
|
683
|
+
'EXPECTED_TOKEN',
|
|
684
|
+
token.position,
|
|
685
|
+
token,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
return this.advance();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private hasTokens(): boolean {
|
|
692
|
+
return this.peek().type !== TokenType.EOF;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Parse a dice notation string into an AST.
|
|
698
|
+
*
|
|
699
|
+
* @param notation - The dice notation to parse
|
|
700
|
+
* @returns The root AST node
|
|
701
|
+
* @throws {LexerError} If the input contains invalid characters
|
|
702
|
+
* @throws {ParseError} If the input has invalid syntax
|
|
703
|
+
*
|
|
704
|
+
* @example
|
|
705
|
+
* ```typescript
|
|
706
|
+
* const ast = parse('2d6+3');
|
|
707
|
+
* // { type: 'BinaryOp', operator: '+',
|
|
708
|
+
* // left: { type: 'Dice', count: 2, sides: 6 },
|
|
709
|
+
* // right: { type: 'Literal', value: 3 } }
|
|
710
|
+
* ```
|
|
711
|
+
*/
|
|
712
|
+
export function parse(notation: string): ASTNode {
|
|
713
|
+
const tokens = lex(notation);
|
|
714
|
+
const parser = new Parser(tokens);
|
|
715
|
+
return parser.parse();
|
|
716
|
+
}
|