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
This commit is contained in:
Ean Milligan 2025-05-03 07:51:12 -04:00
parent bdec5e7850
commit 03a2acc386
10 changed files with 461 additions and 328 deletions

View File

@ -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];
}
};

View File

@ -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[] = <MathConf[]> cmd
.replace(cmdSplitRegex, '')
.replace(internalWrapRegex, '')
.replace(/ /g, '')
.split(/([-+()*/^]|(?<![d%])%)/g)
.filter((x) => 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] = <number> mathConf[i] * -1;
} else {
(<SolvedStep> mathConf[i]).total = (<SolvedStep> mathConf[i]).total * -1;
(<SolvedStep> mathConf[i]).details = `-${(<SolvedStep> 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,
];
};

View File

@ -1,22 +1,19 @@
import { log, LogTypes as LT } from '@Log4Deno'; 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 { cmdSplitRegex, escapeCharacters } from 'artigen/utils/escape.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 { loggingEnabled } from 'artigen/utils/logFlag.ts'; import { loggingEnabled } from 'artigen/utils/logFlag.ts';
import { compareTotalRolls, compareTotalRollsReverse } from 'artigen/utils/sortFuncs.ts'; import { compareTotalRolls, compareTotalRollsReverse } from 'artigen/utils/sortFuncs.ts';
import { translateError } from 'artigen/utils/translateError.ts';
import { RollModifiers } from 'src/mod.d.ts'; import { RollModifiers } from 'src/mod.d.ts';
import { assertPrePostBalance } from 'src/artigen/utils/parenBalance.ts';
// parseRoll(fullCmd, modifiers) // parseRoll(fullCmd, modifiers)
// parseRoll handles converting fullCmd into a computer readable format for processing, and finally executes the solving // parseRoll handles converting fullCmd into a computer readable format for processing, and finally executes the solving
export const parseRoll = (fullCmd: string, modifiers: RollModifiers): SolvedRoll => { export const parseRoll = (fullCmd: string, modifiers: RollModifiers): SolvedRoll => {
const operators = ['(', ')', '^', '*', '/', '%', '+', '-'];
const returnMsg = <SolvedRoll> { const returnMsg = <SolvedRoll> {
error: false, error: false,
errorCode: '', 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 { try {
// Split the fullCmd by the command prefix to allow every roll/math op to be handled individually // filter removes all null/empty strings since we don't care about them
const sepRolls = fullCmd.split(config.prefix); const sepCmds = fullCmd.split(cmdSplitRegex).filter((x) => x);
// TODO(@burn-e99): HERE for the [[ ]] nesting stuff loggingEnabled && log(LT.LOG, `Split cmd into parts ${JSON.stringify(sepCmds)}`);
const tempReturnData: ReturnData[] = []; // Verify prefix/postfix balance
const tempCountDetails: CountDetails[] = [ assertPrePostBalance(sepCmds);
{
total: 0,
successful: 0,
failed: 0,
rerolled: 0,
dropped: 0,
exploded: 0,
},
];
// Loop thru all roll/math ops // Send the split roll into the command tokenizer to get raw response data
for (const sepRoll of sepRolls) { const [tempReturnData, tempCountDetails] = tokenizeCmd(sepCmds, modifiers, true);
loggingEnabled && log(LT.LOG, `Parsing roll ${fullCmd} | Working ${sepRoll}`); loggingEnabled && log(LT.LOG, `Return data is back ${JSON.stringify(tempReturnData)}`);
// 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(/([-+()*/^]|(?<![d%])%)/g);
// Verify there are equal numbers of opening and closing parenthesis by adding 1 for opening parens and subtracting 1 for closing parens
let parenCnt = 0;
mathConf.forEach((e) => {
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] = <number> mathConf[i] * -1;
} else {
(<SolvedStep> mathConf[i]).total = (<SolvedStep> mathConf[i]).total * -1;
(<SolvedStep> mathConf[i]).details = `-${(<SolvedStep> 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
// Remove any floating spaces from fullCmd // Remove any floating spaces from fullCmd
if (fullCmd[fullCmd.length - 1] === ' ') { fullCmd = fullCmd.trim();
fullCmd = fullCmd.substring(0, fullCmd.length - 1);
}
// Escape any | and ` chars in fullCmd to prevent spoilers and code blocks from acting up // Escape any | and ` chars in fullCmd to prevent spoilers and code blocks from acting up
fullCmd = escapeCharacters(fullCmd, '|'); 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 // The ': ' is used by generateRollEmbed to split line 2 up
const resultStr = tempReturnData.length > 1 ? 'Results: ' : 'Result: '; 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 a theoretical roll is requested, mark the output as such, else use default formatting
if (modifiers.maxRoll || modifiers.minRoll || modifiers.nominalRoll) { 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 theoreticalBools = [modifiers.maxRoll, modifiers.minRoll, modifiers.nominalRoll];
const theoreticalText = theoreticalTexts[theoreticalBools.indexOf(true)]; 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}`; line2 = `Theoretical ${theoreticalText} ${resultStr}`;
} else if (modifiers.order === 'a') { } else if (modifiers.order === 'a') {
line1 = ` requested the following rolls to be ordered from least to greatest:\n\`${config.prefix}${fullCmd}\``; line1 = ` requested the following rolls to be ordered from least to greatest:\n\`${fullCmd}\``;
line2 = resultStr;
tempReturnData.sort(compareTotalRolls); tempReturnData.sort(compareTotalRolls);
} else if (modifiers.order === 'd') { } else if (modifiers.order === 'd') {
line1 = ` requested the following rolls to be ordered from greatest to least:\n\`${config.prefix}${fullCmd}\``; line1 = ` requested the following rolls to be ordered from greatest to least:\n\`${fullCmd}\``;
line2 = resultStr;
tempReturnData.sort(compareTotalRollsReverse); tempReturnData.sort(compareTotalRollsReverse);
} else { } else {
line1 = ` rolled:\n\`${config.prefix}${fullCmd}\``; line1 = ` rolled:\n\`${fullCmd}\``;
line2 = resultStr;
} }
// Fill out all of the details and results now // Fill out all of the details and results now
@ -260,11 +95,8 @@ export const parseRoll = (fullCmd: string, modifiers: RollModifiers): SolvedRoll
// Populate line2 (the results) and line3 (the details) with their data // Populate line2 (the results) and line3 (the details) with their data
if (modifiers.order === '') { if (modifiers.order === '') {
line2 += `${preFormat}${modifiers.commaTotals ? e.rollTotal.toLocaleString() : e.rollTotal}${postFormat}${ line2 += `${e.rollPreFormat ? escapeCharacters(e.rollPreFormat, '|*_~`') : ' '}${preFormat}${modifiers.commaTotals ? e.rollTotal.toLocaleString() : e.rollTotal}${postFormat}${
escapeCharacters( e.rollPostFormat ? escapeCharacters(e.rollPostFormat, '|*_~`') : ''
e.rollPostFormat,
'|*_~`',
)
}`; }`;
} else { } else {
// If order is on, turn rolls into csv without formatting // If order is on, turn rolls into csv without formatting
@ -294,114 +126,9 @@ export const parseRoll = (fullCmd: string, modifiers: RollModifiers): SolvedRoll
exploded: acc.exploded + cnt.exploded, exploded: acc.exploded + cnt.exploded,
})); }));
} catch (e) { } 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 // Fill in the return block
returnMsg.error = true; returnMsg.error = true;
returnMsg.errorCode = solverError.message; [returnMsg.errorCode, returnMsg.errorMsg] = translateError(e as Error);
returnMsg.errorMsg = errorMsg;
} }
return returnMsg; return returnMsg;

View File

@ -1,6 +1,8 @@
// Available Roll Types // Available Roll Types
export type RollType = '' | 'roll20' | 'fate' | 'cwod' | 'ova'; export type RollType = '' | 'roll20' | 'fate' | 'cwod' | 'ova';
export type MathConf = string | number | SolvedStep;
// RollSet is used to preserve all information about a calculated roll // RollSet is used to preserve all information about a calculated roll
export type RollSet = { export type RollSet = {
type: RollType; type: RollType;
@ -24,6 +26,7 @@ export type SolvedStep = {
// ReturnData is the temporary internal type used before getting turned into SolvedRoll // ReturnData is the temporary internal type used before getting turned into SolvedRoll
export type ReturnData = { export type ReturnData = {
rollTotal: number; rollTotal: number;
rollPreFormat: string;
rollPostFormat: string; rollPostFormat: string;
rollDetails: string; rollDetails: string;
containsCrit: boolean; containsCrit: boolean;

View File

@ -5,14 +5,15 @@
*/ */
import { log, LogTypes as LT } from '@Log4Deno'; 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 { legalMath, legalMathOperators } from 'artigen/utils/legalMath.ts';
import { loggingEnabled } from 'artigen/utils/logFlag.ts'; import { loggingEnabled } from 'artigen/utils/logFlag.ts';
import { getMatchingParenIdx } from 'artigen/utils/parenBalance.ts';
// fullSolver(conf, wrapDetails) returns one condensed SolvedStep // fullSolver(conf, wrapDetails) returns one condensed SolvedStep
// fullSolver is a function that recursively solves the full roll and math // 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 // Initialize PEMDAS
const signs = ['^', '*', '/', '%', '+', '-']; const signs = ['^', '*', '/', '%', '+', '-'];
const stepSolve = { 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 (`); loggingEnabled && log(LT.LOG, `Evaluating roll ${JSON.stringify(conf)} | Looking for (`);
// Get first open parenthesis // Get first open parenthesis
let openParenIdx = conf.indexOf('('); let openParenIdx = conf.indexOf('(');
let closeParenIdx = -1; const closeParenIdx = getMatchingParenIdx(conf, openParenIdx);
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');
}
// Call the solver on the items between openParenIdx and closeParenIdx (excluding the parens) // Call the solver on the items between openParenIdx and closeParenIdx (excluding the parens)
const parenSolve = fullSolver(conf.slice(openParenIdx + 1, closeParenIdx), true); const parenSolve = fullSolver(conf.slice(openParenIdx + 1, closeParenIdx), true);

View File

@ -1,5 +1,7 @@
import { log, LogTypes as LT } from '@Log4Deno'; import { log, LogTypes as LT } from '@Log4Deno';
import config from '~config';
import { loggingEnabled } from 'artigen/utils/logFlag.ts'; import { loggingEnabled } from 'artigen/utils/logFlag.ts';
// escapeCharacters(str, esc) returns str // escapeCharacters(str, esc) returns str
@ -14,3 +16,13 @@ export const escapeCharacters = (str: string, esc: string): string => {
} }
return str; 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');

View File

@ -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);

View File

@ -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];
};

View File

@ -49,13 +49,10 @@ export const roll = async (message: DiscordenoMessage, args: string[], command:
return; 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({ sendRollRequest({
apiRoll: false, apiRoll: false,
dd: { myResponse: m, originalMessage: message }, dd: { myResponse: m, originalMessage: message },
rollCmd, rollCmd: message.content,
modifiers, modifiers,
originalCommand, originalCommand,
}); });

View File

@ -78,7 +78,7 @@ export const apiRoll = async (query: Map<string, string>, 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 // 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 = { const modifiers: RollModifiers = {
noDetails: query.has('nd'), noDetails: query.has('nd'),