roll-parser 3.0.0-beta.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -9
- package/MIGRATION.md +147 -0
- package/README.md +960 -154
- package/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +81 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/format.d.ts +15 -3
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +17 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +14 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/main.d.ts +36 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +83 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/errors.d.ts +332 -17
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +141 -0
- package/dist/errors.js.map +1 -0
- package/dist/evaluator/die.d.ts +26 -0
- package/dist/evaluator/die.d.ts.map +1 -0
- package/dist/evaluator/die.js +19 -0
- package/dist/evaluator/die.js.map +1 -0
- package/dist/evaluator/env.d.ts +58 -0
- package/dist/evaluator/env.d.ts.map +1 -0
- package/dist/evaluator/env.js +11 -0
- package/dist/evaluator/env.js.map +1 -0
- package/dist/evaluator/evaluator.d.ts +41 -75
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +906 -0
- package/dist/evaluator/evaluator.js.map +1 -0
- package/dist/evaluator/modifiers/compare.js +15 -0
- package/dist/evaluator/modifiers/compare.js.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts +0 -1
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.js +23 -0
- package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
- package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
- package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
- package/dist/evaluator/modifiers/die-bound.js +14 -0
- package/dist/evaluator/modifiers/die-bound.js.map +1 -0
- package/dist/evaluator/modifiers/explode.d.ts +13 -5
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/explode.js +103 -0
- package/dist/evaluator/modifiers/explode.js.map +1 -0
- package/dist/evaluator/modifiers/flags.d.ts +37 -0
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
- package/dist/evaluator/modifiers/flags.js +18 -0
- package/dist/evaluator/modifiers/flags.js.map +1 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.js +82 -0
- package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
- package/dist/evaluator/modifiers/reroll.d.ts +12 -4
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
- package/dist/evaluator/modifiers/reroll.js +62 -0
- package/dist/evaluator/modifiers/reroll.js.map +1 -0
- package/dist/evaluator/modifiers/sort.d.ts +4 -0
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
- package/dist/evaluator/modifiers/sort.js +13 -0
- package/dist/evaluator/modifiers/sort.js.map +1 -0
- package/dist/evaluator/modifiers/success-count.d.ts +2 -6
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +24 -0
- package/dist/evaluator/modifiers/success-count.js.map +1 -0
- package/dist/index.d.ts +33 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2560
- package/dist/index.js.map +1 -26
- package/dist/lexer/lexer.d.ts +50 -5
- package/dist/lexer/lexer.d.ts.map +1 -1
- package/dist/lexer/lexer.js +260 -0
- package/dist/lexer/lexer.js.map +1 -0
- package/dist/lexer/tokens.d.ts +31 -6
- package/dist/lexer/tokens.d.ts.map +1 -1
- package/dist/lexer/tokens.js +42 -0
- package/dist/lexer/tokens.js.map +1 -0
- package/dist/parser/ast.d.ts +314 -160
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/ast.js +52 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/guards.d.ts +106 -0
- package/dist/parser/guards.d.ts.map +1 -0
- package/dist/parser/guards.js +121 -0
- package/dist/parser/guards.js.map +1 -0
- package/dist/parser/parser.d.ts +118 -14
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +751 -0
- package/dist/parser/parser.js.map +1 -0
- package/dist/rng/mock.d.ts +73 -12
- package/dist/rng/mock.d.ts.map +1 -1
- package/dist/rng/mock.js +30 -0
- package/dist/rng/mock.js.map +1 -0
- package/dist/rng/seeded.d.ts +141 -9
- package/dist/rng/seeded.d.ts.map +1 -1
- package/dist/rng/seeded.js +138 -0
- package/dist/rng/seeded.js.map +1 -0
- package/dist/rng/types.d.ts +57 -0
- package/dist/rng/types.d.ts.map +1 -1
- package/dist/rng/types.js +2 -0
- package/dist/rng/types.js.map +1 -0
- package/dist/roll.d.ts +58 -28
- package/dist/roll.d.ts.map +1 -1
- package/dist/roll.js +8 -0
- package/dist/roll.js.map +1 -0
- package/dist/testing.d.ts +5 -4
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +2 -41
- package/dist/testing.js.map +1 -11
- package/dist/types.d.ts +306 -44
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +83 -34
- package/src/cli/args.ts +66 -10
- package/src/cli/format.ts +22 -3
- package/src/cli/index.ts +27 -84
- package/src/cli/main.ts +129 -0
- package/src/errors.ts +480 -27
- package/src/evaluator/die.ts +50 -0
- package/src/evaluator/env.ts +73 -0
- package/src/evaluator/evaluator.ts +653 -421
- package/src/evaluator/modifiers/crit-threshold.ts +3 -6
- package/src/evaluator/modifiers/die-bound.ts +39 -0
- package/src/evaluator/modifiers/explode.ts +60 -58
- package/src/evaluator/modifiers/flags.ts +61 -0
- package/src/evaluator/modifiers/keep-drop.ts +124 -126
- package/src/evaluator/modifiers/reroll.ts +28 -49
- package/src/evaluator/modifiers/sort.ts +14 -1
- package/src/evaluator/modifiers/success-count.ts +5 -8
- package/src/index.ts +56 -35
- package/src/lexer/lexer.ts +107 -34
- package/src/lexer/tokens.ts +31 -6
- package/src/parser/ast.ts +323 -341
- package/src/parser/guards.ts +248 -0
- package/src/parser/parser.ts +419 -242
- package/src/rng/mock.ts +74 -13
- package/src/rng/seeded.ts +299 -64
- package/src/rng/types.ts +57 -0
- package/src/roll.ts +64 -47
- package/src/testing.ts +5 -9
- package/src/types.ts +310 -43
- package/src/version.ts +2 -0
- package/dist/cli.js +0 -2608
- package/dist/cli.js.map +0 -28
- package/dist/evaluator/index.d.ts +0 -8
- package/dist/evaluator/index.d.ts.map +0 -1
- package/dist/rng/index.d.ts +0 -8
- package/dist/rng/index.d.ts.map +0 -1
- package/src/evaluator/index.ts +0 -14
- package/src/rng/index.ts +0 -8
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
import { describeValue, EvaluatorError, RollParserError, stampEvaluatorSpan } from '../errors.js';
|
|
2
|
+
import { isKeepDrop } from '../parser/ast.js';
|
|
3
|
+
import { containsVersus } from '../parser/guards.js';
|
|
4
|
+
import { DegreeOfSuccess } from '../types.js';
|
|
5
|
+
import { createDieResult, createFateDieResult } from './die.js';
|
|
6
|
+
import { chargeDice } from './env.js';
|
|
7
|
+
import { applyCritThresholds } from './modifiers/crit-threshold.js';
|
|
8
|
+
import { applyDieBound } from './modifiers/die-bound.js';
|
|
9
|
+
import { applyCompoundExplode, applyPenetratingExplode, applyStandardExplode, buildShouldExplode, DEFAULT_MAX_EXPLODE_ITERATIONS, } from './modifiers/explode.js';
|
|
10
|
+
import { isVersusDc, rewriteFlags, SELECTION_AND_TALLY_FLAGS, SELECTION_FLAGS, } from './modifiers/flags.js';
|
|
11
|
+
import { markDroppedIndices, sumKeptDice } from './modifiers/keep-drop.js';
|
|
12
|
+
import { applyRecursiveReroll, applyRerollOnce, DEFAULT_MAX_REROLL_ITERATIONS, } from './modifiers/reroll.js';
|
|
13
|
+
import { sortDice } from './modifiers/sort.js';
|
|
14
|
+
import { countSuccesses } from './modifiers/success-count.js';
|
|
15
|
+
export { EvaluatorError };
|
|
16
|
+
export const DEFAULT_MAX_DICE = 10_000;
|
|
17
|
+
const MAX_DICE_SIDES = Number.MAX_SAFE_INTEGER;
|
|
18
|
+
function resolveLimit(value, option, fallback, min) {
|
|
19
|
+
if (value == null)
|
|
20
|
+
return fallback;
|
|
21
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min) {
|
|
22
|
+
throw new RollParserError(`Option '${option}' must be an integer >= ${min}, received ${describeValue(value)}`, 'INVALID_EVALUATION_LIMIT');
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
export { DEFAULT_MAX_EXPLODE_ITERATIONS, DEFAULT_MAX_REROLL_ITERATIONS };
|
|
27
|
+
function createContext() {
|
|
28
|
+
return { rolls: [], expressionParts: [], renderedParts: [] };
|
|
29
|
+
}
|
|
30
|
+
function partSpan(node) {
|
|
31
|
+
if (node.start == null || node.end == null)
|
|
32
|
+
return {};
|
|
33
|
+
return { start: node.start, end: node.end };
|
|
34
|
+
}
|
|
35
|
+
function appendAll(target, source) {
|
|
36
|
+
for (const item of source) {
|
|
37
|
+
target.push(item);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function toPublicSpecs(specs) {
|
|
41
|
+
return specs.map(({ code: _code, ...spec }) => spec);
|
|
42
|
+
}
|
|
43
|
+
function renderDice(dice) {
|
|
44
|
+
let rendered = '[';
|
|
45
|
+
let isFirst = true;
|
|
46
|
+
for (const die of dice) {
|
|
47
|
+
const { modifiers } = die;
|
|
48
|
+
if (modifiers.length === 1 && modifiers[0] === 'kept') {
|
|
49
|
+
rendered = isFirst ? `${rendered}${die.result}` : `${rendered}, ${die.result}`;
|
|
50
|
+
isFirst = false;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (modifiers.includes('meta'))
|
|
54
|
+
continue;
|
|
55
|
+
if (!isFirst)
|
|
56
|
+
rendered += ', ';
|
|
57
|
+
isFirst = false;
|
|
58
|
+
rendered += renderDie(die.result, modifiers);
|
|
59
|
+
}
|
|
60
|
+
return `${rendered}]`;
|
|
61
|
+
}
|
|
62
|
+
function renderDie(result, modifiers) {
|
|
63
|
+
if (modifiers.includes('dropped'))
|
|
64
|
+
return `~~${result}~~`;
|
|
65
|
+
if (modifiers.includes('success'))
|
|
66
|
+
return `**${result}**`;
|
|
67
|
+
if (modifiers.includes('failure'))
|
|
68
|
+
return `__${result}__`;
|
|
69
|
+
return String(result);
|
|
70
|
+
}
|
|
71
|
+
export function mergeMetaRolls(parent, source) {
|
|
72
|
+
for (const die of source.rolls) {
|
|
73
|
+
parent.rolls.push({
|
|
74
|
+
...die,
|
|
75
|
+
modifiers: rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'meta', 'dropped'),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function evalNode(node, rng, ctx, env) {
|
|
80
|
+
try {
|
|
81
|
+
return evalNodeInner(node, rng, ctx, env);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error instanceof EvaluatorError && node.start != null) {
|
|
85
|
+
stampEvaluatorSpan(error, node.start, node.end);
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function evalNodeInner(node, rng, ctx, env) {
|
|
91
|
+
switch (node.type) {
|
|
92
|
+
case 'Literal':
|
|
93
|
+
return evalLiteral(node, ctx);
|
|
94
|
+
case 'Dice':
|
|
95
|
+
return evalDice(node, rng, ctx, env);
|
|
96
|
+
case 'FateDice':
|
|
97
|
+
return evalFateDice(node, rng, ctx, env);
|
|
98
|
+
case 'BinaryOp':
|
|
99
|
+
return evalBinaryOp(node, rng, ctx, env);
|
|
100
|
+
case 'UnaryOp':
|
|
101
|
+
return evalUnaryOp(node, rng, ctx, env);
|
|
102
|
+
case 'KeepDrop':
|
|
103
|
+
return evalKeepDrop(node, rng, ctx, env);
|
|
104
|
+
case 'Explode':
|
|
105
|
+
return evalExplode(node, rng, ctx, env);
|
|
106
|
+
case 'Reroll':
|
|
107
|
+
return evalReroll(node, rng, ctx, env);
|
|
108
|
+
case 'DieBound':
|
|
109
|
+
return evalDieBound(node, rng, ctx, env);
|
|
110
|
+
case 'SuccessCount':
|
|
111
|
+
return evalSuccessCount(node, rng, ctx, env);
|
|
112
|
+
case 'Versus':
|
|
113
|
+
return evalVersus(node, rng, ctx, env);
|
|
114
|
+
case 'FunctionCall':
|
|
115
|
+
return evalFunctionCall(node, rng, ctx, env);
|
|
116
|
+
case 'Grouped':
|
|
117
|
+
return evalGrouped(node, rng, ctx, env);
|
|
118
|
+
case 'Group':
|
|
119
|
+
return evalGroup(node, rng, ctx, env);
|
|
120
|
+
case 'Sort':
|
|
121
|
+
return evalSort(node, rng, ctx, env);
|
|
122
|
+
case 'CritThreshold':
|
|
123
|
+
return evalCritThreshold(node, rng, ctx, env);
|
|
124
|
+
case 'Variable':
|
|
125
|
+
return evalVariable(node, ctx, env);
|
|
126
|
+
default: {
|
|
127
|
+
const exhaustive = node;
|
|
128
|
+
throw new EvaluatorError(`Unknown node type: ${exhaustive.type}`, 'UNKNOWN_NODE_TYPE', exhaustive.type);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function evalLiteral(node, ctx) {
|
|
133
|
+
const { value } = node;
|
|
134
|
+
ctx.expressionParts.push(String(value));
|
|
135
|
+
ctx.renderedParts.push(String(value));
|
|
136
|
+
return { total: value, part: { type: 'literal', value, total: value, ...partSpan(node) } };
|
|
137
|
+
}
|
|
138
|
+
function variableNeedsBraces(name) {
|
|
139
|
+
return !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
|
140
|
+
}
|
|
141
|
+
function evalVariable(node, ctx, env) {
|
|
142
|
+
const present = Object.hasOwn(env.context, node.name);
|
|
143
|
+
if (!present) {
|
|
144
|
+
if (env.onMissingVariable === 'throw') {
|
|
145
|
+
throw new EvaluatorError(`Undefined variable: ${node.name}`, 'UNDEFINED_VARIABLE', 'Variable');
|
|
146
|
+
}
|
|
147
|
+
const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
|
|
148
|
+
ctx.expressionParts.push('0');
|
|
149
|
+
ctx.renderedParts.push(`${display}[0]`);
|
|
150
|
+
return {
|
|
151
|
+
total: 0,
|
|
152
|
+
part: { type: 'variable', name: node.name, value: 0, total: 0, ...partSpan(node) },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const value = env.context[node.name];
|
|
156
|
+
if (!Number.isFinite(value)) {
|
|
157
|
+
throw new EvaluatorError(`Invalid variable value: ${node.name} = ${value}`, 'INVALID_VARIABLE_VALUE', 'Variable');
|
|
158
|
+
}
|
|
159
|
+
const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
|
|
160
|
+
ctx.expressionParts.push(String(value));
|
|
161
|
+
ctx.renderedParts.push(`${display}[${value}]`);
|
|
162
|
+
return {
|
|
163
|
+
total: value,
|
|
164
|
+
part: { type: 'variable', name: node.name, value, total: value, ...partSpan(node) },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function evalMetaOperand(node, rng, ctx, env) {
|
|
168
|
+
if (node.type === 'Literal')
|
|
169
|
+
return node.value;
|
|
170
|
+
if (containsVersus(node)) {
|
|
171
|
+
const error = new EvaluatorError('Versus cannot be used as a meta-expression', 'NESTED_VERSUS', 'Versus');
|
|
172
|
+
if (node.start != null)
|
|
173
|
+
stampEvaluatorSpan(error, node.start, node.end);
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
const metaCtx = createContext();
|
|
177
|
+
const value = evalNode(node, rng, metaCtx, env).total;
|
|
178
|
+
mergeMetaRolls(ctx, metaCtx);
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
function requireDiceCount(count, nodeType) {
|
|
182
|
+
if (!Number.isInteger(count) || count < 0) {
|
|
183
|
+
throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', nodeType);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function rollPool(count, notation, rollDie, ctx) {
|
|
187
|
+
const dice = [];
|
|
188
|
+
let total = 0;
|
|
189
|
+
for (let i = 0; i < count; i++) {
|
|
190
|
+
const die = rollDie();
|
|
191
|
+
dice.push(die);
|
|
192
|
+
total += die.result;
|
|
193
|
+
}
|
|
194
|
+
appendAll(ctx.rolls, dice);
|
|
195
|
+
ctx.expressionParts.push(notation);
|
|
196
|
+
ctx.renderedParts.push(`${notation}${renderDice(dice)}`);
|
|
197
|
+
return { total, rolls: dice };
|
|
198
|
+
}
|
|
199
|
+
function evalDice(node, rng, ctx, env) {
|
|
200
|
+
const count = evalMetaOperand(node.count, rng, ctx, env);
|
|
201
|
+
const sides = evalMetaOperand(node.sides, rng, ctx, env);
|
|
202
|
+
requireDiceCount(count, 'Dice');
|
|
203
|
+
if (!Number.isInteger(sides) || sides < 1) {
|
|
204
|
+
throw new EvaluatorError(`Invalid dice sides: ${sides}`, 'INVALID_DICE_SIDES', 'Dice');
|
|
205
|
+
}
|
|
206
|
+
if (sides > MAX_DICE_SIDES) {
|
|
207
|
+
throw new EvaluatorError(`Dice sides ${sides} exceeds maximum of ${MAX_DICE_SIDES}`, 'INVALID_DICE_SIDES', 'Dice');
|
|
208
|
+
}
|
|
209
|
+
chargeDice(env, count, 'Dice');
|
|
210
|
+
const { total, rolls } = rollPool(count, `${count}d${sides}`, () => createDieResult(sides, rng.nextInt(1, sides), ['kept']), ctx);
|
|
211
|
+
return { total, part: { type: 'dice', count, sides, rolls, total, ...partSpan(node) } };
|
|
212
|
+
}
|
|
213
|
+
function evalFateDice(node, rng, ctx, env) {
|
|
214
|
+
const count = evalMetaOperand(node.count, rng, ctx, env);
|
|
215
|
+
requireDiceCount(count, 'FateDice');
|
|
216
|
+
chargeDice(env, count, 'FateDice');
|
|
217
|
+
const { total, rolls } = rollPool(count, `${count}dF`, () => createFateDieResult(rng.nextInt(-1, 1), ['kept']), ctx);
|
|
218
|
+
return { total, part: { type: 'fateDice', count, rolls, total, ...partSpan(node) } };
|
|
219
|
+
}
|
|
220
|
+
function propagateMetadata(parent, metadata) {
|
|
221
|
+
if (!metadata)
|
|
222
|
+
return;
|
|
223
|
+
if (parent.versusMetadata) {
|
|
224
|
+
throw new EvaluatorError('Multiple versus operators in the same expression', 'NESTED_VERSUS', 'Versus');
|
|
225
|
+
}
|
|
226
|
+
parent.versusMetadata = metadata;
|
|
227
|
+
}
|
|
228
|
+
function mergeContext(parent, child) {
|
|
229
|
+
appendAll(parent.rolls, child.rolls);
|
|
230
|
+
propagateMetadata(parent, child.versusMetadata);
|
|
231
|
+
}
|
|
232
|
+
function evalBinaryOp(node, rng, ctx, env) {
|
|
233
|
+
const leftCtx = createContext();
|
|
234
|
+
const rightCtx = createContext();
|
|
235
|
+
const left = evalNode(node.left, rng, leftCtx, env);
|
|
236
|
+
const right = evalNode(node.right, rng, rightCtx, env);
|
|
237
|
+
mergeContext(ctx, leftCtx);
|
|
238
|
+
mergeContext(ctx, rightCtx);
|
|
239
|
+
const leftExpr = leftCtx.expressionParts.join('');
|
|
240
|
+
const rightExpr = rightCtx.expressionParts.join('');
|
|
241
|
+
const leftRendered = leftCtx.renderedParts.join('');
|
|
242
|
+
const rightRendered = rightCtx.renderedParts.join('');
|
|
243
|
+
ctx.expressionParts.push(`${leftExpr} ${node.operator} ${rightExpr}`);
|
|
244
|
+
ctx.renderedParts.push(`${leftRendered} ${node.operator} ${rightRendered}`);
|
|
245
|
+
const total = applyBinaryOperator(node.operator, left.total, right.total);
|
|
246
|
+
return {
|
|
247
|
+
total,
|
|
248
|
+
part: {
|
|
249
|
+
type: 'binaryOp',
|
|
250
|
+
operator: node.operator,
|
|
251
|
+
left: left.part,
|
|
252
|
+
right: right.part,
|
|
253
|
+
total,
|
|
254
|
+
...partSpan(node),
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function applyBinaryOperator(operator, left, right) {
|
|
259
|
+
switch (operator) {
|
|
260
|
+
case '+':
|
|
261
|
+
return left + right;
|
|
262
|
+
case '-':
|
|
263
|
+
return left - right;
|
|
264
|
+
case '*':
|
|
265
|
+
return left * right;
|
|
266
|
+
case '/':
|
|
267
|
+
if (right === 0) {
|
|
268
|
+
throw new EvaluatorError('Division by zero', 'DIVISION_BY_ZERO', 'BinaryOp');
|
|
269
|
+
}
|
|
270
|
+
return left / right;
|
|
271
|
+
case '%':
|
|
272
|
+
if (right === 0) {
|
|
273
|
+
throw new EvaluatorError('Modulo by zero', 'MODULO_BY_ZERO', 'BinaryOp');
|
|
274
|
+
}
|
|
275
|
+
return left % right;
|
|
276
|
+
case '**':
|
|
277
|
+
return left ** right;
|
|
278
|
+
default: {
|
|
279
|
+
const exhaustive = operator;
|
|
280
|
+
throw new EvaluatorError(`Unknown operator: ${exhaustive}`, 'UNKNOWN_OPERATOR', 'BinaryOp');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function evalUnaryOp(node, rng, ctx, env) {
|
|
285
|
+
const innerCtx = createContext();
|
|
286
|
+
const inner = evalNode(node.operand, rng, innerCtx, env);
|
|
287
|
+
mergeContext(ctx, innerCtx);
|
|
288
|
+
const innerExpr = innerCtx.expressionParts.join('');
|
|
289
|
+
const innerRendered = innerCtx.renderedParts.join('');
|
|
290
|
+
ctx.expressionParts.push(`-${innerExpr}`);
|
|
291
|
+
ctx.renderedParts.push(`-${innerRendered}`);
|
|
292
|
+
const total = -inner.total;
|
|
293
|
+
return {
|
|
294
|
+
total,
|
|
295
|
+
part: { type: 'unaryOp', operator: '-', operand: inner.part, total, ...partSpan(node) },
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function evalGrouped(node, rng, ctx, env) {
|
|
299
|
+
const innerCtx = createContext();
|
|
300
|
+
const inner = evalNode(node.expression, rng, innerCtx, env);
|
|
301
|
+
mergeContext(ctx, innerCtx);
|
|
302
|
+
ctx.expressionParts.push(`(${innerCtx.expressionParts.join('')})`);
|
|
303
|
+
ctx.renderedParts.push(`(${innerCtx.renderedParts.join('')})`);
|
|
304
|
+
return {
|
|
305
|
+
total: inner.total,
|
|
306
|
+
part: { type: 'grouped', inner: inner.part, total: inner.total, ...partSpan(node) },
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function evalGroup(node, rng, ctx, env) {
|
|
310
|
+
const subExprs = [];
|
|
311
|
+
const subRendered = [];
|
|
312
|
+
const subParts = [];
|
|
313
|
+
let total = 0;
|
|
314
|
+
for (const expr of node.expressions) {
|
|
315
|
+
const subCtx = createContext();
|
|
316
|
+
const sub = evalNode(expr, rng, subCtx, env);
|
|
317
|
+
mergeContext(ctx, subCtx);
|
|
318
|
+
subExprs.push(subCtx.expressionParts.join(''));
|
|
319
|
+
subRendered.push(subCtx.renderedParts.join(''));
|
|
320
|
+
subParts.push(sub.part);
|
|
321
|
+
total += sub.total;
|
|
322
|
+
}
|
|
323
|
+
ctx.expressionParts.push(`{${subExprs.join(', ')}}`);
|
|
324
|
+
ctx.renderedParts.push(`{${subRendered.join(', ')}}`);
|
|
325
|
+
return { total, part: { type: 'group', parts: subParts, total, ...partSpan(node) } };
|
|
326
|
+
}
|
|
327
|
+
function evalFunctionCall(node, rng, ctx, env) {
|
|
328
|
+
const argCtxs = [];
|
|
329
|
+
const argResults = [];
|
|
330
|
+
for (const arg of node.args) {
|
|
331
|
+
const argCtx = createContext();
|
|
332
|
+
argResults.push(evalNode(arg, rng, argCtx, env));
|
|
333
|
+
argCtxs.push(argCtx);
|
|
334
|
+
}
|
|
335
|
+
for (const argCtx of argCtxs) {
|
|
336
|
+
mergeContext(ctx, argCtx);
|
|
337
|
+
}
|
|
338
|
+
const argExprs = argCtxs.map((c) => c.expressionParts.join(''));
|
|
339
|
+
const argRendereds = argCtxs.map((c) => c.renderedParts.join(''));
|
|
340
|
+
ctx.expressionParts.push(`${node.name}(${argExprs.join(', ')})`);
|
|
341
|
+
ctx.renderedParts.push(`${node.name}(${argRendereds.join(', ')})`);
|
|
342
|
+
const total = applyFunction(node.name, argResults.map((r) => r.total));
|
|
343
|
+
return {
|
|
344
|
+
total,
|
|
345
|
+
part: {
|
|
346
|
+
type: 'functionCall',
|
|
347
|
+
name: node.name,
|
|
348
|
+
args: argResults.map((r) => r.part),
|
|
349
|
+
total,
|
|
350
|
+
...partSpan(node),
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function applyFunction(name, values) {
|
|
355
|
+
switch (name) {
|
|
356
|
+
case 'floor':
|
|
357
|
+
return Math.floor(requireUnaryArg(name, values));
|
|
358
|
+
case 'ceil':
|
|
359
|
+
return Math.ceil(requireUnaryArg(name, values));
|
|
360
|
+
case 'round':
|
|
361
|
+
return Math.round(requireUnaryArg(name, values));
|
|
362
|
+
case 'abs':
|
|
363
|
+
return Math.abs(requireUnaryArg(name, values));
|
|
364
|
+
case 'sqrt':
|
|
365
|
+
return Math.sqrt(requireUnaryArg(name, values));
|
|
366
|
+
case 'pow': {
|
|
367
|
+
const [base, exponent] = requireBinaryArgs(name, values);
|
|
368
|
+
return base ** exponent;
|
|
369
|
+
}
|
|
370
|
+
case 'max':
|
|
371
|
+
return extremumOf(values, 'max');
|
|
372
|
+
case 'min':
|
|
373
|
+
return extremumOf(values, 'min');
|
|
374
|
+
default:
|
|
375
|
+
throw new EvaluatorError(`Unknown function: ${name}`, 'UNKNOWN_FUNCTION', 'FunctionCall');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function extremumOf(values, kind) {
|
|
379
|
+
let result = kind === 'max' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
|
|
380
|
+
for (const value of values) {
|
|
381
|
+
if (Number.isNaN(value))
|
|
382
|
+
return Number.NaN;
|
|
383
|
+
if (kind === 'max' ? value > result : value < result)
|
|
384
|
+
result = value;
|
|
385
|
+
}
|
|
386
|
+
return result;
|
|
387
|
+
}
|
|
388
|
+
function requireUnaryArg(name, values) {
|
|
389
|
+
const [x] = values;
|
|
390
|
+
if (x == null) {
|
|
391
|
+
throw new EvaluatorError(`Function '${name}' requires an argument`, 'UNKNOWN_FUNCTION', 'FunctionCall');
|
|
392
|
+
}
|
|
393
|
+
return x;
|
|
394
|
+
}
|
|
395
|
+
function requireBinaryArgs(name, values) {
|
|
396
|
+
const [a, b] = values;
|
|
397
|
+
if (a == null || b == null) {
|
|
398
|
+
throw new EvaluatorError(`Function '${name}' requires two arguments`, 'UNKNOWN_FUNCTION', 'FunctionCall');
|
|
399
|
+
}
|
|
400
|
+
return [a, b];
|
|
401
|
+
}
|
|
402
|
+
const KEEP_DROP_CODES = {
|
|
403
|
+
keep: { highest: 'kh', lowest: 'kl' },
|
|
404
|
+
drop: { highest: 'dh', lowest: 'dl' },
|
|
405
|
+
};
|
|
406
|
+
function keepDropCode(kind, selector) {
|
|
407
|
+
return KEEP_DROP_CODES[kind][selector];
|
|
408
|
+
}
|
|
409
|
+
function flattenKeepDropChain(node, rng, ctx, env) {
|
|
410
|
+
const specs = [];
|
|
411
|
+
let current = node;
|
|
412
|
+
while (isKeepDrop(current)) {
|
|
413
|
+
const modCount = evalMetaOperand(current.count, rng, ctx, env);
|
|
414
|
+
if (!Number.isInteger(modCount) || modCount < 0) {
|
|
415
|
+
throw new EvaluatorError(`Invalid keep/drop count: ${modCount}`, 'INVALID_KEEP_DROP_COUNT', 'KeepDrop');
|
|
416
|
+
}
|
|
417
|
+
specs.push({
|
|
418
|
+
kind: current.kind,
|
|
419
|
+
selector: current.selector,
|
|
420
|
+
count: modCount,
|
|
421
|
+
code: keepDropCode(current.kind, current.selector),
|
|
422
|
+
});
|
|
423
|
+
current = current.target;
|
|
424
|
+
}
|
|
425
|
+
specs.reverse();
|
|
426
|
+
return { specs, baseTarget: current };
|
|
427
|
+
}
|
|
428
|
+
function mergeDropSets(baseDice, specs) {
|
|
429
|
+
const droppedMask = new Uint8Array(baseDice.length);
|
|
430
|
+
for (const spec of specs) {
|
|
431
|
+
markDroppedIndices(baseDice, spec.count, spec.kind, spec.selector, droppedMask);
|
|
432
|
+
}
|
|
433
|
+
for (let index = 0; index < baseDice.length; index++) {
|
|
434
|
+
const die = baseDice[index];
|
|
435
|
+
if (die == null)
|
|
436
|
+
continue;
|
|
437
|
+
const marker = droppedMask[index] === 1 ? 'dropped' : 'kept';
|
|
438
|
+
const { modifiers } = die;
|
|
439
|
+
if (modifiers.length === 1 && modifiers[0] === marker)
|
|
440
|
+
continue;
|
|
441
|
+
die.modifiers = rewriteFlags(modifiers, SELECTION_FLAGS, marker);
|
|
442
|
+
}
|
|
443
|
+
return baseDice;
|
|
444
|
+
}
|
|
445
|
+
const EXPLODE_MARKERS = {
|
|
446
|
+
standard: '!',
|
|
447
|
+
compound: '!!',
|
|
448
|
+
penetrating: '!p',
|
|
449
|
+
};
|
|
450
|
+
const EXPLODE_APPLIERS = {
|
|
451
|
+
standard: applyStandardExplode,
|
|
452
|
+
compound: applyCompoundExplode,
|
|
453
|
+
penetrating: applyPenetratingExplode,
|
|
454
|
+
};
|
|
455
|
+
function formatExplodeCode(variant, threshold, thresholdValue) {
|
|
456
|
+
const marker = EXPLODE_MARKERS[variant];
|
|
457
|
+
if (threshold == null || thresholdValue == null)
|
|
458
|
+
return marker;
|
|
459
|
+
return `${marker}${threshold.operator}${thresholdValue}`;
|
|
460
|
+
}
|
|
461
|
+
function evalExplode(node, rng, ctx, env) {
|
|
462
|
+
const targetCtx = createContext();
|
|
463
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
464
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
465
|
+
let thresholdValue;
|
|
466
|
+
if (node.threshold != null) {
|
|
467
|
+
thresholdValue = evalMetaOperand(node.threshold.value, rng, ctx, env);
|
|
468
|
+
}
|
|
469
|
+
const code = formatExplodeCode(node.variant, node.threshold, thresholdValue);
|
|
470
|
+
const buildPart = (total) => {
|
|
471
|
+
const part = {
|
|
472
|
+
type: 'explode',
|
|
473
|
+
variant: node.variant,
|
|
474
|
+
target: target.part,
|
|
475
|
+
total,
|
|
476
|
+
...partSpan(node),
|
|
477
|
+
};
|
|
478
|
+
if (node.threshold != null && thresholdValue != null) {
|
|
479
|
+
part.threshold = { operator: node.threshold.operator, value: thresholdValue };
|
|
480
|
+
}
|
|
481
|
+
return part;
|
|
482
|
+
};
|
|
483
|
+
if (targetCtx.rolls.length === 0) {
|
|
484
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
485
|
+
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
486
|
+
return { total: target.total, part: buildPart(target.total) };
|
|
487
|
+
}
|
|
488
|
+
const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
|
|
489
|
+
const expanded = EXPLODE_APPLIERS[node.variant](targetCtx.rolls, shouldExplode, rng, env);
|
|
490
|
+
appendAll(ctx.rolls, expanded);
|
|
491
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
492
|
+
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
|
|
493
|
+
const total = sumKeptDice(expanded);
|
|
494
|
+
return { total, part: buildPart(total) };
|
|
495
|
+
}
|
|
496
|
+
function evalReroll(node, rng, ctx, env) {
|
|
497
|
+
const targetCtx = createContext();
|
|
498
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
499
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
500
|
+
const thresholdValue = evalMetaOperand(node.condition.value, rng, ctx, env);
|
|
501
|
+
const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
|
|
502
|
+
const condition = {
|
|
503
|
+
operator: node.condition.operator,
|
|
504
|
+
value: thresholdValue,
|
|
505
|
+
};
|
|
506
|
+
if (targetCtx.rolls.length === 0) {
|
|
507
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
508
|
+
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
509
|
+
const total = sumKeptDice(targetCtx.rolls);
|
|
510
|
+
return {
|
|
511
|
+
total,
|
|
512
|
+
part: {
|
|
513
|
+
type: 'reroll',
|
|
514
|
+
once: node.once,
|
|
515
|
+
condition,
|
|
516
|
+
target: target.part,
|
|
517
|
+
total,
|
|
518
|
+
...partSpan(node),
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
const pool = node.once
|
|
523
|
+
? applyRerollOnce(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env)
|
|
524
|
+
: applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
|
|
525
|
+
appendAll(ctx.rolls, pool);
|
|
526
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
527
|
+
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
|
|
528
|
+
const total = sumKeptDice(pool);
|
|
529
|
+
return {
|
|
530
|
+
total,
|
|
531
|
+
part: {
|
|
532
|
+
type: 'reroll',
|
|
533
|
+
once: node.once,
|
|
534
|
+
condition,
|
|
535
|
+
target: target.part,
|
|
536
|
+
total,
|
|
537
|
+
...partSpan(node),
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function evalDieBound(node, rng, ctx, env) {
|
|
542
|
+
const targetCtx = createContext();
|
|
543
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
544
|
+
const boundValue = evalMetaOperand(node.value, rng, ctx, env);
|
|
545
|
+
if (!Number.isFinite(boundValue)) {
|
|
546
|
+
throw new EvaluatorError(`Invalid ${node.bound} bound: ${boundValue}`, 'INVALID_THRESHOLD', 'DieBound');
|
|
547
|
+
}
|
|
548
|
+
applyDieBound(targetCtx.rolls, node.bound, boundValue);
|
|
549
|
+
appendAll(ctx.rolls, targetCtx.rolls);
|
|
550
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
551
|
+
const code = boundValue < 0 ? `${node.bound}(${boundValue})` : `${node.bound}${boundValue}`;
|
|
552
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
553
|
+
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
|
|
554
|
+
const total = sumKeptDice(targetCtx.rolls);
|
|
555
|
+
return {
|
|
556
|
+
total,
|
|
557
|
+
part: {
|
|
558
|
+
type: 'dieBound',
|
|
559
|
+
bound: node.bound,
|
|
560
|
+
value: boundValue,
|
|
561
|
+
target: target.part,
|
|
562
|
+
total,
|
|
563
|
+
...partSpan(node),
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function evalSort(node, rng, ctx, env) {
|
|
568
|
+
const targetCtx = createContext();
|
|
569
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
570
|
+
const sortedRolls = sortDice(targetCtx.rolls, node.order);
|
|
571
|
+
appendAll(ctx.rolls, sortedRolls);
|
|
572
|
+
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
573
|
+
const code = node.order === 'ascending' ? 's' : 'sd';
|
|
574
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
575
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
576
|
+
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(sortedRolls)}`);
|
|
577
|
+
return {
|
|
578
|
+
total: target.total,
|
|
579
|
+
part: {
|
|
580
|
+
type: 'sort',
|
|
581
|
+
order: node.order,
|
|
582
|
+
rolls: sortedRolls,
|
|
583
|
+
target: target.part,
|
|
584
|
+
total: target.total,
|
|
585
|
+
...partSpan(node),
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
function evalCritThreshold(node, rng, ctx, env) {
|
|
590
|
+
const targetCtx = createContext();
|
|
591
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
592
|
+
const successResolved = node.successThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
|
|
593
|
+
const failResolved = node.failThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
|
|
594
|
+
const successApplied = successResolved.length > 0 ? successResolved : ['default'];
|
|
595
|
+
const failApplied = failResolved.length > 0 ? failResolved : ['default'];
|
|
596
|
+
applyCritThresholds(targetCtx.rolls, successApplied, failApplied);
|
|
597
|
+
appendAll(ctx.rolls, targetCtx.rolls);
|
|
598
|
+
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
599
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
600
|
+
const codes = [
|
|
601
|
+
...successResolved.map((t) => (t === 'default' ? 'cs' : `cs${t.operator}${t.value}`)),
|
|
602
|
+
...failResolved.map((t) => (t === 'default' ? 'cf' : `cf${t.operator}${t.value}`)),
|
|
603
|
+
].join('');
|
|
604
|
+
ctx.expressionParts.push(`${targetExpr}${codes}`);
|
|
605
|
+
ctx.renderedParts.push(`${targetExpr}${codes}${renderDice(targetCtx.rolls)}`);
|
|
606
|
+
return {
|
|
607
|
+
total: target.total,
|
|
608
|
+
part: {
|
|
609
|
+
type: 'critThreshold',
|
|
610
|
+
successThresholds: successResolved,
|
|
611
|
+
failThresholds: failResolved,
|
|
612
|
+
target: target.part,
|
|
613
|
+
total: target.total,
|
|
614
|
+
...partSpan(node),
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
function resolveCritThreshold(threshold, rng, ctx, env) {
|
|
619
|
+
if (threshold === 'default')
|
|
620
|
+
return 'default';
|
|
621
|
+
const resolved = evalMetaOperand(threshold.value, rng, ctx, env);
|
|
622
|
+
if (!Number.isFinite(resolved)) {
|
|
623
|
+
throw new EvaluatorError(`Invalid crit threshold: ${resolved}`, 'INVALID_THRESHOLD', 'CritThreshold');
|
|
624
|
+
}
|
|
625
|
+
return { operator: threshold.operator, value: resolved };
|
|
626
|
+
}
|
|
627
|
+
function evalKeepDrop(node, rng, ctx, env) {
|
|
628
|
+
const { specs, baseTarget } = flattenKeepDropChain(node, rng, ctx, env);
|
|
629
|
+
if (baseTarget.type === 'Group' && baseTarget.expressions.length >= 2) {
|
|
630
|
+
return evalGroupKeepDrop(node, baseTarget, specs, rng, ctx, env);
|
|
631
|
+
}
|
|
632
|
+
const targetCtx = createContext();
|
|
633
|
+
const target = evalNode(baseTarget, rng, targetCtx, env);
|
|
634
|
+
const mergedDice = mergeDropSets(targetCtx.rolls, specs);
|
|
635
|
+
appendAll(ctx.rolls, mergedDice);
|
|
636
|
+
const total = sumKeptDice(mergedDice);
|
|
637
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
638
|
+
const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
639
|
+
ctx.expressionParts.push(`${targetExpr}${keepDropCodes}`);
|
|
640
|
+
ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
|
|
641
|
+
return {
|
|
642
|
+
total,
|
|
643
|
+
part: {
|
|
644
|
+
type: 'keepDrop',
|
|
645
|
+
specs: toPublicSpecs(specs),
|
|
646
|
+
target: target.part,
|
|
647
|
+
total,
|
|
648
|
+
...partSpan(node),
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
function stripInnerMarkers(rendered) {
|
|
653
|
+
return rendered
|
|
654
|
+
.replace(/\*\*(-?\d+)\*\*/g, '$1')
|
|
655
|
+
.replace(/__(-?\d+)__/g, '$1')
|
|
656
|
+
.replace(/~~(-?\d+)~~/g, '$1');
|
|
657
|
+
}
|
|
658
|
+
function evalGroupKeepDrop(node, group, specs, rng, ctx, env) {
|
|
659
|
+
const subRolls = group.expressions.map((expr) => {
|
|
660
|
+
const subCtx = createContext();
|
|
661
|
+
const sub = evalNode(expr, rng, subCtx, env);
|
|
662
|
+
return {
|
|
663
|
+
subtotal: sub.total,
|
|
664
|
+
part: sub.part,
|
|
665
|
+
rolls: subCtx.rolls,
|
|
666
|
+
expr: subCtx.expressionParts.join(''),
|
|
667
|
+
rendered: subCtx.renderedParts.join(''),
|
|
668
|
+
versusMetadata: subCtx.versusMetadata,
|
|
669
|
+
};
|
|
670
|
+
});
|
|
671
|
+
const syntheticDice = subRolls.map((sub) => ({
|
|
672
|
+
sides: 0,
|
|
673
|
+
result: sub.subtotal,
|
|
674
|
+
modifiers: [],
|
|
675
|
+
critical: false,
|
|
676
|
+
fumble: false,
|
|
677
|
+
}));
|
|
678
|
+
const mergedSynthetic = mergeDropSets(syntheticDice, specs);
|
|
679
|
+
const outerRendered = [];
|
|
680
|
+
const keptIndices = [];
|
|
681
|
+
let total = 0;
|
|
682
|
+
for (let i = 0; i < subRolls.length; i++) {
|
|
683
|
+
const sub = subRolls[i];
|
|
684
|
+
const synth = mergedSynthetic[i];
|
|
685
|
+
const isDropped = synth.modifiers.includes('dropped');
|
|
686
|
+
if (isDropped) {
|
|
687
|
+
for (const die of sub.rolls) {
|
|
688
|
+
die.modifiers = rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'dropped');
|
|
689
|
+
}
|
|
690
|
+
appendAll(ctx.rolls, sub.rolls);
|
|
691
|
+
outerRendered.push(`~~${stripInnerMarkers(sub.rendered)}~~`);
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
keptIndices.push(i);
|
|
695
|
+
appendAll(ctx.rolls, sub.rolls);
|
|
696
|
+
outerRendered.push(sub.rendered);
|
|
697
|
+
total += sub.subtotal;
|
|
698
|
+
}
|
|
699
|
+
if (!isDropped) {
|
|
700
|
+
propagateMetadata(ctx, sub.versusMetadata);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
const subExprStrs = subRolls.map((s) => s.expr);
|
|
704
|
+
const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
705
|
+
ctx.expressionParts.push(`{${subExprStrs.join(', ')}}${keepDropCodes}`);
|
|
706
|
+
ctx.renderedParts.push(`{${outerRendered.join(', ')}}`);
|
|
707
|
+
const groupPart = {
|
|
708
|
+
type: 'group',
|
|
709
|
+
parts: subRolls.map((s) => s.part),
|
|
710
|
+
keptIndices,
|
|
711
|
+
total,
|
|
712
|
+
...partSpan(group),
|
|
713
|
+
};
|
|
714
|
+
return {
|
|
715
|
+
total,
|
|
716
|
+
part: {
|
|
717
|
+
type: 'keepDrop',
|
|
718
|
+
specs: toPublicSpecs(specs),
|
|
719
|
+
target: groupPart,
|
|
720
|
+
total,
|
|
721
|
+
...partSpan(node),
|
|
722
|
+
},
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
function resolveThreshold(value, rng, ctx, env, role) {
|
|
726
|
+
const resolved = evalMetaOperand(value, rng, ctx, env);
|
|
727
|
+
if (!Number.isFinite(resolved)) {
|
|
728
|
+
throw new EvaluatorError(`Invalid ${role}: ${resolved}`, 'INVALID_THRESHOLD', 'SuccessCount');
|
|
729
|
+
}
|
|
730
|
+
return resolved;
|
|
731
|
+
}
|
|
732
|
+
function formatFailCode(operator, value) {
|
|
733
|
+
return operator === '=' ? `f${value}` : `f${operator}${value}`;
|
|
734
|
+
}
|
|
735
|
+
function evalSuccessCount(node, rng, ctx, env) {
|
|
736
|
+
env.hasSuccessCount = true;
|
|
737
|
+
const targetCtx = createContext();
|
|
738
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
739
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
740
|
+
const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
|
|
741
|
+
const failValue = node.failThreshold != null
|
|
742
|
+
? resolveThreshold(node.failThreshold.value, rng, ctx, env, 'fail threshold')
|
|
743
|
+
: undefined;
|
|
744
|
+
const failCode = failValue != null && node.failThreshold != null
|
|
745
|
+
? formatFailCode(node.failThreshold.operator, failValue)
|
|
746
|
+
: '';
|
|
747
|
+
const code = `${node.threshold.operator}${thresholdValue}${failCode}`;
|
|
748
|
+
const buildPart = (total, successes, failures) => {
|
|
749
|
+
const part = {
|
|
750
|
+
type: 'successCount',
|
|
751
|
+
threshold: { operator: node.threshold.operator, value: thresholdValue },
|
|
752
|
+
target: target.part,
|
|
753
|
+
successes,
|
|
754
|
+
failures,
|
|
755
|
+
total,
|
|
756
|
+
...partSpan(node),
|
|
757
|
+
};
|
|
758
|
+
if (failValue != null && node.failThreshold != null) {
|
|
759
|
+
part.failThreshold = { operator: node.failThreshold.operator, value: failValue };
|
|
760
|
+
}
|
|
761
|
+
return part;
|
|
762
|
+
};
|
|
763
|
+
if (targetCtx.rolls.length === 0) {
|
|
764
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
765
|
+
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
766
|
+
return { total: target.total, part: buildPart(target.total, 0, 0) };
|
|
767
|
+
}
|
|
768
|
+
const result = countSuccesses(targetCtx.rolls, { operator: node.threshold.operator, value: thresholdValue }, failValue != null && node.failThreshold != null
|
|
769
|
+
? { operator: node.failThreshold.operator, value: failValue }
|
|
770
|
+
: undefined);
|
|
771
|
+
appendAll(ctx.rolls, targetCtx.rolls);
|
|
772
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
773
|
+
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
|
|
774
|
+
return {
|
|
775
|
+
total: result.total,
|
|
776
|
+
part: buildPart(result.total, result.successes, result.failures),
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
function extractNatural(rolls) {
|
|
780
|
+
const primaries = rolls.filter((d) => d.sides === 20 &&
|
|
781
|
+
!d.modifiers.includes('dropped') &&
|
|
782
|
+
!(d.modifiers.includes('exploded') && d.initialResult == null));
|
|
783
|
+
if (primaries.length !== 1)
|
|
784
|
+
return undefined;
|
|
785
|
+
const die = primaries[0];
|
|
786
|
+
return die?.initialResult ?? die?.result;
|
|
787
|
+
}
|
|
788
|
+
function calculateDegree(total, dc, natural) {
|
|
789
|
+
let degree;
|
|
790
|
+
if (total >= dc + 10)
|
|
791
|
+
degree = DegreeOfSuccess.CriticalSuccess;
|
|
792
|
+
else if (total >= dc)
|
|
793
|
+
degree = DegreeOfSuccess.Success;
|
|
794
|
+
else if (total > dc - 10)
|
|
795
|
+
degree = DegreeOfSuccess.Failure;
|
|
796
|
+
else
|
|
797
|
+
degree = DegreeOfSuccess.CriticalFailure;
|
|
798
|
+
if (natural === 20 && degree < DegreeOfSuccess.CriticalSuccess)
|
|
799
|
+
degree++;
|
|
800
|
+
if (natural === 1 && degree > DegreeOfSuccess.CriticalFailure)
|
|
801
|
+
degree--;
|
|
802
|
+
return degree;
|
|
803
|
+
}
|
|
804
|
+
function degreeLabel(degree) {
|
|
805
|
+
switch (degree) {
|
|
806
|
+
case DegreeOfSuccess.CriticalFailure:
|
|
807
|
+
return 'Critical Failure';
|
|
808
|
+
case DegreeOfSuccess.Failure:
|
|
809
|
+
return 'Failure';
|
|
810
|
+
case DegreeOfSuccess.Success:
|
|
811
|
+
return 'Success';
|
|
812
|
+
case DegreeOfSuccess.CriticalSuccess:
|
|
813
|
+
return 'Critical Success';
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
function evalVersus(node, rng, ctx, env) {
|
|
817
|
+
if (env.insideVersus) {
|
|
818
|
+
throw new EvaluatorError('Cannot nest versus operators', 'NESTED_VERSUS', 'Versus');
|
|
819
|
+
}
|
|
820
|
+
env.insideVersus = true;
|
|
821
|
+
try {
|
|
822
|
+
const rollCtx = createContext();
|
|
823
|
+
const rollResult = evalNode(node.roll, rng, rollCtx, env);
|
|
824
|
+
const natural = extractNatural(rollCtx.rolls);
|
|
825
|
+
const dcCtx = createContext();
|
|
826
|
+
const dcResult = evalNode(node.dc, rng, dcCtx, env);
|
|
827
|
+
const degree = calculateDegree(rollResult.total, dcResult.total, natural);
|
|
828
|
+
appendAll(ctx.rolls, rollCtx.rolls);
|
|
829
|
+
for (const die of dcCtx.rolls) {
|
|
830
|
+
die.modifiers.push('dc');
|
|
831
|
+
}
|
|
832
|
+
appendAll(ctx.rolls, dcCtx.rolls);
|
|
833
|
+
const rollExpr = rollCtx.expressionParts.join('');
|
|
834
|
+
const dcExpr = dcCtx.expressionParts.join('');
|
|
835
|
+
const rollRendered = rollCtx.renderedParts.join('');
|
|
836
|
+
const dcRendered = dcCtx.renderedParts.join('');
|
|
837
|
+
ctx.expressionParts.push(`${rollExpr} vs ${dcExpr}`);
|
|
838
|
+
ctx.renderedParts.push(`${rollRendered} vs ${dcRendered}`);
|
|
839
|
+
ctx.versusMetadata = { degree, natural, dcTotal: dcResult.total };
|
|
840
|
+
return {
|
|
841
|
+
total: rollResult.total,
|
|
842
|
+
part: {
|
|
843
|
+
type: 'versus',
|
|
844
|
+
roll: rollResult.part,
|
|
845
|
+
dc: dcResult.part,
|
|
846
|
+
degree,
|
|
847
|
+
total: rollResult.total,
|
|
848
|
+
...partSpan(node),
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
finally {
|
|
853
|
+
env.insideVersus = false;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
export function evaluate(ast, rng, options = {}) {
|
|
857
|
+
const maxDice = resolveLimit(options.maxDice, 'maxDice', DEFAULT_MAX_DICE, 1);
|
|
858
|
+
const maxExplodeIterations = resolveLimit(options.maxExplodeIterations, 'maxExplodeIterations', DEFAULT_MAX_EXPLODE_ITERATIONS, 0);
|
|
859
|
+
const maxRerollIterations = resolveLimit(options.maxRerollIterations, 'maxRerollIterations', DEFAULT_MAX_REROLL_ITERATIONS, 0);
|
|
860
|
+
const context = options.context ?? {};
|
|
861
|
+
const onMissingVariable = options.onMissingVariable ?? 'throw';
|
|
862
|
+
const env = {
|
|
863
|
+
maxDice,
|
|
864
|
+
maxExplodeIterations,
|
|
865
|
+
maxRerollIterations,
|
|
866
|
+
totalDiceRolled: 0,
|
|
867
|
+
hasSuccessCount: false,
|
|
868
|
+
insideVersus: false,
|
|
869
|
+
context,
|
|
870
|
+
onMissingVariable,
|
|
871
|
+
};
|
|
872
|
+
const ctx = createContext();
|
|
873
|
+
const { total, part } = evalNode(ast, rng, ctx, env);
|
|
874
|
+
if (!Number.isFinite(total)) {
|
|
875
|
+
throw new EvaluatorError(`Result is not a finite number: ${total}`, 'NON_FINITE_RESULT', ast.type);
|
|
876
|
+
}
|
|
877
|
+
const expression = ctx.expressionParts.join('');
|
|
878
|
+
const trailing = ctx.versusMetadata ? degreeLabel(ctx.versusMetadata.degree) : String(total);
|
|
879
|
+
const rendered = `${ctx.renderedParts.join('')} = ${trailing}`;
|
|
880
|
+
const versus = ctx.versusMetadata;
|
|
881
|
+
return {
|
|
882
|
+
total,
|
|
883
|
+
notation: options.notation ?? expression,
|
|
884
|
+
expression,
|
|
885
|
+
rendered,
|
|
886
|
+
rolls: ctx.rolls,
|
|
887
|
+
parts: part,
|
|
888
|
+
...(env.hasSuccessCount ? countTaggedDice(ctx.rolls) : {}),
|
|
889
|
+
...(versus ? { degree: versus.degree } : {}),
|
|
890
|
+
...(versus?.natural != null ? { natural: versus.natural } : {}),
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
function countTaggedDice(rolls) {
|
|
894
|
+
let successes = 0;
|
|
895
|
+
let failures = 0;
|
|
896
|
+
for (const die of rolls) {
|
|
897
|
+
if (isVersusDc(die))
|
|
898
|
+
continue;
|
|
899
|
+
if (die.modifiers.includes('success'))
|
|
900
|
+
successes += 1;
|
|
901
|
+
else if (die.modifiers.includes('failure'))
|
|
902
|
+
failures += 1;
|
|
903
|
+
}
|
|
904
|
+
return { successes, failures };
|
|
905
|
+
}
|
|
906
|
+
//# sourceMappingURL=evaluator.js.map
|