diff --git a/README.md b/README.md index 4ba17b1..1aac456 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,5 @@ A dice roller Discord bot using roll20 format, also functioning as a basic calculator. https://discord.com/api/oauth2/authorize?client_id=789045930011656223&permissions=2048&scope=bot + +https://discord.com/api/oauth2/authorize?client_id=789045930011656223&permissions=10240&scope=bot diff --git a/config.example.ts b/config.example.ts index 1e47033..139b5a8 100644 --- a/config.example.ts +++ b/config.example.ts @@ -5,6 +5,8 @@ export const config = { "prefix": "[[", "postfix": "]]", "logChannel": "the_log_channel", + "reportChannel": "the_report_channel", + "devServer": "the_dev_server", "help": [ "```fix", "The Artificer Help", diff --git a/mod.ts b/mod.ts index cce96ac..7369f7c 100644 --- a/mod.ts +++ b/mod.ts @@ -4,7 +4,10 @@ * December 21, 2020 */ +// DEVMODE is to prevent users from accessing parts of the bot that are currently broken const DEVMODE = false; +// DEBUG is used to toggle the cmdPrompt +const DEBUG = true; import { startBot, editBotsStatus, @@ -25,6 +28,7 @@ startBot({ ready: () => { console.log("Logged in!"); editBotsStatus(StatusTypes.Online, `${config.prefix}help for commands`, ActivityType.Game); + // setTimeout added to make sure the startup message does not error out setTimeout(() => { sendMessage(config.logChannel, `${config.name} has started, running version ${config.version}.`).catch(() => { console.error("Failed to send message 00"); @@ -59,7 +63,6 @@ startBot({ // Its a ping test, what else do you want. if (command === "ping") { // Calculates ping between sending a message and editing it, giving a nice round-trip latency. - // The second ping is an average latency between the bot and the websocket server (one-way, not round-trip) try { const m = await utils.sendIndirectMessage(message, "Ping?", sendMessage, sendDirectMessage); m.edit(`Pong! Latency is ${m.timestamp - message.timestamp}ms.`); @@ -76,7 +79,7 @@ startBot({ }); } - // [[v or [[version + // [[version or [[v // Returns version of the bot else if (command === "version" || command === "v") { utils.sendIndirectMessage(message, `My current version is ${config.version}.`, sendMessage, sendDirectMessage).catch(err => { @@ -84,9 +87,9 @@ startBot({ }); } - // [[popcat + // [[popcat or [[pop or [[p // popcat animated emoji - else if (command === "popcat") { + else if (command === "popcat" || command === "pop" || command === "p") { utils.sendIndirectMessage(message, `<${config.emojis.popcat.animated ? "a" : ""}:${config.emojis.popcat.name}:${config.emojis.popcat.id}>`, sendMessage, sendDirectMessage).catch(err => { console.error("Failed to send message 40", message, err); }); @@ -98,7 +101,7 @@ startBot({ // [[report or [[r (command that failed) // Manually report a failed roll else if (command === "report" || command === "r") { - sendMessage(config.logChannel, ("USER REPORT:\n" + args.join(" "))).catch(err => { + sendMessage(config.reportChannel, ("USER REPORT:\n" + args.join(" "))).catch(err => { console.error("Failed to send message 50", message, err); }); utils.sendIndirectMessage(message, "Failed command has been reported to my developer.", sendMessage, sendDirectMessage).catch(err => { @@ -117,13 +120,15 @@ startBot({ // [[ // Dice rolling commence! else { - if (DEVMODE && message.guildID !== "317852981733097473") { + // If DEVMODE is on, only allow this command to be used in the devServer + if (DEVMODE && message.guildID !== config.devServer) { utils.sendIndirectMessage(message, "Command is in development, please try again later.", sendMessage, sendDirectMessage).catch(err => { console.error("Failed to send message 70", message, err); }); return; } + // Rest of this command is in a try-catch to protect all sends/edits from erroring out try { const m = await utils.sendIndirectMessage(message, "Rolling...", sendMessage, sendDirectMessage); @@ -135,8 +140,10 @@ startBot({ gmRoll: false, gms: [] }; + + // Check if any of the args are command flags and pull those out into the modifiers object for (let i = 0; i < args.length; i++) { - switch (args[i]) { + switch (args[i].toLowerCase()) { case "-nd": modifiers.noDetails = true; @@ -163,11 +170,15 @@ startBot({ break; case "-gm": modifiers.gmRoll = true; + + // -gm is a little more complex, as we must get all of the GMs that need to be DMd while (((i + 1) < args.length) && args[i + 1].startsWith("<@!")) { + // Keep looping thru the rest of the args until one does not start with the discord mention code modifiers.gms.push(args[i + 1]); args.splice((i + 1), 1); } if (modifiers.gms.length < 1) { + // If -gm is on and none were found, throw an error m.edit("Error: Must specifiy at least one GM by mentioning them"); return; } @@ -180,26 +191,40 @@ startBot({ } } - const rollCmd = command + " " + args.join(" "); + // maxRoll and nominalRoll cannot both be on, throw an error + if (modifiers.maxRoll && modifiers.nominalRoll) { + m.edit("Error: Cannot maximise and nominise the roll at the same time"); + return; + } + // Rejoin all of the args and send it into the solver, if solver returns a falsy item, an error object will be substituded in + const rollCmd = command + " " + args.join(" "); const returnmsg = solver.parseRoll(rollCmd, config.prefix, config.postfix, modifiers.maxRoll, modifiers.nominalRoll) || { error: true, errorMsg: "Error: Empty message", line1: "", line2: "", line3: "" }; + let returnText = ""; + // If there was an error, report it to the user in hopes that they can determine what they did wrong if (returnmsg.error) { returnText = returnmsg.errorMsg; + m.edit(returnText); + return; } else { + // Else format the output using details from the solver returnText = "<@" + message.author.id + ">" + returnmsg.line1 + "\n" + returnmsg.line2; if (modifiers.noDetails) { - returnText += "\nDetails suppressed by -nd flag."; + returnText += "\nDetails suppressed by -nd flag."; } else { returnText += "\nDetails:\n" + modifiers.spoiler + returnmsg.line3 + modifiers.spoiler; } } + // If the roll was a GM roll, send DMs to all the GMs if (modifiers.gmRoll) { + // Make a new return line to be sent to the roller const normalText = "<@" + message.author.id + ">" + returnmsg.line1 + "\nResults have been messaged to the following GMs: " + modifiers.gms.join(" "); + // And message the full details to each of the GMs, alerting roller of every GM that could not be messaged modifiers.gms.forEach(async e => { const msgs = utils.split2k(returnText); const failedDMs = []; @@ -213,7 +238,9 @@ startBot({ m.edit(normalText); } else { + // When not a GM roll, make sure the message is not too big if (returnText.length > 2000) { + // If its too big, attempt to DM details to the roller const msgs = utils.split2k(returnText); let failed = false; for (let i = 0; (!failed && (i < msgs.length)); i++) { @@ -221,6 +248,7 @@ startBot({ failed = true; }); } + // If DM fails to send, alert roller of the failure, else handle normally if (failed) { returnText = "<@" + message.author.id + ">" + returnmsg.line1 + "\n" + returnmsg.line2 + "\nDetails have been ommitted from this message for being over 2000 characters. WARNING: <@" + message.author.id + "> could **NOT** be messaged full details for verification purposes."; } else { @@ -228,6 +256,7 @@ startBot({ } } + // Finally send the text m.edit(returnText); } } catch (err) { @@ -238,4 +267,7 @@ startBot({ } }); -utils.cmdPrompt(config.logChannel, config.name, sendMessage); +// Start up the command prompt for debug usage +if (DEBUG) { + utils.cmdPrompt(config.logChannel, config.name, sendMessage); +} diff --git a/src/solver.d.ts b/src/solver.d.ts index fb91da6..37c2d92 100644 --- a/src/solver.d.ts +++ b/src/solver.d.ts @@ -1,3 +1,6 @@ +// solver.ts custom types + +// RollSet is used to preserve all information about a calculated roll export type RollSet = { origidx: number, roll: number, @@ -8,6 +11,7 @@ export type RollSet = { critFail: boolean }; +// SolvedStep is used to preserve information while math is being performed on the roll export type SolvedStep = { total: number, details: string, @@ -15,6 +19,7 @@ export type SolvedStep = { containsFail: boolean }; +// SolvedRoll is the complete solved and formatted roll, or the error said roll created export type SolvedRoll = { error: boolean, errorMsg: string, diff --git a/src/solver.ts b/src/solver.ts index a15916c..54be30c 100644 --- a/src/solver.ts +++ b/src/solver.ts @@ -1,11 +1,19 @@ import { RollSet, SolvedStep, SolvedRoll } 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; @@ -16,6 +24,8 @@ const compareRolls = (a: RollSet, b: RollSet): number => { 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; @@ -26,32 +36,48 @@ const compareOrigidx = (a: RollSet, b: RollSet): number => { 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++) { + // 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 const Capabilities ==> + /* 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 [OPT] - drops the lowest z dice, cannot be used with kz - * kz [OPT] - keeps the highest 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 [OPT] - changes crit score to q, where q can be a single number or a range formatted as q-u - * ! [OPT] - exploding, rolls another dy for every crit roll + * + * 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, @@ -73,89 +99,109 @@ const roll = (rollStr: string, maximiseRoll: boolean, nominalRoll: boolean): Rol }, reroll: { on: false, - nums: [0] + nums: [] }, critScore: { on: false, - range: [0] + range: [] }, critFail: { on: false, - range: [0] + 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(""); + // 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) { + // 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++) { 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++) { rollConf.critScore.range.push(i); @@ -163,39 +209,46 @@ const roll = (rollStr: string, maximiseRoll: boolean, nominalRoll: boolean): Rol 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++) { 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++) { 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 + // 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 => { if (e) { @@ -233,7 +286,7 @@ const roll = (rollStr: string, maximiseRoll: boolean, nominalRoll: boolean): Rol * critHit: false, * critFail: false * } - * + * * Each of these is defined as following: * { * origidx: The original index of the roll @@ -246,6 +299,7 @@ const roll = (rollStr: string, maximiseRoll: boolean, nominalRoll: boolean): Rol * } */ + // Initialize a templet rollSet to copy multiple times const templateRoll = { origidx: 0, roll: 0, @@ -256,71 +310,95 @@ const roll = (rollStr: string, maximiseRoll: boolean, nominalRoll: boolean): Rol 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++) { + // 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)); + // 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++) { + // 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); } @@ -328,44 +406,50 @@ const roll = (rollStr: string, maximiseRoll: boolean, nominalRoll: boolean): Rol } } - let rerollCount = 0; - for (let i = 0; i < rollSet.length; i++) { - rollSet[i].origidx = i; + // 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++) { + rollSet[i].origidx = i; - if (rollSet[i].rerolled) { - rerollCount++; + if (rollSet[i].rerolled) { + rerollCount++; + } + } } - } - if (rollConf.drop.on || rollConf.keep.on || rollConf.dropHigh.on || rollConf.keepLow.on) { + // Order the rolls from least to greatest (by RollSet.roll) rollSet.sort(compareRolls); - let dropCount = 0; + // 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; } - } - - if (rollConf.keep.on) { + } else if (rollConf.keep.on) { dropCount = validRolls - rollConf.keep.count; if (dropCount < 0) { dropCount = 0; } } - if (rollConf.dropHigh.on) { + // 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; } - } - - if (rollConf.keepLow.on) { + } else if (rollConf.keepLow.on) { rollSet.reverse(); dropCount = validRolls - rollConf.keepLow.count; if (dropCount < 0) { @@ -373,8 +457,10 @@ const roll = (rollStr: string, maximiseRoll: boolean, nominalRoll: boolean): Rol } } + // Now its time to drop all dice needed let i = 0; while (dropCount > 0 && i < rollSet.length) { + // Skip all rolls that were rerolled if (!rollSet[i].rerolled) { rollSet[i].dropped = true; dropCount--; @@ -382,24 +468,31 @@ const roll = (rollStr: string, maximiseRoll: boolean, nominalRoll: boolean): Rol 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 => { 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; @@ -408,21 +501,27 @@ const formatRoll = (rollConf: string, maximiseRoll: boolean, nominalRoll: boolea 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.substr(0, (tempDetails.length - 3)); tempDetails += "]"; @@ -434,7 +533,10 @@ const formatRoll = (rollConf: string, maximiseRoll: boolean, nominalRoll: boolea }; }; +// 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, @@ -443,6 +545,7 @@ const fullSolver = (conf: (string | number | SolvedStep)[], wrapDetails: boolean containsFail: false }; + // If entering with a single number, note it now let singleNum = false; if (conf.length === 1) { singleNum = true; @@ -450,47 +553,63 @@ const fullSolver = (conf: (string | number | SolvedStep)[], wrapDetails: boolean // Evaluate all parenthesis while (conf.indexOf("(") > -1) { + // 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++) { + // 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"); } - conf.splice(openParen, closeParen, fullSolver(conf.slice((openParen + 1), closeParen), true)); + // 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 + // Evaluate all EMMDAS by looping thru each teir of operators (exponential is the higehest teir, addition/subtraction the lowest) const allCurOps = [["^"], ["*", "/", "%"], ["+", "-"]]; allCurOps.forEach(curOps => { + // Iterate thru all operators/operands in the conf for (let i = 0; i < conf.length; 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 = { @@ -500,31 +619,38 @@ const fullSolver = (conf: (string | number | SolvedStep)[], wrapDetails: boolean containsFail: false }; + // 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 oper1 = parseFloat(operand1.toString()); - subStepSolve.details = oper1.toString() + conf[i]; + subStepSolve.details = oper1.toString() + "\\" + conf[i]; } + // 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); @@ -551,28 +677,35 @@ const fullSolver = (conf: (string | number | SolvedStep)[], wrapDetails: boolean throw new Error("EMDASNotNumber"); } + // Replace the two operands and their operator with our subStepSolve conf.splice((i - 1), (i + 2), 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--; } } }); + // If we somehow have more than one item left in conf at this point, something broke, throw an error if (conf.length > 1) { 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"); } @@ -580,6 +713,8 @@ const fullSolver = (conf: (string | number | SolvedStep)[], wrapDetails: boolean 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): SolvedRoll => { const returnmsg = { error: false, @@ -589,16 +724,22 @@ const parseRoll = (fullCmd: string, localPrefix: string, localPostfix: string, m 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 = []; + // Loop thru all roll/math ops for (let i = 0; i < sepRolls.length; 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 => { if (e === "(") { @@ -608,6 +749,7 @@ const parseRoll = (fullCmd: string, localPrefix: string, localPostfix: string, m } }); + // 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"); } @@ -615,17 +757,52 @@ const parseRoll = (fullCmd: string, localPrefix: string, localPostfix: string, m // Evaluate all rolls into stepSolve format and all numbers into floats for (let i = 0; i < mathConf.length; 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") { + // 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, @@ -636,14 +813,21 @@ const parseRoll = (fullCmd: string, localPrefix: string, localPostfix: string, m }); } + // Parsing/Solving done, time to format the output for Discord + + // Remove any floating spaces from fullCmd if (fullCmd[fullCmd.length - 1] === " ") { - fullCmd = escapeCharacters(fullCmd.substr(0, (fullCmd.length - 1)), "|"); + fullCmd = fullCmd.substr(0, (fullCmd.length - 1)); } + // Escape any | chars in fullCmd to prevent spoilers from acting up + fullCmd = escapeCharacters(fullCmd, "|"); + 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: `[[" + fullCmd + "`"; line2 = "Theoretical Maximum Results: "; @@ -655,9 +839,12 @@ const parseRoll = (fullCmd: string, localPrefix: string, localPostfix: string, m line2 = "Results: "; } + // Fill out all of the details and results now tempReturnData.forEach(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 + "**"; @@ -667,19 +854,26 @@ const parseRoll = (fullCmd: string, localPrefix: string, localPostfix: string, m postFormat = postFormat + "__"; } + // Populate line2 (the results) and line3 (the details) with their data line2 += preFormat + e.rollTotal + postFormat + escapeCharacters(e.rollPostFormat, "|*_~`"); line3 += "`" + e.initConfig + "` = " + e.rollDetails + " = " + preFormat + e.rollTotal + postFormat + "\n"; }); + // 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"; @@ -754,10 +948,11 @@ const parseRoll = (fullCmd: string, localPrefix: string, localPostfix: string, m break; default: console.error(errorName, errorDetails); - errorMsg = "Unhandled Error: " + solverError.message; + errorMsg = "Unhandled Error: " + solverError.message + "\nCheck input and try again, if issue persists, please use `[[report` to alert the devs of the issue"; break; } + // Fill in the return block returnmsg.error = true; returnmsg.errorMsg = errorMsg; } diff --git a/src/utils.ts b/src/utils.ts index f3d4efc..9ce227e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,18 +1,26 @@ import { Message } from "https://deno.land/x/discordeno@10.0.0/mod.ts"; +// split2k(longMessage) returns shortMessage[] +// split2k takes a long string in and cuts it into shorter strings to be sent in Discord const split2k = (chunk: string): string[] => { + // Replace any malformed newline characters chunk = chunk.replace(/\\n/g, "\n"); const bites = []; + + // While there is more characters than allowed to be sent in discord while (chunk.length > 2000) { - // take 2001 chars to see if word magically ends on char 2000 + // Take 2001 chars to see if word magically ends on char 2000 let bite = chunk.substr(0, 2001); - const etib = bite.split("").reverse().join(""); - const lastI = etib.indexOf(" "); // might be able to do lastIndexOf now - if (lastI > 0) { - bite = bite.substr(0, 2000 - lastI); + const lastI = bite.lastIndexOf(" "); + if (lastI < 2000) { + // If there is a final word before the 2000 split point, split right after that word + bite = bite.substr(0, lastI); } else { + // Else cut exactly 2000 characters bite = bite.substr(0, 2000); } + + // Push and remove the bite taken out of the chunk bites.push(bite); chunk = chunk.slice(bite.length); } @@ -22,7 +30,9 @@ const split2k = (chunk: string): string[] => { return bites; }; -const ask = async (question: string, stdin = Deno.stdin, stdout = Deno.stdout) => { +// ask(prompt) returns string +// ask prompts the user at command line for message +const ask = async (question: string, stdin = Deno.stdin, stdout = Deno.stdout): Promise => { const buf = new Uint8Array(1024); // Write question to console @@ -35,25 +45,44 @@ const ask = async (question: string, stdin = Deno.stdin, stdout = Deno.stdout) = return answer.trim(); }; +// cmdPrompt(logChannel, botName, sendMessage) returns nothing +// cmdPrompt creates an interactive CLI for the bot, commands can vary const cmdPrompt = async (logChannel: string, botName: string, sendMessage: (c: string, m: string) => Promise): Promise => { let done = false; while (!done) { + // Get a command and its args const fullCmd = await ask("cmd> "); + // Split the args off of the command and prep the command const args = fullCmd.split(" "); const command = args.shift()?.toLowerCase(); + + // All commands below here + + // exit or e + // Fully closes the bot if (command === "exit" || command === "e") { console.log(`${botName} Shutting down.\n\nGoodbye.`); done = true; Deno.exit(0); - } else if (command === "stop") { + } + + // stop + // Closes the CLI only, leaving the bot running truly headless + else if (command === "stop") { console.log(`Closing ${botName} CLI. Bot will continue to run.\n\nGoodbye.`); done = true; - } else if (command === "m") { + } + + // m [channel] [message] + // Sends [message] to specified [channel] + else if (command === "m") { try { const channelID = args.shift() || ""; const message = args.join(" "); + + // Utilize the split2k function to ensure a message over 2000 chars is not sent const messages = split2k(message); for (let i = 0; i < messages.length; i++) { sendMessage(channelID, messages[i]).catch(reason => { @@ -64,27 +93,44 @@ const cmdPrompt = async (logChannel: string, botName: string, sendMessage: (c: s catch (e) { console.error(e); } - } else if (command === "ml") { + } + + // ml [message] + // Sends a message to the specified log channel + else if (command === "ml") { const message = args.join(" "); + + // Utilize the split2k function to ensure a message over 2000 chars is not sent const messages = split2k(message); for (let i = 0; i < messages.length; i++) { sendMessage(logChannel, messages[i]).catch(reason => { console.error(reason); }); } - } else if (command === "help" || command === "h") { + } + + // help or h + // Shows a basic help menu + else if (command === "help" || command === "h") { console.log(`${botName} CLI Help:\n\nAvailable Commands:\n exit - closes bot\n stop - closes the CLI\n m [ChannelID] [messgae] - sends message to specific ChannelID as the bot\n ml [message] sends a message to the specified botlog\n help - this message`); - } else { + } + + // Unhandled commands die here + else { console.log("undefined command"); } } }; -const sendIndirectMessage = async (message: Message, messageContent: string, sendMessage: (c: string, m: string) => Promise, sendDirectMessage: (c: string, m: string) => Promise): Promise => { - if (message.guildID === "") { - return await sendDirectMessage(message.author.id, messageContent); +// sendIndirectMessage(originalMessage, messageContent, sendMessage, sendDirectMessage) returns Message +// sendIndirectMessage determines if the message needs to be sent as a direct message or as a normal message +const sendIndirectMessage = async (originalMessage: Message, messageContent: string, sendMessage: (c: string, m: string) => Promise, sendDirectMessage: (c: string, m: string) => Promise): Promise => { + if (originalMessage.guildID === "") { + // guildID was empty, meaning the original message was sent as a DM + return await sendDirectMessage(originalMessage.author.id, messageContent); } else { - return await sendMessage(message.channelID, messageContent); + // guildID was not empty, meaning the original message was sent in a server + return await sendMessage(originalMessage.channelID, messageContent); } };