UNPKG

dice-table

Version:

CLI and SDK for tabletop RPG dice rolling

363 lines (324 loc) 9.15 kB
/** * Dice Notation Parser * Parses dice expressions into structured data for processing * * Supported formats: * - Basic: 1d6, 2d20, 3d8 * - Modifiers: 1d20+5, 2d6-2 * - Per-die floor: 4d6f3 (each die minimum 3) * - Total floor: 2d6tf10 (total minimum 10) * - Keep highest/lowest: 2d20kh1, 4d6kh3, 2d20kl1, 2d20kh (defaults to 1) * - Drop highest/lowest: 4d6dh1, 4d6dl1, 4d6dh (defaults to 1) * - Complex: 1d20+1d6+5, 2d20kh1+1d8+3, 4d6f3kh3+2tf10 */ /** * Parse a dice expression into structured components * @param {string} expression - The dice expression to parse * @returns {Object} Parsed result with validation status and components */ function parse(expression) { // Validate input if (typeof expression !== 'string') { return { valid: false, error: 'Expression must be a string', expression: String(expression) }; } const originalExpression = expression.trim(); if (!originalExpression) { return { valid: false, error: 'Expression cannot be empty', expression: originalExpression }; } try { // Remove all whitespace for easier parsing const cleanExpression = originalExpression.replace(/\s+/g, ''); // Split expression into terms (handle + and - operators) const terms = splitExpression(cleanExpression); if (terms.length === 0) { return { valid: false, error: 'No valid terms found in expression', expression: originalExpression }; } const parts = []; for (const term of terms) { const parsedTerm = parseTerm(term); if (!parsedTerm.valid) { return { valid: false, error: parsedTerm.error, expression: originalExpression }; } parts.push(parsedTerm.data); } return { valid: true, expression: originalExpression, parts: parts }; } catch (error) { return { valid: false, error: `Parse error: ${error.message}`, expression: originalExpression }; } } /** * Split expression into individual terms while preserving operators * @param {string} expression - Clean expression without whitespace * @returns {Array} Array of term objects with value and operator */ function splitExpression(expression) { const terms = []; let currentTerm = ''; let currentOperator = '+'; // First term is always positive let isFirst = true; for (let i = 0; i < expression.length; i++) { const char = expression[i]; if ((char === '+' || char === '-') && !isFirst && currentTerm) { // Found an operator, save current term with its operator terms.push({ value: currentTerm, operator: currentOperator }); currentTerm = ''; currentOperator = char; // Set operator for next term isFirst = false; } else { currentTerm += char; isFirst = false; } } // Add the last term if (currentTerm) { terms.push({ value: currentTerm, operator: currentOperator }); } return terms; } /** * Parse an individual term (dice notation or modifier) * @param {Object} term - Term object with value and operator * @returns {Object} Parsed term data or error */ function parseTerm(term) { const value = term.value; const isNegative = term.operator === '-'; // Check if it's a pure number (modifier) if (/^\d+$/.test(value)) { const modifier = parseInt(value, 10) * (isNegative ? -1 : 1); if (modifier < -9999 || modifier > 9999) { return { valid: false, error: `Modifier ${modifier} is out of range (-9999 to +9999)` }; } return { valid: true, data: { modifier: modifier } }; } // Parse dice notation const diceMatch = value.match(/^(\d+)d(\d+)(.*)$/i); if (!diceMatch) { return { valid: false, error: `Invalid term: ${term.value}` }; } // Dice expressions cannot be negative if (isNegative) { return { valid: false, error: `Dice expressions cannot be negative: -${value}` }; } const count = parseInt(diceMatch[1], 10); const sides = parseInt(diceMatch[2], 10); const modifiers = diceMatch[3]; // Validate dice count if (count < 1 || count > 999) { return { valid: false, error: `Dice count ${count} is out of range (1 to 999)` }; } // Validate die sides if (sides < 2 || sides > 1000) { return { valid: false, error: `Die sides ${sides} is out of range (2 to 1000)` }; } const diceData = { count: count, sides: sides }; // Apply negative modifier to the entire dice group if needed if (isNegative) { diceData.negative = true; } // Parse modifiers (floor, keep/drop, total floor) if (modifiers) { const modifiersResult = parseModifiers(modifiers, count, sides); if (!modifiersResult.valid) { return modifiersResult; } if (modifiersResult.data) { Object.assign(diceData, modifiersResult.data); } } return { valid: true, data: diceData }; } /** * Parse all modifiers: floor (fX), keep/drop (kh/kl/dh/dl), total floor (tfX) * Order: fX comes first, then kh/kl/dh/dl, then tfX comes last * @param {string} modifiers - The modifier string * @param {number} diceCount - Total number of dice being rolled * @param {number} diceSides - Number of sides on the die * @returns {Object} Parsed modifier data or error */ function parseModifiers(modifiers, diceCount, diceSides) { const result = {}; let remaining = modifiers; // 1. Check for per-die floor (fX) - must come first const floorMatch = remaining.match(/^f(\d+)/i); if (floorMatch) { const floorValue = parseInt(floorMatch[1], 10); if (floorValue < 1) { return { valid: false, error: `Floor value must be at least 1` }; } if (floorValue > diceSides) { return { valid: false, error: `Floor value ${floorValue} cannot exceed die sides ${diceSides}` }; } result.floor = floorValue; remaining = remaining.substring(floorMatch[0].length); } // 2. Check for keep/drop modifiers const keepHighMatch = remaining.match(/^kh(\d*)/i); const keepLowMatch = remaining.match(/^kl(\d*)/i); const dropHighMatch = remaining.match(/^dh(\d*)/i); const dropLowMatch = remaining.match(/^dl(\d*)/i); if (keepHighMatch) { const keepCount = keepHighMatch[1] ? parseInt(keepHighMatch[1], 10) : 1; if (keepCount >= diceCount) { return { valid: false, error: `Cannot keep ${keepCount} dice from ${diceCount} dice` }; } if (keepCount < 1) { return { valid: false, error: `Keep count must be at least 1` }; } result.keep = { type: 'highest', count: keepCount }; remaining = remaining.substring(keepHighMatch[0].length); } else if (keepLowMatch) { const keepCount = keepLowMatch[1] ? parseInt(keepLowMatch[1], 10) : 1; if (keepCount >= diceCount) { return { valid: false, error: `Cannot keep ${keepCount} dice from ${diceCount} dice` }; } if (keepCount < 1) { return { valid: false, error: `Keep count must be at least 1` }; } result.keep = { type: 'lowest', count: keepCount }; remaining = remaining.substring(keepLowMatch[0].length); } else if (dropHighMatch) { const dropCount = dropHighMatch[1] ? parseInt(dropHighMatch[1], 10) : 1; if (dropCount >= diceCount) { return { valid: false, error: `Cannot drop ${dropCount} dice from ${diceCount} dice` }; } if (dropCount < 1) { return { valid: false, error: `Drop count must be at least 1` }; } result.drop = { type: 'highest', count: dropCount }; remaining = remaining.substring(dropHighMatch[0].length); } else if (dropLowMatch) { const dropCount = dropLowMatch[1] ? parseInt(dropLowMatch[1], 10) : 1; if (dropCount >= diceCount) { return { valid: false, error: `Cannot drop ${dropCount} dice from ${diceCount} dice` }; } if (dropCount < 1) { return { valid: false, error: `Drop count must be at least 1` }; } result.drop = { type: 'lowest', count: dropCount }; remaining = remaining.substring(dropLowMatch[0].length); } // 3. Check for total floor (tfX) - must come last const totalFloorMatch = remaining.match(/^tf(\d+)$/i); if (totalFloorMatch) { const totalFloorValue = parseInt(totalFloorMatch[1], 10); if (totalFloorValue < 1) { return { valid: false, error: `Total floor value must be at least 1` }; } result.totalFloor = totalFloorValue; remaining = ''; } // If there's anything left unparsed, it's an error if (remaining) { return { valid: false, error: `Unknown modifier: ${remaining}` }; } return { valid: true, data: Object.keys(result).length > 0 ? result : null }; } module.exports = { parse };