Skip to content

feat: add rich JSON parts output #84

@edloidas

Description

@edloidas

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

Metadata

Metadata

Assignees

Labels

featureNew functionality

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions