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
|
@@ -10,17 +10,18 @@
|
|
|
10
10
|
* excluded from counting and are never tagged.
|
|
11
11
|
*
|
|
12
12
|
* Mutates the input pool in place to add `'success'` / `'failure'` modifier
|
|
13
|
-
* flags — mirrors the mutation pattern of explode and reroll modifiers.
|
|
14
|
-
*
|
|
15
|
-
* (`{4d6>=5}
|
|
16
|
-
*
|
|
13
|
+
* flags — mirrors the mutation pattern of explode and reroll modifiers. The
|
|
14
|
+
* tags are rebuilt, not appended: a group counted after its members
|
|
15
|
+
* (`{4d6>=5}<=2f5`) runs this pass twice over the same dice, and the outermost
|
|
16
|
+
* pass is the one whose arithmetic `RollResult.successes` / `failures` and the
|
|
17
|
+
* rendered markers report.
|
|
17
18
|
*
|
|
18
19
|
* @module evaluator/modifiers/success-count
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
import type { DieResult, ResolvedComparePoint } from '../../types.js';
|
|
22
23
|
import { matchesCondition } from './compare.js';
|
|
23
|
-
import { isVersusDc } from './flags.js';
|
|
24
|
+
import { isVersusDc, stripFlags, TALLY_FLAGS } from './flags.js';
|
|
24
25
|
|
|
25
26
|
export type SuccessCountResult = {
|
|
26
27
|
total: number;
|
|
@@ -33,27 +34,33 @@ export function countSuccesses(
|
|
|
33
34
|
threshold: ResolvedComparePoint,
|
|
34
35
|
failThreshold: ResolvedComparePoint | undefined,
|
|
35
36
|
hasVersusDc: boolean,
|
|
37
|
+
poolAlreadyCounted: boolean,
|
|
36
38
|
): SuccessCountResult {
|
|
37
39
|
let successes = 0;
|
|
38
40
|
let failures = 0;
|
|
39
41
|
|
|
40
42
|
// ! Excluded up front, never inside the loop below. Even short-circuited on a
|
|
41
43
|
// ! false flag, a `hasVersusDc && isVersusDc(die)` guard in this loop costs
|
|
42
|
-
// ! ~9% on `10d10>=6f1` — measured, not assumed
|
|
44
|
+
// ! ~9% on `10d10>=6f1` — measured, not assumed. Filtering keeps the
|
|
43
45
|
// ! hot body identical to the pre-exclusion one and pays an allocation only
|
|
44
46
|
// ! on the `vs` path. The dice are the same objects either way, so the
|
|
45
47
|
// ! `'success'` / `'failure'` tags written below still land on the pool.
|
|
46
48
|
const pool = hasVersusDc ? dice.filter((die) => !isVersusDc(die)) : dice;
|
|
47
49
|
|
|
48
|
-
// !
|
|
49
|
-
// !
|
|
50
|
-
// !
|
|
51
|
-
|
|
50
|
+
// ! Dropped dice are stripped too, not just the ones re-tagged below. This
|
|
51
|
+
// ! pass skips them, so an inner count's tag would otherwise survive into the
|
|
52
|
+
// ! top-level successes/failures scan and break `total === successes - failures`.
|
|
53
|
+
if (poolAlreadyCounted) {
|
|
54
|
+
for (const die of pool) {
|
|
55
|
+
die.modifiers = stripFlags(die.modifiers, TALLY_FLAGS);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
52
59
|
for (const die of pool) {
|
|
53
60
|
if (die.modifiers.includes('dropped')) continue;
|
|
54
61
|
|
|
55
62
|
if (matchesCondition(die.result, threshold.operator, threshold.value)) {
|
|
56
|
-
|
|
63
|
+
die.modifiers.push('success');
|
|
57
64
|
successes += 1;
|
|
58
65
|
continue;
|
|
59
66
|
}
|
|
@@ -62,7 +69,7 @@ export function countSuccesses(
|
|
|
62
69
|
failThreshold != null &&
|
|
63
70
|
matchesCondition(die.result, failThreshold.operator, failThreshold.value)
|
|
64
71
|
) {
|
|
65
|
-
|
|
72
|
+
die.modifiers.push('failure');
|
|
66
73
|
failures += 1;
|
|
67
74
|
}
|
|
68
75
|
}
|
package/src/notation.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notation serialization shared by the two breakdown builders — the
|
|
3
|
+
* evaluator's `expression`/`rendered` strings and `render.ts`.
|
|
4
|
+
*
|
|
5
|
+
* @module notation
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ! `cs`, `cf`, `s`, and `sd` are the only codes ending in a letter the lexer
|
|
9
|
+
// ! scans as an identifier — maximal munch then swallows what follows, so
|
|
10
|
+
// ! `cs` + `cf` re-lexes as `cscf`. `4dF` and `!p` are their own tokens.
|
|
11
|
+
const BARE_MODIFIER_END = /(?:cs|cf|sd|s)$/;
|
|
12
|
+
|
|
13
|
+
const STARTS_WITH_LETTER = /^[A-Za-z]/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Appends a modifier code to the expression built so far, separating the two
|
|
17
|
+
* with a space when concatenating them would re-lex as a single identifier.
|
|
18
|
+
* The space is the same separator the input used to make them parse.
|
|
19
|
+
*/
|
|
20
|
+
export function joinModifierCode(expression: string, code: string): string {
|
|
21
|
+
return BARE_MODIFIER_END.test(expression) && STARTS_WITH_LETTER.test(code)
|
|
22
|
+
? `${expression} ${code}`
|
|
23
|
+
: `${expression}${code}`;
|
|
24
|
+
}
|
package/src/parser/guards.ts
CHANGED
|
@@ -61,7 +61,7 @@ export function unwrapAllTransparent(node: ASTNode): ASTNode {
|
|
|
61
61
|
* Returns `true` when `node` or any descendant satisfies `isHit`. Recurses
|
|
62
62
|
* directly through the entire node vocabulary: arithmetic operands,
|
|
63
63
|
* modifier-chain targets, `Versus` sides, function arguments, and group
|
|
64
|
-
* sub-expressions. This is the shared driver behind the
|
|
64
|
+
* sub-expressions. This is the shared driver behind the deep walkers
|
|
65
65
|
* below. The shallow walkers (`containsDicePool`, `containsFatePool`) stay
|
|
66
66
|
* hand-written because their rejection semantics deliberately stop at
|
|
67
67
|
* arithmetic boundaries.
|
|
@@ -112,6 +112,9 @@ const isDicePoolHit = (current: ASTNode): boolean =>
|
|
|
112
112
|
|
|
113
113
|
const isFateDiceHit = (current: ASTNode): boolean => current.type === 'FateDice';
|
|
114
114
|
|
|
115
|
+
const isDiceHit = (current: ASTNode): boolean =>
|
|
116
|
+
current.type === 'Dice' || current.type === 'FateDice';
|
|
117
|
+
|
|
115
118
|
const isMultiSubGroupHit = (current: ASTNode): boolean =>
|
|
116
119
|
current.type === 'Group' && current.expressions.length >= 2;
|
|
117
120
|
|
|
@@ -168,6 +171,94 @@ export function deepContainsDicePool(node: ASTNode): boolean {
|
|
|
168
171
|
return someDescendant(node, isDicePoolHit);
|
|
169
172
|
}
|
|
170
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Returns `true` when this node's total provably equals the sum of its kept
|
|
176
|
+
* dice faces: a pool, a chain of pool modifiers over one, or an addition of
|
|
177
|
+
* those. A multi-sub-roll `Group` qualifies when every sub-roll does, since its
|
|
178
|
+
* total is their sum and `evalGroupKeepDrop` re-flags dropped sub-rolls' dice.
|
|
179
|
+
*
|
|
180
|
+
* Guards the single-sub-roll `Group` keep/drop target, whose flat-pool path
|
|
181
|
+
* totals `sumKeptDice`. Anything else in there — a literal, a subtracted or
|
|
182
|
+
* scaled dice term, a function call, a success count — contributes to the
|
|
183
|
+
* group's own total but not to that face sum, so `{2d6+3}kh2` would lose the
|
|
184
|
+
* `+3` and `{2d6-1d4}kh3` would flip the `1d4` from `-3` to `+3` while dropping
|
|
185
|
+
* nothing.
|
|
186
|
+
*/
|
|
187
|
+
export function sumsToKeptFaces(node: ASTNode): boolean {
|
|
188
|
+
switch (node.type) {
|
|
189
|
+
case 'Dice':
|
|
190
|
+
case 'FateDice':
|
|
191
|
+
return true;
|
|
192
|
+
case 'KeepDrop':
|
|
193
|
+
case 'Explode':
|
|
194
|
+
case 'Reroll':
|
|
195
|
+
case 'DieBound':
|
|
196
|
+
case 'Sort':
|
|
197
|
+
case 'CritThreshold':
|
|
198
|
+
return sumsToKeptFaces(node.target);
|
|
199
|
+
case 'Grouped':
|
|
200
|
+
return sumsToKeptFaces(node.expression);
|
|
201
|
+
case 'Group':
|
|
202
|
+
return node.expressions.every(sumsToKeptFaces);
|
|
203
|
+
case 'BinaryOp':
|
|
204
|
+
return node.operator === '+' && sumsToKeptFaces(node.left) && sumsToKeptFaces(node.right);
|
|
205
|
+
default:
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Returns `true` when comparing this node's dice one face at a time is faithful
|
|
212
|
+
* to what it totals. Shaped like `sumsToKeptFaces` but answering a different
|
|
213
|
+
* question, so the two differ on three node types:
|
|
214
|
+
*
|
|
215
|
+
* - `SuccessCount` passes — a nested count re-scores the very same faces.
|
|
216
|
+
* - `Versus` looks only at its roll side — no pool pass ever counts a DC.
|
|
217
|
+
* - a multi-sub-roll `Group` fails everywhere, because its units are subtotals.
|
|
218
|
+
* `sumsToKeptFaces` accepts one, a sum of subtotals still being a face sum.
|
|
219
|
+
*
|
|
220
|
+
* Guards the flat success-count path, whose units are individual dice. The
|
|
221
|
+
* subtotal path takes a direct multi-sub-roll `Group` before this is consulted.
|
|
222
|
+
*/
|
|
223
|
+
export function countsPerDie(node: ASTNode): boolean {
|
|
224
|
+
switch (node.type) {
|
|
225
|
+
case 'Dice':
|
|
226
|
+
case 'FateDice':
|
|
227
|
+
return true;
|
|
228
|
+
case 'KeepDrop':
|
|
229
|
+
case 'Explode':
|
|
230
|
+
case 'Reroll':
|
|
231
|
+
case 'DieBound':
|
|
232
|
+
case 'Sort':
|
|
233
|
+
case 'CritThreshold':
|
|
234
|
+
case 'SuccessCount':
|
|
235
|
+
return countsPerDie(node.target);
|
|
236
|
+
case 'Versus':
|
|
237
|
+
return countsPerDie(node.roll);
|
|
238
|
+
case 'Grouped':
|
|
239
|
+
return countsPerDie(node.expression);
|
|
240
|
+
case 'Group':
|
|
241
|
+
return node.expressions.length === 1 && node.expressions.every(countsPerDie);
|
|
242
|
+
case 'BinaryOp':
|
|
243
|
+
return node.operator === '+' && countsPerDie(node.left) && countsPerDie(node.right);
|
|
244
|
+
default:
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Deep-walks a node to find any descendant `Dice` or `FateDice`. Unlike
|
|
251
|
+
* `deepContainsDicePool`, a multi-sub-roll `Group` is not a hit in its own
|
|
252
|
+
* right — only real dice are.
|
|
253
|
+
*
|
|
254
|
+
* Used by the success-count parser guard, which tallies individual dice rather
|
|
255
|
+
* than subtotals: a literal-only group satisfies `containsDicePool` yet rolls
|
|
256
|
+
* nothing to tally.
|
|
257
|
+
*/
|
|
258
|
+
export function containsDice(node: ASTNode): boolean {
|
|
259
|
+
return someDescendant(node, isDiceHit);
|
|
260
|
+
}
|
|
261
|
+
|
|
171
262
|
/**
|
|
172
263
|
* Returns `true` if the pool this node resolves to is (or wraps) a `FateDice`
|
|
173
264
|
* pool. Walks through chained pool modifiers (`KeepDrop` / `Explode` /
|
package/src/parser/parser.ts
CHANGED
|
@@ -32,11 +32,14 @@ import type {
|
|
|
32
32
|
} from './ast.js';
|
|
33
33
|
import { isCritThreshold, isSuccessCount } from './ast.js';
|
|
34
34
|
import {
|
|
35
|
+
containsDice,
|
|
35
36
|
containsDicePool,
|
|
36
37
|
containsFatePool,
|
|
37
38
|
containsMultiSubGroup,
|
|
38
39
|
containsVersus,
|
|
40
|
+
countsPerDie,
|
|
39
41
|
deepContainsDicePool,
|
|
42
|
+
sumsToKeptFaces,
|
|
40
43
|
unwrapAllTransparent,
|
|
41
44
|
unwrapGrouped,
|
|
42
45
|
} from './guards.js';
|
|
@@ -767,6 +770,39 @@ export class Parser {
|
|
|
767
770
|
);
|
|
768
771
|
}
|
|
769
772
|
|
|
773
|
+
// ! A single-sub-roll Group is the flat-pool escape hatch, so `containsDicePool`
|
|
774
|
+
// ! deep-walks the arithmetic that `(1d6+5)kh1` rejects outright — but the flat
|
|
775
|
+
// ! path totals `sumKeptDice`, faces only. `{2d6+3}kh2` and `{2d6-1d4}kh3` are
|
|
776
|
+
// ! exactly the drop that reject exists to prevent.
|
|
777
|
+
const base = unwrapAllTransparent(target);
|
|
778
|
+
if (base.type === 'Group' && base.expressions.length === 1) {
|
|
779
|
+
const inner = base.expressions[0];
|
|
780
|
+
if (inner != null) {
|
|
781
|
+
// Single-sub Groups hide a count from the shallow reject at the top of this
|
|
782
|
+
// method, so peel them to any depth and `{4d6>=5}kh1`, `{{4d6>=5}}kh1`, and
|
|
783
|
+
// `(4d6>=5)kh1` all report one code. Peeling only picks which error the
|
|
784
|
+
// caller sees — `sumsToKeptFaces` below refuses a count either way.
|
|
785
|
+
let innermost = inner;
|
|
786
|
+
while (true) {
|
|
787
|
+
const peeled = unwrapGrouped(innermost);
|
|
788
|
+
if (peeled.type !== 'Group' || peeled.expressions.length !== 1) break;
|
|
789
|
+
const next = peeled.expressions[0];
|
|
790
|
+
if (next == null) break;
|
|
791
|
+
innermost = next;
|
|
792
|
+
}
|
|
793
|
+
this.rejectSuccessCountTarget(innermost, token);
|
|
794
|
+
|
|
795
|
+
if (!sumsToKeptFaces(inner)) {
|
|
796
|
+
throw new ParseError(
|
|
797
|
+
`Keep/drop on a single-sub-roll group requires added dice terms only`,
|
|
798
|
+
'INVALID_KEEP_DROP_TARGET',
|
|
799
|
+
token.position,
|
|
800
|
+
token,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
770
806
|
const kind =
|
|
771
807
|
token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
|
|
772
808
|
|
|
@@ -1087,7 +1123,12 @@ export class Parser {
|
|
|
1087
1123
|
// Success counting reads a raw pool, so arithmetic or composition wrappers
|
|
1088
1124
|
// (`1>=3`, `(1+2)>=3`, `(1d6*2)>=10`, `(1d20 vs 15)>=1`) would be silently
|
|
1089
1125
|
// ignored.
|
|
1090
|
-
|
|
1126
|
+
//
|
|
1127
|
+
// ! Both checks are load-bearing. `containsDicePool` rejects those wrappers
|
|
1128
|
+
// ! despite the dice; `containsDice` rejects a group holding none
|
|
1129
|
+
// ! (`{3, 5, 7}>=4`), which the multi-sub-roll rule accepts — that rule is
|
|
1130
|
+
// ! written for keep/drop, whose units are subtotals, not dice.
|
|
1131
|
+
if (!containsDicePool(target) || !containsDice(target)) {
|
|
1091
1132
|
throw new ParseError(
|
|
1092
1133
|
`Success counting requires a dice pool target`,
|
|
1093
1134
|
'INVALID_SUCCESS_COUNT_TARGET',
|
|
@@ -1096,6 +1137,20 @@ export class Parser {
|
|
|
1096
1137
|
);
|
|
1097
1138
|
}
|
|
1098
1139
|
|
|
1140
|
+
// ! A multi-sub-roll group is counted by subtotal, and only `evalSuccessCount`
|
|
1141
|
+
// ! knows how — it needs the group as its direct target. Reached any other way
|
|
1142
|
+
// ! (`{{2d6, 2d8}}>=4`, `({2d6, 2d8})>=4`, `{2d6, 2d8}kh1>=4`) the subtotals are
|
|
1143
|
+
// ! gone by the time the count runs, leaving loose faces to compare.
|
|
1144
|
+
const isSubtotalGroup = target.type === 'Group' && target.expressions.length >= 2;
|
|
1145
|
+
if (!isSubtotalGroup && !countsPerDie(target)) {
|
|
1146
|
+
throw new ParseError(
|
|
1147
|
+
`Success counting requires a target whose dice can be counted one face at a time`,
|
|
1148
|
+
'INVALID_SUCCESS_COUNT_TARGET',
|
|
1149
|
+
token.position,
|
|
1150
|
+
token,
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1099
1154
|
const operator = this.getCompareOp(token);
|
|
1100
1155
|
// Threshold binds at `BP.DICE_LEFT` — see `parseComparePoint` TSDoc.
|
|
1101
1156
|
const value = this.parseExpression(BP.DICE_LEFT);
|
package/src/render.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @module render
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { joinModifierCode } from './notation.js';
|
|
12
13
|
import type {
|
|
13
14
|
DieResult,
|
|
14
15
|
KeepDropSpec,
|
|
@@ -122,7 +123,6 @@ function failCode(point: ResolvedComparePoint): string {
|
|
|
122
123
|
function expr(part: RollPart): string {
|
|
123
124
|
switch (part.type) {
|
|
124
125
|
case 'literal':
|
|
125
|
-
return String(part.value);
|
|
126
126
|
case 'variable':
|
|
127
127
|
return String(part.value);
|
|
128
128
|
case 'dice':
|
|
@@ -140,17 +140,17 @@ function expr(part: RollPart): string {
|
|
|
140
140
|
case 'functionCall':
|
|
141
141
|
return `${part.name}(${part.args.map(expr).join(', ')})`;
|
|
142
142
|
case 'keepDrop':
|
|
143
|
-
return
|
|
143
|
+
return joinModifierCode(expr(part.target), part.specs.map(keepDropCode).join(''));
|
|
144
144
|
case 'explode':
|
|
145
145
|
return `${expr(part.target)}${explodeCode(part)}`;
|
|
146
146
|
case 'reroll':
|
|
147
|
-
return
|
|
147
|
+
return joinModifierCode(expr(part.target), rerollCode(part));
|
|
148
148
|
case 'dieBound':
|
|
149
|
-
return
|
|
149
|
+
return joinModifierCode(expr(part.target), dieBoundCode(part));
|
|
150
150
|
case 'sort':
|
|
151
|
-
return
|
|
151
|
+
return joinModifierCode(expr(part.target), part.order === 'ascending' ? 's' : 'sd');
|
|
152
152
|
case 'critThreshold':
|
|
153
|
-
return
|
|
153
|
+
return joinModifierCode(expr(part.target), critThresholdCode(part));
|
|
154
154
|
case 'successCount':
|
|
155
155
|
return `${expr(part.target)}${successCountCode(part)}`;
|
|
156
156
|
case 'versus':
|
|
@@ -163,6 +163,10 @@ function explodeCode(part: Extract<RollPart, { type: 'explode' }>): string {
|
|
|
163
163
|
return part.threshold == null ? marker : `${marker}${comparePointCode(part.threshold)}`;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
function rerollCode(part: Extract<RollPart, { type: 'reroll' }>): string {
|
|
167
|
+
return `${part.once ? 'ro' : 'r'}${comparePointCode(part.condition)}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
166
170
|
/** Negative bounds are parenthesized so the expression re-parses. */
|
|
167
171
|
function dieBoundCode(part: Extract<RollPart, { type: 'dieBound' }>): string {
|
|
168
172
|
return part.value < 0 ? `${part.bound}(${part.value})` : `${part.bound}${part.value}`;
|
|
@@ -172,7 +176,7 @@ function critThresholdCode(part: Extract<RollPart, { type: 'critThreshold' }>):
|
|
|
172
176
|
return [
|
|
173
177
|
...part.successThresholds.map((threshold) => critCode('cs', threshold)),
|
|
174
178
|
...part.failThresholds.map((threshold) => critCode('cf', threshold)),
|
|
175
|
-
].
|
|
179
|
+
].reduce(joinModifierCode, '');
|
|
176
180
|
}
|
|
177
181
|
|
|
178
182
|
function successCountCode(part: Extract<RollPart, { type: 'successCount' }>): string {
|
|
@@ -235,9 +239,8 @@ function poolOf(part: RollPart): DieResult[] {
|
|
|
235
239
|
}
|
|
236
240
|
|
|
237
241
|
/**
|
|
238
|
-
* Marks one die. `plain` suppresses the three state marks
|
|
239
|
-
*
|
|
240
|
-
* crit and fumble survive, since they describe the face, not the selection.
|
|
242
|
+
* Marks one die. `plain` suppresses the three state marks inside a dropped
|
|
243
|
+
* sub-roll — the rule {@link DieMarks.droppedGroup} documents.
|
|
241
244
|
*/
|
|
242
245
|
function markDie(die: DieResult, marks: DieMarks, plain: boolean): string {
|
|
243
246
|
let text = String(die.result);
|
|
@@ -279,7 +282,7 @@ function renderModifier(
|
|
|
279
282
|
plain: boolean,
|
|
280
283
|
): string {
|
|
281
284
|
const pool = dice.length === 0 ? '' : renderPool(dice, marks, plain);
|
|
282
|
-
return `${expr(target)
|
|
285
|
+
return `${joinModifierCode(expr(target), code)}${pool}`;
|
|
283
286
|
}
|
|
284
287
|
|
|
285
288
|
function renderPart(part: RollPart, marks: DieMarks, plain: boolean): string {
|
|
@@ -316,21 +319,20 @@ function renderPart(part: RollPart, marks: DieMarks, plain: boolean): string {
|
|
|
316
319
|
case 'explode':
|
|
317
320
|
return renderModifier(part.target, explodeCode(part), part.rolls, marks, plain);
|
|
318
321
|
case 'reroll':
|
|
319
|
-
return renderModifier(
|
|
320
|
-
part.target,
|
|
321
|
-
`${part.once ? 'ro' : 'r'}${comparePointCode(part.condition)}`,
|
|
322
|
-
part.rolls,
|
|
323
|
-
marks,
|
|
324
|
-
plain,
|
|
325
|
-
);
|
|
322
|
+
return renderModifier(part.target, rerollCode(part), part.rolls, marks, plain);
|
|
326
323
|
case 'successCount':
|
|
327
|
-
|
|
324
|
+
// Subtotal counting renders through the group, which shows each sub-roll's
|
|
325
|
+
// own dice; only the flat form collapses into one bracket.
|
|
326
|
+
return part.target.type === 'group' && part.target.parts.length >= 2
|
|
327
|
+
? `${renderPart(part.target, marks, plain)}${successCountCode(part)}`
|
|
328
|
+
: renderModifier(part.target, successCountCode(part), part.rolls, marks, plain);
|
|
329
|
+
// These three render as their own normalized expression plus a pool.
|
|
328
330
|
case 'dieBound':
|
|
329
|
-
return `${expr(part
|
|
331
|
+
return `${expr(part)}${renderPool(poolOf(part.target), marks, plain)}`;
|
|
330
332
|
case 'sort':
|
|
331
|
-
return `${expr(part
|
|
333
|
+
return `${expr(part)}${renderPool(part.rolls, marks, plain)}`;
|
|
332
334
|
case 'critThreshold':
|
|
333
|
-
return `${expr(part
|
|
335
|
+
return `${expr(part)}${renderPool(poolOf(part.target), marks, plain)}`;
|
|
334
336
|
}
|
|
335
337
|
}
|
|
336
338
|
|
package/src/types.ts
CHANGED
|
@@ -165,8 +165,9 @@ export type DieResult = {
|
|
|
165
165
|
/** The rolled value */
|
|
166
166
|
result: number;
|
|
167
167
|
/**
|
|
168
|
-
* Raw first roll before any mutation
|
|
169
|
-
* Only populated when `result` has
|
|
168
|
+
* Raw first roll before any mutation — compound-explode accumulation, the
|
|
169
|
+
* `!p` decrement, or a `minN`/`maxN` clamp. Only populated when `result` has
|
|
170
|
+
* been overwritten with a computed value.
|
|
170
171
|
* Consumers that need the original face (nat-20 / nat-1 detection) should
|
|
171
172
|
* read `initialResult ?? result`.
|
|
172
173
|
*/
|
|
@@ -234,7 +235,10 @@ export type RollPartBase = {
|
|
|
234
235
|
* - `literal.total === value` and `variable.total === value`.
|
|
235
236
|
* - Each part's `rolls[]` shares `DieResult` references with
|
|
236
237
|
* `RollResult.rolls[]`; both reflect post-evaluation state (explode
|
|
237
|
-
* accumulation, reroll flags, keep/drop flags). No deep
|
|
238
|
+
* accumulation, reroll flags, keep/drop flags, success/failure tags). No deep
|
|
239
|
+
* clone. A part's own numbers record what that part computed, so an inner
|
|
240
|
+
* `successCount` re-scored by an outer one (`{4d6>=5}<=2f5`) keeps its
|
|
241
|
+
* tally while the dice it shares show the outer count's tags.
|
|
238
242
|
*
|
|
239
243
|
* Meta-expression sub-trees (`4d6kh(1d2)`, `(1+1)d6` counts/sides, computed
|
|
240
244
|
* thresholds) are not surfaced as nested parts — their resolved numbers
|
|
@@ -449,6 +453,14 @@ export type RollResult = Readonly<{
|
|
|
449
453
|
* arithmetic on top of a success count (e.g. `{5d6>=5}+2`) affects `total`
|
|
450
454
|
* but not `successes`. Success counts are terminal, so the group braces are
|
|
451
455
|
* required: `5d6>=5 * 2` is an `INVALID_SUCCESS_COUNT_TARGET` parse error.
|
|
456
|
+
*
|
|
457
|
+
* Braces also let a second count re-count the same pool (`{4d6>=5}<=2f5`).
|
|
458
|
+
* The outermost count owns the outcome: it re-scores the pool against its own
|
|
459
|
+
* thresholds, so no die is ever both a success and a failure, and dice it
|
|
460
|
+
* does not count come out untagged rather than keeping the inner count's
|
|
461
|
+
* tags. The DC side of a `vs` is never tallied here; it keeps whatever tags
|
|
462
|
+
* an inner count gave it, unless the outer count scores subtotals rather
|
|
463
|
+
* than dice, which leaves every die untagged.
|
|
452
464
|
*/
|
|
453
465
|
successes?: number;
|
|
454
466
|
/**
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by `bun run generate:version` from package.json — do not edit.
|
|
2
|
-
export const version = '3.
|
|
2
|
+
export const version = '3.2.0';
|