roll-parser 3.0.0-beta.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 +107 -9
- package/MIGRATION.md +147 -0
- package/README.md +960 -154
- package/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +81 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/format.d.ts +15 -3
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +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 -17
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +141 -0
- package/dist/errors.js.map +1 -0
- package/dist/evaluator/die.d.ts +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 +41 -75
- 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.js +15 -0
- package/dist/evaluator/modifiers/compare.js.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts +0 -1
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
- 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 +13 -5
- 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 +12 -4
- 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 +4 -0
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
- package/dist/evaluator/modifiers/sort.js +13 -0
- package/dist/evaluator/modifiers/sort.js.map +1 -0
- package/dist/evaluator/modifiers/success-count.d.ts +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 +33 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2560
- package/dist/index.js.map +1 -26
- package/dist/lexer/lexer.d.ts +50 -5
- package/dist/lexer/lexer.d.ts.map +1 -1
- package/dist/lexer/lexer.js +260 -0
- package/dist/lexer/lexer.js.map +1 -0
- package/dist/lexer/tokens.d.ts +31 -6
- package/dist/lexer/tokens.d.ts.map +1 -1
- package/dist/lexer/tokens.js +42 -0
- package/dist/lexer/tokens.js.map +1 -0
- package/dist/parser/ast.d.ts +314 -160
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/ast.js +52 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/guards.d.ts +106 -0
- package/dist/parser/guards.d.ts.map +1 -0
- package/dist/parser/guards.js +121 -0
- package/dist/parser/guards.js.map +1 -0
- package/dist/parser/parser.d.ts +118 -14
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +751 -0
- package/dist/parser/parser.js.map +1 -0
- package/dist/rng/mock.d.ts +73 -12
- package/dist/rng/mock.d.ts.map +1 -1
- package/dist/rng/mock.js +30 -0
- package/dist/rng/mock.js.map +1 -0
- package/dist/rng/seeded.d.ts +141 -9
- package/dist/rng/seeded.d.ts.map +1 -1
- package/dist/rng/seeded.js +138 -0
- package/dist/rng/seeded.js.map +1 -0
- package/dist/rng/types.d.ts +57 -0
- package/dist/rng/types.d.ts.map +1 -1
- package/dist/rng/types.js +2 -0
- package/dist/rng/types.js.map +1 -0
- package/dist/roll.d.ts +58 -28
- package/dist/roll.d.ts.map +1 -1
- package/dist/roll.js +8 -0
- package/dist/roll.js.map +1 -0
- package/dist/testing.d.ts +5 -4
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +2 -41
- package/dist/testing.js.map +1 -11
- package/dist/types.d.ts +306 -44
- 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 +83 -34
- package/src/cli/args.ts +66 -10
- package/src/cli/format.ts +22 -3
- package/src/cli/index.ts +27 -84
- package/src/cli/main.ts +129 -0
- package/src/errors.ts +480 -27
- package/src/evaluator/die.ts +50 -0
- package/src/evaluator/env.ts +73 -0
- package/src/evaluator/evaluator.ts +653 -421
- package/src/evaluator/modifiers/crit-threshold.ts +3 -6
- package/src/evaluator/modifiers/die-bound.ts +39 -0
- package/src/evaluator/modifiers/explode.ts +60 -58
- package/src/evaluator/modifiers/flags.ts +61 -0
- package/src/evaluator/modifiers/keep-drop.ts +124 -126
- package/src/evaluator/modifiers/reroll.ts +28 -49
- package/src/evaluator/modifiers/sort.ts +14 -1
- package/src/evaluator/modifiers/success-count.ts +5 -8
- package/src/index.ts +56 -35
- package/src/lexer/lexer.ts +107 -34
- package/src/lexer/tokens.ts +31 -6
- package/src/parser/ast.ts +323 -341
- package/src/parser/guards.ts +248 -0
- package/src/parser/parser.ts +419 -242
- package/src/rng/mock.ts +74 -13
- package/src/rng/seeded.ts +299 -64
- package/src/rng/types.ts +57 -0
- package/src/roll.ts +64 -47
- package/src/testing.ts +5 -9
- package/src/types.ts +310 -43
- package/src/version.ts +2 -0
- package/dist/cli.js +0 -2608
- package/dist/cli.js.map +0 -28
- package/dist/evaluator/index.d.ts +0 -8
- package/dist/evaluator/index.d.ts.map +0 -1
- package/dist/rng/index.d.ts +0 -8
- package/dist/rng/index.d.ts.map +0 -1
- package/src/evaluator/index.ts +0 -14
- package/src/rng/index.ts +0 -8
|
@@ -9,49 +9,37 @@
|
|
|
9
9
|
* @module evaluator/modifiers/reroll
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { EvaluatorError } from '../../errors.js';
|
|
12
13
|
import type { RNG } from '../../rng/types.js';
|
|
13
14
|
import type { CompareOp, DieResult } from '../../types.js';
|
|
14
|
-
import {
|
|
15
|
+
import { createDieResult, createFateDieResult } from '../die.js';
|
|
16
|
+
import { chargeDie, type EvalEnv } from '../env.js';
|
|
15
17
|
import { matchesCondition } from './compare.js';
|
|
18
|
+
import { isVersusDc, REROLL_SLOT_FLAGS, rewriteFlags } from './flags.js';
|
|
16
19
|
|
|
17
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Default value of `EvaluationOptions.maxRerollIterations`: how many times a
|
|
22
|
+
* single die may be re-rolled by `r` before `REROLL_LIMIT_EXCEEDED` is
|
|
23
|
+
* thrown.
|
|
24
|
+
*
|
|
25
|
+
* Per die, not per pool — it bounds notations like `1d1r<2`, whose reroll
|
|
26
|
+
* condition can never stop matching. `ro` re-rolls exactly once and is
|
|
27
|
+
* unaffected.
|
|
28
|
+
*
|
|
29
|
+
* @category Limits
|
|
30
|
+
*/
|
|
18
31
|
export const DEFAULT_MAX_REROLL_ITERATIONS = 1_000;
|
|
19
32
|
|
|
20
|
-
export { matchesCondition };
|
|
21
|
-
|
|
22
33
|
/**
|
|
23
34
|
* Rolls a replacement die for the given sides, charging it against the global
|
|
24
35
|
* dice limit. Fate dice (sides === 0) re-roll on the {-1, 0, +1} range.
|
|
25
36
|
*/
|
|
26
37
|
function rollReplacement(sides: number, rng: RNG, env: EvalEnv): DieResult {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
'DICE_LIMIT_EXCEEDED',
|
|
31
|
-
'Reroll',
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
env.totalDiceRolled += 1;
|
|
35
|
-
|
|
36
|
-
if (sides === 0) {
|
|
37
|
-
const result = rng.nextInt(-1, 1);
|
|
38
|
-
return {
|
|
39
|
-
sides: 0,
|
|
40
|
-
result,
|
|
41
|
-
modifiers: [],
|
|
42
|
-
critical: false,
|
|
43
|
-
fumble: false,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
38
|
+
chargeDie(env, 'Reroll');
|
|
39
|
+
|
|
40
|
+
if (sides === 0) return createFateDieResult(rng.nextInt(-1, 1), []);
|
|
46
41
|
|
|
47
|
-
|
|
48
|
-
return {
|
|
49
|
-
sides,
|
|
50
|
-
result,
|
|
51
|
-
modifiers: [],
|
|
52
|
-
critical: result === sides && sides > 1,
|
|
53
|
-
fumble: result === 1 && sides > 1,
|
|
54
|
-
};
|
|
42
|
+
return createDieResult(sides, rng.nextInt(1, sides), []);
|
|
55
43
|
}
|
|
56
44
|
|
|
57
45
|
function rerollLimitError(maxIterations: number): EvaluatorError {
|
|
@@ -67,16 +55,7 @@ function rerollLimitError(maxIterations: number): EvaluatorError {
|
|
|
67
55
|
* keep/drop modifier) are left alone.
|
|
68
56
|
*/
|
|
69
57
|
function canReroll(die: DieResult): boolean {
|
|
70
|
-
return !die.modifiers.includes('dropped');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Returns the die's modifiers with any "slot" flags removed. Slot flags
|
|
75
|
-
* (`kept`, `dropped`, `rerolled`) are controlled by the surrounding logic
|
|
76
|
-
* and should be reassigned each pass.
|
|
77
|
-
*/
|
|
78
|
-
function stripSlotFlags(modifiers: DieResult['modifiers']): DieResult['modifiers'] {
|
|
79
|
-
return modifiers.filter((m) => m !== 'kept' && m !== 'dropped' && m !== 'rerolled');
|
|
58
|
+
return !die.modifiers.includes('dropped') && !isVersusDc(die);
|
|
80
59
|
}
|
|
81
60
|
|
|
82
61
|
/**
|
|
@@ -105,21 +84,21 @@ export function applyRecursiveReroll(
|
|
|
105
84
|
let current = original;
|
|
106
85
|
let iterations = 0;
|
|
107
86
|
|
|
108
|
-
//
|
|
109
|
-
//
|
|
87
|
+
// Mutate flags in place — the same DieResult objects are shared with
|
|
88
|
+
// the RollPart tree, and both views must reflect reroll state.
|
|
110
89
|
while (matchesCondition(current.result, operator, value)) {
|
|
111
90
|
if (iterations >= env.maxRerollIterations) {
|
|
112
91
|
throw rerollLimitError(env.maxRerollIterations);
|
|
113
92
|
}
|
|
114
93
|
|
|
115
|
-
current.modifiers =
|
|
94
|
+
current.modifiers = rewriteFlags(current.modifiers, REROLL_SLOT_FLAGS, 'rerolled', 'dropped');
|
|
116
95
|
result.push(current);
|
|
117
96
|
|
|
118
97
|
current = rollReplacement(current.sides, rng, env);
|
|
119
98
|
iterations += 1;
|
|
120
99
|
}
|
|
121
100
|
|
|
122
|
-
current.modifiers =
|
|
101
|
+
current.modifiers = rewriteFlags(current.modifiers, REROLL_SLOT_FLAGS, 'kept');
|
|
123
102
|
result.push(current);
|
|
124
103
|
}
|
|
125
104
|
|
|
@@ -146,18 +125,18 @@ export function applyRerollOnce(
|
|
|
146
125
|
continue;
|
|
147
126
|
}
|
|
148
127
|
|
|
149
|
-
//
|
|
128
|
+
// Mutate flags in place — see `applyRecursiveReroll`.
|
|
150
129
|
if (!matchesCondition(original.result, operator, value)) {
|
|
151
|
-
original.modifiers =
|
|
130
|
+
original.modifiers = rewriteFlags(original.modifiers, REROLL_SLOT_FLAGS, 'kept');
|
|
152
131
|
result.push(original);
|
|
153
132
|
continue;
|
|
154
133
|
}
|
|
155
134
|
|
|
156
|
-
original.modifiers =
|
|
135
|
+
original.modifiers = rewriteFlags(original.modifiers, REROLL_SLOT_FLAGS, 'rerolled', 'dropped');
|
|
157
136
|
result.push(original);
|
|
158
137
|
|
|
159
138
|
const replacement = rollReplacement(original.sides, rng, env);
|
|
160
|
-
replacement.modifiers =
|
|
139
|
+
replacement.modifiers = rewriteFlags(replacement.modifiers, REROLL_SLOT_FLAGS, 'kept');
|
|
161
140
|
result.push(replacement);
|
|
162
141
|
}
|
|
163
142
|
|
|
@@ -7,10 +7,15 @@
|
|
|
7
7
|
* Dropped dice stay in the pool and sort alongside kept dice so readers can
|
|
8
8
|
* see where the dropped value landed in the ordered sequence.
|
|
9
9
|
*
|
|
10
|
+
* DC dice are the exception: they hold their positions rather than sorting,
|
|
11
|
+
* so a `vs` comparison's DC faces never appear shuffled into the roll-side
|
|
12
|
+
* pool they are not a member of.
|
|
13
|
+
*
|
|
10
14
|
* @module evaluator/modifiers/sort
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import type { DieResult } from '../../types.js';
|
|
18
|
+
import { isVersusDc } from './flags.js';
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* Returns a sorted copy of `dice` in the given order, preserving every die's
|
|
@@ -26,5 +31,13 @@ export function sortDice(dice: DieResult[], order: 'ascending' | 'descending'):
|
|
|
26
31
|
order === 'ascending'
|
|
27
32
|
? (a: DieResult, b: DieResult) => a.result - b.result
|
|
28
33
|
: (a: DieResult, b: DieResult) => b.result - a.result;
|
|
29
|
-
|
|
34
|
+
|
|
35
|
+
const sortable = dice.filter((die) => !isVersusDc(die));
|
|
36
|
+
if (sortable.length === dice.length) return [...dice].sort(cmp);
|
|
37
|
+
|
|
38
|
+
// Sort only the pool members, then lay them back into the slots they came
|
|
39
|
+
// from, leaving every DC die exactly where it was.
|
|
40
|
+
sortable.sort(cmp);
|
|
41
|
+
let next = 0;
|
|
42
|
+
return dice.map((die) => (isVersusDc(die) ? die : (sortable[next++] as DieResult)));
|
|
30
43
|
}
|
|
@@ -15,13 +15,9 @@
|
|
|
15
15
|
* @module evaluator/modifiers/success-count
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import type {
|
|
18
|
+
import type { DieResult, ResolvedComparePoint } from '../../types.js';
|
|
19
19
|
import { matchesCondition } from './compare.js';
|
|
20
|
-
|
|
21
|
-
export type ResolvedThreshold = {
|
|
22
|
-
operator: CompareOp;
|
|
23
|
-
value: number;
|
|
24
|
-
};
|
|
20
|
+
import { isVersusDc } from './flags.js';
|
|
25
21
|
|
|
26
22
|
export type SuccessCountResult = {
|
|
27
23
|
total: number;
|
|
@@ -31,13 +27,14 @@ export type SuccessCountResult = {
|
|
|
31
27
|
|
|
32
28
|
export function countSuccesses(
|
|
33
29
|
dice: DieResult[],
|
|
34
|
-
threshold:
|
|
35
|
-
failThreshold?:
|
|
30
|
+
threshold: ResolvedComparePoint,
|
|
31
|
+
failThreshold?: ResolvedComparePoint,
|
|
36
32
|
): SuccessCountResult {
|
|
37
33
|
let successes = 0;
|
|
38
34
|
let failures = 0;
|
|
39
35
|
|
|
40
36
|
for (const die of dice) {
|
|
37
|
+
if (isVersusDc(die)) continue;
|
|
41
38
|
if (die.modifiers.includes('dropped')) continue;
|
|
42
39
|
|
|
43
40
|
if (matchesCondition(die.result, threshold.operator, threshold.value)) {
|
package/src/index.ts
CHANGED
|
@@ -4,31 +4,45 @@
|
|
|
4
4
|
* @module roll-parser
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
export { RollParserError, isRollParserError } from './errors.js';
|
|
9
|
-
export type { RollParserErrorCode } from './errors.js';
|
|
7
|
+
import { version } from './version.js';
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
export {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
export type { ErrorSpan, NotationErrorCode, RollParserErrorCode } from './errors.js';
|
|
10
|
+
export {
|
|
11
|
+
getErrorSpan,
|
|
12
|
+
isNotationError,
|
|
13
|
+
isRollParserError,
|
|
14
|
+
NOTATION_ERROR_CODES,
|
|
15
|
+
ROLL_PARSER_ERROR_CODES,
|
|
16
|
+
RollParserError,
|
|
17
|
+
} from './errors.js';
|
|
18
|
+
export {
|
|
19
|
+
DEFAULT_MAX_DICE,
|
|
20
|
+
DEFAULT_MAX_EXPLODE_ITERATIONS,
|
|
21
|
+
DEFAULT_MAX_REROLL_ITERATIONS,
|
|
22
|
+
EvaluatorError,
|
|
23
|
+
evaluate,
|
|
24
|
+
} from './evaluator/evaluator.js';
|
|
25
|
+
export { LexerError, lex } from './lexer/lexer.js';
|
|
26
|
+
export type { Token } from './lexer/tokens.js';
|
|
27
|
+
export { TokenType } from './lexer/tokens.js';
|
|
16
28
|
export type {
|
|
17
29
|
ASTNode,
|
|
18
30
|
BinaryOpNode,
|
|
31
|
+
CritThreshold,
|
|
32
|
+
CritThresholdNode,
|
|
19
33
|
DiceNode,
|
|
20
|
-
|
|
34
|
+
DieBoundNode,
|
|
21
35
|
ExplodeNode,
|
|
22
36
|
FateDiceNode,
|
|
23
37
|
FunctionCallNode,
|
|
24
38
|
GroupedNode,
|
|
39
|
+
GroupNode,
|
|
40
|
+
KeepDropNode,
|
|
25
41
|
LiteralNode,
|
|
26
|
-
|
|
42
|
+
NodeSpan,
|
|
27
43
|
RerollNode,
|
|
28
|
-
SuccessCountNode,
|
|
29
44
|
SortNode,
|
|
30
|
-
|
|
31
|
-
GroupNode,
|
|
45
|
+
SuccessCountNode,
|
|
32
46
|
UnaryOpNode,
|
|
33
47
|
VariableNode,
|
|
34
48
|
VersusNode,
|
|
@@ -37,13 +51,14 @@ export {
|
|
|
37
51
|
isBinaryOp,
|
|
38
52
|
isCritThreshold,
|
|
39
53
|
isDice,
|
|
54
|
+
isDieBound,
|
|
40
55
|
isExplode,
|
|
41
56
|
isFateDice,
|
|
42
57
|
isFunctionCall,
|
|
43
58
|
isGroup,
|
|
44
59
|
isGrouped,
|
|
60
|
+
isKeepDrop,
|
|
45
61
|
isLiteral,
|
|
46
|
-
isModifier,
|
|
47
62
|
isReroll,
|
|
48
63
|
isSort,
|
|
49
64
|
isSuccessCount,
|
|
@@ -51,40 +66,46 @@ export {
|
|
|
51
66
|
isVariable,
|
|
52
67
|
isVersus,
|
|
53
68
|
} from './parser/ast.js';
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
export type { RNG } from './rng/types.js';
|
|
69
|
+
export { MAX_PARSE_DEPTH, ParseError, parse } from './parser/parser.js';
|
|
70
|
+
export type { RngState } from './rng/seeded.js';
|
|
57
71
|
export { SeededRNG } from './rng/seeded.js';
|
|
58
|
-
|
|
59
|
-
|
|
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';
|
|
72
|
+
export type { RNG } from './rng/types.js';
|
|
73
|
+
export type { RollOptions } from './roll.js';
|
|
74
|
+
export { roll } from './roll.js';
|
|
67
75
|
export type {
|
|
68
76
|
CompareOp,
|
|
69
77
|
ComparePoint,
|
|
70
78
|
DieModifier,
|
|
71
79
|
DieResult,
|
|
72
80
|
EvaluateOptions,
|
|
73
|
-
|
|
81
|
+
EvaluationOptions,
|
|
82
|
+
KeepDropSpec,
|
|
74
83
|
ResolvedComparePoint,
|
|
75
84
|
ResolvedCritThreshold,
|
|
76
85
|
RollPart,
|
|
86
|
+
RollPartBase,
|
|
77
87
|
RollPartType,
|
|
78
88
|
RollResult,
|
|
79
89
|
} from './types.js';
|
|
80
90
|
export { DegreeOfSuccess } from './types.js';
|
|
81
91
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Installed roll-parser version — the exact string in `package.json`, so
|
|
94
|
+
* pre-releases keep their suffix (`'3.0.0-beta.0'`). Sourced from the
|
|
95
|
+
* generated `src/version.ts`, kept in sync with the manifest by
|
|
96
|
+
* `bun run generate:version` and gated by `check:version` and CI.
|
|
97
|
+
*
|
|
98
|
+
* Useful in bug reports and for feature-gating against a minimum version. The
|
|
99
|
+
* CLI prints it for `--version` and in the `--help` header.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```typescript
|
|
103
|
+
* import { VERSION } from 'roll-parser';
|
|
104
|
+
*
|
|
105
|
+
* VERSION; // e.g. '3.0.0', or '3.1.0-beta.0' on a pre-release
|
|
106
|
+
* VERSION.startsWith('3.'); // true
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
109
|
+
* @category Core
|
|
110
|
+
*/
|
|
90
111
|
export const VERSION: string = version;
|
package/src/lexer/lexer.ts
CHANGED
|
@@ -5,24 +5,72 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { RollParserErrorCode } from '../errors.js';
|
|
8
|
-
import { RollParserError } from '../errors.js';
|
|
8
|
+
import { describeValue, RollParserError } from '../errors.js';
|
|
9
9
|
import { type Token, TokenType } from './tokens.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Error thrown when the lexer encounters an invalid character.
|
|
13
|
+
*
|
|
14
|
+
* `position` is a zero-based UTF-16 offset into the input. It is deliberately
|
|
15
|
+
* absent from `message` — read it from the field, or uniformly across all
|
|
16
|
+
* roll-parser errors via `getErrorSpan`.
|
|
17
|
+
*
|
|
18
|
+
* Codes: `UNEXPECTED_CHARACTER` for a character that cannot start any token,
|
|
19
|
+
* `UNEXPECTED_IDENTIFIER` for a word that is not a known keyword.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* import { LexerError, roll } from 'roll-parser';
|
|
24
|
+
*
|
|
25
|
+
* try {
|
|
26
|
+
* roll('2d6+&');
|
|
27
|
+
* } catch (error) {
|
|
28
|
+
* const typed = error as LexerError;
|
|
29
|
+
* typed.code; // 'UNEXPECTED_CHARACTER'
|
|
30
|
+
* typed.character; // '&'
|
|
31
|
+
* typed.position; // 4
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @category Errors
|
|
13
36
|
*/
|
|
14
37
|
export class LexerError extends RollParserError {
|
|
38
|
+
/** Zero-based UTF-16 offset of the offending character in the input. */
|
|
15
39
|
readonly position: number;
|
|
40
|
+
/**
|
|
41
|
+
* The offending text — a single character for `UNEXPECTED_CHARACTER` (the
|
|
42
|
+
* whole code point, so astral symbols are not split into surrogates), or
|
|
43
|
+
* the unrecognized word for `UNEXPECTED_IDENTIFIER`.
|
|
44
|
+
*/
|
|
16
45
|
readonly character: string;
|
|
17
46
|
|
|
18
|
-
constructor(
|
|
19
|
-
|
|
47
|
+
constructor(
|
|
48
|
+
message: string,
|
|
49
|
+
code: RollParserErrorCode,
|
|
50
|
+
position: number,
|
|
51
|
+
character: string,
|
|
52
|
+
options?: ErrorOptions,
|
|
53
|
+
) {
|
|
54
|
+
super(`${message}: '${character}'`, code, options);
|
|
20
55
|
this.name = 'LexerError';
|
|
21
56
|
this.position = position;
|
|
22
57
|
this.character = character;
|
|
23
58
|
}
|
|
24
59
|
}
|
|
25
60
|
|
|
61
|
+
//
|
|
62
|
+
// * Character codes
|
|
63
|
+
//
|
|
64
|
+
|
|
65
|
+
// Range tests compare code units: `char.toLowerCase()` per character allocated
|
|
66
|
+
// a string on the lexer's hottest loop.
|
|
67
|
+
const CHAR_DIGIT_0 = 48;
|
|
68
|
+
const CHAR_DIGIT_9 = 57;
|
|
69
|
+
const CHAR_UPPER_A = 65;
|
|
70
|
+
const CHAR_UPPER_Z = 90;
|
|
71
|
+
const CHAR_LOWER_A = 97;
|
|
72
|
+
const CHAR_LOWER_Z = 122;
|
|
73
|
+
|
|
26
74
|
/** Known identifier keywords mapped to their token types. */
|
|
27
75
|
const IDENTIFIER_KEYWORDS: Record<string, TokenType> = {
|
|
28
76
|
kh: TokenType.KEEP_HIGH,
|
|
@@ -39,6 +87,8 @@ const IDENTIFIER_KEYWORDS: Record<string, TokenType> = {
|
|
|
39
87
|
ceil: TokenType.FUNCTION,
|
|
40
88
|
round: TokenType.FUNCTION,
|
|
41
89
|
abs: TokenType.FUNCTION,
|
|
90
|
+
sqrt: TokenType.FUNCTION,
|
|
91
|
+
pow: TokenType.FUNCTION,
|
|
42
92
|
max: TokenType.FUNCTION,
|
|
43
93
|
min: TokenType.FUNCTION,
|
|
44
94
|
s: TokenType.SORT_ASC,
|
|
@@ -106,22 +156,18 @@ export class Lexer {
|
|
|
106
156
|
const startPos = this.pos;
|
|
107
157
|
const char = this.peek();
|
|
108
158
|
|
|
109
|
-
// * Numbers
|
|
110
159
|
if (this.isDigit(char)) {
|
|
111
160
|
return this.scanNumber();
|
|
112
161
|
}
|
|
113
162
|
|
|
114
|
-
// * Identifiers (d, kh, kl, dh, dl, r, ro, f, vs, floor, ceil, ...)
|
|
115
163
|
if (this.isAlpha(char)) {
|
|
116
164
|
return this.scanIdentifier();
|
|
117
165
|
}
|
|
118
166
|
|
|
119
|
-
// * Variable reference
|
|
120
167
|
if (char === '@') {
|
|
121
168
|
return this.scanAt();
|
|
122
169
|
}
|
|
123
170
|
|
|
124
|
-
// * Operators and punctuation
|
|
125
171
|
this.advance();
|
|
126
172
|
|
|
127
173
|
switch (char) {
|
|
@@ -166,8 +212,7 @@ export class Lexer {
|
|
|
166
212
|
if (this.match('!')) {
|
|
167
213
|
return this.createTokenAt(TokenType.EXPLODE_COMPOUND, '!!', startPos);
|
|
168
214
|
}
|
|
169
|
-
if (
|
|
170
|
-
this.advance();
|
|
215
|
+
if (this.match('p') || this.match('P')) {
|
|
171
216
|
return this.createTokenAt(TokenType.EXPLODE_PENETRATING, '!p', startPos);
|
|
172
217
|
}
|
|
173
218
|
return this.createTokenAt(TokenType.EXPLODE, char, startPos);
|
|
@@ -181,7 +226,9 @@ export class Lexer {
|
|
|
181
226
|
}
|
|
182
227
|
}
|
|
183
228
|
|
|
229
|
+
//
|
|
184
230
|
// * Private helpers
|
|
231
|
+
//
|
|
185
232
|
|
|
186
233
|
private skipWhitespace(): void {
|
|
187
234
|
while (!this.isAtEnd() && this.isWhitespace(this.peek())) {
|
|
@@ -189,24 +236,23 @@ export class Lexer {
|
|
|
189
236
|
}
|
|
190
237
|
}
|
|
191
238
|
|
|
239
|
+
// Scanners slice once from a recorded start offset rather than accumulating
|
|
240
|
+
// `value += this.advance()` — one string per token instead of one per character.
|
|
192
241
|
private scanNumber(): Token {
|
|
193
242
|
const startPos = this.pos;
|
|
194
|
-
let value = '';
|
|
195
243
|
|
|
196
|
-
// Integer part
|
|
197
244
|
while (!this.isAtEnd() && this.isDigit(this.peek())) {
|
|
198
|
-
|
|
245
|
+
this.pos++;
|
|
199
246
|
}
|
|
200
247
|
|
|
201
|
-
// Decimal part
|
|
202
248
|
if (!this.isAtEnd() && this.peek() === '.' && this.isDigit(this.peekNext())) {
|
|
203
|
-
|
|
249
|
+
this.pos++;
|
|
204
250
|
while (!this.isAtEnd() && this.isDigit(this.peek())) {
|
|
205
|
-
|
|
251
|
+
this.pos++;
|
|
206
252
|
}
|
|
207
253
|
}
|
|
208
254
|
|
|
209
|
-
return this.createTokenAt(TokenType.NUMBER,
|
|
255
|
+
return this.createTokenAt(TokenType.NUMBER, this.input.slice(startPos, this.pos), startPos);
|
|
210
256
|
}
|
|
211
257
|
|
|
212
258
|
/**
|
|
@@ -227,18 +273,15 @@ export class Lexer {
|
|
|
227
273
|
const first = this.peek();
|
|
228
274
|
const second = this.peekNext();
|
|
229
275
|
if ((first === 'd' || first === 'D') && (second === 'f' || second === 'F')) {
|
|
230
|
-
this.
|
|
231
|
-
this.advance();
|
|
276
|
+
this.pos += 2;
|
|
232
277
|
return this.createTokenAt(TokenType.DICE_FATE, 'df', startPos);
|
|
233
278
|
}
|
|
234
279
|
|
|
235
|
-
let value = '';
|
|
236
|
-
|
|
237
280
|
while (!this.isAtEnd() && this.isAlpha(this.peek())) {
|
|
238
|
-
|
|
281
|
+
this.pos++;
|
|
239
282
|
}
|
|
240
283
|
|
|
241
|
-
const lower =
|
|
284
|
+
const lower = this.input.slice(startPos, this.pos).toLowerCase();
|
|
242
285
|
|
|
243
286
|
if (lower === 'd' && !this.isAtEnd() && this.peek() === '%') {
|
|
244
287
|
this.advance();
|
|
@@ -272,11 +315,11 @@ export class Lexer {
|
|
|
272
315
|
*/
|
|
273
316
|
private scanAt(): Token {
|
|
274
317
|
const startPos = this.pos;
|
|
275
|
-
this.advance();
|
|
318
|
+
this.advance();
|
|
276
319
|
|
|
277
320
|
let name: string;
|
|
278
321
|
if (!this.isAtEnd() && this.peek() === '{') {
|
|
279
|
-
this.advance();
|
|
322
|
+
this.advance();
|
|
280
323
|
const nameStart = this.pos;
|
|
281
324
|
while (!this.isAtEnd() && this.peek() !== '}' && this.peek() !== '\n') {
|
|
282
325
|
this.advance();
|
|
@@ -285,7 +328,7 @@ export class Lexer {
|
|
|
285
328
|
throw new LexerError('Unterminated @{...} variable', 'UNEXPECTED_CHARACTER', startPos, '@');
|
|
286
329
|
}
|
|
287
330
|
name = this.input.slice(nameStart, this.pos);
|
|
288
|
-
this.advance();
|
|
331
|
+
this.advance();
|
|
289
332
|
} else {
|
|
290
333
|
const nameStart = this.pos;
|
|
291
334
|
if (this.isAtEnd() || !this.isIdentifierStart(this.peek())) {
|
|
@@ -324,13 +367,19 @@ export class Lexer {
|
|
|
324
367
|
return this.pos >= this.input.length;
|
|
325
368
|
}
|
|
326
369
|
|
|
370
|
+
// `NaN` from an empty `peek()` fails every comparison, so end-of-input
|
|
371
|
+
// still reads as "not a digit / not alpha" without an extra guard.
|
|
327
372
|
private isDigit(char: string): boolean {
|
|
328
|
-
|
|
373
|
+
const code = char.charCodeAt(0);
|
|
374
|
+
return code >= CHAR_DIGIT_0 && code <= CHAR_DIGIT_9;
|
|
329
375
|
}
|
|
330
376
|
|
|
331
377
|
private isAlpha(char: string): boolean {
|
|
332
|
-
const
|
|
333
|
-
return
|
|
378
|
+
const code = char.charCodeAt(0);
|
|
379
|
+
return (
|
|
380
|
+
(code >= CHAR_LOWER_A && code <= CHAR_LOWER_Z) ||
|
|
381
|
+
(code >= CHAR_UPPER_A && code <= CHAR_UPPER_Z)
|
|
382
|
+
);
|
|
334
383
|
}
|
|
335
384
|
|
|
336
385
|
private isIdentifierStart(char: string): boolean {
|
|
@@ -345,8 +394,8 @@ export class Lexer {
|
|
|
345
394
|
return char === ' ' || char === '\t' || char === '\n' || char === '\r';
|
|
346
395
|
}
|
|
347
396
|
|
|
348
|
-
//
|
|
349
|
-
//
|
|
397
|
+
// Both factories run after the token's characters were consumed, so
|
|
398
|
+
// `this.pos` is the exclusive end offset.
|
|
350
399
|
private createToken(type: TokenType, value: string): Token {
|
|
351
400
|
return { type, value, position: this.pos, end: this.pos };
|
|
352
401
|
}
|
|
@@ -357,18 +406,42 @@ export class Lexer {
|
|
|
357
406
|
}
|
|
358
407
|
|
|
359
408
|
/**
|
|
360
|
-
*
|
|
409
|
+
* Tokenizes a dice notation string. The first stage of the pipeline —
|
|
410
|
+
* {@link parse} calls it for you; reach for `lex` directly only to build a
|
|
411
|
+
* syntax highlighter or an editor integration.
|
|
412
|
+
*
|
|
413
|
+
* Notation is case-insensitive and whitespace-tolerant: `2D20 + 5` and
|
|
414
|
+
* `2d20+5` produce the same tokens, and identifier tokens carry a lowercased
|
|
415
|
+
* `value`. The one exception is `@name`, whose case is preserved.
|
|
361
416
|
*
|
|
362
417
|
* @param input - The dice notation to tokenize
|
|
363
|
-
* @returns
|
|
364
|
-
*
|
|
418
|
+
* @returns Every token in source order, always ending with one
|
|
419
|
+
* `TokenType.EOF` token
|
|
420
|
+
* @throws {LexerError} If an invalid character or unknown identifier is found
|
|
421
|
+
* @throws {RollParserError} `INVALID_NOTATION_TYPE` when `input` is not a
|
|
422
|
+
* string — raised before scanning, so it carries no position
|
|
365
423
|
*
|
|
366
424
|
* @example
|
|
367
425
|
* ```typescript
|
|
426
|
+
* import { lex, TokenType } from 'roll-parser';
|
|
427
|
+
*
|
|
368
428
|
* const tokens = lex('2d20+5');
|
|
369
|
-
* //
|
|
429
|
+
* tokens.length; // 6 — NUMBER DICE NUMBER PLUS NUMBER EOF
|
|
430
|
+
* tokens[0]; // { type: TokenType.NUMBER, value: '2', position: 0, end: 1 }
|
|
431
|
+
* tokens[1].type === TokenType.DICE; // true
|
|
432
|
+
* tokens.at(-1)?.type === TokenType.EOF; // true
|
|
370
433
|
* ```
|
|
434
|
+
*
|
|
435
|
+
* @category Core
|
|
371
436
|
*/
|
|
372
437
|
export function lex(input: string): Token[] {
|
|
438
|
+
// ! The pipeline's only notation type guard — `parse` and `roll` both funnel through here.
|
|
439
|
+
if (typeof input !== 'string') {
|
|
440
|
+
throw new RollParserError(
|
|
441
|
+
`Notation must be a string, received ${describeValue(input)}`,
|
|
442
|
+
'INVALID_NOTATION_TYPE',
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
373
446
|
return new Lexer(input).tokenize();
|
|
374
447
|
}
|
package/src/lexer/tokens.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Grouped semantically. Numeric values are stable identifiers — the specific
|
|
11
11
|
* numbers don't matter, but they must be unique.
|
|
12
|
+
*
|
|
13
|
+
* @category AST
|
|
12
14
|
*/
|
|
13
15
|
export enum TokenType {
|
|
14
16
|
//
|
|
@@ -116,7 +118,11 @@ export enum TokenType {
|
|
|
116
118
|
// * Functions
|
|
117
119
|
//
|
|
118
120
|
|
|
119
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* Math function: 'floor', 'ceil', 'round', 'abs', 'sqrt', 'pow', 'max',
|
|
123
|
+
* 'min'. In postfix position, 'min'/'max' double as per-die clamp
|
|
124
|
+
* modifiers (`4d6min2`) — the parser decides by position, not the lexer.
|
|
125
|
+
*/
|
|
120
126
|
FUNCTION = 28,
|
|
121
127
|
|
|
122
128
|
//
|
|
@@ -169,19 +175,38 @@ export enum TokenType {
|
|
|
169
175
|
}
|
|
170
176
|
|
|
171
177
|
/**
|
|
172
|
-
* A token produced by the
|
|
178
|
+
* A token produced by {@link lex}: what it is, the text it came from, and
|
|
179
|
+
* where in the input that text sits.
|
|
180
|
+
*
|
|
181
|
+
* `position`/`end` are a half-open range, so `input.slice(position, end)`
|
|
182
|
+
* recovers the original source text — useful for syntax highlighting, where
|
|
183
|
+
* `value` alone is lossy.
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* ```typescript
|
|
187
|
+
* import { lex, TokenType } from 'roll-parser';
|
|
188
|
+
*
|
|
189
|
+
* const input = '4D6KH3';
|
|
190
|
+
* const tokens = lex(input);
|
|
191
|
+
* tokens[1]; // { type: TokenType.DICE, value: 'd', position: 1, end: 2 }
|
|
192
|
+
* tokens[3].type === TokenType.KEEP_HIGH; // true
|
|
193
|
+
* input.slice(tokens[3].position, tokens[3].end); // 'KH' — original casing
|
|
194
|
+
* tokens[3].value; // 'kh' — normalized
|
|
195
|
+
* ```
|
|
196
|
+
*
|
|
197
|
+
* @category AST
|
|
173
198
|
*/
|
|
174
199
|
export type Token = {
|
|
175
200
|
/** The type of this token */
|
|
176
|
-
type: TokenType;
|
|
201
|
+
readonly type: TokenType;
|
|
177
202
|
/** The raw string value from input (lowercased for identifiers) */
|
|
178
|
-
value: string;
|
|
203
|
+
readonly value: string;
|
|
179
204
|
/** Zero-based start offset in the input string (UTF-16 code units) */
|
|
180
|
-
position: number;
|
|
205
|
+
readonly position: number;
|
|
181
206
|
/**
|
|
182
207
|
* Zero-based end offset (exclusive). Not always `position + value.length` —
|
|
183
208
|
* braced variables (`@{name}` stores only `name`) and case-normalized
|
|
184
209
|
* identifiers consume more input than their `value` retains.
|
|
185
210
|
*/
|
|
186
|
-
end: number;
|
|
211
|
+
readonly end: number;
|
|
187
212
|
};
|