roll-parser 3.0.0-beta.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +142 -9
- package/MIGRATION.md +219 -0
- package/README.md +1026 -154
- package/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +81 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/format.d.ts +15 -3
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +18 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +14 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/main.d.ts +36 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +83 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/errors.d.ts +332 -17
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +141 -0
- package/dist/errors.js.map +1 -0
- package/dist/evaluator/die.d.ts +27 -0
- package/dist/evaluator/die.d.ts.map +1 -0
- package/dist/evaluator/die.js +19 -0
- package/dist/evaluator/die.js.map +1 -0
- package/dist/evaluator/env.d.ts +89 -0
- package/dist/evaluator/env.d.ts.map +1 -0
- package/dist/evaluator/env.js +11 -0
- package/dist/evaluator/env.js.map +1 -0
- package/dist/evaluator/evaluator.d.ts +41 -75
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +914 -0
- package/dist/evaluator/evaluator.js.map +1 -0
- package/dist/evaluator/modifiers/compare.js +15 -0
- package/dist/evaluator/modifiers/compare.js.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts +57 -5
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.js +42 -0
- package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
- package/dist/evaluator/modifiers/die-bound.d.ts +29 -0
- package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
- package/dist/evaluator/modifiers/die-bound.js +17 -0
- package/dist/evaluator/modifiers/die-bound.js.map +1 -0
- package/dist/evaluator/modifiers/explode.d.ts +13 -5
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/explode.js +105 -0
- package/dist/evaluator/modifiers/explode.js.map +1 -0
- package/dist/evaluator/modifiers/flags.d.ts +47 -0
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
- package/dist/evaluator/modifiers/flags.js +25 -0
- package/dist/evaluator/modifiers/flags.js.map +1 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts +15 -29
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.js +82 -0
- package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
- package/dist/evaluator/modifiers/reroll.d.ts +12 -4
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
- package/dist/evaluator/modifiers/reroll.js +68 -0
- package/dist/evaluator/modifiers/reroll.js.map +1 -0
- package/dist/evaluator/modifiers/sort.d.ts +5 -1
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
- package/dist/evaluator/modifiers/sort.js +13 -0
- package/dist/evaluator/modifiers/sort.js.map +1 -0
- package/dist/evaluator/modifiers/success-count.d.ts +6 -7
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +25 -0
- package/dist/evaluator/modifiers/success-count.js.map +1 -0
- package/dist/index.d.ts +33 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2560
- package/dist/index.js.map +1 -26
- package/dist/lexer/lexer.d.ts +50 -5
- package/dist/lexer/lexer.d.ts.map +1 -1
- package/dist/lexer/lexer.js +260 -0
- package/dist/lexer/lexer.js.map +1 -0
- package/dist/lexer/tokens.d.ts +31 -6
- package/dist/lexer/tokens.d.ts.map +1 -1
- package/dist/lexer/tokens.js +42 -0
- package/dist/lexer/tokens.js.map +1 -0
- package/dist/parser/ast.d.ts +324 -165
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/ast.js +52 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/guards.d.ts +106 -0
- package/dist/parser/guards.d.ts.map +1 -0
- package/dist/parser/guards.js +121 -0
- package/dist/parser/guards.js.map +1 -0
- package/dist/parser/parser.d.ts +118 -14
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +751 -0
- package/dist/parser/parser.js.map +1 -0
- package/dist/render.d.ts +95 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +227 -0
- package/dist/render.js.map +1 -0
- package/dist/rng/mock.d.ts +73 -12
- package/dist/rng/mock.d.ts.map +1 -1
- package/dist/rng/mock.js +30 -0
- package/dist/rng/mock.js.map +1 -0
- package/dist/rng/seeded.d.ts +141 -9
- package/dist/rng/seeded.d.ts.map +1 -1
- package/dist/rng/seeded.js +138 -0
- package/dist/rng/seeded.js.map +1 -0
- package/dist/rng/types.d.ts +57 -0
- package/dist/rng/types.d.ts.map +1 -1
- package/dist/rng/types.js +2 -0
- package/dist/rng/types.js.map +1 -0
- package/dist/roll.d.ts +58 -28
- package/dist/roll.d.ts.map +1 -1
- package/dist/roll.js +8 -0
- package/dist/roll.js.map +1 -0
- package/dist/testing.d.ts +5 -4
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +2 -41
- package/dist/testing.js.map +1 -11
- package/dist/types.d.ts +349 -47
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +93 -34
- package/src/cli/args.ts +66 -10
- package/src/cli/format.ts +37 -26
- package/src/cli/index.ts +27 -84
- package/src/cli/main.ts +129 -0
- package/src/errors.ts +480 -27
- package/src/evaluator/die.ts +51 -0
- package/src/evaluator/env.ts +105 -0
- package/src/evaluator/evaluator.ts +693 -434
- package/src/evaluator/modifiers/crit-threshold.ts +96 -14
- package/src/evaluator/modifiers/die-bound.ts +48 -0
- package/src/evaluator/modifiers/explode.ts +70 -62
- package/src/evaluator/modifiers/flags.ts +78 -0
- package/src/evaluator/modifiers/keep-drop.ts +129 -127
- package/src/evaluator/modifiers/reroll.ts +44 -56
- package/src/evaluator/modifiers/sort.ts +21 -2
- package/src/evaluator/modifiers/success-count.ts +24 -12
- package/src/index.ts +56 -35
- package/src/lexer/lexer.ts +107 -34
- package/src/lexer/tokens.ts +31 -6
- package/src/parser/ast.ts +333 -346
- package/src/parser/guards.ts +248 -0
- package/src/parser/parser.ts +419 -242
- package/src/render.ts +392 -0
- package/src/rng/mock.ts +74 -13
- package/src/rng/seeded.ts +299 -64
- package/src/rng/types.ts +57 -0
- package/src/roll.ts +64 -47
- package/src/testing.ts +5 -9
- package/src/types.ts +353 -46
- package/src/version.ts +2 -0
- package/dist/cli.js +0 -2608
- package/dist/cli.js.map +0 -28
- package/dist/evaluator/index.d.ts +0 -8
- package/dist/evaluator/index.d.ts.map +0 -1
- package/dist/rng/index.d.ts +0 -8
- package/dist/rng/index.d.ts.map +0 -1
- package/src/evaluator/index.ts +0 -14
- package/src/rng/index.ts +0 -8
|
@@ -4,148 +4,150 @@
|
|
|
4
4
|
* @module evaluator/modifiers/keep-drop
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { DieResult } from '../../types.js';
|
|
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
|
+
hasVersusDc: boolean,
|
|
33
|
+
): void {
|
|
34
|
+
if (count === 1) {
|
|
35
|
+
markSingleExtreme(dice, kind, selector, droppedMask, hasVersusDc);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const eligible: EligibleDie[] = [];
|
|
40
|
+
|
|
41
|
+
for (let index = 0; index < dice.length; index++) {
|
|
42
|
+
const die = dice[index];
|
|
43
|
+
if (die == null) continue;
|
|
44
|
+
if (hasVersusDc && isVersusDc(die)) continue;
|
|
45
|
+
|
|
46
|
+
if (die.modifiers.includes('dropped')) {
|
|
47
|
+
droppedMask[index] = 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
eligible.push({ result: die.result, index });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isKeep = kind === 'keep';
|
|
55
|
+
|
|
56
|
+
// Keep-everything: a keep covering the whole eligible pool, or a zero drop.
|
|
57
|
+
if (isKeep ? count >= eligible.length : count <= 0) return;
|
|
58
|
+
|
|
59
|
+
// Drop-everything: a zero keep, or a drop covering the whole eligible pool.
|
|
60
|
+
if (isKeep ? count <= 0 : count >= eligible.length) {
|
|
61
|
+
for (const item of eligible) {
|
|
62
|
+
droppedMask[item.index] = 1;
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Stable sort — ties resolve by original pool order.
|
|
68
|
+
eligible.sort(
|
|
69
|
+
selector === 'highest' ? (a, b) => b.result - a.result : (a, b) => a.result - b.result,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// The sort puts the acted-on dice first: `keep` drops everything past
|
|
73
|
+
// `count`, `drop` drops the selection itself.
|
|
74
|
+
const start = isKeep ? count : 0;
|
|
75
|
+
const end = isKeep ? eligible.length : count;
|
|
76
|
+
|
|
77
|
+
for (let i = start; i < end; i++) {
|
|
78
|
+
const item = eligible[i];
|
|
79
|
+
if (item != null) droppedMask[item.index] = 1;
|
|
80
|
+
}
|
|
123
81
|
}
|
|
124
82
|
|
|
125
83
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
84
|
+
* `count === 1` fast path: a single linear scan replaces the wrapper array
|
|
85
|
+
* and comparator sort — `2d20kh1` (advantage) and `4d6dl1` are the most
|
|
86
|
+
* common notations. Strict comparison preserves the stable sort's
|
|
87
|
+
* first-occurrence tie-break, and bits are only ever set, never cleared, so
|
|
88
|
+
* a shared mask keeps every previous spec's drops.
|
|
128
89
|
*/
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
90
|
+
function markSingleExtreme(
|
|
91
|
+
dice: DieResult[],
|
|
92
|
+
kind: KeepDropSpec['kind'],
|
|
93
|
+
selector: KeepDropSpec['selector'],
|
|
94
|
+
droppedMask: Uint8Array,
|
|
95
|
+
hasVersusDc: boolean,
|
|
96
|
+
): void {
|
|
97
|
+
const isKeep = kind === 'keep';
|
|
98
|
+
const wantHighest = selector === 'highest';
|
|
99
|
+
|
|
100
|
+
let extremeIndex = -1;
|
|
101
|
+
let extremeResult = 0;
|
|
102
|
+
|
|
103
|
+
for (let index = 0; index < dice.length; index++) {
|
|
104
|
+
const die = dice[index];
|
|
105
|
+
if (die == null) continue;
|
|
106
|
+
if (hasVersusDc && isVersusDc(die)) continue;
|
|
107
|
+
|
|
108
|
+
if (die.modifiers.includes('dropped')) {
|
|
109
|
+
droppedMask[index] = 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const { result } = die;
|
|
114
|
+
|
|
115
|
+
if (extremeIndex === -1) {
|
|
116
|
+
extremeIndex = index;
|
|
117
|
+
extremeResult = result;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (wantHighest ? result > extremeResult : result < extremeResult) {
|
|
122
|
+
// A keep drops the dethroned extreme; a drop keeps everything else.
|
|
123
|
+
if (isKeep) droppedMask[extremeIndex] = 1;
|
|
124
|
+
extremeIndex = index;
|
|
125
|
+
extremeResult = result;
|
|
126
|
+
} else if (isKeep) {
|
|
127
|
+
droppedMask[index] = 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Keeping 1 of ≤1 eligible dice drops nothing; dropping 1 of ≥1 drops the
|
|
132
|
+
// extreme — both match the general path's whole-pool guards.
|
|
133
|
+
if (!isKeep && extremeIndex !== -1) droppedMask[extremeIndex] = 1;
|
|
139
134
|
}
|
|
140
135
|
|
|
141
136
|
/**
|
|
142
137
|
* Calculates total from dice, excluding dropped dice.
|
|
143
138
|
*
|
|
144
139
|
* @param dice - Array of die results
|
|
140
|
+
* @param hasVersusDc - Shared env flag; skips the DC exclusion when no `vs` has
|
|
141
|
+
* tagged anything
|
|
145
142
|
* @returns Sum of non-dropped dice
|
|
146
143
|
*/
|
|
147
|
-
export function sumKeptDice(dice: DieResult[]): number {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
144
|
+
export function sumKeptDice(dice: DieResult[], hasVersusDc: boolean): number {
|
|
145
|
+
let total = 0;
|
|
146
|
+
|
|
147
|
+
for (const die of dice) {
|
|
148
|
+
if (hasVersusDc && isVersusDc(die)) continue;
|
|
149
|
+
if (!die.modifiers.includes('dropped')) total += die.result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return total;
|
|
151
153
|
}
|
|
@@ -9,49 +9,45 @@
|
|
|
9
9
|
* @module evaluator/modifiers/reroll
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { EvaluatorError } from '../../errors.js';
|
|
12
13
|
import type { RNG } from '../../rng/types.js';
|
|
13
14
|
import type { CompareOp, DieResult } from '../../types.js';
|
|
14
|
-
import {
|
|
15
|
+
import { createDieResult, createFateDieResult } from '../die.js';
|
|
16
|
+
import { chargeDie, type EvalEnv } from '../env.js';
|
|
15
17
|
import { matchesCondition } from './compare.js';
|
|
18
|
+
import { inheritCritRule } from './crit-threshold.js';
|
|
19
|
+
import { isVersusDc, REROLL_SLOT_FLAGS, rewriteFlags } from './flags.js';
|
|
16
20
|
|
|
17
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Default value of `EvaluationOptions.maxRerollIterations`: how many times a
|
|
23
|
+
* single die may be re-rolled by `r` before `REROLL_LIMIT_EXCEEDED` is
|
|
24
|
+
* thrown.
|
|
25
|
+
*
|
|
26
|
+
* Per die, not per pool — it bounds notations like `1d1r<2`, whose reroll
|
|
27
|
+
* condition can never stop matching. `ro` re-rolls exactly once and is
|
|
28
|
+
* unaffected.
|
|
29
|
+
*
|
|
30
|
+
* @category Limits
|
|
31
|
+
*/
|
|
18
32
|
export const DEFAULT_MAX_REROLL_ITERATIONS = 1_000;
|
|
19
33
|
|
|
20
|
-
export { matchesCondition };
|
|
21
|
-
|
|
22
34
|
/**
|
|
23
|
-
* Rolls a replacement
|
|
24
|
-
*
|
|
35
|
+
* Rolls a replacement for `parent`, charging it against the global dice limit.
|
|
36
|
+
* Fate dice (sides === 0) re-roll on the {-1, 0, +1} range. Any `cs`/`cf`
|
|
37
|
+
* governing `parent` carries over, so a replacement is judged by the rule the
|
|
38
|
+
* user declared rather than the built-in default.
|
|
25
39
|
*/
|
|
26
|
-
function rollReplacement(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
}
|
|
40
|
+
function rollReplacement(parent: DieResult, rng: RNG, env: EvalEnv): DieResult {
|
|
41
|
+
chargeDie(env, 'Reroll');
|
|
42
|
+
|
|
43
|
+
const sides = parent.sides;
|
|
44
|
+
const die =
|
|
45
|
+
sides === 0
|
|
46
|
+
? createFateDieResult(rng.nextInt(-1, 1), [])
|
|
47
|
+
: createDieResult(sides, rng.nextInt(1, sides), []);
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
return
|
|
49
|
-
sides,
|
|
50
|
-
result,
|
|
51
|
-
modifiers: [],
|
|
52
|
-
critical: result === sides && sides > 1,
|
|
53
|
-
fumble: result === 1 && sides > 1,
|
|
54
|
-
};
|
|
49
|
+
inheritCritRule(env, parent, die);
|
|
50
|
+
return die;
|
|
55
51
|
}
|
|
56
52
|
|
|
57
53
|
function rerollLimitError(maxIterations: number): EvaluatorError {
|
|
@@ -66,19 +62,11 @@ function rerollLimitError(maxIterations: number): EvaluatorError {
|
|
|
66
62
|
* True for dice eligible to start rerolling. Dropped dice (from a preceding
|
|
67
63
|
* keep/drop modifier) are left alone.
|
|
68
64
|
*/
|
|
69
|
-
function canReroll(die: DieResult): boolean {
|
|
65
|
+
function canReroll(die: DieResult, hasVersusDc: boolean): boolean {
|
|
66
|
+
if (hasVersusDc && isVersusDc(die)) return false;
|
|
70
67
|
return !die.modifiers.includes('dropped');
|
|
71
68
|
}
|
|
72
69
|
|
|
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
70
|
/**
|
|
83
71
|
* Applies recursive reroll: re-roll each matching die until it no longer
|
|
84
72
|
* matches or the per-die iteration limit is reached. Intermediate dice are
|
|
@@ -97,7 +85,7 @@ export function applyRecursiveReroll(
|
|
|
97
85
|
const result: DieResult[] = [];
|
|
98
86
|
|
|
99
87
|
for (const original of pool) {
|
|
100
|
-
if (!canReroll(original)) {
|
|
88
|
+
if (!canReroll(original, env.hasVersusDc)) {
|
|
101
89
|
result.push(original);
|
|
102
90
|
continue;
|
|
103
91
|
}
|
|
@@ -105,21 +93,21 @@ export function applyRecursiveReroll(
|
|
|
105
93
|
let current = original;
|
|
106
94
|
let iterations = 0;
|
|
107
95
|
|
|
108
|
-
//
|
|
109
|
-
//
|
|
96
|
+
// Mutate flags in place — the same DieResult objects are shared with
|
|
97
|
+
// the RollPart tree, and both views must reflect reroll state.
|
|
110
98
|
while (matchesCondition(current.result, operator, value)) {
|
|
111
99
|
if (iterations >= env.maxRerollIterations) {
|
|
112
100
|
throw rerollLimitError(env.maxRerollIterations);
|
|
113
101
|
}
|
|
114
102
|
|
|
115
|
-
current.modifiers =
|
|
103
|
+
current.modifiers = rewriteFlags(current.modifiers, REROLL_SLOT_FLAGS, 'rerolled', 'dropped');
|
|
116
104
|
result.push(current);
|
|
117
105
|
|
|
118
|
-
current = rollReplacement(current
|
|
106
|
+
current = rollReplacement(current, rng, env);
|
|
119
107
|
iterations += 1;
|
|
120
108
|
}
|
|
121
109
|
|
|
122
|
-
current.modifiers =
|
|
110
|
+
current.modifiers = rewriteFlags(current.modifiers, REROLL_SLOT_FLAGS, 'kept');
|
|
123
111
|
result.push(current);
|
|
124
112
|
}
|
|
125
113
|
|
|
@@ -141,23 +129,23 @@ export function applyRerollOnce(
|
|
|
141
129
|
const result: DieResult[] = [];
|
|
142
130
|
|
|
143
131
|
for (const original of pool) {
|
|
144
|
-
if (!canReroll(original)) {
|
|
132
|
+
if (!canReroll(original, env.hasVersusDc)) {
|
|
145
133
|
result.push(original);
|
|
146
134
|
continue;
|
|
147
135
|
}
|
|
148
136
|
|
|
149
|
-
//
|
|
137
|
+
// Mutate flags in place — see `applyRecursiveReroll`.
|
|
150
138
|
if (!matchesCondition(original.result, operator, value)) {
|
|
151
|
-
original.modifiers =
|
|
139
|
+
original.modifiers = rewriteFlags(original.modifiers, REROLL_SLOT_FLAGS, 'kept');
|
|
152
140
|
result.push(original);
|
|
153
141
|
continue;
|
|
154
142
|
}
|
|
155
143
|
|
|
156
|
-
original.modifiers =
|
|
144
|
+
original.modifiers = rewriteFlags(original.modifiers, REROLL_SLOT_FLAGS, 'rerolled', 'dropped');
|
|
157
145
|
result.push(original);
|
|
158
146
|
|
|
159
|
-
const replacement = rollReplacement(original
|
|
160
|
-
replacement.modifiers =
|
|
147
|
+
const replacement = rollReplacement(original, rng, env);
|
|
148
|
+
replacement.modifiers = rewriteFlags(replacement.modifiers, REROLL_SLOT_FLAGS, 'kept');
|
|
161
149
|
result.push(replacement);
|
|
162
150
|
}
|
|
163
151
|
|
|
@@ -7,10 +7,15 @@
|
|
|
7
7
|
* Dropped dice stay in the pool and sort alongside kept dice so readers can
|
|
8
8
|
* see where the dropped value landed in the ordered sequence.
|
|
9
9
|
*
|
|
10
|
+
* DC dice are the exception: they hold their positions rather than sorting,
|
|
11
|
+
* so a `vs` comparison's DC faces never appear shuffled into the roll-side
|
|
12
|
+
* pool they are not a member of.
|
|
13
|
+
*
|
|
10
14
|
* @module evaluator/modifiers/sort
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import type { DieResult } from '../../types.js';
|
|
18
|
+
import { isVersusDc } from './flags.js';
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* Returns a sorted copy of `dice` in the given order, preserving every die's
|
|
@@ -21,10 +26,24 @@ import type { DieResult } from '../../types.js';
|
|
|
21
26
|
* Relies on `Array.prototype.sort` being stable — equal-valued dice retain
|
|
22
27
|
* their original insertion order.
|
|
23
28
|
*/
|
|
24
|
-
export function sortDice(
|
|
29
|
+
export function sortDice(
|
|
30
|
+
dice: DieResult[],
|
|
31
|
+
order: 'ascending' | 'descending',
|
|
32
|
+
hasVersusDc: boolean,
|
|
33
|
+
): DieResult[] {
|
|
25
34
|
const cmp =
|
|
26
35
|
order === 'ascending'
|
|
27
36
|
? (a: DieResult, b: DieResult) => a.result - b.result
|
|
28
37
|
: (a: DieResult, b: DieResult) => b.result - a.result;
|
|
29
|
-
|
|
38
|
+
|
|
39
|
+
// Scan before allocating: the `filter` this replaced built a throwaway array
|
|
40
|
+
// on every sort to serve a case only a `vs` can produce (#281).
|
|
41
|
+
if (!hasVersusDc || !dice.some(isVersusDc)) return [...dice].sort(cmp);
|
|
42
|
+
|
|
43
|
+
// Sort only the pool members, then lay them back into the slots they came
|
|
44
|
+
// from, leaving every DC die exactly where it was.
|
|
45
|
+
const sortable = dice.filter((die) => !isVersusDc(die));
|
|
46
|
+
sortable.sort(cmp);
|
|
47
|
+
let next = 0;
|
|
48
|
+
return dice.map((die) => (isVersusDc(die) ? die : (sortable[next++] as DieResult)));
|
|
30
49
|
}
|
|
@@ -10,18 +10,17 @@
|
|
|
10
10
|
* excluded from counting and are never tagged.
|
|
11
11
|
*
|
|
12
12
|
* Mutates the input pool in place to add `'success'` / `'failure'` modifier
|
|
13
|
-
* flags — mirrors the mutation pattern of explode and reroll modifiers.
|
|
13
|
+
* flags — mirrors the mutation pattern of explode and reroll modifiers. Each
|
|
14
|
+
* tag is written at most once per die: a group counted after its members
|
|
15
|
+
* (`{4d6>=5}>=1`) runs this pass twice over the same dice, and the parse-time
|
|
16
|
+
* reject that blocks a direct `4d6>=5>=4` does not reach through a group.
|
|
14
17
|
*
|
|
15
18
|
* @module evaluator/modifiers/success-count
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
|
-
import type {
|
|
21
|
+
import type { DieResult, ResolvedComparePoint } from '../../types.js';
|
|
19
22
|
import { matchesCondition } from './compare.js';
|
|
20
|
-
|
|
21
|
-
export type ResolvedThreshold = {
|
|
22
|
-
operator: CompareOp;
|
|
23
|
-
value: number;
|
|
24
|
-
};
|
|
23
|
+
import { isVersusDc } from './flags.js';
|
|
25
24
|
|
|
26
25
|
export type SuccessCountResult = {
|
|
27
26
|
total: number;
|
|
@@ -31,17 +30,30 @@ export type SuccessCountResult = {
|
|
|
31
30
|
|
|
32
31
|
export function countSuccesses(
|
|
33
32
|
dice: DieResult[],
|
|
34
|
-
threshold:
|
|
35
|
-
failThreshold
|
|
33
|
+
threshold: ResolvedComparePoint,
|
|
34
|
+
failThreshold: ResolvedComparePoint | undefined,
|
|
35
|
+
hasVersusDc: boolean,
|
|
36
36
|
): SuccessCountResult {
|
|
37
37
|
let successes = 0;
|
|
38
38
|
let failures = 0;
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
// ! Excluded up front, never inside the loop below. Even short-circuited on a
|
|
41
|
+
// ! false flag, a `hasVersusDc && isVersusDc(die)` guard in this loop costs
|
|
42
|
+
// ! ~9% on `10d10>=6f1` — measured, not assumed (#281). Filtering keeps the
|
|
43
|
+
// ! hot body identical to the pre-exclusion one and pays an allocation only
|
|
44
|
+
// ! on the `vs` path. The dice are the same objects either way, so the
|
|
45
|
+
// ! `'success'` / `'failure'` tags written below still land on the pool.
|
|
46
|
+
const pool = hasVersusDc ? dice.filter((die) => !isVersusDc(die)) : dice;
|
|
47
|
+
|
|
48
|
+
// ! The guards below only stop the *same* tag being written twice. A nested
|
|
49
|
+
// ! count with a different threshold still appends to the first pass's tags,
|
|
50
|
+
// ! so a die can end up both `'success'` and `'failure'`, and the returned
|
|
51
|
+
// ! counts then disagree with the tags in `rolls`.
|
|
52
|
+
for (const die of pool) {
|
|
41
53
|
if (die.modifiers.includes('dropped')) continue;
|
|
42
54
|
|
|
43
55
|
if (matchesCondition(die.result, threshold.operator, threshold.value)) {
|
|
44
|
-
die.modifiers.push('success');
|
|
56
|
+
if (!die.modifiers.includes('success')) die.modifiers.push('success');
|
|
45
57
|
successes += 1;
|
|
46
58
|
continue;
|
|
47
59
|
}
|
|
@@ -50,7 +62,7 @@ export function countSuccesses(
|
|
|
50
62
|
failThreshold != null &&
|
|
51
63
|
matchesCondition(die.result, failThreshold.operator, failThreshold.value)
|
|
52
64
|
) {
|
|
53
|
-
die.modifiers.push('failure');
|
|
65
|
+
if (!die.modifiers.includes('failure')) die.modifiers.push('failure');
|
|
54
66
|
failures += 1;
|
|
55
67
|
}
|
|
56
68
|
}
|