From 337b266456bfe370dbf9a6432e19c4703500361e Mon Sep 17 00:00:00 2001 From: "Ean Milligan (Bastion)" Date: Fri, 6 May 2022 23:20:04 -0400 Subject: [PATCH] Continued reorg, broke solver down into parts for better readability --- src/api.ts | 2 +- src/commands/apiCmd.ts | 2 +- .../apiCmd/{_apiIndex.ts => _index.ts} | 0 src/commands/roll.ts | 4 +- .../roll/{_rollIndex.ts => _index.ts} | 0 src/solver.ts | 1048 ----------------- src/solver/_index.ts | 5 + src/solver/parser.ts | 286 +++++ src/solver/rollFormatter.ts | 66 ++ src/solver/rollUtils.ts | 67 ++ src/solver/roller.ts | 449 +++++++ src/{ => solver}/solver.d.ts | 0 src/solver/solver.ts | 211 ++++ 13 files changed, 1088 insertions(+), 1052 deletions(-) rename src/commands/apiCmd/{_apiIndex.ts => _index.ts} (100%) rename src/commands/roll/{_rollIndex.ts => _index.ts} (100%) delete mode 100644 src/solver.ts create mode 100644 src/solver/_index.ts create mode 100644 src/solver/parser.ts create mode 100644 src/solver/rollFormatter.ts create mode 100644 src/solver/rollUtils.ts create mode 100644 src/solver/roller.ts rename src/{ => solver}/solver.d.ts (100%) create mode 100644 src/solver/solver.ts diff --git a/src/api.ts b/src/api.ts index 4918e92..7b16729 100644 --- a/src/api.ts +++ b/src/api.ts @@ -20,7 +20,7 @@ import { } from "../deps.ts"; import { dbClient, queries } from "./db.ts"; -import solver from "./solver.ts"; +import solver from "./solver/_index.ts"; import { generateApiKeyEmail, generateApiDeleteEmail, generateDMFailed } from "./constantCmds.ts"; diff --git a/src/commands/apiCmd.ts b/src/commands/apiCmd.ts index 4eefcae..6538a32 100644 --- a/src/commands/apiCmd.ts +++ b/src/commands/apiCmd.ts @@ -6,7 +6,7 @@ import { // Log4Deno deps LT, log } from "../../deps.ts"; -import apiCommands from "./apiCmd/_apiIndex.ts"; +import apiCommands from "./apiCmd/_index.ts"; import { constantCmds } from "../constantCmds.ts"; export const api = async (message: DiscordenoMessage, args: string[]) => { diff --git a/src/commands/apiCmd/_apiIndex.ts b/src/commands/apiCmd/_index.ts similarity index 100% rename from src/commands/apiCmd/_apiIndex.ts rename to src/commands/apiCmd/_index.ts diff --git a/src/commands/roll.ts b/src/commands/roll.ts index 7aaf627..161f550 100644 --- a/src/commands/roll.ts +++ b/src/commands/roll.ts @@ -8,9 +8,9 @@ import { // Log4Deno deps LT, log } from "../../deps.ts"; -import solver from "../solver.ts"; +import solver from "../solver/_index.ts"; import { constantCmds, generateDMFailed } from "../constantCmds.ts"; -import rollFuncs from "./roll/_rollIndex.ts"; +import rollFuncs from "./roll/_index.ts"; export const roll = async (message: DiscordenoMessage, args: string[], command: string) => { // Light telemetry to see how many times a command is being run diff --git a/src/commands/roll/_rollIndex.ts b/src/commands/roll/_index.ts similarity index 100% rename from src/commands/roll/_rollIndex.ts rename to src/commands/roll/_index.ts diff --git a/src/solver.ts b/src/solver.ts deleted file mode 100644 index d32a596..0000000 --- a/src/solver.ts +++ /dev/null @@ -1,1048 +0,0 @@ -/* The Artificer was built in memory of Babka - * With love, Ean - * - * December 21, 2020 - */ - -import { - // Log4Deno deps - LT, log -} from "../deps.ts"; - -import config from "../config.ts"; -import { RollSet, SolvedStep, SolvedRoll, ReturnData } from "./solver.d.ts"; - -// MAXLOOPS determines how long the bot will attempt a roll -// Default is 5000000 (5 million), which results in at most a 10 second delay before the bot calls the roll infinite or too complex -// Increase at your own risk -const MAXLOOPS = 5000000; - -// genRoll(size) returns number -// genRoll rolls a die of size size and returns the result -const genRoll = (size: number): number => { - // Math.random * size will return a decimal number between 0 and size (excluding size), so add 1 and floor the result to not get 0 as a result - return Math.floor((Math.random() * size) + 1); -}; - -// compareRolls(a, b) returns -1|0|1 -// compareRolls is used to order an array of RollSets by RollSet.roll -const compareRolls = (a: RollSet, b: RollSet): number => { - if (a.roll < b.roll) { - return -1; - } - if (a.roll > b.roll) { - return 1; - } - return 0; -}; - -// compareTotalRolls(a, b) returns -1|0|1 -// compareTotalRolls is used to order an array of RollSets by RollSet.roll -const compareTotalRolls = (a: ReturnData, b: ReturnData): number => { - if (a.rollTotal < b.rollTotal) { - return -1; - } - if (a.rollTotal > b.rollTotal) { - return 1; - } - return 0; -}; - -// compareRolls(a, b) returns -1|0|1 -// compareRolls is used to order an array of RollSets by RollSet.origidx -const compareOrigidx = (a: RollSet, b: RollSet): number => { - if (a.origidx < b.origidx) { - return -1; - } - if (a.origidx > b.origidx) { - return 1; - } - return 0; -}; - -// escapeCharacters(str, esc) returns str -// escapeCharacters escapes all characters listed in esc -const escapeCharacters = (str: string, esc: string): string => { - // Loop thru each esc char one at a time - for (let i = 0; i < esc.length; i++) { - log(LT.LOG, `Escaping character ${esc[i]} | ${str}, ${esc}`); - // Create a new regex to look for that char that needs replaced and escape it - const temprgx = new RegExp(`[${esc[i]}]`, "g"); - str = str.replace(temprgx, `\\${esc[i]}`); - } - return str; -}; - -// roll(rollStr, maximiseRoll, nominalRoll) returns RollSet -// roll parses and executes the rollStr, if needed it will also make the roll the maximum or average -const roll = (rollStr: string, maximiseRoll: boolean, nominalRoll: boolean): RollSet[] => { - /* Roll Capabilities - * Deciphers and rolls a single dice roll set - * xdydzracsq! - * - * x [OPT] - number of dice to roll, if omitted, 1 is used - * dy [REQ] - size of dice to roll, d20 = 20 sided die - * dz || dlz [OPT] - drops the lowest z dice, cannot be used with kz - * kz || khz [OPT] - keeps the highest z dice, cannot be used with dz - * dhz [OPT] - drops the highest z dice, cannot be used with kz - * klz [OPT] - keeps the lowest z dice, cannot be used with dz - * ra [OPT] - rerolls any rolls that match a, r3 will reroll any dice that land on 3, throwing out old rolls - * csq || cs=q [OPT] - changes crit score to q - * csq [OPT] - changes crit score to be greater than or equal to q - * cfq || cs=q [OPT] - changes crit fail to q - * cfq [OPT] - changes crit fail to be greater than or equal to q - * ! [OPT] - exploding, rolls another dy for every crit roll - */ - - // Make entire roll lowercase for ease of parsing - rollStr = rollStr.toLowerCase(); - - // Split the roll on the die size (and the drop if its there) - const dpts = rollStr.split("d"); - - // Initialize the configuration to store the parsed data - const rollConf = { - dieCount: 0, - dieSize: 0, - drop: { - on: false, - count: 0 - }, - keep: { - on: false, - count: 0 - }, - dropHigh: { - on: false, - count: 0 - }, - keepLow: { - on: false, - count: 0 - }, - reroll: { - on: false, - nums: [] - }, - critScore: { - on: false, - range: [] - }, - critFail: { - on: false, - range: [] - }, - exploding: false - }; - - // If the dpts is not long enough, throw error - if (dpts.length < 2) { - throw new Error("YouNeedAD"); - } - - // Fill out the die count, first item will either be an int or empty string, short circuit execution will take care of replacing the empty string with a 1 - const tempDC = dpts.shift(); - rollConf.dieCount = parseInt(tempDC || "1"); - - // Finds the end of the die size/beginnning of the additional options - let afterDieIdx = dpts[0].search(/\D/); - if (afterDieIdx === -1) { - afterDieIdx = dpts[0].length; - } - - // Rejoin all remaining parts - let remains = dpts.join("d"); - // Get the die size out of the remains and into the rollConf - rollConf.dieSize = parseInt(remains.slice(0, afterDieIdx)); - remains = remains.slice(afterDieIdx); - - // Finish parsing the roll - if (remains.length > 0) { - // Determine if the first item is a drop, and if it is, add the d back in - if (remains.search(/\D/) !== 0 || remains.indexOf("l") === 0 || remains.indexOf("h") === 0) { - remains = `d${remains}`; - } - - // Loop until all remaining args are parsed - while (remains.length > 0) { - log(LT.LOG, `Handling roll ${rollStr} | Parsing remains ${remains}`); - // Find the next number in the remains to be able to cut out the rule name - let afterSepIdx = remains.search(/\d/); - if (afterSepIdx < 0) { - afterSepIdx = remains.length; - } - // Save the rule name to tSep and remove it from remains - const tSep = remains.slice(0, afterSepIdx); - remains = remains.slice(afterSepIdx); - // Find the next non-number in the remains to be able to cut out the count/num - let afterNumIdx = remains.search(/\D/); - if (afterNumIdx < 0) { - afterNumIdx = remains.length; - } - // Save the count/num to tNum leaving it in remains for the time being - const tNum = parseInt(remains.slice(0, afterNumIdx)); - - // Switch on rule name - switch (tSep) { - case "dl": - case "d": - // Configure Drop (Lowest) - rollConf.drop.on = true; - rollConf.drop.count = tNum; - break; - case "kh": - case "k": - // Configure Keep (Highest) - rollConf.keep.on = true; - rollConf.keep.count = tNum; - break; - case "dh": - // Configure Drop (Highest) - rollConf.dropHigh.on = true; - rollConf.dropHigh.count = tNum; - break; - case "kl": - // Configure Keep (Lowest) - rollConf.keepLow.on = true; - rollConf.keepLow.count = tNum; - break; - case "r": - // Configure Reroll (this can happen multiple times) - rollConf.reroll.on = true; - rollConf.reroll.nums.push(tNum); - break; - case "cs": - case "cs=": - // Configure CritScore for one number (this can happen multiple times) - rollConf.critScore.on = true; - rollConf.critScore.range.push(tNum); - break; - case "cs>": - // Configure CritScore for all numbers greater than or equal to tNum (this could happen multiple times, but why) - rollConf.critScore.on = true; - for (let i = tNum; i <= rollConf.dieSize; i++) { - log(LT.LOG, `Handling roll ${rollStr} | Parsing cs> ${i}`); - rollConf.critScore.range.push(i); - } - break; - case "cs<": - // Configure CritScore for all numbers less than or equal to tNum (this could happen multiple times, but why) - rollConf.critScore.on = true; - for (let i = 0; i <= tNum; i++) { - log(LT.LOG, `Handling roll ${rollStr} | Parsing cs< ${i}`); - rollConf.critScore.range.push(i); - } - break; - case "cf": - case "cf=": - // Configure CritFail for one number (this can happen multiple times) - rollConf.critFail.on = true; - rollConf.critFail.range.push(tNum); - break; - case "cf>": - // Configure CritFail for all numbers greater than or equal to tNum (this could happen multiple times, but why) - rollConf.critFail.on = true; - for (let i = tNum; i <= rollConf.dieSize; i++) { - log(LT.LOG, `Handling roll ${rollStr} | Parsing cf> ${i}`); - rollConf.critFail.range.push(i); - } - break; - case "cf<": - // Configure CritFail for all numbers less than or equal to tNum (this could happen multiple times, but why) - rollConf.critFail.on = true; - for (let i = 0; i <= tNum; i++) { - log(LT.LOG, `Handling roll ${rollStr} | Parsing cf< ${i}`); - rollConf.critFail.range.push(i); - } - break; - case "!": - // Configure Exploding - rollConf.exploding = true; - afterNumIdx = 1; - break; - default: - // Throw error immediately if unknown op is encountered - throw new Error(`UnknownOperation_${tSep}`); - } - // Finally slice off everything else parsed this loop - remains = remains.slice(afterNumIdx); - } - } - - // Verify the parse, throwing errors for every invalid config - if (rollConf.dieCount < 0) { - throw new Error("NoZerosAllowed_base"); - } - if (rollConf.dieCount === 0 || rollConf.dieSize === 0) { - throw new Error("NoZerosAllowed_base"); - } - // Since only one drop or keep option can be active, count how many are active to throw the right error - let dkdkCnt = 0; - [rollConf.drop.on, rollConf.keep.on, rollConf.dropHigh.on, rollConf.keepLow.on].forEach(e => { - log(LT.LOG, `Handling roll ${rollStr} | Checking if drop/keep is on ${e}`); - if (e) { - dkdkCnt++; - } - }); - if (dkdkCnt > 1) { - throw new Error("FormattingError_dk"); - } - if (rollConf.drop.on && rollConf.drop.count === 0) { - throw new Error("NoZerosAllowed_drop"); - } - if (rollConf.keep.on && rollConf.keep.count === 0) { - throw new Error("NoZerosAllowed_keep"); - } - if (rollConf.dropHigh.on && rollConf.dropHigh.count === 0) { - throw new Error("NoZerosAllowed_dropHigh"); - } - if (rollConf.keepLow.on && rollConf.keepLow.count === 0) { - throw new Error("NoZerosAllowed_keepLow"); - } - if (rollConf.reroll.on && rollConf.reroll.nums.indexOf(0) >= 0) { - throw new Error("NoZerosAllowed_reroll"); - } - - // Roll the roll - const rollSet = []; - /* Roll will contain objects of the following format: - * { - * origidx: 0, - * roll: 0, - * dropped: false, - * rerolled: false, - * exploding: false, - * critHit: false, - * critFail: false - * } - * - * Each of these is defined as following: - * { - * origidx: The original index of the roll - * roll: The resulting roll on this die in the set - * dropped: This die is to be dropped as it was one of the dy lowest dice - * rerolled: This die has been rerolled as it matched rz, it is replaced by the very next die in the set - * exploding: This die was rolled as the previous die exploded (was a crit hit) - * critHit: This die matched csq[-u], max die value used if cs not used - * critFail: This die rolled a nat 1, a critical failure - * } - */ - - // Initialize a templet rollSet to copy multiple times - const templateRoll = { - origidx: 0, - roll: 0, - dropped: false, - rerolled: false, - exploding: false, - critHit: false, - critFail: false - }; - - // Begin counting the number of loops to prevent from getting into an infinite loop - let loopCount = 0; - - // Initial rolling, not handling reroll or exploding here - for (let i = 0; i < rollConf.dieCount; i++) { - log(LT.LOG, `Handling roll ${rollStr} | Initial rolling ${i} of ${JSON.stringify(rollConf)}`); - // If loopCount gets too high, stop trying to calculate infinity - if (loopCount > MAXLOOPS) { - throw new Error("MaxLoopsExceeded"); - } - - // Copy the template to fill out for this iteration - const rolling = JSON.parse(JSON.stringify(templateRoll)); - // If maximiseRoll is on, set the roll to the dieSize, else if nominalRoll is on, set the roll to the average roll of dieSize, else generate a new random roll - rolling.roll = maximiseRoll ? rollConf.dieSize : (nominalRoll ? ((rollConf.dieSize / 2) + 0.5) : genRoll(rollConf.dieSize)); - // Set origidx of roll - rolling.origidx = i; - - // If critScore arg is on, check if the roll should be a crit, if its off, check if the roll matches the die size - if (rollConf.critScore.on && rollConf.critScore.range.indexOf(rolling.roll) >= 0) { - rolling.critHit = true; - } else if (!rollConf.critScore.on) { - rolling.critHit = (rolling.roll === rollConf.dieSize); - } - // If critFail arg is on, check if the roll should be a fail, if its off, check if the roll matches 1 - if (rollConf.critFail.on && rollConf.critFail.range.indexOf(rolling.roll) >= 0) { - rolling.critFail = true; - } else if (!rollConf.critFail.on) { - rolling.critFail = (rolling.roll === 1); - } - - // Push the newly created roll and loop again - rollSet.push(rolling); - loopCount++; - } - - // If needed, handle rerolling and exploding dice now - if (rollConf.reroll.on || rollConf.exploding) { - for (let i = 0; i < rollSet.length; i++) { - log(LT.LOG, `Handling roll ${rollStr} | Handling rerolling and exploding ${JSON.stringify(rollSet[i])}`); - // If loopCount gets too high, stop trying to calculate infinity - if (loopCount > MAXLOOPS) { - throw new Error("MaxLoopsExceeded"); - } - - // If we need to reroll this roll, flag its been replaced and... - if (rollConf.reroll.on && rollConf.reroll.nums.indexOf(rollSet[i].roll) >= 0) { - rollSet[i].rerolled = true; - - // Copy the template to fill out for this iteration - const newRoll = JSON.parse(JSON.stringify(templateRoll)); - // If maximiseRoll is on, set the roll to the dieSize, else if nominalRoll is on, set the roll to the average roll of dieSize, else generate a new random roll - newRoll.roll = maximiseRoll ? rollConf.dieSize : (nominalRoll ? ((rollConf.dieSize / 2) + 0.5) : genRoll(rollConf.dieSize)); - - // If critScore arg is on, check if the roll should be a crit, if its off, check if the roll matches the die size - if (rollConf.critScore.on && rollConf.critScore.range.indexOf(newRoll.roll) >= 0) { - newRoll.critHit = true; - } else if (!rollConf.critScore.on) { - newRoll.critHit = (newRoll.roll === rollConf.dieSize); - } - // If critFail arg is on, check if the roll should be a fail, if its off, check if the roll matches 1 - if (rollConf.critFail.on && rollConf.critFail.range.indexOf(newRoll.roll) >= 0) { - newRoll.critFail = true; - } else if (!rollConf.critFail.on) { - newRoll.critFail = (newRoll.roll === 1); - } - - // Slot this new roll in after the current iteration so it can be processed in the next loop - rollSet.splice(i + 1, 0, newRoll); - } else if (rollConf.exploding && !rollSet[i].rerolled && rollSet[i].critHit) { - //If it exploded, we keep both, so no flags need to be set - - // Copy the template to fill out for this iteration - const newRoll = JSON.parse(JSON.stringify(templateRoll)); - // If maximiseRoll is on, set the roll to the dieSize, else if nominalRoll is on, set the roll to the average roll of dieSize, else generate a new random roll - newRoll.roll = maximiseRoll ? rollConf.dieSize : (nominalRoll ? ((rollConf.dieSize / 2) + 0.5) : genRoll(rollConf.dieSize)); - // Always mark this roll as exploding - newRoll.exploding = true; - - // If critScore arg is on, check if the roll should be a crit, if its off, check if the roll matches the die size - if (rollConf.critScore.on && rollConf.critScore.range.indexOf(newRoll.roll) >= 0) { - newRoll.critHit = true; - } else if (!rollConf.critScore.on) { - newRoll.critHit = (newRoll.roll === rollConf.dieSize); - } - // If critFail arg is on, check if the roll should be a fail, if its off, check if the roll matches 1 - if (rollConf.critFail.on && rollConf.critFail.range.indexOf(newRoll.roll) >= 0) { - newRoll.critFail = true; - } else if (!rollConf.critFail.on) { - newRoll.critFail = (newRoll.roll === 1); - } - - // Slot this new roll in after the current iteration so it can be processed in the next loop - rollSet.splice(i + 1, 0, newRoll); - } - - loopCount++; - } - } - - // If we need to handle the drop/keep flags - if (dkdkCnt > 0) { - // Count how many rerolled dice there are if the reroll flag was on - let rerollCount = 0; - if (rollConf.reroll.on) { - for (let i = 0; i < rollSet.length; i++) { - log(LT.LOG, `Handling roll ${rollStr} | Setting originalIdx on ${JSON.stringify(rollSet[i])}`); - rollSet[i].origidx = i; - - if (rollSet[i].rerolled) { - rerollCount++; - } - } - } - - // Order the rolls from least to greatest (by RollSet.roll) - rollSet.sort(compareRolls); - - // Determine how many valid rolls there are to drop from (may not be equal to dieCount due to exploding) - const validRolls = rollSet.length - rerollCount; - let dropCount = 0; - - // For normal drop and keep, simple subtraction is enough to determine how many to drop - // Protections are in to prevent the dropCount from going below 0 or more than the valid rolls to drop - if (rollConf.drop.on) { - dropCount = rollConf.drop.count; - if (dropCount > validRolls) { - dropCount = validRolls; - } - } else if (rollConf.keep.on) { - dropCount = validRolls - rollConf.keep.count; - if (dropCount < 0) { - dropCount = 0; - } - } - - // For inverted drop and keep, order must be flipped to greatest to least before the simple subtraction can determine how many to drop - // Protections are in to prevent the dropCount from going below 0 or more than the valid rolls to drop - else if (rollConf.dropHigh.on) { - rollSet.reverse(); - dropCount = rollConf.dropHigh.count; - if (dropCount > validRolls) { - dropCount = validRolls; - } - } else if (rollConf.keepLow.on) { - rollSet.reverse(); - dropCount = validRolls - rollConf.keepLow.count; - if (dropCount < 0) { - dropCount = 0; - } - } - - // Now its time to drop all dice needed - let i = 0; - while (dropCount > 0 && i < rollSet.length) { - log(LT.LOG, `Handling roll ${rollStr} | Dropping dice ${dropCount} ${JSON.stringify(rollSet[i])}`); - // Skip all rolls that were rerolled - if (!rollSet[i].rerolled) { - rollSet[i].dropped = true; - dropCount--; - } - i++; - } - - // Finally, return the rollSet to its original order - rollSet.sort(compareOrigidx); - } - - return rollSet; -}; - -// formatRoll(rollConf, maximiseRoll, nominalRoll) returns one SolvedStep -// formatRoll handles creating and formatting the completed rolls into the SolvedStep format -const formatRoll = (rollConf: string, maximiseRoll: boolean, nominalRoll: boolean): SolvedStep => { - let tempTotal = 0; - let tempDetails = "["; - let tempCrit = false; - let tempFail = false; - - // Generate the roll, passing flags thru - const tempRollSet = roll(rollConf, maximiseRoll, nominalRoll); - - // Loop thru all parts of the roll to document everything that was done to create the total roll - tempRollSet.forEach(e => { - log(LT.LOG, `Formatting roll ${rollConf} | ${JSON.stringify(e)}`); - let preFormat = ""; - let postFormat = ""; - - if (!e.dropped && !e.rerolled) { - // If the roll was not dropped or rerolled, add it to the stepTotal and flag the critHit/critFail - tempTotal += e.roll; - if (e.critHit) { - tempCrit = true; - } - if (e.critFail) { - tempFail = true; - } - } - // If the roll was a crit hit or fail, or dropped/rerolled, add the formatting needed - if (e.critHit) { - // Bold for crit success - preFormat = `**${preFormat}`; - postFormat = `${postFormat}**`; - } - if (e.critFail) { - // Underline for crit fail - preFormat = `__${preFormat}`; - postFormat = `${postFormat}__`; - } - if (e.dropped || e.rerolled) { - // Strikethrough for dropped/rerolled rolls - preFormat = `~~${preFormat}`; - postFormat = `${postFormat}~~`; - } - - // Finally add this to the roll's details - tempDetails += `${preFormat}${e.roll}${postFormat} + `; - }); - // After the looping is done, remove the extra " + " from the details and cap it with the closing ] - tempDetails = tempDetails.substring(0, (tempDetails.length - 3)); - tempDetails += "]"; - - return { - total: tempTotal, - details: tempDetails, - containsCrit: tempCrit, - containsFail: tempFail - }; -}; - -// fullSolver(conf, wrapDetails) returns one condensed SolvedStep -// fullSolver is a function that recursively solves the full roll and math -const fullSolver = (conf: (string | number | SolvedStep)[], wrapDetails: boolean): SolvedStep => { - // Initialize PEMDAS - const signs = ["^", "*", "/", "%", "+", "-"]; - const stepSolve = { - total: 0, - details: "", - containsCrit: false, - containsFail: false - }; - - // If entering with a single number, note it now - let singleNum = false; - if (conf.length === 1) { - singleNum = true; - } - - // Evaluate all parenthesis - while (conf.indexOf("(") > -1) { - log(LT.LOG, `Evaluating roll ${JSON.stringify(conf)} | Looking for (`); - // Get first open parenthesis - const openParen = conf.indexOf("("); - let closeParen = -1; - let nextParen = 0; - - // Using nextParen to count the opening/closing parens, find the matching paren to openParen above - for (let i = openParen; i < conf.length; i++) { - log(LT.LOG, `Evaluating roll ${JSON.stringify(conf)} | Looking for matching ) openIdx: ${openParen} checking: ${i}`); - // If we hit an open, add one (this includes the openParen we start with), if we hit a close, subtract one - if (conf[i] === "(") { - nextParen++; - } else if (conf[i] === ")") { - nextParen--; - } - - // When nextParen reaches 0 again, we will have found the matching closing parenthesis and can safely exit the for loop - if (nextParen === 0) { - closeParen = i; - break; - } - } - - // Make sure we did find the correct closing paren, if not, error out now - if (closeParen === -1 || closeParen < openParen) { - throw new Error("UnbalancedParens"); - } - - // Replace the itemes between openParen and closeParen (including the parens) with its solved equilvalent by calling the solver on the items between openParen and closeParen (excluding the parens) - conf.splice(openParen, (closeParen + 1), fullSolver(conf.slice((openParen + 1), closeParen), true)); - - // Determing if we need to add in a multiplication sign to handle implicit multiplication (like "(4)2" = 8) - // insertedMult flags if there was a multiplication sign inserted before the parens - let insertedMult = false; - // Check if a number was directly before openParen and slip in the "*" if needed - if (((openParen - 1) > -1) && (signs.indexOf(conf[openParen - 1].toString()) === -1)) { - insertedMult = true; - conf.splice(openParen, 0, "*"); - } - // Check if a number is directly after closeParen and slip in the "*" if needed - if (!insertedMult && (((openParen + 1) < conf.length) && (signs.indexOf(conf[openParen + 1].toString()) === -1))) { - conf.splice((openParen + 1), 0, "*"); - } else if (insertedMult && (((openParen + 2) < conf.length) && (signs.indexOf(conf[openParen + 2].toString()) === -1))) { - // insertedMult is utilized here to let us account for an additional item being inserted into the array (the "*" from before openParn) - conf.splice((openParen + 2), 0, "*"); - } - } - - // Evaluate all EMMDAS by looping thru each teir of operators (exponential is the higehest teir, addition/subtraction the lowest) - const allCurOps = [["^"], ["*", "/", "%"], ["+", "-"]]; - allCurOps.forEach(curOps => { - log(LT.LOG, `Evaluating roll ${JSON.stringify(conf)} | Evaluating ${JSON.stringify(curOps)}`); - // Iterate thru all operators/operands in the conf - for (let i = 0; i < conf.length; i++) { - log(LT.LOG, `Evaluating roll ${JSON.stringify(conf)} | Evaluating ${JSON.stringify(curOps)} | Checking ${JSON.stringify(conf[i])}`); - // Check if the current index is in the active teir of operators - if (curOps.indexOf(conf[i].toString()) > -1) { - // Grab the operands from before and after the operator - const operand1 = conf[i - 1]; - const operand2 = conf[i + 1]; - // Init temp math to NaN to catch bad parsing - let oper1 = NaN; - let oper2 = NaN; - const subStepSolve = { - total: NaN, - details: "", - containsCrit: false, - containsFail: false - }; - // Flag to prevent infinte loop when dealing with negative numbers (such as [[-1+20]]) - let shouldDecrement = true; - - // If operand1 is a SolvedStep, populate our subStepSolve with its details and crit/fail flags - if (typeof operand1 === "object") { - oper1 = operand1.total; - subStepSolve.details = `${operand1.details}\\${conf[i]}`; - subStepSolve.containsCrit = operand1.containsCrit; - subStepSolve.containsFail = operand1.containsFail; - } else { - // else parse it as a number and add it to the subStep details - if (operand1) { - oper1 = parseFloat(operand1.toString()); - subStepSolve.details = `${oper1.toString()}\\${conf[i]}`; - } else if (conf[i] === '-') { - oper1 = 0; - subStepSolve.details = `\\${conf[i]}`; - shouldDecrement = false; - } - } - - // If operand2 is a SolvedStep, populate our subStepSolve with its details without overriding what operand1 filled in - if (typeof operand2 === "object") { - oper2 = operand2.total; - subStepSolve.details += operand2.details; - subStepSolve.containsCrit = subStepSolve.containsCrit || operand2.containsCrit; - subStepSolve.containsFail = subStepSolve.containsFail || operand2.containsFail; - } else { - // else parse it as a number and add it to the subStep details - oper2 = parseFloat(operand2.toString()); - subStepSolve.details += oper2; - } - - // Make sure neither operand is NaN before continuing - if (isNaN(oper1) || isNaN(oper2)) { - throw new Error("OperandNaN"); - } - - // Verify a second time that both are numbers before doing math, throwing an error if necessary - if ((typeof oper1 === "number") && (typeof oper2 === "number")) { - // Finally do the operator on the operands, throw an error if the operator is not found - switch (conf[i]) { - case "^": - subStepSolve.total = Math.pow(oper1, oper2); - break; - case "*": - subStepSolve.total = oper1 * oper2; - break; - case "/": - subStepSolve.total = oper1 / oper2; - break; - case "%": - subStepSolve.total = oper1 % oper2; - break; - case "+": - subStepSolve.total = oper1 + oper2; - break; - case "-": - subStepSolve.total = oper1 - oper2; - break; - default: - throw new Error("OperatorWhat"); - } - } else { - throw new Error("EMDASNotNumber"); - } - - // Determine if we actually did math or just smashed a - sign onto a number - if (shouldDecrement) { - // Replace the two operands and their operator with our subStepSolve - conf.splice((i - 1), 3, subStepSolve); - // Because we are messing around with the array we are iterating thru, we need to back up one idx to make sure every operator gets processed - i--; - } else { - // Replace the one operand and its operator (-) with our subStepSolve - conf.splice(i, 2, subStepSolve); - } - } - } - }); - - // If we somehow have more than one item left in conf at this point, something broke, throw an error - if (conf.length > 1) { - log(LT.LOG, `ConfWHAT? ${JSON.stringify(conf)}`); - throw new Error("ConfWhat"); - } else if (singleNum && (typeof (conf[0]) === "number")) { - // If we are only left with a number, populate the stepSolve with it - stepSolve.total = conf[0]; - stepSolve.details = conf[0].toString(); - } else { - // Else fully populate the stepSolve with what was computed - stepSolve.total = (conf[0]).total; - stepSolve.details = (conf[0]).details; - stepSolve.containsCrit = (conf[0]).containsCrit; - stepSolve.containsFail = (conf[0]).containsFail; - } - - // If this was a nested call, add on parens around the details to show what math we've done - if (wrapDetails) { - stepSolve.details = `(${stepSolve.details})`; - } - - // If our total has reached undefined for some reason, error out now - if (stepSolve.total === undefined) { - throw new Error("UndefinedStep"); - } - - return stepSolve; -}; - -// parseRoll(fullCmd, localPrefix, localPostfix, maximiseRoll, nominalRoll) -// parseRoll handles converting fullCmd into a computer readable format for processing, and finally executes the solving -const parseRoll = (fullCmd: string, localPrefix: string, localPostfix: string, maximiseRoll: boolean, nominalRoll: boolean, order: string): SolvedRoll => { - const returnmsg = { - error: false, - errorMsg: "", - errorCode: "", - line1: "", - line2: "", - line3: "" - }; - - // Whole function lives in a try-catch to allow safe throwing of errors on purpose - try { - // Split the fullCmd by the command prefix to allow every roll/math op to be handled individually - const sepRolls = fullCmd.split(localPrefix); - - const tempReturnData: ReturnData[] = []; - - // Loop thru all roll/math ops - for (let i = 0; i < sepRolls.length; i++) { - log(LT.LOG, `Parsing roll ${fullCmd} | Working ${sepRolls[i]}`); - // Split the current iteration on the command postfix to separate the operation to be parsed and the text formatting after the opertaion - const [tempConf, tempFormat] = sepRolls[i].split(localPostfix); - - // 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(/([-+()*/%^])/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 => { - log(LT.LOG, `Parsing roll ${fullCmd} | Checking parenthesis balance ${e}`); - if (e === "(") { - parenCnt++; - } else if (e === ")") { - parenCnt--; - } - }); - - // 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++) { - log(LT.LOG, `Parsing roll ${fullCmd} | Evaluating rolls into mathable items ${JSON.stringify(mathConf[i])}`); - if (mathConf[i].toString().length === 0) { - // If its an empty string, get it out of here - mathConf.splice(i, 1); - i--; - } else if (mathConf[i] == parseFloat(mathConf[i].toString())) { - // If its a number, parse the number out - mathConf[i] = parseFloat(mathConf[i].toString()); - } else if (/([0123456789])/g.test(mathConf[i].toString())) { - // If there is a number somewhere in mathconf[i] but there are also other characters preventing it from parsing correctly as a number, it should be a dice roll, parse it as such (if it for some reason is not a dice roll, formatRoll/roll will handle it) - mathConf[i] = formatRoll(mathConf[i].toString(), maximiseRoll, nominalRoll); - } else if (mathConf[i].toString().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 (mathConf[i].toString().toLowerCase() === "pi" || mathConf[i].toString().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 (mathConf[i].toString().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 - }]); - } - } - - // 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 - if (fullCmd[fullCmd.length - 1] === " ") { - fullCmd = fullCmd.substring(0, (fullCmd.length - 1)); - } - - // Escape any | and ` chars in fullCmd to prevent spoilers and code blocks from acting up - fullCmd = escapeCharacters(fullCmd, "|"); - fullCmd = fullCmd.replace(/`/g, ""); - - let line1 = ""; - let line2 = ""; - let line3 = ""; - - // If maximiseRoll or nominalRoll are on, mark the output as such, else use default formatting - if (maximiseRoll) { - line1 = ` requested the theoretical maximum of: \`${localPrefix}${fullCmd}\``; - line2 = "Theoretical Maximum Results: "; - } else if (nominalRoll) { - line1 = ` requested the theoretical nominal of: \`${localPrefix}${fullCmd}\``; - line2 = "Theoretical Nominal Results: "; - } else if (order === "a") { - line1 = ` requested the following rolls to be ordered from least to greatest: \`${localPrefix}${fullCmd}\``; - line2 = "Results: "; - tempReturnData.sort(compareTotalRolls); - } else if (order === "d") { - line1 = ` requested the following rolls to be ordered from greatest to least: \`${localPrefix}${fullCmd}\``; - line2 = "Results: "; - tempReturnData.sort(compareTotalRolls); - tempReturnData.reverse(); - } else { - line1 = ` rolled: \`${localPrefix}${fullCmd}\``; - line2 = "Results: "; - } - - // Fill out all of the details and results now - tempReturnData.forEach(e => { - log(LT.LOG, `Parsing roll ${fullCmd} | Making return text ${JSON.stringify(e)}`); - let preFormat = ""; - let postFormat = ""; - - // If the roll containted a crit success or fail, set the formatting around it - if (e.containsCrit) { - preFormat = `**${preFormat}`; - postFormat = `${postFormat}**`; - } - if (e.containsFail) { - preFormat = `__${preFormat}`; - postFormat = `${postFormat}__`; - } - - // Populate line2 (the results) and line3 (the details) with their data - if (order === "") { - line2 += `${preFormat}${e.rollTotal}${postFormat}${escapeCharacters(e.rollPostFormat, "|*_~`")}`; - } else { - // If order is on, turn rolls into csv without formatting - line2 += `${preFormat}${e.rollTotal}${postFormat}, `; - } - - line2 = line2.replace(/\*\*\*\*/g, "** **").replace(/____/g, "__ __").replace(/~~~~/g, "~~ ~~"); - - line3 += `\`${e.initConfig}\` = ${e.rollDetails} = ${preFormat}${e.rollTotal}${postFormat}\n`; - }); - - // If order is on, remove trailing ", " - if (order !== "") { - line2 = line2.substring(0, (line2.length - 2)); - } - - // Fill in the return block - returnmsg.line1 = line1; - returnmsg.line2 = line2; - returnmsg.line3 = line3; - - } catch (solverError) { - // Welp, the unthinkable happened, we hit an error - - // Split on _ for the error messages that have more info than just their name - const [errorName, errorDetails] = solverError.message.split("_"); - - let errorMsg = ""; - - // Translate the errorName to a specific errorMsg - switch (errorName) { - case "YouNeedAD": - errorMsg = "Formatting Error: Missing die size and count config"; - 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 ${localPostfix}`; - } - 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; - default: - errorMsg += `Unhandled - ${errorDetails}`; - break; - } - errorMsg += " cannot be zero"; - 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, `Undangled 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; - } - - return returnmsg; -}; - -export default { parseRoll }; diff --git a/src/solver/_index.ts b/src/solver/_index.ts new file mode 100644 index 0000000..0eb901a --- /dev/null +++ b/src/solver/_index.ts @@ -0,0 +1,5 @@ +import { parseRoll } from "./parser.ts"; + +export default { + parseRoll +}; diff --git a/src/solver/parser.ts b/src/solver/parser.ts new file mode 100644 index 0000000..921a0dd --- /dev/null +++ b/src/solver/parser.ts @@ -0,0 +1,286 @@ +import { + // Log4Deno deps + LT, log +} from "../../deps.ts"; + +import config from "../../config.ts"; + +import { SolvedStep, SolvedRoll, ReturnData } from "./solver.d.ts"; +import { compareTotalRolls, escapeCharacters } from "./rollUtils.ts"; +import { formatRoll } from "./rollFormatter.ts"; +import { fullSolver } from "./solver.ts"; + +// parseRoll(fullCmd, localPrefix, localPostfix, maximiseRoll, nominalRoll) +// parseRoll handles converting fullCmd into a computer readable format for processing, and finally executes the solving +export const parseRoll = (fullCmd: string, localPrefix: string, localPostfix: string, maximiseRoll: boolean, nominalRoll: boolean, order: string): SolvedRoll => { + const returnmsg = { + error: false, + errorMsg: "", + errorCode: "", + line1: "", + line2: "", + line3: "" + }; + + // Whole function lives in a try-catch to allow safe throwing of errors on purpose + try { + // Split the fullCmd by the command prefix to allow every roll/math op to be handled individually + const sepRolls = fullCmd.split(localPrefix); + + const tempReturnData: ReturnData[] = []; + + // Loop thru all roll/math ops + for (let i = 0; i < sepRolls.length; i++) { + log(LT.LOG, `Parsing roll ${fullCmd} | Working ${sepRolls[i]}`); + // Split the current iteration on the command postfix to separate the operation to be parsed and the text formatting after the opertaion + const [tempConf, tempFormat] = sepRolls[i].split(localPostfix); + + // 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(/([-+()*/%^])/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 => { + log(LT.LOG, `Parsing roll ${fullCmd} | Checking parenthesis balance ${e}`); + if (e === "(") { + parenCnt++; + } else if (e === ")") { + parenCnt--; + } + }); + + // 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++) { + log(LT.LOG, `Parsing roll ${fullCmd} | Evaluating rolls into mathable items ${JSON.stringify(mathConf[i])}`); + if (mathConf[i].toString().length === 0) { + // If its an empty string, get it out of here + mathConf.splice(i, 1); + i--; + } else if (mathConf[i] == parseFloat(mathConf[i].toString())) { + // If its a number, parse the number out + mathConf[i] = parseFloat(mathConf[i].toString()); + } else if (/([0123456789])/g.test(mathConf[i].toString())) { + // If there is a number somewhere in mathconf[i] but there are also other characters preventing it from parsing correctly as a number, it should be a dice roll, parse it as such (if it for some reason is not a dice roll, formatRoll/roll will handle it) + mathConf[i] = formatRoll(mathConf[i].toString(), maximiseRoll, nominalRoll); + } else if (mathConf[i].toString().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 (mathConf[i].toString().toLowerCase() === "pi" || mathConf[i].toString().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 (mathConf[i].toString().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 + }]); + } + } + + // 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 + if (fullCmd[fullCmd.length - 1] === " ") { + fullCmd = fullCmd.substring(0, (fullCmd.length - 1)); + } + + // Escape any | and ` chars in fullCmd to prevent spoilers and code blocks from acting up + fullCmd = escapeCharacters(fullCmd, "|"); + fullCmd = fullCmd.replace(/`/g, ""); + + let line1 = ""; + let line2 = ""; + let line3 = ""; + + // If maximiseRoll or nominalRoll are on, mark the output as such, else use default formatting + if (maximiseRoll) { + line1 = ` requested the theoretical maximum of: \`${localPrefix}${fullCmd}\``; + line2 = "Theoretical Maximum Results: "; + } else if (nominalRoll) { + line1 = ` requested the theoretical nominal of: \`${localPrefix}${fullCmd}\``; + line2 = "Theoretical Nominal Results: "; + } else if (order === "a") { + line1 = ` requested the following rolls to be ordered from least to greatest: \`${localPrefix}${fullCmd}\``; + line2 = "Results: "; + tempReturnData.sort(compareTotalRolls); + } else if (order === "d") { + line1 = ` requested the following rolls to be ordered from greatest to least: \`${localPrefix}${fullCmd}\``; + line2 = "Results: "; + tempReturnData.sort(compareTotalRolls); + tempReturnData.reverse(); + } else { + line1 = ` rolled: \`${localPrefix}${fullCmd}\``; + line2 = "Results: "; + } + + // Fill out all of the details and results now + tempReturnData.forEach(e => { + log(LT.LOG, `Parsing roll ${fullCmd} | Making return text ${JSON.stringify(e)}`); + let preFormat = ""; + let postFormat = ""; + + // If the roll containted a crit success or fail, set the formatting around it + if (e.containsCrit) { + preFormat = `**${preFormat}`; + postFormat = `${postFormat}**`; + } + if (e.containsFail) { + preFormat = `__${preFormat}`; + postFormat = `${postFormat}__`; + } + + // Populate line2 (the results) and line3 (the details) with their data + if (order === "") { + line2 += `${preFormat}${e.rollTotal}${postFormat}${escapeCharacters(e.rollPostFormat, "|*_~`")}`; + } else { + // If order is on, turn rolls into csv without formatting + line2 += `${preFormat}${e.rollTotal}${postFormat}, `; + } + + line2 = line2.replace(/\*\*\*\*/g, "** **").replace(/____/g, "__ __").replace(/~~~~/g, "~~ ~~"); + + line3 += `\`${e.initConfig}\` = ${e.rollDetails} = ${preFormat}${e.rollTotal}${postFormat}\n`; + }); + + // If order is on, remove trailing ", " + if (order !== "") { + line2 = line2.substring(0, (line2.length - 2)); + } + + // Fill in the return block + returnmsg.line1 = line1; + returnmsg.line2 = line2; + returnmsg.line3 = line3; + + } catch (solverError) { + // Welp, the unthinkable happened, we hit an error + + // Split on _ for the error messages that have more info than just their name + const [errorName, errorDetails] = solverError.message.split("_"); + + let errorMsg = ""; + + // Translate the errorName to a specific errorMsg + switch (errorName) { + case "YouNeedAD": + errorMsg = "Formatting Error: Missing die size and count config"; + 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 ${localPostfix}`; + } + 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; + default: + errorMsg += `Unhandled - ${errorDetails}`; + break; + } + errorMsg += " cannot be zero"; + 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, `Undangled 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; + } + + return returnmsg; +}; diff --git a/src/solver/rollFormatter.ts b/src/solver/rollFormatter.ts new file mode 100644 index 0000000..f39c013 --- /dev/null +++ b/src/solver/rollFormatter.ts @@ -0,0 +1,66 @@ +import { + // Log4Deno deps + LT, log +} from "../../deps.ts"; + +import { roll } from "./roller.ts"; +import { SolvedStep } from "./solver.d.ts"; + +// formatRoll(rollConf, maximiseRoll, nominalRoll) returns one SolvedStep +// formatRoll handles creating and formatting the completed rolls into the SolvedStep format +export const formatRoll = (rollConf: string, maximiseRoll: boolean, nominalRoll: boolean): SolvedStep => { + let tempTotal = 0; + let tempDetails = "["; + let tempCrit = false; + let tempFail = false; + + // Generate the roll, passing flags thru + const tempRollSet = roll(rollConf, maximiseRoll, nominalRoll); + + // Loop thru all parts of the roll to document everything that was done to create the total roll + tempRollSet.forEach(e => { + log(LT.LOG, `Formatting roll ${rollConf} | ${JSON.stringify(e)}`); + let preFormat = ""; + let postFormat = ""; + + if (!e.dropped && !e.rerolled) { + // If the roll was not dropped or rerolled, add it to the stepTotal and flag the critHit/critFail + tempTotal += e.roll; + if (e.critHit) { + tempCrit = true; + } + if (e.critFail) { + tempFail = true; + } + } + // If the roll was a crit hit or fail, or dropped/rerolled, add the formatting needed + if (e.critHit) { + // Bold for crit success + preFormat = `**${preFormat}`; + postFormat = `${postFormat}**`; + } + if (e.critFail) { + // Underline for crit fail + preFormat = `__${preFormat}`; + postFormat = `${postFormat}__`; + } + if (e.dropped || e.rerolled) { + // Strikethrough for dropped/rerolled rolls + preFormat = `~~${preFormat}`; + postFormat = `${postFormat}~~`; + } + + // Finally add this to the roll's details + tempDetails += `${preFormat}${e.roll}${postFormat} + `; + }); + // After the looping is done, remove the extra " + " from the details and cap it with the closing ] + tempDetails = tempDetails.substring(0, (tempDetails.length - 3)); + tempDetails += "]"; + + return { + total: tempTotal, + details: tempDetails, + containsCrit: tempCrit, + containsFail: tempFail + }; +}; diff --git a/src/solver/rollUtils.ts b/src/solver/rollUtils.ts new file mode 100644 index 0000000..64888eb --- /dev/null +++ b/src/solver/rollUtils.ts @@ -0,0 +1,67 @@ +import { + // Log4Deno deps + LT, log +} from "../../deps.ts"; + +import { RollSet, ReturnData } from "./solver.d.ts"; + +// MAXLOOPS determines how long the bot will attempt a roll +// Default is 5000000 (5 million), which results in at most a 10 second delay before the bot calls the roll infinite or too complex +// Increase at your own risk +export const MAXLOOPS = 5000000; + +// genRoll(size) returns number +// genRoll rolls a die of size size and returns the result +export const genRoll = (size: number): number => { + // Math.random * size will return a decimal number between 0 and size (excluding size), so add 1 and floor the result to not get 0 as a result + return Math.floor((Math.random() * size) + 1); +}; + +// compareRolls(a, b) returns -1|0|1 +// compareRolls is used to order an array of RollSets by RollSet.roll +export const compareRolls = (a: RollSet, b: RollSet): number => { + if (a.roll < b.roll) { + return -1; + } + if (a.roll > b.roll) { + return 1; + } + return 0; +}; + +// compareTotalRolls(a, b) returns -1|0|1 +// compareTotalRolls is used to order an array of RollSets by RollSet.roll +export const compareTotalRolls = (a: ReturnData, b: ReturnData): number => { + if (a.rollTotal < b.rollTotal) { + return -1; + } + if (a.rollTotal > b.rollTotal) { + return 1; + } + return 0; +}; + +// compareRolls(a, b) returns -1|0|1 +// compareRolls is used to order an array of RollSets by RollSet.origidx +export const compareOrigidx = (a: RollSet, b: RollSet): number => { + if (a.origidx < b.origidx) { + return -1; + } + if (a.origidx > b.origidx) { + return 1; + } + return 0; +}; + +// escapeCharacters(str, esc) returns str +// escapeCharacters escapes all characters listed in esc +export const escapeCharacters = (str: string, esc: string): string => { + // Loop thru each esc char one at a time + for (let i = 0; i < esc.length; i++) { + log(LT.LOG, `Escaping character ${esc[i]} | ${str}, ${esc}`); + // Create a new regex to look for that char that needs replaced and escape it + const temprgx = new RegExp(`[${esc[i]}]`, "g"); + str = str.replace(temprgx, `\\${esc[i]}`); + } + return str; +}; diff --git a/src/solver/roller.ts b/src/solver/roller.ts new file mode 100644 index 0000000..6f81314 --- /dev/null +++ b/src/solver/roller.ts @@ -0,0 +1,449 @@ +import { + // Log4Deno deps + LT, log +} from "../../deps.ts"; + +import { RollSet } from "./solver.d.ts"; +import { MAXLOOPS, genRoll, compareRolls, compareOrigidx } from "./rollUtils.ts"; + +// roll(rollStr, maximiseRoll, nominalRoll) returns RollSet +// roll parses and executes the rollStr, if needed it will also make the roll the maximum or average +export const roll = (rollStr: string, maximiseRoll: boolean, nominalRoll: boolean): RollSet[] => { + /* Roll Capabilities + * Deciphers and rolls a single dice roll set + * xdydzracsq! + * + * x [OPT] - number of dice to roll, if omitted, 1 is used + * dy [REQ] - size of dice to roll, d20 = 20 sided die + * dz || dlz [OPT] - drops the lowest z dice, cannot be used with kz + * kz || khz [OPT] - keeps the highest z dice, cannot be used with dz + * dhz [OPT] - drops the highest z dice, cannot be used with kz + * klz [OPT] - keeps the lowest z dice, cannot be used with dz + * ra [OPT] - rerolls any rolls that match a, r3 will reroll any dice that land on 3, throwing out old rolls + * csq || cs=q [OPT] - changes crit score to q + * csq [OPT] - changes crit score to be greater than or equal to q + * cfq || cs=q [OPT] - changes crit fail to q + * cfq [OPT] - changes crit fail to be greater than or equal to q + * ! [OPT] - exploding, rolls another dy for every crit roll + */ + + // Make entire roll lowercase for ease of parsing + rollStr = rollStr.toLowerCase(); + + // Split the roll on the die size (and the drop if its there) + const dpts = rollStr.split("d"); + + // Initialize the configuration to store the parsed data + const rollConf = { + dieCount: 0, + dieSize: 0, + drop: { + on: false, + count: 0 + }, + keep: { + on: false, + count: 0 + }, + dropHigh: { + on: false, + count: 0 + }, + keepLow: { + on: false, + count: 0 + }, + reroll: { + on: false, + nums: [] + }, + critScore: { + on: false, + range: [] + }, + critFail: { + on: false, + range: [] + }, + exploding: false + }; + + // If the dpts is not long enough, throw error + if (dpts.length < 2) { + throw new Error("YouNeedAD"); + } + + // Fill out the die count, first item will either be an int or empty string, short circuit execution will take care of replacing the empty string with a 1 + const tempDC = (dpts.shift() || "1").replace(/\D/g,''); + rollConf.dieCount = parseInt(tempDC); + + // Finds the end of the die size/beginnning of the additional options + let afterDieIdx = dpts[0].search(/\D/); + if (afterDieIdx === -1) { + afterDieIdx = dpts[0].length; + } + + // Rejoin all remaining parts + let remains = dpts.join("d"); + // Get the die size out of the remains and into the rollConf + rollConf.dieSize = parseInt(remains.slice(0, afterDieIdx)); + remains = remains.slice(afterDieIdx); + + log(LT.LOG, `Handling roll ${rollStr} | Parsed Die Count: ${rollConf.dieCount}`); + log(LT.LOG, `Handling roll ${rollStr} | Parsed Die Size: ${rollConf.dieSize}`); + + // Finish parsing the roll + if (remains.length > 0) { + // Determine if the first item is a drop, and if it is, add the d back in + if (remains.search(/\D/) !== 0 || remains.indexOf("l") === 0 || remains.indexOf("h") === 0) { + remains = `d${remains}`; + } + + // Loop until all remaining args are parsed + while (remains.length > 0) { + log(LT.LOG, `Handling roll ${rollStr} | Parsing remains ${remains}`); + // Find the next number in the remains to be able to cut out the rule name + let afterSepIdx = remains.search(/\d/); + if (afterSepIdx < 0) { + afterSepIdx = remains.length; + } + // Save the rule name to tSep and remove it from remains + const tSep = remains.slice(0, afterSepIdx); + remains = remains.slice(afterSepIdx); + // Find the next non-number in the remains to be able to cut out the count/num + let afterNumIdx = remains.search(/\D/); + if (afterNumIdx < 0) { + afterNumIdx = remains.length; + } + // Save the count/num to tNum leaving it in remains for the time being + const tNum = parseInt(remains.slice(0, afterNumIdx)); + + // Switch on rule name + switch (tSep) { + case "dl": + case "d": + // Configure Drop (Lowest) + rollConf.drop.on = true; + rollConf.drop.count = tNum; + break; + case "kh": + case "k": + // Configure Keep (Highest) + rollConf.keep.on = true; + rollConf.keep.count = tNum; + break; + case "dh": + // Configure Drop (Highest) + rollConf.dropHigh.on = true; + rollConf.dropHigh.count = tNum; + break; + case "kl": + // Configure Keep (Lowest) + rollConf.keepLow.on = true; + rollConf.keepLow.count = tNum; + break; + case "r": + // Configure Reroll (this can happen multiple times) + rollConf.reroll.on = true; + rollConf.reroll.nums.push(tNum); + break; + case "cs": + case "cs=": + // Configure CritScore for one number (this can happen multiple times) + rollConf.critScore.on = true; + rollConf.critScore.range.push(tNum); + break; + case "cs>": + // Configure CritScore for all numbers greater than or equal to tNum (this could happen multiple times, but why) + rollConf.critScore.on = true; + for (let i = tNum; i <= rollConf.dieSize; i++) { + log(LT.LOG, `Handling roll ${rollStr} | Parsing cs> ${i}`); + rollConf.critScore.range.push(i); + } + break; + case "cs<": + // Configure CritScore for all numbers less than or equal to tNum (this could happen multiple times, but why) + rollConf.critScore.on = true; + for (let i = 0; i <= tNum; i++) { + log(LT.LOG, `Handling roll ${rollStr} | Parsing cs< ${i}`); + rollConf.critScore.range.push(i); + } + break; + case "cf": + case "cf=": + // Configure CritFail for one number (this can happen multiple times) + rollConf.critFail.on = true; + rollConf.critFail.range.push(tNum); + break; + case "cf>": + // Configure CritFail for all numbers greater than or equal to tNum (this could happen multiple times, but why) + rollConf.critFail.on = true; + for (let i = tNum; i <= rollConf.dieSize; i++) { + log(LT.LOG, `Handling roll ${rollStr} | Parsing cf> ${i}`); + rollConf.critFail.range.push(i); + } + break; + case "cf<": + // Configure CritFail for all numbers less than or equal to tNum (this could happen multiple times, but why) + rollConf.critFail.on = true; + for (let i = 0; i <= tNum; i++) { + log(LT.LOG, `Handling roll ${rollStr} | Parsing cf< ${i}`); + rollConf.critFail.range.push(i); + } + break; + case "!": + // Configure Exploding + rollConf.exploding = true; + afterNumIdx = 1; + break; + default: + // Throw error immediately if unknown op is encountered + throw new Error(`UnknownOperation_${tSep}`); + } + // Finally slice off everything else parsed this loop + remains = remains.slice(afterNumIdx); + } + } + + // Verify the parse, throwing errors for every invalid config + if (rollConf.dieCount < 0) { + throw new Error("NoZerosAllowed_base"); + } + if (rollConf.dieCount === 0 || rollConf.dieSize === 0) { + throw new Error("NoZerosAllowed_base"); + } + // Since only one drop or keep option can be active, count how many are active to throw the right error + let dkdkCnt = 0; + [rollConf.drop.on, rollConf.keep.on, rollConf.dropHigh.on, rollConf.keepLow.on].forEach(e => { + log(LT.LOG, `Handling roll ${rollStr} | Checking if drop/keep is on ${e}`); + if (e) { + dkdkCnt++; + } + }); + if (dkdkCnt > 1) { + throw new Error("FormattingError_dk"); + } + if (rollConf.drop.on && rollConf.drop.count === 0) { + throw new Error("NoZerosAllowed_drop"); + } + if (rollConf.keep.on && rollConf.keep.count === 0) { + throw new Error("NoZerosAllowed_keep"); + } + if (rollConf.dropHigh.on && rollConf.dropHigh.count === 0) { + throw new Error("NoZerosAllowed_dropHigh"); + } + if (rollConf.keepLow.on && rollConf.keepLow.count === 0) { + throw new Error("NoZerosAllowed_keepLow"); + } + if (rollConf.reroll.on && rollConf.reroll.nums.indexOf(0) >= 0) { + throw new Error("NoZerosAllowed_reroll"); + } + + // Roll the roll + const rollSet = []; + /* Roll will contain objects of the following format: + * { + * origidx: 0, + * roll: 0, + * dropped: false, + * rerolled: false, + * exploding: false, + * critHit: false, + * critFail: false + * } + * + * Each of these is defined as following: + * { + * origidx: The original index of the roll + * roll: The resulting roll on this die in the set + * dropped: This die is to be dropped as it was one of the dy lowest dice + * rerolled: This die has been rerolled as it matched rz, it is replaced by the very next die in the set + * exploding: This die was rolled as the previous die exploded (was a crit hit) + * critHit: This die matched csq[-u], max die value used if cs not used + * critFail: This die rolled a nat 1, a critical failure + * } + */ + + // Initialize a templet rollSet to copy multiple times + const templateRoll = { + origidx: 0, + roll: 0, + dropped: false, + rerolled: false, + exploding: false, + critHit: false, + critFail: false + }; + + // Begin counting the number of loops to prevent from getting into an infinite loop + let loopCount = 0; + + // Initial rolling, not handling reroll or exploding here + for (let i = 0; i < rollConf.dieCount; i++) { + log(LT.LOG, `Handling roll ${rollStr} | Initial rolling ${i} of ${JSON.stringify(rollConf)}`); + // If loopCount gets too high, stop trying to calculate infinity + if (loopCount > MAXLOOPS) { + throw new Error("MaxLoopsExceeded"); + } + + // Copy the template to fill out for this iteration + const rolling = JSON.parse(JSON.stringify(templateRoll)); + // If maximiseRoll is on, set the roll to the dieSize, else if nominalRoll is on, set the roll to the average roll of dieSize, else generate a new random roll + rolling.roll = maximiseRoll ? rollConf.dieSize : (nominalRoll ? ((rollConf.dieSize / 2) + 0.5) : genRoll(rollConf.dieSize)); + // Set origidx of roll + rolling.origidx = i; + + // If critScore arg is on, check if the roll should be a crit, if its off, check if the roll matches the die size + if (rollConf.critScore.on && rollConf.critScore.range.indexOf(rolling.roll) >= 0) { + rolling.critHit = true; + } else if (!rollConf.critScore.on) { + rolling.critHit = (rolling.roll === rollConf.dieSize); + } + // If critFail arg is on, check if the roll should be a fail, if its off, check if the roll matches 1 + if (rollConf.critFail.on && rollConf.critFail.range.indexOf(rolling.roll) >= 0) { + rolling.critFail = true; + } else if (!rollConf.critFail.on) { + rolling.critFail = (rolling.roll === 1); + } + + // Push the newly created roll and loop again + rollSet.push(rolling); + loopCount++; + } + + // If needed, handle rerolling and exploding dice now + if (rollConf.reroll.on || rollConf.exploding) { + for (let i = 0; i < rollSet.length; i++) { + log(LT.LOG, `Handling roll ${rollStr} | Handling rerolling and exploding ${JSON.stringify(rollSet[i])}`); + // If loopCount gets too high, stop trying to calculate infinity + if (loopCount > MAXLOOPS) { + throw new Error("MaxLoopsExceeded"); + } + + // If we need to reroll this roll, flag its been replaced and... + if (rollConf.reroll.on && rollConf.reroll.nums.indexOf(rollSet[i].roll) >= 0) { + rollSet[i].rerolled = true; + + // Copy the template to fill out for this iteration + const newRoll = JSON.parse(JSON.stringify(templateRoll)); + // If maximiseRoll is on, set the roll to the dieSize, else if nominalRoll is on, set the roll to the average roll of dieSize, else generate a new random roll + newRoll.roll = maximiseRoll ? rollConf.dieSize : (nominalRoll ? ((rollConf.dieSize / 2) + 0.5) : genRoll(rollConf.dieSize)); + + // If critScore arg is on, check if the roll should be a crit, if its off, check if the roll matches the die size + if (rollConf.critScore.on && rollConf.critScore.range.indexOf(newRoll.roll) >= 0) { + newRoll.critHit = true; + } else if (!rollConf.critScore.on) { + newRoll.critHit = (newRoll.roll === rollConf.dieSize); + } + // If critFail arg is on, check if the roll should be a fail, if its off, check if the roll matches 1 + if (rollConf.critFail.on && rollConf.critFail.range.indexOf(newRoll.roll) >= 0) { + newRoll.critFail = true; + } else if (!rollConf.critFail.on) { + newRoll.critFail = (newRoll.roll === 1); + } + + // Slot this new roll in after the current iteration so it can be processed in the next loop + rollSet.splice(i + 1, 0, newRoll); + } else if (rollConf.exploding && !rollSet[i].rerolled && rollSet[i].critHit) { + //If it exploded, we keep both, so no flags need to be set + + // Copy the template to fill out for this iteration + const newRoll = JSON.parse(JSON.stringify(templateRoll)); + // If maximiseRoll is on, set the roll to the dieSize, else if nominalRoll is on, set the roll to the average roll of dieSize, else generate a new random roll + newRoll.roll = maximiseRoll ? rollConf.dieSize : (nominalRoll ? ((rollConf.dieSize / 2) + 0.5) : genRoll(rollConf.dieSize)); + // Always mark this roll as exploding + newRoll.exploding = true; + + // If critScore arg is on, check if the roll should be a crit, if its off, check if the roll matches the die size + if (rollConf.critScore.on && rollConf.critScore.range.indexOf(newRoll.roll) >= 0) { + newRoll.critHit = true; + } else if (!rollConf.critScore.on) { + newRoll.critHit = (newRoll.roll === rollConf.dieSize); + } + // If critFail arg is on, check if the roll should be a fail, if its off, check if the roll matches 1 + if (rollConf.critFail.on && rollConf.critFail.range.indexOf(newRoll.roll) >= 0) { + newRoll.critFail = true; + } else if (!rollConf.critFail.on) { + newRoll.critFail = (newRoll.roll === 1); + } + + // Slot this new roll in after the current iteration so it can be processed in the next loop + rollSet.splice(i + 1, 0, newRoll); + } + + loopCount++; + } + } + + // If we need to handle the drop/keep flags + if (dkdkCnt > 0) { + // Count how many rerolled dice there are if the reroll flag was on + let rerollCount = 0; + if (rollConf.reroll.on) { + for (let i = 0; i < rollSet.length; i++) { + log(LT.LOG, `Handling roll ${rollStr} | Setting originalIdx on ${JSON.stringify(rollSet[i])}`); + rollSet[i].origidx = i; + + if (rollSet[i].rerolled) { + rerollCount++; + } + } + } + + // Order the rolls from least to greatest (by RollSet.roll) + rollSet.sort(compareRolls); + + // Determine how many valid rolls there are to drop from (may not be equal to dieCount due to exploding) + const validRolls = rollSet.length - rerollCount; + let dropCount = 0; + + // For normal drop and keep, simple subtraction is enough to determine how many to drop + // Protections are in to prevent the dropCount from going below 0 or more than the valid rolls to drop + if (rollConf.drop.on) { + dropCount = rollConf.drop.count; + if (dropCount > validRolls) { + dropCount = validRolls; + } + } else if (rollConf.keep.on) { + dropCount = validRolls - rollConf.keep.count; + if (dropCount < 0) { + dropCount = 0; + } + } + + // For inverted drop and keep, order must be flipped to greatest to least before the simple subtraction can determine how many to drop + // Protections are in to prevent the dropCount from going below 0 or more than the valid rolls to drop + else if (rollConf.dropHigh.on) { + rollSet.reverse(); + dropCount = rollConf.dropHigh.count; + if (dropCount > validRolls) { + dropCount = validRolls; + } + } else if (rollConf.keepLow.on) { + rollSet.reverse(); + dropCount = validRolls - rollConf.keepLow.count; + if (dropCount < 0) { + dropCount = 0; + } + } + + // Now its time to drop all dice needed + let i = 0; + while (dropCount > 0 && i < rollSet.length) { + log(LT.LOG, `Handling roll ${rollStr} | Dropping dice ${dropCount} ${JSON.stringify(rollSet[i])}`); + // Skip all rolls that were rerolled + if (!rollSet[i].rerolled) { + rollSet[i].dropped = true; + dropCount--; + } + i++; + } + + // Finally, return the rollSet to its original order + rollSet.sort(compareOrigidx); + } + + return rollSet; +}; diff --git a/src/solver.d.ts b/src/solver/solver.d.ts similarity index 100% rename from src/solver.d.ts rename to src/solver/solver.d.ts diff --git a/src/solver/solver.ts b/src/solver/solver.ts new file mode 100644 index 0000000..9b97f9b --- /dev/null +++ b/src/solver/solver.ts @@ -0,0 +1,211 @@ +/* The Artificer was built in memory of Babka + * With love, Ean + * + * December 21, 2020 + */ + +import { + // Log4Deno deps + LT, log +} from "../../deps.ts"; + +import { SolvedStep } from "./solver.d.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 => { + // Initialize PEMDAS + const signs = ["^", "*", "/", "%", "+", "-"]; + const stepSolve = { + total: 0, + details: "", + containsCrit: false, + containsFail: false + }; + + // If entering with a single number, note it now + let singleNum = false; + if (conf.length === 1) { + singleNum = true; + } + + // Evaluate all parenthesis + while (conf.indexOf("(") > -1) { + log(LT.LOG, `Evaluating roll ${JSON.stringify(conf)} | Looking for (`); + // Get first open parenthesis + const openParen = conf.indexOf("("); + let closeParen = -1; + let nextParen = 0; + + // Using nextParen to count the opening/closing parens, find the matching paren to openParen above + for (let i = openParen; i < conf.length; i++) { + log(LT.LOG, `Evaluating roll ${JSON.stringify(conf)} | Looking for matching ) openIdx: ${openParen} checking: ${i}`); + // If we hit an open, add one (this includes the openParen we start with), if we hit a close, subtract one + if (conf[i] === "(") { + nextParen++; + } else if (conf[i] === ")") { + nextParen--; + } + + // When nextParen reaches 0 again, we will have found the matching closing parenthesis and can safely exit the for loop + if (nextParen === 0) { + closeParen = i; + break; + } + } + + // Make sure we did find the correct closing paren, if not, error out now + if (closeParen === -1 || closeParen < openParen) { + throw new Error("UnbalancedParens"); + } + + // Replace the itemes between openParen and closeParen (including the parens) with its solved equilvalent by calling the solver on the items between openParen and closeParen (excluding the parens) + conf.splice(openParen, (closeParen + 1), fullSolver(conf.slice((openParen + 1), closeParen), true)); + + // Determing if we need to add in a multiplication sign to handle implicit multiplication (like "(4)2" = 8) + // insertedMult flags if there was a multiplication sign inserted before the parens + let insertedMult = false; + // Check if a number was directly before openParen and slip in the "*" if needed + if (((openParen - 1) > -1) && (signs.indexOf(conf[openParen - 1].toString()) === -1)) { + insertedMult = true; + conf.splice(openParen, 0, "*"); + } + // Check if a number is directly after closeParen and slip in the "*" if needed + if (!insertedMult && (((openParen + 1) < conf.length) && (signs.indexOf(conf[openParen + 1].toString()) === -1))) { + conf.splice((openParen + 1), 0, "*"); + } else if (insertedMult && (((openParen + 2) < conf.length) && (signs.indexOf(conf[openParen + 2].toString()) === -1))) { + // insertedMult is utilized here to let us account for an additional item being inserted into the array (the "*" from before openParn) + conf.splice((openParen + 2), 0, "*"); + } + } + + // Evaluate all EMMDAS by looping thru each teir of operators (exponential is the higehest teir, addition/subtraction the lowest) + const allCurOps = [["^"], ["*", "/", "%"], ["+", "-"]]; + allCurOps.forEach(curOps => { + log(LT.LOG, `Evaluating roll ${JSON.stringify(conf)} | Evaluating ${JSON.stringify(curOps)}`); + // Iterate thru all operators/operands in the conf + for (let i = 0; i < conf.length; i++) { + log(LT.LOG, `Evaluating roll ${JSON.stringify(conf)} | Evaluating ${JSON.stringify(curOps)} | Checking ${JSON.stringify(conf[i])}`); + // Check if the current index is in the active teir of operators + if (curOps.indexOf(conf[i].toString()) > -1) { + // Grab the operands from before and after the operator + const operand1 = conf[i - 1]; + const operand2 = conf[i + 1]; + // Init temp math to NaN to catch bad parsing + let oper1 = NaN; + let oper2 = NaN; + const subStepSolve = { + total: NaN, + details: "", + containsCrit: false, + containsFail: false + }; + // Flag to prevent infinte loop when dealing with negative numbers (such as [[-1+20]]) + let shouldDecrement = true; + + // If operand1 is a SolvedStep, populate our subStepSolve with its details and crit/fail flags + if (typeof operand1 === "object") { + oper1 = operand1.total; + subStepSolve.details = `${operand1.details}\\${conf[i]}`; + subStepSolve.containsCrit = operand1.containsCrit; + subStepSolve.containsFail = operand1.containsFail; + } else { + // else parse it as a number and add it to the subStep details + if (operand1) { + oper1 = parseFloat(operand1.toString()); + subStepSolve.details = `${oper1.toString()}\\${conf[i]}`; + } else if (conf[i] === '-') { + oper1 = 0; + subStepSolve.details = `\\${conf[i]}`; + shouldDecrement = false; + } + } + + // If operand2 is a SolvedStep, populate our subStepSolve with its details without overriding what operand1 filled in + if (typeof operand2 === "object") { + oper2 = operand2.total; + subStepSolve.details += operand2.details; + subStepSolve.containsCrit = subStepSolve.containsCrit || operand2.containsCrit; + subStepSolve.containsFail = subStepSolve.containsFail || operand2.containsFail; + } else { + // else parse it as a number and add it to the subStep details + oper2 = parseFloat(operand2.toString()); + subStepSolve.details += oper2; + } + + // Make sure neither operand is NaN before continuing + if (isNaN(oper1) || isNaN(oper2)) { + throw new Error("OperandNaN"); + } + + // Verify a second time that both are numbers before doing math, throwing an error if necessary + if ((typeof oper1 === "number") && (typeof oper2 === "number")) { + // Finally do the operator on the operands, throw an error if the operator is not found + switch (conf[i]) { + case "^": + subStepSolve.total = Math.pow(oper1, oper2); + break; + case "*": + subStepSolve.total = oper1 * oper2; + break; + case "/": + subStepSolve.total = oper1 / oper2; + break; + case "%": + subStepSolve.total = oper1 % oper2; + break; + case "+": + subStepSolve.total = oper1 + oper2; + break; + case "-": + subStepSolve.total = oper1 - oper2; + break; + default: + throw new Error("OperatorWhat"); + } + } else { + throw new Error("EMDASNotNumber"); + } + + // Determine if we actually did math or just smashed a - sign onto a number + if (shouldDecrement) { + // Replace the two operands and their operator with our subStepSolve + conf.splice((i - 1), 3, subStepSolve); + // Because we are messing around with the array we are iterating thru, we need to back up one idx to make sure every operator gets processed + i--; + } else { + // Replace the one operand and its operator (-) with our subStepSolve + conf.splice(i, 2, subStepSolve); + } + } + } + }); + + // If we somehow have more than one item left in conf at this point, something broke, throw an error + if (conf.length > 1) { + log(LT.LOG, `ConfWHAT? ${JSON.stringify(conf)}`); + throw new Error("ConfWhat"); + } else if (singleNum && (typeof (conf[0]) === "number")) { + // If we are only left with a number, populate the stepSolve with it + stepSolve.total = conf[0]; + stepSolve.details = conf[0].toString(); + } else { + // Else fully populate the stepSolve with what was computed + stepSolve.total = (conf[0]).total; + stepSolve.details = (conf[0]).details; + stepSolve.containsCrit = (conf[0]).containsCrit; + stepSolve.containsFail = (conf[0]).containsFail; + } + + // If this was a nested call, add on parens around the details to show what math we've done + if (wrapDetails) { + stepSolve.details = `(${stepSolve.details})`; + } + + // If our total has reached undefined for some reason, error out now + if (stepSolve.total === undefined) { + throw new Error("UndefinedStep"); + } + + return stepSolve; +};