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
|
@@ -4,21 +4,21 @@
|
|
|
4
4
|
* @module evaluator/evaluator
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import
|
|
8
|
-
import { RollParserError } from '../errors.js';
|
|
7
|
+
import { describeValue, EvaluatorError, RollParserError, stampEvaluatorSpan } from '../errors.js';
|
|
9
8
|
import type {
|
|
10
9
|
ASTNode,
|
|
11
10
|
BinaryOpNode,
|
|
12
11
|
CritThreshold,
|
|
13
12
|
CritThresholdNode,
|
|
14
13
|
DiceNode,
|
|
14
|
+
DieBoundNode,
|
|
15
15
|
ExplodeNode,
|
|
16
16
|
FateDiceNode,
|
|
17
17
|
FunctionCallNode,
|
|
18
18
|
GroupedNode,
|
|
19
19
|
GroupNode,
|
|
20
|
+
KeepDropNode,
|
|
20
21
|
LiteralNode,
|
|
21
|
-
ModifierNode,
|
|
22
22
|
RerollNode,
|
|
23
23
|
SortNode,
|
|
24
24
|
SuccessCountNode,
|
|
@@ -26,18 +26,26 @@ import type {
|
|
|
26
26
|
VariableNode,
|
|
27
27
|
VersusNode,
|
|
28
28
|
} from '../parser/ast.js';
|
|
29
|
-
import {
|
|
29
|
+
import { isKeepDrop } from '../parser/ast.js';
|
|
30
|
+
import { containsVersus } from '../parser/guards.js';
|
|
30
31
|
import type { RNG } from '../rng/types.js';
|
|
31
32
|
import type {
|
|
33
|
+
CompareOp,
|
|
32
34
|
ComparePoint,
|
|
35
|
+
DieModifier,
|
|
33
36
|
DieResult,
|
|
34
37
|
EvaluateOptions,
|
|
38
|
+
KeepDropSpec,
|
|
35
39
|
ResolvedComparePoint,
|
|
40
|
+
ResolvedCritThreshold,
|
|
36
41
|
RollPart,
|
|
37
42
|
RollResult,
|
|
38
43
|
} from '../types.js';
|
|
39
44
|
import { DegreeOfSuccess } from '../types.js';
|
|
40
|
-
import {
|
|
45
|
+
import { createDieResult, createFateDieResult } from './die.js';
|
|
46
|
+
import { chargeDice, type EvalEnv } from './env.js';
|
|
47
|
+
import { applyCritThresholds } from './modifiers/crit-threshold.js';
|
|
48
|
+
import { applyDieBound } from './modifiers/die-bound.js';
|
|
41
49
|
import {
|
|
42
50
|
applyCompoundExplode,
|
|
43
51
|
applyPenetratingExplode,
|
|
@@ -46,13 +54,12 @@ import {
|
|
|
46
54
|
DEFAULT_MAX_EXPLODE_ITERATIONS,
|
|
47
55
|
} from './modifiers/explode.js';
|
|
48
56
|
import {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
} from './modifiers/keep-drop.js';
|
|
57
|
+
isVersusDc,
|
|
58
|
+
rewriteFlags,
|
|
59
|
+
SELECTION_AND_TALLY_FLAGS,
|
|
60
|
+
SELECTION_FLAGS,
|
|
61
|
+
} from './modifiers/flags.js';
|
|
62
|
+
import { markDroppedIndices, sumKeptDice } from './modifiers/keep-drop.js';
|
|
56
63
|
import {
|
|
57
64
|
applyRecursiveReroll,
|
|
58
65
|
applyRerollOnce,
|
|
@@ -61,74 +68,71 @@ import {
|
|
|
61
68
|
import { sortDice } from './modifiers/sort.js';
|
|
62
69
|
import { countSuccesses } from './modifiers/success-count.js';
|
|
63
70
|
|
|
71
|
+
// Defined in `errors.ts` so the modifier modules can throw it without an ESM
|
|
72
|
+
// value cycle back through here; re-exported for importers that expect it here.
|
|
73
|
+
export { EvaluatorError };
|
|
74
|
+
|
|
75
|
+
//
|
|
76
|
+
// * Limits
|
|
77
|
+
//
|
|
78
|
+
|
|
64
79
|
/**
|
|
65
|
-
*
|
|
80
|
+
* Default value of `EvaluationOptions.maxDice`: the number of dice a single
|
|
81
|
+
* evaluation may roll before `DICE_LIMIT_EXCEEDED` is thrown.
|
|
82
|
+
*
|
|
83
|
+
* Counted across the whole expression, not per pool, so `6000d6+6000d6`
|
|
84
|
+
* breaches it. Includes dice rolled by explosions, rerolls, and
|
|
85
|
+
* meta-expressions.
|
|
86
|
+
*
|
|
87
|
+
* @category Limits
|
|
66
88
|
*/
|
|
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
89
|
export const DEFAULT_MAX_DICE = 10_000;
|
|
88
90
|
|
|
89
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Largest rollable `sides` value. `SeededRNG.nextInt` cannot sample ranges
|
|
93
|
+
* above 2^53 without bias and throws a bare `RangeError` there, so cap one
|
|
94
|
+
* below that and report an `EvaluatorError` instead — every failure the
|
|
95
|
+
* library raises must satisfy `isRollParserError`.
|
|
96
|
+
*/
|
|
97
|
+
const MAX_DICE_SIDES = Number.MAX_SAFE_INTEGER;
|
|
90
98
|
|
|
91
99
|
/**
|
|
92
|
-
*
|
|
100
|
+
* Resolves a user-supplied evaluation limit, failing closed.
|
|
93
101
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
102
|
+
* Absent (`undefined` / `null`) takes the library default — the no-options
|
|
103
|
+
* path. Anything else must be a safe integer at or above `min`; strings,
|
|
104
|
+
* `NaN`, `±Infinity`, negatives, and fractions all throw — substituting the
|
|
105
|
+
* default for a rejected value would hand the caller a *higher* limit than
|
|
106
|
+
* they asked for.
|
|
96
107
|
*/
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
};
|
|
108
|
+
function resolveLimit(
|
|
109
|
+
value: number | undefined,
|
|
110
|
+
option: string,
|
|
111
|
+
fallback: number,
|
|
112
|
+
min: number,
|
|
113
|
+
): number {
|
|
114
|
+
if (value == null) return fallback;
|
|
115
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min) {
|
|
116
|
+
throw new RollParserError(
|
|
117
|
+
`Option '${option}' must be an integer >= ${min}, received ${describeValue(value)}`,
|
|
118
|
+
'INVALID_EVALUATION_LIMIT',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export { DEFAULT_MAX_EXPLODE_ITERATIONS, DEFAULT_MAX_REROLL_ITERATIONS };
|
|
125
|
+
|
|
126
|
+
//
|
|
127
|
+
// * Context
|
|
128
|
+
//
|
|
127
129
|
|
|
128
130
|
/**
|
|
129
131
|
* Per-branch mutable accumulator for tracking rolls and output during recursion.
|
|
130
132
|
*
|
|
131
|
-
*
|
|
133
|
+
* Module-level export, deliberately absent from `src/index.ts` — the package
|
|
134
|
+
* surface never mentions it. See {@link mergeMetaRolls} for why the export
|
|
135
|
+
* exists at all.
|
|
132
136
|
*/
|
|
133
137
|
export type EvalContext = {
|
|
134
138
|
rolls: DieResult[];
|
|
@@ -147,16 +151,20 @@ export type EvalContext = {
|
|
|
147
151
|
};
|
|
148
152
|
};
|
|
149
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Creates an empty per-branch accumulator. Every sub-evaluation runs in a
|
|
156
|
+
* fresh context so its rolls and rendered fragments can be merged back on the
|
|
157
|
+
* parent's terms.
|
|
158
|
+
*/
|
|
159
|
+
function createContext(): EvalContext {
|
|
160
|
+
return { rolls: [], expressionParts: [], renderedParts: [] };
|
|
161
|
+
}
|
|
162
|
+
|
|
150
163
|
/**
|
|
151
164
|
* Flattened representation of a keep/drop modifier for chain evaluation.
|
|
152
|
-
* Superset of the public `
|
|
165
|
+
* Superset of the public `KeepDropSpec` (adds the notation `code`).
|
|
153
166
|
*/
|
|
154
|
-
type
|
|
155
|
-
modifier: 'keep' | 'drop';
|
|
156
|
-
selector: 'highest' | 'lowest';
|
|
157
|
-
count: number;
|
|
158
|
-
code: string;
|
|
159
|
-
};
|
|
167
|
+
type KeepDropChainEntry = KeepDropSpec & { code: string };
|
|
160
168
|
|
|
161
169
|
/**
|
|
162
170
|
* Every branch returns its numeric total AND the `RollPart` it contributes
|
|
@@ -168,49 +176,36 @@ type EvalResult = {
|
|
|
168
176
|
part: RollPart;
|
|
169
177
|
};
|
|
170
178
|
|
|
179
|
+
//
|
|
180
|
+
// * Shared helpers
|
|
181
|
+
//
|
|
182
|
+
|
|
171
183
|
/**
|
|
172
184
|
* Copies an AST node's source span onto a part (spread into the literal).
|
|
173
185
|
* Empty when the AST was built without parser spans.
|
|
174
186
|
*/
|
|
175
187
|
function partSpan(node: ASTNode): { start?: number; end?: number } {
|
|
176
|
-
if (node.start
|
|
188
|
+
if (node.start == null || node.end == null) return {};
|
|
177
189
|
return { start: node.start, end: node.end };
|
|
178
190
|
}
|
|
179
191
|
|
|
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
192
|
/**
|
|
188
|
-
*
|
|
193
|
+
* Appends every element of `source` to `target`.
|
|
194
|
+
*
|
|
195
|
+
* Replaces `target.push(...source)` — the spread form passes one argument per
|
|
196
|
+
* element and overflows the call stack somewhere above half a million dice,
|
|
197
|
+
* which a user-raised `maxDice` can reach. A `RangeError` there would escape
|
|
198
|
+
* the `isRollParserError` contract.
|
|
189
199
|
*/
|
|
190
|
-
function
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
sides,
|
|
195
|
-
result,
|
|
196
|
-
modifiers: [],
|
|
197
|
-
critical: result === sides && sides > 1,
|
|
198
|
-
fumble: result === 1 && sides > 1,
|
|
199
|
-
};
|
|
200
|
+
function appendAll<T>(target: T[], source: readonly T[]): void {
|
|
201
|
+
for (const item of source) {
|
|
202
|
+
target.push(item);
|
|
203
|
+
}
|
|
200
204
|
}
|
|
201
205
|
|
|
202
|
-
/**
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
*/
|
|
206
|
-
function createFateDieResult(result: number): DieResult {
|
|
207
|
-
return {
|
|
208
|
-
sides: 0,
|
|
209
|
-
result,
|
|
210
|
-
modifiers: [],
|
|
211
|
-
critical: false,
|
|
212
|
-
fumble: false,
|
|
213
|
-
};
|
|
206
|
+
/** Drops the internal notation `code`, leaving the public `KeepDropSpec` shape. */
|
|
207
|
+
function toPublicSpecs(specs: KeepDropChainEntry[]): KeepDropSpec[] {
|
|
208
|
+
return specs.map(({ code: _code, ...spec }) => spec);
|
|
214
209
|
}
|
|
215
210
|
|
|
216
211
|
/**
|
|
@@ -223,21 +218,40 @@ function createFateDieResult(result: number): DieResult {
|
|
|
223
218
|
* `RollResult.rolls` for audit, not for display.
|
|
224
219
|
*/
|
|
225
220
|
function renderDice(dice: DieResult[]): string {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
221
|
+
// Hot path: one pass into a single string — filter + map + join allocated two
|
|
222
|
+
// intermediate arrays plus a string per die.
|
|
223
|
+
let rendered = '[';
|
|
224
|
+
let isFirst = true;
|
|
225
|
+
|
|
226
|
+
for (const die of dice) {
|
|
227
|
+
const { modifiers } = die;
|
|
228
|
+
|
|
229
|
+
// Fast path: a die untouched by any modifier carries exactly ['kept'].
|
|
230
|
+
// Skipping the `meta` scan here and the three in `renderDie` more than
|
|
231
|
+
// halves a plain `1000d6` — rendering dominates large unmodified pools.
|
|
232
|
+
if (modifiers.length === 1 && modifiers[0] === 'kept') {
|
|
233
|
+
rendered = isFirst ? `${rendered}${die.result}` : `${rendered}, ${die.result}`;
|
|
234
|
+
isFirst = false;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (modifiers.includes('meta')) continue;
|
|
239
|
+
|
|
240
|
+
if (!isFirst) rendered += ', ';
|
|
241
|
+
isFirst = false;
|
|
242
|
+
|
|
243
|
+
rendered += renderDie(die.result, modifiers);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return `${rendered}]`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Marker-wrapped spelling of one die, per `renderDice`'s priority order. */
|
|
250
|
+
function renderDie(result: number, modifiers: readonly DieModifier[]): string {
|
|
251
|
+
if (modifiers.includes('dropped')) return `~~${result}~~`;
|
|
252
|
+
if (modifiers.includes('success')) return `**${result}**`;
|
|
253
|
+
if (modifiers.includes('failure')) return `__${result}__`;
|
|
254
|
+
return String(result);
|
|
241
255
|
}
|
|
242
256
|
|
|
243
257
|
/**
|
|
@@ -253,23 +267,27 @@ function renderDice(dice: DieResult[]): string {
|
|
|
253
267
|
* a SuccessCount leaking into a meta sub-expression (parser rejects all such
|
|
254
268
|
* wrappings; this strip ensures a future parse regression cannot leak tags
|
|
255
269
|
* into the top-level `successes`/`failures` scan).
|
|
270
|
+
*
|
|
271
|
+
* `versusMetadata` is deliberately not forwarded — a meta sub-expression
|
|
272
|
+
* resolves to a scalar. `evalMetaOperand` rejects a versus before reaching
|
|
273
|
+
* here, so nothing is lost by the omission.
|
|
256
274
|
*/
|
|
257
|
-
|
|
275
|
+
// Exported rather than `@internal`: the tag strip above is unreachable through
|
|
276
|
+
// any parseable notation, so pinning it needs a direct call with a hand-built
|
|
277
|
+
// context — and `@internal` would hide that guarantee from the docs.
|
|
258
278
|
export function mergeMetaRolls(parent: EvalContext, source: EvalContext): void {
|
|
259
279
|
for (const die of source.rolls) {
|
|
260
280
|
parent.rolls.push({
|
|
261
281
|
...die,
|
|
262
|
-
modifiers:
|
|
263
|
-
...die.modifiers.filter(
|
|
264
|
-
(m) => m !== 'kept' && m !== 'dropped' && m !== 'success' && m !== 'failure',
|
|
265
|
-
),
|
|
266
|
-
'meta',
|
|
267
|
-
'dropped',
|
|
268
|
-
],
|
|
282
|
+
modifiers: rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'meta', 'dropped'),
|
|
269
283
|
});
|
|
270
284
|
}
|
|
271
285
|
}
|
|
272
286
|
|
|
287
|
+
//
|
|
288
|
+
// * Node dispatch
|
|
289
|
+
//
|
|
290
|
+
|
|
273
291
|
/**
|
|
274
292
|
* Evaluates an AST node, returning its total and `RollPart` while updating
|
|
275
293
|
* the context.
|
|
@@ -282,9 +300,8 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eval
|
|
|
282
300
|
try {
|
|
283
301
|
return evalNodeInner(node, rng, ctx, env);
|
|
284
302
|
} catch (error) {
|
|
285
|
-
if (error instanceof EvaluatorError &&
|
|
286
|
-
error.start
|
|
287
|
-
error.end = node.end;
|
|
303
|
+
if (error instanceof EvaluatorError && node.start != null) {
|
|
304
|
+
stampEvaluatorSpan(error, node.start, node.end);
|
|
288
305
|
}
|
|
289
306
|
throw error;
|
|
290
307
|
}
|
|
@@ -307,8 +324,8 @@ function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
307
324
|
case 'UnaryOp':
|
|
308
325
|
return evalUnaryOp(node, rng, ctx, env);
|
|
309
326
|
|
|
310
|
-
case '
|
|
311
|
-
return
|
|
327
|
+
case 'KeepDrop':
|
|
328
|
+
return evalKeepDrop(node, rng, ctx, env);
|
|
312
329
|
|
|
313
330
|
case 'Explode':
|
|
314
331
|
return evalExplode(node, rng, ctx, env);
|
|
@@ -316,6 +333,9 @@ function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
316
333
|
case 'Reroll':
|
|
317
334
|
return evalReroll(node, rng, ctx, env);
|
|
318
335
|
|
|
336
|
+
case 'DieBound':
|
|
337
|
+
return evalDieBound(node, rng, ctx, env);
|
|
338
|
+
|
|
319
339
|
case 'SuccessCount':
|
|
320
340
|
return evalSuccessCount(node, rng, ctx, env);
|
|
321
341
|
|
|
@@ -351,6 +371,10 @@ function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
351
371
|
}
|
|
352
372
|
}
|
|
353
373
|
|
|
374
|
+
//
|
|
375
|
+
// * Leaf nodes
|
|
376
|
+
//
|
|
377
|
+
|
|
354
378
|
function evalLiteral(node: LiteralNode, ctx: EvalContext): EvalResult {
|
|
355
379
|
const { value } = node;
|
|
356
380
|
ctx.expressionParts.push(String(value));
|
|
@@ -413,99 +437,149 @@ function evalVariable(node: VariableNode, ctx: EvalContext, env: EvalEnv): EvalR
|
|
|
413
437
|
};
|
|
414
438
|
}
|
|
415
439
|
|
|
440
|
+
//
|
|
441
|
+
// * Dice pools
|
|
442
|
+
//
|
|
443
|
+
|
|
416
444
|
/**
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
445
|
+
* Evaluates a meta sub-expression (dice count, dice sides, a modifier count, a
|
|
446
|
+
* threshold) in an isolated context and forwards its rolls into `ctx` as meta
|
|
447
|
+
* dice.
|
|
448
|
+
*
|
|
449
|
+
* A `Literal` operand — the overwhelming majority (`3d6`, `4d6kh3`, `1d20!>18`)
|
|
450
|
+
* — is answered from the node without allocating the throwaway context: a
|
|
451
|
+
* literal draws no RNG, produces no rolls, and cannot throw, so the merge has
|
|
452
|
+
* nothing to carry. Draw order is untouched (see README, Randomness).
|
|
453
|
+
*
|
|
454
|
+
* A versus operand is rejected rather than reduced to its total: this forwards
|
|
455
|
+
* rolls but not `versusMetadata`, so consuming one would drop the resolved
|
|
456
|
+
* `degree`/`natural` with no signal. `rejectVersusMetaOperand` refuses the same
|
|
457
|
+
* positions at parse time; this is the backstop for a hand-built AST.
|
|
458
|
+
*
|
|
459
|
+
* The scan is structural and runs first. Watching for surviving `versusMetadata`
|
|
460
|
+
* instead would miss a versus under a wrapper that voids it (`DieBound`,
|
|
461
|
+
* `KeepDrop`), lose the race to any other runtime error in the operand, and burn
|
|
462
|
+
* RNG draws before failing.
|
|
423
463
|
*/
|
|
424
|
-
function
|
|
425
|
-
|
|
426
|
-
const count = evalNode(node.count, rng, countCtx, env).total;
|
|
427
|
-
mergeMetaRolls(ctx, countCtx);
|
|
464
|
+
function evalMetaOperand(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
|
|
465
|
+
if (node.type === 'Literal') return node.value;
|
|
428
466
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
throw new EvaluatorError(`Invalid dice sides: ${sides}`, 'INVALID_DICE_SIDES', 'Dice');
|
|
467
|
+
if (containsVersus(node)) {
|
|
468
|
+
const error = new EvaluatorError(
|
|
469
|
+
'Versus cannot be used as a meta-expression',
|
|
470
|
+
'NESTED_VERSUS',
|
|
471
|
+
'Versus',
|
|
472
|
+
);
|
|
473
|
+
if (node.start != null) stampEvaluatorSpan(error, node.start, node.end);
|
|
474
|
+
throw error;
|
|
438
475
|
}
|
|
439
476
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
477
|
+
const metaCtx = createContext();
|
|
478
|
+
const value = evalNode(node, rng, metaCtx, env).total;
|
|
479
|
+
mergeMetaRolls(ctx, metaCtx);
|
|
480
|
+
return value;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Rejects dice counts that cannot address a pool. */
|
|
484
|
+
function requireDiceCount(count: number, nodeType: 'Dice' | 'FateDice'): void {
|
|
485
|
+
if (!Number.isInteger(count) || count < 0) {
|
|
486
|
+
throw new EvaluatorError(`Invalid dice count: ${count}`, 'INVALID_DICE_COUNT', nodeType);
|
|
446
487
|
}
|
|
447
|
-
|
|
488
|
+
}
|
|
448
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Rolls `count` dice and writes the pool into `ctx` — the tail shared by
|
|
492
|
+
* `evalDice` and `evalFateDice`. `rollDie` produces one die (drawing exactly
|
|
493
|
+
* one RNG value), `notation` is the canonical `NdX` / `NdF` spelling used by
|
|
494
|
+
* both the expression and rendered forms.
|
|
495
|
+
*
|
|
496
|
+
* `rollDie` is responsible for stamping `'kept'` at construction. A fresh pool
|
|
497
|
+
* has no pre-dropped dice, so the `markAllKept` pass this used to run could
|
|
498
|
+
* only ever append that one flag — at the cost of cloning every die. The same
|
|
499
|
+
* reasoning makes the running `total` exact: nothing here is dropped, so it
|
|
500
|
+
* equals `sumKeptDice(dice)`.
|
|
501
|
+
*/
|
|
502
|
+
function rollPool(
|
|
503
|
+
count: number,
|
|
504
|
+
notation: string,
|
|
505
|
+
rollDie: () => DieResult,
|
|
506
|
+
ctx: EvalContext,
|
|
507
|
+
): { total: number; rolls: DieResult[] } {
|
|
449
508
|
const dice: DieResult[] = [];
|
|
509
|
+
let total = 0;
|
|
510
|
+
|
|
450
511
|
for (let i = 0; i < count; i++) {
|
|
451
|
-
const
|
|
452
|
-
dice.push(
|
|
512
|
+
const die = rollDie();
|
|
513
|
+
dice.push(die);
|
|
514
|
+
total += die.result;
|
|
453
515
|
}
|
|
454
516
|
|
|
455
|
-
|
|
456
|
-
ctx.rolls.push(...markedDice);
|
|
457
|
-
|
|
458
|
-
const total = sumKeptDice(markedDice);
|
|
459
|
-
const notation = `${count}d${sides}`;
|
|
517
|
+
appendAll(ctx.rolls, dice);
|
|
460
518
|
|
|
461
519
|
ctx.expressionParts.push(notation);
|
|
462
|
-
ctx.renderedParts.push(`${notation}${renderDice(
|
|
520
|
+
ctx.renderedParts.push(`${notation}${renderDice(dice)}`);
|
|
463
521
|
|
|
464
|
-
return {
|
|
465
|
-
total,
|
|
466
|
-
part: { type: 'dice', count, sides, rolls: markedDice, total, ...partSpan(node) },
|
|
467
|
-
};
|
|
522
|
+
return { total, rolls: dice };
|
|
468
523
|
}
|
|
469
524
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
525
|
+
/**
|
|
526
|
+
* RNG draw order: `count` expression → `sides` expression → pool dice
|
|
527
|
+
* (one `nextInt` per die, left-to-right). Meta-expressions on `count`/`sides`
|
|
528
|
+
* (e.g. `(1+1)d(3*2)`) draw before the pool. For keep/drop-argument
|
|
529
|
+
* meta-expressions like `4d6kh(1d2)`, `flattenKeepDropChain` draws the
|
|
530
|
+
* keep/drop args first, then `evalKeepDrop` calls `evalDice` for the base
|
|
531
|
+
* pool. See README, Randomness → Draw order, for the full spec.
|
|
532
|
+
*/
|
|
533
|
+
function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
534
|
+
const count = evalMetaOperand(node.count, rng, ctx, env);
|
|
535
|
+
const sides = evalMetaOperand(node.sides, rng, ctx, env);
|
|
474
536
|
|
|
475
|
-
|
|
476
|
-
|
|
537
|
+
requireDiceCount(count, 'Dice');
|
|
538
|
+
if (!Number.isInteger(sides) || sides < 1) {
|
|
539
|
+
throw new EvaluatorError(`Invalid dice sides: ${sides}`, 'INVALID_DICE_SIDES', 'Dice');
|
|
477
540
|
}
|
|
478
|
-
|
|
479
|
-
if (env.totalDiceRolled + count > env.maxDice) {
|
|
541
|
+
if (sides > MAX_DICE_SIDES) {
|
|
480
542
|
throw new EvaluatorError(
|
|
481
|
-
`
|
|
482
|
-
'
|
|
483
|
-
'
|
|
543
|
+
`Dice sides ${sides} exceeds maximum of ${MAX_DICE_SIDES}`,
|
|
544
|
+
'INVALID_DICE_SIDES',
|
|
545
|
+
'Dice',
|
|
484
546
|
);
|
|
485
547
|
}
|
|
486
|
-
env.totalDiceRolled += count;
|
|
487
548
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
549
|
+
chargeDice(env, count, 'Dice');
|
|
550
|
+
|
|
551
|
+
const { total, rolls } = rollPool(
|
|
552
|
+
count,
|
|
553
|
+
`${count}d${sides}`,
|
|
554
|
+
// Fresh `['kept']` per die — `success-count` appends tally flags in
|
|
555
|
+
// place, so a shared literal would tag the whole pool at once.
|
|
556
|
+
() => createDieResult(sides, rng.nextInt(1, sides), ['kept']),
|
|
557
|
+
ctx,
|
|
558
|
+
);
|
|
493
559
|
|
|
494
|
-
|
|
495
|
-
|
|
560
|
+
return { total, part: { type: 'dice', count, sides, rolls, total, ...partSpan(node) } };
|
|
561
|
+
}
|
|
496
562
|
|
|
497
|
-
|
|
498
|
-
const
|
|
563
|
+
function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
564
|
+
const count = evalMetaOperand(node.count, rng, ctx, env);
|
|
499
565
|
|
|
500
|
-
|
|
501
|
-
|
|
566
|
+
requireDiceCount(count, 'FateDice');
|
|
567
|
+
chargeDice(env, count, 'FateDice');
|
|
502
568
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
569
|
+
const { total, rolls } = rollPool(
|
|
570
|
+
count,
|
|
571
|
+
`${count}dF`,
|
|
572
|
+
() => createFateDieResult(rng.nextInt(-1, 1), ['kept']),
|
|
573
|
+
ctx,
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
return { total, part: { type: 'fateDice', count, rolls, total, ...partSpan(node) } };
|
|
507
577
|
}
|
|
508
578
|
|
|
579
|
+
//
|
|
580
|
+
// * Context merging
|
|
581
|
+
//
|
|
582
|
+
|
|
509
583
|
/**
|
|
510
584
|
* Propagates `versusMetadata` onto a parent so `degree`/`natural` survive
|
|
511
585
|
* wrappers like `floor(...)`, `(vs) + 0`, or `-(vs)`. Throws `NESTED_VERSUS`
|
|
@@ -513,9 +587,27 @@ function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
513
587
|
* the same `RollResult`. No-op when `metadata` is `undefined`.
|
|
514
588
|
*
|
|
515
589
|
* Use this directly when the caller has already pushed (or transformed) `rolls`
|
|
516
|
-
* itself
|
|
517
|
-
*
|
|
518
|
-
*
|
|
590
|
+
* itself. For the default case where the child's raw rolls flow up unchanged,
|
|
591
|
+
* use `mergeContext` instead.
|
|
592
|
+
*
|
|
593
|
+
* A **postfix modifier** may propagate only when it leaves `total` equal to the
|
|
594
|
+
* value the degree was resolved from — `evalVersus` computes `degree` once,
|
|
595
|
+
* against the total it saw, so a modifier that re-totals invalidates it. Among
|
|
596
|
+
* the modifiers only `evalSort` (reorders) and `evalCritThreshold` (tags)
|
|
597
|
+
* qualify. `evalExplode` adds dice, `evalReroll` replaces them, `evalKeepDrop`
|
|
598
|
+
* removes them, `evalDieBound` re-sums after clamping, and `evalSuccessCount`
|
|
599
|
+
* redefines `total` as a success tally, so none of those five may.
|
|
600
|
+
* `evalGroupKeepDrop` applies the rule per sub-roll, propagating only from
|
|
601
|
+
* sub-rolls it kept.
|
|
602
|
+
*
|
|
603
|
+
* Judge a modifier by what it does to `total`, not by which dice survive. The
|
|
604
|
+
* membership reading — "every die still contributes" — admits `evalDieBound`,
|
|
605
|
+
* which drops no dice and still invalidates the degree.
|
|
606
|
+
*
|
|
607
|
+
* Arithmetic wrappers are the deliberate exception named above: `(vs) + 100`
|
|
608
|
+
* reports `degree` beside a total of 103, because the wrapper post-processes a
|
|
609
|
+
* number without altering the comparison the degree came from. That is why they
|
|
610
|
+
* route through `mergeContext` rather than being held to the rule here.
|
|
519
611
|
*/
|
|
520
612
|
function propagateMetadata(parent: EvalContext, metadata: EvalContext['versusMetadata']): void {
|
|
521
613
|
if (!metadata) return;
|
|
@@ -537,13 +629,17 @@ function propagateMetadata(parent: EvalContext, metadata: EvalContext['versusMet
|
|
|
537
629
|
* those with its own operator/function syntax.
|
|
538
630
|
*/
|
|
539
631
|
function mergeContext(parent: EvalContext, child: EvalContext): void {
|
|
540
|
-
parent.rolls
|
|
632
|
+
appendAll(parent.rolls, child.rolls);
|
|
541
633
|
propagateMetadata(parent, child.versusMetadata);
|
|
542
634
|
}
|
|
543
635
|
|
|
636
|
+
//
|
|
637
|
+
// * Arithmetic
|
|
638
|
+
//
|
|
639
|
+
|
|
544
640
|
function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
545
|
-
const leftCtx
|
|
546
|
-
const rightCtx
|
|
641
|
+
const leftCtx = createContext();
|
|
642
|
+
const rightCtx = createContext();
|
|
547
643
|
|
|
548
644
|
const left = evalNode(node.left, rng, leftCtx, env);
|
|
549
645
|
const right = evalNode(node.right, rng, rightCtx, env);
|
|
@@ -606,7 +702,7 @@ function applyBinaryOperator(
|
|
|
606
702
|
}
|
|
607
703
|
|
|
608
704
|
function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
609
|
-
const innerCtx
|
|
705
|
+
const innerCtx = createContext();
|
|
610
706
|
const inner = evalNode(node.operand, rng, innerCtx, env);
|
|
611
707
|
|
|
612
708
|
mergeContext(ctx, innerCtx);
|
|
@@ -625,8 +721,12 @@ function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
625
721
|
};
|
|
626
722
|
}
|
|
627
723
|
|
|
724
|
+
//
|
|
725
|
+
// * Groups and functions
|
|
726
|
+
//
|
|
727
|
+
|
|
628
728
|
function evalGrouped(node: GroupedNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
629
|
-
const innerCtx
|
|
729
|
+
const innerCtx = createContext();
|
|
630
730
|
const inner = evalNode(node.expression, rng, innerCtx, env);
|
|
631
731
|
|
|
632
732
|
mergeContext(ctx, innerCtx);
|
|
@@ -646,7 +746,7 @@ function evalGrouped(node: GroupedNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
646
746
|
* Each sub-expression is evaluated in an isolated context, then its rolls
|
|
647
747
|
* and `versusMetadata` propagate up via `mergeContext`. Sub-roll subtotals
|
|
648
748
|
* sum to the group's total. When the group is the base target of a
|
|
649
|
-
* keep/drop modifier with `expressions.length >= 2`, `
|
|
749
|
+
* keep/drop modifier with `expressions.length >= 2`, `evalKeepDrop`
|
|
650
750
|
* intercepts first and never calls this function — dual semantics
|
|
651
751
|
* (flat-pool vs sub-roll) are decided there.
|
|
652
752
|
*/
|
|
@@ -657,7 +757,7 @@ function evalGroup(node: GroupNode, rng: RNG, ctx: EvalContext, env: EvalEnv): E
|
|
|
657
757
|
let total = 0;
|
|
658
758
|
|
|
659
759
|
for (const expr of node.expressions) {
|
|
660
|
-
const subCtx
|
|
760
|
+
const subCtx = createContext();
|
|
661
761
|
const sub = evalNode(expr, rng, subCtx, env);
|
|
662
762
|
mergeContext(ctx, subCtx);
|
|
663
763
|
subExprs.push(subCtx.expressionParts.join(''));
|
|
@@ -669,8 +769,8 @@ function evalGroup(node: GroupNode, rng: RNG, ctx: EvalContext, env: EvalEnv): E
|
|
|
669
769
|
ctx.expressionParts.push(`{${subExprs.join(', ')}}`);
|
|
670
770
|
ctx.renderedParts.push(`{${subRendered.join(', ')}}`);
|
|
671
771
|
|
|
672
|
-
//
|
|
673
|
-
//
|
|
772
|
+
// No `keptIndices` — bare groups (and single-sub passthroughs) perform
|
|
773
|
+
// no sub-roll selection; only `evalGroupKeepDrop` sets it.
|
|
674
774
|
return { total, part: { type: 'group', parts: subParts, total, ...partSpan(node) } };
|
|
675
775
|
}
|
|
676
776
|
|
|
@@ -684,7 +784,7 @@ function evalFunctionCall(
|
|
|
684
784
|
const argResults: EvalResult[] = [];
|
|
685
785
|
|
|
686
786
|
for (const arg of node.args) {
|
|
687
|
-
const argCtx
|
|
787
|
+
const argCtx = createContext();
|
|
688
788
|
argResults.push(evalNode(arg, rng, argCtx, env));
|
|
689
789
|
argCtxs.push(argCtx);
|
|
690
790
|
}
|
|
@@ -723,26 +823,52 @@ function applyFunction(name: string, values: number[]): number {
|
|
|
723
823
|
case 'ceil':
|
|
724
824
|
return Math.ceil(requireUnaryArg(name, values));
|
|
725
825
|
case 'round':
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
// not `-3`. Users needing symmetric rounding must compose via `floor`.
|
|
826
|
+
// `Math.round` breaks halves toward +∞: `round(2.5) === 3` but
|
|
827
|
+
// `round(-2.5) === -2`. Symmetric rounding must be composed via `floor`.
|
|
729
828
|
return Math.round(requireUnaryArg(name, values));
|
|
730
829
|
case 'abs':
|
|
731
830
|
return Math.abs(requireUnaryArg(name, values));
|
|
831
|
+
case 'sqrt':
|
|
832
|
+
// A negative argument yields NaN, surfaced as `NON_FINITE_RESULT` by
|
|
833
|
+
// the top-level finiteness check — same policy as `1/0`.
|
|
834
|
+
return Math.sqrt(requireUnaryArg(name, values));
|
|
835
|
+
case 'pow': {
|
|
836
|
+
const [base, exponent] = requireBinaryArgs(name, values);
|
|
837
|
+
return base ** exponent;
|
|
838
|
+
}
|
|
732
839
|
case 'max':
|
|
733
|
-
return
|
|
840
|
+
return extremumOf(values, 'max');
|
|
734
841
|
case 'min':
|
|
735
|
-
return
|
|
842
|
+
return extremumOf(values, 'min');
|
|
736
843
|
default:
|
|
737
844
|
throw new EvaluatorError(`Unknown function: ${name}`, 'UNKNOWN_FUNCTION', 'FunctionCall');
|
|
738
845
|
}
|
|
739
846
|
}
|
|
740
847
|
|
|
848
|
+
/**
|
|
849
|
+
* Folded replacement for `Math.max(...values)` / `Math.min(...values)`.
|
|
850
|
+
*
|
|
851
|
+
* `max`/`min` are variadic, so a pathological argument count blows the call
|
|
852
|
+
* stack with a bare `RangeError` in the spread form. NaN still poisons the
|
|
853
|
+
* result the way `Math.max` does, so a non-finite argument keeps surfacing as
|
|
854
|
+
* `NON_FINITE_RESULT` rather than being silently skipped.
|
|
855
|
+
*/
|
|
856
|
+
function extremumOf(values: number[], kind: 'max' | 'min'): number {
|
|
857
|
+
let result = kind === 'max' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
|
|
858
|
+
|
|
859
|
+
for (const value of values) {
|
|
860
|
+
if (Number.isNaN(value)) return Number.NaN;
|
|
861
|
+
if (kind === 'max' ? value > result : value < result) result = value;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
return result;
|
|
865
|
+
}
|
|
866
|
+
|
|
741
867
|
function requireUnaryArg(name: string, values: number[]): number {
|
|
742
868
|
const [x] = values;
|
|
743
|
-
if (x
|
|
744
|
-
//
|
|
745
|
-
// `noNonNullAssertion`.
|
|
869
|
+
if (x == null) {
|
|
870
|
+
// Unreachable: the parser validates arity before evaluation. Present to
|
|
871
|
+
// satisfy `noNonNullAssertion`.
|
|
746
872
|
throw new EvaluatorError(
|
|
747
873
|
`Function '${name}' requires an argument`,
|
|
748
874
|
'UNKNOWN_FUNCTION',
|
|
@@ -752,42 +878,64 @@ function requireUnaryArg(name: string, values: number[]): number {
|
|
|
752
878
|
return x;
|
|
753
879
|
}
|
|
754
880
|
|
|
881
|
+
function requireBinaryArgs(name: string, values: number[]): [number, number] {
|
|
882
|
+
const [a, b] = values;
|
|
883
|
+
if (a == null || b == null) {
|
|
884
|
+
// ? Unreachable: parser validates arity before evaluation. Defensive for
|
|
885
|
+
// `noNonNullAssertion`.
|
|
886
|
+
throw new EvaluatorError(
|
|
887
|
+
`Function '${name}' requires two arguments`,
|
|
888
|
+
'UNKNOWN_FUNCTION',
|
|
889
|
+
'FunctionCall',
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
return [a, b];
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
//
|
|
896
|
+
// * Keep/drop modifiers
|
|
897
|
+
//
|
|
898
|
+
|
|
899
|
+
/** Notation spelling of each keep/drop combination. */
|
|
900
|
+
const KEEP_DROP_CODES = {
|
|
901
|
+
keep: { highest: 'kh', lowest: 'kl' },
|
|
902
|
+
drop: { highest: 'dh', lowest: 'dl' },
|
|
903
|
+
} as const;
|
|
904
|
+
|
|
905
|
+
function keepDropCode(kind: KeepDropSpec['kind'], selector: KeepDropSpec['selector']): string {
|
|
906
|
+
return KEEP_DROP_CODES[kind][selector];
|
|
907
|
+
}
|
|
908
|
+
|
|
755
909
|
/**
|
|
756
|
-
* Walks a nested
|
|
910
|
+
* Walks a nested KeepDropNode chain, collecting specs outermost-first,
|
|
757
911
|
* then reverses to notation order (innermost-first).
|
|
758
912
|
*/
|
|
759
|
-
function
|
|
760
|
-
node:
|
|
913
|
+
function flattenKeepDropChain(
|
|
914
|
+
node: KeepDropNode,
|
|
761
915
|
rng: RNG,
|
|
762
916
|
ctx: EvalContext,
|
|
763
917
|
env: EvalEnv,
|
|
764
|
-
): { specs:
|
|
765
|
-
const specs:
|
|
918
|
+
): { specs: KeepDropChainEntry[]; baseTarget: ASTNode } {
|
|
919
|
+
const specs: KeepDropChainEntry[] = [];
|
|
766
920
|
let current: ASTNode = node;
|
|
767
921
|
|
|
768
|
-
while (
|
|
769
|
-
const
|
|
770
|
-
const modCount = evalNode(current.count, rng, countCtx, env).total;
|
|
771
|
-
mergeMetaRolls(ctx, countCtx);
|
|
922
|
+
while (isKeepDrop(current)) {
|
|
923
|
+
const modCount = evalMetaOperand(current.count, rng, ctx, env);
|
|
772
924
|
|
|
773
925
|
if (!Number.isInteger(modCount) || modCount < 0) {
|
|
774
926
|
throw new EvaluatorError(
|
|
775
|
-
`Invalid
|
|
776
|
-
'
|
|
777
|
-
'
|
|
927
|
+
`Invalid keep/drop count: ${modCount}`,
|
|
928
|
+
'INVALID_KEEP_DROP_COUNT',
|
|
929
|
+
'KeepDrop',
|
|
778
930
|
);
|
|
779
931
|
}
|
|
780
932
|
|
|
781
|
-
|
|
782
|
-
current.
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
? 'dh'
|
|
788
|
-
: 'dl';
|
|
789
|
-
|
|
790
|
-
specs.push({ modifier: current.modifier, selector: current.selector, count: modCount, code });
|
|
933
|
+
specs.push({
|
|
934
|
+
kind: current.kind,
|
|
935
|
+
selector: current.selector,
|
|
936
|
+
count: modCount,
|
|
937
|
+
code: keepDropCode(current.kind, current.selector),
|
|
938
|
+
});
|
|
791
939
|
current = current.target;
|
|
792
940
|
}
|
|
793
941
|
|
|
@@ -795,50 +943,57 @@ function flattenModifierChain(
|
|
|
795
943
|
return { specs, baseTarget: current };
|
|
796
944
|
}
|
|
797
945
|
|
|
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
946
|
/**
|
|
813
947
|
* Applies each modifier independently to the full dice pool
|
|
814
948
|
* and merges drop sets via union. A die is dropped if ANY modifier dropped it.
|
|
815
949
|
*
|
|
816
950
|
* Mutates each die's flags in place — the same `DieResult` objects are
|
|
817
|
-
* shared between `RollResult.rolls` and the `RollPart` tree.
|
|
818
|
-
*
|
|
819
|
-
*
|
|
951
|
+
* shared between `RollResult.rolls` and the `RollPart` tree. Each spec still
|
|
952
|
+
* selects against the unmodified pool: `markDroppedIndices` reads results and
|
|
953
|
+
* writes only into `droppedMask`, so no spec can observe another's outcome.
|
|
820
954
|
*/
|
|
821
|
-
function mergeDropSets(baseDice: DieResult[], specs:
|
|
822
|
-
const
|
|
955
|
+
function mergeDropSets(baseDice: DieResult[], specs: KeepDropChainEntry[]): DieResult[] {
|
|
956
|
+
const droppedMask = new Uint8Array(baseDice.length);
|
|
823
957
|
|
|
824
958
|
for (const spec of specs) {
|
|
825
|
-
|
|
826
|
-
for (let i = 0; i < result.length; i++) {
|
|
827
|
-
if (result[i]?.modifiers.includes('dropped')) {
|
|
828
|
-
droppedIndices.add(i);
|
|
829
|
-
}
|
|
830
|
-
}
|
|
959
|
+
markDroppedIndices(baseDice, spec.count, spec.kind, spec.selector, droppedMask);
|
|
831
960
|
}
|
|
832
961
|
|
|
833
|
-
baseDice.
|
|
834
|
-
die
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
962
|
+
for (let index = 0; index < baseDice.length; index++) {
|
|
963
|
+
const die = baseDice[index];
|
|
964
|
+
if (die == null) continue;
|
|
965
|
+
|
|
966
|
+
const marker = droppedMask[index] === 1 ? 'dropped' : 'kept';
|
|
967
|
+
const { modifiers } = die;
|
|
968
|
+
|
|
969
|
+
// Already exactly this slot flag — `rewriteFlags` would rebuild an
|
|
970
|
+
// identical single-element array. Fresh pool dice hit this every time.
|
|
971
|
+
if (modifiers.length === 1 && modifiers[0] === marker) continue;
|
|
972
|
+
|
|
973
|
+
die.modifiers = rewriteFlags(modifiers, SELECTION_FLAGS, marker);
|
|
974
|
+
}
|
|
838
975
|
|
|
839
976
|
return baseDice;
|
|
840
977
|
}
|
|
841
978
|
|
|
979
|
+
//
|
|
980
|
+
// * Explode and reroll
|
|
981
|
+
//
|
|
982
|
+
|
|
983
|
+
/** Notation marker for each explode variant. */
|
|
984
|
+
const EXPLODE_MARKERS: Record<ExplodeNode['variant'], string> = {
|
|
985
|
+
standard: '!',
|
|
986
|
+
compound: '!!',
|
|
987
|
+
penetrating: '!p',
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
/** Pool transform for each explode variant. */
|
|
991
|
+
const EXPLODE_APPLIERS: Record<ExplodeNode['variant'], typeof applyStandardExplode> = {
|
|
992
|
+
standard: applyStandardExplode,
|
|
993
|
+
compound: applyCompoundExplode,
|
|
994
|
+
penetrating: applyPenetratingExplode,
|
|
995
|
+
};
|
|
996
|
+
|
|
842
997
|
/**
|
|
843
998
|
* Builds the notation string for an explode modifier, e.g. `!`, `!!>=3`, `!p>5`.
|
|
844
999
|
*/
|
|
@@ -847,21 +1002,19 @@ function formatExplodeCode(
|
|
|
847
1002
|
threshold: ComparePoint | undefined,
|
|
848
1003
|
thresholdValue: number | undefined,
|
|
849
1004
|
): string {
|
|
850
|
-
const marker = variant
|
|
1005
|
+
const marker = EXPLODE_MARKERS[variant];
|
|
851
1006
|
if (threshold == null || thresholdValue == null) return marker;
|
|
852
1007
|
return `${marker}${threshold.operator}${thresholdValue}`;
|
|
853
1008
|
}
|
|
854
1009
|
|
|
855
1010
|
function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
856
|
-
const targetCtx
|
|
1011
|
+
const targetCtx = createContext();
|
|
857
1012
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
858
1013
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
859
1014
|
|
|
860
1015
|
let thresholdValue: number | undefined;
|
|
861
1016
|
if (node.threshold != null) {
|
|
862
|
-
|
|
863
|
-
thresholdValue = evalNode(node.threshold.value, rng, thresholdCtx, env).total;
|
|
864
|
-
mergeMetaRolls(ctx, thresholdCtx);
|
|
1017
|
+
thresholdValue = evalMetaOperand(node.threshold.value, rng, ctx, env);
|
|
865
1018
|
}
|
|
866
1019
|
|
|
867
1020
|
const code = formatExplodeCode(node.variant, node.threshold, thresholdValue);
|
|
@@ -874,7 +1027,7 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
874
1027
|
total,
|
|
875
1028
|
...partSpan(node),
|
|
876
1029
|
};
|
|
877
|
-
if (node.threshold != null && thresholdValue
|
|
1030
|
+
if (node.threshold != null && thresholdValue != null) {
|
|
878
1031
|
part.threshold = { operator: node.threshold.operator, value: thresholdValue };
|
|
879
1032
|
}
|
|
880
1033
|
return part;
|
|
@@ -889,18 +1042,12 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
889
1042
|
|
|
890
1043
|
const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
|
|
891
1044
|
|
|
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);
|
|
1045
|
+
const expanded = EXPLODE_APPLIERS[node.variant](targetCtx.rolls, shouldExplode, rng, env);
|
|
898
1046
|
|
|
899
|
-
ctx.rolls
|
|
1047
|
+
appendAll(ctx.rolls, expanded);
|
|
900
1048
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
// dropped dice are visible; explosion-origin is otherwise invisible.
|
|
1049
|
+
// Rendered form carries the explode code — unlike kh/dl, whose dropped dice
|
|
1050
|
+
// are self-evident, explosion origin is otherwise invisible.
|
|
904
1051
|
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
|
|
905
1052
|
|
|
906
1053
|
const total = sumKeptDice(expanded);
|
|
@@ -908,13 +1055,11 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
908
1055
|
}
|
|
909
1056
|
|
|
910
1057
|
function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
911
|
-
const targetCtx
|
|
1058
|
+
const targetCtx = createContext();
|
|
912
1059
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
913
1060
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
914
1061
|
|
|
915
|
-
const
|
|
916
|
-
const thresholdValue = evalNode(node.condition.value, rng, thresholdCtx, env).total;
|
|
917
|
-
mergeMetaRolls(ctx, thresholdCtx);
|
|
1062
|
+
const thresholdValue = evalMetaOperand(node.condition.value, rng, ctx, env);
|
|
918
1063
|
|
|
919
1064
|
const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
|
|
920
1065
|
const condition: ResolvedComparePoint = {
|
|
@@ -944,7 +1089,7 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
944
1089
|
? applyRerollOnce(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env)
|
|
945
1090
|
: applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
|
|
946
1091
|
|
|
947
|
-
ctx.rolls
|
|
1092
|
+
appendAll(ctx.rolls, pool);
|
|
948
1093
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
949
1094
|
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
|
|
950
1095
|
|
|
@@ -962,25 +1107,77 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
962
1107
|
};
|
|
963
1108
|
}
|
|
964
1109
|
|
|
1110
|
+
/**
|
|
1111
|
+
* Evaluates a per-die clamp (`minN` / `maxN`). The bound expression draws
|
|
1112
|
+
* *after* the target pool, like other threshold arguments (see README,
|
|
1113
|
+
* Randomness → Draw order). Dice are clamped in place by `applyDieBound`
|
|
1114
|
+
* and the total re-summed, since clamping changes kept-die values.
|
|
1115
|
+
*/
|
|
1116
|
+
function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
1117
|
+
const targetCtx = createContext();
|
|
1118
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1119
|
+
|
|
1120
|
+
const boundValue = evalMetaOperand(node.value, rng, ctx, env);
|
|
1121
|
+
if (!Number.isFinite(boundValue)) {
|
|
1122
|
+
throw new EvaluatorError(
|
|
1123
|
+
`Invalid ${node.bound} bound: ${boundValue}`,
|
|
1124
|
+
'INVALID_THRESHOLD',
|
|
1125
|
+
'DieBound',
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
applyDieBound(targetCtx.rolls, node.bound, boundValue);
|
|
1130
|
+
|
|
1131
|
+
appendAll(ctx.rolls, targetCtx.rolls);
|
|
1132
|
+
// ! No `propagateMetadata` here: clamping re-sums, so a propagated `degree`
|
|
1133
|
+
// ! would have been resolved against a total this node just replaced.
|
|
1134
|
+
// ! See the rule on `propagateMetadata`.
|
|
1135
|
+
|
|
1136
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
1137
|
+
// Negative bounds render parenthesized so `result.expression` re-parses
|
|
1138
|
+
// (`4d6min-2` is a syntax error; `4d6min(-2)` is not).
|
|
1139
|
+
const code = boundValue < 0 ? `${node.bound}(${boundValue})` : `${node.bound}${boundValue}`;
|
|
1140
|
+
|
|
1141
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1142
|
+
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
|
|
1143
|
+
|
|
1144
|
+
const total = sumKeptDice(targetCtx.rolls);
|
|
1145
|
+
return {
|
|
1146
|
+
total,
|
|
1147
|
+
part: {
|
|
1148
|
+
type: 'dieBound',
|
|
1149
|
+
bound: node.bound,
|
|
1150
|
+
value: boundValue,
|
|
1151
|
+
target: target.part,
|
|
1152
|
+
total,
|
|
1153
|
+
...partSpan(node),
|
|
1154
|
+
},
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
//
|
|
1159
|
+
// * Sort and crit thresholds
|
|
1160
|
+
//
|
|
1161
|
+
|
|
965
1162
|
/**
|
|
966
1163
|
* Evaluates a sort modifier. Purely visual — sorts the dice produced by
|
|
967
1164
|
* `node.target` in ascending or descending order of `result` without
|
|
968
1165
|
* changing the total or any die-level flag. Dropped dice participate in
|
|
969
1166
|
* the sort alongside kept dice, preserving their `'dropped'` marker.
|
|
970
1167
|
*
|
|
971
|
-
* Rendering mirrors `evalExplode` / `
|
|
1168
|
+
* Rendering mirrors `evalExplode` / `evalKeepDrop`: emits
|
|
972
1169
|
* `<targetExpr><code>[<sortedDice>]`, replacing any inline dice brackets
|
|
973
1170
|
* the target itself rendered. Multi-sub-roll Group targets (`{4d6, 3d6}s`)
|
|
974
1171
|
* are rejected at parse time with `INVALID_SORT_TARGET` until hierarchical
|
|
975
1172
|
* per-sub-roll sorting (Stage 3 spec §3 "Group interaction") is implemented.
|
|
976
1173
|
*/
|
|
977
1174
|
function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
978
|
-
const targetCtx
|
|
1175
|
+
const targetCtx = createContext();
|
|
979
1176
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
980
1177
|
|
|
981
1178
|
const sortedRolls = sortDice(targetCtx.rolls, node.order);
|
|
982
1179
|
|
|
983
|
-
ctx.rolls
|
|
1180
|
+
appendAll(ctx.rolls, sortedRolls);
|
|
984
1181
|
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
985
1182
|
|
|
986
1183
|
const code = node.order === 'ascending' ? 's' : 'sd';
|
|
@@ -994,6 +1191,7 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
|
|
|
994
1191
|
part: {
|
|
995
1192
|
type: 'sort',
|
|
996
1193
|
order: node.order,
|
|
1194
|
+
rolls: sortedRolls,
|
|
997
1195
|
target: target.part,
|
|
998
1196
|
total: target.total,
|
|
999
1197
|
...partSpan(node),
|
|
@@ -1023,21 +1221,21 @@ function evalCritThreshold(
|
|
|
1023
1221
|
ctx: EvalContext,
|
|
1024
1222
|
env: EvalEnv,
|
|
1025
1223
|
): EvalResult {
|
|
1026
|
-
const targetCtx
|
|
1224
|
+
const targetCtx = createContext();
|
|
1027
1225
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1028
1226
|
|
|
1029
1227
|
const successResolved = node.successThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
|
|
1030
1228
|
const failResolved = node.failThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
|
|
1031
1229
|
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
1230
|
+
// A side with no explicit threshold falls back to the default rule rather
|
|
1231
|
+
// than being wiped by the other side's override.
|
|
1034
1232
|
const successApplied: ResolvedCritThreshold[] =
|
|
1035
1233
|
successResolved.length > 0 ? successResolved : ['default'];
|
|
1036
1234
|
const failApplied: ResolvedCritThreshold[] = failResolved.length > 0 ? failResolved : ['default'];
|
|
1037
1235
|
|
|
1038
1236
|
applyCritThresholds(targetCtx.rolls, successApplied, failApplied);
|
|
1039
1237
|
|
|
1040
|
-
ctx.rolls
|
|
1238
|
+
appendAll(ctx.rolls, targetCtx.rolls);
|
|
1041
1239
|
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
1042
1240
|
|
|
1043
1241
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
@@ -1069,9 +1267,8 @@ function resolveCritThreshold(
|
|
|
1069
1267
|
env: EvalEnv,
|
|
1070
1268
|
): ResolvedCritThreshold {
|
|
1071
1269
|
if (threshold === 'default') return 'default';
|
|
1072
|
-
|
|
1073
|
-
const resolved =
|
|
1074
|
-
mergeMetaRolls(ctx, thresholdCtx);
|
|
1270
|
+
|
|
1271
|
+
const resolved = evalMetaOperand(threshold.value, rng, ctx, env);
|
|
1075
1272
|
if (!Number.isFinite(resolved)) {
|
|
1076
1273
|
throw new EvaluatorError(
|
|
1077
1274
|
`Invalid crit threshold: ${resolved}`,
|
|
@@ -1082,36 +1279,39 @@ function resolveCritThreshold(
|
|
|
1082
1279
|
return { operator: threshold.operator, value: resolved };
|
|
1083
1280
|
}
|
|
1084
1281
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1282
|
+
//
|
|
1283
|
+
// * Keep/drop evaluation
|
|
1284
|
+
//
|
|
1087
1285
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
//
|
|
1286
|
+
function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
1287
|
+
const { specs, baseTarget } = flattenKeepDropChain(node, rng, ctx, env);
|
|
1288
|
+
|
|
1289
|
+
// Multi-sub-roll group: keep/drop treats each sub-roll subtotal as a compound
|
|
1290
|
+
// die. Single-sub groups fall through to the flat-pool path, where
|
|
1291
|
+
// `mergeDropSets` selects individual dice.
|
|
1092
1292
|
if (baseTarget.type === 'Group' && baseTarget.expressions.length >= 2) {
|
|
1093
|
-
return
|
|
1293
|
+
return evalGroupKeepDrop(node, baseTarget, specs, rng, ctx, env);
|
|
1094
1294
|
}
|
|
1095
1295
|
|
|
1096
|
-
const targetCtx
|
|
1296
|
+
const targetCtx = createContext();
|
|
1097
1297
|
const target = evalNode(baseTarget, rng, targetCtx, env);
|
|
1098
1298
|
|
|
1099
1299
|
const mergedDice = mergeDropSets(targetCtx.rolls, specs);
|
|
1100
1300
|
|
|
1101
|
-
ctx.rolls
|
|
1301
|
+
appendAll(ctx.rolls, mergedDice);
|
|
1102
1302
|
|
|
1103
1303
|
const total = sumKeptDice(mergedDice);
|
|
1104
1304
|
|
|
1105
1305
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
1106
|
-
const
|
|
1306
|
+
const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
1107
1307
|
|
|
1108
|
-
ctx.expressionParts.push(`${targetExpr}${
|
|
1308
|
+
ctx.expressionParts.push(`${targetExpr}${keepDropCodes}`);
|
|
1109
1309
|
ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
|
|
1110
1310
|
|
|
1111
1311
|
return {
|
|
1112
1312
|
total,
|
|
1113
1313
|
part: {
|
|
1114
|
-
type: '
|
|
1314
|
+
type: 'keepDrop',
|
|
1115
1315
|
specs: toPublicSpecs(specs),
|
|
1116
1316
|
target: target.part,
|
|
1117
1317
|
total,
|
|
@@ -1143,10 +1343,10 @@ function stripInnerMarkers(rendered: string): string {
|
|
|
1143
1343
|
* on the propagated rolls still agrees with the group total, and the
|
|
1144
1344
|
* rendered form wraps them in strikethrough `~~...~~`.
|
|
1145
1345
|
*/
|
|
1146
|
-
function
|
|
1147
|
-
node:
|
|
1346
|
+
function evalGroupKeepDrop(
|
|
1347
|
+
node: KeepDropNode,
|
|
1148
1348
|
group: GroupNode,
|
|
1149
|
-
specs:
|
|
1349
|
+
specs: KeepDropChainEntry[],
|
|
1150
1350
|
rng: RNG,
|
|
1151
1351
|
ctx: EvalContext,
|
|
1152
1352
|
env: EvalEnv,
|
|
@@ -1161,7 +1361,7 @@ function evalGroupModifier(
|
|
|
1161
1361
|
};
|
|
1162
1362
|
|
|
1163
1363
|
const subRolls: SubRoll[] = group.expressions.map((expr) => {
|
|
1164
|
-
const subCtx
|
|
1364
|
+
const subCtx = createContext();
|
|
1165
1365
|
const sub = evalNode(expr, rng, subCtx, env);
|
|
1166
1366
|
return {
|
|
1167
1367
|
subtotal: sub.total,
|
|
@@ -1173,9 +1373,8 @@ function evalGroupModifier(
|
|
|
1173
1373
|
};
|
|
1174
1374
|
});
|
|
1175
1375
|
|
|
1176
|
-
//
|
|
1177
|
-
//
|
|
1178
|
-
// `critical`/`fumble` flags would be meaningless for a subtotal.
|
|
1376
|
+
// `sides = 0` sentinel: synthetic dice only feed `mergeDropSets`, never reach
|
|
1377
|
+
// `ctx.rolls`, and crit/fumble is meaningless for a subtotal.
|
|
1179
1378
|
const syntheticDice: DieResult[] = subRolls.map((sub) => ({
|
|
1180
1379
|
sides: 0,
|
|
1181
1380
|
result: sub.subtotal,
|
|
@@ -1196,51 +1395,41 @@ function evalGroupModifier(
|
|
|
1196
1395
|
const isDropped = synth.modifiers.includes('dropped');
|
|
1197
1396
|
|
|
1198
1397
|
if (isDropped) {
|
|
1199
|
-
// Flag every inner die dropped so propagated rolls
|
|
1200
|
-
//
|
|
1201
|
-
//
|
|
1202
|
-
//
|
|
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.
|
|
1398
|
+
// Flag every inner die dropped so propagated rolls still sum to the
|
|
1399
|
+
// total, stripping `'success'`/`'failure'` too so the top-level tally
|
|
1400
|
+
// cannot count a dropped sub-roll. Mutated in place — the same objects
|
|
1401
|
+
// live in the sub-roll's `RollPart`.
|
|
1205
1402
|
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
|
-
];
|
|
1403
|
+
die.modifiers = rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'dropped');
|
|
1212
1404
|
}
|
|
1213
|
-
ctx.rolls
|
|
1405
|
+
appendAll(ctx.rolls, sub.rolls);
|
|
1214
1406
|
outerRendered.push(`~~${stripInnerMarkers(sub.rendered)}~~`);
|
|
1215
1407
|
} else {
|
|
1216
1408
|
keptIndices.push(i);
|
|
1217
|
-
ctx.rolls
|
|
1409
|
+
appendAll(ctx.rolls, sub.rolls);
|
|
1218
1410
|
outerRendered.push(sub.rendered);
|
|
1219
1411
|
total += sub.subtotal;
|
|
1220
1412
|
}
|
|
1221
1413
|
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
1225
|
-
// inside `propagateMetadata`.
|
|
1414
|
+
// Only kept sub-rolls propagate versus metadata — `degree` must reflect
|
|
1415
|
+
// dice that contributed to the total. Two kept versus sub-rolls still
|
|
1416
|
+
// collide via `propagateMetadata`'s `NESTED_VERSUS` guard.
|
|
1226
1417
|
if (!isDropped) {
|
|
1227
1418
|
propagateMetadata(ctx, sub.versusMetadata);
|
|
1228
1419
|
}
|
|
1229
1420
|
}
|
|
1230
1421
|
|
|
1231
1422
|
const subExprStrs = subRolls.map((s) => s.expr);
|
|
1232
|
-
const
|
|
1423
|
+
const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
1233
1424
|
|
|
1234
|
-
ctx.expressionParts.push(`{${subExprStrs.join(', ')}}${
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
// which sub-rolls were kept vs dropped.
|
|
1425
|
+
ctx.expressionParts.push(`{${subExprStrs.join(', ')}}${keepDropCodes}`);
|
|
1426
|
+
// Keep/drop codes live in `expressionParts` only — the per-sub strikethrough
|
|
1427
|
+
// already shows which sub-rolls were kept.
|
|
1238
1428
|
ctx.renderedParts.push(`{${outerRendered.join(', ')}}`);
|
|
1239
1429
|
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
// their complete parts; consumers filter by `keptIndices`.
|
|
1430
|
+
// `keptIndices` sits on the inner `group` part even though the outer modifier
|
|
1431
|
+
// computed it — it describes sub-roll selection. Dropped sub-rolls keep their
|
|
1432
|
+
// complete parts; consumers filter by `keptIndices`.
|
|
1244
1433
|
const groupPart: RollPart = {
|
|
1245
1434
|
type: 'group',
|
|
1246
1435
|
parts: subRolls.map((s) => s.part),
|
|
@@ -1252,7 +1441,7 @@ function evalGroupModifier(
|
|
|
1252
1441
|
return {
|
|
1253
1442
|
total,
|
|
1254
1443
|
part: {
|
|
1255
|
-
type: '
|
|
1444
|
+
type: 'keepDrop',
|
|
1256
1445
|
specs: toPublicSpecs(specs),
|
|
1257
1446
|
target: groupPart,
|
|
1258
1447
|
total,
|
|
@@ -1261,6 +1450,10 @@ function evalGroupModifier(
|
|
|
1261
1450
|
};
|
|
1262
1451
|
}
|
|
1263
1452
|
|
|
1453
|
+
//
|
|
1454
|
+
// * Success counting
|
|
1455
|
+
//
|
|
1456
|
+
|
|
1264
1457
|
function resolveThreshold(
|
|
1265
1458
|
value: ASTNode,
|
|
1266
1459
|
rng: RNG,
|
|
@@ -1268,9 +1461,7 @@ function resolveThreshold(
|
|
|
1268
1461
|
env: EvalEnv,
|
|
1269
1462
|
role: 'threshold' | 'fail threshold',
|
|
1270
1463
|
): number {
|
|
1271
|
-
const
|
|
1272
|
-
const resolved = evalNode(value, rng, thresholdCtx, env).total;
|
|
1273
|
-
mergeMetaRolls(ctx, thresholdCtx);
|
|
1464
|
+
const resolved = evalMetaOperand(value, rng, ctx, env);
|
|
1274
1465
|
|
|
1275
1466
|
if (!Number.isFinite(resolved)) {
|
|
1276
1467
|
throw new EvaluatorError(`Invalid ${role}: ${resolved}`, 'INVALID_THRESHOLD', 'SuccessCount');
|
|
@@ -1279,17 +1470,30 @@ function resolveThreshold(
|
|
|
1279
1470
|
return resolved;
|
|
1280
1471
|
}
|
|
1281
1472
|
|
|
1473
|
+
/**
|
|
1474
|
+
* Builds the failure-threshold suffix of a success-count expression, e.g.
|
|
1475
|
+
* `f3`, `f<3`, `f>=3`.
|
|
1476
|
+
*
|
|
1477
|
+
* `'='` is elided because bare `fN` parses back to `{ operator: '=' }`, so
|
|
1478
|
+
* `f3` is the canonical spelling. Every other operator must be emitted —
|
|
1479
|
+
* dropping it silently rewrites `f<3` into `f3`, which counts a different
|
|
1480
|
+
* set of dice as failures.
|
|
1481
|
+
*/
|
|
1482
|
+
function formatFailCode(operator: CompareOp, value: number): string {
|
|
1483
|
+
return operator === '=' ? `f${value}` : `f${operator}${value}`;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1282
1486
|
function evalSuccessCount(
|
|
1283
1487
|
node: SuccessCountNode,
|
|
1284
1488
|
rng: RNG,
|
|
1285
1489
|
ctx: EvalContext,
|
|
1286
1490
|
env: EvalEnv,
|
|
1287
1491
|
): EvalResult {
|
|
1288
|
-
//
|
|
1289
|
-
//
|
|
1492
|
+
// Flag tracks syntactic presence of success-count notation, not pool size —
|
|
1493
|
+
// set before any early return so empty pools still populate successes/failures.
|
|
1290
1494
|
env.hasSuccessCount = true;
|
|
1291
1495
|
|
|
1292
|
-
const targetCtx
|
|
1496
|
+
const targetCtx = createContext();
|
|
1293
1497
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1294
1498
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
1295
1499
|
|
|
@@ -1299,9 +1503,12 @@ function evalSuccessCount(
|
|
|
1299
1503
|
? resolveThreshold(node.failThreshold.value, rng, ctx, env, 'fail threshold')
|
|
1300
1504
|
: undefined;
|
|
1301
1505
|
|
|
1302
|
-
const
|
|
1303
|
-
failValue != null
|
|
1304
|
-
|
|
1506
|
+
const failCode =
|
|
1507
|
+
failValue != null && node.failThreshold != null
|
|
1508
|
+
? formatFailCode(node.failThreshold.operator, failValue)
|
|
1509
|
+
: '';
|
|
1510
|
+
|
|
1511
|
+
const code = `${node.threshold.operator}${thresholdValue}${failCode}`;
|
|
1305
1512
|
|
|
1306
1513
|
const buildPart = (total: number, successes: number, failures: number): RollPart => {
|
|
1307
1514
|
const part: RollPart = {
|
|
@@ -1319,10 +1526,9 @@ function evalSuccessCount(
|
|
|
1319
1526
|
return part;
|
|
1320
1527
|
};
|
|
1321
1528
|
|
|
1322
|
-
// No-op when the target produced no dice
|
|
1323
|
-
//
|
|
1324
|
-
//
|
|
1325
|
-
// the `total === successes - failures` invariant holds.
|
|
1529
|
+
// No-op when the target produced no dice (`0d6>=4`); `containsDicePool`
|
|
1530
|
+
// should already reject dice-less targets at parse time. `target.total` is 0
|
|
1531
|
+
// for an empty pool, so `total === successes - failures` still holds.
|
|
1326
1532
|
if (targetCtx.rolls.length === 0) {
|
|
1327
1533
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1328
1534
|
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
@@ -1337,7 +1543,7 @@ function evalSuccessCount(
|
|
|
1337
1543
|
: undefined,
|
|
1338
1544
|
);
|
|
1339
1545
|
|
|
1340
|
-
ctx.rolls
|
|
1546
|
+
appendAll(ctx.rolls, targetCtx.rolls);
|
|
1341
1547
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1342
1548
|
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
|
|
1343
1549
|
|
|
@@ -1347,6 +1553,10 @@ function evalSuccessCount(
|
|
|
1347
1553
|
};
|
|
1348
1554
|
}
|
|
1349
1555
|
|
|
1556
|
+
//
|
|
1557
|
+
// * Versus
|
|
1558
|
+
//
|
|
1559
|
+
|
|
1350
1560
|
/**
|
|
1351
1561
|
* Extracts the "natural" d20 value from a roll-side dice pool. Returns the
|
|
1352
1562
|
* single value when exactly one primary kept d20 is present; otherwise
|
|
@@ -1368,7 +1578,7 @@ function extractNatural(rolls: DieResult[]): number | undefined {
|
|
|
1368
1578
|
(d) =>
|
|
1369
1579
|
d.sides === 20 &&
|
|
1370
1580
|
!d.modifiers.includes('dropped') &&
|
|
1371
|
-
!(d.modifiers.includes('exploded') && d.initialResult
|
|
1581
|
+
!(d.modifiers.includes('exploded') && d.initialResult == null),
|
|
1372
1582
|
);
|
|
1373
1583
|
if (primaries.length !== 1) return undefined;
|
|
1374
1584
|
const die = primaries[0];
|
|
@@ -1412,18 +1622,22 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1412
1622
|
|
|
1413
1623
|
env.insideVersus = true;
|
|
1414
1624
|
try {
|
|
1415
|
-
const rollCtx
|
|
1625
|
+
const rollCtx = createContext();
|
|
1416
1626
|
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
1627
|
const natural = extractNatural(rollCtx.rolls);
|
|
1420
1628
|
|
|
1421
|
-
const dcCtx
|
|
1629
|
+
const dcCtx = createContext();
|
|
1422
1630
|
const dcResult = evalNode(node.dc, rng, dcCtx, env);
|
|
1423
1631
|
|
|
1424
1632
|
const degree = calculateDegree(rollResult.total, dcResult.total, natural);
|
|
1425
1633
|
|
|
1426
|
-
ctx.rolls
|
|
1634
|
+
appendAll(ctx.rolls, rollCtx.rolls);
|
|
1635
|
+
// ! Tag before merging: past this point the DC dice are indistinguishable
|
|
1636
|
+
// ! from the roll side, and every pool modifier walks the merged array.
|
|
1637
|
+
for (const die of dcCtx.rolls) {
|
|
1638
|
+
die.modifiers.push('dc');
|
|
1639
|
+
}
|
|
1640
|
+
appendAll(ctx.rolls, dcCtx.rolls);
|
|
1427
1641
|
|
|
1428
1642
|
const rollExpr = rollCtx.expressionParts.join('');
|
|
1429
1643
|
const dcExpr = dcCtx.expressionParts.join('');
|
|
@@ -1450,41 +1664,61 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1450
1664
|
}
|
|
1451
1665
|
}
|
|
1452
1666
|
|
|
1667
|
+
//
|
|
1668
|
+
// * Entry point
|
|
1669
|
+
//
|
|
1670
|
+
|
|
1453
1671
|
/**
|
|
1454
|
-
* Evaluates a parsed AST and returns the roll result.
|
|
1672
|
+
* Evaluates a parsed AST against an {@link RNG} and returns the roll result.
|
|
1673
|
+
*
|
|
1674
|
+
* The second half of the pipeline — {@link roll} is `evaluate(parse(...))`.
|
|
1675
|
+
* Call it directly to reuse one AST across many rolls, or to drive a
|
|
1676
|
+
* hand-built AST.
|
|
1677
|
+
*
|
|
1678
|
+
* Unlike `roll`, the RNG is required: `evaluate` never invents a randomness
|
|
1679
|
+
* source, so a caller can never accidentally get an unseeded roll.
|
|
1455
1680
|
*
|
|
1456
|
-
* @param ast - The
|
|
1457
|
-
* @param rng -
|
|
1458
|
-
* @param options -
|
|
1459
|
-
*
|
|
1681
|
+
* @param ast - The AST to evaluate, from {@link parse} or hand-built
|
|
1682
|
+
* @param rng - Randomness source; one `nextInt` call per die
|
|
1683
|
+
* @param options - Evaluation limits plus the original `notation` string,
|
|
1684
|
+
* which the AST cannot supply
|
|
1685
|
+
* @returns Complete {@link RollResult}
|
|
1686
|
+
* @throws {EvaluatorError} On a limit breach, division by zero, an undefined
|
|
1687
|
+
* variable, or a non-finite total
|
|
1688
|
+
* @throws {RollParserError} `INVALID_EVALUATION_LIMIT` when a supplied limit is
|
|
1689
|
+
* not an integer in range — raised before any die is rolled
|
|
1460
1690
|
*
|
|
1461
1691
|
* @example
|
|
1462
1692
|
* ```typescript
|
|
1693
|
+
* import { evaluate, parse } from 'roll-parser';
|
|
1694
|
+
* import { createMockRng } from 'roll-parser/testing';
|
|
1695
|
+
*
|
|
1463
1696
|
* const ast = parse('2d6+3');
|
|
1464
|
-
* const
|
|
1465
|
-
*
|
|
1466
|
-
*
|
|
1697
|
+
* const result = evaluate(ast, createMockRng([4, 2]), { notation: '2d6+3' });
|
|
1698
|
+
* result.total; // 9
|
|
1699
|
+
* result.rendered; // '2d6[4, 2] + 3 = 9'
|
|
1467
1700
|
* ```
|
|
1701
|
+
*
|
|
1702
|
+
* Omitting `notation` falls back to the normalized `expression`, which is
|
|
1703
|
+
* reconstructed from the AST — so `RollResult.notation` is always a string,
|
|
1704
|
+
* just not necessarily the one the user typed.
|
|
1705
|
+
*
|
|
1706
|
+
* @category Core
|
|
1468
1707
|
*/
|
|
1469
1708
|
export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}): RollResult {
|
|
1470
|
-
const maxDice =
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
options.
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
options.maxRerollIterations != null &&
|
|
1484
|
-
Number.isFinite(options.maxRerollIterations) &&
|
|
1485
|
-
options.maxRerollIterations >= 0
|
|
1486
|
-
? Math.floor(options.maxRerollIterations)
|
|
1487
|
-
: DEFAULT_MAX_REROLL_ITERATIONS;
|
|
1709
|
+
const maxDice = resolveLimit(options.maxDice, 'maxDice', DEFAULT_MAX_DICE, 1);
|
|
1710
|
+
const maxExplodeIterations = resolveLimit(
|
|
1711
|
+
options.maxExplodeIterations,
|
|
1712
|
+
'maxExplodeIterations',
|
|
1713
|
+
DEFAULT_MAX_EXPLODE_ITERATIONS,
|
|
1714
|
+
0,
|
|
1715
|
+
);
|
|
1716
|
+
const maxRerollIterations = resolveLimit(
|
|
1717
|
+
options.maxRerollIterations,
|
|
1718
|
+
'maxRerollIterations',
|
|
1719
|
+
DEFAULT_MAX_REROLL_ITERATIONS,
|
|
1720
|
+
0,
|
|
1721
|
+
);
|
|
1488
1722
|
|
|
1489
1723
|
const context = options.context ?? {};
|
|
1490
1724
|
const onMissingVariable = options.onMissingVariable ?? 'throw';
|
|
@@ -1499,11 +1733,7 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
1499
1733
|
context,
|
|
1500
1734
|
onMissingVariable,
|
|
1501
1735
|
};
|
|
1502
|
-
const ctx
|
|
1503
|
-
rolls: [],
|
|
1504
|
-
expressionParts: [],
|
|
1505
|
-
renderedParts: [],
|
|
1506
|
-
};
|
|
1736
|
+
const ctx = createContext();
|
|
1507
1737
|
|
|
1508
1738
|
const { total, part } = evalNode(ast, rng, ctx, env);
|
|
1509
1739
|
|
|
@@ -1516,37 +1746,39 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
1516
1746
|
}
|
|
1517
1747
|
|
|
1518
1748
|
const expression = ctx.expressionParts.join('');
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
1749
|
+
// Versus replaces the numeric total with the degree label in the rendered
|
|
1750
|
+
// form; `RollResult.total` remains the numeric roll total.
|
|
1521
1751
|
const trailing = ctx.versusMetadata ? degreeLabel(ctx.versusMetadata.degree) : String(total);
|
|
1522
1752
|
const rendered = `${ctx.renderedParts.join('')} = ${trailing}`;
|
|
1523
1753
|
|
|
1524
|
-
|
|
1754
|
+
// `RollResult` is `Readonly` — optional fields fold in via conditional spreads.
|
|
1755
|
+
const versus = ctx.versusMetadata;
|
|
1756
|
+
|
|
1757
|
+
return {
|
|
1525
1758
|
total,
|
|
1526
1759
|
notation: options.notation ?? expression,
|
|
1527
1760
|
expression,
|
|
1528
1761
|
rendered,
|
|
1529
1762
|
rolls: ctx.rolls,
|
|
1530
1763
|
parts: part,
|
|
1764
|
+
...(env.hasSuccessCount ? countTaggedDice(ctx.rolls) : {}),
|
|
1765
|
+
...(versus ? { degree: versus.degree } : {}),
|
|
1766
|
+
...(versus?.natural != null ? { natural: versus.natural } : {}),
|
|
1531
1767
|
};
|
|
1768
|
+
}
|
|
1532
1769
|
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
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
|
-
}
|
|
1770
|
+
/** Tallies the `'success'` / `'failure'` tags across a whole roll. */
|
|
1771
|
+
function countTaggedDice(rolls: DieResult[]): { successes: number; failures: number } {
|
|
1772
|
+
let successes = 0;
|
|
1773
|
+
let failures = 0;
|
|
1774
|
+
|
|
1775
|
+
for (const die of rolls) {
|
|
1776
|
+
// A success-count inside the DC sub-expression tags its own dice before
|
|
1777
|
+
// `evalVersus` marks them `'dc'`, so they arrive here already tagged.
|
|
1778
|
+
if (isVersusDc(die)) continue;
|
|
1779
|
+
if (die.modifiers.includes('success')) successes += 1;
|
|
1780
|
+
else if (die.modifiers.includes('failure')) failures += 1;
|
|
1549
1781
|
}
|
|
1550
1782
|
|
|
1551
|
-
return
|
|
1783
|
+
return { successes, failures };
|
|
1552
1784
|
}
|