Extend RollResult with a parts: RollPart tree that mirrors the AST 1:1 — each node holds its sub-total, rolled dice, and (for modifiers) the applied modifier specs. RollPart is a 16-arm discriminated union covering Literal, Dice, FateDice, Grouped, BinaryOp, UnaryOp, Modifier, Explode, Reroll, SuccessCount, Versus, FunctionCall, Group, Sort, CritThreshold, and Variable nodes. Always-built during evaluation (no opt-in flag); the flat rolls array remains for simple consumers. Enables UI components (character sheets, chat clients, spell cards) to render step-by-step breakdowns of compound expressions without re-parsing the notation.
Source of truth for the type shape, invariants, edge cases, and per-branch construction notes: STAGE3.md §5. Hardened in #114.
Rationale
The flat rolls array is sufficient for total + show the dice rendering but insufficient for "show the expression tree with per-sub-total highlights" use cases — exactly the Roll20 / FoundryVTT chat output style. Building the tree during evaluation is essentially free (we already traverse the AST), and always-on avoids an awkward feature-detection API surface. Depends on the shape of all Stage 3 feature nodes, so lands after them.
Implementation tasks
- Evaluator-wide sweep: convert every internal
evalNode( call in src/evaluator/ to evalNodeDetailed(node, rng, ctx, env): { total: number; part: RollPart }. Sites include modifier targets, threshold meta-expressions in explode/reroll/crit-threshold/success-count, count/sides meta in evalDice, flattenModifierChain count-meta, function-call args, binaryOp/unaryOp operands, versus roll/dc, group sub-rolls. TypeScript exhaustiveness enforces that every branch produces a part.
evalVersus refactor: compute degree locally via extractNatural + calculateDegree to populate the versus part. Existing ctx.versusMetadata flow stays for top-level RollResult.degree. Both must agree.
evalSuccessCount refactor: track per-node successes / failures locally during the branch. The top-level scan in evaluate() remains as the source of RollResult.successes / failures; values must match (parser guarantees at most one SuccessCount per expression).
evalGroupModifier: write keptIndices onto the inner group part it constructs (the outer evaluator owns the inner part — accept the small model leak).
RollResult.parts: extend the public type and ensure result.parts.total === result.total for every code path.
src/evaluator/parts.test.ts: snapshot tests over ~10 representative notations + deterministic createMockRng (expect(result.parts).toMatchInlineSnapshot(...)). Covers 1d20+5, (1d6+2)*3, 4d6kh3, 4d6kh3kl1, {1d6,1d8}kh1, floor(1d10/2), 1d20+@str, 1d20 vs 15, 1d20cs>18, 5d6>=5, 5d6>=5*2, 4d6r<3!.
- Per-variant structural assertions: one test per RollPart variant covering shape, discriminator, total, structural children. Pin
count/sides on dice, condition on reroll, threshold on explode/successCount, successThresholds/failThresholds on critThreshold, specs[] on modifier.
- Cross-field consistency:
result.successes === parts…successCount.successes (and same for failures); result.degree === parts…versus.degree when versus is at root.
- Reference-sharing:
=== check between result.rolls[i] and the corresponding parts.<dice-part>.rolls[i]. Encodes the no-deep-clone decision.
- Variable resolution: defined and
onMissingVariable: 'zero' cases both produce { value: number, total: number }.
- Group
keptIndices: accurate after kh/kl/dh/dl; absent on bare groups.
- Property test (
fast-check, numRuns: 200+): root-only parts.total === result.total. Recursive consistency is covered by snapshots, not the property invariant.
- JSON safety:
JSON.parse(JSON.stringify(result.parts)) deep-equals result.parts. No undefined field values that drop on serialization.
Out of scope
- Meta-expression sub-trees (
4d6kh(1d2), (1+1)d(3*2), 4d6!>(1d2), 4d6cs>(1d2), 10d10>=(1d2)): not surfaced as nested RollParts — schema would balloon and the AST 1:1 mapping breaks. Consumers needing meta breakdown inspect result.rolls (filter by modifiers.includes('meta')) or re-parse the meta substring. Stage 4 candidate.
EvaluateOptions.includeParts opt-out: parts is non-optional and always built. Add an opt-out only if perf evidence later justifies it.
Drafted with AI assistance
Extend
RollResultwith aparts: RollParttree that mirrors the AST 1:1 — each node holds its sub-total, rolled dice, and (for modifiers) the applied modifier specs.RollPartis a 16-arm discriminated union covering Literal, Dice, FateDice, Grouped, BinaryOp, UnaryOp, Modifier, Explode, Reroll, SuccessCount, Versus, FunctionCall, Group, Sort, CritThreshold, and Variable nodes. Always-built during evaluation (no opt-in flag); the flatrollsarray remains for simple consumers. Enables UI components (character sheets, chat clients, spell cards) to render step-by-step breakdowns of compound expressions without re-parsing the notation.Source of truth for the type shape, invariants, edge cases, and per-branch construction notes: STAGE3.md §5. Hardened in #114.
Rationale
The flat
rollsarray is sufficient fortotal + show the dicerendering but insufficient for "show the expression tree with per-sub-total highlights" use cases — exactly the Roll20 / FoundryVTT chat output style. Building the tree during evaluation is essentially free (we already traverse the AST), and always-on avoids an awkward feature-detection API surface. Depends on the shape of all Stage 3 feature nodes, so lands after them.Implementation tasks
evalNode(call insrc/evaluator/toevalNodeDetailed(node, rng, ctx, env): { total: number; part: RollPart }. Sites include modifier targets, threshold meta-expressions in explode/reroll/crit-threshold/success-count, count/sides meta inevalDice,flattenModifierChaincount-meta, function-call args, binaryOp/unaryOp operands, versus roll/dc, group sub-rolls. TypeScript exhaustiveness enforces that every branch produces a part.evalVersusrefactor: computedegreelocally viaextractNatural+calculateDegreeto populate theversuspart. Existingctx.versusMetadataflow stays for top-levelRollResult.degree. Both must agree.evalSuccessCountrefactor: track per-nodesuccesses/failureslocally during the branch. The top-level scan inevaluate()remains as the source ofRollResult.successes/failures; values must match (parser guarantees at most oneSuccessCountper expression).evalGroupModifier: writekeptIndicesonto the innergrouppart it constructs (the outer evaluator owns the inner part — accept the small model leak).RollResult.parts: extend the public type and ensureresult.parts.total === result.totalfor every code path.src/evaluator/parts.test.ts: snapshot tests over ~10 representative notations + deterministiccreateMockRng(expect(result.parts).toMatchInlineSnapshot(...)). Covers1d20+5,(1d6+2)*3,4d6kh3,4d6kh3kl1,{1d6,1d8}kh1,floor(1d10/2),1d20+@str,1d20 vs 15,1d20cs>18,5d6>=5,5d6>=5*2,4d6r<3!.count/sidesondice,conditiononreroll,thresholdonexplode/successCount,successThresholds/failThresholdsoncritThreshold,specs[]onmodifier.result.successes === parts…successCount.successes(and same forfailures);result.degree === parts…versus.degreewhen versus is at root.===check betweenresult.rolls[i]and the correspondingparts.<dice-part>.rolls[i]. Encodes the no-deep-clone decision.onMissingVariable: 'zero'cases both produce{ value: number, total: number }.keptIndices: accurate afterkh/kl/dh/dl; absent on bare groups.fast-check,numRuns: 200+): root-onlyparts.total === result.total. Recursive consistency is covered by snapshots, not the property invariant.JSON.parse(JSON.stringify(result.parts))deep-equalsresult.parts. Noundefinedfield values that drop on serialization.Out of scope
4d6kh(1d2),(1+1)d(3*2),4d6!>(1d2),4d6cs>(1d2),10d10>=(1d2)): not surfaced as nested RollParts — schema would balloon and the AST 1:1 mapping breaks. Consumers needing meta breakdown inspectresult.rolls(filter bymodifiers.includes('meta')) or re-parse the meta substring. Stage 4 candidate.EvaluateOptions.includePartsopt-out:partsis non-optional and always built. Add an opt-out only if perf evidence later justifies it.Drafted with AI assistance