Add Roll Distribution flag

This commit is contained in:
Ean Milligan 2025-06-21 16:18:04 -04:00
parent 308f897eb7
commit babc57497e
19 changed files with 481 additions and 209 deletions

View File

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

View File

@ -14,6 +14,7 @@
"CWOD",
"DEVMODE",
"Discordeno",
"Dists",
"dkdk",
"EMDAS",
"funciton",

View File

@ -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 <ins>underlined</ins>

View File

@ -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"

View File

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

View File

@ -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<string, number[]>(),
};
// 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;

View File

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

View File

@ -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<string, number[]>;
// 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;

View File

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

View File

@ -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<string, number[]>(),
};
};

View File

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

View File

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

View File

@ -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<SolvedRoll>, workerTimeout: number, rollRequest: QueuedRoll) => {
let apiErroredOut = false;
@ -28,24 +32,57 @@ export const onWorkerComplete = async (workerMessage: MessageEvent<SolvedRoll>,
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<SolvedRoll>,
])
.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,
<SolvedRoll> {
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',
},
<RollModifiers> {},
)
).embed,
],
});
}
if (rollRequest.apiRoll && !apiErroredOut) {
rollRequest.api.resolve(stdResp.InternalServerError(JSON.stringify(e)));
}

View File

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

View File

@ -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',
},
};
};

View File

@ -0,0 +1 @@
export const basicReducer = (prev: number, cur: number) => prev + cur;

View File

@ -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<string, number[]>();
rollSet.forEach((roll) => {
loopCountCheck();
const tempArr: number[] = rollDistMap.get(rollDistKey(roll.type, roll.size)) ?? new Array<number>(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<number>(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<string, number[]>());

View File

@ -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',
},
})

View File

@ -95,6 +95,7 @@ export const apiRoll = async (query: Map<string, string>, 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(),