roll-parser 3.0.0-alpha.0 → 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 +166 -30
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/format.d.ts +1 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli.js +1110 -277
- package/dist/cli.js.map +28 -0
- package/dist/errors.d.ts +7 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/evaluator/evaluator.d.ts +63 -7
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/index.d.ts +2 -2
- package/dist/evaluator/index.d.ts.map +1 -1
- package/dist/evaluator/modifiers/compare.d.ts +1 -1
- package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
- 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 +8 -4
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.d.ts +1 -1
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
- package/dist/evaluator/modifiers/reroll.d.ts +4 -4
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
- 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 +1 -1
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/index.d.ts +13 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1112 -275
- package/dist/index.js.map +26 -0
- package/dist/lexer/lexer.d.ts +18 -3
- package/dist/lexer/lexer.d.ts.map +1 -1
- package/dist/lexer/tokens.d.ts +22 -2
- package/dist/lexer/tokens.d.ts.map +1 -1
- package/dist/parser/ast.d.ts +209 -24
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/parser.d.ts +48 -5
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/rng/index.d.ts +2 -2
- package/dist/rng/index.d.ts.map +1 -1
- package/dist/rng/mock.d.ts +1 -1
- package/dist/rng/mock.d.ts.map +1 -1
- package/dist/rng/seeded.d.ts +8 -1
- package/dist/rng/seeded.d.ts.map +1 -1
- package/dist/roll.d.ts +6 -2
- package/dist/roll.d.ts.map +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +3 -0
- package/dist/testing.js.map +11 -0
- package/dist/types.d.ts +143 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +22 -18
- package/src/cli/args.ts +2 -1
- package/src/cli/format.ts +8 -4
- package/src/cli/index.ts +22 -5
- package/src/errors.ts +15 -3
- package/src/evaluator/evaluator.ts +826 -111
- package/src/evaluator/index.ts +2 -2
- package/src/evaluator/modifiers/compare.ts +1 -1
- package/src/evaluator/modifiers/crit-threshold.ts +59 -0
- package/src/evaluator/modifiers/explode.ts +29 -25
- package/src/evaluator/modifiers/keep-drop.ts +1 -1
- package/src/evaluator/modifiers/reroll.ts +18 -25
- package/src/evaluator/modifiers/sort.ts +30 -0
- package/src/evaluator/modifiers/success-count.ts +2 -2
- package/src/index.ts +33 -15
- package/src/lexer/lexer.ts +101 -8
- package/src/lexer/tokens.ts +42 -2
- package/src/parser/ast.ts +397 -30
- package/src/parser/parser.ts +590 -67
- package/src/rng/index.ts +2 -2
- package/src/rng/mock.ts +1 -1
- package/src/rng/seeded.ts +31 -1
- package/src/roll.ts +14 -6
- package/src/testing.ts +1 -1
- package/src/types.ts +127 -2
- package/dist/index.mjs +0 -1724
- package/dist/testing.mjs +0 -39
package/src/evaluator/index.ts
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* @module evaluator
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export { DEFAULT_MAX_DICE, evaluate, EvaluatorError } from './evaluator';
|
|
7
|
+
export { DEFAULT_MAX_DICE, evaluate, EvaluatorError } from './evaluator.js';
|
|
8
8
|
export {
|
|
9
9
|
applyDropHighest,
|
|
10
10
|
applyDropLowest,
|
|
11
11
|
applyKeepHighest,
|
|
12
12
|
applyKeepLowest,
|
|
13
13
|
sumKeptDice,
|
|
14
|
-
} from './modifiers/keep-drop';
|
|
14
|
+
} from './modifiers/keep-drop.js';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Critical / fumble threshold modifier.
|
|
3
|
+
*
|
|
4
|
+
* Overrides the default `critical`/`fumble` flag logic for a dice pool.
|
|
5
|
+
* `cs` and `cf` are independent (Roll20 semantics): the evaluator passes
|
|
6
|
+
* `['default']` for a side with no explicit thresholds, so overriding one
|
|
7
|
+
* side never wipes the other. An empty threshold array (not produced by the
|
|
8
|
+
* evaluator) would force the corresponding flag to `false` on every die.
|
|
9
|
+
* Meta dice (rolled to compute counts/sides/modifier args) are skipped so
|
|
10
|
+
* their bookkeeping stays untouched.
|
|
11
|
+
*
|
|
12
|
+
* Display-only: does not alter `total`, explosion triggers, success
|
|
13
|
+
* counting, or any other modifier flag. Dropped dice still participate —
|
|
14
|
+
* their `critical`/`fumble` metadata reflects what they rolled, not
|
|
15
|
+
* whether they contributed to the total.
|
|
16
|
+
*
|
|
17
|
+
* @module evaluator/modifiers/crit-threshold
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { DieResult, ResolvedCritThreshold } from '../../types.js';
|
|
21
|
+
import { matchesCondition } from './compare.js';
|
|
22
|
+
|
|
23
|
+
// ? Re-exported for existing importers — the definition moved to types.ts so
|
|
24
|
+
// the public RollPart union can reference it without a circular import.
|
|
25
|
+
export type { ResolvedCritThreshold } from '../../types.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Applies success/fail threshold arrays to a dice pool, overriding each
|
|
29
|
+
* die's `critical` and `fumble` flags in place. A die matches `'default'`
|
|
30
|
+
* on the success side when `result === sides && sides > 1`, and on the
|
|
31
|
+
* fail side when `result === 1`. Meta dice are skipped.
|
|
32
|
+
*/
|
|
33
|
+
export function applyCritThresholds(
|
|
34
|
+
dice: DieResult[],
|
|
35
|
+
successThresholds: ResolvedCritThreshold[],
|
|
36
|
+
failThresholds: ResolvedCritThreshold[],
|
|
37
|
+
): void {
|
|
38
|
+
for (const die of dice) {
|
|
39
|
+
if (die.modifiers.includes('meta')) continue;
|
|
40
|
+
|
|
41
|
+
die.critical = successThresholds.some((t) => matchesCrit(t, die));
|
|
42
|
+
die.fumble = failThresholds.some((t) => matchesFumble(t, die));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function matchesCrit(threshold: ResolvedCritThreshold, die: DieResult): boolean {
|
|
47
|
+
if (threshold === 'default') {
|
|
48
|
+
return die.result === die.sides && die.sides > 1;
|
|
49
|
+
}
|
|
50
|
+
return matchesCondition(die.result, threshold.operator, threshold.value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult): boolean {
|
|
54
|
+
if (threshold === 'default') {
|
|
55
|
+
// ? Mirrors `createDieResult` — a d1 always rolls 1, never a fumble.
|
|
56
|
+
return die.result === 1 && die.sides > 1;
|
|
57
|
+
}
|
|
58
|
+
return matchesCondition(die.result, threshold.operator, threshold.value);
|
|
59
|
+
}
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* @module evaluator/modifiers/explode
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { RNG } from '../../rng/types';
|
|
12
|
-
import type { CompareOp } from '../../types';
|
|
13
|
-
import type { DieResult } from '../../types';
|
|
14
|
-
import { EvaluatorError, type EvalEnv } from '../evaluator';
|
|
11
|
+
import type { RNG } from '../../rng/types.js';
|
|
12
|
+
import type { CompareOp } from '../../types.js';
|
|
13
|
+
import type { DieResult } from '../../types.js';
|
|
14
|
+
import { EvaluatorError, type EvalEnv } from '../evaluator.js';
|
|
15
15
|
|
|
16
16
|
/** Default maximum explosion iterations per die. */
|
|
17
17
|
export const DEFAULT_MAX_EXPLODE_ITERATIONS = 1_000;
|
|
@@ -75,8 +75,11 @@ function explodeLimitError(maxIterations: number): EvaluatorError {
|
|
|
75
75
|
* Returns true when the die is eligible to start exploding: it must not
|
|
76
76
|
* already be dropped by a prior modifier, and its `sides` must be rollable.
|
|
77
77
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
78
|
+
* The `sides < 1` branch is a defense-in-depth fallback — Fate dice
|
|
79
|
+
* (sides = 0) are rejected at parse time via `INVALID_EXPLODE_TARGET`
|
|
80
|
+
* (see `parseExplode` in `src/parser/parser.ts`), so this guard should
|
|
81
|
+
* never fire during normal flow. Keeping it ensures `rng.nextInt(1, 0)`
|
|
82
|
+
* can never be reached if a future AST path slips past the parser gate.
|
|
80
83
|
*/
|
|
81
84
|
function canExplode(die: DieResult): boolean {
|
|
82
85
|
if (die.modifiers.includes('dropped')) return false;
|
|
@@ -114,7 +117,7 @@ export function applyStandardExplode(
|
|
|
114
117
|
result: next,
|
|
115
118
|
modifiers: ['exploded', 'kept'],
|
|
116
119
|
critical: next === sides && sides > 1,
|
|
117
|
-
fumble: next === 1,
|
|
120
|
+
fumble: next === 1 && sides > 1,
|
|
118
121
|
});
|
|
119
122
|
last = next;
|
|
120
123
|
iterations += 1;
|
|
@@ -128,6 +131,10 @@ export function applyStandardExplode(
|
|
|
128
131
|
* Compound explode: pool length stays the same. Each original die's `result`
|
|
129
132
|
* accumulates every explosion roll, and gains the `'exploded'` modifier once
|
|
130
133
|
* it actually exploded at least once.
|
|
134
|
+
*
|
|
135
|
+
* Mutates exploded dice in place — the same `DieResult` objects are shared
|
|
136
|
+
* between `RollResult.rolls` and the `RollPart` tree, and both must reflect
|
|
137
|
+
* post-explosion state.
|
|
131
138
|
*/
|
|
132
139
|
export function applyCompoundExplode(
|
|
133
140
|
pool: DieResult[],
|
|
@@ -135,8 +142,8 @@ export function applyCompoundExplode(
|
|
|
135
142
|
rng: RNG,
|
|
136
143
|
env: EvalEnv,
|
|
137
144
|
): DieResult[] {
|
|
138
|
-
|
|
139
|
-
if (!canExplode(original))
|
|
145
|
+
for (const original of pool) {
|
|
146
|
+
if (!canExplode(original)) continue;
|
|
140
147
|
|
|
141
148
|
const sides = original.sides;
|
|
142
149
|
let accumulated = original.result;
|
|
@@ -155,21 +162,18 @@ export function applyCompoundExplode(
|
|
|
155
162
|
iterations += 1;
|
|
156
163
|
}
|
|
157
164
|
|
|
158
|
-
if (!exploded)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
fumble: original.fumble,
|
|
171
|
-
};
|
|
172
|
-
});
|
|
165
|
+
if (!exploded) continue;
|
|
166
|
+
|
|
167
|
+
// ? `critical` and `fumble` keep referring to the original triggering
|
|
168
|
+
// roll — after compounding the `result` is a sum.
|
|
169
|
+
original.initialResult = original.result;
|
|
170
|
+
original.result = accumulated;
|
|
171
|
+
if (!original.modifiers.includes('exploded')) {
|
|
172
|
+
original.modifiers = [...original.modifiers, 'exploded'];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return pool;
|
|
173
177
|
}
|
|
174
178
|
|
|
175
179
|
/**
|
|
@@ -204,7 +208,7 @@ export function applyPenetratingExplode(
|
|
|
204
208
|
result: stored,
|
|
205
209
|
modifiers: ['exploded', 'kept'],
|
|
206
210
|
critical: raw === sides && sides > 1,
|
|
207
|
-
fumble: raw === 1,
|
|
211
|
+
fumble: raw === 1 && sides > 1,
|
|
208
212
|
});
|
|
209
213
|
lastRaw = raw;
|
|
210
214
|
iterations += 1;
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
* @module evaluator/modifiers/reroll
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { RNG } from '../../rng/types';
|
|
13
|
-
import type { CompareOp, DieResult } from '../../types';
|
|
14
|
-
import { EvaluatorError, type EvalEnv } from '../evaluator';
|
|
15
|
-
import { matchesCondition } from './compare';
|
|
12
|
+
import type { RNG } from '../../rng/types.js';
|
|
13
|
+
import type { CompareOp, DieResult } from '../../types.js';
|
|
14
|
+
import { EvaluatorError, type EvalEnv } from '../evaluator.js';
|
|
15
|
+
import { matchesCondition } from './compare.js';
|
|
16
16
|
|
|
17
17
|
/** Default maximum reroll iterations per die. */
|
|
18
18
|
export const DEFAULT_MAX_REROLL_ITERATIONS = 1_000;
|
|
@@ -50,7 +50,7 @@ function rollReplacement(sides: number, rng: RNG, env: EvalEnv): DieResult {
|
|
|
50
50
|
result,
|
|
51
51
|
modifiers: [],
|
|
52
52
|
critical: result === sides && sides > 1,
|
|
53
|
-
fumble: result === 1,
|
|
53
|
+
fumble: result === 1 && sides > 1,
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -105,24 +105,22 @@ export function applyRecursiveReroll(
|
|
|
105
105
|
let current = original;
|
|
106
106
|
let iterations = 0;
|
|
107
107
|
|
|
108
|
+
// ? Mutate flags in place — the same DieResult objects are shared with
|
|
109
|
+
// the RollPart tree, and both views must reflect reroll state.
|
|
108
110
|
while (matchesCondition(current.result, operator, value)) {
|
|
109
111
|
if (iterations >= env.maxRerollIterations) {
|
|
110
112
|
throw rerollLimitError(env.maxRerollIterations);
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
modifiers: [...stripSlotFlags(current.modifiers), 'rerolled', 'dropped'],
|
|
116
|
-
});
|
|
115
|
+
current.modifiers = [...stripSlotFlags(current.modifiers), 'rerolled', 'dropped'];
|
|
116
|
+
result.push(current);
|
|
117
117
|
|
|
118
118
|
current = rollReplacement(current.sides, rng, env);
|
|
119
119
|
iterations += 1;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
modifiers: [...stripSlotFlags(current.modifiers), 'kept'],
|
|
125
|
-
});
|
|
122
|
+
current.modifiers = [...stripSlotFlags(current.modifiers), 'kept'];
|
|
123
|
+
result.push(current);
|
|
126
124
|
}
|
|
127
125
|
|
|
128
126
|
return result;
|
|
@@ -148,24 +146,19 @@ export function applyRerollOnce(
|
|
|
148
146
|
continue;
|
|
149
147
|
}
|
|
150
148
|
|
|
149
|
+
// ? Mutate flags in place — see `applyRecursiveReroll`.
|
|
151
150
|
if (!matchesCondition(original.result, operator, value)) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
modifiers: [...stripSlotFlags(original.modifiers), 'kept'],
|
|
155
|
-
});
|
|
151
|
+
original.modifiers = [...stripSlotFlags(original.modifiers), 'kept'];
|
|
152
|
+
result.push(original);
|
|
156
153
|
continue;
|
|
157
154
|
}
|
|
158
155
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
modifiers: [...stripSlotFlags(original.modifiers), 'rerolled', 'dropped'],
|
|
162
|
-
});
|
|
156
|
+
original.modifiers = [...stripSlotFlags(original.modifiers), 'rerolled', 'dropped'];
|
|
157
|
+
result.push(original);
|
|
163
158
|
|
|
164
159
|
const replacement = rollReplacement(original.sides, rng, env);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
modifiers: [...stripSlotFlags(replacement.modifiers), 'kept'],
|
|
168
|
-
});
|
|
160
|
+
replacement.modifiers = [...stripSlotFlags(replacement.modifiers), 'kept'];
|
|
161
|
+
result.push(replacement);
|
|
169
162
|
}
|
|
170
163
|
|
|
171
164
|
return result;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sort modifier implementation.
|
|
3
|
+
*
|
|
4
|
+
* Sort is purely cosmetic — it reorders the dice produced by its target in
|
|
5
|
+
* ascending or descending order of `result` without touching any flag
|
|
6
|
+
* (`kept`, `dropped`, `critical`, `fumble`, `rerolled`, `meta`) or the total.
|
|
7
|
+
* Dropped dice stay in the pool and sort alongside kept dice so readers can
|
|
8
|
+
* see where the dropped value landed in the ordered sequence.
|
|
9
|
+
*
|
|
10
|
+
* @module evaluator/modifiers/sort
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { DieResult } from '../../types.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns a sorted copy of `dice` in the given order, preserving every die's
|
|
17
|
+
* original flags and metadata. Comparison is on `.result` — the raw face
|
|
18
|
+
* value is what the user sees in rendered output, so ordering by raw value
|
|
19
|
+
* matches the visual intent regardless of any `kept`/`dropped` flagging.
|
|
20
|
+
*
|
|
21
|
+
* Relies on `Array.prototype.sort` being stable — equal-valued dice retain
|
|
22
|
+
* their original insertion order.
|
|
23
|
+
*/
|
|
24
|
+
export function sortDice(dice: DieResult[], order: 'ascending' | 'descending'): DieResult[] {
|
|
25
|
+
const cmp =
|
|
26
|
+
order === 'ascending'
|
|
27
|
+
? (a: DieResult, b: DieResult) => a.result - b.result
|
|
28
|
+
: (a: DieResult, b: DieResult) => b.result - a.result;
|
|
29
|
+
return [...dice].sort(cmp);
|
|
30
|
+
}
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* @module evaluator/modifiers/success-count
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import type { CompareOp, DieResult } from '../../types';
|
|
19
|
-
import { matchesCondition } from './compare';
|
|
18
|
+
import type { CompareOp, DieResult } from '../../types.js';
|
|
19
|
+
import { matchesCondition } from './compare.js';
|
|
20
20
|
|
|
21
21
|
export type ResolvedThreshold = {
|
|
22
22
|
operator: CompareOp;
|
package/src/index.ts
CHANGED
|
@@ -5,45 +5,56 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
// * Error hierarchy
|
|
8
|
-
export { RollParserError, isRollParserError } from './errors';
|
|
9
|
-
export type { RollParserErrorCode } from './errors';
|
|
8
|
+
export { RollParserError, isRollParserError } from './errors.js';
|
|
9
|
+
export type { RollParserErrorCode } from './errors.js';
|
|
10
10
|
|
|
11
11
|
// * Lexer exports
|
|
12
|
-
export { LexerError } from './lexer/lexer';
|
|
12
|
+
export { LexerError } from './lexer/lexer.js';
|
|
13
13
|
|
|
14
14
|
// * Parser exports
|
|
15
|
-
export { parse, ParseError } from './parser/parser';
|
|
15
|
+
export { parse, ParseError } from './parser/parser.js';
|
|
16
16
|
export type {
|
|
17
17
|
ASTNode,
|
|
18
18
|
BinaryOpNode,
|
|
19
19
|
DiceNode,
|
|
20
|
+
NodeSpan,
|
|
20
21
|
ExplodeNode,
|
|
21
22
|
FateDiceNode,
|
|
22
23
|
FunctionCallNode,
|
|
24
|
+
GroupedNode,
|
|
23
25
|
LiteralNode,
|
|
24
26
|
ModifierNode,
|
|
25
27
|
RerollNode,
|
|
26
28
|
SuccessCountNode,
|
|
29
|
+
SortNode,
|
|
30
|
+
CritThresholdNode,
|
|
31
|
+
GroupNode,
|
|
27
32
|
UnaryOpNode,
|
|
33
|
+
VariableNode,
|
|
28
34
|
VersusNode,
|
|
29
|
-
} from './parser/ast';
|
|
35
|
+
} from './parser/ast.js';
|
|
30
36
|
export {
|
|
31
37
|
isBinaryOp,
|
|
38
|
+
isCritThreshold,
|
|
32
39
|
isDice,
|
|
33
40
|
isExplode,
|
|
34
41
|
isFateDice,
|
|
35
42
|
isFunctionCall,
|
|
43
|
+
isGroup,
|
|
44
|
+
isGrouped,
|
|
36
45
|
isLiteral,
|
|
37
46
|
isModifier,
|
|
38
47
|
isReroll,
|
|
48
|
+
isSort,
|
|
39
49
|
isSuccessCount,
|
|
40
50
|
isUnaryOp,
|
|
51
|
+
isVariable,
|
|
41
52
|
isVersus,
|
|
42
|
-
} from './parser/ast';
|
|
53
|
+
} from './parser/ast.js';
|
|
43
54
|
|
|
44
55
|
// * RNG exports
|
|
45
|
-
export type { RNG } from './rng/types';
|
|
46
|
-
export { SeededRNG } from './rng/seeded';
|
|
56
|
+
export type { RNG } from './rng/types.js';
|
|
57
|
+
export { SeededRNG } from './rng/seeded.js';
|
|
47
58
|
|
|
48
59
|
// * Evaluator exports
|
|
49
60
|
export {
|
|
@@ -52,21 +63,28 @@ export {
|
|
|
52
63
|
DEFAULT_MAX_REROLL_ITERATIONS,
|
|
53
64
|
evaluate,
|
|
54
65
|
EvaluatorError,
|
|
55
|
-
} from './evaluator/evaluator';
|
|
66
|
+
} from './evaluator/evaluator.js';
|
|
56
67
|
export type {
|
|
57
68
|
CompareOp,
|
|
58
69
|
ComparePoint,
|
|
59
70
|
DieModifier,
|
|
60
71
|
DieResult,
|
|
61
72
|
EvaluateOptions,
|
|
73
|
+
ModifierSpec,
|
|
74
|
+
ResolvedComparePoint,
|
|
75
|
+
ResolvedCritThreshold,
|
|
76
|
+
RollPart,
|
|
77
|
+
RollPartType,
|
|
62
78
|
RollResult,
|
|
63
|
-
} from './types';
|
|
64
|
-
export { DegreeOfSuccess } from './types';
|
|
79
|
+
} from './types.js';
|
|
80
|
+
export { DegreeOfSuccess } from './types.js';
|
|
65
81
|
|
|
66
82
|
// * Public API
|
|
67
|
-
export { roll } from './roll';
|
|
68
|
-
export type { RollOptions } from './roll';
|
|
83
|
+
export { roll } from './roll.js';
|
|
84
|
+
export type { RollOptions } from './roll.js';
|
|
69
85
|
|
|
70
|
-
import
|
|
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';
|
|
71
89
|
|
|
72
|
-
export const VERSION: string =
|
|
90
|
+
export const VERSION: string = version;
|
package/src/lexer/lexer.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* @module lexer/lexer
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { RollParserErrorCode } from '../errors';
|
|
8
|
-
import { RollParserError } from '../errors';
|
|
9
|
-
import { type Token, TokenType } from './tokens';
|
|
7
|
+
import type { RollParserErrorCode } from '../errors.js';
|
|
8
|
+
import { RollParserError } from '../errors.js';
|
|
9
|
+
import { type Token, TokenType } from './tokens.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Error thrown when the lexer encounters an invalid character.
|
|
@@ -41,8 +41,29 @@ const IDENTIFIER_KEYWORDS: Record<string, TokenType> = {
|
|
|
41
41
|
abs: TokenType.FUNCTION,
|
|
42
42
|
max: TokenType.FUNCTION,
|
|
43
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,
|
|
44
49
|
};
|
|
45
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
|
+
|
|
46
67
|
/**
|
|
47
68
|
* Lexer for dice notation.
|
|
48
69
|
*
|
|
@@ -95,6 +116,11 @@ export class Lexer {
|
|
|
95
116
|
return this.scanIdentifier();
|
|
96
117
|
}
|
|
97
118
|
|
|
119
|
+
// * Variable reference
|
|
120
|
+
if (char === '@') {
|
|
121
|
+
return this.scanAt();
|
|
122
|
+
}
|
|
123
|
+
|
|
98
124
|
// * Operators and punctuation
|
|
99
125
|
this.advance();
|
|
100
126
|
|
|
@@ -120,6 +146,10 @@ export class Lexer {
|
|
|
120
146
|
return this.createTokenAt(TokenType.RPAREN, char, startPos);
|
|
121
147
|
case ',':
|
|
122
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);
|
|
123
153
|
case '>':
|
|
124
154
|
if (this.match('=')) {
|
|
125
155
|
return this.createTokenAt(TokenType.GREATER_EQUAL, '>=', startPos);
|
|
@@ -141,8 +171,13 @@ export class Lexer {
|
|
|
141
171
|
return this.createTokenAt(TokenType.EXPLODE_PENETRATING, '!p', startPos);
|
|
142
172
|
}
|
|
143
173
|
return this.createTokenAt(TokenType.EXPLODE, char, startPos);
|
|
144
|
-
default:
|
|
145
|
-
|
|
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
|
+
}
|
|
146
181
|
}
|
|
147
182
|
}
|
|
148
183
|
|
|
@@ -215,7 +250,55 @@ export class Lexer {
|
|
|
215
250
|
return this.createTokenAt(tokenType, lower, startPos);
|
|
216
251
|
}
|
|
217
252
|
|
|
218
|
-
throw new LexerError(
|
|
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);
|
|
219
302
|
}
|
|
220
303
|
|
|
221
304
|
private peek(): string {
|
|
@@ -250,16 +333,26 @@ export class Lexer {
|
|
|
250
333
|
return c >= 'a' && c <= 'z';
|
|
251
334
|
}
|
|
252
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
|
+
|
|
253
344
|
private isWhitespace(char: string): boolean {
|
|
254
345
|
return char === ' ' || char === '\t' || char === '\n' || char === '\r';
|
|
255
346
|
}
|
|
256
347
|
|
|
348
|
+
// ? Both factories run after the token's characters were consumed, so
|
|
349
|
+
// `this.pos` is the exclusive end offset.
|
|
257
350
|
private createToken(type: TokenType, value: string): Token {
|
|
258
|
-
return { type, value, position: this.pos };
|
|
351
|
+
return { type, value, position: this.pos, end: this.pos };
|
|
259
352
|
}
|
|
260
353
|
|
|
261
354
|
private createTokenAt(type: TokenType, value: string, position: number): Token {
|
|
262
|
-
return { type, value, position };
|
|
355
|
+
return { type, value, position, end: this.pos };
|
|
263
356
|
}
|
|
264
357
|
}
|
|
265
358
|
|
package/src/lexer/tokens.ts
CHANGED
|
@@ -126,12 +126,46 @@ export enum TokenType {
|
|
|
126
126
|
/** Versus operator: 'vs' */
|
|
127
127
|
VS = 29,
|
|
128
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
|
+
|
|
129
163
|
//
|
|
130
164
|
// * End of input
|
|
131
165
|
//
|
|
132
166
|
|
|
133
167
|
/** End of input marker */
|
|
134
|
-
EOF =
|
|
168
|
+
EOF = 37,
|
|
135
169
|
}
|
|
136
170
|
|
|
137
171
|
/**
|
|
@@ -142,6 +176,12 @@ export type Token = {
|
|
|
142
176
|
type: TokenType;
|
|
143
177
|
/** The raw string value from input (lowercased for identifiers) */
|
|
144
178
|
value: string;
|
|
145
|
-
/** Zero-based
|
|
179
|
+
/** Zero-based start offset in the input string (UTF-16 code units) */
|
|
146
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;
|
|
147
187
|
};
|