roll-parser 3.2.2 → 3.3.1
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 +47 -1
- package/README.md +7 -6
- package/dist/evaluator/evaluator.d.ts +15 -3
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +115 -91
- package/dist/evaluator/evaluator.js.map +1 -1
- package/dist/evaluator/modifiers/flags.d.ts +7 -1
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -1
- package/dist/evaluator/modifiers/flags.js +9 -1
- package/dist/evaluator/modifiers/flags.js.map +1 -1
- package/dist/lexer/lexer.d.ts +3 -0
- package/dist/lexer/lexer.d.ts.map +1 -1
- package/dist/lexer/lexer.js +30 -8
- package/dist/lexer/lexer.js.map +1 -1
- package/dist/parser/parser.d.ts +11 -1
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +72 -31
- package/dist/parser/parser.js.map +1 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +2 -1
- package/dist/render.js.map +1 -1
- package/dist/rng/seeded.d.ts.map +1 -1
- package/dist/rng/seeded.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +10 -11
- package/src/evaluator/evaluator.ts +155 -131
- package/src/evaluator/modifiers/flags.ts +18 -2
- package/src/lexer/lexer.ts +73 -13
- package/src/parser/parser.ts +135 -33
- package/src/render.ts +2 -1
- package/src/rng/seeded.ts +4 -0
- package/src/version.ts +1 -1
|
@@ -132,7 +132,8 @@ export { DEFAULT_MAX_EXPLODE_ITERATIONS, DEFAULT_MAX_REROLL_ITERATIONS };
|
|
|
132
132
|
//
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
* Per-branch
|
|
135
|
+
* Per-branch accumulator for what flows up a recursion unchanged or merged. A
|
|
136
|
+
* field a parent reformats instead belongs on {@link EvalResult}.
|
|
136
137
|
*
|
|
137
138
|
* Module-level export, deliberately absent from `src/index.ts` — the package
|
|
138
139
|
* surface never mentions it. See {@link mergeMetaRolls} for why the export
|
|
@@ -140,8 +141,19 @@ export { DEFAULT_MAX_EXPLODE_ITERATIONS, DEFAULT_MAX_REROLL_ITERATIONS };
|
|
|
140
141
|
*/
|
|
141
142
|
export type EvalContext = {
|
|
142
143
|
rolls: DieResult[];
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Set by a parent that discards its child's `rendered`. Every pool modifier
|
|
146
|
+
* does: it rebuilds the breakdown from the target's `expression` plus a
|
|
147
|
+
* `renderDice` pass of its own.
|
|
148
|
+
*
|
|
149
|
+
* ! In a chain each modifier's own context is the next one out's discarded
|
|
150
|
+
* ! target, so a render site that skips the check leaves every level above the
|
|
151
|
+
* ! innermost still rendering into the void.
|
|
152
|
+
*
|
|
153
|
+
* ! Does not propagate into nested contexts — `(4d6)kh1` evaluates into a
|
|
154
|
+
* ! fresh one, so only a modifier's direct dice-pool target is spared.
|
|
155
|
+
*/
|
|
156
|
+
suppressRender?: boolean;
|
|
145
157
|
/**
|
|
146
158
|
* Populated by `evalVersus` with the resolved degree and natural value.
|
|
147
159
|
* `evaluate()` reads this from the top-level ctx to surface `degree` and
|
|
@@ -156,12 +168,28 @@ export type EvalContext = {
|
|
|
156
168
|
};
|
|
157
169
|
|
|
158
170
|
/**
|
|
159
|
-
* Creates an empty per-branch accumulator. Every sub-evaluation runs in a
|
|
160
|
-
*
|
|
161
|
-
* parent's terms.
|
|
171
|
+
* Creates an empty per-branch accumulator. Every sub-evaluation runs in a fresh
|
|
172
|
+
* context so its rolls merge back on the parent's terms.
|
|
162
173
|
*/
|
|
163
174
|
function createContext(): EvalContext {
|
|
164
|
-
return { rolls: []
|
|
175
|
+
return { rolls: [] };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Creates the context a postfix pool modifier evaluates its target into.
|
|
180
|
+
*
|
|
181
|
+
* ! Not for `evalSuccessCount`'s subtotal branch or `evalGroupKeepDrop`. Both
|
|
182
|
+
* ! read their child's `rendered`, and both must keep using `createContext`.
|
|
183
|
+
*/
|
|
184
|
+
function createDiscardedRenderContext(): EvalContext {
|
|
185
|
+
const ctx = createContext();
|
|
186
|
+
ctx.suppressRender = true;
|
|
187
|
+
return ctx;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Every pool render routes through here, including ones that look unreachable. */
|
|
191
|
+
function renderedPool(ctx: EvalContext, prefix: string, dice: DieResult[]): string {
|
|
192
|
+
return ctx.suppressRender ? '' : `${prefix}${renderDice(dice)}`;
|
|
165
193
|
}
|
|
166
194
|
|
|
167
195
|
/**
|
|
@@ -171,13 +199,15 @@ function createContext(): EvalContext {
|
|
|
171
199
|
type KeepDropChainEntry = KeepDropSpec & { code: string };
|
|
172
200
|
|
|
173
201
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* branch
|
|
202
|
+
* What a branch produces for its parent to reformat — TypeScript exhaustiveness
|
|
203
|
+
* guarantees none of the four can be forgotten. `expression` and `rendered` carry
|
|
204
|
+
* this branch's share of the same fields on `RollResult`.
|
|
177
205
|
*/
|
|
178
206
|
type EvalResult = {
|
|
179
207
|
total: number;
|
|
180
208
|
part: RollPart;
|
|
209
|
+
expression: string;
|
|
210
|
+
rendered: string;
|
|
181
211
|
};
|
|
182
212
|
|
|
183
213
|
//
|
|
@@ -338,7 +368,7 @@ function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eval
|
|
|
338
368
|
function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
339
369
|
switch (node.type) {
|
|
340
370
|
case 'Literal':
|
|
341
|
-
return evalLiteral(node
|
|
371
|
+
return evalLiteral(node);
|
|
342
372
|
|
|
343
373
|
case 'Dice':
|
|
344
374
|
return evalDice(node, rng, ctx, env);
|
|
@@ -386,7 +416,7 @@ function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
386
416
|
return evalCritThreshold(node, rng, ctx, env);
|
|
387
417
|
|
|
388
418
|
case 'Variable':
|
|
389
|
-
return evalVariable(node,
|
|
419
|
+
return evalVariable(node, env);
|
|
390
420
|
|
|
391
421
|
default: {
|
|
392
422
|
const exhaustive: never = node;
|
|
@@ -403,11 +433,15 @@ function evalNodeInner(node: ASTNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
403
433
|
// * Leaf nodes
|
|
404
434
|
//
|
|
405
435
|
|
|
406
|
-
function evalLiteral(node: LiteralNode
|
|
436
|
+
function evalLiteral(node: LiteralNode): EvalResult {
|
|
407
437
|
const { value } = node;
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
438
|
+
const text = String(value);
|
|
439
|
+
return {
|
|
440
|
+
total: value,
|
|
441
|
+
part: { type: 'literal', value, total: value, ...partSpan(node) },
|
|
442
|
+
expression: text,
|
|
443
|
+
rendered: text,
|
|
444
|
+
};
|
|
411
445
|
}
|
|
412
446
|
|
|
413
447
|
/**
|
|
@@ -429,7 +463,7 @@ function variableNeedsBraces(name: string): boolean {
|
|
|
429
463
|
* while `rendered` keeps the original `@name` (or `@{name}`) annotated with
|
|
430
464
|
* the resolved value in brackets so readers can attribute the number.
|
|
431
465
|
*/
|
|
432
|
-
function evalVariable(node: VariableNode,
|
|
466
|
+
function evalVariable(node: VariableNode, env: EvalEnv): EvalResult {
|
|
433
467
|
const present = Object.hasOwn(env.context, node.name);
|
|
434
468
|
if (!present) {
|
|
435
469
|
if (env.onMissingVariable === 'throw') {
|
|
@@ -440,11 +474,11 @@ function evalVariable(node: VariableNode, ctx: EvalContext, env: EvalEnv): EvalR
|
|
|
440
474
|
);
|
|
441
475
|
}
|
|
442
476
|
const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
|
|
443
|
-
ctx.expressionParts.push('0');
|
|
444
|
-
ctx.renderedParts.push(`${display}[0]`);
|
|
445
477
|
return {
|
|
446
478
|
total: 0,
|
|
447
479
|
part: { type: 'variable', name: node.name, value: 0, total: 0, ...partSpan(node) },
|
|
480
|
+
expression: '0',
|
|
481
|
+
rendered: `${display}[0]`,
|
|
448
482
|
};
|
|
449
483
|
}
|
|
450
484
|
|
|
@@ -457,11 +491,11 @@ function evalVariable(node: VariableNode, ctx: EvalContext, env: EvalEnv): EvalR
|
|
|
457
491
|
);
|
|
458
492
|
}
|
|
459
493
|
const display = variableNeedsBraces(node.name) ? `@{${node.name}}` : `@${node.name}`;
|
|
460
|
-
ctx.expressionParts.push(String(value));
|
|
461
|
-
ctx.renderedParts.push(`${display}[${value}]`);
|
|
462
494
|
return {
|
|
463
495
|
total: value,
|
|
464
496
|
part: { type: 'variable', name: node.name, value, total: value, ...partSpan(node) },
|
|
497
|
+
expression: String(value),
|
|
498
|
+
rendered: `${display}[${value}]`,
|
|
465
499
|
};
|
|
466
500
|
}
|
|
467
501
|
|
|
@@ -556,7 +590,7 @@ function rollPool(
|
|
|
556
590
|
notation: string,
|
|
557
591
|
rollDie: () => DieResult,
|
|
558
592
|
ctx: EvalContext,
|
|
559
|
-
): { total: number; rolls: DieResult[] } {
|
|
593
|
+
): { total: number; rolls: DieResult[]; expression: string; rendered: string } {
|
|
560
594
|
const dice: DieResult[] = [];
|
|
561
595
|
let total = 0;
|
|
562
596
|
|
|
@@ -568,10 +602,13 @@ function rollPool(
|
|
|
568
602
|
|
|
569
603
|
appendAll(ctx.rolls, dice);
|
|
570
604
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
605
|
+
return {
|
|
606
|
+
total,
|
|
607
|
+
rolls: dice,
|
|
608
|
+
expression: notation,
|
|
609
|
+
// Empty rather than notation-only, so a stray reader fails visibly.
|
|
610
|
+
rendered: ctx.suppressRender ? '' : `${notation}${renderDice(dice)}`,
|
|
611
|
+
};
|
|
575
612
|
}
|
|
576
613
|
|
|
577
614
|
/**
|
|
@@ -604,7 +641,7 @@ function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
|
|
|
604
641
|
|
|
605
642
|
chargeDice(env, count, 'Dice');
|
|
606
643
|
|
|
607
|
-
const { total, rolls } = rollPool(
|
|
644
|
+
const { total, rolls, expression, rendered } = rollPool(
|
|
608
645
|
count,
|
|
609
646
|
`${count}d${sides}`,
|
|
610
647
|
// Fresh `['kept']` per die — `success-count` appends tally flags in
|
|
@@ -613,7 +650,12 @@ function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
|
|
|
613
650
|
ctx,
|
|
614
651
|
);
|
|
615
652
|
|
|
616
|
-
return {
|
|
653
|
+
return {
|
|
654
|
+
total,
|
|
655
|
+
part: { type: 'dice', count, sides, rolls, total, ...partSpan(node) },
|
|
656
|
+
expression,
|
|
657
|
+
rendered,
|
|
658
|
+
};
|
|
617
659
|
}
|
|
618
660
|
|
|
619
661
|
function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
@@ -622,14 +664,19 @@ function evalFateDice(node: FateDiceNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
622
664
|
requireDiceCount(count, 'FateDice');
|
|
623
665
|
chargeDice(env, count, 'FateDice');
|
|
624
666
|
|
|
625
|
-
const { total, rolls } = rollPool(
|
|
667
|
+
const { total, rolls, expression, rendered } = rollPool(
|
|
626
668
|
count,
|
|
627
669
|
`${count}dF`,
|
|
628
670
|
() => createFateDieResult(rng.nextInt(-1, 1), ['kept']),
|
|
629
671
|
ctx,
|
|
630
672
|
);
|
|
631
673
|
|
|
632
|
-
return {
|
|
674
|
+
return {
|
|
675
|
+
total,
|
|
676
|
+
part: { type: 'fateDice', count, rolls, total, ...partSpan(node) },
|
|
677
|
+
expression,
|
|
678
|
+
rendered,
|
|
679
|
+
};
|
|
633
680
|
}
|
|
634
681
|
|
|
635
682
|
//
|
|
@@ -680,9 +727,6 @@ function propagateMetadata(parent: EvalContext, metadata: EvalContext['versusMet
|
|
|
680
727
|
/**
|
|
681
728
|
* Merges a child sub-context back into its parent. Copies `rolls` and
|
|
682
729
|
* delegates `versusMetadata` propagation to `propagateMetadata`.
|
|
683
|
-
*
|
|
684
|
-
* Does not merge `expressionParts` / `renderedParts` — each wrapper formats
|
|
685
|
-
* those with its own operator/function syntax.
|
|
686
730
|
*/
|
|
687
731
|
function mergeContext(parent: EvalContext, child: EvalContext): void {
|
|
688
732
|
appendAll(parent.rolls, child.rolls);
|
|
@@ -703,14 +747,6 @@ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
703
747
|
mergeContext(ctx, leftCtx);
|
|
704
748
|
mergeContext(ctx, rightCtx);
|
|
705
749
|
|
|
706
|
-
const leftExpr = leftCtx.expressionParts.join('');
|
|
707
|
-
const rightExpr = rightCtx.expressionParts.join('');
|
|
708
|
-
const leftRendered = leftCtx.renderedParts.join('');
|
|
709
|
-
const rightRendered = rightCtx.renderedParts.join('');
|
|
710
|
-
|
|
711
|
-
ctx.expressionParts.push(`${leftExpr} ${node.operator} ${rightExpr}`);
|
|
712
|
-
ctx.renderedParts.push(`${leftRendered} ${node.operator} ${rightRendered}`);
|
|
713
|
-
|
|
714
750
|
const total = applyBinaryOperator(node.operator, left.total, right.total);
|
|
715
751
|
|
|
716
752
|
return {
|
|
@@ -723,6 +759,8 @@ function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
723
759
|
total,
|
|
724
760
|
...partSpan(node),
|
|
725
761
|
},
|
|
762
|
+
expression: `${left.expression} ${node.operator} ${right.expression}`,
|
|
763
|
+
rendered: `${left.rendered} ${node.operator} ${right.rendered}`,
|
|
726
764
|
};
|
|
727
765
|
}
|
|
728
766
|
|
|
@@ -763,17 +801,13 @@ function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
763
801
|
|
|
764
802
|
mergeContext(ctx, innerCtx);
|
|
765
803
|
|
|
766
|
-
const innerExpr = innerCtx.expressionParts.join('');
|
|
767
|
-
const innerRendered = innerCtx.renderedParts.join('');
|
|
768
|
-
|
|
769
|
-
ctx.expressionParts.push(`-${innerExpr}`);
|
|
770
|
-
ctx.renderedParts.push(`-${innerRendered}`);
|
|
771
|
-
|
|
772
804
|
const total = -inner.total;
|
|
773
805
|
|
|
774
806
|
return {
|
|
775
807
|
total,
|
|
776
808
|
part: { type: 'unaryOp', operator: '-', operand: inner.part, total, ...partSpan(node) },
|
|
809
|
+
expression: `-${inner.expression}`,
|
|
810
|
+
rendered: `-${inner.rendered}`,
|
|
777
811
|
};
|
|
778
812
|
}
|
|
779
813
|
|
|
@@ -787,12 +821,11 @@ function evalGrouped(node: GroupedNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
787
821
|
|
|
788
822
|
mergeContext(ctx, innerCtx);
|
|
789
823
|
|
|
790
|
-
ctx.expressionParts.push(`(${innerCtx.expressionParts.join('')})`);
|
|
791
|
-
ctx.renderedParts.push(`(${innerCtx.renderedParts.join('')})`);
|
|
792
|
-
|
|
793
824
|
return {
|
|
794
825
|
total: inner.total,
|
|
795
826
|
part: { type: 'grouped', inner: inner.part, total: inner.total, ...partSpan(node) },
|
|
827
|
+
expression: `(${inner.expression})`,
|
|
828
|
+
rendered: `(${inner.rendered})`,
|
|
796
829
|
};
|
|
797
830
|
}
|
|
798
831
|
|
|
@@ -816,18 +849,20 @@ function evalGroup(node: GroupNode, rng: RNG, ctx: EvalContext, env: EvalEnv): E
|
|
|
816
849
|
const subCtx = createContext();
|
|
817
850
|
const sub = evalNode(expr, rng, subCtx, env);
|
|
818
851
|
mergeContext(ctx, subCtx);
|
|
819
|
-
subExprs.push(
|
|
820
|
-
subRendered.push(
|
|
852
|
+
subExprs.push(sub.expression);
|
|
853
|
+
subRendered.push(sub.rendered);
|
|
821
854
|
subParts.push(sub.part);
|
|
822
855
|
total += sub.total;
|
|
823
856
|
}
|
|
824
857
|
|
|
825
|
-
ctx.expressionParts.push(`{${subExprs.join(', ')}}`);
|
|
826
|
-
ctx.renderedParts.push(`{${subRendered.join(', ')}}`);
|
|
827
|
-
|
|
828
858
|
// No `keptIndices` — bare groups (and single-sub passthroughs) perform
|
|
829
859
|
// no sub-roll selection; only `evalGroupKeepDrop` sets it.
|
|
830
|
-
return {
|
|
860
|
+
return {
|
|
861
|
+
total,
|
|
862
|
+
part: { type: 'group', parts: subParts, total, ...partSpan(node) },
|
|
863
|
+
expression: `{${subExprs.join(', ')}}`,
|
|
864
|
+
rendered: `{${subRendered.join(', ')}}`,
|
|
865
|
+
};
|
|
831
866
|
}
|
|
832
867
|
|
|
833
868
|
function evalFunctionCall(
|
|
@@ -849,12 +884,6 @@ function evalFunctionCall(
|
|
|
849
884
|
mergeContext(ctx, argCtx);
|
|
850
885
|
}
|
|
851
886
|
|
|
852
|
-
const argExprs = argCtxs.map((c) => c.expressionParts.join(''));
|
|
853
|
-
const argRendereds = argCtxs.map((c) => c.renderedParts.join(''));
|
|
854
|
-
|
|
855
|
-
ctx.expressionParts.push(`${node.name}(${argExprs.join(', ')})`);
|
|
856
|
-
ctx.renderedParts.push(`${node.name}(${argRendereds.join(', ')})`);
|
|
857
|
-
|
|
858
887
|
const total = applyFunction(
|
|
859
888
|
node.name,
|
|
860
889
|
argResults.map((r) => r.total),
|
|
@@ -869,6 +898,8 @@ function evalFunctionCall(
|
|
|
869
898
|
total,
|
|
870
899
|
...partSpan(node),
|
|
871
900
|
},
|
|
901
|
+
expression: `${node.name}(${argResults.map((r) => r.expression).join(', ')})`,
|
|
902
|
+
rendered: `${node.name}(${argResults.map((r) => r.rendered).join(', ')})`,
|
|
872
903
|
};
|
|
873
904
|
}
|
|
874
905
|
|
|
@@ -1068,9 +1099,9 @@ function formatExplodeCode(
|
|
|
1068
1099
|
}
|
|
1069
1100
|
|
|
1070
1101
|
function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
1071
|
-
const targetCtx =
|
|
1102
|
+
const targetCtx = createDiscardedRenderContext();
|
|
1072
1103
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1073
|
-
const targetExpr =
|
|
1104
|
+
const targetExpr = target.expression;
|
|
1074
1105
|
|
|
1075
1106
|
let thresholdValue: number | undefined;
|
|
1076
1107
|
if (node.threshold != null) {
|
|
@@ -1096,9 +1127,12 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
1096
1127
|
|
|
1097
1128
|
// No-op when the target produced no dice (e.g., `(1+2)!`).
|
|
1098
1129
|
if (targetCtx.rolls.length === 0) {
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1130
|
+
return {
|
|
1131
|
+
total: target.total,
|
|
1132
|
+
part: buildPart(target.total, targetCtx.rolls),
|
|
1133
|
+
expression: `${targetExpr}${code}`,
|
|
1134
|
+
rendered: ctx.suppressRender ? '' : `${targetExpr}${code}`,
|
|
1135
|
+
};
|
|
1102
1136
|
}
|
|
1103
1137
|
|
|
1104
1138
|
const shouldExplode = buildShouldExplode(node.threshold?.operator, thresholdValue);
|
|
@@ -1106,23 +1140,25 @@ function evalExplode(node: ExplodeNode, rng: RNG, ctx: EvalContext, env: EvalEnv
|
|
|
1106
1140
|
const expanded = EXPLODE_APPLIERS[node.variant](targetCtx.rolls, shouldExplode, rng, env);
|
|
1107
1141
|
|
|
1108
1142
|
appendAll(ctx.rolls, expanded);
|
|
1109
|
-
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1110
|
-
// Rendered form carries the explode code — unlike kh/dl, whose dropped dice
|
|
1111
|
-
// are self-evident, explosion origin is otherwise invisible.
|
|
1112
|
-
ctx.renderedParts.push(`${targetExpr}${code}${renderDice(expanded)}`);
|
|
1113
|
-
|
|
1114
1143
|
const total = sumKeptDice(expanded, env.hasVersusDc);
|
|
1115
|
-
return {
|
|
1144
|
+
return {
|
|
1145
|
+
total,
|
|
1146
|
+
part: buildPart(total, expanded),
|
|
1147
|
+
expression: `${targetExpr}${code}`,
|
|
1148
|
+
// Rendered form carries the explode code — unlike kh/dl, whose dropped dice
|
|
1149
|
+
// are self-evident, explosion origin is otherwise invisible.
|
|
1150
|
+
rendered: renderedPool(ctx, `${targetExpr}${code}`, expanded),
|
|
1151
|
+
};
|
|
1116
1152
|
}
|
|
1117
1153
|
|
|
1118
1154
|
function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
1119
|
-
const targetCtx =
|
|
1155
|
+
const targetCtx = createDiscardedRenderContext();
|
|
1120
1156
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1121
1157
|
|
|
1122
1158
|
const thresholdValue = evalMetaOperand(node.condition.value, rng, ctx, env);
|
|
1123
1159
|
|
|
1124
1160
|
const code = `${node.once ? 'ro' : 'r'}${node.condition.operator}${thresholdValue}`;
|
|
1125
|
-
const modifierExpr = joinModifierCode(
|
|
1161
|
+
const modifierExpr = joinModifierCode(target.expression, code);
|
|
1126
1162
|
const condition: ResolvedComparePoint = {
|
|
1127
1163
|
operator: node.condition.operator,
|
|
1128
1164
|
value: thresholdValue,
|
|
@@ -1130,8 +1166,6 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1130
1166
|
|
|
1131
1167
|
// No-op when the target produced no dice (e.g., `(1+2)r<5`).
|
|
1132
1168
|
if (targetCtx.rolls.length === 0) {
|
|
1133
|
-
ctx.expressionParts.push(modifierExpr);
|
|
1134
|
-
ctx.renderedParts.push(modifierExpr);
|
|
1135
1169
|
const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
|
|
1136
1170
|
return {
|
|
1137
1171
|
total,
|
|
@@ -1144,6 +1178,8 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1144
1178
|
total,
|
|
1145
1179
|
...partSpan(node),
|
|
1146
1180
|
},
|
|
1181
|
+
expression: modifierExpr,
|
|
1182
|
+
rendered: ctx.suppressRender ? '' : modifierExpr,
|
|
1147
1183
|
};
|
|
1148
1184
|
}
|
|
1149
1185
|
|
|
@@ -1152,9 +1188,6 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1152
1188
|
: applyRecursiveReroll(targetCtx.rolls, node.condition.operator, thresholdValue, rng, env);
|
|
1153
1189
|
|
|
1154
1190
|
appendAll(ctx.rolls, pool);
|
|
1155
|
-
ctx.expressionParts.push(modifierExpr);
|
|
1156
|
-
ctx.renderedParts.push(`${modifierExpr}${renderDice(pool)}`);
|
|
1157
|
-
|
|
1158
1191
|
const total = sumKeptDice(pool, env.hasVersusDc);
|
|
1159
1192
|
return {
|
|
1160
1193
|
total,
|
|
@@ -1167,6 +1200,8 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1167
1200
|
total,
|
|
1168
1201
|
...partSpan(node),
|
|
1169
1202
|
},
|
|
1203
|
+
expression: modifierExpr,
|
|
1204
|
+
rendered: renderedPool(ctx, modifierExpr, pool),
|
|
1170
1205
|
};
|
|
1171
1206
|
}
|
|
1172
1207
|
|
|
@@ -1177,7 +1212,7 @@ function evalReroll(node: RerollNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1177
1212
|
* and the total re-summed, since clamping changes kept-die values.
|
|
1178
1213
|
*/
|
|
1179
1214
|
function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
1180
|
-
const targetCtx =
|
|
1215
|
+
const targetCtx = createDiscardedRenderContext();
|
|
1181
1216
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1182
1217
|
|
|
1183
1218
|
const boundValue = evalMetaOperand(node.value, rng, ctx, env);
|
|
@@ -1199,13 +1234,12 @@ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1199
1234
|
// Negative bounds render parenthesized so `result.expression` re-parses
|
|
1200
1235
|
// (`4d6min-2` is a syntax error; `4d6min(-2)` is not).
|
|
1201
1236
|
const code = boundValue < 0 ? `${node.bound}(${boundValue})` : `${node.bound}${boundValue}`;
|
|
1202
|
-
const modifierExpr = joinModifierCode(
|
|
1203
|
-
|
|
1204
|
-
ctx.expressionParts.push(modifierExpr);
|
|
1205
|
-
ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
|
|
1237
|
+
const modifierExpr = joinModifierCode(target.expression, code);
|
|
1206
1238
|
|
|
1207
1239
|
const total = sumKeptDice(targetCtx.rolls, env.hasVersusDc);
|
|
1208
1240
|
return {
|
|
1241
|
+
expression: modifierExpr,
|
|
1242
|
+
rendered: renderedPool(ctx, modifierExpr, targetCtx.rolls),
|
|
1209
1243
|
total,
|
|
1210
1244
|
part: {
|
|
1211
1245
|
type: 'dieBound',
|
|
@@ -1235,7 +1269,7 @@ function evalDieBound(node: DieBoundNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1235
1269
|
* per-sub-roll sorting (Stage 3 spec §3 "Group interaction") is implemented.
|
|
1236
1270
|
*/
|
|
1237
1271
|
function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): EvalResult {
|
|
1238
|
-
const targetCtx =
|
|
1272
|
+
const targetCtx = createDiscardedRenderContext();
|
|
1239
1273
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1240
1274
|
|
|
1241
1275
|
const sortedRolls = sortDice(targetCtx.rolls, node.order, env.hasVersusDc);
|
|
@@ -1244,12 +1278,11 @@ function evalSort(node: SortNode, rng: RNG, ctx: EvalContext, env: EvalEnv): Eva
|
|
|
1244
1278
|
propagateMetadata(ctx, targetCtx.versusMetadata);
|
|
1245
1279
|
|
|
1246
1280
|
const code = node.order === 'ascending' ? 's' : 'sd';
|
|
1247
|
-
const modifierExpr = joinModifierCode(
|
|
1248
|
-
|
|
1249
|
-
ctx.expressionParts.push(modifierExpr);
|
|
1250
|
-
ctx.renderedParts.push(`${modifierExpr}${renderDice(sortedRolls)}`);
|
|
1281
|
+
const modifierExpr = joinModifierCode(target.expression, code);
|
|
1251
1282
|
|
|
1252
1283
|
return {
|
|
1284
|
+
expression: modifierExpr,
|
|
1285
|
+
rendered: renderedPool(ctx, modifierExpr, sortedRolls),
|
|
1253
1286
|
total: target.total,
|
|
1254
1287
|
part: {
|
|
1255
1288
|
type: 'sort',
|
|
@@ -1288,7 +1321,7 @@ function evalCritThreshold(
|
|
|
1288
1321
|
ctx: EvalContext,
|
|
1289
1322
|
env: EvalEnv,
|
|
1290
1323
|
): EvalResult {
|
|
1291
|
-
const targetCtx =
|
|
1324
|
+
const targetCtx = createDiscardedRenderContext();
|
|
1292
1325
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1293
1326
|
|
|
1294
1327
|
const successResolved = node.successThresholds.map((t) => resolveCritThreshold(t, rng, ctx, env));
|
|
@@ -1308,12 +1341,11 @@ function evalCritThreshold(
|
|
|
1308
1341
|
const modifierExpr = [
|
|
1309
1342
|
...successResolved.map((t) => (t === 'default' ? 'cs' : `cs${t.operator}${t.value}`)),
|
|
1310
1343
|
...failResolved.map((t) => (t === 'default' ? 'cf' : `cf${t.operator}${t.value}`)),
|
|
1311
|
-
].reduce(joinModifierCode,
|
|
1312
|
-
|
|
1313
|
-
ctx.expressionParts.push(modifierExpr);
|
|
1314
|
-
ctx.renderedParts.push(`${modifierExpr}${renderDice(targetCtx.rolls)}`);
|
|
1344
|
+
].reduce(joinModifierCode, target.expression);
|
|
1315
1345
|
|
|
1316
1346
|
return {
|
|
1347
|
+
expression: modifierExpr,
|
|
1348
|
+
rendered: renderedPool(ctx, modifierExpr, targetCtx.rolls),
|
|
1317
1349
|
total: target.total,
|
|
1318
1350
|
part: {
|
|
1319
1351
|
type: 'critThreshold',
|
|
@@ -1359,7 +1391,7 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1359
1391
|
return evalGroupKeepDrop(node, baseTarget, specs, rng, ctx, env);
|
|
1360
1392
|
}
|
|
1361
1393
|
|
|
1362
|
-
const targetCtx =
|
|
1394
|
+
const targetCtx = createDiscardedRenderContext();
|
|
1363
1395
|
const target = evalNode(baseTarget, rng, targetCtx, env);
|
|
1364
1396
|
|
|
1365
1397
|
const mergedDice = mergeDropSets(targetCtx.rolls, specs, env.hasVersusDc);
|
|
@@ -1368,12 +1400,9 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1368
1400
|
|
|
1369
1401
|
const total = sumKeptDice(mergedDice, env.hasVersusDc);
|
|
1370
1402
|
|
|
1371
|
-
const targetExpr =
|
|
1403
|
+
const targetExpr = target.expression;
|
|
1372
1404
|
const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
1373
1405
|
|
|
1374
|
-
ctx.expressionParts.push(joinModifierCode(targetExpr, keepDropCodes));
|
|
1375
|
-
ctx.renderedParts.push(`${targetExpr}${renderDice(mergedDice)}`);
|
|
1376
|
-
|
|
1377
1406
|
return {
|
|
1378
1407
|
total,
|
|
1379
1408
|
part: {
|
|
@@ -1383,6 +1412,8 @@ function evalKeepDrop(node: KeepDropNode, rng: RNG, ctx: EvalContext, env: EvalE
|
|
|
1383
1412
|
total,
|
|
1384
1413
|
...partSpan(node),
|
|
1385
1414
|
},
|
|
1415
|
+
expression: joinModifierCode(targetExpr, keepDropCodes),
|
|
1416
|
+
rendered: renderedPool(ctx, targetExpr, mergedDice),
|
|
1386
1417
|
};
|
|
1387
1418
|
}
|
|
1388
1419
|
|
|
@@ -1446,8 +1477,8 @@ function evalGroupKeepDrop(
|
|
|
1446
1477
|
subtotal: sub.total,
|
|
1447
1478
|
part: sub.part,
|
|
1448
1479
|
rolls: subCtx.rolls,
|
|
1449
|
-
expr:
|
|
1450
|
-
rendered:
|
|
1480
|
+
expr: sub.expression,
|
|
1481
|
+
rendered: sub.rendered,
|
|
1451
1482
|
versusMetadata: subCtx.versusMetadata,
|
|
1452
1483
|
scoredSuccesses: env.subtotalSuccesses - successTally,
|
|
1453
1484
|
scoredFailures: env.subtotalFailures - failureTally,
|
|
@@ -1507,11 +1538,6 @@ function evalGroupKeepDrop(
|
|
|
1507
1538
|
const subExprStrs = subRolls.map((s) => s.expr);
|
|
1508
1539
|
const keepDropCodes = specs.map((s) => `${s.code}${s.count}`).join('');
|
|
1509
1540
|
|
|
1510
|
-
ctx.expressionParts.push(`{${subExprStrs.join(', ')}}${keepDropCodes}`);
|
|
1511
|
-
// Keep/drop codes live in `expressionParts` only — the per-sub strikethrough
|
|
1512
|
-
// already shows which sub-rolls were kept.
|
|
1513
|
-
ctx.renderedParts.push(`{${outerRendered.join(', ')}}`);
|
|
1514
|
-
|
|
1515
1541
|
// `keptIndices` sits on the inner `group` part even though the outer modifier
|
|
1516
1542
|
// computed it — it describes sub-roll selection. Dropped sub-rolls keep their
|
|
1517
1543
|
// complete parts; consumers filter by `keptIndices`.
|
|
@@ -1532,6 +1558,10 @@ function evalGroupKeepDrop(
|
|
|
1532
1558
|
total,
|
|
1533
1559
|
...partSpan(node),
|
|
1534
1560
|
},
|
|
1561
|
+
expression: `{${subExprStrs.join(', ')}}${keepDropCodes}`,
|
|
1562
|
+
// Keep/drop codes live in `expression` only — the per-sub strikethrough
|
|
1563
|
+
// already shows which sub-rolls were kept.
|
|
1564
|
+
rendered: `{${outerRendered.join(', ')}}`,
|
|
1535
1565
|
};
|
|
1536
1566
|
}
|
|
1537
1567
|
|
|
@@ -1582,7 +1612,7 @@ function evalSuccessCount(
|
|
|
1582
1612
|
|
|
1583
1613
|
const targetCtx = createContext();
|
|
1584
1614
|
const target = evalNode(node.target, rng, targetCtx, env);
|
|
1585
|
-
const targetExpr =
|
|
1615
|
+
const targetExpr = target.expression;
|
|
1586
1616
|
|
|
1587
1617
|
// True once any count has run: an inner one that tagged this very pool (only
|
|
1588
1618
|
// a group can arrange that — `{4d6>=5}<=2f5`), or an unrelated earlier one,
|
|
@@ -1652,9 +1682,12 @@ function evalSuccessCount(
|
|
|
1652
1682
|
// Reachable only through a zero-count pool — a target holding no dice node at
|
|
1653
1683
|
// all is rejected at parse time.
|
|
1654
1684
|
if (pool.length === 0) {
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1685
|
+
return {
|
|
1686
|
+
total: 0,
|
|
1687
|
+
part: buildPart(0, 0, 0),
|
|
1688
|
+
expression: `${targetExpr}${code}`,
|
|
1689
|
+
rendered: `${targetExpr}${code}`,
|
|
1690
|
+
};
|
|
1658
1691
|
}
|
|
1659
1692
|
|
|
1660
1693
|
const result = countSuccesses(
|
|
@@ -1673,19 +1706,16 @@ function evalSuccessCount(
|
|
|
1673
1706
|
}
|
|
1674
1707
|
|
|
1675
1708
|
appendAll(ctx.rolls, targetCtx.rolls);
|
|
1676
|
-
ctx.expressionParts.push(`${targetExpr}${code}`);
|
|
1677
|
-
// A subtotal count renders through the group — its sub-rolls carry their own
|
|
1678
|
-
// brackets, and one flat bracket would spell out the units it never used. The
|
|
1679
|
-
// strip pairs with the tag release above: markers and tags go together.
|
|
1680
|
-
ctx.renderedParts.push(
|
|
1681
|
-
bySubtotal
|
|
1682
|
-
? `${stripTallyMarkers(targetCtx.renderedParts.join(''))}${code}`
|
|
1683
|
-
: `${targetExpr}${code}${renderDice(targetCtx.rolls)}`,
|
|
1684
|
-
);
|
|
1685
|
-
|
|
1686
1709
|
return {
|
|
1687
1710
|
total: result.total,
|
|
1688
1711
|
part: buildPart(result.total, result.successes, result.failures),
|
|
1712
|
+
expression: `${targetExpr}${code}`,
|
|
1713
|
+
// A subtotal count renders through the group — its sub-rolls carry their own
|
|
1714
|
+
// brackets, and one flat bracket would spell out the units it never used. The
|
|
1715
|
+
// strip pairs with the tag release above: markers and tags go together.
|
|
1716
|
+
rendered: bySubtotal
|
|
1717
|
+
? `${stripTallyMarkers(target.rendered)}${code}`
|
|
1718
|
+
: renderedPool(ctx, `${targetExpr}${code}`, targetCtx.rolls),
|
|
1689
1719
|
};
|
|
1690
1720
|
}
|
|
1691
1721
|
|
|
@@ -1778,13 +1808,6 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1778
1808
|
if (dcCtx.rolls.length > 0) env.hasVersusDc = true;
|
|
1779
1809
|
appendAll(ctx.rolls, dcCtx.rolls);
|
|
1780
1810
|
|
|
1781
|
-
const rollExpr = rollCtx.expressionParts.join('');
|
|
1782
|
-
const dcExpr = dcCtx.expressionParts.join('');
|
|
1783
|
-
const rollRendered = rollCtx.renderedParts.join('');
|
|
1784
|
-
const dcRendered = dcCtx.renderedParts.join('');
|
|
1785
|
-
|
|
1786
|
-
ctx.expressionParts.push(`${rollExpr} vs ${dcExpr}`);
|
|
1787
|
-
ctx.renderedParts.push(`${rollRendered} vs ${dcRendered}`);
|
|
1788
1811
|
ctx.versusMetadata = { degree, natural, dcTotal: dcResult.total };
|
|
1789
1812
|
|
|
1790
1813
|
return {
|
|
@@ -1797,6 +1820,8 @@ function evalVersus(node: VersusNode, rng: RNG, ctx: EvalContext, env: EvalEnv):
|
|
|
1797
1820
|
total: rollResult.total,
|
|
1798
1821
|
...partSpan(node),
|
|
1799
1822
|
},
|
|
1823
|
+
expression: `${rollResult.expression} vs ${dcResult.expression}`,
|
|
1824
|
+
rendered: `${rollResult.rendered} vs ${dcResult.rendered}`,
|
|
1800
1825
|
};
|
|
1801
1826
|
} finally {
|
|
1802
1827
|
env.insideVersus = false;
|
|
@@ -1879,7 +1904,7 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
1879
1904
|
};
|
|
1880
1905
|
const ctx = createContext();
|
|
1881
1906
|
|
|
1882
|
-
const { total, part } = evalNode(ast, rng, ctx, env);
|
|
1907
|
+
const { total, part, expression, rendered: renderedBody } = evalNode(ast, rng, ctx, env);
|
|
1883
1908
|
|
|
1884
1909
|
if (!Number.isFinite(total)) {
|
|
1885
1910
|
throw new EvaluatorError(
|
|
@@ -1889,11 +1914,10 @@ export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}):
|
|
|
1889
1914
|
);
|
|
1890
1915
|
}
|
|
1891
1916
|
|
|
1892
|
-
const expression = ctx.expressionParts.join('');
|
|
1893
1917
|
// Versus replaces the numeric total with the degree label in the rendered
|
|
1894
1918
|
// form; `RollResult.total` remains the numeric roll total.
|
|
1895
1919
|
const trailing = ctx.versusMetadata ? degreeLabel(ctx.versusMetadata.degree) : String(total);
|
|
1896
|
-
const rendered = `${
|
|
1920
|
+
const rendered = `${renderedBody} = ${trailing}`;
|
|
1897
1921
|
|
|
1898
1922
|
// `RollResult` is `Readonly` — optional fields fold in via conditional spreads.
|
|
1899
1923
|
const versus = ctx.versusMetadata;
|