Right, I successfully procrastinated and avoided doing any of the work I was supposed to do. Hooray! I also updated the script above. It now puts a marker on all the tokens, default is 'tread' / 'tread@2' / 'tread@3' for the levels of encumbrance. It'll also update a new page's tokens on player ribbon move (not DM view move). It does not update NPCs, as they don't have the right speed attribute or an inventory. The command line goes: !varEnc --option1 arguments1 --option2 arguments2 Available options: --chat [public | whisper | off] toggles the main status update in the template from public posting / whispered (to the affected character) / off. Default is public --chat speed [public | whisper | off] toggles the new speed update in chat from public post / whispered (to the affected character) / off. Default is whisper --chat template=" roll20 template macro " change the template output for the main status update. Replacer String for the main message is %msg%. Default is &{template:desc}{{desc=%msg%}} --rule [none | light | heavy | immobile]=" Your custom rule here " edit the rules printed to the chat status update for each level of encumbrance. Replacer string for character's name is %name%, e.g. " %name%'s bag is full!" --marker [on | off] turn the auto-marker on or off. Default is on --marker page [on | off] turn the auto-marker update on player page move on or off. Default is on -- marker [none | light | heavy | immobile]=" markerName " change the marker for the supplied level of encumbrance. Optionally supply an @X on the end to print the number X on the icon. Fails if the supplied marker name is not found in your Campaign. -- players [ on | off ] Toggle allowing players to use --setspeed and --report. All other commands are GM-only, no matter what this setting is. Default is on -- setspeed [ X ] change the basespeed of the selected token's character sheet. Supply a number to change it to that number, or just "--setspeed" to set basespeed to the currently displayed @{speed} on the character sheet. @{basespeed} is not displayed anywhere on the sheet, but can be called to chat with @{charname|basespeed}. --report [ w | whisper ] send an inventory report to chat. If an argument is supplied and contains 'w', the report will be whispered to the character. Reporting send info on equipped/unequipped weights, coin weight, "weightless" total (for items with a non-integer in the weight field) and also calculates container weights if you're using "=== Backpack ===" style markers to organise your inventory. Any item with two equals signs in a row "==" will be assumed to be a container, all text in the field that isn't an equals sign is assumed to be the container name. All items below are assumed to belong to that container until another "==" is hit. Example below. Examples: Change to the default template, and modify the text for the 'none' encumbrance rules: !varEnc --chat template="&{template:default}{{name=Alternate View}}{{%msg%}}" --rule none="The sudden release of weight sends %name% hurtling into the Ethereal." Change the modified movement value to public chat, change the selected token's default move speed to 50 feet !varEnc --setspeed 50 --chat speed public Generate a report on a character's inventory. The example shows the container calculation done on a sheet containing "=== Belt ===" style markers, and the bag weight report that's generated if any of these containers are found. The script also calculates bag weights for any "weightless" items; items where the weight has a non-integer at the start of the weight field so the sheet doesn't calculate it. !varEnc --report varEnc API script /* globals */ const varEnc = (() => { // eslint-disable-line no-unused-vars const ver = { M: 0, m: 3, p: 2, getString: function(){ return `${this.M}.${this.m}.${this.p}` }, getFloat: function(){ return parseFloat(`${ver.M}${ver.m}.${ver.p}`) }, }; // Constants, config & data const defaultConfig = { ver: ver.getFloat(), tokenMarker: { apply: 1, watchPage: 1, none: '', light: 'tread', heavy: 'tread@2', immobile: 'tread@3' }, chatConfig: { showStatus: 2, showSpeed: 1, template: `&{template:desc}{{desc=%msg%}}`, rules: { none: `**%name%** is no longer encumbered.`, light: `**%name%** is lightly encumbered. Their speed drops by 10ft.`, heavy: `**%name%** is heavily encumbered. Their speed drops by 20ft, and they have disadvantage on ability checks, attack rolls, and saving throws that use Strength, Dexterity, or Constitution.`, immobile: `**%name%** is immobilised. Their speed is now 0, and they have disadvantage on ability checks, attack rolls, and saving throws that use Strength, Dexterity, or Constitution.`, }, }, chatName: `varEnc API`, playerFunctions: 1, } // Regex const rx = { cliTrue: /\b(on|1|true)\b/i, cliFalse: /\b(off|0|false)\b/i, getQuoteString: /"([^"]+?)"/, encLevels: /\b(none|light|heavy|immobile)\b/i, chatTypes: /\b(public|private|whisper|none|off)\b/i } // State config const checkInstall = () => { // Ver control - no install / pre-versioning install / versioning if (!Object.keys(state).includes('varEnc') || !state.varEnc.chatConfig || !state.varEnc.chatConfig.rules) state.varEnc = defaultConfig; if (!state.varEnc.ver) { Object.assign(state.varEnc, {ver: defaultConfig.ver, playerFunctions: defaultConfig.playerFunctions}); log(`Updating ${scriptName}...`); } let installedVer = state.varEnc.ver; if (installedVer < ver.getFloat()) { // Update Script } // Update variables chatName = state.varEnc.chatName; H.updateChatConfig(); let markerSet = Campaign().get('token_markers')||`[]`; currentPage = Campaign().get('playerpageid'); availableMarkers = JSON.parse(markerSet).map(m => m.name); log(`{ ${scriptName} } v${ver.getString()} initialised`); } const scriptName = `varEnc API`; let chatName = `varEnc API`; let availableMarkers = []; const chatMsg = { template: '', none: '', light: '', heavy: '', immobile: '', create: function(encumbrance, charName='Character') { let enc = this[encumbrance] || `Encumbrance changed on %name%, but no rule for "${encumbrance}" was found.`; return `${this.template.replace(/%msg%/i, enc.replace(/%name%/g, charName))}`; } }; let currentPage; // Handle encumbrance changes const handleAttrChange = (ev, refreshOnly) => { if (!/encumberance/i.test(ev.get('name'))) return; let char = getObj('character', ev.get('characterid')); if (!char) return; let encumberLevel = ev.get('current'), charname = char.get('name'), speedAttr = findObjs({type: 'attribute', name: 'speed', characterid: char.id})[0], speed = speedAttr ? parseInt(speedAttr.get('current')) : null, baseSpeedAttr = findObjs({type: 'attribute', name: 'basespeed', characterid: char.id})[0], baseSpeed = baseSpeedAttr ? parseInt(baseSpeedAttr.get('current')) : null, newSpeed, chatTemplate; if (!baseSpeed && (speed == null || isNaN(speed))) return sendChat(chatName, `/w gm No valid movement speed found on ${charname} for encumberance change!`); if (!baseSpeed) { createObj('attribute', {name: 'basespeed', characterid: char.id, current: speed}); baseSpeed = speed; } newSpeed = /immob/i.test(encumberLevel) ? 0 : /heav/i.test(encumberLevel) ? Math.max(baseSpeed - 20, 0) : /enc/i.test(encumberLevel) ? Math.max(baseSpeed - 10, 0) : baseSpeed; encumberLevel = /immob/i.test(encumberLevel) ? 'immobile' : /heav/i.test(encumberLevel) ? 'heavy' : /enc/i.test(encumberLevel) ? 'light' : 'none'; chatTemplate = chatMsg.create(encumberLevel, charname); if (state.varEnc.tokenMarker.apply) H.handleMarker(char, encumberLevel); if (isNaN(newSpeed)) sendChat(chatName, `/w gm ${charname}'s new speed could not be calculated.`); else { speedAttr.set({current: `${newSpeed}`}); if (newSpeed !== speed) { if (state.varEnc.chatConfig.showSpeed === 2) sendChat(chatName, `${charname}'s movement speed has been changed to ${newSpeed}.`); if (state.varEnc.chatConfig.showSpeed === 1) sendChat(chatName, `/w "${charname}" Your movement speed has been changed to ${newSpeed}.`); } } if (state.varEnc.chatConfig.showStatus === 2 && !refreshOnly) sendChat(chatName, chatTemplate); else if (state.varEnc.chatConfig.showStatus === 1 && !refreshOnly) sendChat(chatName, `/w "${charname}" ${chatTemplate}`); } const handleInput = (msg) => { if ('api' === msg.type && /^!varenc\s/i.test(msg.content) ) { let gmFlag = playerIsGM(msg.playerid), playerFlag = state.varEnc.playerFunctions; if (msg.rolltemplate) sendChat('sdg', msg.rolltemplate); let cmdLine = msg.content.match(/^!varenc\s+(.*)/i)[1], cmds = cmdLine.split(/\s*--\s*/g), msgWho = `"${msg.who.replace(/\s\(gm\).*/i, '')}"`; cmds.shift(); cmds.forEach(cmd => { let opt = (cmd.match(/^(\w+)/)||[])[1], args = opt ? cmd.replace(opt, '') : ''; // GM only settings if (gmFlag) { // Token marker settings if (/^marker/i.test(opt)) { if (/=/i.test(args)) { let tier = (args.match(rx.encLevels)||[])[1], marker = (args.match(rx.getQuoteString)||[])[1], markerName = marker ? marker.replace(/@\d+/, '') : null; if (tier && marker) { if (availableMarkers.includes(markerName)) H.changeSetting(tier, marker, 'tokenMarker'); else sendChat(chatName, `/w ${msgWho} marker "${markerName}" not found in Campaign`); } } else if (/\bpage/.test(args)) { if (rx.cliFalse.test(args)) H.changeSetting('watchPage', 0, 'tokenMarker'); else if (rx.cliTrue.test(args)) H.changeSetting('watchPage', 1, 'tokenMarker'); } else { if (rx.cliFalse.test(args)) H.changeSetting('apply', 0, 'tokenMarker'); else if (rx.cliTrue.test(args)) H.changeSetting('apply', 1, 'tokenMarker'); } // Chat settings } else if (/^chat/i.test(opt)) { // Change template - check for missing &{template} parameter since Roll20 steals it. if (/template\s*=\s*/i.test(args)) { let newTemp = (args.match(rx.getQuoteString)||[])[1].trim(), parsedTemplate = msg.rolltemplate; newTemp = /^\s*\{template:/i.test(newTemp) ? `&${newTemp}` : parsedTemplate ? `&{template:${parsedTemplate}} ${newTemp}` : null; if (newTemp) H.changeSetting('template', newTemp, 'chatConfig'); } else { let chatType = (args.match(rx.chatTypes)||[])[1]; if (chatType) { let settingTarget = /speed/i.test(args) ? 'showSpeed' : 'showStatus'; chatType = /(none|off)/i.test(chatType) ? 0 : /(private|whisper)/i.test(chatType) ? 1 : 2; H.changeSetting(settingTarget, chatType, 'chatConfig'); } } // Encumbrance rule settings } else if (/^rule/i.test(opt)) { let tier = (args.match(rx.encLevels)||[])[1], newRule = (args.match(rx.getQuoteString)||[])[1]; if (tier && newRule) H.changeSetting(tier, newRule, 'chatConfig/rules'); // Reset character base speed } else if (/^player/i.test(opt)) { if (rx.cliFalse.test(args)) H.changeSetting('playerFunctions', 0); else if (rx.cliTrue.test(args)) H.changeSetting('playerFunctions', 1); } } // Players (if enabled) settings if (gmFlag || playerFlag) { // Change base speed if (/^(speed|setspeed)/i.test(opt)) { let char = H.getSelected(msg.selected), newSpeed = args ? args.replace(/\D/g, '') : null; if (!char) return; if (!newSpeed || isNaN(newSpeed)) { let currentSpeedAttr = findObjs({type:'attribute', name:'speed', characterid: char.id})[0], currentSpeed = currentSpeedAttr ? currentSpeedAttr.get('current') : null; newSpeed = parseInt(currentSpeed) || null; } if (!newSpeed) return sendChat(chatName, `/w ${msgWho} Couldn't find a valid speed for ${char.get('name')}`); let baseSpeedAttr = findObjs({type:'attribute', name:'basespeed', characterid:char.id})[0]; if (!baseSpeedAttr) createObj('attribute', {name:'basespeed', characterid:char.id, current: newSpeed}); else baseSpeedAttr.set({current: newSpeed}); sendChat(chatName, `/w ${msgWho} Base speed for ${char.get('name')} set to ${newSpeed}.`); let encumberAttr = findObjs({type:'attribute', characterid: char.id, name:'encumberance'}); if (encumberAttr) handleAttrChange(encumberAttr[0], true); // Send inventory report to chat for selected char } else if (/^report/i.test(opt)) { let char = H.getSelected(msg.selected), report = (char) ? iH.inventoryReport(char) : '', whisper = /w/i.test(args) ? `/w ${msgWho} ${report}` : ``; sendChat(chatName, `${whisper}${report}`); } } }); } } // Helpers const H = (() => { const changeSetting = (key, value, path='', msg) => { let target = `${path}`.split(/\//g).filter(v=>v).reduce((a,v) => { if (a && a[v]) return a[v]; }, state.varEnc); Object.assign(target, {[key]: value}); if (msg !== null) { msg = msg||`State setting "${path}/${key}" changed to "${value}"`; sendChat(chatName, `/w gm ${msg}`); } if (/chat/i.test(`${path}`)) H.updateChatConfig(); } const getSelected = (selection) => { if (!selection || selection.length < 1) return null; let id = selection[0]._id, tok = id ? getObj('graphic', id) : null, reps = tok ? tok.get('represents') : null; return reps ? getObj('character', reps) : null; } const updateChatConfig = () => { Object.assign(chatMsg, state.varEnc.chatConfig.rules, {template: state.varEnc.chatConfig.template}); chatName = state.varEnc.chatName; } const handleMarker = (char, encumberLevel, pageId) => { let active = pageId || Campaign().get('playerpageid') || null, toks = active ? findObjs({type:'graphic', /*_pageid: active,*/ represents: char.id}) : null, allMarkers = ['none','light','heavy','immobile'].map(tier=> { if (tier !== encumberLevel) return state.varEnc.tokenMarker[tier]; } ), newMarker = allMarkers ? state.varEnc.tokenMarker[encumberLevel] : null; if (newMarker != null) { toks.forEach(t => { let mrk = t.get('statusmarkers'), mrkArray = mrk != null ? mrk.split(/\s*,\s*/g) : null; if (mrkArray != null) { mrkArray.forEach((um,i) => { if (allMarkers.includes(um)) mrkArray[i] = null; }); if (!mrkArray.includes(newMarker)) mrkArray.push(newMarker); let newString = mrkArray.filter(v=>v).join(','); t.set('statusmarkers', newString); } }); } else log(`varEnc API: error marking tokens, tokens found: ${toks.length}, marker: ${newMarker}`); } const handlePageMove = () => { currentPage = Campaign().get('playerpageid'); let currentToks = findObjs({type: 'graphic', _pageid: currentPage}), tokIds = currentToks ? currentToks.filter(t => /-[A-Za-z0-9_-]{19}/.test(t.get('represents'))).map(t=>t.get('represents')) : [], npcAttrs = findObjs({type: 'attribute', name: 'npc'}), pcIds = npcAttrs ? npcAttrs.filter(a => a.get('current') != 1).map(a => a.get('characterid')) : []; let activeChars = pcIds.filter(id => tokIds.includes(id)); activeChars.forEach(cid => { let char = getObj('character', cid), currentEncumber = findObjs({type: 'attribute', name: 'encumberance', characterid: cid})[0], encumberLevel = currentEncumber ? currentEncumber.get('current') : null; if (encumberLevel) { encumberLevel = /immob/i.test(encumberLevel) ? 'immobile' : /heav/i.test(encumberLevel) ? 'heavy' : /enc/i.test(encumberLevel) ? 'light' : 'none'; H.handleMarker(char, encumberLevel); } }); } const getOrderedSecIds = (repSecAttrs) => { let reporderAttr = repSecAttrs.find(a => /^_reporder/i.test(a.get('name'))), repOrder = reporderAttr ? reporderAttr.get('current').split(/,/g) : [], sectionIds = []; repSecAttrs.forEach(a => { let rowId = (a.get('name').match(/_(-[A-Za-z0-9-]{19})_/)||[])[1]; if (rowId && !sectionIds.includes(rowId) && !repOrder.includes(rowId)) sectionIds.push(rowId); }); return repOrder.concat(sectionIds); } return { changeSetting, getSelected, updateChatConfig, handleMarker, handlePageMove, getOrderedSecIds } })(); // Inventory Helpers const iH = (() => { const createInventoryObject = (attrArray, idOrder) => { const getAttrVal = (suffix, id, max=false) => { let a = attrArray.find(a => a.get('name').indexOf(`${id}_${suffix}`) > -1); return a ? max ? a.get('max') : a.get('current') : null; } let output = []; idOrder.forEach(id => { let data = { name: getAttrVal('itemname', id), rowId: id, quantity: getAttrVal('itemcount', id) === null ? 1 : getAttrVal('itemcount', id) || 0, equipped: getAttrVal('equipped', id) == 0 ? false : true, weight: `${getAttrVal('itemweight', id)}`.replace(/[^\d.]/g, ''), weightless: /^\s*[A-Za-z]/.test(`${getAttrVal('itemweight', id)}`) ? true : false, }; data.totalWeight = parseInt(data.quantity) * parseFloat(data.weight); output.push(data); }); return output; } const getBagWeights = (inventoryObject) => { let bags = {unpacked: 0, weightless: {}}, currentBag = ''; inventoryObject.forEach(item => { if (/==/.test(item.name)) { let bagName = item.name.replace(/=/g, '').trim(); currentBag = bagName; if (!bags[currentBag]) bags[currentBag] = 0; } else if (item.totalWeight) { let targetBag = currentBag||'unpacked' bags[targetBag] += item.totalWeight||0; if (item.weightless) bags.weightless[targetBag] = 1; } }); return bags; } const getOtherWeights = (inventoryObject) => { let weight = {equipped: 0, unequipped: 0, weightless: {equipped: 0, unequipped: 0, total: 0}}; inventoryObject.forEach(item => { let target = item.equipped ? 'equipped' : 'unequipped'; weight[target] += parseFloat(item.totalWeight)||0; if (item.weightless) weight.weightless[target] += item.totalWeight||0; }); weight.weightless.total = weight.weightless.equipped + weight.weightless.unequipped; return weight; } const inventoryReport = (char) => { let chatTemplate = `&{template:default}{{name=${char.get('name')}'s Inventory Report}}`; let attrs = findObjs({type: 'attribute', characterid: char.id}).filter(a=>/repeating_inventory/i.test(a.get('name'))); if (attrs.length) { let invIds = H.getOrderedSecIds(attrs), inventoryData = createInventoryObject(attrs, invIds); if (inventoryData.length) { let weightTotal = findObjs({type:'attribute', characterid: char.id, name:'weighttotal'})[0], str = findObjs({type:'attribute', characterid: char.id, name:'strength'})[0], coins = findObjs({type: 'attribute', characterid: char.id}).filter(a => /^(cp|sp|ep|gp|pp)$/i.test(a.get('name'))); let coinWeight = coins.reduce((a,v) => a += (parseInt(v.get('current'))||0)*0.02, 0); let report = { total: weightTotal ? parseFloat(weightTotal.get('current')) : null, str: str ? parseInt(str.get('current')) : null, summary: getOtherWeights(inventoryData), bags: getBagWeights(inventoryData), cash: coinWeight.toFixed(2), }; let percentage = (report.total && report.str) ? (report.total/(report.str*15))*100 : null, colour = percentage >= 100 ? 'red' : percentage > 66 ? 'orange' : percentage > 33 ? '#e1da00' : 'green', progressBar = percentage ? `<div style="position:relative; width:100%; height: 1.5em;border: 1px solid black;" class="prog-container"> <div style="position:absolute; top:0%; width:${percentage}%; background-color:${colour}; height: 1.5em;" class="prog-bar"></div> <div style="position:absolute; top:0%; width:33%; right:33%; height:100%;border-left:2px solid black;border-right: 2px solid black;" class="prog-markers"></div> </div>` : '', chatTotal = `{{Total weight=${report.total} / ${report.str*15}pds.}}{{Level=${progressBar}}}`, chatEquipped = report.summary.equipped ? `{{Equipped=${report.summary.equipped}pds.${report.summary.weightless.equipped > 0 ? '*' : ''}}}{{Unequipped=${report.summary.unequipped}pds.${report.summary.weightless.unequipped > 0 ? '*' : ''}}}` : '', chatCoins = report.cash ? `{{Coins=${report.cash}pds.}}` : '', chatWeightless = report.summary.weightless.total ? `{{Weightless=${report.summary.weightless.total}pds.}}` : '', chatBagTitle = `{{--&nbsp;&nbsp;Containers=**--**}}`, chatBags = Object.keys(report.bags).length > 1 ? Object.entries(report.bags).map(bag => { if (!/weightless/i.test(bag[0])) return `{{${bag[0]}=${bag[1]}pds.${report.bags.weightless[bag[0]] ? '*' : ''}}}`; }) : '', chatWeightlessHint = (Object.keys(report.bags.weightless).length || report.summary.weightless.total > 0) ? `{{=* denotes a total which includes weightless items}}` : ''; return `${chatTemplate}${chatTotal}${chatEquipped}${chatCoins}${chatWeightless}${chatBags ? chatBagTitle : ''}${chatBags}${chatWeightlessHint}`; } } } return { inventoryReport } })(); const init = (() => { //eslint-disable-line no-unused-vars on('ready', () => { checkInstall(); on('chat:message', (msg) => handleInput(msg)); on('change:attribute', (ev) => handleAttrChange(ev)); on('change:campaign', (ev) => { if (ev.get('playerpageid') !== currentPage && state.varEnc.tokenMarker.watchPage) H.handlePageMove(); }); }); })(); })();