roll-parser 3.3.1 → 3.4.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 +31 -1
- package/README.md +54 -27
- package/dist/cli/args.d.ts +11 -1
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +67 -42
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/format.d.ts +8 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +4 -3
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/main.d.ts +3 -3
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +50 -19
- package/dist/cli/main.js.map +1 -1
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +19 -14
- package/dist/evaluator/evaluator.js.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.js +19 -16
- package/dist/evaluator/modifiers/keep-drop.js.map +1 -1
- package/dist/lexer/lexer.d.ts +5 -0
- package/dist/lexer/lexer.d.ts.map +1 -1
- package/dist/lexer/lexer.js +3 -0
- package/dist/lexer/lexer.js.map +1 -1
- package/dist/parser/parser.d.ts +1 -0
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +23 -20
- package/dist/parser/parser.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -7
- package/src/cli/args.ts +112 -40
- package/src/cli/format.ts +11 -3
- package/src/cli/main.ts +97 -22
- package/src/evaluator/evaluator.ts +34 -19
- package/src/evaluator/modifiers/keep-drop.ts +42 -16
- package/src/lexer/lexer.ts +8 -0
- package/src/parser/parser.ts +36 -32
- package/src/version.ts +1 -1
|
@@ -1598,6 +1598,37 @@ function formatFailCode(operator: CompareOp, value: number): string {
|
|
|
1598
1598
|
return operator === '=' ? `f${value}` : `f${operator}${value}`;
|
|
1599
1599
|
}
|
|
1600
1600
|
|
|
1601
|
+
/**
|
|
1602
|
+
* The units a success count scores: sub-roll subtotals for a multi-sub group,
|
|
1603
|
+
* the target's own dice otherwise.
|
|
1604
|
+
*/
|
|
1605
|
+
// ! Releases every die an inner count tagged, `vs` DC dice included, though
|
|
1606
|
+
// ! `countSuccesses` spares those. The `stripTallyMarkers` pass below reads
|
|
1607
|
+
// ! rendered text and cannot tell a DC die apart, so sparing one here leaves a
|
|
1608
|
+
// ! tag whose `**` is already gone and `renderBreakdown` stops reproducing
|
|
1609
|
+
// ! `rendered`.
|
|
1610
|
+
function clearTallyFlags(rolls: DieResult[]): void {
|
|
1611
|
+
for (const die of rolls) {
|
|
1612
|
+
die.modifiers = stripFlags(die.modifiers, TALLY_FLAGS);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
function countablePool(
|
|
1617
|
+
target: EvalResult,
|
|
1618
|
+
targetCtx: EvalContext,
|
|
1619
|
+
bySubtotal: boolean,
|
|
1620
|
+
): DieResult[] {
|
|
1621
|
+
if (!bySubtotal) return targetCtx.rolls;
|
|
1622
|
+
|
|
1623
|
+
return (target.part as Extract<RollPart, { type: 'group' }>).parts.map((sub) => ({
|
|
1624
|
+
sides: 0,
|
|
1625
|
+
result: sub.total,
|
|
1626
|
+
modifiers: [],
|
|
1627
|
+
critical: false,
|
|
1628
|
+
fumble: false,
|
|
1629
|
+
}));
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1601
1632
|
function evalSuccessCount(
|
|
1602
1633
|
node: SuccessCountNode,
|
|
1603
1634
|
rng: RNG,
|
|
@@ -1657,25 +1688,9 @@ function evalSuccessCount(
|
|
|
1657
1688
|
// `ctx.rolls`. Only a direct group target arrives here — the parser refuses
|
|
1658
1689
|
// every form that would reach the count with the subtotals already gone.
|
|
1659
1690
|
const bySubtotal = node.target.type === 'Group' && node.target.expressions.length >= 2;
|
|
1660
|
-
const pool
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
result: sub.total,
|
|
1664
|
-
modifiers: [],
|
|
1665
|
-
critical: false,
|
|
1666
|
-
fumble: false,
|
|
1667
|
-
}))
|
|
1668
|
-
: targetCtx.rolls;
|
|
1669
|
-
|
|
1670
|
-
// ! Releases every die an inner count tagged, `vs` DC dice included, though
|
|
1671
|
-
// ! `countSuccesses` spares those. The marker strip below reads rendered text
|
|
1672
|
-
// ! and cannot tell a DC die apart, so sparing one here leaves a tag whose
|
|
1673
|
-
// ! `**` is already gone and `renderBreakdown` stops reproducing `rendered`.
|
|
1674
|
-
if (bySubtotal && poolAlreadyCounted) {
|
|
1675
|
-
for (const die of targetCtx.rolls) {
|
|
1676
|
-
die.modifiers = stripFlags(die.modifiers, TALLY_FLAGS);
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1691
|
+
const pool = countablePool(target, targetCtx, bySubtotal);
|
|
1692
|
+
|
|
1693
|
+
if (bySubtotal && poolAlreadyCounted) clearTallyFlags(targetCtx.rolls);
|
|
1679
1694
|
|
|
1680
1695
|
// An empty pool (`0d6>=4`) scores zero of both, so its total is 0 — never
|
|
1681
1696
|
// `target.total`, which would break `total === successes - failures`.
|
|
@@ -10,6 +10,29 @@ import { isVersusDc } from './flags.js';
|
|
|
10
10
|
/** One selectable die: its rolled value and its slot in the original pool. */
|
|
11
11
|
type EligibleDie = { result: number; index: number };
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The die at pool slot `index` when it is eligible for keep/drop selection,
|
|
15
|
+
* `null` otherwise. Marks an ineligible-but-droppable die in `droppedMask` on
|
|
16
|
+
* the way past.
|
|
17
|
+
*/
|
|
18
|
+
function selectOrMarkDropped(
|
|
19
|
+
dice: DieResult[],
|
|
20
|
+
index: number,
|
|
21
|
+
droppedMask: Uint8Array,
|
|
22
|
+
hasVersusDc: boolean,
|
|
23
|
+
): DieResult | null {
|
|
24
|
+
const die = dice[index];
|
|
25
|
+
if (die == null) return null;
|
|
26
|
+
if (hasVersusDc && isVersusDc(die)) return null;
|
|
27
|
+
|
|
28
|
+
if (die.modifiers.includes('dropped')) {
|
|
29
|
+
droppedMask[index] = 1;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return die;
|
|
34
|
+
}
|
|
35
|
+
|
|
13
36
|
/**
|
|
14
37
|
* Records into `droppedMask` every pool slot that `kind` / `selector` /
|
|
15
38
|
* `count` drops.
|
|
@@ -39,14 +62,8 @@ export function markDroppedIndices(
|
|
|
39
62
|
const eligible: EligibleDie[] = [];
|
|
40
63
|
|
|
41
64
|
for (let index = 0; index < dice.length; index++) {
|
|
42
|
-
const die = dice
|
|
65
|
+
const die = selectOrMarkDropped(dice, index, droppedMask, hasVersusDc);
|
|
43
66
|
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
67
|
|
|
51
68
|
eligible.push({ result: die.result, index });
|
|
52
69
|
}
|
|
@@ -64,6 +81,20 @@ export function markDroppedIndices(
|
|
|
64
81
|
return;
|
|
65
82
|
}
|
|
66
83
|
|
|
84
|
+
dropBySelection(eligible, count, isKeep, selector, droppedMask);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Reached only once both whole-pool cases are ruled out, so the selected range
|
|
89
|
+
* is always a strict subset of `eligible`.
|
|
90
|
+
*/
|
|
91
|
+
function dropBySelection(
|
|
92
|
+
eligible: EligibleDie[],
|
|
93
|
+
count: number,
|
|
94
|
+
isKeep: boolean,
|
|
95
|
+
selector: KeepDropSpec['selector'],
|
|
96
|
+
droppedMask: Uint8Array,
|
|
97
|
+
): void {
|
|
67
98
|
// Stable sort — ties resolve by original pool order.
|
|
68
99
|
eligible.sort(
|
|
69
100
|
selector === 'highest' ? (a, b) => b.result - a.result : (a, b) => a.result - b.result,
|
|
@@ -95,20 +126,15 @@ function markSingleExtreme(
|
|
|
95
126
|
hasVersusDc: boolean,
|
|
96
127
|
): void {
|
|
97
128
|
const isKeep = kind === 'keep';
|
|
98
|
-
const
|
|
129
|
+
const isMoreExtreme =
|
|
130
|
+
selector === 'highest' ? (a: number, b: number) => a > b : (a: number, b: number) => a < b;
|
|
99
131
|
|
|
100
132
|
let extremeIndex = -1;
|
|
101
133
|
let extremeResult = 0;
|
|
102
134
|
|
|
103
135
|
for (let index = 0; index < dice.length; index++) {
|
|
104
|
-
const die = dice
|
|
136
|
+
const die = selectOrMarkDropped(dice, index, droppedMask, hasVersusDc);
|
|
105
137
|
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
138
|
|
|
113
139
|
const { result } = die;
|
|
114
140
|
|
|
@@ -118,7 +144,7 @@ function markSingleExtreme(
|
|
|
118
144
|
continue;
|
|
119
145
|
}
|
|
120
146
|
|
|
121
|
-
if (
|
|
147
|
+
if (isMoreExtreme(result, extremeResult)) {
|
|
122
148
|
// A keep drops the dethroned extreme; a drop keeps everything else.
|
|
123
149
|
if (isKeep) droppedMask[extremeIndex] = 1;
|
|
124
150
|
extremeIndex = index;
|
package/src/lexer/lexer.ts
CHANGED
|
@@ -215,6 +215,14 @@ export class Lexer {
|
|
|
215
215
|
return this.scanAt();
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
return this.scanOperator(char, startPos);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Single- and two-character operators. The last resort in `nextToken`'s
|
|
223
|
+
* dispatch, so an unrecognised character can only be a lexer error.
|
|
224
|
+
*/
|
|
225
|
+
private scanOperator(char: string, startPos: number): Token {
|
|
218
226
|
this.advance();
|
|
219
227
|
|
|
220
228
|
switch (char) {
|
package/src/parser/parser.ts
CHANGED
|
@@ -850,6 +850,41 @@ export class Parser {
|
|
|
850
850
|
}
|
|
851
851
|
}
|
|
852
852
|
|
|
853
|
+
// ! A single-sub-roll Group is the flat-pool escape hatch, so `containsDicePool`
|
|
854
|
+
// ! deep-walks the arithmetic that `(1d6+5)kh1` rejects outright — but the flat
|
|
855
|
+
// ! path totals `sumKeptDice`, faces only. `{2d6+3}kh2` and `{2d6-1d4}kh3` are
|
|
856
|
+
// ! exactly the drop that reject exists to prevent.
|
|
857
|
+
private rejectSingleSubGroupTarget(target: ASTNode, token: Token): void {
|
|
858
|
+
const base = unwrapAllTransparent(target);
|
|
859
|
+
if (base.type !== 'Group' || base.expressions.length !== 1) return;
|
|
860
|
+
|
|
861
|
+
const inner = base.expressions[0];
|
|
862
|
+
if (inner == null) return;
|
|
863
|
+
|
|
864
|
+
// Single-sub Groups hide a count from the shallow reject in `parseKeepDrop`,
|
|
865
|
+
// so peel them to any depth and `{4d6>=5}kh1`, `{{4d6>=5}}kh1`, and
|
|
866
|
+
// `(4d6>=5)kh1` all report one code. Peeling only picks which error the
|
|
867
|
+
// caller sees — `sumsToKeptFaces` below refuses a count either way.
|
|
868
|
+
let innermost = inner;
|
|
869
|
+
while (true) {
|
|
870
|
+
const peeled = unwrapGrouped(innermost);
|
|
871
|
+
if (peeled.type !== 'Group' || peeled.expressions.length !== 1) break;
|
|
872
|
+
const next = peeled.expressions[0];
|
|
873
|
+
if (next == null) break;
|
|
874
|
+
innermost = next;
|
|
875
|
+
}
|
|
876
|
+
this.rejectSuccessCountTarget(innermost, token);
|
|
877
|
+
|
|
878
|
+
if (!sumsToKeptFaces(inner)) {
|
|
879
|
+
throw new ParseError(
|
|
880
|
+
`Keep/drop on a single-sub-roll group requires added dice terms only`,
|
|
881
|
+
'INVALID_KEEP_DROP_TARGET',
|
|
882
|
+
token.position,
|
|
883
|
+
token,
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
853
888
|
private parseKeepDrop(target: ASTNode, token: Token): KeepDropNode {
|
|
854
889
|
this.rejectSuccessCountTarget(target, token);
|
|
855
890
|
// ! Keep/drop on a Versus target silently drops `degree`/`natural` metadata,
|
|
@@ -869,38 +904,7 @@ export class Parser {
|
|
|
869
904
|
);
|
|
870
905
|
}
|
|
871
906
|
|
|
872
|
-
|
|
873
|
-
// ! deep-walks the arithmetic that `(1d6+5)kh1` rejects outright — but the flat
|
|
874
|
-
// ! path totals `sumKeptDice`, faces only. `{2d6+3}kh2` and `{2d6-1d4}kh3` are
|
|
875
|
-
// ! exactly the drop that reject exists to prevent.
|
|
876
|
-
const base = unwrapAllTransparent(target);
|
|
877
|
-
if (base.type === 'Group' && base.expressions.length === 1) {
|
|
878
|
-
const inner = base.expressions[0];
|
|
879
|
-
if (inner != null) {
|
|
880
|
-
// Single-sub Groups hide a count from the shallow reject at the top of this
|
|
881
|
-
// method, so peel them to any depth and `{4d6>=5}kh1`, `{{4d6>=5}}kh1`, and
|
|
882
|
-
// `(4d6>=5)kh1` all report one code. Peeling only picks which error the
|
|
883
|
-
// caller sees — `sumsToKeptFaces` below refuses a count either way.
|
|
884
|
-
let innermost = inner;
|
|
885
|
-
while (true) {
|
|
886
|
-
const peeled = unwrapGrouped(innermost);
|
|
887
|
-
if (peeled.type !== 'Group' || peeled.expressions.length !== 1) break;
|
|
888
|
-
const next = peeled.expressions[0];
|
|
889
|
-
if (next == null) break;
|
|
890
|
-
innermost = next;
|
|
891
|
-
}
|
|
892
|
-
this.rejectSuccessCountTarget(innermost, token);
|
|
893
|
-
|
|
894
|
-
if (!sumsToKeptFaces(inner)) {
|
|
895
|
-
throw new ParseError(
|
|
896
|
-
`Keep/drop on a single-sub-roll group requires added dice terms only`,
|
|
897
|
-
'INVALID_KEEP_DROP_TARGET',
|
|
898
|
-
token.position,
|
|
899
|
-
token,
|
|
900
|
-
);
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
}
|
|
907
|
+
this.rejectSingleSubGroupTarget(target, token);
|
|
904
908
|
|
|
905
909
|
const kind =
|
|
906
910
|
token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by `bun run generate:version` from package.json — do not edit.
|
|
2
|
-
export const version = '3.
|
|
2
|
+
export const version = '3.4.0';
|