roll-parser 3.0.0-alpha.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 +189 -0
- package/MIGRATION.md +147 -0
- package/README.md +985 -43
- 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 +16 -4
- 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 -14
- 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 +62 -40
- 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.d.ts +1 -1
- package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
- 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 +27 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
- 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 +18 -6
- 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 +14 -6
- 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 +27 -0
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
- 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 +35 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1723
- package/dist/index.js.map +1 -0
- package/dist/lexer/lexer.d.ts +68 -8
- 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 +52 -7
- 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 +419 -80
- 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 +162 -15
- 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 +74 -13
- 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 +149 -10
- 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 +59 -25
- 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 -38
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +427 -24
- 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 +93 -40
- package/src/cli/args.ts +66 -9
- package/src/cli/format.ts +30 -7
- package/src/cli/index.ts +27 -67
- package/src/cli/main.ts +129 -0
- package/src/errors.ts +488 -23
- package/src/evaluator/die.ts +50 -0
- package/src/evaluator/env.ts +73 -0
- package/src/evaluator/evaluator.ts +1296 -349
- package/src/evaluator/modifiers/compare.ts +1 -1
- package/src/evaluator/modifiers/crit-threshold.ts +56 -0
- package/src/evaluator/modifiers/die-bound.ts +39 -0
- package/src/evaluator/modifiers/explode.ts +82 -76
- package/src/evaluator/modifiers/flags.ts +61 -0
- package/src/evaluator/modifiers/keep-drop.ts +124 -126
- package/src/evaluator/modifiers/reroll.ts +36 -64
- package/src/evaluator/modifiers/sort.ts +43 -0
- package/src/evaluator/modifiers/success-count.ts +6 -9
- package/src/index.ts +73 -34
- package/src/lexer/lexer.ts +201 -35
- package/src/lexer/tokens.ts +72 -7
- package/src/parser/ast.ts +453 -104
- package/src/parser/guards.ts +248 -0
- package/src/parser/parser.ts +835 -135
- package/src/rng/mock.ts +75 -14
- package/src/rng/seeded.ts +323 -58
- package/src/rng/types.ts +57 -0
- package/src/roll.ts +66 -41
- package/src/testing.ts +5 -9
- package/src/types.ts +416 -24
- package/src/version.ts +2 -0
- package/dist/cli.js +0 -1775
- package/dist/evaluator/index.d.ts +0 -8
- package/dist/evaluator/index.d.ts.map +0 -1
- package/dist/index.mjs +0 -1724
- package/dist/rng/index.d.ts +0 -8
- package/dist/rng/index.d.ts.map +0 -1
- package/dist/testing.mjs +0 -39
- package/src/evaluator/index.ts +0 -14
- package/src/rng/index.ts +0 -8
|
@@ -4,95 +4,137 @@
|
|
|
4
4
|
* @module evaluator/evaluator
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import
|
|
8
|
-
import { RollParserError } from '../errors';
|
|
7
|
+
import { describeValue, EvaluatorError, RollParserError, stampEvaluatorSpan } from '../errors.js';
|
|
9
8
|
import type {
|
|
10
9
|
ASTNode,
|
|
11
10
|
BinaryOpNode,
|
|
11
|
+
CritThreshold,
|
|
12
|
+
CritThresholdNode,
|
|
12
13
|
DiceNode,
|
|
14
|
+
DieBoundNode,
|
|
13
15
|
ExplodeNode,
|
|
14
16
|
FateDiceNode,
|
|
15
17
|
FunctionCallNode,
|
|
16
|
-
|
|
18
|
+
GroupedNode,
|
|
19
|
+
GroupNode,
|
|
20
|
+
KeepDropNode,
|
|
21
|
+
LiteralNode,
|
|
17
22
|
RerollNode,
|
|
23
|
+
SortNode,
|
|
18
24
|
SuccessCountNode,
|
|
19
25
|
UnaryOpNode,
|
|
26
|
+
VariableNode,
|
|
20
27
|
VersusNode,
|
|
21
|
-
} from '../parser/ast';
|
|
22
|
-
import {
|
|
23
|
-
import
|
|
24
|
-
import type {
|
|
25
|
-
import {
|
|
28
|
+
} from '../parser/ast.js';
|
|
29
|
+
import { isKeepDrop } from '../parser/ast.js';
|
|
30
|
+
import { containsVersus } from '../parser/guards.js';
|
|
31
|
+
import type { RNG } from '../rng/types.js';
|
|
32
|
+
import type {
|
|
33
|
+
CompareOp,
|
|
34
|
+
ComparePoint,
|
|
35
|
+
DieModifier,
|
|
36
|
+
DieResult,
|
|
37
|
+
EvaluateOptions,
|
|
38
|
+
KeepDropSpec,
|
|
39
|
+
ResolvedComparePoint,
|
|
40
|
+
ResolvedCritThreshold,
|
|
41
|
+
RollPart,
|
|
42
|
+
RollResult,
|
|
43
|
+
} from '../types.js';
|
|
44
|
+
import { DegreeOfSuccess } from '../types.js';
|
|
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';
|
|
26
49
|
import {
|
|
27
50
|
applyCompoundExplode,
|
|
28
51
|
applyPenetratingExplode,
|
|
29
52
|
applyStandardExplode,
|
|
30
53
|
buildShouldExplode,
|
|
31
54
|
DEFAULT_MAX_EXPLODE_ITERATIONS,
|
|
32
|
-
} from './modifiers/explode';
|
|
55
|
+
} from './modifiers/explode.js';
|
|
33
56
|
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
} from './modifiers/keep-drop';
|
|
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';
|
|
41
63
|
import {
|
|
42
64
|
applyRecursiveReroll,
|
|
43
65
|
applyRerollOnce,
|
|
44
66
|
DEFAULT_MAX_REROLL_ITERATIONS,
|
|
45
|
-
} from './modifiers/reroll';
|
|
46
|
-
import {
|
|
67
|
+
} from './modifiers/reroll.js';
|
|
68
|
+
import { sortDice } from './modifiers/sort.js';
|
|
69
|
+
import { countSuccesses } from './modifiers/success-count.js';
|
|
47
70
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
export class EvaluatorError extends RollParserError {
|
|
52
|
-
readonly nodeType: string | undefined;
|
|
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 };
|
|
53
74
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
this.nodeType = nodeType ?? undefined;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
75
|
+
//
|
|
76
|
+
// * Limits
|
|
77
|
+
//
|
|
60
78
|
|
|
61
|
-
/**
|
|
79
|
+
/**
|
|
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
|
|
88
|
+
*/
|
|
62
89
|
export const DEFAULT_MAX_DICE = 10_000;
|
|
63
90
|
|
|
64
|
-
|
|
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;
|
|
65
98
|
|
|
66
99
|
/**
|
|
67
|
-
*
|
|
100
|
+
* Resolves a user-supplied evaluation limit, failing closed.
|
|
68
101
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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.
|
|
71
107
|
*/
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
//
|
|
91
129
|
|
|
92
130
|
/**
|
|
93
131
|
* Per-branch mutable accumulator for tracking rolls and output during recursion.
|
|
132
|
+
*
|
|
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.
|
|
94
136
|
*/
|
|
95
|
-
type EvalContext = {
|
|
137
|
+
export type EvalContext = {
|
|
96
138
|
rolls: DieResult[];
|
|
97
139
|
expressionParts: string[];
|
|
98
140
|
renderedParts: string[];
|
|
@@ -109,71 +151,166 @@ type EvalContext = {
|
|
|
109
151
|
};
|
|
110
152
|
};
|
|
111
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
|
+
|
|
112
163
|
/**
|
|
113
164
|
* Flattened representation of a keep/drop modifier for chain evaluation.
|
|
165
|
+
* Superset of the public `KeepDropSpec` (adds the notation `code`).
|
|
166
|
+
*/
|
|
167
|
+
type KeepDropChainEntry = KeepDropSpec & { code: string };
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Every branch returns its numeric total AND the `RollPart` it contributes
|
|
171
|
+
* to the structured breakdown — TypeScript exhaustiveness guarantees no
|
|
172
|
+
* branch can forget to produce a part.
|
|
114
173
|
*/
|
|
115
|
-
type
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
count: number;
|
|
119
|
-
code: string;
|
|
174
|
+
type EvalResult = {
|
|
175
|
+
total: number;
|
|
176
|
+
part: RollPart;
|
|
120
177
|
};
|
|
121
178
|
|
|
179
|
+
//
|
|
180
|
+
// * Shared helpers
|
|
181
|
+
//
|
|
182
|
+
|
|
122
183
|
/**
|
|
123
|
-
*
|
|
184
|
+
* Copies an AST node's source span onto a part (spread into the literal).
|
|
185
|
+
* Empty when the AST was built without parser spans.
|
|
124
186
|
*/
|
|
125
|
-
function
|
|
126
|
-
return {
|
|
127
|
-
|
|
128
|
-
result,
|
|
129
|
-
modifiers: [],
|
|
130
|
-
critical: result === sides && sides > 1,
|
|
131
|
-
fumble: result === 1,
|
|
132
|
-
};
|
|
187
|
+
function partSpan(node: ASTNode): { start?: number; end?: number } {
|
|
188
|
+
if (node.start == null || node.end == null) return {};
|
|
189
|
+
return { start: node.start, end: node.end };
|
|
133
190
|
}
|
|
134
191
|
|
|
135
192
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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.
|
|
138
199
|
*/
|
|
139
|
-
function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
200
|
+
function appendAll<T>(target: T[], source: readonly T[]): void {
|
|
201
|
+
for (const item of source) {
|
|
202
|
+
target.push(item);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
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);
|
|
147
209
|
}
|
|
148
210
|
|
|
149
211
|
/**
|
|
150
212
|
* Renders dice results for display. Marker priority: dropped wins over
|
|
151
213
|
* success/failure (dropped dice are never counted), success wins over
|
|
152
214
|
* failure (a die cannot be both). Example: `[~~1~~, **6**, __1__, 3]`.
|
|
215
|
+
*
|
|
216
|
+
* Dice tagged `'meta'` (rolled to compute sub-expression parameters such as
|
|
217
|
+
* count/sides/threshold) are hidden from the rendered output — they exist in
|
|
218
|
+
* `RollResult.rolls` for audit, not for display.
|
|
153
219
|
*/
|
|
154
220
|
function renderDice(dice: DieResult[]): string {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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;
|
|
161
236
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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);
|
|
168
255
|
}
|
|
169
256
|
|
|
170
257
|
/**
|
|
171
|
-
*
|
|
258
|
+
* Forwards rolls from a throwaway sub-expression context into the parent
|
|
259
|
+
* audit trail, tagging them as `'meta'` + `'dropped'`. Meta dice are dice
|
|
260
|
+
* rolled to compute parameters (dice count, sides, threshold, modifier
|
|
261
|
+
* count) — they consume RNG and count against `maxDice`, so they must be
|
|
262
|
+
* inspectable. Tagging them `'dropped'` keeps totals correct via
|
|
263
|
+
* `sumKeptDice`; `'meta'` lets renderers hide them and lets callers
|
|
264
|
+
* distinguish them from ordinary pool dice.
|
|
265
|
+
*
|
|
266
|
+
* `'success'`/`'failure'` tags are stripped here as defense-in-depth against
|
|
267
|
+
* a SuccessCount leaking into a meta sub-expression (parser rejects all such
|
|
268
|
+
* wrappings; this strip ensures a future parse regression cannot leak tags
|
|
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.
|
|
172
274
|
*/
|
|
173
|
-
|
|
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.
|
|
278
|
+
export function mergeMetaRolls(parent: EvalContext, source: EvalContext): void {
|
|
279
|
+
for (const die of source.rolls) {
|
|
280
|
+
parent.rolls.push({
|
|
281
|
+
...die,
|
|
282
|
+
modifiers: rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'meta', 'dropped'),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
//
|
|
288
|
+
// * Node dispatch
|
|
289
|
+
//
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Evaluates an AST node, returning its total and `RollPart` while updating
|
|
293
|
+
* the context.
|
|
294
|
+
*
|
|
295
|
+
* Errors bubbling up get the source span of the tightest node that was being
|
|
296
|
+
* evaluated — the innermost `evalNode` frame stamps first, outer frames leave
|
|
297
|
+
* an already-stamped error untouched.
|
|
298
|
+
*/
|
|
299
|
+
function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
300
|
+
try {
|
|
301
|
+
return evalNodeInner(node, rng, ctx, env);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
if (error instanceof EvaluatorError && node.start != null) {
|
|
304
|
+
stampEvaluatorSpan(error, node.start, node.end);
|
|
305
|
+
}
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
174
311
|
switch (node.type) {
|
|
175
312
|
case 'Literal':
|
|
176
|
-
return evalLiteral(node
|
|
313
|
+
return evalLiteral(node, ctx);
|
|
177
314
|
|
|
178
315
|
case 'Dice':
|
|
179
316
|
return evalDice(node, rng, ctx, env);
|
|
@@ -187,8 +324,8 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): numb
|
|
|
187
324
|
case 'UnaryOp':
|
|
188
325
|
return evalUnaryOp(node, rng, ctx, env);
|
|
189
326
|
|
|
190
|
-
case '
|
|
191
|
-
return
|
|
327
|
+
case 'KeepDrop':
|
|
328
|
+
return evalKeepDrop(node, rng, ctx, env);
|
|
192
329
|
|
|
193
330
|
case 'Explode':
|
|
194
331
|
return evalExplode(node, rng, ctx, env);
|
|
@@ -196,6 +333,9 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): numb
|
|
|
196
333
|
case 'Reroll':
|
|
197
334
|
return evalReroll(node, rng, ctx, env);
|
|
198
335
|
|
|
336
|
+
case 'DieBound':
|
|
337
|
+
return evalDieBound(node, rng, ctx, env);
|
|
338
|
+
|
|
199
339
|
case 'SuccessCount':
|
|
200
340
|
return evalSuccessCount(node, rng, ctx, env);
|
|
201
341
|
|
|
@@ -205,6 +345,21 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): numb
|
|
|
205
345
|
case 'FunctionCall':
|
|
206
346
|
return evalFunctionCall(node, rng, ctx, env);
|
|
207
347
|
|
|
348
|
+
case 'Grouped':
|
|
349
|
+
return evalGrouped(node, rng, ctx, env);
|
|
350
|
+
|
|
351
|
+
case 'Group':
|
|
352
|
+
return evalGroup(node, rng, ctx, env);
|
|
353
|
+
|
|
354
|
+
case 'Sort':
|
|
355
|
+
return evalSort(node, rng, ctx, env);
|
|
356
|
+
|
|
357
|
+
case 'CritThreshold':
|
|
358
|
+
return evalCritThreshold(node, rng, ctx, env);
|
|
359
|
+
|
|
360
|
+
case 'Variable':
|
|
361
|
+
return evalVariable(node, ctx, env);
|
|
362
|
+
|
|
208
363
|
default: {
|
|
209
364
|
const exhaustive: never = node;
|
|
210
365
|
throw new EvaluatorError(
|
|
@@ -216,107 +371,281 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): numb
|
|
|
216
371
|
}
|
|
217
372
|
}
|
|
218
373
|
|
|
219
|
-
|
|
374
|
+
//
|
|
375
|
+
// * Leaf nodes
|
|
376
|
+
//
|
|
377
|
+
|
|
378
|
+
function evalLiteral(node: LiteralNode, ctx: EvalContext): EvalResult {
|
|
379
|
+
const { value } = node;
|
|
220
380
|
ctx.expressionParts.push(String(value));
|
|
221
381
|
ctx.renderedParts.push(String(value));
|
|
222
|
-
return value;
|
|
382
|
+
return { total: value, part: { type: 'literal', value, total: value, ...partSpan(node) } };
|
|
223
383
|
}
|
|
224
384
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
env,
|
|
237
|
-
);
|
|
385
|
+
/**
|
|
386
|
+
* Re-derives whether a variable name needs braces in its rendered form.
|
|
387
|
+
*
|
|
388
|
+
* The lexer accepts `@name` (bare) or `@{name with spaces}` (braced) but
|
|
389
|
+
* strips the braces from the captured value. To round-trip through `rendered`
|
|
390
|
+
* we re-derive bracedness from the name shape — anything outside the bare
|
|
391
|
+
* identifier grammar implies the user wrote braces (or would need them).
|
|
392
|
+
*/
|
|
393
|
+
function variableNeedsBraces(name: string): boolean {
|
|
394
|
+
return !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
|
395
|
+
}
|
|
238
396
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
397
|
+
/**
|
|
398
|
+
* Looks up a variable in `env.context` and resolves missing keys per
|
|
399
|
+
* `env.onMissingVariable`. The resolved scalar is the variable's value;
|
|
400
|
+
* `expression` shows the resolved number (mirrors how literals render),
|
|
401
|
+
* while `rendered` keeps the original `@name` (or `@{name}`) annotated with
|
|
402
|
+
* the resolved value in brackets so readers can attribute the number.
|
|
403
|
+
*/
|
|
404
|
+
function evalVariable(node: VariableNode, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
405
|
+
const present = Object.hasOwn(env.context, node.name);
|
|
406
|
+
if (!present) {
|
|
407
|
+
if (env.onMissingVariable === 'throw') {
|
|
408
|
+
throw new EvaluatorError(
|
|
409
|
+
`Undefined variable: ${node.name}`,
|
|
410
|
+
'UNDEFINED_VARIABLE',
|
|
411
|
+
'Variable',
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
|
|
415
|
+
ctx.expressionParts.push('0');
|
|
416
|
+
ctx.renderedParts.push(`${display}[0]`);
|
|
417
|
+
return {
|
|
418
|
+
total: 0,
|
|
419
|
+
part: { type: 'variable', name: node.name, value: 0, total: 0, ...partSpan(node) },
|
|
420
|
+
};
|
|
244
421
|
}
|
|
245
422
|
|
|
246
|
-
|
|
423
|
+
const value = env.context[node.name] as number;
|
|
424
|
+
if (!Number.isFinite(value)) {
|
|
247
425
|
throw new EvaluatorError(
|
|
248
|
-
`
|
|
249
|
-
'
|
|
250
|
-
'
|
|
426
|
+
`Invalid variable value: ${node.name} = ${value}`,
|
|
427
|
+
'INVALID_VARIABLE_VALUE',
|
|
428
|
+
'Variable',
|
|
251
429
|
);
|
|
252
430
|
}
|
|
253
|
-
|
|
431
|
+
const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
|
|
432
|
+
ctx.expressionParts.push(String(value));
|
|
433
|
+
ctx.renderedParts.push(`${display}[${value}]`);
|
|
434
|
+
return {
|
|
435
|
+
total: value,
|
|
436
|
+
part: { type: 'variable', name: node.name, value, total: value, ...partSpan(node) },
|
|
437
|
+
};
|
|
438
|
+
}
|
|
254
439
|
|
|
440
|
+
//
|
|
441
|
+
// * Dice pools
|
|
442
|
+
//
|
|
443
|
+
|
|
444
|
+
/**
|
|
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.
|
|
463
|
+
*/
|
|
464
|
+
function evalMetaOperand(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): number {
|
|
465
|
+
if (node.type === 'Literal') return node.value;
|
|
466
|
+
|
|
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;
|
|
475
|
+
}
|
|
476
|
+
|
|
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);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
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[] } {
|
|
255
508
|
const dice: DieResult[] = [];
|
|
509
|
+
let total = 0;
|
|
510
|
+
|
|
256
511
|
for (let i = 0; i < count; i++) {
|
|
257
|
-
const
|
|
258
|
-
dice.push(
|
|
512
|
+
const die = rollDie();
|
|
513
|
+
dice.push(die);
|
|
514
|
+
total += die.result;
|
|
259
515
|
}
|
|
260
516
|
|
|
261
|
-
|
|
262
|
-
ctx.rolls.push(...markedDice);
|
|
263
|
-
|
|
264
|
-
const total = sumKeptDice(markedDice);
|
|
265
|
-
const notation = `${count}d${sides}`;
|
|
517
|
+
appendAll(ctx.rolls, dice);
|
|
266
518
|
|
|
267
519
|
ctx.expressionParts.push(notation);
|
|
268
|
-
ctx.renderedParts.push(`${notation}${renderDice(
|
|
520
|
+
ctx.renderedParts.push(`${notation}${renderDice(dice)}`);
|
|
269
521
|
|
|
270
|
-
return total;
|
|
522
|
+
return { total, rolls: dice };
|
|
271
523
|
}
|
|
272
524
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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);
|
|
280
536
|
|
|
281
|
-
|
|
282
|
-
|
|
537
|
+
requireDiceCount(count, 'Dice');
|
|
538
|
+
if (!Number.isInteger(sides) || sides < 1) {
|
|
539
|
+
throw new EvaluatorError(`Invalid dice sides: ${sides}`, 'INVALID_DICE_SIDES', 'Dice');
|
|
283
540
|
}
|
|
284
|
-
|
|
285
|
-
if (env.totalDiceRolled + count > env.maxDice) {
|
|
541
|
+
if (sides > MAX_DICE_SIDES) {
|
|
286
542
|
throw new EvaluatorError(
|
|
287
|
-
`
|
|
288
|
-
'
|
|
289
|
-
'
|
|
543
|
+
`Dice sides ${sides} exceeds maximum of ${MAX_DICE_SIDES}`,
|
|
544
|
+
'INVALID_DICE_SIDES',
|
|
545
|
+
'Dice',
|
|
290
546
|
);
|
|
291
547
|
}
|
|
292
|
-
env.totalDiceRolled += count;
|
|
293
548
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
+
);
|
|
299
559
|
|
|
300
|
-
|
|
301
|
-
|
|
560
|
+
return { total, part: { type: 'dice', count, sides, rolls, total, ...partSpan(node) } };
|
|
561
|
+
}
|
|
302
562
|
|
|
303
|
-
|
|
304
|
-
const
|
|
563
|
+
function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
564
|
+
const count = evalMetaOperand(node.count, rng, ctx, env);
|
|
305
565
|
|
|
306
|
-
|
|
307
|
-
|
|
566
|
+
requireDiceCount(count, 'FateDice');
|
|
567
|
+
chargeDice(env, count, 'FateDice');
|
|
568
|
+
|
|
569
|
+
const { total, rolls } = rollPool(
|
|
570
|
+
count,
|
|
571
|
+
`${count}dF`,
|
|
572
|
+
() => createFateDieResult(rng.nextInt(-1, 1), ['kept']),
|
|
573
|
+
ctx,
|
|
574
|
+
);
|
|
308
575
|
|
|
309
|
-
return total;
|
|
576
|
+
return { total, part: { type: 'fateDice', count, rolls, total, ...partSpan(node) } };
|
|
310
577
|
}
|
|
311
578
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
579
|
+
//
|
|
580
|
+
// * Context merging
|
|
581
|
+
//
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Propagates `versusMetadata` onto a parent so `degree`/`natural` survive
|
|
585
|
+
* wrappers like `floor(...)`, `(vs) + 0`, or `-(vs)`. Throws `NESTED_VERSUS`
|
|
586
|
+
* if the parent already carries metadata — two versus results cannot occupy
|
|
587
|
+
* the same `RollResult`. No-op when `metadata` is `undefined`.
|
|
588
|
+
*
|
|
589
|
+
* Use this directly when the caller has already pushed (or transformed) `rolls`
|
|
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.
|
|
611
|
+
*/
|
|
612
|
+
function propagateMetadata(parent: EvalContext, metadata: EvalContext['versusMetadata']): void {
|
|
613
|
+
if (!metadata) return;
|
|
614
|
+
if (parent.versusMetadata) {
|
|
615
|
+
throw new EvaluatorError(
|
|
616
|
+
'Multiple versus operators in the same expression',
|
|
617
|
+
'NESTED_VERSUS',
|
|
618
|
+
'Versus',
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
parent.versusMetadata = metadata;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Merges a child sub-context back into its parent. Copies `rolls` and
|
|
626
|
+
* delegates `versusMetadata` propagation to `propagateMetadata`.
|
|
627
|
+
*
|
|
628
|
+
* Does not merge `expressionParts` / `renderedParts` — each wrapper formats
|
|
629
|
+
* those with its own operator/function syntax.
|
|
630
|
+
*/
|
|
631
|
+
function mergeContext(parent: EvalContext, child: EvalContext): void {
|
|
632
|
+
appendAll(parent.rolls, child.rolls);
|
|
633
|
+
propagateMetadata(parent, child.versusMetadata);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
//
|
|
637
|
+
// * Arithmetic
|
|
638
|
+
//
|
|
639
|
+
|
|
640
|
+
function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
641
|
+
const leftCtx = createContext();
|
|
642
|
+
const rightCtx = createContext();
|
|
315
643
|
|
|
316
644
|
const left = evalNode(node.left, rng, leftCtx, env);
|
|
317
645
|
const right = evalNode(node.right, rng, rightCtx, env);
|
|
318
646
|
|
|
319
|
-
ctx
|
|
647
|
+
mergeContext(ctx, leftCtx);
|
|
648
|
+
mergeContext(ctx, rightCtx);
|
|
320
649
|
|
|
321
650
|
const leftExpr = leftCtx.expressionParts.join('');
|
|
322
651
|
const rightExpr = rightCtx.expressionParts.join('');
|
|
@@ -326,7 +655,27 @@ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
326
655
|
ctx.expressionParts.push(`${leftExpr} ${node.operator} ${rightExpr}`);
|
|
327
656
|
ctx.renderedParts.push(`${leftRendered} ${node.operator} ${rightRendered}`);
|
|
328
657
|
|
|
329
|
-
|
|
658
|
+
const total = applyBinaryOperator(node.operator, left.total, right.total);
|
|
659
|
+
|
|
660
|
+
return {
|
|
661
|
+
total,
|
|
662
|
+
part: {
|
|
663
|
+
type: 'binaryOp',
|
|
664
|
+
operator: node.operator,
|
|
665
|
+
left: left.part,
|
|
666
|
+
right: right.part,
|
|
667
|
+
total,
|
|
668
|
+
...partSpan(node),
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function applyBinaryOperator(
|
|
674
|
+
operator: BinaryOpNode['operator'],
|
|
675
|
+
left: number,
|
|
676
|
+
right: number,
|
|
677
|
+
): number {
|
|
678
|
+
switch (operator) {
|
|
330
679
|
case '+':
|
|
331
680
|
return left + right;
|
|
332
681
|
case '-':
|
|
@@ -346,17 +695,17 @@ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
346
695
|
case '**':
|
|
347
696
|
return left ** right;
|
|
348
697
|
default: {
|
|
349
|
-
const exhaustive: never =
|
|
698
|
+
const exhaustive: never = operator;
|
|
350
699
|
throw new EvaluatorError(`Unknown operator: ${exhaustive}`, 'UNKNOWN_OPERATOR', 'BinaryOp');
|
|
351
700
|
}
|
|
352
701
|
}
|
|
353
702
|
}
|
|
354
703
|
|
|
355
|
-
function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
356
|
-
const innerCtx
|
|
357
|
-
const
|
|
704
|
+
function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
705
|
+
const innerCtx = createContext();
|
|
706
|
+
const inner = evalNode(node.operand, rng, innerCtx, env);
|
|
358
707
|
|
|
359
|
-
ctx
|
|
708
|
+
mergeContext(ctx, innerCtx);
|
|
360
709
|
|
|
361
710
|
const innerExpr = innerCtx.expressionParts.join('');
|
|
362
711
|
const innerRendered = innerCtx.renderedParts.join('');
|
|
@@ -364,7 +713,65 @@ function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
364
713
|
ctx.expressionParts.push(`-${innerExpr}`);
|
|
365
714
|
ctx.renderedParts.push(`-${innerRendered}`);
|
|
366
715
|
|
|
367
|
-
|
|
716
|
+
const total = -inner.total;
|
|
717
|
+
|
|
718
|
+
return {
|
|
719
|
+
total,
|
|
720
|
+
part: { type: 'unaryOp', operator: '-', operand: inner.part, total, ...partSpan(node) },
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
//
|
|
725
|
+
// * Groups and functions
|
|
726
|
+
//
|
|
727
|
+
|
|
728
|
+
function evalGrouped(node: GroupedNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
729
|
+
const innerCtx = createContext();
|
|
730
|
+
const inner = evalNode(node.expression, rng, innerCtx, env);
|
|
731
|
+
|
|
732
|
+
mergeContext(ctx, innerCtx);
|
|
733
|
+
|
|
734
|
+
ctx.expressionParts.push(`(${innerCtx.expressionParts.join('')})`);
|
|
735
|
+
ctx.renderedParts.push(`(${innerCtx.renderedParts.join('')})`);
|
|
736
|
+
|
|
737
|
+
return {
|
|
738
|
+
total: inner.total,
|
|
739
|
+
part: { type: 'grouped', inner: inner.part, total: inner.total, ...partSpan(node) },
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Evaluates a grouped roll `{expr1, expr2, ...}`.
|
|
745
|
+
*
|
|
746
|
+
* Each sub-expression is evaluated in an isolated context, then its rolls
|
|
747
|
+
* and `versusMetadata` propagate up via `mergeContext`. Sub-roll subtotals
|
|
748
|
+
* sum to the group's total. When the group is the base target of a
|
|
749
|
+
* keep/drop modifier with `expressions.length >= 2`, `evalKeepDrop`
|
|
750
|
+
* intercepts first and never calls this function — dual semantics
|
|
751
|
+
* (flat-pool vs sub-roll) are decided there.
|
|
752
|
+
*/
|
|
753
|
+
function evalGroup(node: GroupNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
754
|
+
const subExprs: string[] = [];
|
|
755
|
+
const subRendered: string[] = [];
|
|
756
|
+
const subParts: RollPart[] = [];
|
|
757
|
+
let total = 0;
|
|
758
|
+
|
|
759
|
+
for (const expr of node.expressions) {
|
|
760
|
+
const subCtx = createContext();
|
|
761
|
+
const sub = evalNode(expr, rng, subCtx, env);
|
|
762
|
+
mergeContext(ctx, subCtx);
|
|
763
|
+
subExprs.push(subCtx.expressionParts.join(''));
|
|
764
|
+
subRendered.push(subCtx.renderedParts.join(''));
|
|
765
|
+
subParts.push(sub.part);
|
|
766
|
+
total += sub.total;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
ctx.expressionParts.push(`{${subExprs.join(', ')}}`);
|
|
770
|
+
ctx.renderedParts.push(`{${subRendered.join(', ')}}`);
|
|
771
|
+
|
|
772
|
+
// No `keptIndices` — bare groups (and single-sub passthroughs) perform
|
|
773
|
+
// no sub-roll selection; only `evalGroupKeepDrop` sets it.
|
|
774
|
+
return { total, part: { type: 'group', parts: subParts, total, ...partSpan(node) } };
|
|
368
775
|
}
|
|
369
776
|
|
|
370
777
|
function evalFunctionCall(
|
|
@@ -372,18 +779,18 @@ function evalFunctionCall(
|
|
|
372
779
|
rng: RNG,
|
|
373
780
|
ctx: EvalContext,
|
|
374
781
|
env: EvalEnv,
|
|
375
|
-
):
|
|
782
|
+
): EvalResult {
|
|
376
783
|
const argCtxs: EvalContext[] = [];
|
|
377
|
-
const
|
|
784
|
+
const argResults: EvalResult[] = [];
|
|
378
785
|
|
|
379
786
|
for (const arg of node.args) {
|
|
380
|
-
const argCtx
|
|
381
|
-
|
|
787
|
+
const argCtx = createContext();
|
|
788
|
+
argResults.push(evalNode(arg, rng, argCtx, env));
|
|
382
789
|
argCtxs.push(argCtx);
|
|
383
790
|
}
|
|
384
791
|
|
|
385
792
|
for (const argCtx of argCtxs) {
|
|
386
|
-
ctx
|
|
793
|
+
mergeContext(ctx, argCtx);
|
|
387
794
|
}
|
|
388
795
|
|
|
389
796
|
const argExprs = argCtxs.map((c) => c.expressionParts.join(''));
|
|
@@ -392,7 +799,21 @@ function evalFunctionCall(
|
|
|
392
799
|
ctx.expressionParts.push(`${node.name}(${argExprs.join(', ')})`);
|
|
393
800
|
ctx.renderedParts.push(`${node.name}(${argRendereds.join(', ')})`);
|
|
394
801
|
|
|
395
|
-
|
|
802
|
+
const total = applyFunction(
|
|
803
|
+
node.name,
|
|
804
|
+
argResults.map((r) => r.total),
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
return {
|
|
808
|
+
total,
|
|
809
|
+
part: {
|
|
810
|
+
type: 'functionCall',
|
|
811
|
+
name: node.name,
|
|
812
|
+
args: argResults.map((r) => r.part),
|
|
813
|
+
total,
|
|
814
|
+
...partSpan(node),
|
|
815
|
+
},
|
|
816
|
+
};
|
|
396
817
|
}
|
|
397
818
|
|
|
398
819
|
function applyFunction(name: string, values: number[]): number {
|
|
@@ -402,23 +823,52 @@ function applyFunction(name: string, values: number[]): number {
|
|
|
402
823
|
case 'ceil':
|
|
403
824
|
return Math.ceil(requireUnaryArg(name, values));
|
|
404
825
|
case 'round':
|
|
826
|
+
// `Math.round` breaks halves toward +∞: `round(2.5) === 3` but
|
|
827
|
+
// `round(-2.5) === -2`. Symmetric rounding must be composed via `floor`.
|
|
405
828
|
return Math.round(requireUnaryArg(name, values));
|
|
406
829
|
case 'abs':
|
|
407
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
|
+
}
|
|
408
839
|
case 'max':
|
|
409
|
-
return
|
|
840
|
+
return extremumOf(values, 'max');
|
|
410
841
|
case 'min':
|
|
411
|
-
return
|
|
842
|
+
return extremumOf(values, 'min');
|
|
412
843
|
default:
|
|
413
844
|
throw new EvaluatorError(`Unknown function: ${name}`, 'UNKNOWN_FUNCTION', 'FunctionCall');
|
|
414
845
|
}
|
|
415
846
|
}
|
|
416
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
|
+
|
|
417
867
|
function requireUnaryArg(name: string, values: number[]): number {
|
|
418
868
|
const [x] = values;
|
|
419
|
-
if (x
|
|
420
|
-
//
|
|
421
|
-
// `noNonNullAssertion`.
|
|
869
|
+
if (x == null) {
|
|
870
|
+
// Unreachable: the parser validates arity before evaluation. Present to
|
|
871
|
+
// satisfy `noNonNullAssertion`.
|
|
422
872
|
throw new EvaluatorError(
|
|
423
873
|
`Function '${name}' requires an argument`,
|
|
424
874
|
'UNKNOWN_FUNCTION',
|
|
@@ -428,40 +878,64 @@ function requireUnaryArg(name: string, values: number[]): number {
|
|
|
428
878
|
return x;
|
|
429
879
|
}
|
|
430
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
|
+
|
|
431
909
|
/**
|
|
432
|
-
* Walks a nested
|
|
910
|
+
* Walks a nested KeepDropNode chain, collecting specs outermost-first,
|
|
433
911
|
* then reverses to notation order (innermost-first).
|
|
434
912
|
*/
|
|
435
|
-
function
|
|
436
|
-
node:
|
|
913
|
+
function flattenKeepDropChain(
|
|
914
|
+
node: KeepDropNode,
|
|
437
915
|
rng: RNG,
|
|
916
|
+
ctx: EvalContext,
|
|
438
917
|
env: EvalEnv,
|
|
439
|
-
): { specs:
|
|
440
|
-
const specs:
|
|
918
|
+
): { specs: KeepDropChainEntry[]; baseTarget: ASTNode } {
|
|
919
|
+
const specs: KeepDropChainEntry[] = [];
|
|
441
920
|
let current: ASTNode = node;
|
|
442
921
|
|
|
443
|
-
while (
|
|
444
|
-
const
|
|
445
|
-
const modCount = evalNode(current.count, rng, countCtx, env);
|
|
922
|
+
while (isKeepDrop(current)) {
|
|
923
|
+
const modCount = evalMetaOperand(current.count, rng, ctx, env);
|
|
446
924
|
|
|
447
925
|
if (!Number.isInteger(modCount) || modCount < 0) {
|
|
448
926
|
throw new EvaluatorError(
|
|
449
|
-
`Invalid
|
|
450
|
-
'
|
|
451
|
-
'
|
|
927
|
+
`Invalid keep/drop count: ${modCount}`,
|
|
928
|
+
'INVALID_KEEP_DROP_COUNT',
|
|
929
|
+
'KeepDrop',
|
|
452
930
|
);
|
|
453
931
|
}
|
|
454
932
|
|
|
455
|
-
|
|
456
|
-
current.
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
? 'dh'
|
|
462
|
-
: 'dl';
|
|
463
|
-
|
|
464
|
-
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
|
+
});
|
|
465
939
|
current = current.target;
|
|
466
940
|
}
|
|
467
941
|
|
|
@@ -469,44 +943,57 @@ function flattenModifierChain(
|
|
|
469
943
|
return { specs, baseTarget: current };
|
|
470
944
|
}
|
|
471
945
|
|
|
472
|
-
/**
|
|
473
|
-
* Applies a single modifier spec to a dice pool.
|
|
474
|
-
*/
|
|
475
|
-
function applyModifierSpec(dice: DieResult[], spec: ModifierSpec): DieResult[] {
|
|
476
|
-
if (spec.modifier === 'keep') {
|
|
477
|
-
return spec.selector === 'highest'
|
|
478
|
-
? applyKeepHighest(dice, spec.count)
|
|
479
|
-
: applyKeepLowest(dice, spec.count);
|
|
480
|
-
}
|
|
481
|
-
return spec.selector === 'highest'
|
|
482
|
-
? applyDropHighest(dice, spec.count)
|
|
483
|
-
: applyDropLowest(dice, spec.count);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
946
|
/**
|
|
487
947
|
* Applies each modifier independently to the full dice pool
|
|
488
948
|
* and merges drop sets via union. A die is dropped if ANY modifier dropped it.
|
|
949
|
+
*
|
|
950
|
+
* Mutates each die's flags in place — the same `DieResult` objects are
|
|
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.
|
|
489
954
|
*/
|
|
490
|
-
function mergeDropSets(baseDice: DieResult[], specs:
|
|
491
|
-
const
|
|
955
|
+
function mergeDropSets(baseDice: DieResult[], specs: KeepDropChainEntry[]): DieResult[] {
|
|
956
|
+
const droppedMask = new Uint8Array(baseDice.length);
|
|
492
957
|
|
|
493
958
|
for (const spec of specs) {
|
|
494
|
-
|
|
495
|
-
for (let i = 0; i < result.length; i++) {
|
|
496
|
-
if (result[i]?.modifiers.includes('dropped')) {
|
|
497
|
-
droppedIndices.add(i);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
959
|
+
markDroppedIndices(baseDice, spec.count, spec.kind, spec.selector, droppedMask);
|
|
500
960
|
}
|
|
501
961
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
+
}
|
|
975
|
+
|
|
976
|
+
return baseDice;
|
|
508
977
|
}
|
|
509
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
|
+
|
|
510
997
|
/**
|
|
511
998
|
* Builds the notation string for an explode modifier, e.g. `!`, `!!>=3`, `!p>5`.
|
|
512
999
|
*/
|
|
@@ -515,107 +1002,466 @@ function formatExplodeCode(
|
|
|
515
1002
|
threshold: ComparePoint | undefined,
|
|
516
1003
|
thresholdValue: number | undefined,
|
|
517
1004
|
): string {
|
|
518
|
-
const marker = variant
|
|
1005
|
+
const marker = EXPLODE_MARKERS[variant];
|
|
519
1006
|
if (threshold == null || thresholdValue == null) return marker;
|
|
520
1007
|
return `${marker}${threshold.operator}${thresholdValue}`;
|
|
521
1008
|
}
|
|
522
1009
|
|
|
523
|
-
function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
524
|
-
const targetCtx
|
|
525
|
-
const
|
|
1010
|
+
function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
1011
|
+
const targetCtx = createContext();
|
|
1012
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
526
1013
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
527
1014
|
|
|
528
1015
|
let thresholdValue: number | undefined;
|
|
529
1016
|
if (node.threshold != null) {
|
|
530
|
-
|
|
531
|
-
thresholdValue = evalNode(node.threshold.value, rng, thresholdCtx, env);
|
|
1017
|
+
thresholdValue = evalMetaOperand(node.threshold.value, rng, ctx, env);
|
|
532
1018
|
}
|
|
533
1019
|
|
|
534
1020
|
const code = formatExplodeCode(node.variant, node.threshold, thresholdValue);
|
|
535
1021
|
|
|
1022
|
+
const buildPart = (total: number): RollPart => {
|
|
1023
|
+
const part: RollPart = {
|
|
1024
|
+
type: 'explode',
|
|
1025
|
+
variant: node.variant,
|
|
1026
|
+
target: target.part,
|
|
1027
|
+
total,
|
|
1028
|
+
...partSpan(node),
|
|
1029
|
+
};
|
|
1030
|
+
if (node.threshold != null && thresholdValue != null) {
|
|
1031
|
+
part.threshold = { operator: node.threshold.operator, value: thresholdValue };
|
|
1032
|
+
}
|
|
1033
|
+
return part;
|
|
1034
|
+
};
|
|
1035
|
+
|
|
536
1036
|
// No-op when the target produced no dice (e.g., `(1+2)!`).
|
|
537
1037
|
if (targetCtx.rolls.length === 0) {
|
|
538
1038
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
539
1039
|
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
540
|
-
return
|
|
1040
|
+
return { total: target.total, part: buildPart(target.total) };
|
|
541
1041
|
}
|
|
542
1042
|
|
|
543
1043
|
const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
|
|
544
1044
|
|
|
545
|
-
const expanded =
|
|
546
|
-
node.variant === 'standard'
|
|
547
|
-
? applyStandardExplode(targetCtx.rolls, shouldExplode, rng, env)
|
|
548
|
-
: node.variant === 'compound'
|
|
549
|
-
? applyCompoundExplode(targetCtx.rolls, shouldExplode, rng, env)
|
|
550
|
-
: applyPenetratingExplode(targetCtx.rolls, shouldExplode, rng, env);
|
|
1045
|
+
const expanded = EXPLODE_APPLIERS[node.variant](targetCtx.rolls, shouldExplode, rng, env);
|
|
551
1046
|
|
|
552
|
-
ctx.rolls
|
|
1047
|
+
appendAll(ctx.rolls, expanded);
|
|
553
1048
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
// 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.
|
|
557
1051
|
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
|
|
558
1052
|
|
|
559
|
-
|
|
1053
|
+
const total = sumKeptDice(expanded);
|
|
1054
|
+
return { total, part: buildPart(total) };
|
|
560
1055
|
}
|
|
561
1056
|
|
|
562
|
-
function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
563
|
-
const targetCtx
|
|
564
|
-
evalNode(node.target, rng, targetCtx, env);
|
|
1057
|
+
function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
1058
|
+
const targetCtx = createContext();
|
|
1059
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
565
1060
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
566
1061
|
|
|
567
|
-
const
|
|
568
|
-
const thresholdValue = evalNode(node.condition.value, rng, thresholdCtx, env);
|
|
1062
|
+
const thresholdValue = evalMetaOperand(node.condition.value, rng, ctx, env);
|
|
569
1063
|
|
|
570
1064
|
const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
|
|
1065
|
+
const condition: ResolvedComparePoint = {
|
|
1066
|
+
operator: node.condition.operator,
|
|
1067
|
+
value: thresholdValue,
|
|
1068
|
+
};
|
|
571
1069
|
|
|
572
1070
|
// No-op when the target produced no dice (e.g., `(1+2)r<5`).
|
|
573
1071
|
if (targetCtx.rolls.length === 0) {
|
|
574
1072
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
575
1073
|
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
576
|
-
|
|
1074
|
+
const total = sumKeptDice(targetCtx.rolls);
|
|
1075
|
+
return {
|
|
1076
|
+
total,
|
|
1077
|
+
part: {
|
|
1078
|
+
type: 'reroll',
|
|
1079
|
+
once: node.once,
|
|
1080
|
+
condition,
|
|
1081
|
+
target: target.part,
|
|
1082
|
+
total,
|
|
1083
|
+
...partSpan(node),
|
|
1084
|
+
},
|
|
1085
|
+
};
|
|
577
1086
|
}
|
|
578
1087
|
|
|
579
1088
|
const pool = node.once
|
|
580
1089
|
? applyRerollOnce(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env)
|
|
581
1090
|
: applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
|
|
582
1091
|
|
|
583
|
-
ctx.rolls
|
|
1092
|
+
appendAll(ctx.rolls, pool);
|
|
584
1093
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
585
1094
|
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
|
|
586
1095
|
|
|
587
|
-
|
|
1096
|
+
const total = sumKeptDice(pool);
|
|
1097
|
+
return {
|
|
1098
|
+
total,
|
|
1099
|
+
part: {
|
|
1100
|
+
type: 'reroll',
|
|
1101
|
+
once: node.once,
|
|
1102
|
+
condition,
|
|
1103
|
+
target: target.part,
|
|
1104
|
+
total,
|
|
1105
|
+
...partSpan(node),
|
|
1106
|
+
},
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
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
|
+
};
|
|
588
1156
|
}
|
|
589
1157
|
|
|
590
|
-
|
|
591
|
-
|
|
1158
|
+
//
|
|
1159
|
+
// * Sort and crit thresholds
|
|
1160
|
+
//
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Evaluates a sort modifier. Purely visual — sorts the dice produced by
|
|
1164
|
+
* `node.target` in ascending or descending order of `result` without
|
|
1165
|
+
* changing the total or any die-level flag. Dropped dice participate in
|
|
1166
|
+
* the sort alongside kept dice, preserving their `'dropped'` marker.
|
|
1167
|
+
*
|
|
1168
|
+
* Rendering mirrors `evalExplode` / `evalKeepDrop`: emits
|
|
1169
|
+
* `<targetExpr><code>[<sortedDice>]`, replacing any inline dice brackets
|
|
1170
|
+
* the target itself rendered. Multi-sub-roll Group targets (`{4d6, 3d6}s`)
|
|
1171
|
+
* are rejected at parse time with `INVALID_SORT_TARGET` until hierarchical
|
|
1172
|
+
* per-sub-roll sorting (Stage 3 spec §3 "Group interaction") is implemented.
|
|
1173
|
+
*/
|
|
1174
|
+
function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
1175
|
+
const targetCtx = createContext();
|
|
1176
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
592
1177
|
|
|
593
|
-
const
|
|
594
|
-
|
|
1178
|
+
const sortedRolls = sortDice(targetCtx.rolls, node.order);
|
|
1179
|
+
|
|
1180
|
+
appendAll(ctx.rolls, sortedRolls);
|
|
1181
|
+
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
1182
|
+
|
|
1183
|
+
const code = node.order === 'ascending' ? 's' : 'sd';
|
|
1184
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
1185
|
+
|
|
1186
|
+
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1187
|
+
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(sortedRolls)}`);
|
|
1188
|
+
|
|
1189
|
+
return {
|
|
1190
|
+
total: target.total,
|
|
1191
|
+
part: {
|
|
1192
|
+
type: 'sort',
|
|
1193
|
+
order: node.order,
|
|
1194
|
+
rolls: sortedRolls,
|
|
1195
|
+
target: target.part,
|
|
1196
|
+
total: target.total,
|
|
1197
|
+
...partSpan(node),
|
|
1198
|
+
},
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Evaluates a critical/fumble threshold modifier. Pure post-processing:
|
|
1204
|
+
* evaluates the target in an isolated context, resolves each threshold's
|
|
1205
|
+
* ComparePoint value (including meta-expressions, which consume RNG draws
|
|
1206
|
+
* AFTER the target pool), then overrides `critical`/`fumble` flags on the
|
|
1207
|
+
* produced dice in place.
|
|
1208
|
+
*
|
|
1209
|
+
* Independent overrides (Roll20 semantics): `cs` thresholds replace only the
|
|
1210
|
+
* crit criteria and `cf` thresholds replace only the fumble criteria. When a
|
|
1211
|
+
* side has no explicit threshold, the `'default'` rule applies — so
|
|
1212
|
+
* `1d20cf<3` keeps the default nat-20 crit. Bare `cs`/`cf` uses the
|
|
1213
|
+
* `'default'` sentinel resolved per-die to `result === sides` or
|
|
1214
|
+
* `result === 1`.
|
|
1215
|
+
*
|
|
1216
|
+
* Renders `<targetExpr><codes>[<dice>]`, mirroring `evalSort`/`evalExplode`.
|
|
1217
|
+
*/
|
|
1218
|
+
function evalCritThreshold(
|
|
1219
|
+
node: CritThresholdNode,
|
|
1220
|
+
rng: RNG,
|
|
1221
|
+
ctx: EvalContext,
|
|
1222
|
+
env: EvalEnv,
|
|
1223
|
+
): EvalResult {
|
|
1224
|
+
const targetCtx = createContext();
|
|
1225
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1226
|
+
|
|
1227
|
+
const successResolved = node.successThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
|
|
1228
|
+
const failResolved = node.failThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
|
|
1229
|
+
|
|
1230
|
+
// A side with no explicit threshold falls back to the default rule rather
|
|
1231
|
+
// than being wiped by the other side's override.
|
|
1232
|
+
const successApplied: ResolvedCritThreshold[] =
|
|
1233
|
+
successResolved.length > 0 ? successResolved : ['default'];
|
|
1234
|
+
const failApplied: ResolvedCritThreshold[] = failResolved.length > 0 ? failResolved : ['default'];
|
|
1235
|
+
|
|
1236
|
+
applyCritThresholds(targetCtx.rolls, successApplied, failApplied);
|
|
1237
|
+
|
|
1238
|
+
appendAll(ctx.rolls, targetCtx.rolls);
|
|
1239
|
+
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
1240
|
+
|
|
1241
|
+
const targetExpr = targetCtx.expressionParts.join('');
|
|
1242
|
+
const codes = [
|
|
1243
|
+
...successResolved.map((t) => (t === 'default' ? 'cs' : `cs${t.operator}${t.value}`)),
|
|
1244
|
+
...failResolved.map((t) => (t === 'default' ? 'cf' : `cf${t.operator}${t.value}`)),
|
|
1245
|
+
].join('');
|
|
1246
|
+
|
|
1247
|
+
ctx.expressionParts.push(`${targetExpr}${codes}`);
|
|
1248
|
+
ctx.renderedParts.push(`${targetExpr}${codes}${renderDice(targetCtx.rolls)}`);
|
|
1249
|
+
|
|
1250
|
+
return {
|
|
1251
|
+
total: target.total,
|
|
1252
|
+
part: {
|
|
1253
|
+
type: 'critThreshold',
|
|
1254
|
+
successThresholds: successResolved,
|
|
1255
|
+
failThresholds: failResolved,
|
|
1256
|
+
target: target.part,
|
|
1257
|
+
total: target.total,
|
|
1258
|
+
...partSpan(node),
|
|
1259
|
+
},
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function resolveCritThreshold(
|
|
1264
|
+
threshold: CritThreshold,
|
|
1265
|
+
rng: RNG,
|
|
1266
|
+
ctx: EvalContext,
|
|
1267
|
+
env: EvalEnv,
|
|
1268
|
+
): ResolvedCritThreshold {
|
|
1269
|
+
if (threshold === 'default') return 'default';
|
|
1270
|
+
|
|
1271
|
+
const resolved = evalMetaOperand(threshold.value, rng, ctx, env);
|
|
1272
|
+
if (!Number.isFinite(resolved)) {
|
|
1273
|
+
throw new EvaluatorError(
|
|
1274
|
+
`Invalid crit threshold: ${resolved}`,
|
|
1275
|
+
'INVALID_THRESHOLD',
|
|
1276
|
+
'CritThreshold',
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
return { operator: threshold.operator, value: resolved };
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
//
|
|
1283
|
+
// * Keep/drop evaluation
|
|
1284
|
+
//
|
|
1285
|
+
|
|
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.
|
|
1292
|
+
if (baseTarget.type === 'Group' && baseTarget.expressions.length >= 2) {
|
|
1293
|
+
return evalGroupKeepDrop(node, baseTarget, specs, rng, ctx, env);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const targetCtx = createContext();
|
|
1297
|
+
const target = evalNode(baseTarget, rng, targetCtx, env);
|
|
595
1298
|
|
|
596
1299
|
const mergedDice = mergeDropSets(targetCtx.rolls, specs);
|
|
597
1300
|
|
|
598
|
-
ctx.rolls
|
|
1301
|
+
appendAll(ctx.rolls, mergedDice);
|
|
599
1302
|
|
|
600
1303
|
const total = sumKeptDice(mergedDice);
|
|
601
1304
|
|
|
602
1305
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
603
|
-
const
|
|
1306
|
+
const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
604
1307
|
|
|
605
|
-
ctx.expressionParts.push(`${targetExpr}${
|
|
1308
|
+
ctx.expressionParts.push(`${targetExpr}${keepDropCodes}`);
|
|
606
1309
|
ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
|
|
607
1310
|
|
|
608
|
-
return
|
|
1311
|
+
return {
|
|
1312
|
+
total,
|
|
1313
|
+
part: {
|
|
1314
|
+
type: 'keepDrop',
|
|
1315
|
+
specs: toPublicSpecs(specs),
|
|
1316
|
+
target: target.part,
|
|
1317
|
+
total,
|
|
1318
|
+
...partSpan(node),
|
|
1319
|
+
},
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Strips per-die markdown markers from an already-rendered sub-roll string.
|
|
1325
|
+
* Used when a whole group sub-roll is dropped: the outer `~~...~~` wrap
|
|
1326
|
+
* supersedes inner success (`**`), failure (`__`), and dropped (`~~`)
|
|
1327
|
+
* markers, and leaving them in place would nest strikethroughs or show
|
|
1328
|
+
* success highlights inside a dropped span.
|
|
1329
|
+
*/
|
|
1330
|
+
function stripInnerMarkers(rendered: string): string {
|
|
1331
|
+
return rendered
|
|
1332
|
+
.replace(/\*\*(-?\d+)\*\*/g, '$1')
|
|
1333
|
+
.replace(/__(-?\d+)__/g, '$1')
|
|
1334
|
+
.replace(/~~(-?\d+)~~/g, '$1');
|
|
609
1335
|
}
|
|
610
1336
|
|
|
1337
|
+
/**
|
|
1338
|
+
* Evaluates a keep/drop modifier chain whose base target is a multi
|
|
1339
|
+
* sub-roll group. Each sub-roll is evaluated in isolation so its subtotal
|
|
1340
|
+
* and dice are captured separately. Synthetic dice — one per sub-roll,
|
|
1341
|
+
* `result = subtotal` — feed `mergeDropSets` to pick kept/dropped indices.
|
|
1342
|
+
* Dropped sub-rolls' inner dice are re-flagged `'dropped'` so `sumKeptDice`
|
|
1343
|
+
* on the propagated rolls still agrees with the group total, and the
|
|
1344
|
+
* rendered form wraps them in strikethrough `~~...~~`.
|
|
1345
|
+
*/
|
|
1346
|
+
function evalGroupKeepDrop(
|
|
1347
|
+
node: KeepDropNode,
|
|
1348
|
+
group: GroupNode,
|
|
1349
|
+
specs: KeepDropChainEntry[],
|
|
1350
|
+
rng: RNG,
|
|
1351
|
+
ctx: EvalContext,
|
|
1352
|
+
env: EvalEnv,
|
|
1353
|
+
): EvalResult {
|
|
1354
|
+
type SubRoll = {
|
|
1355
|
+
subtotal: number;
|
|
1356
|
+
part: RollPart;
|
|
1357
|
+
rolls: DieResult[];
|
|
1358
|
+
expr: string;
|
|
1359
|
+
rendered: string;
|
|
1360
|
+
versusMetadata: EvalContext['versusMetadata'];
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
const subRolls: SubRoll[] = group.expressions.map((expr) => {
|
|
1364
|
+
const subCtx = createContext();
|
|
1365
|
+
const sub = evalNode(expr, rng, subCtx, env);
|
|
1366
|
+
return {
|
|
1367
|
+
subtotal: sub.total,
|
|
1368
|
+
part: sub.part,
|
|
1369
|
+
rolls: subCtx.rolls,
|
|
1370
|
+
expr: subCtx.expressionParts.join(''),
|
|
1371
|
+
rendered: subCtx.renderedParts.join(''),
|
|
1372
|
+
versusMetadata: subCtx.versusMetadata,
|
|
1373
|
+
};
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
// `sides = 0` sentinel: synthetic dice only feed `mergeDropSets`, never reach
|
|
1377
|
+
// `ctx.rolls`, and crit/fumble is meaningless for a subtotal.
|
|
1378
|
+
const syntheticDice: DieResult[] = subRolls.map((sub) => ({
|
|
1379
|
+
sides: 0,
|
|
1380
|
+
result: sub.subtotal,
|
|
1381
|
+
modifiers: [],
|
|
1382
|
+
critical: false,
|
|
1383
|
+
fumble: false,
|
|
1384
|
+
}));
|
|
1385
|
+
|
|
1386
|
+
const mergedSynthetic = mergeDropSets(syntheticDice, specs);
|
|
1387
|
+
|
|
1388
|
+
const outerRendered: string[] = [];
|
|
1389
|
+
const keptIndices: number[] = [];
|
|
1390
|
+
let total = 0;
|
|
1391
|
+
|
|
1392
|
+
for (let i = 0; i < subRolls.length; i++) {
|
|
1393
|
+
const sub = subRolls[i] as SubRoll;
|
|
1394
|
+
const synth = mergedSynthetic[i] as DieResult;
|
|
1395
|
+
const isDropped = synth.modifiers.includes('dropped');
|
|
1396
|
+
|
|
1397
|
+
if (isDropped) {
|
|
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`.
|
|
1402
|
+
for (const die of sub.rolls) {
|
|
1403
|
+
die.modifiers = rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'dropped');
|
|
1404
|
+
}
|
|
1405
|
+
appendAll(ctx.rolls, sub.rolls);
|
|
1406
|
+
outerRendered.push(`~~${stripInnerMarkers(sub.rendered)}~~`);
|
|
1407
|
+
} else {
|
|
1408
|
+
keptIndices.push(i);
|
|
1409
|
+
appendAll(ctx.rolls, sub.rolls);
|
|
1410
|
+
outerRendered.push(sub.rendered);
|
|
1411
|
+
total += sub.subtotal;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
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.
|
|
1417
|
+
if (!isDropped) {
|
|
1418
|
+
propagateMetadata(ctx, sub.versusMetadata);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const subExprStrs = subRolls.map((s) => s.expr);
|
|
1423
|
+
const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
1424
|
+
|
|
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.
|
|
1428
|
+
ctx.renderedParts.push(`{${outerRendered.join(', ')}}`);
|
|
1429
|
+
|
|
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`.
|
|
1433
|
+
const groupPart: RollPart = {
|
|
1434
|
+
type: 'group',
|
|
1435
|
+
parts: subRolls.map((s) => s.part),
|
|
1436
|
+
keptIndices,
|
|
1437
|
+
total,
|
|
1438
|
+
...partSpan(group),
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
return {
|
|
1442
|
+
total,
|
|
1443
|
+
part: {
|
|
1444
|
+
type: 'keepDrop',
|
|
1445
|
+
specs: toPublicSpecs(specs),
|
|
1446
|
+
target: groupPart,
|
|
1447
|
+
total,
|
|
1448
|
+
...partSpan(node),
|
|
1449
|
+
},
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
//
|
|
1454
|
+
// * Success counting
|
|
1455
|
+
//
|
|
1456
|
+
|
|
611
1457
|
function resolveThreshold(
|
|
612
1458
|
value: ASTNode,
|
|
613
1459
|
rng: RNG,
|
|
1460
|
+
ctx: EvalContext,
|
|
614
1461
|
env: EvalEnv,
|
|
615
1462
|
role: 'threshold' | 'fail threshold',
|
|
616
1463
|
): number {
|
|
617
|
-
const
|
|
618
|
-
const resolved = evalNode(value, rng, thresholdCtx, env);
|
|
1464
|
+
const resolved = evalMetaOperand(value, rng, ctx, env);
|
|
619
1465
|
|
|
620
1466
|
if (!Number.isFinite(resolved)) {
|
|
621
1467
|
throw new EvaluatorError(`Invalid ${role}: ${resolved}`, 'INVALID_THRESHOLD', 'SuccessCount');
|
|
@@ -624,32 +1470,69 @@ function resolveThreshold(
|
|
|
624
1470
|
return resolved;
|
|
625
1471
|
}
|
|
626
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
|
+
|
|
627
1486
|
function evalSuccessCount(
|
|
628
1487
|
node: SuccessCountNode,
|
|
629
1488
|
rng: RNG,
|
|
630
1489
|
ctx: EvalContext,
|
|
631
1490
|
env: EvalEnv,
|
|
632
|
-
):
|
|
633
|
-
|
|
634
|
-
|
|
1491
|
+
): EvalResult {
|
|
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.
|
|
1494
|
+
env.hasSuccessCount = true;
|
|
1495
|
+
|
|
1496
|
+
const targetCtx = createContext();
|
|
1497
|
+
const target = evalNode(node.target, rng, targetCtx, env);
|
|
635
1498
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
636
1499
|
|
|
637
|
-
const thresholdValue = resolveThreshold(node.threshold.value, rng, env, 'threshold');
|
|
1500
|
+
const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
|
|
638
1501
|
const failValue =
|
|
639
1502
|
node.failThreshold != null
|
|
640
|
-
? resolveThreshold(node.failThreshold.value, rng, env, 'fail threshold')
|
|
1503
|
+
? resolveThreshold(node.failThreshold.value, rng, ctx, env, 'fail threshold')
|
|
641
1504
|
: undefined;
|
|
642
1505
|
|
|
643
|
-
const
|
|
644
|
-
failValue != null
|
|
645
|
-
|
|
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}`;
|
|
1512
|
+
|
|
1513
|
+
const buildPart = (total: number, successes: number, failures: number): RollPart => {
|
|
1514
|
+
const part: RollPart = {
|
|
1515
|
+
type: 'successCount',
|
|
1516
|
+
threshold: { operator: node.threshold.operator, value: thresholdValue },
|
|
1517
|
+
target: target.part,
|
|
1518
|
+
successes,
|
|
1519
|
+
failures,
|
|
1520
|
+
total,
|
|
1521
|
+
...partSpan(node),
|
|
1522
|
+
};
|
|
1523
|
+
if (failValue != null && node.failThreshold != null) {
|
|
1524
|
+
part.failThreshold = { operator: node.failThreshold.operator, value: failValue };
|
|
1525
|
+
}
|
|
1526
|
+
return part;
|
|
1527
|
+
};
|
|
646
1528
|
|
|
647
|
-
// No-op when the target produced no dice
|
|
648
|
-
// already reject
|
|
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.
|
|
649
1532
|
if (targetCtx.rolls.length === 0) {
|
|
650
1533
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
651
1534
|
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
652
|
-
return
|
|
1535
|
+
return { total: target.total, part: buildPart(target.total, 0, 0) };
|
|
653
1536
|
}
|
|
654
1537
|
|
|
655
1538
|
const result = countSuccesses(
|
|
@@ -660,28 +1543,46 @@ function evalSuccessCount(
|
|
|
660
1543
|
: undefined,
|
|
661
1544
|
);
|
|
662
1545
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
ctx.rolls.push(...targetCtx.rolls);
|
|
1546
|
+
appendAll(ctx.rolls, targetCtx.rolls);
|
|
666
1547
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
667
1548
|
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
|
|
668
1549
|
|
|
669
|
-
return
|
|
1550
|
+
return {
|
|
1551
|
+
total: result.total,
|
|
1552
|
+
part: buildPart(result.total, result.successes, result.failures),
|
|
1553
|
+
};
|
|
670
1554
|
}
|
|
671
1555
|
|
|
1556
|
+
//
|
|
1557
|
+
// * Versus
|
|
1558
|
+
//
|
|
1559
|
+
|
|
672
1560
|
/**
|
|
673
1561
|
* Extracts the "natural" d20 value from a roll-side dice pool. Returns the
|
|
674
|
-
* single value when exactly one kept d20 is present; otherwise
|
|
1562
|
+
* single value when exactly one primary kept d20 is present; otherwise
|
|
1563
|
+
* `undefined`.
|
|
675
1564
|
*
|
|
676
|
-
* Excludes dropped (`kh`/`kl`/`dh`/`dl
|
|
677
|
-
*
|
|
678
|
-
* `
|
|
1565
|
+
* Excludes dropped (`kh`/`kl`/`dh`/`dl`/`r`/`ro`) dice — these aren't the
|
|
1566
|
+
* final kept result. Explosion continuation dice (appended by standard/
|
|
1567
|
+
* penetrating explode, tagged `'exploded'` with no `initialResult`) are not
|
|
1568
|
+
* primaries either — `1d20! vs DC` keeps the natural from the original d20.
|
|
1569
|
+
* Compound explode accumulates into the original die and sets
|
|
1570
|
+
* `initialResult`, so it stays a primary and the raw first face is used.
|
|
1571
|
+
* Multiple primary kept d20s (e.g., `1d20+1d20`) yield `undefined` so no
|
|
1572
|
+
* ambiguous upgrade/downgrade is applied.
|
|
679
1573
|
*/
|
|
680
1574
|
function extractNatural(rolls: DieResult[]): number | undefined {
|
|
681
|
-
|
|
682
|
-
|
|
1575
|
+
// Rerolled intermediates are always stamped `['rerolled', 'dropped']`
|
|
1576
|
+
// (see `modifiers/reroll.ts`), so filtering by `'dropped'` covers them.
|
|
1577
|
+
const primaries = rolls.filter(
|
|
1578
|
+
(d) =>
|
|
1579
|
+
d.sides === 20 &&
|
|
1580
|
+
!d.modifiers.includes('dropped') &&
|
|
1581
|
+
!(d.modifiers.includes('exploded') && d.initialResult == null),
|
|
683
1582
|
);
|
|
684
|
-
|
|
1583
|
+
if (primaries.length !== 1) return undefined;
|
|
1584
|
+
const die = primaries[0];
|
|
1585
|
+
return die?.initialResult ?? die?.result;
|
|
685
1586
|
}
|
|
686
1587
|
|
|
687
1588
|
/**
|
|
@@ -714,25 +1615,29 @@ function degreeLabel(degree: DegreeOfSuccess): string {
|
|
|
714
1615
|
}
|
|
715
1616
|
}
|
|
716
1617
|
|
|
717
|
-
function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
1618
|
+
function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
718
1619
|
if (env.insideVersus) {
|
|
719
1620
|
throw new EvaluatorError('Cannot nest versus operators', 'NESTED_VERSUS', 'Versus');
|
|
720
1621
|
}
|
|
721
1622
|
|
|
722
1623
|
env.insideVersus = true;
|
|
723
1624
|
try {
|
|
724
|
-
const rollCtx
|
|
725
|
-
const
|
|
726
|
-
// ? Extract natural from rollCtx directly — the roll-side pool is isolated
|
|
727
|
-
// here, so no index slicing on the merged parent pool is needed.
|
|
1625
|
+
const rollCtx = createContext();
|
|
1626
|
+
const rollResult = evalNode(node.roll, rng, rollCtx, env);
|
|
728
1627
|
const natural = extractNatural(rollCtx.rolls);
|
|
729
1628
|
|
|
730
|
-
const dcCtx
|
|
731
|
-
const
|
|
1629
|
+
const dcCtx = createContext();
|
|
1630
|
+
const dcResult = evalNode(node.dc, rng, dcCtx, env);
|
|
732
1631
|
|
|
733
|
-
const degree = calculateDegree(
|
|
1632
|
+
const degree = calculateDegree(rollResult.total, dcResult.total, natural);
|
|
734
1633
|
|
|
735
|
-
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);
|
|
736
1641
|
|
|
737
1642
|
const rollExpr = rollCtx.expressionParts.join('');
|
|
738
1643
|
const dcExpr = dcCtx.expressionParts.join('');
|
|
@@ -741,49 +1646,82 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
741
1646
|
|
|
742
1647
|
ctx.expressionParts.push(`${rollExpr} vs ${dcExpr}`);
|
|
743
1648
|
ctx.renderedParts.push(`${rollRendered} vs ${dcRendered}`);
|
|
744
|
-
ctx.versusMetadata = { degree, natural, dcTotal };
|
|
745
|
-
|
|
746
|
-
return
|
|
1649
|
+
ctx.versusMetadata = { degree, natural, dcTotal: dcResult.total };
|
|
1650
|
+
|
|
1651
|
+
return {
|
|
1652
|
+
total: rollResult.total,
|
|
1653
|
+
part: {
|
|
1654
|
+
type: 'versus',
|
|
1655
|
+
roll: rollResult.part,
|
|
1656
|
+
dc: dcResult.part,
|
|
1657
|
+
degree,
|
|
1658
|
+
total: rollResult.total,
|
|
1659
|
+
...partSpan(node),
|
|
1660
|
+
},
|
|
1661
|
+
};
|
|
747
1662
|
} finally {
|
|
748
1663
|
env.insideVersus = false;
|
|
749
1664
|
}
|
|
750
1665
|
}
|
|
751
1666
|
|
|
1667
|
+
//
|
|
1668
|
+
// * Entry point
|
|
1669
|
+
//
|
|
1670
|
+
|
|
752
1671
|
/**
|
|
753
|
-
* Evaluates a parsed AST and returns the roll result.
|
|
1672
|
+
* Evaluates a parsed AST against an {@link RNG} and returns the roll result.
|
|
754
1673
|
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
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.
|
|
1680
|
+
*
|
|
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
|
|
759
1690
|
*
|
|
760
1691
|
* @example
|
|
761
1692
|
* ```typescript
|
|
1693
|
+
* import { evaluate, parse } from 'roll-parser';
|
|
1694
|
+
* import { createMockRng } from 'roll-parser/testing';
|
|
1695
|
+
*
|
|
762
1696
|
* const ast = parse('2d6+3');
|
|
763
|
-
* const
|
|
764
|
-
*
|
|
765
|
-
*
|
|
1697
|
+
* const result = evaluate(ast, createMockRng([4, 2]), { notation: '2d6+3' });
|
|
1698
|
+
* result.total; // 9
|
|
1699
|
+
* result.rendered; // '2d6[4, 2] + 3 = 9'
|
|
766
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
|
|
767
1707
|
*/
|
|
768
1708
|
export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}): RollResult {
|
|
769
|
-
const maxDice =
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
options.
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
? Math.floor(options.maxRerollIterations)
|
|
786
|
-
: 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
|
+
);
|
|
1722
|
+
|
|
1723
|
+
const context = options.context ?? {};
|
|
1724
|
+
const onMissingVariable = options.onMissingVariable ?? 'throw';
|
|
787
1725
|
|
|
788
1726
|
const env: EvalEnv = {
|
|
789
1727
|
maxDice,
|
|
@@ -792,46 +1730,55 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
792
1730
|
totalDiceRolled: 0,
|
|
793
1731
|
hasSuccessCount: false,
|
|
794
1732
|
insideVersus: false,
|
|
1733
|
+
context,
|
|
1734
|
+
onMissingVariable,
|
|
795
1735
|
};
|
|
796
|
-
const ctx
|
|
797
|
-
rolls: [],
|
|
798
|
-
expressionParts: [],
|
|
799
|
-
renderedParts: [],
|
|
800
|
-
};
|
|
1736
|
+
const ctx = createContext();
|
|
801
1737
|
|
|
802
|
-
const total = evalNode(ast, rng, ctx, env);
|
|
1738
|
+
const { total, part } = evalNode(ast, rng, ctx, env);
|
|
1739
|
+
|
|
1740
|
+
if (!Number.isFinite(total)) {
|
|
1741
|
+
throw new EvaluatorError(
|
|
1742
|
+
`Result is not a finite number: ${total}`,
|
|
1743
|
+
'NON_FINITE_RESULT',
|
|
1744
|
+
ast.type,
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
803
1747
|
|
|
804
1748
|
const expression = ctx.expressionParts.join('');
|
|
805
|
-
//
|
|
806
|
-
//
|
|
1749
|
+
// Versus replaces the numeric total with the degree label in the rendered
|
|
1750
|
+
// form; `RollResult.total` remains the numeric roll total.
|
|
807
1751
|
const trailing = ctx.versusMetadata ? degreeLabel(ctx.versusMetadata.degree) : String(total);
|
|
808
1752
|
const rendered = `${ctx.renderedParts.join('')} = ${trailing}`;
|
|
809
1753
|
|
|
810
|
-
|
|
1754
|
+
// `RollResult` is `Readonly` — optional fields fold in via conditional spreads.
|
|
1755
|
+
const versus = ctx.versusMetadata;
|
|
1756
|
+
|
|
1757
|
+
return {
|
|
811
1758
|
total,
|
|
812
1759
|
notation: options.notation ?? expression,
|
|
813
1760
|
expression,
|
|
814
1761
|
rendered,
|
|
815
1762
|
rolls: ctx.rolls,
|
|
1763
|
+
parts: part,
|
|
1764
|
+
...(env.hasSuccessCount ? countTaggedDice(ctx.rolls) : {}),
|
|
1765
|
+
...(versus ? { degree: versus.degree } : {}),
|
|
1766
|
+
...(versus?.natural != null ? { natural: versus.natural } : {}),
|
|
816
1767
|
};
|
|
1768
|
+
}
|
|
817
1769
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
if (ctx.versusMetadata) {
|
|
830
|
-
result.degree = ctx.versusMetadata.degree;
|
|
831
|
-
if (ctx.versusMetadata.natural !== undefined) {
|
|
832
|
-
result.natural = ctx.versusMetadata.natural;
|
|
833
|
-
}
|
|
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;
|
|
834
1781
|
}
|
|
835
1782
|
|
|
836
|
-
return
|
|
1783
|
+
return { successes, failures };
|
|
837
1784
|
}
|