Skip to content

feat: add success counting (dice pools) #40

@edloidas

Description

@edloidas

Add success counting (dice pools): 10d10>=6, 10d10>=6f1. Instead of summing dice, the total is a count of successes (dice meeting a threshold), optionally minus failures.

With the Stage 2 foundation (#34), the lexer already emits comparison tokens and the FAIL token, and ComparePoint is defined in types.ts. This issue covers the AST node, parser, evaluator, new modifier module, and type extensions.

AST Changes

New node type in src/parser/ast.ts:

type SuccessCountNode = {
  type: 'SuccessCount';
  target: ASTNode;
  threshold: ComparePoint;
  failThreshold?: ComparePoint;
};

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

Parser Changes

  • LED handler for comparison tokens (GREATER, GREATER_EQUAL, LESS, LESS_EQUAL, EQUAL) — when a comparison follows a dice expression, it creates a SuccessCountNode
  • getLeftBp for comparison tokens: BP.MODIFIER (35)
  • After parsing threshold (parseComparePoint()), check for FAIL token → if present, consume and parse fail value. f1 means ComparePoint { operator: '=', value: Literal(1) }
  • FAIL has BP = -1 (consumed inside parseSuccessCount, never standalone)
  • Terminal constraint: if target.type === 'SuccessCount', throw ParseError — nothing can wrap a success count

Wait — there is a subtlety. Comparison tokens currently have BP = -1 (terminators, set in #34). For success counting, they need BP = MODIFIER (35) in LED to bind as postfix modifiers on dice expressions. This needs to be changed from the Plan 0 default. The parseComparePoint() utility (called by explode/reroll) manually consumes comparison tokens regardless of BP, so changing their BP does not break that flow.

Important parser change: Update comparison token getLeftBp from -1 to BP.MODIFIER (35). The parseComparePoint() method is called explicitly by modifier parsers and doesn't rely on BP.

Evaluator Changes

New file: src/evaluator/modifiers/success-count.ts

function compareResult(value: number, operator: CompareOp, threshold: number): boolean;
function countSuccesses(dice: DieResult[], threshold, failThreshold?): { successes: number; failures: number };

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

  1. Evaluate target to get dice pool
  2. Evaluate threshold value (ASTNode → number)
  3. Optionally evaluate fail threshold value
  4. For each non-dropped die:
    • Check success first (success wins on overlap): if compareResult(die.result, threshold) → +1 success, add 'success' modifier
    • Else if fail threshold exists and compareResult(die.result, failThreshold) → -1 success, add 'failure' modifier
    • Else → neutral (no modifier added)
  5. Total = successes - failures (can be negative, consistent with WoD rules and PRD 3.7 no-clamping)
  6. Build expression/rendered strings

Success/Failure Overlap Precedence

When a die matches both thresholds (e.g., 10d10>=3f3, die rolls 3): success wins. Check success first, skip fail if already counted as success. This matches rpg-dice-roller convention.

Type Changes

In src/types.ts:

Add to DieModifier union:

type DieModifier = 'dropped' | 'kept' | 'exploded' | 'rerolled' | 'success' | 'failure';

Add optional fields to RollResult:

type RollResult = {
  // ... existing fields
  successes?: number;
  failures?: number;
};

These are undefined for non-success-count expressions, preserving backward compatibility.

Rendered Output

10d10>=6f1 with rolls [7, 3, 8, 1, 6, 9, 2, 5, 10, 4]:

  • Successes (>=6): 7, 8, 6, 9, 10 → 5
  • Failures (=1): 1 → 1
  • Total: 5 - 1 = 4
  • Rendered: 10d10[**7**, 3, **8**, __1__, **6**, **9**, 2, 5, **10**, 4] = 4

Markers: **value** for success (bold), __value__ for failure (underline).

CLI formatRendered needs extension to handle these markers.

New Error Code

Add INVALID_THRESHOLD to ROLL_PARSER_ERROR_CODES.

Arithmetic on Success Counts

10d10>=6+5 parses as BinaryOp(+, SuccessCount(Dice, >=6), Literal(5)). The success count returns a number (the count), and +5 adds to it. This works naturally — the evaluator doesn't need to know the semantic origin of the number.

10d10>=6*2 doubles the success count. 10d10>=6+1d4 adds a random number. All arithmetic on success counts is valid.

Modifier Order

  • 10d10kh5>=6 — keep highest 5, then count successes among those ✓
  • 10d10>=6kh5 — ParseError (SuccessCountNode is terminal, modifiers after it rejected)

Edge Cases

Expression Expected
10d10>=6f1 WoD standard — successes at 6+, failures at 1
10d10>5 Strict greater than — 6+ are successes
5d6>=5+3 Count successes, add 3
10d10kh5>=6 Keep highest 5, count among those
10d10>=6kh5 ParseError — modifier after terminal
10d10>=6>=5 ParseError — double success count
1d6>=7 Impossible threshold, zero successes
0d10>=6 Zero dice, zero successes
10d10>=6f1 all 1s Total = -10 (negative allowed)
10d10>=3f3 die=3 Success wins (3 >= 3 → success, f3 skipped)

Test Plan

  • Lexer: comparison tokens + f token (covered in build: prepare Stage 2 foundation #34)
  • Parser: AST structure for 10d10>=6, 10d10>=6f1, 10d10>5, modifier ordering
  • Parser errors: 10d10>=6kh5, 10d10>=6>=5, f1 standalone
  • Evaluator with MockRNG:
    • 10d10>=6 with known values → verify count
    • 10d10>=6f1 with 1s and 6+ → verify successes - failures
    • Negative total: all failures
    • Overlap: 10d10>=3f3 die=3 → success (not failure)
    • Keep/drop then count: 10d10kh5>=6
    • Zero dice: 0d10>=6 → total 0
    • Arithmetic: 5d6>=5+3 → count + 3
    • RollResult.successes and .failures populated correctly
  • Integration: roll('10d10>=6f1', { rng }), rendered output with bold/underline markers
  • Property: success count in range [-N, N] for NdX>=Y, success+failure+neutral = dice 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