Skip to content

feat: add PF2e Degrees of Success #41

@edloidas

Description

@edloidas

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 VS token
  • BP: VS_LEFT = 2, VS_RIGHT = 3 — lowest precedence, below addition (10). This ensures 1d20+10 vs 25 parses as (1d20+10) vs (25)
  • Both sides are full expressions: 1d20+10 vs 15+10 is valid (DC = 25)
  • Multiple vs rejection: if left.type === 'Versus', throw ParseError

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:

  1. Add insideVersus: boolean to EvalEnv (default false)
  2. evalVersus checks the flag — throws NESTED_VERSUS if already true
  3. Sets insideVersus = true
  4. Evaluates roll side in its own EvalContext
  5. Evaluates DC side in its own EvalContext
  6. Extracts natural d20 value from roll-side rolls
  7. Computes degree of success
  8. Stores metadata on ctx.versusMetadata
  9. 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 === 20 and 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 = Success
  • 1d20+10 vs 25 (roll=20) → 1d20[20] + 10 vs 25 = Critical Success
  • 2d20kh1+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 25 tokenization (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 15 with roll=12 → Failure
    • 1d20 vs 15 with roll=15 → Success
    • 1d20 vs 15 with roll=25 → CriticalSuccess (total >= dc + 10)
    • 1d20 vs 15 with roll=4 → CriticalFailure (total <= dc - 10)
    • Nat 20 upgrade: 1d20-2 vs 25 roll=20 → total=18, base=Failure, upgraded=Success
    • Nat 1 downgrade: 1d20+15 vs 15 roll=1 → total=16, base=Success, downgraded=Failure
    • Advantage: 2d20kh1+5 vs 20 rolls=[7, 18] → natural=18, total=23, Success
    • No d20: 1d6+10 vs 15 → natural=undefined
    • RollResult: .degree, .natural, .total all 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

Metadata

Metadata

Assignees

Labels

featureNew functionality

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions