roll-parser 3.0.0 → 3.2.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 +61 -1
- package/MIGRATION.md +293 -4
- package/README.md +246 -55
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +8 -7
- package/dist/cli/format.js.map +1 -1
- package/dist/evaluator/die.d.ts +2 -1
- package/dist/evaluator/die.d.ts.map +1 -1
- package/dist/evaluator/die.js.map +1 -1
- package/dist/evaluator/env.d.ts +57 -0
- package/dist/evaluator/env.d.ts.map +1 -1
- package/dist/evaluator/env.js.map +1 -1
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +104 -56
- package/dist/evaluator/evaluator.js.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.d.ts +43 -5
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.js +24 -11
- package/dist/evaluator/modifiers/crit-threshold.js.map +1 -1
- package/dist/evaluator/modifiers/die-bound.d.ts +5 -2
- package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -1
- package/dist/evaluator/modifiers/die-bound.js +6 -3
- package/dist/evaluator/modifiers/die-bound.js.map +1 -1
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/explode.js +13 -5
- package/dist/evaluator/modifiers/explode.js.map +1 -1
- package/dist/evaluator/modifiers/flags.d.ts +17 -0
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
- package/dist/evaluator/modifiers/flags.js +4 -4
- package/dist/evaluator/modifiers/flags.js.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.d.ts +5 -5
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.js +7 -7
- package/dist/evaluator/modifiers/keep-drop.js.map +1 -1
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
- package/dist/evaluator/modifiers/reroll.js +16 -10
- package/dist/evaluator/modifiers/reroll.js.map +1 -1
- package/dist/evaluator/modifiers/sort.d.ts +1 -1
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
- package/dist/evaluator/modifiers/sort.js +3 -3
- package/dist/evaluator/modifiers/sort.js.map +1 -1
- package/dist/evaluator/modifiers/success-count.d.ts +6 -2
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +9 -5
- package/dist/evaluator/modifiers/success-count.js.map +1 -1
- package/dist/notation.d.ts +13 -0
- package/dist/notation.d.ts.map +1 -0
- package/dist/notation.js +8 -0
- package/dist/notation.js.map +1 -0
- package/dist/parser/ast.d.ts +12 -7
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/ast.js.map +1 -1
- package/dist/parser/guards.d.ts +38 -0
- package/dist/parser/guards.d.ts.map +1 -1
- package/dist/parser/guards.js +51 -0
- package/dist/parser/guards.js.map +1 -1
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +26 -2
- package/dist/parser/parser.js.map +1 -1
- package/dist/render.d.ts +95 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +232 -0
- package/dist/render.js.map +1 -0
- package/dist/types.d.ts +64 -12
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +14 -4
- package/src/cli/format.ts +15 -23
- package/src/evaluator/die.ts +2 -1
- package/src/evaluator/env.ts +58 -0
- package/src/evaluator/evaluator.ts +192 -70
- package/src/evaluator/modifiers/crit-threshold.ts +81 -15
- package/src/evaluator/modifiers/die-bound.ts +13 -4
- package/src/evaluator/modifiers/explode.ts +21 -6
- package/src/evaluator/modifiers/flags.ts +21 -4
- package/src/evaluator/modifiers/keep-drop.ts +10 -8
- package/src/evaluator/modifiers/reroll.ts +20 -11
- package/src/evaluator/modifiers/sort.ts +9 -3
- package/src/evaluator/modifiers/success-count.ts +27 -5
- package/src/notation.ts +24 -0
- package/src/parser/ast.ts +12 -7
- package/src/parser/guards.ts +92 -1
- package/src/parser/parser.ts +56 -1
- package/src/render.ts +394 -0
- package/src/types.ts +64 -12
- package/src/version.ts +1 -1
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { describeValue, EvaluatorError, RollParserError, stampEvaluatorSpan } from '../errors.js';
|
|
8
|
+
import { joinModifierCode } from '../notation.js';
|
|
8
9
|
import type {
|
|
9
10
|
ASTNode,
|
|
10
11
|
BinaryOpNode,
|
|
@@ -55,9 +56,12 @@ import {
|
|
|
55
56
|
} from './modifiers/explode.js';
|
|
56
57
|
import {
|
|
57
58
|
isVersusDc,
|
|
59
|
+
META_MERGE_FLAGS,
|
|
58
60
|
rewriteFlags,
|
|
59
61
|
SELECTION_AND_TALLY_FLAGS,
|
|
60
62
|
SELECTION_FLAGS,
|
|
63
|
+
stripFlags,
|
|
64
|
+
TALLY_FLAGS,
|
|
61
65
|
} from './modifiers/flags.js';
|
|
62
66
|
import { markDroppedIndices, sumKeptDice } from './modifiers/keep-drop.js';
|
|
63
67
|
import {
|
|
@@ -263,6 +267,10 @@ function renderDie(result: number, modifiers: readonly DieModifier[]): string {
|
|
|
263
267
|
* `sumKeptDice`; `'meta'` lets renderers hide them and lets callers
|
|
264
268
|
* distinguish them from ordinary pool dice.
|
|
265
269
|
*
|
|
270
|
+
* `'meta'` is stripped before being re-added: meta operands nest
|
|
271
|
+
* (`((1d2)d4)d6`), so the innermost dice pass through here once per level and
|
|
272
|
+
* an append-only rewrite would leave them carrying the tag once per level.
|
|
273
|
+
*
|
|
266
274
|
* `'success'`/`'failure'` tags are stripped here as defense-in-depth against
|
|
267
275
|
* a SuccessCount leaking into a meta sub-expression (parser rejects all such
|
|
268
276
|
* wrappings; this strip ensures a future parse regression cannot leak tags
|
|
@@ -279,7 +287,7 @@ export function mergeMetaRolls(parent: EvalContext, source: EvalContext): void {
|
|
|
279
287
|
for (const die of source.rolls) {
|
|
280
288
|
parent.rolls.push({
|
|
281
289
|
...die,
|
|
282
|
-
modifiers: rewriteFlags(die.modifiers,
|
|
290
|
+
modifiers: rewriteFlags(die.modifiers, META_MERGE_FLAGS, 'meta', 'dropped'),
|
|
283
291
|
});
|
|
284
292
|
}
|
|
285
293
|
}
|
|
@@ -475,11 +483,31 @@ function evalMetaOperand(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
475
483
|
}
|
|
476
484
|
|
|
477
485
|
const metaCtx = createContext();
|
|
478
|
-
|
|
486
|
+
// The tally counterpart of the `TALLY_FLAGS` strip in `mergeMetaRolls`.
|
|
487
|
+
const value = evalDiscardingSubtotals(node, rng, metaCtx, env).total;
|
|
479
488
|
mergeMetaRolls(ctx, metaCtx);
|
|
480
489
|
return value;
|
|
481
490
|
}
|
|
482
491
|
|
|
492
|
+
/**
|
|
493
|
+
* Evaluates a node whose result is consumed as a scalar — a meta operand or a
|
|
494
|
+
* `vs` DC — rolling back any subtotal verdicts it scored so they never reach
|
|
495
|
+
* the top-level tally.
|
|
496
|
+
*/
|
|
497
|
+
function evalDiscardingSubtotals(
|
|
498
|
+
node: ASTNode,
|
|
499
|
+
rng: RNG,
|
|
500
|
+
ctx: EvalContext,
|
|
501
|
+
env: EvalEnv,
|
|
502
|
+
): EvalResult {
|
|
503
|
+
const successTally = env.subtotalSuccesses;
|
|
504
|
+
const failureTally = env.subtotalFailures;
|
|
505
|
+
const result = evalNode(node, rng, ctx, env);
|
|
506
|
+
env.subtotalSuccesses = successTally;
|
|
507
|
+
env.subtotalFailures = failureTally;
|
|
508
|
+
return result;
|
|
509
|
+
}
|
|
510
|
+
|
|
483
511
|
/** Rejects dice counts that cannot address a pool. */
|
|
484
512
|
function requireDiceCount(count: number, nodeType: 'Dice' | 'FateDice'): void {
|
|
485
513
|
if (!Number.isInteger(count) || count < 0) {
|
|
@@ -952,11 +980,15 @@ function flattenKeepDropChain(
|
|
|
952
980
|
* selects against the unmodified pool: `markDroppedIndices` reads results and
|
|
953
981
|
* writes only into `droppedMask`, so no spec can observe another's outcome.
|
|
954
982
|
*/
|
|
955
|
-
function mergeDropSets(
|
|
983
|
+
function mergeDropSets(
|
|
984
|
+
baseDice: DieResult[],
|
|
985
|
+
specs: KeepDropChainEntry[],
|
|
986
|
+
hasVersusDc: boolean,
|
|
987
|
+
): DieResult[] {
|
|
956
988
|
const droppedMask = new Uint8Array(baseDice.length);
|
|
957
989
|
|
|
958
990
|
for (const spec of specs) {
|
|
959
|
-
markDroppedIndices(baseDice, spec.count, spec.kind, spec.selector, droppedMask);
|
|
991
|
+
markDroppedIndices(baseDice, spec.count, spec.kind, spec.selector, droppedMask, hasVersusDc);
|
|
960
992
|
}
|
|
961
993
|
|
|
962
994
|
for (let index = 0; index < baseDice.length; index++) {
|
|
@@ -1019,10 +1051,11 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
1019
1051
|
|
|
1020
1052
|
const code = formatExplodeCode(node.variant, node.threshold, thresholdValue);
|
|
1021
1053
|
|
|
1022
|
-
const buildPart = (total: number): RollPart => {
|
|
1054
|
+
const buildPart = (total: number, rolls: DieResult[]): RollPart => {
|
|
1023
1055
|
const part: RollPart = {
|
|
1024
1056
|
type: 'explode',
|
|
1025
1057
|
variant: node.variant,
|
|
1058
|
+
rolls,
|
|
1026
1059
|
target: target.part,
|
|
1027
1060
|
total,
|
|
1028
1061
|
...partSpan(node),
|
|
@@ -1037,7 +1070,7 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
1037
1070
|
if (targetCtx.rolls.length === 0) {
|
|
1038
1071
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1039
1072
|
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
1040
|
-
return { total: target.total, part: buildPart(target.total) };
|
|
1073
|
+
return { total: target.total, part: buildPart(target.total, targetCtx.rolls) };
|
|
1041
1074
|
}
|
|
1042
1075
|
|
|
1043
1076
|
const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
|
|
@@ -1050,18 +1083,18 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
1050
1083
|
// are self-evident, explosion origin is otherwise invisible.
|
|
1051
1084
|
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
|
|
1052
1085
|
|
|
1053
|
-
const total = sumKeptDice(expanded);
|
|
1054
|
-
return { total, part: buildPart(total) };
|
|
1086
|
+
const total = sumKeptDice(expanded, env.hasVersusDc);
|
|
1087
|
+
return { total, part: buildPart(total, expanded) };
|
|
1055
1088
|
}
|
|
1056
1089
|
|
|
1057
1090
|
function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
1058
1091
|
const targetCtx = createContext();
|
|
1059
1092
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1060
|
-
const targetExpr = targetCtx.expressionParts.join('');
|
|
1061
1093
|
|
|
1062
1094
|
const thresholdValue = evalMetaOperand(node.condition.value, rng, ctx, env);
|
|
1063
1095
|
|
|
1064
1096
|
const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
|
|
1097
|
+
const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
|
|
1065
1098
|
const condition: ResolvedComparePoint = {
|
|
1066
1099
|
operator: node.condition.operator,
|
|
1067
1100
|
value: thresholdValue,
|
|
@@ -1069,15 +1102,16 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1069
1102
|
|
|
1070
1103
|
// No-op when the target produced no dice (e.g., `(1+2)r<5`).
|
|
1071
1104
|
if (targetCtx.rolls.length === 0) {
|
|
1072
|
-
ctx.expressionParts.push(
|
|
1073
|
-
ctx.renderedParts.push(
|
|
1074
|
-
const total = sumKeptDice(targetCtx.rolls);
|
|
1105
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1106
|
+
ctx.renderedParts.push(modifierExpr);
|
|
1107
|
+
const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
|
|
1075
1108
|
return {
|
|
1076
1109
|
total,
|
|
1077
1110
|
part: {
|
|
1078
1111
|
type: 'reroll',
|
|
1079
1112
|
once: node.once,
|
|
1080
1113
|
condition,
|
|
1114
|
+
rolls: targetCtx.rolls,
|
|
1081
1115
|
target: target.part,
|
|
1082
1116
|
total,
|
|
1083
1117
|
...partSpan(node),
|
|
@@ -1090,16 +1124,17 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1090
1124
|
: applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
|
|
1091
1125
|
|
|
1092
1126
|
appendAll(ctx.rolls, pool);
|
|
1093
|
-
ctx.expressionParts.push(
|
|
1094
|
-
ctx.renderedParts.push(`${
|
|
1127
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1128
|
+
ctx.renderedParts.push(`${modifierExpr}${renderDice(pool)}`);
|
|
1095
1129
|
|
|
1096
|
-
const total = sumKeptDice(pool);
|
|
1130
|
+
const total = sumKeptDice(pool, env.hasVersusDc);
|
|
1097
1131
|
return {
|
|
1098
1132
|
total,
|
|
1099
1133
|
part: {
|
|
1100
1134
|
type: 'reroll',
|
|
1101
1135
|
once: node.once,
|
|
1102
1136
|
condition,
|
|
1137
|
+
rolls: pool,
|
|
1103
1138
|
target: target.part,
|
|
1104
1139
|
total,
|
|
1105
1140
|
...partSpan(node),
|
|
@@ -1126,22 +1161,22 @@ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1126
1161
|
);
|
|
1127
1162
|
}
|
|
1128
1163
|
|
|
1129
|
-
applyDieBound(targetCtx.rolls, node.bound, boundValue);
|
|
1164
|
+
applyDieBound(targetCtx.rolls, node.bound, boundValue, env.hasVersusDc);
|
|
1130
1165
|
|
|
1131
1166
|
appendAll(ctx.rolls, targetCtx.rolls);
|
|
1132
1167
|
// ! No `propagateMetadata` here: clamping re-sums, so a propagated `degree`
|
|
1133
1168
|
// ! would have been resolved against a total this node just replaced.
|
|
1134
1169
|
// ! See the rule on `propagateMetadata`.
|
|
1135
1170
|
|
|
1136
|
-
const targetExpr = targetCtx.expressionParts.join('');
|
|
1137
1171
|
// Negative bounds render parenthesized so `result.expression` re-parses
|
|
1138
1172
|
// (`4d6min-2` is a syntax error; `4d6min(-2)` is not).
|
|
1139
1173
|
const code = boundValue < 0 ? `${node.bound}(${boundValue})` : `${node.bound}${boundValue}`;
|
|
1174
|
+
const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
|
|
1140
1175
|
|
|
1141
|
-
ctx.expressionParts.push(
|
|
1142
|
-
ctx.renderedParts.push(`${
|
|
1176
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1177
|
+
ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
|
|
1143
1178
|
|
|
1144
|
-
const total = sumKeptDice(targetCtx.rolls);
|
|
1179
|
+
const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
|
|
1145
1180
|
return {
|
|
1146
1181
|
total,
|
|
1147
1182
|
part: {
|
|
@@ -1175,16 +1210,16 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
|
|
|
1175
1210
|
const targetCtx = createContext();
|
|
1176
1211
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1177
1212
|
|
|
1178
|
-
const sortedRolls = sortDice(targetCtx.rolls, node.order);
|
|
1213
|
+
const sortedRolls = sortDice(targetCtx.rolls, node.order, env.hasVersusDc);
|
|
1179
1214
|
|
|
1180
1215
|
appendAll(ctx.rolls, sortedRolls);
|
|
1181
1216
|
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
1182
1217
|
|
|
1183
1218
|
const code = node.order === 'ascending' ? 's' : 'sd';
|
|
1184
|
-
const
|
|
1219
|
+
const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
|
|
1185
1220
|
|
|
1186
|
-
ctx.expressionParts.push(
|
|
1187
|
-
ctx.renderedParts.push(`${
|
|
1221
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1222
|
+
ctx.renderedParts.push(`${modifierExpr}${renderDice(sortedRolls)}`);
|
|
1188
1223
|
|
|
1189
1224
|
return {
|
|
1190
1225
|
total: target.total,
|
|
@@ -1210,8 +1245,12 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
|
|
|
1210
1245
|
* crit criteria and `cf` thresholds replace only the fumble criteria. When a
|
|
1211
1246
|
* side has no explicit threshold, the `'default'` rule applies — so
|
|
1212
1247
|
* `1d20cf<3` keeps the default nat-20 crit. Bare `cs`/`cf` uses the
|
|
1213
|
-
* `'default'` sentinel resolved per-die
|
|
1214
|
-
* `result
|
|
1248
|
+
* `'default'` sentinel, resolved per-die against the natural face
|
|
1249
|
+
* (`initialResult ?? result`) rather than the possibly-rewritten `result`.
|
|
1250
|
+
*
|
|
1251
|
+
* The resolved rule is recorded per die, so an enclosing explode or reroll
|
|
1252
|
+
* judges the dice it mints by it too — `1d6cs<2!` no longer reports the
|
|
1253
|
+
* default-rule crit the user overrode.
|
|
1215
1254
|
*
|
|
1216
1255
|
* Renders `<targetExpr><codes>[<dice>]`, mirroring `evalSort`/`evalExplode`.
|
|
1217
1256
|
*/
|
|
@@ -1233,19 +1272,18 @@ function evalCritThreshold(
|
|
|
1233
1272
|
successResolved.length > 0 ? successResolved : ['default'];
|
|
1234
1273
|
const failApplied: ResolvedCritThreshold[] = failResolved.length > 0 ? failResolved : ['default'];
|
|
1235
1274
|
|
|
1236
|
-
applyCritThresholds(targetCtx.rolls, successApplied, failApplied);
|
|
1275
|
+
applyCritThresholds(targetCtx.rolls, successApplied, failApplied, env);
|
|
1237
1276
|
|
|
1238
1277
|
appendAll(ctx.rolls, targetCtx.rolls);
|
|
1239
1278
|
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
1240
1279
|
|
|
1241
|
-
const
|
|
1242
|
-
const codes = [
|
|
1280
|
+
const modifierExpr = [
|
|
1243
1281
|
...successResolved.map((t) => (t === 'default' ? 'cs' : `cs${t.operator}${t.value}`)),
|
|
1244
1282
|
...failResolved.map((t) => (t === 'default' ? 'cf' : `cf${t.operator}${t.value}`)),
|
|
1245
|
-
].join('');
|
|
1283
|
+
].reduce(joinModifierCode, targetCtx.expressionParts.join(''));
|
|
1246
1284
|
|
|
1247
|
-
ctx.expressionParts.push(
|
|
1248
|
-
ctx.renderedParts.push(`${
|
|
1285
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1286
|
+
ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
|
|
1249
1287
|
|
|
1250
1288
|
return {
|
|
1251
1289
|
total: target.total,
|
|
@@ -1296,16 +1334,16 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1296
1334
|
const targetCtx = createContext();
|
|
1297
1335
|
const target = evalNode(baseTarget, rng, targetCtx, env);
|
|
1298
1336
|
|
|
1299
|
-
const mergedDice = mergeDropSets(targetCtx.rolls, specs);
|
|
1337
|
+
const mergedDice = mergeDropSets(targetCtx.rolls, specs, env.hasVersusDc);
|
|
1300
1338
|
|
|
1301
1339
|
appendAll(ctx.rolls, mergedDice);
|
|
1302
1340
|
|
|
1303
|
-
const total = sumKeptDice(mergedDice);
|
|
1341
|
+
const total = sumKeptDice(mergedDice, env.hasVersusDc);
|
|
1304
1342
|
|
|
1305
1343
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
1306
1344
|
const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
1307
1345
|
|
|
1308
|
-
ctx.expressionParts.push(
|
|
1346
|
+
ctx.expressionParts.push(joinModifierCode(targetExpr, keepDropCodes));
|
|
1309
1347
|
ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
|
|
1310
1348
|
|
|
1311
1349
|
return {
|
|
@@ -1328,10 +1366,17 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1328
1366
|
* success highlights inside a dropped span.
|
|
1329
1367
|
*/
|
|
1330
1368
|
function stripInnerMarkers(rendered: string): string {
|
|
1331
|
-
return rendered
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1369
|
+
return stripTallyMarkers(rendered).replace(/~~(-?\d+)~~/g, '$1');
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Strips success (`**`) and failure (`__`) markers from an already-rendered
|
|
1374
|
+
* sub-roll, leaving dropped dice struck. Pairs with a `TALLY_FLAGS` strip: the
|
|
1375
|
+
* tags and the text they produced have to go together, or `renderBreakdown`
|
|
1376
|
+
* stops reproducing `rendered`.
|
|
1377
|
+
*/
|
|
1378
|
+
function stripTallyMarkers(rendered: string): string {
|
|
1379
|
+
return rendered.replace(/\*\*(-?\d+)\*\*/g, '$1').replace(/__(-?\d+)__/g, '$1');
|
|
1335
1380
|
}
|
|
1336
1381
|
|
|
1337
1382
|
/**
|
|
@@ -1358,10 +1403,16 @@ function evalGroupKeepDrop(
|
|
|
1358
1403
|
expr: string;
|
|
1359
1404
|
rendered: string;
|
|
1360
1405
|
versusMetadata: EvalContext['versusMetadata'];
|
|
1406
|
+
scoredSuccesses: number;
|
|
1407
|
+
scoredFailures: number;
|
|
1361
1408
|
};
|
|
1362
1409
|
|
|
1363
1410
|
const subRolls: SubRoll[] = group.expressions.map((expr) => {
|
|
1364
1411
|
const subCtx = createContext();
|
|
1412
|
+
// What a subtotal count inside this sub-roll scored, so a drop can take it
|
|
1413
|
+
// back — the tally counterpart of the `TALLY_FLAGS` rewrite below.
|
|
1414
|
+
const successTally = env.subtotalSuccesses;
|
|
1415
|
+
const failureTally = env.subtotalFailures;
|
|
1365
1416
|
const sub = evalNode(expr, rng, subCtx, env);
|
|
1366
1417
|
return {
|
|
1367
1418
|
subtotal: sub.total,
|
|
@@ -1370,6 +1421,8 @@ function evalGroupKeepDrop(
|
|
|
1370
1421
|
expr: subCtx.expressionParts.join(''),
|
|
1371
1422
|
rendered: subCtx.renderedParts.join(''),
|
|
1372
1423
|
versusMetadata: subCtx.versusMetadata,
|
|
1424
|
+
scoredSuccesses: env.subtotalSuccesses - successTally,
|
|
1425
|
+
scoredFailures: env.subtotalFailures - failureTally,
|
|
1373
1426
|
};
|
|
1374
1427
|
});
|
|
1375
1428
|
|
|
@@ -1383,7 +1436,7 @@ function evalGroupKeepDrop(
|
|
|
1383
1436
|
fumble: false,
|
|
1384
1437
|
}));
|
|
1385
1438
|
|
|
1386
|
-
const mergedSynthetic = mergeDropSets(syntheticDice, specs);
|
|
1439
|
+
const mergedSynthetic = mergeDropSets(syntheticDice, specs, env.hasVersusDc);
|
|
1387
1440
|
|
|
1388
1441
|
const outerRendered: string[] = [];
|
|
1389
1442
|
const keptIndices: number[] = [];
|
|
@@ -1402,6 +1455,10 @@ function evalGroupKeepDrop(
|
|
|
1402
1455
|
for (const die of sub.rolls) {
|
|
1403
1456
|
die.modifiers = rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'dropped');
|
|
1404
1457
|
}
|
|
1458
|
+
// A count on subtotals left no tag for the rewrite above to strip, so its
|
|
1459
|
+
// verdicts come back from the env tally instead.
|
|
1460
|
+
env.subtotalSuccesses -= sub.scoredSuccesses;
|
|
1461
|
+
env.subtotalFailures -= sub.scoredFailures;
|
|
1405
1462
|
appendAll(ctx.rolls, sub.rolls);
|
|
1406
1463
|
outerRendered.push(`~~${stripInnerMarkers(sub.rendered)}~~`);
|
|
1407
1464
|
} else {
|
|
@@ -1489,14 +1546,24 @@ function evalSuccessCount(
|
|
|
1489
1546
|
ctx: EvalContext,
|
|
1490
1547
|
env: EvalEnv,
|
|
1491
1548
|
): EvalResult {
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
1494
|
-
|
|
1549
|
+
// Rolled back below, so a subtotal count nested in the target
|
|
1550
|
+
// (`{{2d6, 2d6}>=10, 1d8}>=1`) is not reported alongside the subtotal this
|
|
1551
|
+
// pass re-scores it into.
|
|
1552
|
+
const successTallyBefore = env.subtotalSuccesses;
|
|
1553
|
+
const failureTallyBefore = env.subtotalFailures;
|
|
1495
1554
|
|
|
1496
1555
|
const targetCtx = createContext();
|
|
1497
1556
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1498
1557
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
1499
1558
|
|
|
1559
|
+
// True once any count has run: an inner one that tagged this very pool (only
|
|
1560
|
+
// a group can arrange that — `{4d6>=5}<=2f5`), or an unrelated earlier one,
|
|
1561
|
+
// which costs a redundant strip over dice nothing tagged. The flag otherwise
|
|
1562
|
+
// tracks syntactic presence of success-count notation, not pool size — set
|
|
1563
|
+
// before any early return so empty pools still populate successes/failures.
|
|
1564
|
+
const poolAlreadyCounted = env.hasSuccessCount;
|
|
1565
|
+
env.hasSuccessCount = true;
|
|
1566
|
+
|
|
1500
1567
|
const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
|
|
1501
1568
|
const failValue =
|
|
1502
1569
|
node.failThreshold != null
|
|
@@ -1514,6 +1581,7 @@ function evalSuccessCount(
|
|
|
1514
1581
|
const part: RollPart = {
|
|
1515
1582
|
type: 'successCount',
|
|
1516
1583
|
threshold: { operator: node.threshold.operator, value: thresholdValue },
|
|
1584
|
+
rolls: targetCtx.rolls,
|
|
1517
1585
|
target: target.part,
|
|
1518
1586
|
successes,
|
|
1519
1587
|
failures,
|
|
@@ -1526,26 +1594,66 @@ function evalSuccessCount(
|
|
|
1526
1594
|
return part;
|
|
1527
1595
|
};
|
|
1528
1596
|
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
1531
|
-
//
|
|
1532
|
-
|
|
1597
|
+
// Multi-sub-roll group: the units are sub-roll subtotals, not dice. The
|
|
1598
|
+
// `sides = 0` synthetics are `evalGroupKeepDrop`'s sentinel and never reach
|
|
1599
|
+
// `ctx.rolls`. Only a direct group target arrives here — the parser refuses
|
|
1600
|
+
// every form that would reach the count with the subtotals already gone.
|
|
1601
|
+
const bySubtotal = node.target.type === 'Group' && node.target.expressions.length >= 2;
|
|
1602
|
+
const pool: DieResult[] = bySubtotal
|
|
1603
|
+
? (target.part as Extract<RollPart, { type: 'group' }>).parts.map((sub) => ({
|
|
1604
|
+
sides: 0,
|
|
1605
|
+
result: sub.total,
|
|
1606
|
+
modifiers: [],
|
|
1607
|
+
critical: false,
|
|
1608
|
+
fumble: false,
|
|
1609
|
+
}))
|
|
1610
|
+
: targetCtx.rolls;
|
|
1611
|
+
|
|
1612
|
+
// ! Releases every die an inner count tagged, `vs` DC dice included, though
|
|
1613
|
+
// ! `countSuccesses` spares those. The marker strip below reads rendered text
|
|
1614
|
+
// ! and cannot tell a DC die apart, so sparing one here leaves a tag whose
|
|
1615
|
+
// ! `**` is already gone and `renderBreakdown` stops reproducing `rendered`.
|
|
1616
|
+
if (bySubtotal && poolAlreadyCounted) {
|
|
1617
|
+
for (const die of targetCtx.rolls) {
|
|
1618
|
+
die.modifiers = stripFlags(die.modifiers, TALLY_FLAGS);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// An empty pool (`0d6>=4`) scores zero of both, so its total is 0 — never
|
|
1623
|
+
// `target.total`, which would break `total === successes - failures`.
|
|
1624
|
+
// Reachable only through a zero-count pool — a target holding no dice node at
|
|
1625
|
+
// all is rejected at parse time.
|
|
1626
|
+
if (pool.length === 0) {
|
|
1533
1627
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1534
1628
|
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
1535
|
-
return { total:
|
|
1629
|
+
return { total: 0, part: buildPart(0, 0, 0) };
|
|
1536
1630
|
}
|
|
1537
1631
|
|
|
1538
1632
|
const result = countSuccesses(
|
|
1539
|
-
|
|
1633
|
+
pool,
|
|
1540
1634
|
{ operator: node.threshold.operator, value: thresholdValue },
|
|
1541
1635
|
failValue != null && node.failThreshold != null
|
|
1542
1636
|
? { operator: node.failThreshold.operator, value: failValue }
|
|
1543
1637
|
: undefined,
|
|
1638
|
+
!bySubtotal && env.hasVersusDc,
|
|
1639
|
+
!bySubtotal && poolAlreadyCounted,
|
|
1544
1640
|
);
|
|
1545
1641
|
|
|
1642
|
+
if (bySubtotal) {
|
|
1643
|
+
env.subtotalSuccesses = successTallyBefore + result.successes;
|
|
1644
|
+
env.subtotalFailures = failureTallyBefore + result.failures;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1546
1647
|
appendAll(ctx.rolls, targetCtx.rolls);
|
|
1547
1648
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1548
|
-
|
|
1649
|
+
// A subtotal count renders through the group — its sub-rolls carry their own
|
|
1650
|
+
// brackets, and one flat bracket would spell out the units it never used. The
|
|
1651
|
+
// strip pairs with the tag release above: markers and tags go together.
|
|
1652
|
+
ctx.renderedParts.push(
|
|
1653
|
+
bySubtotal
|
|
1654
|
+
? `${stripTallyMarkers(targetCtx.renderedParts.join(''))}${code}`
|
|
1655
|
+
: `${targetExpr}${code}${renderDice(targetCtx.rolls)}`,
|
|
1656
|
+
);
|
|
1549
1657
|
|
|
1550
1658
|
return {
|
|
1551
1659
|
total: result.total,
|
|
@@ -1563,22 +1671,18 @@ function evalSuccessCount(
|
|
|
1563
1671
|
* `undefined`.
|
|
1564
1672
|
*
|
|
1565
1673
|
* Excludes dropped (`kh`/`kl`/`dh`/`dl`/`r`/`ro`) dice — these aren't the
|
|
1566
|
-
* final kept result
|
|
1567
|
-
*
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1570
|
-
* `
|
|
1571
|
-
* Multiple primary kept d20s (e.g., `1d20+1d20`) yield `undefined` so no
|
|
1572
|
-
* ambiguous upgrade/downgrade is applied.
|
|
1674
|
+
* final kept result — and the continuation dice `env.explosionDice` marks, so
|
|
1675
|
+
* `1d20! vs DC` keeps the natural from the original d20. A compound explode
|
|
1676
|
+
* accumulates into that original instead of appending, so it stays a primary
|
|
1677
|
+
* and its raw first face is used. Multiple primary kept d20s (e.g.,
|
|
1678
|
+
* `1d20+1d20`) yield `undefined` so no ambiguous upgrade/downgrade is applied.
|
|
1573
1679
|
*/
|
|
1574
|
-
function extractNatural(rolls: DieResult[]): number | undefined {
|
|
1680
|
+
function extractNatural(rolls: DieResult[], env: EvalEnv): number | undefined {
|
|
1575
1681
|
// Rerolled intermediates are always stamped `['rerolled', 'dropped']`
|
|
1576
1682
|
// (see `modifiers/reroll.ts`), so filtering by `'dropped'` covers them.
|
|
1683
|
+
const appended = env.explosionDice;
|
|
1577
1684
|
const primaries = rolls.filter(
|
|
1578
|
-
(d) =>
|
|
1579
|
-
d.sides === 20 &&
|
|
1580
|
-
!d.modifiers.includes('dropped') &&
|
|
1581
|
-
!(d.modifiers.includes('exploded') && d.initialResult == null),
|
|
1685
|
+
(d) => d.sides === 20 && !d.modifiers.includes('dropped') && !appended?.has(d),
|
|
1582
1686
|
);
|
|
1583
1687
|
if (primaries.length !== 1) return undefined;
|
|
1584
1688
|
const die = primaries[0];
|
|
@@ -1624,19 +1728,26 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1624
1728
|
try {
|
|
1625
1729
|
const rollCtx = createContext();
|
|
1626
1730
|
const rollResult = evalNode(node.roll, rng, rollCtx, env);
|
|
1627
|
-
const natural = extractNatural(rollCtx.rolls);
|
|
1731
|
+
const natural = extractNatural(rollCtx.rolls, env);
|
|
1628
1732
|
|
|
1629
1733
|
const dcCtx = createContext();
|
|
1630
|
-
|
|
1734
|
+
// A subtotal count on the DC side (`1d20 vs {{2d6, 2d6}>=10}`) is rolled
|
|
1735
|
+
// back for the same reason `countTaggedDice` skips DC dice: no pool pass
|
|
1736
|
+
// may tally that side.
|
|
1737
|
+
const dcResult = evalDiscardingSubtotals(node.dc, rng, dcCtx, env);
|
|
1631
1738
|
|
|
1632
1739
|
const degree = calculateDegree(rollResult.total, dcResult.total, natural);
|
|
1633
1740
|
|
|
1634
1741
|
appendAll(ctx.rolls, rollCtx.rolls);
|
|
1635
1742
|
// ! Tag before merging: past this point the DC dice are indistinguishable
|
|
1636
1743
|
// ! from the roll side, and every pool modifier walks the merged array.
|
|
1744
|
+
// ! Bare push, deliberately: `insideVersus` above rejects every nesting, so
|
|
1745
|
+
// ! each die reaches exactly one `dcCtx` and cannot be tagged twice.
|
|
1637
1746
|
for (const die of dcCtx.rolls) {
|
|
1638
1747
|
die.modifiers.push('dc');
|
|
1639
1748
|
}
|
|
1749
|
+
// Arms the exclusion checks every enclosing pool operation skips by default.
|
|
1750
|
+
if (dcCtx.rolls.length > 0) env.hasVersusDc = true;
|
|
1640
1751
|
appendAll(ctx.rolls, dcCtx.rolls);
|
|
1641
1752
|
|
|
1642
1753
|
const rollExpr = rollCtx.expressionParts.join('');
|
|
@@ -1729,7 +1840,12 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
1729
1840
|
maxRerollIterations,
|
|
1730
1841
|
totalDiceRolled: 0,
|
|
1731
1842
|
hasSuccessCount: false,
|
|
1843
|
+
subtotalSuccesses: 0,
|
|
1844
|
+
subtotalFailures: 0,
|
|
1732
1845
|
insideVersus: false,
|
|
1846
|
+
hasVersusDc: false,
|
|
1847
|
+
critRules: undefined,
|
|
1848
|
+
explosionDice: undefined,
|
|
1733
1849
|
context,
|
|
1734
1850
|
onMissingVariable,
|
|
1735
1851
|
};
|
|
@@ -1761,21 +1877,27 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
1761
1877
|
rendered,
|
|
1762
1878
|
rolls: ctx.rolls,
|
|
1763
1879
|
parts: part,
|
|
1764
|
-
...(env.hasSuccessCount ? countTaggedDice(ctx.rolls) : {}),
|
|
1880
|
+
...(env.hasSuccessCount ? countTaggedDice(ctx.rolls, env) : {}),
|
|
1765
1881
|
...(versus ? { degree: versus.degree } : {}),
|
|
1766
1882
|
...(versus?.natural != null ? { natural: versus.natural } : {}),
|
|
1767
1883
|
};
|
|
1768
1884
|
}
|
|
1769
1885
|
|
|
1770
|
-
/**
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1886
|
+
/**
|
|
1887
|
+
* Tallies the `'success'` / `'failure'` tags across a whole roll, on top of
|
|
1888
|
+
* what a group count scored on subtotals — those carry no tag to find.
|
|
1889
|
+
*/
|
|
1890
|
+
function countTaggedDice(
|
|
1891
|
+
rolls: DieResult[],
|
|
1892
|
+
env: EvalEnv,
|
|
1893
|
+
): { successes: number; failures: number } {
|
|
1894
|
+
let successes = env.subtotalSuccesses;
|
|
1895
|
+
let failures = env.subtotalFailures;
|
|
1774
1896
|
|
|
1775
1897
|
for (const die of rolls) {
|
|
1776
1898
|
// A success-count inside the DC sub-expression tags its own dice before
|
|
1777
1899
|
// `evalVersus` marks them `'dc'`, so they arrive here already tagged.
|
|
1778
|
-
if (isVersusDc(die)) continue;
|
|
1900
|
+
if (env.hasVersusDc && isVersusDc(die)) continue;
|
|
1779
1901
|
if (die.modifiers.includes('success')) successes += 1;
|
|
1780
1902
|
else if (die.modifiers.includes('failure')) failures += 1;
|
|
1781
1903
|
}
|