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 .
×
Create a free account

[Script Help] Spellbook buttons

First off, I've been using GiGs awesome Universal Chat Menus script for my Pathfinder 2 game and it has been great. My only issue is, cause I'm picky, that I hate the default spell and attack rolls from the sheet. They look clunky and have a lot of extraneous information. So I have a character that is just for attack and spell macros that I've created Powercards that automatically do saving throws, apply damage, and generally just automate a lot for me. So I wanted to see if I could slap something similar to access those macros instead.  Full disclosure: I have been learning Javascript for about 2 weeks now and am up to lesson 10 on Codecademy so this code is very blunt and is on the "See Spot Run" level or lower. :) What I want this to do is to take a selected token and output chat buttons for spells. These buttons will activate the associated macro on the designated sheet if it exists or default back to the regular ol' spell roll from the character if it does not. I've got it mostly working but I have some questions.  on("chat:message",function(msg){ if(msg.type=="api" && msg.content.indexOf("!createSpellbook")==0) { var selected = msg.selected; if (selected===undefined) { sendChat("API","Please select a character."); return; }; var token = getObj("graphic",selected[0]._id); var character = getObj("character",token.get("represents")); var charGM = getObj("character","-MEAniRG_nMlLDMz8HIV"); //Focus Spells var focusOutput = ""; for (focusRow=0;focusRow<5;focusRow++) { var focusList = getAttrByName(character.id,"repeating_spellfocus_$" + focusRow +"_name"); if (focusList.length==0) { break; }; focusOutput+=`[${focusList}](~GMSpells|${focusList})`; }; //Cantrips var cantripOutput = ""; for (cantripRow=0;cantripRow<10;cantripRow++) { var cantripList = getAttrByName(character.id,"repeating_cantrip_$" + cantripRow +"_name"); if (cantripList.length==0) {    break; };      var isMacro = findObjs({_type: "ability",_characterid: charGM.id,name: cantripList}); if (isMacro.length!=0) {    cantripOutput+=`[${cantripList}](~GMSpells|${cantripList})`; } else {    cantripOutput+="[" + cantripList + "](~" + character.get("name") + "|repeating_cantrip_$" + cantripRow + "_spellroll)"; } };        var outputFocus = focusOutput; var outputCantrip = cantripOutput; sendChat("Create Spellbook",outputFocus + outputCantrip); }; }); 1) My first issue is row sorting. What I am doing, or try to, here is taking a look at each row and testing if the length of the name of the row is 0 or empty. If it is empty, then I break out of the loop. If there is a name there, it processes the rest of the loop.  The first time I call the script it doesn't seem to output in the same order that the rows are:  If I reorder the row sorting and call the script again, the button order doesn't change, but clicking the spell casts the correct row based on the row number.  So I'm wondering if how I'm iterating the loop isn't taking something into account. 2) My second question is about the output of the chat button themselves. Right now if the macro exists on my macro mule the button will output into cantripOutput as [Detect Magic](~GMSpells|Detect Magic). Which isn't quite what I need as my macro can't have spaces in it.  So I need to take out the whitespace in the second ${cantripList} in cantripOutput+=`[${cantripList}](~GMSpells|${cantripList})`; but can't quite get it to work. I tried ${cantripList.ReplaceAll(\s+,'')} but it doesn't seem to be working. I'm not quite sure of the formatting here. 
1597205706

Edited 1597257994
timmaugh
Pro
API Scripter
Hey, Erik! Always glad to see someone picking up the scripting playbook and giving it a run! This is definitely not 'see spot run' stuff... it looks like you're doing great! Let me answer your second question first, since it's a little easier. If you want to remove all of the whitespace from a string, you have to use a regex with the replace function. Single forward slashes denote a regex: /this is a regex/ ...or you can use the RegExp() function let demo = new RegEx('this is a regex'); Regex is a huge topic, and sites like regex101.com are great testbeds to figure out the mechanics of what you want to do. The answer to your question is that ReplaceAll() isn't a function, and your regex isn't denoted by /slashes/. Try this: cantripOutput+=`[${cantripList}](~GMSpells|${cantripList.replace(/\s+/g, '')})`; The 'g' following the regex is the 'global' flag; that's what let's this regex operate across the entire string. In answer to your first question, I would suggest a handful of array functions as worth your time to learn: map() reduce() filter() forEach() sort() They are powerful (if a bit esoteric to learn at the beginning), and they can connect one to the next to make for some tight code. They're especially helpful on Roll20 where functions like findObjs() returns an array of objects. If you can get all of the objects that fit your basic criteria, you can massage the array from there into the shape that you want. I'm just guessing that 5 and 10 are arbitrary limits to your spells and cantrips. This is what it might look like to not have to worry about those limits: //Focus Spells let focusOutput = findObjs({ type: 'attribute', characterid: character.id})     .filter(s => /^repeating_spellfocus_\$/.test(s.get('name') && s.get('current').length)     .map(s => `[${s.get('current')}](~GMSpells|${s.get('current')})`)     .join(''); That's air-coded without knowledge of your sheet, but the rough idea should be right. I would point out using 'let' (instead of 'var'), and the fact that the filter() function is testing both for the name of the attribute AND that the value of it is not empty. Since the non-matching attributes are removed at that point, we can continue with the map() operation to transform each element of the array (which we're calling 's') into the individual button trigger you wanted to build at the end. We join the output, returning a string (a bunch of buttons), and though we're now four operations in, we've only just ended our line. (reduce() is like map() in that it operates over each element of the array, but it reduces the whole array into a single value by passing the result of the previous operation on the array into the next as the seed value; this can be a valuable and necessary alternative, but in your case we can get by with a simple map().join().)
This looks both so much simpler and yet kinda brain melting so I want to make sure I understand what is happening.  The first line basically just grabs every attribute from the character of characterid with the ID of character.id.  Then we filter those objects (attributes) into an array called s that contains all of the entries that start with "repeating_spellfocus_$" and tests (.test) if there is a name present in the first half of line 2. The second condition gets the number of entries of the array s where 'current' will become the 'name' that we tested. Then we map each current entry into the chat menu button form so we now have a list of all the entries in the repeating rows as an array of buttons that we then join together in line 4.  So the focusOutput variable is a string that basically looks like "[name1](~macroSheet|name1) [name2](~macroSheet|name2) [name3]~macroSheet|name3)" assuming there are 3 rows under focus spells.   So then I, of course, have a couple clarifying questions.  What is the ^ for in the filter function in the regex? Can I still use the .replace(/\s+/g, '') in line 3 for the second name to remove whitespace like ${s.get('current').replace(/\s+/g, '')}? Can I still test the 'current' entry against my macro sheet to see if the macro exists and if it doesn't to put the spellroll in where the call is repeating_spellfocus_$'current'_'current'? With the way this is, can I then output to chat using another script like Powercards to format it like sendChat("API","!power {{ --name|Focus Spells --List|" + focusOutput)? Thanks for all your help! It is greatly appreciated. 
1597257934
timmaugh
Pro
API Scripter
You nearly have it. All of the functions I gave you (filter, map, reduce, sort, forEach) all take what we call a callback function. You could write it with typical function invocation: myarray.map(function(a){//...}); Or with fat arrow syntax (what I used). myarray.map(a => {//...}); So the 'a' in the above line (or the 's' from the filter function from your question) is an argument passed into the filter callback. A general rule is if you see one argument passed into the callback, you're passing the element, itself... which in the case of these functions is each element in the array, one at a time. What's happening is: filterObjs() returns an array (just like you said) ...since it is an array, we can do other array operations on it, like filter() filter() tests each element (s) to see if the regex test is true     (you can see how we call the s.get('name') function... we can do that because each element     in the array returned from filterObjs() is an object with a get() method);     it also tests the length property of the 'current' value (since 0 is a falsey value, this requires that there be a non-zero length value) ...since filter() returns an array, we can do other array operations on it, like map() map() runs your callback function for every element in your array;     again, we're using the s.get() method since we know we're still dealing with an object that has that method* ...since map() returns an array, we can do other array operations on it, like join() * - the callback function of map() doesn't JUST have to modify the content each array element, it can do other things, like this: myarray = myarray.map((a, i) => {     log(`A${i}: ${a}`);     someOtherArray.push(a);     return [a.get('name'), a.get('value')]; }); ...but then you have to explicitly return something at the end of the callback if you want to change the data in that element. That is, if you examine just myarray before and after the above code (not worrying about the push() to the other array), it will look the same as if you examined it before and after this code: myarray = myarray.map((a, i) => [a.get('name'), a.get('value')]); For single line operations, you don't have to use curly brackets or  return since that is the default behavior (returning what you construct in that line). The exception there is if you are returning an object you are constructing with curly braces. In order to differentiate the OBJECT curly braces from the callback function's SCOPE curly braces, you have to use all of it: myarray = myarray.map((a, i) => { return { a.get('name'), a.get('value')} }); If you DON'T need to modify the data in your original array AND you don't need to return an array, you should really use .forEach(). Now, your other questions... What is the ^ for in the filter function in the regex? In that context, it asserts a 'beginning of line' condition. In other words, I don't want to find the word 'repeating' somewhere buried in the middle of the attribute name. The name has to START with my test. The ^ character is used elsewhere in regex as a 'not-in' character class, as in: match anything NOT listed in this class. That would look like: [^\s]    <==would match any non-whitespace character Also, I just noticed an error in my regex (I shouldn't air code at 1 in the morning). =D I will go back and fix it. The error is that the '$' character as a special meaning in regex -- it asserts the END of a line/string (depending on your flags). If you want to use the character $ instead of the regex command $, you have to escape it: \$ Like I said, I will fix it in the previous post as soon as I post this one. Can I still use the .replace(/\s+/g, '') in line 3 for the second name to remove whitespace like ${s.get('current').replace(/\s+/g, '')}? You can use a regex replace like that anywhere you have a string... so if you aren't going to be always returning a string you might need to test that you aren't dealing with a number (perhaps with a filter() condition...?) prior to getting to that line. Can I still test the 'current' entry against my macro sheet to see if the macro exists and if it doesn't to put the spellroll in where the call is repeating_spellfocus_$'current'_'current'? A small side-note, here. People often conflate macros and abilities because they use the same general language and perform much the same. When you're dealing with scripting, though, they are different sorts of objects (type: 'ability' vs type: 'macro'), and they are associated with a different entity (abilities are on a character sheet, macros are associated with a playerid). That said, I'm not sure I understand your question. If you need the name of the element in the callback function, you'll have to get it the way you would normally for that sort of element... in this case a .get('name') call... but then you could use that in your comparison. If, instead, you are looking for a number (which it seems you are), then you might want to look at the second parameter of a map() callback: the index. I used it, above passing 2 variables into the callback and logging the value at that index. Lots of options, and logging is your friend, when you're working this stuff out! Post back if you have more questions!
Yeah, I should have been more clear in that last piece.  So I have a character that its only purpose is to house abilities that I use for NPCs to cast spells on players.  What I would like the script to do is to see if that ability exists on this GMSpells character and if it does, then output (for example): [Lightning Bolt](~GMSpells|LightningBolt) to use the script above. And if the ability doesn't exist to output: [Cone of Cold](~selectedCharacterName|repeating_spellfocus_$1_spellroll)  - which is like clicking the spell button on the sheet of the character who is selected when the script activates.  So I inserted the chunk that you gave me and took everything else out for now. So the following is only looking at focus spells on a character: on("chat:message",function(msg){ if(msg.type=="api" && msg.content.indexOf("!createSpellbook")==0) { var selected = msg.selected; if (selected===undefined) { sendChat("API","Please select a character."); return; }; var token = getObj("graphic",selected[0]._id); var character = getObj("character",token.get("represents")); var charGM = getObj("character","-MEAniRG_nMlLDMz8HIV"); //Focus Spells         let focusOutput = findObjs({ type: 'attribute', _characterid: character.id})             .filter(s => /^repeating_spellfocus\$/.test(s.get('name') && s.get('current').length))             .map(s => `[${s.get('current')}](~GMSpells|${s.get('current')})`)             .join(''); sendChat("Create Spellbook","Here is the stuff." + focusOutput); }; }); However, nothing seems to be in the focusOutput variable at the end. So I'm wondering how to see what the list of attributes are so that I can make sure that I am looking for the correct repeating attribute.  
1597277151
timmaugh
Pro
API Scripter
I think you're missing the underscore before the \$ in your regex. You're asking for the dollar sign to follow immediately on spellfocus, when your write up says it should follow an underscore.
1597277814

Edited 1597278660
timmaugh
Pro
API Scripter
Anytime you have an array and you're fairly certain you don't have many elements, you can log each one by dropping a .map() on it. That lets you see what is going on at that point in the transformation.         let focusOutput = findObjs({ type: 'attribute', _characterid: character.id})             .filter(s => /^repeating_spellfocus_\$/.test(s.get('name') && s.get('current').length))             .map(s => log(s.get('name')))                                        // <== NEW LINE             .map(s => `[${s.get('current')}](~GMSpells|${s.get('current')})`)             .join(''); But to the goal you're trying to accomplish... I'm not with you, yet. I know you are comparing some pool of abilities against the GM Char to see if they exist there. From where are you drawing the source of the abilities/spells? ...because if you have that well defined AND you are talking about the same ability (the same Roll20 object, I mean), it seems you should only have to compare the associated characterid property of the ability to the GM Char. If it matches, output the first format... if it doesn't, output the second. Where is your master list of spells/abilities you want to output coming from? EDIT : I think I see. The above list is the abilities on the character, right? And when you create the button, you need to know how to construct the button...? So collect the abilities from the selected character, test the GM Char to see if the ability came from there, and output the right button... except that the name might have spaces, so we need to remove those. Are there limits to how many cantrips and spells a character can have?
Sigh. Darn underscore. I added it back in and didn't get any change in behavior. Nor do I see any output in the log in the API Script window. All my players have personalized sheets that have "unique to their character" spell formatting. So basically the idea is to use the GMSpells sheet as a repository for generic spell templates for NPC spellcasters from their monster sheet. Most monsters have 4-8 Cantrips and 6-18 normal spells, maybe some innate one and maybe 1-3 focus spells. It really depends on the level of the monster.  I've just hardcoded the ID for the GMSpells sheet as the CharGM variable since that shouldn't ever change. The GMSpells character has Powercards that automatically roll saving throws for targets, automatically apply damage to tokens based on saving throws, or set conditions or other things the default sheet can't do. But on the flip side, there is really no need for a Powercard for a Detect Magic spell because it is pretty generic, so the default spell roll for that button, and other spells like it, is just fine.   So the output button I would like to point to the Ability on GMSpells if there is one, or default back to the spell roll.  Sorry if I'm being unclear. 
1597323282

Edited 1597343128
timmaugh
Pro
API Scripter
OK... we're getting there. If anything I assume is wrong, just post back and we'll get it sorted. Here's what i understand from what you just added to the conversation... An NPC can have Cantrips of 1, 2, B, F, 5. Your GM Char has templates for A, B, C, D, E, F, G, H... You want to output buttons for the NPC's Cantrips. If the name is the same as the entry on the GM Char, you want the button to point to the ability on that sheet rather than the NPC's own sheet. So, for the NPC listed, we'd get 5 buttons: [Spell 1](~selectedCharacterName|repeating_spellfocus_$1_spellroll) [Spell 2](~selectedCharacterName|repeating_spellfocus_$2_spellroll) [Spell B](~GMSpells|SpellB) [Spell F](~GMSpells|SpellF) [Spell 5](~selectedCharacterName|repeating_spellfocus_$5_spellroll) Generally right, at least for outcome? Because there's a problem in there with regard to syntax... as far as I understand it (and I just spent 15 minutes testing it), the tilde directs the parser to an ability, not a repeating attribute. But there is a workaround: [Button Label](!
@{selectedCharacterName|repeating_spellfocus_$1_spellroll}) OR [Button Label](!
@{selectedCharacterName|repeating_spellfocus_-Msfdjfif022n3419nrv_spellroll}) The good thing about this kind of workaround is that it works for both attributes (@) and abilities (%). Which can simplify our code a bit, if we want to use it. I'll write up a way to go about getting these in a moment (have to step away), but now I think I see the answer to one of your initial questions: I think your sorting was getting wonky because the number you have access to (as part of the repeating attribute) is the order of how the attribute was added to the sheet. If the repeating elements were reordered on the sheet after the fact, their ordinal index is different and your order would look wrong. Aaron had a little scriptlet to help decode one index to the other, but I don't think we'll need it, since we have access to the ID of the attribute from the API, if we do it right. More to come...
Yes, that is exactly what I am looking for. :D As for the ~, I just tried in game and typed  [Buttun Label](~@{selected|character_name}|repeating_spellfocus_$0_spellroll) into the chat and it output a button that rolled as intended. Is this maybe something in the API?
1597342606

Edited 1597346870
timmaugh
Pro
API Scripter
OK, to create the button list, here's the general process of what we need to do (this will be for either the cantrips or the spells... and obviously would have to be duplicated for the other): get the things (all of them of that type) into an array map them into the component parts of your output     test for whether the length is empty to build components that reference the GM sheet, instead map them to join each record individually, building a button out of the component parts join the entire array, giving you your buttons Let's see if we can figure that out. let focusOutput = findObjs({ type: 'attribute', characterid: character.id})     .filter(s => /^repeating_spellfocus_/.test(s.get('name'))) ...pausing here in the code; I'll reprint these lines when I get back to code, but I wanted to point out that the reason you weren't getting anything before was because when you're referencing the repeating objects at the object level, they aren't stored using the '$' at all. That's a construct for the macro/ability, as I understand it. A single entry for "Magic Missile" might look like this (the names might be wrong, but this is the way sheets are structured: repeating_spellfocus_-M1234567890_spellname     current = "Magic Missile" repeating_spellfocus_-M1234567890_damage_roll     current= "[[1d20+2]]" repeating_spellfocus_-M1234567890_anothersubattribute     current="some value" ... So if a character has 3 spells in a repeating section that has 5 sub-attributes each, your initial pull of findObjs for the repeating section "spellfocus" would bring you 15 attributes. We need to get that down to the 3 that we would reference from the sheet level. They will all share the element id (-M1234567890), but have different suffixes. We can do that 2 ways (at least). Option 1 - If you know the naming suffix You can build your regex to look for one of the field suffixes (preferably the naming suffix... so you are referring to the attribute whose value is the name of the spell/repeating element). If that sub-attribute has a suffix of "spellname", you'd use a regex like this: /^repeating_spellfocus_([^_]+?)_.spellname$/ ...That will give a cross section of the repeating spells, one entry each, where group1 of the regex is the ID we're looking for. (To see that at work, copy the above and paste it it at regex101.com... then type in the test box a sample attribute name like "repeating_spellfocus_-M1234567890_spellname". When the regex hits, you will see the matches at the right, including the capture group denoted by the parentheses.) The next step for this option would be to map the entry into a record containing this ID as well as the current value (which would be our button label, including the spaces. Here is what this option would look like: let spellrx = /^repeating_spellfocus_([^_]+?)_.spellname$/g; let focusSpells = findObjs({ type: 'attribute', characterid: character.id})     .filter(s => spellrx.test(s.get('name')))     .map(s => { return { id: s.id, lbl: s.get('current') }; })              // start creating the object of component parts That would leave you with an array of objects. Each object would have 2 properties so far: id and lbl. The next bit is a bit trickier, since we have to track down the "action" attribute -- that is, the one that executes when you click on "Magic Missile." More on that in a minute. For now, the other option, when you don't know the naming suffix. Option 2 - When you don't know the naming suffix When you don't know the naming suffix (or any suffix, for that matter), you can try this method to get your set of unique IDs. It starts the same, but in the middle we use a little spread operator and 'Set' magic to get a new array of unique values. In this case, we're using a similar regex with the same capture group, but we're not able to limit it to a particular sub-attribute to get our cross section. That's why we need to get our unique set. One more thing to understand... in regex matches, the full match is always group 0, and your capturing groups start numbering at 1. So when we .exec() a regex (or, from the string perspective, we could string.match()), we are left with a regex object where [1] would refer to our first capture group. Whew. All of that should help you to understand what is going on here: let spellrx = /^repeating_spellfocus_([^_]+?)_.*$/g; let attrs = findObjs({ type: 'attribute', characterid: character.id})     .filter(s => spellrx.test(s.get('name'))); let focusSpells = [...new Set(attrs.map(a => spellrx.exec(a.get('name'))[1]))]   // gives us an array of unique ids     .map(s => {return { id: s }; });                                             // start creating the object of component parts At this point we have an array of objects, and each object has one property (id). We don't have the button label (lbl) because we didn't have the naming attribute. In fact, at this point, if you don't have access to your sheet, you're going to need to make use of your log (or chat). (This is just development legwork... you own't have to include this in your runtime version.) You'll want to map an output of all the attributes that begin with 'repeating_spellfocus_' and then the id from ONE of your entries in focusSpells. That would look like this: findObjs({ type: 'attribute', characterid: character.id })     .filter(a => new Regexp(`^repeating_spellfocus_${focusSpells[0].id}_.*`).test(a.get('name')))     .forEach(a => log(a.get('name'))); That will output the name of every sub-attribute associated with a given element in the repeating section. The trick then is to figure out which to use. (I have a script that could do a lot of this dev legwork for you. I will be releasing either today or tomorrow!) Putting it all together Once you have the naming sub-attribute and action sub-attribute, you are in a position to pull out the pieces you want. This is when you would test whether the current value of the action attribute has no length (meaning you want to construct it from the GM sheet, instead). What we're aiming for is an array of objects (focusSpells) where each element is an object. Each object should look like: { id: RegexGroupExtraction, lbl: Naming Attribute Current,     cn: CharacterName or GMCharacter Name elem: @ (for attributes, meaning from THIS sheet) -- OR -- % (for abilities, meaning from the GM sheet) exec: ActionAttributeName (if from this sheet) -- OR -- NamingAttributeCurrent (that is, the lbl property, removing the spaces, if we can assume that it exists on the GM sheet) } If you have trouble bridging where I left the code, above, to this point, post back and I can try to connect the dots. Basically, in a map operation for each item in the array, you're going to test the current value of the action attribute (building its name using the ID you've already isolated and the action_suffix you'd append to the attribute name) to see if it's empty. If it is, the elem property gets the % value (HTML entity for %), the exec property gets the action attribute name, and the cn property gets filled with the GM Character Name. Otherwise (if the current value of the action element is NOT empty), elem gets @ (HTML entity for @), the exec property gets the lbl (which is the NAMING attribute's current value), but you have to remove the spaces, and the cn property gets the name of THIS character. Are you still with me? This gets a little weedy. When everything is in order, your output would be a map operation putting those pieces together: focusSpells = focusSpells.map(s => `[${s.lbl}](!&#13${elem}{${cn}|${exec}})`).join(''); As an example from my game (different system, different repeating sections, I know), the following text: [Sight Beyond](!
@{Heretic|repeating_powers_-M5sVh3oYbp20R7VZWwQ_use_power2_formula}) Produces a button to run the "use_power2_formula" sub-attribute of the element -M5sVh3oYbp20R7VZWwQ in the repeating section, 'powers' (the attribute appears on the character sheet for 'Heretic'  as "Sight Beyond"): If you're not already exhausted I know this is a lot, but if you wanted to sort the output, you'd do that before that last map operation. You can sort of array of objects by a property of those objects like this: .sort((a,b) => a.lbl > b.lbl ? 1 : -1); Two items fed into that callback, and you're comparing the lbl property of each. The callback is executing a ternary comparison (a fancy way of doing a single line 'if' construction). It says if the lbl property of a is greater than the lbl property of b (meaning that's how it would sort), return 1. Otherwise, return -1. That tells the sort() operation how to order the elements. I'll see if I can post my script that does some of these same sort of things, if you want to use it for the legwork and/or to compare.
1597343070
timmaugh
Pro
API Scripter
As for the ~, I just tried in game and typed  [Buttun Label](~@{selected|character_name}|repeating_spellfocus_$0_spellroll) into the chat and it output a button that rolled as intended. Is this maybe something in the API? OK, yes, I missed the @. I spend so much time in the API that I forget the simplest things about the chat input. =D But that would still leave you with the sorting problem... for which you could use Aaron's scriptlet to manage the numbering, if you wanted. You can pull some of the pieces out of my previous post to hybridize your own process if you want... plus the way I built it isn't beholden to any particular number of spells and/or cantrips. It will just build out as many buttons as it needs to. Post back if you have trouble!
This looks great. I'm sure I'll pick it apart and see what works what way. I'll start digging and let you know how it goes.  Thanks again!!
1597461364

Edited 1597462787
Ok, I think I've got a method to get the buttons populated and created by comparing array elements with .includes and then having the appropriate variables for cn and exec assigned.  But ... sigh ... I am getting a Syntax Error: Unexpected Token '{' I have been over this code for an hour and cannot find where I've added an extra curly bracket or forgot to close one.  Maybe some of my syntax for the push, includes or map commands are wrong and that is why it hates me? Never mind! You found an extra ) which was throwing things off. But now I am getting cn is not defined error even though I've defined it in the for loop, I thought? on("chat:message",function(msg){ if(msg.type=="api" && msg.content.indexOf("!createSpellbook")==0) { //Must have a token selected var selected = msg.selected; if (selected===undefined) { sendChat("API","Please select a character."); return; }; //selected token var token = getObj("graphic",selected[0]._id); var character = getObj("character",token.get("represents")); //Sheet with GM spell abilities var charGM = getObj("character","-MEAniRG_nMlLDMz8HIV"); //Focus Spells let spellrx = /^repeating_spellfocus_([^_]+?)_name$/g; let focusSpells = findObjs({ type: 'attribute', characterid: character.id}) .filter(s => spellrx.test(s.get('name'))) .map(s => { return { id: s.id, lbl: s.get('current') }; }) // start creating the object of component parts //Test code for mapping var isOnGmSheet = findObjs({_type: "ability",_characterid: charGM.id}) //Find the Abilites on the GMSpells sheet .map(x => x.name); //Create an array of just the names let focusNames = focusSpells.map(s => s.lbl); //Make an array of just names from focusSpell array above let focusOutput = []; //Create output array //Populate output array by testing if the i-th element of focusNames is present in isOnGmSheet //Use this test to create character name (cn) and executable (exec) variables for (i=0;i<focusNames.length;i++){ if (isOnGmSheet.includes(focusNames[i])){ let cn = 'GMSpells' let exec = focusNames[i].replace(/\s+/g, '') } else { let cn = character.get("name").toLowerCase() let exec = 'repeating_spellfocus_' + focusSpells[i].id + '_spellroll' }; focusOutput.push({ id: focusSpells[i].id, lbl: focusSpells[i].lbl, name: `${cn}`, command: `${exec}` }); }; //map the new focusOutput array into the needed buttons based on above for loop focusOutput = focusOutput.map(s => `[${s.lbl}](!&#13$${s.name}|${s.command})`).join(''); sendChat("Create Spellbook","Here is the stuff." + focusOutput); }; });
1597462260
timmaugh
Pro
API Scripter
Look at line 35... You don't close your if() parentheses... if (isOnGmSheet.includes(focusNames[i])) { // ^ // Need this character
1597462433
timmaugh
Pro
API Scripter
Sometimes you get that kind of unexpected token not because of the character they reference, but because that character came before one that you should have had, or because you included a character that put the parsing/grouping out of order, if that makes sense. A good IDE can help locate these things. I use Visual Studio Community Edition (free), and it pinpointed this right away. ;-)
1597462772

Edited 1597462805
Yep, I kept looking and looking for that curly brace and didn't notice that close parenthesis.  Updated my code with a new error!
1597463141

Edited 1597467605
timmaugh
Pro
API Scripter
Yeah, so this is the thing about let/const...  var declarations have a function scope... anywhere you declare them they will be available in the function. They are "hoisted" to the top of the function to make them available even before you actually declare them (your script is read and compiled by the page -- or the R20 engine -- prior to the execution of any code, so the code knows what you've declared using var. let and const declarations are block scoped... meaning that they are only available in the block in which they are declared. They are also not hoisted, so they are only available after you declare them. Since your for(){} loop creates a block (see the braces?) any let/const declarations there are not available once the block closes. If you want it to be available outside of the loop, you have to declare it first, then use it in the loop.
1597467983

Edited 1597468067
Ah ha! That makes scope make sense.  So I decided that since I really only needed those variables in the loop, why bother to make the variables? I went ahead and just pushed different values based on the comparison instead.  //Populate output array by testing if the i-th element of focusNames is present in isOnGmSheet //Use this test to create character name (cn) and executable (exec) variables                 for (i=0;i<focusNames.length;i++){        if (isOnGmSheet.includes(focusNames[i])){         focusOutput.push({ id: focusSpells[i].id, lbl: focusSpells[i].lbl, name: '%{GMSpells', command: focusNames[i].replace(/\s+/g, '') + '}' });           } else {           focusOutput.push({ id: focusSpells[i].id, lbl: focusSpells[i].lbl, name: '%{' + character.get("name").toLowerCase(), command: 'repeating_spellfocus_$' + i + '_spellroll}' });         };      };              //map the new focusOutput array into the needed buttons based on above for loop focusOutput = focusOutput.map(s => `[${s.lbl}](!
${s.name}|${s.command})`).join(''); sendChat("Create Spellbook","Here is the stuff." + focusOutput); I think I've got it working!
1597468790
timmaugh
Pro
API Scripter
Excellent! Well done! Jazz hands all around!
Hahaha! So as a follow up if I wanted to filter another regex into the array, could just add it the filter command?  For example, I want to have the spell level included in the normal spells array and I want to sort first by spell level, then alphabetize within the spell level. That way I see all level 1 spells then all level 2 and so on. I've played with the syntax but it doesn't look like I've quite got it.  I've added another regex for the spell's current level but I am not sure how to pass the 2nd regex into the array as it is already getting the _name for 'current'.         let spellns = /^repeating_normalspells_([^_]+?)_name$/g;         let spelllvl = /^repeating_normalspells_([^_]+?)_current_level$/g;         let normalSpells = findObjs({ type: 'attribute', characterid: character.id})             .filter(s => spellns.test(s.get('name')) && spelllvl.test(s.get('name')))             .map(s => { return { id: s.id, lbl: s.get('current') }; })  
1597531721
timmaugh
Pro
API Scripter
You won't be able to test using that regex, because, as I think you already figured out, you are testing the name of the attribute, and the attribute is already matched using the name suffix. What you need to do is use the first regex to create the group one match, which is the ID of the repeating section (so, a spell in the list of spells), and once you have that and the name suffix of the current level attribute, all you need to do is feed that as a name into a findObj or getObj so that you can return the current value of that current level sub attribute. Since this is going to be different for each spell in your array, you would need to do that find operation during a map so that you can add the current level field to the object you are building of all of the values you have. I know it doesn't make sense to just read it, but as I'm on my phone right now I can't type up the example code of how to do it. I will try to do that later tonight to give you an example of what you're looking to do.
That actually makes sense. So I can create 2 separate arrays; one for name based from id and one for level based on id and zip those together?  Then after they are zipped I can sort it by level, by name?         let spellns = /^repeating_normalspells_([^_]+?)_name$/g;         let spelllvl = /^repeating_normalspells_([^_]+?)_current_level$/g;                  let normalSpells = findObjs({ type: 'attribute', characterid: character.id})             .filter(s => spellns.test(s.get('name')))             .map(s => { return { id: s.id, lbl: s.get('current') }; })                        let normalLevels = findObjs({ type: 'attribute', characterid: character.id})             .filter(s => spelllvl.test(s.get('name')))             .map(s => { return { level: s.get('current') }; })                        let normalArray = [];         normalArray = _.zip(normalSpells,normalLevels);
1597542899
timmaugh
Pro
API Scripter
I wouldn't create 2 arrays and then zip them together... I would stay with your first array and just add a field for the current_level value. That would look like this: let spellns = /^repeating_normalspells_([^_]+?)_name$/g;         let spelllvl = /^repeating_normalspells_([^_]+?)_current_level$/g; let elem;         let normalSpells = findObjs({ type: 'attribute', characterid: character.id})     .filter(s => spellns.test(s.get('name')))     .map(s => {         elem = spellns.exec(s.get('name'));         return { id: s.id, lbl: s.get('current'), lvl: getAttrByName(character.id, `repeating_normalspells_${elem[1]}_current_level`) };     }); Either way, you should end up with the level in the same record object in the normalSpells array. Then, you have to sort it. This can be a little odd... i'll write it as nested ternary operators, but you can certainly expand it will full 'if' statements. Let's say that you want to sort by lvl (ascending), then within that by lbl (ascending). This should do the trick: .sort((a, b) => parseFloat(a.lvl) > parseFloat(b.lvl) ? 1 : -1).sort((a,b) => {     return parseFloat(a.lvl) === parseFloat(b.lvl) ? a.lbl > b.lbl ? 1 : -1 : 0; });
That is a much better way to append elements in an array. Does this mean we don't need the 2nd regex at all then? I tried this and it did throw an error  TypeError: Cannot read property '1' of null So there is an issue with the elem variable? And while we are there, what does .exec do? Google was a little unclear there. 
1597586229
timmaugh
Pro
API Scripter
Exactly... No need for second regex. And my apologies for that error. The explanation for exec has to do with the reason why you're getting the error, too. A regex object has a method called exec() that basically exerts the regex over what you feed to it. Where .test() just looks for any match (if the string passes the regex test), .exec() applies the regex and returns an object. That's how you access capture groups (full match is group 0, first capture group is 1 -- for your spell regex, the first capture group is the ID of the repeating group). You can also have named capture groups (a regex feature), and those would be accessed on the returned object under obj.groups.yourGroupNameHere. Anyway, the reason you're getting the error is that the regex holds onto the index of its last match... Which means between iterations of the map operation it finds itself at the end of your string (an index other than 0) and therefore returns no match. No match means no object... Means an undefined return. I believe the line you need is: spellns.lastIndex =0; Put it right before your exec() in the map to reset it every time.
Tim,  Thank you so much for all your help these last few days. You have no idea how much I've learned.  I thought you'd like to see the final result of your work. :) The only issue is that some of the spells in the PF2 monster list are lower case which causes the compare to the GMSpells sheet to fail, but that is a failure of the sheet which is easily fixed by just capitalizing the names manually. :) on("chat:message",function(msg){ if(msg.type=="api" && msg.content.indexOf("!createSpellbook")==0) { //Must have a token selected var selected = msg.selected; if (selected===undefined) { sendChat("API","Please select a character."); return; }; //selected token var token = getObj("graphic",selected[0]._id); var character = getObj("character",token.get("represents")); //Sheet with GM spell abilities var charGM = getObj("character","-MEAniRG_nMlLDMz8HIV"); var isOnGmSheet = findObjs({_type: "ability",_characterid: charGM.id}) //Find the Abilites on the GMSpells sheet .map(x => x.get('name')); //Create an array of just the names //Innate Spells let spellin = /^repeating_spellinnate_([^_]+?)_name$/g; let innateSpells = findObjs({ type: 'attribute', characterid: character.id}) .filter(s => spellin.test(s.get('name'))) .map(s => { return { id: s.id, lbl: s.get('current') }; }) // start creating the object of component parts let innateNames = innateSpells.map(s => s.lbl.replace(/\s+/g, '')); //Make an array of just names from Spell array above let innateOutput = []; //Initialize output array //Populate output array by testing if the i-th element of innateNames is present in isOnGmSheet for (i=0;i<innateNames.length;i++){ if (isOnGmSheet.includes(focusNames[i])){ innateOutput.push({ id: innateSpells[i].id, lbl: innateSpells[i].lbl, name: '%{GMSpells', command: innateNames[i].replace(/\s+/g, '') + '}' }); } else { innateOutput.push({ id: innateSpells[i].id, lbl: innateSpells[i].lbl, name: '%{' + character.get("name").toLowerCase(), command: 'repeating_spellinnate_$' + i + '_spellroll}' }); }; }; //map the new innateOutput array into the needed buttons based on above for loop innateOutput = innateOutput.map(s => `[${s.lbl}](!
${s.name}|${s.command})`).join(''); //Focus Spells let spellrx = /^repeating_spellfocus_([^_]+?)_name$/g; let focusSpells = findObjs({ type: 'attribute', characterid: character.id}) .filter(s => spellrx.test(s.get('name'))) .map(s => { return { id: s.id, lbl: s.get('current') }; }) // start creating the object of component parts let focusNames = focusSpells.map(s => s.lbl.replace(/\s+/g, '')); //Make an array of just names from Spell array above let focusOutput = []; //Initialize output array //Populate output array by testing if the i-th element of focusNames is present in isOnGmSheet for (i=0;i<focusNames.length;i++){ if (isOnGmSheet.includes(focusNames[i])){ focusOutput.push({ id: focusSpells[i].id, lbl: focusSpells[i].lbl, name: '%{GMSpells', command: focusNames[i].replace(/\s+/g, '') + '}' }); } else { focusOutput.push({ id: focusSpells[i].id, lbl: focusSpells[i].lbl, name: '%{' + character.get("name").toLowerCase(), command: 'repeating_spellfocus_$' + i + '_spellroll}' }); }; }; //map the new focusOutput array into the needed buttons based on above for loop focusOutput = focusOutput.map(s => `[${s.lbl}](!
${s.name}|${s.command})`).join(''); //Cantrips let spellct = /^repeating_cantrip_([^_]+?)_name$/g; let cantripSpells = findObjs({ type: 'attribute', characterid: character.id}) .filter(s => spellct.test(s.get('name'))) .map(s => { return { id: s.id, lbl: s.get('current') }; }) // start creating the object of component parts let cantripNames = cantripSpells.map(s => s.lbl); //Make an array of just names from cantripSpell array above cantripNames.sort(); let cantripOutput = []; //Initialize output array //Populate output array by testing if the i-th element of focusNames is present in isOnGmSheet for (i=0;i<cantripNames.length;i++){ if (isOnGmSheet.includes(cantripNames[i])){ cantripOutput.push({ lbl: cantripNames[i], name: '%{GMSpells', command: cantripNames[i].replace(/\s+/g, '') + '}' }); } else { cantripOutput.push({ lbl: cantripNames[i], name: '%{' + character.get("name").toLowerCase(), command: 'repeating_cantrip_$' + i + '_spellroll}' }); }; }; //map the new cantripOutput array into the needed buttons based on above for loop cantripOutput = cantripOutput.map(s => `[${s.lbl}](!
${s.name}|${s.command})`).join(''); //Normal Spells let spellns = /^repeating_normalspells_([^_]+?)_name$/g; let elem; let normalSpells = findObjs({ type: 'attribute', characterid: character.id}) .filter(s => spellns.test(s.get('name'))) .map(s => { spellns.lastIndex =0; elem = spellns.exec(s.get('name')); return { id: s.id, lbl: s.get('current'), lvl: getAttrByName(character.id, `repeating_normalspells_${elem[1]}_current_level`) }; }); normalSpells.sort((a, b) => parseFloat(a.lvl) > parseFloat(b.lvl) ? 1 : -1).sort((a,b) => { return parseFloat(a.lvl) === parseFloat(b.lvl) ? a.lbl > b.lbl ? 1 : -1 : 0; }); let normalNames = normalSpells.map(s => s.lbl); //Make an array of just names from Spell array above let normalOutput = []; //Create output array //Populate output array by testing if the i-th element of focusNames is present in isOnGmSheet if there are normal spells for (i=0;i<normalNames.length;i++){ if (isOnGmSheet.includes(normalNames[i].replace(/\s+/g, ''))){ normalOutput.push({ lbl: normalNames[i], name: '%{GMSpells', command: normalNames[i].replace(/\s+/g, '') + '}' }); } else { normalOutput.push({ lbl: normalNames[i], name: '%{' + character.get("name").toLowerCase(), command: 'repeating_normalspells_$' + i + '_spellroll}' }); }; }; //map the new normalOutput array into the needed buttons based on above for loop normalOutput = normalOutput.map(s => `[${s.lbl}](!
${s.name}|${s.command})`).join(''); //format to Powercard var printNormal = ''; var printFocus = ''; var printInnate = ''; var printCantrip = ''; if (innateNames.length != 0) { printInnate = ' --Innate Spells|' + innateOutput }; if (focusNames.length != 0) { printFocus = ' --Focus Spells|' + focusOutput }; if (cantripNames.length != 0) { printCantrip = ' --Cantrips|' + cantripOutput }; if (normalNames.length != 0) { printNormal = ' --Normal Spells|' + normalOutput }; sendChat("Create Spellbook",`!power {{ --name|${character.get('name')} Spellcasting --whisper|GM --format|npcattack ${printInnate} ${printFocus} ${printCantrip} ${printNormal}`); }; });
1597610513
timmaugh
Pro
API Scripter
First of all, that's awesome. And... 'my work'? *pfffffff* Pretty sure that will be coded by the R20 forum engine into "universal disdain emoji". ... ...prrrrretty sure.