dice-table
Version:
CLI and SDK for tabletop RPG dice rolling
363 lines (324 loc) • 9.15 kB
JavaScript
/**
* 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
};