roll-parser 3.0.0-alpha.0 → 3.0.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 +189 -0
- package/MIGRATION.md +147 -0
- package/README.md +985 -43
- 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 +16 -4
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +17 -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 -14
- 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 +26 -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 +58 -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 +62 -40
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +906 -0
- package/dist/evaluator/evaluator.js.map +1 -0
- package/dist/evaluator/modifiers/compare.d.ts +1 -1
- package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
- 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 +27 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.js +23 -0
- package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
- package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
- package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
- package/dist/evaluator/modifiers/die-bound.js +14 -0
- package/dist/evaluator/modifiers/die-bound.js.map +1 -0
- package/dist/evaluator/modifiers/explode.d.ts +18 -6
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/explode.js +103 -0
- package/dist/evaluator/modifiers/explode.js.map +1 -0
- package/dist/evaluator/modifiers/flags.d.ts +37 -0
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
- package/dist/evaluator/modifiers/flags.js +18 -0
- package/dist/evaluator/modifiers/flags.js.map +1 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
- 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 +14 -6
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
- package/dist/evaluator/modifiers/reroll.js +62 -0
- package/dist/evaluator/modifiers/reroll.js.map +1 -0
- package/dist/evaluator/modifiers/sort.d.ts +27 -0
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
- 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 +2 -6
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +24 -0
- package/dist/evaluator/modifiers/success-count.js.map +1 -0
- package/dist/index.d.ts +35 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1723
- package/dist/index.js.map +1 -0
- package/dist/lexer/lexer.d.ts +68 -8
- 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 +52 -7
- 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 +419 -80
- 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 +162 -15
- 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/rng/mock.d.ts +74 -13
- 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 +149 -10
- 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 +59 -25
- 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 -38
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +427 -24
- 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 -40
- package/src/cli/args.ts +66 -9
- package/src/cli/format.ts +30 -7
- package/src/cli/index.ts +27 -67
- package/src/cli/main.ts +129 -0
- package/src/errors.ts +488 -23
- package/src/evaluator/die.ts +50 -0
- package/src/evaluator/env.ts +73 -0
- package/src/evaluator/evaluator.ts +1296 -349
- package/src/evaluator/modifiers/compare.ts +1 -1
- package/src/evaluator/modifiers/crit-threshold.ts +56 -0
- package/src/evaluator/modifiers/die-bound.ts +39 -0
- package/src/evaluator/modifiers/explode.ts +82 -76
- package/src/evaluator/modifiers/flags.ts +61 -0
- package/src/evaluator/modifiers/keep-drop.ts +124 -126
- package/src/evaluator/modifiers/reroll.ts +36 -64
- package/src/evaluator/modifiers/sort.ts +43 -0
- package/src/evaluator/modifiers/success-count.ts +6 -9
- package/src/index.ts +73 -34
- package/src/lexer/lexer.ts +201 -35
- package/src/lexer/tokens.ts +72 -7
- package/src/parser/ast.ts +453 -104
- package/src/parser/guards.ts +248 -0
- package/src/parser/parser.ts +835 -135
- package/src/rng/mock.ts +75 -14
- package/src/rng/seeded.ts +323 -58
- package/src/rng/types.ts +57 -0
- package/src/roll.ts +66 -41
- package/src/testing.ts +5 -9
- package/src/types.ts +416 -24
- package/src/version.ts +2 -0
- package/dist/cli.js +0 -1775
- package/dist/evaluator/index.d.ts +0 -8
- package/dist/evaluator/index.d.ts.map +0 -1
- package/dist/index.mjs +0 -1724
- package/dist/rng/index.d.ts +0 -8
- package/dist/rng/index.d.ts.map +0 -1
- package/dist/testing.mjs +0 -39
- package/src/evaluator/index.ts +0 -14
- package/src/rng/index.ts +0 -8
package/src/parser/parser.ts
CHANGED
|
@@ -4,39 +4,87 @@
|
|
|
4
4
|
* @module parser/parser
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { RollParserErrorCode } from '../errors';
|
|
8
|
-
import { RollParserError } from '../errors';
|
|
9
|
-
import { lex } from '../lexer/lexer';
|
|
10
|
-
import { type Token, TokenType } from '../lexer/tokens';
|
|
11
|
-
import type { CompareOp, ComparePoint } from '../types';
|
|
7
|
+
import type { RollParserErrorCode } from '../errors.js';
|
|
8
|
+
import { RollParserError } from '../errors.js';
|
|
9
|
+
import { lex } from '../lexer/lexer.js';
|
|
10
|
+
import { type Token, TokenType } from '../lexer/tokens.js';
|
|
11
|
+
import type { CompareOp, ComparePoint } from '../types.js';
|
|
12
12
|
import type {
|
|
13
13
|
ASTNode,
|
|
14
14
|
BinaryOpNode,
|
|
15
|
+
CritThreshold,
|
|
16
|
+
CritThresholdNode,
|
|
15
17
|
DiceNode,
|
|
18
|
+
DieBoundNode,
|
|
16
19
|
ExplodeNode,
|
|
17
20
|
FateDiceNode,
|
|
18
21
|
FunctionCallNode,
|
|
22
|
+
GroupedNode,
|
|
23
|
+
GroupNode,
|
|
24
|
+
KeepDropNode,
|
|
19
25
|
LiteralNode,
|
|
20
|
-
ModifierNode,
|
|
21
26
|
RerollNode,
|
|
27
|
+
SortNode,
|
|
22
28
|
SuccessCountNode,
|
|
23
29
|
UnaryOpNode,
|
|
30
|
+
VariableNode,
|
|
24
31
|
VersusNode,
|
|
25
|
-
} from './ast';
|
|
26
|
-
import {
|
|
32
|
+
} from './ast.js';
|
|
33
|
+
import { isCritThreshold, isSuccessCount } from './ast.js';
|
|
34
|
+
import {
|
|
35
|
+
containsDicePool,
|
|
36
|
+
containsFatePool,
|
|
37
|
+
containsMultiSubGroup,
|
|
38
|
+
containsVersus,
|
|
39
|
+
deepContainsDicePool,
|
|
40
|
+
unwrapAllTransparent,
|
|
41
|
+
unwrapGrouped,
|
|
42
|
+
} from './guards.js';
|
|
27
43
|
|
|
28
44
|
/**
|
|
29
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
|
|
30
66
|
*/
|
|
31
67
|
export class ParseError extends RollParserError {
|
|
68
|
+
/** Zero-based UTF-16 offset in the notation where parsing failed. */
|
|
32
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
|
+
*/
|
|
33
75
|
readonly token: Token | undefined;
|
|
34
76
|
|
|
35
|
-
constructor(
|
|
36
|
-
|
|
77
|
+
constructor(
|
|
78
|
+
message: string,
|
|
79
|
+
code: RollParserErrorCode,
|
|
80
|
+
position: number,
|
|
81
|
+
token?: Token,
|
|
82
|
+
options?: ErrorOptions,
|
|
83
|
+
) {
|
|
84
|
+
super(message, code, options);
|
|
37
85
|
this.name = 'ParseError';
|
|
38
86
|
this.position = position;
|
|
39
|
-
this.token = token
|
|
87
|
+
this.token = token;
|
|
40
88
|
}
|
|
41
89
|
}
|
|
42
90
|
|
|
@@ -46,6 +94,7 @@ export class ParseError extends RollParserError {
|
|
|
46
94
|
*
|
|
47
95
|
* Precedence order (lowest to highest):
|
|
48
96
|
* - Versus (`vs`): 2-3 (lowest — full expressions on both sides)
|
|
97
|
+
* - Comparison (success-count LED): 8
|
|
49
98
|
* - Addition/subtraction: 10
|
|
50
99
|
* - Multiplication/division/modulo: 20
|
|
51
100
|
* - Unary minus: 25 (binds to complete dice expr: -1d4 = -(1d4))
|
|
@@ -54,27 +103,72 @@ export class ParseError extends RollParserError {
|
|
|
54
103
|
* - Dice: 40-41
|
|
55
104
|
*/
|
|
56
105
|
const BP = {
|
|
57
|
-
//
|
|
106
|
+
// Lowest, so both sides take a full expression: `1d20+10 vs 25+10` =
|
|
107
|
+
// `(1d20+10) vs (25+10)`.
|
|
58
108
|
VS_LEFT: 2,
|
|
59
109
|
VS_RIGHT: 3,
|
|
60
|
-
//
|
|
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 `+`.
|
|
112
|
+
COMPARE: 8,
|
|
61
113
|
ADD_LEFT: 10,
|
|
62
114
|
ADD_RIGHT: 11,
|
|
63
|
-
// Multiplication/division/modulo (left-associative)
|
|
64
115
|
MUL_LEFT: 20,
|
|
65
116
|
MUL_RIGHT: 21,
|
|
66
|
-
//
|
|
117
|
+
// Between MUL and POW so `-1d4` = `-(1d4)`, not `(-1)d4`.
|
|
67
118
|
UNARY: 25,
|
|
68
|
-
// Power (right-associative: left > right)
|
|
69
119
|
POW_LEFT: 31,
|
|
70
120
|
POW_RIGHT: 30,
|
|
71
|
-
//
|
|
121
|
+
// Must stay below DICE_RIGHT so a postfix modifier binds to the whole dice
|
|
122
|
+
// expression: `4d6kh3` = `(4d6)kh3`.
|
|
72
123
|
MODIFIER: 35,
|
|
73
|
-
// Dice operator (highest math precedence)
|
|
74
124
|
DICE_LEFT: 40,
|
|
75
125
|
DICE_RIGHT: 41,
|
|
76
126
|
} as const;
|
|
77
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Maximum expression nesting depth. Far beyond any human-authored notation —
|
|
130
|
+
* exists so adversarial input like 20,000 nested parens throws a typed
|
|
131
|
+
* `ParseError` instead of an uncaught `RangeError` stack overflow (which
|
|
132
|
+
* would also break the `isRollParserError` contract). Bounding parse depth
|
|
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
|
|
152
|
+
*/
|
|
153
|
+
export const MAX_PARSE_DEPTH = 128;
|
|
154
|
+
|
|
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 = {
|
|
162
|
+
[TokenType.LPAREN]: `'('`,
|
|
163
|
+
[TokenType.RPAREN]: `')'`,
|
|
164
|
+
[TokenType.LBRACE]: `'{'`,
|
|
165
|
+
[TokenType.RBRACE]: `'}'`,
|
|
166
|
+
[TokenType.COMMA]: `','`,
|
|
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;
|
|
171
|
+
|
|
78
172
|
/**
|
|
79
173
|
* Arity table for math functions. `min` and `max` are inclusive.
|
|
80
174
|
* `POSITIVE_INFINITY` means unbounded (variadic).
|
|
@@ -84,10 +178,26 @@ const FUNCTION_ARITY: Record<string, { min: number; max: number }> = {
|
|
|
84
178
|
ceil: { min: 1, max: 1 },
|
|
85
179
|
round: { min: 1, max: 1 },
|
|
86
180
|
abs: { min: 1, max: 1 },
|
|
181
|
+
sqrt: { min: 1, max: 1 },
|
|
182
|
+
pow: { min: 2, max: 2 },
|
|
87
183
|
max: { min: 2, max: Number.POSITIVE_INFINITY },
|
|
88
184
|
min: { min: 2, max: Number.POSITIVE_INFINITY },
|
|
89
185
|
};
|
|
90
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
|
+
|
|
91
201
|
/**
|
|
92
202
|
* Pratt parser for dice notation.
|
|
93
203
|
*
|
|
@@ -97,6 +207,7 @@ const FUNCTION_ARITY: Record<string, { min: number; max: number }> = {
|
|
|
97
207
|
export class Parser {
|
|
98
208
|
private readonly tokens: Token[];
|
|
99
209
|
private pos = 0;
|
|
210
|
+
private depth = 0;
|
|
100
211
|
|
|
101
212
|
constructor(tokens: Token[]) {
|
|
102
213
|
this.tokens = tokens;
|
|
@@ -112,7 +223,6 @@ export class Parser {
|
|
|
112
223
|
|
|
113
224
|
const ast = this.parseExpression(0);
|
|
114
225
|
|
|
115
|
-
// Ensure we consumed all tokens
|
|
116
226
|
if (this.peek().type !== TokenType.EOF) {
|
|
117
227
|
const token = this.peek();
|
|
118
228
|
throw new ParseError(
|
|
@@ -130,19 +240,47 @@ export class Parser {
|
|
|
130
240
|
* Parse an expression with minimum binding power.
|
|
131
241
|
*/
|
|
132
242
|
private parseExpression(minBp: number): ASTNode {
|
|
133
|
-
|
|
243
|
+
const entryDepth = this.depth;
|
|
244
|
+
this.depth += 1;
|
|
245
|
+
this.guardDepth();
|
|
134
246
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const leftBp = this.getLeftBp(token);
|
|
247
|
+
try {
|
|
248
|
+
let left = this.parseNud();
|
|
138
249
|
|
|
139
|
-
|
|
250
|
+
while (this.hasTokens()) {
|
|
251
|
+
const token = this.peek();
|
|
252
|
+
const leftBp = this.getLeftBp(token);
|
|
140
253
|
|
|
141
|
-
|
|
142
|
-
|
|
254
|
+
if (leftBp < minBp) break;
|
|
255
|
+
|
|
256
|
+
this.advance();
|
|
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();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return left;
|
|
266
|
+
} finally {
|
|
267
|
+
this.depth = entryDepth;
|
|
143
268
|
}
|
|
269
|
+
}
|
|
144
270
|
|
|
145
|
-
|
|
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
|
+
);
|
|
283
|
+
}
|
|
146
284
|
}
|
|
147
285
|
|
|
148
286
|
/**
|
|
@@ -157,23 +295,29 @@ export class Parser {
|
|
|
157
295
|
return this.parseLiteral(token);
|
|
158
296
|
|
|
159
297
|
case TokenType.MINUS:
|
|
160
|
-
return this.parseUnaryMinus();
|
|
298
|
+
return this.parseUnaryMinus(token);
|
|
161
299
|
|
|
162
300
|
case TokenType.DICE:
|
|
163
|
-
return this.parsePrefixDice();
|
|
301
|
+
return this.parsePrefixDice(token);
|
|
164
302
|
|
|
165
303
|
case TokenType.DICE_PERCENT:
|
|
166
|
-
return this.parsePrefixDicePercent();
|
|
304
|
+
return this.parsePrefixDicePercent(token);
|
|
167
305
|
|
|
168
306
|
case TokenType.DICE_FATE:
|
|
169
|
-
return this.parsePrefixFateDice();
|
|
307
|
+
return this.parsePrefixFateDice(token);
|
|
170
308
|
|
|
171
309
|
case TokenType.LPAREN:
|
|
172
|
-
return this.parseGrouped();
|
|
310
|
+
return this.parseGrouped(token);
|
|
311
|
+
|
|
312
|
+
case TokenType.LBRACE:
|
|
313
|
+
return this.parseGroup(token);
|
|
173
314
|
|
|
174
315
|
case TokenType.FUNCTION:
|
|
175
316
|
return this.parseFunctionCall(token);
|
|
176
317
|
|
|
318
|
+
case TokenType.AT:
|
|
319
|
+
return this.parseVariable(token);
|
|
320
|
+
|
|
177
321
|
case TokenType.EOF:
|
|
178
322
|
throw new ParseError('Unexpected end of input', 'UNEXPECTED_END', token.position);
|
|
179
323
|
|
|
@@ -194,13 +338,13 @@ export class Parser {
|
|
|
194
338
|
private parseLed(left: ASTNode, token: Token): ASTNode {
|
|
195
339
|
switch (token.type) {
|
|
196
340
|
case TokenType.DICE:
|
|
197
|
-
return this.parseInfixDice(left);
|
|
341
|
+
return this.parseInfixDice(left, token);
|
|
198
342
|
|
|
199
343
|
case TokenType.DICE_PERCENT:
|
|
200
|
-
return this.parseInfixDicePercent(left);
|
|
344
|
+
return this.parseInfixDicePercent(left, token);
|
|
201
345
|
|
|
202
346
|
case TokenType.DICE_FATE:
|
|
203
|
-
return this.parseInfixFateDice(left);
|
|
347
|
+
return this.parseInfixFateDice(left, token);
|
|
204
348
|
|
|
205
349
|
case TokenType.PLUS:
|
|
206
350
|
case TokenType.MINUS:
|
|
@@ -214,7 +358,7 @@ export class Parser {
|
|
|
214
358
|
case TokenType.KEEP_LOW:
|
|
215
359
|
case TokenType.DROP_HIGH:
|
|
216
360
|
case TokenType.DROP_LOW:
|
|
217
|
-
return this.
|
|
361
|
+
return this.parseKeepDrop(left, token);
|
|
218
362
|
|
|
219
363
|
case TokenType.EXPLODE:
|
|
220
364
|
case TokenType.EXPLODE_COMPOUND:
|
|
@@ -225,6 +369,19 @@ export class Parser {
|
|
|
225
369
|
case TokenType.REROLL_ONCE:
|
|
226
370
|
return this.parseReroll(left, token);
|
|
227
371
|
|
|
372
|
+
case TokenType.SORT_ASC:
|
|
373
|
+
case TokenType.SORT_DESC:
|
|
374
|
+
return this.parseSort(left, token);
|
|
375
|
+
|
|
376
|
+
case TokenType.CRIT_SUCCESS:
|
|
377
|
+
case TokenType.CRIT_FAIL:
|
|
378
|
+
return this.parseCritThreshold(left, token);
|
|
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
|
+
|
|
228
385
|
case TokenType.GREATER:
|
|
229
386
|
case TokenType.GREATER_EQUAL:
|
|
230
387
|
case TokenType.LESS:
|
|
@@ -245,108 +402,176 @@ export class Parser {
|
|
|
245
402
|
}
|
|
246
403
|
}
|
|
247
404
|
|
|
405
|
+
//
|
|
248
406
|
// * Node parsers
|
|
407
|
+
//
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Zero-width span for synthetic nodes (implicit counts, `d%` sides) that
|
|
411
|
+
* have no source text of their own — anchored at the governing token.
|
|
412
|
+
*/
|
|
413
|
+
private static syntheticLiteral(value: number, token: Token): LiteralNode {
|
|
414
|
+
return { type: 'Literal', value, start: token.position, end: token.position };
|
|
415
|
+
}
|
|
249
416
|
|
|
250
417
|
private parseLiteral(token: Token): LiteralNode {
|
|
251
418
|
return {
|
|
252
419
|
type: 'Literal',
|
|
253
420
|
value: Number.parseFloat(token.value),
|
|
421
|
+
start: token.position,
|
|
422
|
+
end: token.end,
|
|
254
423
|
};
|
|
255
424
|
}
|
|
256
425
|
|
|
257
|
-
private parseUnaryMinus(): UnaryOpNode {
|
|
426
|
+
private parseUnaryMinus(token: Token): UnaryOpNode {
|
|
258
427
|
const operand = this.parseExpression(BP.UNARY);
|
|
428
|
+
this.rejectSuccessCountTarget(operand, token);
|
|
259
429
|
return {
|
|
260
430
|
type: 'UnaryOp',
|
|
261
431
|
operator: '-',
|
|
262
432
|
operand,
|
|
433
|
+
start: token.position,
|
|
434
|
+
end: operand.end ?? token.end,
|
|
263
435
|
};
|
|
264
436
|
}
|
|
265
437
|
|
|
266
|
-
private parsePrefixDice(): DiceNode {
|
|
267
|
-
// d20 → Dice(1, 20)
|
|
438
|
+
private parsePrefixDice(token: Token): DiceNode {
|
|
268
439
|
const sides = this.parseExpression(BP.DICE_RIGHT);
|
|
440
|
+
this.rejectSuccessCountTarget(sides, token);
|
|
441
|
+
this.rejectVersusMetaOperand(sides, token);
|
|
269
442
|
return {
|
|
270
443
|
type: 'Dice',
|
|
271
|
-
count:
|
|
444
|
+
count: Parser.syntheticLiteral(1, token),
|
|
272
445
|
sides,
|
|
446
|
+
start: token.position,
|
|
447
|
+
end: sides.end ?? token.end,
|
|
273
448
|
};
|
|
274
449
|
}
|
|
275
450
|
|
|
276
|
-
private parseInfixDice(left: ASTNode): DiceNode {
|
|
277
|
-
|
|
451
|
+
private parseInfixDice(left: ASTNode, token: Token): DiceNode {
|
|
452
|
+
this.rejectSuccessCountTarget(left, token);
|
|
453
|
+
this.rejectVersusMetaOperand(left, token);
|
|
454
|
+
this.rejectBareDiceChain(left, token);
|
|
278
455
|
const sides = this.parseExpression(BP.DICE_RIGHT);
|
|
456
|
+
this.rejectSuccessCountTarget(sides, token);
|
|
457
|
+
this.rejectVersusMetaOperand(sides, token);
|
|
279
458
|
return {
|
|
280
459
|
type: 'Dice',
|
|
281
460
|
count: left,
|
|
282
461
|
sides,
|
|
462
|
+
start: left.start ?? token.position,
|
|
463
|
+
end: sides.end ?? token.end,
|
|
283
464
|
};
|
|
284
465
|
}
|
|
285
466
|
|
|
286
|
-
private parsePrefixDicePercent(): DiceNode {
|
|
287
|
-
// d% → Dice(1, 100)
|
|
467
|
+
private parsePrefixDicePercent(token: Token): DiceNode {
|
|
288
468
|
return {
|
|
289
469
|
type: 'Dice',
|
|
290
|
-
count:
|
|
291
|
-
sides:
|
|
470
|
+
count: Parser.syntheticLiteral(1, token),
|
|
471
|
+
sides: Parser.syntheticLiteral(100, token),
|
|
472
|
+
start: token.position,
|
|
473
|
+
end: token.end,
|
|
292
474
|
};
|
|
293
475
|
}
|
|
294
476
|
|
|
295
|
-
private parseInfixDicePercent(left: ASTNode): DiceNode {
|
|
296
|
-
|
|
477
|
+
private parseInfixDicePercent(left: ASTNode, token: Token): DiceNode {
|
|
478
|
+
this.rejectSuccessCountTarget(left, token);
|
|
479
|
+
this.rejectVersusMetaOperand(left, token);
|
|
480
|
+
this.rejectBareDiceChain(left, token);
|
|
297
481
|
return {
|
|
298
482
|
type: 'Dice',
|
|
299
483
|
count: left,
|
|
300
|
-
sides:
|
|
484
|
+
sides: Parser.syntheticLiteral(100, token),
|
|
485
|
+
start: left.start ?? token.position,
|
|
486
|
+
end: token.end,
|
|
301
487
|
};
|
|
302
488
|
}
|
|
303
489
|
|
|
304
|
-
private parsePrefixFateDice(): FateDiceNode {
|
|
305
|
-
// dF → FateDice(1)
|
|
490
|
+
private parsePrefixFateDice(token: Token): FateDiceNode {
|
|
306
491
|
return {
|
|
307
492
|
type: 'FateDice',
|
|
308
|
-
count:
|
|
493
|
+
count: Parser.syntheticLiteral(1, token),
|
|
494
|
+
start: token.position,
|
|
495
|
+
end: token.end,
|
|
309
496
|
};
|
|
310
497
|
}
|
|
311
498
|
|
|
312
|
-
private parseInfixFateDice(left: ASTNode): FateDiceNode {
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
499
|
+
private parseInfixFateDice(left: ASTNode, token: Token): FateDiceNode {
|
|
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.
|
|
502
|
+
this.rejectSuccessCountTarget(left, token);
|
|
503
|
+
this.rejectVersusMetaOperand(left, token);
|
|
504
|
+
this.rejectBareDiceChain(left, token);
|
|
316
505
|
return {
|
|
317
506
|
type: 'FateDice',
|
|
318
507
|
count: left,
|
|
508
|
+
start: left.start ?? token.position,
|
|
509
|
+
end: token.end,
|
|
319
510
|
};
|
|
320
511
|
}
|
|
321
512
|
|
|
322
|
-
private parseGrouped():
|
|
323
|
-
const
|
|
324
|
-
this.expect(TokenType.RPAREN);
|
|
325
|
-
return
|
|
513
|
+
private parseGrouped(token: Token): GroupedNode {
|
|
514
|
+
const expression = this.parseExpression(0);
|
|
515
|
+
const close = this.expect(TokenType.RPAREN);
|
|
516
|
+
return { type: 'Grouped', expression, start: token.position, end: close.end };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private parseGroup(startToken: Token): GroupNode {
|
|
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.
|
|
523
|
+
if (this.peek().type === TokenType.RBRACE) {
|
|
524
|
+
throw new ParseError('Empty group', 'UNEXPECTED_TOKEN', startToken.position, startToken);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const expressions: ASTNode[] = [this.parseExpression(0)];
|
|
528
|
+
while (this.peek().type === TokenType.COMMA) {
|
|
529
|
+
this.advance();
|
|
530
|
+
expressions.push(this.parseExpression(0));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (this.peek().type !== TokenType.RBRACE) {
|
|
534
|
+
const unterminated = this.peek();
|
|
535
|
+
throw new ParseError(
|
|
536
|
+
`Unterminated group: expected '}' or ','`,
|
|
537
|
+
'EXPECTED_TOKEN',
|
|
538
|
+
unterminated.position,
|
|
539
|
+
unterminated,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
const close = this.advance();
|
|
543
|
+
|
|
544
|
+
return { type: 'Group', expressions, start: startToken.position, end: close.end };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private parseVariable(token: Token): VariableNode {
|
|
548
|
+
return { type: 'Variable', name: token.value, start: token.position, end: token.end };
|
|
326
549
|
}
|
|
327
550
|
|
|
328
551
|
private parseFunctionCall(token: Token): FunctionCallNode {
|
|
329
|
-
// `FUNCTION`
|
|
330
|
-
//
|
|
331
|
-
// 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.
|
|
332
554
|
this.expect(TokenType.LPAREN);
|
|
333
555
|
|
|
334
556
|
const args: ASTNode[] = [];
|
|
335
557
|
if (this.peek().type !== TokenType.RPAREN) {
|
|
336
|
-
|
|
558
|
+
const first = this.parseExpression(0);
|
|
559
|
+
this.rejectSuccessCountTarget(first, token);
|
|
560
|
+
args.push(first);
|
|
337
561
|
while (this.peek().type === TokenType.COMMA) {
|
|
338
562
|
this.advance();
|
|
339
|
-
|
|
563
|
+
const next = this.parseExpression(0);
|
|
564
|
+
this.rejectSuccessCountTarget(next, token);
|
|
565
|
+
args.push(next);
|
|
340
566
|
}
|
|
341
567
|
}
|
|
342
568
|
|
|
343
|
-
this.expect(TokenType.RPAREN);
|
|
569
|
+
const close = this.expect(TokenType.RPAREN);
|
|
344
570
|
|
|
345
571
|
const arity = FUNCTION_ARITY[token.value];
|
|
346
|
-
if (arity
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
// 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.
|
|
350
575
|
throw new ParseError(
|
|
351
576
|
`Unknown function '${token.value}'`,
|
|
352
577
|
'UNKNOWN_FUNCTION',
|
|
@@ -356,12 +581,7 @@ export class Parser {
|
|
|
356
581
|
}
|
|
357
582
|
|
|
358
583
|
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}`;
|
|
584
|
+
const expected = formatArity(arity);
|
|
365
585
|
throw new ParseError(
|
|
366
586
|
`Function '${token.value}' expects ${expected} argument${arity.min === 1 && arity.max === 1 ? '' : 's'}, got ${args.length}`,
|
|
367
587
|
'INVALID_FUNCTION_ARITY',
|
|
@@ -370,24 +590,69 @@ export class Parser {
|
|
|
370
590
|
);
|
|
371
591
|
}
|
|
372
592
|
|
|
373
|
-
return {
|
|
593
|
+
return {
|
|
594
|
+
type: 'FunctionCall',
|
|
595
|
+
name: token.value,
|
|
596
|
+
args,
|
|
597
|
+
start: token.position,
|
|
598
|
+
end: close.end,
|
|
599
|
+
};
|
|
374
600
|
}
|
|
375
601
|
|
|
376
602
|
private parseBinaryOp(left: ASTNode, token: Token): BinaryOpNode {
|
|
603
|
+
this.rejectSuccessCountTarget(left, token);
|
|
604
|
+
|
|
377
605
|
const operator = this.getOperatorSymbol(token);
|
|
378
606
|
const rightBp = this.getRightBp(token);
|
|
379
607
|
const right = this.parseExpression(rightBp);
|
|
380
608
|
|
|
609
|
+
this.rejectSuccessCountTarget(right, token);
|
|
610
|
+
|
|
381
611
|
return {
|
|
382
612
|
type: 'BinaryOp',
|
|
383
613
|
operator,
|
|
384
614
|
left,
|
|
385
615
|
right,
|
|
616
|
+
start: left.start ?? token.position,
|
|
617
|
+
end: right.end ?? token.end,
|
|
386
618
|
};
|
|
387
619
|
}
|
|
388
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Rejects a dice token whose count operand is itself a bare (unparenthesized)
|
|
623
|
+
* dice expression. `4d6d1` would otherwise silently parse as `(4d6)d1` —
|
|
624
|
+
* roll 4d6, then use the result as a count of d1 dice — which is almost
|
|
625
|
+
* never intended: every major dice dialect reads `4d6d1` as "drop lowest 1".
|
|
626
|
+
* Both meanings stay reachable through explicit forms: `4d6dl1` to drop,
|
|
627
|
+
* `(4d6)d1` for nested dice.
|
|
628
|
+
*/
|
|
629
|
+
private rejectBareDiceChain(left: ASTNode, token: Token): void {
|
|
630
|
+
switch (left.type) {
|
|
631
|
+
case 'Dice':
|
|
632
|
+
case 'FateDice':
|
|
633
|
+
case 'Explode':
|
|
634
|
+
case 'Reroll':
|
|
635
|
+
case 'DieBound':
|
|
636
|
+
case 'KeepDrop':
|
|
637
|
+
case 'Sort':
|
|
638
|
+
case 'CritThreshold':
|
|
639
|
+
throw new ParseError(
|
|
640
|
+
`Ambiguous dice chain: use 'dl'/'dh' to drop dice (4d6dl1) or parentheses for nested dice ((4d6)d1)`,
|
|
641
|
+
'AMBIGUOUS_DICE_CHAIN',
|
|
642
|
+
token.position,
|
|
643
|
+
token,
|
|
644
|
+
);
|
|
645
|
+
default:
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
389
650
|
private rejectSuccessCountTarget(target: ASTNode, token: Token): void {
|
|
390
|
-
|
|
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);
|
|
655
|
+
if (isSuccessCount(node)) {
|
|
391
656
|
throw new ParseError(
|
|
392
657
|
`Cannot apply modifier after success counting`,
|
|
393
658
|
'INVALID_SUCCESS_COUNT_TARGET',
|
|
@@ -397,10 +662,112 @@ export class Parser {
|
|
|
397
662
|
}
|
|
398
663
|
}
|
|
399
664
|
|
|
400
|
-
|
|
665
|
+
/**
|
|
666
|
+
* Rejects `GroupNode` (or a wrapper-cloaked group) as the target of `token`.
|
|
667
|
+
* Explode, reroll, and crit-threshold wrap bare dice pools only — a group
|
|
668
|
+
* is a container of sub-expressions, so these modifiers have no defined
|
|
669
|
+
* semantics. Walks `Grouped`/`KeepDrop`/`Sort`/`CritThreshold` so wrappers
|
|
670
|
+
* cannot smuggle a group past the check (`{1d6}kh1cs>5`, `({1d6})!`,
|
|
671
|
+
* `{1d6}scs>5` all reject the same as `{1d6}!`/`{1d6}cs>5`).
|
|
672
|
+
*
|
|
673
|
+
* `singleSubRollPasses` opts the caller into the single-sub-roll passthrough:
|
|
674
|
+
* a `Group` with one expression is the user's explicit flat-pool escape
|
|
675
|
+
* hatch and is equivalent to its unwrapped form. Currently only
|
|
676
|
+
* `parseCritThreshold` opts in — explode/reroll keep the strict reject so
|
|
677
|
+
* existing notation contracts don't shift.
|
|
678
|
+
*/
|
|
679
|
+
private rejectGroupTarget(
|
|
680
|
+
target: ASTNode,
|
|
681
|
+
token: Token,
|
|
682
|
+
action: string,
|
|
683
|
+
code: RollParserErrorCode,
|
|
684
|
+
singleSubRollPasses = false,
|
|
685
|
+
): void {
|
|
686
|
+
const node = unwrapAllTransparent(target);
|
|
687
|
+
if (node.type !== 'Group') return;
|
|
688
|
+
if (singleSubRollPasses && node.expressions.length === 1) {
|
|
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.
|
|
694
|
+
const inner = node.expressions[0];
|
|
695
|
+
if (inner != null && containsMultiSubGroup(inner)) {
|
|
696
|
+
throw new ParseError(`Cannot ${action} a group`, code, token.position, token);
|
|
697
|
+
}
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
throw new ParseError(`Cannot ${action} a group`, code, token.position, token);
|
|
701
|
+
}
|
|
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
|
+
|
|
731
|
+
private rejectVersusTarget(target: ASTNode, token: Token): void {
|
|
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.
|
|
745
|
+
if (node.type === 'Group' && node.expressions.length === 1) {
|
|
746
|
+
const inner = node.expressions[0];
|
|
747
|
+
if (inner != null && containsVersus(inner)) Parser.throwVersusMetaExpression(token);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private parseKeepDrop(target: ASTNode, token: Token): KeepDropNode {
|
|
401
752
|
this.rejectSuccessCountTarget(target, token);
|
|
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`.
|
|
757
|
+
this.rejectVersusTarget(target, token);
|
|
758
|
+
|
|
759
|
+
// Keep/drop needs a pool to select from; `(1d6+5)kh1` or `4d6+2kh3` would
|
|
760
|
+
// silently drop the user's arithmetic.
|
|
761
|
+
if (!containsDicePool(target)) {
|
|
762
|
+
throw new ParseError(
|
|
763
|
+
`Keep/drop modifiers require a dice pool target`,
|
|
764
|
+
'INVALID_KEEP_DROP_TARGET',
|
|
765
|
+
token.position,
|
|
766
|
+
token,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
402
769
|
|
|
403
|
-
const
|
|
770
|
+
const kind =
|
|
404
771
|
token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
|
|
405
772
|
|
|
406
773
|
const selector =
|
|
@@ -408,27 +775,58 @@ export class Parser {
|
|
|
408
775
|
? 'highest'
|
|
409
776
|
: 'lowest';
|
|
410
777
|
|
|
411
|
-
// Default to 1 when no explicit count follows the modifier (e.g., 4d6kh → 4d6kh1)
|
|
412
778
|
const nextToken = this.peek().type;
|
|
413
|
-
const
|
|
414
|
-
nextToken === TokenType.NUMBER ||
|
|
415
|
-
|
|
416
|
-
|
|
779
|
+
const hasExplicitCount =
|
|
780
|
+
nextToken === TokenType.NUMBER ||
|
|
781
|
+
nextToken === TokenType.LPAREN ||
|
|
782
|
+
nextToken === TokenType.AT;
|
|
783
|
+
const count: ASTNode = hasExplicitCount
|
|
784
|
+
? this.parseExpression(BP.DICE_LEFT)
|
|
785
|
+
: Parser.syntheticLiteral(1, token);
|
|
786
|
+
this.rejectSuccessCountTarget(count, token);
|
|
787
|
+
this.rejectVersusMetaOperand(count, token);
|
|
417
788
|
|
|
418
789
|
return {
|
|
419
|
-
type: '
|
|
420
|
-
|
|
790
|
+
type: 'KeepDrop',
|
|
791
|
+
kind,
|
|
421
792
|
selector,
|
|
422
793
|
count,
|
|
423
794
|
target,
|
|
795
|
+
start: target.start ?? token.position,
|
|
796
|
+
end: hasExplicitCount ? (count.end ?? token.end) : token.end,
|
|
424
797
|
};
|
|
425
798
|
}
|
|
426
799
|
|
|
427
800
|
private parseExplode(target: ASTNode, token: Token): ExplodeNode {
|
|
428
801
|
this.rejectSuccessCountTarget(target, token);
|
|
429
802
|
|
|
430
|
-
//
|
|
431
|
-
//
|
|
803
|
+
// Groups have no explode semantics. Must precede `containsDicePool`, which
|
|
804
|
+
// recurses into `Group` and would let `{4d6}!` slip through.
|
|
805
|
+
this.rejectGroupTarget(target, token, 'explode', 'INVALID_EXPLODE_TARGET');
|
|
806
|
+
|
|
807
|
+
// Arithmetic wrappers like `(1d6+5)!` or `floor(1d6/2)!` would silently drop
|
|
808
|
+
// the user's math.
|
|
809
|
+
if (!containsDicePool(target)) {
|
|
810
|
+
throw new ParseError(
|
|
811
|
+
`Explode modifier requires a dice pool target`,
|
|
812
|
+
'INVALID_EXPLODE_TARGET',
|
|
813
|
+
token.position,
|
|
814
|
+
token,
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
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.
|
|
820
|
+
if (containsFatePool(target)) {
|
|
821
|
+
throw new ParseError(
|
|
822
|
+
`Fate dice cannot explode`,
|
|
823
|
+
'INVALID_EXPLODE_TARGET',
|
|
824
|
+
token.position,
|
|
825
|
+
token,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// A second explode token atop an `Explode` (`1d6!!!`) has no semantics.
|
|
432
830
|
if (target.type === 'Explode') {
|
|
433
831
|
throw new ParseError(
|
|
434
832
|
`Cannot chain explode modifiers`,
|
|
@@ -438,24 +836,45 @@ export class Parser {
|
|
|
438
836
|
);
|
|
439
837
|
}
|
|
440
838
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
: token.type === TokenType.EXPLODE_COMPOUND
|
|
445
|
-
? 'compound'
|
|
446
|
-
: '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';
|
|
447
842
|
|
|
448
|
-
const
|
|
449
|
-
if (this.isComparePointAhead()) {
|
|
450
|
-
|
|
843
|
+
const start = target.start ?? token.position;
|
|
844
|
+
if (!this.isComparePointAhead()) {
|
|
845
|
+
return { type: 'Explode', variant, target, start, end: token.end };
|
|
451
846
|
}
|
|
452
|
-
|
|
847
|
+
|
|
848
|
+
const threshold = this.parseComparePoint();
|
|
849
|
+
|
|
850
|
+
return {
|
|
851
|
+
type: 'Explode',
|
|
852
|
+
variant,
|
|
853
|
+
target,
|
|
854
|
+
threshold,
|
|
855
|
+
start,
|
|
856
|
+
end: threshold.value.end ?? token.end,
|
|
857
|
+
};
|
|
453
858
|
}
|
|
454
859
|
|
|
455
860
|
private parseReroll(target: ASTNode, token: Token): RerollNode {
|
|
456
861
|
this.rejectSuccessCountTarget(target, token);
|
|
457
862
|
|
|
458
|
-
//
|
|
863
|
+
// Groups have no reroll semantics. Must precede `containsDicePool`, which
|
|
864
|
+
// recurses into `Group`.
|
|
865
|
+
this.rejectGroupTarget(target, token, 'reroll', 'INVALID_REROLL_TARGET');
|
|
866
|
+
|
|
867
|
+
// Arithmetic wrappers like `(1d6+5)r<3` or `floor(1d6/2)ro<3` would silently
|
|
868
|
+
// drop the user's math.
|
|
869
|
+
if (!containsDicePool(target)) {
|
|
870
|
+
throw new ParseError(
|
|
871
|
+
`Reroll modifier requires a dice pool target`,
|
|
872
|
+
'INVALID_REROLL_TARGET',
|
|
873
|
+
token.position,
|
|
874
|
+
token,
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
|
|
459
878
|
if (!this.isComparePointAhead()) {
|
|
460
879
|
throw new ParseError(
|
|
461
880
|
`Expected comparison operator after '${token.value}'`,
|
|
@@ -468,18 +887,209 @@ export class Parser {
|
|
|
468
887
|
const once = token.type === TokenType.REROLL_ONCE;
|
|
469
888
|
const condition = this.parseComparePoint();
|
|
470
889
|
|
|
471
|
-
return {
|
|
890
|
+
return {
|
|
891
|
+
type: 'Reroll',
|
|
892
|
+
once,
|
|
893
|
+
condition,
|
|
894
|
+
target,
|
|
895
|
+
start: target.start ?? token.position,
|
|
896
|
+
end: condition.value.end ?? token.end,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
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
|
+
|
|
960
|
+
private parseSort(target: ASTNode, token: Token): SortNode {
|
|
961
|
+
this.rejectSuccessCountTarget(target, token);
|
|
962
|
+
this.rejectVersusTarget(target, token);
|
|
963
|
+
|
|
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.
|
|
967
|
+
if (!deepContainsDicePool(target)) {
|
|
968
|
+
throw new ParseError(
|
|
969
|
+
`Sort modifier requires a dice pool target`,
|
|
970
|
+
'INVALID_SORT_TARGET',
|
|
971
|
+
token.position,
|
|
972
|
+
token,
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
|
|
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);
|
|
982
|
+
if (base.type === 'Group' && base.expressions.length >= 2) {
|
|
983
|
+
throw new ParseError(
|
|
984
|
+
`Sort modifier does not yet support multi-sub-roll groups`,
|
|
985
|
+
'INVALID_SORT_TARGET',
|
|
986
|
+
token.position,
|
|
987
|
+
token,
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const order: SortNode['order'] = token.type === TokenType.SORT_ASC ? 'ascending' : 'descending';
|
|
992
|
+
|
|
993
|
+
// Chained sorts (`4d6ss`, `4d6sasd`) are deliberately allowed: repeats are
|
|
994
|
+
// idempotent and a later `sd` just overrides the order.
|
|
995
|
+
return {
|
|
996
|
+
type: 'Sort',
|
|
997
|
+
order,
|
|
998
|
+
target,
|
|
999
|
+
start: target.start ?? token.position,
|
|
1000
|
+
end: token.end,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
private parseCritThreshold(target: ASTNode, token: Token): CritThresholdNode {
|
|
1005
|
+
this.rejectSuccessCountTarget(target, token);
|
|
1006
|
+
this.rejectVersusTarget(target, token);
|
|
1007
|
+
|
|
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`.
|
|
1013
|
+
this.rejectGroupTarget(
|
|
1014
|
+
target,
|
|
1015
|
+
token,
|
|
1016
|
+
'apply crit threshold to',
|
|
1017
|
+
'INVALID_CRIT_THRESHOLD_TARGET',
|
|
1018
|
+
true,
|
|
1019
|
+
);
|
|
1020
|
+
|
|
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.
|
|
1024
|
+
if (!containsDicePool(target)) {
|
|
1025
|
+
throw new ParseError(
|
|
1026
|
+
`Crit threshold modifier requires a dice pool target`,
|
|
1027
|
+
'INVALID_CRIT_THRESHOLD_TARGET',
|
|
1028
|
+
token.position,
|
|
1029
|
+
token,
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
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.
|
|
1036
|
+
if (!this.isComparePointAhead() && containsFatePool(target)) {
|
|
1037
|
+
throw new ParseError(
|
|
1038
|
+
`Bare cs/cf cannot apply to Fate dice`,
|
|
1039
|
+
'INVALID_CRIT_THRESHOLD_TARGET',
|
|
1040
|
+
token.position,
|
|
1041
|
+
token,
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const threshold: CritThreshold = this.isComparePointAhead()
|
|
1046
|
+
? this.parseComparePoint()
|
|
1047
|
+
: 'default';
|
|
1048
|
+
const end = threshold === 'default' ? token.end : (threshold.value.end ?? token.end);
|
|
1049
|
+
|
|
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.
|
|
1053
|
+
let chainTarget: ASTNode = target;
|
|
1054
|
+
while (chainTarget.type === 'Grouped') {
|
|
1055
|
+
chainTarget = chainTarget.expression;
|
|
1056
|
+
}
|
|
1057
|
+
if (isCritThreshold(chainTarget)) {
|
|
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
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
return {
|
|
1074
|
+
type: 'CritThreshold',
|
|
1075
|
+
successThresholds: token.type === TokenType.CRIT_SUCCESS ? [threshold] : [],
|
|
1076
|
+
failThresholds: token.type === TokenType.CRIT_FAIL ? [threshold] : [],
|
|
1077
|
+
target,
|
|
1078
|
+
start: target.start ?? token.position,
|
|
1079
|
+
end,
|
|
1080
|
+
};
|
|
472
1081
|
}
|
|
473
1082
|
|
|
474
1083
|
private parseSuccessCount(target: ASTNode, token: Token): SuccessCountNode {
|
|
475
1084
|
// Success counting is terminal: chaining (`>=5>=3`) has no semantics.
|
|
476
1085
|
this.rejectSuccessCountTarget(target, token);
|
|
477
1086
|
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
|
|
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.
|
|
1090
|
+
if (!containsDicePool(target)) {
|
|
481
1091
|
throw new ParseError(
|
|
482
|
-
`Success counting requires a dice
|
|
1092
|
+
`Success counting requires a dice pool target`,
|
|
483
1093
|
'INVALID_SUCCESS_COUNT_TARGET',
|
|
484
1094
|
token.position,
|
|
485
1095
|
token,
|
|
@@ -487,41 +1097,77 @@ export class Parser {
|
|
|
487
1097
|
}
|
|
488
1098
|
|
|
489
1099
|
const operator = this.getCompareOp(token);
|
|
1100
|
+
// Threshold binds at `BP.DICE_LEFT` — see `parseComparePoint` TSDoc.
|
|
490
1101
|
const value = this.parseExpression(BP.DICE_LEFT);
|
|
491
|
-
|
|
1102
|
+
this.rejectSuccessCountTarget(value, token);
|
|
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 {
|
|
492
1115
|
type: 'SuccessCount',
|
|
493
1116
|
target,
|
|
494
1117
|
threshold: { operator, value },
|
|
1118
|
+
failThreshold,
|
|
1119
|
+
start,
|
|
1120
|
+
end: failThreshold.value.end ?? end,
|
|
495
1121
|
};
|
|
1122
|
+
}
|
|
496
1123
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
node.failThreshold = { operator: '=', value: failValue };
|
|
501
|
-
}
|
|
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();
|
|
502
1127
|
|
|
503
|
-
|
|
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);
|
|
1132
|
+
|
|
1133
|
+
return { operator: '=', value: failValue };
|
|
504
1134
|
}
|
|
505
1135
|
|
|
506
1136
|
private parseVersus(left: ASTNode, token: Token): VersusNode {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
//
|
|
510
|
-
|
|
1137
|
+
this.rejectSuccessCountTarget(left, token);
|
|
1138
|
+
|
|
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`.
|
|
1143
|
+
let leftChain: ASTNode = left;
|
|
1144
|
+
while (leftChain.type === 'Grouped') {
|
|
1145
|
+
leftChain = leftChain.expression;
|
|
1146
|
+
}
|
|
1147
|
+
if (leftChain.type === 'Versus') {
|
|
511
1148
|
throw new ParseError('Cannot chain versus operators', 'NESTED_VERSUS', token.position, token);
|
|
512
1149
|
}
|
|
513
1150
|
|
|
514
1151
|
const dc = this.parseExpression(BP.VS_RIGHT);
|
|
1152
|
+
this.rejectSuccessCountTarget(dc, token);
|
|
515
1153
|
|
|
516
|
-
return {
|
|
1154
|
+
return {
|
|
1155
|
+
type: 'Versus',
|
|
1156
|
+
roll: left,
|
|
1157
|
+
dc,
|
|
1158
|
+
start: left.start ?? token.position,
|
|
1159
|
+
end: dc.end ?? token.end,
|
|
1160
|
+
};
|
|
517
1161
|
}
|
|
518
1162
|
|
|
1163
|
+
//
|
|
519
1164
|
// * Compare point utilities
|
|
1165
|
+
//
|
|
520
1166
|
|
|
521
1167
|
/**
|
|
522
1168
|
* Checks whether the next token is a comparison operator.
|
|
523
1169
|
*/
|
|
524
|
-
isComparePointAhead(): boolean {
|
|
1170
|
+
private isComparePointAhead(): boolean {
|
|
525
1171
|
const type = this.peek().type;
|
|
526
1172
|
return (
|
|
527
1173
|
type === TokenType.GREATER ||
|
|
@@ -536,16 +1182,25 @@ export class Parser {
|
|
|
536
1182
|
* Parses a comparison operator followed by a value expression.
|
|
537
1183
|
* Called by modifier parsers (explode, reroll, success counting).
|
|
538
1184
|
*
|
|
1185
|
+
* The threshold value is parsed at `BP.DICE_LEFT`, which binds tighter than
|
|
1186
|
+
* arithmetic. This keeps the comparison bound to the dice pool on the left
|
|
1187
|
+
* rather than letting arithmetic to the right be consumed into the
|
|
1188
|
+
* threshold. As a consequence, `1d6>=5+2` parses as `(1d6>=5)+2` with
|
|
1189
|
+
* threshold `5` — not `7`. Computed thresholds require parens:
|
|
1190
|
+
* `1d6>=(5+2)`. Same binding applies to `parseSuccessCount` below.
|
|
1191
|
+
*
|
|
539
1192
|
* @returns A ComparePoint with the operator and value AST node
|
|
540
1193
|
* @throws {ParseError} If the next token is not a comparison operator
|
|
541
1194
|
*/
|
|
542
|
-
parseComparePoint(): ComparePoint {
|
|
1195
|
+
private parseComparePoint(): ComparePoint {
|
|
543
1196
|
const token = this.peek();
|
|
544
1197
|
const operator = this.getCompareOp(token);
|
|
545
1198
|
|
|
546
1199
|
this.advance();
|
|
547
1200
|
|
|
548
1201
|
const value = this.parseExpression(BP.DICE_LEFT);
|
|
1202
|
+
this.rejectSuccessCountTarget(value, token);
|
|
1203
|
+
this.rejectVersusMetaOperand(value, token);
|
|
549
1204
|
|
|
550
1205
|
return { operator, value };
|
|
551
1206
|
}
|
|
@@ -572,7 +1227,9 @@ export class Parser {
|
|
|
572
1227
|
}
|
|
573
1228
|
}
|
|
574
1229
|
|
|
1230
|
+
//
|
|
575
1231
|
// * Helpers
|
|
1232
|
+
//
|
|
576
1233
|
|
|
577
1234
|
private getOperatorSymbol(token: Token): '+' | '-' | '*' | '/' | '%' | '**' {
|
|
578
1235
|
switch (token.type) {
|
|
@@ -624,20 +1281,31 @@ export class Parser {
|
|
|
624
1281
|
case TokenType.EXPLODE_PENETRATING:
|
|
625
1282
|
case TokenType.REROLL:
|
|
626
1283
|
case TokenType.REROLL_ONCE:
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
1284
|
+
case TokenType.SORT_ASC:
|
|
1285
|
+
case TokenType.SORT_DESC:
|
|
1286
|
+
case TokenType.CRIT_SUCCESS:
|
|
1287
|
+
case TokenType.CRIT_FAIL:
|
|
1288
|
+
return BP.MODIFIER;
|
|
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.
|
|
630
1292
|
case TokenType.GREATER:
|
|
631
1293
|
case TokenType.GREATER_EQUAL:
|
|
632
1294
|
case TokenType.LESS:
|
|
633
1295
|
case TokenType.LESS_EQUAL:
|
|
634
1296
|
case TokenType.EQUAL:
|
|
635
|
-
return BP.
|
|
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;
|
|
636
1302
|
case TokenType.RPAREN:
|
|
637
1303
|
case TokenType.EOF:
|
|
638
|
-
// Punctuation and keywords that terminate expressions
|
|
639
1304
|
case TokenType.COMMA:
|
|
640
|
-
|
|
1305
|
+
// `}` is consumed inside `parseGroup`, and a stray `{` after a complete
|
|
1306
|
+
// expression is an error — both must terminate the outer Pratt loop.
|
|
1307
|
+
case TokenType.LBRACE:
|
|
1308
|
+
case TokenType.RBRACE:
|
|
641
1309
|
return -1;
|
|
642
1310
|
default:
|
|
643
1311
|
return 0;
|
|
@@ -665,7 +1333,9 @@ export class Parser {
|
|
|
665
1333
|
}
|
|
666
1334
|
|
|
667
1335
|
private peek(): Token {
|
|
668
|
-
return
|
|
1336
|
+
return (
|
|
1337
|
+
this.tokens[this.pos] ?? { type: TokenType.EOF, value: '', position: this.pos, end: this.pos }
|
|
1338
|
+
);
|
|
669
1339
|
}
|
|
670
1340
|
|
|
671
1341
|
private advance(): Token {
|
|
@@ -674,12 +1344,13 @@ export class Parser {
|
|
|
674
1344
|
return token;
|
|
675
1345
|
}
|
|
676
1346
|
|
|
677
|
-
private expect(type:
|
|
1347
|
+
private expect(type: ExpectableToken): Token {
|
|
678
1348
|
const token = this.peek();
|
|
679
1349
|
if (token.type !== type) {
|
|
680
|
-
const expected =
|
|
1350
|
+
const expected = TOKEN_DISPLAY[type];
|
|
1351
|
+
const got = token.type === TokenType.EOF ? 'end of input' : `'${token.value}'`;
|
|
681
1352
|
throw new ParseError(
|
|
682
|
-
`Expected ${expected} but got
|
|
1353
|
+
`Expected ${expected} but got ${got}`,
|
|
683
1354
|
'EXPECTED_TOKEN',
|
|
684
1355
|
token.position,
|
|
685
1356
|
token,
|
|
@@ -694,20 +1365,49 @@ export class Parser {
|
|
|
694
1365
|
}
|
|
695
1366
|
|
|
696
1367
|
/**
|
|
697
|
-
*
|
|
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.
|
|
698
1374
|
*
|
|
699
1375
|
* @param notation - The dice notation to parse
|
|
700
|
-
* @returns The root AST node
|
|
1376
|
+
* @returns The root AST node, with `start`/`end` spans on every node
|
|
701
1377
|
* @throws {LexerError} If the input contains invalid characters
|
|
702
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
|
|
703
1381
|
*
|
|
704
1382
|
* @example
|
|
705
1383
|
* ```typescript
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
* //
|
|
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
|
+
* // }
|
|
710
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);
|
|
1408
|
+
* ```
|
|
1409
|
+
*
|
|
1410
|
+
* @category Core
|
|
711
1411
|
*/
|
|
712
1412
|
export function parse(notation: string): ASTNode {
|
|
713
1413
|
const tokens = lex(notation);
|