roll-parser 2.3.2 → 3.0.0-alpha.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/README.md +47 -178
- 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 +1775 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/evaluator/evaluator.d.ts +65 -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/explode.d.ts +44 -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/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 +1723 -0
- package/dist/index.mjs +1724 -0
- package/dist/lexer/lexer.d.ts +75 -0
- package/dist/lexer/lexer.d.ts.map +1 -0
- package/dist/lexer/tokens.d.ts +87 -0
- package/dist/lexer/tokens.d.ts.map +1 -0
- package/dist/parser/ast.d.ts +183 -0
- package/dist/parser/ast.d.ts.map +1 -0
- package/dist/parser/parser.d.ts +102 -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 +33 -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 +47 -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 +38 -0
- package/dist/testing.mjs +39 -0
- package/dist/types.d.ts +105 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +62 -43
- package/src/cli/args.ts +72 -0
- package/src/cli/format.ts +45 -0
- package/src/cli/index.ts +75 -0
- package/src/errors.ts +78 -0
- package/src/evaluator/evaluator.ts +837 -0
- package/src/evaluator/index.ts +14 -0
- package/src/evaluator/modifiers/compare.ts +29 -0
- package/src/evaluator/modifiers/explode.ts +215 -0
- package/src/evaluator/modifiers/keep-drop.ts +151 -0
- package/src/evaluator/modifiers/reroll.ts +172 -0
- package/src/evaluator/modifiers/success-count.ts +59 -0
- package/src/index.ts +72 -0
- package/src/lexer/lexer.ts +281 -0
- package/src/lexer/tokens.ts +147 -0
- package/src/parser/ast.ts +261 -0
- package/src/parser/parser.ts +716 -0
- package/src/rng/index.ts +8 -0
- package/src/rng/mock.ts +65 -0
- package/src/rng/seeded.ts +127 -0
- package/src/rng/types.ts +25 -0
- package/src/roll.ts +66 -0
- package/src/testing.ts +16 -0
- package/src/types.ts +112 -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,837 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST evaluator - transforms parsed AST into roll results.
|
|
3
|
+
*
|
|
4
|
+
* @module evaluator/evaluator
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RollParserErrorCode } from '../errors';
|
|
8
|
+
import { RollParserError } from '../errors';
|
|
9
|
+
import type {
|
|
10
|
+
ASTNode,
|
|
11
|
+
BinaryOpNode,
|
|
12
|
+
DiceNode,
|
|
13
|
+
ExplodeNode,
|
|
14
|
+
FateDiceNode,
|
|
15
|
+
FunctionCallNode,
|
|
16
|
+
ModifierNode,
|
|
17
|
+
RerollNode,
|
|
18
|
+
SuccessCountNode,
|
|
19
|
+
UnaryOpNode,
|
|
20
|
+
VersusNode,
|
|
21
|
+
} from '../parser/ast';
|
|
22
|
+
import { isModifier } from '../parser/ast';
|
|
23
|
+
import type { RNG } from '../rng/types';
|
|
24
|
+
import type { ComparePoint, DieResult, EvaluateOptions, RollResult } from '../types';
|
|
25
|
+
import { DegreeOfSuccess } from '../types';
|
|
26
|
+
import {
|
|
27
|
+
applyCompoundExplode,
|
|
28
|
+
applyPenetratingExplode,
|
|
29
|
+
applyStandardExplode,
|
|
30
|
+
buildShouldExplode,
|
|
31
|
+
DEFAULT_MAX_EXPLODE_ITERATIONS,
|
|
32
|
+
} from './modifiers/explode';
|
|
33
|
+
import {
|
|
34
|
+
applyDropHighest,
|
|
35
|
+
applyDropLowest,
|
|
36
|
+
applyKeepHighest,
|
|
37
|
+
applyKeepLowest,
|
|
38
|
+
markAllKept,
|
|
39
|
+
sumKeptDice,
|
|
40
|
+
} from './modifiers/keep-drop';
|
|
41
|
+
import {
|
|
42
|
+
applyRecursiveReroll,
|
|
43
|
+
applyRerollOnce,
|
|
44
|
+
DEFAULT_MAX_REROLL_ITERATIONS,
|
|
45
|
+
} from './modifiers/reroll';
|
|
46
|
+
import { countSuccesses } from './modifiers/success-count';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Error thrown during AST evaluation.
|
|
50
|
+
*/
|
|
51
|
+
export class EvaluatorError extends RollParserError {
|
|
52
|
+
readonly nodeType: string | undefined;
|
|
53
|
+
|
|
54
|
+
constructor(message: string, code: RollParserErrorCode, nodeType?: string) {
|
|
55
|
+
super(message, code);
|
|
56
|
+
this.name = 'EvaluatorError';
|
|
57
|
+
this.nodeType = nodeType ?? undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Default maximum total dice allowed per evaluation. */
|
|
62
|
+
export const DEFAULT_MAX_DICE = 10_000;
|
|
63
|
+
|
|
64
|
+
export { DEFAULT_MAX_EXPLODE_ITERATIONS, DEFAULT_MAX_REROLL_ITERATIONS };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Per-evaluation shared environment (created once, shared across all branches).
|
|
68
|
+
*
|
|
69
|
+
* Exported for use by modifier implementations under `./modifiers/*`. Not part
|
|
70
|
+
* of the public library API.
|
|
71
|
+
*/
|
|
72
|
+
export type EvalEnv = {
|
|
73
|
+
readonly maxDice: number;
|
|
74
|
+
readonly maxExplodeIterations: number;
|
|
75
|
+
readonly maxRerollIterations: number;
|
|
76
|
+
totalDiceRolled: number;
|
|
77
|
+
/**
|
|
78
|
+
* Set to `true` by `evalSuccessCount`. Propagates through the shared env
|
|
79
|
+
* so `evaluate()` can include `successes`/`failures` fields even when no
|
|
80
|
+
* die was tagged (impossible threshold).
|
|
81
|
+
*/
|
|
82
|
+
hasSuccessCount: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* `true` while the evaluator is inside a `VersusNode`'s roll or DC
|
|
85
|
+
* sub-evaluation. `evalVersus` rejects nesting via this flag — catches
|
|
86
|
+
* paren-nested versus (`1d20 vs (5 vs 3)`) that slip past the parser's
|
|
87
|
+
* left-chain check.
|
|
88
|
+
*/
|
|
89
|
+
insideVersus: boolean;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Per-branch mutable accumulator for tracking rolls and output during recursion.
|
|
94
|
+
*/
|
|
95
|
+
type EvalContext = {
|
|
96
|
+
rolls: DieResult[];
|
|
97
|
+
expressionParts: string[];
|
|
98
|
+
renderedParts: string[];
|
|
99
|
+
/**
|
|
100
|
+
* Populated by `evalVersus` with the resolved degree and natural value.
|
|
101
|
+
* `evaluate()` reads this from the top-level ctx to surface `degree` and
|
|
102
|
+
* `natural` on the final `RollResult`. Only populated when `vs` is the
|
|
103
|
+
* root of the expression.
|
|
104
|
+
*/
|
|
105
|
+
versusMetadata?: {
|
|
106
|
+
degree: DegreeOfSuccess;
|
|
107
|
+
natural: number | undefined;
|
|
108
|
+
dcTotal: number;
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Flattened representation of a keep/drop modifier for chain evaluation.
|
|
114
|
+
*/
|
|
115
|
+
type ModifierSpec = {
|
|
116
|
+
modifier: 'keep' | 'drop';
|
|
117
|
+
selector: 'highest' | 'lowest';
|
|
118
|
+
count: number;
|
|
119
|
+
code: string;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Creates a new die result with critical/fumble detection.
|
|
124
|
+
*/
|
|
125
|
+
function createDieResult(sides: number, result: number): DieResult {
|
|
126
|
+
return {
|
|
127
|
+
sides,
|
|
128
|
+
result,
|
|
129
|
+
modifiers: [],
|
|
130
|
+
critical: result === sides && sides > 1,
|
|
131
|
+
fumble: result === 1,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Creates a Fate/Fudge die result. Uses `sides = 0` as a sentinel — Fate dice
|
|
137
|
+
* have no max-face concept, so `critical` and `fumble` are always `false`.
|
|
138
|
+
*/
|
|
139
|
+
function createFateDieResult(result: number): DieResult {
|
|
140
|
+
return {
|
|
141
|
+
sides: 0,
|
|
142
|
+
result,
|
|
143
|
+
modifiers: [],
|
|
144
|
+
critical: false,
|
|
145
|
+
fumble: false,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Renders dice results for display. Marker priority: dropped wins over
|
|
151
|
+
* success/failure (dropped dice are never counted), success wins over
|
|
152
|
+
* failure (a die cannot be both). Example: `[~~1~~, **6**, __1__, 3]`.
|
|
153
|
+
*/
|
|
154
|
+
function renderDice(dice: DieResult[]): string {
|
|
155
|
+
const parts = dice.map((die) => {
|
|
156
|
+
if (die.modifiers.includes('dropped')) {
|
|
157
|
+
return `~~${die.result}~~`;
|
|
158
|
+
}
|
|
159
|
+
if (die.modifiers.includes('success')) {
|
|
160
|
+
return `**${die.result}**`;
|
|
161
|
+
}
|
|
162
|
+
if (die.modifiers.includes('failure')) {
|
|
163
|
+
return `__${die.result}__`;
|
|
164
|
+
}
|
|
165
|
+
return String(die.result);
|
|
166
|
+
});
|
|
167
|
+
return `[${parts.join(', ')}]`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Evaluates an AST node, returning value and updating context.
|
|
172
|
+
*/
|
|
173
|
+
function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
|
|
174
|
+
switch (node.type) {
|
|
175
|
+
case 'Literal':
|
|
176
|
+
return evalLiteral(node.value, ctx);
|
|
177
|
+
|
|
178
|
+
case 'Dice':
|
|
179
|
+
return evalDice(node, rng, ctx, env);
|
|
180
|
+
|
|
181
|
+
case 'FateDice':
|
|
182
|
+
return evalFateDice(node, rng, ctx, env);
|
|
183
|
+
|
|
184
|
+
case 'BinaryOp':
|
|
185
|
+
return evalBinaryOp(node, rng, ctx, env);
|
|
186
|
+
|
|
187
|
+
case 'UnaryOp':
|
|
188
|
+
return evalUnaryOp(node, rng, ctx, env);
|
|
189
|
+
|
|
190
|
+
case 'Modifier':
|
|
191
|
+
return evalModifier(node, rng, ctx, env);
|
|
192
|
+
|
|
193
|
+
case 'Explode':
|
|
194
|
+
return evalExplode(node, rng, ctx, env);
|
|
195
|
+
|
|
196
|
+
case 'Reroll':
|
|
197
|
+
return evalReroll(node, rng, ctx, env);
|
|
198
|
+
|
|
199
|
+
case 'SuccessCount':
|
|
200
|
+
return evalSuccessCount(node, rng, ctx, env);
|
|
201
|
+
|
|
202
|
+
case 'Versus':
|
|
203
|
+
return evalVersus(node, rng, ctx, env);
|
|
204
|
+
|
|
205
|
+
case 'FunctionCall':
|
|
206
|
+
return evalFunctionCall(node, rng, ctx, env);
|
|
207
|
+
|
|
208
|
+
default: {
|
|
209
|
+
const exhaustive: never = node;
|
|
210
|
+
throw new EvaluatorError(
|
|
211
|
+
`Unknown node type: ${(exhaustive as ASTNode).type}`,
|
|
212
|
+
'UNKNOWN_NODE_TYPE',
|
|
213
|
+
(exhaustive as ASTNode).type,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function evalLiteral(value: number, ctx: EvalContext): number {
|
|
220
|
+
ctx.expressionParts.push(String(value));
|
|
221
|
+
ctx.renderedParts.push(String(value));
|
|
222
|
+
return value;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
|
|
226
|
+
const count = evalNode(
|
|
227
|
+
node.count,
|
|
228
|
+
rng,
|
|
229
|
+
{ rolls: [], expressionParts: [], renderedParts: [] },
|
|
230
|
+
env,
|
|
231
|
+
);
|
|
232
|
+
const sides = evalNode(
|
|
233
|
+
node.sides,
|
|
234
|
+
rng,
|
|
235
|
+
{ rolls: [], expressionParts: [], renderedParts: [] },
|
|
236
|
+
env,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
if (!Number.isInteger(count) || count < 0) {
|
|
240
|
+
throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', 'Dice');
|
|
241
|
+
}
|
|
242
|
+
if (!Number.isInteger(sides) || sides < 1) {
|
|
243
|
+
throw new EvaluatorError(`Invalid dice sides: ${sides}`, 'INVALID_DICE_SIDES', 'Dice');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (env.totalDiceRolled + count > env.maxDice) {
|
|
247
|
+
throw new EvaluatorError(
|
|
248
|
+
`Total dice count ${env.totalDiceRolled + count} exceeds limit of ${env.maxDice}`,
|
|
249
|
+
'DICE_LIMIT_EXCEEDED',
|
|
250
|
+
'Dice',
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
env.totalDiceRolled += count;
|
|
254
|
+
|
|
255
|
+
const dice: DieResult[] = [];
|
|
256
|
+
for (let i = 0; i < count; i++) {
|
|
257
|
+
const result = rng.nextInt(1, sides);
|
|
258
|
+
dice.push(createDieResult(sides, result));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const markedDice = markAllKept(dice);
|
|
262
|
+
ctx.rolls.push(...markedDice);
|
|
263
|
+
|
|
264
|
+
const total = sumKeptDice(markedDice);
|
|
265
|
+
const notation = `${count}d${sides}`;
|
|
266
|
+
|
|
267
|
+
ctx.expressionParts.push(notation);
|
|
268
|
+
ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`);
|
|
269
|
+
|
|
270
|
+
return total;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
|
|
274
|
+
const count = evalNode(
|
|
275
|
+
node.count,
|
|
276
|
+
rng,
|
|
277
|
+
{ rolls: [], expressionParts: [], renderedParts: [] },
|
|
278
|
+
env,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
if (!Number.isInteger(count) || count < 0) {
|
|
282
|
+
throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', 'FateDice');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (env.totalDiceRolled + count > env.maxDice) {
|
|
286
|
+
throw new EvaluatorError(
|
|
287
|
+
`Total dice count ${env.totalDiceRolled + count} exceeds limit of ${env.maxDice}`,
|
|
288
|
+
'DICE_LIMIT_EXCEEDED',
|
|
289
|
+
'FateDice',
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
env.totalDiceRolled += count;
|
|
293
|
+
|
|
294
|
+
const dice: DieResult[] = [];
|
|
295
|
+
for (let i = 0; i < count; i++) {
|
|
296
|
+
const result = rng.nextInt(-1, 1);
|
|
297
|
+
dice.push(createFateDieResult(result));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const markedDice = markAllKept(dice);
|
|
301
|
+
ctx.rolls.push(...markedDice);
|
|
302
|
+
|
|
303
|
+
const total = sumKeptDice(markedDice);
|
|
304
|
+
const notation = `${count}dF`;
|
|
305
|
+
|
|
306
|
+
ctx.expressionParts.push(notation);
|
|
307
|
+
ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`);
|
|
308
|
+
|
|
309
|
+
return total;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
|
|
313
|
+
const leftCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
314
|
+
const rightCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
315
|
+
|
|
316
|
+
const left = evalNode(node.left, rng, leftCtx, env);
|
|
317
|
+
const right = evalNode(node.right, rng, rightCtx, env);
|
|
318
|
+
|
|
319
|
+
ctx.rolls.push(...leftCtx.rolls, ...rightCtx.rolls);
|
|
320
|
+
|
|
321
|
+
const leftExpr = leftCtx.expressionParts.join('');
|
|
322
|
+
const rightExpr = rightCtx.expressionParts.join('');
|
|
323
|
+
const leftRendered = leftCtx.renderedParts.join('');
|
|
324
|
+
const rightRendered = rightCtx.renderedParts.join('');
|
|
325
|
+
|
|
326
|
+
ctx.expressionParts.push(`${leftExpr} ${node.operator} ${rightExpr}`);
|
|
327
|
+
ctx.renderedParts.push(`${leftRendered} ${node.operator} ${rightRendered}`);
|
|
328
|
+
|
|
329
|
+
switch (node.operator) {
|
|
330
|
+
case '+':
|
|
331
|
+
return left + right;
|
|
332
|
+
case '-':
|
|
333
|
+
return left - right;
|
|
334
|
+
case '*':
|
|
335
|
+
return left * right;
|
|
336
|
+
case '/':
|
|
337
|
+
if (right === 0) {
|
|
338
|
+
throw new EvaluatorError('Division by zero', 'DIVISION_BY_ZERO', 'BinaryOp');
|
|
339
|
+
}
|
|
340
|
+
return left / right;
|
|
341
|
+
case '%':
|
|
342
|
+
if (right === 0) {
|
|
343
|
+
throw new EvaluatorError('Modulo by zero', 'MODULO_BY_ZERO', 'BinaryOp');
|
|
344
|
+
}
|
|
345
|
+
return left % right;
|
|
346
|
+
case '**':
|
|
347
|
+
return left ** right;
|
|
348
|
+
default: {
|
|
349
|
+
const exhaustive: never = node.operator;
|
|
350
|
+
throw new EvaluatorError(`Unknown operator: ${exhaustive}`, 'UNKNOWN_OPERATOR', 'BinaryOp');
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
|
|
356
|
+
const innerCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
357
|
+
const value = evalNode(node.operand, rng, innerCtx, env);
|
|
358
|
+
|
|
359
|
+
ctx.rolls.push(...innerCtx.rolls);
|
|
360
|
+
|
|
361
|
+
const innerExpr = innerCtx.expressionParts.join('');
|
|
362
|
+
const innerRendered = innerCtx.renderedParts.join('');
|
|
363
|
+
|
|
364
|
+
ctx.expressionParts.push(`-${innerExpr}`);
|
|
365
|
+
ctx.renderedParts.push(`-${innerRendered}`);
|
|
366
|
+
|
|
367
|
+
return -value;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function evalFunctionCall(
|
|
371
|
+
node: FunctionCallNode,
|
|
372
|
+
rng: RNG,
|
|
373
|
+
ctx: EvalContext,
|
|
374
|
+
env: EvalEnv,
|
|
375
|
+
): number {
|
|
376
|
+
const argCtxs: EvalContext[] = [];
|
|
377
|
+
const values: number[] = [];
|
|
378
|
+
|
|
379
|
+
for (const arg of node.args) {
|
|
380
|
+
const argCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
381
|
+
values.push(evalNode(arg, rng, argCtx, env));
|
|
382
|
+
argCtxs.push(argCtx);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
for (const argCtx of argCtxs) {
|
|
386
|
+
ctx.rolls.push(...argCtx.rolls);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const argExprs = argCtxs.map((c) => c.expressionParts.join(''));
|
|
390
|
+
const argRendereds = argCtxs.map((c) => c.renderedParts.join(''));
|
|
391
|
+
|
|
392
|
+
ctx.expressionParts.push(`${node.name}(${argExprs.join(', ')})`);
|
|
393
|
+
ctx.renderedParts.push(`${node.name}(${argRendereds.join(', ')})`);
|
|
394
|
+
|
|
395
|
+
return applyFunction(node.name, values);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function applyFunction(name: string, values: number[]): number {
|
|
399
|
+
switch (name) {
|
|
400
|
+
case 'floor':
|
|
401
|
+
return Math.floor(requireUnaryArg(name, values));
|
|
402
|
+
case 'ceil':
|
|
403
|
+
return Math.ceil(requireUnaryArg(name, values));
|
|
404
|
+
case 'round':
|
|
405
|
+
return Math.round(requireUnaryArg(name, values));
|
|
406
|
+
case 'abs':
|
|
407
|
+
return Math.abs(requireUnaryArg(name, values));
|
|
408
|
+
case 'max':
|
|
409
|
+
return Math.max(...values);
|
|
410
|
+
case 'min':
|
|
411
|
+
return Math.min(...values);
|
|
412
|
+
default:
|
|
413
|
+
throw new EvaluatorError(`Unknown function: ${name}`, 'UNKNOWN_FUNCTION', 'FunctionCall');
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function requireUnaryArg(name: string, values: number[]): number {
|
|
418
|
+
const [x] = values;
|
|
419
|
+
if (x === undefined) {
|
|
420
|
+
// ? Unreachable: parser validates arity before evaluation. Defensive for
|
|
421
|
+
// `noNonNullAssertion`.
|
|
422
|
+
throw new EvaluatorError(
|
|
423
|
+
`Function '${name}' requires an argument`,
|
|
424
|
+
'UNKNOWN_FUNCTION',
|
|
425
|
+
'FunctionCall',
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
return x;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Walks a nested ModifierNode chain, collecting specs outermost-first,
|
|
433
|
+
* then reverses to notation order (innermost-first).
|
|
434
|
+
*/
|
|
435
|
+
function flattenModifierChain(
|
|
436
|
+
node: ModifierNode,
|
|
437
|
+
rng: RNG,
|
|
438
|
+
env: EvalEnv,
|
|
439
|
+
): { specs: ModifierSpec[]; baseTarget: ASTNode } {
|
|
440
|
+
const specs: ModifierSpec[] = [];
|
|
441
|
+
let current: ASTNode = node;
|
|
442
|
+
|
|
443
|
+
while (isModifier(current)) {
|
|
444
|
+
const countCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
445
|
+
const modCount = evalNode(current.count, rng, countCtx, env);
|
|
446
|
+
|
|
447
|
+
if (!Number.isInteger(modCount) || modCount < 0) {
|
|
448
|
+
throw new EvaluatorError(
|
|
449
|
+
`Invalid modifier count: ${modCount}`,
|
|
450
|
+
'INVALID_MODIFIER_COUNT',
|
|
451
|
+
'Modifier',
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const code =
|
|
456
|
+
current.modifier === 'keep'
|
|
457
|
+
? current.selector === 'highest'
|
|
458
|
+
? 'kh'
|
|
459
|
+
: 'kl'
|
|
460
|
+
: current.selector === 'highest'
|
|
461
|
+
? 'dh'
|
|
462
|
+
: 'dl';
|
|
463
|
+
|
|
464
|
+
specs.push({ modifier: current.modifier, selector: current.selector, count: modCount, code });
|
|
465
|
+
current = current.target;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
specs.reverse();
|
|
469
|
+
return { specs, baseTarget: current };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Applies a single modifier spec to a dice pool.
|
|
474
|
+
*/
|
|
475
|
+
function applyModifierSpec(dice: DieResult[], spec: ModifierSpec): DieResult[] {
|
|
476
|
+
if (spec.modifier === 'keep') {
|
|
477
|
+
return spec.selector === 'highest'
|
|
478
|
+
? applyKeepHighest(dice, spec.count)
|
|
479
|
+
: applyKeepLowest(dice, spec.count);
|
|
480
|
+
}
|
|
481
|
+
return spec.selector === 'highest'
|
|
482
|
+
? applyDropHighest(dice, spec.count)
|
|
483
|
+
: applyDropLowest(dice, spec.count);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Applies each modifier independently to the full dice pool
|
|
488
|
+
* and merges drop sets via union. A die is dropped if ANY modifier dropped it.
|
|
489
|
+
*/
|
|
490
|
+
function mergeDropSets(baseDice: DieResult[], specs: ModifierSpec[]): DieResult[] {
|
|
491
|
+
const droppedIndices = new Set<number>();
|
|
492
|
+
|
|
493
|
+
for (const spec of specs) {
|
|
494
|
+
const result = applyModifierSpec(baseDice, spec);
|
|
495
|
+
for (let i = 0; i < result.length; i++) {
|
|
496
|
+
if (result[i]?.modifiers.includes('dropped')) {
|
|
497
|
+
droppedIndices.add(i);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return baseDice.map((die, index) => ({
|
|
503
|
+
...die,
|
|
504
|
+
modifiers: droppedIndices.has(index)
|
|
505
|
+
? [...die.modifiers.filter((m) => m !== 'kept' && m !== 'dropped'), 'dropped']
|
|
506
|
+
: [...die.modifiers.filter((m) => m !== 'dropped' && m !== 'kept'), 'kept'],
|
|
507
|
+
}));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Builds the notation string for an explode modifier, e.g. `!`, `!!>=3`, `!p>5`.
|
|
512
|
+
*/
|
|
513
|
+
function formatExplodeCode(
|
|
514
|
+
variant: ExplodeNode['variant'],
|
|
515
|
+
threshold: ComparePoint | undefined,
|
|
516
|
+
thresholdValue: number | undefined,
|
|
517
|
+
): string {
|
|
518
|
+
const marker = variant === 'standard' ? '!' : variant === 'compound' ? '!!' : '!p';
|
|
519
|
+
if (threshold == null || thresholdValue == null) return marker;
|
|
520
|
+
return `${marker}${threshold.operator}${thresholdValue}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
|
|
524
|
+
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
525
|
+
const targetValue = evalNode(node.target, rng, targetCtx, env);
|
|
526
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
527
|
+
|
|
528
|
+
let thresholdValue: number | undefined;
|
|
529
|
+
if (node.threshold != null) {
|
|
530
|
+
const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
531
|
+
thresholdValue = evalNode(node.threshold.value, rng, thresholdCtx, env);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const code = formatExplodeCode(node.variant, node.threshold, thresholdValue);
|
|
535
|
+
|
|
536
|
+
// No-op when the target produced no dice (e.g., `(1+2)!`).
|
|
537
|
+
if (targetCtx.rolls.length === 0) {
|
|
538
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
539
|
+
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
540
|
+
return targetValue;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
|
|
544
|
+
|
|
545
|
+
const expanded =
|
|
546
|
+
node.variant === 'standard'
|
|
547
|
+
? applyStandardExplode(targetCtx.rolls, shouldExplode, rng, env)
|
|
548
|
+
: node.variant === 'compound'
|
|
549
|
+
? applyCompoundExplode(targetCtx.rolls, shouldExplode, rng, env)
|
|
550
|
+
: applyPenetratingExplode(targetCtx.rolls, shouldExplode, rng, env);
|
|
551
|
+
|
|
552
|
+
ctx.rolls.push(...expanded);
|
|
553
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
554
|
+
// ? Include the explode code in rendered output so readers can attribute
|
|
555
|
+
// the extra dice. Modifier rendering (kh/dl) skips its code because
|
|
556
|
+
// dropped dice are visible; explosion-origin is otherwise invisible.
|
|
557
|
+
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
|
|
558
|
+
|
|
559
|
+
return sumKeptDice(expanded);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
|
|
563
|
+
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
564
|
+
evalNode(node.target, rng, targetCtx, env);
|
|
565
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
566
|
+
|
|
567
|
+
const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
568
|
+
const thresholdValue = evalNode(node.condition.value, rng, thresholdCtx, env);
|
|
569
|
+
|
|
570
|
+
const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
|
|
571
|
+
|
|
572
|
+
// No-op when the target produced no dice (e.g., `(1+2)r<5`).
|
|
573
|
+
if (targetCtx.rolls.length === 0) {
|
|
574
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
575
|
+
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
576
|
+
return sumKeptDice(targetCtx.rolls);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const pool = node.once
|
|
580
|
+
? applyRerollOnce(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env)
|
|
581
|
+
: applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
|
|
582
|
+
|
|
583
|
+
ctx.rolls.push(...pool);
|
|
584
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
585
|
+
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
|
|
586
|
+
|
|
587
|
+
return sumKeptDice(pool);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function evalModifier(node: ModifierNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
|
|
591
|
+
const { specs, baseTarget } = flattenModifierChain(node, rng, env);
|
|
592
|
+
|
|
593
|
+
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
594
|
+
evalNode(baseTarget, rng, targetCtx, env);
|
|
595
|
+
|
|
596
|
+
const mergedDice = mergeDropSets(targetCtx.rolls, specs);
|
|
597
|
+
|
|
598
|
+
ctx.rolls.push(...mergedDice);
|
|
599
|
+
|
|
600
|
+
const total = sumKeptDice(mergedDice);
|
|
601
|
+
|
|
602
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
603
|
+
const modifierCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
604
|
+
|
|
605
|
+
ctx.expressionParts.push(`${targetExpr}${modifierCodes}`);
|
|
606
|
+
ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
|
|
607
|
+
|
|
608
|
+
return total;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function resolveThreshold(
|
|
612
|
+
value: ASTNode,
|
|
613
|
+
rng: RNG,
|
|
614
|
+
env: EvalEnv,
|
|
615
|
+
role: 'threshold' | 'fail threshold',
|
|
616
|
+
): number {
|
|
617
|
+
const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
618
|
+
const resolved = evalNode(value, rng, thresholdCtx, env);
|
|
619
|
+
|
|
620
|
+
if (!Number.isFinite(resolved)) {
|
|
621
|
+
throw new EvaluatorError(`Invalid ${role}: ${resolved}`, 'INVALID_THRESHOLD', 'SuccessCount');
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return resolved;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function evalSuccessCount(
|
|
628
|
+
node: SuccessCountNode,
|
|
629
|
+
rng: RNG,
|
|
630
|
+
ctx: EvalContext,
|
|
631
|
+
env: EvalEnv,
|
|
632
|
+
): number {
|
|
633
|
+
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
634
|
+
const targetValue = evalNode(node.target, rng, targetCtx, env);
|
|
635
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
636
|
+
|
|
637
|
+
const thresholdValue = resolveThreshold(node.threshold.value, rng, env, 'threshold');
|
|
638
|
+
const failValue =
|
|
639
|
+
node.failThreshold != null
|
|
640
|
+
? resolveThreshold(node.failThreshold.value, rng, env, 'fail threshold')
|
|
641
|
+
: undefined;
|
|
642
|
+
|
|
643
|
+
const code = `${node.threshold.operator}${thresholdValue}${
|
|
644
|
+
failValue != null ? `f${failValue}` : ''
|
|
645
|
+
}`;
|
|
646
|
+
|
|
647
|
+
// No-op when the target produced no dice in its pool. `containsDice` should
|
|
648
|
+
// already reject this at parse time, but guard defensively.
|
|
649
|
+
if (targetCtx.rolls.length === 0) {
|
|
650
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
651
|
+
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
652
|
+
return targetValue;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const result = countSuccesses(
|
|
656
|
+
targetCtx.rolls,
|
|
657
|
+
{ operator: node.threshold.operator, value: thresholdValue },
|
|
658
|
+
failValue != null && node.failThreshold != null
|
|
659
|
+
? { operator: node.failThreshold.operator, value: failValue }
|
|
660
|
+
: undefined,
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
env.hasSuccessCount = true;
|
|
664
|
+
|
|
665
|
+
ctx.rolls.push(...targetCtx.rolls);
|
|
666
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
667
|
+
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
|
|
668
|
+
|
|
669
|
+
return result.total;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Extracts the "natural" d20 value from a roll-side dice pool. Returns the
|
|
674
|
+
* single value when exactly one kept d20 is present; otherwise `undefined`.
|
|
675
|
+
*
|
|
676
|
+
* Excludes dropped (`kh`/`kl`/`dh`/`dl`) and rerolled (`r`/`ro`) dice — these
|
|
677
|
+
* aren't the final kept result. Multiple kept d20s (e.g., `1d20+1d20`) yield
|
|
678
|
+
* `undefined` so no ambiguous upgrade/downgrade is applied.
|
|
679
|
+
*/
|
|
680
|
+
function extractNatural(rolls: DieResult[]): number | undefined {
|
|
681
|
+
const keptD20s = rolls.filter(
|
|
682
|
+
(d) => d.sides === 20 && !d.modifiers.includes('dropped') && !d.modifiers.includes('rerolled'),
|
|
683
|
+
);
|
|
684
|
+
return keptD20s.length === 1 ? keptD20s[0]?.result : undefined;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* PF2e degree of success: compares `total` to `dc` at three thresholds and
|
|
689
|
+
* applies natural 20 upgrade / natural 1 downgrade with clamping.
|
|
690
|
+
*/
|
|
691
|
+
function calculateDegree(total: number, dc: number, natural: number | undefined): DegreeOfSuccess {
|
|
692
|
+
let degree: DegreeOfSuccess;
|
|
693
|
+
if (total >= dc + 10) degree = DegreeOfSuccess.CriticalSuccess;
|
|
694
|
+
else if (total >= dc) degree = DegreeOfSuccess.Success;
|
|
695
|
+
else if (total > dc - 10) degree = DegreeOfSuccess.Failure;
|
|
696
|
+
else degree = DegreeOfSuccess.CriticalFailure;
|
|
697
|
+
|
|
698
|
+
if (natural === 20 && degree < DegreeOfSuccess.CriticalSuccess) degree++;
|
|
699
|
+
if (natural === 1 && degree > DegreeOfSuccess.CriticalFailure) degree--;
|
|
700
|
+
|
|
701
|
+
return degree;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function degreeLabel(degree: DegreeOfSuccess): string {
|
|
705
|
+
switch (degree) {
|
|
706
|
+
case DegreeOfSuccess.CriticalFailure:
|
|
707
|
+
return 'Critical Failure';
|
|
708
|
+
case DegreeOfSuccess.Failure:
|
|
709
|
+
return 'Failure';
|
|
710
|
+
case DegreeOfSuccess.Success:
|
|
711
|
+
return 'Success';
|
|
712
|
+
case DegreeOfSuccess.CriticalSuccess:
|
|
713
|
+
return 'Critical Success';
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
|
|
718
|
+
if (env.insideVersus) {
|
|
719
|
+
throw new EvaluatorError('Cannot nest versus operators', 'NESTED_VERSUS', 'Versus');
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
env.insideVersus = true;
|
|
723
|
+
try {
|
|
724
|
+
const rollCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
725
|
+
const rollTotal = evalNode(node.roll, rng, rollCtx, env);
|
|
726
|
+
// ? Extract natural from rollCtx directly — the roll-side pool is isolated
|
|
727
|
+
// here, so no index slicing on the merged parent pool is needed.
|
|
728
|
+
const natural = extractNatural(rollCtx.rolls);
|
|
729
|
+
|
|
730
|
+
const dcCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
731
|
+
const dcTotal = evalNode(node.dc, rng, dcCtx, env);
|
|
732
|
+
|
|
733
|
+
const degree = calculateDegree(rollTotal, dcTotal, natural);
|
|
734
|
+
|
|
735
|
+
ctx.rolls.push(...rollCtx.rolls, ...dcCtx.rolls);
|
|
736
|
+
|
|
737
|
+
const rollExpr = rollCtx.expressionParts.join('');
|
|
738
|
+
const dcExpr = dcCtx.expressionParts.join('');
|
|
739
|
+
const rollRendered = rollCtx.renderedParts.join('');
|
|
740
|
+
const dcRendered = dcCtx.renderedParts.join('');
|
|
741
|
+
|
|
742
|
+
ctx.expressionParts.push(`${rollExpr} vs ${dcExpr}`);
|
|
743
|
+
ctx.renderedParts.push(`${rollRendered} vs ${dcRendered}`);
|
|
744
|
+
ctx.versusMetadata = { degree, natural, dcTotal };
|
|
745
|
+
|
|
746
|
+
return rollTotal;
|
|
747
|
+
} finally {
|
|
748
|
+
env.insideVersus = false;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Evaluates a parsed AST and returns the roll result.
|
|
754
|
+
*
|
|
755
|
+
* @param ast - The parsed AST node
|
|
756
|
+
* @param rng - Random number generator to use for dice rolls
|
|
757
|
+
* @param options - Optional evaluation options
|
|
758
|
+
* @returns Complete roll result with total and metadata
|
|
759
|
+
*
|
|
760
|
+
* @example
|
|
761
|
+
* ```typescript
|
|
762
|
+
* const ast = parse('2d6+3');
|
|
763
|
+
* const rng = new SeededRNG('test');
|
|
764
|
+
* const result = evaluate(ast, rng);
|
|
765
|
+
* console.log(result.total); // Sum of dice plus 3
|
|
766
|
+
* ```
|
|
767
|
+
*/
|
|
768
|
+
export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}): RollResult {
|
|
769
|
+
const maxDice =
|
|
770
|
+
options.maxDice != null && Number.isFinite(options.maxDice) && options.maxDice > 0
|
|
771
|
+
? Math.floor(options.maxDice)
|
|
772
|
+
: DEFAULT_MAX_DICE;
|
|
773
|
+
|
|
774
|
+
const maxExplodeIterations =
|
|
775
|
+
options.maxExplodeIterations != null &&
|
|
776
|
+
Number.isFinite(options.maxExplodeIterations) &&
|
|
777
|
+
options.maxExplodeIterations >= 0
|
|
778
|
+
? Math.floor(options.maxExplodeIterations)
|
|
779
|
+
: DEFAULT_MAX_EXPLODE_ITERATIONS;
|
|
780
|
+
|
|
781
|
+
const maxRerollIterations =
|
|
782
|
+
options.maxRerollIterations != null &&
|
|
783
|
+
Number.isFinite(options.maxRerollIterations) &&
|
|
784
|
+
options.maxRerollIterations >= 0
|
|
785
|
+
? Math.floor(options.maxRerollIterations)
|
|
786
|
+
: DEFAULT_MAX_REROLL_ITERATIONS;
|
|
787
|
+
|
|
788
|
+
const env: EvalEnv = {
|
|
789
|
+
maxDice,
|
|
790
|
+
maxExplodeIterations,
|
|
791
|
+
maxRerollIterations,
|
|
792
|
+
totalDiceRolled: 0,
|
|
793
|
+
hasSuccessCount: false,
|
|
794
|
+
insideVersus: false,
|
|
795
|
+
};
|
|
796
|
+
const ctx: EvalContext = {
|
|
797
|
+
rolls: [],
|
|
798
|
+
expressionParts: [],
|
|
799
|
+
renderedParts: [],
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
const total = evalNode(ast, rng, ctx, env);
|
|
803
|
+
|
|
804
|
+
const expression = ctx.expressionParts.join('');
|
|
805
|
+
// ? Versus replaces the numeric total with the degree label in the rendered
|
|
806
|
+
// form; `RollResult.total` remains the numeric roll total.
|
|
807
|
+
const trailing = ctx.versusMetadata ? degreeLabel(ctx.versusMetadata.degree) : String(total);
|
|
808
|
+
const rendered = `${ctx.renderedParts.join('')} = ${trailing}`;
|
|
809
|
+
|
|
810
|
+
const result: RollResult = {
|
|
811
|
+
total,
|
|
812
|
+
notation: options.notation ?? expression,
|
|
813
|
+
expression,
|
|
814
|
+
rendered,
|
|
815
|
+
rolls: ctx.rolls,
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
if (env.hasSuccessCount) {
|
|
819
|
+
let successes = 0;
|
|
820
|
+
let failures = 0;
|
|
821
|
+
for (const die of ctx.rolls) {
|
|
822
|
+
if (die.modifiers.includes('success')) successes += 1;
|
|
823
|
+
else if (die.modifiers.includes('failure')) failures += 1;
|
|
824
|
+
}
|
|
825
|
+
result.successes = successes;
|
|
826
|
+
result.failures = failures;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (ctx.versusMetadata) {
|
|
830
|
+
result.degree = ctx.versusMetadata.degree;
|
|
831
|
+
if (ctx.versusMetadata.natural !== undefined) {
|
|
832
|
+
result.natural = ctx.versusMetadata.natural;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return result;
|
|
837
|
+
}
|