From 90284b95882d87c76f76db3bc2bd2a267265f19e Mon Sep 17 00:00:00 2001 From: Mikita Taukachou Date: Mon, 5 Jan 2026 14:43:26 +0300 Subject: [PATCH] feat: implement public roll() API with integration tests #7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the main public API for roll-parser: - roll(notation, options?) - main entry point - RollOptions type with rng, seed, maxIterations - Integration tests covering full pipeline - Property-based tests with fast-check 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/index.ts | 4 +- src/integration.test.ts | 304 ++++++++++++++++++++++++++++++++++++++++ src/property.test.ts | 276 ++++++++++++++++++++++++++++++++++++ src/roll.ts | 54 +++++++ 4 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 src/integration.test.ts create mode 100644 src/property.test.ts create mode 100644 src/roll.ts diff --git a/src/index.ts b/src/index.ts index 407f9d4..bc28b10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,8 @@ export { createMockRng, MockRNGExhaustedError } from './rng/mock'; export { evaluate, EvaluatorError } from './evaluator/evaluator'; export type { DieModifier, DieResult, EvaluateOptions, RollResult } from './types'; -// TODO: [Phase 5] Export public API (roll function) +// * Public API +export { roll } from './roll'; +export type { RollOptions } from './roll'; export const VERSION = '3.0.0-alpha.0'; diff --git a/src/integration.test.ts b/src/integration.test.ts new file mode 100644 index 0000000..ccbd475 --- /dev/null +++ b/src/integration.test.ts @@ -0,0 +1,304 @@ +/** + * Integration tests for the roll() public API. + * + * Tests the full pipeline: notation string → RollResult + */ + +import { describe, expect, test } from 'bun:test'; +import { EvaluatorError } from './evaluator/evaluator'; +import { ParseError } from './parser/parser'; +import { createMockRng } from './rng/mock'; +import { roll } from './roll'; + +describe('roll() integration', () => { + describe('full pipeline', () => { + test('basic dice roll', () => { + const result = roll('2d6', { rng: createMockRng([3, 5]) }); + expect(result.total).toBe(8); + expect(result.notation).toBe('2d6'); + expect(result.expression).toBe('2d6'); + expect(result.rolls).toHaveLength(2); + }); + + test('implicit count (d20)', () => { + const result = roll('d20', { rng: createMockRng([15]) }); + expect(result.total).toBe(15); + expect(result.rolls).toHaveLength(1); + }); + + test('dice with arithmetic', () => { + const result = roll('1d20+5', { rng: createMockRng([12]) }); + expect(result.total).toBe(17); + expect(result.notation).toBe('1d20+5'); + }); + + test('complex expression', () => { + // (1d6+1)*2 with roll of 4 → (4+1)*2 = 10 + const result = roll('(1d6+1)*2', { rng: createMockRng([4]) }); + expect(result.total).toBe(10); + }); + + test('multiple dice groups', () => { + // 2d6+1d4 with rolls [3, 5] and [2] → 8 + 2 = 10 + const result = roll('2d6+1d4', { rng: createMockRng([3, 5, 2]) }); + expect(result.total).toBe(10); + expect(result.rolls).toHaveLength(3); + }); + }); + + describe('modifiers', () => { + test('keep highest (4d6kh3)', () => { + // Rolls: [3, 1, 4, 2] → keep [3, 4, 2] = 9 + const result = roll('4d6kh3', { rng: createMockRng([3, 1, 4, 2]) }); + expect(result.total).toBe(9); + expect(result.rolls.filter((r) => r.modifiers.includes('dropped'))).toHaveLength(1); + }); + + test('keep lowest (2d20kl1)', () => { + // Rolls: [15, 8] → keep 8 + const result = roll('2d20kl1', { rng: createMockRng([15, 8]) }); + expect(result.total).toBe(8); + }); + + test('drop lowest (4d6dl1)', () => { + // Rolls: [3, 1, 4, 2] → drop 1, sum = 9 + const result = roll('4d6dl1', { rng: createMockRng([3, 1, 4, 2]) }); + expect(result.total).toBe(9); + }); + + test('drop highest (4d6dh1)', () => { + // Rolls: [3, 1, 4, 2] → drop 4, sum = 6 + const result = roll('4d6dh1', { rng: createMockRng([3, 1, 4, 2]) }); + expect(result.total).toBe(6); + }); + + test('advantage (2d20kh1)', () => { + const result = roll('2d20kh1', { rng: createMockRng([7, 18]) }); + expect(result.total).toBe(18); + }); + + test('disadvantage (2d20kl1)', () => { + const result = roll('2d20kl1', { rng: createMockRng([7, 18]) }); + expect(result.total).toBe(7); + }); + }); + + describe('seeded reproducibility', () => { + test('same seed produces same result', () => { + const r1 = roll('4d6', { seed: 'test-seed-123' }); + const r2 = roll('4d6', { seed: 'test-seed-123' }); + expect(r1.total).toBe(r2.total); + expect(r1.rolls.map((r) => r.result)).toEqual(r2.rolls.map((r) => r.result)); + }); + + test('different seeds produce different results (statistically)', () => { + const results = new Set(); + for (let i = 0; i < 10; i++) { + const result = roll('1d100', { seed: `seed-${i}` }); + results.add(result.total); + } + // With 10 different seeds rolling d100, we expect at least 5 unique values + expect(results.size).toBeGreaterThanOrEqual(5); + }); + + test('string and numeric seeds work', () => { + const r1 = roll('3d6', { seed: 'hello' }); + const r2 = roll('3d6', { seed: 42 }); + // Both should produce valid results + expect(r1.total).toBeGreaterThanOrEqual(3); + expect(r1.total).toBeLessThanOrEqual(18); + expect(r2.total).toBeGreaterThanOrEqual(3); + expect(r2.total).toBeLessThanOrEqual(18); + }); + }); + + describe('PRD 3.7 regression: negative numbers', () => { + test('negative result is NOT clamped to zero', () => { + // Roll 1 on d4, subtract 5 → -4 (NOT 0) + const result = roll('1d4-5', { rng: createMockRng([1]) }); + expect(result.total).toBe(-4); + }); + + test('unary minus on dice', () => { + // -1d4 with roll of 3 → -3 + const result = roll('-1d4', { rng: createMockRng([3]) }); + expect(result.total).toBe(-3); + }); + + test('unary minus equivalent to subtraction from zero', () => { + const rng1 = createMockRng([3]); + const rng2 = createMockRng([3]); + const r1 = roll('-1d4', { rng: rng1 }); + const r2 = roll('0-1d4', { rng: rng2 }); + expect(r1.total).toBe(r2.total); + }); + + test('negative literal', () => { + const result = roll('-5', {}); + expect(result.total).toBe(-5); + }); + }); + + describe('edge cases', () => { + test('single-sided die (1d1)', () => { + const result = roll('1d1', {}); + expect(result.total).toBe(1); + }); + + test('zero dice (0d6)', () => { + const result = roll('0d6', {}); + expect(result.total).toBe(0); + expect(result.rolls).toHaveLength(0); + }); + + test('computed dice count', () => { + // (1+1)d6 → 2d6 + const result = roll('(1+1)d6', { rng: createMockRng([3, 4]) }); + expect(result.total).toBe(7); + expect(result.rolls).toHaveLength(2); + }); + + test('computed sides', () => { + // 2d(3*2) → 2d6 + const result = roll('2d(3*2)', { rng: createMockRng([3, 4]) }); + expect(result.total).toBe(7); + }); + + test('deeply nested expression', () => { + // ((1+1)d(2*3))kh2 → 2d6kh2 + const result = roll('((1+1)d(2*3))kh2', { rng: createMockRng([3, 5]) }); + expect(result.total).toBe(8); + }); + + test('power operator right-associativity', () => { + // 2**3**2 = 2^(3^2) = 2^9 = 512 + const result = roll('2**3**2', {}); + expect(result.total).toBe(512); + }); + + test('operator precedence', () => { + // 1+2*3 = 1 + 6 = 7 (not 9) + const result = roll('1+2*3', {}); + expect(result.total).toBe(7); + }); + }); + + describe('RollOptions', () => { + test('custom RNG takes precedence over seed', () => { + const mockRng = createMockRng([6, 6, 6]); + const result = roll('3d6', { rng: mockRng, seed: 'ignored' }); + expect(result.total).toBe(18); + }); + + test('no options uses random RNG', () => { + const result = roll('1d6'); + expect(result.total).toBeGreaterThanOrEqual(1); + expect(result.total).toBeLessThanOrEqual(6); + }); + }); + + describe('result metadata', () => { + test('notation is original input', () => { + const result = roll(' 2d6 + 3 ', { rng: createMockRng([3, 4]) }); + expect(result.notation).toBe(' 2d6 + 3 '); + }); + + test('expression is normalized', () => { + const result = roll('2d6+3', { rng: createMockRng([3, 4]) }); + expect(result.expression).toBe('2d6 + 3'); + }); + + test('rendered shows individual rolls', () => { + const result = roll('2d6+3', { rng: createMockRng([3, 4]) }); + expect(result.rendered).toContain('[3, 4]'); + expect(result.rendered).toContain('= 10'); + }); + + test('critical detection', () => { + const result = roll('1d20', { rng: createMockRng([20]) }); + const die = result.rolls[0]; + expect(die).toBeDefined(); + expect(die?.critical).toBe(true); + expect(die?.fumble).toBe(false); + }); + + test('fumble detection', () => { + const result = roll('1d20', { rng: createMockRng([1]) }); + const die = result.rolls[0]; + expect(die).toBeDefined(); + expect(die?.fumble).toBe(true); + expect(die?.critical).toBe(false); + }); + }); + + describe('error cases', () => { + test('empty input throws ParseError', () => { + expect(() => roll('')).toThrow(ParseError); + }); + + test('whitespace-only input throws ParseError', () => { + expect(() => roll(' ')).toThrow(ParseError); + }); + + test('division by zero throws EvaluatorError', () => { + expect(() => roll('1/0')).toThrow(EvaluatorError); + }); + + test('modulo by zero throws EvaluatorError', () => { + expect(() => roll('1%0')).toThrow(EvaluatorError); + }); + + test('negative dice count throws EvaluatorError', () => { + expect(() => roll('(-1)d6')).toThrow(EvaluatorError); + }); + + test('zero-sided die throws EvaluatorError', () => { + expect(() => roll('1d0')).toThrow(EvaluatorError); + }); + + test('floating point dice count throws EvaluatorError', () => { + expect(() => roll('(1.5)d6')).toThrow(EvaluatorError); + }); + + test('floating point dice sides throws EvaluatorError', () => { + expect(() => roll('1d(6.5)')).toThrow(EvaluatorError); + }); + }); + + describe('syntax variations', () => { + test('caret is alias for power operator', () => { + expect(roll('2^3').total).toBe(8); + expect(roll('2**3').total).toBe(8); + }); + + test('k is shorthand for kh (keep highest)', () => { + const result = roll('4d6k3', { rng: createMockRng([3, 5, 1, 4]) }); + expect(result.total).toBe(12); // 3 + 5 + 4 + }); + }); + + describe('edge case behaviors', () => { + test('very large power produces Infinity', () => { + // 2^1024 overflows IEEE 754 double (~1.8e308 max) + expect(roll('2**1024').total).toBe(Number.POSITIVE_INFINITY); + }); + + test('unary minus on grouped expression', () => { + expect(roll('-(2+3)').total).toBe(-5); + }); + + test('rendered shows dropped dice with strikethrough', () => { + const result = roll('4d6dl1', { rng: createMockRng([3, 1, 4, 2]) }); + expect(result.rendered).toContain('~~1~~'); + }); + + test('chained modifiers evaluate sequentially (current behavior - see issue #12)', () => { + // Documents current behavior: inner modifier fully evaluates first + // Note: This differs from Roll20/RPG Dice Roller + // See issue #12 for planned Stage 2/3 enhancement + const result = roll('4d6dl1kh3', { rng: createMockRng([3, 1, 4, 2]) }); + // dl1 drops 1, kh3 on remaining [3, 4, 2] keeps all 3 + expect(result.total).toBe(9); + }); + }); +}); diff --git a/src/property.test.ts b/src/property.test.ts new file mode 100644 index 0000000..27ee04b --- /dev/null +++ b/src/property.test.ts @@ -0,0 +1,276 @@ +/** + * Property-based tests using fast-check. + * + * Tests invariants that should hold for all valid inputs. + */ + +import { describe, test } from 'bun:test'; +import fc from 'fast-check'; +import { roll } from './roll'; + +describe('property-based invariants', () => { + describe('dice roll bounds', () => { + test('NdX total is always in valid range [N, N*X]', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 20 }), + fc.integer({ min: 1, max: 100 }), + (count, sides) => { + const result = roll(`${count}d${sides}`); + return result.total >= count && result.total <= count * sides; + }, + ), + { numRuns: 500 }, + ); + }); + + test('0dX always returns 0', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 100 }), (sides) => { + const result = roll(`0d${sides}`); + return result.total === 0 && result.rolls.length === 0; + }), + { numRuns: 100 }, + ); + }); + + test('Nd1 always equals N', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 50 }), (count) => { + const result = roll(`${count}d1`); + return result.total === count; + }), + { numRuns: 100 }, + ); + }); + }); + + describe('modifier invariants', () => { + test('keep highest keeps exactly min(N, count) dice', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 10 }), + fc.integer({ min: 1, max: 20 }), + fc.integer({ min: 1, max: 10 }), + (count, sides, keep) => { + const keepN = Math.min(keep, count); + const result = roll(`${count}d${sides}kh${keepN}`); + const keptCount = result.rolls.filter((r) => !r.modifiers.includes('dropped')).length; + return keptCount === keepN; + }, + ), + { numRuns: 300 }, + ); + }); + + test('keep lowest keeps exactly min(N, count) dice', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 10 }), + fc.integer({ min: 1, max: 20 }), + fc.integer({ min: 1, max: 10 }), + (count, sides, keep) => { + const keepN = Math.min(keep, count); + const result = roll(`${count}d${sides}kl${keepN}`); + const keptCount = result.rolls.filter((r) => !r.modifiers.includes('dropped')).length; + return keptCount === keepN; + }, + ), + { numRuns: 300 }, + ); + }); + + test('drop lowest drops exactly min(N, count) dice', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 10 }), + fc.integer({ min: 1, max: 20 }), + fc.integer({ min: 1, max: 10 }), + (count, sides, drop) => { + const dropN = Math.min(drop, count); + const result = roll(`${count}d${sides}dl${dropN}`); + const droppedCount = result.rolls.filter((r) => r.modifiers.includes('dropped')).length; + return droppedCount === dropN; + }, + ), + { numRuns: 300 }, + ); + }); + + test('keep highest total >= keep lowest total (for same rolls)', () => { + fc.assert( + fc.property( + fc.integer({ min: 2, max: 6 }), + fc.integer({ min: 1, max: 20 }), + fc.integer({ min: 1, max: 3 }), + fc.integer({ min: 0, max: 0xffffffff }), + (count, sides, keep, seed) => { + const keepN = Math.min(keep, count); + const seedStr = `prop-test-${seed}`; + const khResult = roll(`${count}d${sides}kh${keepN}`, { seed: seedStr }); + const klResult = roll(`${count}d${sides}kl${keepN}`, { seed: seedStr }); + return khResult.total >= klResult.total; + }, + ), + { numRuns: 200 }, + ); + }); + }); + + describe('arithmetic invariants', () => { + test('addition is commutative for literals', () => { + fc.assert( + fc.property( + fc.integer({ min: -100, max: 100 }), + fc.integer({ min: -100, max: 100 }), + (a, b) => { + const r1 = roll(`${a}+${b}`); + const r2 = roll(`${b}+${a}`); + return r1.total === r2.total; + }, + ), + { numRuns: 100 }, + ); + }); + + test('multiplication is commutative for literals', () => { + fc.assert( + fc.property( + fc.integer({ min: -10, max: 10 }), + fc.integer({ min: -10, max: 10 }), + (a, b) => { + const r1 = roll(`${a}*${b}`); + const r2 = roll(`${b}*${a}`); + return r1.total === r2.total; + }, + ), + { numRuns: 100 }, + ); + }); + + test('adding zero is identity', () => { + fc.assert( + fc.property(fc.integer({ min: -100, max: 100 }), (a) => { + const r1 = roll(`${a}+0`); + const r2 = roll(`${a}`); + return r1.total === r2.total; + }), + { numRuns: 100 }, + ); + }); + + test('multiplying by one is identity', () => { + fc.assert( + fc.property(fc.integer({ min: -100, max: 100 }), (a) => { + const r1 = roll(`${a}*1`); + const r2 = roll(`${a}`); + return r1.total === r2.total; + }), + { numRuns: 100 }, + ); + }); + + test('unary minus equivalent to subtraction from zero', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 10 }), + fc.integer({ min: 1, max: 20 }), + fc.integer({ min: 0, max: 0xffffffff }), + (count, sides, seed) => { + const seedStr = `neg-test-${seed}`; + const r1 = roll(`-${count}d${sides}`, { seed: seedStr }); + const r2 = roll(`0-${count}d${sides}`, { seed: seedStr }); + return r1.total === r2.total; + }, + ), + { numRuns: 100 }, + ); + }); + }); + + describe('result structure invariants', () => { + test('rolls array length matches dice count', () => { + fc.assert( + fc.property( + fc.integer({ min: 0, max: 20 }), + fc.integer({ min: 1, max: 20 }), + (count, sides) => { + const result = roll(`${count}d${sides}`); + return result.rolls.length === count; + }, + ), + { numRuns: 200 }, + ); + }); + + test('each die result is within valid range', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 10 }), + fc.integer({ min: 1, max: 100 }), + (count, sides) => { + const result = roll(`${count}d${sides}`); + return result.rolls.every( + (r) => r.result >= 1 && r.result <= sides && r.sides === sides, + ); + }, + ), + { numRuns: 200 }, + ); + }); + + test('critical is only set when result equals sides', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 10 }), + fc.integer({ min: 2, max: 20 }), + (count, sides) => { + const result = roll(`${count}d${sides}`); + return result.rolls.every((r) => r.critical === (r.result === sides)); + }, + ), + { numRuns: 200 }, + ); + }); + + test('fumble is only set when result is 1', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 10 }), + fc.integer({ min: 2, max: 20 }), + (count, sides) => { + const result = roll(`${count}d${sides}`); + return result.rolls.every((r) => r.fumble === (r.result === 1)); + }, + ), + { numRuns: 200 }, + ); + }); + }); + + describe('seeded reproducibility', () => { + test('same seed always produces same results', () => { + fc.assert( + fc.property( + fc.string({ minLength: 1, maxLength: 20 }), + fc.integer({ min: 1, max: 10 }), + fc.integer({ min: 1, max: 20 }), + (seed, count, sides) => { + const r1 = roll(`${count}d${sides}`, { seed }); + const r2 = roll(`${count}d${sides}`, { seed }); + if (r1.total !== r2.total || r1.rolls.length !== r2.rolls.length) { + return false; + } + for (let i = 0; i < r1.rolls.length; i++) { + if (r1.rolls[i]?.result !== r2.rolls[i]?.result) { + return false; + } + } + return true; + }, + ), + { numRuns: 100 }, + ); + }); + }); +}); diff --git a/src/roll.ts b/src/roll.ts new file mode 100644 index 0000000..2146a07 --- /dev/null +++ b/src/roll.ts @@ -0,0 +1,54 @@ +/** + * Main public API for rolling dice expressions. + * + * @module roll + */ + +import type { RNG } from './rng/types'; +import type { RollResult } from './types'; +import { evaluate } from './evaluator/evaluator'; +import { lex } from './lexer/lexer'; +import { Parser } from './parser/parser'; +import { SeededRNG } from './rng/seeded'; + +/** + * Options for the roll function. + */ +export type RollOptions = { + /** Custom RNG instance (takes precedence over seed) */ + rng?: RNG; + /** Seed for deterministic rolls (ignored if rng provided) */ + seed?: string | number; + /** Safety limit for iterations (default: 1000) - Stage 2 exploding dice */ + maxIterations?: number; +}; + +/** + * Parses and evaluates a dice notation string. + * + * @param notation - Dice notation (e.g., "2d6+3", "4d6kh3") + * @param options - Optional configuration (RNG or seed) + * @returns Complete roll result with total and metadata + * + * @example + * ```typescript + * // Random roll + * const result = roll('2d6+3'); + * console.log(result.total); // 5-15 + * + * // Seeded for reproducibility + * const r1 = roll('4d6', { seed: 'test' }); + * const r2 = roll('4d6', { seed: 'test' }); + * r1.total === r2.total; // true + * + * // Custom RNG for testing + * const result = roll('1d20', { rng: createMockRng([15]) }); + * result.total; // 15 + * ``` + */ +export function roll(notation: string, options: RollOptions = {}): RollResult { + const rng = options.rng ?? new SeededRNG(options.seed); + const tokens = lex(notation); + const ast = new Parser(tokens).parse(); + return evaluate(ast, rng, { notation }); +}