roll-parser 3.0.0-beta.0 → 3.1.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 +142 -9
- package/MIGRATION.md +219 -0
- package/README.md +1026 -154
- package/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +81 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/format.d.ts +15 -3
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +18 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +14 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/main.d.ts +36 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +83 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/errors.d.ts +332 -17
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +141 -0
- package/dist/errors.js.map +1 -0
- package/dist/evaluator/die.d.ts +27 -0
- package/dist/evaluator/die.d.ts.map +1 -0
- package/dist/evaluator/die.js +19 -0
- package/dist/evaluator/die.js.map +1 -0
- package/dist/evaluator/env.d.ts +89 -0
- package/dist/evaluator/env.d.ts.map +1 -0
- package/dist/evaluator/env.js +11 -0
- package/dist/evaluator/env.js.map +1 -0
- package/dist/evaluator/evaluator.d.ts +41 -75
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +914 -0
- package/dist/evaluator/evaluator.js.map +1 -0
- package/dist/evaluator/modifiers/compare.js +15 -0
- package/dist/evaluator/modifiers/compare.js.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts +57 -5
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.js +42 -0
- package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
- package/dist/evaluator/modifiers/die-bound.d.ts +29 -0
- package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
- package/dist/evaluator/modifiers/die-bound.js +17 -0
- package/dist/evaluator/modifiers/die-bound.js.map +1 -0
- package/dist/evaluator/modifiers/explode.d.ts +13 -5
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/explode.js +105 -0
- package/dist/evaluator/modifiers/explode.js.map +1 -0
- package/dist/evaluator/modifiers/flags.d.ts +47 -0
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
- package/dist/evaluator/modifiers/flags.js +25 -0
- package/dist/evaluator/modifiers/flags.js.map +1 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts +15 -29
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.js +82 -0
- package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
- package/dist/evaluator/modifiers/reroll.d.ts +12 -4
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
- package/dist/evaluator/modifiers/reroll.js +68 -0
- package/dist/evaluator/modifiers/reroll.js.map +1 -0
- package/dist/evaluator/modifiers/sort.d.ts +5 -1
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
- package/dist/evaluator/modifiers/sort.js +13 -0
- package/dist/evaluator/modifiers/sort.js.map +1 -0
- package/dist/evaluator/modifiers/success-count.d.ts +6 -7
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +25 -0
- package/dist/evaluator/modifiers/success-count.js.map +1 -0
- package/dist/index.d.ts +33 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2560
- package/dist/index.js.map +1 -26
- package/dist/lexer/lexer.d.ts +50 -5
- package/dist/lexer/lexer.d.ts.map +1 -1
- package/dist/lexer/lexer.js +260 -0
- package/dist/lexer/lexer.js.map +1 -0
- package/dist/lexer/tokens.d.ts +31 -6
- package/dist/lexer/tokens.d.ts.map +1 -1
- package/dist/lexer/tokens.js +42 -0
- package/dist/lexer/tokens.js.map +1 -0
- package/dist/parser/ast.d.ts +324 -165
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/ast.js +52 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/guards.d.ts +106 -0
- package/dist/parser/guards.d.ts.map +1 -0
- package/dist/parser/guards.js +121 -0
- package/dist/parser/guards.js.map +1 -0
- package/dist/parser/parser.d.ts +118 -14
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +751 -0
- package/dist/parser/parser.js.map +1 -0
- package/dist/render.d.ts +95 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +227 -0
- package/dist/render.js.map +1 -0
- package/dist/rng/mock.d.ts +73 -12
- package/dist/rng/mock.d.ts.map +1 -1
- package/dist/rng/mock.js +30 -0
- package/dist/rng/mock.js.map +1 -0
- package/dist/rng/seeded.d.ts +141 -9
- package/dist/rng/seeded.d.ts.map +1 -1
- package/dist/rng/seeded.js +138 -0
- package/dist/rng/seeded.js.map +1 -0
- package/dist/rng/types.d.ts +57 -0
- package/dist/rng/types.d.ts.map +1 -1
- package/dist/rng/types.js +2 -0
- package/dist/rng/types.js.map +1 -0
- package/dist/roll.d.ts +58 -28
- package/dist/roll.d.ts.map +1 -1
- package/dist/roll.js +8 -0
- package/dist/roll.js.map +1 -0
- package/dist/testing.d.ts +5 -4
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +2 -41
- package/dist/testing.js.map +1 -11
- package/dist/types.d.ts +349 -47
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +93 -34
- package/src/cli/args.ts +66 -10
- package/src/cli/format.ts +37 -26
- package/src/cli/index.ts +27 -84
- package/src/cli/main.ts +129 -0
- package/src/errors.ts +480 -27
- package/src/evaluator/die.ts +51 -0
- package/src/evaluator/env.ts +105 -0
- package/src/evaluator/evaluator.ts +693 -434
- package/src/evaluator/modifiers/crit-threshold.ts +96 -14
- package/src/evaluator/modifiers/die-bound.ts +48 -0
- package/src/evaluator/modifiers/explode.ts +70 -62
- package/src/evaluator/modifiers/flags.ts +78 -0
- package/src/evaluator/modifiers/keep-drop.ts +129 -127
- package/src/evaluator/modifiers/reroll.ts +44 -56
- package/src/evaluator/modifiers/sort.ts +21 -2
- package/src/evaluator/modifiers/success-count.ts +24 -12
- package/src/index.ts +56 -35
- package/src/lexer/lexer.ts +107 -34
- package/src/lexer/tokens.ts +31 -6
- package/src/parser/ast.ts +333 -346
- package/src/parser/guards.ts +248 -0
- package/src/parser/parser.ts +419 -242
- package/src/render.ts +392 -0
- package/src/rng/mock.ts +74 -13
- package/src/rng/seeded.ts +299 -64
- package/src/rng/types.ts +57 -0
- package/src/roll.ts +64 -47
- package/src/testing.ts +5 -9
- package/src/types.ts +353 -46
- package/src/version.ts +2 -0
- package/dist/cli.js +0 -2608
- package/dist/cli.js.map +0 -28
- package/dist/evaluator/index.d.ts +0 -8
- package/dist/evaluator/index.d.ts.map +0 -1
- package/dist/rng/index.d.ts +0 -8
- package/dist/rng/index.d.ts.map +0 -1
- package/src/evaluator/index.ts +0 -14
- package/src/rng/index.ts +0 -8
package/src/parser/parser.ts
CHANGED
|
@@ -15,13 +15,14 @@ import type {
|
|
|
15
15
|
CritThreshold,
|
|
16
16
|
CritThresholdNode,
|
|
17
17
|
DiceNode,
|
|
18
|
+
DieBoundNode,
|
|
18
19
|
ExplodeNode,
|
|
19
20
|
FateDiceNode,
|
|
20
21
|
FunctionCallNode,
|
|
21
22
|
GroupedNode,
|
|
22
23
|
GroupNode,
|
|
24
|
+
KeepDropNode,
|
|
23
25
|
LiteralNode,
|
|
24
|
-
ModifierNode,
|
|
25
26
|
RerollNode,
|
|
26
27
|
SortNode,
|
|
27
28
|
SuccessCountNode,
|
|
@@ -29,29 +30,61 @@ import type {
|
|
|
29
30
|
VariableNode,
|
|
30
31
|
VersusNode,
|
|
31
32
|
} from './ast.js';
|
|
33
|
+
import { isCritThreshold, isSuccessCount } from './ast.js';
|
|
32
34
|
import {
|
|
33
35
|
containsDicePool,
|
|
34
36
|
containsFatePool,
|
|
35
37
|
containsMultiSubGroup,
|
|
36
38
|
containsVersus,
|
|
37
39
|
deepContainsDicePool,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} from './ast.js';
|
|
40
|
+
unwrapAllTransparent,
|
|
41
|
+
unwrapGrouped,
|
|
42
|
+
} from './guards.js';
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* Error thrown when the parser encounters invalid syntax.
|
|
46
|
+
*
|
|
47
|
+
* `position` is a zero-based UTF-16 offset into the notation. It is
|
|
48
|
+
* deliberately absent from `message` — read it from the field, or uniformly
|
|
49
|
+
* across all roll-parser errors via `getErrorSpan`.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* import { ParseError, roll, TokenType } from 'roll-parser';
|
|
54
|
+
*
|
|
55
|
+
* try {
|
|
56
|
+
* roll('4d6d1');
|
|
57
|
+
* } catch (error) {
|
|
58
|
+
* const typed = error as ParseError;
|
|
59
|
+
* typed.code; // 'AMBIGUOUS_DICE_CHAIN'
|
|
60
|
+
* typed.position; // 3
|
|
61
|
+
* typed.token?.type === TokenType.DICE; // true
|
|
62
|
+
* }
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* @category Errors
|
|
45
66
|
*/
|
|
46
67
|
export class ParseError extends RollParserError {
|
|
68
|
+
/** Zero-based UTF-16 offset in the notation where parsing failed. */
|
|
47
69
|
readonly position: number;
|
|
70
|
+
/**
|
|
71
|
+
* The token the parser was looking at. `undefined` when the failure is not
|
|
72
|
+
* anchored to a token — currently only `UNEXPECTED_END`, raised when the
|
|
73
|
+
* input stops short.
|
|
74
|
+
*/
|
|
48
75
|
readonly token: Token | undefined;
|
|
49
76
|
|
|
50
|
-
constructor(
|
|
51
|
-
|
|
77
|
+
constructor(
|
|
78
|
+
message: string,
|
|
79
|
+
code: RollParserErrorCode,
|
|
80
|
+
position: number,
|
|
81
|
+
token?: Token,
|
|
82
|
+
options?: ErrorOptions,
|
|
83
|
+
) {
|
|
84
|
+
super(message, code, options);
|
|
52
85
|
this.name = 'ParseError';
|
|
53
86
|
this.position = position;
|
|
54
|
-
this.token = token
|
|
87
|
+
this.token = token;
|
|
55
88
|
}
|
|
56
89
|
}
|
|
57
90
|
|
|
@@ -61,6 +94,7 @@ export class ParseError extends RollParserError {
|
|
|
61
94
|
*
|
|
62
95
|
* Precedence order (lowest to highest):
|
|
63
96
|
* - Versus (`vs`): 2-3 (lowest — full expressions on both sides)
|
|
97
|
+
* - Comparison (success-count LED): 8
|
|
64
98
|
* - Addition/subtraction: 10
|
|
65
99
|
* - Multiplication/division/modulo: 20
|
|
66
100
|
* - Unary minus: 25 (binds to complete dice expr: -1d4 = -(1d4))
|
|
@@ -69,27 +103,24 @@ export class ParseError extends RollParserError {
|
|
|
69
103
|
* - Dice: 40-41
|
|
70
104
|
*/
|
|
71
105
|
const BP = {
|
|
72
|
-
//
|
|
106
|
+
// Lowest, so both sides take a full expression: `1d20+10 vs 25+10` =
|
|
107
|
+
// `(1d20+10) vs (25+10)`.
|
|
73
108
|
VS_LEFT: 2,
|
|
74
109
|
VS_RIGHT: 3,
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// guard with a clear error, instead of the `>` stealing `N` from the `+`.
|
|
110
|
+
// Below ADD/MUL so `XdY+N>T` parses as `(XdY+N)>T` and fails the pool-target
|
|
111
|
+
// guard with a clear error, instead of `>` stealing `N` from the `+`.
|
|
78
112
|
COMPARE: 8,
|
|
79
|
-
// Addition/subtraction (left-associative)
|
|
80
113
|
ADD_LEFT: 10,
|
|
81
114
|
ADD_RIGHT: 11,
|
|
82
|
-
// Multiplication/division/modulo (left-associative)
|
|
83
115
|
MUL_LEFT: 20,
|
|
84
116
|
MUL_RIGHT: 21,
|
|
85
|
-
//
|
|
117
|
+
// Between MUL and POW so `-1d4` = `-(1d4)`, not `(-1)d4`.
|
|
86
118
|
UNARY: 25,
|
|
87
|
-
// Power (right-associative: left > right)
|
|
88
119
|
POW_LEFT: 31,
|
|
89
120
|
POW_RIGHT: 30,
|
|
90
|
-
//
|
|
121
|
+
// Must stay below DICE_RIGHT so a postfix modifier binds to the whole dice
|
|
122
|
+
// expression: `4d6kh3` = `(4d6)kh3`.
|
|
91
123
|
MODIFIER: 35,
|
|
92
|
-
// Dice operator (highest math precedence)
|
|
93
124
|
DICE_LEFT: 40,
|
|
94
125
|
DICE_RIGHT: 41,
|
|
95
126
|
} as const;
|
|
@@ -100,17 +131,43 @@ const BP = {
|
|
|
100
131
|
* `ParseError` instead of an uncaught `RangeError` stack overflow (which
|
|
101
132
|
* would also break the `isRollParserError` contract). Bounding parse depth
|
|
102
133
|
* also bounds AST depth, protecting the recursive AST walkers and evaluator.
|
|
134
|
+
*
|
|
135
|
+
* Not configurable — unlike the {@link EvaluationOptions} caps, this one always
|
|
136
|
+
* applies, so untrusted notation can never blow the stack.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```typescript
|
|
140
|
+
* import { isRollParserError, MAX_PARSE_DEPTH, parse } from 'roll-parser';
|
|
141
|
+
*
|
|
142
|
+
* MAX_PARSE_DEPTH; // 128
|
|
143
|
+
*
|
|
144
|
+
* try {
|
|
145
|
+
* parse('('.repeat(20_000) + '1d6' + ')'.repeat(20_000));
|
|
146
|
+
* } catch (error) {
|
|
147
|
+
* isRollParserError(error) && error.code; // 'MAX_DEPTH_EXCEEDED'
|
|
148
|
+
* }
|
|
149
|
+
* ```
|
|
150
|
+
*
|
|
151
|
+
* @category Limits
|
|
103
152
|
*/
|
|
104
|
-
const MAX_PARSE_DEPTH = 128;
|
|
153
|
+
export const MAX_PARSE_DEPTH = 128;
|
|
105
154
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
155
|
+
/**
|
|
156
|
+
* Human-readable symbols for the tokens `expect()` can be asked for.
|
|
157
|
+
*
|
|
158
|
+
* `satisfies` keeps the key literals, so `ExpectableToken` narrows `expect()`
|
|
159
|
+
* to exactly these tokens and the lookup can never miss.
|
|
160
|
+
*/
|
|
161
|
+
const TOKEN_DISPLAY = {
|
|
108
162
|
[TokenType.LPAREN]: `'('`,
|
|
109
163
|
[TokenType.RPAREN]: `')'`,
|
|
110
164
|
[TokenType.LBRACE]: `'{'`,
|
|
111
165
|
[TokenType.RBRACE]: `'}'`,
|
|
112
166
|
[TokenType.COMMA]: `','`,
|
|
113
|
-
}
|
|
167
|
+
} satisfies Partial<Record<TokenType, string>>;
|
|
168
|
+
|
|
169
|
+
/** Token types that carry a display symbol and may be passed to `expect()`. */
|
|
170
|
+
type ExpectableToken = keyof typeof TOKEN_DISPLAY;
|
|
114
171
|
|
|
115
172
|
/**
|
|
116
173
|
* Arity table for math functions. `min` and `max` are inclusive.
|
|
@@ -121,10 +178,26 @@ const FUNCTION_ARITY: Record<string, { min: number; max: number }> = {
|
|
|
121
178
|
ceil: { min: 1, max: 1 },
|
|
122
179
|
round: { min: 1, max: 1 },
|
|
123
180
|
abs: { min: 1, max: 1 },
|
|
181
|
+
sqrt: { min: 1, max: 1 },
|
|
182
|
+
pow: { min: 2, max: 2 },
|
|
124
183
|
max: { min: 2, max: Number.POSITIVE_INFINITY },
|
|
125
184
|
min: { min: 2, max: Number.POSITIVE_INFINITY },
|
|
126
185
|
};
|
|
127
186
|
|
|
187
|
+
/** Renders an arity range for the `INVALID_FUNCTION_ARITY` message. */
|
|
188
|
+
function formatArity(arity: { min: number; max: number }): string {
|
|
189
|
+
if (arity.max === Number.POSITIVE_INFINITY) return `at least ${arity.min}`;
|
|
190
|
+
if (arity.min === arity.max) return `${arity.min}`;
|
|
191
|
+
return `${arity.min}–${arity.max}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Explode variant produced by each explode token. */
|
|
195
|
+
const EXPLODE_VARIANTS: Partial<Record<TokenType, ExplodeNode['variant']>> = {
|
|
196
|
+
[TokenType.EXPLODE]: 'standard',
|
|
197
|
+
[TokenType.EXPLODE_COMPOUND]: 'compound',
|
|
198
|
+
[TokenType.EXPLODE_PENETRATING]: 'penetrating',
|
|
199
|
+
};
|
|
200
|
+
|
|
128
201
|
/**
|
|
129
202
|
* Pratt parser for dice notation.
|
|
130
203
|
*
|
|
@@ -150,7 +223,6 @@ export class Parser {
|
|
|
150
223
|
|
|
151
224
|
const ast = this.parseExpression(0);
|
|
152
225
|
|
|
153
|
-
// Ensure we consumed all tokens
|
|
154
226
|
if (this.peek().type !== TokenType.EOF) {
|
|
155
227
|
const token = this.peek();
|
|
156
228
|
throw new ParseError(
|
|
@@ -168,15 +240,9 @@ export class Parser {
|
|
|
168
240
|
* Parse an expression with minimum binding power.
|
|
169
241
|
*/
|
|
170
242
|
private parseExpression(minBp: number): ASTNode {
|
|
243
|
+
const entryDepth = this.depth;
|
|
171
244
|
this.depth += 1;
|
|
172
|
-
|
|
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
|
-
}
|
|
245
|
+
this.guardDepth();
|
|
180
246
|
|
|
181
247
|
try {
|
|
182
248
|
let left = this.parseNud();
|
|
@@ -189,11 +255,31 @@ export class Parser {
|
|
|
189
255
|
|
|
190
256
|
this.advance();
|
|
191
257
|
left = this.parseLed(left, token);
|
|
258
|
+
// ! Each continuation wraps `left`, so the AST is one level deeper even
|
|
259
|
+
// ! though this loop never recursed. Drop this and the bound stops
|
|
260
|
+
// ! holding for left-associative chains.
|
|
261
|
+
this.depth += 1;
|
|
262
|
+
this.guardDepth();
|
|
192
263
|
}
|
|
193
264
|
|
|
194
265
|
return left;
|
|
195
266
|
} finally {
|
|
196
|
-
this.depth
|
|
267
|
+
this.depth = entryDepth;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Throw once the AST under construction is deeper than the recursive walkers
|
|
273
|
+
* can safely descend.
|
|
274
|
+
*/
|
|
275
|
+
private guardDepth(): void {
|
|
276
|
+
if (this.depth > MAX_PARSE_DEPTH) {
|
|
277
|
+
throw new ParseError(
|
|
278
|
+
`Expression nesting exceeds the maximum depth of ${MAX_PARSE_DEPTH}`,
|
|
279
|
+
'MAX_DEPTH_EXCEEDED',
|
|
280
|
+
this.peek().position,
|
|
281
|
+
this.peek(),
|
|
282
|
+
);
|
|
197
283
|
}
|
|
198
284
|
}
|
|
199
285
|
|
|
@@ -272,7 +358,7 @@ export class Parser {
|
|
|
272
358
|
case TokenType.KEEP_LOW:
|
|
273
359
|
case TokenType.DROP_HIGH:
|
|
274
360
|
case TokenType.DROP_LOW:
|
|
275
|
-
return this.
|
|
361
|
+
return this.parseKeepDrop(left, token);
|
|
276
362
|
|
|
277
363
|
case TokenType.EXPLODE:
|
|
278
364
|
case TokenType.EXPLODE_COMPOUND:
|
|
@@ -291,6 +377,11 @@ export class Parser {
|
|
|
291
377
|
case TokenType.CRIT_FAIL:
|
|
292
378
|
return this.parseCritThreshold(left, token);
|
|
293
379
|
|
|
380
|
+
case TokenType.FUNCTION:
|
|
381
|
+
// Only `min`/`max` reach LED position — `getLeftBp` keeps every
|
|
382
|
+
// other function name at -1, ending the Pratt loop before dispatch.
|
|
383
|
+
return this.parseDieBound(left, token);
|
|
384
|
+
|
|
294
385
|
case TokenType.GREATER:
|
|
295
386
|
case TokenType.GREATER_EQUAL:
|
|
296
387
|
case TokenType.LESS:
|
|
@@ -311,7 +402,9 @@ export class Parser {
|
|
|
311
402
|
}
|
|
312
403
|
}
|
|
313
404
|
|
|
405
|
+
//
|
|
314
406
|
// * Node parsers
|
|
407
|
+
//
|
|
315
408
|
|
|
316
409
|
/**
|
|
317
410
|
* Zero-width span for synthetic nodes (implicit counts, `d%` sides) that
|
|
@@ -343,10 +436,9 @@ export class Parser {
|
|
|
343
436
|
}
|
|
344
437
|
|
|
345
438
|
private parsePrefixDice(token: Token): DiceNode {
|
|
346
|
-
// d20 → Dice(1, 20)
|
|
347
439
|
const sides = this.parseExpression(BP.DICE_RIGHT);
|
|
348
440
|
this.rejectSuccessCountTarget(sides, token);
|
|
349
|
-
this.
|
|
441
|
+
this.rejectVersusMetaOperand(sides, token);
|
|
350
442
|
return {
|
|
351
443
|
type: 'Dice',
|
|
352
444
|
count: Parser.syntheticLiteral(1, token),
|
|
@@ -357,13 +449,12 @@ export class Parser {
|
|
|
357
449
|
}
|
|
358
450
|
|
|
359
451
|
private parseInfixDice(left: ASTNode, token: Token): DiceNode {
|
|
360
|
-
// 4d6 → Dice(4, 6)
|
|
361
452
|
this.rejectSuccessCountTarget(left, token);
|
|
362
|
-
this.
|
|
453
|
+
this.rejectVersusMetaOperand(left, token);
|
|
363
454
|
this.rejectBareDiceChain(left, token);
|
|
364
455
|
const sides = this.parseExpression(BP.DICE_RIGHT);
|
|
365
456
|
this.rejectSuccessCountTarget(sides, token);
|
|
366
|
-
this.
|
|
457
|
+
this.rejectVersusMetaOperand(sides, token);
|
|
367
458
|
return {
|
|
368
459
|
type: 'Dice',
|
|
369
460
|
count: left,
|
|
@@ -374,7 +465,6 @@ export class Parser {
|
|
|
374
465
|
}
|
|
375
466
|
|
|
376
467
|
private parsePrefixDicePercent(token: Token): DiceNode {
|
|
377
|
-
// d% → Dice(1, 100)
|
|
378
468
|
return {
|
|
379
469
|
type: 'Dice',
|
|
380
470
|
count: Parser.syntheticLiteral(1, token),
|
|
@@ -385,9 +475,8 @@ export class Parser {
|
|
|
385
475
|
}
|
|
386
476
|
|
|
387
477
|
private parseInfixDicePercent(left: ASTNode, token: Token): DiceNode {
|
|
388
|
-
// 2d% → Dice(2, 100)
|
|
389
478
|
this.rejectSuccessCountTarget(left, token);
|
|
390
|
-
this.
|
|
479
|
+
this.rejectVersusMetaOperand(left, token);
|
|
391
480
|
this.rejectBareDiceChain(left, token);
|
|
392
481
|
return {
|
|
393
482
|
type: 'Dice',
|
|
@@ -399,7 +488,6 @@ export class Parser {
|
|
|
399
488
|
}
|
|
400
489
|
|
|
401
490
|
private parsePrefixFateDice(token: Token): FateDiceNode {
|
|
402
|
-
// dF → FateDice(1)
|
|
403
491
|
return {
|
|
404
492
|
type: 'FateDice',
|
|
405
493
|
count: Parser.syntheticLiteral(1, token),
|
|
@@ -409,11 +497,10 @@ export class Parser {
|
|
|
409
497
|
}
|
|
410
498
|
|
|
411
499
|
private parseInfixFateDice(left: ASTNode, token: Token): FateDiceNode {
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
// without BP competition against a right-operand.
|
|
500
|
+
// No sides sub-parse, unlike `parseInfixDice`, so modifiers (`kh`, `dl`, …)
|
|
501
|
+
// bind at the outer Pratt loop with no BP competition from a right operand.
|
|
415
502
|
this.rejectSuccessCountTarget(left, token);
|
|
416
|
-
this.
|
|
503
|
+
this.rejectVersusMetaOperand(left, token);
|
|
417
504
|
this.rejectBareDiceChain(left, token);
|
|
418
505
|
return {
|
|
419
506
|
type: 'FateDice',
|
|
@@ -430,9 +517,9 @@ export class Parser {
|
|
|
430
517
|
}
|
|
431
518
|
|
|
432
519
|
private parseGroup(startToken: Token): GroupNode {
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
520
|
+
// `LBRACE`/`RBRACE`/`COMMA` all have `getLeftBp === -1`, so inner
|
|
521
|
+
// `parseExpression(0)` calls stop at the first `,` or `}` without competing
|
|
522
|
+
// with modifier/arithmetic BPs.
|
|
436
523
|
if (this.peek().type === TokenType.RBRACE) {
|
|
437
524
|
throw new ParseError('Empty group', 'UNEXPECTED_TOKEN', startToken.position, startToken);
|
|
438
525
|
}
|
|
@@ -462,9 +549,8 @@ export class Parser {
|
|
|
462
549
|
}
|
|
463
550
|
|
|
464
551
|
private parseFunctionCall(token: Token): FunctionCallNode {
|
|
465
|
-
// `FUNCTION`
|
|
466
|
-
//
|
|
467
|
-
// natural.
|
|
552
|
+
// `FUNCTION`, `COMMA`, and `RPAREN` all sit at BP -1, so argument boundaries
|
|
553
|
+
// fall out of the inner `parseExpression(0)` calls terminating on their own.
|
|
468
554
|
this.expect(TokenType.LPAREN);
|
|
469
555
|
|
|
470
556
|
const args: ASTNode[] = [];
|
|
@@ -483,10 +569,9 @@ export class Parser {
|
|
|
483
569
|
const close = this.expect(TokenType.RPAREN);
|
|
484
570
|
|
|
485
571
|
const arity = FUNCTION_ARITY[token.value];
|
|
486
|
-
if (arity
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
// symmetrical.
|
|
572
|
+
if (arity == null) {
|
|
573
|
+
// Unreachable: the lexer only emits `FUNCTION` for registered names. Kept
|
|
574
|
+
// so the parser/evaluator error-code contract stays symmetrical.
|
|
490
575
|
throw new ParseError(
|
|
491
576
|
`Unknown function '${token.value}'`,
|
|
492
577
|
'UNKNOWN_FUNCTION',
|
|
@@ -496,12 +581,7 @@ export class Parser {
|
|
|
496
581
|
}
|
|
497
582
|
|
|
498
583
|
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}`;
|
|
584
|
+
const expected = formatArity(arity);
|
|
505
585
|
throw new ParseError(
|
|
506
586
|
`Function '${token.value}' expects ${expected} argument${arity.min === 1 && arity.max === 1 ? '' : 's'}, got ${args.length}`,
|
|
507
587
|
'INVALID_FUNCTION_ARITY',
|
|
@@ -552,7 +632,8 @@ export class Parser {
|
|
|
552
632
|
case 'FateDice':
|
|
553
633
|
case 'Explode':
|
|
554
634
|
case 'Reroll':
|
|
555
|
-
case '
|
|
635
|
+
case 'DieBound':
|
|
636
|
+
case 'KeepDrop':
|
|
556
637
|
case 'Sort':
|
|
557
638
|
case 'CritThreshold':
|
|
558
639
|
throw new ParseError(
|
|
@@ -567,11 +648,10 @@ export class Parser {
|
|
|
567
648
|
}
|
|
568
649
|
|
|
569
650
|
private rejectSuccessCountTarget(target: ASTNode, token: Token): void {
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
|
|
574
|
-
const node = unwrapTransparent(target, ['Grouped']);
|
|
651
|
+
// Narrow unwrap (only `Grouped`): `KeepDrop`/`Sort`/`CritThreshold` each run
|
|
652
|
+
// this same reject before building their wrapper, so a `SuccessCount` can
|
|
653
|
+
// never hide inside one and widening the set would never match.
|
|
654
|
+
const node = unwrapGrouped(target);
|
|
575
655
|
if (isSuccessCount(node)) {
|
|
576
656
|
throw new ParseError(
|
|
577
657
|
`Cannot apply modifier after success counting`,
|
|
@@ -586,12 +666,11 @@ export class Parser {
|
|
|
586
666
|
* Rejects `GroupNode` (or a wrapper-cloaked group) as the target of `token`.
|
|
587
667
|
* Explode, reroll, and crit-threshold wrap bare dice pools only — a group
|
|
588
668
|
* is a container of sub-expressions, so these modifiers have no defined
|
|
589
|
-
* semantics. Walks `Grouped`/`
|
|
669
|
+
* semantics. Walks `Grouped`/`KeepDrop`/`Sort`/`CritThreshold` so wrappers
|
|
590
670
|
* cannot smuggle a group past the check (`{1d6}kh1cs>5`, `({1d6})!`,
|
|
591
671
|
* `{1d6}scs>5` all reject the same as `{1d6}!`/`{1d6}cs>5`).
|
|
592
672
|
*
|
|
593
|
-
* `singleSubRollPasses` opts the caller into the
|
|
594
|
-
* passthrough rule (STAGE3.md "Group Semantics: Single vs Multi Sub-Roll"):
|
|
673
|
+
* `singleSubRollPasses` opts the caller into the single-sub-roll passthrough:
|
|
595
674
|
* a `Group` with one expression is the user's explicit flat-pool escape
|
|
596
675
|
* hatch and is equivalent to its unwrapped form. Currently only
|
|
597
676
|
* `parseCritThreshold` opts in — explode/reroll keep the strict reject so
|
|
@@ -604,14 +683,14 @@ export class Parser {
|
|
|
604
683
|
code: RollParserErrorCode,
|
|
605
684
|
singleSubRollPasses = false,
|
|
606
685
|
): void {
|
|
607
|
-
const node =
|
|
686
|
+
const node = unwrapAllTransparent(target);
|
|
608
687
|
if (node.type !== 'Group') return;
|
|
609
688
|
if (singleSubRollPasses && node.expressions.length === 1) {
|
|
610
|
-
// ! Deep-walk the inner sub-expression — `
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
//
|
|
689
|
+
// ! Deep-walk the inner sub-expression — `unwrapAllTransparent` only peels
|
|
690
|
+
// ! `Grouped`/`KeepDrop`/`Sort`/`CritThreshold`, so a multi-sub Group
|
|
691
|
+
// ! buried under arithmetic (`{{1d6,2d8}+0}cs>5`), a function call
|
|
692
|
+
// ! (`{abs({1d6,2d8})}cs>5`), or a unary op would reach the evaluator and
|
|
693
|
+
// ! override `critical`/`fumble` on dice from dropped sub-rolls.
|
|
615
694
|
const inner = node.expressions[0];
|
|
616
695
|
if (inner != null && containsMultiSubGroup(inner)) {
|
|
617
696
|
throw new ParseError(`Cannot ${action} a group`, code, token.position, token);
|
|
@@ -621,65 +700,74 @@ export class Parser {
|
|
|
621
700
|
throw new ParseError(`Cannot ${action} a group`, code, token.position, token);
|
|
622
701
|
}
|
|
623
702
|
|
|
703
|
+
/**
|
|
704
|
+
* Rejects a Versus anywhere inside a meta operand — a dice count, dice sides,
|
|
705
|
+
* a modifier count, or a threshold/bound value.
|
|
706
|
+
*
|
|
707
|
+
* Deep, unlike {@link rejectVersusTarget}: `evalMetaOperand` reduces the
|
|
708
|
+
* operand to a scalar and forwards rolls but not `versusMetadata`, so
|
|
709
|
+
* `floor(1d20 vs 15)` or `(1d20 vs 15)+0` buried in a count loses the degree
|
|
710
|
+
* exactly as a bare one does.
|
|
711
|
+
*/
|
|
712
|
+
private rejectVersusMetaOperand(operand: ASTNode, token: Token): void {
|
|
713
|
+
if (containsVersus(operand)) Parser.throwVersusMetaExpression(token);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* The wording the meta-expression rejections share. Not every `NESTED_VERSUS`
|
|
718
|
+
* throw — `parseVersus` says "Cannot chain versus operators" for `a vs b vs c`,
|
|
719
|
+
* a different condition. Routing that one through here would silently rewrite
|
|
720
|
+
* its message, and `expectRollError` asserts only the code.
|
|
721
|
+
*/
|
|
722
|
+
private static throwVersusMetaExpression(token: Token): never {
|
|
723
|
+
throw new ParseError(
|
|
724
|
+
`Versus cannot be used as a meta-expression`,
|
|
725
|
+
'NESTED_VERSUS',
|
|
726
|
+
token.position,
|
|
727
|
+
token,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
624
731
|
private rejectVersusTarget(target: ASTNode, token: Token): void {
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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.
|
|
732
|
+
// A Versus degree is a terminal scalar, never a valid modifier target —
|
|
733
|
+
// every one of these modifiers drops `versusMetadata` on the way through.
|
|
734
|
+
// Shallow on purpose: a multi-sub group like `{1d20 vs 15, 1d6}kh1` is
|
|
735
|
+
// legal, and `evalGroupKeepDrop` propagates the degree from kept sub-rolls.
|
|
736
|
+
// Narrow unwrap (only `Grouped`): `containsDicePool` does not recurse into
|
|
737
|
+
// `Versus`, so `KeepDrop`/`Sort`/`CritThreshold` reject the wrap upstream.
|
|
738
|
+
const node = unwrapGrouped(target);
|
|
739
|
+
if (node.type === 'Versus') Parser.throwVersusMetaExpression(token);
|
|
740
|
+
// ! `containsDicePool` recurses into a single-sub-roll Group via
|
|
741
|
+
// ! `deepContainsDicePool`, which traverses Versus's `roll`/`dc` — so that
|
|
742
|
+
// ! route gets a Versus past the shallow guards. Deep-walk for any descendant
|
|
743
|
+
// ! Versus, or `{1d20 vs 15}cs>18`, `{1+(1d20 vs 15)}cs>18`, and
|
|
744
|
+
// ! `4d6>={abs(1d20 vs 15)}` would parse while `(1d20 vs 15)cs>18` rejects.
|
|
647
745
|
if (node.type === 'Group' && node.expressions.length === 1) {
|
|
648
746
|
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
|
-
}
|
|
747
|
+
if (inner != null && containsVersus(inner)) Parser.throwVersusMetaExpression(token);
|
|
657
748
|
}
|
|
658
749
|
}
|
|
659
750
|
|
|
660
|
-
private
|
|
751
|
+
private parseKeepDrop(target: ASTNode, token: Token): KeepDropNode {
|
|
661
752
|
this.rejectSuccessCountTarget(target, token);
|
|
662
|
-
// !
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
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.
|
|
753
|
+
// ! Keep/drop on a Versus target silently drops `degree`/`natural` metadata,
|
|
754
|
+
// ! and `{1d20 vs 15}kh1` gets past `containsDicePool` — whose Group deep
|
|
755
|
+
// ! walk recurses into Versus's `roll`/`dc` — so this reject is the only
|
|
756
|
+
// ! thing closing that hole. Mirrors `parseSort`/`parseCritThreshold`.
|
|
669
757
|
this.rejectVersusTarget(target, token);
|
|
670
758
|
|
|
671
|
-
// Keep/drop
|
|
672
|
-
//
|
|
759
|
+
// Keep/drop needs a pool to select from; `(1d6+5)kh1` or `4d6+2kh3` would
|
|
760
|
+
// silently drop the user's arithmetic.
|
|
673
761
|
if (!containsDicePool(target)) {
|
|
674
762
|
throw new ParseError(
|
|
675
763
|
`Keep/drop modifiers require a dice pool target`,
|
|
676
|
-
'
|
|
764
|
+
'INVALID_KEEP_DROP_TARGET',
|
|
677
765
|
token.position,
|
|
678
766
|
token,
|
|
679
767
|
);
|
|
680
768
|
}
|
|
681
769
|
|
|
682
|
-
const
|
|
770
|
+
const kind =
|
|
683
771
|
token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
|
|
684
772
|
|
|
685
773
|
const selector =
|
|
@@ -687,7 +775,6 @@ export class Parser {
|
|
|
687
775
|
? 'highest'
|
|
688
776
|
: 'lowest';
|
|
689
777
|
|
|
690
|
-
// Default to 1 when no explicit count follows the modifier (e.g., 4d6kh → 4d6kh1)
|
|
691
778
|
const nextToken = this.peek().type;
|
|
692
779
|
const hasExplicitCount =
|
|
693
780
|
nextToken === TokenType.NUMBER ||
|
|
@@ -697,11 +784,11 @@ export class Parser {
|
|
|
697
784
|
? this.parseExpression(BP.DICE_LEFT)
|
|
698
785
|
: Parser.syntheticLiteral(1, token);
|
|
699
786
|
this.rejectSuccessCountTarget(count, token);
|
|
700
|
-
this.
|
|
787
|
+
this.rejectVersusMetaOperand(count, token);
|
|
701
788
|
|
|
702
789
|
return {
|
|
703
|
-
type: '
|
|
704
|
-
|
|
790
|
+
type: 'KeepDrop',
|
|
791
|
+
kind,
|
|
705
792
|
selector,
|
|
706
793
|
count,
|
|
707
794
|
target,
|
|
@@ -713,13 +800,12 @@ export class Parser {
|
|
|
713
800
|
private parseExplode(target: ASTNode, token: Token): ExplodeNode {
|
|
714
801
|
this.rejectSuccessCountTarget(target, token);
|
|
715
802
|
|
|
716
|
-
// Groups have no explode semantics
|
|
717
|
-
//
|
|
718
|
-
// let `{4d6}!` slip through.
|
|
803
|
+
// Groups have no explode semantics. Must precede `containsDicePool`, which
|
|
804
|
+
// recurses into `Group` and would let `{4d6}!` slip through.
|
|
719
805
|
this.rejectGroupTarget(target, token, 'explode', 'INVALID_EXPLODE_TARGET');
|
|
720
806
|
|
|
721
|
-
//
|
|
722
|
-
//
|
|
807
|
+
// Arithmetic wrappers like `(1d6+5)!` or `floor(1d6/2)!` would silently drop
|
|
808
|
+
// the user's math.
|
|
723
809
|
if (!containsDicePool(target)) {
|
|
724
810
|
throw new ParseError(
|
|
725
811
|
`Explode modifier requires a dice pool target`,
|
|
@@ -729,9 +815,8 @@ export class Parser {
|
|
|
729
815
|
);
|
|
730
816
|
}
|
|
731
817
|
|
|
732
|
-
// Fate
|
|
733
|
-
// no
|
|
734
|
-
// time rather than silently no-op in the evaluator.
|
|
818
|
+
// Fate's symmetric -1/0/+1 range has no max face to trigger on, so exploding
|
|
819
|
+
// it has no defined semantics — reject here rather than no-op in the evaluator.
|
|
735
820
|
if (containsFatePool(target)) {
|
|
736
821
|
throw new ParseError(
|
|
737
822
|
`Fate dice cannot explode`,
|
|
@@ -741,8 +826,7 @@ export class Parser {
|
|
|
741
826
|
);
|
|
742
827
|
}
|
|
743
828
|
|
|
744
|
-
//
|
|
745
|
-
// an ExplodeNode has no meaningful semantics and is rejected per spec.
|
|
829
|
+
// A second explode token atop an `Explode` (`1d6!!!`) has no semantics.
|
|
746
830
|
if (target.type === 'Explode') {
|
|
747
831
|
throw new ParseError(
|
|
748
832
|
`Cannot chain explode modifiers`,
|
|
@@ -752,36 +836,36 @@ export class Parser {
|
|
|
752
836
|
);
|
|
753
837
|
}
|
|
754
838
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
: token.type === TokenType.EXPLODE_COMPOUND
|
|
759
|
-
? 'compound'
|
|
760
|
-
: 'penetrating';
|
|
839
|
+
// `parseLed` reaches here only for the three explode tokens, so the lookup
|
|
840
|
+
// always hits; the fallback exists to keep the type non-optional.
|
|
841
|
+
const variant: ExplodeNode['variant'] = EXPLODE_VARIANTS[token.type] ?? 'penetrating';
|
|
761
842
|
|
|
762
|
-
const
|
|
843
|
+
const start = target.start ?? token.position;
|
|
844
|
+
if (!this.isComparePointAhead()) {
|
|
845
|
+
return { type: 'Explode', variant, target, start, end: token.end };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const threshold = this.parseComparePoint();
|
|
849
|
+
|
|
850
|
+
return {
|
|
763
851
|
type: 'Explode',
|
|
764
852
|
variant,
|
|
765
853
|
target,
|
|
766
|
-
|
|
767
|
-
|
|
854
|
+
threshold,
|
|
855
|
+
start,
|
|
856
|
+
end: threshold.value.end ?? token.end,
|
|
768
857
|
};
|
|
769
|
-
if (this.isComparePointAhead()) {
|
|
770
|
-
node.threshold = this.parseComparePoint();
|
|
771
|
-
node.end = node.threshold.value.end ?? token.end;
|
|
772
|
-
}
|
|
773
|
-
return node;
|
|
774
858
|
}
|
|
775
859
|
|
|
776
860
|
private parseReroll(target: ASTNode, token: Token): RerollNode {
|
|
777
861
|
this.rejectSuccessCountTarget(target, token);
|
|
778
862
|
|
|
779
|
-
// Groups have no reroll semantics
|
|
780
|
-
//
|
|
863
|
+
// Groups have no reroll semantics. Must precede `containsDicePool`, which
|
|
864
|
+
// recurses into `Group`.
|
|
781
865
|
this.rejectGroupTarget(target, token, 'reroll', 'INVALID_REROLL_TARGET');
|
|
782
866
|
|
|
783
|
-
//
|
|
784
|
-
//
|
|
867
|
+
// Arithmetic wrappers like `(1d6+5)r<3` or `floor(1d6/2)ro<3` would silently
|
|
868
|
+
// drop the user's math.
|
|
785
869
|
if (!containsDicePool(target)) {
|
|
786
870
|
throw new ParseError(
|
|
787
871
|
`Reroll modifier requires a dice pool target`,
|
|
@@ -791,7 +875,6 @@ export class Parser {
|
|
|
791
875
|
);
|
|
792
876
|
}
|
|
793
877
|
|
|
794
|
-
// A reroll token must be followed by a comparison — bare `r` / `ro` is invalid.
|
|
795
878
|
if (!this.isComparePointAhead()) {
|
|
796
879
|
throw new ParseError(
|
|
797
880
|
`Expected comparison operator after '${token.value}'`,
|
|
@@ -814,14 +897,73 @@ export class Parser {
|
|
|
814
897
|
};
|
|
815
898
|
}
|
|
816
899
|
|
|
900
|
+
private parseDieBound(target: ASTNode, token: Token): DieBoundNode {
|
|
901
|
+
this.rejectSuccessCountTarget(target, token);
|
|
902
|
+
this.rejectVersusTarget(target, token);
|
|
903
|
+
|
|
904
|
+
// Groups have no per-die clamp semantics — a group's "dice" are sub-roll
|
|
905
|
+
// subtotals. Must come before `containsDicePool`, which recurses into
|
|
906
|
+
// `Group`.
|
|
907
|
+
this.rejectGroupTarget(target, token, `apply '${token.value}' to`, 'INVALID_DIE_BOUND_TARGET');
|
|
908
|
+
|
|
909
|
+
// A clamp needs a dice pool to act on. Wrapping arithmetic (e.g.
|
|
910
|
+
// `(1d6+5)min3`) would silently drop user math.
|
|
911
|
+
if (!containsDicePool(target)) {
|
|
912
|
+
throw new ParseError(
|
|
913
|
+
`Die bound modifier requires a dice pool target`,
|
|
914
|
+
'INVALID_DIE_BOUND_TARGET',
|
|
915
|
+
token.position,
|
|
916
|
+
token,
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Fate dice roll a fixed {-1, 0, +1}; clamping those faces has no
|
|
921
|
+
// established semantics. Mirrors the Fate-explosion rejection.
|
|
922
|
+
if (containsFatePool(target)) {
|
|
923
|
+
throw new ParseError(
|
|
924
|
+
`Fate dice cannot be clamped`,
|
|
925
|
+
'INVALID_DIE_BOUND_TARGET',
|
|
926
|
+
token.position,
|
|
927
|
+
token,
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// A bound must be explicit — bare `4d6min` has nothing to clamp to.
|
|
932
|
+
const nextToken = this.peek().type;
|
|
933
|
+
const hasExplicitValue =
|
|
934
|
+
nextToken === TokenType.NUMBER ||
|
|
935
|
+
nextToken === TokenType.LPAREN ||
|
|
936
|
+
nextToken === TokenType.AT;
|
|
937
|
+
if (!hasExplicitValue) {
|
|
938
|
+
throw new ParseError(
|
|
939
|
+
`Expected value after '${token.value}'`,
|
|
940
|
+
'EXPECTED_TOKEN',
|
|
941
|
+
token.position,
|
|
942
|
+
token,
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const value = this.parseExpression(BP.DICE_LEFT);
|
|
947
|
+
this.rejectSuccessCountTarget(value, token);
|
|
948
|
+
this.rejectVersusMetaOperand(value, token);
|
|
949
|
+
|
|
950
|
+
return {
|
|
951
|
+
type: 'DieBound',
|
|
952
|
+
bound: token.value === 'min' ? 'min' : 'max',
|
|
953
|
+
value,
|
|
954
|
+
target,
|
|
955
|
+
start: target.start ?? token.position,
|
|
956
|
+
end: value.end ?? token.end,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
817
960
|
private parseSort(target: ASTNode, token: Token): SortNode {
|
|
818
961
|
this.rejectSuccessCountTarget(target, token);
|
|
819
962
|
this.rejectVersusTarget(target, token);
|
|
820
963
|
|
|
821
|
-
//
|
|
822
|
-
// `5s`, `(1+2)s`,
|
|
823
|
-
//
|
|
824
|
-
// Stage 3 spec, while pure literals/arithmetic reject.
|
|
964
|
+
// Deep guard, unlike explode/reroll: arithmetic-wrapped pools like
|
|
965
|
+
// `(1d6+2d8)s` are sortable, while `5s`, `(1+2)s`, and `floor(5)s` have no
|
|
966
|
+
// dice to reorder.
|
|
825
967
|
if (!deepContainsDicePool(target)) {
|
|
826
968
|
throw new ParseError(
|
|
827
969
|
`Sort modifier requires a dice pool target`,
|
|
@@ -831,14 +973,12 @@ export class Parser {
|
|
|
831
973
|
);
|
|
832
974
|
}
|
|
833
975
|
|
|
834
|
-
// ! Multi-sub-roll groups (`{a, b}s`, `({a, b})s`)
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
|
|
840
|
-
// expression, which is the user's flat-pool escape hatch).
|
|
841
|
-
const base = unwrapTransparent(target, ['Grouped', 'Modifier', 'Sort', 'CritThreshold']);
|
|
976
|
+
// ! Multi-sub-roll groups (`{a, b}s`, `({a, b})s`) require hierarchical sort —
|
|
977
|
+
// ! dice within each sub-roll, then sub-rolls by total — but `evalSort` only
|
|
978
|
+
// ! flat-sorts, so accepting the syntax would silently ship wrong output.
|
|
979
|
+
// ! Single-sub Groups still pass; that is the flat-pool escape hatch.
|
|
980
|
+
// TODO: Implement hierarchical group sort, then drop this reject.
|
|
981
|
+
const base = unwrapAllTransparent(target);
|
|
842
982
|
if (base.type === 'Group' && base.expressions.length >= 2) {
|
|
843
983
|
throw new ParseError(
|
|
844
984
|
`Sort modifier does not yet support multi-sub-roll groups`,
|
|
@@ -850,9 +990,8 @@ export class Parser {
|
|
|
850
990
|
|
|
851
991
|
const order: SortNode['order'] = token.type === TokenType.SORT_ASC ? 'ascending' : 'descending';
|
|
852
992
|
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
// overrides the order since both pass over the same pool.
|
|
993
|
+
// Chained sorts (`4d6ss`, `4d6sasd`) are deliberately allowed: repeats are
|
|
994
|
+
// idempotent and a later `sd` just overrides the order.
|
|
856
995
|
return {
|
|
857
996
|
type: 'Sort',
|
|
858
997
|
order,
|
|
@@ -866,12 +1005,11 @@ export class Parser {
|
|
|
866
1005
|
this.rejectSuccessCountTarget(target, token);
|
|
867
1006
|
this.rejectVersusTarget(target, token);
|
|
868
1007
|
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
// `containsDicePool`, which recurses into `Group`.
|
|
1008
|
+
// A multi-sub-roll group is a container of sub-roll subtotals, not a pool:
|
|
1009
|
+
// `cs`/`cf` there would override `critical`/`fumble` on dropped sub-roll
|
|
1010
|
+
// dice. Single-sub-roll groups pass through as the flat-pool form
|
|
1011
|
+
// (`{1d20}kh1cs>18` ≡ `(1d20)kh1cs>18`). Must run before `containsDicePool`,
|
|
1012
|
+
// which recurses into `Group`.
|
|
875
1013
|
this.rejectGroupTarget(
|
|
876
1014
|
target,
|
|
877
1015
|
token,
|
|
@@ -880,10 +1018,9 @@ export class Parser {
|
|
|
880
1018
|
true,
|
|
881
1019
|
);
|
|
882
1020
|
|
|
883
|
-
// Shallow
|
|
884
|
-
// `
|
|
885
|
-
//
|
|
886
|
-
// recurses into its `target`, so chained `cs`/`cf` naturally pass.
|
|
1021
|
+
// Shallow check like explode/reroll: `(1d6+2d8)cs>5`, `5cs`, `(1+2)cs`, and
|
|
1022
|
+
// `floor(5)cs` reject. Chained `cs`/`cf` still pass, since `containsDicePool`
|
|
1023
|
+
// recurses into a `CritThreshold`'s own target.
|
|
887
1024
|
if (!containsDicePool(target)) {
|
|
888
1025
|
throw new ParseError(
|
|
889
1026
|
`Crit threshold modifier requires a dice pool target`,
|
|
@@ -893,11 +1030,9 @@ export class Parser {
|
|
|
893
1030
|
);
|
|
894
1031
|
}
|
|
895
1032
|
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
// thresholds with explicit ComparePoints remain accepted. Mirrors the
|
|
900
|
-
// Fate-explosion rejection above.
|
|
1033
|
+
// The bare `cs`/`cf` per-die default assumes max-side/1 semantics, which on
|
|
1034
|
+
// Fate's `{-1, 0, +1}` would flag the best face (`+1`) as a fumble. Explicit
|
|
1035
|
+
// ComparePoints stay accepted. Mirrors the Fate-explosion reject above.
|
|
901
1036
|
if (!this.isComparePointAhead() && containsFatePool(target)) {
|
|
902
1037
|
throw new ParseError(
|
|
903
1038
|
`Bare cs/cf cannot apply to Fate dice`,
|
|
@@ -912,23 +1047,27 @@ export class Parser {
|
|
|
912
1047
|
: 'default';
|
|
913
1048
|
const end = threshold === 'default' ? token.end : (threshold.value.end ?? token.end);
|
|
914
1049
|
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
// from STAGE3.md §"CritThreshold Collects Multiple Thresholds" would
|
|
919
|
-
// be subtly broken for any user who parenthesizes the chain.
|
|
1050
|
+
// Unwrap parens so `(1d20cs>19)cs=1` collapses into the inner node instead of
|
|
1051
|
+
// nesting a second `CritThreshold` around the `Grouped` — every threshold in
|
|
1052
|
+
// a chain must collect on one node, which parenthesizing would break.
|
|
920
1053
|
let chainTarget: ASTNode = target;
|
|
921
1054
|
while (chainTarget.type === 'Grouped') {
|
|
922
1055
|
chainTarget = chainTarget.expression;
|
|
923
1056
|
}
|
|
924
1057
|
if (isCritThreshold(chainTarget)) {
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1058
|
+
// Rebuilt, not mutated: `NodeSpan` is readonly, and the discarded `Grouped`
|
|
1059
|
+
// wrapper must not leave a stale `end`.
|
|
1060
|
+
const isSuccess = token.type === TokenType.CRIT_SUCCESS;
|
|
1061
|
+
return {
|
|
1062
|
+
...chainTarget,
|
|
1063
|
+
successThresholds: isSuccess
|
|
1064
|
+
? [...chainTarget.successThresholds, threshold]
|
|
1065
|
+
: chainTarget.successThresholds,
|
|
1066
|
+
failThresholds: isSuccess
|
|
1067
|
+
? chainTarget.failThresholds
|
|
1068
|
+
: [...chainTarget.failThresholds, threshold],
|
|
1069
|
+
end,
|
|
1070
|
+
};
|
|
932
1071
|
}
|
|
933
1072
|
|
|
934
1073
|
return {
|
|
@@ -945,9 +1084,9 @@ export class Parser {
|
|
|
945
1084
|
// Success counting is terminal: chaining (`>=5>=3`) has no semantics.
|
|
946
1085
|
this.rejectSuccessCountTarget(target, token);
|
|
947
1086
|
|
|
948
|
-
//
|
|
949
|
-
// `(1d20 vs 15)>=1
|
|
950
|
-
//
|
|
1087
|
+
// Success counting reads a raw pool, so arithmetic or composition wrappers
|
|
1088
|
+
// (`1>=3`, `(1+2)>=3`, `(1d6*2)>=10`, `(1d20 vs 15)>=1`) would be silently
|
|
1089
|
+
// ignored.
|
|
951
1090
|
if (!containsDicePool(target)) {
|
|
952
1091
|
throw new ParseError(
|
|
953
1092
|
`Success counting requires a dice pool target`,
|
|
@@ -958,42 +1097,49 @@ export class Parser {
|
|
|
958
1097
|
}
|
|
959
1098
|
|
|
960
1099
|
const operator = this.getCompareOp(token);
|
|
961
|
-
//
|
|
1100
|
+
// Threshold binds at `BP.DICE_LEFT` — see `parseComparePoint` TSDoc.
|
|
962
1101
|
const value = this.parseExpression(BP.DICE_LEFT);
|
|
963
1102
|
this.rejectSuccessCountTarget(value, token);
|
|
964
|
-
this.
|
|
965
|
-
const
|
|
1103
|
+
this.rejectVersusMetaOperand(value, token);
|
|
1104
|
+
const start = target.start ?? token.position;
|
|
1105
|
+
const end = value.end ?? token.end;
|
|
1106
|
+
|
|
1107
|
+
if (this.peek().type !== TokenType.FAIL) {
|
|
1108
|
+
return { type: 'SuccessCount', target, threshold: { operator, value }, start, end };
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
this.advance();
|
|
1112
|
+
const failThreshold = this.parseFailThreshold(token);
|
|
1113
|
+
|
|
1114
|
+
return {
|
|
966
1115
|
type: 'SuccessCount',
|
|
967
1116
|
target,
|
|
968
1117
|
threshold: { operator, value },
|
|
969
|
-
|
|
970
|
-
|
|
1118
|
+
failThreshold,
|
|
1119
|
+
start,
|
|
1120
|
+
end: failThreshold.value.end ?? end,
|
|
971
1121
|
};
|
|
1122
|
+
}
|
|
972
1123
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
this.rejectVersusTarget(failValue, token);
|
|
982
|
-
node.failThreshold = { operator: '=', value: failValue };
|
|
983
|
-
}
|
|
984
|
-
node.end = node.failThreshold.value.end ?? node.end ?? token.end;
|
|
985
|
-
}
|
|
1124
|
+
/** Parses the `f...` suffix of a success count. Bare `fN` means `f=N`. */
|
|
1125
|
+
private parseFailThreshold(token: Token): ComparePoint {
|
|
1126
|
+
if (this.isComparePointAhead()) return this.parseComparePoint();
|
|
1127
|
+
|
|
1128
|
+
// Same threshold binding as `parseComparePoint` (BP.DICE_LEFT).
|
|
1129
|
+
const failValue = this.parseExpression(BP.DICE_LEFT);
|
|
1130
|
+
this.rejectSuccessCountTarget(failValue, token);
|
|
1131
|
+
this.rejectVersusMetaOperand(failValue, token);
|
|
986
1132
|
|
|
987
|
-
return
|
|
1133
|
+
return { operator: '=', value: failValue };
|
|
988
1134
|
}
|
|
989
1135
|
|
|
990
1136
|
private parseVersus(left: ASTNode, token: Token): VersusNode {
|
|
991
1137
|
this.rejectSuccessCountTarget(left, token);
|
|
992
1138
|
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
//
|
|
996
|
-
//
|
|
1139
|
+
// A degree is a scalar, not a comparable, so `a vs b vs c` has no semantics.
|
|
1140
|
+
// Unwrapping `Grouped` makes `(a vs b) vs c` reject here like the bare form;
|
|
1141
|
+
// a paren-nested DC (`a vs (b vs c)`) is caught later by the evaluator's
|
|
1142
|
+
// `mergeContext`.
|
|
997
1143
|
let leftChain: ASTNode = left;
|
|
998
1144
|
while (leftChain.type === 'Grouped') {
|
|
999
1145
|
leftChain = leftChain.expression;
|
|
@@ -1014,12 +1160,14 @@ export class Parser {
|
|
|
1014
1160
|
};
|
|
1015
1161
|
}
|
|
1016
1162
|
|
|
1163
|
+
//
|
|
1017
1164
|
// * Compare point utilities
|
|
1165
|
+
//
|
|
1018
1166
|
|
|
1019
1167
|
/**
|
|
1020
1168
|
* Checks whether the next token is a comparison operator.
|
|
1021
1169
|
*/
|
|
1022
|
-
isComparePointAhead(): boolean {
|
|
1170
|
+
private isComparePointAhead(): boolean {
|
|
1023
1171
|
const type = this.peek().type;
|
|
1024
1172
|
return (
|
|
1025
1173
|
type === TokenType.GREATER ||
|
|
@@ -1044,7 +1192,7 @@ export class Parser {
|
|
|
1044
1192
|
* @returns A ComparePoint with the operator and value AST node
|
|
1045
1193
|
* @throws {ParseError} If the next token is not a comparison operator
|
|
1046
1194
|
*/
|
|
1047
|
-
parseComparePoint(): ComparePoint {
|
|
1195
|
+
private parseComparePoint(): ComparePoint {
|
|
1048
1196
|
const token = this.peek();
|
|
1049
1197
|
const operator = this.getCompareOp(token);
|
|
1050
1198
|
|
|
@@ -1052,7 +1200,7 @@ export class Parser {
|
|
|
1052
1200
|
|
|
1053
1201
|
const value = this.parseExpression(BP.DICE_LEFT);
|
|
1054
1202
|
this.rejectSuccessCountTarget(value, token);
|
|
1055
|
-
this.
|
|
1203
|
+
this.rejectVersusMetaOperand(value, token);
|
|
1056
1204
|
|
|
1057
1205
|
return { operator, value };
|
|
1058
1206
|
}
|
|
@@ -1079,7 +1227,9 @@ export class Parser {
|
|
|
1079
1227
|
}
|
|
1080
1228
|
}
|
|
1081
1229
|
|
|
1230
|
+
//
|
|
1082
1231
|
// * Helpers
|
|
1232
|
+
//
|
|
1083
1233
|
|
|
1084
1234
|
private getOperatorSymbol(token: Token): '+' | '-' | '*' | '/' | '%' | '**' {
|
|
1085
1235
|
switch (token.type) {
|
|
@@ -1136,26 +1286,24 @@ export class Parser {
|
|
|
1136
1286
|
case TokenType.CRIT_SUCCESS:
|
|
1137
1287
|
case TokenType.CRIT_FAIL:
|
|
1138
1288
|
return BP.MODIFIER;
|
|
1139
|
-
// Comparison operators act as LED-dispatched success-count modifiers
|
|
1140
|
-
//
|
|
1141
|
-
//
|
|
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.
|
|
1289
|
+
// Comparison operators act as LED-dispatched success-count modifiers here;
|
|
1290
|
+
// `parseComparePoint`, called manually by explode/reroll, bypasses this BP.
|
|
1291
|
+
// See `BP.COMPARE` for why it sits below ADD/MUL.
|
|
1145
1292
|
case TokenType.GREATER:
|
|
1146
1293
|
case TokenType.GREATER_EQUAL:
|
|
1147
1294
|
case TokenType.LESS:
|
|
1148
1295
|
case TokenType.LESS_EQUAL:
|
|
1149
1296
|
case TokenType.EQUAL:
|
|
1150
1297
|
return BP.COMPARE;
|
|
1298
|
+
// `min`/`max` double as postfix per-die clamps (`4d6min2`); every
|
|
1299
|
+
// other function name terminates the expression like punctuation.
|
|
1300
|
+
case TokenType.FUNCTION:
|
|
1301
|
+
return token.value === 'min' || token.value === 'max' ? BP.MODIFIER : -1;
|
|
1151
1302
|
case TokenType.RPAREN:
|
|
1152
1303
|
case TokenType.EOF:
|
|
1153
|
-
// Punctuation and keywords that terminate expressions
|
|
1154
1304
|
case TokenType.COMMA:
|
|
1155
|
-
|
|
1156
|
-
//
|
|
1157
|
-
// `parseGroup`), and a stray `{` after a complete expression is an
|
|
1158
|
-
// error. Both terminate the outer Pratt loop at -1.
|
|
1305
|
+
// `}` is consumed inside `parseGroup`, and a stray `{` after a complete
|
|
1306
|
+
// expression is an error — both must terminate the outer Pratt loop.
|
|
1159
1307
|
case TokenType.LBRACE:
|
|
1160
1308
|
case TokenType.RBRACE:
|
|
1161
1309
|
return -1;
|
|
@@ -1196,10 +1344,10 @@ export class Parser {
|
|
|
1196
1344
|
return token;
|
|
1197
1345
|
}
|
|
1198
1346
|
|
|
1199
|
-
private expect(type:
|
|
1347
|
+
private expect(type: ExpectableToken): Token {
|
|
1200
1348
|
const token = this.peek();
|
|
1201
1349
|
if (token.type !== type) {
|
|
1202
|
-
const expected = TOKEN_DISPLAY[type]
|
|
1350
|
+
const expected = TOKEN_DISPLAY[type];
|
|
1203
1351
|
const got = token.type === TokenType.EOF ? 'end of input' : `'${token.value}'`;
|
|
1204
1352
|
throw new ParseError(
|
|
1205
1353
|
`Expected ${expected} but got ${got}`,
|
|
@@ -1217,20 +1365,49 @@ export class Parser {
|
|
|
1217
1365
|
}
|
|
1218
1366
|
|
|
1219
1367
|
/**
|
|
1220
|
-
*
|
|
1368
|
+
* Parses a dice notation string into an {@link ASTNode} tree, lexing it first.
|
|
1369
|
+
*
|
|
1370
|
+
* {@link evaluate} never mutates the AST, so parse once and evaluate many times
|
|
1371
|
+
* when rolling the same notation repeatedly — that skips the lexer and parser
|
|
1372
|
+
* on every roll after the first. Use `parse` alone to validate notation
|
|
1373
|
+
* without consuming randomness.
|
|
1221
1374
|
*
|
|
1222
1375
|
* @param notation - The dice notation to parse
|
|
1223
|
-
* @returns The root AST node
|
|
1376
|
+
* @returns The root AST node, with `start`/`end` spans on every node
|
|
1224
1377
|
* @throws {LexerError} If the input contains invalid characters
|
|
1225
1378
|
* @throws {ParseError} If the input has invalid syntax
|
|
1379
|
+
* @throws {RollParserError} `INVALID_NOTATION_TYPE` when `notation` is not a
|
|
1380
|
+
* string — raised before lexing, so it carries no position
|
|
1226
1381
|
*
|
|
1227
1382
|
* @example
|
|
1228
1383
|
* ```typescript
|
|
1229
|
-
*
|
|
1230
|
-
*
|
|
1231
|
-
*
|
|
1232
|
-
* //
|
|
1384
|
+
* import { parse } from 'roll-parser';
|
|
1385
|
+
*
|
|
1386
|
+
* parse('2d6+3');
|
|
1387
|
+
* // {
|
|
1388
|
+
* // type: 'BinaryOp', operator: '+', start: 0, end: 5,
|
|
1389
|
+
* // left: {
|
|
1390
|
+
* // type: 'Dice', start: 0, end: 3,
|
|
1391
|
+
* // count: { type: 'Literal', value: 2, start: 0, end: 1 },
|
|
1392
|
+
* // sides: { type: 'Literal', value: 6, start: 2, end: 3 },
|
|
1393
|
+
* // },
|
|
1394
|
+
* // right: { type: 'Literal', value: 3, start: 4, end: 5 },
|
|
1395
|
+
* // }
|
|
1396
|
+
* ```
|
|
1397
|
+
*
|
|
1398
|
+
* `count` and `sides` are full nodes, not numbers — that is what makes
|
|
1399
|
+
* computed dice like `(1d4)d6` expressible.
|
|
1400
|
+
*
|
|
1401
|
+
* @example Parse once, roll many
|
|
1402
|
+
* ```typescript
|
|
1403
|
+
* import { evaluate, parse, SeededRNG } from 'roll-parser';
|
|
1404
|
+
*
|
|
1405
|
+
* const ast = parse('4d6kh3');
|
|
1406
|
+
* const rng = new SeededRNG('demo');
|
|
1407
|
+
* const scores = Array.from({ length: 6 }, () => evaluate(ast, rng).total);
|
|
1233
1408
|
* ```
|
|
1409
|
+
*
|
|
1410
|
+
* @category Core
|
|
1234
1411
|
*/
|
|
1235
1412
|
export function parse(notation: string): ASTNode {
|
|
1236
1413
|
const tokens = lex(notation);
|