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
|
@@ -4,32 +4,47 @@
|
|
|
4
4
|
* @module evaluator/evaluator
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { RollParserErrorCode } from '../errors';
|
|
8
|
-
import { RollParserError } from '../errors';
|
|
7
|
+
import type { RollParserErrorCode } from '../errors.js';
|
|
8
|
+
import { RollParserError } from '../errors.js';
|
|
9
9
|
import type {
|
|
10
10
|
ASTNode,
|
|
11
11
|
BinaryOpNode,
|
|
12
|
+
CritThreshold,
|
|
13
|
+
CritThresholdNode,
|
|
12
14
|
DiceNode,
|
|
13
15
|
ExplodeNode,
|
|
14
16
|
FateDiceNode,
|
|
15
17
|
FunctionCallNode,
|
|
18
|
+
GroupedNode,
|
|
19
|
+
GroupNode,
|
|
20
|
+
LiteralNode,
|
|
16
21
|
ModifierNode,
|
|
17
22
|
RerollNode,
|
|
23
|
+
SortNode,
|
|
18
24
|
SuccessCountNode,
|
|
19
25
|
UnaryOpNode,
|
|
26
|
+
VariableNode,
|
|
20
27
|
VersusNode,
|
|
21
|
-
} from '../parser/ast';
|
|
22
|
-
import { isModifier } from '../parser/ast';
|
|
23
|
-
import type { RNG } from '../rng/types';
|
|
24
|
-
import type {
|
|
25
|
-
|
|
28
|
+
} from '../parser/ast.js';
|
|
29
|
+
import { isModifier } from '../parser/ast.js';
|
|
30
|
+
import type { RNG } from '../rng/types.js';
|
|
31
|
+
import type {
|
|
32
|
+
ComparePoint,
|
|
33
|
+
DieResult,
|
|
34
|
+
EvaluateOptions,
|
|
35
|
+
ResolvedComparePoint,
|
|
36
|
+
RollPart,
|
|
37
|
+
RollResult,
|
|
38
|
+
} from '../types.js';
|
|
39
|
+
import { DegreeOfSuccess } from '../types.js';
|
|
40
|
+
import { applyCritThresholds, type ResolvedCritThreshold } from './modifiers/crit-threshold.js';
|
|
26
41
|
import {
|
|
27
42
|
applyCompoundExplode,
|
|
28
43
|
applyPenetratingExplode,
|
|
29
44
|
applyStandardExplode,
|
|
30
45
|
buildShouldExplode,
|
|
31
46
|
DEFAULT_MAX_EXPLODE_ITERATIONS,
|
|
32
|
-
} from './modifiers/explode';
|
|
47
|
+
} from './modifiers/explode.js';
|
|
33
48
|
import {
|
|
34
49
|
applyDropHighest,
|
|
35
50
|
applyDropLowest,
|
|
@@ -37,24 +52,34 @@ import {
|
|
|
37
52
|
applyKeepLowest,
|
|
38
53
|
markAllKept,
|
|
39
54
|
sumKeptDice,
|
|
40
|
-
} from './modifiers/keep-drop';
|
|
55
|
+
} from './modifiers/keep-drop.js';
|
|
41
56
|
import {
|
|
42
57
|
applyRecursiveReroll,
|
|
43
58
|
applyRerollOnce,
|
|
44
59
|
DEFAULT_MAX_REROLL_ITERATIONS,
|
|
45
|
-
} from './modifiers/reroll';
|
|
46
|
-
import {
|
|
60
|
+
} from './modifiers/reroll.js';
|
|
61
|
+
import { sortDice } from './modifiers/sort.js';
|
|
62
|
+
import { countSuccesses } from './modifiers/success-count.js';
|
|
47
63
|
|
|
48
64
|
/**
|
|
49
65
|
* Error thrown during AST evaluation.
|
|
50
66
|
*/
|
|
51
67
|
export class EvaluatorError extends RollParserError {
|
|
52
68
|
readonly nodeType: string | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Source span of the tightest AST node that was being evaluated when the
|
|
71
|
+
* error was thrown — stamped by `evalNode` on the way up, so the innermost
|
|
72
|
+
* node wins. `undefined` when the AST was built without parser spans.
|
|
73
|
+
*/
|
|
74
|
+
start: number | undefined;
|
|
75
|
+
end: number | undefined;
|
|
53
76
|
|
|
54
77
|
constructor(message: string, code: RollParserErrorCode, nodeType?: string) {
|
|
55
78
|
super(message, code);
|
|
56
79
|
this.name = 'EvaluatorError';
|
|
57
80
|
this.nodeType = nodeType ?? undefined;
|
|
81
|
+
this.start = undefined;
|
|
82
|
+
this.end = undefined;
|
|
58
83
|
}
|
|
59
84
|
}
|
|
60
85
|
|
|
@@ -87,12 +112,25 @@ export type EvalEnv = {
|
|
|
87
112
|
* left-chain check.
|
|
88
113
|
*/
|
|
89
114
|
insideVersus: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* User-supplied variable map for `@name` / `@{name}` references. Always
|
|
117
|
+
* defined — `evaluate()` defaults to an empty object so lookups can be
|
|
118
|
+
* branch-free on presence.
|
|
119
|
+
*/
|
|
120
|
+
readonly context: Readonly<Record<string, number>>;
|
|
121
|
+
/**
|
|
122
|
+
* Behavior when a referenced variable is missing from `context`. Always
|
|
123
|
+
* defined — `evaluate()` defaults to `'throw'`.
|
|
124
|
+
*/
|
|
125
|
+
readonly onMissingVariable: 'throw' | 'zero';
|
|
90
126
|
};
|
|
91
127
|
|
|
92
128
|
/**
|
|
93
129
|
* Per-branch mutable accumulator for tracking rolls and output during recursion.
|
|
130
|
+
*
|
|
131
|
+
* @internal exported for targeted `mergeMetaRolls` tests only; not a public API.
|
|
94
132
|
*/
|
|
95
|
-
type EvalContext = {
|
|
133
|
+
export type EvalContext = {
|
|
96
134
|
rolls: DieResult[];
|
|
97
135
|
expressionParts: string[];
|
|
98
136
|
renderedParts: string[];
|
|
@@ -111,6 +149,7 @@ type EvalContext = {
|
|
|
111
149
|
|
|
112
150
|
/**
|
|
113
151
|
* Flattened representation of a keep/drop modifier for chain evaluation.
|
|
152
|
+
* Superset of the public `ModifierSpec` (adds the notation `code`).
|
|
114
153
|
*/
|
|
115
154
|
type ModifierSpec = {
|
|
116
155
|
modifier: 'keep' | 'drop';
|
|
@@ -119,16 +158,44 @@ type ModifierSpec = {
|
|
|
119
158
|
code: string;
|
|
120
159
|
};
|
|
121
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Every branch returns its numeric total AND the `RollPart` it contributes
|
|
163
|
+
* to the structured breakdown — TypeScript exhaustiveness guarantees no
|
|
164
|
+
* branch can forget to produce a part.
|
|
165
|
+
*/
|
|
166
|
+
type EvalResult = {
|
|
167
|
+
total: number;
|
|
168
|
+
part: RollPart;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Copies an AST node's source span onto a part (spread into the literal).
|
|
173
|
+
* Empty when the AST was built without parser spans.
|
|
174
|
+
*/
|
|
175
|
+
function partSpan(node: ASTNode): { start?: number; end?: number } {
|
|
176
|
+
if (node.start === undefined || node.end === undefined) return {};
|
|
177
|
+
return { start: node.start, end: node.end };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Maps internal modifier specs to the public `RollPart` spec shape. */
|
|
181
|
+
function toPublicSpecs(
|
|
182
|
+
specs: ModifierSpec[],
|
|
183
|
+
): { kind: 'keep' | 'drop'; selector: 'highest' | 'lowest'; count: number }[] {
|
|
184
|
+
return specs.map((spec) => ({ kind: spec.modifier, selector: spec.selector, count: spec.count }));
|
|
185
|
+
}
|
|
186
|
+
|
|
122
187
|
/**
|
|
123
188
|
* Creates a new die result with critical/fumble detection.
|
|
124
189
|
*/
|
|
125
190
|
function createDieResult(sides: number, result: number): DieResult {
|
|
191
|
+
// ? `sides > 1` guards both flags — a d1 always rolls 1, so it is neither
|
|
192
|
+
// an exceptional max (critical) nor an exceptional min (fumble).
|
|
126
193
|
return {
|
|
127
194
|
sides,
|
|
128
195
|
result,
|
|
129
196
|
modifiers: [],
|
|
130
197
|
critical: result === sides && sides > 1,
|
|
131
|
-
fumble: result === 1,
|
|
198
|
+
fumble: result === 1 && sides > 1,
|
|
132
199
|
};
|
|
133
200
|
}
|
|
134
201
|
|
|
@@ -150,30 +217,83 @@ function createFateDieResult(result: number): DieResult {
|
|
|
150
217
|
* Renders dice results for display. Marker priority: dropped wins over
|
|
151
218
|
* success/failure (dropped dice are never counted), success wins over
|
|
152
219
|
* failure (a die cannot be both). Example: `[~~1~~, **6**, __1__, 3]`.
|
|
220
|
+
*
|
|
221
|
+
* Dice tagged `'meta'` (rolled to compute sub-expression parameters such as
|
|
222
|
+
* count/sides/threshold) are hidden from the rendered output — they exist in
|
|
223
|
+
* `RollResult.rolls` for audit, not for display.
|
|
153
224
|
*/
|
|
154
225
|
function renderDice(dice: DieResult[]): string {
|
|
155
|
-
const parts = dice
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
226
|
+
const parts = dice
|
|
227
|
+
.filter((die) => !die.modifiers.includes('meta'))
|
|
228
|
+
.map((die) => {
|
|
229
|
+
if (die.modifiers.includes('dropped')) {
|
|
230
|
+
return `~~${die.result}~~`;
|
|
231
|
+
}
|
|
232
|
+
if (die.modifiers.includes('success')) {
|
|
233
|
+
return `**${die.result}**`;
|
|
234
|
+
}
|
|
235
|
+
if (die.modifiers.includes('failure')) {
|
|
236
|
+
return `__${die.result}__`;
|
|
237
|
+
}
|
|
238
|
+
return String(die.result);
|
|
239
|
+
});
|
|
167
240
|
return `[${parts.join(', ')}]`;
|
|
168
241
|
}
|
|
169
242
|
|
|
170
243
|
/**
|
|
171
|
-
*
|
|
244
|
+
* Forwards rolls from a throwaway sub-expression context into the parent
|
|
245
|
+
* audit trail, tagging them as `'meta'` + `'dropped'`. Meta dice are dice
|
|
246
|
+
* rolled to compute parameters (dice count, sides, threshold, modifier
|
|
247
|
+
* count) — they consume RNG and count against `maxDice`, so they must be
|
|
248
|
+
* inspectable. Tagging them `'dropped'` keeps totals correct via
|
|
249
|
+
* `sumKeptDice`; `'meta'` lets renderers hide them and lets callers
|
|
250
|
+
* distinguish them from ordinary pool dice.
|
|
251
|
+
*
|
|
252
|
+
* `'success'`/`'failure'` tags are stripped here as defense-in-depth against
|
|
253
|
+
* a SuccessCount leaking into a meta sub-expression (parser rejects all such
|
|
254
|
+
* wrappings; this strip ensures a future parse regression cannot leak tags
|
|
255
|
+
* into the top-level `successes`/`failures` scan).
|
|
256
|
+
*/
|
|
257
|
+
/** @internal exported for targeted defense-in-depth tests only. */
|
|
258
|
+
export function mergeMetaRolls(parent: EvalContext, source: EvalContext): void {
|
|
259
|
+
for (const die of source.rolls) {
|
|
260
|
+
parent.rolls.push({
|
|
261
|
+
...die,
|
|
262
|
+
modifiers: [
|
|
263
|
+
...die.modifiers.filter(
|
|
264
|
+
(m) => m !== 'kept' && m !== 'dropped' && m !== 'success' && m !== 'failure',
|
|
265
|
+
),
|
|
266
|
+
'meta',
|
|
267
|
+
'dropped',
|
|
268
|
+
],
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Evaluates an AST node, returning its total and `RollPart` while updating
|
|
275
|
+
* the context.
|
|
276
|
+
*
|
|
277
|
+
* Errors bubbling up get the source span of the tightest node that was being
|
|
278
|
+
* evaluated — the innermost `evalNode` frame stamps first, outer frames leave
|
|
279
|
+
* an already-stamped error untouched.
|
|
172
280
|
*/
|
|
173
|
-
function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
281
|
+
function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
282
|
+
try {
|
|
283
|
+
return evalNodeInner(node, rng, ctx, env);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (error instanceof EvaluatorError && error.start === undefined && node.start !== undefined) {
|
|
286
|
+
error.start = node.start;
|
|
287
|
+
error.end = node.end;
|
|
288
|
+
}
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
174
294
|
switch (node.type) {
|
|
175
295
|
case 'Literal':
|
|
176
|
-
return evalLiteral(node
|
|
296
|
+
return evalLiteral(node, ctx);
|
|
177
297
|
|
|
178
298
|
case 'Dice':
|
|
179
299
|
return evalDice(node, rng, ctx, env);
|
|
@@ -205,6 +325,21 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): numb
|
|
|
205
325
|
case 'FunctionCall':
|
|
206
326
|
return evalFunctionCall(node, rng, ctx, env);
|
|
207
327
|
|
|
328
|
+
case 'Grouped':
|
|
329
|
+
return evalGrouped(node, rng, ctx, env);
|
|
330
|
+
|
|
331
|
+
case 'Group':
|
|
332
|
+
return evalGroup(node, rng, ctx, env);
|
|
333
|
+
|
|
334
|
+
case 'Sort':
|
|
335
|
+
return evalSort(node, rng, ctx, env);
|
|
336
|
+
|
|
337
|
+
case 'CritThreshold':
|
|
338
|
+
return evalCritThreshold(node, rng, ctx, env);
|
|
339
|
+
|
|
340
|
+
case 'Variable':
|
|
341
|
+
return evalVariable(node, ctx, env);
|
|
342
|
+
|
|
208
343
|
default: {
|
|
209
344
|
const exhaustive: never = node;
|
|
210
345
|
throw new EvaluatorError(
|
|
@@ -216,25 +351,84 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): numb
|
|
|
216
351
|
}
|
|
217
352
|
}
|
|
218
353
|
|
|
219
|
-
function evalLiteral(
|
|
354
|
+
function evalLiteral(node: LiteralNode, ctx: EvalContext): EvalResult {
|
|
355
|
+
const { value } = node;
|
|
220
356
|
ctx.expressionParts.push(String(value));
|
|
221
357
|
ctx.renderedParts.push(String(value));
|
|
222
|
-
return value;
|
|
358
|
+
return { total: value, part: { type: 'literal', value, total: value, ...partSpan(node) } };
|
|
223
359
|
}
|
|
224
360
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Re-derives whether a variable name needs braces in its rendered form.
|
|
363
|
+
*
|
|
364
|
+
* The lexer accepts `@name` (bare) or `@{name with spaces}` (braced) but
|
|
365
|
+
* strips the braces from the captured value. To round-trip through `rendered`
|
|
366
|
+
* we re-derive bracedness from the name shape — anything outside the bare
|
|
367
|
+
* identifier grammar implies the user wrote braces (or would need them).
|
|
368
|
+
*/
|
|
369
|
+
function variableNeedsBraces(name: string): boolean {
|
|
370
|
+
return !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Looks up a variable in `env.context` and resolves missing keys per
|
|
375
|
+
* `env.onMissingVariable`. The resolved scalar is the variable's value;
|
|
376
|
+
* `expression` shows the resolved number (mirrors how literals render),
|
|
377
|
+
* while `rendered` keeps the original `@name` (or `@{name}`) annotated with
|
|
378
|
+
* the resolved value in brackets so readers can attribute the number.
|
|
379
|
+
*/
|
|
380
|
+
function evalVariable(node: VariableNode, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
381
|
+
const present = Object.hasOwn(env.context, node.name);
|
|
382
|
+
if (!present) {
|
|
383
|
+
if (env.onMissingVariable === 'throw') {
|
|
384
|
+
throw new EvaluatorError(
|
|
385
|
+
`Undefined variable: ${node.name}`,
|
|
386
|
+
'UNDEFINED_VARIABLE',
|
|
387
|
+
'Variable',
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
|
|
391
|
+
ctx.expressionParts.push('0');
|
|
392
|
+
ctx.renderedParts.push(`${display}[0]`);
|
|
393
|
+
return {
|
|
394
|
+
total: 0,
|
|
395
|
+
part: { type: 'variable', name: node.name, value: 0, total: 0, ...partSpan(node) },
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const value = env.context[node.name] as number;
|
|
400
|
+
if (!Number.isFinite(value)) {
|
|
401
|
+
throw new EvaluatorError(
|
|
402
|
+
`Invalid variable value: ${node.name} = ${value}`,
|
|
403
|
+
'INVALID_VARIABLE_VALUE',
|
|
404
|
+
'Variable',
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
|
|
408
|
+
ctx.expressionParts.push(String(value));
|
|
409
|
+
ctx.renderedParts.push(`${display}[${value}]`);
|
|
410
|
+
return {
|
|
411
|
+
total: value,
|
|
412
|
+
part: { type: 'variable', name: node.name, value, total: value, ...partSpan(node) },
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* RNG draw order: `count` expression → `sides` expression → pool dice
|
|
418
|
+
* (one `nextInt` per die, left-to-right). Meta-expressions on `count`/`sides`
|
|
419
|
+
* (e.g. `(1+1)d(3*2)`) draw before the pool. For modifier-argument
|
|
420
|
+
* meta-expressions like `4d6kh(1d2)`, `flattenModifierChain` draws the
|
|
421
|
+
* modifier args first, then `evalModifier` calls `evalDice` for the base
|
|
422
|
+
* pool. See `.claude/rules/rng.md` for the full draw-order spec.
|
|
423
|
+
*/
|
|
424
|
+
function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
425
|
+
const countCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
426
|
+
const count = evalNode(node.count, rng, countCtx, env).total;
|
|
427
|
+
mergeMetaRolls(ctx, countCtx);
|
|
428
|
+
|
|
429
|
+
const sidesCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
430
|
+
const sides = evalNode(node.sides, rng, sidesCtx, env).total;
|
|
431
|
+
mergeMetaRolls(ctx, sidesCtx);
|
|
238
432
|
|
|
239
433
|
if (!Number.isInteger(count) || count < 0) {
|
|
240
434
|
throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', 'Dice');
|
|
@@ -267,16 +461,16 @@ function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): num
|
|
|
267
461
|
ctx.expressionParts.push(notation);
|
|
268
462
|
ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`);
|
|
269
463
|
|
|
270
|
-
return
|
|
464
|
+
return {
|
|
465
|
+
total,
|
|
466
|
+
part: { type: 'dice', count, sides, rolls: markedDice, total, ...partSpan(node) },
|
|
467
|
+
};
|
|
271
468
|
}
|
|
272
469
|
|
|
273
|
-
function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
{ rolls: [], expressionParts: [], renderedParts: [] },
|
|
278
|
-
env,
|
|
279
|
-
);
|
|
470
|
+
function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
471
|
+
const countCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
472
|
+
const count = evalNode(node.count, rng, countCtx, env).total;
|
|
473
|
+
mergeMetaRolls(ctx, countCtx);
|
|
280
474
|
|
|
281
475
|
if (!Number.isInteger(count) || count < 0) {
|
|
282
476
|
throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', 'FateDice');
|
|
@@ -306,17 +500,56 @@ function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
306
500
|
ctx.expressionParts.push(notation);
|
|
307
501
|
ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`);
|
|
308
502
|
|
|
309
|
-
return
|
|
503
|
+
return {
|
|
504
|
+
total,
|
|
505
|
+
part: { type: 'fateDice', count, rolls: markedDice, total, ...partSpan(node) },
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Propagates `versusMetadata` onto a parent so `degree`/`natural` survive
|
|
511
|
+
* wrappers like `floor(...)`, `(vs) + 0`, or `-(vs)`. Throws `NESTED_VERSUS`
|
|
512
|
+
* if the parent already carries metadata — two versus results cannot occupy
|
|
513
|
+
* the same `RollResult`. No-op when `metadata` is `undefined`.
|
|
514
|
+
*
|
|
515
|
+
* Use this directly when the caller has already pushed (or transformed) `rolls`
|
|
516
|
+
* itself — e.g. `evalSort`, `evalCritThreshold`, `evalGroupModifier`. For the
|
|
517
|
+
* default case where the child's raw rolls flow up unchanged, use
|
|
518
|
+
* `mergeContext` instead.
|
|
519
|
+
*/
|
|
520
|
+
function propagateMetadata(parent: EvalContext, metadata: EvalContext['versusMetadata']): void {
|
|
521
|
+
if (!metadata) return;
|
|
522
|
+
if (parent.versusMetadata) {
|
|
523
|
+
throw new EvaluatorError(
|
|
524
|
+
'Multiple versus operators in the same expression',
|
|
525
|
+
'NESTED_VERSUS',
|
|
526
|
+
'Versus',
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
parent.versusMetadata = metadata;
|
|
310
530
|
}
|
|
311
531
|
|
|
312
|
-
|
|
532
|
+
/**
|
|
533
|
+
* Merges a child sub-context back into its parent. Copies `rolls` and
|
|
534
|
+
* delegates `versusMetadata` propagation to `propagateMetadata`.
|
|
535
|
+
*
|
|
536
|
+
* Does not merge `expressionParts` / `renderedParts` — each wrapper formats
|
|
537
|
+
* those with its own operator/function syntax.
|
|
538
|
+
*/
|
|
539
|
+
function mergeContext(parent: EvalContext, child: EvalContext): void {
|
|
540
|
+
parent.rolls.push(...child.rolls);
|
|
541
|
+
propagateMetadata(parent, child.versusMetadata);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
313
545
|
const leftCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
314
546
|
const rightCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
315
547
|
|
|
316
548
|
const left = evalNode(node.left, rng, leftCtx, env);
|
|
317
549
|
const right = evalNode(node.right, rng, rightCtx, env);
|
|
318
550
|
|
|
319
|
-
ctx
|
|
551
|
+
mergeContext(ctx, leftCtx);
|
|
552
|
+
mergeContext(ctx, rightCtx);
|
|
320
553
|
|
|
321
554
|
const leftExpr = leftCtx.expressionParts.join('');
|
|
322
555
|
const rightExpr = rightCtx.expressionParts.join('');
|
|
@@ -326,7 +559,27 @@ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
326
559
|
ctx.expressionParts.push(`${leftExpr} ${node.operator} ${rightExpr}`);
|
|
327
560
|
ctx.renderedParts.push(`${leftRendered} ${node.operator} ${rightRendered}`);
|
|
328
561
|
|
|
329
|
-
|
|
562
|
+
const total = applyBinaryOperator(node.operator, left.total, right.total);
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
total,
|
|
566
|
+
part: {
|
|
567
|
+
type: 'binaryOp',
|
|
568
|
+
operator: node.operator,
|
|
569
|
+
left: left.part,
|
|
570
|
+
right: right.part,
|
|
571
|
+
total,
|
|
572
|
+
...partSpan(node),
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function applyBinaryOperator(
|
|
578
|
+
operator: BinaryOpNode['operator'],
|
|
579
|
+
left: number,
|
|
580
|
+
right: number,
|
|
581
|
+
): number {
|
|
582
|
+
switch (operator) {
|
|
330
583
|
case '+':
|
|
331
584
|
return left + right;
|
|
332
585
|
case '-':
|
|
@@ -346,17 +599,17 @@ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
346
599
|
case '**':
|
|
347
600
|
return left ** right;
|
|
348
601
|
default: {
|
|
349
|
-
const exhaustive: never =
|
|
602
|
+
const exhaustive: never = operator;
|
|
350
603
|
throw new EvaluatorError(`Unknown operator: ${exhaustive}`, 'UNKNOWN_OPERATOR', 'BinaryOp');
|
|
351
604
|
}
|
|
352
605
|
}
|
|
353
606
|
}
|
|
354
607
|
|
|
355
|
-
function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
608
|
+
function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
356
609
|
const innerCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
357
|
-
const
|
|
610
|
+
const inner = evalNode(node.operand, rng, innerCtx, env);
|
|
358
611
|
|
|
359
|
-
ctx
|
|
612
|
+
mergeContext(ctx, innerCtx);
|
|
360
613
|
|
|
361
614
|
const innerExpr = innerCtx.expressionParts.join('');
|
|
362
615
|
const innerRendered = innerCtx.renderedParts.join('');
|
|
@@ -364,7 +617,61 @@ function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
364
617
|
ctx.expressionParts.push(`-${innerExpr}`);
|
|
365
618
|
ctx.renderedParts.push(`-${innerRendered}`);
|
|
366
619
|
|
|
367
|
-
|
|
620
|
+
const total = -inner.total;
|
|
621
|
+
|
|
622
|
+
return {
|
|
623
|
+
total,
|
|
624
|
+
part: { type: 'unaryOp', operator: '-', operand: inner.part, total, ...partSpan(node) },
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function evalGrouped(node: GroupedNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
629
|
+
const innerCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
630
|
+
const inner = evalNode(node.expression, rng, innerCtx, env);
|
|
631
|
+
|
|
632
|
+
mergeContext(ctx, innerCtx);
|
|
633
|
+
|
|
634
|
+
ctx.expressionParts.push(`(${innerCtx.expressionParts.join('')})`);
|
|
635
|
+
ctx.renderedParts.push(`(${innerCtx.renderedParts.join('')})`);
|
|
636
|
+
|
|
637
|
+
return {
|
|
638
|
+
total: inner.total,
|
|
639
|
+
part: { type: 'grouped', inner: inner.part, total: inner.total, ...partSpan(node) },
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Evaluates a grouped roll `{expr1, expr2, ...}`.
|
|
645
|
+
*
|
|
646
|
+
* Each sub-expression is evaluated in an isolated context, then its rolls
|
|
647
|
+
* and `versusMetadata` propagate up via `mergeContext`. Sub-roll subtotals
|
|
648
|
+
* sum to the group's total. When the group is the base target of a
|
|
649
|
+
* keep/drop modifier with `expressions.length >= 2`, `evalModifier`
|
|
650
|
+
* intercepts first and never calls this function — dual semantics
|
|
651
|
+
* (flat-pool vs sub-roll) are decided there.
|
|
652
|
+
*/
|
|
653
|
+
function evalGroup(node: GroupNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
654
|
+
const subExprs: string[] = [];
|
|
655
|
+
const subRendered: string[] = [];
|
|
656
|
+
const subParts: RollPart[] = [];
|
|
657
|
+
let total = 0;
|
|
658
|
+
|
|
659
|
+
for (const expr of node.expressions) {
|
|
660
|
+
const subCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
661
|
+
const sub = evalNode(expr, rng, subCtx, env);
|
|
662
|
+
mergeContext(ctx, subCtx);
|
|
663
|
+
subExprs.push(subCtx.expressionParts.join(''));
|
|
664
|
+
subRendered.push(subCtx.renderedParts.join(''));
|
|
665
|
+
subParts.push(sub.part);
|
|
666
|
+
total += sub.total;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
ctx.expressionParts.push(`{${subExprs.join(', ')}}`);
|
|
670
|
+
ctx.renderedParts.push(`{${subRendered.join(', ')}}`);
|
|
671
|
+
|
|
672
|
+
// ? No `keptIndices` — bare groups (and single-sub passthroughs) perform
|
|
673
|
+
// no sub-roll selection; only `evalGroupModifier` sets it.
|
|
674
|
+
return { total, part: { type: 'group', parts: subParts, total, ...partSpan(node) } };
|
|
368
675
|
}
|
|
369
676
|
|
|
370
677
|
function evalFunctionCall(
|
|
@@ -372,18 +679,18 @@ function evalFunctionCall(
|
|
|
372
679
|
rng: RNG,
|
|
373
680
|
ctx: EvalContext,
|
|
374
681
|
env: EvalEnv,
|
|
375
|
-
):
|
|
682
|
+
): EvalResult {
|
|
376
683
|
const argCtxs: EvalContext[] = [];
|
|
377
|
-
const
|
|
684
|
+
const argResults: EvalResult[] = [];
|
|
378
685
|
|
|
379
686
|
for (const arg of node.args) {
|
|
380
687
|
const argCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
381
|
-
|
|
688
|
+
argResults.push(evalNode(arg, rng, argCtx, env));
|
|
382
689
|
argCtxs.push(argCtx);
|
|
383
690
|
}
|
|
384
691
|
|
|
385
692
|
for (const argCtx of argCtxs) {
|
|
386
|
-
ctx
|
|
693
|
+
mergeContext(ctx, argCtx);
|
|
387
694
|
}
|
|
388
695
|
|
|
389
696
|
const argExprs = argCtxs.map((c) => c.expressionParts.join(''));
|
|
@@ -392,7 +699,21 @@ function evalFunctionCall(
|
|
|
392
699
|
ctx.expressionParts.push(`${node.name}(${argExprs.join(', ')})`);
|
|
393
700
|
ctx.renderedParts.push(`${node.name}(${argRendereds.join(', ')})`);
|
|
394
701
|
|
|
395
|
-
|
|
702
|
+
const total = applyFunction(
|
|
703
|
+
node.name,
|
|
704
|
+
argResults.map((r) => r.total),
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
return {
|
|
708
|
+
total,
|
|
709
|
+
part: {
|
|
710
|
+
type: 'functionCall',
|
|
711
|
+
name: node.name,
|
|
712
|
+
args: argResults.map((r) => r.part),
|
|
713
|
+
total,
|
|
714
|
+
...partSpan(node),
|
|
715
|
+
},
|
|
716
|
+
};
|
|
396
717
|
}
|
|
397
718
|
|
|
398
719
|
function applyFunction(name: string, values: number[]): number {
|
|
@@ -402,6 +723,9 @@ function applyFunction(name: string, values: number[]): number {
|
|
|
402
723
|
case 'ceil':
|
|
403
724
|
return Math.ceil(requireUnaryArg(name, values));
|
|
404
725
|
case 'round':
|
|
726
|
+
// ? Delegates to `Math.round`, which rounds half-values toward +∞
|
|
727
|
+
// (IEEE-754 half-up). So `round(2.5) === 3` but `round(-2.5) === -2`,
|
|
728
|
+
// not `-3`. Users needing symmetric rounding must compose via `floor`.
|
|
405
729
|
return Math.round(requireUnaryArg(name, values));
|
|
406
730
|
case 'abs':
|
|
407
731
|
return Math.abs(requireUnaryArg(name, values));
|
|
@@ -435,6 +759,7 @@ function requireUnaryArg(name: string, values: number[]): number {
|
|
|
435
759
|
function flattenModifierChain(
|
|
436
760
|
node: ModifierNode,
|
|
437
761
|
rng: RNG,
|
|
762
|
+
ctx: EvalContext,
|
|
438
763
|
env: EvalEnv,
|
|
439
764
|
): { specs: ModifierSpec[]; baseTarget: ASTNode } {
|
|
440
765
|
const specs: ModifierSpec[] = [];
|
|
@@ -442,7 +767,8 @@ function flattenModifierChain(
|
|
|
442
767
|
|
|
443
768
|
while (isModifier(current)) {
|
|
444
769
|
const countCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
445
|
-
const modCount = evalNode(current.count, rng, countCtx, env);
|
|
770
|
+
const modCount = evalNode(current.count, rng, countCtx, env).total;
|
|
771
|
+
mergeMetaRolls(ctx, countCtx);
|
|
446
772
|
|
|
447
773
|
if (!Number.isInteger(modCount) || modCount < 0) {
|
|
448
774
|
throw new EvaluatorError(
|
|
@@ -486,6 +812,11 @@ function applyModifierSpec(dice: DieResult[], spec: ModifierSpec): DieResult[] {
|
|
|
486
812
|
/**
|
|
487
813
|
* Applies each modifier independently to the full dice pool
|
|
488
814
|
* and merges drop sets via union. A die is dropped if ANY modifier dropped it.
|
|
815
|
+
*
|
|
816
|
+
* Mutates each die's flags in place — the same `DieResult` objects are
|
|
817
|
+
* shared between `RollResult.rolls` and the `RollPart` tree. The per-spec
|
|
818
|
+
* appliers still work on copies internally (each spec must see the
|
|
819
|
+
* unmodified pool), only the merged outcome is written back.
|
|
489
820
|
*/
|
|
490
821
|
function mergeDropSets(baseDice: DieResult[], specs: ModifierSpec[]): DieResult[] {
|
|
491
822
|
const droppedIndices = new Set<number>();
|
|
@@ -499,12 +830,13 @@ function mergeDropSets(baseDice: DieResult[], specs: ModifierSpec[]): DieResult[
|
|
|
499
830
|
}
|
|
500
831
|
}
|
|
501
832
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
modifiers: droppedIndices.has(index)
|
|
833
|
+
baseDice.forEach((die, index) => {
|
|
834
|
+
die.modifiers = droppedIndices.has(index)
|
|
505
835
|
? [...die.modifiers.filter((m) => m !== 'kept' && m !== 'dropped'), 'dropped']
|
|
506
|
-
: [...die.modifiers.filter((m) => m !== 'dropped' && m !== 'kept'), 'kept']
|
|
507
|
-
})
|
|
836
|
+
: [...die.modifiers.filter((m) => m !== 'dropped' && m !== 'kept'), 'kept'];
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
return baseDice;
|
|
508
840
|
}
|
|
509
841
|
|
|
510
842
|
/**
|
|
@@ -520,24 +852,39 @@ function formatExplodeCode(
|
|
|
520
852
|
return `${marker}${threshold.operator}${thresholdValue}`;
|
|
521
853
|
}
|
|
522
854
|
|
|
523
|
-
function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
855
|
+
function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
524
856
|
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
525
|
-
const
|
|
857
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
526
858
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
527
859
|
|
|
528
860
|
let thresholdValue: number | undefined;
|
|
529
861
|
if (node.threshold != null) {
|
|
530
862
|
const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
531
|
-
thresholdValue = evalNode(node.threshold.value, rng, thresholdCtx, env);
|
|
863
|
+
thresholdValue = evalNode(node.threshold.value, rng, thresholdCtx, env).total;
|
|
864
|
+
mergeMetaRolls(ctx, thresholdCtx);
|
|
532
865
|
}
|
|
533
866
|
|
|
534
867
|
const code = formatExplodeCode(node.variant, node.threshold, thresholdValue);
|
|
535
868
|
|
|
869
|
+
const buildPart = (total: number): RollPart => {
|
|
870
|
+
const part: RollPart = {
|
|
871
|
+
type: 'explode',
|
|
872
|
+
variant: node.variant,
|
|
873
|
+
target: target.part,
|
|
874
|
+
total,
|
|
875
|
+
...partSpan(node),
|
|
876
|
+
};
|
|
877
|
+
if (node.threshold != null && thresholdValue !== undefined) {
|
|
878
|
+
part.threshold = { operator: node.threshold.operator, value: thresholdValue };
|
|
879
|
+
}
|
|
880
|
+
return part;
|
|
881
|
+
};
|
|
882
|
+
|
|
536
883
|
// No-op when the target produced no dice (e.g., `(1+2)!`).
|
|
537
884
|
if (targetCtx.rolls.length === 0) {
|
|
538
885
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
539
886
|
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
540
|
-
return
|
|
887
|
+
return { total: target.total, part: buildPart(target.total) };
|
|
541
888
|
}
|
|
542
889
|
|
|
543
890
|
const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
|
|
@@ -556,24 +903,41 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
556
903
|
// dropped dice are visible; explosion-origin is otherwise invisible.
|
|
557
904
|
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
|
|
558
905
|
|
|
559
|
-
|
|
906
|
+
const total = sumKeptDice(expanded);
|
|
907
|
+
return { total, part: buildPart(total) };
|
|
560
908
|
}
|
|
561
909
|
|
|
562
|
-
function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
910
|
+
function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
563
911
|
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
564
|
-
evalNode(node.target, rng, targetCtx, env);
|
|
912
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
565
913
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
566
914
|
|
|
567
915
|
const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
568
|
-
const thresholdValue = evalNode(node.condition.value, rng, thresholdCtx, env);
|
|
916
|
+
const thresholdValue = evalNode(node.condition.value, rng, thresholdCtx, env).total;
|
|
917
|
+
mergeMetaRolls(ctx, thresholdCtx);
|
|
569
918
|
|
|
570
919
|
const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
|
|
920
|
+
const condition: ResolvedComparePoint = {
|
|
921
|
+
operator: node.condition.operator,
|
|
922
|
+
value: thresholdValue,
|
|
923
|
+
};
|
|
571
924
|
|
|
572
925
|
// No-op when the target produced no dice (e.g., `(1+2)r<5`).
|
|
573
926
|
if (targetCtx.rolls.length === 0) {
|
|
574
927
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
575
928
|
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
576
|
-
|
|
929
|
+
const total = sumKeptDice(targetCtx.rolls);
|
|
930
|
+
return {
|
|
931
|
+
total,
|
|
932
|
+
part: {
|
|
933
|
+
type: 'reroll',
|
|
934
|
+
once: node.once,
|
|
935
|
+
condition,
|
|
936
|
+
target: target.part,
|
|
937
|
+
total,
|
|
938
|
+
...partSpan(node),
|
|
939
|
+
},
|
|
940
|
+
};
|
|
577
941
|
}
|
|
578
942
|
|
|
579
943
|
const pool = node.once
|
|
@@ -584,14 +948,153 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
584
948
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
585
949
|
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
|
|
586
950
|
|
|
587
|
-
|
|
951
|
+
const total = sumKeptDice(pool);
|
|
952
|
+
return {
|
|
953
|
+
total,
|
|
954
|
+
part: {
|
|
955
|
+
type: 'reroll',
|
|
956
|
+
once: node.once,
|
|
957
|
+
condition,
|
|
958
|
+
target: target.part,
|
|
959
|
+
total,
|
|
960
|
+
...partSpan(node),
|
|
961
|
+
},
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Evaluates a sort modifier. Purely visual — sorts the dice produced by
|
|
967
|
+
* `node.target` in ascending or descending order of `result` without
|
|
968
|
+
* changing the total or any die-level flag. Dropped dice participate in
|
|
969
|
+
* the sort alongside kept dice, preserving their `'dropped'` marker.
|
|
970
|
+
*
|
|
971
|
+
* Rendering mirrors `evalExplode` / `evalModifier`: emits
|
|
972
|
+
* `<targetExpr><code>[<sortedDice>]`, replacing any inline dice brackets
|
|
973
|
+
* the target itself rendered. Multi-sub-roll Group targets (`{4d6, 3d6}s`)
|
|
974
|
+
* are rejected at parse time with `INVALID_SORT_TARGET` until hierarchical
|
|
975
|
+
* per-sub-roll sorting (Stage 3 spec §3 "Group interaction") is implemented.
|
|
976
|
+
*/
|
|
977
|
+
function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
978
|
+
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
979
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
980
|
+
|
|
981
|
+
const sortedRolls = sortDice(targetCtx.rolls, node.order);
|
|
982
|
+
|
|
983
|
+
ctx.rolls.push(...sortedRolls);
|
|
984
|
+
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
985
|
+
|
|
986
|
+
const code = node.order === 'ascending' ? 's' : 'sd';
|
|
987
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
988
|
+
|
|
989
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
990
|
+
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(sortedRolls)}`);
|
|
991
|
+
|
|
992
|
+
return {
|
|
993
|
+
total: target.total,
|
|
994
|
+
part: {
|
|
995
|
+
type: 'sort',
|
|
996
|
+
order: node.order,
|
|
997
|
+
target: target.part,
|
|
998
|
+
total: target.total,
|
|
999
|
+
...partSpan(node),
|
|
1000
|
+
},
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Evaluates a critical/fumble threshold modifier. Pure post-processing:
|
|
1006
|
+
* evaluates the target in an isolated context, resolves each threshold's
|
|
1007
|
+
* ComparePoint value (including meta-expressions, which consume RNG draws
|
|
1008
|
+
* AFTER the target pool), then overrides `critical`/`fumble` flags on the
|
|
1009
|
+
* produced dice in place.
|
|
1010
|
+
*
|
|
1011
|
+
* Independent overrides (Roll20 semantics): `cs` thresholds replace only the
|
|
1012
|
+
* crit criteria and `cf` thresholds replace only the fumble criteria. When a
|
|
1013
|
+
* side has no explicit threshold, the `'default'` rule applies — so
|
|
1014
|
+
* `1d20cf<3` keeps the default nat-20 crit. Bare `cs`/`cf` uses the
|
|
1015
|
+
* `'default'` sentinel resolved per-die to `result === sides` or
|
|
1016
|
+
* `result === 1`.
|
|
1017
|
+
*
|
|
1018
|
+
* Renders `<targetExpr><codes>[<dice>]`, mirroring `evalSort`/`evalExplode`.
|
|
1019
|
+
*/
|
|
1020
|
+
function evalCritThreshold(
|
|
1021
|
+
node: CritThresholdNode,
|
|
1022
|
+
rng: RNG,
|
|
1023
|
+
ctx: EvalContext,
|
|
1024
|
+
env: EvalEnv,
|
|
1025
|
+
): EvalResult {
|
|
1026
|
+
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
1027
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1028
|
+
|
|
1029
|
+
const successResolved = node.successThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
|
|
1030
|
+
const failResolved = node.failThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
|
|
1031
|
+
|
|
1032
|
+
// Independent overrides: a side with no explicit threshold falls back to
|
|
1033
|
+
// the default rule instead of being wiped by the other side's override.
|
|
1034
|
+
const successApplied: ResolvedCritThreshold[] =
|
|
1035
|
+
successResolved.length > 0 ? successResolved : ['default'];
|
|
1036
|
+
const failApplied: ResolvedCritThreshold[] = failResolved.length > 0 ? failResolved : ['default'];
|
|
1037
|
+
|
|
1038
|
+
applyCritThresholds(targetCtx.rolls, successApplied, failApplied);
|
|
1039
|
+
|
|
1040
|
+
ctx.rolls.push(...targetCtx.rolls);
|
|
1041
|
+
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
1042
|
+
|
|
1043
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
1044
|
+
const codes = [
|
|
1045
|
+
...successResolved.map((t) => (t === 'default' ? 'cs' : `cs${t.operator}${t.value}`)),
|
|
1046
|
+
...failResolved.map((t) => (t === 'default' ? 'cf' : `cf${t.operator}${t.value}`)),
|
|
1047
|
+
].join('');
|
|
1048
|
+
|
|
1049
|
+
ctx.expressionParts.push(`${targetExpr}${codes}`);
|
|
1050
|
+
ctx.renderedParts.push(`${targetExpr}${codes}${renderDice(targetCtx.rolls)}`);
|
|
1051
|
+
|
|
1052
|
+
return {
|
|
1053
|
+
total: target.total,
|
|
1054
|
+
part: {
|
|
1055
|
+
type: 'critThreshold',
|
|
1056
|
+
successThresholds: successResolved,
|
|
1057
|
+
failThresholds: failResolved,
|
|
1058
|
+
target: target.part,
|
|
1059
|
+
total: target.total,
|
|
1060
|
+
...partSpan(node),
|
|
1061
|
+
},
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function resolveCritThreshold(
|
|
1066
|
+
threshold: CritThreshold,
|
|
1067
|
+
rng: RNG,
|
|
1068
|
+
ctx: EvalContext,
|
|
1069
|
+
env: EvalEnv,
|
|
1070
|
+
): ResolvedCritThreshold {
|
|
1071
|
+
if (threshold === 'default') return 'default';
|
|
1072
|
+
const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
1073
|
+
const resolved = evalNode(threshold.value, rng, thresholdCtx, env).total;
|
|
1074
|
+
mergeMetaRolls(ctx, thresholdCtx);
|
|
1075
|
+
if (!Number.isFinite(resolved)) {
|
|
1076
|
+
throw new EvaluatorError(
|
|
1077
|
+
`Invalid crit threshold: ${resolved}`,
|
|
1078
|
+
'INVALID_THRESHOLD',
|
|
1079
|
+
'CritThreshold',
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
return { operator: threshold.operator, value: resolved };
|
|
588
1083
|
}
|
|
589
1084
|
|
|
590
|
-
function evalModifier(node: ModifierNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
591
|
-
const { specs, baseTarget } = flattenModifierChain(node, rng, env);
|
|
1085
|
+
function evalModifier(node: ModifierNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
1086
|
+
const { specs, baseTarget } = flattenModifierChain(node, rng, ctx, env);
|
|
1087
|
+
|
|
1088
|
+
// Multi-sub-roll group: keep/drop operates on sub-roll subtotals as if
|
|
1089
|
+
// each subtotal were a compound die. Single-sub-roll groups fall through
|
|
1090
|
+
// to the flat-pool path below — `evalGroup` evaluates the inner expression
|
|
1091
|
+
// into `targetCtx.rolls`, and `mergeDropSets` selects individual dice.
|
|
1092
|
+
if (baseTarget.type === 'Group' && baseTarget.expressions.length >= 2) {
|
|
1093
|
+
return evalGroupModifier(node, baseTarget, specs, rng, ctx, env);
|
|
1094
|
+
}
|
|
592
1095
|
|
|
593
1096
|
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
594
|
-
evalNode(baseTarget, rng, targetCtx, env);
|
|
1097
|
+
const target = evalNode(baseTarget, rng, targetCtx, env);
|
|
595
1098
|
|
|
596
1099
|
const mergedDice = mergeDropSets(targetCtx.rolls, specs);
|
|
597
1100
|
|
|
@@ -605,17 +1108,169 @@ function evalModifier(node: ModifierNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
605
1108
|
ctx.expressionParts.push(`${targetExpr}${modifierCodes}`);
|
|
606
1109
|
ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
|
|
607
1110
|
|
|
608
|
-
return
|
|
1111
|
+
return {
|
|
1112
|
+
total,
|
|
1113
|
+
part: {
|
|
1114
|
+
type: 'modifier',
|
|
1115
|
+
specs: toPublicSpecs(specs),
|
|
1116
|
+
target: target.part,
|
|
1117
|
+
total,
|
|
1118
|
+
...partSpan(node),
|
|
1119
|
+
},
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Strips per-die markdown markers from an already-rendered sub-roll string.
|
|
1125
|
+
* Used when a whole group sub-roll is dropped: the outer `~~...~~` wrap
|
|
1126
|
+
* supersedes inner success (`**`), failure (`__`), and dropped (`~~`)
|
|
1127
|
+
* markers, and leaving them in place would nest strikethroughs or show
|
|
1128
|
+
* success highlights inside a dropped span.
|
|
1129
|
+
*/
|
|
1130
|
+
function stripInnerMarkers(rendered: string): string {
|
|
1131
|
+
return rendered
|
|
1132
|
+
.replace(/\*\*(-?\d+)\*\*/g, '$1')
|
|
1133
|
+
.replace(/__(-?\d+)__/g, '$1')
|
|
1134
|
+
.replace(/~~(-?\d+)~~/g, '$1');
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Evaluates a keep/drop modifier chain whose base target is a multi
|
|
1139
|
+
* sub-roll group. Each sub-roll is evaluated in isolation so its subtotal
|
|
1140
|
+
* and dice are captured separately. Synthetic dice — one per sub-roll,
|
|
1141
|
+
* `result = subtotal` — feed `mergeDropSets` to pick kept/dropped indices.
|
|
1142
|
+
* Dropped sub-rolls' inner dice are re-flagged `'dropped'` so `sumKeptDice`
|
|
1143
|
+
* on the propagated rolls still agrees with the group total, and the
|
|
1144
|
+
* rendered form wraps them in strikethrough `~~...~~`.
|
|
1145
|
+
*/
|
|
1146
|
+
function evalGroupModifier(
|
|
1147
|
+
node: ModifierNode,
|
|
1148
|
+
group: GroupNode,
|
|
1149
|
+
specs: ModifierSpec[],
|
|
1150
|
+
rng: RNG,
|
|
1151
|
+
ctx: EvalContext,
|
|
1152
|
+
env: EvalEnv,
|
|
1153
|
+
): EvalResult {
|
|
1154
|
+
type SubRoll = {
|
|
1155
|
+
subtotal: number;
|
|
1156
|
+
part: RollPart;
|
|
1157
|
+
rolls: DieResult[];
|
|
1158
|
+
expr: string;
|
|
1159
|
+
rendered: string;
|
|
1160
|
+
versusMetadata: EvalContext['versusMetadata'];
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
const subRolls: SubRoll[] = group.expressions.map((expr) => {
|
|
1164
|
+
const subCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
1165
|
+
const sub = evalNode(expr, rng, subCtx, env);
|
|
1166
|
+
return {
|
|
1167
|
+
subtotal: sub.total,
|
|
1168
|
+
part: sub.part,
|
|
1169
|
+
rolls: subCtx.rolls,
|
|
1170
|
+
expr: subCtx.expressionParts.join(''),
|
|
1171
|
+
rendered: subCtx.renderedParts.join(''),
|
|
1172
|
+
versusMetadata: subCtx.versusMetadata,
|
|
1173
|
+
};
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
// ? Synthetic dice carry `sides = 0` as a sentinel — they're never
|
|
1177
|
+
// surfaced in `ctx.rolls`, only used as input to `mergeDropSets`. Any
|
|
1178
|
+
// `critical`/`fumble` flags would be meaningless for a subtotal.
|
|
1179
|
+
const syntheticDice: DieResult[] = subRolls.map((sub) => ({
|
|
1180
|
+
sides: 0,
|
|
1181
|
+
result: sub.subtotal,
|
|
1182
|
+
modifiers: [],
|
|
1183
|
+
critical: false,
|
|
1184
|
+
fumble: false,
|
|
1185
|
+
}));
|
|
1186
|
+
|
|
1187
|
+
const mergedSynthetic = mergeDropSets(syntheticDice, specs);
|
|
1188
|
+
|
|
1189
|
+
const outerRendered: string[] = [];
|
|
1190
|
+
const keptIndices: number[] = [];
|
|
1191
|
+
let total = 0;
|
|
1192
|
+
|
|
1193
|
+
for (let i = 0; i < subRolls.length; i++) {
|
|
1194
|
+
const sub = subRolls[i] as SubRoll;
|
|
1195
|
+
const synth = mergedSynthetic[i] as DieResult;
|
|
1196
|
+
const isDropped = synth.modifiers.includes('dropped');
|
|
1197
|
+
|
|
1198
|
+
if (isDropped) {
|
|
1199
|
+
// Flag every inner die dropped so propagated rolls match the total.
|
|
1200
|
+
// Keep existing `'kept'` stripped — the sub-roll didn't contribute,
|
|
1201
|
+
// so its die-level kept annotation no longer applies. `'success'` /
|
|
1202
|
+
// `'failure'` are stripped too: the top-level successes/failures scan
|
|
1203
|
+
// must not count dice from a sub-roll that was dropped. Mutated in
|
|
1204
|
+
// place — the same objects live in the sub-roll's RollPart.
|
|
1205
|
+
for (const die of sub.rolls) {
|
|
1206
|
+
die.modifiers = [
|
|
1207
|
+
...die.modifiers.filter(
|
|
1208
|
+
(m) => m !== 'kept' && m !== 'dropped' && m !== 'success' && m !== 'failure',
|
|
1209
|
+
),
|
|
1210
|
+
'dropped',
|
|
1211
|
+
];
|
|
1212
|
+
}
|
|
1213
|
+
ctx.rolls.push(...sub.rolls);
|
|
1214
|
+
outerRendered.push(`~~${stripInnerMarkers(sub.rendered)}~~`);
|
|
1215
|
+
} else {
|
|
1216
|
+
keptIndices.push(i);
|
|
1217
|
+
ctx.rolls.push(...sub.rolls);
|
|
1218
|
+
outerRendered.push(sub.rendered);
|
|
1219
|
+
total += sub.subtotal;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// Propagate Versus metadata only from kept sub-rolls — `RollResult.degree`
|
|
1223
|
+
// must reflect dice that contributed to `RollResult.total`. Two kept
|
|
1224
|
+
// versus sub-rolls still collide here via the `NESTED_VERSUS` guard
|
|
1225
|
+
// inside `propagateMetadata`.
|
|
1226
|
+
if (!isDropped) {
|
|
1227
|
+
propagateMetadata(ctx, sub.versusMetadata);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const subExprStrs = subRolls.map((s) => s.expr);
|
|
1232
|
+
const modifierCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
1233
|
+
|
|
1234
|
+
ctx.expressionParts.push(`{${subExprStrs.join(', ')}}${modifierCodes}`);
|
|
1235
|
+
// ? Mirror `evalModifier`'s flat-pool rendering: modifier codes live in
|
|
1236
|
+
// `expressionParts` only — the per-sub strikethrough already signals
|
|
1237
|
+
// which sub-rolls were kept vs dropped.
|
|
1238
|
+
ctx.renderedParts.push(`{${outerRendered.join(', ')}}`);
|
|
1239
|
+
|
|
1240
|
+
// ? `keptIndices` lives on the inner `group` part (it describes sub-roll
|
|
1241
|
+
// selection), even though the outer modifier evaluation computed it —
|
|
1242
|
+
// the accepted model leak from STAGE3.md §5. Dropped sub-rolls keep
|
|
1243
|
+
// their complete parts; consumers filter by `keptIndices`.
|
|
1244
|
+
const groupPart: RollPart = {
|
|
1245
|
+
type: 'group',
|
|
1246
|
+
parts: subRolls.map((s) => s.part),
|
|
1247
|
+
keptIndices,
|
|
1248
|
+
total,
|
|
1249
|
+
...partSpan(group),
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
return {
|
|
1253
|
+
total,
|
|
1254
|
+
part: {
|
|
1255
|
+
type: 'modifier',
|
|
1256
|
+
specs: toPublicSpecs(specs),
|
|
1257
|
+
target: groupPart,
|
|
1258
|
+
total,
|
|
1259
|
+
...partSpan(node),
|
|
1260
|
+
},
|
|
1261
|
+
};
|
|
609
1262
|
}
|
|
610
1263
|
|
|
611
1264
|
function resolveThreshold(
|
|
612
1265
|
value: ASTNode,
|
|
613
1266
|
rng: RNG,
|
|
1267
|
+
ctx: EvalContext,
|
|
614
1268
|
env: EvalEnv,
|
|
615
1269
|
role: 'threshold' | 'fail threshold',
|
|
616
1270
|
): number {
|
|
617
1271
|
const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
618
|
-
const resolved = evalNode(value, rng, thresholdCtx, env);
|
|
1272
|
+
const resolved = evalNode(value, rng, thresholdCtx, env).total;
|
|
1273
|
+
mergeMetaRolls(ctx, thresholdCtx);
|
|
619
1274
|
|
|
620
1275
|
if (!Number.isFinite(resolved)) {
|
|
621
1276
|
throw new EvaluatorError(`Invalid ${role}: ${resolved}`, 'INVALID_THRESHOLD', 'SuccessCount');
|
|
@@ -629,27 +1284,49 @@ function evalSuccessCount(
|
|
|
629
1284
|
rng: RNG,
|
|
630
1285
|
ctx: EvalContext,
|
|
631
1286
|
env: EvalEnv,
|
|
632
|
-
):
|
|
1287
|
+
): EvalResult {
|
|
1288
|
+
// ? Flag tracks syntactic presence of success-count notation, not pool size —
|
|
1289
|
+
// set before any early return so empty pools still populate successes/failures.
|
|
1290
|
+
env.hasSuccessCount = true;
|
|
1291
|
+
|
|
633
1292
|
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
634
|
-
const
|
|
1293
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
635
1294
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
636
1295
|
|
|
637
|
-
const thresholdValue = resolveThreshold(node.threshold.value, rng, env, 'threshold');
|
|
1296
|
+
const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
|
|
638
1297
|
const failValue =
|
|
639
1298
|
node.failThreshold != null
|
|
640
|
-
? resolveThreshold(node.failThreshold.value, rng, env, 'fail threshold')
|
|
1299
|
+
? resolveThreshold(node.failThreshold.value, rng, ctx, env, 'fail threshold')
|
|
641
1300
|
: undefined;
|
|
642
1301
|
|
|
643
1302
|
const code = `${node.threshold.operator}${thresholdValue}${
|
|
644
1303
|
failValue != null ? `f${failValue}` : ''
|
|
645
1304
|
}`;
|
|
646
1305
|
|
|
647
|
-
|
|
648
|
-
|
|
1306
|
+
const buildPart = (total: number, successes: number, failures: number): RollPart => {
|
|
1307
|
+
const part: RollPart = {
|
|
1308
|
+
type: 'successCount',
|
|
1309
|
+
threshold: { operator: node.threshold.operator, value: thresholdValue },
|
|
1310
|
+
target: target.part,
|
|
1311
|
+
successes,
|
|
1312
|
+
failures,
|
|
1313
|
+
total,
|
|
1314
|
+
...partSpan(node),
|
|
1315
|
+
};
|
|
1316
|
+
if (failValue != null && node.failThreshold != null) {
|
|
1317
|
+
part.failThreshold = { operator: node.failThreshold.operator, value: failValue };
|
|
1318
|
+
}
|
|
1319
|
+
return part;
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
// No-op when the target produced no dice in its pool (`0d6>=4`).
|
|
1323
|
+
// `containsDicePool` should already reject dice-less targets at parse
|
|
1324
|
+
// time, but guard defensively. `targetValue` is 0 for an empty pool, so
|
|
1325
|
+
// the `total === successes - failures` invariant holds.
|
|
649
1326
|
if (targetCtx.rolls.length === 0) {
|
|
650
1327
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
651
1328
|
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
652
|
-
return
|
|
1329
|
+
return { total: target.total, part: buildPart(target.total, 0, 0) };
|
|
653
1330
|
}
|
|
654
1331
|
|
|
655
1332
|
const result = countSuccesses(
|
|
@@ -660,28 +1337,42 @@ function evalSuccessCount(
|
|
|
660
1337
|
: undefined,
|
|
661
1338
|
);
|
|
662
1339
|
|
|
663
|
-
env.hasSuccessCount = true;
|
|
664
|
-
|
|
665
1340
|
ctx.rolls.push(...targetCtx.rolls);
|
|
666
1341
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
667
1342
|
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
|
|
668
1343
|
|
|
669
|
-
return
|
|
1344
|
+
return {
|
|
1345
|
+
total: result.total,
|
|
1346
|
+
part: buildPart(result.total, result.successes, result.failures),
|
|
1347
|
+
};
|
|
670
1348
|
}
|
|
671
1349
|
|
|
672
1350
|
/**
|
|
673
1351
|
* Extracts the "natural" d20 value from a roll-side dice pool. Returns the
|
|
674
|
-
* single value when exactly one kept d20 is present; otherwise
|
|
1352
|
+
* single value when exactly one primary kept d20 is present; otherwise
|
|
1353
|
+
* `undefined`.
|
|
675
1354
|
*
|
|
676
|
-
* Excludes dropped (`kh`/`kl`/`dh`/`dl
|
|
677
|
-
*
|
|
678
|
-
* `
|
|
1355
|
+
* Excludes dropped (`kh`/`kl`/`dh`/`dl`/`r`/`ro`) dice — these aren't the
|
|
1356
|
+
* final kept result. Explosion continuation dice (appended by standard/
|
|
1357
|
+
* penetrating explode, tagged `'exploded'` with no `initialResult`) are not
|
|
1358
|
+
* primaries either — `1d20! vs DC` keeps the natural from the original d20.
|
|
1359
|
+
* Compound explode accumulates into the original die and sets
|
|
1360
|
+
* `initialResult`, so it stays a primary and the raw first face is used.
|
|
1361
|
+
* Multiple primary kept d20s (e.g., `1d20+1d20`) yield `undefined` so no
|
|
1362
|
+
* ambiguous upgrade/downgrade is applied.
|
|
679
1363
|
*/
|
|
680
1364
|
function extractNatural(rolls: DieResult[]): number | undefined {
|
|
681
|
-
|
|
682
|
-
|
|
1365
|
+
// Rerolled intermediates are always stamped `['rerolled', 'dropped']`
|
|
1366
|
+
// (see `modifiers/reroll.ts`), so filtering by `'dropped'` covers them.
|
|
1367
|
+
const primaries = rolls.filter(
|
|
1368
|
+
(d) =>
|
|
1369
|
+
d.sides === 20 &&
|
|
1370
|
+
!d.modifiers.includes('dropped') &&
|
|
1371
|
+
!(d.modifiers.includes('exploded') && d.initialResult === undefined),
|
|
683
1372
|
);
|
|
684
|
-
|
|
1373
|
+
if (primaries.length !== 1) return undefined;
|
|
1374
|
+
const die = primaries[0];
|
|
1375
|
+
return die?.initialResult ?? die?.result;
|
|
685
1376
|
}
|
|
686
1377
|
|
|
687
1378
|
/**
|
|
@@ -714,7 +1405,7 @@ function degreeLabel(degree: DegreeOfSuccess): string {
|
|
|
714
1405
|
}
|
|
715
1406
|
}
|
|
716
1407
|
|
|
717
|
-
function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
1408
|
+
function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
718
1409
|
if (env.insideVersus) {
|
|
719
1410
|
throw new EvaluatorError('Cannot nest versus operators', 'NESTED_VERSUS', 'Versus');
|
|
720
1411
|
}
|
|
@@ -722,15 +1413,15 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
722
1413
|
env.insideVersus = true;
|
|
723
1414
|
try {
|
|
724
1415
|
const rollCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
725
|
-
const
|
|
1416
|
+
const rollResult = evalNode(node.roll, rng, rollCtx, env);
|
|
726
1417
|
// ? Extract natural from rollCtx directly — the roll-side pool is isolated
|
|
727
1418
|
// here, so no index slicing on the merged parent pool is needed.
|
|
728
1419
|
const natural = extractNatural(rollCtx.rolls);
|
|
729
1420
|
|
|
730
1421
|
const dcCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
731
|
-
const
|
|
1422
|
+
const dcResult = evalNode(node.dc, rng, dcCtx, env);
|
|
732
1423
|
|
|
733
|
-
const degree = calculateDegree(
|
|
1424
|
+
const degree = calculateDegree(rollResult.total, dcResult.total, natural);
|
|
734
1425
|
|
|
735
1426
|
ctx.rolls.push(...rollCtx.rolls, ...dcCtx.rolls);
|
|
736
1427
|
|
|
@@ -741,9 +1432,19 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
741
1432
|
|
|
742
1433
|
ctx.expressionParts.push(`${rollExpr} vs ${dcExpr}`);
|
|
743
1434
|
ctx.renderedParts.push(`${rollRendered} vs ${dcRendered}`);
|
|
744
|
-
ctx.versusMetadata = { degree, natural, dcTotal };
|
|
745
|
-
|
|
746
|
-
return
|
|
1435
|
+
ctx.versusMetadata = { degree, natural, dcTotal: dcResult.total };
|
|
1436
|
+
|
|
1437
|
+
return {
|
|
1438
|
+
total: rollResult.total,
|
|
1439
|
+
part: {
|
|
1440
|
+
type: 'versus',
|
|
1441
|
+
roll: rollResult.part,
|
|
1442
|
+
dc: dcResult.part,
|
|
1443
|
+
degree,
|
|
1444
|
+
total: rollResult.total,
|
|
1445
|
+
...partSpan(node),
|
|
1446
|
+
},
|
|
1447
|
+
};
|
|
747
1448
|
} finally {
|
|
748
1449
|
env.insideVersus = false;
|
|
749
1450
|
}
|
|
@@ -785,6 +1486,9 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
785
1486
|
? Math.floor(options.maxRerollIterations)
|
|
786
1487
|
: DEFAULT_MAX_REROLL_ITERATIONS;
|
|
787
1488
|
|
|
1489
|
+
const context = options.context ?? {};
|
|
1490
|
+
const onMissingVariable = options.onMissingVariable ?? 'throw';
|
|
1491
|
+
|
|
788
1492
|
const env: EvalEnv = {
|
|
789
1493
|
maxDice,
|
|
790
1494
|
maxExplodeIterations,
|
|
@@ -792,6 +1496,8 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
792
1496
|
totalDiceRolled: 0,
|
|
793
1497
|
hasSuccessCount: false,
|
|
794
1498
|
insideVersus: false,
|
|
1499
|
+
context,
|
|
1500
|
+
onMissingVariable,
|
|
795
1501
|
};
|
|
796
1502
|
const ctx: EvalContext = {
|
|
797
1503
|
rolls: [],
|
|
@@ -799,7 +1505,15 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
799
1505
|
renderedParts: [],
|
|
800
1506
|
};
|
|
801
1507
|
|
|
802
|
-
const total = evalNode(ast, rng, ctx, env);
|
|
1508
|
+
const { total, part } = evalNode(ast, rng, ctx, env);
|
|
1509
|
+
|
|
1510
|
+
if (!Number.isFinite(total)) {
|
|
1511
|
+
throw new EvaluatorError(
|
|
1512
|
+
`Result is not a finite number: ${total}`,
|
|
1513
|
+
'NON_FINITE_RESULT',
|
|
1514
|
+
ast.type,
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
803
1517
|
|
|
804
1518
|
const expression = ctx.expressionParts.join('');
|
|
805
1519
|
// ? Versus replaces the numeric total with the degree label in the rendered
|
|
@@ -813,6 +1527,7 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
813
1527
|
expression,
|
|
814
1528
|
rendered,
|
|
815
1529
|
rolls: ctx.rolls,
|
|
1530
|
+
parts: part,
|
|
816
1531
|
};
|
|
817
1532
|
|
|
818
1533
|
if (env.hasSuccessCount) {
|