diff --git a/.bruno/Authenticated/Roll Requests/Roll Dice.bru b/.bruno/Authenticated/Roll Requests/Roll Dice.bru index 2d12b1a..692c2a4 100644 --- a/.bruno/Authenticated/Roll Requests/Roll Dice.bru +++ b/.bruno/Authenticated/Roll Requests/Roll Dice.bru @@ -5,7 +5,7 @@ meta { } get { - url: http://localhost:8166/api/roll?user=[discord-user-id]&channel=[discord-channel-id]&rollstr=[artificer-roll-cmd]&documentation=All items below are optional. Flags do not need values.&nd=[no-details-flag]&snd=[super-no-details-flag]&hr=[hide-raw-roll-details-flag]&s=[spoiler-results-flag]&m-or-max=[max-roll-flag, cannot be used with n flag]&min=[[min-roll-flag, cannot be used with n or max]&n=[nominal-roll-flag, cannot be used with max or min flag]&gms=[csv-of-discord-user-ids-to-be-dmed-results]&o=[order-rolls, must be a or d]&c=[count-flag]&cc=[confirm-crit-flag] + url: http://localhost:8166/api/roll?user=[discord-user-id]&channel=[discord-channel-id]&rollstr=[artificer-roll-cmd]&documentation=All items below are optional. Flags do not need values.&nd=[no-details-flag]&snd=[super-no-details-flag]&hr=[hide-raw-roll-details-flag]&s=[spoiler-results-flag]&m-or-max=[max-roll-flag, cannot be used with n flag]&min=[[min-roll-flag, cannot be used with n or max]&n=[nominal-roll-flag, cannot be used with max or min flag]&gms=[csv-of-discord-user-ids-to-be-dmed-results]&o=[order-rolls, must be a or d]&c=[count-flag]&cc=[confirm-crit-flag]&rd=[roll-distribution-flag] body: none auth: inherit } diff --git a/.vscode/settings.json b/.vscode/settings.json index 666f4fa..3a7c5b1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,6 +14,7 @@ "CWOD", "DEVMODE", "Discordeno", + "Dists", "dkdk", "EMDAS", "funciton", diff --git a/README.md b/README.md index f45be71..81fa6bf 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,8 @@ The Artificer comes with a few supplemental commands to the main rolling command * `-gm @user1 @user2 ... @userN` - GM Roll - Rolls the requested roll in GM mode, suppressing all publicly shown results and details and sending the results directly to the specified GMs * `-o a` or `-o d` - Order Roll - Rolls the requested roll and orders the results in the requested direction * `-ct` - Comma Totals - Adds commas to totals for readability + * `-cc` - Confirm Critical Hits - Automatically rerolls whenever a crit hits + - `-rd` - Roll Distribution - Shows a raw roll distribution of all dice in roll * The results have some formatting applied on them to provide details on what happened during this roll. * Critical successes will be **bolded** * Critical fails will be underlined diff --git a/config.example.ts b/config.example.ts index 8a088e0..72a85e9 100644 --- a/config.example.ts +++ b/config.example.ts @@ -1,5 +1,6 @@ export const config = { name: 'The Artificer', // Name of the bot + maxFileSize: 8388290, // Max file size bot can send version: '3.0.0', // Version of the bot token: 'the_bot_token', // Discord API Token for this bot localtoken: 'local_testing_token', // Discord API Token for a secondary OPTIONAL testing bot, THIS MUST BE DIFFERENT FROM "token" diff --git a/src/artigen/artigen.d.ts b/src/artigen/artigen.d.ts index 4f3792a..3d3b817 100644 --- a/src/artigen/artigen.d.ts +++ b/src/artigen/artigen.d.ts @@ -1,4 +1,6 @@ -import { CountDetails } from 'artigen/dice/dice.d.ts'; +import { Embed, FileContent } from '@discordeno'; + +import { CountDetails, RollDistributionMap } from 'artigen/dice/dice.d.ts'; // ReturnData is the temporary internal type used before getting turned into SolvedRoll export interface ReturnData { @@ -20,4 +22,19 @@ export interface SolvedRoll { line2: string; line3: string; counts: CountDetails; + rollDistributions: RollDistributionMap; +} + +interface basicArtigenEmbed { + charCount: number; + embed: Embed; +} + +export interface ArtigenEmbedNoAttachment extends basicArtigenEmbed { + hasAttachment: false; +} + +export interface ArtigenEmbedWithAttachment extends basicArtigenEmbed { + hasAttachment: true; + attachment: FileContent; } diff --git a/src/artigen/artigen.ts b/src/artigen/artigen.ts index e610f00..d26d0f5 100644 --- a/src/artigen/artigen.ts +++ b/src/artigen/artigen.ts @@ -6,12 +6,13 @@ import { tokenizeCmd } from 'artigen/cmdTokenizer.ts'; import { loopCountCheck } from 'artigen/managers/loopManager.ts'; import { QueuedRoll } from 'artigen/managers/manager.d.ts'; +import { reduceCountDetails } from 'artigen/utils/counter.ts'; import { cmdSplitRegex, escapeCharacters } from 'artigen/utils/escape.ts'; import { loggingEnabled } from 'artigen/utils/logFlag.ts'; import { assertPrePostBalance } from 'artigen/utils/parenBalance.ts'; +import { reduceRollDistMaps } from 'artigen/utils/rollDist.ts'; import { compareTotalRolls, compareTotalRollsReverse } from 'artigen/utils/sortFuncs.ts'; import { translateError } from 'artigen/utils/translateError.ts'; -import { reduceCountDetails } from 'artigen/utils/counter.ts'; // runCmd(rollRequest) // runCmd handles converting rollRequest into a computer readable format for processing, and finally executes the solving @@ -31,6 +32,7 @@ export const runCmd = (rollRequest: QueuedRoll): SolvedRoll => { dropped: 0, exploded: 0, }, + rollDistributions: new Map(), }; // Whole processor lives in a try-catch to catch artigen's intentional error conditions @@ -43,8 +45,8 @@ export const runCmd = (rollRequest: QueuedRoll): SolvedRoll => { assertPrePostBalance(sepCmds); // Send the split roll into the command tokenizer to get raw response data - const [tempReturnData, tempCountDetails] = tokenizeCmd(sepCmds, rollRequest.modifiers, true); - loggingEnabled && log(LT.LOG, `Return data is back ${JSON.stringify(tempReturnData)}`); + const [tempReturnData, tempCountDetails, tempRollDists] = tokenizeCmd(sepCmds, rollRequest.modifiers, true); + loggingEnabled && log(LT.LOG, `Return data is back ${JSON.stringify(tempReturnData)} ${JSON.stringify(tempCountDetails)} ${JSON.stringify(tempRollDists)}`); // Remove any floating spaces from originalCommand // Escape any | and ` chars in originalCommand to prevent spoilers and code blocks from acting up @@ -120,6 +122,9 @@ export const runCmd = (rollRequest: QueuedRoll): SolvedRoll => { // Reduce counts to a single object returnMsg.counts = reduceCountDetails(tempCountDetails); + + // Reduce rollDist maps into a single map + returnMsg.rollDistributions = reduceRollDistMaps(tempRollDists); } catch (e) { // Fill in the return block const solverError = e as Error; diff --git a/src/artigen/cmdTokenizer.ts b/src/artigen/cmdTokenizer.ts index f1782b1..abbaf94 100644 --- a/src/artigen/cmdTokenizer.ts +++ b/src/artigen/cmdTokenizer.ts @@ -4,23 +4,29 @@ import config from '~config'; import { ReturnData } from 'artigen/artigen.d.ts'; -import { CountDetails, RollModifiers } from 'artigen/dice/dice.d.ts'; +import { CountDetails, RollDistributionMap, RollModifiers } from 'artigen/dice/dice.d.ts'; import { loopCountCheck } from 'artigen/managers/loopManager.ts'; import { tokenizeMath } from 'artigen/math/mathTokenizer.ts'; +import { reduceCountDetails } from 'artigen/utils/counter.ts'; import { closeInternal, internalWrapRegex, openInternal } from 'artigen/utils/escape.ts'; import { loggingEnabled } from 'artigen/utils/logFlag.ts'; import { getMatchingInternalIdx, getMatchingPostfixIdx } from 'artigen/utils/parenBalance.ts'; -import { reduceCountDetails } from 'artigen/utils/counter.ts'; // tokenizeCmd expects a string[] of items that are either config.prefix/config.postfix or some text that contains math and/or dice rolls -export const tokenizeCmd = (cmd: string[], modifiers: RollModifiers, topLevel: boolean, previousResults: number[] = []): [ReturnData[], CountDetails[]] => { +export const tokenizeCmd = ( + cmd: string[], + modifiers: RollModifiers, + topLevel: boolean, + previousResults: number[] = [], +): [ReturnData[], CountDetails[], RollDistributionMap[]] => { loggingEnabled && log(LT.LOG, `Tokenizing command ${JSON.stringify(cmd)}`); const returnData: ReturnData[] = []; const countDetails: CountDetails[] = []; + const rollDists: RollDistributionMap[] = []; // Wrapped commands still exist, unwrap them while (cmd.includes(config.prefix)) { @@ -34,7 +40,7 @@ export const tokenizeCmd = (cmd: string[], modifiers: RollModifiers, topLevel: b loggingEnabled && log(LT.LOG, `Setting previous results: topLevel:${topLevel} ${topLevel ? returnData.map((rd) => rd.rollTotal) : previousResults}`); // Handle any nested commands - const [tempData, tempCounts] = tokenizeCmd(currentCmd, modifiers, false, topLevel ? returnData.map((rd) => rd.rollTotal) : previousResults); + const [tempData, tempCounts, tempDists] = tokenizeCmd(currentCmd, modifiers, false, topLevel ? returnData.map((rd) => rd.rollTotal) : previousResults); const data = tempData[0]; if (topLevel) { @@ -53,14 +59,22 @@ export const tokenizeCmd = (cmd: string[], modifiers: RollModifiers, topLevel: b // Store results returnData.push(data); countDetails.push(...tempCounts); + rollDists.push(...tempDists); + // Handle ConfirmCrit if its on if (topLevel && modifiers.confirmCrit && reduceCountDetails(tempCounts).successful) { loggingEnabled && log(LT.LOG, `ConfirmCrit on ${JSON.stringify(currentCmd)}`); let done = false; while (!done) { loopCountCheck(); - const [ccTempData, ccTempCounts] = tokenizeCmd(currentCmd, modifiers, false, topLevel ? returnData.map((rd) => rd.rollTotal) : previousResults); + // Keep running the same roll again until its not successful + const [ccTempData, ccTempCounts, ccTempDists] = tokenizeCmd( + currentCmd, + modifiers, + false, + topLevel ? returnData.map((rd) => rd.rollTotal) : previousResults, + ); const ccData = ccTempData[0]; ccData.rollPreFormat = '\nAuto-Confirming Crit: '; @@ -69,6 +83,7 @@ export const tokenizeCmd = (cmd: string[], modifiers: RollModifiers, topLevel: b // Store CC results returnData.push(ccData); countDetails.push(...ccTempCounts); + rollDists.push(...ccTempDists); done = reduceCountDetails(ccTempCounts).successful === 0; } @@ -80,17 +95,19 @@ export const tokenizeCmd = (cmd: string[], modifiers: RollModifiers, topLevel: b loggingEnabled && log(LT.LOG, `Adding leftover formatting to last returnData ${JSON.stringify(cmd)}`); returnData[returnData.length - 1].rollPostFormat = cmd.join(''); } - return [returnData, countDetails]; + return [returnData, countDetails, rollDists]; } else { loggingEnabled && log(LT.LOG, `Tokenizing math ${JSON.stringify(cmd)}`); // Solve the math and rolls for this cmd - const [tempData, tempCounts] = tokenizeMath(cmd.join(''), modifiers, previousResults); + const [tempData, tempCounts, tempDists] = tokenizeMath(cmd.join(''), modifiers, previousResults); const data = tempData[0]; - loggingEnabled && log(LT.LOG, `Solved math is back ${JSON.stringify(data)} | ${JSON.stringify(returnData)}`); + loggingEnabled && + log(LT.LOG, `Solved math is back ${JSON.stringify(data)} | ${JSON.stringify(returnData)} ${JSON.stringify(tempCounts)} ${JSON.stringify(tempDists)}`); // Merge counts countDetails.push(...tempCounts); + rollDists.push(...tempDists); // Handle merging returnData into tempData const initConf = data.initConfig.split(internalWrapRegex).filter((x) => x); @@ -112,6 +129,6 @@ export const tokenizeCmd = (cmd: string[], modifiers: RollModifiers, topLevel: b // Join all parts/remainders data.initConfig = initConf.join(''); loggingEnabled && log(LT.LOG, `ReturnData merged into solved math ${JSON.stringify(data)} | ${JSON.stringify(countDetails)}`); - return [[data], countDetails]; + return [[data], countDetails, rollDists]; } }; diff --git a/src/artigen/dice/dice.d.ts b/src/artigen/dice/dice.d.ts index fb3cea6..44f263b 100644 --- a/src/artigen/dice/dice.d.ts +++ b/src/artigen/dice/dice.d.ts @@ -8,6 +8,7 @@ export interface RollSet { type: RollType; origIdx: number; roll: number; + size: number; dropped: boolean; rerolled: boolean; exploding: boolean; @@ -25,10 +26,15 @@ export interface CountDetails { exploded: number; } +// RollDistribution is used for storing the raw roll distribution +// use rollDistKey to generate the key +export type RollDistributionMap = Map; + // RollFormat is the return structure for the rollFormatter -export interface RollFormat { +export interface FormattedRoll { solvedStep: SolvedStep; countDetails: CountDetails; + rollDistributions: RollDistributionMap; } // RollModifiers is the structure to keep track of the decorators applied to a roll command @@ -46,6 +52,7 @@ export interface RollModifiers { count: boolean; commaTotals: boolean; confirmCrit: boolean; + rollDist: boolean; apiWarn: string; valid: boolean; error: Error; diff --git a/src/artigen/dice/executeRoll.ts b/src/artigen/dice/executeRoll.ts index 77b50b7..438a25b 100644 --- a/src/artigen/dice/executeRoll.ts +++ b/src/artigen/dice/executeRoll.ts @@ -54,6 +54,7 @@ export const executeRoll = (rollStr: string, modifiers: RollModifiers): RollSet[ type: rollConf.type, origIdx: 0, roll: 0, + size: 0, dropped: false, rerolled: false, exploding: false, @@ -71,6 +72,7 @@ export const executeRoll = (rollStr: string, modifiers: RollModifiers): RollSet[ const rolling = getTemplateRoll(); // If maximizeRoll 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 = rollConf.type === 'fate' ? genFateRoll(modifiers) : genRoll(rollConf.dieSize, modifiers, rollConf.dPercent); + rolling.size = rollConf.dieSize; // Set origIdx of roll rolling.origIdx = i; @@ -110,6 +112,7 @@ export const executeRoll = (rollStr: string, modifiers: RollModifiers): RollSet[ // Copy the template to fill out for this iteration const newReroll = getTemplateRoll(); + newReroll.size = rollConf.dieSize; if (modifiers.maxRoll && !minMaxOverride) { // If maximizeRoll is on and we've entered the reroll code, dieSize is not allowed, determine the next best option and always return that mmMaxLoop: for (let m = rollConf.dieSize - 1; m > 0; m--) { @@ -167,6 +170,7 @@ export const executeRoll = (rollStr: string, modifiers: RollModifiers): RollSet[ const newExplodingRoll = getTemplateRoll(); // If maximizeRoll 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 newExplodingRoll.roll = genRoll(rollConf.dieSize, modifiers, rollConf.dPercent); + newExplodingRoll.size = rollConf.dieSize; // Always mark this roll as exploding newExplodingRoll.exploding = true; diff --git a/src/artigen/dice/generateFormattedRoll.ts b/src/artigen/dice/generateFormattedRoll.ts index ba3e7ac..f00b5b6 100644 --- a/src/artigen/dice/generateFormattedRoll.ts +++ b/src/artigen/dice/generateFormattedRoll.ts @@ -1,16 +1,17 @@ import { log, LogTypes as LT } from '@Log4Deno'; -import { RollFormat, RollModifiers } from 'artigen/dice/dice.d.ts'; +import { FormattedRoll, RollModifiers } from 'artigen/dice/dice.d.ts'; import { executeRoll } from 'artigen/dice/executeRoll.ts'; import { loopCountCheck } from 'artigen/managers/loopManager.ts'; import { rollCounter } from 'artigen/utils/counter.ts'; import { loggingEnabled } from 'artigen/utils/logFlag.ts'; +import { createRollDistMap } from 'artigen/utils/rollDist.ts'; // generateFormattedRoll(rollConf, modifiers) returns one SolvedStep // generateFormattedRoll handles creating and formatting the completed rolls into the SolvedStep format -export const generateFormattedRoll = (rollConf: string, modifiers: RollModifiers): RollFormat => { +export const generateFormattedRoll = (rollConf: string, modifiers: RollModifiers): FormattedRoll => { let tempTotal = 0; let tempDetails = '['; let tempCrit = false; @@ -85,5 +86,6 @@ export const generateFormattedRoll = (rollConf: string, modifiers: RollModifiers containsFail: tempFail, }, countDetails: modifiers.count || modifiers.confirmCrit ? rollCounter(tempRollSet) : rollCounter([]), + rollDistributions: modifiers.rollDist ? createRollDistMap(tempRollSet) : new Map(), }; }; diff --git a/src/artigen/dice/getModifiers.ts b/src/artigen/dice/getModifiers.ts index fd2aa9b..488ddca 100644 --- a/src/artigen/dice/getModifiers.ts +++ b/src/artigen/dice/getModifiers.ts @@ -16,6 +16,7 @@ export const Modifiers = Object.freeze({ Order: '-o', CommaTotals: '-ct', ConfirmCrit: '-cc', + RollDistribution: '-rd', }); export const getModifiers = (args: string[]): [RollModifiers, string[]] => { @@ -33,6 +34,7 @@ export const getModifiers = (args: string[]): [RollModifiers, string[]] => { count: false, commaTotals: false, confirmCrit: false, + rollDist: false, apiWarn: '', valid: false, error: new Error(), @@ -104,6 +106,9 @@ export const getModifiers = (args: string[]): [RollModifiers, string[]] => { case Modifiers.CommaTotals: modifiers.commaTotals = true; break; + case Modifiers.RollDistribution: + modifiers.rollDist = true; + break; default: // Default case should not mess with the array defaultCase = true; diff --git a/src/artigen/dice/getRollConf.ts b/src/artigen/dice/getRollConf.ts index c9e3025..4eef389 100644 --- a/src/artigen/dice/getRollConf.ts +++ b/src/artigen/dice/getRollConf.ts @@ -72,7 +72,7 @@ export const getRollConf = (rollStr: string): RollConf => { const rawDC = dPts.shift() || '1'; if (rawDC.includes('.')) { throw new Error('WholeDieCountSizeOnly'); - } else if (rawDC.match(/\D/)) { + } else if (!rawDC.endsWith('cwo') && !rawDC.endsWith('ova') && rawDC.match(/\D/)) { throw new Error(`CannotParseDieCount_${rawDC}`); } const tempDC = rawDC.replace(/\D/g, ''); diff --git a/src/artigen/managers/handler/workerComplete.ts b/src/artigen/managers/handler/workerComplete.ts index 02e1a5a..160da87 100644 --- a/src/artigen/managers/handler/workerComplete.ts +++ b/src/artigen/managers/handler/workerComplete.ts @@ -1,4 +1,4 @@ -import { DiscordenoMessage, sendDirectMessage, sendMessage } from '@discordeno'; +import { botId, DiscordenoMessage, Embed, FileContent, sendDirectMessage, sendMessage } from '@discordeno'; import { log, LogTypes as LT } from '@Log4Deno'; import config from '~config'; @@ -6,10 +6,12 @@ import { DEVMODE } from '~flags'; import { SolvedRoll } from 'artigen/artigen.d.ts'; +import { RollModifiers } from 'artigen/dice/dice.d.ts'; + import { removeWorker } from 'artigen/managers/countManager.ts'; import { QueuedRoll } from 'artigen/managers/manager.d.ts'; -import { generateCountDetailsEmbed, generateDMFailed, generateRollEmbed } from 'artigen/utils/embeds.ts'; +import { generateCountDetailsEmbed, generateDMFailed, generateRollDistsEmbed, generateRollEmbed } from 'artigen/utils/embeds.ts'; import { loggingEnabled } from 'artigen/utils/logFlag.ts'; import dbClient from 'db/client.ts'; @@ -18,6 +20,8 @@ import { queries } from 'db/common.ts'; import stdResp from 'endpoints/stdResponses.ts'; import utils from 'utils/utils.ts'; +import { infoColor1 } from 'embeds/colors.ts'; +import { basicReducer } from 'artigen/utils/reducers.ts'; export const onWorkerComplete = async (workerMessage: MessageEvent, workerTimeout: number, rollRequest: QueuedRoll) => { let apiErroredOut = false; @@ -28,24 +32,57 @@ export const onWorkerComplete = async (workerMessage: MessageEvent, const returnMsg = workerMessage.data; loggingEnabled && log(LT.LOG, `Roll came back from worker: ${returnMsg.line1.length} |&| ${returnMsg.line2.length} |&| ${returnMsg.line3.length} `); loggingEnabled && log(LT.LOG, `Roll came back from worker: ${returnMsg.line1} |&| ${returnMsg.line2} |&| ${returnMsg.line3} `); - const pubEmbedDetails = await generateRollEmbed( + const pubEmbedDetails = generateRollEmbed( rollRequest.apiRoll ? rollRequest.api.userId : rollRequest.dd.originalMessage.authorId, returnMsg, rollRequest.modifiers, ); - const gmEmbedDetails = await generateRollEmbed(rollRequest.apiRoll ? rollRequest.api.userId : rollRequest.dd.originalMessage.authorId, returnMsg, { + const gmEmbedDetails = generateRollEmbed(rollRequest.apiRoll ? rollRequest.api.userId : rollRequest.dd.originalMessage.authorId, returnMsg, { ...rollRequest.modifiers, gmRoll: false, }); - const countEmbed = generateCountDetailsEmbed(returnMsg.counts); - loggingEnabled && log(LT.LOG, `Embeds are generated: ${JSON.stringify(pubEmbedDetails)} |&| ${JSON.stringify(gmEmbedDetails)}`); + + let pubRespCharCount = pubEmbedDetails.charCount; + let gmRespCharCount = gmEmbedDetails.charCount; + const pubEmbeds: Embed[] = [pubEmbedDetails.embed]; + const gmEmbeds: Embed[] = [gmEmbedDetails.embed]; + const pubAttachments: FileContent[] = pubEmbedDetails.hasAttachment ? [pubEmbedDetails.attachment] : []; + const gmAttachments: FileContent[] = gmEmbedDetails.hasAttachment ? [gmEmbedDetails.attachment] : []; + + // Handle adding count embed to correct list + if (rollRequest.modifiers.count) { + const countEmbed = generateCountDetailsEmbed(returnMsg.counts); + if (rollRequest.modifiers.gmRoll) { + gmEmbeds.push(countEmbed.embed); + gmRespCharCount += countEmbed.charCount; + } else { + pubEmbeds.push(countEmbed.embed); + pubRespCharCount += countEmbed.charCount; + } + } + + // Handle adding rollDist embed to correct list + if (rollRequest.modifiers.rollDist) { + const rollDistEmbed = generateRollDistsEmbed(returnMsg.rollDistributions); + if (rollRequest.modifiers.gmRoll) { + gmEmbeds.push(rollDistEmbed.embed); + rollDistEmbed.hasAttachment && gmAttachments.push(rollDistEmbed.attachment); + gmRespCharCount += rollDistEmbed.charCount; + } else { + pubEmbeds.push(rollDistEmbed.embed); + rollDistEmbed.hasAttachment && pubAttachments.push(rollDistEmbed.attachment); + pubRespCharCount += rollDistEmbed.charCount; + } + } + + loggingEnabled && log(LT.LOG, `Embeds are generated: ${pubRespCharCount} ${JSON.stringify(pubEmbeds)} |&| ${gmRespCharCount} ${JSON.stringify(gmEmbeds)}`); // If there was an error, report it to the user in hopes that they can determine what they did wrong if (returnMsg.error) { if (rollRequest.apiRoll) { rollRequest.api.resolve(stdResp.InternalServerError(returnMsg.errorMsg)); } else { - rollRequest.dd.myResponse.edit({ embeds: [pubEmbedDetails.embed] }); + rollRequest.dd.myResponse.edit({ embeds: pubEmbeds }); } if (rollRequest.apiRoll || (DEVMODE && config.logRolls)) { @@ -58,103 +95,142 @@ export const onWorkerComplete = async (workerMessage: MessageEvent, ]) .catch((e) => utils.commonLoggers.dbError('rollQueue.ts:82', 'insert into', e)); } + + return; + } + + let newMsg: DiscordenoMessage | void = undefined; + // Determine if we are to send a GM roll or a normal roll + if (rollRequest.modifiers.gmRoll) { + if (rollRequest.apiRoll) { + newMsg = await sendMessage(rollRequest.api.channelId, { + content: rollRequest.modifiers.apiWarn, + embeds: pubEmbeds, + }).catch(() => { + apiErroredOut = true; + rollRequest.api.resolve(stdResp.InternalServerError('Message failed to send - location 0.')); + }); + } else { + // Send the public embed to correct channel + rollRequest.dd.myResponse.edit({ embeds: pubEmbeds }); + } + + if (!apiErroredOut) { + // And message the full details to each of the GMs, alerting roller of every GM that could not be messaged + rollRequest.modifiers.gms.forEach(async (gm) => { + const gmId: bigint = BigInt(gm.startsWith('<') ? gm.substring(2, gm.length - 1) : gm); + log(LT.LOG, `Messaging GM ${gm} | ${gmId}`); + // Attempt to DM the GM and send a warning if it could not DM a GM + await sendDirectMessage(gmId, { + content: `Original GM Roll Request: ${rollRequest.apiRoll ? newMsg && newMsg.link : rollRequest.dd.myResponse.link}`, + embeds: gmEmbeds, + }) + .then(async () => { + // Check if we need to attach a file and send it after the initial details sent + if (gmAttachments.length) { + await sendDirectMessage(gmId, { + file: gmAttachments, + }).catch(() => { + if (newMsg && rollRequest.apiRoll) { + newMsg.reply(generateDMFailed(gmId)); + } else if (!rollRequest.apiRoll) { + rollRequest.dd.originalMessage.reply(generateDMFailed(gmId)); + } + }); + } + }) + .catch(() => { + if (rollRequest.apiRoll && newMsg) { + newMsg.reply(generateDMFailed(gmId)); + } else if (!rollRequest.apiRoll) { + rollRequest.dd.originalMessage.reply(generateDMFailed(gmId)); + } + }); + }); + } } else { - let newMsg: DiscordenoMessage | void = undefined; - // Determine if we are to send a GM roll or a normal roll - if (rollRequest.modifiers.gmRoll) { - if (rollRequest.apiRoll) { - newMsg = await sendMessage(rollRequest.api.channelId, { - content: rollRequest.modifiers.apiWarn, - embeds: [pubEmbedDetails.embed], - }).catch(() => { - apiErroredOut = true; - rollRequest.api.resolve(stdResp.InternalServerError('Message failed to send - location 0.')); + // Not a gm roll, so just send normal embed to correct channel + if (rollRequest.apiRoll) { + newMsg = await sendMessage(rollRequest.api.channelId, { + content: rollRequest.modifiers.apiWarn, + embeds: pubEmbeds, + }).catch(() => { + apiErroredOut = true; + rollRequest.api.resolve(stdResp.InternalServerError('Message failed to send - location 1.')); + }); + } else { + newMsg = await rollRequest.dd.myResponse.edit({ + embeds: pubEmbeds, + }); + } + + if (pubAttachments.length && newMsg) { + // Attachment requires you to send a new message + const respMessage: Embed[] = [ + { + color: infoColor1, + description: `This message contains information for a previous roll.\nPlease click on "<@${botId}> *Click to see attachment*" above this message to see the previous roll.`, + }, + ]; + + if (pubAttachments.map((file) => file.blob.size).reduce(basicReducer) < config.maxFileSize) { + // All attachments will fit in one message + newMsg.reply({ + embeds: respMessage, + file: pubAttachments, }); } else { - // Send the public embed to correct channel - rollRequest.dd.myResponse.edit({ embeds: [pubEmbedDetails.embed] }); - } - - if (!apiErroredOut) { - // And message the full details to each of the GMs, alerting roller of every GM that could not be messaged - rollRequest.modifiers.gms.forEach(async (gm) => { - const gmId: bigint = BigInt(gm.startsWith('<') ? gm.substring(2, gm.length - 1) : gm); - log(LT.LOG, `Messaging GM ${gm} | ${gmId}`); - // Attempt to DM the GM and send a warning if it could not DM a GM - await sendDirectMessage(gmId, { - content: `Original GM Roll Request: ${rollRequest.apiRoll ? newMsg && newMsg.link : rollRequest.dd.myResponse.link}`, - embeds: rollRequest.modifiers.count ? [gmEmbedDetails.embed, countEmbed] : [gmEmbedDetails.embed], - }) - .then(async () => { - // Check if we need to attach a file and send it after the initial details sent - if (gmEmbedDetails.hasAttachment) { - await sendDirectMessage(gmId, { - file: gmEmbedDetails.attachment, - }).catch(() => { - if (newMsg && rollRequest.apiRoll) { - newMsg.reply(generateDMFailed(gmId)); - } else if (!rollRequest.apiRoll) { - rollRequest.dd.originalMessage.reply(generateDMFailed(gmId)); - } - }); - } - }) - .catch(() => { - if (rollRequest.apiRoll && newMsg) { - newMsg.reply(generateDMFailed(gmId)); - } else if (!rollRequest.apiRoll) { - rollRequest.dd.originalMessage.reply(generateDMFailed(gmId)); - } + pubAttachments.forEach((file) => { + newMsg && + newMsg.reply({ + embeds: respMessage, + file, }); }); } - } else { - // Not a gm roll, so just send normal embed to correct channel - if (rollRequest.apiRoll) { - newMsg = await sendMessage(rollRequest.api.channelId, { - content: rollRequest.modifiers.apiWarn, - embeds: rollRequest.modifiers.count ? [pubEmbedDetails.embed, countEmbed] : [pubEmbedDetails.embed], - }).catch(() => { - apiErroredOut = true; - rollRequest.api.resolve(stdResp.InternalServerError('Message failed to send - location 1.')); - }); - } else { - newMsg = await rollRequest.dd.myResponse.edit({ - embeds: rollRequest.modifiers.count ? [pubEmbedDetails.embed, countEmbed] : [pubEmbedDetails.embed], - }); - } - - if (pubEmbedDetails.hasAttachment && newMsg) { - // Attachment requires you to send a new message - newMsg.reply({ - file: pubEmbedDetails.attachment, - }); - } } + } - if (rollRequest.apiRoll && !apiErroredOut) { - dbClient - .execute(queries.insertRollLogCmd(1, 0), [rollRequest.originalCommand, returnMsg.errorCode, newMsg ? newMsg.id : null]) - .catch((e) => utils.commonLoggers.dbError('rollQueue.ts:155', 'insert into', e)); + if (rollRequest.apiRoll && !apiErroredOut) { + dbClient + .execute(queries.insertRollLogCmd(1, 0), [rollRequest.originalCommand, returnMsg.errorCode, newMsg ? newMsg.id : null]) + .catch((e) => utils.commonLoggers.dbError('rollQueue.ts:155', 'insert into', e)); - rollRequest.api.resolve( - stdResp.OK( - JSON.stringify( - rollRequest.modifiers.count - ? { - counts: countEmbed, - details: pubEmbedDetails, - } - : { - details: pubEmbedDetails, - }, - ), + rollRequest.api.resolve( + stdResp.OK( + JSON.stringify( + rollRequest.modifiers.count + ? { + counts: returnMsg.counts, + details: pubEmbedDetails, + } + : { + details: pubEmbedDetails, + }, ), - ); - } + ), + ); } } catch (e) { log(LT.ERROR, `Unhandled rollRequest Error: ${JSON.stringify(e)}`); + if (!rollRequest.apiRoll) { + rollRequest.dd.myResponse.edit({ + embeds: [ + ( + await generateRollEmbed( + rollRequest.dd.originalMessage.authorId, + { + error: true, + errorMsg: + `Something weird went wrong, likely the requested roll is too complex and caused the response to be too large for Discord. Try breaking the request down into smaller messages and try again.\n\nIf this error continues to come up, please \`${config.prefix}report\` this to my developer.`, + errorCode: 'UnhandledWorkerComplete', + }, + {}, + ) + ).embed, + ], + }); + } if (rollRequest.apiRoll && !apiErroredOut) { rollRequest.api.resolve(stdResp.InternalServerError(JSON.stringify(e))); } diff --git a/src/artigen/math/mathTokenizer.ts b/src/artigen/math/mathTokenizer.ts index 86b52b5..b875c38 100644 --- a/src/artigen/math/mathTokenizer.ts +++ b/src/artigen/math/mathTokenizer.ts @@ -4,7 +4,7 @@ import { ReturnData } from 'artigen/artigen.d.ts'; import { MathConf, SolvedStep } from 'artigen/math/math.d.ts'; -import { CountDetails, RollModifiers } from 'artigen/dice/dice.d.ts'; +import { CountDetails, RollDistributionMap, RollModifiers } from 'artigen/dice/dice.d.ts'; import { generateFormattedRoll } from 'artigen/dice/generateFormattedRoll.ts'; import { loopCountCheck } from 'artigen/managers/loopManager.ts'; @@ -20,8 +20,9 @@ import { assertParenBalance } from 'artigen/utils/parenBalance.ts'; const minusOps = ['(', '^', '*', '/', '%', '+', '-']; const allOps = [...minusOps, ')']; -export const tokenizeMath = (cmd: string, modifiers: RollModifiers, previousResults: number[]): [ReturnData[], CountDetails[]] => { +export const tokenizeMath = (cmd: string, modifiers: RollModifiers, previousResults: number[]): [ReturnData[], CountDetails[], RollDistributionMap[]] => { const countDetails: CountDetails[] = []; + const rollDists: RollDistributionMap[] = []; loggingEnabled && log(LT.LOG, `Parsing roll ${cmd} | ${JSON.stringify(modifiers)} | ${JSON.stringify(previousResults)}`); @@ -146,6 +147,7 @@ export const tokenizeMath = (cmd: string, modifiers: RollModifiers, previousResu const formattedRoll = generateFormattedRoll(curMathConfStr, modifiers); mathConf[i] = formattedRoll.solvedStep; countDetails.push(formattedRoll.countDetails); + rollDists.push(formattedRoll.rollDistributions); } // Identify if we are in a state where the current number is a negative number @@ -185,5 +187,6 @@ export const tokenizeMath = (cmd: string, modifiers: RollModifiers, previousResu }, ], countDetails, + rollDists, ]; }; diff --git a/src/artigen/utils/embeds.ts b/src/artigen/utils/embeds.ts index c15afec..508e583 100644 --- a/src/artigen/utils/embeds.ts +++ b/src/artigen/utils/embeds.ts @@ -1,15 +1,17 @@ +import { CreateMessage, EmbedField } from '@discordeno'; import { log, LogTypes as LT } from '@Log4Deno'; import config from '~config'; -import { SolvedRoll } from 'artigen/artigen.d.ts'; +import { ArtigenEmbedNoAttachment, ArtigenEmbedWithAttachment, SolvedRoll } from 'artigen/artigen.d.ts'; -import { CountDetails, RollModifiers } from 'artigen/dice/dice.d.ts'; +import { CountDetails, RollDistributionMap, RollModifiers } from 'artigen/dice/dice.d.ts'; import { loggingEnabled } from 'artigen/utils/logFlag.ts'; import { failColor, infoColor1, infoColor2 } from 'embeds/colors.ts'; +import { basicReducer } from 'artigen/utils/reducers.ts'; -export const rollingEmbed = { +export const rollingEmbed: CreateMessage = { embeds: [ { color: infoColor1, @@ -18,7 +20,7 @@ export const rollingEmbed = { ], }; -export const generateDMFailed = (user: bigint) => ({ +export const generateDMFailed = (user: bigint): CreateMessage => ({ embeds: [ { color: failColor, @@ -28,7 +30,7 @@ export const generateDMFailed = (user: bigint) => ({ ], }); -export const generateRollError = (errorType: string, errorName: string, errorMsg: string) => ({ +export const generateRollError = (errorType: string, errorName: string, errorMsg: string): CreateMessage => ({ embeds: [ { color: failColor, @@ -46,10 +48,9 @@ export const generateRollError = (errorType: string, errorName: string, errorMsg ], }); -export const generateCountDetailsEmbed = (counts: CountDetails) => ({ - color: infoColor1, - title: 'Roll Count Details:', - fields: [ +export const generateCountDetailsEmbed = (counts: CountDetails): ArtigenEmbedNoAttachment => { + const title = 'Roll Count Details:'; + const fields: EmbedField[] = [ { name: 'Total Rolls:', value: `${counts.total}`, @@ -80,104 +81,191 @@ export const generateCountDetailsEmbed = (counts: CountDetails) => ({ value: `${counts.exploded}`, inline: true, }, - ], -}); + ]; -export const generateRollEmbed = async (authorId: bigint, returnDetails: SolvedRoll, modifiers: RollModifiers) => { + return { + charCount: title.length + fields.map((field) => field.name.length + field.value.length).reduce(basicReducer), + embed: { + color: infoColor1, + title, + fields, + }, + hasAttachment: false, + }; +}; + +const getDistName = (key: string) => { + const [type, size] = key.split('-'); + switch (type) { + case 'fate': + return 'Fate dice'; + case 'cwod': + return `CWOD d${size}`; + case 'ova': + return `OVA d${size}`; + case 'roll20': + default: + return `d${size}`; + } +}; + +export const generateRollDistsEmbed = (rollDists: RollDistributionMap): ArtigenEmbedNoAttachment | ArtigenEmbedWithAttachment => { + const fields = rollDists + .entries() + .toArray() + .slice(0, 25) + .map(([key, distArr]) => { + const total = distArr.reduce(basicReducer); + return { + name: `${getDistName(key)} (Total rolls: ${total}):`, + value: distArr.map((cnt, dieIdx) => `${key.startsWith('fate') ? dieIdx - 1 : dieIdx + 1}: ${cnt} (${((cnt / total) * 100).toFixed(1)}%)`).join('\n'), + inline: true, + }; + }); + const rollDistTitle = 'Roll Distributions:'; + + const totalSize = fields.map((field) => field.name.length + field.value.length).reduce(basicReducer); + if (totalSize > 4000 || fields.some((field) => field.name.length > 256 || field.value.length > 1024)) { + const rollDistBlob = new Blob([fields.map((field) => `# ${field.name}\n${field.value}`).join('\n\n') as BlobPart], { type: 'text' }); + if (rollDistBlob.size > config.maxFileSize) { + const rollDistErrDesc = + 'The roll distribution was too large to be included and could not be attached below. If you would like to see the roll distribution details, please send the rolls in multiple messages.'; + return { + charCount: rollDistTitle.length + rollDistErrDesc.length, + embed: { + color: failColor, + title: rollDistTitle, + description: rollDistErrDesc, + }, + hasAttachment: false, + }; + } else { + const rollDistErrDesc = 'The roll distribution was too large to be included and has been attached below.'; + return { + charCount: rollDistTitle.length + rollDistErrDesc.length, + embed: { + color: failColor, + title: rollDistTitle, + description: rollDistErrDesc, + }, + hasAttachment: true, + attachment: { + name: 'rollDistributions.md', + blob: rollDistBlob, + }, + }; + } + } + + return { + charCount: rollDistTitle.length + totalSize, + embed: { + color: infoColor1, + title: rollDistTitle, + fields, + }, + hasAttachment: false, + }; +}; + +export const generateRollEmbed = ( + authorId: bigint, + returnDetails: SolvedRoll, + modifiers: RollModifiers, +): ArtigenEmbedNoAttachment | ArtigenEmbedWithAttachment => { if (returnDetails.error) { // Roll had an error, send error embed + const errTitle = 'Roll failed:'; + const errDesc = `${returnDetails.errorMsg}`; + const errCode = `Code: ${returnDetails.errorCode}`; + return { + charCount: errTitle.length + errDesc.length + errCode.length, embed: { color: failColor, - title: 'Roll failed:', - description: `${returnDetails.errorMsg}`, + title: errTitle, + description: errDesc, footer: { - text: `Code: ${returnDetails.errorCode}`, + text: errCode, }, }, hasAttachment: false, - attachment: { - blob: await new Blob(['' as BlobPart], { type: 'text' }), - name: 'rollDetails.txt', - }, }; - } else { - const line1Details = modifiers.hideRaw ? '' : `<@${authorId}>${returnDetails.line1}\n`; - if (modifiers.gmRoll) { - // Roll is a GM Roll, send this in the pub channel (this funciton will be ran again to get details for the GMs) - return { - embed: { - color: infoColor2, - description: `${line1Details}${line1Details ? '\n' : ''}Results have been messaged to the following GMs: ${ - modifiers.gms - .map((gm) => (gm.startsWith('<') ? gm : `<@${gm}>`)) - .join(' ') - }`, - }, - hasAttachment: false, - attachment: { - blob: await new Blob(['' as BlobPart], { type: 'text' }), - name: 'rollDetails.txt', - }, - }; - } else { - // Roll is normal, make normal embed - const line2Details = returnDetails.line2.split(': '); - let details = ''; - - if (!modifiers.superNoDetails) { - details = `**Details:**\n${modifiers.spoiler}${returnDetails.line3}${modifiers.spoiler}`; - loggingEnabled && log(LT.LOG, `${returnDetails.line3} |&| ${details}`); - } - - const baseDesc = `${line1Details}**${line2Details.shift()}:**\n${line2Details.join(': ')}`; - - // Embed desc limit is 4096 - if (baseDesc.length + details.length < 4090) { - return { - embed: { - color: infoColor2, - description: `${baseDesc}\n\n${details}`, - }, - hasAttachment: false, - attachment: { - blob: await new Blob(['' as BlobPart], { type: 'text' }), - name: 'rollDetails.txt', - }, - }; - } else { - // If its too big, collapse it into a .txt file and send that instead. - const b = await new Blob([`${baseDesc}\n\n${details}` as BlobPart], { type: 'text' }); - details = 'Details have been omitted from this message for being over 4000 characters.'; - if (b.size > 8388290) { - details += - '\n\nFull details could not be attached to this messaged as a `.txt` file as the file would be too large for Discord to handle. If you would like to see the details of rolls, please send the rolls in multiple messages instead of bundled into one.'; - return { - embed: { - color: infoColor2, - description: `${baseDesc}\n\n${details}`, - }, - hasAttachment: false, - attachment: { - blob: await new Blob(['' as BlobPart], { type: 'text' }), - name: 'rollDetails.txt', - }, - }; - } else { - details += '\n\nFull details have been attached to this messaged as a `.txt` file for verification purposes.'; - return { - embed: { - color: infoColor2, - description: `${baseDesc}\n\n${details}`, - }, - hasAttachment: true, - attachment: { - blob: b, - name: 'rollDetails.txt', - }, - }; - } - } - } } + + const line1Details = modifiers.hideRaw ? '' : `<@${authorId}>${returnDetails.line1}\n`; + if (modifiers.gmRoll) { + // Roll is a GM Roll, send this in the pub channel (this funciton will be ran again to get details for the GMs) + const desc = `${line1Details}${line1Details ? '\n' : ''}Results have been messaged to the following GMs: ${ + modifiers.gms + .map((gm) => (gm.startsWith('<') ? gm : `<@${gm}>`)) + .join(' ') + }`; + + return { + charCount: desc.length, + embed: { + color: infoColor2, + description: desc, + }, + hasAttachment: false, + }; + } + + // Roll is normal, make normal embed + const line2Details = returnDetails.line2.split(': '); + let details = ''; + + if (!modifiers.superNoDetails) { + details = `**Details:**\n${modifiers.spoiler}${returnDetails.line3}${modifiers.spoiler}`; + loggingEnabled && log(LT.LOG, `${returnDetails.line3} |&| ${details}`); + } + + const baseDesc = `${line1Details}**${line2Details.shift()}:**\n${line2Details.join(': ')}`; + + // Embed desc limit is 4096 + if (baseDesc.length + details.length < 4000) { + // Response is valid size + const desc = `${baseDesc}\n\n${details}`; + return { + charCount: desc.length, + embed: { + color: infoColor2, + description: desc, + }, + hasAttachment: false, + }; + } + + // Response is too big, collapse it into a .txt file and send that instead. + const b = new Blob([`${baseDesc}\n\n${details}` as BlobPart], { type: 'text' }); + details = `${baseDesc}\n\nDetails have been omitted from this message for being over 4000 characters.`; + if (b.size > config.maxFileSize) { + // blob is too big, don't attach it + details += + '\n\nFull details could not be attached to this messaged as a `.txt` file as the file would be too large for Discord to handle. If you would like to see the details of rolls, please send the rolls in multiple messages.'; + return { + charCount: details.length, + embed: { + color: infoColor2, + description: details, + }, + hasAttachment: false, + }; + } + + // blob is small enough, attach it + details += '\n\nFull details have been attached to this messaged as a `.txt` file for verification purposes.'; + return { + charCount: details.length, + embed: { + color: infoColor2, + description: details, + }, + hasAttachment: true, + attachment: { + blob: b, + name: 'rollDetails.txt', + }, + }; }; diff --git a/src/artigen/utils/reducers.ts b/src/artigen/utils/reducers.ts new file mode 100644 index 0000000..964699d --- /dev/null +++ b/src/artigen/utils/reducers.ts @@ -0,0 +1 @@ +export const basicReducer = (prev: number, cur: number) => prev + cur; diff --git a/src/artigen/utils/rollDist.ts b/src/artigen/utils/rollDist.ts new file mode 100644 index 0000000..92f6bfc --- /dev/null +++ b/src/artigen/utils/rollDist.ts @@ -0,0 +1,42 @@ +import { RollDistributionMap, RollSet, RollType } from 'artigen/dice/dice.d.ts'; + +import { loopCountCheck } from 'artigen/managers/loopManager.ts'; + +// Used to generate consistent keys for rollDistributions +export const rollDistKey = (type: RollType, size: number) => `${type}-${size}`; + +// Converts a RollSet into a RollDistMap +export const createRollDistMap = (rollSet: RollSet[]): RollDistributionMap => { + const rollDistMap = new Map(); + + rollSet.forEach((roll) => { + loopCountCheck(); + const tempArr: number[] = rollDistMap.get(rollDistKey(roll.type, roll.size)) ?? new Array(roll.type === 'fate' ? roll.size + 2 : roll.size).fill(0); + tempArr[roll.type === 'fate' ? roll.roll + 1 : roll.roll - 1]++; + rollDistMap.set(rollDistKey(roll.type, roll.size), tempArr); + }); + + return rollDistMap; +}; + +// Collapses an array of RollDistMaps into a single RollDistMap +export const reduceRollDistMaps = (rollDistArr: RollDistributionMap[]): RollDistributionMap => + rollDistArr.reduce((acc, cur) => { + loopCountCheck(); + + cur + .entries() + .toArray() + .forEach(([key, value]) => { + loopCountCheck(); + + const tempArr = acc.get(key) ?? new Array(value.length).fill(0); + for (let i = 0; i < tempArr.length; i++) { + loopCountCheck(); + tempArr[i] += value[i]; + } + + acc.set(key, tempArr); + }); + return acc; + }, new Map()); diff --git a/src/commands/auditCmd/auditGuilds.ts b/src/commands/auditCmd/auditGuilds.ts index bbb3a6f..6208717 100644 --- a/src/commands/auditCmd/auditGuilds.ts +++ b/src/commands/auditCmd/auditGuilds.ts @@ -140,7 +140,7 @@ Please see attached file for audit details on cached guilds and members.`, }, ], file: { - blob: b.size > 8388290 ? tooBig : b, + blob: b.size > config.maxFileSize ? tooBig : b, name: 'auditDetails.txt', }, }) diff --git a/src/endpoints/gets/apiRoll.ts b/src/endpoints/gets/apiRoll.ts index 1066eff..3b90592 100644 --- a/src/endpoints/gets/apiRoll.ts +++ b/src/endpoints/gets/apiRoll.ts @@ -95,6 +95,7 @@ export const apiRoll = async (query: Map, apiUserid: bigint): Pr count: query.has('c'), commaTotals: query.has('ct'), confirmCrit: query.has('cc'), + rollDist: query.has('rd'), apiWarn: hideWarn ? '' : apiWarning, valid: true, error: new Error(),