-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add exploding dice #38
Description
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, callparseComparePoint()for the optional threshold getLeftBp:BP.MODIFIER(35) — same as keep/drop- Nested explode rejection: if
target.type === 'Explode', throwParseError(prevents1d6!!!)
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:
- Evaluate target to get initial dice pool
- Determine threshold: if
node.threshold, evaluate its value; otherwise useresult === die.sides - Build
shouldExplode(result): booleanpredicate from the ComparePoint - Apply the appropriate explosion variant
- Each explosion die increments
env.totalDiceRolled(counts againstmaxDice) - 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 = 1000per die- New
EvalEnvfield:maxExplodeIterations(configurable viaEvaluateOptionsandRollOptions) - Each explosion increments
env.totalDiceRolled→ globalmaxDice(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 hitINVALID_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 accumulatedresult
Modifier Chain Interaction
The Pratt parser produces correct nesting via left-to-right binding at BP.MODIFIER (35):
4d6!kh3→Modifier(kh3, Explode(Dice))— explode first, keep highest 3 from expanded pool4d6kh3!→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!>5tokenization (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!pwith[6, 3]→ pool = [6, 2], total = 8 (3-1=2) - Threshold:
1d6!>4with[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
- Standard:
- Integration:
roll('1d6!', { rng }),roll('2d6!!', { rng }) - Property:
NdX! total >= NdX total(exploding only adds), explosion dice count >= original count
Drafted with AI assistance