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
|
@@ -26,17 +26,23 @@ import { isVersusDc } from './flags.js';
|
|
|
26
26
|
* Relies on `Array.prototype.sort` being stable — equal-valued dice retain
|
|
27
27
|
* their original insertion order.
|
|
28
28
|
*/
|
|
29
|
-
export function sortDice(
|
|
29
|
+
export function sortDice(
|
|
30
|
+
dice: DieResult[],
|
|
31
|
+
order: 'ascending' | 'descending',
|
|
32
|
+
hasVersusDc: boolean,
|
|
33
|
+
): DieResult[] {
|
|
30
34
|
const cmp =
|
|
31
35
|
order === 'ascending'
|
|
32
36
|
? (a: DieResult, b: DieResult) => a.result - b.result
|
|
33
37
|
: (a: DieResult, b: DieResult) => b.result - a.result;
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
// Scan before allocating: the `filter` this replaced built a throwaway array
|
|
40
|
+
// on every sort to serve a case only a `vs` can produce (#281).
|
|
41
|
+
if (!hasVersusDc || !dice.some(isVersusDc)) return [...dice].sort(cmp);
|
|
37
42
|
|
|
38
43
|
// Sort only the pool members, then lay them back into the slots they came
|
|
39
44
|
// from, leaving every DC die exactly where it was.
|
|
45
|
+
const sortable = dice.filter((die) => !isVersusDc(die));
|
|
40
46
|
sortable.sort(cmp);
|
|
41
47
|
let next = 0;
|
|
42
48
|
return dice.map((die) => (isVersusDc(die) ? die : (sortable[next++] as DieResult)));
|
|
@@ -10,7 +10,10 @@
|
|
|
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.
|
|
13
|
+
* flags — mirrors the mutation pattern of explode and reroll modifiers. Each
|
|
14
|
+
* tag is written at most once per die: a group counted after its members
|
|
15
|
+
* (`{4d6>=5}>=1`) runs this pass twice over the same dice, and the parse-time
|
|
16
|
+
* reject that blocks a direct `4d6>=5>=4` does not reach through a group.
|
|
14
17
|
*
|
|
15
18
|
* @module evaluator/modifiers/success-count
|
|
16
19
|
*/
|
|
@@ -28,17 +31,29 @@ export type SuccessCountResult = {
|
|
|
28
31
|
export function countSuccesses(
|
|
29
32
|
dice: DieResult[],
|
|
30
33
|
threshold: ResolvedComparePoint,
|
|
31
|
-
failThreshold
|
|
34
|
+
failThreshold: ResolvedComparePoint | undefined,
|
|
35
|
+
hasVersusDc: boolean,
|
|
32
36
|
): SuccessCountResult {
|
|
33
37
|
let successes = 0;
|
|
34
38
|
let failures = 0;
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
// ! Excluded up front, never inside the loop below. Even short-circuited on a
|
|
41
|
+
// ! false flag, a `hasVersusDc && isVersusDc(die)` guard in this loop costs
|
|
42
|
+
// ! ~9% on `10d10>=6f1` — measured, not assumed (#281). Filtering keeps the
|
|
43
|
+
// ! hot body identical to the pre-exclusion one and pays an allocation only
|
|
44
|
+
// ! on the `vs` path. The dice are the same objects either way, so the
|
|
45
|
+
// ! `'success'` / `'failure'` tags written below still land on the pool.
|
|
46
|
+
const pool = hasVersusDc ? dice.filter((die) => !isVersusDc(die)) : dice;
|
|
47
|
+
|
|
48
|
+
// ! The guards below only stop the *same* tag being written twice. A nested
|
|
49
|
+
// ! count with a different threshold still appends to the first pass's tags,
|
|
50
|
+
// ! so a die can end up both `'success'` and `'failure'`, and the returned
|
|
51
|
+
// ! counts then disagree with the tags in `rolls`.
|
|
52
|
+
for (const die of pool) {
|
|
38
53
|
if (die.modifiers.includes('dropped')) continue;
|
|
39
54
|
|
|
40
55
|
if (matchesCondition(die.result, threshold.operator, threshold.value)) {
|
|
41
|
-
die.modifiers.push('success');
|
|
56
|
+
if (!die.modifiers.includes('success')) die.modifiers.push('success');
|
|
42
57
|
successes += 1;
|
|
43
58
|
continue;
|
|
44
59
|
}
|
|
@@ -47,7 +62,7 @@ export function countSuccesses(
|
|
|
47
62
|
failThreshold != null &&
|
|
48
63
|
matchesCondition(die.result, failThreshold.operator, failThreshold.value)
|
|
49
64
|
) {
|
|
50
|
-
die.modifiers.push('failure');
|
|
65
|
+
if (!die.modifiers.includes('failure')) die.modifiers.push('failure');
|
|
51
66
|
failures += 1;
|
|
52
67
|
}
|
|
53
68
|
}
|
package/src/parser/ast.ts
CHANGED
|
@@ -61,8 +61,11 @@ export type DiceNode = NodeSpan & {
|
|
|
61
61
|
* Fate/Fudge dice node (`dF`).
|
|
62
62
|
* Each die produces a result in {-1, 0, +1}. No configurable sides.
|
|
63
63
|
*
|
|
64
|
-
* Fate dice carry `sides: 0` as a sentinel in their `DieResult`,
|
|
65
|
-
*
|
|
64
|
+
* Fate dice carry `sides: 0` as a sentinel in their `DieResult`, so the
|
|
65
|
+
* default `critical`/`fumble` rule never fires — it is guarded on `sides > 1`,
|
|
66
|
+
* which also stops a `+1` face from reading as a fumbled 1. An explicit
|
|
67
|
+
* threshold still applies: `4dFcs>0` and `4dFcf=-1` set the flags, while the
|
|
68
|
+
* bare `cs`/`cf` forms are rejected at parse time.
|
|
66
69
|
*
|
|
67
70
|
* @category AST
|
|
68
71
|
*/
|
|
@@ -314,9 +317,10 @@ export type SortNode = NodeSpan & {
|
|
|
314
317
|
};
|
|
315
318
|
|
|
316
319
|
/**
|
|
317
|
-
* Sentinel for bare `cs` / `cf` without a ComparePoint. Resolved
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
+
* Sentinel for bare `cs` / `cf` without a ComparePoint. Resolved at
|
|
321
|
+
* evaluation time against each die's own `sides` and its natural face
|
|
322
|
+
* (`initialResult ?? result`): critical when that face equals `sides`,
|
|
323
|
+
* fumble when it equals 1.
|
|
320
324
|
*
|
|
321
325
|
* @category AST
|
|
322
326
|
*/
|
|
@@ -325,8 +329,9 @@ export type CritThreshold = ComparePoint | 'default';
|
|
|
325
329
|
/**
|
|
326
330
|
* Critical threshold modifier node (`cs`, `cf`).
|
|
327
331
|
*
|
|
328
|
-
* Overrides the default `critical`/`fumble` flag logic for
|
|
329
|
-
*
|
|
332
|
+
* Overrides the default `critical`/`fumble` flag logic for `target`'s dice
|
|
333
|
+
* pool — including dice an enclosing `!` or `r` adds to it afterwards, which
|
|
334
|
+
* `target` never produced. Bare `cs`/`cf` uses the `'default'` sentinel
|
|
330
335
|
* (max face / 1). Custom thresholds accept any ComparePoint. Chaining
|
|
331
336
|
* collapses into a single node — `1d20cs=20cs=1cf>18` has two success
|
|
332
337
|
* and one fail threshold. Display-only: does not change `total`,
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breakdown rendering with consumer-supplied markers.
|
|
3
|
+
*
|
|
4
|
+
* `RollResult.rendered` bakes one markdown dialect into a string. This module
|
|
5
|
+
* rebuilds the same breakdown from `RollResult.parts`, letting the caller
|
|
6
|
+
* decide how each die is marked — HTML spans, ANSI codes, Telegram
|
|
7
|
+
* MarkdownV2, or nothing at all. Import it from `roll-parser/render`.
|
|
8
|
+
*
|
|
9
|
+
* @module render
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
DieResult,
|
|
14
|
+
KeepDropSpec,
|
|
15
|
+
ResolvedComparePoint,
|
|
16
|
+
ResolvedCritThreshold,
|
|
17
|
+
RollPart,
|
|
18
|
+
RollResult,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
import { DegreeOfSuccess } from './types.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Per-die markers. Every slot is optional; an omitted slot leaves the die's
|
|
24
|
+
* text untouched, so `{}` renders a breakdown with no markup whatsoever.
|
|
25
|
+
*
|
|
26
|
+
* `text` arrives as the die's value already wrapped by any inner mark, and
|
|
27
|
+
* composition order is fixed: `critical` then `fumble` innermost, then
|
|
28
|
+
* exactly one of `dropped`, `success`, or `failure` outermost — matching the
|
|
29
|
+
* priority `RollResult.rendered` uses, where a dropped die is never also
|
|
30
|
+
* shown as a success.
|
|
31
|
+
*
|
|
32
|
+
* @example A die that is both critical and dropped
|
|
33
|
+
* ```typescript
|
|
34
|
+
* { critical: (_die, text) => `<b>${text}</b>`, dropped: (_die, text) => `<s>${text}</s>` }
|
|
35
|
+
* // renders <s><b>20</b></s>
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* @category Rendering
|
|
39
|
+
*/
|
|
40
|
+
export type DieMarks = {
|
|
41
|
+
/** Excluded from the total by `kh`/`kl`/`dh`/`dl`, a reroll, or group selection. */
|
|
42
|
+
dropped?: (die: DieResult, text: string) => string;
|
|
43
|
+
/** Met a success-count threshold. */
|
|
44
|
+
success?: (die: DieResult, text: string) => string;
|
|
45
|
+
/** Met a failure threshold. */
|
|
46
|
+
failure?: (die: DieResult, text: string) => string;
|
|
47
|
+
/** `DieResult.critical` — the default rule or an explicit `cs` threshold. */
|
|
48
|
+
critical?: (die: DieResult, text: string) => string;
|
|
49
|
+
/** `DieResult.fumble` — the default rule or an explicit `cf` threshold. */
|
|
50
|
+
fumble?: (die: DieResult, text: string) => string;
|
|
51
|
+
/**
|
|
52
|
+
* Wraps a whole sub-roll dropped by group selection (`{1d8, 1d10}kh1`).
|
|
53
|
+
*
|
|
54
|
+
* Inside it, `dropped`, `success`, and `failure` are suppressed — the
|
|
55
|
+
* wrapper already carries the verdict, and marking a dropped die inside a
|
|
56
|
+
* dropped sub-roll says nothing extra. `critical` and `fumble` still apply:
|
|
57
|
+
* they describe the face, not the selection. A nested `droppedGroup` also
|
|
58
|
+
* survives, so `{{1d6, 1d8}kh1, 1d10}kh1` can wrap twice.
|
|
59
|
+
*/
|
|
60
|
+
droppedGroup?: (inner: string) => string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The marks `RollResult.rendered` itself uses. Applied when
|
|
65
|
+
* {@link renderBreakdown} is called without a `marks` argument; spread it to
|
|
66
|
+
* override one slot while keeping the rest markdown.
|
|
67
|
+
*
|
|
68
|
+
* @category Rendering
|
|
69
|
+
*/
|
|
70
|
+
export const MARKDOWN_MARKS: DieMarks = {
|
|
71
|
+
dropped: (_die, text) => `~~${text}~~`,
|
|
72
|
+
success: (_die, text) => `**${text}**`,
|
|
73
|
+
failure: (_die, text) => `__${text}__`,
|
|
74
|
+
droppedGroup: (inner) => `~~${inner}~~`,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const KEEP_DROP_CODES = {
|
|
78
|
+
keep: { highest: 'kh', lowest: 'kl' },
|
|
79
|
+
drop: { highest: 'dh', lowest: 'dl' },
|
|
80
|
+
} as const;
|
|
81
|
+
|
|
82
|
+
const EXPLODE_MARKERS = {
|
|
83
|
+
standard: '!',
|
|
84
|
+
compound: '!!',
|
|
85
|
+
penetrating: '!p',
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
const DEGREE_LABELS: Record<DegreeOfSuccess, string> = {
|
|
89
|
+
[DegreeOfSuccess.CriticalFailure]: 'Critical Failure',
|
|
90
|
+
[DegreeOfSuccess.Failure]: 'Failure',
|
|
91
|
+
[DegreeOfSuccess.Success]: 'Success',
|
|
92
|
+
[DegreeOfSuccess.CriticalSuccess]: 'Critical Success',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Bare identifier grammar; anything else was written `@{like this}`. */
|
|
96
|
+
const BARE_VARIABLE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
97
|
+
|
|
98
|
+
function comparePointCode(point: ResolvedComparePoint): string {
|
|
99
|
+
return `${point.operator}${point.value}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function critCode(prefix: 'cs' | 'cf', threshold: ResolvedCritThreshold): string {
|
|
103
|
+
return threshold === 'default' ? prefix : `${prefix}${comparePointCode(threshold)}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function keepDropCode(spec: KeepDropSpec): string {
|
|
107
|
+
return `${KEEP_DROP_CODES[spec.kind][spec.selector]}${spec.count}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `'='` is elided because bare `fN` parses back to it — `f3`, `f<3`, `f>=3`. */
|
|
111
|
+
function failCode(point: ResolvedComparePoint): string {
|
|
112
|
+
return point.operator === '=' ? `f${point.value}` : `f${comparePointCode(point)}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Rebuilds the normalized expression a part contributes to
|
|
117
|
+
* `RollResult.expression` — the prefix every dice bracket hangs off.
|
|
118
|
+
*
|
|
119
|
+
* Meta-expressions are already resolved to numbers here, so `4d6kh(1d2)`
|
|
120
|
+
* comes back as `4d6kh1`, exactly as the evaluator spells it.
|
|
121
|
+
*/
|
|
122
|
+
function expr(part: RollPart): string {
|
|
123
|
+
switch (part.type) {
|
|
124
|
+
case 'literal':
|
|
125
|
+
return String(part.value);
|
|
126
|
+
case 'variable':
|
|
127
|
+
return String(part.value);
|
|
128
|
+
case 'dice':
|
|
129
|
+
return `${part.count}d${part.sides}`;
|
|
130
|
+
case 'fateDice':
|
|
131
|
+
return `${part.count}dF`;
|
|
132
|
+
case 'grouped':
|
|
133
|
+
return `(${expr(part.inner)})`;
|
|
134
|
+
case 'binaryOp':
|
|
135
|
+
return `${expr(part.left)} ${part.operator} ${expr(part.right)}`;
|
|
136
|
+
case 'unaryOp':
|
|
137
|
+
return `-${expr(part.operand)}`;
|
|
138
|
+
case 'group':
|
|
139
|
+
return `{${part.parts.map(expr).join(', ')}}`;
|
|
140
|
+
case 'functionCall':
|
|
141
|
+
return `${part.name}(${part.args.map(expr).join(', ')})`;
|
|
142
|
+
case 'keepDrop':
|
|
143
|
+
return `${expr(part.target)}${part.specs.map(keepDropCode).join('')}`;
|
|
144
|
+
case 'explode':
|
|
145
|
+
return `${expr(part.target)}${explodeCode(part)}`;
|
|
146
|
+
case 'reroll':
|
|
147
|
+
return `${expr(part.target)}${part.once ? 'ro' : 'r'}${comparePointCode(part.condition)}`;
|
|
148
|
+
case 'dieBound':
|
|
149
|
+
return `${expr(part.target)}${dieBoundCode(part)}`;
|
|
150
|
+
case 'sort':
|
|
151
|
+
return `${expr(part.target)}${part.order === 'ascending' ? 's' : 'sd'}`;
|
|
152
|
+
case 'critThreshold':
|
|
153
|
+
return `${expr(part.target)}${critThresholdCode(part)}`;
|
|
154
|
+
case 'successCount':
|
|
155
|
+
return `${expr(part.target)}${successCountCode(part)}`;
|
|
156
|
+
case 'versus':
|
|
157
|
+
return `${expr(part.roll)} vs ${expr(part.dc)}`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function explodeCode(part: Extract<RollPart, { type: 'explode' }>): string {
|
|
162
|
+
const marker = EXPLODE_MARKERS[part.variant];
|
|
163
|
+
return part.threshold == null ? marker : `${marker}${comparePointCode(part.threshold)}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Negative bounds are parenthesized so the expression re-parses. */
|
|
167
|
+
function dieBoundCode(part: Extract<RollPart, { type: 'dieBound' }>): string {
|
|
168
|
+
return part.value < 0 ? `${part.bound}(${part.value})` : `${part.bound}${part.value}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function critThresholdCode(part: Extract<RollPart, { type: 'critThreshold' }>): string {
|
|
172
|
+
return [
|
|
173
|
+
...part.successThresholds.map((threshold) => critCode('cs', threshold)),
|
|
174
|
+
...part.failThresholds.map((threshold) => critCode('cf', threshold)),
|
|
175
|
+
].join('');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function successCountCode(part: Extract<RollPart, { type: 'successCount' }>): string {
|
|
179
|
+
const fail = part.failThreshold == null ? '' : failCode(part.failThreshold);
|
|
180
|
+
return `${comparePointCode(part.threshold)}${fail}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Collects the dice a part's subtree produced, in evaluation order.
|
|
185
|
+
*
|
|
186
|
+
* `sort`, `explode`, `reroll`, and `successCount` carry their own pool and
|
|
187
|
+
* stop the descent: a sorted pool is reordered, and an exploded or rerolled
|
|
188
|
+
* one holds dice that exist nowhere under `target`.
|
|
189
|
+
*/
|
|
190
|
+
function collectDice(part: RollPart, out: DieResult[]): void {
|
|
191
|
+
switch (part.type) {
|
|
192
|
+
case 'dice':
|
|
193
|
+
case 'fateDice':
|
|
194
|
+
case 'sort':
|
|
195
|
+
case 'explode':
|
|
196
|
+
case 'reroll':
|
|
197
|
+
case 'successCount':
|
|
198
|
+
for (const die of part.rolls) out.push(die);
|
|
199
|
+
return;
|
|
200
|
+
case 'grouped':
|
|
201
|
+
collectDice(part.inner, out);
|
|
202
|
+
return;
|
|
203
|
+
case 'unaryOp':
|
|
204
|
+
collectDice(part.operand, out);
|
|
205
|
+
return;
|
|
206
|
+
case 'binaryOp':
|
|
207
|
+
collectDice(part.left, out);
|
|
208
|
+
collectDice(part.right, out);
|
|
209
|
+
return;
|
|
210
|
+
case 'keepDrop':
|
|
211
|
+
case 'dieBound':
|
|
212
|
+
case 'critThreshold':
|
|
213
|
+
collectDice(part.target, out);
|
|
214
|
+
return;
|
|
215
|
+
case 'group':
|
|
216
|
+
for (const sub of part.parts) collectDice(sub, out);
|
|
217
|
+
return;
|
|
218
|
+
case 'functionCall':
|
|
219
|
+
for (const arg of part.args) collectDice(arg, out);
|
|
220
|
+
return;
|
|
221
|
+
case 'versus':
|
|
222
|
+
collectDice(part.roll, out);
|
|
223
|
+
collectDice(part.dc, out);
|
|
224
|
+
return;
|
|
225
|
+
case 'literal':
|
|
226
|
+
case 'variable':
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function poolOf(part: RollPart): DieResult[] {
|
|
232
|
+
const dice: DieResult[] = [];
|
|
233
|
+
collectDice(part, dice);
|
|
234
|
+
return dice;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Marks one die. `plain` suppresses the three state marks for dice inside a
|
|
239
|
+
* dropped sub-roll, where the group wrapper already carries the verdict —
|
|
240
|
+
* crit and fumble survive, since they describe the face, not the selection.
|
|
241
|
+
*/
|
|
242
|
+
function markDie(die: DieResult, marks: DieMarks, plain: boolean): string {
|
|
243
|
+
let text = String(die.result);
|
|
244
|
+
|
|
245
|
+
if (die.critical) text = marks.critical?.(die, text) ?? text;
|
|
246
|
+
if (die.fumble) text = marks.fumble?.(die, text) ?? text;
|
|
247
|
+
if (plain) return text;
|
|
248
|
+
|
|
249
|
+
const { modifiers } = die;
|
|
250
|
+
if (modifiers.includes('dropped')) return marks.dropped?.(die, text) ?? text;
|
|
251
|
+
if (modifiers.includes('success')) return marks.success?.(die, text) ?? text;
|
|
252
|
+
if (modifiers.includes('failure')) return marks.failure?.(die, text) ?? text;
|
|
253
|
+
|
|
254
|
+
return text;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** `'meta'` dice were rolled to resolve a parameter — they are never shown. */
|
|
258
|
+
function renderPool(dice: readonly DieResult[], marks: DieMarks, plain: boolean): string {
|
|
259
|
+
const shown: string[] = [];
|
|
260
|
+
|
|
261
|
+
for (const die of dice) {
|
|
262
|
+
if (die.modifiers.includes('meta')) continue;
|
|
263
|
+
shown.push(markDie(die, marks, plain));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return `[${shown.join(', ')}]`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A modifier renders as `<target expression><code><pool>`, replacing whatever
|
|
271
|
+
* bracket the target would have shown on its own. An empty pool means the
|
|
272
|
+
* target rolled nothing at all, and the bracket is dropped with it.
|
|
273
|
+
*/
|
|
274
|
+
function renderModifier(
|
|
275
|
+
target: RollPart,
|
|
276
|
+
code: string,
|
|
277
|
+
dice: readonly DieResult[],
|
|
278
|
+
marks: DieMarks,
|
|
279
|
+
plain: boolean,
|
|
280
|
+
): string {
|
|
281
|
+
const pool = dice.length === 0 ? '' : renderPool(dice, marks, plain);
|
|
282
|
+
return `${expr(target)}${code}${pool}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function renderPart(part: RollPart, marks: DieMarks, plain: boolean): string {
|
|
286
|
+
switch (part.type) {
|
|
287
|
+
case 'literal':
|
|
288
|
+
return String(part.value);
|
|
289
|
+
case 'variable': {
|
|
290
|
+
const display = BARE_VARIABLE.test(part.name) ? `@${part.name}` : `@{${part.name}}`;
|
|
291
|
+
return `${display}[${part.value}]`;
|
|
292
|
+
}
|
|
293
|
+
case 'dice':
|
|
294
|
+
case 'fateDice':
|
|
295
|
+
return `${expr(part)}${renderPool(part.rolls, marks, plain)}`;
|
|
296
|
+
case 'grouped':
|
|
297
|
+
return `(${renderPart(part.inner, marks, plain)})`;
|
|
298
|
+
case 'binaryOp':
|
|
299
|
+
return `${renderPart(part.left, marks, plain)} ${part.operator} ${renderPart(part.right, marks, plain)}`;
|
|
300
|
+
case 'unaryOp':
|
|
301
|
+
return `-${renderPart(part.operand, marks, plain)}`;
|
|
302
|
+
case 'functionCall': {
|
|
303
|
+
const args = part.args.map((arg) => renderPart(arg, marks, plain)).join(', ');
|
|
304
|
+
return `${part.name}(${args})`;
|
|
305
|
+
}
|
|
306
|
+
case 'versus':
|
|
307
|
+
return `${renderPart(part.roll, marks, plain)} vs ${renderPart(part.dc, marks, plain)}`;
|
|
308
|
+
case 'group':
|
|
309
|
+
return renderGroup(part, marks, plain);
|
|
310
|
+
case 'keepDrop':
|
|
311
|
+
// Sub-roll selection renders through the group, which strikes whole
|
|
312
|
+
// sub-rolls; only the flat-pool form collapses into one bracket.
|
|
313
|
+
return part.target.type === 'group' && part.target.keptIndices != null
|
|
314
|
+
? renderPart(part.target, marks, plain)
|
|
315
|
+
: `${expr(part.target)}${renderPool(poolOf(part.target), marks, plain)}`;
|
|
316
|
+
case 'explode':
|
|
317
|
+
return renderModifier(part.target, explodeCode(part), part.rolls, marks, plain);
|
|
318
|
+
case 'reroll':
|
|
319
|
+
return renderModifier(
|
|
320
|
+
part.target,
|
|
321
|
+
`${part.once ? 'ro' : 'r'}${comparePointCode(part.condition)}`,
|
|
322
|
+
part.rolls,
|
|
323
|
+
marks,
|
|
324
|
+
plain,
|
|
325
|
+
);
|
|
326
|
+
case 'successCount':
|
|
327
|
+
return renderModifier(part.target, successCountCode(part), part.rolls, marks, plain);
|
|
328
|
+
case 'dieBound':
|
|
329
|
+
return `${expr(part.target)}${dieBoundCode(part)}${renderPool(poolOf(part.target), marks, plain)}`;
|
|
330
|
+
case 'sort':
|
|
331
|
+
return `${expr(part.target)}${part.order === 'ascending' ? 's' : 'sd'}${renderPool(part.rolls, marks, plain)}`;
|
|
332
|
+
case 'critThreshold':
|
|
333
|
+
return `${expr(part.target)}${critThresholdCode(part)}${renderPool(poolOf(part.target), marks, plain)}`;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function renderGroup(
|
|
338
|
+
part: Extract<RollPart, { type: 'group' }>,
|
|
339
|
+
marks: DieMarks,
|
|
340
|
+
plain: boolean,
|
|
341
|
+
): string {
|
|
342
|
+
const { keptIndices } = part;
|
|
343
|
+
const subRolls = part.parts.map((sub, index) => {
|
|
344
|
+
if (keptIndices == null || keptIndices.includes(index)) {
|
|
345
|
+
return renderPart(sub, marks, plain);
|
|
346
|
+
}
|
|
347
|
+
const inner = renderPart(sub, marks, true);
|
|
348
|
+
return marks.droppedGroup?.(inner) ?? inner;
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return `{${subRolls.join(', ')}}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Rebuilds a roll's breakdown from `RollResult.parts`, applying `marks` to
|
|
356
|
+
* every die.
|
|
357
|
+
*
|
|
358
|
+
* With no `marks` the output is byte-identical to `RollResult.rendered` — a
|
|
359
|
+
* property test pins that over generated notation. Pass any object to take
|
|
360
|
+
* over: omitted slots render plain, so `{}` strips markup entirely and
|
|
361
|
+
* `{ ...MARKDOWN_MARKS, critical: … }` keeps the rest of the markdown.
|
|
362
|
+
*
|
|
363
|
+
* The trailing `= <total>` is included, and becomes the degree label for a
|
|
364
|
+
* `vs` roll, exactly as `rendered` does.
|
|
365
|
+
*
|
|
366
|
+
* @param result - A finished result from `roll` or `evaluate`
|
|
367
|
+
* @param marks - Per-die markers; defaults to {@link MARKDOWN_MARKS}
|
|
368
|
+
* @returns The rendered breakdown
|
|
369
|
+
*
|
|
370
|
+
* @example
|
|
371
|
+
* ```typescript
|
|
372
|
+
* import { roll } from 'roll-parser';
|
|
373
|
+
* import { renderBreakdown } from 'roll-parser/render';
|
|
374
|
+
* import { createMockRng } from 'roll-parser/testing';
|
|
375
|
+
*
|
|
376
|
+
* const result = roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) });
|
|
377
|
+
*
|
|
378
|
+
* renderBreakdown(result); // '4d6[3, 6, ~~2~~, 5] = 14'
|
|
379
|
+
* renderBreakdown(result, {}); // '4d6[3, 6, 2, 5] = 14'
|
|
380
|
+
* renderBreakdown(result, {
|
|
381
|
+
* dropped: (_die, text) => `<s>${text}</s>`,
|
|
382
|
+
* critical: (_die, text) => `<b>${text}</b>`,
|
|
383
|
+
* }); // '4d6[3, <b>6</b>, <s>2</s>, 5] = 14'
|
|
384
|
+
* ```
|
|
385
|
+
*
|
|
386
|
+
* @category Rendering
|
|
387
|
+
*/
|
|
388
|
+
export function renderBreakdown(result: RollResult, marks: DieMarks = MARKDOWN_MARKS): string {
|
|
389
|
+
const trailing = result.degree == null ? String(result.total) : DEGREE_LABELS[result.degree];
|
|
390
|
+
|
|
391
|
+
return `${renderPart(result.parts, marks, false)} = ${trailing}`;
|
|
392
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -42,9 +42,12 @@ export type ResolvedComparePoint = {
|
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* A resolved crit threshold — `'default'` means the per-die default rule
|
|
46
|
-
*
|
|
47
|
-
*
|
|
45
|
+
* A resolved crit threshold — `'default'` means the per-die default rule,
|
|
46
|
+
* which is what bare `cs` / `cf` produce. It reads the natural face
|
|
47
|
+
* (`initialResult ?? result`): critical when it equals `sides`, fumble when
|
|
48
|
+
* it equals 1, both only for `sides > 1`. An explicit ComparePoint instead
|
|
49
|
+
* reads the die's current `result`, so a preceding modifier that rewrote it
|
|
50
|
+
* is visible to the comparison.
|
|
48
51
|
*
|
|
49
52
|
* @category Results
|
|
50
53
|
*/
|
|
@@ -70,10 +73,14 @@ export type ResolvedCritThreshold = ResolvedComparePoint | 'default';
|
|
|
70
73
|
*
|
|
71
74
|
* `'meta'` is the one tag with no counterpart in the notation. Dice counts,
|
|
72
75
|
* sides, modifier counts and computed thresholds may themselves be dice
|
|
73
|
-
* (`(1d4)d6`, `4d6kh(1d2)`, `1d6!>(1d2+3)`). Those inner dice
|
|
74
|
-
*
|
|
75
|
-
* `RollResult.rolls` tagged `'meta'` so an audit log can still
|
|
76
|
-
* meta-expression rolled.
|
|
76
|
+
* (`(1d4)d6`, `4d6kh(1d2)`, `1d6!>(1d2+3)`). Those inner dice belong to no
|
|
77
|
+
* pool: they never appear on a `dice` or `fateDice` part, and they are
|
|
78
|
+
* appended to `RollResult.rolls` tagged `'meta'` so an audit log can still
|
|
79
|
+
* show what the meta-expression rolled.
|
|
80
|
+
*
|
|
81
|
+
* The four whole-pool views — `sort`, `explode`, `reroll`, and `successCount`
|
|
82
|
+
* `rolls` — are snapshots of an evaluation context rather than of a pool, so
|
|
83
|
+
* they do carry meta dice. Filter them out when summing or displaying a pool.
|
|
77
84
|
*
|
|
78
85
|
* `'dc'` marks the DC side of a `vs` comparison. Unlike `'meta'` these dice do
|
|
79
86
|
* render — `1d20[3] vs 2d10[5, 6]` shows both sides — but they are not part of
|
|
@@ -166,9 +173,19 @@ export type DieResult = {
|
|
|
166
173
|
initialResult?: number;
|
|
167
174
|
/** Modifiers applied to this die */
|
|
168
175
|
modifiers: DieModifier[];
|
|
169
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* True if the die met its critical criteria — by default, rolling the
|
|
178
|
+
* maximum face on a die with more than one side. That default never fires
|
|
179
|
+
* on `d1` or on Fate dice (`sides = 0` has no maximum face), but an
|
|
180
|
+
* explicit `cs` threshold does: `4dFcs>0` flags every `+1`.
|
|
181
|
+
*/
|
|
170
182
|
critical: boolean;
|
|
171
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* True if the die met its fumble criteria — by default, rolling a 1 on a
|
|
185
|
+
* die with more than one side. As with {@link DieResult.critical}, the
|
|
186
|
+
* default never fires on `d1` or Fate dice, while an explicit `cf`
|
|
187
|
+
* threshold does: `4dFcf=-1` flags every `-1`.
|
|
188
|
+
*/
|
|
172
189
|
fumble: boolean;
|
|
173
190
|
};
|
|
174
191
|
|
|
@@ -299,12 +316,30 @@ export type RollPart =
|
|
|
299
316
|
type: 'explode';
|
|
300
317
|
variant: 'standard' | 'compound' | 'penetrating';
|
|
301
318
|
threshold?: ResolvedComparePoint;
|
|
319
|
+
/**
|
|
320
|
+
* The expanded pool. Standard and penetrating explosions append dice
|
|
321
|
+
* that exist nowhere under `target`, so this is the only view of the
|
|
322
|
+
* pool the modifier actually produced. Compound explosions accumulate
|
|
323
|
+
* in place, making it the same dice as `target` carries.
|
|
324
|
+
*
|
|
325
|
+
* Like `RollResult.rolls` — and unlike the pool under `target` — this
|
|
326
|
+
* keeps `'meta'` dice. Filter them out before counting or displaying.
|
|
327
|
+
*/
|
|
328
|
+
rolls: DieResult[];
|
|
302
329
|
target: RollPart;
|
|
303
330
|
})
|
|
304
331
|
| (RollPartBase & {
|
|
305
332
|
type: 'reroll';
|
|
306
333
|
once: boolean;
|
|
307
334
|
condition: ResolvedComparePoint;
|
|
335
|
+
/**
|
|
336
|
+
* The post-reroll pool: discarded intermediates (`'rerolled'` +
|
|
337
|
+
* `'dropped'`) alongside their replacements. Both are appended rather
|
|
338
|
+
* than substituted, so neither appears under `target`.
|
|
339
|
+
*
|
|
340
|
+
* Keeps `'meta'` dice, as `RollResult.rolls` does.
|
|
341
|
+
*/
|
|
342
|
+
rolls: DieResult[];
|
|
308
343
|
target: RollPart;
|
|
309
344
|
})
|
|
310
345
|
| (RollPartBase & { type: 'dieBound'; bound: 'min' | 'max'; value: number; target: RollPart })
|
|
@@ -312,6 +347,11 @@ export type RollPart =
|
|
|
312
347
|
type: 'successCount';
|
|
313
348
|
threshold: ResolvedComparePoint;
|
|
314
349
|
failThreshold?: ResolvedComparePoint;
|
|
350
|
+
/**
|
|
351
|
+
* The tallied pool, sharing `DieResult` references with `target`.
|
|
352
|
+
* Keeps `'meta'` dice, as `RollResult.rolls` does.
|
|
353
|
+
*/
|
|
354
|
+
rolls: DieResult[];
|
|
315
355
|
target: RollPart;
|
|
316
356
|
successes: number;
|
|
317
357
|
failures: number;
|
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.1.0';
|