Major update - this kid grew up and moved out of home. New version is over here
Minor update - script no longer responds to API messages (like unWhisper). This can be changed with --ignoreAPI
Ok, so I had some procrastinating to do today.
Here's a test script - see if it does what you want.
[Test Script] - autoButtons v0.1.3
A script that sends buttons (currently GM only) to apply Crit damage, Full damage, Half damage, or Healing to the selected token. Each damage roll picked up by the script will have its own buttons, and they only work for that attack. The script attempts to grab a name from the roll template, anything it finds in {{*name=<here>}}, so you could get the template name, creature or attack depending on macro order. It should be enough to ensure the buttons are attached to the right attack though.
It should pick up damage1, damage2, crit1, crit2, upcast, upcastCrit, globalDamage, globalCrit. Buttons are provided for:
- Crit: Apply all damage
- Full: Apply all non-crit damage
- Half: Apply half of non-crit damage, rounded down
- Heal: Apply all non-crit damage as healing
IMPORTANT:
- This requires token-mod, can't be bothered rewriting token functions that Aaron's already done to perfection... but hey, who the hell is playing without tokenMod?
- Only works for the Roll20 5e sheet in its current form
- The sheet silently rolls crit damage for all kinds of stuff like normal hits, spells with a save etc. I seriously can't be bothered trying to filter the stupid rolls out of every possible template combination, so if the attack shouldn't be able do crit, *don't click the crit button* :)
- CLI is basic to non-existent. A couple of things work:
!autobut --reset -reload the 5e presets if it's not picking up templates or whatever... shouldn't need to do this currently since you can't change the preset
!autobut --bar X -set the token bar to target with tokenMod. 1, 2 or 3 are valid. Default is 1
!autobut --ignoreAPI [ on | 1 | true | off | 0 | false ] -whether to ignore damage rolltemplates if they're posted by API. Default is on
There are almost certainly bugs.
Click for giffage:

The test script:
/* globals log on playerIsGM, state, sendChat */
const autoButtons = (() => { //eslint-disable-line no-unused-vars
const scriptName = 'autoButtons';
const config = {
version: {
M: 0,
m: 1,
p: 3,
get: function() {
return `${this.M}.${this.m}.${this.p}`
},
getFloat: function() {
return parseFloat(`${this.M}.${this.m}${this.p}`)
}
},
settings: {
sheet: 'dnd5e_r20',
templates: {},
buttons: [],
gmOnly: true,
hpBar: 1,
ignoreAPI: 1,
},
fetchFromState: function() {
Object.assign(this.settings, state[scriptName].settings);
},
saveToState: function() {
Object.assign(state[scriptName].settings, this.settings);
},
// Provide path relative to {config.settings}, e.g. changeSetting('sheet', ['mySheet']);
changeSetting: function(pathString, newValue, confirmMessage) {
if (typeof(pathString) !== 'string' || newValue === undefined) return;
let keyName = (pathString.match(/[^/]+$/) || [])[0],
path = /.+\/.+/.test(pathString) ? pathString.match(/(.+)\/[^/]+$/)[1] : '';
let configPath = path ? h.getObjectPath(path) : config.settings;
if (configPath && keyName) {
configPath[keyName] = newValue;
if (confirmMessage) sendChat(scriptName, `/w gm ${confirmMessage}`);
this.saveToState();
return 1;
} else {
log(`${scriptName}: bad config path ${pathString}`);
return 0;
}
},
getSetting: function(pathString) {
if (typeof(pathString) !== 'string') return null;
let configValue = h.getObjectPath(pathString);
return configValue;
},
loadPreset: function() {
if (Object.keys(preset).includes(this.settings.sheet)) {
this.settings.templates = preset[this.settings.sheet].templates;
this.settings.buttons = preset[this.settings.sheet].buttons;
h.toChat(`Loaded preset: ${config.getSetting('sheet')}`);
this.saveToState();
return 1;
} else return 0;
}
};
const preset = {
dnd5e_r20: {
templates: {
names: ['atkdmg', 'dmg', 'npcfullatk', 'npcdmg'],
damageFields: ['dmg1', 'dmg2', 'globaldamage'],
critFields: ['crit1', 'crit2', 'globaldamagecrit'],
upcastDamage: ['hldmg'],
upcastCrit: ['hldmgcrit'],
},
buttons: ['damageCrit', 'damageFull', 'damageHalf', 'healingFull'],
}
}
const styles = {
error: `color: red; font-weight: bold`,
outer: `position: relative; vertical-align: middle; font-family: pictos; display: block; background: #f4e6b6; border: 1px solid black; height: 34px; line-height: 34px; text-align: right;`,
rollName: `font-family: arial; font-size: 1.1rem; color: black; font-style:italic; float:left; position:absolute; overflow: hidden; display: block;text-align: left; max-width: 80px; line-height: 1rem; top: 3px; left: 3px;`,
buttonContainer: `display: inline-block; text-align: center; vertical-align: middle; line-height: 26px; margin: auto 5px auto 5px; height: 26px; width: 26px; border: #8c6700 1px solid; box-shadow: 0px 0px 3px #805200; border-radius: 5px; background-color: whitesmoke;`,
buttonShared: `background-color: transparent; border: none; padding: 0px; width: 100%; height: 100%; overflow: hidden; white-space: nowrap;`,
crit: `color: red; font-size: 1.5rem`,
full: `color: darkred; font-size: 2.1rem`,
half: `color: black; font-family: pictos three; font-size: 2rem; padding-top:1px;`,
healFull: `color: green; font-size: 2rem`
}
const buttons = {
damageCrit: {
label: `Crit (%)`,
style: styles.crit,
math: (d, c) => -(1 * c),
content: 'kk',
},
damageFull: {
label: `Full (%)`,
style: styles.full,
math: (d) => -(1 * d),
content: 'k',
},
damageHalf: {
label: `Half (%)`,
style: styles.half,
math: (d) => -(Math.floor(0.5 * d)),
content: 'b',
},
healingFull: {
label: `Heal (%)`,
style: styles.healFull,
math: (d) => `+${(1 * d)}`,
content: '&',
},
create: function(buttonName, damage, crit) {
let btn = this[buttonName],
bar = config.getSetting('hpBar');
if (!btn || !bar > 0) return log(`${scriptName}: error creating button ${buttonName}`);
let modifier = btn.math(damage, crit);
let label = btn.label.replace(/%/, `${Math.abs(modifier)}HP`);
return `<div style="${styles.buttonContainer}" title="${label}"><a href="!token-mod --set bar${bar}_value|${modifier}" style="${styles.buttonShared}${btn.style}">${btn.content}</a></div>`;
},
getNames: function() {
return Object.entries(this).map(e => {
if (typeof(e[1]) !== 'function') return e[0]
}).filter(v => v);
}
}
const rx = {
on: /\b(1|true|on)\b/i,
off: /\b(0|false|off)\b/i
};
const initScript = () => {
setTimeout(() => {
if (!/object/i.test(typeof(['token-mod']))) return sendChat(scriptName, `/w gm <div style="${styles.error}">tokenMod not found - this script requires tokenMod to function! Aborting init...</div>`), 500
});
if (!state[scriptName] || !state[scriptName].version) {
state[scriptName] = {
version: config.version.getFloat(),
settings: config.settings,
}
} else if (state[scriptName].version < config.version.getFloat()) {
let v = state[scriptName].version;
if (v < 0.13) {
Object.assign(state[scriptName].settings, {
ignoreAPI: 1
});
}
state[scriptName].version = config.version.getFloat();
log(`====> Updated ${scriptName} to v${config.version.get()}`);
log(state[scriptName]);
}
config.fetchFromState();
if (
(!config.getSetting('templates/names') || !config.getSetting('templates/names').length) ||
(!config.getSetting('buttons') || !config.getSetting('buttons').length)) {
config.loadPreset();
h.toChat(`Error fetching config - loaded preset defaults`);
}
on('chat:message', handleInput);
log(`- Initialised ${scriptName} - v${config.version.get()} -`);
}
const sendButtons = (damage, crit, msg) => {
let gmo = config.getSetting('gmOnly') ? true : false;
let buttonHtml = '',
activeButtons = config.getSetting(`buttons`),
name = h.findName(msg.content);
name = name || `Apply:`;
activeButtons.forEach(btn => buttonHtml += buttons.create(btn, damage, crit));
const buttonTemplate = `<div style="${styles.outer}"><div style="${styles.rollName}">${name}</div>${buttonHtml}</div>`;
h.toChat(`${buttonTemplate}`, gmo);
}
const handleDamageRoll = (msg) => {
let dmgFields = config.getSetting('templates/damageFields') || [],
critFields = config.getSetting('templates/critFields') || [];
// log(`Found fields: ${dmgFields.concat(critFields).join(', ')}`);
let dmgTotal = h.processFields(dmgFields, msg),
critTotal = h.processFields(critFields, msg);
let isSpell = h.isAttackSpell(msg.content);
if (isSpell) {
let upcastDmg = config.getSetting('templates/upcastDamage') || [],
upcastCrit = config.getSetting('templates/upcastCrit') || [];
dmgTotal += h.processFields(upcastDmg, msg);
critTotal += h.processFields(upcastCrit, msg);
}
critTotal += dmgTotal;
sendButtons(dmgTotal, critTotal, msg);
}
const handleInput = (msg) => {
if (msg.type === 'api' && playerIsGM(msg.playerid) && /^!(autobut)/i.test(msg.content)) {
// h.toChat(`handle CLI`);
let cmdLine = (msg.content.match(/^![^\s]+\s+(.+)/i) || [])[1],
params = cmdLine ? cmdLine.split(/\s*--\s*/g) : [];
params.shift();
params = params.length ? params : [''];
params.forEach(param => {
let cmd = (param.match(/^([^\s]+)/) || [])[1],
args = (param.match(/\s+(.+)/) || [])[1],
oldVal,
newVal,
changed = [];
if (!cmd) return;
switch (cmd) {
case 'reset':
if (config.getSetting('sheet')) config.loadPreset();
else h.toChat(`No preset found!`);
break;
case 'bar':
newVal = parseInt(`${args}`.replace(/\D/g, ''));
if (newVal > 0 && newVal < 4) {
if (config.changeSetting('hpBar', newVal)) changed.push(`hpBar: ${newVal}`);
}
break;
case 'setPreset':
h.toChat(`Not yet implemented: ${args}`);
newVal = args.trim();
if (Object.keys(preset).includes(newVal)) {
if (config.changeSetting('sheet', newVal)) {
config.loadPreset();
changed.push(`Preset changed: ${newVal}`);
} else log(`${scriptName}: error changing preset to "${newVal}"`);
}
break;
case 'addTemplate':
h.toChat(`Not yet implemented: ${args}`);
break;
case 'removeTemplate':
h.toChat(`Not yet implemented: ${args}`);
break;
case 'addButton':
newVal = args.trim();
if (buttons.getNames().includes(newVal)) {
oldVal = config.getSetting('buttons');
if (!oldVal.includes(newVal)) {
oldVal.push(newVal);
config.changeSetting(oldVal)
changed.push(`Added button "${newVal}" ==> [ ${oldVal.join(' | ')} ]`);
} else log(`${scriptName}: unrecognised button name`);
}
break;
case 'removeButton':
h.toChat(`Not yet implemented: ${args}`);
break;
case 'ignoreAPI':
newVal = rx.off.test(args) ? 0 : rx.on.test(args) ? 1 : null;
if (newVal !== null && config.changeSetting('ignoreAPI', newVal)) {
changed.push(`ignoreAPI: ${newVal ? 'on' : 'off'}`);
} else log(`${scriptName}: error setting ignoreAPI to ${newVal}`);
break;
case 'settings':
h.toChat(`Not yet implemented: ${args}`);
break;
default:
showHelp();
}
if (changed.length) h.toChat(`Settings changed: ${changed.join('<br>')}`);
});
} else if (msg.rolltemplate && config.getSetting('templates/names').includes(msg.rolltemplate)) {
let ignoreAPI = config.getSetting('ignoreAPI');
if (ignoreAPI && /^api$/i.test(msg.playerid)) return;
handleDamageRoll(msg);
}
}
const showHelp = () => h.toChat(`Haaaaalp!`);
const h = (() => {
const processFields = (fieldArray, msg) => {
let rolls = msg.inlinerolls;
return fieldArray.reduce((m, v) => {
// log(`Processing ${v}...`)
let rxIndex = new RegExp(`{${v}=\\$\\[\\[\\d+`, 'g');
let indexResult = msg.content.match(rxIndex);
if (indexResult) {
let index = indexResult.pop().match(/\d+$/)[0];
// log(`Found index: ${index}`);
// log(`Vanilla: ${msg.inlinerolls[index].results.total}`);
let total = rolls[index].results.total;
// log(`TOTAL: ${total}`);
if (total > 0) return m + total;
}
return m;
}, 0);
}
const isAttackSpell = (msgContent) => {
const rxSpell = /{spelllevel=(cantrip|\d+)/;
return rxSpell.test(msgContent) ? 1 : 0;
}
const findName = (msgContent) => {
const rxName = /name=([^}]+)}/i;
let name = msgContent.match(rxName);
return name ? name[1] : null;
}
const getObjectPath = (pathString, baseObject = config.settings, createPath = true) => {
let parts = pathString.split(/\/+/g);
let objRef = parts.reduce((m, v) => {
if (!m) return;
if (!m[v]) {
if (createPath) m[v] = {};
else return null;
}
return m[v];
}, baseObject)
return objRef;
}
const toChat = (msg, whisper = true) => {
let prefix = whisper ? `/w gm ` : '';
sendChat(scriptName, `${prefix}${msg}`);
}
return {
processFields,
isAttackSpell,
findName,
getObjectPath,
toChat
}
})();
on('ready', () => initScript());
})();