roll-parser 3.1.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 +26 -1
- package/MIGRATION.md +225 -8
- package/README.md +185 -60
- package/dist/evaluator/env.d.ts +27 -1
- 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 +80 -40
- package/dist/evaluator/evaluator.js.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.d.ts +13 -28
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.js +6 -12
- package/dist/evaluator/modifiers/crit-threshold.js.map +1 -1
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/explode.js +7 -1
- package/dist/evaluator/modifiers/explode.js.map +1 -1
- package/dist/evaluator/modifiers/flags.d.ts +9 -2
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
- package/dist/evaluator/modifiers/flags.js +4 -11
- package/dist/evaluator/modifiers/flags.js.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.d.ts +1 -3
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.js.map +1 -1
- package/dist/evaluator/modifiers/success-count.d.ts +6 -5
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +9 -6
- 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/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.map +1 -1
- package/dist/render.js +18 -13
- package/dist/render.js.map +1 -1
- package/dist/types.d.ts +15 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
- package/src/evaluator/env.ts +27 -1
- package/src/evaluator/evaluator.ts +148 -53
- package/src/evaluator/modifiers/crit-threshold.ts +29 -48
- package/src/evaluator/modifiers/explode.ts +13 -4
- package/src/evaluator/modifiers/flags.ts +13 -13
- package/src/evaluator/modifiers/keep-drop.ts +1 -3
- package/src/evaluator/modifiers/sort.ts +1 -1
- package/src/evaluator/modifiers/success-count.ts +19 -12
- package/src/notation.ts +24 -0
- package/src/parser/guards.ts +92 -1
- package/src/parser/parser.ts +56 -1
- package/src/render.ts +24 -22
- package/src/types.ts +15 -3
- 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,
|
|
@@ -59,6 +60,8 @@ import {
|
|
|
59
60
|
rewriteFlags,
|
|
60
61
|
SELECTION_AND_TALLY_FLAGS,
|
|
61
62
|
SELECTION_FLAGS,
|
|
63
|
+
stripFlags,
|
|
64
|
+
TALLY_FLAGS,
|
|
62
65
|
} from './modifiers/flags.js';
|
|
63
66
|
import { markDroppedIndices, sumKeptDice } from './modifiers/keep-drop.js';
|
|
64
67
|
import {
|
|
@@ -480,11 +483,31 @@ function evalMetaOperand(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
480
483
|
}
|
|
481
484
|
|
|
482
485
|
const metaCtx = createContext();
|
|
483
|
-
|
|
486
|
+
// The tally counterpart of the `TALLY_FLAGS` strip in `mergeMetaRolls`.
|
|
487
|
+
const value = evalDiscardingSubtotals(node, rng, metaCtx, env).total;
|
|
484
488
|
mergeMetaRolls(ctx, metaCtx);
|
|
485
489
|
return value;
|
|
486
490
|
}
|
|
487
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
|
+
|
|
488
511
|
/** Rejects dice counts that cannot address a pool. */
|
|
489
512
|
function requireDiceCount(count: number, nodeType: 'Dice' | 'FateDice'): void {
|
|
490
513
|
if (!Number.isInteger(count) || count < 0) {
|
|
@@ -1067,11 +1090,11 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
1067
1090
|
function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
1068
1091
|
const targetCtx = createContext();
|
|
1069
1092
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1070
|
-
const targetExpr = targetCtx.expressionParts.join('');
|
|
1071
1093
|
|
|
1072
1094
|
const thresholdValue = evalMetaOperand(node.condition.value, rng, ctx, env);
|
|
1073
1095
|
|
|
1074
1096
|
const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
|
|
1097
|
+
const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
|
|
1075
1098
|
const condition: ResolvedComparePoint = {
|
|
1076
1099
|
operator: node.condition.operator,
|
|
1077
1100
|
value: thresholdValue,
|
|
@@ -1079,8 +1102,8 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1079
1102
|
|
|
1080
1103
|
// No-op when the target produced no dice (e.g., `(1+2)r<5`).
|
|
1081
1104
|
if (targetCtx.rolls.length === 0) {
|
|
1082
|
-
ctx.expressionParts.push(
|
|
1083
|
-
ctx.renderedParts.push(
|
|
1105
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1106
|
+
ctx.renderedParts.push(modifierExpr);
|
|
1084
1107
|
const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
|
|
1085
1108
|
return {
|
|
1086
1109
|
total,
|
|
@@ -1101,8 +1124,8 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1101
1124
|
: applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
|
|
1102
1125
|
|
|
1103
1126
|
appendAll(ctx.rolls, pool);
|
|
1104
|
-
ctx.expressionParts.push(
|
|
1105
|
-
ctx.renderedParts.push(`${
|
|
1127
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1128
|
+
ctx.renderedParts.push(`${modifierExpr}${renderDice(pool)}`);
|
|
1106
1129
|
|
|
1107
1130
|
const total = sumKeptDice(pool, env.hasVersusDc);
|
|
1108
1131
|
return {
|
|
@@ -1145,13 +1168,13 @@ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1145
1168
|
// ! would have been resolved against a total this node just replaced.
|
|
1146
1169
|
// ! See the rule on `propagateMetadata`.
|
|
1147
1170
|
|
|
1148
|
-
const targetExpr = targetCtx.expressionParts.join('');
|
|
1149
1171
|
// Negative bounds render parenthesized so `result.expression` re-parses
|
|
1150
1172
|
// (`4d6min-2` is a syntax error; `4d6min(-2)` is not).
|
|
1151
1173
|
const code = boundValue < 0 ? `${node.bound}(${boundValue})` : `${node.bound}${boundValue}`;
|
|
1174
|
+
const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
|
|
1152
1175
|
|
|
1153
|
-
ctx.expressionParts.push(
|
|
1154
|
-
ctx.renderedParts.push(`${
|
|
1176
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1177
|
+
ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
|
|
1155
1178
|
|
|
1156
1179
|
const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
|
|
1157
1180
|
return {
|
|
@@ -1193,10 +1216,10 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
|
|
|
1193
1216
|
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
1194
1217
|
|
|
1195
1218
|
const code = node.order === 'ascending' ? 's' : 'sd';
|
|
1196
|
-
const
|
|
1219
|
+
const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
|
|
1197
1220
|
|
|
1198
|
-
ctx.expressionParts.push(
|
|
1199
|
-
ctx.renderedParts.push(`${
|
|
1221
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1222
|
+
ctx.renderedParts.push(`${modifierExpr}${renderDice(sortedRolls)}`);
|
|
1200
1223
|
|
|
1201
1224
|
return {
|
|
1202
1225
|
total: target.total,
|
|
@@ -1254,14 +1277,13 @@ function evalCritThreshold(
|
|
|
1254
1277
|
appendAll(ctx.rolls, targetCtx.rolls);
|
|
1255
1278
|
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
1256
1279
|
|
|
1257
|
-
const
|
|
1258
|
-
const codes = [
|
|
1280
|
+
const modifierExpr = [
|
|
1259
1281
|
...successResolved.map((t) => (t === 'default' ? 'cs' : `cs${t.operator}${t.value}`)),
|
|
1260
1282
|
...failResolved.map((t) => (t === 'default' ? 'cf' : `cf${t.operator}${t.value}`)),
|
|
1261
|
-
].join('');
|
|
1283
|
+
].reduce(joinModifierCode, targetCtx.expressionParts.join(''));
|
|
1262
1284
|
|
|
1263
|
-
ctx.expressionParts.push(
|
|
1264
|
-
ctx.renderedParts.push(`${
|
|
1285
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1286
|
+
ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
|
|
1265
1287
|
|
|
1266
1288
|
return {
|
|
1267
1289
|
total: target.total,
|
|
@@ -1321,7 +1343,7 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1321
1343
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
1322
1344
|
const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
1323
1345
|
|
|
1324
|
-
ctx.expressionParts.push(
|
|
1346
|
+
ctx.expressionParts.push(joinModifierCode(targetExpr, keepDropCodes));
|
|
1325
1347
|
ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
|
|
1326
1348
|
|
|
1327
1349
|
return {
|
|
@@ -1344,10 +1366,17 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1344
1366
|
* success highlights inside a dropped span.
|
|
1345
1367
|
*/
|
|
1346
1368
|
function stripInnerMarkers(rendered: string): string {
|
|
1347
|
-
return rendered
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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');
|
|
1351
1380
|
}
|
|
1352
1381
|
|
|
1353
1382
|
/**
|
|
@@ -1374,10 +1403,16 @@ function evalGroupKeepDrop(
|
|
|
1374
1403
|
expr: string;
|
|
1375
1404
|
rendered: string;
|
|
1376
1405
|
versusMetadata: EvalContext['versusMetadata'];
|
|
1406
|
+
scoredSuccesses: number;
|
|
1407
|
+
scoredFailures: number;
|
|
1377
1408
|
};
|
|
1378
1409
|
|
|
1379
1410
|
const subRolls: SubRoll[] = group.expressions.map((expr) => {
|
|
1380
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;
|
|
1381
1416
|
const sub = evalNode(expr, rng, subCtx, env);
|
|
1382
1417
|
return {
|
|
1383
1418
|
subtotal: sub.total,
|
|
@@ -1386,6 +1421,8 @@ function evalGroupKeepDrop(
|
|
|
1386
1421
|
expr: subCtx.expressionParts.join(''),
|
|
1387
1422
|
rendered: subCtx.renderedParts.join(''),
|
|
1388
1423
|
versusMetadata: subCtx.versusMetadata,
|
|
1424
|
+
scoredSuccesses: env.subtotalSuccesses - successTally,
|
|
1425
|
+
scoredFailures: env.subtotalFailures - failureTally,
|
|
1389
1426
|
};
|
|
1390
1427
|
});
|
|
1391
1428
|
|
|
@@ -1418,6 +1455,10 @@ function evalGroupKeepDrop(
|
|
|
1418
1455
|
for (const die of sub.rolls) {
|
|
1419
1456
|
die.modifiers = rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'dropped');
|
|
1420
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;
|
|
1421
1462
|
appendAll(ctx.rolls, sub.rolls);
|
|
1422
1463
|
outerRendered.push(`~~${stripInnerMarkers(sub.rendered)}~~`);
|
|
1423
1464
|
} else {
|
|
@@ -1505,14 +1546,24 @@ function evalSuccessCount(
|
|
|
1505
1546
|
ctx: EvalContext,
|
|
1506
1547
|
env: EvalEnv,
|
|
1507
1548
|
): EvalResult {
|
|
1508
|
-
//
|
|
1509
|
-
//
|
|
1510
|
-
|
|
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;
|
|
1511
1554
|
|
|
1512
1555
|
const targetCtx = createContext();
|
|
1513
1556
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1514
1557
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
1515
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
|
+
|
|
1516
1567
|
const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
|
|
1517
1568
|
const failValue =
|
|
1518
1569
|
node.failThreshold != null
|
|
@@ -1543,27 +1594,66 @@ function evalSuccessCount(
|
|
|
1543
1594
|
return part;
|
|
1544
1595
|
};
|
|
1545
1596
|
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
|
|
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) {
|
|
1550
1627
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1551
1628
|
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
1552
|
-
return { total:
|
|
1629
|
+
return { total: 0, part: buildPart(0, 0, 0) };
|
|
1553
1630
|
}
|
|
1554
1631
|
|
|
1555
1632
|
const result = countSuccesses(
|
|
1556
|
-
|
|
1633
|
+
pool,
|
|
1557
1634
|
{ operator: node.threshold.operator, value: thresholdValue },
|
|
1558
1635
|
failValue != null && node.failThreshold != null
|
|
1559
1636
|
? { operator: node.failThreshold.operator, value: failValue }
|
|
1560
1637
|
: undefined,
|
|
1561
|
-
env.hasVersusDc,
|
|
1638
|
+
!bySubtotal && env.hasVersusDc,
|
|
1639
|
+
!bySubtotal && poolAlreadyCounted,
|
|
1562
1640
|
);
|
|
1563
1641
|
|
|
1642
|
+
if (bySubtotal) {
|
|
1643
|
+
env.subtotalSuccesses = successTallyBefore + result.successes;
|
|
1644
|
+
env.subtotalFailures = failureTallyBefore + result.failures;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1564
1647
|
appendAll(ctx.rolls, targetCtx.rolls);
|
|
1565
1648
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1566
|
-
|
|
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
|
+
);
|
|
1567
1657
|
|
|
1568
1658
|
return {
|
|
1569
1659
|
total: result.total,
|
|
@@ -1581,22 +1671,18 @@ function evalSuccessCount(
|
|
|
1581
1671
|
* `undefined`.
|
|
1582
1672
|
*
|
|
1583
1673
|
* Excludes dropped (`kh`/`kl`/`dh`/`dl`/`r`/`ro`) dice — these aren't the
|
|
1584
|
-
* final kept result
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1588
|
-
* `
|
|
1589
|
-
* Multiple primary kept d20s (e.g., `1d20+1d20`) yield `undefined` so no
|
|
1590
|
-
* 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.
|
|
1591
1679
|
*/
|
|
1592
|
-
function extractNatural(rolls: DieResult[]): number | undefined {
|
|
1680
|
+
function extractNatural(rolls: DieResult[], env: EvalEnv): number | undefined {
|
|
1593
1681
|
// Rerolled intermediates are always stamped `['rerolled', 'dropped']`
|
|
1594
1682
|
// (see `modifiers/reroll.ts`), so filtering by `'dropped'` covers them.
|
|
1683
|
+
const appended = env.explosionDice;
|
|
1595
1684
|
const primaries = rolls.filter(
|
|
1596
|
-
(d) =>
|
|
1597
|
-
d.sides === 20 &&
|
|
1598
|
-
!d.modifiers.includes('dropped') &&
|
|
1599
|
-
!(d.modifiers.includes('exploded') && d.initialResult == null),
|
|
1685
|
+
(d) => d.sides === 20 && !d.modifiers.includes('dropped') && !appended?.has(d),
|
|
1600
1686
|
);
|
|
1601
1687
|
if (primaries.length !== 1) return undefined;
|
|
1602
1688
|
const die = primaries[0];
|
|
@@ -1642,10 +1728,13 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1642
1728
|
try {
|
|
1643
1729
|
const rollCtx = createContext();
|
|
1644
1730
|
const rollResult = evalNode(node.roll, rng, rollCtx, env);
|
|
1645
|
-
const natural = extractNatural(rollCtx.rolls);
|
|
1731
|
+
const natural = extractNatural(rollCtx.rolls, env);
|
|
1646
1732
|
|
|
1647
1733
|
const dcCtx = createContext();
|
|
1648
|
-
|
|
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);
|
|
1649
1738
|
|
|
1650
1739
|
const degree = calculateDegree(rollResult.total, dcResult.total, natural);
|
|
1651
1740
|
|
|
@@ -1751,9 +1840,12 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
1751
1840
|
maxRerollIterations,
|
|
1752
1841
|
totalDiceRolled: 0,
|
|
1753
1842
|
hasSuccessCount: false,
|
|
1843
|
+
subtotalSuccesses: 0,
|
|
1844
|
+
subtotalFailures: 0,
|
|
1754
1845
|
insideVersus: false,
|
|
1755
1846
|
hasVersusDc: false,
|
|
1756
1847
|
critRules: undefined,
|
|
1848
|
+
explosionDice: undefined,
|
|
1757
1849
|
context,
|
|
1758
1850
|
onMissingVariable,
|
|
1759
1851
|
};
|
|
@@ -1785,24 +1877,27 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
1785
1877
|
rendered,
|
|
1786
1878
|
rolls: ctx.rolls,
|
|
1787
1879
|
parts: part,
|
|
1788
|
-
...(env.hasSuccessCount ? countTaggedDice(ctx.rolls, env
|
|
1880
|
+
...(env.hasSuccessCount ? countTaggedDice(ctx.rolls, env) : {}),
|
|
1789
1881
|
...(versus ? { degree: versus.degree } : {}),
|
|
1790
1882
|
...(versus?.natural != null ? { natural: versus.natural } : {}),
|
|
1791
1883
|
};
|
|
1792
1884
|
}
|
|
1793
1885
|
|
|
1794
|
-
/**
|
|
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
|
+
*/
|
|
1795
1890
|
function countTaggedDice(
|
|
1796
1891
|
rolls: DieResult[],
|
|
1797
|
-
|
|
1892
|
+
env: EvalEnv,
|
|
1798
1893
|
): { successes: number; failures: number } {
|
|
1799
|
-
let successes =
|
|
1800
|
-
let failures =
|
|
1894
|
+
let successes = env.subtotalSuccesses;
|
|
1895
|
+
let failures = env.subtotalFailures;
|
|
1801
1896
|
|
|
1802
1897
|
for (const die of rolls) {
|
|
1803
1898
|
// A success-count inside the DC sub-expression tags its own dice before
|
|
1804
1899
|
// `evalVersus` marks them `'dc'`, so they arrive here already tagged.
|
|
1805
|
-
if (hasVersusDc && isVersusDc(die)) continue;
|
|
1900
|
+
if (env.hasVersusDc && isVersusDc(die)) continue;
|
|
1806
1901
|
if (die.modifiers.includes('success')) successes += 1;
|
|
1807
1902
|
else if (die.modifiers.includes('failure')) failures += 1;
|
|
1808
1903
|
}
|
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The two threshold kinds deliberately read different values. `'default'`
|
|
13
13
|
* reads `initialResult ?? result`, the same source as the versus `natural`,
|
|
14
|
-
* so "rolled the maximum face" survives
|
|
15
|
-
* `result` while recording the face
|
|
16
|
-
* `minN`/`maxN`. An explicit threshold (`cs>4`,
|
|
17
|
-
* current `result`: it is a predicate over the die's
|
|
18
|
-
* modifiers are order-sensitive by design, so `4d6min5cs>4`
|
|
19
|
-
* see the clamped faces. The two can therefore disagree on one
|
|
20
|
-
* `4d6min5cs>4` flags a clamped natural 1 as both critical and fumble.
|
|
14
|
+
* so "rolled the maximum face" survives every modifier that overwrites
|
|
15
|
+
* `result` while recording the face it replaced — compound explode,
|
|
16
|
+
* penetrating explode, and `minN`/`maxN`. An explicit threshold (`cs>4`,
|
|
17
|
+
* `cf<=2`) reads the die's current `result`: it is a predicate over the die's
|
|
18
|
+
* value, and postfix modifiers are order-sensitive by design, so `4d6min5cs>4`
|
|
19
|
+
* is meant to see the clamped faces. The two can therefore disagree on one
|
|
20
|
+
* die — `4d6min5cs>4` flags a clamped natural 1 as both critical and fumble.
|
|
21
21
|
*
|
|
22
22
|
* They also diverge on Fate dice. `'default'` carries a `sides > 1` guard, so
|
|
23
23
|
* it never fires on a `sides = 0` pool; an explicit threshold has none and
|
|
@@ -26,15 +26,6 @@
|
|
|
26
26
|
* `cs`/`cf` forms instead, since they would resolve to `'default'` and
|
|
27
27
|
* silently do nothing.
|
|
28
28
|
*
|
|
29
|
-
* ! Penetrating explode is not covered: it stores `raw - 1` in `result`
|
|
30
|
-
* ! without recording `initialResult`, so `1d6!pcs` still judges a natural
|
|
31
|
-
* ! 6 by its decremented 5. An *inherited* rule is handed the raw roll
|
|
32
|
-
* ! instead, which keeps `1d6cf>5!p` agreeing with `1d6!p` on the side the
|
|
33
|
-
* ! user never overrode — at the cost of `1d6cf>5!p` and `1d6!pcf>5`
|
|
34
|
-
* ! disagreeing on it. Recording `initialResult` here would settle both, but
|
|
35
|
-
* ! `extractNatural` reads that field to tell an appended explosion die from
|
|
36
|
-
* ! a compounded one, and would start counting these as versus primaries.
|
|
37
|
-
*
|
|
38
29
|
* The rule is recorded per die on `env.critRules`, so dice that explode and
|
|
39
30
|
* reroll mint *after* the crit node has run inherit it from the die they
|
|
40
31
|
* descended from. `cs`/`cf` therefore covers the whole pool wherever it sits
|
|
@@ -57,11 +48,8 @@ import { matchesCondition } from './compare.js';
|
|
|
57
48
|
import { isVersusDc } from './flags.js';
|
|
58
49
|
|
|
59
50
|
/**
|
|
60
|
-
* Applies success/fail threshold arrays to a dice pool, overriding each
|
|
61
|
-
*
|
|
62
|
-
* `initialResult ?? result`, a die matches `'default'` on the success side
|
|
63
|
-
* when `natural === sides && sides > 1`, and on the fail side when
|
|
64
|
-
* `natural === 1 && sides > 1`. Meta dice are skipped.
|
|
51
|
+
* Applies success/fail threshold arrays to a dice pool, overriding each die's
|
|
52
|
+
* `critical` and `fumble` flags in place. Meta and DC dice are skipped.
|
|
65
53
|
*
|
|
66
54
|
* Also records the rule against every die it touched, so later explode and
|
|
67
55
|
* reroll dice can inherit it via {@link inheritCritRule}.
|
|
@@ -88,12 +76,11 @@ export function applyCritThresholds(
|
|
|
88
76
|
/**
|
|
89
77
|
* Judges one die by a recorded rule, overwriting both flags. A side always
|
|
90
78
|
* carries at least `'default'` — `evalCritThreshold` fills the side the user
|
|
91
|
-
* left out — so neither flag is silently left untouched.
|
|
92
|
-
* the `'default'` sentinel reads; an explicit threshold always reads `result`.
|
|
79
|
+
* left out — so neither flag is silently left untouched.
|
|
93
80
|
*/
|
|
94
81
|
function applyCritRule(die: DieResult, rule: CritRule, natural: number): void {
|
|
95
|
-
die.critical = rule.success.some((t) =>
|
|
96
|
-
die.fumble = rule.fail.some((t) =>
|
|
82
|
+
die.critical = rule.success.some((t) => matchesThreshold(t, die, natural, die.sides));
|
|
83
|
+
die.fumble = rule.fail.some((t) => matchesThreshold(t, die, natural, 1));
|
|
97
84
|
}
|
|
98
85
|
|
|
99
86
|
/**
|
|
@@ -102,40 +89,34 @@ function applyCritRule(die: DieResult, rule: CritRule, natural: number): void {
|
|
|
102
89
|
* reroll inherits it in turn. No-op when no `cs`/`cf` governs `parent`, which
|
|
103
90
|
* leaves the `createDieResult` default rule in place.
|
|
104
91
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* ! Call this only once the child's final `result` is stored — an explicit
|
|
109
|
-
* ! threshold is a predicate over `result`, so a penetrating die is judged by
|
|
110
|
-
* ! its decremented value, matching `1d6!pcs<2`.
|
|
92
|
+
* ! Call this only once the child's final `result` and `initialResult` are
|
|
93
|
+
* ! stored — an explicit threshold is a predicate over `result`, so a
|
|
94
|
+
* ! penetrating die is judged by its decremented value, matching `1d6!pcs<2`.
|
|
111
95
|
*/
|
|
112
|
-
export function inheritCritRule(
|
|
113
|
-
env: EvalEnv,
|
|
114
|
-
parent: DieResult,
|
|
115
|
-
child: DieResult,
|
|
116
|
-
natural = child.initialResult ?? child.result,
|
|
117
|
-
): void {
|
|
96
|
+
export function inheritCritRule(env: EvalEnv, parent: DieResult, child: DieResult): void {
|
|
118
97
|
const rules = env.critRules;
|
|
119
98
|
if (rules === undefined) return;
|
|
120
99
|
|
|
121
100
|
const rule = rules.get(parent);
|
|
122
101
|
if (rule === undefined) return;
|
|
123
102
|
|
|
124
|
-
applyCritRule(child, rule,
|
|
103
|
+
applyCritRule(child, rule, child.initialResult ?? child.result);
|
|
125
104
|
rules.set(child, rule);
|
|
126
105
|
}
|
|
127
106
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
107
|
+
/**
|
|
108
|
+
* `defaultFace` is the face the `'default'` sentinel looks for — `die.sides`
|
|
109
|
+
* on the success side, `1` on the fail side. The `sides > 1` guard mirrors
|
|
110
|
+
* `createDieResult`: a d1 always rolls 1, so it is neither.
|
|
111
|
+
*/
|
|
112
|
+
function matchesThreshold(
|
|
113
|
+
threshold: ResolvedCritThreshold,
|
|
114
|
+
die: DieResult,
|
|
115
|
+
natural: number,
|
|
116
|
+
defaultFace: number,
|
|
117
|
+
): boolean {
|
|
136
118
|
if (threshold === 'default') {
|
|
137
|
-
|
|
138
|
-
return natural === 1 && die.sides > 1;
|
|
119
|
+
return natural === defaultFace && die.sides > 1;
|
|
139
120
|
}
|
|
140
121
|
return matchesCondition(die.result, threshold.operator, threshold.value);
|
|
141
122
|
}
|
|
@@ -98,11 +98,14 @@ function canExplode(die: DieResult, hasVersusDc: boolean): boolean {
|
|
|
98
98
|
* the value recorded on the appended die, which is the only thing standard
|
|
99
99
|
* and penetrating explosions disagree about.
|
|
100
100
|
*
|
|
101
|
+
* A die whose stored value differs from its raw roll records the raw face in
|
|
102
|
+
* `initialResult`, so `initialResult ?? result` recovers what was rolled.
|
|
103
|
+
*
|
|
101
104
|
* `critical`/`fumble` are likewise derived from the raw roll — a penetrating
|
|
102
105
|
* die that rolled its max face is still a crit even though it stores one less.
|
|
103
|
-
*
|
|
104
|
-
* explicit threshold reads the stored value
|
|
105
|
-
*
|
|
106
|
+
* A `cs`/`cf` rule keeps that: it is applied after `storeResult`, so an
|
|
107
|
+
* explicit threshold reads the stored value while the `'default'` sentinel
|
|
108
|
+
* reads `initialResult`.
|
|
106
109
|
*/
|
|
107
110
|
function applyAppendingExplode(
|
|
108
111
|
pool: DieResult[],
|
|
@@ -128,7 +131,13 @@ function applyAppendingExplode(
|
|
|
128
131
|
const raw = rollExplosion(sides, rng, env);
|
|
129
132
|
const die = createDieResult(sides, raw, ['exploded', 'kept']);
|
|
130
133
|
die.result = storeResult(raw);
|
|
131
|
-
|
|
134
|
+
if (die.result !== raw) die.initialResult = raw;
|
|
135
|
+
// Only `extractNatural` reads this, and only inside a `vs`.
|
|
136
|
+
if (env.insideVersus) {
|
|
137
|
+
env.explosionDice ??= new WeakSet();
|
|
138
|
+
env.explosionDice.add(die);
|
|
139
|
+
}
|
|
140
|
+
inheritCritRule(env, original, die);
|
|
132
141
|
result.push(die);
|
|
133
142
|
lastRaw = raw;
|
|
134
143
|
iterations += 1;
|
|
@@ -22,8 +22,8 @@ import type { DieModifier, DieResult } from '../../types.js';
|
|
|
22
22
|
* `{1d20 vs 2d10, 1d4}>=5` must not count the DC faces as successes.
|
|
23
23
|
*
|
|
24
24
|
* ! Call this behind the shared `hasVersusDc` env flag, never bare. Unguarded
|
|
25
|
-
* ! inside a per-die loop it cost 11-38% on notation that cannot carry the
|
|
26
|
-
* !
|
|
25
|
+
* ! inside a per-die loop it cost 11-38% on notation that cannot carry the
|
|
26
|
+
* ! tag; the flag is `false` until a `vs` has actually tagged something.
|
|
27
27
|
*/
|
|
28
28
|
export function isVersusDc(die: DieResult): boolean {
|
|
29
29
|
return die.modifiers.includes('dc');
|
|
@@ -32,16 +32,22 @@ export function isVersusDc(die: DieResult): boolean {
|
|
|
32
32
|
/** Kept/dropped selection flags — rebuilt by every keep/drop pass. */
|
|
33
33
|
export const SELECTION_FLAGS: readonly DieModifier[] = ['kept', 'dropped'];
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Success-count tally flags — rebuilt by every counting pass. A group lets a
|
|
37
|
+
* second count reach a pool the first one already tagged (`{4d6>=5}<=2f5`);
|
|
38
|
+
* stripping these first is what keeps the outermost count the only one the
|
|
39
|
+
* tags describe.
|
|
40
|
+
*/
|
|
41
|
+
export const TALLY_FLAGS: readonly DieModifier[] = ['success', 'failure'];
|
|
42
|
+
|
|
35
43
|
/**
|
|
36
44
|
* Selection flags plus the success-count tally flags. Stripped when a die
|
|
37
45
|
* leaves the pool that tagged it (meta sub-expressions, dropped group
|
|
38
46
|
* sub-rolls) so the top-level successes/failures scan cannot count it.
|
|
39
47
|
*/
|
|
40
48
|
export const SELECTION_AND_TALLY_FLAGS: readonly DieModifier[] = [
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
'success',
|
|
44
|
-
'failure',
|
|
49
|
+
...SELECTION_FLAGS,
|
|
50
|
+
...TALLY_FLAGS,
|
|
45
51
|
];
|
|
46
52
|
|
|
47
53
|
/**
|
|
@@ -49,13 +55,7 @@ export const SELECTION_AND_TALLY_FLAGS: readonly DieModifier[] = [
|
|
|
49
55
|
* into a parent. Meta operands nest (`((1d2)d4)d6`), so a die passes through
|
|
50
56
|
* the merge once per level and the tag must be rebuilt, not appended.
|
|
51
57
|
*/
|
|
52
|
-
export const META_MERGE_FLAGS: readonly DieModifier[] = [
|
|
53
|
-
'kept',
|
|
54
|
-
'dropped',
|
|
55
|
-
'success',
|
|
56
|
-
'failure',
|
|
57
|
-
'meta',
|
|
58
|
-
];
|
|
58
|
+
export const META_MERGE_FLAGS: readonly DieModifier[] = [...SELECTION_AND_TALLY_FLAGS, 'meta'];
|
|
59
59
|
|
|
60
60
|
/** Selection flags plus `rerolled` — reassigned on every reroll pass. */
|
|
61
61
|
export const REROLL_SLOT_FLAGS: readonly DieModifier[] = ['kept', 'dropped', 'rerolled'];
|
|
@@ -134,12 +134,10 @@ function markSingleExtreme(
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
*
|
|
137
|
+
* Sums the dice a keep/drop pass left standing.
|
|
138
138
|
*
|
|
139
|
-
* @param dice - Array of die results
|
|
140
139
|
* @param hasVersusDc - Shared env flag; skips the DC exclusion when no `vs` has
|
|
141
140
|
* tagged anything
|
|
142
|
-
* @returns Sum of non-dropped dice
|
|
143
141
|
*/
|
|
144
142
|
export function sumKeptDice(dice: DieResult[], hasVersusDc: boolean): number {
|
|
145
143
|
let total = 0;
|
|
@@ -37,7 +37,7 @@ export function sortDice(
|
|
|
37
37
|
: (a: DieResult, b: DieResult) => b.result - a.result;
|
|
38
38
|
|
|
39
39
|
// Scan before allocating: the `filter` this replaced built a throwaway array
|
|
40
|
-
// on every sort to serve a case only a `vs` can produce
|
|
40
|
+
// on every sort to serve a case only a `vs` can produce.
|
|
41
41
|
if (!hasVersusDc || !dice.some(isVersusDc)) return [...dice].sort(cmp);
|
|
42
42
|
|
|
43
43
|
// Sort only the pool members, then lay them back into the slots they came
|