Skip to content

feat: add exploding dice #38

@edloidas

Description

@edloidas

Add all four exploding dice variants: standard (!), compounding (!!), penetrating (!p), and threshold (!>Y, !>=Y). Exploding dice re-roll when a condition is met, adding results to the pool.

With the Stage 2 foundation (#34), the lexer already emits EXPLODE, EXPLODE_COMPOUND, and EXPLODE_PENETRATING 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 ExplodeNode = {
  type: 'Explode';
  variant: 'standard' | 'compound' | 'penetrating';
  threshold?: ComparePoint;
  target: ASTNode;
};

Add to ASTNode union. Add isExplode type guard. Export from src/index.ts.

ExplodeNode is separate from ModifierNode — exploding generates new dice while keep/drop filters existing ones. Different semantics warrant different node types.

When threshold is undefined, the default behavior applies: explode when result === die.sides (maximum face value, determined from DieResult.sides after evaluation).

Parser Changes

  • LED handler for EXPLODE, EXPLODE_COMPOUND, EXPLODE_PENETRATING
  • Map token type to variant: EXPLODE'standard', EXPLODE_COMPOUND'compound', EXPLODE_PENETRATING'penetrating'
  • After consuming the explode token, check isComparePointAhead() — if true, call parseComparePoint() for the optional threshold
  • getLeftBp: BP.MODIFIER (35) — same as keep/drop
  • Nested explode rejection: if target.type === 'Explode', throw ParseError (prevents 1d6!!!)

The threshold value expression is parsed with BP.DICE_LEFT (40) to grab only the immediate number/parenthesized expression, not trailing arithmetic.

Evaluator Changes

New file: src/evaluator/modifiers/explode.ts

Three exported functions:

function applyStandardExplode(dice, shouldExplode, sides, rng, env): DieResult[];
function applyCompoundExplode(dice, shouldExplode, sides, rng, env): DieResult[];
function applyPenetratingExplode(dice, shouldExplode, sides, rng, env): DieResult[];

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

  1. Evaluate target to get initial dice pool
  2. Determine threshold: if node.threshold, evaluate its value; otherwise use result === die.sides
  3. Build shouldExplode(result): boolean predicate from the ComparePoint
  4. Apply the appropriate explosion variant
  5. Each explosion die increments env.totalDiceRolled (counts against maxDice)
  6. Build expression/rendered strings

Explosion Variants

Variant Behavior Pool effect DieResult
Standard (!) Roll again on match, add new die to pool Pool grows New dice get 'exploded' modifier
Compound (!!) Roll again on match, accumulate into original die's result Pool unchanged Original die gets 'exploded', result is accumulated total
Penetrating (!p) Roll again on match, new die result = rawRoll - 1 (no floor) Pool grows New dice get 'exploded' modifier

For penetrating: the explosion check uses the raw roll (before decrement). Only the stored result is decremented. A d2 penetrating that rolls 1 produces result = 0 — this is intentional per rpg-dice-roller convention.

Safety Mechanism

  • DEFAULT_MAX_EXPLODE_ITERATIONS = 1000 per die
  • New EvalEnv field: maxExplodeIterations (configurable via EvaluateOptions and RollOptions)
  • Each explosion increments env.totalDiceRolled → global maxDice (10,000) is the ultimate ceiling
  • 1d1! hits the per-die limit after 1000 iterations → EXPLODE_LIMIT_EXCEEDED

New Error Codes

Add to ROLL_PARSER_ERROR_CODES:

  • EXPLODE_LIMIT_EXCEEDED — per-die explosion limit hit
  • INVALID_EXPLODE_TARGET — explode applied to non-dice expression (defensive)

DieResult Modifier Semantics

  • 'exploded' means "this DieResult was generated by an explosion chain" (additional die, not original)
  • Original die that triggered explosion: no 'exploded' modifier, still 'kept'
  • All explosion-generated dice: ['exploded', 'kept']
  • For compound: original die gets ['exploded', 'kept'] with accumulated result

Modifier Chain Interaction

The Pratt parser produces correct nesting via left-to-right binding at BP.MODIFIER (35):

  • 4d6!kh3Modifier(kh3, Explode(Dice)) — explode first, keep highest 3 from expanded pool
  • 4d6kh3!Explode(Modifier(kh3, Dice)) — keep highest 3 first, then explode the filtered pool

Inside-out evaluation via evalNode dispatch handles both orderings correctly. flattenModifierChain stops at ExplodeNode (it's not a ModifierNode), treating it as the base target.

Edge Cases

Expression Expected
1d6! Standard explode on max (6)
1d6!! Compound — accumulates into single result
1d6!p Penetrating — explosion results decremented by 1
1d6!>5 Explode when > 5 (i.e., on 6)
1d6!>=5 Explode when >= 5 (on 5 or 6)
1d6!!>5 Compound with threshold
1d6!p>3 Penetrating with threshold
4d6!kh3 Explode then keep highest 3
d6! Prefix dice + explode
1d1! Always explodes → EXPLODE_LIMIT_EXCEEDED after 1000
0d6! Empty pool, no explosions, total = 0
1d6!!! ParseError — nested explode rejected
(1+2)! ExplodeNode targeting BinaryOp — no dice in pool, explosion is no-op

Test Plan

  • Lexer: !, !!, !p, !P, 1d6!, 1d6!>5 tokenization (covered in build: prepare Stage 2 foundation #34)
  • Parser: AST structure for all variants, threshold parsing, nested rejection, modifier chaining
  • Evaluator with MockRNG:
    • Standard: 1d6! with [6, 3] → pool = [6, 3], total = 9
    • Standard chain: 1d6! with [6, 6, 2] → pool = [6, 6, 2], total = 14
    • Compound: 1d6!! with [6, 6, 3] → single die result = 15
    • Penetrating: 1d6!p with [6, 3] → pool = [6, 2], total = 8 (3-1=2)
    • Threshold: 1d6!>4 with [5, 3] → pool = [5, 3], total = 8
    • No explosion: 1d6! with [3] → pool = [3], total = 3
    • Safety: 1d1! → EXPLODE_LIMIT_EXCEEDED
    • Chaining: 4d6!kh3 → explode pool, keep highest 3
  • Integration: roll('1d6!', { rng }), roll('2d6!!', { rng })
  • Property: NdX! total >= NdX total (exploding only adds), explosion dice count >= original count

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