-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add reroll mechanics #39
Description
Add reroll mechanics: recursive reroll (r) and reroll-once (ro). A reroll modifier re-rolls dice that match a comparison condition.
With the Stage 2 foundation (#34), the lexer already emits REROLL and REROLL_ONCE tokens, and ComparePoint is defined in types.ts. This issue covers the AST node, parser, evaluator, and new modifier module.
AST Changes
New node type in src/parser/ast.ts:
type RerollNode = {
type: 'Reroll';
once: boolean;
condition: ComparePoint;
target: ASTNode;
};Add to ASTNode union. Add isReroll type guard. Export from src/index.ts.
The condition is mandatory — bare r without a comparison is invalid syntax. once distinguishes r (false) from ro (true).
Parser Changes
- LED handler for
REROLLandREROLL_ONCE - After consuming the reroll token, call
parseComparePoint()(mandatory — throws if no comparison follows) getLeftBp:BP.MODIFIER(35) — same as keep/drop and explodeonce = token.type === TokenType.REROLL_ONCE
Evaluator Changes
New file: src/evaluator/modifiers/reroll.ts
Shared comparison helper (reusable by explode and success counting):
function matchesCondition(result: number, condition: ComparePoint, evaluatedValue: number): boolean;Two core functions:
Recursive reroll (once: false):
- For each die: roll → check condition → if match, mark
'rerolled', re-roll → repeat - Stop when condition no longer matches or per-die limit (1000) hit
- Final result marked
'kept'
Reroll once (once: true):
- For each die: roll → check condition → if match, mark
'rerolled', roll once more - Keep second result regardless of whether it still matches
- Second result marked
'kept'
New evalReroll function in src/evaluator/evaluator.ts:
- Evaluate target to get initial dice pool
- Evaluate condition's
ComparePoint.value(ASTNode → number) - Apply reroll variant to each die
- Each reroll increments
env.totalDiceRolled(counts againstmaxDice) - Build expression/rendered strings
Safety Mechanism
DEFAULT_MAX_REROLL_ITERATIONS = 1000per die1d6r<7(always rerolls — all d6 results are < 7) hits the limit →REROLL_LIMIT_EXCEEDED- Each reroll increments
env.totalDiceRolled→ globalmaxDiceis the ultimate ceiling
New Error Code
Add REROLL_LIMIT_EXCEEDED to ROLL_PARSER_ERROR_CODES.
DieResult Modifier Semantics
- Intermediate rerolled dice:
modifiers: ['rerolled']— rendered with strikethrough~~value~~ - Final kept dice:
modifiers: ['kept']— rendered normally - All dice (intermediate + final) appear in
RollResult.rolls sumKeptDicealready excludes'dropped'dice; rerolled intermediate dice don't have'kept', so they are naturally excluded when summing only kept dice
Rendered Output
2d6r<2 with rolls [1, 4, 3] (first die: 1 → reroll → 4; second die: 3):
- Expression:
2d6r<2 - Rendered:
2d6r<2[~~1~~, 4, 3] = 7
Modifier Chain Interaction
Left-to-right binding at BP.MODIFIER produces correct nesting:
2d6r<2kh1→Modifier(kh1, Reroll(r<2, Dice))— reroll first, then keep highest2d6kh1r<2→Reroll(r<2, Modifier(kh1, Dice))— keep highest first, then reroll the survivor2d6ro<2r<3→Reroll(r<3, Reroll(ro<2, Dice))— inner reroll-once, then outer recursive
Inside-out evaluation handles all orderings correctly.
Negative Compare Values (Fate Dice)
4dFr=-1 (reroll Fate dice that roll -1) works because ComparePoint.value is ASTNode. The parser produces ComparePoint { operator: '=', value: UnaryOp(-, Literal(1)) }, which evaluates to -1. Test this case.
Edge Cases
| Expression | Expected |
|---|---|
2d6r<2 |
Reroll 1s until >= 2 |
2d6ro<3 |
Reroll 1-2 once, keep second result regardless |
2d6r=1 |
Reroll exact 1s recursively |
1d6r<7 |
Always rerolls → REROLL_LIMIT_EXCEEDED |
1d6ro<7 |
Rerolls once, keeps second result (always terminates) |
2d6r<2kh1 |
Reroll, then keep highest |
2d6ro<2r<3 |
Chained: inner reroll-once, outer recursive |
0d6r<2 |
Zero dice, no-op, total = 0 |
2d6r<1 |
Condition impossible (no d6 result < 1), no rerolling |
4dFr=-1 |
Reroll Fate -1 (negative compare value) |
Test Plan
- Lexer:
r,ro,r<2,ro>=3tokenization (covered in build: prepare Stage 2 foundation #34) - Parser: AST structure for
2d6r<2,2d6ro<3,2d6r=1, modifier chaining - Evaluator with MockRNG:
2d6r<2with[1, 3, 5]→ die 1: 1→reroll→3, die 2: 5, total = 82d6ro<3with[2, 1, 5]→ die 1: 2→reroll→1(keep), die 2: 5, total = 61d6r<7→ REROLL_LIMIT_EXCEEDED1d6ro<7with[3, 5]→ rerolls once to 5, total = 54dFr=-1with[-1, 0, 1]→ rerolls -1 to 0, total = 1- Rerolled dice have
'rerolled'modifier, final dice have'kept'
- Integration:
roll('2d6r<2', { rng }), rendered output contains strikethrough - Property: recursive reroll results never match the condition (for non-limit cases)
Drafted with AI assistance