diff --git a/.bruno/Authenticated/Roll Requests/Roll Dice.bru b/.bruno/Authenticated/Roll Requests/Roll Dice.bru index a9fb0c3..d1d58d0 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]&rd=[roll-dist-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, sn, or max]&n=[nominal-roll-flag, cannot be used with sn, max or min flag]&sn=[simulated-nominal-flag, cannot be used with max, min, n. or cc]&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, cannot be used with sn]&rd=[roll-dist-flag] body: none auth: inherit } @@ -20,11 +20,12 @@ params:query { 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] + min: [min-roll-flag, cannot be used with n, sn, or max] + n: [nominal-roll-flag, cannot be used with sn, max or min flag] + sn: [simulated-nominal-flag, can pass number with it, cannot be used with max, min, n. or cc] 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] + cc: [confirm-crit-flag, cannot be used with sn] rd: [roll-dist-flag] } diff --git a/README.md b/README.md index 81fa6bf..ed9f1b5 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,14 @@ The Artificer comes with a few supplemental commands to the main rolling command * `-nd` - No Details - Suppresses all details of the requested roll * `-snd` - Super No Details - Suppresses all details of the requested roll and hides no details message * `-s` - Spoiler - Spoilers all details of the requested roll - * `-m` or `-max` - Maximize Roll - Rolls the theoretical maximum roll, cannot be used with `-n` or `-min` - * `-min` - Minimize Roll - Rolls the theoretical minimum roll, cannot be used with `-m`, `-max`, or `-n` - * `-n` - Nominal Roll - Rolls the theoretical nominal roll, cannot be used with `-m`, `-max`, or `-min` + * `-m` or `-max` - Maximize Roll - Rolls the theoretical maximum roll, cannot be used with `-n`, `-min`, or `-sn` + * `-min` - Minimize Roll - Rolls the theoretical minimum roll, cannot be used with `-m`, `-max`, `-n`, or `-sn` + * `-n` - Nominal Roll - Rolls the theoretical nominal roll, cannot be used with `-m`, `-max`, `-min`, or `-sn` + * `-sn` or `-sn [number]` - Simulated Nominal - Rolls the requests roll many times to approximately simulate the nominal of complex rolls, can specify the amount or accept default amount by not specify the amount, cannot be used with `-m`, `-max`, `-min`, `-n`, or `-cc` * `-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 + * `-cc` - Confirm Critical Hits - Automatically rerolls whenever a crit hits, cannot be used with `-sn` - `-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** diff --git a/config.example.ts b/config.example.ts index 72a85e9..3bc34af 100644 --- a/config.example.ts +++ b/config.example.ts @@ -11,6 +11,7 @@ export const config = { maxLoops: 1000000, // Determines how long the bot will attempt a roll, number of loops before it kills a roll. Increase this at your own risk. maxWorkers: 16, // Maximum number of worker threads to spawn at once (Set this to less than the number of threads your CPU has, Artificer will eat it all if too many rolls happen at once) workerTimeout: 300000, // Maximum time before the bot kills a worker thread in ms + simulatedNominal: 100000, // Number of loops to run for simulating a nominal }, api: { // Setting for the built-in API diff --git a/src/artigen/artigen.ts b/src/artigen/artigen.ts index d26d0f5..fa83b8c 100644 --- a/src/artigen/artigen.ts +++ b/src/artigen/artigen.ts @@ -61,13 +61,18 @@ export const runCmd = (rollRequest: QueuedRoll): SolvedRoll => { line2 = resultStr; // If a theoretical roll is requested, mark the output as such, else use default formatting - if (rollRequest.modifiers.maxRoll || rollRequest.modifiers.minRoll || rollRequest.modifiers.nominalRoll) { - const theoreticalTexts = ['Maximum', 'Minimum', 'Nominal']; - const theoreticalBools = [rollRequest.modifiers.maxRoll, rollRequest.modifiers.minRoll, rollRequest.modifiers.nominalRoll]; + const theoreticalBools = [ + rollRequest.modifiers.maxRoll, + rollRequest.modifiers.minRoll, + rollRequest.modifiers.nominalRoll, + rollRequest.modifiers.simulatedNominal > 0, + ]; + if (theoreticalBools.includes(true)) { + const theoreticalTexts = ['Theoretical Maximum', 'Theoretical Minimum', 'Theoretical Nominal', 'Simulated Nominal']; const theoreticalText = theoreticalTexts[theoreticalBools.indexOf(true)]; - line1 = ` requested the Theoretical ${theoreticalText} of:\n\`${rawCmd}\``; - line2 = `Theoretical ${theoreticalText} ${resultStr}`; + line1 = ` requested the ${theoreticalText.toLowerCase()} of:\n\`${rawCmd}\``; + line2 = `${theoreticalText} ${resultStr}`; } else if (rollRequest.modifiers.order === 'a') { line1 = ` requested the following rolls to be ordered from least to greatest:\n\`${rawCmd}\``; tempReturnData.sort(compareTotalRolls); @@ -78,6 +83,8 @@ export const runCmd = (rollRequest: QueuedRoll): SolvedRoll => { line1 = ` rolled:\n\`${rawCmd}\``; } + if (rollRequest.modifiers.simulatedNominal) line2 += `Iterations performed per roll: \`${rollRequest.modifiers.simulatedNominal}\`\n`; + // Fill out all of the details and results now tempReturnData.forEach((e) => { loopCountCheck(); @@ -86,14 +93,16 @@ export const runCmd = (rollRequest: QueuedRoll): SolvedRoll => { let preFormat = ''; let postFormat = ''; - // If the roll contained a crit success or fail, set the formatting around it - if (e.containsCrit) { - preFormat = `**${preFormat}`; - postFormat = `${postFormat}**`; - } - if (e.containsFail) { - preFormat = `__${preFormat}`; - postFormat = `${postFormat}__`; + if (!rollRequest.modifiers.simulatedNominal) { + // If the roll contained a crit success or fail, set the formatting around it + if (e.containsCrit) { + preFormat = `**${preFormat}`; + postFormat = `${postFormat}**`; + } + if (e.containsFail) { + preFormat = `__${preFormat}`; + postFormat = `${postFormat}__`; + } } // Populate line2 (the results) and line3 (the details) with their data @@ -106,7 +115,7 @@ export const runCmd = (rollRequest: QueuedRoll): SolvedRoll => { line2 += `${preFormat}${rollRequest.modifiers.commaTotals ? e.rollTotal.toLocaleString() : e.rollTotal}${postFormat}, `; } - const rollDetails = rollRequest.modifiers.noDetails ? ' = ' : ` = ${e.rollDetails} = `; + const rollDetails = rollRequest.modifiers.noDetails || rollRequest.modifiers.simulatedNominal > 0 ? ' = ' : ` = ${e.rollDetails} = `; line3 += `\`${e.initConfig}\`${rollDetails}${preFormat}${rollRequest.modifiers.commaTotals ? e.rollTotal.toLocaleString() : e.rollTotal}${postFormat}\n`; }); diff --git a/src/artigen/cmdTokenizer.ts b/src/artigen/cmdTokenizer.ts index abbaf94..4498a5a 100644 --- a/src/artigen/cmdTokenizer.ts +++ b/src/artigen/cmdTokenizer.ts @@ -14,13 +14,14 @@ 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 { basicReducer } from 'artigen/utils/reducers.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[] = [], + previousResults: number[] = [] ): [ReturnData[], CountDetails[], RollDistributionMap[]] => { loggingEnabled && log(LT.LOG, `Tokenizing command ${JSON.stringify(cmd)}`); @@ -37,56 +38,90 @@ export const tokenizeCmd = ( const currentCmd = cmd.slice(openIdx + 1, closeIdx); - loggingEnabled && log(LT.LOG, `Setting previous results: topLevel:${topLevel} ${topLevel ? returnData.map((rd) => rd.rollTotal) : previousResults}`); + const simulatedLoopCount = modifiers.simulatedNominal || 1; - // Handle any nested commands - const [tempData, tempCounts, tempDists] = tokenizeCmd(currentCmd, modifiers, false, topLevel ? returnData.map((rd) => rd.rollTotal) : previousResults); - const data = tempData[0]; + loggingEnabled && + log( + LT.LOG, + `Setting previous results: topLevel:${topLevel} ${ + topLevel ? returnData.map((rd) => rd.rollTotal) : previousResults + } simulatedLoopCount:${simulatedLoopCount}` + ); - if (topLevel) { - // Handle saving any formatting between dice - if (openIdx !== 0) { - data.rollPreFormat = cmd.slice(0, openIdx).join(''); + const simulatedData: ReturnData[] = []; + for (let i = 0; i < simulatedLoopCount; i++) { + loopCountCheck(); + + loggingEnabled && log(LT.LOG, `In simLoop:${i} "${currentCmd}" of ${JSON.stringify(cmd)}`); + + // Handle any nested commands + const [tempData, tempCounts, tempDists] = tokenizeCmd(currentCmd, modifiers, false, topLevel ? returnData.map((rd) => rd.rollTotal) : previousResults); + const data = tempData[0]; + loggingEnabled && log(LT.LOG, `Data back from tokenizeCmd, "${currentCmd}" of "${JSON.stringify(cmd)}" ${JSON.stringify(data)}`); + + // Only run this on first loop + if (topLevel && i === 0) { + // Handle saving any formatting between dice + if (openIdx !== 0) { + data.rollPreFormat = cmd.slice(0, openIdx).join(''); + } + + // Chop off all formatting between cmds along with the processed cmd + cmd.splice(0, closeIdx + 1); } + // Store results + modifiers.simulatedNominal ? simulatedData.push(data) : returnData.push(data); + countDetails.push(...tempCounts); + rollDists.push(...tempDists); - // Chop off all formatting between cmds along with the processed cmd - cmd.splice(0, closeIdx + 1); - } else { - // We're handling something nested, replace [[cmd]] with the cmd's result - cmd.splice(openIdx, closeIdx - openIdx + 1, `${openInternal}${data.rollTotal}${closeInternal}`); + // 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(); + + // 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: '; + + loggingEnabled && + log(LT.LOG, `ConfirmCrit on ${JSON.stringify(currentCmd)} | Rolled again ${JSON.stringify(ccData)} ${JSON.stringify(ccTempCounts)}`); + + // Store CC results + returnData.push(ccData); + countDetails.push(...ccTempCounts); + rollDists.push(...ccTempDists); + + done = reduceCountDetails(ccTempCounts).successful === 0; + } + } } - // Store results - returnData.push(data); - countDetails.push(...tempCounts); - rollDists.push(...tempDists); + // Turn the simulated return data into a single usable payload + if (modifiers.simulatedNominal) { + loggingEnabled && log(LT.LOG, `SN on, condensing array into single item ${JSON.stringify(simulatedData)}`); + returnData.push({ + rollTotal: simulatedData.map((data) => data.rollTotal).reduce(basicReducer) / simulatedData.length, + rollPreFormat: simulatedData[0].rollPreFormat, + rollPostFormat: simulatedData[0].rollPostFormat, + rollDetails: simulatedData[0].rollDetails, + containsCrit: simulatedData.some((data) => data.containsCrit), + containsFail: simulatedData.some((data) => data.containsFail), + initConfig: simulatedData[0].initConfig, + }); + loggingEnabled && log(LT.LOG, `SN on, returnData updated ${JSON.stringify(returnData)}`); + } - // 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(); - - // 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: '; - - loggingEnabled && log(LT.LOG, `ConfirmCrit on ${JSON.stringify(currentCmd)} | Rolled again ${JSON.stringify(ccData)} ${JSON.stringify(ccTempCounts)}`); - - // Store CC results - returnData.push(ccData); - countDetails.push(...ccTempCounts); - rollDists.push(...ccTempDists); - - done = reduceCountDetails(ccTempCounts).successful === 0; - } + // Finally, if we are handling a nested [[cmd]], fill in the rollTotal correctly + if (!topLevel) { + cmd.splice(openIdx, closeIdx - openIdx + 1, `${openInternal}${Math.round(returnData[returnData.length - 1].rollTotal)}${closeInternal}`); } } diff --git a/src/artigen/dice/dice.d.ts b/src/artigen/dice/dice.d.ts index 44f263b..3b57b0e 100644 --- a/src/artigen/dice/dice.d.ts +++ b/src/artigen/dice/dice.d.ts @@ -46,6 +46,7 @@ export interface RollModifiers { maxRoll: boolean; minRoll: boolean; nominalRoll: boolean; + simulatedNominal: number; gmRoll: boolean; gms: string[]; order: string; diff --git a/src/artigen/dice/getModifiers.ts b/src/artigen/dice/getModifiers.ts index 488ddca..6c9367a 100644 --- a/src/artigen/dice/getModifiers.ts +++ b/src/artigen/dice/getModifiers.ts @@ -1,6 +1,7 @@ import { log, LogTypes as LT } from '@Log4Deno'; import { RollModifiers } from 'artigen/dice/dice.d.ts'; +import config from '~config'; export const Modifiers = Object.freeze({ Count: '-c', @@ -12,6 +13,7 @@ export const Modifiers = Object.freeze({ MaxShorthand: '-m', Min: '-min', Nominal: '-n', + SimulatedNominal: '-sn', GM: '-gm', Order: '-o', CommaTotals: '-ct', @@ -28,6 +30,7 @@ export const getModifiers = (args: string[]): [RollModifiers, string[]] => { maxRoll: false, minRoll: false, nominalRoll: false, + simulatedNominal: 0, gmRoll: false, gms: [], order: '', @@ -36,7 +39,7 @@ export const getModifiers = (args: string[]): [RollModifiers, string[]] => { confirmCrit: false, rollDist: false, apiWarn: '', - valid: false, + valid: true, error: new Error(), }; @@ -70,6 +73,16 @@ export const getModifiers = (args: string[]): [RollModifiers, string[]] => { case Modifiers.Nominal: modifiers.nominalRoll = true; break; + case Modifiers.SimulatedNominal: + if (args[i + 1] && parseInt(args[i + 1]).toString() === args[i + 1]) { + // Shift the -sn out so the next item is the amount + args.splice(i, 1); + + modifiers.simulatedNominal = parseInt(args[i]); + } else { + modifiers.simulatedNominal = 10000; + } + break; case Modifiers.ConfirmCrit: modifiers.confirmCrit = true; break; @@ -121,13 +134,26 @@ export const getModifiers = (args: string[]): [RollModifiers, string[]] => { } } - // maxRoll, minRoll, and nominalRoll cannot be on at same time, throw an error - if ([modifiers.maxRoll, modifiers.minRoll, modifiers.nominalRoll].filter((b) => b).length > 1) { + // maxRoll, minRoll, nominalRoll, simulatedNominal cannot be on at same time, throw an error + if ([modifiers.maxRoll, modifiers.minRoll, modifiers.nominalRoll, modifiers.simulatedNominal].filter((b) => b).length > 1) { modifiers.error.name = 'MaxAndNominal'; - modifiers.error.message = 'Can only use one of the following at a time:\n`maximize`, `minimize`, `nominal`'; - return [modifiers, args]; + modifiers.error.message = 'Can only use one of the following at a time:\n`maximize`, `minimize`, `nominal`, `simulatedNominal`'; + modifiers.valid = false; + } + + // simulatedNominal and confirmCrit cannot be used at same time, throw an error + if ([modifiers.confirmCrit, modifiers.simulatedNominal].filter((b) => b).length > 1) { + modifiers.error.name = 'SimNominalAndCC'; + modifiers.error.message = 'Cannot use the following at the same time:\n`confirmCrit`, `simulatedNominal`'; + modifiers.valid = false; + } + + // simulatedNominal cannot be greater than config.limits.simulatedNominal + if (modifiers.simulatedNominal > config.limits.simulatedNominal) { + modifiers.error.name = 'SimNominalTooBig'; + modifiers.error.message = `Number of iterations for \`simulatedNominal\` cannot be greater than \`${config.limits.simulatedNominal}\``; + modifiers.valid = false; } - modifiers.valid = true; return [modifiers, args]; }; diff --git a/src/endpoints/gets/apiRoll.ts b/src/endpoints/gets/apiRoll.ts index 3b90592..869a86a 100644 --- a/src/endpoints/gets/apiRoll.ts +++ b/src/endpoints/gets/apiRoll.ts @@ -81,6 +81,8 @@ export const apiRoll = async (query: Map, apiUserid: bigint): Pr // Clip off the leading prefix. API calls must be formatted with a prefix at the start to match how commands are sent in Discord rollCmd = rollCmd.replace(/%20/g, ' ').trim(); + const rawSimNom = parseInt(query.get('sn') ?? '0'); + const simNom = rawSimNom || 10000; const modifiers: RollModifiers = { noDetails: query.has('nd'), superNoDetails: query.has('snd'), @@ -89,6 +91,7 @@ export const apiRoll = async (query: Map, apiUserid: bigint): Pr maxRoll: query.has('m') || query.has('max'), minRoll: query.has('min'), nominalRoll: query.has('n'), + simulatedNominal: query.has('sn') ? simNom : 0, gmRoll: query.has('gms'), gms: query.has('gms') ? (query.get('gms') || '').split(',') : [], order: query.has('o') ? query.get('o')?.toLowerCase() || '' : '', @@ -101,6 +104,21 @@ export const apiRoll = async (query: Map, apiUserid: bigint): Pr error: new Error(), }; + // maxRoll, minRoll, and nominalRoll cannot be on at same time, throw an error + if ([modifiers.maxRoll, modifiers.minRoll, modifiers.nominalRoll, modifiers.simulatedNominal].filter((b) => b).length > 1) { + return stdResp.BadRequest('Can only use one of the following at a time:\n`maximize`, `minimize`, `nominal`, `simulatedNominal`'); + } + + // simulatedNominal and confirmCrit cannot be used at same time, throw an error + if ([modifiers.confirmCrit, modifiers.simulatedNominal].filter((b) => b).length > 1) { + return stdResp.BadRequest('Cannot use the following at the same time:\n`confirmCrit`, `simulatedNominal`'); + } + + // simulatedNominal cannot be greater than config.limits.simulatedNominal + if (modifiers.simulatedNominal > config.limits.simulatedNominal) { + return stdResp.BadRequest(`Number of iterations for \`simulatedNominal\` cannot be greater than \`${config.limits.simulatedNominal}\``); + } + return new Promise((resolve) => { sendRollRequest({ apiRoll: true, @@ -120,7 +138,7 @@ export const apiRoll = async (query: Map, apiUserid: bigint): Pr } else { // Alert API user that they messed up return stdResp.Forbidden( - `Verify you are a member of the guild you are sending this roll to. If you are, the ${config.name} may not have that registered, please send a message in the guild so ${config.name} can register this. This registration is temporary, so if you see this error again, just poke your server again.`, + `Verify you are a member of the guild you are sending this roll to. If you are, the ${config.name} may not have that registered, please send a message in the guild so ${config.name} can register this. This registration is temporary, so if you see this error again, just poke your server again.` ); } } else {