From 41214bd0d3cc44d71127918938e489a5625d23b7 Mon Sep 17 00:00:00 2001 From: Ean Milligan Date: Mon, 7 Jul 2025 04:33:23 -0400 Subject: [PATCH] Add support for modifiers on multi-mode --- src/artigen/artigen.d.ts | 1 + src/artigen/cmdTokenizer.ts | 13 +- src/artigen/dice/dice.d.ts | 26 ++-- src/artigen/dice/getGroupConf.ts | 36 +++++ src/artigen/dice/groupHandler.ts | 169 +++++++++++++++++++++--- src/artigen/math/mathTokenizer.ts | 5 +- src/artigen/utils/escape.ts | 3 + src/artigen/utils/groupResultFlagger.ts | 13 ++ src/artigen/utils/sortFuncs.ts | 8 +- 9 files changed, 242 insertions(+), 32 deletions(-) create mode 100644 src/artigen/dice/getGroupConf.ts create mode 100644 src/artigen/utils/groupResultFlagger.ts diff --git a/src/artigen/artigen.d.ts b/src/artigen/artigen.d.ts index bef6dd0..4a77b2d 100644 --- a/src/artigen/artigen.d.ts +++ b/src/artigen/artigen.d.ts @@ -4,6 +4,7 @@ import { CountDetails, RollDistributionMap } from 'artigen/dice/dice.d.ts'; // ReturnData is the temporary internal type used before getting turned into SolvedRoll export interface ReturnData { + origIdx?: number; rollTotal: number; rollPreFormat: string; rollPostFormat: string; diff --git a/src/artigen/cmdTokenizer.ts b/src/artigen/cmdTokenizer.ts index 2325bf1..6cd97d3 100644 --- a/src/artigen/cmdTokenizer.ts +++ b/src/artigen/cmdTokenizer.ts @@ -12,7 +12,7 @@ import { loopCountCheck } from 'artigen/managers/loopManager.ts'; import { tokenizeMath } from 'artigen/math/mathTokenizer.ts'; import { reduceCountDetails } from 'artigen/utils/counter.ts'; -import { closeInternal, closeInternalGrp, internalGrpWrapRegex, internalWrapRegex, openInternal, openInternalGrp } from 'artigen/utils/escape.ts'; +import { closeInternal, closeInternalGrp, internalGrpWrapRegex, internalWrapRegex, mathSplitRegex, openInternal, openInternalGrp } from 'artigen/utils/escape.ts'; import { loggingEnabled } from 'artigen/utils/logFlag.ts'; import { assertGroupBalance, getMatchingGroupIdx, getMatchingInternalGrpIdx, getMatchingInternalIdx, getMatchingPostfixIdx } from 'artigen/utils/parenBalance.ts'; import { basicReducer } from 'artigen/utils/reducers.ts'; @@ -149,7 +149,16 @@ export const tokenizeCmd = ( const currentGrp = groupParts.slice(openIdx + 1, closeIdx); - const [tempData, tempCounts, tempDists] = handleGroup(currentGrp, '', modifiers, previousResults); + // Try to find and "eat" any modifiers from the next groupPart + let thisGrpMods = ''; + const possibleMods = groupParts[closeIdx + 1]?.trim() ?? ''; + if (possibleMods.match(/^[dk<>=f].*/g)) { + const items = groupParts[closeIdx + 1].split(mathSplitRegex).filter((x) => x); + thisGrpMods = items.shift() ?? ''; + groupParts[closeIdx + 1] = items.join(''); + } + + const [tempData, tempCounts, tempDists] = handleGroup(currentGrp, thisGrpMods, modifiers, previousResults); const data = tempData[0]; log(LT.LOG, `Solved Group is back ${JSON.stringify(data)} | ${JSON.stringify(returnData)} ${JSON.stringify(tempCounts)} ${JSON.stringify(tempDists)}`); diff --git a/src/artigen/dice/dice.d.ts b/src/artigen/dice/dice.d.ts index db86539..81b358a 100644 --- a/src/artigen/dice/dice.d.ts +++ b/src/artigen/dice/dice.d.ts @@ -91,16 +91,22 @@ export interface DPercentConf { critVal: number; } -// RollConf is used by the roll20 setup -export interface RollConf { - type: RollType; - dieCount: number; - dieSize: number; - dPercent: DPercentConf; +// GroupConf carries the machine readable group configuration the user specified +export interface GroupConf { drop: CountConf; keep: CountConf; dropHigh: CountConf; keepLow: CountConf; + success: RangeConf; + fail: RangeConf; +} + +// RollConf carries the machine readable roll configuration the user specified +export interface RollConf extends GroupConf { + type: RollType; + dieCount: number; + dieSize: number; + dPercent: DPercentConf; reroll: { on: boolean; once: boolean; @@ -121,8 +127,6 @@ export interface RollConf { returnTotal: boolean; }; sort: SortDisabled | SortEnabled; - success: RangeConf; - fail: RangeConf; } export interface SumOverride { @@ -136,3 +140,9 @@ export interface ExecutedRoll { countFailOverride: boolean; sumOverride: SumOverride; } + +export interface GroupResultFlags { + dropped: boolean; + success: boolean; + failed: boolean; +} diff --git a/src/artigen/dice/getGroupConf.ts b/src/artigen/dice/getGroupConf.ts new file mode 100644 index 0000000..2292334 --- /dev/null +++ b/src/artigen/dice/getGroupConf.ts @@ -0,0 +1,36 @@ +import { log, LogTypes as LT } from '@Log4Deno'; + +import { GroupConf } from 'artigen/dice/dice.d.ts'; +import { getRollConf } from 'artigen/dice/getRollConf.ts'; + +import { loopCountCheck } from 'artigen/managers/loopManager.ts'; + +import { loggingEnabled } from 'artigen/utils/logFlag.ts'; + +// Wrapper to abuse getRollConf, produces a GroupConf by making the groupStr into a rollStr by adding a 1d1 onto it +export const getGroupConf = (groupStr: string, rawStr: string): GroupConf => { + const numberMatches = rawStr.match(/\d+/g) ?? ['1']; + + let biggest = parseInt(numberMatches.length ? numberMatches[0] : '1'); + for (const num of numberMatches) { + loopCountCheck(); + + const curNum = parseInt(num); + loggingEnabled && log(LT.LOG, `Finding biggest number to use as die size, ${curNum} ${biggest}`); + if (curNum > biggest) { + biggest = curNum; + } + } + + loggingEnabled && log(LT.LOG, `Abusing getRollConf with "1d${biggest} ${groupStr}"`); + const fakeRollConf = getRollConf(`1d${biggest}${groupStr}`); + loggingEnabled && log(LT.LOG, `Abused rollConf back for ${groupStr}: ${JSON.stringify(fakeRollConf)}`); + return { + drop: fakeRollConf.drop, + keep: fakeRollConf.keep, + dropHigh: fakeRollConf.dropHigh, + keepLow: fakeRollConf.keepLow, + success: fakeRollConf.success, + fail: fakeRollConf.fail, + }; +}; diff --git a/src/artigen/dice/groupHandler.ts b/src/artigen/dice/groupHandler.ts index 093a1f5..f8b4480 100644 --- a/src/artigen/dice/groupHandler.ts +++ b/src/artigen/dice/groupHandler.ts @@ -2,15 +2,18 @@ import { log, LogTypes as LT } from '@Log4Deno'; import { ReturnData } from 'artigen/artigen.d.ts'; -import { CountDetails, RollDistributionMap, RollModifiers } from 'artigen/dice/dice.d.ts'; +import { CountDetails, GroupConf, GroupResultFlags, RollDistributionMap, RollModifiers } from 'artigen/dice/dice.d.ts'; import { loopCountCheck } from 'artigen/managers/loopManager.ts'; import { tokenizeMath } from 'artigen/math/mathTokenizer.ts'; -import { closeInternalGrp, openInternalGrp } from 'artigen/utils/escape.ts'; +import { closeInternalGrp, internalGrpWrapRegex, mathSplitRegex, openInternalGrp } from 'artigen/utils/escape.ts'; import { loggingEnabled } from 'artigen/utils/logFlag.ts'; -import { getMatchingGroupIdx } from 'artigen/utils/parenBalance.ts'; +import { getMatchingGroupIdx, getMatchingInternalGrpIdx } from 'artigen/utils/parenBalance.ts'; +import { getGroupConf } from 'artigen/dice/getGroupConf.ts'; +import { compareOrigIdx, compareTotalRolls } from 'artigen/utils/sortFuncs.ts'; +import { applyFlags } from '../utils/groupResultFlagger.ts'; export const handleGroup = ( groupParts: string[], @@ -18,9 +21,12 @@ export const handleGroup = ( modifiers: RollModifiers, previousResults: number[], ): [ReturnData[], CountDetails[], RollDistributionMap[]] => { + let retData: ReturnData; const returnData: ReturnData[] = []; const countDetails: CountDetails[] = []; const rollDists: RollDistributionMap[] = []; + const groupConf: GroupConf = getGroupConf(groupModifiers, groupParts.join('')); + const prevGrpReturnData: ReturnData[] = []; // Nested groups still exist, unwrap them while (groupParts.includes('{')) { @@ -28,12 +34,21 @@ export const handleGroup = ( loggingEnabled && log(LT.LOG, `Handling Nested Groups | Current cmd: ${JSON.stringify(groupParts)}`); - const openIdx = groupParts.indexOf('}'); + const openIdx = groupParts.indexOf('{'); const closeIdx = getMatchingGroupIdx(groupParts, openIdx); const currentGrp = groupParts.slice(openIdx + 1, closeIdx); - const [tempData, tempCounts, tempDists] = handleGroup(currentGrp, '', modifiers, previousResults); + // Try to find and "eat" any modifiers from the next groupPart + let thisGrpMods = ''; + const possibleMods = groupParts[closeIdx + 1]?.trim() ?? ''; + if (possibleMods.match(/^[dk<>=f].*/g)) { + const items = groupParts[closeIdx + 1].split(mathSplitRegex).filter((x) => x); + thisGrpMods = items.shift() ?? ''; + groupParts[closeIdx + 1] = items.join(''); + } + + const [tempData, tempCounts, tempDists] = handleGroup(currentGrp, thisGrpMods, modifiers, previousResults); const data = tempData[0]; loggingEnabled && log(LT.LOG, `Solved Nested Group is back ${JSON.stringify(data)} | ${JSON.stringify(tempCounts)} ${JSON.stringify(tempDists)}`); @@ -41,7 +56,8 @@ export const handleGroup = ( rollDists.push(...tempDists); // Merge result back into groupParts - groupParts.splice(openIdx, closeIdx - openIdx + 1, `${openInternalGrp}${data.rollTotal}${closeInternalGrp}`); + groupParts.splice(openIdx, closeIdx - openIdx + 1, `${openInternalGrp}${prevGrpReturnData.length}${closeInternalGrp}`); + prevGrpReturnData.push(data); } // Handle the items in the groups @@ -51,7 +67,7 @@ export const handleGroup = ( .filter((x) => x); if (commaParts.length > 1) { - loggingEnabled && log(LT.LOG, `In multi-mode ${JSON.stringify(commaParts)} ${groupModifiers}`); + loggingEnabled && log(LT.LOG, `In multi-mode ${JSON.stringify(commaParts)} ${groupModifiers} ${JSON.stringify(groupConf)}`); // Handle "normal operation" of group const groupResults: ReturnData[] = []; @@ -59,7 +75,7 @@ export const handleGroup = ( loopCountCheck(); loggingEnabled && log(LT.LOG, `Solving commaPart: ${part}`); - const [tempData, tempCounts, tempDists] = tokenizeMath(part, modifiers, previousResults, []); + const [tempData, tempCounts, tempDists] = tokenizeMath(part, modifiers, previousResults, prevGrpReturnData); const data = tempData[0]; loggingEnabled && log(LT.LOG, `Solved Math for Group is back ${JSON.stringify(data)} | ${JSON.stringify(tempCounts)} ${JSON.stringify(tempDists)}`); @@ -69,29 +85,133 @@ export const handleGroup = ( groupResults.push(data); } - if (groupModifiers) { + if (groupModifiers.trim()) { // Handle the provided modifiers + const getTemplateFlags = (): GroupResultFlags => ({ dropped: false, success: false, failed: false }); + + // Assign original indexes + const resultFlags: GroupResultFlags[] = []; + groupResults.forEach((rd, idx) => { + rd.origIdx = idx; + resultFlags.push(getTemplateFlags()); + }); + + // Handle drop/keep options + if (groupConf.drop.on || groupConf.keep.on || groupConf.dropHigh.on || groupConf.keepLow.on) { + groupResults.sort(compareTotalRolls); + let dropCount = 0; + + // For normal drop and keep, simple subtraction is enough to determine how many to drop + // Protections are in to prevent the dropCount from going below 0 or more than the valid rolls to drop + if (groupConf.drop.on) { + dropCount = groupConf.drop.count; + if (dropCount > groupResults.length) { + dropCount = groupResults.length; + } + } else if (groupConf.keep.on) { + dropCount = groupResults.length - groupConf.keep.count; + if (dropCount < 0) { + dropCount = 0; + } + } // For inverted drop and keep, order must be flipped to greatest to least before the simple subtraction can determine how many to drop + // Protections are in to prevent the dropCount from going below 0 or more than the valid rolls to drop + else if (groupConf.dropHigh.on) { + groupResults.reverse(); + dropCount = groupConf.dropHigh.count; + if (dropCount > groupResults.length) { + dropCount = groupResults.length; + } + } else if (groupConf.keepLow.on) { + groupResults.reverse(); + dropCount = groupResults.length - groupConf.keepLow.count; + if (dropCount < 0) { + dropCount = 0; + } + } + + let i = 0; + while (dropCount > 0 && i < groupResults.length) { + loopCountCheck(); + + loggingEnabled && log(LT.LOG, `Handling group dropping | Dropping ${dropCount}`); + + resultFlags[groupResults[i].origIdx ?? -1].dropped = true; + + dropCount--; + i++; + } + + groupResults.sort(compareOrigIdx); + } + + let successCnt = 0; + let failCnt = 0; + if (groupConf.success.on || groupConf.fail.on) { + groupResults.forEach((rd, idx) => { + loopCountCheck(); + + if (!resultFlags[idx].dropped) { + if (groupConf.success.on && groupConf.success.range.includes(rd.rollTotal)) { + successCnt++; + resultFlags[idx].success = true; + } + if (groupConf.fail.on && groupConf.fail.range.includes(rd.rollTotal)) { + failCnt++; + resultFlags[idx].failed = true; + } + } + }); + } + + loggingEnabled && log(LT.LOG, `Applying group flags: ${JSON.stringify(resultFlags)}`); + const data = groupResults.reduce((prev, cur, idx) => ({ + rollTotal: resultFlags[idx].dropped ? prev.rollTotal : prev.rollTotal + cur.rollTotal, + rollPreFormat: '', + rollPostFormat: '', + rollDetails: `${prev.rollDetails}, ${applyFlags(cur.rollDetails, resultFlags[idx])}`, + containsCrit: resultFlags[idx].dropped ? prev.containsCrit : prev.containsCrit || cur.containsCrit, + containsFail: resultFlags[idx].dropped ? prev.containsFail : prev.containsFail || cur.containsFail, + initConfig: `${prev.initConfig}, ${cur.initConfig}`, + isComplex: prev.isComplex || cur.isComplex, + })); + data.initConfig = `{${data.initConfig}}`; + + if (groupConf.success.on || groupConf.fail.on) { + data.rollTotal = 0; + } + if (groupConf.success.on) { + data.rollTotal += successCnt; + data.rollDetails += `, ${successCnt} Success${successCnt !== 1 ? 'es' : ''}`; + } + if (groupConf.fail.on) { + data.rollTotal -= failCnt; + data.rollDetails += `, ${failCnt} Fail${failCnt !== 1 ? 's' : ''}`; + } + + data.rollDetails = `{${data.rollDetails}}`; + retData = data; } else { // Sum mode const data = groupResults.reduce((prev, cur) => ({ rollTotal: prev.rollTotal + cur.rollTotal, rollPreFormat: '', rollPostFormat: '', - rollDetails: prev.rollDetails + ' + ' + cur.rollDetails, + rollDetails: `${prev.rollDetails} + ${cur.rollDetails}`, containsCrit: prev.containsCrit || cur.containsCrit, containsFail: prev.containsFail || cur.containsFail, - initConfig: prev.initConfig + ', ' + cur.initConfig, + initConfig: `${prev.initConfig}, ${cur.initConfig}`, isComplex: prev.isComplex || cur.isComplex, })); data.initConfig = `{${data.initConfig}}`; data.rollDetails = `{${data.rollDetails}}`; - returnData.push(data); + retData = data; } } else { - loggingEnabled && log(LT.LOG, `In single-mode ${JSON.stringify(commaParts)} ${groupModifiers}`); - if (groupModifiers) { + loggingEnabled && log(LT.LOG, `In single-mode ${JSON.stringify(commaParts)} ${groupModifiers} ${JSON.stringify(groupConf)}`); + if (groupModifiers.trim()) { // Handle special case where the group modifiers are applied across the dice rolled // ex from roll20 docs: {4d6+3d8}k4 - Roll 4 d6's and 3 d8's, out of those 7 dice the highest 4 are kept and summed up. + retData = {}; } else { // why did you put this in a group, that was entirely pointless loggingEnabled && log(LT.LOG, `Solving commaPart: ${commaParts[0]}`); @@ -104,9 +224,28 @@ export const handleGroup = ( rollDists.push(...tempDists); data.initConfig = `{${data.initConfig}}`; data.rollDetails = `{${data.rollDetails}}`; - returnData.push(data); + retData = data; } } + // Handle merging back any nested groups to prevent an internalGrp marker from sneaking out + const initConf = retData.initConfig.split(internalGrpWrapRegex).filter((x) => x); + loggingEnabled && log(LT.LOG, `Split retData into initConf ${JSON.stringify(initConf)}`); + while (initConf.includes(openInternalGrp)) { + loopCountCheck(); + + const openIdx = initConf.indexOf(openInternalGrp); + const closeIdx = getMatchingInternalGrpIdx(initConf, openIdx); + + // Take first groupResult out of array + const dataToMerge = prevGrpReturnData.shift(); + + // Replace the found pair with the nested initConfig and result + initConf.splice(openIdx, closeIdx - openIdx + 1, `${dataToMerge?.initConfig}`); + loggingEnabled && log(LT.LOG, `Current initConf state ${JSON.stringify(initConf)}`); + } + + retData.initConfig = initConf.join(''); + returnData.push(retData); return [returnData, countDetails, rollDists]; }; diff --git a/src/artigen/math/mathTokenizer.ts b/src/artigen/math/mathTokenizer.ts index 1bfc360..f616431 100644 --- a/src/artigen/math/mathTokenizer.ts +++ b/src/artigen/math/mathTokenizer.ts @@ -11,7 +11,7 @@ import { loopCountCheck } from 'artigen/managers/loopManager.ts'; import { mathSolver } from 'artigen/math/mathSolver.ts'; -import { closeInternalGrp, cmdSplitRegex, internalWrapRegex, openInternalGrp } from 'artigen/utils/escape.ts'; +import { closeInternalGrp, cmdSplitRegex, internalWrapRegex, mathSplitRegex, openInternalGrp } from 'artigen/utils/escape.ts'; import { legalMathOperators } from 'artigen/utils/legalMath.ts'; import { loggingEnabled } from 'artigen/utils/logFlag.ts'; import { assertParenBalance } from 'artigen/utils/parenBalance.ts'; @@ -36,8 +36,7 @@ export const tokenizeMath = ( .replace(cmdSplitRegex, '') .replace(internalWrapRegex, '') .replace(/ /g, '') - // breaks the string on the following: (\*\*) ** for exponents ([+()*/^] for basic algebra (?])- for breaking on - correctly with fate dice) (x\d+(\.\d*)?) x# for variables - .split(/(\*\*)|([+()*/^]|(?])-)|(x\d+(\.\d*)?)/g) + .split(mathSplitRegex) .filter((x) => x); loggingEnabled && log(LT.LOG, `Split roll into mathConf ${JSON.stringify(mathConf)}`); diff --git a/src/artigen/utils/escape.ts b/src/artigen/utils/escape.ts index 873f7ca..3eec2b9 100644 --- a/src/artigen/utils/escape.ts +++ b/src/artigen/utils/escape.ts @@ -26,6 +26,9 @@ export const escapeCharacters = (str: string, esc: string): string => { const escapePrefixPostfix = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); export const cmdSplitRegex = new RegExp(`(${escapePrefixPostfix(config.prefix)})|(${escapePrefixPostfix(config.postfix)})`, 'g'); +// breaks the string on the following: (\*\*) ** for exponents ([+()*/^] for basic algebra (?])- for breaking on - correctly with fate dice) (x\d+(\.\d*)?) x# for variables +export const mathSplitRegex = /(\*\*)|([+()*/^]|(?])-)|(x\d+(\.\d*)?)/g; + // Internal is used for recursive text replacement, these will always be the top level as they get replaced with config.prefix/postfix when exiting each level export const openInternal = '\u2045'; export const closeInternal = '\u2046'; diff --git a/src/artigen/utils/groupResultFlagger.ts b/src/artigen/utils/groupResultFlagger.ts new file mode 100644 index 0000000..1370bab --- /dev/null +++ b/src/artigen/utils/groupResultFlagger.ts @@ -0,0 +1,13 @@ +import { GroupResultFlags } from 'artigen/dice/dice.d.ts'; + +export const applyFlags = (rollDetails: string, flags: GroupResultFlags): string => { + if (flags.dropped) { + return `~~${rollDetails.replaceAll('~', '')}~~`; + } else if (flags.success) { + return `S:${rollDetails}`; + } else if (flags.failed) { + return `F:${rollDetails}`; + } else { + return rollDetails; + } +}; diff --git a/src/artigen/utils/sortFuncs.ts b/src/artigen/utils/sortFuncs.ts index f4e45ea..0e10516 100644 --- a/src/artigen/utils/sortFuncs.ts +++ b/src/artigen/utils/sortFuncs.ts @@ -39,12 +39,12 @@ export const compareTotalRolls = (a: ReturnData, b: ReturnData): number => inter export const compareTotalRollsReverse = (a: ReturnData, b: ReturnData): number => internalCompareTotalRolls(a, b, -1); // compareRolls(a, b) returns -1|0|1 -// compareRolls is used to order an array of RollSets by RollSet.origIdx -export const compareOrigIdx = (a: RollSet, b: RollSet): number => { - if (a.origIdx < b.origIdx) { +// compareRolls is used to order an array of RollSet or ReturnData by X.origIdx +export const compareOrigIdx = (a: RollSet | ReturnData, b: RollSet | ReturnData): number => { + if ((a.origIdx ?? 0) < (b.origIdx ?? 0)) { return -1; } - if (a.origIdx > b.origIdx) { + if ((a.origIdx ?? 0) > (b.origIdx ?? 0)) { return 1; } return 0;