roll-parser 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -1
- package/MIGRATION.md +73 -1
- package/README.md +85 -19
- 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 +31 -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 +29 -21
- package/dist/evaluator/evaluator.js.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.d.ts +57 -4
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.js +27 -8
- 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 +7 -5
- package/dist/evaluator/modifiers/explode.js.map +1 -1
- package/dist/evaluator/modifiers/flags.d.ts +10 -0
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
- package/dist/evaluator/modifiers/flags.js +7 -0
- package/dist/evaluator/modifiers/flags.js.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.d.ts +4 -2
- 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 +5 -2
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +7 -6
- package/dist/evaluator/modifiers/success-count.js.map +1 -1
- 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/render.d.ts +95 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +227 -0
- package/dist/render.js.map +1 -0
- package/dist/types.d.ts +49 -9
- 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 +11 -1
- package/src/cli/format.ts +15 -23
- package/src/evaluator/die.ts +2 -1
- package/src/evaluator/env.ts +32 -0
- package/src/evaluator/evaluator.ts +48 -21
- package/src/evaluator/modifiers/crit-threshold.ts +95 -10
- package/src/evaluator/modifiers/die-bound.ts +13 -4
- package/src/evaluator/modifiers/explode.ts +12 -6
- package/src/evaluator/modifiers/flags.ts +17 -0
- package/src/evaluator/modifiers/keep-drop.ts +9 -5
- package/src/evaluator/modifiers/reroll.ts +20 -11
- package/src/evaluator/modifiers/sort.ts +9 -3
- package/src/evaluator/modifiers/success-count.ts +21 -6
- package/src/parser/ast.ts +12 -7
- package/src/render.ts +392 -0
- package/src/types.ts +49 -9
- package/src/version.ts +1 -1
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
} from './modifiers/explode.js';
|
|
56
56
|
import {
|
|
57
57
|
isVersusDc,
|
|
58
|
+
META_MERGE_FLAGS,
|
|
58
59
|
rewriteFlags,
|
|
59
60
|
SELECTION_AND_TALLY_FLAGS,
|
|
60
61
|
SELECTION_FLAGS,
|
|
@@ -263,6 +264,10 @@ function renderDie(result: number, modifiers: readonly DieModifier[]): string {
|
|
|
263
264
|
* `sumKeptDice`; `'meta'` lets renderers hide them and lets callers
|
|
264
265
|
* distinguish them from ordinary pool dice.
|
|
265
266
|
*
|
|
267
|
+
* `'meta'` is stripped before being re-added: meta operands nest
|
|
268
|
+
* (`((1d2)d4)d6`), so the innermost dice pass through here once per level and
|
|
269
|
+
* an append-only rewrite would leave them carrying the tag once per level.
|
|
270
|
+
*
|
|
266
271
|
* `'success'`/`'failure'` tags are stripped here as defense-in-depth against
|
|
267
272
|
* a SuccessCount leaking into a meta sub-expression (parser rejects all such
|
|
268
273
|
* wrappings; this strip ensures a future parse regression cannot leak tags
|
|
@@ -279,7 +284,7 @@ export function mergeMetaRolls(parent: EvalContext, source: EvalContext): void {
|
|
|
279
284
|
for (const die of source.rolls) {
|
|
280
285
|
parent.rolls.push({
|
|
281
286
|
...die,
|
|
282
|
-
modifiers: rewriteFlags(die.modifiers,
|
|
287
|
+
modifiers: rewriteFlags(die.modifiers, META_MERGE_FLAGS, 'meta', 'dropped'),
|
|
283
288
|
});
|
|
284
289
|
}
|
|
285
290
|
}
|
|
@@ -952,11 +957,15 @@ function flattenKeepDropChain(
|
|
|
952
957
|
* selects against the unmodified pool: `markDroppedIndices` reads results and
|
|
953
958
|
* writes only into `droppedMask`, so no spec can observe another's outcome.
|
|
954
959
|
*/
|
|
955
|
-
function mergeDropSets(
|
|
960
|
+
function mergeDropSets(
|
|
961
|
+
baseDice: DieResult[],
|
|
962
|
+
specs: KeepDropChainEntry[],
|
|
963
|
+
hasVersusDc: boolean,
|
|
964
|
+
): DieResult[] {
|
|
956
965
|
const droppedMask = new Uint8Array(baseDice.length);
|
|
957
966
|
|
|
958
967
|
for (const spec of specs) {
|
|
959
|
-
markDroppedIndices(baseDice, spec.count, spec.kind, spec.selector, droppedMask);
|
|
968
|
+
markDroppedIndices(baseDice, spec.count, spec.kind, spec.selector, droppedMask, hasVersusDc);
|
|
960
969
|
}
|
|
961
970
|
|
|
962
971
|
for (let index = 0; index < baseDice.length; index++) {
|
|
@@ -1019,10 +1028,11 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
1019
1028
|
|
|
1020
1029
|
const code = formatExplodeCode(node.variant, node.threshold, thresholdValue);
|
|
1021
1030
|
|
|
1022
|
-
const buildPart = (total: number): RollPart => {
|
|
1031
|
+
const buildPart = (total: number, rolls: DieResult[]): RollPart => {
|
|
1023
1032
|
const part: RollPart = {
|
|
1024
1033
|
type: 'explode',
|
|
1025
1034
|
variant: node.variant,
|
|
1035
|
+
rolls,
|
|
1026
1036
|
target: target.part,
|
|
1027
1037
|
total,
|
|
1028
1038
|
...partSpan(node),
|
|
@@ -1037,7 +1047,7 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
1037
1047
|
if (targetCtx.rolls.length === 0) {
|
|
1038
1048
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1039
1049
|
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
1040
|
-
return { total: target.total, part: buildPart(target.total) };
|
|
1050
|
+
return { total: target.total, part: buildPart(target.total, targetCtx.rolls) };
|
|
1041
1051
|
}
|
|
1042
1052
|
|
|
1043
1053
|
const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
|
|
@@ -1050,8 +1060,8 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
1050
1060
|
// are self-evident, explosion origin is otherwise invisible.
|
|
1051
1061
|
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
|
|
1052
1062
|
|
|
1053
|
-
const total = sumKeptDice(expanded);
|
|
1054
|
-
return { total, part: buildPart(total) };
|
|
1063
|
+
const total = sumKeptDice(expanded, env.hasVersusDc);
|
|
1064
|
+
return { total, part: buildPart(total, expanded) };
|
|
1055
1065
|
}
|
|
1056
1066
|
|
|
1057
1067
|
function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
@@ -1071,13 +1081,14 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1071
1081
|
if (targetCtx.rolls.length === 0) {
|
|
1072
1082
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1073
1083
|
ctx.renderedParts.push(`${targetExpr}${code}`);
|
|
1074
|
-
const total = sumKeptDice(targetCtx.rolls);
|
|
1084
|
+
const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
|
|
1075
1085
|
return {
|
|
1076
1086
|
total,
|
|
1077
1087
|
part: {
|
|
1078
1088
|
type: 'reroll',
|
|
1079
1089
|
once: node.once,
|
|
1080
1090
|
condition,
|
|
1091
|
+
rolls: targetCtx.rolls,
|
|
1081
1092
|
target: target.part,
|
|
1082
1093
|
total,
|
|
1083
1094
|
...partSpan(node),
|
|
@@ -1093,13 +1104,14 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1093
1104
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1094
1105
|
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(pool)}`);
|
|
1095
1106
|
|
|
1096
|
-
const total = sumKeptDice(pool);
|
|
1107
|
+
const total = sumKeptDice(pool, env.hasVersusDc);
|
|
1097
1108
|
return {
|
|
1098
1109
|
total,
|
|
1099
1110
|
part: {
|
|
1100
1111
|
type: 'reroll',
|
|
1101
1112
|
once: node.once,
|
|
1102
1113
|
condition,
|
|
1114
|
+
rolls: pool,
|
|
1103
1115
|
target: target.part,
|
|
1104
1116
|
total,
|
|
1105
1117
|
...partSpan(node),
|
|
@@ -1126,7 +1138,7 @@ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1126
1138
|
);
|
|
1127
1139
|
}
|
|
1128
1140
|
|
|
1129
|
-
applyDieBound(targetCtx.rolls, node.bound, boundValue);
|
|
1141
|
+
applyDieBound(targetCtx.rolls, node.bound, boundValue, env.hasVersusDc);
|
|
1130
1142
|
|
|
1131
1143
|
appendAll(ctx.rolls, targetCtx.rolls);
|
|
1132
1144
|
// ! No `propagateMetadata` here: clamping re-sums, so a propagated `degree`
|
|
@@ -1141,7 +1153,7 @@ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1141
1153
|
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1142
1154
|
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(targetCtx.rolls)}`);
|
|
1143
1155
|
|
|
1144
|
-
const total = sumKeptDice(targetCtx.rolls);
|
|
1156
|
+
const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
|
|
1145
1157
|
return {
|
|
1146
1158
|
total,
|
|
1147
1159
|
part: {
|
|
@@ -1175,7 +1187,7 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
|
|
|
1175
1187
|
const targetCtx = createContext();
|
|
1176
1188
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1177
1189
|
|
|
1178
|
-
const sortedRolls = sortDice(targetCtx.rolls, node.order);
|
|
1190
|
+
const sortedRolls = sortDice(targetCtx.rolls, node.order, env.hasVersusDc);
|
|
1179
1191
|
|
|
1180
1192
|
appendAll(ctx.rolls, sortedRolls);
|
|
1181
1193
|
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
@@ -1210,8 +1222,12 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
|
|
|
1210
1222
|
* crit criteria and `cf` thresholds replace only the fumble criteria. When a
|
|
1211
1223
|
* side has no explicit threshold, the `'default'` rule applies — so
|
|
1212
1224
|
* `1d20cf<3` keeps the default nat-20 crit. Bare `cs`/`cf` uses the
|
|
1213
|
-
* `'default'` sentinel resolved per-die
|
|
1214
|
-
* `result
|
|
1225
|
+
* `'default'` sentinel, resolved per-die against the natural face
|
|
1226
|
+
* (`initialResult ?? result`) rather than the possibly-rewritten `result`.
|
|
1227
|
+
*
|
|
1228
|
+
* The resolved rule is recorded per die, so an enclosing explode or reroll
|
|
1229
|
+
* judges the dice it mints by it too — `1d6cs<2!` no longer reports the
|
|
1230
|
+
* default-rule crit the user overrode.
|
|
1215
1231
|
*
|
|
1216
1232
|
* Renders `<targetExpr><codes>[<dice>]`, mirroring `evalSort`/`evalExplode`.
|
|
1217
1233
|
*/
|
|
@@ -1233,7 +1249,7 @@ function evalCritThreshold(
|
|
|
1233
1249
|
successResolved.length > 0 ? successResolved : ['default'];
|
|
1234
1250
|
const failApplied: ResolvedCritThreshold[] = failResolved.length > 0 ? failResolved : ['default'];
|
|
1235
1251
|
|
|
1236
|
-
applyCritThresholds(targetCtx.rolls, successApplied, failApplied);
|
|
1252
|
+
applyCritThresholds(targetCtx.rolls, successApplied, failApplied, env);
|
|
1237
1253
|
|
|
1238
1254
|
appendAll(ctx.rolls, targetCtx.rolls);
|
|
1239
1255
|
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
@@ -1296,11 +1312,11 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1296
1312
|
const targetCtx = createContext();
|
|
1297
1313
|
const target = evalNode(baseTarget, rng, targetCtx, env);
|
|
1298
1314
|
|
|
1299
|
-
const mergedDice = mergeDropSets(targetCtx.rolls, specs);
|
|
1315
|
+
const mergedDice = mergeDropSets(targetCtx.rolls, specs, env.hasVersusDc);
|
|
1300
1316
|
|
|
1301
1317
|
appendAll(ctx.rolls, mergedDice);
|
|
1302
1318
|
|
|
1303
|
-
const total = sumKeptDice(mergedDice);
|
|
1319
|
+
const total = sumKeptDice(mergedDice, env.hasVersusDc);
|
|
1304
1320
|
|
|
1305
1321
|
const targetExpr = targetCtx.expressionParts.join('');
|
|
1306
1322
|
const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
@@ -1383,7 +1399,7 @@ function evalGroupKeepDrop(
|
|
|
1383
1399
|
fumble: false,
|
|
1384
1400
|
}));
|
|
1385
1401
|
|
|
1386
|
-
const mergedSynthetic = mergeDropSets(syntheticDice, specs);
|
|
1402
|
+
const mergedSynthetic = mergeDropSets(syntheticDice, specs, env.hasVersusDc);
|
|
1387
1403
|
|
|
1388
1404
|
const outerRendered: string[] = [];
|
|
1389
1405
|
const keptIndices: number[] = [];
|
|
@@ -1514,6 +1530,7 @@ function evalSuccessCount(
|
|
|
1514
1530
|
const part: RollPart = {
|
|
1515
1531
|
type: 'successCount',
|
|
1516
1532
|
threshold: { operator: node.threshold.operator, value: thresholdValue },
|
|
1533
|
+
rolls: targetCtx.rolls,
|
|
1517
1534
|
target: target.part,
|
|
1518
1535
|
successes,
|
|
1519
1536
|
failures,
|
|
@@ -1541,6 +1558,7 @@ function evalSuccessCount(
|
|
|
1541
1558
|
failValue != null && node.failThreshold != null
|
|
1542
1559
|
? { operator: node.failThreshold.operator, value: failValue }
|
|
1543
1560
|
: undefined,
|
|
1561
|
+
env.hasVersusDc,
|
|
1544
1562
|
);
|
|
1545
1563
|
|
|
1546
1564
|
appendAll(ctx.rolls, targetCtx.rolls);
|
|
@@ -1634,9 +1652,13 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1634
1652
|
appendAll(ctx.rolls, rollCtx.rolls);
|
|
1635
1653
|
// ! Tag before merging: past this point the DC dice are indistinguishable
|
|
1636
1654
|
// ! from the roll side, and every pool modifier walks the merged array.
|
|
1655
|
+
// ! Bare push, deliberately: `insideVersus` above rejects every nesting, so
|
|
1656
|
+
// ! each die reaches exactly one `dcCtx` and cannot be tagged twice.
|
|
1637
1657
|
for (const die of dcCtx.rolls) {
|
|
1638
1658
|
die.modifiers.push('dc');
|
|
1639
1659
|
}
|
|
1660
|
+
// Arms the exclusion checks every enclosing pool operation skips by default.
|
|
1661
|
+
if (dcCtx.rolls.length > 0) env.hasVersusDc = true;
|
|
1640
1662
|
appendAll(ctx.rolls, dcCtx.rolls);
|
|
1641
1663
|
|
|
1642
1664
|
const rollExpr = rollCtx.expressionParts.join('');
|
|
@@ -1730,6 +1752,8 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
1730
1752
|
totalDiceRolled: 0,
|
|
1731
1753
|
hasSuccessCount: false,
|
|
1732
1754
|
insideVersus: false,
|
|
1755
|
+
hasVersusDc: false,
|
|
1756
|
+
critRules: undefined,
|
|
1733
1757
|
context,
|
|
1734
1758
|
onMissingVariable,
|
|
1735
1759
|
};
|
|
@@ -1761,21 +1785,24 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
1761
1785
|
rendered,
|
|
1762
1786
|
rolls: ctx.rolls,
|
|
1763
1787
|
parts: part,
|
|
1764
|
-
...(env.hasSuccessCount ? countTaggedDice(ctx.rolls) : {}),
|
|
1788
|
+
...(env.hasSuccessCount ? countTaggedDice(ctx.rolls, env.hasVersusDc) : {}),
|
|
1765
1789
|
...(versus ? { degree: versus.degree } : {}),
|
|
1766
1790
|
...(versus?.natural != null ? { natural: versus.natural } : {}),
|
|
1767
1791
|
};
|
|
1768
1792
|
}
|
|
1769
1793
|
|
|
1770
1794
|
/** Tallies the `'success'` / `'failure'` tags across a whole roll. */
|
|
1771
|
-
function countTaggedDice(
|
|
1795
|
+
function countTaggedDice(
|
|
1796
|
+
rolls: DieResult[],
|
|
1797
|
+
hasVersusDc: boolean,
|
|
1798
|
+
): { successes: number; failures: number } {
|
|
1772
1799
|
let successes = 0;
|
|
1773
1800
|
let failures = 0;
|
|
1774
1801
|
|
|
1775
1802
|
for (const die of rolls) {
|
|
1776
1803
|
// A success-count inside the DC sub-expression tags its own dice before
|
|
1777
1804
|
// `evalVersus` marks them `'dc'`, so they arrive here already tagged.
|
|
1778
|
-
if (isVersusDc(die)) continue;
|
|
1805
|
+
if (hasVersusDc && isVersusDc(die)) continue;
|
|
1779
1806
|
if (die.modifiers.includes('success')) successes += 1;
|
|
1780
1807
|
else if (die.modifiers.includes('failure')) failures += 1;
|
|
1781
1808
|
}
|
|
@@ -9,6 +9,40 @@
|
|
|
9
9
|
* Meta dice (rolled to compute counts/sides/modifier args) are skipped so
|
|
10
10
|
* their bookkeeping stays untouched.
|
|
11
11
|
*
|
|
12
|
+
* The two threshold kinds deliberately read different values. `'default'`
|
|
13
|
+
* reads `initialResult ?? result`, the same source as the versus `natural`,
|
|
14
|
+
* so "rolled the maximum face" survives the two modifiers that overwrite
|
|
15
|
+
* `result` while recording the face they replaced — compound explode and
|
|
16
|
+
* `minN`/`maxN`. An explicit threshold (`cs>4`, `cf<=2`) reads the die's
|
|
17
|
+
* current `result`: it is a predicate over the die's value, and postfix
|
|
18
|
+
* modifiers are order-sensitive by design, so `4d6min5cs>4` is meant to
|
|
19
|
+
* see the clamped faces. The two can therefore disagree on one die —
|
|
20
|
+
* `4d6min5cs>4` flags a clamped natural 1 as both critical and fumble.
|
|
21
|
+
*
|
|
22
|
+
* They also diverge on Fate dice. `'default'` carries a `sides > 1` guard, so
|
|
23
|
+
* it never fires on a `sides = 0` pool; an explicit threshold has none and
|
|
24
|
+
* compares the raw {-1, 0, +1} face, so `4dFcs>0` and `4dFcf=-1` do set the
|
|
25
|
+
* flags. The missing guard is deliberate — the parser rejects the bare
|
|
26
|
+
* `cs`/`cf` forms instead, since they would resolve to `'default'` and
|
|
27
|
+
* silently do nothing.
|
|
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
|
+
* The rule is recorded per die on `env.critRules`, so dice that explode and
|
|
39
|
+
* reroll mint *after* the crit node has run inherit it from the die they
|
|
40
|
+
* descended from. `cs`/`cf` therefore covers the whole pool wherever it sits
|
|
41
|
+
* in the postfix chain — `1d6cs<2!` and `1d6!cs<2` agree. Two things stay
|
|
42
|
+
* outside that: compound explode, which mints no die and so keeps the flags
|
|
43
|
+
* its accumulated `result` had when the crit node ran, and `minN`/`maxN`,
|
|
44
|
+
* which rewrites `result` under an explicit threshold's feet.
|
|
45
|
+
*
|
|
12
46
|
* Display-only: does not alter `total`, explosion triggers, success
|
|
13
47
|
* counting, or any other modifier flag. Dropped dice still participate —
|
|
14
48
|
* their `critical`/`fumble` metadata reflects what they rolled, not
|
|
@@ -18,39 +52,90 @@
|
|
|
18
52
|
*/
|
|
19
53
|
|
|
20
54
|
import type { DieResult, ResolvedCritThreshold } from '../../types.js';
|
|
55
|
+
import type { CritRule, EvalEnv } from '../env.js';
|
|
21
56
|
import { matchesCondition } from './compare.js';
|
|
22
57
|
import { isVersusDc } from './flags.js';
|
|
23
58
|
|
|
24
59
|
/**
|
|
25
60
|
* Applies success/fail threshold arrays to a dice pool, overriding each
|
|
26
|
-
* die's `critical` and `fumble` flags in place.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
61
|
+
* die's `critical` and `fumble` flags in place. Writing `natural` for
|
|
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.
|
|
65
|
+
*
|
|
66
|
+
* Also records the rule against every die it touched, so later explode and
|
|
67
|
+
* reroll dice can inherit it via {@link inheritCritRule}.
|
|
29
68
|
*/
|
|
30
69
|
export function applyCritThresholds(
|
|
31
70
|
dice: DieResult[],
|
|
32
71
|
successThresholds: ResolvedCritThreshold[],
|
|
33
72
|
failThresholds: ResolvedCritThreshold[],
|
|
73
|
+
env: EvalEnv,
|
|
34
74
|
): void {
|
|
75
|
+
const rule: CritRule = { success: successThresholds, fail: failThresholds };
|
|
76
|
+
env.critRules ??= new WeakMap();
|
|
77
|
+
const rules = env.critRules;
|
|
78
|
+
|
|
35
79
|
for (const die of dice) {
|
|
36
|
-
if (die.modifiers.includes('meta')
|
|
80
|
+
if (die.modifiers.includes('meta')) continue;
|
|
81
|
+
if (env.hasVersusDc && isVersusDc(die)) continue;
|
|
37
82
|
|
|
38
|
-
die
|
|
39
|
-
|
|
83
|
+
applyCritRule(die, rule, die.initialResult ?? die.result);
|
|
84
|
+
rules.set(die, rule);
|
|
40
85
|
}
|
|
41
86
|
}
|
|
42
87
|
|
|
43
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Judges one die by a recorded rule, overwriting both flags. A side always
|
|
90
|
+
* carries at least `'default'` — `evalCritThreshold` fills the side the user
|
|
91
|
+
* left out — so neither flag is silently left untouched. `natural` is the face
|
|
92
|
+
* the `'default'` sentinel reads; an explicit threshold always reads `result`.
|
|
93
|
+
*/
|
|
94
|
+
function applyCritRule(die: DieResult, rule: CritRule, natural: number): void {
|
|
95
|
+
die.critical = rule.success.some((t) => matchesCrit(t, die, natural));
|
|
96
|
+
die.fumble = rule.fail.some((t) => matchesFumble(t, die, natural));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Passes the crit rule recorded for `parent` down to a die minted from it,
|
|
101
|
+
* judging the child by that rule and recording it so a further explode or
|
|
102
|
+
* reroll inherits it in turn. No-op when no `cs`/`cf` governs `parent`, which
|
|
103
|
+
* leaves the `createDieResult` default rule in place.
|
|
104
|
+
*
|
|
105
|
+
* `natural` is the face the `'default'` sentinel reads, defaulting to the
|
|
106
|
+
* child's own. Penetrating explode passes its raw roll — see the module note.
|
|
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`.
|
|
111
|
+
*/
|
|
112
|
+
export function inheritCritRule(
|
|
113
|
+
env: EvalEnv,
|
|
114
|
+
parent: DieResult,
|
|
115
|
+
child: DieResult,
|
|
116
|
+
natural = child.initialResult ?? child.result,
|
|
117
|
+
): void {
|
|
118
|
+
const rules = env.critRules;
|
|
119
|
+
if (rules === undefined) return;
|
|
120
|
+
|
|
121
|
+
const rule = rules.get(parent);
|
|
122
|
+
if (rule === undefined) return;
|
|
123
|
+
|
|
124
|
+
applyCritRule(child, rule, natural);
|
|
125
|
+
rules.set(child, rule);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function matchesCrit(threshold: ResolvedCritThreshold, die: DieResult, natural: number): boolean {
|
|
44
129
|
if (threshold === 'default') {
|
|
45
|
-
return
|
|
130
|
+
return natural === die.sides && die.sides > 1;
|
|
46
131
|
}
|
|
47
132
|
return matchesCondition(die.result, threshold.operator, threshold.value);
|
|
48
133
|
}
|
|
49
134
|
|
|
50
|
-
function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult): boolean {
|
|
135
|
+
function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult, natural: number): boolean {
|
|
51
136
|
if (threshold === 'default') {
|
|
52
137
|
// Mirrors `createDieResult` — a d1 always rolls 1, never a fumble.
|
|
53
|
-
return
|
|
138
|
+
return natural === 1 && die.sides > 1;
|
|
54
139
|
}
|
|
55
140
|
return matchesCondition(die.result, threshold.operator, threshold.value);
|
|
56
141
|
}
|
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
* `initialResult` (first writer wins, so a compound-explode accumulation
|
|
7
7
|
* that was clamped afterwards still reports its original first roll), and
|
|
8
8
|
* the die is tagged `'min'` / `'max'` so parts consumers can tell a clamped
|
|
9
|
-
* value from a natural one.
|
|
9
|
+
* value from a natural one. The tag is written at most once, so a chain of
|
|
10
|
+
* same-kind bounds (`4d6min3min4`) leaves a die carrying a single `'min'`.
|
|
10
11
|
*
|
|
11
12
|
* `critical` / `fumble` keep reflecting the natural face — a clamped 1 is
|
|
12
13
|
* still a fumble, matching the raw-face crit semantics used everywhere else.
|
|
14
|
+
* A following bare `cs`/`cf` agrees; a following *explicit* threshold
|
|
15
|
+
* (`4d6min5cs>4`) is a predicate over the clamped value and overrides it.
|
|
13
16
|
* Meta dice (rolled to compute counts/sides/modifier args) are skipped.
|
|
14
17
|
* Dropped dice are clamped too — they are excluded from totals anyway, and
|
|
15
18
|
* clamping them keeps the rendered pool consistent.
|
|
@@ -25,15 +28,21 @@ import { isVersusDc } from './flags.js';
|
|
|
25
28
|
* `bound: 'min'` lifts lower results up to `value`; `'max'` caps higher
|
|
26
29
|
* results down to it. Untouched dice keep their tags.
|
|
27
30
|
*/
|
|
28
|
-
export function applyDieBound(
|
|
31
|
+
export function applyDieBound(
|
|
32
|
+
dice: DieResult[],
|
|
33
|
+
bound: 'min' | 'max',
|
|
34
|
+
value: number,
|
|
35
|
+
hasVersusDc: boolean,
|
|
36
|
+
): void {
|
|
29
37
|
for (const die of dice) {
|
|
30
|
-
if (die.modifiers.includes('meta')
|
|
38
|
+
if (die.modifiers.includes('meta')) continue;
|
|
39
|
+
if (hasVersusDc && isVersusDc(die)) continue;
|
|
31
40
|
|
|
32
41
|
const clamped = bound === 'min' ? Math.max(die.result, value) : Math.min(die.result, value);
|
|
33
42
|
if (clamped === die.result) continue;
|
|
34
43
|
|
|
35
44
|
die.initialResult ??= die.result;
|
|
36
45
|
die.result = clamped;
|
|
37
|
-
die.modifiers.push(bound);
|
|
46
|
+
if (!die.modifiers.includes(bound)) die.modifiers.push(bound);
|
|
38
47
|
}
|
|
39
48
|
}
|
|
@@ -13,6 +13,7 @@ import type { RNG } from '../../rng/types.js';
|
|
|
13
13
|
import type { CompareOp, DieResult } from '../../types.js';
|
|
14
14
|
import { createDieResult } from '../die.js';
|
|
15
15
|
import { chargeDie, type EvalEnv } from '../env.js';
|
|
16
|
+
import { inheritCritRule } from './crit-threshold.js';
|
|
16
17
|
import { isVersusDc } from './flags.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -84,8 +85,8 @@ function explodeLimitError(maxIterations: number): EvaluatorError {
|
|
|
84
85
|
* never fire during normal flow. Keeping it ensures `rng.nextInt(1, 0)`
|
|
85
86
|
* can never be reached if a future AST path slips past the parser gate.
|
|
86
87
|
*/
|
|
87
|
-
function canExplode(die: DieResult): boolean {
|
|
88
|
-
if (isVersusDc(die)) return false;
|
|
88
|
+
function canExplode(die: DieResult, hasVersusDc: boolean): boolean {
|
|
89
|
+
if (hasVersusDc && isVersusDc(die)) return false;
|
|
89
90
|
if (die.modifiers.includes('dropped')) return false;
|
|
90
91
|
if (die.sides < 1) return false;
|
|
91
92
|
return true;
|
|
@@ -99,6 +100,9 @@ function canExplode(die: DieResult): boolean {
|
|
|
99
100
|
*
|
|
100
101
|
* `critical`/`fumble` are likewise derived from the raw roll — a penetrating
|
|
101
102
|
* die that rolled its max face is still a crit even though it stores one less.
|
|
103
|
+
* An inherited `cs`/`cf` keeps that: it is applied after `storeResult`, so an
|
|
104
|
+
* explicit threshold reads the stored value, while the raw roll is handed over
|
|
105
|
+
* for the `'default'` sentinel to read.
|
|
102
106
|
*/
|
|
103
107
|
function applyAppendingExplode(
|
|
104
108
|
pool: DieResult[],
|
|
@@ -111,7 +115,7 @@ function applyAppendingExplode(
|
|
|
111
115
|
|
|
112
116
|
for (const original of pool) {
|
|
113
117
|
result.push(original);
|
|
114
|
-
if (!canExplode(original)) continue;
|
|
118
|
+
if (!canExplode(original, env.hasVersusDc)) continue;
|
|
115
119
|
|
|
116
120
|
const sides = original.sides;
|
|
117
121
|
let lastRaw = original.result;
|
|
@@ -124,6 +128,7 @@ function applyAppendingExplode(
|
|
|
124
128
|
const raw = rollExplosion(sides, rng, env);
|
|
125
129
|
const die = createDieResult(sides, raw, ['exploded', 'kept']);
|
|
126
130
|
die.result = storeResult(raw);
|
|
131
|
+
inheritCritRule(env, original, die, raw);
|
|
127
132
|
result.push(die);
|
|
128
133
|
lastRaw = raw;
|
|
129
134
|
iterations += 1;
|
|
@@ -172,7 +177,7 @@ export function applyCompoundExplode(
|
|
|
172
177
|
env: EvalEnv,
|
|
173
178
|
): DieResult[] {
|
|
174
179
|
for (const original of pool) {
|
|
175
|
-
if (!canExplode(original)) continue;
|
|
180
|
+
if (!canExplode(original, env.hasVersusDc)) continue;
|
|
176
181
|
|
|
177
182
|
const sides = original.sides;
|
|
178
183
|
let accumulated = original.result;
|
|
@@ -194,8 +199,9 @@ export function applyCompoundExplode(
|
|
|
194
199
|
if (!exploded) continue;
|
|
195
200
|
|
|
196
201
|
// `critical` and `fumble` keep referring to the original triggering
|
|
197
|
-
// roll — after compounding the `result` is a sum.
|
|
198
|
-
|
|
202
|
+
// roll — after compounding the `result` is a sum. First writer wins, so
|
|
203
|
+
// a preceding `minN`/`maxN` clamp keeps its record of the raw face.
|
|
204
|
+
original.initialResult ??= original.result;
|
|
199
205
|
original.result = accumulated;
|
|
200
206
|
if (!original.modifiers.includes('exploded')) {
|
|
201
207
|
original.modifiers = [...original.modifiers, 'exploded'];
|
|
@@ -20,6 +20,10 @@ import type { DieModifier, DieResult } from '../../types.js';
|
|
|
20
20
|
* operates on. Nothing may sum, select, clamp, reroll, explode, or tally them
|
|
21
21
|
* — a `d10` on the DC side must never come back clamped to 20, and
|
|
22
22
|
* `{1d20 vs 2d10, 1d4}>=5` must not count the DC faces as successes.
|
|
23
|
+
*
|
|
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 tag
|
|
26
|
+
* ! (#281); the flag is `false` until a `vs` has actually tagged something.
|
|
23
27
|
*/
|
|
24
28
|
export function isVersusDc(die: DieResult): boolean {
|
|
25
29
|
return die.modifiers.includes('dc');
|
|
@@ -40,6 +44,19 @@ export const SELECTION_AND_TALLY_FLAGS: readonly DieModifier[] = [
|
|
|
40
44
|
'failure',
|
|
41
45
|
];
|
|
42
46
|
|
|
47
|
+
/**
|
|
48
|
+
* {@link SELECTION_AND_TALLY_FLAGS} plus `'meta'`, for a meta context merging
|
|
49
|
+
* into a parent. Meta operands nest (`((1d2)d4)d6`), so a die passes through
|
|
50
|
+
* the merge once per level and the tag must be rebuilt, not appended.
|
|
51
|
+
*/
|
|
52
|
+
export const META_MERGE_FLAGS: readonly DieModifier[] = [
|
|
53
|
+
'kept',
|
|
54
|
+
'dropped',
|
|
55
|
+
'success',
|
|
56
|
+
'failure',
|
|
57
|
+
'meta',
|
|
58
|
+
];
|
|
59
|
+
|
|
43
60
|
/** Selection flags plus `rerolled` — reassigned on every reroll pass. */
|
|
44
61
|
export const REROLL_SLOT_FLAGS: readonly DieModifier[] = ['kept', 'dropped', 'rerolled'];
|
|
45
62
|
|
|
@@ -29,9 +29,10 @@ export function markDroppedIndices(
|
|
|
29
29
|
kind: KeepDropSpec['kind'],
|
|
30
30
|
selector: KeepDropSpec['selector'],
|
|
31
31
|
droppedMask: Uint8Array,
|
|
32
|
+
hasVersusDc: boolean,
|
|
32
33
|
): void {
|
|
33
34
|
if (count === 1) {
|
|
34
|
-
markSingleExtreme(dice, kind, selector, droppedMask);
|
|
35
|
+
markSingleExtreme(dice, kind, selector, droppedMask, hasVersusDc);
|
|
35
36
|
return;
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -40,7 +41,7 @@ export function markDroppedIndices(
|
|
|
40
41
|
for (let index = 0; index < dice.length; index++) {
|
|
41
42
|
const die = dice[index];
|
|
42
43
|
if (die == null) continue;
|
|
43
|
-
if (isVersusDc(die)) continue;
|
|
44
|
+
if (hasVersusDc && isVersusDc(die)) continue;
|
|
44
45
|
|
|
45
46
|
if (die.modifiers.includes('dropped')) {
|
|
46
47
|
droppedMask[index] = 1;
|
|
@@ -91,6 +92,7 @@ function markSingleExtreme(
|
|
|
91
92
|
kind: KeepDropSpec['kind'],
|
|
92
93
|
selector: KeepDropSpec['selector'],
|
|
93
94
|
droppedMask: Uint8Array,
|
|
95
|
+
hasVersusDc: boolean,
|
|
94
96
|
): void {
|
|
95
97
|
const isKeep = kind === 'keep';
|
|
96
98
|
const wantHighest = selector === 'highest';
|
|
@@ -101,7 +103,7 @@ function markSingleExtreme(
|
|
|
101
103
|
for (let index = 0; index < dice.length; index++) {
|
|
102
104
|
const die = dice[index];
|
|
103
105
|
if (die == null) continue;
|
|
104
|
-
if (isVersusDc(die)) continue;
|
|
106
|
+
if (hasVersusDc && isVersusDc(die)) continue;
|
|
105
107
|
|
|
106
108
|
if (die.modifiers.includes('dropped')) {
|
|
107
109
|
droppedMask[index] = 1;
|
|
@@ -135,13 +137,15 @@ function markSingleExtreme(
|
|
|
135
137
|
* Calculates total from dice, excluding dropped dice.
|
|
136
138
|
*
|
|
137
139
|
* @param dice - Array of die results
|
|
140
|
+
* @param hasVersusDc - Shared env flag; skips the DC exclusion when no `vs` has
|
|
141
|
+
* tagged anything
|
|
138
142
|
* @returns Sum of non-dropped dice
|
|
139
143
|
*/
|
|
140
|
-
export function sumKeptDice(dice: DieResult[]): number {
|
|
144
|
+
export function sumKeptDice(dice: DieResult[], hasVersusDc: boolean): number {
|
|
141
145
|
let total = 0;
|
|
142
146
|
|
|
143
147
|
for (const die of dice) {
|
|
144
|
-
if (isVersusDc(die)) continue;
|
|
148
|
+
if (hasVersusDc && isVersusDc(die)) continue;
|
|
145
149
|
if (!die.modifiers.includes('dropped')) total += die.result;
|
|
146
150
|
}
|
|
147
151
|
|
|
@@ -15,6 +15,7 @@ import type { CompareOp, DieResult } from '../../types.js';
|
|
|
15
15
|
import { createDieResult, createFateDieResult } from '../die.js';
|
|
16
16
|
import { chargeDie, type EvalEnv } from '../env.js';
|
|
17
17
|
import { matchesCondition } from './compare.js';
|
|
18
|
+
import { inheritCritRule } from './crit-threshold.js';
|
|
18
19
|
import { isVersusDc, REROLL_SLOT_FLAGS, rewriteFlags } from './flags.js';
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -31,15 +32,22 @@ import { isVersusDc, REROLL_SLOT_FLAGS, rewriteFlags } from './flags.js';
|
|
|
31
32
|
export const DEFAULT_MAX_REROLL_ITERATIONS = 1_000;
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
|
-
* Rolls a replacement
|
|
35
|
-
*
|
|
35
|
+
* Rolls a replacement for `parent`, charging it against the global dice limit.
|
|
36
|
+
* Fate dice (sides === 0) re-roll on the {-1, 0, +1} range. Any `cs`/`cf`
|
|
37
|
+
* governing `parent` carries over, so a replacement is judged by the rule the
|
|
38
|
+
* user declared rather than the built-in default.
|
|
36
39
|
*/
|
|
37
|
-
function rollReplacement(
|
|
40
|
+
function rollReplacement(parent: DieResult, rng: RNG, env: EvalEnv): DieResult {
|
|
38
41
|
chargeDie(env, 'Reroll');
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
const sides = parent.sides;
|
|
44
|
+
const die =
|
|
45
|
+
sides === 0
|
|
46
|
+
? createFateDieResult(rng.nextInt(-1, 1), [])
|
|
47
|
+
: createDieResult(sides, rng.nextInt(1, sides), []);
|
|
41
48
|
|
|
42
|
-
|
|
49
|
+
inheritCritRule(env, parent, die);
|
|
50
|
+
return die;
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
function rerollLimitError(maxIterations: number): EvaluatorError {
|
|
@@ -54,8 +62,9 @@ function rerollLimitError(maxIterations: number): EvaluatorError {
|
|
|
54
62
|
* True for dice eligible to start rerolling. Dropped dice (from a preceding
|
|
55
63
|
* keep/drop modifier) are left alone.
|
|
56
64
|
*/
|
|
57
|
-
function canReroll(die: DieResult): boolean {
|
|
58
|
-
|
|
65
|
+
function canReroll(die: DieResult, hasVersusDc: boolean): boolean {
|
|
66
|
+
if (hasVersusDc && isVersusDc(die)) return false;
|
|
67
|
+
return !die.modifiers.includes('dropped');
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
/**
|
|
@@ -76,7 +85,7 @@ export function applyRecursiveReroll(
|
|
|
76
85
|
const result: DieResult[] = [];
|
|
77
86
|
|
|
78
87
|
for (const original of pool) {
|
|
79
|
-
if (!canReroll(original)) {
|
|
88
|
+
if (!canReroll(original, env.hasVersusDc)) {
|
|
80
89
|
result.push(original);
|
|
81
90
|
continue;
|
|
82
91
|
}
|
|
@@ -94,7 +103,7 @@ export function applyRecursiveReroll(
|
|
|
94
103
|
current.modifiers = rewriteFlags(current.modifiers, REROLL_SLOT_FLAGS, 'rerolled', 'dropped');
|
|
95
104
|
result.push(current);
|
|
96
105
|
|
|
97
|
-
current = rollReplacement(current
|
|
106
|
+
current = rollReplacement(current, rng, env);
|
|
98
107
|
iterations += 1;
|
|
99
108
|
}
|
|
100
109
|
|
|
@@ -120,7 +129,7 @@ export function applyRerollOnce(
|
|
|
120
129
|
const result: DieResult[] = [];
|
|
121
130
|
|
|
122
131
|
for (const original of pool) {
|
|
123
|
-
if (!canReroll(original)) {
|
|
132
|
+
if (!canReroll(original, env.hasVersusDc)) {
|
|
124
133
|
result.push(original);
|
|
125
134
|
continue;
|
|
126
135
|
}
|
|
@@ -135,7 +144,7 @@ export function applyRerollOnce(
|
|
|
135
144
|
original.modifiers = rewriteFlags(original.modifiers, REROLL_SLOT_FLAGS, 'rerolled', 'dropped');
|
|
136
145
|
result.push(original);
|
|
137
146
|
|
|
138
|
-
const replacement = rollReplacement(original
|
|
147
|
+
const replacement = rollReplacement(original, rng, env);
|
|
139
148
|
replacement.modifiers = rewriteFlags(replacement.modifiers, REROLL_SLOT_FLAGS, 'kept');
|
|
140
149
|
result.push(replacement);
|
|
141
150
|
}
|