From 0034cf42abec5b3bf7101ebc798124d0147eee4f Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Fri, 27 Dec 2024 15:34:38 -0600 Subject: [PATCH 1/7] Updated GroupInitiative to v0.9.39 --- GroupInitiative/0.9.39/GroupInitiative.js | 2246 +++++++++++++++++++++ GroupInitiative/GroupInitiative.js | 104 +- GroupInitiative/script.json | 5 +- 3 files changed, 2331 insertions(+), 24 deletions(-) create mode 100644 GroupInitiative/0.9.39/GroupInitiative.js diff --git a/GroupInitiative/0.9.39/GroupInitiative.js b/GroupInitiative/0.9.39/GroupInitiative.js new file mode 100644 index 0000000000..4fb6af39aa --- /dev/null +++ b/GroupInitiative/0.9.39/GroupInitiative.js @@ -0,0 +1,2246 @@ +// Github: https://github.com/shdwjk/Roll20API/blob/master/GroupInitiative/GroupInitiative.js +// By: The Aaron, Arcane Scriptomancer +// Contact: https://app.roll20.net/users/104025/the-aaron +var API_Meta = API_Meta||{}; //eslint-disable-line no-var +API_Meta.GroupInitiative={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; +{try{throw new Error('');}catch(e){API_Meta.GroupInitiative.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-6);}} + +const GroupInitiative = (() => { // eslint-disable-line no-unused-vars + + const scriptName = "GroupInitiative"; + const version = '0.9.39'; + API_Meta.GroupInitiative.version = version; + const lastUpdate = 1735335074; + const schemaVersion = 1.3; + + const isString = (s)=>'string'===typeof s || s instanceof String; + const isFunction = (f)=>'function'===typeof f; + const getComputedProxy = ("undefined" !== typeof getComputed) + ? async (...a) => await getComputed(...a) + : async ()=>{} + ; + + + let observers = { + turnOrderChange: [] + }; + let validCharacterSheets = []; + + const sorters = { + 'None': { + desc: `No sorting is applied.`, + func: (to)=>to + }, + 'Ascending': { + desc: `Sorts the Turn Order from highest to lowest`, + func: (to,preserveFirst) => { + let first = to[0]; + const sorter_asc = (a, b) => a.pr - b.pr; + let newTo = to.sort(sorter_asc); + if(preserveFirst){ + let idx = newTo.findIndex(e=>e===first); + newTo = [...newTo.slice(idx),...newTo.slice(0,idx)]; + } + return newTo; + } + }, + 'Descending': { + desc: `Sorts the Turn Order from lowest to highest.`, + func: (to,preserveFirst) => { + let first = to[0]; + const sorter_desc = (a, b) => b.pr - a.pr; + let newTo = to.sort(sorter_desc); + if(preserveFirst){ + let idx = newTo.findIndex(e=>e===first); + newTo = [...newTo.slice(idx),...newTo.slice(0,idx)]; + } + return newTo; + } + } + }; + + const ch = (c) => { + const entities = { + '<' : 'lt', + '>' : 'gt', + '&' : 'amp', + "'" : '#39', + '@' : '#64', + '{' : '#123', + '|' : '#124', + '}' : '#125', + '[' : '#91', + ']' : '#93', + '"' : 'quot', + '*' : 'ast', + '/' : 'sol', + ' ' : 'nbsp' + }; + + if( entities.hasOwnProperty(c) ){ + return `&${entities[c]};`; + } + return ''; + }; + + const standardConfigs = { + 'dnd2024byroll20': { + title: `D${ch('&')}D 2024 by Roll20`, + desc: `This is the Roll20 provided 2024 edition character sheet build on Beacon Technology. Note: This sheet requires using the Experimental Mod (API) Server.`, + func: ()=>{ + state[scriptName].bonusStatGroups = [ + [ + { + adjustments:[ + "filter-sheet" + ], + attribute: 'dnd2024byroll20' + }, + { + adjustments: [ + "computed" + ], + attribute: "initiative_bonus" + }, + { + adjustments: [ + "computed" + ], + attribute: "init_tiebreaker" + } + ], + ...(state[scriptName].bonusStatGroups||[]) + ]; + state[scriptName].dieSize = 20; + state[scriptName].diceCount = 1; + } + }, + 'dnd5eogl': { + title: `D${ch('&')}D 5E by Roll20`, + desc: `This is the standard Roll20 provided 5th edition character sheet. It is the one used by default in most 5th edition Modules and all Official Dungeons and Dragons Modules and Addons.`, + func: ()=>{ + state[scriptName].bonusStatGroups = [ + [ + { + adjustments:[ + "filter-sheet" + ], + attribute: 'ogl5e' + }, + { + attribute: "initiative_bonus" + }, + { + adjustments: [ + "tie-breaker" + ], + attribute: "initiative_bonus" + } + ], + ...(state[scriptName].bonusStatGroups||[]) + ]; + state[scriptName].dieSize = 20; + state[scriptName].diceCount = 1; + } + }, + 'dnd5eshaped2': { + title: `D${ch('&')}D 5e Shaped Sheet`, + desc: `This is the high-powered and very customizable Dungeons and Dragons 5e Shaped Sheet. You'll know you're using it because you had to manually install it (probably) and it has a nice heart shaped hit-point box. This is not the default sheet for DnD Modules on Roll20, if you aren't sure if you're using this sheet, you aren't.`, + func: ()=>{ + state[scriptName].bonusStatGroups = [ + [ + { + "attribute": "initiative_formula" + } + ] + ]; + state[scriptName].dieSize = 20; + state[scriptName].diceCount = 0; + } + }, + 'stargaterpgofficial': { + title: `Stargate RPG by Wyvern Gaming`, + desc: `This will configure GroupInitiative to work with the Official Stargate RPG sheet. It adds Initiative & Moxie options for rolling, with Initiative enabled by default. You can swap between using Initiative and Moxie by issuing the command "!group-init --promote 2" in the chat, with subsequent calls again reversing the selection.`, + func: ()=>{ + state[scriptName].bonusStatGroups = [ + [ + { + "attribute": "init" + } + ], + [ + { + "attribute": "moxie" + } + ] + ]; + state[scriptName].dieSize = 20; + state[scriptName].diceCount = 1; + } + } + }; + + const HE = (() => { + const esRE = (s) => s.replace(/(\\|\/|\[|\]|\(|\)|\{|\}|\?|\+|\*|\||\.|\^|\$)/g,'\\$1'); + const e = (s) => `&${s};`; + const entities = { + '<' : e('lt'), + '>' : e('gt'), + "'" : e('#39'), + '@' : e('#64'), + '{' : e('#123'), + '|' : e('#124'), + '}' : e('#125'), + '[' : e('#91'), + ']' : e('#93'), + '"' : e('quot') + }; + const re = new RegExp(`(${Object.keys(entities).map(esRE).join('|')})`,'g'); + return (s) => s.replace(re, (c) => (entities[c] || c) ); + })(); + + + const observeTurnOrderChange = function(handler){ + if(handler && _.isFunction(handler)){ + observers.turnOrderChange.push(handler); + } + }; + + const notifyObservers = function(event,obj,prev){ + _.each(observers[event],function(handler){ + handler(obj,prev); + }); + }; + + const formatDieRoll = function(rollData) { + let critFail = _.reduce(rollData.rolls,function(m,r){ + return m || _.contains(r.rolls,1); + },false), + critSuccess = _.reduce(rollData.rolls,function(m,r){ + return m || _.contains(r.rolls,r.sides); + },false), + highlight = ( (critFail && critSuccess) ? + '#4A57ED' : + ( critFail ? + '#B31515' : + ( critSuccess ? + '#3FB315' : + '#FEF68E' + ) + ) + ), + dicePart = _.reduce(rollData.rolls, function(m,r){ + _.reduce(r.rolls,function(dm,dr){ + let dielight=( 1 === dr ? + '#ff0000' : + ( r.sides === dr ? + '#00ff00' : + 'white' + ) + ); + dm.push(''+dr+''); + return dm; + },m); + return m; + },[]).join(' + '); + + return ''+ + dicePart+' [init] '+ + (rollData.bonus>=0 ? '+' :'-')+' '+Math.abs(rollData.bonus)+' [bonus]'+ + '' + ))+'">'+ + rollData.total+ + ''; + }; + + const buildAnnounceGroups = function(l) { + let groupColors = { + npc: '#eef', + character: '#efe', + gmlayer: '#aaa' + }; + return _.reduce(l,function(m,s){ + let type= ('gmlayer' === s.token.get('layer') ? + 'gmlayer' : ( + (s.character && _.filter(s.character.get('controlledby').split(/,/),function(c){ + return 'all' === c || ('' !== c && !playerIsGM(c) ); + }).length>0) || false ? + 'character' : + 'npc' + )); + if('graphic'!==s.token.get('type') || 'token' !==s.token.get('subtype')) { + return m; + } + m[type].push('
'+ + '
'+ + ''+ + ((s.token && s.token.get('name')) || (s.character && s.character.get('name')) || '(Creature)')+ + '
'+ + '
'+ + formatDieRoll(s.rollResults)+ + '
'+ + '
'+ + '
'); + return m; + },{npc:[],character:[],gmlayer:[]}); + }; + + const announcers = { + 'None': { + desc: `Shows nothing in chat when a roll is made.`, + func: () => {} + }, + 'Hidden': { + desc: `Whispers all rolls to the GM, regardless of who controls the tokens.`, + func: (l) => { + let groups = buildAnnounceGroups(l); + if(groups.npc.length || groups.character.length || groups.gmlayer.length){ + sendChat(scriptName,'/w gm '+ + '
'+ + groups.character.join('')+ + groups.npc.join('')+ + groups.gmlayer.join('')+ + '
'+ + '
'); + } + } + }, + 'Partial': { + desc: `Character rolls are shown in chat (Player controlled tokens), all others are whispered to the GM.`, + func: (l) => { + let groups = buildAnnounceGroups(l); + if(groups.character.length){ + sendChat(scriptName,'/direct '+ + '
'+ + groups.character.join('')+ + '
'+ + '
'); + } + if(groups.npc.length || groups.gmlayer.length){ + sendChat(scriptName,'/w gm '+ + '
'+ + groups.npc.join('')+ + groups.gmlayer.join('')+ + '
'+ + '
'); + } + } + }, + 'Visible': { + desc: `Rolls for tokens on the Objects Layer are shown to all in chat. Tokens on the GM Layer have their rolls whispered to the GM. `, + func: (l) => { + let groups=buildAnnounceGroups(l); + if(groups.npc.length || groups.character.length){ + sendChat(scriptName,'/direct '+ + '
'+ + groups.character.join('')+ + groups.npc.join('')+ + '
'+ + '
'); + } + if(groups.gmlayer.length){ + sendChat(scriptName,'/w gm '+ + '
'+ + groups.gmlayer.join('')+ + '
'+ + '
'); + } + } + } + }; + + const adjustments = { + STAT: 'stat', + COMPUTED: 'computed', + TOKEN: 'token', + CHARACTER: 'character', + BONUS: 'bonus', + FILTER: 'filter', + ROLLADJ: 'roll-adjustment' + }; + + const statAdjustments = { + 'filter-sheet': { + type: adjustments.FILTER, + func: async (t,c,v) => c.get('charactersheetname') === v, + desc: 'Forces calculations only for specific character sheets.' + }, + 'filter-status': { + type: adjustments.FILTER, + func: async (t,c,v) => t.get(`status_${v}`) !== false, + desc: 'Forces calculations only for tokens with a given status marker.' + }, + 'filter-tooltip': { + type: adjustments.FILTER, + func: async (t,c,v) => t.get(`tooltip`).toLowerCase().split(/[^a-zA-Z0-9:#|-]+/).includes(v), + desc: 'Forces calculations only for tokens with a tooltip containing the given word.' + }, + 'roll-die-count': { + type: adjustments.ROLLADJ, + func: async (t,c,v) => ({die_count:Number(v)||state[scriptName].config.diceCount}), + desc: 'Forces the number of dice rolled to this value for the matching tokens.' + }, + 'roll-die-size': { + type: adjustments.ROLLADJ, + func: async (t,c,v) => ({die_size:Number(v)||state[scriptName].config.dieSize}), + desc: 'Forces the size of the die rolled to this value for the matching tokens.' + }, + 'roll-die-mod': { + type: adjustments.ROLLADJ, + func: async (t,c,v) => ({die_mod:v}), + desc: 'Applies the given die mod to the roll.' + }, + 'bonus': { + type: adjustments.BONUS, + func: async (v) => Number(v), + desc: 'Adds a raw number.' + }, + 'computed': { + type: adjustments.COMPUTED, + func: async (c,v) => { + return await getComputedProxy({characterId:c.id,property:v}); + }, + desc: 'Reads the adjustment from a Beacon Sheet Computed.' + }, + 'stat-dnd': { + type: adjustments.STAT, + func: async function(v) { + return 'floor((('+v+')-10)/2)'; + }, + desc: 'Calculates the bonus as if the value were a DnD Stat.' + }, + 'negative': { + type: adjustments.STAT, + func: async function(v) { + return '(-1*('+v+'))'; + }, + desc: 'Returns the negative version of the stat' + }, + 'bare': { + type: adjustments.STAT, + func: async function(v) { + return v; + }, + desc: 'No Adjustment.' + }, + 'floor': { + type: adjustments.STAT, + func: async function(v) { + return 'floor('+v+')'; + }, + desc: 'Rounds down to the nearest integer.' + }, + 'tie-breaker': { + type: adjustments.STAT, + func: async function(v) { + return '(0.01*('+v+'))'; + }, + desc: 'Adds the accompanying attribute as a decimal (0.01)' + }, + 'ceiling': { + type: adjustments.STAT, + func: async function(v) { + return 'ceil('+v+')'; + }, + desc: 'Rounds up to the nearest integer.' + }, + 'token_bar': { + type: adjustments.TOKEN, + func: async function(t,idx) { + return parseFloat(t.get(`bar${idx}_value`))||0; + }, + desc: 'Takes the bonus from the numbered bar on the token. Use 1, 2, or 3. Defaults to 0 in the absense of a number.' + }, + 'token_bar_max': { + type: adjustments.TOKEN, + func: async function(t,idx) { + return parseFloat(t.get(`bar${idx}_max`))||0; + }, + desc: 'Takes the bonus from the max value of the numbered bar on the token. Use 1, 2, or 3. Defaults to 0 in the absense of a number.' + }, + 'token_aura': { + type: adjustments.TOKEN, + func: async function(t,idx) { + return parseFloat(t.get(`aura${idx}_radius`))||0; + }, + desc: 'Takes the bonus from the radius of the token aura. Use 1 or 2. Defaults to 0 in the absense of a number.' + } + + }; + + const buildInitDiceExpression = function(s,a){ + let diceCount = state[scriptName].config.diceCount; + let diceSize = a?.die_size || state[scriptName].config.dieSize; + let diceMod = a?.die_mod || state[scriptName].config.diceMod || ''; + + if(a.hasOwnProperty('die_count')){ + diceCount = a.die_count; + } else { + let stat=(''!== state[scriptName].config.diceCountAttribute && s.character && getAttrByName(s.character.id, state[scriptName].config.diceCountAttribute, 'current')); + if(stat ) { + stat = (_.isString(stat) ? stat : stat+''); + if('0' !== stat) { + stat = stat.replace(/@\{([^|]*?|[^|]*?\|max|[^|]*?\|current)\}/g, '@{'+(s.character.get('name'))+'|$1}'); + diceCount = `(${stat})`; + } + } + } + return `${diceCount}d${diceSize}${diceMod}`; + }; + + const rollers = { + 'Individual-Roll': { + mutator: function(l){ + return l; + }, + func: function(s,a){ + return buildInitDiceExpression(s,a); + }, + desc: 'Sets the initiative individually for each member of the group.' + }, + 'Least-All-Roll':{ + mutator: function(l){ + let min=_.reduce(l,function(m,r){ + if(!m || (r.total < m.total)) { + return r; + } + return m; + },false); + return _.times(l.length, function(){ + return min; + }); + }, + func: function(s,a){ + return buildInitDiceExpression(s,a); + }, + desc: 'Sets the initiative to the lowest of all initiatives rolled for the group.' + }, + 'Mean-All-Roll':{ + mutator: function(l){ + let mean = l[Math.round((l.length/2)-0.5)]; + return _.times(l.length, function(){ + return mean; + }); + }, + func: function(s,a){ + return buildInitDiceExpression(s,a); + }, + desc: 'Sets the initiative to the mean (average) of all initiatives rolled for the group.' + }, + 'Constant-By-Stat': { + mutator: function(l){ + return l; + }, + func: function(){ + return '0'; + }, + desc: 'Sets the initiative individually for each member of the group to their bonus with no roll.' + } + }; + + const assureHelpHandout = (create = false) => { + const helpIcon = "https://s3.amazonaws.com/files.d20.io/images/295769190/Abc99DVcre9JA2tKrVDCvA/thumb.png?1658515304"; + + // find handout + let props = {type:'handout', name:`Help: ${scriptName}`}; + let hh = findObjs(props)[0]; + if(!hh) { + hh = createObj('handout',Object.assign(props, {avatar: helpIcon})); + create = true; + } + if(create || version !== state[scriptName].lastHelpVersion){ + hh.set({ + notes: helpParts.helpDoc({who:'handout',playerid:'handout'}) + }); + state[scriptName].lastHelpVersion = version; + log(' > Updating Help Handout to v'+version+' <'); + } + }; + + const buildCharacterSheetList = () => { + validCharacterSheets = [...new Set(findObjs({type:"character"}).map(c=>c.get('charactersheetname')))]; + }; + + + const checkForNoConfig = () => { + if(state[scriptName].config.checkForNoConfig && (0 === state[scriptName].bonusStatGroups.length)){ + setTimeout(()=>{ + sendChat(scriptName,`/w gm ${helpParts.helpNoConfig()}`); + },1000); + } + }; + + const checkInstall = function() { + log(`-=> ${scriptName} v${version} <=- [${new Date(lastUpdate*1000)}]`); + + if( ! _.has(state,scriptName) || state[scriptName].version !== schemaVersion) { + log(' > Updating Schema to v'+schemaVersion+' <'); + switch(state[scriptName] && state[scriptName].version) { + case 0.5: + state[scriptName].replaceRoll = false; + /* break; // intentional dropthrough */ /* falls through */ + + case 0.6: + state[scriptName].config = { + rollType: state[scriptName].rollType, + replaceRoll: state[scriptName].replaceRoll, + dieSize: 20, + autoOpenInit: true, + sortOption: 'Descending' + }; + delete state[scriptName].replaceRoll; + delete state[scriptName].rollType; + /* break; // intentional dropthrough */ /* falls through */ + + case 0.7: + state[scriptName].config.announcer = 'Partial'; + /* break; // intentional dropthrough */ /* falls through */ + + case 0.8: + state[scriptName].config.diceCount = 1; + state[scriptName].config.maxDecimal = 2; + /* break; // intentional dropthrough */ /* falls through */ + + case 0.9: + state[scriptName].config.diceCountAttribute = ''; + /* break; // intentional dropthrough */ /* falls through */ + + case 0.10: + if(_.has(state[scriptName].config,'dieCountAttribute')){ + delete state[scriptName].config.dieCountAttribute; + state[scriptName].config.diceCountAttribute = ''; + } + if(_.has(state[scriptName].config,'dieCount')){ + delete state[scriptName].config.dieCount; + state[scriptName].config.diceCount = 1; + } + /* break; // intentional dropthrough */ /* falls through */ + + case 1.0: + state[scriptName].savedTurnOrders =[]; + /* break; // intentional dropthrough */ /* falls through */ + + case 1.1: + state[scriptName].config.diceMod=''; + /* break; // intentional dropthrough */ /* falls through */ + + case 1.2: + state[scriptName].lastHelpVersion=version; + state[scriptName].config.checkForNoConfig = true; + /* break; // intentional dropthrough */ /* falls through */ + + case 1.3: + state[scriptName].config.preserveFirst=false; + /* break; // intentional dropthrough */ /* falls through */ + + case 'UpdateSchemaVersion': + state[scriptName].version = schemaVersion; + break; + + default: + state[scriptName] = { + version: schemaVersion, + lastHelpVersion: version, + bonusStatGroups: [], + savedTurnOrders: [], + config: { + rollType: 'Individual-Roll', + replaceRoll: false, + dieSize: 20, + diceCount: 1, + maxDecimal: 2, + diceCountAttribute: '', + diceMod: '', + checkForNoConfig: true, + autoOpenInit: true, + sortOption: 'Descending', + preserveFirst: true, + announcer: 'Partial' + } + }; + break; + } + } + assureHelpHandout(); + buildCharacterSheetList(); + checkForNoConfig(); + }; + + const S = { + + button: { + 'border': '1px solid #cccccc', + 'border-radius': '.5em', + 'background-color': '#006dcc', + 'margin': '0 .1em', + 'font-weight': 'bold', + 'padding': '.1em 1em', + 'color': 'white' + }, + buttonCompact: { + 'border': '1px solid #cccccc', + 'border-radius': '.5em', + 'background-color': '#006dcc', + 'margin': '0 .1em', + 'font-weight': 'bold', + 'padding': '.1em .25em', + 'color': 'white' + }, + bubble: { + 'display': 'inline-block', + 'border': '1px solid #999', + 'border-radius': '1em', + 'padding': '.1em .5em', + 'font-weight': 'bold', + 'background-color': '#009688', + 'color': 'white' + }, + adj: { + default: { + 'display': 'inline-block', + 'border': '1px solid #999', + 'border-radius': '.25em', + 'padding': '.1em .25em', + 'font-weight': 'bold', + 'background-color': '#009688', + 'color': 'white', + 'margin':'.25em' + }, + 'negative': { + 'background-color': 'white', + 'color': '#009688' + }, + 'floor': { + 'background-color': '#274e13' + }, + 'ceiling': { + 'background-color': '#3c78d8' + }, + 'stat-dnd': { + 'background-color': '#990099' + }, + 'filter-sheet': { + 'background-color': '#996600' + }, + 'filter-status': { + 'background-color': '#993300' + }, + 'filter-tooltip': { + 'background-color': '#cc6600' + }, + 'roll-die-count': { + 'background-color': '#0066cc' + }, + 'roll-die-size': { + 'background-color': '#0033cc' + }, + 'roll-die-mod': { + 'background-color': '#0099cc' + }, + 'computed': { + 'background-color': '#a61c00' + }, + 'bonus': { + 'background-color': '#f1c232', + 'color': '#555500' + }, + 'token_bar': { + 'background-color': '#1c4587' + }, + 'token_bar_max': { + 'background-color': '#674ea7' + }, + 'token_aura': { + 'background-color': '#a64d79' + } + + }, + configRow: { + 'border': '1px solid #ccc;', + 'border-radius': '.2em;', + 'background-color': 'white;', + 'margin': '0 1em;', + 'padding': '.1em .3em;' + }, + bsgRow:{ + 'position': 'relative', + 'padding': '.5em 4em .5em .5em', + 'margin-bottom': '.5em' + }, + oe: [ + { 'background-color': '#eeffee' }, + { 'background-color': '#eeeeff' } + ], + block: { + 'border': '1px solid #ff0000' + } + }; + + const css = (rules) => `style="${Object.keys(rules).map(k=>`${k}:${rules[k]};`).join('')}"`; + + const _h = { + outer: (...o) => `
${o.join(' ')}
`, + title: (t,v) => `
${t} v${v}
`, + subhead: (...o) => `${o.join(' ')}`, + minorhead: (...o) => `${o.join(' ')}`, + optional: (...o) => `${ch('[')}${o.join(` ${ch('|')} `)}${ch(']')}`, + required: (...o) => `${ch('<')}${o.join(` ${ch('|')} `)}${ch('>')}`, + header: (...o) => `
${o.join(' ')}
`, + section: (s,...o) => `${_h.subhead(s)}${_h.inset(...o)}`, + paragraph: (...o) => `

${o.join(' ')}

`, + items: (o) => o.map(i=>`
  • ${i}
  • `).join(''), + ol: (...o) => `
      ${_h.items(o)}
    `, + ul: (...o) => ``, + block: (...o) => `
    ${o.join(' ')}
    `, + grid: (...o) => `
    ${o.join('')}
    `, + cell: (o) => `
    ${o}
    `, + inset: (...o) => `
    ${o.join(' ')}
    `, + join: (...o) => o.join(' '), + pre: (...o) =>`
    ${o.join(' ')}
    `, + preformatted: (...o) =>_h.pre(o.join('
    ').replace(/\s/g,ch(' '))), + code: (...o) => `${o.join(' ')}`, + attr: { + bare: (o)=>`${ch('@')}${ch('{')}${o}${ch('}')}`, + selected: (o)=>`${ch('@')}${ch('{')}selected${ch('|')}${o}${ch('}')}`, + target: (o)=>`${ch('@')}${ch('{')}target${ch('|')}${o}${ch('}')}`, + char: (o,c)=>`${ch('@')}${ch('{')}${c||'CHARACTER NAME'}${ch('|')}${o}${ch('}')}` + }, + bold: (...o) => `${o.join(' ')}`, + italic: (...o) => `${o.join(' ')}`, + font: { + command: (...o)=>`${o.join(' ')}` + }, + bsgAdj: (adj,m) => `${adj}( ${m} )`, + bsgRowBlock: (c,n) => `
    ${c}
    `, + bsgAdjPart: (e) => (e.adjustments||['']).reduce((m,adj) => _h.bsgAdj(adj,m) , `${e.attribute}${e.type?`|${e.type}`:''}`), + bsgRowStats: (g) => `
    ${g.map(_h.bsgAdjPart).join('')}
    `, + bsgRowButtons: (n) => `
    ${_h.ui.buttonCompact(`⮙`,`!group-init --promote ${n}`)}${_h.ui.buttonCompact(`🚫`,`!group-init --del-group ${n}`)}
    `, + bsgRow: (g,n) =>_h.bsgRowBlock(`${_h.bsgRowStats(g)}${_h.bsgRowButtons(n)}`,n-1), + ui : { + float: (t) => `
    ${t}
    `, + clear: () => `
    `, + bubble: (label) => `${label}`, + button: (label,link) => `${label}`, + buttonCompact: (label,link) => `${label}` + } + }; + + + + + const helpParts = { + helpBody: (context) => _h.join( + _h.header( + _h.paragraph( + `Rolls initiative for the selected tokens and adds them to the Turn Order if they don${ch("'")}t have a turn yet.` + ), + _h.paragraph( + `The calculation of initiative is handled by the combination of Roller (See ${_h.bold("Roller Options")} below) and a Bonus. The Bonus is determined based on an ordered list of Stat Groups (See ${_h.bold("Bonus Stat Groups")} below). Stat Groups are evaluated in order. The bonus computed by the first Stat Group for which all attributes exist and have a numeric value is used. This allows you to have several Stat Groups that apply to different types of characters. In practice you will probably only have one, but more are there if you need them.` + ) + ), + helpParts.commands(context) + ), + rollingCommands: (/*context*/) => _h.section('Commands for Rolling', + _h.paragraph(`GroupInitiative's primary role is rolling initiative. It has many options for performing the roll, most of which operate on the selected tokens.`), + _h.inset( + _h.font.command( + `!group-init` + ), + _h.paragraph( + `This command uses the configured Roller to dtermine the initiative order for all the selected tokens.` + ), + _h.font.command( + `!group-init`, + `--bonus`, + _h.required('bonus') + ), + _h.paragraph( + `This command is just line the bare !group-init roll, but will add the supplied bonus to all rolls. The bonus can be from an inline roll.` + ), + _h.font.command( + `!group-init`, + `--reroll`, + _h.optional('bonus') + ), + _h.paragraph( + `This command rerolls all of the tokens currently in the turn order as if they were selected when you executed !group-init. An optional bonus can be supplied, which can be the result of an inline roll.` + ), + _h.font.command( + `!group-init`, + `--ids`, + _h.optional('...') + ), + _h.paragraph( + `This command uses the configured Roller to determine the initiative order for all tokens whose ids are specified.` + ), + + _h.font.command( + `!group-init`, + `--adjust`, + _h.required('adjustment'), + _h.optional('minimum') + ), + _h.paragraph( + `Applies an adjustment to all the current Turn Order tokens (Custom entries ignored). The required adjustment value will be applied to the current value of all Turn Order entries. The optional minium value will be used if the value after adjustiment is lower, which can end up raising Turn Order values even if they were already lower.` + ), + _h.font.command( + `!group-init`, + `--adjust-current`, + _h.required('adjustment'), + _h.optional('minimum') + ), + _h.paragraph( + `This is identical to --adjust, save that it is only applied to the top entry in the Turn Order.` + ) + ) + ), + helpCommands: (/*context*/) => _h.section('Help and Configuration', + _h.paragraph( + `All of these commands are documented in the build in help. Additionally, there are many configuration options that can only be accessed there.` + ), + _h.inset( + _h.font.command( + `!group-init`, + `--help` + ), + _h.paragraph(`This command displays the help and configuration options.`) + ) + ), + + buildStatAdjustmentRows: ( /* context */) => _h.ul( + ...Object.keys(statAdjustments).map(k=>`${_h.bold(k)} -- ${statAdjustments[k].desc}`) + ), + + buildCharacterSheetRows: ( /* context */) => _h.ul( + ...validCharacterSheets.map(k=>`${_h.bold(k)}`) + ), + + showStandardConfigOptions: ( /* context */ ) => _h.ul( + ...Object.keys(standardConfigs).map(c=>_h.join( + _h.ui.float(_h.ui.button(`Apply Config`,`!group-init-config --apply-standard-config|${c}`)), + _h.subhead(standardConfigs[c].title), + _h.paragraph(`${standardConfigs[c].desc}${_h.ui.clear()}`) + )) + ), + + statGroupCommands: (/*context*/) => _h.section('Commands for Stat Groups', + _h.paragraph( + `Stat Groups are the method through which GroupInitiative knows what to do to create the initiative value for a token. Generally, they will be some combination of attributes and adjustments to look up on each token and character.` + ), + _h.inset( + _h.font.command( + `!group-init`, + `--add-group --${_h.required('adjustment')} ${_h.optional('arguments')}`, + _h.optional( + `--add-group --${_h.required('adjustment')} ${_h.optional('arguments')}`, + `...` + ) + ), + _h.paragraph( + `Adds a new Bonus Stat Group to the end of the list. Each adjustment operation can be followed by another adjustment operation, but eventually must end in an attribute name. Adjustment operations are applied to the result of the adjustment operations that follow them.` + ), + _h.minorhead('Available Stat Adjustment Options:'), + helpParts.buildStatAdjustmentRows(), + + _h.font.command( + `!group-init`, + `--show-sheets` + ), + _h.paragraph( + `This command shows the names of the character sheets currently installed in the game, for use with ${_h.code('--filter-sheet')}.` + ), + + _h.font.command( + `!group-init`, + `--promote`, + _h.required('index') + ), + _h.paragraph( + `This command increases the importants of the Bonus Stat Group at the supplied index.` + ), + + _h.font.command( + `!group-init`, + `--del-group`, + _h.required('index') + ), + _h.paragraph( + `This command removes the Bonus Stat Group at the supplied index.` + ) + ) + ), + stackCommands: (/*context*/) => _h.section('Commands for Stacks of Initiative', + _h.paragraph( + `GroupInitiative provides a system called ${_h.bold('Stacks')} which lets you store collections of prerolled initiative values and combine or cycle them as desired.` + ), + _h.inset( + _h.font.command( + `!group-init`, + `--stack`, + _h.optional('operation'), + _h.optional('label') + ), + _h.inset( + _h.minorhead('Available Operations:'), + _h.ul( + `${_h.bold('list')} -- Displays the stack of saved Turn Orders. (default)`, + `${_h.bold('clear')} -- Clears the stack of saved Turn Orders.`, + `${_h.bold(`copy${ch('|')}dup ${ch('[')}label${ch(']')}`)} -- Adds a copy of the current Turn Order to the stack.`, + `${_h.bold(`push ${ch('[')}label${ch(']')}`)} -- Adds a copy of the current Turn Order to the stack and clears the Turn Order. Anything after the command will be used as a label for the entry.`, + `${_h.bold('pop')} -- Replaces the current Turn Order with the last entry in the stack removing it from the stack.`, + `${_h.bold('apply')} -- Replaces the current Turn Order with the last entry in the stack leaving it on the stack.`, + `${_h.bold(`swap ${ch('[')}label${ch(']')}`)} -- Swaps the current Turn Order with the last entry in the stack. Anything after the command will be used as a label for the entry placed in the stack.`, + `${_h.bold(`tswap${ch('|')}tail-swap ${ch('[')}label${ch(']')}`)} -- Swaps the current Turn Order with the first entry in the stack. Anything after the command will be used as a label for the entry placed in the stack.`, + `${_h.bold('merge')} -- Removes the last entry in the stack and adds it to the current Turn Order and sorts the new Turn Order with the configured sort method.`, + `${_h.bold(`apply-merge${ch('|')}amerge`)} -- Merges the last entry in the stack with the current Turn Order and sorts the new Turn Order with the configured sort method, leaving the stack unchanged.`, + `${_h.bold(`rotate${ch('|')}rot ${ch('[')}label${ch(']')}`)} -- Pushes the current Turn Order onto the end of the stack and restores the first entry from the stack to the Turn Order. Anything after the command will be used as a label for the entry placed in the stack.`, + `${_h.bold(`reverse-rotate${ch('|')}rrot ${ch('[')}label${ch(']')}`)} -- Pushes the current Turn Order onto the beginning of the stack and restores the last entry from the stack to the Turn Order. Anything after the command will be used as a label for the entry placed in the stack.` + ) + ) + ) + ), + turnOrderCommands: (/*context*/) => _h.section('Commands for Turn Order Management', + _h.paragraph( + `The Turn Order is an integral part of initiative, so GroupInitiative provides some methods for manipulating it.` + ), + _h.inset( + _h.font.command( + `!group-init`, + `--toggle-turnorder` + ), + _h.paragraph( + `Opens or closes the Turn Order window.` + ), + _h.font.command( + `!group-init`, + `--sort` + ), + _h.paragraph( + `Applies the configured sort operation to the current Turn Order.` + ), + _h.font.command( + `!group-init`, + `--clear` + ), + _h.paragraph( + `Removes all tokens from the Turn Order. If Auto Open Init is enabled it will also close the Turn Order box.` + )) + ), + commands: (context) => _h.join( + _h.subhead('Commands'), + helpParts.rollingCommands(context), + helpParts.helpCommands(context), + helpParts.statGroupCommands(context), + helpParts.turnOrderCommands(context), + helpParts.stackCommands(context) + ), + + rollerConfig: (/*context*/) => _h.section('Roller Options', + _h.paragraph( + `The Roller determines how token rolls are performed for groups of tokens.` + ), + _h.inset( + _h.ul( + ...Object.keys(rollers).map(r=>`${_h.ui.float( + ( r === state[scriptName].config.rollType) + ? _h.ui.bubble(_h.bold('Selected')) + : _h.ui.button(`Use ${r}`,`!group-init-config --set-roller|${r}`) + )}${_h.bold(r)} -- ${rollers[r].desc}${_h.ui.clear()}` ) + ) + ) + ), + + sortOptionsConfig: ( /* context */ ) => _h.section('Sorter Options', + _h.paragraph( + `The Sorter is used to determine how to reorder entries in the Turn Order whenever GroupInitiative performs a sort. Sorting occurs when the sort command (${_h.code('!group-init --sort')}) is issued, when stack entries are merged into the current Turn Order, and when new entries are added to the Turn Order with a GroupInitiative command (like ${_h.code('!group-init')}).` + ), + _h.inset( + _h.ul( + ...Object.keys(sorters).map(s=>`${_h.ui.float( + (s === state[scriptName].config.sortOption) + ? _h.ui.bubble(_h.bold('Selected')) + : _h.ui.button(`Use ${s}`,`!group-init-config --sort-option|${s}`) + )}${_h.bold(s)} -- ${sorters[s].desc}${_h.ui.clear()}`) + ) + ) + ), + + dieSizeConfig: ( /* context */ ) => _h.section('Initiative Die Size', + _h.paragraph( + `The Initiative Die sets the size of the die that GroupInitiative will roll for each initiative value.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button('Set Die Size', `!group-init-config --set-die-size|?{Number of sides the initiative die has:|${state[scriptName].config.dieSize}}`))}Initiative Die Size is currently set to ${_h.bold(state[scriptName].config.dieSize)}. ${_h.ui.clear()}`) + ) + ), + diceCountConfig: ( /* context */ ) => _h.section('Initiative Dice Count', + _h.paragraph( + `The Initiative Dice Count sets the number of dice GroupInitiative will roll for each initiative value. You can set this number to 0 to prevent any dice from being rolled.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button('Set Dice Count', `!group-init-config --set-dice-count|?{Number of initiative dice to roll:|${state[scriptName].config.diceCount}}`))}Initiative Dice Count is currently set to ${_h.bold(state[scriptName].config.diceCount)}. ${_h.ui.clear()}`) + ) + ), + diceCountAttributeConfig: ( /* context */ ) => _h.section('Dice Count Attribute', + _h.paragraph( + `If this attribute is set, it will be used to determine the number of dice to roll for each initiatitve value. If the value is not set, or not a valid number, the Intiative Dice Count is used instead.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button('Set Attribute', `!group-init-config --set-dice-count-attribute|?{Attribute to use for number of initiative dice to roll (Blank to disable):|${state[scriptName].config.diceCountAttribute}}`))}Dice Count Attribute is currently set to ${_h.bold((state[scriptName].config.diceCountAttribute.length ? state[scriptName].config.diceCountAttribute : 'DISABLED'))}. ${_h.ui.clear()}`) + ) + ), + diceModConfig: ( /* context */ ) => _h.section('Dice Modifier String', + _h.paragraph( + `The Dice Modifier String is appended to the roll made by GroupInitiative for each Initiative. It can be used for rerolling 1s or dropping the lower roll, etc.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button('Set Dice Modifiers', `!group-init-config --set-dice-mod|?{Dice Modifiers to be appended to roll (Blank to disable):|${state[scriptName].config.diceMod}}`))}Dice Modifier String is currently set to ${_h.bold((state[scriptName].config.diceMod.length ? state[scriptName].config.diceMod : 'DISABLED'))}. ${_h.ui.clear()}`) + ) + ), + maxDecimalConfig: ( /* context */ ) => _h.section('Maximum Decimal Places', + _h.paragraph( + `This is the Maximum number of decimal places to show in the Initiative when Tie-Breakers are rolled.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button('Set Max Decimal', `!group-init-config --set-max-decimal|?{Maximum number of decimal places:|${state[scriptName].config.maxDecimal}}`))}Maximum Decimal Places is currently set to ${_h.bold(state[scriptName].config.maxDecimal)}. ${_h.ui.clear()}`) + ) + ), + autoOpenInitConfig: ( /* context */ ) => _h.section('Auto Open Turn Order', + _h.paragraph( + `This option causes GroupInitiative to open the Turn Order whenever it makes an initiative roll.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button((state[scriptName].config.autoOpenInit ? 'Disable' : 'Enable'), `!group-init-config --toggle-auto-open-init`))}Auto Open Turn Order is currently ${_h.bold( (state[scriptName].config.autoOpenInit ? 'On' : 'Off') )}. ${_h.ui.clear()}`) + ) + ), + replaceRollConfig: ( /* context */ ) => _h.section('Replace Roll', + _h.paragraph( + `This option causes GroupInitiative to replace a roll in the Turn Order if a token is already present there when it makes a roll for it. Otherwise, the token is ignored and the current roll is retained.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button((state[scriptName].config.replaceRoll ? 'Disable' : 'Enable'), `!group-init-config --toggle-replace-roll`))}Replace Roll is currently ${_h.bold( (state[scriptName].config.replaceRoll ? 'On' : 'Off') )}. ${_h.ui.clear()}`) + ) + ), + checkForNoConfigConfig: ( /* context */ ) => _h.section('Check For No Configuration', + _h.paragraph( + `This option causes GroupInitiative to prompt with standard configuration options when it starts up and there are no Stat Groups. You can suppress it by turning it off, in the case that your game is just dice and you don't need Stat Groups.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button((state[scriptName].config.checkForNoConfig ? 'Disable' : 'Enable'), `!group-init-config --toggle-check-for-no-config`))}No Configuration Checking is currently ${_h.bold( (state[scriptName].config.checkForNoConfig ? 'On' : 'Off') )}. ${_h.ui.clear()}`) + ) + ), + preserveFirstConfig: ( /* context */ ) => _h.section('Preserve First on Sorted Add', + _h.paragraph( + `This option causes GroupInitiative to preserve the first Turn Order entry when sorting the Turn Order after adding creatures.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button((state[scriptName].config.preserveFirst ? 'Disable' : 'Enable'), `!group-init-config --toggle-preserve-first`))}Preserve First on Sorted Add is currently ${_h.bold( (state[scriptName].config.preserveFirst ? 'On' : 'Off') )}. ${_h.ui.clear()}`) + ) + ), + announcerConfig: (/*context*/) => _h.section('Announcer Options', + _h.paragraph( + `The Announcer controls what is shown in chat when a roll is performed.` + ), + _h.inset( + _h.ul( + ...Object.keys(announcers).map(a=>`${_h.ui.float( + (a === state[scriptName].config.announcer) + ? _h.ui.bubble(_h.bold('Selected')) + : _h.ui.button(`Use ${a}`,`!group-init-config --set-announcer|${a}`) + )}${_h.bold(a)} -- ${announcers[a].desc}${_h.ui.clear()}` ) + ) + ) + ), + standardConfig: (context) => _h.join( + _h.subhead('Configuration'), + _h.paragraph( + `Standard Configurations give you some quick options for certain character sheets. If you${ch("'")}re using one of these sheets in a pretty standard game, look no further than one of these options.` + ), + _h.inset( + _h.minorhead('Available Standard Configuration Options:'), + helpParts.showStandardConfigOptions(context) + ) + ), + + showBonusStatGroupsConfig: (/*context*/) => _h.section('Bonus Stat Groups', + _h.ol( ...state[scriptName].bonusStatGroups.map((a,n)=>_h.bsgRow(a,n+1) )) + ), + + configuration: (context) => _h.join( + _h.subhead('Configuration'), + _h.inset( + helpParts.rollerConfig(context), + helpParts.sortOptionsConfig(context), + helpParts.preserveFirstConfig(context), + helpParts.dieSizeConfig(context), + helpParts.diceCountConfig(context), + helpParts.diceCountAttributeConfig(context), + helpParts.diceModConfig(context), + helpParts.maxDecimalConfig(context), + helpParts.autoOpenInitConfig(context), + helpParts.replaceRollConfig(context), + helpParts.checkForNoConfigConfig(context), + helpParts.announcerConfig(context), + helpParts.showBonusStatGroupsConfig(context) + ) + ), + + helpDoc: (context) => _h.join( + _h.title(scriptName, version), + helpParts.helpBody(context) + ), + + helpChat: (context) => _h.outer( + _h.title(scriptName, version), + helpParts.helpBody(context), + helpParts.standardConfig(context), + helpParts.configuration(context) + ), + + helpNoConfig: (context) => _h.outer( + _h.title(scriptName, version), + _h.paragraph(`You do not have any Bonus Stat Groups configured right now. Usually that means this is the first time you have used GroupInitiative. If you would like, you can try one of the Standard Configurations below to configure GroupInitiative for some popular character sheets. If you want to configure a different sheet or without a sheet at all, see the extensive ${_h.ui.button('Online Help',`!group-init --help`)}. If you do not want to see this message again, you can ${_h.ui.button('Hide it',`!group-init-config --toggle-check-for-no-config`)}.`), + _h.inset( + _h.minorhead('Available Standard Configuration Options:'), + helpParts.showStandardConfigOptions(context) + ) + ), + + helpConfig: (context) => _h.outer( + _h.title(scriptName, version), + helpParts.configuration(context) + ) + + }; + + const showHelp = (playerid) => { + const who=(getObj('player',playerid)||{get:()=>'API'}).get('_displayname'); + let context = { + who, + playerid + }; + sendChat('', '/w "'+who+'" '+ helpParts.helpChat(context)); + }; + + const parseEmbeddedStatReferences = function(stat,charObj){ + let charName=charObj.get('name'), + stext=(stat+'').replace(/@{[^}]*}/g,(s)=>{ + let parts=_.rest(s.match(/@{([^|}]*)\|?([^|}]*)\|?([^|}]*)}/)), + statName,modName; + if(parts[2].length){ + statName=parts[1]; + modName=parts[2]; + } else if(parts[1].length){ + if(_.contains(['max','current'],parts[1])){ + statName=parts[0]; + modName=parts[1]; + } else { + statName=parts[1]; + } + } else { + statName=parts[0]; + } + + return `@{${charName}|${statName}${modName?`|${modName}`:''}}`; + }) + .replace(/&{tracker}/,''); + return stext; + }; + +const findInitiativeBonus = async (charObj, token) => { + let bonus = ''; + let rolladj = {}; + + const GetBonuses = async (group) => { + let cachedRollAdj = {}; + let bonusPromises = group.map( async (details) => { + let stat=getAttrByName(charObj.id, details.attribute, details.type||'current'); + + if( undefined === stat || null === stat){ + stat = undefined; + } else if(!Number.isNaN(Number(stat))){ + stat = parseFloat(stat); + } else if(isString(stat)) { + stat = parseEmbeddedStatReferences(stat,charObj); + stat = stat.length ? stat : 0; + } else { + stat = undefined; + } + + return await (details.adjustments || []).reduce(async (memo,a) => { + let args = a.split(':'); + let adjustment = args.shift().toLowerCase(); + let func=statAdjustments[adjustment].func; + let adjType=statAdjustments[adjustment].type; + if(isFunction(func)) { + switch(adjType){ + + case adjustments.STAT: + if(undefined !== stat){ + args.unshift(memo); + memo = await func.apply({},[...args]); + } + break; + + case adjustments.COMPUTED: + args.unshift(memo); + memo = await func.apply({},[charObj,details.attribute]); + break; + + case adjustments.TOKEN: + memo = await func(token,details.attribute); + break; + + case adjustments.BONUS: + memo = await func(details.attribute); + break; + + case adjustments.FILTER: + if(!await func(token,charObj,details.attribute)) { + memo=null; + } else { + memo = 0; // necessary to select this stat group + } + break; + case adjustments.ROLLADJ:{ + let adj = await func(token,charObj,details.attribute); + cachedRollAdj = {...cachedRollAdj,...adj}; + memo = 0; // necessary to select this stat group + } + break; + } + } + return memo; + },stat); + }); + + bonus = await Promise.all(bonusPromises); + + + if(_.contains(bonus,undefined) || _.contains(bonus,null) || _.contains(bonus,NaN)) { + bonus=''; + return false; + } + bonus = bonus.join('+'); + rolladj = cachedRollAdj; + + return true; + }; + + + for(const group of state[scriptName].bonusStatGroups){ + let found = await GetBonuses(group); + + if(found){ + break; + } + } + return {bonus,rolladj}; +}; + +const rollForTokenIDsExternal = (ids,options) => { + if(Array.isArray(ids)){ + setTimeout(()=>makeRollsForIDs(ids,{ + isReroll: false, + prev: Campaign().get('turnorder'), + manualBonus: parseFloat(options && options.manualBonus)||0 + }), 0); + } +}; + +const makeRollsForIDs = async (ids,options={}) => { + let turnorder = Campaign().get('turnorder'); + + turnorder = ('' === turnorder) ? [] : JSON.parse(turnorder); + if(state[scriptName].config.replaceRoll || options.isReroll) { + turnorder = turnorder.filter(e => !ids.includes(e.id)); + } + + let turnorderIDS = turnorder.map(e=>e.id); + + let initFunc=rollers[state[scriptName].config.rollType].func; + + let rollSetupPromises = ids + .filter( id => !turnorderIDS.includes(id)) + .map(id => getObj('graphic',id)) + .filter( g => undefined !== g) + .map(g => ({ + token: g, + character: getObj('character', g.get('represents')) + })) + .map(async g => { + g.roll=[]; + + let {bonus,rolladj} = await findInitiativeBonus(g.character||{},g.token); + bonus = (isString(bonus) ? (bonus.trim().length ? bonus : '0') : bonus); + g.roll.push(bonus); + + if(options.manualBonus){ + g.roll.push( options.manualBonus ); + } + g.roll.push( initFunc(g,rolladj) ); + return g; + }); + + let rollSetup = await Promise.all(rollSetupPromises); + + let pageid = (rollSetup[0]||{token:{get:()=>{}}}).token.get('pageid'); + + + let initRolls = _.map(rollSetup,function(rs,i){ + return { + index: i, + roll: ('[[('+ _.reject(rs.roll,function(r){ + return _.isString(r) && _.isEmpty(r); + }) + .join(') + (')+')]]') + .replace(/\[\[\[/g, "[[ [") + }; + }); + + let turnEntries = []; + let finalize = _.after(initRolls.length,function(){ + turnEntries = _.sortBy(turnEntries,'order'); + turnEntries = rollers[state[scriptName].config.rollType].mutator(turnEntries); + + Campaign().set({ + turnorder: JSON.stringify( + sorters[state[scriptName].config.sortOption].func( + turnorder.concat( + _.chain(rollSetup) + .map(function(s){ + s.rollResults=turnEntries.shift(); + return s; + }) + .tap(announcers[state[scriptName].config.announcer].func) + .map(function(s){ + return { + id: s.token.id, + pr: s.rollResults.total, + _pageid: s.token.get('pageid'), + custom: '' + }; + }) + .value() + ), + state[scriptName].config.preserveFirst + ) + ) + }); + notifyObservers('turnOrderChange',Campaign().get('turnorder'), options.prev); + + if(state[scriptName].config.autoOpenInit && !Campaign().get('initativepage')) { + Campaign().set({ + initiativepage: pageid + }); + } + }); + + initRolls.forEach((ir) => { + let chatText = ir.index+':'+ir.roll.replace(/\[\[\s+/,'[['); + sendChat('',chatText ,(msg) => { + let parts = msg[0].content.split(/:/); + let ird = msg[0].inlinerolls[parts[1].match(/\d+/)]; + let rdata = { + order: parseInt(parts[0],10), + total: (ird.results.total%1===0 ? + ird.results.total : + parseFloat(ird.results.total.toFixed(state[scriptName].config.maxDecimal))), + rolls: _.reduce(ird.results.rolls,function(m,rs){ + if('R' === rs.type) { + m.push({ + sides: rs.sides, + rolls: _.pluck(rs.results.filter(r=>true!==r.d),'v') + }); + } + return m; + },[]) + }; + + rdata.bonus = (ird.results.total - (_.reduce(rdata.rolls,function(m,r){ + m+=_.reduce(r.rolls,function(s,dieroll){ + return s+dieroll; + },0); + return m; + },0))); + + rdata.bonus = (rdata.bonus%1===0 ? + rdata.bonus : + parseFloat(rdata.bonus.toFixed(state[scriptName].config.maxDecimal))); + + turnEntries.push(rdata); + + finalize(); + }); + }); +}; + +const handleInput = async (msg_orig) => { + let msg = _.clone(msg_orig), + prev=Campaign().get('turnorder'), + args, + cmds, + workgroup, + workvar, + error=false, + cont=false, + manualBonus=0, + manualBonusMin=0, + isReroll=false + ; + const who=(getObj('player',msg.playerid)||{get:()=>'API'}).get('_displayname'); + + let context = { + who, + playerid: msg.playerid + }; + + let ids =[]; + + if(msg.selected){ + ids = [...ids, ...msg.selected.map(o=>o._id)]; + } + + if (msg.type !== "api" ) { + return; + } + + if(_.has(msg,'inlinerolls')){ + msg.content = _.chain(msg.inlinerolls) + .reduce(function(m,v,k){ + m['$[['+k+']]']=v.results.total || 0; + return m; + },{}) + .reduce(function(m,v,k){ + return m.replace(k,v); + },msg.content) + .value(); + } + + args = msg.content.split(/\s+--/); + switch(args.shift()) { + case '!group-init': + if(args.length > 0) { + cmds=args.shift().split(/\s+/); + + switch(cmds[0]) { + case 'help': + if(!playerIsGM(msg.playerid)){ + return; + } + showHelp(msg.playerid); + break; + + case 'ids': + if(playerIsGM(msg.playerid) || msg.playerid === 'api' ){ + ids = [...new Set([...ids, ...cmds.slice(1)])]; + cont = true; + } + break; + + case 'show-sheets': + sendChat('!group-init --add-group', `/w "${who}" ` + + '
    '+ + 'The following sheets are present in the game currently:'+ + helpParts.buildCharacterSheetRows(context) + + '
    ' + ); + return; + + case 'add-group': + if(!playerIsGM(msg.playerid)){ + return; + } + workgroup=[]; + workvar={}; + + _.each(args,function(arg){ + let argParts=arg.split(/\s+(.+)/), + adjustmentName, + parameter=argParts[0].split(/:/); + parameter[0]=parameter[0].toLowerCase(); + + if(_.has(statAdjustments, parameter[0])) { + if('filter-sheet' === parameter[0]){ + if( ! validCharacterSheets.includes(argParts[1])) { + sendChat('!group-init --add-group', `/w "${who}" ` + + '
    '+ + 'Unknown Character Sheet: '+argParts[1]+'
    '+ + 'Use one of the following:'+ + helpParts.buildCharacterSheetRows(context) + + '
    ' + ); + error=true; + + } + } + + if('bare' !== parameter[0]) { + if(!_.has(workvar,'adjustments')) { + workvar.adjustments=[]; + } + workvar.adjustments.unshift(argParts[0]); + } + if(argParts.length > 1){ + adjustmentName=argParts[1].split(/\|/); + workvar.attribute=adjustmentName[0]; + if('max'===adjustmentName[1]) { + workvar.type = 'max'; + } + workgroup.push(workvar); + workvar={}; + } + } else { + sendChat('!group-init --add-group', `/w "${who}" ` + + '
    '+ + 'Unknown Stat Adjustment: '+parameter[0]+'
    '+ + 'Use one of the following:'+ + helpParts.buildStatAdjustmentRows(context) + + '
    ' + ); + error=true; + } + }); + if(!error) { + if(!_.has(workvar,'adjustments')){ + state[scriptName].bonusStatGroups.push(workgroup); + sendChat(scriptName, `/w "${who}" ${_h.outer(helpParts.showBonusStatGroupsConfig(context))}`); + } else { + sendChat('!group-init --add-group', `/w "${who}" ` + + '
    '+ + 'All Stat Adjustments must have a final attribute name as an argument. Please add an attribute name after --'+args.pop()+ + '
    ' + ); + } + } + break; + + case 'stack': { + if(!playerIsGM(msg.playerid)){ + return; + } + cmds.shift(); + let operation=cmds.shift(), + showdate=function(ms){ + let ds=Math.round((_.now()-ms)/1000), + str=[]; + + if(ds>86400){ + str.push(`${Math.round(ds/86400)}d`); + ds%=86400; + } + if(ds>3600){ + str.push(`${Math.round(ds/3600)}h`); + ds%=3600; + } + + if(ds>60){ + str.push(`${Math.round(ds/60)}m`); + ds%=60; + } + str.push(`${Math.round(ds)}s`); + + return str.join(' '); + }, + stackrecord=function(label){ + let toRaw=Campaign().get('turnorder'), + to=JSON.parse(toRaw)||[], + summary=_.chain(to) + .map((o)=>{ + return { + entry: o, + token: getObj('graphic',o.id) + }; + }) + .map((o)=>{ + return { + img: (o.token ? o.token.get('imgsrc') : ''), + name: (o.token ? o.token.get('name') : o.entry.custom), + pr: o.entry.pr + }; + }) + .value(); + + return { + label: label || (to.length ? `{${to.length} records}`: '{empty}'), + date: _.now(), + summary: summary, + turnorder: toRaw + }; + }, + toMiniDisplay=function(summary){ + return '
    '+ + _.map(summary,(sume)=>{ + return `
    ${sume.pr}
    ${sume.name||'&'+'nbsp;'}
    `; + }).join('')+ + '
    '; + }, + stacklist=function(){ + sendChat('', `/w "${who}" ` + + '
      '+ + _.map(state[scriptName].savedTurnOrders,(o)=>`
    1. ${o.label} [${showdate(o.date)}]${toMiniDisplay(o.summary)}
    2. `).join('')+ + '
    ' + ); + + }; + switch(operation){ + case 'dup': + case 'copy': + // take current Turn Order and put it on top. + state[scriptName].savedTurnOrders.push(stackrecord(cmds.join(' '))); + stacklist(); + break; + + case 'push': + // take current Turn Order and put it on top. + state[scriptName].savedTurnOrders.push(stackrecord(cmds.join(' '))); + Campaign().set('turnorder','[]'); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + break; + + case 'pop': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders.pop(); + Campaign().set('turnorder',sto.turnorder); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } else { + sendChat('!group-init --stack pop', `/w "${who}" ` + + '
    '+ + 'No Saved Turn Orders to restore!'+ + '
    ' + ); + } + break; + + case 'apply': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders[0]; + Campaign().set('turnorder',sto.turnorder); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } else { + sendChat('!group-init --stack pop', `/w "${who}" ` + + '
    '+ + 'No Saved Turn Orders to apply!'+ + '
    ' + ); + } + break; + + case 'rot': + case 'rotate': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders.shift(); + state[scriptName].savedTurnOrders.push(stackrecord(cmds.join(' '))); + Campaign().set('turnorder',sto.turnorder); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } + break; + + case 'rrot': + case 'reverse-rotate': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders.pop(); + state[scriptName].savedTurnOrders.unshift(stackrecord(cmds.join(' '))); + Campaign().set('turnorder',sto.turnorder); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } + break; + + case 'swap': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders.shift(); + state[scriptName].savedTurnOrders.unshift(stackrecord(cmds.join(' '))); + Campaign().set('turnorder',sto.turnorder); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } + break; + + case 'tswap': + case 'tail-swap': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders.pop(); + state[scriptName].savedTurnOrders.push(stackrecord(cmds.join(' '))); + Campaign().set('turnorder',sto.turnorder); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } + break; + + case 'amerge': + case 'apply-merge': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders[0]; + + Campaign().set('turnorder', JSON.stringify( + sorters[state[scriptName].config.sortOption].func( + _.union( + JSON.parse(Campaign().get('turnorder'))||[], + JSON.parse(sto.turnorder)||[] + ), + state[scriptName].config.preserveFirst + ) + )); + + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } + break; + + case 'merge': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders.pop(); + + Campaign().set('turnorder', JSON.stringify( + sorters[state[scriptName].config.sortOption].func( + _.union( + JSON.parse(Campaign().get('turnorder'))||[], + JSON.parse(sto.turnorder)||[] + ), + state[scriptName].config.preserveFirst + ) + )); + + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } + break; + + case 'clear': + state[scriptName].savedTurnOrders=[]; + break; + + default: + case 'list': + stacklist(); + break; + } + } + break; + + case 'promote': + if(!playerIsGM(msg.playerid)){ + return; + } + cmds[1]=Math.max(parseInt(cmds[1],10),1); + if(state[scriptName].bonusStatGroups.length >= cmds[1]) { + if(1 !== cmds[1]) { + workvar=state[scriptName].bonusStatGroups[cmds[1]-1]; + state[scriptName].bonusStatGroups[cmds[1]-1] = state[scriptName].bonusStatGroups[cmds[1]-2]; + state[scriptName].bonusStatGroups[cmds[1]-2] = workvar; + } + + sendChat(scriptName, `/w "${who}" ${_h.outer(helpParts.showBonusStatGroupsConfig(context))}`); + } else { + sendChat('!group-init --promote', `/w "${who}" ` + + '
    '+ + 'Please specify one of the following by number:'+ + _h.outer(helpParts.showBonusStatGroupsConfig(context)) + + '
    ' + ); + } + break; + + case 'del-group': + if(!playerIsGM(msg.playerid)){ + return; + } + cmds[1]=Math.max(parseInt(cmds[1],10),1); + if(state[scriptName].bonusStatGroups.length >= cmds[1]) { + state[scriptName].bonusStatGroups=_.filter(state[scriptName].bonusStatGroups, function(v,k){ + return (k !== (cmds[1]-1)); + }); + + sendChat(scriptName, `/w "${who}" ${_h.outer(helpParts.showBonusStatGroupsConfig(context))}`); + } else { + sendChat('!group-init --del-group', `/w "${who}" ` + + '
    '+ + 'Please specify one of the following by number:'+ + _h.outer(helpParts.showBonusStatGroupsConfig(context)) + + '
    ' + ); + } + break; + + case 'toggle-turnorder': + if(!playerIsGM(msg.playerid)){ + return; + } + if(false !== Campaign().get('initiativepage') ){ + Campaign().set({ + initiativepage: false + }); + } else { + let player = (getObj('player',msg.playerid)||{get: ()=>true}); + let pid = player.get('_lastpage'); + if(!pid){ + pid = Campaign().get('playerpageid'); + } + Campaign().set({ + initiativepage: pid + }); + } + break; + + case 'reroll': + isReroll=true; + if(cmds[1] && cmds[1].match(/^[-+]?\d+(\.\d+)?$/)){ + manualBonus=parseFloat(cmds[1])||0; + } + + ids = JSON.parse(Campaign().get('turnorder')||'[]') + .filter(e=> '-1' !== e.id) + .map(e=>e.id); + + cont=true; + break; + + case 'sort': + if(!playerIsGM(msg.playerid)){ + return; + } + Campaign().set('turnorder', JSON.stringify( + sorters[state[scriptName].config.sortOption].func( + JSON.parse(Campaign().get('turnorder'))||[], + false + ) + )); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + + break; + + case 'adjust': + if(!playerIsGM(msg.playerid)){ + return; + } + if(cmds[1] && cmds[1].match(/^[-+]?\d+(\.\d+)?$/)){ + manualBonus=parseFloat(cmds[1]); + manualBonusMin=parseFloat(cmds[2]); + manualBonusMin=_.isNaN(manualBonusMin)?-10000:manualBonusMin; + + Campaign().set({ + turnorder: JSON.stringify( + _.map(JSON.parse(Campaign().get('turnorder'))||[], function(e){ + if('-1' !== e.id){ + e.pr=Math.max((_.isNaN(parseFloat(e.pr))?0:parseFloat(e.pr))+manualBonus,manualBonusMin).toFixed(state[scriptName].config.maxDecimal); + } + return e; + }) + ) + }); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + } else { + sendChat(scriptName, `/w "${who}" ` + + '
    '+ + 'Not a valid adjustment: '+cmds[1]+''+ + '
    ' + ); + } + break; + + case 'adjust-current': + if(!playerIsGM(msg.playerid)){ + return; + } + if(cmds[1] && cmds[1].match(/^[-+]?\d+(\.\d+)?$/)){ + manualBonus=parseFloat(cmds[1]); + manualBonusMin=parseFloat(cmds[2]); + manualBonusMin=_.isNaN(manualBonusMin)?-10000:manualBonusMin; + + Campaign().set({ + turnorder: JSON.stringify( + _.map(JSON.parse(Campaign().get('turnorder'))||[], function(e,idx){ + if(0===idx && '-1' !== e.id){ + e.pr=Math.max((_.isNaN(parseFloat(e.pr))?0:parseFloat(e.pr))+manualBonus,manualBonusMin).toFixed(state[scriptName].config.maxDecimal); + } + return e; + }) + ) + }); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + } else { + sendChat(scriptName, `/w "${who}" ` + + '
    '+ + 'Not a valid adjustment: '+cmds[1]+''+ + '
    ' + ); + } + break; + + + case 'clear': + if(!playerIsGM(msg.playerid)){ + return; + } + Campaign().set({ + turnorder: '[]', + initiativepage: (state[scriptName].config.autoOpenInit ? false : Campaign().get('initiativepage')) + }); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + break; + + case 'bonus': + if(cmds[1] && cmds[1].match(/^[-+]?\d+(\.\d+)?$/)){ + manualBonus=parseFloat(cmds[1]); + cont=true; + } else { + sendChat(scriptName, `/w "${who}" ` + + '
    '+ + 'Not a valid bonus: '+cmds[1]+''+ + '
    ' + ); + } + break; + + default: + if(!playerIsGM(msg.playerid)){ + return; + } + sendChat(scriptName, `/w "${who}" ` + + '
    '+ + 'Not a valid command: '+cmds[0]+''+ + '
    ' + ); + break; + } + } else { + cont=true; + } + + if(cont) { + if(ids.length) { + await makeRollsForIDs(ids,{isReroll,manualBonus,prev}); + } else { + showHelp(msg.playerid); + } + } + break; + + case '!group-init-config': + if(!playerIsGM(msg.playerid)){ + return; + } + if(_.contains(args,'--help')) { + showHelp(msg.playerid); + return; + } + if(!args.length) { + sendChat('',`/w "${who}" ${helpParts.helpConfig(context)}`); + return; + } + _.each(args,function(a){ + let opt=a.split(/\|/), + omsg=''; + switch(opt.shift()) { + case 'apply-standard-config': + if(standardConfigs[opt[0]]) { + standardConfigs[opt[0]].func(); + sendChat('',`/w "${who}" `+ + '
    '+ + `Now configured for ${standardConfigs[opt[0]].title}`+ + '
    ' + ); + } else { + sendChat('',`/w "${who}" `+ + '
    '+ + '
    Error: Not a valid standard Config: '+opt[0]+'
    '+ + helpParts.showStandardConfigOptions(context)+ + '
    ' + ); + } + + break; + + case 'sort-option': + if(sorters[opt[0]]) { + state[scriptName].config.sortOption=opt[0]; + } else { + omsg='
    Error: Not a valid sort method: '+opt[0]+'
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.sortOptionsConfig(context)+ + '
    ' + ); + break; + + case 'set-die-size': + if(opt[0].match(/^\d+$/)) { + state[scriptName].config.dieSize=parseInt(opt[0],10); + } else { + omsg='
    Error: Not a die size: '+opt[0]+'
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.dieSizeConfig(context)+ + '
    ' + ); + break; + + case 'set-max-decimal': + if(opt[0].match(/^\d+$/)) { + state[scriptName].config.maxDecimal=parseInt(opt[0],10); + } else { + omsg='
    Error: Not a valid decimal count: '+opt[0]+'
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.maxDecimalConfig(context)+ + '
    ' + ); + break; + + + case 'set-dice-count': + if(opt[0].match(/^\d+$/)) { + state[scriptName].config.diceCount=parseInt(opt[0],10); + } else { + omsg='
    Error: Not a valid dice count: '+opt[0]+'
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.diceCountConfig(context)+ + '
    ' + ); + break; + + case 'set-dice-count-attribute': + if(opt[0]) { + state[scriptName].config.diceCountAttribute=opt[0]; + } else { + state[scriptName].config.diceCountAttribute=''; + omsg='
    Cleared Dice Count Attribute.
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.diceCountAttributeConfig(context)+ + '
    ' + ); + break; + + case 'set-dice-mod': + if(opt[0]) { + state[scriptName].config.diceMod=opt[0]; + } else { + state[scriptName].config.diceMod=''; + omsg='
    Cleared Dice Modifiers.
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.diceModConfig(context)+ + '
    ' + ); + break; + + case 'toggle-auto-open-init': + state[scriptName].config.autoOpenInit = !state[scriptName].config.autoOpenInit; + sendChat('',`/w "${who}" `+ + '
    '+ + helpParts.autoOpenInitConfig(context)+ + '
    ' + ); + break; + + case 'toggle-replace-roll': + state[scriptName].config.replaceRoll = !state[scriptName].config.replaceRoll; + sendChat('',`/w "${who}" `+ + '
    '+ + helpParts.replaceRollConfig(context)+ + '
    ' + ); + break; + + case 'toggle-preserve-first': + state[scriptName].config.preserveFirst = !state[scriptName].config.preserveFirst; + sendChat('',`/w "${who}" `+ + '
    '+ + helpParts.preserveFirstConfig(context)+ + '
    ' + ); + break; + + case 'toggle-check-for-no-config': + state[scriptName].config.checkForNoConfig = !state[scriptName].config.checkForNoConfig; + sendChat('',`/w "${who}" `+ + '
    '+ + helpParts.checkForNoConfigConfig(context)+ + '
    ' + ); + break; + + case 'set-announcer': + if(announcers[opt[0]]) { + state[scriptName].config.announcer=opt[0]; + } else { + omsg='
    Error: Not a valid announcer: '+opt[0]+'
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.announcerConfig(context)+ + '
    ' + ); + break; + + case 'set-roller': + if(rollers[opt[0]]) { + state[scriptName].config.rollType=opt[0]; + } else { + omsg='
    Error: Not a valid roller: '+opt[0]+'
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.rollerConfig(context)+ + '
    ' + ); + break; + + default: + sendChat('',`/w "${who}" `+ + '
    Unsupported Option:
    '+a+'' + ); + } + + }); + + break; + } + +}; + + +const registerEventHandlers = function() { + on('chat:message', handleInput); +}; + +on("ready",() => { + checkInstall(); + registerEventHandlers(); +}); + +return { + ObserveTurnOrderChange: observeTurnOrderChange, + RollForTokenIDs: rollForTokenIDsExternal +}; + +})(); + + +{try{throw new Error('');}catch(e){API_Meta.GroupInitiative.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.GroupInitiative.offset);}} diff --git a/GroupInitiative/GroupInitiative.js b/GroupInitiative/GroupInitiative.js index 342126e193..4fb6af39aa 100644 --- a/GroupInitiative/GroupInitiative.js +++ b/GroupInitiative/GroupInitiative.js @@ -8,9 +8,9 @@ API_Meta.GroupInitiative={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; const GroupInitiative = (() => { // eslint-disable-line no-unused-vars const scriptName = "GroupInitiative"; - const version = '0.9.37'; + const version = '0.9.39'; API_Meta.GroupInitiative.version = version; - const lastUpdate = 1734981538; + const lastUpdate = 1735335074; const schemaVersion = 1.3; const isString = (s)=>'string'===typeof s || s instanceof String; @@ -360,7 +360,8 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars TOKEN: 'token', CHARACTER: 'character', BONUS: 'bonus', - FILTER: 'filter' + FILTER: 'filter', + ROLLADJ: 'roll-adjustment' }; const statAdjustments = { @@ -369,6 +370,31 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars func: async (t,c,v) => c.get('charactersheetname') === v, desc: 'Forces calculations only for specific character sheets.' }, + 'filter-status': { + type: adjustments.FILTER, + func: async (t,c,v) => t.get(`status_${v}`) !== false, + desc: 'Forces calculations only for tokens with a given status marker.' + }, + 'filter-tooltip': { + type: adjustments.FILTER, + func: async (t,c,v) => t.get(`tooltip`).toLowerCase().split(/[^a-zA-Z0-9:#|-]+/).includes(v), + desc: 'Forces calculations only for tokens with a tooltip containing the given word.' + }, + 'roll-die-count': { + type: adjustments.ROLLADJ, + func: async (t,c,v) => ({die_count:Number(v)||state[scriptName].config.diceCount}), + desc: 'Forces the number of dice rolled to this value for the matching tokens.' + }, + 'roll-die-size': { + type: adjustments.ROLLADJ, + func: async (t,c,v) => ({die_size:Number(v)||state[scriptName].config.dieSize}), + desc: 'Forces the size of the die rolled to this value for the matching tokens.' + }, + 'roll-die-mod': { + type: adjustments.ROLLADJ, + func: async (t,c,v) => ({die_mod:v}), + desc: 'Applies the given die mod to the roll.' + }, 'bonus': { type: adjustments.BONUS, func: async (v) => Number(v), @@ -447,16 +473,24 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars }; - const buildInitDiceExpression = function(s){ - let stat=(''!== state[scriptName].config.diceCountAttribute && s.character && getAttrByName(s.character.id, state[scriptName].config.diceCountAttribute, 'current')); - if(stat ) { - stat = (_.isString(stat) ? stat : stat+''); - if('0' !== stat) { - stat = stat.replace(/@\{([^|]*?|[^|]*?\|max|[^|]*?\|current)\}/g, '@{'+(s.character.get('name'))+'|$1}'); - return '('+stat+')d'+state[scriptName].config.dieSize; - } - } - return state[scriptName].config.diceCount+'d'+state[scriptName].config.dieSize+state[scriptName].config.diceMod; + const buildInitDiceExpression = function(s,a){ + let diceCount = state[scriptName].config.diceCount; + let diceSize = a?.die_size || state[scriptName].config.dieSize; + let diceMod = a?.die_mod || state[scriptName].config.diceMod || ''; + + if(a.hasOwnProperty('die_count')){ + diceCount = a.die_count; + } else { + let stat=(''!== state[scriptName].config.diceCountAttribute && s.character && getAttrByName(s.character.id, state[scriptName].config.diceCountAttribute, 'current')); + if(stat ) { + stat = (_.isString(stat) ? stat : stat+''); + if('0' !== stat) { + stat = stat.replace(/@\{([^|]*?|[^|]*?\|max|[^|]*?\|current)\}/g, '@{'+(s.character.get('name'))+'|$1}'); + diceCount = `(${stat})`; + } + } + } + return `${diceCount}d${diceSize}${diceMod}`; }; const rollers = { @@ -464,8 +498,8 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars mutator: function(l){ return l; }, - func: function(s){ - return buildInitDiceExpression(s); + func: function(s,a){ + return buildInitDiceExpression(s,a); }, desc: 'Sets the initiative individually for each member of the group.' }, @@ -481,8 +515,8 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars return min; }); }, - func: function(s){ - return buildInitDiceExpression(s); + func: function(s,a){ + return buildInitDiceExpression(s,a); }, desc: 'Sets the initiative to the lowest of all initiatives rolled for the group.' }, @@ -493,8 +527,8 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars return mean; }); }, - func: function(s){ - return buildInitDiceExpression(s); + func: function(s,a){ + return buildInitDiceExpression(s,a); }, desc: 'Sets the initiative to the mean (average) of all initiatives rolled for the group.' }, @@ -693,6 +727,21 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars 'filter-sheet': { 'background-color': '#996600' }, + 'filter-status': { + 'background-color': '#993300' + }, + 'filter-tooltip': { + 'background-color': '#cc6600' + }, + 'roll-die-count': { + 'background-color': '#0066cc' + }, + 'roll-die-size': { + 'background-color': '#0033cc' + }, + 'roll-die-mod': { + 'background-color': '#0099cc' + }, 'computed': { 'background-color': '#a61c00' }, @@ -1205,8 +1254,10 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars const findInitiativeBonus = async (charObj, token) => { let bonus = ''; + let rolladj = {}; const GetBonuses = async (group) => { + let cachedRollAdj = {}; let bonusPromises = group.map( async (details) => { let stat=getAttrByName(charObj.id, details.attribute, details.type||'current'); @@ -1256,6 +1307,12 @@ const findInitiativeBonus = async (charObj, token) => { memo = 0; // necessary to select this stat group } break; + case adjustments.ROLLADJ:{ + let adj = await func(token,charObj,details.attribute); + cachedRollAdj = {...cachedRollAdj,...adj}; + memo = 0; // necessary to select this stat group + } + break; } } return memo; @@ -1264,11 +1321,14 @@ const findInitiativeBonus = async (charObj, token) => { bonus = await Promise.all(bonusPromises); + if(_.contains(bonus,undefined) || _.contains(bonus,null) || _.contains(bonus,NaN)) { bonus=''; return false; } bonus = bonus.join('+'); + rolladj = cachedRollAdj; + return true; }; @@ -1280,7 +1340,7 @@ const findInitiativeBonus = async (charObj, token) => { break; } } - return bonus; + return {bonus,rolladj}; }; const rollForTokenIDsExternal = (ids,options) => { @@ -1316,14 +1376,14 @@ const makeRollsForIDs = async (ids,options={}) => { .map(async g => { g.roll=[]; - let bonus = await findInitiativeBonus(g.character||{},g.token); + let {bonus,rolladj} = await findInitiativeBonus(g.character||{},g.token); bonus = (isString(bonus) ? (bonus.trim().length ? bonus : '0') : bonus); g.roll.push(bonus); if(options.manualBonus){ g.roll.push( options.manualBonus ); } - g.roll.push( initFunc(g) ); + g.roll.push( initFunc(g,rolladj) ); return g; }); diff --git a/GroupInitiative/script.json b/GroupInitiative/script.json index db3e2bd356..ec1d4f084a 100644 --- a/GroupInitiative/script.json +++ b/GroupInitiative/script.json @@ -1,6 +1,6 @@ { "name": "GroupInitiative", - "version": "0.9.38", + "version": "0.9.39", "description": "Rolls initiative for the selected tokens and adds them to the turn order if they don't have a turn yet.\r\rThe calculation of initiative is handled by the combination of Roller (See **Roller Options** below) and a Bonus. The Bonus is determined based on an ordered list of Stat Groups (See **Bonus Stat Groups** below). Stat Groups are evaluated in order. The bonus computed by the first Stat Group for which all attributes exist and have a numeric value is used. This allows you to have several Stat Groups that apply to different types of characters. In practice you will probably only have one, but more are there if you need them.\r\r\r## Commands\r\r```!group-init ```\r\rThis command uses the configured Roller to determine the initiative order for all selected tokens.\r\r```!group-init --ids [ ...]```\r\rThis command uses the configured Roller to determine the initiative order for all tokens whose ids are specified.\r\r```!group-init --help```\r\rThis command displays the help and configuration options, as well as the currently configured groups.\r\r```!group-init --promote ```\r\rIncreases the importance the specified Bonus Stat Group.\r\rThis command requires 1 parameter:\r\r* `index` -- The numeric index of the Bonus Stat Group to promote.\r\r`!group-init --del-group `\r\rDeletes the specified Bonus Stat Group.\r\rThis command requires 1 parameter:\r\r* `index` -- The numeric index of the Bonus Stat Group to delete.\r\r```!group-init --add-group -- [--] ]> [-- [--] ]> ...]```\r\rAdds a new Bonus Stat Group to the end of the list. Each adjustment operation can be followed by another adjustment operation, but eventually must end in an attriute name. Adjustment operations are applied to the result of the adjustment operations that follow them.\r\rFor example: `--Bounded:-2:2 --Stat-DnD wisdom|max` would first computer the DnD Stat bonus for the max field of the wisdom attribute, then bound it between `-2` and `+2`.\r\rThis command takes multiple parameters:\r\r* `adjustment` -- One of the Stat Adjustment Options.\r* `attribute name` -- The name of an attribute. You can specify `|max` or `|current` on the end to target those specific fields (defaults to `|current`).\r\r```!group-init --reroll```\r\rRerolls all the tokens in the turn order as if they were selected when you executed the bare `!group-init` command.\r\r```!group-init --clear```\r\rRemoves all tokens from the turn order. If Auto Open Init is enabled it will also close the turn order box.\r\r## Roller Options\r\r* `Least-All-Roll` -- Sets the initiative to the lowest of all initiatives rolled for the group.\r* `Mean-All-Roll` -- Sets the initiative to the mean (average) of all initiatives rolled for the group.\r* `Individual-Roll` -- Sets the initiative individually for each member of the group.\r* `Constant-By-Stat` -- Sets the initiative individually for each member of the group to their bonus with no roll.\r\r## Stat Adjustment Options\r\r* `Stat-DnD` -- Calculates the bonus as if the value were a DnD Stat.\r* `Bare` -- No Adjustment.\r* `Floor` -- Rounds down to the nearest integer.\r* `Tie-Breaker` -- Adds the accompanying attribute as a decimal (`0.01`)\r* `Ceiling` -- Rounds up to the nearest integer.\r* `Bounded` -- **DEPREICATED** - will not work with expresions.", "authors": "The Aaron", "roll20userid": "104025", @@ -37,6 +37,7 @@ "0.9.34", "0.9.35", "0.9.36", - "0.9.37" + "0.9.37", + "0.9.38" ] } \ No newline at end of file From 5802267f1f733266968557a09a640116a9d5635e Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Sat, 28 Dec 2024 18:58:14 -0600 Subject: [PATCH 2/7] Updated GroupInitiative to v0.9.40 --- GroupInitiative/0.9.40/GroupInitiative.js | 2263 +++++++++++++++++++++ GroupInitiative/GroupInitiative.js | 215 +- GroupInitiative/script.json | 5 +- 3 files changed, 2412 insertions(+), 71 deletions(-) create mode 100644 GroupInitiative/0.9.40/GroupInitiative.js diff --git a/GroupInitiative/0.9.40/GroupInitiative.js b/GroupInitiative/0.9.40/GroupInitiative.js new file mode 100644 index 0000000000..9284739d73 --- /dev/null +++ b/GroupInitiative/0.9.40/GroupInitiative.js @@ -0,0 +1,2263 @@ +// Github: https://github.com/shdwjk/Roll20API/blob/master/GroupInitiative/GroupInitiative.js +// By: The Aaron, Arcane Scriptomancer +// Contact: https://app.roll20.net/users/104025/the-aaron +var API_Meta = API_Meta||{}; //eslint-disable-line no-var +API_Meta.GroupInitiative={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; +{try{throw new Error('');}catch(e){API_Meta.GroupInitiative.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-6);}} + +const GroupInitiative = (() => { // eslint-disable-line no-unused-vars + + const scriptName = "GroupInitiative"; + const version = '0.9.40'; + API_Meta.GroupInitiative.version = version; + const lastUpdate = 1735433392; + const schemaVersion = 1.3; + + const isString = (s)=>'string'===typeof s || s instanceof String; + const isFunction = (f)=>'function'===typeof f; + const getComputedProxy = ("undefined" !== typeof getComputed) + ? async (...a) => await getComputed(...a) + : async ()=>{} + ; + + + let observers = { + turnOrderChange: [] + }; + let validCharacterSheets = []; + + const sorters = { + 'None': { + desc: `No sorting is applied.`, + func: (to)=>to + }, + 'Ascending': { + desc: `Sorts the Turn Order from highest to lowest`, + func: (to,preserveFirst) => { + let first = to[0]; + const sorter_asc = (a, b) => a.pr - b.pr; + let newTo = to.sort(sorter_asc); + if(preserveFirst){ + let idx = newTo.findIndex(e=>e===first); + newTo = [...newTo.slice(idx),...newTo.slice(0,idx)]; + } + return newTo; + } + }, + 'Descending': { + desc: `Sorts the Turn Order from lowest to highest.`, + func: (to,preserveFirst) => { + let first = to[0]; + const sorter_desc = (a, b) => b.pr - a.pr; + let newTo = to.sort(sorter_desc); + if(preserveFirst){ + let idx = newTo.findIndex(e=>e===first); + newTo = [...newTo.slice(idx),...newTo.slice(0,idx)]; + } + return newTo; + } + } + }; + + const ch = (c) => { + const entities = { + '<' : 'lt', + '>' : 'gt', + '&' : 'amp', + "'" : '#39', + '@' : '#64', + '{' : '#123', + '|' : '#124', + '}' : '#125', + '[' : '#91', + ']' : '#93', + '"' : 'quot', + '*' : 'ast', + '/' : 'sol', + ' ' : 'nbsp' + }; + + if( entities.hasOwnProperty(c) ){ + return `&${entities[c]};`; + } + return ''; + }; + + const standardConfigs = { + 'dnd2024byroll20': { + title: `D${ch('&')}D 2024 by Roll20`, + desc: `This is the Roll20 provided 2024 edition character sheet build on Beacon Technology. Note: This sheet requires using the Experimental Mod (API) Server.`, + func: ()=>{ + state[scriptName].bonusStatGroups = [ + [ + { + adjustments:[ + "filter-sheet" + ], + attribute: 'dnd2024byroll20' + }, + { + adjustments: [ + "computed" + ], + attribute: "initiative_bonus" + }, + { + adjustments: [ + "computed" + ], + attribute: "init_tiebreaker" + } + ], + ...(state[scriptName].bonusStatGroups||[]) + ]; + state[scriptName].dieSize = 20; + state[scriptName].diceCount = 1; + } + }, + 'dnd5eogl': { + title: `D${ch('&')}D 5E by Roll20`, + desc: `This is the standard Roll20 provided 5th edition character sheet. It is the one used by default in most 5th edition Modules and all Official Dungeons and Dragons Modules and Addons.`, + func: ()=>{ + state[scriptName].bonusStatGroups = [ + [ + { + adjustments:[ + "filter-sheet" + ], + attribute: 'ogl5e' + }, + { + attribute: "initiative_bonus" + }, + { + adjustments: [ + "tie-breaker" + ], + attribute: "initiative_bonus" + } + ], + ...(state[scriptName].bonusStatGroups||[]) + ]; + state[scriptName].dieSize = 20; + state[scriptName].diceCount = 1; + } + }, + 'dnd5eshaped2': { + title: `D${ch('&')}D 5e Shaped Sheet`, + desc: `This is the high-powered and very customizable Dungeons and Dragons 5e Shaped Sheet. You'll know you're using it because you had to manually install it (probably) and it has a nice heart shaped hit-point box. This is not the default sheet for DnD Modules on Roll20, if you aren't sure if you're using this sheet, you aren't.`, + func: ()=>{ + state[scriptName].bonusStatGroups = [ + [ + { + "attribute": "initiative_formula" + } + ] + ]; + state[scriptName].dieSize = 20; + state[scriptName].diceCount = 0; + } + }, + 'stargaterpgofficial': { + title: `Stargate RPG by Wyvern Gaming`, + desc: `This will configure GroupInitiative to work with the Official Stargate RPG sheet. It adds Initiative & Moxie options for rolling, with Initiative enabled by default. You can swap between using Initiative and Moxie by issuing the command "!group-init --promote 2" in the chat, with subsequent calls again reversing the selection.`, + func: ()=>{ + state[scriptName].bonusStatGroups = [ + [ + { + "attribute": "init" + } + ], + [ + { + "attribute": "moxie" + } + ] + ]; + state[scriptName].dieSize = 20; + state[scriptName].diceCount = 1; + } + } + }; + + const HE = (() => { + const esRE = (s) => s.replace(/(\\|\/|\[|\]|\(|\)|\{|\}|\?|\+|\*|\||\.|\^|\$)/g,'\\$1'); + const e = (s) => `&${s};`; + const entities = { + '<' : e('lt'), + '>' : e('gt'), + "'" : e('#39'), + '@' : e('#64'), + '{' : e('#123'), + '|' : e('#124'), + '}' : e('#125'), + '[' : e('#91'), + ']' : e('#93'), + '"' : e('quot') + }; + const re = new RegExp(`(${Object.keys(entities).map(esRE).join('|')})`,'g'); + return (s) => s.replace(re, (c) => (entities[c] || c) ); + })(); + + + const observeTurnOrderChange = function(handler){ + if(handler && _.isFunction(handler)){ + observers.turnOrderChange.push(handler); + } + }; + + const notifyObservers = function(event,obj,prev){ + _.each(observers[event],function(handler){ + handler(obj,prev); + }); + }; + + const formatDieRoll = function(rollData) { + const critFail = rollData.rolls.reduce((m,r)=> m || r.rolls.includes(1),false); + const critSuccess = rollData.rolls.reduce((m,r)=> m || r.rolls.includes(r.sides),false); + const highlight = ( (critFail && critSuccess) + ? '#4A57ED' + : ( critFail + ? '#B31515' + : ( critSuccess + ? '#3FB315' + : '#FEF68E' + ) + ) + ); + + const HH = (a)=>HE(HE(a)); + const HDie = (n,m) => n===m ? '#00ff00' : (n===1 ? '#ff0000' : '#ffffff'); + const HR = (n,m) => `${n}`; + const HDiscard = () => '#999999'; + const HD = (n,m) => `${n}`; + const HRolls = rollData.rolls.reduce((m,rs)=>({ + r:[...m.r,...rs.rolls.map(n=>HR(n,rs.sides))], + d:[...m.d,...rs.discards.map(n=>HD(n,rs.sides))] + }),{r:[],d:[]}); + + const b = (text)=>`${text}`; + const block = (text,style)=>`${text}`; + const LabelBlock = (label) => block(label,`font-size:1em;`); + const FormulaBlock = (formula,bonus) => block(`${b(formula)} ${bonus}`,`font-size:.8em;`); + const ResultBlock = (result) => block(block(result,`display:inline-block;background:#fef68e;color:#404040;font-weight:bold;padding: .1em .2em; border:3px solid ${highlight};border-radius:.5em;min-width:2em;font-size:2em;`)); + const RollsBlock = (rolls,bonus) => block(`${b('(')}${[...rolls.r,...rolls.d].join(',')}${b(')')} ${bonus}`,`font-size:1.5em;`); + const popup = (label,formula,bonus,result,rolls) => block(`${LabelBlock(label)}${FormulaBlock(formula,bonus)}${ResultBlock(result)}${RollsBlock(rolls,bonus)}`,`font-color:white`); + + let bonus = `${(rollData.bonus>=0 ? '+' :'-')} ${b(Math.abs(rollData.bonus))}`; + let popText = popup( + rollData.source.label, + rollData.source.formula, + bonus, + rollData.total, + HRolls); + + return ''+ + rollData.total+ + ''; + }; + + const buildAnnounceGroups = function(l) { + let groupColors = { + npc: '#eef', + character: '#efe', + gmlayer: '#aaa' + }; + return _.reduce(l,function(m,s){ + let type= ('gmlayer' === s.token.get('layer') ? + 'gmlayer' : ( + (s.character && _.filter(s.character.get('controlledby').split(/,/),function(c){ + return 'all' === c || ('' !== c && !playerIsGM(c) ); + }).length>0) || false ? + 'character' : + 'npc' + )); + if('graphic'!==s.token.get('type') || 'token' !==s.token.get('subtype')) { + return m; + } + m[type].push('
    '+ + '
    '+ + ''+ + ((s.token && s.token.get('name')) || (s.character && s.character.get('name')) || '(Creature)')+ + '
    '+ + '
    '+ + formatDieRoll(s.rollResults)+ + '
    '+ + '
    '+ + '
    '); + return m; + },{npc:[],character:[],gmlayer:[]}); + }; + + const announcers = { + 'None': { + desc: `Shows nothing in chat when a roll is made.`, + func: () => {} + }, + 'Hidden': { + desc: `Whispers all rolls to the GM, regardless of who controls the tokens.`, + func: (l) => { + let groups = buildAnnounceGroups(l); + if(groups.npc.length || groups.character.length || groups.gmlayer.length){ + sendChat(scriptName,'/w gm '+ + '
    '+ + groups.character.join('')+ + groups.npc.join('')+ + groups.gmlayer.join('')+ + '
    '+ + '
    '); + } + } + }, + 'Partial': { + desc: `Character rolls are shown in chat (Player controlled tokens), all others are whispered to the GM.`, + func: (l) => { + let groups = buildAnnounceGroups(l); + if(groups.character.length){ + sendChat(scriptName,'/direct '+ + '
    '+ + groups.character.join('')+ + '
    '+ + '
    '); + } + if(groups.npc.length || groups.gmlayer.length){ + sendChat(scriptName,'/w gm '+ + '
    '+ + groups.npc.join('')+ + groups.gmlayer.join('')+ + '
    '+ + '
    '); + } + } + }, + 'Visible': { + desc: `Rolls for tokens on the Objects Layer are shown to all in chat. Tokens on the GM Layer have their rolls whispered to the GM. `, + func: (l) => { + let groups=buildAnnounceGroups(l); + if(groups.npc.length || groups.character.length){ + sendChat(scriptName,'/direct '+ + '
    '+ + groups.character.join('')+ + groups.npc.join('')+ + '
    '+ + '
    '); + } + if(groups.gmlayer.length){ + sendChat(scriptName,'/w gm '+ + '
    '+ + groups.gmlayer.join('')+ + '
    '+ + '
    '); + } + } + } + }; + + const adjustments = { + STAT: 'stat', + COMPUTED: 'computed', + TOKEN: 'token', + CHARACTER: 'character', + BONUS: 'bonus', + FILTER: 'filter', + ROLLADJ: 'roll-adjustment', + LABEL: 'label' + }; + + const statAdjustments = { + 'filter-sheet': { + type: adjustments.FILTER, + func: async (t,c,v) => c?.get('charactersheetname') === v, + desc: 'Forces calculations only for specific character sheets.' + }, + 'filter-status': { + type: adjustments.FILTER, + func: async (t,c,v) => t.get(`status_${v}`) !== false, + desc: 'Forces calculations only for tokens with a given status marker.' + }, + 'filter-tooltip': { + type: adjustments.FILTER, + func: async (t,c,v) => t.get(`tooltip`).toLowerCase().split(/[^a-zA-Z0-9:#|-]+/).includes(v), + desc: 'Forces calculations only for tokens with a tooltip containing the given word.' + }, + 'roll-die-count': { + type: adjustments.ROLLADJ, + func: async (t,c,v) => ({die_count:Number(v)||state[scriptName].config.diceCount}), + desc: 'Forces the number of dice rolled to this value for the matching tokens.' + }, + 'roll-die-size': { + type: adjustments.ROLLADJ, + func: async (t,c,v) => ({die_size:Number(v)||state[scriptName].config.dieSize}), + desc: 'Forces the size of the die rolled to this value for the matching tokens.' + }, + 'roll-die-mod': { + type: adjustments.ROLLADJ, + func: async (t,c,v) => ({die_mod:v}), + desc: 'Applies the given die mod to the roll.' + }, + 'label': { + type: adjustments.LABEL, + func: async (t,c,v) => ({label:v}), + desc: 'Attaches a label to the rule for use in reporting.' + }, + 'bonus': { + type: adjustments.BONUS, + func: async (v) => Number(v), + desc: 'Adds a raw number.' + }, + 'computed': { + type: adjustments.COMPUTED, + func: async (c,v) => { + return await getComputedProxy({characterId:c.id,property:v}); + }, + desc: 'Reads the adjustment from a Beacon Sheet Computed.' + }, + 'stat-dnd': { + type: adjustments.STAT, + func: async function(v) { + return 'floor((('+v+')-10)/2)'; + }, + desc: 'Calculates the bonus as if the value were a DnD Stat.' + }, + 'negative': { + type: adjustments.STAT, + func: async function(v) { + return '(-1*('+v+'))'; + }, + desc: 'Returns the negative version of the stat' + }, + 'bare': { + type: adjustments.STAT, + func: async function(v) { + return v; + }, + desc: 'No Adjustment.' + }, + 'floor': { + type: adjustments.STAT, + func: async function(v) { + return 'floor('+v+')'; + }, + desc: 'Rounds down to the nearest integer.' + }, + 'tie-breaker': { + type: adjustments.STAT, + func: async function(v) { + return '(0.01*('+v+'))'; + }, + desc: 'Adds the accompanying attribute as a decimal (0.01)' + }, + 'ceiling': { + type: adjustments.STAT, + func: async function(v) { + return 'ceil('+v+')'; + }, + desc: 'Rounds up to the nearest integer.' + }, + 'token_bar': { + type: adjustments.TOKEN, + func: async function(t,idx) { + return parseFloat(t.get(`bar${idx}_value`))||0; + }, + desc: 'Takes the bonus from the numbered bar on the token. Use 1, 2, or 3. Defaults to 0 in the absense of a number.' + }, + 'token_bar_max': { + type: adjustments.TOKEN, + func: async function(t,idx) { + return parseFloat(t.get(`bar${idx}_max`))||0; + }, + desc: 'Takes the bonus from the max value of the numbered bar on the token. Use 1, 2, or 3. Defaults to 0 in the absense of a number.' + }, + 'token_aura': { + type: adjustments.TOKEN, + func: async function(t,idx) { + return parseFloat(t.get(`aura${idx}_radius`))||0; + }, + desc: 'Takes the bonus from the radius of the token aura. Use 1 or 2. Defaults to 0 in the absense of a number.' + } + + }; + + const buildInitDiceExpression = function(s,a){ + let diceCount = state[scriptName].config.diceCount; + let diceSize = a?.die_size || state[scriptName].config.dieSize; + let diceMod = a?.die_mod || state[scriptName].config.diceMod || ''; + + if(a.hasOwnProperty('die_count')){ + diceCount = a.die_count; + } else { + let stat=(''!== state[scriptName].config.diceCountAttribute && s.character && getAttrByName(s.character.id, state[scriptName].config.diceCountAttribute, 'current')); + if(stat ) { + stat = (_.isString(stat) ? stat : stat+''); + if('0' !== stat) { + stat = stat.replace(/@\{([^|]*?|[^|]*?\|max|[^|]*?\|current)\}/g, '@{'+(s.character.get('name'))+'|$1}'); + diceCount = `(${stat})`; + } + } + } + return `${diceCount}d${diceSize}${diceMod}`; + }; + + const rollers = { + 'Individual-Roll': { + mutator: function(l){ + return l; + }, + func: function(s,a){ + return buildInitDiceExpression(s,a); + }, + desc: 'Sets the initiative individually for each member of the group.' + }, + 'Least-All-Roll':{ + mutator: function(l){ + let min=_.reduce(l,function(m,r){ + if(!m || (r.total < m.total)) { + return r; + } + return m; + },false); + return _.times(l.length, function(){ + return min; + }); + }, + func: function(s,a){ + return buildInitDiceExpression(s,a); + }, + desc: 'Sets the initiative to the lowest of all initiatives rolled for the group.' + }, + 'Mean-All-Roll':{ + mutator: function(l){ + let mean = l[Math.round((l.length/2)-0.5)]; + return _.times(l.length, function(){ + return mean; + }); + }, + func: function(s,a){ + return buildInitDiceExpression(s,a); + }, + desc: 'Sets the initiative to the mean (average) of all initiatives rolled for the group.' + }, + 'Constant-By-Stat': { + mutator: function(l){ + return l; + }, + func: function(){ + return '0'; + }, + desc: 'Sets the initiative individually for each member of the group to their bonus with no roll.' + } + }; + + const assureHelpHandout = (create = false) => { + const helpIcon = "https://s3.amazonaws.com/files.d20.io/images/295769190/Abc99DVcre9JA2tKrVDCvA/thumb.png?1658515304"; + + // find handout + let props = {type:'handout', name:`Help: ${scriptName}`}; + let hh = findObjs(props)[0]; + if(!hh) { + hh = createObj('handout',Object.assign(props, {avatar: helpIcon})); + create = true; + } + if(create || version !== state[scriptName].lastHelpVersion){ + hh.set({ + notes: helpParts.helpDoc({who:'handout',playerid:'handout'}) + }); + state[scriptName].lastHelpVersion = version; + log(' > Updating Help Handout to v'+version+' <'); + } + }; + + const buildCharacterSheetList = () => { + validCharacterSheets = [...new Set(findObjs({type:"character"}).map(c=>c.get('charactersheetname')))]; + }; + + + const checkForNoConfig = () => { + if(state[scriptName].config.checkForNoConfig && (0 === state[scriptName].bonusStatGroups.length)){ + setTimeout(()=>{ + sendChat(scriptName,`/w gm ${helpParts.helpNoConfig()}`); + },1000); + } + }; + + const checkInstall = function() { + log(`-=> ${scriptName} v${version} <=- [${new Date(lastUpdate*1000)}]`); + + if( ! _.has(state,scriptName) || state[scriptName].version !== schemaVersion) { + log(' > Updating Schema to v'+schemaVersion+' <'); + switch(state[scriptName] && state[scriptName].version) { + case 0.5: + state[scriptName].replaceRoll = false; + /* break; // intentional dropthrough */ /* falls through */ + + case 0.6: + state[scriptName].config = { + rollType: state[scriptName].rollType, + replaceRoll: state[scriptName].replaceRoll, + dieSize: 20, + autoOpenInit: true, + sortOption: 'Descending' + }; + delete state[scriptName].replaceRoll; + delete state[scriptName].rollType; + /* break; // intentional dropthrough */ /* falls through */ + + case 0.7: + state[scriptName].config.announcer = 'Partial'; + /* break; // intentional dropthrough */ /* falls through */ + + case 0.8: + state[scriptName].config.diceCount = 1; + state[scriptName].config.maxDecimal = 2; + /* break; // intentional dropthrough */ /* falls through */ + + case 0.9: + state[scriptName].config.diceCountAttribute = ''; + /* break; // intentional dropthrough */ /* falls through */ + + case 0.10: + if(_.has(state[scriptName].config,'dieCountAttribute')){ + delete state[scriptName].config.dieCountAttribute; + state[scriptName].config.diceCountAttribute = ''; + } + if(_.has(state[scriptName].config,'dieCount')){ + delete state[scriptName].config.dieCount; + state[scriptName].config.diceCount = 1; + } + /* break; // intentional dropthrough */ /* falls through */ + + case 1.0: + state[scriptName].savedTurnOrders =[]; + /* break; // intentional dropthrough */ /* falls through */ + + case 1.1: + state[scriptName].config.diceMod=''; + /* break; // intentional dropthrough */ /* falls through */ + + case 1.2: + state[scriptName].lastHelpVersion=version; + state[scriptName].config.checkForNoConfig = true; + /* break; // intentional dropthrough */ /* falls through */ + + case 1.3: + state[scriptName].config.preserveFirst=false; + /* break; // intentional dropthrough */ /* falls through */ + + case 'UpdateSchemaVersion': + state[scriptName].version = schemaVersion; + break; + + default: + state[scriptName] = { + version: schemaVersion, + lastHelpVersion: version, + bonusStatGroups: [], + savedTurnOrders: [], + config: { + rollType: 'Individual-Roll', + replaceRoll: false, + dieSize: 20, + diceCount: 1, + maxDecimal: 2, + diceCountAttribute: '', + diceMod: '', + checkForNoConfig: true, + autoOpenInit: true, + sortOption: 'Descending', + preserveFirst: true, + announcer: 'Partial' + } + }; + break; + } + } + assureHelpHandout(); + buildCharacterSheetList(); + checkForNoConfig(); + }; + + const S = { + + button: { + 'border': '1px solid #cccccc', + 'border-radius': '.5em', + 'background-color': '#006dcc', + 'margin': '0 .1em', + 'font-weight': 'bold', + 'padding': '.1em 1em', + 'color': 'white' + }, + buttonCompact: { + 'border': '1px solid #cccccc', + 'border-radius': '.5em', + 'background-color': '#006dcc', + 'margin': '0 .1em', + 'font-weight': 'bold', + 'padding': '.1em .25em', + 'color': 'white' + }, + bubble: { + 'display': 'inline-block', + 'border': '1px solid #999', + 'border-radius': '1em', + 'padding': '.1em .5em', + 'font-weight': 'bold', + 'background-color': '#009688', + 'color': 'white' + }, + adj: { + default: { + 'display': 'inline-block', + 'border': '1px solid #999', + 'border-radius': '.25em', + 'padding': '.1em .25em', + 'font-weight': 'bold', + 'background-color': '#009688', + 'color': 'white', + 'margin':'.25em' + }, + 'negative': { + 'background-color': 'white', + 'color': '#009688' + }, + 'floor': { + 'background-color': '#274e13' + }, + 'ceiling': { + 'background-color': '#3c78d8' + }, + 'stat-dnd': { + 'background-color': '#990099' + }, + 'filter-sheet': { + 'background-color': '#996600' + }, + 'filter-status': { + 'background-color': '#993300' + }, + 'filter-tooltip': { + 'background-color': '#cc6600' + }, + 'roll-die-count': { + 'background-color': '#0066cc' + }, + 'roll-die-size': { + 'background-color': '#0033cc' + }, + 'roll-die-mod': { + 'background-color': '#0099cc' + }, + 'label': { + 'color': '#000000', + 'background-color': '#ffff00' + }, + 'computed': { + 'background-color': '#a61c00' + }, + 'bonus': { + 'background-color': '#f1c232', + 'color': '#555500' + }, + 'token_bar': { + 'background-color': '#1c4587' + }, + 'token_bar_max': { + 'background-color': '#674ea7' + }, + 'token_aura': { + 'background-color': '#a64d79' + } + + }, + configRow: { + 'border': '1px solid #ccc;', + 'border-radius': '.2em;', + 'background-color': 'white;', + 'margin': '0 1em;', + 'padding': '.1em .3em;' + }, + bsgRow:{ + 'position': 'relative', + 'padding': '.5em 4em .5em .5em', + 'margin-bottom': '.5em' + }, + oe: [ + { 'background-color': '#eeffee' }, + { 'background-color': '#eeeeff' } + ], + block: { + 'border': '1px solid #ff0000' + } + }; + + const css = (rules) => `style="${Object.keys(rules).map(k=>`${k}:${rules[k]};`).join('')}"`; + + const _h = { + outer: (...o) => `
    ${o.join(' ')}
    `, + title: (t,v) => `
    ${t} v${v}
    `, + subhead: (...o) => `${o.join(' ')}`, + minorhead: (...o) => `${o.join(' ')}`, + optional: (...o) => `${ch('[')}${o.join(` ${ch('|')} `)}${ch(']')}`, + required: (...o) => `${ch('<')}${o.join(` ${ch('|')} `)}${ch('>')}`, + header: (...o) => `
    ${o.join(' ')}
    `, + section: (s,...o) => `${_h.subhead(s)}${_h.inset(...o)}`, + paragraph: (...o) => `

    ${o.join(' ')}

    `, + items: (o) => o.map(i=>`
  • ${i}
  • `).join(''), + ol: (...o) => `
      ${_h.items(o)}
    `, + ul: (...o) => `
      ${_h.items(o)}
    `, + block: (...o) => `
    ${o.join(' ')}
    `, + grid: (...o) => `
    ${o.join('')}
    `, + cell: (o) => `
    ${o}
    `, + inset: (...o) => `
    ${o.join(' ')}
    `, + join: (...o) => o.join(' '), + pre: (...o) =>`
    ${o.join(' ')}
    `, + preformatted: (...o) =>_h.pre(o.join('
    ').replace(/\s/g,ch(' '))), + code: (...o) => `${o.join(' ')}`, + attr: { + bare: (o)=>`${ch('@')}${ch('{')}${o}${ch('}')}`, + selected: (o)=>`${ch('@')}${ch('{')}selected${ch('|')}${o}${ch('}')}`, + target: (o)=>`${ch('@')}${ch('{')}target${ch('|')}${o}${ch('}')}`, + char: (o,c)=>`${ch('@')}${ch('{')}${c||'CHARACTER NAME'}${ch('|')}${o}${ch('}')}` + }, + bold: (...o) => `${o.join(' ')}`, + italic: (...o) => `${o.join(' ')}`, + font: { + command: (...o)=>`${o.join(' ')}` + }, + bsgAdj: (adj,m) => `${adj}( ${m} )`, + bsgRowBlock: (c,n) => `
    ${c}
    `, + bsgAdjPart: (e) => (e.adjustments||['']).reduce((m,adj) => _h.bsgAdj(adj,m) , `${e.attribute}${e.type?`|${e.type}`:''}`), + bsgRowStats: (g) => `
    ${g.map(_h.bsgAdjPart).join('')}
    `, + bsgRowButtons: (n) => `
    ${_h.ui.buttonCompact(`⮙`,`!group-init --promote ${n}`)}${_h.ui.buttonCompact(`🚫`,`!group-init --del-group ${n}`)}
    `, + bsgRow: (g,n) =>_h.bsgRowBlock(`${_h.bsgRowStats(g)}${_h.bsgRowButtons(n)}`,n-1), + ui : { + float: (t) => `
    ${t}
    `, + clear: () => `
    `, + bubble: (label) => `${label}`, + button: (label,link) => `${label}`, + buttonCompact: (label,link) => `${label}` + } + }; + + + + + const helpParts = { + helpBody: (context) => _h.join( + _h.header( + _h.paragraph( + `Rolls initiative for the selected tokens and adds them to the Turn Order if they don${ch("'")}t have a turn yet.` + ), + _h.paragraph( + `The calculation of initiative is handled by the combination of Roller (See ${_h.bold("Roller Options")} below) and a Bonus. The Bonus is determined based on an ordered list of Stat Groups (See ${_h.bold("Bonus Stat Groups")} below). Stat Groups are evaluated in order. The bonus computed by the first Stat Group for which all attributes exist and have a numeric value is used. This allows you to have several Stat Groups that apply to different types of characters. In practice you will probably only have one, but more are there if you need them.` + ) + ), + helpParts.commands(context) + ), + rollingCommands: (/*context*/) => _h.section('Commands for Rolling', + _h.paragraph(`GroupInitiative's primary role is rolling initiative. It has many options for performing the roll, most of which operate on the selected tokens.`), + _h.inset( + _h.font.command( + `!group-init` + ), + _h.paragraph( + `This command uses the configured Roller to dtermine the initiative order for all the selected tokens.` + ), + _h.font.command( + `!group-init`, + `--bonus`, + _h.required('bonus') + ), + _h.paragraph( + `This command is just line the bare !group-init roll, but will add the supplied bonus to all rolls. The bonus can be from an inline roll.` + ), + _h.font.command( + `!group-init`, + `--reroll`, + _h.optional('bonus') + ), + _h.paragraph( + `This command rerolls all of the tokens currently in the turn order as if they were selected when you executed !group-init. An optional bonus can be supplied, which can be the result of an inline roll.` + ), + _h.font.command( + `!group-init`, + `--ids`, + _h.optional('...') + ), + _h.paragraph( + `This command uses the configured Roller to determine the initiative order for all tokens whose ids are specified.` + ), + + _h.font.command( + `!group-init`, + `--adjust`, + _h.required('adjustment'), + _h.optional('minimum') + ), + _h.paragraph( + `Applies an adjustment to all the current Turn Order tokens (Custom entries ignored). The required adjustment value will be applied to the current value of all Turn Order entries. The optional minium value will be used if the value after adjustiment is lower, which can end up raising Turn Order values even if they were already lower.` + ), + _h.font.command( + `!group-init`, + `--adjust-current`, + _h.required('adjustment'), + _h.optional('minimum') + ), + _h.paragraph( + `This is identical to --adjust, save that it is only applied to the top entry in the Turn Order.` + ) + ) + ), + helpCommands: (/*context*/) => _h.section('Help and Configuration', + _h.paragraph( + `All of these commands are documented in the build in help. Additionally, there are many configuration options that can only be accessed there.` + ), + _h.inset( + _h.font.command( + `!group-init`, + `--help` + ), + _h.paragraph(`This command displays the help and configuration options.`) + ) + ), + + buildStatAdjustmentRows: ( /* context */) => _h.ul( + ...Object.keys(statAdjustments).map(k=>`${_h.bold(k)} -- ${statAdjustments[k].desc}`) + ), + + buildCharacterSheetRows: ( /* context */) => _h.ul( + ...validCharacterSheets.map(k=>`${_h.bold(k)}`) + ), + + showStandardConfigOptions: ( /* context */ ) => _h.ul( + ...Object.keys(standardConfigs).map(c=>_h.join( + _h.ui.float(_h.ui.button(`Apply Config`,`!group-init-config --apply-standard-config|${c}`)), + _h.subhead(standardConfigs[c].title), + _h.paragraph(`${standardConfigs[c].desc}${_h.ui.clear()}`) + )) + ), + + statGroupCommands: (/*context*/) => _h.section('Commands for Stat Groups', + _h.paragraph( + `Stat Groups are the method through which GroupInitiative knows what to do to create the initiative value for a token. Generally, they will be some combination of attributes and adjustments to look up on each token and character.` + ), + _h.inset( + _h.font.command( + `!group-init`, + `--add-group --${_h.required('adjustment')} ${_h.optional('arguments')}`, + _h.optional( + `--add-group --${_h.required('adjustment')} ${_h.optional('arguments')}`, + `...` + ) + ), + _h.paragraph( + `Adds a new Bonus Stat Group to the end of the list. Each adjustment operation can be followed by another adjustment operation, but eventually must end in an attribute name. Adjustment operations are applied to the result of the adjustment operations that follow them.` + ), + _h.minorhead('Available Stat Adjustment Options:'), + helpParts.buildStatAdjustmentRows(), + + _h.font.command( + `!group-init`, + `--show-sheets` + ), + _h.paragraph( + `This command shows the names of the character sheets currently installed in the game, for use with ${_h.code('--filter-sheet')}.` + ), + + _h.font.command( + `!group-init`, + `--promote`, + _h.required('index') + ), + _h.paragraph( + `This command increases the importants of the Bonus Stat Group at the supplied index.` + ), + + _h.font.command( + `!group-init`, + `--del-group`, + _h.required('index') + ), + _h.paragraph( + `This command removes the Bonus Stat Group at the supplied index.` + ) + ) + ), + stackCommands: (/*context*/) => _h.section('Commands for Stacks of Initiative', + _h.paragraph( + `GroupInitiative provides a system called ${_h.bold('Stacks')} which lets you store collections of prerolled initiative values and combine or cycle them as desired.` + ), + _h.inset( + _h.font.command( + `!group-init`, + `--stack`, + _h.optional('operation'), + _h.optional('label') + ), + _h.inset( + _h.minorhead('Available Operations:'), + _h.ul( + `${_h.bold('list')} -- Displays the stack of saved Turn Orders. (default)`, + `${_h.bold('clear')} -- Clears the stack of saved Turn Orders.`, + `${_h.bold(`copy${ch('|')}dup ${ch('[')}label${ch(']')}`)} -- Adds a copy of the current Turn Order to the stack.`, + `${_h.bold(`push ${ch('[')}label${ch(']')}`)} -- Adds a copy of the current Turn Order to the stack and clears the Turn Order. Anything after the command will be used as a label for the entry.`, + `${_h.bold('pop')} -- Replaces the current Turn Order with the last entry in the stack removing it from the stack.`, + `${_h.bold('apply')} -- Replaces the current Turn Order with the last entry in the stack leaving it on the stack.`, + `${_h.bold(`swap ${ch('[')}label${ch(']')}`)} -- Swaps the current Turn Order with the last entry in the stack. Anything after the command will be used as a label for the entry placed in the stack.`, + `${_h.bold(`tswap${ch('|')}tail-swap ${ch('[')}label${ch(']')}`)} -- Swaps the current Turn Order with the first entry in the stack. Anything after the command will be used as a label for the entry placed in the stack.`, + `${_h.bold('merge')} -- Removes the last entry in the stack and adds it to the current Turn Order and sorts the new Turn Order with the configured sort method.`, + `${_h.bold(`apply-merge${ch('|')}amerge`)} -- Merges the last entry in the stack with the current Turn Order and sorts the new Turn Order with the configured sort method, leaving the stack unchanged.`, + `${_h.bold(`rotate${ch('|')}rot ${ch('[')}label${ch(']')}`)} -- Pushes the current Turn Order onto the end of the stack and restores the first entry from the stack to the Turn Order. Anything after the command will be used as a label for the entry placed in the stack.`, + `${_h.bold(`reverse-rotate${ch('|')}rrot ${ch('[')}label${ch(']')}`)} -- Pushes the current Turn Order onto the beginning of the stack and restores the last entry from the stack to the Turn Order. Anything after the command will be used as a label for the entry placed in the stack.` + ) + ) + ) + ), + turnOrderCommands: (/*context*/) => _h.section('Commands for Turn Order Management', + _h.paragraph( + `The Turn Order is an integral part of initiative, so GroupInitiative provides some methods for manipulating it.` + ), + _h.inset( + _h.font.command( + `!group-init`, + `--toggle-turnorder` + ), + _h.paragraph( + `Opens or closes the Turn Order window.` + ), + _h.font.command( + `!group-init`, + `--sort` + ), + _h.paragraph( + `Applies the configured sort operation to the current Turn Order.` + ), + _h.font.command( + `!group-init`, + `--clear` + ), + _h.paragraph( + `Removes all tokens from the Turn Order. If Auto Open Init is enabled it will also close the Turn Order box.` + )) + ), + commands: (context) => _h.join( + _h.subhead('Commands'), + helpParts.rollingCommands(context), + helpParts.helpCommands(context), + helpParts.statGroupCommands(context), + helpParts.turnOrderCommands(context), + helpParts.stackCommands(context) + ), + + rollerConfig: (/*context*/) => _h.section('Roller Options', + _h.paragraph( + `The Roller determines how token rolls are performed for groups of tokens.` + ), + _h.inset( + _h.ul( + ...Object.keys(rollers).map(r=>`${_h.ui.float( + ( r === state[scriptName].config.rollType) + ? _h.ui.bubble(_h.bold('Selected')) + : _h.ui.button(`Use ${r}`,`!group-init-config --set-roller|${r}`) + )}${_h.bold(r)} -- ${rollers[r].desc}${_h.ui.clear()}` ) + ) + ) + ), + + sortOptionsConfig: ( /* context */ ) => _h.section('Sorter Options', + _h.paragraph( + `The Sorter is used to determine how to reorder entries in the Turn Order whenever GroupInitiative performs a sort. Sorting occurs when the sort command (${_h.code('!group-init --sort')}) is issued, when stack entries are merged into the current Turn Order, and when new entries are added to the Turn Order with a GroupInitiative command (like ${_h.code('!group-init')}).` + ), + _h.inset( + _h.ul( + ...Object.keys(sorters).map(s=>`${_h.ui.float( + (s === state[scriptName].config.sortOption) + ? _h.ui.bubble(_h.bold('Selected')) + : _h.ui.button(`Use ${s}`,`!group-init-config --sort-option|${s}`) + )}${_h.bold(s)} -- ${sorters[s].desc}${_h.ui.clear()}`) + ) + ) + ), + + dieSizeConfig: ( /* context */ ) => _h.section('Initiative Die Size', + _h.paragraph( + `The Initiative Die sets the size of the die that GroupInitiative will roll for each initiative value.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button('Set Die Size', `!group-init-config --set-die-size|?{Number of sides the initiative die has:|${state[scriptName].config.dieSize}}`))}Initiative Die Size is currently set to ${_h.bold(state[scriptName].config.dieSize)}. ${_h.ui.clear()}`) + ) + ), + diceCountConfig: ( /* context */ ) => _h.section('Initiative Dice Count', + _h.paragraph( + `The Initiative Dice Count sets the number of dice GroupInitiative will roll for each initiative value. You can set this number to 0 to prevent any dice from being rolled.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button('Set Dice Count', `!group-init-config --set-dice-count|?{Number of initiative dice to roll:|${state[scriptName].config.diceCount}}`))}Initiative Dice Count is currently set to ${_h.bold(state[scriptName].config.diceCount)}. ${_h.ui.clear()}`) + ) + ), + diceCountAttributeConfig: ( /* context */ ) => _h.section('Dice Count Attribute', + _h.paragraph( + `If this attribute is set, it will be used to determine the number of dice to roll for each initiatitve value. If the value is not set, or not a valid number, the Intiative Dice Count is used instead.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button('Set Attribute', `!group-init-config --set-dice-count-attribute|?{Attribute to use for number of initiative dice to roll (Blank to disable):|${state[scriptName].config.diceCountAttribute}}`))}Dice Count Attribute is currently set to ${_h.bold((state[scriptName].config.diceCountAttribute.length ? state[scriptName].config.diceCountAttribute : 'DISABLED'))}. ${_h.ui.clear()}`) + ) + ), + diceModConfig: ( /* context */ ) => _h.section('Dice Modifier String', + _h.paragraph( + `The Dice Modifier String is appended to the roll made by GroupInitiative for each Initiative. It can be used for rerolling 1s or dropping the lower roll, etc.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button('Set Dice Modifiers', `!group-init-config --set-dice-mod|?{Dice Modifiers to be appended to roll (Blank to disable):|${state[scriptName].config.diceMod}}`))}Dice Modifier String is currently set to ${_h.bold((state[scriptName].config.diceMod.length ? state[scriptName].config.diceMod : 'DISABLED'))}. ${_h.ui.clear()}`) + ) + ), + maxDecimalConfig: ( /* context */ ) => _h.section('Maximum Decimal Places', + _h.paragraph( + `This is the Maximum number of decimal places to show in the Initiative when Tie-Breakers are rolled.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button('Set Max Decimal', `!group-init-config --set-max-decimal|?{Maximum number of decimal places:|${state[scriptName].config.maxDecimal}}`))}Maximum Decimal Places is currently set to ${_h.bold(state[scriptName].config.maxDecimal)}. ${_h.ui.clear()}`) + ) + ), + autoOpenInitConfig: ( /* context */ ) => _h.section('Auto Open Turn Order', + _h.paragraph( + `This option causes GroupInitiative to open the Turn Order whenever it makes an initiative roll.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button((state[scriptName].config.autoOpenInit ? 'Disable' : 'Enable'), `!group-init-config --toggle-auto-open-init`))}Auto Open Turn Order is currently ${_h.bold( (state[scriptName].config.autoOpenInit ? 'On' : 'Off') )}. ${_h.ui.clear()}`) + ) + ), + replaceRollConfig: ( /* context */ ) => _h.section('Replace Roll', + _h.paragraph( + `This option causes GroupInitiative to replace a roll in the Turn Order if a token is already present there when it makes a roll for it. Otherwise, the token is ignored and the current roll is retained.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button((state[scriptName].config.replaceRoll ? 'Disable' : 'Enable'), `!group-init-config --toggle-replace-roll`))}Replace Roll is currently ${_h.bold( (state[scriptName].config.replaceRoll ? 'On' : 'Off') )}. ${_h.ui.clear()}`) + ) + ), + checkForNoConfigConfig: ( /* context */ ) => _h.section('Check For No Configuration', + _h.paragraph( + `This option causes GroupInitiative to prompt with standard configuration options when it starts up and there are no Stat Groups. You can suppress it by turning it off, in the case that your game is just dice and you don't need Stat Groups.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button((state[scriptName].config.checkForNoConfig ? 'Disable' : 'Enable'), `!group-init-config --toggle-check-for-no-config`))}No Configuration Checking is currently ${_h.bold( (state[scriptName].config.checkForNoConfig ? 'On' : 'Off') )}. ${_h.ui.clear()}`) + ) + ), + preserveFirstConfig: ( /* context */ ) => _h.section('Preserve First on Sorted Add', + _h.paragraph( + `This option causes GroupInitiative to preserve the first Turn Order entry when sorting the Turn Order after adding creatures.` + ), + _h.inset( + _h.paragraph(`${_h.ui.float(_h.ui.button((state[scriptName].config.preserveFirst ? 'Disable' : 'Enable'), `!group-init-config --toggle-preserve-first`))}Preserve First on Sorted Add is currently ${_h.bold( (state[scriptName].config.preserveFirst ? 'On' : 'Off') )}. ${_h.ui.clear()}`) + ) + ), + announcerConfig: (/*context*/) => _h.section('Announcer Options', + _h.paragraph( + `The Announcer controls what is shown in chat when a roll is performed.` + ), + _h.inset( + _h.ul( + ...Object.keys(announcers).map(a=>`${_h.ui.float( + (a === state[scriptName].config.announcer) + ? _h.ui.bubble(_h.bold('Selected')) + : _h.ui.button(`Use ${a}`,`!group-init-config --set-announcer|${a}`) + )}${_h.bold(a)} -- ${announcers[a].desc}${_h.ui.clear()}` ) + ) + ) + ), + standardConfig: (context) => _h.join( + _h.subhead('Configuration'), + _h.paragraph( + `Standard Configurations give you some quick options for certain character sheets. If you${ch("'")}re using one of these sheets in a pretty standard game, look no further than one of these options.` + ), + _h.inset( + _h.minorhead('Available Standard Configuration Options:'), + helpParts.showStandardConfigOptions(context) + ) + ), + + showBonusStatGroupsConfig: (/*context*/) => _h.section('Bonus Stat Groups', + _h.ol( ...state[scriptName].bonusStatGroups.map((a,n)=>_h.bsgRow(a,n+1) )) + ), + + configuration: (context) => _h.join( + _h.subhead('Configuration'), + _h.inset( + helpParts.rollerConfig(context), + helpParts.sortOptionsConfig(context), + helpParts.preserveFirstConfig(context), + helpParts.dieSizeConfig(context), + helpParts.diceCountConfig(context), + helpParts.diceCountAttributeConfig(context), + helpParts.diceModConfig(context), + helpParts.maxDecimalConfig(context), + helpParts.autoOpenInitConfig(context), + helpParts.replaceRollConfig(context), + helpParts.checkForNoConfigConfig(context), + helpParts.announcerConfig(context), + helpParts.showBonusStatGroupsConfig(context) + ) + ), + + helpDoc: (context) => _h.join( + _h.title(scriptName, version), + helpParts.helpBody(context) + ), + + helpChat: (context) => _h.outer( + _h.title(scriptName, version), + helpParts.helpBody(context), + helpParts.standardConfig(context), + helpParts.configuration(context) + ), + + helpNoConfig: (context) => _h.outer( + _h.title(scriptName, version), + _h.paragraph(`You do not have any Bonus Stat Groups configured right now. Usually that means this is the first time you have used GroupInitiative. If you would like, you can try one of the Standard Configurations below to configure GroupInitiative for some popular character sheets. If you want to configure a different sheet or without a sheet at all, see the extensive ${_h.ui.button('Online Help',`!group-init --help`)}. If you do not want to see this message again, you can ${_h.ui.button('Hide it',`!group-init-config --toggle-check-for-no-config`)}.`), + _h.inset( + _h.minorhead('Available Standard Configuration Options:'), + helpParts.showStandardConfigOptions(context) + ) + ), + + helpConfig: (context) => _h.outer( + _h.title(scriptName, version), + helpParts.configuration(context) + ) + + }; + + const showHelp = (playerid) => { + const who=(getObj('player',playerid)||{get:()=>'API'}).get('_displayname'); + let context = { + who, + playerid + }; + sendChat('', '/w "'+who+'" '+ helpParts.helpChat(context)); + }; + + const parseEmbeddedStatReferences = function(stat,charObj){ + let charName=charObj.get('name'), + stext=(stat+'').replace(/@{[^}]*}/g,(s)=>{ + let parts=_.rest(s.match(/@{([^|}]*)\|?([^|}]*)\|?([^|}]*)}/)), + statName,modName; + if(parts[2].length){ + statName=parts[1]; + modName=parts[2]; + } else if(parts[1].length){ + if(_.contains(['max','current'],parts[1])){ + statName=parts[0]; + modName=parts[1]; + } else { + statName=parts[1]; + } + } else { + statName=parts[0]; + } + + return `@{${charName}|${statName}${modName?`|${modName}`:''}}`; + }) + .replace(/&{tracker}/,''); + return stext; + }; + +const findInitiativeBonus = async (charObj, token) => { + let bonus = ''; + let rolladj = {}; + + const GetBonuses = async (group, index) => { + let cachedRollAdj = {index}; + let bonusPromises = group.map( async (details) => { + let stat=getAttrByName(charObj.id, details.attribute, details.type||'current'); + + if( undefined === stat || null === stat){ + stat = undefined; + } else if(!Number.isNaN(Number(stat))){ + stat = parseFloat(stat); + } else if(isString(stat)) { + stat = parseEmbeddedStatReferences(stat,charObj); + stat = stat.length ? stat : 0; + } else { + stat = undefined; + } + + return await (details.adjustments || []).reduce(async (memo,a) => { + let args = a.split(':'); + let adjustment = args.shift().toLowerCase(); + let func=statAdjustments[adjustment].func; + let adjType=statAdjustments[adjustment].type; + if(isFunction(func)) { + switch(adjType){ + + case adjustments.STAT: + if(undefined !== stat){ + args.unshift(memo); + memo = await func.apply({},[...args]); + } + break; + + case adjustments.COMPUTED: + args.unshift(memo); + memo = await func.apply({},[charObj,details.attribute]); + break; + + case adjustments.TOKEN: + memo = await func(token,details.attribute); + break; + + case adjustments.BONUS: + memo = await func(details.attribute); + break; + + case adjustments.FILTER: + if(!await func(token,charObj,details.attribute)) { + memo=null; + } else { + memo = 0; // necessary to select this stat group + } + break; + case adjustments.LABEL: + case adjustments.ROLLADJ:{ + let adj = await func(token,charObj,details.attribute); + cachedRollAdj = {...cachedRollAdj,...adj}; + memo = 0; // necessary to select this stat group + } + break; + } + } + return memo; + },stat); + }); + + bonus = await Promise.all(bonusPromises); + + + if(_.contains(bonus,undefined) || _.contains(bonus,null) || _.contains(bonus,NaN)) { + bonus=''; + return false; + } + bonus = bonus.join('+'); + rolladj = cachedRollAdj; + + return true; + }; + + + for(let i = 0; i { + if(Array.isArray(ids)){ + setTimeout(()=>makeRollsForIDs(ids,{ + isReroll: false, + prev: Campaign().get('turnorder'), + manualBonus: parseFloat(options && options.manualBonus)||0 + }), 0); + } +}; + +const makeRollsForIDs = async (ids,options={}) => { + let turnorder = Campaign().get('turnorder'); + + turnorder = ('' === turnorder) ? [] : JSON.parse(turnorder); + if(state[scriptName].config.replaceRoll || options.isReroll) { + turnorder = turnorder.filter(e => !ids.includes(e.id)); + } + + let turnorderIDS = turnorder.map(e=>e.id); + + let initFunc=rollers[state[scriptName].config.rollType].func; + + let rollSetupPromises = ids + .filter( id => !turnorderIDS.includes(id)) + .map(id => getObj('graphic',id)) + .filter( g => undefined !== g) + .map(g => ({ + token: g, + character: getObj('character', g.get('represents')) + })) + .map(async g => { + g.roll=[]; + + let {bonus,rolladj} = await findInitiativeBonus(g.character||{},g.token); + bonus = (isString(bonus) ? (bonus.trim().length ? bonus : '0') : bonus); + g.roll.push(bonus); + + if(options.manualBonus){ + g.roll.push( options.manualBonus ); + } + g.label = rolladj.label||(rolladj.hasOwnProperty('index') ? `Rule #${rolladj.index+1}` : `No Matching Rule`); + g.formula = initFunc(g,rolladj); + g.roll.push(g.formula ); + return g; + }); + + let rollSetup = await Promise.all(rollSetupPromises); + + let pageid = (rollSetup[0]||{token:{get:()=>{}}}).token.get('pageid'); + + + let initRolls = _.map(rollSetup,function(rs,i){ + return { + index: i, + label: rs.label, + formula: rs.formula, + roll: `[[(${rs.roll.filter(r=>isString(r) && r.length).join(`) + (`)})]]`.replace(/\[\[\[/g, "[[ [") + }; + }); + + let turnEntries = []; + let finalize = _.after(initRolls.length,function(){ + turnEntries = _.sortBy(turnEntries,'order'); + turnEntries = rollers[state[scriptName].config.rollType].mutator(turnEntries); + + Campaign().set({ + turnorder: JSON.stringify( + sorters[state[scriptName].config.sortOption].func( + turnorder.concat( + _.chain(rollSetup) + .map(function(s,i){ + s.rollResults={ + ...turnEntries.shift(), + source: initRolls[i] + }; + return s; + }) + .tap(announcers[state[scriptName].config.announcer].func) + .map(function(s){ + return { + id: s.token.id, + pr: s.rollResults.total, + _pageid: s.token.get('pageid'), + custom: '' + }; + }) + .value() + ), + state[scriptName].config.preserveFirst + ) + ) + }); + notifyObservers('turnOrderChange',Campaign().get('turnorder'), options.prev); + + if(state[scriptName].config.autoOpenInit && !Campaign().get('initativepage')) { + Campaign().set({ + initiativepage: pageid + }); + } + }); + + initRolls.forEach((ir) => { + let chatText = ir.index+':'+ir.roll.replace(/\[\[\s+/,'[['); + sendChat('',chatText ,(msg) => { + let parts = msg[0].content.split(/:/); + let ird = msg[0].inlinerolls[parts[1].match(/\d+/)]; + let rdata = { + order: parseInt(parts[0],10), + total: (ird.results.total%1===0 ? + ird.results.total : + parseFloat(ird.results.total.toFixed(state[scriptName].config.maxDecimal))), + rolls: _.reduce(ird.results.rolls,function(m,rs){ + if('R' === rs.type) { + m.push({ + sides: rs.sides, + rolls: _.pluck(rs.results.filter(r=>true!==r.d),'v'), + discards: _.pluck(rs.results.filter(r=>true===r.d),'v') + }); + } + return m; + },[]) + }; + + rdata.bonus = (ird.results.total - (_.reduce(rdata.rolls,function(m,r){ + m+=_.reduce(r.rolls,function(s,dieroll){ + return s+dieroll; + },0); + return m; + },0))); + + rdata.bonus = (rdata.bonus%1===0 ? + rdata.bonus : + parseFloat(rdata.bonus.toFixed(state[scriptName].config.maxDecimal))); + + turnEntries.push(rdata); + + finalize(); + }); + }); +}; + +const handleInput = async (msg_orig) => { + let msg = _.clone(msg_orig), + prev=Campaign().get('turnorder'), + args, + cmds, + workgroup, + workvar, + error=false, + cont=false, + manualBonus=0, + manualBonusMin=0, + isReroll=false + ; + const who=(getObj('player',msg.playerid)||{get:()=>'API'}).get('_displayname'); + + let context = { + who, + playerid: msg.playerid + }; + + let ids =[]; + + if(msg.selected){ + ids = [...ids, ...msg.selected.map(o=>o._id)]; + } + + if (msg.type !== "api" ) { + return; + } + + if(_.has(msg,'inlinerolls')){ + msg.content = _.chain(msg.inlinerolls) + .reduce(function(m,v,k){ + m['$[['+k+']]']=v.results.total || 0; + return m; + },{}) + .reduce(function(m,v,k){ + return m.replace(k,v); + },msg.content) + .value(); + } + + args = msg.content.split(/\s+--/); + switch(args.shift()) { + case '!group-init': + if(args.length > 0) { + cmds=args.shift().split(/\s+/); + + switch(cmds[0]) { + case 'help': + if(!playerIsGM(msg.playerid)){ + return; + } + showHelp(msg.playerid); + break; + + case 'ids': + if(playerIsGM(msg.playerid) || msg.playerid === 'api' ){ + ids = [...new Set([...ids, ...cmds.slice(1)])]; + cont = true; + } + break; + + case 'show-sheets': + sendChat('!group-init --add-group', `/w "${who}" ` + + '
    '+ + 'The following sheets are present in the game currently:'+ + helpParts.buildCharacterSheetRows(context) + + '
    ' + ); + return; + + case 'add-group': + if(!playerIsGM(msg.playerid)){ + return; + } + workgroup=[]; + workvar={}; + + _.each(args,function(arg){ + let argParts=arg.split(/\s+(.+)/), + adjustmentName, + parameter=argParts[0].split(/:/); + parameter[0]=parameter[0].toLowerCase(); + + if(_.has(statAdjustments, parameter[0])) { + if('filter-sheet' === parameter[0]){ + if( ! validCharacterSheets.includes(argParts[1])) { + sendChat('!group-init --add-group', `/w "${who}" ` + + '
    '+ + 'Unknown Character Sheet: '+argParts[1]+'
    '+ + 'Use one of the following:'+ + helpParts.buildCharacterSheetRows(context) + + '
    ' + ); + error=true; + + } + } + + if('bare' !== parameter[0]) { + if(!_.has(workvar,'adjustments')) { + workvar.adjustments=[]; + } + workvar.adjustments.unshift(argParts[0]); + } + if(argParts.length > 1){ + adjustmentName=argParts[1].split(/\|/); + workvar.attribute=adjustmentName[0]; + if('max'===adjustmentName[1]) { + workvar.type = 'max'; + } + workgroup.push(workvar); + workvar={}; + } + } else { + sendChat('!group-init --add-group', `/w "${who}" ` + + '
    '+ + 'Unknown Stat Adjustment: '+parameter[0]+'
    '+ + 'Use one of the following:'+ + helpParts.buildStatAdjustmentRows(context) + + '
    ' + ); + error=true; + } + }); + if(!error) { + if(!_.has(workvar,'adjustments')){ + state[scriptName].bonusStatGroups.push(workgroup); + sendChat(scriptName, `/w "${who}" ${_h.outer(helpParts.showBonusStatGroupsConfig(context))}`); + } else { + sendChat('!group-init --add-group', `/w "${who}" ` + + '
    '+ + 'All Stat Adjustments must have a final attribute name as an argument. Please add an attribute name after --'+args.pop()+ + '
    ' + ); + } + } + break; + + case 'stack': { + if(!playerIsGM(msg.playerid)){ + return; + } + cmds.shift(); + let operation=cmds.shift(), + showdate=function(ms){ + let ds=Math.round((_.now()-ms)/1000), + str=[]; + + if(ds>86400){ + str.push(`${Math.round(ds/86400)}d`); + ds%=86400; + } + if(ds>3600){ + str.push(`${Math.round(ds/3600)}h`); + ds%=3600; + } + + if(ds>60){ + str.push(`${Math.round(ds/60)}m`); + ds%=60; + } + str.push(`${Math.round(ds)}s`); + + return str.join(' '); + }, + stackrecord=function(label){ + let toRaw=Campaign().get('turnorder'), + to=JSON.parse(toRaw)||[], + summary=_.chain(to) + .map((o)=>{ + return { + entry: o, + token: getObj('graphic',o.id) + }; + }) + .map((o)=>{ + return { + img: (o.token ? o.token.get('imgsrc') : ''), + name: (o.token ? o.token.get('name') : o.entry.custom), + pr: o.entry.pr + }; + }) + .value(); + + return { + label: label || (to.length ? `{${to.length} records}`: '{empty}'), + date: _.now(), + summary: summary, + turnorder: toRaw + }; + }, + toMiniDisplay=function(summary){ + return '
    '+ + _.map(summary,(sume)=>{ + return `
    ${sume.pr}
    ${sume.name||'&'+'nbsp;'}
    `; + }).join('')+ + '
    '; + }, + stacklist=function(){ + sendChat('', `/w "${who}" ` + + '
      '+ + _.map(state[scriptName].savedTurnOrders,(o)=>`
    1. ${o.label} [${showdate(o.date)}]${toMiniDisplay(o.summary)}
    2. `).join('')+ + '
    ' + ); + + }; + switch(operation){ + case 'dup': + case 'copy': + // take current Turn Order and put it on top. + state[scriptName].savedTurnOrders.push(stackrecord(cmds.join(' '))); + stacklist(); + break; + + case 'push': + // take current Turn Order and put it on top. + state[scriptName].savedTurnOrders.push(stackrecord(cmds.join(' '))); + Campaign().set('turnorder','[]'); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + break; + + case 'pop': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders.pop(); + Campaign().set('turnorder',sto.turnorder); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } else { + sendChat('!group-init --stack pop', `/w "${who}" ` + + '
    '+ + 'No Saved Turn Orders to restore!'+ + '
    ' + ); + } + break; + + case 'apply': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders[0]; + Campaign().set('turnorder',sto.turnorder); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } else { + sendChat('!group-init --stack pop', `/w "${who}" ` + + '
    '+ + 'No Saved Turn Orders to apply!'+ + '
    ' + ); + } + break; + + case 'rot': + case 'rotate': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders.shift(); + state[scriptName].savedTurnOrders.push(stackrecord(cmds.join(' '))); + Campaign().set('turnorder',sto.turnorder); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } + break; + + case 'rrot': + case 'reverse-rotate': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders.pop(); + state[scriptName].savedTurnOrders.unshift(stackrecord(cmds.join(' '))); + Campaign().set('turnorder',sto.turnorder); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } + break; + + case 'swap': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders.shift(); + state[scriptName].savedTurnOrders.unshift(stackrecord(cmds.join(' '))); + Campaign().set('turnorder',sto.turnorder); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } + break; + + case 'tswap': + case 'tail-swap': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders.pop(); + state[scriptName].savedTurnOrders.push(stackrecord(cmds.join(' '))); + Campaign().set('turnorder',sto.turnorder); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } + break; + + case 'amerge': + case 'apply-merge': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders[0]; + + Campaign().set('turnorder', JSON.stringify( + sorters[state[scriptName].config.sortOption].func( + _.union( + JSON.parse(Campaign().get('turnorder'))||[], + JSON.parse(sto.turnorder)||[] + ), + state[scriptName].config.preserveFirst + ) + )); + + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } + break; + + case 'merge': + if(state[scriptName].savedTurnOrders.length){ + let sto=state[scriptName].savedTurnOrders.pop(); + + Campaign().set('turnorder', JSON.stringify( + sorters[state[scriptName].config.sortOption].func( + _.union( + JSON.parse(Campaign().get('turnorder'))||[], + JSON.parse(sto.turnorder)||[] + ), + state[scriptName].config.preserveFirst + ) + )); + + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + stacklist(); + } + break; + + case 'clear': + state[scriptName].savedTurnOrders=[]; + break; + + default: + case 'list': + stacklist(); + break; + } + } + break; + + case 'promote': + if(!playerIsGM(msg.playerid)){ + return; + } + cmds[1]=Math.max(parseInt(cmds[1],10),1); + if(state[scriptName].bonusStatGroups.length >= cmds[1]) { + if(1 !== cmds[1]) { + workvar=state[scriptName].bonusStatGroups[cmds[1]-1]; + state[scriptName].bonusStatGroups[cmds[1]-1] = state[scriptName].bonusStatGroups[cmds[1]-2]; + state[scriptName].bonusStatGroups[cmds[1]-2] = workvar; + } + + sendChat(scriptName, `/w "${who}" ${_h.outer(helpParts.showBonusStatGroupsConfig(context))}`); + } else { + sendChat('!group-init --promote', `/w "${who}" ` + + '
    '+ + 'Please specify one of the following by number:'+ + _h.outer(helpParts.showBonusStatGroupsConfig(context)) + + '
    ' + ); + } + break; + + case 'del-group': + if(!playerIsGM(msg.playerid)){ + return; + } + cmds[1]=Math.max(parseInt(cmds[1],10),1); + if(state[scriptName].bonusStatGroups.length >= cmds[1]) { + state[scriptName].bonusStatGroups=_.filter(state[scriptName].bonusStatGroups, function(v,k){ + return (k !== (cmds[1]-1)); + }); + + sendChat(scriptName, `/w "${who}" ${_h.outer(helpParts.showBonusStatGroupsConfig(context))}`); + } else { + sendChat('!group-init --del-group', `/w "${who}" ` + + '
    '+ + 'Please specify one of the following by number:'+ + _h.outer(helpParts.showBonusStatGroupsConfig(context)) + + '
    ' + ); + } + break; + + case 'toggle-turnorder': + if(!playerIsGM(msg.playerid)){ + return; + } + if(false !== Campaign().get('initiativepage') ){ + Campaign().set({ + initiativepage: false + }); + } else { + let player = (getObj('player',msg.playerid)||{get: ()=>true}); + let pid = player.get('_lastpage'); + if(!pid){ + pid = Campaign().get('playerpageid'); + } + Campaign().set({ + initiativepage: pid + }); + } + break; + + case 'reroll': + isReroll=true; + if(cmds[1] && cmds[1].match(/^[-+]?\d+(\.\d+)?$/)){ + manualBonus=parseFloat(cmds[1])||0; + } + + ids = JSON.parse(Campaign().get('turnorder')||'[]') + .filter(e=> '-1' !== e.id) + .map(e=>e.id); + + cont=true; + break; + + case 'sort': + if(!playerIsGM(msg.playerid)){ + return; + } + Campaign().set('turnorder', JSON.stringify( + sorters[state[scriptName].config.sortOption].func( + JSON.parse(Campaign().get('turnorder'))||[], + false + ) + )); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + + break; + + case 'adjust': + if(!playerIsGM(msg.playerid)){ + return; + } + if(cmds[1] && cmds[1].match(/^[-+]?\d+(\.\d+)?$/)){ + manualBonus=parseFloat(cmds[1]); + manualBonusMin=parseFloat(cmds[2]); + manualBonusMin=_.isNaN(manualBonusMin)?-10000:manualBonusMin; + + Campaign().set({ + turnorder: JSON.stringify( + _.map(JSON.parse(Campaign().get('turnorder'))||[], function(e){ + if('-1' !== e.id){ + e.pr=Math.max((_.isNaN(parseFloat(e.pr))?0:parseFloat(e.pr))+manualBonus,manualBonusMin).toFixed(state[scriptName].config.maxDecimal); + } + return e; + }) + ) + }); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + } else { + sendChat(scriptName, `/w "${who}" ` + + '
    '+ + 'Not a valid adjustment: '+cmds[1]+''+ + '
    ' + ); + } + break; + + case 'adjust-current': + if(!playerIsGM(msg.playerid)){ + return; + } + if(cmds[1] && cmds[1].match(/^[-+]?\d+(\.\d+)?$/)){ + manualBonus=parseFloat(cmds[1]); + manualBonusMin=parseFloat(cmds[2]); + manualBonusMin=_.isNaN(manualBonusMin)?-10000:manualBonusMin; + + Campaign().set({ + turnorder: JSON.stringify( + _.map(JSON.parse(Campaign().get('turnorder'))||[], function(e,idx){ + if(0===idx && '-1' !== e.id){ + e.pr=Math.max((_.isNaN(parseFloat(e.pr))?0:parseFloat(e.pr))+manualBonus,manualBonusMin).toFixed(state[scriptName].config.maxDecimal); + } + return e; + }) + ) + }); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + } else { + sendChat(scriptName, `/w "${who}" ` + + '
    '+ + 'Not a valid adjustment: '+cmds[1]+''+ + '
    ' + ); + } + break; + + + case 'clear': + if(!playerIsGM(msg.playerid)){ + return; + } + Campaign().set({ + turnorder: '[]', + initiativepage: (state[scriptName].config.autoOpenInit ? false : Campaign().get('initiativepage')) + }); + notifyObservers('turnOrderChange',Campaign().get('turnorder'),prev); + break; + + case 'bonus': + if(cmds[1] && cmds[1].match(/^[-+]?\d+(\.\d+)?$/)){ + manualBonus=parseFloat(cmds[1]); + cont=true; + } else { + sendChat(scriptName, `/w "${who}" ` + + '
    '+ + 'Not a valid bonus: '+cmds[1]+''+ + '
    ' + ); + } + break; + + default: + if(!playerIsGM(msg.playerid)){ + return; + } + sendChat(scriptName, `/w "${who}" ` + + '
    '+ + 'Not a valid command: '+cmds[0]+''+ + '
    ' + ); + break; + } + } else { + cont=true; + } + + if(cont) { + if(ids.length) { + await makeRollsForIDs(ids,{isReroll,manualBonus,prev}); + } else { + showHelp(msg.playerid); + } + } + break; + + case '!group-init-config': + if(!playerIsGM(msg.playerid)){ + return; + } + if(_.contains(args,'--help')) { + showHelp(msg.playerid); + return; + } + if(!args.length) { + sendChat('',`/w "${who}" ${helpParts.helpConfig(context)}`); + return; + } + _.each(args,function(a){ + let opt=a.split(/\|/), + omsg=''; + switch(opt.shift()) { + case 'apply-standard-config': + if(standardConfigs[opt[0]]) { + standardConfigs[opt[0]].func(); + sendChat('',`/w "${who}" `+ + '
    '+ + `Now configured for ${standardConfigs[opt[0]].title}`+ + '
    ' + ); + } else { + sendChat('',`/w "${who}" `+ + '
    '+ + '
    Error: Not a valid standard Config: '+opt[0]+'
    '+ + helpParts.showStandardConfigOptions(context)+ + '
    ' + ); + } + + break; + + case 'sort-option': + if(sorters[opt[0]]) { + state[scriptName].config.sortOption=opt[0]; + } else { + omsg='
    Error: Not a valid sort method: '+opt[0]+'
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.sortOptionsConfig(context)+ + '
    ' + ); + break; + + case 'set-die-size': + if(opt[0].match(/^\d+$/)) { + state[scriptName].config.dieSize=parseInt(opt[0],10); + } else { + omsg='
    Error: Not a die size: '+opt[0]+'
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.dieSizeConfig(context)+ + '
    ' + ); + break; + + case 'set-max-decimal': + if(opt[0].match(/^\d+$/)) { + state[scriptName].config.maxDecimal=parseInt(opt[0],10); + } else { + omsg='
    Error: Not a valid decimal count: '+opt[0]+'
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.maxDecimalConfig(context)+ + '
    ' + ); + break; + + + case 'set-dice-count': + if(opt[0].match(/^\d+$/)) { + state[scriptName].config.diceCount=parseInt(opt[0],10); + } else { + omsg='
    Error: Not a valid dice count: '+opt[0]+'
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.diceCountConfig(context)+ + '
    ' + ); + break; + + case 'set-dice-count-attribute': + if(opt[0]) { + state[scriptName].config.diceCountAttribute=opt[0]; + } else { + state[scriptName].config.diceCountAttribute=''; + omsg='
    Cleared Dice Count Attribute.
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.diceCountAttributeConfig(context)+ + '
    ' + ); + break; + + case 'set-dice-mod': + if(opt[0]) { + state[scriptName].config.diceMod=opt[0]; + } else { + state[scriptName].config.diceMod=''; + omsg='
    Cleared Dice Modifiers.
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.diceModConfig(context)+ + '
    ' + ); + break; + + case 'toggle-auto-open-init': + state[scriptName].config.autoOpenInit = !state[scriptName].config.autoOpenInit; + sendChat('',`/w "${who}" `+ + '
    '+ + helpParts.autoOpenInitConfig(context)+ + '
    ' + ); + break; + + case 'toggle-replace-roll': + state[scriptName].config.replaceRoll = !state[scriptName].config.replaceRoll; + sendChat('',`/w "${who}" `+ + '
    '+ + helpParts.replaceRollConfig(context)+ + '
    ' + ); + break; + + case 'toggle-preserve-first': + state[scriptName].config.preserveFirst = !state[scriptName].config.preserveFirst; + sendChat('',`/w "${who}" `+ + '
    '+ + helpParts.preserveFirstConfig(context)+ + '
    ' + ); + break; + + case 'toggle-check-for-no-config': + state[scriptName].config.checkForNoConfig = !state[scriptName].config.checkForNoConfig; + sendChat('',`/w "${who}" `+ + '
    '+ + helpParts.checkForNoConfigConfig(context)+ + '
    ' + ); + break; + + case 'set-announcer': + if(announcers[opt[0]]) { + state[scriptName].config.announcer=opt[0]; + } else { + omsg='
    Error: Not a valid announcer: '+opt[0]+'
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.announcerConfig(context)+ + '
    ' + ); + break; + + case 'set-roller': + if(rollers[opt[0]]) { + state[scriptName].config.rollType=opt[0]; + } else { + omsg='
    Error: Not a valid roller: '+opt[0]+'
    '; + } + sendChat('',`/w "${who}" `+ + '
    '+ + omsg+ + helpParts.rollerConfig(context)+ + '
    ' + ); + break; + + default: + sendChat('',`/w "${who}" `+ + '
    Unsupported Option:
    '+a+'' + ); + } + + }); + + break; + } + +}; + + +const registerEventHandlers = function() { + on('chat:message', handleInput); +}; + +on("ready",() => { + checkInstall(); + registerEventHandlers(); +}); + +return { + ObserveTurnOrderChange: observeTurnOrderChange, + RollForTokenIDs: rollForTokenIDsExternal +}; + +})(); + + +{try{throw new Error('');}catch(e){API_Meta.GroupInitiative.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.GroupInitiative.offset);}} diff --git a/GroupInitiative/GroupInitiative.js b/GroupInitiative/GroupInitiative.js index 342126e193..9284739d73 100644 --- a/GroupInitiative/GroupInitiative.js +++ b/GroupInitiative/GroupInitiative.js @@ -8,9 +8,9 @@ API_Meta.GroupInitiative={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; const GroupInitiative = (() => { // eslint-disable-line no-unused-vars const scriptName = "GroupInitiative"; - const version = '0.9.37'; + const version = '0.9.40'; API_Meta.GroupInitiative.version = version; - const lastUpdate = 1734981538; + const lastUpdate = 1735433392; const schemaVersion = 1.3; const isString = (s)=>'string'===typeof s || s instanceof String; @@ -213,47 +213,49 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars }; const formatDieRoll = function(rollData) { - let critFail = _.reduce(rollData.rolls,function(m,r){ - return m || _.contains(r.rolls,1); - },false), - critSuccess = _.reduce(rollData.rolls,function(m,r){ - return m || _.contains(r.rolls,r.sides); - },false), - highlight = ( (critFail && critSuccess) ? - '#4A57ED' : - ( critFail ? - '#B31515' : - ( critSuccess ? - '#3FB315' : - '#FEF68E' + const critFail = rollData.rolls.reduce((m,r)=> m || r.rolls.includes(1),false); + const critSuccess = rollData.rolls.reduce((m,r)=> m || r.rolls.includes(r.sides),false); + const highlight = ( (critFail && critSuccess) + ? '#4A57ED' + : ( critFail + ? '#B31515' + : ( critSuccess + ? '#3FB315' + : '#FEF68E' ) ) - ), - dicePart = _.reduce(rollData.rolls, function(m,r){ - _.reduce(r.rolls,function(dm,dr){ - let dielight=( 1 === dr ? - '#ff0000' : - ( r.sides === dr ? - '#00ff00' : - 'white' - ) - ); - dm.push(''+dr+''); - return dm; - },m); - return m; - },[]).join(' + '); + ); + + const HH = (a)=>HE(HE(a)); + const HDie = (n,m) => n===m ? '#00ff00' : (n===1 ? '#ff0000' : '#ffffff'); + const HR = (n,m) => `${n}`; + const HDiscard = () => '#999999'; + const HD = (n,m) => `${n}`; + const HRolls = rollData.rolls.reduce((m,rs)=>({ + r:[...m.r,...rs.rolls.map(n=>HR(n,rs.sides))], + d:[...m.d,...rs.discards.map(n=>HD(n,rs.sides))] + }),{r:[],d:[]}); + + const b = (text)=>`${text}`; + const block = (text,style)=>`${text}`; + const LabelBlock = (label) => block(label,`font-size:1em;`); + const FormulaBlock = (formula,bonus) => block(`${b(formula)} ${bonus}`,`font-size:.8em;`); + const ResultBlock = (result) => block(block(result,`display:inline-block;background:#fef68e;color:#404040;font-weight:bold;padding: .1em .2em; border:3px solid ${highlight};border-radius:.5em;min-width:2em;font-size:2em;`)); + const RollsBlock = (rolls,bonus) => block(`${b('(')}${[...rolls.r,...rolls.d].join(',')}${b(')')} ${bonus}`,`font-size:1.5em;`); + const popup = (label,formula,bonus,result,rolls) => block(`${LabelBlock(label)}${FormulaBlock(formula,bonus)}${ResultBlock(result)}${RollsBlock(rolls,bonus)}`,`font-color:white`); + + let bonus = `${(rollData.bonus>=0 ? '+' :'-')} ${b(Math.abs(rollData.bonus))}`; + let popText = popup( + rollData.source.label, + rollData.source.formula, + bonus, + rollData.total, + HRolls); return ''+ - dicePart+' [init] '+ - (rollData.bonus>=0 ? '+' :'-')+' '+Math.abs(rollData.bonus)+' [bonus]'+ - '' - ))+'">'+ + ' title="'+HH(popText)+'">'+ rollData.total+ ''; }; @@ -360,15 +362,47 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars TOKEN: 'token', CHARACTER: 'character', BONUS: 'bonus', - FILTER: 'filter' + FILTER: 'filter', + ROLLADJ: 'roll-adjustment', + LABEL: 'label' }; const statAdjustments = { 'filter-sheet': { type: adjustments.FILTER, - func: async (t,c,v) => c.get('charactersheetname') === v, + func: async (t,c,v) => c?.get('charactersheetname') === v, desc: 'Forces calculations only for specific character sheets.' }, + 'filter-status': { + type: adjustments.FILTER, + func: async (t,c,v) => t.get(`status_${v}`) !== false, + desc: 'Forces calculations only for tokens with a given status marker.' + }, + 'filter-tooltip': { + type: adjustments.FILTER, + func: async (t,c,v) => t.get(`tooltip`).toLowerCase().split(/[^a-zA-Z0-9:#|-]+/).includes(v), + desc: 'Forces calculations only for tokens with a tooltip containing the given word.' + }, + 'roll-die-count': { + type: adjustments.ROLLADJ, + func: async (t,c,v) => ({die_count:Number(v)||state[scriptName].config.diceCount}), + desc: 'Forces the number of dice rolled to this value for the matching tokens.' + }, + 'roll-die-size': { + type: adjustments.ROLLADJ, + func: async (t,c,v) => ({die_size:Number(v)||state[scriptName].config.dieSize}), + desc: 'Forces the size of the die rolled to this value for the matching tokens.' + }, + 'roll-die-mod': { + type: adjustments.ROLLADJ, + func: async (t,c,v) => ({die_mod:v}), + desc: 'Applies the given die mod to the roll.' + }, + 'label': { + type: adjustments.LABEL, + func: async (t,c,v) => ({label:v}), + desc: 'Attaches a label to the rule for use in reporting.' + }, 'bonus': { type: adjustments.BONUS, func: async (v) => Number(v), @@ -447,16 +481,24 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars }; - const buildInitDiceExpression = function(s){ - let stat=(''!== state[scriptName].config.diceCountAttribute && s.character && getAttrByName(s.character.id, state[scriptName].config.diceCountAttribute, 'current')); - if(stat ) { - stat = (_.isString(stat) ? stat : stat+''); - if('0' !== stat) { - stat = stat.replace(/@\{([^|]*?|[^|]*?\|max|[^|]*?\|current)\}/g, '@{'+(s.character.get('name'))+'|$1}'); - return '('+stat+')d'+state[scriptName].config.dieSize; - } - } - return state[scriptName].config.diceCount+'d'+state[scriptName].config.dieSize+state[scriptName].config.diceMod; + const buildInitDiceExpression = function(s,a){ + let diceCount = state[scriptName].config.diceCount; + let diceSize = a?.die_size || state[scriptName].config.dieSize; + let diceMod = a?.die_mod || state[scriptName].config.diceMod || ''; + + if(a.hasOwnProperty('die_count')){ + diceCount = a.die_count; + } else { + let stat=(''!== state[scriptName].config.diceCountAttribute && s.character && getAttrByName(s.character.id, state[scriptName].config.diceCountAttribute, 'current')); + if(stat ) { + stat = (_.isString(stat) ? stat : stat+''); + if('0' !== stat) { + stat = stat.replace(/@\{([^|]*?|[^|]*?\|max|[^|]*?\|current)\}/g, '@{'+(s.character.get('name'))+'|$1}'); + diceCount = `(${stat})`; + } + } + } + return `${diceCount}d${diceSize}${diceMod}`; }; const rollers = { @@ -464,8 +506,8 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars mutator: function(l){ return l; }, - func: function(s){ - return buildInitDiceExpression(s); + func: function(s,a){ + return buildInitDiceExpression(s,a); }, desc: 'Sets the initiative individually for each member of the group.' }, @@ -481,8 +523,8 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars return min; }); }, - func: function(s){ - return buildInitDiceExpression(s); + func: function(s,a){ + return buildInitDiceExpression(s,a); }, desc: 'Sets the initiative to the lowest of all initiatives rolled for the group.' }, @@ -493,8 +535,8 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars return mean; }); }, - func: function(s){ - return buildInitDiceExpression(s); + func: function(s,a){ + return buildInitDiceExpression(s,a); }, desc: 'Sets the initiative to the mean (average) of all initiatives rolled for the group.' }, @@ -693,6 +735,25 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars 'filter-sheet': { 'background-color': '#996600' }, + 'filter-status': { + 'background-color': '#993300' + }, + 'filter-tooltip': { + 'background-color': '#cc6600' + }, + 'roll-die-count': { + 'background-color': '#0066cc' + }, + 'roll-die-size': { + 'background-color': '#0033cc' + }, + 'roll-die-mod': { + 'background-color': '#0099cc' + }, + 'label': { + 'color': '#000000', + 'background-color': '#ffff00' + }, 'computed': { 'background-color': '#a61c00' }, @@ -1205,8 +1266,10 @@ const GroupInitiative = (() => { // eslint-disable-line no-unused-vars const findInitiativeBonus = async (charObj, token) => { let bonus = ''; + let rolladj = {}; - const GetBonuses = async (group) => { + const GetBonuses = async (group, index) => { + let cachedRollAdj = {index}; let bonusPromises = group.map( async (details) => { let stat=getAttrByName(charObj.id, details.attribute, details.type||'current'); @@ -1256,6 +1319,13 @@ const findInitiativeBonus = async (charObj, token) => { memo = 0; // necessary to select this stat group } break; + case adjustments.LABEL: + case adjustments.ROLLADJ:{ + let adj = await func(token,charObj,details.attribute); + cachedRollAdj = {...cachedRollAdj,...adj}; + memo = 0; // necessary to select this stat group + } + break; } } return memo; @@ -1264,23 +1334,26 @@ const findInitiativeBonus = async (charObj, token) => { bonus = await Promise.all(bonusPromises); + if(_.contains(bonus,undefined) || _.contains(bonus,null) || _.contains(bonus,NaN)) { bonus=''; return false; } bonus = bonus.join('+'); + rolladj = cachedRollAdj; + return true; }; - for(const group of state[scriptName].bonusStatGroups){ - let found = await GetBonuses(group); + for(let i = 0; i { @@ -1316,14 +1389,16 @@ const makeRollsForIDs = async (ids,options={}) => { .map(async g => { g.roll=[]; - let bonus = await findInitiativeBonus(g.character||{},g.token); + let {bonus,rolladj} = await findInitiativeBonus(g.character||{},g.token); bonus = (isString(bonus) ? (bonus.trim().length ? bonus : '0') : bonus); g.roll.push(bonus); if(options.manualBonus){ g.roll.push( options.manualBonus ); } - g.roll.push( initFunc(g) ); + g.label = rolladj.label||(rolladj.hasOwnProperty('index') ? `Rule #${rolladj.index+1}` : `No Matching Rule`); + g.formula = initFunc(g,rolladj); + g.roll.push(g.formula ); return g; }); @@ -1335,11 +1410,9 @@ const makeRollsForIDs = async (ids,options={}) => { let initRolls = _.map(rollSetup,function(rs,i){ return { index: i, - roll: ('[[('+ _.reject(rs.roll,function(r){ - return _.isString(r) && _.isEmpty(r); - }) - .join(') + (')+')]]') - .replace(/\[\[\[/g, "[[ [") + label: rs.label, + formula: rs.formula, + roll: `[[(${rs.roll.filter(r=>isString(r) && r.length).join(`) + (`)})]]`.replace(/\[\[\[/g, "[[ [") }; }); @@ -1353,8 +1426,11 @@ const makeRollsForIDs = async (ids,options={}) => { sorters[state[scriptName].config.sortOption].func( turnorder.concat( _.chain(rollSetup) - .map(function(s){ - s.rollResults=turnEntries.shift(); + .map(function(s,i){ + s.rollResults={ + ...turnEntries.shift(), + source: initRolls[i] + }; return s; }) .tap(announcers[state[scriptName].config.announcer].func) @@ -1395,7 +1471,8 @@ const makeRollsForIDs = async (ids,options={}) => { if('R' === rs.type) { m.push({ sides: rs.sides, - rolls: _.pluck(rs.results.filter(r=>true!==r.d),'v') + rolls: _.pluck(rs.results.filter(r=>true!==r.d),'v'), + discards: _.pluck(rs.results.filter(r=>true===r.d),'v') }); } return m; diff --git a/GroupInitiative/script.json b/GroupInitiative/script.json index db3e2bd356..01f743488f 100644 --- a/GroupInitiative/script.json +++ b/GroupInitiative/script.json @@ -1,6 +1,6 @@ { "name": "GroupInitiative", - "version": "0.9.38", + "version": "0.9.40", "description": "Rolls initiative for the selected tokens and adds them to the turn order if they don't have a turn yet.\r\rThe calculation of initiative is handled by the combination of Roller (See **Roller Options** below) and a Bonus. The Bonus is determined based on an ordered list of Stat Groups (See **Bonus Stat Groups** below). Stat Groups are evaluated in order. The bonus computed by the first Stat Group for which all attributes exist and have a numeric value is used. This allows you to have several Stat Groups that apply to different types of characters. In practice you will probably only have one, but more are there if you need them.\r\r\r## Commands\r\r```!group-init ```\r\rThis command uses the configured Roller to determine the initiative order for all selected tokens.\r\r```!group-init --ids [ ...]```\r\rThis command uses the configured Roller to determine the initiative order for all tokens whose ids are specified.\r\r```!group-init --help```\r\rThis command displays the help and configuration options, as well as the currently configured groups.\r\r```!group-init --promote ```\r\rIncreases the importance the specified Bonus Stat Group.\r\rThis command requires 1 parameter:\r\r* `index` -- The numeric index of the Bonus Stat Group to promote.\r\r`!group-init --del-group `\r\rDeletes the specified Bonus Stat Group.\r\rThis command requires 1 parameter:\r\r* `index` -- The numeric index of the Bonus Stat Group to delete.\r\r```!group-init --add-group -- [--] ]> [-- [--] ]> ...]```\r\rAdds a new Bonus Stat Group to the end of the list. Each adjustment operation can be followed by another adjustment operation, but eventually must end in an attriute name. Adjustment operations are applied to the result of the adjustment operations that follow them.\r\rFor example: `--Bounded:-2:2 --Stat-DnD wisdom|max` would first computer the DnD Stat bonus for the max field of the wisdom attribute, then bound it between `-2` and `+2`.\r\rThis command takes multiple parameters:\r\r* `adjustment` -- One of the Stat Adjustment Options.\r* `attribute name` -- The name of an attribute. You can specify `|max` or `|current` on the end to target those specific fields (defaults to `|current`).\r\r```!group-init --reroll```\r\rRerolls all the tokens in the turn order as if they were selected when you executed the bare `!group-init` command.\r\r```!group-init --clear```\r\rRemoves all tokens from the turn order. If Auto Open Init is enabled it will also close the turn order box.\r\r## Roller Options\r\r* `Least-All-Roll` -- Sets the initiative to the lowest of all initiatives rolled for the group.\r* `Mean-All-Roll` -- Sets the initiative to the mean (average) of all initiatives rolled for the group.\r* `Individual-Roll` -- Sets the initiative individually for each member of the group.\r* `Constant-By-Stat` -- Sets the initiative individually for each member of the group to their bonus with no roll.\r\r## Stat Adjustment Options\r\r* `Stat-DnD` -- Calculates the bonus as if the value were a DnD Stat.\r* `Bare` -- No Adjustment.\r* `Floor` -- Rounds down to the nearest integer.\r* `Tie-Breaker` -- Adds the accompanying attribute as a decimal (`0.01`)\r* `Ceiling` -- Rounds up to the nearest integer.\r* `Bounded` -- **DEPREICATED** - will not work with expresions.", "authors": "The Aaron", "roll20userid": "104025", @@ -37,6 +37,7 @@ "0.9.34", "0.9.35", "0.9.36", - "0.9.37" + "0.9.37", + "0.9.38" ] } \ No newline at end of file From 1b4a8869b9b6e8ca17d3c39e517634b66425ecf1 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Thu, 2 Jan 2025 21:48:57 -0600 Subject: [PATCH 3/7] Updated TokenMod to v0.8.80 --- TokenMod/0.8.80/TokenMod.js | 4165 +++++++++++++++++++++++++++++++++++ TokenMod/TokenMod.js | 94 +- TokenMod/script.json | 5 +- 3 files changed, 4258 insertions(+), 6 deletions(-) create mode 100644 TokenMod/0.8.80/TokenMod.js diff --git a/TokenMod/0.8.80/TokenMod.js b/TokenMod/0.8.80/TokenMod.js new file mode 100644 index 0000000000..daa0ef5cda --- /dev/null +++ b/TokenMod/0.8.80/TokenMod.js @@ -0,0 +1,4165 @@ +// Github: https://github.com/shdwjk/Roll20API/blob/master/TokenMod/TokenMod.js +// By: The Aaron, Arcane Scriptomancer +// Contact: https://app.roll20.net/users/104025/the-aaron +var API_Meta = API_Meta||{}; // eslint-disable-line no-var +API_Meta.TokenMod={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; +{try{throw new Error('');}catch(e){API_Meta.TokenMod.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-6);}} + +const TokenMod = (() => { // eslint-disable-line no-unused-vars + + const scriptName = "TokenMod"; + const version = '0.8.80'; + API_Meta.TokenMod.version = version; + const lastUpdate = 1735875678; + const schemaVersion = 0.4; + + const fields = { + // booleans + showname: {type: 'boolean'}, + show_tooltip: {type: 'boolean'}, + showplayers_name: {type: 'boolean'}, + showplayers_bar1: {type: 'boolean'}, + showplayers_bar2: {type: 'boolean'}, + showplayers_bar3: {type: 'boolean'}, + showplayers_aura1: {type: 'boolean'}, + showplayers_aura2: {type: 'boolean'}, + playersedit_name: {type: 'boolean'}, + playersedit_bar1: {type: 'boolean'}, + playersedit_bar2: {type: 'boolean'}, + playersedit_bar3: {type: 'boolean'}, + playersedit_aura1: {type: 'boolean'}, + playersedit_aura2: {type: 'boolean'}, + light_otherplayers: {type: 'boolean'}, + light_hassight: {type: 'boolean'}, + isdrawing: {type: 'boolean'}, + flipv: {type: 'boolean'}, + fliph: {type: 'boolean'}, + aura1_square: {type: 'boolean'}, + aura2_square: {type: 'boolean'}, + lockMovement: {type: 'boolean'}, + + // UDL settings + has_bright_light_vision: {type: 'boolean'}, + has_night_vision: {type: 'boolean'}, + emits_bright_light: {type: 'boolean'}, + emits_low_light: {type: 'boolean'}, + has_limit_field_of_vision: {type: 'boolean'}, + has_limit_field_of_night_vision: {type: 'boolean'}, + has_directional_bright_light: {type: 'boolean'}, + has_directional_dim_light: {type: 'boolean'}, + light_sensitivity_multiplier: {type: 'number'}, + night_vision_effect: {type: 'option'}, + + + // bounded by screen size + left: {type: 'number', transform: 'screen'}, + top: {type: 'number', transform: 'screen'}, + width: {type: 'number', transform: 'screen'}, + height: {type: 'number', transform: 'screen'}, + scale: {type: 'number', transform: 'screen'}, + + // 360 degrees + rotation: {type: 'degrees'}, + light_angle: {type: 'circleSegment'}, + light_losangle: {type: 'circleSegment'}, + + limit_field_of_vision_center: {type: 'degrees'}, + limit_field_of_night_vision_center: {type: 'degrees'}, + directional_bright_light_center: {type: 'degrees'}, + directional_dim_light_center: {type: 'degrees'}, + + limit_field_of_vision_total: {type: 'circleSegment'}, + limit_field_of_night_vision_total: {type: 'circleSegment'}, + directional_bright_light_total: {type: 'circleSegment'}, + directional_dim_light_total: {type: 'circleSegment'}, + + + + // distance + light_radius: {type: 'numberBlank'}, + light_dimradius: {type: 'numberBlank'}, + light_multiplier: {type: 'numberBlank'}, + adv_fow_view_distance: {type: 'numberBlank'}, + aura1_radius: {type: 'numberBlank'}, + aura2_radius: {type: 'numberBlank'}, + + //UDL settings + night_vision_distance: {type: 'numberBlank'}, + bright_light_distance: {type: 'numberBlank'}, + low_light_distance: {type: 'numberBlank'}, + dim_light_opacity: {type: 'percentage'}, + + // text or numbers + bar1_value: {type: 'text'}, + bar2_value: {type: 'text'}, + bar3_value: {type: 'text'}, + bar1_max: {type: 'text'}, + bar2_max: {type: 'text'}, + bar3_max: {type: 'text'}, + bar1: {type: 'text'}, + bar2: {type: 'text'}, + bar3: {type: 'text'}, + bar1_reset: {type: 'text'}, + bar2_reset: {type: 'text'}, + bar3_reset: {type: 'text'}, + + bar_location: {type: 'option'}, + compact_bar: {type: 'option'}, + + + // colors + aura1_color: {type: 'color'}, + aura2_color: {type: 'color'}, + tint_color: {type: 'color'}, + night_vision_tint: {type: 'color'}, + lightColor: {type: 'color'}, + + // Text : special + name: {type: 'text'}, + tooltip: {type: 'text'}, + + statusmarkers: {type: 'status'}, + layer: {type: 'layer'}, + represents: {type: 'character_id'}, + bar1_link: {type: 'attribute'}, + bar2_link: {type: 'attribute'}, + bar3_link: {type: 'attribute'}, + currentSide: {type: 'sideNumber'}, + imgsrc: {type: 'image'}, + sides: {type: 'image' }, + + controlledby: {type: 'player'}, + + // : special + defaulttoken: {type: 'defaulttoken'} + }; + + const fieldAliases = { + bar1_current: "bar1_value", + bar2_current: "bar2_value", + bar3_current: "bar3_value", + bright_vision: "has_bright_light_vision", + night_vision: "has_night_vision", + emits_bright: "emits_bright_light", + emits_low: "emits_low_light", + night_distance: "night_vision_distance", + bright_distance: "bright_light_distance", + low_distance: "low_light_distance", + low_light_opacity: "dim_light_opacity", + has_directional_low_light: "has_directional_dim_light", + directional_low_light_total: "directional_dim_light_total", + directional_low_light_center: "directional_dim_light_center", + currentside: "currentSide", // fix for case issue + lightcolor: "lightColor", // fix for case issue + light_color: "lightColor", // fix for case issue + lockmovement: "lockMovement", // fix for case issue + lock_movement: "lockMovement" // fix for case issue + }; + + const reportTypes = [ + 'gm', 'player', 'all', 'control', 'token', 'character' + ]; + + const probBool = { + couldbe: ()=>(randomInteger(8)<=1), + sometimes: ()=>(randomInteger(8)<=2), + maybe: ()=>(randomInteger(8)<=4), + probably: ()=>(randomInteger(8)<=6), + likely: ()=>(randomInteger(8)<=7) + }; + + const unalias = (name) => fieldAliases.hasOwnProperty(name) ? fieldAliases[name] : name; + + const filters = { + hasArgument: (a) => a.match(/.+[|#]/) || 'defaulttoken'===a, + isBoolean: (a) => 'boolean' === (fields[a]||{type:'UNKNOWN'}).type, + isTruthyArgument: (a) => [1,'1','on','yes','true','sure','yup'].includes(a) + }; + + const getCleanImgsrc = (imgsrc) => { + let parts = (imgsrc||'').match(/(.*\/images\/.*)(thumb|med|original|max)([^?]*)(\?[^?]+)?$/); + if(parts) { + let leader = parts[1].replace(/^https:\/\/s3.amazonaws.com\/files.d20.io\//,'https://files.d20.io/'); + return `${leader}thumb${parts[3]}${parts[4] ? parts[4] : `?${Math.round(Math.random()*9999999)}`}`; + } + }; + + const forceLightUpdateOnPage = (()=>{ + const forPage = (pid) => (getObj('page',pid)||{set:()=>{}}).set('force_lighting_refresh',true); + let pids = new Set(); + let t; + + return (pid) => { + pids.add(pid); + clearTimeout(t); + t = setTimeout(() => { + let activePages = getActivePages(); + [...pids].filter(p=>activePages.includes(p)).forEach(forPage); + pids.clear(); + },100); + }; + })(); + + const option_fields = { + night_vision_effect: { + __default__: ()=>()=>'None', + off: ()=>()=>'None', + ['none']: ()=>()=>'None', + ['dimming']: (amount='5ft')=>(token,mods)=>{ + const regexp = /^([=+\-/*])?(-?\d+\.?|\d*\.\d+)(u|g|s|ft|m|km|mi|in|cm|un|hex|sq|%)?$/i; // */ + let match = `${amount}`.match(regexp); + let factor; + let pnv; + if(mods.hasOwnProperty('night_vision_distance')){ + pnv = mods.night_vision_distance; + } else { + pnv = token.get('night_vision_distance'); + } + + let dp; + if(mods.hasOwnProperty('night_vision_effect') && /^Dimming_/.test(mods.night_vision_effect)){ + dp = (parseFloat(mods.night_vision_effect.replace(/^Dimming_/,''))||0)*pnv; + } else if(/^Dimming_/.test(token.get('night_vision_effect'))){ + dp = (parseFloat(token.get('night_vision_effect').replace(/^Dimming_/,''))||0)*pnv; + } + + if(match){ + let dist; + switch(match[3]){ + + // handle percentage + case '%': { + let p = parseFloat(match[2])||0; + if(p>1){ + p*=.01; + } + p = Math.min(1,p); + + dist = p*pnv; + } + break; + + // handle units + default: { + let page=getObj('page',token.get('pageid')); + if(page){ + dist = numberOp.ConvertUnitsRoll20(match[2],match[3],page); + } + else { + dist=5; + } + } + break; + } + switch(match[1]){ + default: + case '=': + factor=(dist/pnv); + break; + + case '+': + factor=((dist+dp)/pnv); + break; + case '-': + factor=((dp-dist)/pnv); + break; + case '*': + factor=((dist*dp)/pnv); + break; + case '/': + factor=((dp/dist)/pnv); + break; + } + } else { + factor=(5/pnv); + } + + return `Dimming_${Math.min(1,Math.max(0,factor))}`; + }, + ['nocturnal']: ()=>()=>'Nocturnal' + }, + bar_location: { + __default__ : ()=>null, + off : ()=>null, + none : ()=>null, + ['above'] : ()=>null, + ['overlap_top'] : ()=>'overlap_top', + ['overlap_bottom'] : ()=>'overlap_bottom', + ['below'] : ()=>'below' + }, + compact_bar: { + __default__ : ()=>null, + off : ()=>null, + none : ()=>null, + ['compact'] : ()=>'compact', + ['on'] : ()=>'compact' + } + }; + + const regex = { + moveAngle: /^(=)?([+-]?(?:0|[1-9][0-9]*))(!)?$/, + moveDistance: /^([+-]?\d+\.?|\d*\.\d+)(u|g|s|ft|m|km|mi|in|cm|un|hex|sq)?$/i, + numberString: /^[-+*/=]?[-+]?(0|[1-9][0-9]*)([.]+[0-9]*)?([eE][-+]?[0-9]+)?(!)?$/, + stripSingleQuotes: /'([^']+(?='))'/g, + stripDoubleQuotes: /"([^"]+(?="))"/g, + layers: /^(?:gmlayer|objects|map|walls)$/, + + imgsrc: /(.*\/images\/.*)(thumb|med|original|max)(.*)$/, + imageOp: /^(?:(-(?:\d*(?:\s*,\s*\d+)*|\*)$)|(\/(?:\d+@\d+(?:\s*,\s*\d+@\d+)*|\*)$)|([+^]))?(=?)(?:(https?:\/\/.*$)|([-\d\w]*))(?::(.*))?$/, + sideNumber: /^(\?)?([-+=*])?(\d*)$/, + color : { + ops: '([*=+\\-!])?', + transparent: '(transparent)', + html: '#?((?:[0-9a-f]{6})|(?:[0-9a-f]{3}))', + rgb: '(rgb\\(\\s*(?:(?:\\d*\\.\\d+)\\s*,\\s*(?:\\d*\\.\\d+)\\s*,\\s*(?:\\d*\\.\\d+)|(?:\\d+)\\s*,\\s*(?:\\d+)\\s*,\\s*(?:\\d+))\\s*\\))', + hsv: '(hsv\\(\\s*(?:(?:\\d*\\.\\d+)\\s*,\\s*(?:\\d*\\.\\d+)\\s*,\\s*(?:\\d*\\.\\d+)|(?:\\d+)\\s*,\\s*(?:\\d+)\\s*,\\s*(?:\\d+))\\s*\\))' + } + }; + + const colorOpReg = new RegExp(`^${regex.color.ops}(?:${regex.color.transparent}|${regex.color.html}|${regex.color.rgb}|${regex.color.hsv})$`,'i'); + const colorReg = new RegExp(`^(?:${regex.color.transparent}|${regex.color.html}|${regex.color.rgb}|${regex.color.hsv})$`,'i'); + const colorParams = /\(\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\s*\)/; + + + + //////////////////////////////////////////////////////////// + // Number Operations + //////////////////////////////////////////////////////////// + + class numberOp { + static parse(field, str, permitBlank=true) { + const regexp = /^([=+\-/*!])?(-?\d+\.?|-?\d*\.\d+)(u|g|s|ft|m|km|mi|in|cm|un|hex|sq)?(!)?$/i; // */ + + if(!str.length && permitBlank){ + return new numberOp(field, '','','' ); + } + + let m = `${str}`.match(regexp); + + if(m){ + let oper = m[1]||''; + let num = parseFloat(m[2]); + let scale = m[3]||''; + let enforceBounds = '!'===m[4]; + + return new numberOp(field, oper, num, scale.toLowerCase(),enforceBounds); + } + return {getMods:()=>({})}; + } + + constructor(field,op,num,units,enforce){ + this.field=field; + this.operation = op; + this.num = num; + this.units = units; + this.enforce = enforce; + } + + static ConvertUnitsPixel(num,unit,page){ + const unitSize = 70; + switch(unit){ + case 'u': + return num*unitSize; + + case 'g': + return num*(parseFloat(page.get('snapping_increment'))*unitSize); + + case 'ft': + case 'm': + case 'km': + case 'mi': + case 'in': + case 'cm': + case 'un': + case 'hex': + case 'sq': + case 's': + return (num/(parseFloat(page.get('scale_number'))||1))*unitSize; + default: + return num; + } + } + + static ConvertUnitsRoll20(num,unit,page){ + switch(unit){ + case 'u': + return num*(parseFloat(page.get('scale_number'))*(1/parseFloat(page.get('snapping_increment'))||1)); + + case 'g': + return num*parseFloat(page.get('scale_number')); + + default: + case 'ft': + case 'm': + case 'km': + case 'mi': + case 'in': + case 'cm': + case 'un': + case 'hex': + case 'sq': + case 's': + return num; + } + } + + getMods(token,mods){ + let num = this.num; + let page = getObj('page',token.get('pageid')); + switch(this.field){ + + case 'light_radius': + case 'light_dimradius': + case 'aura2_radius': + case 'aura1_radius': + case 'adv_fow_view_distance': + case 'night_vision_distance': + case 'bright_light_distance': + case 'low_light_distance': + case 'night_distance': + case 'bright_distance': + case 'low_distance': + num = numberOp.ConvertUnitsRoll20(num,this.units,page); + break; + + default: + case 'left': + case 'top': + case 'width': + case 'height': + num = numberOp.ConvertUnitsPixel(num,this.units,page); + break; + + case 'light_multiplier': + case 'light_sensitivity_multiplier': + break; + + } + + let current = parseFloat(token.get(this.field))||0; + const getValue = (k,m,t) => m.hasOwnProperty(k) ? m[k] : t.get(k); + + let adjuster = (a)=>a; + + switch(this.field){ + case 'bar1_value': + case 'bar2_value': + case 'bar3_value': + if(this.enforce){ + adjuster = (a,t)=>Math.max(0,Math.min(a,t.get(this.field.replace(/_value/,'_max')))); + } + break; + + case 'night_vision_distance': + adjuster = (a,token,mods) => { + let pnv; + if(mods.hasOwnProperty('night_vision_distance')){ + pnv = mods.night_vision_distance; + } else { + pnv = token.get('night_vision_distance'); + } + + let dp; + if(mods.hasOwnProperty('night_vision_effect') && /^Dimming_/.test(mods.night_vision_effect)){ + dp = parseFloat(mods.night_vision_effect.replace(/^Dimming_/,''))||undefined; + } else if(/^Dimming_/.test(token.get('night_vision_effect'))){ + dp = parseFloat(token.get('night_vision_effect').replace(/^Dimming_/,''))||undefined; + } + + if(undefined !== dp) { + let dd = 0; + if(dp>0){ + dd = parseFloat(pnv)*dp; + dp=Math.min(1,parseFloat(dd.toFixed(2))/a); + } + + mods.night_vision_effect=`Dimming_${dp}`; + } + return a; + }; + break; + } + + switch(this.operation){ + default: + case '=': { + switch(this.field){ + case 'bright_light_distance': + num=num||0; + return { + bright_light_distance: num, + low_light_distance: (parseFloat(getValue('low_light_distance',mods,token)||0)-(parseFloat(getValue('bright_light_distance',mods,token))||0)+num) + }; + case 'low_light_distance': + num=num||0; + return { + low_light_distance: ((parseFloat(getValue('bright_light_distance',mods,token))||0)+num) + }; + + default: + return {[this.field]:adjuster(num,token,mods)}; + } + } + case '!': return {[this.field]:adjuster((current===0 ? num : ''),token,mods)}; + case '+': return {[this.field]:adjuster((current+num),token,mods)}; + case '-': return {[this.field]:adjuster((current-num),token,mods)}; + case '/': return {[this.field]:adjuster((current/(num||1)),0,mods)}; + case '*': return {[this.field]:adjuster((current*num),0,mods)}; + } + } + + } + + //////////////////////////////////////////////////////////// + // Image Operations + //////////////////////////////////////////////////////////// + + + class imageOp { + static parseImage(input){ + const OP_REMOVE_BY_INDEX = 1; + const OP_REORDER = 2; + const OP_OPERATION = 3; + const OP_EXPLICIT_SET = 4; + const OP_IMAGE_URL = 5; + const OP_TOKEN_ID = 6; + const OP_TOKEN_SIDE_INDEX = 7; + + let parsed = input.match(regex.imageOp); + + if(parsed && parsed.length){ + if(parsed[ OP_REMOVE_BY_INDEX ]){ + let idxs=parsed[ OP_REMOVE_BY_INDEX ].slice(1); + return new imageOp('-',false, + '*'===idxs + ? ['*'] + : idxs.split(/\s*,\s*/).filter(s=>s.length).map((idx)=>parseInt(idx,10)-1) + ); + } else if(parsed[ OP_REORDER ]){ + let idxs=parsed[ OP_REORDER ].slice(1); + + return new imageOp('/',false, + idxs.split(/\s*,\s*/) + .filter(s=>s.length) + .map((idx)=>{ + let parts = idx.split(/@/); + return { + idx: (parseInt(parts[0])-1), + pos: (parseInt(parts[1])) + }; + }) + ); + } else { + let op = parsed[ OP_OPERATION ]||'_'; + let set = '='===parsed[ OP_EXPLICIT_SET ]; + if(parsed[ OP_IMAGE_URL ]){ + + let parts = parsed[ OP_IMAGE_URL ].split(/:@/); + let url=getCleanImgsrc(parts[0]); + if(url){ + return new imageOp(op,set,[],[{url,index:parseInt(parts[1])||undefined}]); + } + } else { + let id = parsed[ OP_TOKEN_ID ]; + let t = getObj('graphic',id); + + if(t){ + if(parsed[ OP_TOKEN_SIDE_INDEX ]){ + let sides = t.get('sides'); + if(sides.length){ + sides = sides.split(/\|/).map(decodeURIComponent).map(getCleanImgsrc); + } else { + sides = [getCleanImgsrc(t.get('imgsrc'))]; + } + let urls=[]; + let idxs; + if('*'===parsed[ OP_TOKEN_SIDE_INDEX ]){ + idxs=sides.reduce((m,v)=> ({ c:m.c+1, i:(v?[...m.i,m.c]:m.i) }), {c:0,i:[]}).i.map(id=>({idx:id})); + } else { + idxs=parsed[ OP_TOKEN_SIDE_INDEX ] + .split(/\s*,\s*/) + .filter(s=>s.length) + .map((idx)=>({ + idx: (parseInt(idx,10)||1)-1, + insert: parseInt(idx.split(/@/)[1])||undefined + })); + } + idxs.forEach((i)=>{ + if(sides[i.idx]){ + urls.push({url:sides[i.idx], index: i.insert }); + } + }); + + if(urls.length){ + return new imageOp(op,set,[],urls); + } + } else { + let url=getCleanImgsrc(t.get('imgsrc')); + if(url){ + return new imageOp(op,set,[],[{url}]); + } + } + } + } + } + } + return new imageOp(); + } + + constructor(op,set,indicies,urls){ + this.op = op||'/'; + this.set = set || false; + this.indicies=indicies||[]; + this.urls=urls||[]; + } + + getMods(token /* ,mods */){ + let sideText = token.get('sides'); + let sides; + + + if( sideText.length ){ + sides = sideText.split(/\|/).map(decodeURIComponent).map(getCleanImgsrc); + } else { + sides = [getCleanImgsrc(token.get('imgsrc'))]; + if('^' === this.op){ + this.op = '_'; + } + } + + switch(this.op) { + case '-': { + if('*'===this.indicies[0]){ + return { + currentSide: 0, + sides: '' + }; + } + let currentSide=token.get('currentSide'); + if(this.indicies.length){ + this.indicies.forEach((i)=>{ + if(currentSide===i){ + currentSide=0; + } + delete sides[i]; + }); + } else { + delete sides[currentSide]; + currentSide=0; + } + let idxs=sides.reduce((m,v)=> ({ c:m.c+1, i:(v?[...m.i,m.c]:m.i) }), {c:0,i:[]}).i; + sides=sides.reduce((m,s)=>m.concat( s ? [s] : []),[]); + currentSide=Math.max(_.indexOf(idxs,currentSide),0); + if(sides.length){ + return { + imgsrc: sides[currentSide], + currentSide: currentSide, + sides: sides.reduce((m,s)=>m.concat(s),[]).map(encodeURIComponent).join('|') + }; + } + return { + currentSide: 0, + sides: '' + }; + } + + case '/': { + let currentSide=token.get('currentSide'); + let imgsrc=token.get('imgsrc'); + let sidesOld=token.get('sides'); + + sides = this.indicies.reduce( (s,o) => { + let url = s[o.idx]; + s.splice(o.idx,1); + return [...s.slice(0,(o.pos||Number.MAX_SAFE_INTEGER)-1), url, ...s.slice((o.pos||Number.MAX_SAFE_INTEGER)-1)]; + },sides); + + + let retr = { + sides: sides.map(encodeURIComponent).join('|') + }; + if(retr.sides===sidesOld){ + delete retr.sides; + } + + if(imgsrc !== sides[currentSide]){ + retr.imgsrc=sides[currentSide]; + } + return retr; + } + + case '_': + return { + imgsrc: this.urls[0].url + }; + + case '^': { + // replacing + let currentSide=token.get('currentSide'); + let imgsrc=token.get('imgsrc'); + + sides = this.urls.reduce((s,u) => { + let replaceIdx =(u.index||Number.MAX_SAFE_INTEGER)-1; + if(sides.hasOwnProperty(replaceIdx)){ + sides[replaceIdx] = u.url; + } else { + sides.push(u.url); + } + return sides; + },sides); + + let retr = { + sides: sides.map(encodeURIComponent).join('|') + }; + if(this.set){ + retr.imgsrc=sides.slice(-1)[0]; + retr.currentSide=sides.length-1; + } + if(imgsrc !== sides[currentSide]){ + retr.imgsrc=sides[currentSide]; + } + return retr; + } + + case '+': { + + // appending + let currentSide=token.get('currentSide'); + let imgsrc=token.get('imgsrc'); + sides = this.urls.reduce((s,u) => + [...s.slice(0,(u.index||Number.MAX_SAFE_INTEGER)-1), u.url, ...s.slice((u.index||Number.MAX_SAFE_INTEGER)-1)] + ,sides); + let retr = { + sides: sides.map(encodeURIComponent).join('|') + }; + if(this.set){ + retr.imgsrc=sides.slice(-1)[0]; + retr.currentSide=sides.length-1; + } + if(imgsrc !== sides[currentSide]){ + retr.imgsrc=sides[currentSide]; + } + return retr; + } + } + return {}; + } + } + + //////////////////////////////////////////////////////////// + // Side Numbers + //////////////////////////////////////////////////////////// + + class sideNumberOp { + + static parseSideNumber(input){ + const OP_FLAG = 1; + const OP_OPERATION = 2; + const OP_COUNT = 3; + let parsed = input.toLowerCase().match(regex.sideNumber); + if(parsed && parsed.length){ + return new sideNumberOp( parsed[ OP_FLAG ], parsed[ OP_OPERATION ], parsed[ OP_COUNT ] ); + } + return new sideNumberOp(false,'/'); + } + + constructor(flag,op,count){ + this.flag=flag||false; + this.operation=op||'='; + this.count=(parseInt(`${count}`)||1); + } + + + getMods(token /*,mods */){ + // get sides + let sides = token.get('sides').split(/\|/).map(decodeURIComponent).map(getCleanImgsrc); + switch(this.operation){ + case '/': + return {}; + case '=': + if(sides[this.count-1]){ + return { + currentSide: this.count-1, + imgsrc: sides[this.count-1] + }; + } + return {}; + case '*': { + // get indexes that are valid + let idxs=sides.reduce((m,v)=> ({ c:m.c+1, i:(v?[...m.i,m.c]:m.i) }), {c:0,i:[]}).i; + if(idxs.length){ + let idx=_.sample(idxs); + return { + currentSide: idx, + imgsrc: sides[idx] + }; + } + return {}; + } + case '+': + case '-': { + let idx = token.get('currentSide')||0; + idx += ('-'===this.operation ? -1 : 1)*this.count; + if(this.flag){ + idx=Math.max(Math.min(idx,sides.length-1),0); + } else { + idx=(idx%sides.length)+(idx<0 ? sides.length : 0); + } + if(sides[idx]){ + return { + currentSide: idx, + imgsrc: sides[idx] + }; + } + return {}; + } + + } + + } + } + + + //////////////////////////////////////////////////////////// + // Colors + //////////////////////////////////////////////////////////// + + class Color { + static hsv2rgb(h, s, v) { + let r, g, b; + + let i = Math.floor(h * 6); + let f = h * 6 - i; + let p = v * (1 - s); + let q = v * (1 - f * s); + let t = v * (1 - (1 - f) * s); + + switch (i % 6) { + case 0: r = v, g = t, b = p; break; + case 1: r = q, g = v, b = p; break; + case 2: r = p, g = v, b = t; break; + case 3: r = p, g = q, b = v; break; + case 4: r = t, g = p, b = v; break; + case 5: r = v, g = p, b = q; break; + } + + return { r , g , b }; + } + + static rgb2hsv(r,g,b) { + let max = Math.max(r, g, b), + min = Math.min(r, g, b); + let h, s, v = max; + + let d = max - min; + s = max == 0 ? 0 : d / max; + + if (max == min) { + h = 0; // achromatic + } else { + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + + h /= 6; + } + + return { h, s, v }; + } + + static dec2hex (n){ + n = (Math.max(Math.min(Math.round(n*255),255), 0)||0); + return `${n<16?'0':''}${n.toString(16)}`; + } + + static hex2dec (n){ + return Math.max(Math.min(parseInt(n,16),255), 0)/255; + } + + static html2rgb(htmlstring){ + let s=htmlstring.toLowerCase().replace(/[^0-9a-f]/,''); + if(3===s.length){ + s=`${s[0]}${s[0]}${s[1]}${s[1]}${s[2]}${s[2]}`; + } + return { + r: this.hex2dec(s.substr(0,2)), + g: this.hex2dec(s.substr(2,2)), + b: this.hex2dec(s.substr(4,2)) + }; + } + + static parseRGBParam(p){ + if(/\./.test(p)){ + return parseFloat(p); + } + return parseInt(p,10)/255; + } + static parseHSVParam(p,f){ + if(/\./.test(p)){ + return parseFloat(p); + } + switch(f){ + case 'h': + return parseInt(p,10)/360; + case 's': + case 'v': + return parseInt(p,10)/100; + } + } + + static parseColor(input){ + return Color.buildColor(`${input}`.toLowerCase().match(colorReg)); + } + static buildColor(parsed){ + const idx = { + transparent: 1, + html: 2, + rgb: 3, + hsv: 4 + }; + + if(parsed){ + let c = new Color(); + if(parsed[idx.transparent]){ + c.type = 'transparent'; + } else if(parsed[idx.html]){ + c.type = 'rgb'; + _.each(Color.html2rgb(parsed[idx.html]),(v,k)=>{ + c[k]=v; + }); + } else if(parsed[idx.rgb]){ + c.type = 'rgb'; + let params = parsed[idx.rgb].match(colorParams); + c.r= Color.parseRGBParam(params[1]); + c.g= Color.parseRGBParam(params[2]); + c.b= Color.parseRGBParam(params[3]); + } else if(parsed[idx.hsv]){ + c.type = 'hsv'; + let params = parsed[idx.hsv].match(colorParams); + c.h= Color.parseHSVParam(params[1],'h'); + c.s= Color.parseHSVParam(params[2],'s'); + c.v= Color.parseHSVParam(params[3],'v'); + } + return c; + } + return new Color(); + } + + constructor(){ + this.type='transparent'; + } + + clone(){ + return Object.assign(new Color(), this); + } + + toRGB(){ + if('hsv'===this.type){ + _.each(Color.hsv2rgb(this.h,this.s,this.v),(v,k)=>{ + this[k]=v; + }); + this.type='rgb'; + } else if ('transparent' === this.type){ + this.type='rgb'; + this.r=0.0; + this.g=0.0; + this.b=0.0; + } + delete this.h; + delete this.s; + delete this.v; + return this; + } + + toHSV(){ + if('rgb'===this.type){ + _.each(Color.rgb2hsv(this.r,this.g,this.b),(v,k)=>{ + this[k]=v; + }); + this.type='hsv'; + } else if('transparent' === this.type){ + this.type='hsv'; + this.h=0.0; + this.s=0.0; + this.v=0.0; + } + + delete this.r; + delete this.g; + delete this.b; + + return this; + } + + toHTML(){ + switch(this.type){ + case 'transparent': + return 'transparent'; + case 'hsv': { + return this.clone().toRGB().toHTML(); + } + case 'rgb': + return `#${Color.dec2hex(this.r)}${Color.dec2hex(this.g)}${Color.dec2hex(this.b)}`; + } + } + } + + class ColorOp extends Color { + + constructor( op ) { + super(); + this.operation = op; + } + + static parseColor(input){ + const idx = { + ops: 1, + transparent: 2, + html: 3, + rgb: 4, + hsv: 5 + }; + + let parsed = `${input}`.toLowerCase().match(colorOpReg)||[]; + + if(parsed.length) { + return Object.assign(new ColorOp(parsed[idx.ops]||'='), Color.buildColor(parsed.slice(1))); + } else { + return Object.assign(new ColorOp(parsed[idx.ops]||(input.length ? '*':'=')), Color.parseColor('transparent')); + } + } + + applyTo(c){ + if( !(c instanceof Color) ){ + c = Color.parseColor(c); + } + switch(this.operation){ + case '=': + return this; + case '!': + return ('transparent'===c.type ? this : Color.parseColor('transparent')); + } + switch(this.type){ + case 'transparent': + return c; + case 'hsv': + c.toHSV(); + switch(this.operation){ + case '*': + c.h*=this.h; + c.s*=this.s; + c.v*=this.v; + c.toRGB(); + return c; + case '+': + c.h+=this.h; + c.s+=this.s; + c.v+=this.v; + c.toRGB(); + return c; + case '-': + c.h-=this.h; + c.s-=this.s; + c.v-=this.v; + c.toRGB(); + return c; + } + break; + case 'rgb': + c.toRGB(); + switch(this.operation){ + case '*': + c.r*=this.r; + c.g*=this.g; + c.b*=this.b; + return c; + case '+': + c.r+=this.r; + c.g+=this.g; + c.b+=this.b; + return c; + case '-': + c.r-=this.r; + c.g-=this.g; + c.b-=this.b; + return c; + } + } + + return c; + } + + + toString(){ + let extra =''; + switch (this.type){ + case 'transparent': + extra='(0.0, 0.0, 0.0, 1.0)'; + break; + case 'rgb': + extra=`(${this.r},${this.g},${this.b})`; + break; + case 'hsv': + extra=`(${this.h},${this.s},${this.v})`; + break; + } + return `${this.operation} ${this.type}${extra} ${this.toHTML()}`; + } + + } + + //////////////////////////////////////////////////////////// + // StatusMarkers + //////////////////////////////////////////////////////////// + + class TokenMarker { + constructor( name, tag, url ) { + this.name = name; + this.tag = tag; + this.url = url; + } + + getName() { + return this.name; + } + getTag() { + return this.tag; + } + + getHTML(scale = 1.4){ + return `
    `; + } + } + + class ColorDotTokenMarker extends TokenMarker { + constructor( name, color ) { + super(name,name); + this.color = color; + } + + getHTML(scale = 1.4){ + return `
    `; + } + } + + class ColorTextTokenMarker extends TokenMarker { + constructor( name, letter, color ) { + super(name,name); + this.color = color; + this.letter = letter; + } + + getHTML(scale = 1.4){ + return `
    ${this.letter}
    `; + } + } + + // legacy + class ImageStripTokenMarker extends TokenMarker { + constructor( name, offset){ + super(name, name); + this.offset = offset; + } + + getHTML(scale = 1.4){ + const ratio = 2.173913; + const statusSheet = 'https://app.roll20.net/images/statussheet.png'; + + return `
    `; + } + } + + class StatusMarkers { + + static init(){ + let tokenMarkers = {}; + let orderedLookup = new Set(); + let reverseLookup = {}; + + const insertTokenMarker = (tm) => { + tokenMarkers[tm.getTag()] = tm; + orderedLookup.add(tm.getTag()); + + reverseLookup[tm.getName()] = reverseLookup[tm.getName()]||[]; + reverseLookup[tm.getName()].push(tm.getTag()); + }; + + const buildStaticMarkers = () => { + insertTokenMarker(new ColorDotTokenMarker('red', '#C91010')); + insertTokenMarker(new ColorDotTokenMarker(`blue`, '#1076c9')); + insertTokenMarker(new ColorDotTokenMarker(`green`, '#2fc910')); + insertTokenMarker(new ColorDotTokenMarker(`brown`, '#c97310')); + insertTokenMarker(new ColorDotTokenMarker(`purple`, '#9510c9')); + insertTokenMarker(new ColorDotTokenMarker(`pink`, '#eb75e1')); + insertTokenMarker(new ColorDotTokenMarker(`yellow`, '#e5eb75')); + + insertTokenMarker(new ColorTextTokenMarker('dead', 'X', '#cc1010')); + }; + + const buildLegacyMarkers = () => { + const legacyNames = [ + 'skull', 'sleepy', 'half-heart', 'half-haze', 'interdiction', + 'snail', 'lightning-helix', 'spanner', 'chained-heart', + 'chemical-bolt', 'death-zone', 'drink-me', 'edge-crack', + 'ninja-mask', 'stopwatch', 'fishing-net', 'overdrive', 'strong', + 'fist', 'padlock', 'three-leaves', 'fluffy-wing', 'pummeled', + 'tread', 'arrowed', 'aura', 'back-pain', 'black-flag', + 'bleeding-eye', 'bolt-shield', 'broken-heart', 'cobweb', + 'broken-shield', 'flying-flag', 'radioactive', 'trophy', + 'broken-skull', 'frozen-orb', 'rolling-bomb', 'white-tower', + 'grab', 'screaming', 'grenade', 'sentry-gun', 'all-for-one', + 'angel-outfit', 'archery-target' + ]; + legacyNames.forEach( (n,i)=>insertTokenMarker(new ImageStripTokenMarker(n,i))); + }; + + const readTokenMarkers = () => { + JSON.parse(Campaign().get('_token_markers')||'[]').forEach( tm => insertTokenMarker(new TokenMarker(tm.name, tm.tag, tm.url))); + }; + + StatusMarkers.getStatus = (keyOrName) => { + if(tokenMarkers.hasOwnProperty(keyOrName)){ + return tokenMarkers[keyOrName]; + } + if(reverseLookup.hasOwnProperty(keyOrName)){ + return tokenMarkers[reverseLookup[keyOrName][0]]; // returning first one... + } + // maybe return a null status marker object? + }; + + StatusMarkers.getOrderedList = () => { + return [...orderedLookup].map( key => tokenMarkers[key]); + }; + + const simpleObj = o => JSON.parse(JSON.stringify(o||'{}')); + + + buildStaticMarkers(); + if(simpleObj(Campaign()).hasOwnProperty('_token_markers')){ + readTokenMarkers(); + } else { + buildLegacyMarkers(); + } + } + } + + class statusOp { + + static decomposeStatuses(statuses){ + return _.reduce(statuses.split(/,/),function(memo,st,idx){ + let parts=st.split(/@/), + entry = { + mark: parts[0], + num: parseInt(parts[1],10), + idx: idx + }; + if(isNaN(entry.num)){ + entry.num=''; + } + if(parts[0].length) { + memo[parts[0]] = ( memo[parts[0]] && memo[parts[0]].push(entry) && memo[parts[0]]) || [entry] ; + } + return memo; + },{}); + } + + static composeStatuses(statuses){ + return _.chain(statuses) + .reduce(function(m,s){ + _.each(s,function(sd){ + m.push(sd); + }); + return m; + },[]) + .sortBy(function(s){ + return s.idx; + }) + .map( (s) => ('dead'===s.mark ? 'dead' : ( s.mark+(s.num!=='' ? '@'+s.num : ''))) ) + .value() + .join(','); + } + + static parse(status) { + let s = status.split(/[:;]/); + if(s.hasOwnProperty(1) && 0 === s[1].length){ + s = [`${s[0]}::${s[2]}`,...s.slice(3)]; + } + let statparts = s.shift().match(/^(\S+?)(\[(\d*)\]|)$/)||[]; + let index = ( '[]' === statparts[2] ? statparts[2] : ( undefined !== statparts[3] ? Math.max(parseInt(statparts[3],10)-1,0) : 0 ) ); + + let stat=statparts[1]||''; + let op = (_.contains(['*','/','-','+','=','!','?'], stat[0]) ? stat[0] : false); + let numraw = s.shift() || ''; + let min = Math.min(Math.max(parseInt(s.shift(),10)||0, 0),9); + let max = Math.max(Math.min(parseInt(s.shift(),10)||9,9),0); + let numop = (_.contains(['*','/','-','+'],numraw[0]) ? numraw[0] : false); + let num = Math.max(0,Math.min(9,Math.abs(parseInt(numraw,10)))); + + if(isNaN(num)){ + num = ''; + } + + stat = ( op ? stat.substring(1) : stat); + + let tokenMarker = StatusMarkers.getStatus(stat); + + if(tokenMarker) { + return new statusOp( + tokenMarker, + { + status: tokenMarker.getTag(), + number: num, + index: index, + sign: numop, + min: (minmin?max:min), + operation: op || '+' + }); + } + if('=' === op && stat.length===0){ + return {getMods:(/*c*/)=>({statusmarkers:''})}; + } + + return {getMods:(c)=>({statusmarkers:c})}; + } + + constructor(tm, ops) { + this.tokenMarker = tm; + this.ops = ops; + } + + getMods(statuses='') { + let current = statusOp.decomposeStatuses(statuses); + let statusCount=(statuses).split(/,/).length; + let sm = this.ops; + + switch(sm.operation){ + case '!': + if('[]' !== sm.index && _.has(current,sm.status) ){ + if( _.has(current[sm.status],sm.index) ) { + current[sm.status]= _.filter(current[sm.status],function(e,idx){ + return idx !== sm.index; + }); + } else { + current[sm.status] = current[sm.status] || []; + current[sm.status].push({ + mark: sm.status, + num: (sm.number !=='' ? Math.max(sm.min,Math.min(sm.max,getRelativeChange(0, sm.sign+sm.number))):''), + index: statusCount++ + }); + } + } else { + current[sm.status] = current[sm.status] || []; + current[sm.status].push({ + mark: sm.status, + num: (sm.number!=='' ? Math.max(sm.min,Math.min(sm.max,getRelativeChange(0, sm.sign+sm.number))):''), + index: statusCount++ + }); + } + break; + case '?': + if('[]' !== sm.index && _.has(current,sm.status) && _.has(current[sm.status],sm.index)){ + current[sm.status][sm.index].num = (sm.number !== '') ? (Math.max(sm.min,Math.min(sm.max,getRelativeChange(current[sm.status][sm.index].num, sm.sign+sm.number)))) : ''; + + if([0,''].includes(current[sm.status][sm.index].num)) { + current[sm.status]= _.filter(current[sm.status],function(e,idx){ + return idx !== sm.index; + }); + } + } + break; + case '+': + if('[]' !== sm.index && _.has(current,sm.status) && _.has(current[sm.status],sm.index)){ + current[sm.status][sm.index].num = (sm.number !== '') ? (Math.max(sm.min,Math.min(sm.max,getRelativeChange(current[sm.status][sm.index].num, sm.sign+sm.number)))) : ''; + } else { + current[sm.status] = current[sm.status] || []; + current[sm.status].push({ + mark: sm.status, + num: (sm.number!=='' ? Math.max(sm.min,Math.min(sm.max,getRelativeChange(0, sm.sign+sm.number))):''), + index: statusCount++ + }); + } + break; + case '-': + if('[]' !== sm.index && _.has(current,sm.status)){ + if( _.has(current[sm.status],sm.index )) { + current[sm.status]= _.filter(current[sm.status],function(e,idx){ + return idx !== sm.index; + }); + } + } else { + current[sm.status] = current[sm.status] || []; + current[sm.status].pop(); + } + break; + case '=': + current = {}; + current[sm.status] = []; + current[sm.status].push({ + mark: sm.status, + num: (sm.number!=='' ? Math.max(sm.min,Math.min(sm.max,getRelativeChange(0, sm.sign+sm.number))):''), + index: statusCount++ + }); + break; + } + return { + statusmarkers: statusOp.composeStatuses(current) + }; + } + } + + + //////////////////////////////////////////////////////////// + // moveOp ////////////////////////////////////////////////// + //////////////////////////////////////////////////////////// + + class moveOp { + static parse(args){ + const identity = {getMods:() => ({})}; + let angle = 0; + let relativeAngle = true; + let updateAngle = false; + let distance = 0; + let units = ''; + + if(args.length>1){ + let match = args.shift().match(regex.moveAngle); + if(match) { + angle = transforms.degrees(match[2]); + relativeAngle = '='!==match[1]; + updateAngle = '!'===match[3]; + } else { + return identity; + } + } + + { + let match = args.shift().match(regex.moveDistance); + if(match){ + distance = match[1]; + units = match[2]; + } else { + return identity; + } + } + return new moveOp( + angle, + relativeAngle, + updateAngle, + distance, + units + ); + + } + + constructor(angle,relativeAngle,updateAngle,distance,units){ + this.angle = angle; + this.relativeAngle = relativeAngle; + this.updateAngle = updateAngle; + this.distance = distance; + this.units = units; + } + + getMods(token,mods){ + const getValue = (k) => mods.hasOwnProperty(k) ? mods[k] : token.get(k); + // find angle + // find postion from current by distance over angle. + // if current last move start with the token current position, update. + let angle = 0; + if(this.relativeAngle){ + angle = parseFloat(getValue('rotation')); + } + angle = (transforms.degrees(angle+this.angle)||0); + let radAngle = (angle-90) * (Math.PI/180); + + let page = getObj('page',token.get('pageid')); + if(page){ + let distance = numberOp.ConvertUnitsPixel(this.distance,this.units,page); + let cx = getValue('left'); + let cy = getValue('top'); + let lm = getValue('lastmove'); + if(mods.hasOwnProperty('lastmove')){ + lm +=`,${cx},${cy}`; + } else { + lm = `${cx},${cy}`; + } + + let x = cx+(distance*Math.cos(radAngle)); + let y = cy+(distance*Math.sin(radAngle)); + let props = { + lastmove: lm, + top: y, + left: x + }; + if(this.updateAngle){ + props.rotation = angle; + } + return props; + } + return {}; + } + } + + //////////////////////////////////////////////////////////// + // IsComputedAttr ////////////////////////////////////////// + //////////////////////////////////////////////////////////// + const getComputedProxy = ("undefined" !== typeof getComputed) + ? async (...a) => await getComputed(...a) + : async ()=>{} + ; + + class IsComputedAttr { + static #computedMap = new Map(); + static #sheetMap = new Map(); + + static async DoReady() { + let c = Campaign(); + Object.keys(c?.computedSummary||{}).forEach(k=>{ + IsComputedAttr.#computedMap.set(k,c.computedSummary[k]); + }); + + let cMap = findObjs({type:"character"}).reduce((m,c)=>({...m,[c.get('charactersheetname')]:c.id}),{}); + let promises = Object.keys(cMap).map(async c => { + let k = IsComputedAttr.#computedMap.keys().next().value; + if(k) { + let v = await getComputedProxy({characterId:cMap[c],property:k}); + IsComputedAttr.#sheetMap.set(c, undefined !== v); + } + }); + await Promise.all(promises); + } + + static Check(attrName) { + return IsComputedAttr.#computedMap.has(attrName); + } + + static Assignable(attrName) { + return IsComputedAttr.#computedMap.get(attrName)?.tokenBarValue ?? false; + } + + static Readonly(attrName) { + return IsComputedAttr.#computedMap.get(attrName)?.readonly ?? true; + } + + static IsComputed(sheet,attrName) { + let sheetName = sheet.get('charactersheetname'); + + if(IsComputedAttr.Check(attrName) && IsComputedAttr.#sheetMap.has(sheetName)){ + return IsComputedAttr.#sheetMap.get(sheetName); + } + return false; + } + + } + on('ready',IsComputedAttr.DoReady); + + //////////////////////////////////////////////////////////// + + + + + let observers = { + tokenChange: [] + }; + + const getActivePages = () => [...new Set([ + Campaign().get('playerpageid'), + ...Object.values(Campaign().get('playerspecificpages')), + ...findObjs({ + type: 'player', + online: true + }) + .filter((p)=>playerIsGM(p.id)) + .map((p)=>p.get('lastpage')) + ]) + ]; + + const getPageForPlayer = (playerid) => { + let player = getObj('player',playerid); + if(playerIsGM(playerid)){ + return player.get('lastpage') || Campaign().get('playerpageid'); + } + + let psp = Campaign().get('playerspecificpages'); + if(psp[playerid]){ + return psp[playerid]; + } + + return Campaign().get('playerpageid'); + }; + + + const transforms = { + percentage: (p)=>{ + let n = parseFloat(p); + if(!_.isNaN(n)){ + if(n > 1){ + n = Math.min(1,Math.max(n/100,0)); + } else { + n = Math.min(1,Math.max(n,0)); + } + } + return n; + }, + degrees: function(t){ + let n = parseFloat(t); + if(!_.isNaN(n)) { + n %= 360; + } + return n; + }, + circleSegment: function(t){ + let n = Math.abs(parseFloat(t)); + if(!_.isNaN(n)) { + n = Math.min(360,Math.max(0,n)); + } + return n; + }, + orderType: function(t){ + switch(t){ + case 'tofront': + case 'front': + case 'f': + case 'top': + return 'tofront'; + + case 'toback': + case 'back': + case 'b': + case 'bottom': + return 'toback'; + default: + return; + } + }, + keyHash: function(t){ + return (t && t.toLowerCase().replace(/\s+/,'_')) || undefined; + } + }; + + const checkGlobalConfig = function(){ + let s=state.TokenMod, + g=globalconfig && globalconfig.tokenmod; + + if(g && g.lastsaved && g.lastsaved > s.globalconfigCache.lastsaved){ + log(' > Updating from Global Config < ['+(new Date(g.lastsaved*1000))+']'); + + s.playersCanUse_ids = 'playersCanIDs' === g['Players can use --ids']; + state.TokenMod.globalconfigCache=globalconfig.tokenmod; + } + }; + + const assureHelpHandout = (create = false) => { + if(state.TheAaron && state.TheAaron.config && (false === state.TheAaron.config.makeHelpHandouts) ){ + return; + } + const helpIcon = "https://s3.amazonaws.com/files.d20.io/images/295769190/Abc99DVcre9JA2tKrVDCvA/thumb.png?1658515304"; + + // find handout + let props = {type:'handout', name:`Help: ${scriptName}`}; + let hh = findObjs(props)[0]; + if(!hh) { + hh = createObj('handout',Object.assign(props, {inplayerjournals: "all", avatar: helpIcon})); + create = true; + } + if(create || version !== state[scriptName].lastHelpVersion){ + hh.set({ + notes: helpParts.helpDoc({who:'handout',playerid:'handout'}) + }); + state[scriptName].lastHelpVersion = version; + log(' > Updating Help Handout to v'+version+' <'); + } + }; + + const checkInstall = function() { + log('-=> TokenMod v'+version+' <=- ['+(new Date(lastUpdate*1000))+']'); + + if( ! _.has(state,'TokenMod') || state.TokenMod.version !== schemaVersion) { + log(' > Updating Schema to v'+schemaVersion+' <'); + switch(state.TokenMod && state.TokenMod.version) { + + case 0.1: + case 0.2: + delete state.TokenMod.globalConfigCache; + state.TokenMod.globalconfigCache = {lastsaved:0}; + /* falls through */ + + case 0.3: + state.TokenMod.lastHelpVersion = version; + /* falls through */ + + case 'UpdateSchemaVersion': + state.TokenMod.version = schemaVersion; + break; + + default: + state.TokenMod = { + version: schemaVersion, + globalconfigCache: {lastsaved:0}, + playersCanUse_ids: false, + lastHelpVersion: version + }; + break; + } + } + checkGlobalConfig(); + StatusMarkers.init(); + assureHelpHandout(); + }; + + const observeTokenChange = function(handler){ + if(handler && _.isFunction(handler)){ + observers.tokenChange.push(handler); + } + }; + + const notifyObservers = function(event,obj,prev){ + _.each(observers[event],function(handler){ + try { + handler(obj,prev); + } catch(e) { + log(`TokenMod: An observer threw and exception in handler: ${handler}`); + } + }); + }; + + const getPlayerIDs = (function(){ + let age=0, + cache=[], + checkCache=function(){ + if(_.now()-60000>age){ + cache=_.chain(findObjs({type:'player'})) + .map((p)=>({ + id: p.id, + userid: p.get('d20userid'), + keyHash: transforms.keyHash(p.get('displayname')) + })) + .value(); + } + }, + findPlayer = function(data){ + checkCache(); + let pids=_.reduce(cache,(m,p)=>{ + if(p.id===data || p.userid===data || (-1 !== p.keyHash.indexOf(transforms.keyHash(data)))){ + m.push(p.id); + } + return m; + },[]); + if(!pids.length){ + let obj=filterObjs((o)=>(o.id===data && _.contains(['character','graphic'],o.get('type'))))[0]; + if(obj){ + let charObj = ('graphic'===obj.get('type') && getObj('character',obj.get('represents'))), + cb = (charObj ? charObj : obj).get('controlledby'); + pids = (cb.length ? cb.split(/,/) : []); + } + } + return pids; + }; + + return function(datum){ + return 'all'===datum ? ['all'] : findPlayer(datum); + }; + }()); + + const HE = (() => { + const esRE = (s) => s.replace(/(\\|\/|\[|\]|\(|\)|\{|\}|\?|\+|\*|\||\.|\^|\$)/g,'\\$1'); + const e = (s) => `&${s};`; + const entities = { + '<' : e('lt'), + '>' : e('gt'), + "'" : e('#39'), + '@' : e('#64'), + '{' : e('#123'), + '|' : e('#124'), + '}' : e('#125'), + '[' : e('#91'), + ']' : e('#93'), + '"' : e('quot') + }; + const re = new RegExp(`(${Object.keys(entities).map(esRE).join('|')})`,'g'); + return (s) => s.replace(re, (c) => (entities[c] || c) ); + })(); + + const ch = function (c) { + let entities = { + '<' : 'lt', + '>' : 'gt', + "'" : '#39', + '@' : '#64', + '{' : '#123', + '|' : '#124', + '}' : '#125', + '[' : '#91', + ']' : '#93', + '"' : 'quot', + '*' : 'ast', + '/' : 'sol', + ' ' : 'nbsp' + }; + + if(_.has(entities,c) ){ + return ('&'+entities[c]+';'); + } + return ''; + }; + + const getConfigOption_PlayersCanIDs = function() { + let text = ( state.TokenMod.playersCanUse_ids ? + 'ON' : + 'OFF' + ); + return '
    '+ + 'Players can IDs is currently '+ + text+ + ''+ + 'Toggle'+ + ''+ + '
    '; + + }; + + const _h = { + outer: (...o) => `
    ${o.join(' ')}
    `, + title: (t,v) => `
    ${t} v${v}
    `, + subhead: (...o) => `${o.join(' ')}`, + minorhead: (...o) => `${o.join(' ')}`, + optional: (...o) => `${ch('[')}${o.join(` ${ch('|')} `)}${ch(']')}`, + required: (...o) => `${ch('<')}${o.join(` ${ch('|')} `)}${ch('>')}`, + header: (...o) => `
    ${o.join(' ')}
    `, + section: (s,...o) => `${_h.subhead(s)}${_h.inset(...o)}`, + paragraph: (...o) => `

    ${o.join(' ')}

    `, + experimental: () => `
    Experimental
    `, + items: (o) => o.map(t=>`
  • ${t}
  • `).join(''), + ol: (...o) => `
      ${_h.items(o)}
    `, + ul: (...o) => `
      ${_h.items(o)}
    `, + grid: (...o) => `
    ${o.join('')}
    `, + cell: (o) => `
    ${o}
    `, + statusCell: (o) => { + let text = `${o.getName()}${o.getName()!==o.getTag()?` [${_h.code(o.getTag())}]`:''}`; + return `
    ${o.getHTML()}${text}
    `; + }, + helpHandoutLink: ()=>{ + let props = {type:'handout', name:`Help: ${scriptName}`}; + let hh = findObjs(props)[0]; + return `Help: ${scriptName}`; + }, + inset: (...o) => `
    ${o.join(' ')}
    `, + join: (...o) => o.join(' '), + pre: (...o) =>`
    ${o.join(' ')}
    `, + preformatted: (...o) =>_h.pre(o.join('
    ').replace(/\s/g,ch(' '))), + code: (...o) => `${o.join(' ')}`, + attr: { + bare: (o)=>`${ch('@')}${ch('{')}${o}${ch('}')}`, + selected: (o)=>`${ch('@')}${ch('{')}selected${ch('|')}${o}${ch('}')}`, + target: (o)=>`${ch('@')}${ch('{')}target${ch('|')}${o}${ch('}')}`, + char: (o,c)=>`${ch('@')}${ch('{')}${c||'CHARACTER NAME'}${ch('|')}${o}${ch('}')}` + }, + bold: (...o) => `${o.join(' ')}`, + italic: (...o) => `${o.join(' ')}`, + font: { + command: (...o)=>`${o.join(' ')}` + } + }; + + + const helpParts = { + commands: (/* context */) => _h.join( + _h.subhead('Commands'), + _h.inset( + _h.font.command( + `!token-mod `, + _h.required( + `--help`, + `--rebuild-help`, + `--help-statusmarkers`, + `--ignore-selected`, + `--current-page`, + `--active-pages`, + `--api-as`, + `--config`, + `--on`, + `--off`, + `--flip`, + `--set`, + `--move`, + `--report`, + `--order` + ), + _h.required(`parameter`), + _h.optional(`${_h.required(`parameter`)} ...`), + `...`, + _h.optional( + `--ids`, + _h.required(`token_id`), + _h.optional(`${_h.required(`token_id`)} ...`) + ) + ), + _h.paragraph('This command takes a list of modifications and applies them to the selected tokens (or tokens specified with --ids by a GM or Player depending on configuration).'), + _h.paragraph(`${_h.bold('Note:')} Each --option can be specified multiple times and in any order.`), + _h.paragraph(`${_h.bold('Note:')} If you are using multiple ${_h.attr.target('token_id')} calls in a macro, and need to adjust fewer than the supplied number of token ids, simply select the same token several times. The duplicates will be removed.`), + _h.paragraph(`${_h.bold('Note:')} Anywhere you use ${_h.code('|')}, you can use ${_h.code('#')} instead. Sometimes this makes macros easier.`), + _h.paragraph(`${_h.bold('Note:')} You can use the ${_h.code('{{')} and ${_h.code('}}')} to span multiple lines with your command for easier clarity and editing:`), + _h.inset( + _h.preformatted( + '!token-mod {{', + ' --on', + ' flipv', + ' fliph', + ' --set', + ' rotation|180', + ` bar1|${ch('[')+ch('[')}8d8+8${ch(']')+ch(']')}`, + ' light_radius|60', + ' light_dimradius|30', + ' name|"My bright token"', + '}}' + ) + ), + _h.ul( + `${_h.bold('--help')} -- Displays this help`, + `${_h.bold('--rebuild-help')} -- Recreated the help handout in the journal. Useful for showing updated custom status markers.`, + `${_h.bold('--help-statusmarkers')} -- Output just the list of known status markers into the chat.`, + `${_h.bold('--ignore-selected')} -- Prevents modifications to the selected tokens (only modifies tokens passed with --ids).`, + `${_h.bold('--current-page')} -- Only modifies tokens on the calling player${ch("'")}s current page. This is particularly useful when passing character_ids to ${_h.italic('--ids')}.`, + `${_h.bold('--active-pages')} -- Only modifies tokens on pages where there is a player or the GM. This is particularly useful when passing character_ids to ${_h.italic('--ids')}.`, + `${_h.bold('--api-as')} ${_h.required('playerid')} -- Sets the player id to use as the player when the API is calling the script.`, + `${_h.bold('--config')} -- Sets Config options. `, + `${_h.bold('--on')} -- Turns on any of the specified parameters (See ${_h.bold('Boolean Arguments')} below).`, + `${_h.bold('--off')} -- Turns off any of the specified parameters (See ${_h.bold('Boolean Arguments')} below).`, + `${_h.bold('--flip')} -- Flips the value of any of the specified parameters (See ${_h.bold('Boolean Arguments')} below).`, + `${_h.bold('--set')} -- Each parameter is treated as a key and value, divided by a ${_h.code('|')} character. Sets the key to the value. If the value has spaces, you must enclose it ${_h.code(ch("'"))} or ${_h.code(ch('"'))}. See below for specific value handling logic.`, + `${_h.bold('--move')} -- Moves each token in a direction and distance based on its facing.`, + `${_h.bold('--order')} -- Changes the ordering of tokens. Specify one of ${_h.code('tofront')}, ${_h.code('front')}, ${_h.code('f')}, ${_h.code('top')} to bring something to the front or ${_h.code('toback')}, ${_h.code('back')}, ${_h.code('b')}, ${_h.code('bottom')} to push it to the back.`, + `${_h.bold('--report')} -- Displays a report of what changed for each token. ${_h.experimental()}`, + `${_h.bold('--ids')} -- Each parameter is a Token ID, usually supplied with something like ${_h.attr.target(`Target 1${ch('|')}token_id`)}. By default, only a GM can use this argument. You can enable players to use it as well with ${_h.bold('--config players-can-ids|on')}.` + ) + ), + // SECTION: --ids, --ignore-selected, etc... + _h.section('Token Specification', + _h.paragraph(`By default, any selected token is adjusted when the command is executed. Note that there is a bug where using ${_h.attr.target('')} commands, they may cause them to get skipped.`), + _h.paragraph(`${_h.italic('--ids')} takes token ids to operate on, separated by spaces.`), + _h.inset(_h.pre( `!token-mod --ids -Jbz-mlHr1UXlfWnGaLh -JbjeTZycgyo0JqtFj-r -JbjYq5lqfXyPE89CJVs --on showname showplayers_name`)), + _h.paragraph(`Usually, you will want to specify these with the ${_h.attr.target('')} syntax:`), + _h.inset(_h.pre( `!token-mod --ids ${_h.attr.target('1|token_id')} ${_h.attr.target('2|token_id')} ${_h.attr.target('3|token_id')} --on showname showplayers_name`)), + _h.paragraph(`${_h.italic('--ignore-selected')} can be used when you want to be sure selected tokens are not affected. This is particularly useful when specifying the id of a known token, such as moving a graphic from the gm layer to the objects layer, or coloring an object on the map.`) + ) + ), + move: (/* context */) => _h.join( + _h.section('Move', + _h.paragraph(`Use ${_h.code('--move')} to supply a sequence of move operations to apply to a token. By default, moves are relative to the current facing of the token as defined by the rotation handle (generally, the "up" direction when the token is unrotated). Each operation can be either a distance, or a rotation followed by a distance, separated by a pipe ${_h.code('|')}. Distances can use the unit specifiers (${_h.code('g')},${_h.code('u')},${_h.code('ft')},etc -- see the ${_h.bold('Numbers')} section for more) and may be positive or negative. Rotations can be positive or negative. They can be prefaced by a ${_h.code('=')} to ignore the current rotation of the character and instead move based on up being 0. They can further be followed by a ${_h.code('!')} to also rotate the token to the new direction.`), + _h.paragraph(`Moving 3 grid spaces in the current facing.`), + _h.inset( + _h.preformatted( + '!token-mod --move 3g' + ) + ), + _h.paragraph(`Moving 3 grid spaces at 45 degrees to the current facing.`), + _h.inset( + _h.preformatted( + '!token-mod --move 45|3g' + ) + ), + _h.paragraph(`Moving 2 units to the right, ignoring the current facing.`), + _h.inset( + _h.preformatted( + '!token-mod --move =90|2u' + ) + ), + _h.paragraph(`Moving 10ft in the direction 90 degrees to the left of the current facing, and updating the facing to that new direction.`), + _h.inset( + _h.preformatted( + '!token-mod --move -90!|10ft' + ) + ), + _h.paragraph(`Moving forward 2 grid spaces, then right 10ft, then 3 units at 45 degrees to the current facing and updating to that face that direction. `), + _h.inset( + _h.preformatted( + '!token-mod --move 2g 90|10ft =45!|3u' + ) + ) + ) + ), + + booleans: (/* context */) => _h.join( + // SECTION: --on, --off, --flip, etc... + _h.section('Boolean Arguments', + _h.paragraph(`${_h.italic('--on')}, ${_h.italic('--off')} and ${_h.italic('--flip')} options only work on properties of a token that are either ${_h.code('true')} or ${_h.code('false')}, usually represented as checkboxes in the User Interface. Specified properties will only be changed once, priority is given to arguments to ${_h.italic('--on')} first, then ${_h.italic('--off')} and finally to ${_h.italic('--flip')}.`), + _h.inset( + _h.pre(`!token-mod --on showname light_hassight --off isdrawing --flip flipv fliph`) + ), + _h.minorhead('Available Boolean Properties:'), + _h.inset( + _h.grid( + _h.cell('showname'), + _h.cell('show_tooltip'), + _h.cell('showplayers_name'), + _h.cell('showplayers_bar1'), + _h.cell('showplayers_bar2'), + _h.cell('showplayers_bar3'), + _h.cell('showplayers_aura1'), + _h.cell('showplayers_aura2'), + _h.cell('playersedit_name'), + _h.cell('playersedit_bar1'), + _h.cell('playersedit_bar2'), + _h.cell('playersedit_bar3'), + _h.cell('playersedit_aura1'), + _h.cell('playersedit_aura2'), + _h.cell('light_otherplayers'), + _h.cell('light_hassight'), + _h.cell('isdrawing'), + _h.cell('flipv'), + _h.cell('fliph'), + _h.cell('aura1_square'), + _h.cell('aura2_square'), + + _h.cell("has_bright_light_vision"), + _h.cell("has_limit_field_of_vision"), + _h.cell("has_limit_field_of_night_vision"), + _h.cell("has_directional_bright_light"), + _h.cell("has_directional_dim_light"), + _h.cell("bright_vision"), + _h.cell("has_night_vision"), + _h.cell("night_vision"), + _h.cell("emits_bright_light"), + _h.cell("emits_bright"), + _h.cell("emits_low_light"), + _h.cell("emits_low"), + _h.cell('lockMovement') + ) + ), + _h.paragraph( `Any of the booleans can be set with the ${_h.italic('--set')} command by passing a true or false as the value`), + _h.inset( + _h.pre('!token-mod --set showname|yes isdrawing|no') + ), + _h.paragraph(`The following are considered true values: ${_h.code('1')}, ${_h.code('on')}, ${_h.code('yes')}, ${_h.code('true')}, ${_h.code('sure')}, ${_h.code('yup')}`), + + _h.subhead("Probabilistic Booleans"), + _h.paragraph(`TokenMod accepts the following probabilistic values which are true some of the time and false otherwise: ${_h.code('couldbe')} (true 1 in 8 times) , ${_h.code('sometimes')} (true 1 in 4 times) , ${_h.code('maybe')} (true 1 in 2 times), ${_h.code('probably')} (true 3 in 4 times), ${_h.code('likely')} (true 7 in 8 times)`), + + _h.paragraph(`Anything else is considered false.`), + + _h.subhead("Updated Dynamic Lighting"), + _h.paragraph(`${_h.code("has_bright_light_vision")} is the UDL version of ${_h.bold("light_hassight")}. It controls if a token can see at all, and must be turned on for a token to use UDL. You can also use the alias ${_h.code("bright_vision")}.`), + _h.paragraph(`${_h.code("has_night_vision")} controls if a token can see without emitted light around it. This was handled with ${_h.bold("light_otherplayers")} in the old light system. In the new light system, you don't need to be emitting light to see if you have night vision turned on. You can also use the alias ${_h.code("night_vision")}.`), + _h.paragraph(`${_h.code("emits_bright_light")} determines if the configured ${_h.bold("bright_light_distance")} is active or not. There wasn't a concept like this in the old system, it would be synonymous with setting the ${_h.bold("light_radius")} to 0, but now it's not necessary. You can also use the alias ${_h.code("emits_bright")}.`), + _h.paragraph(`${_h.code("emits_low_light")} determines if the configured ${_h.bold("low_light_distance")} is active or not. There wasn't a concept like this in the old system, it would be synonymous with setting the ${_h.bold("light_dimradius")} to 0 (kind of), but now it's not necessary. You can also use the alias ${_h.code("emits_low")}.`) + ) + ), + + setPercentage: (/* context*/) => _h.join( + _h.subhead('Percentage'), + _h.inset( + _h.paragraph(`Percentage values can be a floating point number between 0 and 1.0, such as ${_h.code('0.35')}, or an integer number between 1 and 100.`), + _h.minorhead('Available Percentage Properties:'), + _h.inset( + _h.grid( + _h.cell('dim_light_opacity') + ) + ), + _h.paragraph(`Setting the low light opacity to 30%:`), + _h.inset( + _h.pre( '!token-mod --set dim_light_opacity|30' ) + ), + _h.inset( + _h.pre( '!token-mod --set dim_light_opacity|0.3' ) + ) + ) + ), + + setNightVisionEffect: (/* context*/) => _h.join( + _h.subhead('Night Vision Effect'), + _h.inset( + _h.paragraph(`Night Vision Effect specifies how the region of night vision around a token looks. There are two effects that can be turned on: ${_h.code('dimming')} and ${_h.code('nocturnal')}. You can disable Night Vision Effects using ${_h.code('off')}, ${_h.code('none')}, or leave the field blank. Any other value is ignored.`), + _h.minorhead('Available Night Vision Effect Properties:'), + _h.inset( + _h.grid( + _h.cell('night_vision_effect') + ) + ), + _h.paragraph(`Enable the nocturnal Night Vision Effect on a token:`), + _h.inset( + _h.pre( '!token-mod --set night_vision_effect|nocturnal' ) + ), + _h.paragraph(`Enable the dimming Night Vision Effect on a token, with dimming starting at 5ft from the token:`), + _h.inset( + _h.pre( '!token-mod --set night_vision_effect|dimming' ) + ), + _h.paragraph(`Dimming can take an additional argument to set the distance from the token to begin dimming. The default is 5ft if not specified. Distances are provided by appending a another ${_h.code('|')} character and adding a number followed by either a unit or a ${_h.code('%')}:`), + _h.inset( + _h.pre( '!token-mod --set night_vision_effect|dimming|5ft' ), + _h.pre( '!token-mod --set night_vision_effect|dimming|1u' ) + ), + _h.paragraph(`Using the ${_h.code('%')} allows you to specify the distance as a percentage of the Night Vision Distance. Numbers less than 1 are treated as a decimal percentage. Both of the following are the same:`), + _h.inset( + _h.pre( '!token-mod --set night_vision_effect|dimming|20%' ), + _h.pre( '!token-mod --set night_vision_effect|dimming|0.2%' ) + ), + _h.paragraph(`You can also use operators to make relative changes. Operators are ${_h.code('+')}, ${_h.code('-')}, ${_h.code('*')}, and ${_h.code('/')}`), + _h.inset( + _h.pre( '!token-mod --set night_vision_effect|dimming|+10%' ), + _h.pre( '!token-mod --set night_vision_effect|dimming|-5ft' ), + _h.pre( '!token-mod --set night_vision_effect|dimming|/2' ), + _h.pre( '!token-mod --set night_vision_effect|dimming|*10' ) + ), + _h.paragraph(`Disable any Night Vision Effects on a token:`), + _h.inset( + _h.pre( '!token-mod --set night_vision_effect|off' ) + ), + _h.inset( + _h.pre( '!token-mod --set night_vision_effect|none' ) + ), + _h.inset( + _h.pre( '!token-mod --set night_vision_effect|' ) + ) + ) + ), + + setCompactBar: (/* context*/) => _h.join( + _h.subhead('Compact Bar'), + _h.inset( + _h.paragraph(`Compact Bar specifes how the bar looks. A compact bar is much smaller than the normal presentation and does not have numbers overlaying it. To enable Compact Bar for a token, use ${_h.code('compact')} or ${_h.code('on')}. You can disable Compact Bar using ${_h.code('off')}, ${_h.code('none')}, or leave the field blank. Any other value is ignored.`), + _h.minorhead('Available Compact Bar Properties:'), + _h.inset( + _h.grid( + _h.cell('compact_bar') + ) + ), + _h.paragraph(`Enable Compact Bar on a token:`), + _h.inset( + _h.pre( '!token-mod --set compact_bar|compact' ) + ), + _h.inset( + _h.pre( '!token-mod --set compact_bar|on' ) + ), + _h.paragraph(`Disable Compact Bar on a token:`), + _h.inset( + _h.pre( '!token-mod --set compact_bar|off' ) + ), + _h.inset( + _h.pre( '!token-mod --set compact_bar|none' ) + ), + _h.inset( + _h.pre( '!token-mod --set compact_bar|' ) + ) + ) + ), + + setBarLocation: (/* context*/) => _h.join( + _h.subhead('Bar Location'), + _h.inset( + _h.paragraph(`Bar Location specifes where the bar on a token appears. There are 4 options: ${_h.code('above')}, ${_h.code('overlap_top')}, ${_h.code('overlap_bottom')}, and ${_h.code('below')}. You can also use ${_h.code('off')}, ${_h.code('none')}, or leave the field blank as an alias for ${_h.code('above')}. Any other value is ignored.`), + _h.minorhead('Available Bar Location Properties:'), + _h.inset( + _h.grid( + _h.cell('bar_location') + ) + ), + _h.paragraph(`Setting the bar location to below the token:`), + _h.inset( + _h.pre( '!token-mod --set bar_location|below' ) + ), + _h.paragraph(`Setting the bar location to overlap the top of the token:`), + _h.inset( + _h.pre( '!token-mod --set bar_location|overlap_top' ) + ), + _h.paragraph(`Setting the bar location to overlap the bottom of the token:`), + _h.inset( + _h.pre( '!token-mod --set bar_location|overlap_bottom' ) + ), + _h.paragraph(`Setting the bar location to above the token:`), + _h.inset( + _h.pre( '!token-mod --set bar_location|above' ) + ), + _h.inset( + _h.pre( '!token-mod --set dim_light_opacity|none' ) + ), + _h.inset( + _h.pre( '!token-mod --set dim_light_opacity|off' ) + ), + _h.inset( + _h.pre( '!token-mod --set dim_light_opacity|' ) + ) + ) + ), + + setNumbers: (/* context*/) => _h.join( + _h.subhead('Numbers'), + _h.inset( + _h.paragraph('Number values can be any floating point number (though most fields will drop the fractional part). Numbers must be given a numeric value. They cannot be blank or a non-numeric string.'), + _h.minorhead('Available Numbers Properties:'), + _h.inset( + _h.grid( + _h.cell('left'), + _h.cell('top'), + _h.cell('width'), + _h.cell('height'), + _h.cell('scale'), + _h.cell('light_sensitivity_multiplier') + ) + ), + _h.paragraph( `It${ch("'")}s probably a good idea not to set the location of a token off screen, or the width or height to 0.`), + _h.paragraph( `Placing a token in the top left corner of the map and making it take up a 2x2 grid section:`), + _h.inset( + _h.pre( '!token-mod --set top|0 left|0 width|140 height|140' ) + ), + _h.paragraph(`You can also apply relative change using ${_h.code('+')}, ${_h.code('-')}, ${_h.code(ch('*'))}, and ${_h.code(ch('/'))}. This will move each token one unit down, 2 units left, then make it 5 times as wide and half as tall.`), + _h.inset( + _h.pre( `!token-mod --set top|+70 left|-140 width|${ch('*')}5 height|/2` ) + ), + _h.paragraph(`You can use ${_h.code('=')} to explicity set a value. This is the default behavior, but you might need to use it to move something to a location off the edge using a negative number but not a relative number:`), + _h.inset( + _h.pre( '!token-mod --set top|=-140' ) + ), + _h.paragraph( `${_h.code('scale')} is a pseudo field which adjusts both ${_h.code('width')} and ${_h.code('height')} with the same operation. This will scale a token to twice it's current size.`), + _h.inset( + _h.pre( '!token-mod --set scale|*2' ) + ), + _h.paragraph(`You can follow a number by one of ${_h.code('u')}, ${_h.code('g')}, or ${_h.code('s')} to adjust the scale that the number is applied in.`), + _h.paragraph(`Use ${_h.code('u')} to use a number based on Roll20 Units, which are 70 pixels at 100% zoom. This will set a graphic to 280x140.`), + _h.inset( + _h.pre( '!token-mod --set width|4u height|2u' ) + ), + _h.paragraph(`Use ${_h.code('g')} to use a number based on the current grid size. This will set a token to the middle of the 8th column, 4rd row grid. (.5 offset for half the center)`), + _h.inset( + _h.pre( '!token-mod --set left|7.5g top|3.5g' ) + ), + _h.paragraph(`Use ${_h.code('s')} to use a number based on the current unit if measure. (ft, m, mi, etc) This will set a token to be 25ft by 35ft (assuming ft are the unit of measure)`), + _h.inset( + _h.pre( '!token-mod --set width|25s height|35s' ) + ), + _h.paragraph(`Currently, you can also use any of the default units of measure as alternatives to ${_h.code('s')}: ${_h.code('ft')}, ${_h.code('m')}, ${_h.code('km')}, ${_h.code('mi')}, ${_h.code('in')}, ${_h.code('cm')}, ${_h.code('un')}, ${_h.code('hex')}, ${_h.code('sq')}`), + _h.inset( + _h.pre( '!token-mod --set width|25ft height|35ft' ) + ) + ) + ), + + setNumbersOrBlank: ( /* context */) => _h.join( + _h.subhead('Numbers or Blank'), + _h.inset( + _h.paragraph('Just like the Numbers fields, except you can set them to blank as well.'), + _h.minorhead('Available Numbers or Blank Properties:'), + _h.inset( + _h.grid( + _h.cell('light_radius'), + _h.cell('light_dimradius'), + _h.cell('light_multiplier'), + _h.cell('aura1_radius'), + _h.cell('aura2_radius'), + _h.cell('adv_fow_view_distance'), + _h.cell("night_vision_distance"), + _h.cell("night_distance"), + _h.cell("bright_light_distance"), + _h.cell("bright_distance"), + _h.cell("low_light_distance"), + _h.cell("low_distance") + ) + ), + _h.paragraph(`Here is setting a standard DnD 5e torch, turning off aura1 and setting aura2 to 30. Note that the ${_h.code('|')} is still required for setting a blank value, such as aura1_radius below.`), + _h.inset( + _h.pre('!token-mod --set light_radius|40 light_dimradius|20 aura1_radius| aura2_radius|30') + ), + _h.paragraph(`Just as above, you can use ${_h.code('=')}, ${_h.code('+')}, ${_h.code('-')}, ${_h.code(ch('*'))}, and ${_h.code(ch('/'))} when setting these values.`), + _h.paragraph(`Here is setting a standard DnD 5e torch, with advanced fog of war revealed for 30.`), + _h.inset( + _h.pre('!token-mod --set light_radius|40 light_dimradius|20 adv_fow_view_distance|30') + ), + _h.paragraph(`Sometimes it is convenient to have a way to set a radius if there is none, but remove it if it is set. This allows toggling a known radius on and off, or setting a multiplier if there isn't one, but clearing it if there is. You can preface a number with ${_h.code('!')} to toggle it${ch("'")}s value on and off. Here is an example that will add or remove a 20${ch("'")} radius aura 1 from a token:`), + _h.inset( + _h.pre('!token-mod --set aura1_radius|!20') + ), + _h.paragraph(`These also support the relative scale operations that ${_h.bold('Numbers')} support: ${_h.code('u')}, ${_h.code('g')}, ${_h.code('s')}`), + _h.inset( + _h.pre('!token-mod --set aura1_radius|3g aura2_radius|10u light_radius|25s') + ), + _h.paragraph(`${_h.bold('Note:')} ${_h.code('light_multiplier')} ignores these modifiers. Additionally, the rest are already in the scale of measuring distance (${_h.code('s')}) so there is no difference between ${_h.code('25s')}, ${_h.code('25ft')}, and ${_h.code('25')}.`), + _h.subhead(`Updated Dynamic Lighting`), + _h.paragraph(`${_h.code("night_vision_distance")} lets you set how far away a token can see with no light. You need to have ${_h.bold("has_night_vision")} turned on for this to take effect. You can also use the alias ${_h.code("night_distance")}.`), + _h.paragraph(`${_h.code("bright_light_distance")} lets you set how far bright light is emitted from the token. You need to have ${_h.bold("has_bright_light_vision")} turned on for this to take effect. You can also use the alias ${_h.code("bright_distance")}.`), + _h.paragraph(`${_h.code("low_light_distance")} lets you set how far low light is emitted from the token. You need to have ${_h.bold("has_bright_light_vision")} turned on for this to take effect. You can also use the alias ${_h.code("low_distance")}.`) + ) + ), + + setDegrees: ( /* context */) => _h.join( + _h.subhead('Degrees'), + _h.inset( + _h.paragraph('Any positive or negative number. Values will be automatically adjusted to be in the 0-360 range, so if you add 120 to 270, it will wrap around to 90.'), + _h.minorhead('Available Degrees Properties:'), + _h.inset( + _h.grid( + _h.cell('rotation'), + _h.cell("limit_field_of_vision_center"), + _h.cell("limit_field_of_night_vision_center"), + _h.cell("directional_bright_light_center"), + _h.cell("directional_dim_light_center") + ) + ), + _h.paragraph('Rotating a token by 180 degrees.'), + _h.inset( + _h.pre('!token-mod --set rotation|+180') + ) + ) + ), + + setCircleSegment: ( /* context */) => _h.join( + _h.subhead('Circle Segment (Arc)'), + _h.inset( + _h.paragraph('Any Positive or negative number, with the final result being clamped to from 0-360. This is different from a degrees setting, where 0 and 360 are the same thing and subtracting 1 from 0 takes you to 359. Anything lower than 0 will become 0 and anything higher than 360 will become 360.'), + _h.minorhead('Available Circle Segment (Arc) Properties:'), + _h.inset( + _h.grid( + _h.cell('light_angle'), + _h.cell('light_losangle'), + _h.cell("limit_field_of_vision_total"), + _h.cell("limit_field_of_night_vision_total"), + _h.cell("directional_bright_light_total"), + _h.cell("directional_dim_light_total") + ) + ), + _h.paragraph('Setting line of sight angle to 90 degrees.'), + _h.inset( + _h.pre('!token-mod --set light_losangle|90') + ) + ) + ), + + setColors: ( /* context */) => _h.join( + _h.subhead('Colors'), + _h.inset( + _h.paragraph(`Colors can be specified in multiple formats:`), + _h.inset( + _h.ul( + `${_h.bold('Transparent')} -- This is the special literal ${_h.code('transparent')} and represents no color at all.`, + `${_h.bold('HTML Color')} -- This is 6 or 3 hexidecimal digits, optionally prefaced by ${_h.code('#')}. Digits in a 3 digit hexidecimal color are doubled. All of the following are the same: ${_h.code('#ff00aa')}, ${_h.code('#f0a')}, ${_h.code('ff00aa')}, ${_h.code('f0a')}`, + `${_h.bold('RGB Color')} -- This is an RGB color in the format ${_h.code('rgb(1.0,1.0,1.0)')} or ${_h.code('rgb(256,256,256)')}. Decimal numbers are in the scale of 0.0 to 1.0, integer numbers are scaled 0 to 256. Note that numbers can be outside this range for the purpose of doing math.`, + `${_h.bold('HSV Color')} -- This is an HSV color in the format ${_h.code('hsv(1.0,1.0,1.0)')} or ${_h.code('hsv(360,100,100)')}. Decimal numbers are in the scale of 0.0 to 1.0, integer numbers are scaled 0 to 360 for the hue and 0 to 100 for saturation and value. Note that numbers can be outside this range for the purpose of doing math.` + ) + ), + _h.minorhead('Available Colors Properties:'), + _h.inset( + _h.grid( + _h.cell('tint_color'), + _h.cell('aura1_color'), + _h.cell('aura2_color'), + _h.cell('night_vision_tint'), + _h.cell('lightColor'), + _h.cell('lightcolor') + ) + ), + _h.paragraph('Turning off the tint and setting aura1 to a reddish color. All of the following are the same:'), + _h.inset( + _h.pre('!token-mod --set tint_color|transparent aura1_color|ff3366'), + _h.pre('!token-mod --set tint_color| aura1_color|f36'), + _h.pre('!token-mod --set tint_color|transparent aura1_color|#f36'), + _h.pre('!token-mod --set tint_color| aura1_color|#ff3366') + ), + _h.paragraph('Setting the tint_color using an RGB Color using Integer and Decimal notations:'), + _h.inset( + _h.pre('!token-mod --set tint_color|rgb(127,0,256)'), + _h.pre('!token-mod --set tint_color|rgb(.5,0.0,1.0)') + ), + _h.paragraph('Setting the tint_color using an HSV Color using Integer and Decimal notations:'), + _h.inset( + _h.pre('!token-mod --set tint_color|hsv(0,50,100)'), + _h.pre('!token-mod --set tint_color|hsv(0.0,.5,1.0)') + ), + + _h.paragraph(`You can toggle a color on and off by prefacing it with ${_h.code('!')}. If the color is currently transparent, it will be set to the specified color, otherwise it will be set to transparent:`), + _h.inset( + _h.pre('!token-mod --set tint_color|!rgb(1.0,.0,.2)') + ), + + _h.minorhead('Color Math'), + + _h.paragraph(`You can perform math on colors using ${_h.code('+')}, ${_h.code('-')}, and ${_h.code(ch('*'))}.`), + _h.paragraph(`Making the aura just a little more red:`), + _h.inset( + _h.pre('!token-mod --set aura1_color|+#330000') + ), + _h.paragraph(`Making the aura just a little less blue:`), + _h.inset( + _h.pre('!token-mod --set aura1_color|-rgb(0.0,0.0,0.1)') + ), + _h.paragraph(`HSV colors are especially good for color math. Making the aura twice as bright:`), + _h.inset( + _h.pre(`!token-mod --set aura1_color|${ch('*')}hsv(1.0,1.0,2.0)`) + ), + + _h.paragraph(`Performing math operations with a transparent color as the command argument does nothing:`), + _h.inset( + _h.pre(`!token-mod --set aura1_color|${ch('*')}transparent`) + ), + + _h.paragraph(`Performing math operations on a transparent color on a token treats the color as black. Assuming a token had a transparent aura1, this would set it to #330000.`), + _h.inset( + _h.pre('!token-mod --set aura1_color|+300') + ) + ) + ), + + setText: ( /* context */) => _h.join( + _h.subhead('Text'), + _h.inset( + _h.paragraph(`These can be pretty much anything. If your value has spaces in it, you need to enclose it in ${ch("'")} or ${ch('"')}.`), + _h.minorhead('Available Text Properties:'), + _h.inset( + _h.grid( + _h.cell('name'), + _h.cell('tooltip'), + _h.cell('bar1_value'), + _h.cell('bar2_value'), + _h.cell('bar3_value'), + _h.cell('bar1_current'), + _h.cell('bar2_current'), + _h.cell('bar3_current'), + _h.cell('bar1_max'), + _h.cell('bar2_max'), + _h.cell('bar3_max'), + _h.cell('bar1'), + _h.cell('bar2'), + _h.cell('bar3'), + _h.cell('bar1_reset'), + _h.cell('bar2_reset'), + _h.cell('bar3_reset') + ) + ), + _h.paragraph(`Setting a token${ch("'")}s name to ${ch('"')}Sir Thomas${ch('"')} and bar1 value to 23.`), + _h.inset( + _h.pre(`!token-mod --set name|${ch('"')}Sir Thomas${ch('"')} bar1_value|23`) + ), + _h.paragraph(`Setting a bar to a numeric value will be treated as a relative change if prefaced by ${_h.code('+')}, ${_h.code('-')}, ${_h.code(ch('*'))}, or ${_h.code('/')}, or will be explicitly set when prefaced with a ${_h.code('=')}. If you are setting a bar value, you can append a ${_h.code('!')} to the value to force it to be bounded between ${_h.code('0')} and ${_h.code('max')} for the bar.`), + _h.paragraph(`${_h.italic('bar1')}, ${_h.italic('bar2')} and ${_h.italic('bar3')} are special. Any value set on them will be set in both the ${_h.italic('_value')} and ${_h.italic('_max')} fields for that bar. This is most useful for setting hit points, particularly if the value comes from an inline roll.`), + _h.inset( + _h.pre(`!token-mod --set bar1|${ch('[')}${ch('[')}3d6+8${ch(']')}${ch(']')}`) + ), + _h.paragraph(`${_h.italic('bar1_reset')}, ${_h.italic('bar2_reset')} and ${_h.italic('bar3_reset')} are special. Any value set on them will be ignored, instead they will set the ${_h.italic('_value')} field for that bar to whatever the matching ${_h.italic('_max')} field is set to. This is most useful for resetting hit points or resource counts like spells. (The ${_h.code('|')} is currently still required.)`), + _h.inset( + _h.pre(`!token-mod --set bar1_reset| bar3_reset|`) + ) + ) + ), + + setLayer: ( /* context */) => _h.join( + _h.subhead('Layer'), + _h.inset( + _h.paragraph(`There is only one Layer property. It can be one of 4 values, listed below.`), + _h.minorhead('Available Layer Values:'), + _h.inset( + _h.grid( + _h.cell('gmlayer'), + _h.cell('objects'), + _h.cell('map'), + _h.cell('walls') + ) + ), + _h.paragraph('Moving something to the gmlayer.'), + _h.inset( + _h.pre('!token-mod --set layer|gmlayer') + ) + ) + ), + availableStatusMarkers: (/* context */) => _h.join( + _h.minorhead('Available Status Markers:'), + _h.inset( + _h.grid( + ...StatusMarkers.getOrderedList().map(tm=>_h.statusCell(tm)) + ) + ) + ), + setStatus: ( context ) => _h.join( + _h.subhead('Status'), + _h.inset( + _h.paragraph(`There is only one Status property. Status has a somewhat complicated syntax to support the greatest possible flexibility.`), + _h.minorhead('Available Status Property:'), + _h.inset( + _h.grid( + _h.cell('statusmarkers') + ) + ), + + _h.paragraph(`Status is the only property that supports multiple values, all separated by ${_h.code('|')} as seen below. This command adds the blue, red, green, padlock and broken-sheilds to a token, on top of any other status markers it already has:`), + _h.inset( + _h.pre('!token-mod --set statusmarkers|blue|red|green|padlock|broken-shield') + ), + + _h.paragraph(`You can optionally preface each status with a ${_h.code('+')} to remind you it is being added. This command is identical:`), + _h.inset( + _h.pre('!token-mod --set statusmarkers|+blue|+red|+green|+padlock|+broken-shield') + ), + + _h.paragraph(`Each value can be followed by a ${_h.code(':')} and a number between 0 and 9. (The number following the ${_h.italic('dead')} status is ignored as that status is special.) This will set the blue status with no number overlay, red with a 3 overlay, green with no overlay, padlock with a 2 overlay, and broken-shield with a 7 overlay:`), + _h.inset( + _h.pre('!token-mod --set statusmarkers|blue:0|red:3|green|padlock:2|broken-shield:7') + ), + _h.paragraph(`${_h.bold('Note:')} TokenMod will now show 0 on status markers everywhere that makes sense to do.`), + + _h.paragraph(`You can use a semicolon (${_h.code(';')}) in place of a colon (${_h.code(':')}) to allow setting statuses with numbers from API Buttons.`), + _h.inset( + _h.pre(`${ch('[')}[Set some statuses](!token-mod --set statusmarkers|blue;0|red;3|green|padlock;2|broken-shield;7)`) + ), + + _h.paragraph(`The numbers following a status can be prefaced with a ${_h.code('+')} or ${_h.code('-')}, which causes their value to be applied to the current value. Here${ch("'")}s an example showing blue getting incremented by 2, and padlock getting decremented by 1. Values will be bounded between 0 and 9.`), + _h.inset( + _h.pre('!token-mod --set statusmarkers|blue:+2|padlock:-1') + ), + + _h.paragraph(`You can append two additional numbers separated by ${_h.code(':')}. These numbers will be used as the minimum and maximum value when setting or adjusting the number on a status marker. Specified minimum and maximum values will be kept between 0 and 9.`), + _h.inset( + _h.pre('!token-mod --set statusmarkers|blue:+1:2:5') + ), + + _h.paragraph(`Omitting either of the numbers will cause them to use their default value. Here is an example limiting the max to 5:`), + _h.inset( + _h.pre('!token-mod --set statusmarkers|blue:+1::5') + ), + + _h.paragraph(`You can optionally preface each status with a ${_h.code('?')} to modify the way ${_h.code('+')} and ${_h.code('-')} on status numbers work. With ${_h.code('?')} on the front of the status, only selected tokens that have that status will be modified. Additionally, if the status reaches 0, it will be removed. Here${ch("'")}s an example showing blue getting decremented by 1. If it reaches 0, it will be removed and no status will be added if it is missing.`), + _h.inset( + _h.pre('!token-mod --set statusmarkers|?blue:-1') + ), + + _h.paragraph(`By default, status markers will be added, retaining whichever status markers are already present. You can override this behavior by prefacing a status with a ${_h.code('-')} to cause the status to be removed. This will remove the blue and padlock status:`), + _h.inset( + _h.pre('!token-mod --set statusmarkers|-blue|-padlock') + ), + + _h.paragraph(`Sometimes it is convenient to have a way to add a status if it is not there, but remove it if it is. This allows marking tokens with markers and clearing them with the same command. You can preface a status with ${_h.code('!')} to toggle it${ch("'")}s state on and off. Here is an example that will add or remove the Rook piece from a token:`), + _h.inset( + _h.pre('!token-mod --set statusmarkers|!white-tower') + ), + + _h.paragraph(`Sometimes, you might want to clear all status marker as part of setting a new status marker. You can do this by prefacing a status marker with an ${_h.code('=')}. Note that this affects all status markers before as well, so you will want to do this only on the first status marker. This will remove all status markers and set only the dead marker:`), + _h.inset( + _h.pre('!token-mod --set statusmarkers|=dead') + ), + + _h.paragraph(`If you want to remove all status markers, just set an empty status marker with ${_h.code('=')}. This will clear all the status markers:`), + _h.inset( + _h.pre('!token-mod --set statusmarkers|=') + ), + + _h.paragraph(`You can also do this by setting a single status marker, then removing it:`), + _h.inset( + _h.pre('!token-mod --set statusmarkers|=blue|-blue') + ), + + _h.paragraph(`You can set multiple of the same status marker with a bracket syntax. Copies of a status are indexed starting at 1 from left to right. Leaving brackets off will be the same as specifying index 1. Using empty brackets is the same as specifying an index 1 greater than the highest index in use. When setting a status at an index that doesn${ch("'")}t exist (say, 8 when you only have 2 of that status) it will be appended to the right as the next index. When removing a status that doesn${ch("'")}t exist, it will be ignored. Removing the empty bracket status will remove all statues of that type.`), + _h.paragraph(`Adding 2 blue status markers with the numbers 7 and 5 in a few different ways:`), + _h.inset( + _h.pre('!token-mod --set statusmarkers|blue:7|blue[]:5'), + _h.pre('!token-mod --set statusmarkers|blue[]:7|blue[]:5'), + _h.pre('!token-mod --set statusmarkers|blue[1]:7|blue[2]:5') + ), + _h.paragraph('Removing the second blue status marker:'), + _h.inset( + _h.pre('!token-mod --set statusmarkers|-blue[2]') + ), + _h.paragraph('Removing all blue status markers:'), + _h.inset( + _h.pre('!token-mod --set statusmarkers|-blue[]') + ), + + _h.paragraph('All of these operations can be combine in a single statusmarkers command.'), + _h.inset( + _h.pre('!token-mod --set statusmarkers|blue:3|-dead|red:3') + ), + helpParts.availableStatusMarkers(context), + _h.paragraph(`Status Markers with a space in the name must be specified using the tag name, which appears in ${_h.code('[')}${_h.code(']')} above.`), + _h.inset( + _h.pre('!token-mod --set statusmarkers|Mountain_Pass::1234568') + ), + _h.paragraph(`You can use a semicolon (${_h.code(';')}) in place of a colon (${_h.code(':')}) to allow setting statuses with numbers from API Buttons.`), + _h.inset( + _h.pre(`${ch('[')}3 Mountain Pass](!token-mod --set statusmarkers|Mountain_Pass;;1234568;3)`) + ) + + ) + ), + + setImage: ( /* context */) => _h.join( + _h.subhead('Image'), + _h.inset( + _h.paragraph(`The Image type lets you manage the image a token uses, as well as the available images for Multi-Sided tokens. Images must be in a user library or will be ignored. The full path must be provided.`), + _h.minorhead('Available Image Properties:'), + _h.inset( + _h.grid( + _h.cell('imgsrc') + ) + ), + _h.paragraph(`Setting the token image to a library image using a url (in this case, the orange ring I use for ${_h.italic('TurnMarker1')}):`), + _h.inset( + _h.pre('!token-mod --set imgsrc|https://s3.amazonaws.com/files.d20.io/images/4095816/086YSl3v0Kz3SlDAu245Vg/max.png?1400535580') + ), + _h.paragraph(`Setting the token image from another token by specifying it${ch("'")}s token_id:`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|${_h.attr.target('token_id')} --ids ${_h.attr.selected('token_id')}`) + ), + _h.paragraph(`${_h.bold('WARNING:')} Because of a Roll20 bug with ${_h.attr.target('')} and the API, you must specify the tokens you want to change using ${_h.code('--ids')} when using ${_h.attr.target('')}.`), + + _h.minorhead('Multi-Sided Token Options'), + _h.inset( + _h.subhead('Appending (+)'), + _h.inset( + _h.paragraph(`You can append additional images to the list of sides by prefacing the source of an image with ${_h.code('+')}:`), + _h.inset( + _h.pre('!token-mod --set imgsrc|+https://s3.amazonaws.com/files.d20.io/images/4095816/086YSl3v0Kz3SlDAu245Vg/max.png?1400535580'), + _h.pre(`!token-mod --set imgsrc|+${_h.attr.target('token_id')} --ids ${_h.attr.selected('token_id')}`) + ), + _h.paragraph(`If you follow the ${_h.code('+')} with a ${_h.code('=')}, it will update the current side to the freshly added image:`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|+=${_h.attr.target('token_id')} --ids ${_h.attr.selected('token_id')}`) + ), + _h.paragraph(`When getting the image from a token, you can append a ${_h.code(':')} and follow it with an index to copy. Indicies start at 1, if you specify an index that doesn${ch("'")}t exist, nothing will happen:`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|+${_h.attr.target('token_id')}:3 --ids ${_h.attr.selected('token_id')}`) + ), + _h.paragraph(`You can specify the ${_h.code('=')} with this syntax:`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|+=${_h.attr.target('token_id')}:3 --ids ${_h.attr.selected('token_id')}`) + ), + _h.paragraph(`You can specify multiple indices to copy by using a ${_h.code(',')} separated list:`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|+${_h.attr.target('token_id')}:3,4,5,9 --ids ${_h.attr.selected('token_id')}`) + ), + _h.paragraph(`Using ${_h.code('=')} with this syntax will set the current side to the last added image:`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|+=${_h.attr.target('token_id')}:3,4,5,9 --ids ${_h.attr.selected('token_id')}`) + ), + _h.paragraph(`Images are copied in the order specified. You can even copy images from a token you${ch("'")}re setting.`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|+${_h.attr.target('token_id')}:3,2,1 --ids ${_h.attr.selected('token_id')}`) + ), + _h.paragraph(`You can use an ${_h.code(ch('*'))} after the ${_h.code(':')} to copy all the images from a token. The order will be from 1 to the maximum image.`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|+${_h.attr.target('token_id')}:${ch('*')} --ids ${_h.attr.selected('token_id')}`) + ), + + _h.paragraph(`When appending a url, you can use a ${_h.code(ch(':@'))} followed by a number to specify where to place the new image. Indicies start at 1.`), + _h.inset( + _h.pre('!token-mod --set imgsrc|+https://s3.amazonaws.com/files.d20.io/images/4095816/086YSl3v0Kz3SlDAu245Vg/max.png?1400535580:@1') + ), + + _h.paragraph(`When appending from a token, you can use an ${_h.code(ch('@'))} followed by a number to specify where each copied image is inserted. Indicies start at 1.`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|+${_h.attr.target('token_id')}:3@1,4@2,5@4,9@5 --ids ${_h.attr.selected('token_id')}`) + ), + _h.paragraph(`Note that inserts are performed in order, so continuously inserting at a position will insert in reverse order.`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|+${_h.attr.target('token_id')}:3@1,4@1,5@1,9@1 --ids ${_h.attr.selected('token_id')}`) + ) + ), + + _h.subhead('Replacing (^)'), + _h.inset( + _h.paragraph(`You can replace images in the list of sides by prefacing the source of an image with ${_h.code('^')} and append ${_h.code(ch(':@'))} followed by a number to specify which images to replace. Indicies start at 1.`), + _h.inset( + _h.pre('!token-mod --set imgsrc|^https://s3.amazonaws.com/files.d20.io/images/4095816/086YSl3v0Kz3SlDAu245Vg/max.png?1400535580:@2'), + _h.pre(`!token-mod --set imgsrc|^${_h.attr.target('token_id')}:@2 --ids ${_h.attr.selected('token_id')}`) + ), + _h.paragraph(`When replacing from a token, you can specify multiple replacements from a source token to the destination token:`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|^${_h.attr.target('token_id')}:3@1,4@2,5@4,9@5 --ids ${_h.attr.selected('token_id')}`) + ) + ), + + _h.subhead('Reordering (/)'), + _h.inset( + _h.paragraph(`You can use a ${_h.code(ch('/'))} followed by a pair of numbers separated by ${_h.code('@')} to move an image on the token from one postion to another. Indicies start at 1.`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|/3@1 --ids ${_h.attr.selected('token_id')}`) + ), + _h.paragraph(`You can string these together with commas. Note that operationes are performed in order and may displace prior moved images.`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|/3@1,4@2,5@3,9@4 --ids ${_h.attr.selected('token_id')}`) + ) + ), + + _h.subhead('Removing (-)'), + _h.inset( + _h.paragraph(`You can remove images from the image list using ${_h.code('-')} followed by the index to remove. If you remove the currently used image, the side will be set to 1.`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|-3`) + ), + _h.paragraph(`If you omit the number, it will remove the current side:`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|-`) + ), + + _h.paragraph(`You can follow the ${_h.code('-')} with a ${_h.code(',')} separated list of indicies to remove. If any of the indicies don${ch("'")}t exist, they will be ignored:`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|-3,4,7`) + ), + + _h.paragraph(`You can follow the ${_h.code('-')} with an ${_h.code(ch('*'))} to remove all the images, turning the Multi-Sided token back into a regular token. (This also happens if you remove the last image by index.):`), + _h.inset( + _h.pre(`!token-mod --set imgsrc|-${ch('*')}`) + ) + ), + + _h.paragraph(`${_h.bold('WARNING:')} If you attempt to change the image list for a token with images in the Marketplace Library, it will remove all of them from that token.`) + ) + ) + ), + + setSideNumber: ( /* context */) => _h.join( + _h.subhead('SideNumber'), + _h.inset( + _h.paragraph(`This is the index of the side to show for Multi-Sided tokens. Indicies start at 1. If you have a 6-sided token, it will have indicies 1, 2, 3, 4, 5 and 6. An empty index is considered to be 1. If a token doesn't have the index specified, it isn't changed.`), + _h.paragraph(`${_h.bold('NOTICE:')} This only works for images in the User Image library. If your token has images that are stored in the Marketplace Library, they will not be selectable with this command. You can download those images and upload them to your User Image Library to use them with this.`), + _h.minorhead('Available SideNumber Properties:'), + _h.inset( + _h.grid( + _h.cell('currentside') + ) + ), + _h.paragraph(`Setting a token to index 2:`), + _h.inset( + _h.pre('!token-mod --set currentside|2') + ), + _h.paragraph(`Not specifying an index will set the index to 1, the first image:`), + _h.inset( + _h.pre('!token-mod --set currentside|') + ), + + _h.paragraph(`You can shift the image by some amount by using ${_h.code('+')} or ${_h.code('-')} followed by an optional number.`), + _h.paragraph(`Moving all tokens to the next image:`), + _h.inset( + _h.pre('!token-mod --set currentside|+') + ), + _h.paragraph(`Moving all tokens back 2 images:`), + _h.inset( + _h.pre('!token-mod --set currentside|-2') + ), + _h.paragraph(`By default, if you go off either end of the list of images, you will wrap back around to the opposite side. If this token is showing image 3 out of 4 and this command is run, it will be on image 2:`), + _h.inset( + _h.pre('!token-mod --set currentside|+3') + ), + _h.paragraph(`If you preface the command with a ${_h.code('?')}, the index will be bounded to the number of images and not wrap. In the same scenario, this would leave the above token at image 4:`), + _h.inset( + _h.pre('!token-mod --set currentside|?+3') + ), + _h.paragraph(`In the same scenario, this would leave the above token at image 1:`), + _h.inset( + _h.pre('!token-mod --set currentside|?-30') + ), + + _h.paragraph(`If you want to choose a random image, you can use ${_h.code(ch('*'))}. This will choose one of the valid images at random (all equally weighted):`), + _h.inset( + _h.pre(`!token-mod --set currentside|${ch('*')}`) + ) + ) + ), + + setCharacterID: ( /*context*/ ) => _h.join( + _h.subhead('Character ID'), + _h.inset( + _h.paragraph(`You can use the ${_h.attr.char('character_id')} syntax to specify a character_id directly or use the name of a character (quoted if it contains spaces) or just the shortest part of the name that is unique (${ch("'")}Sir Maximus Strongbow${ch("'")} could just be ${ch("'")}max${ch("'")}.). Not case sensitive: Max = max = MaX = MAX`), + _h.minorhead('Available Character ID Properties:'), + _h.inset( + _h.grid( + _h.cell('represents') + ) + ), + _h.paragraph('Here is setting the represents to the character Bob.'), + _h.inset( + _h.pre(`!token-mod --set represents|${_h.attr.char('character_id','Bob')}`) + ), + _h.paragraph('Note that setting the represents will clear the links for the bars, so you will probably want to set those again.') + ) + ), + + setAttributeName: ( /*context*/ ) => _h.join( + _h.subhead('Attribute Name'), + _h.inset( + _h.paragraph(`These are resolved from the represented character id. If the token doesn${ch("'")}t represent a character, these will be ignored. If the Attribute Name specified doesn${ch("'")}t exist for the represented character, the link is unchanged. You can clear a link by passing a blank Attribute Name.`), + _h.minorhead('Available Attribute Name Properties:'), + _h.inset( + _h.grid( + _h.cell('bar1_link'), + _h.cell('bar2_link'), + _h.cell('bar3_link') + ) + ), + _h.paragraph('Here is setting the represents to the character Bob and setting bar1 to be the npc hit points attribute.'), + _h.inset( + _h.pre(`!token-mod --set represents|${_h.attr.char('character_id','Bob')} bar1_link|npc_HP`) + ), + _h.paragraph('Here is clearing the link for bar3:'), + _h.inset( + _h.pre('!token-mod --set bar3_link|') + ) + ) + ), + + + setPlayer: ( /*context*/ ) => _h.join( + _h.subhead('Player'), + _h.inset( + _h.paragraph('You can specify Players using one of five methods: Player ID, Roll20 ID Number, Player Name Matching, Token ID, Character ID'), + _h.inset( + _h.ul( + 'Player ID is a unique identifier assigned that player in a specific game. You can only find this id from the API, so this is likely the least useful method.', + 'Roll20 ID Number is a unique identifier assigned to a specific player. You can find it in the URL of their profile page as the number preceeding their name. This is really useful if you play with the same people all the time, or are cloning the same game with the same players, etc.', + 'Player Name Matching is a string that will be matched to the current name of the player in game. Just like with Characters above, it can be quoted if it has spaces and is case insensitive. All players that match a given string will be used.', + 'Token ID will be used to collect the controlledby entries for a token or the associated character if the token represetns one.', + 'Character ID will be used to collect the controlledby entries for a character.' + ) + ), + _h.paragraph(`Note that you can use the special string ${_h.italic('all')} to denote the All Players special player.`), + _h.minorhead('Available Player Properties:'), + _h.inset( + _h.grid( + _h.cell('controlledby') + ) + ), + + _h.paragraph(`Controlled by supports multiple values, all separated by ${_h.code('|')} as seen below.`), + _h.inset( + _h.pre('!token-mod --set controlledby|aaron|+stephen|+russ') + ), + + _h.paragraph(`There are 3 operations that can be specified with leading characters: ${_h.code('+')}, ${_h.code('-')}, ${_h.code('=')} (default)`), + _h.inset( + _h.ul( + `${_h.code('+')} will add the player(s) to the controlledby list.`, + `${_h.code('-')} will remove the player(s) from the controlledby list.`, + `${_h.code('=')} will set the controlledby list to only the player(s). (Default)` + ) + ), + + _h.paragraph('Adding control for roll20 player number 123456:'), + _h.inset( + _h.pre('!token-mod --set controlledby|+123456') + ), + + _h.paragraph('Setting control for all players:'), + _h.inset( + _h.pre('!token-mod --set controlledby|all') + ), + + _h.paragraph('Adding all the players with k in their name but removing karen:'), + _h.inset( + _h.pre('!token-mod --set controlledby|+k|-karen') + ), + + _h.paragraph( 'Adding the player with player id -JsABCabc123-12:' ), + _h.inset( + _h.pre( '!token-mod --set controlledby|+-JsABCabc123-12' ) + ), + + _h.paragraph( 'In the case of a leading character on the name that would be interpreted as an operation, you can use quotes:' ), + _h.inset( + _h.pre('!token-mod --set controlledby|"-JsABCabc123-12"') + ), + + _h.paragraph( `When using Token ID or Character ID methods, it${ch("'")}s a good idea to use an explicit operation:` ), + _h.inset( + _h.pre( `!token-mod --set controlledby|=${_h.attr.target('token_id')}`) + ), + + _h.paragraph( 'Quotes will also help with names that have spaces, or with nested other quotes:' ), + _h.inset( + _h.pre( `!token-mod --set controlledby|+${ch("'")}Bob "tiny" Slayer${ch("'")}`) + ), + + _h.paragraph( 'You can remove all controlling players by using a blank list or explicitly setting equal to nothing:'), + _h.inset( + _h.pre('!token-mod --set controlledby|'), + _h.pre('!token-mod --set controlledby|=') + ), + + _h.paragraph( `A specified action that doesn${ch("'")}t match any player(s) will be ignored. If there are no players named Tim, this won${ch("'")}t change the list:`), + _h.inset( + _h.pre('!token-mod --set controlledby|tim') + ), + + _h.paragraph( 'If you wanted to force an empty list and set tim if tim is available, you can chain this with blanking the list:'), + _h.inset( + _h.pre('!token-mod --set controlledby||tim') + ), + + _h.minorhead( 'Using controlledby with represents'), + _h.paragraph( 'When a token represents a character, the controlledby property that is adjusted is the one on the character. This works as you would want it to, so if you are changing the represents as part of the same command, it will adjust the location that will be correct after all commands are run.'), + + _h.paragraph( 'Set the token to represent the character with rook in the name and assign control to players matching bob:'), + _h.inset( + _h.pre('!token-mod --set represents|rook controlledby|bob') + ), + + _h.paragraph( 'Remove the represent setting for the token and then give bob control of that token (useful for one-offs from npcs or monsters):'), + _h.inset( + _h.pre('!token-mod --set represents| controlledby|bob') + ) + ) + ), + + setDefaultToken: ( /*context*/ ) => _h.join( + _h.subhead('DefaultToken'), + _h.inset( + _h.paragraph(`You can set the default token by specifying defaulttoken in your set list.`), + _h.minorhead('Available DefaultToken Properties:'), + _h.inset( + _h.grid( + _h.cell('defaulttoken') + ) + ), + _h.paragraph('There is no argument for defaulttoken, and this relies on the token representing a character.'), + _h.inset( + _h.pre('!token-mod --set defaulttoken') + ), + _h.paragraph('Setting defaulttoken along with represents works as expected:'), + _h.inset( + _h.pre(`!token-mod --set represents|${_h.attr.char('character_id','Bob')} defaulttoken`) + ), + _h.paragraph(`Be sure that defaulttoken is after all changes to the token you want to store are made. For example, if you set the defaulttoken, then set the bar links, the bars won${ch("'")}t be linked when you pull out the token.`) + ) + ), + + sets: ( context ) => _h.join( + // SECTION: --set, etc + _h.section('Set Arguments', + _h.paragraph(`${_h.italic('--set')} takes key-value pairs, separated by ${_h.code('|')} characters (or ${_h.code('#')} characters).`), + _h.inset( + _h.pre('!token-mod --set key|value key|value key|value') + ), + _h.paragraph(`You can use inline rolls wherever you like, including rollable tables:`), + _h.inset( + _h.pre(`!token-mod --set bar${ch('[')}${ch('[')}1d3${ch(']')}${ch(']')}_value|X statusmarkers|blue:${ch('[')}${ch('[')}1d9${ch(']')}${ch(']')}|green:${ch('[')}${ch('[')}1d9${ch(']')}${ch(']')} name:${ch('"')}${ch('[')}${ch('[')}1t${ch('[')}randomName${ch(']')}${ch(']')}${ch(']')}"`) + ), + + _h.paragraph(`You can use ${_h.code('+')} or ${_h.code('-')} before any number to make an adjustment to the current value:`), + _h.inset( + _h.pre('!token-mod --set bar1_value|-3 statusmarkers|blue:+1|green:-1') + ), + + _h.paragraph(`You can preface a ${_h.code('+')} or ${_h.code('-')} with an ${_h.code('=')} to explicitly set the number to a negative or positive value:`), + _h.inset( + _h.pre('!token-mod --set bar1_value|=+3 light_radius|=-10') + ), + + _h.paragraph('There are several types of keys with special value formats:'), + _h.inset( + helpParts.setNumbers(context), + helpParts.setPercentage(context), + helpParts.setNumbersOrBlank(context), + helpParts.setDegrees(context), + helpParts.setCircleSegment(context), + helpParts.setColors(context), + helpParts.setText(context), + helpParts.setNightVisionEffect(context), + helpParts.setBarLocation(context), + helpParts.setCompactBar(context), + helpParts.setLayer(context), + helpParts.setStatus(context), + helpParts.setImage(context), + helpParts.setSideNumber(context), + helpParts.setCharacterID(context), + helpParts.setAttributeName(context), + helpParts.setDefaultToken(context), + helpParts.setPlayer(context) + ) + ) + ), + + reports: (/* context */) => _h.join( + // SECTION: --report + _h.section('Report', + _h.paragraph(`${_h.experimental()} ${_h.italic('--report')} provides feedback about the changes that were made to each token that a command affects. Arguments to the ${_h.italic('--report')} command are ${_h.code('|')} separated pairs of Who to tell, and what to tell them, with the following format:`), + _h.inset( + _h.pre(`!token-mod --report Who[:Who ...]|Message`) + ), + _h.paragraph(`You can specify multiple different Who arguments by separating them with a ${_h.code(':')}. Be sure you have no spaces.`), + _h.minorhead('Available options for Who'), + _h.inset( + _h.ul( + `${_h.code('player')} will whisper the report to the player who issued the command.`, + `${_h.code('gm')} will whisper the report to the gm.`, + `${_h.code('all')} will send the report publicly to chat for everyone to see.`, + `${_h.code('token')} will whisper to whomever controls the token.`, + `${_h.code('character')} will whisper to whomever controls the character the token represents.`, + `${_h.code('control')} will whisper to whomever can control the token from either the token or character controlledby list. This is equivalent to specifying ${_h.code('token:character')}.` + ) + ), + _h.paragraph(`The Message must be enclosed in quotes if it has spaces in it. The Message can contain any of the properties of the of the token, enclosed in ${_h.code('{ }')}, and they will be replaced with the final value of that property. Additionally, each property may have a modifier to select slightly different information:`), + _h.minorhead('Available options for Property Modifiers'), + _h.inset( + _h.ul( + `${_h.code('before')} -- Show the value of the property before a change was applied.`, + `${_h.code('change')} -- Show the change that was applied to the property. (Only works on numeric fields, will result in 0 on things like name or imagsrc.)`, + `${_h.code('abschange')} -- Show the absolute value of the change that was applied to the property. (Only works on numeric fields, will result in 0 on things like name or imagsrc.)` + ) + ), + _h.paragraph(`Showing the amount of damage done to a token.`), + _h.inset( + _h.preformatted( + '!token-mod {{', + ' --set', + ` bar1_value|-${ch('[')}${ch('[')}2d6+8${ch(']')}${ch(']')}`, + ' --report', + ' all|"{name} takes {bar1_value:abschange} points of damage."', + '}}' + ) + ), + _h.paragraph(`Showing everyone the results of the hit, but only the gm and the controlling players the actual damage and original hit point value.`), + _h.inset( + _h.preformatted( + '!token-mod {{', + ' --set', + ` bar1_value|-${ch('[')}${ch('[')}2d6+8${ch(']')}${ch(']')}`, + ' --report', + ' all|"{name} takes a vicious wound leaving them at {bar1_value}hp out of {bar1_max}hp."', + ' gm:control|"{name} damage: {bar1_value:change}hp, was at {bar1_value:before}hp"', + '}}' + ) + ) + ) + ), + config: (context) => _h.join( + // SECTION: --config, etc + _h.section('Configuration', + _h.paragraph(`${_h.italic('--config')} takes option value pairs, separated by | characters.`), + _h.inset( + _h.pre( '!token-mod --config option|value option|value') + ), + _h.minorhead('Available Configuration Properties:'), + _h.ul( + `${_h.bold('players-can-ids')} -- Determines if players can use --ids. Specifying a value which is true allows players to use --ids. Omitting a value flips the current setting. ` + ), + ( playerIsGM(context.playerid) + ? _h.paragraph(getConfigOption_PlayersCanIDs()) + : '' + ) + ) + ), + + apiInterface: (/* context */) => _h.join( + // SECTION: .ObserveTokenChange(), etc + _h.section('API Notifications', + _h.paragraph( 'API Scripts can register for the following notifications:'), + _h.inset( + _h.paragraph( `${_h.bold('Token Changes')} -- Register your function by passing it to ${_h.code('TokenMod.ObserveTokenChange(yourFuncObject);')}. When TokenMod changes a token, it will call your function with the Token as the first argument and the previous properties as the second argument, identical to an ${_h.code("on('change:graphic',yourFuncObject);")} call.`), + _h.paragraph( `Example script that notifies when a token${ch("'")}s status markers are changed by TokenMod:`), + _h.inset( + _h.preformatted( + `on('ready',function(){`, + ` if('undefined' !== typeof TokenMod && TokenMod.ObserveTokenChange){`, + ` TokenMod.ObserveTokenChange(function(obj,prev){`, + ` if(obj.get('statusmarkers') !== prev.statusmarkers){`, + ` sendChat('Observer Token Change','Token: '+obj.get('name')+' has changed status markers!');`, + ` }`, + ` });`, + ` }`, + `});` + ) + ) + ) + ) + ), + + helpBody: (context) => _h.join( + _h.header( + _h.paragraph( 'TokenMod provides an interface to setting almost all settable properties of a token.') + ), + helpParts.commands(context), + helpParts.booleans(context), + helpParts.sets(context), + helpParts.move(context), + helpParts.reports(context), + helpParts.config(context), + helpParts.apiInterface(context) + ), + + helpDoc: (context) => _h.join( + _h.title('TokenMod',version), + helpParts.helpBody(context) + ), + + helpChat: (context) => _h.outer( + _h.title('TokenMod',version), + helpParts.helpBody(context) + ), + + helpStatusMarkers: (context) => _h.outer( + _h.title('TokenMod',version), + helpParts.availableStatusMarkers(context) + ), + + rebuiltHelp: (/*context*/) => _h.outer( + _h.title('TokenMod',version), + _h.header( + _h.paragraph( `${_h.helpHandoutLink()} regenerated.`) + ) + ) + }; + + + const showHelp = function(playerid) { + let who=(getObj('player',playerid)||{get:()=>'API'}).get('_displayname'); + let context = { + who, + playerid + }; + sendChat('', '/w "'+who+'" '+ helpParts.helpChat(context)); + }; + + + const getRelativeChange = function(current,update) { + let cnum,unum,op='='; + if(_.isString(update)){ + if( _.has(update,0) && ('=' === update[0]) ){ + return parseFloat(_.rest(update).join('')); + } + if( _.has(update,0) && ('!' === update[0]) ){ + if(''===current || 0===parseInt(current) ){ + return parseFloat(_.rest(update).join('')); + } else { + return ''; + } + } + + if(update.match(/^[+\-/*]/)){ // */ + op=update[0]; + update=_.rest(update).join(''); + } + } + + cnum = parseFloat(current); + unum = parseFloat(update); + + if(!_.isNaN(unum) && !_.isUndefined(unum) ) { + if(!_.isNaN(cnum) && !_.isUndefined(cnum) ) { + switch(op) { + case '+': + return cnum+unum; + case '-': + return cnum-unum; + case '*': + return cnum*unum; + case '/': + return cnum/(unum||1); + + default: + return unum; + } + } else { + return unum; + } + } + return update; + }; + + const parseArguments = function(a) { + let args = a.replace(/(\|#|##)/g,'|%%HASHMARK%%').split(/[|#]/).map((v)=>v.replace('%%HASHMARK%%','#')); + let cmd = unalias(args.shift().toLowerCase()); + let retr={}; + let t; + let t2; + + if(_.has(fields,cmd)) { + retr[cmd]=[]; + switch(fields[cmd].type) { + case 'boolean': { + let v = args.shift().toLowerCase(); + if(filters.isTruthyArgument(v)){ + retr[cmd].push(true); + } else if (probBool.hasOwnProperty(v)){ + retr[cmd].push(probBool[v]()); + } else { + retr[cmd].push(false); + } + } + break; + + case 'text': + retr[cmd].push(args.shift().replace(regex.stripSingleQuotes,'$1').replace(regex.stripDoubleQuotes,'$1')); + break; + + case 'option': { + let o = option_fields[cmd]; + let ks = Object.keys(o); + let arg = args.shift().toLowerCase(); + if(0 === arg.length || !ks.includes(arg)) { + arg='__default__'; + } + retr[cmd].push(o[arg](args.shift())); + } + break; + + + case 'numberBlank': + retr[cmd].push(numberOp.parse(cmd,args.shift())); + break; + + case 'number': + retr[cmd].push(numberOp.parse(cmd,args.shift(),false)); + break; + + case 'percentage': + retr[cmd].push(numberOp.parse(cmd,transforms.percentage(args.shift()),false)); + break; + + case 'degrees': + if( '=' === args[0][0] ) { + t='='; + args[0]=args[0].slice(1); + } else { + t=''; + } + retr[cmd].push(t+(['-','+'].includes(args[0][0]) ? args[0][0] : '') + Math.abs(transforms.degrees(args.shift()))); + break; + + case 'circleSegment': + if( '=' === args[0][0] ) { + t='='; + args[0]=_.rest(args[0]); + } else { + t=''; + } + retr[cmd].push(t+(_.contains(['-','+'],args[0][0]) ? args[0][0] : '') + transforms.circleSegment(args.shift())); + break; + + case 'layer': + retr[cmd].push((args.shift().match(regex.layers)||[]).shift()); + if(0 === (retr[cmd][0]||'').length) { + retr = undefined; + } + break; + + case 'defaulttoken': // blank + retr[cmd].push(''); + break; + + case 'sideNumber': { + let c = sideNumberOp.parseSideNumber(args.shift()); + if(c){ + retr[cmd].push(c); + } else { + retr = undefined; + } + } + break; + + case 'image': { + let c = imageOp.parseImage(args.shift()); + if(c){ + retr[cmd].push(c); + } else { + retr = undefined; + } + } + break; + + case 'color': { + let c = ColorOp.parseColor(args.shift()); + if(c){ + retr[cmd].push(c); + } else { + retr = undefined; + } + } + break; + + case 'character_id': + if('' === args[0]){ + retr[cmd].push(''); + } else { + t=getObj('character', args[0]); + if(t) { + retr[cmd].push(args[0]); + } else { + // try to find a character with this name + t2=findObjs({type: 'character',archived: false}); + t=_.chain([ args[0].replace(regex.stripSingleQuotes,'$1').replace(regex.stripDoubleQuotes,'$1') ]) + .map(function(n){ + let l=_.filter(t2,function(c){ + return c.get('name').toLowerCase() === n.toLowerCase(); + }); + return ( 1 === l.length ? l : _.filter(t2,function(c){ + return -1 !== c.get('name').toLowerCase().indexOf(n.toLowerCase()); + })); + }) + .flatten() + .value(); + if(1 === t.length) { + retr[cmd].push(t[0].id); + } else { + retr=undefined; + } + } + } + break; + + case 'attribute': + retr[cmd].push(args.shift().replace(regex.stripSingleQuotes,'$1').replace(regex.stripDoubleQuotes,'$1')); + break; + + case 'player': + _.each(args, function(p){ + let parts = p.match(/^([+-=]?)(.*)$/), + pids = (parts ? getPlayerIDs(parts[2].replace(regex.stripSingleQuotes,'$1').replace(regex.stripDoubleQuotes,'$1')):[]); + if(pids.length){ + _.each(pids,(pid)=>{ + retr[cmd].push({ + pid: pid, + operation: parts[1] || '=' + }); + parts[1]='+'; + }); + } else if(_.contains(['','='],p)){ + retr[cmd].push({ + pid:'', + operation:'=' + }); + } + }); + break; + + case 'status': + _.each(args, function(a) { + retr[cmd].push(statusOp.parse(a)); + }); + break; + + default: + retr=undefined; + break; + } + } + + return retr; + }; + + const expandMetaArguments = function(memo,a) { + let args=a.split(/[|#]/), + cmd=args.shift(); + switch(cmd) { + case 'bar1': + case 'bar2': + case 'bar3': + args=args.join('|'); + memo.push(cmd+'_value|'+args); + memo.push(cmd+'_max|'+args); + break; + case 'scale': + args.join('|'); + memo.push(`width|${args}`); + memo.push(`height|${args}`); + break; + default: + memo.push(a); + break; + } + return memo; + }; + + const parseOrderArguments = function(list,base) { + return _.chain(list) + .map(transforms.orderType) + .reject(_.isUndefined) + .union(base) + .value(); + }; + + const parseSetArguments = function(list,base) { + return _.chain(list) + .filter(filters.hasArgument) + .reduce(expandMetaArguments,[]) + .map(parseArguments) + .reject(_.isUndefined) + .reduce(function(memo,i){ + _.each(i,function(v,k){ + switch(k){ + case 'statusmarkers': + if(_.has(memo,k)) { + memo[k]=_.union(v,memo[k]); + } else { + memo[k]=v; + } + break; + default: + memo[k]=v; + break; + } + }); + return memo; + },base) + .value(); + }; + + const parseMoveArguments = (list,base) => + list + .reduce((m,a)=>{ + let args=a.replace(/(\|#|##)/g,'|%%HASHMARK%%').split(/[|#]/).map((v)=>v.replace('%%HASHMARK%%','#')); + m.push(moveOp.parse(args)); + return m; + },base) + ; + + const parseReportArguments = (list,base) => + list + .filter(filters.hasArgument) + .reduce((m,a)=>{ + let args=a.replace(/(\|#|##)/g,'|%%HASHMARK%%').split(/[|#]/).map((v)=>v.replace('%%HASHMARK%%','#')); + let whose=args.shift().toLowerCase().split(/[:;]/); + let msg = args.shift(); + if(/^(".*")|('.*')$/.test(msg)){ + msg=msg.slice(1,-1); + } + whose = whose.filter((w)=>reportTypes.includes(w)); + if(whose.length){ + m.push({who:whose,msg}); + } + return m; + },base) + ; + + const doSetWithWorkerOnLinkedBars = (token, mods) => { + [1,2,3].forEach(n=>{ + if(mods.hasOwnProperty(`bar${n}_value`) || mods.hasOwnProperty(`bar${n}_max`)){ + let a = getObj('attribute',token.get(`bar${n}_link`)); + if(a) { + let ops = {}; + if(mods.hasOwnProperty(`bar${n}_value`)){ + ops[`current`]=mods[`bar${n}_value`]; + delete mods[`bar${n}_value`]; + } + if(mods.hasOwnProperty(`bar${n}_max`)){ + ops[`max`]=mods[`bar${n}_max`]; + delete mods[`bar${n}_max`]; + } + if(Object.keys(ops).length){ + a.setWithWorker(ops); + } + } + } + }); + + return mods; + }; + + const applyModListToToken = function(modlist, token) { + let ctx={ + token: token, + prev: JSON.parse(JSON.stringify(token)) + }, + mods={ + statusmarkers: token.get('statusmarkers') + }, + delta, + cid, + repChar, + controlList = (modlist.set && (modlist.set.controlledby || modlist.set.defaulttoken)) ? (function(){ + let list; + repChar = getObj('character', modlist.set.represents || token.get('represents')); + + list = (repChar ? repChar.get('controlledby') : token.get('controlledby')); + return (list ? list.split(/,/) : []); + }()) : []; + + _.each(modlist.order,function(f){ + switch(f){ + case 'tofront': + toFront(token); + break; + + case 'toback': + toBack(token); + break; + } + }); + _.each(modlist.on,function(f){ + mods[f]=true; + }); + _.each(modlist.off,function(f){ + mods[f]=false; + }); + _.each(modlist.flip,function(f){ + mods[f]=!token.get(f); + }); + _.each(modlist.set,function(f,k){ + switch(k) { + case 'controlledby': + _.each(f, function(cb){ + switch(cb.operation){ + case '=': controlList=[cb.pid]; break; + case '+': controlList=_.union(controlList,[cb.pid]); break; + case '-': controlList=_.without(controlList,cb.pid); break; + } + }); + if(repChar){ + repChar.set('controlledby',controlList.join(',')); + } else { + mods[k]=controlList.join(','); + } + forceLightUpdateOnPage(token.get('pageid')); + break; + + case 'defaulttoken': + if(repChar){ + token.set(mods); + setDefaultTokenForCharacter(repChar,token); + } + break; + + case 'statusmarkers': + _.each(f, function (sm){ + mods.statusmarkers = sm.getMods(mods.statusmarkers).statusmarkers; + }); + break; + + case 'represents': + mods[k]=f[0]; + mods.bar1_link=''; + mods.bar2_link=''; + mods.bar3_link=''; + break; + + case 'bar1_link': + case 'bar2_link': + case 'bar3_link': + if( '' === f[0] ) { + mods[k]=''; + } else { + cid=mods.represents || token.get('represents') || ''; + if('' !== cid) { + delta=findObjs({type: 'attribute', characterid: cid, name: f[0]}, {caseInsensitive: true})[0]; + if(delta) { + mods[k]=delta.id; + mods[k.split(/_/)[0]+'_value']=delta.get('current'); + mods[k.split(/_/)[0]+'_max']=delta.get('max'); + } else { + let c = getObj('character',cid); + if(c) { + if(IsComputedAttr.IsComputed(c,f[0])){ + if(IsComputedAttr.IsAssignable(f[0])){ + mods[k]=f[0]; + } + } else { + mods[k]=`sheetattr_${f[0]}`; + } + } + } + } + } + break; + + + case 'dim_light_opacity': + mods = Object.assign( mods, f[0].getMods(token,mods)); + break; + + case 'left': + case 'top': + case 'width': + case 'height': + mods = Object.assign( mods, f[0].getMods(token,mods)); + break; + + case 'rotation': + case 'limit_field_of_vision_center': + case 'limit_field_of_night_vision_center': + case 'directional_bright_light_center': + case 'directional_dim_light_center': + delta=getRelativeChange(token.get(k),f[0]); + if(_.isNumber(delta)) { + mods[k]=(((delta%360)+360)%360); + } + break; + + case 'light_angle': + case 'light_losangle': + case 'limit_field_of_vision_total': + case 'limit_field_of_night_vision_total': + case 'directional_bright_light_total': + case 'directional_dim_light_total': + delta=getRelativeChange(token.get(k),f[0]); + if(_.isNumber(delta)) { + mods[k] = Math.min(360,Math.max(0,delta)); + } + break; + + case 'light_radius': + case 'light_dimradius': + case 'light_multiplier': + case 'light_sensitivity_multiplier': + case 'aura2_radius': + case 'aura1_radius': + case 'adv_fow_view_distance': + case 'night_vision_distance': + case 'bright_light_distance': + case 'low_light_distance': + case 'night_distance': + case 'bright_distance': + case 'low_distance': + mods = Object.assign( mods, f[0].getMods(token,mods)); + break; + + + case 'bar1_reset': + case 'bar2_reset': + case 'bar3_reset': { + let field = k.replace(/_reset$/,'_max'); + delta = mods[field] || token.get(field); + if(!_.isUndefined(delta)) { + mods[k.replace(/_reset$/,'_value')]=delta; + } + } + break; + + + case 'bar1_value': + case 'bar2_value': + case 'bar3_value': + if(regex.numberString.test(f[0])){ + delta=getRelativeChange(token.get(k),f[0]); + if(_.isNumber(delta) || _.isString(delta)) { + if(/!$/.test(f[0])) { + delta = Math.max(0,Math.min(delta,token.get(k.replace(/_value$/,'_max')))); + } + let link = token.get(k.replace(/_value$/,'_link')); + if(IsComputedAttr.Check(link)) { + if(!IsComputedAttr.Readonly(link)){ + setComputed({characterId:token.get('represents'),property:link,args:[delta]}); + mods[k]=delta; + } + } else { + mods[k]=delta; + } + } + } else { + mods[k]=f[0]; + } + break; + + case 'bar1_max': + case 'bar2_max': + case 'bar3_max': + if(regex.numberString.test(f[0])){ + delta=getRelativeChange(token.get(k),f[0]); + if(_.isNumber(delta) || _.isString(delta)) { + let link = `${token.get(k.replace(/_max$/,'_link'))}_max`; + if(IsComputedAttr.Check(link)) { + if(!IsComputedAttr.Readonly(link)){ + setComputed({characterId:token.get('represents'),property:link,args:[delta]}); + mods[k]=delta; + } + } else { + mods[k]=delta; + } + } + } else { + mods[k]=f[0]; + } + break; + case 'name': + if(regex.numberString.test(f[0])){ + delta=getRelativeChange(token.get(k),f[0]); + if(_.isNumber(delta) || _.isString(delta)) { + mods[k]=delta; + } + } else { + mods[k]=f[0]; + } + break; + + case 'currentSide': + case 'currentside': + mods = Object.assign( mods, f[0].getMods(token,mods)); + break; + case 'imgsrc': + mods = Object.assign( mods, f[0].getMods(token,mods)); + break; + + case 'aura1_color': + case 'aura2_color': + case 'tint_color': + case 'night_vision_tint': + case 'lightColor': + mods[k]=f[0].applyTo(token.get(k)).toHTML(); + break; + + case 'night_vision_effect': + mods[k]=f[0](token,mods); + break; + +/* + case 'light_sensitivity_multiplier': + // {type: 'number'}, + break; + + // 'None', 'Dimming', 'Nocturnal' + break; + case 'bar_location': + // null, 'overlap_top', 'overlap_bottom', 'below' + break; + + case 'compact_bar': + // null, 'compact' + break; +*/ + + default: + mods[k]=f[0]; + break; + } + }); + + // move ops + _.each(modlist.move,function(f){ + mods = Object.assign(mods, f.getMods(token,mods)); + }); + + mods = doSetWithWorkerOnLinkedBars(token,mods); + + token.set(mods); + notifyObservers('tokenChange',token,ctx.prev); + return ctx; + }; + + const getWho = (()=> { + let cache={}; + return (ids) => { + let names = []; + ids.forEach(id=>{ + if(cache.hasOwnProperty(id)){ + names.push(cache[id]); + } else { + if('all'===id){ + cache.all = 'all'; + names.push('all'); + } else { + let p = findObjs({ type: 'player', id})[0]; + if(p){ + cache[id]=p.get('displayname'); + names.push(cache[id]); + } + } + } + }); + if(names.includes('all')){ + return ['all']; + } + if(0===names.length){ + return ['gm']; + } + return names; + }; + })(); + + const doReports = (ctx,reports,callerWho) => { + const transforms = { + identity: a=>a, + addOne: a=>a+1 + }; + + const getTransform = (p) => { + switch(p){ + case 'currentSide': return transforms.addOne; + default: return transforms.identity; + } + }; + + + const getChange = (()=> { + const charName = (cid) => (getObj('character',cid)||{get:()=>'[Missing]'}).get('name'); + const attrName = (aid) => (/^sheetattr_/.test(aid) ? aid.replace(/^sheetattr_/,'') : (getObj('attribute',aid)||{get:()=>'[Missing]'}).get('name')); + const playerName = (pid) => (getObj('player',pid)||{get:()=>pid}).get('_displayname'); + const nameList = (pl) => pl.split(/\s*,\s*/).filter(s=>s.length).map(playerName).join(', '); + const boolName = (b) => (b ? 'true' : 'false'); + + const diffNum = (was,is) => is-was; + const showDiff = (was,is) => `${was} -> ${is}`; + const funcs = { + boolean: (was,is) => showDiff(boolName(was),boolName(is)), + number: diffNum, + degrees: diffNum, + circleSegment: diffNum, + numberBlank: diffNum, + sideNumber: diffNum, + text: (was,is) => showDiff(was,is), + status: (was,is) => showDiff(was,is), + layer: (was,is) => showDiff(was,is), + character_id: (was,is) => showDiff(charName(was),charName(is)), + attribute: (was,is) => showDiff(attrName(was),attrName(is)), + player: (was,is) => showDiff(nameList(was),nameList(is)), + defaulttoken: (was,is) => showDiff(HE(was),HE(is)) + }; + + return (type,was,is) => (funcs.hasOwnProperty(type) ? funcs[type] : ()=>'[not supported]')(was,is); + + })(); + + reports.forEach( r =>{ + let pmsg = r.msg.replace(/\{(.+?)\}/g, (m,n)=>{ + let parts=n.toLowerCase().split(/[:;]/); + let prop=unalias(parts[0]); + let t = getTransform(prop); + + let mod=parts[1]; + + switch(mod){ + case 'before': + return t(ctx.prev[prop]); + + case 'abschange': + return t(Math.abs((parseFloat(ctx.token.get(prop))||0) - (parseFloat(ctx.prev[prop]||0)))); + + case 'change': + return t(getChange((fields[prop]||{type:'unknown'}).type,ctx.prev[prop],ctx.token.get(prop))); + + default: + return t(ctx.token.get(prop)); + } + }); + + let whoList = r.who.reduce((m, w)=>{ + switch(w){ + case 'gm': + return [...new Set([...m,'gm'])]; + + case 'player': + return [...new Set([...m,callerWho])]; + + case 'all': + return [...new Set([...m,'all'])]; + + case 'token': + return [...new Set([...m, ...getWho(ctx.token.get('controlledby').split(/,/))])]; + + case 'character': { + let c = getObj('character',ctx.token.get('represents')) || {get:()=>''}; + return [...new Set([...m, ...getWho(c.get('controlledby').split(/,/))])]; + } + + case 'control': { + let c = getObj('character',ctx.token.get('represents')) || {get:()=>''}; + return [...new Set([ + ...m, + ...getWho(ctx.token.get('controlledby').split(/,/)), + ...getWho(c.get('controlledby').split(/,/)) + ])]; + } + } + }, []); + + if(whoList.includes('all')){ + sendChat('',`${pmsg}`); + } else { + whoList.forEach(w=>sendChat('',`/w "${w}" ${pmsg}`)); + } + }); + }; + + const handleConfig = function(config, id) { + let args, cmd, who=(getObj('player',id)||{get:()=>'API'}).get('_displayname'); + + if(config.length) { + while(config.length) { + args=config.shift().split(/[|#]/); + cmd=args.shift(); + switch(cmd) { + case 'players-can-ids': + if(args.length) { + state.TokenMod.playersCanUse_ids = filters.isTruthyArgument(args.shift()); + } else { + state.TokenMod.playersCanUse_ids = !state.TokenMod.playersCanUse_ids; + } + sendChat('','/w "'+who+'" '+ + '
    '+ + getConfigOption_PlayersCanIDs()+ + '
    ' + ); + break; + default: + sendChat('', '/w "'+who+'" '+ + '
    '+ + 'Error: '+ + 'No configuration setting for ['+cmd+']'+ + '
    ' + ); + break; + } + } + } else { + sendChat('','/w "'+who+'" '+ + '
    '+ + '
    '+ + 'TokenMod v'+version+ + '
    '+ + getConfigOption_PlayersCanIDs()+ + '
    ' + ); + } + }; + + + + +const OutputDebugInfo = (msg,ids /*, modlist, badCmds */) => { + let selMap = (msg.selected||[]).map(o=>o._id); + let who=(getObj('player',msg.playerid)||{get:()=>'API'}).get('_displayname'); + let fMsg = HE(msg.content.replace(//g,'')).replace(/ /g,' ').replace(/\$/g,'$'); + let fIds = ids.map((o)=>{ + if(undefined !== o.token){ + return `${_h.bold('Token:')} ${o.token.get('name')} [${_h.code(o.token.id)}]${selMap.includes(o.token.id)?` ${_h.bold('Selected')}`:''}`; + } else if(undefined !== o.character){ + return `${_h.bold('Character:')} ${o.character.get('name')} [${_h.code(o.character.id)}]`; + } + return `${_h.bold('Unknown:')} [${_h.code(o.id)}]`; + }); + + sendChat('TokenMod: Debug',`/w "${who}" &{template:default}{{Command=${_h.pre(fMsg)}}}{{Targets=${_h.ul(...fIds)}}}`); + + //$d({msg:msg.content,fMsg,modlist,badCmds}); + }; + + + const processInlinerolls = (msg) => { + if(msg.hasOwnProperty('inlinerolls')){ + return msg.inlinerolls + .reduce((m,v,k) => { + let ti=v.results.rolls.reduce((m2,v2) => { + if(v2.hasOwnProperty('table')){ + m2.push(v2.results.reduce((m3,v3) => [...m3,(v3.tableItem||{}).name],[]).join(", ")); + } + return m2; + },[]).join(', '); + return [...m,{k:`$[[${k}]]`, v:(ti.length && ti) || v.results.total || 0}]; + },[]) + .reduce((m,o) => m.replaceAll(o.k,o.v), msg.content); + } else { + return msg.content; + } + }; + +// */ + const handleInput = function(msg_orig) { + try { + if (msg_orig.type !== "api" || !/^!token-mod(\b\s|$)/.test(msg_orig.content)) { + return; + } + + let msg = _.clone(msg_orig); + let who=(getObj('player',msg_orig.playerid)||{get:()=>'API'}).get('_displayname'); + let playerid = msg.playerid; + let args; + let cmds; + let ids=[]; + let ignoreSelected = false; + let pageRestriction=[]; + let modlist={ + flip: [], + on: [], + off: [], + set: {}, + move: [], + order: [] + }; + let reports=[]; + + msg.content = processInlinerolls(msg); + + args = msg.content + .replace(/\n/g, ' ') + .replace(/(\{\{(.*?)\}\})/g," $2 ") + .split(/\s+--/); + + let IsDebugRequest = false; + let Debug_UnrecognizedCommands = []; + + + while(args.length) { + cmds=args.shift().match(/([^\s]+[|#]'[^']+'|[^\s]+[|#]"[^"]+"|[^\s]+)/g); + let cmd = cmds.shift(); + switch(cmd) { + case 'help-statusmarkers': { + let context = { + who, + playerid:msg.playerid + }; + sendChat('', '/w "'+who+'" '+ helpParts.helpStatusMarkers(context)); + } + return; + + case 'rebuild-help': { + assureHelpHandout(true); + let context = { + who, + playerid:msg.playerid + }; + + sendChat('', `/w "${who}" ${helpParts.rebuiltHelp(context)}`); + + } + return; + + case 'help': + +// !tokenmod --help [all] +// just the top part and ToC + +// !tokenmod --help +// just the top part and ToC + +// !tokenmod --help[-only] [set|on|off|flip|config] +// top part, plus the command parts +// -only leaves off top part + +// !tokenmod --help[-only] [ +// explains the parts command + + + showHelp(playerid); + return; + + case 'api-as': + if('API' === playerid){ + let player = getObj('player',cmds[0]); + if(player){ + playerid = player.id; + who = player.get('_displayname'); + } + } + break; + + case 'debug': { + IsDebugRequest = true; + } + break; + + case 'config': + if(playerIsGM(playerid)) { + handleConfig(cmds,playerid); + } + return; + + + case 'flip': + modlist.flip=_.union(_.filter(cmds.map(unalias),filters.isBoolean),modlist.flip); + break; + + case 'on': + modlist.on=_.union(_.filter(cmds.map(unalias),filters.isBoolean),modlist.on); + break; + + case 'off': + modlist.off=_.union(_.filter(cmds.map(unalias),filters.isBoolean),modlist.off); + break; + + case 'set': + modlist.set=parseSetArguments(cmds,modlist.set); + break; + + case 'order': + modlist.order=parseOrderArguments(cmds,modlist.order); + break; + + case 'report': + reports= parseReportArguments(cmds,reports); + break; + + case 'move': + modlist.move = parseMoveArguments(cmds,modlist.move); + break; + + case 'ignore-selected': + ignoreSelected=true; + break; + + case 'active-pages': + pageRestriction=getActivePages(); + break; + + case 'current-page': + pageRestriction=[getPageForPlayer(playerid)]; + break; + + case 'ids': + ids=_.union(cmds,ids); + break; + + default: + Debug_UnrecognizedCommands.push({cmd,args:cmds}); + break; + } + } + modlist.off=_.difference(modlist.off,modlist.on); + modlist.flip=_.difference(modlist.flip,modlist.on,modlist.off); + if( !playerIsGM(playerid) && !state.TokenMod.playersCanUse_ids ) { + ids=[]; + } + + if(!ignoreSelected) { + ids=_.union(ids,_.pluck(msg.selected,'_id')); + } + + let pageFilter = pageRestriction.length + ? (o) => pageRestriction.includes(o.get('pageid')) + : () => true; + + ids = [...new Set([...ids])] + .map(function(t){ + return { + id: t, + token: getObj('graphic',t), + character: getObj('character',t) + }; + }); + + if(IsDebugRequest){ + OutputDebugInfo(msg_orig,ids,modlist,Debug_UnrecognizedCommands); + } + + if(ids.length){ + [...new Set(ids.reduce(function(m,o){ + if(o.token){ + m.push(o.token); + } else if(o.character){ + m=_.union(m,findObjs({type:'graphic',represents:o.character.id})); + } + return m; + },[]))] + .filter(o=>undefined !== o) + .filter(pageFilter) + .forEach((t) => { + let ctx = applyModListToToken(modlist,t); + doReports(ctx,reports,who); + }); + } + } catch (e) { + let who=(getObj('player',msg_orig.playerid)||{get:()=>'API'}).get('_displayname'); + sendChat('TokenMod',`/w "${who}" `+ + `
    `+ + `
    There was an error while trying to run your command:
    `+ + `
    ${msg_orig.content}
    `+ + `
    Please send me this information so I can make sure this doesn't happen again (triple click for easy select in most browsers.):
    `+ + `
    `+ + JSON.stringify({msg: msg_orig, version:version, stack: e.stack, API_Meta})+ + `
    `+ + `
    ` + ); + } + + }; + + const registerEventHandlers = function() { + on('chat:message', handleInput); + on('change:campaign:_token_markers',()=>StatusMarkers.init()); + }; + + on("ready",() => { + checkInstall(); + registerEventHandlers(); + }); + + return { + ObserveTokenChange: observeTokenChange + }; +})(); + +{try{throw new Error('');}catch(e){API_Meta.TokenMod.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.TokenMod.offset);}} diff --git a/TokenMod/TokenMod.js b/TokenMod/TokenMod.js index f7576acd93..daa0ef5cda 100644 --- a/TokenMod/TokenMod.js +++ b/TokenMod/TokenMod.js @@ -8,9 +8,9 @@ API_Meta.TokenMod={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; const TokenMod = (() => { // eslint-disable-line no-unused-vars const scriptName = "TokenMod"; - const version = '0.8.79'; + const version = '0.8.80'; API_Meta.TokenMod.version = version; - const lastUpdate = 1734377331; + const lastUpdate = 1735875678; const schemaVersion = 0.4; const fields = { @@ -1503,7 +1503,59 @@ const TokenMod = (() => { // eslint-disable-line no-unused-vars } //////////////////////////////////////////////////////////// + // IsComputedAttr ////////////////////////////////////////// + //////////////////////////////////////////////////////////// + const getComputedProxy = ("undefined" !== typeof getComputed) + ? async (...a) => await getComputed(...a) + : async ()=>{} + ; + + class IsComputedAttr { + static #computedMap = new Map(); + static #sheetMap = new Map(); + + static async DoReady() { + let c = Campaign(); + Object.keys(c?.computedSummary||{}).forEach(k=>{ + IsComputedAttr.#computedMap.set(k,c.computedSummary[k]); + }); + + let cMap = findObjs({type:"character"}).reduce((m,c)=>({...m,[c.get('charactersheetname')]:c.id}),{}); + let promises = Object.keys(cMap).map(async c => { + let k = IsComputedAttr.#computedMap.keys().next().value; + if(k) { + let v = await getComputedProxy({characterId:cMap[c],property:k}); + IsComputedAttr.#sheetMap.set(c, undefined !== v); + } + }); + await Promise.all(promises); + } + + static Check(attrName) { + return IsComputedAttr.#computedMap.has(attrName); + } + + static Assignable(attrName) { + return IsComputedAttr.#computedMap.get(attrName)?.tokenBarValue ?? false; + } + + static Readonly(attrName) { + return IsComputedAttr.#computedMap.get(attrName)?.readonly ?? true; + } + + static IsComputed(sheet,attrName) { + let sheetName = sheet.get('charactersheetname'); + + if(IsComputedAttr.Check(attrName) && IsComputedAttr.#sheetMap.has(sheetName)){ + return IsComputedAttr.#sheetMap.get(sheetName); + } + return false; + } + + } + on('ready',IsComputedAttr.DoReady); + //////////////////////////////////////////////////////////// @@ -3477,7 +3529,16 @@ const TokenMod = (() => { // eslint-disable-line no-unused-vars mods[k.split(/_/)[0]+'_value']=delta.get('current'); mods[k.split(/_/)[0]+'_max']=delta.get('max'); } else { - mods[k]=`sheetattr_${f[0]}`; + let c = getObj('character',cid); + if(c) { + if(IsComputedAttr.IsComputed(c,f[0])){ + if(IsComputedAttr.IsAssignable(f[0])){ + mods[k]=f[0]; + } + } else { + mods[k]=`sheetattr_${f[0]}`; + } + } } } } @@ -3556,7 +3617,15 @@ const TokenMod = (() => { // eslint-disable-line no-unused-vars if(/!$/.test(f[0])) { delta = Math.max(0,Math.min(delta,token.get(k.replace(/_value$/,'_max')))); } - mods[k]=delta; + let link = token.get(k.replace(/_value$/,'_link')); + if(IsComputedAttr.Check(link)) { + if(!IsComputedAttr.Readonly(link)){ + setComputed({characterId:token.get('represents'),property:link,args:[delta]}); + mods[k]=delta; + } + } else { + mods[k]=delta; + } } } else { mods[k]=f[0]; @@ -3566,6 +3635,23 @@ const TokenMod = (() => { // eslint-disable-line no-unused-vars case 'bar1_max': case 'bar2_max': case 'bar3_max': + if(regex.numberString.test(f[0])){ + delta=getRelativeChange(token.get(k),f[0]); + if(_.isNumber(delta) || _.isString(delta)) { + let link = `${token.get(k.replace(/_max$/,'_link'))}_max`; + if(IsComputedAttr.Check(link)) { + if(!IsComputedAttr.Readonly(link)){ + setComputed({characterId:token.get('represents'),property:link,args:[delta]}); + mods[k]=delta; + } + } else { + mods[k]=delta; + } + } + } else { + mods[k]=f[0]; + } + break; case 'name': if(regex.numberString.test(f[0])){ delta=getRelativeChange(token.get(k),f[0]); diff --git a/TokenMod/script.json b/TokenMod/script.json index 3b94ec9558..9e127b8995 100644 --- a/TokenMod/script.json +++ b/TokenMod/script.json @@ -1,7 +1,7 @@ { "name": "TokenMod", "script": "TokenMod.js", - "version": "0.8.79", + "version": "0.8.80", "description": "TokenMod provides an interface to setting almost all settable properties of a token.\r\rFor instructions see the *Help: TokenMod* Handout in game, or run `!token-mod --help` in game, or visit [TokenMod Forum Thread](https://app.roll20.net/forum/post/4225825/script-update-tokenmod-an-interface-to-adjusting-properties-of-a-token-from-a-macro-or-the-chat-area).", "authors": "The Aaron", "roll20userid": "104025", @@ -72,6 +72,7 @@ "0.8.75", "0.8.76", "0.8.77", - "0.8.78" + "0.8.78", + "0.8.79" ] } \ No newline at end of file From 99d42ea1cfb25c25304c8718d3cfaeec6c42df44 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Thu, 2 Jan 2025 21:54:03 -0600 Subject: [PATCH 4/7] removed modifiers --- GroupInitiative/script.json | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/GroupInitiative/script.json b/GroupInitiative/script.json index db3e2bd356..f0e9cfa5f8 100644 --- a/GroupInitiative/script.json +++ b/GroupInitiative/script.json @@ -6,13 +6,6 @@ "roll20userid": "104025", "patreon": "https://www.patreon.com/shdwjk", "dependencies": [], - "modifies": { - "state.GroupInitiative": "read,write", - "campaign.turnorder": "read,write", - "graphic.represents": "read", - "attribute.current": "read", - "attribute.max": "read" - }, "conflicts": [], "script": "GroupInitiative.js", "useroptions": [], @@ -39,4 +32,4 @@ "0.9.36", "0.9.37" ] -} \ No newline at end of file +} From 3406945f54d83522a4aa7fc16f5955354ce24e57 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Thu, 2 Jan 2025 21:54:48 -0600 Subject: [PATCH 5/7] Removed Modifiers --- GroupInitiative/script.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/GroupInitiative/script.json b/GroupInitiative/script.json index 35e7e33689..09ae84325c 100644 --- a/GroupInitiative/script.json +++ b/GroupInitiative/script.json @@ -6,13 +6,6 @@ "roll20userid": "104025", "patreon": "https://www.patreon.com/shdwjk", "dependencies": [], - "modifies": { - "state.GroupInitiative": "read,write", - "campaign.turnorder": "read,write", - "graphic.represents": "read", - "attribute.current": "read", - "attribute.max": "read" - }, "conflicts": [], "script": "GroupInitiative.js", "useroptions": [], From d962407c5bcd6347c2b958d2529af18b0ca7010d Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Thu, 2 Jan 2025 21:56:16 -0600 Subject: [PATCH 6/7] Revert change in wrong branch --- GroupInitiative/script.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/GroupInitiative/script.json b/GroupInitiative/script.json index f0e9cfa5f8..db3e2bd356 100644 --- a/GroupInitiative/script.json +++ b/GroupInitiative/script.json @@ -6,6 +6,13 @@ "roll20userid": "104025", "patreon": "https://www.patreon.com/shdwjk", "dependencies": [], + "modifies": { + "state.GroupInitiative": "read,write", + "campaign.turnorder": "read,write", + "graphic.represents": "read", + "attribute.current": "read", + "attribute.max": "read" + }, "conflicts": [], "script": "GroupInitiative.js", "useroptions": [], @@ -32,4 +39,4 @@ "0.9.36", "0.9.37" ] -} +} \ No newline at end of file From e4b37680f96a745c2c596b45b05b4463cb354c3b Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Thu, 2 Jan 2025 21:58:04 -0600 Subject: [PATCH 7/7] Fixed hanging comma --- GroupInitiative/script.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GroupInitiative/script.json b/GroupInitiative/script.json index 09ae84325c..2a6254f29c 100644 --- a/GroupInitiative/script.json +++ b/GroupInitiative/script.json @@ -32,6 +32,6 @@ "0.9.36", "0.9.37", "0.9.38", - "0.9.39", + "0.9.39" ] }