Skip to content

feat: add reroll mechanics #39

@edloidas

Description

@edloidas

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 REROLL and REROLL_ONCE
  • After consuming the reroll token, call parseComparePoint() (mandatory — throws if no comparison follows)
  • getLeftBp: BP.MODIFIER (35) — same as keep/drop and explode
  • once = 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):

  1. For each die: roll → check condition → if match, mark 'rerolled', re-roll → repeat
  2. Stop when condition no longer matches or per-die limit (1000) hit
  3. Final result marked 'kept'

Reroll once (once: true):

  1. For each die: roll → check condition → if match, mark 'rerolled', roll once more
  2. Keep second result regardless of whether it still matches
  3. Second result marked 'kept'

New evalReroll function in src/evaluator/evaluator.ts:

  1. Evaluate target to get initial dice pool
  2. Evaluate condition's ComparePoint.value (ASTNode → number)
  3. Apply reroll variant to each die
  4. Each reroll increments env.totalDiceRolled (counts against maxDice)
  5. Build expression/rendered strings

Safety Mechanism

  • DEFAULT_MAX_REROLL_ITERATIONS = 1000 per die
  • 1d6r<7 (always rerolls — all d6 results are < 7) hits the limit → REROLL_LIMIT_EXCEEDED
  • Each reroll increments env.totalDiceRolled → global maxDice is 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
  • sumKeptDice already 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<2kh1Modifier(kh1, Reroll(r<2, Dice)) — reroll first, then keep highest
  • 2d6kh1r<2Reroll(r<2, Modifier(kh1, Dice)) — keep highest first, then reroll the survivor
  • 2d6ro<2r<3Reroll(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>=3 tokenization (covered in build: prepare Stage 2 foundation #34)
  • Parser: AST structure for 2d6r<2, 2d6ro<3, 2d6r=1, modifier chaining
  • Evaluator with MockRNG:
    • 2d6r<2 with [1, 3, 5] → die 1: 1→reroll→3, die 2: 5, total = 8
    • 2d6ro<3 with [2, 1, 5] → die 1: 2→reroll→1(keep), die 2: 5, total = 6
    • 1d6r<7 → REROLL_LIMIT_EXCEEDED
    • 1d6ro<7 with [3, 5] → rerolls once to 5, total = 5
    • 4dFr=-1 with [-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

Metadata

Metadata

Assignees

Labels

featureNew functionality

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions