From b111ba9cec6d0a71c1469cda8df728c5ec7e684f Mon Sep 17 00:00:00 2001 From: Mikita Taukachou Date: Mon, 5 Jan 2026 12:15:03 +0300 Subject: [PATCH] feat: implement AST evaluator with modifiers #6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the evaluator module that transforms parsed AST into roll results: **Core Implementation**: - Add RollResult and DieResult types in src/types.ts - Implement keep/drop modifiers (kh, kl, dh, dl) - Implement recursive AST evaluation with RNG injection - Track critical/fumble detection on individual dice - Ensure negative results are NOT clamped (1d4-5 = -4) **Parser Bug Fix**: - Fix modifier chaining: 4d6dl1kh3 now parses as (4d6dl1)kh3 - Change binding power from BP.MODIFIER to BP.DICE_LEFT - Prevents modifiers in count position while allowing computed counts - Satisfies PRD requirement for modifier chaining (line 478) **Tests**: - 37 evaluator unit tests (using MockRNG for determinism) - 5 parser tests for modifier chaining - Total: 186 tests passing (all green) **Documentation**: - Document evaluator limitation: sequential vs Roll20 behavior - Add gitignore entries for package-lock.json and pnpm-lock.yaml - Created follow-up issue #12 for evaluator refactor (Stage 2/3) **Validation**: All checks pass (typecheck, lint, format, build, test) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 2 + src/evaluator/evaluator.test.ts | 394 +++++++++++++++++++++++++++ src/evaluator/evaluator.ts | 272 ++++++++++++++++++ src/evaluator/index.ts | 14 + src/evaluator/modifiers/keep-drop.ts | 153 +++++++++++ src/index.ts | 7 +- src/parser/parser.test.ts | 67 +++++ src/parser/parser.ts | 11 +- src/types.ts | 50 ++++ 9 files changed, 964 insertions(+), 6 deletions(-) create mode 100644 src/evaluator/evaluator.test.ts create mode 100644 src/evaluator/evaluator.ts create mode 100644 src/evaluator/index.ts create mode 100644 src/evaluator/modifiers/keep-drop.ts create mode 100644 src/types.ts diff --git a/.gitignore b/.gitignore index 5b5570d..a1d5268 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ api/ # Dependencies node_modules/ +package-lock.json +pnpm-lock.yaml # OS files .DS_Store diff --git a/src/evaluator/evaluator.test.ts b/src/evaluator/evaluator.test.ts new file mode 100644 index 0000000..b771766 --- /dev/null +++ b/src/evaluator/evaluator.test.ts @@ -0,0 +1,394 @@ +/** + * Tests for the AST evaluator. + * + * @module evaluator/evaluator.test + */ + +import { describe, expect, test } from 'bun:test'; +import { parse } from '../parser/parser'; +import { createMockRng } from '../rng/mock'; +import type { DieResult } from '../types'; +import { evaluate, EvaluatorError } from './evaluator'; + +/** + * Helper to safely get a die result at index, throwing if not present. + */ +function getDie(rolls: DieResult[], index: number): DieResult { + const die = rolls[index]; + if (!die) { + throw new Error(`Expected die at index ${index}, but only ${rolls.length} dice found`); + } + return die; +} + +describe('evaluate', () => { + describe('literals', () => { + test('evaluates integer literal', () => { + const ast = parse('42'); + const rng = createMockRng([]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(42); + expect(result.rolls).toHaveLength(0); + }); + + test('evaluates decimal literal', () => { + const ast = parse('3.14'); + const rng = createMockRng([]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(3.14); + }); + }); + + describe('basic dice', () => { + test('evaluates single die roll', () => { + const ast = parse('1d6'); + const rng = createMockRng([4]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(4); + expect(result.rolls).toHaveLength(1); + expect(getDie(result.rolls, 0).sides).toBe(6); + expect(getDie(result.rolls, 0).result).toBe(4); + }); + + test('evaluates multiple dice', () => { + const ast = parse('3d6'); + const rng = createMockRng([3, 4, 5]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(12); + expect(result.rolls).toHaveLength(3); + }); + + test('evaluates implicit count d20', () => { + const ast = parse('d20'); + const rng = createMockRng([15]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(15); + expect(result.rolls).toHaveLength(1); + expect(getDie(result.rolls, 0).sides).toBe(20); + }); + }); + + describe('arithmetic operations', () => { + test('addition', () => { + const ast = parse('1d6 + 3'); + const rng = createMockRng([4]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(7); + }); + + test('subtraction', () => { + const ast = parse('1d6 - 2'); + const rng = createMockRng([5]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(3); + }); + + test('multiplication', () => { + const ast = parse('1d6 * 2'); + const rng = createMockRng([3]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(6); + }); + + test('division', () => { + const ast = parse('1d6 / 2'); + const rng = createMockRng([6]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(3); + }); + + test('modulo', () => { + const ast = parse('1d10 % 3'); + const rng = createMockRng([7]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(1); + }); + + test('exponentiation', () => { + const ast = parse('2 ** 3'); + const rng = createMockRng([]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(8); + }); + + test('complex expression with precedence', () => { + const ast = parse('1d6 + 2 * 3'); + const rng = createMockRng([4]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(10); // 4 + (2 * 3) = 10 + }); + + test('parenthesized expression', () => { + const ast = parse('(1d4 + 1) * 2'); + const rng = createMockRng([3]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(8); // (3 + 1) * 2 = 8 + }); + }); + + describe('negative numbers - CRITICAL', () => { + test('negative result is NOT clamped to zero', () => { + const ast = parse('1d4 - 5'); + const rng = createMockRng([1]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(-4); // 1 - 5 = -4, NOT 0! + }); + + test('unary minus on dice', () => { + const ast = parse('-1d4'); + const rng = createMockRng([3]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(-3); + }); + + test('binary minus equivalent to unary', () => { + const ast1 = parse('-1d4'); + const ast2 = parse('0 - 1d4'); + const rng1 = createMockRng([3]); + const rng2 = createMockRng([3]); + + expect(evaluate(ast1, rng1).total).toBe(-3); + expect(evaluate(ast2, rng2).total).toBe(-3); + }); + + test('negative literal', () => { + const ast = parse('-5'); + const rng = createMockRng([]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(-5); + }); + }); + + describe('keep highest modifier', () => { + test('keeps highest die from pool', () => { + const ast = parse('2d20kh1'); + const rng = createMockRng([7, 15]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(15); + expect(result.rolls).toHaveLength(2); + expect(getDie(result.rolls, 0).modifiers).toContain('dropped'); + expect(getDie(result.rolls, 1).modifiers).toContain('kept'); + }); + + test('advantage roll (2d20kh1)', () => { + const ast = parse('2d20kh1'); + const rng = createMockRng([12, 18]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(18); + }); + + test('keep multiple highest', () => { + const ast = parse('4d6kh3'); + const rng = createMockRng([3, 5, 2, 6]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(14); // 5 + 3 + 6 = 14 + expect(result.rolls.filter((r) => r.modifiers.includes('dropped'))).toHaveLength(1); + }); + }); + + describe('keep lowest modifier', () => { + test('keeps lowest die from pool', () => { + const ast = parse('2d20kl1'); + const rng = createMockRng([15, 7]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(7); + expect(getDie(result.rolls, 0).modifiers).toContain('dropped'); + expect(getDie(result.rolls, 1).modifiers).toContain('kept'); + }); + + test('disadvantage roll (2d20kl1)', () => { + const ast = parse('2d20kl1'); + const rng = createMockRng([18, 12]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(12); + }); + }); + + describe('drop lowest modifier', () => { + test('drops lowest die from pool', () => { + const ast = parse('4d6dl1'); + const rng = createMockRng([4, 2, 5, 3]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(12); // 4 + 5 + 3 = 12 + expect(getDie(result.rolls, 1).modifiers).toContain('dropped'); + }); + + test('stat generation (4d6dl1)', () => { + const ast = parse('4d6dl1'); + const rng = createMockRng([3, 5, 6, 2]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(14); // 3 + 5 + 6 = 14, drop 2 + }); + }); + + describe('drop highest modifier', () => { + test('drops highest die from pool', () => { + const ast = parse('4d6dh1'); + const rng = createMockRng([4, 2, 6, 3]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(9); // 4 + 2 + 3 = 9 + expect(getDie(result.rolls, 2).modifiers).toContain('dropped'); + }); + }); + + describe('critical and fumble detection', () => { + test('detects critical (max value)', () => { + const ast = parse('1d20'); + const rng = createMockRng([20]); + const result = evaluate(ast, rng); + + expect(getDie(result.rolls, 0).critical).toBe(true); + expect(getDie(result.rolls, 0).fumble).toBe(false); + }); + + test('detects fumble (rolled 1)', () => { + const ast = parse('1d20'); + const rng = createMockRng([1]); + const result = evaluate(ast, rng); + + expect(getDie(result.rolls, 0).fumble).toBe(true); + expect(getDie(result.rolls, 0).critical).toBe(false); + }); + + test('normal roll has no critical or fumble', () => { + const ast = parse('1d20'); + const rng = createMockRng([10]); + const result = evaluate(ast, rng); + + expect(getDie(result.rolls, 0).critical).toBe(false); + expect(getDie(result.rolls, 0).fumble).toBe(false); + }); + + test('critical on d6', () => { + const ast = parse('1d6'); + const rng = createMockRng([6]); + const result = evaluate(ast, rng); + + expect(getDie(result.rolls, 0).critical).toBe(true); + }); + }); + + describe('result metadata', () => { + test('includes notation in result', () => { + const ast = parse('2d6+3'); + const rng = createMockRng([4, 5]); + const result = evaluate(ast, rng, { notation: '2d6+3' }); + + expect(result.notation).toBe('2d6+3'); + }); + + test('includes expression', () => { + const ast = parse('2d6 + 3'); + const rng = createMockRng([4, 5]); + const result = evaluate(ast, rng); + + expect(result.expression).toContain('2d6'); + expect(result.expression).toContain('+'); + expect(result.expression).toContain('3'); + }); + + test('includes rendered output with individual rolls', () => { + const ast = parse('2d6'); + const rng = createMockRng([4, 5]); + const result = evaluate(ast, rng); + + expect(result.rendered).toContain('[4, 5]'); + expect(result.rendered).toContain('= 9'); + }); + + test('rendered output shows dropped dice with strikethrough', () => { + const ast = parse('2d20kh1'); + const rng = createMockRng([7, 15]); + const result = evaluate(ast, rng); + + expect(result.rendered).toContain('~~7~~'); + expect(result.rendered).toContain('15'); + }); + }); + + describe('edge cases', () => { + test('zero dice count', () => { + const ast = parse('0d6'); + const rng = createMockRng([]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(0); + expect(result.rolls).toHaveLength(0); + }); + + test('computed dice count', () => { + const ast = parse('(1+1)d6'); + const rng = createMockRng([3, 4]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(7); + expect(result.rolls).toHaveLength(2); + }); + + test('computed dice sides', () => { + const ast = parse('1d(2*3)'); + const rng = createMockRng([5]); + const result = evaluate(ast, rng); + + expect(getDie(result.rolls, 0).sides).toBe(6); + }); + + test('division by zero throws', () => { + const ast = parse('1d6 / 0'); + const rng1 = createMockRng([3]); + const rng2 = createMockRng([3]); + + expect(() => evaluate(ast, rng1)).toThrow(EvaluatorError); + expect(() => evaluate(ast, rng2)).toThrow('Division by zero'); + }); + + test('modulo by zero throws', () => { + const ast = parse('1d6 % 0'); + const rng = createMockRng([3]); + + expect(() => evaluate(ast, rng)).toThrow('Modulo by zero'); + }); + + test('keeps all dice when keep count exceeds pool', () => { + const ast = parse('2d6kh5'); + const rng = createMockRng([3, 4]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(7); + expect(result.rolls.every((r) => r.modifiers.includes('kept'))).toBe(true); + }); + + test('drops all dice when drop count equals pool', () => { + const ast = parse('2d6dl2'); + const rng = createMockRng([3, 4]); + const result = evaluate(ast, rng); + + expect(result.total).toBe(0); + expect(result.rolls.every((r) => r.modifiers.includes('dropped'))).toBe(true); + }); + }); +}); diff --git a/src/evaluator/evaluator.ts b/src/evaluator/evaluator.ts new file mode 100644 index 0000000..58bc630 --- /dev/null +++ b/src/evaluator/evaluator.ts @@ -0,0 +1,272 @@ +/** + * AST evaluator - transforms parsed AST into roll results. + * + * @module evaluator/evaluator + */ + +import type { ASTNode, BinaryOpNode, DiceNode, ModifierNode, UnaryOpNode } from '../parser/ast'; +import type { RNG } from '../rng/types'; +import type { DieResult, EvaluateOptions, RollResult } from '../types'; +import { + applyDropHighest, + applyDropLowest, + applyKeepHighest, + applyKeepLowest, + markAllKept, + sumKeptDice, +} from './modifiers/keep-drop'; + +/** + * Error thrown during AST evaluation. + */ +export class EvaluatorError extends Error { + constructor(message: string) { + super(message); + this.name = 'EvaluatorError'; + } +} + +/** + * Internal evaluation context for tracking state during recursion. + */ +type EvalContext = { + rolls: DieResult[]; + expressionParts: string[]; + renderedParts: string[]; +}; + +/** + * Creates a new die result with critical/fumble detection. + */ +function createDieResult(sides: number, result: number): DieResult { + return { + sides, + result, + modifiers: [], + critical: result === sides, + fumble: result === 1, + }; +} + +/** + * Renders dice results for display (e.g., "[3, 5, 2]" or "[~~3~~, 5, ~~2~~]"). + */ +function renderDice(dice: DieResult[]): string { + const parts = dice.map((die) => { + if (die.modifiers.includes('dropped')) { + return `~~${die.result}~~`; + } + return String(die.result); + }); + return `[${parts.join(', ')}]`; +} + +/** + * Evaluates an AST node, returning value and updating context. + */ +function evalNode(node: ASTNode, rng: RNG, ctx: EvalContext): number { + switch (node.type) { + case 'Literal': + return evalLiteral(node.value, ctx); + + case 'Dice': + return evalDice(node, rng, ctx); + + case 'BinaryOp': + return evalBinaryOp(node, rng, ctx); + + case 'UnaryOp': + return evalUnaryOp(node, rng, ctx); + + case 'Modifier': + return evalModifier(node, rng, ctx); + + default: { + const exhaustive: never = node; + throw new EvaluatorError(`Unknown node type: ${(exhaustive as ASTNode).type}`); + } + } +} + +function evalLiteral(value: number, ctx: EvalContext): number { + ctx.expressionParts.push(String(value)); + ctx.renderedParts.push(String(value)); + return value; +} + +function evalDice(node: DiceNode, rng: RNG, ctx: EvalContext): number { + const count = evalNode(node.count, rng, { rolls: [], expressionParts: [], renderedParts: [] }); + const sides = evalNode(node.sides, rng, { rolls: [], expressionParts: [], renderedParts: [] }); + + if (!Number.isInteger(count) || count < 0) { + throw new EvaluatorError(`Invalid dice count: ${count}`); + } + if (!Number.isInteger(sides) || sides < 1) { + throw new EvaluatorError(`Invalid dice sides: ${sides}`); + } + + const dice: DieResult[] = []; + for (let i = 0; i < count; i++) { + const result = rng.nextInt(1, sides); + dice.push(createDieResult(sides, result)); + } + + const markedDice = markAllKept(dice); + ctx.rolls.push(...markedDice); + + const total = sumKeptDice(markedDice); + const notation = `${count}d${sides}`; + + ctx.expressionParts.push(notation); + ctx.renderedParts.push(`${notation}${renderDice(markedDice)}`); + + return total; +} + +function evalBinaryOp(node: BinaryOpNode, rng: RNG, ctx: EvalContext): number { + const leftCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] }; + const rightCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] }; + + const left = evalNode(node.left, rng, leftCtx); + const right = evalNode(node.right, rng, rightCtx); + + ctx.rolls.push(...leftCtx.rolls, ...rightCtx.rolls); + + const leftExpr = leftCtx.expressionParts.join(''); + const rightExpr = rightCtx.expressionParts.join(''); + const leftRendered = leftCtx.renderedParts.join(''); + const rightRendered = rightCtx.renderedParts.join(''); + + ctx.expressionParts.push(`${leftExpr} ${node.operator} ${rightExpr}`); + ctx.renderedParts.push(`${leftRendered} ${node.operator} ${rightRendered}`); + + switch (node.operator) { + case '+': + return left + right; + case '-': + return left - right; + case '*': + return left * right; + case '/': + if (right === 0) { + throw new EvaluatorError('Division by zero'); + } + return left / right; + case '%': + if (right === 0) { + throw new EvaluatorError('Modulo by zero'); + } + return left % right; + case '**': + return left ** right; + default: { + const exhaustive: never = node.operator; + throw new EvaluatorError(`Unknown operator: ${exhaustive}`); + } + } +} + +function evalUnaryOp(node: UnaryOpNode, rng: RNG, ctx: EvalContext): number { + const innerCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] }; + const value = evalNode(node.operand, rng, innerCtx); + + ctx.rolls.push(...innerCtx.rolls); + + const innerExpr = innerCtx.expressionParts.join(''); + const innerRendered = innerCtx.renderedParts.join(''); + + ctx.expressionParts.push(`-${innerExpr}`); + ctx.renderedParts.push(`-${innerRendered}`); + + return -value; +} + +function evalModifier(node: ModifierNode, rng: RNG, ctx: EvalContext): number { + // LIMITATION: Chained modifiers (e.g., 4d6dl1kh3) evaluate sequentially, not per industry standard. + // Current: Inner modifier is fully evaluated, outer sees final result. + // Roll20/RPG Dice Roller: Each modifier sees ALL original dice and can override previous modifiers. + // See: https://dice-roller.github.io/documentation/guide/notation/modifiers.html + // TODO: Refactor to pass dice pools through modifier chain (Stage 2/3 enhancement) + + const countCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] }; + const modCount = evalNode(node.count, rng, countCtx); + + if (!Number.isInteger(modCount) || modCount < 0) { + throw new EvaluatorError(`Invalid modifier count: ${modCount}`); + } + + const targetCtx: EvalContext = { rolls: [], expressionParts: [], renderedParts: [] }; + evalNode(node.target, rng, targetCtx); + + let modifiedDice: DieResult[]; + + if (node.modifier === 'keep') { + if (node.selector === 'highest') { + modifiedDice = applyKeepHighest(targetCtx.rolls, modCount); + } else { + modifiedDice = applyKeepLowest(targetCtx.rolls, modCount); + } + } else { + if (node.selector === 'highest') { + modifiedDice = applyDropHighest(targetCtx.rolls, modCount); + } else { + modifiedDice = applyDropLowest(targetCtx.rolls, modCount); + } + } + + ctx.rolls.push(...modifiedDice); + + const total = sumKeptDice(modifiedDice); + + const targetExpr = targetCtx.expressionParts.join(''); + const modifierCode = + node.modifier === 'keep' + ? node.selector === 'highest' + ? 'kh' + : 'kl' + : node.selector === 'highest' + ? 'dh' + : 'dl'; + + ctx.expressionParts.push(`${targetExpr}${modifierCode}${modCount}`); + ctx.renderedParts.push(`${targetExpr}${renderDice(modifiedDice)}`); + + return total; +} + +/** + * Evaluates a parsed AST and returns the roll result. + * + * @param ast - The parsed AST node + * @param rng - Random number generator to use for dice rolls + * @param options - Optional evaluation options + * @returns Complete roll result with total and metadata + * + * @example + * ```typescript + * const ast = parse('2d6+3'); + * const rng = new SeededRNG('test'); + * const result = evaluate(ast, rng); + * console.log(result.total); // Sum of dice plus 3 + * ``` + */ +export function evaluate(ast: ASTNode, rng: RNG, options: EvaluateOptions = {}): RollResult { + const ctx: EvalContext = { + rolls: [], + expressionParts: [], + renderedParts: [], + }; + + const total = evalNode(ast, rng, ctx); + + const expression = ctx.expressionParts.join(''); + const rendered = `${ctx.renderedParts.join('')} = ${total}`; + + return { + total, + notation: options.notation ?? expression, + expression, + rendered, + rolls: ctx.rolls, + }; +} diff --git a/src/evaluator/index.ts b/src/evaluator/index.ts new file mode 100644 index 0000000..4386883 --- /dev/null +++ b/src/evaluator/index.ts @@ -0,0 +1,14 @@ +/** + * Evaluator module - AST to roll result transformation. + * + * @module evaluator + */ + +export { evaluate, EvaluatorError } from './evaluator'; +export { + applyDropHighest, + applyDropLowest, + applyKeepHighest, + applyKeepLowest, + sumKeptDice, +} from './modifiers/keep-drop'; diff --git a/src/evaluator/modifiers/keep-drop.ts b/src/evaluator/modifiers/keep-drop.ts new file mode 100644 index 0000000..e37f776 --- /dev/null +++ b/src/evaluator/modifiers/keep-drop.ts @@ -0,0 +1,153 @@ +/** + * Keep/drop modifier implementations for dice pools. + * + * @module evaluator/modifiers/keep-drop + */ + +import type { DieResult } from '../../types'; + +/** + * Marks all dice as 'kept' initially (for dice without explicit modifiers). + */ +export function markAllKept(dice: DieResult[]): DieResult[] { + return dice.map((die) => ({ + ...die, + modifiers: die.modifiers.includes('kept') ? die.modifiers : [...die.modifiers, 'kept'], + })); +} + +/** + * Applies keep highest modifier - keeps the N highest dice, marks others as dropped. + * + * @param dice - Array of die results + * @param count - Number of dice to keep + * @returns New array with appropriate modifiers applied + */ +export function applyKeepHighest(dice: DieResult[], count: number): DieResult[] { + if (count >= dice.length) { + return markAllKept(dice); + } + if (count <= 0) { + return dice.map((die) => ({ + ...die, + modifiers: [...die.modifiers.filter((m) => m !== 'kept'), 'dropped'], + })); + } + + const indexed = dice.map((die, index) => ({ die, index })); + indexed.sort((a, b) => b.die.result - a.die.result); + + const keptIndices = new Set(indexed.slice(0, count).map((item) => item.index)); + + return dice.map((die, index) => ({ + ...die, + modifiers: keptIndices.has(index) + ? [...die.modifiers.filter((m) => m !== 'dropped'), 'kept'] + : [...die.modifiers.filter((m) => m !== 'kept'), 'dropped'], + })); +} + +/** + * Applies keep lowest modifier - keeps the N lowest dice, marks others as dropped. + * + * @param dice - Array of die results + * @param count - Number of dice to keep + * @returns New array with appropriate modifiers applied + */ +export function applyKeepLowest(dice: DieResult[], count: number): DieResult[] { + if (count >= dice.length) { + return markAllKept(dice); + } + if (count <= 0) { + return dice.map((die) => ({ + ...die, + modifiers: [...die.modifiers.filter((m) => m !== 'kept'), 'dropped'], + })); + } + + const indexed = dice.map((die, index) => ({ die, index })); + indexed.sort((a, b) => a.die.result - b.die.result); + + const keptIndices = new Set(indexed.slice(0, count).map((item) => item.index)); + + return dice.map((die, index) => ({ + ...die, + modifiers: keptIndices.has(index) + ? [...die.modifiers.filter((m) => m !== 'dropped'), 'kept'] + : [...die.modifiers.filter((m) => m !== 'kept'), 'dropped'], + })); +} + +/** + * Applies drop highest modifier - drops the N highest dice, keeps others. + * + * @param dice - Array of die results + * @param count - Number of dice to drop + * @returns New array with appropriate modifiers applied + */ +export function applyDropHighest(dice: DieResult[], count: number): DieResult[] { + if (count <= 0) { + return markAllKept(dice); + } + if (count >= dice.length) { + return dice.map((die) => ({ + ...die, + modifiers: [...die.modifiers.filter((m) => m !== 'kept'), 'dropped'], + })); + } + + const indexed = dice.map((die, index) => ({ die, index })); + indexed.sort((a, b) => b.die.result - a.die.result); + + const droppedIndices = new Set(indexed.slice(0, count).map((item) => item.index)); + + return dice.map((die, index) => ({ + ...die, + modifiers: droppedIndices.has(index) + ? [...die.modifiers.filter((m) => m !== 'kept'), 'dropped'] + : [...die.modifiers.filter((m) => m !== 'dropped'), 'kept'], + })); +} + +/** + * Applies drop lowest modifier - drops the N lowest dice, keeps others. + * + * @param dice - Array of die results + * @param count - Number of dice to drop + * @returns New array with appropriate modifiers applied + */ +export function applyDropLowest(dice: DieResult[], count: number): DieResult[] { + if (count <= 0) { + return markAllKept(dice); + } + if (count >= dice.length) { + return dice.map((die) => ({ + ...die, + modifiers: [...die.modifiers.filter((m) => m !== 'kept'), 'dropped'], + })); + } + + const indexed = dice.map((die, index) => ({ die, index })); + indexed.sort((a, b) => a.die.result - b.die.result); + + const droppedIndices = new Set(indexed.slice(0, count).map((item) => item.index)); + + return dice.map((die, index) => ({ + ...die, + modifiers: droppedIndices.has(index) + ? [...die.modifiers.filter((m) => m !== 'kept'), 'dropped'] + : [...die.modifiers.filter((m) => m !== 'dropped'), 'kept'], + })); +} + +/** + * Calculates total from dice, excluding dropped dice. + * + * @param dice - Array of die results + * @returns Sum of non-dropped dice + */ +export function sumKeptDice(dice: DieResult[]): number { + return dice + .filter((die) => !die.modifiers.includes('dropped')) + .reduce((sum, die) => sum + die.result, 0); +} diff --git a/src/index.ts b/src/index.ts index 867815f..407f9d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,7 +31,10 @@ export type { RNG } from './rng/types'; export { SeededRNG } from './rng/seeded'; export { createMockRng, MockRNGExhaustedError } from './rng/mock'; -// TODO: [Phase 4] Export evaluator and result types -// TODO: [Phase 5] Export public API (roll, evaluate) +// * Evaluator exports +export { evaluate, EvaluatorError } from './evaluator/evaluator'; +export type { DieModifier, DieResult, EvaluateOptions, RollResult } from './types'; + +// TODO: [Phase 5] Export public API (roll function) export const VERSION = '3.0.0-alpha.0'; diff --git a/src/parser/parser.test.ts b/src/parser/parser.test.ts index e86dfca..4f81d12 100644 --- a/src/parser/parser.test.ts +++ b/src/parser/parser.test.ts @@ -369,6 +369,73 @@ describe('Parser', () => { }); }); + describe('modifier chaining', () => { + it('should parse 4d6dl1kh3 as (4d6dl1)kh3', () => { + // Chained modifiers: drop lowest 1, then keep highest 3 + expect(parse('4d6dl1kh3')).toEqual( + modifier( + 'keep', + 'highest', + literal(3), + modifier('drop', 'lowest', literal(1), dice(literal(4), literal(6))), + ), + ); + }); + + it('should parse 4d6kh3dl1 (reverse order)', () => { + // Reverse order: keep highest 3, then drop lowest 1 + expect(parse('4d6kh3dl1')).toEqual( + modifier( + 'drop', + 'lowest', + literal(1), + modifier('keep', 'highest', literal(3), dice(literal(4), literal(6))), + ), + ); + }); + + it('should parse multiple same modifiers: 4d6dl1dl1', () => { + // Chaining same modifier type (semantically odd but should parse) + expect(parse('4d6dl1dl1')).toEqual( + modifier( + 'drop', + 'lowest', + literal(1), + modifier('drop', 'lowest', literal(1), dice(literal(4), literal(6))), + ), + ); + }); + + it('should allow computed count in chain: 4d6kh(1+2)', () => { + // Computed counts should still work with the fix + expect(parse('4d6kh(1+2)')).toEqual( + modifier( + 'keep', + 'highest', + binary('+', literal(1), literal(2)), + dice(literal(4), literal(6)), + ), + ); + }); + + it('should parse triple chain: 4d6dl1kh3dh1', () => { + // Three modifiers in sequence + expect(parse('4d6dl1kh3dh1')).toEqual( + modifier( + 'drop', + 'highest', + literal(1), + modifier( + 'keep', + 'highest', + literal(3), + modifier('drop', 'lowest', literal(1), dice(literal(4), literal(6))), + ), + ), + ); + }); + }); + describe('Parser class', () => { it('should allow direct usage with tokens', () => { const tokens = lex('2d6'); diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 7a91e97..88011ff 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -234,16 +234,19 @@ export class Parser { ? 'highest' : 'lowest'; - // Count is required after modifier - if (this.peek().type !== TokenType.NUMBER) { + // Count is required after modifier (number or parenthesized expression) + const nextToken = this.peek().type; + if (nextToken !== TokenType.NUMBER && nextToken !== TokenType.LPAREN) { throw new ParseError( - `Expected number after '${token.value}' modifier`, + `Expected number or expression after '${token.value}' modifier`, this.peek().position, this.peek(), ); } - const count = this.parseExpression(BP.MODIFIER); + // Use DICE_LEFT to prevent modifiers from appearing in count position (e.g., 4d6kh1kh3) + // This allows computed counts like 4d6kh(1+2) but prevents nested modifiers + const count = this.parseExpression(BP.DICE_LEFT); return { type: 'Modifier', diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..7d637fd --- /dev/null +++ b/src/types.ts @@ -0,0 +1,50 @@ +/** + * Shared type definitions for roll results. + * + * @module types + */ + +/** + * Modifier flags applied to individual die results. + */ +export type DieModifier = 'dropped' | 'kept' | 'exploded' | 'rerolled'; + +/** + * Individual die roll result with metadata. + */ +export type DieResult = { + /** Number of sides on the die */ + sides: number; + /** The rolled value */ + result: number; + /** Modifiers applied to this die */ + modifiers: DieModifier[]; + /** True if rolled the maximum value */ + critical: boolean; + /** True if rolled 1 */ + fumble: boolean; +}; + +/** + * Complete roll result with all metadata. + */ +export type RollResult = { + /** Final computed total */ + total: number; + /** Original input notation */ + notation: string; + /** Normalized expression */ + expression: string; + /** Rendered result with individual rolls shown */ + rendered: string; + /** All individual die results */ + rolls: DieResult[]; +}; + +/** + * Options for the evaluate function. + */ +export type EvaluateOptions = { + /** Original notation string (for result metadata) */ + notation?: string; +};