roll-parser 2.3.2 → 3.0.0-beta.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 +91 -0
- package/README.md +175 -170
- package/dist/cli/args.d.ts +35 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/format.d.ts +20 -0
- package/dist/cli/format.d.ts.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli.js +2608 -0
- package/dist/cli.js.map +28 -0
- package/dist/errors.d.ts +42 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/evaluator/evaluator.d.ts +121 -0
- package/dist/evaluator/evaluator.d.ts.map +1 -0
- package/dist/evaluator/index.d.ts +8 -0
- package/dist/evaluator/index.d.ts.map +1 -0
- package/dist/evaluator/modifiers/compare.d.ts +15 -0
- package/dist/evaluator/modifiers/compare.d.ts.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts +28 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
- package/dist/evaluator/modifiers/explode.d.ts +48 -0
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts +44 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -0
- package/dist/evaluator/modifiers/reroll.d.ts +33 -0
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -0
- package/dist/evaluator/modifiers/sort.d.ts +23 -0
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
- package/dist/evaluator/modifiers/success-count.d.ts +28 -0
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2560 -0
- package/dist/index.js.map +26 -0
- package/dist/lexer/lexer.d.ts +90 -0
- package/dist/lexer/lexer.d.ts.map +1 -0
- package/dist/lexer/tokens.d.ts +107 -0
- package/dist/lexer/tokens.d.ts.map +1 -0
- package/dist/parser/ast.d.ts +368 -0
- package/dist/parser/ast.d.ts.map +1 -0
- package/dist/parser/parser.d.ts +145 -0
- package/dist/parser/parser.d.ts.map +1 -0
- package/dist/rng/index.d.ts +8 -0
- package/dist/rng/index.d.ts.map +1 -0
- package/dist/rng/mock.d.ts +36 -0
- package/dist/rng/mock.d.ts.map +1 -0
- package/dist/rng/seeded.d.ts +40 -0
- package/dist/rng/seeded.d.ts.map +1 -0
- package/dist/rng/types.d.ts +24 -0
- package/dist/rng/types.d.ts.map +1 -0
- package/dist/roll.d.ts +51 -0
- package/dist/roll.d.ts.map +1 -0
- package/dist/testing.d.ts +11 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +41 -0
- package/dist/testing.js.map +11 -0
- package/dist/types.d.ts +246 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +66 -43
- package/src/cli/args.ts +73 -0
- package/src/cli/format.ts +49 -0
- package/src/cli/index.ts +92 -0
- package/src/errors.ts +90 -0
- package/src/evaluator/evaluator.ts +1552 -0
- package/src/evaluator/index.ts +14 -0
- package/src/evaluator/modifiers/compare.ts +29 -0
- package/src/evaluator/modifiers/crit-threshold.ts +59 -0
- package/src/evaluator/modifiers/explode.ts +219 -0
- package/src/evaluator/modifiers/keep-drop.ts +151 -0
- package/src/evaluator/modifiers/reroll.ts +165 -0
- package/src/evaluator/modifiers/sort.ts +30 -0
- package/src/evaluator/modifiers/success-count.ts +59 -0
- package/src/index.ts +90 -0
- package/src/lexer/lexer.ts +374 -0
- package/src/lexer/tokens.ts +187 -0
- package/src/parser/ast.ts +628 -0
- package/src/parser/parser.ts +1239 -0
- package/src/rng/index.ts +8 -0
- package/src/rng/mock.ts +65 -0
- package/src/rng/seeded.ts +157 -0
- package/src/rng/types.ts +25 -0
- package/src/roll.ts +74 -0
- package/src/testing.ts +16 -0
- package/src/types.ts +237 -0
- package/bin/help.txt +0 -19
- package/bin/roll-parser.js +0 -23
- package/dist/roll-parser.js +0 -697
- package/dist/roll-parser.min.js +0 -1
- package/index.js +0 -24
- package/src/complex/parse.js +0 -37
- package/src/complex/parseAndRoll.js +0 -21
- package/src/complex/parseAndRollClassic.js +0 -20
- package/src/complex/parseAndRollSimple.js +0 -19
- package/src/complex/parseAndRollWod.js +0 -20
- package/src/complex/parseClassicRoll.js +0 -22
- package/src/complex/parseSimpleRoll.js +0 -21
- package/src/complex/parseWodRoll.js +0 -21
- package/src/converter.js +0 -45
- package/src/grammar.js +0 -14
- package/src/mapper.js +0 -50
- package/src/normalizer.js +0 -51
- package/src/object/Result.js +0 -23
- package/src/object/Roll.js +0 -26
- package/src/object/Type.js +0 -5
- package/src/object/WodRoll.js +0 -29
- package/src/parser.js +0 -43
- package/src/random.js +0 -22
- package/src/roller.js +0 -106
- package/src/stringifier.js +0 -33
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluator module - AST to roll result transformation.
|
|
3
|
+
*
|
|
4
|
+
* @module evaluator
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { DEFAULT_MAX_DICE, evaluate, EvaluatorError } from './evaluator.js';
|
|
8
|
+
export {
|
|
9
|
+
applyDropHighest,
|
|
10
|
+
applyDropLowest,
|
|
11
|
+
applyKeepHighest,
|
|
12
|
+
applyKeepLowest,
|
|
13
|
+
sumKeptDice,
|
|
14
|
+
} from './modifiers/keep-drop.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared comparison helper for dice-pool modifiers.
|
|
3
|
+
*
|
|
4
|
+
* Both reroll and success-counting modifiers compare a die result against a
|
|
5
|
+
* fixed threshold. Extracting the comparison keeps those modules free of
|
|
6
|
+
* duplicated logic.
|
|
7
|
+
*
|
|
8
|
+
* @module evaluator/modifiers/compare
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CompareOp } from '../../types.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compares a die result against a fixed comparison threshold.
|
|
15
|
+
*/
|
|
16
|
+
export function matchesCondition(result: number, operator: CompareOp, value: number): boolean {
|
|
17
|
+
switch (operator) {
|
|
18
|
+
case '>':
|
|
19
|
+
return result > value;
|
|
20
|
+
case '>=':
|
|
21
|
+
return result >= value;
|
|
22
|
+
case '<':
|
|
23
|
+
return result < value;
|
|
24
|
+
case '<=':
|
|
25
|
+
return result <= value;
|
|
26
|
+
case '=':
|
|
27
|
+
return result === value;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
|
|
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';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Applies success/fail threshold arrays to a dice pool, overriding each
|
|
29
|
+
* die's `critical` and `fumble` flags in place. A die matches `'default'`
|
|
30
|
+
* on the success side when `result === sides && sides > 1`, and on the
|
|
31
|
+
* fail side when `result === 1`. Meta dice are skipped.
|
|
32
|
+
*/
|
|
33
|
+
export function applyCritThresholds(
|
|
34
|
+
dice: DieResult[],
|
|
35
|
+
successThresholds: ResolvedCritThreshold[],
|
|
36
|
+
failThresholds: ResolvedCritThreshold[],
|
|
37
|
+
): void {
|
|
38
|
+
for (const die of dice) {
|
|
39
|
+
if (die.modifiers.includes('meta')) continue;
|
|
40
|
+
|
|
41
|
+
die.critical = successThresholds.some((t) => matchesCrit(t, die));
|
|
42
|
+
die.fumble = failThresholds.some((t) => matchesFumble(t, die));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function matchesCrit(threshold: ResolvedCritThreshold, die: DieResult): boolean {
|
|
47
|
+
if (threshold === 'default') {
|
|
48
|
+
return die.result === die.sides && die.sides > 1;
|
|
49
|
+
}
|
|
50
|
+
return matchesCondition(die.result, threshold.operator, threshold.value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult): boolean {
|
|
54
|
+
if (threshold === 'default') {
|
|
55
|
+
// ? Mirrors `createDieResult` — a d1 always rolls 1, never a fumble.
|
|
56
|
+
return die.result === 1 && die.sides > 1;
|
|
57
|
+
}
|
|
58
|
+
return matchesCondition(die.result, threshold.operator, threshold.value);
|
|
59
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exploding dice modifier implementations.
|
|
3
|
+
*
|
|
4
|
+
* Standard (`!`): re-roll on match, append each new die to the pool.
|
|
5
|
+
* Compound (`!!`): re-roll on match, accumulate into the original die's `result`.
|
|
6
|
+
* Penetrating (`!p`): re-roll on match, append with `result = rawRoll - 1`.
|
|
7
|
+
*
|
|
8
|
+
* @module evaluator/modifiers/explode
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { RNG } from '../../rng/types.js';
|
|
12
|
+
import type { CompareOp } from '../../types.js';
|
|
13
|
+
import type { DieResult } from '../../types.js';
|
|
14
|
+
import { EvaluatorError, type EvalEnv } from '../evaluator.js';
|
|
15
|
+
|
|
16
|
+
/** Default maximum explosion iterations per die. */
|
|
17
|
+
export const DEFAULT_MAX_EXPLODE_ITERATIONS = 1_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Predicate deciding whether a die with the given latest raw roll continues
|
|
21
|
+
* to explode. `sides` is passed separately because the default predicate
|
|
22
|
+
* ("explode on max face") uses `result === sides`.
|
|
23
|
+
*/
|
|
24
|
+
export type ShouldExplode = (rawResult: number, sides: number) => boolean;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds an explosion predicate from an optional ComparePoint. When no
|
|
28
|
+
* threshold is given, explode when the raw result equals the die's max face.
|
|
29
|
+
*/
|
|
30
|
+
export function buildShouldExplode(
|
|
31
|
+
operator: CompareOp | undefined,
|
|
32
|
+
thresholdValue: number | undefined,
|
|
33
|
+
): ShouldExplode {
|
|
34
|
+
if (operator == null || thresholdValue == null) {
|
|
35
|
+
return (result, sides) => result === sides;
|
|
36
|
+
}
|
|
37
|
+
switch (operator) {
|
|
38
|
+
case '>':
|
|
39
|
+
return (result) => result > thresholdValue;
|
|
40
|
+
case '>=':
|
|
41
|
+
return (result) => result >= thresholdValue;
|
|
42
|
+
case '<':
|
|
43
|
+
return (result) => result < thresholdValue;
|
|
44
|
+
case '<=':
|
|
45
|
+
return (result) => result <= thresholdValue;
|
|
46
|
+
case '=':
|
|
47
|
+
return (result) => result === thresholdValue;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Rolls one explosion die, charging it against the global dice limit.
|
|
53
|
+
*/
|
|
54
|
+
function rollExplosion(sides: number, rng: RNG, env: EvalEnv): number {
|
|
55
|
+
if (env.totalDiceRolled + 1 > env.maxDice) {
|
|
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;
|
|
63
|
+
return rng.nextInt(1, sides);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function explodeLimitError(maxIterations: number): EvaluatorError {
|
|
67
|
+
return new EvaluatorError(
|
|
68
|
+
`Explode iteration limit of ${maxIterations} exceeded`,
|
|
69
|
+
'EXPLODE_LIMIT_EXCEEDED',
|
|
70
|
+
'Explode',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Returns true when the die is eligible to start exploding: it must not
|
|
76
|
+
* already be dropped by a prior modifier, and its `sides` must be rollable.
|
|
77
|
+
*
|
|
78
|
+
* The `sides < 1` branch is a defense-in-depth fallback — Fate dice
|
|
79
|
+
* (sides = 0) are rejected at parse time via `INVALID_EXPLODE_TARGET`
|
|
80
|
+
* (see `parseExplode` in `src/parser/parser.ts`), so this guard should
|
|
81
|
+
* never fire during normal flow. Keeping it ensures `rng.nextInt(1, 0)`
|
|
82
|
+
* can never be reached if a future AST path slips past the parser gate.
|
|
83
|
+
*/
|
|
84
|
+
function canExplode(die: DieResult): boolean {
|
|
85
|
+
if (die.modifiers.includes('dropped')) return false;
|
|
86
|
+
if (die.sides < 1) return false;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Standard explode: append each new die to the pool. The original die keeps
|
|
92
|
+
* its modifiers untouched; new dice carry `['exploded', 'kept']`.
|
|
93
|
+
*/
|
|
94
|
+
export function applyStandardExplode(
|
|
95
|
+
pool: DieResult[],
|
|
96
|
+
shouldExplode: ShouldExplode,
|
|
97
|
+
rng: RNG,
|
|
98
|
+
env: EvalEnv,
|
|
99
|
+
): DieResult[] {
|
|
100
|
+
const result: DieResult[] = [];
|
|
101
|
+
|
|
102
|
+
for (const original of pool) {
|
|
103
|
+
result.push(original);
|
|
104
|
+
if (!canExplode(original)) continue;
|
|
105
|
+
|
|
106
|
+
const sides = original.sides;
|
|
107
|
+
let last = original.result;
|
|
108
|
+
let iterations = 0;
|
|
109
|
+
|
|
110
|
+
while (shouldExplode(last, sides)) {
|
|
111
|
+
if (iterations >= env.maxExplodeIterations) {
|
|
112
|
+
throw explodeLimitError(env.maxExplodeIterations);
|
|
113
|
+
}
|
|
114
|
+
const next = rollExplosion(sides, rng, env);
|
|
115
|
+
result.push({
|
|
116
|
+
sides,
|
|
117
|
+
result: next,
|
|
118
|
+
modifiers: ['exploded', 'kept'],
|
|
119
|
+
critical: next === sides && sides > 1,
|
|
120
|
+
fumble: next === 1 && sides > 1,
|
|
121
|
+
});
|
|
122
|
+
last = next;
|
|
123
|
+
iterations += 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Compound explode: pool length stays the same. Each original die's `result`
|
|
132
|
+
* accumulates every explosion roll, and gains the `'exploded'` modifier once
|
|
133
|
+
* it actually exploded at least once.
|
|
134
|
+
*
|
|
135
|
+
* Mutates exploded dice in place — the same `DieResult` objects are shared
|
|
136
|
+
* between `RollResult.rolls` and the `RollPart` tree, and both must reflect
|
|
137
|
+
* post-explosion state.
|
|
138
|
+
*/
|
|
139
|
+
export function applyCompoundExplode(
|
|
140
|
+
pool: DieResult[],
|
|
141
|
+
shouldExplode: ShouldExplode,
|
|
142
|
+
rng: RNG,
|
|
143
|
+
env: EvalEnv,
|
|
144
|
+
): DieResult[] {
|
|
145
|
+
for (const original of pool) {
|
|
146
|
+
if (!canExplode(original)) continue;
|
|
147
|
+
|
|
148
|
+
const sides = original.sides;
|
|
149
|
+
let accumulated = original.result;
|
|
150
|
+
let last = original.result;
|
|
151
|
+
let iterations = 0;
|
|
152
|
+
let exploded = false;
|
|
153
|
+
|
|
154
|
+
while (shouldExplode(last, sides)) {
|
|
155
|
+
if (iterations >= env.maxExplodeIterations) {
|
|
156
|
+
throw explodeLimitError(env.maxExplodeIterations);
|
|
157
|
+
}
|
|
158
|
+
const next = rollExplosion(sides, rng, env);
|
|
159
|
+
accumulated += next;
|
|
160
|
+
last = next;
|
|
161
|
+
exploded = true;
|
|
162
|
+
iterations += 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!exploded) continue;
|
|
166
|
+
|
|
167
|
+
// ? `critical` and `fumble` keep referring to the original triggering
|
|
168
|
+
// roll — after compounding the `result` is a sum.
|
|
169
|
+
original.initialResult = original.result;
|
|
170
|
+
original.result = accumulated;
|
|
171
|
+
if (!original.modifiers.includes('exploded')) {
|
|
172
|
+
original.modifiers = [...original.modifiers, 'exploded'];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return pool;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Penetrating explode: like standard, but each appended die stores
|
|
181
|
+
* `result = rawRoll - 1`. The explosion predicate runs on the RAW roll
|
|
182
|
+
* (pre-decrement), matching rpg-dice-roller convention.
|
|
183
|
+
*/
|
|
184
|
+
export function applyPenetratingExplode(
|
|
185
|
+
pool: DieResult[],
|
|
186
|
+
shouldExplode: ShouldExplode,
|
|
187
|
+
rng: RNG,
|
|
188
|
+
env: EvalEnv,
|
|
189
|
+
): DieResult[] {
|
|
190
|
+
const result: DieResult[] = [];
|
|
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;
|
|
219
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep/drop modifier implementations for dice pools.
|
|
3
|
+
*
|
|
4
|
+
* @module evaluator/modifiers/keep-drop
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DieResult } from '../../types.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Marks all eligible dice as `'kept'`. Dice that already carry `'dropped'`
|
|
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
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns indexed dice that are eligible for keep/drop selection — dice that
|
|
26
|
+
* have not already been dropped by a preceding modifier (e.g. reroll's
|
|
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.
|
|
76
|
+
*
|
|
77
|
+
* @param dice - Array of die results
|
|
78
|
+
* @param count - Number of dice to keep
|
|
79
|
+
* @returns New array with appropriate modifiers applied
|
|
80
|
+
*/
|
|
81
|
+
export function applyKeepHighest(dice: DieResult[], count: number): DieResult[] {
|
|
82
|
+
const eligible = eligibleIndexed(dice);
|
|
83
|
+
|
|
84
|
+
if (count >= eligible.length) return markAllKept(dice);
|
|
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.
|
|
112
|
+
*/
|
|
113
|
+
export function applyDropHighest(dice: DieResult[], count: number): DieResult[] {
|
|
114
|
+
const eligible = eligibleIndexed(dice);
|
|
115
|
+
|
|
116
|
+
if (count <= 0) return markAllKept(dice);
|
|
117
|
+
if (count >= eligible.length) return markAllDropped(dice);
|
|
118
|
+
|
|
119
|
+
const sorted = [...eligible].sort((a, b) => b.die.result - a.die.result);
|
|
120
|
+
const droppedIndices = new Set(sorted.slice(0, count).map((item) => item.index));
|
|
121
|
+
|
|
122
|
+
return applySelection(dice, droppedIndices, 'dropped');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Applies drop lowest modifier - drops the N lowest eligible dice, keeps
|
|
127
|
+
* the rest. Dice already marked `'dropped'` are left unchanged.
|
|
128
|
+
*/
|
|
129
|
+
export function applyDropLowest(dice: DieResult[], count: number): DieResult[] {
|
|
130
|
+
const eligible = eligibleIndexed(dice);
|
|
131
|
+
|
|
132
|
+
if (count <= 0) return markAllKept(dice);
|
|
133
|
+
if (count >= eligible.length) return markAllDropped(dice);
|
|
134
|
+
|
|
135
|
+
const sorted = [...eligible].sort((a, b) => a.die.result - b.die.result);
|
|
136
|
+
const droppedIndices = new Set(sorted.slice(0, count).map((item) => item.index));
|
|
137
|
+
|
|
138
|
+
return applySelection(dice, droppedIndices, 'dropped');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Calculates total from dice, excluding dropped dice.
|
|
143
|
+
*
|
|
144
|
+
* @param dice - Array of die results
|
|
145
|
+
* @returns Sum of non-dropped dice
|
|
146
|
+
*/
|
|
147
|
+
export function sumKeptDice(dice: DieResult[]): number {
|
|
148
|
+
return dice
|
|
149
|
+
.filter((die) => !die.modifiers.includes('dropped'))
|
|
150
|
+
.reduce((sum, die) => sum + die.result, 0);
|
|
151
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reroll modifier implementations.
|
|
3
|
+
*
|
|
4
|
+
* Recursive (`r`): re-roll matching dice until the condition no longer holds,
|
|
5
|
+
* or the per-die iteration limit is reached.
|
|
6
|
+
* Reroll-once (`ro`): re-roll matching dice exactly once, keeping the second
|
|
7
|
+
* result regardless of whether it matches.
|
|
8
|
+
*
|
|
9
|
+
* @module evaluator/modifiers/reroll
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { RNG } from '../../rng/types.js';
|
|
13
|
+
import type { CompareOp, DieResult } from '../../types.js';
|
|
14
|
+
import { EvaluatorError, type EvalEnv } from '../evaluator.js';
|
|
15
|
+
import { matchesCondition } from './compare.js';
|
|
16
|
+
|
|
17
|
+
/** Default maximum reroll iterations per die. */
|
|
18
|
+
export const DEFAULT_MAX_REROLL_ITERATIONS = 1_000;
|
|
19
|
+
|
|
20
|
+
export { matchesCondition };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Rolls a replacement die for the given sides, charging it against the global
|
|
24
|
+
* dice limit. Fate dice (sides === 0) re-roll on the {-1, 0, +1} range.
|
|
25
|
+
*/
|
|
26
|
+
function rollReplacement(sides: number, rng: RNG, env: EvalEnv): DieResult {
|
|
27
|
+
if (env.totalDiceRolled + 1 > env.maxDice) {
|
|
28
|
+
throw new EvaluatorError(
|
|
29
|
+
`Total dice count ${env.totalDiceRolled + 1} exceeds limit of ${env.maxDice}`,
|
|
30
|
+
'DICE_LIMIT_EXCEEDED',
|
|
31
|
+
'Reroll',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
env.totalDiceRolled += 1;
|
|
35
|
+
|
|
36
|
+
if (sides === 0) {
|
|
37
|
+
const result = rng.nextInt(-1, 1);
|
|
38
|
+
return {
|
|
39
|
+
sides: 0,
|
|
40
|
+
result,
|
|
41
|
+
modifiers: [],
|
|
42
|
+
critical: false,
|
|
43
|
+
fumble: false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = rng.nextInt(1, sides);
|
|
48
|
+
return {
|
|
49
|
+
sides,
|
|
50
|
+
result,
|
|
51
|
+
modifiers: [],
|
|
52
|
+
critical: result === sides && sides > 1,
|
|
53
|
+
fumble: result === 1 && sides > 1,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function rerollLimitError(maxIterations: number): EvaluatorError {
|
|
58
|
+
return new EvaluatorError(
|
|
59
|
+
`Reroll iteration limit of ${maxIterations} exceeded`,
|
|
60
|
+
'REROLL_LIMIT_EXCEEDED',
|
|
61
|
+
'Reroll',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* True for dice eligible to start rerolling. Dropped dice (from a preceding
|
|
67
|
+
* keep/drop modifier) are left alone.
|
|
68
|
+
*/
|
|
69
|
+
function canReroll(die: DieResult): boolean {
|
|
70
|
+
return !die.modifiers.includes('dropped');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Returns the die's modifiers with any "slot" flags removed. Slot flags
|
|
75
|
+
* (`kept`, `dropped`, `rerolled`) are controlled by the surrounding logic
|
|
76
|
+
* and should be reassigned each pass.
|
|
77
|
+
*/
|
|
78
|
+
function stripSlotFlags(modifiers: DieResult['modifiers']): DieResult['modifiers'] {
|
|
79
|
+
return modifiers.filter((m) => m !== 'kept' && m !== 'dropped' && m !== 'rerolled');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Applies recursive reroll: re-roll each matching die until it no longer
|
|
84
|
+
* matches or the per-die iteration limit is reached. Intermediate dice are
|
|
85
|
+
* appended to the output pool with `['rerolled', 'dropped']` so they:
|
|
86
|
+
* 1. Render as strikethrough (via `renderDice`'s `'dropped'` check).
|
|
87
|
+
* 2. Are excluded from `sumKeptDice`.
|
|
88
|
+
* 3. Are ignored by subsequent keep/drop modifiers.
|
|
89
|
+
*/
|
|
90
|
+
export function applyRecursiveReroll(
|
|
91
|
+
pool: DieResult[],
|
|
92
|
+
operator: CompareOp,
|
|
93
|
+
value: number,
|
|
94
|
+
rng: RNG,
|
|
95
|
+
env: EvalEnv,
|
|
96
|
+
): DieResult[] {
|
|
97
|
+
const result: DieResult[] = [];
|
|
98
|
+
|
|
99
|
+
for (const original of pool) {
|
|
100
|
+
if (!canReroll(original)) {
|
|
101
|
+
result.push(original);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let current = original;
|
|
106
|
+
let iterations = 0;
|
|
107
|
+
|
|
108
|
+
// ? Mutate flags in place — the same DieResult objects are shared with
|
|
109
|
+
// the RollPart tree, and both views must reflect reroll state.
|
|
110
|
+
while (matchesCondition(current.result, operator, value)) {
|
|
111
|
+
if (iterations >= env.maxRerollIterations) {
|
|
112
|
+
throw rerollLimitError(env.maxRerollIterations);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
current.modifiers = [...stripSlotFlags(current.modifiers), 'rerolled', 'dropped'];
|
|
116
|
+
result.push(current);
|
|
117
|
+
|
|
118
|
+
current = rollReplacement(current.sides, rng, env);
|
|
119
|
+
iterations += 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
current.modifiers = [...stripSlotFlags(current.modifiers), 'kept'];
|
|
123
|
+
result.push(current);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Applies reroll-once: re-roll each matching die exactly once, keeping the
|
|
131
|
+
* second result regardless of whether it matches. Non-matching dice pass
|
|
132
|
+
* through with the `'kept'` slot flag.
|
|
133
|
+
*/
|
|
134
|
+
export function applyRerollOnce(
|
|
135
|
+
pool: DieResult[],
|
|
136
|
+
operator: CompareOp,
|
|
137
|
+
value: number,
|
|
138
|
+
rng: RNG,
|
|
139
|
+
env: EvalEnv,
|
|
140
|
+
): DieResult[] {
|
|
141
|
+
const result: DieResult[] = [];
|
|
142
|
+
|
|
143
|
+
for (const original of pool) {
|
|
144
|
+
if (!canReroll(original)) {
|
|
145
|
+
result.push(original);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ? Mutate flags in place — see `applyRecursiveReroll`.
|
|
150
|
+
if (!matchesCondition(original.result, operator, value)) {
|
|
151
|
+
original.modifiers = [...stripSlotFlags(original.modifiers), 'kept'];
|
|
152
|
+
result.push(original);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
original.modifiers = [...stripSlotFlags(original.modifiers), 'rerolled', 'dropped'];
|
|
157
|
+
result.push(original);
|
|
158
|
+
|
|
159
|
+
const replacement = rollReplacement(original.sides, rng, env);
|
|
160
|
+
replacement.modifiers = [...stripSlotFlags(replacement.modifiers), 'kept'];
|
|
161
|
+
result.push(replacement);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sort modifier implementation.
|
|
3
|
+
*
|
|
4
|
+
* Sort is purely cosmetic — it reorders the dice produced by its target in
|
|
5
|
+
* ascending or descending order of `result` without touching any flag
|
|
6
|
+
* (`kept`, `dropped`, `critical`, `fumble`, `rerolled`, `meta`) or the total.
|
|
7
|
+
* Dropped dice stay in the pool and sort alongside kept dice so readers can
|
|
8
|
+
* see where the dropped value landed in the ordered sequence.
|
|
9
|
+
*
|
|
10
|
+
* @module evaluator/modifiers/sort
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { DieResult } from '../../types.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns a sorted copy of `dice` in the given order, preserving every die's
|
|
17
|
+
* original flags and metadata. Comparison is on `.result` — the raw face
|
|
18
|
+
* value is what the user sees in rendered output, so ordering by raw value
|
|
19
|
+
* matches the visual intent regardless of any `kept`/`dropped` flagging.
|
|
20
|
+
*
|
|
21
|
+
* Relies on `Array.prototype.sort` being stable — equal-valued dice retain
|
|
22
|
+
* their original insertion order.
|
|
23
|
+
*/
|
|
24
|
+
export function sortDice(dice: DieResult[], order: 'ascending' | 'descending'): DieResult[] {
|
|
25
|
+
const cmp =
|
|
26
|
+
order === 'ascending'
|
|
27
|
+
? (a: DieResult, b: DieResult) => a.result - b.result
|
|
28
|
+
: (a: DieResult, b: DieResult) => b.result - a.result;
|
|
29
|
+
return [...dice].sort(cmp);
|
|
30
|
+
}
|