Add simulated nominal flag

This commit is contained in:
Ean Milligan 2025-06-21 21:08:53 -04:00
parent 8793011350
commit 9d6b389d71
8 changed files with 165 additions and 73 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]&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]
}

View File

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

View File

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

View File

@ -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,6 +93,7 @@ export const runCmd = (rollRequest: QueuedRoll): SolvedRoll => {
let preFormat = '';
let postFormat = '';
if (!rollRequest.modifiers.simulatedNominal) {
// If the roll contained a crit success or fail, set the formatting around it
if (e.containsCrit) {
preFormat = `**${preFormat}`;
@ -95,6 +103,7 @@ export const runCmd = (rollRequest: QueuedRoll): SolvedRoll => {
preFormat = `__${preFormat}`;
postFormat = `${postFormat}__`;
}
}
// Populate line2 (the results) and line3 (the details) with their data
if (rollRequest.modifiers.order === '') {
@ -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`;
});

View File

@ -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,13 +38,29 @@ 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;
loggingEnabled &&
log(
LT.LOG,
`Setting previous results: topLevel:${topLevel} ${
topLevel ? returnData.map((rd) => rd.rollTotal) : previousResults
} simulatedLoopCount:${simulatedLoopCount}`
);
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)}`);
if (topLevel) {
// 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('');
@ -51,13 +68,9 @@ export const tokenizeCmd = (
// 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}`);
}
// Store results
returnData.push(data);
modifiers.simulatedNominal ? simulatedData.push(data) : returnData.push(data);
countDetails.push(...tempCounts);
rollDists.push(...tempDists);
@ -73,12 +86,13 @@ export const tokenizeCmd = (
currentCmd,
modifiers,
false,
topLevel ? returnData.map((rd) => rd.rollTotal) : previousResults,
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)}`);
loggingEnabled &&
log(LT.LOG, `ConfirmCrit on ${JSON.stringify(currentCmd)} | Rolled again ${JSON.stringify(ccData)} ${JSON.stringify(ccTempCounts)}`);
// Store CC results
returnData.push(ccData);
@ -90,6 +104,27 @@ export const tokenizeCmd = (
}
}
// 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)}`);
}
// 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}`);
}
}
if (topLevel) {
if (cmd.length) {
loggingEnabled && log(LT.LOG, `Adding leftover formatting to last returnData ${JSON.stringify(cmd)}`);

View File

@ -46,6 +46,7 @@ export interface RollModifiers {
maxRoll: boolean;
minRoll: boolean;
nominalRoll: boolean;
simulatedNominal: number;
gmRoll: boolean;
gms: string[];
order: string;

View File

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

View File

@ -81,6 +81,8 @@ export const apiRoll = async (query: Map<string, string>, 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<string, string>, 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<string, string>, 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<Response>((resolve) => {
sendRollRequest({
apiRoll: true,
@ -120,7 +138,7 @@ export const apiRoll = async (query: Map<string, string>, 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 {