roll-parser 3.0.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 +61 -1
- package/MIGRATION.md +293 -4
- package/README.md +246 -55
- 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 +57 -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 +104 -56
- package/dist/evaluator/evaluator.js.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.d.ts +43 -5
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.js +24 -11
- 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 +13 -5
- package/dist/evaluator/modifiers/explode.js.map +1 -1
- package/dist/evaluator/modifiers/flags.d.ts +17 -0
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
- package/dist/evaluator/modifiers/flags.js +4 -4
- package/dist/evaluator/modifiers/flags.js.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.d.ts +5 -5
- 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 +6 -2
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +9 -5
- 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/ast.d.ts +12 -7
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/ast.js.map +1 -1
- 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 +95 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +232 -0
- package/dist/render.js.map +1 -0
- package/dist/types.d.ts +64 -12
- 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 +14 -4
- package/src/cli/format.ts +15 -23
- package/src/evaluator/die.ts +2 -1
- package/src/evaluator/env.ts +58 -0
- package/src/evaluator/evaluator.ts +192 -70
- package/src/evaluator/modifiers/crit-threshold.ts +81 -15
- package/src/evaluator/modifiers/die-bound.ts +13 -4
- package/src/evaluator/modifiers/explode.ts +21 -6
- package/src/evaluator/modifiers/flags.ts +21 -4
- package/src/evaluator/modifiers/keep-drop.ts +10 -8
- package/src/evaluator/modifiers/reroll.ts +20 -11
- package/src/evaluator/modifiers/sort.ts +9 -3
- package/src/evaluator/modifiers/success-count.ts +27 -5
- package/src/notation.ts +24 -0
- package/src/parser/ast.ts +12 -7
- package/src/parser/guards.ts +92 -1
- package/src/parser/parser.ts +56 -1
- package/src/render.ts +394 -0
- package/src/types.ts +64 -12
- package/src/version.ts +1 -1
|
@@ -9,6 +9,31 @@
|
|
|
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 every modifier that overwrites
|
|
15
|
+
* `result` while recording the face it replaced — compound explode,
|
|
16
|
+
* penetrating explode, and `minN`/`maxN`. An explicit threshold (`cs>4`,
|
|
17
|
+
* `cf<=2`) reads the die's current `result`: it is a predicate over the die's
|
|
18
|
+
* value, and postfix modifiers are order-sensitive by design, so `4d6min5cs>4`
|
|
19
|
+
* is meant to see the clamped faces. The two can therefore disagree on one
|
|
20
|
+
* die — `4d6min5cs>4` flags a clamped natural 1 as both critical and fumble.
|
|
21
|
+
*
|
|
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
|
+
* The rule is recorded per die on `env.critRules`, so dice that explode and
|
|
30
|
+
* reroll mint *after* the crit node has run inherit it from the die they
|
|
31
|
+
* descended from. `cs`/`cf` therefore covers the whole pool wherever it sits
|
|
32
|
+
* in the postfix chain — `1d6cs<2!` and `1d6!cs<2` agree. Two things stay
|
|
33
|
+
* outside that: compound explode, which mints no die and so keeps the flags
|
|
34
|
+
* its accumulated `result` had when the crit node ran, and `minN`/`maxN`,
|
|
35
|
+
* which rewrites `result` under an explicit threshold's feet.
|
|
36
|
+
*
|
|
12
37
|
* Display-only: does not alter `total`, explosion triggers, success
|
|
13
38
|
* counting, or any other modifier flag. Dropped dice still participate —
|
|
14
39
|
* their `critical`/`fumble` metadata reflects what they rolled, not
|
|
@@ -18,39 +43,80 @@
|
|
|
18
43
|
*/
|
|
19
44
|
|
|
20
45
|
import type { DieResult, ResolvedCritThreshold } from '../../types.js';
|
|
46
|
+
import type { CritRule, EvalEnv } from '../env.js';
|
|
21
47
|
import { matchesCondition } from './compare.js';
|
|
22
48
|
import { isVersusDc } from './flags.js';
|
|
23
49
|
|
|
24
50
|
/**
|
|
25
|
-
* Applies success/fail threshold arrays to a dice pool, overriding each
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
51
|
+
* Applies success/fail threshold arrays to a dice pool, overriding each die's
|
|
52
|
+
* `critical` and `fumble` flags in place. Meta and DC dice are skipped.
|
|
53
|
+
*
|
|
54
|
+
* Also records the rule against every die it touched, so later explode and
|
|
55
|
+
* reroll dice can inherit it via {@link inheritCritRule}.
|
|
29
56
|
*/
|
|
30
57
|
export function applyCritThresholds(
|
|
31
58
|
dice: DieResult[],
|
|
32
59
|
successThresholds: ResolvedCritThreshold[],
|
|
33
60
|
failThresholds: ResolvedCritThreshold[],
|
|
61
|
+
env: EvalEnv,
|
|
34
62
|
): void {
|
|
63
|
+
const rule: CritRule = { success: successThresholds, fail: failThresholds };
|
|
64
|
+
env.critRules ??= new WeakMap();
|
|
65
|
+
const rules = env.critRules;
|
|
66
|
+
|
|
35
67
|
for (const die of dice) {
|
|
36
|
-
if (die.modifiers.includes('meta')
|
|
68
|
+
if (die.modifiers.includes('meta')) continue;
|
|
69
|
+
if (env.hasVersusDc && isVersusDc(die)) continue;
|
|
37
70
|
|
|
38
|
-
die
|
|
39
|
-
|
|
71
|
+
applyCritRule(die, rule, die.initialResult ?? die.result);
|
|
72
|
+
rules.set(die, rule);
|
|
40
73
|
}
|
|
41
74
|
}
|
|
42
75
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Judges one die by a recorded rule, overwriting both flags. A side always
|
|
78
|
+
* carries at least `'default'` — `evalCritThreshold` fills the side the user
|
|
79
|
+
* left out — so neither flag is silently left untouched.
|
|
80
|
+
*/
|
|
81
|
+
function applyCritRule(die: DieResult, rule: CritRule, natural: number): void {
|
|
82
|
+
die.critical = rule.success.some((t) => matchesThreshold(t, die, natural, die.sides));
|
|
83
|
+
die.fumble = rule.fail.some((t) => matchesThreshold(t, die, natural, 1));
|
|
48
84
|
}
|
|
49
85
|
|
|
50
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Passes the crit rule recorded for `parent` down to a die minted from it,
|
|
88
|
+
* judging the child by that rule and recording it so a further explode or
|
|
89
|
+
* reroll inherits it in turn. No-op when no `cs`/`cf` governs `parent`, which
|
|
90
|
+
* leaves the `createDieResult` default rule in place.
|
|
91
|
+
*
|
|
92
|
+
* ! Call this only once the child's final `result` and `initialResult` are
|
|
93
|
+
* ! stored — an explicit threshold is a predicate over `result`, so a
|
|
94
|
+
* ! penetrating die is judged by its decremented value, matching `1d6!pcs<2`.
|
|
95
|
+
*/
|
|
96
|
+
export function inheritCritRule(env: EvalEnv, parent: DieResult, child: DieResult): void {
|
|
97
|
+
const rules = env.critRules;
|
|
98
|
+
if (rules === undefined) return;
|
|
99
|
+
|
|
100
|
+
const rule = rules.get(parent);
|
|
101
|
+
if (rule === undefined) return;
|
|
102
|
+
|
|
103
|
+
applyCritRule(child, rule, child.initialResult ?? child.result);
|
|
104
|
+
rules.set(child, rule);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `defaultFace` is the face the `'default'` sentinel looks for — `die.sides`
|
|
109
|
+
* on the success side, `1` on the fail side. The `sides > 1` guard mirrors
|
|
110
|
+
* `createDieResult`: a d1 always rolls 1, so it is neither.
|
|
111
|
+
*/
|
|
112
|
+
function matchesThreshold(
|
|
113
|
+
threshold: ResolvedCritThreshold,
|
|
114
|
+
die: DieResult,
|
|
115
|
+
natural: number,
|
|
116
|
+
defaultFace: number,
|
|
117
|
+
): boolean {
|
|
51
118
|
if (threshold === 'default') {
|
|
52
|
-
|
|
53
|
-
return die.result === 1 && die.sides > 1;
|
|
119
|
+
return natural === defaultFace && die.sides > 1;
|
|
54
120
|
}
|
|
55
121
|
return matchesCondition(die.result, threshold.operator, threshold.value);
|
|
56
122
|
}
|
|
@@ -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;
|
|
@@ -97,8 +98,14 @@ function canExplode(die: DieResult): boolean {
|
|
|
97
98
|
* the value recorded on the appended die, which is the only thing standard
|
|
98
99
|
* and penetrating explosions disagree about.
|
|
99
100
|
*
|
|
101
|
+
* A die whose stored value differs from its raw roll records the raw face in
|
|
102
|
+
* `initialResult`, so `initialResult ?? result` recovers what was rolled.
|
|
103
|
+
*
|
|
100
104
|
* `critical`/`fumble` are likewise derived from the raw roll — a penetrating
|
|
101
105
|
* die that rolled its max face is still a crit even though it stores one less.
|
|
106
|
+
* A `cs`/`cf` rule keeps that: it is applied after `storeResult`, so an
|
|
107
|
+
* explicit threshold reads the stored value while the `'default'` sentinel
|
|
108
|
+
* reads `initialResult`.
|
|
102
109
|
*/
|
|
103
110
|
function applyAppendingExplode(
|
|
104
111
|
pool: DieResult[],
|
|
@@ -111,7 +118,7 @@ function applyAppendingExplode(
|
|
|
111
118
|
|
|
112
119
|
for (const original of pool) {
|
|
113
120
|
result.push(original);
|
|
114
|
-
if (!canExplode(original)) continue;
|
|
121
|
+
if (!canExplode(original, env.hasVersusDc)) continue;
|
|
115
122
|
|
|
116
123
|
const sides = original.sides;
|
|
117
124
|
let lastRaw = original.result;
|
|
@@ -124,6 +131,13 @@ function applyAppendingExplode(
|
|
|
124
131
|
const raw = rollExplosion(sides, rng, env);
|
|
125
132
|
const die = createDieResult(sides, raw, ['exploded', 'kept']);
|
|
126
133
|
die.result = storeResult(raw);
|
|
134
|
+
if (die.result !== raw) die.initialResult = raw;
|
|
135
|
+
// Only `extractNatural` reads this, and only inside a `vs`.
|
|
136
|
+
if (env.insideVersus) {
|
|
137
|
+
env.explosionDice ??= new WeakSet();
|
|
138
|
+
env.explosionDice.add(die);
|
|
139
|
+
}
|
|
140
|
+
inheritCritRule(env, original, die);
|
|
127
141
|
result.push(die);
|
|
128
142
|
lastRaw = raw;
|
|
129
143
|
iterations += 1;
|
|
@@ -172,7 +186,7 @@ export function applyCompoundExplode(
|
|
|
172
186
|
env: EvalEnv,
|
|
173
187
|
): DieResult[] {
|
|
174
188
|
for (const original of pool) {
|
|
175
|
-
if (!canExplode(original)) continue;
|
|
189
|
+
if (!canExplode(original, env.hasVersusDc)) continue;
|
|
176
190
|
|
|
177
191
|
const sides = original.sides;
|
|
178
192
|
let accumulated = original.result;
|
|
@@ -194,8 +208,9 @@ export function applyCompoundExplode(
|
|
|
194
208
|
if (!exploded) continue;
|
|
195
209
|
|
|
196
210
|
// `critical` and `fumble` keep referring to the original triggering
|
|
197
|
-
// roll — after compounding the `result` is a sum.
|
|
198
|
-
|
|
211
|
+
// roll — after compounding the `result` is a sum. First writer wins, so
|
|
212
|
+
// a preceding `minN`/`maxN` clamp keeps its record of the raw face.
|
|
213
|
+
original.initialResult ??= original.result;
|
|
199
214
|
original.result = accumulated;
|
|
200
215
|
if (!original.modifiers.includes('exploded')) {
|
|
201
216
|
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
|
|
26
|
+
* ! tag; 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');
|
|
@@ -28,18 +32,31 @@ export function isVersusDc(die: DieResult): boolean {
|
|
|
28
32
|
/** Kept/dropped selection flags — rebuilt by every keep/drop pass. */
|
|
29
33
|
export const SELECTION_FLAGS: readonly DieModifier[] = ['kept', 'dropped'];
|
|
30
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Success-count tally flags — rebuilt by every counting pass. A group lets a
|
|
37
|
+
* second count reach a pool the first one already tagged (`{4d6>=5}<=2f5`);
|
|
38
|
+
* stripping these first is what keeps the outermost count the only one the
|
|
39
|
+
* tags describe.
|
|
40
|
+
*/
|
|
41
|
+
export const TALLY_FLAGS: readonly DieModifier[] = ['success', 'failure'];
|
|
42
|
+
|
|
31
43
|
/**
|
|
32
44
|
* Selection flags plus the success-count tally flags. Stripped when a die
|
|
33
45
|
* leaves the pool that tagged it (meta sub-expressions, dropped group
|
|
34
46
|
* sub-rolls) so the top-level successes/failures scan cannot count it.
|
|
35
47
|
*/
|
|
36
48
|
export const SELECTION_AND_TALLY_FLAGS: readonly DieModifier[] = [
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
'success',
|
|
40
|
-
'failure',
|
|
49
|
+
...SELECTION_FLAGS,
|
|
50
|
+
...TALLY_FLAGS,
|
|
41
51
|
];
|
|
42
52
|
|
|
53
|
+
/**
|
|
54
|
+
* {@link SELECTION_AND_TALLY_FLAGS} plus `'meta'`, for a meta context merging
|
|
55
|
+
* into a parent. Meta operands nest (`((1d2)d4)d6`), so a die passes through
|
|
56
|
+
* the merge once per level and the tag must be rebuilt, not appended.
|
|
57
|
+
*/
|
|
58
|
+
export const META_MERGE_FLAGS: readonly DieModifier[] = [...SELECTION_AND_TALLY_FLAGS, 'meta'];
|
|
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;
|
|
@@ -132,16 +134,16 @@ function markSingleExtreme(
|
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
/**
|
|
135
|
-
*
|
|
137
|
+
* Sums the dice a keep/drop pass left standing.
|
|
136
138
|
*
|
|
137
|
-
* @param
|
|
138
|
-
*
|
|
139
|
+
* @param hasVersusDc - Shared env flag; skips the DC exclusion when no `vs` has
|
|
140
|
+
* tagged anything
|
|
139
141
|
*/
|
|
140
|
-
export function sumKeptDice(dice: DieResult[]): number {
|
|
142
|
+
export function sumKeptDice(dice: DieResult[], hasVersusDc: boolean): number {
|
|
141
143
|
let total = 0;
|
|
142
144
|
|
|
143
145
|
for (const die of dice) {
|
|
144
|
-
if (isVersusDc(die)) continue;
|
|
146
|
+
if (hasVersusDc && isVersusDc(die)) continue;
|
|
145
147
|
if (!die.modifiers.includes('dropped')) total += die.result;
|
|
146
148
|
}
|
|
147
149
|
|
|
@@ -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
|
}
|
|
@@ -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.
|
|
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,14 +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.
|
|
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.
|
|
14
18
|
*
|
|
15
19
|
* @module evaluator/modifiers/success-count
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
import type { DieResult, ResolvedComparePoint } from '../../types.js';
|
|
19
23
|
import { matchesCondition } from './compare.js';
|
|
20
|
-
import { isVersusDc } from './flags.js';
|
|
24
|
+
import { isVersusDc, stripFlags, TALLY_FLAGS } from './flags.js';
|
|
21
25
|
|
|
22
26
|
export type SuccessCountResult = {
|
|
23
27
|
total: number;
|
|
@@ -28,13 +32,31 @@ export type SuccessCountResult = {
|
|
|
28
32
|
export function countSuccesses(
|
|
29
33
|
dice: DieResult[],
|
|
30
34
|
threshold: ResolvedComparePoint,
|
|
31
|
-
failThreshold
|
|
35
|
+
failThreshold: ResolvedComparePoint | undefined,
|
|
36
|
+
hasVersusDc: boolean,
|
|
37
|
+
poolAlreadyCounted: boolean,
|
|
32
38
|
): SuccessCountResult {
|
|
33
39
|
let successes = 0;
|
|
34
40
|
let failures = 0;
|
|
35
41
|
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
// ! Excluded up front, never inside the loop below. Even short-circuited on a
|
|
43
|
+
// ! false flag, a `hasVersusDc && isVersusDc(die)` guard in this loop costs
|
|
44
|
+
// ! ~9% on `10d10>=6f1` — measured, not assumed. Filtering keeps the
|
|
45
|
+
// ! hot body identical to the pre-exclusion one and pays an allocation only
|
|
46
|
+
// ! on the `vs` path. The dice are the same objects either way, so the
|
|
47
|
+
// ! `'success'` / `'failure'` tags written below still land on the pool.
|
|
48
|
+
const pool = hasVersusDc ? dice.filter((die) => !isVersusDc(die)) : dice;
|
|
49
|
+
|
|
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
|
+
|
|
59
|
+
for (const die of pool) {
|
|
38
60
|
if (die.modifiers.includes('dropped')) continue;
|
|
39
61
|
|
|
40
62
|
if (matchesCondition(die.result, threshold.operator, threshold.value)) {
|
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/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`,
|