From 03a2acc3862dde248fc54f06e973e76f60b98ee6 Mon Sep 17 00:00:00 2001 From: Ean Milligan Date: Sat, 3 May 2025 07:51:12 -0400 Subject: [PATCH] BIIIIIG change here: NESTED ROLLS :D Also, start the reorganization of artigen to support nesting better, make smaller more readable files, and better named functions --- src/artigen/cmdTokenizer.ts | 85 ++++++++ src/artigen/mathTokenizer.ts | 158 ++++++++++++++ src/artigen/parser.ts | 321 +++------------------------- src/artigen/solver.d.ts | 3 + src/artigen/solver.ts | 30 +-- src/artigen/utils/escape.ts | 12 ++ src/artigen/utils/parenBalance.ts | 59 +++++ src/artigen/utils/translateError.ts | 114 ++++++++++ src/commands/roll.ts | 5 +- src/endpoints/gets/apiRoll.ts | 2 +- 10 files changed, 461 insertions(+), 328 deletions(-) create mode 100644 src/artigen/cmdTokenizer.ts create mode 100644 src/artigen/mathTokenizer.ts create mode 100644 src/artigen/utils/parenBalance.ts create mode 100644 src/artigen/utils/translateError.ts diff --git a/src/artigen/cmdTokenizer.ts b/src/artigen/cmdTokenizer.ts new file mode 100644 index 0000000..720d0f1 --- /dev/null +++ b/src/artigen/cmdTokenizer.ts @@ -0,0 +1,85 @@ +import { log, LogTypes as LT } from '@Log4Deno'; + +import config from '~config'; + +import { tokenizeMath } from 'artigen/mathTokenizer.ts'; +import { CountDetails, ReturnData } from 'artigen/solver.d.ts'; + +import { closeInternal, internalWrapRegex, openInternal } from 'artigen/utils/escape.ts'; +import { loggingEnabled } from 'artigen/utils/logFlag.ts'; +import { getMatchingInternalIdx, getMatchingPostfixIdx } from 'artigen/utils/parenBalance.ts'; + +import { RollModifiers } from 'src/mod.d.ts'; + +// tokenizeCmd expects a string[] of items that are either config.prefix/config.postfix or some text that contains math and/or dice rolls +export const tokenizeCmd = (cmd: string[], modifiers: RollModifiers, topLevel: boolean): [ReturnData[], CountDetails[]] => { + loggingEnabled && log(LT.LOG, `Tokenizing command ${JSON.stringify(cmd)}`); + + const returnData: ReturnData[] = []; + const countDetails: CountDetails[] = []; + + // Wrapped commands still exist, unwrap them + while (cmd.includes(config.prefix)) { + const openIdx = cmd.indexOf(config.prefix); + const closeIdx = getMatchingPostfixIdx(cmd, openIdx); + + // Handle any nested commands + const [tempData, tempCounts] = tokenizeCmd(cmd.slice(openIdx + 1, closeIdx), modifiers, false); + const data = tempData[0]; + + if (topLevel) { + // Handle saving any formatting between dice + if (openIdx !== 0) { + data.rollPreFormat = cmd.slice(0, openIdx).join(''); + } + + // Chop off all formatting between cmds along with the processed cmd + cmd.splice(0, closeIdx + 1); + } else { + // We're handling something nested, replace [[cmd]] with the cmd's result + cmd.splice(openIdx, closeIdx - openIdx + 1, `${openInternal}${data.rollTotal}${closeInternal}`); + } + + // Store results + returnData.push(data); + countDetails.push(...tempCounts); + } + + if (topLevel) { + if (cmd.length) { + loggingEnabled && log(LT.LOG, `Adding leftover formatting to last returnData ${JSON.stringify(cmd)}`); + returnData[returnData.length - 1].rollPostFormat = cmd.join(''); + } + return [returnData, countDetails]; + } else { + loggingEnabled && log(LT.LOG, `Tokenizing math ${JSON.stringify(cmd)}`); + + // Solve the math and rolls for this cmd + const [tempData, tempCounts] = tokenizeMath(cmd.join(''), modifiers); + const data = tempData[0]; + loggingEnabled && log(LT.LOG, `Solved math is back ${JSON.stringify(data)} | ${JSON.stringify(returnData)}`); + + // Merge counts + countDetails.push(...tempCounts); + + // Handle merging returnData into tempData + const initConf = data.initConfig.split(internalWrapRegex).filter((x) => x); + loggingEnabled && log(LT.LOG, `Split solved math initConfig ${JSON.stringify(initConf)}`); + while (initConf.includes(openInternal)) { + const openIdx = initConf.indexOf(openInternal); + const closeIdx = getMatchingInternalIdx(initConf, openIdx); + + // Take first returnData out of array + const dataToMerge = returnData.shift(); + + // Replace the found pair with the nested initConfig and result + initConf.splice(openIdx, closeIdx - openIdx + 1, `${config.prefix}${dataToMerge?.initConfig}=${dataToMerge?.rollTotal}${config.postfix}`); + loggingEnabled && log(LT.LOG, `Current initConf state ${JSON.stringify(initConf)}`); + } + + // Join all parts/remainders + data.initConfig = initConf.join(''); + loggingEnabled && log(LT.LOG, `ReturnData merged into solved math ${JSON.stringify(data)} | ${JSON.stringify(countDetails)}`); + return [[data], countDetails]; + } +}; diff --git a/src/artigen/mathTokenizer.ts b/src/artigen/mathTokenizer.ts new file mode 100644 index 0000000..b494b31 --- /dev/null +++ b/src/artigen/mathTokenizer.ts @@ -0,0 +1,158 @@ +import { log, LogTypes as LT } from '@Log4Deno'; + +import { formatRoll } from 'artigen/rollFormatter.ts'; +import { fullSolver } from 'artigen/solver.ts'; +import { CountDetails, MathConf, ReturnData, SolvedStep } from 'artigen/solver.d.ts'; + +import { cmdSplitRegex, internalWrapRegex } from 'artigen/utils/escape.ts'; +import { legalMathOperators } from 'artigen/utils/legalMath.ts'; +import { loggingEnabled } from 'artigen/utils/logFlag.ts'; +import { assertParenBalance } from 'artigen/utils/parenBalance.ts'; + +import { RollModifiers } from 'src/mod.d.ts'; + +const operators = ['(', ')', '^', '*', '/', '%', '+', '-']; + +export const tokenizeMath = (cmd: string, modifiers: RollModifiers): [ReturnData[], CountDetails[]] => { + const countDetails: CountDetails[] = []; + + loggingEnabled && log(LT.LOG, `Parsing roll ${cmd}`); + + // Remove all spaces from the operation config and split it by any operator (keeping the operator in mathConf for fullSolver to do math on) + const mathConf: MathConf[] = cmd + .replace(cmdSplitRegex, '') + .replace(internalWrapRegex, '') + .replace(/ /g, '') + .split(/([-+()*/^]|(? x); + loggingEnabled && log(LT.LOG, `Split roll into mathConf ${JSON.stringify(mathConf)}`); + + // Verify balanced parens before doing anything + assertParenBalance(mathConf); + + // Evaluate all rolls into stepSolve format and all numbers into floats + for (let i = 0; i < mathConf.length; i++) { + loggingEnabled && log(LT.LOG, `Parsing roll ${JSON.stringify(cmd)} | Evaluating rolls into math-able items ${JSON.stringify(mathConf[i])}`); + + const strMathConfI = mathConf[i].toString(); + + if (strMathConfI.length === 0) { + // If its an empty string, get it out of here + mathConf.splice(i, 1); + i--; + } else if (mathConf[i] == parseFloat(strMathConfI)) { + // If its a number, parse the number out + mathConf[i] = parseFloat(strMathConfI); + } else if (strMathConfI.toLowerCase() === 'e') { + // If the operand is the constant e, create a SolvedStep for it + mathConf[i] = { + total: Math.E, + details: '*e*', + containsCrit: false, + containsFail: false, + }; + } else if (strMathConfI.toLowerCase() === 'fart' || strMathConfI.toLowerCase() === '๐Ÿ’ฉ') { + mathConf[i] = { + total: 7, + details: '๐Ÿ’ฉ', + containsCrit: false, + containsFail: false, + }; + } else if (strMathConfI.toLowerCase() === 'sex') { + mathConf[i] = { + total: 69, + details: '( อกยฐ อœส– อกยฐ)', + containsCrit: false, + containsFail: false, + }; + } else if (strMathConfI.toLowerCase() === 'inf' || strMathConfI.toLowerCase() === 'infinity' || strMathConfI.toLowerCase() === 'โˆž') { + // If the operand is the constant Infinity, create a SolvedStep for it + mathConf[i] = { + total: Infinity, + details: 'โˆž', + containsCrit: false, + containsFail: false, + }; + } else if (strMathConfI.toLowerCase() === 'pi' || strMathConfI.toLowerCase() === '๐œ‹') { + // If the operand is the constant pi, create a SolvedStep for it + mathConf[i] = { + total: Math.PI, + details: '๐œ‹', + containsCrit: false, + containsFail: false, + }; + } else if (strMathConfI.toLowerCase() === 'pie') { + // If the operand is pie, pi*e, create a SolvedStep for e and pi (and the multiplication symbol between them) + mathConf[i] = { + total: Math.PI, + details: '๐œ‹', + containsCrit: false, + containsFail: false, + }; + mathConf.splice( + i + 1, + 0, + ...[ + '*', + { + total: Math.E, + details: '*e*', + containsCrit: false, + containsFail: false, + }, + ], + ); + i += 2; + } else if (!legalMathOperators.includes(strMathConfI) && legalMathOperators.some((mathOp) => strMathConfI.endsWith(mathOp))) { + // Identify when someone does something weird like 4floor(2.5) and split 4 and floor + const matchedMathOp = legalMathOperators.filter((mathOp) => strMathConfI.endsWith(mathOp))[0]; + mathConf[i] = parseFloat(strMathConfI.replace(matchedMathOp, '')); + + mathConf.splice(i + 1, 0, ...['*', matchedMathOp]); + i += 2; + } else if (![...operators, ...legalMathOperators].includes(strMathConfI)) { + // If nothing else has handled it by now, try it as a roll + const formattedRoll = formatRoll(strMathConfI, modifiers); + mathConf[i] = formattedRoll.solvedStep; + countDetails.push(formattedRoll.countDetails); + } + + // Identify if we are in a state where the current number is a negative number + if (mathConf[i - 1] === '-' && ((!mathConf[i - 2] && mathConf[i - 2] !== 0) || mathConf[i - 2] === '(')) { + if (typeof mathConf[i] === 'string') { + // Current item is a mathOp, need to insert a "-1 *" before it + mathConf.splice(i - 1, 1, ...[parseFloat('-1'), '*']); + i += 2; + } else { + // Handle normally, just set current item to negative + if (typeof mathConf[i] === 'number') { + mathConf[i] = mathConf[i] * -1; + } else { + ( mathConf[i]).total = ( mathConf[i]).total * -1; + ( mathConf[i]).details = `-${( mathConf[i]).details}`; + } + mathConf.splice(i - 1, 1); + i--; + } + } + } + + // Now that mathConf is parsed, send it into the solver + const tempSolved = fullSolver(mathConf); + + // Push all of this step's solved data into the temp array + return [ + [ + { + rollTotal: tempSolved.total, + rollPreFormat: '', + rollPostFormat: '', + rollDetails: tempSolved.details, + containsCrit: tempSolved.containsCrit, + containsFail: tempSolved.containsFail, + initConfig: cmd, + }, + ], + countDetails, + ]; +}; diff --git a/src/artigen/parser.ts b/src/artigen/parser.ts index 723d0c7..29ebdb1 100644 --- a/src/artigen/parser.ts +++ b/src/artigen/parser.ts @@ -1,22 +1,19 @@ import { log, LogTypes as LT } from '@Log4Deno'; -import config from '~config'; +import { tokenizeCmd } from 'artigen/cmdTokenizer.ts'; +import { SolvedRoll } from 'artigen/solver.d.ts'; -import { formatRoll } from 'artigen/rollFormatter.ts'; -import { fullSolver } from 'artigen/solver.ts'; -import { CountDetails, ReturnData, SolvedRoll, SolvedStep } from 'artigen/solver.d.ts'; - -import { escapeCharacters } from 'artigen/utils/escape.ts'; -import { legalMathOperators } from 'artigen/utils/legalMath.ts'; +import { cmdSplitRegex, escapeCharacters } from 'artigen/utils/escape.ts'; import { loggingEnabled } from 'artigen/utils/logFlag.ts'; import { compareTotalRolls, compareTotalRollsReverse } from 'artigen/utils/sortFuncs.ts'; +import { translateError } from 'artigen/utils/translateError.ts'; import { RollModifiers } from 'src/mod.d.ts'; +import { assertPrePostBalance } from 'src/artigen/utils/parenBalance.ts'; // parseRoll(fullCmd, modifiers) // parseRoll handles converting fullCmd into a computer readable format for processing, and finally executes the solving export const parseRoll = (fullCmd: string, modifiers: RollModifiers): SolvedRoll => { - const operators = ['(', ')', '^', '*', '/', '%', '+', '-']; const returnMsg = { error: false, errorCode: '', @@ -34,181 +31,21 @@ export const parseRoll = (fullCmd: string, modifiers: RollModifiers): SolvedRoll }, }; - // Whole function lives in a try-catch to allow safe throwing of errors on purpose + // Whole processor lives in a try-catch to catch artigen's intentional error conditions try { - // Split the fullCmd by the command prefix to allow every roll/math op to be handled individually - const sepRolls = fullCmd.split(config.prefix); - // TODO(@burn-e99): HERE for the [[ ]] nesting stuff + // filter removes all null/empty strings since we don't care about them + const sepCmds = fullCmd.split(cmdSplitRegex).filter((x) => x); + loggingEnabled && log(LT.LOG, `Split cmd into parts ${JSON.stringify(sepCmds)}`); - const tempReturnData: ReturnData[] = []; - const tempCountDetails: CountDetails[] = [ - { - total: 0, - successful: 0, - failed: 0, - rerolled: 0, - dropped: 0, - exploded: 0, - }, - ]; + // Verify prefix/postfix balance + assertPrePostBalance(sepCmds); - // Loop thru all roll/math ops - for (const sepRoll of sepRolls) { - loggingEnabled && log(LT.LOG, `Parsing roll ${fullCmd} | Working ${sepRoll}`); - // Split the current iteration on the command postfix to separate the operation to be parsed and the text formatting after the operation - const [tempConf, tempFormat] = sepRoll.split(config.postfix); - - // Remove all spaces from the operation config and split it by any operator (keeping the operator in mathConf for fullSolver to do math on) - const mathConf: (string | number | SolvedStep)[] = <(string | number | SolvedStep)[]> tempConf.replace(/ /g, '').split(/([-+()*/^]|(? { - loggingEnabled && log(LT.LOG, `Parsing roll ${fullCmd} | Checking parenthesis balance ${e}`); - if (e === '(') { - parenCnt++; - } else if (e === ')') { - parenCnt--; - } - - // If parenCnt ever goes below 0, that means too many closing paren appeared before opening parens - if (parenCnt < 0) { - throw new Error('UnbalancedParens'); - } - }); - - // If the parenCnt is not 0, then we do not have balanced parens and need to error out now - if (parenCnt !== 0) { - throw new Error('UnbalancedParens'); - } - - // Evaluate all rolls into stepSolve format and all numbers into floats - for (let i = 0; i < mathConf.length; i++) { - loggingEnabled && log(LT.LOG, `Parsing roll ${fullCmd} | Evaluating rolls into math-able items ${JSON.stringify(mathConf[i])}`); - - const strMathConfI = mathConf[i].toString(); - - if (strMathConfI.length === 0) { - // If its an empty string, get it out of here - mathConf.splice(i, 1); - i--; - } else if (mathConf[i] == parseFloat(strMathConfI)) { - // If its a number, parse the number out - mathConf[i] = parseFloat(strMathConfI); - } else if (strMathConfI.toLowerCase() === 'e') { - // If the operand is the constant e, create a SolvedStep for it - mathConf[i] = { - total: Math.E, - details: '*e*', - containsCrit: false, - containsFail: false, - }; - } else if (strMathConfI.toLowerCase() === 'fart' || strMathConfI.toLowerCase() === '๐Ÿ’ฉ') { - mathConf[i] = { - total: 7, - details: '๐Ÿ’ฉ', - containsCrit: false, - containsFail: false, - }; - } else if (strMathConfI.toLowerCase() === 'sex') { - mathConf[i] = { - total: 69, - details: '( อกยฐ อœส– อกยฐ)', - containsCrit: false, - containsFail: false, - }; - } else if (strMathConfI.toLowerCase() === 'inf' || strMathConfI.toLowerCase() === 'infinity' || strMathConfI.toLowerCase() === 'โˆž') { - // If the operand is the constant Infinity, create a SolvedStep for it - mathConf[i] = { - total: Infinity, - details: 'โˆž', - containsCrit: false, - containsFail: false, - }; - } else if (strMathConfI.toLowerCase() === 'pi' || strMathConfI.toLowerCase() === '๐œ‹') { - // If the operand is the constant pi, create a SolvedStep for it - mathConf[i] = { - total: Math.PI, - details: '๐œ‹', - containsCrit: false, - containsFail: false, - }; - } else if (strMathConfI.toLowerCase() === 'pie') { - // If the operand is pie, pi*e, create a SolvedStep for e and pi (and the multiplication symbol between them) - mathConf[i] = { - total: Math.PI, - details: '๐œ‹', - containsCrit: false, - containsFail: false, - }; - mathConf.splice( - i + 1, - 0, - ...[ - '*', - { - total: Math.E, - details: '*e*', - containsCrit: false, - containsFail: false, - }, - ], - ); - i += 2; - } else if (!legalMathOperators.includes(strMathConfI) && legalMathOperators.some((mathOp) => strMathConfI.endsWith(mathOp))) { - // Identify when someone does something weird like 4floor(2.5) and split 4 and floor - const matchedMathOp = legalMathOperators.filter((mathOp) => strMathConfI.endsWith(mathOp))[0]; - mathConf[i] = parseFloat(strMathConfI.replace(matchedMathOp, '')); - - mathConf.splice(i + 1, 0, ...['*', matchedMathOp]); - i += 2; - } else if (![...operators, ...legalMathOperators].includes(strMathConfI)) { - // If nothing else has handled it by now, try it as a roll - const formattedRoll = formatRoll(strMathConfI, modifiers); - mathConf[i] = formattedRoll.solvedStep; - tempCountDetails.push(formattedRoll.countDetails); - } - - // Identify if we are in a state where the current number is a negative number - if (mathConf[i - 1] === '-' && ((!mathConf[i - 2] && mathConf[i - 2] !== 0) || mathConf[i - 2] === '(')) { - if (typeof mathConf[i] === 'string') { - // Current item is a mathOp, need to insert a "-1 *" before it - mathConf.splice(i - 1, 1, ...[parseFloat('-1'), '*']); - i += 2; - } else { - // Handle normally, just set current item to negative - if (typeof mathConf[i] === 'number') { - mathConf[i] = mathConf[i] * -1; - } else { - ( mathConf[i]).total = ( mathConf[i]).total * -1; - ( mathConf[i]).details = `-${( mathConf[i]).details}`; - } - mathConf.splice(i - 1, 1); - i--; - } - } - } - - // Now that mathConf is parsed, send it into the solver - const tempSolved = fullSolver(mathConf, false); - - // Push all of this step's solved data into the temp array - tempReturnData.push({ - rollTotal: tempSolved.total, - rollPostFormat: tempFormat, - rollDetails: tempSolved.details, - containsCrit: tempSolved.containsCrit, - containsFail: tempSolved.containsFail, - initConfig: tempConf, - }); - } - - // Parsing/Solving done, time to format the output for Discord + // Send the split roll into the command tokenizer to get raw response data + const [tempReturnData, tempCountDetails] = tokenizeCmd(sepCmds, modifiers, true); + loggingEnabled && log(LT.LOG, `Return data is back ${JSON.stringify(tempReturnData)}`); // Remove any floating spaces from fullCmd - if (fullCmd[fullCmd.length - 1] === ' ') { - fullCmd = fullCmd.substring(0, fullCmd.length - 1); - } + fullCmd = fullCmd.trim(); // Escape any | and ` chars in fullCmd to prevent spoilers and code blocks from acting up fullCmd = escapeCharacters(fullCmd, '|'); @@ -220,6 +57,7 @@ export const parseRoll = (fullCmd: string, modifiers: RollModifiers): SolvedRoll // The ': ' is used by generateRollEmbed to split line 2 up const resultStr = tempReturnData.length > 1 ? 'Results: ' : 'Result: '; + line2 = resultStr; // If a theoretical roll is requested, mark the output as such, else use default formatting if (modifiers.maxRoll || modifiers.minRoll || modifiers.nominalRoll) { @@ -227,19 +65,16 @@ export const parseRoll = (fullCmd: string, modifiers: RollModifiers): SolvedRoll const theoreticalBools = [modifiers.maxRoll, modifiers.minRoll, modifiers.nominalRoll]; const theoreticalText = theoreticalTexts[theoreticalBools.indexOf(true)]; - line1 = ` requested the Theoretical ${theoreticalText} of:\n\`${config.prefix}${fullCmd}\``; + line1 = ` requested the Theoretical ${theoreticalText} of:\n\`${fullCmd}\``; line2 = `Theoretical ${theoreticalText} ${resultStr}`; } else if (modifiers.order === 'a') { - line1 = ` requested the following rolls to be ordered from least to greatest:\n\`${config.prefix}${fullCmd}\``; - line2 = resultStr; + line1 = ` requested the following rolls to be ordered from least to greatest:\n\`${fullCmd}\``; tempReturnData.sort(compareTotalRolls); } else if (modifiers.order === 'd') { - line1 = ` requested the following rolls to be ordered from greatest to least:\n\`${config.prefix}${fullCmd}\``; - line2 = resultStr; + line1 = ` requested the following rolls to be ordered from greatest to least:\n\`${fullCmd}\``; tempReturnData.sort(compareTotalRollsReverse); } else { - line1 = ` rolled:\n\`${config.prefix}${fullCmd}\``; - line2 = resultStr; + line1 = ` rolled:\n\`${fullCmd}\``; } // Fill out all of the details and results now @@ -260,12 +95,9 @@ export const parseRoll = (fullCmd: string, modifiers: RollModifiers): SolvedRoll // Populate line2 (the results) and line3 (the details) with their data if (modifiers.order === '') { - line2 += `${preFormat}${modifiers.commaTotals ? e.rollTotal.toLocaleString() : e.rollTotal}${postFormat}${ - escapeCharacters( - e.rollPostFormat, - '|*_~`', - ) - } `; + line2 += `${e.rollPreFormat ? escapeCharacters(e.rollPreFormat, '|*_~`') : ' '}${preFormat}${modifiers.commaTotals ? e.rollTotal.toLocaleString() : e.rollTotal}${postFormat}${ + e.rollPostFormat ? escapeCharacters(e.rollPostFormat, '|*_~`') : '' + }`; } else { // If order is on, turn rolls into csv without formatting line2 += `${preFormat}${modifiers.commaTotals ? e.rollTotal.toLocaleString() : e.rollTotal}${postFormat}, `; @@ -294,114 +126,9 @@ export const parseRoll = (fullCmd: string, modifiers: RollModifiers): SolvedRoll exploded: acc.exploded + cnt.exploded, })); } catch (e) { - const solverError = e as Error; - // Welp, the unthinkable happened, we hit an error - - // Split on _ for the error messages that have more info than just their name - const errorSplits = solverError.message.split('_'); - const errorName = errorSplits.shift(); - const errorDetails = errorSplits.join('_'); - - let errorMsg = ''; - - // Translate the errorName to a specific errorMsg - switch (errorName) { - case 'WholeDieCountSizeOnly': - errorMsg = 'Error: Die Size and Die Count must be whole numbers'; - break; - case 'YouNeedAD': - errorMsg = 'Formatting Error: Missing die size and count config'; - break; - case 'CannotParseDieCount': - errorMsg = `Formatting Error: Cannot parse \`${errorDetails}\` as a number`; - break; - case 'DoubleSeparator': - errorMsg = `Formatting Error: \`${errorDetails}\` should only be specified once per roll, remove all but one and repeat roll`; - break; - case 'FormattingError': - errorMsg = 'Formatting Error: Cannot use Keep and Drop at the same time, remove all but one and repeat roll'; - break; - case 'NoMaxWithDash': - errorMsg = 'Formatting Error: CritScore range specified without a maximum, remove - or add maximum to correct'; - break; - case 'UnknownOperation': - errorMsg = `Error: Unknown Operation ${errorDetails}`; - if (errorDetails === '-') { - errorMsg += '\nNote: Negative numbers are not supported'; - } else if (errorDetails === ' ') { - errorMsg += `\nNote: Every roll must be closed by ${config.postfix}`; - } - break; - case 'NoZerosAllowed': - errorMsg = 'Formatting Error: '; - switch (errorDetails) { - case 'base': - errorMsg += 'Die Size and Die Count'; - break; - case 'drop': - errorMsg += 'Drop (`d` or `dl`)'; - break; - case 'keep': - errorMsg += 'Keep (`k` or `kh`)'; - break; - case 'dropHigh': - errorMsg += 'Drop Highest (`dh`)'; - break; - case 'keepLow': - errorMsg += 'Keep Lowest (`kl`)'; - break; - case 'reroll': - errorMsg += 'Reroll (`r`)'; - break; - case 'critScore': - errorMsg += 'Crit Score (`cs`)'; - break; - case 'critFail': - errorMsg += 'Crit Fail (`cf`)'; - break; - default: - errorMsg += `Unhandled - ${errorDetails}`; - break; - } - errorMsg += ' cannot be zero'; - break; - case 'NoRerollOnAllSides': - errorMsg = 'Error: Cannot reroll all sides of a die, must have at least one side that does not get rerolled'; - break; - case 'CritScoreMinGtrMax': - errorMsg = 'Formatting Error: CritScore maximum cannot be greater than minimum, check formatting and flip min/max'; - break; - case 'MaxLoopsExceeded': - errorMsg = 'Error: Roll is too complex or reaches infinity'; - break; - case 'UnbalancedParens': - errorMsg = 'Formatting Error: At least one of the equations contains unbalanced parenthesis'; - break; - case 'EMDASNotNumber': - errorMsg = 'Error: One or more operands is not a number'; - break; - case 'ConfWhat': - errorMsg = 'Error: Not all values got processed, please report the command used'; - break; - case 'OperatorWhat': - errorMsg = 'Error: Something really broke with the Operator, try again'; - break; - case 'OperandNaN': - errorMsg = 'Error: One or more operands reached NaN, check input'; - break; - case 'UndefinedStep': - errorMsg = 'Error: Roll became undefined, one or more operands are not a roll or a number, check input'; - break; - default: - log(LT.ERROR, `Unhandled Parser Error: ${errorName}, ${errorDetails}`); - errorMsg = `Unhandled Error: ${solverError.message}\nCheck input and try again, if issue persists, please use \`${config.prefix}report\` to alert the devs of the issue`; - break; - } - // Fill in the return block returnMsg.error = true; - returnMsg.errorCode = solverError.message; - returnMsg.errorMsg = errorMsg; + [returnMsg.errorCode, returnMsg.errorMsg] = translateError(e as Error); } return returnMsg; diff --git a/src/artigen/solver.d.ts b/src/artigen/solver.d.ts index 7e471b9..1191ba6 100644 --- a/src/artigen/solver.d.ts +++ b/src/artigen/solver.d.ts @@ -1,6 +1,8 @@ // Available Roll Types export type RollType = '' | 'roll20' | 'fate' | 'cwod' | 'ova'; +export type MathConf = string | number | SolvedStep; + // RollSet is used to preserve all information about a calculated roll export type RollSet = { type: RollType; @@ -24,6 +26,7 @@ export type SolvedStep = { // ReturnData is the temporary internal type used before getting turned into SolvedRoll export type ReturnData = { rollTotal: number; + rollPreFormat: string; rollPostFormat: string; rollDetails: string; containsCrit: boolean; diff --git a/src/artigen/solver.ts b/src/artigen/solver.ts index ed4178f..be44043 100644 --- a/src/artigen/solver.ts +++ b/src/artigen/solver.ts @@ -5,14 +5,15 @@ */ import { log, LogTypes as LT } from '@Log4Deno'; -import { SolvedStep } from 'artigen/solver.d.ts'; +import { MathConf, SolvedStep } from 'artigen/solver.d.ts'; import { legalMath, legalMathOperators } from 'artigen/utils/legalMath.ts'; import { loggingEnabled } from 'artigen/utils/logFlag.ts'; +import { getMatchingParenIdx } from 'artigen/utils/parenBalance.ts'; // fullSolver(conf, wrapDetails) returns one condensed SolvedStep // fullSolver is a function that recursively solves the full roll and math -export const fullSolver = (conf: (string | number | SolvedStep)[], wrapDetails: boolean): SolvedStep => { +export const fullSolver = (conf: MathConf[], wrapDetails = false): SolvedStep => { // Initialize PEMDAS const signs = ['^', '*', '/', '%', '+', '-']; const stepSolve = { @@ -33,30 +34,7 @@ export const fullSolver = (conf: (string | number | SolvedStep)[], wrapDetails: loggingEnabled && log(LT.LOG, `Evaluating roll ${JSON.stringify(conf)} | Looking for (`); // Get first open parenthesis let openParenIdx = conf.indexOf('('); - let closeParenIdx = -1; - let nextParenIdx = 0; - - // Using nextParenIdx to count the opening/closing parens, find the matching paren to openParenIdx above - closingParenLocator: for (let i = openParenIdx; i < conf.length; i++) { - loggingEnabled && log(LT.LOG, `Evaluating roll ${JSON.stringify(conf)} | Looking for matching ) openIdx: ${openParenIdx} checking: ${i}`); - // If we hit an open, add one (this includes the openParenIdx we start with), if we hit a close, subtract one - if (conf[i] === '(') { - nextParenIdx++; - } else if (conf[i] === ')') { - nextParenIdx--; - } - - // When nextParenIdx reaches 0 again, we will have found the matching closing parenthesis and can safely exit the for loop - if (nextParenIdx === 0) { - closeParenIdx = i; - break closingParenLocator; - } - } - - // Make sure we did find the correct closing paren, if not, error out now - if (closeParenIdx === -1 || closeParenIdx < openParenIdx) { - throw new Error('UnbalancedParens'); - } + const closeParenIdx = getMatchingParenIdx(conf, openParenIdx); // Call the solver on the items between openParenIdx and closeParenIdx (excluding the parens) const parenSolve = fullSolver(conf.slice(openParenIdx + 1, closeParenIdx), true); diff --git a/src/artigen/utils/escape.ts b/src/artigen/utils/escape.ts index 7fc43ca..1463cd3 100644 --- a/src/artigen/utils/escape.ts +++ b/src/artigen/utils/escape.ts @@ -1,5 +1,7 @@ import { log, LogTypes as LT } from '@Log4Deno'; +import config from '~config'; + import { loggingEnabled } from 'artigen/utils/logFlag.ts'; // escapeCharacters(str, esc) returns str @@ -14,3 +16,13 @@ export const escapeCharacters = (str: string, esc: string): string => { } return str; }; + +// escapePrefixPostfix(str) returns str +// Escapes all characters that need escaped in a regex string to allow prefix/postfix to be configurable +const escapePrefixPostfix = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +export const cmdSplitRegex = new RegExp(`(${escapePrefixPostfix(config.prefix)})|(${escapePrefixPostfix(config.postfix)})`, 'g'); + +// Internal is used for recursive text replacement, these will always be the top level as they get replaced with config.prefix/postfix when exiting each level +export const openInternal = '\u2045'; +export const closeInternal = '\u2046'; +export const internalWrapRegex = new RegExp(`([${openInternal}${closeInternal}])`, 'g'); diff --git a/src/artigen/utils/parenBalance.ts b/src/artigen/utils/parenBalance.ts new file mode 100644 index 0000000..0b68b85 --- /dev/null +++ b/src/artigen/utils/parenBalance.ts @@ -0,0 +1,59 @@ +import { log, LogTypes as LT } from '@Log4Deno'; + +import config from '~config'; + +import { MathConf } from 'artigen/solver.d.ts'; + +import { closeInternal, openInternal } from 'artigen/utils/escape.ts'; +import { loggingEnabled } from 'artigen/utils/logFlag.ts'; + +const checkBalance = (conf: MathConf[], openStr: string, closeStr: string, errorType: string, getMatching: boolean, openIdx: number): number => { + let parenCnt = 0; + + // Verify there are equal numbers of opening and closing parenthesis by adding 1 for opening parens and subtracting 1 for closing parens + for (let i = openIdx; i < conf.length; i++) { + loggingEnabled && + log( + LT.LOG, + `${getMatching ? 'Looking for matching' : 'Checking'} ${openStr}/${closeStr} ${getMatching ? '' : 'balance '}on ${ + JSON.stringify( + conf, + ) + } | at ${JSON.stringify(conf[i])}`, + ); + if (conf[i] === openStr) { + parenCnt++; + } else if (conf[i] === closeStr) { + parenCnt--; + } + + // If parenCnt ever goes below 0, that means too many closing paren appeared before opening parens + if (parenCnt < 0) { + throw new Error(`Unbalanced${errorType}`); + } + + // When parenCnt reaches 0 again, we will have found the matching closing parenthesis and can safely exit the for loop + if (getMatching && parenCnt === 0) { + loggingEnabled && log(LT.LOG, `Matching ${openStr}/${closeStr} found at "${i}" | ${JSON.stringify(conf[i])}`); + return i; + } + } + + // If the parenCnt is not 0, then we do not have balanced parens and need to error out now + // If getMatching flag is set and we have exited the loop, we did not find a matching paren + if (parenCnt !== 0 || getMatching) { + throw new Error(`Unbalanced${errorType}`); + } + + // getMatching flag not set, this value is unused + return 0; +}; + +// assertXBalance verifies the entire conf has balanced X +export const assertParenBalance = (conf: MathConf[]) => checkBalance(conf, '(', ')', 'Paren', false, 0); +export const assertPrePostBalance = (conf: MathConf[]) => checkBalance(conf, config.prefix, config.postfix, 'PrefixPostfix', false, 0); + +// getMatchingXIdx gets the matching X, also partially verifies the conf has balanced X +export const getMatchingInternalIdx = (conf: MathConf[], openIdx: number): number => checkBalance(conf, openInternal, closeInternal, 'Internal', true, openIdx); +export const getMatchingParenIdx = (conf: MathConf[], openIdx: number): number => checkBalance(conf, '(', ')', 'Paren', true, openIdx); +export const getMatchingPostfixIdx = (conf: MathConf[], openIdx: number): number => checkBalance(conf, config.prefix, config.postfix, 'PrefixPostfix', true, openIdx); diff --git a/src/artigen/utils/translateError.ts b/src/artigen/utils/translateError.ts new file mode 100644 index 0000000..d2d0fd4 --- /dev/null +++ b/src/artigen/utils/translateError.ts @@ -0,0 +1,114 @@ +import { log, LogTypes as LT } from '@Log4Deno'; + +import config from '~config'; + +export const translateError = (solverError: Error): [string, string] => { + // Welp, the unthinkable happened, we hit an error + + // Split on _ for the error messages that have more info than just their name + const errorSplits = solverError.message.split('_'); + const errorName = errorSplits.shift(); + const errorDetails = errorSplits.join('_'); + + let errorMsg = ''; + + // Translate the errorName to a specific errorMsg + switch (errorName) { + case 'WholeDieCountSizeOnly': + errorMsg = 'Error: Die Size and Die Count must be whole numbers'; + break; + case 'YouNeedAD': + errorMsg = 'Formatting Error: Missing die size and count config'; + break; + case 'CannotParseDieCount': + errorMsg = `Formatting Error: Cannot parse \`${errorDetails}\` as a number`; + break; + case 'DoubleSeparator': + errorMsg = `Formatting Error: \`${errorDetails}\` should only be specified once per roll, remove all but one and repeat roll`; + break; + case 'FormattingError': + errorMsg = 'Formatting Error: Cannot use Keep and Drop at the same time, remove all but one and repeat roll'; + break; + case 'NoMaxWithDash': + errorMsg = 'Formatting Error: CritScore range specified without a maximum, remove - or add maximum to correct'; + break; + case 'UnknownOperation': + errorMsg = `Error: Unknown Operation ${errorDetails}`; + if (errorDetails === '-') { + errorMsg += '\nNote: Negative numbers are not supported'; + } else if (errorDetails === ' ') { + errorMsg += `\nNote: Every roll must be closed by ${config.postfix}`; + } + break; + case 'NoZerosAllowed': + errorMsg = 'Formatting Error: '; + switch (errorDetails) { + case 'base': + errorMsg += 'Die Size and Die Count'; + break; + case 'drop': + errorMsg += 'Drop (`d` or `dl`)'; + break; + case 'keep': + errorMsg += 'Keep (`k` or `kh`)'; + break; + case 'dropHigh': + errorMsg += 'Drop Highest (`dh`)'; + break; + case 'keepLow': + errorMsg += 'Keep Lowest (`kl`)'; + break; + case 'reroll': + errorMsg += 'Reroll (`r`)'; + break; + case 'critScore': + errorMsg += 'Crit Score (`cs`)'; + break; + case 'critFail': + errorMsg += 'Crit Fail (`cf`)'; + break; + default: + errorMsg += `Unhandled - ${errorDetails}`; + break; + } + errorMsg += ' cannot be zero'; + break; + case 'NoRerollOnAllSides': + errorMsg = 'Error: Cannot reroll all sides of a die, must have at least one side that does not get rerolled'; + break; + case 'CritScoreMinGtrMax': + errorMsg = 'Formatting Error: CritScore maximum cannot be greater than minimum, check formatting and flip min/max'; + break; + case 'Invalid string length': + case 'MaxLoopsExceeded': + errorMsg = 'Error: Roll is too complex or reaches infinity'; + break; + case 'UnbalancedParen': + errorMsg = 'Formatting Error: At least one of the equations contains unbalanced `(`/`)`'; + break; + case 'UnbalancedPrefixPostfix': + errorMsg = `Formatting Error: At least one of the equations contains unbalanced \`${config.prefix}\`/\`${config.postfix}\``; + break; + case 'EMDASNotNumber': + errorMsg = 'Error: One or more operands is not a number'; + break; + case 'ConfWhat': + errorMsg = 'Error: Not all values got processed, please report the command used'; + break; + case 'OperatorWhat': + errorMsg = 'Error: Something really broke with the Operator, try again'; + break; + case 'OperandNaN': + errorMsg = 'Error: One or more operands reached NaN, check input'; + break; + case 'UndefinedStep': + errorMsg = 'Error: Roll became undefined, one or more operands are not a roll or a number, check input'; + break; + default: + log(LT.ERROR, `Unhandled Parser Error: ${errorName}, ${errorDetails}`); + errorMsg = `Unhandled Error: ${solverError.message}\nCheck input and try again, if issue persists, please use \`${config.prefix}report\` to alert the devs of the issue`; + break; + } + + return [solverError.message, errorMsg]; +}; diff --git a/src/commands/roll.ts b/src/commands/roll.ts index b0ac47d..b1f2158 100644 --- a/src/commands/roll.ts +++ b/src/commands/roll.ts @@ -49,13 +49,10 @@ export const roll = async (message: DiscordenoMessage, args: string[], command: return; } - // Rejoin all of the args and send it into the solver, if solver returns a falsy item, an error object will be substituted in - const rollCmd = message.content.substring(config.prefix.length); - sendRollRequest({ apiRoll: false, dd: { myResponse: m, originalMessage: message }, - rollCmd, + rollCmd: message.content, modifiers, originalCommand, }); diff --git a/src/endpoints/gets/apiRoll.ts b/src/endpoints/gets/apiRoll.ts index 67396eb..7824b3a 100644 --- a/src/endpoints/gets/apiRoll.ts +++ b/src/endpoints/gets/apiRoll.ts @@ -78,7 +78,7 @@ export const apiRoll = async (query: Map, apiUserid: bigint): Pr } // Clip off the leading prefix. API calls must be formatted with a prefix at the start to match how commands are sent in Discord - rollCmd = rollCmd.substring(rollCmd.indexOf(config.prefix) + 2).replace(/%20/g, ' '); + rollCmd = rollCmd.replace(/%20/g, ' ').trim(); const modifiers: RollModifiers = { noDetails: query.has('nd'),