roll-parser 2.3.2 → 3.0.0-alpha.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/README.md +47 -178
- 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 +1775 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/evaluator/evaluator.d.ts +65 -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/explode.d.ts +44 -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/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 +1723 -0
- package/dist/index.mjs +1724 -0
- package/dist/lexer/lexer.d.ts +75 -0
- package/dist/lexer/lexer.d.ts.map +1 -0
- package/dist/lexer/tokens.d.ts +87 -0
- package/dist/lexer/tokens.d.ts.map +1 -0
- package/dist/parser/ast.d.ts +183 -0
- package/dist/parser/ast.d.ts.map +1 -0
- package/dist/parser/parser.d.ts +102 -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 +33 -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 +47 -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 +38 -0
- package/dist/testing.mjs +39 -0
- package/dist/types.d.ts +105 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +62 -43
- package/src/cli/args.ts +72 -0
- package/src/cli/format.ts +45 -0
- package/src/cli/index.ts +75 -0
- package/src/errors.ts +78 -0
- package/src/evaluator/evaluator.ts +837 -0
- package/src/evaluator/index.ts +14 -0
- package/src/evaluator/modifiers/compare.ts +29 -0
- package/src/evaluator/modifiers/explode.ts +215 -0
- package/src/evaluator/modifiers/keep-drop.ts +151 -0
- package/src/evaluator/modifiers/reroll.ts +172 -0
- package/src/evaluator/modifiers/success-count.ts +59 -0
- package/src/index.ts +72 -0
- package/src/lexer/lexer.ts +281 -0
- package/src/lexer/tokens.ts +147 -0
- package/src/parser/ast.ts +261 -0
- package/src/parser/parser.ts +716 -0
- package/src/rng/index.ts +8 -0
- package/src/rng/mock.ts +65 -0
- package/src/rng/seeded.ts +127 -0
- package/src/rng/types.ts +25 -0
- package/src/roll.ts +66 -0
- package/src/testing.ts +16 -0
- package/src/types.ts +112 -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';
|
|
8
|
+
export {
|
|
9
|
+
applyDropHighest,
|
|
10
|
+
applyDropLowest,
|
|
11
|
+
applyKeepHighest,
|
|
12
|
+
applyKeepLowest,
|
|
13
|
+
sumKeptDice,
|
|
14
|
+
} from './modifiers/keep-drop';
|
|
@@ -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';
|
|
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,215 @@
|
|
|
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';
|
|
12
|
+
import type { CompareOp } from '../../types';
|
|
13
|
+
import type { DieResult } from '../../types';
|
|
14
|
+
import { EvaluatorError, type EvalEnv } from '../evaluator';
|
|
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
|
+
* Fate dice (sides = 0) are skipped defensively — `rng.nextInt(1, 0)` is
|
|
79
|
+
* invalid. Fate + explode is out of scope for the current feature set.
|
|
80
|
+
*/
|
|
81
|
+
function canExplode(die: DieResult): boolean {
|
|
82
|
+
if (die.modifiers.includes('dropped')) return false;
|
|
83
|
+
if (die.sides < 1) return false;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Standard explode: append each new die to the pool. The original die keeps
|
|
89
|
+
* its modifiers untouched; new dice carry `['exploded', 'kept']`.
|
|
90
|
+
*/
|
|
91
|
+
export function applyStandardExplode(
|
|
92
|
+
pool: DieResult[],
|
|
93
|
+
shouldExplode: ShouldExplode,
|
|
94
|
+
rng: RNG,
|
|
95
|
+
env: EvalEnv,
|
|
96
|
+
): DieResult[] {
|
|
97
|
+
const result: DieResult[] = [];
|
|
98
|
+
|
|
99
|
+
for (const original of pool) {
|
|
100
|
+
result.push(original);
|
|
101
|
+
if (!canExplode(original)) continue;
|
|
102
|
+
|
|
103
|
+
const sides = original.sides;
|
|
104
|
+
let last = original.result;
|
|
105
|
+
let iterations = 0;
|
|
106
|
+
|
|
107
|
+
while (shouldExplode(last, sides)) {
|
|
108
|
+
if (iterations >= env.maxExplodeIterations) {
|
|
109
|
+
throw explodeLimitError(env.maxExplodeIterations);
|
|
110
|
+
}
|
|
111
|
+
const next = rollExplosion(sides, rng, env);
|
|
112
|
+
result.push({
|
|
113
|
+
sides,
|
|
114
|
+
result: next,
|
|
115
|
+
modifiers: ['exploded', 'kept'],
|
|
116
|
+
critical: next === sides && sides > 1,
|
|
117
|
+
fumble: next === 1,
|
|
118
|
+
});
|
|
119
|
+
last = next;
|
|
120
|
+
iterations += 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Compound explode: pool length stays the same. Each original die's `result`
|
|
129
|
+
* accumulates every explosion roll, and gains the `'exploded'` modifier once
|
|
130
|
+
* it actually exploded at least once.
|
|
131
|
+
*/
|
|
132
|
+
export function applyCompoundExplode(
|
|
133
|
+
pool: DieResult[],
|
|
134
|
+
shouldExplode: ShouldExplode,
|
|
135
|
+
rng: RNG,
|
|
136
|
+
env: EvalEnv,
|
|
137
|
+
): DieResult[] {
|
|
138
|
+
return pool.map((original) => {
|
|
139
|
+
if (!canExplode(original)) return original;
|
|
140
|
+
|
|
141
|
+
const sides = original.sides;
|
|
142
|
+
let accumulated = original.result;
|
|
143
|
+
let last = original.result;
|
|
144
|
+
let iterations = 0;
|
|
145
|
+
let exploded = false;
|
|
146
|
+
|
|
147
|
+
while (shouldExplode(last, sides)) {
|
|
148
|
+
if (iterations >= env.maxExplodeIterations) {
|
|
149
|
+
throw explodeLimitError(env.maxExplodeIterations);
|
|
150
|
+
}
|
|
151
|
+
const next = rollExplosion(sides, rng, env);
|
|
152
|
+
accumulated += next;
|
|
153
|
+
last = next;
|
|
154
|
+
exploded = true;
|
|
155
|
+
iterations += 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!exploded) return original;
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
sides,
|
|
162
|
+
result: accumulated,
|
|
163
|
+
modifiers: original.modifiers.includes('exploded')
|
|
164
|
+
? original.modifiers
|
|
165
|
+
: [...original.modifiers, 'exploded'],
|
|
166
|
+
// ? `critical` and `fumble` refer to the original triggering roll, but
|
|
167
|
+
// after compounding the `result` is a sum. Mark critical if the
|
|
168
|
+
// original roll was max; fumble stays as-is (can't accumulate down).
|
|
169
|
+
critical: original.critical,
|
|
170
|
+
fumble: original.fumble,
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Penetrating explode: like standard, but each appended die stores
|
|
177
|
+
* `result = rawRoll - 1`. The explosion predicate runs on the RAW roll
|
|
178
|
+
* (pre-decrement), matching rpg-dice-roller convention.
|
|
179
|
+
*/
|
|
180
|
+
export function applyPenetratingExplode(
|
|
181
|
+
pool: DieResult[],
|
|
182
|
+
shouldExplode: ShouldExplode,
|
|
183
|
+
rng: RNG,
|
|
184
|
+
env: EvalEnv,
|
|
185
|
+
): DieResult[] {
|
|
186
|
+
const result: DieResult[] = [];
|
|
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;
|
|
215
|
+
}
|
|
@@ -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';
|
|
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,172 @@
|
|
|
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';
|
|
13
|
+
import type { CompareOp, DieResult } from '../../types';
|
|
14
|
+
import { EvaluatorError, type EvalEnv } from '../evaluator';
|
|
15
|
+
import { matchesCondition } from './compare';
|
|
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,
|
|
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
|
+
while (matchesCondition(current.result, operator, value)) {
|
|
109
|
+
if (iterations >= env.maxRerollIterations) {
|
|
110
|
+
throw rerollLimitError(env.maxRerollIterations);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
result.push({
|
|
114
|
+
...current,
|
|
115
|
+
modifiers: [...stripSlotFlags(current.modifiers), 'rerolled', 'dropped'],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
current = rollReplacement(current.sides, rng, env);
|
|
119
|
+
iterations += 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
result.push({
|
|
123
|
+
...current,
|
|
124
|
+
modifiers: [...stripSlotFlags(current.modifiers), 'kept'],
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Applies reroll-once: re-roll each matching die exactly once, keeping the
|
|
133
|
+
* second result regardless of whether it matches. Non-matching dice pass
|
|
134
|
+
* through with the `'kept'` slot flag.
|
|
135
|
+
*/
|
|
136
|
+
export function applyRerollOnce(
|
|
137
|
+
pool: DieResult[],
|
|
138
|
+
operator: CompareOp,
|
|
139
|
+
value: number,
|
|
140
|
+
rng: RNG,
|
|
141
|
+
env: EvalEnv,
|
|
142
|
+
): DieResult[] {
|
|
143
|
+
const result: DieResult[] = [];
|
|
144
|
+
|
|
145
|
+
for (const original of pool) {
|
|
146
|
+
if (!canReroll(original)) {
|
|
147
|
+
result.push(original);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!matchesCondition(original.result, operator, value)) {
|
|
152
|
+
result.push({
|
|
153
|
+
...original,
|
|
154
|
+
modifiers: [...stripSlotFlags(original.modifiers), 'kept'],
|
|
155
|
+
});
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
result.push({
|
|
160
|
+
...original,
|
|
161
|
+
modifiers: [...stripSlotFlags(original.modifiers), 'rerolled', 'dropped'],
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const replacement = rollReplacement(original.sides, rng, env);
|
|
165
|
+
result.push({
|
|
166
|
+
...replacement,
|
|
167
|
+
modifiers: [...stripSlotFlags(replacement.modifiers), 'kept'],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Success counting modifier.
|
|
3
|
+
*
|
|
4
|
+
* Transforms a dice pool into a signed success count: each die matching the
|
|
5
|
+
* success `threshold` contributes +1, each die matching the optional
|
|
6
|
+
* `failThreshold` contributes -1. When a single die matches both thresholds,
|
|
7
|
+
* success wins (checked first).
|
|
8
|
+
*
|
|
9
|
+
* Dropped dice (from a preceding keep/drop or intermediate reroll) are
|
|
10
|
+
* excluded from counting and are never tagged.
|
|
11
|
+
*
|
|
12
|
+
* Mutates the input pool in place to add `'success'` / `'failure'` modifier
|
|
13
|
+
* flags — mirrors the mutation pattern of explode and reroll modifiers.
|
|
14
|
+
*
|
|
15
|
+
* @module evaluator/modifiers/success-count
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { CompareOp, DieResult } from '../../types';
|
|
19
|
+
import { matchesCondition } from './compare';
|
|
20
|
+
|
|
21
|
+
export type ResolvedThreshold = {
|
|
22
|
+
operator: CompareOp;
|
|
23
|
+
value: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type SuccessCountResult = {
|
|
27
|
+
total: number;
|
|
28
|
+
successes: number;
|
|
29
|
+
failures: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function countSuccesses(
|
|
33
|
+
dice: DieResult[],
|
|
34
|
+
threshold: ResolvedThreshold,
|
|
35
|
+
failThreshold?: ResolvedThreshold,
|
|
36
|
+
): SuccessCountResult {
|
|
37
|
+
let successes = 0;
|
|
38
|
+
let failures = 0;
|
|
39
|
+
|
|
40
|
+
for (const die of dice) {
|
|
41
|
+
if (die.modifiers.includes('dropped')) continue;
|
|
42
|
+
|
|
43
|
+
if (matchesCondition(die.result, threshold.operator, threshold.value)) {
|
|
44
|
+
die.modifiers.push('success');
|
|
45
|
+
successes += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (
|
|
50
|
+
failThreshold != null &&
|
|
51
|
+
matchesCondition(die.result, failThreshold.operator, failThreshold.value)
|
|
52
|
+
) {
|
|
53
|
+
die.modifiers.push('failure');
|
|
54
|
+
failures += 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { total: successes - failures, successes, failures };
|
|
59
|
+
}
|