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
|
@@ -9,6 +9,40 @@
|
|
|
9
9
|
* Meta dice (rolled to compute counts/sides/modifier args) are skipped so
|
|
10
10
|
* their bookkeeping stays untouched.
|
|
11
11
|
*
|
|
12
|
+
* The two threshold kinds deliberately read different values. `'default'`
|
|
13
|
+
* reads `initialResult ?? result`, the same source as the versus `natural`,
|
|
14
|
+
* so "rolled the maximum face" survives the two modifiers that overwrite
|
|
15
|
+
* `result` while recording the face they replaced — compound explode and
|
|
16
|
+
* `minN`/`maxN`. An explicit threshold (`cs>4`, `cf<=2`) reads the die's
|
|
17
|
+
* current `result`: it is a predicate over the die's value, and postfix
|
|
18
|
+
* modifiers are order-sensitive by design, so `4d6min5cs>4` is meant to
|
|
19
|
+
* see the clamped faces. The two can therefore disagree on one die —
|
|
20
|
+
* `4d6min5cs>4` flags a clamped natural 1 as both critical and fumble.
|
|
21
|
+
*
|
|
22
|
+
* They also diverge on Fate dice. `'default'` carries a `sides > 1` guard, so
|
|
23
|
+
* it never fires on a `sides = 0` pool; an explicit threshold has none and
|
|
24
|
+
* compares the raw {-1, 0, +1} face, so `4dFcs>0` and `4dFcf=-1` do set the
|
|
25
|
+
* flags. The missing guard is deliberate — the parser rejects the bare
|
|
26
|
+
* `cs`/`cf` forms instead, since they would resolve to `'default'` and
|
|
27
|
+
* silently do nothing.
|
|
28
|
+
*
|
|
29
|
+
* ! Penetrating explode is not covered: it stores `raw - 1` in `result`
|
|
30
|
+
* ! without recording `initialResult`, so `1d6!pcs` still judges a natural
|
|
31
|
+
* ! 6 by its decremented 5. An *inherited* rule is handed the raw roll
|
|
32
|
+
* ! instead, which keeps `1d6cf>5!p` agreeing with `1d6!p` on the side the
|
|
33
|
+
* ! user never overrode — at the cost of `1d6cf>5!p` and `1d6!pcf>5`
|
|
34
|
+
* ! disagreeing on it. Recording `initialResult` here would settle both, but
|
|
35
|
+
* ! `extractNatural` reads that field to tell an appended explosion die from
|
|
36
|
+
* ! a compounded one, and would start counting these as versus primaries.
|
|
37
|
+
*
|
|
38
|
+
* The rule is recorded per die on `env.critRules`, so dice that explode and
|
|
39
|
+
* reroll mint *after* the crit node has run inherit it from the die they
|
|
40
|
+
* descended from. `cs`/`cf` therefore covers the whole pool wherever it sits
|
|
41
|
+
* in the postfix chain — `1d6cs<2!` and `1d6!cs<2` agree. Two things stay
|
|
42
|
+
* outside that: compound explode, which mints no die and so keeps the flags
|
|
43
|
+
* its accumulated `result` had when the crit node ran, and `minN`/`maxN`,
|
|
44
|
+
* which rewrites `result` under an explicit threshold's feet.
|
|
45
|
+
*
|
|
12
46
|
* Display-only: does not alter `total`, explosion triggers, success
|
|
13
47
|
* counting, or any other modifier flag. Dropped dice still participate —
|
|
14
48
|
* their `critical`/`fumble` metadata reflects what they rolled, not
|
|
@@ -18,42 +52,90 @@
|
|
|
18
52
|
*/
|
|
19
53
|
|
|
20
54
|
import type { DieResult, ResolvedCritThreshold } from '../../types.js';
|
|
55
|
+
import type { CritRule, EvalEnv } from '../env.js';
|
|
21
56
|
import { matchesCondition } from './compare.js';
|
|
22
|
-
|
|
23
|
-
// ? Re-exported for existing importers — the definition moved to types.ts so
|
|
24
|
-
// the public RollPart union can reference it without a circular import.
|
|
25
|
-
export type { ResolvedCritThreshold } from '../../types.js';
|
|
57
|
+
import { isVersusDc } from './flags.js';
|
|
26
58
|
|
|
27
59
|
/**
|
|
28
60
|
* Applies success/fail threshold arrays to a dice pool, overriding each
|
|
29
|
-
* die's `critical` and `fumble` flags in place.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
61
|
+
* die's `critical` and `fumble` flags in place. Writing `natural` for
|
|
62
|
+
* `initialResult ?? result`, a die matches `'default'` on the success side
|
|
63
|
+
* when `natural === sides && sides > 1`, and on the fail side when
|
|
64
|
+
* `natural === 1 && sides > 1`. Meta dice are skipped.
|
|
65
|
+
*
|
|
66
|
+
* Also records the rule against every die it touched, so later explode and
|
|
67
|
+
* reroll dice can inherit it via {@link inheritCritRule}.
|
|
32
68
|
*/
|
|
33
69
|
export function applyCritThresholds(
|
|
34
70
|
dice: DieResult[],
|
|
35
71
|
successThresholds: ResolvedCritThreshold[],
|
|
36
72
|
failThresholds: ResolvedCritThreshold[],
|
|
73
|
+
env: EvalEnv,
|
|
37
74
|
): void {
|
|
75
|
+
const rule: CritRule = { success: successThresholds, fail: failThresholds };
|
|
76
|
+
env.critRules ??= new WeakMap();
|
|
77
|
+
const rules = env.critRules;
|
|
78
|
+
|
|
38
79
|
for (const die of dice) {
|
|
39
80
|
if (die.modifiers.includes('meta')) continue;
|
|
81
|
+
if (env.hasVersusDc && isVersusDc(die)) continue;
|
|
40
82
|
|
|
41
|
-
die
|
|
42
|
-
|
|
83
|
+
applyCritRule(die, rule, die.initialResult ?? die.result);
|
|
84
|
+
rules.set(die, rule);
|
|
43
85
|
}
|
|
44
86
|
}
|
|
45
87
|
|
|
46
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Judges one die by a recorded rule, overwriting both flags. A side always
|
|
90
|
+
* carries at least `'default'` — `evalCritThreshold` fills the side the user
|
|
91
|
+
* left out — so neither flag is silently left untouched. `natural` is the face
|
|
92
|
+
* the `'default'` sentinel reads; an explicit threshold always reads `result`.
|
|
93
|
+
*/
|
|
94
|
+
function applyCritRule(die: DieResult, rule: CritRule, natural: number): void {
|
|
95
|
+
die.critical = rule.success.some((t) => matchesCrit(t, die, natural));
|
|
96
|
+
die.fumble = rule.fail.some((t) => matchesFumble(t, die, natural));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Passes the crit rule recorded for `parent` down to a die minted from it,
|
|
101
|
+
* judging the child by that rule and recording it so a further explode or
|
|
102
|
+
* reroll inherits it in turn. No-op when no `cs`/`cf` governs `parent`, which
|
|
103
|
+
* leaves the `createDieResult` default rule in place.
|
|
104
|
+
*
|
|
105
|
+
* `natural` is the face the `'default'` sentinel reads, defaulting to the
|
|
106
|
+
* child's own. Penetrating explode passes its raw roll — see the module note.
|
|
107
|
+
*
|
|
108
|
+
* ! Call this only once the child's final `result` is stored — an explicit
|
|
109
|
+
* ! threshold is a predicate over `result`, so a penetrating die is judged by
|
|
110
|
+
* ! its decremented value, matching `1d6!pcs<2`.
|
|
111
|
+
*/
|
|
112
|
+
export function inheritCritRule(
|
|
113
|
+
env: EvalEnv,
|
|
114
|
+
parent: DieResult,
|
|
115
|
+
child: DieResult,
|
|
116
|
+
natural = child.initialResult ?? child.result,
|
|
117
|
+
): void {
|
|
118
|
+
const rules = env.critRules;
|
|
119
|
+
if (rules === undefined) return;
|
|
120
|
+
|
|
121
|
+
const rule = rules.get(parent);
|
|
122
|
+
if (rule === undefined) return;
|
|
123
|
+
|
|
124
|
+
applyCritRule(child, rule, natural);
|
|
125
|
+
rules.set(child, rule);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function matchesCrit(threshold: ResolvedCritThreshold, die: DieResult, natural: number): boolean {
|
|
47
129
|
if (threshold === 'default') {
|
|
48
|
-
return
|
|
130
|
+
return natural === die.sides && die.sides > 1;
|
|
49
131
|
}
|
|
50
132
|
return matchesCondition(die.result, threshold.operator, threshold.value);
|
|
51
133
|
}
|
|
52
134
|
|
|
53
|
-
function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult): boolean {
|
|
135
|
+
function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult, natural: number): boolean {
|
|
54
136
|
if (threshold === 'default') {
|
|
55
|
-
//
|
|
56
|
-
return
|
|
137
|
+
// Mirrors `createDieResult` — a d1 always rolls 1, never a fumble.
|
|
138
|
+
return natural === 1 && die.sides > 1;
|
|
57
139
|
}
|
|
58
140
|
return matchesCondition(die.result, threshold.operator, threshold.value);
|
|
59
141
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-die clamp modifier (`minN` / `maxN`).
|
|
3
|
+
*
|
|
4
|
+
* Rewrites each die's `result` toward the bound: `min` raises results below
|
|
5
|
+
* the bound, `max` lowers results above it. The raw face is preserved in
|
|
6
|
+
* `initialResult` (first writer wins, so a compound-explode accumulation
|
|
7
|
+
* that was clamped afterwards still reports its original first roll), and
|
|
8
|
+
* the die is tagged `'min'` / `'max'` so parts consumers can tell a clamped
|
|
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'`.
|
|
11
|
+
*
|
|
12
|
+
* `critical` / `fumble` keep reflecting the natural face — a clamped 1 is
|
|
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.
|
|
16
|
+
* Meta dice (rolled to compute counts/sides/modifier args) are skipped.
|
|
17
|
+
* Dropped dice are clamped too — they are excluded from totals anyway, and
|
|
18
|
+
* clamping them keeps the rendered pool consistent.
|
|
19
|
+
*
|
|
20
|
+
* @module evaluator/modifiers/die-bound
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { DieResult } from '../../types.js';
|
|
24
|
+
import { isVersusDc } from './flags.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Clamps every non-meta die in `dice` against `value`, in place.
|
|
28
|
+
* `bound: 'min'` lifts lower results up to `value`; `'max'` caps higher
|
|
29
|
+
* results down to it. Untouched dice keep their tags.
|
|
30
|
+
*/
|
|
31
|
+
export function applyDieBound(
|
|
32
|
+
dice: DieResult[],
|
|
33
|
+
bound: 'min' | 'max',
|
|
34
|
+
value: number,
|
|
35
|
+
hasVersusDc: boolean,
|
|
36
|
+
): void {
|
|
37
|
+
for (const die of dice) {
|
|
38
|
+
if (die.modifiers.includes('meta')) continue;
|
|
39
|
+
if (hasVersusDc && isVersusDc(die)) continue;
|
|
40
|
+
|
|
41
|
+
const clamped = bound === 'min' ? Math.max(die.result, value) : Math.min(die.result, value);
|
|
42
|
+
if (clamped === die.result) continue;
|
|
43
|
+
|
|
44
|
+
die.initialResult ??= die.result;
|
|
45
|
+
die.result = clamped;
|
|
46
|
+
if (!die.modifiers.includes(bound)) die.modifiers.push(bound);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -8,12 +8,23 @@
|
|
|
8
8
|
* @module evaluator/modifiers/explode
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { EvaluatorError } from '../../errors.js';
|
|
11
12
|
import type { RNG } from '../../rng/types.js';
|
|
12
|
-
import type { CompareOp } from '../../types.js';
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
13
|
+
import type { CompareOp, DieResult } from '../../types.js';
|
|
14
|
+
import { createDieResult } from '../die.js';
|
|
15
|
+
import { chargeDie, type EvalEnv } from '../env.js';
|
|
16
|
+
import { inheritCritRule } from './crit-threshold.js';
|
|
17
|
+
import { isVersusDc } from './flags.js';
|
|
15
18
|
|
|
16
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Default value of `EvaluationOptions.maxExplodeIterations`: how many times a
|
|
21
|
+
* single die may explode before `EXPLODE_LIMIT_EXCEEDED` is thrown.
|
|
22
|
+
*
|
|
23
|
+
* Per die, not per pool — it bounds notations like `1d1!`, where the
|
|
24
|
+
* explosion condition can never stop being true.
|
|
25
|
+
*
|
|
26
|
+
* @category Limits
|
|
27
|
+
*/
|
|
17
28
|
export const DEFAULT_MAX_EXPLODE_ITERATIONS = 1_000;
|
|
18
29
|
|
|
19
30
|
/**
|
|
@@ -52,14 +63,7 @@ export function buildShouldExplode(
|
|
|
52
63
|
* Rolls one explosion die, charging it against the global dice limit.
|
|
53
64
|
*/
|
|
54
65
|
function rollExplosion(sides: number, rng: RNG, env: EvalEnv): number {
|
|
55
|
-
|
|
56
|
-
throw new EvaluatorError(
|
|
57
|
-
`Total dice count ${env.totalDiceRolled + 1} exceeds limit of ${env.maxDice}`,
|
|
58
|
-
'DICE_LIMIT_EXCEEDED',
|
|
59
|
-
'Explode',
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
env.totalDiceRolled += 1;
|
|
66
|
+
chargeDie(env, 'Explode');
|
|
63
67
|
return rng.nextInt(1, sides);
|
|
64
68
|
}
|
|
65
69
|
|
|
@@ -81,45 +85,52 @@ function explodeLimitError(maxIterations: number): EvaluatorError {
|
|
|
81
85
|
* never fire during normal flow. Keeping it ensures `rng.nextInt(1, 0)`
|
|
82
86
|
* can never be reached if a future AST path slips past the parser gate.
|
|
83
87
|
*/
|
|
84
|
-
function canExplode(die: DieResult): boolean {
|
|
88
|
+
function canExplode(die: DieResult, hasVersusDc: boolean): boolean {
|
|
89
|
+
if (hasVersusDc && isVersusDc(die)) return false;
|
|
85
90
|
if (die.modifiers.includes('dropped')) return false;
|
|
86
91
|
if (die.sides < 1) return false;
|
|
87
92
|
return true;
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
96
|
+
* Shared implementation of the two appending explode variants. The explosion
|
|
97
|
+
* predicate always runs on the RAW roll; `storeResult` maps that raw roll to
|
|
98
|
+
* the value recorded on the appended die, which is the only thing standard
|
|
99
|
+
* and penetrating explosions disagree about.
|
|
100
|
+
*
|
|
101
|
+
* `critical`/`fumble` are likewise derived from the raw roll — a penetrating
|
|
102
|
+
* die that rolled its max face is still a crit even though it stores one less.
|
|
103
|
+
* An inherited `cs`/`cf` keeps that: it is applied after `storeResult`, so an
|
|
104
|
+
* explicit threshold reads the stored value, while the raw roll is handed over
|
|
105
|
+
* for the `'default'` sentinel to read.
|
|
93
106
|
*/
|
|
94
|
-
|
|
107
|
+
function applyAppendingExplode(
|
|
95
108
|
pool: DieResult[],
|
|
96
109
|
shouldExplode: ShouldExplode,
|
|
97
110
|
rng: RNG,
|
|
98
111
|
env: EvalEnv,
|
|
112
|
+
storeResult: (raw: number) => number,
|
|
99
113
|
): DieResult[] {
|
|
100
114
|
const result: DieResult[] = [];
|
|
101
115
|
|
|
102
116
|
for (const original of pool) {
|
|
103
117
|
result.push(original);
|
|
104
|
-
if (!canExplode(original)) continue;
|
|
118
|
+
if (!canExplode(original, env.hasVersusDc)) continue;
|
|
105
119
|
|
|
106
120
|
const sides = original.sides;
|
|
107
|
-
let
|
|
121
|
+
let lastRaw = original.result;
|
|
108
122
|
let iterations = 0;
|
|
109
123
|
|
|
110
|
-
while (shouldExplode(
|
|
124
|
+
while (shouldExplode(lastRaw, sides)) {
|
|
111
125
|
if (iterations >= env.maxExplodeIterations) {
|
|
112
126
|
throw explodeLimitError(env.maxExplodeIterations);
|
|
113
127
|
}
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
fumble: next === 1 && sides > 1,
|
|
121
|
-
});
|
|
122
|
-
last = next;
|
|
128
|
+
const raw = rollExplosion(sides, rng, env);
|
|
129
|
+
const die = createDieResult(sides, raw, ['exploded', 'kept']);
|
|
130
|
+
die.result = storeResult(raw);
|
|
131
|
+
inheritCritRule(env, original, die, raw);
|
|
132
|
+
result.push(die);
|
|
133
|
+
lastRaw = raw;
|
|
123
134
|
iterations += 1;
|
|
124
135
|
}
|
|
125
136
|
}
|
|
@@ -127,6 +138,29 @@ export function applyStandardExplode(
|
|
|
127
138
|
return result;
|
|
128
139
|
}
|
|
129
140
|
|
|
141
|
+
/** Records the raw roll unchanged — standard explode. */
|
|
142
|
+
function keepRaw(raw: number): number {
|
|
143
|
+
return raw;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Records one less than the raw roll — penetrating explode. */
|
|
147
|
+
function penetratingPenalty(raw: number): number {
|
|
148
|
+
return raw - 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Standard explode: append each new die to the pool. The original die keeps
|
|
153
|
+
* its modifiers untouched; new dice carry `['exploded', 'kept']`.
|
|
154
|
+
*/
|
|
155
|
+
export function applyStandardExplode(
|
|
156
|
+
pool: DieResult[],
|
|
157
|
+
shouldExplode: ShouldExplode,
|
|
158
|
+
rng: RNG,
|
|
159
|
+
env: EvalEnv,
|
|
160
|
+
): DieResult[] {
|
|
161
|
+
return applyAppendingExplode(pool, shouldExplode, rng, env, keepRaw);
|
|
162
|
+
}
|
|
163
|
+
|
|
130
164
|
/**
|
|
131
165
|
* Compound explode: pool length stays the same. Each original die's `result`
|
|
132
166
|
* accumulates every explosion roll, and gains the `'exploded'` modifier once
|
|
@@ -143,7 +177,7 @@ export function applyCompoundExplode(
|
|
|
143
177
|
env: EvalEnv,
|
|
144
178
|
): DieResult[] {
|
|
145
179
|
for (const original of pool) {
|
|
146
|
-
if (!canExplode(original)) continue;
|
|
180
|
+
if (!canExplode(original, env.hasVersusDc)) continue;
|
|
147
181
|
|
|
148
182
|
const sides = original.sides;
|
|
149
183
|
let accumulated = original.result;
|
|
@@ -164,9 +198,10 @@ export function applyCompoundExplode(
|
|
|
164
198
|
|
|
165
199
|
if (!exploded) continue;
|
|
166
200
|
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
201
|
+
// `critical` and `fumble` keep referring to the original triggering
|
|
202
|
+
// roll — after compounding the `result` is a sum. First writer wins, so
|
|
203
|
+
// a preceding `minN`/`maxN` clamp keeps its record of the raw face.
|
|
204
|
+
original.initialResult ??= original.result;
|
|
170
205
|
original.result = accumulated;
|
|
171
206
|
if (!original.modifiers.includes('exploded')) {
|
|
172
207
|
original.modifiers = [...original.modifiers, 'exploded'];
|
|
@@ -179,7 +214,8 @@ export function applyCompoundExplode(
|
|
|
179
214
|
/**
|
|
180
215
|
* Penetrating explode: like standard, but each appended die stores
|
|
181
216
|
* `result = rawRoll - 1`. The explosion predicate runs on the RAW roll
|
|
182
|
-
* (pre-decrement),
|
|
217
|
+
* (pre-decrement), so a max face still chains even though its stored value
|
|
218
|
+
* is one lower.
|
|
183
219
|
*/
|
|
184
220
|
export function applyPenetratingExplode(
|
|
185
221
|
pool: DieResult[],
|
|
@@ -187,33 +223,5 @@ export function applyPenetratingExplode(
|
|
|
187
223
|
rng: RNG,
|
|
188
224
|
env: EvalEnv,
|
|
189
225
|
): DieResult[] {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
for (const original of pool) {
|
|
193
|
-
result.push(original);
|
|
194
|
-
if (!canExplode(original)) continue;
|
|
195
|
-
|
|
196
|
-
const sides = original.sides;
|
|
197
|
-
let lastRaw = original.result;
|
|
198
|
-
let iterations = 0;
|
|
199
|
-
|
|
200
|
-
while (shouldExplode(lastRaw, sides)) {
|
|
201
|
-
if (iterations >= env.maxExplodeIterations) {
|
|
202
|
-
throw explodeLimitError(env.maxExplodeIterations);
|
|
203
|
-
}
|
|
204
|
-
const raw = rollExplosion(sides, rng, env);
|
|
205
|
-
const stored = raw - 1;
|
|
206
|
-
result.push({
|
|
207
|
-
sides,
|
|
208
|
-
result: stored,
|
|
209
|
-
modifiers: ['exploded', 'kept'],
|
|
210
|
-
critical: raw === sides && sides > 1,
|
|
211
|
-
fumble: raw === 1 && sides > 1,
|
|
212
|
-
});
|
|
213
|
-
lastRaw = raw;
|
|
214
|
-
iterations += 1;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
return result;
|
|
226
|
+
return applyAppendingExplode(pool, shouldExplode, rng, env, penetratingPenalty);
|
|
219
227
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared slot-flag rewriting for `DieResult.modifiers`.
|
|
3
|
+
*
|
|
4
|
+
* "Slot" flags (`kept` / `dropped` / `rerolled`) and tally flags
|
|
5
|
+
* (`success` / `failure`) are owned by whichever pass is currently deciding a
|
|
6
|
+
* die's fate, so each pass rebuilds them rather than appending. Every rewrite
|
|
7
|
+
* site strips its own exclusion list and appends its own markers — the lists
|
|
8
|
+
* differ, the mechanics do not.
|
|
9
|
+
*
|
|
10
|
+
* @module evaluator/modifiers/flags
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { DieModifier, DieResult } from '../../types.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* True when a die belongs to the DC side of a `vs` comparison.
|
|
17
|
+
*
|
|
18
|
+
* DC dice share the roll side's `rolls` array so they still render
|
|
19
|
+
* (`1d20[3] vs 2d10[5, 6]`), but they are not part of the pool a modifier
|
|
20
|
+
* operates on. Nothing may sum, select, clamp, reroll, explode, or tally them
|
|
21
|
+
* — a `d10` on the DC side must never come back clamped to 20, and
|
|
22
|
+
* `{1d20 vs 2d10, 1d4}>=5` must not count the DC faces as successes.
|
|
23
|
+
*
|
|
24
|
+
* ! Call this behind the shared `hasVersusDc` env flag, never bare. Unguarded
|
|
25
|
+
* ! inside a per-die loop it cost 11-38% on notation that cannot carry the tag
|
|
26
|
+
* ! (#281); the flag is `false` until a `vs` has actually tagged something.
|
|
27
|
+
*/
|
|
28
|
+
export function isVersusDc(die: DieResult): boolean {
|
|
29
|
+
return die.modifiers.includes('dc');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Kept/dropped selection flags — rebuilt by every keep/drop pass. */
|
|
33
|
+
export const SELECTION_FLAGS: readonly DieModifier[] = ['kept', 'dropped'];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Selection flags plus the success-count tally flags. Stripped when a die
|
|
37
|
+
* leaves the pool that tagged it (meta sub-expressions, dropped group
|
|
38
|
+
* sub-rolls) so the top-level successes/failures scan cannot count it.
|
|
39
|
+
*/
|
|
40
|
+
export const SELECTION_AND_TALLY_FLAGS: readonly DieModifier[] = [
|
|
41
|
+
'kept',
|
|
42
|
+
'dropped',
|
|
43
|
+
'success',
|
|
44
|
+
'failure',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* {@link SELECTION_AND_TALLY_FLAGS} plus `'meta'`, for a meta context merging
|
|
49
|
+
* into a parent. Meta operands nest (`((1d2)d4)d6`), so a die passes through
|
|
50
|
+
* the merge once per level and the tag must be rebuilt, not appended.
|
|
51
|
+
*/
|
|
52
|
+
export const META_MERGE_FLAGS: readonly DieModifier[] = [
|
|
53
|
+
'kept',
|
|
54
|
+
'dropped',
|
|
55
|
+
'success',
|
|
56
|
+
'failure',
|
|
57
|
+
'meta',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** Selection flags plus `rerolled` — reassigned on every reroll pass. */
|
|
61
|
+
export const REROLL_SLOT_FLAGS: readonly DieModifier[] = ['kept', 'dropped', 'rerolled'];
|
|
62
|
+
|
|
63
|
+
/** Returns `modifiers` with every flag in `excluded` removed. */
|
|
64
|
+
export function stripFlags(
|
|
65
|
+
modifiers: readonly DieModifier[],
|
|
66
|
+
excluded: readonly DieModifier[],
|
|
67
|
+
): DieModifier[] {
|
|
68
|
+
return modifiers.filter((modifier) => !excluded.includes(modifier));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Returns `modifiers` with `excluded` removed and `added` appended, in order. */
|
|
72
|
+
export function rewriteFlags(
|
|
73
|
+
modifiers: readonly DieModifier[],
|
|
74
|
+
excluded: readonly DieModifier[],
|
|
75
|
+
...added: DieModifier[]
|
|
76
|
+
): DieModifier[] {
|
|
77
|
+
return [...stripFlags(modifiers, excluded), ...added];
|
|
78
|
+
}
|