-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add success counting (dice pools) #40
Description
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 aSuccessCountNode getLeftBpfor comparison tokens:BP.MODIFIER(35)- After parsing threshold (
parseComparePoint()), check forFAILtoken → if present, consume and parse fail value.f1meansComparePoint { operator: '=', value: Literal(1) } FAILhasBP = -1(consumed insideparseSuccessCount, never standalone)- Terminal constraint: if
target.type === 'SuccessCount', throwParseError— 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:
- Evaluate target to get dice pool
- Evaluate threshold value (ASTNode → number)
- Optionally evaluate fail threshold value
- 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)
- Check success first (success wins on overlap): if
- Total = successes - failures (can be negative, consistent with WoD rules and PRD 3.7 no-clamping)
- 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 +
ftoken (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,f1standalone - Evaluator with MockRNG:
10d10>=6with known values → verify count10d10>=6f1with 1s and 6+ → verify successes - failures- Negative total: all failures
- Overlap:
10d10>=3f3die=3 → success (not failure) - Keep/drop then count:
10d10kh5>=6 - Zero dice:
0d10>=6→ total 0 - Arithmetic:
5d6>=5+3→ count + 3 RollResult.successesand.failurespopulated 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