diff --git a/autoButtons/0.5.4/autoButtons.js b/autoButtons/0.5.4/autoButtons.js deleted file mode 100644 index 33d7657a1c..0000000000 --- a/autoButtons/0.5.4/autoButtons.js +++ /dev/null @@ -1,1202 +0,0 @@ -/* globals state log on sendChat playerIsGM */ //eslint-disable-line -const autoButtons = (() => { // eslint-disable-line no-unused-vars - - const scriptName = `autoButtons`, - scriptVersion = `0.5.4`, - debugLevel = 1; - let undoUninstall = null; - -/** - * CORE SCRIPT - */ - const startScript = () => { - - const Services = new ServiceLocator({ name: 'autoButtonServices' }); - - const Config = new ConfigController(scriptName, { - version: scriptVersion, - store: { - customButtons: {} - }, - settings: { - sheet: 'dnd5e_r20', - templates: {}, - enabledButtons: [], - gmOnly: true, - hpBar: 1, - ignoreAPI: true, - overheal: false, - overkill: false, - targetTokens: false, - bump: true, - }, - }); - Services.register({serviceName: 'config', serviceReference: Config }); - - const ButtonStore = new ButtonController({ - name: 'ButtonStore', - defaultButtons: _defaultButtons, - services: [Services.config], - }); - Services.register({ serviceName: 'buttons', serviceReference: ButtonStore }); - - const CLI = new CommandLineInterface({ - name: `autoButtonsMenu`, - options: defaultCliOptions, - }); - Services.register({ serviceName: 'cli', serviceReference: CLI }); - - //////// v0.5.x additions - CLI.addOptions([ - { - name: 'bump', - rx: /^bump/i, - description: `Bump the button UI up to the top of the chat message`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('bump', args, { createPath: true, force: 'boolean' }) } - }, - { - name: 'targetTokens', - rx: /^targett/i, - description: `Use @{target} instead of @{select} for applying damage to tokens`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - const result = this.config.changeSetting('targetTokens', args, { createPath: true, force: 'boolean' }); - if (this.config.getSetting('targetTokens') && result.success && result.msg) result.msg.push(`*Important*: Players cannot use targeting unless TokenMod is set to allow players to use token ids.`); - return result; - } - } - ]); - ///////// - - // Check install and version - const checkInstall = () => { - let firstTimeSetup; - setTimeout(() => { if (!/object/i.test(typeof(['token-mod']))) return sendChat(scriptName, `/w gm
tokenMod not found - this script requires tokenMod to function! Aborting init...
`), 500 }); - if (!state[scriptName] || !state[scriptName].version ) { - log(`autoButtons: first time setup...`); - firstTimeSetup = 1; - state[scriptName] = { - version: Config.version, - settings: Config._settings, - store: Config._store - } - } - if (typeof(state[scriptName].version) === 'number' && state[scriptName].version % 1 !== 0) { state[scriptName].version = `${state[scriptName].version}`.replace(/\D/g, '').split('', 3).join('.') } - if (state[scriptName].version < Config.version) { - const v = state[scriptName].version; - if (v < `0.1.3`) { /* 0.5.3 fix - bad key names for very old versions */ - Object.assign(state[scriptName].settings, { ignoreAPI: 1 }); // new Config key - } - if (v < `0.2.0`) { - Object.assign(state[scriptName].settings, { overkill: 0, overheal: 0, enabledButtons: [] }); // new Config keys - } - if (v < `0.3.0`) { - Config.loadPreset(); // structure of preset has changed - reload - } - // if (v < `0.4.0`) { *** store is already outdated *** - // // state[scriptName].customButtons = {}; - // } - if (v < `0.5.0`) { // major refactor - helpers.copyOldButtonStore(); - state[scriptName].settings.bump = state[scriptName].settings.bump || true; - state[scriptName].settings.targetTokens = state[scriptName].settings.targetTokens || false; - } - log(`***UPDATED*** ====> ${scriptName} to v${Config.version}`); - } - state[scriptName].version = Config.version; - Config.fetchFromState(); - if ( - (!Config.getSetting('templates/names') || !Config.getSetting('templates/names').length) || - (!Config.getSetting('enabledButtons') || !Config.getSetting('enabledButtons').length)) { - Config.loadPreset(); - if (!firstTimeSetup) new ChatDialog({ title: `${scriptName} Install`, content:`Error fetching Config - loaded preset defaults` }, 'error'); - } - // Check state of buttons, repair if needed - if (!state[scriptName].store) helpers.copyOldButtonStore(); - for (const button in state[scriptName].store.customButtons) { - state[scriptName].store.customButtons[button].default = false; - const { err } = ButtonStore.addButton(state[scriptName].store.customButtons[button]); - if (err) console.error(`${err}`); - } - const allButtons = ButtonStore.getButtonNames(), - enabledButtons = Config.getSetting('enabledButtons'); - const validButtons = enabledButtons.filter(v => allButtons.includes(v)); - if (validButtons.length !== enabledButtons.length) { - console.warn(`Invalid button found in enabledButtons - button hidden.`); - Config.changeSetting('enabledButtons', validButtons, { overwriteArray: true }); - } - log(`=( Initialised ${scriptName} - v${Config.version} )=`); - // console.log(state[scriptName]); - } - - // Send buttons to chat - const sendButtons = (damage, crit, msg) => { - const gmOnly = Config.getSetting('gmOnly') ? true : false; - let buttonHtml = '', - activeButtons = Config.getSetting(`enabledButtons`) || [], - name = helpers.findName(msg.content); - name = name || `Apply:`; - activeButtons.forEach(btn => buttonHtml += ButtonStore.createApiButton(btn, damage, crit)); - const buttonTemplate = `
${name}
${buttonHtml}
`; - helpers.toChat(`${buttonTemplate}`, gmOnly); - } - - // Deconstruct roll - const handleDamageRoll = (msg) => { - const dmgFields = Config.getSetting('templates/damageProperties/damage')||[], - critFields = Config.getSetting('templates/damageProperties/crit')||[]; - const damage = helpers.processFields(dmgFields, msg), - crit = helpers.processFields(critFields, msg); - if ('dnd5e_r20' === Config.getSetting('sheet')) { - const isSpell = helpers5e.is5eAttackSpell(msg.content); - if (isSpell) { - const upcastDamageFields = Config.getSetting('templates/damageProperties/upcastDamage')||[], - upcastCritFields = Config.getSetting('templates/damageProperties/upcastCrit')||[]; - damage.total += helpers.processFields(upcastDamageFields, msg).total||0; - crit.total += helpers.processFields(upcastCritFields, msg).total||0; - } - } - sendButtons(damage, crit, msg); - } - - // The input... it must be handled - const handleInput = (msg) => { - const msgIsGM = playerIsGM(msg.playerid); - if (msg.type === 'api' && msgIsGM && /^!(autobut)/i.test(msg.content)) { - let cmdLine = (msg.content.match(/^![^\s]+\s+(.+)/i) || [])[1], - commands = cmdLine ? cmdLine.split(/\s*--\s*/g) : []; - commands.shift(); - if (commands.length) CLI.assess(commands); - } - else if (msg.rolltemplate && Config.getSetting('templates/names').includes(msg.rolltemplate)) { - const ignoreAPI = Config.getSetting('ignoreAPI'); - if (ignoreAPI && /^api$/i.test(msg.playerid)) return; - handleDamageRoll(msg); - } - } - - // The Go button - checkInstall(); - on('chat:message', handleInput); - - } - - /** - * SCRIPT DATA - */ - - // TODO: Replace with PresetController class - const preset = { - dnd5e_r20: { - sheet: ['dnd5e_r20'], - templates: { - names: ['atkdmg', 'dmg', 'npcfullatk', 'npcdmg'], - damageProperties: { - damage: ['dmg1', 'dmg2', 'globaldamage'], - crit: ['crit1', 'crit2', 'globaldamagecrit'], - upcastDamage: ['hldmg'], - upcastCrit: ['hldmgcrit'], - } - }, - defaultButtons: ['damageCrit', 'damageFull', 'damageHalf', 'healingFull'], - // userButtons array, to save user button setup? - }, - custom: { - sheet: [], - templates: { - names: [], - damageProperties: { - damage: [], - crit: [], - } - }, - defaultButtons: [] - } - } - - // Styles for chat UI elements - const styles = { - error: `color: red; font-weight: bold;`, - outer: `position: relative; vertical-align: middle; font-family: pictos; display: block; background: #f4e6b6; border: 1px solid black; height: auto; line-height: 34px; text-align: center; border-radius: 2px;`, - rollName: `font-family: arial; font-size: 1.1rem; color: black; font-style:italic; position:relative; overflow: hidden; display: block; line-height: 1rem; margin: 2px 0px 1px 0px; white-space: nowrap; text-align: left; left: 2px;`, - buttonContainer: `display: inline-block; text-align: center; vertical-align: middle; line-height: 26px; margin: auto 5px auto 5px; height: 26px; width: 26px; border: #8c6700 1px solid; box-shadow: 0px 0px 3px #805200; border-radius: 5px; background-color: whitesmoke;`, - buttonShared: `background-color: transparent; border: none; padding: 0px; width: 100%; height: 100%; overflow: hidden; white-space: nowrap;`, - crit: `color: red; font-size: 1.5rem;`, - full: `color: darkred; font-size: 2.1rem;`, - half: `color: black; font-family: pictos three; font-size: 2rem; padding-top:1px;`, - healFull: `color: green; font-size: 2rem;`, - list: { - container: `font-size: 1.5rem; background: #41415c; border: 5px solid #1c7b74; border-radius: 3px; color: white; vertical-align: middle;`, - header: `text-align: center; font-weight: bold; padding: 6px 0px 6px 0px; border-bottom: solid 1px darkgray; line-height: 1.5em;`, - body: `padding: 8px 0px 8px 0px;`, - row: `vertical-align: middle; margin: 0.2em auto 0.2em auto; font-size: 1.2em; line-height: 1.4em;`, - name: `display: inline-block; vertical-align: middle; width: 60%; margin-left: 5%; overflow-x: hidden;`, - buttonContainer: ` display: inline-block; vertical-align: middle; width: 10%; text-align: center; line-height: 1.2em;`, - controls: { - common: `position: relative; font-family: pictos; display: inline-block; background-color: darkgray; padding: 0px; margin: 0px; border: 1px solid #c2c2c2; border-radius: 3px; width: 1.1em; height: 1.1em; line-height: 1.1em; font-size: 1.2em;`, - show: `color: #03650b;`, - hide: `color: #2a2a2a;`, - disabled: `color: gray; cursor: pointer;`, - delete: `color: darkred;`, - create: `display: inline-block; background-color: darkgray; padding: 0px; margin: 0px; border: 1px solid #c2c2c2; border-radius: 3px; color: #066a66; padding: 2px 5px 2px 5px; font-size: 1.1em; line-height: 1.2em;`, - no: `position: absolute; left: 0.4em; font-weight: bold; font-family: arial;` - }, - footer: `text-align: center; font-weight: bold; padding: 6px 0px 6px 0px; border-top: solid 1px darkgray; line-height: 1.5em;`, - }, -/////// New in v0.5.1 - mods: { - bump: `left: -5px; top: -30px; margin-bottom: -34px;` - } - } - - // Default buttons - const _defaultButtons = { - damageCrit: { - sheets: ['dnd5e_r20'], - tooltip: `Crit (%)`, - style: styles.crit, - math: (damage, crit) => -(damage.total + crit.total), - content: 'kk', - }, - damageFull: { - sheets: ['dnd5e_r20'], - tooltip: `Full (%)`, - style: styles.full, - math: (damage) => -(1 * damage.total), - content: 'k', - }, - damageHalf: { - sheets: ['dnd5e_r20'], - tooltip: `Half (%)`, - style: styles.half, - math: (damage) => -(Math.floor(0.5 * damage.total)), - content: 'b', - }, - healingFull: { - sheets: ['dnd5e_r20'], - tooltip: `Heal (%)`, - style: styles.healFull, - math: (damage) => (damage.total), - content: '&', - }, - }; - - // Global regex - const rx = { on: /\b(1|true|on)\b/i, off: /\b(0|false|off)\b/i }; - - // Control debug levels with debugLevel variable at top of script. Move debug to CLI command? - const console = (() => { - const passthrough = (...args) => log(...args), - nope = () => {}; - return { - log: debugLevel > 3 ? passthrough : nope, - info: debugLevel > 2 ? passthrough : nope, - warn: debugLevel > 1 ? passthrough : nope, - error: debugLevel > 0 ? passthrough : nope, - } - })(); - - // Helper functions - const helpers = (() => { - // Process roll object according to rolltemplate fields - const processFields = (fieldArray, msg) => { - let output = {} - const rolls = msg.inlinerolls; - output.total = fieldArray.reduce((m, v) => { - const rxIndex = new RegExp(`{${v}=\\$\\[\\[\\d+`, 'g'), - indexResult = msg.content.match(rxIndex); - if (indexResult) { - const index = indexResult.pop().match(/\d+$/)[0], - total = isNaN(rolls[index].results.total) ? 0 : rolls[index].results.total; - output[v] = total; - return m + total; - } else { // if roll template property's inline roll is not found, return 0 to prevent errors down the line - output[v] = 0; - } - return m; - }, 0); - return output; - } - // Simple name finder, provided rolltemplate has some kind of 'name' property - const findName = (msgContent) => { - const rxName = /name=([^}]+)}/i; - let name = msgContent.match(rxName); - return name ? name[1] : null; - } - // sendChat shortcut - const toChat = (msg, whisper = true) => { - let prefix = whisper ? `/w gm ` : ''; - sendChat(scriptName, `${prefix}${msg}`, {noarchive: true}); - } - const toArray = (inp) => Array.isArray(inp) ? inp : [inp]; - const emproper = (inpString) => { - let words = inpString.split(/\s+/g); - return words.map(w => `${w[0].toUpperCase()}${w.slice(1)}`).join(` `); - } - // Split {{handlebars=moustache}} notation to key value - const splitHandlebars = (inputString) => { - let output = {}, - kvArray = inputString.match(/{{[^}]+}}/g)||[]; - kvArray.forEach(kv => { - kv = kv.replace(/({{|}})/g, ''); - const key = kv.match(/^[^=]+/), - value = (kv.match(/=(.+)/)||[])[1] || ``; - if (key) output[key] = value; - }); - return Object.keys(output).length ? output : null; - } - // Camelise a name if user tries to use whitespace - const camelise = (inp, options={enforceCase:false}) => { - if (typeof(inp) !== 'string') return null; - const words = inp.split(/[\s_]+/g); - return words.map((w,i) => { - const wPre = i > 0 ? w[0].toUpperCase() : w[0].toLowerCase(); - const wSuf = options.enforceCase ? w.slice(1).toLowerCase() : w.slice(1); - return `${wPre}${wSuf}`; - }).join(''); - } - const copyObj = (inputObj) => (typeof inputObj !== 'object') ? null : JSON.parse(JSON.stringify(inputObj)); - - const copyOldButtonStore = () => { - let names = []; - state[scriptName].store = state[scriptName].store || {}; - state[scriptName].store.customButtons = helpers.copyObj(state[scriptName].customButtons) || {}; // copy old store to new store - for (const button in state[scriptName].store.customButtons) { - state[scriptName].store.customButtons[button].name = state[scriptName].store.customButtons[button].name || button; - state[scriptName].store.customButtons[button].mathString = state[scriptName].store.customButtons[button].mathString || state[scriptName].store.customButtons[button].math; - names.push(state[scriptName].store.customButtons[button].name); - } - if (names.length) new ChatDialog({ title: 'Buttons copied to new version', content: names }); - } - - return { processFields, findName, toChat, toArray, emproper, splitHandlebars, camelise, copyObj, copyOldButtonStore } - })(); - - // 5e specific helpers - const helpers5e = (() => { - // Spell detection - const is5eAttackSpell = (msgContent) => { - const rxSpell = /{spelllevel=(cantrip|\d+)/; - return rxSpell.test(msgContent) ? 1 : 0; - } - return { is5eAttackSpell } - })(); - - // Default command line options - const defaultCliOptions = [ - { - name: 'reset', - rx: /^reset/i, - description: `Reset configuration from preset`, - requiredServices: { config: 'ConfigController' }, - action: function () { - if (this.config.getSetting('sheet')) { - this.config.loadPreset(); - return { success: 1, msg: `Config reset from preset: "${this.config.getSetting('sheet')}"` }; - } else return { err: `No preset found!` }; - } - }, - { - name: 'bar', - rx: /^(hp)?bar/i, - description: `Select which token bar represents hit points`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - const newVal = parseInt(`${args}`.replace(/\D/g, '')); - if (newVal > 0 && newVal < 4) { - return this.config.changeSetting('hpBar', newVal); - } else return { err: `token bar value must be 1, 2 or 3`} - } - }, - { - name: 'loadPreset', - rx: /^loadpre/i, - description: `Select a preset for a Game System`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonController' }, - action: function (args) { - const newVal = args.trim(); - if (Object.keys(preset).includes(newVal)) { - const newSheet = this.config.changeSetting('sheet', newVal); - if (newSheet.success) { - this.config.loadPreset(); - this.buttons.verifyButtons(); - return { success: 1, msg: `Preset changed: ${newVal}` }; - } else return { err: `Error changing preset to "${newVal}"` }; - } else return { err: `Coudln't find sheet/preset: ${args}` } - } - }, - { - name: 'listTemplates', - rx: /^(list)?templ/i, - description: `List roll templates the script is listening for`, - requiredServices: { config: 'ConfigController' }, - action: function () { - const templates = this.config.getSetting(`templates/names`), - templateText = ` ${templates.join(', ')}`; - new ChatDialog({ content: templateText, title: `Roll Template List` }); - } - }, - { - name: 'addTemplate', - rx: /^addtem/i, - description: `Add roll template name to listen list for damage rolls`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - if (!this.config.getSetting('templates/names').includes(args)) { - const result = this.config.changeSetting('templates/names', args); - if (result.success) result.msg = `Added template ${args} to listener list`; - return result; - } - } - }, - { - name: 'removeTemplate', - rx: /^rem(ove)?tem/i, - description: `Remove roll template from listen list`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - if (this.config.getSetting('templates/names').includes(args)) { - const result = this.config.changeSetting(args, 'templates/names'); - if (result.success) result.msg = `Removed template ${args} to listener list`; - return result; - } - } - }, - { - name: 'listProperties', - rx: /^(list)?(propert|props)/i, - description: `List roll template properties inline rolls are grabbed from`, - requiredServices: { config: 'ConfigController' }, - action: function () { - const properties = this.config.getSetting('templates/damageProperties'); - let templateText = []; - if (typeof properties === 'object') { - for (let category in properties) templateText.push(` ${category}=${properties[category].join(`, `)}`); - } else return { err: `Error getting damage properties from state` } - new ChatDialog({ title: 'Roll Template Properties', content: templateText }); - } - }, - { - name: 'addProperty', - rx: /^addprop/i, - description: `Add a roll template property to the listener`, - requiredServices: { config: 'ConfigController', }, - action: function (args) { - const parts = args.match(/([^/]+)\/(.+)/); - if (parts && parts.length === 3) { - if (this.config.getSetting(`templates/damageProperties/${parts[1]}`) == null) { - helpers.toChat(`Created new roll template damage property category: ${parts[1]}`); - state[scriptName].settings.templates.damageProperties[parts[1]] = []; - } - return this.config.changeSetting(`templates/damageProperties/${parts[1]}`, parts[2]); - } else { - return { err: `Bad property path supplied, must be in the form "category/propertyName". Example: damage/dmg1` }; - } - } - }, - { - name: 'removeProperty', - rx: /^rem(ove)?prop/i, - description: `Remove a roll template property from the listener`, - requiredServices: { config: 'ConfigController', }, - action: function (args) { - const parts = args.match(/([^/]+)\/(.+)/); - if (parts && parts.length === 3) { - const currentArray = this.config.getSetting(`templates/damageProperties/${parts[1]}`); - if (currentArray != null) { - const result = this.config.changeSetting(`templates/damageProperties/${parts[1]}`, parts[2]); - if (result.success && !/^(damage|crit)$/i.test(parts[1])) { // Clean up category if it's now empty, and isn't a core category - const newArray = this.config.getSetting(`templates/damageProperties/${parts[1]}`); - if (newArray.length === 0) { - delete state[scriptName].settings.templates.damageProperties[parts[1]]; - result.msg += `\nCategory ${parts[1]} was empty, and was removed.`; - } - } - return result; - } else return { err: `Could not find roll template property category: ${parts[1]}` } - } else { - return { err: `Bad property path supplied, must be in the form "category/propertyName". Example: damage/dmg1` } - } - } - }, - { - name: 'listButtons', - rx: /^(list)?button/i, - description: `List available buttons`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonController' }, - action: function() { - const removableButtons = this.buttons.getButtonNames({ default: false }), - usedButtons = this.config.getSetting('enabledButtons'), - unusedButtons = this.buttons.getButtonNames({ hidden: true }), - availableButtons = this.buttons.getButtonNames({ hidden: true, currentSheet: true }), - reorderedButtons = usedButtons.concat(unusedButtons); - const links = { - hide: `!autoButton --hideButton %name%`, - show: `!autoButton --showButton %name%`, - delete: `!autoButton --deleteButton %name%`, - disabled: `#` - } - const labels = { - hide: `E/`, - show: 'E', - delete: 'D', - disabled: '!' - }; - const controls = ['show', 'hide', 'delete']; - const listBody = reorderedButtons.map(button => { - let rowHtml = `
${removableButtons.includes(button) ? '' : '*'}%name%
`; - controls.forEach(control => { - const controlType = ( - (control === 'show' && availableButtons.includes(button)) || - (control === 'hide' && usedButtons.includes(button)) || - (control === 'delete' && removableButtons.includes(button))) ? - control : 'disabled'; - rowHtml += `
${labels[control]}
`; - }); - return `${rowHtml.replace(/%name%/g, button)}
`; - }); - const headerText = `autoButton list (sheet: ${this.config.getSetting('sheet')})`, - bodyText = listBody.join(''), - footerText = `Create New Button`; - new ChatDialog({ header: headerText, body: bodyText, footer: footerText }, 'listButtons'); - }, - }, - { - name: 'showButton', - rx: /^showbut/i, - description: `Add a button to the template`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonController' }, - action: function (args) { - const newVal = args.trim(); - const validButtons = this.buttons.getButtonNames({ hidden: true, currentSheet: true }); - if (validButtons.includes(newVal)) { - return this.config.changeSetting('enabledButtons', newVal); - } else new ChatDialog({ title: 'Error', content: `Unrecognised or incompatible button: "${newVal}"` }, 'error'); - } - }, - { - name: 'hideButton', - rx: /^hidebut/i, - description: `Remove a button from the template`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonController' }, - action: function (args) { - const newVal = args.trim(); - const validButtons = this.buttons.getButtonNames({ shown: true, currentSheet: true }); - if (validButtons.includes(newVal)) { - return this.config.changeSetting('enabledButtons', newVal); - } else new ChatDialog({ title: 'Error', content: `Unrecognised or incompatible button: "${newVal}"` }, 'error'); - } - }, - { - name: 'reorderButtons', - rx: /^(re)?order/i, - description: `Change order of buttons`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - if (!args) return; - const newIndices = args.replace(/[^\d,]/g, '').split(/,/g), - currentOrder = this.config.getSetting('enabledButtons'); - let newOrder = []; - let valid = true; - newIndices.forEach(buttonIndex => { - const realIndex = buttonIndex - 1; - if (realIndex > -1 && realIndex < currentOrder.length) { - if (currentOrder[realIndex]) { - newOrder.push(currentOrder[realIndex]); - currentOrder[realIndex] = null; - } - } else valid = false; - }); - if (!valid) return { err: `Invalid button order input: ${args}. Indices must be between 1 and total number of buttons in use.` } - newOrder = newOrder.concat(currentOrder.filter(v => v)); - if (newOrder.length === currentOrder.length) return this.config.changeSetting('enabledButtons', newOrder, { overwriteArray: true }); - } - }, - { - name: 'createButton', - rx: /^createbut/i, - description: `Create a new button`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonController' }, - action: function (args) { - const buttonData = helpers.splitHandlebars(args); - if (buttonData && buttonData.name) { - if (/^[^A-Za-z]/.test(buttonData.name)) return { err: `Invalid button name: must start with a letter` }; - let buttonName = /\s/.test(buttonData.name) ? helpers.camelise(buttonData.name) : buttonData.name; - if (this.buttons.getButtonNames().includes(buttonName)) return { err: `Invalid button name, already in use: "${buttonName}"` } - if (!buttonData.math) return { err: `Button must have an associated function, {{math=...}}` } - buttonData.default = false; - console.info(buttonData); - // const newButton = new CustomButton(buttonData); - // if (newButton && newButton.math) { - return this.buttons.addButton(buttonData); - // } - } else return { err: `Bad input for button creation` } - } - }, - { - name: 'editButton', - rx: /^editbut/i, - description: `Edit an existing button`, - requiredServices: { buttons: 'ButtonController' }, - action: function (args) { - let buttonData = helpers.splitHandlebars(args); - console.log(buttonData); - if (buttonData && buttonData.name) { - if (this.buttons.getButtonNames().includes(buttonData.name)) { - return this.buttons.editButton(buttonData); - } - } - } - }, - { - name: 'deleteButton', - rx: /^del(ete)?but/i, - description: `Remove a button`, - requiredServices: { buttons: 'ButtonController', config: 'ConfigController' }, - action: function (args) { - const removeResult = this.buttons.removeButton({ name: args }), - buttonIsEnabled = this.config.getSetting('enabledButtons').includes(args); - if (removeResult.success) { - if (buttonIsEnabled) this.config.changeSetting('enabledButtons', args); - return removeResult; - } else return removeResult; - } - }, - { - name: 'ignoreApi', - rx: /^ignoreapi/i, - description: `Ignore anything sent to chat by the API`, - requiredServices: { config: 'ConfigController' }, - action: function(args) { return this.config.changeSetting('ignoreAPI', args) } - }, - { - name: 'overheal', - rx: /^overh/i, - description: `Allow healing to push hp above hpMax`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('overheal', args) } - }, - { - name: 'overkill', - rx: /^overk/i, - description: `Allow healing to push hp above hpMax`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('overkill', args) } - }, - { - name: 'gmOnly', - rx: /^gmo/i, - description: `Whisper the buttons to GM, or post publicly`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('gmOnly', args) } - }, - { - name: 'settings', - rx: /^setting/i, - description: `Open settings UI`, - action: function() { new ChatDialog({ title: `Script Settings UI`, content: `Not yet implemented.` }) } - }, - { - name: 'help', - rx: /^(\?$|h$|help)/i, - description: `Display script help`, - action: function() { new ChatDialog({ title: `Script Help`, content: `Not yet implemented.` }) } - }, - { - name: 'uninstall', - rx: /^uninstall$/i, - description: `Remove all script settings from API state`, - action: function(args) { - if (/^undo/i.test(args)) { - state[scriptName] = helpers.copyObj(undoUninstall); - new ChatDialog({ title: 'Reverse! Reverse the reverse!', content: `State settings have been restored. Let's pretend that never happend, eh?` }, 'error') - } else if (!undoUninstall) { - undoUninstall = helpers.copyObj(state[scriptName]); - state[scriptName] = null; - delete state[scriptName]; - new ChatDialog({ - header: `${scriptName} uninstalled!`, - body: `Removed all ${scriptName} settings from API state. Click the 'whoopsie' button below if you didn't mean to destroy all your settings! All settings will be *permantently* lost on sandbox restart.`, - footer: `Oh Shit!`, - }, 'listButtons'); - } - } - } - ] - - /** - * CLASS DEFINITIONS - */ - - class ServiceLocator { - - static _active = null; - _services = {}; - - constructor(services={}) { - if (ServiceLocator._active) return ServiceLocator._active; - this.name = `ServiceLocator`; - for (let svc in services) { this._services[svc] = services[svc] } - ServiceLocator._active = this; - } - - static getLocator() { return ServiceLocator._active } - - register({ serviceName, serviceReference }) { if (!this._services[serviceName]) this._services[serviceName] = serviceReference } - - // Find a service. If service has multiple instances, make sure to request by instance name, or only the first registered constructor name will be returned. - // Search by Class Constructor Name is only suitable for unique class instances - getService(serviceName) { - if (this._services[serviceName]) return { [serviceName]: this._services[serviceName] } - else { - const rxServices = new RegExp(`${serviceName}`, 'i') - for (let service in this._services) { - if (this._services[service].constructor && rxServices.test(this._services[service].constructor.name)) return this._services[service]; - } - } - } - } - class ConfigController { - - _version = { M: 0, m: 0, p: 0 }; - - constructor(scriptName, scriptData={}) { - Object.assign(this, { - name: scriptName || `newScript`, - // _version: { M: 0, m: 0, p: 0 }, - _settings: scriptData.settings || {}, - _store: scriptData.store || {}, - }); - if (scriptData.version) this.version = scriptData.version; - } - - _getObjectPath(pathString, baseObject, createPath, deleteTarget) { - const parts = pathString.split(/\/+/g); - const objRef = parts.reduce((m,v,i) => { - if (m == null) return; - if (m[v] == null) { - if (createPath) m[v] = {}; - else return null; - } - if (deleteTarget && (i === parts.length-1)) delete m[v]; - else return m[v];}, baseObject) - return objRef; - } - - // If value exists in array, it will be removed, otherwise it will be added - // Do validation beforehand - _modifyArray(targetArray, newValue) { - if (!Array.isArray(targetArray || newValue == null)) return { err: `modifyArray error, bad parameters` }; - return targetArray.includes(newValue) ? { result: 'removed', newArray: targetArray.filter(v=>v!==newValue) } : { result: 'added', newArray: targetArray.concat([newValue]) } - } - - get version() { return `${this._version.M}.${this._version.m}.${this._version.p}` } - set version(newVersion) { - if (typeof(newVersion) === 'object' && newVersion.M && newVersion.m && newVersion.p) Object.assign(this._version, newVersion); - else { - const parts = `${newVersion}`.split(/\./g); - if (!parts.length) console.error(`Bad version number, not setting version.`) - else Object.keys(this._version).forEach((v,i) => this._version[v] = parseInt(parts[i]) || 0); - } - } - - fromStore(path) { return this._getObjectPath(path, this._store, false) } - toStore(path, data) { // Supplying data=null will delete the target - const ref = this._getObjectPath(path, this._store, true); - let msg; - if (ref) { - if (data) { - Object.assign(ref, data); - msg = `New data written to "${path}"`; - } else if (data === null) { - this._getObjectPath(path, this._store, false, true); - msg = `${path} deleted from store.`; - } else return { success: 0, err: `Bad data supplied (type: ${typeof data})` } - } else return { success: 0, err: `Bad store path: "${path}"` } - this.saveToState(); - return { success: 1, msg: msg } - } - - fetchFromState() { - Object.assign(this, { - _settings: state[scriptName].settings, - _store: state[scriptName].store, - }); - } - saveToState() { - Object.assign(state[scriptName], { - settings: this._settings, - store: this._store, - }); - } - - // Provide path relative to {Config._settings}, e.g. changeSetting('sheet', 'mySheet'); - // booleans with no "newValue" supplied will be toggled - // Use options.force 'type' to force a type on the setting e.g. array or boolean - // Combine with options.createPath: true to create a new setting of the correct type - // TODO: add missing types & options.force switches - changeSetting(pathString, newValue, options = { baseObject: this._settings, createPath: false, overwriteArray: false, ignoreBoolen: false, force: null }) { - let modded = []; - if (typeof(pathString) !== 'string' || newValue === undefined) return; - const keyName = (pathString.match(/[^/]+$/)||[])[0], - path = /.+\/.+/.test(pathString) ? pathString.match(/(.+)\/[^/]+$/)[1] : '', - configPath = path ? this._getObjectPath(path, options.baseObject, options.createPath) : this._settings; - if (configPath && keyName) { - if ((typeof(configPath[keyName]) === 'boolean' && !options.ignoreBoolen) || options.force === 'boolean') { - configPath[keyName] = (newValue == null || newValue === '') ? !configPath[keyName] : - rx.on.test(newValue) ? true : - rx.off.test(newValue) ? false : - configPath[keyName]; - modded.push(`${keyName}: **${this.getSetting(pathString)}**`); - } - else if (Array.isArray(configPath[keyName]) && !options.overwriteArray) { - const { newArray, result } = this._modifyArray(configPath[keyName], newValue); - if (result) { - console.log(newArray); - configPath[keyName] = newArray; - modded.push(`${newValue} was ***${result}*** to **${pathString}**`); - } - } - else { - configPath[keyName] = newValue; - modded.push(`${keyName}: **${this.getSetting(pathString)}**`); - } - if (modded.length) { - this.saveToState() - return { success: 1, msg: modded } - } - } else { - return { success: 0, err: `Bad Config path *${pathString}*` } - } - } - getSetting(pathString, baseObject = this._settings) { - if (typeof(pathString) !== 'string') return null; - let configValue = this._getObjectPath(pathString, baseObject, false); - return (typeof configValue === 'object') ? helpers.copyObj(configValue) : configValue; - } - loadPreset() { - const currentSheet = this._settings.sheet || ''; - if (Object.keys(preset).includes(currentSheet)) { - this._settings.templates = preset[currentSheet].templates || []; - this._settings.enabledButtons = preset[currentSheet].defaultButtons || []; - this.saveToState(); - return { res: 1, data: `${this.getSetting('sheet')}` } - } else return { res: 0, err: `Preset not found for sheet: "${currentSheet}"`} - } - } - - class ButtonController { - - static _buttonKeys = ['sheets', 'content', 'tooltip', 'style', 'math', 'default', 'mathString']; - _locator = null; - _Config = {}; - _buttons = {}; - - constructor(data={}) { - Object.assign(this, { name: data.name || 'newButtonController' }); - // Requires access to a ConfigController - this._locator = ServiceLocator.getLocator() || this._locator; - this._Config = this._locator ? this._locator.getService('ConfigController') : null; - if (!this._Config) return {}; - for (let button in data.defaultButtons) { this._buttons[button] = new Button(data.defaultButtons[button], styles) } - } - - get keys() { return ButtonController._buttonKeys } - - getButtonNames(filters={ default: null, currentSheet: null, shown: null, hidden: null }) { - let buttons = Object.entries(this._buttons); - const sheet = this._Config.getSetting('sheet'), - enabledButtons = this._Config.getSetting('enabledButtons'); - if (typeof filters.default === 'boolean') buttons = buttons.filter(kv => kv[1].default === filters.default); - if (typeof filters.currentSheet === 'boolean') buttons = buttons.filter(kv => (!kv[1].sheets.length || sheet === 'custom' || (kv[1].sheets.includes(sheet) === filters.currentSheet))); - if (typeof filters.shown === 'boolean') buttons = buttons.filter(kv => (enabledButtons.includes(kv[0]) === filters.shown)); - if (typeof filters.hidden === 'boolean') buttons = buttons.filter(kv => (enabledButtons.includes(kv[0]) === !filters.hidden)); - const output = buttons.map(kv=>kv[0]); - console.log(`button names: ${output.join(', ')}`); - return output; - } - - static parseMathString(inputString) { - let err = ''; - // Convert to JS - const formulaReplacer = { - '$1Math.floor': /([^.]|^)floor/ig, - '$1Math.ceil': /([^.]|^)ceil/ig, - '$1Math.round': /([^.]|^)round/ig, - '($1||0)': /((damage|crit)\.\w+)/ig, - } - // Very basic security, at least stops a `state = null` - const disallowed = [ /=/g, /\bstate\b/gi ]; - - disallowed.forEach(rx => { if (rx.test(inputString)) err += `Disallowed value in math formula: "${`${rx}`.replace(/(\\\w|\/)/g, '')}"` }); - - let newFormula = inputString; - for (let f in formulaReplacer) newFormula = newFormula.replace(formulaReplacer[f], f); - - // Create a test object - let damageKeys = inputString.match(/(damage|crit)\.(\w+)/g), - testKeys = {}; - damageKeys = damageKeys ? damageKeys.map(k => k.replace(/^[^.]*\./, '')) : []; - damageKeys.forEach(k => testKeys[k] = 5); - - let validate = false, - newFunc; - try { - newFunc = new Function(`damage`, `crit`, `return (${newFormula})`) - validate = isNaN(newFunc(testKeys, testKeys)) ? false : true; - } catch(e) { err += (`${scriptName}: formula failed validation`) } - - if (validate && !err) { - return newFunc; - } else { - return new Error(err); - } - } - addButton(buttonData={}) { - const newButton = buttonData.default === false ? new CustomButton(buttonData) : new Button(buttonData); - if (newButton.err) return { success: 0, err: newButton.err } - if (this._buttons[newButton.name]) return { success: 0, err: `Button "${newButton.name}" already exists` }; - this._buttons[newButton.name] = newButton; - console.info(this._buttons); - this.saveToStore(); - return { success: 1, msg: `New Button "${newButton.name}" successfully created` } - } - editButton(buttonData={}) { - let modded = []; - if (!this._buttons[buttonData.name]) return { success: 0, err: `Button "${buttonData.name}" does not exist.` } - if (this._buttons[buttonData.name].default) return { success: 0, err: `Cannot edit default buttons.` } - this.keys.forEach(k => { - if (buttonData[k] != null) { - if (k === 'default') return; - if (k === 'math') { - let newMath = ButtonController.parseMathString(buttonData[k]); - if (newMath.err) return newMath; - else { - this._buttons[buttonData.name].mathString = buttonData[k]; - this._buttons[buttonData.name].math = newMath; - modded.push(k); - } - } else { - this._buttons[buttonData.name][k] = buttonData[k]; - modded.push(k); - } - } - }); - if (modded.length) this.saveToStore(); - return modded.length ? { success: 1, msg: `Modified ${buttonData.name} fields: ${modded.join(', ')}` } : { success: 0, err: `No fields supplied.` } - } - removeButton(buttonData={}) { - if (!this._buttons[buttonData.name]) return { success: 0, err: `Button "${buttonData.name}" does not exist.` } - if (this._buttons[buttonData.name].default) return { success: 0, err: `Cannot delete default buttons.` } - delete this._buttons[buttonData.name]; - this._Config.toStore(`customButtons/${buttonData.name}`, null); - return { success: 1, msg: `Removed "${buttonData.name}".` } - } - showButton(buttonName) { - if (this._buttons[buttonName] && !this._Config.getSetting('enabledButtons').includes(buttonName)) { return this._Config.changeSetting('enabledButtons', buttonName) } - } - hideButton(buttonName) { - if (this._buttons[buttonName] && this._Config.getSetting('enabledButtons').includes(buttonName)) { return this._Config.changeSetting('enabledButtons', buttonName) } - } - saveToStore() { - const customButtons = this.getButtonNames({default: false}); - customButtons.forEach(button => this._Config.toStore(`customButtons/${button}`, helpers.copyObj(this._buttons[button]))); - console.log(this._Config.fromStore('customButtons')); - } - createApiButton(buttonName, damage, crit) { - const btn = this._buttons[buttonName], - bar = this._Config.getSetting('hpBar'), - overheal = this._Config.getSetting('overheal'), - overkill = this._Config.getSetting('overkill'); - if (!btn || typeof(btn.math) !== 'function') { - console.error(`${scriptName}: error creating API button ${buttonName}`); - console.error(`No button found or invalid math function: "${btn.math}"`); - return ``; - } - const modifier = btn.math(damage, crit), - tooltip = btn.tooltip.replace(/%/, `${modifier} HP`), - tokenModCmd = (modifier > 0) ? (!overheal) ? `+${modifier}!` : `+${modifier}` : (modifier < 0 && !overkill) ? `${modifier}!` : modifier, - selectOrTarget = (this._Config.getSetting('targetTokens') === true) ? `--ids @{target|token_id} ` : ``; - return `
${btn.content}
`; - } - verifyButtons() { - const currentSheet = this._Config.getSetting('sheet'), - currentButtons = this._Config.getSetting('enabledButtons'), - validButtons = currentButtons.filter(button => { - if (currentSheet === 'custom' || this._buttons[button] && this._buttons[button].sheets.includes(currentSheet)) return 1; - }); - if (validButtons.length !== currentButtons.length) { - const { success, msg, err } = this._Config.changeSetting('enabledButtons', validButtons); - if (success && msg) new ChatDialog({ content: msg, title: 'Buttons Changed' }); - else if (err) new ChatDialog({ content: err }, 'error'); - } - } - } - - class Button { - constructor(buttonData={}, styleData=styles) { - Object.assign(this, { - name: buttonData.name || 'newButton', - sheets: Array.isArray(buttonData.sheets) ? buttonData.sheets : [], - tooltip: `${buttonData.tooltip || ''}`, - style: styleData[buttonData.style] || buttonData.style || '', - content: buttonData.content || '?', - math: buttonData.math || null, - mathString: buttonData.mathString || buttonData.math.toString(), - default: buttonData.default === false ? false : true, - }); - if (typeof(this.math) !== 'function') return { err: `Button "${this.name}" math function failed validation` }; - } - } - - class CustomButton extends Button { - constructor(buttonData={}) { - if (!buttonData.math && !buttonData.mathString) return { err: `Button must contain a function in 'math' key.` }; - buttonData.name = buttonData.name || 'newCustomButton', - buttonData.mathString = buttonData.mathString || buttonData.math.toString(); - buttonData.math = ButtonController.parseMathString(buttonData.mathString); - buttonData.style = buttonData.style || 'full'; - super(buttonData); - } - } - - class CommandLineInterface { - - _locator = null; - _services = {}; - _options = {}; - - constructor(cliData={}) { - this.name = cliData.name || `Cli`; - this._locator = ServiceLocator.getLocator(); - if (!this._locator) console.warn(`${this.constructor.name} could not find the service locator. Any commands relying on services will be disabled.`); - Object.assign(this._services, { - config: this._locator.getService('ConfigController'), - buttons: this._locator.getService('ButtonController'), - cli: this, - }); - if (cliData.options && cliData.options.length) this.addOptions(cliData.options); - console.info(`Initialised CLI`); - } - - // Add one or more options to the CLI - addOptions(optionData) { - optionData = helpers.toArray(optionData); - optionData.forEach(data => { - if (data.name && !this._options[data.name]) { - const suppliedServices = { cli: this } - if (data.requiredServices) { - for (let service in data.requiredServices) { - const svc = - service === 'ConfigController' ? this._services.config - : service === 'ButtonController' ? this._services.buttons - : this._locator.getService(data.requiredServices[service]); - if (svc) suppliedServices[service] = svc; - else return console.warn(`${this.name}: Warning - Service "${service}" could not be found for option ${data.name}. CLI option not registered.`); - } - } - data.services = suppliedServices; - this._options[data.name] = new CommandLineOption(data); - } else console.warn(`Bad data supplied to CLI Option constructor`); - }); - } - - assess(commandArray, reportToChat = true) { - let changed = [], errs = []; - commandArray.forEach(command => { - const cmd = (command.match(/^([^\s]+)/)||[])[1], - args = (command.match(/\s+(.+)/)||['',''])[1]; - for (let option in this._options) { - if (this._options[option].rx.test(cmd)) { - const { success, msg, err } = (this._options[option].action(args) || {}); - // console.log(msg||err); - if (success && msg) changed.push(helpers.toArray(msg).join('
')); - if (err) errs.push(err); - } - } - }); - if (changed.length && reportToChat) { - // console.info(changed); - const chatData = { - title: `${scriptName} settings changed`, - content: changed - }; - new ChatDialog(chatData); - } - if (errs.length) new ChatDialog( { title: 'Errors', content: errs }, 'error'); - } - - trigger(option, ...args) { if (this._options[option]) this._options[option].action(...args) } - - } - - class CommandLineOption { - - constructor(optionData={}) { - for (let service in optionData.services) { - this[service] = optionData.services[service]; - } - Object.assign(this, { - name: optionData.name || 'newOption', - rx: optionData.rx || new RegExp(`${optionData.name}`, 'i'), - description: optionData.description || `Description goes here...`, - action: optionData.action - }); - } - - } - - class ChatDialog { - - static _templates = { - none: ({content}) => `${content}`, - default: ({ title, content }) => { - const msgArray = content ? helpers.toArray(content) : [], - body = msgArray.map(row => `
${row}
`).join('') - return ` -
-
${title||scriptName}
-
- ${body} -
-
`; - }, - error: ({ title, content }) => { - const errArray = content ? helpers.toArray(content) : []; - return ` -
-
${title}
-
${errArray.join('
')}
-
`; - }, - listButtons: ({ header, body, footer }) => { - return ` -
-
${header}
-
- ${body} -
- -
- `; - } - } - - constructor(message, template = 'default', autoSend = true) { - this.msg = ChatDialog._templates[template] ? ChatDialog._templates[template](message) : null; - if (this.msg) { - this.msg = this.msg.replace(/\n/g, ''); - if (autoSend) helpers.toChat(this.msg); - } else { - console.warn(`${scriptName}: error creating chat dialog, missing template "${template}"`); - return {}; - } - } - } - - on('ready', startScript); - -})(); \ No newline at end of file diff --git a/autoButtons/0.6.2/autoButtons.js b/autoButtons/0.6.2/autoButtons.js deleted file mode 100644 index f4759be5fe..0000000000 --- a/autoButtons/0.6.2/autoButtons.js +++ /dev/null @@ -1,1636 +0,0 @@ -/* globals state log on sendChat playerIsGM */ //eslint-disable-line -const autoButtons = (() => { // eslint-disable-line no-unused-vars - - const scriptName = `autoButtons`, - scriptVersion = `0.6.2`, - debugLevel = 2; - let undoUninstall = null; - - const debug = { - log: function(...args) { if (debugLevel > 3) console.log(...args) }, - info: function(...args) { if (debugLevel > 2) console.info(...args) }, - warn: function(...args) { if (debugLevel > 1) console.warn(...args) }, - error: function(...args) { if (debugLevel > 0) console.error(...args) }, - } - -/** - * CORE SCRIPT - */ - const startScript = () => { - - const Services = new ServiceLocator({ name: 'autoButtonServices' }); - - const Config = new ConfigController(scriptName, { - version: scriptVersion, - store: { - customButtons: {} - }, - settings: { - // 0.6.x Setting additions - report: { - type: 'string', - range: [ 'off', 'gm', 'control', 'all' ], - rangeLabels: [ 'Off', 'GM', 'Character', 'Public' ], - validate: function(v) { return this.range.find(r => r.toLowerCase() === v.toLowerCase()) }, - default: 'Off', - name: `Report changes`, - description: `Report hitpoint changes to chat`, - menuAction: `$--report`, - }, - // - ...defaultScriptSettings, - }, - }); - Services.register({serviceName: 'config', serviceReference: Config }); - - const ButtonStore = new ButtonController({ - name: 'ButtonStore', - defaultButtons: _defaultButtons, - services: [Services.config], - }); - Services.register({ serviceName: 'buttons', serviceReference: ButtonStore }); - - const CLI = new CommandLineInterface({ - name: `autoButtonsMenu`, - options: defaultCliOptions, - }); - Services.register({ serviceName: 'cli', serviceReference: CLI }); - - // v0.6.x CLI additions - CLI.addOptions([ - { - name: 'report', - rx: /^report/i, - description: `Change settings for reporting HP changes to chat`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - const newVal = `${args}`.replace(/\W/g, '').toLowerCase(); - return this.config.changeSetting('report', newVal); - } - }, - ]); - // - - // Check install and version - const checkInstall = () => { - let firstTimeSetup; - setTimeout(() => { if (!/object/i.test(typeof(['token-mod']))) return sendChat(scriptName, `/w gm
tokenMod not found - this script requires tokenMod to function! Aborting init...
`), 500 }); - if (!state[scriptName] || !state[scriptName].version ) { - log(`autoButtons: first time setup...`); - firstTimeSetup = 1; - state[scriptName] = Config.initialState(); - } - if (typeof(state[scriptName].version) === 'number' && state[scriptName].version % 1 !== 0) { state[scriptName].version = `${state[scriptName].version}`.replace(/\D/g, '').split('', 3).join('.') } - if (state[scriptName].version < Config.version) { - const v = state[scriptName].version; - if (v < `0.1.3`) { /* 0.5.3 fix - bad key names for very old versions */ - Object.assign(state[scriptName].settings, { ignoreAPI: 1 }); // new Config key - } - if (v < `0.2.0`) { - Object.assign(state[scriptName].settings, { overkill: 0, overheal: 0, enabledButtons: [] }); // new Config keys - } - if (v < `0.3.0`) { - Config.loadPreset(); // structure of preset has changed - reload - } - if (v < `0.5.0`) { // major refactor - move buttons over to new button store - Helpers.copyOldButtonStore(); - state[scriptName].settings.bump = state[scriptName].settings.bump || true; - state[scriptName].settings.targetTokens = state[scriptName].settings.targetTokens || false; - } - if (v < `0.6.0`) { - // Remove the old buttons store - if (state[scriptName].settings.buttons && state[scriptName].store) delete state[scriptName].settings.buttons; - // Update template property structure - if (state[scriptName].settings.templates.damageProperties.damage && !state[scriptName].settings.templates.damageProperties.damageFields) { - state[scriptName].settings.templates.damageProperties.damageFields = state[scriptName].settings.templates.damageProperties.damage; - delete state[scriptName].settings.templates.damageProperties.damage; - state[scriptName].settings.templates.damageProperties.critFields = state[scriptName].settings.templates.damageProperties.crit; - delete state[scriptName].settings.templates.damageProperties.crit; - } - } - state[scriptName].version = Config.version; - log(`***UPDATED*** ====> ${scriptName} to v${Config.version}`); - } - Config.fetchFromState(); - if ( - (!Config.getSetting('templates/names') || !Config.getSetting('templates/names').length) || - (!Config.getSetting('enabledButtons') || !Config.getSetting('enabledButtons').length)) { - // debug.log(`Loading preset...`); - if (firstTimeSetup) Config.loadPreset(); - else new ChatDialog({ title: `${scriptName} Install`, content:`No roll templates registered, or no buttons enabled. AutoButtons will not currently do anything. If you're still setting things up, this is probably ok, otherwise you may want to Reset to default sheet settings.` }, 'error'); - } - // Check state of buttons, repair if needed - if (!state[scriptName].store) Helpers.copyOldButtonStore(); - for (const button in state[scriptName].store.customButtons) { - state[scriptName].store.customButtons[button].default = false; - const { err } = ButtonStore.addButton(state[scriptName].store.customButtons[button]); - if (err) debug.error(`${err}`); - } - const allButtons = ButtonStore.getButtonNames(), - enabledButtons = Config.getSetting('enabledButtons'); - const validButtons = enabledButtons.filter(v => allButtons.includes(v)); - if (validButtons.length !== enabledButtons.length) { - debug.warn(`Invalid button found in enabledButtons - button hidden.`); - Config.changeSetting('enabledButtons', validButtons, { overwriteArray: true }); - } - log(`=( Initialised ${scriptName} - v${Config.version} )=`); - } - - // Send buttons to chat - const sendButtons = (damage, crit, msg) => { - const gmOnly = Config.getSetting('gmOnly') ? true : false; - let buttonHtml = '', - activeButtons = Config.getSetting(`enabledButtons`) || [], - name = Helpers.findName(msg.content); - name = name || `Apply:`; - activeButtons.forEach(btn => buttonHtml += ButtonStore.createApiButton(btn, damage, crit)); - const buttonTemplate = `
${name}
${buttonHtml}
`; - Helpers.toChat(`${buttonTemplate}`, gmOnly); - } - - // Deconstruct roll - const handleDamageRoll = (msg) => { - const dmgFields = Config.getSetting('templates/damageProperties/damageFields')||[], - critFields = Config.getSetting('templates/damageProperties/critFields')||[]; - const damage = Helpers.processFields(dmgFields, msg), - crit = Helpers.processFields(critFields, msg); - if ('dnd5e_r20' === Config.getSetting('sheet')) { - const isSpell = Helpers5e.is5eAttackSpell(msg.content); - if (isSpell) { - const upcastDamageFields = Config.getSetting('templates/damageProperties/upcastDamage')||[], - upcastCritFields = Config.getSetting('templates/damageProperties/upcastCrit')||[]; - damage.total += Helpers.processFields(upcastDamageFields, msg).total||0; - crit.total += Helpers.processFields(upcastCritFields, msg).total||0; - } - } - sendButtons(damage, crit, msg); - } - - // The input... it must be handled - const handleInput = (msg) => { - const msgIsGM = playerIsGM(msg.playerid); - if (msg.type === 'api' && msgIsGM && /^!autobut(ton)?s?\b/i.test(msg.content)) { - const cmdLine = (msg.content.match(/^![^\s]+\s+(.+)/i) || [])[1], - commands = cmdLine ? cmdLine.split(/\s*--\s*/g) : []; - commands.shift(); - debug.log(commands); - if (commands.length) CLI.assess(commands); - } - else if (msg.rolltemplate && Config.getSetting('templates/names').includes(msg.rolltemplate)) { - const ignoreAPI = Config.getSetting('ignoreAPI'); - if (ignoreAPI && /^api$/i.test(msg.playerid)) return; - handleDamageRoll(msg); - } - } - - // The Go button - checkInstall(); - on('chat:message', handleInput); - - } - - /** - * SCRIPT DATA - */ - - // TODO: Replace with PresetController class - const preset = { - dnd5e_r20: { - sheet: ['dnd5e_r20'], - templates: { - names: ['atkdmg', 'dmg', 'npcfullatk', 'npcdmg'], - damageProperties: { - damageFields: ['dmg1', 'dmg2', 'globaldamage'], - critFields: ['crit1', 'crit2', 'globaldamagecrit'], - upcastDamage: ['hldmg'], - upcastCrit: ['hldmgcrit'], - }, - }, - defaultButtons: ['damageCrit', 'damageFull', 'damageHalf', 'healingFull'], - // userButtons array, to save user button setup? - }, - custom: { - sheet: [], - templates: { - names: [], - damageProperties: { - damageFields: [], - critFields: [], - }, - }, - defaultButtons: [] - } - } - - // Styles for chat UI elements - const styles = { - error: `color: red; font-weight: bold;`, - outer: `position: relative; vertical-align: middle; font-family: pictos; display: block; background: #f4e6b6; border: 1px solid black; height: auto; line-height: 34px; text-align: center; border-radius: 2px;`, - rollName: `font-family: arial; font-size: 1.1rem; color: black; font-style:italic; position:relative; overflow: hidden; display: block; line-height: 1rem; margin: 2px 0px 1px 0px; white-space: nowrap; text-align: left; left: 2px;`, - buttonContainer: `display: inline-block; text-align: center; vertical-align: middle; line-height: 26px; margin: auto 5px auto 5px; height: 26px; width: 26px; border: #8c6700 1px solid; box-shadow: 0px 0px 3px #805200; border-radius: 5px; background-color: whitesmoke;`, - buttonShared: `background-color: transparent; border: none; padding: 0px; width: 100%; height: 100%; overflow: hidden; white-space: nowrap;`, - crit: `color: red; font-size: 1.5rem;`, - full: `color: darkred; font-size: 2.1rem;`, - half: `color: black; font-family: pictos three; font-size: 2rem; padding-top:1px;`, - healFull: `color: green; font-size: 2rem;`, - list: { - container: `font-size: 1.5rem; background: #41415c; border: 5px solid #1c7b74; border-radius: 3px; color: white; vertical-align: middle;`, - header: `text-align: center; font-weight: bold; padding: 6px 0px 6px 0px; border-bottom: solid 1px darkgray; line-height: 1.5em;`, - body: `padding: 8px 1rem 8px 1rem;`, - row: `vertical-align: middle; margin: 0.2em auto 0.2em auto; font-size: 1.2em; line-height: 1.4em;`, - name: `display: inline-block; vertical-align: middle; width: 60%; margin-left: 5%; overflow-x: hidden;`, - buttonContainer: ` display: inline-block; vertical-align: middle; width: 10%; text-align: center; line-height: 1.2em;`, - controls: { - common: `position: relative; font-family: pictos; display: inline-block; background-color: darkgray; padding: 0px; margin: 0px; border: 1px solid #c2c2c2; border-radius: 3px; width: 1.1em; height: 1.1em; line-height: 1.1em; font-size: 1.2em;`, - show: `color: #03650b;`, - hide: `color: #2a2a2a;`, - disabled: `color: gray; cursor: pointer;`, - delete: `color: darkred;`, - create: `display: inline-block; background-color: darkgray; padding: 0px; margin: 1rem 0; border: 1px solid #c2c2c2; border-radius: 3px; color: #066a66; padding: 2px 5px 2px 5px; font-size: 1.1em; line-height: 1.2em;`, - no: `position: absolute; left: 0.4em; font-weight: bold; font-family: arial;` - }, - footer: `text-align: center; font-weight: bold; padding: 6px 0px 6px 0px; border-top: solid 1px darkgray; line-height: 1.5em;`, - }, - table: { - outer: `overflow-x: auto; width: 100%;`, - table: `margin: 1rem auto; width: 95%; justify-content: center; border: 1px solid #7fb07f;`, - headerRow: ``, - row: `background-color: #5e5e63; margin: 0.5rem;`, - headerCell: ` text-align: center; font-size: 1.7rem; padding: 1rem; border-bottom: 1px solid #7fb07f;`, - cell: `padding: 0.2rem 1rem; line-height: 3.5rem;`, - rowBorders: `border-top: 1px solid #7fb07f;`, - footer: `margin: 0 auto 1.5rem auto;`, - settingName: `border: 1px solid whitesmoke; padding: 0.4rem 0; border-radius: 0.5rem; cursor: help;`, - button: ` display: inline-block; background-color: darkgray; border: 1px solid #cae1df; box-shadow: 0px 0px 3px #bcdbd8; border-radius: 3px; color: #045754; padding: 0.3rem 0.5rem; margin: 0.2rem 0!important; font-size: 1.1em; line-height: 1.2em;`, - }, - components: { - labelWithDelete: function(label, commandString) { - const styleOuter = `border: 1px solid whitesmoke; padding: 0.2rem 0rem; border-radius: 0.5rem; width: max-content; margin: auto; display: inline-block; line-height: 1.2rem; white-space: nowrap;`, - styleDelete = `font-family: pictos; color: darkred; background-color: gray; height: 1rem; line-height: 1.2rem; width: 1.2rem; text-align: center; margin: 0 1rem; border: 1px solid #aaa8a8; border-radius: 0.5rem;`, - styleLabel = `display: inline-block; overflow-x: clip; margin-left: 0.5rem;` - return `
${label}
*
` - }, - confirmApiCommand: function(confirmAction) { - return `!autobut?{Are you sure you wish to ${confirmAction}|Yes, |No,fffff}`; - }, - }, - report: ``, - // BUMP setting CSS - if Roll20 dick with the chatbar CSS this will need to be updated - mods: { - bump: `left: -5px; top: -30px; margin-bottom: -34px;` - } - } - - // Default buttons - const _defaultButtons = { - damageCrit: { - sheets: ['dnd5e_r20'], - tooltip: `Crit (%)`, - style: styles.crit, - math: (damage, crit) => -(damage.total + crit.total), - content: 'kk', - }, - damageFull: { - sheets: ['dnd5e_r20'], - tooltip: `Full (%)`, - style: styles.full, - math: (damage) => -(1 * damage.total), - content: 'k', - }, - damageHalf: { - sheets: ['dnd5e_r20'], - tooltip: `Half (%)`, - style: styles.half, - math: (damage) => -(Math.floor(0.5 * damage.total)), - content: 'b', - }, - healingFull: { - sheets: ['dnd5e_r20'], - tooltip: `Heal (%)`, - style: styles.healFull, - math: (damage) => (damage.total), - content: '&', - }, - }; - - // Global regex - const rx = { on: /\b(1|true|on)\b/i, off: /\b(0|false|off)\b/i }; - - // Helper functions - class Helpers { - // Process roll object according to rolltemplate fields - static processFields(fieldArray, msg) { - let output = {} - const rolls = msg.inlinerolls; - output.total = fieldArray.reduce((m, v) => { - const rxIndex = new RegExp(`{${v}=\\$\\[\\[\\d+`, 'g'), - indexResult = msg.content.match(rxIndex); - if (indexResult) { - const index = indexResult.pop().match(/\d+$/)[0], - total = isNaN(rolls[index].results.total) ? 0 : rolls[index].results.total; - output[v] = total; - return m + total; - } else { // if roll template property's inline roll is not found, return 0 to prevent errors down the line - output[v] = 0; - } - return m; - }, 0); - return output; - } - // Simple name finder, provided rolltemplate has some kind of 'name' property - static findName(msgContent) { - const rxName = /name=([^}]+)}/i; - let name = msgContent.match(rxName); - return name ? name[1] : null; - } - // sendChat shortcut - static toChat(msg, whisper = true) { - let prefix = whisper ? `/w gm ` : ''; - sendChat(scriptName, `${prefix}${msg}`, {noarchive: true}); - } - static toArray(inp) { return Array.isArray(inp) ? inp : [inp]; } - static emproper(inpString) { - let words = inpString.split(/\s+/g); - return words.map(w => `${w[0].toUpperCase()}${w.slice(1)}`).join(` `); - } - // Split {{handlebars=moustache}} notation to key value - static splitHandlebars(inputString) { - let output = {}, - kvArray = inputString.match(/{{[^}]+}}/g)||[]; - kvArray.forEach(kv => { - kv = kv.replace(/({{|}})/g, ''); - const key = kv.match(/^[^=]+/), - value = (kv.match(/=(.+)/)||[])[1] || ``; - if (key) output[key] = value; - }); - return Object.keys(output).length ? output : null; - } - // Camelise a name if user tries to use whitespace - static camelise(inp, options={enforceCase:false}) { - if (typeof(inp) !== 'string') return null; - const words = inp.split(/[\s_]+/g); - return words.map((w,i) => { - const wPre = i > 0 ? w[0].toUpperCase() : w[0].toLowerCase(); - const wSuf = options.enforceCase ? w.slice(1).toLowerCase() : w.slice(1); - return `${wPre}${wSuf}`; - }).join(''); - } - - static isObj(input) { return (typeof(input) === 'object' && input.constructor.name === 'Object') ? true : false } - - static copyObj(inputObj) { return (typeof inputObj !== 'object') ? null : JSON.parse(JSON.stringify(inputObj)); } - - static getObjectPath(pathString, baseObject, createPath, deleteTarget) { - const parts = pathString.split(/\/+/g); - const objRef = parts.reduce((m,v,i) => { - if (m == null) return; - if (m[v] == null) { - if (createPath) m[v] = {}; - else return null; - } - if (deleteTarget && (i === parts.length-1)) delete m[v]; - else return m[v];}, baseObject) - return objRef; - } - - // If value exists in array, it will be removed, otherwise it will be added. No validation done. Does not mutate the original. - static modifyArray(targetArray, newValue) { - if (!Array.isArray(targetArray || newValue == null)) return { err: `modifyArray error, bad parameters` }; - if (targetArray.includes(newValue)) { - Helpers.filterAndMutate(targetArray, (v) => v === newValue); - return { msg: `Removed ${newValue} from array.` } - } - else { - targetArray = targetArray.push(newValue); - return { msg: `Added ${newValue} to array.` } - } - } - - /** - * Filter an array by reference - * @param {array.} inputArray - * @param {function} predicate - * @return {boolean} success/failure - */ - static filterAndMutate(inputArray, predicate) { - if (typeof(predicate) !== 'function' || !Array.isArray(inputArray)) { - debug.error(`filterMutate requires an array and a predicate function.`); - return false; - } - for (let i=inputArray.length-1; i>=0; i--) { - if (predicate(inputArray[i])) inputArray.splice(i, 1); - } - return true; - } - - static copyOldButtonStore() { - let names = []; - state[scriptName].store = state[scriptName].store || {}; - state[scriptName].store.customButtons = Helpers.copyObj(state[scriptName].customButtons) || {}; // copy old store to new store - for (const button in state[scriptName].store.customButtons) { - state[scriptName].store.customButtons[button].name = state[scriptName].store.customButtons[button].name || button; - state[scriptName].store.customButtons[button].mathString = state[scriptName].store.customButtons[button].mathString || state[scriptName].store.customButtons[button].math; - names.push(state[scriptName].store.customButtons[button].name); - } - if (names.length) new ChatDialog({ title: 'Buttons copied to new version', content: names }); - } - } - - // 5e specific helpers - class Helpers5e { - // Spell detection - static is5eAttackSpell(msgContent) { - const rxSpell = /{spelllevel=(cantrip|\d+)/; - return rxSpell.test(msgContent) ? 1 : 0; - } - } - - // Default command line options - const defaultCliOptions = [ - { - name: 'bump', - rx: /^bump/i, - description: `Bump the button UI up to the top of the chat message`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('bump', args, { createPath: true, force: 'boolean' }) } - }, - { - name: 'targetTokens', - rx: /^targett/i, - description: `Use target instead of select for applying damage to tokens`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - const result = this.config.changeSetting('targetTokens', args, { createPath: true, force: 'boolean' }); - if (this.config.getSetting('targetTokens') && result.success && result.msg) result.msg.push(`*Important*: Players cannot use targeting unless TokenMod is set to allow players to use token ids.`); - return result; - } - }, - { - name: 'reset', - rx: /^reset/i, - description: `Reset configuration from preset`, - requiredServices: { config: 'ConfigController' }, - action: function () { - if (this.config.getSetting('sheet')) { - this.config.loadPreset(); - return { success: 1, msg: `Config reset from preset: "${this.config.getSetting('sheet')}"` }; - } else return { err: `No preset found!` }; - } - }, - { - name: 'bar', - rx: /^(hp)?bar/i, - description: `Select which token bar represents hit points`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - const newVal = parseInt(`${args}`.replace(/\D/g, '')); - if (newVal > 0 && newVal < 4) { - return this.config.changeSetting('hpBar', newVal); - } else return { err: `token bar value must be 1, 2 or 3`} - } - }, - { - name: 'loadPreset', - rx: /^loadpre/i, - description: `Select a preset for a Game System`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonController' }, - action: function (args) { - const newVal = args.trim(); - if (Object.keys(preset).includes(newVal)) { - const newSheet = this.config.changeSetting('sheet', newVal); - if (newSheet.msg) { - this.config.loadPreset(); - this.buttons.verifyButtons(); - return { success: 1, msg: `Preset changed: ${newVal}` }; - } else return { err: `Error changing preset to "${newVal}"` }; - } else return { err: `Coudln't find sheet/preset: ${args}` } - } - }, - { - name: 'listTemplates', - rx: /^(list)?templ/i, - description: `List roll templates the script is listening for`, - requiredServices: { config: 'ConfigController' }, - action: function () { - const templates = this.config.getSetting(`templates/names`), - confirm = styles.components.confirmApiCommand(`delete this template name?`), - templateText = Helpers.toArray(templates).map(v => [ - // `
${v}
`, - // `Delete` - styles.components.labelWithDelete(v, `${confirm}autobut --deleteTemplate ${v}`) - ]), - footerContent = `Add template`; - templateText.unshift([ 'Template name']); - new ChatDialog({ content: templateText, title: `Roll Template List`, footer: footerContent }, 'table'); - } - }, - { - name: 'addTemplate', - rx: /^addtem/i, - description: `Add roll template name to listen list for damage rolls`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - if (!this.config.getSetting('templates/names').includes(args)) { - const result = this.config.changeSetting('templates/names', args); - if (result.success) result.msg = `Added template ${args} to listener list`; - return result; - } - } - }, - { - name: 'removeTemplate', - rx: /^(remove|delete)tem/i, - description: `Remove roll template from listen list`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - if (this.config.getSetting('templates/names').includes(args)) { - const result = this.config.changeSetting('templates/names', args); - if (result.success) result.msg = `Removed template ${args} to listener list`; - return result; - } - } - }, - { - name: 'listProperties', - rx: /^(list)?(propert|props)/i, - description: `List roll template properties inline rolls are grabbed from`, - requiredServices: { config: 'ConfigController' }, - action: function () { - const properties = this.config.getSetting('templates/damageProperties'), - confirm = styles.components.confirmApiCommand(`delete this template property?`), - styleCategory = `font-size: 1.4rem; font-weight: bold; font-style: italic;` - let templateText = [ ['Category', 'Properties'] ]; - if (typeof properties === 'object') { - for (let category in properties) { - const propButtons = properties[category].map(prop => styles.components.labelWithDelete(prop, `${confirm}autobut --deleteprop ${category}/${prop}`)); - templateText.push([ - `${category}`, - `${propButtons.join(`
`)}
Add Property` - ]); - } - } else return { err: `Error getting damage properties from state` } - new ChatDialog({ title: 'Roll Template Properties', content: templateText, borders: { row: true } }, 'table'); - } - }, - { - name: 'addProperty', - rx: /^addprop/i, - description: `Add a roll template property to the listener`, - requiredServices: { config: 'ConfigController', }, - action: function (args) { - const parts = args.match(/([^/]+)\/(.+)/); - if (parts && parts.length === 3) { - if (this.config.getSetting(`templates/damageProperties/${parts[1]}`) == null) { - Helpers.toChat(`Created new roll template damage property category: ${parts[1]}`); - state[scriptName].settings.templates.damageProperties[parts[1]] = []; - } - return this.config.changeSetting(`templates/damageProperties/${parts[1]}`, parts[2]); - } else { - return { err: `Bad property path supplied, must be in the form "category/propertyName". Example: damage/dmg1` }; - } - } - }, - { - name: 'removeProperty', - rx: /^(remove|delete)?prop/i, - description: `Remove a roll template property from the listener`, - requiredServices: { config: 'ConfigController', }, - action: function (args) { - const parts = args.match(/([^/]+)\/(.+)/); - if (parts && parts.length === 3) { - const currentArray = this.config.getSetting(`templates/damageProperties/${parts[1]}`); - if (currentArray != null) { - const result = this.config.changeSetting(`templates/damageProperties/${parts[1]}`, parts[2]); - if (result.success && !/^(damage|crit)$/i.test(parts[1])) { // Clean up category if it's now empty, and isn't a core category - const newArray = this.config.getSetting(`templates/damageProperties/${parts[1]}`); - if (newArray.length === 0) { - delete state[scriptName].settings.templates.damageProperties[parts[1]]; - result.msg += `\nCategory ${parts[1]} was empty, and was removed.`; - } - } - return result; - } else return { err: `Could not find roll template property category: ${parts[1]}` } - } else { - return { err: `Bad property path supplied, must be in the form "category/propertyName". Example: damage/dmg1` } - } - } - }, - { - name: 'listButtons', - rx: /^(list)?button/i, - description: `List available buttons`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonController' }, - action: function() { - const removableButtons = this.buttons.getButtonNames({ default: false }), - usedButtons = this.config.getSetting('enabledButtons'), - unusedButtons = this.buttons.getButtonNames({ hidden: true }), - availableButtons = this.buttons.getButtonNames({ hidden: true, currentSheet: true }), - reorderedButtons = usedButtons.concat(unusedButtons); - const links = { - hide: `!autoButton --hideButton %name%`, - show: `!autoButton --showButton %name%`, - delete: `!autoButton --deleteButton %name%`, - disabled: `#` - } - const labels = { - hide: `E/`, - show: 'E', - delete: 'D', - disabled: '!' - }; - const controls = ['show', 'hide', 'delete']; - const listBody = reorderedButtons.map(button => { - let rowHtml = `
${removableButtons.includes(button) ? '' : '*'}%name%
`; - controls.forEach(control => { - const controlType = ( - (control === 'show' && availableButtons.includes(button)) || - (control === 'hide' && usedButtons.includes(button)) || - (control === 'delete' && removableButtons.includes(button))) ? - control : 'disabled'; - rowHtml += ``; - }); - return `${rowHtml.replace(/%name%/g, button)}
`; - }); - const headerText = `autoButton list (sheet: ${this.config.getSetting('sheet')})`, - bodyText = listBody.join(''), - footerText = `Create New Button`; - new ChatDialog({ header: headerText, body: bodyText, footer: footerText }, 'listButtons'); - }, - }, - { - name: 'showButton', - rx: /^showbut/i, - description: `Add a button to the template`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonController' }, - action: function (args) { - const newVal = args.trim(); - const validButtons = this.buttons.getButtonNames({ hidden: true, currentSheet: true }); - if (validButtons.includes(newVal)) { - return this.config.changeSetting('enabledButtons', newVal); - } else new ChatDialog({ title: 'Error', content: `Unrecognised or incompatible button: "${newVal}"` }, 'error'); - } - }, - { - name: 'hideButton', - rx: /^hidebut/i, - description: `Remove a button from the template`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonController' }, - action: function (args) { - const newVal = args.trim(); - const validButtons = this.buttons.getButtonNames({ shown: true, currentSheet: true }); - if (validButtons.includes(newVal)) { - return this.config.changeSetting('enabledButtons', newVal); - } else new ChatDialog({ title: 'Error', content: `Unrecognised or incompatible button: "${newVal}"` }, 'error'); - } - }, - { - name: 'reorderButtons', - rx: /^(re)?order/i, - description: `Change order of buttons`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - if (!args) return; - const newIndices = args.replace(/[^\d,]/g, '').split(/,/g), - currentOrder = this.config.getSetting('enabledButtons'); - let newOrder = []; - let valid = true; - newIndices.forEach(buttonIndex => { - const realIndex = buttonIndex - 1; - if (realIndex > -1 && realIndex < currentOrder.length) { - if (currentOrder[realIndex]) { - newOrder.push(currentOrder[realIndex]); - currentOrder[realIndex] = null; - } - } else valid = false; - }); - if (!valid) return { err: `Invalid button order input: ${args}. Indices must be between 1 and total number of buttons in use.` } - newOrder = newOrder.concat(currentOrder.filter(v => v)); - if (newOrder.length === currentOrder.length) return this.config.changeSetting('enabledButtons', newOrder, { overwriteArray: true }); - } - }, - { - name: 'createButton', - rx: /^createbut/i, - description: `Create a new button`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonController' }, - action: function (args) { - const buttonData = Helpers.splitHandlebars(args); - if (buttonData && buttonData.name) { - if (/^[^A-Za-z]/.test(buttonData.name)) return { err: `Invalid button name: must start with a letter` }; - let buttonName = /\s/.test(buttonData.name) ? Helpers.camelise(buttonData.name) : buttonData.name; - if (this.buttons.getButtonNames().includes(buttonName)) return { err: `Invalid button name, already in use: "${buttonName}"` } - if (!buttonData.math) return { err: `Button must have an associated function, {{math=...}}` } - buttonData.default = false; - return this.buttons.addButton(buttonData); - } else return { err: `Bad input for button creation` } - } - }, - { - name: 'editButton', - rx: /^editbut/i, - description: `Edit an existing button`, - requiredServices: { buttons: 'ButtonController' }, - action: function (args) { - let buttonData = Helpers.splitHandlebars(args); - debug.log(buttonData); - if (buttonData && buttonData.name) { - if (this.buttons.getButtonNames().includes(buttonData.name)) { - return this.buttons.editButton(buttonData); - } - } - } - }, - { - name: 'deleteButton', - rx: /^del(ete)?but/i, - description: `Remove a button`, - requiredServices: { buttons: 'ButtonController', config: 'ConfigController' }, - action: function (args) { - const removeResult = this.buttons.removeButton({ name: args }), - buttonIsEnabled = this.config.getSetting('enabledButtons').includes(args); - if (removeResult.success) { - if (buttonIsEnabled) this.config.changeSetting('enabledButtons', args); - return removeResult; - } else return removeResult; - } - }, - { - name: 'ignoreApi', - rx: /^ignoreapi/i, - description: `Ignore anything sent to chat by the API`, - requiredServices: { config: 'ConfigController' }, - action: function(args) { return this.config.changeSetting('ignoreAPI', args) } - }, - { - name: 'overheal', - rx: /^overh/i, - description: `Allow healing to push hp above hpMax`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('overheal', args) } - }, - { - name: 'overkill', - rx: /^overk/i, - description: `Allow healing to push hp above hpMax`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('overkill', args) } - }, - { - name: 'gmOnly', - rx: /^gmo/i, - description: `Whisper the buttons to GM, or post publicly`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('gmOnly', args) } - }, - { - name: 'settings', - rx: /^setting/i, - description: `Open settings UI`, - requiredServices: { config: 'ConfigController' }, - action: function() { this.config.getSettingsMenu() } - }, - { - name: 'help', - rx: /^(\?$|h$|help)/i, - description: `Display script help`, - action: function() { new ChatDialog({ title: `Script Help`, content: `Please visit the autoButtons thread for documentation.` }) } - }, - { - name: 'uninstall', - rx: /^uninstall$/i, - description: `Remove all script settings from API state`, - action: function(args) { - if (/^undo/i.test(args)) { - state[scriptName] = Helpers.copyObj(undoUninstall); - new ChatDialog({ title: 'Reverse! Reverse the reverse!', content: `State settings have been restored. Let's pretend that never happend, eh?` }, 'error') - } else if (!undoUninstall) { - undoUninstall = Helpers.copyObj(state[scriptName]); - state[scriptName] = null; - delete state[scriptName]; - new ChatDialog({ - header: `${scriptName} uninstalled!`, - body: `Removed all ${scriptName} settings from API state. Click the 'whoopsie' button below if you didn't mean to destroy all your settings! All settings will be *permantently* lost on sandbox restart.`, - footer: `Oh Shit!`, - }, 'listButtons'); - } - } - } - ]; - - /** - * Must have a valid type to be pulled into SettingsManager as a setting - * 'object' type can be used for nesting settings keys - * 'validate' is a validator for the input, not necessarily the key itself (e.g. an array might accept strings in the validator) - * 'name'/'description' are only used for chat menu - * 'menuAction' must be supplied. Starting with '$' will automatically convert into a button, otherwise supply actual text required - */ - const defaultScriptSettings = { - sheet: { - type: 'string', - range: ['dnd5e_r20', 'custom'], - rangeLabels: [ 'DnD5e by Roll20', 'Custom' ], - validate: function(v) { return this.range.includes(v) }, - default: 'dnd5e_r20', - name: 'Character sheet', - description: 'Character sheet in use', - menuAction: `$--loadPreset` - }, - templates: { - type: 'object', - names: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [], - name: `Roll templates & properties`, - description: `Names of roll templates & properties watched by autoButtons`, - menuAction: `Templates
Properties`, - }, - damageProperties: { - type: 'object', - damageFields: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [], - }, - critFields: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [] - }, - upcastDamage: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [] - }, - upcastCrit: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [] - }, - get value() { - const output = {}; - for (const key in this) { - if (key === 'value') continue; - if (this[key].value) output[key] = this[key].value; - } - return output; - } - }, - }, - enabledButtons: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [], - }, - gmOnly: { - type: 'boolean', - default: true, - name: `GM-only buttons`, - description: `Whether the buttons are visible to players`, - menuAction: `$--gmo`, - }, - hpBar: { - type: 'integer', - range: [1,2,3], - validate: function(v) { return this.range.includes(v) }, - default: 1, - name: `Token HP bar`, - description: `Which token bar contains hit points`, - menuAction: `$--bar`, - }, - ignoreAPI: { - type: 'boolean', - default: true, - name: `Ignore API posts`, - description: `Ignore any automated damage rolls made by scripts`, - menuAction: `$--ignoreapi`, - }, - overheal: { - type: 'boolean', - default: false, - name: `Allow overheal`, - description: `Allow HP to go above max`, - menuAction: `$--overheal`, - }, - overkill: { - type: 'boolean', - default: false, - name: `Allow overkill`, - description: `Allow HP to go below 0`, - menuAction: `$--overkill`, - }, - targetTokens: { - type: 'boolean', - default: false, - name: `Target tokens`, - description: `Use a target click to target token, instead of current selection`, - menuAction: `$--targettoken`, - }, - bump: { - type: 'boolean', - default: true, - name: `Slim buttons`, - description: `CSS to bump the button container up in chat to save some space`, - menuAction: `$--bump`, - } - } - - /** - * CLASS DEFINITIONS - */ - - class ServiceLocator { - - static _active = null; - _services = {}; - - constructor(services={}) { - if (ServiceLocator._active) return ServiceLocator._active; - this.name = `ServiceLocator`; - for (let svc in services) { this._services[svc] = services[svc] } - ServiceLocator._active = this; - } - - static getLocator() { return ServiceLocator._active } - - register({ serviceName, serviceReference }) { if (!this._services[serviceName]) this._services[serviceName] = serviceReference } - - // Find a service. If service has multiple instances, make sure to request by instance name, or only the first registered constructor name will be returned. - // Search by Class Constructor Name is only suitable for unique class instances - getService(serviceName) { - if (this._services[serviceName]) return { [serviceName]: this._services[serviceName] } - else { - const rxServices = new RegExp(`${serviceName}`, 'i') - for (let service in this._services) { - if (this._services[service].constructor && rxServices.test(this._services[service].constructor.name)) return this._services[service]; - } - } - } - } - - class SettingsManager { - - _settingsKeys = {}; - - constructor(settingsData = {}) { - const processObject = (currentObject, targetPath) => { - for (const key in currentObject) { - if (!currentObject[key].type) { - debug.log(`Skipping ${key}, no type found`); - continue; - } - debug.log(`Processing ${key}...`); - if (currentObject[key].type === 'object' && Helpers.isObj(currentObject[key])) { - targetPath[key] = currentObject[key]; - processObject(currentObject[key], targetPath[key]); - } - else if (this._validateKey(currentObject[key], currentObject[key].default)) { - targetPath[key] = currentObject[key]; - targetPath[key].value = currentObject[key].default; - } - else debug.warn(`${this.constructor.name}: Bad key used in constructor: ${key} default value does not match specified type`, currentObject[key]); - } - } - processObject(settingsData, this._settingsKeys); - debug.log(this._settingsKeys); - } - - get settingsKeys() { return this._settingsKeys } - - // Validate a settings key and the stored value - _validateKey(settingsKey, settingsValue) { - if (!settingsKey) return false; - // debug.log(`Validating ${settingsValue}...`); - const passValidation = ( - settingsKey.type === 'array' && Array.isArray(settingsValue) - || ['float', 'integer', 'number'].includes(settingsKey.type) && typeof(settingsValue) === 'number' - || typeof(settingsValue) === settingsKey.type - ) ? true : false; - // debug.log(passValidation); - return passValidation; - } - - - // Validate an input to be stored in a settings key (e.g. may be a primitive value to be stored in an object type key) - // Returns undefined for failed validation, otherwise returns value ready for storage - _validateNewValue(settingsKey, newValue, options = { forceValidation: null }) { - if (!settingsKey || typeof(settingsKey) !== 'object' || !settingsKey.type || newValue === undefined) return debug.error(`${this.constructor.name}: Bad settings key`, settingsKey); - // Handle keys with validators (Objects and Arrays must have a validator since they can't be passed from Roll20) - if (typeof(options.forceValidation) === 'function') { - if (options.forceValidation(newValue)) return newValue; - else return undefined; - } - else if (typeof(settingsKey.validate) === 'function') { - if (settingsKey.validate(newValue)) return newValue; - else return undefined; - } - // Handle booleans - else if (settingsKey.type === 'boolean') { - if (rx.on.test(newValue)) return true; - else if (rx.off.test(newValue)) return false; - else return undefined; - } - // Otherwise, type match - else if (settingsKey.type === 'integer' && parseInt(newValue) === parseInt(newValue)) return parseInt(newValue); - else if (settingsKey.type === 'float' && parseFloat(newValue) === parseFloat(newValue)) return parseFloat(newValue); - else if (settingsKey.type === typeof(newValue)) return newValue; - else return undefined; - } - - _writeSetting(settingsKey, newValue, options = { overwriteArray: false }) { - const validationOptions = (options.overwriteArray) ? { forceValidation: (v) => Array.isArray(v) } : {}, - validData = this._validateNewValue(settingsKey, newValue, validationOptions); - if (validData === undefined) { - debug.error(`${this.constructor.name}: Settings change not applied, value failed validation`, settingsKey, newValue); - return { err: `${this.constructor.name}: Settings change not applied, value failed validation` } - } - else { - if (settingsKey.type === 'array') { - if (options.overwriteArray && Array.isArray(newValue)) { - settingsKey.value = newValue; - return { msg: `Saved new Array: [${newValue.join(', ')}]` } - } - else return Helpers.modifyArray(settingsKey.value, newValue); - } - else { - settingsKey.value = newValue; - return { msg: `Saved value: ${newValue}` } - } - } - } - - importSettingsValues(importedKeys = {}) { - if (typeof(importedKeys) !== 'object') return debug.error(`${this.constructor.name}: Bad settings import, must only supply object type`); - const processObject = (currentObject, targetPath) => { - for (const key in currentObject) { - if (targetPath[key]) { - if (!targetPath[key].type) { - debug.log(`Skipping ${key}, no type defined`); - continue; - } - if (targetPath[key].type === 'object' && Helpers.isObj(currentObject[key])) { - processObject(currentObject[key], targetPath[key]); - } - else if (this._validateKey(targetPath[key], currentObject[key])) { - targetPath[key].value = currentObject[key]; - } - else debug.warn(`${this.constructor.name}: Key "${key}" failed validation`, currentObject[key]); - } - else debug.warn(`${this.constructor.name}: Key "${key}" does not exist.`, currentObject[key]); - } - } - processObject(importedKeys, this._settingsKeys); - debug.log(this._settingsKeys); - } - - exportSettingsValues() { - const output = {}; - const processObject = (currentObject, targetPath) => { - for (const key in currentObject) { - if (currentObject[key].type === 'object' && Helpers.isObj(currentObject[key])) { - targetPath[key] = {}; - processObject(currentObject[key], targetPath[key]); - } - else if (currentObject[key].type) { - targetPath[key] = currentObject[key].value; - } - } - } - processObject(this._settingsKeys, output); - return output; - } - - // Provide path relative to {Config._settings}, e.g. changeSetting('sheet', 'mySheet'); - // booleans with no "newValue" supplied will be toggled - // Use options.force 'type' to force a type on the setting e.g. array or boolean - // Combine with options.createPath: true to create a new setting of the correct type - updateSetting(pathString, newValue, options = { createPath: false, overwriteArray: false, force: null }) { - if (typeof(pathString) !== 'string' || newValue === undefined) return { err: `Bad path string or no new value supplied.` }; - // Can probably remove this bit now that a .value key is used - const keyName = (pathString.match(/[^/]+$/)||[])[0], - path = /.+\/.+/.test(pathString) ? pathString.match(/(.+)\/[^/]+$/)[1] : '', - configPath = path ? Helpers.getObjectPath(path, this._settingsKeys, options.createPath) : this._settingsKeys, - targetKey = configPath[keyName]; - if (targetKey) { - debug.log(`changeSetting - ${keyName}`, targetKey, options, newValue); - if (targetKey.type === 'boolean') { - newValue = (newValue == null || newValue === '') ? !targetKey.value : - rx.on.test(newValue) ? true : - rx.off.test(newValue) ? false : - newValue; - } - const result = this._writeSetting(targetKey, newValue, options); - if (result.msg) result.msg = `Changed setting: ${pathString}
${result.msg}`; - else if (result.err) result.err = `Changed setting: ${pathString}
${result.err}`; - return result; - } - else { - return { err: `Settings key not found - *${pathString}*` } - } - } - - readSetting(pathString) { - if (typeof(pathString) !== 'string') return; - const targetKey = Helpers.getObjectPath(pathString, this._settingsKeys, false); - return targetKey ? targetKey.value : undefined; - } - - // Export this._settingsKeys as chatbar-friendly text - getMenuText() { - const output = []; - const processObject = (currentObject, targetOutput) => { - for (const key in currentObject) { - if (currentObject[key].type === 'object') { - processObject(currentObject[key], targetOutput); - } - else if (currentObject[key].menuAction) { - const name = currentObject[key].name || key, - hover = currentObject[key].description ? `title="${currentObject[key].description}"` : ``, - settingName = `
${name}
`, - currentSetting = `${currentObject[key].value}`; - // Entry has a custom menu action - if (/^[^$]/.test(currentObject[key].menuAction)) { - targetOutput.push([ settingName, currentObject[key].menuAction ]); - } - // Autofill prompt for boolean or defined range - else { - const queryRange = - currentObject[key].type === 'boolean' ? ['True', 'False'] - : currentObject[key].range ? - currentObject[key].rangeLabels ? currentObject[key].range.map((v,i) => `${currentObject[key].rangeLabels[i]||v},${v}`) - : Helpers.toArray(currentObject[key].range) - : '', - queryString = queryRange ? `?{Select new value|${queryRange.join('|')}}` : `?{Enter new value}`, - cliFlag = (`${currentObject[key].menuAction}`.match(/^\$(.+)/)||[])[1] || `--${key}`, - commandString = `!${scriptName} ${cliFlag} ${queryString}`; - targetOutput.push([ settingName, `${currentSetting}`]); - } - } - } - } - processObject(this._settingsKeys, output); - return output; - } - - } - - class ConfigController { - - _version = { M: 0, m: 0, p: 0 }; - - constructor(scriptName, scriptData={}) { - Object.assign(this, { - name: scriptName || `newScript`, - _settings: new SettingsManager(scriptData.settings) || {}, - _store: scriptData.store || {}, - }); - if (scriptData.version) this.version = scriptData.version; - } - - get version() { return `${this._version.M}.${this._version.m}.${this._version.p}` } - set version(newVersion) { - if (typeof(newVersion) === 'object' && newVersion.M && newVersion.m && newVersion.p) Object.assign(this._version, newVersion); - else { - const parts = `${newVersion}`.split(/\./g); - if (!parts.length) debug.error(`Bad version number, not setting version.`) - else Object.keys(this._version).forEach((v,i) => this._version[v] = parseInt(parts[i]) || 0); - } - } - - initialState() { - return { - version: this.version, - settings: this._settings.exportSettingsValues(), - store: this._store - } - } - - fromStore(path) { return Helpers.getObjectPath(path, this._store, false) } - toStore(path, data) { // Supplying data=null will delete the target - const ref = Helpers.getObjectPath(path, this._store, true); - let msg; - if (ref) { - if (data) { - Object.assign(ref, data); - msg = `New data written to "${path}"`; - } else if (data === null) { - Helpers.getObjectPath(path, this._store, false, true); - msg = `${path} deleted from store.`; - } else return { success: 0, err: `Bad data supplied (type: ${typeof data})` } - } else return { success: 0, err: `Bad store path: "${path}"` } - this.saveToState(); - return { success: 1, msg: msg } - } - - fetchFromState() { - Object.assign(this, { _store: state[scriptName].store, }); - this._settings.importSettingsValues(state[scriptName].settings); - } - saveToState() { - Object.assign(state[scriptName], { - settings: this._settings.exportSettingsValues(), - store: this._store, - }); - } - - changeSetting(pathString, newValue, options) { - options = typeof(options) === 'object' ? options : undefined; - const result = this._settings.updateSetting(pathString, newValue, options); - debug.log(`Setting change attempted`, result); - if (result.msg) this.saveToState(); - return result; - } - getSetting(pathString) { - const currentValue = this._settings.readSetting(pathString); - return (typeof currentValue === 'object') ? Helpers.copyObj(currentValue) : currentValue; - } - loadPreset() { - const currentSheet = this.getSetting('sheet') || ''; - if (Object.keys(preset).includes(currentSheet)) { - // Load template names - this._settings.updateSetting('templates/names', preset[currentSheet].templates.names, { overwriteArray: true }); - // Load damage properties - for (const key in preset[currentSheet].templates.damageProperties) { - // debug.info(`Processing ${key} in preset...`); - this._settings.updateSetting(`templates/damageProperties/${key}`, preset[currentSheet].templates.damageProperties[key], { overwriteArray: true }); - } - this._settings.updateSetting('enabledButtons', preset[currentSheet].defaultButtons || [], { overwriteArray: true }); - this.saveToState(); - return { res: 1, data: `${this.getSetting('sheet')}` } - } else return { res: 0, err: `Preset not found for sheet: "${currentSheet}"`} - } - getSettingsMenu() { - const menuOptions = this._settings.getMenuText(), - confirm = styles.components.confirmApiCommand(`reset to default sheet settings?`), - footerContent = `
Reset Sheet Settings`; - menuOptions.unshift(['Key', 'Setting']); - new ChatDialog({ title: `${scriptName} Settings`, content: menuOptions, footer: footerContent }, 'table'); - } - } - - class ButtonController { - - static _buttonKeys = ['sheets', 'content', 'tooltip', 'style', 'math', 'default', 'mathString']; - _locator = null; - _Config = {}; - _buttons = {}; - - constructor(data={}) { - Object.assign(this, { name: data.name || 'newButtonController' }); - // Requires access to a ConfigController - this._locator = ServiceLocator.getLocator() || this._locator; - this._Config = this._locator ? this._locator.getService('ConfigController') : null; - if (!this._Config) return {}; - for (let button in data.defaultButtons) { this._buttons[button] = new Button(data.defaultButtons[button], styles) } - } - - get keys() { return ButtonController._buttonKeys } - - getButtonNames(filters={ default: null, currentSheet: null, shown: null, hidden: null }) { - let buttons = Object.entries(this._buttons); - const sheet = this._Config.getSetting('sheet'), - enabledButtons = this._Config.getSetting('enabledButtons'); - if (typeof filters.default === 'boolean') buttons = buttons.filter(kv => kv[1].default === filters.default); - if (typeof filters.currentSheet === 'boolean') buttons = buttons.filter(kv => (!kv[1].sheets.length || sheet === 'custom' || (kv[1].sheets.includes(sheet) === filters.currentSheet))); - if (typeof filters.shown === 'boolean') buttons = buttons.filter(kv => (enabledButtons.includes(kv[0]) === filters.shown)); - if (typeof filters.hidden === 'boolean') buttons = buttons.filter(kv => (enabledButtons.includes(kv[0]) === !filters.hidden)); - const output = buttons.map(kv=>kv[0]); - // debug.log(`button names: ${output.join(', ')}`); - return output; - } - - static parseMathString(inputString) { - debug.log(inputString); - inputString = `${inputString}`; - let err = ''; - // Convert to JS - const formulaReplacer = { - '$1Math.floor': /([^.]|^)floor/ig, - '$1Math.ceil': /([^.]|^)ceil/ig, - '$1Math.round': /([^.]|^)round/ig, - '($1||0)': /((damage|crit)\.\w+)/ig, - } - // Very basic security, at least stops a `state = null` - const disallowed = [ /=/g, /\bstate\b/gi ]; - - disallowed.forEach(rx => { if (rx.test(inputString)) err += `Disallowed value in math formula: "${`${rx}`.replace(/(\\\w|\/)/g, '')}"` }); - - let newFormula = inputString; - for (let f in formulaReplacer) newFormula = newFormula.replace(formulaReplacer[f], f); - - // Create a test object - let damageKeys = inputString.match(/(damage|crit)\.(\w+)/g), - testKeys = {}; - damageKeys = damageKeys ? damageKeys.map(k => k.replace(/^[^.]*\./, '')) : []; - damageKeys.forEach(k => testKeys[k] = 5); - - let validate = false, - newFunc; - try { - newFunc = new Function(`damage`, `crit`, `return (${newFormula})`) - validate = isNaN(newFunc(testKeys, testKeys)) ? false : true; - } catch(e) { err += (`${scriptName}: formula failed validation`) } - - if (validate && !err) { - return newFunc; - } else { - return new Error(err); - } - } - addButton(buttonData={}) { - const newButton = buttonData.default === false ? new CustomButton(buttonData) : new Button(buttonData); - if (newButton.err) return { success: 0, err: newButton.err } - if (this._buttons[newButton.name]) return { success: 0, err: `Button "${newButton.name}" already exists` }; - this._buttons[newButton.name] = newButton; - debug.info(this._buttons); - this.saveToStore(); - return { success: 1, msg: `New Button "${newButton.name}" successfully created` } - } - editButton(buttonData={}) { - const modded = []; - if (!this._buttons[buttonData.name]) return { success: 0, err: `Button "${buttonData.name}" does not exist.` } - if (this._buttons[buttonData.name].default) return { success: 0, err: `Cannot edit default buttons.` } - this.keys.forEach(k => { - if (buttonData[k] != null) { - if (k === 'default') return; // Don't allow reassignment of 'default' property - else if (k === 'math') { - const newMath = ButtonController.parseMathString(buttonData[k]); - if (newMath.err) return newMath; - else { - this._buttons[buttonData.name].mathString = buttonData[k]; - this._buttons[buttonData.name].math = newMath; - modded.push(k); - } - } - else if (k === 'style') { - this._buttons[buttonData.name].style = styles[buttonData[k]] || buttonData[k] || ''; - modded.push(k); - } - else { - this._buttons[buttonData.name][k] = buttonData[k]; - modded.push(k); - } - } - }); - if (modded.length) this.saveToStore(); - return modded.length ? { success: 1, msg: `Modified ${buttonData.name} fields: ${modded.join(', ')}` } : { success: 0, err: `No fields supplied.` } - } - removeButton(buttonData={}) { - if (!this._buttons[buttonData.name]) return { success: 0, err: `Button "${buttonData.name}" does not exist.` } - if (this._buttons[buttonData.name].default) return { success: 0, err: `Cannot delete default buttons.` } - delete this._buttons[buttonData.name]; - this._Config.toStore(`customButtons/${buttonData.name}`, null); - return { success: 1, msg: `Removed "${buttonData.name}".` } - } - showButton(buttonName) { - if (this._buttons[buttonName] && !this._Config.getSetting('enabledButtons').includes(buttonName)) { return this._Config.changeSetting('enabledButtons', buttonName) } - } - hideButton(buttonName) { - if (this._buttons[buttonName] && this._Config.getSetting('enabledButtons').includes(buttonName)) { return this._Config.changeSetting('enabledButtons', buttonName) } - } - saveToStore() { - const customButtons = this.getButtonNames({default: false}); - customButtons.forEach(button => this._Config.toStore(`customButtons/${button}`, Helpers.copyObj(this._buttons[button]))); - // debug.log(this._Config.fromStore('customButtons')); - } - _getReportTemplate(barNumber) { - const template = `'*({name}) {bar${barNumber}_value;before}HP -> {bar${barNumber}_value}HP*'`; - return template; - // Styled report template for if Aaron implements decoding in TM - // const templateRaw = `'
{name}: {bar1_value:before}HP >> {bar1_value}HP
'`; - // return encodeURIComponent(templateRaw); - } - createApiButton(buttonName, damage, crit) { - const btn = this._buttons[buttonName], - bar = this._Config.getSetting('hpBar'), - overheal = this._Config.getSetting('overheal'), - overkill = this._Config.getSetting('overkill'), - sendReport = (this._Config.getSetting('report')||``).toLowerCase(), - reportString = [ 'all', 'gm', 'control' ].includes(sendReport) - ? ` --report ${sendReport}|${this._getReportTemplate(bar)}` - : ``; - console.info(reportString); - if (!btn || typeof(btn.math) !== 'function') { - debug.error(`${scriptName}: error creating API button ${buttonName}`); - return ``; - } - const modifier = btn.math(damage, crit), - tooltip = btn.tooltip.replace(/%/, `${modifier} HP`), - tokenModCmd = (modifier > 0) ? (!overheal) ? `+${modifier}!` : `+${modifier}` : (modifier < 0 && !overkill) ? `${modifier}!` : modifier, - selectOrTarget = (this._Config.getSetting('targetTokens') === true) ? `--ids @{target|token_id} ` : ``; - return `
${btn.content}
`; - } - verifyButtons() { - const currentSheet = this._Config.getSetting('sheet'), - currentButtons = this._Config.getSetting('enabledButtons'), - validButtons = currentButtons.filter(button => { - if (currentSheet === 'custom' || this._buttons[button] && this._buttons[button].sheets.includes(currentSheet)) return 1; - }); - if (validButtons.length !== currentButtons.length) { - const { success, msg, err } = this._Config.changeSetting('enabledButtons', validButtons); - if (success && msg) new ChatDialog({ content: msg, title: 'Buttons Changed' }); - else if (err) new ChatDialog({ content: err }, 'error'); - } - } - } - - class Button { - constructor(buttonData={}, styleData=styles) { - Object.assign(this, { - name: buttonData.name || 'newButton', - sheets: Array.isArray(buttonData.sheets) ? buttonData.sheets : [], - tooltip: `${buttonData.tooltip || ''}`, - style: styleData[buttonData.style] || buttonData.style || '', - content: buttonData.content || '?', - math: buttonData.math || null, - mathString: buttonData.mathString || buttonData.math.toString(), - default: buttonData.default === false ? false : true, - }); - debug.log(this); - if (typeof(this.math) !== 'function') return { err: `Button "${this.name}" math function failed validation` }; - } - } - - class CustomButton extends Button { - constructor(buttonData={}) { - debug.log(buttonData); - if (!buttonData.math && !buttonData.mathString) return { err: `Button must contain a function in 'math' key.` }; - Object.assign(buttonData, { - name: buttonData.name || 'newCustomButton', - mathString: buttonData.mathString || buttonData.math.toString(), - math: ButtonController.parseMathString(buttonData.mathString || buttonData.math.toString()), - style: buttonData.style || 'full', - default: false, - }); - super(buttonData); - } - } - - class CommandLineInterface { - - _locator = null; - _services = {}; - _options = {}; - - constructor(cliData={}) { - this.name = cliData.name || `Cli`; - this._locator = ServiceLocator.getLocator(); - if (!this._locator) debug.warn(`${this.constructor.name} could not find the service locator. Any commands relying on services will be disabled.`); - Object.assign(this._services, { - config: this._locator.getService('ConfigController'), - buttons: this._locator.getService('ButtonController'), - cli: this, - }); - if (cliData.options && cliData.options.length) this.addOptions(cliData.options); - debug.log(`Initialised CLI`); - } - - // Add one or more options to the CLI - addOptions(optionData) { - optionData = Helpers.toArray(optionData); - optionData.forEach(data => { - if (data.name && !this._options[data.name]) { - const suppliedServices = { cli: this } - if (data.requiredServices) { - for (let service in data.requiredServices) { - const svc = - service === 'ConfigController' ? this._services.config - : service === 'ButtonController' ? this._services.buttons - : this._locator.getService(data.requiredServices[service]); - if (svc) suppliedServices[service] = svc; - else return debug.warn(`${this.name}: Warning - Service "${service}" could not be found for option ${data.name}. CLI option not registered.`); - } - } - data.services = suppliedServices; - this._options[data.name] = new CommandLineOption(data); - } else debug.warn(`Bad data supplied to CLI Option constructor`); - }); - } - - assess(commandArray, reportToChat = true) { - let changed = [], errs = []; - commandArray.forEach(command => { - const cmd = (command.match(/^([^\s]+)/)||[])[1], - args = (command.match(/\s+(.+)/)||['',''])[1]; - for (let option in this._options) { - if (this._options[option].rx.test(cmd)) { - const { msg, err } = (this._options[option].action(args) || {}); - // debug.log(msg||err); - if (msg) changed.push(Helpers.toArray(msg).join('
')); - if (err) errs.push(err); - } - } - }); - if (changed.length && reportToChat) { - // debug.info(changed); - const chatData = { - title: `${scriptName} settings changed`, - content: changed - }; - new ChatDialog(chatData); - } - if (errs.length) new ChatDialog( { title: 'Errors', content: errs }, 'error'); - } - - trigger(option, ...args) { if (this._options[option]) this._options[option].action(...args) } - - } - - class CommandLineOption { - - constructor(optionData={}) { - for (let service in optionData.services) { - this[service] = optionData.services[service]; - } - Object.assign(this, { - name: optionData.name || 'newOption', - rx: optionData.rx || new RegExp(`${optionData.name}`, 'i'), - description: optionData.description || `Description goes here...`, - action: optionData.action - }); - } - - } - - class ChatDialog { - - static _templates = { - none: ({content}) => `${content}`, - default: ({ title, content }) => { - const msgArray = content ? Helpers.toArray(content) : [], - body = msgArray.map(row => `
${row}
`).join('') - return ` -
-
${title||scriptName}
-
- ${body} -
-
`; - }, - table: ({ title, content, footer, borders }) => { - // debug.log(content); - const rowBorders = borders && borders.row ? styles.table.rowBorders : ``; - const msgArray = content ? Helpers.toArray(content) : [], - columns = msgArray[0].length || 1, - tableRows = msgArray.map((row,i) => { - const tc = i === 0 ? 'th' : 'td', - tcStyle = i === 0 ? styles.table.headerCell : `${styles.table.cell}${rowBorders}`, - trStyle = i === 0 ? styles.table.headerRow : styles.table.row; - let cells = ``; - for (let i=0; i < columns; i++) { cells += `<${tc} style="${tcStyle}">${row[i]}` } - return ` - - ${cells} - `; - }).join(''), - footerContent = footer ? `` : ``; - return ` -
-
${title||scriptName}
-
- - ${tableRows} -
-
- ${footerContent} -
- `; - }, - error: ({ title, content }) => { - const errArray = content ? Helpers.toArray(content) : []; - return ` -
-
${title}
-
${errArray.join('
')}
-
`; - }, - listButtons: ({ header, body, footer }) => { - return ` -
-
${header}
-
- ${body} -
- -
- `; - } - } - - constructor(message, template = 'default', autoSend = true) { - this.msg = ChatDialog._templates[template] ? ChatDialog._templates[template](message) : null; - if (this.msg) { - this.msg = this.msg.replace(/\n/g, ''); - if (autoSend) Helpers.toChat(this.msg); - } else { - debug.warn(`${scriptName}: error creating chat dialog, missing template "${template}"`); - return {}; - } - } - } - - on('ready', startScript); - -})(); \ No newline at end of file diff --git a/autoButtons/0.7.2/autoButtons.js b/autoButtons/0.7.2/autoButtons.js deleted file mode 100644 index f6a4b8babf..0000000000 --- a/autoButtons/0.7.2/autoButtons.js +++ /dev/null @@ -1,2157 +0,0 @@ -/* globals state log on sendChat playerIsGM findObjs */ //eslint-disable-line - -const autoButtons = (() => { // eslint-disable-line no-unused-vars - - const scriptName = `autoButtons`, - scriptVersion = `0.7.0`, - debugLevel = 2; - let undoUninstall = null; - - const debug = { - log: function(...args) { if (debugLevel > 3) console.log(...args) }, - info: function(...args) { if (debugLevel > 2) console.info(...args) }, - warn: function(...args) { if (debugLevel > 1) console.warn(...args) }, - error: function(...args) { if (debugLevel > 0) console.error(...args) }, - } - - /** - * INIT SCRIPT & SETTINGS/CLI ADDITIONS FROM LAST MINOR VERSION - */ - const startScript = () => { - - const Services = new ServiceLocator({ name: 'autoButtonServices' }); - - const Config = new ConfigController(scriptName, { - version: scriptVersion, - store: { - customButtons: {} - }, - settings: { - // 0.6.x => 0.7.0 Setting additions - darkMode: { - type: 'boolean', - default: false, - name: `Dark Mode`, - description: `Palette change for the button bar`, - menuAction: `$--darkMode`, - }, - multiattack: { - type: 'boolean', - default: false, - name: `Multiattack`, - description: `Attempt to link the button bar label to the source attack for easy repeat rolls. 5e only.`, - menuAction: `$--multiattack`, - }, - allowNegatives: { - type: 'boolean', - default: false, - name: `Allow negatives`, - description: `Allow final results to be negative. This can cause healing to cause damage, or damage to heal`, - menuAction: `$--negatives`, - }, - autosort: { - type: 'boolean', - default: false, - name: `Sort buttons`, - description: `Auto sort buttons by unicode order`, - menuAction: `$--autosort`, - }, - autohide: { - type: 'boolean', - default: true, - name: `Autohide buttons`, - description: `Autohide buttons with 0 reported damage`, - menuAction: `$--autohide`, - }, - report: { - type: 'string', - range: [ 'off', 'gm', 'control', 'all' ], - rangeLabels: [ 'Off', 'GM', 'Character', 'Public' ], - validate: function(v) { return this.range.find(r => r.toLowerCase() === v.toLowerCase()) }, - default: 'Off', - name: `Report changes`, - description: `Report hitpoint changes to chat`, - menuAction: `$--report`, - }, - ...defaultScriptSettings, - }, - }); - Services.register({serviceName: 'config', serviceReference: Config }); - - const ButtonStore = new ButtonManager({ - name: 'ButtonStore', - defaultButtons: _defaultButtons, - services: [Services.config], - }); - Services.register({ serviceName: 'buttons', serviceReference: ButtonStore }); - - const CLI = new CommandLineInterface({ - name: `autoButtonsMenu`, - options: defaultCliOptions, - }); - Services.register({ serviceName: 'cli', serviceReference: CLI }); - // v0.6.x => 0.7.0 CLI additions - CLI.addOptions([ - { - name: `cloneButton`, - rx: /^clonebut/i, - description: `Clone a button`, - requiredServices: { buttons: 'ButtonManager' }, - action: function(args) { - const parts = args.trim().split(/\s+/g), - originalButtonName = parts[0], - cloneName = parts[1]; - return this.buttons.cloneButton(originalButtonName, cloneName); - } - }, - { - name: `renameButton`, - rx: /^renamebut/i, - description: `Rename a button (Custom buttons only)`, - requiredServices: { buttons: 'ButtonManager' }, - action: function(args) { - const parts = args.trim().split(/\s+/g), - originalButtonName = parts[0], - newName = parts[1]; - return this.buttons.renameButton(originalButtonName, newName); - } - }, - { - name: 'darkMode', - rx: /^dark/i, - description: `Palette change for the button bar`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('darkMode', args) } - }, - { - name: 'multiattack', - rx: /^multiat/i, - description: `Attempt to link the button bar label to the source attack for easy repeat rolls`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('multiattack', args) } - }, - { - name: 'allowNegatives', - rx: /^negative/i, - description: `Allow final results to be negative`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('allowNegatives', args) } - }, - { - name: 'autosort', - rx: /^autosort/i, - description: `Auto sort buttons by unicode order`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('autosort', args) } - }, - { - name: 'autohide', - rx: /^autohide/i, - description: `Autohide buttons with 0 reported damage`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('autohide', args) } - }, - { - name: 'report', - rx: /^report/i, - description: `Change settings for reporting HP changes to chat`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - const newVal = `${args}`.replace(/\W/g, '').toLowerCase(); - return this.config.changeSetting('report', newVal); - } - }, - ]); - - // Check install and version - const checkInstall = () => { - let firstTimeSetup; - setTimeout(() => { if (!/object/i.test(typeof(['token-mod']))) return sendChat(scriptName, `/w gm
tokenMod not found - this script requires tokenMod to function! Aborting init...
`), 500 }); - if (!state[scriptName] || !state[scriptName].version ) { - log(`autoButtons: first time setup...`); - firstTimeSetup = 1; - state[scriptName] = Config.initialState(); - } - if (typeof(state[scriptName].version) === 'number' && state[scriptName].version % 1 !== 0) { state[scriptName].version = `${state[scriptName].version}`.replace(/\D/g, '').split('', 3).join('.') } - if (state[scriptName].version < Config.version) { - const v = state[scriptName].version; - if (v < `0.1.3`) { /* 0.5.3 fix - bad key names for very old versions */ - Object.assign(state[scriptName].settings, { ignoreAPI: 1 }); // new Config key - } - if (v < `0.2.0`) { - Object.assign(state[scriptName].settings, { overkill: 0, overheal: 0, enabledButtons: [] }); // new Config keys - } - if (v < `0.3.0`) { - Config.loadPreset(); // structure of preset has changed - reload - } - if (v < `0.5.0`) { // major refactor - move buttons over to new button store - Helpers.copyOldButtonStore(); - state[scriptName].settings.bump = state[scriptName].settings.bump || true; - state[scriptName].settings.targetTokens = state[scriptName].settings.targetTokens || false; - } - if (v < `0.6.0`) { - // Remove the old buttons store - if (state[scriptName].settings.buttons && state[scriptName].store) delete state[scriptName].settings.buttons; - // Update template property structure - if (state[scriptName].settings.templates.damageProperties.damage && !state[scriptName].settings.templates.damageProperties.damageFields) { - state[scriptName].settings.templates.damageProperties.damageFields = state[scriptName].settings.templates.damageProperties.damage; - delete state[scriptName].settings.templates.damageProperties.damage; - state[scriptName].settings.templates.damageProperties.critFields = state[scriptName].settings.templates.damageProperties.crit; - delete state[scriptName].settings.templates.damageProperties.crit; - } - } - if (v < `0.7.0`) { - // Two default buttons renamed - damageCrit => crit, and damageFull => damage - const currentShownButtons = state[scriptName].settings.enabledButtons; - debug.warn(currentShownButtons); - if (currentShownButtons) { - const { oldDamage, oldCrit } = currentShownButtons.reduce((out, v, i) => v === 'damageCrit' ? { ...out, oldCrit: i } : v === 'damageFull' ? { ...out, oldDamage: i } : out, {}); - if (oldDamage != null) currentShownButtons[oldDamage] = 'damage'; - if (oldCrit != null) currentShownButtons[oldCrit] = 'crit'; - debug.warn(state[scriptName].settings.enabledButtons); - } - } - state[scriptName].version = Config.version; - log(`***UPDATED*** ====> ${scriptName} to v${Config.version}`); - } - Config.fetchFromState(); - if ( - (!Config.getSetting('templates/names') || !Config.getSetting('templates/names').length) || - (!Config.getSetting('enabledButtons') || !Config.getSetting('enabledButtons').length)) { - // debug.log(`Loading preset...`); - if (firstTimeSetup) Config.loadPreset(); - else new ChatDialog({ title: `${scriptName} Install`, content:`No roll templates registered, or no buttons enabled. AutoButtons will not currently do anything. If you're still setting things up, this is probably ok, otherwise you may want to Reset to default sheet settings.` }, 'error'); - } - // Check state of buttons, repair if needed - if (!state[scriptName].store) Helpers.copyOldButtonStore(); - for (const button in state[scriptName].store.customButtons) { - state[scriptName].store.customButtons[button].default = false; - const { err } = ButtonStore.addButton(state[scriptName].store.customButtons[button]); - if (err) debug.error(`${err}`); - } - const allButtons = ButtonStore.getButtonNames(), - enabledButtons = Config.getSetting('enabledButtons'); - const validButtons = enabledButtons.filter(v => allButtons.includes(v)); - if (validButtons.length !== enabledButtons.length) { - debug.warn(`Invalid button found in enabledButtons - button hidden.`); - Config.changeSetting('enabledButtons', validButtons, { overwriteArray: true }); - } - log(`=( Initialised ${scriptName} - v${Config.version} )=`); - } - - // Send buttons to chat - const sendButtons = (damage, crit, msg) => { - const gmOnly = Config.getSetting('gmOnly') ? true : false, - activeButtons = Config.getSetting(`enabledButtons`) || [], - name = Helpers.findName(msg.content), - buttonArray = Config.getSetting('autosort') ? activeButtons.sort((a,b) => a > b ? 1 : -1) : activeButtons, - htmlArray = buttonArray.map(btn => ButtonStore.createApiButton(btn, damage, crit)).filter(v=>v), - darkMode = Config.getSetting('darkMode'); - let sourceAttackAbility; - if (Config.getSetting('multiattack')) sourceAttackAbility = Helpers5e.findNpcAttack(msg, name); - const buttonBarLabel = sourceAttackAbility ? `` : `
${name}
`; - if (htmlArray.length < 1) { - debug.info(`No valid buttons were returned`); - return; - } - const buttonHtml = htmlArray.join(''); - const buttonTemplate = `
${buttonBarLabel}${buttonHtml}
`; - Helpers.toChat(`${buttonTemplate}`, gmOnly); - } - - // Deconstruct & repackage Roll20 roll object - const handleDamageRoll = (msg) => { - const dmgFields = Config.getSetting('templates/damageProperties/damageFields')||[], - critFields = Config.getSetting('templates/damageProperties/critFields')||[]; - const damage = Helpers.processFields(dmgFields, msg), - crit = Helpers.processFields(critFields, msg); - if ('dnd5e_r20' === Config.getSetting('sheet')) { - const isSpell = Helpers5e.is5eAttackSpell(msg.content); - if (isSpell) { - const upcastDamageFields = Config.getSetting('templates/damageProperties/upcastDamage')||[], - upcastCritFields = Config.getSetting('templates/damageProperties/upcastCrit')||[]; - const upcastDamage = Helpers.processFields(upcastDamageFields, msg), - upcastCrit = Helpers.processFields(upcastCritFields, msg); - Helpers.mergeDamageObjects(damage, upcastDamage); - Helpers.mergeDamageObjects(crit, upcastCrit); - } - } - sendButtons(damage, crit, msg); - } - - // The input... it must be handled - const handleInput = (msg) => { - const msgIsGM = playerIsGM(msg.playerid); - if (msg.type === 'api' && msgIsGM && /^!autobut(ton)?s?\b/i.test(msg.content)) { - const cmdLine = (msg.content.match(/^![^\s]+\s+(.+)/i) || [])[1], - commands = cmdLine ? cmdLine.split(/\s*--\s*/g) : []; - commands.shift(); - debug.log(commands); - if (commands.length) CLI.assess(commands); - } - else if (msg.rolltemplate && Config.getSetting('templates/names').includes(msg.rolltemplate)) { - const ignoreAPI = Config.getSetting('ignoreAPI'); - if (ignoreAPI && /^api$/i.test(msg.playerid)) return; - handleDamageRoll(msg); - } - } - - // Make script do stuff - checkInstall(); - on('chat:message', handleInput); - } - - /** - * SHEET PRESET DATA - */ - // TODO: Replace with PresetController class ??? - const preset = { - dnd5e_r20: { - sheet: ['dnd5e_r20'], - templates: { - names: ['atkdmg', 'dmg', 'npcfullatk', 'npcdmg'], - damageProperties: { - damageFields: ['dmg1', 'dmg2', 'globaldamage'], - critFields: ['crit1', 'crit2', 'globaldamagecrit'], - upcastDamage: ['hldmg'], - upcastCrit: ['hldmgcrit'], - }, - }, - defaultButtons: ['crit', 'critHalf', 'damage', 'damageHalf', 'healingFull'], - }, - custom: { - sheet: [], - templates: { - names: [], - damageProperties: { - damageFields: [], - critFields: [], - }, - }, - defaultButtons: [] - } - } - - /** - * CSS STYLES - */ - const styles = { - error: `color: red; font-weight: bold;`, - outer: `position: relative; vertical-align: middle; font-family: pictos; display: block; background: #f4e6b6; border: 1px solid black; height: auto; line-height: 34px; text-align: center; border-radius: 2px;`, - rollName: `font-family: arial; font-size: 1.1rem; color: black; font-style:italic; font-weight: bold; position:relative; overflow: hidden; display: block; line-height: 1.2rem; margin: 1px 0px 0px 0px; white-space: nowrap; text-align: left; left: 2px;`, - buttonContainer: `display: inline-block; text-align: center; vertical-align: middle; line-height: 26px; margin: auto 5px auto 5px; height: 26px; width: 26px; border: #8c6700 1px solid; box-shadow: 0px 0px 3px #805200; border-radius: 5px; background-color: whitesmoke; position: relative;`, - buttonShared: `background-color: transparent; border: none; border-radius: 5px; padding: 0px; width: 100%; height: 100%; overflow: hidden; white-space: nowrap; position: absolute; top: 0; left: 0; text-decoration: none;`, - crit: `color: darkred; font-size: 2.9rem; line-height: 2.3rem; text-shadow: 0px 0px 2px black;`, - crit2: `color: #ff4040; font-size: 1.8rem; line-height: 2.4rem;`, - full: `color: darkred; font-size: 2.4rem; line-height: 2.3rem; text-shadow: 0px 0px 2px black;`, - half: `color: black; font-family: pictos three; font-size: 2.6rem; line-height: 3rem; text-shadow: 0px 0px 2px black;`, - halfSmall: `color: black; font-family: pictos three; font-size: 2.2rem; line-height: 2.8rem; text-shadow: 0px 0px 1px black;`, - half2: `color: whitesmoke; font-family: cursive; font-size: 0.9rem;`, - halfCrit: `color: #d51d1d; font-family: pictos three; font-size: 3.2rem; line-height: 2.9rem; text-shadow: 0px 0px 2px black;`, - healFull: `color: green; font-size: 2.4rem; line-height: 2.3rem; text-shadow: 0px 0px 2px black;`, - damageLabel: `font-family: cursive; font-size: 1.2rem; font-weight: bolder; color: #f2c8c8; line-height: 2.4rem;`, - healLabel: `color: #cdf7d1; font-family:cursive; font-size:1.8rem; font-weight:bold; line-height: 2.2rem; text-shadow: 0px 0px 2px white;`, - resist: ` font-family: pictos three; font-size: 2.6rem; line-height: 3rem; text-shadow: 0px 0px 2px black; color: #003f82;`, - resistSmall: ` font-family: pictos three; font-size: 2.2rem; line-height: 2.8rem; color: #003f82; text-shadow: 0px 0px 1px black;`, - resistLabel: `font-family: cursive; font-size: 1rem;`, - darkMode: { - rollName: `color: white;`, - outer: `background: #31302c;`, - buttonContainer: `background-color: #7b7565; border-color: #aea190; box-shadow: 0px 0px 2px #aea190;`, - }, - list: { - container: `font-size: 1.5rem; background: #41415c; border: 5px solid #1c7b74; border-radius: 3px; color: white; vertical-align: middle;`, - header: `text-align: center; font-weight: bold; padding: 6px 0px 6px 0px; border-bottom: solid 1px darkgray; line-height: 1.5em;`, - body: `padding: 8px 1rem 8px 1rem;`, - row: `vertical-align: middle; margin: 0.2em auto 0.2em auto; font-size: 1.2em; line-height: 1.4em;`, - name: `display: inline-block; vertical-align: middle; width: 60%; margin-left: 5%; overflow-x: hidden;`, - faded: `opacity: 0.4;`, - buttonContainer: ` display: inline-block; vertical-align: middle; width: 10%; text-align: center; line-height: 1.2em;`, - controls: { - common: `position: relative; font-family: pictos; display: inline-block; background-color: darkgray; padding: 0px; margin: 0px; border: 1px solid #c2c2c2; border-radius: 3px; width: 1.1em; height: 1.1em; line-height: 1.1em; font-size: 1.2em;`, - show: `color: #03650b;`, - hide: `color: #2a2a2a;`, - disabled: `color: gray; cursor: pointer;`, - delete: `color: darkred;`, - create: `display: inline-block; background-color: darkgray; padding: 0px; margin: 1rem 0; border: 1px solid #c2c2c2; border-radius: 3px; color: #066a66; padding: 2px 5px 2px 5px; font-size: 1.1em; line-height: 1.2em;`, - no: `position: absolute; left: 0.4em; font-weight: bold; font-family: arial;` - }, - footer: `text-align: center; font-weight: bold; padding: 6px 0px 6px 0px; border-top: solid 1px darkgray; line-height: 1.5em;`, - }, - table: { - outer: `overflow-x: auto; width: 100%;`, - table: `margin: 1rem auto; width: 95%; justify-content: center; border: 1px solid #7fb07f;`, - headerRow: ``, - row: `background-color: #5e5e63; margin: 0.5rem;`, - headerCell: ` text-align: center; font-size: 1.7rem; padding: 1rem; border-bottom: 1px solid #7fb07f;`, - cell: `padding: 0.2rem 1rem; line-height: 2.5rem; margin: 1px 0px;`, - rowBorders: `border-top: 1px solid #7fb07f;`, - footer: `margin: 0 auto 1.5rem auto;`, - settingName: `border: 1px solid whitesmoke; padding: 0.4rem 0; border-radius: 0.5rem; cursor: help; margin: 1px auto;`, - button: ` display: inline-block; background-color: darkgray; border: 1px solid #cae1df; box-shadow: 0px 0px 3px #bcdbd8; border-radius: 3px; color: #045754; padding: 0.3rem 0.5rem; margin: 0.2rem 0!important; font-size: 1.1em; line-height: 1.2em;`, - }, - components: { - labelWithDelete: function(label, commandString) { - const styleOuter = `border: 1px solid whitesmoke; padding: 0.2rem 0rem; border-radius: 0.5rem; width: max-content; margin: 2px auto; display: inline-block; line-height: 1.2rem; white-space: nowrap;`, - styleDelete = `font-family: pictos; color: darkred; background-color: gray; height: 1rem; line-height: 1.2rem; width: 1.2rem; text-align: center; margin: 0 1rem; border: 1px solid #aaa8a8; border-radius: 0.5rem;`, - styleLabel = `display: inline-block; overflow-x: clip; margin-left: 0.5rem;` - return `
${label}
*
` - }, - confirmApiCommand: function(confirmAction) { - return `!autobut?{Are you sure you wish to ${confirmAction}|Yes, |No,fffff}`; - }, - }, - report: ``, - // BUMP setting CSS - if Roll20 dick with the chatbar CSS this will need to be updated - mods: { - bump: `left: -5px; top: -30px; margin-bottom: -34px;` - } - } - - /** - * DEFAULT BUTTONS - */ - const _defaultButtons = { - crit: { - name: `crit`, - sheets: ['dnd5e_r20'], - tooltip: `Crit (%)`, - style: styles.crit, - style2: styles.crit2, - // style2: styles.critBackground, - math: (damage, crit) => -(damage.total + crit.total), - content: 'k', - content2: 'k' - }, - critHalf: { - name: `critHalf`, - sheets: ['dnd5e_r20'], - tooltip: `Half Crit (%)`, - style: styles.halfCrit, - style2: styles.halfSmall, - style3: styles.half2, - math: (damage, crit) => -(Math.floor(0.5 * (damage.total + crit.total))), - content: 'b', - content2: 'b', - content3: '1/2', - }, - damage: { - name: `damage`, - sheets: ['dnd5e_r20'], - tooltip: `Full (%)`, - style: styles.full, - math: (damage) => -(damage.total), - content: 'k', - }, - damageHalf: { - name: `damageHalf`, - sheets: ['dnd5e_r20'], - tooltip: `Half (%)`, - style: styles.half, - style2: styles.half2, - math: (damage) => -(Math.floor(0.5 * damage.total)), - content: 'b', - content2: '1/2', - }, - healingFull: { - name: `healing`, - sheets: ['dnd5e_r20'], - tooltip: `Heal (%)`, - style: styles.healFull, - style2: styles.healLabel, - math: (damage) => (damage.total), - content: 'k', - content2: '+', - }, - // Buttons added in 0.6.x - damagePrimary: { - name: `damagePrimary`, - sheets: ['dnd5e_r20'], - tooltip: `Damage 1 (%)`, - style: styles.full, - style2: styles.damageLabel, - math: (damage) => -(damage.dmg1 + (damage.hldmg||0) + damage.globaldamage), - content: 'k', - content2: '1', - }, - damageSecondary: { - name: `damageSecondary`, - sheets: ['dnd5e_r20'], - tooltip: `Damage 2 (%)`, - style: styles.full, - style2: styles.damageLabel, - math: (damage) => -(damage.dmg2), - content: 'k', - content2: '2', - }, - critPrimary: { - name: `critPrimary`, - sheets: ['dnd5e_r20'], - tooltip: `Crit 1 (%)`, - style: styles.crit, - style2: styles.crit2, - style3: styles.damageLabel, - math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg||0) + (crit.hldmgcrit||0) + damage.globaldamage + crit.globaldamagecrit), - content: 'k', - content2: 'k', - content3: '1', - }, - critSecondary: { - name: `critSecondary`, - sheets: ['dnd5e_r20'], - tooltip: `Crit 2 (%)`, - style: styles.crit, - style2: styles.crit2, - style3: styles.damageLabel, - math: (damage, crit) => -(damage.dmg2 + crit.crit2), - content: 'k', - content2: 'k', - content3: '2', - }, - 'resist%': { - name: 'resist%', - sheets: ['dnd5e_r20'], - tooltip: `Damage Resist % (%)`, - style: styles.resist, - style2: styles.resistLabel, - math: (damage) => -(damage.total), - query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, - content: 'b', - content2: '%', - }, - 'resistN': { - name: 'resistN', - sheets: ['dnd5e_r20'], - tooltip: `Damage Resist Flat (%)`, - style: styles.resist, - style2: styles.resistLabel, - math: (damage) => -(damage.total), - query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, - content: 'b', - content2: 'n', - }, - 'resistCrit%': { - name: 'resistCrit%', - sheets: ['dnd5e_r20'], - tooltip: `Crit Resist % (%)`, - style: styles.halfCrit, - style2: styles.resistSmall, - style3: styles.resistLabel, - math: (damage, crit) => -(damage.total + crit.total), - query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, - content: 'b', - content2: 'b', - content3: '%', - }, - 'resistCritN': { - name: 'resistCritN', - sheets: ['dnd5e_r20'], - tooltip: `Crit Resist Flat (%)`, - style: styles.halfCrit, - style2: styles.resistSmall, - style3: styles.resistLabel, - math: (damage, crit) => -(damage.total + crit.total), - query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, - content: 'b', - content2: 'b', - content3: 'n', - }, - 'resistPrimary%': { - name: 'resistPrimary%', - sheets: ['dnd5e_r20'], - tooltip: `Damage Resist 1 % (%)`, - style: styles.resist, - style2: styles.resistLabel, - math: (damage) => -(damage.dmg1 + (damage.hldmg||0) + damage.globaldamage), - query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, - content: 'b', - content2: '1%', - }, - 'resistPrimaryN': { - name: 'resistPrimaryN', - sheets: ['dnd5e_r20'], - tooltip: `Damage Resist 1 Flat (%)`, - style: styles.resist, - style2: styles.resistLabel, - math: (damage) => -(damage.dmg1 + (damage.hldmg||0) + damage.globaldamage), - query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, - content: 'b', - content2: '1n', - }, - 'resistSecondary%': { - name: 'resistSecondary%', - sheets: ['dnd5e_r20'], - tooltip: `Damage Resist 2 % (%)`, - style: styles.resist, - style2: styles.resistLabel, - math: (damage) => -(damage.dmg2), - query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, - content: 'b', - content2: '%2', - }, - 'resistSecondaryN': { - name: 'resistSecondaryN', - sheets: ['dnd5e_r20'], - tooltip: `Damage Resist 2 Flat (%)`, - style: styles.resist, - style2: styles.resistLabel, - math: (damage) => -(damage.dmg2), - query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, - content: 'b', - content2: 'n2', - }, - 'resistPrimaryCrit%': { - name: 'resistPrimaryCrit%', - sheets: ['dnd5e_r20'], - tooltip: `Crit Resist 1 % (%)`, - style: styles.halfCrit, - style2: styles.resistSmall, - style3: styles.resistLabel, - math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg||0) + (crit.hldmgcrit||0) + damage.globaldamage + crit.globaldamagecrit), - query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, - content: 'b', - content2: 'b', - content3: '1%', - }, - 'resistPrimaryCritN': { - name: 'resistPrimaryCritN', - sheets: ['dnd5e_r20'], - tooltip: `Crit Resist 1 Flat (%)`, - style: styles.halfCrit, - style2: styles.resistSmall, - style3: styles.resistLabel, - math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg||0) + (crit.hldmgcrit||0) + damage.globaldamage + crit.globaldamagecrit), - query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, - content: 'b', - content2: 'b', - content3: '1n', - }, - 'resistSecondaryCrit%': { - name: 'resistSecondaryCrit%', - sheets: ['dnd5e_r20'], - tooltip: `Crit Resist 2 % (%)`, - style: styles.halfCrit, - style2: styles.resistSmall, - style3: styles.resistLabel, - math: (damage, crit) => -(damage.dmg2 + crit.crit2), - query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, - content: 'b', - content2: 'b', - content3: '%2', - }, - 'resistSecondaryCritN': { - name: 'resistSecondaryCritN', - sheets: ['dnd5e_r20'], - tooltip: `Crit Resist 2 Flat (%)`, - style: styles.halfCrit, - style2: styles.resistSmall, - style3: styles.resistLabel, - math: (damage, crit) => -(damage.dmg2 + crit.crit2), - query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, - content: 'b', - content2: 'b', - content3: 'n2', - }, - }; - - // Global regex - const rx = { on: /\b(1|true|on)\b/i, off: /\b(0|false|off)\b/i }; - - /** - * HELPER FUNCTIONS - */ - class Helpers { - // Process roll object according to rolltemplate fields - static processFields(fieldArray, msg) { - let output = {} - const rolls = msg.inlinerolls; - output.total = fieldArray.reduce((m, v) => { - const rxIndex = new RegExp(`{${v}=\\$\\[\\[\\d+`, 'g'), - indexResult = msg.content.match(rxIndex); - if (indexResult) { - const index = indexResult.pop().match(/\d+$/)[0], - total = isNaN(rolls[index].results.total) ? 0 : rolls[index].results.total; - output[v] = total; - return m + total; - } else { // if roll template property's inline roll is not found, return 0 to prevent errors down the line - output[v] = 0; - } - return m; - }, 0); - return output; - } - // Simple name finder, provided rolltemplate has some kind of 'name' property - static findName(msgContent) { - const rxRname = /{rname=(.+?)}}/i; - const rxName = /{name=(.+?)}}/i; - let name = msgContent.match(rxRname) || msgContent.match(rxName); - return name ? name[1] : 'Apply:'; - } - // sendChat shortcut - static toChat(msg, whisper = true) { - let prefix = whisper ? `/w gm ` : ''; - sendChat(scriptName, `${prefix}${msg}`, {noarchive: true}); - } - static toArray(inp) { return Array.isArray(inp) ? inp : [inp]; } - static emproper(inpString) { - let words = inpString.split(/\s+/g); - return words.map(w => `${w[0].toUpperCase()}${w.slice(1)}`).join(` `); - } - // Split {{handlebars=moustache}} notation to key:value - static splitHandlebars(inputString) { - let output = {}, - kvArray = inputString.match(/{{[^}]+}}/g)||[]; - kvArray.forEach(kv => { - kv = kv.replace(/({{|}})/g, ''); - const key = kv.match(/^[^=]+/), - value = (kv.match(/=(.+)/)||[])[1] || ``; - if (key) output[key] = value; - }); - return Object.keys(output).length ? output : null; - } - // Camelise a name if user tries to use whitespace - static camelise(inp, options={enforceCase:false}) { - if (typeof(inp) !== 'string') return null; - const words = inp.split(/[\s_]+/g); - return words.map((w,i) => { - const wPre = i > 0 ? w[0].toUpperCase() : w[0].toLowerCase(); - const wSuf = options.enforceCase ? w.slice(1).toLowerCase() : w.slice(1); - return `${wPre}${wSuf}`; - }).join(''); - } - /** - * Grab a dark mode CSS append string if it exists and dark mode is enabled - * @param {string} styleName - keyname of style - * @param {boolean} darkModeEnabled - boolean dark mode setting - * @param {object} stylesPath - parent object of target key/value pair - * @returns {string} - CSS style string - */ - static appendDarkMode(styleName, darkModeEnabled, stylesPath = styles) { - return (!darkModeEnabled || !stylesPath || !stylesPath.darkMode || !stylesPath.darkMode[styleName]) ? `` : stylesPath.darkMode[styleName]; - } - /** - * Check if an object is a basic JS object - * @param {any} input - * @returns {bool} - */ - static isObj(input) { return (typeof(input) === 'object' && input.constructor.name === 'Object') ? true : false } - - static copyObj(inputObj) { return (typeof inputObj !== 'object') ? null : JSON.parse(JSON.stringify(inputObj)); } - - static getObjectPath(pathString, baseObject, createPath, deleteTarget) { - const parts = pathString.split(/\/+/g); - const objRef = parts.reduce((m,v,i) => { - if (m == null) return; - if (m[v] == null) { - if (createPath) m[v] = {}; - else return null; - } - if (deleteTarget && (i === parts.length-1)) delete m[v]; - else return m[v];}, baseObject) - return objRef; - } - - // If value exists in array, it will be removed, otherwise it will be added. No validation done. Does not mutate the original. - static modifyArray(targetArray, newValue) { - if (!Array.isArray(targetArray || newValue == null)) return { err: `modifyArray error, bad parameters` }; - if (targetArray.includes(newValue)) { - Helpers.filterAndMutate(targetArray, (v) => v === newValue); - return { msg: `Removed ${newValue} from array.` } - } - else { - targetArray = targetArray.push(newValue); - return { msg: `Added ${newValue} to array.` } - } - } - - /** - * Filter an array by reference - * @param {array.} inputArray - * @param {function} predicate - * @return {boolean} success/failure - */ - static filterAndMutate(inputArray, predicate) { - if (typeof(predicate) !== 'function' || !Array.isArray(inputArray)) { - debug.error(`filterMutate requires an array and a predicate function.`); - return false; - } - for (let i=inputArray.length-1; i>=0; i--) { - if (predicate(inputArray[i])) inputArray.splice(i, 1); - } - return true; - } - - static copyOldButtonStore() { - let names = []; - state[scriptName].store = state[scriptName].store || {}; - state[scriptName].store.customButtons = Helpers.copyObj(state[scriptName].customButtons) || {}; // copy old store to new store - for (const button in state[scriptName].store.customButtons) { - state[scriptName].store.customButtons[button].name = state[scriptName].store.customButtons[button].name || button; - state[scriptName].store.customButtons[button].mathString = state[scriptName].store.customButtons[button].mathString || state[scriptName].store.customButtons[button].math; - names.push(state[scriptName].store.customButtons[button].name); - } - if (names.length) new ChatDialog({ title: 'Buttons copied to new version', content: names }); - } - - /** - * Recalculate the total key in a damage object - * @param {object} damageObject - */ - - static recalculateDamageTotal(damageObject) { - damageObject.total = 0; - for (const key in damageObject) damageObject.total += key === 'total' ? 0 : damageObject[key]; - } - /** - * Merge two damage objects together and recalculate total - * @param {object} baseObject - * @param {object} addObject - */ - static mergeDamageObjects(baseObject, addObject) { - Object.assign(baseObject, addObject); - Helpers.recalculateDamageTotal(baseObject); - } - } - - /** - * 5E-SPECIFIC HELPERS - */ - class Helpers5e { - // Spell detection - static is5eAttackSpell(msgContent) { - const rxSpell = /{spelllevel=(cantrip|\d+)/; - return rxSpell.test(msgContent) ? 1 : 0; - } - /** - * Find a repeating_npcaction attack from the roll template content. Optionally supply the attack name. - * @param {Object} msg - r20 message object - * @param {string} [attackName] - name of the attack - * @returns {?string} - content of @{rollbase} in the target attack - */ - static findNpcAttack = (msg, attackName) => { - if (!msg.rolltemplate || !/^npc/.test(msg.rolltemplate)) return; - const rx = { - attackName: /rname=(.+?)}}/, - characterName: /{{name=(.+?)}}/, - attackNameAttribute: /^repeating_npcaction_(-[0-z-]{19})_name/i, - }; - attackName = attackName || (msg.content.match(rx.attackName)||[])[1]; - const characterName = (msg.content.match(rx.characterName)||[])[1], - char = findObjs({ type: 'character', name: characterName })[0]; - if (!char || !attackName) return null; - const attackRowId = findObjs({ type: 'attribute', characterid: char.id }).reduce((out, attribute) => { - if (attribute.get('current') === attackName) { - const rowMatch = attribute.get('name').match(rx.attackNameAttribute); - if (rowMatch) return rowMatch[1]; - } - return out; - }, ``); - return attackRowId ? `@{${characterName}|repeating_npcaction_${attackRowId}_rollbase}` : null; - // const targetRollAttribute = findObjs({ type: 'attribute', characterid: char.id, name: `repeating_npcaction_${attackRowId}_rollbase` })[0]; - // if (targetRollAttribute) return targetRollAttribute.get('current'); - } - } - - /** - * COMMAND LINE INTERFACE OPTIONS - */ - const defaultCliOptions = [ - { - name: 'bump', - rx: /^bump/i, - description: `Bump the button UI up to the top of the chat message`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('bump', args, { createPath: true, force: 'boolean' }) } - }, - { - name: 'targetTokens', - rx: /^targett/i, - description: `Use target instead of select for applying damage to tokens`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - const result = this.config.changeSetting('targetTokens', args, { createPath: true, force: 'boolean' }); - if (this.config.getSetting('targetTokens') && result.success && result.msg) result.msg.push(`*Important*: Players cannot use targeting unless TokenMod is set to allow players to use token ids.`); - return result; - } - }, - { - name: 'reset', - rx: /^reset/i, - description: `Reset configuration from preset`, - requiredServices: { config: 'ConfigController' }, - action: function () { - if (this.config.getSetting('sheet')) { - this.config.loadPreset(); - return { success: 1, msg: `Config reset from preset: "${this.config.getSetting('sheet')}"` }; - } else return { err: `No preset found!` }; - } - }, - { - name: 'bar', - rx: /^(hp)?bar/i, - description: `Select which token bar represents hit points`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - const newVal = parseInt(`${args}`.replace(/\D/g, '')); - if (newVal > 0 && newVal < 4) { - return this.config.changeSetting('hpBar', newVal); - } else return { err: `token bar value must be 1, 2 or 3`} - } - }, - { - name: 'loadPreset', - rx: /^loadpre/i, - description: `Select a preset for a Game System`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, - action: function (args) { - const newVal = args.trim(); - if (Object.keys(preset).includes(newVal)) { - const newSheet = this.config.changeSetting('sheet', newVal); - if (newSheet.msg) { - this.config.loadPreset(); - this.buttons.verifyButtons(); - return { success: 1, msg: `Preset changed: ${newVal}` }; - } else return { err: `Error changing preset to "${newVal}"` }; - } else return { err: `Coudln't find sheet/preset: ${args}` } - } - }, - { - name: 'listTemplates', - rx: /^(list)?templ/i, - description: `List roll templates the script is listening for`, - requiredServices: { config: 'ConfigController' }, - action: function () { - const templates = this.config.getSetting(`templates/names`), - confirm = styles.components.confirmApiCommand(`delete this template name?`), - templateText = Helpers.toArray(templates).map(v => [ - // `
${v}
`, - // `Delete` - styles.components.labelWithDelete(v, `${confirm}autobut --deleteTemplate ${v}`) - ]), - footerContent = `Add template`; - templateText.unshift([ 'Template name']); - new ChatDialog({ content: templateText, title: `Roll Template List`, footer: footerContent }, 'table'); - } - }, - { - name: 'addTemplate', - rx: /^addtem/i, - description: `Add roll template name to listen list for damage rolls`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - if (!this.config.getSetting('templates/names').includes(args)) { - const result = this.config.changeSetting('templates/names', args); - if (result.success) result.msg = `Added template ${args} to listener list`; - return result; - } - } - }, - { - name: 'removeTemplate', - rx: /^(remove|delete)tem/i, - description: `Remove roll template from listen list`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - if (this.config.getSetting('templates/names').includes(args)) { - const result = this.config.changeSetting('templates/names', args); - if (result.success) result.msg = `Removed template ${args} to listener list`; - return result; - } - } - }, - { - name: 'listProperties', - rx: /^(list)?(propert|props)/i, - description: `List roll template properties inline rolls are grabbed from`, - requiredServices: { config: 'ConfigController' }, - action: function () { - const properties = this.config.getSetting('templates/damageProperties'), - confirm = styles.components.confirmApiCommand(`delete this template property?`), - styleCategory = `font-size: 1.4rem; font-weight: bold; font-style: italic;` - let templateText = [ ['Category', 'Properties'] ]; - if (typeof properties === 'object') { - for (let category in properties) { - const propButtons = properties[category].map(prop => styles.components.labelWithDelete(prop, `${confirm}autobut --deleteprop ${category}/${prop}`)); - templateText.push([ - `${category}`, - `${propButtons.join(`
`)}
Add Property` - ]); - } - } else return { err: `Error getting damage properties from state` } - new ChatDialog({ title: 'Roll Template Properties', content: templateText, borders: { row: true } }, 'table'); - } - }, - { - name: 'addProperty', - rx: /^addprop/i, - description: `Add a roll template property to the listener`, - requiredServices: { config: 'ConfigController', }, - action: function (args) { - const parts = args.match(/([^/]+)\/(.+)/); - if (parts && parts.length === 3) { - if (this.config.getSetting(`templates/damageProperties/${parts[1]}`) == null) { - Helpers.toChat(`Created new roll template damage property category: ${parts[1]}`); - state[scriptName].settings.templates.damageProperties[parts[1]] = []; - } - return this.config.changeSetting(`templates/damageProperties/${parts[1]}`, parts[2]); - } else { - return { err: `Bad property path supplied, must be in the form "category/propertyName". Example: damage/dmg1` }; - } - } - }, - { - name: 'removeProperty', - rx: /^(remove|delete)?prop/i, - description: `Remove a roll template property from the listener`, - requiredServices: { config: 'ConfigController', }, - action: function (args) { - const parts = args.match(/([^/]+)\/(.+)/); - if (parts && parts.length === 3) { - const currentArray = this.config.getSetting(`templates/damageProperties/${parts[1]}`); - if (currentArray != null) { - const result = this.config.changeSetting(`templates/damageProperties/${parts[1]}`, parts[2]); - if (result.success && !/^(damage|crit)$/i.test(parts[1])) { // Clean up category if it's now empty, and isn't a core category - const newArray = this.config.getSetting(`templates/damageProperties/${parts[1]}`); - if (newArray.length === 0) { - delete state[scriptName].settings.templates.damageProperties[parts[1]]; - result.msg += `\nCategory ${parts[1]} was empty, and was removed.`; - } - } - return result; - } else return { err: `Could not find roll template property category: ${parts[1]}` } - } else { - return { err: `Bad property path supplied, must be in the form "category/propertyName". Example: damage/dmg1` } - } - } - }, - { - name: 'listButtons', - rx: /^(list)?button/i, - description: `List available buttons`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, - action: function() { - const removableButtons = this.buttons.getButtonNames({ default: false }), - usedButtons = this.config.getSetting('enabledButtons'), - unusedButtons = this.buttons.getButtonNames({ hidden: true }), - availableButtons = this.buttons.getButtonNames({ hidden: true, currentSheet: true }), - reorderedButtons = usedButtons.concat(unusedButtons); - const links = { - hide: `!autoButton --hideButton %name%`, - show: `!autoButton --showButton %name%`, - delete: `${styles.components.confirmApiCommand(`delete button %name%?`)}--deleteButton %name%`, - disabled: `#` - } - const labels = { - hide: `E/`, - show: 'E', - delete: 'D', - disabled: '!' - }; - const controls = ['show', 'hide', 'delete']; - const listBody = reorderedButtons.map(button => { - const fadeText = usedButtons.includes(button) ? '' : styles.list.faded; - let rowHtml = `
${removableButtons.includes(button) ? '' : '*'}%name%
`; - controls.forEach(control => { - const controlType = ( - (control === 'show' && availableButtons.includes(button)) || - (control === 'hide' && usedButtons.includes(button)) || - (control === 'delete' && removableButtons.includes(button))) ? - control : 'disabled'; - rowHtml += ``; - }); - return `${rowHtml.replace(/%name%/g, button)}
`; - }); - const headerText = `autoButton list (sheet: ${this.config.getSetting('sheet')})`, - bodyText = listBody.join(''), - footerText = `Create New Button`; - new ChatDialog({ header: headerText, body: bodyText, footer: footerText }, 'listButtons'); - }, - }, - { - name: 'showButton', - rx: /^showbut/i, - description: `Add a button to the button bar`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, - action: function (args) { - const newVal = args.trim(); - const validButtons = this.buttons.getButtonNames({ hidden: true, currentSheet: true }); - if (validButtons.includes(newVal)) { - return this.config.changeSetting('enabledButtons', newVal); - } else new ChatDialog({ title: 'Error', content: `Unrecognised or incompatible button: "${newVal}"` }, 'error'); - } - }, - { - name: 'hideButton', - rx: /^hidebut/i, - description: `Remove a button from the template`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, - action: function (args) { - const newVal = args.trim(); - const validButtons = this.buttons.getButtonNames({ shown: true, currentSheet: true }); - if (validButtons.includes(newVal)) { - return this.config.changeSetting('enabledButtons', newVal); - } else new ChatDialog({ title: 'Error', content: `Unrecognised or incompatible button: "${newVal}"` }, 'error'); - } - }, - { - name: 'reorderButtons', - rx: /^(re)?order/i, - description: `Change order of buttons`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - if (!args) return; - const newIndices = args.replace(/[^\d,]/g, '').split(/,/g), - currentOrder = this.config.getSetting('enabledButtons'); - let newOrder = []; - let valid = true; - newIndices.forEach(buttonIndex => { - const realIndex = buttonIndex - 1; - if (realIndex > -1 && realIndex < currentOrder.length) { - if (currentOrder[realIndex]) { - newOrder.push(currentOrder[realIndex]); - currentOrder[realIndex] = null; - } - } else valid = false; - }); - if (!valid) return { err: `Invalid button order input: ${args}. Indices must be between 1 and total number of buttons in use.` } - newOrder = newOrder.concat(currentOrder.filter(v => v)); - if (newOrder.length === currentOrder.length) return this.config.changeSetting('enabledButtons', newOrder, { overwriteArray: true }); - } - }, - { - name: 'createButton', - rx: /^createbut/i, - description: `Create a new button`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, - action: function (args) { - const buttonData = Helpers.splitHandlebars(args); - if (buttonData && buttonData.name) { - if (/^[^A-Za-z]/.test(buttonData.name)) return { err: `Invalid button name: must start with a letter` }; - let buttonName = /\s/.test(buttonData.name) ? Helpers.camelise(buttonData.name) : buttonData.name; - if (this.buttons.getButtonNames().includes(buttonName)) return { err: `Invalid button name, already in use: "${buttonName}"` } - if (!buttonData.math) return { err: `Button must have an associated function, {{math=...}}` } - buttonData.default = false; - // construct query if provided - // if (buttonData.query) buttonData.query = Button.splitAndEscapeQuery(buttonData.query); - const result = this.buttons.addButton(buttonData); - if (result.success) { - this.buttons.showButton(buttonName); - return result; - } - } else return { err: `Bad input for button creation` } - } - }, - { - name: 'editButton', - rx: /^editbut/i, - description: `Edit an existing button`, - requiredServices: { buttons: 'ButtonManager' }, - action: function (args) { - let buttonData = Helpers.splitHandlebars(args); - debug.log(buttonData); - if (buttonData && buttonData.name) { - if (this.buttons.getButtonNames().includes(buttonData.name)) { - return this.buttons.editButton(buttonData); - } - } - } - }, - { - name: 'deleteButton', - rx: /^del(ete)?but/i, - description: `Remove a button`, - requiredServices: { buttons: 'ButtonManager', config: 'ConfigController' }, - action: function (args) { - const removeResult = this.buttons.removeButton(args.trim()), - buttonIsEnabled = this.config.getSetting('enabledButtons').includes(args); - if (removeResult.success) { - if (buttonIsEnabled) this.config.changeSetting('enabledButtons', args); - return removeResult; - } else return removeResult; - } - }, - { - name: 'ignoreApi', - rx: /^ignoreapi/i, - description: `Ignore anything sent to chat by the API`, - requiredServices: { config: 'ConfigController' }, - action: function(args) { return this.config.changeSetting('ignoreAPI', args) } - }, - { - name: 'overheal', - rx: /^overh/i, - description: `Allow healing to push hp above hpMax`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('overheal', args) } - }, - { - name: 'overkill', - rx: /^overk/i, - description: `Allow healing to push hp above hpMax`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('overkill', args) } - }, - { - name: 'gmOnly', - rx: /^gmo/i, - description: `Whisper the buttons to GM, or post publicly`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('gmOnly', args) } - }, - { - name: 'settings', - rx: /^setting/i, - description: `Open settings UI`, - requiredServices: { config: 'ConfigController' }, - action: function() { this.config.getSettingsMenu() } - }, - { - name: 'help', - rx: /^(\?$|h$|help)/i, - description: `Display script help`, - action: function() { new ChatDialog({ title: `Script Help`, content: `Please visit the autoButtons thread for documentation.` }) } - }, - { - name: 'uninstall', - rx: /^uninstall$/i, - description: `Remove all script settings from API state`, - action: function(args) { - if (/^undo/i.test(args)) { - state[scriptName] = Helpers.copyObj(undoUninstall); - new ChatDialog({ title: 'Reverse! Reverse the reverse!', content: `State settings have been restored. Let's pretend that never happend, eh?` }, 'error') - } else if (!undoUninstall) { - undoUninstall = Helpers.copyObj(state[scriptName]); - state[scriptName] = null; - delete state[scriptName]; - new ChatDialog({ - header: `${scriptName} uninstalled!`, - body: `Removed all ${scriptName} data from API state. Click the 'whoopsie' button below if you didn't mean to destroy all your settings!
Otherwise, all settings will be *permantently* lost on sandbox restart.
Deleting the script now will result in a complete removal of the script and all associated data.`, - footer: `Restore!`, - }, 'listButtons'); - } - } - } - ]; - - /** - * SCRIPT USER-CONFIG OPTIONS - * - * Must have a valid type to be pulled into SettingsManager as a setting - * 'object' type can be used for nesting settings keys - * 'validate' is a validator for the input, not necessarily the key itself (e.g. an array might accept strings in the validator) - * 'name'/'description' are only used for chat menu UI - * 'menuAction' must be supplied. Starting with '$' will automatically convert into a button with leading API command syntax, otherwise supply actual text required - */ - const defaultScriptSettings = { - sheet: { - type: 'string', - range: ['dnd5e_r20', 'custom'], - rangeLabels: [ 'DnD5e by Roll20', 'Custom' ], - validate: function(v) { return this.range.includes(v) }, - default: 'dnd5e_r20', - name: 'Character sheet', - description: 'Character sheet in use', - menuAction: `$--loadPreset` - }, - enabledButtons: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [], - }, - gmOnly: { - type: 'boolean', - default: true, - name: `GM-only buttons`, - description: `Whether the buttons are visible to players`, - menuAction: `$--gmo`, - }, - hpBar: { - type: 'integer', - range: [1,2,3], - validate: function(v) { return this.range.includes(v) }, - default: 1, - name: `Token HP bar`, - description: `Which token bar contains hit points`, - menuAction: `$--bar`, - }, - ignoreAPI: { - type: 'boolean', - default: true, - name: `Ignore API posts`, - description: `Ignore any automated damage rolls made by scripts`, - menuAction: `$--ignoreapi`, - }, - overheal: { - type: 'boolean', - default: false, - name: `Allow overheal`, - description: `Allow HP to go above max`, - menuAction: `$--overheal`, - }, - overkill: { - type: 'boolean', - default: false, - name: `Allow overkill`, - description: `Allow HP to go below 0`, - menuAction: `$--overkill`, - }, - targetTokens: { - type: 'boolean', - default: false, - name: `Target tokens`, - description: `Use a target click to target token, instead of current selection`, - menuAction: `$--targettoken`, - }, - bump: { - type: 'boolean', - default: true, - name: `Slim buttons`, - description: `CSS to bump the button container up in chat to save some space`, - menuAction: `$--bump`, - }, - templates: { - type: 'object', - names: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [], - name: `Roll templates & properties`, - description: `Names of roll templates & properties watched by autoButtons`, - menuAction: `Templates
Properties`, - }, - damageProperties: { - type: 'object', - damageFields: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [], - }, - critFields: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [] - }, - upcastDamage: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [] - }, - upcastCrit: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [] - }, - get value() { - const output = {}; - for (const key in this) { - if (key === 'value') continue; - if (this[key].value) output[key] = this[key].value; - } - return output; - } - }, - }, - } - - /** - * CLASS DEFINITIONS - */ - - /** - * Service Locator - Find a registered service from any scope in the script with ServiceLocator.getLocator().getService('serviceName') - */ - class ServiceLocator { - - static _active = null; - _services = {}; - - constructor(services={}) { - if (ServiceLocator._active) return ServiceLocator._active; - this.name = `ServiceLocator`; - for (let svc in services) { this._services[svc] = services[svc] } - ServiceLocator._active = this; - } - - static getLocator() { return ServiceLocator._active } - - register({ serviceName, serviceReference }) { if (!this._services[serviceName]) this._services[serviceName] = serviceReference } - - // Find a service. If service has multiple instances, make sure to request by instance name, or only the first registered constructor name will be returned. - // Search by Class Constructor Name is only suitable for unique class instances - getService(serviceName) { - if (this._services[serviceName]) return { [serviceName]: this._services[serviceName] } - else { - const rxServices = new RegExp(`${serviceName}`, 'i') - for (let service in this._services) { - if (this._services[service].constructor && rxServices.test(this._services[service].constructor.name)) return this._services[service]; - } - } - } - } - - /** - * Settings Manager - Handles fetch and store of user settings to state{} object, reads and writes to user settings. Processes the defaultScriptSettings{} object on init. Access via ConfigManager - */ - class SettingsManager { - - _settingsKeys = {}; - - constructor(settingsData = {}) { - const processObject = (currentObject, targetPath) => { - for (const key in currentObject) { - if (!currentObject[key].type) { - debug.log(`Skipping ${key}, no type found`); - continue; - } - debug.log(`Processing ${key}...`); - if (currentObject[key].type === 'object' && Helpers.isObj(currentObject[key])) { - targetPath[key] = currentObject[key]; - processObject(currentObject[key], targetPath[key]); - } - else if (this._validateKey(currentObject[key], currentObject[key].default)) { - targetPath[key] = currentObject[key]; - targetPath[key].value = currentObject[key].default; - } - else debug.warn(`${this.constructor.name}: Bad key used in constructor: ${key} default value does not match specified type`, currentObject[key]); - } - } - processObject(settingsData, this._settingsKeys); - debug.log(this._settingsKeys); - } - - get settingsKeys() { return this._settingsKeys } - - // Validate a settings key and the stored value - _validateKey(settingsKey, settingsValue) { - if (!settingsKey) return false; - // debug.log(`Validating ${settingsValue}...`); - const passValidation = ( - settingsKey.type === 'array' && Array.isArray(settingsValue) - || ['float', 'integer', 'number'].includes(settingsKey.type) && typeof(settingsValue) === 'number' - || typeof(settingsValue) === settingsKey.type - ) ? true : false; - // debug.log(passValidation); - return passValidation; - } - - - // Validate an input to be stored in a settings key (e.g. may be a primitive value to be stored in an object type key) - // Returns undefined for failed validation, otherwise returns value ready for storage - _validateNewValue(settingsKey, newValue, options = { forceValidation: null }) { - if (!settingsKey || typeof(settingsKey) !== 'object' || !settingsKey.type || newValue === undefined) return debug.error(`${this.constructor.name}: Bad settings key`, settingsKey); - // Handle keys with validators (Objects and Arrays must have a validator since they can't be passed from Roll20) - if (typeof(options.forceValidation) === 'function') { - if (options.forceValidation(newValue)) return newValue; - else return undefined; - } - else if (typeof(settingsKey.validate) === 'function') { - if (settingsKey.validate(newValue)) return newValue; - else return undefined; - } - // Handle booleans - else if (settingsKey.type === 'boolean') { - if (rx.on.test(newValue)) return true; - else if (rx.off.test(newValue)) return false; - else return undefined; - } - // Otherwise, type match - else if (settingsKey.type === 'integer' && parseInt(newValue) === parseInt(newValue)) return parseInt(newValue); - else if (settingsKey.type === 'float' && parseFloat(newValue) === parseFloat(newValue)) return parseFloat(newValue); - else if (settingsKey.type === typeof(newValue)) return newValue; - else return undefined; - } - - _writeSetting(settingsKey, newValue, options = { overwriteArray: false }) { - const validationOptions = (options.overwriteArray) ? { forceValidation: (v) => Array.isArray(v) } : {}, - validData = this._validateNewValue(settingsKey, newValue, validationOptions); - if (validData === undefined) { - debug.error(`${this.constructor.name}: Settings change not applied, value failed validation`, settingsKey, newValue); - return { err: `${this.constructor.name}: Settings change not applied, value failed validation` } - } - else { - if (settingsKey.type === 'array') { - if (options.overwriteArray && Array.isArray(newValue)) { - settingsKey.value = newValue; - return { msg: `Saved new Array: [${newValue.join(', ')}]` } - } - else return Helpers.modifyArray(settingsKey.value, newValue); - } - else { - settingsKey.value = newValue; - return { msg: `Saved value: ${newValue}` } - } - } - } - - importSettingsValues(importedKeys = {}) { - if (typeof(importedKeys) !== 'object') return debug.error(`${this.constructor.name}: Bad settings import, must only supply object type`); - const processObject = (currentObject, targetPath) => { - for (const key in currentObject) { - if (targetPath[key]) { - if (!targetPath[key].type) { - debug.log(`Skipping ${key}, no type defined`); - continue; - } - if (targetPath[key].type === 'object' && Helpers.isObj(currentObject[key])) { - processObject(currentObject[key], targetPath[key]); - } - else if (this._validateKey(targetPath[key], currentObject[key])) { - targetPath[key].value = currentObject[key]; - } - else debug.warn(`${this.constructor.name}: Key "${key}" failed validation`, currentObject[key]); - } - else debug.warn(`${this.constructor.name}: Key "${key}" does not exist.`, currentObject[key]); - } - } - processObject(importedKeys, this._settingsKeys); - debug.log(this._settingsKeys); - } - - exportSettingsValues() { - const output = {}; - const processObject = (currentObject, targetPath) => { - for (const key in currentObject) { - if (currentObject[key].type === 'object' && Helpers.isObj(currentObject[key])) { - targetPath[key] = {}; - processObject(currentObject[key], targetPath[key]); - } - else if (currentObject[key].type) { - targetPath[key] = currentObject[key].value; - } - } - } - processObject(this._settingsKeys, output); - return output; - } - - // Provide path relative to {Config._settings}, e.g. changeSetting('sheet', 'mySheet'); - // booleans with no "newValue" supplied will be toggled - // Use options.force 'type' to force a type on the setting e.g. array or boolean - // Combine with options.createPath: true to create a new setting of the correct type - updateSetting(pathString, newValue, options = { createPath: false, overwriteArray: false, force: null }) { - if (typeof(pathString) !== 'string' || newValue === undefined) return { err: `Bad path string or no new value supplied.` }; - // Can probably remove this bit now that a .value key is used - const keyName = (pathString.match(/[^/]+$/)||[])[0], - path = /.+\/.+/.test(pathString) ? pathString.match(/(.+)\/[^/]+$/)[1] : '', - configPath = path ? Helpers.getObjectPath(path, this._settingsKeys, options.createPath) : this._settingsKeys, - targetKey = configPath[keyName]; - if (targetKey) { - debug.log(`changeSetting - ${keyName}`, targetKey, options, newValue); - if (targetKey.type === 'boolean') { - newValue = (newValue == null || newValue === '') ? !targetKey.value : - rx.on.test(newValue) ? true : - rx.off.test(newValue) ? false : - newValue; - } - const result = this._writeSetting(targetKey, newValue, options); - if (result.msg) result.msg = `Changed setting: ${pathString}
${result.msg}`; - else if (result.err) result.err = `Changed setting: ${pathString}
${result.err}`; - return result; - } - else { - return { err: `Settings key not found - *${pathString}*` } - } - } - - readSetting(pathString) { - if (typeof(pathString) !== 'string') return; - const targetKey = Helpers.getObjectPath(pathString, this._settingsKeys, false); - return targetKey ? targetKey.value : undefined; - } - - // Export this._settingsKeys as chatbar-friendly text - getMenuText() { - const output = []; - const processObject = (currentObject, targetOutput) => { - for (const key in currentObject) { - if (currentObject[key].type === 'object') { - processObject(currentObject[key], targetOutput); - } - else if (currentObject[key].menuAction) { - const name = currentObject[key].name || key, - hover = currentObject[key].description ? `title="${currentObject[key].description}"` : ``, - settingName = `
${name}
`, - currentSetting = `${currentObject[key].value}`; - // Entry has a custom menu action - if (/^[^$]/.test(currentObject[key].menuAction)) { - targetOutput.push([ settingName, currentObject[key].menuAction ]); - } - // Autofill prompt for boolean or defined range - else { - const queryRange = - currentObject[key].type === 'boolean' ? ['True', 'False'] - : currentObject[key].range ? - currentObject[key].rangeLabels ? currentObject[key].range.map((v,i) => `${currentObject[key].rangeLabels[i]||v},${v}`) - : Helpers.toArray(currentObject[key].range) - : '', - queryString = queryRange ? `?{Select new value|${queryRange.join('|')}}` : `?{Enter new value}`, - cliFlag = (`${currentObject[key].menuAction}`.match(/^\$(.+)/)||[])[1] || `--${key}`, - commandString = `!${scriptName} ${cliFlag} ${queryString}`; - targetOutput.push([ settingName, `${currentSetting}`]); - } - } - } - } - processObject(this._settingsKeys, output); - return output; - } - } - - /** - * Config Controller - Handles user settings via injected SettingsManager, and Custom Button storage via internal _store - */ - class ConfigController { - - _version = { M: 0, m: 0, p: 0 }; - - constructor(scriptName, scriptData={}) { - Object.assign(this, { - name: scriptName || `newScript`, - _settings: new SettingsManager(scriptData.settings) || {}, - _store: scriptData.store || {}, - }); - if (scriptData.version) this.version = scriptData.version; - } - - get version() { return `${this._version.M}.${this._version.m}.${this._version.p}` } - set version(newVersion) { - if (typeof(newVersion) === 'object' && newVersion.M && newVersion.m && newVersion.p) Object.assign(this._version, newVersion); - else { - const parts = `${newVersion}`.split(/\./g); - if (!parts.length) debug.error(`Bad version number, not setting version.`) - else Object.keys(this._version).forEach((v,i) => this._version[v] = parseInt(parts[i]) || 0); - } - } - - initialState() { - return { - version: this.version, - settings: this._settings.exportSettingsValues(), - store: this._store - } - } - - fromStore(path) { return Helpers.getObjectPath(path, this._store, false) } - toStore(path, data) { // Supplying data=null will delete the target - const ref = Helpers.getObjectPath(path, this._store, true); - let msg; - if (ref) { - if (data) { - Object.assign(ref, data); - msg = `New data written to "${path}"`; - } else if (data === null) { - Helpers.getObjectPath(path, this._store, false, true); - msg = `${path} deleted from store.`; - } else return { success: 0, err: `Bad data supplied (type: ${typeof data})` } - } else return { success: 0, err: `Bad store path: "${path}"` } - this.saveToState(); - return { success: 1, msg: msg } - } - - fetchFromState() { - Object.assign(this, { _store: state[scriptName].store, }); - this._settings.importSettingsValues(state[scriptName].settings); - } - saveToState() { - Object.assign(state[scriptName], { - settings: this._settings.exportSettingsValues(), - store: this._store, - }); - } - - changeSetting(pathString, newValue, options) { - options = typeof(options) === 'object' ? options : undefined; - const result = this._settings.updateSetting(pathString, newValue, options); - debug.log(`Setting change attempted`, result); - if (result.msg) this.saveToState(); - return result; - } - getSetting(pathString) { - const currentValue = this._settings.readSetting(pathString); - return (typeof currentValue === 'object') ? Helpers.copyObj(currentValue) : currentValue; - } - loadPreset() { - const currentSheet = this.getSetting('sheet') || ''; - if (Object.keys(preset).includes(currentSheet)) { - // Load template names - this._settings.updateSetting('templates/names', preset[currentSheet].templates.names, { overwriteArray: true }); - // Load damage properties - for (const key in preset[currentSheet].templates.damageProperties) { - // debug.info(`Processing ${key} in preset...`); - this._settings.updateSetting(`templates/damageProperties/${key}`, preset[currentSheet].templates.damageProperties[key], { overwriteArray: true }); - } - this._settings.updateSetting('enabledButtons', preset[currentSheet].defaultButtons || [], { overwriteArray: true }); - this.saveToState(); - return { res: 1, data: `${this.getSetting('sheet')}` } - } else return { res: 0, err: `Preset not found for sheet: "${currentSheet}"`} - } - getSettingsMenu() { - const menuOptions = this._settings.getMenuText(), - confirm = styles.components.confirmApiCommand(`reset to default sheet settings?`), - footerContent = `
Reset Sheet Settings`; - menuOptions.unshift(['Key', 'Setting']); - new ChatDialog({ title: `${scriptName} Settings`, content: menuOptions, footer: footerContent }, 'table'); - } - } - - /** - * Button Manager - Handles CRUD operations, math/query functions and HTML output for all buttons, both internal and Custom Button - */ - class ButtonManager { - - static _buttonKeys = ['sheets', 'content', 'content2', 'content3', 'tooltip', 'style', 'style2', 'style3', 'math', 'default', 'mathString', 'query']; - static _editKeys = ['clone', 'rename']; - _locator = null; - _Config = {}; - _buttons = {}; - - constructor(data={}) { - Object.assign(this, { name: data.name || 'newButtonManager' }); - // Requires access to a ConfigController - this._locator = ServiceLocator.getLocator() || this._locator; - this._Config = this._locator ? this._locator.getService('ConfigController') : null; - if (!this._Config) return {}; - for (let button in data.defaultButtons) { this._buttons[button] = new Button(data.defaultButtons[button], styles) } - } - - get keys() { return ButtonManager._buttonKeys } - get editKeys() { return [ ...ButtonManager._buttonKeys, ...ButtonManager._editKeys ]} - - getButtonNames(filters={ default: null, currentSheet: null, shown: null, hidden: null }) { - let buttons = Object.entries(this._buttons); - const sheet = this._Config.getSetting('sheet'), - enabledButtons = this._Config.getSetting('enabledButtons'); - if (typeof filters.default === 'boolean') buttons = buttons.filter(kv => kv[1].default === filters.default); - if (typeof filters.currentSheet === 'boolean') buttons = buttons.filter(kv => (!kv[1].sheets.length || sheet === 'custom' || (kv[1].sheets.includes(sheet) === filters.currentSheet))); - if (typeof filters.shown === 'boolean') buttons = buttons.filter(kv => (enabledButtons.includes(kv[0]) === filters.shown)); - if (typeof filters.hidden === 'boolean') buttons = buttons.filter(kv => (enabledButtons.includes(kv[0]) === !filters.hidden)); - const output = buttons.map(kv=>kv[0]); - // debug.log(`button names: ${output.join(', ')}`); - return output; - } - - static parseMathString(inputString) { - // debug.info(inputString); - inputString = `${inputString}`; - let err = ''; - // Default buttons will send in a JS function, remove the declaration part - inputString = inputString.replace(/^.*?=>\s*/, ''); - - // Convert to JS - const formulaReplacer = { - '$1Math.floor': /([^.]|^)floor/ig, - '$1Math.ceil': /([^.]|^)ceil/ig, - '$1Math.round': /([^.]|^)round/ig, - '($1||0)': /((damage|crit)\.\w+)/ig, - } - // Very basic security, at least stops a `state = null` - const disallowed = [ /=/g, /\bstate\b/gi ]; - - disallowed.forEach(rx => { if (rx.test(inputString)) err += `Disallowed value in math formula: "${`${rx}`.replace(/(\\\w|\/)/g, '')}"` }); - - let newFormula = inputString; - for (let f in formulaReplacer) newFormula = newFormula.replace(formulaReplacer[f], f); - - // Create a test object - let damageKeys = inputString.match(/(damage|crit)\.(\w+)/g), - testKeys = {}; - damageKeys = damageKeys ? damageKeys.map(k => k.replace(/^[^.]*\./, '')) : []; - damageKeys.forEach(k => testKeys[k] = 5); - - let validate = false, - newFunc; - try { - newFunc = new Function(`damage`, `crit`, `return (${newFormula})`) - validate = isNaN(newFunc(testKeys, testKeys)) ? false : true; - } catch(e) { err += (`${scriptName}: formula failed validation`) } - - if (validate && !err) { - return newFunc; - } else { - return new Error(err); - } - } - addButton(buttonData={}) { - debug.info(buttonData); - const newButton = buttonData.default === false ? new CustomButton(buttonData) : new Button(buttonData); - if (newButton.err) return { success: 0, err: newButton.err } - if (this._buttons[newButton.name]) return { success: 0, err: `Button "${newButton.name}" already exists` }; - this._buttons[newButton.name] = newButton; - this.saveToStore(); - return { success: 1, msg: `New Button "${newButton.name}" successfully created` } - } - editButton(buttonData={}) { - const modded = []; - if (!this._buttons[buttonData.name]) return { success: 0, err: `Button "${buttonData.name}" does not exist.` } - if (this._buttons[buttonData.name].default) return { success: 0, err: `Cannot edit default buttons.` } - this.editKeys.forEach(k => { - debug.log(k, buttonData[k]); - if (buttonData[k] != null) { - if (k === 'default') return; // Don't allow reassignment of 'default' property - else if (k === 'math') { - const newMath = ButtonManager.parseMathString(buttonData[k]); - if (newMath.err) return newMath; - else { - this._buttons[buttonData.name].mathString = buttonData[k]; - this._buttons[buttonData.name].math = newMath; - modded.push(k); - } - } - else if (/^style/.test(k)) { - this._buttons[buttonData.name][k] = styles[buttonData[k]] || buttonData[k] || ''; - modded.push(k); - } - // else if (k === 'query') { - // this._buttons[buttonData.name].query = Button.splitAndEscapeQuery(buttonData.query); - // modded.push(k); - // } - else { - this._buttons[buttonData.name][k] = buttonData[k]; - modded.push(k); - } - } - }); - if (modded.length) this.saveToStore(); - return modded.length ? { success: 1, msg: `Modified ${buttonData.name} fields: ${modded.join(', ')}` } : { success: 0, err: `No fields supplied.` } - } - removeButton(buttonName) { - if (!this._buttons[buttonName]) return { success: 0, err: `Button "${buttonName}" does not exist.` } - if (this._buttons[buttonName].default) return { success: 0, err: `Cannot delete default buttons.` } - delete this._buttons[buttonName]; - this._Config.toStore(`customButtons/${buttonName}`, null); - return { success: 1, msg: `Removed "${buttonName}".` } - } - cloneButton(originalButtonName, newButtonName) { - if (this._buttons[originalButtonName] && newButtonName) { - const cloneName = /\s/.test(newButtonName) ? Helpers.camelise(newButtonName) : newButtonName, - cloneData = { ...this._buttons[originalButtonName], name: cloneName, default: false }, - copyResult = this.addButton(cloneData); - return copyResult.success ? { success: 1, msg: `Cloned button ${originalButtonName} => ${cloneName}` } : copyResult; - } - else return { err: `Could not find button "${originalButtonName}", or bad clone button name "${newButtonName}"` } - } - renameButton(originalButtonName, newButtonName) { - if (!this._buttons[originalButtonName]) return { success: 0, err: `Button "${originalButtonName}" could not be found` }; - if (this._buttons[originalButtonName].default) return { success: 0, err: `Cannot rename a default button.` }; - const cloneName = /\s/.test(newButtonName) ? Helpers.camelise(newButtonName) : newButtonName, - cloneResult = this.cloneButton(originalButtonName, cloneName); - if (cloneResult.success) { - this.removeButton(originalButtonName); - return { success: 1, msg: `Renamed button ${originalButtonName} => ${cloneName}` }; - } - else return cloneResult; - } - showButton(buttonName) { - if (this._buttons[buttonName] && !this._Config.getSetting('enabledButtons').includes(buttonName)) { return this._Config.changeSetting('enabledButtons', buttonName) } - } - hideButton(buttonName) { - if (this._buttons[buttonName] && this._Config.getSetting('enabledButtons').includes(buttonName)) { return this._Config.changeSetting('enabledButtons', buttonName) } - } - saveToStore() { - const customButtons = this.getButtonNames({default: false}); - customButtons.forEach(button => this._Config.toStore(`customButtons/${button}`, Helpers.copyObj(this._buttons[button]))); - // debug.log(this._Config.fromStore('customButtons')); - } - _getReportTemplate(barNumber) { - const template = `'*({name}) {bar${barNumber}_value;before}HP -> {bar${barNumber}_value}HP*'`; - return template; - // Styled report template for if Aaron implements decoding in TM - // const templateRaw = `'
{name}: {bar1_value:before}HP >> {bar1_value}HP
'`; - // return encodeURIComponent(templateRaw); - - // !token-mod --set bar1_value|-[[floor(query*17)]]! - - } - createApiButton(buttonName, damage, crit) { - const btn = this._buttons[buttonName], - autoHide = this._Config.getSetting(`autohide`), - bar = this._Config.getSetting('hpBar'), - overheal = this._Config.getSetting('overheal'), - overkill = this._Config.getSetting('overkill'), - sendReport = (this._Config.getSetting('report')||``).toLowerCase(), - reportString = [ 'all', 'gm', 'control' ].includes(sendReport) - ? ` --report ${sendReport}|${this._getReportTemplate(bar)}` - : ``, - darkMode = this._Config.getSetting('darkMode'); - const zeroBound = this._Config.getSetting('allowNegatives') ? false : true, - boundingPre = zeroBound ? `{0, ` : ``, - boundingPost = zeroBound ? `}kh1` : ``; - const queryString = Button.splitAndEscapeQuery(btn.query) || ''; - // debug.info(reportString); - if (!btn || typeof(btn.math) !== 'function') { - debug.error(`${scriptName}: error creating API button ${buttonName}`); - return ``; - } - const modifier = btn.math(damage, crit), - tooltip = btn.tooltip.replace(/%/, `${modifier} HP`), - setWithQuery = queryString ? `[[${boundingPre}${queryString.replace(/%%MODIFIER%%/g, Math.abs(modifier))}${boundingPost}]]` : `${Math.abs(modifier)}`, - tokenModCmd = (modifier > 0) ? (!overheal) ? `+${setWithQuery}!` : `+${setWithQuery}` : (modifier < 0 && !overkill) ? `-${setWithQuery}!` : `-${setWithQuery}`, - selectOrTarget = (this._Config.getSetting('targetTokens') === true) ? `--ids @{target|token_id} ` : ``, - buttonHref = `!token-mod ${selectOrTarget}--set bar${bar}_value|${tokenModCmd}${reportString}`, - buttonContent = `${btn.content}`, - buttonContent2 = btn.content2 ? `${btn.content2}` : ``, - buttonContent3 = btn.content3 ? `${btn.content3}` : ``; - return (autoHide && modifier == 0) ? - `` - : `
${buttonContent}${buttonContent2}${buttonContent3}
`; - } - verifyButtons() { - const currentSheet = this._Config.getSetting('sheet'), - currentButtons = this._Config.getSetting('enabledButtons'), - validButtons = currentButtons.filter(button => { - if (currentSheet === 'custom' || this._buttons[button] && this._buttons[button].sheets.includes(currentSheet)) return 1; - }); - if (validButtons.length !== currentButtons.length) { - const { success, msg, err } = this._Config.changeSetting('enabledButtons', validButtons); - if (success && msg) new ChatDialog({ content: msg, title: 'Buttons Changed' }); - else if (err) new ChatDialog({ content: err }, 'error'); - } - } - } - - /** - * Button - Basic schema of a Button object - */ - class Button { - constructor(buttonData={}, styleData=styles) { - Object.assign(this, { - name: buttonData.name || 'newButton', - sheets: Array.isArray(buttonData.sheets) ? buttonData.sheets : [], - tooltip: `${buttonData.tooltip || ''}`, - style: styleData[buttonData.style] || buttonData.style || '', - style2: styleData[buttonData.style2] || buttonData.style2 || '', - style3: styleData[buttonData.style3] || buttonData.style3 || '', - content: buttonData.content || '?', - content2: buttonData.content2 || '', - content3: buttonData.content3 || '', - math: buttonData.math || null, - mathString: buttonData.mathString || buttonData.math.toString(), - query: buttonData.query || ``, - default: buttonData.default === false ? false : true, - }); - debug.log(this); - if (typeof(this.math) !== 'function') return { err: `Button "${this.name}" math function failed validation` }; - } - - static splitAndEscapeQuery(queryString) { - if (!queryString || typeof(queryString) !== 'string') return ``; - const replacers = { - '*': `*`, - '+': `+`, - } - const replacerFunction = (m) => replacers[m], - rxQuerySplit = /^[+*/-][+-0]?\|/, - rxReplacers = new RegExp(`[${Object.keys(replacers).reduce((out,v) => out += `\\${v}`, ``)}]`, 'g'); - let operator = (queryString.match(rxQuerySplit)||[])[0] || ``, - query = queryString.replace(rxQuerySplit, ''), - roundingPre = ``, - roundingPost = ``; - // Deal with rounding for * and / - if (/^[*/]/.test(operator)) { - roundingPre = operator[1] === '+' ? - `ceil(` - : `floor(` - roundingPost = `)`; - } - operator = (operator[0]||``).replace(rxReplacers, replacerFunction); - return query ? `${roundingPre}%%MODIFIER%%${operator}?{${query}}${roundingPost}` : ``; - } - } - - /** - * Custom Button - user-made buttons pass through here for validation before being passed to superclass - */ - class CustomButton extends Button { - constructor(buttonData={}) { - debug.log(buttonData); - if (!buttonData.math && !buttonData.mathString) return { err: `Button must contain a function in 'math' key.` }; - Object.assign(buttonData, { - name: buttonData.name || 'newCustomButton', - mathString: buttonData.mathString || buttonData.math.toString(), - math: ButtonManager.parseMathString(buttonData.mathString || buttonData.math.toString()), - style: buttonData.style || 'full', - query: buttonData.query || ``, - default: false, - }); - super(buttonData); - } - } - - /** - * Command Line Interface - handle adding and removing CLI Options, and assess chat input when passed in from HandleInput() - */ - class CommandLineInterface { - - _locator = null; - _services = {}; - _options = {}; - - constructor(cliData={}) { - this.name = cliData.name || `Cli`; - this._locator = ServiceLocator.getLocator(); - if (!this._locator) debug.warn(`${this.constructor.name} could not find the service locator. Any commands relying on services will be disabled.`); - Object.assign(this._services, { - config: this._locator.getService('ConfigController'), - buttons: this._locator.getService('ButtonManager'), - cli: this, - }); - if (cliData.options && cliData.options.length) this.addOptions(cliData.options); - debug.log(`Initialised CLI`); - } - - // Add one or more options to the CLI - addOptions(optionData) { - optionData = Helpers.toArray(optionData); - optionData.forEach(data => { - if (data.name && !this._options[data.name]) { - const suppliedServices = { cli: this } - if (data.requiredServices) { - for (let service in data.requiredServices) { - const svc = - service === 'ConfigController' ? this._services.config - : service === 'ButtonManager' ? this._services.buttons - : this._locator.getService(data.requiredServices[service]); - if (svc) suppliedServices[service] = svc; - else return debug.warn(`${this.name}: Warning - Service "${service}" could not be found for option ${data.name}. CLI option not registered.`); - } - } - data.services = suppliedServices; - this._options[data.name] = new CommandLineOption(data); - } else debug.warn(`Bad data supplied to CLI Option constructor`); - }); - } - - assess(commandArray, reportToChat = true) { - let changed = [], errs = []; - commandArray.forEach(command => { - const cmd = (command.match(/^([^\s]+)/)||[])[1], - args = (command.match(/\s+(.+)/)||['',''])[1]; - for (let option in this._options) { - if (this._options[option].rx.test(cmd)) { - const { msg, err } = (this._options[option].action(args) || {}); - // debug.log(msg||err); - if (msg) changed.push(Helpers.toArray(msg).join('
')); - if (err) errs.push(err); - } - } - }); - if (changed.length && reportToChat) { - // debug.info(changed); - const chatData = { - title: `${scriptName} settings changed`, - content: changed - }; - new ChatDialog(chatData); - } - if (errs.length) new ChatDialog( { title: 'Errors', content: errs }, 'error'); - } - - trigger(option, ...args) { if (this._options[option]) this._options[option].action(...args) } - } - - /** - * Command Line Option - basic model for a user-facing CLI option - */ - class CommandLineOption { - - constructor(optionData={}) { - for (let service in optionData.services) { - this[service] = optionData.services[service]; - } - Object.assign(this, { - name: optionData.name || 'newOption', - rx: optionData.rx || new RegExp(`${optionData.name}`, 'i'), - description: optionData.description || `Description goes here...`, - action: optionData.action - }); - } - - } - - /** - * Chat Dialog - Short-lived layout class which, by default, is sent straight to chat once constructed. - * Can be instantiated and persisted by disabling the default autoSend in the constructor - */ - class ChatDialog { - - static _templates = { - none: ({content}) => `${content}`, - default: ({ title, content }) => { - const msgArray = content ? Helpers.toArray(content) : [], - body = msgArray.map(row => `
${row}
`).join('') - return ` -
-
${title||scriptName}
-
- ${body} -
-
`; - }, - table: ({ title, content, footer, borders }) => { - // debug.log(content); - const rowBorders = borders && borders.row ? styles.table.rowBorders : ``; - const msgArray = content ? Helpers.toArray(content) : [], - columns = msgArray[0].length || 1, - tableRows = msgArray.map((row,i) => { - const tc = i === 0 ? 'th' : 'td', - tcStyle = i === 0 ? styles.table.headerCell : `${styles.table.cell}${rowBorders}`, - trStyle = i === 0 ? styles.table.headerRow : styles.table.row; - let cells = ``; - for (let i=0; i < columns; i++) { cells += `<${tc} style="${tcStyle}">${row[i]}` } - return ` - - ${cells} - `; - }).join(''), - footerContent = footer ? `` : ``; - return ` -
-
${title||scriptName}
-
- - ${tableRows} -
-
- ${footerContent} -
- `; - }, - error: ({ title, content }) => { - const errArray = content ? Helpers.toArray(content) : []; - return ` -
-
${title}
-
${errArray.join('
')}
-
`; - }, - listButtons: ({ header, body, footer }) => { - return ` -
-
${header}
-
- ${body} -
- -
- `; - } - } - - constructor(message, template = 'default', autoSend = true) { - this.msg = ChatDialog._templates[template] ? ChatDialog._templates[template](message) : null; - if (this.msg) { - this.msg = this.msg.replace(/\n/g, ''); - if (autoSend) Helpers.toChat(this.msg); - } else { - debug.warn(`${scriptName}: error creating chat dialog, missing template "${template}"`); - return {}; - } - } - } - - // Tim would get mad if this wasn't here - on('ready', startScript); - -})(); \ No newline at end of file diff --git a/autoButtons/0.8.0/autoButtons.js b/autoButtons/0.8.0/autoButtons.js deleted file mode 100644 index 08ef625b6f..0000000000 --- a/autoButtons/0.8.0/autoButtons.js +++ /dev/null @@ -1,2213 +0,0 @@ -/* globals state log on sendChat playerIsGM findObjs */ //eslint-disable-line -var API_Meta = API_Meta || {}; -API_Meta.autoButtons = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 }; -{ try { throw new Error(''); } catch (e) { API_Meta.autoButtons.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (13)); } } - -const autoButtons = (() => { // eslint-disable-line no-unused-vars - - const scriptName = `autoButtons`, - scriptVersion = `0.8.0`, - debugLevel = 1; - let undoUninstall = null, - cacheBusted = false; - - const debug = { - log: function(...args) { if (debugLevel > 3) console.log(...args) }, - info: function(...args) { if (debugLevel > 2) console.info(...args) }, - warn: function(...args) { if (debugLevel > 1) console.warn(...args) }, - error: function(...args) { if (debugLevel > 0) console.error(...args) }, - } - - /** - * INIT SCRIPT & SETTINGS/CLI ADDITIONS FROM LAST MINOR VERSION - */ - const startScript = () => { - - const Services = new ServiceLocator({ name: 'autoButtonServices' }); - - const Config = new ConfigController(scriptName, { - version: scriptVersion, - store: { - customButtons: {} - }, - settings: { - // 0.6.x => 0.8.0 Setting additions - imageIcons: { - type: 'boolean', - default: true, - name: `Image Icons`, - description: `Render default icons as images (may solve font aligntment issues on Mac / ChromeOS)`, - menuAction: `$--imageicon`, - }, - darkMode: { - type: 'boolean', - default: false, - name: `Dark Mode`, - description: `Palette change for the button bar`, - menuAction: `$--darkMode`, - }, - multiattack: { - type: 'boolean', - default: false, - name: `Multiattack`, - description: `Attempt to link the button bar label to the source attack for easy repeat rolls. 5e only.`, - menuAction: `$--multiattack`, - }, - allowNegatives: { - type: 'boolean', - default: false, - name: `Allow negatives`, - description: `Allow final results to be negative. This can cause healing to cause damage, or damage to heal`, - menuAction: `$--negatives`, - }, - autosort: { - type: 'boolean', - default: false, - name: `Sort buttons`, - description: `Auto sort buttons by unicode order`, - menuAction: `$--autosort`, - }, - autohide: { - type: 'boolean', - default: true, - name: `Autohide buttons`, - description: `Autohide buttons with 0 reported damage`, - menuAction: `$--autohide`, - }, - report: { - type: 'string', - range: [ 'off', 'gm', 'control', 'all' ], - rangeLabels: [ 'Off', 'GM', 'Character', 'Public' ], - validate: function(v) { return this.range.find(r => r.toLowerCase() === v.toLowerCase()) }, - default: 'Off', - name: `Report changes`, - description: `Report hitpoint changes to chat`, - menuAction: `$--report`, - }, - ...defaultScriptSettings, - }, - }); - Services.register({serviceName: 'config', serviceReference: Config }); - - const ButtonStore = new ButtonManager({ - name: 'ButtonStore', - defaultButtons: _defaultButtons, - services: [Services.config], - }); - Services.register({ serviceName: 'buttons', serviceReference: ButtonStore }); - - const CLI = new CommandLineInterface({ - name: `autoButtonsMenu`, - options: defaultCliOptions, - }); - Services.register({ serviceName: 'cli', serviceReference: CLI }); - // v0.6.x => 0.7.0 CLI additions - CLI.addOptions([ - { - name: 'imageIcons', - rx: /^imagei/i, - description: `Render default icons as images (may solve font aligntment issues on Mac / ChromeOS)`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('imageIcons', args) } - }, - { - name: `cloneButton`, - rx: /^clonebut/i, - description: `Clone a button`, - requiredServices: { buttons: 'ButtonManager' }, - action: function(args) { - const parts = args.trim().split(/\s+/g), - originalButtonName = parts[0], - cloneName = parts[1]; - return this.buttons.cloneButton(originalButtonName, cloneName); - } - }, - { - name: `renameButton`, - rx: /^renamebut/i, - description: `Rename a button (Custom buttons only)`, - requiredServices: { buttons: 'ButtonManager' }, - action: function(args) { - const parts = args.trim().split(/\s+/g), - originalButtonName = parts[0], - newName = parts[1]; - return this.buttons.renameButton(originalButtonName, newName); - } - }, - { - name: 'darkMode', - rx: /^dark/i, - description: `Palette change for the button bar`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('darkMode', args) } - }, - { - name: 'multiattack', - rx: /^multiat/i, - description: `Attempt to link the button bar label to the source attack for easy repeat rolls`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('multiattack', args) } - }, - { - name: 'allowNegatives', - rx: /^negative/i, - description: `Allow final results to be negative`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('allowNegatives', args) } - }, - { - name: 'autosort', - rx: /^autosort/i, - description: `Auto sort buttons by unicode order`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('autosort', args) } - }, - { - name: 'autohide', - rx: /^autohide/i, - description: `Autohide buttons with 0 reported damage`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('autohide', args) } - }, - { - name: 'report', - rx: /^report/i, - description: `Change settings for reporting HP changes to chat`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - const newVal = `${args}`.replace(/\W/g, '').toLowerCase(); - return this.config.changeSetting('report', newVal); - } - }, - ]); - - // Check install and version - const checkInstall = () => { - let firstTimeSetup; - setTimeout(() => { if (!/object/i.test(typeof(['token-mod']))) return sendChat(scriptName, `/w gm
tokenMod not found - this script requires tokenMod to function! Aborting init...
`), 500 }); - if (!state[scriptName] || !state[scriptName].version ) { - log(`autoButtons: first time setup...`); - firstTimeSetup = 1; - state[scriptName] = Config.initialState(); - } - if (typeof(state[scriptName].version) === 'number' && state[scriptName].version % 1 !== 0) { state[scriptName].version = `${state[scriptName].version}`.replace(/\D/g, '').split('', 3).join('.') } - if (state[scriptName].version < Config.version) { - const v = state[scriptName].version; - if (v < `0.1.3`) { /* 0.5.3 fix - bad key names for very old versions */ - Object.assign(state[scriptName].settings, { ignoreAPI: 1 }); // new Config key - } - if (v < `0.2.0`) { - Object.assign(state[scriptName].settings, { overkill: 0, overheal: 0, enabledButtons: [] }); // new Config keys - } - if (v < `0.3.0`) { - Config.loadPreset(); // structure of preset has changed - reload - } - if (v < `0.5.0`) { // major refactor - move buttons over to new button store - Helpers.copyOldButtonStore(); - state[scriptName].settings.bump = state[scriptName].settings.bump || true; - state[scriptName].settings.targetTokens = state[scriptName].settings.targetTokens || false; - } - if (v < `0.6.0`) { - // Remove the old buttons store - if (state[scriptName].settings.buttons && state[scriptName].store) delete state[scriptName].settings.buttons; - // Update template property structure - if (state[scriptName].settings.templates.damageProperties.damage && !state[scriptName].settings.templates.damageProperties.damageFields) { - state[scriptName].settings.templates.damageProperties.damageFields = state[scriptName].settings.templates.damageProperties.damage; - delete state[scriptName].settings.templates.damageProperties.damage; - state[scriptName].settings.templates.damageProperties.critFields = state[scriptName].settings.templates.damageProperties.crit; - delete state[scriptName].settings.templates.damageProperties.crit; - } - } - if (v < `0.7.0`) { - // Two default buttons renamed - damageCrit => crit, and damageFull => damage - const currentShownButtons = state[scriptName].settings.enabledButtons; - debug.warn(currentShownButtons); - if (currentShownButtons) { - const { oldDamage, oldCrit } = currentShownButtons.reduce((out, v, i) => v === 'damageCrit' ? { ...out, oldCrit: i } : v === 'damageFull' ? { ...out, oldDamage: i } : out, {}); - if (oldDamage != null) currentShownButtons[oldDamage] = 'damage'; - if (oldCrit != null) currentShownButtons[oldCrit] = 'crit'; - debug.warn(state[scriptName].settings.enabledButtons); - } - } - state[scriptName].version = Config.version; - log(`***UPDATED*** ====> ${scriptName} to v${Config.version}`); - } - Config.fetchFromState(); - if ( - (!Config.getSetting('templates/names') || !Config.getSetting('templates/names').length) || - (!Config.getSetting('enabledButtons') || !Config.getSetting('enabledButtons').length)) { - // debug.log(`Loading preset...`); - if (firstTimeSetup) Config.loadPreset(); - else new ChatDialog({ title: `${scriptName} Install`, content:`No roll templates registered, or no buttons enabled. AutoButtons will not currently do anything. If you're still setting things up, this is probably ok, otherwise you may want to Reset to default sheet settings.` }, 'error'); - } - // Check state of buttons, repair if needed - if (!state[scriptName].store) Helpers.copyOldButtonStore(); - for (const button in state[scriptName].store.customButtons) { - state[scriptName].store.customButtons[button].default = false; - const { err } = ButtonStore.addButton(state[scriptName].store.customButtons[button]); - if (err) debug.error(`${err}`); - } - const allButtons = ButtonStore.getButtonNames(), - enabledButtons = Config.getSetting('enabledButtons'); - const validButtons = enabledButtons.filter(v => allButtons.includes(v)); - if (validButtons.length !== enabledButtons.length) { - debug.warn(`Invalid button found in enabledButtons - button hidden.`); - Config.changeSetting('enabledButtons', validButtons, { overwriteArray: true }); - } - log(`=( Initialised ${scriptName} - v${Config.version} )=`); - } - - // Send buttons to chat - const sendButtons = (damage, crit, msg) => { - const gmOnly = Config.getSetting('gmOnly') ? true : false, - activeButtons = Config.getSetting(`enabledButtons`) || [], - name = Helpers.findName(msg.content), - buttonArray = Config.getSetting('autosort') ? activeButtons.sort((a,b) => a > b ? 1 : -1) : activeButtons, - htmlArray = buttonArray.map(btn => ButtonStore.createApiButton(btn, damage, crit)).filter(v=>v), - darkMode = Config.getSetting('darkMode'); - let sourceAttackAbility; - if (Config.getSetting('multiattack')) sourceAttackAbility = Helpers5e.findNpcAttack(msg, name); - const buttonBarLabel = sourceAttackAbility ? `` : `
${name}
`; - if (htmlArray.length < 1) { - debug.info(`No valid buttons were returned`); - return; - } - const buttonHtml = htmlArray.join(''); - const buttonTemplate = `
${buttonBarLabel}${buttonHtml}
`; - Helpers.toChat(`${buttonTemplate}`, gmOnly); - cacheBusted = true; - } - - // Deconstruct & repackage Roll20 roll object - const handleDamageRoll = (msg) => { - const dmgFields = Config.getSetting('templates/damageProperties/damageFields')||[], - critFields = Config.getSetting('templates/damageProperties/critFields')||[]; - const damage = Helpers.processFields(dmgFields, msg), - crit = Helpers.processFields(critFields, msg); - if ('dnd5e_r20' === Config.getSetting('sheet')) { - const isSpell = Helpers5e.is5eAttackSpell(msg.content); - if (isSpell) { - const upcastDamageFields = Config.getSetting('templates/damageProperties/upcastDamage')||[], - upcastCritFields = Config.getSetting('templates/damageProperties/upcastCrit')||[]; - const upcastDamage = Helpers.processFields(upcastDamageFields, msg), - upcastCrit = Helpers.processFields(upcastCritFields, msg); - Helpers.mergeDamageObjects(damage, upcastDamage); - Helpers.mergeDamageObjects(crit, upcastCrit); - } - } - sendButtons(damage, crit, msg); - } - - // The input... it must be handled - const handleInput = (msg) => { - const msgIsGM = playerIsGM(msg.playerid); - if (msg.type === 'api' && msgIsGM && /^!autobut(ton)?s?\b/i.test(msg.content)) { - const cmdLine = (msg.content.match(/^![^\s]+\s+(.+)/i) || [])[1], - commands = cmdLine ? cmdLine.split(/\s*--\s*/g) : []; - commands.shift(); - debug.log(commands); - if (commands.length) CLI.assess(commands); - } - else if (msg.rolltemplate && Config.getSetting('templates/names').includes(msg.rolltemplate)) { - const ignoreAPI = Config.getSetting('ignoreAPI'); - if (ignoreAPI && /^api$/i.test(msg.playerid)) return; - handleDamageRoll(msg); - } - } - - // Make script do stuff - checkInstall(); - on('chat:message', handleInput); - } - - /** - * SHEET PRESET DATA - */ - // TODO: Replace with PresetController class ??? - const preset = { - dnd5e_r20: { - sheet: ['dnd5e_r20'], - templates: { - names: ['atkdmg', 'dmg', 'npcfullatk', 'npcdmg'], - damageProperties: { - damageFields: ['dmg1', 'dmg2', 'globaldamage'], - critFields: ['crit1', 'crit2', 'globaldamagecrit'], - upcastDamage: ['hldmg'], - upcastCrit: ['hldmgcrit'], - }, - }, - defaultButtons: ['crit', 'critHalf', 'damage', 'damageHalf', 'healingFull'], - }, - custom: { - sheet: [], - templates: { - names: [], - damageProperties: { - damageFields: [], - critFields: [], - }, - }, - defaultButtons: [] - } - } - - /** - * CSS STYLES - */ - const styles = { - error: `color: red; font-weight: bold;`, - outer: `position: relative; vertical-align: middle; font-family: pictos; display: block; background: #f4e6b6; border: 1px solid black; height: auto; line-height: 34px; text-align: center; border-radius: 2px;`, - rollName: `font-family: arial; font-size: 1.1rem; color: black; font-style:italic; font-weight: bold; position:relative; overflow: hidden; display: block; line-height: 1.2rem; margin: 1px 0px 0px 0px; white-space: nowrap; text-align: left; left: 2px;`, - buttonContainer: `display: inline-block; text-align: center; vertical-align: middle; line-height: 26px; margin: auto 5px auto 5px; height: 2.6rem; width: 2.6rem; border: #8c6700 1px solid; box-shadow: 0px 0px 3px #805200; border-radius: 5px; background-color: whitesmoke; position: relative;`, - buttonShared: `background-color: transparent; border: none; border-radius: 5px; padding: 0px; width: 100%; height: 100%; overflow: hidden; white-space: nowrap; position: absolute; top: 0; left: 0; text-decoration: none;`, - crit: `color: darkred; font-size: 2.9rem; line-height: 2.3rem; text-shadow: 0px 0px 2px black;`, - crit2: `color: #ff4040; font-size: 1.8rem; line-height: 2.4rem;`, - full: `color: darkred; font-size: 2.4rem; line-height: 2.3rem; text-shadow: 0px 0px 2px black;`, - half: `color: black; font-family: pictos three; font-size: 2.6rem; line-height: 3rem; text-shadow: 0px 0px 2px black;`, - halfSmall: `color: black; font-family: pictos three; font-size: 2.2rem; line-height: 2.8rem; text-shadow: 0px 0px 1px black;`, - half2: `color: whitesmoke; font-family: cursive; font-size: 0.9rem; line-height: 2.6rem;`, - critHalf: `color: #d51d1d; font-family: pictos three; font-size: 3.2rem; line-height: 2.8rem; text-shadow: 0px 0px 2px black;`, - healFull: `color: green; font-size: 2.4rem; line-height: 2.3rem; text-shadow: 0px 0px 2px black;`, - damageLabel: `font-family: cursive; font-size: 1.2rem; font-weight: bolder; color: #f2c8c8; line-height: 2.4rem;`, - healLabel: `color: #cdf7d1; font-family:cursive; font-size:1.8rem; font-weight:bold; line-height: 2.2rem; text-shadow: 0px 0px 2px white;`, - resist: ` font-family: pictos three; font-size: 2.6rem; line-height: 2.8rem; text-shadow: 0px 0px 2px black; color: #003f82;`, - resistSmall: ` font-family: pictos three; font-size: 2.2rem; line-height: 2.8rem; color: #003f82; text-shadow: 0px 0px 1px black;`, - resistLabel: `font-family: cursive; font-size: 1rem; line-height: 2.6rem; `, - imageIcon: `width: 100%;`, //background-color: transparent; border: none; border-radius: 5px; padding: 0px; - imageIcons: { - damage: `https://s3.amazonaws.com/files.d20.io/images/306656028/gtPy6tdbegC9QOtDd1nf6Q/original.png`, - damageHalf: ``, - crit: ``, - critHalf: ``, - healingFull: ``, - damagePrimary: ``, - damageSecondary: ``, - critPrimary: ``, - critSecondary: ``, - 'resist%': ``, - 'resistN': ``, - 'resistCrit%': ``, - 'resistCritN': ``, - 'resistPrimary%': ``, - 'resistPrimaryN': ``, - 'resistSecondary%': ``, - 'resistSecondaryN': ``, - 'resistPrimaryCrit%': ``, - 'resistPrimaryCritN': ``, - 'resistSecondaryCrit%': ``, - 'resistSecondaryCritN': ``, - }, - darkMode: { - rollName: `color: white;`, - outer: `background: #31302c;`, - buttonContainer: `background-color: #7b7565; border-color: #aea190; box-shadow: 0px 0px 2px #aea190;`, - }, - list: { - container: `font-size: 1.5rem; background: #41415c; border: 5px solid #1c7b74; border-radius: 3px; color: white; vertical-align: middle;`, - header: `text-align: center; font-weight: bold; padding: 6px 0px 6px 0px; border-bottom: solid 1px darkgray; line-height: 1.5em;`, - body: `padding: 8px 1rem 8px 1rem;`, - row: `vertical-align: middle; margin: 0.2em auto 0.2em auto; font-size: 1.2em; line-height: 1.4em;`, - name: `display: inline-block; vertical-align: middle; width: 60%; margin-left: 5%; overflow-x: hidden;`, - faded: `opacity: 0.4;`, - buttonContainer: ` display: inline-block; vertical-align: middle; width: 10%; text-align: center; line-height: 1.2em; text-decoration: none;`, - controls: { - common: `position: relative; font-family: pictos; display: inline-block; background-color: darkgray; padding: 0px; margin: 0px; border: 1px solid #c2c2c2; border-radius: 3px; width: 1.1em; height: 1.1em; line-height: 1.1em; font-size: 1.2em;`, - show: `color: #03650b;`, - hide: `color: #2a2a2a;`, - disabled: `color: gray; cursor: pointer;`, - delete: `color: darkred;`, - create: `display: inline-block; background-color: darkgray; padding: 0px; margin: 1rem 0; border: 1px solid #c2c2c2; border-radius: 3px; color: #066a66; padding: 2px 5px 2px 5px; font-size: 1.1em; line-height: 1.2em;`, - no: `position: absolute; left: 0.4em; font-weight: bold; font-family: arial;` - }, - footer: `text-align: center; font-weight: bold; padding: 6px 0px 6px 0px; border-top: solid 1px darkgray; line-height: 1.5em;`, - }, - table: { - outer: `overflow-x: auto; width: 100%;`, - table: `margin: 1rem auto; width: 95%; justify-content: center; border: 1px solid #7fb07f;`, - headerRow: ``, - row: `background-color: #5e5e63; margin: 0.5rem;`, - headerCell: ` text-align: center; font-size: 1.7rem; padding: 1rem; border-bottom: 1px solid #7fb07f;`, - cell: `padding: 0.2rem 1rem; line-height: 2.5rem; margin: 1px 0px;`, - rowBorders: `border-top: 1px solid #7fb07f;`, - footer: `margin: 0 auto 1.5rem auto;`, - settingName: `border: 1px solid whitesmoke; padding: 0.4rem 0; border-radius: 0.5rem; cursor: help; margin: 1px auto;`, - button: ` display: inline-block; background-color: darkgray; border: 1px solid #cae1df; box-shadow: 0px 0px 3px #bcdbd8; border-radius: 3px; color: #045754; padding: 0.3rem 0.5rem; margin: 0.2rem 0!important; font-size: 1.1em; line-height: 1.2em;`, - }, - components: { - labelWithDelete: function(label, commandString) { - const styleOuter = `border: 1px solid whitesmoke; padding: 0.2rem 0rem; border-radius: 0.5rem; width: max-content; margin: 2px auto; display: inline-block; line-height: 1.2rem; white-space: nowrap;`, - styleDelete = `font-family: pictos; color: darkred; background-color: gray; height: 1rem; line-height: 1.2rem; width: 1.2rem; text-align: center; margin: 0 1rem; border: 1px solid #aaa8a8; border-radius: 0.5rem;`, - styleLabel = `display: inline-block; overflow-x: clip; margin-left: 0.5rem;` - return `
${label}
*
` - }, - confirmApiCommand: function(confirmAction) { - return `!autobut?{Are you sure you wish to ${confirmAction}|Yes, |No,fffff}`; - }, - }, - report: ``, - // BUMP setting CSS - if Roll20 dick with the chatbar CSS this will need to be updated - mods: { - bump: `left: -5px; top: -30px; margin-bottom: -34px;` - } - } - - /** - * DEFAULT BUTTONS - */ - const _defaultButtons = { - crit: { - name: `crit`, - sheets: ['dnd5e_r20'], - tooltip: `Crit (%)`, - style: styles.crit, - style2: styles.crit2, - // style2: styles.critBackground, - math: (damage, crit) => -(damage.total + crit.total), - content: 'k', - content2: 'k' - }, - critHalf: { - name: `critHalf`, - sheets: ['dnd5e_r20'], - tooltip: `Half Crit (%)`, - style: styles.critHalf, - style2: styles.halfSmall, - style3: styles.half2, - math: (damage, crit) => -(Math.floor(0.5 * (damage.total + crit.total))), - content: 'b', - content2: 'b', - content3: '1/2', - }, - damage: { - name: `damage`, - sheets: ['dnd5e_r20'], - tooltip: `Full (%)`, - style: styles.full, - math: (damage) => -(damage.total), - content: 'k', - }, - damageHalf: { - name: `damageHalf`, - sheets: ['dnd5e_r20'], - tooltip: `Half (%)`, - style: styles.half, - style2: styles.half2, - math: (damage) => -(Math.floor(0.5 * damage.total)), - content: 'b', - content2: '1/2', - }, - healingFull: { - name: `healingFull`, - sheets: ['dnd5e_r20'], - tooltip: `Heal (%)`, - style: styles.healFull, - style2: styles.healLabel, - math: (damage) => (damage.total), - content: 'k', - content2: '+', - }, - // Buttons added in 0.6.x - damagePrimary: { - name: `damagePrimary`, - sheets: ['dnd5e_r20'], - tooltip: `Damage 1 (%)`, - style: styles.full, - style2: styles.damageLabel, - math: (damage) => -(damage.dmg1 + (damage.hldmg||0) + damage.globaldamage), - content: 'k', - content2: '1', - }, - damageSecondary: { - name: `damageSecondary`, - sheets: ['dnd5e_r20'], - tooltip: `Damage 2 (%)`, - style: styles.full, - style2: styles.damageLabel, - math: (damage) => -(damage.dmg2), - content: 'k', - content2: '2', - }, - critPrimary: { - name: `critPrimary`, - sheets: ['dnd5e_r20'], - tooltip: `Crit 1 (%)`, - style: styles.crit, - style2: styles.crit2, - style3: styles.damageLabel, - math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg||0) + (crit.hldmgcrit||0) + damage.globaldamage + crit.globaldamagecrit), - content: 'k', - content2: 'k', - content3: '1', - }, - critSecondary: { - name: `critSecondary`, - sheets: ['dnd5e_r20'], - tooltip: `Crit 2 (%)`, - style: styles.crit, - style2: styles.crit2, - style3: styles.damageLabel, - math: (damage, crit) => -(damage.dmg2 + crit.crit2), - content: 'k', - content2: 'k', - content3: '2', - }, - 'resist%': { - name: 'resist%', - sheets: ['dnd5e_r20'], - tooltip: `Damage Resist % (%)`, - style: styles.resist, - style2: styles.resistLabel, - math: (damage) => -(damage.total), - query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, - content: 'b', - content2: '%', - }, - 'resistN': { - name: 'resistN', - sheets: ['dnd5e_r20'], - tooltip: `Damage Resist Flat (%)`, - style: styles.resist, - style2: styles.resistLabel, - math: (damage) => -(damage.total), - query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, - content: 'b', - content2: 'n', - }, - 'resistCrit%': { - name: 'resistCrit%', - sheets: ['dnd5e_r20'], - tooltip: `Crit Resist % (%)`, - style: styles.critHalf, - style2: styles.resistSmall, - style3: styles.resistLabel, - math: (damage, crit) => -(damage.total + crit.total), - query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, - content: 'b', - content2: 'b', - content3: '%', - }, - 'resistCritN': { - name: 'resistCritN', - sheets: ['dnd5e_r20'], - tooltip: `Crit Resist Flat (%)`, - style: styles.critHalf, - style2: styles.resistSmall, - style3: styles.resistLabel, - math: (damage, crit) => -(damage.total + crit.total), - query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, - content: 'b', - content2: 'b', - content3: 'n', - }, - 'resistPrimary%': { - name: 'resistPrimary%', - sheets: ['dnd5e_r20'], - tooltip: `Damage Resist 1 % (%)`, - style: styles.resist, - style2: styles.resistLabel, - math: (damage) => -(damage.dmg1 + (damage.hldmg||0) + damage.globaldamage), - query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, - content: 'b', - content2: '1%', - }, - 'resistPrimaryN': { - name: 'resistPrimaryN', - sheets: ['dnd5e_r20'], - tooltip: `Damage Resist 1 Flat (%)`, - style: styles.resist, - style2: styles.resistLabel, - math: (damage) => -(damage.dmg1 + (damage.hldmg||0) + damage.globaldamage), - query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, - content: 'b', - content2: '1n', - }, - 'resistSecondary%': { - name: 'resistSecondary%', - sheets: ['dnd5e_r20'], - tooltip: `Damage Resist 2 % (%)`, - style: styles.resist, - style2: styles.resistLabel, - math: (damage) => -(damage.dmg2), - query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, - content: 'b', - content2: '%2', - }, - 'resistSecondaryN': { - name: 'resistSecondaryN', - sheets: ['dnd5e_r20'], - tooltip: `Damage Resist 2 Flat (%)`, - style: styles.resist, - style2: styles.resistLabel, - math: (damage) => -(damage.dmg2), - query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, - content: 'b', - content2: 'n2', - }, - 'resistPrimaryCrit%': { - name: 'resistPrimaryCrit%', - sheets: ['dnd5e_r20'], - tooltip: `Crit Resist 1 % (%)`, - style: styles.critHalf, - style2: styles.resistSmall, - style3: styles.resistLabel, - math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg||0) + (crit.hldmgcrit||0) + damage.globaldamage + crit.globaldamagecrit), - query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, - content: 'b', - content2: 'b', - content3: '1%', - }, - 'resistPrimaryCritN': { - name: 'resistPrimaryCritN', - sheets: ['dnd5e_r20'], - tooltip: `Crit Resist 1 Flat (%)`, - style: styles.critHalf, - style2: styles.resistSmall, - style3: styles.resistLabel, - math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg||0) + (crit.hldmgcrit||0) + damage.globaldamage + crit.globaldamagecrit), - query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, - content: 'b', - content2: 'b', - content3: '1n', - }, - 'resistSecondaryCrit%': { - name: 'resistSecondaryCrit%', - sheets: ['dnd5e_r20'], - tooltip: `Crit Resist 2 % (%)`, - style: styles.critHalf, - style2: styles.resistSmall, - style3: styles.resistLabel, - math: (damage, crit) => -(damage.dmg2 + crit.crit2), - query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, - content: 'b', - content2: 'b', - content3: '%2', - }, - 'resistSecondaryCritN': { - name: 'resistSecondaryCritN', - sheets: ['dnd5e_r20'], - tooltip: `Crit Resist 2 Flat (%)`, - style: styles.critHalf, - style2: styles.resistSmall, - style3: styles.resistLabel, - math: (damage, crit) => -(damage.dmg2 + crit.crit2), - query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, - content: 'b', - content2: 'b', - content3: 'n2', - }, - }; - - // Global regex - const rx = { on: /\b(1|true|on)\b/i, off: /\b(0|false|off)\b/i }; - - /** - * HELPER FUNCTIONS - */ - class Helpers { - // Process roll object according to rolltemplate fields - static processFields(fieldArray, msg) { - let output = {} - const rolls = msg.inlinerolls; - output.total = fieldArray.reduce((m, v) => { - const rxIndex = new RegExp(`{${v}=\\$\\[\\[\\d+`, 'g'), - indexResult = msg.content.match(rxIndex); - if (indexResult) { - const index = indexResult.pop().match(/\d+$/)[0], - total = isNaN(rolls[index].results.total) ? 0 : rolls[index].results.total; - output[v] = total; - return m + total; - } else { // if roll template property's inline roll is not found, return 0 to prevent errors down the line - output[v] = 0; - } - return m; - }, 0); - return output; - } - // Simple name finder, provided rolltemplate has some kind of 'name' property - static findName(msgContent) { - const rxRname = /{rname=(.+?)}}/i; - const rxName = /{name=(.+?)}}/i; - let name = msgContent.match(rxRname) || msgContent.match(rxName); - return name ? name[1] : 'Apply:'; - } - // sendChat shortcut - static toChat(msg, whisper = true) { - let prefix = whisper ? `/w gm ` : ''; - sendChat(scriptName, `${prefix}${msg}`, {noarchive: true}); - } - static toArray(inp) { return Array.isArray(inp) ? inp : [inp]; } - static emproper(inpString) { - let words = inpString.split(/\s+/g); - return words.map(w => `${w[0].toUpperCase()}${w.slice(1)}`).join(` `); - } - // Split {{handlebars=moustache}} notation to key:value - static splitHandlebars(inputString) { - let output = {}, - kvArray = inputString.match(/{{[^}]+}}/g)||[]; - kvArray.forEach(kv => { - kv = kv.replace(/({{|}})/g, ''); - const key = kv.match(/^[^=]+/), - value = (kv.match(/=(.+)/)||[])[1] || ``; - if (key) output[key] = value; - }); - return Object.keys(output).length ? output : null; - } - // Camelise a name if user tries to use whitespace - static camelise(inp, options={enforceCase:false}) { - if (typeof(inp) !== 'string') return null; - const words = inp.split(/[\s_]+/g); - return words.map((w,i) => { - const wPre = i > 0 ? w[0].toUpperCase() : w[0].toLowerCase(); - const wSuf = options.enforceCase ? w.slice(1).toLowerCase() : w.slice(1); - return `${wPre}${wSuf}`; - }).join(''); - } - /** - * Grab a dark mode CSS append string if it exists and dark mode is enabled - * @param {string} styleName - keyname of style - * @param {boolean} darkModeEnabled - boolean dark mode setting - * @param {object} stylesPath - parent object of target key/value pair - * @returns {string} - CSS style string - */ - static appendDarkMode(styleName, darkModeEnabled, stylesPath = styles) { - return (!darkModeEnabled || !stylesPath || !stylesPath.darkMode || !stylesPath.darkMode[styleName]) ? `` : stylesPath.darkMode[styleName]; - } - /** - * Check if an object is a basic JS object - * @param {any} input - * @returns {bool} - */ - static isObj(input) { return (typeof(input) === 'object' && input.constructor.name === 'Object') ? true : false } - - static copyObj(inputObj) { return (typeof inputObj !== 'object') ? null : JSON.parse(JSON.stringify(inputObj)); } - - static getObjectPath(pathString, baseObject, createPath, deleteTarget) { - const parts = pathString.split(/\/+/g); - const objRef = parts.reduce((m,v,i) => { - if (m == null) return; - if (m[v] == null) { - if (createPath) m[v] = {}; - else return null; - } - if (deleteTarget && (i === parts.length-1)) delete m[v]; - else return m[v];}, baseObject) - return objRef; - } - - // If value exists in array, it will be removed, otherwise it will be added. No validation done. Does not mutate the original. - static modifyArray(targetArray, newValue) { - if (!Array.isArray(targetArray || newValue == null)) return { err: `modifyArray error, bad parameters` }; - if (targetArray.includes(newValue)) { - Helpers.filterAndMutate(targetArray, (v) => v === newValue); - return { msg: `Removed ${newValue} from array.` } - } - else { - targetArray = targetArray.push(newValue); - return { msg: `Added ${newValue} to array.` } - } - } - - /** - * Filter an array by reference - * @param {array.} inputArray - * @param {function} predicate - * @return {boolean} success/failure - */ - static filterAndMutate(inputArray, predicate) { - if (typeof(predicate) !== 'function' || !Array.isArray(inputArray)) { - debug.error(`filterMutate requires an array and a predicate function.`); - return false; - } - for (let i=inputArray.length-1; i>=0; i--) { - if (predicate(inputArray[i])) inputArray.splice(i, 1); - } - return true; - } - - static copyOldButtonStore() { - let names = []; - state[scriptName].store = state[scriptName].store || {}; - state[scriptName].store.customButtons = Helpers.copyObj(state[scriptName].customButtons) || {}; // copy old store to new store - for (const button in state[scriptName].store.customButtons) { - state[scriptName].store.customButtons[button].name = state[scriptName].store.customButtons[button].name || button; - state[scriptName].store.customButtons[button].mathString = state[scriptName].store.customButtons[button].mathString || state[scriptName].store.customButtons[button].math; - names.push(state[scriptName].store.customButtons[button].name); - } - if (names.length) new ChatDialog({ title: 'Buttons copied to new version', content: names }); - } - - /** - * Recalculate the total key in a damage object - * @param {object} damageObject - */ - - static recalculateDamageTotal(damageObject) { - damageObject.total = 0; - for (const key in damageObject) damageObject.total += key === 'total' ? 0 : damageObject[key]; - } - /** - * Merge two damage objects together and recalculate total - * @param {object} baseObject - * @param {object} addObject - */ - static mergeDamageObjects(baseObject, addObject) { - Object.assign(baseObject, addObject); - Helpers.recalculateDamageTotal(baseObject); - } - } - - /** - * 5E-SPECIFIC HELPERS - */ - class Helpers5e { - // Spell detection - static is5eAttackSpell(msgContent) { - const rxSpell = /{spelllevel=(cantrip|\d+)/; - return rxSpell.test(msgContent) ? 1 : 0; - } - /** - * Find a repeating_npcaction attack from the roll template content. Optionally supply the attack name. - * @param {Object} msg - r20 message object - * @param {string} [attackName] - name of the attack - * @returns {?string} - content of @{rollbase} in the target attack - */ - static findNpcAttack = (msg, attackName) => { - if (!msg.rolltemplate || !/^npc/.test(msg.rolltemplate)) return; - const rx = { - attackName: /rname=(.+?)}}/, - characterName: /{{name=(.+?)}}/, - attackNameAttribute: /^repeating_npcaction_(-[0-z-]{19})_name/i, - }; - attackName = attackName || (msg.content.match(rx.attackName)||[])[1]; - const characterName = (msg.content.match(rx.characterName)||[])[1], - char = findObjs({ type: 'character', name: characterName })[0]; - if (!char || !attackName) return null; - const attackRowId = findObjs({ type: 'attribute', characterid: char.id }).reduce((out, attribute) => { - if (attribute.get('current') === attackName) { - const rowMatch = attribute.get('name').match(rx.attackNameAttribute); - if (rowMatch) return rowMatch[1]; - } - return out; - }, ``); - return attackRowId ? `@{${characterName}|repeating_npcaction_${attackRowId}_rollbase}` : null; - // const targetRollAttribute = findObjs({ type: 'attribute', characterid: char.id, name: `repeating_npcaction_${attackRowId}_rollbase` })[0]; - // if (targetRollAttribute) return targetRollAttribute.get('current'); - } - } - - /** - * COMMAND LINE INTERFACE OPTIONS - */ - const defaultCliOptions = [ - { - name: 'bump', - rx: /^bump/i, - description: `Bump the button UI up to the top of the chat message`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('bump', args, { createPath: true, force: 'boolean' }) } - }, - { - name: 'targetTokens', - rx: /^targett/i, - description: `Use target instead of select for applying damage to tokens`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - const result = this.config.changeSetting('targetTokens', args, { createPath: true, force: 'boolean' }); - if (this.config.getSetting('targetTokens') && result.success && result.msg) result.msg.push(`*Important*: Players cannot use targeting unless TokenMod is set to allow players to use token ids.`); - return result; - } - }, - { - name: 'reset', - rx: /^reset/i, - description: `Reset configuration from preset`, - requiredServices: { config: 'ConfigController' }, - action: function () { - if (this.config.getSetting('sheet')) { - this.config.loadPreset(); - return { success: 1, msg: `Config reset from preset: "${this.config.getSetting('sheet')}"` }; - } else return { err: `No preset found!` }; - } - }, - { - name: 'bar', - rx: /^(hp)?bar/i, - description: `Select which token bar represents hit points`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - const newVal = parseInt(`${args}`.replace(/\D/g, '')); - if (newVal > 0 && newVal < 4) { - return this.config.changeSetting('hpBar', newVal); - } else return { err: `token bar value must be 1, 2 or 3`} - } - }, - { - name: 'loadPreset', - rx: /^loadpre/i, - description: `Select a preset for a Game System`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, - action: function (args) { - const newVal = args.trim(); - if (Object.keys(preset).includes(newVal)) { - const newSheet = this.config.changeSetting('sheet', newVal); - if (newSheet.msg) { - this.config.loadPreset(); - this.buttons.verifyButtons(); - return { success: 1, msg: `Preset changed: ${newVal}` }; - } else return { err: `Error changing preset to "${newVal}"` }; - } else return { err: `Coudln't find sheet/preset: ${args}` } - } - }, - { - name: 'listTemplates', - rx: /^(list)?templ/i, - description: `List roll templates the script is listening for`, - requiredServices: { config: 'ConfigController' }, - action: function () { - const templates = this.config.getSetting(`templates/names`), - confirm = styles.components.confirmApiCommand(`delete this template name?`), - templateText = Helpers.toArray(templates).map(v => [ - // `
${v}
`, - // `Delete` - styles.components.labelWithDelete(v, `${confirm}autobut --deleteTemplate ${v}`) - ]), - footerContent = `Add template`; - templateText.unshift([ 'Template name']); - new ChatDialog({ content: templateText, title: `Roll Template List`, footer: footerContent }, 'table'); - } - }, - { - name: 'addTemplate', - rx: /^addtem/i, - description: `Add roll template name to listen list for damage rolls`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - if (!this.config.getSetting('templates/names').includes(args)) { - const result = this.config.changeSetting('templates/names', args); - if (result.success) result.msg = `Added template ${args} to listener list`; - return result; - } - } - }, - { - name: 'removeTemplate', - rx: /^(remove|delete)tem/i, - description: `Remove roll template from listen list`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - if (this.config.getSetting('templates/names').includes(args)) { - const result = this.config.changeSetting('templates/names', args); - if (result.success) result.msg = `Removed template ${args} to listener list`; - return result; - } - } - }, - { - name: 'listProperties', - rx: /^(list)?(propert|props)/i, - description: `List roll template properties inline rolls are grabbed from`, - requiredServices: { config: 'ConfigController' }, - action: function () { - const properties = this.config.getSetting('templates/damageProperties'), - confirm = styles.components.confirmApiCommand(`delete this template property?`), - styleCategory = `font-size: 1.4rem; font-weight: bold; font-style: italic;` - let templateText = [ ['Category', 'Properties'] ]; - if (typeof properties === 'object') { - for (let category in properties) { - const propButtons = properties[category].map(prop => styles.components.labelWithDelete(prop, `${confirm}autobut --deleteprop ${category}/${prop}`)); - templateText.push([ - `${category}`, - `${propButtons.join(`
`)}
Add Property` - ]); - } - } else return { err: `Error getting damage properties from state` } - new ChatDialog({ title: 'Roll Template Properties', content: templateText, borders: { row: true } }, 'table'); - } - }, - { - name: 'addProperty', - rx: /^addprop/i, - description: `Add a roll template property to the listener`, - requiredServices: { config: 'ConfigController', }, - action: function (args) { - const parts = args.match(/([^/]+)\/(.+)/); - if (parts && parts.length === 3) { - if (this.config.getSetting(`templates/damageProperties/${parts[1]}`) == null) { - Helpers.toChat(`Created new roll template damage property category: ${parts[1]}`); - state[scriptName].settings.templates.damageProperties[parts[1]] = []; - } - return this.config.changeSetting(`templates/damageProperties/${parts[1]}`, parts[2]); - } else { - return { err: `Bad property path supplied, must be in the form "category/propertyName". Example: damage/dmg1` }; - } - } - }, - { - name: 'removeProperty', - rx: /^(remove|delete)?prop/i, - description: `Remove a roll template property from the listener`, - requiredServices: { config: 'ConfigController', }, - action: function (args) { - const parts = args.match(/([^/]+)\/(.+)/); - if (parts && parts.length === 3) { - const currentArray = this.config.getSetting(`templates/damageProperties/${parts[1]}`); - if (currentArray != null) { - const result = this.config.changeSetting(`templates/damageProperties/${parts[1]}`, parts[2]); - if (result.success && !/^(damage|crit)$/i.test(parts[1])) { // Clean up category if it's now empty, and isn't a core category - const newArray = this.config.getSetting(`templates/damageProperties/${parts[1]}`); - if (newArray.length === 0) { - delete state[scriptName].settings.templates.damageProperties[parts[1]]; - result.msg += `\nCategory ${parts[1]} was empty, and was removed.`; - } - } - return result; - } else return { err: `Could not find roll template property category: ${parts[1]}` } - } else { - return { err: `Bad property path supplied, must be in the form "category/propertyName". Example: damage/dmg1` } - } - } - }, - { - name: 'listButtons', - rx: /^(list)?button/i, - description: `List available buttons`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, - action: function() { - const removableButtons = this.buttons.getButtonNames({ default: false }), - usedButtons = this.config.getSetting('enabledButtons'), - unusedButtons = this.buttons.getButtonNames({ hidden: true }), - availableButtons = this.buttons.getButtonNames({ hidden: true, currentSheet: true }), - reorderedButtons = usedButtons.concat(unusedButtons); - const links = { - hide: `!autoButton --hideButton %name%`, - show: `!autoButton --showButton %name%`, - delete: `${styles.components.confirmApiCommand(`delete button %name%?`)}--deleteButton %name%`, - disabled: `#` - } - const labels = { - hide: `E/`, - show: 'E', - delete: 'D', - disabled: '!' - }; - const controls = ['show', 'hide', 'delete']; - const listBody = reorderedButtons.map(button => { - const fadeText = usedButtons.includes(button) ? '' : styles.list.faded; - let rowHtml = `
${removableButtons.includes(button) ? '' : '*'}%name%
`; - controls.forEach(control => { - const controlType = ( - (control === 'show' && availableButtons.includes(button)) || - (control === 'hide' && usedButtons.includes(button)) || - (control === 'delete' && removableButtons.includes(button))) ? - control : 'disabled'; - rowHtml += ``; - }); - return `${rowHtml.replace(/%name%/g, button)}
`; - }); - const headerText = `autoButton list (sheet: ${this.config.getSetting('sheet')})`, - bodyText = listBody.join(''), - footerText = `Create New Button`; - new ChatDialog({ header: headerText, body: bodyText, footer: footerText }, 'listButtons'); - }, - }, - { - name: 'showButton', - rx: /^showbut/i, - description: `Add a button to the button bar`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, - action: function (args) { - const newVal = args.trim(); - const validButtons = this.buttons.getButtonNames({ hidden: true, currentSheet: true }); - if (validButtons.includes(newVal)) { - return this.config.changeSetting('enabledButtons', newVal); - } else new ChatDialog({ title: 'Error', content: `Unrecognised or incompatible button: "${newVal}"` }, 'error'); - } - }, - { - name: 'hideButton', - rx: /^hidebut/i, - description: `Remove a button from the template`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, - action: function (args) { - const newVal = args.trim(); - const validButtons = this.buttons.getButtonNames({ shown: true, currentSheet: true }); - if (validButtons.includes(newVal)) { - return this.config.changeSetting('enabledButtons', newVal); - } else new ChatDialog({ title: 'Error', content: `Unrecognised or incompatible button: "${newVal}"` }, 'error'); - } - }, - { - name: 'reorderButtons', - rx: /^(re)?order/i, - description: `Change order of buttons`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - if (!args) return; - const newIndices = args.replace(/[^\d,]/g, '').split(/,/g), - currentOrder = this.config.getSetting('enabledButtons'); - let newOrder = []; - let valid = true; - newIndices.forEach(buttonIndex => { - const realIndex = buttonIndex - 1; - if (realIndex > -1 && realIndex < currentOrder.length) { - if (currentOrder[realIndex]) { - newOrder.push(currentOrder[realIndex]); - currentOrder[realIndex] = null; - } - } else valid = false; - }); - if (!valid) return { err: `Invalid button order input: ${args}. Indices must be between 1 and total number of buttons in use.` } - newOrder = newOrder.concat(currentOrder.filter(v => v)); - if (newOrder.length === currentOrder.length) return this.config.changeSetting('enabledButtons', newOrder, { overwriteArray: true }); - } - }, - { - name: 'createButton', - rx: /^createbut/i, - description: `Create a new button`, - requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, - action: function (args) { - const buttonData = Helpers.splitHandlebars(args); - if (buttonData && buttonData.name) { - if (/^[^A-Za-z]/.test(buttonData.name)) return { err: `Invalid button name: must start with a letter` }; - let buttonName = /\s/.test(buttonData.name) ? Helpers.camelise(buttonData.name) : buttonData.name; - if (this.buttons.getButtonNames().includes(buttonName)) return { err: `Invalid button name, already in use: "${buttonName}"` } - if (!buttonData.math) return { err: `Button must have an associated function, {{math=...}}` } - buttonData.default = false; - // construct query if provided - // if (buttonData.query) buttonData.query = Button.splitAndEscapeQuery(buttonData.query); - const result = this.buttons.addButton(buttonData); - if (result.success) { - this.buttons.showButton(buttonName); - return result; - } - } else return { err: `Bad input for button creation` } - } - }, - { - name: 'editButton', - rx: /^editbut/i, - description: `Edit an existing button`, - requiredServices: { buttons: 'ButtonManager' }, - action: function (args) { - let buttonData = Helpers.splitHandlebars(args); - debug.log(buttonData); - if (buttonData && buttonData.name) { - if (this.buttons.getButtonNames().includes(buttonData.name)) { - return this.buttons.editButton(buttonData); - } - } - } - }, - { - name: 'deleteButton', - rx: /^del(ete)?but/i, - description: `Remove a button`, - requiredServices: { buttons: 'ButtonManager', config: 'ConfigController' }, - action: function (args) { - const removeResult = this.buttons.removeButton(args.trim()), - buttonIsEnabled = this.config.getSetting('enabledButtons').includes(args); - if (removeResult.success) { - if (buttonIsEnabled) this.config.changeSetting('enabledButtons', args); - return removeResult; - } else return removeResult; - } - }, - { - name: 'ignoreApi', - rx: /^ignoreapi/i, - description: `Ignore anything sent to chat by the API`, - requiredServices: { config: 'ConfigController' }, - action: function(args) { return this.config.changeSetting('ignoreAPI', args) } - }, - { - name: 'overheal', - rx: /^overh/i, - description: `Allow healing to push hp above hpMax`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('overheal', args) } - }, - { - name: 'overkill', - rx: /^overk/i, - description: `Allow healing to push hp above hpMax`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('overkill', args) } - }, - { - name: 'gmOnly', - rx: /^gmo/i, - description: `Whisper the buttons to GM, or post publicly`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('gmOnly', args) } - }, - { - name: 'settings', - rx: /^setting/i, - description: `Open settings UI`, - requiredServices: { config: 'ConfigController' }, - action: function() { this.config.getSettingsMenu() } - }, - { - name: 'help', - rx: /^(\?$|h$|help)/i, - description: `Display script help`, - action: function() { new ChatDialog({ title: `Script Help`, content: `Please visit the autoButtons thread for documentation.` }) } - }, - { - name: 'uninstall', - rx: /^uninstall$/i, - description: `Remove all script settings from API state`, - action: function(args) { - if (/^undo/i.test(args)) { - state[scriptName] = Helpers.copyObj(undoUninstall); - new ChatDialog({ title: 'Reverse! Reverse the reverse!', content: `State settings have been restored. Let's pretend that never happend, eh?` }, 'error') - } else if (!undoUninstall) { - undoUninstall = Helpers.copyObj(state[scriptName]); - state[scriptName] = null; - delete state[scriptName]; - new ChatDialog({ - header: `${scriptName} uninstalled!`, - body: `Removed all ${scriptName} data from API state. Click the 'whoopsie' button below if you didn't mean to destroy all your settings!
Otherwise, all settings will be *permantently* lost on sandbox restart.
Deleting the script now will result in a complete removal of the script and all associated data.`, - footer: `Restore!`, - }, 'listButtons'); - } - } - } - ]; - - /** - * SCRIPT USER-CONFIG OPTIONS - * - * Must have a valid type to be pulled into SettingsManager as a setting - * 'object' type can be used for nesting settings keys - * 'validate' is a validator for the input, not necessarily the key itself (e.g. an array might accept strings in the validator) - * 'name'/'description' are only used for chat menu UI - * 'menuAction' must be supplied. Starting with '$' will automatically convert into a button with leading API command syntax, otherwise supply actual text required - */ - const defaultScriptSettings = { - sheet: { - type: 'string', - range: ['dnd5e_r20', 'custom'], - rangeLabels: [ 'DnD5e by Roll20', 'Custom' ], - validate: function(v) { return this.range.includes(v) }, - default: 'dnd5e_r20', - name: 'Character sheet', - description: 'Character sheet in use', - menuAction: `$--loadPreset` - }, - enabledButtons: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [], - }, - gmOnly: { - type: 'boolean', - default: true, - name: `GM-only buttons`, - description: `Whether the buttons are visible to players`, - menuAction: `$--gmo`, - }, - hpBar: { - type: 'integer', - range: [1,2,3], - validate: function(v) { return this.range.includes(v) }, - default: 1, - name: `Token HP bar`, - description: `Which token bar contains hit points`, - menuAction: `$--bar`, - }, - ignoreAPI: { - type: 'boolean', - default: true, - name: `Ignore API posts`, - description: `Ignore any automated damage rolls made by scripts`, - menuAction: `$--ignoreapi`, - }, - overheal: { - type: 'boolean', - default: false, - name: `Allow overheal`, - description: `Allow HP to go above max`, - menuAction: `$--overheal`, - }, - overkill: { - type: 'boolean', - default: false, - name: `Allow overkill`, - description: `Allow HP to go below 0`, - menuAction: `$--overkill`, - }, - targetTokens: { - type: 'boolean', - default: false, - name: `Target tokens`, - description: `Use a target click to target token, instead of current selection`, - menuAction: `$--targettoken`, - }, - bump: { - type: 'boolean', - default: true, - name: `Slim buttons`, - description: `CSS to bump the button container up in chat to save some space`, - menuAction: `$--bump`, - }, - templates: { - type: 'object', - names: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [], - name: `Roll templates & properties`, - description: `Names of roll templates & properties watched by autoButtons`, - menuAction: `Templates
Properties`, - }, - damageProperties: { - type: 'object', - damageFields: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [], - }, - critFields: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [] - }, - upcastDamage: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [] - }, - upcastCrit: { - type: 'array', - validate: (v) => typeof(v) === 'string', - default: [] - }, - get value() { - const output = {}; - for (const key in this) { - if (key === 'value') continue; - if (this[key].value) output[key] = this[key].value; - } - return output; - } - }, - }, - } - - /** - * CLASS DEFINITIONS - */ - - /** - * Service Locator - Find a registered service from any scope in the script with ServiceLocator.getLocator().getService('serviceName') - */ - class ServiceLocator { - - static _active = null; - _services = {}; - - constructor(services={}) { - if (ServiceLocator._active) return ServiceLocator._active; - this.name = `ServiceLocator`; - for (let svc in services) { this._services[svc] = services[svc] } - ServiceLocator._active = this; - } - - static getLocator() { return ServiceLocator._active } - - register({ serviceName, serviceReference }) { if (!this._services[serviceName]) this._services[serviceName] = serviceReference } - - // Find a service. If service has multiple instances, make sure to request by instance name, or only the first registered constructor name will be returned. - // Search by Class Constructor Name is only suitable for unique class instances - getService(serviceName) { - if (this._services[serviceName]) return { [serviceName]: this._services[serviceName] } - else { - const rxServices = new RegExp(`${serviceName}`, 'i') - for (let service in this._services) { - if (this._services[service].constructor && rxServices.test(this._services[service].constructor.name)) return this._services[service]; - } - } - } - } - - /** - * Settings Manager - Handles fetch and store of user settings to state{} object, reads and writes to user settings. Processes the defaultScriptSettings{} object on init. Access via ConfigManager - */ - class SettingsManager { - - _settingsKeys = {}; - - constructor(settingsData = {}) { - const processObject = (currentObject, targetPath) => { - for (const key in currentObject) { - if (!currentObject[key].type) { - debug.log(`Skipping ${key}, no type found`); - continue; - } - debug.log(`Processing ${key}...`); - if (currentObject[key].type === 'object' && Helpers.isObj(currentObject[key])) { - targetPath[key] = currentObject[key]; - processObject(currentObject[key], targetPath[key]); - } - else if (this._validateKey(currentObject[key], currentObject[key].default)) { - targetPath[key] = currentObject[key]; - targetPath[key].value = currentObject[key].default; - } - else debug.warn(`${this.constructor.name}: Bad key used in constructor: ${key} default value does not match specified type`, currentObject[key]); - } - } - processObject(settingsData, this._settingsKeys); - debug.log(this._settingsKeys); - } - - get settingsKeys() { return this._settingsKeys } - - // Validate a settings key and the stored value - _validateKey(settingsKey, settingsValue) { - if (!settingsKey) return false; - // debug.log(`Validating ${settingsValue}...`); - const passValidation = ( - settingsKey.type === 'array' && Array.isArray(settingsValue) - || ['float', 'integer', 'number'].includes(settingsKey.type) && typeof(settingsValue) === 'number' - || typeof(settingsValue) === settingsKey.type - ) ? true : false; - // debug.log(passValidation); - return passValidation; - } - - - // Validate an input to be stored in a settings key (e.g. may be a primitive value to be stored in an object type key) - // Returns undefined for failed validation, otherwise returns value ready for storage - _validateNewValue(settingsKey, newValue, options = { forceValidation: null }) { - if (!settingsKey || typeof(settingsKey) !== 'object' || !settingsKey.type || newValue === undefined) return debug.error(`${this.constructor.name}: Bad settings key`, settingsKey); - // Handle keys with validators (Objects and Arrays must have a validator since they can't be passed from Roll20) - if (typeof(options.forceValidation) === 'function') { - if (options.forceValidation(newValue)) return newValue; - else return undefined; - } - else if (typeof(settingsKey.validate) === 'function') { - if (settingsKey.validate(newValue)) return newValue; - else return undefined; - } - // Handle booleans - else if (settingsKey.type === 'boolean') { - if (rx.on.test(newValue)) return true; - else if (rx.off.test(newValue)) return false; - else return undefined; - } - // Otherwise, type match - else if (settingsKey.type === 'integer' && parseInt(newValue) === parseInt(newValue)) return parseInt(newValue); - else if (settingsKey.type === 'float' && parseFloat(newValue) === parseFloat(newValue)) return parseFloat(newValue); - else if (settingsKey.type === typeof(newValue)) return newValue; - else return undefined; - } - - _writeSetting(settingsKey, newValue, options = { overwriteArray: false }) { - const validationOptions = (options.overwriteArray) ? { forceValidation: (v) => Array.isArray(v) } : {}, - validData = this._validateNewValue(settingsKey, newValue, validationOptions); - if (validData === undefined) { - debug.error(`${this.constructor.name}: Settings change not applied, value failed validation`, settingsKey, newValue); - return { err: `${this.constructor.name}: Settings change not applied, value failed validation` } - } - else { - if (settingsKey.type === 'array') { - if (options.overwriteArray && Array.isArray(newValue)) { - settingsKey.value = newValue; - return { msg: `Saved new Array: [${newValue.join(', ')}]` } - } - else return Helpers.modifyArray(settingsKey.value, newValue); - } - else { - settingsKey.value = newValue; - return { msg: `Saved value: ${newValue}` } - } - } - } - - importSettingsValues(importedKeys = {}) { - if (typeof(importedKeys) !== 'object') return debug.error(`${this.constructor.name}: Bad settings import, must only supply object type`); - const processObject = (currentObject, targetPath) => { - for (const key in currentObject) { - if (targetPath[key]) { - if (!targetPath[key].type) { - debug.log(`Skipping ${key}, no type defined`); - continue; - } - if (targetPath[key].type === 'object' && Helpers.isObj(currentObject[key])) { - processObject(currentObject[key], targetPath[key]); - } - else if (this._validateKey(targetPath[key], currentObject[key])) { - targetPath[key].value = currentObject[key]; - } - else debug.warn(`${this.constructor.name}: Key "${key}" failed validation`, currentObject[key]); - } - else debug.warn(`${this.constructor.name}: Key "${key}" does not exist.`, currentObject[key]); - } - } - processObject(importedKeys, this._settingsKeys); - debug.log(this._settingsKeys); - } - - exportSettingsValues() { - const output = {}; - const processObject = (currentObject, targetPath) => { - for (const key in currentObject) { - if (currentObject[key].type === 'object' && Helpers.isObj(currentObject[key])) { - targetPath[key] = {}; - processObject(currentObject[key], targetPath[key]); - } - else if (currentObject[key].type) { - targetPath[key] = currentObject[key].value; - } - } - } - processObject(this._settingsKeys, output); - return output; - } - - // Provide path relative to {Config._settings}, e.g. changeSetting('sheet', 'mySheet'); - // booleans with no "newValue" supplied will be toggled - // Use options.force 'type' to force a type on the setting e.g. array or boolean - // Combine with options.createPath: true to create a new setting of the correct type - updateSetting(pathString, newValue, options = { createPath: false, overwriteArray: false, force: null }) { - if (typeof(pathString) !== 'string' || newValue === undefined) return { err: `Bad path string or no new value supplied.` }; - // Can probably remove this bit now that a .value key is used - const keyName = (pathString.match(/[^/]+$/)||[])[0], - path = /.+\/.+/.test(pathString) ? pathString.match(/(.+)\/[^/]+$/)[1] : '', - configPath = path ? Helpers.getObjectPath(path, this._settingsKeys, options.createPath) : this._settingsKeys, - targetKey = configPath[keyName]; - if (targetKey) { - debug.log(`changeSetting - ${keyName}`, targetKey, options, newValue); - if (targetKey.type === 'boolean') { - newValue = (newValue == null || newValue === '') ? !targetKey.value : - rx.on.test(newValue) ? true : - rx.off.test(newValue) ? false : - newValue; - } - const result = this._writeSetting(targetKey, newValue, options); - if (result.msg) result.msg = `Changed setting: ${pathString}
${result.msg}`; - else if (result.err) result.err = `Changed setting: ${pathString}
${result.err}`; - return result; - } - else { - return { err: `Settings key not found - *${pathString}*` } - } - } - - readSetting(pathString) { - if (typeof(pathString) !== 'string') return; - const targetKey = Helpers.getObjectPath(pathString, this._settingsKeys, false); - return targetKey ? targetKey.value : undefined; - } - - // Export this._settingsKeys as chatbar-friendly text - getMenuText() { - const output = []; - const processObject = (currentObject, targetOutput) => { - for (const key in currentObject) { - if (currentObject[key].type === 'object') { - processObject(currentObject[key], targetOutput); - } - else if (currentObject[key].menuAction) { - const name = currentObject[key].name || key, - hover = currentObject[key].description ? `title="${currentObject[key].description}"` : ``, - settingName = `
${name}
`, - currentSetting = `${currentObject[key].value}`; - // Entry has a custom menu action - if (/^[^$]/.test(currentObject[key].menuAction)) { - targetOutput.push([ settingName, currentObject[key].menuAction ]); - } - // Autofill prompt for boolean or defined range - else { - const queryRange = - currentObject[key].type === 'boolean' ? ['True', 'False'] - : currentObject[key].range ? - currentObject[key].rangeLabels ? currentObject[key].range.map((v,i) => `${currentObject[key].rangeLabels[i]||v},${v}`) - : Helpers.toArray(currentObject[key].range) - : '', - queryString = queryRange ? `?{Select new value|${queryRange.join('|')}}` : `?{Enter new value}`, - cliFlag = (`${currentObject[key].menuAction}`.match(/^\$(.+)/)||[])[1] || `--${key}`, - commandString = `!${scriptName} ${cliFlag} ${queryString}`; - targetOutput.push([ settingName, `${currentSetting}`]); - } - } - } - } - processObject(this._settingsKeys, output); - return output; - } - } - - /** - * Config Controller - Handles user settings via injected SettingsManager, and Custom Button storage via internal _store - */ - class ConfigController { - - _version = { M: 0, m: 0, p: 0 }; - - constructor(scriptName, scriptData={}) { - Object.assign(this, { - name: scriptName || `newScript`, - _settings: new SettingsManager(scriptData.settings) || {}, - _store: scriptData.store || {}, - }); - if (scriptData.version) this.version = scriptData.version; - } - - get version() { return `${this._version.M}.${this._version.m}.${this._version.p}` } - set version(newVersion) { - if (typeof(newVersion) === 'object' && newVersion.M && newVersion.m && newVersion.p) Object.assign(this._version, newVersion); - else { - const parts = `${newVersion}`.split(/\./g); - if (!parts.length) debug.error(`Bad version number, not setting version.`) - else Object.keys(this._version).forEach((v,i) => this._version[v] = parseInt(parts[i]) || 0); - } - } - - initialState() { - return { - version: this.version, - settings: this._settings.exportSettingsValues(), - store: this._store - } - } - - fromStore(path) { return Helpers.getObjectPath(path, this._store, false) } - toStore(path, data) { // Supplying data=null will delete the target - const ref = Helpers.getObjectPath(path, this._store, true); - let msg; - if (ref) { - if (data) { - Object.assign(ref, data); - msg = `New data written to "${path}"`; - } else if (data === null) { - Helpers.getObjectPath(path, this._store, false, true); - msg = `${path} deleted from store.`; - } else return { success: 0, err: `Bad data supplied (type: ${typeof data})` } - } else return { success: 0, err: `Bad store path: "${path}"` } - this.saveToState(); - return { success: 1, msg: msg } - } - - fetchFromState() { - Object.assign(this, { _store: state[scriptName].store, }); - this._settings.importSettingsValues(state[scriptName].settings); - } - saveToState() { - Object.assign(state[scriptName], { - settings: this._settings.exportSettingsValues(), - store: this._store, - }); - } - - changeSetting(pathString, newValue, options) { - options = typeof(options) === 'object' ? options : undefined; - const result = this._settings.updateSetting(pathString, newValue, options); - debug.log(`Setting change attempted`, result); - if (result.msg) this.saveToState(); - return result; - } - getSetting(pathString) { - const currentValue = this._settings.readSetting(pathString); - return (typeof currentValue === 'object') ? Helpers.copyObj(currentValue) : currentValue; - } - loadPreset() { - const currentSheet = this.getSetting('sheet') || ''; - if (Object.keys(preset).includes(currentSheet)) { - // Load template names - this._settings.updateSetting('templates/names', preset[currentSheet].templates.names, { overwriteArray: true }); - // Load damage properties - for (const key in preset[currentSheet].templates.damageProperties) { - // debug.info(`Processing ${key} in preset...`); - this._settings.updateSetting(`templates/damageProperties/${key}`, preset[currentSheet].templates.damageProperties[key], { overwriteArray: true }); - } - this._settings.updateSetting('enabledButtons', preset[currentSheet].defaultButtons || [], { overwriteArray: true }); - this.saveToState(); - return { res: 1, data: `${this.getSetting('sheet')}` } - } else return { res: 0, err: `Preset not found for sheet: "${currentSheet}"`} - } - getSettingsMenu() { - const menuOptions = this._settings.getMenuText(), - confirm = styles.components.confirmApiCommand(`reset to default sheet settings?`), - footerContent = `
Reset Sheet Settings`; - menuOptions.unshift(['Key', 'Setting']); - new ChatDialog({ title: `${scriptName} settings
v${scriptVersion}`, content: menuOptions, footer: footerContent }, 'table'); - } - } - - /** - * Button Manager - Handles CRUD operations, math/query functions and HTML output for all buttons, both internal and Custom Button - */ - class ButtonManager { - - static _buttonKeys = ['sheets', 'content', 'content2', 'content3', 'tooltip', 'style', 'style2', 'style3', 'math', 'default', 'mathString', 'query']; - static _editKeys = ['clone', 'rename']; - _locator = null; - _Config = {}; - _buttons = {}; - - constructor(data={}) { - Object.assign(this, { name: data.name || 'newButtonManager' }); - // Requires access to a ConfigController - this._locator = ServiceLocator.getLocator() || this._locator; - this._Config = this._locator ? this._locator.getService('ConfigController') : null; - if (!this._Config) return {}; - for (let button in data.defaultButtons) { this._buttons[button] = new Button(data.defaultButtons[button], styles) } - } - - get keys() { return ButtonManager._buttonKeys } - get editKeys() { return [ ...ButtonManager._buttonKeys, ...ButtonManager._editKeys ]} - - getButtonNames(filters={ default: null, currentSheet: null, shown: null, hidden: null }) { - let buttons = Object.entries(this._buttons); - const sheet = this._Config.getSetting('sheet'), - enabledButtons = this._Config.getSetting('enabledButtons'); - if (typeof filters.default === 'boolean') buttons = buttons.filter(kv => kv[1].default === filters.default); - if (typeof filters.currentSheet === 'boolean') buttons = buttons.filter(kv => (!kv[1].sheets.length || sheet === 'custom' || (kv[1].sheets.includes(sheet) === filters.currentSheet))); - if (typeof filters.shown === 'boolean') buttons = buttons.filter(kv => (enabledButtons.includes(kv[0]) === filters.shown)); - if (typeof filters.hidden === 'boolean') buttons = buttons.filter(kv => (enabledButtons.includes(kv[0]) === !filters.hidden)); - const output = buttons.map(kv=>kv[0]); - // debug.log(`button names: ${output.join(', ')}`); - return output; - } - - static parseMathString(inputString) { - // debug.info(inputString); - inputString = `${inputString}`; - let err = ''; - // Default buttons will send in a JS function, remove the declaration part - inputString = inputString.replace(/^.*?=>\s*/, ''); - - // Convert to JS - const formulaReplacer = { - '$1Math.floor': /([^.]|^)floor/ig, - '$1Math.ceil': /([^.]|^)ceil/ig, - '$1Math.round': /([^.]|^)round/ig, - '($1||0)': /((damage|crit)\.\w+)/ig, - } - // Very basic security, at least stops a `state = null` - const disallowed = [ /=/g, /\bstate\b/gi ]; - - disallowed.forEach(rx => { if (rx.test(inputString)) err += `Disallowed value in math formula: "${`${rx}`.replace(/(\\\w|\/)/g, '')}"` }); - - let newFormula = inputString; - for (let f in formulaReplacer) newFormula = newFormula.replace(formulaReplacer[f], f); - - // Create a test object - let damageKeys = inputString.match(/(damage|crit)\.(\w+)/g), - testKeys = {}; - damageKeys = damageKeys ? damageKeys.map(k => k.replace(/^[^.]*\./, '')) : []; - damageKeys.forEach(k => testKeys[k] = 5); - - let validate = false, - newFunc; - try { - newFunc = new Function(`damage`, `crit`, `return (${newFormula})`) - validate = isNaN(newFunc(testKeys, testKeys)) ? false : true; - } catch(e) { err += (`${scriptName}: formula failed validation`) } - - if (validate && !err) { - return newFunc; - } else { - return new Error(err); - } - } - addButton(buttonData={}) { - debug.info(buttonData); - const newButton = buttonData.default === false ? new CustomButton(buttonData) : new Button(buttonData); - if (newButton.err) return { success: 0, err: newButton.err } - if (this._buttons[newButton.name]) return { success: 0, err: `Button "${newButton.name}" already exists` }; - this._buttons[newButton.name] = newButton; - this.saveToStore(); - return { success: 1, msg: `New Button "${newButton.name}" successfully created` } - } - editButton(buttonData={}) { - const modded = []; - if (!this._buttons[buttonData.name]) return { success: 0, err: `Button "${buttonData.name}" does not exist.` } - if (this._buttons[buttonData.name].default) return { success: 0, err: `Cannot edit default buttons.` } - this.editKeys.forEach(k => { - debug.log(k, buttonData[k]); - if (buttonData[k] != null) { - if (k === 'default') return; // Don't allow reassignment of 'default' property - else if (k === 'math') { - const newMath = ButtonManager.parseMathString(buttonData[k]); - if (newMath.err) return newMath; - else { - this._buttons[buttonData.name].mathString = buttonData[k]; - this._buttons[buttonData.name].math = newMath; - modded.push(k); - } - } - else if (/^style/.test(k)) { - this._buttons[buttonData.name][k] = styles[buttonData[k]] || buttonData[k] || ''; - modded.push(k); - } - // else if (k === 'query') { - // this._buttons[buttonData.name].query = Button.splitAndEscapeQuery(buttonData.query); - // modded.push(k); - // } - else { - this._buttons[buttonData.name][k] = buttonData[k]; - modded.push(k); - } - } - }); - if (modded.length) this.saveToStore(); - return modded.length ? { success: 1, msg: `Modified ${buttonData.name} fields: ${modded.join(', ')}` } : { success: 0, err: `No fields supplied.` } - } - removeButton(buttonName) { - if (!this._buttons[buttonName]) return { success: 0, err: `Button "${buttonName}" does not exist.` } - if (this._buttons[buttonName].default) return { success: 0, err: `Cannot delete default buttons.` } - delete this._buttons[buttonName]; - this._Config.toStore(`customButtons/${buttonName}`, null); - return { success: 1, msg: `Removed "${buttonName}".` } - } - cloneButton(originalButtonName, newButtonName) { - if (this._buttons[originalButtonName] && newButtonName) { - const cloneName = /\s/.test(newButtonName) ? Helpers.camelise(newButtonName) : newButtonName, - cloneData = { ...this._buttons[originalButtonName], name: cloneName, default: false }, - copyResult = this.addButton(cloneData); - return copyResult.success ? { success: 1, msg: `Cloned button ${originalButtonName} => ${cloneName}` } : copyResult; - } - else return { err: `Could not find button "${originalButtonName}", or bad clone button name "${newButtonName}"` } - } - renameButton(originalButtonName, newButtonName) { - if (!this._buttons[originalButtonName]) return { success: 0, err: `Button "${originalButtonName}" could not be found` }; - if (this._buttons[originalButtonName].default) return { success: 0, err: `Cannot rename a default button.` }; - const cloneName = /\s/.test(newButtonName) ? Helpers.camelise(newButtonName) : newButtonName, - cloneResult = this.cloneButton(originalButtonName, cloneName); - if (cloneResult.success) { - this.removeButton(originalButtonName); - return { success: 1, msg: `Renamed button ${originalButtonName} => ${cloneName}` }; - } - else return cloneResult; - } - showButton(buttonName) { - if (this._buttons[buttonName] && !this._Config.getSetting('enabledButtons').includes(buttonName)) { return this._Config.changeSetting('enabledButtons', buttonName) } - } - hideButton(buttonName) { - if (this._buttons[buttonName] && this._Config.getSetting('enabledButtons').includes(buttonName)) { return this._Config.changeSetting('enabledButtons', buttonName) } - } - saveToStore() { - const customButtons = this.getButtonNames({default: false}); - customButtons.forEach(button => this._Config.toStore(`customButtons/${button}`, Helpers.copyObj(this._buttons[button]))); - } - _getReportTemplate(barNumber) { - const template = `'*({name}) {bar${barNumber}_value;before}HP -> {bar${barNumber}_value}HP*'`; - return template; - // Styled report template for if Aaron implements decoding in TM - // const templateRaw = `'
{name}: {bar1_value:before}HP >> {bar1_value}HP
'`; - // return encodeURIComponent(templateRaw); - - // !token-mod --set bar1_value|-[[floor(query*17)]]! - } - _getImageIcon(buttonName, cacheBust, version = '2a') { - if (!cacheBusted) { - cacheBust = true; - } - const url = `https://raw.githubusercontent.com/ooshhub/autoButtons/main/assets/imageIcons/${buttonName}.png?${version}`.replace(/%/g, 'P'); - return cacheBust ? - `${url}${Math.floor(Math.random()*1000000000)}` - : url; - // May need to switch to this if images move - // return styles.imageIcons[buttonName]; - } - createApiButton(buttonName, damage, crit) { - const btn = this._buttons[buttonName], - autoHide = this._Config.getSetting(`autohide`), - bar = this._Config.getSetting('hpBar'), - overheal = this._Config.getSetting('overheal'), - overkill = this._Config.getSetting('overkill'), - sendReport = (this._Config.getSetting('report')||``).toLowerCase(), - reportString = [ 'all', 'gm', 'control' ].includes(sendReport) - ? ` --report ${sendReport}|${this._getReportTemplate(bar)}` - : ``, - darkMode = this._Config.getSetting('darkMode'); - const zeroBound = this._Config.getSetting('allowNegatives') ? false : true, - boundingPre = zeroBound ? `{0, ` : ``, - boundingPost = zeroBound ? `}kh1` : ``; - const queryString = Button.splitAndEscapeQuery(btn.query) || ''; - if (!btn || typeof(btn.math) !== 'function') { - debug.error(`${scriptName}: error creating API button ${buttonName}`); - return ``; - } - const modifier = btn.math(damage, crit), - tooltip = btn.tooltip.replace(/%/, `${modifier} HP`), - setWithQuery = queryString ? `[[${boundingPre}${queryString.replace(/%%MODIFIER%%/g, Math.abs(modifier))}${boundingPost}]]` : `${Math.abs(modifier)}`, - tokenModCmd = (modifier > 0) ? (!overheal) ? `+${setWithQuery}!` : `+${setWithQuery}` : (modifier < 0 && !overkill) ? `-${setWithQuery}!` : `-${setWithQuery}`, - selectOrTarget = (this._Config.getSetting('targetTokens') === true) ? `--ids @{target|token_id} ` : ``, - buttonHref = `!token-mod ${selectOrTarget}--set bar${bar}_value|${tokenModCmd}${reportString}`, - useImageIcon = this._Config.getSetting('imageIcons') && btn.default, - buttonContent = useImageIcon ? `` - : `${btn.content}`, - buttonContent2 = useImageIcon ? `` - : btn.content2 ? `${btn.content2}` : ``, - buttonContent3 = useImageIcon ? `` - : btn.content3 ? `${btn.content3}` : ``; - return (autoHide && modifier == 0) ? - `` - : `
${buttonContent}${buttonContent2}${buttonContent3}
`; - } - verifyButtons() { - const currentSheet = this._Config.getSetting('sheet'), - currentButtons = this._Config.getSetting('enabledButtons'), - validButtons = currentButtons.filter(button => { - if (currentSheet === 'custom' || this._buttons[button] && this._buttons[button].sheets.includes(currentSheet)) return 1; - }); - if (validButtons.length !== currentButtons.length) { - const { success, msg, err } = this._Config.changeSetting('enabledButtons', validButtons); - if (success && msg) new ChatDialog({ content: msg, title: 'Buttons Changed' }); - else if (err) new ChatDialog({ content: err }, 'error'); - } - } - } - - /** - * Button - Basic schema of a Button object - */ - class Button { - constructor(buttonData={}, styleData=styles) { - Object.assign(this, { - name: buttonData.name || 'newButton', - sheets: Array.isArray(buttonData.sheets) ? buttonData.sheets : [], - tooltip: `${buttonData.tooltip || ''}`, - style: styleData[buttonData.style] || buttonData.style || '', - style2: styleData[buttonData.style2] || buttonData.style2 || '', - style3: styleData[buttonData.style3] || buttonData.style3 || '', - content: buttonData.content || '?', - content2: buttonData.content2 || '', - content3: buttonData.content3 || '', - math: buttonData.math || null, - mathString: buttonData.mathString || buttonData.math.toString(), - query: buttonData.query || ``, - default: buttonData.default === false ? false : true, - }); - debug.log(this); - if (typeof(this.math) !== 'function') return { err: `Button "${this.name}" math function failed validation` }; - } - - static splitAndEscapeQuery(queryString) { - if (!queryString || typeof(queryString) !== 'string') return ``; - const replacers = { - '*': `*`, - '+': `+`, - } - const replacerFunction = (m) => replacers[m], - rxQuerySplit = /^[+*/-][+-0]?\|/, - rxReplacers = new RegExp(`[${Object.keys(replacers).reduce((out,v) => out += `\\${v}`, ``)}]`, 'g'); - let operator = (queryString.match(rxQuerySplit)||[])[0] || ``, - query = queryString.replace(rxQuerySplit, ''), - roundingPre = ``, - roundingPost = ``; - // Deal with rounding for * and / - if (/^[*/]/.test(operator)) { - roundingPre = operator[1] === '+' ? - `ceil(` - : `floor(` - roundingPost = `)`; - } - operator = (operator[0]||``).replace(rxReplacers, replacerFunction); - return query ? `${roundingPre}%%MODIFIER%%${operator}?{${query}}${roundingPost}` : ``; - } - } - - /** - * Custom Button - user-made buttons pass through here for validation before being passed to superclass - */ - class CustomButton extends Button { - constructor(buttonData={}) { - debug.log(buttonData); - if (!buttonData.math && !buttonData.mathString) return { err: `Button must contain a function in 'math' key.` }; - Object.assign(buttonData, { - name: buttonData.name || 'newCustomButton', - mathString: buttonData.mathString || buttonData.math.toString(), - math: ButtonManager.parseMathString(buttonData.mathString || buttonData.math.toString()), - style: buttonData.style || 'full', - query: buttonData.query || ``, - default: false, - }); - super(buttonData); - } - } - - /** - * Command Line Interface - handle adding and removing CLI Options, and assess chat input when passed in from HandleInput() - */ - class CommandLineInterface { - - _locator = null; - _services = {}; - _options = {}; - - constructor(cliData={}) { - this.name = cliData.name || `Cli`; - this._locator = ServiceLocator.getLocator(); - if (!this._locator) debug.warn(`${this.constructor.name} could not find the service locator. Any commands relying on services will be disabled.`); - Object.assign(this._services, { - config: this._locator.getService('ConfigController'), - buttons: this._locator.getService('ButtonManager'), - cli: this, - }); - if (cliData.options && cliData.options.length) this.addOptions(cliData.options); - debug.log(`Initialised CLI`); - } - - // Add one or more options to the CLI - addOptions(optionData) { - optionData = Helpers.toArray(optionData); - optionData.forEach(data => { - if (data.name && !this._options[data.name]) { - const suppliedServices = { cli: this } - if (data.requiredServices) { - for (let service in data.requiredServices) { - const svc = - service === 'ConfigController' ? this._services.config - : service === 'ButtonManager' ? this._services.buttons - : this._locator.getService(data.requiredServices[service]); - if (svc) suppliedServices[service] = svc; - else return debug.warn(`${this.name}: Warning - Service "${service}" could not be found for option ${data.name}. CLI option not registered.`); - } - } - data.services = suppliedServices; - this._options[data.name] = new CommandLineOption(data); - } else debug.warn(`Bad data supplied to CLI Option constructor`); - }); - } - - assess(commandArray, reportToChat = true) { - let changed = [], errs = []; - commandArray.forEach(command => { - const cmd = (command.match(/^([^\s]+)/)||[])[1], - args = (command.match(/\s+(.+)/)||['',''])[1]; - for (let option in this._options) { - if (this._options[option].rx.test(cmd)) { - const { msg, err } = (this._options[option].action(args) || {}); - // debug.log(msg||err); - if (msg) changed.push(Helpers.toArray(msg).join('
')); - if (err) errs.push(err); - } - } - }); - if (changed.length && reportToChat) { - // debug.info(changed); - const chatData = { - title: `${scriptName} settings changed`, - content: changed - }; - new ChatDialog(chatData); - } - if (errs.length) new ChatDialog( { title: 'Errors', content: errs }, 'error'); - } - - trigger(option, ...args) { if (this._options[option]) this._options[option].action(...args) } - } - - /** - * Command Line Option - basic model for a user-facing CLI option - */ - class CommandLineOption { - - constructor(optionData={}) { - for (let service in optionData.services) { - this[service] = optionData.services[service]; - } - Object.assign(this, { - name: optionData.name || 'newOption', - rx: optionData.rx || new RegExp(`${optionData.name}`, 'i'), - description: optionData.description || `Description goes here...`, - action: optionData.action - }); - } - - } - - /** - * Chat Dialog - Short-lived layout class which, by default, is sent straight to chat once constructed. - * Can be instantiated and persisted by disabling the default autoSend in the constructor - */ - class ChatDialog { - - static _templates = { - none: ({content}) => `${content}`, - default: ({ title, content }) => { - const msgArray = content ? Helpers.toArray(content) : [], - body = msgArray.map(row => `
${row}
`).join('') - return ` -
-
${title||scriptName}
-
- ${body} -
-
`; - }, - table: ({ title, content, footer, borders }) => { - // debug.log(content); - const rowBorders = borders && borders.row ? styles.table.rowBorders : ``; - const msgArray = content ? Helpers.toArray(content) : [], - columns = msgArray[0].length || 1, - tableRows = msgArray.map((row,i) => { - const tc = i === 0 ? 'th' : 'td', - tcStyle = i === 0 ? styles.table.headerCell : `${styles.table.cell}${rowBorders}`, - trStyle = i === 0 ? styles.table.headerRow : styles.table.row; - let cells = ``; - for (let i=0; i < columns; i++) { cells += `<${tc} style="${tcStyle}">${row[i]}` } - return ` - - ${cells} - `; - }).join(''), - footerContent = footer ? `` : ``; - return ` -
-
${title||scriptName}
-
- - ${tableRows} -
-
- ${footerContent} -
- `; - }, - error: ({ title, content }) => { - const errArray = content ? Helpers.toArray(content) : []; - return ` -
-
${title}
-
${errArray.join('
')}
-
`; - }, - listButtons: ({ header, body, footer }) => { - return ` -
-
${header}
-
- ${body} -
- -
- `; - } - } - - constructor(message, template = 'default', autoSend = true) { - this.msg = ChatDialog._templates[template] ? ChatDialog._templates[template](message) : null; - if (this.msg) { - this.msg = this.msg.replace(/\n/g, ''); - if (autoSend) Helpers.toChat(this.msg); - } else { - debug.warn(`${scriptName}: error creating chat dialog, missing template "${template}"`); - return {}; - } - } - } - - on('ready', startScript); - -})(); -{ try { throw new Error(''); } catch (e) { API_Meta.autoButtons.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.autoButtons.offset); } } -/* */ \ No newline at end of file diff --git a/autoButtons/0.8.9/autoButtons.js b/autoButtons/0.8.9/autoButtons.js new file mode 100644 index 0000000000..7089c950b3 --- /dev/null +++ b/autoButtons/0.8.9/autoButtons.js @@ -0,0 +1,2377 @@ +var API_Meta = API_Meta || {}; +API_Meta.autoButtons = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 }; +{ try { throw new Error(''); } catch (e) { API_Meta.autoButtons.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (13)); } } + +const autoButtons = (() => { + + const scriptName = `autoButtons`, + scriptVersion = `0.8.9d`, + mathOpsZeroPatch = true, + debugLevel = 1; + let undoUninstall = null, + cacheBusted = false; + + const debug = { + log: function(...args) { if (debugLevel > 3) console.log(...args) }, + info: function(...args) { if (debugLevel > 2) console.info(...args) }, + warn: function(...args) { if (debugLevel > 1) console.warn(...args) }, + error: function(...args) { if (debugLevel > 0) console.error(...args) }, + } + + /** + * INIT SCRIPT & SETTINGS/CLI ADDITIONS FROM LAST MINOR VERSION + */ + const startScript = () => { + + const Services = new ServiceLocator({ name: 'autoButtonServices' }); + + const Config = new ConfigController(scriptName, { + version: scriptVersion, + store: { + customButtons: {} + }, + settings: { + ...defaultScriptSettings, + }, + }); + Services.register({serviceName: 'config', serviceReference: Config }); + + const ButtonStore = new ButtonManager({ + name: 'ButtonStore', + defaultButtons: _defaultButtons, + services: [Services.config], + }); + Services.register({ serviceName: 'buttons', serviceReference: ButtonStore }); + + const CLI = new CommandLineInterface({ + name: `autoButtonsMenu`, + options: defaultCliOptions, + }); + Services.register({ serviceName: 'cli', serviceReference: CLI }); + + const checkDependencies = async () => { + let err; + try { + err = typeof(MathOps) !== 'object' || typeof(TokenMod) !== 'object' + ? `${scriptName}: requires TokenMod and MathOps` + : typeof(MathOps.MathProcessor) !== 'function' + ? `${scriptName}: a newer version of MathOps is required.` + : null; + } + catch(e) { err = `${scriptName} dependencies could not be resolved - MathOps and TokenMod are required.` } + if (err) new ChatDialog({ title: `Fatal Error - ${scriptName} exiting...`, content: err }, 'error'); + return !err; + } + + // Check install and version + const checkInstall = async () => { + let firstTimeSetup; + if (!(await checkDependencies())) return; + if (!state[scriptName] || !state[scriptName].version ) { + log(`autoButtons: first time setup...`); + firstTimeSetup = 1; + state[scriptName] = Config.initialState(); + } + if (typeof(state[scriptName].version) === 'number' && state[scriptName].version % 1 !== 0) { state[scriptName].version = `${state[scriptName].version}`.replace(/\D/g, '').split('', 3).join('.') } + if (state[scriptName].version < Config.version) { + const v = state[scriptName].version; + if (v < `0.1.3`) { /* 0.5.3 fix - bad key names for very old versions */ + Object.assign(state[scriptName].settings, { ignoreAPI: 1 }); // new Config key + } + if (v < `0.2.0`) { + Object.assign(state[scriptName].settings, { overkill: 0, overheal: 0, enabledButtons: [] }); // new Config keys + } + if (v < `0.3.0`) { + Config.loadPreset(); // structure of preset has changed - reload + } + if (v < `0.5.0`) { // major refactor - move buttons over to new button store + Helpers.copyOldButtonStore(); + state[scriptName].settings.bump = state[scriptName].settings.bump || true; + state[scriptName].settings.targetTokens = state[scriptName].settings.targetTokens || false; + } + if (v < `0.6.0`) { + // Remove the old buttons store + if (state[scriptName].settings.buttons && state[scriptName].store) delete state[scriptName].settings.buttons; + // Update template property structure + if (state[scriptName].settings.templates.damageProperties.damage && !state[scriptName].settings.templates.damageProperties.damageFields) { + state[scriptName].settings.templates.damageProperties.damageFields = state[scriptName].settings.templates.damageProperties.damage; + delete state[scriptName].settings.templates.damageProperties.damage; + state[scriptName].settings.templates.damageProperties.critFields = state[scriptName].settings.templates.damageProperties.crit; + delete state[scriptName].settings.templates.damageProperties.crit; + } + } + if (v < `0.7.0`) { + // Two default buttons renamed - damageCrit => crit, and damageFull => damage + const currentShownButtons = state[scriptName].settings.enabledButtons; + debug.log(currentShownButtons); + if (currentShownButtons) { + const { oldDamage, oldCrit } = currentShownButtons.reduce((out, v, i) => v === 'damageCrit' ? { ...out, oldCrit: i } : v === 'damageFull' ? { ...out, oldDamage: i } : out, {}); + if (oldDamage != null) currentShownButtons[oldDamage] = 'damage'; + if (oldCrit != null) currentShownButtons[oldCrit] = 'crit'; + debug.log(state[scriptName].settings.enabledButtons); + } + } + if (v < `0.8.9`) { + log(`Backing up math strings on custom buttons...`); + if (state[scriptName].store && state[scriptName].store.customButtons) { + for (const button in state[scriptName].store.customButtons) { + if (state[scriptName].store.customButtons[button].mathString) { + state[scriptName].store.customButtons[button].mathBackup = state[scriptName].store.customButtons[button].mathString; + } + } + } + } + state[scriptName].version = Config.version; + log(`***UPDATED*** ====> ${scriptName} to v${Config.version}`); + } + Config.fetchFromState(); + if ( + (!Config.getSetting('templates/names') || !Config.getSetting('templates/names').length) || + (!Config.getSetting('enabledButtons') || !Config.getSetting('enabledButtons').length)) { + if (firstTimeSetup) Config.loadPreset(); + else new ChatDialog({ title: `${scriptName} Install`, content:`No roll templates registered, or no buttons enabled. AutoButtons will not currently do anything. If you're still setting things up, this is probably ok, otherwise you may want to Reset to default sheet settings.` }, 'error'); + } + // Check state of buttons, repair if needed + if (!state[scriptName].store) Helpers.copyOldButtonStore(); + for (const button in state[scriptName].store.customButtons) { + state[scriptName].store.customButtons[button].default = false; + const { err } = ButtonStore.addButton(state[scriptName].store.customButtons[button]); + const errorString = `${err}`; + if (err) { + new ChatDialog({ title: `${scriptName}: invalid button **${button}**`, content: errorString }); // **${state[scriptName].store.customButtons[button].name}** - ${err} + const recoverButton = { ...state[scriptName].store.customButtons[button], mathString: '0' }; + const { err } = ButtonStore.addButton(recoverButton); + if (!err) { + new ChatDialog({ title: `${scriptName}: recovered button`, content: `Button math was cleared, the problem math string was ${recoverButton.mathBackup}.` }); + } + } + } + const allButtons = ButtonStore.getButtonNames(), + enabledButtons = Config.getSetting('enabledButtons'); + const validButtons = enabledButtons.filter(v => allButtons.includes(v)); + if (validButtons.length !== enabledButtons.length) { + debug.warn(`Invalid button found in enabledButtons - button hidden.`); + Config.changeSetting('enabledButtons', validButtons, { overwriteArray: true }); + } + log(`=( Initialised ${scriptName} - v${Config.version} )=`); + } + + // Send buttons to chat + const sendButtons = (damage, crit, msg) => { + const gmOnly = Config.getSetting('gmOnly') ? true : false, + activeButtons = Config.getSetting(`enabledButtons`) || [], + name = Helpers.findName(msg.content), + buttonArray = Config.getSetting('autosort') ? activeButtons.sort((a,b) => a > b ? 1 : -1) : activeButtons, + htmlArray = buttonArray.map(btn => ButtonStore.createApiButton(btn, damage, crit)).filter(v=>v), + darkMode = Config.getSetting('darkMode'); + let sourceAttackAbility; + if (Config.getSetting('multiattack')) sourceAttackAbility = Helpers5e.findNpcAttack(msg, name); + const buttonBarLabel = sourceAttackAbility ? `` : `
${name}
`; + if (htmlArray.length < 1) { + debug.info(`No valid buttons were returned`); + return; + } + const buttonHtml = htmlArray.join(''); + const buttonTemplate = `
${buttonBarLabel}${buttonHtml}
`; + Helpers.toChat(`${buttonTemplate}`, gmOnly); + cacheBusted = true; + } + + // Deconstruct & repackage Roll20 roll object + const handleDamageRoll = (msg) => { + const dmgFields = Config.getSetting('templates/damageProperties/damageFields')||[], + critFields = Config.getSetting('templates/damageProperties/critFields')||[]; + const damage = Helpers.processFields(dmgFields, msg), + crit = Helpers.processFields(critFields, msg); + if ('dnd5e_r20' === Config.getSetting('sheet')) { + const isSpell = Helpers5e.is5eAttackSpell(msg.content); + if (isSpell) { + const upcastDamageFields = Config.getSetting('templates/damageProperties/upcastDamage')||[], + upcastCritFields = Config.getSetting('templates/damageProperties/upcastCrit')||[]; + const upcastDamage = Helpers.processFields(upcastDamageFields, msg), + upcastCrit = Helpers.processFields(upcastCritFields, msg); + Helpers.mergeDamageObjects(damage, upcastDamage); + Helpers.mergeDamageObjects(crit, upcastCrit); + } + } + sendButtons(damage, crit, msg); + } + + // The input... it must be handled + const handleInput = (msg) => { + const msgIsGM = playerIsGM(msg.playerid); + if (msg.type === 'api' && msgIsGM && /^!autobut(ton)?s?\b/i.test(msg.content)) { + const cmdLine = (msg.content.match(/^![^\s]+\s+(.+)/i) || [])[1], + commands = cmdLine ? cmdLine.split(/\s*--\s*/g) : []; + commands.shift(); + debug.log(commands); + if (commands.length) CLI.assess(commands); + } + else if (msg.rolltemplate && Config.getSetting('templates/names').includes(msg.rolltemplate)) { + const ignoreAPI = Config.getSetting('ignoreAPI'); + if (ignoreAPI && /^api$/i.test(msg.playerid)) return; + handleDamageRoll(msg); + } + } + + // Make script do stuff + checkInstall(); + on('chat:message', handleInput); + } + + /** + * SHEET PRESET DATA + */ + const preset = { + dnd5e_r20: { + sheet: ['dnd5e_r20'], + templates: { + names: ['atkdmg', 'dmg', 'npcfullatk', 'npcdmg'], + damageProperties: { + damageFields: ['dmg1', 'dmg2', 'globaldamage'], + critFields: ['crit1', 'crit2', 'globaldamagecrit'], + upcastDamage: ['hldmg'], + upcastCrit: ['hldmgcrit'], + }, + }, + defaultButtons: ['crit', 'critHalf', 'damage', 'damageHalf', 'healingFull'], + }, + custom: { + sheet: [], + templates: { + names: [], + damageProperties: { + damageFields: [], + critFields: [], + }, + }, + defaultButtons: [] + } + } + + /** + * CSS STYLES + */ + const styles = { + error: `color: red; font-weight: bold;`, + outer: `position: relative; vertical-align: middle; font-family: pictos; display: block; background: #f4e6b6; border: 1px solid black; height: auto; line-height: 34px; text-align: center; border-radius: 2px;`, + rollName: `font-family: arial; font-size: 1.1rem; color: black; font-style:italic; font-weight: bold; position:relative; overflow: hidden; display: block; line-height: 1.2rem; margin: 1px 0px 0px 0px; white-space: nowrap; text-align: left; left: 2px;`, + buttonContainer: `display: inline-block; text-align: center; vertical-align: middle; line-height: 26px; margin: auto 5px auto 5px; height: 2.6rem; width: 2.6rem; border: #8c6700 1px solid; box-shadow: 0px 0px 3px #805200; border-radius: 5px; background-color: whitesmoke; position: relative;`, + buttonShared: `background-color: transparent; border: none; border-radius: 5px; padding: 0px; width: 100%; height: 100%; overflow: hidden; white-space: nowrap; position: absolute; top: 0; left: 0; text-decoration: none;`, + crit: `color: darkred; font-size: 2.9rem; line-height: 2.3rem; text-shadow: 0px 0px 2px black;`, + crit2: `color: #ff4040; font-size: 1.8rem; line-height: 2.4rem;`, + full: `color: darkred; font-size: 2.4rem; line-height: 2.3rem; text-shadow: 0px 0px 2px black;`, + half: `color: black; font-family: pictos three; font-size: 2.6rem; line-height: 3rem; text-shadow: 0px 0px 2px black;`, + halfSmall: `color: black; font-family: pictos three; font-size: 2.2rem; line-height: 2.8rem; text-shadow: 0px 0px 1px black;`, + half2: `color: whitesmoke; font-family: cursive; font-size: 0.9rem; line-height: 2.6rem;`, + critHalf: `color: #d51d1d; font-family: pictos three; font-size: 3.2rem; line-height: 2.8rem; text-shadow: 0px 0px 2px black;`, + healFull: `color: green; font-size: 2.4rem; line-height: 2.3rem; text-shadow: 0px 0px 2px black;`, + damageLabel: `font-family: cursive; font-size: 1.2rem; font-weight: bolder; color: #f2c8c8; line-height: 2.4rem;`, + healLabel: `color: #cdf7d1; font-family:cursive; font-size:1.8rem; font-weight:bold; line-height: 2.2rem; text-shadow: 0px 0px 2px white;`, + resist: ` font-family: pictos three; font-size: 2.6rem; line-height: 2.8rem; text-shadow: 0px 0px 2px black; color: #003f82;`, + resistSmall: ` font-family: pictos three; font-size: 2.2rem; line-height: 2.8rem; color: #003f82; text-shadow: 0px 0px 1px black;`, + resistLabel: `font-family: cursive; font-size: 1rem; line-height: 2.6rem; `, + imageIcon: `width: 100%;`, //background-color: transparent; border: none; border-radius: 5px; padding: 0px; + imageIcons: { + damage: `https://s3.amazonaws.com/files.d20.io/images/306656028/gtPy6tdbegC9QOtDd1nf6Q/original.png`, + damageHalf: ``, + crit: ``, + critHalf: ``, + healingFull: ``, + damagePrimary: ``, + damageSecondary: ``, + critPrimary: ``, + critSecondary: ``, + 'resist%': ``, + 'resistN': ``, + 'resistCrit%': ``, + 'resistCritN': ``, + 'resistPrimary%': ``, + 'resistPrimaryN': ``, + 'resistSecondary%': ``, + 'resistSecondaryN': ``, + 'resistPrimaryCrit%': ``, + 'resistPrimaryCritN': ``, + 'resistSecondaryCrit%': ``, + 'resistSecondaryCritN': ``, + }, + darkMode: { + rollName: `color: white;`, + outer: `background: #31302c;`, + buttonContainer: `background-color: #7b7565; border-color: #aea190; box-shadow: 0px 0px 2px #aea190;`, + }, + list: { + container: `font-size: 1.5rem; background: #41415c; border: 5px solid #1c7b74; border-radius: 3px; color: white; vertical-align: middle;`, + header: `text-align: center; font-weight: bold; padding: 6px 0px 6px 0px; border-bottom: solid 1px darkgray; line-height: 1.5em;`, + body: `padding: 8px 1rem 8px 1rem;`, + row: `vertical-align: middle; margin: 0.2em auto 0.2em auto; font-size: 1.2em; line-height: 1.4em;`, + name: `display: inline-block; vertical-align: middle; width: 60%; margin-left: 5%; overflow-x: hidden;`, + faded: `opacity: 0.4;`, + buttonContainer: ` display: inline-block; vertical-align: middle; width: 10%; text-align: center; line-height: 1.2em; text-decoration: none;`, + controls: { + common: `position: relative; font-family: pictos; display: inline-block; background-color: darkgray; padding: 0px; margin: 0px; border: 1px solid #c2c2c2; border-radius: 3px; width: 1.1em; height: 1.1em; line-height: 1.1em; font-size: 1.2em;`, + show: `color: #03650b;`, + hide: `color: #2a2a2a;`, + disabled: `color: gray; cursor: pointer;`, + delete: `color: darkred;`, + create: `display: inline-block; background-color: darkgray; padding: 0px; margin: 1rem 0; border: 1px solid #c2c2c2; border-radius: 3px; color: #066a66; padding: 2px 5px 2px 5px; font-size: 1.1em; line-height: 1.2em;`, + no: `position: absolute; left: 0.4em; font-weight: bold; font-family: arial;` + }, + footer: `text-align: center; font-weight: bold; padding: 6px 0px 6px 0px; border-top: solid 1px darkgray; line-height: 1.5em;`, + }, + table: { + outer: `overflow-x: auto; width: 100%;`, + table: `margin: 1rem auto; width: 95%; justify-content: center; border: 1px solid #7fb07f;`, + headerRow: ``, + row: `background-color: #5e5e63; margin: 0.5rem;`, + headerCell: ` text-align: center; font-size: 1.7rem; padding: 1rem; border-bottom: 1px solid #7fb07f;`, + cell: `padding: 0.2rem 1rem; line-height: 2.5rem; margin: 1px 0px;`, + rowBorders: `border-top: 1px solid #7fb07f;`, + footer: `margin: 0 auto 1.5rem auto;`, + settingName: `border: 1px solid whitesmoke; padding: 0.4rem 0; border-radius: 0.5rem; cursor: help; margin: 1px auto;`, + button: ` display: inline-block; background-color: darkgray; border: 1px solid #cae1df; box-shadow: 0px 0px 3px #bcdbd8; border-radius: 3px; color: #045754; padding: 0.3rem 0.5rem; margin: 0.2rem 0!important; font-size: 1.1em; line-height: 1.2em;`, + }, + components: { + labelWithDelete: function(label, commandString) { + const styleOuter = `border: 1px solid whitesmoke; padding: 0.2rem 0rem; border-radius: 0.5rem; width: max-content; margin: 2px auto; display: inline-block; line-height: 1.2rem; white-space: nowrap;`, + styleDelete = `font-family: pictos; color: darkred; background-color: gray; height: 1rem; line-height: 1.2rem; width: 1.2rem; text-align: center; margin: 0 1rem; border: 1px solid #aaa8a8; border-radius: 0.5rem;`, + styleLabel = `display: inline-block; overflow-x: clip; margin-left: 0.5rem;` + return `
${label}
*
` + }, + confirmApiCommand: function(confirmAction) { + return `!autobut?{Are you sure you wish to ${confirmAction}|Yes, |No,fffff}`; + }, + }, + report: ``, + // BUMP setting CSS - if Roll20 dick with the chatbar CSS this will need to be updated + mods: { + bump: `left: -5px; top: -30px; margin-bottom: -34px;` + } + } + + /** + * DEFAULT BUTTONS + */ + const _defaultButtons = { + crit: { + name: `crit`, + sheets: ['dnd5e_r20'], + tooltip: `Crit (%)`, + style: styles.crit, + style2: styles.crit2, + // style2: styles.critBackground, + math: (damage, crit) => -(damage.total + crit.total), + content: 'k', + content2: 'k' + }, + critHalf: { + name: `critHalf`, + sheets: ['dnd5e_r20'], + tooltip: `Half Crit (%)`, + style: styles.critHalf, + style2: styles.halfSmall, + style3: styles.half2, + math: (damage, crit) => -(Math.floor(0.5 * (damage.total + crit.total))), + content: 'b', + content2: 'b', + content3: '1/2', + }, + damage: { + name: `damage`, + sheets: ['dnd5e_r20'], + tooltip: `Full (%)`, + style: styles.full, + math: (damage) => -(damage.total), + content: 'k', + }, + damageHalf: { + name: `damageHalf`, + sheets: ['dnd5e_r20'], + tooltip: `Half (%)`, + style: styles.half, + style2: styles.half2, + math: (damage) => -(Math.floor(0.5 * damage.total)), + content: 'b', + content2: '1/2', + }, + healingFull: { + name: `healingFull`, + sheets: ['dnd5e_r20'], + tooltip: `Heal (%)`, + style: styles.healFull, + style2: styles.healLabel, + math: (damage) => (damage.total), + content: 'k', + content2: '+', + }, + // Buttons added in 0.6.x + damagePrimary: { + name: `damagePrimary`, + sheets: ['dnd5e_r20'], + tooltip: `Damage 1 (%)`, + style: styles.full, + style2: styles.damageLabel, + math: (damage) => -(damage.dmg1 + (damage.hldmg||0) + damage.globaldamage), + content: 'k', + content2: '1', + }, + damageSecondary: { + name: `damageSecondary`, + sheets: ['dnd5e_r20'], + tooltip: `Damage 2 (%)`, + style: styles.full, + style2: styles.damageLabel, + math: (damage) => -(damage.dmg2), + content: 'k', + content2: '2', + }, + critPrimary: { + name: `critPrimary`, + sheets: ['dnd5e_r20'], + tooltip: `Crit 1 (%)`, + style: styles.crit, + style2: styles.crit2, + style3: styles.damageLabel, + math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg||0) + (crit.hldmgcrit||0) + damage.globaldamage + crit.globaldamagecrit), + content: 'k', + content2: 'k', + content3: '1', + }, + critSecondary: { + name: `critSecondary`, + sheets: ['dnd5e_r20'], + tooltip: `Crit 2 (%)`, + style: styles.crit, + style2: styles.crit2, + style3: styles.damageLabel, + math: (damage, crit) => -(damage.dmg2 + crit.crit2), + content: 'k', + content2: 'k', + content3: '2', + }, + 'resist%': { + name: 'resist%', + sheets: ['dnd5e_r20'], + tooltip: `Damage Resist % (%)`, + style: styles.resist, + style2: styles.resistLabel, + math: (damage) => -(damage.total), + query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, + content: 'b', + content2: '%', + }, + 'resistN': { + name: 'resistN', + sheets: ['dnd5e_r20'], + tooltip: `Damage Resist Flat (%)`, + style: styles.resist, + style2: styles.resistLabel, + math: (damage) => -(damage.total), + query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, + content: 'b', + content2: 'n', + }, + 'resistCrit%': { + name: 'resistCrit%', + sheets: ['dnd5e_r20'], + tooltip: `Crit Resist % (%)`, + style: styles.critHalf, + style2: styles.resistSmall, + style3: styles.resistLabel, + math: (damage, crit) => -(damage.total + crit.total), + query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, + content: 'b', + content2: 'b', + content3: '%', + }, + 'resistCritN': { + name: 'resistCritN', + sheets: ['dnd5e_r20'], + tooltip: `Crit Resist Flat (%)`, + style: styles.critHalf, + style2: styles.resistSmall, + style3: styles.resistLabel, + math: (damage, crit) => -(damage.total + crit.total), + query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, + content: 'b', + content2: 'b', + content3: 'n', + }, + 'resistPrimary%': { + name: 'resistPrimary%', + sheets: ['dnd5e_r20'], + tooltip: `Damage Resist 1 % (%)`, + style: styles.resist, + style2: styles.resistLabel, + math: (damage) => -(damage.dmg1 + (damage.hldmg||0) + damage.globaldamage), + query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, + content: 'b', + content2: '1%', + }, + 'resistPrimaryN': { + name: 'resistPrimaryN', + sheets: ['dnd5e_r20'], + tooltip: `Damage Resist 1 Flat (%)`, + style: styles.resist, + style2: styles.resistLabel, + math: (damage) => -(damage.dmg1 + (damage.hldmg||0) + damage.globaldamage), + query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, + content: 'b', + content2: '1n', + }, + 'resistSecondary%': { + name: 'resistSecondary%', + sheets: ['dnd5e_r20'], + tooltip: `Damage Resist 2 % (%)`, + style: styles.resist, + style2: styles.resistLabel, + math: (damage) => -(damage.dmg2), + query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, + content: 'b', + content2: '%2', + }, + 'resistSecondaryN': { + name: 'resistSecondaryN', + sheets: ['dnd5e_r20'], + tooltip: `Damage Resist 2 Flat (%)`, + style: styles.resist, + style2: styles.resistLabel, + math: (damage) => -(damage.dmg2), + query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, + content: 'b', + content2: 'n2', + }, + 'resistPrimaryCrit%': { + name: 'resistPrimaryCrit%', + sheets: ['dnd5e_r20'], + tooltip: `Crit Resist 1 % (%)`, + style: styles.critHalf, + style2: styles.resistSmall, + style3: styles.resistLabel, + math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg||0) + (crit.hldmgcrit||0) + damage.globaldamage + crit.globaldamagecrit), + query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, + content: 'b', + content2: 'b', + content3: '1%', + }, + 'resistPrimaryCritN': { + name: 'resistPrimaryCritN', + sheets: ['dnd5e_r20'], + tooltip: `Crit Resist 1 Flat (%)`, + style: styles.critHalf, + style2: styles.resistSmall, + style3: styles.resistLabel, + math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg||0) + (crit.hldmgcrit||0) + damage.globaldamage + crit.globaldamagecrit), + query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, + content: 'b', + content2: 'b', + content3: '1n', + }, + 'resistSecondaryCrit%': { + name: 'resistSecondaryCrit%', + sheets: ['dnd5e_r20'], + tooltip: `Crit Resist 2 % (%)`, + style: styles.critHalf, + style2: styles.resistSmall, + style3: styles.resistLabel, + math: (damage, crit) => -(damage.dmg2 + crit.crit2), + query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, + content: 'b', + content2: 'b', + content3: '%2', + }, + 'resistSecondaryCritN': { + name: 'resistSecondaryCritN', + sheets: ['dnd5e_r20'], + tooltip: `Crit Resist 2 Flat (%)`, + style: styles.critHalf, + style2: styles.resistSmall, + style3: styles.resistLabel, + math: (damage, crit) => -(damage.dmg2 + crit.crit2), + query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, + content: 'b', + content2: 'b', + content3: 'n2', + }, + }; + + // Global regex + const rx = { on: /\b(1|true|on)\b/i, off: /\b(0|false|off)\b/i }; + + /** + * HELPER FUNCTIONS + */ + class Helpers { + // Process roll object according to rolltemplate fields + static processFields(fieldArray, msg) { + let output = {} + const rolls = msg.inlinerolls; + output.total = fieldArray.reduce((m, v) => { + const rxIndex = new RegExp(`{${v}=\\$\\[\\[\\d+`, 'g'), + indexResult = msg.content.match(rxIndex); + if (indexResult) { + const index = indexResult.pop().match(/\d+$/)[0], + total = isNaN(rolls[index].results.total) ? 0 : rolls[index].results.total; + output[v] = total; + return m + total; + } else { // if roll template property's inline roll is not found, return 0 to prevent errors down the line + output[v] = 0; + } + return m; + }, 0); + return output; + } + // Simple name finder, provided rolltemplate has some kind of 'name' property + static findName(msgContent) { + const rxRname = /{rname=(.+?)}}/i; + const rxName = /{name=(.+?)}}/i; + let name = msgContent.match(rxRname) || msgContent.match(rxName); + return name ? name[1] : 'Apply:'; + } + // sendChat shortcut + static toChat(msg, whisper = true) { + let prefix = whisper ? `/w gm ` : ''; + sendChat(scriptName, `${prefix}${msg}`, {noarchive: true}); + } + static toArray(inp) { return Array.isArray(inp) ? inp : [inp]; } + static emproper(inpString) { + let words = inpString.split(/\s+/g); + return words.map(w => `${w[0].toUpperCase()}${w.slice(1)}`).join(` `); + } + // Split {{handlebars=moustache}} notation to key:value + static splitHandlebars(inputString) { + let output = {}, + kvArray = inputString.match(/{{[^}]+}}/g)||[]; + kvArray.forEach(kv => { + kv = kv.replace(/({{|}})/g, ''); + const key = kv.match(/^[^=]+/), + value = (kv.match(/=(.+)/)||[])[1] || ``; + if (key) output[key] = value; + }); + return Object.keys(output).length ? output : null; + } + // Camelise a name if user tries to use whitespace + static camelise(inp, options={enforceCase:false}) { + if (typeof(inp) !== 'string') return null; + const words = inp.split(/[\s_]+/g); + return words.map((w,i) => { + const wPre = i > 0 ? w[0].toUpperCase() : w[0].toLowerCase(); + const wSuf = options.enforceCase ? w.slice(1).toLowerCase() : w.slice(1); + return `${wPre}${wSuf}`; + }).join(''); + } + /** + * Grab a dark mode CSS append string if it exists and dark mode is enabled + * @param {string} styleName - keyname of style + * @param {boolean} darkModeEnabled - boolean dark mode setting + * @param {object} stylesPath - parent object of target key/value pair + * @returns {string} - CSS style string + */ + static appendDarkMode(styleName, darkModeEnabled, stylesPath = styles) { + return (!darkModeEnabled || !stylesPath || !stylesPath.darkMode || !stylesPath.darkMode[styleName]) ? `` : stylesPath.darkMode[styleName]; + } + /** + * Check if an object is a basic JS object + * @param {any} input + * @returns {bool} + */ + static isObj(input) { + return (typeof(input) === 'object' && (!input.constructor || !input.constructor.name || input.constructor.name === 'Object')) ? true : false; + } + + static copyObj(inputObj) { return (typeof inputObj !== 'object') ? null : JSON.parse(JSON.stringify(inputObj)); } + + static getObjectPath(pathString, baseObject, createPath, deleteTarget) { + const parts = pathString.split(/\/+/g); + const objRef = parts.reduce((m,v,i) => { + if (m == null) return; + if (m[v] == null) { + if (createPath) m[v] = {}; + else return null; + } + if (deleteTarget && (i === parts.length-1)) delete m[v]; + else return m[v];}, baseObject) + return objRef; + } + + // If value exists in array, it will be removed, otherwise it will be added. No validation done. + static modifyArray(targetArray, newValue) { + if (!Array.isArray(targetArray || newValue == null)) return { err: `modifyArray error, bad parameters` }; + if (targetArray.includes(newValue)) { + Helpers.filterAndMutate(targetArray, (v) => v === newValue); + return { msg: `Removed ${newValue} from array.` } + } + else { + targetArray = targetArray.push(newValue); + return { msg: `Added ${newValue} to array.` } + } + } + + /** + * Filter an array by reference + * @param {array.} inputArray + * @param {function} predicate + * @return {boolean} success/failure + */ + static filterAndMutate(inputArray, predicate) { + if (typeof(predicate) !== 'function' || !Array.isArray(inputArray)) { + debug.error(`filterAndMutate requires an array and a predicate function.`); + return false; + } + for (let i=inputArray.length-1; i>=0; i--) { + if (predicate(inputArray[i])) inputArray.splice(i, 1); + } + return true; + } + + static copyOldButtonStore() { + let names = []; + state[scriptName].store = state[scriptName].store || {}; + state[scriptName].store.customButtons = Helpers.copyObj(state[scriptName].customButtons) || {}; // copy old store to new store + for (const button in state[scriptName].store.customButtons) { + state[scriptName].store.customButtons[button].name = state[scriptName].store.customButtons[button].name || button; + state[scriptName].store.customButtons[button].mathString = state[scriptName].store.customButtons[button].mathString || state[scriptName].store.customButtons[button].math; + names.push(state[scriptName].store.customButtons[button].name); + } + if (names.length) new ChatDialog({ title: 'Buttons copied to new version', content: names }); + } + + /** + * Recalculate the total key in a damage object + * @param {object} damageObject + */ + + static recalculateDamageTotal(damageObject) { + damageObject.total = 0; + for (const key in damageObject) damageObject.total += key === 'total' ? 0 : damageObject[key]; + } + /** + * Merge two damage objects together and recalculate total + * @param {object} baseObject + * @param {object} addObject + */ + static mergeDamageObjects(baseObject, addObject) { + Object.assign(baseObject, addObject); + Helpers.recalculateDamageTotal(baseObject); + } + } + + /** + * 5E-SPECIFIC HELPERS + */ + class Helpers5e { + // Spell detection + static is5eAttackSpell(msgContent) { + const rxSpell = /{spelllevel=(cantrip|\d+)/; + return rxSpell.test(msgContent) ? 1 : 0; + } + /** + * Find a repeating_npcaction attack from the roll template content. Optionally supply the attack name. + * @param {Object} msg - r20 message object + * @param {string} [attackName] - name of the attack + * @returns {?string} - content of @{rollbase} in the target attack + */ + static findNpcAttack = (msg, attackName) => { + if (!msg.rolltemplate || !/^npc/.test(msg.rolltemplate)) return; + const rx = { + attackName: /rname=(.+?)}}/, + characterName: /{{name=(.+?)}}/, + attackNameAttribute: /^repeating_npcaction_(-[0-z-]{19})_name/i, + }; + attackName = attackName || (msg.content.match(rx.attackName)||[])[1]; + const characterName = (msg.content.match(rx.characterName)||[])[1], + char = findObjs({ type: 'character', name: characterName })[0]; + if (!char || !attackName) return null; + const attackRowId = findObjs({ type: 'attribute', characterid: char.id }).reduce((out, attribute) => { + if (attribute.get('current') === attackName) { + const rowMatch = attribute.get('name').match(rx.attackNameAttribute); + if (rowMatch) return rowMatch[1]; + } + return out; + }, ``); + return attackRowId ? `@{${characterName}|repeating_npcaction_${attackRowId}_rollbase}` : null; + // const targetRollAttribute = findObjs({ type: 'attribute', characterid: char.id, name: `repeating_npcaction_${attackRowId}_rollbase` })[0]; + // if (targetRollAttribute) return targetRollAttribute.get('current'); + } + } + + /** + * MATH-OPS - Transform autoButtons math strings and damage objects for MathOps API + */ + class MathOpsTransformer { + constructor() { + throw new Error(`${this.constructor.name} cannot be instantiated.`); + } + + static rxKeyDigitReplacer = /(damage||crit)\.(\w+)/g; + static replacers = { + 0: 'Zero', + 1: 'One', + 2: 'Two', + 3: 'Three', + 4: 'Four', + 5: 'Five', + 6: 'Six', + 7: 'Seven', + 8: 'Eight', + 9: 'Nine', + }; + static prefixJoin = 'X'; + + /** + * Replace all digits in a string with alpha characters + * @param {string} inputString + * @returns {string} + */ + static digitReplacer(inputString) { + if (!/\d/.test(inputString)) return inputString; + let modifiedString = inputString; + for (const digit in this.replacers) { + const rxReplacer = new RegExp(digit, 'g'); + modifiedString = modifiedString.replace(rxReplacer, this.replacers[digit]); + } + return modifiedString; + } + + /** + * Transform the keynames in the damage object to make them MathOps-friendly + * @param {object} damageObject - autoButtons damage object with damage values + * @param {string} prefix - prefix string, damage or crit + * @returns {object} - autoButtons damage object with numerals replaced with alpha character in key names + */ + static transformDamageObject(damageObject, prefix) { + return Object.entries(damageObject).reduce((output, [ key, value ]) => { + const newKey = `${prefix}${this.prefixJoin}${this.digitReplacer(key)}`; + output[newKey] = value; + return output; + }, {}); + } + + /** + * Transform a math string for MathOps - same transform as the damage objects + * @param {string} mathString - autoButtons math string + * @returns {string} - math string with key references transformed to remove digits + */ + static transformMathString(mathString) { + const doTransform = (match, prefix, keyName) => { + return `${prefix}${this.prefixJoin}${this.digitReplacer(keyName)}`; + } + const transform = mathString.replace(this.rxKeyDigitReplacer, doTransform); + return /^\s*[+-]/.test(transform) + ? `0${transform}` + : transform; + } + + /** + * Transform the damage and crit objects for use with MathOps + * @param {object} damageObject - autoButtons damage object with damage values + * @param {object} critObject - autoButtons crit object with damage values + * @returns {object} - flattened object with all numerals in keynames replaced with alpha characters, prefixed with parent object name + */ + static transformMathOpsPayload(damageObject, critObject = {}) { + return { + ...this.transformDamageObject(damageObject, 'damage'), + ...this.transformDamageObject(critObject, 'crit'), + } + } + } + + /** + * COMMAND LINE INTERFACE OPTIONS + */ + const defaultCliOptions = [ + { + name: 'bump', + rx: /^bump/i, + description: `Bump the button UI up to the top of the chat message`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { return this.config.changeSetting('bump', args, { createPath: true, force: 'boolean' }) } + }, + { + name: 'targetTokens', + rx: /^targett/i, + description: `Use target instead of select for applying damage to tokens`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { + const result = this.config.changeSetting('targetTokens', args, { createPath: true, force: 'boolean' }); + if (this.config.getSetting('targetTokens') && result.success && result.msg) result.msg.push(`*Important*: Players cannot use targeting unless TokenMod is set to allow players to use token ids.`); + return result; + } + }, + { + name: 'reset', + rx: /^reset/i, + description: `Reset configuration from preset`, + requiredServices: { config: 'ConfigController' }, + action: function () { + if (this.config.getSetting('sheet')) { + this.config.loadPreset(); + return { success: 1, msg: `Config reset from preset: "${this.config.getSetting('sheet')}"` }; + } else return { err: `No preset found!` }; + } + }, + { + name: 'bar', + rx: /^(hp)?bar/i, + description: `Select which token bar represents hit points`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { + const newVal = parseInt(`${args}`.replace(/\D/g, '')); + if (newVal > 0 && newVal < 4) { + return this.config.changeSetting('hpBar', newVal); + } else return { err: `token bar value must be 1, 2 or 3`} + } + }, + { + name: 'loadPreset', + rx: /^loadpre/i, + description: `Select a preset for a Game System`, + requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, + action: function (args) { + const newVal = args.trim(); + if (Object.keys(preset).includes(newVal)) { + const newSheet = this.config.changeSetting('sheet', newVal); + if (newSheet.msg) { + this.config.loadPreset(); + this.buttons.verifyButtons(); + return { success: 1, msg: `Preset changed: ${newVal}` }; + } else return { err: `Error changing preset to "${newVal}"` }; + } else return { err: `Coudln't find sheet/preset: ${args}` } + } + }, + { + name: 'listTemplates', + rx: /^(list)?templ/i, + description: `List roll templates the script is listening for`, + requiredServices: { config: 'ConfigController' }, + action: function () { + const templates = this.config.getSetting(`templates/names`), + confirm = styles.components.confirmApiCommand(`delete this template name?`), + templateText = Helpers.toArray(templates).map(v => [ + // `
${v}
`, + // `Delete` + styles.components.labelWithDelete(v, `${confirm}autobut --deleteTemplate ${v}`) + ]), + footerContent = `Add template`; + templateText.unshift([ 'Template name']); + new ChatDialog({ content: templateText, title: `Roll Template List`, footer: footerContent }, 'table'); + } + }, + { + name: 'addTemplate', + rx: /^addtem/i, + description: `Add roll template name to listen list for damage rolls`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { + if (!this.config.getSetting('templates/names').includes(args)) { + const result = this.config.changeSetting('templates/names', args); + if (result.success) result.msg = `Added template ${args} to listener list`; + return result; + } + } + }, + { + name: 'removeTemplate', + rx: /^(remove|delete)tem/i, + description: `Remove roll template from listen list`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { + if (this.config.getSetting('templates/names').includes(args)) { + const result = this.config.changeSetting('templates/names', args); + if (result.success) result.msg = `Removed template ${args} to listener list`; + return result; + } + } + }, + { + name: 'listProperties', + rx: /^(list)?(propert|props)/i, + description: `List roll template properties inline rolls are grabbed from`, + requiredServices: { config: 'ConfigController' }, + action: function () { + const properties = this.config.getSetting('templates/damageProperties'), + confirm = styles.components.confirmApiCommand(`delete this template property?`), + styleCategory = `font-size: 1.4rem; font-weight: bold; font-style: italic;` + let templateText = [ ['Category', 'Properties'] ]; + if (typeof properties === 'object') { + for (let category in properties) { + const propButtons = properties[category].map(prop => styles.components.labelWithDelete(prop, `${confirm}autobut --deleteprop ${category}/${prop}`)); + templateText.push([ + `${category}`, + `${propButtons.join(`
`)}
Add Property` + ]); + } + } else return { err: `Error getting damage properties from state` } + new ChatDialog({ title: 'Roll Template Properties', content: templateText, borders: { row: true } }, 'table'); + } + }, + { + name: 'addProperty', + rx: /^addprop/i, + description: `Add a roll template property to the listener`, + requiredServices: { config: 'ConfigController', }, + action: function (args) { + const parts = args.match(/([^/]+)\/(.+)/); + if (parts && parts.length === 3) { + if (this.config.getSetting(`templates/damageProperties/${parts[1]}`) == null) { + Helpers.toChat(`Created new roll template damage property category: ${parts[1]}`); + state[scriptName].settings.templates.damageProperties[parts[1]] = []; + } + return this.config.changeSetting(`templates/damageProperties/${parts[1]}`, parts[2]); + } else { + return { err: `Bad property path supplied, must be in the form "category/propertyName". Example: damage/dmg1` }; + } + } + }, + { + name: 'removeProperty', + rx: /^(remove|delete)?prop/i, + description: `Remove a roll template property from the listener`, + requiredServices: { config: 'ConfigController', }, + action: function (args) { + const parts = args.match(/([^/]+)\/(.+)/); + if (parts && parts.length === 3) { + const currentArray = this.config.getSetting(`templates/damageProperties/${parts[1]}`); + if (currentArray != null) { + const result = this.config.changeSetting(`templates/damageProperties/${parts[1]}`, parts[2]); + if (result.success && !/^(damage|crit)$/i.test(parts[1])) { // Clean up category if it's now empty, and isn't a core category + const newArray = this.config.getSetting(`templates/damageProperties/${parts[1]}`); + if (newArray.length === 0) { + delete state[scriptName].settings.templates.damageProperties[parts[1]]; + result.msg += `\nCategory ${parts[1]} was empty, and was removed.`; + } + } + return result; + } else return { err: `Could not find roll template property category: ${parts[1]}` } + } else { + return { err: `Bad property path supplied, must be in the form "category/propertyName". Example: damage/dmg1` } + } + } + }, + { + name: 'listButtons', + rx: /^(list)?button/i, + description: `List available buttons`, + requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, + action: function() { + const removableButtons = this.buttons.getButtonNames({ default: false }), + usedButtons = this.config.getSetting('enabledButtons'), + unusedButtons = this.buttons.getButtonNames({ hidden: true }), + availableButtons = this.buttons.getButtonNames({ hidden: true, currentSheet: true }), + reorderedButtons = usedButtons.concat(unusedButtons); + const links = { + hide: `!autoButton --hideButton %name%`, + show: `!autoButton --showButton %name%`, + delete: `${styles.components.confirmApiCommand(`delete button %name%?`)}--deleteButton %name%`, + disabled: `#` + } + const labels = { + hide: `E/`, + show: 'E', + delete: 'D', + disabled: '!' + }; + const controls = ['show', 'hide', 'delete']; + const listBody = reorderedButtons.map(button => { + const fadeText = usedButtons.includes(button) ? '' : styles.list.faded; + let rowHtml = `
${removableButtons.includes(button) ? '' : '*'}%name%
`; + controls.forEach(control => { + const controlType = ( + (control === 'show' && availableButtons.includes(button)) || + (control === 'hide' && usedButtons.includes(button)) || + (control === 'delete' && removableButtons.includes(button))) ? + control : 'disabled'; + rowHtml += ``; + }); + return `${rowHtml.replace(/%name%/g, button)}
`; + }); + const headerText = `autoButton list (sheet: ${this.config.getSetting('sheet')})`, + bodyText = listBody.join(''), + footerText = `Create New Button`; + new ChatDialog({ header: headerText, body: bodyText, footer: footerText }, 'listButtons'); + }, + }, + { + name: 'showButton', + rx: /^showbut/i, + description: `Add a button to the button bar`, + requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, + action: function (args) { + const newVal = args.trim(); + const validButtons = this.buttons.getButtonNames({ hidden: true, currentSheet: true }); + if (validButtons.includes(newVal)) { + return this.config.changeSetting('enabledButtons', newVal); + } else new ChatDialog({ title: 'Error', content: `Unrecognised or incompatible button: "${newVal}"` }, 'error'); + } + }, + { + name: 'hideButton', + rx: /^hidebut/i, + description: `Remove a button from the template`, + requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, + action: function (args) { + const newVal = args.trim(); + const validButtons = this.buttons.getButtonNames({ shown: true, currentSheet: true }); + if (validButtons.includes(newVal)) { + return this.config.changeSetting('enabledButtons', newVal); + } else new ChatDialog({ title: 'Error', content: `Unrecognised or incompatible button: "${newVal}"` }, 'error'); + } + }, + { + name: 'reorderButtons', + rx: /^(re)?order/i, + description: `Change order of buttons`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { + if (!args) return; + const newIndices = args.replace(/[^\d,]/g, '').split(/,/g), + currentOrder = this.config.getSetting('enabledButtons'); + let newOrder = []; + let valid = true; + newIndices.forEach(buttonIndex => { + const realIndex = buttonIndex - 1; + if (realIndex > -1 && realIndex < currentOrder.length) { + if (currentOrder[realIndex]) { + newOrder.push(currentOrder[realIndex]); + currentOrder[realIndex] = null; + } + } else valid = false; + }); + if (!valid) return { err: `Invalid button order input: ${args}. Indices must be between 1 and total number of buttons in use.` } + newOrder = newOrder.concat(currentOrder.filter(v => v)); + if (newOrder.length === currentOrder.length) return this.config.changeSetting('enabledButtons', newOrder, { overwriteArray: true }); + } + }, + { + name: 'createButton', + rx: /^createbut/i, + description: `Create a new button`, + requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, + action: function (args) { + const buttonData = Helpers.splitHandlebars(args); + if (buttonData && buttonData.name) { + if (/^[^A-Za-z]/.test(buttonData.name)) return { err: `Invalid button name: must start with a letter` }; + let buttonName = /\s/.test(buttonData.name) ? Helpers.camelise(buttonData.name) : buttonData.name; + if (this.buttons.getButtonNames().includes(buttonName)) return { err: `Invalid button name, already in use: "${buttonName}"` } + if (!buttonData.math) return { err: `Button must have an associated function, {{math=...}}` } + buttonData.default = false; + buttonData.mathString = buttonData.math; + const result = this.buttons.addButton(buttonData); + if (result.success) { + this.buttons.showButton(buttonName); + return result; + } + else return result.err || `An error occurred creating the button.`; + } else return { err: `Bad input for button creation` } + } + }, + { + name: 'editButton', + rx: /^editbut/i, + description: `Edit an existing button`, + requiredServices: { buttons: 'ButtonManager' }, + action: function (args) { + let buttonData = Helpers.splitHandlebars(args); + debug.log(buttonData); + if (buttonData && buttonData.name) { + if (this.buttons.getButtonNames().includes(buttonData.name)) { + return this.buttons.editButton(buttonData); + } + } + } + }, + { + name: 'deleteButton', + rx: /^del(ete)?but/i, + description: `Remove a button`, + requiredServices: { buttons: 'ButtonManager', config: 'ConfigController' }, + action: function (args) { + const removeResult = this.buttons.removeButton(args.trim()), + buttonIsEnabled = this.config.getSetting('enabledButtons').includes(args); + if (removeResult.success) { + if (buttonIsEnabled) this.config.changeSetting('enabledButtons', args); + return removeResult; + } else return removeResult; + } + }, + { + name: 'ignoreApi', + rx: /^ignoreapi/i, + description: `Ignore anything sent to chat by the API`, + requiredServices: { config: 'ConfigController' }, + action: function(args) { return this.config.changeSetting('ignoreAPI', args) } + }, + { + name: 'overheal', + rx: /^overh/i, + description: `Allow healing to push hp above hpMax`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { return this.config.changeSetting('overheal', args) } + }, + { + name: 'overkill', + rx: /^overk/i, + description: `Allow healing to push hp above hpMax`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { return this.config.changeSetting('overkill', args) } + }, + { + name: 'gmOnly', + rx: /^gmo/i, + description: `Whisper the buttons to GM, or post publicly`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { return this.config.changeSetting('gmOnly', args) } + }, + { + name: 'imageIcons', + rx: /^imagei/i, + description: `Render default icons as images (may solve font aligntment issues on Mac / ChromeOS)`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { return this.config.changeSetting('imageIcons', args) } + }, + { + name: `cloneButton`, + rx: /^clonebut/i, + description: `Clone a button`, + requiredServices: { buttons: 'ButtonManager' }, + action: function(args) { + const parts = args.trim().split(/\s+/g), + originalButtonName = parts[0], + cloneName = parts[1]; + return this.buttons.cloneButton(originalButtonName, cloneName); + } + }, + { + name: `renameButton`, + rx: /^renamebut/i, + description: `Rename a button (Custom buttons only)`, + requiredServices: { buttons: 'ButtonManager' }, + action: function(args) { + const parts = args.trim().split(/\s+/g), + originalButtonName = parts[0], + newName = parts[1]; + return this.buttons.renameButton(originalButtonName, newName); + } + }, + { + name: 'darkMode', + rx: /^dark/i, + description: `Palette change for the button bar`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { return this.config.changeSetting('darkMode', args) } + }, + { + name: 'multiattack', + rx: /^multiat/i, + description: `Attempt to link the button bar label to the source attack for easy repeat rolls`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { return this.config.changeSetting('multiattack', args) } + }, + { + name: 'allowNegatives', + rx: /^negative/i, + description: `Allow final results to be negative`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { return this.config.changeSetting('allowNegatives', args) } + }, + { + name: 'autosort', + rx: /^autosort/i, + description: `Auto sort buttons by unicode order`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { return this.config.changeSetting('autosort', args) } + }, + { + name: 'autohide', + rx: /^autohide/i, + description: `Autohide buttons with 0 reported damage`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { return this.config.changeSetting('autohide', args) } + }, + { + name: 'report', + rx: /^report/i, + description: `Change settings for reporting HP changes to chat`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { + const newVal = `${args}`.replace(/\W/g, '').toLowerCase(); + return this.config.changeSetting('report', newVal); + } + }, + { + name: 'repair', + rx: /^repair/, + description: `Attempt to repair a button from the backed up math string.`, + requiredServices: { buttons: 'ButtonManager' }, + action: function() { + for (const _button in this.buttons._buttons) { + const button = this.buttons._buttons[_button]; + if (!button.default) { + if (!button.mathString.trim() || button.mathString.trim() === '0') { + if (button.mathBackup) { + const valid = ButtonManager.validateMathString(button.mathBackup, button.name); + if (valid.success) { + button.mathString = button.mathBackup; + this.buttons.saveToStore(); + new ChatDialog({ content: `${button.name} was restored from backup.` }); + } + } + } + } + } + } + }, + { + name: 'settings', + rx: /^setting/i, + description: `Open settings UI`, + requiredServices: { config: 'ConfigController' }, + action: function() { this.config.getSettingsMenu() } + }, + { + name: 'help', + rx: /^(\?$|h$|help)/i, + description: `Display script help`, + action: function() { new ChatDialog({ title: `Script Help`, content: `Please visit the autoButtons thread for documentation.` }) } + }, + { + name: 'uninstall', + rx: /^uninstall$/i, + description: `Remove all script settings from API state`, + action: function(args) { + if (/^undo/i.test(args)) { + state[scriptName] = Helpers.copyObj(undoUninstall); + new ChatDialog({ title: 'Reverse! Reverse the reverse!', content: `State settings have been restored. Let's pretend that never happend, eh?` }, 'error') + } else if (!undoUninstall) { + undoUninstall = Helpers.copyObj(state[scriptName]); + state[scriptName] = null; + delete state[scriptName]; + new ChatDialog({ + header: `${scriptName} uninstalled!`, + body: `Removed all ${scriptName} data from API state. Click the 'whoopsie' button below if you didn't mean to destroy all your settings!
Otherwise, all settings will be *permantently* lost on sandbox restart.
Deleting the script now will result in a complete removal of the script and all associated data.`, + footer: `Restore!`, + }, 'listButtons'); + } + } + } + ]; + + /** + * SCRIPT USER-CONFIG OPTIONS + * + * Must have a valid type to be pulled into SettingsManager as a setting + * 'object' type can be used for nesting settings keys + * 'validate' is a validator for the input, not necessarily the key itself (e.g. an array might accept strings in the validator) + * 'name'/'description' are only used for chat menu UI + * 'menuAction' must be supplied. Starting with '$' will automatically convert into a button with leading API command syntax, otherwise supply actual text required + */ + const defaultScriptSettings = { + sheet: { + type: 'string', + range: ['dnd5e_r20', 'custom'], + rangeLabels: [ 'DnD5e by Roll20', 'Custom' ], + validate: function(v) { return this.range.includes(v) }, + default: 'dnd5e_r20', + name: 'Character sheet', + description: 'Character sheet in use', + menuAction: `$--loadPreset` + }, + enabledButtons: { + type: 'array', + validate: (v) => typeof(v) === 'string', + default: [], + }, + gmOnly: { + type: 'boolean', + default: true, + name: `GM-only buttons`, + description: `Whether the buttons are visible to players`, + menuAction: `$--gmo`, + }, + hpBar: { + type: 'integer', + range: [1,2,3], + validate: function(v) { return this.range.includes(v) }, + default: 1, + name: `Token HP bar`, + description: `Which token bar contains hit points`, + menuAction: `$--bar`, + }, + ignoreAPI: { + type: 'boolean', + default: true, + name: `Ignore API posts`, + description: `Ignore any automated damage rolls made by scripts`, + menuAction: `$--ignoreapi`, + }, + overheal: { + type: 'boolean', + default: false, + name: `Allow overheal`, + description: `Allow HP to go above max`, + menuAction: `$--overheal`, + }, + overkill: { + type: 'boolean', + default: false, + name: `Allow overkill`, + description: `Allow HP to go below 0`, + menuAction: `$--overkill`, + }, + targetTokens: { + type: 'boolean', + default: false, + name: `Target tokens`, + description: `Use a target click to target token, instead of current selection`, + menuAction: `$--targettoken`, + }, + bump: { + type: 'boolean', + default: true, + name: `Slim buttons`, + description: `CSS to bump the button container up in chat to save some space`, + menuAction: `$--bump`, + }, + imageIcons: { + type: 'boolean', + default: true, + name: `Image Icons`, + description: `Render default icons as images (may solve font aligntment issues on Mac / ChromeOS)`, + menuAction: `$--imageicon`, + }, + darkMode: { + type: 'boolean', + default: false, + name: `Dark Mode`, + description: `Palette change for the button bar`, + menuAction: `$--darkMode`, + }, + multiattack: { + type: 'boolean', + default: false, + name: `Multiattack`, + description: `Attempt to link the button bar label to the source attack for easy repeat rolls. 5e only.`, + menuAction: `$--multiattack`, + }, + allowNegatives: { + type: 'boolean', + default: false, + name: `Allow negatives`, + description: `Allow final results to be negative. This can cause healing to cause damage, or damage to heal`, + menuAction: `$--negatives`, + }, + autosort: { + type: 'boolean', + default: false, + name: `Sort buttons`, + description: `Auto sort buttons by unicode order`, + menuAction: `$--autosort`, + }, + autohide: { + type: 'boolean', + default: true, + name: `Autohide buttons`, + description: `Autohide buttons with 0 reported damage`, + menuAction: `$--autohide`, + }, + report: { + type: 'string', + range: [ 'off', 'gm', 'control', 'all' ], + rangeLabels: [ 'Off', 'GM', 'Character', 'Public' ], + validate: function(v) { return this.range.find(r => r.toLowerCase() === v.toLowerCase()) }, + default: 'Off', + name: `Report changes`, + description: `Report hitpoint changes to chat`, + menuAction: `$--report`, + }, + templates: { + type: 'object', + names: { + type: 'array', + validate: (v) => typeof(v) === 'string', + default: [], + name: `Roll templates & properties`, + description: `Names of roll templates & properties watched by autoButtons`, + menuAction: `Templates
Properties`, + }, + damageProperties: { + type: 'object', + damageFields: { + type: 'array', + validate: (v) => typeof(v) === 'string', + default: [], + }, + critFields: { + type: 'array', + validate: (v) => typeof(v) === 'string', + default: [] + }, + upcastDamage: { + type: 'array', + validate: (v) => typeof(v) === 'string', + default: [] + }, + upcastCrit: { + type: 'array', + validate: (v) => typeof(v) === 'string', + default: [] + }, + get value() { + const output = {}; + for (const key in this) { + if (key === 'value') continue; + if (this[key].value) output[key] = this[key].value; + } + return output; + } + }, + }, + } + + /** + * CLASS DEFINITIONS + */ + + /** + * Service Locator - Find a registered service from any scope in the script with ServiceLocator.getLocator().getService('serviceName') + */ + class ServiceLocator { + + static _active = null; + _services = {}; + + constructor(services={}) { + if (ServiceLocator._active) return ServiceLocator._active; + this.name = `ServiceLocator`; + for (let svc in services) { this._services[svc] = services[svc] } + ServiceLocator._active = this; + } + + static getLocator() { return ServiceLocator._active } + + register({ serviceName, serviceReference }) { if (!this._services[serviceName]) this._services[serviceName] = serviceReference } + + // Find a service. If service has multiple instances, make sure to request by instance name, or only the first registered constructor name will be returned. + // Search by Class Constructor Name is only suitable for unique class instances + getService(serviceName) { + if (this._services[serviceName]) return { [serviceName]: this._services[serviceName] } + else { + const rxServices = new RegExp(`${serviceName}`, 'i') + for (let service in this._services) { + if (this._services[service].constructor && rxServices.test(this._services[service].constructor.name)) return this._services[service]; + } + } + } + } + + /** + * Settings Manager - Handles fetch and store of user settings to state{} object, reads and writes to user settings. Processes the defaultScriptSettings{} object on init. Access via ConfigManager + */ + class SettingsManager { + + _settingsKeys = {}; + + constructor(settingsData = {}) { + const processObject = (currentObject, targetPath) => { + for (const key in currentObject) { + if (!currentObject[key].type) { + debug.log(`Skipping ${key}, no type found`); + continue; + } + debug.log(`Processing ${key}...`); + if (currentObject[key].type === 'object' && Helpers.isObj(currentObject[key])) { + targetPath[key] = currentObject[key]; + processObject(currentObject[key], targetPath[key]); + } + else if (this._validateKey(currentObject[key], currentObject[key].default)) { + targetPath[key] = currentObject[key]; + targetPath[key].value = currentObject[key].default; + } + else debug.warn(`${this.constructor.name}: Bad key used in constructor: ${key} default value does not match specified type`, currentObject[key]); + } + } + processObject(settingsData, this._settingsKeys); + debug.log(this._settingsKeys); + } + + get settingsKeys() { return this._settingsKeys } + + // Validate a settings key and the stored value + _validateKey(settingsKey, settingsValue) { + if (!settingsKey) return false; + // debug.log(`Validating ${settingsValue}...`); + const passValidation = ( + settingsKey.type === 'array' && Array.isArray(settingsValue) + || ['float', 'integer', 'number'].includes(settingsKey.type) && typeof(settingsValue) === 'number' + || typeof(settingsValue) === settingsKey.type + ) ? true : false; + // debug.log(passValidation); + return passValidation; + } + + + // Validate an input to be stored in a settings key (e.g. may be a primitive value to be stored in an object type key) + // Returns undefined for failed validation, otherwise returns value ready for storage + _validateNewValue(settingsKey, newValue, options = { forceValidation: null }) { + if (!settingsKey || typeof(settingsKey) !== 'object' || !settingsKey.type || newValue === undefined) return debug.error(`${this.constructor.name}: Bad settings key`, settingsKey); + // Handle keys with validators (Objects and Arrays must have a validator since they can't be passed from Roll20) + if (typeof(options.forceValidation) === 'function') { + if (options.forceValidation(newValue)) return newValue; + else return undefined; + } + else if (typeof(settingsKey.validate) === 'function') { + if (settingsKey.validate(newValue)) return newValue; + else return undefined; + } + // Handle booleans + else if (settingsKey.type === 'boolean') { + if (rx.on.test(newValue)) return true; + else if (rx.off.test(newValue)) return false; + else return undefined; + } + // Otherwise, type match + else if (settingsKey.type === 'integer' && parseInt(newValue) === parseInt(newValue)) return parseInt(newValue); + else if (settingsKey.type === 'float' && parseFloat(newValue) === parseFloat(newValue)) return parseFloat(newValue); + else if (settingsKey.type === typeof(newValue)) return newValue; + else return undefined; + } + + _writeSetting(settingsKey, newValue, options = { overwriteArray: false }) { + const validationOptions = (options.overwriteArray) ? { forceValidation: (v) => Array.isArray(v) } : {}, + validData = this._validateNewValue(settingsKey, newValue, validationOptions); + if (validData === undefined) { + debug.error(`${this.constructor.name}: Settings change not applied, value failed validation`, settingsKey, newValue); + return { err: `${this.constructor.name}: Settings change not applied, value failed validation` } + } + else { + if (settingsKey.type === 'array') { + if (options.overwriteArray && Array.isArray(newValue)) { + settingsKey.value = newValue; + return { msg: `Saved new Array: [${newValue.join(', ')}]` } + } + else return Helpers.modifyArray(settingsKey.value, newValue); + } + else { + settingsKey.value = newValue; + return { msg: `Saved value: ${newValue}` } + } + } + } + + importSettingsValues(importedKeys = {}) { + if (typeof(importedKeys) !== 'object') return debug.error(`${this.constructor.name}: Bad settings import, must only supply object type`); + const processObject = (currentObject, targetPath) => { + for (const key in currentObject) { + if (targetPath[key]) { + if (!targetPath[key].type) { + debug.log(`Skipping ${key}, no type defined`); + continue; + } + if (targetPath[key].type === 'object' && Helpers.isObj(currentObject[key])) { + processObject(currentObject[key], targetPath[key]); + } + else if (this._validateKey(targetPath[key], currentObject[key])) { + targetPath[key].value = currentObject[key]; + } + else debug.warn(`${this.constructor.name}: Key "${key}" failed validation`, currentObject[key]); + } + else debug.warn(`${this.constructor.name}: Key "${key}" does not exist.`, currentObject[key]); + } + } + processObject(importedKeys, this._settingsKeys); + debug.log(this._settingsKeys); + } + + exportSettingsValues() { + const output = {}; + const processObject = (currentObject, targetPath) => { + for (const key in currentObject) { + if (currentObject[key].type === 'object' && Helpers.isObj(currentObject[key])) { + targetPath[key] = {}; + processObject(currentObject[key], targetPath[key]); + } + else if (currentObject[key].type) { + targetPath[key] = currentObject[key].value; + } + } + } + processObject(this._settingsKeys, output); + return output; + } + + // Provide path relative to {Config._settings}, e.g. changeSetting('sheet', 'mySheet'); + // booleans with no "newValue" supplied will be toggled + // Use options.force 'type' to force a type on the setting e.g. array or boolean + // Combine with options.createPath: true to create a new setting of the correct type + updateSetting(pathString, newValue, options = { createPath: false, overwriteArray: false, force: null }) { + if (typeof(pathString) !== 'string' || newValue === undefined) return { err: `Bad path string or no new value supplied.` }; + // Can probably remove this bit now that a .value key is used + const keyName = (pathString.match(/[^/]+$/)||[])[0], + path = /.+\/.+/.test(pathString) ? pathString.match(/(.+)\/[^/]+$/)[1] : '', + configPath = path ? Helpers.getObjectPath(path, this._settingsKeys, options.createPath) : this._settingsKeys, + targetKey = configPath[keyName]; + if (targetKey) { + debug.log(`changeSetting - ${keyName}`, targetKey, options, newValue); + if (targetKey.type === 'boolean') { + newValue = (newValue == null || newValue === '') ? !targetKey.value : + rx.on.test(newValue) ? true : + rx.off.test(newValue) ? false : + newValue; + } + const result = this._writeSetting(targetKey, newValue, options); + if (result.msg) result.msg = `Changed setting: ${pathString}
${result.msg}`; + else if (result.err) result.err = `Changed setting: ${pathString}
${result.err}`; + return result; + } + else { + return { err: `Settings key not found - *${pathString}*` } + } + } + + readSetting(pathString) { + if (typeof(pathString) !== 'string') return; + const targetKey = Helpers.getObjectPath(pathString, this._settingsKeys, false); + return targetKey ? targetKey.value : undefined; + } + + // Export this._settingsKeys as chatbar-friendly text + getMenuText() { + const output = []; + const processObject = (currentObject, targetOutput) => { + for (const key in currentObject) { + if (currentObject[key].type === 'object') { + processObject(currentObject[key], targetOutput); + } + else if (currentObject[key].menuAction) { + const name = currentObject[key].name || key, + hover = currentObject[key].description ? `title="${currentObject[key].description}"` : ``, + settingName = `
${name}
`, + currentSetting = `${currentObject[key].value}`; + // Entry has a custom menu action + if (/^[^$]/.test(currentObject[key].menuAction)) { + targetOutput.push([ settingName, currentObject[key].menuAction ]); + } + // Autofill prompt for boolean or defined range + else { + const queryRange = + currentObject[key].type === 'boolean' ? ['True', 'False'] + : currentObject[key].range ? + currentObject[key].rangeLabels ? currentObject[key].range.map((v,i) => `${currentObject[key].rangeLabels[i]||v},${v}`) + : Helpers.toArray(currentObject[key].range) + : '', + queryString = queryRange ? `?{Select new value|${queryRange.join('|')}}` : `?{Enter new value}`, + cliFlag = (`${currentObject[key].menuAction}`.match(/^\$(.+)/)||[])[1] || `--${key}`, + commandString = `!${scriptName} ${cliFlag} ${queryString}`; + targetOutput.push([ settingName, `${currentSetting}`]); + } + } + } + } + processObject(this._settingsKeys, output); + return output; + } + } + + /** + * Config Controller - Handles user settings via injected SettingsManager, and Custom Button storage via internal _store + */ + class ConfigController { + + _version = { M: 0, m: 0, p: 0 }; + + constructor(scriptName, scriptData={}) { + Object.assign(this, { + name: scriptName || `newScript`, + _settings: new SettingsManager(scriptData.settings) || {}, + _store: scriptData.store || {}, + }); + if (scriptData.version) this.version = scriptData.version; + } + + get version() { return `${this._version.M}.${this._version.m}.${this._version.p}` } + set version(newVersion) { + if (typeof(newVersion) === 'object' && newVersion.M && newVersion.m && newVersion.p) Object.assign(this._version, newVersion); + else { + const parts = `${newVersion}`.split(/\./g); + if (!parts.length) debug.error(`Bad version number, not setting version.`) + else Object.keys(this._version).forEach((v,i) => this._version[v] = parseInt(parts[i]) || 0); + } + } + + initialState() { + return { + version: this.version, + settings: this._settings.exportSettingsValues(), + store: this._store + } + } + + fromStore(path) { return Helpers.getObjectPath(path, this._store, false) } + toStore(path, data) { // Supplying data=null will delete the target + const ref = Helpers.getObjectPath(path, this._store, true); + let msg; + if (ref) { + if (data) { + Object.assign(ref, data); + msg = `New data written to "${path}"`; + } else if (data === null) { + Helpers.getObjectPath(path, this._store, false, true); + msg = `${path} deleted from store.`; + } else return { success: 0, err: `Bad data supplied (type: ${typeof data})` } + } else return { success: 0, err: `Bad store path: "${path}"` } + this.saveToState(); + return { success: 1, msg: msg } + } + + fetchFromState() { + Object.assign(this, { _store: state[scriptName].store, }); + this._settings.importSettingsValues(state[scriptName].settings); + } + saveToState() { + Object.assign(state[scriptName], { + settings: this._settings.exportSettingsValues(), + store: this._store, + }); + } + + changeSetting(pathString, newValue, options) { + options = typeof(options) === 'object' ? options : undefined; + const result = this._settings.updateSetting(pathString, newValue, options); + debug.log(`Setting change attempted`, result); + if (result.msg) this.saveToState(); + return result; + } + getSetting(pathString) { + const currentValue = this._settings.readSetting(pathString); + return (typeof currentValue === 'object') ? Helpers.copyObj(currentValue) : currentValue; + } + loadPreset() { + const currentSheet = this.getSetting('sheet') || ''; + if (Object.keys(preset).includes(currentSheet)) { + // Load template names + this._settings.updateSetting('templates/names', preset[currentSheet].templates.names, { overwriteArray: true }); + // Load damage properties + for (const key in preset[currentSheet].templates.damageProperties) { + // debug.info(`Processing ${key} in preset...`); + this._settings.updateSetting(`templates/damageProperties/${key}`, preset[currentSheet].templates.damageProperties[key], { overwriteArray: true }); + } + this._settings.updateSetting('enabledButtons', preset[currentSheet].defaultButtons || [], { overwriteArray: true }); + this.saveToState(); + return { res: 1, data: `${this.getSetting('sheet')}` } + } else return { res: 0, err: `Preset not found for sheet: "${currentSheet}"`} + } + getSettingsMenu() { + const menuOptions = this._settings.getMenuText(), + confirm = styles.components.confirmApiCommand(`reset to default sheet settings?`), + footerContent = `
Reset Sheet Settings`; + menuOptions.unshift(['Key', 'Setting']); + new ChatDialog({ title: `${scriptName} settings
v${scriptVersion}`, content: menuOptions, footer: footerContent }, 'table'); + } + } + + /** + * Button Manager - Handles CRUD operations, math/query functions and HTML output for all buttons, both internal and Custom Button + */ + class ButtonManager { + + static _buttonKeys = ['sheets', 'content', 'content2', 'content3', 'tooltip', 'style', 'style2', 'style3', 'math', 'default', 'mathString', 'query']; + static _editKeys = ['clone', 'rename']; + _locator = null; + _Config = {}; + _buttons = {}; + + constructor(data={}) { + Object.assign(this, { name: data.name || 'newButtonManager' }); + // Requires access to a ConfigController + this._locator = ServiceLocator.getLocator() || this._locator; + this._Config = this._locator ? this._locator.getService('ConfigController') : null; + if (!this._Config) return {}; + for (let button in data.defaultButtons) { this._buttons[button] = new Button(data.defaultButtons[button], styles) } + } + + get keys() { return ButtonManager._buttonKeys } + get editKeys() { return [ ...ButtonManager._buttonKeys, ...ButtonManager._editKeys ]} + + getButtonNames(filters={ default: null, currentSheet: null, shown: null, hidden: null }) { + let buttons = Object.entries(this._buttons); + const sheet = this._Config.getSetting('sheet'), + enabledButtons = this._Config.getSetting('enabledButtons'); + if (typeof filters.default === 'boolean') buttons = buttons.filter(kv => kv[1].default === filters.default); + if (typeof filters.currentSheet === 'boolean') buttons = buttons.filter(kv => (!kv[1].sheets.length || sheet === 'custom' || (kv[1].sheets.includes(sheet) === filters.currentSheet))); + if (typeof filters.shown === 'boolean') buttons = buttons.filter(kv => (enabledButtons.includes(kv[0]) === filters.shown)); + if (typeof filters.hidden === 'boolean') buttons = buttons.filter(kv => (enabledButtons.includes(kv[0]) === !filters.hidden)); + const output = buttons.map(kv=>kv[0]); + // debug.log(`button names: ${output.join(', ')}`); + return output; + } + + static validateMathString(inputString, buttonName) { + debug.info(inputString); + inputString = `${inputString}`; + + // Default buttons will send in a JS function, remove the declaration part + inputString = inputString.replace(/^.*?=>\s*/, ''); + + let newFormula = inputString; + const mathOpsString = MathOpsTransformer.transformMathString(newFormula); + debug.info(mathOpsString); + + // Create a test object + const damageKeyMatches = inputString.match(/damage\.(\w+)/g) || [], + critKeyMatches = inputString.match(/crit\.(\w+)/g) || [], + damageKeys = damageKeyMatches.reduce((output, key) => ({ ...output, [key.replace(/^[^.]*\./, '')]: 5 }), {}), + critKeys = critKeyMatches.reduce((output, key) => ({ ...output, [key.replace(/^[^.]*\./, '')]: 5 }), {}); + + const { config } = ServiceLocator.getLocator().getService('config'); + const damageProperties = Object.values(config.getSetting('templates/damageProperties')).reduce((output, category) => [ ...output, ...category ], []); + const invalidProperties = [ ...Object.keys(damageKeys), ...Object.keys(critKeys) ].filter(key => !(damageProperties.includes(key))); + + const mathOpsKeys = MathOpsTransformer.transformMathOpsPayload(damageKeys, critKeys); + debug.info(mathOpsKeys); + + let error; + try { + const testResult = MathOps.MathProcessor({ code: mathOpsString, known: mathOpsKeys }); + debug.info(testResult); + if (testResult.message) { + error = testResult.message; + } + else if (isNaN(testResult)) { + error = `The supplied math did not return a number: ${inputString}`; + } + } + catch(e) { error = `Math failed validation - ${e}`; } + if (invalidProperties.length) new ChatDialog({ title: `Button Warning: "${buttonName}"`, content: `The following damage properties in the button are not set up in this game: ${invalidProperties.join(', ')}` }, 'error'); + + return error + ? { success: false, err: error } + : { success: true, err: null } + } + addButton(buttonData={}) { + const newButton = buttonData.default === false ? new CustomButton(buttonData) : new Button(buttonData); + debug.warn(newButton); + if (newButton.err || !newButton.math) return { success: 0, err: newButton.err || `Button ${buttonData.name} could not be created.` } + if (this._buttons[newButton.name]) return { success: 0, err: `Button "${newButton.name}" already exists` }; + this._buttons[newButton.name] = newButton; + this.saveToStore(); + return { success: 1, msg: `New Button "${newButton.name}" successfully created` } + } + editButton(buttonData={}) { + const modded = []; + if (!this._buttons[buttonData.name]) return { success: 0, err: `Button "${buttonData.name}" does not exist.` } + if (this._buttons[buttonData.name].default) return { success: 0, err: `Cannot edit default buttons.` } + this.editKeys.forEach(k => { + debug.log(k, buttonData[k]); + if (buttonData[k] != null) { + if (k === 'default') return; // Don't allow reassignment of 'default' property + else if (k === 'math') { + const { success, err } = ButtonManager.validateMathString(buttonData[k], buttonData.name); + if (!success) return { err }; + else { + this._buttons[buttonData.name].mathString = buttonData[k]; + modded.push(k); + } + } + else if (/^style/.test(k)) { + this._buttons[buttonData.name][k] = styles[buttonData[k]] || buttonData[k] || ''; + modded.push(k); + } + // else if (k === 'query') { + // this._buttons[buttonData.name].query = Button.splitAndEscapeQuery(buttonData.query); + // modded.push(k); + // } + else { + this._buttons[buttonData.name][k] = buttonData[k]; + modded.push(k); + } + } + }); + if (modded.length) this.saveToStore(); + return modded.length ? { success: 1, msg: `Modified ${buttonData.name} fields: ${modded.join(', ')}` } : { success: 0, err: `No fields supplied.` } + } + removeButton(buttonName) { + if (!this._buttons[buttonName]) return { success: 0, err: `Button "${buttonName}" does not exist.` } + if (this._buttons[buttonName].default) return { success: 0, err: `Cannot delete default buttons.` } + delete this._buttons[buttonName]; + this._Config.toStore(`customButtons/${buttonName}`, null); + return { success: 1, msg: `Removed "${buttonName}".` } + } + cloneButton(originalButtonName, newButtonName) { + if (this._buttons[originalButtonName] && newButtonName) { + const cloneName = /\s/.test(newButtonName) ? Helpers.camelise(newButtonName) : newButtonName, + cloneData = { ...this._buttons[originalButtonName], name: cloneName, default: false }, + copyResult = this.addButton(cloneData); + return copyResult.success ? { success: 1, msg: `Cloned button ${originalButtonName} => ${cloneName}` } : copyResult; + } + else return { err: `Could not find button "${originalButtonName}", or bad clone button name "${newButtonName}"` } + } + renameButton(originalButtonName, newButtonName) { + if (!this._buttons[originalButtonName]) return { success: 0, err: `Button "${originalButtonName}" could not be found` }; + if (this._buttons[originalButtonName].default) return { success: 0, err: `Cannot rename a default button.` }; + const cloneName = /\s/.test(newButtonName) ? Helpers.camelise(newButtonName) : newButtonName, + cloneResult = this.cloneButton(originalButtonName, cloneName); + if (cloneResult.success) { + this.removeButton(originalButtonName); + return { success: 1, msg: `Renamed button ${originalButtonName} => ${cloneName}` }; + } + else return cloneResult; + } + showButton(buttonName) { + if (this._buttons[buttonName] && !this._Config.getSetting('enabledButtons').includes(buttonName)) { return this._Config.changeSetting('enabledButtons', buttonName) } + } + hideButton(buttonName) { + if (this._buttons[buttonName] && this._Config.getSetting('enabledButtons').includes(buttonName)) { return this._Config.changeSetting('enabledButtons', buttonName) } + } + saveToStore() { + const customButtons = this.getButtonNames({default: false}); + customButtons.forEach(button => this._Config.toStore(`customButtons/${button}`, Helpers.copyObj(this._buttons[button]))); + } + _getReportTemplate(barNumber) { + const template = `'*({name}) {bar${barNumber}_value;before}HP -> {bar${barNumber}_value}HP*'`; + return template; + // Styled report template for if Aaron implements decoding in TM + // const templateRaw = `'
{name}: {bar1_value:before}HP >> {bar1_value}HP
'`; + // return encodeURIComponent(templateRaw); + + // !token-mod --set bar1_value|-[[floor(query*17)]]! + } + _getImageIcon(buttonName, cacheBust, version = '2a') { + if (!cacheBusted) { + cacheBust = true; + } + const url = `https://raw.githubusercontent.com/ooshhub/autoButtons/main/assets/imageIcons/${buttonName}.png?${version}`.replace(/%/g, 'P'); + return cacheBust ? + `${url}${Math.floor(Math.random()*1000000000)}` + : url; + // May need to switch to this if images move + // return styles.imageIcons[buttonName]; + } + createApiButton(buttonName, damage, crit) { + // debug.info(this._buttons[buttonName]); + const btn = this._buttons[buttonName], + autoHide = this._Config.getSetting(`autohide`), + bar = this._Config.getSetting('hpBar'), + overheal = this._Config.getSetting('overheal'), + overkill = this._Config.getSetting('overkill'), + sendReport = (this._Config.getSetting('report')||``).toLowerCase(), + reportString = [ 'all', 'gm', 'control' ].includes(sendReport) + ? ` --report ${sendReport}|${this._getReportTemplate(bar)}` + : ``, + darkMode = this._Config.getSetting('darkMode'); + const zeroBound = this._Config.getSetting('allowNegatives') ? false : true, + boundingPre = zeroBound ? `{0, ` : ``, + boundingPost = zeroBound ? `}kh1` : ``; + const queryString = Button.splitAndEscapeQuery(btn.query) || ''; + if (!btn || typeof(btn.math) !== 'function') { + debug.error(`${scriptName}: error creating API button ${buttonName}`); + return ``; + } + const modifier = this.resolveButtonMath(btn, damage, crit), + tooltip = btn.tooltip.replace(/%/, `${modifier} HP`), + setWithQuery = queryString ? `[[${boundingPre}${queryString.replace(/%%MODIFIER%%/g, Math.abs(modifier))}${boundingPost}]]` : `${Math.abs(modifier)}`, + tokenModCmd = (modifier > 0) ? (!overheal) ? `+${setWithQuery}!` : `+${setWithQuery}` : (modifier < 0 && !overkill) ? `-${setWithQuery}!` : `-${setWithQuery}`, + selectOrTarget = (this._Config.getSetting('targetTokens') === true) ? `--ids @{target|token_id} ` : ``, + buttonHref = `!token-mod ${selectOrTarget}--set bar${bar}_value|${tokenModCmd}${reportString}`, + useImageIcon = this._Config.getSetting('imageIcons') && btn.default, + buttonContent = useImageIcon ? `` + : `${btn.content}`, + buttonContent2 = useImageIcon ? `` + : btn.content2 ? `${btn.content2}` : ``, + buttonContent3 = useImageIcon ? `` + : btn.content3 ? `${btn.content3}` : ``; + return (autoHide && modifier == 0) ? + `` + : `
${buttonContent}${buttonContent2}${buttonContent3}
`; + } + verifyButtons() { + const currentSheet = this._Config.getSetting('sheet'), + currentButtons = this._Config.getSetting('enabledButtons'), + validButtons = currentButtons.filter(button => { + if (currentSheet === 'custom' || this._buttons[button] && this._buttons[button].sheets.includes(currentSheet)) return 1; + }); + if (validButtons.length !== currentButtons.length) { + const { success, msg, err } = this._Config.changeSetting('enabledButtons', validButtons); + if (success && msg) new ChatDialog({ content: msg, title: 'Buttons Changed' }); + else if (err) new ChatDialog({ content: err }, 'error'); + } + } + resolveButtonMath(button, damage, crit) { + const buttonType = button.constructor.name; + if (buttonType === 'CustomButton') { + debug.info(button.mathString, MathOpsTransformer.transformMathOpsPayload(damage, crit), MathOpsTransformer.transformMathString(button.mathString)); + let mathOpsString = MathOpsTransformer.transformMathString(button.mathString); + const mathOpsDamageKeys = MathOpsTransformer.transformMathOpsPayload(damage, crit); + // MathOps zeroed key patch + mathOpsString = mathOpsZeroPatch ? this.resolveZeroedKeys(mathOpsString, mathOpsDamageKeys) : mathOpsString; + debug.warn(mathOpsString); + let result = MathOps.MathProcessor({ code: mathOpsString, known: mathOpsDamageKeys}); + debug.info(result); + return isNaN(result) ? 0 : result; + } + else if (buttonType === 'Button') { + return button.math(damage, crit); + } + } + resolveZeroedKeys(mathOpsString, mathOpsDamageKeys) { + for (const key in mathOpsDamageKeys) { + if (mathOpsDamageKeys[key] === 0) { + const rxReplacer = new RegExp(key, 'g'); + mathOpsString = mathOpsString.replace(rxReplacer, '0'); + } + } + return mathOpsString; + } + } + + /** + * Button - Basic schema of a Button object + */ + class Button { + constructor(buttonData={}, styleData=styles) { + Object.assign(this, { + name: buttonData.name || 'newButton', + sheets: Array.isArray(buttonData.sheets) ? buttonData.sheets : [], + tooltip: `${buttonData.tooltip || ''}`, + style: styleData[buttonData.style] || buttonData.style || '', + style2: styleData[buttonData.style2] || buttonData.style2 || '', + style3: styleData[buttonData.style3] || buttonData.style3 || '', + content: buttonData.content || '?', + content2: buttonData.content2 || '', + content3: buttonData.content3 || '', + math: buttonData.math || null, + mathString: buttonData.mathString, + query: buttonData.query || ``, + default: buttonData.default === false ? false : true, + mathBackup: buttonData.mathBackup || '', + }); + debug.log(this); + if (typeof(this.math) !== 'function') return { err: `Button "${this.name}" math function failed validation` }; + } + + static splitAndEscapeQuery(queryString) { + if (!queryString || typeof(queryString) !== 'string') return ``; + const replacers = { + '*': `*`, + '+': `+`, + } + const replacerFunction = (m) => replacers[m], + rxQuerySplit = /^[+*/-][+-0]?\|/, + rxReplacers = new RegExp(`[${Object.keys(replacers).reduce((out,v) => out += `\\${v}`, ``)}]`, 'g'); + let operator = (queryString.match(rxQuerySplit)||[])[0] || ``, + query = queryString.replace(rxQuerySplit, ''), + roundingPre = ``, + roundingPost = ``; + // Deal with rounding for * and / + if (/^[*/]/.test(operator)) { + roundingPre = operator[1] === '+' ? + `ceil(` + : `floor(` + roundingPost = `)`; + } + operator = (operator[0]||``).replace(rxReplacers, replacerFunction); + return query ? `${roundingPre}%%MODIFIER%%${operator}?{${query}}${roundingPost}` : ``; + } + } + + /** + * Custom Button - user-made buttons pass through here for validation before being passed to superclass + */ + class CustomButton extends Button { + constructor(buttonData={}) { + debug.info(buttonData); + if (!buttonData.mathString) return { err: `Button must contain a math string.` }; + const { success, err } = ButtonManager.validateMathString(buttonData.mathString, buttonData.name); + if (!success) { + return { err }; + } + Object.assign(buttonData, { + name: buttonData.name || 'newCustomButton', + mathString: buttonData.mathString, + math: (code, known) => MathOps.MathProcessor({ code: MathOpsTransformer.transformMathString(code), known }), + style: buttonData.style || 'full', + query: buttonData.query || ``, + default: false, + mathBackup: buttonData.mathBackup || buttonData.mathString, + }); + super(buttonData); + } + } + + /** + * Command Line Interface - handle adding and removing CLI Options, and assess chat input when passed in from HandleInput() + */ + class CommandLineInterface { + + _locator = null; + _services = {}; + _options = {}; + + constructor(cliData={}) { + this.name = cliData.name || `Cli`; + this._locator = ServiceLocator.getLocator(); + if (!this._locator) debug.warn(`${this.constructor.name} could not find the service locator. Any commands relying on services will be disabled.`); + Object.assign(this._services, { + config: this._locator.getService('ConfigController'), + buttons: this._locator.getService('ButtonManager'), + cli: this, + }); + if (cliData.options && cliData.options.length) this.addOptions(cliData.options); + debug.log(`Initialised CLI`); + } + + // Add one or more options to the CLI + addOptions(optionData) { + optionData = Helpers.toArray(optionData); + optionData.forEach(data => { + if (data.name && !this._options[data.name]) { + const suppliedServices = { cli: this } + if (data.requiredServices) { + for (let service in data.requiredServices) { + const svc = + service === 'ConfigController' ? this._services.config + : service === 'ButtonManager' ? this._services.buttons + : this._locator.getService(data.requiredServices[service]); + if (svc) suppliedServices[service] = svc; + else return debug.warn(`${this.name}: Warning - Service "${service}" could not be found for option ${data.name}. CLI option not registered.`); + } + } + data.services = suppliedServices; + this._options[data.name] = new CommandLineOption(data); + } else debug.warn(`Bad data supplied to CLI Option constructor`); + }); + } + + assess(commandArray, reportToChat = true) { + let changed = [], errs = []; + commandArray.forEach(command => { + const cmd = (command.match(/^([^\s]+)/)||[])[1], + args = (command.match(/\s+(.+)/)||['',''])[1]; + for (let option in this._options) { + if (this._options[option].rx.test(cmd)) { + const { msg, err } = (this._options[option].action(args) || {}); + // debug.log(msg||err); + if (msg) changed.push(Helpers.toArray(msg).join('
')); + if (err) errs.push(err); + } + } + }); + if (changed.length && reportToChat) { + // debug.info(changed); + const chatData = { + title: `${scriptName} settings changed`, + content: changed + }; + new ChatDialog(chatData); + } + if (errs.length) new ChatDialog( { title: 'Errors', content: errs }, 'error'); + } + + trigger(option, ...args) { if (this._options[option]) this._options[option].action(...args) } + } + + /** + * Command Line Option - basic model for a user-facing CLI option + */ + class CommandLineOption { + + constructor(optionData={}) { + for (let service in optionData.services) { + this[service] = optionData.services[service]; + } + Object.assign(this, { + name: optionData.name || 'newOption', + rx: optionData.rx || new RegExp(`${optionData.name}`, 'i'), + description: optionData.description || `Description goes here...`, + action: optionData.action + }); + } + + } + + /** + * Chat Dialog - Short-lived layout class which, by default, is sent straight to chat once constructed. + * Can be instantiated and persisted by disabling the default autoSend in the constructor + */ + class ChatDialog { + + static _templates = { + none: ({content}) => `${content}`, + default: ({ title, content }) => { + const msgArray = content ? Helpers.toArray(content) : [], + body = msgArray.map(row => `
${row}
`).join('') + return ` +
+
${title||scriptName}
+
+ ${body} +
+
`; + }, + table: ({ title, content, footer, borders }) => { + const rowBorders = borders && borders.row ? styles.table.rowBorders : ``; + const msgArray = content ? Helpers.toArray(content) : [], + columns = msgArray[0].length || 1, + tableRows = msgArray.map((row,i) => { + const tc = i === 0 ? 'th' : 'td', + tcStyle = i === 0 ? styles.table.headerCell : `${styles.table.cell}${rowBorders}`, + trStyle = i === 0 ? styles.table.headerRow : styles.table.row; + let cells = ``; + for (let i=0; i < columns; i++) { cells += `<${tc} style="${tcStyle}">${row[i]}` } + return ` + + ${cells} + `; + }).join(''), + footerContent = footer ? `` : ``; + return ` +
+
${title||scriptName}
+
+ + ${tableRows} +
+
+ ${footerContent} +
+ `; + }, + error: ({ title, content }) => { + const errArray = content ? Helpers.toArray(content) : []; + return ` +
+
${title}
+
${errArray.join('
')}
+
`; + }, + listButtons: ({ header, body, footer }) => { + return ` +
+
${header}
+
+ ${body} +
+ +
+ `; + } + } + + constructor(message, template = 'default', autoSend = true) { + this.msg = ChatDialog._templates[template] ? ChatDialog._templates[template](message) : null; + if (this.msg) { + this.msg = this.msg.replace(/\n/g, ''); + if (autoSend) Helpers.toChat(this.msg); + } else { + debug.warn(`${scriptName}: error creating chat dialog, missing template "${template}"`); + return {}; + } + } + } + + on('ready', startScript); + +})(); +{ try { throw new Error(''); } catch (e) { API_Meta.autoButtons.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.autoButtons.offset); } } +/* */ \ No newline at end of file diff --git a/autoButtons/autoButtons.js b/autoButtons/autoButtons.js index 08ef625b6f..e906f04e6b 100644 --- a/autoButtons/autoButtons.js +++ b/autoButtons/autoButtons.js @@ -1,23 +1,38 @@ -/* globals state log on sendChat playerIsGM findObjs */ //eslint-disable-line var API_Meta = API_Meta || {}; API_Meta.autoButtons = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 }; -{ try { throw new Error(''); } catch (e) { API_Meta.autoButtons.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (13)); } } +{ + try { + throw new Error(''); + } catch (e) { + API_Meta.autoButtons.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (13)); + } +} -const autoButtons = (() => { // eslint-disable-line no-unused-vars +(() => { const scriptName = `autoButtons`, - scriptVersion = `0.8.0`, - debugLevel = 1; + scriptVersion = `0.9.0`, + mathOpsZeroPatch = true, + debugLevel = 2; let undoUninstall = null, - cacheBusted = false; + cacheBusted = false; + const beaconRollDiscriminator = 'advancedroll'; const debug = { - log: function(...args) { if (debugLevel > 3) console.log(...args) }, - info: function(...args) { if (debugLevel > 2) console.info(...args) }, - warn: function(...args) { if (debugLevel > 1) console.warn(...args) }, - error: function(...args) { if (debugLevel > 0) console.error(...args) }, + log: function (...args) { + if (debugLevel > 3) console.log(...args) + }, + info: function (...args) { + if (debugLevel > 2) console.info(...args) + }, + warn: function (...args) { + if (debugLevel > 1) console.warn(...args) + }, + error: function (...args) { + if (debugLevel > 0) console.error(...args) + }, } - + /** * INIT SCRIPT & SETTINGS/CLI ADDITIONS FROM LAST MINOR VERSION */ @@ -31,63 +46,10 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars customButtons: {} }, settings: { - // 0.6.x => 0.8.0 Setting additions - imageIcons: { - type: 'boolean', - default: true, - name: `Image Icons`, - description: `Render default icons as images (may solve font aligntment issues on Mac / ChromeOS)`, - menuAction: `$--imageicon`, - }, - darkMode: { - type: 'boolean', - default: false, - name: `Dark Mode`, - description: `Palette change for the button bar`, - menuAction: `$--darkMode`, - }, - multiattack: { - type: 'boolean', - default: false, - name: `Multiattack`, - description: `Attempt to link the button bar label to the source attack for easy repeat rolls. 5e only.`, - menuAction: `$--multiattack`, - }, - allowNegatives: { - type: 'boolean', - default: false, - name: `Allow negatives`, - description: `Allow final results to be negative. This can cause healing to cause damage, or damage to heal`, - menuAction: `$--negatives`, - }, - autosort: { - type: 'boolean', - default: false, - name: `Sort buttons`, - description: `Auto sort buttons by unicode order`, - menuAction: `$--autosort`, - }, - autohide: { - type: 'boolean', - default: true, - name: `Autohide buttons`, - description: `Autohide buttons with 0 reported damage`, - menuAction: `$--autohide`, - }, - report: { - type: 'string', - range: [ 'off', 'gm', 'control', 'all' ], - rangeLabels: [ 'Off', 'GM', 'Character', 'Public' ], - validate: function(v) { return this.range.find(r => r.toLowerCase() === v.toLowerCase()) }, - default: 'Off', - name: `Report changes`, - description: `Report hitpoint changes to chat`, - menuAction: `$--report`, - }, ...defaultScriptSettings, }, }); - Services.register({serviceName: 'config', serviceReference: Config }); + Services.register({ serviceName: 'config', serviceReference: Config }); const ButtonStore = new ButtonManager({ name: 'ButtonStore', @@ -101,96 +63,34 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars options: defaultCliOptions, }); Services.register({ serviceName: 'cli', serviceReference: CLI }); - // v0.6.x => 0.7.0 CLI additions - CLI.addOptions([ - { - name: 'imageIcons', - rx: /^imagei/i, - description: `Render default icons as images (may solve font aligntment issues on Mac / ChromeOS)`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('imageIcons', args) } - }, - { - name: `cloneButton`, - rx: /^clonebut/i, - description: `Clone a button`, - requiredServices: { buttons: 'ButtonManager' }, - action: function(args) { - const parts = args.trim().split(/\s+/g), - originalButtonName = parts[0], - cloneName = parts[1]; - return this.buttons.cloneButton(originalButtonName, cloneName); - } - }, - { - name: `renameButton`, - rx: /^renamebut/i, - description: `Rename a button (Custom buttons only)`, - requiredServices: { buttons: 'ButtonManager' }, - action: function(args) { - const parts = args.trim().split(/\s+/g), - originalButtonName = parts[0], - newName = parts[1]; - return this.buttons.renameButton(originalButtonName, newName); - } - }, - { - name: 'darkMode', - rx: /^dark/i, - description: `Palette change for the button bar`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('darkMode', args) } - }, - { - name: 'multiattack', - rx: /^multiat/i, - description: `Attempt to link the button bar label to the source attack for easy repeat rolls`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('multiattack', args) } - }, - { - name: 'allowNegatives', - rx: /^negative/i, - description: `Allow final results to be negative`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('allowNegatives', args) } - }, - { - name: 'autosort', - rx: /^autosort/i, - description: `Auto sort buttons by unicode order`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('autosort', args) } - }, - { - name: 'autohide', - rx: /^autohide/i, - description: `Autohide buttons with 0 reported damage`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('autohide', args) } - }, - { - name: 'report', - rx: /^report/i, - description: `Change settings for reporting HP changes to chat`, - requiredServices: { config: 'ConfigController' }, - action: function (args) { - const newVal = `${args}`.replace(/\W/g, '').toLowerCase(); - return this.config.changeSetting('report', newVal); - } - }, - ]); + + const checkDependencies = async () => { + let err; + try { + err = typeof (MathOps) !== 'object' || typeof (TokenMod) !== 'object' + ? `${scriptName}: requires TokenMod and MathOps` + : typeof (MathOps.MathProcessor) !== 'function' + ? `${scriptName}: a newer version of MathOps is required.` + : null; + } catch (e) { + err = `${scriptName} dependencies could not be resolved - MathOps and TokenMod are required.` + } + if (err) new ChatDialog({ title: `Fatal Error - ${scriptName} exiting...`, content: err }, 'error'); + return !err; + } // Check install and version - const checkInstall = () => { + const checkInstall = async () => { let firstTimeSetup; - setTimeout(() => { if (!/object/i.test(typeof(['token-mod']))) return sendChat(scriptName, `/w gm
tokenMod not found - this script requires tokenMod to function! Aborting init...
`), 500 }); - if (!state[scriptName] || !state[scriptName].version ) { + if (!(await checkDependencies())) return; + if (!state[scriptName] || !state[scriptName].version) { log(`autoButtons: first time setup...`); firstTimeSetup = 1; state[scriptName] = Config.initialState(); } - if (typeof(state[scriptName].version) === 'number' && state[scriptName].version % 1 !== 0) { state[scriptName].version = `${state[scriptName].version}`.replace(/\D/g, '').split('', 3).join('.') } + if (typeof (state[scriptName].version) === 'number' && state[scriptName].version % 1 !== 0) { + state[scriptName].version = `${state[scriptName].version}`.replace(/\D/g, '').split('', 3).join('.') + } if (state[scriptName].version < Config.version) { const v = state[scriptName].version; if (v < `0.1.3`) { /* 0.5.3 fix - bad key names for very old versions */ @@ -221,12 +121,28 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars if (v < `0.7.0`) { // Two default buttons renamed - damageCrit => crit, and damageFull => damage const currentShownButtons = state[scriptName].settings.enabledButtons; - debug.warn(currentShownButtons); + debug.log(currentShownButtons); if (currentShownButtons) { - const { oldDamage, oldCrit } = currentShownButtons.reduce((out, v, i) => v === 'damageCrit' ? { ...out, oldCrit: i } : v === 'damageFull' ? { ...out, oldDamage: i } : out, {}); + const { + oldDamage, + oldCrit + } = currentShownButtons.reduce((out, v, i) => v === 'damageCrit' ? { + ...out, + oldCrit: i + } : v === 'damageFull' ? { ...out, oldDamage: i } : out, {}); if (oldDamage != null) currentShownButtons[oldDamage] = 'damage'; if (oldCrit != null) currentShownButtons[oldCrit] = 'crit'; - debug.warn(state[scriptName].settings.enabledButtons); + debug.log(state[scriptName].settings.enabledButtons); + } + } + if (v < `0.8.9`) { + log(`Backing up math strings on custom buttons...`); + if (state[scriptName].store && state[scriptName].store.customButtons) { + for (const button in state[scriptName].store.customButtons) { + if (state[scriptName].store.customButtons[button].mathString) { + state[scriptName].store.customButtons[button].mathBackup = state[scriptName].store.customButtons[button].mathString; + } + } } } state[scriptName].version = Config.version; @@ -234,37 +150,67 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars } Config.fetchFromState(); if ( - (!Config.getSetting('templates/names') || !Config.getSetting('templates/names').length) || - (!Config.getSetting('enabledButtons') || !Config.getSetting('enabledButtons').length)) { - // debug.log(`Loading preset...`); - if (firstTimeSetup) Config.loadPreset(); - else new ChatDialog({ title: `${scriptName} Install`, content:`No roll templates registered, or no buttons enabled. AutoButtons will not currently do anything. If you're still setting things up, this is probably ok, otherwise you may want to Reset to default sheet settings.` }, 'error'); + (!Config.getSetting('templates/names') || !Config.getSetting('templates/names').length) || + (!Config.getSetting('enabledButtons') || !Config.getSetting('enabledButtons').length)) { + if (firstTimeSetup) Config.loadPreset(); + else new ChatDialog({ + title: `${scriptName} Install`, + content: `No roll templates registered, or no buttons enabled. AutoButtons will not currently do anything. If you're still setting things up, this is probably ok, otherwise you may want to Reset to default sheet settings.` + }, 'error'); } // Check state of buttons, repair if needed if (!state[scriptName].store) Helpers.copyOldButtonStore(); for (const button in state[scriptName].store.customButtons) { state[scriptName].store.customButtons[button].default = false; const { err } = ButtonStore.addButton(state[scriptName].store.customButtons[button]); - if (err) debug.error(`${err}`); + const errorString = `${err}`; + if (err) { + new ChatDialog({ title: `${scriptName}: invalid button **${button}**`, content: errorString }); // **${state[scriptName].store.customButtons[button].name}** - ${err} + const recoverButton = { ...state[scriptName].store.customButtons[button], mathString: '0' }; + const { err } = ButtonStore.addButton(recoverButton); + if (!err) { + new ChatDialog({ + title: `${scriptName}: recovered button`, + content: `Button math was cleared, the problem math string was ${recoverButton.mathBackup}.` + }); + } + } } const allButtons = ButtonStore.getButtonNames(), - enabledButtons = Config.getSetting('enabledButtons'); + enabledButtons = Config.getSetting('enabledButtons'); const validButtons = enabledButtons.filter(v => allButtons.includes(v)); if (validButtons.length !== enabledButtons.length) { - debug.warn(`Invalid button found in enabledButtons - button hidden.`); + debug.warn(`Invalid button found in enabledButtons - button hidden.`); Config.changeSetting('enabledButtons', validButtons, { overwriteArray: true }); } log(`=( Initialised ${scriptName} - v${Config.version} )=`); } - + // Send buttons to chat - const sendButtons = (damage, crit, msg) => { - const gmOnly = Config.getSetting('gmOnly') ? true : false, - activeButtons = Config.getSetting(`enabledButtons`) || [], - name = Helpers.findName(msg.content), - buttonArray = Config.getSetting('autosort') ? activeButtons.sort((a,b) => a > b ? 1 : -1) : activeButtons, - htmlArray = buttonArray.map(btn => ButtonStore.createApiButton(btn, damage, crit)).filter(v=>v), - darkMode = Config.getSetting('darkMode'); + const sendButtons = (damage, crit, msg, beaconRoll = false) => { + const beaconSheetName = beaconRoll + ? preset[Config.getSetting('sheet')]?.beaconSheet?.sheet?.[0] ?? null + : null; + const beaconButtons = ButtonStore.getBeaconButtonNames(beaconSheetName); + + const gmOnly = Config.getSetting('gmOnly') ? true : false + const activeButtons = Config.getSetting(`enabledButtons`) || []; + let name = beaconRoll + ? Helpers.findBeaconName(msg.content) + : Helpers.findName(msg.content); + const damageType = beaconRoll + ? Helpers.findBeaconDamageType(msg.content, beaconSheetName) + : ''; + if (damageType) + name = `${name} - ${damageType}`; + + const buttonArray = beaconRoll && beaconSheetName + ? activeButtons.filter(button => beaconButtons.includes(button)) + : activeButtons; + const sortedButtons = Config.getSetting('autosort') ? buttonArray.sort((a, b) => a > b ? 1 : -1) : buttonArray; + const htmlArray = sortedButtons.map(btn => ButtonStore.createApiButton(btn, damage, crit)).filter(v => v); + const darkMode = Config.getSetting('darkMode'); + const baseSize = Config.getSetting('baseSize'); let sourceAttackAbility; if (Config.getSetting('multiattack')) sourceAttackAbility = Helpers5e.findNpcAttack(msg, name); const buttonBarLabel = sourceAttackAbility ? `` : `
${name}
`; @@ -273,24 +219,24 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars return; } const buttonHtml = htmlArray.join(''); - const buttonTemplate = `
${buttonBarLabel}${buttonHtml}
`; + const buttonTemplate = `
${buttonBarLabel}${buttonHtml}
`; Helpers.toChat(`${buttonTemplate}`, gmOnly); cacheBusted = true; } // Deconstruct & repackage Roll20 roll object const handleDamageRoll = (msg) => { - const dmgFields = Config.getSetting('templates/damageProperties/damageFields')||[], - critFields = Config.getSetting('templates/damageProperties/critFields')||[]; + const dmgFields = Config.getSetting('templates/damageProperties/damageFields') || [], + critFields = Config.getSetting('templates/damageProperties/critFields') || []; const damage = Helpers.processFields(dmgFields, msg), - crit = Helpers.processFields(critFields, msg); + crit = Helpers.processFields(critFields, msg); if ('dnd5e_r20' === Config.getSetting('sheet')) { const isSpell = Helpers5e.is5eAttackSpell(msg.content); if (isSpell) { - const upcastDamageFields = Config.getSetting('templates/damageProperties/upcastDamage')||[], - upcastCritFields = Config.getSetting('templates/damageProperties/upcastCrit')||[]; + const upcastDamageFields = Config.getSetting('templates/damageProperties/upcastDamage') || [], + upcastCritFields = Config.getSetting('templates/damageProperties/upcastCrit') || []; const upcastDamage = Helpers.processFields(upcastDamageFields, msg), - upcastCrit = Helpers.processFields(upcastCritFields, msg); + upcastCrit = Helpers.processFields(upcastCritFields, msg); Helpers.mergeDamageObjects(damage, upcastDamage); Helpers.mergeDamageObjects(crit, upcastCrit); } @@ -303,7 +249,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars const msgIsGM = playerIsGM(msg.playerid); if (msg.type === 'api' && msgIsGM && /^!autobut(ton)?s?\b/i.test(msg.content)) { const cmdLine = (msg.content.match(/^![^\s]+\s+(.+)/i) || [])[1], - commands = cmdLine ? cmdLine.split(/\s*--\s*/g) : []; + commands = cmdLine ? cmdLine.split(/\s*--\s*/g) : []; commands.shift(); debug.log(commands); if (commands.length) CLI.assess(commands); @@ -313,6 +259,33 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars if (ignoreAPI && /^api$/i.test(msg.playerid)) return; handleDamageRoll(msg); } + else if (msg.type === beaconRollDiscriminator && Config.getSetting('beacon')) { + const sheet = Config.getSetting('sheet'); + const damageTotal = scanBeaconRollOutput(sheet, msg.content); + if (damageTotal > 0) + sendBeaconDamage(damageTotal, msg); + } + } + + const sendBeaconDamage = (baseDamage, msg) => { + sendButtons({ dmg1: baseDamage, total: baseDamage }, { crit1: baseDamage, total: baseDamage }, msg, true) + } + + const scanBeaconRollOutput = (sheet, msgContent) => { + const beaconSheet = preset[sheet]?.beaconSheet; + if (beaconSheet) { + const templateName = msgContent.match(beaconSheet.templates.nameGroupRegex)?.[1] ?? ''; + if (beaconSheet.templates.nameTriggerRegex.test(templateName)) { + const header = msgContent.match(beaconSheet.templates.damageGroupRegex)?.[1] ?? ''; + if (beaconSheet.templates.damageTriggerRegex.test(header)) { + const damageResult = msgContent.match(beaconSheet.templates.damageResultGroupRegex)?.[1] ?? ''; + + return damageResult + ? parseInt(damageResult) + : null; + } + } + } } // Make script do stuff @@ -323,10 +296,36 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars /** * SHEET PRESET DATA */ - // TODO: Replace with PresetController class ??? + // Experimental Beacon support + const dndDamageTypes = ['custom', 'Acid', 'Bludgeoning', 'Cold', 'Fire', 'Force', 'Lightning', 'Necrotic', 'Piercing', 'Poison', 'Psychic', 'Radiant', 'Slashing', 'Thunder']; + const beaconPreset = { + dnd5e_2024: { + sheet: ['dnd5e_2024'], + templates: { + nameGroupRegex: /^([^<]+)/, + damageTriggerRegex: new RegExp(dndDamageTypes.reduce((output, type, index) => { + return`${output}${index === 0 + ? `\(${type}|` + : index === dndDamageTypes.length - 1 + ? `${type}\)` + : `${type}|`}`; + }, ''), 'i'), + damageResultGroupRegex: /data-result="(\d+)/, + damageFields: ['damage'], + critFields: ['crit'], + upcastDamage: [], + upcastCrit: [], + }, + defaultButtons: ['damage', 'damageHalf', 'healingFull'], + } + }; + const preset = { dnd5e_r20: { sheet: ['dnd5e_r20'], + beaconSheet: beaconPreset.dnd5e_2024, templates: { names: ['atkdmg', 'dmg', 'npcfullatk', 'npcdmg'], damageProperties: { @@ -357,23 +356,24 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars const styles = { error: `color: red; font-weight: bold;`, outer: `position: relative; vertical-align: middle; font-family: pictos; display: block; background: #f4e6b6; border: 1px solid black; height: auto; line-height: 34px; text-align: center; border-radius: 2px;`, - rollName: `font-family: arial; font-size: 1.1rem; color: black; font-style:italic; font-weight: bold; position:relative; overflow: hidden; display: block; line-height: 1.2rem; margin: 1px 0px 0px 0px; white-space: nowrap; text-align: left; left: 2px;`, - buttonContainer: `display: inline-block; text-align: center; vertical-align: middle; line-height: 26px; margin: auto 5px auto 5px; height: 2.6rem; width: 2.6rem; border: #8c6700 1px solid; box-shadow: 0px 0px 3px #805200; border-radius: 5px; background-color: whitesmoke; position: relative;`, - buttonShared: `background-color: transparent; border: none; border-radius: 5px; padding: 0px; width: 100%; height: 100%; overflow: hidden; white-space: nowrap; position: absolute; top: 0; left: 0; text-decoration: none;`, - crit: `color: darkred; font-size: 2.9rem; line-height: 2.3rem; text-shadow: 0px 0px 2px black;`, - crit2: `color: #ff4040; font-size: 1.8rem; line-height: 2.4rem;`, - full: `color: darkred; font-size: 2.4rem; line-height: 2.3rem; text-shadow: 0px 0px 2px black;`, - half: `color: black; font-family: pictos three; font-size: 2.6rem; line-height: 3rem; text-shadow: 0px 0px 2px black;`, - halfSmall: `color: black; font-family: pictos three; font-size: 2.2rem; line-height: 2.8rem; text-shadow: 0px 0px 1px black;`, - half2: `color: whitesmoke; font-family: cursive; font-size: 0.9rem; line-height: 2.6rem;`, - critHalf: `color: #d51d1d; font-family: pictos three; font-size: 3.2rem; line-height: 2.8rem; text-shadow: 0px 0px 2px black;`, - healFull: `color: green; font-size: 2.4rem; line-height: 2.3rem; text-shadow: 0px 0px 2px black;`, - damageLabel: `font-family: cursive; font-size: 1.2rem; font-weight: bolder; color: #f2c8c8; line-height: 2.4rem;`, - healLabel: `color: #cdf7d1; font-family:cursive; font-size:1.8rem; font-weight:bold; line-height: 2.2rem; text-shadow: 0px 0px 2px white;`, - resist: ` font-family: pictos three; font-size: 2.6rem; line-height: 2.8rem; text-shadow: 0px 0px 2px black; color: #003f82;`, - resistSmall: ` font-family: pictos three; font-size: 2.2rem; line-height: 2.8rem; color: #003f82; text-shadow: 0px 0px 1px black;`, - resistLabel: `font-family: cursive; font-size: 1rem; line-height: 2.6rem; `, - imageIcon: `width: 100%;`, //background-color: transparent; border: none; border-radius: 5px; padding: 0px; + rollName: `font-family: arial; font-size: 0.9em; color: black; font-style:italic; font-weight: bold; position:relative; overflow: hidden; display: block; line-height: 1.2em; margin: 1px 0px 0px 0px; white-space: nowrap; text-align: left; left: 2px;`, + buttonContainer: `display: flex; align-items: center; gap: 2px;`, + button: `display: inline-block; text-align: center; vertical-align: middle; line-height: 2em; margin: auto 0.2em 0.2em 0.2em; height: 2em; width: 2em; border: #8c6700 1px solid; box-shadow: 0px 0px 3px #805200; border-radius: 5px; background-color: whitesmoke; position: relative;`, + buttonShared: `background-color: transparent; border: none; border-radius: 5px; padding: 0px; width: 100%; height: 100%; overflow: hidden; white-space: nowrap; position: absolute; top: 0; left: 0; text-decoration: none; font-size: 2em; `, + crit: `color: darkred; font-size: 2.2em; text-shadow: 0px 0px 2px black; top: -0.05em;`, + crit2: `color: #ff4040; font-size: 1.3em; top: -0.05em;`, + full: `color: darkred; font-size: 2.0em; text-shadow: 0px 0px 2px black; top: -0.05em;`, + half: `color: black; font-family: pictos three; font-size: 2.1em; text-shadow: 0px 0px 2px black;`, + halfSmall: `color: black; font-family: pictos three; font-size: 1.5em; text-shadow: 0px 0px 1px black;`, + half2: `color: whitesmoke; font-family: cursive; font-size: 0.55em; `, + critHalf: `color: #d51d1d; font-family: pictos three; font-size: 2.2em; text-shadow: 0px 0px 2px black;`, + healFull: `color: green; font-size: 2.0em; text-shadow: 0px 0px 2px black; top: -0.05em;`, + damageLabel: `font-family: cursive; font-size: 1.0em; font-weight: bolder; color: #f2c8c8; `, + healLabel: `color: #cdf7d1; font-family:cursive; font-size:1.2em; font-weight:bold; text-shadow: 0px 0px 2px white; top: -0.05em;`, + resist: ` font-family: pictos three; font-size: 2.0em; text-shadow: 0px 0px 2px black; color: #003f82;`, + resistSmall: ` font-family: pictos three; font-size: 1.5em; color: #003f82; text-shadow: 0px 0px 1px black;`, + resistLabel: `font-family: cursive; font-size: 0.55em; `, + imageIcon: `width: 100%;`, //background-color: transparent; border: none; border-radius: 5px; padding: 0px; imageIcons: { damage: `https://s3.amazonaws.com/files.d20.io/images/306656028/gtPy6tdbegC9QOtDd1nf6Q/original.png`, damageHalf: ``, @@ -403,9 +403,9 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars buttonContainer: `background-color: #7b7565; border-color: #aea190; box-shadow: 0px 0px 2px #aea190;`, }, list: { - container: `font-size: 1.5rem; background: #41415c; border: 5px solid #1c7b74; border-radius: 3px; color: white; vertical-align: middle;`, + container: `font-size: 1rem; background: #41415c; border: 5px solid #1c7b74; border-radius: 3px; color: white; vertical-align: middle;`, header: `text-align: center; font-weight: bold; padding: 6px 0px 6px 0px; border-bottom: solid 1px darkgray; line-height: 1.5em;`, - body: `padding: 8px 1rem 8px 1rem;`, + body: `padding: 8px 1em 8px 1em;`, row: `vertical-align: middle; margin: 0.2em auto 0.2em auto; font-size: 1.2em; line-height: 1.4em;`, name: `display: inline-block; vertical-align: middle; width: 60%; margin-left: 5%; overflow-x: hidden;`, faded: `opacity: 0.4;`, @@ -416,38 +416,38 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars hide: `color: #2a2a2a;`, disabled: `color: gray; cursor: pointer;`, delete: `color: darkred;`, - create: `display: inline-block; background-color: darkgray; padding: 0px; margin: 1rem 0; border: 1px solid #c2c2c2; border-radius: 3px; color: #066a66; padding: 2px 5px 2px 5px; font-size: 1.1em; line-height: 1.2em;`, + create: `display: inline-block; background-color: darkgray; padding: 0px; margin: 1em 0; border: 1px solid #c2c2c2; border-radius: 3px; color: #066a66; padding: 2px 5px 2px 5px; font-size: 1.1em; line-height: 1.2em;`, no: `position: absolute; left: 0.4em; font-weight: bold; font-family: arial;` }, footer: `text-align: center; font-weight: bold; padding: 6px 0px 6px 0px; border-top: solid 1px darkgray; line-height: 1.5em;`, }, table: { - outer: `overflow-x: auto; width: 100%;`, - table: `margin: 1rem auto; width: 95%; justify-content: center; border: 1px solid #7fb07f;`, + outer: `font-size: 1rem; overflow-x: auto; width: 100%;`, + table: `margin: 1em auto; width: 95%; justify-content: center; border: 1px solid #7fb07f;`, headerRow: ``, - row: `background-color: #5e5e63; margin: 0.5rem;`, - headerCell: ` text-align: center; font-size: 1.7rem; padding: 1rem; border-bottom: 1px solid #7fb07f;`, - cell: `padding: 0.2rem 1rem; line-height: 2.5rem; margin: 1px 0px;`, + row: `background-color: #5e5e63; margin: 0.5em;`, + headerCell: `text-align: center; font-size: 1.2em; padding: 0.6em; border-bottom: 1px solid #7fb07f;`, + cell: `padding: 0.2em 0.3em; line-height: 1em; margin: 1px 0px;`, rowBorders: `border-top: 1px solid #7fb07f;`, - footer: `margin: 0 auto 1.5rem auto;`, - settingName: `border: 1px solid whitesmoke; padding: 0.4rem 0; border-radius: 0.5rem; cursor: help; margin: 1px auto;`, - button: ` display: inline-block; background-color: darkgray; border: 1px solid #cae1df; box-shadow: 0px 0px 3px #bcdbd8; border-radius: 3px; color: #045754; padding: 0.3rem 0.5rem; margin: 0.2rem 0!important; font-size: 1.1em; line-height: 1.2em;`, + footer: `margin: 0 auto 1.1em auto;`, + settingName: `border: 1px solid whitesmoke; padding: 0.4em 0; border-radius: 0.5em; cursor: help; margin: 1px auto;`, + button: `display: inline-block; background-color: darkgray; border: 1px solid #cae1df; box-shadow: 0px 0px 3px #bcdbd8; border-radius: 3px; color: #045754; padding: 0.3em 0.5em; margin: 0.2em 0!important; font-size: 1em; line-height: 1.1em;`, }, components: { - labelWithDelete: function(label, commandString) { + labelWithDelete: function (label, commandString) { const styleOuter = `border: 1px solid whitesmoke; padding: 0.2rem 0rem; border-radius: 0.5rem; width: max-content; margin: 2px auto; display: inline-block; line-height: 1.2rem; white-space: nowrap;`, - styleDelete = `font-family: pictos; color: darkred; background-color: gray; height: 1rem; line-height: 1.2rem; width: 1.2rem; text-align: center; margin: 0 1rem; border: 1px solid #aaa8a8; border-radius: 0.5rem;`, - styleLabel = `display: inline-block; overflow-x: clip; margin-left: 0.5rem;` + styleDelete = `font-family: pictos; color: darkred; background-color: gray; height: 1rem; line-height: 1.2rem; width: 1.2rem; text-align: center; margin: 0 1rem; border: 1px solid #aaa8a8; border-radius: 0.5rem;`, + styleLabel = `display: inline-block; overflow-x: clip; margin-left: 0.5rem;` return `
${label}
*
` }, - confirmApiCommand: function(confirmAction) { + confirmApiCommand: function (confirmAction) { return `!autobut?{Are you sure you wish to ${confirmAction}|Yes, |No,fffff}`; }, }, report: ``, // BUMP setting CSS - if Roll20 dick with the chatbar CSS this will need to be updated mods: { - bump: `left: -5px; top: -30px; margin-bottom: -34px;` + bump: `left: -5px; top: -30px; margin-bottom: -34px; padding-bottom: 1px;` } } @@ -480,7 +480,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, damage: { name: `damage`, - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Full (%)`, style: styles.full, math: (damage) => -(damage.total), @@ -488,7 +488,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, damageHalf: { name: `damageHalf`, - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Half (%)`, style: styles.half, style2: styles.half2, @@ -498,7 +498,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, healingFull: { name: `healingFull`, - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Heal (%)`, style: styles.healFull, style2: styles.healLabel, @@ -509,17 +509,17 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars // Buttons added in 0.6.x damagePrimary: { name: `damagePrimary`, - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Damage 1 (%)`, style: styles.full, style2: styles.damageLabel, - math: (damage) => -(damage.dmg1 + (damage.hldmg||0) + damage.globaldamage), + math: (damage) => -(damage.dmg1 + (damage.hldmg || 0) + damage.globaldamage), content: 'k', content2: '1', }, damageSecondary: { name: `damageSecondary`, - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Damage 2 (%)`, style: styles.full, style2: styles.damageLabel, @@ -534,7 +534,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars style: styles.crit, style2: styles.crit2, style3: styles.damageLabel, - math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg||0) + (crit.hldmgcrit||0) + damage.globaldamage + crit.globaldamagecrit), + math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg || 0) + (crit.hldmgcrit || 0) + damage.globaldamage + crit.globaldamagecrit), content: 'k', content2: 'k', content3: '1', @@ -553,7 +553,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, 'resist%': { name: 'resist%', - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Damage Resist % (%)`, style: styles.resist, style2: styles.resistLabel, @@ -564,7 +564,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, 'resistN': { name: 'resistN', - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Damage Resist Flat (%)`, style: styles.resist, style2: styles.resistLabel, @@ -601,29 +601,29 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, 'resistPrimary%': { name: 'resistPrimary%', - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Damage Resist 1 % (%)`, style: styles.resist, style2: styles.resistLabel, - math: (damage) => -(damage.dmg1 + (damage.hldmg||0) + damage.globaldamage), + math: (damage) => -(damage.dmg1 + (damage.hldmg || 0) + damage.globaldamage), query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, content: 'b', content2: '1%', }, 'resistPrimaryN': { name: 'resistPrimaryN', - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Damage Resist 1 Flat (%)`, style: styles.resist, style2: styles.resistLabel, - math: (damage) => -(damage.dmg1 + (damage.hldmg||0) + damage.globaldamage), + math: (damage) => -(damage.dmg1 + (damage.hldmg || 0) + damage.globaldamage), query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, content: 'b', content2: '1n', }, 'resistSecondary%': { name: 'resistSecondary%', - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Damage Resist 2 % (%)`, style: styles.resist, style2: styles.resistLabel, @@ -634,7 +634,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, 'resistSecondaryN': { name: 'resistSecondaryN', - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Damage Resist 2 Flat (%)`, style: styles.resist, style2: styles.resistLabel, @@ -645,12 +645,12 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, 'resistPrimaryCrit%': { name: 'resistPrimaryCrit%', - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Crit Resist 1 % (%)`, style: styles.critHalf, style2: styles.resistSmall, style3: styles.resistLabel, - math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg||0) + (crit.hldmgcrit||0) + damage.globaldamage + crit.globaldamagecrit), + math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg || 0) + (crit.hldmgcrit || 0) + damage.globaldamage + crit.globaldamagecrit), query: `*|Damage multiplier (??? * %%MODIFIER%% damage)|0`, content: 'b', content2: 'b', @@ -658,12 +658,12 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, 'resistPrimaryCritN': { name: 'resistPrimaryCritN', - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Crit Resist 1 Flat (%)`, style: styles.critHalf, style2: styles.resistSmall, style3: styles.resistLabel, - math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg||0) + (crit.hldmgcrit||0) + damage.globaldamage + crit.globaldamagecrit), + math: (damage, crit) => -(damage.dmg1 + crit.crit1 + (damage.hldmg || 0) + (crit.hldmgcrit || 0) + damage.globaldamage + crit.globaldamagecrit), query: `-|Damage resist (%%MODIFIER%% - ??? damage)|0`, content: 'b', content2: 'b', @@ -671,7 +671,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, 'resistSecondaryCrit%': { name: 'resistSecondaryCrit%', - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Crit Resist 2 % (%)`, style: styles.critHalf, style2: styles.resistSmall, @@ -684,7 +684,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, 'resistSecondaryCritN': { name: 'resistSecondaryCritN', - sheets: ['dnd5e_r20'], + sheets: ['dnd5e_r20', 'dnd5e_2024'], tooltip: `Crit Resist 2 Flat (%)`, style: styles.critHalf, style2: styles.resistSmall, @@ -703,17 +703,17 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars /** * HELPER FUNCTIONS */ - class Helpers { + class Helpers { // Process roll object according to rolltemplate fields static processFields(fieldArray, msg) { let output = {} const rolls = msg.inlinerolls; output.total = fieldArray.reduce((m, v) => { const rxIndex = new RegExp(`{${v}=\\$\\[\\[\\d+`, 'g'), - indexResult = msg.content.match(rxIndex); + indexResult = msg.content.match(rxIndex); if (indexResult) { const index = indexResult.pop().match(/\d+$/)[0], - total = isNaN(rolls[index].results.total) ? 0 : rolls[index].results.total; + total = isNaN(rolls[index].results.total) ? 0 : rolls[index].results.total; output[v] = total; return m + total; } else { // if roll template property's inline roll is not found, return 0 to prevent errors down the line @@ -723,6 +723,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, 0); return output; } + // Simple name finder, provided rolltemplate has some kind of 'name' property static findName(msgContent) { const rxRname = /{rname=(.+?)}}/i; @@ -730,38 +731,55 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars let name = msgContent.match(rxRname) || msgContent.match(rxName); return name ? name[1] : 'Apply:'; } + + static findBeaconName(msgContent) { + const name = msgContent.match(/class="header__title">([^<]+)/)?.[1]; + return name ?? 'Apply:'; + } + + static findBeaconDamageType(msgContent, beaconSheetName) { + return msgContent.match(beaconPreset[beaconSheetName]?.templates?.damageGroupRegex)?.[1] ?? ''; + } + // sendChat shortcut static toChat(msg, whisper = true) { let prefix = whisper ? `/w gm ` : ''; - sendChat(scriptName, `${prefix}${msg}`, {noarchive: true}); + sendChat(scriptName, `${prefix}${msg}`, { noarchive: true }); + } + + static toArray(inp) { + return Array.isArray(inp) ? inp : [inp]; } - static toArray(inp) { return Array.isArray(inp) ? inp : [inp]; } + static emproper(inpString) { let words = inpString.split(/\s+/g); return words.map(w => `${w[0].toUpperCase()}${w.slice(1)}`).join(` `); } + // Split {{handlebars=moustache}} notation to key:value static splitHandlebars(inputString) { let output = {}, - kvArray = inputString.match(/{{[^}]+}}/g)||[]; + kvArray = inputString.match(/{{[^}]+}}/g) || []; kvArray.forEach(kv => { kv = kv.replace(/({{|}})/g, ''); const key = kv.match(/^[^=]+/), - value = (kv.match(/=(.+)/)||[])[1] || ``; + value = (kv.match(/=(.+)/) || [])[1] || ``; if (key) output[key] = value; }); return Object.keys(output).length ? output : null; } + // Camelise a name if user tries to use whitespace - static camelise(inp, options={enforceCase:false}) { - if (typeof(inp) !== 'string') return null; + static camelise(inp, options = { enforceCase: false }) { + if (typeof (inp) !== 'string') return null; const words = inp.split(/[\s_]+/g); - return words.map((w,i) => { + return words.map((w, i) => { const wPre = i > 0 ? w[0].toUpperCase() : w[0].toLowerCase(); const wSuf = options.enforceCase ? w.slice(1).toLowerCase() : w.slice(1); return `${wPre}${wSuf}`; }).join(''); } + /** * Grab a dark mode CSS append string if it exists and dark mode is enabled * @param {string} styleName - keyname of style @@ -772,36 +790,55 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars static appendDarkMode(styleName, darkModeEnabled, stylesPath = styles) { return (!darkModeEnabled || !stylesPath || !stylesPath.darkMode || !stylesPath.darkMode[styleName]) ? `` : stylesPath.darkMode[styleName]; } + + static appendBaseSize(fontSize) { + const validated = this.clamp(parseFloat(fontSize ?? ''), 0.5, 3.0); + + return validated > 0 + ? `font-size: ${validated}rem; line-height: ${validated}rem; ` + : 'font-size: 1rem; line-height: 1rem; '; + } + + static clamp(value, min, max) { + if ([value, min, max].some(v => isNaN(v))) + return null; + return Math.min(max, Math.max(min, value)); + } + /** * Check if an object is a basic JS object - * @param {any} input - * @returns {bool} + * @param {any} input + * @returns {boolean} */ - static isObj(input) { return (typeof(input) === 'object' && input.constructor.name === 'Object') ? true : false } + static isObj(input) { + return (typeof (input) === 'object' && (!input.constructor || !input.constructor.name || input.constructor.name === 'Object')); + } - static copyObj(inputObj) { return (typeof inputObj !== 'object') ? null : JSON.parse(JSON.stringify(inputObj)); } + static copyObj(inputObj) { + return (typeof inputObj !== 'object') ? null : JSON.parse(JSON.stringify(inputObj)); + } static getObjectPath(pathString, baseObject, createPath, deleteTarget) { const parts = pathString.split(/\/+/g); - const objRef = parts.reduce((m,v,i) => { + const objRef = parts.reduce((m, v, i) => { if (m == null) return; if (m[v] == null) { if (createPath) m[v] = {}; else return null; } - if (deleteTarget && (i === parts.length-1)) delete m[v]; - else return m[v];}, baseObject) + if (deleteTarget && (i === parts.length - 1)) delete m[v]; + else return m[v]; + }, baseObject) return objRef; } - // If value exists in array, it will be removed, otherwise it will be added. No validation done. Does not mutate the original. - static modifyArray(targetArray, newValue) { + // If value exists in array, it will be removed, otherwise it will be added. No validation done. + static modifyArray(targetArray, newValue) { if (!Array.isArray(targetArray || newValue == null)) return { err: `modifyArray error, bad parameters` }; if (targetArray.includes(newValue)) { - Helpers.filterAndMutate(targetArray, (v) => v === newValue); + Helpers.filterAndMutate(targetArray, (v) => v === newValue); return { msg: `Removed ${newValue} from array.` } - } - else { + } else { targetArray = targetArray.push(newValue); return { msg: `Added ${newValue} to array.` } } @@ -810,15 +847,15 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars /** * Filter an array by reference * @param {array.} inputArray - * @param {function} predicate + * @param {function} predicate * @return {boolean} success/failure */ static filterAndMutate(inputArray, predicate) { - if (typeof(predicate) !== 'function' || !Array.isArray(inputArray)) { - debug.error(`filterMutate requires an array and a predicate function.`); + if (typeof (predicate) !== 'function' || !Array.isArray(inputArray)) { + debug.error(`filterAndMutate requires an array and a predicate function.`); return false; } - for (let i=inputArray.length-1; i>=0; i--) { + for (let i = inputArray.length - 1; i >= 0; i--) { if (predicate(inputArray[i])) inputArray.splice(i, 1); } return true; @@ -838,17 +875,18 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars /** * Recalculate the total key in a damage object - * @param {object} damageObject + * @param {object} damageObject */ static recalculateDamageTotal(damageObject) { damageObject.total = 0; for (const key in damageObject) damageObject.total += key === 'total' ? 0 : damageObject[key]; } + /** * Merge two damage objects together and recalculate total - * @param {object} baseObject - * @param {object} addObject + * @param {object} baseObject + * @param {object} addObject */ static mergeDamageObjects(baseObject, addObject) { Object.assign(baseObject, addObject); @@ -865,10 +903,11 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars const rxSpell = /{spelllevel=(cantrip|\d+)/; return rxSpell.test(msgContent) ? 1 : 0; } + /** * Find a repeating_npcaction attack from the roll template content. Optionally supply the attack name. * @param {Object} msg - r20 message object - * @param {string} [attackName] - name of the attack + * @param {string} [attackName] - name of the attack * @returns {?string} - content of @{rollbase} in the target attack */ static findNpcAttack = (msg, attackName) => { @@ -878,9 +917,9 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars characterName: /{{name=(.+?)}}/, attackNameAttribute: /^repeating_npcaction_(-[0-z-]{19})_name/i, }; - attackName = attackName || (msg.content.match(rx.attackName)||[])[1]; - const characterName = (msg.content.match(rx.characterName)||[])[1], - char = findObjs({ type: 'character', name: characterName })[0]; + attackName = attackName || (msg.content.match(rx.attackName) || [])[1]; + const characterName = (msg.content.match(rx.characterName) || [])[1], + char = findObjs({ type: 'character', name: characterName })[0]; if (!char || !attackName) return null; const attackRowId = findObjs({ type: 'attribute', characterid: char.id }).reduce((out, attribute) => { if (attribute.get('current') === attackName) { @@ -895,6 +934,87 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars } } + /** + * MATH-OPS - Transform autoButtons math strings and damage objects for MathOps API + */ + class MathOpsTransformer { + constructor() { + throw new Error(`${this.constructor.name} cannot be instantiated.`); + } + + static rxKeyDigitReplacer = /(damage||crit)\.(\w+)/g; + static replacers = { + 0: 'Zero', + 1: 'One', + 2: 'Two', + 3: 'Three', + 4: 'Four', + 5: 'Five', + 6: 'Six', + 7: 'Seven', + 8: 'Eight', + 9: 'Nine', + }; + static prefixJoin = 'X'; + + /** + * Replace all digits in a string with alpha characters + * @param {string} inputString + * @returns {string} + */ + static digitReplacer(inputString) { + if (!/\d/.test(inputString)) return inputString; + let modifiedString = inputString; + for (const digit in this.replacers) { + const rxReplacer = new RegExp(digit, 'g'); + modifiedString = modifiedString.replace(rxReplacer, this.replacers[digit]); + } + return modifiedString; + } + + /** + * Transform the keynames in the damage object to make them MathOps-friendly + * @param {object} damageObject - autoButtons damage object with damage values + * @param {string} prefix - prefix string, damage or crit + * @returns {object} - autoButtons damage object with numerals replaced with alpha character in key names + */ + static transformDamageObject(damageObject, prefix) { + return Object.entries(damageObject).reduce((output, [key, value]) => { + const newKey = `${prefix}${this.prefixJoin}${this.digitReplacer(key)}`; + output[newKey] = value; + return output; + }, {}); + } + + /** + * Transform a math string for MathOps - same transform as the damage objects + * @param {string} mathString - autoButtons math string + * @returns {string} - math string with key references transformed to remove digits + */ + static transformMathString(mathString) { + const doTransform = (match, prefix, keyName) => { + return `${prefix}${this.prefixJoin}${this.digitReplacer(keyName)}`; + } + const transform = mathString.replace(this.rxKeyDigitReplacer, doTransform); + return /^\s*[+-]/.test(transform) + ? `0${transform}` + : transform; + } + + /** + * Transform the damage and crit objects for use with MathOps + * @param {object} damageObject - autoButtons damage object with damage values + * @param {object} critObject - autoButtons crit object with damage values + * @returns {object} - flattened object with all numerals in keynames replaced with alpha characters, prefixed with parent object name + */ + static transformMathOpsPayload(damageObject, critObject = {}) { + return { + ...this.transformDamageObject(damageObject, 'damage'), + ...this.transformDamageObject(critObject, 'crit'), + } + } + } + /** * COMMAND LINE INTERFACE OPTIONS */ @@ -904,7 +1024,9 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars rx: /^bump/i, description: `Bump the button UI up to the top of the chat message`, requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('bump', args, { createPath: true, force: 'boolean' }) } + action: function (args) { + return this.config.changeSetting('bump', args, { createPath: true, force: 'boolean' }) + } }, { name: 'targetTokens', @@ -915,7 +1037,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars const result = this.config.changeSetting('targetTokens', args, { createPath: true, force: 'boolean' }); if (this.config.getSetting('targetTokens') && result.success && result.msg) result.msg.push(`*Important*: Players cannot use targeting unless TokenMod is set to allow players to use token ids.`); return result; - } + } }, { name: 'reset', @@ -938,7 +1060,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars const newVal = parseInt(`${args}`.replace(/\D/g, '')); if (newVal > 0 && newVal < 4) { return this.config.changeSetting('hpBar', newVal); - } else return { err: `token bar value must be 1, 2 or 3`} + } else return { err: `token bar value must be 1, 2 or 3` } } }, { @@ -955,7 +1077,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars this.buttons.verifyButtons(); return { success: 1, msg: `Preset changed: ${newVal}` }; } else return { err: `Error changing preset to "${newVal}"` }; - } else return { err: `Coudln't find sheet/preset: ${args}` } + } else return { err: `Couldn't find sheet/preset: ${args}` } } }, { @@ -965,14 +1087,12 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars requiredServices: { config: 'ConfigController' }, action: function () { const templates = this.config.getSetting(`templates/names`), - confirm = styles.components.confirmApiCommand(`delete this template name?`), - templateText = Helpers.toArray(templates).map(v => [ - // `
${v}
`, - // `Delete` - styles.components.labelWithDelete(v, `${confirm}autobut --deleteTemplate ${v}`) - ]), - footerContent = `Add template`; - templateText.unshift([ 'Template name']); + confirm = styles.components.confirmApiCommand(`delete this template name?`), + templateText = Helpers.toArray(templates).map(v => [ + styles.components.labelWithDelete(v, `${confirm}autobut --deleteTemplate ${v}`) + ]), + footerContent = `Add template`; + templateText.unshift(['Template name']); new ChatDialog({ content: templateText, title: `Roll Template List`, footer: footerContent }, 'table'); } }, @@ -1009,9 +1129,9 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars requiredServices: { config: 'ConfigController' }, action: function () { const properties = this.config.getSetting('templates/damageProperties'), - confirm = styles.components.confirmApiCommand(`delete this template property?`), - styleCategory = `font-size: 1.4rem; font-weight: bold; font-style: italic;` - let templateText = [ ['Category', 'Properties'] ]; + confirm = styles.components.confirmApiCommand(`delete this template property?`), + styleCategory = `font-size: 1.1em; font-weight: bold; font-style: italic;` + let templateText = [['Category', 'Properties']]; if (typeof properties === 'object') { for (let category in properties) { const propButtons = properties[category].map(prop => styles.components.labelWithDelete(prop, `${confirm}autobut --deleteprop ${category}/${prop}`)); @@ -1021,14 +1141,18 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars ]); } } else return { err: `Error getting damage properties from state` } - new ChatDialog({ title: 'Roll Template Properties', content: templateText, borders: { row: true } }, 'table'); + new ChatDialog({ + title: 'Roll Template Properties', + content: templateText, + borders: { row: true } + }, 'table'); } }, { name: 'addProperty', rx: /^addprop/i, description: `Add a roll template property to the listener`, - requiredServices: { config: 'ConfigController', }, + requiredServices: { config: 'ConfigController', }, action: function (args) { const parts = args.match(/([^/]+)\/(.+)/); if (parts && parts.length === 3) { @@ -1046,7 +1170,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars name: 'removeProperty', rx: /^(remove|delete)?prop/i, description: `Remove a roll template property from the listener`, - requiredServices: { config: 'ConfigController', }, + requiredServices: { config: 'ConfigController', }, action: function (args) { const parts = args.match(/([^/]+)\/(.+)/); if (parts && parts.length === 3) { @@ -1072,12 +1196,12 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars rx: /^(list)?button/i, description: `List available buttons`, requiredServices: { config: 'ConfigController', buttons: 'ButtonManager' }, - action: function() { + action: function () { const removableButtons = this.buttons.getButtonNames({ default: false }), - usedButtons = this.config.getSetting('enabledButtons'), - unusedButtons = this.buttons.getButtonNames({ hidden: true }), - availableButtons = this.buttons.getButtonNames({ hidden: true, currentSheet: true }), - reorderedButtons = usedButtons.concat(unusedButtons); + usedButtons = this.config.getSetting('enabledButtons'), + unusedButtons = this.buttons.getButtonNames({ hidden: true }), + availableButtons = this.buttons.getButtonNames({ hidden: true, currentSheet: true }), + reorderedButtons = usedButtons.concat(unusedButtons); const links = { hide: `!autoButton --hideButton %name%`, show: `!autoButton --showButton %name%`, @@ -1092,21 +1216,21 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }; const controls = ['show', 'hide', 'delete']; const listBody = reorderedButtons.map(button => { - const fadeText = usedButtons.includes(button) ? '' : styles.list.faded; + const fadeText = usedButtons.includes(button) ? '' : styles.list.faded; let rowHtml = `
${removableButtons.includes(button) ? '' : '*'}%name%
`; controls.forEach(control => { const controlType = ( (control === 'show' && availableButtons.includes(button)) || (control === 'hide' && usedButtons.includes(button)) || (control === 'delete' && removableButtons.includes(button))) ? - control : 'disabled'; + control : 'disabled'; rowHtml += ``; }); return `${rowHtml.replace(/%name%/g, button)}
`; }); const headerText = `autoButton list (sheet: ${this.config.getSetting('sheet')})`, - bodyText = listBody.join(''), - footerText = `Create New Button`; + bodyText = listBody.join(''), + footerText = `Create New Button`; new ChatDialog({ header: headerText, body: bodyText, footer: footerText }, 'listButtons'); }, }, @@ -1120,7 +1244,10 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars const validButtons = this.buttons.getButtonNames({ hidden: true, currentSheet: true }); if (validButtons.includes(newVal)) { return this.config.changeSetting('enabledButtons', newVal); - } else new ChatDialog({ title: 'Error', content: `Unrecognised or incompatible button: "${newVal}"` }, 'error'); + } else new ChatDialog({ + title: 'Error', + content: `Unrecognised or incompatible button: "${newVal}"` + }, 'error'); } }, { @@ -1133,7 +1260,10 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars const validButtons = this.buttons.getButtonNames({ shown: true, currentSheet: true }); if (validButtons.includes(newVal)) { return this.config.changeSetting('enabledButtons', newVal); - } else new ChatDialog({ title: 'Error', content: `Unrecognised or incompatible button: "${newVal}"` }, 'error'); + } else new ChatDialog({ + title: 'Error', + content: `Unrecognised or incompatible button: "${newVal}"` + }, 'error'); } }, { @@ -1144,7 +1274,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars action: function (args) { if (!args) return; const newIndices = args.replace(/[^\d,]/g, '').split(/,/g), - currentOrder = this.config.getSetting('enabledButtons'); + currentOrder = this.config.getSetting('enabledButtons'); let newOrder = []; let valid = true; newIndices.forEach(buttonIndex => { @@ -1174,13 +1304,12 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars if (this.buttons.getButtonNames().includes(buttonName)) return { err: `Invalid button name, already in use: "${buttonName}"` } if (!buttonData.math) return { err: `Button must have an associated function, {{math=...}}` } buttonData.default = false; - // construct query if provided - // if (buttonData.query) buttonData.query = Button.splitAndEscapeQuery(buttonData.query); + buttonData.mathString = buttonData.math; const result = this.buttons.addButton(buttonData); if (result.success) { this.buttons.showButton(buttonName); return result; - } + } else return result.err || `An error occurred creating the button.`; } else return { err: `Bad input for button creation` } } }, @@ -1196,7 +1325,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars if (this.buttons.getButtonNames().includes(buttonData.name)) { return this.buttons.editButton(buttonData); } - } + } } }, { @@ -1206,7 +1335,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars requiredServices: { buttons: 'ButtonManager', config: 'ConfigController' }, action: function (args) { const removeResult = this.buttons.removeButton(args.trim()), - buttonIsEnabled = this.config.getSetting('enabledButtons').includes(args); + buttonIsEnabled = this.config.getSetting('enabledButtons').includes(args); if (removeResult.success) { if (buttonIsEnabled) this.config.changeSetting('enabledButtons', args); return removeResult; @@ -1218,50 +1347,205 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars rx: /^ignoreapi/i, description: `Ignore anything sent to chat by the API`, requiredServices: { config: 'ConfigController' }, - action: function(args) { return this.config.changeSetting('ignoreAPI', args) } + action: function (args) { + return this.config.changeSetting('ignoreAPI', args) + } }, { name: 'overheal', rx: /^overh/i, description: `Allow healing to push hp above hpMax`, requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('overheal', args) } + action: function (args) { + return this.config.changeSetting('overheal', args) + } }, { name: 'overkill', rx: /^overk/i, description: `Allow healing to push hp above hpMax`, requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('overkill', args) } + action: function (args) { + return this.config.changeSetting('overkill', args) + } }, { name: 'gmOnly', rx: /^gmo/i, description: `Whisper the buttons to GM, or post publicly`, requiredServices: { config: 'ConfigController' }, - action: function (args) { return this.config.changeSetting('gmOnly', args) } + action: function (args) { + return this.config.changeSetting('gmOnly', args) + } + }, + { + name: 'imageIcons', + rx: /^imagei/i, + description: `Render default icons as images (may solve font aligntment issues on Mac / ChromeOS)`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { + return this.config.changeSetting('imageIcons', args) + } + }, + { + name: `cloneButton`, + rx: /^clonebut/i, + description: `Clone a button`, + requiredServices: { buttons: 'ButtonManager' }, + action: function (args) { + const parts = args.trim().split(/\s+/g), + originalButtonName = parts[0], + cloneName = parts[1]; + return this.buttons.cloneButton(originalButtonName, cloneName); + } + }, + { + name: `renameButton`, + rx: /^renamebut/i, + description: `Rename a button (Custom buttons only)`, + requiredServices: { buttons: 'ButtonManager' }, + action: function (args) { + const parts = args.trim().split(/\s+/g), + originalButtonName = parts[0], + newName = parts[1]; + return this.buttons.renameButton(originalButtonName, newName); + } + }, + { + name: 'darkMode', + rx: /^dark/i, + description: `Palette change for the button bar`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { + return this.config.changeSetting('darkMode', args) + } + }, + { + name: 'baseSize', + rx: /^(base)?size/i, + description: 'Change the base size of the buttons', + requiredServices: { config: 'ConfigController' }, + action: function (arg) { + const validated = parseFloat(`${arg}`); + if (!validated || validated < 0.5 || validated > 3) { + new ChatDialog({ + title: 'Error', + content: `Please use a value between 0.5 and 3.0 for base size setting."`, + }, 'error'); + } + else + return this.config.changeSetting('baseSize', validated); + } + }, + { + name: 'multiattack', + rx: /^multiat/i, + description: `Attempt to link the button bar label to the source attack for easy repeat rolls`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { + return this.config.changeSetting('multiattack', args) + } + }, + { + name: 'allowNegatives', + rx: /^negative/i, + description: `Allow final results to be negative`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { + return this.config.changeSetting('allowNegatives', args) + } + }, + { + name: 'autosort', + rx: /^autosort/i, + description: `Auto sort buttons by unicode order`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { + return this.config.changeSetting('autosort', args) + } + }, + { + name: 'autohide', + rx: /^autohide/i, + description: `Autohide buttons with 0 reported damage`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { + return this.config.changeSetting('autohide', args) + } + }, + { + name: 'report', + rx: /^report/i, + description: `Change settings for reporting HP changes to chat`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { + const newVal = `${args}`.replace(/\W/g, '').toLowerCase(); + return this.config.changeSetting('report', newVal); + } + }, + { + name: 'repair', + rx: /^repair/, + description: `Attempt to repair a button from the backed up math string.`, + requiredServices: { buttons: 'ButtonManager' }, + action: function () { + for (const _button in this.buttons._buttons) { + const button = this.buttons._buttons[_button]; + if (!button.default) { + if (!button.mathString.trim() || button.mathString.trim() === '0') { + if (button.mathBackup) { + const valid = ButtonManager.validateMathString(button.mathBackup, button.name); + if (valid.success) { + button.mathString = button.mathBackup; + this.buttons.saveToStore(); + new ChatDialog({ content: `${button.name} was restored from backup.` }); + } + } + } + } + } + } }, { name: 'settings', rx: /^setting/i, description: `Open settings UI`, requiredServices: { config: 'ConfigController' }, - action: function() { this.config.getSettingsMenu() } + action: function () { + this.config.getSettingsMenu() + } + }, + { + name: 'beacon', + rx: /^beacon/i, + description: `Toggle Beacon support`, + requiredServices: { config: 'ConfigController' }, + action: function (args) { + return this.config.changeSetting('beacon', args); + } }, { name: 'help', rx: /^(\?$|h$|help)/i, description: `Display script help`, - action: function() { new ChatDialog({ title: `Script Help`, content: `Please visit the autoButtons thread for documentation.` }) } + action: function () { + new ChatDialog({ + title: `Script Help`, + content: `Please visit the autoButtons thread for documentation.` + }) + } }, { name: 'uninstall', rx: /^uninstall$/i, description: `Remove all script settings from API state`, - action: function(args) { + action: function (args) { if (/^undo/i.test(args)) { state[scriptName] = Helpers.copyObj(undoUninstall); - new ChatDialog({ title: 'Reverse! Reverse the reverse!', content: `State settings have been restored. Let's pretend that never happend, eh?` }, 'error') + new ChatDialog({ + title: 'Reverse! Reverse the reverse!', + content: `State settings have been restored. Let's pretend that never happend, eh?` + }, 'error') } else if (!undoUninstall) { undoUninstall = Helpers.copyObj(state[scriptName]); state[scriptName] = null; @@ -1278,7 +1562,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars /** * SCRIPT USER-CONFIG OPTIONS - * + * * Must have a valid type to be pulled into SettingsManager as a setting * 'object' type can be used for nesting settings keys * 'validate' is a validator for the input, not necessarily the key itself (e.g. an array might accept strings in the validator) @@ -1289,8 +1573,10 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars sheet: { type: 'string', range: ['dnd5e_r20', 'custom'], - rangeLabels: [ 'DnD5e by Roll20', 'Custom' ], - validate: function(v) { return this.range.includes(v) }, + rangeLabels: ['DnD5e by Roll20', 'Custom'], + validate: function (v) { + return this.range.includes(v) + }, default: 'dnd5e_r20', name: 'Character sheet', description: 'Character sheet in use', @@ -1298,7 +1584,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, enabledButtons: { type: 'array', - validate: (v) => typeof(v) === 'string', + validate: (v) => typeof (v) === 'string', default: [], }, gmOnly: { @@ -1310,8 +1596,10 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, hpBar: { type: 'integer', - range: [1,2,3], - validate: function(v) { return this.range.includes(v) }, + range: [1, 2, 3], + validate: function (v) { + return this.range.includes(v) + }, default: 1, name: `Token HP bar`, description: `Which token bar contains hit points`, @@ -1352,11 +1640,79 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars description: `CSS to bump the button container up in chat to save some space`, menuAction: `$--bump`, }, + imageIcons: { + type: 'boolean', + default: true, + name: `Image Icons`, + description: `Render default icons as images (may solve font aligntment issues on Mac / ChromeOS)`, + menuAction: `$--imageicon`, + }, + darkMode: { + type: 'boolean', + default: false, + name: `Dark Mode`, + description: `Palette change for the button bar`, + menuAction: `$--darkMode`, + }, + baseSize: { + type: 'float', + default: 1.0, + name: 'Base Size', + description: 'Base size of button styles', + menuAction: '$--size', + }, + multiattack: { + type: 'boolean', + default: false, + name: `Multiattack`, + description: `Attempt to link the button bar label to the source attack for easy repeat rolls. 5e only.`, + menuAction: `$--multiattack`, + }, + allowNegatives: { + type: 'boolean', + default: false, + name: `Allow negatives`, + description: `Allow final results to be negative. This can cause healing to cause damage, or damage to heal`, + menuAction: `$--negatives`, + }, + autosort: { + type: 'boolean', + default: false, + name: `Sort buttons`, + description: `Auto sort buttons by unicode order`, + menuAction: `$--autosort`, + }, + autohide: { + type: 'boolean', + default: true, + name: `Autohide buttons`, + description: `Autohide buttons with 0 reported damage`, + menuAction: `$--autohide`, + }, + beacon: { + type: 'boolean', + default: false, + name: 'Enable Beacon support', + description: 'Experimental beacon sheet support for DnD2024', + menuAction: `$--beacon`, + }, + report: { + type: 'string', + range: ['off', 'gm', 'control', 'all'], + rangeLabels: ['Off', 'GM', 'Character', 'Public'], + validate: function (v) { + return this.range.find(r => r.toLowerCase() === v.toLowerCase()) + }, + default: 'Off', + name: `Report changes`, + description: `Report hitpoint changes to chat`, + menuAction: `$--report`, + }, templates: { type: 'object', names: { type: 'array', - validate: (v) => typeof(v) === 'string', + validate: (v) => typeof (v) === 'string', default: [], name: `Roll templates & properties`, description: `Names of roll templates & properties watched by autoButtons`, @@ -1364,24 +1720,24 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, damageProperties: { type: 'object', - damageFields: { + damageFields: { type: 'array', - validate: (v) => typeof(v) === 'string', + validate: (v) => typeof (v) === 'string', default: [], }, critFields: { type: 'array', - validate: (v) => typeof(v) === 'string', + validate: (v) => typeof (v) === 'string', default: [] }, upcastDamage: { type: 'array', - validate: (v) => typeof(v) === 'string', + validate: (v) => typeof (v) === 'string', default: [] }, upcastCrit: { type: 'array', - validate: (v) => typeof(v) === 'string', + validate: (v) => typeof (v) === 'string', default: [] }, get value() { @@ -1396,9 +1752,9 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }, } - /** - * CLASS DEFINITIONS - */ + /** + * CLASS DEFINITIONS + */ /** * Service Locator - Find a registered service from any scope in the script with ServiceLocator.getLocator().getService('serviceName') @@ -1408,16 +1764,22 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars static _active = null; _services = {}; - constructor(services={}) { + constructor(services = {}) { if (ServiceLocator._active) return ServiceLocator._active; this.name = `ServiceLocator`; - for (let svc in services) { this._services[svc] = services[svc] } + for (let svc in services) { + this._services[svc] = services[svc] + } ServiceLocator._active = this; } - static getLocator() { return ServiceLocator._active } + static getLocator() { + return ServiceLocator._active + } - register({ serviceName, serviceReference }) { if (!this._services[serviceName]) this._services[serviceName] = serviceReference } + register({ serviceName, serviceReference }) { + if (!this._services[serviceName]) this._services[serviceName] = serviceReference + } // Find a service. If service has multiple instances, make sure to request by instance name, or only the first registered constructor name will be returned. // Search by Class Constructor Name is only suitable for unique class instances @@ -1450,19 +1812,19 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars if (currentObject[key].type === 'object' && Helpers.isObj(currentObject[key])) { targetPath[key] = currentObject[key]; processObject(currentObject[key], targetPath[key]); - } - else if (this._validateKey(currentObject[key], currentObject[key].default)) { + } else if (this._validateKey(currentObject[key], currentObject[key].default)) { targetPath[key] = currentObject[key]; targetPath[key].value = currentObject[key].default; - } - else debug.warn(`${this.constructor.name}: Bad key used in constructor: ${key} default value does not match specified type`, currentObject[key]); + } else debug.warn(`${this.constructor.name}: Bad key used in constructor: ${key} default value does not match specified type`, currentObject[key]); } } processObject(settingsData, this._settingsKeys); debug.log(this._settingsKeys); } - get settingsKeys() { return this._settingsKeys } + get settingsKeys() { + return this._settingsKeys + } // Validate a settings key and the stored value _validateKey(settingsKey, settingsValue) { @@ -1470,24 +1832,23 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars // debug.log(`Validating ${settingsValue}...`); const passValidation = ( settingsKey.type === 'array' && Array.isArray(settingsValue) - || ['float', 'integer', 'number'].includes(settingsKey.type) && typeof(settingsValue) === 'number' - || typeof(settingsValue) === settingsKey.type - ) ? true : false; + || ['float', 'integer', 'number'].includes(settingsKey.type) && typeof (settingsValue) === 'number' + || typeof (settingsValue) === settingsKey.type + ) ? true : false; // debug.log(passValidation); return passValidation; } - + // Validate an input to be stored in a settings key (e.g. may be a primitive value to be stored in an object type key) // Returns undefined for failed validation, otherwise returns value ready for storage _validateNewValue(settingsKey, newValue, options = { forceValidation: null }) { - if (!settingsKey || typeof(settingsKey) !== 'object' || !settingsKey.type || newValue === undefined) return debug.error(`${this.constructor.name}: Bad settings key`, settingsKey); + if (!settingsKey || typeof (settingsKey) !== 'object' || !settingsKey.type || newValue === undefined) return debug.error(`${this.constructor.name}: Bad settings key`, settingsKey); // Handle keys with validators (Objects and Arrays must have a validator since they can't be passed from Roll20) - if (typeof(options.forceValidation) === 'function') { + if (typeof (options.forceValidation) === 'function') { if (options.forceValidation(newValue)) return newValue; else return undefined; - } - else if (typeof(settingsKey.validate) === 'function') { + } else if (typeof (settingsKey.validate) === 'function') { if (settingsKey.validate(newValue)) return newValue; else return undefined; } @@ -1500,26 +1861,23 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars // Otherwise, type match else if (settingsKey.type === 'integer' && parseInt(newValue) === parseInt(newValue)) return parseInt(newValue); else if (settingsKey.type === 'float' && parseFloat(newValue) === parseFloat(newValue)) return parseFloat(newValue); - else if (settingsKey.type === typeof(newValue)) return newValue; + else if (settingsKey.type === typeof (newValue)) return newValue; else return undefined; } _writeSetting(settingsKey, newValue, options = { overwriteArray: false }) { const validationOptions = (options.overwriteArray) ? { forceValidation: (v) => Array.isArray(v) } : {}, - validData = this._validateNewValue(settingsKey, newValue, validationOptions); + validData = this._validateNewValue(settingsKey, newValue, validationOptions); if (validData === undefined) { debug.error(`${this.constructor.name}: Settings change not applied, value failed validation`, settingsKey, newValue); return { err: `${this.constructor.name}: Settings change not applied, value failed validation` } - } - else { + } else { if (settingsKey.type === 'array') { if (options.overwriteArray && Array.isArray(newValue)) { settingsKey.value = newValue; return { msg: `Saved new Array: [${newValue.join(', ')}]` } - } - else return Helpers.modifyArray(settingsKey.value, newValue); - } - else { + } else return Helpers.modifyArray(settingsKey.value, newValue); + } else { settingsKey.value = newValue; return { msg: `Saved value: ${newValue}` } } @@ -1527,7 +1885,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars } importSettingsValues(importedKeys = {}) { - if (typeof(importedKeys) !== 'object') return debug.error(`${this.constructor.name}: Bad settings import, must only supply object type`); + if (typeof (importedKeys) !== 'object') return debug.error(`${this.constructor.name}: Bad settings import, must only supply object type`); const processObject = (currentObject, targetPath) => { for (const key in currentObject) { if (targetPath[key]) { @@ -1537,13 +1895,10 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars } if (targetPath[key].type === 'object' && Helpers.isObj(currentObject[key])) { processObject(currentObject[key], targetPath[key]); - } - else if (this._validateKey(targetPath[key], currentObject[key])) { + } else if (this._validateKey(targetPath[key], currentObject[key])) { targetPath[key].value = currentObject[key]; - } - else debug.warn(`${this.constructor.name}: Key "${key}" failed validation`, currentObject[key]); - } - else debug.warn(`${this.constructor.name}: Key "${key}" does not exist.`, currentObject[key]); + } else debug.warn(`${this.constructor.name}: Key "${key}" failed validation`, currentObject[key]); + } else debug.warn(`${this.constructor.name}: Key "${key}" does not exist.`, currentObject[key]); } } processObject(importedKeys, this._settingsKeys); @@ -1557,8 +1912,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars if (currentObject[key].type === 'object' && Helpers.isObj(currentObject[key])) { targetPath[key] = {}; processObject(currentObject[key], targetPath[key]); - } - else if (currentObject[key].type) { + } else if (currentObject[key].type) { targetPath[key] = currentObject[key].value; } } @@ -1572,32 +1926,31 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars // Use options.force 'type' to force a type on the setting e.g. array or boolean // Combine with options.createPath: true to create a new setting of the correct type updateSetting(pathString, newValue, options = { createPath: false, overwriteArray: false, force: null }) { - if (typeof(pathString) !== 'string' || newValue === undefined) return { err: `Bad path string or no new value supplied.` }; + if (typeof (pathString) !== 'string' || newValue === undefined) return { err: `Bad path string or no new value supplied.` }; // Can probably remove this bit now that a .value key is used - const keyName = (pathString.match(/[^/]+$/)||[])[0], - path = /.+\/.+/.test(pathString) ? pathString.match(/(.+)\/[^/]+$/)[1] : '', - configPath = path ? Helpers.getObjectPath(path, this._settingsKeys, options.createPath) : this._settingsKeys, - targetKey = configPath[keyName]; + const keyName = (pathString.match(/[^/]+$/) || [])[0], + path = /.+\/.+/.test(pathString) ? pathString.match(/(.+)\/[^/]+$/)[1] : '', + configPath = path ? Helpers.getObjectPath(path, this._settingsKeys, options.createPath) : this._settingsKeys, + targetKey = configPath[keyName]; if (targetKey) { debug.log(`changeSetting - ${keyName}`, targetKey, options, newValue); if (targetKey.type === 'boolean') { newValue = (newValue == null || newValue === '') ? !targetKey.value : - rx.on.test(newValue) ? true : - rx.off.test(newValue) ? false : - newValue; + rx.on.test(newValue) ? true : + rx.off.test(newValue) ? false : + newValue; } const result = this._writeSetting(targetKey, newValue, options); if (result.msg) result.msg = `Changed setting: ${pathString}
${result.msg}`; else if (result.err) result.err = `Changed setting: ${pathString}
${result.err}`; return result; - } - else { + } else { return { err: `Settings key not found - *${pathString}*` } } } readSetting(pathString) { - if (typeof(pathString) !== 'string') return; + if (typeof (pathString) !== 'string') return; const targetKey = Helpers.getObjectPath(pathString, this._settingsKeys, false); return targetKey ? targetKey.value : undefined; } @@ -1609,28 +1962,27 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars for (const key in currentObject) { if (currentObject[key].type === 'object') { processObject(currentObject[key], targetOutput); - } - else if (currentObject[key].menuAction) { + } else if (currentObject[key].menuAction) { const name = currentObject[key].name || key, - hover = currentObject[key].description ? `title="${currentObject[key].description}"` : ``, - settingName = `
${name}
`, - currentSetting = `${currentObject[key].value}`; + hover = currentObject[key].description ? `title="${currentObject[key].description}"` : ``, + settingName = `
${name}
`, + currentSetting = `${currentObject[key].value}`; // Entry has a custom menu action if (/^[^$]/.test(currentObject[key].menuAction)) { - targetOutput.push([ settingName, currentObject[key].menuAction ]); + targetOutput.push([settingName, currentObject[key].menuAction]); } // Autofill prompt for boolean or defined range else { const queryRange = - currentObject[key].type === 'boolean' ? ['True', 'False'] - : currentObject[key].range ? - currentObject[key].rangeLabels ? currentObject[key].range.map((v,i) => `${currentObject[key].rangeLabels[i]||v},${v}`) - : Helpers.toArray(currentObject[key].range) - : '', - queryString = queryRange ? `?{Select new value|${queryRange.join('|')}}` : `?{Enter new value}`, - cliFlag = (`${currentObject[key].menuAction}`.match(/^\$(.+)/)||[])[1] || `--${key}`, - commandString = `!${scriptName} ${cliFlag} ${queryString}`; - targetOutput.push([ settingName, `${currentSetting}`]); + currentObject[key].type === 'boolean' ? ['True', 'False'] + : currentObject[key].range ? + currentObject[key].rangeLabels ? currentObject[key].range.map((v, i) => `${currentObject[key].rangeLabels[i] || v},${v}`) + : Helpers.toArray(currentObject[key].range) + : '', + queryString = queryRange ? `?{Select new value|${queryRange.join('|')}}` : `?{Enter new value}`, + cliFlag = (`${currentObject[key].menuAction}`.match(/^\$(.+)/) || [])[1] || `--${key}`, + commandString = `!${scriptName} ${cliFlag} ${queryString}`; + targetOutput.push([settingName, `${currentSetting}`]); } } } @@ -1647,7 +1999,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars _version = { M: 0, m: 0, p: 0 }; - constructor(scriptName, scriptData={}) { + constructor(scriptName, scriptData = {}) { Object.assign(this, { name: scriptName || `newScript`, _settings: new SettingsManager(scriptData.settings) || {}, @@ -1656,13 +2008,16 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars if (scriptData.version) this.version = scriptData.version; } - get version() { return `${this._version.M}.${this._version.m}.${this._version.p}` } + get version() { + return `${this._version.M}.${this._version.m}.${this._version.p}` + } + set version(newVersion) { - if (typeof(newVersion) === 'object' && newVersion.M && newVersion.m && newVersion.p) Object.assign(this._version, newVersion); + if (typeof (newVersion) === 'object' && newVersion.M && newVersion.m && newVersion.p) Object.assign(this._version, newVersion); else { const parts = `${newVersion}`.split(/\./g); if (!parts.length) debug.error(`Bad version number, not setting version.`) - else Object.keys(this._version).forEach((v,i) => this._version[v] = parseInt(parts[i]) || 0); + else Object.keys(this._version).forEach((v, i) => this._version[v] = parseInt(parts[i]) || 0); } } @@ -1674,7 +2029,10 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars } } - fromStore(path) { return Helpers.getObjectPath(path, this._store, false) } + fromStore(path) { + return Helpers.getObjectPath(path, this._store, false) + } + toStore(path, data) { // Supplying data=null will delete the target const ref = Helpers.getObjectPath(path, this._store, true); let msg; @@ -1695,6 +2053,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars Object.assign(this, { _store: state[scriptName].store, }); this._settings.importSettingsValues(state[scriptName].settings); } + saveToState() { Object.assign(state[scriptName], { settings: this._settings.exportSettingsValues(), @@ -1703,16 +2062,18 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars } changeSetting(pathString, newValue, options) { - options = typeof(options) === 'object' ? options : undefined; + options = typeof (options) === 'object' ? options : undefined; const result = this._settings.updateSetting(pathString, newValue, options); debug.log(`Setting change attempted`, result); if (result.msg) this.saveToState(); return result; } + getSetting(pathString) { const currentValue = this._settings.readSetting(pathString); return (typeof currentValue === 'object') ? Helpers.copyObj(currentValue) : currentValue; } + loadPreset() { const currentSheet = this.getSetting('sheet') || ''; if (Object.keys(preset).includes(currentSheet)) { @@ -1726,14 +2087,19 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars this._settings.updateSetting('enabledButtons', preset[currentSheet].defaultButtons || [], { overwriteArray: true }); this.saveToState(); return { res: 1, data: `${this.getSetting('sheet')}` } - } else return { res: 0, err: `Preset not found for sheet: "${currentSheet}"`} + } else return { res: 0, err: `Preset not found for sheet: "${currentSheet}"` } } + getSettingsMenu() { const menuOptions = this._settings.getMenuText(), - confirm = styles.components.confirmApiCommand(`reset to default sheet settings?`), - footerContent = `
Reset Sheet Settings`; + confirm = styles.components.confirmApiCommand(`reset to default sheet settings?`), + footerContent = `
Reset Sheet Settings`; menuOptions.unshift(['Key', 'Setting']); - new ChatDialog({ title: `${scriptName} settings
v${scriptVersion}`, content: menuOptions, footer: footerContent }, 'table'); + new ChatDialog({ + title: `${scriptName} settings
v${scriptVersion}`, + content: menuOptions, + footer: footerContent + }, 'table'); } } @@ -1748,105 +2114,136 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars _Config = {}; _buttons = {}; - constructor(data={}) { + constructor(data = {}) { Object.assign(this, { name: data.name || 'newButtonManager' }); // Requires access to a ConfigController this._locator = ServiceLocator.getLocator() || this._locator; this._Config = this._locator ? this._locator.getService('ConfigController') : null; if (!this._Config) return {}; - for (let button in data.defaultButtons) { this._buttons[button] = new Button(data.defaultButtons[button], styles) } + for (let button in data.defaultButtons) { + this._buttons[button] = new Button(data.defaultButtons[button], styles) + } + } + + get keys() { + return ButtonManager._buttonKeys } - get keys() { return ButtonManager._buttonKeys } - get editKeys() { return [ ...ButtonManager._buttonKeys, ...ButtonManager._editKeys ]} + get editKeys() { + return [...ButtonManager._buttonKeys, ...ButtonManager._editKeys] + } - getButtonNames(filters={ default: null, currentSheet: null, shown: null, hidden: null }) { + getBeaconButtonNames(beaconSheet) { + return Object.values(this._buttons).reduce((output, button) => { + return (!button.sheets?.length || button.sheets.includes(beaconSheet)) ? [button.name, ...output] : output + }, []); + } + + getButtonNames(filters = { default: null, currentSheet: null, shown: null, hidden: null }) { let buttons = Object.entries(this._buttons); const sheet = this._Config.getSetting('sheet'), - enabledButtons = this._Config.getSetting('enabledButtons'); + enabledButtons = this._Config.getSetting('enabledButtons'); if (typeof filters.default === 'boolean') buttons = buttons.filter(kv => kv[1].default === filters.default); if (typeof filters.currentSheet === 'boolean') buttons = buttons.filter(kv => (!kv[1].sheets.length || sheet === 'custom' || (kv[1].sheets.includes(sheet) === filters.currentSheet))); if (typeof filters.shown === 'boolean') buttons = buttons.filter(kv => (enabledButtons.includes(kv[0]) === filters.shown)); if (typeof filters.hidden === 'boolean') buttons = buttons.filter(kv => (enabledButtons.includes(kv[0]) === !filters.hidden)); - const output = buttons.map(kv=>kv[0]); + const output = buttons.map(kv => kv[0]); // debug.log(`button names: ${output.join(', ')}`); return output; } - static parseMathString(inputString) { - // debug.info(inputString); + static validateMathString(inputString, buttonName) { + debug.info(inputString); inputString = `${inputString}`; - let err = ''; + // Default buttons will send in a JS function, remove the declaration part inputString = inputString.replace(/^.*?=>\s*/, ''); - // Convert to JS - const formulaReplacer = { - '$1Math.floor': /([^.]|^)floor/ig, - '$1Math.ceil': /([^.]|^)ceil/ig, - '$1Math.round': /([^.]|^)round/ig, - '($1||0)': /((damage|crit)\.\w+)/ig, - } - // Very basic security, at least stops a `state = null` - const disallowed = [ /=/g, /\bstate\b/gi ]; - - disallowed.forEach(rx => { if (rx.test(inputString)) err += `Disallowed value in math formula: "${`${rx}`.replace(/(\\\w|\/)/g, '')}"` }); - let newFormula = inputString; - for (let f in formulaReplacer) newFormula = newFormula.replace(formulaReplacer[f], f); + const mathOpsString = MathOpsTransformer.transformMathString(newFormula); + debug.info(mathOpsString); // Create a test object - let damageKeys = inputString.match(/(damage|crit)\.(\w+)/g), - testKeys = {}; - damageKeys = damageKeys ? damageKeys.map(k => k.replace(/^[^.]*\./, '')) : []; - damageKeys.forEach(k => testKeys[k] = 5); - - let validate = false, - newFunc; + const damageKeyMatches = inputString.match(/damage\.(\w+)/g) || [], + critKeyMatches = inputString.match(/crit\.(\w+)/g) || [], + damageKeys = damageKeyMatches.reduce((output, key) => ({ + ...output, + [key.replace(/^[^.]*\./, '')]: 5 + }), {}), + critKeys = critKeyMatches.reduce((output, key) => ({ + ...output, + [key.replace(/^[^.]*\./, '')]: 5 + }), {}); + + const { config } = ServiceLocator.getLocator().getService('config'); + const damageProperties = [ + ...Object.values(config.getSetting('templates/damageProperties')).reduce((output, category) => [...output, ...category], []), + 'total', + ]; + const invalidProperties = [...Object.keys(damageKeys), ...Object.keys(critKeys)].filter(key => !(damageProperties.includes(key))); + + const mathOpsKeys = MathOpsTransformer.transformMathOpsPayload(damageKeys, critKeys); + debug.info(mathOpsKeys); + + let error; try { - newFunc = new Function(`damage`, `crit`, `return (${newFormula})`) - validate = isNaN(newFunc(testKeys, testKeys)) ? false : true; - } catch(e) { err += (`${scriptName}: formula failed validation`) } - - if (validate && !err) { - return newFunc; - } else { - return new Error(err); + const testResult = MathOps.MathProcessor({ code: mathOpsString, known: mathOpsKeys }); + debug.info(testResult); + if (testResult.message) { + error = testResult.message; + } else if (isNaN(testResult)) { + error = `The supplied math did not return a number: ${inputString}`; + } + } catch (e) { + error = `Math failed validation - ${e}`; } + if (invalidProperties.length) new ChatDialog({ + title: `Button Warning: "${buttonName}"`, + content: `The following damage properties in the button are not set up in this game: ${invalidProperties.join(', ')}` + }, 'error'); + + return error + ? { success: false, err: error } + : { success: true, err: null } } - addButton(buttonData={}) { - debug.info(buttonData); + + addButton(buttonData = {}) { const newButton = buttonData.default === false ? new CustomButton(buttonData) : new Button(buttonData); - if (newButton.err) return { success: 0, err: newButton.err } + if (newButton.err || !newButton.math) return { + success: 0, + err: newButton.err || `Button ${buttonData.name} could not be created.` + } if (this._buttons[newButton.name]) return { success: 0, err: `Button "${newButton.name}" already exists` }; this._buttons[newButton.name] = newButton; this.saveToStore(); return { success: 1, msg: `New Button "${newButton.name}" successfully created` } } - editButton(buttonData={}) { + + editButton(buttonData = {}) { const modded = []; - if (!this._buttons[buttonData.name]) return { success: 0, err: `Button "${buttonData.name}" does not exist.` } + if (!this._buttons[buttonData.name]) return { + success: 0, + err: `Button "${buttonData.name}" does not exist.` + } if (this._buttons[buttonData.name].default) return { success: 0, err: `Cannot edit default buttons.` } this.editKeys.forEach(k => { debug.log(k, buttonData[k]); if (buttonData[k] != null) { if (k === 'default') return; // Don't allow reassignment of 'default' property else if (k === 'math') { - const newMath = ButtonManager.parseMathString(buttonData[k]); - if (newMath.err) return newMath; + const { success, err } = ButtonManager.validateMathString(buttonData[k], buttonData.name); + if (!success) return { err }; else { this._buttons[buttonData.name].mathString = buttonData[k]; - this._buttons[buttonData.name].math = newMath; modded.push(k); } - } - else if (/^style/.test(k)) { + } else if (/^style/.test(k)) { this._buttons[buttonData.name][k] = styles[buttonData[k]] || buttonData[k] || ''; modded.push(k); } - // else if (k === 'query') { - // this._buttons[buttonData.name].query = Button.splitAndEscapeQuery(buttonData.query); - // modded.push(k); + // else if (k === 'query') { + // this._buttons[buttonData.name].query = Button.splitAndEscapeQuery(buttonData.query); + // modded.push(k); // } else { this._buttons[buttonData.name][k] = buttonData[k]; @@ -1855,8 +2252,12 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars } }); if (modded.length) this.saveToStore(); - return modded.length ? { success: 1, msg: `Modified ${buttonData.name} fields: ${modded.join(', ')}` } : { success: 0, err: `No fields supplied.` } + return modded.length ? { + success: 1, + msg: `Modified ${buttonData.name} fields: ${modded.join(', ')}` + } : { success: 0, err: `No fields supplied.` } } + removeButton(buttonName) { if (!this._buttons[buttonName]) return { success: 0, err: `Button "${buttonName}" does not exist.` } if (this._buttons[buttonName].default) return { success: 0, err: `Cannot delete default buttons.` } @@ -1864,36 +2265,53 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars this._Config.toStore(`customButtons/${buttonName}`, null); return { success: 1, msg: `Removed "${buttonName}".` } } + cloneButton(originalButtonName, newButtonName) { if (this._buttons[originalButtonName] && newButtonName) { const cloneName = /\s/.test(newButtonName) ? Helpers.camelise(newButtonName) : newButtonName, - cloneData = { ...this._buttons[originalButtonName], name: cloneName, default: false }, - copyResult = this.addButton(cloneData); - return copyResult.success ? { success: 1, msg: `Cloned button ${originalButtonName} => ${cloneName}` } : copyResult; - } - else return { err: `Could not find button "${originalButtonName}", or bad clone button name "${newButtonName}"` } + cloneData = { ...this._buttons[originalButtonName], name: cloneName, default: false }, + copyResult = this.addButton(cloneData); + return copyResult.success ? { + success: 1, + msg: `Cloned button ${originalButtonName} => ${cloneName}` + } : copyResult; + } else return { err: `Could not find button "${originalButtonName}", or bad clone button name "${newButtonName}"` } } + renameButton(originalButtonName, newButtonName) { - if (!this._buttons[originalButtonName]) return { success: 0, err: `Button "${originalButtonName}" could not be found` }; - if (this._buttons[originalButtonName].default) return { success: 0, err: `Cannot rename a default button.` }; + if (!this._buttons[originalButtonName]) return { + success: 0, + err: `Button "${originalButtonName}" could not be found` + }; + if (this._buttons[originalButtonName].default) return { + success: 0, + err: `Cannot rename a default button.` + }; const cloneName = /\s/.test(newButtonName) ? Helpers.camelise(newButtonName) : newButtonName, - cloneResult = this.cloneButton(originalButtonName, cloneName); + cloneResult = this.cloneButton(originalButtonName, cloneName); if (cloneResult.success) { this.removeButton(originalButtonName); return { success: 1, msg: `Renamed button ${originalButtonName} => ${cloneName}` }; - } - else return cloneResult; + } else return cloneResult; } + showButton(buttonName) { - if (this._buttons[buttonName] && !this._Config.getSetting('enabledButtons').includes(buttonName)) { return this._Config.changeSetting('enabledButtons', buttonName) } + if (this._buttons[buttonName] && !this._Config.getSetting('enabledButtons').includes(buttonName)) { + return this._Config.changeSetting('enabledButtons', buttonName) + } } + hideButton(buttonName) { - if (this._buttons[buttonName] && this._Config.getSetting('enabledButtons').includes(buttonName)) { return this._Config.changeSetting('enabledButtons', buttonName) } + if (this._buttons[buttonName] && this._Config.getSetting('enabledButtons').includes(buttonName)) { + return this._Config.changeSetting('enabledButtons', buttonName) + } } + saveToStore() { - const customButtons = this.getButtonNames({default: false}); + const customButtons = this.getButtonNames({ default: false }); customButtons.forEach(button => this._Config.toStore(`customButtons/${button}`, Helpers.copyObj(this._buttons[button]))); } + _getReportTemplate(barNumber) { const template = `'*({name}) {bar${barNumber}_value;before}HP -> {bar${barNumber}_value}HP*'`; return template; @@ -1903,72 +2321,103 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars // !token-mod --set bar1_value|-[[floor(query*17)]]! } + _getImageIcon(buttonName, cacheBust, version = '2a') { if (!cacheBusted) { cacheBust = true; } const url = `https://raw.githubusercontent.com/ooshhub/autoButtons/main/assets/imageIcons/${buttonName}.png?${version}`.replace(/%/g, 'P'); return cacheBust ? - `${url}${Math.floor(Math.random()*1000000000)}` - : url; + `${url}${Math.floor(Math.random() * 1000000000)}` + : url; // May need to switch to this if images move // return styles.imageIcons[buttonName]; } + createApiButton(buttonName, damage, crit) { + // debug.info(this._buttons[buttonName]); const btn = this._buttons[buttonName], - autoHide = this._Config.getSetting(`autohide`), - bar = this._Config.getSetting('hpBar'), - overheal = this._Config.getSetting('overheal'), - overkill = this._Config.getSetting('overkill'), - sendReport = (this._Config.getSetting('report')||``).toLowerCase(), - reportString = [ 'all', 'gm', 'control' ].includes(sendReport) - ? ` --report ${sendReport}|${this._getReportTemplate(bar)}` - : ``, - darkMode = this._Config.getSetting('darkMode'); + autoHide = this._Config.getSetting(`autohide`), + bar = this._Config.getSetting('hpBar'), + overheal = this._Config.getSetting('overheal'), + overkill = this._Config.getSetting('overkill'), + sendReport = (this._Config.getSetting('report') || ``).toLowerCase(), + reportString = ['all', 'gm', 'control'].includes(sendReport) + ? ` --report ${sendReport}|${this._getReportTemplate(bar)}` + : ``, + darkMode = this._Config.getSetting('darkMode'); const zeroBound = this._Config.getSetting('allowNegatives') ? false : true, - boundingPre = zeroBound ? `{0, ` : ``, - boundingPost = zeroBound ? `}kh1` : ``; + boundingPre = zeroBound ? `{0, ` : ``, + boundingPost = zeroBound ? `}kh1` : ``; const queryString = Button.splitAndEscapeQuery(btn.query) || ''; - if (!btn || typeof(btn.math) !== 'function') { + if (!btn || typeof (btn.math) !== 'function') { debug.error(`${scriptName}: error creating API button ${buttonName}`); return ``; } - const modifier = btn.math(damage, crit), - tooltip = btn.tooltip.replace(/%/, `${modifier} HP`), - setWithQuery = queryString ? `[[${boundingPre}${queryString.replace(/%%MODIFIER%%/g, Math.abs(modifier))}${boundingPost}]]` : `${Math.abs(modifier)}`, - tokenModCmd = (modifier > 0) ? (!overheal) ? `+${setWithQuery}!` : `+${setWithQuery}` : (modifier < 0 && !overkill) ? `-${setWithQuery}!` : `-${setWithQuery}`, - selectOrTarget = (this._Config.getSetting('targetTokens') === true) ? `--ids @{target|token_id} ` : ``, - buttonHref = `!token-mod ${selectOrTarget}--set bar${bar}_value|${tokenModCmd}${reportString}`, - useImageIcon = this._Config.getSetting('imageIcons') && btn.default, - buttonContent = useImageIcon ? `` - : `${btn.content}`, - buttonContent2 = useImageIcon ? `` - : btn.content2 ? `${btn.content2}` : ``, - buttonContent3 = useImageIcon ? `` - : btn.content3 ? `${btn.content3}` : ``; + const modifier = this.resolveButtonMath(btn, damage, crit), + tooltip = btn.tooltip.replace(/%/, `${modifier} HP`), + setWithQuery = queryString ? `[[${boundingPre}${queryString.replace(/%%MODIFIER%%/g, Math.abs(modifier))}${boundingPost}]]` : `${Math.abs(modifier)}`, + tokenModCmd = (modifier > 0) ? (!overheal) ? `+${setWithQuery}!` : `+${setWithQuery}` : (modifier < 0 && !overkill) ? `-${setWithQuery}!` : `-${setWithQuery}`, + selectOrTarget = (this._Config.getSetting('targetTokens') === true) ? `--ids @{target|token_id} ` : ``, + buttonHref = `!token-mod ${selectOrTarget}--set bar${bar}_value|${tokenModCmd}${reportString}`, + useImageIcon = this._Config.getSetting('imageIcons') && btn.default, + buttonContent = useImageIcon ? `` + : `${btn.content}`, + buttonContent2 = useImageIcon ? `` + : btn.content2 ? `${btn.content2}` : ``, + buttonContent3 = useImageIcon ? `` + : btn.content3 ? `${btn.content3}` : ``; return (autoHide && modifier == 0) ? - `` - : `
${buttonContent}${buttonContent2}${buttonContent3}
`; + `` + : `
${buttonContent}${buttonContent2}${buttonContent3}
`; } + verifyButtons() { const currentSheet = this._Config.getSetting('sheet'), - currentButtons = this._Config.getSetting('enabledButtons'), - validButtons = currentButtons.filter(button => { - if (currentSheet === 'custom' || this._buttons[button] && this._buttons[button].sheets.includes(currentSheet)) return 1; - }); + currentButtons = this._Config.getSetting('enabledButtons'), + validButtons = currentButtons.filter(button => { + if (currentSheet === 'custom' || this._buttons[button] && this._buttons[button].sheets.includes(currentSheet)) return 1; + }); if (validButtons.length !== currentButtons.length) { const { success, msg, err } = this._Config.changeSetting('enabledButtons', validButtons); if (success && msg) new ChatDialog({ content: msg, title: 'Buttons Changed' }); else if (err) new ChatDialog({ content: err }, 'error'); } } + + resolveButtonMath(button, damage, crit) { + const buttonType = button.constructor.name; + if (buttonType === 'CustomButton') { + debug.info(button.mathString, MathOpsTransformer.transformMathOpsPayload(damage, crit), MathOpsTransformer.transformMathString(button.mathString)); + let mathOpsString = MathOpsTransformer.transformMathString(button.mathString); + const mathOpsDamageKeys = MathOpsTransformer.transformMathOpsPayload(damage, crit); + // MathOps zeroed key patch + mathOpsString = mathOpsZeroPatch ? this.resolveZeroedKeys(mathOpsString, mathOpsDamageKeys) : mathOpsString; + debug.warn(mathOpsString); + let result = MathOps.MathProcessor({ code: mathOpsString, known: mathOpsDamageKeys }); + debug.info(result); + return isNaN(result) ? 0 : result; + } else if (buttonType === 'Button') { + return button.math(damage, crit); + } + } + + resolveZeroedKeys(mathOpsString, mathOpsDamageKeys) { + for (const key in mathOpsDamageKeys) { + if (mathOpsDamageKeys[key] === 0) { + const rxReplacer = new RegExp(key, 'g'); + mathOpsString = mathOpsString.replace(rxReplacer, '0'); + } + } + return mathOpsString; + } } /** * Button - Basic schema of a Button object */ class Button { - constructor(buttonData={}, styleData=styles) { + constructor(buttonData = {}, styleData = styles) { Object.assign(this, { name: buttonData.name || 'newButton', sheets: Array.isArray(buttonData.sheets) ? buttonData.sheets : [], @@ -1980,35 +2429,36 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars content2: buttonData.content2 || '', content3: buttonData.content3 || '', math: buttonData.math || null, - mathString: buttonData.mathString || buttonData.math.toString(), + mathString: buttonData.mathString, query: buttonData.query || ``, default: buttonData.default === false ? false : true, + mathBackup: buttonData.mathBackup || '', }); debug.log(this); - if (typeof(this.math) !== 'function') return { err: `Button "${this.name}" math function failed validation` }; + if (typeof (this.math) !== 'function') return { err: `Button "${this.name}" math function failed validation` }; } static splitAndEscapeQuery(queryString) { - if (!queryString || typeof(queryString) !== 'string') return ``; + if (!queryString || typeof (queryString) !== 'string') return ``; const replacers = { '*': `*`, '+': `+`, } const replacerFunction = (m) => replacers[m], - rxQuerySplit = /^[+*/-][+-0]?\|/, - rxReplacers = new RegExp(`[${Object.keys(replacers).reduce((out,v) => out += `\\${v}`, ``)}]`, 'g'); - let operator = (queryString.match(rxQuerySplit)||[])[0] || ``, - query = queryString.replace(rxQuerySplit, ''), - roundingPre = ``, - roundingPost = ``; + rxQuerySplit = /^[+*/-][+-0]?\|/, + rxReplacers = new RegExp(`[${Object.keys(replacers).reduce((out,v) => out += `\\${v}`, ``)}]`, 'g'); + let operator = (queryString.match(rxQuerySplit) || [])[0] || ``, + query = queryString.replace(rxQuerySplit, ''), + roundingPre = ``, + roundingPost = ``; // Deal with rounding for * and / if (/^[*/]/.test(operator)) { roundingPre = operator[1] === '+' ? - `ceil(` - : `floor(` + `ceil(` + : `floor(` roundingPost = `)`; } - operator = (operator[0]||``).replace(rxReplacers, replacerFunction); + operator = (operator[0] || ``).replace(rxReplacers, replacerFunction); return query ? `${roundingPre}%%MODIFIER%%${operator}?{${query}}${roundingPost}` : ``; } } @@ -2017,16 +2467,24 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars * Custom Button - user-made buttons pass through here for validation before being passed to superclass */ class CustomButton extends Button { - constructor(buttonData={}) { - debug.log(buttonData); - if (!buttonData.math && !buttonData.mathString) return { err: `Button must contain a function in 'math' key.` }; + constructor(buttonData = {}) { + debug.info(buttonData); + if (!buttonData.mathString) return { err: `Button must contain a math string.` }; + const { success, err } = ButtonManager.validateMathString(buttonData.mathString, buttonData.name); + if (!success) { + return { err }; + } Object.assign(buttonData, { name: buttonData.name || 'newCustomButton', - mathString: buttonData.mathString || buttonData.math.toString(), - math: ButtonManager.parseMathString(buttonData.mathString || buttonData.math.toString()), + mathString: buttonData.mathString, + math: (code, known) => MathOps.MathProcessor({ + code: MathOpsTransformer.transformMathString(code), + known + }), style: buttonData.style || 'full', query: buttonData.query || ``, default: false, + mathBackup: buttonData.mathBackup || buttonData.mathString, }); super(buttonData); } @@ -2041,7 +2499,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars _services = {}; _options = {}; - constructor(cliData={}) { + constructor(cliData = {}) { this.name = cliData.name || `Cli`; this._locator = ServiceLocator.getLocator(); if (!this._locator) debug.warn(`${this.constructor.name} could not find the service locator. Any commands relying on services will be disabled.`); @@ -2063,9 +2521,9 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars if (data.requiredServices) { for (let service in data.requiredServices) { const svc = - service === 'ConfigController' ? this._services.config - : service === 'ButtonManager' ? this._services.buttons - : this._locator.getService(data.requiredServices[service]); + service === 'ConfigController' ? this._services.config + : service === 'ButtonManager' ? this._services.buttons + : this._locator.getService(data.requiredServices[service]); if (svc) suppliedServices[service] = svc; else return debug.warn(`${this.name}: Warning - Service "${service}" could not be found for option ${data.name}. CLI option not registered.`); } @@ -2079,8 +2537,8 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars assess(commandArray, reportToChat = true) { let changed = [], errs = []; commandArray.forEach(command => { - const cmd = (command.match(/^([^\s]+)/)||[])[1], - args = (command.match(/\s+(.+)/)||['',''])[1]; + const cmd = (command.match(/^([^\s]+)/) || [])[1], + args = (command.match(/\s+(.+)/) || ['', ''])[1]; for (let option in this._options) { if (this._options[option].rx.test(cmd)) { const { msg, err } = (this._options[option].action(args) || {}); @@ -2098,10 +2556,12 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars }; new ChatDialog(chatData); } - if (errs.length) new ChatDialog( { title: 'Errors', content: errs }, 'error'); + if (errs.length) new ChatDialog({ title: 'Errors', content: errs }, 'error'); } - trigger(option, ...args) { if (this._options[option]) this._options[option].action(...args) } + trigger(option, ...args) { + if (this._options[option]) this._options[option].action(...args) + } } /** @@ -2109,7 +2569,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars */ class CommandLineOption { - constructor(optionData={}) { + constructor(optionData = {}) { for (let service in optionData.services) { this[service] = optionData.services[service]; } @@ -2120,7 +2580,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars action: optionData.action }); } - + } /** @@ -2130,38 +2590,39 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars class ChatDialog { static _templates = { - none: ({content}) => `${content}`, + none: ({ content }) => `${content}`, default: ({ title, content }) => { const msgArray = content ? Helpers.toArray(content) : [], - body = msgArray.map(row => `
${row}
`).join('') + body = msgArray.map(row => `
${row}
`).join('') return `
-
${title||scriptName}
+
${title || scriptName}
${body}
`; }, table: ({ title, content, footer, borders }) => { - // debug.log(content); const rowBorders = borders && borders.row ? styles.table.rowBorders : ``; const msgArray = content ? Helpers.toArray(content) : [], - columns = msgArray[0].length || 1, - tableRows = msgArray.map((row,i) => { - const tc = i === 0 ? 'th' : 'td', - tcStyle = i === 0 ? styles.table.headerCell : `${styles.table.cell}${rowBorders}`, - trStyle = i === 0 ? styles.table.headerRow : styles.table.row; - let cells = ``; - for (let i=0; i < columns; i++) { cells += `<${tc} style="${tcStyle}">${row[i]}` } - return ` + columns = msgArray[0].length || 1, + tableRows = msgArray.map((row, i) => { + const tc = i === 0 ? 'th' : 'td', + tcStyle = i === 0 ? styles.table.headerCell : `${styles.table.cell}${rowBorders}`, + trStyle = i === 0 ? styles.table.headerRow : styles.table.row; + let cells = ``; + for (let i = 0; i < columns; i++) { + cells += `<${tc} style="${tcStyle}">${row[i]}` + } + return ` ${cells} `; - }).join(''), - footerContent = footer ? `` : ``; + }).join(''), + footerContent = footer ? `` : ``; return `
-
${title||scriptName}
+
${title || scriptName}
${tableRows} @@ -2193,7 +2654,7 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars `; } } - + constructor(message, template = 'default', autoSend = true) { this.msg = ChatDialog._templates[template] ? ChatDialog._templates[template](message) : null; if (this.msg) { @@ -2209,5 +2670,11 @@ const autoButtons = (() => { // eslint-disable-line no-unused-vars on('ready', startScript); })(); -{ try { throw new Error(''); } catch (e) { API_Meta.autoButtons.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.autoButtons.offset); } } +{ + try { + throw new Error(''); + } catch (e) { + API_Meta.autoButtons.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.autoButtons.offset); + } +} /* */ \ No newline at end of file diff --git a/autoButtons/readme.md b/autoButtons/readme.md index 5d4f2a38a6..bff2f39f56 100644 --- a/autoButtons/readme.md +++ b/autoButtons/readme.md @@ -5,6 +5,13 @@ For documentation and support please visit Roll20: ### [autoButtons thread on Roll20](https://app.roll20.net/forum/permalink/10766392/) +## v0.9.0 +- Added baseSize scale setting +- Added DnD2024 Beacon support + +## v0.8.9: +- Added MathOps dependency due to sandbox security changes breaking original methods + ## v0.8.x: ### Changes: - Added API meta data diff --git a/autoButtons/script.json b/autoButtons/script.json index b5b605de6d..7b28b7b7c8 100644 --- a/autoButtons/script.json +++ b/autoButtons/script.json @@ -1,15 +1,15 @@ { "name": "autoButtons", "script": "autoButtons.js", - "version": "0.8.0", + "version": "0.9.0", "description": "*autoButtons* automatically generates damage buttons after a damage roll to streamline the application of damage to a single token. Pre-configured for DnD5e by Roll20, but is highly configurable for use with other sheets.\r\rSee the main [autoButtons thread](https://app.roll20.net/forum/permalink/10766392/) for documentation.", "authors": "oosh", "roll20userid": "4775411", "useroptions": [], - "dependencies": ["TokenMod"], + "dependencies": ["TokenMod", "MathOps"], "modifies": { "state.autoButtons": "read,write" }, "conflicts": [], - "previousversions": ["0.5.4", "0.6.2", "0.7.2"] + "previousversions": ["0.8.9"] }