roll-parser 2.3.2 → 3.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/README.md +175 -170
- package/dist/cli/args.d.ts +35 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/format.d.ts +20 -0
- package/dist/cli/format.d.ts.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli.js +2608 -0
- package/dist/cli.js.map +28 -0
- package/dist/errors.d.ts +42 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/evaluator/evaluator.d.ts +121 -0
- package/dist/evaluator/evaluator.d.ts.map +1 -0
- package/dist/evaluator/index.d.ts +8 -0
- package/dist/evaluator/index.d.ts.map +1 -0
- package/dist/evaluator/modifiers/compare.d.ts +15 -0
- package/dist/evaluator/modifiers/compare.d.ts.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts +28 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
- package/dist/evaluator/modifiers/explode.d.ts +48 -0
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts +44 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -0
- package/dist/evaluator/modifiers/reroll.d.ts +33 -0
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -0
- package/dist/evaluator/modifiers/sort.d.ts +23 -0
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
- package/dist/evaluator/modifiers/success-count.d.ts +28 -0
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2560 -0
- package/dist/index.js.map +26 -0
- package/dist/lexer/lexer.d.ts +90 -0
- package/dist/lexer/lexer.d.ts.map +1 -0
- package/dist/lexer/tokens.d.ts +107 -0
- package/dist/lexer/tokens.d.ts.map +1 -0
- package/dist/parser/ast.d.ts +368 -0
- package/dist/parser/ast.d.ts.map +1 -0
- package/dist/parser/parser.d.ts +145 -0
- package/dist/parser/parser.d.ts.map +1 -0
- package/dist/rng/index.d.ts +8 -0
- package/dist/rng/index.d.ts.map +1 -0
- package/dist/rng/mock.d.ts +36 -0
- package/dist/rng/mock.d.ts.map +1 -0
- package/dist/rng/seeded.d.ts +40 -0
- package/dist/rng/seeded.d.ts.map +1 -0
- package/dist/rng/types.d.ts +24 -0
- package/dist/rng/types.d.ts.map +1 -0
- package/dist/roll.d.ts +51 -0
- package/dist/roll.d.ts.map +1 -0
- package/dist/testing.d.ts +11 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +41 -0
- package/dist/testing.js.map +11 -0
- package/dist/types.d.ts +246 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +66 -43
- package/src/cli/args.ts +73 -0
- package/src/cli/format.ts +49 -0
- package/src/cli/index.ts +92 -0
- package/src/errors.ts +90 -0
- package/src/evaluator/evaluator.ts +1552 -0
- package/src/evaluator/index.ts +14 -0
- package/src/evaluator/modifiers/compare.ts +29 -0
- package/src/evaluator/modifiers/crit-threshold.ts +59 -0
- package/src/evaluator/modifiers/explode.ts +219 -0
- package/src/evaluator/modifiers/keep-drop.ts +151 -0
- package/src/evaluator/modifiers/reroll.ts +165 -0
- package/src/evaluator/modifiers/sort.ts +30 -0
- package/src/evaluator/modifiers/success-count.ts +59 -0
- package/src/index.ts +90 -0
- package/src/lexer/lexer.ts +374 -0
- package/src/lexer/tokens.ts +187 -0
- package/src/parser/ast.ts +628 -0
- package/src/parser/parser.ts +1239 -0
- package/src/rng/index.ts +8 -0
- package/src/rng/mock.ts +65 -0
- package/src/rng/seeded.ts +157 -0
- package/src/rng/types.ts +25 -0
- package/src/roll.ts +74 -0
- package/src/testing.ts +16 -0
- package/src/types.ts +237 -0
- package/bin/help.txt +0 -19
- package/bin/roll-parser.js +0 -23
- package/dist/roll-parser.js +0 -697
- package/dist/roll-parser.min.js +0 -1
- package/index.js +0 -24
- package/src/complex/parse.js +0 -37
- package/src/complex/parseAndRoll.js +0 -21
- package/src/complex/parseAndRollClassic.js +0 -20
- package/src/complex/parseAndRollSimple.js +0 -19
- package/src/complex/parseAndRollWod.js +0 -20
- package/src/complex/parseClassicRoll.js +0 -22
- package/src/complex/parseSimpleRoll.js +0 -21
- package/src/complex/parseWodRoll.js +0 -21
- package/src/converter.js +0 -45
- package/src/grammar.js +0 -14
- package/src/mapper.js +0 -50
- package/src/normalizer.js +0 -51
- package/src/object/Result.js +0 -23
- package/src/object/Roll.js +0 -26
- package/src/object/Type.js +0 -5
- package/src/object/WodRoll.js +0 -29
- package/src/parser.js +0 -43
- package/src/random.js +0 -22
- package/src/roller.js +0 -106
- package/src/stringifier.js +0 -33
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Success counting modifier.
|
|
3
|
+
*
|
|
4
|
+
* Transforms a dice pool into a signed success count: each die matching the
|
|
5
|
+
* success `threshold` contributes +1, each die matching the optional
|
|
6
|
+
* `failThreshold` contributes -1. When a single die matches both thresholds,
|
|
7
|
+
* success wins (checked first).
|
|
8
|
+
*
|
|
9
|
+
* Dropped dice (from a preceding keep/drop or intermediate reroll) are
|
|
10
|
+
* excluded from counting and are never tagged.
|
|
11
|
+
*
|
|
12
|
+
* Mutates the input pool in place to add `'success'` / `'failure'` modifier
|
|
13
|
+
* flags — mirrors the mutation pattern of explode and reroll modifiers.
|
|
14
|
+
*
|
|
15
|
+
* @module evaluator/modifiers/success-count
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { CompareOp, DieResult } from '../../types.js';
|
|
19
|
+
import { matchesCondition } from './compare.js';
|
|
20
|
+
|
|
21
|
+
export type ResolvedThreshold = {
|
|
22
|
+
operator: CompareOp;
|
|
23
|
+
value: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type SuccessCountResult = {
|
|
27
|
+
total: number;
|
|
28
|
+
successes: number;
|
|
29
|
+
failures: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function countSuccesses(
|
|
33
|
+
dice: DieResult[],
|
|
34
|
+
threshold: ResolvedThreshold,
|
|
35
|
+
failThreshold?: ResolvedThreshold,
|
|
36
|
+
): SuccessCountResult {
|
|
37
|
+
let successes = 0;
|
|
38
|
+
let failures = 0;
|
|
39
|
+
|
|
40
|
+
for (const die of dice) {
|
|
41
|
+
if (die.modifiers.includes('dropped')) continue;
|
|
42
|
+
|
|
43
|
+
if (matchesCondition(die.result, threshold.operator, threshold.value)) {
|
|
44
|
+
die.modifiers.push('success');
|
|
45
|
+
successes += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (
|
|
50
|
+
failThreshold != null &&
|
|
51
|
+
matchesCondition(die.result, failThreshold.operator, failThreshold.value)
|
|
52
|
+
) {
|
|
53
|
+
die.modifiers.push('failure');
|
|
54
|
+
failures += 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { total: successes - failures, successes, failures };
|
|
59
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roll Parser - Dice notation parser for tabletop RPGs.
|
|
3
|
+
*
|
|
4
|
+
* @module roll-parser
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// * Error hierarchy
|
|
8
|
+
export { RollParserError, isRollParserError } from './errors.js';
|
|
9
|
+
export type { RollParserErrorCode } from './errors.js';
|
|
10
|
+
|
|
11
|
+
// * Lexer exports
|
|
12
|
+
export { LexerError } from './lexer/lexer.js';
|
|
13
|
+
|
|
14
|
+
// * Parser exports
|
|
15
|
+
export { parse, ParseError } from './parser/parser.js';
|
|
16
|
+
export type {
|
|
17
|
+
ASTNode,
|
|
18
|
+
BinaryOpNode,
|
|
19
|
+
DiceNode,
|
|
20
|
+
NodeSpan,
|
|
21
|
+
ExplodeNode,
|
|
22
|
+
FateDiceNode,
|
|
23
|
+
FunctionCallNode,
|
|
24
|
+
GroupedNode,
|
|
25
|
+
LiteralNode,
|
|
26
|
+
ModifierNode,
|
|
27
|
+
RerollNode,
|
|
28
|
+
SuccessCountNode,
|
|
29
|
+
SortNode,
|
|
30
|
+
CritThresholdNode,
|
|
31
|
+
GroupNode,
|
|
32
|
+
UnaryOpNode,
|
|
33
|
+
VariableNode,
|
|
34
|
+
VersusNode,
|
|
35
|
+
} from './parser/ast.js';
|
|
36
|
+
export {
|
|
37
|
+
isBinaryOp,
|
|
38
|
+
isCritThreshold,
|
|
39
|
+
isDice,
|
|
40
|
+
isExplode,
|
|
41
|
+
isFateDice,
|
|
42
|
+
isFunctionCall,
|
|
43
|
+
isGroup,
|
|
44
|
+
isGrouped,
|
|
45
|
+
isLiteral,
|
|
46
|
+
isModifier,
|
|
47
|
+
isReroll,
|
|
48
|
+
isSort,
|
|
49
|
+
isSuccessCount,
|
|
50
|
+
isUnaryOp,
|
|
51
|
+
isVariable,
|
|
52
|
+
isVersus,
|
|
53
|
+
} from './parser/ast.js';
|
|
54
|
+
|
|
55
|
+
// * RNG exports
|
|
56
|
+
export type { RNG } from './rng/types.js';
|
|
57
|
+
export { SeededRNG } from './rng/seeded.js';
|
|
58
|
+
|
|
59
|
+
// * Evaluator exports
|
|
60
|
+
export {
|
|
61
|
+
DEFAULT_MAX_DICE,
|
|
62
|
+
DEFAULT_MAX_EXPLODE_ITERATIONS,
|
|
63
|
+
DEFAULT_MAX_REROLL_ITERATIONS,
|
|
64
|
+
evaluate,
|
|
65
|
+
EvaluatorError,
|
|
66
|
+
} from './evaluator/evaluator.js';
|
|
67
|
+
export type {
|
|
68
|
+
CompareOp,
|
|
69
|
+
ComparePoint,
|
|
70
|
+
DieModifier,
|
|
71
|
+
DieResult,
|
|
72
|
+
EvaluateOptions,
|
|
73
|
+
ModifierSpec,
|
|
74
|
+
ResolvedComparePoint,
|
|
75
|
+
ResolvedCritThreshold,
|
|
76
|
+
RollPart,
|
|
77
|
+
RollPartType,
|
|
78
|
+
RollResult,
|
|
79
|
+
} from './types.js';
|
|
80
|
+
export { DegreeOfSuccess } from './types.js';
|
|
81
|
+
|
|
82
|
+
// * Public API
|
|
83
|
+
export { roll } from './roll.js';
|
|
84
|
+
export type { RollOptions } from './roll.js';
|
|
85
|
+
|
|
86
|
+
// ? Named import — Bun's bundler tree-shakes the JSON module down to the
|
|
87
|
+
// single used property, so the full manifest is not embedded in dist.
|
|
88
|
+
import { version } from '../package.json';
|
|
89
|
+
|
|
90
|
+
export const VERSION: string = version;
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dice notation lexer with character-by-character scanning.
|
|
3
|
+
*
|
|
4
|
+
* @module lexer/lexer
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RollParserErrorCode } from '../errors.js';
|
|
8
|
+
import { RollParserError } from '../errors.js';
|
|
9
|
+
import { type Token, TokenType } from './tokens.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Error thrown when the lexer encounters an invalid character.
|
|
13
|
+
*/
|
|
14
|
+
export class LexerError extends RollParserError {
|
|
15
|
+
readonly position: number;
|
|
16
|
+
readonly character: string;
|
|
17
|
+
|
|
18
|
+
constructor(message: string, code: RollParserErrorCode, position: number, character: string) {
|
|
19
|
+
super(`${message} at position ${position}: '${character}'`, code);
|
|
20
|
+
this.name = 'LexerError';
|
|
21
|
+
this.position = position;
|
|
22
|
+
this.character = character;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Known identifier keywords mapped to their token types. */
|
|
27
|
+
const IDENTIFIER_KEYWORDS: Record<string, TokenType> = {
|
|
28
|
+
kh: TokenType.KEEP_HIGH,
|
|
29
|
+
kl: TokenType.KEEP_LOW,
|
|
30
|
+
k: TokenType.KEEP_HIGH,
|
|
31
|
+
dh: TokenType.DROP_HIGH,
|
|
32
|
+
dl: TokenType.DROP_LOW,
|
|
33
|
+
d: TokenType.DICE,
|
|
34
|
+
r: TokenType.REROLL,
|
|
35
|
+
ro: TokenType.REROLL_ONCE,
|
|
36
|
+
f: TokenType.FAIL,
|
|
37
|
+
vs: TokenType.VS,
|
|
38
|
+
floor: TokenType.FUNCTION,
|
|
39
|
+
ceil: TokenType.FUNCTION,
|
|
40
|
+
round: TokenType.FUNCTION,
|
|
41
|
+
abs: TokenType.FUNCTION,
|
|
42
|
+
max: TokenType.FUNCTION,
|
|
43
|
+
min: TokenType.FUNCTION,
|
|
44
|
+
s: TokenType.SORT_ASC,
|
|
45
|
+
sa: TokenType.SORT_ASC,
|
|
46
|
+
sd: TokenType.SORT_DESC,
|
|
47
|
+
cs: TokenType.CRIT_SUCCESS,
|
|
48
|
+
cf: TokenType.CRIT_FAIL,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Builds a hint for identifiers that start with a known keyword. Maximal
|
|
53
|
+
* munch merges adjacent modifiers when the first has no count — `4d6khs`
|
|
54
|
+
* lexes as one identifier `khs` instead of `kh` + `s`. Point the user at the
|
|
55
|
+
* explicit-count (or whitespace) split.
|
|
56
|
+
*/
|
|
57
|
+
function buildIdentifierHint(identifier: string): string {
|
|
58
|
+
for (let length = identifier.length - 1; length >= 1; length--) {
|
|
59
|
+
const prefix = identifier.slice(0, length);
|
|
60
|
+
if (IDENTIFIER_KEYWORDS[prefix] == null) continue;
|
|
61
|
+
const rest = identifier.slice(length);
|
|
62
|
+
return ` (did you mean '${prefix}' followed by '${rest}'? separate modifiers with a count or space, e.g. '${prefix}1${rest}')`;
|
|
63
|
+
}
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Lexer for dice notation.
|
|
69
|
+
*
|
|
70
|
+
* Produces a stream of tokens from an input string using character-by-character
|
|
71
|
+
* scanning with maximal munch for multi-character tokens.
|
|
72
|
+
*/
|
|
73
|
+
export class Lexer {
|
|
74
|
+
private pos = 0;
|
|
75
|
+
private readonly input: string;
|
|
76
|
+
|
|
77
|
+
constructor(input: string) {
|
|
78
|
+
this.input = input;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Tokenize the entire input and return all tokens.
|
|
83
|
+
*/
|
|
84
|
+
tokenize(): Token[] {
|
|
85
|
+
const tokens: Token[] = [];
|
|
86
|
+
|
|
87
|
+
while (true) {
|
|
88
|
+
const token = this.nextToken();
|
|
89
|
+
tokens.push(token);
|
|
90
|
+
if (token.type === TokenType.EOF) break;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return tokens;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get the next token from the input.
|
|
98
|
+
*/
|
|
99
|
+
nextToken(): Token {
|
|
100
|
+
this.skipWhitespace();
|
|
101
|
+
|
|
102
|
+
if (this.isAtEnd()) {
|
|
103
|
+
return this.createToken(TokenType.EOF, '');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const startPos = this.pos;
|
|
107
|
+
const char = this.peek();
|
|
108
|
+
|
|
109
|
+
// * Numbers
|
|
110
|
+
if (this.isDigit(char)) {
|
|
111
|
+
return this.scanNumber();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// * Identifiers (d, kh, kl, dh, dl, r, ro, f, vs, floor, ceil, ...)
|
|
115
|
+
if (this.isAlpha(char)) {
|
|
116
|
+
return this.scanIdentifier();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// * Variable reference
|
|
120
|
+
if (char === '@') {
|
|
121
|
+
return this.scanAt();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// * Operators and punctuation
|
|
125
|
+
this.advance();
|
|
126
|
+
|
|
127
|
+
switch (char) {
|
|
128
|
+
case '+':
|
|
129
|
+
return this.createTokenAt(TokenType.PLUS, char, startPos);
|
|
130
|
+
case '-':
|
|
131
|
+
return this.createTokenAt(TokenType.MINUS, char, startPos);
|
|
132
|
+
case '*':
|
|
133
|
+
if (this.match('*')) {
|
|
134
|
+
return this.createTokenAt(TokenType.POWER, '**', startPos);
|
|
135
|
+
}
|
|
136
|
+
return this.createTokenAt(TokenType.MULTIPLY, char, startPos);
|
|
137
|
+
case '/':
|
|
138
|
+
return this.createTokenAt(TokenType.DIVIDE, char, startPos);
|
|
139
|
+
case '%':
|
|
140
|
+
return this.createTokenAt(TokenType.MODULO, char, startPos);
|
|
141
|
+
case '^':
|
|
142
|
+
return this.createTokenAt(TokenType.POWER, char, startPos);
|
|
143
|
+
case '(':
|
|
144
|
+
return this.createTokenAt(TokenType.LPAREN, char, startPos);
|
|
145
|
+
case ')':
|
|
146
|
+
return this.createTokenAt(TokenType.RPAREN, char, startPos);
|
|
147
|
+
case ',':
|
|
148
|
+
return this.createTokenAt(TokenType.COMMA, char, startPos);
|
|
149
|
+
case '{':
|
|
150
|
+
return this.createTokenAt(TokenType.LBRACE, char, startPos);
|
|
151
|
+
case '}':
|
|
152
|
+
return this.createTokenAt(TokenType.RBRACE, char, startPos);
|
|
153
|
+
case '>':
|
|
154
|
+
if (this.match('=')) {
|
|
155
|
+
return this.createTokenAt(TokenType.GREATER_EQUAL, '>=', startPos);
|
|
156
|
+
}
|
|
157
|
+
return this.createTokenAt(TokenType.GREATER, char, startPos);
|
|
158
|
+
case '<':
|
|
159
|
+
if (this.match('=')) {
|
|
160
|
+
return this.createTokenAt(TokenType.LESS_EQUAL, '<=', startPos);
|
|
161
|
+
}
|
|
162
|
+
return this.createTokenAt(TokenType.LESS, char, startPos);
|
|
163
|
+
case '=':
|
|
164
|
+
return this.createTokenAt(TokenType.EQUAL, char, startPos);
|
|
165
|
+
case '!':
|
|
166
|
+
if (this.match('!')) {
|
|
167
|
+
return this.createTokenAt(TokenType.EXPLODE_COMPOUND, '!!', startPos);
|
|
168
|
+
}
|
|
169
|
+
if (!this.isAtEnd() && this.peek().toLowerCase() === 'p') {
|
|
170
|
+
this.advance();
|
|
171
|
+
return this.createTokenAt(TokenType.EXPLODE_PENETRATING, '!p', startPos);
|
|
172
|
+
}
|
|
173
|
+
return this.createTokenAt(TokenType.EXPLODE, char, startPos);
|
|
174
|
+
default: {
|
|
175
|
+
// Surrogate pairs (emoji, astral symbols) span two code units —
|
|
176
|
+
// report the full code point instead of a lone surrogate ('�').
|
|
177
|
+
const codePoint = this.input.codePointAt(startPos);
|
|
178
|
+
const display = codePoint == null ? char : String.fromCodePoint(codePoint);
|
|
179
|
+
throw new LexerError('Unexpected character', 'UNEXPECTED_CHARACTER', startPos, display);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// * Private helpers
|
|
185
|
+
|
|
186
|
+
private skipWhitespace(): void {
|
|
187
|
+
while (!this.isAtEnd() && this.isWhitespace(this.peek())) {
|
|
188
|
+
this.advance();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private scanNumber(): Token {
|
|
193
|
+
const startPos = this.pos;
|
|
194
|
+
let value = '';
|
|
195
|
+
|
|
196
|
+
// Integer part
|
|
197
|
+
while (!this.isAtEnd() && this.isDigit(this.peek())) {
|
|
198
|
+
value += this.advance();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Decimal part
|
|
202
|
+
if (!this.isAtEnd() && this.peek() === '.' && this.isDigit(this.peekNext())) {
|
|
203
|
+
value += this.advance(); // consume '.'
|
|
204
|
+
while (!this.isAtEnd() && this.isDigit(this.peek())) {
|
|
205
|
+
value += this.advance();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return this.createTokenAt(TokenType.NUMBER, value, startPos);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Scans an identifier using full-accumulation: collects all consecutive
|
|
214
|
+
* alpha characters, then classifies the result against known keywords.
|
|
215
|
+
*
|
|
216
|
+
* Special cases run before/after the accumulation loop:
|
|
217
|
+
* - `dF` / `Df` / `dF` / `DF` produces DICE_FATE. Must be handled BEFORE
|
|
218
|
+
* the loop because `F` is alpha and would otherwise be greedily merged
|
|
219
|
+
* into identifiers like `dfkh` (from `4dFkh2`) or `dfdf` (from `dFdF`).
|
|
220
|
+
* Reserves the `d[fF]` prefix namespace for Fate dice.
|
|
221
|
+
* - Bare `d` followed by `%` produces DICE_PERCENT. `%` is not alpha so the
|
|
222
|
+
* accumulation loop stops naturally and the post-loop check handles it.
|
|
223
|
+
*/
|
|
224
|
+
private scanIdentifier(): Token {
|
|
225
|
+
const startPos = this.pos;
|
|
226
|
+
|
|
227
|
+
const first = this.peek();
|
|
228
|
+
const second = this.peekNext();
|
|
229
|
+
if ((first === 'd' || first === 'D') && (second === 'f' || second === 'F')) {
|
|
230
|
+
this.advance();
|
|
231
|
+
this.advance();
|
|
232
|
+
return this.createTokenAt(TokenType.DICE_FATE, 'df', startPos);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let value = '';
|
|
236
|
+
|
|
237
|
+
while (!this.isAtEnd() && this.isAlpha(this.peek())) {
|
|
238
|
+
value += this.advance();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const lower = value.toLowerCase();
|
|
242
|
+
|
|
243
|
+
if (lower === 'd' && !this.isAtEnd() && this.peek() === '%') {
|
|
244
|
+
this.advance();
|
|
245
|
+
return this.createTokenAt(TokenType.DICE_PERCENT, 'd%', startPos);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const tokenType = IDENTIFIER_KEYWORDS[lower];
|
|
249
|
+
if (tokenType != null) {
|
|
250
|
+
return this.createTokenAt(tokenType, lower, startPos);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
throw new LexerError(
|
|
254
|
+
`Unexpected identifier${buildIdentifierHint(lower)}`,
|
|
255
|
+
'UNEXPECTED_IDENTIFIER',
|
|
256
|
+
startPos,
|
|
257
|
+
lower,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Scans a variable reference introduced by `@`.
|
|
263
|
+
*
|
|
264
|
+
* Two forms:
|
|
265
|
+
* - Bare: `@name` where `name` matches `[A-Za-z_][A-Za-z0-9_]*` (case preserved).
|
|
266
|
+
* - Braced: `@{name}` where `name` is any run of printable characters except
|
|
267
|
+
* `}` and newline (permits spaces, hyphens, digits).
|
|
268
|
+
*
|
|
269
|
+
* Case is preserved — distinct from `scanIdentifier`, which lowercases the
|
|
270
|
+
* captured value. The emitted token's `value` is the variable name without
|
|
271
|
+
* the leading `@` or the surrounding braces.
|
|
272
|
+
*/
|
|
273
|
+
private scanAt(): Token {
|
|
274
|
+
const startPos = this.pos;
|
|
275
|
+
this.advance(); // consume '@'
|
|
276
|
+
|
|
277
|
+
let name: string;
|
|
278
|
+
if (!this.isAtEnd() && this.peek() === '{') {
|
|
279
|
+
this.advance(); // consume '{'
|
|
280
|
+
const nameStart = this.pos;
|
|
281
|
+
while (!this.isAtEnd() && this.peek() !== '}' && this.peek() !== '\n') {
|
|
282
|
+
this.advance();
|
|
283
|
+
}
|
|
284
|
+
if (this.isAtEnd() || this.peek() !== '}') {
|
|
285
|
+
throw new LexerError('Unterminated @{...} variable', 'UNEXPECTED_CHARACTER', startPos, '@');
|
|
286
|
+
}
|
|
287
|
+
name = this.input.slice(nameStart, this.pos);
|
|
288
|
+
this.advance(); // consume '}'
|
|
289
|
+
} else {
|
|
290
|
+
const nameStart = this.pos;
|
|
291
|
+
if (this.isAtEnd() || !this.isIdentifierStart(this.peek())) {
|
|
292
|
+
throw new LexerError('Empty @ variable name', 'UNEXPECTED_CHARACTER', startPos, '@');
|
|
293
|
+
}
|
|
294
|
+
this.advance();
|
|
295
|
+
while (!this.isAtEnd() && this.isIdentifierPart(this.peek())) {
|
|
296
|
+
this.advance();
|
|
297
|
+
}
|
|
298
|
+
name = this.input.slice(nameStart, this.pos);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return this.createTokenAt(TokenType.AT, name, startPos);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private peek(): string {
|
|
305
|
+
return this.input[this.pos] ?? '';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private peekNext(): string {
|
|
309
|
+
return this.input[this.pos + 1] ?? '';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private advance(): string {
|
|
313
|
+
return this.input[this.pos++] ?? '';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private match(expected: string): boolean {
|
|
317
|
+
if (this.isAtEnd()) return false;
|
|
318
|
+
if (this.input[this.pos] !== expected) return false;
|
|
319
|
+
this.pos++;
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private isAtEnd(): boolean {
|
|
324
|
+
return this.pos >= this.input.length;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private isDigit(char: string): boolean {
|
|
328
|
+
return char >= '0' && char <= '9';
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private isAlpha(char: string): boolean {
|
|
332
|
+
const c = char.toLowerCase();
|
|
333
|
+
return c >= 'a' && c <= 'z';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private isIdentifierStart(char: string): boolean {
|
|
337
|
+
return this.isAlpha(char) || char === '_';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private isIdentifierPart(char: string): boolean {
|
|
341
|
+
return this.isAlpha(char) || this.isDigit(char) || char === '_';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private isWhitespace(char: string): boolean {
|
|
345
|
+
return char === ' ' || char === '\t' || char === '\n' || char === '\r';
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ? Both factories run after the token's characters were consumed, so
|
|
349
|
+
// `this.pos` is the exclusive end offset.
|
|
350
|
+
private createToken(type: TokenType, value: string): Token {
|
|
351
|
+
return { type, value, position: this.pos, end: this.pos };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private createTokenAt(type: TokenType, value: string, position: number): Token {
|
|
355
|
+
return { type, value, position, end: this.pos };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Tokenize a dice notation string.
|
|
361
|
+
*
|
|
362
|
+
* @param input - The dice notation to tokenize
|
|
363
|
+
* @returns Array of tokens including EOF
|
|
364
|
+
* @throws {LexerError} If an invalid character is encountered
|
|
365
|
+
*
|
|
366
|
+
* @example
|
|
367
|
+
* ```typescript
|
|
368
|
+
* const tokens = lex('2d20+5');
|
|
369
|
+
* // [NUMBER(2), DICE, NUMBER(20), PLUS, NUMBER(5), EOF]
|
|
370
|
+
* ```
|
|
371
|
+
*/
|
|
372
|
+
export function lex(input: string): Token[] {
|
|
373
|
+
return new Lexer(input).tokenize();
|
|
374
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token type definitions for the dice notation lexer.
|
|
3
|
+
*
|
|
4
|
+
* @module lexer/tokens
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Token types for dice notation.
|
|
9
|
+
*
|
|
10
|
+
* Grouped semantically. Numeric values are stable identifiers — the specific
|
|
11
|
+
* numbers don't matter, but they must be unique.
|
|
12
|
+
*/
|
|
13
|
+
export enum TokenType {
|
|
14
|
+
//
|
|
15
|
+
// * Literals
|
|
16
|
+
//
|
|
17
|
+
|
|
18
|
+
/** Numeric literal: integer or decimal */
|
|
19
|
+
NUMBER = 0,
|
|
20
|
+
|
|
21
|
+
//
|
|
22
|
+
// * Dice operators
|
|
23
|
+
//
|
|
24
|
+
|
|
25
|
+
/** Dice operator: 'd' or 'D' */
|
|
26
|
+
DICE = 1,
|
|
27
|
+
/** Percentile dice operator: 'd%' (alias for d100) */
|
|
28
|
+
DICE_PERCENT = 2,
|
|
29
|
+
/** Fate/Fudge dice operator: 'dF' */
|
|
30
|
+
DICE_FATE = 3,
|
|
31
|
+
|
|
32
|
+
//
|
|
33
|
+
// * Arithmetic operators
|
|
34
|
+
//
|
|
35
|
+
|
|
36
|
+
/** Addition operator: '+' */
|
|
37
|
+
PLUS = 4,
|
|
38
|
+
/** Subtraction operator: '-' */
|
|
39
|
+
MINUS = 5,
|
|
40
|
+
/** Multiplication operator: '*' */
|
|
41
|
+
MULTIPLY = 6,
|
|
42
|
+
/** Division operator: '/' */
|
|
43
|
+
DIVIDE = 7,
|
|
44
|
+
/** Modulo operator: '%' */
|
|
45
|
+
MODULO = 8,
|
|
46
|
+
/** Power operator: '**' or '^' */
|
|
47
|
+
POWER = 9,
|
|
48
|
+
|
|
49
|
+
//
|
|
50
|
+
// * Comparison operators
|
|
51
|
+
//
|
|
52
|
+
|
|
53
|
+
/** Greater than: '>' */
|
|
54
|
+
GREATER = 10,
|
|
55
|
+
/** Greater than or equal: '>=' */
|
|
56
|
+
GREATER_EQUAL = 11,
|
|
57
|
+
/** Less than: '<' */
|
|
58
|
+
LESS = 12,
|
|
59
|
+
/** Less than or equal: '<=' */
|
|
60
|
+
LESS_EQUAL = 13,
|
|
61
|
+
/** Equal: '=' */
|
|
62
|
+
EQUAL = 14,
|
|
63
|
+
|
|
64
|
+
//
|
|
65
|
+
// * Grouping and punctuation
|
|
66
|
+
//
|
|
67
|
+
|
|
68
|
+
/** Left parenthesis: '(' */
|
|
69
|
+
LPAREN = 15,
|
|
70
|
+
/** Right parenthesis: ')' */
|
|
71
|
+
RPAREN = 16,
|
|
72
|
+
/** Argument separator: ',' */
|
|
73
|
+
COMMA = 17,
|
|
74
|
+
|
|
75
|
+
//
|
|
76
|
+
// * Keep/drop modifiers
|
|
77
|
+
//
|
|
78
|
+
|
|
79
|
+
/** Keep highest modifier: 'kh' or 'k' */
|
|
80
|
+
KEEP_HIGH = 18,
|
|
81
|
+
/** Keep lowest modifier: 'kl' */
|
|
82
|
+
KEEP_LOW = 19,
|
|
83
|
+
/** Drop highest modifier: 'dh' */
|
|
84
|
+
DROP_HIGH = 20,
|
|
85
|
+
/** Drop lowest modifier: 'dl' */
|
|
86
|
+
DROP_LOW = 21,
|
|
87
|
+
|
|
88
|
+
//
|
|
89
|
+
// * Explode modifiers
|
|
90
|
+
//
|
|
91
|
+
|
|
92
|
+
/** Standard explode: '!' */
|
|
93
|
+
EXPLODE = 22,
|
|
94
|
+
/** Compounding explode: '!!' */
|
|
95
|
+
EXPLODE_COMPOUND = 23,
|
|
96
|
+
/** Penetrating explode: '!p' */
|
|
97
|
+
EXPLODE_PENETRATING = 24,
|
|
98
|
+
|
|
99
|
+
//
|
|
100
|
+
// * Reroll modifiers
|
|
101
|
+
//
|
|
102
|
+
|
|
103
|
+
/** Recursive reroll: 'r' */
|
|
104
|
+
REROLL = 25,
|
|
105
|
+
/** Reroll once: 'ro' */
|
|
106
|
+
REROLL_ONCE = 26,
|
|
107
|
+
|
|
108
|
+
//
|
|
109
|
+
// * Success counting
|
|
110
|
+
//
|
|
111
|
+
|
|
112
|
+
/** Fail marker: 'f' */
|
|
113
|
+
FAIL = 27,
|
|
114
|
+
|
|
115
|
+
//
|
|
116
|
+
// * Functions
|
|
117
|
+
//
|
|
118
|
+
|
|
119
|
+
/** Math function: 'floor', 'ceil', 'round', 'abs', 'max', 'min' */
|
|
120
|
+
FUNCTION = 28,
|
|
121
|
+
|
|
122
|
+
//
|
|
123
|
+
// * Keywords
|
|
124
|
+
//
|
|
125
|
+
|
|
126
|
+
/** Versus operator: 'vs' */
|
|
127
|
+
VS = 29,
|
|
128
|
+
|
|
129
|
+
//
|
|
130
|
+
// * Group boundaries
|
|
131
|
+
//
|
|
132
|
+
|
|
133
|
+
/** Left brace: '{' */
|
|
134
|
+
LBRACE = 30,
|
|
135
|
+
/** Right brace: '}' */
|
|
136
|
+
RBRACE = 31,
|
|
137
|
+
|
|
138
|
+
//
|
|
139
|
+
// * Variables
|
|
140
|
+
//
|
|
141
|
+
|
|
142
|
+
/** Variable reference prefix: '@' */
|
|
143
|
+
AT = 32,
|
|
144
|
+
|
|
145
|
+
//
|
|
146
|
+
// * Sort modifiers
|
|
147
|
+
//
|
|
148
|
+
|
|
149
|
+
/** Ascending sort: 's' or 'sa' */
|
|
150
|
+
SORT_ASC = 33,
|
|
151
|
+
/** Descending sort: 'sd' */
|
|
152
|
+
SORT_DESC = 34,
|
|
153
|
+
|
|
154
|
+
//
|
|
155
|
+
// * Crit thresholds
|
|
156
|
+
//
|
|
157
|
+
|
|
158
|
+
/** Critical success threshold: 'cs' */
|
|
159
|
+
CRIT_SUCCESS = 35,
|
|
160
|
+
/** Critical failure threshold: 'cf' */
|
|
161
|
+
CRIT_FAIL = 36,
|
|
162
|
+
|
|
163
|
+
//
|
|
164
|
+
// * End of input
|
|
165
|
+
//
|
|
166
|
+
|
|
167
|
+
/** End of input marker */
|
|
168
|
+
EOF = 37,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A token produced by the lexer.
|
|
173
|
+
*/
|
|
174
|
+
export type Token = {
|
|
175
|
+
/** The type of this token */
|
|
176
|
+
type: TokenType;
|
|
177
|
+
/** The raw string value from input (lowercased for identifiers) */
|
|
178
|
+
value: string;
|
|
179
|
+
/** Zero-based start offset in the input string (UTF-16 code units) */
|
|
180
|
+
position: number;
|
|
181
|
+
/**
|
|
182
|
+
* Zero-based end offset (exclusive). Not always `position + value.length` —
|
|
183
|
+
* braced variables (`@{name}` stores only `name`) and case-normalized
|
|
184
|
+
* identifiers consume more input than their `value` retains.
|
|
185
|
+
*/
|
|
186
|
+
end: number;
|
|
187
|
+
};
|