roll-parser 3.0.0-beta.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 +142 -9
- package/MIGRATION.md +219 -0
- package/README.md +1026 -154
- package/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +81 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/format.d.ts +15 -3
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +18 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +14 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/main.d.ts +36 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +83 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/errors.d.ts +332 -17
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +141 -0
- package/dist/errors.js.map +1 -0
- package/dist/evaluator/die.d.ts +27 -0
- package/dist/evaluator/die.d.ts.map +1 -0
- package/dist/evaluator/die.js +19 -0
- package/dist/evaluator/die.js.map +1 -0
- package/dist/evaluator/env.d.ts +89 -0
- package/dist/evaluator/env.d.ts.map +1 -0
- package/dist/evaluator/env.js +11 -0
- package/dist/evaluator/env.js.map +1 -0
- package/dist/evaluator/evaluator.d.ts +41 -75
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +914 -0
- package/dist/evaluator/evaluator.js.map +1 -0
- package/dist/evaluator/modifiers/compare.js +15 -0
- package/dist/evaluator/modifiers/compare.js.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts +57 -5
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.js +42 -0
- package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
- package/dist/evaluator/modifiers/die-bound.d.ts +29 -0
- package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
- package/dist/evaluator/modifiers/die-bound.js +17 -0
- package/dist/evaluator/modifiers/die-bound.js.map +1 -0
- package/dist/evaluator/modifiers/explode.d.ts +13 -5
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/explode.js +105 -0
- package/dist/evaluator/modifiers/explode.js.map +1 -0
- package/dist/evaluator/modifiers/flags.d.ts +47 -0
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
- package/dist/evaluator/modifiers/flags.js +25 -0
- package/dist/evaluator/modifiers/flags.js.map +1 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts +15 -29
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.js +82 -0
- package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
- package/dist/evaluator/modifiers/reroll.d.ts +12 -4
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
- package/dist/evaluator/modifiers/reroll.js +68 -0
- package/dist/evaluator/modifiers/reroll.js.map +1 -0
- package/dist/evaluator/modifiers/sort.d.ts +5 -1
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
- package/dist/evaluator/modifiers/sort.js +13 -0
- package/dist/evaluator/modifiers/sort.js.map +1 -0
- package/dist/evaluator/modifiers/success-count.d.ts +6 -7
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +25 -0
- package/dist/evaluator/modifiers/success-count.js.map +1 -0
- package/dist/index.d.ts +33 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2560
- package/dist/index.js.map +1 -26
- package/dist/lexer/lexer.d.ts +50 -5
- package/dist/lexer/lexer.d.ts.map +1 -1
- package/dist/lexer/lexer.js +260 -0
- package/dist/lexer/lexer.js.map +1 -0
- package/dist/lexer/tokens.d.ts +31 -6
- package/dist/lexer/tokens.d.ts.map +1 -1
- package/dist/lexer/tokens.js +42 -0
- package/dist/lexer/tokens.js.map +1 -0
- package/dist/parser/ast.d.ts +324 -165
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/ast.js +52 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/guards.d.ts +106 -0
- package/dist/parser/guards.d.ts.map +1 -0
- package/dist/parser/guards.js +121 -0
- package/dist/parser/guards.js.map +1 -0
- package/dist/parser/parser.d.ts +118 -14
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +751 -0
- package/dist/parser/parser.js.map +1 -0
- 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/rng/mock.d.ts +73 -12
- package/dist/rng/mock.d.ts.map +1 -1
- package/dist/rng/mock.js +30 -0
- package/dist/rng/mock.js.map +1 -0
- package/dist/rng/seeded.d.ts +141 -9
- package/dist/rng/seeded.d.ts.map +1 -1
- package/dist/rng/seeded.js +138 -0
- package/dist/rng/seeded.js.map +1 -0
- package/dist/rng/types.d.ts +57 -0
- package/dist/rng/types.d.ts.map +1 -1
- package/dist/rng/types.js +2 -0
- package/dist/rng/types.js.map +1 -0
- package/dist/roll.d.ts +58 -28
- package/dist/roll.d.ts.map +1 -1
- package/dist/roll.js +8 -0
- package/dist/roll.js.map +1 -0
- package/dist/testing.d.ts +5 -4
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +2 -41
- package/dist/testing.js.map +1 -11
- package/dist/types.d.ts +349 -47
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +93 -34
- package/src/cli/args.ts +66 -10
- package/src/cli/format.ts +37 -26
- package/src/cli/index.ts +27 -84
- package/src/cli/main.ts +129 -0
- package/src/errors.ts +480 -27
- package/src/evaluator/die.ts +51 -0
- package/src/evaluator/env.ts +105 -0
- package/src/evaluator/evaluator.ts +693 -434
- package/src/evaluator/modifiers/crit-threshold.ts +96 -14
- package/src/evaluator/modifiers/die-bound.ts +48 -0
- package/src/evaluator/modifiers/explode.ts +70 -62
- package/src/evaluator/modifiers/flags.ts +78 -0
- package/src/evaluator/modifiers/keep-drop.ts +129 -127
- package/src/evaluator/modifiers/reroll.ts +44 -56
- package/src/evaluator/modifiers/sort.ts +21 -2
- package/src/evaluator/modifiers/success-count.ts +24 -12
- package/src/index.ts +56 -35
- package/src/lexer/lexer.ts +107 -34
- package/src/lexer/tokens.ts +31 -6
- package/src/parser/ast.ts +333 -346
- package/src/parser/guards.ts +248 -0
- package/src/parser/parser.ts +419 -242
- package/src/render.ts +392 -0
- package/src/rng/mock.ts +74 -13
- package/src/rng/seeded.ts +299 -64
- package/src/rng/types.ts +57 -0
- package/src/roll.ts +64 -47
- package/src/testing.ts +5 -9
- package/src/types.ts +353 -46
- package/src/version.ts +2 -0
- package/dist/cli.js +0 -2608
- package/dist/cli.js.map +0 -28
- package/dist/evaluator/index.d.ts +0 -8
- package/dist/evaluator/index.d.ts.map +0 -1
- package/dist/rng/index.d.ts +0 -8
- package/dist/rng/index.d.ts.map +0 -1
- package/src/evaluator/index.ts +0 -14
- package/src/rng/index.ts +0 -8
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/rng/mock.ts
CHANGED
|
@@ -7,12 +7,44 @@
|
|
|
7
7
|
import type { RNG } from './types.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Error thrown when
|
|
10
|
+
* Error thrown when a mock RNG runs out of predefined values.
|
|
11
11
|
*
|
|
12
|
-
* This is intentional behavior
|
|
13
|
-
*
|
|
12
|
+
* This is intentional behavior, not a limitation: a mock that wrapped around
|
|
13
|
+
* would silently pass a test whose expression rolls more dice than the author
|
|
14
|
+
* thought. Seeing this error means the notation consumed more draws than the
|
|
15
|
+
* sequence supplied — count the dice, including explosions, rerolls, and
|
|
16
|
+
* meta-expressions, and check the draw order in the roll-parser README.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately outside the `RollParserError` hierarchy — no `code`, and
|
|
19
|
+
* `isRollParserError` answers `false` for it. An exhausted mock is a bug in the
|
|
20
|
+
* test fixture, not a failure mode of the notation, so the
|
|
21
|
+
* `if (!isRollParserError(error)) throw error` line a consumer writes around
|
|
22
|
+
* `roll()` rethrows it and fails the test instead of routing it to a "bad dice"
|
|
23
|
+
* message. `instanceof` is the check here — there is no `code` to branch on.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* import { roll } from 'roll-parser';
|
|
28
|
+
* import { createMockRng, MockRNGExhaustedError } from 'roll-parser/testing';
|
|
29
|
+
*
|
|
30
|
+
* try {
|
|
31
|
+
* roll('4d6', { rng: createMockRng([1, 2, 3]) });
|
|
32
|
+
* } catch (error) {
|
|
33
|
+
* error instanceof MockRNGExhaustedError; // true
|
|
34
|
+
* (error as MockRNGExhaustedError).consumed; // 3
|
|
35
|
+
* (error as Error).message;
|
|
36
|
+
* // 'MockRNG exhausted: consumed 3 values, no more available'
|
|
37
|
+
* }
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @category Testing
|
|
14
41
|
*/
|
|
15
42
|
export class MockRNGExhaustedError extends Error {
|
|
43
|
+
/**
|
|
44
|
+
* How many values the sequence handed out before running dry — equivalently,
|
|
45
|
+
* the length of the array that was passed to `createMockRng`. Compare it
|
|
46
|
+
* against the dice you expected to be rolled to find the miscount.
|
|
47
|
+
*/
|
|
16
48
|
readonly consumed: number;
|
|
17
49
|
|
|
18
50
|
constructor(consumed: number) {
|
|
@@ -23,29 +55,58 @@ export class MockRNGExhaustedError extends Error {
|
|
|
23
55
|
}
|
|
24
56
|
|
|
25
57
|
/**
|
|
26
|
-
* Creates a mock RNG that
|
|
58
|
+
* Creates a mock {@link RNG} that hands out predefined values in order — the
|
|
59
|
+
* way to write dice tests with exact expected totals.
|
|
60
|
+
*
|
|
61
|
+
* Two deliberate strictnesses, both there to surface a miscounted sequence
|
|
62
|
+
* instead of hiding it:
|
|
27
63
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
64
|
+
* - It never wraps around. Running out throws {@link MockRNGExhaustedError}.
|
|
65
|
+
* - `nextInt` rejects a scripted value outside the requested `[min, max]`
|
|
66
|
+
* with a `RangeError`, so `createMockRng([7])` cannot satisfy a `d6`.
|
|
30
67
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
68
|
+
* Both are the only failures that reach a caller of `roll()` without a
|
|
69
|
+
* roll-parser `code`, and only ever when a mock was injected — see
|
|
70
|
+
* {@link MockRNGExhaustedError} for why they stay outside the hierarchy.
|
|
71
|
+
*
|
|
72
|
+
* Draw order matters when the notation contains meta-expressions. Keep/drop
|
|
73
|
+
* counts (`4d6kh(1d2)`) are drawn *before* the pool; threshold expressions
|
|
74
|
+
* (`4d6cs>(1d2)`) are drawn *after* it. The README's Randomness section has
|
|
75
|
+
* the full tables.
|
|
76
|
+
*
|
|
77
|
+
* @param values - Values to return, in draw order (die faces for `nextInt`,
|
|
78
|
+
* floats in `[0, 1)` for `next`)
|
|
79
|
+
* @returns An `RNG` that replays `values`
|
|
33
80
|
*
|
|
34
81
|
* @example
|
|
35
82
|
* ```typescript
|
|
83
|
+
* import { createMockRng } from 'roll-parser/testing';
|
|
84
|
+
*
|
|
36
85
|
* const rng = createMockRng([4, 2, 6]);
|
|
37
|
-
* rng.nextInt(1, 6); //
|
|
38
|
-
* rng.nextInt(1, 6); //
|
|
39
|
-
* rng.nextInt(1, 6); //
|
|
40
|
-
* rng.nextInt(1, 6); //
|
|
86
|
+
* rng.nextInt(1, 6); // 4
|
|
87
|
+
* rng.nextInt(1, 6); // 2
|
|
88
|
+
* rng.nextInt(1, 6); // 6
|
|
89
|
+
* rng.nextInt(1, 6); // throws MockRNGExhaustedError
|
|
90
|
+
* ```
|
|
91
|
+
*
|
|
92
|
+
* @example Pinning a roll
|
|
93
|
+
* ```typescript
|
|
94
|
+
* import { roll } from 'roll-parser';
|
|
95
|
+
* import { createMockRng } from 'roll-parser/testing';
|
|
96
|
+
*
|
|
97
|
+
* const result = roll('4d6kh3', { rng: createMockRng([3, 6, 2, 5]) });
|
|
98
|
+
* result.total; // 14
|
|
99
|
+
* result.rendered; // '4d6[3, 6, ~~2~~, 5] = 14'
|
|
41
100
|
* ```
|
|
101
|
+
*
|
|
102
|
+
* @category Testing
|
|
42
103
|
*/
|
|
43
104
|
export function createMockRng(values: number[]): RNG {
|
|
44
105
|
let index = 0;
|
|
45
106
|
|
|
46
107
|
const getNext = (): number => {
|
|
47
108
|
const value = values[index];
|
|
48
|
-
if (value
|
|
109
|
+
if (value == null) {
|
|
49
110
|
throw new MockRNGExhaustedError(index);
|
|
50
111
|
}
|
|
51
112
|
index++;
|