roll-parser 3.0.0-alpha.0 → 3.0.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 +189 -0
- package/MIGRATION.md +147 -0
- package/README.md +985 -43
- 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 +16 -4
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +17 -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 -14
- 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 +26 -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 +58 -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 +62 -40
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +906 -0
- package/dist/evaluator/evaluator.js.map +1 -0
- package/dist/evaluator/modifiers/compare.d.ts +1 -1
- package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
- 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 +27 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.js +23 -0
- package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
- package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
- package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
- package/dist/evaluator/modifiers/die-bound.js +14 -0
- package/dist/evaluator/modifiers/die-bound.js.map +1 -0
- package/dist/evaluator/modifiers/explode.d.ts +18 -6
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/explode.js +103 -0
- package/dist/evaluator/modifiers/explode.js.map +1 -0
- package/dist/evaluator/modifiers/flags.d.ts +37 -0
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
- package/dist/evaluator/modifiers/flags.js +18 -0
- package/dist/evaluator/modifiers/flags.js.map +1 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
- 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 +14 -6
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
- package/dist/evaluator/modifiers/reroll.js +62 -0
- package/dist/evaluator/modifiers/reroll.js.map +1 -0
- package/dist/evaluator/modifiers/sort.d.ts +27 -0
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
- 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 +2 -6
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +24 -0
- package/dist/evaluator/modifiers/success-count.js.map +1 -0
- package/dist/index.d.ts +35 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1723
- package/dist/index.js.map +1 -0
- package/dist/lexer/lexer.d.ts +68 -8
- 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 +52 -7
- 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 +419 -80
- 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 +162 -15
- 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/rng/mock.d.ts +74 -13
- 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 +149 -10
- 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 +59 -25
- 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 -38
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +427 -24
- 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 -40
- package/src/cli/args.ts +66 -9
- package/src/cli/format.ts +30 -7
- package/src/cli/index.ts +27 -67
- package/src/cli/main.ts +129 -0
- package/src/errors.ts +488 -23
- package/src/evaluator/die.ts +50 -0
- package/src/evaluator/env.ts +73 -0
- package/src/evaluator/evaluator.ts +1296 -349
- package/src/evaluator/modifiers/compare.ts +1 -1
- package/src/evaluator/modifiers/crit-threshold.ts +56 -0
- package/src/evaluator/modifiers/die-bound.ts +39 -0
- package/src/evaluator/modifiers/explode.ts +82 -76
- package/src/evaluator/modifiers/flags.ts +61 -0
- package/src/evaluator/modifiers/keep-drop.ts +124 -126
- package/src/evaluator/modifiers/reroll.ts +36 -64
- package/src/evaluator/modifiers/sort.ts +43 -0
- package/src/evaluator/modifiers/success-count.ts +6 -9
- package/src/index.ts +73 -34
- package/src/lexer/lexer.ts +201 -35
- package/src/lexer/tokens.ts +72 -7
- package/src/parser/ast.ts +453 -104
- package/src/parser/guards.ts +248 -0
- package/src/parser/parser.ts +835 -135
- package/src/rng/mock.ts +75 -14
- package/src/rng/seeded.ts +323 -58
- package/src/rng/types.ts +57 -0
- package/src/roll.ts +66 -41
- package/src/testing.ts +5 -9
- package/src/types.ts +416 -24
- package/src/version.ts +2 -0
- package/dist/cli.js +0 -1775
- package/dist/evaluator/index.d.ts +0 -8
- package/dist/evaluator/index.d.ts.map +0 -1
- package/dist/index.mjs +0 -1724
- package/dist/rng/index.d.ts +0 -8
- package/dist/rng/index.d.ts.map +0 -1
- package/dist/testing.mjs +0 -39
- package/src/evaluator/index.ts +0 -14
- package/src/rng/index.ts +0 -8
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Critical / fumble threshold modifier.
|
|
3
|
+
*
|
|
4
|
+
* Overrides the default `critical`/`fumble` flag logic for a dice pool.
|
|
5
|
+
* `cs` and `cf` are independent (Roll20 semantics): the evaluator passes
|
|
6
|
+
* `['default']` for a side with no explicit thresholds, so overriding one
|
|
7
|
+
* side never wipes the other. An empty threshold array (not produced by the
|
|
8
|
+
* evaluator) would force the corresponding flag to `false` on every die.
|
|
9
|
+
* Meta dice (rolled to compute counts/sides/modifier args) are skipped so
|
|
10
|
+
* their bookkeeping stays untouched.
|
|
11
|
+
*
|
|
12
|
+
* Display-only: does not alter `total`, explosion triggers, success
|
|
13
|
+
* counting, or any other modifier flag. Dropped dice still participate —
|
|
14
|
+
* their `critical`/`fumble` metadata reflects what they rolled, not
|
|
15
|
+
* whether they contributed to the total.
|
|
16
|
+
*
|
|
17
|
+
* @module evaluator/modifiers/crit-threshold
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { DieResult, ResolvedCritThreshold } from '../../types.js';
|
|
21
|
+
import { matchesCondition } from './compare.js';
|
|
22
|
+
import { isVersusDc } from './flags.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Applies success/fail threshold arrays to a dice pool, overriding each
|
|
26
|
+
* die's `critical` and `fumble` flags in place. A die matches `'default'`
|
|
27
|
+
* on the success side when `result === sides && sides > 1`, and on the
|
|
28
|
+
* fail side when `result === 1`. Meta dice are skipped.
|
|
29
|
+
*/
|
|
30
|
+
export function applyCritThresholds(
|
|
31
|
+
dice: DieResult[],
|
|
32
|
+
successThresholds: ResolvedCritThreshold[],
|
|
33
|
+
failThresholds: ResolvedCritThreshold[],
|
|
34
|
+
): void {
|
|
35
|
+
for (const die of dice) {
|
|
36
|
+
if (die.modifiers.includes('meta') || isVersusDc(die)) continue;
|
|
37
|
+
|
|
38
|
+
die.critical = successThresholds.some((t) => matchesCrit(t, die));
|
|
39
|
+
die.fumble = failThresholds.some((t) => matchesFumble(t, die));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function matchesCrit(threshold: ResolvedCritThreshold, die: DieResult): boolean {
|
|
44
|
+
if (threshold === 'default') {
|
|
45
|
+
return die.result === die.sides && die.sides > 1;
|
|
46
|
+
}
|
|
47
|
+
return matchesCondition(die.result, threshold.operator, threshold.value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult): boolean {
|
|
51
|
+
if (threshold === 'default') {
|
|
52
|
+
// Mirrors `createDieResult` — a d1 always rolls 1, never a fumble.
|
|
53
|
+
return die.result === 1 && die.sides > 1;
|
|
54
|
+
}
|
|
55
|
+
return matchesCondition(die.result, threshold.operator, threshold.value);
|
|
56
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* `critical` / `fumble` keep reflecting the natural face — a clamped 1 is
|
|
12
|
+
* still a fumble, matching the raw-face crit semantics used everywhere else.
|
|
13
|
+
* Meta dice (rolled to compute counts/sides/modifier args) are skipped.
|
|
14
|
+
* Dropped dice are clamped too — they are excluded from totals anyway, and
|
|
15
|
+
* clamping them keeps the rendered pool consistent.
|
|
16
|
+
*
|
|
17
|
+
* @module evaluator/modifiers/die-bound
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { DieResult } from '../../types.js';
|
|
21
|
+
import { isVersusDc } from './flags.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Clamps every non-meta die in `dice` against `value`, in place.
|
|
25
|
+
* `bound: 'min'` lifts lower results up to `value`; `'max'` caps higher
|
|
26
|
+
* results down to it. Untouched dice keep their tags.
|
|
27
|
+
*/
|
|
28
|
+
export function applyDieBound(dice: DieResult[], bound: 'min' | 'max', value: number): void {
|
|
29
|
+
for (const die of dice) {
|
|
30
|
+
if (die.modifiers.includes('meta') || isVersusDc(die)) continue;
|
|
31
|
+
|
|
32
|
+
const clamped = bound === 'min' ? Math.max(die.result, value) : Math.min(die.result, value);
|
|
33
|
+
if (clamped === die.result) continue;
|
|
34
|
+
|
|
35
|
+
die.initialResult ??= die.result;
|
|
36
|
+
die.result = clamped;
|
|
37
|
+
die.modifiers.push(bound);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -8,12 +8,22 @@
|
|
|
8
8
|
* @module evaluator/modifiers/explode
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import
|
|
12
|
-
import type {
|
|
13
|
-
import type { DieResult } from '../../types';
|
|
14
|
-
import {
|
|
11
|
+
import { EvaluatorError } from '../../errors.js';
|
|
12
|
+
import type { RNG } from '../../rng/types.js';
|
|
13
|
+
import type { CompareOp, DieResult } from '../../types.js';
|
|
14
|
+
import { createDieResult } from '../die.js';
|
|
15
|
+
import { chargeDie, type EvalEnv } from '../env.js';
|
|
16
|
+
import { isVersusDc } from './flags.js';
|
|
15
17
|
|
|
16
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Default value of `EvaluationOptions.maxExplodeIterations`: how many times a
|
|
20
|
+
* single die may explode before `EXPLODE_LIMIT_EXCEEDED` is thrown.
|
|
21
|
+
*
|
|
22
|
+
* Per die, not per pool — it bounds notations like `1d1!`, where the
|
|
23
|
+
* explosion condition can never stop being true.
|
|
24
|
+
*
|
|
25
|
+
* @category Limits
|
|
26
|
+
*/
|
|
17
27
|
export const DEFAULT_MAX_EXPLODE_ITERATIONS = 1_000;
|
|
18
28
|
|
|
19
29
|
/**
|
|
@@ -52,14 +62,7 @@ export function buildShouldExplode(
|
|
|
52
62
|
* Rolls one explosion die, charging it against the global dice limit.
|
|
53
63
|
*/
|
|
54
64
|
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;
|
|
65
|
+
chargeDie(env, 'Explode');
|
|
63
66
|
return rng.nextInt(1, sides);
|
|
64
67
|
}
|
|
65
68
|
|
|
@@ -75,24 +78,34 @@ function explodeLimitError(maxIterations: number): EvaluatorError {
|
|
|
75
78
|
* Returns true when the die is eligible to start exploding: it must not
|
|
76
79
|
* already be dropped by a prior modifier, and its `sides` must be rollable.
|
|
77
80
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
81
|
+
* The `sides < 1` branch is a defense-in-depth fallback — Fate dice
|
|
82
|
+
* (sides = 0) are rejected at parse time via `INVALID_EXPLODE_TARGET`
|
|
83
|
+
* (see `parseExplode` in `src/parser/parser.ts`), so this guard should
|
|
84
|
+
* never fire during normal flow. Keeping it ensures `rng.nextInt(1, 0)`
|
|
85
|
+
* can never be reached if a future AST path slips past the parser gate.
|
|
80
86
|
*/
|
|
81
87
|
function canExplode(die: DieResult): boolean {
|
|
88
|
+
if (isVersusDc(die)) return false;
|
|
82
89
|
if (die.modifiers.includes('dropped')) return false;
|
|
83
90
|
if (die.sides < 1) return false;
|
|
84
91
|
return true;
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
95
|
+
* Shared implementation of the two appending explode variants. The explosion
|
|
96
|
+
* predicate always runs on the RAW roll; `storeResult` maps that raw roll to
|
|
97
|
+
* the value recorded on the appended die, which is the only thing standard
|
|
98
|
+
* and penetrating explosions disagree about.
|
|
99
|
+
*
|
|
100
|
+
* `critical`/`fumble` are likewise derived from the raw roll — a penetrating
|
|
101
|
+
* die that rolled its max face is still a crit even though it stores one less.
|
|
90
102
|
*/
|
|
91
|
-
|
|
103
|
+
function applyAppendingExplode(
|
|
92
104
|
pool: DieResult[],
|
|
93
105
|
shouldExplode: ShouldExplode,
|
|
94
106
|
rng: RNG,
|
|
95
107
|
env: EvalEnv,
|
|
108
|
+
storeResult: (raw: number) => number,
|
|
96
109
|
): DieResult[] {
|
|
97
110
|
const result: DieResult[] = [];
|
|
98
111
|
|
|
@@ -101,22 +114,18 @@ export function applyStandardExplode(
|
|
|
101
114
|
if (!canExplode(original)) continue;
|
|
102
115
|
|
|
103
116
|
const sides = original.sides;
|
|
104
|
-
let
|
|
117
|
+
let lastRaw = original.result;
|
|
105
118
|
let iterations = 0;
|
|
106
119
|
|
|
107
|
-
while (shouldExplode(
|
|
120
|
+
while (shouldExplode(lastRaw, sides)) {
|
|
108
121
|
if (iterations >= env.maxExplodeIterations) {
|
|
109
122
|
throw explodeLimitError(env.maxExplodeIterations);
|
|
110
123
|
}
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
critical: next === sides && sides > 1,
|
|
117
|
-
fumble: next === 1,
|
|
118
|
-
});
|
|
119
|
-
last = next;
|
|
124
|
+
const raw = rollExplosion(sides, rng, env);
|
|
125
|
+
const die = createDieResult(sides, raw, ['exploded', 'kept']);
|
|
126
|
+
die.result = storeResult(raw);
|
|
127
|
+
result.push(die);
|
|
128
|
+
lastRaw = raw;
|
|
120
129
|
iterations += 1;
|
|
121
130
|
}
|
|
122
131
|
}
|
|
@@ -124,10 +133,37 @@ export function applyStandardExplode(
|
|
|
124
133
|
return result;
|
|
125
134
|
}
|
|
126
135
|
|
|
136
|
+
/** Records the raw roll unchanged — standard explode. */
|
|
137
|
+
function keepRaw(raw: number): number {
|
|
138
|
+
return raw;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Records one less than the raw roll — penetrating explode. */
|
|
142
|
+
function penetratingPenalty(raw: number): number {
|
|
143
|
+
return raw - 1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Standard explode: append each new die to the pool. The original die keeps
|
|
148
|
+
* its modifiers untouched; new dice carry `['exploded', 'kept']`.
|
|
149
|
+
*/
|
|
150
|
+
export function applyStandardExplode(
|
|
151
|
+
pool: DieResult[],
|
|
152
|
+
shouldExplode: ShouldExplode,
|
|
153
|
+
rng: RNG,
|
|
154
|
+
env: EvalEnv,
|
|
155
|
+
): DieResult[] {
|
|
156
|
+
return applyAppendingExplode(pool, shouldExplode, rng, env, keepRaw);
|
|
157
|
+
}
|
|
158
|
+
|
|
127
159
|
/**
|
|
128
160
|
* Compound explode: pool length stays the same. Each original die's `result`
|
|
129
161
|
* accumulates every explosion roll, and gains the `'exploded'` modifier once
|
|
130
162
|
* it actually exploded at least once.
|
|
163
|
+
*
|
|
164
|
+
* Mutates exploded dice in place — the same `DieResult` objects are shared
|
|
165
|
+
* between `RollResult.rolls` and the `RollPart` tree, and both must reflect
|
|
166
|
+
* post-explosion state.
|
|
131
167
|
*/
|
|
132
168
|
export function applyCompoundExplode(
|
|
133
169
|
pool: DieResult[],
|
|
@@ -135,8 +171,8 @@ export function applyCompoundExplode(
|
|
|
135
171
|
rng: RNG,
|
|
136
172
|
env: EvalEnv,
|
|
137
173
|
): DieResult[] {
|
|
138
|
-
|
|
139
|
-
if (!canExplode(original))
|
|
174
|
+
for (const original of pool) {
|
|
175
|
+
if (!canExplode(original)) continue;
|
|
140
176
|
|
|
141
177
|
const sides = original.sides;
|
|
142
178
|
let accumulated = original.result;
|
|
@@ -155,27 +191,25 @@ export function applyCompoundExplode(
|
|
|
155
191
|
iterations += 1;
|
|
156
192
|
}
|
|
157
193
|
|
|
158
|
-
if (!exploded)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
fumble: original.fumble,
|
|
171
|
-
};
|
|
172
|
-
});
|
|
194
|
+
if (!exploded) continue;
|
|
195
|
+
|
|
196
|
+
// `critical` and `fumble` keep referring to the original triggering
|
|
197
|
+
// roll — after compounding the `result` is a sum.
|
|
198
|
+
original.initialResult = original.result;
|
|
199
|
+
original.result = accumulated;
|
|
200
|
+
if (!original.modifiers.includes('exploded')) {
|
|
201
|
+
original.modifiers = [...original.modifiers, 'exploded'];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return pool;
|
|
173
206
|
}
|
|
174
207
|
|
|
175
208
|
/**
|
|
176
209
|
* Penetrating explode: like standard, but each appended die stores
|
|
177
210
|
* `result = rawRoll - 1`. The explosion predicate runs on the RAW roll
|
|
178
|
-
* (pre-decrement),
|
|
211
|
+
* (pre-decrement), so a max face still chains even though its stored value
|
|
212
|
+
* is one lower.
|
|
179
213
|
*/
|
|
180
214
|
export function applyPenetratingExplode(
|
|
181
215
|
pool: DieResult[],
|
|
@@ -183,33 +217,5 @@ export function applyPenetratingExplode(
|
|
|
183
217
|
rng: RNG,
|
|
184
218
|
env: EvalEnv,
|
|
185
219
|
): DieResult[] {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
for (const original of pool) {
|
|
189
|
-
result.push(original);
|
|
190
|
-
if (!canExplode(original)) continue;
|
|
191
|
-
|
|
192
|
-
const sides = original.sides;
|
|
193
|
-
let lastRaw = original.result;
|
|
194
|
-
let iterations = 0;
|
|
195
|
-
|
|
196
|
-
while (shouldExplode(lastRaw, sides)) {
|
|
197
|
-
if (iterations >= env.maxExplodeIterations) {
|
|
198
|
-
throw explodeLimitError(env.maxExplodeIterations);
|
|
199
|
-
}
|
|
200
|
-
const raw = rollExplosion(sides, rng, env);
|
|
201
|
-
const stored = raw - 1;
|
|
202
|
-
result.push({
|
|
203
|
-
sides,
|
|
204
|
-
result: stored,
|
|
205
|
-
modifiers: ['exploded', 'kept'],
|
|
206
|
-
critical: raw === sides && sides > 1,
|
|
207
|
-
fumble: raw === 1,
|
|
208
|
-
});
|
|
209
|
-
lastRaw = raw;
|
|
210
|
-
iterations += 1;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return result;
|
|
220
|
+
return applyAppendingExplode(pool, shouldExplode, rng, env, penetratingPenalty);
|
|
215
221
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
export function isVersusDc(die: DieResult): boolean {
|
|
25
|
+
return die.modifiers.includes('dc');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Kept/dropped selection flags — rebuilt by every keep/drop pass. */
|
|
29
|
+
export const SELECTION_FLAGS: readonly DieModifier[] = ['kept', 'dropped'];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Selection flags plus the success-count tally flags. Stripped when a die
|
|
33
|
+
* leaves the pool that tagged it (meta sub-expressions, dropped group
|
|
34
|
+
* sub-rolls) so the top-level successes/failures scan cannot count it.
|
|
35
|
+
*/
|
|
36
|
+
export const SELECTION_AND_TALLY_FLAGS: readonly DieModifier[] = [
|
|
37
|
+
'kept',
|
|
38
|
+
'dropped',
|
|
39
|
+
'success',
|
|
40
|
+
'failure',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Selection flags plus `rerolled` — reassigned on every reroll pass. */
|
|
44
|
+
export const REROLL_SLOT_FLAGS: readonly DieModifier[] = ['kept', 'dropped', 'rerolled'];
|
|
45
|
+
|
|
46
|
+
/** Returns `modifiers` with every flag in `excluded` removed. */
|
|
47
|
+
export function stripFlags(
|
|
48
|
+
modifiers: readonly DieModifier[],
|
|
49
|
+
excluded: readonly DieModifier[],
|
|
50
|
+
): DieModifier[] {
|
|
51
|
+
return modifiers.filter((modifier) => !excluded.includes(modifier));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Returns `modifiers` with `excluded` removed and `added` appended, in order. */
|
|
55
|
+
export function rewriteFlags(
|
|
56
|
+
modifiers: readonly DieModifier[],
|
|
57
|
+
excluded: readonly DieModifier[],
|
|
58
|
+
...added: DieModifier[]
|
|
59
|
+
): DieModifier[] {
|
|
60
|
+
return [...stripFlags(modifiers, excluded), ...added];
|
|
61
|
+
}
|
|
@@ -4,138 +4,131 @@
|
|
|
4
4
|
* @module evaluator/modifiers/keep-drop
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { DieResult } from '../../types';
|
|
7
|
+
import type { DieResult, KeepDropSpec } from '../../types.js';
|
|
8
|
+
import { isVersusDc } from './flags.js';
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
* (e.g., intermediate rerolls, or the loser of a prior modifier) are left
|
|
12
|
-
* untouched so those drops cannot be silently revived.
|
|
13
|
-
*/
|
|
14
|
-
export function markAllKept(dice: DieResult[]): DieResult[] {
|
|
15
|
-
return dice.map((die) => {
|
|
16
|
-
if (die.modifiers.includes('dropped')) return die;
|
|
17
|
-
return {
|
|
18
|
-
...die,
|
|
19
|
-
modifiers: die.modifiers.includes('kept') ? die.modifiers : [...die.modifiers, 'kept'],
|
|
20
|
-
};
|
|
21
|
-
});
|
|
22
|
-
}
|
|
10
|
+
/** One selectable die: its rolled value and its slot in the original pool. */
|
|
11
|
+
type EligibleDie = { result: number; index: number };
|
|
23
12
|
|
|
24
13
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* intermediate dice carry `'dropped'` and must be ignored here).
|
|
28
|
-
*/
|
|
29
|
-
function eligibleIndexed(dice: DieResult[]): { die: DieResult; index: number }[] {
|
|
30
|
-
return dice
|
|
31
|
-
.map((die, index) => ({ die, index }))
|
|
32
|
-
.filter(({ die }) => !die.modifiers.includes('dropped'));
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Rebuilds a die's slot flags (`kept` / `dropped`) from the selection set.
|
|
37
|
-
* Pre-dropped dice are returned unchanged so their state is preserved.
|
|
38
|
-
*/
|
|
39
|
-
function applySelection(
|
|
40
|
-
dice: DieResult[],
|
|
41
|
-
selectionIndices: Set<number>,
|
|
42
|
-
selectionMarker: 'kept' | 'dropped',
|
|
43
|
-
): DieResult[] {
|
|
44
|
-
const otherMarker = selectionMarker === 'kept' ? 'dropped' : 'kept';
|
|
45
|
-
|
|
46
|
-
return dice.map((die, index) => {
|
|
47
|
-
if (die.modifiers.includes('dropped')) return die;
|
|
48
|
-
|
|
49
|
-
const isSelected = selectionIndices.has(index);
|
|
50
|
-
const marker = isSelected ? selectionMarker : otherMarker;
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
...die,
|
|
54
|
-
modifiers: [...die.modifiers.filter((m) => m !== 'kept' && m !== 'dropped'), marker],
|
|
55
|
-
};
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Drops every non-already-dropped die. Used by keep-N when N <= 0 and by
|
|
61
|
-
* drop-N when N >= eligible.length.
|
|
62
|
-
*/
|
|
63
|
-
function markAllDropped(dice: DieResult[]): DieResult[] {
|
|
64
|
-
return dice.map((die) => {
|
|
65
|
-
if (die.modifiers.includes('dropped')) return die;
|
|
66
|
-
return {
|
|
67
|
-
...die,
|
|
68
|
-
modifiers: [...die.modifiers.filter((m) => m !== 'kept'), 'dropped'],
|
|
69
|
-
};
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Applies keep highest modifier - keeps the N highest eligible dice, marks
|
|
75
|
-
* others as dropped. Dice already marked `'dropped'` are left unchanged.
|
|
14
|
+
* Records into `droppedMask` every pool slot that `kind` / `selector` /
|
|
15
|
+
* `count` drops.
|
|
76
16
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (count <= 0) return markAllDropped(dice);
|
|
86
|
-
|
|
87
|
-
const sorted = [...eligible].sort((a, b) => b.die.result - a.die.result);
|
|
88
|
-
const keptIndices = new Set(sorted.slice(0, count).map((item) => item.index));
|
|
89
|
-
|
|
90
|
-
return applySelection(dice, keptIndices, 'kept');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Applies keep lowest modifier - keeps the N lowest eligible dice, marks
|
|
95
|
-
* others as dropped. Dice already marked `'dropped'` are left unchanged.
|
|
96
|
-
*/
|
|
97
|
-
export function applyKeepLowest(dice: DieResult[], count: number): DieResult[] {
|
|
98
|
-
const eligible = eligibleIndexed(dice);
|
|
99
|
-
|
|
100
|
-
if (count >= eligible.length) return markAllKept(dice);
|
|
101
|
-
if (count <= 0) return markAllDropped(dice);
|
|
102
|
-
|
|
103
|
-
const sorted = [...eligible].sort((a, b) => a.die.result - b.die.result);
|
|
104
|
-
const keptIndices = new Set(sorted.slice(0, count).map((item) => item.index));
|
|
105
|
-
|
|
106
|
-
return applySelection(dice, keptIndices, 'kept');
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Applies drop highest modifier - drops the N highest eligible dice, keeps
|
|
111
|
-
* the rest. Dice already marked `'dropped'` are left unchanged.
|
|
17
|
+
* Nothing is cloned and no flag is written. The caller owns the merge, so a
|
|
18
|
+
* chain like `4d6kh3dl1` runs one pass per spec over the same mask and then
|
|
19
|
+
* rewrites each die's slot flags exactly once — where the previous
|
|
20
|
+
* clone-per-spec appliers rebuilt the whole pool for every spec only to have
|
|
21
|
+
* their flags read back and discarded.
|
|
22
|
+
*
|
|
23
|
+
* Dice already carrying `'dropped'` (reroll intermediates, meta dice, a
|
|
24
|
+
* preceding chain's losers) are ineligible for selection and stay dropped.
|
|
112
25
|
*/
|
|
113
|
-
export function
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
26
|
+
export function markDroppedIndices(
|
|
27
|
+
dice: DieResult[],
|
|
28
|
+
count: number,
|
|
29
|
+
kind: KeepDropSpec['kind'],
|
|
30
|
+
selector: KeepDropSpec['selector'],
|
|
31
|
+
droppedMask: Uint8Array,
|
|
32
|
+
): void {
|
|
33
|
+
if (count === 1) {
|
|
34
|
+
markSingleExtreme(dice, kind, selector, droppedMask);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const eligible: EligibleDie[] = [];
|
|
39
|
+
|
|
40
|
+
for (let index = 0; index < dice.length; index++) {
|
|
41
|
+
const die = dice[index];
|
|
42
|
+
if (die == null) continue;
|
|
43
|
+
if (isVersusDc(die)) continue;
|
|
44
|
+
|
|
45
|
+
if (die.modifiers.includes('dropped')) {
|
|
46
|
+
droppedMask[index] = 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
eligible.push({ result: die.result, index });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isKeep = kind === 'keep';
|
|
54
|
+
|
|
55
|
+
// Keep-everything: a keep covering the whole eligible pool, or a zero drop.
|
|
56
|
+
if (isKeep ? count >= eligible.length : count <= 0) return;
|
|
57
|
+
|
|
58
|
+
// Drop-everything: a zero keep, or a drop covering the whole eligible pool.
|
|
59
|
+
if (isKeep ? count <= 0 : count >= eligible.length) {
|
|
60
|
+
for (const item of eligible) {
|
|
61
|
+
droppedMask[item.index] = 1;
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Stable sort — ties resolve by original pool order.
|
|
67
|
+
eligible.sort(
|
|
68
|
+
selector === 'highest' ? (a, b) => b.result - a.result : (a, b) => a.result - b.result,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// The sort puts the acted-on dice first: `keep` drops everything past
|
|
72
|
+
// `count`, `drop` drops the selection itself.
|
|
73
|
+
const start = isKeep ? count : 0;
|
|
74
|
+
const end = isKeep ? eligible.length : count;
|
|
75
|
+
|
|
76
|
+
for (let i = start; i < end; i++) {
|
|
77
|
+
const item = eligible[i];
|
|
78
|
+
if (item != null) droppedMask[item.index] = 1;
|
|
79
|
+
}
|
|
123
80
|
}
|
|
124
81
|
|
|
125
82
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
83
|
+
* `count === 1` fast path: a single linear scan replaces the wrapper array
|
|
84
|
+
* and comparator sort — `2d20kh1` (advantage) and `4d6dl1` are the most
|
|
85
|
+
* common notations. Strict comparison preserves the stable sort's
|
|
86
|
+
* first-occurrence tie-break, and bits are only ever set, never cleared, so
|
|
87
|
+
* a shared mask keeps every previous spec's drops.
|
|
128
88
|
*/
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
89
|
+
function markSingleExtreme(
|
|
90
|
+
dice: DieResult[],
|
|
91
|
+
kind: KeepDropSpec['kind'],
|
|
92
|
+
selector: KeepDropSpec['selector'],
|
|
93
|
+
droppedMask: Uint8Array,
|
|
94
|
+
): void {
|
|
95
|
+
const isKeep = kind === 'keep';
|
|
96
|
+
const wantHighest = selector === 'highest';
|
|
97
|
+
|
|
98
|
+
let extremeIndex = -1;
|
|
99
|
+
let extremeResult = 0;
|
|
100
|
+
|
|
101
|
+
for (let index = 0; index < dice.length; index++) {
|
|
102
|
+
const die = dice[index];
|
|
103
|
+
if (die == null) continue;
|
|
104
|
+
if (isVersusDc(die)) continue;
|
|
105
|
+
|
|
106
|
+
if (die.modifiers.includes('dropped')) {
|
|
107
|
+
droppedMask[index] = 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { result } = die;
|
|
112
|
+
|
|
113
|
+
if (extremeIndex === -1) {
|
|
114
|
+
extremeIndex = index;
|
|
115
|
+
extremeResult = result;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (wantHighest ? result > extremeResult : result < extremeResult) {
|
|
120
|
+
// A keep drops the dethroned extreme; a drop keeps everything else.
|
|
121
|
+
if (isKeep) droppedMask[extremeIndex] = 1;
|
|
122
|
+
extremeIndex = index;
|
|
123
|
+
extremeResult = result;
|
|
124
|
+
} else if (isKeep) {
|
|
125
|
+
droppedMask[index] = 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Keeping 1 of ≤1 eligible dice drops nothing; dropping 1 of ≥1 drops the
|
|
130
|
+
// extreme — both match the general path's whole-pool guards.
|
|
131
|
+
if (!isKeep && extremeIndex !== -1) droppedMask[extremeIndex] = 1;
|
|
139
132
|
}
|
|
140
133
|
|
|
141
134
|
/**
|
|
@@ -145,7 +138,12 @@ export function applyDropLowest(dice: DieResult[], count: number): DieResult[] {
|
|
|
145
138
|
* @returns Sum of non-dropped dice
|
|
146
139
|
*/
|
|
147
140
|
export function sumKeptDice(dice: DieResult[]): number {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
141
|
+
let total = 0;
|
|
142
|
+
|
|
143
|
+
for (const die of dice) {
|
|
144
|
+
if (isVersusDc(die)) continue;
|
|
145
|
+
if (!die.modifiers.includes('dropped')) total += die.result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return total;
|
|
151
149
|
}
|