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