roll-parser 3.1.0 → 3.2.1
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 +37 -1
- package/MIGRATION.md +225 -8
- package/README.md +188 -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 +89 -43
- 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 +5 -5
- package/src/evaluator/env.ts +27 -1
- package/src/evaluator/evaluator.ts +179 -56
- 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 {
|
|
@@ -204,6 +207,26 @@ function appendAll<T>(target: T[], source: readonly T[]): void {
|
|
|
204
207
|
}
|
|
205
208
|
}
|
|
206
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Message for an operand that has to be a whole number — dice count, dice
|
|
212
|
+
* sides, keep/drop count. A fractional value there is almost always an
|
|
213
|
+
* unrounded division (`5d(20/3)`, `4d6kh(3/2)`), and the fix is a function the
|
|
214
|
+
* notation already carries, so the message names it rather than leaving the
|
|
215
|
+
* author to find it. Only finite positive values get the hint: no rounding
|
|
216
|
+
* rescues a negative count or a `NaN`.
|
|
217
|
+
*
|
|
218
|
+
* The value prints in full, repeating decimals and all. Shortening it to a
|
|
219
|
+
* fixed width read better on `20/3` but rounded `1.00001` to `1` — naming a
|
|
220
|
+
* side count the evaluator accepts, which reads as a bug in the library rather
|
|
221
|
+
* than in the notation. Exactness is worth more than the digits it costs.
|
|
222
|
+
*/
|
|
223
|
+
function invalidIntegerOperand(label: string, value: number): string {
|
|
224
|
+
if (Number.isInteger(value) || !Number.isFinite(value) || value <= 0) {
|
|
225
|
+
return `${label}: ${value}`;
|
|
226
|
+
}
|
|
227
|
+
return `${label}: ${value} (use 'floor', 'ceil', or 'round')`;
|
|
228
|
+
}
|
|
229
|
+
|
|
207
230
|
/** Drops the internal notation `code`, leaving the public `KeepDropSpec` shape. */
|
|
208
231
|
function toPublicSpecs(specs: KeepDropChainEntry[]): KeepDropSpec[] {
|
|
209
232
|
return specs.map(({ code: _code, ...spec }) => spec);
|
|
@@ -480,15 +503,39 @@ function evalMetaOperand(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
480
503
|
}
|
|
481
504
|
|
|
482
505
|
const metaCtx = createContext();
|
|
483
|
-
|
|
506
|
+
// The tally counterpart of the `TALLY_FLAGS` strip in `mergeMetaRolls`.
|
|
507
|
+
const value = evalDiscardingSubtotals(node, rng, metaCtx, env).total;
|
|
484
508
|
mergeMetaRolls(ctx, metaCtx);
|
|
485
509
|
return value;
|
|
486
510
|
}
|
|
487
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Evaluates a node whose result is consumed as a scalar — a meta operand or a
|
|
514
|
+
* `vs` DC — rolling back any subtotal verdicts it scored so they never reach
|
|
515
|
+
* the top-level tally.
|
|
516
|
+
*/
|
|
517
|
+
function evalDiscardingSubtotals(
|
|
518
|
+
node: ASTNode,
|
|
519
|
+
rng: RNG,
|
|
520
|
+
ctx: EvalContext,
|
|
521
|
+
env: EvalEnv,
|
|
522
|
+
): EvalResult {
|
|
523
|
+
const successTally = env.subtotalSuccesses;
|
|
524
|
+
const failureTally = env.subtotalFailures;
|
|
525
|
+
const result = evalNode(node, rng, ctx, env);
|
|
526
|
+
env.subtotalSuccesses = successTally;
|
|
527
|
+
env.subtotalFailures = failureTally;
|
|
528
|
+
return result;
|
|
529
|
+
}
|
|
530
|
+
|
|
488
531
|
/** Rejects dice counts that cannot address a pool. */
|
|
489
532
|
function requireDiceCount(count: number, nodeType: 'Dice' | 'FateDice'): void {
|
|
490
533
|
if (!Number.isInteger(count) || count < 0) {
|
|
491
|
-
throw new EvaluatorError(
|
|
534
|
+
throw new EvaluatorError(
|
|
535
|
+
invalidIntegerOperand('Invalid dice count', count),
|
|
536
|
+
'INVALID_DICE_COUNT',
|
|
537
|
+
nodeType,
|
|
538
|
+
);
|
|
492
539
|
}
|
|
493
540
|
}
|
|
494
541
|
|
|
@@ -541,7 +588,11 @@ function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
|
|
|
541
588
|
|
|
542
589
|
requireDiceCount(count, 'Dice');
|
|
543
590
|
if (!Number.isInteger(sides) || sides < 1) {
|
|
544
|
-
throw new EvaluatorError(
|
|
591
|
+
throw new EvaluatorError(
|
|
592
|
+
invalidIntegerOperand('Invalid dice sides', sides),
|
|
593
|
+
'INVALID_DICE_SIDES',
|
|
594
|
+
'Dice',
|
|
595
|
+
);
|
|
545
596
|
}
|
|
546
597
|
if (sides > MAX_DICE_SIDES) {
|
|
547
598
|
throw new EvaluatorError(
|
|
@@ -929,7 +980,7 @@ function flattenKeepDropChain(
|
|
|
929
980
|
|
|
930
981
|
if (!Number.isInteger(modCount) || modCount < 0) {
|
|
931
982
|
throw new EvaluatorError(
|
|
932
|
-
|
|
983
|
+
invalidIntegerOperand('Invalid keep/drop count', modCount),
|
|
933
984
|
'INVALID_KEEP_DROP_COUNT',
|
|
934
985
|
'KeepDrop',
|
|
935
986
|
);
|
|
@@ -1067,11 +1118,11 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
1067
1118
|
function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
1068
1119
|
const targetCtx = createContext();
|
|
1069
1120
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1070
|
-
const targetExpr = targetCtx.expressionParts.join('');
|
|
1071
1121
|
|
|
1072
1122
|
const thresholdValue = evalMetaOperand(node.condition.value, rng, ctx, env);
|
|
1073
1123
|
|
|
1074
1124
|
const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
|
|
1125
|
+
const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
|
|
1075
1126
|
const condition: ResolvedComparePoint = {
|
|
1076
1127
|
operator: node.condition.operator,
|
|
1077
1128
|
value: thresholdValue,
|
|
@@ -1079,8 +1130,8 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1079
1130
|
|
|
1080
1131
|
// No-op when the target produced no dice (e.g., `(1+2)r<5`).
|
|
1081
1132
|
if (targetCtx.rolls.length === 0) {
|
|
1082
|
-
ctx.expressionParts.push(
|
|
1083
|
-
ctx.renderedParts.push(
|
|
1133
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1134
|
+
ctx.renderedParts.push(modifierExpr);
|
|
1084
1135
|
const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
|
|
1085
1136
|
return {
|
|
1086
1137
|
total,
|
|
@@ -1101,8 +1152,8 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1101
1152
|
: applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
|
|
1102
1153
|
|
|
1103
1154
|
appendAll(ctx.rolls, pool);
|
|
1104
|
-
ctx.expressionParts.push(
|
|
1105
|
-
ctx.renderedParts.push(`${
|
|
1155
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1156
|
+
ctx.renderedParts.push(`${modifierExpr}${renderDice(pool)}`);
|
|
1106
1157
|
|
|
1107
1158
|
const total = sumKeptDice(pool, env.hasVersusDc);
|
|
1108
1159
|
return {
|
|
@@ -1145,13 +1196,13 @@ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1145
1196
|
// ! would have been resolved against a total this node just replaced.
|
|
1146
1197
|
// ! See the rule on `propagateMetadata`.
|
|
1147
1198
|
|
|
1148
|
-
const targetExpr = targetCtx.expressionParts.join('');
|
|
1149
1199
|
// Negative bounds render parenthesized so `result.expression` re-parses
|
|
1150
1200
|
// (`4d6min-2` is a syntax error; `4d6min(-2)` is not).
|
|
1151
1201
|
const code = boundValue < 0 ? `${node.bound}(${boundValue})` : `${node.bound}${boundValue}`;
|
|
1202
|
+
const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
|
|
1152
1203
|
|
|
1153
|
-
ctx.expressionParts.push(
|
|
1154
|
-
ctx.renderedParts.push(`${
|
|
1204
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1205
|
+
ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
|
|
1155
1206
|
|
|
1156
1207
|
const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
|
|
1157
1208
|
return {
|
|
@@ -1193,10 +1244,10 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
|
|
|
1193
1244
|
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
1194
1245
|
|
|
1195
1246
|
const code = node.order === 'ascending' ? 's' : 'sd';
|
|
1196
|
-
const
|
|
1247
|
+
const modifierExpr = joinModifierCode(targetCtx.expressionParts.join(''), code);
|
|
1197
1248
|
|
|
1198
|
-
ctx.expressionParts.push(
|
|
1199
|
-
ctx.renderedParts.push(`${
|
|
1249
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1250
|
+
ctx.renderedParts.push(`${modifierExpr}${renderDice(sortedRolls)}`);
|
|
1200
1251
|
|
|
1201
1252
|
return {
|
|
1202
1253
|
total: target.total,
|
|
@@ -1254,14 +1305,13 @@ function evalCritThreshold(
|
|
|
1254
1305
|
appendAll(ctx.rolls, targetCtx.rolls);
|
|
1255
1306
|
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
1256
1307
|
|
|
1257
|
-
const
|
|
1258
|
-
const codes = [
|
|
1308
|
+
const modifierExpr = [
|
|
1259
1309
|
...successResolved.map((t) => (t === 'default' ? 'cs' : `cs${t.operator}${t.value}`)),
|
|
1260
1310
|
...failResolved.map((t) => (t === 'default' ? 'cf' : `cf${t.operator}${t.value}`)),
|
|
1261
|
-
].join('');
|
|
1311
|
+
].reduce(joinModifierCode, targetCtx.expressionParts.join(''));
|
|
1262
1312
|
|
|
1263
|
-
ctx.expressionParts.push(
|
|
1264
|
-
ctx.renderedParts.push(`${
|
|
1313
|
+
ctx.expressionParts.push(modifierExpr);
|
|
1314
|
+
ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
|
|
1265
1315
|
|
|
1266
1316
|
return {
|
|
1267
1317
|
total: target.total,
|
|
@@ -1321,7 +1371,7 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1321
1371
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
1322
1372
|
const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
1323
1373
|
|
|
1324
|
-
ctx.expressionParts.push(
|
|
1374
|
+
ctx.expressionParts.push(joinModifierCode(targetExpr, keepDropCodes));
|
|
1325
1375
|
ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
|
|
1326
1376
|
|
|
1327
1377
|
return {
|
|
@@ -1344,10 +1394,17 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1344
1394
|
* success highlights inside a dropped span.
|
|
1345
1395
|
*/
|
|
1346
1396
|
function stripInnerMarkers(rendered: string): string {
|
|
1347
|
-
return rendered
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1397
|
+
return stripTallyMarkers(rendered).replace(/~~(-?\d+)~~/g, '$1');
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* Strips success (`**`) and failure (`__`) markers from an already-rendered
|
|
1402
|
+
* sub-roll, leaving dropped dice struck. Pairs with a `TALLY_FLAGS` strip: the
|
|
1403
|
+
* tags and the text they produced have to go together, or `renderBreakdown`
|
|
1404
|
+
* stops reproducing `rendered`.
|
|
1405
|
+
*/
|
|
1406
|
+
function stripTallyMarkers(rendered: string): string {
|
|
1407
|
+
return rendered.replace(/\*\*(-?\d+)\*\*/g, '$1').replace(/__(-?\d+)__/g, '$1');
|
|
1351
1408
|
}
|
|
1352
1409
|
|
|
1353
1410
|
/**
|
|
@@ -1374,10 +1431,16 @@ function evalGroupKeepDrop(
|
|
|
1374
1431
|
expr: string;
|
|
1375
1432
|
rendered: string;
|
|
1376
1433
|
versusMetadata: EvalContext['versusMetadata'];
|
|
1434
|
+
scoredSuccesses: number;
|
|
1435
|
+
scoredFailures: number;
|
|
1377
1436
|
};
|
|
1378
1437
|
|
|
1379
1438
|
const subRolls: SubRoll[] = group.expressions.map((expr) => {
|
|
1380
1439
|
const subCtx = createContext();
|
|
1440
|
+
// What a subtotal count inside this sub-roll scored, so a drop can take it
|
|
1441
|
+
// back — the tally counterpart of the `TALLY_FLAGS` rewrite below.
|
|
1442
|
+
const successTally = env.subtotalSuccesses;
|
|
1443
|
+
const failureTally = env.subtotalFailures;
|
|
1381
1444
|
const sub = evalNode(expr, rng, subCtx, env);
|
|
1382
1445
|
return {
|
|
1383
1446
|
subtotal: sub.total,
|
|
@@ -1386,6 +1449,8 @@ function evalGroupKeepDrop(
|
|
|
1386
1449
|
expr: subCtx.expressionParts.join(''),
|
|
1387
1450
|
rendered: subCtx.renderedParts.join(''),
|
|
1388
1451
|
versusMetadata: subCtx.versusMetadata,
|
|
1452
|
+
scoredSuccesses: env.subtotalSuccesses - successTally,
|
|
1453
|
+
scoredFailures: env.subtotalFailures - failureTally,
|
|
1389
1454
|
};
|
|
1390
1455
|
});
|
|
1391
1456
|
|
|
@@ -1418,6 +1483,10 @@ function evalGroupKeepDrop(
|
|
|
1418
1483
|
for (const die of sub.rolls) {
|
|
1419
1484
|
die.modifiers = rewriteFlags(die.modifiers, SELECTION_AND_TALLY_FLAGS, 'dropped');
|
|
1420
1485
|
}
|
|
1486
|
+
// A count on subtotals left no tag for the rewrite above to strip, so its
|
|
1487
|
+
// verdicts come back from the env tally instead.
|
|
1488
|
+
env.subtotalSuccesses -= sub.scoredSuccesses;
|
|
1489
|
+
env.subtotalFailures -= sub.scoredFailures;
|
|
1421
1490
|
appendAll(ctx.rolls, sub.rolls);
|
|
1422
1491
|
outerRendered.push(`~~${stripInnerMarkers(sub.rendered)}~~`);
|
|
1423
1492
|
} else {
|
|
@@ -1505,14 +1574,24 @@ function evalSuccessCount(
|
|
|
1505
1574
|
ctx: EvalContext,
|
|
1506
1575
|
env: EvalEnv,
|
|
1507
1576
|
): EvalResult {
|
|
1508
|
-
//
|
|
1509
|
-
//
|
|
1510
|
-
|
|
1577
|
+
// Rolled back below, so a subtotal count nested in the target
|
|
1578
|
+
// (`{{2d6, 2d6}>=10, 1d8}>=1`) is not reported alongside the subtotal this
|
|
1579
|
+
// pass re-scores it into.
|
|
1580
|
+
const successTallyBefore = env.subtotalSuccesses;
|
|
1581
|
+
const failureTallyBefore = env.subtotalFailures;
|
|
1511
1582
|
|
|
1512
1583
|
const targetCtx = createContext();
|
|
1513
1584
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1514
1585
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
1515
1586
|
|
|
1587
|
+
// True once any count has run: an inner one that tagged this very pool (only
|
|
1588
|
+
// a group can arrange that — `{4d6>=5}<=2f5`), or an unrelated earlier one,
|
|
1589
|
+
// which costs a redundant strip over dice nothing tagged. The flag otherwise
|
|
1590
|
+
// tracks syntactic presence of success-count notation, not pool size — set
|
|
1591
|
+
// before any early return so empty pools still populate successes/failures.
|
|
1592
|
+
const poolAlreadyCounted = env.hasSuccessCount;
|
|
1593
|
+
env.hasSuccessCount = true;
|
|
1594
|
+
|
|
1516
1595
|
const thresholdValue = resolveThreshold(node.threshold.value, rng, ctx, env, 'threshold');
|
|
1517
1596
|
const failValue =
|
|
1518
1597
|
node.failThreshold != null
|
|
@@ -1543,27 +1622,66 @@ function evalSuccessCount(
|
|
|
1543
1622
|
return part;
|
|
1544
1623
|
};
|
|
1545
1624
|
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
|
|
1625
|
+
// Multi-sub-roll group: the units are sub-roll subtotals, not dice. The
|
|
1626
|
+
// `sides = 0` synthetics are `evalGroupKeepDrop`'s sentinel and never reach
|
|
1627
|
+
// `ctx.rolls`. Only a direct group target arrives here — the parser refuses
|
|
1628
|
+
// every form that would reach the count with the subtotals already gone.
|
|
1629
|
+
const bySubtotal = node.target.type === 'Group' && node.target.expressions.length >= 2;
|
|
1630
|
+
const pool: DieResult[] = bySubtotal
|
|
1631
|
+
? (target.part as Extract<RollPart, { type: 'group' }>).parts.map((sub) => ({
|
|
1632
|
+
sides: 0,
|
|
1633
|
+
result: sub.total,
|
|
1634
|
+
modifiers: [],
|
|
1635
|
+
critical: false,
|
|
1636
|
+
fumble: false,
|
|
1637
|
+
}))
|
|
1638
|
+
: targetCtx.rolls;
|
|
1639
|
+
|
|
1640
|
+
// ! Releases every die an inner count tagged, `vs` DC dice included, though
|
|
1641
|
+
// ! `countSuccesses` spares those. The marker strip below reads rendered text
|
|
1642
|
+
// ! and cannot tell a DC die apart, so sparing one here leaves a tag whose
|
|
1643
|
+
// ! `**` is already gone and `renderBreakdown` stops reproducing `rendered`.
|
|
1644
|
+
if (bySubtotal && poolAlreadyCounted) {
|
|
1645
|
+
for (const die of targetCtx.rolls) {
|
|
1646
|
+
die.modifiers = stripFlags(die.modifiers, TALLY_FLAGS);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// An empty pool (`0d6>=4`) scores zero of both, so its total is 0 — never
|
|
1651
|
+
// `target.total`, which would break `total === successes - failures`.
|
|
1652
|
+
// Reachable only through a zero-count pool — a target holding no dice node at
|
|
1653
|
+
// all is rejected at parse time.
|
|
1654
|
+
if (pool.length === 0) {
|
|
1550
1655
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1551
1656
|
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
1552
|
-
return { total:
|
|
1657
|
+
return { total: 0, part: buildPart(0, 0, 0) };
|
|
1553
1658
|
}
|
|
1554
1659
|
|
|
1555
1660
|
const result = countSuccesses(
|
|
1556
|
-
|
|
1661
|
+
pool,
|
|
1557
1662
|
{ operator: node.threshold.operator, value: thresholdValue },
|
|
1558
1663
|
failValue != null && node.failThreshold != null
|
|
1559
1664
|
? { operator: node.failThreshold.operator, value: failValue }
|
|
1560
1665
|
: undefined,
|
|
1561
|
-
env.hasVersusDc,
|
|
1666
|
+
!bySubtotal && env.hasVersusDc,
|
|
1667
|
+
!bySubtotal && poolAlreadyCounted,
|
|
1562
1668
|
);
|
|
1563
1669
|
|
|
1670
|
+
if (bySubtotal) {
|
|
1671
|
+
env.subtotalSuccesses = successTallyBefore + result.successes;
|
|
1672
|
+
env.subtotalFailures = failureTallyBefore + result.failures;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1564
1675
|
appendAll(ctx.rolls, targetCtx.rolls);
|
|
1565
1676
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1566
|
-
|
|
1677
|
+
// A subtotal count renders through the group — its sub-rolls carry their own
|
|
1678
|
+
// brackets, and one flat bracket would spell out the units it never used. The
|
|
1679
|
+
// strip pairs with the tag release above: markers and tags go together.
|
|
1680
|
+
ctx.renderedParts.push(
|
|
1681
|
+
bySubtotal
|
|
1682
|
+
? `${stripTallyMarkers(targetCtx.renderedParts.join(''))}${code}`
|
|
1683
|
+
: `${targetExpr}${code}${renderDice(targetCtx.rolls)}`,
|
|
1684
|
+
);
|
|
1567
1685
|
|
|
1568
1686
|
return {
|
|
1569
1687
|
total: result.total,
|
|
@@ -1581,22 +1699,18 @@ function evalSuccessCount(
|
|
|
1581
1699
|
* `undefined`.
|
|
1582
1700
|
*
|
|
1583
1701
|
* 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.
|
|
1702
|
+
* final kept result — and the continuation dice `env.explosionDice` marks, so
|
|
1703
|
+
* `1d20! vs DC` keeps the natural from the original d20. A compound explode
|
|
1704
|
+
* accumulates into that original instead of appending, so it stays a primary
|
|
1705
|
+
* and its raw first face is used. Multiple primary kept d20s (e.g.,
|
|
1706
|
+
* `1d20+1d20`) yield `undefined` so no ambiguous upgrade/downgrade is applied.
|
|
1591
1707
|
*/
|
|
1592
|
-
function extractNatural(rolls: DieResult[]): number | undefined {
|
|
1708
|
+
function extractNatural(rolls: DieResult[], env: EvalEnv): number | undefined {
|
|
1593
1709
|
// Rerolled intermediates are always stamped `['rerolled', 'dropped']`
|
|
1594
1710
|
// (see `modifiers/reroll.ts`), so filtering by `'dropped'` covers them.
|
|
1711
|
+
const appended = env.explosionDice;
|
|
1595
1712
|
const primaries = rolls.filter(
|
|
1596
|
-
(d) =>
|
|
1597
|
-
d.sides === 20 &&
|
|
1598
|
-
!d.modifiers.includes('dropped') &&
|
|
1599
|
-
!(d.modifiers.includes('exploded') && d.initialResult == null),
|
|
1713
|
+
(d) => d.sides === 20 && !d.modifiers.includes('dropped') && !appended?.has(d),
|
|
1600
1714
|
);
|
|
1601
1715
|
if (primaries.length !== 1) return undefined;
|
|
1602
1716
|
const die = primaries[0];
|
|
@@ -1642,10 +1756,13 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1642
1756
|
try {
|
|
1643
1757
|
const rollCtx = createContext();
|
|
1644
1758
|
const rollResult = evalNode(node.roll, rng, rollCtx, env);
|
|
1645
|
-
const natural = extractNatural(rollCtx.rolls);
|
|
1759
|
+
const natural = extractNatural(rollCtx.rolls, env);
|
|
1646
1760
|
|
|
1647
1761
|
const dcCtx = createContext();
|
|
1648
|
-
|
|
1762
|
+
// A subtotal count on the DC side (`1d20 vs {{2d6, 2d6}>=10}`) is rolled
|
|
1763
|
+
// back for the same reason `countTaggedDice` skips DC dice: no pool pass
|
|
1764
|
+
// may tally that side.
|
|
1765
|
+
const dcResult = evalDiscardingSubtotals(node.dc, rng, dcCtx, env);
|
|
1649
1766
|
|
|
1650
1767
|
const degree = calculateDegree(rollResult.total, dcResult.total, natural);
|
|
1651
1768
|
|
|
@@ -1751,9 +1868,12 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
1751
1868
|
maxRerollIterations,
|
|
1752
1869
|
totalDiceRolled: 0,
|
|
1753
1870
|
hasSuccessCount: false,
|
|
1871
|
+
subtotalSuccesses: 0,
|
|
1872
|
+
subtotalFailures: 0,
|
|
1754
1873
|
insideVersus: false,
|
|
1755
1874
|
hasVersusDc: false,
|
|
1756
1875
|
critRules: undefined,
|
|
1876
|
+
explosionDice: undefined,
|
|
1757
1877
|
context,
|
|
1758
1878
|
onMissingVariable,
|
|
1759
1879
|
};
|
|
@@ -1785,24 +1905,27 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
1785
1905
|
rendered,
|
|
1786
1906
|
rolls: ctx.rolls,
|
|
1787
1907
|
parts: part,
|
|
1788
|
-
...(env.hasSuccessCount ? countTaggedDice(ctx.rolls, env
|
|
1908
|
+
...(env.hasSuccessCount ? countTaggedDice(ctx.rolls, env) : {}),
|
|
1789
1909
|
...(versus ? { degree: versus.degree } : {}),
|
|
1790
1910
|
...(versus?.natural != null ? { natural: versus.natural } : {}),
|
|
1791
1911
|
};
|
|
1792
1912
|
}
|
|
1793
1913
|
|
|
1794
|
-
/**
|
|
1914
|
+
/**
|
|
1915
|
+
* Tallies the `'success'` / `'failure'` tags across a whole roll, on top of
|
|
1916
|
+
* what a group count scored on subtotals — those carry no tag to find.
|
|
1917
|
+
*/
|
|
1795
1918
|
function countTaggedDice(
|
|
1796
1919
|
rolls: DieResult[],
|
|
1797
|
-
|
|
1920
|
+
env: EvalEnv,
|
|
1798
1921
|
): { successes: number; failures: number } {
|
|
1799
|
-
let successes =
|
|
1800
|
-
let failures =
|
|
1922
|
+
let successes = env.subtotalSuccesses;
|
|
1923
|
+
let failures = env.subtotalFailures;
|
|
1801
1924
|
|
|
1802
1925
|
for (const die of rolls) {
|
|
1803
1926
|
// A success-count inside the DC sub-expression tags its own dice before
|
|
1804
1927
|
// `evalVersus` marks them `'dc'`, so they arrive here already tagged.
|
|
1805
|
-
if (hasVersusDc && isVersusDc(die)) continue;
|
|
1928
|
+
if (env.hasVersusDc && isVersusDc(die)) continue;
|
|
1806
1929
|
if (die.modifiers.includes('success')) successes += 1;
|
|
1807
1930
|
else if (die.modifiers.includes('failure')) failures += 1;
|
|
1808
1931
|
}
|
|
@@ -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'];
|