Roll20 uses cookies to improve your experience on our site. Cookies enable you to enjoy certain features, social sharing functionality, and tailor message and display ads to your interests on our site and others. They also help us understand how our site is being used. By continuing to use our site, you consent to our use of cookies. Update your cookie preferences .
×

Looking for a Pathfinder 2e universal save macro

I just made the change from in-person to Roll20 (for obvious reasons), and I'm trying to make a macro.  I've spent the last two weeks trying to cram this system into my head, along with the 600 page rulebook and the adventure, and I'm about out of brain power.  I need to devote at least a few days to getting a handle on the macro system, which seems complex and comparable to learning a programming language - something I haven't done in 30 years.  The problem is that I'm out of time.  The game kicks off the day after tomorrow, and tomorrow's busy. For now, I'd really just like either one macro or three, and I'm hoping someone can help me out.  Specifically, I'd like to select a token and GM-whisper that token's saves.  This could be either via the menu pop-up option or simply be three macros, one for each save.  I'll eventually learn to do this myself, but for now I haven't the faintest idea of where to even start.
As a pro user you can use API scripts in your games. The script Universal Chat Menus would be really useful for what you want. It lets you format custom chat menus based on the sheet you're using. Here's one that I use for Saves and Ability checks: You select token and click the macro which sends this to chat. It creates a button for each item you specify in the menu using the values from the selected token. The original script is in the link, but I use this modified version that makes the buttons automatically whisper the rolls if you are the GM, while giving players buttons that only whisper if they set their sheet to whisper. var universalChatMenu = universalChatMenu || (function () { 'use strict'; const version = '0.3.8', scriptName = 'Universal Chat Menu', lastUpdate = 1559680306108, //defaultTitle = 'Actions', defaultHeader = (who, actions = 'Actions') => `${who} ${actions} Menu`, BUTTONSYMBOL = '!', MENUPLACE = 'CHATMENU', SEPARATOR = ' ', checkInstall = () => { log('-=> ' + scriptName + ' v' + version + ' <=- [' + (new Date(lastUpdate)) + ']'); }, ch = function (c) { var entities = { '<': 'lt', '>': 'gt', "'": '#39', '@': '#64', '{': '#123', '|': '#124', '}': '#125', '[': '#91', ']': '#93', '"': 'quot', '-': 'mdash', ' ': 'nbsp' }; if (_.has(entities, c)) { return ('&' + entities[c] + ';'); } return ''; }, // build attribute name for a repeating section repname = (section, id, name) => `repeating_${section}_${id}_${name}`, // get an array of attributes on this character - handy to shorten code elsewhere. getAttributes = (cid) => { return findObjs({ _type: 'attribute', _characterid: cid }); }, // find a list of all the row ids in a repeating section. getRepeatingIDs = (cid, section, attribute) => { const repeating = getAttributes(cid) .filter((obj) => { return obj.get('name').startsWith(`repeating_${section}`) && obj.get('name').endsWith(attribute); }) .map(obj => obj.get('name').replace(`repeating_${section}_`, '').replace('_' + attribute, '').trim()); return repeating; }, checkFilter = (check, cid, prefix = '') => { // prefix is for repeating stats, supply 'repeating_${section}_${id}_' as the prefix //log('=== FILTER ===='); //log(`check: ${check} prefix: ${prefix}`); let tests = check.toString().split(','); let rules = []; let pass = 1; // if tests are passed, return 1, otherwise 0; tests.forEach(test => { // need to check it has the three results, if only one, use rule.match = 0, and rule.type = '>' let s = test.split(/[!<>=]/); let rule = {}; rule.stat = s[0]; rule.match = s.length > 1 ? s[1] : ''; rule.type = s.length > 1 ? test.replace(s[0], '').replace(s[1], '') : '#'; // # is used for no filter rules.push(rule); // rules is an array of objects. don't need a key since will just be looping through it. }); //log(`rules: ${JSON.stringify(rules)}`); // now have a set of rules can use to test the filters // will return true or false for (let rule of rules) { let value = getAttrByName(cid, `${prefix}${rule.stat}`) || ''; // if cell is empty, it is set to null switch (rule.type) { case '#': //no filter supplied, just include if attribute exists pass = (0 === +value || (typeof value === 'string' && value.trim() === '')) ? false : true; break; case '=': // if rule.match exists in the target cell; if its a number treat as perfect match. Problem: null ||0 turns empty cells into 0. if(isNaN(value)) { pass = value.toString().toLowerCase().includes(rule.match.toString().toLowerCase()); } else { pass = (+value || 0) === (+rule.match || 0); } break; case '>': pass = (+value || 0) >= (+rule.match || Infinity); break; case '<': pass = (parseInt(value, 10) || 0) < (parseInt(rule.match, 10) || 0); break; case '!': if(isNaN(value)) { pass = !value.toString().toLowerCase().includes(rule.match.toString().toLowerCase()); } else { pass = (+value || 0) !== (+rule.match || 0); } break; } if (!pass) break; } // at this point, pass will be 0 or 1, true or false; return pass; }, buildButtons = (cid, who, args, pid) => { let parameters = {}; let title = ''; parameters[title] = []; let settings_allowed = ['title', 'footer', 'separator']; for (let arg of args) { let settings = arg.split(':'); let setting = settings[0].toLowerCase(); let setting_details = settings.length > 0 ? settings[1] : ''; if (settings_allowed.includes(setting)) { // this is a config setting; only one supported right now is title // if new title, create an empty array in parameters to hold the following attributes. switch (setting) { case 'title': title = setting_details; // dont need defaulttitle here any more //|| defaultTitle; if (!parameters.hasOwnProperty(title)) parameters[title] = []; break; case 'footer': case 'separator': parameters[setting] = setting_details; break; } } else if (arg.toLowerCase().startsWith('repeating_')) { // this is a repeating section, need to split on "|" const repeatingAttribute = arg.split('|'); if (repeatingAttribute.length < 3) { // not valid, skip it parameters['error'] = 'Error in Repeating Attributue: ' + arg; break; } const section = repeatingAttribute[0].replace('repeating_', '').trim(); //repeating section name const display = repeatingAttribute[1].trim() || 'N/A'; //display name let button = repeatingAttribute[2].trim() || 'N/A'; let buttonAttr = getButtonType(button); if (buttonAttr) button = button.split(BUTTONSYMBOL)[0].trim(); if (display === 'N/A' || button === 'N/A') { // this will likely never trigger, it just wont print that section. parameters['error'] = 'Error in Repeating Attributue: ' + arg; break; } let repeating = getRepeatingIDs(cid, section, display); let checkAttribute = repeatingAttribute.length > 3 ? repeatingAttribute.slice(3) : []; // build chat menu buttons repeating.forEach(id => { let showButton = 1; if (checkAttribute.length > 0) { showButton = checkFilter(checkAttribute,cid, `repeating_${section}_${id}_`); } if (showButton > 0) { parameters[title].push( getButtonCode(getAttrByName(cid, repname(section, id, display)), who, repname(section, id, button), pid, buttonAttr)); } }); } else { // this is a non-repeating set of attributes, split on | and then , let singleAttributes = arg.split('|'); singleAttributes.forEach(attr => { let attr_array = attr.split(','); let label = attr_array[0]; let button = attr_array[1] || label; let check = attr_array.length > 2 ? attr_array.slice(2) : []; let buttonAttr = getButtonType(button); if (buttonAttr) button = button.split(BUTTONSYMBOL)[0].trim(); // if check doesnt exist, or if it does exist but has a value of zero, print it. Otherwise don';'t let pass = 1; if(check.length > 0) { //log(`${label}: ${check}`); pass = checkFilter(check,cid,''); } if (pass) { parameters[title].push( getButtonCode(label, who, button, pid, buttonAttr)); } }); } } return parameters; }, getButtonType = (button) => button.split(BUTTONSYMBOL).length > 1, getButtonCode = (label, who, button, cid, attribute = false) => { let code = `[${label}](`; if (attribute) { code += `!
/w gm &${ch('{')}template:default${ch('}')}${ch('{')}${ch('{')}name=${ch('@')}${ch('{')}selected|character_name${ch('}')} ${label}${ch('}')}${ch('}')}${ch('{')}${ch('{')}=${ch('@')}${ch('{')}selected${ch('|')}${button}${ch('}')}${ch('}')}${ch('}')})`; } else if (playerIsGM(cid)){ code += ``/w gm %{${who}|${button}})`; } else { code += ``%{${who}|${button}})`; } return code; }, extractFrom = (buttons, field, fallback) => { let found = fallback; if (buttons.hasOwnProperty(field)) { found = buttons[field]; delete buttons[field]; } return found; }, handleInput = (msg) => { // copy the body of your on(chat:message) event to this function if ('api' === msg.type && msg.content.toLowerCase().startsWith('!chatmenu ')) { let args = msg.content.split(/\s+--/); // get character and heading let parameters = args[0].split(/\s+/); const cid = parameters[1] || ''; //character id if (cid === '') { sendChat(scriptName, 'No recognised character.'); return; } const who = getAttrByName(cid, 'character_name'); if (!who) { sendChat(scriptName, 'No recognised character.'); return; } const sender = 'character|' + cid; const caller = (getObj('player', msg.playerid) || { get: () => 'API' }).get('_displayname'); let header = parameters.slice(2).join(' ') || defaultHeader(who); // if this starts with {template: it is a rolltemplate // and if it contains CHATMENU, the menu buttons are placed there. Otherwise they are placed at the end. // get buttons let combineSections = false; // if false, defaultTemplate sections; if true, combine all sections into one args.shift(); let buttons = buildButtons(cid, who, args, msg.playerid); // buttons will be an object, each item key is a section title, and each item value is an array of buttons in that section // build chat menu buttons if (buttons.hasOwnProperty('error')) { sendChat(scriptName, buttons['error'].toString()); return; } let headerPrint = `&{template:default}{{name=${header}}}`; if (header.includes('template:')) { headerPrint = `&${header}`; } headerPrint = headerPrint.replace(/(\[)/g, '[[').replace(/(\])/g, ']]'); // make sure no inline rolls present let footer = extractFrom(buttons, 'footer', ''); let separator = extractFrom(buttons, 'separator', SEPARATOR); let output = ''; let sections = Object.keys(buttons); if (headerPrint.includes(MENUPLACE)) combineSections = true; for (let section of sections) { if (buttons[section].length > 0) { output += (combineSections ? `**${section.toUpperCase()}**\n` : `{{**${section}**=`); output += buttons[section].join(separator); output += (combineSections ? `\n` : `}}`); } } let print = combineSections ? headerPrint.replace(MENUPLACE, output) : headerPrint + output; print += footer ? `\n/w ${caller} ${footer}` : ''; sendChat(sender, `/w ${caller} ${print}`); } }, registerEventHandlers = () => { on('chat:message', handleInput); }; return { CheckInstall: checkInstall, RegisterEventHandlers: registerEventHandlers }; }()); on('ready', () => { 'use strict'; universalChatMenu.CheckInstall(); universalChatMenu.RegisterEventHandlers(); }); Copy the code and then go to your API Scripts page. Click on New Script, paste the code in the box, then save it. Here's the macro I use for the Saves and Abilities menu: !chatmenu @{selected|character_id} {template:rolls} {{charactername=@{selected|character_name}}} {{header=Saves and Abilities}} {{desc=CHATMENU}} --title:Saving Throws --Fort (@{selected|saving_throws_fortitude}) **@{selected|saving_throws_fortitude_proficiency_display}**,FORT|Ref (@{selected|saving_throws_reflex}) **@{selected|saving_throws_reflex_proficiency_display}**,REF|Will (@{selected|saving_throws_will}) **@{selected|saving_throws_will_proficiency_display}**,WILL --title:Ability Checks --STR @{selected|strength} (@{selected|strength_modifier}),STR|DEX @{selected|dexterity} (@{selected|dexterity_modifier}),DEX|CON @{selected|constitution} (@{selected|constitution_modifier}),CON|INT @{selected|intelligence} (@{selected|intelligence_modifier}),INT|WIS @{selected|wisdom} (@{selected|wisdom_modifier}),WIS|CHA @{selected|charisma} (@{selected|charisma_modifier}),CHA --separator: | : You can set it up as a token action on each PC or as a macro with the Show as Token Action box ticked. It technically works for NPCs too, but I have a separate NPC macro.
Thank you, I will attempt that after I've had some sleep. ;)  Roll20 is powerful, but there certainly is a learning curve!
Ok, sat down this morning and set it up.  It seems to work perfectly, at least once I figured out that the names on token nameplates and character sheets seem to need to match. It's a fantastic solution to my problem.  Thank you!
Your welcome! That's odd about the names though, all of my players' tokens have nicknames that don't match the full character name. I've got documentation for all the menu macros I use Here
I had it set up with the full name on the character sheet and the nickname on the token (in the 'show nameplate') slot, and it gave an error (well, a nonsense response) every time until I changed the two to match. Huh, had a thought:  I had the full name formatted with quotation marks (Robert Smith "Rob").  I wonder if that could be affecting it?
Oh, yeah I almost forgot about that. I ran into that issue last year, names with quotation marks or parentheses will cause issues, but you could change that name to Robert Smith 'Rob'.