-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add PF2e Degrees of Success #41
Description
Add PF2e Degrees of Success via the vs (versus) keyword. The syntax 1d20+10 vs 25 evaluates the roll, compares against a DC, and returns a structured degree (Critical Failure, Failure, Success, Critical Success) with natural d20 upgrade/downgrade.
With the Stage 2 foundation (#34), the lexer already emits the VS token. This issue covers the AST node, parser, evaluator, type extensions, and CLI changes.
AST Changes
New node type in src/parser/ast.ts:
type VersusNode = {
type: 'Versus';
roll: ASTNode;
dc: ASTNode;
};Add to ASTNode union. Add isVersus type guard. Export from src/index.ts.
Field naming uses roll and dc (not left/right) to make the semantic purpose clear.
Parser Changes
- LED handler for
VStoken BP: VS_LEFT = 2, VS_RIGHT = 3— lowest precedence, below addition (10). This ensures1d20+10 vs 25parses as(1d20+10) vs (25)- Both sides are full expressions:
1d20+10 vs 15+10is valid (DC = 25) - Multiple vs rejection: if
left.type === 'Versus', throwParseError
Evaluator Changes — evalNode, NOT evaluate()
VersusNode is handled inside evalNode (preserving the AST closure property), NOT at the evaluate() level. This was a deliberate design decision from the consilium review.
Implementation:
- Add
insideVersus: booleantoEvalEnv(defaultfalse) evalVersuschecks the flag — throwsNESTED_VERSUSif alreadytrue- Sets
insideVersus = true - Evaluates roll side in its own
EvalContext - Evaluates DC side in its own
EvalContext - Extracts natural d20 value from roll-side rolls
- Computes degree of success
- Stores metadata on
ctx.versusMetadata - Returns the roll total as the numeric result
evaluate() reads ctx.versusMetadata after evalNode returns and populates RollResult.degree and .natural.
Natural d20 Extraction
function extractNatural(rolls: DieResult[]): number | undefined;Rule:
- Filter for
sides === 20and not'dropped' - Exactly one kept d20 → that value is the natural
- Zero kept d20s →
undefined(no upgrade/downgrade) - Multiple kept d20s →
undefined(ambiguous, no upgrade/downgrade)
This covers all standard PF2e patterns:
1d20+10 vs 25— one d20 ✓2d20kh1+5 vs 20— advantage, one kept d20 ✓2d20kl1+5 vs 20— disadvantage, one kept d20 ✓1d20+1d20 vs 25— two kept d20s → undefined (not standard PF2e)1d6+10 vs 15— no d20 → undefined
Degree Calculation
Follows the PRD specification exactly:
function calculateDegree(total: number, dc: number, natural: number | undefined): DegreeOfSuccess {
let degree: DegreeOfSuccess;
if (total >= dc + 10) degree = CriticalSuccess;
else if (total >= dc) degree = Success;
else if (total > dc - 10) degree = Failure;
else degree = CriticalFailure;
// Nat 20 upgrades one step (not auto-crit)
if (natural === 20 && degree < 3) degree++;
// Nat 1 downgrades one step (not auto-fail)
if (natural === 1 && degree > 0) degree--;
return degree;
}When natural is undefined, no upgrade/downgrade is applied.
Type Changes
New enum in src/types.ts:
enum DegreeOfSuccess {
CriticalFailure = 0,
Failure = 1,
Success = 2,
CriticalSuccess = 3,
}New optional fields on RollResult:
type RollResult = {
// ... existing fields
degree?: DegreeOfSuccess;
natural?: number;
};These are undefined for non-versus expressions. Export DegreeOfSuccess from src/index.ts.
EvalContext Extension
Add optional versusMetadata to EvalContext:
type EvalContext = {
rolls: DieResult[];
expressionParts: string[];
renderedParts: string[];
versusMetadata?: {
dcTotal: number;
rollRollCount: number;
};
};rollRollCount is the number of rolls from the left side, so extractNatural can correctly slice ctx.rolls to inspect only roll-side dice.
Rendered Output
1d20+10 vs 25(roll=15) →1d20[15] + 10 vs 25 = Success1d20+10 vs 25(roll=20) →1d20[20] + 10 vs 25 = Critical Success2d20kh1+5 vs 20(rolls=7,18) →2d20[~~7~~, 18] + 5 vs 20 = Success
The rendered string shows the degree label instead of the numeric total. RollResult.total still contains the numeric roll total (25 in the first example).
New Error Code
Add NESTED_VERSUS to ROLL_PARSER_ERROR_CODES.
Edge Cases
| Expression | Expected |
|---|---|
1d20 vs 15 |
Simple check — degree based on roll vs 15 |
1d20+10 vs 25 |
With modifier — total = roll + 10 |
2d20kh1+5 vs 20 |
Advantage — natural = kept d20 |
2d20kl1+5 vs 20 |
Disadvantage — natural = kept d20 |
1d20-2 vs 25 roll=20 |
Nat 20 upgrade: total=18, base=Failure, upgraded to Success |
1d20+15 vs 15 roll=1 |
Nat 1 downgrade: total=16, base=Success, downgraded to Failure |
1d20+10 vs 15+10 |
DC is expression (evaluates to 25) |
1d20+1d20 vs 25 |
Two kept d20s → natural undefined, no upgrade/downgrade |
1d6+10 vs 15 |
No d20 → natural undefined |
1d20 vs 1d20+10 |
Contested — DC side has dice (valid) |
1d20+5 vs 15 vs 20 |
ParseError — multiple vs |
10 vs 15 |
No rolls — natural undefined, degree = Failure |
Test Plan
- Lexer:
vs,VS,1d20+10 vs 25tokenization (covered in build: prepare Stage 2 foundation #34) - Parser: AST structure for simple vs, with modifiers, with DC expression, multiple vs rejection
- Evaluator with MockRNG:
1d20 vs 15with roll=12 → Failure1d20 vs 15with roll=15 → Success1d20 vs 15with roll=25 → CriticalSuccess (total >= dc + 10)1d20 vs 15with roll=4 → CriticalFailure (total <= dc - 10)- Nat 20 upgrade:
1d20-2 vs 25roll=20 → total=18, base=Failure, upgraded=Success - Nat 1 downgrade:
1d20+15 vs 15roll=1 → total=16, base=Success, downgraded=Failure - Advantage:
2d20kh1+5 vs 20rolls=[7, 18] → natural=18, total=23, Success - No d20:
1d6+10 vs 15→ natural=undefined - RollResult:
.degree,.natural,.totalall populated correctly
- Integration:
roll('1d20+10 vs 25', { rng }), rendered output contains degree label - Property: degree is always in [0, 3], total is numeric regardless of degree
Drafted with AI assistance