diff --git a/.claude/docs/STAGE2.md b/.claude/docs/STAGE2.md new file mode 100644 index 0000000..9dbba25 --- /dev/null +++ b/.claude/docs/STAGE2.md @@ -0,0 +1,507 @@ +# Stage 2 — Implementation Guide + +Consolidated design decisions, architecture notes, and implementation details for +Stage 2 features (epic [#25](https://github.com/edloidas/roll-parser/issues/25)). + +Based on PRD sections 4.1–4.5, gap analysis (#18), and consilium review findings. + +## Foundation (Plan 0) + +Implemented as a preparatory commit before any individual feature. Provides the +shared infrastructure all Stage 2 features depend on. + +### Token Allocation + +All Stage 2 token IDs allocated in a single pass. Grouped semantically: + +| ID | Name | Syntax | Group | +|----|------|--------|-------| +| 0 | `NUMBER` | `42`, `1.5` | Literals | +| 1 | `DICE` | `d`, `D` | Dice operators | +| 2 | `DICE_PERCENT` | `d%` | Dice operators | +| 3 | `DICE_FATE` | `dF` | Dice operators | +| 4–9 | `PLUS`..`POWER` | `+`, `-`, `*`, `/`, `%`, `**`/`^` | Arithmetic | +| 10–14 | `GREATER`..`EQUAL` | `>`, `>=`, `<`, `<=`, `=` | Comparison | +| 15–17 | `LPAREN`..`COMMA` | `(`, `)`, `,` | Grouping | +| 18–21 | `KEEP_HIGH`..`DROP_LOW` | `kh`/`k`, `kl`, `dh`, `dl` | Keep/drop | +| 22–24 | `EXPLODE`..`EXPLODE_PENETRATING` | `!`, `!!`, `!p` | Explode | +| 25–26 | `REROLL`, `REROLL_ONCE` | `r`, `ro` | Reroll | +| 27 | `FAIL` | `f` | Success counting | +| 28 | `FUNCTION` | `floor`, `ceil`, etc. | Functions | +| 29 | `VS` | `vs` | Keywords | +| 30 | `EOF` | — | End of input | + +### ComparePoint + +Canonical definition in `src/types.ts`: + +```typescript +type CompareOp = '>' | '>=' | '<' | '<=' | '='; +type ComparePoint = { operator: CompareOp; value: ASTNode }; +``` + +`value` is `ASTNode` (not `number`) to: +- Match the pattern used by `DiceNode.count` and `DiceNode.sides` +- Support computed thresholds like `>=ceil(5)` when math functions are available +- Support negative compare values via unary minus (`=-1` for Fate dice) + +### Lexer: Full-Accumulation scanIdentifier + +The lexer's `scanIdentifier` was rewritten from incremental peek-based scanning to +full-accumulation: all consecutive alpha characters are collected, then the result +is classified against a keyword lookup table. + +This resolves: +- The `d`-in-`round` problem (`round` accumulates fully before classification) +- `d%` scanning (bare `d` checks for non-alpha `%` after accumulation) +- `f` vs `floor` disambiguation (`f` alone → FAIL, `floor` → FUNCTION) +- All future identifier keywords (`vs`, `r`, `ro`, etc.) + +### Parser: ComparePoint Utilities + +The `Parser` class exposes two methods for Stage 2 modifier parsers: + +- `isComparePointAhead(): boolean` — peeks for comparison operator tokens +- `parseComparePoint(): ComparePoint` — consumes comparison operator + value expression + +Comparison tokens have `BP = -1` (terminators), so they never interfere with the +main Pratt loop. They are consumed exclusively by modifier parsers. + +### Implementation Order + +1. Comparison operators (foundation — already done in Plan 0) +2. Percentile dice (`d%`) — simple, self-contained +3. Fate/Fudge dice (`dF`) — simple, self-contained +4. Math functions — parser/evaluator only (lexer done in Plan 0) +5. Exploding dice — depends on ComparePoint +6. Reroll mechanics — depends on ComparePoint +7. Success counting — depends on ComparePoint, most complex semantics +8. PF2e Degrees of Success — standalone + +Items 2–3 and 5–6 can be parallelized within their groups. + +--- + +## Design Decisions + +Resolved during consilium review. These apply across all Stage 2 features. + +### Modifier Chain Evaluation Order + +The Pratt parser produces nested AST nodes based on left-to-right binding at +equal binding power (BP = 35 for all modifiers). The evaluator uses inside-out +evaluation via `evalNode` dispatch: + +- `4d6!kh3` → `Modifier(kh3, Explode(Dice))` — explode first, then keep highest +- `4d6kh3!` → `Explode(Modifier(kh3, Dice))` — keep highest first, then explode +- `4d6r<2!kh3` → `Modifier(kh3, Explode(Reroll(Dice)))` — reroll → explode → keep + +`flattenModifierChain` only applies when `ModifierNode` is outermost. New modifier +types (Explode, Reroll, SuccessCount) each have their own `eval*` function that +calls `evalNode` on their target. No changes to `flattenModifierChain` needed. + +### VersusNode Evaluation + +Handle inside `evalNode`, NOT at the `evaluate()` level. This preserves the AST +closure property (every ASTNode can be recursively evaluated). + +Implementation: +- Add `insideVersus: boolean` to `EvalEnv` (default `false`) +- `evalVersus` checks the flag, throws if already `true`, sets it to `true` +- Evaluates roll side and DC side independently +- Stores degree/natural metadata on `EvalContext.versusMetadata` +- `evaluate()` reads `versusMetadata` and populates `RollResult.degree`/`.natural` + +### Natural d20 Extraction (PF2e) + +Rule: filter left-side rolls for `sides === 20` and not `'dropped'`. + +- Exactly **one** kept d20 → that's the natural value (upgrade/downgrade applies) +- **Zero** kept d20s → `natural: undefined` (no upgrade/downgrade) +- **Multiple** kept d20s → `natural: undefined` (ambiguous, no upgrade/downgrade) + +Covers 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 ✓ + +### Negative Compare Values + +Supported out of the box. `ComparePoint.value` is `ASTNode`, so `=-1` parses as +`UnaryOp(-, Literal(1))` and evaluates to `-1`. This enables Fate dice comparisons +like `4dFr=-1` (reroll exact -1). + +Tests for negative compare values must be included in the Fate dice and reroll +feature implementations. + +### Safety Counters (Explode/Reroll) + +Two independent per-die limits + existing global ceiling: + +| Limit | Scope | Default | Purpose | +|-------|-------|---------|---------| +| `maxExplodeIterations` | Per die | 1,000 | Prevents `1d1!` infinite loop | +| `maxRerollIterations` | Per die | 1,000 | Prevents `1d6r<7` infinite loop | +| `maxDice` | Per expression | 10,000 | Prevents resource exhaustion | + +Both explode and reroll increment `env.totalDiceRolled` for each re-roll, so the +global `maxDice` limit acts as the ultimate safety net. No shared iteration budget +needed. + +### Success/Failure Threshold Overlap + +When a die matches both success and fail thresholds (e.g., `10d10>=3f3` and die +rolls 3): **success wins**. + +Evaluation order: +1. Check success threshold → if match, `+1` success, done +2. Else check fail threshold → if match, `-1` success +3. Else → neutral (no effect on count) + +Negative totals are allowed (more failures than successes). No clamping to zero, +consistent with PRD 3.7 and WoD rules. + +### Fate Dice DieResult + +- `DieResult.sides = 0` — sentinel value identifying Fate dice +- `DieResult.result` ∈ `{-1, 0, +1}` +- `critical: false` always (no max-value concept) +- `fumble: false` always (no min-value concept) +- Dedicated `createFateDieResult()` in evaluator bypasses normal critical/fumble logic +- Rolling: `rng.nextInt(-1, 1)` directly +- Keep/drop works naturally (sorts by result value: -1 < 0 < +1) + +### Penetrating Explosion Results + +`result = rawRoll - 1` with **no floor**. A penetrating explosion on a d2 that +rolls 1 produces `result = 0`. This matches rpg-dice-roller convention. + +The explosion check uses the **raw** roll value (before decrement). Only the +stored result is decremented. + +### Rendered Output Extensions + +Each new feature extends `renderDice` with its modifier: + +| Modifier | Render | Meaning | +|----------|--------|---------| +| `'dropped'` | `~~value~~` | Dropped by keep/drop (existing) | +| `'rerolled'` | `~~value~~` | Intermediate reroll result (strikethrough) | +| `'exploded'` | unmarked | Generated by explosion chain | +| `'success'` | `**value**` | Counted as success | +| `'failure'` | `__value__` | Counted as failure | + +--- + +## Feature Specifications + +### 1. Percentile Dice (`d%`) + +**Syntax:** `d%`, `2d%`, `d%+5`, `2d%kh1` + +**Lexer:** `DICE_PERCENT` token (already implemented in Plan 0) + +**Parser:** +- NUD: `d%` → `DiceNode(Literal(1), Literal(100))` +- LED: `2d%` → `DiceNode(left, Literal(100))` +- `getLeftBp`: `BP.DICE_LEFT` (40) + +**AST:** No new node. Reuses `DiceNode` with `sides = Literal(100)`. + +**Evaluator:** No changes. `DiceNode` with `sides = 100` works as-is. + +**Expression output:** Shows `1d100` (canonical form). `notation` preserves `d%`. + +**Edge cases:** +- `d%` → 1d100 +- `2d%` → 2d100 +- `d%+5` → 1d100 + 5 +- `d % 3` → error (whitespace breaks the token) +- `d%%` → error (d% consumed, then bare % is MODULO with no NUD handler) + +### 2. Fate/Fudge Dice (`dF`) + +**Syntax:** `dF`, `4dF`, `dF+5`, `4dFkh2` + +**Lexer:** `DICE_FATE` token (already implemented in Plan 0) + +**Parser:** +- New AST node: `FateDiceNode = { type: 'FateDice'; count: ASTNode }` +- NUD: `dF` → `FateDice(Literal(1))` +- LED: `4dF` → `FateDice(left)` +- `getLeftBp`: `BP.DICE_LEFT` (40) +- No `getRightBp` needed (no sides expression to parse) + +**AST:** Add `FateDiceNode` to `ASTNode` union. Add `isFateDice` type guard. + +**Evaluator:** +- New `evalFateDice` function +- `createFateDieResult(result)`: `sides = 0`, `critical = false`, `fumble = false` +- Rolling: `rng.nextInt(-1, 1)` for each die +- Dice count validation and `maxDice` limit (same as `evalDice`) +- Expression: `4dF`, rendered: `4dF[-1, 0, 1, 1]` + +**CLI format.ts:** Update regex from `/~~(\d+)~~/g` to `/~~(-?\d+)~~/g` for +negative values in strikethrough. + +**Tests must cover:** +- `dF`, `4dF`, `dF+5`, `4dFkh2`, `4dFdl1` +- `0dF` → total 0, empty rolls +- `(-1)dF` → EvaluatorError +- All results have `sides === 0`, `critical === false`, `fumble === false` +- `4dF` total is in range `[-4, +4]` (property test) +- MockRNG with `[-1, 0, 1, 1]` → total 1 +- Negative compare values: `4dFr=-1` (test with reroll feature) + +### 3. Math Functions + +**Syntax:** `floor(expr)`, `ceil(expr)`, `round(expr)`, `abs(expr)`, +`max(expr, expr, ...)`, `min(expr, expr, ...)` + +**Lexer:** `FUNCTION` and `COMMA` tokens (already implemented in Plan 0) + +**Parser:** +- New AST node: `FunctionCallNode = { type: 'FunctionCall'; name: string; args: ASTNode[] }` +- NUD handler for `FUNCTION`: `parseFunctionCall(token)` +- Argument parsing: `LPAREN` + comma-separated expressions + `RPAREN` +- Arity validation at parse time: + + | Function | Min args | Max args | + |----------|----------|----------| + | `floor` | 1 | 1 | + | `ceil` | 1 | 1 | + | `round` | 1 | 1 | + | `abs` | 1 | 1 | + | `max` | 2 | ∞ | + | `min` | 2 | ∞ | + +- `FUNCTION` and `COMMA` have `BP = -1` (terminators) + +**AST:** Add `FunctionCallNode` to `ASTNode` union. Add `isFunctionCall` type guard. + +**Evaluator:** +- New `evalFunctionCall` function +- Evaluates each arg in its own `EvalContext` +- Dispatches to `Math.floor`, `Math.ceil`, `Math.round`, `Math.abs`, `Math.max`, `Math.min` +- Expression: `floor(1d6 / 3)`, rendered: `floor(1d6[5] / 3) = 1` + +**New error codes:** `INVALID_FUNCTION_ARITY`, `UNKNOWN_FUNCTION` + +**Edge cases:** +- `floor(10/3)` → 3 (resolves GAPS #5 — non-integer division) +- `max(1d6, 1d8)` → higher of two rolls +- `floor(floor(10/3)/2)` → nested functions +- `floor()` → INVALID_FUNCTION_ARITY +- `floor(1, 2)` → INVALID_FUNCTION_ARITY +- `max(1d6)` → INVALID_FUNCTION_ARITY (needs 2+) +- `FLOOR(10/3)` → case-insensitive, works + +### 4. Exploding Dice + +**Syntax:** `1d6!`, `1d6!!`, `1d6!p`, `1d6!>5`, `1d6!!>=3`, `1d6!p>3` + +**Lexer:** `EXPLODE`, `EXPLODE_COMPOUND`, `EXPLODE_PENETRATING` tokens +(already implemented in Plan 0) + +**Parser:** +- New AST node: + ``` + ExplodeNode = { + type: 'Explode'; + variant: 'standard' | 'compound' | 'penetrating'; + threshold?: ComparePoint; + target: ASTNode; + } + ``` +- LED handler for all three explode tokens +- After consuming explode token, check `isComparePointAhead()` for optional threshold +- `getLeftBp`: `BP.MODIFIER` (35) +- Nested explode rejection: if `target.type === 'Explode'`, throw ParseError + +**AST:** Add `ExplodeNode` to `ASTNode` union. Add `isExplode` type guard. + +**Evaluator:** +- New `src/evaluator/modifiers/explode.ts` module +- `evalExplode`: evaluates target, determines threshold, applies explosion variant +- Default threshold (no ComparePoint): explode when `result === die.sides` +- Sides determined from `DieResult.sides` in the evaluated pool + +**Variants:** + +| Variant | Behavior | Pool effect | +|---------|----------|-------------| +| Standard (`!`) | Roll again, add new die to pool | Pool grows | +| Compound (`!!`) | Roll again, accumulate into original die's result | Pool unchanged | +| Penetrating (`!p`) | Roll again, new die result = rawRoll - 1 (no floor) | Pool grows | + +**Safety:** `DEFAULT_MAX_EXPLODE_ITERATIONS = 1000` per die. New `EvalEnv` field: +`maxExplodeIterations`. Each explosion increments `env.totalDiceRolled`. + +**DieResult modifiers:** Explosion-generated dice get `'exploded'` modifier. + +**New error codes:** `EXPLODE_LIMIT_EXCEEDED`, `INVALID_EXPLODE_TARGET` + +**Edge cases:** +- `1d1!` → hits 1000-iteration limit, throws EXPLODE_LIMIT_EXCEEDED +- `0d6!` → empty pool, no explosions, total = 0 +- `4d6!kh3` → explode first, then keep highest 3 from expanded pool +- `d6!` → prefix dice, works naturally +- `1d6!!!` → ParseError (nested explode rejected) +- `1d6!!>5` → compound with threshold ≥5 + +### 5. Reroll Mechanics + +**Syntax:** `2d6r<2`, `2d6ro<3`, `2d6r=1`, `2d6ro>=5` + +**Lexer:** `REROLL` and `REROLL_ONCE` tokens (already implemented in Plan 0) + +**Parser:** +- New AST node: + ``` + RerollNode = { + type: 'Reroll'; + once: boolean; + condition: ComparePoint; + target: ASTNode; + } + ``` +- LED handler for `REROLL` and `REROLL_ONCE` +- After consuming reroll token, call `parseComparePoint()` (mandatory — bare `r` without condition is invalid) +- `getLeftBp`: `BP.MODIFIER` (35) + +**AST:** Add `RerollNode` to `ASTNode` union. Add `isReroll` type guard. + +**Evaluator:** +- New `src/evaluator/modifiers/reroll.ts` module +- `matchesCondition(result, condition)` — reusable comparison helper +- Recursive reroll (`once: false`): roll → check → if match, mark `'rerolled'` and re-roll → repeat until no match or limit hit +- Reroll once (`once: true`): roll → check → if match, mark `'rerolled'` and roll once more → keep second result regardless + +**Safety:** `DEFAULT_MAX_REROLL_ITERATIONS = 1000` per die. Each reroll increments +`env.totalDiceRolled`. + +**DieResult modifiers:** +- Intermediate dice: `['rerolled']` (rendered as strikethrough) +- Final kept dice: `['kept']` + +**New error code:** `REROLL_LIMIT_EXCEEDED` + +**Edge cases:** +- `2d6r<2` → reroll 1s until ≥2 +- `2d6ro<3` → reroll 1–2 once, keep second result regardless +- `1d6r<7` → always rerolls (all d6 results < 7), hits 1000 limit +- `2d6r<2kh1` → reroll first, then keep highest from rerolled pool +- `2d6ro<2r<3` → chained: inner reroll-once, then outer recursive reroll +- `4dFr=-1` → reroll Fate dice that roll -1 (negative compare value test) + +### 6. Success Counting (Dice Pools) + +**Syntax:** `10d10>=6`, `10d10>=6f1`, `10d10>5`, `5d6>=5+3` + +**Lexer:** Comparison tokens + `FAIL` token (already implemented in Plan 0) + +**Parser:** +- New AST node: + ``` + SuccessCountNode = { + type: 'SuccessCount'; + target: ASTNode; + threshold: ComparePoint; + failThreshold?: ComparePoint; + } + ``` +- LED handler for comparison tokens (GREATER, GREATER_EQUAL, LESS, LESS_EQUAL, EQUAL) + when they appear after a dice expression +- After parsing threshold, check for `FAIL` token → if present, consume and parse + fail value as `ComparePoint` with `operator: '='` +- `getLeftBp` for comparison tokens: `BP.MODIFIER` (35) +- `FAIL` has `BP = -1` (consumed inside `parseSuccessCount`, never standalone) +- Terminal constraint: if `target.type === 'SuccessCount'`, throw ParseError + +**AST:** Add `SuccessCountNode` to `ASTNode` union. Add `isSuccessCount` type guard. + +**Evaluator:** +- New `src/evaluator/modifiers/success-count.ts` module +- `compareResult(value, operator, threshold)` — pure comparison (reusable) +- Success/failure precedence: **success wins** (check success first, then fail) +- Total = count of successes minus count of failures (can be negative) +- Dropped dice excluded from counting + +**New DieModifier values:** `'success'`, `'failure'` + +**New optional RollResult fields:** `successes?: number`, `failures?: number` + +**New error code:** `INVALID_THRESHOLD` + +**Rendered output:** `**value**` for success, `__value__` for failure + +**Edge cases:** +- `10d10>=6f1` → WoD standard (successes at 6+, failures at 1) +- `10d10>5` → strict greater than +- `5d6>=5+3` → count successes, then add 3 to count +- `10d10kh5>=6` → keep highest 5, then count among those +- `10d10>=6kh5` → ParseError (modifier after terminal SuccessCountNode) +- `1d6>=7` → impossible threshold, zero successes +- Negative total allowed (more failures than successes) + +### 7. PF2e Degrees of Success + +**Syntax:** `1d20+10 vs 25`, `2d20kh1+5 vs 20` + +**Lexer:** `VS` token (already implemented in Plan 0) + +**Parser:** +- New AST node: + ``` + VersusNode = { + type: 'Versus'; + roll: ASTNode; + dc: ASTNode; + } + ``` +- LED handler for `VS` token +- `BP: VS_LEFT = 2, VS_RIGHT = 3` (lowest precedence — below addition) +- Multiple `vs` rejected: if `left.type === 'Versus'`, throw ParseError + +**AST:** Add `VersusNode` to `ASTNode` union. Add `isVersus` type guard. + +**Evaluator:** +- Handled inside `evalNode` (not at `evaluate()` level) +- `EvalEnv.insideVersus: boolean` — prevents nesting, throws if already `true` +- Evaluates roll side and DC side in separate `EvalContext` objects +- `extractNatural(rolls)`: exactly one kept d20 → natural; else `undefined` +- `calculateDegree(total, dc, natural)`: + - `total >= dc + 10` → CriticalSuccess + - `total >= dc` → Success + - `total > dc - 10` → Failure + - else → CriticalFailure + - Nat 20: upgrade one step (if < CriticalSuccess) + - Nat 1: downgrade one step (if > CriticalFailure) +- Stores `{ dcTotal, rollCount }` in `EvalContext.versusMetadata` +- `evaluate()` reads metadata → populates `RollResult.degree` and `.natural` +- `RollResult.total` = roll total (not the degree enum value) + +**New types:** +``` +enum DegreeOfSuccess { + CriticalFailure = 0, + Failure = 1, + Success = 2, + CriticalSuccess = 3, +} +``` + +**New RollResult fields:** `degree?: DegreeOfSuccess`, `natural?: number` + +**New error code:** `NESTED_VERSUS` + +**Rendered:** `1d20[15] + 10 vs 25 = Success` + +**Edge cases:** +- `1d20 vs 15` → simple check +- `1d20+10 vs 25` → with modifier +- `2d20kh1+5 vs 20` → advantage (one kept d20 → natural detected) +- `1d20+1d20 vs 25` → multiple kept d20s → natural undefined, no upgrade/downgrade +- `1d6+10 vs 15` → no d20 → natural undefined +- `1d20 vs 1d20+10` → contested check (DC side has dice) +- `1d20+5 vs 15 vs 20` → ParseError (multiple vs) diff --git a/CLAUDE.md b/CLAUDE.md index bcbcbeb..4d21ea8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,11 +33,12 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf`, `bui - **Title**: `: ` - Use `epic: ` for issues that aggregate sub-issues and describe a long-form implementation plan. Not used in commits. - **Body**: concisely explain what and why, skip trivial details +- **Headers**: use `####` (h4) for short issues (1–2 headers), `###` (h3) when there are 3 or more ``` <4–8 sentence description: what, what's affected, how to reproduce, impact> - ##### Rationale + #### Rationale Drafted with AI assistance diff --git a/src/index.ts b/src/index.ts index 57e068a..c74c5ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,7 +35,14 @@ export { SeededRNG } from './rng/seeded'; // * Evaluator exports export { DEFAULT_MAX_DICE, evaluate, EvaluatorError } from './evaluator/evaluator'; -export type { DieModifier, DieResult, EvaluateOptions, RollResult } from './types'; +export type { + CompareOp, + ComparePoint, + DieModifier, + DieResult, + EvaluateOptions, + RollResult, +} from './types'; // * Public API export { roll } from './roll'; diff --git a/src/lexer/lexer.test.ts b/src/lexer/lexer.test.ts index b3a7d6f..c3fc4e3 100644 --- a/src/lexer/lexer.test.ts +++ b/src/lexer/lexer.test.ts @@ -242,6 +242,278 @@ describe('Lexer', () => { }); }); + describe('comparison operators', () => { + it('should tokenize > as GREATER', () => { + const tokens = lex('>5'); + + expect(tokens).toHaveLength(3); + expect(tokens[0]).toEqual({ type: TokenType.GREATER, value: '>', position: 0 }); + expect(tokens[1]).toEqual({ type: TokenType.NUMBER, value: '5', position: 1 }); + }); + + it('should tokenize >= as GREATER_EQUAL (maximal munch)', () => { + const tokens = lex('>=6'); + + expect(tokens).toHaveLength(3); + expect(tokens[0]).toEqual({ type: TokenType.GREATER_EQUAL, value: '>=', position: 0 }); + expect(tokens[1]).toEqual({ type: TokenType.NUMBER, value: '6', position: 2 }); + }); + + it('should tokenize < as LESS', () => { + const tokens = lex('<2'); + + expect(tokens).toHaveLength(3); + expect(tokens[0]).toEqual({ type: TokenType.LESS, value: '<', position: 0 }); + expect(tokens[1]).toEqual({ type: TokenType.NUMBER, value: '2', position: 1 }); + }); + + it('should tokenize <= as LESS_EQUAL (maximal munch)', () => { + const tokens = lex('<=3'); + + expect(tokens).toHaveLength(3); + expect(tokens[0]).toEqual({ type: TokenType.LESS_EQUAL, value: '<=', position: 0 }); + expect(tokens[1]).toEqual({ type: TokenType.NUMBER, value: '3', position: 2 }); + }); + + it('should tokenize = as EQUAL', () => { + const tokens = lex('=1'); + + expect(tokens).toHaveLength(3); + expect(tokens[0]).toEqual({ type: TokenType.EQUAL, value: '=', position: 0 }); + expect(tokens[1]).toEqual({ type: TokenType.NUMBER, value: '1', position: 1 }); + }); + + it('should not conflate > = (with space) as >=', () => { + const tokens = lex('> ='); + + expect(tokens).toHaveLength(3); + expect(tokens[0]).toEqual({ type: TokenType.GREATER, value: '>', position: 0 }); + expect(tokens[1]).toEqual({ type: TokenType.EQUAL, value: '=', position: 2 }); + }); + }); + + describe('explode operators', () => { + it('should tokenize ! as EXPLODE', () => { + const tokens = lex('!'); + + expect(tokens).toHaveLength(2); + expect(tokens[0]).toEqual({ type: TokenType.EXPLODE, value: '!', position: 0 }); + }); + + it('should tokenize !! as EXPLODE_COMPOUND (maximal munch)', () => { + const tokens = lex('!!'); + + expect(tokens).toHaveLength(2); + expect(tokens[0]).toEqual({ type: TokenType.EXPLODE_COMPOUND, value: '!!', position: 0 }); + }); + + it('should tokenize !p as EXPLODE_PENETRATING', () => { + const tokens = lex('!p'); + + expect(tokens).toHaveLength(2); + expect(tokens[0]).toEqual({ type: TokenType.EXPLODE_PENETRATING, value: '!p', position: 0 }); + }); + + it('should be case-insensitive for !P', () => { + const tokens = lex('!P'); + + expect(tokens).toHaveLength(2); + expect(tokens[0]).toEqual({ type: TokenType.EXPLODE_PENETRATING, value: '!p', position: 0 }); + }); + + it('should tokenize 1d6! as dice + explode', () => { + const tokens = lex('1d6!'); + + expect(tokens).toHaveLength(5); + expect(tokens[0]?.type).toBe(TokenType.NUMBER); + expect(tokens[1]?.type).toBe(TokenType.DICE); + expect(tokens[2]?.type).toBe(TokenType.NUMBER); + expect(tokens[3]?.type).toBe(TokenType.EXPLODE); + }); + + it('should tokenize 1d6!>5 as dice + explode + comparison', () => { + const tokens = lex('1d6!>5'); + + expect(tokens).toHaveLength(7); + expect(tokens[3]?.type).toBe(TokenType.EXPLODE); + expect(tokens[4]?.type).toBe(TokenType.GREATER); + expect(tokens[5]).toEqual({ type: TokenType.NUMBER, value: '5', position: 5 }); + }); + }); + + describe('reroll tokens', () => { + it('should tokenize r as REROLL', () => { + const tokens = lex('r'); + + expect(tokens).toHaveLength(2); + expect(tokens[0]).toEqual({ type: TokenType.REROLL, value: 'r', position: 0 }); + }); + + it('should tokenize ro as REROLL_ONCE (maximal munch)', () => { + const tokens = lex('ro'); + + expect(tokens).toHaveLength(2); + expect(tokens[0]).toEqual({ type: TokenType.REROLL_ONCE, value: 'ro', position: 0 }); + }); + + it('should tokenize r<2 as REROLL + LESS + NUMBER', () => { + const tokens = lex('r<2'); + + expect(tokens).toHaveLength(4); + expect(tokens[0]?.type).toBe(TokenType.REROLL); + expect(tokens[1]?.type).toBe(TokenType.LESS); + expect(tokens[2]).toEqual({ type: TokenType.NUMBER, value: '2', position: 2 }); + }); + + it('should tokenize ro>=3 as REROLL_ONCE + GREATER_EQUAL + NUMBER', () => { + const tokens = lex('ro>=3'); + + expect(tokens).toHaveLength(4); + expect(tokens[0]?.type).toBe(TokenType.REROLL_ONCE); + expect(tokens[1]?.type).toBe(TokenType.GREATER_EQUAL); + expect(tokens[2]).toEqual({ type: TokenType.NUMBER, value: '3', position: 4 }); + }); + }); + + describe('percentile dice', () => { + it('should tokenize d% as DICE_PERCENT', () => { + const tokens = lex('d%'); + + expect(tokens).toHaveLength(2); + expect(tokens[0]).toEqual({ type: TokenType.DICE_PERCENT, value: 'd%', position: 0 }); + }); + + it('should tokenize 2d% as NUMBER + DICE_PERCENT', () => { + const tokens = lex('2d%'); + + expect(tokens).toHaveLength(3); + expect(tokens[0]).toEqual({ type: TokenType.NUMBER, value: '2', position: 0 }); + expect(tokens[1]).toEqual({ type: TokenType.DICE_PERCENT, value: 'd%', position: 1 }); + }); + + it('should not confuse standalone % with d%', () => { + const tokens = lex('10%3'); + + expect(tokens).toHaveLength(4); + expect(tokens[0]?.type).toBe(TokenType.NUMBER); + expect(tokens[1]?.type).toBe(TokenType.MODULO); + expect(tokens[2]?.type).toBe(TokenType.NUMBER); + }); + }); + + describe('fate dice', () => { + it('should tokenize dF as DICE_FATE', () => { + const tokens = lex('dF'); + + expect(tokens).toHaveLength(2); + expect(tokens[0]).toEqual({ type: TokenType.DICE_FATE, value: 'df', position: 0 }); + }); + + it('should be case-insensitive for df', () => { + const tokens = lex('df'); + + expect(tokens).toHaveLength(2); + expect(tokens[0]).toEqual({ type: TokenType.DICE_FATE, value: 'df', position: 0 }); + }); + + it('should tokenize 4dF as NUMBER + DICE_FATE', () => { + const tokens = lex('4dF'); + + expect(tokens).toHaveLength(3); + expect(tokens[0]).toEqual({ type: TokenType.NUMBER, value: '4', position: 0 }); + expect(tokens[1]).toEqual({ type: TokenType.DICE_FATE, value: 'df', position: 1 }); + }); + + it('should not confuse dF with dh/dl', () => { + expect(lex('dh')[0]?.type).toBe(TokenType.DROP_HIGH); + expect(lex('dl')[0]?.type).toBe(TokenType.DROP_LOW); + expect(lex('dF')[0]?.type).toBe(TokenType.DICE_FATE); + }); + }); + + describe('fail token', () => { + it('should tokenize f as FAIL', () => { + const tokens = lex('f1'); + + expect(tokens).toHaveLength(3); + expect(tokens[0]).toEqual({ type: TokenType.FAIL, value: 'f', position: 0 }); + expect(tokens[1]).toEqual({ type: TokenType.NUMBER, value: '1', position: 1 }); + }); + }); + + describe('function tokens', () => { + it('should tokenize floor as FUNCTION', () => { + const tokens = lex('floor'); + + expect(tokens).toHaveLength(2); + expect(tokens[0]).toEqual({ type: TokenType.FUNCTION, value: 'floor', position: 0 }); + }); + + it('should tokenize all math functions', () => { + for (const name of ['floor', 'ceil', 'round', 'abs', 'max', 'min']) { + const tokens = lex(name); + expect(tokens[0]?.type).toBe(TokenType.FUNCTION); + expect(tokens[0]?.value).toBe(name); + } + }); + + it('should be case-insensitive for functions', () => { + expect(lex('FLOOR')[0]?.type).toBe(TokenType.FUNCTION); + expect(lex('Floor')[0]?.type).toBe(TokenType.FUNCTION); + expect(lex('CEIL')[0]?.type).toBe(TokenType.FUNCTION); + }); + + it('should resolve d-in-round: round is FUNCTION, not DICE', () => { + const tokens = lex('round'); + + expect(tokens).toHaveLength(2); + expect(tokens[0]?.type).toBe(TokenType.FUNCTION); + expect(tokens[0]?.value).toBe('round'); + }); + + it('should not confuse function names with dice/modifiers', () => { + // 'd' alone is still DICE + expect(lex('d')[0]?.type).toBe(TokenType.DICE); + // 'k' alone is still KEEP_HIGH + expect(lex('k')[0]?.type).toBe(TokenType.KEEP_HIGH); + // 'kh' is still KEEP_HIGH + expect(lex('kh')[0]?.type).toBe(TokenType.KEEP_HIGH); + }); + }); + + describe('comma token', () => { + it('should tokenize , as COMMA', () => { + const tokens = lex('1,2'); + + expect(tokens).toHaveLength(4); + expect(tokens[0]?.type).toBe(TokenType.NUMBER); + expect(tokens[1]).toEqual({ type: TokenType.COMMA, value: ',', position: 1 }); + expect(tokens[2]?.type).toBe(TokenType.NUMBER); + }); + }); + + describe('vs token', () => { + it('should tokenize vs as VS', () => { + const tokens = lex('vs'); + + expect(tokens).toHaveLength(2); + expect(tokens[0]).toEqual({ type: TokenType.VS, value: 'vs', position: 0 }); + }); + + it('should be case-insensitive for VS', () => { + expect(lex('VS')[0]?.type).toBe(TokenType.VS); + expect(lex('Vs')[0]?.type).toBe(TokenType.VS); + }); + + it('should tokenize 1d20+10 vs 25', () => { + const tokens = lex('1d20+10 vs 25'); + + expect(tokens).toHaveLength(8); + expect(tokens[5]).toEqual({ type: TokenType.VS, value: 'vs', position: 8 }); + expect(tokens[6]).toEqual({ type: TokenType.NUMBER, value: '25', position: 11 }); + }); + }); + describe('edge cases', () => { it('should tokenize 0d6 (zero count dice)', () => { const tokens = lex('0d6'); @@ -289,14 +561,15 @@ describe('Lexer', () => { it('should throw for unexpected identifier', () => { expect(() => lex('2d20x')).toThrow(LexerError); + expect(() => lex('xyz')).toThrow(LexerError); }); - it('should include character in error message', () => { + it('should include identifier in error message', () => { try { lex('abc'); } catch (e) { expect(e).toBeInstanceOf(LexerError); - expect((e as LexerError).message).toContain('a'); + expect((e as LexerError).message).toContain('abc'); } }); }); diff --git a/src/lexer/lexer.ts b/src/lexer/lexer.ts index 683f0d1..5d00d11 100644 --- a/src/lexer/lexer.ts +++ b/src/lexer/lexer.ts @@ -23,6 +23,26 @@ export class LexerError extends RollParserError { } } +/** Known identifier keywords mapped to their token types. */ +const IDENTIFIER_KEYWORDS: Record = { + kh: TokenType.KEEP_HIGH, + kl: TokenType.KEEP_LOW, + k: TokenType.KEEP_HIGH, + dh: TokenType.DROP_HIGH, + dl: TokenType.DROP_LOW, + d: TokenType.DICE, + r: TokenType.REROLL, + ro: TokenType.REROLL_ONCE, + f: TokenType.FAIL, + vs: TokenType.VS, + floor: TokenType.FUNCTION, + ceil: TokenType.FUNCTION, + round: TokenType.FUNCTION, + abs: TokenType.FUNCTION, + max: TokenType.FUNCTION, + min: TokenType.FUNCTION, +}; + /** * Lexer for dice notation. * @@ -70,7 +90,7 @@ export class Lexer { return this.scanNumber(); } - // * Identifiers and modifiers (d, k, kh, kl, dh, dl) + // * Identifiers (d, kh, kl, dh, dl, r, ro, f, vs, floor, ceil, ...) if (this.isAlpha(char)) { return this.scanIdentifier(); } @@ -98,6 +118,29 @@ export class Lexer { return this.createTokenAt(TokenType.LPAREN, char, startPos); case ')': return this.createTokenAt(TokenType.RPAREN, char, startPos); + case ',': + return this.createTokenAt(TokenType.COMMA, char, startPos); + case '>': + if (this.match('=')) { + return this.createTokenAt(TokenType.GREATER_EQUAL, '>=', startPos); + } + return this.createTokenAt(TokenType.GREATER, char, startPos); + case '<': + if (this.match('=')) { + return this.createTokenAt(TokenType.LESS_EQUAL, '<=', startPos); + } + return this.createTokenAt(TokenType.LESS, char, startPos); + case '=': + return this.createTokenAt(TokenType.EQUAL, char, startPos); + case '!': + if (this.match('!')) { + return this.createTokenAt(TokenType.EXPLODE_COMPOUND, '!!', startPos); + } + if (!this.isAtEnd() && this.peek().toLowerCase() === 'p') { + this.advance(); + return this.createTokenAt(TokenType.EXPLODE_PENETRATING, '!p', startPos); + } + return this.createTokenAt(TokenType.EXPLODE, char, startPos); default: throw new LexerError('Unexpected character', 'UNEXPECTED_CHARACTER', startPos, char); } @@ -131,43 +174,40 @@ export class Lexer { return this.createTokenAt(TokenType.NUMBER, value, startPos); } + /** + * Scans an identifier using full-accumulation: collects all consecutive + * alpha characters, then classifies the result against known keywords. + * + * Special case: bare 'd' followed by '%' produces DICE_PERCENT. + */ private scanIdentifier(): Token { const startPos = this.pos; - const char = this.advance().toLowerCase(); + let value = ''; - // Check for two-character modifiers first (maximal munch) - if (!this.isAtEnd()) { - const nextChar = this.peek().toLowerCase(); - const twoChar = char + nextChar; + while (!this.isAtEnd() && this.isAlpha(this.peek())) { + value += this.advance(); + } - if (twoChar === 'kh') { - this.advance(); - return this.createTokenAt(TokenType.KEEP_HIGH, twoChar, startPos); - } - if (twoChar === 'kl') { - this.advance(); - return this.createTokenAt(TokenType.KEEP_LOW, twoChar, startPos); - } - if (twoChar === 'dh') { - this.advance(); - return this.createTokenAt(TokenType.DROP_HIGH, twoChar, startPos); - } - if (twoChar === 'dl') { - this.advance(); - return this.createTokenAt(TokenType.DROP_LOW, twoChar, startPos); - } + const lower = value.toLowerCase(); + + // Special case: d% (percentile dice) — '%' is not alpha, so it's not + // accumulated above. Check after accumulation if we got bare 'd'. + if (lower === 'd' && !this.isAtEnd() && this.peek() === '%') { + this.advance(); + return this.createTokenAt(TokenType.DICE_PERCENT, 'd%', startPos); } - // Single character tokens - if (char === 'd') { - return this.createTokenAt(TokenType.DICE, char, startPos); + // Special case: dF — accumulated as 'df', maps to DICE_FATE + if (lower === 'df') { + return this.createTokenAt(TokenType.DICE_FATE, lower, startPos); } - if (char === 'k') { - // 'k' alone is shorthand for 'kh' - return this.createTokenAt(TokenType.KEEP_HIGH, char, startPos); + + const tokenType = IDENTIFIER_KEYWORDS[lower]; + if (tokenType != null) { + return this.createTokenAt(tokenType, lower, startPos); } - throw new LexerError('Unexpected identifier', 'UNEXPECTED_IDENTIFIER', startPos, char); + throw new LexerError('Unexpected identifier', 'UNEXPECTED_IDENTIFIER', startPos, lower); } private peek(): string { diff --git a/src/lexer/tokens.ts b/src/lexer/tokens.ts index 4530bfb..1abe38f 100644 --- a/src/lexer/tokens.ts +++ b/src/lexer/tokens.ts @@ -5,41 +5,133 @@ */ /** - * Token types for Stage 1 dice notation. + * Token types for dice notation. * - * Using numeric enum values for efficient comparisons and switch statements. + * Grouped semantically. Numeric values are stable identifiers — the specific + * numbers don't matter, but they must be unique. */ export enum TokenType { + // + // * Literals + // + /** Numeric literal: integer or decimal */ NUMBER = 0, + + // + // * Dice operators + // + /** Dice operator: 'd' or 'D' */ DICE = 1, + /** Percentile dice operator: 'd%' (alias for d100) */ + DICE_PERCENT = 2, + /** Fate/Fudge dice operator: 'dF' */ + DICE_FATE = 3, + + // + // * Arithmetic operators + // + /** Addition operator: '+' */ - PLUS = 2, + PLUS = 4, /** Subtraction operator: '-' */ - MINUS = 3, + MINUS = 5, /** Multiplication operator: '*' */ - MULTIPLY = 4, + MULTIPLY = 6, /** Division operator: '/' */ - DIVIDE = 5, + DIVIDE = 7, /** Modulo operator: '%' */ - MODULO = 6, + MODULO = 8, /** Power operator: '**' or '^' */ - POWER = 7, + POWER = 9, + + // + // * Comparison operators + // + + /** Greater than: '>' */ + GREATER = 10, + /** Greater than or equal: '>=' */ + GREATER_EQUAL = 11, + /** Less than: '<' */ + LESS = 12, + /** Less than or equal: '<=' */ + LESS_EQUAL = 13, + /** Equal: '=' */ + EQUAL = 14, + + // + // * Grouping and punctuation + // + /** Left parenthesis: '(' */ - LPAREN = 8, + LPAREN = 15, /** Right parenthesis: ')' */ - RPAREN = 9, + RPAREN = 16, + /** Argument separator: ',' */ + COMMA = 17, + + // + // * Keep/drop modifiers + // + /** Keep highest modifier: 'kh' or 'k' */ - KEEP_HIGH = 10, + KEEP_HIGH = 18, /** Keep lowest modifier: 'kl' */ - KEEP_LOW = 11, + KEEP_LOW = 19, /** Drop highest modifier: 'dh' */ - DROP_HIGH = 12, + DROP_HIGH = 20, /** Drop lowest modifier: 'dl' */ - DROP_LOW = 13, + DROP_LOW = 21, + + // + // * Explode modifiers + // + + /** Standard explode: '!' */ + EXPLODE = 22, + /** Compounding explode: '!!' */ + EXPLODE_COMPOUND = 23, + /** Penetrating explode: '!p' */ + EXPLODE_PENETRATING = 24, + + // + // * Reroll modifiers + // + + /** Recursive reroll: 'r' */ + REROLL = 25, + /** Reroll once: 'ro' */ + REROLL_ONCE = 26, + + // + // * Success counting + // + + /** Fail marker: 'f' */ + FAIL = 27, + + // + // * Functions + // + + /** Math function: 'floor', 'ceil', 'round', 'abs', 'max', 'min' */ + FUNCTION = 28, + + // + // * Keywords + // + + /** Versus operator: 'vs' */ + VS = 29, + + // + // * End of input + // + /** End of input marker */ - EOF = 14, + EOF = 30, } /** @@ -48,7 +140,7 @@ export enum TokenType { export type Token = { /** The type of this token */ type: TokenType; - /** The raw string value from input */ + /** The raw string value from input (lowercased for identifiers) */ value: string; /** Zero-based position in the input string */ position: number; diff --git a/src/parser/parser.ts b/src/parser/parser.ts index f28f677..e76430b 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -8,6 +8,7 @@ import type { RollParserErrorCode } from '../errors'; import { RollParserError } from '../errors'; import { lex } from '../lexer/lexer'; import { type Token, TokenType } from '../lexer/tokens'; +import type { CompareOp, ComparePoint } from '../types'; import type { ASTNode, BinaryOpNode, @@ -268,6 +269,62 @@ export class Parser { }; } + // * Compare point utilities + + /** + * Checks whether the next token is a comparison operator. + */ + isComparePointAhead(): boolean { + const type = this.peek().type; + return ( + type === TokenType.GREATER || + type === TokenType.GREATER_EQUAL || + type === TokenType.LESS || + type === TokenType.LESS_EQUAL || + type === TokenType.EQUAL + ); + } + + /** + * Parses a comparison operator followed by a value expression. + * Called by modifier parsers (explode, reroll, success counting). + * + * @returns A ComparePoint with the operator and value AST node + * @throws {ParseError} If the next token is not a comparison operator + */ + parseComparePoint(): ComparePoint { + const token = this.peek(); + const operator = this.getCompareOp(token); + + this.advance(); + + const value = this.parseExpression(BP.DICE_LEFT); + + return { operator, value }; + } + + private getCompareOp(token: Token): CompareOp { + switch (token.type) { + case TokenType.GREATER: + return '>'; + case TokenType.GREATER_EQUAL: + return '>='; + case TokenType.LESS: + return '<'; + case TokenType.LESS_EQUAL: + return '<='; + case TokenType.EQUAL: + return '='; + default: + throw new ParseError( + `Expected comparison operator but got '${token.value}'`, + 'EXPECTED_TOKEN', + token.position, + token, + ); + } + } + // * Helpers private getOperatorSymbol(token: Token): '+' | '-' | '*' | '/' | '%' | '**' { @@ -314,7 +371,17 @@ export class Parser { return BP.MODIFIER; case TokenType.RPAREN: case TokenType.EOF: - // Terminators have negative BP to always break the expression loop + // Comparison operators terminate the current expression — they are + // consumed by modifier parsers (explode, reroll, success counting), + // not by the main Pratt loop. + case TokenType.GREATER: + case TokenType.GREATER_EQUAL: + case TokenType.LESS: + case TokenType.LESS_EQUAL: + case TokenType.EQUAL: + // Punctuation and keywords that terminate expressions + case TokenType.COMMA: + case TokenType.FUNCTION: return -1; default: return 0; diff --git a/src/types.ts b/src/types.ts index ec952fe..4816937 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,27 @@ /** - * Shared type definitions for roll results. + * Shared type definitions for roll results and comparison primitives. * * @module types */ +import type { ASTNode } from './parser/ast'; + +/** + * Comparison operator for compare points. + */ +export type CompareOp = '>' | '>=' | '<' | '<=' | '='; + +/** + * A comparison threshold used by exploding dice, reroll, and success counting. + * + * The value is an ASTNode to support computed thresholds (e.g., `>=ceil(5)`), + * matching the pattern used by DiceNode.count and DiceNode.sides. + */ +export type ComparePoint = { + operator: CompareOp; + value: ASTNode; +}; + /** * Modifier flags applied to individual die results. */