roll-parser 2.3.2 → 3.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/README.md +175 -170
- package/dist/cli/args.d.ts +35 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/format.d.ts +20 -0
- package/dist/cli/format.d.ts.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli.js +2608 -0
- package/dist/cli.js.map +28 -0
- package/dist/errors.d.ts +42 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/evaluator/evaluator.d.ts +121 -0
- package/dist/evaluator/evaluator.d.ts.map +1 -0
- package/dist/evaluator/index.d.ts +8 -0
- package/dist/evaluator/index.d.ts.map +1 -0
- package/dist/evaluator/modifiers/compare.d.ts +15 -0
- package/dist/evaluator/modifiers/compare.d.ts.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts +28 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
- package/dist/evaluator/modifiers/explode.d.ts +48 -0
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts +44 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -0
- package/dist/evaluator/modifiers/reroll.d.ts +33 -0
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -0
- package/dist/evaluator/modifiers/sort.d.ts +23 -0
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
- package/dist/evaluator/modifiers/success-count.d.ts +28 -0
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2560 -0
- package/dist/index.js.map +26 -0
- package/dist/lexer/lexer.d.ts +90 -0
- package/dist/lexer/lexer.d.ts.map +1 -0
- package/dist/lexer/tokens.d.ts +107 -0
- package/dist/lexer/tokens.d.ts.map +1 -0
- package/dist/parser/ast.d.ts +368 -0
- package/dist/parser/ast.d.ts.map +1 -0
- package/dist/parser/parser.d.ts +145 -0
- package/dist/parser/parser.d.ts.map +1 -0
- package/dist/rng/index.d.ts +8 -0
- package/dist/rng/index.d.ts.map +1 -0
- package/dist/rng/mock.d.ts +36 -0
- package/dist/rng/mock.d.ts.map +1 -0
- package/dist/rng/seeded.d.ts +40 -0
- package/dist/rng/seeded.d.ts.map +1 -0
- package/dist/rng/types.d.ts +24 -0
- package/dist/rng/types.d.ts.map +1 -0
- package/dist/roll.d.ts +51 -0
- package/dist/roll.d.ts.map +1 -0
- package/dist/testing.d.ts +11 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +41 -0
- package/dist/testing.js.map +11 -0
- package/dist/types.d.ts +246 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +66 -43
- package/src/cli/args.ts +73 -0
- package/src/cli/format.ts +49 -0
- package/src/cli/index.ts +92 -0
- package/src/errors.ts +90 -0
- package/src/evaluator/evaluator.ts +1552 -0
- package/src/evaluator/index.ts +14 -0
- package/src/evaluator/modifiers/compare.ts +29 -0
- package/src/evaluator/modifiers/crit-threshold.ts +59 -0
- package/src/evaluator/modifiers/explode.ts +219 -0
- package/src/evaluator/modifiers/keep-drop.ts +151 -0
- package/src/evaluator/modifiers/reroll.ts +165 -0
- package/src/evaluator/modifiers/sort.ts +30 -0
- package/src/evaluator/modifiers/success-count.ts +59 -0
- package/src/index.ts +90 -0
- package/src/lexer/lexer.ts +374 -0
- package/src/lexer/tokens.ts +187 -0
- package/src/parser/ast.ts +628 -0
- package/src/parser/parser.ts +1239 -0
- package/src/rng/index.ts +8 -0
- package/src/rng/mock.ts +65 -0
- package/src/rng/seeded.ts +157 -0
- package/src/rng/types.ts +25 -0
- package/src/roll.ts +74 -0
- package/src/testing.ts +16 -0
- package/src/types.ts +237 -0
- package/bin/help.txt +0 -19
- package/bin/roll-parser.js +0 -23
- package/dist/roll-parser.js +0 -697
- package/dist/roll-parser.min.js +0 -1
- package/index.js +0 -24
- package/src/complex/parse.js +0 -37
- package/src/complex/parseAndRoll.js +0 -21
- package/src/complex/parseAndRollClassic.js +0 -20
- package/src/complex/parseAndRollSimple.js +0 -19
- package/src/complex/parseAndRollWod.js +0 -20
- package/src/complex/parseClassicRoll.js +0 -22
- package/src/complex/parseSimpleRoll.js +0 -21
- package/src/complex/parseWodRoll.js +0 -21
- package/src/converter.js +0 -45
- package/src/grammar.js +0 -14
- package/src/mapper.js +0 -50
- package/src/normalizer.js +0 -51
- package/src/object/Result.js +0 -23
- package/src/object/Roll.js +0 -26
- package/src/object/Type.js +0 -5
- package/src/object/WodRoll.js +0 -29
- package/src/parser.js +0 -43
- package/src/random.js +0 -22
- package/src/roller.js +0 -106
- package/src/stringifier.js +0 -33
|
@@ -0,0 +1,1552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST evaluator - transforms parsed AST into roll results.
|
|
3
|
+
*
|
|
4
|
+
* @module evaluator/evaluator
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RollParserErrorCode } from '../errors.js';
|
|
8
|
+
import { RollParserError } from '../errors.js';
|
|
9
|
+
import type {
|
|
10
|
+
ASTNode,
|
|
11
|
+
BinaryOpNode,
|
|
12
|
+
CritThreshold,
|
|
13
|
+
CritThresholdNode,
|
|
14
|
+
DiceNode,
|
|
15
|
+
ExplodeNode,
|
|
16
|
+
FateDiceNode,
|
|
17
|
+
FunctionCallNode,
|
|
18
|
+
GroupedNode,
|
|
19
|
+
GroupNode,
|
|
20
|
+
LiteralNode,
|
|
21
|
+
ModifierNode,
|
|
22
|
+
RerollNode,
|
|
23
|
+
SortNode,
|
|
24
|
+
SuccessCountNode,
|
|
25
|
+
UnaryOpNode,
|
|
26
|
+
VariableNode,
|
|
27
|
+
VersusNode,
|
|
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';
|
|
41
|
+
import {
|
|
42
|
+
applyCompoundExplode,
|
|
43
|
+
applyPenetratingExplode,
|
|
44
|
+
applyStandardExplode,
|
|
45
|
+
buildShouldExplode,
|
|
46
|
+
DEFAULT_MAX_EXPLODE_ITERATIONS,
|
|
47
|
+
} from './modifiers/explode.js';
|
|
48
|
+
import {
|
|
49
|
+
applyDropHighest,
|
|
50
|
+
applyDropLowest,
|
|
51
|
+
applyKeepHighest,
|
|
52
|
+
applyKeepLowest,
|
|
53
|
+
markAllKept,
|
|
54
|
+
sumKeptDice,
|
|
55
|
+
} from './modifiers/keep-drop.js';
|
|
56
|
+
import {
|
|
57
|
+
applyRecursiveReroll,
|
|
58
|
+
applyRerollOnce,
|
|
59
|
+
DEFAULT_MAX_REROLL_ITERATIONS,
|
|
60
|
+
} from './modifiers/reroll.js';
|
|
61
|
+
import { sortDice } from './modifiers/sort.js';
|
|
62
|
+
import { countSuccesses } from './modifiers/success-count.js';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Error thrown during AST evaluation.
|
|
66
|
+
*/
|
|
67
|
+
export class EvaluatorError extends RollParserError {
|
|
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;
|
|
76
|
+
|
|
77
|
+
constructor(message: string, code: RollParserErrorCode, nodeType?: string) {
|
|
78
|
+
super(message, code);
|
|
79
|
+
this.name = 'EvaluatorError';
|
|
80
|
+
this.nodeType = nodeType ?? undefined;
|
|
81
|
+
this.start = undefined;
|
|
82
|
+
this.end = undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Default maximum total dice allowed per evaluation. */
|
|
87
|
+
export const DEFAULT_MAX_DICE = 10_000;
|
|
88
|
+
|
|
89
|
+
export { DEFAULT_MAX_EXPLODE_ITERATIONS, DEFAULT_MAX_REROLL_ITERATIONS };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Per-evaluation shared environment (created once, shared across all branches).
|
|
93
|
+
*
|
|
94
|
+
* Exported for use by modifier implementations under `./modifiers/*`. Not part
|
|
95
|
+
* of the public library API.
|
|
96
|
+
*/
|
|
97
|
+
export type EvalEnv = {
|
|
98
|
+
readonly maxDice: number;
|
|
99
|
+
readonly maxExplodeIterations: number;
|
|
100
|
+
readonly maxRerollIterations: number;
|
|
101
|
+
totalDiceRolled: number;
|
|
102
|
+
/**
|
|
103
|
+
* Set to `true` by `evalSuccessCount`. Propagates through the shared env
|
|
104
|
+
* so `evaluate()` can include `successes`/`failures` fields even when no
|
|
105
|
+
* die was tagged (impossible threshold).
|
|
106
|
+
*/
|
|
107
|
+
hasSuccessCount: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* `true` while the evaluator is inside a `VersusNode`'s roll or DC
|
|
110
|
+
* sub-evaluation. `evalVersus` rejects nesting via this flag — catches
|
|
111
|
+
* paren-nested versus (`1d20 vs (5 vs 3)`) that slip past the parser's
|
|
112
|
+
* left-chain check.
|
|
113
|
+
*/
|
|
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';
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
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.
|
|
132
|
+
*/
|
|
133
|
+
export type EvalContext = {
|
|
134
|
+
rolls: DieResult[];
|
|
135
|
+
expressionParts: string[];
|
|
136
|
+
renderedParts: string[];
|
|
137
|
+
/**
|
|
138
|
+
* Populated by `evalVersus` with the resolved degree and natural value.
|
|
139
|
+
* `evaluate()` reads this from the top-level ctx to surface `degree` and
|
|
140
|
+
* `natural` on the final `RollResult`. Only populated when `vs` is the
|
|
141
|
+
* root of the expression.
|
|
142
|
+
*/
|
|
143
|
+
versusMetadata?: {
|
|
144
|
+
degree: DegreeOfSuccess;
|
|
145
|
+
natural: number | undefined;
|
|
146
|
+
dcTotal: number;
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Flattened representation of a keep/drop modifier for chain evaluation.
|
|
152
|
+
* Superset of the public `ModifierSpec` (adds the notation `code`).
|
|
153
|
+
*/
|
|
154
|
+
type ModifierSpec = {
|
|
155
|
+
modifier: 'keep' | 'drop';
|
|
156
|
+
selector: 'highest' | 'lowest';
|
|
157
|
+
count: number;
|
|
158
|
+
code: string;
|
|
159
|
+
};
|
|
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
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Creates a new die result with critical/fumble detection.
|
|
189
|
+
*/
|
|
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).
|
|
193
|
+
return {
|
|
194
|
+
sides,
|
|
195
|
+
result,
|
|
196
|
+
modifiers: [],
|
|
197
|
+
critical: result === sides && sides > 1,
|
|
198
|
+
fumble: result === 1 && sides > 1,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Creates a Fate/Fudge die result. Uses `sides = 0` as a sentinel — Fate dice
|
|
204
|
+
* have no max-face concept, so `critical` and `fumble` are always `false`.
|
|
205
|
+
*/
|
|
206
|
+
function createFateDieResult(result: number): DieResult {
|
|
207
|
+
return {
|
|
208
|
+
sides: 0,
|
|
209
|
+
result,
|
|
210
|
+
modifiers: [],
|
|
211
|
+
critical: false,
|
|
212
|
+
fumble: false,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Renders dice results for display. Marker priority: dropped wins over
|
|
218
|
+
* success/failure (dropped dice are never counted), success wins over
|
|
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.
|
|
224
|
+
*/
|
|
225
|
+
function renderDice(dice: DieResult[]): string {
|
|
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
|
+
});
|
|
240
|
+
return `[${parts.join(', ')}]`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
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.
|
|
280
|
+
*/
|
|
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 {
|
|
294
|
+
switch (node.type) {
|
|
295
|
+
case 'Literal':
|
|
296
|
+
return evalLiteral(node, ctx);
|
|
297
|
+
|
|
298
|
+
case 'Dice':
|
|
299
|
+
return evalDice(node, rng, ctx, env);
|
|
300
|
+
|
|
301
|
+
case 'FateDice':
|
|
302
|
+
return evalFateDice(node, rng, ctx, env);
|
|
303
|
+
|
|
304
|
+
case 'BinaryOp':
|
|
305
|
+
return evalBinaryOp(node, rng, ctx, env);
|
|
306
|
+
|
|
307
|
+
case 'UnaryOp':
|
|
308
|
+
return evalUnaryOp(node, rng, ctx, env);
|
|
309
|
+
|
|
310
|
+
case 'Modifier':
|
|
311
|
+
return evalModifier(node, rng, ctx, env);
|
|
312
|
+
|
|
313
|
+
case 'Explode':
|
|
314
|
+
return evalExplode(node, rng, ctx, env);
|
|
315
|
+
|
|
316
|
+
case 'Reroll':
|
|
317
|
+
return evalReroll(node, rng, ctx, env);
|
|
318
|
+
|
|
319
|
+
case 'SuccessCount':
|
|
320
|
+
return evalSuccessCount(node, rng, ctx, env);
|
|
321
|
+
|
|
322
|
+
case 'Versus':
|
|
323
|
+
return evalVersus(node, rng, ctx, env);
|
|
324
|
+
|
|
325
|
+
case 'FunctionCall':
|
|
326
|
+
return evalFunctionCall(node, rng, ctx, env);
|
|
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
|
+
|
|
343
|
+
default: {
|
|
344
|
+
const exhaustive: never = node;
|
|
345
|
+
throw new EvaluatorError(
|
|
346
|
+
`Unknown node type: ${(exhaustive as ASTNode).type}`,
|
|
347
|
+
'UNKNOWN_NODE_TYPE',
|
|
348
|
+
(exhaustive as ASTNode).type,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function evalLiteral(node: LiteralNode, ctx: EvalContext): EvalResult {
|
|
355
|
+
const { value } = node;
|
|
356
|
+
ctx.expressionParts.push(String(value));
|
|
357
|
+
ctx.renderedParts.push(String(value));
|
|
358
|
+
return { total: value, part: { type: 'literal', value, total: value, ...partSpan(node) } };
|
|
359
|
+
}
|
|
360
|
+
|
|
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);
|
|
432
|
+
|
|
433
|
+
if (!Number.isInteger(count) || count < 0) {
|
|
434
|
+
throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', 'Dice');
|
|
435
|
+
}
|
|
436
|
+
if (!Number.isInteger(sides) || sides < 1) {
|
|
437
|
+
throw new EvaluatorError(`Invalid dice sides: ${sides}`, 'INVALID_DICE_SIDES', 'Dice');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (env.totalDiceRolled + count > env.maxDice) {
|
|
441
|
+
throw new EvaluatorError(
|
|
442
|
+
`Total dice count ${env.totalDiceRolled + count} exceeds limit of ${env.maxDice}`,
|
|
443
|
+
'DICE_LIMIT_EXCEEDED',
|
|
444
|
+
'Dice',
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
env.totalDiceRolled += count;
|
|
448
|
+
|
|
449
|
+
const dice: DieResult[] = [];
|
|
450
|
+
for (let i = 0; i < count; i++) {
|
|
451
|
+
const result = rng.nextInt(1, sides);
|
|
452
|
+
dice.push(createDieResult(sides, result));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const markedDice = markAllKept(dice);
|
|
456
|
+
ctx.rolls.push(...markedDice);
|
|
457
|
+
|
|
458
|
+
const total = sumKeptDice(markedDice);
|
|
459
|
+
const notation = `${count}d${sides}`;
|
|
460
|
+
|
|
461
|
+
ctx.expressionParts.push(notation);
|
|
462
|
+
ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`);
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
total,
|
|
466
|
+
part: { type: 'dice', count, sides, rolls: markedDice, total, ...partSpan(node) },
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
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);
|
|
474
|
+
|
|
475
|
+
if (!Number.isInteger(count) || count < 0) {
|
|
476
|
+
throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', 'FateDice');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (env.totalDiceRolled + count > env.maxDice) {
|
|
480
|
+
throw new EvaluatorError(
|
|
481
|
+
`Total dice count ${env.totalDiceRolled + count} exceeds limit of ${env.maxDice}`,
|
|
482
|
+
'DICE_LIMIT_EXCEEDED',
|
|
483
|
+
'FateDice',
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
env.totalDiceRolled += count;
|
|
487
|
+
|
|
488
|
+
const dice: DieResult[] = [];
|
|
489
|
+
for (let i = 0; i < count; i++) {
|
|
490
|
+
const result = rng.nextInt(-1, 1);
|
|
491
|
+
dice.push(createFateDieResult(result));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const markedDice = markAllKept(dice);
|
|
495
|
+
ctx.rolls.push(...markedDice);
|
|
496
|
+
|
|
497
|
+
const total = sumKeptDice(markedDice);
|
|
498
|
+
const notation = `${count}dF`;
|
|
499
|
+
|
|
500
|
+
ctx.expressionParts.push(notation);
|
|
501
|
+
ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`);
|
|
502
|
+
|
|
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;
|
|
530
|
+
}
|
|
531
|
+
|
|
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 {
|
|
545
|
+
const leftCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
546
|
+
const rightCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
547
|
+
|
|
548
|
+
const left = evalNode(node.left, rng, leftCtx, env);
|
|
549
|
+
const right = evalNode(node.right, rng, rightCtx, env);
|
|
550
|
+
|
|
551
|
+
mergeContext(ctx, leftCtx);
|
|
552
|
+
mergeContext(ctx, rightCtx);
|
|
553
|
+
|
|
554
|
+
const leftExpr = leftCtx.expressionParts.join('');
|
|
555
|
+
const rightExpr = rightCtx.expressionParts.join('');
|
|
556
|
+
const leftRendered = leftCtx.renderedParts.join('');
|
|
557
|
+
const rightRendered = rightCtx.renderedParts.join('');
|
|
558
|
+
|
|
559
|
+
ctx.expressionParts.push(`${leftExpr} ${node.operator} ${rightExpr}`);
|
|
560
|
+
ctx.renderedParts.push(`${leftRendered} ${node.operator} ${rightRendered}`);
|
|
561
|
+
|
|
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) {
|
|
583
|
+
case '+':
|
|
584
|
+
return left + right;
|
|
585
|
+
case '-':
|
|
586
|
+
return left - right;
|
|
587
|
+
case '*':
|
|
588
|
+
return left * right;
|
|
589
|
+
case '/':
|
|
590
|
+
if (right === 0) {
|
|
591
|
+
throw new EvaluatorError('Division by zero', 'DIVISION_BY_ZERO', 'BinaryOp');
|
|
592
|
+
}
|
|
593
|
+
return left / right;
|
|
594
|
+
case '%':
|
|
595
|
+
if (right === 0) {
|
|
596
|
+
throw new EvaluatorError('Modulo by zero', 'MODULO_BY_ZERO', 'BinaryOp');
|
|
597
|
+
}
|
|
598
|
+
return left % right;
|
|
599
|
+
case '**':
|
|
600
|
+
return left ** right;
|
|
601
|
+
default: {
|
|
602
|
+
const exhaustive: never = operator;
|
|
603
|
+
throw new EvaluatorError(`Unknown operator: ${exhaustive}`, 'UNKNOWN_OPERATOR', 'BinaryOp');
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
609
|
+
const innerCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
610
|
+
const inner = evalNode(node.operand, rng, innerCtx, env);
|
|
611
|
+
|
|
612
|
+
mergeContext(ctx, innerCtx);
|
|
613
|
+
|
|
614
|
+
const innerExpr = innerCtx.expressionParts.join('');
|
|
615
|
+
const innerRendered = innerCtx.renderedParts.join('');
|
|
616
|
+
|
|
617
|
+
ctx.expressionParts.push(`-${innerExpr}`);
|
|
618
|
+
ctx.renderedParts.push(`-${innerRendered}`);
|
|
619
|
+
|
|
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) } };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function evalFunctionCall(
|
|
678
|
+
node: FunctionCallNode,
|
|
679
|
+
rng: RNG,
|
|
680
|
+
ctx: EvalContext,
|
|
681
|
+
env: EvalEnv,
|
|
682
|
+
): EvalResult {
|
|
683
|
+
const argCtxs: EvalContext[] = [];
|
|
684
|
+
const argResults: EvalResult[] = [];
|
|
685
|
+
|
|
686
|
+
for (const arg of node.args) {
|
|
687
|
+
const argCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
688
|
+
argResults.push(evalNode(arg, rng, argCtx, env));
|
|
689
|
+
argCtxs.push(argCtx);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
for (const argCtx of argCtxs) {
|
|
693
|
+
mergeContext(ctx, argCtx);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const argExprs = argCtxs.map((c) => c.expressionParts.join(''));
|
|
697
|
+
const argRendereds = argCtxs.map((c) => c.renderedParts.join(''));
|
|
698
|
+
|
|
699
|
+
ctx.expressionParts.push(`${node.name}(${argExprs.join(', ')})`);
|
|
700
|
+
ctx.renderedParts.push(`${node.name}(${argRendereds.join(', ')})`);
|
|
701
|
+
|
|
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
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function applyFunction(name: string, values: number[]): number {
|
|
720
|
+
switch (name) {
|
|
721
|
+
case 'floor':
|
|
722
|
+
return Math.floor(requireUnaryArg(name, values));
|
|
723
|
+
case 'ceil':
|
|
724
|
+
return Math.ceil(requireUnaryArg(name, values));
|
|
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`.
|
|
729
|
+
return Math.round(requireUnaryArg(name, values));
|
|
730
|
+
case 'abs':
|
|
731
|
+
return Math.abs(requireUnaryArg(name, values));
|
|
732
|
+
case 'max':
|
|
733
|
+
return Math.max(...values);
|
|
734
|
+
case 'min':
|
|
735
|
+
return Math.min(...values);
|
|
736
|
+
default:
|
|
737
|
+
throw new EvaluatorError(`Unknown function: ${name}`, 'UNKNOWN_FUNCTION', 'FunctionCall');
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function requireUnaryArg(name: string, values: number[]): number {
|
|
742
|
+
const [x] = values;
|
|
743
|
+
if (x === undefined) {
|
|
744
|
+
// ? Unreachable: parser validates arity before evaluation. Defensive for
|
|
745
|
+
// `noNonNullAssertion`.
|
|
746
|
+
throw new EvaluatorError(
|
|
747
|
+
`Function '${name}' requires an argument`,
|
|
748
|
+
'UNKNOWN_FUNCTION',
|
|
749
|
+
'FunctionCall',
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
return x;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Walks a nested ModifierNode chain, collecting specs outermost-first,
|
|
757
|
+
* then reverses to notation order (innermost-first).
|
|
758
|
+
*/
|
|
759
|
+
function flattenModifierChain(
|
|
760
|
+
node: ModifierNode,
|
|
761
|
+
rng: RNG,
|
|
762
|
+
ctx: EvalContext,
|
|
763
|
+
env: EvalEnv,
|
|
764
|
+
): { specs: ModifierSpec[]; baseTarget: ASTNode } {
|
|
765
|
+
const specs: ModifierSpec[] = [];
|
|
766
|
+
let current: ASTNode = node;
|
|
767
|
+
|
|
768
|
+
while (isModifier(current)) {
|
|
769
|
+
const countCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
770
|
+
const modCount = evalNode(current.count, rng, countCtx, env).total;
|
|
771
|
+
mergeMetaRolls(ctx, countCtx);
|
|
772
|
+
|
|
773
|
+
if (!Number.isInteger(modCount) || modCount < 0) {
|
|
774
|
+
throw new EvaluatorError(
|
|
775
|
+
`Invalid modifier count: ${modCount}`,
|
|
776
|
+
'INVALID_MODIFIER_COUNT',
|
|
777
|
+
'Modifier',
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const code =
|
|
782
|
+
current.modifier === 'keep'
|
|
783
|
+
? current.selector === 'highest'
|
|
784
|
+
? 'kh'
|
|
785
|
+
: 'kl'
|
|
786
|
+
: current.selector === 'highest'
|
|
787
|
+
? 'dh'
|
|
788
|
+
: 'dl';
|
|
789
|
+
|
|
790
|
+
specs.push({ modifier: current.modifier, selector: current.selector, count: modCount, code });
|
|
791
|
+
current = current.target;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
specs.reverse();
|
|
795
|
+
return { specs, baseTarget: current };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Applies a single modifier spec to a dice pool.
|
|
800
|
+
*/
|
|
801
|
+
function applyModifierSpec(dice: DieResult[], spec: ModifierSpec): DieResult[] {
|
|
802
|
+
if (spec.modifier === 'keep') {
|
|
803
|
+
return spec.selector === 'highest'
|
|
804
|
+
? applyKeepHighest(dice, spec.count)
|
|
805
|
+
: applyKeepLowest(dice, spec.count);
|
|
806
|
+
}
|
|
807
|
+
return spec.selector === 'highest'
|
|
808
|
+
? applyDropHighest(dice, spec.count)
|
|
809
|
+
: applyDropLowest(dice, spec.count);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Applies each modifier independently to the full dice pool
|
|
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.
|
|
820
|
+
*/
|
|
821
|
+
function mergeDropSets(baseDice: DieResult[], specs: ModifierSpec[]): DieResult[] {
|
|
822
|
+
const droppedIndices = new Set<number>();
|
|
823
|
+
|
|
824
|
+
for (const spec of specs) {
|
|
825
|
+
const result = applyModifierSpec(baseDice, spec);
|
|
826
|
+
for (let i = 0; i < result.length; i++) {
|
|
827
|
+
if (result[i]?.modifiers.includes('dropped')) {
|
|
828
|
+
droppedIndices.add(i);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
baseDice.forEach((die, index) => {
|
|
834
|
+
die.modifiers = droppedIndices.has(index)
|
|
835
|
+
? [...die.modifiers.filter((m) => m !== 'kept' && m !== 'dropped'), 'dropped']
|
|
836
|
+
: [...die.modifiers.filter((m) => m !== 'dropped' && m !== 'kept'), 'kept'];
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
return baseDice;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Builds the notation string for an explode modifier, e.g. `!`, `!!>=3`, `!p>5`.
|
|
844
|
+
*/
|
|
845
|
+
function formatExplodeCode(
|
|
846
|
+
variant: ExplodeNode['variant'],
|
|
847
|
+
threshold: ComparePoint | undefined,
|
|
848
|
+
thresholdValue: number | undefined,
|
|
849
|
+
): string {
|
|
850
|
+
const marker = variant === 'standard' ? '!' : variant === 'compound' ? '!!' : '!p';
|
|
851
|
+
if (threshold == null || thresholdValue == null) return marker;
|
|
852
|
+
return `${marker}${threshold.operator}${thresholdValue}`;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
856
|
+
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
857
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
858
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
859
|
+
|
|
860
|
+
let thresholdValue: number | undefined;
|
|
861
|
+
if (node.threshold != null) {
|
|
862
|
+
const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
863
|
+
thresholdValue = evalNode(node.threshold.value, rng, thresholdCtx, env).total;
|
|
864
|
+
mergeMetaRolls(ctx, thresholdCtx);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const code = formatExplodeCode(node.variant, node.threshold, thresholdValue);
|
|
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
|
+
|
|
883
|
+
// No-op when the target produced no dice (e.g., `(1+2)!`).
|
|
884
|
+
if (targetCtx.rolls.length === 0) {
|
|
885
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
886
|
+
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
887
|
+
return { total: target.total, part: buildPart(target.total) };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
|
|
891
|
+
|
|
892
|
+
const expanded =
|
|
893
|
+
node.variant === 'standard'
|
|
894
|
+
? applyStandardExplode(targetCtx.rolls, shouldExplode, rng, env)
|
|
895
|
+
: node.variant === 'compound'
|
|
896
|
+
? applyCompoundExplode(targetCtx.rolls, shouldExplode, rng, env)
|
|
897
|
+
: applyPenetratingExplode(targetCtx.rolls, shouldExplode, rng, env);
|
|
898
|
+
|
|
899
|
+
ctx.rolls.push(...expanded);
|
|
900
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
901
|
+
// ? Include the explode code in rendered output so readers can attribute
|
|
902
|
+
// the extra dice. Modifier rendering (kh/dl) skips its code because
|
|
903
|
+
// dropped dice are visible; explosion-origin is otherwise invisible.
|
|
904
|
+
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
|
|
905
|
+
|
|
906
|
+
const total = sumKeptDice(expanded);
|
|
907
|
+
return { total, part: buildPart(total) };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
911
|
+
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
912
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
913
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
914
|
+
|
|
915
|
+
const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
916
|
+
const thresholdValue = evalNode(node.condition.value, rng, thresholdCtx, env).total;
|
|
917
|
+
mergeMetaRolls(ctx, thresholdCtx);
|
|
918
|
+
|
|
919
|
+
const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
|
|
920
|
+
const condition: ResolvedComparePoint = {
|
|
921
|
+
operator: node.condition.operator,
|
|
922
|
+
value: thresholdValue,
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
// No-op when the target produced no dice (e.g., `(1+2)r<5`).
|
|
926
|
+
if (targetCtx.rolls.length === 0) {
|
|
927
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
928
|
+
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
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
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const pool = node.once
|
|
944
|
+
? applyRerollOnce(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env)
|
|
945
|
+
: applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
|
|
946
|
+
|
|
947
|
+
ctx.rolls.push(...pool);
|
|
948
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
949
|
+
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
|
|
950
|
+
|
|
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 };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
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
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
1097
|
+
const target = evalNode(baseTarget, rng, targetCtx, env);
|
|
1098
|
+
|
|
1099
|
+
const mergedDice = mergeDropSets(targetCtx.rolls, specs);
|
|
1100
|
+
|
|
1101
|
+
ctx.rolls.push(...mergedDice);
|
|
1102
|
+
|
|
1103
|
+
const total = sumKeptDice(mergedDice);
|
|
1104
|
+
|
|
1105
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
1106
|
+
const modifierCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
1107
|
+
|
|
1108
|
+
ctx.expressionParts.push(`${targetExpr}${modifierCodes}`);
|
|
1109
|
+
ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
|
|
1110
|
+
|
|
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
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function resolveThreshold(
|
|
1265
|
+
value: ASTNode,
|
|
1266
|
+
rng: RNG,
|
|
1267
|
+
ctx: EvalContext,
|
|
1268
|
+
env: EvalEnv,
|
|
1269
|
+
role: 'threshold' | 'fail threshold',
|
|
1270
|
+
): number {
|
|
1271
|
+
const thresholdCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
1272
|
+
const resolved = evalNode(value, rng, thresholdCtx, env).total;
|
|
1273
|
+
mergeMetaRolls(ctx, thresholdCtx);
|
|
1274
|
+
|
|
1275
|
+
if (!Number.isFinite(resolved)) {
|
|
1276
|
+
throw new EvaluatorError(`Invalid ${role}: ${resolved}`, 'INVALID_THRESHOLD', 'SuccessCount');
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
return resolved;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function evalSuccessCount(
|
|
1283
|
+
node: SuccessCountNode,
|
|
1284
|
+
rng: RNG,
|
|
1285
|
+
ctx: EvalContext,
|
|
1286
|
+
env: EvalEnv,
|
|
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
|
+
|
|
1292
|
+
const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
1293
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1294
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
1295
|
+
|
|
1296
|
+
const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
|
|
1297
|
+
const failValue =
|
|
1298
|
+
node.failThreshold != null
|
|
1299
|
+
? resolveThreshold(node.failThreshold.value, rng, ctx, env, 'fail threshold')
|
|
1300
|
+
: undefined;
|
|
1301
|
+
|
|
1302
|
+
const code = `${node.threshold.operator}${thresholdValue}${
|
|
1303
|
+
failValue != null ? `f${failValue}` : ''
|
|
1304
|
+
}`;
|
|
1305
|
+
|
|
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.
|
|
1326
|
+
if (targetCtx.rolls.length === 0) {
|
|
1327
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1328
|
+
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
1329
|
+
return { total: target.total, part: buildPart(target.total, 0, 0) };
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const result = countSuccesses(
|
|
1333
|
+
targetCtx.rolls,
|
|
1334
|
+
{ operator: node.threshold.operator, value: thresholdValue },
|
|
1335
|
+
failValue != null && node.failThreshold != null
|
|
1336
|
+
? { operator: node.failThreshold.operator, value: failValue }
|
|
1337
|
+
: undefined,
|
|
1338
|
+
);
|
|
1339
|
+
|
|
1340
|
+
ctx.rolls.push(...targetCtx.rolls);
|
|
1341
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1342
|
+
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
|
|
1343
|
+
|
|
1344
|
+
return {
|
|
1345
|
+
total: result.total,
|
|
1346
|
+
part: buildPart(result.total, result.successes, result.failures),
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Extracts the "natural" d20 value from a roll-side dice pool. Returns the
|
|
1352
|
+
* single value when exactly one primary kept d20 is present; otherwise
|
|
1353
|
+
* `undefined`.
|
|
1354
|
+
*
|
|
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.
|
|
1363
|
+
*/
|
|
1364
|
+
function extractNatural(rolls: DieResult[]): number | undefined {
|
|
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),
|
|
1372
|
+
);
|
|
1373
|
+
if (primaries.length !== 1) return undefined;
|
|
1374
|
+
const die = primaries[0];
|
|
1375
|
+
return die?.initialResult ?? die?.result;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* PF2e degree of success: compares `total` to `dc` at three thresholds and
|
|
1380
|
+
* applies natural 20 upgrade / natural 1 downgrade with clamping.
|
|
1381
|
+
*/
|
|
1382
|
+
function calculateDegree(total: number, dc: number, natural: number | undefined): DegreeOfSuccess {
|
|
1383
|
+
let degree: DegreeOfSuccess;
|
|
1384
|
+
if (total >= dc + 10) degree = DegreeOfSuccess.CriticalSuccess;
|
|
1385
|
+
else if (total >= dc) degree = DegreeOfSuccess.Success;
|
|
1386
|
+
else if (total > dc - 10) degree = DegreeOfSuccess.Failure;
|
|
1387
|
+
else degree = DegreeOfSuccess.CriticalFailure;
|
|
1388
|
+
|
|
1389
|
+
if (natural === 20 && degree < DegreeOfSuccess.CriticalSuccess) degree++;
|
|
1390
|
+
if (natural === 1 && degree > DegreeOfSuccess.CriticalFailure) degree--;
|
|
1391
|
+
|
|
1392
|
+
return degree;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function degreeLabel(degree: DegreeOfSuccess): string {
|
|
1396
|
+
switch (degree) {
|
|
1397
|
+
case DegreeOfSuccess.CriticalFailure:
|
|
1398
|
+
return 'Critical Failure';
|
|
1399
|
+
case DegreeOfSuccess.Failure:
|
|
1400
|
+
return 'Failure';
|
|
1401
|
+
case DegreeOfSuccess.Success:
|
|
1402
|
+
return 'Success';
|
|
1403
|
+
case DegreeOfSuccess.CriticalSuccess:
|
|
1404
|
+
return 'Critical Success';
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
1409
|
+
if (env.insideVersus) {
|
|
1410
|
+
throw new EvaluatorError('Cannot nest versus operators', 'NESTED_VERSUS', 'Versus');
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
env.insideVersus = true;
|
|
1414
|
+
try {
|
|
1415
|
+
const rollCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
1416
|
+
const rollResult = evalNode(node.roll, rng, rollCtx, env);
|
|
1417
|
+
// ? Extract natural from rollCtx directly — the roll-side pool is isolated
|
|
1418
|
+
// here, so no index slicing on the merged parent pool is needed.
|
|
1419
|
+
const natural = extractNatural(rollCtx.rolls);
|
|
1420
|
+
|
|
1421
|
+
const dcCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] };
|
|
1422
|
+
const dcResult = evalNode(node.dc, rng, dcCtx, env);
|
|
1423
|
+
|
|
1424
|
+
const degree = calculateDegree(rollResult.total, dcResult.total, natural);
|
|
1425
|
+
|
|
1426
|
+
ctx.rolls.push(...rollCtx.rolls, ...dcCtx.rolls);
|
|
1427
|
+
|
|
1428
|
+
const rollExpr = rollCtx.expressionParts.join('');
|
|
1429
|
+
const dcExpr = dcCtx.expressionParts.join('');
|
|
1430
|
+
const rollRendered = rollCtx.renderedParts.join('');
|
|
1431
|
+
const dcRendered = dcCtx.renderedParts.join('');
|
|
1432
|
+
|
|
1433
|
+
ctx.expressionParts.push(`${rollExpr} vs ${dcExpr}`);
|
|
1434
|
+
ctx.renderedParts.push(`${rollRendered} vs ${dcRendered}`);
|
|
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
|
+
};
|
|
1448
|
+
} finally {
|
|
1449
|
+
env.insideVersus = false;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Evaluates a parsed AST and returns the roll result.
|
|
1455
|
+
*
|
|
1456
|
+
* @param ast - The parsed AST node
|
|
1457
|
+
* @param rng - Random number generator to use for dice rolls
|
|
1458
|
+
* @param options - Optional evaluation options
|
|
1459
|
+
* @returns Complete roll result with total and metadata
|
|
1460
|
+
*
|
|
1461
|
+
* @example
|
|
1462
|
+
* ```typescript
|
|
1463
|
+
* const ast = parse('2d6+3');
|
|
1464
|
+
* const rng = new SeededRNG('test');
|
|
1465
|
+
* const result = evaluate(ast, rng);
|
|
1466
|
+
* console.log(result.total); // Sum of dice plus 3
|
|
1467
|
+
* ```
|
|
1468
|
+
*/
|
|
1469
|
+
export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}): RollResult {
|
|
1470
|
+
const maxDice =
|
|
1471
|
+
options.maxDice != null && Number.isFinite(options.maxDice) && options.maxDice > 0
|
|
1472
|
+
? Math.floor(options.maxDice)
|
|
1473
|
+
: DEFAULT_MAX_DICE;
|
|
1474
|
+
|
|
1475
|
+
const maxExplodeIterations =
|
|
1476
|
+
options.maxExplodeIterations != null &&
|
|
1477
|
+
Number.isFinite(options.maxExplodeIterations) &&
|
|
1478
|
+
options.maxExplodeIterations >= 0
|
|
1479
|
+
? Math.floor(options.maxExplodeIterations)
|
|
1480
|
+
: DEFAULT_MAX_EXPLODE_ITERATIONS;
|
|
1481
|
+
|
|
1482
|
+
const maxRerollIterations =
|
|
1483
|
+
options.maxRerollIterations != null &&
|
|
1484
|
+
Number.isFinite(options.maxRerollIterations) &&
|
|
1485
|
+
options.maxRerollIterations >= 0
|
|
1486
|
+
? Math.floor(options.maxRerollIterations)
|
|
1487
|
+
: DEFAULT_MAX_REROLL_ITERATIONS;
|
|
1488
|
+
|
|
1489
|
+
const context = options.context ?? {};
|
|
1490
|
+
const onMissingVariable = options.onMissingVariable ?? 'throw';
|
|
1491
|
+
|
|
1492
|
+
const env: EvalEnv = {
|
|
1493
|
+
maxDice,
|
|
1494
|
+
maxExplodeIterations,
|
|
1495
|
+
maxRerollIterations,
|
|
1496
|
+
totalDiceRolled: 0,
|
|
1497
|
+
hasSuccessCount: false,
|
|
1498
|
+
insideVersus: false,
|
|
1499
|
+
context,
|
|
1500
|
+
onMissingVariable,
|
|
1501
|
+
};
|
|
1502
|
+
const ctx: EvalContext = {
|
|
1503
|
+
rolls: [],
|
|
1504
|
+
expressionParts: [],
|
|
1505
|
+
renderedParts: [],
|
|
1506
|
+
};
|
|
1507
|
+
|
|
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
|
+
}
|
|
1517
|
+
|
|
1518
|
+
const expression = ctx.expressionParts.join('');
|
|
1519
|
+
// ? Versus replaces the numeric total with the degree label in the rendered
|
|
1520
|
+
// form; `RollResult.total` remains the numeric roll total.
|
|
1521
|
+
const trailing = ctx.versusMetadata ? degreeLabel(ctx.versusMetadata.degree) : String(total);
|
|
1522
|
+
const rendered = `${ctx.renderedParts.join('')} = ${trailing}`;
|
|
1523
|
+
|
|
1524
|
+
const result: RollResult = {
|
|
1525
|
+
total,
|
|
1526
|
+
notation: options.notation ?? expression,
|
|
1527
|
+
expression,
|
|
1528
|
+
rendered,
|
|
1529
|
+
rolls: ctx.rolls,
|
|
1530
|
+
parts: part,
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
if (env.hasSuccessCount) {
|
|
1534
|
+
let successes = 0;
|
|
1535
|
+
let failures = 0;
|
|
1536
|
+
for (const die of ctx.rolls) {
|
|
1537
|
+
if (die.modifiers.includes('success')) successes += 1;
|
|
1538
|
+
else if (die.modifiers.includes('failure')) failures += 1;
|
|
1539
|
+
}
|
|
1540
|
+
result.successes = successes;
|
|
1541
|
+
result.failures = failures;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
if (ctx.versusMetadata) {
|
|
1545
|
+
result.degree = ctx.versusMetadata.degree;
|
|
1546
|
+
if (ctx.versusMetadata.natural !== undefined) {
|
|
1547
|
+
result.natural = ctx.versusMetadata.natural;
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
return result;
|
|
1552
|
+
}
|